OAuth2.0 로그인 관련 클래스 코드를 설명드리고자 합니다.
0. 들어가기 전
OAuth 관련 패키지 구조는 다음과 같습니다.
OAuth2Service를 생성하기 위해 spring-boot-starter-oauth2-client 라이브러리를 사용합니다.
build.gradle에 다음과 같이 의존성을 추가해 줬습니다.
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
1. DefaultOAuth2User 클래스
OAuth2UserService에서 사용할 OAuth2User 객체 클래스입니다.
일반적으로, 다른 프로젝트나 서비스에서는 Resource Server가 제공하지 않는 추가 정보를 관리하기 위해 DefaultOAuth2User를 커스텀하여 CustomOAuth2User 클래스를 생성하는 경우가 많습니다. 예를 들어, 이메일이나 역할(Role)과 같은 필드를 추가로 정의하여 사용합니다.
현재 진행 중인 프로젝트에서는 attributes 맵에 email, nickname, profileImage를 포함시켜서 CustomOAuth2User을 별도로 만들지 않고 DefaultOAuth2User를 사용하고 있습니다.
log.info("유저 로그인 성공!");
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority("USER")),
attributes,
"id"
);
2. OAuth2UserService를 커스텀한 CustomOAuth2UserService
🎯 CustomOAuth2UserService 전체 코드
@Service
@Slf4j
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
private final CategoryService categoryService;
private final RewardRepository rewardRepository;
private final CategoryRepository categoryRepository;
private final TodoRepository todoRepository;
private static final ZoneId KOREA_ZONE = ZoneId.of("Asia/Seoul");
@Override
@Transactional
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
if (!"kakao".equals(registrationId)) {
throw new OAuth2AuthenticationException(ErrorCode.INVALID_TOKEN.getMessage());
}
Map<String, Object> attributes = oAuth2User.getAttributes();
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
String email = (String) kakaoAccount.get("email");
String nickname = (String) properties.get("nickname");
String profileImage = (String) properties.get("profile_image");
if (email == null) {
throw new OAuth2AuthenticationException(ErrorCode.TOKEN_NOT_FOUND.getMessage());
}
User user = userRepository.findByEmail(email)
.orElseGet(() -> {
User newUser = User.builder()
.email(email)
.nickname(nickname)
.provider(AuthProvider.KAKAO)
.profileImage(profileImage)
.build();
User savedUser = userRepository.save(newUser);
Reward reward = Reward.builder()
.user(savedUser)
.experience(0)
.nutrition(0)
.build();
rewardRepository.save(reward);
createDefaultCategories(savedUser.getId());
createDefaultInfo(savedUser);
return savedUser;
});
user.updateNickname(nickname);
userRepository.save(user);
log.info("유저 로그인 성공!");
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority("USER")),
attributes,
"id"
);
}
private void createDefaultCategories(Long userId) {
String[] defaultCategories = {"일반", "일일 미션", "자율 미션"};
String[] defaultColors = {"#6DC2FF", "#086BFF", "#7DB1FF"};
for (int i = 0; i < defaultCategories.length; i++) {
CategoryReqDTO categoryReqDto = CategoryReqDTO.builder()
.name(defaultCategories[i])
.color(defaultColors[i])
.build();
categoryService.createCategory(userId, categoryReqDto);
}
}
private void createDefaultInfo(User user){
/*
사용자 생성 시 일일 미션 미리 넣어놓기
카테고리 몇개 넣어 놓기
*/
categoryService.createCategory(user.getId(), new CategoryReqDTO("식습관", "#000000"));
categoryService.createCategory(user.getId(), new CategoryReqDTO("운동", "#000000"));
try {
// 해당 사용자의 '일일 미션' 카테고리 찾기
Category dailyMissionCategory = categoryRepository.findByUserIdAndName(user.getId(), "일일 미션")
.orElseThrow(() -> new ResourceNotFoundException(ErrorCode.CATEGORY_NOT_FOUND));
log.info(dailyMissionCategory.toString());
// AI 서버에 Daily Mission 요청
DailyMissionAiResDTO missionResponse = new DailyMissionAiResDTO("다형성 공부하기");
// 미션 생성
Todo mission = Todo.builder()
.user(user)
.category(dailyMissionCategory)
.title(missionResponse.missionTitle())
.status(false)
.date(LocalDate.now(KOREA_ZONE))
.build();
todoRepository.save(mission);
log.info("사용자 {}의 미션 생성 완료: {}", user.getId(), missionResponse.missionTitle());
} catch (ResourceNotFoundException e) {
log.error("사용자 {}의 강제 미션 카테고리를 찾을 수 없습니다.", user.getId());
} catch (Exception e) {
log.error("사용자 {}의 미션 생성 중 오류 발생: {}", user.getId(), e.getMessage());
}
}
}
a. 카카오 OAuth2 로그인 처리 과정에서 사용자의 정보 가져오기
OAuth2User oAuth2User = super.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
if (!"kakao".equals(registrationId)) {
throw new OAuth2AuthenticationException(ErrorCode.INVALID_TOKEN.getMessage());
}
Map<String, Object> attributes = oAuth2User.getAttributes();
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
String email = (String) kakaoAccount.get("email");
String nickname = (String) properties.get("nickname");
String profileImage = (String) properties.get("profile_image");
if (email == null) {
throw new OAuth2AuthenticationException(ErrorCode.TOKEN_NOT_FOUND.getMessage());
}
먼저, super.loadUser(userRequest)를 호출하여 OAuth2 인증을 통해 사용자 정보를 가져옵니다.
그런 다음, userRequest.getClientRegistration().getRegistrationId()를 통해 사용자가 로그인을 시도한 서비스가 카카오인지 확인합니다. 카카오가 아니라면 인증 오류를 발생시킵니다.
이후, 카카오에서 제공한 사용자 정보는 oAuth2User.getAttributes()를 통해 attributes 맵에 저장됩니다. 이 맵에서 "kakao_account" 키를 통해 카카오 계정 관련 정보를 가져오고, "properties" 키를 통해 사용자 프로필 정보를 얻습니다. 이메일, 닉네임, 프로필 이미지와 같은 사용자 정보를 각각 추출한 후, 이메일이 없다면 예외를 던져 인증 오류를 처리합니다.
이후의 코드들은 프로젝트에 맞게 로그인 후 추가적으로 생성되는 부분이므로, 설명은 생략하겠습니다.
3. OAuth2 로그인 성공 시 로직을 처리하는 OAuth2LoginSuccessHandler
💡OAuth2 로그인이 성공한다면, OAuth2LoginSuccessHandler의 로직이 실행됩니다.
🎯 OAuth2LoginSuccessHandler 전체 코드
@Slf4j
@RequiredArgsConstructor
@Component
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtTokenProvider jwtTokenProvider;
private final UserRepository userRepository;
@Value("${front.redirectUrl}") private String redirectUrl;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
Map<String, Object> kakaoAccount = (Map<String, Object>) oAuth2User.getAttributes().get("kakao_account");
String email = (String) kakaoAccount.get("email");
if (email == null) {
throw new OAuth2AuthenticationException(ErrorCode.TOKEN_NOT_FOUND.getMessage());
}
User user = userRepository.findByEmail(email).orElseThrow(
() -> new OAuth2AuthenticationException(ErrorCode.USER_NOT_FOUND.getMessage())
);
jwtTokenProvider.createRefreshToken(response, user.getId());
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
}
}
인증된 사용자의 정보를 OAuth2User 객체를 통해 가져온 후, 해당 객체에서 Kakao 계정 정보를 추출하여 이메일을 확인합니다. 만약 이메일이 존재하지 않으면 예외를 발생시키고, 이메일이 있을 경우 이를 사용해 데이터베이스에서 사용자 정보를 조회합니다. 사용자 정보를 찾은 후, 사용자의 ID를 기반으로 Refresh Token을 생성하고, 이후 리다이렉션할 URL로 응답을 전송합니다.
.failureHandler((request, response, exception) -> {
log.error("OAuth2 Login Failed", exception);
response.sendRedirect("/auth/login?error");
})
로그인 실패 시, log.error를 통해 실패한 원인과 예외 정보를 로그로 기록합니다. 이후, 사용자에게 로그인 실패를 알리기 위해 /auth/login?error URL로 리다이렉션을 보냅니다.
4. OAuth2 로그아웃 성공 시 로직을 처리하는 OAuth2LogoutSuccessHandler
💡OAuth2 로그아웃 성공한다면, OAuth2LoginSuccessHandler의 로직이 실행됩니다.
🎯 OAuth2LogoutSuccessHandler 전체 코드
@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2LogoutSuccessHandler implements LogoutSuccessHandler {
private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenService refreshTokenService;
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
log.info("Logout successful");
String refreshToken = null;
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("refreshToken".equals(cookie.getName())) {
refreshToken = cookie.getValue();
break;
}
}
}
if (refreshToken != null && !refreshToken.isEmpty()) {
try {
Long userId = jwtTokenProvider.getUserIdFromRefreshToken(refreshToken);
refreshTokenService.deleteRefreshToken(userId);
log.info("Refresh token redis deleted");
} catch (Exception e) {
log.error("Error during logout process: {}", e.getMessage(), e);
}
}
Cookie refreshTokenCookie = new Cookie("refreshToken", null);
refreshTokenCookie.setHttpOnly(true);
refreshTokenCookie.setSecure(true);
refreshTokenCookie.setPath("/");
refreshTokenCookie.setMaxAge(0);
response.addCookie(refreshTokenCookie);
log.info("Refresh token cookie deleted");
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().flush();
}
}
로그아웃이 성공하면, 먼저 "Logout successful" 메시지를 로그로 기록합니다.
이후, 요청에 포함된 쿠키들 중 refreshToken이라는 이름의 쿠키를 찾아 그 값을 가져옵니다. 만약 refreshToken 값이 존재하면, 해당 토큰을 통해 사용자 ID를 추출하고, 이를 기반으로 Redis에서 해당 사용자의 refresh token을 삭제합니다. 삭제 작업이 완료되면 "Refresh token redis deleted"라는 메시지를 로그로 기록합니다.
그 후, 클라이언트에게 보낼 새로운 refreshToken 쿠키를 생성하여 값을 null로 설정하고, 만료 시간을 0으로 설정하여 해당 쿠키를 삭제합니다. 이 삭제 작업이 완료되면 "Refresh token cookie deleted"라는 메시지를 로그로 기록합니다.
마지막으로, HTTP 응답 상태를 OK로 설정하고, 응답을 클라이언트로 전송하여 로그아웃 처리가 완료됩니다.
'Programming > Spring' 카테고리의 다른 글
[Spring] API 공통 응답 포맷 (0) | 2024.11.17 |
---|---|
[Spring] 동시성 처리 (18) | 2024.11.15 |
[Spring] Security + JWT + OAuth2를 이용한 로그인 구현 (4) - OAuth란? (0) | 2024.11.06 |
[Spring] Security + JWT + OAuth2를 이용한 로그인 구현 (3) - Spring Security 개념과 처리 과정 (0) | 2024.11.06 |
[Spring] Security + JWT + OAuth2를 이용한 로그인 구현 (2) - JWT 관련 클래스 생성 / JWT 인증 로직 (0) | 2024.10.29 |