在前面的文章中,我們深入探討了 Spring Security 的相關設定
今天,我們將進一步提升我們的 API 安全性,介紹如何使用 JWT (JSON Web Token)
實現無狀態的身分驗證
JWT
是一種開放標準 (RFC 7519
),它定義了一種簡潔的、自定義的方法,用於在雙方之間安全地傳輸資訊作為 JSON 對象
這些信息可以被驗證和信任,因為它是經過簽名的
JWT 由三部分組成,以點 (.) 分隔
Header
(標頭)Payload
(負載)Signature
(簽名)// 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'
先在 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());
}
}
建立一個 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);
}
}
更新 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();
}
}
建立一個新的 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 來測試
可以看到登入後,就有取得 JWT
把 JWT 拿到 jwt.io ,來看一下裡面的相關 payload
可以看到 subject 為 user
,還有相關的時間
把原本 API 的基本 HTTP 認證
(Basic Authentication)改為使用 JWT 認證
寫一個 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");
}
}
因為我們加入了 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 添加了一個無狀態的身份驗證系統
這種方法不僅提高了應用的安全性,還增強了其可擴展性
然而,在實際應用中,我們還需要考慮一些額外的安全措施
例如
我的粉絲專頁
圖片來源:AI 產生