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ạnDấ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ẩuCho 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ẩuLư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ụcQuê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ứ haiMFA thiếu hoặc không hiệu quả; đường lui (fallback) yếu khi MFA không dùng được
PhiênSession 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 / assertionKhô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:2021A07:2025Đọc thế nào
CWE ánh xạ2236Phạm vi được làm rõ, gom thêm nhóm credential cứng/mặc định
Max incidence rate14,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 rate2,55%2,92%Trung bình nhích lên
Max coverage79,51%100,00%Có tập dữ liệu đã kiểm mọi CWE của mục
Avg coverage45,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 exploit7,407,69Dễ khai thác — nhiều dạng chỉ cần script và danh sách đã lộ
Avg weighted impact6,504,44Tác động trung bình theo CVSS thấp đi — nhiều CWE mới là lỗi cấu hình/credential
Total occurrences132.1951.120.673Tăng ~8,5 lần (một phần do dữ liệu 2025 lớn hơn)
Total CVEs3.8977.147Gầ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ómCWEGiả định sai trong code
Credential cứng, rỗng, mặc định258, 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ếu521, 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ực288, 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ênh291, 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ữa294, 300"Bắt được gói tin cũng không dùng lại được"
Kiểm chứng chỉ TLS295, 297, 298, 299"Tắt verify cho nhanh, mạng nội bộ mà"
Cài thuật toán / bỏ bước287, 303, 304"Luồng MFA có bước 2 nhưng API bước 3 không kiểm bước 2 đã xong"
Phiên384, 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ẩu620, 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ỏiMụcVí dụ LMS (minh hoạ)
Hệ thống có biết chắc ai đang gọi không?A07Phiê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 + A07Trang 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ạ):

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-4Mứ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ựSHALLKhô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ựSHALLCó 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)SHOULDKhô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 NOTBỏ 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 / SHALLBỏ 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ậtSHALL NOTBỏ "tên thú cưng đầu tiên"
Kiểm toàn bộ mật khẩu, không cắt bớtSHALLLưu ý bcrypt chỉ dùng 72 byte đầu — xem Bài 4
Cho phép password manager, autofill, dán (paste)SHALL / SHOULDKhô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ứ haiChống phishing thời gian thực?Rủi ro chínhGhi chú
Passkey / FIDO2 / WebAuthnCó — gắn với origin thậtMất thiết bị → cần khôi phục; passkey đồng bộ phụ thuộc tài khoản cloudNIST: AAL2 phải cung cấp lựa chọn chống phishing
TOTP (app sinh mã)Không — mã chuyển tiếp đượcTrang giả chuyển tiếp mã trong 30 giâyRẻ, không phụ thuộc nhà mạng
Push có number matchingKhông hoàn toànSocial engineeringNIST: phải so khớp bí mật, không chỉ bấm "duyệt"
Push chỉ bấm "Duyệt"KhôngMFA fatigueNIST-4: "no longer considered acceptable"
SMS / gọi thoạiKhôngSIM swap, chuyển sốNIST xếp loại restricted (hạn chế)
Email OTPKhôngEmail cũng chỉ bảo vệ bằng mật khẩuNIST-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ả:

  1. "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.
  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) trong DatabaseTokenRepository và CacheTokenRepository).
  3. "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.
  4. "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-referrer trên trang reset.
  5. "Đổ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 passwordchangelogout cho 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ồnIdleAbsolute
OWASP Session cheat sheet2–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, AAL2SHOULD ≤ 1 giờSHOULD ≤ 24 giờ
NIST 800-63B-4, AAL3SHOULD ≤ 15 phútSHALL ≤ 12 giờ
Laravel 13 mặc định (config/session.php)lifetime = 120 phút, tính lại theo mỗi requestKhô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.

