개발일기

React + Spring Oauth2 구글, 카카오 로그인 본문

photocard backend server 개발일기

React + Spring Oauth2 구글, 카카오 로그인

한둥둥 2024. 7. 3. 14:07

 

build.gradle 설정을 해주었다. 

//oauth2
implementation 'org.springframework.security:spring-security-oauth2-client'
implementation 'org.springframework.security:spring-security-oauth2-jose'

 

Oauth2.0 설정을 위하여 로그인 흐름을 그려보았다. 

1. 로그인 요청 

기본적으로 Spring Security에서 기본적으로 제공하는 URL이 있음.

http://{domain}/oauth2/authorization/{registrationId} 해당 URL을 시큐리티에서 구현해두었고, 따로 Controller를 제작하지 않는다.

2. 리다이렉트 URL 

SpringSecurity에서 기본적으로 제공하는 URL이 있다. http://{domain}/login/oauth2/code/{registrationId} 

 

Application.yml Oauth2설정 

  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope: profile, email
          kakao:
            client-id: ${KAKAO_CLIENT_ID}
            client-secret: ${KAKAO_CLIENT_SECRET}
            redirect-uri: https://photocard.site/login/oauth2/code/kakao
            client-authentication-method: client_secret_post
            authorization-grant-type: authorization_code
            scope: profile_nickname, profile_image, account_email
            client-name: Kakao
            
        provider:
          naver:
            authorization-uri: https://nid.naver.com/oauth2.0/authorize
            token-uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openai.naver.com/v1/nid/me
            user_name_attribute: response

          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id

해당 부분에 대한 설정 방법을 카카오 developer, google devleoper로 가서 설정해주면 된다. 

 

이제 Oauth2.0 관련 클래스를 생성해주자. 

내 서비스에서도 해당 회원에대한 useremail과 토큰을 저장해줘야 하기에 email, role필드를 따로 커스텀하여 추가해주자. 

 

CustomOauth2.0을 구현은 추가정보들을 내서비스에서 가지고자 받게되었다. 

 

 

CustomOauth2User.class

package com.realworld.feature.oauth.domain;

import com.realworld.feature.auth.Authority;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;

import java.util.Collection;
import java.util.Map;

public class CustomOAuth2User extends DefaultOAuth2User {
    private final String email;
    private final Authority authority;

    public CustomOAuth2User(Collection<? extends GrantedAuthority> authorities, Map<String, Object> attributes, String nameAttributeKey, String email, Authority authority) {
        super(authorities, attributes, nameAttributeKey);
        this.email = email;
        this.authority = authority;
    }
}

 

OauthAttributes

각 소셜에서 받아오는 데이터가 다르므로, 소셜별로 받는 데이터를 분기처리하는 DTO 클래스이다. 

package com.realworld.feature.oauth.domain;


import com.realworld.feature.auth.Authority;
import com.realworld.feature.member.domain.Member;
import com.realworld.feature.oauth.domain.user.GoogleOAuth2UserInfo;
import com.realworld.feature.oauth.domain.user.KakaoOAuth2UserInfo;
import com.realworld.feature.oauth.domain.user.OAuth2UserInfo;
import com.realworld.global.category.SocialType;
import lombok.Builder;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;

import java.util.Map;

/**
 * 각 소셜에서 받아오는 데이터가 다르므로
 * 소셜별로 데이터를 받는 데이터를 분기 처리하는 클래스
 */
@Getter
@Slf4j
public class OAuthAttributes {
    private final String nameAttributeKey; // OAuth2 로그인 진행 시 키가 되는 필드 값, PK와 같은 의미
    private final OAuth2UserInfo oAuth2UserInfo;

    @Builder
    private OAuthAttributes(String nameAttributeKey, OAuth2UserInfo oAuth2UserInfo) {
        this.nameAttributeKey = nameAttributeKey;
        this.oAuth2UserInfo = oAuth2UserInfo;
    }

    /**
     * SocialType에 맞는 메소드 호출하여 OAuthAttributes 객체 반환
     * 파라미터 : userNameAttributeName -> OAuth2 로그인 시 키(PK)가 되는 값 / attributes : OAuth 서비스의 유저 정보들
     * 소설별 of 메소드(ofGoogle, ofKakao, ofNaver)들은 각각 소셜 로그인 API에서 제공하는
     * 회원의 식별값(id), attributes, nameAttributeKey를 저장 후 build
     */

    public static OAuthAttributes of(SocialType socialType, String userNameAttributeName, Map<String, Object> attributes) {
        log.info("Extracting OAuthAttributes for socialType: {}", socialType);
        log.info("attributes :: {}", attributes);
        if (socialType == SocialType.KAKAO) {
            return ofKakao(userNameAttributeName, attributes);
        }

        return ofGoogle(userNameAttributeName, attributes);
    }

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

    public static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
        return OAuthAttributes.builder()
                .nameAttributeKey(userNameAttributeName)
                .oAuth2UserInfo(new GoogleOAuth2UserInfo(attributes))
                .build();
    }


    public Member toEntity(SocialType socialType, OAuth2UserInfo oAuth2UserInfo) {
        return Member.builder()
                .userId(oAuth2UserInfo.getId())
                .userEmail(oAuth2UserInfo.getUserEmail())
                .oauthImage(oAuth2UserInfo.getImageUrl())
                .nickname(oAuth2UserInfo.getNickname())
                .authority(Authority.ROLE_USER)
                .build();
    }
}

 

 

빌더 부분

public Member toEntity(SocialType socialType, OAuth2UserInfo oAuth2UserInfo) {
        return Member.builder()
                .userId(oAuth2UserInfo.getId())
                .userEmail(oAuth2UserInfo.getUserEmail())
                .oauthImage(oAuth2UserInfo.getImageUrl())
                .nickname(oAuth2UserInfo.getNickname())
                .authority(Authority.ROLE_USER)
                .build();
    }

 

Oauth2 로그인시 진행되는 값들로 OAuth2UserInfo에는 소셜 타입별 해당하는 유저 정보가 들어있다. 

 

SocialType에 해당하는 of 메소드이다. 

public static OAuthAttributes of(SocialType socialType, String userNameAttributeName, Map<String, Object> attributes) {
        log.info("Extracting OAuthAttributes for socialType: {}", socialType);
        log.info("attributes :: {}", attributes);
        if (socialType == SocialType.KAKAO) {
            return ofKakao(userNameAttributeName, attributes);
        }

        return ofGoogle(userNameAttributeName, attributes);
    }

 

각각의 소셜 타입에 맞게 OAuth2Attributes를 생성해줍니다. 

 

OAuth2UserInfo.class

package com.realworld.feature.oauth.domain.user;

import java.util.Map;

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 getNickname();

    public abstract String getUserEmail();

    public abstract String getImageUrl();
}

 

 

추상 클래스를 상속 받는 클래스에서만 사용할 수 있도록 protected 제어자를 사용한다. 

 

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

생성자로 소셜 타입별 유저 정보 attributes 주입받아서 각 소셜 타입별 유저 정보 attributes를 주입 받아서 각 소셜 타입별 유저 정보 클래스가 소셜 타입에 맞는 attributes를 주입받아 가지도록 하였습니다.

 

구글에 해당하는 GoogleOAuth2UserInfo 코드

package com.realworld.feature.oauth.domain.user;

import java.util.Map;

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 getNickname() {
        return (String) attributes.get("name");
    }

    @Override
    public String getUserEmail() {
        return (String) attributes.get("email");
    }

    @Override
    public String getImageUrl() {
        return (String) attributes.get("picture");
    }
}

 

 

KakaoOauth2UserInfo.class

package com.realworld.feature.oauth.domain.user;

