Mục lục
- Mục Tiêu Bài Học
- Lockout DoS — Mặt Trái Của Hard Lockout
- Ba Loại Lockout Policy
- Hard Lockout — Cài Đặt Và Giới Hạn
- Soft Lockout — CAPTCHA Escalation
- IP-Based Hard Lockout
- Geographic & Behavioral Signals
- Decay Function — Tự Phục Hồi Lockout Level
- Account State Machine
- Self-Service Recovery — Email Link
- 2FA-Based Unlock
- CAPTCHA Integration Chống Replay
- Admin Unlock & Audit
- Thông Báo Khi Bị Lock
- Distributed Lockout — Multi-Region
- Audit Log Stream
- Recovery UX Workflow
- Ngưỡng Lockout Gợi Ý
- Kiểm Tra Recovery Flow Định Kỳ
- Anti-Patterns
- Tổng Kết & Quiz
Mục Tiêu Bài Học
- Phân biệt hard lockout, soft lockout và permanent lockout — khi nào dùng cái nào.
- Hiểu lockout DoS vector: attacker khai thác hard lockout để khoá victim.
- Triển khai soft lockout với CAPTCHA escalation giảm thiểu DoS risk.
- Dùng IP-based lockout thay thế user-based lockout trong một số trường hợp.
- Xây decay function tự hạ lockout level sau khoảng thời gian không có fail attempt.
- Triển khai self-service recovery qua email link và 2FA OTP.
- Tích hợp CAPTCHA với Redis để chống token replay.
- Mô hình hoá account state machine và ghi audit log đầy đủ.
Lockout DoS — Mặt Trái Của Hard Lockout
Hard lockout (khoá account sau N lần sai password) có một điểm yếu cố hữu: attacker không cần biết password để khoá victim, chỉ cần biết username.
Kịch bản cụ thể:
- Attacker biết email của victim (ví dụ từ data breach).
- Gửi 5 request login sai liên tiếp đến account victim.
- Account bị lock 30 phút.
- Attacker lặp lại mỗi 25 phút — victim không bao giờ login được.
Đây là dạng tấn công từ chối dịch vụ nhắm vào tài khoản cụ thể, thường dùng để phá hoại (competitor, troll) hơn là để lấy dữ liệu. Trade-off cần cân nhắc:
| Policy | Bảo vệ brute-force | Rủi ro DoS | UX legit user |
|---|---|---|---|
| Không lockout | Thấp | Không có | Tốt |
| Hard lockout | Cao | Cao | Xấu nếu bị DoS |
| Soft lockout | Trung bình–Cao | Thấp | Chấp nhận được |
| IP-based lock | Trung bình | Thấp (user-side) | Tốt nếu IP đơn |
Ba Loại Lockout Policy
Hard lockout: Account bị khoá trong X phút/giờ. Ngay cả khi nhập đúng password cũng không đăng nhập được. Đơn giản nhất để triển khai nhưng dễ bị lợi dụng làm DoS vector. Phù hợp khi tài nguyên cần bảo vệ có giá trị cao và rủi ro DoS thấp (ví dụ: internal tool, admin panel ít bị target).
Soft lockout (transparent lockout): Vẫn cho phép đăng nhập nhưng tăng dần friction. Fail 3 lần → thêm CAPTCHA; fail 5 lần → CAPTCHA + 2FA; fail 10 lần → CAPTCHA + 2FA + email verify. Legit user (typo mật khẩu vài lần) vẫn vào được, chỉ mất thêm bước xác nhận. Attacker bị chặn bởi cost per attempt tăng. Không có DoS vector vì account không bị block hoàn toàn.
Permanent lockout (suspension): Account bị đình chỉ cho đến khi admin hoặc user tự reset qua email. Dùng cho vi phạm nghiêm trọng (lạm dụng API, fraud), không liên quan đến sai password thông thường.
Hard Lockout — Cài Đặt Và Giới Hạn
Hard lockout trong Redis đơn giản: một key với TTL. Khi key tồn tại → từ chối login.
import redis
import time
r = redis.Redis()
HARD_LOCK_DURATION = 1800 # 30 phút
def hard_lock(email: str, duration: int = HARD_LOCK_DURATION):
r.set(f"lock:hard:{email}", "1", ex=duration)
def is_hard_locked(email: str) -> bool:
return bool(r.exists(f"lock:hard:{email}"))
def hard_lock_ttl(email: str) -> int:
"""Trả về giây còn lại, -2 nếu key không tồn tại."""
return r.ttl(f"lock:hard:{email}")
# Trong login handler:
def login(email: str, password: str):
if is_hard_locked(email):
ttl = hard_lock_ttl(email)
return {"error": "account_locked", "retry_after": ttl}
# ... kiểm tra password ...
Nên chỉ trigger hard lockout ở ngưỡng cao (ví dụ: 50+ fail trong 1 giờ) để giảm DoS risk. Với số lần fail thấp hơn, dùng soft lockout (CAPTCHA escalation) thay thế.
Một điểm hay bị bỏ qua: khi user login thành công, không nên xoá hard lock key. Nếu xoá, attacker có thể dùng một credential hợp lệ (của mình) để reset lock counter của chính mình rồi tiếp tục tấn công account khác. Hard lock chỉ hết hạn tự nhiên theo TTL, hoặc bị admin/self-service unlock.
Soft Lockout — CAPTCHA Escalation
Thay vì block login, tăng dần friction theo số lần fail. Hàm dưới đọc counter từ bài 85 và trả về mức độ cần kiểm tra bổ sung:
def get_login_difficulty(email: str, ip: str) -> str:
"""
Trả về level friction cần thiết cho login attempt.
Counter 'login:fail:email:{email}' được ghi bởi module bài 85.
"""
fails = int(r.get(f"login:fail:email:{email}") or 0)
if fails >= 10:
return "captcha_2fa_email_verify"
if fails >= 5:
return "captcha_2fa"
if fails >= 3:
return "captcha"
return "normal"
# Trong login handler:
def login_with_difficulty(email: str, password: str, ip: str,
captcha_token: str = None, otp: str = None):
difficulty = get_login_difficulty(email, ip)
if difficulty in ("captcha", "captcha_2fa", "captcha_2fa_email_verify"):
if not captcha_token or not verify_captcha(captcha_token):
return {"error": "captcha_required"}
if difficulty in ("captcha_2fa", "captcha_2fa_email_verify"):
if not otp or not verify_totp(email, otp):
return {"error": "2fa_required"}
if difficulty == "captcha_2fa_email_verify":
if not email_verified_recently(email):
trigger_email_verify(email)
return {"error": "email_verify_required"}
# ... xác thực password thực sự ...
Kết quả với hai loại actor:
- Legit user gõ sai 4 lần: lần thứ 5 phải giải CAPTCHA. Vẫn đăng nhập được, chỉ tốn thêm 5–10 giây.
- Attacker brute-force: mỗi attempt đòi CAPTCHA (chi phí cao về thời gian và tiền nếu dùng CAPTCHA farm), sau đó thêm 2FA (6 chữ số không đoán được nếu không có thiết bị victim).
IP-Based Hard Lockout
Lock IP thay vì lock user: attacker spam fail từ một IP → IP đó bị khoá, các user khác không bị ảnh hưởng.
IP_LOCK_DURATION = 3600 # 1 giờ
IP_FAIL_THRESHOLD = 20 # 20 fail trong window
def record_ip_fail(ip: str, window: int = 600) -> int:
"""Tăng counter fail per IP, trả về giá trị mới."""
key = f"login:fail:ip:{ip}"
count = r.incr(key)
if count == 1:
r.expire(key, window)
return count
def check_and_lock_ip(ip: str):
count = record_ip_fail(ip)
if count >= IP_FAIL_THRESHOLD:
r.set(f"lock:ip:{ip}", "1", ex=IP_LOCK_DURATION)
def is_ip_locked(ip: str) -> bool:
return bool(r.exists(f"lock:ip:{ip}"))
# Kiểm tra đầu login handler, trước cả user lookup:
def login(email: str, password: str, ip: str):
if is_ip_locked(ip):
return {"error": "ip_blocked", "retry_after": r.ttl(f"lock:ip:{ip}")}
# ...
Caveat quan trọng: IP-based lock phải cẩn thận với môi trường NAT và CGNAT (carrier-grade NAT của mạng di động). Một địa chỉ IP có thể đại diện cho hàng nghìn user thật. Gợi ý:
- Đặt threshold per-IP cao hơn đáng kể so với per-user (ví dụ: 50–100 fail thay vì 5–10).
- Phân biệt IP private range (10.x, 192.168.x) — không lock.
- Ưu tiên CGNAT range nếu có danh sách từ ISP.
- IP lock chỉ là một lớp bảo vệ, không phải lớp duy nhất.
Geographic & Behavioral Signals
Ngoài fail counter, các tín hiệu ngữ cảnh giúp tăng friction mà không cần lockout cứng:
| Tín hiệu | Hành động | Ghi chú |
|---|---|---|
| Login từ country chưa từng dùng | Yêu cầu 2FA, không lockout | Lưu known_countries của user trong Redis Set |
| Login từ device mới (user-agent + fingerprint) | Gửi email xác nhận thiết bị | Lưu known_devices hash |
| Login lúc bất thường (3am với user thường 9–17h) | Tăng suspicious score, require 2FA | Cần lịch sử ZSet bài 85 để tính giờ bình thường |
| Login với fails > 0 từ country mới | Yêu cầu 2FA dù chưa đến ngưỡng CAPTCHA | Kết hợp geo + fail counter |
Redis lưu các tập known context:
def is_known_country(email: str, country_code: str) -> bool:
return bool(r.sismember(f"user:known_countries:{email}", country_code))
def add_known_country(email: str, country_code: str):
r.sadd(f"user:known_countries:{email}", country_code)
r.expire(f"user:known_countries:{email}", 86400 * 365)
def is_known_device(email: str, device_fingerprint: str) -> bool:
return bool(r.sismember(f"user:known_devices:{email}", device_fingerprint))
def add_known_device(email: str, device_fingerprint: str):
r.sadd(f"user:known_devices:{email}", device_fingerprint)
r.expire(f"user:known_devices:{email}", 86400 * 90) # 90 ngày
Decay Function — Tự Phục Hồi Lockout Level
Giữ lockout level mãi mãi thì user gõ sai một lần sẽ phải giải CAPTCHA vĩnh viễn. Decay function giảm level sau khoảng thời gian không có fail attempt.
import time
DECAY_INTERVAL = 86400 # 1 ngày không fail → level giảm 1
def record_fail_with_timestamp(email: str):
"""Ghi fail counter + timestamp lần fail cuối."""
pipe = r.pipeline()
pipe.incr(f"login:fail:email:{email}")
pipe.expire(f"login:fail:email:{email}", 86400 * 7) # 7 ngày
pipe.set(f"last_fail_ts:{email}", int(time.time()), ex=86400 * 7)
pipe.execute()
def apply_decay(email: str):
"""
Gọi trước khi đọc lockout level trong login handler.
Giảm counter nếu đã qua đủ thời gian không có fail mới.
"""
last_fail_raw = r.get(f"last_fail_ts:{email}")
if not last_fail_raw:
return # Không có lịch sử fail
last_fail = int(last_fail_raw)
elapsed = time.time() - last_fail
decay_steps = int(elapsed // DECAY_INTERVAL)
if decay_steps > 0:
current = int(r.get(f"login:fail:email:{email}") or 0)
new_val = max(0, current - decay_steps)
if new_val == 0:
r.delete(f"login:fail:email:{email}")
r.delete(f"last_fail_ts:{email}")
else:
r.set(f"login:fail:email:{email}", new_val,
ex=86400 * 7)
# Cập nhật timestamp để tránh giảm kép lần sau
r.set(f"last_fail_ts:{email}",
int(last_fail + decay_steps * DECAY_INTERVAL),
ex=86400 * 7)
Lưu ý: apply_decay cần gọi trước khi đọc fail counter để level phản ánh đúng thực tế. Hàm này không cần atomic vì worst case là decay không được áp dụng ngay — fail-safe theo hướng conservative (không giảm nhầm).
Account State Machine
Thay vì kiểm tra nhiều key rời rạc, mô hình hoá state của account thành enum rõ ràng:
| State | Mô tả | Chuyển trạng thái |
|---|---|---|
active |
Bình thường | Fail < 3 → active |
friction |
Yêu cầu CAPTCHA / 2FA | Fail 3–49 → friction; success + decay → active |
locked |
Tạm khoá, có TTL | Fail ≥ 50 → locked; TTL hết hoặc unlock → active |
suspended |
Đình chỉ vĩnh viễn | Vi phạm nghiêm trọng → suspended; admin reset → active |
from enum import Enum
class AccountState(Enum):
ACTIVE = "active"
FRICTION = "friction"
LOCKED = "locked"
SUSPENDED = "suspended"
def get_account_state(email: str) -> AccountState:
# Suspended có ưu tiên cao nhất
if r.exists(f"account:suspended:{email}"):
return AccountState.SUSPENDED
if is_hard_locked(email):
return AccountState.LOCKED
apply_decay(email)
fails = int(r.get(f"login:fail:email:{email}") or 0)
if fails >= 3:
return AccountState.FRICTION
return AccountState.ACTIVE
def set_account_suspended(email: str, reason: str):
r.hset(f"account:suspended:{email}", mapping={
"reason": reason,
"ts": int(time.time()),
})
# Không đặt TTL — suspension chỉ xoá bằng tay
State trong Redis là cache của trạng thái thực sự. Khi state quan trọng (suspended), nên sync xuống DB để không bị mất khi Redis flush/evict.
Self-Service Recovery — Email Link
Khi account bị hard lock, user cần cách tự unlock mà không cần gọi support. Pattern tương tự password reset (bài 84): gửi email link với token có TTL ngắn.
import secrets
UNLOCK_TOKEN_TTL = 900 # 15 phút
def send_unlock_email(email: str):
"""
Tạo unlock token, lưu Redis, gửi email link.
Chỉ gọi khi account đang ở state LOCKED.
"""
token = secrets.token_urlsafe(32)
# Lưu token → email mapping
r.set(f"unlock:token:{token}", email, ex=UNLOCK_TOKEN_TTL)
# Đánh dấu đã gửi để rate-limit email (bài 83)
r.set(f"unlock:sent:{email}", "1", ex=300) # 5 phút cooldown
send_email(email, subject="Mở khoá tài khoản",
body=f"Click vào link để mở khoá: /unlock?token={token}")
def unlock_via_token(token: str) -> bool:
email = r.get(f"unlock:token:{token}")
if not email:
return False # Token không tồn tại hoặc hết hạn
email = email.decode()
# Xoá token ngay — single-use
r.delete(f"unlock:token:{token}")
# Xoá hard lock
r.delete(f"lock:hard:{email}")
# Reset fail counter
r.delete(f"login:fail:email:{email}")
r.delete(f"last_fail_ts:{email}")
# Audit
log_lockout_event(email, "self_service_unlock", "email_token")
return True
Điểm cần kiểm tra:
- Token phải single-use:
r.deletetrước khi thực hiện unlock để tránh race condition (hai tab click cùng lúc). - TTL phải ngắn (15–30 phút): link trong email không có cơ chế expiry từ phía email client, nếu email bị đọc bởi attacker sau này, link không còn dùng được.
- Sau unlock nên force password reset: nếu account bị lock vì có người biết password, unlock mà không đổi password thì cũng không an toàn hơn.
2FA-Based Unlock
Nếu user đã bật TOTP hoặc SMS OTP, có thể dùng OTP để unlock thay vì (hoặc kết hợp với) email link:
def unlock_via_sms_otp(email: str, otp: str) -> dict:
"""
Unlock account bằng SMS OTP.
SMS OTP đã được gửi và lưu vào Redis bởi module rate-limited (bài 83).
"""
# Kiểm tra OTP còn hạn không
stored_otp = r.get(f"sms:otp:{email}")
if not stored_otp:
return {"error": "otp_expired"}
if stored_otp.decode() != otp:
# Tăng counter fail OTP — chống brute-force OTP
otp_fails = r.incr(f"sms:otp:fail:{email}")
r.expire(f"sms:otp:fail:{email}", 300)
if otp_fails >= 5:
# Vô hiệu hoá OTP hiện tại, buộc gửi lại
r.delete(f"sms:otp:{email}")
return {"error": "otp_invalid"}
# OTP đúng — xoá để single-use
r.delete(f"sms:otp:{email}")
r.delete(f"sms:otp:fail:{email}")
# Unlock
r.delete(f"lock:hard:{email}")
r.delete(f"login:fail:email:{email}")
log_lockout_event(email, "2fa_unlock", "sms_otp")
return {"success": True}
Lưu ý: chống brute-force OTP là bắt buộc. Nếu attacker biết account bị lock và có thể trigger SMS OTP (bằng cách dùng "unlock via OTP" endpoint), họ có thể thử 10^6 khả năng của OTP 6 chữ số. Rate limit OTP send (bài 83) và fail counter per OTP session là hai lớp cần có.
CAPTCHA Integration Chống Replay
CAPTCHA token từ Google reCAPTCHA hoặc Cloudflare Turnstile chỉ hợp lệ một lần. Nếu không check replay, attacker có thể dùng một CAPTCHA token đã verify để gửi nhiều login request.
import httpx
CAPTCHA_SECRET = "your_recaptcha_secret_key"
CAPTCHA_TOKEN_TTL = 300 # 5 phút
def verify_captcha(token: str, remote_ip: str = "") -> bool:
"""
Verify CAPTCHA token với Google API.
Lưu token đã dùng vào Redis để ngăn replay.
"""
# Kiểm tra token đã dùng chưa
if r.exists(f"captcha:used:{token}"):
return False # replay attempt
# Gọi Google verify API
response = httpx.post(
"https://www.google.com/recaptcha/api/siteverify",
data={
"secret": CAPTCHA_SECRET,
"response": token,
"remoteip": remote_ip,
},
timeout=5.0,
)
data = response.json()
success = data.get("success", False)
if success:
# Đánh dấu token đã dùng — ngăn replay trong 5 phút
r.set(f"captcha:used:{token}", "1", ex=CAPTCHA_TOKEN_TTL)
return success
TTL của captcha:used:{token} đặt bằng thời gian tối đa một CAPTCHA token có thể hợp lệ theo docs của provider (reCAPTCHA: ~2 phút). Đặt lâu hơn một chút (5 phút) để an toàn.
Nếu Google API timeout (5 giây), fail-open hay fail-closed phụ thuộc vào context: admin panel sensitive → fail-closed (reject), public page → fail-open (allow với log) để không block legit user vì lỗi network.
Admin Unlock & Audit
Support ticket "tôi không đăng nhập được" thường dẫn đến admin unlock. Admin action cần audit log đầy đủ để trace khi có sự cố.
def admin_unlock(email: str, admin_id: str, reason: str):
"""
Admin unlock account. Xoá hard lock, reset fail counter.
Ghi audit log với admin_id để trace.
"""
pipe = r.pipeline()
pipe.delete(f"lock:hard:{email}")
pipe.delete(f"lock:ip:{email}") # nếu có IP lock liên quan
pipe.delete(f"login:fail:email:{email}")
pipe.delete(f"last_fail_ts:{email}")
pipe.execute()
# Audit log — quan trọng, không được bỏ
r.xadd("audit:admin_actions", {
"admin_id": admin_id,
"action": "unlock_account",
"target_email": email,
"reason": reason,
"ts": int(time.time()),
}, maxlen=10**5)
log_lockout_event(email, "admin_unlock", f"admin:{admin_id}")
Audit log admin action quan trọng vì:
- Nếu admin account bị compromise, attacker unlock account dễ dàng mà không dấu vết nếu không có log.
- Regulalory compliance (SOC 2, ISO 27001) yêu cầu log privileged action.
- Troubleshooting: user báo "tôi bị lock lại dù mới unlock hôm qua" — cần trace ai/khi nào lock lại.
Thông Báo Khi Bị Lock
User không nên mở app lên thấy "Invalid credentials" mà không biết tại sao. Khi lock trigger, gửi email notification:
def notify_lockout(email: str, trigger: str, context: dict):
"""
Gửi email cảnh báo khi account bị lock.
Tránh gửi nhiều lần trong ngắn hạn.
"""
notify_key = f"lockout:notified:{email}"
if r.exists(notify_key):
return # Đã gửi trong 1 giờ qua, không spam
r.set(notify_key, "1", ex=3600)
body_lines = [
"Tài khoản của bạn đã bị tạm khoá do nhiều lần đăng nhập thất bại.",
f"Nguyên nhân: {trigger}",
]
if context.get("ip"):
body_lines.append(f"IP nguồn: {context['ip']}")
if context.get("country"):
body_lines.append(f"Quốc gia: {context['country']}")
body_lines += [
"",
"Nếu đây là bạn: chờ 30 phút hoặc dùng link dưới để mở khoá ngay.",
"Nếu không phải bạn: đổi mật khẩu ngay sau khi mở khoá.",
"",
f"/unlock?email={email}",
"/forgot-password",
]
send_email(email, subject="Tài khoản bị tạm khoá", body="\n".join(body_lines))
Nội dung email nên có: lý do (fail count, geo anomaly), IP nguồn nếu có, link self-service unlock, link đổi mật khẩu. Tránh tiết lộ thông tin nhạy cảm (password hash, internal ID).
Distributed Lockout — Multi-Region
Khi service chạy trên nhiều region, lockout state cần được đồng bộ. Hai lựa chọn:
| Chiến lược | Ưu điểm | Nhược điểm |
|---|---|---|
| Global Redis (single cluster, multi-region replicate) | Nhất quán — lock ở US áp dụng ngay tại EU | Latency cao hơn (cross-region read), single point of failure nếu global cluster sập |
| Per-region Redis | Low latency, độc lập region | Attacker đổi sang region khác → counter reset về 0; lock ở EU không áp dụng tại US |
| Hybrid: per-region counter + global lock flag | Counter fast local; lock state sync global | Phức tạp hơn; eventual consistency cho lock trigger |
Với hầu hết web app không có multi-region yêu cầu cực thấp latency, global Redis Cluster (hoặc Redis Enterprise Active-Active) là đủ. Chỉ cần per-region khi SLA latency < 10ms cho auth endpoint.
Audit Log Stream
Ghi toàn bộ lockout/unlock event vào Redis Stream để phục vụ monitoring, phân tích, và compliance:
def log_lockout_event(email: str, event_type: str, trigger: str,
extra: dict = None):
"""
event_type: hard_lock | soft_lock | ip_lock | self_service_unlock
| 2fa_unlock | admin_unlock | email_notify
trigger: fail_count | geo_anomaly | device_new | admin:{id} | ...
"""
payload = {
"email": email,
"type": event_type,
"trigger": trigger,
"ts": int(time.time()),
}
if extra:
payload.update(extra)
r.xadd("audit:lockout", payload, maxlen=100_000)
# Ví dụ query: tất cả hard_lock trong 1 giờ qua
def get_recent_lockouts(since_ts: int) -> list:
entries = r.xrange("audit:lockout", min=f"{since_ts * 1000}-0")
return [
{k.decode(): v.decode() for k, v in fields.items()}
for _, fields in entries
]
Stream với maxlen=100_000 giữ khoảng 100k event gần nhất. Với hệ thống có ít lockout, con số này tương đương vài tháng lịch sử. Nếu cần lưu lâu hơn, consumer group đọc và đẩy sang data warehouse (BigQuery, S3) định kỳ.
Recovery UX Workflow
Từ góc độ user, khi bị lock thì màn hình cần truyền đạt rõ ràng:
- Giải thích cụ thể: "Tài khoản bị tạm khoá 30 phút do đăng nhập sai nhiều lần." Không phải "Invalid credentials" chung chung.
- Countdown TTL: nếu hard lock có TTL, hiển thị "Thử lại sau: 27 phút 43 giây" (đọc từ
lock:hard:ttl). - Options hành động:
- "Mở khoá qua email" — gửi unlock link.
- "Đặt lại mật khẩu" — qua password reset flow (bài 84), tự động unlock sau khi reset thành công.
- "Liên hệ hỗ trợ" — link support ticket.
- Không silent fail: tuyệt đối không trả về "sai mật khẩu" khi thực ra account bị lock — user sẽ thử mãi và không hiểu vấn đề là gì.
Về phía backend, unlock qua password reset nên tự động xoá hard lock:
def complete_password_reset(email: str, new_password_hash: str):
"""Sau khi verify reset token và update mật khẩu."""
# ... update DB ...
# Tự động unlock — password mới thì lock cũ không còn ý nghĩa
r.delete(f"lock:hard:{email}")
r.delete(f"login:fail:email:{email}")
r.delete(f"last_fail_ts:{email}")
log_lockout_event(email, "auto_unlock", "password_reset")
Ngưỡng Lockout Gợi Ý
Không có ngưỡng phổ quát — phụ thuộc vào độ nhạy cảm của tài nguyên và tỉ lệ user nhập sai thực tế trong hệ thống cụ thể. Gợi ý làm điểm xuất phát:
| Fail count (per email, 15 phút) | Hành động |
|---|---|
| 1–2 | Không làm gì thêm. Typo phổ biến. |
| 3–5 | Yêu cầu CAPTCHA ở attempt tiếp theo. |
| 6–10 | CAPTCHA + 2FA (nếu user đã bật). |
| 11–49 | CAPTCHA + 2FA + email verify. Gửi email cảnh báo lần đầu vào ngưỡng 11. |
| ≥ 50 | Hard lockout 30 phút. Email notification. |
Điều chỉnh theo use case: nếu service có nhiều user mobile (gõ bàn phím nhỏ) thì ngưỡng CAPTCHA có thể nâng lên 5–7. Nếu là banking / fintech thì có thể hạ xuống 3.
Kiểm Tra Recovery Flow Định Kỳ
Recovery flow bị hỏng thường chỉ phát hiện khi user thực sự cần dùng — tức là quá muộn. Nên test tự động định kỳ:
def synthetic_lockout_recovery_test():
"""
Chạy trong CI/CD hoặc scheduled job hàng ngày.
Dùng email test account không phải production user.
"""
test_email = "[email protected]"
# 1. Trigger lockout nhân tạo
r.set(f"lock:hard:{test_email}", "1", ex=300)
assert get_account_state(test_email) == AccountState.LOCKED
# 2. Gọi send unlock email
send_unlock_email(test_email)
token_key = None
for key in r.scan_iter(f"unlock:token:*"):
if r.get(key).decode() == test_email:
token_key = key
break
assert token_key is not None, "Unlock token không được tạo"
token = token_key.decode().split("unlock:token:")[-1]
# 3. Verify unlock
result = unlock_via_token(token)
assert result is True
assert get_account_state(test_email) == AccountState.ACTIVE
# 4. Cleanup
r.delete(f"lockout:notified:{test_email}")
r.delete(f"unlock:sent:{test_email}")
Ngoài synthetic test, chaos test định kỳ (lock 1% account test, đo số support ticket phát sinh) cho biết recovery UX có đủ rõ ràng để user tự xử lý hay không.
Anti-Patterns
- Hard lockout vô thời hạn (không có TTL): account bị khoá mãi mãi. Attacker chỉ cần spam fail một lần là victim mất account.
- Lock user mà không thông báo: user không hiểu tại sao login fail, tạo nhiều support ticket, UX tệ, và không có cơ hội biết account đang bị tấn công.
- Unlock email không expire: link gửi tuần trước vẫn hoạt động. Nếu attacker có quyền đọc email trong tương lai (email bị hack sau này) thì có thể dùng lại link cũ.
- Per-IP threshold quá thấp: ngưỡng 5 fail per IP block toàn bộ văn phòng vì shared NAT. Threshold per-IP nên cao hơn per-user đáng kể.
- CAPTCHA token không check replay: attacker giải một CAPTCHA → dùng token đó cho 100 request. Phải mark token đã dùng trong Redis.
- Admin unlock không có audit log: security blind spot. Không trace được ai unlock account nào khi nào, không phát hiện admin account bị compromise.
- Reset fail counter khi login thành công: attacker dùng credential hợp lệ của mình để reset counter, rồi tiếp tục tấn công account khác từ cùng IP.
- Không rate-limit OTP resend cho unlock flow: attacker trigger gửi SMS OTP liên tục để tốn tiền SMS của service.
Tổng Kết & Quiz
Những điểm cần nhớ từ bài này:
- Hard lockout đơn giản nhưng tạo DoS vector — chỉ dùng ở ngưỡng fail cao (50+).
- Soft lockout (CAPTCHA escalation) là lựa chọn mặc định cho phần lớn web app.
- IP-based lockout giảm DoS risk nhưng phải cẩn thận với CGNAT — threshold cao hơn.
- Decay function tự hạ lockout level sau khoảng thời gian idle, tránh trừng phạt vĩnh viễn.
- Self-service recovery (email link, 2FA OTP) giảm tải support; link phải single-use và có TTL ngắn.
- CAPTCHA token phải check replay bằng Redis; admin unlock phải có audit log.
- Thông báo user khi bị lock là bắt buộc — tránh silent fail.
- Test recovery flow định kỳ bằng synthetic test.
Quiz
- Tại sao không nên xoá hard lock key khi user login thành công từ account khác trên cùng IP?
- Decay function trong bài có thể áp dụng decay nhiều lần cho cùng một khoảng thời gian không? Nếu có, code hiện tại xử lý thế nào?
- Nếu Google reCAPTCHA API trả về timeout trong
verify_captcha, hệ thống nên fail-open hay fail-closed? Khi nào thì mỗi lựa chọn phù hợp? - Attacker biết email victim và endpoint "gửi OTP để unlock". Attacker gọi endpoint này liên tục. Hệ thống ngăn chặn bằng cơ chế nào (dựa vào code trong bài)?
- Account state
suspendedkhông đặt TTL trong Redis. Điều này đặt ra yêu cầu gì về việc đồng bộ với database?
Đáp án gợi ý
- Vì hard lock liên quan đến tấn công brute-force account cụ thể, không liên quan đến login thành công của account khác. Nếu xoá, attacker dùng credential hợp lệ của mình để reset lock của victim — vô hiệu hoá toàn bộ lockout mechanism.
- Có thể xảy ra nếu
apply_decaygọi hai lần trong cùng khoảng thời gian giữa hai fail. Code xử lý bằng cách cập nhậtlast_fail_tssau mỗi lần decay:last_fail + decay_steps * DECAY_INTERVAL— lần gọi kế tiếp sẽ thấy timestamp mới và tínhelapsednhỏ hơnDECAY_INTERVAL, không decay thêm. - Fail-open (cho phép login tiếp tục) khi CAPTCHA là friction layer bổ sung và hệ thống vẫn có các lớp bảo vệ khác (rate limit, 2FA). Fail-closed (reject request) khi CAPTCHA là lớp duy nhất ngăn bot, hoặc khi service là tài nguyên nhạy cảm cao (admin panel, tài chính). Quyết định cần document rõ trong security policy.
- Hai cơ chế: (a)
unlock:sent:{email}với TTL 300 giây trongsend_unlock_email— không gửi OTP/email mới trong 5 phút; (b)sms:otp:fail:{email}trongunlock_via_sms_otp— sau 5 lần nhập sai OTP, OTP hiện tại bị xoá, phải trigger gửi lại (lại bị cooldown 5 phút). suspendedkhông có TTL nghĩa là nếu Redis bị flush hoặc key bị evict (do memory pressure vớimaxmemory-policy allkeys-lru), account sẽ không còn bị suspended trong Redis nữa. Vì vậy cần sync statesuspendedxuống DB, và login handler phải check DB khi Redis không có keyaccount:suspended— hoặc dùng Redis với policynoevictioncho namespace này.
Bài tiếp theo
Bài 87 đề cập Remember Me Token: cơ chế persistent login an toàn, token rotation, device binding, và revocation.
