To-Do Calendar - Day28 Spring Security 的認證與授權(三)

接著來實作最後一個部分—定義 token 校驗過濾器。

什麼是 JWT?

JWT(JSON Web Token)是一個開放的標準,用在各方之間以 JSON 物件安全地傳輸資訊。這些資訊透過數位簽章進行授權與驗證。可以使用 RSA 的公開金鑰/私密金鑰對 JWT 進行簽名。

JWT 請求流程

  1. 使用者使用瀏覽器發送帳號和密碼
  2. 伺服器使用金鑰建立一個 JWT
  3. 伺服器傳回該 JWT 給瀏覽器
  4. 瀏覽器將該 JWT 串在請求標頭中向伺服器發送請求
  5. 伺服器驗證該 JWT
  6. 根據授權規則傳回資源給瀏覽器
image

透過前3個步驟獲得 JWT 之後,在 JWT 有效期內,以後都不需要再進行前3個步驟的操作,直接進行步驟 4~6 的請求資源即可。

JWT 的組成

由表頭(Header)、內容(Payload)與簽名(Signature)三個部份組成。

  • Header:含 Token 的種類及產生簽章(signature)要使用的雜湊演算法
  • Payload:帶有欲存放的資訊(例如用戶資訊)
  • Signature:編譯後的 Header、Payload 與密鑰透過雜湊演算法所產生
  • 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);
          }
      }
    
    Info
    Redis 是一個基於記憶體、資料型態為 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();
        }
    }
    

運行結果

image
image

登入(伺服器傳回 token)

image

當瀏覽器發出需要驗證的請求時需在 Header 帶上 Authorization 欄位(格式是「Bearer」加一個空格,再加上 token)

image

當 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);
      }
    );
    

運行結果

image

當收到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);
      }
    

運行結果

image

登出後再重新訪問會要求重新登入



參考資料

comments

comments powered by Disqus