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 過期怎麼辦?#

  1. 短期 Token + Refresh Token(推薦)

    • Access Token 有效期短(如 15 分鐘)
    • Refresh Token 有效期長(如 7 天)
    • 前端定期使用 Refresh Token 取得新的 Access Token
  2. 長期 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 路由系統 了解前端頁面結構。