본문 바로가기
Spring/Spring Boot

[Spring - OAuth2.0] 확장성 갖춘 소셜 로그인 구현

by seoyamin 2024. 1. 18.

Google, Kakao, Naver 등 다양한 환경에 확장성을 갖춘 형태로 소셜 로그인을 구현해보자.
일단 Kako, Naver 두 환경을 대상으로 하며, 각 Developers 홈페이지에서 Application을 생성한 상태임을 가정하고 진행할 것이다.

 

참고 사항

카카오 로그인의 경우 '개인정보 동의항목 심사 신청'이 완료되기 전까지는 닉네임, 카카오계정(이메일) 등의 데이터만 불러올 수 있다. 본 글에서는 일단 닉네임과 이메일을 불러오는 것으로 진행하겠다. 

네이버 로그인의 경우 서비스 상태가 '개발중'인 경우, 테스터 ID 등록 후 해당 계정에 한해서만 소셜 로그인을 적용해볼 수 있다. Naver Developers/내 애플리케이션/멤버관리에서 테스터 ID를 등록하고 진행하자.

 

 

 

build.gradle

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

 

application.yml

spring:
  security:
    oauth2:
      authorizedRedirectUri: https://surveasy-workspace.web.app
      authorizedRedirectUri-local: http://localhost:8080
      client:
        registration:
          kakao:
            client-id: {{앱 설정/요약 정보/REST API KEY}}
            client-secret: {{제품 설정/카카오 로그인/보안/Client Secret}}
            redirect-uri: "{baseUrl}/oauth2/callback/{registrationId}"
            authorization-grant-type: authorization_code
            scope:
              - nickname
              - account_email
        provider:
          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

 

 


1. 사용자 관련 클래스

1-1. User.java

@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Where(clause = "deleted_at IS NULL")
@SQLDelete(sql = "UPDATE user SET deleted_at = CURRENT_TIMESTAMP where user_id = ?")
public class User {

    @Id
    @Column(name = "user_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotNull
    @Size(max = 100)
    private String email;

    @NotNull
    @Size(max = 20)
    private String name;

    @NotNull
    @Size(max = 128)
    @JsonIgnore
    private String password;

    @NotNull
    @Column(length = 15)
    private String phoneNum;

    @NotNull
    @Column(length = 10)
    private String signedUpAt;

    @Nullable
    @Column(length = 10)
    @JsonFormat(shape= JsonFormat.Shape.STRING, pattern="yyyy-MM-dd")
    private LocalDate deletedAt;

    @NotNull
    @Enumerated(EnumType.STRING)
    private UserRole role;

    @Nullable
    @Enumerated(EnumType.STRING)
    private AuthProvider authProvider;


    @Builder
    private User(String email, String name, String password, String phoneNum,
                 AuthProvider authProvider) {
        this.name = name;
        this.email = email;
        this.password = password;
        this.phoneNum = phoneNum;

        if(authProvider != null) this.authProvider = authProvider;

        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
        this.signedUpAt = simpleDateFormat.format(new Date());
        this.role = UserRole.ROLE_ANONYMOUS;
    }

    public static User of(UserSignUpRequestDTO userSignUpRequestDTO, String encodedPassword) {
        return User.builder()
                .email(userSignUpRequestDTO.getUserVo().getEmail())
                .name(userSignUpRequestDTO.getUserVo().getName())
                .password(encodedPassword)
                .phoneNum(userSignUpRequestDTO.getUserVo().getPhoneNum())
                .build();
    }

    public static User ofOAuth(OAuth2UserInfo oAuth2UserInfo, AuthProvider authProvider) {
        return User.builder()
                .email(oAuth2UserInfo.getEmail())
                .name(oAuth2UserInfo.getName())
                .password(oAuth2UserInfo.getOAuth2Id())
                .phoneNum(oAuth2UserInfo.getPhoneNum())
                .authProvider(authProvider)
                .build();
    }

    public User updateFrom(OAuth2UserInfo oAuth2UserInfo) {
        this.password = oAuth2UserInfo.getOAuth2Id();
        this.name = oAuth2UserInfo.getName();
        this.email = oAuth2UserInfo.getEmail();
        return this;
    }
}

 

1-2. PrincipalDetails .java

@Data
public class PrincipalDetails implements UserDetails, OAuth2User {

    private User user;

    private boolean duplicateEmail = false;		// 추가

    @Setter
    private Map<String, Object> attributes;		// 추가

