Nút "Đăng nhập bằng Google" trên trang này trông đơn giản: bấm, chọn tài khoản, quay lại là đã đăng nhập. Phía sau là một giao thức có ba bên không tin nhau hoàn toàn, chạy qua trình duyệt của bạn, một môi trường mà không bên nào kiểm soát. Bài này đi từng bước qua giao thức đó, theo đúng code của viet.sarl.
Lộ trình: OAuth giải quyết bài toán gì, các vai, luồng authorization code từng bước, state, PKCE (kèm mô phỏng), các loại token, OpenID Connect, những luồng đã bị bỏ, các sai lầm hay gặp khi tích hợp, rồi cách trang này quyết định một lần đăng nhập Google rơi vào tài khoản nào. Mô phỏng chạy bằng Python 3.12 trên máy thật.
Bài toán: cho phép mà không đưa mật khẩu
Trước OAuth, một ứng dụng muốn đọc danh bạ Gmail của bạn sẽ hỏi mật khẩu Gmail. Ứng dụng đó có toàn quyền tài khoản, mãi mãi, cho tới khi bạn đổi mật khẩu, và bạn không có cách nào chỉ cho nó đọc danh bạ mà thôi.
OAuth 2.0 (2012) thay việc đó bằng ủy quyền có giới hạn: bạn đăng nhập trực tiếp với Google, Google hỏi "cho ứng dụng X đọc danh bạ không?", rồi đưa cho X một access token chỉ làm được đúng việc đó, có hạn dùng, và thu hồi được. Ứng dụng X không bao giờ thấy mật khẩu.
Một điểm dễ nhầm: OAuth là giao thức ủy quyền (authorization), không phải xác thực (authentication). Access token nói "người cầm token được làm việc Y", chứ không nói "người cầm token là ai". Phần "là ai" do OpenID Connect thêm vào, ở dưới.
Các vai
| Vai | Trong ví dụ của trang này |
|---|---|
| Resource owner (chủ tài nguyên) | Bạn |
| Client (ứng dụng) | viet.sarl |
| Authorization server (máy chủ ủy quyền) | accounts.google.com: đăng nhập, hỏi đồng ý, phát token |
| Resource server (máy chủ tài nguyên) | API của Google trả thông tin hồ sơ (userinfo) |
viet.sarl được Google cấp một client_id (công khai) và một client_secret (bí mật, nằm trong .env trên máy chủ), cùng danh sách redirect URI đã đăng ký trước, như https://viet.sarl/auth/oauth/google/callback.
Luồng authorization code, từng bước
Trình duyệt viet.sarl Google
│ bấm "Google" ──►│
│ │ tạo state + verifier, lưu vào cookie
│◄── chuyển hướng tới Google (client_id, redirect_uri, scope, state, code_challenge)
│ ─────────────────────────────────────────────────►│ đăng nhập, đồng ý
│◄── chuyển hướng về callback?code=...&state=... ────│
│ ───────────────►│ kiểm state với cookie
│ │ ── POST code + client_secret + verifier ──►│ kiểm tra
│ │ ◄── access_token ──────────────────────────│
│ │ ── GET userinfo (Bearer token) ────────────►│
│ │ ◄── sub, email, email_verified, name ──────│
│◄── đặt cookie đăng nhập, chuyển về trang cũ
Bước 1, trang tạo hai số ngẫu nhiên bằng secrets (bài 1) và gửi người dùng sang Google:
def new_state() -> tuple[str, str]:
"""(state, PKCE code verifier)."""
return secrets.token_urlsafe(24), secrets.token_urlsafe(48)
params = {
"client_id": self.client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": self.scope, # "openid email profile"
"state": state,
"code_challenge": challenge, # băm của verifier, ở dưới
"code_challenge_method": "S256",
}
state và verifier được lưu trong cookie oauth_state. Đây là một JWT có chữ ký, HttpOnly, chỉ gửi tới /auth/oauth và sống 10 phút, nên máy chủ không cần lưu gì.
Bước 2, Google gửi người dùng về callback với một authorization code: một mã ngắn hạn, dùng một lần. Code này đi qua trình duyệt, tức qua thanh địa chỉ, lịch sử duyệt web và có thể cả log, nên bản thân nó không phải là token.
Bước 3, máy chủ của trang đổi code lấy token qua một kênh trực tiếp từ máy chủ tới Google (TLS, bài 5), kèm client_secret và code_verifier. Token không bao giờ đi qua trình duyệt.
state: chống bị đăng nhập vào tài khoản của người khác
Khi callback được gọi, làm sao trang biết lần chuyển hướng đó bắt đầu từ chính trình duyệt này? Nếu không kiểm tra, một trang khác có thể dẫn trình duyệt của bạn tới callback với code từ tài khoản của họ. Bạn sẽ bị đăng nhập vào tài khoản của người khác mà không biết, và mọi thứ bạn làm sau đó, như lưu điểm hay nhập thông tin, đều nằm trong tài khoản của họ. Đây là một dạng CSRF trên luồng đăng nhập.
state chặn việc đó: giá trị ngẫu nhiên gửi đi phải khớp với giá trị quay về, và giá trị gốc nằm trong cookie của chính trình duyệt. Trang này so bằng phép so sánh không lộ thời gian (bài 2):
saved = tokens.read_oauth_state(oauth_state) if oauth_state else None
if not (code and state and saved and secrets.compare_digest(state, saved.state)):
... # từ chối, quay về /login?error=oauth
PKCE: code bị lộ cũng không dùng được
PKCE (Proof Key for Code Exchange, đọc là "pixy", RFC 7636) ban đầu được làm cho ứng dụng di động, nơi không giữ được client_secret và một ứng dụng khác trên máy có thể đăng ký cùng kiểu redirect. Giờ nó được khuyên dùng cho mọi client:
- Trước khi chuyển hướng, client tạo một verifier ngẫu nhiên và giữ kín.
- Client gửi đi challenge = base64url(SHA-256(verifier)). Hàm băm một chiều nên challenge không để lộ verifier.
- Máy chủ ủy quyền ghi nhớ challenge cùng với code.
- Khi đổi code, client phải gửi verifier. Máy chủ băm nó và so với challenge đã nhớ.
Ai lấy được code từ thanh địa chỉ hay từ log cũng không có verifier, vì verifier chưa bao giờ rời khỏi máy chủ của client (với trang này, nó nằm trong cookie HttpOnly đã ký).
Mô phỏng: một máy chủ ủy quyền nhỏ bằng Python, nhớ challenge theo từng code, xóa code ngay sau lần dùng đầu tiên, và kiểm verifier bằng compare_digest. Chạy ba trường hợp: người có code nhưng không có verifier (attacker), ứng dụng thật (real app), và dùng lại code lần hai (replay):
verifier len 64 challenge gAh5gt-r1nrlDr8mb4PoaaNfhGJZnjaTWILcd9TuseQ
attacker : invalid_grant (PKCE verifier mismatch)
real app : access_token=x6CnFXDOzQs
replay : invalid_grant (unknown or used code)
Ba quy tắc máy chủ ủy quyền phải giữ: code gắn với challenge, code chỉ dùng một lần, và code gắn với client_id và redirect_uri. Thiếu một trong ba là mở ra một lỗ.
Các loại token
| Token | Cho ai | Nội dung | Thời hạn |
|---|---|---|---|
| Access token | Resource server (API) | Quyền gì, cho client nào | Ngắn: vài phút tới một giờ |
| Refresh token | Authorization server | Dùng để xin access token mới | Dài, và thu hồi được |
| ID token (OIDC) | Client | Người dùng là ai: sub, email, name... |
Ngắn, dùng một lần lúc đăng nhập |
Scope giới hạn access token: trang này chỉ xin openid email profile, tức biết bạn là ai, không đọc được Gmail hay Drive. Đây là nguyên tắc quyền tối thiểu: xin ít nhất có thể, vì token bị lộ chỉ làm được những gì scope cho phép, và người dùng cũng dễ đồng ý hơn.
Lưu ý rằng các token của Google chỉ dùng trong lúc đăng nhập. Sau đó trang này phát token của chính nó (cookie phiên riêng, hạn ngắn) và không giữ lại token của Google.
OpenID Connect: thêm phần "là ai"
OpenID Connect (OIDC, 2014) là một lớp mỏng trên OAuth 2.0. Thêm scope openid thì máy chủ ủy quyền trả về thêm một ID token: một JWT do Google ký, chứa định danh người dùng (sub, ổn định suốt đời tài khoản), email, việc email đã được xác minh hay chưa, và aud (token này dành cho client nào).
Client có hai cách biết người dùng là ai:
- Kiểm ID token: kiểm chữ ký bằng khóa công khai của Google (công bố tại một địa chỉ JWKS), kiểm
iss,aud,expvànonce. - Gọi endpoint userinfo bằng access token vừa nhận.
Trang này chọn cách 2. Comment trong code giải thích vì sao bỏ qua được việc kiểm chữ ký:
# Read straight from the provider over TLS with our own token, so no ID-token signature check needed
info = await client.get(self.userinfo_url, headers={"Authorization": f"Bearer {token.json()['access_token']}"})
Thông tin đến thẳng từ Google qua TLS, bằng một token mà chính trang vừa đổi được bằng code, secret và verifier. Không có chỗ nào để người khác chen vào. Cái giá là thêm một request mạng mỗi lần đăng nhập, không đáng kể.
Định danh dùng để liên kết tài khoản là sub, không phải email. Email có thể đổi, và một địa chỉ có thể được cấp lại cho người khác. sub thì không. Bảng oauth_account có ràng buộc UNIQUE(provider, provider_user_id) để một danh tính Google chỉ thuộc về một tài khoản.
Những luồng đã bị bỏ
- Implicit flow: máy chủ ủy quyền trả access token thẳng trong URL (sau dấu
#), không có bước đổi code. Token nằm trong lịch sử trình duyệt, có thể lọt qua headerReferer, và không có cách nào gắn nó với đúng client. Từng là cách mặc định cho ứng dụng chạy hoàn toàn trên trình duyệt. Giờ thì dùng authorization code + PKCE. - Resource owner password credentials: client hỏi thẳng mật khẩu của người dùng rồi gửi cho máy chủ ủy quyền, tức quay lại đúng bài toán OAuth sinh ra để giải.
OAuth 2.1 (đang hoàn thiện) gom các bài học này lại: bỏ hai luồng trên, bắt buộc PKCE cho mọi luồng authorization code, bắt buộc so redirect URI chính xác từng ký tự, và yêu cầu refresh token cho client công khai phải được xoay vòng hoặc gắn với thiết bị.
Sai lầm hay gặp khi tích hợp
| Sai lầm | Hậu quả | Cách tránh |
|---|---|---|
Không kiểm state |
Bị đăng nhập vào tài khoản người khác | Sinh ngẫu nhiên, lưu ở cookie, so bằng compare_digest |
| Redirect URI so lỏng (tiền tố, ký tự đại diện) | Code bị gửi tới trang của kẻ khác | Đăng ký URI đầy đủ, máy chủ so chính xác |
Tham số next nhận URL bất kỳ |
Open redirect: link thật của trang dẫn người dùng sang trang lừa đảo | Chỉ nhận đường dẫn nội bộ |
| Tin email mà nhà cung cấp chưa xác minh | Chiếm tài khoản sẵn có có cùng email | Chỉ liên kết khi email_verified là đúng |
Liên kết bằng email thay vì sub |
Đổi hoặc tái cấp email là đổi chủ tài khoản | Dùng sub |
| Xin scope rộng | Token bị lộ làm được nhiều việc | Quyền tối thiểu |
Trang này xử lý từng dòng. Tham số ?next= chỉ nhận đường dẫn bắt đầu bằng / trên chính trang (safe_next). Dòng "tin email chưa xác minh" được xử lý kỹ nhất, phần dưới.
Một lần đăng nhập Google rơi vào tài khoản nào
sign_in_with_provider kiểm theo thứ tự:
- Danh tính Google này đã liên kết với một tài khoản → đăng nhập vào tài khoản đó.
- Đang có người đăng nhập → liên kết danh tính với họ.
- Có tài khoản cùng email và Google xác nhận email đã được xác minh → liên kết với tài khoản đó.
- Không có → tạo tài khoản mới.
Bước 3 có một chi tiết tinh tế. Giả sử ai đó đăng ký bằng mật khẩu với email [email protected] mà không phải là An, vì trang chưa xác minh email đó. Sau đó An thật đăng nhập bằng Google. Nếu chỉ liên kết đơn giản, người đăng ký trước vẫn giữ một mật khẩu vào tài khoản của An. Code xử lý:
if user is not None and not user.email_verified:
# Nobody proved they own this email when the account was made, but the provider just did. Drop the
# password so whoever registered it can't keep a way in to the real owner's account.
password_removed = user.password_hash is not None
await session.execute(
update(User).where(User.id == user.id).values(email_verified=True, password_hash=None)
)
Ngược lại, nếu Google báo email chưa xác minh mà trùng một tài khoản có sẵn, đăng nhập bị từ chối: một email chưa ai kiểm tra không chứng minh được quyền sở hữu tài khoản.
Tóm lại
- OAuth 2.0 là ủy quyền có giới hạn, không đưa mật khẩu. OpenID Connect thêm phần "người dùng là ai" (ID token, userinfo).
- Luồng nên dùng: authorization code + PKCE. Code đi qua trình duyệt, token đi trực tiếp giữa các máy chủ qua TLS.
statechặn việc bị đăng nhập vào tài khoản người khác. PKCE làm code bị lộ trở nên vô dụng nếu thiếu verifier (mô phỏng:invalid_grant). Code chỉ dùng một lần.- Xin scope tối thiểu. Liên kết tài khoản bằng
sub, không bằng email, và chỉ tin email đã được xác minh. - Implicit flow và password grant đã bị bỏ. OAuth 2.1 bắt buộc PKCE và so redirect URI chính xác.
Bài tiếp theo chuyển từ trình duyệt sang dòng lệnh: SSH. Khóa máy chủ và known_hosts, đăng nhập bằng khóa ed25519, thuật toán trao đổi khóa hậu lượng tử mà OpenSSH đã bật sẵn, ssh-agent, và gia cố một máy chủ.