Một lần đăng nhập đi qua năm lớp phòng thủ Một thẻ màu xanh dương tên "POST đăng nhập" đi từ trái sang phải qua năm hộp. Hộp 1, throttle: đếm lần sai theo tài khoản cộng IP, hiện "3 trên 5, cho qua". Hộp 2, kiểm mật khẩu trong timebox 200 mili giây, hiện "đúng hay sai đều trả sau khoảng 200 mili giây, thông báo chung". Hộp 3, MFA: phiên ở trạng thái chờ MFA, hiện "passkey hoặc TOTP đúng". Hộp 4, đổi session ID: dòng sid cũ A mờ đi và bị gạch, dòng sid mới B hiện ra màu xanh lá. Hộp 5, cookie: hiện cờ HttpOnly, Secure, SameSite Lax, idle 30 phút, absolute 8 giờ. Dòng dưới cùng: logout thì huỷ phiên ở server, không chỉ xoá cookie. Đăng nhập an toàn = 5 lớp theo thứ tự; lớp nào cũng có thể dừng request 1. Throttle đếm lần SAI theo tài khoản + IP 2. Kiểm mật khẩu trong timebox ~200 ms thông báo chung 3. MFA phiên ở trạng thái "chờ MFA" 4. Đổi session ID chống session fixation 5. Cookie + timeout cờ an toàn, hai loại hạn 3/5 lần sai→ cho quaquá 5 → 429 đúng / sai đều trảsau ~200 mskhông lộ email có tồn tại passkey / TOTP đúngsai quá 5 lần hoặcquá 5 phút → về đầu sid cũ: A sid mới: Bai biết A trước đó: hết dùng HttpOnly; SecureSameSite=Laxidle 30 phútabsolute 8 giờ POST /login Logout: huỷ bản ghi phiên ở server (không chỉ xoá cookie) + thu hồi token liên quan; đổi mật khẩu: huỷ mọi phiên khác. Số liệu trong hình là ví dụ minh hoạ cho LMS (5 lần sai, 30 phút, 8 giờ) — không phải giá trị mặc định của framework.
Hình 1. Thứ tự năm lớp khi đăng nhập. Lớp 1 và 2 chống đoán mật khẩu và dò tài khoản; lớp 3 làm mật khẩu đúng vẫn chưa đủ; lớp 4 cắt mọi liên hệ với phiên trước đăng nhập; lớp 5 giới hạn thời gian một phiên bị cướp còn dùng được. Hình tôn trọng 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:

  1. Timing đã có framework lo, phần chữ thì không. Auth::attempt() của 13.x chạy trong Timebox 200 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::check nên mất luôn lớp này.
  2. 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() sau attempt(); 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ự đặt session(['user_id' => …]) như bản sai — đi vòng qua guard nên không có regenerate.
  3. 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ử.
  4. 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 acct lê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ỹ.
  5. 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:

  1. Gửi 5 ký tự đầu của SHA-1 viết hoa tới api.pwnedpasswords.com/range/ kèm header Add-Padding: true (độn thêm kết quả giả để kích thước response không lộ gì).
  2. 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.
  3. 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).
  4. Ví dụ Password::defaults trong 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:

// ===== ĐÚ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:

  1. SESSION_LIFETIME của Laravel là idle timeout: đọc config/session.php của skeleton 13.x — lifetime mặc định 120 phút, http_only mặc định true, same_site mặc định lax, còn secure lấy từ SESSION_SECURE_COOKIE và không có giá trị mặc định — production quên đặt là cookie phiên đi được qua HTTP thường.
  2. Auth::logoutOtherDevices() cần middleware auth.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 source SessionGuard::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.
  3. password.confirm là 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èm throttle: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.
  4. Với SameSite: cheat sheet coi Strict là ưu tiên, Lax chấ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). Strict là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ọn Lax.
// ===== ĐÚ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ớpChặn đượcKHÔNG chặn đượcGiá phải trả
Đếm lần sai theo tài khoản (+IP)Brute force một tài khoảnPassword spray; stuffing phân tánCó thể bị dùng để khoá người khác
Rate limit theo nguồn, bot detection, CAPTCHALàm chậm stuffing từ ít nguồnBotnet lớn, CAPTCHA-solving thuê ngườiMa 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áoPhát hiện spray, stuffingKhông tự chặn gìCần người trực và runbook
Blocklist mật khẩu lộ + độ dàiMật khẩu dễ đoán, đã lộ ở nơi khác trước khi đặtMậ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, sprayPhishing 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 / WebAuthnThêm cả phishing chuyển tiếpThiết bị đã bị chiếm; khôi phục yếuHỗ 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ướpCướ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ồiToken dùng sai chỗ, token cũToken còn hạn bị trộm trong vài phútThê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ệnKhông chặn gì; email cũng có thể đã bị chiếmDễ 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ụcPhiên server (cookie + store)JWT tự chứa
Thu hồi tức thìXoá bản ghi là xongKhông, trừ khi thêm denylist / version / refresh có trạng thái
Chi phí mỗi request1 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 domainCần store dùng chung hoặc gatewayThuận tiện: service nào có public key là kiểm được
Lộ qua XSSCookie HttpOnly — JS không đọc đượcNếu để ở localStorage thì lộ toàn bộ
CSRFPhải chống (SameSite + token)Không nếu gửi qua header; có nếu để trong cookie
Phù hợpWeb app một hệ thống, SPA cùng site (Sanctum SPA), BFFService-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

