Giới hạn tần suất và khóa đăng nhập trong NestJS với Redis
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-Forkhô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.comparevớ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
Tự host với Docker Compose, Caddy và GitHub self-hosted runner
Cách dailerc.com chạy trên một máy chủ riêng: một origin duy nhất sau Caddy, các dịch vụ trong Docker Compose có healthcheck, và mỗi commit lên main được deploy tự động bởi một runner tự host, có backup và rollback.
Đọc tiếpNhận thanh toán chuyển khoản VietQR và xác nhận qua webhook
Thiết kế luồng thanh toán VietQR cho Dailerc Tools Store: mã đơn trong nội dung chuyển khoản, webhook được xác thực, kiểm tra số tiền chính xác, chống xử lý trùng và phương án xác nhận thủ công.
Đọc tiếpISR và revalidate theo yêu cầu trong Next.js 15
Cách website dailerc.com vừa phục vụ trang tĩnh nhanh, vừa cập nhật ngay khi quản trị viên sửa nội dung: fetch có tag, revalidateTag sau mỗi thay đổi và một route revalidate được bảo vệ cho lúc deploy.
Đọc tiếp