🔵 Đăng nhập bằng Google: OAuth 2.0 ở mức thực dụng

By Better Wise

Bài này mở đầu module cuối, sau khi bạn đã có session/JWT, refresh token rotation và RBAC ở tay. Đăng nhập Google không thay thế mấy thứ đó: nó chỉ là một cách chứng minh danh tính người dùng, xong rồi bạn vẫn phát session hoặc JWT của chính hệ thống mình. Suốt bài, mình sẽ hỏi đúng ba câu mà mình hay hỏi khi debug: token này ai giữ, sống bao lâu, bị lộ thì sao?

Sau bài này bạn sẽ:

  • Tạo được OAuth client với redirect URI và scope đúng
  • Vẽ lại được luồng Authorization Code + PKCE và biết code đổi ở đâu
  • Kiểm tra id_token trước khi tin bất kỳ claim nào
  • Map sub của Google về user trong database, xử lý trùng email an toàn

Bốn vai trong luồng, ai giữ cái gì

Đừng học thuộc lòng OAuth. Chỉ cần nhớ bốn chỗ:

  1. Người dùng: bấm "Đăng nhập bằng Google", gõ mật khẩu ở trang của Google, không phải trang của bạn.
  2. Frontend của bạn: chỉ giữ Client ID, redirect_uri, state, code_verifier tạm thời.
  3. Google: phát authorization code, sau đó phát id_token và access_token.
  4. Backend của bạn: giữ Client Secret, đổi code lấy token, verify, rồi cấp phiên đăng nhập của riêng bạn.
flowchart TD
  U["🧑 Người dùng"] --> F["🔐 Frontend"]
  F --> G["🔑 Google OAuth"]
  G --> C["📥 Callback"]
  C --> B["🖥️ Backend: đổi code và verify"]
  B --> D["🗄️ users + oauth_accounts"]
  D --> S["🎫 Session/JWT của bạn"]

Điểm mấu chốt: authorization code đổi lấy token phải xảy ra ở backend. Nếu bạn thấy code chạy kiểu fetch thẳng tới oauth2.googleapis.com/token từ trình duyệt kèm client_secret, đó là chỗ rò rỉ.

Tạo OAuth client: ba giá trị và một sai lầm

Trong Google Cloud Console bạn khai báo:

Giá trị Được lộ ra client? Ghi chú
Client ID Có Nằm trong URL authorization
Client Secret Không Chỉ ở backend, không nhét vào biến môi trường của frontend
Authorized redirect URI Có Phải khớp chính xác từng ký tự, không wildcard

Scope tối thiểu cho đăng nhập là openid email profile. Xin thêm scope là xin thêm quyền đọc dữ liệu người ta, hãy xin đúng cái mình dùng.

Warning: redirect_uri dùng wildcard hoặc nhận URL bất kỳ từ query là lỗ hổng kinh điển: kẻ tấn công thay redirect_uri trỏ về domain của hắn để hứng code. Luôn so khớp với một danh sách trắng cố định.

Bài tập 1. Bạn cần đưa thông tin OAuth client vào code frontend. Giá trị nào được phép nằm trong bundle JS, giá trị nào tuyệt đối không?

Gợi ý đáp án

Client ID được phép (nó vốn công khai trong URL). Client Secret không được, chỉ backend giữ. redirect_uri phải khai báo cố định ở cả hai phía.

Luồng Authorization Code + PKCE

Với web app có backend, luồng đúng là:

  1. Frontend tạo state ngẫu nhiên và code_verifier, lưu tạm ở sessionStorage.
  2. Chuyển hướng tới accounts.google.com/o/oauth2/v2/auth kèm client_id, redirect_uri, scope, state, code_challenge.
  3. Người dùng đồng ý, Google redirect về /auth/google/callback?code=...&state=....
  4. Backend kiểm tra state khớp, rồi POST code + code_verifier + client_secret lên token endpoint.
  5. Google trả id_token (JWT) và access_token. Backend verify id_token.
  6. Backend map sub về user, tạo session/JWT của bạn, redirect về app.

Tham số state dùng để chống _____ trên luồng OAuth: nếu không kiểm tra, kẻ tấn công có thể ép trình duyệt nạn nhân hoàn tất đăng nhập vào tài khoản của kẻ đó (login CSRF).

Verify id_token trước khi tin bất cứ thứ gì

Frontend có thể gửi lên bất cứ payload nào. Chỉ id_token do backend nhận trực tiếp từ Google (qua kênh đổi code) mới đáng tin. Tối thiểu kiểm tra:

  • iss là accounts.google.com hoặc https://accounts.google.com
  • aud khớp đúng Client ID của bạn
  • exp chưa hết hạn
  • nonce khớp nếu bạn có gửi
  • chữ ký hợp lệ theo JWKS của Google, hoặc dùng thư viện chính thức của Google để verify

Claim _____ là định danh ổn định của người dùng Google. Đừng dùng email làm khóa chính: người ta có thể đổi email, còn sub thì không. Và đừng quên: frontend gửi email lên API là không đủ để đăng nhập.

Bài tập 2. Backend nhận id_token có payload: iss = accounts.google.com, aud = 123-xyz.apps.googleusercontent.com (khác Client ID của bạn), exp là 10 phút trước, email_verified = true. Xử lý thế nào?

Gợi ý đáp án

