[내일배움캠프] Spring_2기 96일차
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
# application.yml
spring:
profiles:
include: oauth
# application-oauth.yml 생성
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope: profile, email
public enum AuthProvider {
GOOGLE, KAKAO, NAVER
}
@Column
private String password;
public static User ofSocial(String email, UserRole role) {
User user = new User();
user.email = email;
user.password = null;
user.role = role;
user.rating = null;
return user;
}
@Getter
@Entity
@Table(name = "user_social_accounts", uniqueConstraints = {
@UniqueConstraint(columnNames = {"user_id", "provider"}),
@UniqueConstraint(columnNames = {"provider", "provider_id"})
}) // 복합 유니크 제약
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserSocialAccount {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long userId;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private AuthProvider provider;
@Column(nullable = false)
private String providerId;
}
public interface UserSocialAccountRepository extends JpaRepository<UserSocialAccount, Long> {
Optional<UserSocialAccount> findByProviderAndProviderId(AuthProvider provider, String providerId);
}
@Getter
@Builder
public class OAuthAttributes {
private String email;
private AuthProvider provider;
private String providerId;
private String nameAttributeKey;
public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
return ofGoogle(userNameAttributeName, attributes);
}
private static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
return OAuthAttributes.builder()
.email((String) attributes.get("email"))
.provider(AuthProvider.GOOGLE)
.providerId((String) attributes.get("sub"))
.nameAttributeKey(userNameAttributeName)
.build();
}
}
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
private final UserSocialAccountRepository userSocialAccountRepository;
@Override
@Transactional
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails()
.getUserInfoEndpoint()
.getUserNameAttributeName();
OAuthAttributes authAttributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
User user = saveOrLoad(authAttributes);
Map<String, Object> principalAttributes = new HashMap<>(oAuth2User.getAttributes());
principalAttributes.put("userId", user.getId());
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(user.getRole().name())),
principalAttributes,
authAttributes.getNameAttributeKey()
);
}
private User saveOrLoad(OAuthAttributes authAttributes) {
Optional<UserSocialAccount> socialAccount = userSocialAccountRepository.findByProviderAndProviderId(
authAttributes.getProvider(), authAttributes.getProviderId());
if (socialAccount.isPresent()) {
return userRepository.findByIdAndDeletedFalse(socialAccount.get().getUserId()).orElseThrow(
() -> new ServiceErrorException(UserErrorEnum.USER_NOT_FOUND));
}
if (userRepository.existsByEmail(authAttributes.getEmail())) {
throw new ServiceErrorException(AuthErrorEnum.SOCIAL_LOGIN_EMAIL_CONFLICT);
}
try {
User newUser = User.ofSocial(authAttributes.getEmail(), UserRole.USER);
userRepository.save(newUser);
UserSocialAccount newSocialAccount = UserSocialAccount.of(newUser.getId(), authAttributes.getProvider(), authAttributes.getProviderId());
userSocialAccountRepository.save(newSocialAccount);
return newUser;
} catch (DataIntegrityViolationException e) {
Optional<UserSocialAccount> existing = userSocialAccountRepository.findByProviderAndProviderId(
authAttributes.getProvider(), authAttributes.getProviderId());
if (existing.isPresent()) {
return userRepository.findByIdAndDeletedFalse(existing.get().getUserId()).orElseThrow(
() -> new ServiceErrorException(UserErrorEnum.USER_NOT_FOUND));
}
if (userRepository.existsByEmail(authAttributes.getEmail())) {
throw new ServiceErrorException(AuthErrorEnum.SOCIAL_LOGIN_EMAIL_CONFLICT);
}
throw e;
}
}
}
loadUser() 호출
↓
구글 응답 → OAuthAttributes 변환
↓
providerId로 기존 소셜 유저 조회
├── 있으면 → 그냥 로그인
└── 없으면
├── 동일 이메일 일반가입 유저 있으면 → 예외
└── 없으면 → 신규 유저 + 소셜 계정 생성
@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtProvider jwtProvider;
private final UserRepository userRepository;
private final RedisTemplate<String, Object> redisTemplate;
private final ObjectMapper objectMapper;
private static final String REFRESH_TOKEN_PREFIX = "refresh:";
@Value("${jwt.refreshExpire}")
private long refreshTokenExpireTime;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
Number userId = oAuth2User.getAttribute("userId");
User user = userRepository.findByIdAndDeletedFalse(userId.longValue()).orElseThrow(
() -> new ServiceErrorException(UserErrorEnum.USER_NOT_FOUND));
String accessToken = jwtProvider.createAccessToken(user.getId(), user.getRole().name());
String refreshToken = jwtProvider.createRefreshToken(user.getId());
redisTemplate.opsForValue().set(
REFRESH_TOKEN_PREFIX + user.getId(),
refreshToken,
refreshTokenExpireTime,
TimeUnit.MILLISECONDS
);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(
BaseResponse.success(HttpStatus.OK.name(), "소셜 로그인 성공", new AuthLoginResponse(accessToken, refreshToken))));
}
}
소셜 로그인 성공
↓
OAuth2SuccessHandler.onAuthenticationSuccess() 호출
↓
authentication에서 이메일 꺼내기
↓
DB에서 유저 조회
↓
JWT 발급 + Redis에 refreshToken 저장
↓
응답으로 accessToken, refreshToken 반환
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(endpoint -> endpoint
.userService(customOAuth2UserService))
.successHandler(oAuth2SuccessHandler))