Khi để AI viết luồng auth: sai lầm và checklist kiểm tra
Bài này nằm ở đâu trong luồng auth
Bài trước bạn đã dựng xong luồng đăng nhập Google: đổi code lấy token, xác thực id_token, map về user trong database. Giờ đến bước thực tế nhất của module: phần lớn code auth trong dự án được bạn (và AI) viết ra không sai cú pháp, nó chỉ sai ở chỗ quyết định ai được đi qua. Bài này là bộ lọc để bạn bắt được những lỗi đó trước khi merge.
Sau bài này, bạn có thể:
- Nhận diện ba nhóm lỗi auth hay xuất hiện trong code do AI sinh ra: so sánh mật khẩu sai cách, thiếu kiểm tra quyền, token bị lộ.
- Áp câu hỏi cố định cho mọi token: ai giữ, sống bao lâu, bị lộ thì sao.
- Chạy checklist review 10 điểm trước khi merge một PR động vào
auth/.
Vì sao code auth do AI viết hay có lỗ
AI viết code để "chạy được happy path", không viết để "không bị khai thác". Ba lý do lặp lại:
- Nó học từ code mẫu trôi nổi trên mạng, trong đó rất nhiều mẫu dùng
md5(password), nhét JWT vàolocalStorage, hoặc trả cảrefreshTokentrong body. - Nó bỏ qua nhánh lỗi: không xử lý trường hợp token hết hạn, không kiểm tra user còn active, không xét trường hợp hai request refresh đến cùng lúc.
- Nó viết đúng phần bạn mô tả, thiếu đúng phần bạn không mô tả. Bạn nói "viết API lấy hoá đơn", nó viết API lấy hoá đơn, không ai bảo nó kiểm tra hoá đơn đó của ai.
Nên cách review hiệu quả không phải đọc từng dòng, mà là đọc theo luồng và hỏi đúng ba câu cho từng token.
Lỗi 1: so sánh mật khẩu và xử lý hash sai cách
Đây là lỗi phổ biến nhất và cũng dễ bắt nhất.
// AI sinh ra: "kiểm tra mật khẩu"
const user = await db.user.findByEmail(email);
const ok = user.password === md5(password);
if (!ok) return res.status(401).json({ error: 'Sai thông tin' });
Hai lỗi trong bốn dòng: md5 là hash nhanh, không salt, brute-force trên GPU rất rẻ; và so sánh chuỗi hash bằng === sẽ luôn sai nếu hash có salt (mỗi lần hash ra một chuỗi khác), đồng thời là so sánh không timing-safe. Cách đúng là để thư viện lo: await bcrypt.compare(password, user.password) hoặc await argon2.verify(user.password, password).
Ba biến thể khác của cùng lỗi này:
- Tự viết hàm so sánh hash bằng vòng
for(thời gian chạy phụ thuộc vị trí ký tự khác đầu tiên). Với so sánh bí mật dạng chuỗi, dùngcrypto.timingSafeEqual. - Trả lỗi khác nhau cho "email không tồn tại" và "sai mật khẩu". Cái này biến form đăng nhập thành công cụ dò danh sách email.
- Hash mật khẩu ở client rồi server hash lần nữa. Hash ở client không thay được TLS, chỉ làm bạn tưởng mình an toàn hơn.
Note: Câu hỏi review: mật khẩu đi qua bao nhiêu chỗ, và ở mỗi chỗ nó là plaintext hay hash? Chỗ nào cũng phải là hash, trừ đúng biến nhận từ request.
Bài tập 1. Đoạn code sau có hai lỗi, tìm cả hai:
const hashed = crypto.createHash('sha256').update(password).digest('hex');
if (hashed === user.password) { /* cho qua */ }
Gợi ý đáp án
sha256là hash nhanh, không salt, không chậm có chủ đích: phải dùngbcrypthoặcargon2.- So sánh bằng
===không timing-safe; với bcrypt/argon2 thì gọicompare/verify, với chuỗi bí mật thìcrypto.timingSafeEqual.
Lỗi 2: chỉ kiểm tra đăng nhập mà không kiểm tra quyền
Đăng nhập trả lời "bạn là ai". Phân quyền trả lời "bạn được làm gì với thứ này". AI rất hay làm xong vế đầu rồi dừng.
router.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await db.invoice.findById(req.params.id);
res.json(invoice); // ai đăng nhập cũng đọc được hoá đơn của người khác
});
Đây là IDOR: đổi :id là đọc được dữ liệu người khác. Cách sửa gọn nhất là đưa chủ sở hữu vào điều kiện truy vấn: findOne({ where: { id: req.params.id, userId: req.user.id } }), sau đó trả 404 chứ không trả 403, để không tiết lộ tài nguyên có tồn tại hay không.
Các biến thể cần soi:
- Middleware sai thứ tự:
router.use(requireRole('admin'))đặt trướcrequireAuth, khi đóreq.userlàundefinedvà điều kiện quyền bị đánh giá sai. - Route mới thêm quên gắn middleware. Cách chặn: đặt
router.use(requireAuth)ở đầu file router, chỉ để public route khai báo tường minh trước nó. - Kiểm tra vai trò ở frontend rồi ẩn nút. Ẩn nút không phải phân quyền; API vẫn gọi được bằng
curl. - Mass assignment:
User.create(req.body)cho client gửi luônrole: "admin". Luôn whitelist field:{ email, passwordHash, name }.
Bài tập 2. API xoá bình luận có requireAuth và kiểm tra req.user.role === 'moderator'. Người dùng thường vẫn xoá được bình luận của chính mình trong UI. Thiếu gì?
Gợi ý đáp án
Thiếu nhánh "chủ sở hữu": cho phép khi comment.userId === req.user.id hoặc khi role là moderator. Nếu chỉ có nhánh role, bạn tự khoá người dùng khỏi dữ liệu của họ.
Lỗi 3: token lộ mà không ai biết
Trước khi đọc tiếp, vẽ tay luồng token của bạn và trả lời ba câu: token này ai giữ, sống bao lâu, bị lộ thì sao. Nếu câu trả lời là "nằm trong localStorage, 30 ngày, kẻ tấn công có toàn bộ tài khoản", thì bạn vừa tìm ra lỗi.
Các dạng rò rỉ hay gặp trong code AI sinh:
res.json({ accessToken, refreshToken, user }): refresh token rơi vào JavaScript, XSS đọc được, và nó sống lâu hơn access token rất nhiều.- Token trong URL query:
?token=...lọt vào access log, headerReferer, lịch sử trình duyệt. - Log nguyên header: middleware log
req.headerslà mỗi request ghi lạiAuthorization: Bearer .... - Cookie thiếu thuộc tính: refresh token trong cookie phải có
httpOnly,secure,sameSite, vàpathgiới hạn ở endpoint refresh. - Secret hardcode hoặc có fallback:
process.env.JWT_SECRET || 'dev-secret'. Thiếu biến môi trường là hệ thống vẫn chạy, với secret ai cũng biết. jwt.decode()được dùng để quyết định cho qua, hoặcjwt.verify()không khai báoalgorithms: ['RS256'].
flowchart TD
A["🔑<br/>Xác thực"] --> B["👤<br/>Chủ sở hữu"]
B --> C["🛡️<br/>Vai trò"]
C --> D["🍪<br/>Nơi lưu token"]
D --> E["🧹<br/>Log và lỗi"]
Bài tập 3. Endpoint login trả về JSON chứa cả hai token. Trả lời ba câu hỏi cho refreshToken.
Gợi ý đáp án
Ai giữ: JavaScript trong trình duyệt, nên mọi script chạy được trên trang đều đọc được. Sống bao lâu: thường 7 đến 30 ngày. Bị lộ thì sao: kẻ tấn công tự cấp access token mới liên tục cho đến khi bạn thu hồi. Sửa: refresh token trong cookie httpOnly; Secure; SameSite=Strict, access token ngắn hạn (5 đến 15 phút) giữ trong memory.
Checklist review auth trước khi merge
Chạy đúng 10 mục này cho mọi PR động vào thư mục auth. Mục nào không trả lời được thì chưa merge.
| # | Kiểm tra | Đạt khi |
|---|---|---|
| 1 | Hash mật khẩu | bcrypt/argon2, không có md5/sha1/sha256 tự chế |
| 2 | So sánh mật khẩu | Dùng compare/verify, không === trên hash |
| 3 | Thông báo lỗi đăng nhập | Một câu chung cho mọi trường hợp sai |
| 4 | Xác thực trước phân quyền | requireAuth đứng trước requireRole, áp ở cấp router |
| 5 | Chủ sở hữu tài nguyên | Mọi query theo :id có kèm userId hoặc có nhánh kiểm tra |
| 6 | Whitelist input | Không có create(req.body), không client nào set được role |
| 7 | Nơi lưu token | Refresh token trong cookie httpOnly, access token trong memory |
| 8 | Verify JWT | Có algorithms, có kiểm tra exp, secret lấy từ env không fallback |
| 9 | Log | Không log header Authorization, không log body login/refresh |
| 10 | Thu hồi | Có đường revoke refresh token và test cho nó |
Tự kiểm tra nhanh phần này:
So sánh mật khẩu phải gọi _____ hoặc argon2.verify, không so sánh chuỗi hash bằng ===. Lỗi cho phép đổi id để đọc dữ liệu của người khác gọi là _____. Refresh token nên nằm trong cookie có thuộc tính _____ để JavaScript không đọc được. Khi verify JWT phải khai báo rõ _____ để chặn thuật toán lạ. Không đặt token trong _____ vì dễ lọt vào log và header Referer.
Mẹo rà auth nhanh và chỗ AI hay đánh lừa
- Đọc theo luồng, không đọc theo file: login, refresh, logout, route bảo vệ. Mỗi chặng hỏi lại ba câu về token.
- Code AI viết thường "trông xong" vì có comment
// TODO: add validation. Coi mọiTODOtrongauth/là lỗi chưa sửa, không phải ghi chú. - Test bằng
curlchứ không chỉ bằng UI. UI ẩn nút, API thì không. Thử đổi:id, thử gửirole: "admin", thử dùng token hết hạn. - Grep ba thứ trước tiên:
md5(,localStorage,process.env.*SECRET ||. Ba dòng grep này bắt được phần lớn lỗi nghiêm trọng. - Khi nhờ AI sửa, đưa luôn ràng buộc: nơi lưu token, thời gian sống, ai được gọi. Yêu cầu AI nêu rõ nó đã kiểm tra quyền ở đâu.
- Đừng tin "đã được AI review". Một vòng review của AI không thay được một lần bạn tự gọi
curlvới token của user khác.
Chốt lại và bước tiếp theo
Ba nhóm lỗi cần nhớ: so sánh mật khẩu sai cách (sửa bằng bcrypt.compare), thiếu kiểm tra quyền (sửa bằng điều kiện chủ sở hữu và thứ tự middleware), token lộ (sửa bằng nơi lưu, thời gian sống và dọn log). Câu hỏi duy nhất bạn phải thuộc: token này ai giữ, sống bao lâu, bị lộ thì sao.
Ôn lại bằng bộ thẻ dưới đây trước khi sang bài mini project, nơi bạn sẽ ghép toàn bộ luồng auth thành một sản phẩm chạy được.
Front: Ba câu hỏi phải đặt cho mọi token là gì? Back: Ai giữ token này, nó sống bao lâu, và bị lộ thì sao.
Front: Vì sao code auth do AI sinh ra hay có lỗ? Back: AI tối ưu cho happy path và học từ code mẫu phổ biến, nên thiếu nhánh lỗi và thiếu kiểm tra quyền.
Front: So sánh mật khẩu đúng cách trong Node.js? Back: Gọi bcrypt.compare hoặc argon2.verify, không so sánh chuỗi hash bằng ===.
Front: Vì sao không tự hash mật khẩu bằng sha256? Back: Hash nhanh, không salt, brute-force và rainbow table rất rẻ; phải dùng bcrypt hoặc argon2.
Front: So sánh hai chuỗi bí mật nên dùng hàm nào? Back: crypto.timingSafeEqual để tránh timing attack.
Front: IDOR là gì? Back: Đổi id trên URL để đọc dữ liệu của người khác, do API chỉ kiểm tra đăng nhập mà không kiểm tra chủ sở hữu.
Front: Thứ tự middleware đúng cho route bảo vệ? Back: requireAuth trước, requireRole sau, và áp ở cấp router để route mới không bị quên.
Front: Mass assignment nguy hiểm thế nào? Back: User.create(req.body) cho client gửi role: "admin"; phải whitelist field được phép.
Front: Vì sao không lưu refresh token trong localStorage? Back: XSS đọc được và nó sống lâu; nên đặt trong cookie httpOnly, Secure, SameSite.
Front: Vì sao không đặt token trong URL query? Back: Token lọt vào access log, header Referer và lịch sử trình duyệt.
Front: Lỗi khi verify JWT mà không khai báo algorithms? Back: Server có thể chấp nhận thuật toán do kẻ tấn công chọn; luôn whitelist algorithms cụ thể.
Front: Vì sao jwt.decode không được dùng để cho qua? Back: decode chỉ đọc payload, không kiểm tra chữ ký; phải dùng verify.
Front: Vì sao tránh process.env.JWT_SECRET || 'dev-secret'? Back: Fallback hardcode khiến hệ thống vẫn chạy với secret ai cũng biết; thiếu env thì phải fail fast. Câu hỏi đầu tiên khi review: token này _____, _____, và _____. So sánh mật khẩu phải dùng _____ chứ không dùng phép so sánh chuỗi thường. Kiểm tra quyền phải dựa trên _____ chứ không chỉ dựa trên việc đã đăng nhập. Refresh token sống lâu nên đặt trong cookie _____, không đặt trong localStorage.
Ba câu hỏi trước khi bấm merge
Đây là phần tôi dùng nhiều nhất khi review code auth, kể cả code do tôi tự viết. Đọc diff xong, tôi chỉ hỏi ba câu:
- Token này ai giữ? Nếu câu trả lời là "JavaScript đọc được" thì đó là rủi ro XSS chờ ngày nổ. Access token sống ngắn trong memory thì chấp nhận được; refresh token thì không.
- Nó sống bao lâu? Access token 15 phút, refresh token 7 đến 30 ngày kèm rotation. Con số nào không có lý do rõ ràng thì thường là do AI bịa ra.
- Bị lộ thì sao? Phải trả lời được: thu hồi bằng cách nào, ai bị ảnh hưởng, log có ghi lại không. Không có câu trả lời nghĩa là chưa có cơ chế thu hồi.
Vài mẹo nhỏ khác:
- Code AI sinh ra thường rất tự tin và rất trơn: đủ import, đủ comment, nhưng thiếu đúng một dòng kiểm tra quyền. Đừng đọc để hiểu, hãy đọc để tìm chỗ thiếu.
- Chạy thử bằng hai tài khoản khác role, đổi id trên URL, xem có trả về dữ liệu không. Đây là bài test 30 giây bắt được nhiều lỗi hơn cả buổi đọc code.
- Grep ba từ khoá trong toàn repo:
jwt.decode,===cạnh biến có tênpassword, vàreq.bodyđi thẳng vào lệnh ghi database. Chỗ nào cũng có là chỗ phải sửa. - Đừng bao giờ để AI tự sinh secret hay tự điền fallback. Thiếu biến môi trường thì app phải dừng ngay khi khởi động, không được chạy tiếp bằng giá trị mặc định.
- Ghi checklist vào PR template. Một checklist sống trong repo sẽ được dùng lại, còn một bài học nằm trong đầu thì sẽ quên.
Chốt lại
AI viết auth nhanh, nhưng nó không biết sản phẩm của bạn sợ điều gì. Bốn lỗi quay đi quay lại vẫn là: so sánh mật khẩu sai cách, thiếu kiểm tra quyền ở cấp bản ghi, token đặt sai chỗ, và secret có fallback. Ba câu hỏi "ai giữ, sống bao lâu, lộ thì sao" bắt được phần lớn trong số đó trước khi code lên production. Code auth không sai ở chỗ khó, nó sai ở chỗ bị bỏ qua.
Bài tiếp theo là mini project: bạn sẽ ghép toàn bộ luồng auth cho một sản phẩm thật, gồm đăng nhập Google, refresh token rotation và RBAC, rồi tự chạy checklist này trên chính code mình viết.