OAuth 2.0 소셜 로그인, Spring Security에서 어떻게 동작할까?

호기성세균·2024년 12월 16일
0

cs

목록 보기
30/30

Spring에서 OAuth 2.0 소셜 로그인 도입은 매우 간단하다.

Spring Security는 대부분의 작업을 자동으로 처리해주기 때문에, 개발자는 최소한의 설정만으로도 주요 소셜 로그인 기능을 구현할 수 있다.

하지만 이러한 자동화는 양날의 검이 될 수 있다. Spring Security가 너무 많은 부분을 알아서 처리해주기 때문에, 문제가 발생했을 때 내부 동작 방식을 이해하지 못하면 해결이 어려울 수 있다. 특히, 커스터마이징이나 디버깅이 필요할 경우 Spring Security의 OAuth 2.0 동작 원리를 파악하는 것이 중요하다.

이번 글에서는 OAuth 2.0이 Spring Security에서 어떻게 동작하고, 어떤 역할을 수행하는지 분석해본다.

OAuth 2.0의 Authorization Code Grant 방식

OAuth는 다양한 방식으로 동작할 수 있지만, 소셜 로그인의 경우 Authorization Code Grant 방식을 사용한다.
이 방식의 가장 큰 특징은 ID/PW의 유효성 검증 후 바로 액세스 토큰을 발급하지 않는다는 점이다.
대신, Authorization Code라는 임시 코드를 먼저 발급받고, 이를 활용해 Access Token을 요청한다.
이 과정을 통해 민감한 정보의 직접적인 노출을 방지하면서도, 인증 과정의 신뢰성을 한층 더 높인다.

Redirection 과정

Authorization Code Grant 방식에서 두 번의 Redirection이 수행된다.

  1. 첫 번째 Redirection
    사용자가 소셜 로그인 버튼을 클릭하면, 소셜 플랫폼의 인증 서버로 리디렉션된다.

  2. 두 번째 Redirection
    소셜 플랫폼에서 사용자가 로그인을 완료하고 권한 승인을 마치면, Authorization Code가 부여된다.
    이 코드는 사전에 지정된 애플리케이션의 엔드포인트로 리디렉션된다.


📌 프로젝트 적용방식 :

Spring Security를 기반으로 OAuth2를 처리하며, JWT 토큰 발급과 사용자 인증 상태를 관리


🔧 1. 사용자 인증 요청

사용자가 /oauth2/authorization/{provider} 경로로 요청하면 Spring Security의 OAuth2LoginConfigurer가 이를 가로채서 처리한다.

요청 흐름

  1. /oauth2/authorization/{provider}로 요청을 보내면 Spring Security가 이 경로를 감지한다.
  2. {provider}는 OAuth2 제공자의 이름으로, Spring Security가 설정한 ClientRegistration 정보에 따라 동작한다.
    • Spring Security는 ClientRegistrationRepository에 등록된 OAuth2 클라이언트 정보를 확인한다.
    • 이 정보는 application.yml 또는 코드에서 설정된다.
  security:
    oauth2:
      client:
        registration:
          naver: # security에서 감지하는 provider(registration id)
            client-name: NAVER
            client-id: ${NAVER_CLIENT_ID}
            client-secret: ${NAVER_CLIENT_SECRET}
            redirect-uri: "http://localhost:8080/login/oauth2/code/naver"
            authorization-grant-type: authorization_code
            scope:
              - name
              - email
        provider:
          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

❗️ 없는 registration id를 사용한 요청

WARN  OAuth2AuthorizationRequestRedirectFilter[unsuccessfulRedirectForAuthorization:231] Authorization Request failed: org.springframework.security.oauth2.client.web.InvalidClientRegistrationIdException: Invalid Client Registration with Id: apple
org.springframework.security.oauth2.client.web.InvalidClientRegistrationIdException: Invalid Client Registration with Id: apple

  • /oauth2/authorization/ 경로이기만 하면 provider 값이 어떤 값이던 Security가 감지한다.
  • 이는 registration id와 provider id를 자율적으로 설정 가능하다는 것을 의미한다.

