Là gì
Xác thực là phần trả lời "người này có đúng là họ nói không". Lỗ hổng xác thực là mọi cách một kẻ tấn công vượt qua bước đó mà không có đúng thông tin đăng nhập: đoán mật khẩu hàng loạt, bỏ qua một bước, dùng lại thông tin bị lộ, hay khai thác các luồng phụ (đặt lại mật khẩu, "nhớ tôi").
Vì sao bạn quan tâm
Xác thực là cửa trước của mọi thứ khác, nên một lỗ ở đây làm vô hiệu mọi phân quyền phía sau. Và điều làm nó khó không phải bước đăng nhập chính — đó là chỗ được chú ý nhất — mà là các luồng phụ mà không ai coi là "xác thực":
- Đặt lại mật khẩu. Token đoán được, token không hết hạn, hay gửi tới địa chỉ do người dùng cung cấp trong request — mỗi cái là account takeover không cần biết mật khẩu.
- Liệt kê người dùng. Thông báo lỗi khác nhau, hay chênh lệch thời gian (xem topic info-disclosure), cho kẻ tấn công một danh sách email có thật để credential stuffing.
- "Nhớ tôi" và session sau đăng nhập. Một token "nhớ tôi" đoán được là bỏ qua cả đăng nhập.
Hai mối đe doạ lớn nhất trong thực tế không phải lỗi logic mà là quy mô:
- Credential stuffing: kẻ tấn công thử email+mật khẩu bị lộ từ site khác. Người dùng dùng lại mật khẩu, nên một phần nhỏ luôn trúng. Đây là kiểu tấn công phổ biến nhất, và MFA là biện pháp duy nhất thật sự chặn nó.
- Password spraying: thử một mật khẩu phổ biến (
Password1!) trên hàng nghìn tài khoản, nên rate limit theo tài khoản không bắt được.
Ghi chú kiến trúc: đừng tự xây xác thực nếu tránh được. Một identity provider (Zitadel, Auth0, Entra) đã xử lý MFA, rate limit, lockout, reset an toàn, và cả OIDC (xem topic oauth-oidc). SecLab dùng Zitadel chính vì lý do này (design/12).
Cơ chế hoạt động
Không có một cơ chế; có một bề mặt gồm luồng chính và các luồng phụ, và các luồng phụ mới là chỗ lỗ hổng sống.
flowchart TD A[Kẻ tấn công] --> M["Đăng nhập chính<br/>credential stuffing, spraying"] A --> R["Đặt lại mật khẩu<br/>token đoán được / không hết hạn"] A --> E["Liệt kê người dùng<br/>lỗi khác nhau / chênh thời gian"] A --> S["Session sau đăng nhập<br/>token nhớ-tôi đoán được, fixation"] M --> T[Chiếm tài khoản] R --> T E --> M S --> TĐiểm cốt lõi: liệt kê người dùng là bước MỘT của các bước khác. Nó không tự gây hại nhiều, nhưng nó biến credential stuffing từ "đoán mù" thành "tấn công một danh sách đã xác nhận".
Bảng các luồng và lỗ hổng đặc trưng:
| Luồng | Lỗ hổng | Bản vá thuộc |
|---|---|---|
| Đăng nhập | credential stuffing, spraying | MFA + rate limit + lockout |
| Đặt lại mật khẩu | token yếu, gửi tới địa chỉ trong request | token CSPRNG, hết hạn, gửi tới địa chỉ ĐÃ lưu |
| Liệt kê | lỗi khác nhau, chênh thời gian | response giống hệt + hằng thời gian (topic info-disclosure) |
| Session | fixation, token nhớ-tôi đoán được | sinh session id mới sau đăng nhập, token CSPRNG |
| Lưu mật khẩu | hash yếu, không salt | Argon2id (topic password-storage) |
Hàng cuối trỏ sang topic password-storage: cách lưu mật khẩu là một topic riêng đủ lớn, nhưng nó là một phần không tách rời của xác thực.
Mô tả sơ đồ: Sơ đồ cho thấy kẻ tấn công có bốn đường tới việc chiếm tài khoản: đăng nhập chính qua credential stuffing và password spraying; đặt lại mật khẩu qua token đoán được hoặc không hết hạn; liệt kê người dùng qua thông báo lỗi khác nhau hoặc chênh lệch thời gian; và session sau đăng nhập qua token nhớ-tôi đoán được hoặc session fixation. Liệt kê người dùng không dẫn thẳng tới chiếm tài khoản mà tiếp sức cho đăng nhập chính, vì nó cho kẻ tấn công một danh sách email đã xác nhận.
Ví dụ cụ thể
Ba luồng, ba lỗ hổng — và luồng đặt lại mật khẩu là chỗ ít ai nhìn.
# ① Credential stuffing. Không có lỗi logic nào — chỉ là quy mô.POST /api/login {"email":"alice@acme.com","password":"<mật khẩu lộ từ site khác>"}# Lặp với 10 triệu cặp email/mật khẩu từ một vụ rò khác. Một phần nhỏ luôn trúng# vì người dùng dùng lại mật khẩu. MFA là thứ DUY NHẤT chặn được.# ② Đặt lại mật khẩu gửi tới địa chỉ TRONG REQUEST.POST /api/password-reset {"email":"victim@acme.com","deliverTo":"attacker@evil.example"}# Server gửi link đặt lại tới deliverTo → account takeover không cần mật khẩu.# Bản vá: gửi tới địa chỉ ĐÃ LƯU của tài khoản, không tới địa chỉ client cung cấp.# ③ Token đặt lại đoán được / không hết hạn.GET /reset?token=1042-1698765432 ← userId + timestamp, không phải ngẫu nhiên# Đoán được từ userId và thời điểm. Token phải là 32 byte CSPRNG, hash trong DB,# hết hạn sau 15 phút, dùng một lần.# Sau khi vá: response đăng nhập giống hệt cho mọi lỗi (topic info-disclosure).POST /api/login {"email":"khong-ton-tai@acme.com","password":"x"}HTTP/1.1 401 {"error":"invalid_credentials"} ← y hệt cho email tồn tại lẫn không[HttpPost("/api/password-reset")]public async Task<IActionResult> RequestReset([FromBody] ResetRequest req){ var user = await _users.FindByEmailAsync(req.Email); if (user is null) // ❌ Rò sự tồn tại: email không có tài khoản trả về khác với email có. return NotFound(new { error = "No account with that email" }); // ❌ Token đoán được: userId + timestamp. Kẻ tấn công biết userId (từ URL công // khai) và đoán được timestamp trong một khoảng hẹp → dựng lại được token. var token = $"{user.Id}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"; // ❌ Lưu token THÔ, và không hết hạn. Một vụ rò DB là mọi token đặt lại. user.ResetToken = token; await _users.SaveAsync(user); // ❌ Gửi tới địa chỉ TRONG REQUEST, không tới địa chỉ đã lưu → account takeover. await _mail.SendAsync(req.DeliverTo ?? req.Email, $"Reset: /reset?token={token}"); return Ok();} [HttpPost("/api/login")]public async Task<IActionResult> Login([FromBody] LoginRequest req){ var user = await _users.FindByEmailAsync(req.Email); // ❌ Không rate limit, không MFA, và trả về sớm khi không có user (chênh thời // gian → liệt kê). Credential stuffing chạy tự do ở đây. if (user is null) return Unauthorized(new { error = "No such user" }); if (!_hasher.Verify(user.PasswordHash, req.Password)) return Unauthorized(new { error = "Wrong password" }); return Ok(new { token = IssueSession(user) }); // session id không đổi → fixation}Chuyện đã xảy ra
Credential stuffing ở quy mô công nghiệp (2018–nay). Các báo cáo của Akamai và các nhà cung cấp CDN ghi nhận hàng chục tỉ lần thử credential stuffing mỗi năm nhắm vào bán lẻ, tài chính, streaming. Không phải một sự cố đơn lẻ — nó là mối đe doạ nền, và nó là lý do MFA chuyển từ "nên có" thành "bắt buộc". Nguyên nhân gốc là dùng lại mật khẩu, nên không app đơn lẻ nào "vá" được nó; chỉ MFA làm mật khẩu bị lộ vô dụng.
Các vụ chiếm tài khoản qua đặt lại mật khẩu (nhiều báo cáo bug bounty). Mẫu lặp lại: token đặt lại là một số tăng dần hoặc md5(email+timestamp), hoặc gửi tới địa chỉ trong request, hoặc không hết hạn. Đáng nhớ vì luồng đặt lại thường được viết nhanh, ít review, và không ai coi nó là một bề mặt xác thực — đúng luận điểm khối 2.
NIST SP 800-63B (2017, cập nhật 2024) thay đổi hướng dẫn mật khẩu một cách căn bản: bỏ yêu cầu đổi mật khẩu định kỳ, bỏ quy tắc "phải có ký tự đặc biệt", thay bằng kiểm mật khẩu với danh sách đã lộ và cho phép mật khẩu dài. Đây là chuẩn nói rằng nhiều "quy tắc mật khẩu mạnh" truyền thống thực ra làm giảm an toàn.
Cách phòng chống
MFA — biện pháp duy nhất chặn được credential stuffing
bắt buộcCredential stuffing khai thác mật khẩu bị lộ, nên không phép kiểm mật khẩu nào chặn được nó — mật khẩu đúng. MFA là biện pháp duy nhất làm mật khẩu bị lộ trở nên vô dụng.
- Ưu tiên passkey/WebAuthn (xem topic mfa-passkeys): nó chống cả phishing, khác với TOTP và SMS. Đây là dạng MFA mạnh nhất và đang trở thành mặc định.
- TOTP (app authenticator) là mức tối thiểu chấp nhận được. Tránh SMS nếu có thể — nó bị SIM-swap, nhưng SMS vẫn tốt hơn không có MFA.
- Bắt buộc MFA cho tài khoản có quyền cao (admin, tài chính), và khuyến khích mạnh cho mọi người.
Và đừng tự xây: một identity provider xử lý MFA đúng cách, kèm khôi phục, thiết bị tin cậy, và step-up cho hành động nhạy cảm. Tự xây MFA là tự nhận một danh sách dài các trường hợp biên (mã khôi phục, mất thiết bị, đồng bộ thời gian TOTP) mà mỗi cái là một lỗ hổng tiềm năng.
[HttpPost("/api/password-reset")]public async Task<IActionResult> RequestReset([FromBody] ResetRequest req, CancellationToken ct){ var user = await _users.FindByEmailAsync(req.Email, ct); // Response GIỐNG HỆT dù email có tồn tại hay không — nếu không, luồng đặt lại // thành một API liệt kê người dùng (khối 3). if (user is not null) { // Token 32 byte CSPRNG. KHÔNG phải userId+timestamp — không đoán được. var raw = RandomNumberGenerator.GetBytes(32); var token = WebEncoders.Base64UrlEncode(raw); // Lưu HASH của token, không phải token thô: nó là credential, đối xử như // mật khẩu. Một vụ rò DB không cho kẻ tấn công token dùng được. user.SetResetToken( SHA256.HashData(raw), expiresAt: DateTime.UtcNow.AddMinutes(15), // hết hạn now: DateTime.UtcNow); await _users.SaveAsync(user, ct); // Gửi tới địa chỉ ĐÃ LƯU của tài khoản. req.DeliverTo bị BỎ QUA hoàn toàn — // đó là đường account takeover ở ví dụ lỗi. await _mail.SendAsync(user.Email, $"Reset: /reset?token={token}", ct); } // Cùng một câu, cùng một status, cho mọi trường hợp. return Ok(new { message = "If that email has an account, we sent a reset link." });} [HttpPost("/api/login")]public async Task<IActionResult> Login([FromBody] LoginRequest req, CancellationToken ct){ // Rate limit theo TÀI KHOẢN và theo IP (lớp 2) — hai chiều chặn hai tấn công: // theo tài khoản chặn brute force, theo IP chặn password spraying. await _limiter.CheckAsync(account: req.Email, ip: HttpContext.Connection.RemoteIpAddress, ct); var user = await _users.FindByEmailAsync(req.Email, ct); // Hằng thời gian: chạy hash giả khi không có user (topic info-disclosure lớp 1b). var hash = user?.PasswordHash ?? PasswordHasher.DummyHash; var ok = _hasher.Verify(hash, req.Password); // Response GIỐNG HỆT cho mọi lỗi — không nói "sai mật khẩu" vs "không có user". if (user is null || !ok) { _log.LogInformation("Failed sign-in for {Email}", req.Email); // lớp 3 return Unauthorized(new { error = "invalid_credentials" }); } // MFA: mật khẩu đúng KHÔNG đủ cho tài khoản có quyền cao. Credential stuffing // dùng mật khẩu đúng, nên đây là chỗ nó bị chặn. if (user.MfaEnabled || user.Role >= SystemRole.Editor) return Ok(new { mfaRequired = true, mfaTicket = IssueMfaTicket(user) }); // Sinh session id MỚI sau đăng nhập thành công (chống fixation). await HttpContext.RegenerateSessionAsync(); return Ok(new { token = IssueSession(user) });}Luồng đặt lại mật khẩu an toàn — token CSPRNG, hết hạn, gửi tới địa chỉ đã lưu
bắt buộcKhối 2 nói đặt lại mật khẩu là bề mặt xác thực bị bỏ quên nhất. Bốn quy tắc, và bỏ bất kỳ cái nào là account takeover không cần mật khẩu:
- Token là 32 byte từ CSPRNG, không phải userId, timestamp, hay
md5(email). Lưu hash của token trong DB (không phải token thô — nó là credential, đối xử như mật khẩu). - Hết hạn sau ~15 phút và dùng MỘT LẦN. Token không hết hạn là một mật khẩu vĩnh viễn.
- Gửi tới địa chỉ ĐÃ LƯU của tài khoản, không tới địa chỉ trong request. Ví dụ ② ở khối 4 là chính xác lỗi này.
- Response giống hệt dù email có tồn tại hay không — "nếu email tồn tại, chúng tôi đã gửi link". Nếu không, luồng đặt lại thành một API liệt kê người dùng.
Và sau khi đặt lại: huỷ mọi session đang mở của tài khoản đó — nếu tài khoản bị chiếm, đặt lại mật khẩu phải đẩy kẻ tấn công ra.
Không rò tài khoản nào tồn tại — và session an toàn sau đăng nhập
bắt buộcLiệt kê người dùng (khối 3 gọi là bước một của các bước khác): response và thời gian giống hệt ở đăng nhập, đăng ký, và đặt lại. Xem topic info-disclosure lớp 1b cho phần hằng-thời-gian (chạy hash giả cho nhánh không tìm thấy user).
Session sau đăng nhập:
- Sinh session id MỚI ngay sau khi đăng nhập thành công (chống session fixation): nếu id không đổi, kẻ tấn công đặt trước một id cho nạn nhân rồi dùng lại nó sau khi nạn nhân đăng nhập.
- Token "nhớ tôi" là CSPRNG, hash trong DB, thu hồi được — nó là một credential dài hạn, nên nó cần đúng mọi biện pháp của một mật khẩu.
- Cookie phiên
HttpOnly+Secure+SameSite(xem topic csrf).
Và lưu mật khẩu bằng Argon2id — đây là topic password-storage, nhưng nó là một phần không tách rời của xác thực: một hash yếu nghĩa là một vụ rò DB thành hàng triệu mật khẩu rõ.
Rate limit theo tài khoản VÀ theo IP, cộng kiểm mật khẩu đã lộ
bắt buộcLớp này làm chậm hai mối đe doạ quy mô ở khối 2. Điểm quan trọng là hai chiều rate limit khác nhau chặn hai tấn công khác nhau:
- Theo tài khoản chặn brute force một tài khoản: khoá mềm sau N lần sai (tăng dần độ trễ, hoặc CAPTCHA), không khoá cứng — khoá cứng theo tài khoản là một DoS (kẻ tấn công khoá tài khoản người khác).
- Theo IP / theo mạng chặn password spraying: một mật khẩu thử trên nghìn tài khoản đến từ ít IP. Rate limit theo tài khoản mù với nó vì mỗi tài khoản chỉ thấy một lần thử.
- Kiểm mật khẩu với danh sách đã lộ (Have I Been Pwned k-anonymity API) lúc đăng ký và đổi mật khẩu. Đây là biện pháp NIST SP 800-63B khuyến nghị thay cho quy tắc phức tạp — nó chặn credential stuffing ở gốc bằng cách không cho dùng mật khẩu đã có trong danh sách rò.
Và alert theo tốc độ: nhiều lần đăng nhập thất bại trên nhiều tài khoản từ một dải IP là chữ ký của spraying (xem topic rate-limiting).
Phát hiện: đăng nhập bất thường và thông báo cho người dùng
Credential stuffing không chặn hết được, nên phát hiện là một phần của phòng chống.
- Tỉ lệ thất bại/thành công theo IP và theo tài khoản. Một IP có nghìn lần thất bại và vài lần thành công là credential stuffing đang chạy — và vài lần thành công đó là các tài khoản đã bị chiếm.
- Đăng nhập thành công từ vị trí/thiết bị mới → thông báo email cho người dùng, và với hành động nhạy cảm thì step-up MFA. Người dùng là bộ phát hiện tốt nhất cho tài khoản của chính họ.
- Đổi mật khẩu, đổi email, thêm MFA → luôn thông báo tới địa chỉ CŨ. Nếu là kẻ tấn công, người dùng thật biết ngay.
Và một biện pháp phòng ngừa quan trọng hơn phát hiện: so email đăng ký với danh sách đã lộ và chủ động buộc đổi mật khẩu khi một credential của người dùng xuất hiện trong một vụ rò mới.
Kiểm chứng đã vá
1. Test luồng đặt lại mật khẩu — bốn quy tắc ở lớp 1b, mỗi cái một test:
B=https://staging.example.com# Gửi tới địa chỉ trong request phải bị bỏ qua — link đi tới địa chỉ ĐÃ LƯU.curl -s -X POST "$B/api/password-reset" -d '{"email":"victim@acme.com","deliverTo":"evil@x.com"}'# Kiểm hộp thư của evil@x.com KHÔNG nhận gì; victim@acme.com nhận link.2. Test token đặt lại là ngẫu nhiên và hết hạn — xem tab csharp / test. Khẳng định token có entropy cao (không đoán được từ userId/thời gian), hết hạn, và dùng một lần.
3. Kiểm response đăng nhập giống hệt (chống liệt kê) — cùng phép kiểm với topic info-disclosure: so nội dung và thời gian trung vị giữa email tồn tại và không tồn tại.
4. Kiểm session id ĐỔI sau đăng nhập (chống fixation):
# id trước đăng nhập phải KHÁC id sau đăng nhập.pre=$(curl -si "$B/login" | grep -oi 'session=[^;]*' | head -1)post=$(curl -si -X POST "$B/api/login" -b "$pre" -d '{...}' | grep -oi 'session=[^;]*' | head -1)[ "$pre" != "$post" ] || { echo "session id không đổi sau đăng nhập — fixation"; exit 1; }5. Kiểm MFA được cưỡng chế cho tài khoản admin:
# Đăng nhập admin bằng đúng mật khẩu nhưng KHÔNG MFA phải KHÔNG cấp phiên đầy đủ.curl -s -X POST "$B/api/login" -d '{"email":"admin@acme.com","password":"correct"}' \ | grep -q 'mfa_required' || echo "admin đăng nhập được không cần MFA"6. Kiểm mật khẩu mới bị từ chối nếu có trong danh sách lộ:
# "Password1!" là mật khẩu bị lộ hàng đầu. Đăng ký với nó phải bị từ chối.curl -s -o /dev/null -w '%{http_code}\n' -X POST "$B/api/register" \ -d '{"email":"new@x.com","password":"Password1!"}' # phải là 422public class AuthTests : IClassFixture<ApiFixture>{ private readonly ApiFixture _fx; public AuthTests(ApiFixture fx) => _fx = fx; /// <summary> /// Đặt lại phải gửi tới địa chỉ ĐÃ LƯU, không tới địa chỉ trong request. Đây là /// một account takeover đầy đủ nếu sai, và luồng reset là bề mặt ít review nhất. /// </summary> [Fact] public async Task Reset_ignores_a_request_supplied_delivery_address() { var victim = await _fx.SeedUserAsync("victim@acme.com"); await _fx.Client.PostAsJsonAsync("/api/password-reset", new { email = "victim@acme.com", deliverTo = "attacker@evil.example" }); // Thư đi tới địa chỉ đã lưu, KHÔNG tới deliverTo. Assert.Equal("victim@acme.com", _fx.LastEmailRecipient); Assert.NotEqual("attacker@evil.example", _fx.LastEmailRecipient); } /// <summary>Token phải ngẫu nhiên — không dựng lại được từ userId và thời gian.</summary> [Fact] public async Task Reset_token_is_high_entropy_and_unpredictable() { var user = await _fx.SeedUserAsync("a@acme.com"); var tokens = new HashSet<string>(); for (var i = 0; i < 5; i++) { await _fx.Client.PostAsJsonAsync("/api/password-reset", new { email = "a@acme.com" }); tokens.Add(_fx.ExtractResetTokenFromLastEmail()); } Assert.Equal(5, tokens.Count); // mỗi lần một token khác Assert.All(tokens, t => Assert.True(t.Length >= 40)); // 32 byte base64url // Và không token nào chứa userId hay một timestamp gần đây. Assert.All(tokens, t => Assert.DoesNotContain(user.Id.ToString(), t)); } [Fact] public async Task Reset_token_expires_and_is_single_use() { var (user, token) = await _fx.RequestResetAsync("b@acme.com"); // Dùng một lần: lần đầu OK, lần hai với cùng token phải thất bại. var first = await _fx.Client.PostAsJsonAsync("/api/reset", new { token, password = "NewPassw0rd!xyz" }); var second = await _fx.Client.PostAsJsonAsync("/api/reset", new { token, password = "Another1!xyz" }); first.EnsureSuccessStatusCode(); Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode); // Hết hạn: một token quá 15 phút phải bị từ chối. var (_, oldToken) = await _fx.RequestResetAsync("b@acme.com"); _fx.AdvanceClock(TimeSpan.FromMinutes(16)); var expired = await _fx.Client.PostAsJsonAsync("/api/reset", new { token = oldToken, password = "Yet1!more" }); Assert.Equal(HttpStatusCode.BadRequest, expired.StatusCode); } /// <summary>Đăng nhập không được rò email nào có tài khoản — nội dung VÀ thời gian.</summary> [Fact] public async Task Login_does_not_reveal_which_emails_exist() { await _fx.SeedUserAsync("real@acme.com", "correct-horse-battery"); var exists = await _fx.Client.PostAsJsonAsync("/api/login", new { email = "real@acme.com", password = "wrong" }); var missing = await _fx.Client.PostAsJsonAsync("/api/login", new { email = "no-such@acme.com", password = "wrong" }); Assert.Equal(exists.StatusCode, missing.StatusCode); Assert.Equal(await exists.Content.ReadAsStringAsync(), await missing.Content.ReadAsStringAsync()); } /// <summary>Session id phải đổi sau đăng nhập (chống fixation).</summary> [Fact] public async Task Session_id_changes_after_login() { var client = _fx.NewClient(); var before = await _fx.GetSessionIdAsync(client); // phiên khách trước đăng nhập await client.PostAsJsonAsync("/api/login", new { email = "real@acme.com", password = "correct-horse-battery" }); var after = await _fx.GetSessionIdAsync(client); Assert.NotEqual(before, after); } /// <summary>MFA bắt buộc cho tài khoản có quyền cao — mật khẩu đúng vẫn chưa đủ.</summary> [Fact] public async Task Admin_login_requires_mfa() { await _fx.SeedUserAsync("admin@acme.com", "correct-horse-battery", role: SystemRole.Admin); var res = await _fx.Client.PostAsJsonAsync("/api/login", new { email = "admin@acme.com", password = "correct-horse-battery" }); var body = await res.Content.ReadFromJsonAsync<JsonObject>(); Assert.True(body!["mfaRequired"]!.GetValue<bool>()); Assert.Null(body["token"]); // không cấp phiên đầy đủ trước MFA }}Sai lầm thường gặp
| "Bản vá" | Vì sao không đúng |
|---|---|
| Rate limit theo IP thôi | Password spraying đến từ ít IP nhưng một lần/tài khoản. Cần rate limit theo TÀI KHOẢN nữa |
| Rate limit theo tài khoản thôi | Bỏ sót spraying, và khoá cứng theo tài khoản là một DoS (kẻ tấn công khoá tài khoản người khác) |
| Bắt mật khẩu phức tạp + đổi định kỳ | NIST SP 800-63B (2024) BỎ cả hai — chúng khiến người dùng chọn mật khẩu tệ hơn. Kiểm danh sách lộ thay vào đó |
Token đặt lại = md5(email+timestamp) | Đoán được. Token phải là 32 byte CSPRNG, hash trong DB |
| Đặt lại gửi tới địa chỉ trong request | Account takeover không cần mật khẩu. Gửi tới địa chỉ ĐÃ LƯU |
| Thông báo lỗi khác nhau cho email tồn tại/không | API liệt kê người dùng. Response và thời gian phải giống hệt |
| Giữ nguyên session id sau đăng nhập | Session fixation. Sinh id mới sau đăng nhập thành công |
| "Chúng ta có mật khẩu mạnh nên không cần MFA" | Credential stuffing dùng mật khẩu ĐÚNG. Chỉ MFA làm nó vô dụng |
| Tự xây MFA/reset/session | Danh sách dài trường hợp biên, mỗi cái một lỗ. Dùng identity provider |
Sai lầm về phạm vi, và nó là sai lầm chính: coi "xác thực" là bước đăng nhập. Bước đăng nhập được review kỹ nhất; lỗ hổng sống ở đặt lại mật khẩu, liệt kê người dùng, "nhớ tôi", và session — các luồng phụ không ai gọi là xác thực.
Sai lầm về kiến trúc: tự xây. MFA đúng cách, reset an toàn, session không fixation, OIDC — mỗi cái là một topic, và một identity provider đã làm tất cả. Tự xây là tự nhận trách nhiệm cho một bề mặt mà cả một ngành công nghiệp tồn tại để xử lý.
Bình luận
Bình luận cần tài khoản đã hoàn thành ít nhất một bài học. Điều kiện đó là thứ giữ cho luồng thảo luận này còn đáng đọc: mỗi ý kiến gắn với một người có thể bị hỏi lại, và reputation tích luỹ theo thời gian.
Bạn vẫn đọc được toàn bộ bình luận dưới đây mà không cần tài khoản. Đăng nhập xong bạn sẽ quay lại đúng chỗ này, không phải đầu trang.
Đang tải bình luận…