在接案過程中,客戶最常問的問題之一就是:「會不會有人惡意發送大量XX?」確實,沒有防護的 OTP 服務就像沒有鎖的金庫,任何人都能無限制地消耗簡訊額度。
從 SaaS 商業模式來看,防濫用機制更是必須:
常見的速率限制演算法比較:
演算法 | 優點 | 缺點 | 適用場景 |
---|---|---|---|
Fixed Window | 實作簡單 | 邊界效應問題 | 粗略限制 |
Sliding Window | 精確控制 | 記憶體消耗大 | 精確計費 |
Token Bucket | 允許突發流量 | 實作複雜 | API 限制 ✅ |
Leaky Bucket | 平滑流量 | 無法突發 | 流量整形 |
對於 OTP 服務,我選擇 Token Bucket,原因是:
@kyong/kyo-core
中已經實作的邏輯:
// packages/kyo-core/src/rateLimiter.ts (實際檔案內容)
import type { RedisLike } from './redis';
export interface RateLimitResult {
allowed: boolean;
remaining: number;
resetInSec: number;
}
export async function tokenBucket(
redis: RedisLike,
key: string,
capacity = 5,
refillPerMinute = 5
): Promise<RateLimitResult> {
const now = Math.floor(Date.now() / 1000);
const tokensKey = `${key}:tokens`;
const tsKey = `${key}:ts`;
// 獲取目前的 tokens 和時間戳
const tokensRaw = await redis.get(tokensKey);
const tsRaw = await redis.get(tsKey);
let tokens = tokensRaw ? Number(tokensRaw) : capacity;
const lastTs = tsRaw ? Number(tsRaw) : now;
// 計算應該補充的 tokens
const elapsed = Math.max(0, now - lastTs);
const refill = Math.floor((elapsed * refillPerMinute) / 60);
tokens = Math.min(capacity, tokens + refill);
// 嘗試消耗一個 token
if (tokens <= 0) {
const resetIn = Math.max(0, 60 - (elapsed % 60));
await redis.set(tokensKey, String(tokens));
await redis.set(tsKey, String(now));
return { allowed: false, remaining: 0, resetInSec: resetIn };
}
// 成功消耗 token
tokens -= 1;
await redis.set(tokensKey, String(tokens));
await redis.set(tsKey, String(now));
const remaining = tokens;
const resetIn = Math.max(0, 60 - (elapsed % 60));
return { allowed: true, remaining, resetInSec: resetIn };
}
這個實作比較簡單,但在高併發情況下可能有 Race Condition 的問題。
OtpService.send()
方法:
// packages/kyo-core/src/index.ts (實際實作)
async send(req: OtpSendRequest): Promise<OtpSendResponse> {
// 1. 速率限制檢查
const rl = await tokenBucket(this.redis, this.rateKey(req.phone));
if (!rl.allowed) {
throw new KyoError('E_RATE_LIMIT', 'Too many requests', 429, {
resetInSec: rl.resetInSec
});
}
// 2. 生成並儲存驗證碼
const code = this.generateCode(6);
await this.redis.set(this.codeKey(req.phone), code, 'EX', 300);
// 3. 發送簡訊
const res = await this.sms.send({
phone: req.phone,
message: `OTP: ${code}`
});
return res;
}
目前的流程:速率限制 → 生成驗證碼 → 發送簡訊。
現有的 verify()
方法包含防暴力破解機制:
// packages/kyo-core/src/index.ts (實際的 verify 邏輯)
async verify(req: OtpVerifyRequest): Promise<OtpVerifyResponse> {
// 1. 檢查鎖定狀態
const locked = await this.redis.get(this.lockKey(req.phone));
if (locked) {
throw new KyoError('E_OTP_LOCKED', 'Locked due to too many attempts', 429);
}
// 2. 驗證碼比對
const code = await this.redis.get(this.codeKey(req.phone));
if (code && code === req.otp) {
await this.redis.del(this.codeKey(req.phone));
return { success: true };
}
// 3. 失敗次數累計與鎖定邏輯
const attemptsKey = `${this.codeKey(req.phone)}:attempts`;
const attempts = await this.redis.incr(attemptsKey);
if (attempts === 1) await this.redis.expire(attemptsKey, 600);
const remaining = Math.max(0, 3 - attempts);
if (attempts >= 3) {
await this.redis.set(this.lockKey(req.phone), '1', 'EX', 600);
throw new KyoError('E_OTP_LOCKED', 'Locked due to too many attempts', 429);
}
throw new KyoError('E_OTP_INVALID', 'Invalid OTP', 400, {
attemptsLeft: remaining
});
}
良好的 Key 設計對效能和維護性都很重要:
// packages/kyo-core/src/index.ts (Key 命名策略)
export class OtpService {
private codeKey(phone: string) {
return `otp:${phone}`;
}
private lockKey(phone: string) {
return `otp:lock:${phone}`;
}
private rateKey(phone: string) {
return `rate:${phone}`;
}
}
Key 設計原則:
otp:
、rate:
清楚區分用途統一的錯誤處理讓前端能提供更好的使用者體驗:
// packages/kyo-types/src/errors.ts (現有錯誤定義)
export class KyoError extends Error {
constructor(
public code: string,
message: string,
public status: number = 500,
public issues?: any
) {
super(message);
this.name = 'KyoError';
}
}
// 常見錯誤碼
// E_RATE_LIMIT: 速率限制 (429)
// E_OTP_LOCKED: 帳號鎖定 (429)
// E_OTP_INVALID: 驗證碼錯誤 (400)
前端處理策略:
// 前端錯誤處理範例
try {
await api.sendOtp({ phone });
} catch (error) {
if (error.code === 'E_RATE_LIMIT') {
toast.error(`請稍後再試,${error.issues.resetInSec} 秒後可重新發送`);
} else if (error.code === 'E_OTP_LOCKED') {
toast.error('嘗試次數過多,請 10 分鐘後再試');
}
}
目前的實作已經涵蓋了基本的防濫用需求,但還有改進空間:
1. Race Condition 問題
// 目前的實作在高併發下可能有問題
// 未來可以考慮使用 Lua Script 來確保原子性
2. 更靈活的配置
// 可以從環境變數或配置檔案讀取參數
const capacity = parseInt(process.env.RATE_LIMIT_CAPACITY || '5');
const refillPerMinute = parseInt(process.env.RATE_LIMIT_REFILL || '5');
3. 監控指標
// 未來可以加入監控,追蹤:
// - 速率限制觸發次數
// - 帳號鎖定事件
// - 平均驗證嘗試次數
✅ Token Bucket 基礎實作:每手機號碼 5 次/分鐘限制
✅ 暴力破解防護:3 次失敗鎖定 10 分鐘
✅ Redis Key 策略:清晰的命名空間設計
✅ 錯誤處理機制:統一的 KyoError 類別
✅ 核心邏輯完整:發送與驗證流程已可運作
優點:
改進空間:
明天(Day5)我們將: