99 Ngày Spring — Ngày 48: OAuth2 login
Ba ngày qua ta tự lo mật khẩu: băm nó, phát token, thu hồi token. Hôm nay là hướng ngược lại — đừng lo mật khẩu nữa. Với OAuth2, người dùng gõ mật khẩu ở Google chứ không phải ở trang của bạn, và Google chỉ nói lại cho bạn biết họ là ai. Bạn không lưu mật khẩu thì cũng không thể làm lộ mật khẩu — đó là toàn bộ sức hấp dẫn của cách này.

Luồng authorization code — năm bước
Nghe "OAuth2" thì thấy phức tạp, nhưng luồng đăng nhập chỉ có năm bước, và chỉ cần hiểu một câu hỏi là hiểu hết: vì sao Google trả về một code tạm chứ không trả token luôn cho xong?
// OAUTH2 LOGIN — ý tưởng gốc: ĐỪNG BAO GIỜ CHẠM VÀO MẬT KHẨU CỦA NGƯỜI TA.
// Người dùng KHÔNG gõ mật khẩu Google vào trang của bạn. Không bao giờ.
// Họ gõ nó ở google.com, rồi Google nói cho bạn biết họ là ai.
// LUỒNG AUTHORIZATION CODE — 5 bước, đọc một lần là hiểu:
// ① Người dùng bấm "Đăng nhập với Google"
// → bạn chuyển hướng họ sang accounts.google.com kèm client_id, scope, state
// ② Họ đăng nhập & bấm đồng ý — TẠI GOOGLE, không phải tại trang bạn
// ③ Google chuyển họ ngược về /login/oauth2/code/google?code=xxx&state=yyy
// ④ SERVER của bạn gọi thẳng sang Google: code + client_secret → access token
// ⭐ Bước này chạy SERVER-TO-SERVER. Trình duyệt không hề thấy client_secret.
// ⑤ Dùng token đó gọi userinfo → email, tên, ảnh → tạo/tìm user trong DB của bạn
// ❗ VÌ SAO PHẢI QUA "CODE" MÀ KHÔNG TRẢ TOKEN LUÔN?
// Vì bước ③ đi qua TRÌNH DUYỆT — nó nằm trong URL, trong lịch sử, trong log proxy.
// Cái code đó dùng MỘT LẦN, sống vài chục giây, và VÔ DỤNG nếu thiếu client_secret.
// Token thật chỉ xuất hiện ở bước ④, nơi trình duyệt không nhìn thấy.
// (Luồng "implicit" trả token thẳng qua URL đã bị KHAI TỬ đúng vì lý do này.)
// ❗ THAM SỐ state LÀ BẮT BUỘC — nó là chống CSRF cho chính bước đăng nhập:
// không có nó, kẻ tấn công ép trình duyệt bạn hoàn tất đăng nhập vào TÀI KHOẢN CỦA HẮN.
// Spring Security tự sinh và tự kiểm tra state. Đừng tự viết lại luồng này bằng tay.Vì bước chuyển về đi qua trình duyệt — nó nằm trong URL, trong lịch sử, trong log của proxy. Token thật không được đi đường đó.
codechỉ dùng một lần, sống vài chục giây, và vô dụng nếu thiếuclient_secret— thứ chỉ server của bạn có.Luồng
implicittrả token thẳng qua URL đã bị khai tử đúng vì lý do này — nếu đọc tài liệu cũ dạy cách đó thì bỏ qua.statelà bắt buộc: thiếu nó, kẻ tấn công ép trình duyệt bạn hoàn tất đăng nhập vào tài khoản của hắn. Spring Security tự lo, đừng tự viết lại luồng.
Cấu hình — ít hơn bạn tưởng rất nhiều
Phần này ngắn tới mức gây bất ngờ: khai client id cùng secret trong application.yml, thêm một dòng oauth2Login(), xong. Spring Boot có sẵn cấu hình cho Google, GitHub, Facebook và Okta nên bạn không phải khai endpoint nào.
// CẤU HÌNH — thật sự chỉ có ngần này.
// application.yml
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID} // ❗ ĐỪNG hardcode vào file
client-secret: ${GOOGLE_CLIENT_SECRET} // Đây là bí mật của SERVER
scope: openid, profile, email
github:
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
// Google là nhà cung cấp DỰNG SẴN trong Spring Boot — khỏi khai issuer-uri, endpoint.
@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(a -> a
.requestMatchers("/", "/login**").permitAll()
.anyRequest().authenticated())
.oauth2Login(o -> o // ⭐ MỘT dòng dựng cả 5 bước ở trên
.userInfoEndpoint(u -> u.userService(dichVuNguoiDung))
.defaultSuccessUrl("/trang-chu", true))
.build();
}
// ❗ URL CHUYỂN VỀ phải khai ĐÚNG TỪNG KÝ TỰ ở Google Console:
// http://localhost:8080/login/oauth2/code/google
// Sai một dấu gạch chéo là "redirect_uri_mismatch" — lỗi tốn thời gian nhất
// của mọi người khi làm OAuth2 lần đầu.client-secretlà bí mật của server — đọc từ biến môi trường, đừng bao giờ commit vào repo.URL chuyển về phải khớp từng ký tự với khai báo ở Google Console; sai một dấu gạch chéo là
redirect_uri_mismatch, lỗi tốn thời gian nhất khi làm OAuth2 lần đầu.scope: openid, profile, emaillà bộ tối thiểu — xin thêm quyền thì màn hình đồng ý dài ra và người dùng ngại bấm.
Ánh xạ user — và cái bẫy chiếm tài khoản
Đây là chỗ OAuth2 gặp hệ thống của bạn, và cũng là chỗ tập trung nhiều lỗi nhất. Mỗi nhà cung cấp trả về một hình dạng dữ liệu khác nhau, GitHub thậm chí có thể trả email rỗng. Nhưng cái bẫy đắt giá nhất là dùng email làm khóa định danh.
// ÁNH XẠ USER — chỗ OAuth2 gặp hệ thống CỦA BẠN, và cũng là chỗ dễ sai nhất.
@Service
class DichVuNguoiDungOAuth2 extends DefaultOAuth2UserService {
@Override
public OAuth2User loadUser(OAuth2UserRequest req) {
OAuth2User oauth = super.loadUser(req); // gọi userinfo của nhà cung cấp
String nhaCungCap = req.getClientRegistration().getRegistrationId();
// ❗ MỖI NHÀ CUNG CẤP TRẢ MỘT HÌNH DẠNG KHÁC NHAU:
// Google: sub, email, name, picture
// GitHub: id, login, name, avatar_url — và email CÓ THỂ NULL!
// (người dùng để email ở chế độ riêng tư → phải gọi thêm /user/emails)
String email = oauth.getAttribute("email");
// ⭐ KHÓA ĐỊNH DANH: dùng cặp (nhà cung cấp, id) chứ ĐỪNG chỉ dùng email.
// Vì sao: email đổi được, và cùng một email có thể tới từ hai nhà cung cấp
// khác nhau — chỉ khớp theo email là bạn tự tạo lỗ hổng CHIẾM TÀI KHOẢN.
var u = repo.timTheoNhaCungCap(nhaCungCap, oauth.getName())
.orElseGet(() -> repo.save(taoMoi(nhaCungCap, oauth)));
// Quyền là của HỆ THỐNG BẠN, không phải của Google (Ngày 47):
return new DefaultOAuth2User(u.layQuyen(), oauth.getAttributes(), "sub");
}
}
// ❗ Google xác nhận BẠN LÀ AI. Còn BẠN ĐƯỢC LÀM GÌ trong ứng dụng này
// thì chỉ hệ thống của bạn mới trả lời được. Đừng lẫn hai chuyện đó.Khóa định danh là cặp (nhà cung cấp, id) — email đổi được, và cùng một email có thể tới từ hai nhà cung cấp, nên khớp theo email là tự mở lỗ hổng chiếm tài khoản.
GitHub trả
emailnull khi người dùng để riêng tư — phải gọi thêm/user/emails, đừng giả định lúc nào cũng có.Google xác nhận bạn là ai, không xác nhận bạn được làm gì — quyền vẫn là chuyện của hệ thống bạn, đúng như Ngày 47.
Bài tập nhỏ
Đăng ký một OAuth client ở Google Console và chạy được nút đăng nhập trên
localhost.Cố tình khai sai URL chuyển về một dấu gạch chéo để gặp
redirect_uri_mismatch— biết mặt nó một lần cho nhớ.Thêm GitHub làm nhà cung cấp thứ hai và in ra toàn bộ
attributescủa cả hai để so hình dạng.Đăng nhập bằng Google rồi bằng GitHub với cùng một email — kiểm tra hệ thống bạn tạo hai user hay một, và tự quyết định đâu là đúng.
Gán quyền
ROLE_USERcho user tạo từ OAuth2 rồi thử vào một endpoint cầnROLE_ADMIN.
Kết lại
Bốn ý gói lại hôm nay: OAuth2 login nghĩa là bạn không bao giờ chạm vào mật khẩu người dùng — không lưu thì không làm lộ được; luồng đi qua một code tạm vì bước chuyển về đi qua trình duyệt, còn token thật chỉ xuất hiện trong lần gọi server-to-server có client_secret; state là bắt buộc và Spring Security đã lo giúp, nên đừng tự viết lại luồng bằng tay; và khóa định danh phải là cặp nhà cung cấp cộng id, vì khớp theo email là tự mở lỗ hổng chiếm tài khoản. Ngày 49 ta nhìn từ phía còn lại: resource server — nhận bearer token, JwtDecoder, và phân quyền theo scope. Hẹn gặp lại!
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


