[IMAD] OAuth2 소셜 로그인 (구글, 네이버, 카카오, 애플)

NCOOKIE·2024년 2월 21일
0

IMAD 프로젝트

목록 보기
10/11
post-thumbnail

들어가며

사실 소셜 로그인은 이전에 구현해둔 상태였지만 계속 다른 일에 밀려서 관련 블로그 글을 작성하는게 늦춰지고 있었다. 이번에 리액트에서도 동작하도록 만드느라 코드와 일부 내용이 수정되어서 겸사겸사 글을 작성해보려고 한다.

소셜 로그인은 스프링 부트의 라이브러리인 spring-boot-starter-oauth2-client을 사용했는데 애플은 구현 형태가 조금 달라서 별도로 구현했다.

소셜 로그인 후 응답을 어떻게 받아올까?

리액트와 네이티브 앱 차이

클라이언트(위 그림에서는 Resource Owner) 입장에서는 소셜 로그인 요청 후 그에 대한 결과나 응답을 받을 수 있어야 한다. OAuth 2.0 인증 과정은 기본적으로 클라이언트 입장에서 외부에서 이루어지기 때문에 이에 대한 별도의 방법이 필요하다.

네이티브 앱

네이티브 앱에서는 크게 두 가지 방법이 있는데 하나는 Service Provider에서 제공하는 SDK를 사용하는 것이고, 다른 하나는 WebView를 사용하여 외부 페이지로부터 응답 데이터를 가져온다. 전자는 외부 SDK에 의존해야 하고 지원을 하지 않는 경우도 있을 수 있기 때문에 우리는 후자의 방법을 사용하고 있다.

만약 스프링이 Thymeleaf 같은 툴을 사용하여 웹서버 역할을 겸하고 있다면 소셜 로그인 과정을 모두 끝마치고 그 결과에 맞는 페이지로 리디렉션 시켜주면 된다. 이는 스프링이나 Thymeleaf가 아닌 다른 프레임워크, 라이브러리 등을 사용해도 마찬가지일 것이다.

그러나 우리는 현재 스프링을 API 서버로 쓰고 있고, 프론트에서는 리액트를 사용 중이므로 리액트 서버의 URL로 리디렉션 시켜주면 된다. 이 때문에 서버 입장에서는 네이티브 앱과 리액트에서의 요청을 구분해야 하고, 리액트에서 요청한 경우 redirect uri(OAuth 2.0에서 사용하는 것과는 별개임)도 어딘가에 저장을 해뒀다가 사용해야 한다.

이 때 사용하는 redirect uri는 OAuth 2.0에서 사용자가 인증 과정을 마치면 인가 코드(authorization code)와 함께 리디렉션 되는 uri와는 다른 것이다. 이 둘이 헷갈리지 않도록 주의하자.

state 파라미터

OAuth 2.0에서 state 파라미터는 인가 코드 받기 API 요청 시 전달한 값을 Redirect URI에 전달하여 인가 요청 출처를 확인하는 데 사용한다. 이는 CSRF(Cross-Site Request Forgery) 공격 방지용으로 활용할 수 있다.

로그인을 시도하는 각 사용자의 로그인 요청에 대한 state 값을 중복되지 않는 고유한 난수로 설정하고, Redirect URI에 전달된 값과 일치하는지 검증하는 방식으로 사용한다. state 값은 세션 또는 동일 출처 정책에 의해 보호되는 쿠키 등 제3자가 접근할 수 없는 위치에 보관하여 사용해야 한다. (참고: RFC 6749 10.12.)

이렇게 CSRF 공격 방지용으로 활용되는 state 파라미터는 리액트 측에서 전달하는 redirect uri를 쿠키에 저장해뒀다가, 로그인을 성공적으로 마치고 success handler에서 무언가 처리를 할 때 다시 꺼내어 사용할 수 있다.

CSRF란?

CSRF(Cross-Site Request Forgery)는 악의적인 사용자가 인증된 사용자의 권한을 이용하여 특정 웹 애플리케이션에 대해 비인가 요청을 보내는 공격이다. 이 공격은 사용자가 자신의 의지와 무관하게 공격자가 의도한 요청을 악용하여 이루어진다. 이 공격을 방지하기 위해 웹 애플리케이션에서 발급하는 랜덤한 토큰인 CSRF 토큰을 사용한다.

OAuth 2.0에서는 공격자가 인가 서버를 가장하고 클라이언트 애플리케이션(스프링 서버)에게 CSRF 공격하는 것을 방지하기 위해 state 파라미터를 사용한다.

스프링 시큐리티

프로젝트에서 소셜 로그인 구현을 위해 spring-boot-starter-oauth2-client 라이브러리를 사용 중이다. 이는 스프링 부트의 일부인 스프링 시큐리티에 대한 의존성을 포함하는 라이브러리로, OAuth 2.0과 관련된 처리를 도와준다.