    public PrincipalDetails(User user) {
        this.user = user;
    }

    public Long getId() {
        return user.getId();
    }

    @Override
    public Map<String, Object> getAttributes() {
        return null;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();
        collection.add((GrantedAuthority) () -> String.valueOf(user.getRole()));
        return collection;
    }

    @Override
    public String getPassword() {
        return null;
    }

    @Override
    public String getUsername() {
        return user.getEmail();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

    @Override
    public String getName() {
        return null;
    }
}

 

 


2. OAuth2.0 데이터 관련 클래스

2-1. AuthProvider.java

import lombok.Getter;

@Getter
public enum AuthProvider {
    KAKAO("카카오"),
    NAVER("네이버");

    private final String value;

    AuthProvider(String value) {
        this.value = value;
    }
}

 

 

2-2. OAuth2UserInfo.java

import lombok.AllArgsConstructor;
import lombok.Getter;

import java.util.Map;

@Getter
@AllArgsConstructor
public abstract class OAuth2UserInfo {
    
    protected Map<String, Object> attributes;
    
    public abstract String getOAuth2Id();
    public abstract String getEmail();
    public abstract String getName();
    public abstract String getPhoneNumber();
}

 

 

 

2-2-1. KakaoOAuth2User.java

import java.util.Map;

public class KakaoOAuth2User extends OAuth2UserInfo {

    private Long id;

    public KakaoOAuth2User(Map<String, Object> attributes) {
        super((Map<String, Object>) attributes.get("kakao_account"));
        this.id = (Long) attributes.get("id");
    }

    @Override
    public String getOAuth2Id() {
        return this.id.toString();
    }

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

    @Override
    public String getName() {
    	return "";
        // 개인정보동의항목 승인 후 return (String) attributes.get("name");
    }

    @Override
    public String getPhoneNum() {
    	return "";
        // 개인정보동의항목 승인 후 return "0" + ((String) attributes.get("phone_number")).substring(4);
    }
}

 

 

2-2-2. NaverOAuth2UserInfo.java

import java.util.Map;

public class NaverOAuth2User extends OAuth2UserInfo {
    public NaverOAuth2User(Map<String, Object> attributes) {
        super((Map<String, Object>) attributes.get("response"));
    }

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

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

    @Override
    public String getName() {
        return (String) attributes.get("name");
    }

    @Override
    public String getPhoneNum() {
        return (String) attributes.get("mobile");
    }
}

 

 

2-3. OAuth2UserInfoFactory.java

import java.util.Map;

public class OAuth2UserInfoFactory {

    public static OAuth2UserInfo getOAuth2UserInfo(AuthProvider authProvider, Map<String, Object> attributes) {
        switch (authProvider) {
            case KAKAO -> {
                return new KakaoOAuth2User(attributes);
            }
            case NAVER -> {
                return new NaverOAuth2User(attributes);
            }

            default -> throw new IllegalArgumentException("Invalid Provider Type");
        }
    }
}

 

 

2-4. CustomOAuth2UserService.java

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

    private final UserRepository userRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) throws OAuth2AuthenticationException {
        OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = oAuth2UserService.loadUser(oAuth2UserRequest);
        return processOAuth2User(oAuth2UserRequest, oAuth2User);
    }

    protected OAuth2User processOAuth2User(OAuth2UserRequest oAuth2UserRequest, OAuth2User oAuth2User) {
        AuthProvider authProvider = AuthProvider.valueOf(oAuth2UserRequest.getClientRegistration().getRegistrationId().toUpperCase());
        OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(authProvider, oAuth2User.getAttributes());

        if(!StringUtils.hasText(oAuth2UserInfo.getEmail())) {
            throw new RuntimeException("Email Not Found From OAuth2 Provider");
        }


        User user = userRepository.findByEmail(oAuth2UserInfo.getEmail()).orElse(null);

        // 이미 회원 가입된 이메일
        if(user != null) {

            // 이메일-PW 회원가입했던 메일 or 다른 provider에서 시도
            if(user.getAuthProvider() == null || !user.getAuthProvider().equals(authProvider)) {
                PrincipalDetails principalDetails = new PrincipalDetails(user);
                principalDetails.setDuplicateEmail(true);
                return principalDetails;
            }

            // 동일 provider에서 시도
            else {
                user = updateUser(user, oAuth2UserInfo);
            }
        }

        // 신규 가입
        else {
            user = registerUser(oAuth2UserInfo, authProvider);
        }

        return new PrincipalDetails(user);
    }

