토리맘의 한글라이즈 프로젝트 logo 토리맘의 한글라이즈 프로젝트

스프링 시큐리티 공식 레퍼런스를 한글로 번역한 문서입니다.

전체 목차는 여기에 있습니다.

목차:


12.1. OAuth 2.0 Login

OAuth 2.0 로그인 기능을 사용하면 어플리케이션의 사용자를 외부 OAuth 2.0 Provider나 (e.g. 깃허브) OpenID Connect 1.0 Provider (구글) 계정으로 로그인할 수 있다. “구글 계정으로 로그인”, “깃허브 계정으로 로그인”은 바로 이 OAuth 2.0 로그인을 구현한 것이다.

OAuth 2.0 로그인은 OAuth 2.0 인가 프레임워크OpenID Connect Core 1.0에 명시된 대로 인가 코드 부여 (Authorization Code Grant) 방식을 사용한다.

12.1.1. Spring Boot 2.x Sample

스프링 부트 2.X는 OAuth 2.0 로그인을 완전히 자동화한다.

이번 섹션에선 구글인증 provider로 사용하는 OAuth 2.0 로그인 샘플 설정을 구성하는 방법을 설명하며, 다음 주제를 다룬다:

Initial setup

구글의 OAuth 2.0 인증 시스템으로 로그인하려면 구글 API 콘솔에 프로젝트를 만들고 OAuth 2.0 credential을 받아야 한다.

구글의 OAuth 2.0 인증OpenID Connect 1.0 스펙을 준수하며 OpenID 인증을 받았다.

OpenID Connect 페이지에 있는 첫 번째 섹션 “Setting up OAuth 2.0” 가이드대로 따라해 봐라.

“Obtain OAuth 2.0 credentials” 섹션까지 마쳤다면, Client ID와 Client Secret으로 구성된 credential과 신규 OAuth 클라이언트가 생겼을 것이다.

Setting the redirect URI

리다이렉트 URL은, 구글로 인증을 마친 최종 사용자가 동의 페이지에서 Oauth 클라이언트에 (전 단계에서 생성한) 접근 권한을 부여하면, 이 사용자의 user-agent가 다시 되돌아가야 할 어플리케이션 path를 의미한다.

“Set a redirect URI” 섹션에선 승인된 리다이렉트 URL 필드를 http://localhost:8080/login/oauth2/code/google로 설정해야 한다.

디폴트 리다이렉트 URL 템플릿은 {baseUrl}/login/oauth2/code/{registrationId}다. registrationIdClientRegistration을 식별하는 유니크한 값이다.

OAuth 클라이언트 앞단에 프록시 서버를 둔다면 어플리케이션 설정에 문제가 없도록 프록시 서버 설정을 확인해보길 권한다. redirect-uri에 사용할 수 있는 URI 템플릿 변수도 참고하면 좋다.

Configure application.yml

이제 구글의 새 OAuth 클라이언트가 준비됐음으로, 어플리케이션의 인증 플로우에서 이 OAuth 클라이언트를 사용하도록 설정해줘야 한다. 이를 위해선:

Boot up the application

스프링 부트 2.x 샘플을 기동한 뒤 http://localhost:8080에 접속해보자. 그러면 구글로 가는 링크가 있는, 자동 생성된 디폴트 로그인 페이지로 이동할 거다.

링크를 클릭하면 구글로 이동해서 인증을 시작한다.

구글 계정 credential로 인증한 다음에 보이는 페이지는 동의 스크린이다. 이 페이지는 이전에 생성한 OAuth 클라이언트에 접근 권한을 줄건지 말건지를 묻는다. Allow를 클릭해서 OAuth 클라이언트가 이메일 주소와 기본적인 프로필 정보에 접근할 수 있게 해주자.

그러면 OAuth 클라이언트는 UserInfo 엔드포인트로부터 이메일 주소와 기본적인 프로필 정보를 가져오며, 인증된 세션을 시작한다.

12.1.2. Spring Boot 2.x Property Mappings

다음은 스프링 부트 2.X OAuth 클라이언트 프로퍼티와 ClientRegistration 프로퍼티의 매핑 정보를 정리한 테이블이다.

Spring Boot 2.x ClientRegistration
spring.security.oauth2.client.registration.[registrationId] registrationId
spring.security.oauth2.client.registration.[registrationId].client-id clientId
spring.security.oauth2.client.registration.[registrationId].client-secret clientSecret
spring.security.oauth2.client.registration.[registrationId].client-authentication-method clientAuthenticationMethod
spring.security.oauth2.client.registration.[registrationId].authorization-grant-type authorizationGrantType
spring.security.oauth2.client.registration.[registrationId].redirect-uri redirectUriTemplate
spring.security.oauth2.client.registration.[registrationId].scope scopes
spring.security.oauth2.client.registration.[registrationId].client-name clientName
spring.security.oauth2.client.provider.[providerId].authorization-uri providerDetails.authorizationUri
spring.security.oauth2.client.provider.[providerId].token-uri providerDetails.tokenUri
spring.security.oauth2.client.provider.[providerId].jwk-set-uri providerDetails.jwkSetUri
spring.security.oauth2.client.provider.[providerId].user-info-uri providerDetails.userInfoEndpoint.uri
spring.security.oauth2.client.provider.[providerId].user-info-authentication-method providerDetails.userInfoEndpoint.authenticationMethod
spring.security.oauth2.client.provider.[providerId].user-name-attribute providerDetails.userInfoEndpoint.userNameAttributeName

spring.security.oauth2.client.provider.[providerId].issuer-uri 프로퍼티를 지정하면 OpenID Connect Provider의 설정 엔드포인트나 인가 서버의 메타데이터 엔드포인트를 찾아 ClientRegistration을 초기화할 수 있다.

12.1.3. CommonOAuth2Provider

CommonOAuth2Provider엔 유명한 provider 구글, 깃허브, 페이스북, 옥타 전용 디폴트 클라이언트 프로퍼티가 미리 정의돼 있다.

예를 들어 provider의 authorization-uri, token-uri, user-info-uri는 자주 변경되는 값이 아니다. 따라서 디폴트 값을 제공해서 필요한 설정을 줄이는 게 바람직하다.

앞에서도 말했지만, 구글 클라이언트를 설정할 때는 client-id, client-secret 프로퍼티만 있으면 된다.

다음은 구글 클라이언트 설정 예시이다:

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: google-client-id
            client-secret: google-client-secret

여기선 클라이언트 프로퍼티가 자동으로 디폴트값으로 들어가는데, registrationId(google)가 CommonOAuth2Provider에 있는 GOOGLE enum과 일치하기 때문이다 (대소문자는 구분하지 않는다).

google-login 등 다른 registrationId를 지정할 때도 provider 프로퍼티를 설정하면 해당하는 디폴트 값이 자동으로 들어가게 할 수 있다.

예를 들어:

spring:
  security:
    oauth2:
      client:
        registration:
          google-login:   # (1)
            provider: google   # (2)
            client-id: google-client-id
            client-secret: google-client-secret

(1) registrationIdgoogle-login으로 설정한다.
(2) provider프로퍼티를 google로 설정하면 CommonOAuth2Provider.GOOGLE.getBuilder()에 있는 디폴트 클라이언트 프로퍼티가 자동으로 설정된다.

12.1.4. Configuring Custom Provider Properties

멀티 테넌시를 지원하는 OAuth 2.0 Provider도 있는데, 이땐 각 테넌트마다 (또는 서브 도메인) 프로토콜 엔드포인트가 다르다.

예를 들어 옥타에 등록한 OAuth 클라이언트를 특정 서브 도메인에 할당하고 각자 다른 프로포콜 엔드포인트를 갖도록 할 수 있다.

스프링 부트 2.X는 이런 경우를 위해 커스텀 provider 프로퍼티를 설정할 수 있는 베이스 프로퍼티를 제공한다: spring.security.oauth2.client.provider.[providerId].

다음은 그 예시이다:

spring:
  security:
    oauth2:
      client:
        registration:
          okta:
            client-id: okta-client-id
            client-secret: okta-client-secret
        provider:
          okta:  # (1)
            authorization-uri: https://your-subdomain.oktapreview.com/oauth2/v1/authorize
            token-uri: https://your-subdomain.oktapreview.com/oauth2/v1/token
            user-info-uri: https://your-subdomain.oktapreview.com/oauth2/v1/userinfo
            user-name-attribute: sub
            jwk-set-uri: https://your-subdomain.oktapreview.com/oauth2/v1/keys

(1) 베이스 프로퍼티를 이용해서 (spring.security.oauth2.client.provider.okta) 프로토콜 엔드포인트마다 커스텀 설정을 적용한다.

12.1.5. Overriding Spring Boot 2.x Auto-configuration

스프링 부트 2.X에서 OAuth 클라이언트의 자동 설정을 지원하는 클래스는 OAuth2ClientAutoConfiguration이다.

이 클래스는 다음과 같은 일을 한다:

이 자동 설정을 요구사항에 따라 재정의하고 싶다면 다음 방법을 사용해라:

Register a ClientRegistrationRepository @Bean

다음 예제는 ClientRegistrationRepository @Bean을 등록하는 방법을 보여준다:

@Configuration
public class OAuth2LoginConfig {

    @Bean
    public ClientRegistrationRepository clientRegistrationRepository() {
        return new InMemoryClientRegistrationRepository(this.googleClientRegistration());
    }

    private ClientRegistration googleClientRegistration() {
        return ClientRegistration.withRegistrationId("google")
            .clientId("google-client-id")
            .clientSecret("google-client-secret")
            .clientAuthenticationMethod(ClientAuthenticationMethod.BASIC)
            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
            .redirectUriTemplate("{baseUrl}/login/oauth2/code/{registrationId}")
            .scope("openid", "profile", "email", "address", "phone")
            .authorizationUri("https://accounts.google.com/o/oauth2/v2/auth")
            .tokenUri("https://www.googleapis.com/oauth2/v4/token")
            .userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo")
            .userNameAttributeName(IdTokenClaimNames.SUB)
            .jwkSetUri("https://www.googleapis.com/oauth2/v3/certs")
            .clientName("Google")
            .build();
    }
}

Provide a WebSecurityConfigurerAdapter

다음 예제는 @EnableWebSecurityWebSecurityConfigurerAdapter를 제공하고, httpSecurity.oauth2Login() 메소드로 OAuth 2.0 로그인을 활성화하는 방법을 보여준다:

Example 81. OAuth2 Login Configuration

java kotlin
@EnableWebSecurity
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests(authorize -> authorize
                .anyRequest().authenticated()
            )
            .oauth2Login(withDefaults());
    }
}
@EnableWebSecurity
class OAuth2LoginSecurityConfig : WebSecurityConfigurerAdapter() {

    override fun configure(http: HttpSecurity) {
        http {
            authorizeRequests {
                authorize(anyRequest, authenticated)
            }
            oauth2Login { }
        }
    }
}

Completely Override the Auto-configuration

다음 예제는 ClientRegistrationRepository @Bean도 등록하고 WebSecurityConfigurerAdapter도 제공해서 자동 설정을 완전히 재정의하는 방법을 보여준다.

Example 82. Overriding the auto-configuration

java kotlin
@Configuration
public class OAuth2LoginConfig {

    @EnableWebSecurity
    public static class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {

        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                .authorizeRequests(authorize -> authorize
                    .anyRequest().authenticated()
                )
                .oauth2Login(withDefaults());
        }
    }

    @Bean
    public ClientRegistrationRepository clientRegistrationRepository() {
        return new InMemoryClientRegistrationRepository(this.googleClientRegistration());
    }

    private ClientRegistration googleClientRegistration() {
        return ClientRegistration.withRegistrationId("google")
            .clientId("google-client-id")
            .clientSecret("google-client-secret")
            .clientAuthenticationMethod(ClientAuthenticationMethod.BASIC)
            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
            .redirectUriTemplate("{baseUrl}/login/oauth2/code/{registrationId}")
            .scope("openid", "profile", "email", "address", "phone")
            .authorizationUri("https://accounts.google.com/o/oauth2/v2/auth")
            .tokenUri("https://www.googleapis.com/oauth2/v4/token")
            .userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo")
            .userNameAttributeName(IdTokenClaimNames.SUB)
            .jwkSetUri("https://www.googleapis.com/oauth2/v3/certs")
            .clientName("Google")
            .build();
    }
}
@Configuration
class OAuth2LoginConfig {

    @EnableWebSecurity
    class OAuth2LoginSecurityConfig: WebSecurityConfigurerAdapter() {

        override fun configure(http: HttpSecurity) {
            http {
                authorizeRequests {
                    authorize(anyRequest, authenticated)
                }
                oauth2Login { }
            }
        }
    }

    @Bean
    fun clientRegistrationRepository(): ClientRegistrationRepository {
        return InMemoryClientRegistrationRepository(googleClientRegistration())
    }

    private fun googleClientRegistration(): ClientRegistration {
        return ClientRegistration.withRegistrationId("google")
                .clientId("google-client-id")
                .clientSecret("google-client-secret")
                .clientAuthenticationMethod(ClientAuthenticationMethod.BASIC)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .redirectUriTemplate("{baseUrl}/login/oauth2/code/{registrationId}")
                .scope("openid", "profile", "email", "address", "phone")
                .authorizationUri("https://accounts.google.com/o/oauth2/v2/auth")
                .tokenUri("https://www.googleapis.com/oauth2/v4/token")
                .userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo")
                .userNameAttributeName(IdTokenClaimNames.SUB)
                .jwkSetUri("https://www.googleapis.com/oauth2/v3/certs")
                .clientName("Google")
                .build()
    }
}

12.1.6. Java Configuration without Spring Boot 2.x

스프링 부트 2.X를 사용하진 않지만 CommonOAuth2Provider에 정의된 provider를 설정하고 싶다면 (e.g. 구글) 다음 설정을 적용해라:

Example 83. OAuth2 Login Configuration

java xml
@Configuration
public class OAuth2LoginConfig {

    @EnableWebSecurity
    public static class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {

        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                .authorizeRequests(authorize -> authorize
                    .anyRequest().authenticated()
                )
                .oauth2Login(withDefaults());
        }
    }

    @Bean
    public ClientRegistrationRepository clientRegistrationRepository() {
        return new InMemoryClientRegistrationRepository(this.googleClientRegistration());
    }

    @Bean
    public OAuth2AuthorizedClientService authorizedClientService(
            ClientRegistrationRepository clientRegistrationRepository) {
        return new InMemoryOAuth2AuthorizedClientService(clientRegistrationRepository);
    }

    @Bean
    public OAuth2AuthorizedClientRepository authorizedClientRepository(
            OAuth2AuthorizedClientService authorizedClientService) {
        return new AuthenticatedPrincipalOAuth2AuthorizedClientRepository(authorizedClientService);
    }

    private ClientRegistration googleClientRegistration() {
        return CommonOAuth2Provider.GOOGLE.getBuilder("google")
            .clientId("google-client-id")
            .clientSecret("google-client-secret")
            .build();
    }
}
<http auto-config="true">
    <intercept-url pattern="/**" access="authenticated"/>
    <oauth2-login authorized-client-repository-ref="authorizedClientRepository"/>
</http>

<client-registrations>
    <client-registration registration-id="google"
                         client-id="google-client-id"
                         client-secret="google-client-secret"
                         provider-id="google"/>
</client-registrations>

<b:bean id="authorizedClientService"
        class="org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService"
        autowire="constructor"/>

<b:bean id="authorizedClientRepository"
        class="org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository">
    <b:constructor-arg ref="authorizedClientService"/>
</b:bean>

12.1.7. Advanced Configuration

HttpSecurity.oauth2Login() 메소드는 OAuth 2.0 설정을 커스텀할 수 있는 많은 옵션을 제공한다. 주요 설정 옵션은 프로토콜 엔드포인트 항목별로 묶여있다.

예를 들어 oauth2Login().authorizationEndpoint()로는 인가 엔드포인트를, oauth2Login().tokenEndpoint()로는 토큰 엔드포인트를 설정할 수 있다.

다음은 이를 설정하는 예시이다:

Example 84. Advanced OAuth2 Login Configuration

java kotlin
@EnableWebSecurity
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .oauth2Login(oauth2 -> oauth2
                .authorizationEndpoint(authorization -> authorization
                        ...
                )
                .redirectionEndpoint(redirection -> redirection
                        ...
                )
                .tokenEndpoint(token -> token
                        ...
                )
                .userInfoEndpoint(userInfo -> userInfo
                        ...
                )
            );
    }
}
@EnableWebSecurity
class OAuth2LoginSecurityConfig : WebSecurityConfigurerAdapter() {

    override fun configure(http: HttpSecurity) {
        http {
            oauth2Login {
                authorizationEndpoint {
                    ...
                }
                redirectionEndpoint {
                    ...
                }
                tokenEndpoint {
                    ...
                }
                userInfoEndpoint {
                    ...
                }
            }
        }
    }
}

oauth2Login() DSL의 핵심 목표는 스펙에 정의된 네이밍과 최대한 일치시키는 것이었다.

OAuth 2.0 인가 프레임워크는 다음과 같은 프로토콜 엔드포인트를 정의하고 있다:

권한을 부여할 때는 두 개의 인가 서버 엔드포인트(HTTP 리소스)를 사용한다:

또한 클라이언트 엔드포인트를 하나 사용한다:

OpenID Connect Core 1.0 스펙은 UserInfo 엔드포인트를 다음과 같이 정의한다:

UserInfo 엔드포인트는 인증된 최종 사용자에 대한 클레임을 리턴하는, OAuth 2.0으로 보호하는 리소스다. 사용자에 대한 요청 클레임을 가져오려면, 클라이언트는 OpenID Connect 인증으로 가져온 액세스 토큰을 사용해서 UserInfo 엔드포인트로 요청해야 한다. 보통 클레임의 name-value 쌍을 컬렉션으로 가지고 있는 JSON 객체로 클레임을 표현한다.

다음은 oauth2Login() DSL로 설정할 수 있는 모든 옵션을 보여주는 예제다:

Example 85. OAuth2 Login Configuration Options

java kotlin
@EnableWebSecurity
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .oauth2Login(oauth2 -> oauth2
                .clientRegistrationRepository(this.clientRegistrationRepository())
                .authorizedClientRepository(this.authorizedClientRepository())
                .authorizedClientService(this.authorizedClientService())
                .loginPage("/login")
                .authorizationEndpoint(authorization -> authorization
                    .baseUri(this.authorizationRequestBaseUri())
                    .authorizationRequestRepository(this.authorizationRequestRepository())
                    .authorizationRequestResolver(this.authorizationRequestResolver())
                )
                .redirectionEndpoint(redirection -> redirection
                    .baseUri(this.authorizationResponseBaseUri())
                )
                .tokenEndpoint(token -> token
                    .accessTokenResponseClient(this.accessTokenResponseClient())
                )
                .userInfoEndpoint(userInfo -> userInfo
                    .userAuthoritiesMapper(this.userAuthoritiesMapper())
                    .userService(this.oauth2UserService())
                    .oidcUserService(this.oidcUserService())
                    .customUserType(GitHubOAuth2User.class, "github")
                )
            );
    }
}
@EnableWebSecurity
class OAuth2LoginSecurityConfig : WebSecurityConfigurerAdapter() {

    override fun configure(http: HttpSecurity) {
        http {
            oauth2Login {
                clientRegistrationRepository = clientRegistrationRepository()
                authorizedClientRepository = authorizedClientRepository()
                authorizedClientService = authorizedClientService()
                loginPage = "/login"
                authorizationEndpoint {
                    baseUri = authorizationRequestBaseUri()
                    authorizationRequestRepository = authorizationRequestRepository()
                    authorizationRequestResolver = authorizationRequestResolver()
                }
                redirectionEndpoint {
                    baseUri = authorizationResponseBaseUri()
                }
                tokenEndpoint {
                    accessTokenResponseClient = accessTokenResponseClient()
                }
                userInfoEndpoint {
                    userAuthoritiesMapper = userAuthoritiesMapper()
                    userService = oauth2UserService()
                    oidcUserService = oidcUserService()
                    customUserType(GitHubOAuth2User::class.java, "github")
                }
            }
        }
    }
}