이 라이브러리는 소셜 로그인 요청 URL을 받으면 해당 인증 페이지로 리디렉션 시켜주고, 인가 코드를 받아서 access token을 발급받는 등의 동작을 알아서 수행한다. 때문에 AuthorizationRequestRepository 인터페이스를 implements 해서 구현해야 한다.


    @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;
        }

        // 쿠키에 REDIRECT URL 첨부
        CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtils.serialize(authorizationRequest), cookieExpireSeconds);
        String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
        if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
            CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, cookieExpireSeconds);
        }
    }

위와 같이 AuthorizationRequestRepository를 implements한 클래스에서 클라이언트에서 전달된 redirect uri를 쿠키에 저장한다.

애플

애플은 상황이 좀 다르다. 애플은 Oauth 2.0 표준을 베이스로 한 자체 인증 메커니즘(Sign in with Apple)을 제공한다. 이 때문에 spring-boot-starter-oauth2-client를 사용해서 구현한 네이버, 카카오, 구글 로그인과 달리 애플 로그인은 자체적으로 구현해야 했다. (애플 로그인 구현 게시글은 이전에 올렸었다.)

그렇지만 애플에도 마찬가지로 CSRF 공격을 방지하기 위한 state 파라미터가 있다. 쿠키가 아니라 인증 페이지에 연결할 때 파라미터로 넘겨줄 수 있다.

String loginUrl = APPLE_AUTH_URL + "/auth/authorize"
                + "?client_id=" + appleProperties.getClientId()
                + "&redirect_uri=" + appleProperties.getRedirectUrl()
                + "&response_type=code%20id_token&scope=name%20email&response_mode=form_post";

if (redirectUri != null && !redirectUri.isEmpty()) {
        loginUrl = loginUrl + "&state=" + redirectUri;
        log.info("리액트에서 애플 로그인 요청 시도 : redirect_uri를 state 파라미터에 추가");
}

나는 redirectUri만 전달하면 되기 때문에 String으로 넣었지만, 여러 데이터가 필요한 경우 JSON Object로 만들어서 인코딩 데이터를 넣어주면 된다. 이후 인가 코드를 받을 때 code와 함께 state를 파라미터로 받아볼 수 있다.

구현

의존성 추가 및 관련 설정

build.gradle


dependencies {
	...

	// 보안
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation "org.springframework.boot:spring-boot-starter-oauth2-client"
    
    ...
}

의존성으로 스프링 시큐리티와 oauth2-client를 추가해준다.

application.yml

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: {google-client-id}
            client-secret: {google-client-secret}
            redirect-uri: {base-url}/login/oauth2/code/google	# 인증 완료 후 인가 코드를 받을 URI
            authorization-grant-type: authorization_code
            scope: profile, email	# 인가 후 받아올 사용자 정보 범위

          kakao:
            client-id: {kakao-client-id}
            redirect-uri: {base-url}/login/oauth2/code/kako
            client-authentication-method: POST
            authorization-grant-type: authorization_code
            scope: profile_nickname, profile_image
            client-name: Kakao

          naver:
            client-id: {naver-client-id}
            client-secret: {naver-client-secret}
            redirect-uri: {base-url}/login/oauth2/code/naver
            authorization-grant-type: authorization_code
            scope: nickname, email, profile_image
            client-name: Naver

        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

          naver:
            authorization_uri: https://nid.naver.com/oauth2.0/authorize
            token_uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user_name_attribute: response
            

apple:
  team-id: {apple-team-id}
  login-key: {apple-login-key}
  client-id: {apple-client-id}
  redirect-url: {base-url}/api/callback/apple
  key-path: "key/AuthKey.p8"
  • 구글, 깃허브 등의 서비스의 provider 정보는 라이브러리에서 제공해주지만 그 외의 네이버와 카카오는 그렇지 않기 때문에 직접 설정해줘야 한다.
  • /login/oauth2/code 엔드포인트는 spring-boot-starter-oauth2-client에서 제공하는 기본 OAuth 2.0 로그인 엔드포인트 중 하나이다.
    • redirect-uri에 이렇게 설정해두면 라이브러리에서 인가 코드를 수신하고 provider 정보의 token_uri로 액세스 토큰 요청을 보낸다.
    • 이 동작은 OAuth2LoginAuthenticationFilter 클래스에서 수행한다고 한다.
    • 애플은 이 라이브러리를 사용하지 않기 때문에 임의로 설정해줬다.

