# 1. 基礎概念入門 ## 1. HTTP 與 TLS 1. 理論 1. HTTP 是「無狀態」的,伺服器不會自動記住用戶。 2. HTTPS 透過 TLS 加密,避免竊聽與中間人攻擊。 3. HSTS 強制瀏覽器只能走 HTTPS。 2. 實作:在 Nginx 加上 HSTS: ``` //nginx server { listen 443 ssl; server_name example.com; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"; } ``` 3. 檢查:用瀏覽器開發者工具(Network → Headers)確認 Strict-Transport-Security 是否存在。 ## 2. Cookie 與 Session 1. 理論 1. Cookie 屬性: 1. Secure:僅限 HTTPS 2. HttpOnly:JS 無法存取 3.SameSite:防止 CSRF 2. Session 管理: 1. 登入成功後應 重新產生 Session ID 2. 設定閒置逾時(如 30 分鐘) 2. 實作:LoginServlet.java ```java= @WebServlet("/login") public class LoginServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); if ("user".equals(username) && "1234".equals(password)) { // 登入成功 → 清掉舊 session,建立新 session HttpSession oldSession = req.getSession(false); if (oldSession != null) oldSession.invalidate(); HttpSession newSession = req.getSession(true); newSession.setMaxInactiveInterval(600); // 10 分鐘 newSession.setAttribute("username", username); resp.sendRedirect("welcome.jsp"); } else { resp.sendRedirect("login.jsp?error=true"); } } } ``` 3. web.xml ```xml= //xml <web-app> <session-config> <session-timeout>30</session-timeout> <!-- 30 分鐘 --> </session-config> </web-app> ``` 4. 測試 1. 部署到 Tomcat,訪問 http://localhost:8080/login.jsp 2. 登入成功 → 10 分鐘內閒置會自動登出 ## 3. 認證 vs 授權 1. 理論 1. 認證 Authentication:你是誰? 2. 授權 Authorization:你能做什麼? 3. 權限模型: 1. RBAC(角色基礎) 2. ABAC(屬性基礎,例如地點、時間) 3. Scopes/Claims(Token 中聲明) 2. 實作(檢查登入狀態) ```java= @WebServlet("/admin") public class AdminServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { HttpSession session = req.getSession(false); if (session == null || session.getAttribute("username") == null) { resp.sendRedirect("login.jsp"); } else { resp.getWriter().println("Welcome, Admin!"); } } } ``` ## 4. 密碼學與密碼儲存 1. 理論 1. 千萬不要存明碼 2. 使用 bcrypt/argon2 這類演算法 3. 每個帳號應有獨立 Salt 2. 實作:BCryptDemo.java ```java= import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; public class BCryptDemo { public static void main(String[] args) { BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(10); // cost=10 String rawPassword = "123456"; String hashed = encoder.encode(rawPassword); System.out.println("Raw: " + rawPassword); System.out.println("Hashed: " + hashed); System.out.println("Match: " + encoder.matches("123456", hashed)); } } ``` 3. 執行 ``` //bash mvn -q exec:java -Dexec.mainClass="BCryptDemo" ``` ## 5. Token 世界觀 1. JWT 結構 ``` //css header.payload.signature ``` 2. 範例程式碼:JwtExample.java ```java= import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import java.util.Date; public class JwtExample { public static void main(String[] args) { String key = "mySecretKey"; String jwt = Jwts.builder() .setSubject("user1") .setIssuer("demo-app") .setExpiration(new Date(System.currentTimeMillis() + 60000)) .signWith(SignatureAlgorithm.HS256, key) .compact(); System.out.println("JWT: " + jwt); } } ``` ## 6. 瀏覽器安全:CSRF / XSS / CORS 1. CSRF 防護 1. SameSite Cookie 2. CSRF Token 2. XSS 防護 1. 輸出前 HTML encode 2. 使用 textContent 而非 innerHTML 3. CORS 1. 僅允許指定來源 2. 不要 * + Credentials ## 7. 多因子驗證(MFA) 範例:TOTP ```java= // 用 Google Authenticator 類似的演算法產生一次性密碼 ``` ## 8. 章末檢核清單 1. HTTPS 全站強制 2. Cookie 有 Secure / HttpOnly / SameSite 3. Session 登入後會重生 ID 4. 密碼儲存使用 bcrypt 5. JWT 有效期設置合理 6. CSRF / XSS / CORS 防護到位 7. 日誌不記敏感資料 ## 9. 練習 ### 1. 概覽(5 件練習) 1. 修改 Session Timeout → 10 分鐘 2. 新增 /admin 頁面 → 需登入才能訪問 3. 將 JWT 演算法改為 RSA (RS256) 並示範 kid 金鑰輪替 4. 為表單加入 CSRF Token(伺服器端驗證) 5. 設定 CORS 只允許 http://localhost:3000 ### 2. 前置說明 / 約定 1. 假設專案目錄結構與先前 starter-kit 相同(module 分別:02-cookie-session, 03-passwords, 04-jwt, 05-csrf-xss, 06-cors)。 2. 下面所有 Java 類都放在相對 package:com.example.*,放在 src/main/java 下。 3. 若你使用 Tomcat:把每個 module mvn package 的 war 丟到 Tomcat webapps/ 即可。沒有 Tomcat 的話我也給出 Jetty 直接跑的指令。 4. 測試我用 curl 範例(必要時會演示如何抓 CSRF token)。 ### A. 02-cookie-session:Session timeout 10 分鐘、/admin、CSRF Token、登入流程(完整檔案) 把以下檔案放到 02-cookie-session 專案對應位置(覆寫原檔)。 #### 1. src/main/java/com/example/basics/LoginServlet.java ```java= package com.example.basics; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.UUID; @WebServlet(urlPatterns = {"/login", "/logout", "/me"}) public class LoginServlet extends HttpServlet { // 測試用硬編碼帳密(示範用,實務請改 DB + bcrypt) private static final String USER = "alice"; private static final String PASS = "123456"; // session idle timeout = 10 分鐘(秒) private static final int SESSION_TIMEOUT_SECONDS = 10 * 60; @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { // 提供 /me 與 /logout 的 GET 介面 resp.setCharacterEncoding("UTF-8"); String path = req.getServletPath(); if ("/logout".equals(path)) { HttpSession session = req.getSession(false); if (session != null) session.invalidate(); resp.getWriter().println("👋 已登出"); return; } else if ("/me".equals(path)) { HttpSession session = req.getSession(false); Object user = (session == null) ? null : session.getAttribute("user"); if (user == null) { resp.setStatus(401); resp.getWriter().println("未登入"); } else { resp.getWriter().println("目前使用者:" + user); } return; } // 其他 GET 不處理(login.jsp 直接作為靜態頁顯示) resp.setStatus(404); resp.getWriter().println("Not Found"); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { req.setCharacterEncoding("UTF-8"); resp.setCharacterEncoding("UTF-8"); // --- CSRF 驗證 --- HttpSession session = req.getSession(false); String sessionCsrf = (session == null) ? null : (String) session.getAttribute("csrfToken"); String requestCsrf = req.getParameter("csrfToken"); if (sessionCsrf == null || requestCsrf == null || !sessionCsrf.equals(requestCsrf)) { resp.setStatus(403); resp.getWriter().println("CSRF token invalid or missing"); return; } // --- end CSRF --- String username = req.getParameter("username"); String password = req.getParameter("password"); if (USER.equals(username) && PASS.equals(password)) { // 防 Session Fixation:先 invalidate 舊 session,再建立新 session if (session != null) session.invalidate(); HttpSession newSession = req.getSession(true); newSession.setAttribute("user", username); // 設定 idle timeout = 10 分鐘 newSession.setMaxInactiveInterval(SESSION_TIMEOUT_SECONDS); // 重新產生 CSRF token(登入後可以更新) String newCsrf = UUID.randomUUID().toString(); newSession.setAttribute("csrfToken", newCsrf); resp.getWriter().println("✅ 登入成功,Hi " + username + "(session timeout 10 分鐘)"); } else { resp.setStatus(401); resp.getWriter().println("❌ 帳號或密碼錯誤"); } } } ``` 說明(要點) 1. CSRF token 由 login.jsp / session 產生並放在 hidden 欄位,伺服器端在 doPost 驗證。 2. 登入成功前先驗 CSRF,通過才比對密碼 — 這可避免 CSRF 導致的未授權行為。 3. 登入時先 invalidate() 舊 session 再 getSession(true) 以防 Session Fixation。 4. setMaxInactiveInterval(SESSION_TIMEOUT_SECONDS) 設為 10 分鐘。 #### 2. src/main/java/com/example/basics/AdminServlet.java ```java= package com.example.basics; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; @WebServlet("/admin") public class AdminServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { HttpSession session = req.getSession(false); Object user = (session == null) ? null : session.getAttribute("user"); if (user == null) { // 未登入 -> 導回 login 頁 resp.sendRedirect("login.jsp"); return; } resp.setCharacterEncoding("UTF-8"); resp.getWriter().println("<html><body>"); resp.getWriter().println("<h2>Admin area</h2>"); resp.getWriter().println("<p>Welcome, " + user + "</p>"); resp.getWriter().println("<a href='logout'>Logout</a>"); resp.getWriter().println("</body></html>"); } } ``` 說明:簡單的訪問控制:只檢查 session 中是否存在 user attribute。實務上你會再檢查角色/權限。 #### 3. src/main/webapp/login.jsp(含 CSRF token) ```jsp= //jsp <%@ page session="true" %> <% // 取得或建立 CSRF token 存進 session String csrf = (String) session.getAttribute("csrfToken"); if (csrf == null) { java.util.UUID uuid = java.util.UUID.randomUUID(); csrf = uuid.toString(); session.setAttribute("csrfToken", csrf); } %> <!DOCTYPE html> <html> <head><meta charset="UTF-8"><title>Login</title></head> <body> <form method="post" action="login"> <div>帳號:<input type="text" name="username"></div> <div>密碼:<input type="password" name="password"></div> <input type="hidden" name="csrfToken" value="<%= csrf %>" /> <button type="submit">登入</button> </form> <a href="me">查看目前使用者</a> | <a href="admin">系統管理區</a> </body> </html> ``` 說明: 1. JSP 在被 GET 時,會把 csrfToken 放到 session 並寫到表單 hidden 欄位。 2. 重要:session cookie 要在 production 加 Secure; HttpOnly; SameSite(可由容器或反向代理設定)。 #### 4. src/main/webapp/WEB-INF/web.xml(Session timeout 設 10 分鐘) ``` <?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd" version="3.1"> <session-config> <session-timeout>10</session-timeout> <!-- 分鐘 --> </session-config> </web-app> ``` #### Build & Run(02-cookie-session) ##### 方法 A — 用 Tomcat(推薦生產測試) 1. cd 02-cookie-session 2. mvn package(會產生 target/cookie-session.war) 3. 把 target/cookie-session.war 複製到 Tomcat 的 webapps/,啟動 Tomcat(或直接在 IDE 中 Run)。 ##### 方法 B — 用 Jetty Maven plugin 快速測試 ``` //bash cd 02-cookie-session mvn org.eclipse.jetty:jetty-maven-plugin:9.4.48.v20220622:run # 服務會在 http://localhost:8080/(context 可能是 /, 視 pom 配置) ``` #### 測試(以 Tomcat context 假設為 /cookie-session) ##### 1. 測 CSRF protection(缺 token) ``` //bash # 嘗試直接 POST(沒有 token) -> 應該得到 403 curl -i -c cookie.txt -d "username=alice&password=123456" http://localhost:8080/cookie-session/login # 回應應包含 HTTP/1.1 403 並訊息 "CSRF token invalid or missing" ``` ##### 2. 正確流程(先 GET 抓 csrf,再 POST 帶 cookie 與 token) ``` //bash # 1) 先 GET login page 並保存 cookie curl -s -c cookie.txt http://localhost:8080/cookie-session/login.jsp -o loginpage.html # 2) 從 loginpage.html 解析 csrf token(Linux: grep+sed) TOKEN=$(grep -oP 'name="csrfToken" value="\K[^"]+' loginpage.html) echo "csrf: $TOKEN" # 3) 使用 cookie 與 token POST 登入 curl -i -b cookie.txt -c cookie.txt -d "username=alice&password=123456&csrfToken=${TOKEN}" http://localhost:8080/cookie-session/login # 回應應為登入成功訊息 # 4) 用 cookie 訪問 /admin(成功) curl -i -b cookie.txt http://localhost:8080/cookie-session/admin ``` ##### 3. 測 session timeout(10 分鐘) 登入後等待 >10 分鐘再訪問 /me 或 /admin,應失去 session(/me 回 401、/admin 跳回 login.jsp)。 ##### 4. 注意事項(02-cookie-session) 1. 現在 CSRF token 存在 session;若在 scale-out 環境(多台 app servers),session 需要共用(Redis 等)。 2. 切勿把 csrfToken 放到 URL 或用 GET 攜帶敏感資訊。 3. Cookie Flags(Secure/HttpOnly/SameSite)應在容器或 reverse proxy 設置,不能只靠前端。 ### B. 04-jwt:把 JWT 改為 RS256(RSA)並示範 kid 金鑰輪替 下面是完整的程式與金鑰產生指令。放到 04-jwt 模組。 #### 1. 產生 RSA 金鑰(在你的 shell 執行) ``` //bash mkdir -p keys && cd keys # 產生第一組私鑰/公鑰(rsa1) openssl genpkey -algorithm RSA -out rsa1_private_pkcs8.pem -pkeyopt rsa_keygen_bits:2048 openssl rsa -in rsa1_private_pkcs8.pem -pubout -out rsa1_public.pem # 產生第二組私鑰/公鑰(rsa2,模擬新 key) openssl genpkey -algorithm RSA -out rsa2_private_pkcs8.pem -pkeyopt rsa_keygen_bits:2048 openssl rsa -in rsa2_private_pkcs8.pem -pubout -out rsa2_public.pem # 回到專案根目錄 cd .. ``` 我們用 PKCS#8 格式的 private key (-----BEGIN PRIVATE KEY-----),方便 Java 直接解析。 #### 2. src/main/java/com/example/jwt/JwtRsaExample.java(完整) ```java= package com.example.jwt; import io.jsonwebtoken.*; import java.nio.file.Files; import java.nio.file.Paths; import java.security.KeyFactory; import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; import java.security.spec.*; import java.util.Base64; import java.util.Date; import java.util.HashMap; import java.util.Map; public class JwtRsaExample { private static RSAPrivateKey loadPrivateKey(String path) throws Exception { String pem = new String(Files.readAllBytes(Paths.get(path))).replaceAll("\\r|\\n", ""); pem = pem.replace("-----BEGIN PRIVATE KEY-----", "").replace("-----END PRIVATE KEY-----", ""); byte[] der = Base64.getDecoder().decode(pem); PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(der); KeyFactory kf = KeyFactory.getInstance("RSA"); return (RSAPrivateKey) kf.generatePrivate(spec); } private static RSAPublicKey loadPublicKey(String path) throws Exception { String pem = new String(Files.readAllBytes(Paths.get(path))).replaceAll("\\r|\\n", ""); pem = pem.replace("-----BEGIN PUBLIC KEY-----", "").replace("-----END PUBLIC KEY-----", ""); byte[] der = Base64.getDecoder().decode(pem); X509EncodedKeySpec spec = new X509EncodedKeySpec(der); KeyFactory kf = KeyFactory.getInstance("RSA"); return (RSAPublicKey) kf.generatePublic(spec); } public static void main(String[] args) throws Exception { RSAPrivateKey priv1 = loadPrivateKey("keys/rsa1_private_pkcs8.pem"); RSAPublicKey pub1 = loadPublicKey("keys/rsa1_public.pem"); RSAPrivateKey priv2 = loadPrivateKey("keys/rsa2_private_pkcs8.pem"); RSAPublicKey pub2 = loadPublicKey("keys/rsa2_public.pem"); Map<String, RSAPublicKey> keyset = new HashMap<>(); keyset.put("rsa1", pub1); keyset.put("rsa2", pub2); // 使用 rsa1 簽名 (kid = rsa1) String token1 = Jwts.builder() .setSubject("alice") .setIssuer("demo-app") .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 5 * 60 * 1000)) .setHeaderParam("kid", "rsa1") .signWith(priv1, SignatureAlgorithm.RS256) .compact(); System.out.println("=== token1 (signed with rsa1) ==="); System.out.println(token1); System.out.println(); // 使用 rsa2 簽名 (kid = rsa2) String token2 = Jwts.builder() .setSubject("bob") .setIssuer("demo-app") .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 5 * 60 * 1000)) .setHeaderParam("kid", "rsa2") .signWith(priv2, SignatureAlgorithm.RS256) .compact(); System.out.println("=== token2 (signed with rsa2) ==="); System.out.println(token2); System.out.println(); // 驗證 token1: 先 parse header 取得 kid,再從 keyset 中取回對應公鑰驗證 JwsHeader header1 = Jwts.parserBuilder().build().parseClaimsJws(token1).getHeader(); String kid1 = (String) header1.get("kid"); RSAPublicKey pubFor1 = keyset.get(kid1); try { Jws<Claims> claims1 = Jwts.parserBuilder() .setSigningKey(pubFor1) .build() .parseClaimsJws(token1); System.out.println("token1 verified; subject=" + claims1.getBody().getSubject()); } catch (JwtException e) { System.out.println("token1 verification failed: " + e.getMessage()); } // 驗證 token2 JwsHeader header2 = Jwts.parserBuilder().build().parseClaimsJws(token2).getHeader(); String kid2 = (String) header2.get("kid"); RSAPublicKey pubFor2 = keyset.get(kid2); try { Jws<Claims> claims2 = Jwts.parserBuilder() .setSigningKey(pubFor2) .build() .parseClaimsJws(token2); System.out.println("token2 verified; subject=" + claims2.getBody().getSubject()); } catch (JwtException e) { System.out.println("token2 verification failed: " + e.getMessage()); } // 嘗試用錯的 public key 驗證 token1 (應該失敗) try { Jwts.parserBuilder().setSigningKey(pub2).build().parseClaimsJws(token1); System.out.println("unexpectedly verified token1 with pub2"); } catch (JwtException e) { System.out.println("As expected, token1 cannot be verified with pub2: " + e.getMessage()); } } } ``` #### 3. pom.xml(04-jwt/pom.xml)應包含 jjwt 依賴(先前 starter-kit 已放入),若沒有請加: ```xml= //xml <dependencies> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> </dependencies> ``` #### 4. Build & Run(04-jwt) ``` //bash # 先建立 keys(如上述 openssl 指令),確保 keys/ 裡有 rsa1_private_pkcs8.pem, rsa1_public.pem, rsa2_*. cd 04-jwt mvn compile exec:java -Dexec.mainClass=com.example.jwt.JwtRsaExample ``` 你會看到:兩個 token(token1、token2)列印出來,並且解析驗證成功;嘗試用錯 key 驗證會失敗。 ##### 5. 為什麼用 RS256 + kid(實務要點) 1. 非對稱簽章:私鑰只給簽發者(auth server),公鑰給資源伺服器(resource server),降低祕密洩漏風險。 2. kid 支援 key rotation(可同時保留舊 key 以驗證既有未過期 token,並用新 key 簽發新 token)。實務上會發佈一個 JWKS endpoint(JSON Web Key Set),供驗證端定期拉取。 ##### 6. 生產注意: 1. 不要把 private key 放在專案裸檔。請使用 vault 或 KMS(AWS KMS / GCP KMS / Azure Key Vault),或以檔案外部注入,並限制檔案權限。 2. 若要撤銷 token,請將 Refresh Token 和 access token 設計為短期 + 使用黑名單/版本策略。 ### C. 06-cors:只允許 http://localhost:3000(完整 server code + 測試) 把以下 server 放到 06-cors/server/src/main/java/... 覆寫。 #### 1. CorsServer.java ```java= package com.example.cors; import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; import java.io.IOException; import java.io.OutputStream; import java.net.InetSocketAddress; import java.util.Set; public class CorsServer { private static final Set<String> ALLOWED_ORIGINS = Set.of("http://localhost:3000"); private static final String ALLOWED_METHODS = "GET, POST, OPTIONS"; private static final String ALLOWED_HEADERS = "Content-Type, Authorization"; public static void main(String[] args) throws Exception { HttpServer server = HttpServer.create(new InetSocketAddress(8081), 0); server.createContext("/api/echo", new EchoHandler()); server.start(); System.out.println("CORS Server on http://localhost:8081"); } static class EchoHandler implements HttpHandler { @Override public void handle(HttpExchange ex) throws IOException { Headers reqHeaders = ex.getRequestHeaders(); String origin = reqHeaders.getFirst("Origin"); Headers h = ex.getResponseHeaders(); boolean originAllowed = origin != null && ALLOWED_ORIGINS.contains(origin); if (originAllowed) { h.add("Access-Control-Allow-Origin", origin); h.add("Access-Control-Allow-Credentials", "true"); } h.add("Vary", "Origin"); h.add("Access-Control-Allow-Headers", ALLOWED_HEADERS); h.add("Access-Control-Allow-Methods", ALLOWED_METHODS); if ("OPTIONS".equalsIgnoreCase(ex.getRequestMethod())) { ex.sendResponseHeaders(204, -1); return; } byte[] body = "ok".getBytes(); ex.sendResponseHeaders(200, body.length); try (OutputStream os = ex.getResponseBody()) { os.write(body); } } } } ``` #### 2. Build & Run(06-cors) ``` //bash cd 06-cors/server mvn compile exec:java -Dexec.mainClass=com.example.cors.CorsServer # 或用 IDE 執行 main ``` #### 3. 測試(模擬 preflight 與實際請求) ##### 1. Preflight request (OPTIONS) — allowed origin ``` //bash curl -i -X OPTIONS http://localhost:8081/api/echo \ -H "Origin: http://localhost:3000" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: Content-Type" ``` 預期:204,headers 含 Access-Control-Allow-Origin: http://localhost:3000 以及 Access-Control-Allow-Credentials: true。 ##### 2. Preflight — not allowed origin ``` //bash curl -i -X OPTIONS http://localhost:8081/api/echo \ -H "Origin: http://evil.com" \ -H "Access-Control-Request-Method: POST" ``` 預期:回應沒有 Access-Control-Allow-Origin(瀏覽器會拒絕前端跨域請求)。 ##### 3. 實際 GET ``` //bash curl -i -X GET http://localhost:8081/api/echo -H "Origin: http://localhost:3000" ``` 預期:200 + Access-Control-Allow-Origin: http://localhost:3000 ##### 4. 為什麼這樣做 1. 只允許白名單 domain(http://localhost:3000),並在 response 中 echo 回 Origin,這是正確做法,能同時允許 Access-Control-Allow-Credentials: true。 2. 別用 * 搭配 Credentials(瀏覽器限制)。生產時把白名單外移到 config,或使用動態檢核。 ### D. 一次性把整個流程測試(整合範例) 下面示範一個完整整合測試流程(假設你已經啟動 02-cookie-session 在 Tomcat,context = /cookie-session,並啟動 06-cors 在 8081,04-jwt 可執行以產生 RSA token): 1. 開 http://localhost:8080/cookie-session/login.jsp(或用 curl 下載 loginpage.html,抓 csrfToken) 2. 用 cookie + csrfToken POST 到 /login,應得到登入成功。 3. 使用 cookie 訪問 /admin,應返回 admin 頁面。 4. 等超過 10 分鐘,訪問 /me 或 /admin 應失效(401 或 redirect 到 login)。 5. 執行 04-jwt program,得到 RSA-signed token (可在其他 microservice 驗證)。 6. 啟動 06-cors,用 http://localhost:3000 origin 在前端 fetch 成功;其他 origin 失敗。 ### E. 補充:如何用 Shell 自動抓 CSRF token(實務測試腳本) ``` //bash # 1) 抓 login page 並存 cookie curl -s -c cookie.txt http://localhost:8080/cookie-session/login.jsp -o loginpage.html # 2) 從 loginpage.html 擷取 CSRF token(Linux with grep -P) TOKEN=$(grep -oP 'name="csrfToken" value="\K[^"]+' loginpage.html) echo "Got CSRF: $TOKEN" # 3) POST 帶 cookie 與 token curl -i -b cookie.txt -c cookie.txt -d "username=alice&password=123456&csrfToken=${TOKEN}" http://localhost:8080/cookie-session/login # 4) 以 cookie 訪問 admin curl -i -b cookie.txt http://localhost:8080/cookie-session/admin ``` ### F. 生產 / 安全最佳實務(重要) 1. HTTPS & Cookie flags:在 Nginx 或 Tomcat 設 Set-Cookie 的 Secure; HttpOnly; SameSite=Lax(或適合你的 UX 的 SameSite)。 2. 密碼儲存:所有密碼在 DB 內 use bcrypt/argon2 with sufficient cost and per-user salt。 3. JWT keys:Private key 放在 KMS/Vault;公鑰透過 JWKS endpoint 公開給 resource servers。 4. Token 撤銷:Access Token 短期、Refresh Token 使用旋轉策略並可撤銷(維護一個 revocation list 或 token version)。 5. Session 在叢集:像使用 Redis session store 或 sticky sessions(但 sticky sessions 有可用性/可擴展疑慮)。 6. CSP / XSS:設定 Content-Security-Policy、HTML encode,並使用框架的防 XSS 工具。 7. 日誌:日誌不要寫明碼或 token;對敏感操作做審計。 # 2. Spring Security 快速上手:完整教學 1. 專案說明 1. 專案名稱:security-spring-security 2. 打包方式:war(部署至 Tomcat 7+) 3. 技術棧:Spring MVC、Spring Security、Servlet 3.0(無 web.xml) 2. 最終功能清單 1. /login:使用 Spring Security 內建登入頁(本章使用預設頁) 2. /login-success:登入成功後跳轉到這個端點 3. /r/**:受保護資源,必須登入後才可訪問 4. 兩位測試用戶(記憶體): 1. zhangsan / 123(授權:p1) 2. lisi / 456(授權:p2) 3. 專案結構(建議) ``` security-spring-security ├─ pom.xml ├─ src ├─ main ├─ java │ └─ com.example.security │ ├─ config │ │ ├─ ApplicationConfig.java │ │ ├─ WebConfig.java │ │ └─ WebSecurityConfig.java │ └─ init │ ├─ SpringApplicationInitializer.java │ └─ SpringSecurityApplicationInitializer.java ├─ resources └─ webapp └─ WEB-INF └─ views └─ login-success.jsp ``` 備註:本章使用 Spring Security 預設登入頁,故無需 login.jsp;若想自訂登入頁,請見附錄 A。 ## 1. Step 0|建立 Maven 專案(WAR) pom.xml(核心片段) ```xml= //pom.xml <project> <properties> <java.version>1.8</java.version> <spring.version>5.3.39</spring.version> <spring.security.version>5.8.12</spring.security.version> <javax.servlet.version>4.0.1</javax.servlet.version> </properties> <dependencies> <!-- Spring MVC --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <!-- Spring Security --> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-config</artifactId> <version>${spring.security.version}</version> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-web</artifactId> <version>${spring.security.version}</version> </dependency> <!-- Servlet API(Provided by container) --> <dependency> <groupId>jakarta.servlet</groupId> <artifactId>jakarta.servlet-api</artifactId> <version>${javax.servlet.version}</version> <scope>provided</scope> </dependency> <!-- JSP 支援(若使用 JSP 視圖) --> <dependency> <groupId>jakarta.servlet.jsp</groupId> <artifactId>jakarta.servlet.jsp-api</artifactId> <version>3.1.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-jasper</artifactId> <version>10.1.26</version> <scope>provided</scope> </dependency> <!-- JSTL(若使用 JSP) --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> </dependencies> </project> ``` 為了對齊新版容器,這裡示範使用 jakarta.servlet 套件命名。若你部署於舊版 Tomcat,請改回 javax.servlet 版本。 ## 2. Step 1|Spring 容器 & MVC 基礎設定(無 web.xml) ### 1-1. Spring 根容器(非 Web MVC) ApplicationConfig.java ```java= package com.example.security.config; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.EnableAspectJAutoProxy; @Configuration @ComponentScan(basePackages = "com.example.security") @EnableAspectJAutoProxy public class ApplicationConfig { } ``` 用來載入服務層、資料層等 Bean。 ### 1-2. Spring MVC 設定(視圖解析、靜態資源) WebConfig.java ```java= package com.example.security.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.ViewResolver; import org.springframework.web.servlet.config.annotation.EnableWebMvc; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.view.InternalResourceViewResolver; @Configuration @EnableWebMvc @ComponentScan(basePackages = "com.example.security") public class WebConfig implements WebMvcConfigurer { @Override public void addViewControllers(ViewControllerRegistry registry) { // 預設根路徑導向 /login(Spring Security 內建登入頁) registry.addViewController("/").setViewName("redirect:/login"); } @Bean public ViewResolver viewResolver() { InternalResourceViewResolver vr = new InternalResourceViewResolver(); vr.setPrefix("/WEB-INF/views/"); vr.setSuffix(".jsp"); return vr; } @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/static/**").addResourceLocations("/static/"); } } ``` 這裡用 ViewController 直接把 / 轉到 /login,方便一啟動就能看到登入頁。 ### 1-3. Servlet 3.0 無 web.xml 啟動 SpringApplicationInitializer.java(載入根與MVC容器) ```java= package com.example.security.init; import com.example.security.config.ApplicationConfig; import com.example.security.config.WebConfig; import org.springframework.web.servlet.support.AbstractAnnotationConfigDispatcherServletInitializer; public class SpringApplicationInitializer extends AbstractAnnotationConfigDispatcherServletInitializer { @Override protected Class<?>[] getRootConfigClasses() { return new Class<?>[]{ ApplicationConfig.class }; } @Override protected Class<?>[] getServletConfigClasses() { return new Class<?>[]{ WebConfig.class }; } @Override protected String[] getServletMappings() { return new String[]{ "/" }; } } ``` ## 3. Step 2|導入 Spring Security 這一步完成「用戶來源 + 安全攔截規則 + 表單登入」。 ### 2-1. 安全設定類 WebSecurityConfig.java: ```java= package com.example.security.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.crypto.password.NoOpPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @Configuration @EnableWebSecurity @SuppressWarnings("deprecation") // Demo 使用 NoOp(教學目的) public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Bean public UserDetailsService userDetailsService() { InMemoryUserDetailsManager mgr = new InMemoryUserDetailsManager(); mgr.createUser( User.withUsername("zhangsan").password("123").authorities("p1").build() ); mgr.createUser( User.withUsername("lisi").password("456").authorities("p2").build() ); return mgr; } @Bean public PasswordEncoder passwordEncoder() { // 教學用:純字串比對。實務請改用 BCrypt。 return NoOpPasswordEncoder.getInstance(); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService()).passwordEncoder(passwordEncoder()); } @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/", "/login", "/static/**").permitAll() .antMatchers("/r/**").authenticated() .anyRequest().permitAll() .and() .formLogin() .loginPage("/login") // 使用預設登入頁的 URL(框架自動提供) .defaultSuccessUrl("/login-success", true) .permitAll() .and() .logout().logoutUrl("/logout").logoutSuccessUrl("/login?logout").permitAll() .and() .csrf(); // 保持預設開啟(附錄 B 說明如何處理 403) } } ``` 關鍵解釋: 1. userDetailsService():這裡採 記憶體 用戶(最小可行)。實務會改成資料庫版本。 2. authorities("p1") / authorities("p2"):先用「權限字串」示範,稍後可延伸為角色/權限模型。 3. HttpSecurity 規則:/r/** 需登入;其他開放。表單登入成功導向 /login-success。 ### 2-2. 把 Security 啟動進 Servlet SpringSecurityApplicationInitializer.java: ```java= package com.example.security.init; import org.springframework.security.web.context.AbstractSecurityWebApplicationInitializer; public class SpringSecurityApplicationInitializer extends AbstractSecurityWebApplicationInitializer { // 空即可:父類已自動註冊 springSecurityFilterChain } ``` 這一步會自動在你的 DispatcherServlet 之前插入 SpringSecurityFilterChain。 ## 4. Step 3|Controller 與視圖 ### 3-1. 登入成功頁(Controller + JSP) 1. LoginController.java: ```java= package com.example.security.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ResponseBody; @Controller public class LoginController { @GetMapping("/login-success") @ResponseBody public String loginSuccess() { return "Login Success!"; } } ``` 為了最小化依賴,這裡先回傳字串。若你偏好 JSP,請把 @ResponseBody 拿掉,並在 /WEB-INF/views/ 放 login-success.jsp: 2. /WEB-INF/views/login-success.jsp ``` <!DOCTYPE html> <html> <head><meta charset="UTF-8"><title>Login Success</title></head> <body> <h2>Login Success</h2> <p>你已成功登入,歡迎回來。</p> <p><a href="/logout">Log Out</a></p> </body> </html> ``` ## 5. Step 4|執行與驗收 1. 部署:將 war 丟到 Tomcat webapps/,啟動伺服器。 2. 開啟:瀏覽器進入 http://localhost:8080/security-spring-security/ → 會自動導向 /login。 3. 測試帳密: 1. zhangsan / 123 → 登入後轉 /login-success 2. lisi / 456 → 同上 4. 受保護資源:登入後訪問 /r/hello(需自行建立 Controller),未登入時會被引導至 /login。 快速測試受保護端點: ```java= // com.example.security.controller.ResourceController @Controller @RequestMapping("/r") public class ResourceController { @GetMapping("/hello") @ResponseBody public String hello() { return "Hello Protected"; } } ``` # 3. Spring Security 進階與實戰(完整教學) ## 重要觀念 1. Modern config:Spring Security 自 5.7+ 建議以 SecurityFilterChain 與 component-style beans 取代 WebSecurityConfigurerAdapter。這讓配置更可組合、測試性更好。 (參考:Spring 官方)。 2. Stateful vs Stateless:傳統表單登入/Session 是有狀態的;REST API 常用 JWT(無狀態)。無狀態設計要 sessionCreationPolicy(SessionCreationPolicy.STATELESS),並處理 CSRF 與 CORS 的差異。 3. Role vs Authority:ROLE_ 前綴是 Spring 的慣例(hasRole("ADMIN") 等於 hasAuthority("ROLE_ADMIN"))。系統可用 GrantedAuthority 更細的權限字串。 4. Method security:把細粒度授權放在 service / controller 方法上通常更穩健(@PreAuthorize("hasRole('ADMIN') and #id == principal.id"))。 ## 範例前置:建議的 POM(關鍵依賴) ```xml= <!-- 片段 -> 需自行合併到你已有的 pom.xml --> <dependencies> <!-- Spring Web / MVC --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> </dependency> <!-- Spring Security core --> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-config</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-web</artifactId> </dependency> <!-- OAuth2 Resource Server (JWT support) --> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-resource-server</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-jose</artifactId> </dependency> <!-- JWT helper (選擇) -> JJWT 或 Nimbus 可以任一 --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <!-- JDBC / Data --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <!-- Password encoder (BCrypt already in security) --> </dependencies> ``` ## 1. 現代化安全設定(SecurityFilterChain) 1. 要點:不用再繼承 WebSecurityConfigurerAdapter,改為註冊 SecurityFilterChain 與 AuthenticationManager / UserDetailsService / PasswordEncoder 的 @Bean。 2. 範例:最小化的 SecurityFilterChain(含 stateless 與 stateful 範例) ```java= @Configuration @EnableWebSecurity @EnableMethodSecurity // 開啟方法層授權(之後章節會解釋) public class SecurityConfig { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public UserDetailsService userDetailsService() { // 簡單示範:記憶體 UserDetails u1 = User.withUsername("zhangsan").password(passwordEncoder().encode("123")).authorities("p1").build(); UserDetails u2 = User.withUsername("lisi").password(passwordEncoder().encode("456")).authorities("p2").build(); return new InMemoryUserDetailsManager(u1, u2); } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) // 例:API 用例(實務請評估) .authorizeHttpRequests(auth -> auth .requestMatchers("/public/**", "/login").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .formLogin(withDefaults()) .httpBasic(withDefaults()); return http.build(); } } ``` 3. 解說(逐步) 1. @EnableMethodSecurity:允許在方法上使用 @PreAuthorize、@PostAuthorize 等。與舊的 @EnableGlobalMethodSecurity 相比,為官方建議的替代方式。 2. PasswordEncoder:示例使用 BCryptPasswordEncoder(生產環境必備)。 3. SecurityFilterChain:使用 http DSL 設定授權規則、csrf、登入等,最後 http.build() 產生 Bean。 ## 2. 方法層級授權(Method-level Security) 1. 為何要用方法層級授權:URL 層授權適合保護「路由」,但有時權限判斷應基於方法參數或業務邏輯(例如:只允許資源擁有者編輯),此時 @PreAuthorize 更合適。 2. 啟用與示例 ```java= @Configuration @EnableMethodSecurity(prePostEnabled = true) public class MethodSecurityConfig { } @Service public class AccountService { @PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id") public AccountDto getAccount(Long userId) { ... } } ``` 3. 解說 1. #userId 代表方法參數,authentication.principal 是當前使用者(你可以改成 principal.username 或自訂 UserDetails)。 2. Spring 在呼叫方法前會做安全檢查,若不通過將丟出 AccessDeniedException。 ## 3. UserDetails 與 JDBC(實務:連資料庫) 1. 建議資料表(示例:與 Spring 的 JDBC schema 類似) ```sql= CREATE TABLE users ( username VARCHAR(50) PRIMARY KEY, password VARCHAR(200) NOT NULL, enabled BOOLEAN NOT NULL ); CREATE TABLE authorities ( username VARCHAR(50) NOT NULL, authority VARCHAR(50) NOT NULL, CONSTRAINT fk_user FOREIGN KEY(username) REFERENCES users(username) ); CREATE UNIQUE INDEX ix_auth_username ON authorities (username, authority); ``` 2. 自訂 UserDetailsService 範例 ```java= @Service public class JdbcUserDetailsService implements UserDetailsService { private final JdbcTemplate jdbc; public JdbcUserDetailsService(JdbcTemplate jdbc) { this.jdbc = jdbc; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { UserRecord user = jdbc.queryForObject("select username,password,enabled from users where username=?", (rs, rowNum) -> new UserRecord(rs.getString("username"), rs.getString("password"), rs.getBoolean("enabled")), username); List<GrantedAuthority> auths = jdbc.query("select authority from authorities where username=?", (rs, rowNum) -> new SimpleGrantedAuthority(rs.getString("authority")), username); return new org.springframework.security.core.userdetails.User(user.username, user.password, user.enabled, true, true, true, auths); } } ``` 3. 與 DaoAuthenticationProvider 整合 ```java= @Bean public AuthenticationProvider daoAuthProvider(UserDetailsService uds, PasswordEncoder encoder) { DaoAuthenticationProvider p = new DaoAuthenticationProvider(); p.setUserDetailsService(uds); p.setPasswordEncoder(encoder); return p; } ``` ## 4. JWT(REST)— 兩種策略 核心選擇:如果你是 標準 OAuth2 / OpenID Connect 生態(Authorization Server exist),建議採用 Resource Server 自動驗證 JWT(官方支援)。若你需要完全自訂或沒有授權伺服器,則可用自訂 JWT filter(自行驗證 token)。 ### A. 推薦:Spring OAuth2 Resource Server(最少自寫驗證邏輯) 1. POM 依賴: ```xml= <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-resource-server</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-jose</artifactId> </dependency> ``` 2. application.yml 範例(使用 issuer-uri) ```xml= spring: security: oauth2: resourceserver: jwt: issuer-uri: https://example-issuer.com/ ``` 3. SecurityFilterChain(Resource Server) ```java= @Bean public SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/public/**").permitAll() .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); return http.build(); } ``` 4. 優點: 1. 官方支援 jwks 自動發現/驗證 2. 可直接把 claims 映射成 Authorities 3. 更少自訂程式碼,較安全 ### B. 教學/控制:自訂 JwtAuthenticationFilter(OncePerRequestFilter) 1. JwtFilter(概念) ```java= public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtService jwtService; // 你實作的 token 驗證/解析 @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String header = request.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { String token = header.substring(7); if (jwtService.validateToken(token)) { String username = jwtService.getUsername(token); List<GrantedAuthority> auths = jwtService.getAuthorities(token); UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(username, null, auths); SecurityContextHolder.getContext().setAuthentication(auth); } } filterChain.doFilter(request, response); } } ``` 2. 加入過濾器到 FilterChain ```java= http.addFilterBefore(new JwtAuthenticationFilter(jwtService), UsernamePasswordAuthenticationFilter.class); http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); ``` 3. 風險與注意 1. 自己負責簽章驗證、失效、演算法切換、鑰匙輪替(key rotation)等 2. 若使用 HMAC,伺服器需安全保存密鑰;若使用 RSA,需處理公鑰/私鑰與 JWKS ## 5 Remember-me(持久化 Token) 1. 原理:使用者勾選 "Remember me" 後,伺服器會發一個 token 存在 cookie,下一次可自動登入。 2. Persistent Token 範例資料表 ```sql= CREATE TABLE persistent_logins ( username VARCHAR(64) NOT NULL, series VARCHAR(64) PRIMARY KEY, token VARCHAR(64) NOT NULL, last_used TIMESTAMP NOT NULL ); ``` 2. 設定範例 ``` @Autowired DataSource dataSource; http .rememberMe(r -> r .tokenRepository(new JdbcTokenRepositoryImpl() {{ setDataSource(dataSource); }}) .tokenValiditySeconds(1209600) // 14 days .userDetailsService(userDetailsService) ); ``` 3. 注意 1. token 必須安全生成(不可預測),且需定期清理過期紀錄 2. persistent token 比單純 cookie 更安全,但仍有被盜用風險(配合 rotating tokens / device fingerprint 可加強) ## 6 Session 管理(Session Fixation、Concurrent Sessions) Session Fixation(預設保障):Spring Security 會在成功登入後替使用者建立新的 session id(session fixation protection)。若需自訂: ``` http.sessionManagement(sm -> sm.sessionFixation(SessionFixation ``` # 4. 身份與授權協定:OAuth2、OpenID Connect、SSO 與授權伺服器 ## 1. OAuth2 與 OIDC 核心概念(先讀) 1. 基本角色 1. Resource Owner:使用者(擁有受保護資源)。 2. Client:代表應用程式(例如 SPA、Web App 或後端服務)。 3. Authorization Server (AS):處理使用者認證、同意(consent)與 token 發放。 4. Resource Server (RS):實際提供受保護資源的 API(驗證 access token)。 2. 常見 Grant Types 1. Authorization Code(推薦,適用於 Web App + server-side、也可配 PKCE 用於 SPA) 2. Implicit(不推薦,因安全問題) 3. Resource Owner Password Credentials (ROPC)(僅限特定情境,已不建議) 4. Client Credentials(機器到機器,沒有使用者) 5. Refresh Token(用於換取新的 access token) 6. Device Flow(IoT / limited-input devices) 3. OIDC(OpenID Connect)關鍵差異 1. OIDC 在 OAuth2 基礎上新增 id_token(JWT),用於標示已驗證的身分(subject、issuer、aud 等 claim)。 2. userinfo endpoint:可請求更多 profile 資料。 ## 2. Spring Authorization Server(SAS)入門(建立你的授權伺服器) 注意:Spring 官方在 2022 之後提供 Spring Authorization Server(SAS)作為推薦方案(取代已退役的 Spring Security OAuth project)。 ### A. 最小可用範例(Spring Boot 3 + Spring Authorization Server) 1. pom.xml(片段) ```xml= <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId> <version>1.1.0</version> <!-- 對應當前 Spring Authorization Server 版本 --> </dependency> ``` 2. 基本設定與註冊 client ```java= @Configuration public class AuthorizationServerConfig { @Bean public RegisteredClientRepository registeredClientRepository(PasswordEncoder encoder) { RegisteredClient client = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("web-client") .clientSecret(encoder.encode("secret")) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .redirectUri("http://localhost:8080/login/oauth2/code/web-client-oidc") .scope(OidcScopes.OPENID) .scope("read") .build(); return new InMemoryRegisteredClientRepository(client); } @Bean public JWKSource<SecurityContext> jwkSource() { RSAKey rsaKey = Jwks.generateRsa(); // helper to generate RSA key pair JWKSet jwkSet = new JWKSet(rsaKey); return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet); } @Bean public ProviderSettings providerSettings() { return ProviderSettings.builder().issuer("http://auth-server:9000").build(); } } ``` 3. 啟動 AuthorizationServer endpoints 1. 授權端點(/oauth2/authorize) 2. token endpoint(/oauth2/token) 3. jwks endpoint(/oauth2/jwks) 4. OIDC 的 /.well-known/openid-configuration 4. 解說 1. RegisteredClient:代表註冊在授權伺服器的 client(client_id, client_secret, grant types, redirect uris, scopes)。 2. JWKSource:用於簽發 JWT token 的金鑰來源(建議 RSA)、/oauth2/jwks 會對外公開公鑰。 3. ProviderSettings.issuer:很重要,JWT 的 iss claim 會帶上此值,Resource Server 與 clients 可基於此值發現 JWKS。 ## 3. Authorization Code Flow(含 PKCE)範例(完整流程) ### A. Authorization Code(Server-side web app) 1. 使用者在 Client(Web App)點擊「使用者登入」→ 導向 AS 的 /oauth2/authorize?response_type=code&client_id=...&redirect_uri=...&scope=openid%20read。 2. AS 顯示登入頁面(及 consent 頁)→ 使用者同意。 3. AS redirect 回 Client 的 redirect_uri,包含 code。 4. Client Server 以 POST /oauth2/token(帶 client credentials)交換 code 換取 access_token 以及 id_token(若 request 含 openid scope)。 5. Client 以 access_token 訪問 Resource Server。 ### B. PKCE(適用於 SPA / 原生 app) 1. PKCE(Proof Key for Code Exchange)是為了防止 code interception: 1. 在發起 /authorize 前,Client 產生 code_verifier(高熵隨機字串),並以 S256 將其雜湊為 code_challenge。 2. AS 在授權階段存下 code_challenge,當 Client 用 code 呼叫 /token 時,必須帶回 code_verifier,AS 驗證匹配後發 token。 2. 範例(交換 token 的 curl) ``` curl -X POST \ -u "web-client:secret" \ -d "grant_type=authorization_code&code=AUTH_CODE&redirect_uri=http://localhost:8080/login/oauth2/code/web-client-oidc&code_verifier=CODE_VERIFIER" \ http://localhost:9000/oauth2/token ``` ## 4. Client Credentials Flow(machine-to-machine) 1. 使用情境:背後服務 A 需要呼叫資源伺服器 B,並不代表使用者,則使用 Client Credentials。 2. 範例 ``` curl -X POST -u "service-client:secret" \ -d "grant_type=client_credentials&scope=read" \ http://localhost:9000/oauth2/token ``` 3. 回傳 access_token(通常為 JWT 或 opaque token)。 ## 5. Refresh Token 與 Refresh Token Rotation 1. 基本:Refresh token 用於在 access token 到期後換取新 token。refresh token 必須安全保管,且通常只給 server-side clients。 2. Refresh Token Rotation(安全強化) 1. 每次使用 refresh token 換 new token 時,AS 發一個新的 refresh token 並使舊的過期(prevent replay attack)。 2. 若偵測到重複使用舊的 token(可能是竊取),可主動撤銷所有 token 或封鎖帳號。 3. Spring Authorization Server 範例設定 1. 在 AuthorizationServerSettings 或 Token 的設定中啟用 refresh token rotation(視版本 API 而定)。 ## 6. Token Revocation / Introspection 1. Revocation Endpoint:RFC 7009:授權伺服器應提供 revocation endpoint,讓 client 可撤銷 access 或 refresh token。 2. 範例(client 撤銷 token) ``` curl -X POST -u "web-client:secret" -d "token=THE_REFRESH_TOKEN&token_type_hint=refresh_token" http://localhost:9000/oauth2/revoke ``` 3. Introspection Endpoint:RFC 7662:resource server(或 introspection 客戶端)帶 token 到 /introspect 檢查是否有效(若使用 opaque token)。 ## 7. OpenID Connect(id_token 與 userinfo) 1. id_token:id_token 是 JWT(或 signed)包含 sub(subject)、iss、aud、exp、iat 等 claim,並可包含 email, name 等。 2. Userinfo Endpoint:用 access_token 呼叫 /userinfo 取得更多 profile 資訊(例如:email_verified 等)。 3. 範例(取得 userinfo) ``` curl -H "Authorization: Bearer ACCESS_TOKEN" http://localhost:9000/userinfo ``` ## 8. Single Sign-On(SSO)實務 1. 原理:SSO 的核心是讓多個應用共享一個 Authorization Server 的 session(通常由 AS 以 cookie 管理),使用者只要在 AS 登入過,一個 Client 的授權步驟可被其他 Client 省去。 2. 實作要點 1. 各 Client 設為 OIDC Client(Authorization Code + PKCE / server-side),共用同一 Authorization Server。 2. AS 的 session cookie(同一頂級網域或透過跨域方案)會保證已登入狀態。 3. 若要達到跨子域 SSO,AS 的 cookie domain 可以設為 .example.com,或採用 central domain 與 redirect 線路。 ## 9. 社群登入(OAuth2 Login)— 以 Google 為例 1. Spring Security OAuth2 Client(快速設定): ``` spring: security: oauth2: client: registration: google: client-id: <client-id> client-secret: <client-secret> scope: openid,profile,email provider: google: issuer-uri: https://accounts.google.com ``` 2. 解說: 1. Spring Boot 會自動建立 OAuth2Login 的 ClientRegistration,並提供 /oauth2/authorization/google 跳轉入口。 2. 在回呼時可以透過 OidcUser 或 OAuth2User 取用 claims,並結合本地帳戶資料。 ## 10. Logout(Front-channel / Back-channel / RP-Initiated) 1. Front-channel logout:Browser 重導到 AS 的 logout endpoint,會清掉 AS 的 session cookie,並可能導向各 Client 的 logout URI。適合簡單場景。 2. Back-channel logout:AS 向各 Client 發送 server-to-server 的 logout notification,Client 伺服器收到後內部清除 session。適合更嚴格一致性的需求。 3. RP-Initiated logout(OIDC):Client 呼叫 AS 的 end_session_endpoint 並帶 id_token_hint 與 post_logout_redirect_uri。 ## 11. Security Considerations 與最佳實務 1. 永遠使用 HTTPS(Token 與 redirect_uri 都必須是安全的)。 2. 使用 PKCE:SPA / Native App 必須啟用。 3. 最小 scope:只請求必要的權限與 claims。 4. 避免 long-lived access tokens:盡量使用短期限 access token 與刷新機制。 5. 保護 refresh token:server-side 儲存(不要放在 localStorage for SPAs)。 6. consent 與 scopes 的呈現:清楚告知使用者將授權的權限。 7. 監控與稽核:token 使用、撤銷、登入地點/時間等的日誌。 ## 12. 測試與調試小技巧 1. 使用 Postman / OAuth2 Playground 測試 flow。 2. 觀察 .well-known/openid-configuration 與 /oauth2/jwks 是否正確回應。 3. 在 Resource Server 用 jwt introspection 或自行驗證 JWT signature 錯誤常見原因:issuer、aud mismatch、clock skew。 4. 檢查 redirect_uri 是否完全相符(含結尾 slash)。 ## 13. 範例練習(逐步) 1. 建立一個最小 SAS(授權伺服器):用 InMemoryRegisteredClient 註冊兩個 client:一個 web client(authorization_code + refresh_token),一個 machine client(client_credentials)。測試 /oauth2/token 與 /oauth2/authorize。 2. 部署 Resource Server:配置 spring.security.oauth2.resourceserver.jwt.issuer-uri 指向你的 SAS,測試受保護 API。 3. 實作 PKCE 的 SPA 登入:使用一個簡單的前端(或 curl 模擬)完成 code+PKCE 流程。 4. 啟用 refresh token rotation:模擬 refresh token 重複使用的偵測。 5. 社群登入整合:把 Google 註冊為 client(在 Google Console 設定 redirect URI),並測試 /oauth2/authorization/google 流程。 ### 1. 建立最小 SAS(Authorization Server) — 註冊兩個 client(web + machine) 目標: 1. 啟動 Authorization Server,並用 InMemoryRegisteredClient 註冊兩個 client: 1. web-client:grant types = authorization_code + refresh_token(適用於 server-side web app) 2. machine-client:grant type = client_credentials(server-to-server) 2. 可以測試 /oauth2/authorize 與 /oauth2/token。 註:Spring Authorization Server 提供註冊 client 的中心元件 RegisteredClientRepository,並且預設會把一套最小 endpoint(/oauth2/authorize, /oauth2/token, /oauth2/jwks, /.well-known/openid-configuration)暴露出來。 #### 1. 依賴(pom 片段) (把這些加到 auth-server 專案的 pom.xml) ```xml= <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-authorization-server</artifactId> <version><!-- 對應當前可用版本,例如 1.1.x --></version> </dependency> <!-- Nimbus JOSE for JWK/JWT handling if needed --> <dependency> <groupId>com.nimbusds</groupId> <artifactId>nimbus-jose-jwt</artifactId> </dependency> ``` #### 2. 最小的 Authorization Server 設定(範例程式碼) AuthorizationServerConfig.java ```java= @Configuration public class AuthorizationServerConfig { @Bean public RegisteredClientRepository registeredClientRepository(PasswordEncoder encoder) { RegisteredClient webClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("web-client") // 若為機密客戶端可設定 clientSecret;若用 PKCE 的 public client 則不給 secret .clientSecret(encoder.encode("web-secret")) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .redirectUri("http://localhost:8080/login/oauth2/code/web-client-oidc") .scope(OidcScopes.OPENID) .scope("read") .tokenSettings(TokenSettings.builder() .accessTokenTimeToLive(Duration.ofMinutes(15)) .refreshTokenTimeToLive(Duration.ofHours(8)) .reuseRefreshTokens(true) // 若要啟用 rotation 改為 false(後面會示範) .build()) .build(); RegisteredClient machineClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("machine-client") .clientSecret(encoder.encode("machine-secret")) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .scope("read") .build(); return new InMemoryRegisteredClientRepository(webClient, machineClient); } @Bean public JWKSource<SecurityContext> jwkSource() { RSAKey rsaKey = Jwks.generateRsa(); // helper: 產生 RSAKey 的程式,可用 Nimbus helper JWKSet jwkSet = new JWKSet(rsaKey); return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet); } @Bean public ProviderSettings providerSettings() { return ProviderSettings.builder() .issuer("http://localhost:9000") .build(); } @Bean public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); // 啟用 OIDC 的 endpoint(如果需要) http.getConfigurer(OAuth2AuthorizationServerConfigurer.class).oidc(Customizer.withDefaults()); // 其餘保護(例如登入)由 Spring Security 預設處理 return http.formLogin(Customizer.withDefaults()).build(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } } ``` 解說(重點) 1. RegisteredClient 表示在授權伺服器上註冊的 client(id/secret/redirect uri/授權類型/scopes...)。RegisteredClientRepository 是存放這些 client 的地方;範例使用 InMemory(測試用)。 2. JWKSource 提供簽發 access_token/ID token 的金鑰,SAS 會暴露 /oauth2/jwks 給 Resource Server 或第三方驗證。 3. ProviderSettings.issuer 很重要,JWT 的 iss 會用到,Resource Server 也會使用此 issuer 來發現 JWKS 等 metadata。 Home #### 3. 啟動與測試(手把手) 1. 啟動 auth-server(預設 http://localhost:9000)。 2. 測試 machine-client(client_credentials)取得 token: ``` //bash curl -u "machine-client:machine-secret" -X POST http://localhost:9000/oauth2/token \ -d "grant_type=client_credentials&scope=read" ``` 會回傳 JSON 包含 access_token(通常是 JWT 或 opaque token),可用於 Resource Server 測試。 3. 測試 web-client 的 Authorization Code 流程(手動流程): 1. 在瀏覽器開啟(或貼到地址列): ``` //bash http://localhost:9000/oauth2/authorize?response_type=code&client_id=web-client&scope=openid%20read&redirect_uri=http://localhost:8080/login/oauth2/code/web-client-oidc&state=xyz ``` 2. AS 會顯示登入頁(或自訂),登入並同意後會 redirect 回 redirect_uri 並附 code: ``` //bash http://localhost:8080/login/oauth2/code/web-client-oidc?code=AUTH_CODE&state=xyz ``` 3. 用 code 換 token: ``` //bash curl -u "web-client:web-secret" -X POST http://localhost:9000/oauth2/token \ -d "grant_type=authorization_code&code=AUTH_CODE&redirect_uri=http://localhost:8080/login/oauth2/code/web-client-oidc" ``` 4. 你會拿到 access_token 與 refresh_token(若 scope 含 refresh)可用來呼叫 Resource Server。 ### 2 部署 Resource Server(以驗證 SAS 發的 JWT) #### 1. Resource Server 設定(application.yml) 在 resource-server 的 application.yml: ```xml= server: port: 8081 spring: security: oauth2: resourceserver: jwt: issuer-uri: http://localhost:9000 ``` Resource Server 會透過 issuer-uri 查 .well-known/openid-configuration / jwks_uri 來自動設定 JWT 驗證器(會自動抓 jwks 並驗 signature)。這是 Spring Resource Server 的啟動預期行為。 Home #### 2. SecurityConfig(簡短) ``` @Configuration public class ResourceServerConfig { @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth -> auth .requestMatchers("/api/public").permitAll() .anyRequest().authenticated() ).oauth2ResourceServer(oauth2 -> oauth2.jwt()); return http.build(); } } ``` #### 3. 測試 Resource Server 1. 先用 machine-client 從 auth-server 拿 token(參考上面 curl)。 2. 呼叫受保護端點: ``` //bash curl -H "Authorization: Bearer <ACCESS_TOKEN>" http://localhost:8081/api/protected ``` 1. 若 JWT 由 auth-server 簽章且 issuer/aud 合法,Resource Server 會回應 200;否則會回 401/403。 2. 若想要 debug,檢查 .well-known/openid-configuration 與 /oauth2/jwks 是否可被 resource-server 存取與解析(網域/防火牆/HTTP),以及 token 的 iss、aud 與 exp。 ### 3. PKCE(SPA)— 完整 code + PKCE 流程(含產生 code_challenge 的範例) #### 1. 為什麼要 PKCE? PKCE(RFC 7636)是為了防止 Authorization Code 被截取(public clients 無法安全保存 client_secret),它要求在授權請求階段先提交 code_challenge(雜湊),在 token 交換階段提交 code_verifier(原始字串)來驗證。PKCE 已成為對 public clients(SPA、native apps)強烈建議/必要的保護機制。 #### 2. 前端(或測試腳本)如何產生 code_verifier / code_challenge(JavaScript 範例) ```javascript= //javascript // 產生 code_verifier(長度 43~128, 隨機字元) function generateCodeVerifier() { const array = new Uint8Array(64); crypto.getRandomValues(array); return base64UrlEncode(array); } async function generateCodeChallenge(verifier) { const encoder = new TextEncoder(); const data = encoder.encode(verifier); const digest = await crypto.subtle.digest("SHA-256", data); return base64UrlEncode(new Uint8Array(digest)); } function base64UrlEncode(arr) { // from byte array to base64url string let str = btoa(String.fromCharCode.apply(null, [...arr])); return str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } ``` #### 3. 流程(步驟) 1. 前端產生 code_verifier,並用 SHA256 產生 code_challenge(S256)。 2. 導向授權端點(例): ``` //bash GET /oauth2/authorize? response_type=code &client_id=public-spa-client &redirect_uri=http://localhost:5173/callback &scope=openid%20read &code_challenge=<CODE_CHALLENGE> &code_challenge_method=S256 ``` 3. 使用者在授權伺服器登入/同意 → redirect 回 redirect_uri?code=...。 4. 前端(或後端)用 code 搭配 code_verifier 到 /oauth2/token 換 token: ``` //bash # 若 client 是 public(無 secret),token exchange 不提供 client secret: curl -X POST http://localhost:9000/oauth2/token \ -d "grant_type=authorization_code&code=AUTH_CODE&redirect_uri=http://localhost:5173/callback&client_id=public-spa-client&code_verifier=THE_VERIFIER" ``` (若 client 被註冊為 confidential,則需同時帶 client_secret 以 Basic auth。) 5. 伺服器驗證 code_verifier 是否與流程階段存下的 code_challenge 對應,如通過則下發 access_token(和 refresh token,視註冊而定)。 6. 範例測試(用 curl): 1. 先準備 code_verifier / code_challenge(可用上面 JS 在瀏覽器 console 生成,或用 openssl / python 生成)。 2. 用瀏覽器導向 authorize URL(含 code_challenge),完成同意後用 curl 換 token。 3. 注意:若是 SPA,最好把 client 註冊為 public(不含 secret)並使用 PKCE;切記不要把 long-lived refresh token 放在 localStorage(XSS 風險),也要考慮 refresh token rotation 或將 refresh token 儲存在 httpOnly cookie(但會帶來 CSRF 要處理)。 IETF Datatracker Auth0 ### 4. 啟用 Refresh Token Rotation(旋轉)與偵測重複使用(reuse detection) #### 1. 概念簡述 Refresh token rotation:每次使用 refresh token 換新 access token 時,AS 同時發一個新的 refresh token 並使舊的失效。若舊的被重複使用(可能表示 token 被竊),AS 可檢測並採取措施(例如撤銷該帳號所有 token)。這能顯著降低 refresh token 被盜用的衝擊。多家廠商與文件建議使用 rotation。 #### 2. Spring Authorization Server 的設定(範例) 在建立 RegisteredClient 時可以透過 TokenSettings 設定 reuseRefreshTokens(false): ```java= RegisteredClient webClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("web-client") .clientSecret(encoder.encode("web-secret")) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .redirectUri("http://localhost:8080/login/oauth2/code/web-client-oidc") .scope("read") .tokenSettings(TokenSettings.builder() .accessTokenTimeToLive(Duration.ofMinutes(15)) .refreshTokenTimeToLive(Duration.ofDays(7)) .reuseRefreshTokens(false) // 這會啟用 rotation(發新 refresh token 並使舊 token 失效) .build()) .build(); ``` #### 3. Token persistence 與偵測 為了做到 rotation 與偵測(detect reuse),必須把 token / 授權資料持久化(例如 JDBC JdbcOAuth2AuthorizationService),因為「是否已被使用/撤銷」需要存在伺服器端可查詢的狀態。範例: ```java= @Bean public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) { return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository); } ``` 當 reuseRefreshTokens(false) 時,SAS 會在 refresh 時發新的 refresh token 並把舊的標記為已使用。若舊 token 再次出現,伺服器可以視為 replay attack 並拒絕,或進一步撤銷該帳號的所有授權。 #### 4. 如何模擬與測試重複使用的情境 1. 使用 Authorization Code(或其他流)拿到 refresh_token(稱為 R1)。 2. 用 R1 呼叫 /oauth2/token?grant_type=refresh_token&refresh_token=R1 → 伺服器回傳新 access token + new refresh token R2(而 R1 被視為失效)。 3. 再次用 R1 嘗試呼叫 /oauth2/token → 若 rotation 被正確實作,伺服器會回 400/error(例如 invalid_grant),代表偵測到 reuse(表示可能遭竊)。你可以在伺服器端記錄/封鎖該 account。 4. 驗證伺服器在重複使用舊 R1 時能夠正確拒絕,並在需要時撤銷該使用者其他授權。 ### 5. 社群登入整合(以 Google 為例) — 作為 OAuth2 Client #### 1. 概念與前置(Google Console) 1. 到 Google Cloud Console → OAuth consent screen → 設定應用、同意畫面(scopes/公開性)。 2. 在 Credentials → Create Credentials → OAuth client ID,選擇 Web Application,並把 Authorized redirect URIs 加入,例如: ``` //bash http://localhost:8080/login/oauth2/code/google ``` (redirect URI 必須與 Spring client registration 的 redirectUri 相同且精確) #### 2. Spring Boot(OAuth2 Client)設定(application.yml) ```xml= spring: security: oauth2: client: registration: google: client-id: <GOOGLE_CLIENT_ID> client-secret: <GOOGLE_CLIENT_SECRET> scope: - openid - profile - email provider: google: issuer-uri: https://accounts.google.com ``` #### 3. SecurityConfig(接入 OAuth2 Login) ```java= @Configuration public class SecurityConfig { @Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/", "/login**", "/oauth2/**").permitAll() .anyRequest().authenticated() ) .oauth2Login(oauth2 -> oauth2 // 可設定 successHandler, userService 等 ); return http.build(); } } ``` #### 4. 取得 user info / token 並與本地帳號關聯 1. Spring 會在成功 callback 時建立 OidcUser 或 OAuth2User。你可以在 OAuth2UserService 或一個 OAuth2AuthenticationSuccessHandler 中抓取 OidcUser 的 claims(例如 email),在你本地系統找現有帳號或建立新帳號,然後建立應用內 session。 2. 測試:在瀏覽器開啟 http://localhost:8080/oauth2/authorization/google,Google 會跳轉到 Google 的登入頁,完成同意後回到你的 redirect_uri。 3. Google 對於 web server flow、redirect-uri 的說明請參考官方文件(建立憑證、設定 redirect URI、授權流程)。 ### 6. 常見錯誤與排查(針對以上練習) 1. redirect_uri_mismatch:redirect uri 必須完全吻合(包含尾斜線與 http/https)。(常在 Google OAuth 設定錯誤) 2. invalid_grant on token exchange:code 過期或已被使用,或 PKCE 的 code_verifier 不匹配。檢查時間差、code 是否已使用。 3. Resource Server 回 401:檢查 JWT 的 iss(issuer)是否為 resource-server 所期望,jwks 是否能讀取,以及 token 是否過期。 4. Refresh token 再使用未被拒絕:確認你是否把 token persist 到 DB 並啟用 reuseRefreshTokens(false)(及用 JDBC 的 OAuth2AuthorizationService 來追蹤與標記狀態)。 ### 7. 測試腳本(快速流程摘要) 1. 啟動 auth-server(9000), resource-server(8081), client(8080)。 2. 取得 machine-client token: ``` //bash curl -u "machine-client:machine-secret" -d "grant_type=client_credentials&scope=read" http://localhost:9000/oauth2/token ``` 3. 用 token 呼叫 resource: ``` //bash curl -H "Authorization: Bearer <ACCESS_TOKEN>" http://localhost:8081/api/protected ``` 4. 授權碼流程測試(web client):在瀏覽器開啟 authorize URL → 同意 → 用 code 用 curl 換 token(帶 client secret) 5. PKCE(SPA)測試:在前端產生 code_verifier / code_challenge → 導向 authorize URL(含 code_challenge)→ 用 code + code_verifier 呼叫 token endpoint(若是 public client,不帶 secret) ## 14. 常見問題與排查清單 1. invalid_grant → code 過期、已使用或 code_verifier mismatch 2. unauthorized_client → client 未允許該 grant type 3. invalid_client → client_id/secret 錯誤或未使用正確的 client auth method 4. insufficient_scope → access token 缺少所需 scope 5. token signature validation failed → wrong public key / issuer mismatch 附錄 A:範例程式碼片段(SAS + Client): 1. 更完整的可跑範例,我可以依你的需求建立並打包成 Spring Boot 範例(包含 SAS + Resource Server + Client 三套微服務)並上傳給你。 # 5. CSRF(跨站請求偽造)與 XSS(跨站腳本)的完整參考教學 ## 1. CSRF(Cross-Site Request Forgery)深入 1. 什麼是 CSRF(概念):CSRF 是攻擊者利用使用者在 另一站(受信任站) 的登入狀態,誘導瀏覽器在受信任站發出有狀態改變的請求(例如轉帳、改密碼)。由於瀏覽器會自動帶上 cookies,目標站若沒有驗證請求來源,就會把該請求當作合法使用者的操作。這是 OWASP 上常見定義與防範核心。 2. 攻擊前提(威脅模型) 1. 受害者已在目標站登入(具有有效 session cookie 或其他自動帶的憑證)。 2. 攻擊者能誘導受害者(例如寄信、造訪惡意頁面)去對目標站發出請求(通常為 POST/PUT/DELETE 等非安全方法)。 3. 目標站無法區分該請求是來自使用者在本站的操作還是外站發起。 3. 常見 CSRF 防禦策略(比較) 1. Synchronizer (server-side) token(同步化 token) — 最可靠:伺服器在 session 中放 token,表單內放 hidden field,提交時比對。適合 server-rendered pages 和大多數情境。OWASP 建議為首選。 2. Double Submit Cookie — 伺服器發一個可被 JS 讀的 cookie(XSRF token),JS 將 cookie 讀出放到 header 或 body,再由伺服器比對 cookie 與 header。適合 stateless 或某些 SPA 情境,但若 cookie 被 JS 讀取,XSS 可洩露,需慎用。 3. SameSite Cookie — 設 SameSite=Lax/Strict/None 可在瀏覽器層阻止大部份跨站帶 cookie 的情形;為非常有效的補強,但並非萬能(瀏覽器相容性、同源情境/導航情境差異)。建議搭配 token 使用。MDN 的 Set-Cookie 文件詳列屬性與注意事項。 4. 要求 re-auth / MFA / 確認碼 — 對高風險操作(轉帳、金流)要求二次驗證。這是風險緩解(risk-based)策略。 4. 作模式(選用建議) 1. Server-rendered (常規 MVC):使用 同步化 token(session 中 token + hidden input),最直接、最被 OWASP 推薦。 2. SPA(前後端分離):推薦用 Cookie + header(例如 Spring 的 CookieCsrfTokenRepository 寫入 XSRF-TOKEN cookie,前端讀 cookie 並在 X-XSRF-TOKEN header 傳回),或在登入後把 token 放到 initial HTML(meta tag)再由 SPA 記住。不要把 token 存到 localStorage。Spring docs 舉例說明。 ## 2. CSRF:同步化 token(Synchronizer Token Pattern)詳細教學(Java Servlet 範例) ### 1. 設計思路(要點) 1. 在使用者建立 session 時(或在 GET 顯示表單時),在 server-side 生成一個隨機 token(cryptographically random),把它放到 session(或 server-side token store)。 2. 把 token 放到表單的 hidden 欄位或 meta tag(SPA),並在提交時把 token 一併送到 server。 3. Server 比對收到的 token 與 session 中保存的 token 是否相符,若不同或缺失則拒絕(HTTP 403)。 1. 要點:token 必須具備不可預測性,且每個 session(或 form)不同。不要把 token 放到 URL。請在 session invalidation(例如 logout)時清掉 token。 ### 2. Java Servlet 實作(從零到尾) #### 步驟 A — 產生 token 的 helper src/main/java/com/example/csrf/CsrfUtil.java: ``` package com.example.csrf; import java.security.SecureRandom; import java.util.Base64; public final class CsrfUtil { private static final SecureRandom RNG = new SecureRandom(); public static String generateToken() { byte[] b = new byte[32]; RNG.nextBytes(b); return Base64.getUrlEncoder().withoutPadding().encodeToString(b); } } ``` 解說:使用 SecureRandom 產生 32 bytes 隨機值,用 Base64URL 編碼(不含 + / =)以便放到 HTML 欄位或 header。這比使用 UUID 更難被猜測。 #### 步驟 B — GET 時放 token 到 session 與 form(JSP 範例) login.jsp: ```jsp= <%@ page session="true" import="com.example.csrf.CsrfUtil" %> <% String csrf = (String) session.getAttribute("csrfToken"); if (csrf == null) { csrf = CsrfUtil.generateToken(); session.setAttribute("csrfToken", csrf); } %> <form action="login" method="post"> <input type="hidden" name="csrfToken" value="<%= csrf %>" /> <!-- 其餘欄位 --> </form> ``` 解說:JSP 在渲染時取得或建立 token,並放入 hidden input。若網站允許快取 form page,請考慮設置 Cache-Control: no-store 以免舊 token 被快取導致 mismatch。 #### 步驟 C — POST 時驗證 token(Servlet) LoginServlet#doPost(...) ```java= HttpSession session = req.getSession(false); String sessionCsrf = (session == null) ? null : (String) session.getAttribute("csrfToken"); String requestCsrf = req.getParameter("csrfToken"); if (sessionCsrf == null || requestCsrf == null || !sessionCsrf.equals(requestCsrf)) { resp.setStatus(HttpServletResponse.SC_FORBIDDEN); resp.getWriter().println("CSRF token invalid or missing"); return; } // 繼續處理登入驗證... ``` 解說:先比對 token 才進行任何狀態改變。若驗證失敗直接回 403 並終止流程。 #### 步驟 D — 測試(curl 範例) 1. 先 GET login 頁面並存 cookie: ``` //bash curl -s -c cookies.txt http://localhost:8080/app/login.jsp -o loginpage.html ``` 2. 從 loginpage.html 擷取 csrfToken(Linux 範例): ``` //basah TOKEN=$(grep -oP 'name="csrfToken" value="\K[^"]+' loginpage.html) ``` 3. 再 POST 帶 cookie 與 token: ``` //bash curl -i -b cookies.txt -c cookies.txt -d "username=alice&password=123&csrfToken=${TOKEN}" http://localhost:8080/app/login ``` 若 token 缺失或錯誤會回 403。 ### 3. 注意與進階 1. Session 共用 / Scale:在多節點部署時 session 需集中儲存(Redis、DB),或改為 stateless token 並另做 CSRF 措施。 2. 表單快取:若反向 proxy 或 CDN 快取 HTML(不安全),會導致舊 token 被用。對 form page 設 Cache-Control: no-store。 3. 高風險操作加強:對重要轉帳或改密等操作,要求使用者再次輸入密碼或 MFA。 ## 3. CSRF 在 SPA / AJAX / Spring Security 的實作(現代做法) ### 1. Spring Security 的支援(重點) Spring Security 預設啟用 CSRF 防護(適用於瀏覽器使用的應用)。它會把 _csrf token 暴露為 request attribute,且可自動插入到使用 Spring form tags 的表單中;對 AJAX 則常用 CookieCsrfTokenRepository(把 token 寫入 cookie),並在前端把 token 加入 header(例如 X-XSRF-TOKEN 或 X-CSRF-TOKEN)。參考 Spring 官方文件。 ### 2. Spring Boot + 前端(簡要範例) 1. Server side(Spring Security config)(示意): @EnableWebSecurity ```java= public class SecurityConfig { @Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) ) .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()); return http.build(); } } ``` 說明:CookieCsrfTokenRepository.withHttpOnlyFalse() 會把 token 寫到 cookie(名稱 XSRF-TOKEN),cookie 設為可被 JS 讀取(HttpOnly=false)以便 SPA 把它放到 header。 2. Client side(fetch 範例): ```//javascript= //javascript function getCookie(name) { return document.cookie.split('; ').find(row => row.startsWith(name+'=')) ?.split('=')[1]; } const token = getCookie('XSRF-TOKEN'); fetch('/api/modify', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': token }, body: JSON.stringify({...}) }); ``` 說明:Spring 會檢查 header 或 request param 中的 token 與 session/cookie 中儲存的 token 是否匹配。詳細配置與範例請參考 Spring Security 文件與 Baeldung 等教學。 ## 4. XSS(Cross-Site Scripting)深入 ### 1. XSS 類型與風險 1. Reflected XSS:惡意 payload 隨參數回顯,通常透過釣魚 URL 觸發。 2. Stored XSS:惡意 payload 存在伺服器(留言、設定),被其他使用者讀取時執行(最嚴重)。 3. DOM-based XSS:payload 在客戶端 DOM 操作時被執行(例如 innerHTML、eval),不經伺服器判斷。 1. XSS 可導致 cookie/LocalStorage 被竊取(若不是 HttpOnly)、執行惡意行為、偽造 UI、進一步的 CSRF 等。OWASP 與 PortSwigger 的資源為權威參考。 ### 2. 防禦總原則(最重要) 1. Contextual output encoding(情境式輸出編碼):根據輸出位置(HTML body、attribute、JS、URL、CSS)使用正確的編碼器。輸入驗證不是充分防線,正確的輸出編碼才是關鍵。 2. Use framework/template auto-escaping:JSTL <c:out>, Thymeleaf 等都有自動 escape 機制 — 儘量使用並避免直接 innerHTML。 3. CSP(Content Security Policy)做為 defense-in-depth:CSP 能大幅降低攻擊面(阻止 inline script、只允許特定來源或 nonce),但不能取代正確的輸出編碼。MDN 與 OWASP 都強調 CSP 為第二層防線。 4. Trusted Types(限制 DOM sink 的非受信值):新興 API,用於強化 CSP 與輸出管控,對抗 DOM-based XSS。MDN 與 W3C 有相關說明(2024-2025 年日益被採用)。 ### 3. XSS 實作範例(從易犯錯到正確) #### 範例 A — Vulnerable(反射 / DOM) vuln.jsp: ```jsp= <% String q = request.getParameter("q"); %> <html> <body> <h3>搜尋結果</h3> <div id="results"> <!-- 危險:直接輸出 user 輸入到 innerHTML --> <script> document.getElementById('results').innerHTML = "<p>結果:<%= q %></p>"; </script> </div> </body> </html> ``` 危險點:把使用者輸入直接注入到 innerHTML。若 q 包含 <img src=x onerror=...> 等,會執行。這是經典 DOM-based XSS。 #### 範例 B — 安全修補(伺服器端做 HTML encode + 前端用 textContent) 伺服器端用 OWASP Java Encoder(建議)或 JSTL c:out 編碼:safe.jsp ```jsp= <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <% String q = request.getParameter("q"); %> <html> <body> <h3>搜尋結果</h3> <div id="results"> <p>結果:<c:out value="${param.q}" /></p> </div> </body> </html> ``` 或若要 JS 更新 DOM: ```javascript= //javascript // server 放入 JSON-escaped string 或直接放 data-* attribute const q = /* safely-escaped server-inserted value */; document.getElementById('results').textContent = "結果:" + q; ``` 解說:用 textContent 或 server-side HTML-escape 工具(OWASP Java Encoder、Apache Commons Lang StringEscapeUtils.escapeHtml4)來避免 XSS。不要用 innerHTML 來放未淨化的字串。 ### 4. CSP(Content Security Policy)實作與實例 1. 目標:防止 inline script 與不受信任來源載入。 2. 範例 header(嚴格但實用): ``` //csharp Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-<RANDOM>' https://apis.examplecdn.com; object-src 'none'; base-uri 'self'; form-action 'self'; report-uri /csp-report; ``` 3. 說明: 1. 使用 nonce- 給可信 inline script(由 server 隨每次回應產生隨機 nonce 並插入對應 script 的 nonce 屬性),可允許必要的 inline script 又防止注入腳本。 2. report-uri / report-to 幫助你收集 CSP 違規事件以觀察是否有被利用。MDN 的 CSP 指南非常詳細。 4. 在 Java Servlet 中設定 CSP header(簡單 filter): ```java= public class SecurityHeadersFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletResponse httpResp = (HttpServletResponse) res; httpResp.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; object-src 'none';"); chain.doFilter(req, res); } } ``` ### 5. Trusted Types(前端強化) 概念:限制把任意 string 傳入 DOM sinks(例如 innerHTML),必須從 Trusted Types API 產生的受信物件。可配合 CSP 的 require-trusted-types-for 'script' 或 trusted-types 指令。這在 2024-2025 年被建議於大型單頁應用逐步採用以防 DOM XSS。 ## 5. 補充標頭與防線(SRI / Referrer-Policy / X-Frame-Options / Security headers) 1. SRI (Subresource Integrity):當從 third-party CDN 載入 JS/CSS 時,加上 hash 確保內容未被篡改。MDN 有實作教學。 2. Referrer-Policy:限制 referer 的泄露,避免敏感 URL 透過 Referer header 洩出。 3. X-Frame-Options / CSP frame-ancestors:防止 clickjacking。 4. 不要只依賴 X-XSS-Protection header:現代瀏覽器已廢用或有限支持,應以 CSP 與輸出編碼為主。 ## 6. 測試、驗證與工具 1.靜態分析(SAST)與動態掃描(DAST)皆不可少。PortSwigger / Burp Suite 非常適合發現 XSS 與 CSRF 相關問題。 2. 自動化測試腳本:用 curl、Selenium、Playwright 測試表單提交(包含不帶 CSRF token 的情形)與用例。 3. CSP report:部署 report-uri / report-to 來收集 CSP 違規事件並分析是否有惡意嘗試。 ## 7. 實務上線檢查清單(Deploy checklist) 1. 所有 state-changing 請求均有 CSRF 防護(POST/PUT/PATCH/DELETE)。 2. cookie 標記:Secure; HttpOnly; SameSite(視 UX 需求選 Lax/Strict/None)並留意 SameSite 的瀏覽器行為。 3. 對所有輸出使用情境式編碼(HTML/Attribute/JS/URL/CSS),並使用框架的安全模板或 OWASP Java Encoder。 4. 設置 CSP(從寬到嚴逐步收緊,先設 report-only 模式觀察)。 5. 為第三方資源加 SRI,並限制來源。 6. 在 SPA 中使用 cookie + header 或 meta tag 的 CSRF 流程(不要把 Token 存 localStorage),並對 AJAX 設置適當 header。 7. 如果採用 double submit cookie,評估 XSS 風險與 JS-cookie 可視性。 ## 8. 練習實驗(你可以照著做,step-by-step) ### A. 練習 1 — 同步化 CSRF token(Servlet + JSP,放到 02-cookie-session) 目標:在 login.jsp 產生一個不可預測的 CSRF token 存入 session,表單以 hidden 欄位帶回;LoginServlet 在 POST 時比對 session 中 token,不符回 403;成功則登入(模擬)。 #### 1. 專案結構(範例) ``` 02-cookie-session/ ├─ pom.xml └─ src/main/java/com/example/csrf/ │ ├─ CsrfUtil.java │ ├─ LoginServlet.java │ └─ AdminServlet.java └─ src/main/webapp/ ├─ login.jsp ├─ welcome.jsp └─ vuln.jsp (選作:XSS 測試頁) ``` #### 2. 必要檔案(完整貼上) ##### 1. pom.xml(使用 Servlet 4.0 + JSP 支援 + Jetty plugin) ```xml= <project xmlns="http://maven.apache.org/POM/4.0.0" ...> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>csrf-demo</artifactId> <version>1.0-SNAPSHOT</version> <packaging>war</packaging> <dependencies> <!-- Servlet API (provided by container) --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <!-- JSP runtime (Tomcat Jasper) --> <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-jasper</artifactId> <version>9.0.73</version> </dependency> <!-- JSTL (若使用 safe.jsp) --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.eclipse.jetty</groupId> <artifactId>jetty-maven-plugin</artifactId> <version>9.4.48.v20220622</version> <configuration> <scanIntervalSeconds>3</scanIntervalSeconds> <webApp> <contextPath>/</contextPath> </webApp> </configuration> </plugin> </plugins> </build> </project> ``` ##### 2. src/main/java/com/example/csrf/CsrfUtil.java ```java= package com.example.csrf; import java.security.SecureRandom; import java.util.Base64; public final class CsrfUtil { private static final SecureRandom RNG = new SecureRandom(); private CsrfUtil() {} public static String generateToken() { byte[] b = new byte[32]; RNG.nextBytes(b); return Base64.getUrlEncoder().withoutPadding().encodeToString(b); } } ``` 說明:使用 SecureRandom 產生 32 bytes → Base64URL,產出難猜的 token。 ##### 3. src/main/java/com/example/csrf/LoginServlet.java ```java= package com.example.csrf; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; @WebServlet("/login") public class LoginServlet extends HttpServlet { // 範例硬編碼帳密(僅示範) private static final String DEMO_USER = "user"; private static final String DEMO_PASS = "1234"; @Override protected void doPost(javax.servlet.http.HttpServletRequest req, javax.servlet.http.HttpServletResponse resp) throws IOException { // 先驗 CSRF token HttpSession session = req.getSession(false); String sessionToken = (session == null) ? null : (String) session.getAttribute("csrfToken"); String requestToken = req.getParameter("csrfToken"); if (sessionToken == null || requestToken == null || !sessionToken.equals(requestToken)) { resp.setStatus(javax.servlet.http.HttpServletResponse.SC_FORBIDDEN); resp.getWriter().println("CSRF token invalid or missing"); return; } // 驗證帳密 (示範) String username = req.getParameter("username"); String password = req.getParameter("password"); if (DEMO_USER.equals(username) && DEMO_PASS.equals(password)) { // 成功 -> 若沒有 session 創建一個(或可先 invalidate 舊 session 做防 Fixation) if (session != null) session.invalidate(); HttpSession newSession = req.getSession(true); newSession.setAttribute("username", username); // OPTIONAL: 重新產生一個 CSRF token(登入後) newSession.setAttribute("csrfToken", CsrfUtil.generateToken()); // Redirect 到 welcome 頁 resp.sendRedirect("welcome.jsp"); } else { resp.getWriter().println("帳號或密碼錯誤"); } } } ``` 重點說明: 1. 在執行任何「會改變伺服器狀態」的操作(這裡是登入流程)時,先比對 CSRF 才進行驗證或變更。 2. 若之前已有 session 建議 invalidate() 後再 getSession(true) 以防 Session Fixation。 ##### 4. src/main/java/com/example/csrf/AdminServlet.java(可選,用來測試 /admin 需登入) ```java= package com.example.csrf; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; @WebServlet("/admin") public class AdminServlet extends HttpServlet { @Override protected void doGet(javax.servlet.http.HttpServletRequest req, javax.servlet.http.HttpServletResponse resp) throws IOException { HttpSession s = req.getSession(false); String user = (s == null) ? null : (String) s.getAttribute("username"); if (user == null) { resp.sendRedirect("login.jsp"); return; } resp.setContentType("text/html;charset=UTF-8"); resp.getWriter().println("<h2>Admin area - welcome " + user + "</h2>"); } } ``` ##### 5. src/main/webapp/login.jsp ```jsp= <%@ page session="true" import="com.example.csrf.CsrfUtil" %> <% String csrf = (String) session.getAttribute("csrfToken"); if (csrf == null) { csrf = CsrfUtil.generateToken(); session.setAttribute("csrfToken", csrf); } %> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"/> <title>Login</title> </head> <body> <h2>登入(示範)</h2> <form method="post" action="login"> <input type="hidden" name="csrfToken" value="<%= csrf %>" /> 帳號: <input type="text" name="username"/><br/> 密碼: <input type="password" name="password"/><br/> <input type="submit" value="登入"/> </form> </body> </html> ``` ##### 6. src/main/webapp/welcome.jsp ```jsp= <%@ page session="true" %> <% String user = (String) session.getAttribute("username"); if (user == null) { response.sendRedirect("login.jsp"); return; } %> <!DOCTYPE html> <html> <head><meta charset="UTF-8"/><title>Welcome</title></head> <body> <h2>歡迎, <%= user %>!</h2> <a href="admin">Admin</a> </body> </html> ``` #### 3. 建置 & 執行 1. 使用 Jetty plugin(快速) ``` //bash cd 02-cookie-session mvn clean package mvn org.eclipse.jetty:jetty-maven-plugin:9.4.48.v20220622:run # 之後開瀏覽器 http://localhost:8080/login.jsp ``` 2. 使用 Tomcat(部署 WAR) ``` //bash cd 02-cookie-session mvn clean package # 會在 target/csrf-demo.war(或 artifactId-wrong? 檢查 target) # 把 war deploy 到 Tomcat webapps 下,啟動 Tomcat ``` #### 4. 測試(curl 範例,演示「不帶 token → 403」與「帶 token → 登入成功」) 1. 取得 login page(並儲存 cookie 與 html) ``` //bash curl -s -c cookies.txt http://localhost:8080/login.jsp -o loginpage.html ``` 2. 擷取 CSRF token(Linux) ``` //bash TOKEN=$(grep -oP 'name="csrfToken" value="\K[^"]+' loginpage.html) echo "token=$TOKEN" ``` 3. 正常 POST(帶 token) ``` //bash curl -i -b cookies.txt -c cookies.txt \ -d "username=user&password=1234&csrfToken=${TOKEN}" \ http://localhost:8080/login ``` 預期:若成功會看到 302 redirect 到 welcome.jsp 或直接成功回應。 4. 攻擊模擬:漏掉 token ``` //bash curl -i -b cookies.txt -c cookies.txt -d "username=user&password=1234" http://localhost:8080/login ``` 預期:HTTP/1.1 403 Forbidden,回應 CSRF token invalid or missing。 #### 5. 每一步詳細說明(教學式) 1. 為什麼要在 GET 時產生 token?因為表單需要一個隱藏值(hidden field)送回 server 作比對;把 token 存在 session 能確保每個 session 的 token 唯一且不可預測。 2. 為什麼 token 要用 SecureRandom?一般 UUID 雖有隨機性,但 cryptographic-strength 的 SecureRandom 更難被猜中。 3. 比對時應在任何 state-changing 請求前驗 token:先驗 token 再做密碼檢查/DB 操作,可避免 CSRF 觸發不必要的查詢/邏輯。 4. 如何在 scale-out(多台機)環境維持 token?Session 需集中儲存(例如 Redis),或把 token 與 session id 綁在同一儲存後端。 5. 如何保護 cookie?Production 要在 Set-Cookie 時加 Secure; HttpOnly; SameSite=Lax/Strict(在容器或 reverse proxy 中設定)。 ### B. 練習 2 — Spring Security + SPA(CookieCsrfTokenRepository.withHttpOnlyFalse()) 目標:示範用 Spring Security 把 CSRF token 寫到 cookie(XSRF-TOKEN),前端(React/Vue)讀 cookie 把值放入 X-XSRF-TOKEN header,伺服器端驗證 token。這個流程是現代 SPA 常用的做法。 #### 1. 專案(簡化 Spring Boot demo) 1. 專案 04-spring-spa-demo/ 主要檔案: ``` 04-spring-spa-demo/ ├─ pom.xml └─ src/main/java/com/example/spa/ ├─ SpaApplication.java ├─ SecurityConfig.java └─ DemoController.java ``` 2. pom.xml(Spring Boot) ```xml= <project ...> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.9</version> </parent> <artifactId>spring-spa-demo</artifactId> <packaging>jar</packaging> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project> ``` 3. SpaApplication.java ```java= package com.example.spa; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class SpaApplication { public static void main(String[] args) { SpringApplication.run(SpaApplication.class, args); } } ``` 4. SecurityConfig.java ```java= package com.example.spa; 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.web.SecurityFilterChain; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; @Configuration public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf // Cookie 存放 CSRF token,允許 JS 可讀取(HttpOnly=false) .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) ) // for demo, allow all requests (簡化測試) .authorizeRequests(auth -> auth.anyRequest().permitAll()); return http.build(); } } ``` 重點:CookieCsrfTokenRepository.withHttpOnlyFalse() 會在 GET 回應將 token 寫入 XSRF-TOKEN cookie(且 cookie 可被 JavaScript 讀取),前端拿到後放入 X-XSRF-TOKEN header。 5. DemoController.java(一個需要 CSRF 的 POST endpoint) ```java= package com.example.spa; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.Map; @RestController @RequestMapping("/api") public class DemoController { @PostMapping("/modify") public ResponseEntity<?> modify(@RequestBody Map<String,Object> body) { // 只回傳收到資料 — 但 Spring Security 已會驗 CSRF token return ResponseEntity.ok(Map.of("status","ok","received", body)); } @GetMapping("/") public String home() { return "SPA CSRF demo home (GET sets XSRF-TOKEN cookie)"; } } ``` #### 2. 建置 & 執行 ``` //bash cd 04-spring-spa-demo mvn spring-boot:run ``` 應啟動在 http://localhost:8080。 #### 3. 前端(React / 任意 SPA)示範:把 cookie 中的 XSRF-TOKEN 取出並放到 X-XSRF-TOKEN header 簡單的 fetch 範例(瀏覽器環境): ```javascript= //javascript function getCookie(name) { const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')); return match ? decodeURIComponent(match[2]) : null; } async function postModify(payload) { const token = getCookie('XSRF-TOKEN'); // 由 Spring 寫入 cookie const res = await fetch('/api/modify', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': token }, credentials: 'include', body: JSON.stringify(payload) }); return res.json(); } ``` 說明: 1. credentials: 'include' 若服務在不同 domain(需允許 CORS with credentials)才需要;本地同域可省略。 2. 這種模式避免把 token 存在 localStorage(XSS 泄露風險),token 存在 cookie 並由 JS 讀取再放 header,server 比對 cookie/token 機制。 #### 4. 測試(curl 模擬) 1. 先 GET 根頁面把 cookie 取回: ``` //bash curl -i -c cookie.txt http://localhost:8080/ ``` 2. 從 cookie.txt 抓出 XSRF-TOKEN(示範用 awk,視 cookie 文件格式而定): ``` //bash # BSD/Linux 的 cookie file 格式不同,這是一般方法嘗試抓 cookie value TOKEN=$(grep XSRF-TOKEN cookie.txt | awk '{print $7}') echo "token=$TOKEN" ``` 3. 再帶 cookie 與 header 發 POST: ``` //bash curl -i -b cookie.txt -H "X-XSRF-TOKEN: ${TOKEN}" -H "Content-Type: application/json" \ -d '{"msg":"hello"}' http://localhost:8080/api/modify ``` 預期:200 + JSON 回應。如果缺少 header 或 header 值不對,Spring Security 會回 403。 #### 5. 詳細解釋(為什麼這樣做) 1. 為 SPA 設計:因為 SPA 常用 AJAX,使用 CookieCsrfTokenRepository 把 token 放 cookie,前端再放 header 是 industry practice。 2. 為何 cookie 要 HttpOnly=false? 因為 JS 必須讀取 token 再放 header。若 HttpOnly=true 就沒法用此策略。 3. 風險:若應用存在 XSS,攻擊者可讀取 cookie 與 token → 所以仍要防範 XSS(輸出編碼、CSP)。 4. 替代:若採用 Bearer token 放在 Authorization header(而非 cookie),瀏覽器不會自動在跨站載入時設定 Authorization header → CSRF 風險不同(但需另處理 token 存放安全性)。 ### C. 練習 3 — XSS 實驗(vulnerable → fix)與加入 CSP header 目標:建立一個「易受攻擊的頁面」vuln.jsp(使用 innerHTML 或在 JSP 直接未編碼輸出)來示範 XSS,然後示範 safe.jsp(用 JSTL c:out 或 OWASP Encoder)修補,並加上 CSP header 觀察防護效果。 #### 1. 必要檔案(放在 02-cookie-session 或 05-csrf-xss) 1. vuln.jsp(示範反射/DOM XSS) 2. safe.jsp(使用 JSTL c:out 或 OWASP Encoder) 3. SecurityHeadersFilter.java(設定 CSP header 的 servlet filter) ##### 1. src/main/webapp/vuln.jsp ```jsp= <%@ page contentType="text/html;charset=UTF-8" %> <% String q = request.getParameter("q"); if (q == null) q = ""; %> <!DOCTYPE html> <html> <head><title>Vulnerable</title></head> <body> <h3>Vulnerable page (DOM-based XSS demo)</h3> <div id="out"></div> <script> // 危險作法:直接把伺服器回傳的字串放到 innerHTML var serverValue = "<%= q %>"; document.getElementById('out').innerHTML = "Search: " + serverValue; </script> </body> </html> ``` 測試:開啟 http://localhost:8080/vuln.jsp?q=<img src=x onerror=alert(1)> 應會彈出 alert(或執行 payload),表示存在 XSS。 ##### 2. src/main/webapp/safe.jsp(使用 JSTL c:out) ```jsp= <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ page contentType="text/html;charset=UTF-8" %> <!DOCTYPE html> <html> <head><title>Safe</title></head> <body> <h3>Safe page (server-side encoding)</h3> <div id="out"> <p>Search: <c:out value="${param.q}" /></p> </div> </body> </html> ``` 說明:c:out 自動 HTML escape,會把 < > 等特殊字元轉成 HTML entity,避免執行。 ##### 3. src/main/java/com/example/csrf/SecurityHeadersFilter.java(設定 CSP header) ```java= package com.example.csrf; import javax.servlet.*; import javax.servlet.http.HttpServletResponse; import java.io.IOException; public class SecurityHeadersFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletResponse resp = (HttpServletResponse) response; // 簡單的 CSP policy(示範) — 嚴格的 CSP 會需要 nonce 或外部 resource 設定 resp.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; object-src 'none';"); resp.setHeader("X-Content-Type-Options", "nosniff"); resp.setHeader("Referrer-Policy", "no-referrer-when-downgrade"); resp.setHeader("X-Frame-Options", "DENY"); chain.doFilter(request, response); } } ``` 在 web.xml 加註冊 filter: ```xml= <filter> <filter-name>securityHeaders</filter-name> <filter-class>com.example.csrf.SecurityHeadersFilter</filter-class> </filter> <filter-mapping> <filter-name>securityHeaders</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> ``` #### 2. 測試流程 1. 先試 vulnerable 頁: 1. 開 http://localhost:8080/vuln.jsp?q=<img src=x onerror=alert('XSS')> 2. 結果:alert 跳出 → 表示頁面存在 XSS(危險)。 2. 換成 safe.jsp: 1. 開 http://localhost:8080/safe.jsp?q=<img src=x onerror=alert('XSS')> 2. 結果:頁面會顯示 &lt;img src=x onerror=alert('XSS')&gt;(被 escape),不會執行。 3. 加入 CSP header(Filter 已註冊): 1. 適用情況:若攻擊者能把 script 注入,CSP 可以阻擋外部 script 與 inline script(視 policy)。 2. 例如你把 script-src 'self' 加入,且 payload 含 inline onerror,CSP 可能阻止執行(不同瀏覽器對 inline event handler 的處理可能不同,所以不要完全依賴 CSP,仍要做正確的編碼)。 #### 3. 註解與實務建議 1. 情境式(contextual)編碼:根據你輸出的位置(HTML body / attribute / JS string / URL),使用不同的 escaping 方法。c:out 處理 HTML body。若輸出在 JavaScript 中需做 JS encoding。 2. Trusted Types + CSP:對大型 SPA 建議採用 Trusted Types 與嚴格 CSP(含 nonce)作為 defense-in-depth。 3. 不要只用 input validation:input validation 有用,但 XSS 防禦的核心是輸出編碼。 4. 部署時:把 CSP 初期放在 report-only 模式收集違規,再逐步收緊 policy。 ### D. 常見測試指令(總結) 1. 取得頁面並儲存 cookie: ``` //bash curl -s -c cookies.txt http://localhost:8080/login.jsp -o loginpage.html ``` 2. 抽 token: ``` //bash TOKEN=$(grep -oP 'name="csrfToken" value="\K[^"]+' loginpage.html) ``` 3. 正常登入: ``` //bash curl -i -b cookies.txt -c cookies.txt -d "username=user&password=1234&csrfToken=${TOKEN}" http://localhost:8080/login ``` 4. 忽略 token(應 403): ``` //bash curl -i -b cookies.txt -c cookies.txt -d "username=user&password=1234" http://localhost:8080/login ``` 5. Spring SPA cookie→header 測試(範例): ``` //bash curl -i -c cookie.txt http://localhost:8080/ TOKEN=$(grep XSRF-TOKEN cookie.txt | awk '{print $7}') curl -i -b cookie.txt -H "X-XSRF-TOKEN: ${TOKEN}" -H "Content-Type: application/json" -d '{"m":"x"}' http://localhost:8080/api/modify ``` (註:cookie 索引位置可能因系統而異,若抓不到可用 cat cookie.txt 查看格式並調整 awk/gawk 的欄位) ### E. 常見陷阱與注意事項(實務教學重點) 1. 不要把 token 放到 URL(GET query) — 會被紀錄在日誌與 Referer 中。 2. 不要把 token 在 localStorage 中長期存放;若你的 token 可被 JS 讀取(如 SPA 需),務必防 XSS。 3. SameSite 可以減少 CSRF(但不要單靠 SameSite,仍應用 token)。 4. 表單頁面不要被公共 CDN 或代理快取(會導致 token 被複用 / 過期),對此設 Cache-Control: no-store。 5. 在多節點部署,session token 需要 centralized session store 或換成可辨識的 stateless token design(但那會影響 CSRF 策略)。 6. XSS 與 CSRF 有交互風險:若系統有 XSS,CSRF 很容易被繞過(XSS 導致 cookie/token 被竊),所以 XSS 的防範等級應優先。 ## 9. 重要參考與延伸閱讀(權威來源) 1. [OWASP CSRF Prevention Cheat Sheet.](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html?utm_source=chatgpt.com) 2. [OWASP XSS Prevention Cheat Sheet. ](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html?utm_source=chatgpt.com) 3. [MDN — Set-Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie?utm_source=chatgpt.com)(SameSite / Secure / HttpOnly)與 Cookies 指南. 4. [MDN — Content Security Policy 指南](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP?utm_source=chatgpt.com)(CSP)。 5.[ MDN — Trusted Types API(2025 年重要防護)](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API?utm_source=chatgpt.com)與 W3C Trusted Types spec。 6. [Spring Security CSRF documentation](https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html?utm_source=chatgpt.com)(如何用 CookieCsrfTokenRepository,AJAX 範例)。 7. [MDN — Subresource Integrity(SRI)實作](https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/SRI?utm_source=chatgpt.com)。 ## 10. 小結(最短速成清單) 1. CSRF:在所有會改變 server state 的請求上,務必有 token(Server-synchronizer 或 SPA 的 cookie+header)。OWASP 優先建議同步化 token。 2. XSS:永遠做情境式輸出編碼;不要相信輸入驗證;CSP/Trusted Types 作為第二道防線。 3. Cookie:Secure; HttpOnly; SameSite;SameSite 能減少 CSRF 風險但不應完全取代 token。 4. 測試:用自動化與手動工具(Burp/PortSwigger)歸納攻擊向量並設報告機制(CSP report)。