Bỏ qua

Bài 8: Authentication — Google OAuth2 + JWT + HMAC

Tham khảo trong project: admin/src/auth.py, admin/src/services/auth_service.py, admin/src/services/signed_url.py, admin/src/routers/auth.py


1. Bức tranh toàn cảnh (Flow Auth)

Trước khi đi sâu vào code, hãy nhìn lướt qua cách hệ thống "chuyền tay" nhau dữ liệu từ lúc user bấm nút Login cho đến khi lấy được danh sách bài hát:

Browser                    Admin API              Google
   │                           │                      │
   │── click "Login Google" ──►│                      │
   │                           │── redirect ─────────►│
   │◄── redirect ──────────────│◄── code + state ─────│
   │                           │── exchange code ────►│
   │                           │◄── access_token ─────│
   │                           │── get userinfo ─────►│
   │                           │◄── email, name ──────│
   │                           │                      │
   │                           │ Check email xem có trong danh sách cho phép (DB)?
   │                           │ Cấp JWT → Nhét vào Cookie
   │◄── kcds_session cookie ───│
   │                           │
   │── GET /api/audio ─────────│ (Browser tự động đính kèm Cookie)
   │                           │ Giải mã JWT → Lấy role mới nhất từ DB → Cho qua!
   │◄── 200 data ──────────────│

2. OAuth2 — Ủy quyền (Authorization Code Flow)

Nhiều bạn hay lầm tưởng OAuth2 là giao thức để xác thực người dùng (Authentication). Thực ra nó là giao thức Ủy quyền (Authorization Protocol): Google chỉ làm đúng một việc là nói với App của bạn rằng: "Đúng, anh chàng này vừa đồng ý cho app của bạn đọc địa chỉ email của anh ta đấy".

Bước 1: Điều hướng user sang trang đăng nhập Google

# admin/src/routers/auth.py
import secrets
from urllib.parse import urlencode

@router.get("/google/login")
async def google_login(response: Response) -> RedirectResponse:
    # Tuyệt chiêu chống hack CSRF: Tạo một chuỗi ngẫu nhiên (state)
    state = secrets.token_urlsafe(32)

    # Gửi chuỗi này xuống trình duyệt giấu trong Cookie (Sống ngắn 10 phút, JavaScript không đọc được)
    response.set_cookie("oauth_state", state, max_age=600, httponly=True)

    # Lắp ráp đường link dắt user sang Google
    params = {
        "client_id": GOOGLE_CLIENT_ID,
        "redirect_uri": GOOGLE_REDIRECT_URI,
        "response_type": "code",
        "scope": "openid email profile",
        "state": state,
    }
    return RedirectResponse(f"[https://accounts.google.com/o/oauth2/auth](https://accounts.google.com/o/oauth2/auth)?{urlencode(params)}")

Bước 2: Đón user quay về (Google Callback)

@router.get("/google/callback")
async def google_callback(
    code: str,          # Thẻ bài (authorization code) Google cấp
    state: str,         # Chuỗi state Google trả lại (Phải y hệt lúc gửi đi)
    request: Request,
    response: Response,
    session: AsyncSession = Depends(get_session),
    svc: AuthService = Depends(get_auth_service),
) -> RedirectResponse:
    # Bắt buộc: So sánh state Google trả về với state đang nằm trong Cookie
    stored_state = request.cookies.get("oauth_state")
    if not stored_state or not hmac.compare_digest(state, stored_state):
        return RedirectResponse("/?login_error=invalid_state")

    try:
        # Mang thẻ bài (code) đi đổi lấy access_token (Call ngầm Server-to-Server)
        token = await _exchange_code(code)

        # Dùng access_token để hỏi Google xem user này email là gì
        userinfo = await _get_userinfo(token)
        email = userinfo["email"]

        # Kiểm tra xem email này có nằm trong danh sách nhân viên của công ty không (Trong DB)
        user = await svc.login_or_reject(session, email, userinfo)

        # Cấp giấy thông hành (JWT)
        jwt_token = create_session(email)

        # Nhét giấy thông hành vào HttpOnly Cookie
        response.set_cookie(
            SESSION_COOKIE_NAME,
            jwt_token,
            httponly=True,        # 🔒 Khóa mồm JavaScript, cấm đọc!
            samesite="lax",       # 🔒 Giảm thiểu tấn công CSRF (Đọc kỹ note bên dưới)
            secure=COOKIE_SECURE, # 🔒 Bắt buộc dùng HTTPS trên production
            max_age=JWT_TTL_SEC,
        )
        return RedirectResponse("/")

    except UserNotWhitelisted:
        return RedirectResponse("/?login_error=not_whitelisted")
    except UserDisabled:
        return RedirectResponse("/?login_error=disabled")

3. JWT — JSON Web Token (Tờ giấy thông hành)

JWT bản chất là một chuỗi ký số (signed token) — Server dùng chìa khóa bí mật (secret) để đóng mộc, cấp cho Client giữ. Mỗi lần Client nhờ vả, Server sẽ soi lại cái mộc đó xem có đúng của mình cấp không.

Bóc trần cấu trúc JWT

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9   ← Phần mào đầu Header (base64)
.eyJzdWIiOiJ1c2VyQGV4YW1wbGUuY29tIiwiZXhwIjoxNzE0MDAwMDAwfQ==  ← Phần ruột Payload (base64)
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c  ← Chữ ký Signature (HMAC-SHA256)

Phần ruột (Payload) sau khi giải mã nó chỉ đơn giản là:

{
  "sub": "user@example.com",   // Chủ nhân của thẻ (subject)
  "exp": 1714000000             // Thời điểm thẻ hết hạn (Unix timestamp)
}

⚠️ SỰ THẬT CHẾT NGƯỜI: JWT payload HOÀN TOÀN KHÔNG BỊ MÃ HÓA — ai có chuỗi token đều có thể đem lên trang jwt.io giải mã và đọc sạch ruột gan bên trong. Chỉ có cái chữ ký (Signature) là không thể giả mạo được thôi. 👉 Tuyệt đối cấm nhét password, mã thẻ cào, hay thông tin bảo mật vào phần Payload của JWT!

Cách Server đóng mộc (Issue JWT)

# admin/src/services/auth_service.py
import jwt
from src.config import JWT_SECRET, JWT_TTL_SEC

def create_session(email: str) -> str:
    """Tạo JWT token cho phiên làm việc."""
    payload = {
        "sub": email,                               # Đánh dấu chủ sở hữu
        "exp": time.time() + JWT_TTL_SEC,           # Hạn dùng (ví dụ: 7 ngày)
    }
    return jwt.encode(payload, JWT_SECRET, algorithm="HS256")

def verify_session(token: str) -> str | None:
    """Soi mộc JWT. Nếu xịn thì nhả ra email, nếu fake/hết hạn thì trả None."""
    try:
        payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
        return payload.get("sub")
    except jwt.ExpiredSignatureError:
        return None  # Thẻ hết hạn
    except jwt.InvalidTokenError:
        return None  # Thẻ bị rách, bị bôi xóa (fake)

Soi mộc ở mỗi Request (Gác cổng)

# admin/src/auth.py
async def get_current_user(
    cookie_token: str | None = Cookie(default=None, alias="kcds_session"),
    session: AsyncSession = Depends(get_session),
    user_repo: UserRepository = Depends(get_user_repo),
) -> dict:
    if not cookie_token:
        raise HTTPException(401, "Chưa đăng nhập")

    # 1. Check chữ ký và Hạn dùng của JWT
    email = verify_session(cookie_token)
    if not email:
        raise HTTPException(401, "Phiên đăng nhập hết hạn")

    # 2. TUYỆT CHIÊU: Móc DB ra check lại quyền (role) CHÍNH XÁC NHẤT
    # Chúng ta không tin vào cái role (nếu có) nằm trong JWT!
    user = await user_repo.get_by_email(session, email)
    if user is None:
        raise HTTPException(401, "Tài khoản không tồn tại")
    if not user.get("enabled"):
        raise HTTPException(401, "Tài khoản đã bị vô hiệu hoá")

    return user  # {"email": ..., "role": ..., "enabled": ...}

Tại sao lại phải tốn công chọc DB thay vì nhét luôn quyền (role) vào JWT cho lẹ?

Hãy tưởng tượng: Lúc 10h sáng, Sếp giáng chức nhân viên A từ "Admin" xuống "User quèn".