SecurityConfig.java

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {

	...
    
    // 소셜 로그인 관련
    private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;
    private final OAuth2LoginFailureHandler oAuth2LoginFailureHandler;
    private final CustomOAuth2UserService customOAuth2UserService;
    
	...
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    
        http
                .formLogin().disable()
                .httpBasic().disable()
                .csrf().disable()
                .cors().configurationSource(corsConfigurationSource()).and()
                .headers().frameOptions().disable()
                .and()
                
				...
        
                // 아래 URL로 들어오는 요청들은 Filter 검사에서 제외됨 
                .requestMatchers(
                        "/api/signup",
                        "/api/user/validation/**",
                        "/api/callback/**",
                        "/api/test/**",
                        "/aws",
                        "/login/**",        // 소셜 로그인 redirect url
                        "/oauth2/login/apple",
                        "/h2-console/**")
                .permitAll()

				// 이 외 나머지 요청은 보안 처리
                .anyRequest().authenticated()
                .and()

                //== 소셜 로그인 설정 ==//
                .oauth2Login()
                .authorizationEndpoint()
                    .baseUri("/oauth2/authorization")
                    .authorizationRequestRepository(cookieOAuth2AuthorizationRequestRepository())
                    .and()
                .successHandler(oAuth2LoginSuccessHandler) // 동의하고 계속하기를 눌렀을 때 Handler 설정
                .failureHandler(oAuth2LoginFailureHandler) // 소셜 로그인 실패 시 핸들러 설정
                .userInfoEndpoint().userService(customOAuth2UserService); // customUserService 설정

        return http.getOrBuild();
    }
    
    ...
    
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.addAllowedOrigin("*"); // 허용할 Origin 설정, *은 모든 Origin을 허용하는 것이므로 실제 환경에서는 제한 필요
        configuration.addAllowedMethod("*"); // 허용할 HTTP Method 설정
        configuration.addAllowedHeader("*"); // 허용할 HTTP Header 설정
        configuration.addExposedHeader("Authorization");
        configuration.addExposedHeader("Authorization-refresh");
        configuration.setAllowCredentials(false); // Credentials를 사용할지 여부 설정

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration); // 모든 경로에 대해 CORS 설정 적용

        return source;
    }
    
    @Bean
    public HttpCookieOAuth2AuthorizationRequestRepository cookieOAuth2AuthorizationRequestRepository() {
        return new HttpCookieOAuth2AuthorizationRequestRepository();
    }
    
    ...
}
  • SecurityConifg.java 파일의 일부만 가져왔다.
  • OAuth 2.0 과정에서 사용할 /login/**/oauth2/login/apple 등의 URI는 접근 권한이 없어도 통과할 수 있도록 했다.
  • authorizationEndpoint()에서 AuthorizationRequestRepository를 implements한 클래스를 붙여준다.
    • 이는 나중에 사용자가 소셜 로그인 요청을 날렸을 때(/oauth2/authorization/{provider}) 또는 인증 서버에서 인가 코드와 함께 리디렉션 해줄 때(/login/oauth2/code/{provider}) 호출된다.

OAuth 2.0 소셜 로그인

흐름

실제 우리가 구현한 클래스들이 어떻게, 어떤 순서로 실행되는지 간단하게 짚어보자.

  • 먼저 OAuth 2.0은 사용자가 웹브라우저 또는 앱에서 소셜 로그인 버튼을 눌러 /oauth2/authorization/{provider}?redirect_uri=<redirect_uri_after_login}으로 접속하는 것으로 시작된다.
    • 라이브러리에서 별 다른 설정을 하지 않으면 기본적으로 /oauth2/authorization/{provider}에 접속하면 해당 provider에 맞는 인증 페이지로 리디렉션 시켜준다. 이를 변경하고 싶다면 위의 SecurityConfig.java에서 엔드포인트를 수정하면 된다.
    • provider는 google, naver, kakao 등이 될 수 있다.
    • redirect_uri는 있어도 되고 없어도 된다. 우리는 이것의 유무로 리액트에서의 요청과 그 외의 요청을 구분하기 때문에 리액트에서 소셜 로그인을 하려면 redirect_uri 파라미터가 있어야 한다.
    • 위에서도 언급했지만 여기서 사용하는 redirect_uriprovider의 어플리케이션에서 사전에 설정하고 yml 파일에서 지정한 redirect_uri와는, OAuth에서 사용되는 것과는 다르다.
  • 소셜 로그인 요청을 받은 클라이언트 서버(여기서는 스프링 API 서버)의 Spring Security는 사용자를 제공된 providerauthorizationUri로 리디렉션 시킨다.
    • authorizationUri는 oauth-client 라이브러리 내부에 지정되어 있거나, yml에서 provider 설정을 했던 uri이다.
    • 이 외에도 clientId, scope, responseType 등의 필드는 OAuth2AuthorizationRequest 객체에서 가지고 있다.
  • 인증 및 권한 요청과 관련된 모든 상태는 SecurityConfig에서 지정된 HttpCookieOAuth2AuthorizationRequestRepository를 사용하여 저장된다.
  • 이제 사용자는 provider가 제공하는 페이지에서 앱에 대한 권한을 허용/거부한다. 사용자가 앱에 대한 권한을 허용하면 공급자는 사용자를 인증 코드오 함께 콜백 URL /login/oauth2/code/{provider}로 리디렉션 시킨다.
    • 이 URL도 마찬가지로 임의로 설정할 수 있으며, 현재 사용 중인 것이 디폴트 콜백 URL이다.
  • 사용자가 권한을 거부하면 동일한 콜백 URL로 리디렉션 되지만 error가 발생한다.
    • 이 때 OAuth2LoginFailureHandler가 호출된다.
  • OAuth2 콜백이 성공하고 인가 코드가 포함된 경우, Spring Security는 access token을 발급받기 위해 authorization_code를 교환하고, SecurityCofnig에서 지정한 customOAuth2UserService을 호출한다.
    • .userInfoEndpoint().userService(customOAuth2UserService)
  • customOAuth2UserService에서는 Resource Server로부터 받아온 사용자 정보가 기존 DB에 있는지 검색하고 결과에 따라 신규 데이터 저장 또는 업데이트를 수행한다.
  • 위의 모든 과정이 성공적으로 이루어진다면 OAuth2LoginSuccessHandler가 호출된다. 여기서 redirect_uri 정보가 쿠키에 저장되어 있는지 여부에 따라 리액트와 그 외(네이티브 앱) 요청을 구분하여 리디렉션 또는 유저 정보 객체 반환 등의 작업을 수행한다.

