iT邦幫忙

2025 iThome 鐵人賽

DAY 10
0

內文

在前幾篇文章中,我們已經理解了 JWT 的結構(Header、Payload、Signature),以及它如何被應用在 Web 的身份驗證機制上。

今天我們將進一步練習,嘗試完全不用任何簡單的框架,直接用Java 原生工具(Base64 + HMAC-SHA256)來手動實作一個最小可行的 JWT。

這樣做的目的,是讓讀者徹底理解:

簽名(Signature)不是黑魔法,而是透過「演算法」與「金鑰」計算出來的字串。

如果要看到詳細的程式碼,可以使用這個Branch:

https://github.com/AnsathSean/spring-security-30days/tree/day10-ManualJWT

那我們就繼續開始吧。

1. JWT 的結構回顧

首先,我們要來回顧一下JWT,JWT 的格式固定為:

Header.Payload.Signature
  • Header:描述演算法(例如 HS256)與類型(JWT
  • Payload:放置使用者資訊(subject、role、iat...)
  • Signature:用 Header + Payload 經過 HMAC-SHA256 計算後產生,確保 資料沒有被竄改

例如一個簡單的 Token:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcyNTE3NTI4OX0
.0rjP7kpC6JgGkIhM1iZHZwqHg-5T5wLvgdT73lBlGxk

2. 程式碼實作

這次我們會分成三個部分:

  1. JwtUtil → 生成與驗證 Token
  2. JwtFilter → 過濾請求,檢查是否攜帶正確的 Token
  3. AuthController → 提供登入與測試 API

(1) JwtUtil:生成與驗證 Token

package com.ansathsean.security;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;

public class JwtUtil {

    private static final String SECRET = "mySecretKey";
    private static final ObjectMapper mapper = new ObjectMapper();

    // 生成 JWT
    public static String generateToken(Map<String, Object> payload) throws Exception {
        // Header
        String headerJson = mapper.writeValueAsString(Map.of("alg", "HS256", "typ", "JWT"));
        String headerEncoded = base64UrlEncode(headerJson.getBytes(StandardCharsets.UTF_8));

        // Payload
        String payloadJson = mapper.writeValueAsString(payload);
        String payloadEncoded = base64UrlEncode(payloadJson.getBytes(StandardCharsets.UTF_8));

        // 簽名
        String data = headerEncoded + "." + payloadEncoded;
        String signature = hmacSha256(data, SECRET);

        return data + "." + signature;
    }

    // 驗證 JWT
    public static boolean validateToken(String token) throws Exception {
        String[] parts = token.split("\\.");
        if (parts.length != 3) return false;

        String data = parts[0] + "." + parts[1];
        String signature = parts[2];

        String expectedSignature = hmacSha256(data, SECRET);

        return expectedSignature.equals(signature);
    }

    // 解析 Payload
    public static Map<String, Object> getPayload(String token) throws Exception {
        String[] parts = token.split("\\.");
        if (parts.length != 3) throw new IllegalArgumentException("Invalid JWT format");

        String payloadJson = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8);
        return mapper.readValue(payloadJson, new TypeReference<Map<String, Object>>() {});
    }

    // 取出 sub
    public static String getSubject(String token) throws Exception {
        return (String) getPayload(token).get("sub");
    }

    // 取出 role
    public static String getRole(String token) throws Exception {
        return (String) getPayload(token).get("role");
    }

    private static String base64UrlEncode(byte[] bytes) {
        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
    }

    private static String hmacSha256(String data, String secret) throws Exception {
        Mac mac = Mac.getInstance("HmacSHA256");
        SecretKeySpec secretKeySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
        mac.init(secretKeySpec);
        return base64UrlEncode(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
    }
}

👉 在這裡,簽名的生成過程就是:

Signature = HMACSHA256( base64UrlEncode(Header) + "." + base64UrlEncode(Payload), SECRET )

(2) JwtFilter:檢查請求是否攜帶 JWT

package com.ansathsean.security;

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

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

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

@Component
public class JwtFilter extends OncePerRequestFilter {

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

        String path = request.getRequestURI();

        if ("/login".equals(path)) {
            filterChain.doFilter(request, response);
            return;
        }

        String authHeader = request.getHeader("Authorization");
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);
            try {
                if (JwtUtil.validateToken(token)) {
                    // 從 token 解析出使用者資訊
                    String username = JwtUtil.getSubject(token);
                    String role = JwtUtil.getRole(token); // 假設你有把角色存進去

                    // 建立 Authentication
                    UsernamePasswordAuthenticationToken authentication =
                            new UsernamePasswordAuthenticationToken(
                                    username,
                                    null,
                                    List.of(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
                            );

                    // 放進 SecurityContext
                    SecurityContextHolder.getContext().setAuthentication(authentication);

                    filterChain.doFilter(request, response);
                    return;
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.getWriter().write("Invalid or missing JWT");
    }
}

(3) AuthController:登入與測試 API

package com.ansathsean.security;

import java.util.Map;
import org.springframework.web.bind.annotation.*;

@RestController
public class AuthController {
    @PostMapping("/login")
    public String login(@RequestBody Map<String, String> request) throws Exception {
        String username = request.get("username");
        String password = request.get("password");

        // 模擬帳密驗證
        if ("admin".equals(username) && "password".equals(password)) {
            return JwtUtil.generateToken(Map.of(
                    "sub", username,
                    "role", "admin",
                    "iat", System.currentTimeMillis() / 1000
            ));
        }
        return "帳號或密碼錯誤";
    }

    @GetMapping("/hello")
    public String hello() {
        return "Hello, JWT!";
    }
}


3. 驗證流程

  1. 登入
    • POST /login
    • 輸入正確帳密 admin / password → 會回傳一個 JWT。

https://ithelp.ithome.com.tw/upload/images/20250919/20152864rtNemkGdHp.png

  1. 攜帶 Token 訪問 API
    • GET /hello

    • 在 Header 加上:

      Authorization: Bearer <你的JWT>
      
    • 如果驗證成功,會回傳:

      Hello, JWT!
      

https://ithelp.ithome.com.tw/upload/images/20250919/201528644acTIgvyyK.png

  1. 錯誤情況
    • Token 缺失或簽名不正確 → HTTP 401 Unauthorized。

https://ithelp.ithome.com.tw/upload/images/20250919/20152864rLI40Eb3nQ.png


結論與思考

今天我們完成了 「手工打造 JWT」 的流程,我們可以從中學到以下幾點:

  • 你可以清楚地看到 Header 與 Payload 只是 Base64 編碼,任何人都能解碼內容。
  • 唯一能保護資料不被竄改的,就是 Signature。只要伺服器掌握正確的 SECRET,就能檢查 Token 是否可信。

這樣的基礎實作,幫助我們真正理解 JWT 的內部機制。這個JWT機制非常的複雜,有手工刻的部分,其實也使用到了Spring boot Security,建議大家先啟動,測試通過之後,再來思考一下這個JWT的設計邏輯。

結束了今天的教學後,接下來我們會進一步引入 Spring Security 與 JWT 整合,讓程式碼更乾淨、更安全,也符合業界的最佳實踐。

打完收工,我們明天見!


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

尚未有邦友留言

立即登入留言