# 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,檢查是否有漏洞套件