HttpCookieOAuth2AuthorizationRequestRepository.java

위에서 설명했던 것처럼 OAuth 2.0에서는 CSRF 공격을 방지하기 위해 state 파라미터 사용을 권장한다. 클라이언트 애플리케이션(스프링 API 서버)은 인증 요청에서 이 매개 변수를 전송하고, OAuth2 공급자는 OAuth2 콜백에서 변경되지 않은 이 매개 변수를 리턴한다.

클라이언트 애플리케이션은 OAuth2 공급자에서 반환 된 state 매개 변수의 값을 초기에 보낸 값과 비교한다. 일치하지 않으면 인증 요청을 거부한다.

이 흐름을 얻기 위해서는 클라이언트 애플리케이션이 인가 서버에서 반환된 상태와 비교할 수 있도록 state 매개 변수를 어딘가에 저장해야 한다. 아래 클래스에서는 인증 요청을 쿠키에 저장하고 검색하는 기능을 제공한다.

  • saveAuthorizationRequest(...) 메소드는 사용자가 /oauth2/authorization/{provider}에 접속하면 호출되며, 리디렉션 시키기 전에 쿠키에 정보를 저장한다. 이 때 /oauth2/authorization/{provider}?redirect_uri=<redirect-uri-after-login>와 같이 요청이 오면 redirect uri도 쿠키에 함께 저장한다.
  • removeAuthorizationRequest(...) 메소드는 서비스 provider에서 설정한 redirect uri로 인가 코드를 받고 나서 호출된다.
@Component
public class HttpCookieOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
    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 cookieExpireSeconds = 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;
        }

        // 쿠키에 REDIRECT URL 첨부
        CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtils.serialize(authorizationRequest), cookieExpireSeconds);
        String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
        if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
            CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, cookieExpireSeconds);
        }
    }

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

CustomOAuth2UserService.java

OAuth2UserService를 implements한 이 클래스는 loadUser(...) 메소드를 오버라디딩하며, provider로부터 access token을 얻은 후에 호출된다. 인증된 사용자의 정보를 확인해서 DB에 이미 있다면 업데이트하고, 없다면 새로 사용자를 등록한다.

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

    private final UserAccountRepository 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 객체를 생성 후 반환
         * DefaultOAuth2UserService의 loadUser()는 소셜 로그인 API의 사용자 정보 제공 URI로 요청을 보내서
         * 사용자 정보를 얻은 후, 이를 통해 DefaultOAuth2User 객체를 생성 후 반환한다.
         * 결과적으로, OAuth2User는 OAuth 서비스에서 가져온 유저 정보를 담고 있는 유저
         */
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

        /**
         * userRequest에서 registrationId 추출 후 registrationId으로 AuthProvider 저장
         * http://localhost:8080/login/oauth2/code/kakao에서 kakao가 registrationId
         * userNameAttributeName은 이후에 nameAttributeKey로 설정된다.
         */
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        AuthProvider authProvider = getSocialType(registrationId);
        String userNameAttributeName = userRequest.getClientRegistration()
                .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); // OAuth2 로그인 시 키(PK)가 되는 값
        Map<String, Object> attributes = oAuth2User.getAttributes(); // 소셜 로그인에서 API가 제공하는 userInfo의 Json 값(유저 정보들)
        String oauth2AccessToken = userRequest.getAccessToken().getTokenValue();

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

        UserAccount createdUser = getUser(extractAttributes, authProvider, oauth2AccessToken); // getUser() 메소드로 User 객체 생성 후 반환

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

    private AuthProvider getSocialType(String registrationId) {
        if (NAVER.equals(registrationId)) {
            return AuthProvider.NAVER;
        }
        if (KAKAO.equals(registrationId)) {
            return AuthProvider.KAKAO;
        }
        return AuthProvider.GOOGLE;
    }

    /**
     * SocialType과 attributes에 들어있는 소셜 로그인의 식별값 id를 통해 회원을 찾아 반환하는 메소드
     * 만약 찾은 회원이 있다면, 그대로 반환하고 없다면 saveUser()를 호출하여 회원을 저장한다.
     */
    private UserAccount getUser(OAuthAttributes attributes, AuthProvider socialType, String accessToken) {
        UserAccount findUser = userRepository.findByAuthProviderAndSocialId(socialType,
                attributes.getOauth2UserInfo().getId()).orElse(null);

        // 신규 회원가입의 경우 DB에 저장
        if (findUser == null) {
            return saveUser(attributes, socialType, accessToken);
        }

        // 기존 회원의 경우 access token 업데이트를 위해 DB에 저장
        findUser.setOauth2AccessToken(accessToken);
        return userRepository.save(findUser);
    }

    /**
     * OAuthAttributes의 toEntity() 메소드를 통해 빌더로 User 객체 생성 후 반환
     * 생성된 User 객체를 DB에 저장 : socialType, socialId, email, role 값만 있는 상태
     */
    private UserAccount saveUser(OAuthAttributes attributes, AuthProvider authProvider, String accessToken) {
        UserAccount createdUser = attributes.toEntity(authProvider, attributes.getOauth2UserInfo(), accessToken);
        return userRepository.save(createdUser);
    }
}