- Nếu bạn lưu role="Admin" chết cứng trong JWT: Thằng A vẫn nghênh ngang cầm cái lệnh bài đó đi phá hệ thống suốt 7 ngày tiếp theo (vì JWT chưa hết hạn).
- Nếu bạn lấy role từ DB (như code trên): Ngay cú click tiếp theo của A lúc 10h01, hệ thống chọc DB thấy nó đã bị giáng chức → Lập tức đá văng nó ra!

Tiêu chí Cookie (Chế độ HttpOnly) Bearer Token (Gắn ở Header)
Chỗ giấu Trình duyệt tự giữ Bỏ vào localStorage / sessionStorage
JavaScript thò tay vào lấy? ❌ Chịu chết (Nhờ HttpOnly) ✅ Lấy dễ như bỡn
Độ lười biếng (Gửi tự động) ✅ Trình duyệt tự nhét vào mỗi request ❌ Coder phải tự viết code gắn Header
Rủi ro bị hack XSS (Mã độc JS) Thấp (JS không móc được thẻ) Cực Cao (Hacker chạy JS lụm mất thẻ)
Rủi ro bị hack CSRF (Dụ click link) Cao (Phải chặn bằng SameSite + CSRF token) Thấp

Project KCDS dùng HttpOnly Cookie vì chúng ta build Admin Panel chạy trên nền web. Kết hợp với cờ SameSite="Lax" là đủ bịt lỗ hổng CSRF mà không cần cấu hình lằng nhằng.

Đào sâu: Tại sao xài Lax chứ không phải Strict?

Chế độ SameSite Cookie sẽ được gửi đi khi... Đánh đổi (Trade-off)
Strict Chỉ khi user đang đứng sẵn trong app của bạn User đang ở Gmail, bấm vô link mở Admin Panel → Cookie bị cấm gửi đi → Bị đá ra trang Login dù phiên vẫn còn. Khách chửi thề ngay!
Lax (Khuyên dùng) Đang ở app + Hành vi mở trang mới (top-level navigation) Giải quyết ngon lành vụ click link từ Gmail. Vẫn đủ sức chặn các trò hack CSRF (gửi ngầm POST/DELETE từ trang web của Hacker).
None Trôi nổi tự do (Cross-site) Phải bật kèm Secure=True. Chỉ xài khi bạn nhúng API vào Iframe hoặc cho app bên thứ 3 xài.

Tóm lại: Lax là "điểm ngọt" (sweet spot) cân bằng hoàn hảo giữa bảo mật và trải nghiệm người dùng (UX).

Về phía Frontend, code khỏe re vì chả cần biết JWT là cái gì:

// admin/src/ui/src/modules/auth.js
export async function initAuth({ onAuthed, onUnauthed }) {
    // Nhờ { credentials: "include" }, trình duyệt tự động lôi Cookie ra trình diện
    const res = await fetch("/api/auth/me", { credentials: "include" });

    if (!res.ok) {
        onUnauthed();   // Rớt mạng, mất thẻ, hoặc bị đuổi việc → Bật màn Login
        return;
    }

    _me = (await res.json()).data;  // Ui chỉ cần biết user có quyền gì, không thèm quan tâm JWT.
    onAuthed();
}

5. Bùa Ký số HMAC — Trị căn bệnh của thẻ <audio>

Cái thẻ <audio src="..."> của HTML5 rất "ngu", nó không cho phép chúng ta đính kèm bất kỳ cái Header Authorization nào vào. Làm sao để cấm người ngoài nghe trộm nhạc của hệ thống?

Giải pháp: Cấp cho nó một cái URL đã được "Làm phép" (HMAC Signed URL).

# admin/src/services/signed_url.py
import hashlib
import hmac
import time
from src.config import AUDIO_STREAM_SECRET, AUDIO_STREAM_TTL_SEC

def sign_audio_url(audio_id: int, ttl_sec: int | None = None) -> tuple[str, int]:
    """Phù phép: Tạo ra một cái link nhạc có tuổi thọ (TTL)."""
    expires = int(time.time()) + (ttl_sec or AUDIO_STREAM_TTL_SEC)  # Cho sống 30 phút
    sig = _hmac(audio_id, expires)

    # Nhét chữ ký và hạn dùng thẳng vào đường link luôn
    return f"/api/audio/{audio_id}/stream?sig={sig}&expires={expires}", expires

