前言
在 {探 Spring Security(二) 用戶帳號資料儲存於DB} 文章中,我們將立了一個 Spring Security 專案,並將使用者相關權限存於DB。
透過這篇文章,我們將Spring Security 與 JWT 結合,改使用JWT認證機制,處理身份驗證和授權功能。
有關於 JWT,本篇就省略該部分的介紹,請各位可以直接過去看看(JSON Web Token)。
實作
步驟一:新增依賴 (Dependencies)
在您的 pom.xml 中,新增 Nimbus JWT 庫的依賴。
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.31</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
</dependencies>
步驟二:配置 JWT 相關的 Bean
我們需要一個秘密金鑰來簽名和驗證 JWT。最安全的方式是使用 SecretKey,而不是直接使用純文本字符串。
1. JwtConfig.java (金鑰配置)
@Configuration
public class JwtConfig {
// 建議將此金鑰從設定檔載入
@Value("${jwt.secret:default-secret-key-32-bytes-long}")
private String secretKeyString;
/**
* 建立 HMAC SHA-256 簽章演算法所需的 SecretKey。
* * @return 用於簽章/驗證的 SecretKey
*/
@Bean
public SecretKey jwtSecretKey() {
// 確保金鑰長度至少為 256 位元(32 位元組)
byte[] keyBytes = secretKeyString.getBytes(StandardCharsets.UTF_8);
if (keyBytes.length < 32) {
throw new IllegalArgumentException("金鑰長度至少為 256 位元.");
}
return new SecretKeySpec(keyBytes, "HmacSHA256");
}
/**
* 建立 JWS 簽署器 (Signer)
*/
@Bean
public JWSVerifier jwsVerifier(SecretKey secretKey) throws JOSEException {
return new MACVerifier(secretKey);
}
/**
* 建立 JWS 驗證器 (Verifier)
*/
@Bean
public JWSSigner jwsSigner(SecretKey secretKey) throws JOSEException {
return new MACSigner(secretKey);
}
}
步驟三:建立 JwtService 服務
這個服務負責 JWT 的核心邏輯:產生 Token 和驗證/解析 Token。
1. JwtService.java
@Service
public class JwtService {
private final JWSSigner signer;
private final JWSVerifier verifier;
private final SecretKey secretKey;
// Token 的有效期限(例如:24小時 86400000 毫秒, 1小時 3600000 毫秒)
@Value("${jwt.expiration-ms:3600000}")
private long jwtExpirationMs;
@Autowired
public JwtProvider(JWSSigner signer, JWSVerifier verifier, SecretKey secretKey) {
this.signer = signer;
this.verifier = verifier;
this.secretKey = secretKey;
}
/**
* 1. 根據 Authentication 物件產生 JWT
*/
public String generateToken(Authentication authentication) {
CustomUserDetails userPrincipal = (CustomUserDetails) authentication.getPrincipal();
Date now = new Date();
Date expiryDate = new Date(now.getTime() + jwtExpirationMs);
// 設定 Claims (負載)
JWTClaimsSet claims = new JWTClaimsSet.Builder()
.subject(userPrincipal.getUsername()) // Sub: 使用者名稱
.issueTime(now) // Iat: 簽發時間
.expirationTime(expiryDate) // Exp: 過期時間
.claim("uid", userPrincipal.getUsername())// 自訂 Claim: 使用者ID
.build();
// 建立 JWS 頭部 (使用 HMAC SHA-256)
JWSHeader header = new JWSHeader(JWSAlgorithm.HS256);
// 建立簽名 JWS 對象
SignedJWT signedJWT = new SignedJWT(header, claims);
try {
// 簽名
signedJWT.sign(signer);
return signedJWT.serialize();
} catch (JOSEException e) {
// 應該要記錄日誌
throw new RuntimeException("Error signing JWT", e);
}
}
/**
* 2. 從 JWT 取得用戶名
*/
public String getUsernameFromToken(String token) {
try {
SignedJWT signedJWT = SignedJWT.parse(token);
return signedJWT.getJWTClaimsSet().getSubject();
} catch (ParseException e) {
// 應該要記錄日誌
return null;
}
}
/**
* 3. 驗證 Token 有效性
*/
public boolean validateToken(String authToken) {
try {
SignedJWT signedJWT = SignedJWT.parse(authToken);
// 驗證簽名
if (!signedJWT.verify(verifier)) {
return false;
}
// 驗證時間戳記 (過期時間)
Date now = new Date();
Date expirationTime = signedJWT.getJWTClaimsSet().getExpirationTime();
if (expirationTime != null && expirationTime.before(now)) {
return false;
}
return true;
} catch (ParseException | JOSEException e) {
// 令牌格式錯誤或簽章驗證失敗
return false;
}
}
}
步驟四:建立 JwtAuthenticationFilter
這個過濾器負責攔截每個請求,從 Header 中提取 Token 並設定 Spring Security 上下文。
1. JwtAuthenticationFilter.java
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtService tokenService;
@Autowired
private CustomUserDetailsService customUserDetailsService;
// 從請求頭取得 Token 的方法
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
// 檢查是否包含 Bearer 前綴
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7); // 擷取 Token
}
return null;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String jwt = getJwtFromRequest(request);
if (StringUtils.hasText(jwt) && tokenService.validateToken(jwt)) {
String username = tokenService.getUsernameFromToken(jwt);
// 從資料庫載入用戶
UserDetails userDetails = customUserDetailsService.loadUserByUsername(username);
// 建立 Authentication 對象
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
// 設定詳細信息,通常是請求的 IP、Session ID 等
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 將 Authentication 物件設定到 SecurityContext 中
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception ex) {
logger.error("Could not set user authentication in security context", ex);
}
filterChain.doFilter(request, response);
}
}
步驟五:更新 SecurityConfig (整合過濾器)
您需要調整 SecurityConfig 來停用 Session,並新增您的 JWT 濾鏡。
1. SecurityConfig.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // Enables @PreAuthorize
public class SecurityConfig {
// JWT Filter
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter();
}
// 設定 AuthenticationManager,用於處理登入要求
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration)
throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
// 停用 Session 管理,使用 STATELESS
.sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// 允許所有人存取登入接口
.requestMatchers("/auth/**", "/login").permitAll()
// 其他受保護的 RESTful 接口
.requestMatchers(HttpMethod.GET, "/api/users", "/api/users/{uid}").hasAnyAuthority("read")
.requestMatchers(HttpMethod.POST, "/api/user").hasAnyAuthority("create")
.requestMatchers(HttpMethod.PUT, "/api/users/{uid}").hasAnyAuthority("update")
.requestMatchers(HttpMethod.DELETE, "/api/users/{uid}").hasAnyAuthority("delete", "ROLE_ADMIN")
.anyRequest().authenticated())
// Spring Security 預設的 UsernamePasswordAuthenticationFilter 之前新增 JWT 過濾器
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
步驟六:更新 AuthController (處理登錄)
您需要一個新的控制器來接收使用者名稱和密碼,進行認證,並在成功後返回 JWT。
1. 登入請求 DTO (LoginRequest.java)
public class LoginRequest {
private String username;
private String password;
// Getters and Setters
}
2. AuthController.java
@RestController
@RequestMapping("/auth")
public class AuthController {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtService tokenService;
@PostMapping("/login")
public ResponseEntity<String> authenticateUser(@RequestBody LoginRequest loginRequest) {
// 1. 使用 AuthenticationManager 驗證憑證
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
loginRequest.getUsername(),
loginRequest.getPassword()));
// 2. 認證成功後,將 Authentication 物件設定到 SecurityContext 中 (可選,但推薦)
SecurityContextHolder.getContext().setAuthentication(authentication);
// 3. 產生 JWT Token
String jwt = tokenService.generateToken(authentication);
// 回傳 Token 給客戶端
return ResponseEntity.ok(jwt);
}
}
測試
使用 Postman:
1. 認證 (Login):
* Method: POST
* URL: http://localhost:8088/auth/login
* Body: application/json
2. JSON{
3. "username": "admin",
4. "password": "password"
5. }
6.
預期回應: 狀態碼 200 OK,回應體中包含產生的 JWT 字串。
測試驗證 (使用 Postman)
方法: GET
URL: http://localhost:8088/api/users
Authorization: Bearer <您在步驟 1 中獲得的 JWT>
預期結果:
如果認證成功: 您將收到狀態碼 200 OK 和使用者清單。
如果認證失敗: 您將收到狀態碼 401 Unauthorized。
方法: GET
URL: http://localhost:8088/api/users/2
預期結果:
如果認證成功: 您將收到狀態碼 200 OK 和該ID使用者清單。
如果認證失敗: 您將收到狀態碼 401 Unauthorized。
文章結束
留言