跳至主要内容

Spring Boot JWT Authentication

JWT 簡介

JSON Web Token (JWT) 是一種開放標準 (RFC 7519),用於在各方之間安全地傳輸資訊。在 Spring Boot 中,JWT 常用於實現無狀態的身份驗證和授權。

JWT 結構

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

header.payload.signature
  1. Header:包含令牌類型和簽名演算法
  2. Payload:包含聲明 (claims),如用戶資訊和權限
  3. Signature:用於驗證令牌的完整性

專案設定

Maven 依賴

pom.xml 中添加必要的依賴:

<dependencies>
<!-- Spring Boot Starter Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- Spring Boot Starter Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

<!-- JWT Library -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>

<!-- Spring Boot Starter Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<!-- H2 Database -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>

JWT 工具類別

JwtUtil.java

package com.example.demo.util;

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

@Component
public class JwtUtil {

@Value("${jwt.secret:mySecretKey}")
private String secret;

@Value("${jwt.expiration:86400000}") // 24 hours
private Long expiration;

private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(secret.getBytes());
}

// 生成 JWT Token
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
return createToken(claims, userDetails.getUsername());
}

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))
.signWith(getSigningKey())
.compact();
}

// 從 Token 中提取用戶名
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}

// 從 Token 中提取過期時間
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}

public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}

private Claims extractAllClaims(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}

// 檢查 Token 是否過期
public Boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}

// 驗證 Token
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
}

用戶實體和服務

User.java

package com.example.demo.model;

import jakarta.persistence.*;

@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(unique = true)
private String username;

private String password;

private String role;

// Constructors, getters, and setters
public User() {}

public User(String username, String password, String role) {
this.username = username;
this.password = password;
this.role = role;
}

// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }

public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }

public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }

public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
}

UserDetailsServiceImpl.java

package com.example.demo.service;

import com.example.demo.model.User;
import com.example.demo.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.core.userdetails.User.UserBuilder;
import org.springframework.stereotype.Service;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

@Autowired
private UserRepository userRepository;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));

UserBuilder builder = org.springframework.security.core.userdetails.User.withUsername(username);
builder.password(user.getPassword());
builder.roles(user.getRole());

return builder.build();
}
}

JWT 過濾器

JwtRequestFilter.java

package com.example.demo.filter;

import com.example.demo.service.UserDetailsServiceImpl;
import com.example.demo.util.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class JwtRequestFilter extends OncePerRequestFilter {

@Autowired
private UserDetailsServiceImpl userDetailsService;

@Autowired
private JwtUtil jwtUtil;

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

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

String username = null;
String jwtToken = null;

// JWT Token 格式: "Bearer token"
if (requestTokenHeader != null && requestTokenHeader.startsWith("Bearer ")) {
jwtToken = requestTokenHeader.substring(7);
try {
username = jwtUtil.extractUsername(jwtToken);
} catch (IllegalArgumentException e) {
logger.error("Unable to get JWT Token");
} catch (Exception e) {
logger.error("JWT Token has expired");
}
} else {
logger.warn("JWT Token does not begin with Bearer String");
}

// 驗證 Token
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);

if (jwtUtil.validateToken(jwtToken, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
chain.doFilter(request, response);
}
}

身份驗證 Controller

AuthController.java

package com.example.demo.controller;

import com.example.demo.model.User;
import com.example.demo.service.UserDetailsServiceImpl;
import com.example.demo.util.JwtUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

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

@Autowired
private AuthenticationManager authenticationManager;

@Autowired
private JwtUtil jwtUtil;

@Autowired
private UserDetailsServiceImpl userDetailsService;

@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) throws Exception {
try {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
loginRequest.getUsername(),
loginRequest.getPassword())
);
} catch (BadCredentialsException e) {
throw new Exception("Invalid credentials", e);
}

final UserDetails userDetails = userDetailsService
.loadUserByUsername(loginRequest.getUsername());
final String jwt = jwtUtil.generateToken(userDetails);

Map<String, String> response = new HashMap<>();
response.put("token", jwt);
response.put("username", userDetails.getUsername());

return ResponseEntity.ok(response);
}

@GetMapping("/profile")
public ResponseEntity<?> getProfile() {
// 這個 endpoint 需要驗證
Map<String, String> response = new HashMap<>();
response.put("message", "This is a protected endpoint");
return ResponseEntity.ok(response);
}
}

// 登入請求 DTO
class LoginRequest {
private String username;
private String password;

// Getters and Setters
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }

public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
}

Security 配置

SecurityConfig.java

package com.example.demo.config;

import com.example.demo.filter.JwtRequestFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

@Autowired
private JwtRequestFilter jwtRequestFilter;

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/auth/login").permitAll()
.requestMatchers("/h2-console/**").permitAll()
.anyRequest().authenticated()
)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);

http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);

return http.build();
}
}

使用範例

1. 登入獲取 Token

curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"username": "user",
"password": "password"
}'

回應:

{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"username": "user"
}

2. 使用 Token 存取受保護的 API

curl -X GET http://localhost:8080/api/auth/profile \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

最佳實踐

1. 安全性

  • 使用強密鑰:至少 256 位的隨機密鑰
  • 設定合理的過期時間:通常 15-30 分鐘
  • 使用 HTTPS:在生產環境中必須使用 HTTPS
  • 實施 Refresh Token:用於延長登入狀態

2. 錯誤處理

@ControllerAdvice
public class JwtExceptionHandler {

@ExceptionHandler(JwtException.class)
public ResponseEntity<?> handleJwtException(JwtException e) {
Map<String, String> error = new HashMap<>();
error.put("error", "Invalid JWT token");
error.put("message", e.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
}
}

3. 配置檔案

# application.yml
jwt:
secret: myVerySecretKeyThatIsAtLeast256BitsLong
expiration: 1800000 # 30 minutes

spring:
security:
user:
name: admin
password: admin123
roles: ADMIN

常見問題

Q: JWT vs Session 有什麼差別?

A:

  • JWT:無狀態、可擴展、跨域支援
  • Session:有狀態、伺服器端儲存、更容易撤銷

Q: 如何處理 Token 過期?

A:

  1. 實施 Refresh Token 機制
  2. 在前端自動重新登入
  3. 提供明確的錯誤訊息

Q: 如何在前端儲存 JWT?

A:

  • localStorage:簡單但存在 XSS 風險
  • httpOnly Cookie:更安全但需處理 CSRF
  • sessionStorage:關閉瀏覽器後清除

See Also