前言:SaaS 系統的認證挑戰
在多租戶 SaaS 系統中,我們面臨兩種使用者:
| 角色 | 說明 | 存取範圍 |
|---|---|---|
| 商戶 (Merchant) | 使用系統的客戶 | 只能看到自己的資料 |
| 平台 (Platform) | 系統管理者 | 可以看到所有商戶資料 |
- 商戶 A 絕對不能看到商戶 B 的訂單
- Token 被盜用時要能快速失效
- 支援多裝置同時登入
Token 設計
Token 結構
“tokenId”: “uuid-xxxx-xxxx”,
“userType”: “MERCHANT”,
“userId”: “U001”,
“merchantId”: “M001”,
“permissions”: [“ORDER_READ”, “ORDER_WRITE”],
“issuedAt”: “2024-03-18T10:00:00Z”,
“expiresAt”: “2024-03-18T12:00:00Z”
}
Token 驗證流程
│
▼
┌─────────────────────┐
│ 1. 檢查 Token 存在 │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ 2. 檢查 Token 有效 │ ──── 過期/無效 ──→ 401 Unauthorized
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ 3. 載入使用者資訊 │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ 4. 設定安全上下文 │
└──────────┬──────────┘
│
▼
繼續處理請求
Filter 實作
public class TokenAuthFilter extends OncePerRequestFilter {
@Autowired
private TokenService tokenService;
@Autowired
private TokenCache tokenCache;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws Exception {
// 1. 從 Header 取得 Token
String token = extractToken(request);
if (token == null) {
sendError(response, “Missing token”);
return;
}
// 2. 驗證 Token(先查快取)
TokenInfo tokenInfo = tokenCache.get(token);
if (tokenInfo == null) {
tokenInfo = tokenService.validate(token);
if (tokenInfo != null) {
tokenCache.put(token, tokenInfo);
}
}
if (tokenInfo == null || tokenInfo.isExpired()) {
sendError(response, “Invalid or expired token”);
return;
}
// 3. 設定安全上下文
SecurityContext.set(tokenInfo);
try {
chain.doFilter(request, response);
} finally {
SecurityContext.clear();
}
}
private String extractToken(HttpServletRequest request) {
String header = request.getHeader(“Authorization”);
if (header != null && header.startsWith(“Bearer “)) {
return header.substring(7);
}
return null;
}
}
商戶隔離
所有資料存取都要加上商戶過濾:
public class OrderRepository {
public List<Order> findOrders(OrderQuery query) {
// 從安全上下文取得當前商戶
TokenInfo token = SecurityContext.get();
if (token.isMerchant()) {
// 商戶只能查自己的資料
query.setMerchantId(token.getMerchantId());
}
// 平台角色可以查所有商戶(不加過濾條件)
return jdbcTemplate.query(
“SELECT * FROM orders WHERE merchant_id = ? AND …”,
query.getMerchantId(),
…
);
}
}
Token 快取策略
| 策略 | 設定 | 原因 |
|---|---|---|
| 快取時間 | 5 分鐘 | 減少 DB 查詢,但不會太舊 |
| 最大數量 | 10,000 | 避免記憶體爆炸 |
| 淘汰策略 | LRU | 不常用的 Token 先移除 |
public Cache<String, TokenInfo> tokenCache() {
return Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats()
.build();
}
Token 失效機制
當需要強制登出時:
public class TokenService {
/**
* 強制失效單一 Token
*/
public void revokeToken(String tokenId) {
// 1. 從資料庫標記失效
tokenRepository.markRevoked(tokenId);
// 2. 從快取移除
tokenCache.invalidate(tokenId);
// 3. 發布失效事件(通知其他節點)
eventPublisher.publish(new TokenRevokedEvent(tokenId));
}
/**
* 強制登出商戶所有 Token
*/
public void revokeAllTokens(String merchantId) {
List<String> tokens = tokenRepository.findByMerchant(merchantId);
tokens.forEach(this::revokeToken);
}
}
權限檢查
public class OrderController {
// 需要 ORDER_READ 權限
@RequirePermission(“ORDER_READ”)
@GetMapping(“/api/orders”)
public List<Order> listOrders() {
return orderService.findOrders();
}
// 需要 ORDER_WRITE 權限
@RequirePermission(“ORDER_WRITE”)
@PostMapping(“/api/orders/{id}/ship”)
public Order shipOrder(@PathVariable String id) {
return orderService.ship(id);
}
}
安全總結
| 安全機制 | 實作方式 | 防護目標 |
|---|---|---|
| Token 驗證 | Filter 攔截 | 未授權存取 |
| 商戶隔離 | 強制覆蓋 merchantId | 資料外洩 |
| Token 快取 | Caffeine + 分散式同步 | 效能 |
| 強制失效 | 事件發布 + 快取清除 | 帳號被盜 |
| 權限檢查 | 註解 + AOP | 越權操作 |
為什麼不用其他方案?
| 方案 | 優點 | 缺點 | 結論 |
|---|---|---|---|
| Session-based | 簡單、狀態伺服器端管理 | 不適合分散式、需要 Session 複製 | 單機可用 |
| JWT(無狀態) | 不需查 DB、自包含 | 無法主動失效、Token 可能很大 | 短期 Token 可用 |
| OAuth 2.0 | 標準協議、支援第三方 | 複雜、需要額外服務 | 需要 SSO 時用 |
| Token + 快取 | 可主動失效、效能好 | 需要 Redis | SaaS 系統首選 |
實戰踩坑
早期版本有個 API 忘記加商戶過濾,某商戶發現可以透過改 URL 的 orderId 看到別人的訂單。好險被內部測試發現,立刻修復並全面檢查所有 API。教訓:所有 Repository 方法預設都要加 merchantId 過濾。
三台 Server,使用者在 A 機登出,但 B、C 機的快取還有 Token。結果登出後還能繼續操作 30 秒。解法:登出時發布事件到所有節點,同步清除快取。
最初設計了 50+ 種權限,結果商戶搞不懂、客服也解釋不清。後來簡化成 5 個角色(管理員、主管、操作員、財務、唯讀),大幅降低維護成本。
系列導航
| ◀ 上一篇 Kafka 事件驅動 |
📚 返回目錄 | 下一篇 ▶ 健康檢查 |
發佈留言