💡 OAuth2LoginConfigurer 동작 총정리

  1. Spring Security는 ClientRegistrationRepository에 등록된 OAuth2 클라이언트 정보를 확인한다.
  2. OAuth2AuthorizationRequest를 생성한다. (요소: client-id, redirect-uri, scope, state)
  3. Spring Security는 제공자의 Authorization Endpoint로 리디렉션한다.

❗️ .yml의 provider URI와 실제 Redirection된 URI가 다른 이유

Spring Security를 사용해 OAuth 2.0 소셜 로그인을 구현할 때, .yml 파일에 설정한 authorization-uri와 실제 리디렉션된 URI가 서로 다를 수 있다. 예를 들어, 아래 설정에서 Google의 authorization-uri와 실제 요청된 URL이 다른 것을 확인할 수 있다.

설정된 URI와 실제 URI 비교

  • .yml에 설정된 URI:
    https://accounts.google.com/o/oauth2/v2/auth
  • 실제 이동한 URI:
    https://accounts.google.com/v3/signin
  security:
    oauth2:
      client:
        registration:
          google:
            client-name: GOOGLE
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            redirect-uri: "http://localhost:8080/login/oauth2/code/google"
            authorization-grant-type: authorization_code
            scope:
              - email
              - profile
        provider:
          google:
             authorization-uri: https://accounts.google.com/o/oauth2/v2/auth
             token-uri: https://oauth2.googleapis.com/token
             user-info-uri: https://openidconnect.googleapis.com/v1/userinfo
             user-name-attribute: sub

실제url :
https:// accounts.google.com/v3/signin/identifier?…

이유: Spring Security의 내장 Provider 기본 설정

Google, GitHub 등 일부 소셜 로그인 제공자는 Spring Security가 기본 URI 정보를 내장하고 있다.
Spring Security는 DefaultOAuth2ProviderDetails를 통해 OAuth 2.0 Provider의 표준 URI를 자동으로 관리한다. 따라서 .yml 파일에서 provider 정보를 생략하더라도, Spring Security는 Google의 인증 및 토큰 엔드포인트에 대한 정보를 이미 알고 있다.
이 과정에서, Spring Security는 Google의 최신 엔드포인트를 사용하여 요청을 리디렉션할 수 있다.
Google의 경우, https://accounts.google.com/v3/signin은 Spring Security가 자동으로 처리하는 최신 엔드포인트다.

  • ❗️실제로 .yml 파일에서 provider 부분을 제거해도 Google 로그인은 정상적으로 동작한다.

🔧 2. OAuth2 제공자와의 인증 과정

  1. 사용자가 소셜 로그인 플랫폼에서 ID/PW를 입력하여 인증 요청을 보낸다.
  2. OAuth2 제공자는 인증을 완료하고, Authorization Code를 발급하여 클라이언트(애플리케이션)로 리디렉션한다.
    • Spring Security는 OAuth2AuthorizationCodeGrantRequest를 통해 이 요청을 처리한다.
    • 이 과정에서 소셜 로그인 플랫폼은 직접 등록된 Redirect URI로 Authorization Code를 전달한다.
    • 플랫폼에 등록된 Redirect URI와 .yml 파일에 설정된 Redirect URI가 정확히 일치해야 다음 단계(Access Token 요청)로 넘어갈 수 있다

❗️ 테스트 목적으로 회원정보 요청 단계를 일부러 고장 내고, Authorization Code가 성공적으로 넘어오는지 확인했다.

결과 확인

  • 성공적으로 설정한 Redirect URI로 state와 code 값이 전달되는 것을 확인했다.

    실제 응답 URL :
http://localhost:8080/login/oauth2/code/google?state=0Cd20cWt4yC8P80%3Dcode=4%2F0AanRRrvEUWa128UWfpwM6L8oegw&scope=email+profile+openid…

