# SpringSecurity+JWT實現登入註冊/後續資源存取 ## 簡述 專案在此 https://github.com/ryanimay/erp <font color="#f00">(如果有疑問,詳細部分要讀sourceCode,筆記有些是舊的程式碼沒有更新)</font> -- 大致概念就是 SpringSecurity負責用戶登入的帳號密碼驗證(包含加密解密),還有用戶權限驗證 JWT實現已登入用戶持續存取資源的驗證(驗證Token有效性確保是經過認證的用戶) 2024.3.19 後端部分API大致完成 總結對SpringSecurity+JWT的實現以及理解 先實現<font color="#f00">UserDetails</font>,用於驗證該用戶狀態及權限 先實現<font color="#f00">UserDetailsService</font>,用於登入驗證時使用userName找到該用戶,返回UserDetails實現類 把先前實現的UserDetailsService類注入<font color="#f00">AuthenticationProvider</font>(DaoAuthenticationProvider) <font color="#f00">SecurityFilterChain</font>設置.addFilterBefore(<font color="#f00">JwtFilter</font>, UsernamePasswordAuthenticationFilter.class) 之後登入就會先進行JWT驗證才會走到security的權限驗證 這邊的API權限設置也都是用動態加仔的方式設置在SecurityFilterChain 後續流程: 用戶註冊時,密碼經由指定的<font color="#f00">PasswordEncoder</font>(像這邊是使用BCryptPasswordEncoder) 加密後存入資料庫 後續用戶發起登入請求,把帳密放進UsernamePasswordAuthenticationToken 進行.authenticate(會使用先前實現的UserDetailsService類進行驗證) 驗證帳號密碼是否通過和驗證該用戶相關狀態 如果通過就產出並核發JWT返回,並且因為SecurityContext只保留再請求的生命週期間 之後登入成功後的每個請求流程都是: Filter先驗證AccessToken是否過期, 如果過期就再驗證RefreshToken是否過期和是否在黑名單內 (如果登入沒有勾選rememberMe,這邊就會直接拋出要求重登) 如果通過驗證則把AccessToken和RefreshToken都刷新,並且把舊Token加入Redis黑名單 如果RefreshToken也過期,就直接拋出,返回Unauthorized 驗證過程中只要是過期以外的錯誤都是直接返回403 並且只要沒有拋出(JWT驗證通過) 就必須透過JWT內儲存的用戶資訊產出Authentication以利該請求進行後續權限驗證 (目前是只存用戶名稱,想秉持用戶驗證和權限驗證的獨立性但不確定優劣) 然後因為是每次用戶請求都會產生新的Authentication,所以權限是即時更新的 但缺點就是每次請求都要查詢當下權限,目前是只想到利用緩存優化 另外 ~~這邊JWT設計是用非對稱式加密 公鑰給前端,前後都需要驗證JWT時效和簽名~~ 2024.4.24: 我的理解錯誤,這邊目前是修改為前端只進行時效驗證因為不需要密鑰 簽名驗證因為有密鑰隱私問題,由後端負責 JWT做非對稱加密的作用應該是比較偏向多服務間的後端溝通,不是前後端溝通(? ## 流程 我自己的理解,概念會像是這樣 ![image](https://hackmd.io/_uploads/BkwkHw44a.png =70%x) <b>請求進來 -> 封裝成驗證用的物件 -> AuthenticationProvider.authenticate()負責驗證,內含passwordEncoder和資料庫搜尋用戶 -> 驗證完成取出用戶資訊,並加工產生Token -> 封裝成response並返回</b> ```java= // 封裝帳密 Authentication authentication = new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()); // security執行帳密認證(會使用authenticationProvider設置的加密類還有UserDetailService實現) authentication = authenticationProvider.authenticate(authentication); // 認證成功後取得結果 UserDetails userDetails = (UserDetails) authentication.getPrincipal(); // 產token String accessToken = createToken(ACCESS_TOKEN, userDetails.getUsername(), ACCESS_TOKEN_EXPIRE_TIME); String refreshToken = createToken(REFRESH_TOKEN, userDetails.getUsername(), REFRESH_TOKEN_EXPIRE_TIME); HttpHeaders httpHeaders = new HttpHeaders(); httpHeaders.add(HttpHeaders.AUTHORIZATION, TOKEN_PREFIX + accessToken); httpHeaders.add(REFRESH_TOKEN, refreshToken); ``` <font color="#f00">最後返回前端的是封裝的Token(令牌),但安全性不受保護(只是base64編碼,不是加密), 所以盡量不要存重要資訊</font> ### SecurityContext 用於保存當前線程資訊,但生命週期僅限當前線程,可以用 SecurityContext securityContext = SecurityContextHolder.getContext();獲得 可以做 securityContext.setAuthentication(authentication)或 securityContext.getAuthentication(authentication) 在線程內獲取任何常用資訊比如說用戶資訊 這邊是在用戶呼叫接口時filter先放入Authentication,後續存儲資源時可以任意調用 ### Authentication 主要分為三部份 (principal, credentials, authorities) 就是 (用戶名(或用戶資訊), 用戶驗證(密碼), 用戶權限) 這邊的用法,除了登入驗證時的UsernamePasswordAuthenticationToken有作用 後續就只是用來程載用戶資訊(UserDetails)的容器,放在principal並且存於SecurityContext 沒有特別使用JwtAuthenticationToken做JWT驗證 後續JWT都是手動解析操作內容 ![image](https://hackmd.io/_uploads/ByroYnPCp.png) ▲找資料的時候找到這張圖,就是框架背後做的事,講得蠻好的 詳細來源 https://www.cnblogs.com/auguse/articles/17654716.html ## 實現 ### dependency ```java= <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!--JWT--> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> ``` 這邊使用是結合JWT(<font color="#f00">先不做OAuth2.0,沒有三方登入需求,目前設計是像session刷新那樣,用RefreshToken刷新AccessToken時間</font>),這邊是使用jjwt ### UserDetails ```java= @Getter @Setter @NoArgsConstructor public class UserDetailImpl implements UserDetails { private Locale locale; private ClientModel clientModel; @JsonIgnore private CacheService cacheService; public UserDetailImpl(ClientModel clientModel, CacheService cacheService) { this.cacheService = cacheService; this.clientModel = clientModel; } @Override @JsonIgnore public String getUsername() { return clientModel.getUsername(); } @Override @JsonIgnore public String getPassword() { return clientModel.getPassword(); } @Override @JsonIgnore public boolean isEnabled() { return clientModel.isActive(); } @Override @JsonIgnore public boolean isAccountNonLocked() { return !clientModel.isLock(); } @Override @JsonIgnore public boolean isCredentialsNonExpired() { return true; } @Override @JsonIgnore public Collection<? extends GrantedAuthority> getAuthorities() { return getRolePermission(clientModel.getRoles()); } @Override @JsonIgnore public boolean isAccountNonExpired() { return true; } private Collection<? extends GrantedAuthority> getRolePermission(Set<RoleModel> roles) { Set<RolePermissionDto> set = new HashSet<>(); for (RoleModel role : roles) { set.addAll(cacheService.getRolePermission(role.getId())); } return set; } } ``` 就是實現UserDetails類 看內容就知道,主要就是@Override的方法用於驗證用戶狀態以及權限 其餘就只是邏輯的實現 ### UserDetailsService ```java= @Service public class UserDetailServiceImpl implements UserDetailsService { LogFactory LOG = new LogFactory(UserDetailServiceImpl.class); private ClientService clientService; private UserDetailFactory userDetailFactory; @Autowired public void setClientService(ClientService clientService) { this.clientService = clientService; } @Autowired public void setUserDetail(UserDetailFactory userDetailFactory) { this.userDetailFactory = userDetailFactory; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { ClientModel client = clientService.findByUsername(username); if (client == null) { LOG.warn("Cant find user: {0}", username); throw new UsernameNotFoundException("Cant find user:" + username); } return userDetailFactory.build(client); } } ``` 再來是實現UserDetailsService類 重點是loadUserByUsername方法,用於登入時要靠用戶名找到對應用戶(從DB) 注意返回類是UserDetails,就是剛剛做的實現類 ### --其餘配置類-- 其餘需要配置的有幾個部分,這邊都是統一放在自訂的SecurityConfig類中 ```java= @Configuration @EnableWebSecurity public class SecurityConfig { ... } ``` 其內容這邊分別提 ### 1. PasswordEncoder ```java= //就是加密解密的方式,這邊是用BCryptPasswordEncoder @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } ``` ### 2. AuthenticationProvider ```java= //把剛剛做的UserDetailsService和PasswordEncoder注入AuthenticationProvider //之後框架驗證就會使用字定義的內容 @Bean public AuthenticationProvider authenticationProvider(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) { DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); provider.setUserDetailsService(userDetailsService); provider.setPasswordEncoder(passwordEncoder); return provider; } ``` ### 3. SecurityFilterChain ```java= @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationFilter authFilter, UserStatusFilter userStatusFilter, DenyPermissionFilter denyPermissionFilter) throws Exception { //配置資料庫內permission表的所有API權限設定 configurePermission(http); //permission表以外的設定 http.authorizeHttpRequests(request -> request .requestMatchers("/swagger/swagger-ui.html", "/swagger/swagger-ui/**", "/swagger/api-docs/**").permitAll() .anyRequest().authenticated()) .csrf(AbstractHttpConfigurer::disable) .addFilterBefore(denyPermissionFilter, UsernamePasswordAuthenticationFilter.class) .addFilterAfter(authFilter, DenyPermissionFilter.class) .addFilterAfter(userStatusFilter, JwtAuthenticationFilter.class) .exceptionHandling(exception -> exception.accessDeniedHandler((request, response, accessDeniedException) -> FilterExceptionResponse.error(response, ApiResponseCode.ACCESS_DENIED)) .authenticationEntryPoint((request, response, authException) -> { authException.printStackTrace(); if(authException instanceof LockedException){ FilterExceptionResponse.error(response, ApiResponseCode.CLIENT_LOCKED); }else if(authException instanceof DisabledException){ FilterExceptionResponse.error(response, ApiResponseCode.CLIENT_DISABLED); }else{ FilterExceptionResponse.error(response, ApiResponseCode.ACCESS_DENIED); } } )); return http.build(); } //動態設定所有權限 private void configurePermission(HttpSecurity http) throws Exception { List<PermissionModel> permissions = permissionService.findAll(); LOG.info("all permission: {0}", ObjectTool.toJson(permissions)); http.authorizeHttpRequests(request -> { for (PermissionModel permission : permissions) { String authority = permission.getAuthority(); String url = permission.getUrl(); if("*".equals(authority)){ request.requestMatchers(url).permitAll();//公開api noRequiresAuthenticationSet.add(url); }else{ request.requestMatchers(antMatcher(url)).hasAuthority(authority); } } }); } ``` SecurityFilterChain這邊詳細說明,主要就是security框架驗證的鏈 從用戶角色相關到路徑權限驗證, 可以設定每個路徑需求的permission或是role (這邊是讀資料庫用動態加載的方式設置) 也可以在這個鏈中加入自己定義的Filter 像這邊還加入了[JWT的Filter](###JWTFilter),下面會說明 ==關於路徑設定權限還有其他方式(annotation之類的) 可以看這邊 https://www.baeldung.com/spring-security-expressions== :::danger ==<font color="#f00">強烈注意</font>== ![image](https://hackmd.io/_uploads/ry1ptq34p.png) 雖然antMatcher已經棄用改成requestMatchers 但原理一樣,如果要用路徑比對權限, 必須按照 精確的路徑需求放前面,大範圍匹配較籠統的放後面 不然按順序掃到全域'/** '不符權限就會拋出了,後面配置的精確路徑權限都會失效 這裡deBug花了兩天... ::: ### JWTFilter 用OncePerRequestFilter 每個請求進來,如果是需要權限的url才會往下做驗證 驗證AccessToken 如果無效或為空就返回403(這邊是自己封裝返回類,詳細實現可以看下面GitCode) 如果過期就驗證RefreshToken ```java= @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { LogFactory LOG = new LogFactory(JwtAuthenticationFilter.class); private final TokenService tokenService; private final CacheService cacheService; public JwtAuthenticationFilter(TokenService tokenService, CacheService cacheService) { this.tokenService = tokenService; this.cacheService = cacheService; } /** * 針對已登入用戶後續存取api做token驗證和例外處理 */ @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { String url = ObjectTool.extractPath(request.getRequestURI()); if (requiresAuthentication(url) && notEqualWebsocketUrl(url)) { String token; //刷Token的請求改成驗證refreshToken if(Router.CLIENT.REFRESHT.equals(url)){ token = request.getHeader(TokenService.REFRESH_TOKEN); }else{ token = request.getHeader(HttpHeaders.AUTHORIZATION); } if(token == null || cacheService.existsTokenBlackList(token)) throw new ExpiredJwtException(null, null, null); Map<String, Object> payload = authenticationToken(token); String uid = String.valueOf(payload.get(TokenService.TOKEN_PROPERTIES_UID)); createAuthentication(Long.parseLong(uid), request); }else{ createEmptyUserAuth(request); } } catch (ExpiredJwtException e) { exceptionResponse(e, response, ApiResponseCode.INVALID_SIGNATURE); return; } catch (SignatureException | AccessDeniedException | MalformedJwtException e) { exceptionResponse(e, response, ApiResponseCode.ACCESS_DENIED); return; } catch (URISyntaxException e) { LOG.error("request path: [{0}] trans error", request.getRequestURI()); exceptionResponse(e, response, ApiResponseCode.ACCESS_DENIED); return; } filterChain.doFilter(request, response); } private boolean requiresAuthentication(String url) { return !SecurityConfig.noRequiresAuthenticationSet.contains(url); } /** * 驗證AccessToken */ private Map<String, Object> authenticationToken(String token) { String accessToken = token.replace(TokenService.TOKEN_PREFIX, ""); return tokenService.parseToken(accessToken); } /** * 建立ClientIdentity * */ private void createAuthentication(Long id, HttpServletRequest request) { String lang = request.getHeader("User-Lang"); ClientIdentityDto client = cacheService.getClient(id); if(client == null) return; UserDetailImpl userDetail = lang == null ? new UserDetailImpl(client, cacheService) : new UserDetailImpl(lang, client, cacheService); Collection<? extends GrantedAuthority> rolePermission = userDetail.getAuthorities(); Authentication authentication = new UsernamePasswordAuthenticationToken(userDetail, null, rolePermission); SecurityContextHolder.getContext().setAuthentication(authentication); } /** * 在lang不為null的情況下 * 創建不需驗證的api用的auth * 只放帶有lang的principal,用於控制接口返回語系 * */ private void createEmptyUserAuth(HttpServletRequest request) { String lang = request.getHeader("User-Lang"); if(lang != null){ UserDetailImpl userDetail = new UserDetailImpl(lang, new ClientIdentityDto(), cacheService); Authentication authentication = new UsernamePasswordAuthenticationToken(userDetail, null, null); SecurityContextHolder.getContext().setAuthentication(authentication); } } private void exceptionResponse(Exception e, HttpServletResponse response, ApiResponseCode code) throws IOException { LOG.error(e.getMessage()); FilterExceptionResponse.error(response, code); } private boolean notEqualWebsocketUrl(String requestedUrl) { return !requestedUrl.contains("/ws"); } ``` ### TOKEN 這邊做非對稱,公鑰會暴露給前端驗證時效使用 密鑰的部份這邊用動態產生的方式,伺服器重啟用戶就必須重登 ```java= private KeyPair keyPair; @PostConstruct public void init(){ keyPair = Keys.keyPairFor(SignatureAlgorithm.RS256); //做非對稱,伺服器重啟時刷新 } public String createToken(String type, String username, int expirationTime) { //轉毫秒 long expirationMillis = getExpireMillisecond(expirationTime); // 設置標準內容與自定義內容 Claims claims = Jwts.claims(); claims.setSubject(type); claims.setIssuedAt(new Date()); claims.setExpiration(new Date(expirationMillis)); claims.put(TOKEN_PROPERTIES_USERNAME, username); // 簽名後產生 token PrivateKey privateKey = keyPair.getPrivate(); return Jwts.builder() .setClaims(claims) .signWith(privateKey) .compact(); } private Long getExpireMillisecond(int expirationTime){ return Instant.now() .plusSeconds(expirationTime) .getEpochSecond() * 1000; } //公鑰解密 public Map<String,Object> parseToken(String token){ PublicKey publicKey = keyPair.getPublic(); Claims claims = Jwts.parserBuilder().setSigningKey(publicKey).build().parseClaimsJws(token).getBody(); return new HashMap<>(claims); } ``` ## Git 專案在此 https://github.com/ryanimay/erp <font color="#f00">(詳細部分要讀sourceCode,筆記有些是舊的程式碼沒有更新)</font>