Security 配置詳解#
本專案使用 Spring Security + JWT (JSON Web Token) 實現無狀態認證。這章節說明認證流程和配置方式。
檔案位置#
backend/src/main/java/tw/waterballsa/academy/
├── config/
│ └── SecurityConfig.java # 安全配置主檔
├── security/
│ ├── JwtUtil.java # JWT 工具類
│ ├── JwtAuthenticationFilter.java # JWT 過濾器
│ └── CustomUserDetailsService.java # 用戶載入服務認證流程#
┌─────────────┐ POST /api/auth/login ┌─────────────┐
│ Client │ ─────────────────────────> │ Backend │
│ (Frontend) │ │ │
│ │ <───────────────────────── │ │
│ │ { token: "eyJhb..." } │ │
└─────────────┘ └─────────────┘
儲存 token 到 localStorage
┌─────────────┐ GET /api/users/me ┌─────────────┐
│ Client │ Authorization: │ Backend │
│ │ Bearer eyJhb... │ │
│ │ ─────────────────────────> │ │
│ │ │ JWT Filter │
│ │ <───────────────────────── │ 驗證 token │
│ │ { user data } │ │
└─────────────┘ └─────────────┘SecurityConfig - 安全配置#
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final CorsConfigurationSource corsConfigurationSource;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 1. 啟用 CORS
.cors(cors -> cors.configurationSource(corsConfigurationSource))
// 2. 停用 CSRF(因為用 JWT,不需要 CSRF protection)
.csrf(csrf -> csrf.disable())
// 3. 無狀態 Session(不使用 HTTP Session)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 4. 設定端點權限
.authorizeHttpRequests(auth -> auth
// 公開端點(不需要登入)
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/leaderboard/**").permitAll()
.requestMatchers("/api/courses/**").permitAll()
.requestMatchers("/api/missions/**").permitAll()
.requestMatchers("/api/gyms/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/uploads/**").permitAll()
// 需要登入的端點
.requestMatchers("/api/challenges/**").authenticated()
.requestMatchers("/api/submissions/**").authenticated()
.requestMatchers("/api/grade/**").authenticated()
// 其他所有端點都需要登入
.anyRequest().authenticated()
)
// 5. 加入 JWT 過濾器
.addFilterBefore(jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
// 密碼編碼器(BCrypt)
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}Django 對照:
# Django REST Framework
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticatedOrReadOnly',
],
}
# urls.py
urlpatterns = [
path('api/auth/', include('auth.urls')), # 公開
path('api/protected/', ProtectedView.as_view()), # 需登入
]端點權限說明#
| 方法 | 說明 | Django 對照 |
|---|---|---|
permitAll() | 任何人都可以存取 | AllowAny |
authenticated() | 必須登入 | IsAuthenticated |
hasRole("ADMIN") | 必須有特定角色 | IsAdminUser |
hasAuthority("...") | 必須有特定權限 | 自訂 Permission |
JwtUtil - JWT 工具類#
@Component
public class JwtUtil {
@Value("${jwt.secret}") // 從設定檔讀取
private String secret;
@Value("${jwt.expiration}") // 過期時間(毫秒)
private Long expiration;
// 取得簽名金鑰
private SecretKey getSigningKey() {
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
// 產生 Token
public String generateToken(String email) {
return Jwts.builder()
.subject(email) // 主體(用戶識別)
.issuedAt(new Date()) // 簽發時間
.expiration(new Date(System.currentTimeMillis() + expiration)) // 過期時間
.signWith(getSigningKey()) // 簽名
.compact();
}
// 從 Token 取出 Email
public String extractEmail(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
}
// 驗證 Token 是否有效
public boolean validateToken(String token) {
try {
Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token);
return true;
} catch (JwtException e) {
return false; // 過期、簽名錯誤等
}
}
}JWT Token 結構#
eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiaWF0IjoxNzA5MTIzNDU2LCJleHAiOjE3MDkyMDk4NTZ9.xxxxx
Header.Payload.Signature
Header (演算法):
{
"alg": "HS384"
}
Payload (資料):
{
"sub": "test@example.com", // subject: 用戶 email
"iat": 1709123456, // issued at: 簽發時間
"exp": 1709209856 // expiration: 過期時間
}
Signature (簽名):
HMACSHA384(base64(header) + "." + base64(payload), secret)JwtAuthenticationFilter - JWT 過濾器#
每個請求都會經過這個過濾器:
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 1. 取得 Authorization header
final String authHeader = request.getHeader("Authorization");
// 2. 檢查是否有 Bearer token
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return; // 沒有 token,繼續處理(可能是公開端點)
}
// 3. 取出 token(去掉 "Bearer " 前綴)
final String jwt = authHeader.substring(7);
try {
// 4. 驗證 token
if (jwtUtil.validateToken(jwt)) {
// 5. 取出 email
String email = jwtUtil.extractEmail(jwt);
// 6. 載入用戶資料
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
// 7. 建立認證物件
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
// 8. 設定到 SecurityContext
SecurityContextHolder.getContext().setAuthentication(authToken);
}
} catch (Exception e) {
// Token 無效,作為匿名用戶繼續
logger.debug("JWT authentication failed: " + e.getMessage());
}
// 9. 繼續處理請求
filterChain.doFilter(request, response);
}
}流程圖:
Request 進來
↓
有 Authorization header?
│
├─ 否 → 繼續(匿名用戶)
│
└─ 是 → 有 Bearer 前綴?
│
├─ 否 → 繼續(匿名用戶)
│
└─ 是 → 驗證 Token
│
├─ 失敗 → 繼續(匿名用戶)
│
└─ 成功 → 設定認證
↓
繼續處理請求在 Controller 中使用認證資訊#
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/me")
public ResponseEntity<UserDto> getCurrentUser(Authentication authentication) {
// authentication 由 Spring Security 自動注入
// 取得 email(JWT 的 subject)
String email = authentication.getName();
// 查詢用戶
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new RuntimeException("User not found"));
return ResponseEntity.ok(convertToDto(user));
}
}判斷是否登入#
@GetMapping("/courses")
public ResponseEntity<List<Course>> getCourses(Authentication authentication) {
// 公開端點,authentication 可能是 null
if (authentication != null) {
// 已登入用戶
String email = authentication.getName();
// 顯示個人化內容...
} else {
// 未登入用戶
// 顯示公開內容...
}
}設定檔#
application.properties#
# JWT 設定
jwt.secret=your-256-bit-secret-key-here-must-be-at-least-32-characters
jwt.expiration=86400000 # 24 小時(毫秒)
# 或使用環境變數
# jwt.secret=${JWT_SECRET}
# jwt.expiration=${JWT_EXPIRATION:86400000}Docker 環境變數#
# docker-compose.yml
services:
backend:
environment:
- JWT_SECRET=your-super-secret-key-at-least-32-characters
- JWT_EXPIRATION=86400000前端整合#
登入並儲存 Token#
// 登入
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await response.json();
// 儲存 Token
localStorage.setItem('token', data.token);請求時帶上 Token#
const token = localStorage.getItem('token');
const response = await fetch('/api/users/me', {
headers: {
'Authorization': `Bearer ${token}`, // 重要!
'Content-Type': 'application/json',
},
});登出#
// 清除 Token
localStorage.removeItem('token');
// 清除前端狀態
useUserStore.getState().logout();CORS 配置#
允許前端跨域請求:
// config/CorsConfig.java
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 允許的來源
configuration.setAllowedOrigins(Arrays.asList(
"http://localhost:3000",
"http://localhost:3001",
"https://yourdomain.com"
));
// 允許的 HTTP 方法
configuration.setAllowedMethods(Arrays.asList(
"GET", "POST", "PUT", "DELETE", "OPTIONS"
));
// 允許的 Header
configuration.setAllowedHeaders(Arrays.asList(
"Authorization",
"Content-Type"
));
// 允許帶上 Cookie
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
}Django 對照:
# settings.py
CORS_ALLOWED_ORIGINS = [
"http://localhost:3000",
"https://yourdomain.com",
]
CORS_ALLOW_CREDENTIALS = True常見問題#
Q: 為什麼停用 CSRF?#
JWT 是無狀態認證,Token 儲存在 Authorization header 而不是 Cookie。CSRF 攻擊主要針對 Cookie 認證,所以使用 JWT 時可以安全地停用 CSRF。
Q: Token 過期怎麼辦?#
短期 Token + Refresh Token(推薦)
- Access Token 有效期短(如 15 分鐘)
- Refresh Token 有效期長(如 7 天)
- 前端定期使用 Refresh Token 取得新的 Access Token
長期 Token(本專案目前使用)
- Token 有效期 24 小時
- 過期後需要重新登入
Q: 如何保護敏感端點?#
在 SecurityConfig 中設定:
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/teacher/**").hasAnyRole("TEACHER", "ADMIN")Q: 密碼如何加密?#
使用 BCrypt 演算法:
// 加密
String encoded = passwordEncoder.encode("password123");
// 結果類似: $2a$10$xxx...
// 驗證
boolean matches = passwordEncoder.matches("password123", encoded);下一步#
後端架構都了解後,接下來看 Frontend 路由系統 了解前端頁面結構。