iT邦幫忙

2025 iThome 鐵人賽

DAY 4
0
Software Development

30 天打造工作室 SaaS 產品 (後端篇)系列 第 4

Day4:防濫用機制:Redis Token Bucket 速率限制

  • 分享至 

  • xImage
  •  

為什麼需要防濫用機制?

在接案過程中,客戶最常問的問題之一就是:「會不會有人惡意發送大量XX?」確實,沒有防護的 OTP 服務就像沒有鎖的金庫,任何人都能無限制地消耗簡訊額度。

從 SaaS 商業模式來看,防濫用機制更是必須:

  • 成本控制:每則簡訊都有費用,無限制發送會造成虧損
  • 服務品質:防止單一用戶影響整體服務效能
  • 法規遵循:避免被當作垃圾簡訊來源

速率限制策略分析

常見的速率限制演算法比較

演算法 優點 缺點 適用場景
Fixed Window 實作簡單 邊界效應問題 粗略限制
Sliding Window 精確控制 記憶體消耗大 精確計費
Token Bucket 允許突發流量 實作複雜 API 限制 ✅
Leaky Bucket 平滑流量 無法突發 流量整形

對於 OTP 服務,我選擇 Token Bucket,原因是:

  • 允許正常用戶偶爾需要快速重發
  • Redis 實作相對簡單
  • 能有效防止持續攻擊

現有實作

@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 的問題。

OTP 服務中的防濫用整合

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

Redis Key 設計策略

良好的 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: 清楚區分用途
  • 可預測性:格式一致,易於除錯
  • 避免衝突:使用手機號碼作為唯一識別
  • 過期策略:所有 Key 都有適當的 TTL

錯誤處理與使用者體驗

統一的錯誤處理讓前端能提供更好的使用者體驗:

// 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)我們將:

  1. 優化 Token Bucket 實作(考慮引入 Lua Script)
  2. 加入基本的監控指標收集
  3. 實作 PostgreSQL 持久化層(otp_logs 表)

上一篇
Day3:初始化後端專案與核心套件設定
系列文
30 天打造工作室 SaaS 產品 (後端篇)4
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言