Spring Security & JWT를 이용한 자체 Login & OAuth2 Login(네이버, 카카오) 구현 (5) - OAuth 관련 클래스 설정

오형상·2024년 9월 22일
0

CoinToZ

목록 보기
5/9
post-thumbnail

1. 의존성 추가

Spring Boot에서 소셜 로그인을 구현하기 위해서는 spring-boot-starter-oauth2-client를 추가해야 합니다.

implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

이 의존성을 추가하면 기본적인 OAuth2 클라이언트 기능을 사용할 수 있습니다.


2. DefalutOAuth2User를 상속한 User 클래스 - CustomOAuth2User

OAuth2 로그인 사용자를 표현하는 기본 클래스인 DefaultOAuth2User를 확장하여 CustomOAuth2User 클래스를 구현합니다. 이 클래스에는 이메일과 사용자 역할(UserRole)을 추가로 저장하도록 설계했습니다.

📌 CustomOAuth2User 전체 코드

/**
 * DefaultOAuth2User를 상속하고, email과 role 필드를 추가로 가진다.
 */
@Getter
public class CustomOAuth2User extends DefaultOAuth2User {

    private String email;
    private UserRole role;

    public CustomOAuth2User(Collection<? extends GrantedAuthority> authorities,
                            Map<String, Object> attributes, String nameAttributeKey,
                            String email, UserRole role) {
        super(authorities, attributes, nameAttributeKey);
        this.email = email;
        this.role = role;
    }
}
  • super()로 부모 객체인 DefaultOAuth2User를 생성하고, emailrole 파라미터를 추가로 받아서, 주입하여 CustomOAuth2User를 생성합니다.

3. OAuth DTO 클래스 - OAuthAttributes

다양한 소셜 로그인 API에서 제공하는 사용자 정보가 다를 수 있습니다. 이 데이터를 통일된 형태로 다룰 수 있도록 OAuthAttributes라는 DTO 클래스를 만들어, 각 소셜 서비스별로 데이터를 분기 처리합니다.

📌 OAuthAttributes 전체 코드

/**
 * 각 소셜에서 받아오는 데이터가 다르므로
 * 소셜별로 데이터를 받는 데이터를 분기 처리하는 DTO 클래스
 */
@Getter
public class OAuthAttributes {

    private String nameAttributeKey; // OAuth2 로그인 진행 시 키가 되는 필드 값, PK와 같은 의미
    private OAuth2UserInfo oauth2UserInfo; // 소셜 타입별 로그인 유저 정보(닉네임, 이메일, 프로필 사진 등등)

    @Builder
    public OAuthAttributes(String nameAttributeKey, OAuth2UserInfo oauth2UserInfo) {
        this.nameAttributeKey = nameAttributeKey;
        this.oauth2UserInfo = oauth2UserInfo;
    }

    /**
     * SocialType에 맞는 메소드 호출하여 OAuthAttributes 객체 반환
     * 파라미터 : userNameAttributeName -> OAuth2 로그인 시 키(PK)가 되는 값 / attributes : OAuth 서비스의 유저 정보들
     */
    public static OAuthAttributes of(SocialType socialType,
                                     String userNameAttributeName, Map<String, Object> attributes) {
        switch (socialType) {
            case NAVER:
                return ofNaver(userNameAttributeName, attributes);
            case KAKAO:
                return ofKakao(userNameAttributeName, attributes);
            default:
                throw new AppException(ErrorCode.INVALID_SOCIAL_TYPE,ErrorCode.INVALID_SOCIAL_TYPE.getMessage());
        }
    }

    private static OAuthAttributes ofKakao(String userNameAttributeName, Map<String, Object> attributes) {
        return OAuthAttributes.builder()
                .nameAttributeKey(userNameAttributeName)
                .oauth2UserInfo(new KakaoOAuth2UserInfo(attributes))
                .build();
    }

    public static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
        return OAuthAttributes.builder()
                .nameAttributeKey(userNameAttributeName)
                .oauth2UserInfo(new NaverOAuth2UserInfo(attributes))
                .build();
    }

    /**
     * of메소드로 OAuthAttributes 객체가 생성되어, 유저 정보들이 담긴 OAuth2UserInfo가 소셜 타입별로 주입된 상태
     * OAuth2UserInfo에서 socialId(식별값), nickname, imageUrl을 가져와서 build
     * email에는 UUID로 중복 없는 랜덤 값 생성
     * role은 GUEST로 설정
     */
    public User toEntity(SocialType socialType, OAuth2UserInfo oauth2UserInfo) {
        return User.builder()
                .socialType(socialType)
                .socialId(oauth2UserInfo.getId())
                .email(UUID.randomUUID() + "@socialUser.com")
                .userName(oauth2UserInfo.getNickname())
                .imageUrl(oauth2UserInfo.getImageUrl())
                .userRole(USER)
                .build();
    }
}

4. 소셜 타입별 유저 정보를 가지는 OAuth2UserInfo 추상 클래스 및 자식 클래스

OAuth2 소셜 서비스별로 다른 유저 정보를 처리하기 위해, OAuth2UserInfo라는 추상 클래스를 만들고 이를 상속한 KakaoOAuth2UserInfoNaverOAuth2UserInfo 클래스를 구현합니다.

📌 OAuth2UserInfo 상세 코드

public abstract class OAuth2UserInfo {

    protected Map<String, Object> attributes;

    public OAuth2UserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }

    public abstract String getId(); // 소셜 식별 값 : 카카오 - "id", 네이버 - "id"
    public abstract String getNickname();
    public abstract String getImageUrl();
}

📌 NaverOAuth2UserInfo 상세 코드

네이버 로그인 API에서 제공하는 사용자 정보를 처리하는 클래스입니다. 네이버는 사용자 정보를 response라는 키 아래에서 제공합니다. 이 구조에 맞춰 getId, getNickname, getImageUrl 메서드를 구현하여, 각각 사용자 ID, 닉네임, 프로필 이미지를 반환하도록 합니다.

@Slf4j
public class NaverOAuth2UserInfo extends OAuth2UserInfo {

    public NaverOAuth2UserInfo(Map<String, Object> attributes) {
        super(attributes);
    }

    @Override
    public String getId() {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");
        return response != null ? (String) response.get("id") : null;
    }

    @Override
    public String getNickname() {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");
        log.info("response:{}", response);
        return response != null ? (String) response.get("name") : null;
    }

    @Override
    public String getImageUrl() {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");
        return response != null ? (String) response.get("profile_image") : null;
    }
}

📌 KakaoOAuth2UserInfo 전체 코드

카카오 로그인 API에서 제공하는 사용자 정보를 처리하는 클래스입니다. 카카오는 사용자 정보를 kakao_accountprofile이라는 구조로 제공합니다. 이를 이용해 getId, getNickname, getImageUrl 메서드를 구현하여, 카카오 사용자의 정보를 추출합니다.

@Slf4j
public class KakaoOAuth2UserInfo extends OAuth2UserInfo {

    public KakaoOAuth2UserInfo(Map<String, Object> attributes) {
        super(attributes);
    }

    @Override
    public String getId() {
        return String.valueOf(attributes.get("id"));
    }

    @Override
    public String getNickname() {
        Map<String, Object> account = (Map<String, Object>) attributes.get("kakao_account");
        Map<String, Object> profile = (Map<String, Object>) account.get("profile");
        return profile != null ? (String) profile.get("nickname") : null;
    }

    @Override
    public String getImageUrl() {
        Map<String, Object> account = (Map<String, Object>) attributes.get("kakao_account");
        Map<String, Object> profile = (Map<String, Object>) account.get("profile");
        return profile != null ? (String) profile.get("thumbnail_image_url") : null;
    }
}

5. OAuth2UserService를 커스텀한 CustomOAuth2UserService

기본적으로 Spring SecurityDefaultOAuth2UserService를 사용하여 OAuth2 로그인을 처리합니다. 우리는 이 기본 동작을 커스터마이징하여, 소셜 서비스에서 제공하는 사용자 정보를 커스터마이징된 OAuth2UserInfo를 통해 처리하고, 이를 User 엔티티와 연결시키기 위해 CustomOAuth2UserService를 구현합니다.

📌 CustomOAuth2UserService 전체 코드

@Slf4j
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final UserRepository userRepository;

    private static final String NAVER = "naver";
    private static final String KAKAO = "kakao";

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        log.info("CustomOAuth2UserService

.loadUser() 실행 - OAuth2 로그인 요청 진입");

        /**
         * DefaultOAuth2UserService 객체를 생성하여, loadUser(userRequest)를 통해 DefaultOAuth2User 객체를 생성 후 반환
         */
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

        /**
         * userRequest에서 registrationId 추출 후 SocialType 저장
         */
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        SocialType socialType = getSocialType(registrationId);
        String userNameAttributeName = userRequest.getClientRegistration()
                .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
        Map<String, Object> attributes = oAuth2User.getAttributes(); 

        // socialType에 따라 유저 정보를 통해 OAuthAttributes 객체 생성
        OAuthAttributes extractAttributes = OAuthAttributes.of(socialType, userNameAttributeName, attributes);

        User createdUser = getUser(extractAttributes, socialType); 

        // DefaultOAuth2User를 구현한 CustomOAuth2User 객체를 생성해서 반환
        return new CustomOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority(createdUser.getUserRole().name())),
                attributes,
                extractAttributes.getNameAttributeKey(),
                createdUser.getEmail(),
                createdUser.getUserRole()
        );
    }

    private SocialType getSocialType(String registrationId) {
        switch (registrationId) {
            case NAVER:
                return SocialType.NAVER;
            case KAKAO:
                return SocialType.KAKAO;
            default:
                throw new AppException(ErrorCode.REGISTRATION_ID_NOT_FOUND, "Unknown registrationId: " + registrationId);
        }
    }

    /**
     * 소셜 로그인의 식별값 id를 통해 회원을 찾아 반환하는 메소드
     */
    private User getUser(OAuthAttributes attributes, SocialType socialType) {
        return userRepository.findBySocialTypeAndSocialId(socialType, attributes.getOauth2UserInfo().getId())
            .orElseGet(() -> saveUser(attributes, socialType));
    }

    /**
     * OAuthAttributes의 toEntity() 메소드를 통해 빌더로 User 객체 생성 후 반환
     */
    private User saveUser(OAuthAttributes attributes, SocialType socialType) {
        User createdUser = attributes.toEntity(socialType, attributes.getOauth2UserInfo());
        return userRepository.save(createdUser);
    }
}
  • CustomOAuth2UserServiceOAuth2UserService 인터페이스를 구현하여, OAuth2 로그인 요청이 들어올 때 사용자의 정보를 추출하고, 데이터베이스에 저장된 유저 정보를 확인합니다. 없으면 새로운 유저를 생성하고, 있으면 기존 유저 정보를 반환합니다.
  • 소셜 로그인으로 인증된 사용자가 이미 존재하는지 확인하고, 존재하지 않는다면 새로운 사용자 정보를 데이터베이스에 저장합니다.