설정한 .yml :

   security:
    oauth2:
      client:
        registration:
          google:
            client-name: GOOGLE
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            redirect-uri: "http://localhost:8080/login/oauth2/code/google"
            authorization-grant-type: authorization_code
            scope:
              - email
              - profile

❗️여기까지 성공하면 소셜 플랫폼에서는 "로그인 완료"로 간주한다

소셜 로그인 과정에서 Authorization Code가 애플리케이션으로 전달되는 순간, 소셜 플랫폼은 인증 절차가 끝났다고 간주한다.
이는 소셜 플랫폼이 이미 다음을 완료했음을 의미한다.

  1. 사용자의 인증(ID/PW 확인).
  2. 사용자로부터 애플리케이션에 권한을 부여받음.
  3. Authorization Code를 애플리케이션으로 전달.

소셜 플랫폼의 관점

소셜 플랫폼의 역할은 다음과 같

  1. 사용자의 신원 확인
    사용자가 올바른 ID/PW를 입력했는지 확인하고, 로그인 과정을 진행한다.

  2. 권한 승인 처리
    사용자가 애플리케이션에 접근 권한을 부여하도록 승인 요청을 처리한다.

  3. Authorization Code 전달
    승인된 권한에 따라 Authorization Code를 사전에 등록된 Redirect URI로 전달한다.

이 모든 작업이 완료되면, 소셜 플랫폼은 인증 과정에서 더 이상 개입하지 않는다.

이 설계는 책임 분리와 보안 모델 강화라는 OAuth 2.0의 철학을 반영한 것이다.

애플리케이션의 관점

소셜 플랫폼에서 Authorization Code를 받은 이후부터는 애플리케이션이 주도적으로 작업을 이어간다.

  1. Access Token 요청
    애플리케이션은 Authorization Code를 사용해 OAuth2 제공자에게 Access Token을 요청한다.

  2. 사용자 정보 요청
    발급받은 Access Token으로 소셜 플랫폼의 API를 호출하여 사용자 정보를 가져온다.

"로그인이 완료되었다"는 의미

소셜 플랫폼에서 Authorization Code를 발급한 시점은 "내 역할은 끝났다" 는 신호다.

  1. 사용자가 소셜 플랫폼에 성공적으로 로그인했다.
    사용자의 인증(ID/PW)이 완료되었고, 권한 승인도 마쳤다.

  2. 애플리케이션에 사용자 정보 접근 권한이 부여되었다.
    Authorization Code를 통해 애플리케이션은 Access Token을 요청할 수 있다.

결과적으로, 소셜 플랫폼의 역할은 인증 대행으로 한정된다.
이후 과정(Access Token 요청, 사용자 정보 활용 등)은 애플리케이션이 주도하며, 이를 통해 사용자 인증 상태를 유지하고 필요한 정보를 처리한다.


🔧 3. 인증 코드로 액세스 토큰 요청

customAccessTokenResponseClientAuthorization Code와 함께 OAuth2 제공자에게 액세스 토큰을 요청합니다.

  • RequestEntity를 생성하여 토큰 URI에 POST 요청.
  • 요청에 필요한 매개변수(grant_type, code, redirect_uri, client_id, client_secret)를 설정.
    @Bean
    public OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> customAccessTokenResponseClient() {
        DefaultAuthorizationCodeTokenResponseClient client = new DefaultAuthorizationCodeTokenResponseClient();

        client.setRequestEntityConverter(grantRequest -> {
            OAuth2AuthorizationRequest authorizationRequest = grantRequest.getAuthorizationExchange().getAuthorizationRequest();
            OAuth2AuthorizationResponse authorizationResponse = grantRequest.getAuthorizationExchange().getAuthorizationResponse();
            String tokenUri = grantRequest.getClientRegistration().getProviderDetails().getTokenUri();

            MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
            parameters.add("grant_type", "authorization_code");
            parameters.add("code", authorizationResponse.getCode());
            parameters.add("redirect_uri", grantRequest.getClientRegistration().getRedirectUri());
            parameters.add("client_id", grantRequest.getClientRegistration().getClientId());
            parameters.add("client_secret", grantRequest.getClientRegistration().getClientSecret()); // 강제 추가

            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

            return new RequestEntity<>(parameters, headers, HttpMethod.POST, URI.create(tokenUri));
        });

        return client;
    }