import lombok.extern.slf4j.Slf4j;

import java.util.Map;

@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");

        if (account == null) {
            return null;
        }

        Map<String, Object> profile = (Map<String, Object>) account.get("profile");

        if (profile == null) {
            return null;
        }

        return (String) profile.get("nickname");
    }

    @Override
    public String getUserEmail() {
        Map<String, Object> account = (Map<String, Object>) attributes.get("kakao_account");

        log.info("email :: {}", account.get("email"));

        return (String) account.get("email");
    }

    @Override
    public String getImageUrl() {
        Map<String, Object> account = (Map<String, Object>) attributes.get("kakao_account");

        if (account == null) {
            return null;
        }

        Map<String, Object> profile = (Map<String, Object>) account.get("profile");

        if (profile == null) {
            return null;
        }

        return (String) profile.get("thumbnail_image_url");
    }
}

 

 

CustomOauth2UserService.class

package com.realworld.feature.oauth.service;

import com.realworld.feature.member.domain.Member;
import com.realworld.feature.member.entity.MemberJpaEntity;
import com.realworld.feature.member.repository.MemberRepository;
import com.realworld.feature.oauth.domain.CustomOAuth2User;
import com.realworld.feature.oauth.domain.OAuthAttributes;
import com.realworld.global.category.SocialType;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

import java.util.Collections;
import java.util.Map;

import static com.realworld.global.category.SocialType.KAKAO;
import static com.realworld.global.category.SocialType.NAVER;

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

    private final MemberRepository memberRepository;

    @Transactional
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        log.info("로그인 요청 진입");
        System.out.println("getClientRegistration: " + userRequest.getClientRegistration());
        System.out.println("getAccessToken: " + userRequest.getAccessToken().getTokenValue());
        /**
         * DefaultOAuth2UserService 객체를 생성하여, loadUser(userRequest)를 통해  DefaultOAuth2User 객체를 생성 후 반환
         * DefaultOAuth2UserService의 loadUser() 소셜 로그인 API의 사용자 정보 제공 URI로 요청을 보냄
         * 사용자 정보를 얻은 후, 이를 통해 DefaultOAuth2User 객체를 생성 후 반환
         * 결과적으로, OAuth2User는 OAuth 서비스에서 가져온 유저 정보를 담고 있는 유저
         */
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

        /**
         * userRequest에서 registrationId 추출 후, registrationId으로 SocialType 저장
         * http://localhost:8080/oauth2/authorization/kakao에서 kakao가 registrationId
         * userNameAttributeName은 이후에 nameAttributeKey로 설정된다.
         */
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        log.info("registrationId :: {} ", registrationId);
        SocialType socialType = getSocialType(registrationId);
        log.info("socialType :: {}", socialType);
        // 3. userNameAttributeName 가져오기
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); // OAuth2 로그인 시 키(PK)가 되는 값
        Map<String, Object> attributes = oAuth2User.getAttributes(); // 소셜 로그인에서 API가 제공하는 userInfo의 Json (유저 정보들)

        log.info("registrationId = {}", registrationId);
        log.info("userNameAttributeName = {}", userNameAttributeName);

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

        Member createMember = getMember(extractAttributes, socialType);

        // DefaultOAuth2User를 구현한 CustomOAuth2User 객체를 생성해서 반환
        return new CustomOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority(createMember.getAuthority().toString())),
                attributes,
                extractAttributes.getNameAttributeKey(),
                createMember.getUserEmail(),
                createMember.getAuthority()
        );
    }

    /**
     * TODO: SocialType,
     * SocialType과 attributes에 들어있는 소셜 로그인의 식별값 id를 통해 회원을 찾아 반환하는 메소드
     * 만약 찾은 회원이 있다면, 그대로 반환하고 없다면 save를 통하여 회원을 저장한다.
     *
     * @param attributes
     * @param socialType
     * @return
     */
    private Member getMember(OAuthAttributes attributes, SocialType socialType) {
        log.info("attributes :: {}", attributes);

        MemberJpaEntity findMemberEntity = memberRepository.findById(attributes.getOAuth2UserInfo().getId()).orElse(null);

        if (findMemberEntity == null) {
            return saveMember(attributes, socialType);
        }
        return findMemberEntity.toDomain();
    }

    private Member saveMember(OAuthAttributes attributes, SocialType socialType) {
        Member createdMember = attributes.toEntity(socialType, attributes.getOAuth2UserInfo());

        return memberRepository.save(createdMember.toEntity()).toDomain();
    }

    private SocialType getSocialType(String registrationId) {
        if (NAVER.equals(registrationId.toUpperCase())) {
            return SocialType.NAVER;
        }
        if (KAKAO.equals(registrationId.toUpperCase())) {
            return SocialType.KAKAO;
        }

        return SocialType.GOOGLE;
    }
}

