企業級多租戶認證:Token 驗證實戰

商業價值:多租戶認證讓「一套系統服務多個商戶」,是 SaaS 模式的技術基礎。這直接支撐 導讀篇提到的 70% 人力成本降低(多商戶共用系統,不用每家都建一套)。

前言: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 實作

@Component
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;
}
}


商戶隔離

所有資料存取都要加上商戶過濾:

@Repository
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(),

);
}
}

安全保證:即使前端傳入其他商戶 ID,後端也會強制覆蓋為當前登入商戶的 ID。

Token 快取策略

策略 設定 原因
快取時間 5 分鐘 減少 DB 查詢,但不會太舊
最大數量 10,000 避免記憶體爆炸
淘汰策略 LRU 不常用的 Token 先移除
@Bean
public Cache<String, TokenInfo> tokenCache() {
return Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats()
.build();
}

Token 失效機制

當需要強制登出時:

@Service
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);
}
}


權限檢查

@RestController
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 系統首選

實戰踩坑

坑 1:商戶資料外洩

早期版本有個 API 忘記加商戶過濾,某商戶發現可以透過改 URL 的 orderId 看到別人的訂單。好險被內部測試發現,立刻修復並全面檢查所有 API。教訓:所有 Repository 方法預設都要加 merchantId 過濾

坑 2:Token 快取同步問題

三台 Server,使用者在 A 機登出,但 B、C 機的快取還有 Token。結果登出後還能繼續操作 30 秒。解法:登出時發布事件到所有節點,同步清除快取。

坑 3:權限設計太細

最初設計了 50+ 種權限,結果商戶搞不懂、客服也解釋不清。後來簡化成 5 個角色(管理員、主管、操作員、財務、唯讀),大幅降低維護成本。


系列導航

◀ 上一篇
Kafka 事件驅動
📚 返回目錄 下一篇 ▶
健康檢查

留言

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *