Danh sách bài viết

Bài 39: Login Protection — Chống Brute Force

Login endpoint là mục tiêu tấn công phổ biến: brute force thử nhiều password cho một account, credential stuffing thử hàng loạt username/password bị leak, password spraying thử vài password yếu trên nhiều account. Bài này đi qua cách xây dựng login protection với Redis: multi-dimension rate limit (per account, per IP), Lua atomic counter, progressive delay (exponential backoff), account lockout có TTL, CAPTCHA trigger, rồi combine tất cả lại thành một luồng defense in depth. Cuối bài có anti-pattern và security note quan trọng.

28/05/2026
0 lượt xem
1

Mục Tiêu Bài Học

  • Phân biệt ba loại tấn công login phổ biến: brute force, credential stuffing, password spraying.
  • Hiểu tại sao phải rate limit theo nhiều chiều (per account, per IP) thay vì chỉ một.
  • Triển khai failed attempt counter với INCR + EXPIRE, phiên bản atomic với Lua.
  • Áp dụng progressive delay (exponential backoff) để làm chậm attacker mà không lock account.
  • Biết khi nào dùng account lockout, khi nào dùng CAPTCHA trigger, trade-off của từng pattern.
  • Combine các pattern thành luồng login_flow hoàn chỉnh theo defense in depth.
  • Nhận diện anti-pattern và hiểu security note về username enumeration, timing attack.
2

Các Kiểu Tấn Công Vào Login

Ba loại tấn công phổ biến nhắm vào login endpoint, mỗi loại có đặc điểm khác nhau:

Kiểu tấn công Mô tả Dấu hiệu nhận biết
Brute force Thử nhiều password cho một account cụ thể. Nhiều fail liên tiếp cùng username, từ ít IP.
Credential stuffing Thử hàng loạt cặp username/password bị leak từ data breach khác. Fail trải rộng trên nhiều account, IP đa dạng (hoặc botnet).
Password spraying Thử vài password phổ biến (Password1, Summer2024) trên nhiều account. Mỗi account chỉ vài fail, nhưng tổng fail trên IP cao.

Điểm khác nhau quan trọng: brute force tập trung vào một account, còn credential stuffing và password spraying trải rộng qua nhiều account. Rate limit chỉ theo account sẽ bỏ qua hai loại sau; rate limit chỉ theo IP sẽ bỏ qua brute force từ nhiều IP. Vì vậy phải rate limit theo nhiều chiều.

3

Multi-Dimension Rate Limit

Login protection cần ít nhất hai chiều counter độc lập:

  • Per account: đếm số lần fail theo username. Chặn brute force nhắm vào một account.
  • Per IP: đếm số lần fail theo địa chỉ IP. Chặn credential stuffing / spraying từ một IP.
  • Per IP + account (combo): granular hơn, dùng khi cần phân tách — một IP thử nhiều account khác với một IP thử cùng account.

Có thể thêm global counter nếu muốn phát hiện đợt tấn công quy mô lớn (tổng fail của toàn hệ thống vượt ngưỡng bất thường), nhưng thường per-account và per-IP đã đủ cho hầu hết trường hợp.

# Key scheme
login:fail:user:{username}      # per account
login:fail:ip:{ip}              # per IP
login:fail:combo:{ip}:{user}    # per combo (tuỳ chọn)
lockout:user:{username}         # account lockout flag
captcha:required:user:{username} # CAPTCHA flag

Mỗi key đều cần TTL để tự xoá sau một khoảng thời gian (thường 15-30 phút). Không có TTL thì counter tích lũy mãi, làm khóa user hợp lệ chỉ vì họ gõ sai vài lần trong quá khứ.

4

Pattern 1 — Failed Attempt Counter

Pattern cơ bản nhất: increment counter khi login fail, check counter trước khi cho phép thử tiếp, reset counter khi login thành công.

import redis

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

ACCOUNT_FAIL_LIMIT = 10   # tối đa fail per account
IP_FAIL_LIMIT = 30        # tối đa fail per IP (nhiều user share IP)
WINDOW_SECONDS = 900      # 15 phút

def record_failed_login(username: str, ip: str) -> tuple[int, int]:
    """Ghi nhận một lần login fail. Trả về (acc_fails, ip_fails)."""
    acc_key = f"login:fail:user:{username}"
    ip_key = f"login:fail:ip:{ip}"

    # INCR trả về giá trị sau khi tăng
    acc_fails = r.incr(acc_key)
    r.expire(acc_key, WINDOW_SECONDS)  # refresh window

    ip_fails = r.incr(ip_key)
    r.expire(ip_key, WINDOW_SECONDS)

    return acc_fails, ip_fails

def is_login_blocked(username: str, ip: str) -> bool:
    """Kiểm tra xem login có đang bị block không."""
    acc_fails = int(r.get(f"login:fail:user:{username}") or 0)
    ip_fails = int(r.get(f"login:fail:ip:{ip}") or 0)
    return acc_fails >= ACCOUNT_FAIL_LIMIT or ip_fails >= IP_FAIL_LIMIT

def reset_fail_counter(username: str) -> None:
    """Gọi khi login thành công để reset counter."""
    r.delete(f"login:fail:user:{username}")

Lưu ý: r.expire(key, WINDOW_SECONDS) gọi sau mỗi incr sẽ refresh TTL về 15 phút tính từ lần fail gần nhất. Nếu muốn window cố định (không refresh), cần dùng Lua hoặc đặt TTL chỉ khi key mới tạo (kiểm tra giá trị trả về của incr bằng 1). Phần dưới trình bày cách làm đúng bằng Lua.

Điểm quan trọng: reset counter khi login thành công. Nếu quên bước này, user gõ sai vài lần trước khi đăng nhập được sẽ bị block ở lần tiếp theo mà không hiểu lý do.

5

Atomic Version Với Lua

Phiên bản ở mục 4 có vấn đề: INCREXPIRE là hai lệnh riêng. Nếu process crash sau INCR nhưng trước EXPIRE, key sẽ tồn tại không có TTL — user bị khóa vĩnh viễn cho đến khi tự xóa. Bài 32 đã trình bày tại sao atomicity quan trọng; đây là một ví dụ thực tế trong login protection.

Ngoài ra, với traffic cao, nhiều request login fail đồng thời có thể xảy ra race: hai thread cùng đọc GET thấy giá trị cũ rồi cùng INCR, TTL bị ghi đè không đúng. Lua script chạy atomically trên Redis, tránh toàn bộ race condition này.

-- Script: increment counter, set TTL chỉ khi key mới, check threshold
-- KEYS[1]: key (vd login:fail:user:alice)
-- ARGV[1]: threshold (số fail tối đa)
-- ARGV[2]: TTL (giây) — chỉ set khi key tạo lần đầu

local fails = redis.call('INCR', KEYS[1])
if fails == 1 then
    -- Key mới tạo: set TTL để giới hạn window
    redis.call('EXPIRE', KEYS[1], ARGV[2])
end
if fails >= tonumber(ARGV[1]) then
    return 1  -- blocked
end
return 0  -- chưa bị block
LUA_CHECK_AND_INCR = """
local fails = redis.call('INCR', KEYS[1])
if fails == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[2])
end
if fails >= tonumber(ARGV[1]) then
    return 1
end
return 0
"""

# Load script một lần khi khởi động
_check_incr_script = r.register_script(LUA_CHECK_AND_INCR)

def record_failed_login_atomic(username: str, ip: str) -> dict:
    """Atomic version dùng Lua. Trả về dict chỉ ra chiều nào bị block."""
    acc_key = f"login:fail:user:{username}"
    ip_key = f"login:fail:ip:{ip}"

    acc_blocked = _check_incr_script(
        keys=[acc_key],
        args=[ACCOUNT_FAIL_LIMIT, WINDOW_SECONDS]
    )
    ip_blocked = _check_incr_script(
        keys=[ip_key],
        args=[IP_FAIL_LIMIT, WINDOW_SECONDS]
    )

    return {
        "account_blocked": bool(acc_blocked),
        "ip_blocked": bool(ip_blocked),
        "blocked": bool(acc_blocked or ip_blocked),
    }

Window ở đây là fixed window bắt đầu từ lần fail đầu tiên. Lần fail đầu tạo key và set TTL; các lần fail sau chỉ increment, TTL giữ nguyên. Key tự xóa sau 15 phút kể từ fail đầu, counter reset về 0. Đây là cách đơn giản nhất cho login protection — sliding window log (bài 34) chính xác hơn nhưng tốn memory hơn và thường không cần thiết cho usecase này.

6

Pattern 2 — Progressive Delay

Progressive delay (exponential backoff) là kỹ thuật thêm độ trễ tăng dần sau mỗi lần fail, thay vì block hoàn toàn. Mục tiêu: làm brute force cực chậm (mỗi guess tốn nhiều giây) trong khi user thật chỉ chịu một khoảng delay nhỏ nếu gõ sai vài lần.

Lần fail thứ Delay Ghi chú
10sKhông penalty — lần đầu có thể đánh máy nhầm.
21s
32s
44s
58s
N2^(N-1)s (capped)Cap ở 60s để không delay quá lâu.
MAX_DELAY_SECONDS = 60

def get_login_delay(username: str) -> int:
    """Trả về số giây cần delay trước khi xử lý login."""
    fails = int(r.get(f"login:fail:user:{username}") or 0)
    if fails < 2:
        return 0
    return min(2 ** (fails - 1), MAX_DELAY_SECONDS)

# Ví dụ dùng trong handler (server-side delay):
import time

def handle_login(username: str, password: str, ip: str):
    delay = get_login_delay(username)
    if delay > 0:
        time.sleep(delay)  # block request, tốn thread
    # ... tiếp tục xử lý

Có hai cách áp delay: server-side (time.sleep trên server — đơn giản nhưng tốn connection/thread) hoặc client-side (server trả về HTTP 429 kèm header Retry-After: N, client chờ rồi thử lại). Cách server-side phù hợp với hệ thống nhỏ; cách client-side phù hợp hơn khi server cần giải phóng thread sớm.

Progressive delay không thay thế counter check — nên dùng kết hợp: check block trước, nếu chưa block thì áp delay, sau đó mới verify password.

7

Pattern 3 — Account Lockout

Sau một số lần fail vượt ngưỡng, tạo key lockout với TTL. Mọi request login tiếp theo cho account đó sẽ bị từ chối ngay, không verify password.

LOCKOUT_THRESHOLD = 15         # fail trước khi lock
LOCKOUT_DURATION_SECONDS = 1800  # 30 phút

def check_and_set_lockout(username: str, current_fails: int) -> bool:
    """Kiểm tra lockout, tạo nếu vượt ngưỡng. Trả True nếu đang bị lock."""
    lockout_key = f"lockout:user:{username}"

    # Check lockout hiện tại
    ttl = r.ttl(lockout_key)
    if ttl > 0:
        return True  # đang bị lock, TTL còn lại = ttl giây

    # Tạo lockout nếu vượt ngưỡng
    if current_fails >= LOCKOUT_THRESHOLD:
        r.setex(lockout_key, LOCKOUT_DURATION_SECONDS, "1")
        return True

    return False

Trade-off quan trọng: account lockout có thể bị attacker lợi dụng để DoS account victim — cố ý thử sai N lần để lock account của người khác. Đây là lý do lockout chỉ theo username đơn thuần là không đủ:

  • Mitigation 1: lock theo IP + account combo, không phải chỉ account — attacker từ IP X cố lock account A, nhưng user thật từ IP Y vẫn vào được.
  • Mitigation 2: cần CAPTCHA trước khi lock (mục 8) — bot không giải được CAPTCHA, lock không xảy ra với user thật.
  • Mitigation 3: thông báo qua email khi account gần bị lock — user thật biết và có thể xác minh là mình.

Luôn dùng SETEX (hoặc SET ... EX) với TTL, không bao giờ lock vĩnh viễn. Lockout vĩnh viễn khóa user thật mãi mãi, chỉ giải được bằng can thiệp thủ công.

8

Pattern 4 — CAPTCHA Trigger

CAPTCHA trigger là bước trung gian giữa "cho qua" và "block hoàn toàn". Sau N lần fail, đặt flag yêu cầu CAPTCHA cho account đó. User thật giải CAPTCHA rồi tiếp tục đăng nhập; bot không giải được thì bị chặn tự nhiên.

CAPTCHA_THRESHOLD = 5          # fail trước khi require CAPTCHA
CAPTCHA_FLAG_TTL = 600         # flag hết hạn sau 10 phút

def set_captcha_required(username: str) -> None:
    r.setex(f"captcha:required:user:{username}", CAPTCHA_FLAG_TTL, "1")

def is_captcha_required(username: str) -> bool:
    return r.exists(f"captcha:required:user:{username}") == 1

def clear_captcha_flag(username: str) -> None:
    """Gọi sau khi user đã verify CAPTCHA thành công."""
    r.delete(f"captcha:required:user:{username}")

So sánh CAPTCHA và lockout:

CAPTCHA trigger Account lockout
UX user thật Tốt — chỉ cần giải CAPTCHA Xấu — bị khóa, phải chờ hoặc liên hệ support
Hiệu quả chặn bot Tốt với CAPTCHA khó Tốt, nhưng dễ bị dùng để DoS
Rủi ro DoS victim Thấp Cao nếu không có mitigation

Trong thực tế nên escalate theo thứ tự: CAPTCHA trước → lockout sau (nếu CAPTCHA vẫn bị vượt). Bộ kết hợp đó được trình bày ở mục tiếp theo.

9

Combine — Defense In Depth

Không có một pattern nào đủ một mình. Defense in depth nghĩa là dùng nhiều lớp, mỗi lớp chặn một loại tấn công khác nhau. Luồng login sau combine toàn bộ:

import time
from dataclasses import dataclass

class AccountLocked(Exception):
    def __init__(self, retry_after: int):
        self.retry_after = retry_after

class TooManyRequests(Exception):
    pass

class CaptchaRequired(Exception):
    pass

class InvalidCredentials(Exception):
    pass

@dataclass
class Session:
    token: str
    username: str

def login_flow(username: str, password: str, ip: str,
               captcha_token: str | None = None) -> Session:
    """
    Luồng login hoàn chỉnh với defense in depth.
    Ném exception tương ứng nếu bị chặn.
    """
    # Lớp 1: Hard lockout — account đang bị lock
    lockout_key = f"lockout:user:{username}"
    lockout_ttl = r.ttl(lockout_key)
    if lockout_ttl > 0:
        raise AccountLocked(retry_after=lockout_ttl)

    # Lớp 2: IP rate limit
    ip_fails = int(r.get(f"login:fail:ip:{ip}") or 0)
    if ip_fails >= IP_FAIL_LIMIT:
        raise TooManyRequests

    # Lớp 3: CAPTCHA nếu account bị flag
    if is_captcha_required(username):
        if not captcha_token:
            raise CaptchaRequired
        # verify_captcha() gọi service bên ngoài (Google reCAPTCHA, hCaptcha, v.v.)
        if not verify_captcha(captcha_token):
            raise CaptchaRequired
        clear_captcha_flag(username)

    # Lớp 4: Progressive delay
    delay = get_login_delay(username)
    if delay > 0:
        time.sleep(delay)

    # Lớp 5: Verify password (constant-time compare, xem security note)
    if not verify_password(username, password):
        result = record_failed_login_atomic(username, ip)
        acc_fails = int(r.get(f"login:fail:user:{username}") or 0)

        # Escalate dựa trên số fail
        if acc_fails >= CAPTCHA_THRESHOLD:
            set_captcha_required(username)
        if acc_fails >= LOCKOUT_THRESHOLD:
            r.setex(lockout_key, LOCKOUT_DURATION_SECONDS, "1")
            # Gửi email cảnh báo (async, không block response)
            notify_user_lockout(username)

        raise InvalidCredentials

    # Lớp 6: Login thành công — reset counter
    reset_fail_counter(username)
    return create_session(username)

Thứ tự các lớp quan trọng: lockout check phải là đầu tiên (tránh verify password không cần thiết), IP check thứ hai (tránh tra cứu account khi IP đã bị block), delay phải trước verify password (không trước check để tránh delay ngay cả khi đã block).

Trong môi trường multi-instance (nhiều server), tất cả counter đều dùng chung Redis — mọi instance đều đọc/ghi cùng một key. Đây là lý do cần central Redis và atomic Lua (đã trình bày ở bài 38).

10

Threshold Tuning

Không có threshold chuẩn cho mọi hệ thống. Ngưỡng quá thấp sẽ khóa user thật; quá cao thì không chặn được tấn công. Điểm cần cân nhắc:

Chiều Gợi ý CAPTCHA Gợi ý Lockout Window
Per account 5–8 fail 10–20 fail 15–30 phút
Per IP 20–30 fail 50–100 fail 15–30 phút

Lý do ngưỡng per-IP cao hơn nhiều: một IP có thể đại diện cho nhiều người dùng thật (văn phòng, NAT, trường học). Nếu đặt per-IP quá thấp, cả một tòa nhà có thể bị block vì vài người gõ sai password.

Những yếu tố ảnh hưởng đến lựa chọn ngưỡng:

  • Độ nhạy cảm của account: tài khoản ngân hàng, thanh toán nên ngưỡng thấp hơn tài khoản blog cá nhân.
  • Có MFA không: nếu có MFA thì brute force password ít nguy hiểm hơn — có thể nới lỏng ngưỡng.
  • User base: user kỹ thuật ít gõ sai hơn user phổ thông.
  • Lịch sử tấn công: monitor log để điều chỉnh ngưỡng theo thực tế.

Nên bắt đầu với ngưỡng trung bình, monitor số lần CAPTCHA trigger và lockout trong 2 tuần đầu, rồi điều chỉnh.

11

Whitelist & Trusted Device

Rate limit chặt có thể gây phiền cho user thật. Trusted device là cách giảm friction: thiết bị đã xác minh trước (qua email OTP hoặc MFA) được nhận diện bằng device ID (thường lưu trong cookie hoặc localStorage), và được áp ngưỡng rate limit cao hơn hoặc bỏ qua một số lớp kiểm tra.

TRUSTED_DEVICE_TTL = 30 * 24 * 3600  # 30 ngày

def register_trusted_device(username: str, device_id: str) -> None:
    """Đánh dấu device là tin cậy sau khi user xác minh thành công."""
    r.sadd(f"trusted:devices:user:{username}", device_id)
    r.expire(f"trusted:devices:user:{username}", TRUSTED_DEVICE_TTL)

def is_trusted_device(username: str, device_id: str) -> bool:
    if not device_id:
        return False
    return r.sismember(f"trusted:devices:user:{username}", device_id)

# Trong login_flow: nếu trusted device thì bỏ qua CAPTCHA và giảm độ nhạy lockout
def get_effective_thresholds(username: str, device_id: str) -> dict:
    if is_trusted_device(username, device_id):
        return {"captcha": 10, "lockout": 25}  # ngưỡng thoải hơn
    return {"captcha": CAPTCHA_THRESHOLD, "lockout": LOCKOUT_THRESHOLD}

Vài điểm cần lưu ý:

  • Device ID phải đủ entropy (UUID v4 trở lên) để không đoán được.
  • Cần giới hạn số trusted device per user (SCARD check trước khi SADD) để tránh set phình to.
  • Cho phép user xem danh sách trusted device và revoke nếu cần (đây là UX an toàn).
12

Anti-Patterns & Security Notes

Anti-patterns thường gặp

  • Chỉ lock theo account, không theo IP: attacker dùng credential stuffing từ botnet vẫn đi qua; nếu attacker cố ý fail nhiều lần còn DoS được account victim.
  • Lock vĩnh viễn (không TTL): user hợp lệ bị khóa mãi, chỉ giải quyết được bằng can thiệp thủ công của support. Luôn dùng SETEX hoặc SET ... EX.
  • Counter không atomic (GET-check-INCR): hai request fail đồng thời có thể cùng đọc cùng giá trị, cùng quyết định chưa block, rồi cùng increment — attacker có burst ngắn vượt qua threshold. Dùng Lua atomic (mục 5).
  • Ngưỡng lockout quá thấp (2-3 fail): user thật gõ nhầm caps-lock bị lock ngay. Làm support quá tải với yêu cầu unlock.
  • Quên reset counter khi login thành công: counter tích lũy fail từ session trước, user thật dần bị CAPTCHA và lock mà không hiểu lý do.
  • Lưu thông tin liên quan password vào Redis key/value: debug log, raw input, partial hash — tất cả có thể leak qua Redis CLI hoặc MONITOR. Redis counter chỉ lưu số đếm và flag, không bao giờ lưu credential.

Security notes

Username enumeration: không trả về message khác nhau cho "username không tồn tại" và "password sai". Luôn trả generic message:

# SAI — tiết lộ username có tồn tại không
if not user_exists(username):
    raise UserNotFound("Không tìm thấy tài khoản")
if not verify_password(username, password):
    raise InvalidCredentials("Sai mật khẩu")

# ĐÚNG — generic message
raise InvalidCredentials("Sai tên đăng nhập hoặc mật khẩu")

Nếu tiết lộ username tồn tại hay không, attacker có thể dùng login endpoint như một công cụ kiểm tra username (account enumeration), thu thập danh sách valid account để dùng cho credential stuffing.

Timing attack: thời gian phản hồi khác nhau giữa "user không tồn tại" và "user tồn tại nhưng sai password" cũng tiết lộ thông tin. Dùng constant-time compare:

import hmac

# SAI — short-circuit so sánh, thời gian khác nhau
if stored_hash != compute_hash(password):
    raise InvalidCredentials

