# 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. 結果:頁面會顯示 <img src=x onerror=alert('XSS')>(被 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)。