Published on

「Spring Boot API 開發:從 0 到 1」Day 35 JWT 實現無狀態身份驗證

在前面的文章中,我們深入探討了 Spring Security 的相關設定

今天,我們將進一步提升我們的 API 安全性,介紹如何使用 JWT (JSON Web Token) 實現無狀態的身分驗證

什麼是 JWT?

JWT 是一種開放標準 (RFC 7519),它定義了一種簡潔的、自定義的方法,用於在雙方之間安全地傳輸資訊作為 JSON 對象

這些信息可以被驗證和信任,因為它是經過簽名的

JWT 由三部分組成,以點 (.) 分隔

  • Header (標頭)
  • Payload (負載)
  • Signature (簽名)

為什麼 API 使用 JWT 是更好的認證方法 ?

  • 無狀態和可擴展性: 伺服器不需要儲存 session 信息,這使得應用更容易擴展
  • 跨網域認證: JWT 可以在多個服務之間共享,非常適合微服務架構
  • 性能: 不需要在伺服器端查詢資料庫來驗證使用者身份
  • 靈活性: 可以攜帶額外的使用者資訊,減少資料庫查詢

修改 Spring Security 配置,加入 JWT 功能

增加 JWT 依賴

// https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'

// https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'

// https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-jackson
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'

設定 JWT 的密鑰和過期時間

先在 application.properties 裡面加入兩個值 secret 和 expiration

# JWT 用於簽名和驗證
jwt.secret=0191eb1feacf719c898518c598134bba4ac6dead4464943885810c7d3938c26
# JWT token 的過期時間,以秒為單位(這裡設置為 24 小時)
jwt.expiration=86400

建立一個 JwtService 來處理 JWT 的生成和驗證

@Service
public class JwtService {

    // 使用 @Value 簡單的注入 properties 裡面的 JWT 密鑰和過期時間
    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private Long expiration;

    public String generateToken(UserDetails userDetails) {
        // 可以加入想要放在 token 裡面的 claims
        Map<String, Object> claims = new HashMap<>();

        return createToken(claims, userDetails.getUsername());
    }

    // 產生 JWT Token,用使用者的 username 來當成 subject
    private String createToken(Map<String, Object> claims, String subject) {
        return Jwts.builder()
                .claims(claims)
                .subject(subject)
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + expiration * 1000))
                .signWith(getSigningKey())
                .compact();
    }

    // 驗證使用者傳來的 JWT 是不是合法的
    public Boolean validateToken(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }

    // 從 JWT 取出裡面的 Subject (使用者的 username)
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    // 把 properties 裡面的 JWT 密鑰,轉換成 Java 的 SecretKey (簽名密鑰)
    private SecretKey getSigningKey() {
        byte[] keyBytes = secret.getBytes();
        return Keys.hmacShaKeyFor(keyBytes);
    }

    // 從 JWT 取出裡面的 Expiration (過期時間)
    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    // 取出 JWT 特定的 Claim
    private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    // 取出 JWT 的所有 Claims
    private Claims extractAllClaims(String token) {
        return Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    // JWT 有沒有過期
    private Boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }
}

建立 JWT 請求過濾器

建立一個 JwtRequestFilter 攔截每個請求並驗證 JWT

// 繼承 OncePerRequestFilter,確保每個請求只執行一次過濾
@Component
public class JwtRequestFilter extends OncePerRequestFilter {

    // 這裡只列出 JWT 相關的程式碼,而不是全部的程式碼

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        final String authorizationHeader = request.getHeader("Authorization");

        String username = null;
        String jwt = null;

        // 從 Header 裡面取出 JWT,並且拿出 JWT 裡面的 username
        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            jwt = authorizationHeader.substring(7);
            username = jwtService.extractUsername(jwt);
        }

        // 如果有 JWT (有 username),而且是未登入狀態
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {

            // 使用 JWT 的 username 去找到使用者相關的資料,這裡是從資料庫裡面取出資料
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);

            // 驗證 JWT 是不是有效的
            if (jwtService.validateToken(jwt, userDetails)) {

                // 如果 JWT 是有效的,就設定到 Spring Security 的 Context,表示使用者已經登入
                var usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
                        userDetails,
                        null,
                        userDetails.getAuthorities());

                usernamePasswordAuthenticationToken
                        .setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
            }
        }

        filterChain.doFilter(request, response);
    }
}

修改 Spring Security 配置

更新 SecurityConfig 以支持 JWT

public class SecurityConfig {