oauth2Login() DSL 외에도 XML 설정을 지원한다.

다음은 시큐리티 네임스페이스로 설정할 수 있는 모든 옵션을 보여주는 예제다:

Example 86. OAuth2 Login XML Configuration Options

<http>
    <oauth2-login client-registration-repository-ref="clientRegistrationRepository"
                  authorized-client-repository-ref="authorizedClientRepository"
                  authorized-client-service-ref="authorizedClientService"
                  authorization-request-repository-ref="authorizationRequestRepository"
                  authorization-request-resolver-ref="authorizationRequestResolver"
                  access-token-response-client-ref="accessTokenResponseClient"
                  user-authorities-mapper-ref="userAuthoritiesMapper"
                  user-service-ref="oauth2UserService"
                  oidc-user-service-ref="oidcUserService"
                  login-processing-url="/login/oauth2/code/*"
                  login-page="/login"
                  authentication-success-handler-ref="authenticationSuccessHandler"
                  authentication-failure-handler-ref="authenticationFailureHandler"
                  jwt-decoder-factory-ref="jwtDecoderFactory"/>
</http>

이어지는 섹션에선 아래 설정 옵션을 좀 더 자세히 살펴본다:

OAuth 2.0 Login Page

기본적으로 OAuth 2.0 로그인 페이지는 DefaultLoginPageGeneratingFilter가 자동으로 생성해 준다. 이 디폴트 로그인 페이지는 설정해둔 OAuth 클라이언트 ClientRegistration.clientName을 보여준다. 링크를 누르면 인가 요청을 (또는 OAuth 2.0 로그인을) 시작할 수 있다.

DefaultLoginPageGeneratingFilter가 설정에 있는 OAuth 클라이언트 링크를 보여주려면, Iterable<ClientRegistration>을 구현하고 있는 ClientRegistrationRepository를 등록해야 한다. InMemoryClientRegistrationRepository를 참고하라.

OAuth 클라이언트에 따른 디폴트 링크는 다음과 같다:

OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI + "/{registrationId}"

예를 들어 다음과 같은 링크를 사용한다:

<a href="/oauth2/authorization/google">Google</a>

디폴트 로그인 페이지를 재정의하려면 oauth2Login().loginPage()를 사용해라. 원한다면 oauth2Login().authorizationEndpoint().baseUri()도 함께 설정할 수 있다.

다음은 디폴트 로그인 페이지를 변경하는 예시이다:

Example 87. OAuth2 Login Page Configuration

java kotlin xml
@EnableWebSecurity
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .oauth2Login(oauth2 -> oauth2
                .loginPage("/login/oauth2")
                ...
                .authorizationEndpoint(authorization -> authorization
                    .baseUri("/login/oauth2/authorization")
                    ...
                )
            );
    }
}
@EnableWebSecurity
class OAuth2LoginSecurityConfig : WebSecurityConfigurerAdapter() {

    override fun configure(http: HttpSecurity) {
        http {
            oauth2Login {
                loginPage = "/login/oauth2"
                authorizationEndpoint {
                    baseUri = "/login/oauth2/authorization"
                }
            }
        }
    }
}
<http>
    <oauth2-login login-page="/login/oauth2"
                  ...
    />
</http>

커스텀 로그인 페이지를 렌더링할, @RequestMapping("/login/oauth2")를 사용하는 @Controller도 만들어야 한다.

앞에서 말했듯이 oauth2Login().authorizationEndpoint().baseUri()는 선택사항이다. 하지만 이를 변경하기로 했다면, 각 OAuth 클라이언트 링크와 authorizationEndpoint().baseUri()가 일치해야 한다. 예를 들어 다음과 같다:

 <a href="/login/oauth2/authorization/google">Google</a>
Redirection Endpoint

리다이렉션 엔드포인트는 인가 서버가 리소스 소유자의 user-agent를 통해 가져온 인가 응답을 (인가 credential 포함) 클라이언트에게 전송할 때 사용한다.

OAuth 2.0 로그인은 인가 코드 부여 (Authorization Code Grant) 방식을 사용한다. 따라서 인가 credential은 인가 코드를 의미한다.