Từ chối. aud sai nghĩa là token phát cho ứng dụng khác, exp đã hết hạn. Chỉ cần một điều kiện sai là trả 401, không cố "nới lỏng cho dễ test".

Map tài khoản Google về user trong database

Tách danh tính khỏi tài khoản. Bảng oauth_accounts giữ liên kết:

Cột Ví dụ Ghi chú
provider google
provider_user_id 110234... chính là claim sub, unique cùng provider
user_id 42 FK tới users
email_at_provider [email protected] chỉ để hiển thị, không dùng làm khóa

Luồng xử lý an toàn:

  1. Tìm (provider='google', provider_user_id=sub). Có thì đăng nhập user đó.
  2. Chưa có thì tìm users.email. Nếu trùng, chỉ tự liên kết khi email đã được verify, và tốt nhất vẫn yêu cầu thêm một bước xác nhận (đăng nhập mật khẩu một lần, hoặc bấm link trong email).
  3. Chưa có gì thì tạo users mới, rồi tạo dòng oauth_accounts.

Sau bước này bạn cấp refresh token của chính mình, có rotation và thu hồi như module 2. Token Google không phải token phiên của bạn.

Bài tập 3. Một người đăng nhập Google bằng email trùng với tài khoản email/mật khẩu đã tồn tại trong hệ thống. Bạn viết gì trong code?

Gợi ý đáp án

Không auto-link mù. Nếu email_verified = true: yêu cầu người dùng xác nhận quyền sở hữu (nhập mật khẩu hiện tại hoặc bấm link xác nhận qua email) rồi mới gắn oauth_accounts. Nếu email_verified = false: từ chối liên kết và coi như tài khoản mới.

Năm vết xe đổ khi gắn Google Login

  • Đổi code ở frontend: secret lộ, code bị dùng lại. Code đổi một lần, và phải ở server.
  • Tin email từ client: ai cũng POST được {"email":"admin@..."}. Chỉ tin id_token đã verify.
  • Bỏ qua state: login CSRF, người dùng lặng lẽ đăng nhập vào tài khoản của kẻ khác.
  • Log nguyên id_token và code: log là nơi rò rỉ chậm mà dai. Cắt bớt hoặc băm trước khi log.
  • Cho redirect_uri nhận tham số động: mở toang cửa cho kẻ hứng code.

Mẹo của mình: viết một hàm verifyGoogleIdToken(token) duy nhất, mọi chỗ cần đăng nhập Google đều đi qua đó, và viết test cho ba case sai (aud, exp, chữ ký). Sau này AI có sinh thêm code auth, bạn vẫn có một chốt chặn để đối chiếu.

Chốt lại và sang bài kiểm tra code AI

Bốn điều cần mang theo: secret ở backend, code đổi ở backend, id_token phải verify iss/aud/exp/chữ ký, và sub là khóa map về user chứ không phải email. Luồng đăng nhập Google của bạn giờ đã đủ để ghép vào sản phẩm.

Bài tiếp theo sẽ rất thực dụng: khi bạn để AI viết luồng auth này, những sai lầm nào hay xuất hiện và checklist nào để bắt chúng trước khi phát hành.

Backend phải kiểm tra claim _____ khớp Client ID của mình và _____ chưa hết hạn trước khi tin id_token. Giá trị _____ chỉ được nằm ở backend, không bao giờ đưa vào bundle frontend.

Front: Trong OAuth client của Google, giá trị nào được phép xuất hiện ở frontend? Back: Client ID; Client Secret chỉ nằm ở backend.

Front: Authorization code dùng để làm gì và đổi ở đâu? Back: Đổi một lần lấy token, tại backend, kèm redirect_uri và client_secret hoặc code_verifier.

Front: Claim nào là định danh ổn định của người dùng Google? Back: sub, không phải email.

Front: Bốn thứ tối thiểu phải kiểm tra trong id_token? Back: iss, aud, exp và chữ ký theo JWKS của Google.

Front: Tham số state chống lại kiểu tấn công nào? Back: CSRF trên luồng OAuth, tức login CSRF.

Front: Frontend gửi email đã đăng nhập Google lên API, backend nên làm gì? Back: Không tin; chỉ tin id_token do backend tự đổi code và verify.

Front: Khóa duy nhất của bảng oauth_accounts là gì? Back: Cặp (provider, provider_user_id), ví dụ ('google', sub).

Front: Có nên tự động liên kết Google vào user cùng email? Back: Không auto-link mù; chỉ liên kết khi email đã verify và có bước xác nhận.

Front: Sau khi đăng nhập Google xong, app cấp gì cho client? Back: Session hoặc JWT kèm refresh token của chính hệ thống bạn.

Front: redirect_uri phải được cấu hình thế nào? Back: Khớp chính xác với danh sách trắng đã khai báo, không wildcard, không nhận URL động.

Front: Scope tối thiểu cho đăng nhập Google? Back: openid email profile.

Front: Vì sao không dùng email làm khóa chính của user Google? Back: Email có thể đổi; sub thì ổn định và gắn với tài khoản Google.

Front: PKCE dùng để làm gì? Back: Ràng buộc code với đúng client đã bắt đầu luồng, chống đánh tráo code.

Front: Sai lầm hay gặp nhất khi gắn Google Login là gì? Back: Đổi code hoặc giữ client_secret ở frontend, và tin email do client gửi lên.