DefaultAuthorizationCodeTokenResponseClient를 사용하며, 요청을 커스터마이징하기 위해 setRequestEntityConverter를 설정

1.grantRequest의 기본 정보 추출

  • OAuth2AuthorizationRequestOAuth2AuthorizationResponse는 인증 과정에서 주고받은 정보를 담고 있음
    • authorizationRequest: 인증 요청 정보(리다이렉트 URI, 클라이언트 ID …)
    • authorizationResponse: 인증 제공자가 반환한 응답(Authorization Code, state …)
  • tokenUri: OAuth2 제공자의 토큰 엔드포인트 URL(yml provider에 설정)

2. 요청 파라미터 구성

MultiValueMap을 사용해 토큰 요청에 필요한 파라미터를 구성(grant_typ, code, redirect_uri, client_id, client_secret)

❗️ MultiValueMap
자바에 기본 내장된 인터페이스가 아닌 스프링에서 제공하는 인터페이스
Map이 인터페이스를 상속할 때 vlaue 값을 list로 감싼 채로 상속받음.
즉 하나의 key와 하나 이상의 value로 이루어진 리스트를 쌍으로 받음

3. HTTP 헤더 설정

  • 요청 헤더는 Content-Typeapplication/x-www-form-urlencoded로 설정.

4. RequestEntity 생성

  • RequestEntity는 HTTP 요청을 나타냅니다.
  • parameters, headers, HttpMethod.POST, URI.create(tokenUri)를 사용해 요청 엔터티를 생성.

5. 커스터마이징된 DefaultAuthorizationCodeTokenResponseClient 반환

6. OAuth2 제공자가 액세스 토큰 / 리프레시 토큰 반환


🔧 4. OAuth2 사용자 정보 가져오기

3번에서 발급받았던 액세스 토큰을 사용하여 소셜 로그인 제공자의 사용자 정보를 요청.

  • Spring Security의 CustomOAuth2UserService에서 loadUser 메서드가 호출됩니다.

❗️DefaultOAuth2UserServiceloadUser 메서드
Spring Security가 제공하는 기본 구현으로, 액세스 토큰을 사용해 사용자 정보를 요청하고 응답을 파싱하여 반환합니다.

  1. loadUser 메서드에서 제공자별 사용자 정보(Attributes)를 가져옵니다.
    • Google, Kakao, Naver와 같이 제공자별 데이터 구조에 따라 사용자 정보를 파싱.
    • 이메일, 이름, 프로필 정보 등을 추출(사용할 수 있는 scope 범위는 따로 설정해야함)

💡custom loadUser 메서드의 작동 방식

 @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) {
        OAuth2User oAuth2User = super.loadUser(userRequest);

        String providerName = userRequest.getClientRegistration().getClientName().toUpperCase();
        Provider provider = getValidProvider(providerName);

        Map<String, Object> attributes = oAuth2User.getAttributes();
        Map<String, Object> response = extractResponseAttributes(providerName, attributes);

        String emailOrUniqueId = resolveEmailOrUniqueId(providerName, response, attributes);
        String name = resolveName(providerName, attributes, response);

        saveOrUpdateUser(emailOrUniqueId, name, provider);

        return createCustomOAuth2User(response, providerName, emailOrUniqueId);
    }

1. OAuth2UserRequest 객체 전달

Spring Security가 loadUser를 호출할 때 OAuth2UserRequest 객체를 전달합니다. 이 객체에는 다음 정보가 포함됩니다:

  • ClientRegistration: 클라이언트 애플리케이션의 OAuth2 클라이언트 등록 정보 (ex. CLIENT ID, CLIENT SECRET등).
  • accessToken: 소셜 인증 제공자로부터 발급받은 액세스 토큰.
  • additionalParameters: 추가 요청 매개변수.