def verify_audio_sig(audio_id: int, sig: str, expires: int) -> bool:
    """Soi bùa: Bùa giả hoặc bùa hết hạn là đuổi cổ."""
    if expires < int(time.time()):
        return False  # Bùa hết phép
    expected = _hmac(audio_id, expires)

    # Tuyệt kỹ compare_digest: So sánh thời gian hằng số, chống hacker soi password
    return hmac.compare_digest(sig, expected)

def _hmac(audio_id: int, expires: int) -> str:
    msg = f"{audio_id}:{expires}".encode()  # Gộp ID và hạn dùng lại
    return hmac.new(
        AUDIO_STREAM_SECRET.encode(),
        msg,
        hashlib.sha256,
    ).hexdigest()  # Đóng mộc!

Cách nó hoạt động:

1. Frontend (đang có Auth đàng hoàng): "Ê Server, cho xin link bài số 42!"
   → Server nhả link: "/api/audio/42/stream?sig=abc123&expires=1714000000"

2. Frontend nhét cái link đó vào thẻ: <audio src="/api/audio/42/stream?sig=abc123&expires=1714000000">
   Browser gọi GET tới link đó (Trần trụi, không có Cookie, chả có Header).

3. Server: Chạy hàm verify_audio_sig(42, "abc123", 1714000000)
   → Cầm secret tự tính lại HMAC xem có ra đúng "abc123" không? → Đúng thì cho stream file!

Tại sao làm thế này Hacker lại phải khóc thét?

  • Cái AUDIO_STREAM_SECRET chỉ có Server giấu kỹ trong bụng.
  • Hacker thấy link, muốn sửa ID 42 thành 43 để nghe trộm bài khác? Nó không có secret nên không thể chế ra chữ ký mới khớp được.
  • Hacker ăn cắp nguyên đường link mang chia sẻ? Đúng 30 phút sau link tự chết!

6. RBAC — Phân tầng giai cấp (Role-Based Access Control)

# admin/src/roles.py
ROLE_ADMIN = "admin"
ROLE_SUB_ADMIN = "sub_admin"
ROLE_USER = "user"
EDITOR_ROLES = (ROLE_ADMIN, ROLE_SUB_ADMIN)

# admin/src/auth.py
def require_user(user=Depends(get_current_user)):
    """Dân thường có tài khoản là vô được."""
    return user

def require_editor(user=Depends(get_current_user)):
    """Trưởng/Phó bảng — Quyền thêm/sửa/xóa nhạc."""
    if user["role"] not in EDITOR_ROLES:
        raise HTTPException(403, "Chỉ quản trị/phó quản trị mới thực hiện được")
    return user

def require_admin(user=Depends(get_current_user)):
    """Trùm cuối — Quyền sinh sát các tài khoản khác."""
    if user["role"] != ROLE_ADMIN:
        raise HTTPException(403, "Chỉ quản trị mới thực hiện được")
    return user
# Gác cửa ở Router
@router.get("", dependencies=[Depends(require_user)])            # Ai xem cũng được
@router.post("", dependencies=[Depends(require_editor)])         # Lãnh đạo mới được up nhạc
@router.delete("/{id}", dependencies=[Depends(require_editor)])  # Lãnh đạo mới được xóa nhạc

Ép luật "Chỉ có 1 Trùm" ngay từ dưới móng DB

Luật kinh doanh yêu cầu: Hệ thống chỉ được phép có duy nhất 1 thằng "Admin". Cách xịn nhất là dùng Partial Unique Index đè thẳng vào SQLite:

-- admin/src/db/schema.sql
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_single_admin
    ON users((CASE WHEN role='admin' THEN 1 END));
-- Cú pháp này nói với DB: Chỉ quan tâm mấy dòng có role='admin'.
-- UNIQUE → Không bao giờ cho phép 2 dòng cùng có role='admin' tồn tại.
-- Lập trình viên có code bug, DB cũng tự động đấm vào mồm không cho insert/update!

Tuyệt chiêu Truyền Ngôi (Resign) - Chuyển giao quyền lực an toàn

