Bài 7 — A07:2025 Authentication Failures: mật khẩu, throttling và lockout, MFA, khôi phục tài khoản, phiên, JWT và SSO
Series OWASP Top 10:2025: ← Bài 0 — tổng quan · ← Trước: Bài 6 — A06 Insecure Design · Bài 7 — A07 Authentication Failures (bài này) · Tiếp: Bài 8 — A08 Software or Data Integrity Failures →
Authentication (xác thực — trả lời câu hỏi "người đang gõ phím có đúng là chủ tài khoản này không") là thứ mà gần như framework nào cũng
làm sẵn: Laravel có Auth::attempt, starter kit, Fortify, Sanctum; NestJS có Passport và @nestjs/jwt. Vậy mà mục này vẫn giữ
nguyên hạng 7 từ 2021 sang 2025, và số lần xuất hiện trong dữ liệu OWASP tăng gần 8,5 lần. Lý do không nằm ở hàm kiểm mật khẩu — hàm đó gần như
luôn đúng — mà ở mọi thứ xung quanh nó: kẻ tấn công thử hàng nghìn mật khẩu đã lộ từ nơi khác mà không bị chặn, luồng quên mật khẩu
yếu hơn luồng đăng nhập, MFA có "đường lui" yếu hơn chính MFA, session ID không đổi sau khi đăng nhập, logout chỉ xoá cookie phía trình duyệt, JWT
được kiểm chữ ký nhưng không kiểm nó được phát cho ai. Câu hỏi interviewer muốn nghe ở mức Senior/Tech Lead: em liệt kê được hết các "cửa"
dẫn vào một tài khoản không, cửa nào yếu nhất, em chặn đoán mật khẩu tự động mà không biến tính năng đó thành công cụ khoá tài khoản người khác như
thế nào, và em huỷ một phiên đăng nhập trên toàn hệ thống trong bao lâu.
Vì sao bài này dùng khung riêng, và vì sao không có lab. Bài về một nhóm rủi ro, không phải một công nghệ, nên dùng khung riêng của series (10 mục cố định) thay cho 9 câu hỏi của CLAUDE.md. Bài này không có lab (quyết định của series): các nhận định về hành vi dựa trên tài liệu chính thức (OWASP Top 10:2025, OWASP Cheat Sheet Series, NIST SP 800-63B bản 4, docs Laravel 13 / NestJS tra qua Context7, và source của Laravel framework 13.x, Moodle 4.5, Passport đọc trực tiếp) và được đánh dấu nguồn; chỗ nào cần lab mới khẳng định được thì ghi "chưa verify" — danh sách tổng hợp ở cuối bài. Hành vi rate limit của Laravel đã có lab thật ở bài Phạm vi rate limit và được dẫn lại thay vì viết lại. Trọng tâm là phòng thủ: mỗi cơ chế được giải thích ở mức "giả định nào trong code bị sai và hệ quả là gì"; bài không chứa hướng dẫn khai thác.
1. Mục này là gì trong 2025
1.1 Định nghĩa: hệ thống nhận nhầm người
1. OWASP định nghĩa ngắn gọn: "When an attacker is able to trick a system into recognizing an invalid or incorrect user as legitimate, this vulnerability is present" — lỗ hổng tồn tại khi kẻ tấn công khiến hệ thống công nhận một người dùng không hợp lệ hoặc sai người là hợp lệ. Định nghĩa này rộng hơn "đăng nhập sai mật khẩu vẫn vào được": nó bao cả trường hợp người thật đã đăng nhập xong, đi khỏi máy, và người khác ngồi vào tiếp tục phiên đó — hệ thống vẫn "công nhận" một người không phải chủ tài khoản.
2. Danh sách dấu hiệu OWASP liệt kê, gom theo giai đoạn của vòng đời tài khoản cho dễ nhớ:
| Giai đoạn | Dấu hiệu OWASP nêu (diễn ý) |
|---|---|
| Đăng nhập bị tự động hoá | Cho phép credential stuffing (dùng danh sách username/mật khẩu đã lộ), và dạng "lai" mới — password spray với biến thể tăng dần của mật khẩu đã lộ; cho phép brute force hoặc script tự động không bị chặn nhanh |
| Tạo / đổi mật khẩu | Cho phép mật khẩu mặc định, yếu, phổ biến; cho phép tạo tài khoản bằng mật khẩu đã nằm trong danh sách bị lộ |
| Lưu mật khẩu | Lưu bản rõ, mã hoá (thay vì băm), hoặc băm yếu — OWASP đẩy chi tiết sang A04 |
| Khôi phục | Quên mật khẩu yếu, ví dụ "câu hỏi bảo mật" (knowledge-based answers) — OWASP nói thẳng: "cannot be made safe" |
| Yếu tố thứ hai | MFA thiếu hoặc không hiệu quả; đường lui (fallback) yếu khi MFA không dùng được |
| Phiên | Session ID nằm trong URL, hidden field hoặc chỗ client đọc được; giữ nguyên session ID sau khi đăng nhập; không huỷ session/token khi logout hoặc khi không hoạt động (đặc biệt token SSO) |
| Token / assertion | Không kiểm phạm vi (scope) và đối tượng nhận (audience) của credential được trình ra |
3. Cách đọc bảng này ở mức tech lead: chỉ một dòng nói về chuyện so khớp mật khẩu. Sáu dòng còn lại là về tự động hoá, đường phụ và vòng đời. Đó là lý do câu "bọn em dùng Laravel Auth nên không lo A07" là câu trả lời yếu trong phỏng vấn: framework lo phần so khớp và phần phiên cơ bản; phần chính sách (đếm lần sai theo cái gì, khoá bao lâu, MFA cho ai, khôi phục bằng gì, phiên sống bao lâu, logout lan tới đâu) là quyết định của team.
1.2 Thay đổi so với 2021
1. Tên: 2021 là "Identification and Authentication Failures", 2025 bỏ chữ Identification, còn "Authentication Failures". OWASP giải thích đây là "a slight name change to more accurately reflect the 36 CWEs in this category" — đổi tên cho khớp với nội dung thật, không phải đổi phạm vi.
2. Hạng: giữ hạng 7. OWASP bình luận: "Despite benefits from standardized frameworks, this category has kept its #7 rank" — framework chuẩn hoá đã giúp, nhưng chưa đủ để mục này rơi khỏi vị trí. (Năm 2017 mục tương ứng là "Broken Authentication" ở hạng 2; 2021 tụt xuống 7 — theo trang A07:2021.)
3. Số CWE: 22 → 36. Những CWE mới đáng chú ý trong danh sách 2025 là nhóm credential cứng và mặc định (CWE-258, 259, 798, 1392, 1393), nhóm xác thực yếu tổng quát (CWE-1390, 1391), và nhóm tin vào thuộc tính mạng (CWE-291 tin IP, 293 tin Referer, 350 tin reverse DNS). Bài 0 có bảng đối chiếu đầy đủ cả 10 mục.
4. Mô tả và phòng thủ có thêm: (a) dạng tấn công "hybrid credential stuffing / password spray" — dùng biến thể của mật khẩu đã lộ
theo thói quen con người, OWASP lấy ví dụ đổi năm trong mật khẩu theo mùa; (b) kiểm mật khẩu mới với danh sách đã lộ ngay lúc đăng ký và đổi
mật khẩu; (c) fallback yếu của MFA; (d) session ID trong hidden field; (e) không kiểm scope và audience của credential — kèm khuyến nghị cụ thể
"for JWTs validate aud, iss claims and scopes"; (f) khuyến khích password manager; (g) "Ideally, use a premade,
well-trusted system ... Transfer this risk whenever possible by buying" — lần đầu OWASP khuyên thẳng "mua thay vì tự làm" cho phần xác thực.
1.3 Bảng điểm chính thức
| Chỉ số | A07:2021 | A07:2025 | Đọc thế nào |
|---|---|---|---|
| CWE ánh xạ | 22 | 36 | Phạm vi được làm rõ, gom thêm nhóm credential cứng/mặc định |
| Max incidence rate | 14,84% | 15,80% | Ứng dụng tệ nhất về mục này có ~1/6 điểm kiểm tra dính lỗi |
| Avg incidence rate | 2,55% | 2,92% | Trung bình nhích lên |
| Max coverage | 79,51% | 100,00% | Có tập dữ liệu đã kiểm mọi CWE của mục |
| Avg coverage | 45,72% | 37,14% | Trung bình các tập dữ liệu kiểm được ít CWE hơn (vì số CWE tăng) |
| Avg weighted exploit | 7,40 | 7,69 | Dễ khai thác — nhiều dạng chỉ cần script và danh sách đã lộ |
| Avg weighted impact | 6,50 | 4,44 | Tác động trung bình theo CVSS thấp đi — nhiều CWE mới là lỗi cấu hình/credential |
| Total occurrences | 132.195 | 1.120.673 | Tăng ~8,5 lần (một phần do dữ liệu 2025 lớn hơn) |
| Total CVEs | 3.897 | 7.147 | Gần gấp đôi |
Cách nói khi phỏng vấn: "A07 năm 2025 vẫn hạng 7, đổi tên bỏ chữ Identification cho khớp với 36 CWE bên trong. Con số em nhớ là số lần xuất hiện tăng khoảng tám lần rưỡi và số CVE gần gấp đôi, trong khi điểm tác động trung bình lại giảm. Em đọc điều đó là: lỗi xác thực ngày càng phổ biến ở dạng 'vặt' — credential mặc định, cấu hình phiên — chứ không phải framework kiểm mật khẩu sai. Và OWASP lần đầu khuyên thẳng là nếu được thì mua một hệ thống xác thực đã được kiểm chứng thay vì tự xây."
1.4 Gom 36 CWE theo cơ chế
Trang OWASP ghi cùng một tên "Improper Validation of Certificate with Host Mismatch" cho cả CWE-297, 298, 299; tên đúng theo MITRE (đã đối chiếu trang cwe.mitre.org) là: 297 — sai tên host, 298 — không kiểm hạn chứng chỉ, 299 — không kiểm thu hồi chứng chỉ. Bảng dưới dùng tên MITRE.
| Nhóm | CWE | Giả định sai trong code |
|---|---|---|
| Credential cứng, rỗng, mặc định | 258, 259, 798, 1392, 1393 | "Mật khẩu trong code/config chỉ mình team biết"; "admin/admin sẽ được đổi sau khi cài" |
| Credential yếu, chính sách yếu | 521, 1390, 1391 | "Bắt có chữ hoa + số + ký tự đặc biệt là mật khẩu mạnh" |
| Đoán tự động, chỉ một yếu tố | 307, 308, 309 | "Không ai thử hàng nghìn lần"; "mật khẩu là đủ" |
| Đường vòng qua xác thực | 288, 289, 290, 302, 305, 306 | "Endpoint này nội bộ / mobile / cũ nên không cần kiểm"; "giá trị trong cookie/header không ai sửa được" |
| Tin thuộc tính mạng và kênh | 291, 293, 346, 350, 940, 941 | "Request đến từ IP văn phòng / Referer đúng / reverse DNS đúng thì là người mình" |
| Phát lại, kênh bị chen giữa | 294, 300 | "Bắt được gói tin cũng không dùng lại được" |
| Kiểm chứng chỉ TLS | 295, 297, 298, 299 | "Tắt verify cho nhanh, mạng nội bộ mà" |
| Cài thuật toán / bỏ bước | 287, 303, 304 | "Luồng MFA có bước 2 nhưng API bước 3 không kiểm bước 2 đã xong" |
| Phiên | 384, 613 | "Session ID trước và sau đăng nhập giống nhau cũng không sao"; "phiên sống mãi cũng tiện cho người dùng" |
| Đổi và khôi phục mật khẩu | 620, 640 | "Đã đăng nhập thì đổi mật khẩu không cần nhập mật khẩu cũ"; "câu hỏi bảo mật là đủ" |
1.5 Ranh giới với các mục khác
| Câu hỏi | Mục | Ví dụ LMS (minh hoạ) |
|---|---|---|
| Hệ thống có biết chắc ai đang gọi không? | A07 | Phiên sinh viên A không hết hạn trên máy phòng lab; người sau dùng tiếp |
| Biết là ai rồi, người đó có được phép làm việc này không? | A01 (Bài 1) | Sinh viên đã đăng nhập xem được bài nộp của người khác qua đổi ID |
| Mật khẩu/token có được băm, sinh ngẫu nhiên, ký đúng thuật toán không? | A04 (Bài 4) | Mật khẩu băm MD5; JWT verify chấp nhận thuật toán do token tự khai |
| Luồng xác thực có được thiết kế với control cần có không? | A06 (Bài 6) | Khôi phục tài khoản bằng câu hỏi bảo mật — không cài cách nào an toàn được |
| Có credential mặc định do cấu hình triển khai để lại không? | A02 + A07 | Trang quản trị Redis/MinIO giữ mật khẩu mặc định — A02 nói về cấu hình, A07 gom các CWE credential mặc định |
| Có ghi log và cảnh báo khi bị dò mật khẩu không? | A09 (Bài 9) | Một triệu lần đăng nhập sai trong đêm, không ai biết |
flowchart TD
Q0["Lỗi vừa tìm thấy"] --> Q1{"Hệ thống có nhận nhầm người không?<br/>sai người, hết hạn, hoặc không kiểm"}
Q1 -- "Có" --> Q2{"Control cần thiết có trong thiết kế không?"}
Q2 -- "Không có, và không cài cách nào an toàn được" --> A06["A06 Insecure Design<br/>vd câu hỏi bảo mật"]
Q2 -- "Có nhưng cài thiếu / sai" --> A07["A07 Authentication Failures"]
Q1 -- "Không, biết đúng người" --> Q3{"Người đó có được phép không?"}
Q3 -- "Không được nhưng vẫn làm được" --> A01["A01 Broken Access Control"]
A07 -. "nếu gốc là băm / ký sai" .-> A04["A04 Cryptographic Failures"]
Cách nói khi phỏng vấn: "Em tách A07 với A01 bằng hai câu hỏi nối tiếp: hệ thống có biết chắc ai đang gọi không, rồi mới tới người đó có được phép không. Sai ở câu đầu là A07, sai ở câu sau là A01. Nếu gốc của lỗi là mật khẩu băm yếu hay JWT ký sai thuật toán thì em xếp thêm vào A04, còn nếu tính năng xác thực đó về bản chất không thể làm an toàn — như câu hỏi bảo mật — thì đó là lỗi thiết kế, A06."
2. Bài toán gốc — vì sao xác thực vẫn hỏng khi framework đã làm sẵn
1. Mật khẩu là bí mật dùng chung giữa nhiều nơi. Về lý thuyết mật khẩu là bí mật giữa một người và một hệ thống. Thực tế người dùng dùng lại cùng mật khẩu ở nhiều trang, nên một trang bị lộ là chìa khoá cho các trang khác. Hệ thống của mình có thể không làm gì sai mà vẫn nhận về những lần đăng nhập "đúng mật khẩu" của kẻ tấn công. Đây là gốc của credential stuffing: hàm so khớp mật khẩu trả lời đúng câu hỏi được hỏi ("mật khẩu này có khớp không") — chỉ là câu hỏi đó không còn đủ để kết luận "đây là chủ tài khoản". OWASP kịch bản số 2 nói đúng điều này: "Most successful authentication attacks occur due to the continued use of passwords as the sole authentication factor".
2. HTTP không có trạng thái, nên bằng chứng xác thực bị đổi thành một token mang theo. Mật khẩu chỉ được kiểm một lần lúc đăng nhập; mọi request sau đó được nhận diện bằng session ID hoặc access token. Token đó là bearer credential (credential kiểu "ai cầm là người đó") — ai có nó thì được coi là chủ tài khoản, không cần biết mật khẩu. Hệ quả: một nửa của A07 thực chất là bảo vệ token: sinh ngẫu nhiên đủ mạnh, đổi khi mức đặc quyền thay đổi, không để lộ ở URL/log/localStorage, và hết hạn đúng lúc. Sự cố Okta 2023 (mục 8) là ví dụ: kẻ tấn công không cần mật khẩu, chỉ cần session token nằm trong file chẩn đoán khách hàng gửi lên.
3. Một tài khoản có nhiều cửa, kẻ tấn công chọn cửa yếu nhất. Đăng nhập chỉ là một cửa. Cùng tài khoản đó còn mở được qua: quên mật khẩu, đổi email, đổi số điện thoại, đăng ký thiết bị MFA mới, mã khôi phục MFA, "ghi nhớ đăng nhập", API token cho mobile, SSO, đăng nhập bằng "magic link", và endpoint cũ chưa ai tắt. Team thường đầu tư vào cửa chính và để các cửa phụ yếu hơn. Độ an toàn của tài khoản bằng độ an toàn của cửa yếu nhất, không phải cửa chính — đó là ý của CWE-288 (đường vòng qua kênh khác) và dòng "fallback yếu" OWASP thêm năm 2025.
flowchart LR ACC["Tài khoản sinh viên"] L1["Form đăng nhập<br/>throttle + MFA"] --> ACC L2["Quên mật khẩu<br/>email chứa link"] --> ACC L3["Đổi email / số điện thoại"] --> ACC L4["Mã khôi phục MFA"] --> ACC L5["Remember-me cookie"] --> ACC L6["API token mobile / portal"] --> ACC L7["SSO từ hệ thống khác"] --> ACC L8["Endpoint cũ chưa tắt<br/>không throttle, không MFA"] --> ACC style L8 stroke:#ff6b6b style L1 stroke:#46d18a
Cách nói khi phỏng vấn: "Khi review xác thực em không bắt đầu từ form đăng nhập mà vẽ ra mọi con đường dẫn tới một tài khoản: quên mật khẩu, đổi email, thêm thiết bị MFA, mã khôi phục, remember-me, API token, SSO, và các endpoint cũ. Tài khoản chỉ an toàn bằng con đường yếu nhất trong danh sách đó. Rất hay gặp chuyện form chính có throttle và MFA, còn endpoint đăng nhập của app mobile đời cũ thì không có gì."
4. Bảo mật và tiện dụng kéo về hai phía, và mỗi control có tác dụng phụ. Khoá tài khoản sau 5 lần sai chặn đoán mật khẩu nhưng cho phép bất kỳ ai khoá tài khoản người khác (một dạng DoS — từ chối dịch vụ). MFA chặn mật khẩu lộ nhưng tạo ra nhu cầu khôi phục khi mất điện thoại, và luồng khôi phục thường yếu hơn MFA. Phiên ngắn an toàn hơn nhưng sinh viên đang làm bài thi 90 phút bị đăng xuất giữa chừng. Vì vậy A07 không có "cấu hình chuẩn": mỗi tham số là một quyết định đánh đổi theo ngữ cảnh, và câu trả lời phỏng vấn tốt luôn nói rõ ngữ cảnh.
5. Hệ phân tán làm "đăng xuất" khó. Với một ứng dụng một session store, logout là xoá một dòng. Với portal NestJS + Moodle + SSO + JWT phát cho mobile, "đăng xuất" phải lan tới mọi nơi đã tin phiên đó. JWT không trạng thái thì không thu hồi được trước hạn nếu không thêm trạng thái — cheat sheet JWT của OWASP nói thẳng: thêm denylist thì "user sessions won't be completely stateless anymore which might defeat the benefits of stateless sessions". OWASP kịch bản số 3 mô tả đúng hệ quả: SSO không có Single Logout, người dùng tưởng đã thoát nhưng vẫn đăng nhập ở các ứng dụng khác trên cùng trình duyệt.
6. Tự viết thay vì dùng thứ đã được kiểm chứng. Phần lớn lỗi A07 trong code review đến từ chỗ tự viết: tự sinh token reset bằng hàm ngẫu nhiên thống kê, tự viết so sánh chuỗi, tự viết luồng MFA hai bước mà bước sau không kiểm bước trước, tự xử lý JWT bằng cách decode rồi đọc payload mà quên verify. OWASP 2025 vì vậy khuyên mua hoặc dùng hệ thống có sẵn — kèm cái giá về vendor lock-in mà mục 5.5 bàn.
3. Cơ chế — mỗi dạng sai ở giả định nào
Mỗi mục con dưới đây theo cùng một khuôn: giả định sai trong code, hệ quả, ví dụ có số, và control tương ứng (chi tiết code ở mục 4). Bài chỉ mô tả cơ chế ở mức khái niệm, dựa trên mô tả và kịch bản của OWASP.
3.1 Đoán mật khẩu tự động — ba hình dạng, ba loại bộ đếm
1. Brute force (dò vét): nhiều mật khẩu nhắm vào một tài khoản. Credential stuffing: các cặp username/mật khẩu đã lộ từ trang khác, mỗi tài khoản thử một hai lần. Password spray: một (hoặc vài) mật khẩu phổ biến thử trên rất nhiều tài khoản. OWASP 2025 thêm dạng "hybrid": lấy mật khẩu đã lộ của chính người đó rồi thử biến thể theo thói quen (đổi năm, tăng số cuối) — hiệu quả hơn bản gốc vì người dùng bị buộc đổi mật khẩu thường đổi theo cách đoán được.
2. Giả định sai chung: "đặt một bộ đếm lần sai là chặn được". Mỗi hình dạng lọt qua một loại bộ đếm khác nhau. Ví dụ có số, LMS 100.000 sinh viên (minh hoạ):
- Chỉ đếm theo IP, 10 lần sai/giờ/IP: kẻ tấn công thuê 5.000 IP (proxy dân dụng, botnet) → 50.000 lần thử/giờ mà không IP nào chạm ngưỡng. Còn ngược lại, cả một phòng máy trường sau NAT (một IP public, 200 sinh viên) gõ sai vài lần là cả phòng bị chặn.
- Chỉ đếm theo tài khoản, 5 lần sai/15 phút: chặn tốt brute force (một tài khoản chỉ bị thử 5 × 4 × 24 = 480 lần/ngày), nhưng password spray thử 1 mật khẩu trên 100.000 tài khoản → mỗi tài khoản chỉ thấy 1 lần sai, không bao giờ chạm ngưỡng. Nếu 0,5% sinh viên dùng mật khẩu đó, kẻ tấn công lấy được ~500 tài khoản trong một lượt.
- Theo cặp tài khoản + IP (mặc định của Laravel starter kit/Fortify): cân bằng giữa hai cái trên cho người dùng bình thường, nhưng vẫn không thấy spray.
3. Hệ quả: chặn đoán mật khẩu tự động cần ít nhất ba tầng nhìn: theo tài khoản (chặn brute force), theo nguồn (IP/ASN/fingerprint — làm chậm stuffing), và theo toàn hệ thống (tỉ lệ đăng nhập sai tổng, chặn spray). Tầng thứ ba không phải bộ đếm chặn request mà là metric + cảnh báo: bình thường LMS có 2.000 lần đăng nhập/giờ, tỉ lệ sai 5% = 100 lần sai/giờ; một đợt spray đẩy lên 10.000 lần sai/giờ — gấp 100 lần nền — dù không tài khoản nào bị khoá. Và cuối cùng, theo cheat sheet Credential Stuffing của OWASP, MFA là lớp chặn mạnh nhất vì nó làm mật khẩu đúng cũng không đủ.
flowchart TD BF["Brute force<br/>nhiều mật khẩu → 1 tài khoản"] --> C1["Bộ đếm theo tài khoản<br/>BẮT được"] CS["Credential stuffing<br/>cặp đã lộ, mỗi tài khoản 1–2 lần, nhiều IP"] --> C2["Theo nguồn IP / ASN / bot<br/>làm CHẬM được"] PS["Password spray<br/>1 mật khẩu → 100.000 tài khoản"] --> C3["Metric tỉ lệ sai toàn hệ thống<br/>PHÁT HIỆN được"] C1 --> MFA["MFA: mật khẩu đúng vẫn chưa đủ<br/>chặn cả ba"] C2 --> MFA C3 --> MFA
Cách nói khi phỏng vấn: "Em phân biệt ba hình dạng: dò nhiều mật khẩu vào một tài khoản, thử cặp đã lộ trên nhiều tài khoản, và thử một mật khẩu phổ biến trên rất nhiều tài khoản. Đếm theo tài khoản bắt được cái đầu nhưng mù với cái cuối, vì mỗi tài khoản chỉ sai một lần. Đếm theo IP thì botnet né được và lại chặn nhầm cả phòng máy sau NAT. Nên em đặt ba tầng: đếm theo tài khoản, làm chậm theo nguồn, và theo dõi tỉ lệ sai toàn hệ thống để phát hiện spray — rồi MFA để mật khẩu đúng vẫn chưa đủ."
3.2 Chính sách mật khẩu — vì sao luật "phức tạp + đổi định kỳ" phản tác dụng
1. Giả định sai: "bắt chữ hoa, chữ thường, số, ký tự đặc biệt và đổi 90 ngày một lần thì mật khẩu mạnh". OWASP kịch bản số 2: các quy định đó "encourage users to both reuse passwords and use weak passwords". Người dùng bị ép phức tạp sẽ chọn mẫu dễ đoán (viết hoa chữ đầu, thêm số và dấu chấm than ở cuối); bị ép đổi định kỳ sẽ tăng số cuối — đúng dạng "hybrid" ở 3.1.
2. NIST SP 800-63B bản 4 (phát hành tháng 8/2025, mục 3.1.1.2) — chuẩn OWASP dẫn — viết bằng SHALL/SHOULD (bắt buộc / nên):
| Quy định NIST 800-63B-4 | Mức | Ý nghĩa thực tế |
|---|---|---|
| Mật khẩu dùng làm yếu tố duy nhất: tối thiểu 15 ký tự | SHALL | Không có MFA thì độ dài là thứ duy nhất chống đoán |
| Mật khẩu chỉ dùng trong MFA: tối thiểu 8 ký tự | SHALL | Có yếu tố thứ hai thì cho phép ngắn hơn |
| Cho phép tối đa ít nhất 64 ký tự | SHOULD | Để dùng passphrase (cụm từ dài) |
| Chấp nhận mọi ký tự in được, khoảng trắng, Unicode (mỗi code point tính 1 ký tự, chuẩn hoá NFC) | SHOULD | Không bắt người dùng tránh dấu tiếng Việt |
| Không áp luật thành phần (bắt trộn loại ký tự) | SHALL NOT | Bỏ luật "phải có chữ hoa + số + ký tự đặc biệt" |
| Không bắt đổi định kỳ; bắt đổi khi có dấu hiệu lộ | SHALL NOT / SHALL | Bỏ chính sách "90 ngày" |
| Kiểm với blocklist: mật khẩu đã lộ, từ điển, từ theo ngữ cảnh (tên dịch vụ, username) | SHALL | "lms2025" bị từ chối, kèm lý do |
| Không cho gợi ý mật khẩu, không hỏi câu hỏi bảo mật | SHALL NOT | Bỏ "tên thú cưng đầu tiên" |
| Kiểm toàn bộ mật khẩu, không cắt bớt | SHALL | Lưu ý bcrypt chỉ dùng 72 byte đầu — xem Bài 4 |
| Cho phép password manager, autofill, dán (paste) | SHALL / SHOULD | Không chặn paste ở ô mật khẩu |
3. Chú ý cách NIST lập luận cho blocklist: blocklist chống đoán trực tuyến — vốn đã bị giới hạn số lần thử (mục 3.2.2 của NIST, tối đa 100 lần sai liên tiếp) — nên "excessively large blocklists are of little incremental security benefit". Nghĩa là: không cần blocklist hàng tỉ mục; cần blocklist đủ để chặn những mật khẩu kẻ tấn công sẽ thử trước khi chạm giới hạn. Hai control (blocklist và throttling) được thiết kế dựa vào nhau — bỏ một cái thì cái kia hết đủ.
4. Credential mặc định và cứng (CWE-258/259/798/1392/1393) là cùng giả định "không ai biết mật khẩu này" nhưng ở tầng triển khai: tài khoản admin seed với mật khẩu cố định, chuỗi kết nối DB trong repo, mật khẩu mặc định của MinIO/Redis/trang quản trị. OWASP: "Do not ship or deploy with any default credentials, particularly for admin users". Phần quản lý secret và quét secret trong repo đã có ở Bài 2 và Bài 3.
3.3 Thông báo lỗi và thời gian phản hồi — account enumeration
1. Giả định sai: "nói rõ cho người dùng biết email chưa đăng ký là thân thiện". Cheat sheet Authentication của OWASP yêu cầu mọi cơ chế xác thực (đăng nhập, reset, khôi phục) trả lời chung chung bất kể username sai, mật khẩu sai, tài khoản không tồn tại, hay bị khoá — để không tạo ra "discrepancy factor" (yếu tố khác biệt, CWE-204). Nếu không, form đăng nhập/quên mật khẩu trở thành công cụ để lọc ra danh sách email thật của hệ thống — đầu vào hoàn hảo cho stuffing và phishing nhắm mục tiêu.
2. Khác biệt không chỉ ở chữ mà ở thời gian. Cheat sheet chỉ ra mẫu "quick exit": nếu user không tồn tại thì trả lỗi ngay; nếu tồn
tại thì băm mật khẩu (bcrypt/Argon2 tốn ~50–300 ms) rồi mới trả lỗi. Cùng một câu thông báo nhưng một bên 5 ms, một bên 250 ms — đo là phân biệt được.
Laravel xử lý chỗ này ở framework: SessionGuard::attempt() trong 13.x chạy toàn bộ logic trong một Timebox mặc định 200.000 µs
(200 ms) và chỉ trả sớm khi đăng nhập thành công; PasswordBroker::sendResetLink() và reset() cũng bọc Timebox (đọc từ source
13.x, chưa đo bằng lab).
3. Nhưng framework không lo phần chữ: PasswordBroker trả về hằng số khác nhau cho "không tìm thấy user"
(INVALID_USER) và "đã gửi link" (RESET_LINK_SENT), và controller của ứng dụng quyết định hiển thị gì. Nếu controller dịch
thẳng hằng số thành câu thông báo thì enumeration quay lại dù đã có Timebox. Câu đúng theo OWASP: "If that email address is in our database, we will
send you an email to reset your password."
4. Trade-off thật: form đăng ký khó trả lời chung chung (người dùng cần biết email đã được dùng). Cheat sheet gợi ý dùng CAPTCHA hoặc throttling cho những chỗ không thể trả lời chung chung, hoặc đổi luồng: luôn trả "kiểm tra email để tiếp tục", và email gửi tới chủ thật sẽ nói "tài khoản đã tồn tại, nếu quên mật khẩu bấm vào đây".
3.4 MFA thiếu, MFA yếu, và đường lui yếu hơn MFA
1. Giả định sai thứ nhất: "có MFA là xong". Không phải MFA nào cũng ngang nhau. Cheat sheet MFA của OWASP (mục MFA Attack Patterns) liệt kê các dạng tấn công nhắm vào triển khai MFA thay vì phá MFA: MFA fatigue (dội thông báo push cho tới khi người dùng bấm duyệt cho đỡ phiền — đúng sự cố Uber 2022), reverse-proxy phishing (trang giả chuyển tiếp trực tiếp cả mật khẩu và mã OTP, lấy luôn session token), SIM swap (chiếm số điện thoại để nhận SMS), và MFA downgrade (đi qua protocol/endpoint cũ không bắt MFA).
2. Thang độ mạnh, rút từ NIST 800-63B-4 và cheat sheet MFA:
| Yếu tố thứ hai | Chống phishing thời gian thực? | Rủi ro chính | Ghi chú |
|---|---|---|---|
| Passkey / FIDO2 / WebAuthn | Có — gắn với origin thật | Mất thiết bị → cần khôi phục; passkey đồng bộ phụ thuộc tài khoản cloud | NIST: AAL2 phải cung cấp lựa chọn chống phishing |
| TOTP (app sinh mã) | Không — mã chuyển tiếp được | Trang giả chuyển tiếp mã trong 30 giây | Rẻ, không phụ thuộc nhà mạng |
| Push có number matching | Không hoàn toàn | Social engineering | NIST: phải so khớp bí mật, không chỉ bấm "duyệt" |
| Push chỉ bấm "Duyệt" | Không | MFA fatigue | NIST-4: "no longer considered acceptable" |
| SMS / gọi thoại | Không | SIM swap, chuyển số | NIST xếp loại restricted (hạn chế) |
| Email OTP | Không | Email cũng chỉ bảo vệ bằng mật khẩu | NIST-4: "Email SHALL NOT be used for out-of-band authentication" |
3. Giả định sai thứ hai: "MFA mạnh thì tài khoản mạnh" — trong khi luồng khôi phục khi mất MFA là "gửi mã qua email" hoặc "gọi hotline đọc ngày sinh". Kẻ tấn công không phá MFA mà đi cửa khôi phục. OWASP 2025 thêm đúng dòng này: "weak or ineffective fallbacks if multi-factor authentication is not available". Cheat sheet MFA gợi ý: mã khôi phục dùng một lần cấp lúc bật MFA, đăng ký nhiều loại yếu tố, quy trình hỗ trợ xác minh nghiêm ngặt, và thông báo qua kênh khác mỗi khi một yếu tố MFA bị thay đổi.
4. Giả định sai thứ ba: "bước 2 có kiểm" — trong khi endpoint sau bước 2 không kiểm bước 2 đã hoàn tất (CWE-304, bỏ bước quan trọng). Mẫu đúng là coi đăng nhập là một state machine ở server: sau mật khẩu đúng, phiên ở trạng thái "chờ MFA" chỉ được gọi đúng endpoint MFA; chỉ khi MFA đúng mới chuyển sang "đã xác thực" và đổi session ID — cùng tư duy state machine ở Bài 6.
stateDiagram-v2 [*] --> Anonymous Anonymous --> PasswordOK: mật khẩu đúng, đổi session ID PasswordOK --> Authenticated: OTP hoặc passkey đúng, đổi session ID lần nữa PasswordOK --> Anonymous: quá số lần sai MFA, hoặc quá 5 phút PasswordOK --> PasswordOK: chỉ gọi được endpoint MFA Authenticated --> Anonymous: logout, idle timeout, absolute timeout
Cách nói khi phỏng vấn: "Em coi đăng nhập có MFA là một state machine ở server. Mật khẩu đúng chỉ đưa phiên sang trạng thái chờ MFA, ở trạng thái đó phiên chỉ được gọi đúng endpoint nhập mã, có giới hạn số lần sai và hạn vài phút. Chỉ khi yếu tố thứ hai đúng mới sang trạng thái đã xác thực, và em đổi session ID ở cả hai lần chuyển. Như vậy không có API nào lọt được khi mới qua bước mật khẩu."
3.5 Quên mật khẩu — cửa phụ thường yếu nhất
1. Cheat sheet Forgot Password của OWASP tóm thành các nguyên tắc: thông báo như nhau cho tài khoản có/không tồn tại; thời gian phản hồi đều; gửi qua kênh phụ (side-channel — email/SMS); token "randomly generated using a cryptographically safe algorithm", đủ dài, "stored securely", "single use and expire after an appropriate period"; và không thay đổi gì trên tài khoản cho tới khi token hợp lệ được trình ra (ví dụ không khoá tài khoản chỉ vì có người bấm "quên mật khẩu").
2. Năm giả định sai hay gặp, mỗi cái một hệ quả:
- "Token chỉ cần khó đoán vừa phải" — token sinh từ thời gian, ID, hàm ngẫu nhiên thống kê thì đoán được trạng thái. Chi tiết sinh và lưu token (CSPRNG, lưu hash) ở Bài 4, mục 4.2.
- "Lưu token bản rõ trong DB cũng được" — DB lộ (backup, replica, SQL injection) là dùng được ngay mọi token đang chờ. Laravel lưu hash
của token (
$this->hasher->make($token)trongDatabaseTokenRepositoryvàCacheTokenRepository). - "Link reset dựng từ Host header của request" — cheat sheet cảnh báo không dựa vào Host header khi dựng URL reset (Host header injection): link
phải dựng từ base URL cấu hình cứng (
APP_URL), không từ request. - "Trang đặt mật khẩu mới có thể tải script/ảnh bên thứ ba" — token nằm trong URL sẽ đi theo header Referer; cheat sheet yêu cầu
Referrer-Policy: no-referrertrên trang reset. - "Đổi mật khẩu xong là xong" — các phiên cũ (có thể là của kẻ tấn công) vẫn sống. Sau reset phải huỷ phiên và token khác; Moodle có hẳn
cài đặt
passwordchangelogoutcho việc này (mục 7).
3. Đổi mật khẩu khi đang đăng nhập (CWE-620) và đổi email: phải hỏi lại mật khẩu hiện tại (cheat sheet: "Require Re-authentication for Sensitive Features"), vì một phiên bị cướp (XSS, máy dùng chung) không được phép biến thành chiếm vĩnh viễn tài khoản. Đổi email là cửa nguy hiểm nhất: đổi được email là reset được mật khẩu. Cheat sheet có quy trình riêng: xác nhận qua cả email cũ và email mới.
3.6 Phiên — fixation, chỗ đặt session ID, hết hạn và logout
1. Session fixation (CWE-384, "cố định phiên"): giả định sai là "session ID trước và sau đăng nhập giữ nguyên cũng không sao". Nếu một bên thứ ba biết hoặc đặt được session ID trước khi nạn nhân đăng nhập (máy dùng chung, subdomain bị kiểm soát, XSS), thì sau khi nạn nhân đăng nhập, ID đó trở thành ID của phiên đã xác thực. Cheat sheet Session Management: session ID "must be renewed or regenerated ... after any privilege level change" — đăng nhập, đổi mật khẩu, đổi quyền, chuyển sang vai trò admin.
2. Chỗ đặt session ID: OWASP liệt kê URL, hidden field và chỗ client đọc được. URL bị ghi vào access log, lịch sử trình duyệt, header
Referer; hidden field và localStorage đọc được bằng JavaScript (một lỗi XSS là lộ). Cheat sheet Session Management hiện có cảnh báo đậm: "Do not store
authentication tokens, session IDs, JWTs, refresh tokens, or any credential in localStorage or sessionStorage" — dùng
cookie HttpOnly; Secure; SameSite hoặc mẫu BFF (Backend-for-Frontend — server trung gian giữ token thay trình duyệt). Tiền tố tên cookie
__Host- được khuyến nghị cho session ID: bắt buộc Secure, không có Domain, Path=/.
3. Hết hạn: hai loại timeout khác nhau, tác dụng khác nhau. Idle timeout (hết hạn khi không hoạt động) giảm cửa sổ cho người ngồi vào máy bị bỏ quên — OWASP kịch bản 3 — nhưng không giới hạn kẻ đã cướp được phiên, vì hắn chỉ cần gọi đều để phiên sống mãi. Absolute timeout (hết hạn tuyệt đối kể từ lúc đăng nhập) mới giới hạn được thời gian dùng phiên bị cướp. Số liệu tham khảo:
| Nguồn | Idle | Absolute |
|---|---|---|
| OWASP Session cheat sheet | 2–5 phút (ứng dụng giá trị cao), 15–30 phút (rủi ro thấp) | 4–8 giờ cho ứng dụng dùng cả ngày làm việc |
| NIST 800-63B-4, AAL2 | SHOULD ≤ 1 giờ | SHOULD ≤ 24 giờ |
| NIST 800-63B-4, AAL3 | SHOULD ≤ 15 phút | SHALL ≤ 12 giờ |
Laravel 13 mặc định (config/session.php) | lifetime = 120 phút, tính lại theo mỗi request | Không có sẵn — phải tự thêm |
Moodle 4.5 mặc định (sessiontimeout) | 8 giờ | — (chưa verify có absolute timeout riêng) |
4. Logout: giả định sai là "xoá cookie ở trình duyệt là đã đăng xuất". Cookie bị xoá nhưng bản ghi phiên ở server còn sống thì ai đã chép được session ID vẫn dùng tiếp. Cheat sheet: "the web application must invalidate the session at least on server side". Với SSO, logout ở một ứng dụng phải lan ra các ứng dụng khác (Single Logout) — kịch bản 3 của OWASP.
3.7 Token và assertion — kiểm chữ ký chưa đủ, phải kiểm token này dành cho ai
1. Phần "ghim thuật toán, không chấp nhận alg: none, chống nhầm loại khoá (key confusion), key đủ mạnh" đã có ở
Bài 4, mục 3.6 và 4.6 — đó là A04 vì gốc là dùng sai mật mã. A07 2025 thêm một lớp khác:
"Does not correctly assert the scope and intended audience of the provided credentials".
2. Audience/issuer confusion: cheat sheet JWT của OWASP (theo RFC 8725, gọi là substitution attack): nếu một khoá ký cho
nhiều dịch vụ, mà dịch vụ nhận chỉ kiểm chữ ký và hạn, thì nó "accepts any token that key has signed, whatever iss and aud
it carries". Ví dụ LMS (minh hoạ): cùng một auth service ký token cho "portal-api" và "report-download"; token tải báo cáo — vốn được gửi qua email,
sống vài giờ, nằm trong URL — được trình cho portal-api và portal-api chấp nhận vì chữ ký hợp lệ. Một token phạm vi hẹp vừa thành token đăng nhập đầy đủ.
3. Token type confusion (cross-JWT): token phát cho mục đích này (reset mật khẩu, ID token) bị chấp nhận như access token. Cheat
sheet: đặt header typ riêng cho từng loại (ví dụ at+jwt cho access token theo RFC 9068) và kiểm typ ở nơi nhận.
4. Thu hồi: JWT không trạng thái sống tới exp. Ba lựa chọn, mỗi cái một giá: (a) access token sống ngắn (5–15 phút) +
refresh token có trạng thái — thu hồi refresh là chặn trong ≤ 15 phút; (b) denylist theo jti — thu hồi tức thì nhưng mỗi request tra
store; (c) "token version" lưu ở user — tăng version là vô hiệu mọi token cũ của người đó, mỗi request đọc một giá trị (có thể cache). Cheat sheet
JWT kết luận thẳng: nếu dùng JWT làm phiên người dùng mà cần thu hồi, hãy cân nhắc dùng phiên server thông thường.
flowchart TD
T["Token đến"] --> S{"Chữ ký hợp lệ với<br/>thuật toán đã ghim?"}
S -- "Không" --> R1["401"]
S -- "Có" --> E{"exp / nbf còn hạn?"}
E -- "Không" --> R1
E -- "Có" --> I{"iss đúng auth service?<br/>aud đúng chính dịch vụ này?"}
I -- "Không" --> R1
I -- "Có" --> TY{"typ đúng loại access token?<br/>scope đủ cho endpoint?"}
TY -- "Không" --> R2["401 / 403"]
TY -- "Có" --> RV{"Chưa bị thu hồi?<br/>jti / token version"}
RV -- "Đã thu hồi" --> R1
RV -- "Còn hiệu lực" --> OK["Chấp nhận"]
Cách nói khi phỏng vấn: "Kiểm JWT với em có năm câu hỏi theo thứ tự: chữ ký đúng với thuật toán đã ghim không, còn hạn không, có phải do đúng auth service phát và phát cho chính dịch vụ của em không, có đúng loại token và đủ scope không, và đã bị thu hồi chưa. Rất nhiều hệ thống dừng ở câu hai. Khi một khoá ký cho nhiều dịch vụ mà không kiểm audience, token tải báo cáo phạm vi hẹp có thể thành token đăng nhập đầy đủ ở dịch vụ khác."
3.8 Tin vào thuộc tính mạng, kênh và credential cứng
1. CWE-291 (tin IP), 293 (tin Referer), 350 (tin reverse DNS), 346/940 (không kiểm nguồn kênh): giả định "request đến từ IP văn phòng / IP của server
nội bộ là đáng tin". Hệ quả: ai đứng được trong dải mạng đó (VPN bị lộ, SSRF từ một service nội bộ — xem Bài 1,
proxy cấu hình sai tin X-Forwarded-For từ client) là qua. IP allowlist dùng được làm lớp bổ sung (giảm bề mặt), không bao giờ là
bằng chứng danh tính.
2. CWE-295/297/298/299 (kiểm chứng chỉ): service A gọi service B qua HTTPS nhưng tắt kiểm chứng chỉ "cho nhanh" — kênh mã hoá nhưng không biết đầu
kia là ai, nên credential gửi đi (API key, client secret) có thể lọt vào tay bên chen giữa. Trong PHP là tắt verify của HTTP client; trong
Node là rejectUnauthorized: false hoặc biến môi trường tắt TLS toàn cục.
3. Credential cứng (CWE-798) giữa các service: API key giữa portal và Moodle web service nằm trong code, dùng chung mọi môi trường, không xoay vòng. Khi lộ (repo, image Docker, log) thì không thu hồi được nếu không deploy lại. Cách sửa: secret store + xoay vòng + mỗi client một credential riêng có scope hẹp (liên hệ Bài 2).
3.9 Hình động: một lần đăng nhập đi qua năm lớp phòng thủ
Hình dưới là luồng phòng thủ, không phải luồng tấn công: một request đăng nhập đi qua năm lớp theo đúng thứ tự mục 4 cài đặt. Điểm cần nhìn là lớp thứ tư — session ID cũ bị huỷ và thay bằng ID mới, nên bất kỳ ai biết ID cũ trước khi đăng nhập đều không dùng được nữa.
prefers-reduced-motion: khi tắt
hoạt ảnh, hiện trạng thái cuối.Cách nói khi phỏng vấn: "Một lần đăng nhập ở hệ thống của em đi qua năm lớp. Đầu tiên đếm số lần sai theo tài khoản cộng IP. Rồi kiểm mật khẩu trong một khoảng thời gian cố định và trả cùng một thông báo, để không ai dò được email nào tồn tại. Rồi tới yếu tố thứ hai. Qua đó em mới đổi session ID, để phiên trước đăng nhập không còn liên quan gì tới phiên sau. Cuối cùng là cookie có cờ an toàn và hai loại hạn: hết hạn khi không hoạt động và hết hạn tuyệt đối."
4. Mẫu code sai và mẫu code đúng (Laravel + NestJS)
Code dưới đây là mẫu rút gọn để đọc trong phỏng vấn, không phải module hoàn chỉnh. Phần API của Laravel 13 và NestJS đã tra qua Context7 (docs chính
thức) và đọc source laravel/framework nhánh 13.x; chỗ nào là quyết định thiết kế riêng (số lần, thời gian) là ví dụ minh hoạ cho LMS.
4.1 Đăng nhập: đếm lần sai đúng chỗ, thông báo chung, đổi session ID
// ===== SAI (Laravel) =====
public function login(Request $request)
{
$user = User::where('email', $request->email)->first();
if (! $user) {
return back()->withErrors(['email' => 'Email chưa đăng ký']); // lộ email nào tồn tại + trả rất nhanh
}
if (! Hash::check($request->password, $user->password)) {
return back()->withErrors(['password' => 'Sai mật khẩu']); // không đếm lần sai
}
session(['user_id' => $user->id]); // tự quản phiên, không đổi session ID
return redirect('/dashboard');
}
// ===== SAI (NestJS) =====
@Post('login')
async login(@Body() body: any) {
const user = await this.users.findByEmail(body.email);
if (!user) throw new NotFoundException('User not found'); // 404 vs 401: lộ email tồn tại
if (!(await bcrypt.compare(body.password, user.passwordHash)))
throw new UnauthorizedException('Wrong password');
return { token: this.jwt.sign({ sub: user.id }) }; // không giới hạn số lần thử
}
// ===== ĐÚNG (Laravel 13) =====
public function login(LoginRequest $request): RedirectResponse
{
$email = Str::lower($request->string('email'));
$pairKey = 'login:pair:'.$email.'|'.$request->ip(); // tài khoản + IP: chặn dò từ một nguồn
$acctKey = 'login:acct:'.$email; // tài khoản, mọi IP: chặn dò phân tán
if (RateLimiter::tooManyAttempts($pairKey, 5) || RateLimiter::tooManyAttempts($acctKey, 20)) {
event(new Lockout($request)); // cho log / cảnh báo (mục 6)
$seconds = max(RateLimiter::availableIn($pairKey), RateLimiter::availableIn($acctKey));
throw ValidationException::withMessages(['email' => __('auth.throttle', ['seconds' => $seconds])]);
}
if (! Auth::attempt(['email' => $email, 'password' => $request->input('password')])) {
RateLimiter::hit($pairKey, 15 * 60); // chỉ đếm lần SAI
RateLimiter::hit($acctKey, 60 * 60);
throw ValidationException::withMessages(['email' => __('auth.failed')]); // một câu chung cho mọi lỗi
}
RateLimiter::clear($pairKey); // đúng thì bỏ đếm (NIST: SHOULD)
RateLimiter::clear($acctKey);
$request->session()->regenerate(); // docs Laravel yêu cầu tường minh
return redirect()->intended('/dashboard');
}
Cách nói khi phỏng vấn: "Bản sai có ba lỗi: nói thẳng email chưa đăng ký, trả lời nhanh hơn hẳn khi email không tồn tại, và không đếm lần sai. Bản đúng của em có hai bộ đếm chỉ tăng khi đăng nhập sai: một theo cặp email cộng IP, ngưỡng thấp, để chặn dò từ một nguồn; một theo riêng email, ngưỡng cao hơn, để chặn dò từ nhiều IP. Mọi lỗi trả cùng một câu. Đăng nhập đúng thì xoá bộ đếm và đổi session ID."
Năm chi tiết cần nói rõ khi bị hỏi sâu:
- Timing đã có framework lo, phần chữ thì không.
Auth::attempt()của 13.x chạy trongTimebox200 ms (mục 3.3), nên nhánh "không có user" và "sai mật khẩu" trả về cùng khoảng thời gian. Bản sai ở trên tự viết truy vấn +Hash::checknên mất luôn lớp này. - Laravel 13 đã tự đổi session ID khi đăng nhập. Đọc source
SessionGuard::login()→updateSession()gọi$this->session->regenerate(true)(đổi ID và huỷ bản ghi phiên cũ). Docs vẫn ghi$request->session()->regenerate()sauattempt(); gọi thêm không hại và làm ý định rõ khi đọc code. Lỗi thật xảy ra khi code tự đặtsession(['user_id' => …])như bản sai — đi vòng qua guard nên không có regenerate. - Kiểm rồi mới tăng là check-then-act. Hai request song song có thể cùng thấy "4 lần" rồi cùng thử. Docs Laravel 13 có cách nguyên
tử: dùng giá trị trả về của
RateLimiter::increment()(nguyên tử trên Redis/Memcached/database cache) để so ngưỡng. Với đăng nhập, lệch 1–2 lần thử thường chấp nhận được; với OTP 6 số thì nên dùng bản nguyên tử. - Bộ đếm theo tài khoản là con dao hai lưỡi. Ai biết email sinh viên đều có thể đẩy bộ đếm
acctlên 20 để sinh viên đó không đăng nhập được trong một giờ — DoS có chủ đích. Vì vậy ngưỡng tài khoản đặt cao hơn ngưỡng cặp, thời gian chặn ngắn và tăng dần, và luồng quên mật khẩu vẫn mở (cheat sheet Authentication gợi ý cho phép đăng nhập qua forgot password khi đang bị khoá). Mục 5.2 bàn kỹ. - Cache store phải dùng chung giữa các node. Bộ đếm nằm trong cache; LMS chạy nhiều app node mà mỗi node dùng cache file riêng thì mỗi node một bộ đếm — ngưỡng 5 thành 5 × số node. Bẫy này và bẫy "nhiều limit chung bộ đếm" đã có lab thật ở bài Phạm vi rate limit.
// ===== ĐÚNG (NestJS 11) — auth.service.ts =====
const DUMMY_HASH = bcrypt.hashSync(randomBytes(16).toString('hex'), 12); // hash giả cùng cost, tạo 1 lần lúc khởi động
async login(emailRaw: string, password: string, ip: string) {
const email = emailRaw.trim().toLowerCase();
const pairKey = `login:pair:${email}|${ip}`;
const acctKey = `login:acct:${email}`;
const [pair, acct] = await this.redis.mget(pairKey, acctKey); // Redis dùng chung mọi instance
if (Number(pair) >= 5 || Number(acct) >= 20) {
this.audit.lockout(email, ip);
throw new HttpException('Too many attempts', HttpStatus.TOO_MANY_REQUESTS);
}
const user = await this.users.findByEmail(email);
const ok = await bcrypt.compare(password, user?.passwordHash ?? DUMMY_HASH); // luôn tốn 1 lần so khớp
if (!user || !ok) {
await this.redis.multi()
.incr(pairKey).expire(pairKey, 15 * 60)
.incr(acctKey).expire(acctKey, 60 * 60)
.exec();
this.audit.loginFailed(email, ip);
throw new UnauthorizedException('Invalid email or password'); // một câu, một status
}
await this.redis.del(pairKey, acctKey);
return this.sessions.start(user); // mục 4.4 / 4.5
}
Cách nói khi phỏng vấn: "Bên NestJS em không dùng throttler có sẵn cho chuyện này, vì nó đếm mọi request chứ không phân biệt đúng sai. Em đếm lần sai trong Redis dùng chung giữa các instance, theo cặp email cộng IP và theo riêng email. Khi email không tồn tại em vẫn so khớp với một hash giả cùng độ khó, để thời gian phản hồi không lộ email nào có thật. Mọi lỗi trả cùng một câu và cùng một mã 401."
Ba điểm cần biết về @nestjs/throttler (docs NestJS qua Context7): (1) nó đếm mọi request trong cửa sổ ttl theo
tracker (mặc định IP), không chỉ request sai — hợp làm lớp chặn thô cho route /login (ví dụ @Throttle({ default: { limit: 10, ttl: 60000 } })),
không thay được bộ đếm lần sai theo tài khoản ở trên; (2) storage mặc định là bộ nhớ trong process — chạy nhiều instance phải gắn storage dùng chung
(Redis) qua option storage; (3) sau reverse proxy phải override getTracker() để lấy IP thật từ req.ips, nếu không
mọi request đều mang IP của proxy và chung một bộ đếm. Laravel có bẫy tương tự với TrustProxies.
flowchart TD
R["POST /login"] --> L{"Bộ đếm sai<br/>cặp ≥ 5 hoặc tài khoản ≥ 20?"}
L -- "Có" --> X["429 + sự kiện Lockout<br/>không kiểm mật khẩu"]
L -- "Không" --> V["So khớp mật khẩu<br/>user thật hoặc hash giả, thời gian như nhau"]
V -- "Sai" --> H["Tăng 2 bộ đếm<br/>401 câu chung"]
V -- "Đúng" --> C["Xoá bộ đếm"] --> S["Đổi session ID / phát token"]
4.2 Chính sách mật khẩu: độ dài + danh sách đã lộ, bỏ luật thành phần
// ===== SAI (Laravel) — luật thành phần, độ dài ngắn, không kiểm đã lộ =====
'password' => ['required', 'min:8', 'regex:/[A-Z]/', 'regex:/[0-9]/', 'regex:/[^A-Za-z0-9]/'],
// kèm cron job bắt đổi mật khẩu mỗi 90 ngày
// ===== ĐÚNG (Laravel 13) — AppServiceProvider::boot() =====
Password::defaults(function () {
return Password::min(15) // NIST: 15 khi mật khẩu là yếu tố duy nhất (8 nếu bắt buộc MFA)
->max(64) // cho passphrase; xem Bài 4 về giới hạn 72 byte của bcrypt
->uncompromised(); // kiểm danh sách đã lộ qua Have I Been Pwned (k-anonymity)
});
// FormRequest đăng ký / đổi / reset mật khẩu đều dùng chung:
'password' => ['required', 'confirmed', Password::defaults(), new NotContextWord(['lms', $schoolName, $email])],
// ===== ĐÚNG (NestJS) — DTO + kiểm đã lộ trong service =====
export class SetPasswordDto {
@IsString() @MinLength(15) @MaxLength(64)
password: string;
}
async assertNotBreached(password: string) {
const sha1 = createHash('sha1').update(password.normalize('NFC')).digest('hex').toUpperCase();
const prefix = sha1.slice(0, 5), suffix = sha1.slice(5);
const body = await this.http.get(`https://api.pwnedpasswords.com/range/${prefix}`, { timeout: 2000 });
if (body.split('\n').some(l => l.startsWith(suffix + ':'))) {
throw new BadRequestException('Mật khẩu này đã xuất hiện trong dữ liệu bị lộ, hãy chọn mật khẩu khác');
}
}
Cách nói khi phỏng vấn: "Chính sách mật khẩu của em theo NIST bản mới: tối thiểu mười lăm ký tự nếu mật khẩu là yếu tố duy nhất, cho phép tới ít nhất sáu mươi tư ký tự, không bắt trộn chữ hoa số ký tự đặc biệt, không bắt đổi định kỳ, nhưng bắt buộc kiểm với danh sách mật khẩu đã lộ và các từ theo ngữ cảnh như tên trường. Kiểm danh sách lộ dùng mô hình k-anonymity: chỉ gửi năm ký tự đầu của mã băm SHA-1 ra ngoài, so phần còn lại ở máy mình, nên mật khẩu không bao giờ rời hệ thống."
Chi tiết đọc từ source Illuminate\Validation\NotPwnedVerifier (13.x) mà docs không nói:
- Gửi 5 ký tự đầu của SHA-1 viết hoa tới
api.pwnedpasswords.com/range/kèm headerAdd-Padding: true(độn thêm kết quả giả để kích thước response không lộ gì). - Fail-open: nếu gọi API lỗi, exception chỉ được
report(), body coi là rỗng → mật khẩu được coi là chưa lộ và qua validation. Đây là lựa chọn có chủ đích (không để dịch vụ ngoài chặn đăng ký) nhưng nghĩa là lúc HIBP sự cố, control này tắt im lặng — nên có metric đếm số lần gọi lỗi. Chủ đề "xử lý lỗi mở hay đóng" là của Bài 10. - Timeout mặc định 30 giây — HIBP chậm thì form đăng ký treo tới 30 giây. Ở bản NestJS trên em đặt 2 giây; với Laravel có thể bind lại verifier với timeout ngắn hơn (chưa verify cách bind trong bài này).
- Ví dụ
Password::defaultstrong docs Laravel cómixedCase()— đó là ví dụ minh hoạ API, không phải khuyến nghị chính sách; NIST cấm luật thành phần (SHALL NOT).
NotContextWord trong ví dụ là rule tự viết (minh hoạ), không có sẵn trong Laravel. Nếu không muốn phụ thuộc dịch vụ ngoài, có thể tự host
bản sao danh sách Pwned Passwords (cheat sheet Credential Stuffing nhắc tới lựa chọn này).
flowchart TD
P["Mật khẩu mới"] --> N["Chuẩn hoá Unicode NFC"]
N --> LEN{"15 ≤ độ dài ≤ 64?"}
LEN -- "Không" --> REJ["Từ chối + nói lý do"]
LEN -- "Có" --> CTX{"Chứa tên dịch vụ, email, tên trường?"}
CTX -- "Có" --> REJ
CTX -- "Không" --> HB["SHA-1 → gửi 5 ký tự đầu<br/>so phần đuôi tại chỗ"]
HB -- "Có trong danh sách lộ" --> REJ
HB -- "Không có / API lỗi" --> OK["Băm Argon2id rồi lưu<br/>API lỗi: ghi metric"]
4.3 Quên mật khẩu: token một lần, có hạn, lưu hash, câu trả lời như nhau
// ===== SAI (Laravel) =====
public function sendReset(Request $request)
{
$user = User::where('email', $request->email)->firstOrFail(); // 404 nếu không có: lộ email
$token = md5($user->email . time()); // đoán được
$user->update(['reset_token' => $token]); // bản rõ, không hạn, dùng nhiều lần
$url = $request->getSchemeAndHttpHost() . '/reset?token=' . $token; // dựng từ Host header của request
Mail::to($user)->send(new ResetMail($url));
return back()->with('status', 'Đã gửi link tới ' . $user->email);
}
// ===== ĐÚNG (Laravel 13) — dùng Password broker có sẵn =====
// config/auth.php: 'passwords' => ['users' => ['provider' => 'users', 'table' => 'password_reset_tokens',
// 'expire' => 30, 'throttle' => 60]],
public function sendReset(Request $request): RedirectResponse
{
$request->validate(['email' => ['required', 'email']]);
Password::sendResetLink($request->only('email')); // KHÔNG rẽ nhánh theo kết quả trả về
return back()->with('status', 'Nếu email này có trong hệ thống, chúng tôi đã gửi hướng dẫn đặt lại mật khẩu.');
}
public function reset(ResetPasswordRequest $request): RedirectResponse
{
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user, string $password) {
$user->forceFill(['password' => Hash::make($password)])
->setRememberToken(Str::random(60)); // vô hiệu remember-me cũ
$user->save();
$user->tokens()->delete(); // Sanctum: thu hồi mọi API token
DB::table('sessions')->where('user_id', $user->id)->delete(); // nếu SESSION_DRIVER=database
event(new PasswordReset($user));
}
);
return $status === Password::PASSWORD_RESET
? redirect()->route('login')->with('status', __($status))
: back()->withErrors(['email' => __('passwords.token')]); // một câu chung cho token sai/hết hạn/email sai
}
Cách nói khi phỏng vấn: "Bản sai lộ email có tồn tại, sinh token đoán được, lưu bản rõ không hạn dùng mãi, và dựng link từ Host header của request — tức là ai gửi request được với Host khác có thể làm email thật chứa link trỏ về chỗ khác. Bản đúng dùng broker có sẵn của Laravel: token sinh ngẫu nhiên, chỉ lưu hash, mỗi người chỉ một token, hết hạn sau ba mươi phút, xoá ngay khi dùng. Câu trả lời luôn giống nhau dù email có hay không. Đặt mật khẩu xong em thu hồi mọi phiên và token cũ, vì phiên cũ có thể chính là của kẻ đã chiếm tài khoản."
Những gì broker Laravel làm sẵn — đọc từ source 13.x (DatabaseTokenRepository, PasswordBroker), chưa chạy lab:
- Token =
hash_hmac('sha256', Str::random(40), $hashKey); DB chỉ lưu$hasher->make($token)(hash) → lộ DB không dùng được token. - Tạo token mới thì xoá token cũ của email đó (mỗi người một token còn hiệu lực).
expiremặc định 3600 giây trong repository (ví dụ cấu hình docs dùng 60 phút; bài này chọn 30 — Moodle mặc định 30 phút, mục 7);throttle60 giây giữa hai lần xin link.- Sau khi callback đặt mật khẩu chạy xong, broker gọi
$this->tokens->delete($user)→ token dùng một lần. - Cả
sendResetLinklẫnresetbọcTimebox. Nhưng hằng số trả về khác nhau (INVALID_USER,RESET_THROTTLED,RESET_LINK_SENT) — chính controller phải nuốt sự khác biệt đó như bản đúng ở trên. - Link trong email được dựng bằng route có tên của ứng dụng; URL gốc phụ thuộc
APP_URLvà cấu hình proxy tin cậy (chưa verify trường hợp app sau proxy không đặtTrustHosts) — đây là chỗ nên kiểm trong review.
// ===== ĐÚNG (NestJS) — tự cài, cùng các tính chất =====
async requestReset(emailRaw: string) {
const user = await this.users.findByEmail(emailRaw.trim().toLowerCase());
if (user && !(await this.resets.recentlyIssued(user.id, 60))) {
const plain = randomBytes(32).toString('base64url'); // 256 bit từ CSPRNG
await this.resets.replaceForUser(user.id, { // xoá token cũ, giữ 1
tokenHash: createHash('sha256').update(plain).digest('hex'), // entropy cao: SHA-256 là đủ
expiresAt: addMinutes(new Date(), 30),
});
const url = new URL(`/reset-password?token=${plain}`, this.config.getOrThrow('APP_URL')); // không dùng Host
await this.mailQueue.add('reset', { to: user.email, url: url.toString() }); // gửi qua queue
}
return { message: 'Nếu email này có trong hệ thống, chúng tôi đã gửi hướng dẫn.' }; // luôn như nhau
}
async reset(plain: string, newPassword: string) {
const tokenHash = createHash('sha256').update(plain).digest('hex');
// tiêu thụ token NGUYÊN TỬ: một câu UPDATE có điều kiện, 2 request song song chỉ 1 thắng (Bài 6, mục 4.4)
const res = await this.resets.createQueryBuilder().update()
.set({ usedAt: () => 'now()' })
.where('token_hash = :tokenHash AND used_at IS NULL AND expires_at > now()', { tokenHash })
.returning(['userId']).execute();
if (res.affected !== 1) throw new BadRequestException('Link không hợp lệ hoặc đã hết hạn');
const userId = res.raw[0].user_id;
await this.users.setPassword(userId, newPassword); // qua cùng chính sách 4.2
await this.sessions.revokeAll(userId); // huỷ phiên + refresh token (4.5)
await this.notify.passwordChanged(userId); // báo cho chủ tài khoản
}
Cách nói khi phỏng vấn: "Bên NestJS em tự cài nên phải tự giữ đủ các tính chất mà broker Laravel cho sẵn: token hai trăm năm mươi sáu bit sinh bằng bộ sinh ngẫu nhiên mật mã, chỉ lưu SHA-256 của nó, mỗi người một token, hết hạn ba mươi phút, dựng link từ cấu hình chứ không từ request, và câu trả lời như nhau. Riêng chỗ tiêu thụ token em dùng một câu cập nhật có điều kiện 'chưa dùng và chưa hết hạn', để hai request song song không cùng dùng được một token. Đặt xong thì thu hồi mọi phiên và gửi thông báo cho chủ tài khoản."
Lấy email chậm (gửi qua queue) cũng giúp đều thời gian phản hồi: request không phải chờ SMTP ở nhánh "có user". Trang /reset-password cần
header Referrer-Policy: no-referrer để token trong URL không đi theo Referer ra tài nguyên bên ngoài.
sequenceDiagram participant U as Người dùng participant A as App participant DB as DB participant M as Mail queue U->>A: Quên mật khẩu (email) A->>DB: Có user? token gần đây? A->>DB: Xoá token cũ, lưu SHA-256 token mới, hạn 30 phút A->>M: Đẩy email (link từ APP_URL) A-->>U: Câu trả lời như nhau dù có hay không U->>A: Mở link, đặt mật khẩu mới A->>DB: UPDATE used_at nếu chưa dùng và còn hạn A->>DB: Lưu hash mật khẩu mới, huỷ mọi phiên và token A-->>U: Về trang đăng nhập
4.4 Phiên trên trình duyệt: cookie, hai loại hạn, logout thật, huỷ phiên khác
// ===== SAI =====
// .env: SESSION_SECURE_COOKIE không đặt, SESSION_LIFETIME=43200 (30 ngày), "để sinh viên đỡ phải đăng nhập lại"
public function logout() { return redirect('/')->withCookie(Cookie::forget('laravel_session')); } // chỉ xoá cookie
// ===== ĐÚNG (Laravel 13) =====
// .env (production)
// SESSION_DRIVER=redis SESSION_LIFETIME=30 SESSION_SECURE_COOKIE=true
// SESSION_HTTP_ONLY=true SESSION_SAME_SITE=lax SESSION_EXPIRE_ON_CLOSE=false
// Absolute timeout — Laravel không có sẵn: middleware tự viết, đặt trong nhóm 'web' sau StartSession
class EnforceAbsoluteSessionTimeout
{
public function handle(Request $request, Closure $next)
{
if (Auth::check()) {
$loginAt = $request->session()->get('auth.login_at'); // ghi lúc đăng nhập (Login event listener)
if (! $loginAt || now()->timestamp - $loginAt > 8 * 3600) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
}
return $next($request);
}
}
public function logout(Request $request): RedirectResponse
{
Auth::logout(); // xoá user khỏi phiên, đổi remember token
$request->session()->invalidate(); // xoá dữ liệu phiên + đổi ID (huỷ bản ghi ở server)
$request->session()->regenerateToken(); // CSRF token mới
return redirect('/');
}
// Đổi mật khẩu khi đang đăng nhập: bắt nhập mật khẩu hiện tại, rồi đăng xuất các thiết bị khác
Route::middleware(['auth', 'auth.session'])->group(function () {
Route::put('/account/password', UpdatePasswordController::class)->middleware('password.confirm');
});
// trong controller, sau khi lưu mật khẩu mới:
Auth::logoutOtherDevices($request->input('current_password'));
Cách nói khi phỏng vấn: "Session của em có cookie HttpOnly, Secure, SameSite, lưu ở Redis để mọi node cùng thấy. Có hai loại hạn: ba mươi phút không hoạt động, và tám tiếng tuyệt đối kể từ lúc đăng nhập — cái sau Laravel không có sẵn nên em viết một middleware nhỏ. Logout thì huỷ phiên ở server chứ không chỉ xoá cookie. Đổi mật khẩu thì phải nhập mật khẩu hiện tại, xong thì đăng xuất mọi thiết bị khác."
Những điểm phải biết khi giải thích đoạn trên:
SESSION_LIFETIMEcủa Laravel là idle timeout: đọcconfig/session.phpcủa skeleton 13.x —lifetimemặc định 120 phút,http_onlymặc địnhtrue,same_sitemặc địnhlax, cònsecurelấy từSESSION_SECURE_COOKIEvà không có giá trị mặc định — production quên đặt là cookie phiên đi được qua HTTP thường.Auth::logoutOtherDevices()cần middlewareauth.session(AuthenticateSession) trên các route: guard lưu một hash của mật khẩu hiện tại vào phiên lúc đăng nhập (password_hash_<guard>trong sourceSessionGuard::login()); middleware so khớp mỗi request, mật khẩu đổi thì phiên ở thiết bị khác không còn khớp và bị đăng xuất. Thiếu middleware thì hàm chạy nhưng không có hiệu lực ở thiết bị khác.password.confirmlà cơ chế "xác thực lại" của Laravel cho hành động nhạy cảm; route xác nhận trong docs có kèmthrottle:6,1— chính form xác nhận cũng phải được giới hạn số lần thử. Fortify 13.x còn cho xác nhận bằng passkey thay mật khẩu.- Với SameSite: cheat sheet coi
Strictlà ưu tiên,Laxchấp nhận được, và SameSite chỉ là lớp phụ cho CSRF chứ không thay CSRF token (CSRF ở Bài 1, mục 4.6).Strictlàm người dùng bấm link từ email vào LMS bị coi là chưa đăng nhập ở request đầu — đây là trade-off thực tế khiến nhiều hệ thống chọnLax.
// ===== ĐÚNG (NestJS 11 + express-session + Passport, phiên server) — main.ts =====
app.use(session({
store: new RedisStore({ client: redis, prefix: 'sess:' }), // dùng chung mọi instance
name: '__Host-sid', // tiền tố __Host-: Secure, không Domain, Path=/
secret: config.getOrThrow('SESSION_SECRET'),
resave: false, saveUninitialized: false,
rolling: true, // gia hạn theo hoạt động = idle timeout
cookie: { httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 30 * 60 * 1000 },
}));
app.set('trust proxy', 1); // sau Nginx: để cờ secure hoạt động
// Đăng nhập: passport >= 0.6 — req.login() TỰ regenerate session (CHANGELOG 0.6.0)
// Absolute timeout: guard so (now - req.session.loginAt) với 8 giờ, quá thì req.session.destroy()
// Logout: req.logout(cb) (cũng regenerate) rồi req.session.destroy() để xoá bản ghi trong Redis
Cách nói khi phỏng vấn: "Nếu NestJS dùng phiên server thì cấu hình gần giống Laravel: lưu phiên ở Redis, cookie tên có tiền tố host để trình duyệt bắt buộc cờ Secure và không chia cho subdomain, gia hạn theo hoạt động làm idle timeout, và một guard nhỏ kiểm hạn tuyệt đối. Từ Passport bản 0.6, hàm đăng nhập và đăng xuất tự tạo session ID mới, nên session fixation được xử lý ở thư viện — nhưng em vẫn huỷ hẳn bản ghi trong Redis khi đăng xuất."
4.5 Token cho API và SPA: ngắn hạn, kiểm audience, thu hồi được, không nằm trong localStorage
// ===== SAI (NestJS) =====
JwtModule.register({ secret: 'lms', signOptions: { expiresIn: '30d' } }); // sống 30 ngày, không thu hồi được
const payload = this.jwt.decode(token); // decode KHÔNG kiểm chữ ký
// frontend: localStorage.setItem('token', token) // một lỗi XSS là lộ token
// ===== ĐÚNG (NestJS 11) =====
JwtModule.register({
privateKey: process.env.JWT_PRIVATE_KEY, publicKey: process.env.JWT_PUBLIC_KEY,
signOptions: { algorithm: 'RS256', expiresIn: '10m', issuer: 'lms-auth', audience: 'portal-api',
header: { alg: 'RS256', typ: 'at+jwt' } },
verifyOptions: { algorithms: ['RS256'], issuer: 'lms-auth', audience: 'portal-api' },
});
@Injectable()
export class AccessTokenGuard implements CanActivate {
async canActivate(ctx: ExecutionContext) {
const req = ctx.switchToHttp().getRequest();
const token = req.cookies?.['__Host-at']; // BFF / cookie HttpOnly, không localStorage
const claims = await this.jwt.verifyAsync(token).catch(() => null); // chữ ký + exp + iss + aud
if (!claims) throw new UnauthorizedException();
const tv = await this.tokenVersion.get(claims.sub); // cache ngắn
if (claims.tv !== tv) throw new UnauthorizedException(); // đã "đăng xuất mọi nơi"
req.user = { id: claims.sub, scope: claims.scope };
return true;
}
}
// Refresh token: chuỗi ngẫu nhiên (KHÔNG phải JWT), lưu SHA-256 trong DB, xoay vòng mỗi lần dùng.
// Một refresh token đã dùng mà xuất hiện lại = dấu hiệu bị đánh cắp → thu hồi cả "họ" token đó.
// ===== ĐÚNG (Laravel 13 + Sanctum) =====
// SPA cùng site (Next.js gọi API Laravel): dùng chế độ SPA của Sanctum = cookie phiên, KHÔNG token
// bootstrap/app.php: ->withMiddleware(fn ($m) => $m->statefulApi())
// Mobile / bên thứ ba: personal access token có hạn, có ability, thu hồi được
$token = $user->createToken('mobile', ['course:read'], now()->plus(days: 7))->plainTextToken;
$user->tokens()->delete(); // đăng xuất mọi thiết bị
Cách nói khi phỏng vấn: "Bản sai có ba lỗi: token sống ba mươi ngày không thu hồi được, đọc payload mà không kiểm chữ ký, và cất token trong localStorage. Bản đúng: access token mười phút, ghim thuật toán, kiểm cả bên phát lẫn bên nhận, có loại token riêng, nằm trong cookie HttpOnly. Để 'đăng xuất mọi nơi' em lưu một số phiên bản token theo user, tăng số đó là mọi access token cũ bị từ chối. Refresh token là chuỗi ngẫu nhiên lưu hash trong DB, xoay vòng mỗi lần dùng; một refresh token cũ quay lại là dấu hiệu bị trộm, em thu hồi cả chuỗi."
Ghi chú về các API đã tra: Sanctum 13.x — tham số thứ ba của createToken() là thời điểm hết hạn riêng của token, tokens()->delete()
thu hồi token, và chế độ SPA "does not use tokens of any kind" mà dùng cookie phiên của guard web (docs Sanctum qua Context7).
@nestjs/jwt nhận signOptions/verifyOptions chuyển xuống thư viện jsonwebtoken, trong đó
algorithms, issuer, audience là option kiểm khi verify (README jsonwebtoken); việc verifyAsync
dùng verifyOptions đăng ký ở module là theo hiểu biết về @nestjs/jwt, chưa verify bằng lab. Mẫu xoay vòng refresh token + phát hiện
dùng lại là khuyến nghị của OAuth 2.0 Security BCP (RFC 9700) mà cheat sheet MFA dẫn — chi tiết cài đặt ở đây là minh hoạ.
flowchart TD
A["Access token 10 phút<br/>cookie HttpOnly"] -- "hết hạn" --> R["Gửi refresh token"]
R --> Q{"Hash có trong DB,<br/>chưa dùng, còn hạn?"}
Q -- "Có" --> N["Đánh dấu đã dùng<br/>phát cặp mới cùng họ"]
Q -- "Đã dùng rồi" --> F["Dấu hiệu bị trộm:<br/>thu hồi cả họ + cảnh báo"]
Q -- "Không có / hết hạn" --> L["Bắt đăng nhập lại"]
N --> A
4.6 MFA và khôi phục: dùng cái có sẵn, khoá cửa phụ
// ===== SAI =====
// - Bật 2FA ngay khi user quét QR, không bắt nhập mã xác nhận → quét sai là tự khoá mình
// - Mất điện thoại: "gửi mã 6 số qua email" rồi tắt 2FA luôn → email thành yếu tố duy nhất
// - Endpoint /api/v1/login (app mobile cũ) không đi qua bước 2FA
// ===== ĐÚNG (Laravel 13 + Fortify) — config/fortify.php =====
'features' => [
Features::twoFactorAuthentication(['confirm' => true, 'confirmPassword' => true]),
Features::passkeys(['confirmPassword' => true]),
],
// - 'confirm' => true: phải nhập đúng một mã TOTP mới bật (POST /user/confirmed-two-factor-authentication)
// - recovery codes: hiện một lần lúc bật; tạo lại qua POST /user/two-factor-recovery-codes
// - POST /two-factor-challenge nhận 'code' (TOTP) HOẶC 'recovery_code' — đặt limiter riêng cho route này
// - passkey: Fortify bọc laravel/passkeys, có limiter 'passkeys' riêng cho route passkey
// ===== ĐÚNG (NestJS) — không có gói "chuẩn" như Fortify: tự ráp, giữ các bất biến =====
// 1. Sau mật khẩu đúng: phiên = { stage: 'mfa_pending', userId, startedAt } — guard chặn mọi route trừ /mfa/*
// 2. /mfa/verify: đếm lần sai theo userId (≤ 5), quá 5 phút từ startedAt → về anonymous
// 3. Đúng: stage = 'authenticated', regenerate session / phát token
// 4. Recovery code: 10 mã ngẫu nhiên, lưu hash, mỗi mã dùng một lần (UPDATE có điều kiện như 4.3)
// 5. Thêm/xoá yếu tố MFA, đổi email/số điện thoại: bắt xác thực lại + gửi thông báo tới kênh cũ
// 6. Mọi đường đăng nhập (web, mobile, API cũ, SSO) đi qua cùng một service → không có cửa không MFA
Cách nói khi phỏng vấn: "Với Laravel em dùng Fortify: bắt người dùng nhập đúng một mã trước khi bật hai lớp, có mã khôi phục hiện một lần, và từ bản 13 có passkey. Với NestJS không có gói tương đương nên em tự ráp nhưng giữ sáu bất biến: phiên chờ MFA chỉ gọi được endpoint MFA, giới hạn số lần sai và thời gian, đổi session khi xong, mã khôi phục lưu hash và dùng một lần, thay đổi yếu tố MFA thì bắt xác thực lại và báo qua kênh cũ, và mọi đường đăng nhập đi qua cùng một service để không còn cửa nào bỏ qua MFA."
Tên option của Fortify ở trên (confirm, confirmPassword, Features::passkeys) lấy theo docs Fortify 13.x qua
Context7; chữ ký chính xác của Features::passkeys() chưa verify bằng cài đặt thật.
flowchart TD
LOST["Mất thiết bị MFA"] --> RC{"Còn mã khôi phục?"}
RC -- "Có" --> USE["Dùng 1 mã, mã bị huỷ<br/>bắt đăng ký lại MFA"]
RC -- "Không" --> SUP["Quy trình hỗ trợ:<br/>xác minh danh tính nghiêm ngặt, có độ trễ"]
SUP --> NOTI["Báo qua mọi kênh cũ<br/>email, thiết bị đã đăng ký"]
USE --> NOTI
NOTI --> AUD["Ghi audit log + metric<br/>số lần khôi phục / ngày"]
5. Phòng thủ nhiều lớp — quyết định và đánh đổi
5.1 Bảng tổng hợp
| Lớp | Chặn được | KHÔNG chặn được | Giá phải trả |
|---|---|---|---|
| Đếm lần sai theo tài khoản (+IP) | Brute force một tài khoản | Password spray; stuffing phân tán | Có thể bị dùng để khoá người khác |
| Rate limit theo nguồn, bot detection, CAPTCHA | Làm chậm stuffing từ ít nguồn | Botnet lớn, CAPTCHA-solving thuê người | Ma sát cho người dùng thật; phòng máy sau NAT |
| Metric tỉ lệ sai toàn hệ thống + cảnh báo | Phát hiện spray, stuffing | Không tự chặn gì | Cần người trực và runbook |
| Blocklist mật khẩu lộ + độ dài | Mật khẩu dễ đoán, đã lộ ở nơi khác trước khi đặt | Mật khẩu lộ sau khi đặt (cần kiểm lại khi đăng nhập) | Phụ thuộc dịch vụ ngoài hoặc tự host bộ dữ liệu |
| MFA (TOTP/push) | Mật khẩu lộ, stuffing, spray | Phishing chuyển tiếp thời gian thực; fatigue (push không so số) | Hỗ trợ khôi phục, ma sát đăng nhập |
| Passkey / WebAuthn | Thêm cả phishing chuyển tiếp | Thiết bị đã bị chiếm; khôi phục yếu | Hỗ trợ trình duyệt/thiết bị, UX khôi phục |
| Session hygiene (regenerate, cờ cookie, timeout, logout server) | Fixation, máy dùng chung, giới hạn thời gian phiên bị cướp | Cướp phiên còn hạn (XSS, malware) | Người dùng phải đăng nhập lại |
| Token ngắn hạn + kiểm iss/aud/typ + thu hồi | Token dùng sai chỗ, token cũ | Token còn hạn bị trộm trong vài phút | Thêm trạng thái (refresh, version) |
| Thông báo cho người dùng (đăng nhập lạ, đổi MFA/email) | Rút ngắn thời gian phát hiện | Không chặn gì; email cũng có thể đã bị chiếm | Dễ thành "thông báo rác" nếu bắn quá nhiều |
5.2 Throttling hay lockout — câu hỏi interviewer thích nhất
1. Lockout cứng (khoá tài khoản tới khi admin mở / người dùng reset) chặn brute force tuyệt đối nhưng biến mỗi email công khai thành nút "tắt tài khoản": trong LMS, danh sách email sinh viên thường đoán được theo mã số sinh viên, nên một script nhỏ có thể khoá cả khoá học trước giờ thi. Cheat sheet Forgot Password nói thẳng: "Accounts should not be locked out in response to a forgotten password attack".
2. Throttling / lockout tạm thời tăng dần: NIST 800-63B-4 (mục 3.2.2) đặt trần 100 lần sai liên tiếp trên một authenticator của một tài khoản, và gợi ý các kỹ thuật giảm nguy cơ khoá nhầm người thật: bắt qua bot challenge, chờ tăng dần khi gần tới trần ("e.g., 30 seconds up to an hour"), dùng tín hiệu rủi ro (IP, vị trí, thời gian, trình duyệt). Cheat sheet Authentication mô tả biến thể "exponential lockout": bắt đầu rất ngắn (1 giây) và nhân đôi mỗi lần sai.
3. Ví dụ con số cho LMS (minh hoạ): cặp tài khoản + IP 5 lần sai / 15 phút; riêng tài khoản 20 lần sai / giờ, sau đó chờ tăng dần 1 → 2 → 4 … tối đa 60 phút; không bao giờ khoá vĩnh viễn; luồng quên mật khẩu luôn mở. Tính nhanh: một tài khoản bị thử tối đa khoảng 20 lần/giờ ≈ 480 lần/ngày. Với chính sách 15 ký tự + blocklist, 480 lần/ngày không đủ đoán trúng mật khẩu không nằm trong danh sách phổ biến; còn với OTP 6 số (106 khả năng), 480 lần/ngày cho xác suất trúng ~0,05%/ngày — vì thế OTP cần trần chặt hơn (5 lần/phiên chờ MFA, hết thì quay lại bước mật khẩu). Đây cũng là lý do NIST chọn con số 100: cân giữa xác suất đoán trúng OTP 6 số và chi phí khôi phục khi người thật bị chặn.
Cách nói khi phỏng vấn: "Em không dùng lockout cứng, vì ở LMS email sinh viên đoán được, khoá cứng là cho người ngoài nút tắt tài khoản của cả lớp trước giờ thi. Em dùng chờ tăng dần: theo cặp tài khoản và IP thì ngưỡng thấp, theo riêng tài khoản thì ngưỡng cao hơn và thời gian chờ tăng gấp đôi tới tối đa một giờ, không bao giờ khoá vĩnh viễn, quên mật khẩu luôn mở. Tính ra một tài khoản bị thử tối đa vài trăm lần một ngày — không đủ với mật khẩu dài đã qua blocklist. Mã OTP sáu số thì em giới hạn chặt hơn nhiều vì không gian đoán nhỏ."
5.3 Phiên server hay JWT — trả lời theo ngữ cảnh
| Trục | Phiên server (cookie + store) | JWT tự chứa |
|---|---|---|
| Thu hồi tức thì | Xoá bản ghi là xong | Không, trừ khi thêm denylist / version / refresh có trạng thái |
| Chi phí mỗi request | 1 lần đọc store (Redis ~sub-ms) | Verify chữ ký, không I/O (nếu không kiểm thu hồi) |
| Nhiều service / nhiều domain | Cần store dùng chung hoặc gateway | Thuận tiện: service nào có public key là kiểm được |
| Lộ qua XSS | Cookie HttpOnly — JS không đọc được | Nếu để ở localStorage thì lộ toàn bộ |
| CSRF | Phải chống (SameSite + token) | Không nếu gửi qua header; có nếu để trong cookie |
| Phù hợp | Web app một hệ thống, SPA cùng site (Sanctum SPA), BFF | Service-to-service, mobile + API nhiều bên, liên kết SSO (ID token) |
Kết luận thực dụng: trình duyệt → phiên server (hoặc BFF giữ token hộ); service ↔ service, mobile → API nhiều bên → JWT ngắn hạn có audience. Câu "JWT để stateless" chỉ đúng khi chấp nhận không thu hồi được trong vòng đời token — cheat sheet JWT của OWASP cảnh báo đúng điểm này.
5.4 Chọn yếu tố MFA theo nhóm người dùng
- Admin, giảng viên có quyền sửa điểm, tài khoản hỗ trợ: bắt buộc MFA, ưu tiên passkey/khoá bảo mật; không cho SMS/email làm yếu tố chính. Đây là nhóm OWASP nhắc qua "default credentials, particularly for admin users", và các sự cố ở mục 8 đều bắt đầu từ tài khoản đặc quyền hoặc tài khoản không có MFA.
- Sinh viên (đông, không đồng đều thiết bị): MFA tuỳ chọn hoặc theo rủi ro (thiết bị mới, đổi email, xem/sửa thông tin cá nhân), passkey là lựa chọn ít ma sát nhất nếu thiết bị hỗ trợ; luôn có mã khôi phục.
- Tài khoản dịch vụ, tích hợp: không dùng mật khẩu người; dùng credential riêng có scope, xoay vòng, hoặc mTLS — bài học Okta (mục 8).
5.5 Tự xây, dùng framework, hay mua IdP
| Lựa chọn | Khi nào hợp | Rủi ro / exit strategy |
|---|---|---|
| Framework kit (Laravel Fortify/Sanctum; Nest Passport + tự ráp) | Một hoặc vài app, team quen stack, chưa cần SSO nhiều bên | Nest phải tự ráp MFA/khôi phục — dễ thiếu bất biến; dữ liệu nằm ở DB mình, rút ra dễ |
| IdP tự host (ví dụ Keycloak) | Nhiều app cần SSO, cần SAML/OIDC, dữ liệu phải ở hạ tầng mình | Thêm một hệ thống phải vá, trực, nâng cấp; người vận hành phải hiểu OIDC |
| IdP SaaS (Auth0, Cognito, Entra ID…) | Cần MFA/passkey/phát hiện bất thường ngay, team nhỏ | Chi phí theo MAU, vendor lock-in; exit: giữ user ID nội bộ độc lập với ID của IdP, chỉ nói OIDC chuẩn, kiểm tra trước khả năng xuất hash mật khẩu |
OWASP 2025 nghiêng về "mua" ("Transfer this risk whenever possible"). Góc nhìn tech lead: chuyển rủi ro cài đặt sang bên khác chứ không chuyển rủi ro cấu hình — Snowflake (mục 8) là nền tảng tốt, nhưng khách hàng không bật MFA vẫn bị lấy dữ liệu.
5.6 SSO / OIDC: những gì phải kiểm ở phía ứng dụng nhận
- Dùng Authorization Code + PKCE, kiểm
state(chống CSRF luồng đăng nhập) vànonce(chống phát lại ID token). - Kiểm ID token: chữ ký với khoá của IdP (JWKS),
issđúng IdP,audchứa client ID của chính ứng dụng, còn hạn — đúng dòng "assert the scope and intended audience" của OWASP 2025. - Liên kết tài khoản theo
sub(định danh ổn định của IdP), không theo email — email có thể đổi hoặc trùng giữa các IdP. - Logout: có Single Logout (front-channel / back-channel) hoặc chấp nhận và nói rõ rằng đăng xuất ở một app không đăng xuất app khác — OWASP kịch bản 3.
- Phiên của app sau SSO vẫn phải tuân mục 4.4 (regenerate, timeout) — SSO không thay thế session hygiene.
Các bước 1–4 là theo đặc tả OIDC/OAuth và cheat sheet; bài này không dựng lab OIDC — chưa verify cách cấu hình cụ thể của từng thư viện.
6. Phát hiện & vận hành
6.1 Code review: câu hỏi cần hỏi
- Liệt kê mọi đường dẫn tới phiên/token đã xác thực (mục 2, ý 3). Đường nào không đi qua cùng service đăng nhập, throttle, MFA?
- Có chỗ nào tự đặt user vào session (
session(['user_id' => …]),req.session.userId = …) thay vì qua guard/passport? - Có chỗ nào
jwt.decode(không verify) rồi tin payload?verifycó ghimalgorithms, kiểmissuer,audiencekhông? - Thông báo lỗi ở đăng nhập, đăng ký, quên mật khẩu, đổi email có khác nhau theo trạng thái tài khoản không? Status code có khác nhau không (404 vs 401)?
- Token reset/mời/xác nhận email: sinh bằng gì, lưu gì, hạn bao lâu, dùng mấy lần, tiêu thụ có nguyên tử không?
- Logout và đổi mật khẩu có huỷ phiên ở server, refresh token, remember-me, API token không?
- Có credential cứng, mật khẩu seed cố định,
verify => false/rejectUnauthorized: falsekhông? - Có logic xác thực dựa trên IP, Referer, header tự đặt (
X-User-Id) mà không có gateway ký/kiểm không?
6.2 Công cụ tự động phủ được gì
- Secret scanning (trong CI, pre-commit): bắt credential cứng CWE-798 — rẻ, nên chặn merge. Đã bàn ở Bài 3.
- SAST (Larastan/PHPStan rule tuỳ chỉnh, Semgrep): bắt mẫu cú pháp —
jwt.decodekhông verify, tắt kiểm chứng chỉ,Math.randomsinh token, so sánh chuỗi không constant-time. Không thấy được lỗi luồng (thiếu throttle, thiếu huỷ phiên). - DAST / quét cấu hình: kiểm cờ cookie, session ID trong URL, header
Referrer-Policytrên trang reset. Chạy trên staging. - Test tích hợp cho bất biến: "6 lần sai thì lần 6 bị 429", "logout rồi dùng session ID cũ thì 401", "token reset dùng lần 2 thì lỗi", "thông báo quên mật khẩu giống hệt nhau cho email có và không có". Đây là lớp duy nhất bắt được lỗi luồng một cách lặp lại.
6.3 Tín hiệu trong production
| Metric / sự kiện | Bất thường trông như thế nào (minh hoạ LMS) | Hành động |
|---|---|---|
| Tỉ lệ đăng nhập sai toàn hệ thống | Nền ~5% của 2.000 lần/giờ; tăng lên 50% hoặc 10.000 lần sai/giờ | Cảnh báo; bật challenge toàn cục; kiểm nguồn |
| Số tài khoản khác nhau bị sai từ một IP/ASN | 1 IP sai trên 300 tài khoản khác nhau trong 10 phút | Chặn nguồn; dấu hiệu spray/stuffing |
| Đăng nhập thành công sau nhiều lần sai | Sai 15 lần rồi đúng, từ IP mới | Bắt MFA / thông báo cho chủ tài khoản |
Sự kiện Lockout | Từ vài chục/ngày lên hàng nghìn | Có thể là tấn công khoá tài khoản hàng loạt |
| Yêu cầu reset mật khẩu, đổi email, đổi MFA | Gấp 10 lần nền | Xem có phải chiếm tài khoản hàng loạt |
| Số push/OTP gửi cho một user | 20 lần trong 5 phút | Dấu hiệu MFA fatigue — chặn gửi tiếp |
| Refresh token bị dùng lại | Bất kỳ lần nào | Thu hồi họ token, cảnh báo |
| Lỗi gọi dịch vụ kiểm mật khẩu lộ | > 0 kéo dài | Control đang fail-open (mục 4.2) |
Runbook khi nghi bị chiếm tài khoản hàng loạt: (1) bật challenge cho đăng nhập toàn hệ thống; (2) buộc reset các tài khoản có đăng nhập thành công bất
thường; (3) huỷ phiên/tăng token version của nhóm đó; (4) nếu nghi lộ khoá ký JWT hoặc APP_KEY/SESSION_SECRET — xoay khoá, nghĩa
là đăng xuất mọi người, nên phải có sẵn quyết định ai được bấm nút này; (5) thông báo người dùng. Phần log và cảnh báo chi tiết thuộc
Bài 9; lưu ý không bao giờ log mật khẩu, token, session ID (kể cả trong URL của access log).
flowchart TD
EV["Sự kiện xác thực<br/>Failed, Lockout, Login, PasswordReset"] --> LOG["Log có cấu trúc<br/>không chứa mật khẩu / token"]
LOG --> MET["Metric: tỉ lệ sai, tài khoản / IP,<br/>reset / giờ, OTP gửi / user"]
MET --> AL{"Vượt nền?"}
AL -- "Có" --> RB["Runbook: challenge toàn cục,<br/>huỷ phiên nhóm nghi ngờ, thông báo"]
AL -- "Không" --> DASH["Dashboard"]
7. Áp dụng vào hệ thống trong CV
Dữ kiện từ cv-v3.md; chỗ có dấu * là giả định, CV không nêu. Các giá trị Moodle dưới đây đọc từ source nhánh MOODLE_405_STABLE
(admin/settings/security.php, admin/settings/server.php); CV không nói LMS Uni đang chạy bản Moodle nào* (CV chỉ nhắc lần nâng
cấp cũ 2.7 → 3.5), nên phải kiểm lại trên bản thật.
7.1 LMS Uni — Moodle, student portal NestJS + Next.js dùng chung MySQL, Redis riêng cho session, 5 app node
- Moodle mặc định không khoá tài khoản.
lockoutthresholdmặc định0("Không"), lựa chọn 3/5/7/10/20/30/50/100;lockoutwindowvàlockoutdurationmặc định 30 phút. Với 100.000 sinh viên, cài đặt này là câu hỏi đầu tiên interviewer có thể hỏi "anh đã chống đoán mật khẩu thế nào". Câu trả lời hợp lý: bật ngưỡng (ví dụ 10–20, kèm thời gian khoá 30 phút) và rate limit ở Nginx LB theo IP cho/login/index.php— nhưng nhớ phòng máy sau NAT (mục 3.1) và việc lockout theo tài khoản có thể bị dùng để khoá sinh viên trước giờ thi (mục 5.2). Hệ thống thật đang đặt bao nhiêu*: phải kiểm. - Chính sách mật khẩu mặc định của Moodle đi ngược NIST-4.
passwordpolicybật, tối thiểu 8 ký tự, và bắt ít nhất 1 chữ số, 1 chữ thường, 1 chữ hoa, 1 ký tự không phải chữ-số — đúng loại luật thành phần NIST nói SHALL NOT. Có thể chỉnh (tăng độ dài, đặt các mục thành phần về 0) nhưng Moodle core không có kiểm danh sách mật khẩu đã lộ (theo cài đặt đã đọc; plugin bên thứ ba chưa tìm hiểu). Nếu đăng nhập qua SSO của trường* thì chính sách nằm ở IdP, không ở Moodle. - Các mặc định tốt nên nói ra:
protectusernamesbật (chống lộ username/email ở trang quên mật khẩu — đúng mục 3.3);pwresettime30 phút (hạn link reset);passwordchangelogoutbật (đổi mật khẩu thì đăng xuất các phiên khác — đúng mục 3.5 ý 5);cookiesecurebật;passwordchangetokendeletion(xoá web service token khi đổi mật khẩu) lại mặc định tắt — nên bật nếu portal hay app gọi Moodle bằng web service token của người dùng*. Moodle 4.5 có plugin MFA trong core (admin/tool/mfa) — có thể bật cho admin và giảng viên trước. sessiontimeoutmặc định 8 giờ — với phòng máy dùng chung trong trường, đây đúng là OWASP kịch bản 3. Trade-off: sinh viên làm quiz dài không được bị đăng xuất giữa chừng; cách cân bằng là giữ idle ngắn hơn cho máy công cộng* hoặc chấp nhận 8 giờ nhưng nhắc "đăng xuất" và xoá phiên khi đóng trình duyệt ở phòng máy.- Portal NestJS xác thực thế nào — cửa phụ lớn nhất của hệ thống. CV không nói portal đăng nhập ra sao*. Ba khả năng và rủi ro A07 của mỗi
cái:
- Portal tự kiểm mật khẩu bằng cách đọc hash trong bảng user của Moodle (dùng chung DB): portal thành một cửa đăng nhập thứ hai, phải tự làm lại lockout, throttle, chính sách, MFA của Moodle — nếu không, kẻ tấn công bỏ qua mọi cài đặt ở ý 1–3 bằng cách đăng nhập qua portal (CWE-288).
- Portal đọc cookie phiên Moodle từ Redis: không có cửa thứ hai, nhưng ghép chặt với định dạng phiên nội bộ của Moodle (đổi bản là vỡ) và portal phải tôn trọng timeout/logout của Moodle.
- Cả hai dựa vào một IdP (SSO trường, OIDC): sạch nhất về A07 — một nơi áp chính sách, MFA, logout — đổi lại thêm một hệ thống và phải làm SLO.
- Link tải báo cáo xác thực bằng JWT (CV có nêu). Token này nằm trong email và URL nên: hạn ngắn,
aud/typriêng cho "report-download" để không dùng được ở API khác (mục 3.7), gắn với ID báo cáo và người nhận, cân nhắc dùng một lần; không để URL có token rơi vào access log của Nginx (lọc query string khi log) và đặtReferrer-Policy: no-referrer. CV không nói hạn và audience của token này*. - 5 app node + Redis riêng cho session (CV có nêu): bản ghi phiên tập trung là điều kiện để logout và huỷ phiên có hiệu lực trên mọi node; cũng là nơi bộ đếm throttle của portal phải nằm (mục 4.1, ý 5).
7.2 LMS Micro-learning — Laravel + Livewire, NestJS, Next.js, PostgreSQL, Redis, 4 trường
- 4 trường trên cùng codebase (CV có nêu): nếu mỗi trường một subdomain*, cookie phiên không nên đặt
Domainchung cho domain mẹ — dùng cookie host-only (__Host-) để phiên của trường A không gửi sang trường B; nếu có SSO riêng từng trường*, liên kết tài khoản theo cặp (IdP,sub) chứ không theo email. - Laravel + Livewire: dùng guard và starter kit/Fortify thay vì tự viết (mục 4.1–4.4); bật
SESSION_SECURE_COOKIE; thêm absolute timeout; Livewire chạy trên phiên web nên mọi điều ở 4.4 áp dụng nguyên. - Next.js gọi NestJS: nếu Next.js lưu JWT trong localStorage* thì đó là điểm nên sửa đầu tiên — chuyển sang cookie HttpOnly do server Next.js (BFF) giữ, hoặc phiên server (mục 5.3).
- Idempotency key + unique constraint mà CV có nêu cho nộp bài cũng là kỹ thuật dùng cho tiêu thụ token reset/recovery code một lần (mục 4.3).
7.3 Trả lời mẫu
Cách nói khi phỏng vấn: "Ở LMS Uni, phần xác thực có hai cửa: Moodle và student portal NestJS. Em xem mặc định của Moodle trước: bản 4.5 không bật khoá tài khoản, chính sách mật khẩu còn luật thành phần kiểu cũ, phiên sống tám tiếng — nên em đề xuất bật ngưỡng khoá có thời hạn, rate limit ở load balancer, và MFA cho admin, giảng viên trước. Rủi ro lớn hơn là portal: nếu portal tự kiểm mật khẩu từ bảng Moodle thì nó là cửa thứ hai bỏ qua mọi cài đặt đó, nên hoặc portal dùng chung phiên Moodle, hoặc cả hai dựa vào một IdP. Link tải báo cáo bằng JWT thì em giới hạn hạn dùng và audience để token đó không dùng được ở API khác."
8. Anti-patterns & bài học thực tế
8.1 Sự cố công khai: điều gì sai và control nào giúp
| Sự cố | Điều đã xảy ra (theo nguồn) | Control A07 liên quan |
|---|---|---|
| 23andMe, 2023 | Theo TechCrunch: kẻ tấn công vào ~14.000 tài khoản bằng credential stuffing (mật khẩu đã lộ ở nơi khác), rồi qua tính năng "DNA Relatives" lấy dữ liệu của 6,9 triệu người. Công ty đổ lỗi người dùng "tái sử dụng mật khẩu". | Phòng chống stuffing (3.1), MFA, kiểm mật khẩu lộ; và bài học A01/A06: một tài khoản bị chiếm thấy được dữ liệu của bao nhiêu người khác |
| Snowflake (UNC5537), 2024 | Mandiant: credential lấy từ máy nhiễm infostealer — có cái từ 2020 — đăng nhập vào instance của khách hàng; các tài khoản bị ảnh hưởng không bật MFA; ~165 tổ chức được thông báo. | MFA bắt buộc, xoay credential, giới hạn nguồn truy cập; "mua" nền tảng tốt không thay được cấu hình đúng (5.5) |
| Microsoft (Midnight Blizzard), 2024 | Microsoft: password spray chiếm một tài khoản thuộc tenant thử nghiệm cũ (legacy, non-production) không có MFA, từ đó leo sang hệ thống công ty. | Phát hiện spray (3.1, 6.3); mọi môi trường — kể cả "test cũ" — cùng chính sách MFA; xoá tài khoản/môi trường bỏ hoang |
| Uber, 2022 | Uber: mật khẩu của một contractor bị lộ (máy cá nhân nhiễm malware); kẻ tấn công thử đăng nhập liên tục, contractor nhận nhiều yêu cầu duyệt đăng nhập hai bước và cuối cùng chấp nhận một. | MFA fatigue (3.4): number matching, giới hạn số push, cảnh báo; passkey |
| Okta, 2023 | Okta: kẻ tấn công vào hệ thống hỗ trợ khách hàng qua một service account mà nhân viên đã lưu credential vào tài khoản Google cá nhân trên laptop công ty; file HAR khách hàng gửi lên chứa session token, dùng để chiếm phiên của 5 khách hàng (134 khách hàng bị truy cập file). | Session token là bearer credential (mục 2, ý 2); tài khoản dịch vụ không dùng mật khẩu người (5.4); gắn phiên với thiết bị/IP và thu hồi phiên nhanh |
8.2 Anti-pattern thường gặp khi review
- "Đã có rate limit 60 request/phút cho toàn API" — không phải chống đoán mật khẩu: không đếm theo tài khoản, không thấy spray, và bộ đếm có thể không chung giữa các node.
- Bắt đổi mật khẩu mỗi 90 ngày + luật thành phần — tạo đúng loại mật khẩu mà "hybrid stuffing" đoán giỏi nhất (OWASP kịch bản 2).
- MFA chỉ ở web, còn API mobile cũ, endpoint đăng nhập "nội bộ", hay đường SSO không qua MFA.
- Khôi phục MFA qua email — biến MFA thành "mật khẩu email".
- Logout chỉ xoá cookie / xoá token ở localStorage, phiên server và refresh token vẫn sống.
- JWT 30 ngày "cho tiện" không có cơ chế thu hồi; hoặc dùng
decodethayverify. - Một khoá ký cho mọi loại token và mọi dịch vụ, không kiểm
aud/typ. - Thông báo "email không tồn tại" ở quên mật khẩu, hoặc status code khác nhau.
- Seed admin với mật khẩu cố định rồi quên đổi trên production; credential service-to-service nằm trong repo.
- Tin IP văn phòng / header
X-Forwarded-Forđể bỏ qua đăng nhập cho trang quản trị.
9. Câu hỏi phụ interviewer hay hỏi tiếp
| Câu hỏi | Trả lời ngắn |
|---|---|
| Credential stuffing khác brute force và password spray thế nào? | Brute force: nhiều mật khẩu vào một tài khoản. Stuffing: cặp đã lộ, mỗi tài khoản vài lần. Spray: một mật khẩu phổ biến vào rất nhiều tài khoản. Mỗi dạng cần một loại bộ đếm/metric khác. |
| Lockout hay throttling? | Throttling/khoá tạm tăng dần, không khoá vĩnh viễn, quên mật khẩu vẫn mở; lockout cứng biến email công khai thành nút DoS. NIST đặt trần 100 lần sai liên tiếp. |
| Chính sách mật khẩu hiện đại? | NIST 800-63B-4: ≥ 15 ký tự nếu là yếu tố duy nhất (≥ 8 nếu có MFA), cho tới ≥ 64, không luật thành phần, không đổi định kỳ, bắt buộc blocklist mật khẩu lộ/ngữ cảnh. |
| Session fixation là gì, chặn thế nào? | Session ID biết trước khi đăng nhập trở thành ID của phiên đã xác thực. Chặn: đổi session ID ở mọi lần đổi mức đặc quyền. Laravel 13 guard và Passport ≥ 0.6 tự làm khi login. |
| JWT hay session? | Trình duyệt: phiên server hoặc BFF (thu hồi được, HttpOnly). Service-to-service/mobile nhiều bên: JWT ngắn hạn có iss/aud/typ + refresh có trạng thái. |
| Thu hồi JWT thế nào? | Hạn ngắn + refresh token xoay vòng; hoặc denylist theo jti; hoặc token version theo user. Cả ba đều thêm trạng thái — đổi lấy khả năng thu hồi. |
| Vì sao SMS OTP bị coi là yếu? | SIM swap, chuyển số; không chống phishing chuyển tiếp. NIST xếp loại restricted. Vẫn tốt hơn chỉ mật khẩu, nhưng không dùng cho admin. |
| Passkey giải quyết được gì mà TOTP không? | Chữ ký gắn với origin thật nên trang giả chuyển tiếp không dùng được; không có bí mật chung để lộ ở server. Điểm yếu còn lại: khôi phục khi mất thiết bị. |
| Làm sao chống dò email ở trang đăng ký? | Không thể luôn trả lời chung; dùng throttling/CAPTCHA, hoặc luôn "kiểm tra email" và báo "đã có tài khoản" qua email cho chủ thật. |
| A07 khác A01 thế nào? | A07: hệ thống có biết đúng ai đang gọi không. A01: biết rồi, người đó có được phép không. Câu hỏi bảo mật làm khôi phục là A06 vì không cài cách nào an toàn được. |
10. Chưa verify và Nguồn
Chưa verify
Bài này không có lab. Ngoài nguyên tắc chung đó, các điểm sau đặc biệt cần lab hoặc đối chiếu thêm mới khẳng định được:
- Timebox 200 ms của
SessionGuard::attempt()vàPasswordBrokerthực sự làm thời gian phản hồi hai nhánh khó phân biệt — mới đọc source 13.x, chưa đo. SessionGuard::login()tựregenerate(true)— đọc source 13.x; chưa kiểm bằng request thật rằng session ID cũ bị huỷ trong Redis.NotPwnedVerifierfail-open khi API lỗi và timeout mặc định 30 giây — đọc source, chưa giả lập API lỗi; cách bind lại verifier với timeout ngắn chưa verify.verifyAsynccủa@nestjs/jwtdùngverifyOptionsđăng ký ở module (issuer/audience/algorithms) — theo hiểu biết về thư viện, chưa chạy.- Chữ ký chính xác của
Features::passkeys()và limiter của/two-factor-challengetrong Fortify 13.x — mới đọc docs qua Context7. - URL trong email reset của Laravel khi app chạy sau proxy mà không cấu hình
TrustHosts/TrustProxies— chưa kiểm. - Giá trị Moodle (lockout, chính sách mật khẩu,
sessiontimeout,protectusernames, tool_mfa) đọc từMOODLE_405_STABLE; bản LMS Uni đang chạy và cấu hình thật chưa biết. Moodle có absolute timeout riêng hay không — chưa tìm hiểu. - Cơ chế xác thực thật của student portal LMS Uni và token tải báo cáo (hạn, audience) — CV không nêu.
- Các con số ví dụ (ngưỡng 5/20, 30 phút, 8 giờ, tỉ lệ sai nền 5%) là minh hoạ, không phải số đo.
- Bước OIDC ở 5.6 theo đặc tả và cheat sheet; chưa dựng IdP để kiểm cấu hình thư viện cụ thể.
Nguồn
- OWASP Top 10:2025 — A07 Authentication Failures: top10.owasp.org (Background, bảng điểm, Description, How to prevent, 3 kịch bản, 36 CWE); bản markdown: github.com/OWASP/Top10; A07:2021 (số liệu để so sánh): github.com/OWASP/Top10; tên CWE-297/298/299: cwe.mitre.org
- OWASP Cheat Sheets: Authentication (generic error, quick-exit timing, lockout threshold/window/duration, re-authentication, đổi email), Session Management (entropy ≥ 64 bit, renew ID,
__Host-, SameSite, idle/absolute timeout, localStorage), Forgot Password, Multifactor Authentication (resetting MFA, MFA attack patterns), JSON Web Token (not using JWTs, audience/issuer confusion, typ, revocation, denylist), Credential Stuffing Prevention - NIST SP 800-63B-4 (8/2025): bản một trang (password 3.1.1, reauthentication/timeout, bảng AAL), Authenticators (3.2.2 rate limiting 100 lần, blocklist, PSTN restricted, push không so khớp)
- Laravel 13 docs (Context7
/laravel/docs): Authentication (attempt, regenerate, logout, logoutOtherDevices, password.confirm, login throttling), Password Reset, Validation — Password rule, Rate Limiting, Fortify (2FA, recovery codes, passkeys, limiter), Sanctum (SPA, token expiration, revoke) - Source
laravel/framework13.x: SessionGuard.php, PasswordBroker.php, DatabaseTokenRepository.php, NotPwnedVerifier.php, Session/Store.php; skeleton config/session.php - NestJS docs (Context7
/nestjs/docs.nestjs.com): Rate limiting (ttl, limit, blockDuration, getTracker, storage), Passport recipe (JwtModule, JwtStrategy); nestjs/throttler README; jsonwebtoken README (algorithms, issuer, audience); Passport CHANGELOG 0.6.0 (login/logout regenerate session) - Moodle source
MOODLE_405_STABLE: admin/settings/security.php, admin/settings/server.php, admin/tool/mfa - Sự cố: TechCrunch — 23andMe tells victims it's their fault that their data was breached; Mandiant/Google Cloud — UNC5537 Targets Snowflake Customer Instances; Microsoft — Midnight Blizzard: Guidance for responders; Uber — Security update (9/2022); Okta — Unauthorized Access to Okta's Support Case Management System: Root Cause and Remediation
- Trong repo: Bài 0 — tổng quan, Bài 1 — A01, Bài 2 — A02, Bài 3 — A03, Bài 4 — A04 (hash mật khẩu, token bí mật, JWT), Bài 5 — A05, Bài 6 — A06 (state machine, UPDATE có điều kiện, câu hỏi bảo mật), Phạm vi rate limit (lab throttle đăng nhập Laravel), 6 thuật toán rate limit, Rate limit phân tán với Redis,
cv-v3.md
Phụ lục lab: không áp dụng — series A01–A10 không có lab (quyết định của series).
Series OWASP Top 10:2025: ← Bài 0 — tổng quan · ← Trước: Bài 6 — A06 Insecure Design · Bài 7 — A07 Authentication Failures (bài này) · Tiếp: Bài 8 — A08 Software or Data Integrity Failures →