카테고리 없음

OAuth2 Client

ssseung 2024. 3. 7. 19:36

[ 목차 ]

    설정

    * 환경 spring boot 3.2.1

     

    소셜 로그인을 하려면 먼저 아래와 같은 설정을 해주어야한다.

     

    1. gradle OAuth2 client 의존성 추가

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

     

    2. spring application.yaml 에 client 정보 등록

    authorization server(kakao developers) 에 client 로써, client application 을 등록하면 client id, secret 을 받을 수 있다.

    scope 은 고객에게서 수집할 정보로 선택한 항목들이며. 

    redirect uri 는  authorization server 로부터  authorization code 를 전달 받을 uri 로, 애플리케이션을 등록할 때 직접 입력하는 항목이다. 

    /login/oauth2/code 는 OAuth2LoginAuthenticationFilter 의 기본 uri 이다.

    client 로 등록한 정보는 스프링에서 RegisteredClient 로 InMemory 에 저장된다.

    spring:
      application:
        name: oauth2-client
      security:
          oauth2:
            client:
              registration:
                  kakao:
                    client-id: ******
                    client-secret: ******
                    authorization-grant-type: authorization_code
                    redirect-uri: http://localhost:8888/login/oauth2/code/ 
                    scope:
                      - profile_nickname
                      - profile_image
                    client-authentication-method: client_secret_post

     

    ※ 선택할 개인정보의 ID 를 scope 에 명시해줘야하는데 잘못 입력하면 로그인 화면을 띄워줄 때 오류가 난다.

    kakao 에서 고객 정보 수집 동의 사항 선택  scope 에 ID 를 잘못 넣었을 때 로그인 화면 redirect 시 발생하는
    오류
    수집 정보 ID 가 명시되어 있다.






    카카오 개인정보 항목 선택 페이지




    scope 을 잘못 설정했을 때 나는 오류

     


     

    구현

    카카오에 로그인해서 얻는 authorization_code, access_code 등의 정보는 기본적으로 아래와 같은 테이블 구조에 저장된다.

    https://docs.spring.io/spring-security/reference/servlet/appendix/database-schema.html#dbschema-oauth2-client

    CREATE TABLE oauth2_authorized_client (
      client_registration_id varchar(100) NOT NULL, //kakao server
      principal_name varchar(200) NOT NULL,  // user 명
      access_token_type varchar(100) NOT NULL,
      access_token_value blob NOT NULL,
      access_token_issued_at timestamp NOT NULL,
      access_token_expires_at timestamp NOT NULL,
      access_token_scopes varchar(1000) DEFAULT NULL,
      refresh_token_value blob DEFAULT NULL,
      refresh_token_issued_at timestamp DEFAULT NULL,
      created_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
      PRIMARY KEY (client_registration_id, principal_name)
    );

     

    spring 에서 default 로는 InMemoryOAuth2AuthorizedClientService 를 사용하고 JdbcOAuth2AuthorizedClientService 로 대체할 수 있는데, 나는 JPA 를 사용하기 위한 구현체를 따로 만들었다.

    import com.sample.oauth.client.repository.JpaAuthorizedClientRepository;
    import lombok.RequiredArgsConstructor;
    import org.springframework.dao.DataRetrievalFailureException;
    import org.springframework.security.core.Authentication;
    import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
    import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
    import org.springframework.security.oauth2.client.registration.ClientRegistration;
    import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
    import org.springframework.security.oauth2.core.OAuth2AccessToken;
    import org.springframework.security.oauth2.core.OAuth2RefreshToken;
    import org.springframework.stereotype.Service;
    import org.springframework.util.StringUtils;
    
    import java.time.Instant;
    import java.util.Collections;
    import java.util.Set;
    
    @Service
    @RequiredArgsConstructor
    public class JpaAuthorizedClientStore implements OAuth2AuthorizedClientService {
        private final JpaAuthorizedClientRepository clientRepository;
        private final ClientRegistrationRepository registrationRepository;
    
    
        @Override
        public <T extends OAuth2AuthorizedClient> T loadAuthorizedClient(String clientRegistrationId, String principalName) {
            AuthorizedClientJpe authorizedClientJpe = clientRepository.findByClientPkClientRegistrationIdAndClientPkPrincipalName(clientRegistrationId, principalName);
            ClientRegistration clientRegistration = this.registrationRepository
                    .findByRegistrationId(clientRegistrationId);
            if (clientRegistration == null) {
                throw new DataRetrievalFailureException(
                        "The ClientRegistration with id '" + clientRegistrationId + "' exists in the data source, "
                                + "however, it was not found in the ClientRegistrationRepository.");
            }
            OAuth2AccessToken.TokenType tokenType = null;
            String accessTokenType = authorizedClientJpe.getAccessTokenType();
            if (OAuth2AccessToken.TokenType.BEARER.getValue().equalsIgnoreCase(accessTokenType)) {
                tokenType = OAuth2AccessToken.TokenType.BEARER;
            }
            String tokenValue = authorizedClientJpe.getAccessTokenValue();
            Instant issuedAt = authorizedClientJpe.getAccessTokenIssuedAt();
            Instant expiresAt = authorizedClientJpe.getAccessTokenExpiresAt();
            Set<String> scopes = Collections.emptySet();
            String accessTokenScopes = authorizedClientJpe.getAccessTokenScopes();
            if (accessTokenScopes != null) {
                scopes = StringUtils.commaDelimitedListToSet(accessTokenScopes);
            }
            OAuth2AccessToken accessToken = new OAuth2AccessToken(tokenType, tokenValue, issuedAt, expiresAt, scopes);
    
            String refreshTokenValue = authorizedClientJpe.getRefreshTokenValue();
            Instant refreshTokenIssuedAt = authorizedClientJpe.getRefreshTokenIssuedAt();
    
            OAuth2RefreshToken refreshToken = new OAuth2RefreshToken(refreshTokenValue, refreshTokenIssuedAt);
    
            return (T) new OAuth2AuthorizedClient(clientRegistration, principalName, accessToken, refreshToken);
        }
    
        @Override
        public void saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal) {
            ClientRegistration clientRegistration = authorizedClient.getClientRegistration();
            OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
            OAuth2RefreshToken refreshToken = authorizedClient.getRefreshToken();
    
            AuthorizedClientJpe authorizedClientJpe = new AuthorizedClientJpe();
            authorizedClientJpe.setClientPk(new ClientPK(clientRegistration.getRegistrationId(), principal.getName()));
            authorizedClientJpe.setAccessTokenType(accessToken.getTokenType().getValue());
            authorizedClientJpe.setAccessTokenValue(accessToken.getTokenValue());
            authorizedClientJpe.setAccessTokenIssuedAt(accessToken.getIssuedAt());
            authorizedClientJpe.setAccessTokenExpiresAt(accessToken.getExpiresAt());
            authorizedClientJpe.setAccessTokenScopes(StringUtils.collectionToDelimitedString(accessToken.getScopes(), ","));
            authorizedClientJpe.setRefreshTokenValue(refreshToken.getTokenValue());
            authorizedClientJpe.setRefreshTokenIssuedAt(refreshToken.getIssuedAt());
            authorizedClientJpe.setCreatedAt(Instant.now());
            clientRepository.save(authorizedClientJpe);
        }
    
        @Override
        public void removeAuthorizedClient(String clientRegistrationId, String principalName) {
            clientRepository.deleteByClientPkClientRegistrationIdAndClientPkPrincipalName(clientRegistrationId, principalName);
        }
    }

     


     

    진행과정

    1. authorization server 에 request 를 보낸다. 

    Get
    {{client-server-uri}}/oauth2/authorization/{{registrationId}}

    registrationId 는 yml 에 등록한 authorization server 의 이름이다.

    http://localhost:8888/oauth2/authorization/kakao

     

    oauth2/authorization 이라는 기본 uri 는 resolver 설정을 통해 원하는 uri 로 변경할 수 있다. 

     private OAuth2AuthorizationRequestResolver authorizationRequestResolver(
                ClientRegistrationRepository clientRegistrationRepository) {
            DefaultOAuth2AuthorizationRequestResolver authorizationRequestResolver =
                    new DefaultOAuth2AuthorizationRequestResolver(
                            clientRegistrationRepository, "/oauth2/authorization");
            return  authorizationRequestResolver;
        }

     

    아래와 같이 호출하면 authorization server 에서 인증되지 않은 유저에 대해 로그인 페이지를 반환한다. 

    로그인을 하면 client에서 OAuth2AuthorizationRequestRedirectFilter 에서 DefaultOAuth2AuthorizationRequestResolver 를 사용해 authorization_code 발행 요청을 하는 request 객체를 만들어 호출한다.

    request 객체에 들어가는 정보는 yaml 에 설정했던 정보이다.

     

    http://localhost:85/api/v1/oauth2/authorize?response_type=code
    &client_id={{registrationid_clientid}}
    &scope=name
    &state=aoknqLflw9irBhXTkowkBKCUZBGBjmPILj8AhGeNlvs%3D
    &redirect_uri=http://localhost:8888/login/oauth2/code&prompt=consent

     

    해당 request 를 받고 authorization server 에서 redirect_uri 로 설정된 주소로 authorization_code 를 반환해주면, 

    OAuth2LoginAuthenticationFilter 가 authorization_code 정보와 RegisteredClient 정보로

    access_token 을 요청하는 request 를 생성하고 response 를 받는다.

    OAuth2LoginAuthenticationFilter - attemptAuthentication

    박스에 있는 getAuthenticationManager 에서 호출된 ProviderManager 는 OAuth2LoginAuthenticationProvider 를 호출하고

    OAuth2LoginAuthenticationProviderOAuth2AuthorizationCodeAuthenticationProvider를,

    OAuth2AuthorizationCodeAuthenticationProvider는 DefaultAuthorizationCodeTokenResponseClient를 호출해

    access token 을 요청하는 request 를 보낸다. 

    받아온 access token 으로 DefaultOAuth2UserService 에서는 리소스 서버에 userinfo 를 요청해 OAuth2User 를 받는다.

    OAuth2LoginAuthenticationProvider - authenticate

    만약 scopes에 openid 가 포함되어있다면, 위 로직을 타지 않고, OidcAuthorizationCodeAuthenticationProviderOidcUserService 를 통해 OAuth2User 를 로드한다. 

     

     

     

     

     

     

    반응형