OAuth2UserService 에 해당하는 코드입니다. 

 

OAuth2LoginSuccessHandler 전체 코드

 

package com.realworld.feature.oauth.handler;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.realworld.feature.token.domain.Token;
import com.realworld.feature.token.service.TokenCommandService;
import com.realworld.global.config.jwt.JwtTokenProvider;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;

import java.io.IOException;

@Slf4j
@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {

    private static final String URI = "/";
    private final JwtTokenProvider tokenProvider;
    private final TokenCommandService tokenCommandService;
    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        log.info("OAuth2 login 성공!");
        log.info("{}", authentication.getName());
        log.info("{}", authentication);

        Token token = tokenProvider.createToken(authentication);
        token.setUserId(authentication.getName());

        tokenCommandService.saveToken(token);
        String redirectUrl = UriComponentsBuilder.fromUriString(URI)
                .build().toUriString();
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");

        String result = objectMapper.writeValueAsString(token);

        response.getWriter().write(result);
        response.sendRedirect(redirectUrl);
    }
}

 

Oauth2.0FailureHandler 코드

package com.realworld.feature.oauth.handler;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Slf4j
@Component
public class OAuth2LoginFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
        response.getWriter().write("소셜 로그인 실패! ");
        log.info("소셜 로그인에 실패했습니다. 에러메시지 : {}", exception.getMessage());
    }
}

 

시큐어리티 설정코드 

@Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .httpBasic(AbstractHttpConfigurer::disable)
                .csrf(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .cors(httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer.configurationSource(corsConfigurationSource()))
                //.headers(headers->
                //        headers.frameOptions(frameOptionsConfig -> frameOptionsConfig.sameOrigin()))
                //.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling(authenticationManager -> authenticationManager
                        .authenticationEntryPoint(new JwtAuthenticationEntryPoint())
                        .accessDeniedHandler(new JwtAccessDeniedHandler()))
                .sessionManagement((httpSecuritySessionManagementConfigurer ->
                        httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(HttpMethod.GET, getExcludeDevURI).permitAll()
                        .requestMatchers(HttpMethod.GET, getExcludeLocalURI).permitAll()
                        .requestMatchers(excludeDevURI).permitAll()
                        .requestMatchers(excludeLocalURI).permitAll()
                        .anyRequest()
                        .authenticated()
                )
                .oauth2Login(oauth ->
                        oauth.userInfoEndpoint(c -> c.userService(oAuth2UserService))
                                .successHandler(oAuth2SuccessHandler)
                                .failureHandler(oAuth2LoginFailureHandler)
                )
                .apply(new JwtSecurityConfig(jwtTokenProvider));
        return http.build();
    }

 

 .oauth2Login(oauth ->
                        oauth.userInfoEndpoint(c -> c.userService(oAuth2UserService))
                                .successHandler(oAuth2SuccessHandler)
                                .failureHandler(oAuth2LoginFailureHandler)
                )

Security 코드에 해당 부분을 추가해줍니다.