任務案例分析
在我們傳統的B\S應用開發方式中,都是使用session進行狀態管理的,比如說:保存登錄、用戶、權限等狀態信息。這種方式的原理大致如下:
上面就是一種有狀態服務。
當然,這整個過程中,cookies和sessionid都是服務端和瀏覽器端自動維護的。所以從編碼層面是感知不到的,程序員只能感知到session數據的存取。但是,這種方式在有些情況下,是不適用的。比如:非瀏覽器的客戶端、手機移動端等等,因為他們沒有瀏覽器自動維護cookies的功能。比如:集群應用,同一個應用部署甲、乙、丙三個主機上,實現負載均衡應用,其中一個掛掉了其他的還能負載工作。要知道session是保存在服務器內存里面的,三個主機一定是不同的內存。那么你登錄的時候訪問甲,而獲取接口數據的時候訪問乙,就無法保證session的唯一性和共享性。當然以上的這些情況我們都有方案(如redis共享session等),可以繼續使用session來保存狀態。但是還有另外一種做法就是不用session了,即開發一個無狀態的應用,JWT就是這樣的一種方案。
那上面說的無狀態是什么?
微服務集群中的每個服務,對外提供的都使用RESTful風格的接口。而RESTful風格的一個最重要的規范就是:服務的無狀態性,即:服務端不保存任何客戶端請求者信息,客戶端的每次請求必須具備自描述信息,通過這些信息識別客戶端身份那么這種無狀態性有哪些好處呢?
客戶端請求不依賴服務端的信息,多次請求不需要必須訪問到同一臺服務器服務端的集群和狀態對客戶端透明服務端可以任意的遷移和伸縮(可以方便的進行集群化部署)減小服務端存儲壓力。
如何實現無狀態登錄的流程:
首先客戶端發送賬戶名/密碼到服務端進行認證認證通過后,服務端將用戶信息加密并且編碼成一個token,返回給客戶端以后客戶端每次發送請求,都需要攜帶認證的token服務端對客戶端發送來的token進行解密,判斷是否有效,并且獲取用戶登錄信息
在前后端分離的項目中,登錄策略也有不少,不過 JWT 算是目前比較流行的一種解決方案了,本文就和大家來分享一下如何將 Spring Security 和 JWT 結合在一起使用,進而實現前后端分離時的登錄解決方案。
什么是JWT?
JWT,全稱是Json Web Token, 是一種JSON風格的輕量級的授權和身份認證規范,可實現無狀態、分布式的Web應用授權。
JWT是一個加密后的接口訪問密碼,并且該密碼里面包含用戶名信息。這樣既可以知道你是誰?又可以知道你是否可以訪問應用?
https://jwt.io/
http://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html
JWT交互流程流程圖:
認證代碼實現:
1.在pom.xml中引入jwt工具包
<!--Token生成與解析-->
<dependency>
? ? <groupId>io.jsonwebtoken</groupId>
? ? <artifactId>jjwt</artifactId>
? ? <version>0.9.0</version>
</dependency>
2.在application.yml中加入如下自定義一些關于JWT的配置
# token配置
token:
? # 令牌自定義標識
? header: Authorization
? # 令牌秘鑰
? #? secret: abcdefghijklmnopqrstuvwxyz
? secret: (EMOK:)_$^11244^%$_(IS:)_@@++--(COOL:)_++++_.sds_(GUY:)
? # 令牌有效期(默認30分鐘)
? expireTime: 3600000
其中header是攜帶JWT令牌的HTTP的Header的名稱。雖然我這里叫做Authorization,但是在實際生產中可讀性越差越安全。secret是用來為JWT基礎信息加密和解密的密鑰。雖然我在這里在配置文件寫死了,但是在實際生產中通常不直接寫在配置文件里面。而是通過應用的啟動參數傳遞,并且需要定期修改。expiration是JWT令牌的有效時間。
3.編寫JwtLoginController類
@RestController
public class JwtLoginController {
? ? @Autowired
? ? JwtAuthService jwtAuthService;
? ? /**
? ? * 登錄方法
? ? *
? ? * @param username 用戶名
? ? * @param password 密碼
? ? * @return 結果
? ? */
? ? @PostMapping({"/login", "/"})
? ? public RestResult login(String username, String password) {
? ? ? ? RestResult result = RestResult.success();
? ? ? ? String token = jwtAuthService.login(username, password);
? ? ? ? result.put("token", token);
? ? ? ? return result;
? ? }
}
4.編寫JwtAuthService類
@Service
public class JwtAuthService {
? ? @Resource
? ? private AuthenticationManager authenticationManager;
? ? @Resource
? ? private jwtTokenUtil jwtTokenUtil;
? ? /**
? ? * 登錄認證換取JWT令牌
? ? *
? ? * @param username 用戶名
? ? * @param password 密碼
? ? * @return 結果
? ? */
? ? public String login(String username, String password)? {
? ? ? ? // 用戶驗證
? ? ? ? Authentication authentication = null;
? ? ? ? try {
? ? ? ? ? ? // 該方法會去調用UserDetailsServiceImpl.loadUserByUsername
? ? ? ? ? ? authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
? ? ? ? } catch (Exception e) {
? ? ? ? ? ? ? ? throw new RuntimeException("用戶名密碼錯誤");
? ? ? ? }
? ? ? ? Users loginUser = (Users) authentication.getPrincipal();
? ? ? ? // 生成token
? ? ? ? return jwtTokenUtil.generateToken(loginUser);
? ? }
}
5.編寫JWT令牌生成、刷新的工具類
@Data
@Component
public class jwtTokenUtil {
? ? @Value("${token.secret}")
? ? private String secret;
? ? @Value("${token.expireTime}")
? ? private Long expiration;
? ? @Value("${token.header}")
? ? private String header;
? ? /**
? ? * 生成token令牌
? ? *
? ? * @param userDetails 用戶
? ? * @return 令token牌
? ? */
? ? public String generateToken(UserDetails userDetails) {
? ? ? ? Map<String, Object> claims = new HashMap<>(2);
? ? ? ? claims.put("sub", userDetails.getUsername());
? ? ? ? claims.put("created", new Date());
? ? ? ? return generateToken(claims);
? ? }
? ? /**
? ? * 從令牌中獲取用戶名
? ? *
? ? * @param token 令牌
? ? * @return 用戶名
? ? */
? ? public String getUsernameFromToken(String token) {
? ? ? ? String username;
? ? ? ? try {
? ? ? ? ? ? Claims claims = getClaimsFromToken(token);
? ? ? ? ? ? username = claims.getSubject();
? ? ? ? } catch (Exception e) {
? ? ? ? ? ? username = null;
? ? ? ? }
? ? ? ? return username;
? ? }
? ? /**
? ? * 判斷令牌是否過期
? ? *
? ? * @param token 令牌
? ? * @return 是否過期
? ? */
? ? public Boolean isTokenExpired(String token) {
? ? ? ? try {
? ? ? ? ? ? Claims claims = getClaimsFromToken(token);
? ? ? ? ? ? Date expiration = claims.getExpiration();
? ? ? ? ? ? return expiration.before(new Date());
? ? ? ? } catch (Exception e) {
? ? ? ? ? ? return false;
? ? ? ? }
? ? }
? ? /**
? ? * 刷新令牌
? ? *
? ? * @param token 原令牌
? ? * @return 新令牌
? ? */
? ? public String refreshToken(String token) {
? ? ? ? String refreshedToken;
? ? ? ? try {
? ? ? ? ? ? Claims claims = getClaimsFromToken(token);
? ? ? ? ? ? claims.put("created", new Date());
? ? ? ? ? ? refreshedToken = generateToken(claims);
? ? ? ? } catch (Exception e) {
? ? ? ? ? ? refreshedToken = null;
? ? ? ? }
? ? ? ? return refreshedToken;
? ? }
? ? /**
? ? * 驗證令牌
? ? *
? ? * @param token? ? ? 令牌
? ? * @param userDetails 用戶
? ? * @return 是否有效
? ? */
? ? public Boolean validateToken(String token, UserDetails userDetails) {
? ? ? ? String username = getUsernameFromToken(token);
? ? ? ? return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
? ? }
? ? /**
? ? * 從claims生成令牌,如果看不懂就看誰調用它
? ? *
? ? * @param claims 數據聲明
? ? * @return 令牌
? ? */
? ? private String generateToken(Map<String, Object> claims) {
? ? ? ? Date expirationDate = new Date(System.currentTimeMillis() + expiration);
? ? ? ? return Jwts.builder().setClaims(claims)
? ? ? ? ? ? ? ? .setExpiration(expirationDate)
? ? ? ? ? ? ? ? .signWith(SignatureAlgorithm.HS512, secret)
? ? ? ? ? ? ? ? .compact();
? ? }
? ? /**
? ? * 從令牌中獲取數據聲明,如果看不懂就看誰調用它
? ? *
? ? * @param token 令牌
? ? * @return 數據聲明
? ? */
? ? private Claims getClaimsFromToken(String token) {
? ? ? ? Claims claims;
? ? ? ? try {
? ? ? ? ? ? claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
? ? ? ? } catch (Exception e) {
? ? ? ? ? ? claims = null;
? ? ? ? }
? ? ? ? return claims;
? ? }
}
鑒權代碼實現:
6.編寫JwtAuthTokenFilter,完成鑒權
@Component
public class JwtAuthTokenFilter extends OncePerRequestFilter {
? ? @Resource
? ? private jwtTokenUtil jwtTokenUtil;
? ? @Resource
? ? UserDetailsService myUserDetailsService;
? ? @Override
? ? protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
? ? ? ? String jwtToken = request.getHeader(jwtTokenUtil.getHeader());
? ? ? ? if(!StringUtils.isEmpty(jwtToken)){
? ? ? ? ? ? String username = jwtTokenUtil.getUsernameFromToken(jwtToken);
? ? ? ? ? ? //如果可以正確的從JWT中提取用戶信息,并且該用戶未被授權
? ? ? ? ? ? if(username != null && SecurityContextHolder.getContext().getAuthentication() == null){
? ? ? ? ? ? ? ? UserDetails userDetails = myUserDetailsService.loadUserByUsername(username);
? ? ? ? ? ? ? ? if(jwtTokenUtil.validateToken(jwtToken,userDetails)){
? ? ? ? ? ? ? ? ? ? //給使用該JWT令牌的用戶進行授權
? ? ? ? ? ? ? ? ? ? UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails,null,userDetails.getAuthorities());
? ? ? ? ? ? ? ? ? ? //交給spring security管理,在之后的過濾器中不會再被攔截進行二次授權了
? ? ? ? ? ? ? ? ? ? SecurityContextHolder.getContext().setAuthentication(authenticationToken);
? ? ? ? ? ? ? ? }
? ? ? ? ? ? }
? ? ? ? }
? ? ? ? filterChain.doFilter(request,response);
? ? }
}
7.修改SecurityConfig.configure方法,配置無狀態以及JWT filter過濾器
@Override
? ? protected void configure(HttpSecurity httpSecurity) throws Exception {
? ? ? ? httpSecurity
? ? ? ? ? ? ? ? // 認證失敗處理類
? ? ? ? ? ? ? ? //.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
? ? ? ? ? ? ? ? // 基于token,所以不需要session
? ? ? ? ? ? ? ? .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
? ? ? ? ? ? ? ? //配置權限
? ? ? ? ? ? ? ? .authorizeRequests()
? ? ? ? ? ? ? ? // 對于登錄login 驗證碼captchaImage 允許匿名訪問
? ? ? ? ? ? ? ? .antMatchers("/login").anonymous()
? ? ? ? ? ? ? ? .antMatchers(
? ? ? ? ? ? ? ? ? ? ? ? HttpMethod.GET,
? ? ? ? ? ? ? ? ? ? ? ? "/*.html",
? ? ? ? ? ? ? ? ? ? ? ? "/**/*.html",
? ? ? ? ? ? ? ? ? ? ? ? "/**/*.css",
? ? ? ? ? ? ? ? ? ? ? ? "/**/*.js"
? ? ? ? ? ? ? ? ).permitAll()
? ? ? ? ? ? ? ? .antMatchers("/order") //需要對外暴露的資源路徑
? ? ? ? ? ? ? ? .hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")? //user角色和admin角色都可以訪問
? ? ? ? ? ? ? ? .antMatchers("/system/user", "/system/role", "/system/menu")
? ? ? ? ? ? ? ? .hasAnyRole("ADMIN")? //admin角色可以訪問
? ? ? ? ? ? ? ? // 除上面外的所有請求全部需要鑒權認證
? ? ? ? ? ? ? ? .anyRequest().authenticated().and()//authenticated()要求在執行該請求時,必須已經登錄了應用
? ? ? ? ? ? ? ? // CRSF禁用,因為不使用session
? ? ? ? ? ? ? ? .csrf().disable() ;//禁用跨站csrf攻擊防御,否則無法登陸成功
? ? ? ? //登出功能
? ? ? ? httpSecurity.logout().logoutUrl("/logout");
? ? ? ? // 添加JWT filter
? ? ? ? httpSecurity.addFilterBefore(jwtAuthTokenFilter, UsernamePasswordAuthenticationFilter.class);
? ? }
8.使用postman進行測試
下面我們訪問一個我們定義的接口“/system/user”,結果是禁止訪問。
輸入username:alex,password:123456,點擊send按鈕:
當我們將上一步返回的token,傳遞到header中,就能正常響應hello的接口結果。