Có một quy tắc "bất thành văn" trong giới bảo mật: "Don't Roll Your Own Crypto/Auth" (Đừng tự viết code mã hóa/đăng nhập).
Tại sao? Vì bảo mật không chỉ là so sánh password === db_password. Đó là một tảng băng chìm khổng lồ mà chỉ cần sai một ly là đi một dặm.
1. Mổ xẻ rủi ro khi "Tự Code" (Deep Dive)
Nếu bạn nghĩ "Login chỉ là kiểm tra User/Pass rồi tạo Cookie", hãy xem Hacker cười vào mặt bạn như thế nào qua 3 kịch bản sau:
Kịch bản 1: Timing Attack (Tấn công đo thời gian)
Code "ngây thơ" của bạn:
// Tự viết logic check password
function checkLogin(inputPass, dbPass) {
if (inputPass === dbPass) { // So sánh chuỗi bình thường
return true;
}
return false;
}Cách Hacker tấn công: Hacker không cần biết mật khẩu. Hắn gửi request login liên tục và đo thời gian phản hồi chính xác đến miligiây.
- Hắn nhập
Aaaaaa...: Server trả về trong 10ms. (Server so sánh chữ cái đầu tiênA!=S(của mật khẩu thậtSecret), sai ngay lập tức -> Return nhanh). - Hắn nhập
Saaaaa...: Server trả về trong 12ms. (Ký tự đầu đúng, server so sánh tiếp ký tự thứ 2 -> Lâu hơn 2ms). -> Hacker biết ký tự đầu là S. Hắn lặp lại quy trình cho đến khi tìm ra toàn bộ chuỗi.
Giải pháp của NextAuth: Dùng crypto.timingSafeEqual. Nó đảm bảo dù sai ở ký tự đầu hay ký tự cuối, thời gian trả về LUÔN LUÔN BẰNG NHAU (Ví dụ luôn là 50ms). Hacker mù tịt.
Kịch bản 2: Session Fixation (Ghim phiên)
Code "ngây thơ" của bạn:
Bạn tạo một trang Login. URL có thể nhận session ID từ query param: myapp.com/login?session_id=123.
Khi user đăng nhập thành công, bạn giữ nguyên session ID 123 đó và gán trạng thái isLoggedIn = true vào Database.
Cách Hacker tấn công:
- Hacker tạo một Session ID rác (
123). - Hacker gửi link
myapp.com/login?session_id=123cho Nạn nhân (qua email lừa đảo). - Nạn nhân ngây thơ bấm vào và đăng nhập. Server của bạn kích hoạt session
123. - Hacker (nắm giữ session
123từ đầu) F5 lại trình duyệt -> VÀO THẲNG TÀI KHOẢN CỦA NẠN NHÂN.
Giải pháp của NextAuth: Mỗi khi đăng nhập thành công, NextAuth hủy bỏ session cũ và cấp một Session ID hoàn toàn mới (Regenerate). Hacker cầm session cũ chỉ là tờ giấy lộn.
Kịch bản 3: Token Generation kém (Dùng Math.random)
Code "ngây thơ" của bạn:
// Tạo token reset password
const token = Math.random().toString(36).substr(2);
// VD: "xy7a1z"Cách Hacker tấn công:
Math.random() không phải là hàm ngẫu nhiên thực sự (Pseudo-random). Nó dựa trên thời gian hệ thống và thuật toán đoán được.
Hacker có thể chạy script mô phỏng lại thuật toán của Math.random() và đoán ra chính xác token tiếp theo mà server bạn sẽ sinh ra. Hắn dùng token đó để reset password của Admin.
Giải pháp của NextAuth: Dùng crypto.randomBytes (CSPRNG - Cryptographically Secure Pseudo-Random Number Generator). Một thuật toán ngẫu nhiên dựa trên độ nhiễu phần cứng (Hardware Noise), không thể đoán trước.
2. NextAuth.js (Auth.js) là gì?
Nó không chỉ là một thư viện "Login with Google". Nó là một Security Framework toàn diện cho Next.js, được thiết kế và audit bởi các chuyên gia bảo mật hàng đầu.
Tính năng "đắt tiền":
- Encrypted Everything: Session Cookie mặc định được mã hóa (JWE). Ngay cả khi hacker trộm được cookie, họ cũng không đọc được nội dung bên trong.
- Database Agnostic: Bạn dùng MySQL? Postgres? MongoDB? Firebase? NextAuth hỗ trợ tất cả thông qua
Adapters. - OAuth State Verification: Tự động kiểm tra tính toàn vẹn của quy trình OAuth để chống tấn công "Man-in-the-middle".
3. Setup NextAuth v5 (Bản mới nhất)
Cài đặt chuẩn chỉnh trong Next.js App Router.
Bước 1: Config (auth.ts)
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import Credentials from "next-auth/providers/credentials"
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
// 1. Login Mạng xã hội (OAuth)
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
// 2. Login User/Pass truyền thống
Credentials({
authorize: async (credentials) => {
// Logic check DB và Hash password ở đây
// (Xem bài Password Hashing để biết cách làm đúng)
return user;
},
}),
],
pages: {
signIn: '/login', // Tự custom giao diện login
}
})Bước 2: Route Handler
Mở đường cho các API /api/auth/signin, /api/auth/signout.
app/api/auth/[...nextauth]/route.ts:
import { handlers } from "@/auth"
export const { GET, POST } = handlersBước 3: Middleware (Bảo vệ Route)
Chặn cửa ngay từ Server, không cho user chưa login nhìn thấy Admin Panel.
// middleware.ts
import { auth } from "@/auth"
export default auth((req) => {
const isLoggedIn = !!req.auth;
// Nếu chưa login mà cố vào dashboard -> Đá về login
if (!isLoggedIn && req.nextUrl.pathname.startsWith('/dashboard')) {
return Response.redirect(new URL('/login', req.nextUrl));
}
})4. Advanced: Tích hợp với Backend API có sẵn (Spring/Go/.NET)
Đây là câu hỏi "triệu đô": "Backend tôi đã có API Login/Refresh Token rồi, NextAuth có dùng được không?" Trả lời: Vô tư! NextAuth lúc này sẽ đóng vai trò như một Secure Proxy.
Quy trình (The Flow)
- Browser: User nhập User/Pass -> Gửi lên Next.js Server (NextAuth).
- NextAuth (Server): Gọi API
/api/logincủa Backend thật (Spring/Go). - Backend: Trả về
access_token+refresh_token. - NextAuth: Lưu cặp token này vào bên trong Session Cookie (đã mã hóa) của chính nó. Không trả trực tiếp Raw Token về Browser.
Code thực chiến (auth.ts)
// 1. Hàm helper gọi Backend để refresh token
async function refreshAccessToken(token) {
try {
const response = await fetch("https://api.backend.com/auth/refresh", {
method: "POST",
body: JSON.stringify({ refresh_token: token.refreshToken }),
})
const refreshedTokens = await response.json()
return {
...token,
accessToken: refreshedTokens.access_token,
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken, // Nếu backend trả về RT mới thì update
expiresAt: Date.now() + refreshedTokens.expires_in * 1000,
}
} catch (error) {
return { ...token, error: "RefreshAccessTokenError" }
}
}
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
// 2. Gọi API Login của Backend
authorize: async (credentials) => {
const res = await fetch("https://api.backend.com/auth/login", {
method: "POST",
body: JSON.stringify(credentials),
headers: { "Content-Type": "application/json" },
})
const user = await res.json()
// Nếu API trả về OK -> Return user kèm token
if (res.ok && user) {
return user // user này chứa { access_token, refresh_token, profile... }
}
return null
},
}),
],
callbacks: {
// 3. Quản lý Token Rotation trong JWT Callback
async jwt({ token, user }) {
// (A) Lần đăng nhập đầu tiên
if (user) {
return {
...token,
accessToken: user.access_token,
refreshToken: user.refresh_token,
expiresAt: Date.now() + user.expires_in * 1000,
}
}
// (B) Nếu token chưa hết hạn -> Dùng tiếp
if (Date.now() < token.expiresAt) {
return token
}
// (C) Nếu hết hạn -> Gọi hàm refresh (Silent Renewal)
return await refreshAccessToken(token)
},
// 4. Đẩy Access Token xuống Session (để Client gọi API)
async session({ session, token }) {
session.accessToken = token.accessToken
session.error = token.error
return session
},
},
})Cách sử dụng Token để gọi API (Implementation)
Sau khi có Access Token trong Session, bạn có thể lấy nó ra ở bất cứ đâu để gọi về Backend.
Cách 1: Server Component (Khuyên dùng) An toàn nhất vì Token không bao giờ lộ ra Client (trừ khi bạn cố tình log ra console browser).
// app/profile/page.tsx
import { auth } from "@/auth";
export default async function ProfilePage() {
const session = await auth();
// Gọi về Backend Service (Spring/Go)
const res = await fetch("https://api.backend.com/users/me", {
headers: {
Authorization: `Bearer ${session?.accessToken}`, // Lấy token từ session
},
});
const data = await res.json();
return <div>Hello, {data.fullName}</div>;
}Cách 2: Client Component (Hạn chế) Chỉ dùng khi bắt buộc phải fetch dữ liệu từ phía Client (ví dụ: realtime charts).
// components/UserList.tsx
"use client";
import { useSession } from "next-auth/react";
export default function UserList() {
const { data: session } = useSession();
const fetchUsers = async () => {
const res = await fetch("https://api.backend.com/users", {
headers: {
Authorization: `Bearer ${session?.accessToken}`,
},
});
// ...
};
return <button onClick={fetchUsers}>Load Users</button>;
}Với cấu hình trên, bạn tận dụng được cả 2 thế giới:
- Backend: Tự chủ logic xác thực phức tạp.
- Frontend: Bảo mật tuyệt đối Access Token (vì NextAuth giấu nó trong Cookie mã hóa), tự động Refresh Token mà Client không cần code một dòng logic nào.
5. Senior Tip: Environment Variables
Lỗi kinh điển của Junior: Push GOOGLE_CLIENT_SECRET lên GitHub. -> Hacker dùng key của bạn để spam, Google khóa tài khoản của bạn.
Quy tắc bất di bất dịch:
- Luôn để Secret trong
.env.local. - Thêm
.env.localvào.gitignore. - Trên Vercel/Production, nhập biến vào phần Settings -> Environment Variables.
# .env.local
AUTH_SECRET="sinh-ra-mot-chuoi-ngau-nhien-dai-loang-ngoang"
GOOGLE_CLIENT_ID="..."
GOOGLE_CLIENT_SECRET="..."6. Kết luận
Viết tính năng Login giống như xây cửa hầm ngân hàng.
- Bạn có thể tự hàn một cái cửa sắt trong 2 tuần, nghĩ rằng nó chắc chắn (Tự Code).
- Hoặc bạn mua một cái cửa thép chuẩn quân đội giá $0 và lắp trong 5 phút (NextAuth).
Là một Senior, hãy chọn cái an toàn hơn cho User và tiết kiệm thời gian cho bạn. Dành thời gian đó để làm tính năng sản phẩm (Feature) thay vì đi vá lỗi bảo mật.