CustomOAuth2User.java

CustomOAuth2UserServiceOAuth2LoginSuccessHandler 사이에서 이메일과 Role 필드를 추가로 가지는 클래스를 만들기 위해서 DefaultOAuth2User를 상속한 클래스다.

/**
 * DefaultOAuth2User를 상속하고, email과 role 필드를 추가로 가진다.
 * 최초 로그인 이후 성별, 연령대 등의 정보를 추가로 얻기 위해 role을 구분함
 */
@Getter
public class CustomOAuth2User extends DefaultOAuth2User {

    private String email;
    private Role role;

    /**
     * Constructs a {@code DefaultOAuth2User} using the provided parameters.
     *
     * @param authorities      the authorities granted to the user
     * @param attributes       the attributes about the user
     * @param nameAttributeKey the key used to access the user's &quot;name&quot; from
     *                         {@link #getAttributes()}
     */
    public CustomOAuth2User(Collection<? extends GrantedAuthority> authorities,
                            Map<String, Object> attributes, String nameAttributeKey,
                            String email, Role role) {
        super(authorities, attributes, nameAttributeKey);
        this.email = email;
        this.role = role;
    }
}

OAuth2UserInfo.java

모든 OAuth provider는 access token을 사용해 인증된 사용자의 세부 정보를 가져올 때 JSON 데이터를 반환한다. Spring Security는 key-value 쌍의 일반 Map 형식으로 응답을 분석한다.

public abstract class OAuth2UserInfo {

    protected Map<String, Object> attributes;

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

    public abstract String getId(); //소셜 식별 값 : 구글 - "sub", 카카오 - "id", 네이버 - "id"

    public abstract String getNickname();

    public abstract String getImageUrl();
}

GoogleOAuth2UserInfo.java

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

KakaoOAuth2UserInfo.java

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

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

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

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

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

        return (String) profile.get("thumbnail_image_url");
    }
}
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");

        if (response == null) {
            return null;
        }
        return (String) response.get("id");
    }

    @Override
    public String getNickname() {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");

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

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

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

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

        return (String) response.get("profile_image");
    }
}

OAuth2LoginSuccessHandler.java

OAuth 인증과 사용자 정보를 얻어오는게 모두 성공적으로 이루어졌을 때 호출된다.

로그인이 성공했을 때 JWT 토큰, access token과 refresh token을 생성한다. 이후 쿠키를 확인하여 redirect uri 여부에 따라 처리한다.

  • redirect uri가 있는 경우
    • 리액트에서 소셜 로그인을 시도한 케이스다.
    • 쿼리 파라미터에 액세스 토큰과 리플레시 토큰을 첨부한다.
    • 해당 경로로 리디렉션한다.
  • redirect uri가 없는 경우
    • 유저 정보를 UserInfoResponse DTO 클래스 형태로 변환시킨 후 반환한다.
    • 헤더에 액세스 토큰과 리플레시 토큰을 첨부한다.
