Backend

99 Ngày Spring — Ngày 48: OAuth2 login

SSite Admin
13 tháng 09, 2026 7 phút đọc 1 lượt xem
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.

Sketchnote Ngày 48: OAuth2 login — luồng authorization code năm bước, vì sao phải qua code thay vì trả token thẳng, tham số state chống CSRF, cấu hình oauth2Login, và ánh xạ user theo cặp nhà cung cấp và id

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 đó.

  • code chỉ dùng một lần, sống vài chục giây, và vô dụng nếu thiếu client_secret — thứ chỉ server của bạn có.

  • Luồng implicit trả 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.

  • statebắ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-secretbí 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, email là 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ả email null 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ộ attributes củ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_USER cho user tạo từ OAuth2 rồi thử vào một endpoint cần ROLE_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; statebắ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!

S

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.

Bài viết liên quan

99 Ngày Java — Ngày 48: Logging đúng cách

Vì sao System.out.println không phải logging, SLF4J là cái cầu chứ không phải bộ ghi log, mức log là câu hỏi ai sẽ đọc dòng này, dấu {} là chuyện hiệu năng — và những thứ tuyệt đối không được log.

13 thg 9, 20268 phút0
99 Ngày Spring — Ngày 47: Phân quyền — role & authority

Role chỉ là authority có tiền tố ROLE_, vì sao hasRole("ROLE_ADMIN") luôn cho 403, thiết kế role gom quyền để thêm vai trò không phải sửa code — và hai thứ im lặng làm hỏng method security.

12 thg 9, 20267 phút7
99 Ngày Java — Ngày 47: Serialization & JSON

Vì sao Serializable của Java bị cả ngành rời bỏ, ObjectMapper tạo một lần dùng mãi, bẫy TypeReference khi đọc List do xóa kiểu — và vì sao record hợp với JSON tới mức không cần annotation nào.

12 thg 9, 20267 phút4