OAuth2 là chuẩn đằng sau nút "Đăng nhập bằng Google". Nó cho phép một ứng dụng truy cập tài khoản của bạn ở một dịch vụ khác mà không thấy mật khẩu của bạn. OIDC (OpenID Connect) là lớp mỏng trên OAuth2 thêm phần "bạn là ai" (danh tính) vào phần "được làm gì" (quyền). Bài này đo cơ chế của luồng OAuth2 chuẩn — authorization code — và ba chỗ hay bị dùng sai, bằng một máy chủ OAuth2 mô phỏng đầy đủ cơ chế bảo mật.

Toàn bộ chạy trong container tự dựng, tự dọn. Không nhắm vào hệ thống của ai.

Luong authorization code OAuth2 va vai tro cua PKCE

Luồng authorization code, rút gọn

Ba bên: bạn (trình duyệt), ứng dụng (muốn truy cập dữ liệu của bạn), và máy chủ ủy quyền (Google, giữ tài khoản của bạn). Luồng:

  1. Ứng dụng chuyển bạn tới máy chủ ủy quyền để đăng nhập và đồng ý.
  2. Máy chủ chuyển bạn về ứng dụng kèm một authorization code ngắn hạn.
  3. Ứng dụng đổi code đó lấy token ở kênh sau (trực tiếp server-tới-server).

Điểm tinh tế: code đi qua trình duyệt của bạn (bước 2), nên nó có thể bị lộ; token thì đi ở kênh sau (bước 3), an toàn hơn. Cả cơ chế bảo mật xoay quanh việc làm cho một code bị lộ trở nên vô dụng.

Số đo

Ba lần chạy, kết quả giống hệt:

Tình huống Kết quả
Đổi code lấy token (verifier đúng) thành công
Dùng lại cùng một code bị chặn — code đã dùng
Đổi redirect_uri sang trang khác bị chặn — không khớp
Kẻ tấn công có code nhưng verifier sai (có PKCE) bị chặn
Kẻ tấn công có code (không PKCE) thành công — lỗ hổng!

Điều đáng nhớ

Authorization code chỉ dùng được một lần. Dòng thứ hai: đổi code lần đầu thành công, dùng lại cùng code bị chặn. Điều này giới hạn thiệt hại nếu code bị lộ — kẻ tấn công phải dùng nó trước ứng dụng thật, và ngay khi một bên dùng, bên kia mất cơ hội. Máy chủ ủy quyền đánh dấu code "đã dùng" ngay lần đổi đầu tiên.

redirect_uri phải khớp chính xác — đây là hàng rào chống chuyển hướng. Máy chủ chỉ trả code về đúng địa chỉ đã đăng ký trước. Nếu kẻ tấn công thử đổi redirect_uri sang trang của nó để code rơi vào tay mình, máy chủ từ chối vì không khớp. Đây là lý do các máy chủ OAuth2 bắt đăng ký trước danh sách redirect URI chính xác, và không chấp nhận khớp mờ (*, tiền tố) — một khớp lỏng lẻo ở đây là một lỗ hổng chiếm tài khoản.

PKCE là thứ chặn tấn công chặn-code — và không có nó là một lỗ hổng thật. Đây là hai dòng cuối bảng, và chúng đối xứng nhau một cách sáng tỏ. PKCE (Proof Key for Code Exchange) hoạt động thế này: trước khi bắt đầu, ứng dụng sinh một bí mật ngẫu nhiên (verifier), gửi bản băm của nó (challenge) kèm yêu cầu. Khi đổi code lấy token, ứng dụng phải trình bản gốc của bí mật. Máy chủ băm lại và so.

Kết quả đo: có PKCE, kẻ tấn công chặn được code nhưng không có verifier gốc (nó chỉ thấy bản băm đi qua trình duyệt) — bị chặn. Không PKCE, chỉ cần cầm code là đổi được token — thành công, một lỗ hổng đầy đủ. PKCE biến "chặn được code là thắng" thành "chặn được code vẫn vô dụng", vì code giờ phải đi kèm một bí mật mà chỉ ứng dụng gốc có.

Vì sao PKCE hoạt động: băm một chiều