@Slf4j
@EnableConfigurationProperties({ JwtProperties.class })
@RequiredArgsConstructor
@Component
public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {
    private final UserRetrievalService userRetrievalService;

    private final JwtService jwtService;
    private final JwtProperties jwtProperties;

    private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();


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

        String accessToken = jwtService.createAccessToken(oAuth2User.getEmail());
        String refreshToken = jwtService.createRefreshToken();

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

        Optional<String> cookieRedirectUrl = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
                .map(Cookie::getValue);

        // 리액트에서 로그인 시도한 경우
        if (cookieRedirectUrl.isPresent()) {
            String redirectUrl = UriComponentsBuilder.fromUriString(cookieRedirectUrl.get())
                    .path("/success")
                    .queryParam("token", accessToken)
                    .queryParam("refresh_token", refreshToken)
                    .build().toUriString();

            log.info("리액트 서버의 소셜 로그인 요청 : redirect 작업을 수행합니다.");
            redirectStrategy.sendRedirect(request, response, redirectUrl);
        } else {
            // 로그인 시 response에 유저 정보 첨부
            UserAccount user = userRetrievalService.getUserFromAccessToken(accessToken);
            UserInfoResponse userInfoResponse = UserInfoResponse.toDTO(user);

            log.info("모바일 애플리케이션의 소셜 로그인 요청 : 유저 정보를 첨부하여 응답합니다.");
            Utils.sendLoginSuccessResponseWithUserInfo(response, userInfoResponse);
        }

        log.info("로그인에 성공하였습니다.");
        log.info("이메일 : {}", oAuth2User.getEmail());
        log.info("로그인에 성공하였습니다. AccessToken : {}", accessToken);
        log.info("로그인에 성공하였습니다. RefreshToken : {}", refreshToken);
        log.info("발급된 AccessToken 만료 기간 : {}", LocalDateTime.now().plusSeconds(jwtProperties.getAccess().getExpiration() / 1000));
        log.info("발급된 RefreshToken 만료 기간 : {}", LocalDateTime.now().plusSeconds(jwtProperties.getRefresh().getExpiration() / 1000));
    }
    // TODO : 소셜 로그인 시에도 무조건 토큰 생성하지 말고 JWT 인증 필터처럼 RefreshToken 유/무에 따라 다르게 처리해보기
}

OAuth2LoginFailureHandler.java

OAuth 인증 중 오류가 발생하면 Spring Security는 SecurityConfig에서 설정한 failure handler를 호출한다. 쿼리 파라미터로 에러 메세지를 첨부해서 리디렉션을 시킬 수도 있다.

인증이 성공했을 때와 마찬가지로 리액트와 그 외 케이스를 구분해서 리디렉션 또는 유저 정보 반환을 수행한다.

@Slf4j
@Component
public class OAuth2LoginFailureHandler implements AuthenticationFailureHandler {
    private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();


    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
        Optional<String> cookieRedirectUrl = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
                .map(Cookie::getValue);

        // 리액트에서 로그인 시도한 경우
        if (cookieRedirectUrl.isPresent()) {
            String redirectUrl = UriComponentsBuilder.fromUriString(cookieRedirectUrl.get())
                    .path("/fail")
                    .build().toUriString();

            log.info("리액트 서버의 소셜 로그인 실패 : redirect 수행");
            redirectStrategy.sendRedirect(request, response, redirectUrl);
        } else {
            // 로그인 실패 response 생성
            Utils.sendErrorResponse(response, HttpServletResponse.SC_BAD_REQUEST, ResponseCode.LOGIN_FAILURE);

            log.info("모바일 애플리케이션의 소셜 로그인 실패");
        }

        log.info("소셜 로그인에 실패했습니다. 에러 메시지 : {}", exception.getMessage());
    }
}

CookieUtils.java

쿠키를 저장하고 꺼낼 때 사용하는 Util 클래스다.


public class CookieUtils {

    public static Optional<Cookie> getCookie(HttpServletRequest request, String name) {
        Cookie[] cookies = request.getCookies();

        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals(name)) {
                    return Optional.of(cookie);
                }
            }
        }

        return Optional.empty();
    }

    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) {
            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> cls) {
        return cls.cast(SerializationUtils.deserialize(
                Base64.getUrlDecoder().decode(cookie.getValue())));
    }
}

애플 로그인

애플은 OAuth 2.0 표준을 베이스로 한 자체 인증 메커니즘(Sign in with Apple)을 제공한다. 때문에 spring-boot-starter-oauth2-client에서는 공식적으로 지원을 해주지 않는 것으로 알고 있다. 이 라이브러리를 사용해서 애플 로그인을 어떻게 구현할 수는 있다고 들었는데, 문서나 코드는 찾지 못해서 직접 구현했다. 해당 내용은 아래 글을 참고 바란다.

[IMAD] 애플 로그인 기능 구현

위 글의 코드를 기반으로 컨트롤러와 서비스의 일부분만 수정했다.

state 파라미터

애플 로그인이 혼자서 자체적인 메커니즘을 가지고 있다고 해도, OAuth 2.0 기반이기 때문에 마찬가지로 CSRF 공격을 방지하기 위한 state 파라미터를 지원한다. 사용 방법도 간단하다.

애플의 인증 페이지에 접속하기 위해서는 https://appleid.apple.com/auth/authorize 뒤에 쿼리 파라미터로 client id, redirect uri(인가 코드를 받을 callback uri), response type, scope, response mode 등을 설정해줘야 한다. 여기서 state 파라미터를 추가하고 그 값을 사용하면 된다.

AppleController.java

