← Blog

Giới hạn tần suất và khóa đăng nhập trong NestJS với Redis

Backend và vận hành

Hai lớp bảo vệ cho API: giới hạn request theo IP bằng @nestjs/throttler lưu trong Redis, và khóa tạm thời theo email sau nhiều lần đăng nhập sai, không làm lộ tài khoản nào tồn tại.

Một endpoint đăng nhập công khai sẽ sớm bị dò mật khẩu. Trong API NestJS của dailerc.com, chúng tôi dùng hai lớp bảo vệ độc lập: giới hạn tần suất theo IP và khóa đăng nhập theo email. Cả hai đều lưu bộ đếm trong Redis để dùng chung giữa nhiều tiến trình.

Lớp 1: giới hạn theo IP với @nestjs/throttler

Chúng tôi đăng ký ThrottlerModule với storage Redis từ gói @nest-lab/throttler-storage-redis, dùng lại client Redis của module cache. Giới hạn mặc định là 120 request mỗi phút cho mỗi IP. Các route nhạy cảm có giới hạn riêng, đặt ở một file duy nhất:

export const LIMITS = {
  contact: { default: { limit: 5, ttl: 600_000 } },
  register: { default: { limit: 5, ttl: 3_600_000 } },
  login: { default: { limit: 10, ttl: 60_000 } },
  refresh: { default: { limit: 30, ttl: 60_000 } },
} as const

@Throttle(LIMITS.login)
@Post('login')
login(@Body() dto: LoginDto) { ... }

Xác định đúng IP của người dùng

Đằng sau reverse proxy, req.ip mặc định là IP của proxy, nghĩa là mọi người dùng chung một bộ đếm. Chúng tôi có đúng một proxy là Caddy, nên đặt app.set('trust proxy', 1). Express khi đó lấy IP từ header X-Forwarded-For do Caddy thêm vào. Đừng đặt trust proxy thành true: khi đó client có thể tự gửi header giả và chọn IP tùy ý.

Guard của chúng tôi kế thừa ThrottlerGuard và thêm ba quy tắc:

  • Request không có X-Forwarded-For không đi qua Caddy, tức là lời gọi nội bộ (ví dụ trang render phía server). Gộp chúng vào một bộ đếm sẽ chặn nhầm cả website, nên chúng được bỏ qua.
  • Kết nối WebSocket không bị giới hạn bởi guard HTTP này.
  • Khi Redis lỗi, request được cho qua (fail-open) và lỗi được ghi log có giới hạn tần suất.
protected async handleRequest(props: ThrottlerRequest): Promise<boolean> {
  try {
    return await super.handleRequest(props)
  } catch (err) {
    if (err instanceof ThrottlerException) throw err
    this.logStorageError(err as Error)
    return true
  }
}

Fail-open là một đánh đổi có chủ đích: với một website doanh nghiệp, Redis gặp sự cố không được phép làm hỏng trang hay chặn đăng nhập. Hệ thống cần chặn tuyệt đối thì nên chọn fail-closed.

Lớp 2: khóa theo email

Giới hạn theo IP không đủ khi kẻ tấn công dùng nhiều IP. Vì vậy chúng tôi đếm lần đăng nhập sai theo email: 5 lần sai trong 15 phút sẽ khóa email đó 15 phút. Bộ đếm dùng lệnh tăng có TTL trong Redis, và email được chuẩn hóa (trim, chữ thường) trước khi làm khóa:

async recordFailure(scope, email) {
  const count = await this.cache.incrWithTtl(countKey(scope, email), 900)
  if (count === null || count < 5) return
  await this.cache.setJson(lockedKey(scope, email), 1, 900)
  await this.cache.delMany([countKey(scope, email)])
}

Đăng nhập thành công sẽ xóa bộ đếm. Dịch vụ khóa dùng cùng cơ chế fail-open: Redis lỗi thì coi như không bị khóa.

Đừng để lộ tài khoản nào tồn tại

Khóa đăng nhập dễ trở thành công cụ dò tài khoản nếu làm vội. Chúng tôi xử lý ba điểm:

  • Kiểm tra khóa trước khi tìm tài khoản, nên email bị khóa trả cùng một lỗi 429 dù tài khoản có tồn tại hay không.
  • Email không tồn tại hoặc tài khoản bị vô hiệu hóa vẫn chạy một lần bcrypt.compare với một hash giả cùng cost 12. Thời gian phản hồi vì thế không tiết lộ email nào có thật.
  • Mọi lần sai đều trả cùng thông báo Invalid credentials.

Phân biệt hai loại 429 cho giao diện

Cả hai lớp đều trả HTTP 429, nhưng ý nghĩa khác nhau: giới hạn IP hết sau khoảng một phút, còn khóa email kéo dài 15 phút. Phản hồi khóa email có thêm code: 'ACCOUNT_LOCKED', nhờ đó trang đăng nhập hiển thị đúng thông báo cho người dùng thay vì một câu chung chung.

Tóm tắt

  • Lưu bộ đếm trong Redis để giới hạn đúng khi chạy nhiều tiến trình.
  • Đặt trust proxy đúng bằng số proxy bạn kiểm soát.
  • Kết hợp giới hạn theo IP và theo tài khoản; mỗi lớp chặn một kiểu tấn công.
  • Giữ thời gian và thông báo lỗi giống nhau cho mọi trường hợp thất bại.

Bài viết liên quan