Mục lục
- Mục Tiêu Bài Học
- Flow Chuẩn
- Token Entropy — Tại Sao Quan Trọng
- Hash Token Trước Khi Lưu Redis
- Request Reset — Gửi Link Email
- Anti Email Enumeration
- Verify & Set New Password
- Single-Use Enforcement Với Lua
- Rate Limit Request
- Multiple Outstanding Token
- Token Trong URL — Rủi Ro Leak
- Post-Reset Actions
- Edge Cases Khác
- Anti-Patterns
- Best Practices
- Tổng Kết & Quiz
Mục Tiêu Bài Học
- Nắm được flow chuẩn của password reset: request → email link → verify → update password → revoke sessions.
- Hiểu tại sao token phải có entropy tối thiểu 128-bit và tại sao phải hash trước khi lưu Redis.
- Viết được
request_password_resetvàverify_and_resetđầy đủ với Python + Redis. - Hiểu và cài đặt được single-use enforcement bằng Lua atomic để tránh race condition.
- Biết cách chống email enumeration: response đồng nhất, xử lý timing attack.
- Cài đặt rate limit per email và per IP để chống spam reset request.
- Xử lý multiple outstanding token: chỉ giữ token mới nhất, invalidate token cũ.
- Nhận diện các anti-pattern và biết hành động cần làm sau khi reset thành công.
Flow Chuẩn
Flow password reset gồm hai request riêng biệt — một để yêu cầu link, một để xác nhận và đổi mật khẩu:
- Request: user nhập email trên trang "quên mật khẩu", submit form POST.
- Generate token: server sinh token ngẫu nhiên độ entropy cao, hash SHA-256, lưu Redis kèm TTL 30 phút.
- Gửi email: server gửi link
https://app/reset?token=<raw_token>(raw, không phải hash) đến email user. - Click link: user click, trình duyệt mở trang form nhập mật khẩu mới.
- Verify: user submit form, server hash token từ URL, lookup Redis, kiểm tra còn hạn.
- Reset: nếu hợp lệ, xóa token (single-use), update password trong DB, revoke tất cả session cũ.
- Gửi confirmation email: thông báo mật khẩu vừa được thay đổi.
Redis đảm nhận lưu trữ token tạm thời với TTL — đây là lý do tự nhiên để dùng Redis thay vì bảng DB riêng, vì token có vòng đời ngắn và cần lookup cực nhanh.
Token Entropy — Tại Sao Quan Trọng
Token gắn thẳng vào URL và dùng để thực hiện hành động nhạy cảm (đổi mật khẩu). Nếu token predictable hoặc entropy thấp, attacker có thể brute force hoặc đoán token để chiếm tài khoản.
Minimum requirement: 128-bit entropy. Với 128-bit ngẫu nhiên thực sự, không thể brute force ngay cả khi có hàng tỉ request mỗi giây.
Thực tế nên dùng 256-bit để thêm biên an toàn:
import secrets
# secrets.token_urlsafe(n) sinh n bytes ngẫu nhiên, encode base64url
# 32 bytes = 256-bit entropy, output ~43 ký tự URL-safe
token = secrets.token_urlsafe(32)
# Ví dụ: "xK4mP-2bNqR7cLvT8sHjE1fYdAoW9uGiZnBp3XC6kVs"
secrets là module Python 3.6+ dùng nguồn ngẫu nhiên mật mã học của OS (/dev/urandom trên Linux, CryptGenRandom trên Windows). KHÔNG dùng random module — đó là PRNG deterministic, không phù hợp cho mục đích bảo mật.
So sánh entropy theo độ dài token:
| Cách sinh | Entropy | Brute force tại 109 req/s |
|---|---|---|
random.randint(100000, 999999) | ~20-bit | < 1 ms |
uuid4() | 122-bit | Hàng tỉ năm |
secrets.token_urlsafe(32) | 256-bit | Không khả thi |
UUID4 đủ an toàn về entropy, nhưng secrets.token_urlsafe(32) ngắn hơn khi URL-encode và không mang format UUID dễ nhận dạng.
Hash Token Trước Khi Lưu Redis
Nếu lưu raw token thẳng vào Redis, một leak Redis dump (RDB snapshot, AOF file, hoặc kẻ tấn công đọc được memory) sẽ có ngay toàn bộ token còn hạn để chiếm tài khoản.
Giải pháp: lưu SHA-256(token) làm key lookup. Raw token chỉ tồn tại trong email link và request của user — server không lưu nó ở đâu cả.
import hashlib
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
# raw token → trong email link
token = secrets.token_urlsafe(32)
# hash → lưu Redis làm key
token_hash = hash_token(token)
# key pattern
redis_key = f"reset:{token_hash}"
Khi user submit token từ URL, server hash lại và lookup Redis bằng hash. Attacker đọc được Redis chỉ thấy hash — không thể reverse SHA-256 để lấy raw token.
Đây là pattern tương tự cách lưu API key: lưu hash, trả user raw key một lần duy nhất.
Request Reset — Gửi Link Email
import secrets
import hashlib
RESET_TTL = 1800 # 30 phút
def request_password_reset(email: str, ip: str) -> dict:
# Rate limit trước (xem section 9)
rate_limit_reset_request(email, ip)
user = find_user_by_email(email)
if user:
token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(token.encode()).hexdigest()
redis.set(f"reset:{token_hash}", user.id, ex=RESET_TTL)
reset_url = f"https://app/reset?token={token}"
send_email_async(email, subject="Đặt lại mật khẩu", body=reset_url)
# Luôn trả cùng response — anti enumeration
return {"status": "Nếu email tồn tại, link đã được gửi"}
Một vài điểm cần chú ý:
- TTL 30 phút là mức phổ biến. Một số hệ thống dùng 15 phút để giảm attack window. Không nên dùng 24h — window quá rộng.
- send_email_async: gửi email bất đồng bộ (task queue: Celery, Bull...) để response trả về nhanh và đồng đều giữa email valid / invalid (chống timing attack).
- Value lưu Redis: chỉ cần
user_idlà đủ — không cần lưu email hay thông tin khác.
Anti Email Enumeration
Email enumeration là lỗ hổng cho phép attacker xác định email nào đã đăng ký dịch vụ:
# BAD — tiết lộ email có tồn tại hay không
if not user:
return {"error": "Email không tồn tại"}, 404
Với response trên, attacker chỉ cần thử từng email trong danh sách bị leak để biết email nào đã dùng dịch vụ — thông tin có giá trị cho phishing, credential stuffing.
# GOOD — response giống nhau dù email valid hay invalid
return {"status": "Nếu email tồn tại, link đã được gửi"}
Timing attack: dù response text giống nhau, nếu path "email không tồn tại" trả về nhanh hơn path "tìm user + gen token + gửi email" thì attacker vẫn đoán được qua đo thời gian response.
Cách xử lý: gọi send_email_async — task được enqueue ngay lập tức (không block), response trả về trong thời gian đồng đều bất kể email có tồn tại hay không. Nếu gửi email synchronous, thêm dummy sleep cho path invalid:
import time
def request_password_reset(email: str, ip: str) -> dict:
start = time.monotonic()
user = find_user_by_email(email)
if user:
# ... gen token, save redis, send email sync
pass
# Đảm bảo minimum response time ~200ms
elapsed = time.monotonic() - start
if elapsed < 0.2:
time.sleep(0.2 - elapsed)
return {"status": "Nếu email tồn tại, link đã được gửi"}
Async send là cách xử lý sạch hơn vì không giữ request thread nhưng vẫn cần đảm bảo DB lookup time gần bằng nhau.
Verify & Set New Password
def verify_and_reset(token: str, new_password: str) -> dict:
# Validate password strength trước khi verify token
validate_password_strength(new_password)
token_hash = hashlib.sha256(token.encode()).hexdigest()
# Atomic: GET + DEL trong một Lua call (xem section 8)
user_id = atomic_get_and_delete(f"reset:{token_hash}")
if not user_id:
raise BadRequest("Token không hợp lệ hoặc đã hết hạn")
# Update password trong DB
update_password_db(user_id, hash_password(new_password))
# Revoke tất cả session (force re-login trên mọi thiết bị)
revoke_all_sessions(user_id)
# Xóa pointer token hiện tại nếu dùng pattern single-active
redis.delete(f"reset:current:{user_id}")
# Audit log
log_event("password_reset", user_id, meta={"ip": request.ip})
# Gửi confirmation email async
send_confirmation_email_async(user_id, event="password_changed")
return {"status": "Mật khẩu đã được cập nhật"}
Thứ tự các bước quan trọng:
- Validate password strength trước khi verify token — nếu password yếu thì reject sớm, không tiêu thụ token.
- Atomic GET+DEL — vừa lấy user_id vừa xóa token trong một thao tác, tránh race condition.
- Update DB.
- Revoke sessions — đây là bước bắt buộc, không được bỏ qua.
- Audit log và confirmation email.
Single-Use Enforcement Với Lua
Nếu dùng GET rồi DEL riêng lẻ, hai request đến cùng lúc (user click link 2 lần nhanh) có thể đều GET được value trước khi cái nào DEL:
# Race condition với GET + DEL riêng lẻ
user_id = redis.get(key) # Request A: thấy value
# Request B: cũng thấy value (chưa bị DEL)
redis.delete(key) # Request A: DEL
redis.delete(key) # Request B: DEL (nop)
# Cả A và B đều có user_id → cả hai đều reset được password
Lua script chạy atomically trên Redis — không có context switch giữa các bước:
ATOMIC_GET_DEL = """
local v = redis.call("GET", KEYS[1])
if v then
redis.call("DEL", KEYS[1])
end
return v
"""
def atomic_get_and_delete(key: str):
result = redis.eval(ATOMIC_GET_DEL, 1, key)
return result.decode() if result else None
Redis 7.4+ có lệnh GETDEL tích hợp sẵn, không cần Lua:
# Redis 7.4+
user_id = redis.getdel(f"reset:{token_hash}")
GETDEL trả về value và xóa key trong một lệnh atomic. Nếu dùng Redis < 7.4 hoặc cần backward compatibility thì dùng Lua.
Rate Limit Request
Không rate limit reset request cho phép:
- Attacker spam reset link vào email nạn nhân (người dùng nhận hàng trăm email reset).
- Làm số lượng key Redis tăng không kiểm soát.
- Khai thác gửi email tốn kém để DDoS dịch vụ gửi mail.
def rate_limit_reset_request(email: str, ip: str):
# Hash email để không lưu PII trực tiếp trong key Redis
email_hash = hashlib.sha256(email.lower().encode()).hexdigest()[:16]
# 1 request / 5 phút per email (cooldown)
cooldown_key = f"reset:cooldown:{email_hash}"
if not redis.set(cooldown_key, "1", nx=True, ex=300):
raise TooManyRequests("Vui lòng đợi 5 phút trước khi thử lại")
# Tối đa 5 request / giờ per email
hourly_key = f"reset:limit:email:{email_hash}"
count = redis.incr(hourly_key)
if count == 1:
redis.expire(hourly_key, 3600)
if count > 5:
raise TooManyRequests("Quá nhiều yêu cầu. Thử lại sau 1 giờ")
# Tối đa 20 request / giờ per IP (chống script)
ip_key = f"reset:limit:ip:{ip}"
ip_count = redis.incr(ip_key)
if ip_count == 1:
redis.expire(ip_key, 3600)
if ip_count > 20:
raise TooManyRequests("Request bị chặn")
Một số điểm cần lưu ý:
- Hash email trước khi dùng làm key Redis: tránh lưu PII (email plaintext) trong Redis key — giảm rủi ro khi dump.
- Cooldown (5 phút): ngăn user vô tình spam chính mình do click nhiều lần.
- Hourly limit per email: giới hạn cứng, kể cả user chờ hết cooldown.
- Per IP limit: chặn script thử nhiều email khác nhau từ cùng IP.
Multiple Outstanding Token
User có thể request reset nhiều lần trước khi dùng bất kỳ link nào — ví dụ không nhận được email lần 1, request lại lần 2. Mặc định cả hai token đều valid trong TTL.
Nếu muốn chính sách chỉ có 1 token active tại một thời điểm (link cũ bị vô hiệu khi có link mới), cần lưu thêm pointer:
def request_password_reset_single_active(email: str, ip: str) -> dict:
rate_limit_reset_request(email, ip)
user = find_user_by_email(email)
if user:
# Invalidate token cũ nếu có
old_hash = redis.get(f"reset:current:{user.id}")
if old_hash:
redis.delete(f"reset:{old_hash.decode()}")
# Tạo token mới
token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(token.encode()).hexdigest()
# Lưu token mới + pointer
pipe = redis.pipeline()
pipe.set(f"reset:{token_hash}", user.id, ex=RESET_TTL)
pipe.set(f"reset:current:{user.id}", token_hash, ex=RESET_TTL)
pipe.execute()
send_email_async(user.email, f"https://app/reset?token={token}")
return {"status": "Nếu email tồn tại, link đã được gửi"}
Trade-off: nếu email lần 1 bị delay và user đã request lần 2, link lần 1 cuối cùng đến inbox sẽ không hoạt động. Đây là hành vi mong muốn từ góc độ bảo mật — chỉ link mới nhất có giá trị.
Token Trong URL — Rủi Ro Leak
Token nằm trong URL query string bị ghi vào nhiều nơi không kiểm soát được:
- Server access log: dòng
GET /reset?token=xxxghi rõ token. - Proxy/CDN log: mọi layer giữa client và server đều có thể log URL đầy đủ.
- Browser history: URL lưu trong history của người dùng.
- Referer header: nếu trang reset có link ra ngoài (analytics, CDN asset), Referer có thể chứa URL đầy đủ.
Biện pháp giảm thiểu:
- HTTPS bắt buộc — ít nhất token không bị sniff trên đường truyền.
- TTL ngắn (15–30 phút) — log capture được nhưng token đã hết hạn trước khi khai thác.
- Single-use — token dùng rồi là vô hiệu ngay.
- Log scrubbing: cấu hình nginx/Apache không log query string, hoặc replace pattern token trong log pipeline.
- Thêm
Referrer-Policy: no-referrerheader trên trang reset.
Pattern thay thế: email link chỉ chứa một opaque code ngắn, khi click mở trang form. Form trang đó gọi API POST với code trong body, không trong URL. Code trong email vẫn cần secure random, nhưng không xuất hiện trong URL của form submit.
Post-Reset Actions
Sau khi reset thành công, các bước sau đây cần thực hiện:
1. Revoke tất cả sessions (bắt buộc)
Session cũ vẫn còn hạn sau khi đổi mật khẩu là lỗ hổng nghiêm trọng: nếu session trước đó bị đánh cắp, attacker vẫn giữ quyền truy cập dù password đã đổi. Pattern revoke đã được đề cập trong bài 80 (multi-device session).
def revoke_all_sessions(user_id: str):
# Pattern: dùng version counter
redis.incr(f"session:version:{user_id}")
# Middleware kiểm tra version khi validate session
2. Confirmation email
Gửi email thông báo "mật khẩu của bạn vừa được thay đổi lúc HH:MM DD/MM/YYYY từ IP X". Nếu user không tự làm điều này, họ biết có người khác reset và cần hành động tiếp theo (liên hệ support, bật 2FA).
3. Audit log
log_event("password_reset", user_id=user_id, meta={
"ip": request.ip,
"user_agent": request.user_agent,
"timestamp": now_utc()
})
4. Optional: grace period "wasn't me?"
Một số hệ thống gửi kèm link "Không phải tôi?" trong confirmation email. Link này trigger một flow khác: tạm lock account, force contact support, hoặc rollback nếu trong 30 phút. Đây là UX tốt nhưng phức tạp hơn để cài đặt đúng.
Edge Cases Khác
OAuth account không có password
User đăng ký qua Google/GitHub OAuth — hệ thống không lưu password. Nếu họ click "Quên mật khẩu", flow thông thường không áp dụng được. Cần detect và trả về response phù hợp:
if user and user.auth_provider in ("google", "github"):
# Không gen reset token — gửi email hướng dẫn đăng nhập qua provider
send_oauth_info_email(email, provider=user.auth_provider)
return {"status": "Nếu email tồn tại, link đã được gửi"}
CSRF defense cho form reset
Form nhập mật khẩu mới phải có CSRF token riêng — không dùng reset token làm CSRF token. POST /reset cần kiểm tra Origin header hoặc CSRF token trong form field.
Password strength check
def validate_password_strength(password: str):
if len(password) < 12:
raise BadRequest("Mật khẩu phải ít nhất 12 ký tự")
# Kiểm tra common password blocklist lưu trong Redis Set
pwd_hash = hashlib.sha256(password.encode()).hexdigest()
if redis.sismember("blocklist:common_passwords", pwd_hash):
raise BadRequest("Mật khẩu quá phổ biến")
Common password blocklist (top 10k–100k password) có thể pre-hash SHA-256 và nạp vào Redis Set khi khởi động. SISMEMBER O(1), rất nhanh.
Magic link login
Một số ứng dụng dùng magic link thay password hoàn toàn — flow và pattern Redis hoàn toàn giống password reset nhưng TTL ngắn hơn (5–15 phút) và không update password sau khi verify, chỉ tạo session.
Mobile app deep link
Email link có thể trigger deep link mở app mobile (myapp://reset?token=xxx). App handle token rồi gọi API. Pattern Redis phía server không thay đổi — token vẫn hash SHA-256, TTL như nhau.
Anti-Patterns
| Anti-pattern | Hậu quả |
|---|---|
| Token plaintext trong Redis | Redis dump leak → attacker có token để reset ngay |
| Token 6–8 ký tự ngẫu nhiên | Entropy thấp, brute force được trong vài phút |
Dùng random module Python | PRNG predictable, không an toàn mật mã học |
| Token không single-use | Attacker capture link có thể dùng nhiều lần |
| Không revoke sessions sau reset | Session bị đánh cắp trước đó vẫn hoạt động |
| TTL 24h hoặc không có TTL | Attack window lớn, link trong log cũ vẫn dùng được |
| Response tiết lộ email tồn tại/không | Email enumeration, phục vụ phishing và credential stuffing |
| Không rate limit | Spam email nạn nhân, DDoS email service |
| GET + DEL riêng lẻ | Race condition: 2 concurrent request đều pass verify |
Best Practices
- Token entropy:
secrets.token_urlsafe(32)= 256-bit. Không dùngrandom, không dùng UUID nếu muốn token ngắn hơn. - Hash SHA-256: lưu hash trong Redis, raw token chỉ gửi qua email và nhận từ user.
- TTL 15–30 phút: đủ thời gian user thực hiện reset, đủ ngắn để giảm attack window.
- Single-use: dùng
GETDEL(Redis ≥ 7.4) hoặc Lua GET+DEL atomic. Không GET rồi DEL riêng. - Revoke all sessions: bắt buộc ngay sau khi update password thành công.
- Anti-enumeration: response HTTP 200 đồng nhất, gửi email async để timing đồng đều.
- Rate limit: cooldown per email (5 phút), hourly limit per email (5 lần), per IP (20 lần). Hash email trước khi dùng làm Redis key.
- Audit log: ghi nhận mọi password_reset event kèm IP, user agent, timestamp.
- HTTPS + Referrer-Policy: token trong URL, bắt buộc phải có HTTPS; thêm
Referrer-Policy: no-referrertrên trang reset. - Confirmation email: thông báo user sau reset — cần thiết để phát hiện reset trái phép.
Tổng Kết & Quiz
Password reset flow dùng Redis làm token store tạm thời — key là hash SHA-256 của token, value là user_id, TTL 30 phút. Raw token chỉ tồn tại trong email link và request user gửi đến. Các điểm bảo mật cốt lõi: entropy đủ cao, hash trước khi lưu, single-use enforcement atomic, revoke session sau reset, response đồng nhất chống enumeration, rate limit chống spam.
Quiz
- Tại sao phải hash token trước khi lưu Redis thay vì lưu trực tiếp raw token?
- Tại sao dùng
secrets.token_urlsafe(32)thay vìstr(uuid.uuid4())? - Mô tả race condition xảy ra khi dùng
GETrồiDELriêng lẻ và cách Lua atomic (hoặcGETDEL) giải quyết. - Tại sao phải trả HTTP 200 với cùng message dù email không tồn tại? Timing attack liên quan thế nào?
- Nếu không revoke sessions sau reset, kẻ tấn công đã đánh cắp session token trước đó được lợi gì?
Đáp án gợi ý
- Redis dump (RDB snapshot, AOF, memory leak) tiết lộ raw token → attacker dùng ngay để reset. Hash SHA-256 không reverse được — chỉ có raw token mới verify được bằng cách hash lại và so sánh.
secrets.token_urlsafe(32)= 256-bit entropy nguồn CSPRNG của OS. UUID4 chỉ 122-bit ngẫu nhiên và dễ nhận dạng qua format. Cả hai đều đủ an toàn về mặt entropy, nhưngsecretsAPI rõ ràng hơn về mục đích security và tạo ra token ngắn hơn.- Request A và B cùng
GETkey trước khi cái nào DEL → cả hai đều nhận được user_id. Lua GET+DEL chạy atomically — Redis không switch context giữa hai lệnh, nên chỉ request đầu tiên nhận value, request thứ hai nhận nil.GETDELlà lệnh built-in Redis 7.4+ với đảm bảo atomicity tương đương. - Response khác nhau cho valid/invalid email cho phép attacker enumerate — thử từng email và dựa vào response để biết email nào đã đăng ký. Timing attack: path invalid return nhanh hơn path valid (bỏ qua DB lookup, token gen, gửi email) nên attacker đo response time vẫn phân biệt được dù message giống. Fix: async send email (không block) hoặc dummy sleep để đồng đều thời gian.
- Session token cũ vẫn authenticate thành công — attacker giữ quyền truy cập dù user đã đổi password. Revoke session (tăng version counter) buộc mọi session cũ verify lại và fail, attacker mất quyền truy cập ngay lập tức.
Bài tiếp theo
Bài 85 chuyển sang login attempt tracking: phát hiện brute-force theo thời gian thực, sliding window counter, geo-based anomaly detection.
