OAuth2 & Đăng Nhập Mạng Xã Hội trong Spring Boot

Giới thiệu
Các nút "Đăng nhập bằng Google" và "Đăng nhập bằng GitHub" có mặt ở khắp nơi. Người dùng mong đợi đăng nhập mạng xã hội — nó loại bỏ sự phiền phức khi phải tạo thêm một tài khoản và mật khẩu mới. OAuth2 giúp thực hiện điều này một cách an toàn, và Spring Boot khiến việc triển khai trở nên dễ dàng đáng ngạc nhiên.
Bạn sẽ học được gì
✅ Hiểu giao thức OAuth2 và luồng authorization code
✅ Thiết lập Google và GitHub làm nhà cung cấp OAuth2
✅ Cấu hình Spring Security OAuth2 Client
✅ Xây dựng custom OAuth2 user service để ánh xạ người dùng
✅ Tự động đăng ký người dùng khi đăng nhập mạng xã hội lần đầu
✅ Liên kết nhiều tài khoản mạng xã hội vào một người dùng
✅ Kết hợp đăng nhập OAuth2 với xác thực JWT token
✅ Tích hợp đăng nhập OAuth2 với frontend SPA
✅ Kiểm thử luồng OAuth2 trong integration test
✅ Áp dụng các best practice bảo mật cho production
Yêu cầu trước
- Đã cài đặt Java 17+
- Kiến thức cơ bản về Spring Boot (xem Hướng dẫn Bắt đầu)
- Hiểu biết về Spring Security & JWT (Hướng dẫn Xác thực JWT)
- Tài khoản Google Cloud và tài khoản GitHub (để lấy thông tin đăng nhập OAuth)
1. Hiểu về OAuth2
OAuth2 là gì?
OAuth 2.0 là một framework ủy quyền cho phép người dùng cấp quyền truy cập hạn chế cho ứng dụng bên thứ ba vào tài khoản của họ trên một dịch vụ khác — mà không cần chia sẻ mật khẩu.
Khi người dùng nhấn "Đăng nhập bằng Google", ứng dụng của bạn không bao giờ thấy mật khẩu Google của họ. Thay vào đó, Google xác nhận danh tính và gửi cho ứng dụng một token chứa thông tin hồ sơ người dùng.
Các vai trò trong OAuth2
| Vai trò | Mô tả | Ví dụ |
|---|---|---|
| Resource Owner | Người dùng sở hữu dữ liệu | Người nhấn "Đăng nhập bằng Google" |
| Client | Ứng dụng của bạn yêu cầu quyền truy cập | Ứng dụng Spring Boot của bạn |
| Authorization Server | Cấp token sau khi xác thực | Máy chủ OAuth của Google |
| Resource Server | Lưu trữ dữ liệu người dùng được bảo vệ | API hồ sơ người dùng của Google |
Luồng Authorization Code
Luồng Authorization Code là tiêu chuẩn cho ứng dụng web phía server và SPA. Đây là luồng OAuth2 an toàn nhất cho đăng nhập mạng xã hội:
Tại sao dùng luồng Authorization Code?
- Authorization code được trao đổi giữa server-to-server — không bao giờ lộ ra trình duyệt
- Access token nằm trên server của bạn — giảm nguy cơ bị đánh cắp token
- Hỗ trợ refresh token cho phiên đăng nhập dài hạn
OAuth2 vs JWT — Khi nào dùng cái nào
| Khía cạnh | OAuth2 (Đăng nhập MXH) | JWT (Xác thực tùy chỉnh) |
|---|---|---|
| Trường hợp sử dụng | "Đăng nhập bằng Google/GitHub" | Đăng nhập bằng username/password |
| Ai quản lý mật khẩu? | Nhà cung cấp (Google, GitHub) | Bạn (cơ sở dữ liệu của bạn) |
| Đăng ký người dùng | Đơn giản hơn — không cần quản lý mật khẩu | Bạn xử lý mọi thứ |
| Mô hình tin cậy | Tin tưởng nhà cung cấp | Tin tưởng server của bạn |
| Phù hợp nhất cho | Ứng dụng consumer, giảm ma sát | Ứng dụng nội bộ, kiểm soát hoàn toàn |
Trong thực tế, hầu hết ứng dụng kết hợp cả hai: OAuth2 cho đăng nhập mạng xã hội VÀ JWT cho xác thực API. Người dùng đăng nhập qua Google → ứng dụng cấp JWT → JWT xác thực các yêu cầu API tiếp theo.
2. Thiết lập nhà cung cấp OAuth2
Trước khi viết bất kỳ code nào, bạn cần thông tin đăng nhập OAuth2 từ mỗi nhà cung cấp.
Thiết lập Google OAuth2
- Truy cập Google Cloud Console
- Tạo dự án mới (hoặc chọn dự án hiện có)
- Điều hướng đến APIs & Services → Credentials
- Nhấn Create Credentials → OAuth client ID
- Chọn Web application
- Thêm Authorized redirect URIs:
http://localhost:8080/login/oauth2/code/google - Sao chép Client ID và Client Secret
Quan trọng: Redirect URI phải khớp chính xác. Mẫu mặc định của Spring Boot là
/login/oauth2/code/{registrationId}.
Thiết lập GitHub OAuth App
- Truy cập GitHub Developer Settings
- Nhấn New OAuth App
- Điền:
- Application name: Tên ứng dụng của bạn
- Homepage URL:
http://localhost:8080 - Authorization callback URL:
http://localhost:8080/login/oauth2/code/github
- Nhấn Register application
- Sao chép Client ID và tạo Client Secret
Lưu trữ thông tin đăng nhập an toàn
Không bao giờ hardcode secret trong mã nguồn. Sử dụng biến môi trường:
# .env (thêm vào .gitignore!)
GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-google-client-secret
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret3. Thiết lập Spring Security OAuth2 Client
Thêm Dependencies
Thêm OAuth2 Client starter vào pom.xml:
<dependencies>
<!-- Spring Boot Starter Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- OAuth2 Client - thêm hỗ trợ đăng nhập mạng xã hội -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<!-- JPA để lưu trữ người dùng -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- PostgreSQL -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Hỗ trợ JWT (để cấp token sau đăng nhập OAuth2) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<!-- Lombok (tùy chọn) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>Cấu hình ứng dụng
Cấu hình cả hai nhà cung cấp OAuth2 trong application.yml:
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope:
- email
- profile
github:
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
scope:
- user:email
- read:user
datasource:
url: jdbc:postgresql://localhost:5432/oauth2_demo
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:postgres}
jpa:
hibernate:
ddl-auto: update
show-sql: true
app:
jwt:
secret: ${JWT_SECRET:your-256-bit-secret-key-for-jwt-signing-min-32-chars}
expiration: 86400000 # 24 giờ
oauth2:
authorized-redirect-uri: http://localhost:3000/oauth2/redirectLưu ý: Spring Boot tự động cấu hình các nhà cung cấp Google và GitHub. Bạn chỉ cần
client-id,client-secret, vàscope. URL authorization, URL token, và URL thông tin người dùng đã được cấu hình sẵn.
Spring Boot tự động làm gì
Chỉ với dependency và properties ở trên, Spring Boot:
- Tạo các endpoint đăng nhập tại
/oauth2/authorization/googlevà/oauth2/authorization/github - Xử lý callback OAuth2 tại
/login/oauth2/code/googlevà/login/oauth2/code/github - Đổi authorization code lấy access token
- Lấy thông tin người dùng từ nhà cung cấp
- Tạo
OAuth2AuthenticationTokentrong SecurityContext
Bạn có thể kiểm tra ngay — truy cập http://localhost:8080/oauth2/authorization/google và bạn sẽ được chuyển hướng đến trang đăng nhập Google.
4. Custom OAuth2 User Service
Hành vi mặc định đăng nhập người dùng nhưng không lưu họ vào cơ sở dữ liệu. Hãy xây dựng một custom user service.
Entity User hỗ trợ nhiều nhà cung cấp
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
private String imageUrl;
@Column(nullable = false)
private Boolean emailVerified = false;
private String password; // null cho người dùng OAuth2
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private AuthProvider provider;
private String providerId;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role = Role.USER;
}Enum AuthProvider
public enum AuthProvider {
LOCAL,
GOOGLE,
GITHUB
}Enum Role
public enum Role {
USER,
ADMIN
}User Repository
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
Boolean existsByEmail(String email);
}Trích xuất thông tin người dùng OAuth2
Mỗi nhà cung cấp trả về dữ liệu người dùng ở định dạng khác nhau. Hãy tạo một lớp trừu tượng:
public abstract class OAuth2UserInfo {
protected Map<String, Object> attributes;
public OAuth2UserInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
public abstract String getId();
public abstract String getName();
public abstract String getEmail();
public abstract String getImageUrl();
}Thông tin người dùng Google:
public class GoogleOAuth2UserInfo extends OAuth2UserInfo {
public GoogleOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return (String) attributes.get("sub");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getImageUrl() {
return (String) attributes.get("picture");
}
}Thông tin người dùng GitHub:
public class GitHubOAuth2UserInfo extends OAuth2UserInfo {
public GitHubOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return String.valueOf(attributes.get("id"));
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getImageUrl() {
return (String) attributes.get("avatar_url");
}
}Factory tạo đúng bộ trích xuất:
public class OAuth2UserInfoFactory {
public static OAuth2UserInfo getOAuth2UserInfo(
String registrationId, Map<String, Object> attributes) {
if (registrationId.equalsIgnoreCase(AuthProvider.GOOGLE.name())) {
return new GoogleOAuth2UserInfo(attributes);
} else if (registrationId.equalsIgnoreCase(AuthProvider.GITHUB.name())) {
return new GitHubOAuth2UserInfo(attributes);
} else {
throw new OAuth2AuthenticationProcessingException(
"Đăng nhập bằng " + registrationId + " không được hỗ trợ");
}
}
}Custom OAuth2 User Service
Đây là component cốt lõi. Nó xử lý đăng nhập OAuth2 và đăng ký/cập nhật người dùng:
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest)
throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
try {
return processOAuth2User(userRequest, oAuth2User);
} catch (Exception ex) {
throw new OAuth2AuthenticationProcessingException(
ex.getMessage(), ex);
}
}
private OAuth2User processOAuth2User(
OAuth2UserRequest userRequest, OAuth2User oAuth2User) {
String registrationId = userRequest.getClientRegistration()
.getRegistrationId();
OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory
.getOAuth2UserInfo(registrationId, oAuth2User.getAttributes());
if (oAuth2UserInfo.getEmail() == null
|| oAuth2UserInfo.getEmail().isEmpty()) {
throw new OAuth2AuthenticationProcessingException(
"Không tìm thấy email từ nhà cung cấp OAuth2");
}
Optional<User> userOptional = userRepository
.findByEmail(oAuth2UserInfo.getEmail());
User user;
if (userOptional.isPresent()) {
user = userOptional.get();
if (!user.getProvider().name()
.equalsIgnoreCase(registrationId)) {
throw new OAuth2AuthenticationProcessingException(
"Bạn đã đăng ký bằng " + user.getProvider()
+ ". Vui lòng sử dụng tài khoản " + user.getProvider()
+ " để đăng nhập.");
}
user = updateExistingUser(user, oAuth2UserInfo);
} else {
user = registerNewUser(registrationId, oAuth2UserInfo);
}
return new CustomUserPrincipal(user, oAuth2User.getAttributes());
}
private User registerNewUser(
String registrationId, OAuth2UserInfo oAuth2UserInfo) {
User user = new User();
user.setProvider(AuthProvider.valueOf(registrationId.toUpperCase()));
user.setProviderId(oAuth2UserInfo.getId());
user.setName(oAuth2UserInfo.getName());
user.setEmail(oAuth2UserInfo.getEmail());
user.setImageUrl(oAuth2UserInfo.getImageUrl());
user.setEmailVerified(true);
return userRepository.save(user);
}
private User updateExistingUser(
User existingUser, OAuth2UserInfo oAuth2UserInfo) {
existingUser.setName(oAuth2UserInfo.getName());
existingUser.setImageUrl(oAuth2UserInfo.getImageUrl());
return userRepository.save(existingUser);
}
}Custom User Principal
Một principal thống nhất hoạt động cho cả người dùng OAuth2 và local (JWT):
public class CustomUserPrincipal implements OAuth2User, UserDetails {
private Long id;
private String email;
private String password;
private Collection<? extends GrantedAuthority> authorities;
private Map<String, Object> attributes;
public CustomUserPrincipal(
Long id, String email, String password,
Collection<? extends GrantedAuthority> authorities) {
this.id = id;
this.email = email;
this.password = password;
this.authorities = authorities;
}
// Constructor cho người dùng OAuth2
public CustomUserPrincipal(User user, Map<String, Object> attributes) {
this.id = user.getId();
this.email = user.getEmail();
this.password = user.getPassword();
this.authorities = Collections.singletonList(
new SimpleGrantedAuthority("ROLE_" + user.getRole().name()));
this.attributes = attributes;
}
// Factory method cho người dùng local
public static CustomUserPrincipal create(User user) {
List<GrantedAuthority> authorities = Collections.singletonList(
new SimpleGrantedAuthority("ROLE_" + user.getRole().name()));
return new CustomUserPrincipal(
user.getId(), user.getEmail(),
user.getPassword(), authorities);
}
// Các phương thức UserDetails
@Override
public String getUsername() { return email; }
@Override
public String getPassword() { return password; }
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public boolean isAccountNonExpired() { return true; }
@Override
public boolean isAccountNonLocked() { return true; }
@Override
public boolean isCredentialsNonExpired() { return true; }
@Override
public boolean isEnabled() { return true; }
// Các phương thức OAuth2User
@Override
public Map<String, Object> getAttributes() { return attributes; }
@Override
public String getName() { return String.valueOf(id); }
public Long getId() { return id; }
}5. Đăng ký người dùng & Liên kết tài khoản
Xử lý các tình huống khác nhau
Khi người dùng đăng nhập qua OAuth2, có nhiều tình huống có thể xảy ra:
Chiến lược liên kết tài khoản
Triển khai cơ bản ở trên từ chối đăng nhập nếu email tồn tại với nhà cung cấp khác. Để trải nghiệm người dùng tốt hơn, bạn có thể cho phép liên kết tài khoản:
@Entity
@Table(name = "user_social_connections")
@Getter
@Setter
@NoArgsConstructor
public class SocialConnection {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private AuthProvider provider;
@Column(nullable = false)
private String providerId;
private String providerEmail;
@Column(nullable = false)
private LocalDateTime connectedAt = LocalDateTime.now();
}@Repository
public interface SocialConnectionRepository
extends JpaRepository<SocialConnection, Long> {
Optional<SocialConnection> findByProviderAndProviderId(
AuthProvider provider, String providerId);
List<SocialConnection> findByUser(User user);
boolean existsByUserAndProvider(User user, AuthProvider provider);
}User service cập nhật với liên kết tài khoản:
private OAuth2User processOAuth2User(
OAuth2UserRequest userRequest, OAuth2User oAuth2User) {
String registrationId = userRequest.getClientRegistration()
.getRegistrationId();
AuthProvider provider = AuthProvider.valueOf(
registrationId.toUpperCase());
OAuth2UserInfo userInfo = OAuth2UserInfoFactory
.getOAuth2UserInfo(registrationId, oAuth2User.getAttributes());
if (userInfo.getEmail() == null || userInfo.getEmail().isEmpty()) {
throw new OAuth2AuthenticationProcessingException(
"Không tìm thấy email từ nhà cung cấp OAuth2");
}
// Kiểm tra xem kết nối mạng xã hội đã tồn tại chưa
Optional<SocialConnection> connectionOpt = socialConnectionRepository
.findByProviderAndProviderId(provider, userInfo.getId());
User user;
if (connectionOpt.isPresent()) {
// Người dùng quay lại — cập nhật thông tin
user = connectionOpt.get().getUser();
user.setName(userInfo.getName());
user.setImageUrl(userInfo.getImageUrl());
userRepository.save(user);
} else {
// Kiểm tra người dùng theo email
Optional<User> userOpt = userRepository
.findByEmail(userInfo.getEmail());
if (userOpt.isPresent()) {
// Liên kết nhà cung cấp mới vào tài khoản hiện có
user = userOpt.get();
createSocialConnection(user, provider, userInfo);
} else {
// Người dùng hoàn toàn mới
user = registerNewUser(provider, userInfo);
createSocialConnection(user, provider, userInfo);
}
}
return new CustomUserPrincipal(user, oAuth2User.getAttributes());
}
private void createSocialConnection(
User user, AuthProvider provider, OAuth2UserInfo userInfo) {
SocialConnection connection = new SocialConnection();
connection.setUser(user);
connection.setProvider(provider);
connection.setProviderId(userInfo.getId());
connection.setProviderEmail(userInfo.getEmail());
socialConnectionRepository.save(connection);
}Trường hợp đặc biệt email GitHub
Người dùng GitHub có thể đặt email ở chế độ riêng tư. Trong trường hợp đó, trường email trong thuộc tính người dùng là null. Bạn cần thực hiện thêm một lệnh gọi API:
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
// ... các trường khác
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest)
throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
// Workaround cho email GitHub
String registrationId = userRequest.getClientRegistration()
.getRegistrationId();
Map<String, Object> attributes =
new HashMap<>(oAuth2User.getAttributes());
if ("github".equals(registrationId)
&& attributes.get("email") == null) {
String email = fetchGitHubEmail(userRequest);
attributes.put("email", email);
}
try {
return processOAuth2User(userRequest,
new DefaultOAuth2User(
oAuth2User.getAuthorities(),
attributes,
"id"));
} catch (Exception ex) {
throw new OAuth2AuthenticationProcessingException(
ex.getMessage(), ex);
}
}
private String fetchGitHubEmail(OAuth2UserRequest userRequest) {
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(
userRequest.getAccessToken().getTokenValue());
HttpEntity<String> entity = new HttpEntity<>(headers);
ResponseEntity<List<Map<String, Object>>> response =
restTemplate.exchange(
"https://api.github.com/user/emails",
HttpMethod.GET,
entity,
new ParameterizedTypeReference<>() {});
return response.getBody().stream()
.filter(email -> Boolean.TRUE.equals(email.get("primary")))
.map(email -> (String) email.get("email"))
.findFirst()
.orElseThrow(() ->
new OAuth2AuthenticationProcessingException(
"Không tìm thấy email chính từ GitHub"));
}
}6. Kết hợp OAuth2 với JWT
Sau khi đăng nhập OAuth2 thành công, chúng ta cấp JWT token để frontend có thể xác thực các yêu cầu API tiếp theo.
JWT Token Provider
Tái sử dụng hoặc điều chỉnh tiện ích JWT từ Hướng dẫn Xác thực JWT:
@Component
public class JwtTokenProvider {
@Value("${app.jwt.secret}")
private String jwtSecret;
@Value("${app.jwt.expiration}")
private long jwtExpirationMs;
public String generateToken(CustomUserPrincipal userPrincipal) {
return Jwts.builder()
.subject(String.valueOf(userPrincipal.getId()))
.issuedAt(new Date())
.expiration(new Date(
System.currentTimeMillis() + jwtExpirationMs))
.signWith(getSigningKey())
.compact();
}
public Long getUserIdFromToken(String token) {
Claims claims = Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
return Long.parseLong(claims.getSubject());
}
public boolean validateToken(String token) {
try {
Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token);
return true;
} catch (JwtException | IllegalArgumentException ex) {
return false;
}
}
private SecretKey getSigningKey() {
byte[] keyBytes = Decoders.BASE64.decode(jwtSecret);
return Keys.hmacShaKeyFor(keyBytes);
}
}OAuth2 Authentication Success Handler
Đây là component quan trọng kết nối đăng nhập OAuth2 và JWT:
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler
extends SimpleUrlAuthenticationSuccessHandler {
private final JwtTokenProvider tokenProvider;
@Value("${app.oauth2.authorized-redirect-uri}")
private String authorizedRedirectUri;
@Override
public void onAuthenticationSuccess(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
CustomUserPrincipal userPrincipal =
(CustomUserPrincipal) authentication.getPrincipal();
String token = tokenProvider.generateToken(userPrincipal);
// Chuyển hướng về frontend với JWT token
String targetUrl = UriComponentsBuilder
.fromUriString(authorizedRedirectUri)
.queryParam("token", token)
.build()
.toUriString();
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}OAuth2 Authentication Failure Handler
Xử lý lỗi một cách graceful:
@Component
public class OAuth2AuthenticationFailureHandler
extends SimpleUrlAuthenticationFailureHandler {
@Value("${app.oauth2.authorized-redirect-uri}")
private String authorizedRedirectUri;
@Override
public void onAuthenticationFailure(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException {
String targetUrl = UriComponentsBuilder
.fromUriString(authorizedRedirectUri)
.queryParam("error", exception.getLocalizedMessage())
.build()
.toUriString();
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}JWT Authentication Filter
Cùng filter từ hướng dẫn JWT — xác thực JWT trên mỗi request:
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider;
private final CustomUserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String jwt = getJwtFromRequest(request);
if (jwt != null && tokenProvider.validateToken(jwt)) {
Long userId = tokenProvider.getUserIdFromToken(jwt);
UserDetails userDetails =
userDetailsService.loadUserById(userId);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails, null,
userDetails.getAuthorities());
authentication.setDetails(
new WebAuthenticationDetailsSource()
.buildDetails(request));
SecurityContextHolder.getContext()
.setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}7. Cấu hình bảo mật
Kết hợp tất cả trong cấu hình bảo mật:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomOAuth2UserService customOAuth2UserService;
private final OAuth2AuthenticationSuccessHandler oAuth2SuccessHandler;
private final OAuth2AuthenticationFailureHandler oAuth2FailureHandler;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http)
throws Exception {
http
// Tắt CSRF cho API JWT stateless
.csrf(csrf -> csrf.disable())
// Quản lý session — stateless cho JWT
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// Quy tắc authorization
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/auth/**",
"/oauth2/**",
"/login/oauth2/**"
).permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
// Cấu hình đăng nhập OAuth2
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService))
.successHandler(oAuth2SuccessHandler)
.failureHandler(oAuth2FailureHandler)
)
// Xử lý exception
.exceptionHandling(ex -> ex
.authenticationEntryPoint(
(request, response, authException) -> {
response.setStatus(
HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write(
"{\"error\": \"Unauthorized\"}");
})
)
// JWT filter trước filter của Spring Security
.addFilterBefore(jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
}Luồng xác thực hoàn chỉnh
8. Hỗ trợ đăng nhập Local
Để hỗ trợ cả OAuth2 và đăng nhập email/mật khẩu truyền thống song song:
Auth Controller
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authenticationManager;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider tokenProvider;
@PostMapping("/signup")
public ResponseEntity<?> registerUser(
@Valid @RequestBody SignUpRequest signUpRequest) {
if (userRepository.existsByEmail(signUpRequest.getEmail())) {
return ResponseEntity.badRequest()
.body(Map.of("error",
"Địa chỉ email đã được sử dụng"));
}
User user = new User();
user.setName(signUpRequest.getName());
user.setEmail(signUpRequest.getEmail());
user.setPassword(passwordEncoder.encode(
signUpRequest.getPassword()));
user.setProvider(AuthProvider.LOCAL);
userRepository.save(user);
return ResponseEntity.status(HttpStatus.CREATED)
.body(Map.of("message",
"Đăng ký người dùng thành công"));
}
@PostMapping("/login")
public ResponseEntity<?> authenticateUser(
@Valid @RequestBody LoginRequest loginRequest) {
Authentication authentication =
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
loginRequest.getEmail(),
loginRequest.getPassword()));
SecurityContextHolder.getContext()
.setAuthentication(authentication);
CustomUserPrincipal userPrincipal =
(CustomUserPrincipal) authentication.getPrincipal();
String token = tokenProvider.generateToken(userPrincipal);
return ResponseEntity.ok(Map.of(
"token", token,
"tokenType", "Bearer"
));
}
}Request DTOs
@Getter
@Setter
public class SignUpRequest {
@NotBlank
private String name;
@NotBlank
@Email
private String email;
@NotBlank
@Size(min = 8, max = 40)
private String password;
}
@Getter
@Setter
public class LoginRequest {
@NotBlank
@Email
private String email;
@NotBlank
private String password;
}Custom User Details Service (cho đăng nhập local)
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email)
throws UsernameNotFoundException {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException(
"Không tìm thấy người dùng với email: " + email));
return CustomUserPrincipal.create(user);
}
public UserDetails loadUserById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new UsernameNotFoundException(
"Không tìm thấy người dùng với id: " + id));
return CustomUserPrincipal.create(user);
}
}9. Tích hợp Frontend
Đăng nhập OAuth2 từ SPA
Frontend của bạn (React, Next.js, Vue, v.v.) khởi tạo luồng OAuth2 bằng cách chuyển hướng đến backend:
// Ví dụ React / Next.js
const API_BASE_URL = 'http://localhost:8080';
function LoginPage() {
const handleGoogleLogin = () => {
window.location.href =
`${API_BASE_URL}/oauth2/authorization/google`;
};
const handleGitHubLogin = () => {
window.location.href =
`${API_BASE_URL}/oauth2/authorization/github`;
};
return (
<div>
<h1>Đăng Nhập</h1>
<button onClick={handleGoogleLogin}>
Đăng nhập bằng Google
</button>
<button onClick={handleGitHubLogin}>
Đăng nhập bằng GitHub
</button>
{/* Form đăng nhập local */}
<form onSubmit={handleLocalLogin}>
<input type="email" placeholder="Email" />
<input type="password" placeholder="Mật khẩu" />
<button type="submit">Đăng nhập</button>
</form>
</div>
);
}Xử lý Redirect Callback
Sau khi đăng nhập OAuth2, backend chuyển hướng về frontend với JWT token trong query string:
// pages/oauth2/redirect.tsx (hoặc tương đương)
import { useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
function OAuth2RedirectPage() {
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
const token = searchParams.get('token');
const error = searchParams.get('error');
if (token) {
// Lưu JWT token
localStorage.setItem('accessToken', token);
router.push('/dashboard');
} else if (error) {
console.error('Lỗi OAuth2:', error);
router.push('/login?error=' + encodeURIComponent(error));
}
}, [searchParams, router]);
return <div>Đang xử lý đăng nhập...</div>;
}
export default OAuth2RedirectPage;Sử dụng JWT Token
Khi đã có JWT, đính kèm nó trong mọi yêu cầu API:
// lib/api.ts
const api = {
async fetch(url: string, options: RequestInit = {}) {
const token = localStorage.getItem('accessToken');
const response = await fetch(`${API_BASE_URL}${url}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...options.headers,
},
});
if (response.status === 401) {
localStorage.removeItem('accessToken');
window.location.href = '/login';
}
return response;
},
get(url: string) {
return this.fetch(url);
},
post(url: string, data: unknown) {
return this.fetch(url, {
method: 'POST',
body: JSON.stringify(data),
});
},
};
export default api;10. Bảo vệ API Endpoints
Lấy thông tin người dùng hiện tại
Tạo annotation @CurrentUser cho tiện lợi:
@Target({ElementType.PARAMETER, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@AuthenticationPrincipal
public @interface CurrentUser {
}Sử dụng trong controllers:
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserRepository userRepository;
@GetMapping("/me")
public ResponseEntity<?> getCurrentUser(
@CurrentUser CustomUserPrincipal currentUser) {
User user = userRepository.findById(currentUser.getId())
.orElseThrow(() -> new ResourceNotFoundException(
"User", "id", currentUser.getId()));
return ResponseEntity.ok(Map.of(
"id", user.getId(),
"name", user.getName(),
"email", user.getEmail(),
"imageUrl", user.getImageUrl(),
"provider", user.getProvider()
));
}
@GetMapping("/me/connections")
public ResponseEntity<?> getSocialConnections(
@CurrentUser CustomUserPrincipal currentUser) {
User user = userRepository.findById(currentUser.getId())
.orElseThrow(() -> new ResourceNotFoundException(
"User", "id", currentUser.getId()));
List<SocialConnection> connections =
socialConnectionRepository.findByUser(user);
return ResponseEntity.ok(connections.stream()
.map(conn -> Map.of(
"provider", conn.getProvider(),
"email", conn.getProviderEmail(),
"connectedAt", conn.getConnectedAt()
))
.toList());
}
}Bảo mật cấp phương thức
Sử dụng @PreAuthorize để kiểm soát truy cập chi tiết — hoạt động giống nhau bất kể người dùng đăng nhập qua OAuth2 hay thông tin đăng nhập local:
@RestController
@RequestMapping("/api/admin")
public class AdminController {
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/users")
public ResponseEntity<List<User>> getAllUsers() {
return ResponseEntity.ok(userRepository.findAll());
}
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/users/{id}")
public ResponseEntity<?> deleteUser(@PathVariable Long id) {
userRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
}11. Kiểm thử OAuth2
Unit Test với Mock OAuth2
Spring Security Test cung cấp @WithMockUser và các tiện ích test OAuth2:
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserRepository userRepository;
@Test
@WithMockUser(username = "1", roles = "USER")
void testGetCurrentUser() throws Exception {
User user = new User();
user.setId(1L);
user.setName("John Doe");
user.setEmail("[email protected]");
user.setProvider(AuthProvider.GOOGLE);
when(userRepository.findById(1L))
.thenReturn(Optional.of(user));
mockMvc.perform(get("/api/users/me"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("John Doe"))
.andExpect(jsonPath("$.provider").value("GOOGLE"));
}
@Test
void testUnauthenticatedAccess() throws Exception {
mockMvc.perform(get("/api/users/me"))
.andExpect(status().isUnauthorized());
}
}Kiểm thử luồng đăng nhập OAuth2
Sử dụng SecurityMockMvcRequestPostProcessors.oauth2Login():
@SpringBootTest
@AutoConfigureMockMvc
class OAuth2LoginTest {
@Autowired
private MockMvc mockMvc;
@Test
void testOAuth2LoginCreatesUser() throws Exception {
mockMvc.perform(get("/api/users/me")
.with(oauth2Login()
.oauth2User(new DefaultOAuth2User(
List.of(
new SimpleGrantedAuthority("ROLE_USER")),
Map.of(
"sub", "123456",
"name", "Test User",
"email", "[email protected]",
"picture", "https://example.com/photo.jpg"
),
"sub"
))))
.andExpect(status().isOk());
}
}Integration Test với luồng đầy đủ
@SpringBootTest
@AutoConfigureMockMvc
class AuthIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
@Autowired
private JwtTokenProvider tokenProvider;
@Test
void testLocalSignupAndLogin() throws Exception {
// 1. Đăng ký
mockMvc.perform(post("/api/auth/signup")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"name": "Jane Doe",
"email": "[email protected]",
"password": "password123"
}
"""))
.andExpect(status().isCreated());
// 2. Đăng nhập và lấy token
MvcResult result = mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"email": "[email protected]",
"password": "password123"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").exists())
.andReturn();
String token = JsonPath.read(
result.getResponse().getContentAsString(), "$.token");
// 3. Truy cập endpoint được bảo vệ với JWT
mockMvc.perform(get("/api/users/me")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email").value("[email protected]"))
.andExpect(jsonPath("$.provider").value("LOCAL"));
}
@AfterEach
void cleanup() {
userRepository.deleteAll();
}
}12. Cân nhắc cho Production
Bảng kiểm tra bảo mật
| Vấn đề | Giải pháp |
|---|---|
| Lưu trữ thông tin đăng nhập | Sử dụng biến môi trường hoặc trình quản lý secrets (Vault, AWS Secrets Manager) |
| Yêu cầu HTTPS | Các nhà cung cấp OAuth2 yêu cầu HTTPS cho redirect URI trong production |
| Tham số state | Spring Security tự động xử lý bảo vệ CSRF qua tham số state |
| Token trong URL | JWT trong redirect URL có thời hạn ngắn; frontend lưu trữ nó và xóa khỏi URL |
| Giới hạn tốc độ | Giới hạn số lần đăng nhập để ngăn tấn công brute-force |
| Hết hạn token | Đặt thời hạn JWT ngắn (15-60 phút) với refresh token |
| Xác thực redirect URI | Chỉ cho phép redirect URI đã được whitelist |
Xác thực Redirect URI
Ngăn chặn tấn công open redirect bằng cách xác thực redirect URI:
@Component
public class OAuth2AuthenticationSuccessHandler
extends SimpleUrlAuthenticationSuccessHandler {
@Value("${app.oauth2.authorized-redirect-uri}")
private String authorizedRedirectUri;
@Override
public void onAuthenticationSuccess(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
String targetUrl = determineTargetUrl(request, authentication);
if (response.isCommitted()) {
logger.debug("Response đã được commit.");
return;
}
getRedirectStrategy().sendRedirect(
request, response, targetUrl);
}
private String determineTargetUrl(
HttpServletRequest request,
Authentication authentication) {
CustomUserPrincipal userPrincipal =
(CustomUserPrincipal) authentication.getPrincipal();
String token = tokenProvider.generateToken(userPrincipal);
return UriComponentsBuilder
.fromUriString(authorizedRedirectUri)
.queryParam("token", token)
.build()
.toUriString();
}
}application.yml cho Production
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope: email, profile
redirect-uri: "https://yourdomain.com/login/oauth2/code/google"
github:
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
scope: user:email, read:user
redirect-uri: "https://yourdomain.com/login/oauth2/code/github"
app:
jwt:
secret: ${JWT_SECRET}
expiration: 3600000 # 1 giờ trong production
oauth2:
authorized-redirect-uri: https://yourdomain.com/oauth2/redirectThêm nhà cung cấp mới
Kiến trúc này giúp dễ dàng thêm nhà cung cấp mới. Ví dụ, để thêm Facebook:
- Đăng ký ứng dụng tại Facebook Developers
- Thêm vào
application.yml:
spring:
security:
oauth2:
client:
registration:
facebook:
client-id: ${FACEBOOK_CLIENT_ID}
client-secret: ${FACEBOOK_CLIENT_SECRET}
scope: email, public_profile- Tạo
FacebookOAuth2UserInfo:
public class FacebookOAuth2UserInfo extends OAuth2UserInfo {
public FacebookOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return (String) attributes.get("id");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getImageUrl() {
Map<String, Object> pictureObj =
(Map<String, Object>) attributes.get("picture");
if (pictureObj != null) {
Map<String, Object> data =
(Map<String, Object>) pictureObj.get("data");
if (data != null) {
return (String) data.get("url");
}
}
return null;
}
}- Thêm vào
OAuth2UserInfoFactoryvà enumAuthProvider
Các vấn đề thường gặp
Vấn đề 1: Redirect URI không khớp
Lỗi: redirect_uri_mismatch từ Google hoặc GitHub
Giải pháp: Redirect URI trong bảng điều khiển nhà cung cấp OAuth phải khớp chính xác với URI mà Spring Boot sử dụng:
http://localhost:8080/login/oauth2/code/google (development)
https://yourdomain.com/login/oauth2/code/google (production)Kiểm tra dấu gạch chéo cuối, http vs https, và số cổng.
Vấn đề 2: Email GitHub là Null
Vấn đề: Người dùng GitHub với email riêng tư trả về null cho trường email.
Giải pháp: Yêu cầu scope user:email và lấy email qua GitHub API (đã trình bày ở Phần 5).
Vấn đề 3: CORS với OAuth2 Redirects
Vấn đề: Lỗi CORS khi frontend chuyển hướng đến endpoint OAuth2 của backend.
Giải pháp: Frontend nên sử dụng window.location.href (chuyển hướng toàn trang), không phải fetch(). Đăng nhập OAuth2 yêu cầu chuyển hướng trình duyệt, không phải lệnh gọi AJAX.
// ĐÚNG — chuyển hướng toàn trang
window.location.href = '/oauth2/authorization/google';
// SAI — sẽ gây lỗi CORS
fetch('/oauth2/authorization/google');Vấn đề 4: Xung đột quản lý Session
Vấn đề: Luồng OAuth2 cần session để lưu authorization request, nhưng API của bạn là stateless.
Giải pháp: Sử dụng HttpSessionOAuth2AuthorizationRequestRepository tạm thời trong luồng OAuth2, nhưng giữ API stateless với JWT:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http)
throws Exception {
http
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(auth -> auth
.authorizationRequestRepository(
new HttpSessionOAuth2AuthorizationRequestRepository()))
// ...
);
return http.build();
}Lưu ý: Để OAuth2 hoàn toàn stateless, hãy cân nhắc sử dụng lưu trữ authorization request dựa trên cookie.
Tóm tắt và những điểm chính
✅ Luồng OAuth2 Authorization Code là tiêu chuẩn cho đăng nhập mạng xã hội — giữ token phía server
✅ Spring Boot tự động cấu hình Google và GitHub — chỉ cần thêm thông tin đăng nhập trong application.yml
✅ CustomOAuth2UserService ánh xạ dữ liệu nhà cung cấp sang mô hình người dùng và xử lý đăng ký
✅ Liên kết tài khoản cho phép người dùng kết nối nhiều nhà cung cấp vào một tài khoản
✅ Kết hợp OAuth2 với JWT — OAuth2 xác thực, sau đó ứng dụng cấp JWT cho truy cập API
✅ OAuth2SuccessHandler là cầu nối bằng cách cấp JWT sau đăng nhập mạng xã hội thành công
✅ Tích hợp frontend sử dụng chuyển hướng toàn trang, không phải AJAX, cho luồng OAuth2
✅ Annotation @CurrentUser cung cấp cách truy cập người dùng đã xác thực trong controller
✅ Test với oauth2Login() mock từ Spring Security Test cho integration testing
✅ Bảo mật production yêu cầu HTTPS, xác thực redirect URI, và quản lý thông tin đăng nhập đúng cách
Tiếp theo là gì?
Bây giờ bạn đã thêm đăng nhập mạng xã hội vào ứng dụng Spring Boot, hãy tiếp tục xây dựng:
Tiếp tục chuỗi Spring Boot
- Kiểm thử nâng cao: Học contract testing và performance testing để đảm bảo luồng OAuth2 sẵn sàng cho production
- Triển khai Docker & Kubernetes: Container hóa ứng dụng với quản lý secrets phù hợp cho thông tin OAuth2
- Microservices với Spring Cloud: Triển khai xác thực OAuth2 tập trung trên nhiều services
Bài viết Spring Boot liên quan
- Xác thực & Phân quyền JWT — nền tảng mà bài viết này xây dựng dựa trên
- REST API Best Practices — các mẫu thiết kế cho endpoint được bảo vệ
- Tài liệu API với OpenAPI — tạo tài liệu cho API được bảo vệ OAuth2
Bài viết nền tảng
- Hướng dẫn đầy đủ về HTTP — hiểu các redirect và header trong luồng OAuth2
- Kiến trúc Server-Client — mô hình client-server đằng sau OAuth2
Thuộc chuỗi Lộ trình học Spring Boot
📬 Đăng ký nhận bản tin
Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.
Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.
💬 Comments
Đăng nhập để bình luận
Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.