기존에 네이티브 앱과만 통신할 때에는 인증 페이지의 URL이 항상 고정이었기 때문에, 사용자가 직접 해당 링크로 접속하는 방식이었다. 그러나 링크가 복잡하기도 하고 리액트는 state 파라미터에 서버 주소에 따른 가변적인 redirect uri 값이 들어가야 하기 때문에 서버에서 리디렉션 해주는 방향으로 수정했다.

  • /oauth2/login/apple : 해당 URL로 사용자가 접속하면 쿼리 파라미터로 redirect_uri가 있는지 확인하고, 그에 따른 URL로 리디렉션 시킨다.
  • /api/callback/apple
    • code 파라미터에서 인가 코드를 읽어오고, state 값이 설정되어 있다면 해당 값도 읽어들인다.
    • 받아온 값으로 Resource Server로부터 사용자 정보를 얻어온다. 이를 통해 로그인의 성공 여부를 알 수 있고, 리액트와 그 외의 로그인 시도를 구분해서 리디렉션 또는 DTO 객체 반환을 수행한다.
@RestController
@RequiredArgsConstructor
public class AppleController {
    private final AppleService appleService;
    private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    @GetMapping("/oauth2/login/apple")
    public void loginRequest(HttpServletRequest request, HttpServletResponse response,
                             @RequestParam(value = "redirect_uri", required = false) String redirectUri) throws IOException {
        redirectStrategy.sendRedirect(request, response, appleService.getAppleLoginUrl(redirectUri));
    }

    @PostMapping("/api/callback/apple")
    public ApiResponse<?> callback(HttpServletRequest request, HttpServletResponse response) throws IOException {
        String redirectUri = request.getParameter("state");

        UserAccount user = appleService.login(request.getParameter("code"));
        boolean isValidRedirectUri = (redirectUri != null && !redirectUri.isEmpty());

        // 로그인 성공
        if (user != null) {
            // 리액트로 로그인한 경우
            if (isValidRedirectUri) {
                redirectStrategy.sendRedirect(request, response, appleService.determineSuccessRedirectUrl(user, redirectUri));
                return null;
            }

            // 네이티브 앱이나 기타 경로에서 로그인한 경우
            appleService.loginSuccess(user, response);
            return ApiResponse.createSuccess(ResponseCode.LOGIN_SUCCESS, UserInfoResponse.toDTO(user));
        }
        // 로그인 실패
        else {
            // 리액트로 로그인한 경우
            if (isValidRedirectUri) {
                redirectStrategy.sendRedirect(request, response, appleService.determineFailureRedirectUrl(redirectUri));
                return null;
            } else {
                return ApiResponse.createError(ResponseCode.LOGIN_FAILURE);
            }
        }
    }
}

AppleService.java

AppleService는 기존의 코드에서 변경된 것이 거의 없고, 결과에 대한 처리도 위의 일반 소셜 로그인과 동일하다.

state 파라미터 유무에 따라 redirect uri를 붙여서 인증 페이지 URL 링크를 반환한다. 로그인 후 사용자 정보가 DB에 없다면 신규 저장하고, 있으면 업데이트한다. 로그인 성공 시 JWT 토큰과 유저 정보 등을 함께 첨부한다.

@Slf4j
@EnableConfigurationProperties({ AppleProperties.class })
@RequiredArgsConstructor
@Service
public class AppleService {

    private final UserAccountRepository userRepository;
    private final JwtService jwtService;

    private final AppleProperties appleProperties;


    private final static String APPLE_AUTH_URL = "https://appleid.apple.com";

    public String getAppleLoginUrl(String redirectUri) {
        String loginUrl = APPLE_AUTH_URL + "/auth/authorize"
                + "?client_id=" + appleProperties.getClientId()
                + "&redirect_uri=" + appleProperties.getRedirectUrl()
                + "&response_type=code%20id_token&scope=name%20email&response_mode=form_post";

        if (redirectUri != null && !redirectUri.isEmpty()) {
            loginUrl = loginUrl + "&state=" + redirectUri;
            log.info("리액트에서 애플 로그인 요청 시도 : redirect_uri를 state 파라미터에 추가");
        }

        log.info("애플 로그인 URL 반환");
        return loginUrl;
    }