# ĐÚNG — constant-time compare
if not hmac.compare_digest(stored_hash, compute_hash(password)):
    raise InvalidCredentials

Rate limit OTP và password-reset: áp dụng pattern tương tự cho endpoint gửi OTP, endpoint reset password — chúng cũng là điểm tấn công nếu không giới hạn. Bài thuộc Module 7 sẽ đi sâu hơn vào auth flow.

Notify user khi phát hiện bất thường: khi counter fail vượt ngưỡng trung bình, gửi email "Phát hiện đăng nhập thất bại nhiều lần từ [IP]". User thật nhận được cảnh báo, có thể đổi password chủ động trước khi bị thiệt hại.

13

Tổng Kết & Quiz

Các điểm chính của bài:

  • Ba loại tấn công login (brute force, credential stuffing, password spraying) yêu cầu rate limit theo nhiều chiều: per account và per IP tối thiểu.
  • Failed attempt counter dùng INCR + EXPIRE; phiên bản atomic Lua tránh race condition và key không có TTL khi crash giữa hai lệnh.
  • Progressive delay tăng theo 2^(N-1) giây làm brute force mất hàng giờ mà không block user thật.
  • Account lockout mạnh nhưng có thể bị lợi dụng để DoS victim — cần kết hợp with per-IP hoặc dùng CAPTCHA làm lớp đệm trước.
  • Thứ tự escalation: counter → progressive delay → CAPTCHA → lockout.
  • Luôn dùng TTL cho mọi key lockout và counter; luôn reset counter khi login thành công; không bao giờ tiết lộ "username không tồn tại".

Best practices tóm tắt

  • Multi-dimension: account + IP.
  • Atomic Lua counter (không GET-rồi-INCR).
  • Escalate: CAPTCHA → delay → lockout (không nhảy thẳng vào lockout).
  • Reset counter on success.
  • TTL mọi key.
  • Generic error message (không enumerate username).
  • Constant-time password compare.
  • Notify user khi phát hiện bất thường.

Quiz

  1. Tại sao chỉ rate limit theo username không đủ để chặn credential stuffing?
  2. Nếu dùng INCR rồi EXPIRE thành hai lệnh riêng, vấn đề gì có thể xảy ra? Lua script khắc phục thế nào?
  3. Mô tả cách attacker có thể dùng account lockout để DoS victim. Đề xuất mitigation.
  4. Vì sao response time khác nhau giữa "user không tồn tại" và "user tồn tại, sai password" là vấn đề bảo mật? Cách khắc phục?
  5. Trong luồng login_flow, tại sao lockout check phải đặt ở lớp đầu tiên thay vì sau verify password?

Đáp án gợi ý

  1. Credential stuffing thử nhiều username khác nhau — mỗi account chỉ có 1-2 fail. Counter per-account không đạt ngưỡng, không bị chặn. Cần thêm counter per-IP để detect tổng fail từ một IP trên nhiều account.
  2. Nếu process crash hoặc network lỗi sau INCR nhưng trước EXPIRE, key tồn tại không có TTL — user bị lock vĩnh viễn. Lua chạy cả hai lệnh atomically; nếu INCR thành công thì EXPIRE chắc chắn cũng chạy trong cùng transaction.
  3. Attacker cố ý fail N lần cho account của victim → trigger lockout → victim không login được trong 30 phút. Mitigation: lock theo IP+account combo (chỉ IP attacker bị lock cho account đó, IP khác vẫn OK); hoặc yêu cầu CAPTCHA trước khi lock (bot không giải được, victim thật giải được).
  4. Attacker đo thời gian phản hồi: "user không tồn tại" trả về nhanh (không cần hash compare); "user tồn tại" chậm hơn (có hash compare). Từ đó suy ra account nào tồn tại — dùng cho credential stuffing. Khắc phục: luôn chạy hash compare dù user có tồn tại hay không, dùng hmac.compare_digest.
  5. Nếu đặt lockout check sau verify password, server vẫn phải gọi hàm verify (có thể tốn CPU để hash/decrypt) dù account đang bị lock. Đặt lockout ở đầu cho phép return sớm (fail fast), tránh tốn tài nguyên không cần thiết và tránh bị dùng làm vector tấn công timing.

Bài tiếp theo

Bài 40 chuyển sang Quota Theo User / API Key: phân tầng Free vs Paid, AI quota, cách thiết kế bucket quota với Redis cho nhiều tier đồng thời.

Tham khảo