Điều làm PKCE hiệu quả là một tính chất đã gặp ở bài băm mật khẩu: băm đi một chiều. Ứng dụng gửi challenge = SHA256(verifier) qua trình duyệt — phần này công khai, kẻ tấn công thấy được. Nhưng để đổi code lấy token, phải trình verifier gốc, và từ challenge không suy ngược ra verifier (đó là bản chất của hàm băm một chiều).

Nên dù kẻ tấn công chặn được cả authorization code lẫn challenge đi qua trình duyệt, nó vẫn thiếu đúng một thứ: verifier gốc, thứ duy nhất không bao giờ rời khỏi ứng dụng. Trong phép đo, khi tôi cho kẻ tấn công thử với một verifier đoán bừa, máy chủ băm nó ra và so với challenge đã lưu — không khớp, từ chối. Đây là cùng một ý tưởng "chứng minh mình biết bí mật mà không tiết lộ bí mật" đứng sau rất nhiều giao thức bảo mật.

Vì sao

Mỗi cơ chế bịt một đường tấn công cụ thể vào cái code đi qua trình duyệt:

  • Dùng một lần giới hạn cửa sổ: code lộ chỉ dùng được tới khi bên thật dùng nó.
  • redirect_uri khớp chính xác chặn kẻ tấn công điều hướng code về mình ngay từ bước cấp.
  • PKCE chặn kẻ tấn công đã có code (qua malware, qua log, qua một ứng dụng độc trên cùng máy) đổi nó lấy token.

Ba lớp này giải thích vì sao authorization code + PKCE là luồng khuyến nghị cho mọi loại ứng dụng hiện nay — kể cả ứng dụng di động và SPA, vốn trước đây dùng implicit flow (trả token thẳng qua URL trình duyệt). Implicit flow bỏ bước đổi code, nên token rơi thẳng vào lịch sử trình duyệt và header Referer — nó bị loại bỏ chính vì thiếu đúng những lớp bảo vệ mà bài này đo.

Nghĩa là gì trong thực tế

  • Luôn dùng authorization code + PKCE. Đây là mặc định đúng cho web app, mobile, và SPA. Đừng dùng implicit flow (đã lỗi thời) hay password grant (ứng dụng thấy mật khẩu — phá vỡ mục đích của OAuth2).
  • Đăng ký redirect URI chính xác, không khớp mờ. Một redirect_uri chấp nhận tiền tố hay wildcard là một lỗ chiếm tài khoản chờ sẵn.
  • Luôn kiểm state (không đo ở đây nhưng bắt buộc): một giá trị ngẫu nhiên gửi đi và kiểm khi quay về, chống CSRF trên chính luồng đăng nhập.
  • Với OIDC, kiểm chữ ký của id_token và trường aud/iss. id_token là một JWT (các bài JWT) — mọi lỗi kiểm JWT đều áp dụng ở đây, cộng thêm phải kiểm token đúng là cấp cho ứng dụng của bạn (aud) bởi máy chủ bạn tin (iss).

Chỗ tôi không kết luận được

Tôi mô phỏng máy chủ ủy quyền trong bộ nhớ để đo cơ chế — mã một lần, PKCE, khớp redirect. Một máy chủ OAuth2 thật (Keycloak, Auth0) có thêm nhiều tầng: hết hạn token, phạm vi quyền (scope), refresh token (bài làm mới token), và luồng đồng ý. Tôi đo phần lõi bảo mật của việc lấy token, không đo vòng đời sau đó.

Và tôi không dựng một cuộc tấn công chặn-code thật qua mạng — tôi đo hậu quả của có/không PKCE bằng cách cho kẻ tấn công cầm sẵn code. Đường code bị chặn trong thực tế (malware, redirect độc, app cùng máy) đa dạng hơn, nhưng điểm mấu chốt — PKCE làm code lộ thành vô dụng — thì phép đo cho thấy rõ.

Thử ba mươi giây

Nếu ứng dụng của bạn dùng "Đăng nhập bằng Google/GitHub/...", tìm trong mã chỗ cấu hình luồng OAuth. Nó dùng response_type=code (authorization code) hay response_type=token (implicit)?

Nếu là token, bạn đang dùng luồng đã lỗi thời để token rơi vào URL trình duyệt — chuyển sang authorization code với PKCE. Nếu là code, kiểm tiếp: có tham số code_challenge không? Nếu không, bạn đang thiếu lớp chặn tấn công chặn-code mà dòng cuối bảng trong bài này cho thấy là một lỗ hổng đầy đủ.