To-Do Calendar - Day28 Spring Security 的認證與授權(三)
接著來實作最後一個部分—定義 token 校驗過濾器。
什麼是 JWT?
JWT(JSON Web Token)是一個開放的標準,用在各方之間以 JSON 物件安全地傳輸資訊。這些資訊透過數位簽章進行授權與驗證。可以使用 RSA 的公開金鑰/私密金鑰對 JWT 進行簽名。
JWT 請求流程
- 使用者使用瀏覽器發送帳號和密碼
- 伺服器使用金鑰建立一個 JWT
- 伺服器傳回該 JWT 給瀏覽器
- 瀏覽器將該 JWT 串在請求標頭中向伺服器發送請求
- 伺服器驗證該 JWT
- 根據授權規則傳回資源給瀏覽器

透過前3個步驟獲得 JWT 之後,在 JWT 有效期內,以後都不需要再進行前3個步驟的操作,直接進行步驟 4~6 的請求資源即可。
JWT 的組成
由表頭(Header)、內容(Payload)與簽名(Signature)三個部份組成。
- Header:含 Token 的種類及產生簽章(signature)要使用的雜湊演算法
- Payload:帶有欲存放的資訊(例如用戶資訊)
- Signature:編譯後的 Header、Payload 與密鑰透過雜湊演算法所產生
Header
- Header 是一個包含定義 Token 種類(type)及雜湊演算法(alg)資訊的 JSON。在此設定 Token 種類為 JWT、產生簽章(signature)要使用的雜湊演算法為 HS256。此 JSON 將被轉換成 Base64 編碼:
{ "alg": "HS256", "typ": "JWT" }
Payload
- Payload 主要包含 claim,claim 是一些實體(通常指使用者)的狀態和額外的中繼資料,有三種類型:Reserved、Public、Private。最後再被轉換成 Base64 編碼:
{ "userId": "62b1361d521d677941408237", "name": "wowo", "role": "user", "iss": "todoCalendar", "nbf": 1596240000000, "exp": 1596241800000 }
Signature
- 簽名的產生方式是將標頭與內容分別用 Base64 編碼,再用「.」符號串接。接著再把這個值經由加密演算法搭配「密鑰」(secret key)的處理後而得之。這三者組合起來便是一個 JWT:
encodedHeader = base64UrlEncode(header) encodedPayload = base64UrlEncode(payload) signature = HMACSHA256(encodedHeader + "." + encodedPayload, secretKey) jwt = encodedHeader + "." + encodedPayload + "." + signature
引入依賴
- pom.xml 添加 jsonwebtoken 配置
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.0</version> </dependency>
建立 jwt 工具類
- 在 utils 目錄下建立 JwtUtil class
public class JwtUtil { public static final Long JWT_TTL = 60 * 60 * 1000L; public static final String JWT_KEY = "sangeng"; public static final String getUUID() { String token = UUID.randomUUID().toString().replaceAll("-", ""); return token; } public static final String createJWT(String subject) { JwtBuilder builder = getJwtBuilder(subject, null, getUUID()); return builder.compact(); } public static final String createJWT(String subject, Long ttlMills) { JwtBuilder builder = getJwtBuilder(subject, ttlMills, getUUID()); return builder.compact(); } public static JwtBuilder getJwtBuilder(String subject, Long ttlMills, String uuid) { SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; SecretKey secretKey = generalKey(); long nowMills = System.currentTimeMillis(); Date now = new Date(nowMills); if (ttlMills == null) { ttlMills = JwtUtil.JWT_TTL; } long expMills = nowMills + ttlMills; Date expDate = new Date(expMills); return Jwts.builder() .setId(uuid) .setSubject(subject) .setIssuer("todoCalendar") .setIssuedAt(now) .signWith(signatureAlgorithm, secretKey) .setExpiration(expDate); } public static String createJWT(String id, String subject, Long ttlMills) { JwtBuilder builder = getJwtBuilder(subject, ttlMills, id); return builder.compact(); } public static SecretKey generalKey() { byte[] encodedKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY); SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES"); return key; } public static Claims parseJWT(String jwt) throws Exception { SecretKey secretKey = generalKey(); return Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(jwt) .getBody(); } }
實作 token 校驗過濾器
建立一個過濾器
- 在 filter 目錄下建立 JwtAuthenticationTokenFilter class,並繼承 OncePerRequestFilter
OncePerRequestFilter
:因為直接繼承 filter interface 可能會存在一些問題,在不同的 servlet 版本當中,为了兼容不同的 web container,導致 filter 可能會被調用多次,所以這裡選擇繼承 OncePerRequestFilter,它是由 Spring 提供的,能確保後端接收到一個請求,該 Filter 只會執行一次
- 覆寫 doFilterInternal 方法
UsernamePasswordAuthenticationToken
:提供了兩種建構子- UsernamePasswordAuthenticationToken(Object principal, Object credentials)
- UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities)
這裡要使用後者,因為在3個參數的建構子裡,才會去調用 setAuthenticated(true),表示已認證狀態,給後面的過濾器判斷用的
@Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired RedisCache redisCache; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 取得 request header 中的 token String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); if (StringUtils.isEmpty(authHeader)) { // 放行 filterChain.doFilter(request, response); return; } // 解析 token 取得 userId String token = authHeader.replace("Bearer ", ""); String userId; try { Claims claims = JwtUtil.parseJWT(token); userId = claims.getSubject(); } catch (Exception e) { e.printStackTrace(); throw new RuntimeException(); } // 通過 userId 從 redis 中取得 LoginUser String redisKey = "login:" + userId; LoginUser loginUser = redisCache.getCacheObject(redisKey); if (Objects.isNull(loginUser)) { throw new RuntimeException(); } //TODO: 獲取權限信息封裝到 authentication 中 // 如果能從 redis 中取得 loginUser -> 存入 SecurityContextHolder if (Objects.nonNull(loginUser)) { UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(loginUser, null, null); SecurityContextHolder.getContext().setAuthentication(authentication); } filterChain.doFilter(request, response); } }
InfoRedis 是一個基於記憶體、資料型態為 key-value 的可選持久化的資料庫,他的讀寫速度非常快,因此常常被用在需要 Cache 一些資料的場合,可以減輕許多後端資料庫的壓力。因為不是本次的重點,所以此處省略了引入 Redis 與建立工具類的過程。
配置 token 校驗過濾器
token 校驗過濾器寫好之後還只是放在 Spring 容器中,還需要在 SecurityConfig 設定檔配置和指定的順序。
- 注入 jwtAuthenticationTokenFilter
- 將 token 校驗過濾器位置指定在 UsernamePasswordAuthenticationFilter 之前
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; @Override protected void configure(HttpSecurity http) throws Exception { http // 關閉CSRF .csrf().disable() // 不通過 Session 取得 SecurityContext .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() // 對登入接口允許匿名訪問 .antMatchers("/users/login").anonymous() // 對註冊接口允許匿名訪問 .antMatchers("/users/register").anonymous() // 剩下的所有請求 .anyRequest().authenticated(); // 將token校驗過濾器加到過濾器鏈中 http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
運行結果
登入(伺服器傳回 token) 當瀏覽器發出需要驗證的請求時需在 Header 帶上 Authorization 欄位(格式是「Bearer」加一個空格,再加上 token) 當 token 逾期或無效伺服器會回傳403
實作前端串接
修改 request 攔截器
- 添加 Authorization 欄位與 token 格式
instance.interceptors.request.use( function (config) { if (store.state.token) { config.headers.Authorization = "Bearer " + store.state.token; } return config; }, function (error) { // Do something with request error return Promise.reject(error); } );
修改 response 攔截器
- 添加 403 統一的錯誤處理(導回首頁)
instance.interceptors.response.use( function (response) { // Do something with response data return response; }, function (error) { if (error.response) { switch (error.response.status) { case 403: alert("請重新登入"); router.push("/"); store.commit("clearStore"); store.commit("removeSession"); store.commit("isLoginModalOpen", true); break; case 500: alert("程式發生問題"); break; } } if (!window.navigator.onLine) { alert("網路出了點問題,請重新連線後重整網頁"); return; } return Promise.reject(error); } );
運行結果
當收到403,前端會導回首頁開啟登入頁,並清空 store 和 sessionStorage
登出 API 細節分析
- 前面在 token 校驗過濾器已經把認證通過的 loginUser 存入 SecurityContextHolder,所以在登出接口可以從 SecurityContextHolder 獲取 userId。
- 刪除 redis 中的值。
實作 logout API
- Request URL
POST http://localhost:8080/users/logout
實作 Controller 層
- 修改 logout 方法
@RestController public class UserController { @Autowired private UserService userService; @PostMapping("/users/logout") public ResponseEntity logout() { userService.logout(); return ResponseEntity.status(HttpStatus.OK).build(); } ... }
實作 Service 層
- 取得 SecurityContextHolder 中的 userId
- 刪除 redis 中的值
@Override public void logout() { UsernamePasswordAuthenticationToken authentication = (UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication(); LoginUser loginUser = (LoginUser) authentication.getPrincipal(); String redisKey = "login:" + loginUser.getUser().getId(); redisCache.deleteObject(redisKey); }
運行結果
登出後再重新訪問會要求重新登入
參考資料
- 《極速開發 Java大型系統:Spring Boot 又輕又快又好學》
- 三更草堂 Spring Security 框架教程
- 【Spring Boot】第19課-從 Access Token 獲取使用者身份