2. 사용자 정보 요청

DefaultOAuth2UserServiceloadUser 메서드는 소셜 인증 제공자의 사용자 정보 API를 호출하여 정보를 가져옵니다:

OAuth2User oAuth2User = super.loadUser(userRequest);

3. 소셜 제공자별 사용자 정보 처리

loadUser 메서드는 소셜 제공자(구글, 카카오 등)에 따라 다른 사용자 정보 구조를 처리합니다. CustomOAuth2UserService에서는 사용자 정보 응답을 커스터마이징하여 처리합니다:

  • getAttributes: 사용자 정보 응답의 JSON 데이터를 Map<String, Object>로 변환.
  • extractResponseAttributes: 소셜 제공자별로 다른 응답 형식을 처리하는 로직:
    • KAKAO: kakao_account에서 이메일, 닉네임 등 추출.
    • NAVER: response 객체에서 필요한 데이터 추출.
    • GOOGLE: 기본 응답에서 데이터 추출.
  • saveOrUpdateUser 메서드에서 동일한 email+provider 이면서 UserStatus가 ACTIVE인 사용자 조회.(프로젝트 중복회원 정책 고려)
    → 만약 조회되지 않으면 관련 회원정보 db에 저장(회원가입)
    → email값이 없을 경우 고유 id값을 email에 대신 저장(카카오, 네이버는 id / 구글은 sub로 회원별 고유값으로 반환)
  • CustomOAuth2User 객체를 생성하여 인증된 사용자 정보를 보유.

🔧 5. JWT 토큰 생성

  • tokenEndpoint(3번) → userInfoEndpoint(4번)을 성공적으로 수행하면 successHandler로 넘어감
.oauth2Login(oauth -> oauth
                        .tokenEndpoint(token -> token
                                .accessTokenResponseClient(customAccessTokenResponseClient())
                        )
                        .userInfoEndpoint(userInfo -> {
                            userInfo.userService(customOAuth2UserService);
                        })
                        .successHandler((request, response, authentication) -> {
                            response.sendRedirect("/users/signin/oauth");
                        })
                        .failureHandler((request, response, exception) -> {
                            throw new InvalidLoginException();
                        })
                );
  1. successHandler의 /users/signin/oauth 호출
  2. AuthService를 사용하여 JWT 액세스 토큰과 리프레시 토큰을 생성.(자체 로그인과 동일한 jwt 토큰 발급 로직 사용)
    • email + provider + id를 토큰에 포함.
 @GetMapping("/users/signin/oauth")
    public ResponseEntity<ResponseDTO<AuthResponseDto>> loginSuccess(Authentication authentication) {
        if (authentication == null || !(authentication.getPrincipal() instanceof CustomOAuth2User)) {
            throw new InvalidLoginException();
        }

        CustomOAuth2User customOAuth2User = (CustomOAuth2User) authentication.getPrincipal();
        String email = customOAuth2User.getEmail();
        String provider = customOAuth2User.getProvider();

        User user = userQueryRepository.findActiveUserByEmailAndProvider(email, Provider.valueOf(provider))
                .orElseThrow(InvalidLoginException::new);
        AuthResponseDto authResponse = authService.generateAndStoreTokens(user);

        return ResponseEntity.ok(ResponseDTO.okWithData(authResponse));
    }
  1. OAuthController/users/signin/oauth 엔드포인트에서 성공적으로 로그인한 사용자에 대한 응답 반환.

    • 클라이언트에게 액세스 토큰, 리프레시 토큰을 전달.

    ❗️ 최종 단계에서 /users/signin/oauth 로 호출을 하고, 기존 로그인 로직과 동일하게 토큰을 생성하여 응답한다

profile
공부...열심히...

0개의 댓글