5.5 Tự xây, dùng framework, hay mua IdP

Lựa chọnKhi nào hợpRủ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ênNest 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ìnhThê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

  1. 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).
  2. Kiểm ID token: chữ ký với khoá của IdP (JWKS), iss đúng IdP, aud chứ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.
  3. 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.
  4. 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.
  5. 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

  1. 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?
  2. Có chỗ nào tự đặt user vào session (session(['user_id' => …]), req.session.userId = …) thay vì qua guard/passport?
  3. Có chỗ nào jwt.decode (không verify) rồi tin payload? verify có ghim algorithms, kiểm issuer, audience không?
  4. 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)?
  5. 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?
  6. Logout và đổi mật khẩu có huỷ phiên ở server, refresh token, remember-me, API token không?
  7. Có credential cứng, mật khẩu seed cố định, verify => false / rejectUnauthorized: false không?
  8. 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ì

6.3 Tín hiệu trong production

Metric / sự kiệnBấ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ốngNề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/ASN1 IP sai trên 300 tài khoản khác nhau trong 10 phútChặn nguồn; dấu hiệu spray/stuffing
Đăng nhập thành công sau nhiều lần saiSai 15 lần rồi đúng, từ IP mớiBắt MFA / thông báo cho chủ tài khoản
Sự kiện LockoutTừ vài chục/ngày lên hàng nghìnCó 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 MFAGấp 10 lần nềnXem có phải chiếm tài khoản hàng loạt
Số push/OTP gửi cho một user20 lần trong 5 phútDấu hiệu MFA fatigue — chặn gửi tiếp
Refresh token bị dùng lạiBất kỳ lần nàoThu 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àiControl đ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

  1. Moodle mặc định không khoá tài khoản. lockoutthreshold mặc định 0 ("Không"), lựa chọn 3/5/7/10/20/30/50/100; lockoutwindow và lockoutduration mặ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.
  2. Chính sách mật khẩu mặc định của Moodle đi ngược NIST-4. passwordpolicy bậ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.
  3. Các mặc định tốt nên nói ra: protectusernames bật (chống lộ username/email ở trang quên mật khẩu — đúng mục 3.3); pwresettime 30 phút (hạn link reset); passwordchangelogout bật (đổi mật khẩu thì đăng xuất các phiên khác — đúng mục 3.5 ý 5); cookiesecure bậ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.
  4. sessiontimeout mặ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.
  5. 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.
  6. 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/typ riê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à đặt Referrer-Policy: no-referrer. CV không nói hạn và audience của token này*.
  7. 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

  1. 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 Domain chung 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.
  2. 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.
  3. 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).
  4. 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, 2023Theo 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), 2024Mandiant: 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), 2024Microsoft: 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, 2022Uber: 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, 2023Okta: 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

  1. "Đã 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.
  2. 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).
  3. MFA chỉ ở web, còn API mobile cũ, endpoint đăng nhập "nội bộ", hay đường SSO không qua MFA.
  4. Khôi phục MFA qua email — biến MFA thành "mật khẩu email".
  5. Logout chỉ xoá cookie / xoá token ở localStorage, phiên server và refresh token vẫn sống.
  6. JWT 30 ngày "cho tiện" không có cơ chế thu hồi; hoặc dùng decode thay verify.
  7. Một khoá ký cho mọi loại token và mọi dịch vụ, không kiểm aud/typ.
  8. Thông báo "email không tồn tại" ở quên mật khẩu, hoặc status code khác nhau.
  9. 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.
  10. 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ỏiTrả 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:

  1. Timebox 200 ms của SessionGuard::attempt() và PasswordBroker thự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.
  2. 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.
  3. NotPwnedVerifier fail-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.
  4. verifyAsync của @nestjs/jwt dùng verifyOptions đăng ký ở module (issuer/audience/algorithms) — theo hiểu biết về thư viện, chưa chạy.
  5. Chữ ký chính xác của Features::passkeys() và limiter của /two-factor-challenge trong Fortify 13.x — mới đọc docs qua Context7.
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. 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

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 →