6. OAuth2 로그인 성공 시 로직을 처리하는 OAuth2LoginSuccessHandler

로그인에 성공했을 때 JWT 토큰을 발급하여 클라이언트에게 전달하는 로직을 OAuth2LoginSuccessHandler에서 구현합니다. 이 핸들러는 SimpleUrlAuthenticationSuccessHandler를 상속받아, 로그인 성공 시 커스텀한 동작을 정의합니다.

📌 OAuth2LoginSuccessHandler 전체 코드

@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtService jwtService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        log.info("OAuth2 Login 성공!");
        try {
            CustomOAuth2User oAuth2User = (CustomOAuth2User) authentication.getPrincipal();

            loginSuccess(request, response, oAuth2User); // 로그인에 성공한 경우 access, refresh 토큰 생성

        } catch (Exception e) {
            throw e;
        }
    }

    private void loginSuccess(HttpServletRequest request, HttpServletResponse response, CustomOAuth2User oAuth2User) throws IOException {
        log.info("getEmail:{}", oAuth2User.getEmail());
        String accessToken = jwtService.createAccessToken(oAuth2User.getEmail());
        String refreshToken = jwtService.createRefreshToken();

        response.addHeader(jwtService.getAccessHeader(), "Bearer " + accessToken);
        response.addHeader(jwtService.getRefreshHeader(), "Bearer " + refreshToken);

        jwtService.sendAccessAndRefreshToken(response, accessToken, refreshToken);
        jwtService.updateRefreshToken(oAuth2User.getEmail(), refreshToken);

        String targetUrl = UriComponentsBuilder.fromUriString("http://localhost:3000/login")
                .queryParam("accessToken", accessToken)
                .queryParam("refreshToken", refreshToken)
                .queryParam("email",oAuth2User.getEmail())
                .build().toUriString();

        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
}
  • 로그인에 성공하면 CustomOAuth2User 객체에서 사용자의 이메일을 추출하고, 이를 통해 JWT 토큰을 생성합니다.
  • 생성된 토큰은 HTTP 응답 헤더에 담아 클라이언트에 전달합니다.

7. OAuth2 로그인 실패 시 로직을 처리하는 OAuth2LoginFailureHandler

소셜 로그인 실패 시 처리할 로직은 OAuth2LoginFailureHandler에 구현됩니다. 이 핸들러는 SimpleUrlAuthenticationFailureHandler를 상속받아, 로그인 실패 시 JSON 형식의 에러 응답을 전송합니다.

📌 OAuth2LoginFailureHandler 전체 코드

@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    private final ObjectMapper objectMapper;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {

        // HTTP 응답 상태 코드를 401로 설정
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        // 응답의 Content-Type을 JSON 형식으로 설정
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");

        // 로그인 실패 시 사용할 ErrorResponse 객체 생성 (ErrorCode와 예외 메시지를 담음)
        ErrorResponse errorResponse = new ErrorResponse(ErrorCode.LOGIN_FAILED, exception.getMessage());

        // ErrorResponse 객체를 Response<ErrorResponse>로 감싸서 통일된 응답 형식으로 변환
        Response<ErrorResponse> responseBody = Response.error("ERROR", errorResponse);

        // ObjectMapper를 사용하여 Response 객체를 JSON으로 직렬화하고, 클라이언트에 응답으로 보냄
        objectMapper.writeValue(response.getWriter(), responseBody);

        // 로그인 실패 로그 기록
        log.info("소셜 로그인에 실패했습니다. 에러 메시지 : {}", exception.getMessage());
    }
}
  • 로그인 실패 시, 상태 코드를 401 Unauthorized로 설정하고, 에러 메시지를 담은 JSON 응답을 전송합니다.

0개의 댓글