1. 程式人生 > >SpringSecurity許可權管理系統實戰—六、SpringSecurity整合JWT

SpringSecurity許可權管理系統實戰—六、SpringSecurity整合JWT

## 目錄 [SpringSecurity許可權管理系統實戰—一、專案簡介和開發環境準備](https://www.cnblogs.com/codermy/p/13516372.html) [SpringSecurity許可權管理系統實戰—二、日誌、介面文件等實現](https://www.cnblogs.com/codermy/p/13516369.html) [SpringSecurity許可權管理系統實戰—三、主要頁面及介面實現](https://www.cnblogs.com/codermy/p/13516379.html) [SpringSecurity許可權管理系統實戰—四、整合SpringSecurity(上)](https://www.cnblogs.com/codermy/p/13516385.html) [SpringSecurity許可權管理系統實戰—五、整合SpringSecurity(下)](https://www.cnblogs.com/codermy/p/13516388.html) [SpringSecurity許可權管理系統實戰—六、SpringSecurity整合jwt](https://www.cnblogs.com/codermy/p/13516394.html) [SpringSecurity許可權管理系統實戰—七、處理一些問題](https://blog.csdn.net/HYDCS/article/details/107765898) [SpringSecurity許可權管理系統實戰—八、AOP 記錄使用者日誌、異常日誌](https://blog.csdn.net/HYDCS/article/details/107965522) ## 前言 最近是真的懶,感覺我每個月都有那麼幾天什麼都不想幹。。 畫風一轉,前幾天的lpl忍界大戰是真的精彩,虛假的電競春晚:RNG vs IG 。真正的電競春晚 TES vs IG。TES自從阿水和kasra加入之後,狀態直接起飛,在我看來TES將是s10奪冠熱門之一。不過這一次木葉村戰勝了曉組織。 本以為會打滿三局,沒想到ig直接2:0帶走。rookie線上壓制了新皇knight,確實永遠可以相信宋義進,或許是因為‍小鈺採訪吧。 這兩把我最沒想到的是kasra被寧王壓著打,幾乎沒有節奏,寶藍在哪都是阿水的噩夢。這波啊,這波是盜版打贏了正版,puff小小的證明了自己。 最後還是希望lpl的飯圈粉少一點,peace ![在這裡插入圖片描述](https://img-blog.csdnimg.cn/20200801163556171.jpg#pic_center) 進入正題 ## 一、無狀態登入 - 有狀態登入 我們知道在原始的專案中我們是通過session和cookie來實現使用者的識別認證。但是這樣做無疑會增加伺服器的壓力,服務的儲存了大量的資料。如果業務需要擴充套件,搭建了叢集的話,還需要將session共享。 - 無狀態登入 而什麼是無狀態登入呢,簡而言之,就是服務的不需要再儲存任何的使用者資訊,而是使用者自己攜帶者資訊去訪問服務端,服務端通過這些資訊來識別客戶端身份。這樣一來,有狀態登入的缺點都被解決了,但是這同樣也會帶來新問題。比如token資訊無法在服務端登出,必須要等其自己過期,佔用更多的空間(意味著需要更多頻寬),修改密碼後原本的token在沒過期時仍然可用訪問系統等。 ## 二、JWT介紹 ### 1、什麼是jwt JWT是 Json Web Token 的縮寫。它是基於 RFC 7519 標準定義的一種可以安全傳輸的 小巧 和 自包含 的JSON物件。由於資料是使用數字簽名的,所以是可信任的和安全的。JWT可以使用HMAC演算法對secret進行加密或者使用RSA的公鑰私鑰對來進行簽名。 我們來看一下jwt長什麼樣 ``` eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJjb2Rlcm15IiwiYXV0aG9yaXRpZXMiOiJhZG1pbiIsImV4cCI6MTU5NjA4MDM5OX0.rfDtzMus50uAFnqMw1tm3c_ZYbmUNkIRqMkeJ0510PAH-RCUWtZkfNPTDYAGVVfDU6jmdEkGyNYvGy3UrNq5pA ``` JSON Web 令牌以緊湊的形式由三個部分組成,由點分隔,它們包括: - 頭部 - 負載 - 簽名 #### 頭部(Header) jwt的頭部承載兩部分資訊: - 宣告型別,這裡是jwt - 宣告加密的演算法 通常直接使用 HMAC SHA256 像這樣 ```json { 'typ': 'JWT', 'alg': 'HS256' } ``` #### 載荷(Payload) 這個部分用來承載要傳遞的資料,他的預設欄位有 - iss:發行人 - exp:到期時間 - sub:主題 - aud:使用者 - nbf:在此之前不可用 - iat:釋出時間 - jti:JWT ID用於標識該JWT 除以上預設欄位外,我們還可以自定義私有欄位,例如 ```json { "sub": "1234567890", "name": "John Doe", "admin": true } ``` #### 簽名(Signature) Signature 部分是對前兩部分的簽名,防止資料篡改。 ### 2、JWT工作流程 - 使用者發起登入請求 - 服務端驗證身份,將使用者資訊,標識等資訊打包成jwt token返回給客戶端 - 使用者拿到token,攜帶token傳送請求給服務端 - 服務的驗證token是否可用,可用便根據其y業務邏輯返回相應結果。 ### 3、簡單實現 首先我們在maven中引入以下依賴 ```xml io.jsonwebtoken jjwt 0.9.1 ``` 新建JwtTest來測試一下 ```java /** * @author codermy * @createTime 2020/7/30 */ public class JwtTest { public static void main(String[] args) { String token = Jwts.builder() //使用者名稱 .setSubject("codermy") //自定義屬性 放入使用者擁有請求許可權 .claim("authorities","admin") // 設定失效時間為1分鐘 .setExpiration(new Date(System.currentTimeMillis()+1000*60)) // 簽名演算法和金鑰 .signWith(SignatureAlgorithm.HS512, "java") .compact(); System.out.println(token); } ``` 輸出 ```bash eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJjb2Rlcm15IiwiYXV0aG9yaXRpZXMiOiJhZG1pbiIsImV4cCI6MTU5NjA4MDM5OX0.rfDtzMus50uAFnqMw1tm3c_ZYbmUNkIRqMkeJ0510PAH-RCUWtZkfNPTDYAGVVfDU6jmdEkGyNYvGy3UrNq5pA ``` 我們再來解析 ```java //解析token Claims claims = Jwts.parser() .setSigningKey("java") .parseClaimsJws(token) .getBody(); System.out.println(claims); //獲取使用者名稱 String username = claims.getSubject(); System.out.println("username:"+username); //獲取許可權 String authority = claims.get("authorities").toString(); System.out.println("許可權:"+authority); System.out.println("到期時間:" + claims.getExpiration()); ``` 輸出 ```bash {sub=codermy, authorities=admin, exp=1596082316} username:codermy 許可權:admin 到期時間:Thu Jul 30 12:11:56 CST 2020 ``` ## 三、整合JWT ### 後端實現 其實jwt本身很好理解,無非就就是一把鑰匙,可用開啟對應的鎖,這不過這把鑰匙稍微特殊點,它還帶了主人的一些資訊。難理解的是要將它符合業務邏輯的整合進框架中。我自己就被繞了好久才明白。 我這裡寫了一個Jwt的工具類,用於生成和解析jwt ```java /** * @author codermy * @createTime 2020/7/23 */ @Component public class JwtUtils { private static final String CLAIM_KEY_USERNAME = "sub"; private static final String CLAIM_KEY_CREATED = "created"; @Value("${jwt.secret}") private String secret; @Value("${jwt.expiration}") private Long expiration; // 建立token public String generateToken(String username) { return Jwts.builder() .signWith(SignatureAlgorithm.HS512, secret) .setSubject(username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expiration * 1000)) .compact(); } // 從token中獲取使用者名稱 public String getUserNameFromToken(String token){ return getTokenBody(token).getSubject(); } // 是否已過期 public boolean isExpiration(String token){ return getTokenBody(token).getExpiration().before(new Date()); } private Claims getTokenBody(String token){ return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); } } ``` 然後我們可以將jwt的一些資訊寫在yml中,使得可以靈活的配置。application.yml中新增如下配置 ```yml jwt: tokenHeader: Authorization #JWT儲存的請求頭 secret: my-springsecurity-plus #JWT加解密使用的金鑰 expiration: 604800 #JWT的超期限時間(60*60*24*7) tokenHead: 'Bearer ' #JWT負載中拿到開頭,空格別忘了 ``` 我們照著jwt的工作流程來,首先是登入成功後客戶端會返回一個jwt token 所以我們首先自定義一個MyAuthenticationSuccessHandler繼承AuthenticationSuccessHandler,這是登入成功後的處理器 ```java /** * @author codermy * @createTime 2020/8/1 * 登入成功 */ @Component @Slf4j public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Autowired private JwtUtils jwtUtils; @Value("${jwt.tokenHeader}") private String tokenHeader; @Value("${jwt.tokenHead}") private String tokenHead; @Override public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException { JwtUserDto userDetails = (JwtUserDto)authentication.getPrincipal();//拿到登入使用者資訊 String jwtToken = jwtUtils.generateToken(userDetails.getUsername());//生成token Result result = Result.ok().message("登入成功").jwt(jwtToken); System.out.println(JSON.toJSONString(result));//用於測試 httpServletResponse.setCharacterEncoding("utf-8");//修改編碼格式 httpServletResponse.setContentType("application/json"); httpServletResponse.getWriter().write(JSON.toJSONString(result));//輸出結果 httpServletResponse.sendRedirect("/api/admin");//重定向到api/admin頁面。我這裡路由名取的不是很好 } } ``` 然後我們再寫一個jwt的攔截器,讓每個請求都需要驗證jwt token ```java /** * @author codermy * @createTime 2020/7/30 */ @Component @Slf4j public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired private UserDetailsServiceImpl userDetailsService; @Autowired private JwtUtils jwtUtils; @Value("${jwt.tokenHeader}") private String tokenHeader; @Value("${jwt.tokenHead}") private String tokenHead; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String authHeader = request.getHeader(this.tokenHeader);//拿到requset中的head if (authHeader != null && authHeader.startsWith(this.tokenHead)) { String authToken = authHeader.substring(this.tokenHead.length());// The part after "Bearer " String username = jwtUtils.getUserNameFromToken(authToken);//解析token獲取使用者名稱 log.info("checking username:{}", username); if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = this.userDetailsService.loadUserByUsername(username); if (userDetails != null) {//判斷是否存在這個給使用者 UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); log.info("authenticated user:{}", username); SecurityContextHolder.getContext().setAuthentication(authentication); } } } chain.doFilter(request, response); } } ``` 這裡為了之後結果更直觀,自定義一個AuthenticationEntryPoint,用於在未登入是訪問介面返回json而不是login.html ```java /** * @author codermy * @createTime 2020/8/1 * 當未登入或者token失效訪問介面時,自定義的返回結果 */ @Component public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { response.setCharacterEncoding("UTF-8");//設定編碼格式 response.setContentType("application/json"); response.getWriter().println(JSON.toJSONString(Result.error().message("尚未登入,或者登入過期 " + authException.getMessage()))); response.getWriter().flush(); } } ``` 將上述方法加入到SpringSecurityConfig中 ```java /** * @author codermy * @createTime 2020/7/15 */ @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) public class SpringSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private UserDetailsServiceImpl userDetailsService; @Autowired private VerifyCodeFilter verifyCodeFilter; @Autowired MyAuthenticationSuccessHandler authenticationSuccessHandler; @Autowired private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; @Autowired private RestAuthenticationEntryPoint restAuthenticationEntryPoint; @Autowired private RestfulAccessDeniedHandler accessDeniedHandler; @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); } /** * 身份認證介面 */ @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()); } @Override public void configure(WebSecurity web) throws Exception { web.ignoring() .antMatchers(HttpMethod.GET, "/swagger-resources/**", "/PearAdmin/**", "/**/*.html", "/**/*.css", "/**/*.js", "/swagger-ui.html", "/webjars/**", "/v2/**");//放行靜態資源 } /** * anyRequest | 匹配所有請求路徑 * access | SpringEl表示式結果為true時可以訪問 * anonymous | 匿名可以訪問 * denyAll | 使用者不能訪問 * fullyAuthenticated | 使用者完全認證可以訪問(非remember-me下自動登入) * hasAnyAuthority | 如果有引數,引數表示許可權,則其中任何一個許可權可以訪問 * hasAnyRole | 如果有引數,引數表示角色,則其中任何一個角色可以訪問 * hasAuthority | 如果有引數,引數表示許可權,則其許可權可以訪問 * hasIpAddress | 如果有引數,引數表示IP地址,如果使用者IP和引數匹配,則可以訪問 * hasRole | 如果有引數,引數表示角色,則其角色可以訪問 * permitAll | 使用者可以任意訪問 * rememberMe | 允許通過remember-me登入的使用者訪問 * authenticated | 使用者登入後可訪問 */ @Override protected void configure(HttpSecurity http) throws Exception { http.addFilterBefore(verifyCodeFilter, UsernamePasswordAuthenticationFilter.class); http.csrf().disable()//關閉csrf .sessionManagement()// 基於token,所以不需要session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .httpBasic().authenticationEntryPoint(restAuthenticationEntryPoint)//未登陸時返回 JSON 格式的資料給前端,否則是html .and() .authorizeRequests() .antMatchers("/captcha").permitAll()//任何人都能訪問這個請求 .anyRequest().authenticated() .and() .formLogin() .loginPage("/login.html")//登入頁面 不設限訪問 .loginProcessingUrl("/login")//攔截的請求 .successHandler(authenticationSuccessHandler) // 登入成功處理器 .permitAll() // 防止iframe 造成跨域 .and() .headers() .frameOptions() .disable() .and(); // 禁用快取 http.headers().cacheControl(); // 新增JWT攔截器 http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); } ``` 我這裡直接貼了完整的程式碼,因為有新增也有刪除,不是很好描述,大家對比著之前的來看,都添加了註釋。 現在我們重啟專案,用**admin賬號**來登入。登入成功後發現頁面並沒有跳轉到我們想去的頁面,但是控制檯打印出了我們想要的jwt資訊 ```bash {"code":200,"data":[],"jwt":"eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImlhdCI6MTU5NjI1OTgyOCwiZXhwIjoxNTk2ODY0NjI4fQ.Khn5t6WjOsuG6R2if1Q_gAeNq-zTamIAO32b1UVc6L8-6_IAHMaCeWr-v7H2-7Hob0SSmmK23dv71_da-YK8hw","msg":"登入成功","success":true} ``` 這是為什麼呢? 著很好理解,因為我們的jwt攔截器已經起了作用,而我們原本的前端頁面是沒有把jwt token新增在header上的,所以認為沒有登入,重定向到了登入頁面。 但是我們現在可以藉助postman來測試,postman是一個測試api的工具,大家可以自行百度,這裡不做過多介紹。 在我們未攜帶jwt token資訊時,訪問http://localhost:8080/api/menu介面,就會報如下錯誤 ![在這裡插入圖片描述](https://img-blog.csdnimg.cn/20200801163702778.PNG?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0hZRENT,size_16,color_FFFFFF,t_70#pic_center) 我們在header中新增上,之前登入成功控制檯列印的token資訊(因為我們添加了圖片驗證碼,所以登入不是很方便用postman,我們可以在瀏覽器中登入或者先把驗證碼的攔截器去除) ![在這裡插入圖片描述](https://img-blog.csdnimg.cn/20200801163738309.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0hZRENT,size_16,color_FFFFFF,t_70#pic_center) 加上了token資訊之後再去訪問http://localhost:8080/api/menu介面,發現已經可以正常訪問了 ![在這裡插入圖片描述](https://img-blog.csdnimg.cn/2020080116375558.PNG?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0hZRENT,size_16,color_FFFFFF,t_70#pic_center) 我們再嘗試用test使用者登入後獲取到jwt token訪問該介面,會報如下錯誤 ![在這裡插入圖片描述](https://img-blog.csdnimg.cn/20200801163922471.PNG?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0hZRENT,size_16,color_FFFFFF,t_70#pic_center) ### 修改Swagger配置 直接貼程式碼 ```java /** * @author codermy * @createTime 2020/7/10 */ @Configuration @EnableSwagger2 public class SwaggerConfig { @Value("${jwt.tokenHeader}") private String tokenHeader; @Value("${jwt.tokenHead}") private String tokenHead; @Bean public Docket createRestApi() { ParameterBuilder ticketPar = new ParameterBuilder(); List pars = new ArrayList<>(); ticketPar.name(tokenHeader).description("token") .modelRef(new ModelRef("string")) .parameterType("header") .defaultValue(tokenHead + " ") .required(true) .build(); pars.add(ticketPar.build()); return new Docket(DocumentationType.SWAGGER_2) .apiInfo(webApiInfo()) .select() .apis(RequestHandlerSelectors.basePackage("com.codermy.myspringsecurityplus.controller")) .paths(PathSelectors.any()) .paths(Predicates.not(PathSelectors.regex("/error.*"))) .build() .globalOperationParameters(pars); } /** * 該套 API 說明,包含作者、簡介、版本、等資訊 * @return */ private ApiInfo webApiInfo(){ return new ApiInfoBuilder() .title("my-springsecurity-plus-API文件") .description("本文件描述了my-springsecurity-plus介面定義") .version("1.0.5") .build(); } } ``` 現在再swagger中就可以新增token測試了 ![在這裡插入圖片描述](https://img-blog.csdnimg.cn/20200801163813187.PNG?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0hZRENT,size_16,color_FFFFFF,t_70#pic_center) ### 前端適配 那麼我們現在已經簡單的實現了jwt的無狀態登入功能,需要做的就是讓前端的請求都帶上jwt token。 。。。研究了半天沒弄懂,所以暫時先擱置,下一章解決它。有知道怎麼設定請求頭的小夥伴也可以留言告訴我 所以本章結束的程式碼是不能正常在瀏覽器執行的,但是可以在postman和swagger中測試(如果想執行,在SpringSecurityConfig中新增上.rememberMe()即可) **在[gitee](https://gitee.com/witmy/my-springsecurity-plus)和[github](https://github.com/witmy/my-springsecurity-plus)中可獲取原始碼,與本系列文章同步更新**