- 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 產生