    private User registerUser(OAuth2UserInfo oAuth2UserInfo, AuthProvider authProvider) {
        User user = User.ofOAuth(oAuth2UserInfo, authProvider);
        return userRepository.save(user);
    }

    private User updateUser(User user, OAuth2UserInfo oAuth2UserInfo) {
        return userRepository.save(user.updateFrom(oAuth2UserInfo));
    }
}

 

 


3. Cookie 관련 클래스

3-1. CookieUtils.java

public class CookieUtils {

    public static Optional<Cookie> getCookie(HttpServletRequest request, String name) {
        Cookie[] cookies = request.getCookies();
        if(cookies != null && cookies.length > 0) {
            for(Cookie cookie : cookies) {
                if(cookie.getName().equals(name)) {
                    return Optional.of(cookie);
                }
            }
        }
        return Optional.empty();
    }

    public static Optional<String> readServletCookie(HttpServletRequest request, String name) {
        return Arrays.stream(request.getCookies())
                .filter(cookie -> name.equals(cookie.getName()))
                .map(Cookie::getValue)
                .findAny();
    }

    public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
        Cookie cookie = new Cookie(name, value);
        cookie.setPath("/");
        cookie.setHttpOnly(true);
        cookie.setMaxAge(maxAge);
        response.addCookie(cookie);
    }

    public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
        Cookie[] cookies = request.getCookies();
        if(cookies != null && cookies.length > 0) {
            for(Cookie cookie : cookies) {
                if(cookie.getName().equals(name)) {
                    cookie.setValue("");
                    cookie.setPath("/");
                    cookie.setMaxAge(0);
                    response.addCookie(cookie);
                }
            }
        }
    }

    public static String serialize(Object object) {
        return Base64.getUrlEncoder().encodeToString(SerializationUtils.serialize(object));
    }

    public static <T> T deserialize(Cookie cookie, Class<T> tClass) {
        return tClass.cast(SerializationUtils.deserialize(Base64.getUrlDecoder().decode(cookie.getValue())));
    }
}

 

3-2. CookieAuthorizationRequestRepository.java

@Component
public class CookieAuthorizationRequestRepository implements AuthorizationRequestRepository {

    public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
    public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri";
    private static final int COOKIE_EXPIRE_SECONDS = 180;

    @Override
    public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
        return CookieUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME)
                .map(cookie -> CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class))
                .orElse(null);
    }

    @Override
    public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
        if(authorizationRequest == null) {
            CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
            CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
            return;
        }

        CookieUtils.addCookie(
                response,
                OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME,
                CookieUtils.serialize(authorizationRequest),
                COOKIE_EXPIRE_SECONDS
        );

        String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
        if(StringUtils.isNoneBlank(redirectUriAfterLogin)) {
            CookieUtils.addCookie(
                    response,
                    REDIRECT_URI_PARAM_COOKIE_NAME,
                    redirectUriAfterLogin,
                    COOKIE_EXPIRE_SECONDS
            );
        }
    }

    @Override
    public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) {
        return this.loadAuthorizationRequest(request);
    }

    public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
        CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
        CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
    }
}

 

 


4. Spring Security 관련 클래스

4-1. OAuth2AuthenticationSuccessHandler