디폴트 인가 응답 baseUri는 (리다이렉션 엔드포인트) **/login/oauth2/code/***이며 OAuth2LoginAuthenticationFilter.DEFAULT_FILTER_PROCESSES_URI에 정의돼 있다.

인가 응답의 baseUri를 커스텀하고 싶다면 아래 예제처럼 설정해라:

Example 88. Redirection Endpoint Configuration

java kotlin xml
@EnableWebSecurity
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .oauth2Login(oauth2 -> oauth2
                .redirectionEndpoint(redirection -> redirection
                    .baseUri("/login/oauth2/callback/*")
                    ...
                )
            );
    }
}
@EnableWebSecurity
class OAuth2LoginSecurityConfig : WebSecurityConfigurerAdapter() {

    override fun configure(http: HttpSecurity) {
        http {
            oauth2Login {
                redirectionEndpoint {
                    baseUri = "/login/oauth2/callback/*"
                }
            }
        }
    }
}
<http>
    <oauth2-login login-processing-url="/login/oauth2/callback/*"
                  ...
    />
</http>

ClientRegistration.redirectUriTemplate도 커스텀 인가 응답 baseUri와 일치해야 한다.

예를 들어 다음과 같다:

 return CommonOAuth2Provider.GOOGLE.getBuilder("google")
     .clientId("google-client-id")
     .clientSecret("google-client-secret")
     .redirectUriTemplate("{baseUrl}/login/oauth2/callback/{registrationId}")
     .build();

UserInfo Endpoint

UserInfo 엔드포인트는 설정할 수 있는 옵션이 많이 있으며, 다음 섹션에서 나눠서 설명한다:

Mapping User Authorities

사용자가 OAuth 2.0 Provider로 인증을 마치고 나면, OAuth2User.getAuthorities() (또는 OidcUser.getAuthorities())는 새 GrantedAuthority 인스턴스 셋으로 매핑되며, 인증을 완료할 때 OAuth2AuthenticationToken에 저장된다.

OAuth2AuthenticationToken.getAuthorities()hasRole('USER')hasRole('ADMIN')같은 인가 요청에 사용한다.

사용자 권한을 매핑할 땐 두 가지 옵션을 선택할 수 있다:

Using a GrantedAuthoritiesMapper

아래 예제처럼 GrantedAuthoritiesMapper 구현체를 만들고 설정하는 방식이다:

Example 89. Granted Authorities Mapper Configuration

java kotlin xml
@EnableWebSecurity
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(userInfo -> userInfo
                    .userAuthoritiesMapper(this.userAuthoritiesMapper())
                    ...
                )
            );
    }

    private GrantedAuthoritiesMapper userAuthoritiesMapper() {
        return (authorities) -> {
            Set<GrantedAuthority> mappedAuthorities = new HashSet<>();

            authorities.forEach(authority -> {
                if (OidcUserAuthority.class.isInstance(authority)) {
                    OidcUserAuthority oidcUserAuthority = (OidcUserAuthority)authority;

                    OidcIdToken idToken = oidcUserAuthority.getIdToken();
                    OidcUserInfo userInfo = oidcUserAuthority.getUserInfo();

                    // Map the claims found in idToken and/or userInfo
                    // to one or more GrantedAuthority's and add it to mappedAuthorities

                } else if (OAuth2UserAuthority.class.isInstance(authority)) {
                    OAuth2UserAuthority oauth2UserAuthority = (OAuth2UserAuthority)authority;

                    Map<String, Object> userAttributes = oauth2UserAuthority.getAttributes();

                    // Map the attributes found in userAttributes
                    // to one or more GrantedAuthority's and add it to mappedAuthorities

                }
            });

            return mappedAuthorities;
        };
    }
}
@EnableWebSecurity
class OAuth2LoginSecurityConfig : WebSecurityConfigurerAdapter() {

    override fun configure(http: HttpSecurity) {
        http {
            oauth2Login {
                userInfoEndpoint {
                    userAuthoritiesMapper = userAuthoritiesMapper()
                }
            }
        }
    }

    private fun userAuthoritiesMapper(): GrantedAuthoritiesMapper = GrantedAuthoritiesMapper { authorities: Collection<GrantedAuthority> ->
        val mappedAuthorities = emptySet<GrantedAuthority>()

        authorities.forEach { authority ->
            if (authority is OidcUserAuthority) {
                val idToken = authority.idToken
                val userInfo = authority.userInfo
                // Map the claims found in idToken and/or userInfo
                // to one or more GrantedAuthority's and add it to mappedAuthorities
            } else if (authority is OAuth2UserAuthority) {
                val userAttributes = authority.attributes
                // Map the attributes found in userAttributes
                // to one or more GrantedAuthority's and add it to mappedAuthorities
            }
        }

        mappedAuthorities
    }
}
<http>
    <oauth2-login user-authorities-mapper-ref="userAuthoritiesMapper"
                  ...
    />
</http>

아니면 아래 예제처럼 GrantedAuthoritiesMapper @Bean을 등록하면 설정에 자동으로 적용된다:

Example 90. Granted Authorities Mapper Bean Configuration

java kotlin
@EnableWebSecurity
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .oauth2Login(withDefaults());
    }

    @Bean
    public GrantedAuthoritiesMapper userAuthoritiesMapper() {
        ...
    }
}
@EnableWebSecurity
class OAuth2LoginSecurityConfig : WebSecurityConfigurerAdapter() {

    override fun configure(http: HttpSecurity) {
        http {
            oauth2Login { }
        }
    }

    @Bean
    fun userAuthoritiesMapper(): GrantedAuthoritiesMapper {
        ...
    }
}

Delegation-based strategy with OAuth2UserService

이 전략은 GrantedAuthoritiesMapper와 비교하면 조금 어렵지만, OAuth2UserRequestOAuth2User나 (OAuth 2.0 UserService를 사용할 때), OidcUserRequestOidcUser에 (OpenID Connect 1.0 UserService를 사용할 때) 접근할 수 있으므로 좀 더 유연한 방식이다.

OAuth2UserRequest는 (OidcUserRequest도 마찬가지) 관련 OAuth2AccessToken에 접근할 수 있으므로, delegator가 사용자의 커스텀 권한을 매핑하기 전에 토큰이 필요한 권한 정보를 가져와야 할 때 매우 유용하다.

다음 예제는 OpenID Connect 1.0 UserService를 사용하는 위임 전략을 설정하는 방법을 보여준다:

Example 91. OAuth2UserService Configuration

java kotlin xml
@EnableWebSecurity
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(userInfo -> userInfo
                    .oidcUserService(this.oidcUserService())
                    ...
                )
            );
    }

    private OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService() {
        final OidcUserService delegate = new OidcUserService();

        return (userRequest) -> {
            // Delegate to the default implementation for loading a user
            OidcUser oidcUser = delegate.loadUser(userRequest);

            OAuth2AccessToken accessToken = userRequest.getAccessToken();
            Set<GrantedAuthority> mappedAuthorities = new HashSet<>();

            // TODO
            // 1) Fetch the authority information from the protected resource using accessToken
            // 2) Map the authority information to one or more GrantedAuthority's and add it to mappedAuthorities

            // 3) Create a copy of oidcUser but use the mappedAuthorities instead
            oidcUser = new DefaultOidcUser(mappedAuthorities, oidcUser.getIdToken(), oidcUser.getUserInfo());

            return oidcUser;
        };
    }
}
@EnableWebSecurity
class OAuth2LoginSecurityConfig : WebSecurityConfigurerAdapter() {

    override fun configure(http: HttpSecurity) {
        http {
            oauth2Login {
                userInfoEndpoint {
                    oidcUserService = oidcUserService()
                }
            }
        }
    }

    @Bean
    fun oidcUserService(): OAuth2UserService<OidcUserRequest, OidcUser> {
        val delegate = OidcUserService()

        return OAuth2UserService { userRequest ->
            // Delegate to the default implementation for loading a user
            var oidcUser = delegate.loadUser(userRequest)

            val accessToken = userRequest.accessToken
            val mappedAuthorities = HashSet<GrantedAuthority>()

            // TODO
            // 1) Fetch the authority information from the protected resource using accessToken
            // 2) Map the authority information to one or more GrantedAuthority's and add it to mappedAuthorities
            // 3) Create a copy of oidcUser but use the mappedAuthorities instead
            oidcUser = DefaultOidcUser(mappedAuthorities, oidcUser.idToken, oidcUser.userInfo)

            oidcUser
        }
    }
}
<http>
    <oauth2-login oidc-user-service-ref="oidcUserService"
                  ...
    />
</http>
Configuring a Custom OAuth2User

CustomUserTypesOAuth2UserService는 커스텀 OAuth2User 타입을 지원하는 OAuth2UserService 구현체다.

디폴트 구현체로 (DefaultOAuth2User) 요구사항을 구현할 수 없는 경우 커스텀 OAuth2User 구현체를 정의할 수 있다.

다음은 깃허브 전용 커스텀 OAuth2User를 등록하는 예시이다:

@EnableWebSecurity
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(userInfo -> userInfo
                    .customUserType(GitHubOAuth2User.class, "github")
                    ...
                )
            );
    }
}

다음 코드는 깃허브 전용 OAuth2User 클래스 예시다:

public class GitHubOAuth2User implements OAuth2User {
    private List<GrantedAuthority> authorities =
        AuthorityUtils.createAuthorityList("ROLE_USER");
    private Map<String, Object> attributes;
    private String id;
    private String name;
    private String login;
    private String email;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.authorities;
    }

    @Override
    public Map<String, Object> getAttributes() {
        if (this.attributes == null) {
            this.attributes = new HashMap<>();
            this.attributes.put("id", this.getId());
            this.attributes.put("name", this.getName());
            this.attributes.put("login", this.getLogin());
            this.attributes.put("email", this.getEmail());
        }
        return attributes;
    }

    public String getId() {
        return this.id;
    }

    public void setId(String id) {
        this.id = id;
    }

    @Override
    public String getName() {
        return this.name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getLogin() {
        return this.login;
    }

    public void setLogin(String login) {
        this.login = login;
    }

    public String getEmail() {
        return this.email;
    }

    public void setEmail(String email) {
        this.email = email;
    }
}

id, name, login, email은 깃허브의 UserInfo 응답에 있는 속성이다. UserInfo 엔드포인트가 리턴하는 정보를 자세히 알고 싶으면 API 문서 “Get the authenticated user”를 참고하라.

OAuth 2.0 UserService

DefaultOAuth2UserService는 표준 OAuth 2.0 Provider를 지원하는 OAuth2UserService 구현체다.

OAuth2UserService는 UserInfo 엔드포인트에서 (인가 플로우에서 클라이언트에 부여한 액세스 토큰으로) 최종 사용자의 (리소스 소유자) 속성을 가져오며, OAuth2User 타입의 AuthenticatedPrincipal을 리턴한다.

DefaultOAuth2UserServiceRestOperations로 UserInfo 엔드포인트에 사용자 속성을 요청한다.

UserInfo 요청 전처리를 커스텀하고 싶다면 DefaultOAuth2UserService.setRequestEntityConverter()에 커스텀 Converter<OAuth2UserRequest, RequestEntity<?>>를 설정하면 된다. 디폴트 구현체 OAuth2UserRequestEntityConverter는 기본적으로 UserInfo 요청을 의미하는 RequestEntity를 빌드할 때 Authorization 헤더에 OAuth2AccessToken을 설정한다.

반대로 이후 UserInfo 응답 처리를 커스텀해야 한다면 DefaultOAuth2UserService.setRestOperations()에 커스텀 RestOperations를 설정해야 한다. 디폴트 RestOperations 설정은 다음과 같다:

RestTemplate restTemplate = new RestTemplate();
restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());

OAuth2ErrorResponseErrorHandler는 OAuth 2.0 에러를 (400 Bad Request) 처리할 수 있는 ResponseErrorHandler다. 이 핸들러는 OAuth2ErrorHttpMessageConverter로 OAuth 2.0 에러 파라미터를 OAuth2Error로 변환한다.

DefaultOAuth2UserService를 커스텀하거나 OAuth2UserService 자체를 직접 구현하고 싶다면 아래 예제처럼 설정해야 한다:

@EnableWebSecurity
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(userInfo -> userInfo
                    .userService(this.oauth2UserService())
                    ...
                )
            );
    }

    private OAuth2UserService<OAuth2UserRequest, OAuth2User> oauth2UserService() {
        ...
    }
}
OpenID Connect 1.0 UserService

OidcUserService는 OpenID Connect 1.0 Provider를 지원하는 OAuth2UserService 구현체다.

OidcUserServiceDefaultOAuth2UserService로 UserInfo 엔드포인트로 사용자 속성을 요청한다.

UserInfo 요청 전처리나 이후 UserInfo 응답 처리를 커스텀하고 싶다면, OidcUserService.setOauth2UserService()에 커스텀 DefaultOAuth2UserService를 설정하면 된다.

OidcUserService를 커스텀하거나 OpenID Connect 1.0 Provider 전용 OAuth2UserService 자체를 직접 구현하고 싶다면 아래 예제처럼 설정해야 한다:

@EnableWebSecurity
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(userInfo -> userInfo
                    .oidcUserService(this.oidcUserService())
                    ...
                )
            );
    }

    private OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService() {
        ...
    }
}

ID Token Signature Verification

OpenID Connect 1.0 인증에선 ID 토큰이란 개념이 나온다. ID 토큰은 최종 사용자 인증에 대한 클레임을 담고 있는 보안 토큰으로, 인가 서버가 클라이언트에게 발급해준다.

ID 토큰은 JSON 웹 토큰(JWT)으로 표현하며 반드시 JSON Web Signature(JWS)로 서명해야 한다.

OidcIdTokenDecoderFactoryOidcIdToken 서명을 검증할 때 사용하는 JwtDecoder를 제공한다. 디폴트 알고리즘은 RS256이지만 등록된 클라이언트에 따라 다를수도 있다. 이런 경우엔 클라이언트마다 원하는 JWS 알고리즘을 리턴하는 리졸버를 설정하면 된다.

JWS 알고리즘 리졸버는 ClientRegistration을 받아 클라이언트별로 원하는 JwsAlgorithm을 (eg. SignatureAlgorithm.RS256, MacAlgorithm.HS256) 리턴하는 Function이다.

다음 코드는 모든 ClientRegistration에 대해 디폴트 MacAlgorithm.HS256을 리턴하는 OidcIdTokenDecoderFactory @Bean을 설정하고 있다:

@Bean
public JwtDecoderFactory<ClientRegistration> idTokenDecoderFactory() {
    OidcIdTokenDecoderFactory idTokenDecoderFactory = new OidcIdTokenDecoderFactory();
    idTokenDecoderFactory.setJwsAlgorithmResolver(clientRegistration -> MacAlgorithm.HS256);
    return idTokenDecoderFactory;
}

HS256, HS384, HS512같은 MAC 기반 알고리즘은 client-id에 상응하는 client-secret을 대칭키로 사용해서 서명을 인증한다.

OpenID Connect 1.0 인증을 사용하는 ClientRegistration을 여러 개 설정했다면, JWS 알고리즘 리졸버에서 건내받은 ClientRegistration을 확인해서 리턴할 알고리즘을 결정하면 된다.

OpenID Connect 1.0 Logout

OpenID Connect Session Management 1.0은 클라이언트로 provider의 최종 사용자를 로그아웃시킬 수 있는 기능이다. 사용할 수 있는 전략 중 하나는 RP-Initiated 로그아웃이다.

OpenID Provider가 Session Management와 Discovery를 모두 지원한다면 클라이언트는 provider의 디스커버리 메타데이터에서 end_session_endpoint URL 정보를 가져올 수 있다. 이땐 다음과 같이 ClientRegistrationissuer-uri를 설정하면 된다:

spring:
  security:
    oauth2:
      client:
        registration:
          okta:
            client-id: okta-client-id
            client-secret: okta-client-secret
            ...
        provider:
          okta:
            issuer-uri: https://dev-1234.oktapreview.com

RP-Initiated 로그아웃을 구현하는 OidcClientInitiatedLogoutSuccessHandler는 다음과 같이 설정할 수 있다:

@EnableWebSecurity
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private ClientRegistrationRepository clientRegistrationRepository;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests(authorize -> authorize
                .anyRequest().authenticated()
            )
            .oauth2Login(withDefaults())
            .logout(logout -> logout
                .logoutSuccessHandler(oidcLogoutSuccessHandler())
            );
    }

    private LogoutSuccessHandler oidcLogoutSuccessHandler() {
        OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler =
                new OidcClientInitiatedLogoutSuccessHandler(this.clientRegistrationRepository);

        // Sets the location that the End-User's User Agent will be redirected to
        // after the logout has been performed at the Provider
        oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}");

        return oidcLogoutSuccessHandler;
    }
}

OidcClientInitiatedLogoutSuccessHandler{baseUrl} 플레이스홀더를 지원한다. 플레이스홀더는 요청 시 https://app.example.org같은 어플리케이션의 베이스 URL로 치환된다.


12.2. OAuth 2.0 Client

OAuth 2.0 클라이언트는 OAuth 2.0 인가 프레임워크에 정의된 클라이언트 역할을 지원한다.

고수준으로 봤을 때 핵심 기능은 다음과 같다:

권한 부여 (Authorization Grant) 지원

HTTP 클라이언트 지원

HttpSecurity.oauth2Client() DSL은 OAuth 2.0 클라이언트에서 사용하는 핵심 컴포넌트를 커스텀할 다양한 설정 옵션을 제공한다. 인가 코드 부여 (Authorization Code grant) 관련 동작은 HttpSecurity.oauth2Client().authorizationCodeGrant()로 커스텀한다.

다음은 HttpSecurity.oauth2Client() DSL로 설정할 수 있는 모든 옵션을 보여주는 예제다:

Example 92. OAuth2 Client Configuration Options

java kotlin
@EnableWebSecurity
public class OAuth2ClientSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .oauth2Client(oauth2 -> oauth2
                .clientRegistrationRepository(this.clientRegistrationRepository())
                .authorizedClientRepository(this.authorizedClientRepository())
                .authorizedClientService(this.authorizedClientService())
                .authorizationCodeGrant(codeGrant -> codeGrant
                    .authorizationRequestRepository(this.authorizationRequestRepository())
                    .authorizationRequestResolver(this.authorizationRequestResolver())
                    .accessTokenResponseClient(this.accessTokenResponseClient())
                )
            );
    }
}
@EnableWebSecurity
class OAuth2ClientSecurityConfig : WebSecurityConfigurerAdapter() {

    override fun configure(http: HttpSecurity) {
        http {
            oauth2Client {
                clientRegistrationRepository = clientRegistrationRepository()
                authorizedClientRepository = authorizedClientRepository()
                authorizedClientService = authorizedClientService()
                authorizationCodeGrant {
                    authorizationRequestRepository = authorizationRequestRepository()
                    authorizationRequestResolver = authorizationRequestResolver()
                    accessTokenResponseClient = accessTokenResponseClient()
                }
            }
        }
    }
}

HttpSecurity.oauth2Client() DSL 외에도 XML 설정을 지원한다.

다음은 시큐리티 네임스페이스로 설정할 수 있는 모든 옵션을 보여주는 예제다:

Example 93. OAuth2 Client XML Configuration Options

<http>
    <oauth2-client client-registration-repository-ref="clientRegistrationRepository"
                   authorized-client-repository-ref="authorizedClientRepository"
                   authorized-client-service-ref="authorizedClientService">
        <authorization-code-grant
                authorization-request-repository-ref="authorizationRequestRepository"
                authorization-request-resolver-ref="authorizationRequestResolver"
                access-token-response-client-ref="accessTokenResponseClient"/>
    </oauth2-client>
</http>

OAuth2AuthorizedClientManager는 OAuth 2.0 클라이언트의 인가(또는 재인가)를 관리하며, 하나 이상의 OAuth2AuthorizedClientProvider와 함께 동작한다.

다음은 OAuth2AuthorizedClientManager @Bean을 등록하고, 권한 부여 (Authorization Grant) 타입 authorization_code, refresh_token, client_credentials, password를 지원하는 OAuth2AuthorizedClientProvider를 설정하는 코드다:

@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
        ClientRegistrationRepository clientRegistrationRepository,
        OAuth2AuthorizedClientRepository authorizedClientRepository) {

    OAuth2AuthorizedClientProvider authorizedClientProvider =
            OAuth2AuthorizedClientProviderBuilder.builder()
                    .authorizationCode()
                    .refreshToken()
                    .clientCredentials()
                    .password()
                    .build();

    DefaultOAuth2AuthorizedClientManager authorizedClientManager =
            new DefaultOAuth2AuthorizedClientManager(
                    clientRegistrationRepository, authorizedClientRepository);
    authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

    return authorizedClientManager;
}

다음 섹션에선 OAuth 2.0 클라이언트가 사용하는 핵심 컴포넌트와 설정 옵션을 좀 더 자세히 다룬다:

12.2.1. Core Interfaces / Classes

ClientRegistration

ClientRegistration은 OAuth 2.0이나 OpenID Connect 1.0 Provider에 등록한 클라이언트를 의미하는 클래스다.

이 클래스는 클라이언트 id, 클라이언트 secret, 권한 부여 (authorization grant) 타입, 리다이렉트 URI, scope(s), 인가 URI, 토큰 URI 등의 상세 정보를 가지고 있다.

ClientRegistration과 해당 프로퍼티는 아래와 같이 정의돼 있다:

public final class ClientRegistration {
    private String registrationId;  // (1)
    private String clientId;    // (2)
    private String clientSecret;    // (3)
    private ClientAuthenticationMethod clientAuthenticationMethod;  // (4)
    private AuthorizationGrantType authorizationGrantType;  // (5)
    private String redirectUriTemplate; // (6)
    private Set<String> scopes; // (7)
    private ProviderDetails providerDetails;  
    private String clientName;  // (8)

    public class ProviderDetails {
        private String authorizationUri;    // (9)
        private String tokenUri;    // (10)
        private UserInfoEndpoint userInfoEndpoint;
        private String jwkSetUri;   // (11)
        private Map<String, Object> configurationMetadata;  // (12)

        public class UserInfoEndpoint {
            private String uri; // (13)
            private AuthenticationMethod authenticationMethod;  // (14)
            private String userNameAttributeName;   // (15)

        }
    }
}

(1) registrationId: ClientRegistration을 식별할 수 있는 유니크한 ID.
(2) clientId: 클라이언트 식별자.
(3) clientSecret: 클라이언트 secret.
(4) clientAuthenticationMethod: provider에서 클라이언트를 인증할 때 사용할 메소드. basic, post, none (public 클라이언트)을 지원한다.
(5) authorizationGrantType: OAuth 2.0 인가 프레임워크는 네 가지 권한 부여 (Authorization Grant) 타입을 정의하고 있다. 지원하는 값은 authorization_code, client_credentials, password다.
(6) redirectUriTemplate: 클라이언트에 등록한 리다이렉트 URL로, 사용자의 인증으로 클라이언트에 접근 권한을 부여하고 나면, 인가 서버가 이 URL로 최종 사용자의 user-agent를 리다이렉트시킨다.
(7) scopes: 인가 요청 플로우에서 클라이언트가 요청한 openid, 이메일, 프로필 등의 scope.
(8) clientName: 클라이언트를 나타내는 이름. 자동 생성되는 로그인 페이지에서 노출하는 등에 사용한다.
(9) authorizationUri: 인가 서버의 인가 엔드포인트 URI.
(10) tokenUri: 인가 서버의 토큰 엔드포인트 URL.
(11) jwkSetUri: 인가 서버에서 JSON 웹 키 (JWK) 셋을 가져올 때 사용할 URI. 이 키 셋엔 ID 토큰의 JSON Web Signature (JWS)를 검증할 때 사용할 암호키가 있으며, UserInfo 응답을 검증할 때도 사용할 수 있다.
(12) configurationMetadata: OpenID Provider 설정 정보. 스프링 부트 2.X 프로퍼티 spring.security.oauth2.client.provider.[providerId].issuerUri를 설정했을 때만 사용할 수 있다.
(13) (userInfoEndpoint)uri: 인증된 최종 사용자의 클레임/속성에 접근할 때 사용하는 UserInfo 엔드포인트 URI.
(14) (userInfoEndpoint)authenticationMethod: UserInfo 엔드포인트로 액세스 토큰을 전송할 때 사용할 인증 메소드. header, form, query를 지원한다.
(15) userNameAttributeName: UserInfo 응답에 있는 속성 이름으로, 최종 사용자의 이름이나 식별자에 접근할 때 사용한다.

ClientRegistration은 OpenID Connect Provider의 설정 엔드포인트나 인가 서버의 메타데이터 엔드포인트를 찾아 초기화할 수 있다.

ClientRegistrations의 메소드를 사용하면 아래 예제처럼 편리하게 ClientRegistration을 설정할 수 있다:

ClientRegistration clientRegistration =
    ClientRegistrations.fromIssuerLocation("https://idp.example.com/issuer").build();

위 코드는 200 응답을 받을 때까지 https://idp.example.com/issuer/.well-known/openid-configuration, https://idp.example.com/.well-known/openid-configuration/issuer, https://idp.example.com/.well-known/oauth-authorization-server/issuer에 차례대로 질의해본다.

아니면 ClientRegistrations.fromOidcIssuerLocation()을 사용해서 OpenID Connect Provider의 설정 엔드포인트에만 질의하는 방법도 있다.

ClientRegistrationRepository

ClientRegistrationRepository는 OAuth 2.0 / OpenID Connect 1.0 ClientRegistration 저장소 역할을 한다.

클라이언트 등록 정보는 궁극적으로 인가 서버가 저장하고 관리한다. 이 레포지토리는 인가 서버에 일차적으로 저장된 클라이언트 등록 정보의 일부를 검색하는 기능을 제공한다.

스프링 부트 2.X 자동 설정은 spring.security.oauth2.client.registration.[registrationId] 하위 프로퍼티를 ClientRegistration 인스턴스에 바인딩하며, 각 ClientRegistration 인스턴스를 ClientRegistrationRepository 안에 구성한다.

ClientRegistrationRepository의 디폴트 구현체는 InMemoryClientRegistrationRepository다.

자동 설정을 사용하면 ClientRegistrationRepositoryApplicationContext@Bean으로 등록하므로 필요하다면 원하는 곳에 의존성을 주입할 수 있다.

다음은 의존성 주입 예시이다:

@Controller
public class OAuth2ClientController {

    @Autowired
    private ClientRegistrationRepository clientRegistrationRepository;

    @GetMapping("/")
    public String index() {
        ClientRegistration oktaRegistration =
            this.clientRegistrationRepository.findByRegistrationId("okta");

        ...

        return "index";
    }
}

OAuth2AuthorizedClient

OAuth2AuthorizedClient는 인가받은 클라이언트를 의미하는 클래스다. 최종 사용자(리소스 소유자)가 클라이언트에게 보호 중인 리소스에 접근할 수 있는 권한을 부여하면, 클라이언트를 인가된 클라이언트로 간주한다.

OAuth2AuthorizedClientOAuth2AccessToken을 (필수는 아니지만 OAuth2RefreshToken도) ClientRegistration(클라이언트)과 리소스 소유자, 즉 권한을 부여한 최종 사용자 Principal과 함께 묶어 준다.

OAuth2AuthorizedClientRepository / OAuth2AuthorizedClientService

OAuth2AuthorizedClientRepository는 다른 웹 요청이 와도 동일한 OAuth2AuthorizedClient를 유지하는 역할을 담당한다. 반면 OAuth2AuthorizedClientService의 일차적인 역할은 어플리케이션 레벨에서 OAuth2AuthorizedClient를 관리하는 일이다.

개발자 관점에서 생각하면, OAuth2AuthorizedClientRepositoryOAuth2AuthorizedClientService는 클라이언트와 관련있는 OAuth2AccessToken을 찾을 수 있는 기능을 제공하므로 보호중인 리소스 요청을 시작할 때 사용할 수 있다.

예를 들어 다음과 같다:

@Controller
public class OAuth2ClientController {

    @Autowired
    private OAuth2AuthorizedClientService authorizedClientService;

    @GetMapping("/")
    public String index(Authentication authentication) {
        OAuth2AuthorizedClient authorizedClient =
            this.authorizedClientService.loadAuthorizedClient("okta", authentication.getName());

        OAuth2AccessToken accessToken = authorizedClient.getAccessToken();

        ...

        return "index";
    }
}

스프링 부트 2.x 자동 설정은 ApplicationContext 안에 OAuth2AuthorizedClientRepository, OAuth2AuthorizedClientService @Bean을 등록한다. 하지만 원한다면 커스텀 OAuth2AuthorizedClientRepositoryOAuth2AuthorizedClientService를 구현해서 @Bean으로 등록할 수 있다.

OAuth2AuthorizedClientService의 디폴트 구현체는 메모리에 OAuth2AuthorizedClient를 저장하는 InMemoryOAuth2AuthorizedClientService다.

OAuth2AuthorizedClient를 데이터베이스에 저장하고 싶다면 JDBC 구현체 JdbcOAuth2AuthorizedClientService를 설정하면 된다.

JdbcOAuth2AuthorizedClientServiceOAuth 2.0 클라이언트 스키마에 정의된 테이블을 사용한다.

OAuth2AuthorizedClientManager / OAuth2AuthorizedClientProvider

OAuth2AuthorizedClientManagerOAuth2AuthorizedClient를 전반적으로 관리하는 인터페이스다.

주로 담당하는 일은 다음과 같다:

OAuth2AuthorizedClientProvider는 OAuth 2.0 클라이언트에 권한을 부여(또는 재부여)하는 전략을 구현한다. 구현체는 보통 authorization_code, client_credentials 등의 특정 권한 부여(authorization grant) 타입 하나를 구현한다.

OAuth2AuthorizedClientManager의 디폴트 구현체 DefaultOAuth2AuthorizedClientManager는 위임 전략을 통해 권한 부여 타입별로 각 OAuth2AuthorizedClientProvider에 위임한다. 위임 전략을 구성하고 빌드할 땐 OAuth2AuthorizedClientProviderBuilder를 사용할 수 있다.

다음 코드는 authorization_code, refresh_token, client_credentials, password 타입을 지원하도록 구성한 OAuth2AuthorizedClientProvider를 빌드한다:

@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
        ClientRegistrationRepository clientRegistrationRepository,
        OAuth2AuthorizedClientRepository authorizedClientRepository) {

    OAuth2AuthorizedClientProvider authorizedClientProvider =
            OAuth2AuthorizedClientProviderBuilder.builder()
                    .authorizationCode()
                    .refreshToken()
                    .clientCredentials()
                    .password()
                    .build();

    DefaultOAuth2AuthorizedClientManager authorizedClientManager =
            new DefaultOAuth2AuthorizedClientManager(
                    clientRegistrationRepository, authorizedClientRepository);
    authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

    return authorizedClientManager;
}

DefaultOAuth2AuthorizedClientManager는 인가에 성공하면 OAuth2AuthorizationSuccessHandler로 위임한다. 이 핸들러는 (디폴트) OAuth2AuthorizedClientRepositoryOAuth2AuthorizedClient를 저장한다. 리프레시 토큰이 유효하지 않는 등의 이유로 재인가에 실패하면, RemoveAuthorizedClientOAuth2AuthorizationFailureHandlerOAuth2AuthorizedClientRepository에 저장된 OAuth2AuthorizedClient를 삭제한다. 이 동작은 setAuthorizationSuccessHandler(OAuth2AuthorizationSuccessHandler)setAuthorizationFailureHandler(OAuth2AuthorizationFailureHandler)로 커스텀할 수 있다.

DefaultOAuth2AuthorizedClientManagerFunction<OAuth2AuthorizeRequest, Map<String, Object>> 타입의 contextAttributesMapper도 사용한다. 이 매퍼는 OAuth2AuthorizeRequest에 있는 속성들을 Map에 매핑한다. 매핑한 값은 OAuth2AuthorizationContext에 담긴다. OAuth2AuthorizedClientProvider에 특정 (지원하는) 속성을 제공해야 할 때 유용하다. 예를 들어 PasswordOAuth2AuthorizedClientProviderOAuth2AuthorizationContext.getAttributes()로 리소스 소유자의 usernamepassword를 가져와야 한다.

다음은 contextAttributesMapper 예시이다:

@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
        ClientRegistrationRepository clientRegistrationRepository,
        OAuth2AuthorizedClientRepository authorizedClientRepository) {

    OAuth2AuthorizedClientProvider authorizedClientProvider =
            OAuth2AuthorizedClientProviderBuilder.builder()
                    .password()
                    .refreshToken()
                    .build();

    DefaultOAuth2AuthorizedClientManager authorizedClientManager =
            new DefaultOAuth2AuthorizedClientManager(
                    clientRegistrationRepository, authorizedClientRepository);
    authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

    // Assuming the `username` and `password` are supplied as `HttpServletRequest` parameters,
    // map the `HttpServletRequest` parameters to `OAuth2AuthorizationContext.getAttributes()`
    authorizedClientManager.setContextAttributesMapper(contextAttributesMapper());

    return authorizedClientManager;
}

private Function<OAuth2AuthorizeRequest, Map<String, Object>> contextAttributesMapper() {
    return authorizeRequest -> {
        Map<String, Object> contextAttributes = Collections.emptyMap();
        HttpServletRequest servletRequest = authorizeRequest.getAttribute(HttpServletRequest.class.getName());
        String username = servletRequest.getParameter(OAuth2ParameterNames.USERNAME);
        String password = servletRequest.getParameter(OAuth2ParameterNames.PASSWORD);
        if (StringUtils.hasText(username) && StringUtils.hasText(password)) {
            contextAttributes = new HashMap<>();

            // `PasswordOAuth2AuthorizedClientProvider` requires both attributes
            contextAttributes.put(OAuth2AuthorizationContext.USERNAME_ATTRIBUTE_NAME, username);
            contextAttributes.put(OAuth2AuthorizationContext.PASSWORD_ATTRIBUTE_NAME, password);
        }
        return contextAttributes;
    };
}

DefaultOAuth2AuthorizedClientManagerHttpServletRequest 컨텍스트 범위 안에서 사용하도록 설계했다. HttpServletRequest 컨텍스트를 벗어난다면 AuthorizedClientServiceOAuth2AuthorizedClientManager를 사용해라.

AuthorizedClientServiceOAuth2AuthorizedClientManager의 일반적인 사용 사례로는 서비스 어플리케이션이 있다. 서비스 어플리케이션은 보통 사용자와의 상호작용 없이 백그라운드에서 실행하며, 일반적으로 사용자 계정이 아닌 시스템 레벨 계정으로 실행한다. 권한 부여 타입을 client_credentials로 설정한 OAuth 2.0 클라이언트를 서비스 어플리케이션으로 생각할 수 있다.

다음은 client_credentials 타입을 지원하는 AuthorizedClientServiceOAuth2AuthorizedClientManager를 설정하는 코드다:

@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
        ClientRegistrationRepository clientRegistrationRepository,
        OAuth2AuthorizedClientService authorizedClientService) {

    OAuth2AuthorizedClientProvider authorizedClientProvider =
            OAuth2AuthorizedClientProviderBuilder.builder()
                    .clientCredentials()
                    .build();

    AuthorizedClientServiceOAuth2AuthorizedClientManager authorizedClientManager =
            new AuthorizedClientServiceOAuth2AuthorizedClientManager(
                    clientRegistrationRepository, authorizedClientService);
    authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

    return authorizedClientManager;
}

12.2.2. Authorization Grant Support

Authorization Code

인가 코드 부여에 대한 자세한 설명은 OAuth 2.0 인가 프레임워크를 참고하라.

Obtaining Authorization

인가 코드를 부여할 때 사용하는 인가 요청/응답 프로토콜을 참고하라.

Initiating the Authorization Request

OAuth2AuthorizationRequestRedirectFilterOAuth2AuthorizationRequestResolverOAuth2AuthorizationRequest를 리졸브하며, 인가 서버의 인가 엔드포인트로 최종 사용자의 user-agent를 리다이렉트해서 인가 코드 부여 플로우를 시작한다.

OAuth2AuthorizationRequestResolver의 주요 역할은 웹 요청으로 OAuth2AuthorizationRequest를 리졸브하는 일이다. 디폴트 구현체 DefaultOAuth2AuthorizationRequestResolver는 (디폴트) path가 /oauth2/authorization/{registrationId}와 일치하는지 확인해서, 일치하면 registrationId를 추출하고, 이를 사용해서 ClientRegistration을 가져와 OAuth2AuthorizationRequest를 빌드한다.

OAuth 2.0 클라이언트 등록과 관련해서는 스프링 부트 2.x엔 다음과 같은 프로퍼티가 있다:

spring:
  security:
    oauth2:
      client:
        registration:
          okta:
            client-id: okta-client-id
            client-secret: okta-client-secret
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/authorized/okta"
            scope: read, write
        provider:
          okta:
            authorization-uri: https://dev-1234.oktapreview.com/oauth2/v1/authorize
            token-uri: https://dev-1234.oktapreview.com/oauth2/v1/token

기본 경로가 /oauth2/authorization/okta인 요청은 OAuth2AuthorizationRequestRedirectFilter의 리다이렉트로 인가 요청을 개시하며, 이때부터 인가 코드 부여 플로우를 시작한다.

AuthorizationCodeOAuth2AuthorizedClientProvider는 인가 코드 부여를 위한 구현체로 OAuth2AuthorizationRequestRedirectFilter의 인가 요청 리다이렉트를 시작한다.

OAuth 2.0 클라이언트가 Public 클라이언트라면 클라이언트 등록 정보를 다음과 같이 설정해라:

spring:
  security:
    oauth2:
      client:
        registration:
          okta:
            client-id: okta-client-id
            client-authentication-method: none
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/authorized/okta"
            ...

Public 클라이언트는 Proof Key for Code Exchange (PKCE)로 지원한다. 클라이언트를 신뢰할 수 없는 환경에서 실행해서 (eg. 네이티브 어플리케이션 또는 웹 브라우저 기반 어플리케이션) credential의 기밀을 유지할 수 없는 상황이라면, 다음 조건을 모두 만족하면 자동으로 PKCE를 사용한다:

  1. client-secret을 생략한 경우 (비어있는 경우도 포함)
  2. client-authentication-method를 “none”으로 설정한 경우 (ClientAuthenticationMethod.NONE)

DefaultOAuth2AuthorizationRequestResolverUriComponentsBuilderredirect-uri를 만들기 때문에 URI 템플릿 변수를 사용할 수 있다.

아래 설정은 지원하는 모든 URI 템플릿 변수를 사용한다:

spring:
  security:
    oauth2:
      client:
        registration:
          okta:
            ...
            redirect-uri: "{baseScheme}://{baseHost}{basePort}{basePath}/authorized/{registrationId}"

redirect-uri의 URI 템플릿 변수는 특히 OAuth 2.0 클라이언트 앞단에 프록시 서버가 있을 때 유용하다. redirect-uri를 재구성하면 X-Forwarded-* 헤더를 사용할 수 있다.

Customizing the Authorization Request

OAuth2AuthorizationRequestResolver는 OAuth 2.0 인가 프레임워크에 정의된 표준 파라미터 외에 다른 파라미터를 추가하는 식으로 인가 요청을 커스텀할 때 주로 사용한다.

예를 들어 OpenID Connect의 인가 코드 플로우에선 OAuth 2.0 인가 프레임워크에 정의된 표준 파라미터를 확장한 다른 요청 파라미터를 사용한다. prompt도 확장 파라미터 중 하나다.

참고 사항. prompt는 인가 서버가 최종 사용자에게 재인증과 동의를 요청할지 여부를 지정하는 값으로, 대소문자를 구분하는 ASCII 문자열 리스트다. 공백으로 구분하며, 사용할 수 있는 값은 none, login, consent, select_account다.

다음 예제는 oauth2Login() 메소드와 DefaultOAuth2AuthorizationRequestResolver를 사용해서 요청 파라미터 prompt=consent를 추가하는 커스텀 Consumer<OAuth2AuthorizationRequest.Builder>를 설정하고 있다.

@EnableWebSecurity
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private ClientRegistrationRepository clientRegistrationRepository;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests(authorize -> authorize
                .anyRequest().authenticated()
            )
            .oauth2Login(oauth2 -> oauth2
                .authorizationEndpoint(authorization -> authorization
                    .authorizationRequestResolver(
                        authorizationRequestResolver(this.clientRegistrationRepository)
                    )
                )
            );
    }

    private OAuth2AuthorizationRequestResolver authorizationRequestResolver(
            ClientRegistrationRepository clientRegistrationRepository) {

        DefaultOAuth2AuthorizationRequestResolver authorizationRequestResolver =
                new DefaultOAuth2AuthorizationRequestResolver(
                        clientRegistrationRepository, "/oauth2/authorization");
        authorizationRequestResolver.setAuthorizationRequestCustomizer(
                authorizationRequestCustomizer());

        return  authorizationRequestResolver;
    }

    private Consumer<OAuth2AuthorizationRequest.Builder> authorizationRequestCustomizer() {
        return customizer -> customizer
                    .additionalParameters(params -> params.put("prompt", "consent"));
    }
}

이 샘플처럼 특정 provider에선 항상 같은 요청 파라미터를 추가한다면 authorization-uri 프로퍼티에 직접 추가해도 된다.

예를 들어 okta provider에선 항상 prompt 파라미터 값이 consent라면 간단히 다음과 같이 설정할 수 있다:

spring:
  security:
    oauth2:
      client:
        provider:
          okta:
            authorization-uri: https://dev-1234.oktapreview.com/oauth2/v1/authorize?prompt=consent

위 예제는 표준 파라미터에 커스텀 파라미터를 추가하는 일반적인 사례를 보여준다. 이와 달리 좀 더 복잡한 요구사항이 있을 때는 OAuth2AuthorizationRequest.authorizationRequestUri 프로퍼티를 재정의해서 인가 요청 URI를 빌드 작업을 직접 컨트롤할 수 있다.

OAuth2AuthorizationRequest.Builder.build()application/x-www-form-urlencoded 포맷을 사용하는 모든 쿼리 파라미터를 포함하는 인가 요청 URI OAuth2AuthorizationRequest.authorizationRequestUri를 만든다.

다음 예제는 위 코드에 있는 authorizationRequestCustomizer()를 조금 바꿔서, OAuth2AuthorizationRequest.authorizationRequestUri 프로퍼티를 재정의하는 코드다.

private Consumer<OAuth2AuthorizationRequest.Builder> authorizationRequestCustomizer() {
    return customizer -> customizer
                .authorizationRequestUri(uriBuilder -> uriBuilder
                    .queryParam("prompt", "consent").build());
}

Storing the Authorization Request

AuthorizationRequestRepository는 인가 요청을 시작한 시점부터 인가 요청을 받는 시점까지 (콜백) OAuth2AuthorizationRequest를 유지해준다.

OAuth2AuthorizationRequest는 인가 응답을 연계하고 검증할 때 사용한다.

AuthorizationRequestRepository의 디폴트 구현체는 HttpSessionOAuth2AuthorizationRequest를 저장하는 HttpSessionOAuth2AuthorizationRequestRepository다.

직접 구현한 AuthorizationRequestRepository는 다음 예제처럼 설정할 수 있다:

Example 94. AuthorizationRequestRepository Configuration

java kotlin xml
@EnableWebSecurity
public class OAuth2ClientSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .oauth2Client(oauth2 -> oauth2
                .authorizationCodeGrant(codeGrant -> codeGrant
                    .authorizationRequestRepository(this.authorizationRequestRepository())
                    ...
                )
            );
    }
}
@EnableWebSecurity
class OAuth2ClientSecurityConfig : WebSecurityConfigurerAdapter() {

    override fun configure(http: HttpSecurity) {
        http {
            oauth2Client {
                authorizationCodeGrant {
                    authorizationRequestRepository = authorizationRequestRepository()
                }
            }
        }
    }
}
<http>
    <oauth2-client>
        <authorization-code-grant authorization-request-repository-ref="authorizationRequestRepository"/>
    </oauth2-client>
</http>

Requesting an Access Token

인가 코드를 부여할 때 사용하는 액세스 토큰 요청/응답 프로토콜을 참고하라.

인가 코드를 부여할 때 사용하는 OAuth2AccessTokenResponseClient의 디폴트 구현체는 DefaultAuthorizationCodeTokenResponseClient다. 이 구현체는 RestOperations로 인가 서버의 토큰 엔드포인트에 요청을 보내 인가 코드를 액세스 토큰으로 교환한다.

DefaultAuthorizationCodeTokenResponseClient는 토큰 요청 전처리나 토큰 응답 후처리를 커스텀할 수 있으므로 꽤 유연한 편이다.

Customizing the Access Token Request

토큰 요청 전처리를 커스텀하고 싶다면, DefaultAuthorizationCodeTokenResponseClient.setRequestEntityConverter()에 커스텀 Converter<OAuth2AuthorizationCodeGrantRequest, RequestEntity<?>>를 설정하면 된다. 디폴트 구현체 OAuth2AuthorizationCodeGrantRequestEntityConverterRequestEntity로 표준 OAuth 2.0 액세스 토큰 요청을 만든다. 하지만 커스텀 Converter를 사용하면 표준 토큰 요청을 확장하고 커스텀 파라미터를 추가할 수 있다.

커스텀 Converter를 만든다면 반드시 의도한 OAuth 2.0 Provider가 이해할 수 있는 유효한 OAuth 2.0 액세스 토큰 요청을 RequestEntity로 만들어 리턴해야 한다.

Customizing the Access Token Response

반대로 이후 토큰 응답 후처리를 커스텀해야 한다면 DefaultAuthorizationCodeTokenResponseClient.setRestOperations()에 커스텀 RestOperations를 설정해야 한다. 디폴트 RestOperations 설정은 다음과 같다:

RestTemplate restTemplate = new RestTemplate(Arrays.asList(
        new FormHttpMessageConverter(),
        new OAuth2AccessTokenResponseHttpMessageConverter()));

restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());

스프링 MVC FormHttpMessageConverter는 OAuth 2.0 액세스 토큰 요청을 전송할 때 필요하다.

OAuth2AccessTokenResponseHttpMessageConverter는 OAuth 2.0 액세스 토큰 응답을 처리하는 HttpMessageConverter다. OAuth 2.0 액세스 토큰 응답 파라미터를 OAuth2AccessTokenResponse로 변환할 때 사용할 커스텀 Converter<Map<String, String>, OAuth2AccessTokenResponse>OAuth2AccessTokenResponseHttpMessageConverter.setTokenResponseConverter()로 설정한다.

OAuth2ErrorResponseErrorHandler는 400 Bad Request 등의 OAuth 2.0 에러를 처리할 수 있는 ResponseErrorHandler다. 이 핸들러는 OAuth2ErrorHttpMessageConverter로 OAuth 2.0 에러 파라미터를 OAuth2Error로 변환한다.

DefaultAuthorizationCodeTokenResponseClient를 커스텀하거나 OAuth2AccessTokenResponseClient 자체를 직접 구현하고 싶다면 아래 예제처럼 설정해야 한다:

Example 95. Access Token Response Configuration

java kotlin xml
@EnableWebSecurity
public class OAuth2ClientSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .oauth2Client(oauth2 -> oauth2
                .authorizationCodeGrant(codeGrant -> codeGrant
                    .accessTokenResponseClient(this.accessTokenResponseClient())
                    ...
                )
            );
    }
}
@EnableWebSecurity
class OAuth2ClientSecurityConfig : WebSecurityConfigurerAdapter() {

    override fun configure(http: HttpSecurity) {
        http {
            oauth2Client {
                authorizationCodeGrant {
                    accessTokenResponseClient = accessTokenResponseClient()
                }
            }
        }
    }
}
<http>
    <oauth2-client>
        <authorization-code-grant access-token-response-client-ref="accessTokenResponseClient"/>
    </oauth2-client>
</http>

Refresh Token

리프레시 토큰에 대한 자세한 설명은 OAuth 2.0 인가 프레임워크를 참고하라.

Refreshing an Access Token

리프레시 토큰을 부여할 때 사용하는 액세스 토큰 요청/응답 프로토콜을 참고하라.

리프레시 토큰을 부여할 때 사용하는 OAuth2AccessTokenResponseClient의 디폴트 구현체는 DefaultRefreshTokenTokenResponseClient다. 이 구현체는 RestOperations로 인가 서버의 토큰 엔드포인트에 요청을 보내 액세스 토큰을 갱신한다.

DefaultRefreshTokenTokenResponseClient는 토큰 요청 전처리나 토큰 응답 후처리를 커스텀할 수 있으므로 꽤 유연한 편이다.

Customizing the Access Token Request

토큰 요청 전처리를 커스텀하고 싶다면, DefaultRefreshTokenTokenResponseClient.setRequestEntityConverter()에 커스텀 Converter<OAuth2RefreshTokenGrantRequest, RequestEntity<?>>를 설정하면 된다. 디폴트 구현체 OAuth2RefreshTokenGrantRequestEntityConverterRequestEntity로 표준 OAuth 2.0 액세스 토큰 요청을 만든다. 하지만 커스텀 Converter를 사용하면 표준 토큰 요청을 확장하고 커스텀 파라미터를 추가할 수 있다.

커스텀 Converter를 만든다면 반드시 의도한 OAuth 2.0 Provider가 이해할 수 있는 유효한 OAuth 2.0 액세스 토큰 요청을 RequestEntity로 만들어 리턴해야 한다.

Customizing the Access Token Response

반대로 이후 토큰 응답 후처리를 커스텀해야 한다면 DefaultRefreshTokenTokenResponseClient.setRestOperations()에 커스텀 RestOperations를 설정해야 한다. 디폴트 RestOperations 설정은 다음과 같다:

RestTemplate restTemplate = new RestTemplate(Arrays.asList(
        new FormHttpMessageConverter(),
        new OAuth2AccessTokenResponseHttpMessageConverter()));

restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());

스프링 MVC FormHttpMessageConverter는 OAuth 2.0 액세스 토큰 요청을 전송할 때 필요하다.

OAuth2AccessTokenResponseHttpMessageConverter는 OAuth 2.0 액세스 토큰 응답을 처리하는 HttpMessageConverter다. OAuth 2.0 액세스 토큰 응답 파라미터를 OAuth2AccessTokenResponse로 변환할 때 사용할 커스텀 Converter<Map<String, String>, OAuth2AccessTokenResponse>OAuth2AccessTokenResponseHttpMessageConverter.setTokenResponseConverter()로 설정한다.

OAuth2ErrorResponseErrorHandler는 400 Bad Request 등의 OAuth 2.0 에러를 처리할 수 있는 ResponseErrorHandler다. 이 핸들러는 OAuth2ErrorHttpMessageConverter로 OAuth 2.0 에러 파라미터를 OAuth2Error로 변환한다.

DefaultRefreshTokenTokenResponseClient를 커스텀하거나 OAuth2AccessTokenResponseClient 자체를 직접 구현하고 싶다면 아래 예제처럼 설정해야 한다:

// Customize
OAuth2AccessTokenResponseClient<OAuth2RefreshTokenGrantRequest> refreshTokenTokenResponseClient = ...

OAuth2AuthorizedClientProvider authorizedClientProvider =
        OAuth2AuthorizedClientProviderBuilder.builder()
                .authorizationCode()
                .refreshToken(configurer -> configurer.accessTokenResponseClient(refreshTokenTokenResponseClient))
                .build();

...

authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

OAuth2AuthorizedClientProviderBuilder.builder().refreshToken()은 리프레시 토큰 부여에 사용할 OAuth2AuthorizedClientProvider의 구현체 RefreshTokenOAuth2AuthorizedClientProvider를 설정한다.

권한 부여 타입이 authorization_code, password일 때만 액세스 토큰 응답에 OAuth2RefreshToken이 들어있다. OAuth2AuthorizedClient.getRefreshToken()을 사용할 수 있고 OAuth2AuthorizedClient.getAccessToken()이 만료됐다면 RefreshTokenOAuth2AuthorizedClientProvider가 토큰을 자동으로 갱신한다.

Client Credentials

클라이언트 Credentials 부여에 대한 자세한 설명은 OAuth 2.0 인가 프레임워크를 참고하라.

Requesting an Access Token

클라이언트 Credential을 부여할 때 사용하는 액세스 토큰 요청/응답 프로토콜을 참고하라.

클라이언트 credential을 부여할 때 사용하는 OAuth2AccessTokenResponseClient의 디폴트 구현체는 DefaultClientCredentialsTokenResponseClient다. 이 구현체는 RestOperations로 인가 서버의 토큰 엔드포인트에 액세스 토큰을 요청한다.

DefaultClientCredentialsTokenResponseClient는 토큰 요청 전처리나 토큰 응답 후처리를 커스텀할 수 있으므로 꽤 유연한 편이다.

Customizing the Access Token Request

토큰 요청 전처리를 커스텀하고 싶다면, DefaultClientCredentialsTokenResponseClient.setRequestEntityConverter()에 커스텀 Converter<OAuth2ClientCredentialsGrantRequest, RequestEntity<?>>를 설정하면 된다. 디폴트 구현체 OAuth2ClientCredentialsGrantRequestEntityConverterRequestEntity로 표준 OAuth 2.0 액세스 토큰 요청을 만든다. 하지만 커스텀 Converter를 사용하면 표준 토큰 요청을 확장하고 커스텀 파라미터를 추가할 수 있다.

커스텀 Converter를 만든다면 반드시 의도한 OAuth 2.0 Provider가 이해할 수 있는 유효한 OAuth 2.0 액세스 토큰 요청을 RequestEntity로 만들어 리턴해야 한다.

Customizing the Access Token Response

반대로 이후 토큰 응답 후처리를 커스텀해야 한다면 DefaultClientCredentialsTokenResponseClient.setRestOperations()에 커스텀 RestOperations를 설정해야 한다. 디폴트 RestOperations 설정은 다음과 같다:

RestTemplate restTemplate = new RestTemplate(Arrays.asList(
        new FormHttpMessageConverter(),
        new OAuth2AccessTokenResponseHttpMessageConverter()));

restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());

스프링 MVC FormHttpMessageConverter는 OAuth 2.0 액세스 토큰 요청을 전송할 때 필요하다.

OAuth2AccessTokenResponseHttpMessageConverter는 OAuth 2.0 액세스 토큰 응답을 처리하는 HttpMessageConverter다. OAuth 2.0 액세스 토큰 응답 파라미터를 OAuth2AccessTokenResponse로 변환할 때 사용할 커스텀 Converter<Map<String, String>, OAuth2AccessTokenResponse>OAuth2AccessTokenResponseHttpMessageConverter.setTokenResponseConverter()로 설정한다.

OAuth2ErrorResponseErrorHandler는 400 Bad Request 등의 OAuth 2.0 에러를 처리할 수 있는 ResponseErrorHandler다. 이 핸들러는 OAuth2ErrorHttpMessageConverter로 OAuth 2.0 에러 파라미터를 OAuth2Error로 변환한다.

DefaultClientCredentialsTokenResponseClient를 커스텀하거나 OAuth2AccessTokenResponseClient 자체를 직접 구현하고 싶다면 아래 예제처럼 설정해야 한다:

// Customize
OAuth2AccessTokenResponseClient<OAuth2ClientCredentialsGrantRequest> clientCredentialsTokenResponseClient = ...

OAuth2AuthorizedClientProvider authorizedClientProvider =
        OAuth2AuthorizedClientProviderBuilder.builder()
                .clientCredentials(configurer -> configurer.accessTokenResponseClient(clientCredentialsTokenResponseClient))
                .build();

...

authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

OAuth2AuthorizedClientProviderBuilder.builder().clientCredentials()는 클라이언트 credential 부여에 사용할 OAuth2AuthorizedClientProvider의 구현체 ClientCredentialsOAuth2AuthorizedClientProvider를 설정한다.

Using the Access Token

OAuth 2.0 클라이언트 등록과 관련해서는 스프링 부트 2.x엔 다음과 같은 프로퍼티가 있다:

spring:
  security:
    oauth2:
      client:
        registration:
          okta:
            client-id: okta-client-id
            client-secret: okta-client-secret
            authorization-grant-type: client_credentials
            scope: read, write
        provider:
          okta:
            token-uri: https://dev-1234.oktapreview.com/oauth2/v1/token

OAuth2AuthorizedClientManager @Bean은 다음과 같다:

@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
        ClientRegistrationRepository clientRegistrationRepository,
        OAuth2AuthorizedClientRepository authorizedClientRepository) {

    OAuth2AuthorizedClientProvider authorizedClientProvider =
            OAuth2AuthorizedClientProviderBuilder.builder()
                    .clientCredentials()
                    .build();

    DefaultOAuth2AuthorizedClientManager authorizedClientManager =
            new DefaultOAuth2AuthorizedClientManager(
                    clientRegistrationRepository, authorizedClientRepository);
    authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

    return authorizedClientManager;
}

OAuth2AccessToken은 다음과 같이 가져올 수 있다:

@Controller
public class OAuth2ClientController {

    @Autowired
    private OAuth2AuthorizedClientManager authorizedClientManager;

    @GetMapping("/")
    public String index(Authentication authentication,
                        HttpServletRequest servletRequest,
                        HttpServletResponse servletResponse) {

        OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest.withClientRegistrationId("okta")
                .principal(authentication)
                .attributes(attrs -> {
                    attrs.put(HttpServletRequest.class.getName(), servletRequest);
                    attrs.put(HttpServletResponse.class.getName(), servletResponse);
                })
                .build();
        OAuth2AuthorizedClient authorizedClient = this.authorizedClientManager.authorize(authorizeRequest);

        OAuth2AccessToken accessToken = authorizedClient.getAccessToken();

        ...

        return "index";
    }
}

HttpServletRequestHttpServletResponse 속성은 모두 선택사항이다. 지정하지 않으면 디폴트로 RequestContextHolder.getRequestAttributes()ServletRequestAttributes를 사용한다.

Resource Owner Password Credentials

리소스 소유자 비밀번호 Password Credentials 부여에 대한 자세한 설명은 OAuth 2.0 인가 프레임워크를 참고하라.

Requesting an Access Token

리소스 소유자 비밀번호 credential을 부여할때 사용하는 액세스 토큰 요청/응답프로토콜을 참고하라.

리소스 소유자 비밀번호 credential 부여할 때 사용하는 OAuth2AccessTokenResponseClient의 디폴트 구현체는 DefaultPasswordTokenResponseClient다. 이 구현체는 RestOperations로 인가 서버의 토큰 엔드포인트에 액세스 토큰을 요청한다.

DefaultPasswordTokenResponseClient는 토큰 요청 전처리나 토큰 응답 후처리를 커스텀할 수 있으므로 꽤 유연한 편이다.

Customizing the Access Token Request

토큰 요청 전처리를 커스텀하고 싶다면, DefaultPasswordTokenResponseClient.setRequestEntityConverter()에 커스텀 Converter<OAuth2PasswordGrantRequest, RequestEntity<?>>를 설정하면 된다. 디폴트 구현체 OAuth2PasswordGrantRequestEntityConverterRequestEntity로 표준 OAuth 2.0 액세스 토큰 요청을 만든다. 하지만 커스텀 Converter를 사용하면 표준 토큰 요청을 확장하고 커스텀 파라미터를 추가할 수 있다.

커스텀 Converter를 만든다면 반드시 의도한 OAuth 2.0 Provider가 이해할 수 있는 유효한 OAuth 2.0 액세스 토큰 요청을 RequestEntity로 만들어 리턴해야 한다.

Customizing the Access Token Response

반대로 이후 토큰 응답 후처리를 커스텀해야 한다면 DefaultPasswordTokenResponseClient.setRestOperations()에 커스텀 RestOperations를 설정해야 한다. 디폴트 RestOperations 설정은 다음과 같다:

RestTemplate restTemplate = new RestTemplate(Arrays.asList(
        new FormHttpMessageConverter(),
        new OAuth2AccessTokenResponseHttpMessageConverter()));

restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());

스프링 MVC FormHttpMessageConverter는 OAuth 2.0 액세스 토큰 요청을 전송할 때 필요하다.

OAuth2AccessTokenResponseHttpMessageConverter는 OAuth 2.0 액세스 토큰 응답을 처리하는 HttpMessageConverter다. OAuth 2.0 액세스 토큰 응답 파라미터를 OAuth2AccessTokenResponse로 변환할 때 사용할 커스텀 Converter<Map<String, String>, OAuth2AccessTokenResponse>OAuth2AccessTokenResponseHttpMessageConverter.setTokenResponseConverter()로 설정한다.

OAuth2ErrorResponseErrorHandler는 400 Bad Request 등의 OAuth 2.0 에러를 처리할 수 있는 ResponseErrorHandler다. 이 핸들러는 OAuth2ErrorHttpMessageConverter로 OAuth 2.0 에러 파라미터를 OAuth2Error로 변환한다.

DefaultPasswordTokenResponseClient를 커스텀하거나 OAuth2AccessTokenResponseClient 자체를 직접 구현하고 싶다면 아래 예제처럼 설정해야 한다:

// Customize
OAuth2AccessTokenResponseClient<OAuth2PasswordGrantRequest> passwordTokenResponseClient = ...

OAuth2AuthorizedClientProvider authorizedClientProvider =
        OAuth2AuthorizedClientProviderBuilder.builder()
                .password(configurer -> configurer.accessTokenResponseClient(passwordTokenResponseClient))
                .refreshToken()
                .build();

...

authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

OAuth2AuthorizedClientProviderBuilder.builder().password()는 리소스 소유자 비밀번호 credential 부여에 사용할 OAuth2AuthorizedClientProvider의 구현체 PasswordOAuth2AuthorizedClientProvider를 설정한다.

Using the Access Token

OAuth 2.0 클라이언트 등록과 관련해서는 스프링 부트 2.x엔 다음과 같은 프로퍼티가 있다:

spring:
  security:
    oauth2:
      client:
        registration:
          okta:
            client-id: okta-client-id
            client-secret: okta-client-secret
            authorization-grant-type: password
            scope: read, write
        provider:
          okta:
            token-uri: https://dev-1234.oktapreview.com/oauth2/v1/token

OAuth2AuthorizedClientManager @Bean은 다음과 같다:

@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
        ClientRegistrationRepository clientRegistrationRepository,
        OAuth2AuthorizedClientRepository authorizedClientRepository) {

    OAuth2AuthorizedClientProvider authorizedClientProvider =
            OAuth2AuthorizedClientProviderBuilder.builder()
                    .password()
                    .refreshToken()
                    .build();

    DefaultOAuth2AuthorizedClientManager authorizedClientManager =
            new DefaultOAuth2AuthorizedClientManager(
                    clientRegistrationRepository, authorizedClientRepository);
    authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

    // Assuming the `username` and `password` are supplied as `HttpServletRequest` parameters,
    // map the `HttpServletRequest` parameters to `OAuth2AuthorizationContext.getAttributes()`
    authorizedClientManager.setContextAttributesMapper(contextAttributesMapper());

    return authorizedClientManager;
}

private Function<OAuth2AuthorizeRequest, Map<String, Object>> contextAttributesMapper() {
    return authorizeRequest -> {
        Map<String, Object> contextAttributes = Collections.emptyMap();
        HttpServletRequest servletRequest = authorizeRequest.getAttribute(HttpServletRequest.class.getName());
        String username = servletRequest.getParameter(OAuth2ParameterNames.USERNAME);
        String password = servletRequest.getParameter(OAuth2ParameterNames.PASSWORD);
        if (StringUtils.hasText(username) && StringUtils.hasText(password)) {
            contextAttributes = new HashMap<>();

            // `PasswordOAuth2AuthorizedClientProvider` requires both attributes
            contextAttributes.put(OAuth2AuthorizationContext.USERNAME_ATTRIBUTE_NAME, username);
            contextAttributes.put(OAuth2AuthorizationContext.PASSWORD_ATTRIBUTE_NAME, password);
        }
        return contextAttributes;
    };
}

OAuth2AccessToken은 다음과 같이 가져올 수 있다:

@Controller
public class OAuth2ClientController {

    @Autowired
    private OAuth2AuthorizedClientManager authorizedClientManager;

    @GetMapping("/")
    public String index(Authentication authentication,
                        HttpServletRequest servletRequest,
                        HttpServletResponse servletResponse) {

        OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest.withClientRegistrationId("okta")
                .principal(authentication)
                .attributes(attrs -> {
                    attrs.put(HttpServletRequest.class.getName(), servletRequest);
                    attrs.put(HttpServletResponse.class.getName(), servletResponse);
                })
                .build();
        OAuth2AuthorizedClient authorizedClient = this.authorizedClientManager.authorize(authorizeRequest);

        OAuth2AccessToken accessToken = authorizedClient.getAccessToken();

        ...

        return "index";
    }
}

HttpServletRequestHttpServletResponse 속성은 모두 선택사항이다. 지정하지 않으면 디폴트로 RequestContextHolder.getRequestAttributes()ServletRequestAttributes를 사용한다.

12.2.3. Additional Features

Resolving an Authorized Client

@RegisteredOAuth2AuthorizedClient 어노테이션은 파라미터를 OAuth2AuthorizedClient 타입 인자로 리졸브해준다. 이 방법은 OAuth2AuthorizedClientManagerOAuth2AuthorizedClientServiceOAuth2AuthorizedClient에 접근하는 것보다 편리하다.

@Controller
public class OAuth2ClientController {

    @GetMapping("/")
    public String index(@RegisteredOAuth2AuthorizedClient("okta") OAuth2AuthorizedClient authorizedClient) {
        OAuth2AccessToken accessToken = authorizedClient.getAccessToken();

        ...

        return "index";
    }
}

@RegisteredOAuth2AuthorizedClientOAuth2AuthorizedClientArgumentResolver가 처리한다. 이 리졸버는 OAuth2AuthorizedClientManager를 직접 사용하기 때문에 매니저의 모든 기능을 사용할 수 있다.

12.2.4. WebClient integration for Servlet Environments

OAuth 2.0 클라이언트 기능과 WebClientExchangeFilterFunction으로 통합한다.

ServletOAuth2AuthorizedClientExchangeFilterFunctionOAuth2AuthorizedClient를 사용해서 보호 중인 리소스를 요청하는 간단한 메커니즘을 제공하며, OAuth2AccessToken을 Bearer 토큰으로 요청에 추가해 준다. 이 펑션은 OAuth2AuthorizedClientManager를 직접 사용하기 때문에 매니저의 모든 기능을 사용할 수 있다.

다음은 WebClient에 OAuth 2.0 클라이언트 기능을 함께 설정하는 예시다:

@Bean
WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
    ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
            new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
    return WebClient.builder()
            .apply(oauth2Client.oauth2Configuration())
            .build();
}

Providing the Authorized Client

ServletOAuth2AuthorizedClientExchangeFilterFunctionClientRequest.attributes()(요청 속성)을 가지고 OAuth2AuthorizedClient를 리졸브해서 요청에 사용할 클라이언트를 결정한다.

다음은 OAuth2AuthorizedClient를 요청 속성으로 설정하는 방법을 보여주는 코드다:

@GetMapping("/")
public String index(@RegisteredOAuth2AuthorizedClient("okta") OAuth2AuthorizedClient authorizedClient) {
    String resourceUri = ...

    String body = webClient
            .get()
            .uri(resourceUri)
            .attributes(oauth2AuthorizedClient(authorizedClient))   // (1)
            .retrieve()
            .bodyToMono(String.class)
            .block();

    ...

    return "index";
}

(1) oauth2AuthorizedClient()ServletOAuth2AuthorizedClientExchangeFilterFunction에 있는 static 메소드다.

다음은 요청 속성에 ClientRegistration.getRegistrationId()를 설정하는 방법이다:

@GetMapping("/")
public String index() {
    String resourceUri = ...

    String body = webClient
            .get()
            .uri(resourceUri)
            .attributes(clientRegistrationId("okta"))   // (1)
            .retrieve()
            .bodyToMono(String.class)
            .block();

    ...

    return "index";
}

(1) clientRegistrationId()ServletOAuth2AuthorizedClientExchangeFilterFunction에 있는 static 메소드다.

Defaulting the Authorized Client

설정 속성에 OAuth2AuthorizedClient도, ClientRegistration.getRegistrationId()도 없다면 ServletOAuth2AuthorizedClientExchangeFilterFunction은 설정에 따라 디폴트 클라이언트를 사용한다.

setDefaultOAuth2AuthorizedClient(true)를 설정했고 사용자가 HttpSecurity.oauth2Login()으로 인증한 사용자라면, 현재 OAuth2AuthenticationToken과 관련된 OAuth2AccessToken을 사용한다.

다음은 설정 예시이다:

@Bean
WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
    ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
            new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
    oauth2Client.setDefaultOAuth2AuthorizedClient(true);
    return WebClient.builder()
            .apply(oauth2Client.oauth2Configuration())
            .build();
}

모든 HTTP 요청이 액세스 토큰을 받으므로 이 기능은 주의해서 사용하는 것이 좋다.

혹은 유효한 ClientRegistrationsetDefaultClientRegistrationId("okta")를 설정했다면, OAuth2AuthorizedClient와 관련된 OAuth2AccessToken을 사용한다.

다음은 설정 예시이다:

@Bean
WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
    ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
            new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
    oauth2Client.setDefaultClientRegistrationId("okta");
    return WebClient.builder()
            .apply(oauth2Client.oauth2Configuration())
            .build();
}

모든 HTTP 요청이 액세스 토큰을 받으므로 이 기능은 주의해서 사용하는 것이 좋다.


12.3. OAuth 2.0 Resource Server

스프링 시큐리티는 OAuth 2.0 Bearer 토큰 두 종류로 엔드포인트를 보호해 준다:

이 기능은 어플리케이션의 권한 관리를 별도 인가 서버에 (ex. Okta, Ping Identity) 위임하는 경우에 사용할 수 있다. 리소스 서버는 요청을 인가할 때 이 인가 서버에 물어볼 수 있다.

스프링 시큐리티 레포지토리JWTOpaque 토큰을 사용한 실전 예제가 모두 있다.

12.3.1. Dependencies

리소스 서버를 지원하는 코드는 대부분 spring-security-oauth2-resource-server에 들어있다. 하지만 JWT를 디코딩하고 검증하는 로직은 spring-security-oauth2-jose에 있다. 따라서 리소스 서버가 사용할 Bearer 토큰을 JWT로 인코딩한다면 두 모듈이 모두 필요하다.

12.3.2. Minimal Configuration for JWTs

스프링 부트를 사용한다면 두 가지만으로 어플리케이션을 리소스 서버로 설정할 수 있다. 필요한 의존성을 추가하고, 인가 서버 위치를 알려주면 된다.

Specifying the Authorization Server

스프링 부트에서 사용할 인가 서버는 간단하게 지정할 수 있다:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://idp.example.com/issuer

여기서 https://idp.example.com/issuer는 인가 서버가 발급할 JWT 토큰의 iss 클레임에 추가되는 값이다. 리소스 서버는 자체 설정에도 이 속성을 사용하며, 이 속성으로 인가 서버의 공개키를 찾고, 건내 받은 JWT의 유효성을 검사한다.

issuer-uri 프로퍼티를 사용하려면 인가 서버가 지원하는 엔드포인트는 반드시 https://idp.example.com/issuer/.well-known/openid-configuration, https://idp.example.com/.well-known/openid-configuration/issuer, https://idp.example.com/.well-known/oauth-authorization-server/issuer 셋 중 하나여야 한다. 이 엔드포인트는 Provider 설정 엔드포인트 또는 인가 서버 메타데이터 엔드포인트라고 한다.

이게 전부다!

Startup Expectations

이 프로퍼티와 의존성을 사용하면 JWT로 인코딩한 Bearer 토큰을 검증하는 리소스 서버가 자동으로 설정된다.

결정적으로 기동 시점에 아래와 같은 처리를 하기 때문이다:

  1. Provider 설정 엔드포인트 또는 인가 서버 메타데이터 엔드포인트를 찔러서 응답으로 jwks_url 프로퍼티를 처리한다.
  2. jwks_url에 유효한 공개키를 질의하기 위한 검증 전략을 설정한다.
  3. https://idp.example.com에 대한 각 JWT iss 클레임을 검증할 전략을 설정한다.

이 프로세스대로 리소스 서버를 기동하려면 반드시 인가 서버가 기동돼서 요청을 받을 수 있는 상태여야 한다.

리소스 서버가 질의할 때 인가 서버가 다운돼 있으면 (적절한 타임아웃이 있으면) 기동에 실패한다.

Runtime Expectations

어플리케이션이 기동되고 나면, 리소스 서버는 Authorization: Bearer 헤더를 포함한 모든 요청을 처리한다:

GET / HTTP/1.1
Authorization: Bearer some-token-value # Resource Server will process this

이 스킴만 명시하면 리소스 서버는 Bearer 토큰 스펙에 따라 요청을 처리한다.

JWT 형식에 이상이 없으면 리소스 서버는:

  1. 기동 시 jwks_url 엔드포인트에서 가져와 JWT에 매칭한 공개키로 서명을 검증한다.
  2. JWT에 있는 exp, nbf 타임스탬프, iss 클레임을 검증하고,
  3. 각 scope를 SCOPE_ 프리픽스를 달아 권한에 매핑한다.

인가 서버가 새로운 키를 만들면 스프링 시큐리티는 자동으로 JWT 검증에 사용할 키를 교체한다.

기본적으로 Authentication#getPrincipal 결과는 스프링 시큐리티의 Jwt 객체이며, Authentication#getName은 JWT의 sub 프로퍼티 값이 있으면 이 값을 사용한다.

여기서부턴 바로 다음 챕터로 넘어가도 좋다:

인가 서버 가용성과는 상관없이 리소스 서버를 기동하게 만드는 설정

스프링 부트 없이 설정하기

12.3.3. Specifying the Authorization Server JWK Set Uri Directly

인가 서버가 설정 엔드포인트를 전부 지원하지 않거나, 인가 서버와는 독립적으로 리소스 서버를 실행해야 하는 상황이라면, 다음과 같이 jwk-set-uri 프로퍼티를 설정해라:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://idp.example.com
          jwk-set-uri: https://idp.example.com/.well-known/jwks.json

JWK Set uri는 표준은 아니지만, 보통 인가 서버 문서에 나와있긴 하다.

이렇게 하면 리소스 서버를 기동할 때 인가 서버를 찔러보지 않는다. 인가 서버가 전달받은 JWT에 있는 iss 클레임을 검증할 수 있도록 issuer-uri는 남겨놨다.

DSL로 직접 프로퍼티를 설정하는 방법도 있다.

12.3.4. Overriding or Replacing Boot Auto Configuration

스프링 부트가 리소스 서버에 생성하는 @Bean은 두 가지가 있다.

하나는 어플리케이션을 리소스 서버로 설정해주는 WebSecurityConfigurerAdapter다. spring-security-oauth2-jose 모듈이 있다면 WebSecurityConfigurerAdapter는 다음과 같이 설정된다:

Example 96. Default JWT Configuration

java kotlin
protected void configure(HttpSecurity http) {
    http
        .authorizeRequests(authorize -> authorize
            .anyRequest().authenticated()
        )
        .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
}
fun configure(http: HttpSecurity) {
    http {
        authorizeRequests {
            authorize(anyRequest, authenticated)
        }
        oauth2ResourceServer {
            jwt { }
        }
    }
}

어플리케이션에서 따로 정의한 WebSecurityConfigurerAdapter 빈이 없다면 스프링 부트가 위에 있는 디폴트 빈을 등록한다.

빈을 바꾸려면 어플리케이션에 빈을 정의하기만 하면 되다:

Example 97. Custom JWT Configuration

java kotlin
@EnableWebSecurity
public class MyCustomSecurityConfiguration extends WebSecurityConfigurerAdapter {
    protected void configure(HttpSecurity http) {
        http
            .authorizeRequests(authorize -> authorize
                .mvcMatchers("/messages/**").hasAuthority("SCOPE_message:read")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwtAuthenticationConverter(myConverter())
                )
            );
    }
}
@EnableWebSecurity
class MyCustomSecurityConfiguration : WebSecurityConfigurerAdapter() {
    override fun configure(http: HttpSecurity) {
        http {
            authorizeRequests {
                authorize("/messages/**", hasAuthority("SCOPE_message:read"))
                authorize(anyRequest, authenticated)
            }
            oauth2ResourceServer {
                jwt {
                    jwtAuthenticationConverter = myConverter()
                }
            }
        }
    }
}

위 설정에선 /messages/로 시작하는 모든 URL은 message:read scope가 있어야 접근할 수 있다.

oauth2ResourceServer DSL 메소드로도 자동 설정을 재정의하거나 아예 바꿔버릴 수 있다.

예를 들어 스프링 부트가 생성하는 두 번째 @BeanJwtDecoder인데, 이 빈은 String 토큰을 검증된 Jwt 인스턴스로 디코딩한다:

Example 98. JWT Decoder

@Bean
public JwtDecoder jwtDecoder() {
    return JwtDecoders.fromIssuerLocation(issuerUri);
}

JwtDecoders#fromIssuerLocation을 호출하면 Provider 설정 또는 인가 서버 메타데이터 엔드포인트로 JWK 셋 Uri를 요청한다.

어플리케이션에서 따로 정의한 JwtDecoder 빈이 없다면 스프링 부트가 위에 있는 디폴트 빈을 등록한다.

이 설정은 jwkSetUri()로 재정의하거나 decoder()로 바꿀 수 있다.

스프링 부트를 사용하지 않는 다면, 필터 체인과 JwtDecoder를 XML에 명시하면 된다.

필터 체인은 다음과 같이 지정한다:

Example 99. Default JWT Configuration

<http>
    <intercept-uri pattern="/**" access="authenticated"/>
    <oauth2-resource-server>
        <jwt decoder-ref="jwtDecoder"/>
    </oauth2-resource-server>
</http>

JwtDecoder는 다음과 같이 지정한다:

Example 100. JWT Decoder

<bean id="jwtDecoder"
        class="org.springframework.security.oauth2.jwt.JwtDecoders"
        factory-method="fromIssuerLocation">
    <constructor-arg value="${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}"/>
</bean>

Using jwkSetUri()

인가 서버의 JWK 셋 Uri는 설정 프로퍼티나 DSL로 지정할 수 있다.

Example 101. JWK Set Uri Configuration

java kotlin xml
@EnableWebSecurity
public class DirectlyConfiguredJwkSetUri extends WebSecurityConfigurerAdapter {
    protected void configure(HttpSecurity http) {
        http
            .authorizeRequests(authorize -> authorize
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwkSetUri("https://idp.example.com/.well-known/jwks.json")
                )
            );
    }
}
@EnableWebSecurity
class DirectlyConfiguredJwkSetUri : WebSecurityConfigurerAdapter() {
    override fun configure(http: HttpSecurity) {
        http {
            authorizeRequests {
                authorize(anyRequest, authenticated)
            }
            oauth2ResourceServer {
                jwt {
                    jwkSetUri = "https://idp.example.com/.well-known/jwks.json"
                }
            }
        }
    }
}
<http>
    <intercept-uri pattern="/**" access="authenticated"/>
    <oauth2-resource-server>
        <jwt jwk-set-uri="https://idp.example.com/.well-known/jwks.json"/>
    </oauth2-resource-server>
</http>

jwkSetUri()가 설정 프로퍼티보다 우선시된다.

Using decoder()

jwkSetUri() 대신 decoder()를 사용하면 부트의 JwtDecoder 자동 설정을 완전히 바꿔버릴 수 있다:

Example 102. JWT Decoder Configuration

java kotlin xml
@EnableWebSecurity
public class DirectlyConfiguredJwtDecoder extends WebSecurityConfigurerAdapter {
    protected void configure(HttpSecurity http) {
        http
            .authorizeRequests(authorize -> authorize
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .decoder(myCustomDecoder())
                )
            );
    }
}
@EnableWebSecurity
class DirectlyConfiguredJwtDecoder : WebSecurityConfigurerAdapter() {
    override fun configure(http: HttpSecurity) {
        http {
            authorizeRequests {
                authorize(anyRequest, authenticated)
            }
            oauth2ResourceServer {
                jwt {
                    jwtDecoder = myCustomDecoder()
                }
            }
        }
    }
}
<http>
    <intercept-uri pattern="/**" access="authenticated"/>
    <oauth2-resource-server>
        <jwt decoder-ref="myCustomDecoder"/>
    </oauth2-resource-server>
</http>

이 방식을 사용하면 검증, 매핑, 요청 타임아웃 등 좀 더 세세한 설정을 쉽게 바꿀 수 있다.

Exposing a JwtDecoder @Bean

JwtDecoder @Bean을 정의하는 것도 decoder()와 동일한 효과가 있다:

@Bean
public JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
}

12.3.5. Configuring Trusted Algorithms

디폴트로 NimbusJwtDecoder를 사용하기 때문에 리소스 서버는 RS256을 사용한 토큰만 신뢰하고 이 토큰만 검증할 수 있다.

알고리즘은 스프링 부트, NimbusJwtDecoder 빌더, 아니면 JWK 셋 응답으로도 커스텀할 수 있다.

Via Spring Boot

알고리즘을 변경하는 가장 쉬운 방법은 스프링 부트 프로퍼티 설정이다:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jws-algorithm: RS512
          jwk-set-uri: https://idp.example.org/.well-known/jwks.json

Using a Builder

하지만 NimbusJwtDecoder 빌더를 사용하면 다른 것도 가능하다:

@Bean
JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.fromJwkSetUri(this.jwkSetUri)
            .jwsAlgorithm(RS512).build();
}

jwsAlgorithm을 여러 번 호출하면 NimbusJwtDecoder는 이 알고리즘을 전부 신뢰할 수 있는 알고리즘으로 판단한다:

@Bean
JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.fromJwkSetUri(this.jwkSetUri)
            .jwsAlgorithm(RS512).jwsAlgorithm(EC512).build();
}

아니면 jwsAlgorithms 메소드를 사용해도 된다:

@Bean
JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.fromJwkSetUri(this.jwkSetUri)
            .jwsAlgorithms(algorithms -> {
                    algorithms.add(RS512);
                    algorithms.add(EC512);
            }).build();
}

From JWK Set response

스프링 시큐리티는 Nimbus를 기반으로 JWT를 지원하기 때문에 Nimbus 기능을 전부 사용할 수 있다.

예를 들어 Nimbus엔 JWK 셋 URI 응답을 기준으로 알고리즘 셋을 선택하는 JWSKeySelector 구현체가 있다. 이 구현체를 사용해서 NimbusJwtDecoder를 만드는 것도 가능하다:

@Bean
public JwtDecoder jwtDecoder() {
    // makes a request to the JWK Set endpoint
    JWSKeySelector<SecurityContext> jwsKeySelector =
            JWSAlgorithmFamilyJWSKeySelector.fromJWKSetURL(this.jwkSetUrl);

    DefaultJWTProcessor<SecurityContext> jwtProcessor =
            new DefaultJWTProcessor<>();
    jwtProcessor.setJWSKeySelector(jwsKeySelector);

    return new NimbusJwtDecoder(jwtProcessor);
}

12.3.6. Trusting a Single Asymmetric Key

리소스 서버에 JWK 셋 엔드포인트를 설정하는 것 보다 RSA 공개키를 하드코딩하는 게 더 간단한다. 공개키는 스프링 부트빌더로 설정할 수 있다.

Via Spring Boot

스프링 부트에 키를 명시하는 건 꽤 간단하다. 다음과 같이 키 위치를 지정할 수 있다:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          public-key-location: classpath:my-key.pub

좀 더 정교한 방법으로 공개키를 찾아야 한다면, RsaKeyConversionServicePostProcessor에 후처리를 추가하면 된다:

@Bean
BeanFactoryPostProcessor conversionServiceCustomizer() {
    return beanFactory ->
        beanFactory.getBean(RsaKeyConversionServicePostProcessor.class)
                .setResourceLoader(new CustomResourceLoader());
}

키가 있는 곳을 명시해라:

key.location: hfds://my-key.pub

그 다음 그 값을 주입해라:

@Value("${key.location}")
RSAPublicKey key;

Using a Builder

간단하게 다음과 같이 NimbusJwtDecoder 빌더로 직접 RSAPublicKey를 주입할 수도 있다:

@Bean
public JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withPublicKey(this.key).build();
}

12.3.7. Trusting a Single Symmetric Key

대칭키 사용도 간단하다. 단순히 SecretKey를 로드해서 NimbusJwtDecoder 빌더에 넣어주면 된다:

@Bean
public JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withSecretKey(this.key).build();
}

12.3.8. Configuring Authorization

OAuth 2.0 인가 서버가 발급한 JWT에는 보통 부여한 scope(권한)를 나타내는 scopescp 속성이 있다. 예를 들어:

{ …, "scope" : "messages contacts"}

이런 경우 리소스 서버는 각 스코프에 “SCOPE_” 프리픽스를 달아 승인된 권한 리스트를 만든다.

즉, JWT의 scope로 특정 엔드포인트나 메소드를 보호하려면, 프리픽스를 포함한 적절한 표현식을 사용해야 한다:

Example 103. Authorization Configuration

java kotlin xml
@EnableWebSecurity
public class DirectlyConfiguredJwkSetUri extends WebSecurityConfigurerAdapter {
    protected void configure(HttpSecurity http) {
        http
            .authorizeRequests(authorize -> authorize
                .mvcMatchers("/contacts/**").hasAuthority("SCOPE_contacts")
                .mvcMatchers("/messages/**").hasAuthority("SCOPE_messages")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
    }
}
@EnableWebSecurity
class DirectlyConfiguredJwkSetUri : WebSecurityConfigurerAdapter() {
    override fun configure(http: HttpSecurity) {
        http {
            authorizeRequests {
                authorize("/contacts/**", hasAuthority("SCOPE_contacts"))
                authorize("/messages/**", hasAuthority("SCOPE_messages"))
                authorize(anyRequest, authenticated)
            }
            oauth2ResourceServer {
                jwt { }
            }
        }
    }
}
<http>
    <intercept-uri pattern="/contacts/**" access="hasAuthority('SCOPE_contacts')"/>
    <intercept-uri pattern="/messages/**" access="hasAuthority('SCOPE_messages')"/>
    <oauth2-resource-server>
        <jwt jwk-set-uri="https://idp.example.org/.well-known/jwks.json"/>
    </oauth2-resource-server>
</http>

메소드 시큐리티도 비슷하다:

@PreAuthorize("hasAuthority('SCOPE_messages')")
public List<Message> getMessages(...) {}

Extracting Authorities Manually

하지만 이 디폴트 동작으로 해결되지 않는 상황도 많다. 예를 들어 일부 인증 서버는 scope 속성 대신 자체 커스텀 속성을 사용한다. 또는 리소스 서버에서 속성 또는 속성 조합을 내부 권한에 맞게 조정해야 할 수도 있다.

DSL엔 이럴 때 사용할 수 있는 jwtAuthenticationConverter() 메소드가 있다. 이 메소드로는 JwtAuthentication으로 변환하는 컨버터를 설정한다.

먼저, Jwt를 승인된 권한 Collection으로 변환하는 하위 컨버터를 설정할 수 있다. 인가 서버가 authorities란 커스텀 클레임을 사용한다고 가정해보자. 이 경우 JwtAuthenticationConverter가 변환해야 하는 클레임을 다음과 같이 설정할 수 있다:

Example 104. Authorities Claim Configuration

java xml
@EnableWebSecurity
public class CustomAuthoritiesClaimName extends WebSecurityConfigurerAdapter {
    protected void configure(HttpSecurity http) {
        http
            .authorizeRequests(authorize -> authorize
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwtAuthenticationConverter(jwtAuthenticationConverter())
                )
            );
    }
}

JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
    grantedAuthoritiesConverter.setAuthoritiesClaimName("authorities");

    JwtAuthenticationConverter authenticationConverter = new JwtAuthenticationConverter();
    jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
    return jwtAuthenticationConverter;
}
<http>
    <intercept-uri pattern="/contacts/**" access="hasAuthority('SCOPE_contacts')"/>
    <intercept-uri pattern="/messages/**" access="hasAuthority('SCOPE_messages')"/>
    <oauth2-resource-server>
        <jwt jwk-set-uri="https://idp.example.org/.well-known/jwks.json"
                jwt-authentication-converter-ref="jwtAuthenticationConverter"/>
    </oauth2-resource-server>
</http>

<bean id="jwtAuthenticationConverter"
        class="org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter">
    <property name="jwtGrantedAuthoritiesConverter" ref="jwtGrantedAuthoritiesConverter"/>
</bean>

<bean id="jwtGrantedAuthoritiesConverter"
        class="org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter">
    <property name="authoritiesClaimName" value="authorities"/>
</bean>

권한 프리픽스도 변경할 수 있다. 다음 예시는 각 권한의 프리픽스를 SCOPE_가 아닌 ROLE_로 변경한다:

Example 105. Authorities Prefix Configuration

java xml
JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
    grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");

    JwtAuthenticationConverter authenticationConverter = new JwtAuthenticationConverter();
    jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
    return jwtAuthenticationConverter;
}
<http>
    <intercept-uri pattern="/contacts/**" access="hasAuthority('SCOPE_contacts')"/>
    <intercept-uri pattern="/messages/**" access="hasAuthority('SCOPE_messages')"/>
    <oauth2-resource-server>
        <jwt jwk-set-uri="https://idp.example.org/.well-known/jwks.json"
                jwt-authentication-converter-ref="jwtAuthenticationConverter"/>
    </oauth2-resource-server>
</http>

<bean id="jwtAuthenticationConverter"
        class="org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter">
    <property name="jwtGrantedAuthoritiesConverter" ref="jwtGrantedAuthoritiesConverter"/>
</bean>

<bean id="jwtGrantedAuthoritiesConverter"
        class="org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter">
    <property name="authorityPrefix" value="ROLE_"/>
</bean>

아니면 JwtGrantedAuthoritiesConverter#setAuthorityPrefix("")로 프리픽스를 완전히 제거하는 것도 가능하다.

좀 더 유연하게는, DSL로 기존 컨버터를 Converter<Jwt, AbstractAuthenticationToken>을 구현하는 다른 클래스로 바꿀 수 있다:

static class CustomAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {
    public AbstractAuthenticationToken convert(Jwt jwt) {
        return new CustomAuthenticationToken(jwt);
    }
}

12.3.9. Configuring Validation

스프링 부트 최소 설정으로 인가 서버의 issuer uri를 지정하면, 리소스 서버는 기본적으로 iss 클레임과 exp, nbf 타임스템프 클레임을 검증한다.

리소스 서버가 사용하는 표준 validator는 두 가지가 있는데, 커스텀 OAuth2TokenValidator 인스턴스도 허용하므로 검증 로직을 커스텀할 수 있다.

Customizing Timestamp Validation

JWT는 보통 nbf 클레임으로 시작하고 exp 클레임으로 끝나는 유효 기간(validity window)이 있다.

하지만 모든 서버는 클럭 드리프트가 발생할 수 있으므로, 서버 하나에선 토큰이 만료되지만 다른 서버에선 아닐 수도 있다. 따라서 분산 시스템에 있는 서버가 많아지면 문제가 될 수 있다.

리소스 서버는 JwtTimestampValidator로 토큰의 유효 기간을 검증하며, clockSkew를 설정하면 이 문제를 어느 정도 해결할 수 있다:

@Bean
JwtDecoder jwtDecoder() {
     NimbusJwtDecoder jwtDecoder = (NimbusJwtDecoder)
             JwtDecoders.fromIssuerLocation(issuerUri);

     OAuth2TokenValidator<Jwt> withClockSkew = new DelegatingOAuth2TokenValidator<>(
            new JwtTimestampValidator(Duration.ofSeconds(60)),
            new IssuerValidator(issuerUri));

     jwtDecoder.setJwtValidator(withClockSkew);

     return jwtDecoder;
}

리소스 서버는 디폴트로 clockSkew를 30초로 설정한다 (30초 이상 차이나야 만료된 것으로 판단한다).

Configuring a Custom Validator

aud 클레임 체크는 OAuth2TokenValidator API로 간단하게 추가할 수 있다:

OAuth2TokenValidator<Jwt> audienceValidator() {
    return new JwtClaimValidator<List<String>>(AUD, aud -> aud.contains("messaging"));
}

직접 OAuth2TokenValidator를 구현하면 좀 더 세세하게 컨트롤할 수 있다:

static class AudienceValidator implements OAuth2TokenValidator<Jwt> {
    OAuth2Error error = new OAuth2Error("custom_code", "Custom error message", null);

    @Override
    public OAuth2TokenValidatorResult validate(Jwt jwt) {
        if (jwt.getAudience().contains("messaging")) {
            return OAuth2TokenValidatorResult.success();
        } else {
            return OAuth2TokenValidatorResult.failure(error);
        }
    }
}

// ...

OAuth2TokenValidator<Jwt> audienceValidator() {
    return new AudienceValidator();
}

그러고 커스텀 구현체를 리소스 서버에 추가하려면 JwtDecoder 인스턴스에 명시하기만 하면 된다:

@Bean
JwtDecoder jwtDecoder() {
    NimbusJwtDecoder jwtDecoder = (NimbusJwtDecoder)
        JwtDecoders.fromIssuerLocation(issuerUri);

    OAuth2TokenValidator<Jwt> audienceValidator = audienceValidator();
    OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri);
    OAuth2TokenValidator<Jwt> withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator);

    jwtDecoder.setJwtValidator(withAudience);

    return jwtDecoder;
}

12.3.10. Configuring Claim Set Mapping

스프링 시큐리티는 JWT를 파싱하고 서명을 검증할 때 Nimbus 라이브러리를 사용한다. 따라서 스프링 시큐리티는 Nimbus가 각 필드를 해석하고 자바 타입으로 변환하는 방식을 그대로 따른다.

예를 들어 Nimbus는 자바 7과 호환되기 때문에 Instant로 타임스탬프 필드를 표현하지 않는다.

JWT를 처리할 때 필드를 자체적으로 해석해는 다른 라이브러리를 사용하는 것도 가능하다.

아니면 간단히 말해서, 도메인 상의 이유로 리소스 서버에서 JWT에 클레임을 추가하거나 제거할 수 있다.

이를 위해 리소스 서버는 MappedJwtClaimSetConverter로 JWT 클레임 셋을 매핑한다.

Customizing the Conversion of a Single Claim

MappedJwtClaimSetConverter는 기본적으로 각 클레임을 다음 타입으로 변환한다:

Claim Java Type
aud Collection<String>
exp Instant
iat Instant
iss String
jti String
nbf Instant
sub String

클레임 별 변환 전략은 MappedJwtClaimSetConverter.withDefaults로 설정할 수 있다:

@Bean
JwtDecoder jwtDecoder() {
    NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();

    MappedJwtClaimSetConverter converter = MappedJwtClaimSetConverter
            .withDefaults(Collections.singletonMap("sub", this::lookupUserIdBySub));
    jwtDecoder.setClaimSetConverter(converter);

    return jwtDecoder;
}

이렇게 사용하면 재정의한 sub 클레임의 디폴트 컨버터를 제외한 다른 디폴트 컨버터는 그대로 유지한다.

Adding a Claim

MappedJwtClaimSetConverter로 기존 시스템에 맞는 커스텀 클레임을 추가하는 것도 가능하다:

MappedJwtClaimSetConverter.withDefaults(Collections.singletonMap("custom", custom -> "value"));

Removing a Claim

같은 API로 간단하게 클레임을 제거할 수도 있다:

MappedJwtClaimSetConverter.withDefaults(Collections.singletonMap("legacyclaim", legacy -> null));

Renaming a Claim

한 번에 여러 클레임을 처리하거나 클레임 이름을 변경하는 등, 복잡한 시나리오에선 Converter<Map<String, Object>, Map<String,Object>> 구현체를 사용하면 된다:

public class UsernameSubClaimAdapter implements Converter<Map<String, Object>, Map<String, Object>> {
    private final MappedJwtClaimSetConverter delegate =
            MappedJwtClaimSetConverter.withDefaults(Collections.emptyMap());

    public Map<String, Object> convert(Map<String, Object> claims) {
        Map<String, Object> convertedClaims = this.delegate.convert(claims);

        String username = (String) convertedClaims.get("user_name");
        convertedClaims.put("sub", username);

        return convertedClaims;
    }
}

그 다음엔 이전과 동일한 방법으로 인스턴스를 등록할 수 있다:

@Bean
JwtDecoder jwtDecoder() {
    NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
    jwtDecoder.setClaimSetConverter(new UsernameSubClaimAdapter());
    return jwtDecoder;
}

12.3.11. Configuring Timeouts

리소스 서버가 인가 서버와 통신할 때 사용하는 디폴트 커넥션 타임아웃과 소켓 타임아웃은 30초다.

필요에 따라 타임아웃을 더 길게 늘려야 할 수도 있다. 게다가 디폴트 타임아웃 설정은 back-off나 discovery 전략같은 정교한 설정은 하지 않고 있다.

리소스 서버가 인가 서버와 통신하는 방식은 NimbusJwtDecoderRestOperations로 설정한다:

@Bean
public JwtDecoder jwtDecoder(RestTemplateBuilder builder) {
    RestOperations rest = builder
            .setConnectionTimeout(60000)
            .setReadTimeout(60000)
            .build();

    NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).restOperations(rest).build();
    return jwtDecoder;
}

12.3.12. Minimal Configuration for Introspection

opaque 토큰은 보통 인가 서버에서 호스트하는 OAuth 2.0 Introspection 엔드포인트로 검증한다. 이 엔드포인트는 토큰을 취소해야 할 때 유용하다.

스프링 부트를 사용하면 두 가지만으로 어플리케이션을 introspection을 사용하는 인가 서버가로 만들 수 있다. 먼저 필요한 의존성을 추가하고, 그 다음 introspection 엔드포인트 상세 정보를 설정한다.

Specifying the Authorization Server

introspection 엔드포인트 위치는 간단하게 다음과 같이 등록한다:

security:
  oauth2:
    resourceserver:
      opaque-token:
        introspection-uri: https://idp.example.com/introspect
        client-id: client
        client-secret: secret

https://idp.example.com/introspect는 인가 서버가 호스트하는 introspection 엔드포인트이며, client-idclient-secret은 엔드포인트 요청에 사용할 credential이다.

리소스 서버는 이 프로퍼티로 자체 설정을 만들어 이후 전달받은 JWT를 검증할 때 사용한다.

introspection을 사용한다면, 인가 서버의 말이 곧 법이다. 인가 서버가 토큰이 유효하다고 응답한다면 유효한 것이다.

이게 전부다!

Startup Expectations

이 프로퍼티와 의존성을 사용하면 Opaque Bearer 토큰을 검증하는 리소스 서버가 자동으로 설정된다.

기동 프로세스는 엔드포인트를 찾거나 검증 룰을 추가하는 작업이 없기 때문에 JWT보다 훨씬 간단하다.

Runtime Expectations

어플리케이션이 기동되고 나면, 리소스 서버는 Authorization: Bearer 헤더를 포함한 모든 요청을 처리한다:

GET / HTTP/1.1
Authorization: Bearer some-token-value # Resource Server will process this

이 스킴만 명시하면 리소스 서버는 Bearer 토큰 스펙에 따라 요청을 처리한다.

Opaque 토큰이 있으면 리소스 서버는:

  1. credential과 토큰으로 설정에 있는 introspection 엔드포인트에 질의한다.
  2. 응답에서 { 'active' : true } 속성을 찾는다.
  3. 각 scope를 SCOPE_ 프리픽스를 달아 권한에 매핑한다.

기본적으로 Authentication#getPrincipal 결과는 스프링 시큐리티의 OAuth2AuthenticatedPrincipal 객체이며, Authentication#getName은 토큰의 sub 프로퍼티 값이 있으면 이 값을 사용한다.

여기서부턴 바로 다음 챕터로 넘어가도 좋다:

12.3.13. Looking Up Attributes Post-Authentication

토큰을 인증하고 나면 SecurityContextBearerTokenAuthentication이 세팅된다.

즉, 설정에 @EnableWebMvc를 추가한 @Controller 메소드에서 이 값을 사용할 수 있다:

@GetMapping("/foo")
public String foo(BearerTokenAuthentication authentication) {
    return authentication.getTokenAttributes().get("sub") + " is the subject";
}

BearerTokenAuthenticationOAuth2AuthenticatedPrincipal이 있기 때문에 이 값도 컨트롤러 메소드에서 사용할 수 있다:

@GetMapping("/foo")
public String foo(@AuthenticationPrincipal OAuth2AuthenticatedPrincipal principal) {
    return principal.getAttribute("sub") + " is the subject";
}

Looking Up Attributes Via SpEL

당연히 SpEL로도 속성에 접근할 수 있다.

예를 들어 @EnableGlobalMethodSecurity를 사용한다면 아래처럼 @PreAuthorize 어노테이션을 사용할 수 있다:

@PreAuthorize("principal?.attributes['sub'] == 'foo'")
public String forFoosEyesOnly() {
    return "foo";
}

12.3.14. Overriding or Replacing Boot Auto Configuration

스프링 부트가 리소스 서버에 생성하는 @Bean은 두 가지가 있다.

하나는 어플리케이션을 리소스 서버로 설정해주는 WebSecurityConfigurerAdapter다. Opaque 토큰을 사용한다면 WebSecurityConfigurerAdapter는 다음과 같이 설정된다:

Example 106. Default Opaque Token Configuration

java kotlin
protected void configure(HttpSecurity http) {
    http
        .authorizeRequests(authorize -> authorize
            .anyRequest().authenticated()
        )
        .oauth2ResourceServer(OAuth2ResourceServerConfigurer::opaqueToken);
}
override fun configure(http: HttpSecurity) {
    http {
        authorizeRequests {
            authorize(anyRequest, authenticated)
        }
        oauth2ResourceServer {
            opaqueToken { }
        }
    }
}

어플리케이션에서 따로 정의한 WebSecurityConfigurerAdapter 빈이 없다면 스프링 부트가 위에 있는 디폴트 빈을 등록한다.

빈을 바꾸려면 어플리케이션에 빈을 정의하기만 하면 되다:

Example 107. Custom Opaque Token Configuration

java kotlin
@EnableWebSecurity
public class MyCustomSecurityConfiguration extends WebSecurityConfigurerAdapter {
    protected void configure(HttpSecurity http) {
        http
            .authorizeRequests(authorize -> authorize
                .mvcMatchers("/messages/**").hasAuthority("SCOPE_message:read")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .opaqueToken(opaqueToken -> opaqueToken
                    .introspector(myIntrospector())
                )
            );
    }
}
@EnableWebSecurity
class MyCustomSecurityConfiguration : WebSecurityConfigurerAdapter() {
    override fun configure(http: HttpSecurity) {
        http {
            authorizeRequests {
                authorize("/messages/**", hasAuthority("SCOPE_message:read"))
                authorize(anyRequest, authenticated)
            }
            oauth2ResourceServer {
                opaqueToken {
                    introspector = myIntrospector()
                }
            }
        }
    }
}

위 설정에선 /messages/로 시작하는 모든 URL은 message:read scope가 있어야 접근할 수 있다.

oauth2ResourceServer DSL 메소드로도 자동 설정을 재정의하거나 아예 바꿔버릴 수 있다.

예를 들어 스프링 부트가 생성하는 두 번째 @BeanOpaqueTokenIntrospector인데, 이 빈은 String 토큰을 검증된 OAuth2AuthenticatedPrincipal 인스턴스로 디코딩한다:

@Bean
public OpaqueTokenIntrospector introspector() {
    return new NimbusOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret);
}

어플리케이션에서 따로 정의한 OpaqueTokenIntrospector 빈이 없다면 스프링 부트가 위에 있는 디폴트 빈을 등록한다.

이 설정은 introspectionUri()introspectionClientCredentials()로 재정의하거나 introspector()로 바꿀 수 있다.

스프링 부트를 사용하지 않는 다면, 필터 체인과 OpaqueTokenIntrospector를 XML에 명시하면 된다.

필터 체인은 다음과 같이 지정한다:

Example 108. Default Opaque Token Configuration

<http>
    <intercept-uri pattern="/**" access="authenticated"/>
    <oauth2-resource-server>
        <opaque-token introspector-ref="opaqueTokenIntrospector"/>
    </oauth2-resource-server>
</http>

OpaqueTokenIntrospector는 다음과 같이 지정한다:

Example 109. Opaque Token Introspector

<bean id="opaqueTokenIntrospector"
        class="org.springframework.security.oauth2.server.resource.introspection.NimbusOpaqueTokenIntrospector">
    <constructor-arg value="${spring.security.oauth2.resourceserver.opaquetoken.introspection_uri}"/>
    <constructor-arg value="${spring.security.oauth2.resourceserver.opaquetoken.client_id}"/>
    <constructor-arg value="${spring.security.oauth2.resourceserver.opaquetoken.client_secret}"/>
</bean>

Using introspectionUri()

인가 서버의 Introspection Uri는 설정 프로퍼티나 DSL로 지정할 수 있다:

Example 110. Introspection URI Configuration

java kotlin xml
@EnableWebSecurity
public class DirectlyConfiguredIntrospectionUri extends WebSecurityConfigurerAdapter {
    protected void configure(HttpSecurity http) {
        http
            .authorizeRequests(authorize -> authorize
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .opaqueToken(opaqueToken -> opaqueToken
                    .introspectionUri("https://idp.example.com/introspect")
                    .introspectionClientCredentials("client", "secret")
                )
            );
    }
}
@EnableWebSecurity
class DirectlyConfiguredIntrospectionUri : WebSecurityConfigurerAdapter() {
    override fun configure(http: HttpSecurity) {
        http {
            authorizeRequests {
                authorize(anyRequest, authenticated)
            }
            oauth2ResourceServer {
                opaqueToken {
                    introspectionUri = "https://idp.example.com/introspect"
                    introspectionClientCredentials("client", "secret")
                }
            }
        }
    }
}
<bean id="opaqueTokenIntrospector"
        class="org.springframework.security.oauth2.server.resource.introspection.NimbusOpaqueTokenIntrospector">
    <constructor-arg value="https://idp.example.com/introspect"/>
    <constructor-arg value="client"/>
    <constructor-arg value="secret"/>
</bean>

introspectionUri()가 설정 프로퍼티보다 우선시된다.

Using introspector()

introspectionUri() 대신 introspector()를 사용하면 부트의 OpaqueTokenIntrospector 자동 설정을 완전히 바꿔버릴 수 있다:

Example 111. Introspector Configuration

java kotlin xml
@EnableWebSecurity
public class DirectlyConfiguredIntrospector extends WebSecurityConfigurerAdapter {
    protected void configure(HttpSecurity http) {
        http
            .authorizeRequests(authorize -> authorize
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .opaqueToken(opaqueToken -> opaqueToken
                    .introspector(myCustomIntrospector())
                )
            );
    }
}
@EnableWebSecurity
class DirectlyConfiguredIntrospector : WebSecurityConfigurerAdapter() {
    override fun configure(http: HttpSecurity) {
        http {
            authorizeRequests {
                authorize(anyRequest, authenticated)
            }
            oauth2ResourceServer {
                opaqueToken {
                    introspector = myCustomIntrospector()
                }
            }
        }
    }
}
<http>
    <intercept-uri pattern="/**" access="authenticated"/>
    <oauth2-resource-server>
        <opaque-token introspector-ref="myCustomIntrospector"/>
    </oauth2-resource-server>
</http>

이 방식을 사용하면 권한 매핑, JWT 취소, 요청 타임아웃 등 좀 더 세세한 설정을 쉽게 바꿀 수 있다.

Exposing a OpaqueTokenIntrospector @Bean

OpaqueTokenIntrospector @Bean을 정의하는 것도 introspector()와 동일한 효과가 있다:

@Bean
public OpaqueTokenIntrospector introspector() {
    return new NimbusOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret);
}

12.3.15. Configuring Authorization

OAuth 2.0 Introspection 엔드포인트는 보통 부여한 scope(권한)를 나타내는 scope 속성을 반환한다. 예를 들어:

{ …, "scope" : "messages contacts"}

이런 경우 리소스 서버는 각 스코프에 “SCOPE_” 프리픽스를 달아 승인된 권한 리스트를 만든다.

즉, Opaque 토큰의 scope로 특정 엔드포인트나 메소드를 보호하려면, 프리픽스를 포함한 적절한 표현식을 사용해야 한다:

Example 112. Authorization Opaque Token Configuration

java xml
@EnableWebSecurity
public class MappedAuthorities extends WebSecurityConfigurerAdapter {
    protected void configure(HttpSecurity http) {
        http
            .authorizeRequests(authorizeRequests -> authorizeRequests
                .mvcMatchers("/contacts/**").hasAuthority("SCOPE_contacts")
                .mvcMatchers("/messages/**").hasAuthority("SCOPE_messages")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(OAuth2ResourceServerConfigurer::opaqueToken);
    }
}
<http>
    <intercept-uri pattern="/contacts/**" access="hasAuthority('SCOPE_contacts')"/>
    <intercept-uri pattern="/messages/**" access="hasAuthority('SCOPE_messages')"/>
    <oauth2-resource-server>
        <opaque-token introspector-ref="opaqueTokenIntrospector"/>
    </oauth2-resource-server>
</http>

메소드 시큐리티도 비슷하다:

@PreAuthorize("hasAuthority('SCOPE_messages')")
public List<Message> getMessages(...) {}

Extracting Authorities Manually

기본적으로 Opaque 토큰을 지원할 땐 introspection 응답에서 각 scope 클레임을 추출해서 GrantedAuthority 인스턴스로 파싱한다.

예를 들어 introspection 응답이 다음과 같다면:

{
    "active" : true,
    "scope" : "message:read message:write"
}

리소스 서버는 message:read, message:write 두 가지 권한을 가진 Authentication을 생성한다.

물론 OpaqueTokenIntrospector를 커스텀하면 속성 셋 중 원하는 값을 변환할 수 있다:

public class CustomAuthoritiesOpaqueTokenIntrospector implements OpaqueTokenIntrospector {
    private OpaqueTokenIntrospector delegate =
            new NimbusOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret");

    public OAuth2AuthenticatedPrincipal introspect(String token) {
        OAuth2AuthenticatedPrincipal principal = this.delegate.introspect(token);
        return new DefaultOAuth2AuthenticatedPrincipal(
                principal.getName(), principal.getAttributes(), extractAuthorities(principal));
    }

    private Collection<GrantedAuthority> extractAuthorities(OAuth2AuthenticatedPrincipal principal) {
        List<String> scopes = principal.getAttribute(OAuth2IntrospectionClaimNames.SCOPE);
        return scopes.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }
}

그 다음 간단히 커스텀 구현체를 @Bean으로 정의하면 된다:

@Bean
public OpaqueTokenIntrospector introspector() {
    return new CustomAuthoritiesOpaqueTokenIntrospector();
}

12.3.16. Configuring Timeouts

리소스 서버가 인가 서버와 통신할 때 사용하는 디폴트 커넥션 타임아웃과 소켓 타임아웃은 30초다.

필요에 따라 타임아웃을 더 길게 늘려야 할 수도 있다. 게다가 디폴트 타임아웃 설정은 back-off나 discovery 전략같은 정교한 설정은 하지 않고 있다.

리소스 서버가 인가 서버와 통신하는 방식은 NimbusOpaqueTokenIntrospectorRestOperations로 설정한다:

@Bean
public OpaqueTokenIntrospector introspector(RestTemplateBuilder builder) {
    RestOperations rest = builder
            .basicAuthentication(clientId, clientSecret)
            .setConnectionTimeout(60000)
            .setReadTimeout(60000)
            .build();

    return new NimbusOpaqueTokenIntrospector(introspectionUri, rest);
}

12.3.17. Using Introspection with JWTs

흔히들 introspection을 JWT와 사용할 수 있는지 묻곤 한다. 스프링 시큐리티의 Opaque 토큰 기능은 토큰 형식과는 상관없이 설계했다. 즉, 설정에 있는 introspection 엔드포인트엔 어떤 토큰이든 전달할 수 있다.

JWT가 취소되면 모든 요청을 인가 서버로 검증해야 하는 요구사항이 있다고 가정해보자.

토큰은 JWT 형식이더라도 검증 방법은 introspection이기 때문에 다음 설정이 필요하다:

spring:
  security:
    oauth2:
      resourceserver:
        opaque-token:
          introspection-uri: https://idp.example.org/introspection
          client-id: client
          client-secret: secret

이 경우 AuthenticationBearerTokenAuthentication이 될 것이다. 이에 해당하는 OAuth2AuthenticatedPrincipal에 있는 모든 속성은 introspection 엔드포인트가 반환한 값이다.

이번에는 이상하긴 하지만, introspection 엔드포인트가 토큰이 활성 상태인지 아닌지만 반환한다고 가정해보자. 이제 어떡할까?

이럴 때는 엔드포인트에 요청하긴 하지만 반환할 principal 속성을 JWT 클레임으로 업데이트하는 커스텀 OpaqueTokenIntrospector를 만들 수 있다:

public class JwtOpaqueTokenIntrospector implements OpaqueTokenIntrospector {
    private OpaqueTokenIntrospector delegate =
            new NimbusOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret");
    private JwtDecoder jwtDecoder = new NimbusJwtDecoder(new ParseOnlyJWTProcessor());

    public OAuth2AuthenticatedPrincipal introspect(String token) {
        OAuth2AuthenticatedPrincipal principal = this.delegate.introspect(token);
        try {
            Jwt jwt = this.jwtDecoder.decode(token);
            return new DefaultOAuth2AuthenticatedPrincipal(jwt.getClaims(), NO_AUTHORITIES);
        } catch (JwtException e) {
            throw new OAuth2IntrospectionException(e);
        }
    }

    private static class ParseOnlyJWTProcessor extends DefaultJWTProcessor<SecurityContext> {
        JWTClaimsSet process(SignedJWT jwt, SecurityContext context)
                throws JOSEException {
            return jwt.getJWTClaimSet();
        }
    }
}

그 다음 간단히 커스텀 구현체를 @Bean으로 정의하면 된다:

@Bean
public OpaqueTokenIntrospector introspector() {
    return new JwtOpaqueTokenIntropsector();
}

12.3.18. Calling a /userinfo Endpoint

일반적으로 리소스 서버는 사용자가 아닌 부여한 권한에만 신경 쓴다.

그렇긴 해도 어쩔땐 인가한 권한을 다시 사용자와 연결하는 게 유용할 때도 있다.

spring-security-oauth2-client 모듈을 사용 중이고, 어플리케이션에 적당한 ClientRegistrationRepository도 설정돼 있다면, 쉽게 커스텀 OpaqueTokenIntrospector를 만들 수 있다. 이 구현체는 다음 세 가지 일을 한다:

public class UserInfoOpaqueTokenIntrospector implements OpaqueTokenIntrospector {
    private final OpaqueTokenIntrospector delegate =
            new NimbusOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret");
    private final OAuth2UserService oauth2UserService = new DefaultOAuth2UserService();

    private final ClientRegistrationRepository repository;

    // ... constructor

    @Override
    public OAuth2AuthenticatedPrincipal introspect(String token) {
        OAuth2AuthenticatedPrincipal authorized = this.delegate.introspect(token);
        Instant issuedAt = authorized.getAttribute(ISSUED_AT);
        Instant expiresAt = authorized.getAttribute(EXPIRES_AT);
        ClientRegistration clientRegistration = this.repository.findByRegistrationId("registration-id");
        OAuth2AccessToken token = new OAuth2AccessToken(BEARER, token, issuedAt, expiresAt);
        OAuth2UserRequest oauth2UserRequest = new OAuth2UserRequest(clientRegistration, token);
        return this.oauth2UserService.loadUser(oauth2UserRequest);
    }
}

spring-security-oauth2-client 모듈을 사용하지 않아도 어렵지 않다. WebClient 인스턴스를 만들어서 /userinfo를 실행하면 된다:

public class UserInfoOpaqueTokenIntrospector implements OpaqueTokenIntrospector {
    private final OpaqueTokenIntrospector delegate =
            new NimbusOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret");
    private final WebClient rest = WebClient.create();

    @Override
    public OAuth2AuthenticatedPrincipal introspect(String token) {
        OAuth2AuthenticatedPrincipal authorized = this.delegate.introspect(token);
        return makeUserInfoRequest(authorized);
    }
}

어떤 방법을 사용했든, OpaqueTokenIntrospector를 만들었다면 @Bean으로 등록해야 디폴트 빈을 재정의한다:

@Bean
OpaqueTokenIntrospector introspector() {
    return new UserInfoOpaqueTokenIntrospector(...);
}

12.3.19. Supporting both JWT and Opaque Token

경우에 따라 두 종류의 토큰에 모두 접근해야 할 수도 있다. 예를 들어 멀티 테넌트를 지원한다면, 테넌트 하나는 JWT를 발급하고 다른 테넌트는 opaque 토큰을 발급할 수도 있다.

요청 시점에 사용할 토큰을 결정해야 한다면 다음과 같이 AuthenticationManagerResolver를 사용하면 된다:

@Bean
AuthenticationManagerResolver<HttpServletRequest> tokenAuthenticationManagerResolver() {
    BearerTokenResolver bearerToken = new DefaultBearerTokenResolver();
    JwtAuthenticationProvider jwt = jwt();
    OpaqueTokenAuthenticationProvider opaqueToken = opaqueToken();

    return request -> {
        if (useJwt(request)) {
            return jwt::authenticate;
        } else {
            return opaqueToken::authenticate;
        }
    }
}

useJwt(HttpServletRequest)는 커스텀하는 방식에 따라 요청 path를 사용하는 등으로 구현하면 된다.

그 다음은 DSL로 AuthenticationManagerResolver를 지정하는 코드다:

Example 113. Authentication Manager Resolver

java xml
http
    .authorizeRequests(authorize -> authorize
        .anyRequest().authenticated()
    )
    .oauth2ResourceServer(oauth2 -> oauth2
        .authenticationManagerResolver(this.tokenAuthenticationManagerResolver)
    );
<http>
    <oauth2-resource-server authentication-manager-resolver-ref="tokenAuthenticationManagerResolver"/>
</http>

12.3.20. Multi-tenancy

테넌트 식별자에 따라 bearer 토큰을 검증하는 전략이 다르다면 리소스 서버를 멀티 테넌트로 간주한다.

예를 들어 리소스 서버가 두 개의 다른 인가 서버에서 bearer 토큰을 받을 수도 있다. 아니면 인가 서버에 issuer가 여러 개 있을 수도 있다.

이럴 때 할 수 있는 일은 두 가지가 있으며, 선택하는 방법에 따라 장단점이 있다:

  1. 테넌트 리졸브
  2. 테넌트 전파

Resolving the Tenant By Claim

테넌트를 구별하는 한 가지 방법은 issuer 클레임이다. issuer 클레임은 서명한 JWT를 수반하므로 다음과 같이 JwtIssuerAuthenticationManagerResolver로 테넌트를 구분할 수 있다:

Example 114. Multitenancy Tenant by JWT Claim

java xml
JwtIssuerAuthenticationManagerResolver authenticationManagerResolver = new JwtIssuerAuthenticationManagerResolver
    ("https://idp.example.org/issuerOne", "https://idp.example.org/issuerTwo");

http
    .authorizeRequests(authorize -> authorize
        .anyRequest().authenticated()
    )
    .oauth2ResourceServer(oauth2 -> oauth2
        .authenticationManagerResolver(authenticationManagerResolver)
    );
<http>
    <oauth2-resource-server authentication-manager-resolver-ref="authenticationManagerResolver"/>
</http>

<bean id="authenticationManagerResolver"
        class="org.springframework.security.oauth2.server.resource.authentication.JwtIssuerAuthenticationManagerResolver">
    <constructor-arg>
        <list>
            <value>https://idp.example.org/issuerOne</value>
            <value>https://idp.example.org/issuerTwo</value>
        </list>
    </constructor-arg>
</bean>

이 방법은 issuer 엔드포인트를 lazy 방식으로 로드한다는 장점이 있다. 실제로 JwtAuthenticationProvider 인스턴스는 해당하는 issuer가 최초 요청을 받아야만 만든다. 따라서 인가 서버의 기동 여부나 가용성과는 상관 없이 어플리케이션을 기동할 수 있다.

Dynamic Tenants

물론 새 테넌트를 추가할 때마다 어플리케이션을 재기동시키는 게 싫을 수도 있다. 이런 경우엔 JwtIssuerAuthenticationManagerResolver를, 런타임에 수정할 수 있는 AuthenticationManager 인스턴스 저장소와 함께 설정하면 된다:

private void addManager(Map<String, AuthenticationManager> authenticationManagers, String issuer) {
    JwtAuthenticationProvider authenticationProvider = new JwtAuthenticationProvider
            (JwtDecoders.fromIssuerLocation(issuer));
    authenticationManagers.put(issuer, authenticationProvider::authenticate);
}

// ...

JwtIssuerAuthenticationManagerResolver authenticationManagerResolver =
        new JwtIssuerAuthenticationManagerResolver(authenticationManagers::get);

http
    .authorizeRequests(authorize -> authorize
        .anyRequest().authenticated()
    )
    .oauth2ResourceServer(oauth2 -> oauth2
        .authenticationManagerResolver(authenticationManagerResolver)
    );

여기선 JwtIssuerAuthenticationManagerResolver에 issuer에 따라 AuthenticationManager를 선택하는 전략을 설정한다. 이렇게 하면 런타임에 저장소에서 (위 코드에선 Map으로 나타냄) 요소를 추가하고 제거할 수 있다.

단순히 issuer를 가져와서 바로 AuthenticationManager를 구성하는 건 안전한 방법이 아니다. issuer는 화이트리스트같은 신뢰할 수 있는 출처에서 가져오고, 코드에서 이를 검증할 수 있어야 한다.

Parsing the Claim Only Once

이 전략은 간단하지만, JWT를 AuthenticationManagerResolver에서 한 번 파싱하고, 요청이 들어오면 또다시 JwtDecoder로 파싱한다는 사실을 눈치챘을 것이다.

Nimbus의 JWTClaimSetAwareJWSKeySelector에 직접 JwtDecoder를 설정하면 두 번 파싱하지 않아도 된다:

@Component
public class TenantJWSKeySelector
    implements JWTClaimSetAwareJWSKeySelector<SecurityContext> {

    private final TenantRepository tenants; // (1)
    private final Map<String, JWSKeySelector<SecurityContext>> selectors = new ConcurrentHashMap<>(); // (2)

    public TenantJWSKeySelector(TenantRepository tenants) {
        this.tenants = tenants;
    }

    @Override
    public List<? extends Key> selectKeys(JWSHeader jwsHeader, JWTClaimsSet jwtClaimsSet, SecurityContext securityContext)
            throws KeySourceException {
        return this.selectors.computeIfAbsent(toTenant(jwtClaimsSet), this::fromTenant)
                .selectJWSKeys(jwsHeader, securityContext);
    }

    private String toTenant(JWTClaimsSet claimSet) {
        return (String) claimSet.getClaim("iss");
    }

    private JWSKeySelector<SecurityContext> fromTenant(String tenant) {
        return Optional.ofNullable(this.tenantRepository.findById(tenant)) // (3)
                .map(t -> t.getAttrbute("jwks_uri"))
                .map(this::fromUri)
                .orElseThrow(() -> new IllegalArgumentException("unknown tenant"));
    }

    private JWSKeySelector<SecurityContext> fromUri(String uri) {
        try {
            return JWSAlgorithmFamilyJWSKeySelector.fromJWKSetURL(new URL(uri)); // (4)
        } catch (Exception e) {
            throw new IllegalArgumentException(e);
        }
    }
}

(1) 테넌트 정보의 가상 소스
(2) 테넌트 식별자를 키로 사용하는 JWKKeySelector 캐시
(3) JWK 셋 엔드포인트를 그때그때 만드는 것보다 이렇게 테넌트를 검색하는 게 더 안전하다 - 이렇게 테넌트 화이트 리스트를 관리한다
(4) JWK 셋 엔드포인트에서 보낸 키 타입으로 JWSKeySelector를 만든다 - 여기선 lazy로 테넌트를 검색하기 때문에 기동 시점에 모든 테넌트를 설정할 필요가 없다

위에선 key selector를 여러 개 구성하고 있다. JWT의 iss 클레임에 따라 사용할 key selector를 선택한다.

이 방식을 사용하려면 인가 서버의 토큰 signature에 클레임 셋이 있어야 한다. 클레임 셋이 없으면 issuer의 변조 여부를 보장할 수 없다.

그다음은 JWTProcessor를 만든다:

@Bean
JWTProcessor jwtProcessor(JWTClaimSetJWSKeySelector keySelector) {
    ConfigurableJWTProcessor<SecurityContext> jwtProcessor =
            new DefaultJWTProcessor();
    jwtProcessor.setJWTClaimSetJWSKeySelector(keySelector);
    return jwtProcessor;
}

이미 느끼고 있겠지만, 테넌트를 이 시점에 인식하게 만들려면 그만큼의 설정이 더 필요하다. 이제 거의 다 왔다.

다음 단계는 issuer 검증이다. issuer는 JWT마다 다를 수 있기 때문에 validator에서도 테넌트를 구분해야 한다:

@Component
public class TenantJwtIssuerValidator implements OAuth2TokenValidator<Jwt> {
    private final TenantRepository tenants;
    private final Map<String, JwtIssuerValidator> validators = new ConcurrentHashMap<>();

    public TenantJwtIssuerValidator(TenantRepository tenants) {
        this.tenants = tenants;
    }

    @Override
    public OAuth2TokenValidatorResult validate(Jwt token) {
        return this.validators.computeIfAbsent(toTenant(token), this::fromTenant)
                .validate(token);
    }

    private String toTenant(Jwt jwt) {
        return jwt.getIssuer();
    }

    private JwtIssuerValidator fromTenant(String tenant) {
        return Optional.ofNullable(this.tenants.findById(tenant))
                .map(t -> t.getAttribute("issuer"))
                .map(JwtIssuerValidator::new)
                .orElseThrow(() -> new IllegalArgumentException("unknown tenant"));
    }
}

이제 테넌트를 인식할 수 있는 processor와 validator가 있으므로, JwtDecoder를 만들면 된다:

@Bean
JwtDecoder jwtDecoder(JWTProcessor jwtProcessor, OAuth2TokenValidator<Jwt> jwtValidator) {
    NimbusJwtDecoder decoder = new NimbusJwtDecoder(processor);
    OAuth2TokenValidator<Jwt> validator = new DelegatingOAuth2TokenValidator<>
            (JwtValidators.createDefault(), this.jwtValidator);
    decoder.setJwtValidator(validator);
    return decoder;
}

테넌트 리졸브에 대한 설명은 여기서 마친다.

JWT 클레임이 아닌 다른 방법으로 테넌트를 리졸브해야 한다면, 다운스트림 리소스 서버도 동일한 방식으로 처리해야 한다. 예를 들어, 서브 도메인을 기준으로 리졸브한다면, 다운스트림 리소스 서버를 처리할 때도 동일한 서브 도메인을 사용해야 한다.

bearer 토큰에 있는 클레임으로 리졸브한다면 스프링 시큐리티에서 bearer 토큰을 전파하는 법을 읽어봐라.

12.3.21. Bearer Token Resolution

기본적으로 리소스 서버는 Authorization 헤더 안에서 bearer 토큰을 찾는다. 하지만 이것도 여러 가지 방법으로 커스텀할 수 있다.

Reading the Bearer Token from a Custom Header

예를 들어 커스텀 헤더에서 bearer 토큰을 읽어야 할 수 있다. 이땐 다음 예제처럼 DSL로 HeaderBearerTokenResolver 인스턴스를 지정하면 된다.

Example 115. Custom Bearer Token Header

java xml
http
    .oauth2ResourceServer(oauth2 -> oauth2
        .bearerTokenResolver(new HeaderBearerTokenResolver("x-goog-iap-jwt-assertion"))
    );
<http>
    <oauth2-resource-server bearer-token-resolver-ref="bearerTokenResolver"/>
</http>

<bean id="bearerTokenResolver"
        class="org.springframework.security.oauth2.server.resource.web.HeaderBearerTokenResolver">
    <constructor-arg value="x-goog-iap-jwt-assertion"/>
</bean>

Reading the Bearer Token from a Form Parameter

아니면 폼 파라미터에서 토큰을 조회해야 할 수도 있다. 이때는 아래 처럼 DefaultBearerTokenResolver를 설정할 수 있다:

Example 116. Form Parameter Bearer Token

java xml
DefaultBearerTokenResolver resolver = new DefaultBearerTokenResolver();
resolver.setAllowFormEncodedBodyParameter(true);
http
    .oauth2ResourceServer(oauth2 -> oauth2
        .bearerTokenResolver(resolver)
    );
<http>
    <oauth2-resource-server bearer-token-resolver-ref="bearerTokenResolver"/>
</http>

<bean id="bearerTokenResolver"
        class="org.springframework.security.oauth2.server.resource.web.HeaderBearerTokenResolver">
    <property name="allowFormEncodedBodyParameter" value="true"/>
</bean>

12.3.22. Bearer Token Propagation

이제 리소스 서버가 토큰을 검증했으므로, 다운스트림 서비스로 편하게 넘겨도 된다. 다음 예제처럼 ServletBearerExchangeFilterFunction을 사용하면 매우 간단해진다.

@Bean
public WebClient rest() {
    return WebClient.builder()
            .filter(new ServletBearerExchangeFilterFunction())
            .build();
}

위에 있는 WebClient로 요청을 수행하면 스프링 시큐리티는 현재 Authentication을 조회하고 AbstractOAuth2Token credential을 추출한다. 그런 다음 이 토큰을 Authorization 헤더에 전파한다.

예를 들어:

this.rest.get()
        .uri("https://other-service.example.com/endpoint")
        .retrieve()
        .bodyToMono(String.class)
        .block()

이 코드는 https://other-service.example.com/endpoint에 요청을 보내며, beaer 토큰 Authorization 헤더를 추가한다.

이 동작을 재정의하고 싶다면, 아래처럼 직접 헤더를 지정하기만 하면 된다:

this.rest.get()
        .uri("https://other-service.example.com/endpoint")
        .headers(headers -> headers.setBearerAuth(overridingToken))
        .retrieve()
        .bodyToMono(String.class)
        .block()

이 경우 이 필터는 폴백되고 나머지 웹 필터 체인으로 요청을 전달한다.

이 필터는 OAuth 2.0 클라이언트 필터 펑션과는 달리, 토큰이 만료돼도 갱신하지 않는다. 이 기능이 필요하다면 OAuth 2.0 클라이언트 필터를 사용해라.

RestTemplate support

현재는 ServletBearerExchangeFilterFunction에 해당하는 RestTemplate은 없지만 자체 인터셉터로도 간단하게 요청에 bearer 토큰을 전파할 수 있다:

@Bean
RestTemplate rest() {
    RestTemplate rest = new RestTemplate();
    rest.getInterceptors().add((request, body, execution) -> {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null) {
            return execution.execute(request, body);
        }

        if (!(authentication.getCredentials() instanceof AbstractOAuth2Token)) {
            return execution.execute(request, body);
        }

        AbstractOAuth2Token token = (AbstractOAuth2Token) authentication.getCredentials();
        request.getHeaders().setBearerAuth(token.getTokenValue());
        return execution.execute(request, body);
    });
    return rest;
}

이 필터는 OAuth2AuthorizedClientManager와는 달리, 토큰이 만료돼도 갱신하지 않는다. 이 기능이 필요하다면 OAuth2AuthorizedClientManager를 사용해라.

12.3.23. Bearer Token Failure

bearer 토큰은 여러 가지 이유로 유효하지 않을 수 있다. 예를 들어 더 이상 활성 상태가 아닐 수도 있다.

리소스 서버는 이런 상황에선 InvalidBearerTokenException을 던진다. 이땐 다른 exception과 마찬가지로 OAuth 2.0 Bearer 토큰 에러 응답을 반환한다.

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error_code="invalid_token", error_description="Unsupported algorithm of none", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"

추가로 AuthenticationFailureBadCredentialsEvent를 발행하므로, 아래처럼 어플리케이션에서 이 이벤트를 수신할 수 있다.

@Component
public class FailureEvents {
    @EventListener
    public void onFailure(AuthenticationFailureEvent failure) {
        if (badCredentials.getAuthentication() instanceof BearerTokenAuthenticationToken) {
            // ... handle
        }
    }
}

Next :
SAML2
서블릿 기반 어플리케이션에서 스프링 시큐리티로 saml2를 적용하는 방법을 설명합니다. 공식 문서에 있는 "saml2" 챕터를 한글로 번역한 문서입니다.

전체 목차는 여기에 있습니다.

<< >>

TOP