# admin/src/repositories/user_repo.py
async def swap_admin(
    self,
    session: AsyncSession,
    *,
    old_admin: str,
    old_new_role: str,
    successor: str,
) -> None:
    """Chiêu thức nguyên tử (Atomic): Lột quyền trùm cũ TRƯỚC, phong vương trùm mới SAU.

    Luật sinh tử: Phải làm đúng thứ tự này! Nếu bạn phong vương cho thằng mới trước
    → Trong một khoảnh khắc tíc-tắc, hệ thống sẽ có 2 Admin
    → Vi phạm ngay cái luật `idx_users_single_admin` dưới DB
    → DB chửi (IntegrityError) và Rollback ngay lập tức!
    """
    await session.execute(
        update(users).where(users.c.email == old_admin).values(role=old_new_role)
    )
    # Tới đoạn này: Hệ thống VÔ CHỦ (0 admin)

    await session.execute(
        update(users).where(users.c.email == successor).values(role="admin")
    )
    # Tới đoạn này: Trùm mới đã lên ngôi (1 admin)

    await session.commit()   # Bấm nút xác nhận cả 2 thao tác cùng lúc!
    # Lỡ câu lệnh thứ 2 bị lỗi văng ra → hàm session.commit() không được gọi → DB tự động Rollback như chưa có cuộc chia ly!

7. Bí kíp Security xương máu từ Project

1. Secret phải giấu vào file môi trường, cấm ghi bậy vào code:

# ✅ Đúng chuẩn
JWT_SECRET = os.getenv("JWT_SECRET", "")

# ❌ Tự hủy
JWT_SECRET = "my-secret-key-123"

2. Đứt cầu chì sớm (Fail-fast) nếu lỡ quên gắn chìa khóa:

# admin/src/config.py
def validate():
    required = [
        ("JWT_SECRET", JWT_SECRET),
        ("AUDIO_STREAM_SECRET", AUDIO_STREAM_SECRET),
        ("GOOGLE_CLIENT_ID", GOOGLE_CLIENT_ID),
    ]
    missing = [name for name, val in required if not val]
    if missing:
        raise RuntimeError(f"Server từ chối chạy vì thiếu biến bảo mật: {missing}")

3. So sánh bằng thời gian hằng số (chống hacker bấm giờ):

# ✅ Khuyên dùng — So sánh đều đặn từ đầu tới cuối (Constant-time)
hmac.compare_digest(provided_sig, expected_sig)

# ❌ Sai lầm chết người — Check kiểu này bị sai 1 ký tự là nó ngưng luôn.
# Hacker có thể đo thời gian server phản hồi để đoán ngược lại mật khẩu!
provided_sig == expected_sig

4. Dùng HttpOnly Cookie — Cắt đường sống của JavaScript:

response.set_cookie(
    "kcds_session",
    jwt_token,
    httponly=True,   # ← Bùa hộ mệnh chống XSS
    samesite="lax",
    secure=True,     # Chỉ chạy qua cáp quang mã hóa HTTPS
)

5. Đính kèm biến State chống CSRF trong OAuth:

state = secrets.token_urlsafe(32)  # Đẻ ra chuỗi ngẫu nhiên không thể mò được
# Lưu state → Gửi sang Google → Google bưng về → Check xem có trùng khớp không!


Nguyên lý tổng quát

Kỹ thuật trong bài Nguyên lý Bảo mật / Bài 0 Áp dụng đi nơi khác
OAuth2 nhét state Defense in Depth (Phòng thủ đa lớp) Tích hợp Facebook, Apple, Github... bắt buộc phải có state để chặn CSRF.
JWT không nhét role, moi từ DB ra Least Privilege (Quyền hạn tối thiểu) Chấp nhận tốn 1 chút xíu hiệu năng đổi lấy khả năng "Trảm" quyền user ngay lập tức.
HttpOnly + SameSite=Lax Defense in Depth Bất cứ hệ thống Web nội bộ nào xài Session cũng nên set config này.
Link Audio ký HMAC + có hạn dùng Least Privilege Giống hệt cách hoạt động của S3 Presigned URL hay CloudFront Signed URL.
Xài hmac.compare_digest Fail Secure (Mặc định phòng ngự) Cứ dính tới so sánh password, hash, token thì tuyệt đối đừng xài dấu ==.
Xài secrets.token_urlsafe Don't roll your own crypto Đừng cố tỏ ra thông minh tự chế hàm random. Xài thư viện mã hóa chuẩn của ngôn ngữ.
Check secret bằng config.validate() Fail Fast (Chết sớm còn hơn nổ bom nổ chậm) Để lọt app chạy mà thiếu secret = thảm họa bảo mật. Chặn ngay từ cửa.
Index Unique 1 Admin dưới DB Fail Secure at DB level Đẩy giới hạn nghiệp vụ xuống sâu nhất có thể, Developer lỡ code sai DB cũng gánh còng lưng.

