SpringSecurity權限管理系統實戰—1、項目簡介和開發環境準備
SpringSecurity權限管理系統實戰—2、日誌、接口文檔等實現
SpringSecurity權限管理系統實戰—3、主要頁面及接口實現
SpringSecurity權限管理系統實戰—4、整合SpringSecurity(上)
SpringSecurity權限管理系統實戰—5、整合SpringSecurity(下)
SpringSecurity權限管理系統實戰—6、SpringSecurity整合jwt
SpringSecurity權限管理系統實戰—7、處理一些問題
SpringSecurity權限管理系統實戰—8、AOP 記錄用戶日誌、異常日誌css
最近是真的懶,感受我每月都有那麼幾天什麼都不想幹。。html
畫風一轉,前幾天的lpl忍界大戰是真的精彩,虛假的電競春晚:RNG vs IG 。真正的電競春晚 TES vs IG。TES自從阿水和kasra加入以後,狀態直接起飛,在我看來TES將是s10奪冠熱門之一。不過這一次木葉村打敗了曉組織。前端
本覺得會打滿三局,沒想到ig直接2:0帶走。rookie線上壓制了新皇knight,確實永遠能夠相信宋義進,或許是由於小鈺採訪吧。java
這兩把我最沒想到的是kasra被寧王壓着打,幾乎沒有節奏,寶藍在哪都是阿水的噩夢。這波啊,這波是盜版打贏了正版,puff小小的證實了本身。git
最後仍是但願lpl的飯圈粉少一點,peacegithub
進入正題web
有狀態登陸算法
咱們知道在原始的項目中咱們是經過session和cookie來實現用戶的識別認證。可是這樣作無疑會增長服務器的壓力,服務的保存了大量的數據。若是業務須要擴展,搭建了集羣的話,還須要將session共享。spring
無狀態登陸json
而什麼是無狀態登陸呢,簡而言之,就是服務的不須要再保存任何的用戶信息,而是用戶本身攜帶者信息去訪問服務端,服務端經過這些信息來識別客戶端身份。這樣一來,有狀態登陸的缺點都被解決了,可是這一樣也會帶來新問題。好比token信息沒法在服務端註銷,必需要等其本身過時,佔用更多的空間(意味着須要更多帶寬),修改密碼後本來的token在沒過時時仍然可用訪問系統等。
JWT是 Json Web Token 的縮寫。它是基於 RFC 7519 標準定義的一種能夠安全傳輸的 小巧 和 自包含 的JSON對象。因爲數據是使用數字簽名的,因此是可信任的和安全的。JWT可使用HMAC算法對secret進行加密或者使用RSA的公鑰私鑰對來進行簽名。
咱們來看一下jwt長什麼樣
eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJjb2Rlcm15IiwiYXV0aG9yaXRpZXMiOiJhZG1pbiIsImV4cCI6MTU5NjA4MDM5OX0.rfDtzMus50uAFnqMw1tm3c_ZYbmUNkIRqMkeJ0510PAH-RCUWtZkfNPTDYAGVVfDU6jmdEkGyNYvGy3UrNq5pA
JSON Web 令牌以緊湊的形式由三個部分組成,由點分隔,它們包括:
jwt的頭部承載兩部分信息:
像這樣
{ 'typ': 'JWT', 'alg': 'HS256' }
這個部分用來承載要傳遞的數據,他的默認字段有
除以上默認字段外,咱們還能夠自定義私有字段,例如
{ "sub": "1234567890", "name": "John Doe", "admin": true }
Signature 部分是對前兩部分的簽名,防止數據篡改。
首先咱們在maven中引入如下依賴
<!--jjwt--> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>
新建JwtTest來測試一下
/** * @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); }
輸出
eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJjb2Rlcm15IiwiYXV0aG9yaXRpZXMiOiJhZG1pbiIsImV4cCI6MTU5NjA4MDM5OX0.rfDtzMus50uAFnqMw1tm3c_ZYbmUNkIRqMkeJ0510PAH-RCUWtZkfNPTDYAGVVfDU6jmdEkGyNYvGy3UrNq5pA
咱們再來解析
//解析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());
輸出
{sub=codermy, authorities=admin, exp=1596082316} username:codermy 權限:admin 到期時間:Thu Jul 30 12:11:56 CST 2020
其實jwt自己很好理解,無非就就是一把鑰匙,可用打開對應的鎖,這不過這把鑰匙稍微特殊點,它還帶了主人的一些信息。難理解的是要將它符合業務邏輯的整合進框架中。我本身就被繞了很久才明白。
我這裏寫了一個Jwt的工具類,用於生成和解析jwt
/** * @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中添加以下配置
jwt: tokenHeader: Authorization #JWT存儲的請求頭 secret: my-springsecurity-plus #JWT加解密使用的密鑰 expiration: 604800 #JWT的超期限時間(60*60*24*7) tokenHead: 'Bearer ' #JWT負載中拿到開頭,空格別忘了
咱們照着jwt的工做流程來,首先是登陸成功後客戶端會返回一個jwt token
因此咱們首先自定義一個MyAuthenticationSuccessHandler繼承AuthenticationSuccessHandler,這是登陸成功後的處理器
/** * @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
/** * @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
/** * @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中
/** * @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信息
{"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接口,就會報以下錯誤
咱們在header中添加上,以前登陸成功控制檯打印的token信息(由於咱們添加了圖片驗證碼,因此登陸不是很方便用postman,咱們能夠在瀏覽器中登陸或者先把驗證碼的攔截器去除)
加上了token信息以後再去訪問http://localhost:8080/api/menu接口,發現已經能夠正常訪問了
咱們再嘗試用test用戶登陸後獲取到jwt token訪問該接口,會報以下錯誤
直接貼代碼
/** * @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<Parameter> 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測試了
那麼咱們如今已經簡單的實現了jwt的無狀態登陸功能,須要作的就是讓前端的請求都帶上jwt token。
。。。研究了半天沒弄懂,因此暫時先擱置,下一章解決它。有知道怎麼設置請求頭的小夥伴也能夠留言告訴我
因此本章結束的代碼是不能正常在瀏覽器運行的,可是能夠在postman和swagger中測試(若是想運行,在SpringSecurityConfig中添加上.rememberMe()便可)