iT邦幫忙

2025 iThome 鐵人賽

DAY 12
0

今天是一個非常重要的日子,就是讓JWT正是組裝到我們的Spring Security框架中。雖然套件都已經做好,要使用也非常簡單,但整個原理我們已經融會貫通,相信到這裡,我們能夠真正的去理解,並且實作我們需要的內容。

今天要做什麼?

把「登入 → 拿到 Token → 用 Token 存取受保護 API」這條路打通。重點是 把 JWT 驗證接到 Spring Security 的 Filter 鏈上,做到 真正的無狀態(Stateless) 後端 API。

  • 使用者 POST /login 帳密 → 後端回一個 Access Token(JWT)
  • 之後前端在請求 Header 放上 Authorization: Bearer <token>
  • 後端的 JwtAuthenticationFilter 會驗證 Token、把使用者身分塞進 SecurityContext
  • 你就可以呼叫像 /hello 這種需要登入才看的 API

請求流程

  1. POST /login(帶帳密) → 成功則回傳 accessToken

  2. 客戶端在之後的請求 Header 帶上:

    Authorization: Bearer <accessToken>

  3. 每次請求先經過 JwtAuthenticationFilter

    → 驗證 Token

    → 驗證通過就把「使用者身分與角色」放進 SecurityContextHolder

  4. Controller 內就能透過 SecurityContext 取得登入使用者

程式碼與重點解說

1) 控制器:登入、示範受保護 API

重點

  • /login:用最簡單的假資料帳密(admin/password)示範,成功就回 Access Token
  • /hello:展示如何從 SecurityContext 拿到當前使用者(代表 Token 驗證成功才會拿得到)。
  • 另外預告 Day 13 的 /login-with-refresh/refresh(今天可以先忽略)。
package com.ansathsean.security;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jws;

import java.util.Map;

@RestController
public class AuthController {

	// Day12: 保留舊的登入方式(只回 Access Token)
    @PostMapping("/login")
    public String login(@RequestBody Map<String, String> request) {
        String username = request.get("username");
        String password = request.get("password");

        if ("admin".equals(username) && "password".equals(password)) {
            return JwtUtil.generateToken(username, "ROLE_ADMIN");
        }
        return "帳號或密碼錯誤";
    }

    @GetMapping("/hello")
    public String hello() {
        return "Hello, " +
            (org.springframework.security.core.context.SecurityContextHolder.getContext()
                .getAuthentication().getName());
    }

    // Day13: 新增支援 Refresh Token 的登入方式
    @PostMapping("/login-with-refresh")
    public ResponseEntity<?> loginWithRefresh(@RequestBody Map<String, String> request) {
        String username = request.get("username");
        String password = request.get("password");

        if ("admin".equals(username) && "password".equals(password)) {
            String accessToken = JwtUtil.generateAccessToken(username, "ROLE_ADMIN");
            String refreshToken = JwtUtil.generateRefreshToken(username);

            return ResponseEntity.ok(Map.of(
                    "accessToken", accessToken,
                    "refreshToken", refreshToken
            ));
        }

        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(Map.of("error", "帳號或密碼錯誤"));
    }

    @PostMapping("/refresh")
    public ResponseEntity<?> refresh(@RequestBody Map<String, String> request) {
        String refreshToken = request.get("refreshToken");

        try {
            // 驗證 Refresh Token
            Jws<Claims> claims = JwtUtil.validateToken(refreshToken);
            String username = claims.getBody().getSubject();

            // 這裡可以加上角色資訊,或直接重新查資料庫
            String newAccessToken = JwtUtil.generateAccessToken(username, "ROLE_ADMIN");

            //回傳新的 Access Token(可選擇是否更新 Refresh Token)
            return ResponseEntity.ok(Map.of(
                    "accessToken", newAccessToken,
                    "refreshToken", refreshToken // 可以重複使用原本的 refresh,或換新
            ));

        } catch (ExpiredJwtException e) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body(Map.of("error", "Refresh Token 過期,請重新登入"));
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body(Map.of("error", "Refresh Token 無效"));
        }
    }
}

你會看到:

  • /login 直接回傳字串(就是 JWT),簡單好測。
  • /hello 會回傳 Hello, <username>,如果沒帶 Token 或 Token 無效,進不到這裡。

2) 過濾器:攔截每個請求,驗證 JWT

重點

  • 從 Header 讀 Authorization,必須以 Bearer 開頭。
  • 成功解析後建立 UsernamePasswordAuthenticationToken,並把「角色」也放進去。
  • 把這個 Authentication 塞進 SecurityContextHolder,後面 Controller 就能知道「你是誰」。
package com.ansathsean.security;

import java.io.IOException;
import java.util.List;

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jws;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {
        String authHeader = request.getHeader("Authorization");

        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);
            try {
                // 驗證 Token
                Jws<Claims> claimsJws = JwtUtil.validateToken(token);
                String username = claimsJws.getBody().getSubject();
                String role = claimsJws.getBody().get("role", String.class);

                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(username, null, List.of(() -> role));
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                //  成功驗證 → 注入 Security Context
                SecurityContextHolder.getContext().setAuthentication(authentication);

            } catch (ExpiredJwtException e) {
                //  Token 過期
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                response.getWriter().write("Token Expired");
                return;
            } catch (Exception e) {
                //  其他驗證錯誤
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                response.getWriter().write("Invalid Token");
                return;
            }
        }

        // 繼續後續 Filter / Controller
        filterChain.doFilter(request, response);
    }
}