@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    @Value("${spring.security.oauth2.authorizedRedirectUri}")
    private String redirectUri;

    @Value("${spring.security.oauth2.authorizedRedirectUri-local}")
    private String redirectUriLocal;
    private final TokenProvider tokenProvider;
    private final CookieAuthorizationRequestRepository cookieAuthorizationRequestRepository;
    private final ObjectMapper objectMapper;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        String targetUrl;
        if(((PrincipalDetails) authentication.getPrincipal()).isDuplicateEmail())
            targetUrl = determineTargetUrlException(request, response);
        else
            targetUrl = determineTargetUrl(request, response, authentication);

        if(response.isCommitted()) {
            logger.debug("Response has already been committed");
            return;
        }

        clearAuthenticationAttributes(request, response);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }

    protected String getRedirectUri(HttpServletRequest request, HttpServletResponse response) {
        Optional<String> redirectUri = CookieUtils.getCookie(request, CookieAuthorizationRequestRepository.REDIRECT_URI_PARAM_COOKIE_NAME)
                .map(Cookie::getValue);

        if(redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) {
            throw new RuntimeException("Redirect URIs are not matched");
        }

        return redirectUri.orElse(getDefaultTargetUrl());
    }
    protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        String targetUrl = getRedirectUri(request, response);

        TokenResponse tokenResponse = TokenResponse.of(
                tokenProvider.createOAuth2AccessToken(authentication),
                tokenProvider.createOAuth2RefreshToken(authentication)
        );

        String result = null;

        try {
            result = objectMapper.writeValueAsString(tokenResponse);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }

        return UriComponentsBuilder.fromHttpUrl(targetUrl)
                .queryParam("result", result)
                .build().toUriString();
    }

    protected String determineTargetUrlException(HttpServletRequest request, HttpServletResponse response) {
        String targetUrl = getRedirectUri(request, response);
        String error = null;

        try {
            error = objectMapper.writeValueAsString(Oauth2DuplicateUser.EXCEPTION.getErrorReason());
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }

        return UriComponentsBuilder.fromHttpUrl(targetUrl)
                .queryParam("error", error)
                .build().toUriString();
    }

    protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
        super.clearAuthenticationAttributes(request);
        cookieAuthorizationRequestRepository.removeAuthorizationRequest(request, response);
    }

    private boolean isAuthorizedRedirectUri(String uri) {
        URI clientRedirectUri = URI.create(uri);
        URI authorizedUri = URI.create(redirectUri);
        URI authorizedUriLocal = URI.create(redirectUriLocal);

        if((authorizedUri.getHost().equalsIgnoreCase(clientRedirectUri.getHost()) && authorizedUri.getPort() == clientRedirectUri.getPort())
        || ((authorizedUriLocal.getHost().equalsIgnoreCase(clientRedirectUri.getHost()) && authorizedUriLocal.getPort() == clientRedirectUri.getPort()))) {
            return true;
        }
        return false;
    }
}

 

4-2. OAuth2AuthenticationFailureHandler

@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    private final CookieAuthorizationRequestRepository cookieAuthorizationRequestRepository;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                                        AuthenticationException authenticationException) throws IOException {
        String targetUrl = CookieUtils.getCookie(request, CookieAuthorizationRequestRepository.REDIRECT_URI_PARAM_COOKIE_NAME)
                .map(Cookie::getValue)
                .orElse("/");


        targetUrl = UriComponentsBuilder.fromUriString(targetUrl)
                .queryParam("error", authenticationException.getLocalizedMessage())
                .build().toUriString();

        cookieAuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
}

 

4-3. WebSecurityConfig

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
            .cors(cors -> cors.configurationSource(configurationSource()))
            .csrf(AbstractHttpConfigurer::disable)
            .headers(headers -> {
                headers.defaultsDisabled().frameOptions(Customizer.withDefaults());
            })
            .sessionManagement(sessionManagement -> {
                sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
            })
            .authorizeHttpRequests(request -> {
                request.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
                        .requestMatchers(SwaggerPatterns).permitAll()
                        .
                        .
                        .
                        .anyRequest().permitAll();
            })
            .oauth2Login(oauth2 -> {
                oauth2.authorizationEndpoint(auth -> auth.baseUri("/oauth2/authorize")
                                .authorizationRequestRepository(cookieAuthorizationRequestRepository))
                        .redirectionEndpoint(redirect -> redirect.baseUri("/oauth2/callback/*"))
                        .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
                        .successHandler(oAuth2AuthenticationSuccessHandler)
                        .failureHandler(oAuth2AuthenticationFailureHandler);
            })
            .logout(logout -> {
                logout.clearAuthentication(true)
                        .deleteCookies("JSESSIONID");
            })
            .exceptionHandling(exceptionHandling -> {
                exceptionHandling.accessDeniedHandler(jwtAccessDeniedHandler)
                        .authenticationEntryPoint(jwtAuthenticationEntryPoint);
            })
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
            .addFilterBefore(jwtExceptionHandlerFilter, JwtAuthenticationFilter.class)
            .addFilterBefore(accessDeniedFilter, AuthorizationFilter.class);

    return http.build();
}

 

 


5. 로그인 요청

5-1. 카카오 로그인

<a href="https://서버URL/oauth2/authorize/kakao?redirect_uri=http://localhost:3000/success(원하는redirect경로)">카카오 로그인</a>

 

 

5-2. 네이버 로그인

<a href="https://서버URL/oauth2/authorize/naver?redirect_uri=http://localhost:3000/success(원하는redirect경로)">네이버 로그인</a>

 

 

 

 

[참고 문서]

https://www.callicoder.com/spring-boot-security-oauth2-social-login-part-2