    // 這裡只列出 JWT 相關的程式碼,而不是全部的程式碼

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        http.authorizeHttpRequests((requests) -> requests
                        // 允許所有人訪問 api/auth 下面的 API
                        .requestMatchers("/api/auth/**").permitAll()
                        // 要求所有其他請求都必須經過認證
                        .anyRequest().authenticated()
                )
                // 設定 session 管理策略為無狀態(STATELESS)
                // 表示應用程式不會為每個使用者儲存相關的 session,這是基於令牌的認證方案 (JWT) 的典型設定
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                // 設定 JWT 請求過濾器,來驗證 JWT,並將其放置在 UsernamePasswordAuthenticationFilter 之前
                // 確保了 JWT 的驗證在使用者名密碼驗證之前進行
                .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    // 建立一個 AuthenticationManager bean
    // AuthenticationManager 是 Spring Security 用於處理認證請求的核心功能
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        // 使用 AuthenticationConfiguration 來取得預設的 AuthenticationManager
        return authenticationConfiguration.getAuthenticationManager();
    }
}

新增獲取 JWT 的 API

建立一個新的 controller 來處理,登入後回傳 JWT 的 API

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    // 這裡只列出 JWT 相關的程式碼,而不是全部的程式碼

    @PostMapping("/login")
    public ResponseEntity<MyApiResponse<AuthenticationResponse>> createAuthenticationToken(@RequestBody AuthenticationRequest authenticationRequest) throws Exception {

        try {
            // 使用帳號和密碼驗證是否可以登入
            authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(authenticationRequest.username(),
                            authenticationRequest.password())
            );
        } catch (BadCredentialsException e) {
            throw new Exception("Incorrect username or password", e);
        }

        // 帳號和密碼認證過了
        // 從資料庫取得使用者相關資料
        final UserDetails userDetails = userDetailsService
                .loadUserByUsername(authenticationRequest.username());

        // 使用相關資料產生 JWT
        final String jwt = jwtService.generateToken(userDetails);

        var authenticationResponse = new AuthenticationResponse(jwt);

        return ResponseEntity.ok(new MyApiResponse<>(true, authenticationResponse, null));
    }
}

測試 API

全部完成後,就可以呼叫 API 來測試

可以看到登入後,就有取得 JWT

把 JWT 拿到 jwt.io ,來看一下裡面的相關 payload

可以看到 subject 為 user,還有相關的時間

把原本 API 的基本 HTTP 認證(Basic Authentication)改為使用 JWT 認證

同場加映:修改測試程式碼支援 JWT

TodoEndToEnd Test

寫一個 getJwtToken 的方法,來實際取得 JWT

然後在 BeforeEach 的時候,把它設定為 全局變數,每一個測試就可以用到了

public class TodoEndToEndTest {

    // 這裡只列出 JWT 相關的程式碼,而不是全部的程式碼

    @BeforeEach
    void setUp() {

        // 獲取 JWT Token 並設置為全局變數
        String token = getJwtToken();
        RestAssured.requestSpecification = given()
                .header(new Header("Authorization", "Bearer " + token));
    }

    // 新增一個方法來獲取 JWT Token
    private String getJwtToken() {

        return given()
                .contentType(ContentType.JSON)
                .body(new AuthenticationRequest(username, password))
                .when()
                .post("/api/auth/login")
                .then()
                .statusCode(200)
                .extract()
                .path("data.jwt");
    }
}

TodoController Test

因為我們加入了 JwtRequestFilter ,在使用 MockMvc 來執行測試的話,會說找不到 JwtRequestFilter

必須要把它相關使用到的類別顯示 Import 進來

另外,因為 UserRepository 它是 interface,需要用 MockBean 的方式

// 這裡只列出 JWT 相關的程式碼,而不是全部的程式碼

@WebMvcTest(TodoController.class)
@Import({JwtRequestFilter.class, CustomUserDetailsService.class, JwtService.class})
public class TodoControllerTest {

    @MockBean
    private UserRepository userRepository;
}

這樣子修改完,測試就又可以動了 XD

結論

通過實現 JWT,我們成功地為我們的 API 添加了一個無狀態的身份驗證系統

這種方法不僅提高了應用的安全性,還增強了其可擴展性

然而,在實際應用中,我們還需要考慮一些額外的安全措施

例如

  • 定期更換密鑰
  • 實現 JWT 刷新機制
  • 處理 JWT 撤銷

同步刊登於 iTHome 鐵人賽 「Spring Boot API 開發:從 0 到 1」Day 35 JWT 實現無狀態身份驗證

圖片來源:AI 產生

參考連結