3) 工具類:簽發與驗證 JWT

重點

  • 使用 io.jsonwebtoken(jjwt)生成與解析 Token。
  • SECRET 至少 32 bytes(示範用,正式上線要改成環境變數)。
  • 這裡示範了三種 Token 產生:
    • generateToken(Day12 用,20 秒有效,方便測試過期)
    • generateAccessToken(1 分鐘有效,Day13 用)
    • generateRefreshToken(7 天有效,Day13 用)
package com.ansathsean.security;

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;

import java.security.Key;
import java.util.Date;
import java.util.Map;

public class JwtUtil {
    private static final String SECRET = "mySecretKeymySecretKeymySecretKey"; // 至少 32 bytes
    private static final Key key = Keys.hmacShaKeyFor(SECRET.getBytes());

    public static String generateToken(String username, String role) {
        return Jwts.builder()
                .setSubject(username)
                .addClaims(Map.of("role", role))
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + 20000)) // 20 秒過期
                //.setExpiration(new Date(System.currentTimeMillis() + 60 * 60 * 1000)) // 1 小時過期
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    public static Jws<Claims> validateToken(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token);
    }

    public static String generateAccessToken(String username, String role) {
        return Jwts.builder()
                .setSubject(username)
                .addClaims(Map.of("role", role))
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + 60 * 1000)) // 1 分鐘有效
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    public static String generateRefreshToken(String username) {
        return Jwts.builder()
                .setSubject(username)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000)) // 7 天有效
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }
}

4) Spring Security 設定:無狀態 + 掛上 JWT 過濾器

重點

  • 關閉 CSRF(純後端 API 常見做法)
  • SessionCreationPolicy.STATELESS:不建立/不使用 Session
  • JwtAuthenticationFilter 加到 UsernamePasswordAuthenticationFilter 之前
package com.ansathsean.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter();

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(auth -> auth
                                .requestMatchers("/login").permitAll()
                                .requestMatchers("/login-with-refresh").permitAll()
                                .anyRequest().authenticated()
                )
                .sessionManagement(management -> management.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }
}


如何測試(Postman / curl)

  1. 登入拿 Token
curl -X POST http://localhost:8080/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"password"}'

成功會回你一串字串(就是 JWT)。Day12 版本有效期只有 20 秒,方便你馬上測試過期情境。

  1. 帶 Token 打受保護 API
curl http://localhost:8080/hello \
  -H "Authorization: Bearer <把剛剛拿到的 token 貼上來>"

預期回應:

Hello, admin
  1. 測試過期
  • 等 20 秒再打 /hello → 預期回 401Token Expired

Postman的部分先前已經教過了,這次想要讓大家使用不同的方法,就是用curl的方式,基本上跟Postman很像,但是是用cmd,在windows跟mac上都可以使用,歡迎大家可以手動來測試看看,直接複製上面的指令貼到cmd/終端機上面試試!

常見錯誤排查

  • 401 Invalid Token / Token Expired
    • 檢查 Header:是否有 Authorization: Bearer <token>,注意 Bearer 後面有空白。
    • Token 是否已過期(Day12 刻意設很短)。
    • 啟動時是否用了不同的 SECRET(簽發與驗證必須用同一把金鑰)。
  • 一直拿不到使用者名稱
    • 確認 JwtAuthenticationFilter 有被註冊進 Security Filter Chain(addFilterBefore)。
    • 確認 SecurityContextHolder.getContext().getAuthentication() 不是 null
  • 角色沒生效/未來想用 @PreAuthorize
    • 目前示範把角色簡單放成 List.of(() -> role)。若要支援 hasRole('ADMIN') 等等,建議把角色前綴一致(如 ROLE_ADMIN),並加上 @EnableMethodSecurity@PreAuthorize(之後章節可擴充)。

今日所學

  • 懂了 JWT 與 Spring Security 的整合點Filter 先驗證、再把身分塞進 SecurityContext
  • 做到 Stateless API:不依賴 Session,每次請求都靠 Token 自給自足。
  • 會測:先 /login,後續請求帶 Bearer Token,能進 /hello 就代表流程成功。

注意事項

  • Access Token 有效期建議短一些(數分鐘),搭配 Refresh Token(Day 13 會補上)。
  • 若要「強制登出/撤銷 Token」,需加黑名單或 Token 版本號策略(之後章節可談)。

今天的程式看起來跟之前差不多,但是整個技術的實力完全提升了一級,到這裡,我相信大家就可以更加了解整個Security的內容,到這裡,也算是把當代的所有防護都放了上了,非常恭喜大家到了這個地步。但這個其實只是開始,接下來就是要深入更多的知識,讓我們的安全網更上一層。

大家今天辛苦了,我們明天見!


上一篇
Day 11 使用 jjwt 或 java-jwt 套件生成與驗證 JWT
下一篇
Day 13 JWT 過期與 Refresh Token 機制
系列文
「站住 口令 誰」關於資安權限與授權的觀念教學,以Spring boot Security框架實作13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言