Spring Boot JWT Authentication
JWT 簡介
JSON Web Token (JWT) 是一種開放標準 (RFC 7519),用於在各方之間安全地傳輸資訊。在 Spring Boot 中,JWT 常用於實現無狀態的身份驗證和授權。
JWT 結構
JWT 由三個部分組成,以點 (.) 分隔:
header.payload.signature
- Header:包含令牌類型和簽名演算法
- Payload:包含聲明 (claims),如用戶資訊和權限
- 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:
- 實施 Refresh Token 機制
- 在前端自動重新登入
- 提供明確的錯誤訊息
Q: 如何在前端儲存 JWT?
A:
- localStorage:簡單但存在 XSS 風險
- httpOnly Cookie:更安全但需處理 CSRF
- sessionStorage:關閉瀏覽器後清除