Hãy nhìn vào Checklist bài 10 §4. Tóm gọn lại như vầy:

  • JWT: Trạng thái tự do (stateless), chia server (scale ngang) rất dễ, nhưng lỡ cấp quyền nhầm thì Rất khó thu hồi (Phải đợi hết hạn).
  • Session Cookie: Cấp quyền/Hạ quyền trong 1 nốt nhạc, nhưng phải tốn tiền duy trì 1 con RAM trung tâm (ví dụ Redis) để lưu thông tin nếu chạy nhiều server.

👉 App KCDS chơi một nước cờ trung gian (pragmatic): Xài JWT làm thẻ giữ phiên, nhưng Role thì moi tươi từ DB ra. Đạt được điểm ngọt: Không cần cài Redis phức tạp, mà vẫn thu hồi quyền user ngay lập tức được!

Danh sách kiểm tra bảo mật (Security Checklist) khi code Auth

  • Chìa khóa (Secret) đã đủ dài và random ngẫu nhiên chưa (≥ 32 bytes)?
  • Chìa khóa đã được cất ra file .env, tuyệt đối không nằm phơi thây trong file code chưa?
  • Token (hoặc link cấp phát) đã được đặt hạn dùng (TTL) ngắn gọn chưa?
  • Cookie đã bật cờ HttpOnly, Secure, và SameSite chưa?
  • Đã xài hàm constant-time để so sánh các chuỗi bảo mật chưa?
  • Hàm Login Google đã kẹp theo biến state để chống CSRF chưa?
  • Khi khởi động Server, có check xem các biến môi trường nhạy cảm đã đủ mặt chưa?
  • Lỗi đăng nhập có bị log cả password/token ra màn hình không (Tuyệt đối không nhé)?

Bài tập áp dụng

Hãy rèn tay bằng cách tự code một bộ khung Auth bằng JWT cho một API quản lý Blog:

# auth.py
import hashlib
import hmac
import jwt
import time
import secrets

SECRET = "your-secret-key"  # Chạy thực tế thì phải bứng từ file env ra nhé!
TTL = 3600  # Cho thẻ sống 1 tiếng thôi

def create_token(user_id: int) -> str:
    return jwt.encode(
        {"sub": user_id, "exp": time.time() + TTL},
        SECRET, algorithm="HS256"
    )

def verify_token(token: str) -> int | None:
    try:
        payload = jwt.decode(token, SECRET, algorithms=["HS256"])
        return payload["sub"]
    except jwt.InvalidTokenError:
        return None

# Dependency nhét vào FastAPI
async def get_current_user(
    authorization: str | None = Header(default=None),
    db: Session = Depends(get_db),
) -> dict:
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(401, detail="Vui lòng mang thẻ tới")

    token = authorization.removeprefix("Bearer ")
    user_id = verify_token(token)

    if not user_id:
        raise HTTPException(401, "Thẻ giả hoặc hết hạn")

    user = await db.get_user(user_id)
    if not user:
        raise HTTPException(401, detail="Tài khoản bốc hơi rồi")

    return user

Thử thách tư duy:

  1. Tại sao mình kiên quyết dặn bạn KHÔNG nhét quyền (role) vào thẳng cục Payload của JWT? Kể tên ưu/nhược điểm của vụ này?
  2. Hàm hmac.compare_digest() có gì thần thánh hơn phép so sánh ==? Tấn công canh thời gian (Timing attack) là cái quái gì?
  3. Tại sao trong cái hàm đổi ngôi Admin, mình bắt buộc phải hạ bệ ông Admin cũ TRƯỚC, rồi mới được phép đưa ông mới lên?
  4. Đặt Cookie SameSite=Lax thì cứu app của bạn khỏi loại tấn công khốn nạn nào của Hacker?