# 6. 基於 Token 的認證(JWT 與實務安全) ## 1. 知識點概覽 1. Session vs Token 認證 1. Session:伺服器端存狀態(需集中式儲存,支援撤銷)。 2. Token:伺服器端只驗 token,不需存狀態(scale-out 輕鬆)。 2. JWT 結構 ``` //css header.payload.signature ``` 1. Header:簽章演算法(HS256/RS256)+ kid 2. Payload:聲明(iss, sub, exp, roles, scopes) 3. Signature:對 header+payload 的簽章 3. Token 安全策略 1. Access Token:短時效(如 15 分鐘) 2. Refresh Token:長時效(綁裝置,旋轉機制) 3. 撤銷方式:黑名單 / 版本號(user.version) ## 2. 範例專案結構(06-jwt-demo) ``` //swift 06-jwt-demo/ ├─ pom.xml └─ src/main/java/com/example/jwt/ ├─ JwtUtil.java ├─ TokenDemo.java └─ VerifyDemo.java └─ keys/ ├─ private.pem └─ public.pem ``` ## 3. 必要檔案 1. pom.xml ```xml= <project xmlns="http://maven.apache.org/POM/4.0.0" ...> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>jwt-demo</artifactId> <version>1.0-SNAPSHOT</version> <dependencies> <!-- JJWT: JSON Web Token library --> <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> </project> ``` 2. keys/private.pem(產 RSA 私鑰) ``` //bash openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048 ``` 3. keys/public.pem(導出公鑰) ``` //bash openssl rsa -in private.pem -pubout -out public.pem ``` 4. JwtUtil.java ```java= package com.example.jwt; import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; import java.nio.file.Files; import java.nio.file.Paths; import java.security.KeyFactory; import java.security.PrivateKey; import java.security.PublicKey; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; import java.util.*; public class JwtUtil { public static PrivateKey loadPrivateKey(String filename) throws Exception { byte[] keyBytes = Files.readAllBytes(Paths.get(filename)); String keyPem = new String(keyBytes) .replaceAll("-----\\w+ PRIVATE KEY-----", "") .replaceAll("\\s", ""); byte[] decoded = Base64.getDecoder().decode(keyPem); PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(decoded); return KeyFactory.getInstance("RSA").generatePrivate(keySpec); } public static PublicKey loadPublicKey(String filename) throws Exception { byte[] keyBytes = Files.readAllBytes(Paths.get(filename)); String keyPem = new String(keyBytes) .replaceAll("-----\\w+ PUBLIC KEY-----", "") .replaceAll("\\s", ""); byte[] decoded = Base64.getDecoder().decode(keyPem); X509EncodedKeySpec keySpec = new X509EncodedKeySpec(decoded); return KeyFactory.getInstance("RSA").generatePublic(keySpec); } public static String generateToken(PrivateKey key, String user, List<String> roles) { return Jwts.builder() .setSubject(user) .claim("roles", roles) .setIssuer("jwt-demo") .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 5 * 60 * 1000)) // 5 分鐘 .signWith(key, SignatureAlgorithm.RS256) .compact(); } public static Jws<Claims> verifyToken(PublicKey key, String token) { return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token); } } ``` 5. TokenDemo.java ```java= package com.example.jwt; import java.security.PrivateKey; import java.util.Arrays; public class TokenDemo { public static void main(String[] args) throws Exception { PrivateKey key = JwtUtil.loadPrivateKey("keys/private.pem"); String jwt = JwtUtil.generateToken(key, "alice", Arrays.asList("USER","ADMIN")); System.out.println("Generated JWT:"); System.out.println(jwt); } } ``` 6. VerifyDemo.java ```java= package com.example.jwt; import java.security.PublicKey; public class VerifyDemo { public static void main(String[] args) throws Exception { String token = System.getenv("JWT_TOKEN"); // 把 token 從環境變數傳入 PublicKey pub = JwtUtil.loadPublicKey("keys/public.pem"); var jws = JwtUtil.verifyToken(pub, token); System.out.println("Subject: " + jws.getBody().getSubject()); System.out.println("Roles: " + jws.getBody().get("roles")); System.out.println("Issuer: " + jws.getBody().getIssuer()); System.out.println("Exp: " + jws.getBody().getExpiration()); } } ``` ## 4. 執行流程 1. 產生 Token ``` //bash mvn -q compile exec:java -Dexec.mainClass="com.example.jwt.TokenDemo" ``` 👉 會印出一個長長的 JWT。 2. 驗證 Token ``` //bash export JWT_TOKEN=<剛剛產生的 JWT> mvn -q compile exec:java -Dexec.mainClass="com.example.jwt.VerifyDemo" ``` 👉 預期輸出: ``` //yaml Subject: alice Roles: [USER, ADMIN] Issuer: jwt-demo Exp: Sat Sep 6 12:34:56 CST 2025 ``` ## 5. 測試攻擊情境 1. 過期測試 1. 修改 JwtUtil → setExpiration(new Date(System.currentTimeMillis() + 2000))(2 秒) 2. 等 5 秒後再驗 → ExpiredJwtException 2. 篡改 Payload 1. 把中間的 payload base64 解碼 → 改成 roles=["SUPERADMIN"] → 再編碼 → 拼回 token 2. 驗證 → 會丟錯誤(簽章無效) ## 6. 常見錯誤與防範 1. ❌ JWT 永不過期 → 攻擊者可永久使用。 ✅ 設定短時效 Access Token,搭配 Refresh。 2. ❌ 把敏感資料(密碼、卡號)放進 payload → JWT payload 是明文可讀。 ✅ 僅放 non-sensitive claims,必要時加密 payload(JWE)。 3. ❌ 只用對稱 HS256 且 key 泄漏 → 全部 Token 可被偽造。 ✅ 使用 RS256 / ES256(公私鑰分離)。 ## 7. 本章檢核清單 1. 能用 RSA 產生 / 驗證 JWT 2. Token 設定了合理的過期時間 3. Refresh Token 機制設計正確(rotation + 撤銷) 4. 沒有把敏感資訊放進 payload 5. Token 驗證失敗時回 401,不是 500 ## 8. 練習實驗 1. 修改 TokenDemo → 將有效期改為 10 秒,測試過期行為。 2. 新增一個 claim → email=alice@example.com,驗證能否正常讀出。 3. 實作一個簡單黑名單機制 → 把某個 jti 記錄到 Redis,驗證時比對。 4. 用 curl 測試 Bearer Token API(新增一個簡單 Controller,只允許帶正確 JWT 的請求)。 # 7. 跨來源資源共用(CORS)、API 安全與前後端分離 ## 1. 知識點概覽 1. 什麼是 CORS? 1. 瀏覽器的 同源策略 (Same-Origin Policy) 會阻止前端 JS 向不同來源 API 發請求。 2. CORS (Cross-Origin Resource Sharing) 是伺服器透過回應特定 HTTP Header,告訴瀏覽器「哪些來源可以跨域存取」。 2. 常見 Header 1. Access-Control-Allow-Origin:允許的來源(建議精準白名單,不要 *)。 2. Access-Control-Allow-Methods:允許的 HTTP 方法(GET, POST, PUT, DELETE)。 3. Access-Control-Allow-Headers:允許的自訂 Header(例如 X-Auth-Token)。 4. Access-Control-Allow-Credentials:是否允許帶 Cookie。 3. Preflight 請求:當請求方法不是 GET/POST,或帶了自訂 header,瀏覽器會先送 OPTIONS 請求,伺服器需回應正確的 Access-Control-* header。 ## 2. 範例專案結構(07-cors-demo) ``` 07-cors-demo/ ├─ pom.xml └─ src/main/java/com/example/cors/ ├─ CorsDemoApplication.java ├─ CorsConfig.java └─ ApiController.java └─ frontend/ └─ index.html (用 fetch 測試跨域呼叫 API) ``` ## 3. 必要檔案 1. 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>cors-demo</artifactId> <packaging>jar</packaging> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project> ``` 2. CorsDemoApplication.java ```java= package com.example.cors; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class CorsDemoApplication { public static void main(String[] args) { SpringApplication.run(CorsDemoApplication.class, args); } } ``` 3. CorsConfig.java(設定 CORS) ```java= package com.example.cors; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class CorsConfig { @Bean public WebMvcConfigurer corsConfigurer() { return new WebMvcConfigurer() { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("http://localhost:3000") // 精準白名單 .allowedMethods("GET", "POST", "PUT", "DELETE") .allowedHeaders("Content-Type", "X-Auth-Token") .allowCredentials(true) // 是否允許帶 Cookie .maxAge(3600); } }; } } ``` 4. ApiController.java ```java= package com.example.cors; import org.springframework.web.bind.annotation.*; import java.util.Map; @RestController @RequestMapping("/api") public class ApiController { @GetMapping("/hello") public Map<String,String> hello() { return Map.of("msg", "Hello from API"); } @PostMapping("/echo") public Map<String,Object> echo(@RequestBody Map<String,Object> payload) { return Map.of("echo", payload); } } ``` 5. frontend/index.html(跨域 fetch 測試) ```javascript= <!DOCTYPE html> <html> <head><meta charset="UTF-8"><title>CORS Client</title></head> <body> <h2>CORS Test</h2> <button onclick="callApi()">Call API</button> <pre id="out"></pre> <script> async function callApi() { const res = await fetch("http://localhost:8080/api/hello", { method: "GET", credentials: "include" // 若需帶 cookie }); document.getElementById("out").textContent = await res.text(); } </script> </body> </html> ``` ## 4. 執行流程 1. 啟動後端 API: ``` //bash cd 07-cors-demo mvn spring-boot:run ``` 2. 開啟前端頁面(例如用 npx serve frontend,跑在 http://localhost:3000)。 3. 點按鈕 → 瀏覽器會送跨域 GET 到 http://localhost:8080/api/hello。 1. 若 CORS config 正確 → 得到 JSON。 2. 若缺設定 → Console 會報錯:Access to fetch has been blocked by CORS policy。 ## 5. 測試指令 1. 模擬 Preflight 請求 ``` //bash curl -i -X OPTIONS http://localhost:8080/api/echo \ -H "Origin: http://localhost:3000" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: Content-Type" ``` 👉 預期:回應包含 ``` //pgsql Access-Control-Allow-Origin: http://localhost:3000 Access-Control-Allow-Methods: GET,POST,PUT,DELETE Access-Control-Allow-Headers: Content-Type,X-Auth-Token ``` 2. 模擬錯誤來源 ``` //bash curl -i -X GET http://localhost:8080/api/hello -H "Origin: http://evil.com" ``` 👉 預期:不會回應 Access-Control-Allow-Origin,瀏覽器會拒絕。 ## 6. 常見錯誤與防範 1. ❌ Access-Control-Allow-Origin: * 同時允許 Credentials → 極大風險。 ✅ 若需要 Cookie,必須指定白名單(不能用 *)。 2. ❌ 忘了設定 OPTIONS 預檢請求 → 前端跨域會失敗。 ✅ 在後端允許 OPTIONS,並正確回應 CORS header。 3. ❌ 把 CORS 當成認證機制 → 攻擊者依然能直接用 curl 請求 API。 ✅ CORS 只是瀏覽器限制,仍需要 Token / Session 認證。 ## 7. 本章檢核清單 1. 後端 API 僅允許指定的 Origin(非 *)。 2. 必要時允許 Credentials,且搭配精準白名單。 3. Access-Control-Allow-Methods 僅允許必要的動詞。 4. Access-Control-Allow-Headers 僅允許必要的 header。 5. 已測試 Preflight 請求,確認 OPTIONS 回應正確。 ## 8. 練習實驗 1. 新增一個 /api/secure endpoint → 要求帶 JWT Token,測試在 CORS 配置下能否正確存取。 2. 修改 CORS config → 將 allowedOrigins("*"),再測試前端可否跨域成功,並思考安全風險。 3. 在前端呼叫時加上自訂 Header(例如 X-Auth-Token),觀察 OPTIONS preflight 是否發生。 4. 嘗試在瀏覽器 Console 用 fetch("http://localhost:8080/api/hello", {headers: {"Origin": "http://evil.com"}}) → 驗證瀏覽器會無視偽造 Origin header。 # 8. OAuth2 與 OpenID Connect(OIDC) ## 1. 知識點概覽 1. OAuth2 四種授權模式(2025 重點只用兩種) 1. Authorization Code + PKCE(推薦,Web/Mobile 標準流程) 2. Client Credentials(機器對機器的 API 呼叫) 3. (隱式流程、Resource Owner Password 已過時,不建議使用) 2. Token 類型 1. Access Token:授權存取資源(通常 JWT 或 opaque token) 2. Refresh Token:換新 Access Token,用於長期會話 3. ID Token(OIDC 才有):聲明使用者身份(誰登入了),用於認證 3. 為什麼要 OIDC? 1. OAuth2 原本只管「授權」 2. OIDC 加上 ID Token,解決「身份認證」問題 3. 這就是 Google、GitHub、Facebook 等第三方登入的基礎 ## 2. 範例專案結構(08-oauth2-demo) ``` 08-oauth2-demo/ ├─ pom.xml └─ src/main/java/com/example/oauth2/ ├─ Oauth2DemoApplication.java └─ HomeController.java └─ src/main/resources/ └─ application.yml ``` ## 3. 必要檔案 1. pom.xml ```xml= <project ...> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.9</version> </parent> <groupId>com.example</groupId> <artifactId>oauth2-demo</artifactId> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-client</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies> </project> ``` 2. application.yml(以 Google OAuth2 為例) ```yaml= spring: security: oauth2: client: registration: google: client-id: YOUR_GOOGLE_CLIENT_ID client-secret: YOUR_GOOGLE_CLIENT_SECRET scope: - openid - profile - email redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" provider: google: issuer-uri: https://accounts.google.com ``` 3. Oauth2DemoApplication.java ```java= package com.example.oauth2; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class Oauth2DemoApplication { public static void main(String[] args) { SpringApplication.run(Oauth2DemoApplication.class, args); } } ``` 4. HomeController.java ```java= package com.example.oauth2; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Map; @RestController public class HomeController { @GetMapping("/") public String home() { return "Welcome to OAuth2 Demo. <a href='/oauth2/authorization/google'>Login with Google</a>"; } @GetMapping("/me") public Map<String,Object> me(@AuthenticationPrincipal OidcUser user) { return Map.of( "name", user.getFullName(), "email", user.getEmail(), "claims", user.getClaims() ); } } ``` ## 4. 執行流程 1. 在 Google Cloud Console:建立 OAuth2 Client,取得 Client ID / Secret。 2. 把 client-id 和 client-secret 填到 application.yml。 3. 執行專案: ``` //bash cd 08-oauth2-demo mvn spring-boot:run ``` 4. 開啟瀏覽器:http://localhost:8080 → 點「Login with Google」 5. 登入後自動跳轉 → /me 會回傳你的使用者資訊(JSON 格式)。 ## 5. 測試與驗證 1. 確認 ID Token 1. 打開瀏覽器 DevTools → Network → 找 /login/oauth2/code/google 回應 2. 其中會有 ID Token(JWT 格式) 3. 可以到 https://jwt.io:貼上解析,會看到 email、name、iss、aud 等 claims 2. 驗證 API 保護: 1. 嘗試呼叫: //bash curl http://localhost:8080/me 1. 👉 未登入 → 302 Redirect to /oauth2/authorization/google 2. 👉 登入後 → 返回使用者資訊 JSON ## 6. 常見錯誤與防範 1. ❌ 只用 Access Token 當身份認證 → 攻擊者可偽造(不同 IdP 可能格式不同) ✅ 必須驗證 ID Token,並檢查 iss、aud、exp 等欄位 2. ❌ Refresh Token 永不過期 → 被盜取就能長期使用 ✅ 設定 Refresh Token 旋轉(每次使用會產生新 token) 3. ❌ 在前端 localStorage 存 Token → 容易被 XSS 竊取 ✅ 使用 HttpOnly Secure Cookie,或只存於記憶體 ## 7. 本章檢核清單 1. 是否能透過 /oauth2/authorization/google 完成登入? 2. 是否能在 /me 正確看到 ID Token 的使用者資訊? 3. 是否有驗證 ID Token 的簽章與 iss/aud 欄位? 4. 是否避免將敏感 Token 存在 localStorage? 5. 是否考慮 Refresh Token 的旋轉與撤銷? ## 8. 練習實驗 1. 新增 GitHub 登入:在 application.yml 增加 GitHub provider,測試能否在 /me 看到 GitHub Profile。 2. 在 Controller 限制角色:新增一個 /admin endpoint,使用 @PreAuthorize("hasRole('ADMIN')"),測試不同使用者權限。 3. 解析 ID Token:在後端驗證 aud 是否等於你的 client-id,若不符則拒絕。 4. 整合前端 React:使用 @azure/msal 或 oidc-client,測試前端跳轉登入 → 後端驗證。 # 9. 多因子驗證(MFA)、TOTP 與 WebAuthn ## 1. 知識點概覽 1. 認證要素三種類型 1. 知識型(Something you know):密碼、PIN 2. 持有型(Something you have):手機、硬體金鑰 3. 屬性型(Something you are):指紋、臉部 4. 👉 MFA 就是要同時具備兩種以上,安全性大幅提升。 2. TOTP(基於時間的一次性密碼) 1. 算法:TOTP = Truncate(HMAC-SHA1(secret, currentTime / 30s)) 2. 每 30 秒產生一組 6 碼,與伺服器驗證 3. 常見工具:Google Authenticator、Authy 3. WebAuthn / FIDO2 1. 基於 公開金鑰加密 的無密碼驗證 2. RP(Relying Party)只存使用者公鑰 3. 攻擊面積小,強抗釣魚與重放 ## 2. 範例專案結構(09-mfa-demo) ``` 09-mfa-demo/ ├─ pom.xml └─ src/main/java/com/example/mfa/ ├─ MfaDemoApplication.java ├─ UserController.java ├─ TotpUtil.java └─ src/main/resources/ ├─ application.yml └─ templates/ ├─ login.html ├─ mfa.html ``` ## 3. 必要檔案 1. pom.xml ```xml= <project ...> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.9</version> </parent> <groupId>com.example</groupId> <artifactId>mfa-demo</artifactId> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>de.taimos</groupId> <artifactId>totp</artifactId> <version>1.0.1</version> </dependency> </dependencies> </project> ``` 2. MfaDemoApplication.java ```java= package com.example.mfa; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class MfaDemoApplication { public static void main(String[] args) { SpringApplication.run(MfaDemoApplication.class, args); } } ``` 3. TotpUtil.java ```java= package com.example.mfa; import de.taimos.totp.TOTP; import javax.crypto.spec.SecretKeySpec; import java.security.Key; import java.util.Base64; public class TotpUtil { // 產生 TOTP 驗證碼 public static String generateCurrentNumber(String secret) { return TOTP.getOTP(secret); } // 產生 Google Authenticator QR Code URL public static String generateGoogleAuthenticatorUrl(String user, String secret) { return "otpauth://totp/" + user + "?secret=" + secret + "&issuer=MFA-Demo"; } // 將 Base32 secret 轉為 Key public static Key generateKeyFromSecret(String base32Secret) { byte[] decodedKey = Base64.getDecoder().decode(base32Secret); return new SecretKeySpec(decodedKey, 0, decodedKey.length, "HmacSHA1"); } } ``` 4. UserController.java ```java= package com.example.mfa; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.*; import javax.servlet.http.HttpSession; import java.util.Map; @Controller public class UserController { private static final String DEMO_SECRET = "JBSWY3DPEHPK3PXP"; // 測試用固定 secret @GetMapping("/login") public String loginPage() { return "login"; } @PostMapping("/login") public String doLogin(@RequestParam String username, @RequestParam String password, HttpSession session) { if ("alice".equals(username) && "password".equals(password)) { session.setAttribute("user", username); return "mfa"; } return "login"; } @PostMapping("/mfa") @ResponseBody public Map<String,String> verifyMfa(@RequestParam String code, HttpSession session) { String current = TotpUtil.generateCurrentNumber(DEMO_SECRET); if (current.equals(code)) { session.setAttribute("mfa", true); return Map.of("status","MFA success"); } return Map.of("status","MFA failed"); } @GetMapping("/secure") @ResponseBody public Map<String,String> secure(HttpSession session) { if (session.getAttribute("mfa") != null) { return Map.of("data", "This is a secure resource"); } return Map.of("error", "Unauthorized"); } } ``` 5. templates/login.html ```javascript= <!DOCTYPE html> <html> <head><title>Login</title></head> <body> <form method="post" action="/login"> <input type="text" name="username" placeholder="Username"/> <input type="password" name="password" placeholder="Password"/> <button type="submit">Login</button> </form> </body> </html> ``` 6. templates/mfa.html ```javascript= <!DOCTYPE html> <html> <head><title>MFA</title></head> <body> <h3>Enter 6-digit code from Google Authenticator:</h3> <form method="post" action="/mfa"> <input type="text" name="code" maxlength="6"/> <button type="submit">Verify</button> </form> </body> </html> ``` ## 4. 執行流程 1. 啟動應用: ``` //bash cd 09-mfa-demo mvn spring-boot:run ``` 2. 開啟瀏覽器 → http://localhost:8080/login 1. 輸入:alice / password → 進入 MFA 驗證頁 3. 在 Google Authenticator 新增帳號: 1. 使用 DEMO_SECRET = JBSWY3DPEHPK3PXP 2. 會自動產生 6 碼 TOTP 4. 在 /mfa 頁輸入該 6 碼 → 驗證成功 5. 測試 /secure endpoint: 1. 未通過 MFA → 返回 Unauthorized 2. 通過 MFA → 返回 This is a secure resource ## 5. 測試與驗證 1. 成功驗證 ``` //bash curl -X POST -d "username=alice&password=password" http://localhost:8080/login curl -X POST -d "code=123456" http://localhost:8080/mfa curl http://localhost:8080/secure ``` 2. 錯誤驗證 1. 輸入錯誤密碼 → 無法登入 2. 輸入錯誤 TOTP → 回傳 MFA failed 3. 未驗證 MFA → /secure 無法存取 ## 6. 常見錯誤與防範 1. ❌ 把 Secret 存在前端 → 攻擊者可複製產生 TOTP ✅ Secret 應安全儲存在伺服器(加密、HSM) 2. ❌ 不容忍時間偏移 → 用戶手機時間差會失敗 ✅ 允許 ±1 window(30 秒) 3. ❌ MFA 只用 Email OTP → 易被攔截 ✅ 建議使用 TOTP 或 WebAuthn ## 7. 本章檢核清單 1. 登入後必須進行第二因子驗證 2. TOTP secret 安全保存,且不暴露給前端 3. 時間同步機制已考慮(允許 ±30 秒誤差) 4. Secure endpoint 僅允許完成 MFA 的使用者存取 5. 已測試錯誤密碼、錯誤 OTP 的行為 ## 8. 練習實驗 1. 更換 Secret:讓每個使用者擁有不同的 Secret(存在資料庫)。 2. 產生 QR Code:在 /mfa-setup 頁顯示 QR Code,用戶掃描即可加入 Google Authenticator。 3. 加入 Spring Security Filter:強制所有 /secure/** endpoint 需通過 MFA 標記。 4. 探索 WebAuthn:使用 webauthn4j 或 FIDO2 Server,測試無密碼登入。 # 10. Method Security(方法層授權) — 完整教學 目的:把「誰可以呼叫哪個方法、在何種條件下可以呼叫」這件事往下移到 Service/方法層,使授權更精細、能基於參數與業務邏輯做判斷。章節內容從觀念、設定、實作範例、進階擴充到測試、常見陷阱與最佳實務,全都給你可以直接貼上跑的程式碼與逐步說明。 ## 1. 為什麼要用 Method Security? 1. URL 層(HttpSecurity)適合粗粒度:誰可以存取某個路徑。 2. Method 層能做更細的授權:依參數 (#id)、依 domain object(「只有 owner 可以修改」)、或在 service 層合併多條件(角色 + 參數比對)。 3. 更安全的分層設計:把安全檢查放在業務邏輯最接近的地方,比在 controller 只是路由層檢查更可靠。 4. 應用場景: 1. 多租戶系統:@PreAuthorize("#tenantId == principal.tenantId") 2. Domain-level 授權:@PreAuthorize("hasPermission(#documentId, 'Document', 'write')") 3. 基於 scope 的 API(OAuth2):@PreAuthorize("hasAuthority('SCOPE_read')") ## 2. 啟用 Method Security(Spring Boot + Spring Security 6 範例) ```java= import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; @Configuration @EnableMethodSecurity // 開啟 @PreAuthorize / @PostAuthorize / @Secured / JSR-250 等 public class MethodSecurityConfig { // 若需自訂 expression handler(例如註冊 PermissionEvaluator / RoleHierarchy),可在此處註冊 bean(見進階章節) } ``` 備註:舊版會用 @EnableGlobalMethodSecurity(prePostEnabled 等),新式推薦 @EnableMethodSecurity。若要同時支援 @Secured 與 JSR-250 的 @RolesAllowed,可傳參數或在 Spring Boot 中預設已支援(視 Spring Security 版本)。若你使用舊的 Spring 版本,屬性名是 prePostEnabled, securedEnabled, jsr250Enabled。 ## 3. 常用註解與語法(概覽) 1. @PreAuthorize("..."):方法執行前檢查(最常用) 2. @PostAuthorize("..."):方法執行後檢查(可以檢查 returnObject) 3. @PreFilter("..."):對傳入集合參數做過濾(在進入方法前) 4. @PostFilter("..."):對回傳集合做過濾(在方法回傳後) 5. @Secured({"ROLE_ADMIN","ROLE_USER"}):簡單角色檢查(舊式) 6. @RolesAllowed({"ROLE_ADMIN"}):JSR-250 標準註解 7. 表达式 helpers: hasRole(), hasAuthority(), hasAnyRole(), isAuthenticated(), principal, authentication, #paramName, hasPermission(...) 等。 ## 4. 基礎實作範例(最常見情境) ### 1. Security config(示範:Spring Boot) ``` @Configuration @EnableMethodSecurity public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) // route-level 例子 .httpBasic(Customizer.withDefaults()); return http.build(); } } ``` ### 2. Service 範例(使用 @PreAuthorize) ```java= @Service public class DocumentService { // 只有 ADMIN 或文件擁有者可以讀取 @PreAuthorize("hasRole('ADMIN') or #owner == authentication.name") public Document getDocument(Long id, String owner) { // 取得 document 的業務邏輯 } // 基於 permission evaluator(domain object)檢查 @PreAuthorize("hasPermission(#id, 'Document', 'write')") public void updateDocument(Long id, DocumentPayload payload) { // 更新 } // 對傳入集合先過濾:只留下 owner 是當前使用者的文件 id 列表 @PreFilter("filterObject.owner == authentication.name") public void bulkDelete(List<DocumentRef> docs) { // docs 只會包含符合條件的元素 } // 對回傳的列表做後置過濾 @PostFilter("filterObject.public == true or filterObject.owner == authentication.name") public List<Document> listAll() { ... } } ``` 逐行說明: 1. authentication 是目前 Authentication 物件;authentication.name 與 principal.username 常用。 2. #id, #owner 代表方法參數(根據參數名稱)——編譯時要保留參數名稱(Java 8+ -parameters)或使用 @Param 類似替代。 3. hasPermission(#id, 'Document', 'write') 會呼叫你註冊的 PermissionEvaluator(下一節示範)。 ## 5. 進階:自訂 PermissionEvaluator(domain object 授權) 當你需要 hasPermission(#id, 'Document', 'write') 這類語法時,需要提供 PermissionEvaluator。 ### 1. PermissionEvaluator 範例 ``` import org.springframework.security.access.PermissionEvaluator; import org.springframework.security.core.Authentication; import java.io.Serializable; public class CustomPermissionEvaluator implements PermissionEvaluator { private final DocumentRepository repo; public CustomPermissionEvaluator(DocumentRepository repo) { this.repo = repo; } @Override public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) { // 當 target 是實例物件時 if (targetDomainObject instanceof Document) { Document doc = (Document) targetDomainObject; return check(authentication, doc, String.valueOf(permission)); } return false; } @Override public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permission) { // 當只有 id 與 type 時(例如 #id, 'Document') if (!"Document".equals(targetType)) return false; Long id = (Long) targetId; Document doc = repo.findById(id).orElse(null); if (doc == null) return false; return check(authentication, doc, String.valueOf(permission)); } private boolean check(Authentication authentication, Document doc, String permission) { String username = authentication.getName(); if ("write".equals(permission)) { return username.equals(doc.getOwner()) || authentication.getAuthorities().stream() .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")); } return false; } } ``` ### 2. 註冊到 Method Security 的 expression handler ``` import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; @Configuration public class MethodSecurityExpressionConfig { @Bean public MethodSecurityExpressionHandler methodSecurityExpressionHandler(CustomPermissionEvaluator permissionEvaluator, RoleHierarchy roleHierarchy) { DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler(); handler.setPermissionEvaluator(permissionEvaluator); handler.setRoleHierarchy(roleHierarchy); // 可選 return handler; } @Bean public RoleHierarchy roleHierarchy() { RoleHierarchyImpl rh = new RoleHierarchyImpl(); rh.setHierarchy("ROLE_ADMIN > ROLE_USER\nROLE_USER > ROLE_GUEST"); return rh; } } ``` 這樣 hasPermission(...)、role hierarchy 都會在 SpEL 表達式中生效。 ## 6. 組合 OAuth2 / JWT 的場景(Scope 與 Authorities) 1. 在 Resource Server 中,OAuth2 scope 通常被映射為 authorities,例如 SCOPE_read。 2. 因此在方法層你會看到: ```java= @PreAuthorize("hasAuthority('SCOPE_read')") public Mono<Item> getItems() { ... } ``` 或 ```java= @PreAuthorize("hasRole('ADMIN')") // 當權限已映射 ROLE_ 前綴時 ``` 小技巧:在 method expressions 使用 #oauth2 的 helper 不是普遍存在於所有 handler;最保守的做法是使用 hasAuthority('SCOPE_read')。 ## 7. 自訂表達式 (Custom SpEL functions) 你可以在 DefaultMethodSecurityExpressionHandler 放入自訂 PermissionEvaluator,也可以註冊自訂 root 執行物件(例如給 expression 增加 @myfuncs.isOwner(#id))——通常要繼承 SecurityExpressionRoot 並改寫 createEvaluationContext。這比較進階,實作範例如上節用 PermissionEvaluator 可滿足多數需求。 ## 8. Self-invocation(內部呼叫)問題與解法 1. 問題:透過 Spring AOP 的 proxy 機制,從同一個 bean 的方法呼叫另一個有 @PreAuthorize 的方法時,方法階層的切面不會被觸發(因為呼叫不經過 proxy)。 2. 解法: 1. 把被保護的方法移到另一個 bean(最簡單、安全的作法)。 2. 使用 AspectJ(LTW 或 compile-time weaving) 以繞過 proxy 的限制(較複雜)。 3. 讓 bean 注入自己的 proxy(AopContext.currentProxy() 或 ApplicationContext.getBean(this.getClass())),但通常不建議(可讀性差且容易出錯)。 ## 9. 與 @Transactional 的交互與 Proxy Ordering 1. 若同時使用 @PreAuthorize 與 @Transactional,兩者都是透過 proxy/advice 實作,執行順序會影響行為(例如是否要在權限檢查前就打開 transaction)。 2. 建議:在 service 的「入口」方法做 @PreAuthorize,而把 @Transactional(需要 db transaction 的部分)放在內部方法或同層,但務必檢查你的 proxy ordering(MethodSecurityAdvisor 應該在 Transactional 之前或之後依情境而定)。若遇到 race 或 transaction 範圍問題,提早把安全檢查放在最外層(controller 或 service 的 public entry point)是最保守的選擇。 ## 10. Reactive(反應式)Method Security 若你的專案是 Spring WebFlux,需要使用 reactive 版本: ```java= import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity; @Configuration @EnableReactiveMethodSecurity public class ReactiveMethodSecurityConfig { } ``` 然後表達式也支援類似語法(注意 Mono/Flux 回傳時 @PostAuthorize 的語意差異)。對於 JWT / OAuth2 scope,也可用 hasAuthority('SCOPE_read')。Reactive 的 security handler 與 blocking 不同,測試和實作上要小心不做阻塞操作於表達式中。 ## 11. 測試(Unit / Integration) ### 1. Service 單元測試(JUnit + Spring Security Test) ```java= @SpringBootTest public class DocumentServiceTest { @Autowired DocumentService documentService; @Test @WithMockUser(username = "alice", roles = {"USER"}) void whenUserIsOwner_thenCanGetDocument() { // arrange: create doc owned by alice (mock repo) // act Document d = documentService.getDocument(1L, "alice"); // assert assertNotNull(d); } @Test @WithMockUser(username = "bob", roles = {"USER"}) void whenNotOwner_thenAccessDenied() { assertThrows(AccessDeniedException.class, () -> documentService.getDocument(1L, "alice")); } } ``` ### 2. Controller + MockMvc 測試(若 method security 應用於 service) ```java= @WebMvcTest(MyController.class) @AutoConfigureMockMvc public class MyControllerTest { @Autowired MockMvc mvc; @Test void securedEndpoint_withJwtScope() throws Exception { mvc.perform(get("/api/data").with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_read")))) .andExpect(status().isOk()); } } ``` spring-security-test 的 jwt() 與 @WithMockUser 是兩個常用工具:jwt() 用於 Resource Server / JWT 情境;@WithMockUser 用於模擬 username/roles。 ## 12. 常見錯誤與排查清單 1. 方法內自呼叫沒被攔截 → 檢查是否為 self-invocation;把方法移到另一 bean 或啟用 AspectJ。 2. #param 為 null / 找不到名稱 → Java 編譯時必須保留參數名稱(-parameters)或改用 @Param 標明。 3. hasPermission 沒作用 → 確認 PermissionEvaluator 已註冊到 MethodSecurityExpressionHandler。 4. OAuth2 scopes 無效 → 在 Resource Server 中觀察 authorities 是否被映射成 SCOPE_xxx。 5. 測試中 AccessDeniedException 沒丟 → 確認 Spring Test context 有啟用 method security(@EnableMethodSecurity 在測試 context 中)與你使用的測試輔助工具(@WithMockUser / jwt())。 6. @Secured 與 @RolesAllowed 沒作用 → 檢查是否啟用了對應功能(在某些舊版本需顯式開啟 securedEnabled / jsr250Enabled)。 ## 13. 最佳實務總結(Checklist) 1. 把關鍵的授權檢查放在 service(或更底層),不要只依賴 controller 層。 2. 優先使用 @PreAuthorize 加上 SpEL 判斷(靈活且可讀)。 3. 用 PermissionEvaluator 做 domain object-level 檢查(比把所有邏輯放在表達式更乾淨)。 4. 避免 self-invocation:將「被保護的邏輯」抽成獨立 Bean 或使用 AspectJ。 5. 在 OAuth2/JWT 場景下,事先規範 scope 與 authorities 映射(例如:scope -> SCOPE_*),在 method-level 使用 hasAuthority('SCOPE_x')。 6. 善用測試工具(@WithMockUser, jwt())寫授權測試。 7. 實務上:把 role 與權限分離(role = 多個 authority 的集合),並考慮 role hierarchy(如 ROLE_ADMIN > ROLE_USER)。 ## 14. 練習題(手把手) 1. 寫一個 DocumentService:get(id), update(id, payload), delete(ids: List),使用 @PreAuthorize, @PreFilter, @PostFilter,並用 Mockito+JUnit 寫 test 驗證 owner 與非 owner 的行為。 2. 實作 CustomPermissionEvaluator,用 DB 查 document owner,並在 update 時用 hasPermission(#id,'Document','write') 檢查。 3. 在 OAuth2 Resource Server(JWT)情境,模擬帶 SCOPE_read 的 JWT 並呼叫 @PreAuthorize("hasAuthority('SCOPE_read')") 的方法。 4. 模擬 self-invocation問題:在同一個 bean 呼叫被 @PreAuthorize 的方法,觀察是否被攔截;接著把被保護方法移到另一 bean 並測試。 5. 實作一個 @AdminOnly 自訂註解(meta-annotation)來簡化 @PreAuthorize("hasRole('ADMIN')") 的重複使用。 ## 15. 範例程式片段(可直接複製貼上) 完整 Service + PermissionEvaluator + 設定(濃縮版) ```java= // DocumentService.java @Service public class DocumentService { @PreAuthorize("hasRole('ADMIN') or @perm.hasPermission(authentication, #id, 'read')") public Document getDocument(Long id) { ... } @PreAuthorize("hasPermission(#id, 'Document', 'write')") public void updateDocument(Long id, DocumentPayload payload) { ... } @PreFilter("filterObject.owner == authentication.name") public void bulkDelete(List<DocumentRef> docs) { ... } } // CustomPermissionEvaluator.java (see earlier) @Configuration @EnableMethodSecurity public class MethodSecConfig { @Bean public MethodSecurityExpressionHandler methodSecurityExpressionHandler(CustomPermissionEvaluator pe, RoleHierarchy rh) { DefaultMethodSecurityExpressionHandler h = new DefaultMethodSecurityExpressionHandler(); h.setPermissionEvaluator(pe); h.setRoleHierarchy(rh); return h; } } ``` # 11. 審計與日誌(安全事件監控) ## 1. 為什麼需要安全審計與日誌? 1. 合規需求:多數安全框架/標準(ISO 27001、GDPR、PCI DSS)要求紀錄登入、授權與異常。 2. 偵測攻擊:透過 log 監控可偵測暴力登入、CSRF/XSS 嘗試、權限濫用。 3. 追蹤事故:事後調查需要「誰在什麼時間做了什麼動作」。 4. 系統健康:瞭解登入失敗率、用戶活動熱點。 ## 2. Spring Security 的審計基礎 1. AuthenticationEventPublisher:登入成功/失敗等事件。 2. AuthorizationEvent:授權成功/失敗事件。 3. AuditEvent:抽象事件,可透過 AuditEventRepository 儲存。 4. SecurityContextHolder:在 log 中取當前使用者資訊。 ## 3. 啟用基本審計(Spring Boot Actuator) Spring Boot 已經內建 AuditEventRepository 與 /actuator/auditevents。 1. pom.xml 依賴 ```xml= <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> ``` 2. application.yml ```yml= management: endpoints: web: exposure: include: auditevents ``` 3. 使用方式 1. 每次登入/登出,Spring Boot 都會寫入 AuditEvent(包含 username、eventType、timestamp)。 2. 查詢: ``` //bash GET http://localhost:8080/actuator/auditevents ``` ## 4. 事件監聽(Authentication/Authorization Events) 4.1 監聽登入成功/失敗 @Component public class AuthenticationEventsListener { private static final Logger log = LoggerFactory.getLogger(AuthenticationEventsListener.class); @EventListener public void onSuccess(AuthenticationSuccessEvent success) { String user = success.getAuthentication().getName(); log.info("登入成功: {}", user); } @EventListener public void onFailure(AbstractAuthenticationFailureEvent failure) { String user = (String) failure.getAuthentication().getPrincipal(); log.warn("登入失敗: {},原因: {}", user, failure.getException().getMessage()); } } 4.2 監聽授權事件 @Component public class AuthorizationEventsListener { @EventListener public void onAuthFailure(AuthorizationDeniedEvent<?> event) { log.warn("授權失敗: user={},resource={},cause={}", event.getAuthentication().get().getName(), event.getAuthorizationDecision(), event.getSource()); } } 5. 自訂 AuditEventRepository(寫入 DB/外部系統) 5.1 JPA 實作 @Entity public class AuditLog { @Id @GeneratedValue private Long id; private String principal; private String type; private Instant timestamp; private String data; // getters/setters } @Repository public interface AuditLogRepository extends JpaRepository<AuditLog, Long> { } 5.2 自訂 Repository @Component public class DatabaseAuditEventRepository implements AuditEventRepository { private final AuditLogRepository repo; public DatabaseAuditEventRepository(AuditLogRepository repo) { this.repo = repo; } @Override public void add(AuditEvent event) { AuditLog log = new AuditLog(); log.setPrincipal(event.getPrincipal()); log.setType(event.getType()); log.setTimestamp(event.getTimestamp()); log.setData(event.getData().toString()); repo.save(log); } @Override public List<AuditEvent> find(String principal, Instant after, String type) { // 可選:自訂查詢 return List.of(); } } 6. API 層級 Audit(業務操作記錄) 有時需要記錄「誰存取了哪個 API,帶了什麼參數」。 6.1 Filter 記錄所有請求 @Component public class AuditLoggingFilter extends OncePerRequestFilter { private static final Logger log = LoggerFactory.getLogger(AuditLoggingFilter.class); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String user = SecurityContextHolder.getContext().getAuthentication().getName(); log.info("API audit: user={}, method={}, uri={}", user, request.getMethod(), request.getRequestURI()); filterChain.doFilter(request, response); } } 6.2 AOP 針對 Service 層 @Aspect @Component public class AuditAspect { private static final Logger log = LoggerFactory.getLogger(AuditAspect.class); @Before("execution(* com.example.service.*.*(..))") public void audit(JoinPoint jp) { String user = SecurityContextHolder.getContext().getAuthentication().getName(); log.info("Service audit: user={} 呼叫方法 {} with args={}", user, jp.getSignature(), Arrays.toString(jp.getArgs())); } } 7. 進階整合:集中式日誌與監控 ELK Stack(Elasticsearch + Logstash + Kibana):集中分析日誌。 SIEM(Security Information & Event Management):像 Splunk, QRadar,結合威脅偵測。 告警:設定登入失敗次數超過閾值 → 觸發 Slack/Email 告警。 範例(Spring Boot logback + JSON 輸出 → ELK): <appender name="JSON" class="ch.qos.logback.core.FileAppender"> <file>logs/security-audit.json</file> <encoder class="net.logstash.logback.encoder.LogstashEncoder"/> </appender> 8. 測試(驗證審計與日誌) 8.1 使用 Spring Security Test @SpringBootTest public class AuditTest { @Autowired MockMvc mvc; @Test @WithMockUser(username="alice", roles={"USER"}) void testAuditLogging() throws Exception { mvc.perform(get("/api/secure")).andExpect(status().isOk()); // 驗證 log/DB 是否寫入紀錄 } } 8.2 模擬暴力登入 寫測試程式/腳本連續打錯密碼,看是否觸發多次 AuthenticationFailureEvent 並被寫入 log。 9. 常見錯誤與排查 AuditEvent 沒紀錄 → 確認有引入 spring-boot-starter-actuator 且開啟 auditevents endpoint。 授權失敗沒事件 → Spring Security 6 之後 AuthorizationDeniedEvent 取代舊有事件,監聽器需更新。 DB log 寫不進去 → 確認自訂的 AuditEventRepository 有被 Spring 管理且覆蓋預設實作。 log 缺少使用者資訊 → 檢查 SecurityContextHolder 的策略(預設是 ThreadLocal,若有 async/thread pool 要用 DelegatingSecurityContextRunnable)。 10. 最佳實務清單 集中化:不要讓安全 log 分散在多台機器 → 收集到集中式日誌系統。 結構化 log:用 JSON 格式方便檢索(配合 ELK/Splunk)。 敏感資訊過濾:不要 log 明文密碼/token;mask email/PII。 告警機制:登入失敗過多、權限異常 → 觸發告警。 Retention policy:依合規保存(常見 90 天 / 180 天 / 1 年)。 監控儀表板:登入次數、失敗率、API 調用趨勢。 11. 練習題 實作一個 AuthenticationEventsListener,將所有登入成功/失敗事件寫到資料庫。 使用 ELK 分析過去一週登入失敗率,畫出趨勢圖。 自訂 AuditAspect,記錄特定 Service 方法的呼叫與參數,並測試當非 owner 呼叫時是否產生授權失敗 log。 實作「暴力登入檢測」:當某個 IP 在 5 分鐘內登入失敗超過 10 次 → log warning 並阻擋該 IP。 # 12. 審計、日誌與安全事件監控 ## 1. 知識點概覽 1. 為什麼需要安全審計? 1. 合規要求:GDPR、ISO 27001、PCI-DSS 都要求記錄使用者行為 2. 偵測異常:帳號爆破、惡意存取可以透過日誌發現 3. 責任追蹤:發生資安事件時能回溯 2. 日誌原則 1. ✅ 記錄:誰(使用者ID)、什麼時候、從哪裡、做了什麼 2. ✅ 保護:日誌存放必須安全,避免被竄改 3. ❌ 禁止:不要在日誌中記錄密碼、完整 Token、信用卡號 3. Spring Security 的事件 1. AuthenticationSuccessEvent 2. AuthenticationFailureBadCredentialsEvent 3. AuthorizationDeniedEvent 4. LogoutSuccessEvent ## 2. 範例專案結構(12-audit-logging-demo) ``` //swift 12-audit-logging-demo/ ├─ pom.xml └─ src/main/java/com/example/audit/ ├─ AuditLoggingApplication.java ├─ SecurityConfig.java ├─ AuditEventListener.java └─ ApiController.java ``` ## 3. 必要檔案 1. pom.xml ```xml= <project ...> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.9</version> </parent> <groupId>com.example</groupId> <artifactId>audit-logging-demo</artifactId> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-logging</artifactId> </dependency> </dependencies> </project> ``` 2. AuditLoggingApplication.java ```java= package com.example.audit; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class AuditLoggingApplication { public static void main(String[] args) { SpringApplication.run(AuditLoggingApplication.class, args); } } ``` 3. SecurityConfig.java ```java= package com.example.audit; 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; @Configuration public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .antMatchers("/public").permitAll() .anyRequest().authenticated() ) .formLogin() .and() .logout(); return http.build(); } } ``` 4. AuditEventListener.java ```java= package com.example.audit; import org.springframework.context.event.EventListener; import org.springframework.security.authentication.event.*; import org.springframework.security.authorization.event.AuthorizationDeniedEvent; import org.springframework.security.web.authentication.logout.LogoutSuccessEvent; import org.springframework.stereotype.Component; import java.time.LocalDateTime; @Component public class AuditEventListener { @EventListener public void onSuccess(AuthenticationSuccessEvent event) { System.out.println("[AUDIT] LOGIN SUCCESS: user=" + event.getAuthentication().getName() + " time=" + LocalDateTime.now()); } @EventListener public void onFailure(AuthenticationFailureBadCredentialsEvent event) { System.out.println("[AUDIT] LOGIN FAILURE: user=" + event.getAuthentication().getName() + " time=" + LocalDateTime.now()); } @EventListener public void onLogout(LogoutSuccessEvent event) { System.out.println("[AUDIT] LOGOUT: user=" + event.getAuthentication().getName() + " time=" + LocalDateTime.now()); } @EventListener public void onDenied(AuthorizationDeniedEvent event) { System.out.println("[AUDIT] ACCESS DENIED: principal=" + event.getAuthentication().getName() + " target=" + event.getSource() + " time=" + LocalDateTime.now()); } } ``` 5. ApiController.java ```java= package com.example.audit; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class ApiController { @GetMapping("/public") public String publicApi() { return "Public API, no login required."; } @GetMapping("/secure") public String secureApi() { return "Secure API, login required."; } } ``` ## 4. 執行流程 1. 啟動應用: ``` //bash cd 12-audit-logging-demo mvn spring-boot:run ``` 2. 瀏覽 http://localhost:8080/public → 直接成功 3. 瀏覽 http://localhost:8080/secure → 會跳到登入頁 4. 登入後: 1. 成功登入 → Console 顯示 [AUDIT] LOGIN SUCCESS: user=... 2. 登入錯誤 → Console 顯示 [AUDIT] LOGIN FAILURE: user=... 3. 登出後 → Console 顯示 [AUDIT] LOGOUT: user=... 4. 嘗試存取無權限資源 → Console 顯示 [AUDIT] ACCESS DENIED... ## 5. 測試與驗證 1. 模擬登入成功 ``` //bash curl -i -u user:password http://localhost:8080/secure ``` 2. 模擬登入失敗 ``` //bash curl -i -u user:wrong http://localhost:8080/secure ``` 3. 模擬存取拒絕 1. 修改 SecurityConfig,只允許 ROLE_ADMIN 訪問 /secure 2. 用普通帳號存取 → Console 會印出 ACCESS DENIED ## 6. 常見錯誤與防範 1. ❌ 在日誌記錄密碼、完整 Token ✅ 只記錄使用者 ID、動作、時間 2. ❌ 日誌存在本機檔案,未集中管理 ✅ 使用 ELK(Elasticsearch + Logstash + Kibana)或 Loki,集中分析 3. ❌ 日誌可被刪改 ✅ 存放到防竄改儲存(append-only DB / 雲端日誌服務) ## 7. 本章檢核清單 1. 是否記錄登入成功/失敗? 2. 是否記錄登出行為? 3. 是否記錄拒絕存取事件? 4. 日誌中是否避免敏感資料? 5. 日誌是否安全存放並可集中分析? ## 8. 練習實驗 1. 集中式日誌:將日誌輸出到 ELK Stack,測試能否用 Kibana 搜索登入失敗次數。 2. 警示機制:當同一個帳號在 5 分鐘內連續失敗 10 次,觸發警報。 3. 審計 API:新增 /audit/logs API,只有管理員可查詢最近事件。 4. 與 SIEM 整合:將安全事件送到 Splunk / Wazuh,做異常行為偵測。 # 13. 測試與攻防演練 ## 1. 知識點概覽 1. 安全測試三層次 1. 單元測試:驗證安全相關邏輯,例如密碼雜湊、Token 驗證。 2. 整合測試:使用 Spring Security Test 驗證 endpoint 存取權限。 3. 滲透測試(PenTest):模擬攻擊者,測試是否能繞過防禦。 2. 常見測試工具 1. Spring Security Test:MockMvc + with(user()) 驗證授權。 2. OWASP ZAP:自動化掃描 XSS/SQLi。 3. Burp Suite:攔截修改 HTTP 請求,模擬攻擊。 4. curl / httpie:手工測試 CORS、CSRF。 3. 攻防演練觀念 1. 紅隊(Red Team):模擬攻擊者,嘗試突破防禦。 2. 藍隊(Blue Team):防禦方,監控、日誌、修補漏洞。 ## 2. 範例專案結構(13-security-testing-demo) ``` 13-security-testing-demo/ ├─ pom.xml └─ src/main/java/com/example/test/ ├─ TestDemoApplication.java └─ ApiController.java └─ src/test/java/com/example/test/ └─ SecurityTests.java ``` ## 3. 必要檔案 1. pom.xml ```xml= <project ...> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.9</version> </parent> <groupId>com.example</groupId> <artifactId>security-testing-demo</artifactId> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency> </dependencies> </project> ``` 2. TestDemoApplication.java ```java= package com.example.test; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class TestDemoApplication { public static void main(String[] args) { SpringApplication.run(TestDemoApplication.class, args); } } ``` 3. ApiController.java ```java= package com.example.test; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class ApiController { @GetMapping("/public") public String publicApi() { return "Public endpoint"; } @GetMapping("/user") public String userApi() { return "User endpoint"; } @GetMapping("/admin") public String adminApi() { return "Admin endpoint"; } } ``` 4. SecurityTests.java ```java= package com.example.test; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @AutoConfigureMockMvc public class SecurityTests { @Autowired private MockMvc mockMvc; @Test public void testPublicEndpoint() throws Exception { mockMvc.perform(get("/public")) .andExpect(status().isOk()); } @Test @WithMockUser(username="alice", roles={"USER"}) public void testUserEndpointAsUser() throws Exception { mockMvc.perform(get("/user")) .andExpect(status().isOk()); } @Test @WithMockUser(username="bob", roles={"USER"}) public void testAdminEndpointAsUser() throws Exception { mockMvc.perform(get("/admin")) .andExpect(status().isForbidden()); } @Test @WithMockUser(username="carol", roles={"ADMIN"}) public void testAdminEndpointAsAdmin() throws Exception { mockMvc.perform(get("/admin")) .andExpect(status().isOk()); } } ``` ## 4. 執行流程 1. 啟動應用: ``` //bash cd 13-security-testing-demo mvn spring-boot:run ``` 2. 測試 public endpoint: ``` //bash curl http://localhost:8080/public ``` 3. 測試 admin endpoint(未帶身份): ``` //bash curl http://localhost:8080/admin # 預期 → 403 Forbidden ``` 4. 執行測試: ``` //bash mvn test ``` 👉 所有測試通過,驗證了角色控制是否正確。 ## 5. 模擬攻擊測試 1. CSRF 測試 1. 建立一個 HTML 表單,自動 POST 到 /user/delete 2. 檢查是否有啟用 CSRF Token,否則會被攻擊 2. JWT 篡改:改掉 JWT payload → 測試是否會被拒絕(簽章驗證應失敗) 3. CORS 測試 ``` //bash curl -i -H "Origin: http://evil.com" http://localhost:8080/public ``` 👉 檢查是否誤配置 Access-Control-Allow-Origin: * ## 6. 常見錯誤與防範 1. ❌ 測試只覆蓋正常流程 → 攻擊情境未驗證 ✅ 必須包含暴力破解、錯誤角色、Token 篡改等測試 2. ❌ 日誌只記錄成功案例 → 無法偵測異常行為 ✅ 失敗登入、拒絕存取都必須有稽核記錄 3. ❌ 測試環境與正式環境不同 → 部署後漏洞才浮現 ✅ 測試應該盡量接近正式環境(相同 Security Config) ## 7. 本章檢核清單 1. 是否有單元測試驗證安全邏輯(如 Token 驗證)? 2. 是否有整合測試驗證角色控制? 3. 是否有模擬攻擊情境測試(CSRF、XSS、JWT 篡改)? 4. 是否有在 CI/CD pipeline 中自動跑安全測試? 5. 是否能追蹤異常測試案例到日誌? ## 8. 練習實驗 1. 新增一個 /deleteUser endpoint → 測試未授權使用者是否能呼叫。 2. 整合 OWASP ZAP → 自動掃描應用,產出報告。 3. 模擬爆破測試:寫 JUnit 測試,嘗試錯誤密碼登入 10 次,檢查是否有鎖定機制。 4. 建立紅隊腳本:用 curl 或 Python script 嘗試攻擊 → 驗證藍隊防護是否有效。 # 14. 部署與最佳實踐 ## 1. 知識點概覽 1. 基本原則 1. 最小暴露面:只開需要的 Port,關閉未用的服務 2. 最小權限:應用、資料庫、檔案權限都應降到最低 3. 自動化安全:CI/CD pipeline 加入測試與安全檢查 2. HTTPS 與反向代理 1. 強制 HTTPS(TLS 1.2+) 2. 設定 HSTS,避免降級攻擊 3. 反向代理(Nginx/HAProxy)建議設定: ``` //nginx add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; ``` 3. Secrets 管理 1. ❌ 不要把金鑰、密碼寫死在程式碼 2. ✅ 使用 環境變數、Vault (HashiCorp Vault, AWS Secrets Manager) 3. ✅ 定期輪替金鑰 4. 雲端環境最佳實踐 1. Docker:使用最小化基底映像檔(distroless, alpine) 2. Kubernetes: 1. Secret 使用 kubectl create secret 管理 2. NetworkPolicy 限制服務之間的存取 3. Pod Security Policy 確保容器不以 root 執行 5. 日誌與監控 1. 日誌集中化(ELK / Loki / Splunk) 2. 建立安全事件警示:暴力破解、異常登入、SQL Injection 嘗試 3. 定期備份並測試還原 ## 2. 範例專案結構(14-deployment-demo) ``` 14-deployment-demo/ ├─ pom.xml ├─ src/main/java/com/example/deploy/ │ └─ DeployDemoApplication.java ├─ src/main/resources/ │ └─ application.yml ├─ Dockerfile └─ k8s-deployment.yaml ``` ## 3. 必要檔案 1. DeployDemoApplication.java ```java= package com.example.deploy; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class DeployDemoApplication { public static void main(String[] args) { SpringApplication.run(DeployDemoApplication.class, args); } } ``` 2. application.yml ``` server: port: 8080 ssl: enabled: true key-store: classpath:keystore.p12 key-store-password: ${KEYSTORE_PASSWORD} key-store-type: PKCS12 ``` 👉 KEYSTORE_PASSWORD 從環境變數讀取,不要寫死。 3. Dockerfile ``` FROM eclipse-temurin:17-jdk-alpine WORKDIR /app COPY target/deploy-demo.jar app.jar EXPOSE 8080 ENTRYPOINT ["java","-jar","app.jar"] ``` 👉 使用 alpine 版本,避免過多不必要套件。 4. k8s-deployment.yaml ``` apiVersion: apps/v1 kind: Deployment metadata: name: deploy-demo spec: replicas: 2 selector: matchLabels: app: deploy-demo template: metadata: labels: app: deploy-demo spec: containers: - name: deploy-demo image: myrepo/deploy-demo:1.0 ports: - containerPort: 8080 env: - name: KEYSTORE_PASSWORD valueFrom: secretKeyRef: name: deploy-secrets key: keystorePassword --- apiVersion: v1 kind: Service metadata: name: deploy-demo-svc spec: type: ClusterIP selector: app: deploy-demo ports: - port: 80 targetPort: 8080 ``` ## 4. 執行流程 1. 產生 Keystore(HTTPS 憑證) ``` //bash keytool -genkeypair -alias demo -keyalg RSA -keystore keystore.p12 -storetype PKCS12 -validity 3650 -storepass secret ``` 2. 打包 Jar ``` //bash mvn clean package -DskipTests ``` 3. 建 Docker 映像檔 ``` //bash docker build -t deploy-demo:1.0 . ``` 4. 本地運行 ``` //bash docker run -p 8080:8080 -e KEYSTORE_PASSWORD=secret deploy-demo:1.0 ``` 5. 部署到 Kubernetes ``` //bash kubectl create secret generic deploy-secrets --from-literal=keystorePassword=secret kubectl apply -f k8s-deployment.yaml ``` ## 5. 測試與驗證 1. 測試 HTTPS ``` //bash curl -k https://localhost:8080/ ``` 👉 確認 SSL 正常工作。 2. 測試日誌:登入應用 → 查看日誌,是否正確輸出稽核資訊。 3. 測試 Secrets:移除環境變數 → 應該啟動失敗(避免 fallback 成硬編碼密碼)。 ## 6. 常見錯誤與防範 1. ❌ 把金鑰、密碼放在程式碼或 GitHub ✅ 使用環境變數或 Secrets Manager 2. ❌ 使用肥大 Docker Image(例如 openjdk:17-jdk) ✅ 使用精簡 image(eclipse-temurin:17-jdk-alpine 或 distroless) 3. ❌ Pod 以 root 身份執行 ✅ 在 Kubernetes 加入 SecurityContext 限制 4. ❌ 沒有限制 CORS,API 被濫用 ✅ 僅允許特定來源 ## 7. 本章檢核清單 1. HTTPS 已啟用,並設定 HSTS 2. Secrets 不在程式碼,而是環境變數 / Vault 3. Docker 映像檔基於最小化 image 4. Kubernetes Deployment 使用 Secret 管理敏感資訊 5. 已集中化日誌,並設定告警機制 ## 8. 練習實驗 1. 替換憑證:把 keystore 換成 Let’s Encrypt SSL,確保能在雲端使用。 2. 新增安全 Header:在 Nginx/反向代理加上 X-Content-Type-Options: nosniff、X-Frame-Options: DENY。 3. 嘗試移除 Secrets:觀察系統是否安全失敗(Fail-Safe)。 4. 安全掃描:用 trivy 掃描 Docker image,檢查是否有漏洞套件