    public UserAccount login(String code) {
        String userId;
        String email;
        String accessToken;

        UserAccount user;

        try {
            JSONParser jsonParser = new JSONParser();
            JSONObject jsonObj = (JSONObject) jsonParser.parse(generateAuthToken(code));

            accessToken = String.valueOf(jsonObj.get("access_token"));

            // ID TOKEN을 통해 회원 고유 식별자 받기
            SignedJWT signedJWT = SignedJWT.parse(String.valueOf(jsonObj.get("id_token")));
            ReadOnlyJWTClaimsSet getPayload = signedJWT.getJWTClaimsSet();

            ObjectMapper objectMapper = new ObjectMapper();
            JSONObject payload = objectMapper.readValue(getPayload.toJSONObject().toJSONString(), JSONObject.class);

            userId = String.valueOf(payload.get("sub"));
            email = String.valueOf(payload.get("email"));

            UserAccount findUser = userRepository
                    .findByAuthProviderAndSocialId(AuthProvider.APPLE, userId)
                    .orElse(null);

            if (findUser == null) {
                // 신규 회원가입의 경우 DB에 저장
                logWithOauthProvider(AuthProvider.APPLE, "신규 회원가입 DB 저장");
                user = userRepository.save(
                        UserAccount.builder()
                                .authProvider(AuthProvider.APPLE)
                                .socialId(userId)
                                .email(email)
                                .role(Role.GUEST)
                                .oauth2AccessToken(accessToken)
                                .refreshToken(jwtService.createRefreshToken())
                                .build()
                );
            } else {
                // 기존 회원의 경우 access token 업데이트를 위해 DB에 저장
                logWithOauthProvider(AuthProvider.APPLE, "기존 회원 DB 업데이트");
                findUser.setOauth2AccessToken(accessToken);
                user = userRepository.save(findUser);
            }

            return user;

        } catch (ParseException | JsonProcessingException e) {
            throw new RuntimeException("Failed to parse json data");
        } catch (IOException | java.text.ParseException e) {
            throw new RuntimeException(e);
        }
    }

    public void loginSuccess(UserAccount user, HttpServletResponse response) {
        String accessToken = jwtService.createAccessToken(user.getEmail());
        String refreshToken = jwtService.createRefreshToken();

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

    public String determineSuccessRedirectUrl(UserAccount user, String baseUrl) {
        String accessToken = jwtService.createAccessToken(user.getEmail());
        String refreshToken = jwtService.createRefreshToken();

        jwtService.updateRefreshToken(user.getEmail(), refreshToken);

        return UriComponentsBuilder.fromUriString(baseUrl)
                .path("/success")
                .queryParam("token", accessToken)
                .queryParam("refresh_token", refreshToken)
                .build().toUriString();
    }

    public String determineFailureRedirectUrl(String baseUrl) {
        return UriComponentsBuilder.fromUriString(baseUrl)
                .path("/fail")
                .build().toUriString();
    }

    public String generateAuthToken(String code) throws IOException {
        if (code == null) throw new IllegalArgumentException("Failed get authorization code");

        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "authorization_code");
        params.add("client_id", appleProperties.getClientId());
        params.add("client_secret", createClientSecretKey());
        params.add("code", code);
        params.add("redirect_uri", appleProperties.getRedirectUrl());

        RestTemplate restTemplate = new RestTemplate();

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
        HttpEntity<MultiValueMap<String, String>> httpEntity = new HttpEntity<>(params, headers);

        try {
            ResponseEntity<String> response = restTemplate.exchange(
                    APPLE_AUTH_URL + "/auth/token",
                    HttpMethod.POST,
                    httpEntity,
                    String.class
            );

            return response.getBody();
        } catch (HttpClientErrorException e) {
            throw new IllegalArgumentException("Apple Auth Token Error");
        }
    }

    public String createClientSecretKey() throws IOException {
        // headerParams 적재
        Map<String, Object> headerParamsMap = new HashMap<>();
        headerParamsMap.put("kid", appleProperties.getLoginKey());
        headerParamsMap.put("alg", "ES256");

        // clientSecretKey 생성
        return Jwts
                .builder()
                .setHeaderParams(headerParamsMap)
                .setIssuer(appleProperties.getTeamId())
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + 1000 * 30)) // 만료 시간 (30초)
                .setAudience(APPLE_AUTH_URL)
                .setSubject(appleProperties.getClientId())
                .signWith(SignatureAlgorithm.ES256, getPrivateKey())
                .compact();
    }

    public String getAppleClientId() {
        return appleProperties.getClientId();
    }

    private PrivateKey getPrivateKey() throws IOException {
        ClassPathResource resource = new ClassPathResource(appleProperties.getKeyPath());
        String privateKey = new String(resource.getInputStream().readAllBytes());

        Reader pemReader = new StringReader(privateKey);
        PEMParser pemParser = new PEMParser(pemReader);
        JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
        PrivateKeyInfo object = (PrivateKeyInfo) pemParser.readObject();

        return converter.getPrivateKey(object);
    }
}

마치며

몇달 전에 다른 사람이 작성한 코드를 가져와서 적용하거나 직접 짜고, 지금 또 추가적으로 수정하니 전체적으로 코드가 어수선한 감이 있다. 당시에는 OAuth 2.0과 코드를 제대로 이해하지 못한 것도 있어 더욱 그런 것 같다. 다음 프로젝트에서도 소셜 로그인을 구현하게 된다면 코드를 다듬고 좀 더 깔끔하게 정리해서 글을 쓸 예정이다.

참고

소셜 로그인 후 응답을 어떻게 받아올까?

구현

profile
일단 해보자

0개의 댓글