스프링 시큐리티 공식 레퍼런스를 한글로 번역한 문서입니다.
전체 목차는 여기에 있습니다.
목차:
- 12.1. OAuth 2.0 Login
- 12.2. OAuth 2.0 Client
- 12.3. OAuth 2.0 Resource Server
- 12.3.1. Dependencies
- 12.3.2. Minimal Configuration for JWTs
- 12.3.3. Specifying the Authorization Server JWK Set Uri Directly
- 12.3.4. Overriding or Replacing Boot Auto Configuration
- 12.3.5. Configuring Trusted Algorithms
- 12.3.6. Trusting a Single Asymmetric Key
- 12.3.7. Trusting a Single Symmetric Key
- 12.3.8. Configuring Authorization
- 12.3.9. Configuring Validation
- 12.3.10. Configuring Claim Set Mapping
- 12.3.11. Configuring Timeouts
- 12.3.12. Minimal Configuration for Introspection
- 12.3.13. Looking Up Attributes Post-Authentication
- 12.3.14. Overriding or Replacing Boot Auto Configuration
- 12.3.15. Configuring Authorization
- 12.3.16. Configuring Timeouts
- 12.3.17. Using Introspection with JWTs
- 12.3.18. Calling a /userinfo Endpoint
- 12.3.19. Supporting both JWT and Opaque Token
- 12.3.20. Multi-tenancy
- 12.3.21. Bearer Token Resolution
- 12.3.22. Bearer Token Propagation
- 12.3.23. Bearer Token Failure
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}
다. registrationId는 ClientRegistration을 식별하는 유니크한 값이다.
OAuth 클라이언트 앞단에 프록시 서버를 둔다면 어플리케이션 설정에 문제가 없도록 프록시 서버 설정을 확인해보길 권한다.
redirect-uri
에 사용할 수 있는URI
템플릿 변수도 참고하면 좋다.
Configure application.yml
이제 구글의 새 OAuth 클라이언트가 준비됐음으로, 어플리케이션의 인증 플로우에서 이 OAuth 클라이언트를 사용하도록 설정해줘야 한다. 이를 위해선:
-
1.
application.yml
로 가서 다음 설정을 변경해라:spring: security: oauth2: client: registration: # (1) google: # (2) client-id: google-client-id client-secret: google-client-secret
Example 80. OAuth Client properties
(1)spring.security.oauth2.client.registration
은 Oauth 클라이언트 프로퍼티의 기본 프리픽스다.
(2) 기본 프리픽스 뒤에는 구글 같은 ClientRegistration ID가 온다. -
2.
client-id
,client-secret
프로퍼티를 앞에서 만든 OAuth 2.0 credential로 변경해라.
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
(CommonOAuth2Provider
에 있는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) registrationId
를 google-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
이다.
이 클래스는 다음과 같은 일을 한다:
- 설정한 OAuth 클라이언트 프로퍼티로
ClientRegistration
을 가지고 있는 (여러 개 가능)ClientRegistrationRepository
@Bean
을 등록한다. WebSecurityConfigurerAdapter
@Configuration
을 제공하고httpSecurity.oauth2Login()
으로 OAuth 2.0 로그인을 활성화한다.
이 자동 설정을 요구사항에 따라 재정의하고 싶다면 다음 방법을 사용해라:
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
다음 예제는 @EnableWebSecurity
로 WebSecurityConfigurerAdapter
를 제공하고, httpSecurity.oauth2Login()
메소드로 OAuth 2.0 로그인을 활성화하는 방법을 보여준다:
Example 81. OAuth2 Login Configuration
@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
@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
@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
@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 리소스)를 사용한다:
- 인가 엔드포인트: 클라이언트가 사용하며, user-agent 리다이렉트를 통해 리소스 소유자에게 인가를 요청한다.
- 토큰 엔드포인트: 클라이언트가 사용하며, 전형적인 클라이언트 인증과 함께, 인가 코드를 액세스 토큰으로 바꾼다.
또한 클라이언트 엔드포인트를 하나 사용한다:
- 리다이렉션 엔드포인트: 인가 서버가 사용하며, 리소스 소유자의 user-agent를 통해서 클라이언트에게 인가 credential을 포함한 응답을 보낸다.
OpenID Connect Core 1.0 스펙은 UserInfo 엔드포인트를 다음과 같이 정의한다:
UserInfo 엔드포인트는 인증된 최종 사용자에 대한 클레임을 리턴하는, OAuth 2.0으로 보호하는 리소스다. 사용자에 대한 요청 클레임을 가져오려면, 클라이언트는 OpenID Connect 인증으로 가져온 액세스 토큰을 사용해서 UserInfo 엔드포인트로 요청해야 한다. 보통 클레임의 name-value 쌍을 컬렉션으로 가지고 있는 JSON 객체로 클레임을 표현한다.
다음은 oauth2Login()
DSL로 설정할 수 있는 모든 옵션을 보여주는 예제다:
Example 85. OAuth2 Login Configuration Options
@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
@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
@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
@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
@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
와 비교하면 조금 어렵지만, OAuth2UserRequest
와 OAuth2User
나 (OAuth 2.0 UserService를 사용할 때), OidcUserRequest
와 OidcUser
에 (OpenID Connect 1.0 UserService를 사용할 때) 접근할 수 있으므로 좀 더 유연한 방식이다.
OAuth2UserRequest
는 (OidcUserRequest
도 마찬가지) 관련 OAuth2AccessToken
에 접근할 수 있으므로, delegator가 사용자의 커스텀 권한을 매핑하기 전에 토큰이 필요한 권한 정보를 가져와야 할 때 매우 유용하다.
다음 예제는 OpenID Connect 1.0 UserService를 사용하는 위임 전략을 설정하는 방법을 보여준다:
Example 91. OAuth2UserService Configuration
@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
,
OAuth 2.0 UserService
DefaultOAuth2UserService
는 표준 OAuth 2.0 Provider를 지원하는 OAuth2UserService
구현체다.
OAuth2UserService
는 UserInfo 엔드포인트에서 (인가 플로우에서 클라이언트에 부여한 액세스 토큰으로) 최종 사용자의 (리소스 소유자) 속성을 가져오며,OAuth2User
타입의AuthenticatedPrincipal
을 리턴한다.
DefaultOAuth2UserService
는 RestOperations
로 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
구현체다.
OidcUserService
는 DefaultOAuth2UserService
로 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)로 서명해야 한다.
OidcIdTokenDecoderFactory
는 OidcIdToken
서명을 검증할 때 사용하는 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
정보를 가져올 수 있다. 이땐 다음과 같이 ClientRegistration
에 issuer-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 클라이언트 지원
- 서블릿 환경에서
WebClient
통합 (보호 중인 리소스를 요청할 때)
HttpSecurity.oauth2Client()
DSL은 OAuth 2.0 클라이언트에서 사용하는 핵심 컴포넌트를 커스텀할 다양한 설정 옵션을 제공한다. 인가 코드 부여 (Authorization Code grant) 관련 동작은 HttpSecurity.oauth2Client().authorizationCodeGrant()
로 커스텀한다.
다음은 HttpSecurity.oauth2Client()
DSL로 설정할 수 있는 모든 옵션을 보여주는 예제다:
Example 92. OAuth2 Client Configuration Options
@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
다.
자동 설정을 사용하면 ClientRegistrationRepository
도 ApplicationContext
내 @Bean
으로 등록하므로 필요하다면 원하는 곳에 의존성을 주입할 수 있다.
다음은 의존성 주입 예시이다:
@Controller
public class OAuth2ClientController {
@Autowired
private ClientRegistrationRepository clientRegistrationRepository;
@GetMapping("/")
public String index() {
ClientRegistration oktaRegistration =
this.clientRegistrationRepository.findByRegistrationId("okta");
...
return "index";
}
}
OAuth2AuthorizedClient
OAuth2AuthorizedClient
는 인가받은 클라이언트를 의미하는 클래스다. 최종 사용자(리소스 소유자)가 클라이언트에게 보호 중인 리소스에 접근할 수 있는 권한을 부여하면, 클라이언트를 인가된 클라이언트로 간주한다.
OAuth2AuthorizedClient
는 OAuth2AccessToken
을 (필수는 아니지만 OAuth2RefreshToken
도) ClientRegistration
(클라이언트)과 리소스 소유자, 즉 권한을 부여한 최종 사용자 Principal
과 함께 묶어 준다.
OAuth2AuthorizedClientRepository / OAuth2AuthorizedClientService
OAuth2AuthorizedClientRepository
는 다른 웹 요청이 와도 동일한 OAuth2AuthorizedClient
를 유지하는 역할을 담당한다. 반면 OAuth2AuthorizedClientService
의 일차적인 역할은 어플리케이션 레벨에서 OAuth2AuthorizedClient
를 관리하는 일이다.
개발자 관점에서 생각하면, OAuth2AuthorizedClientRepository
나 OAuth2AuthorizedClientService
는 클라이언트와 관련있는 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
을 등록한다. 하지만 원한다면 커스텀OAuth2AuthorizedClientRepository
나OAuth2AuthorizedClientService
를 구현해서@Bean
으로 등록할 수 있다.
OAuth2AuthorizedClientService
의 디폴트 구현체는 메모리에 OAuth2AuthorizedClient
를 저장하는 InMemoryOAuth2AuthorizedClientService
다.
OAuth2AuthorizedClient
를 데이터베이스에 저장하고 싶다면 JDBC 구현체 JdbcOAuth2AuthorizedClientService
를 설정하면 된다.
JdbcOAuth2AuthorizedClientService
는 OAuth 2.0 클라이언트 스키마에 정의된 테이블을 사용한다.
OAuth2AuthorizedClientManager / OAuth2AuthorizedClientProvider
OAuth2AuthorizedClientManager
는 OAuth2AuthorizedClient
를 전반적으로 관리하는 인터페이스다.
주로 담당하는 일은 다음과 같다:
OAuth2AuthorizedClientProvider
로 OAuth 2.0 클라이언트에 권한 부여 (또는 재부여).- 전형적으로
OAuth2AuthorizedClientService
나OAuth2AuthorizedClientRepository
에OAuth2AuthorizedClient
저장을 위임 - OAuth 2.0 클라이언트에 권한을 부여(또는 재부여)하면
OAuth2AuthorizationSuccessHandler
로 위임. - OAuth 2.0 클라이언트에 권한을 부여(또는 재부여)하지 못하면
OAuth2AuthorizationFailureHandler
에 위임.
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
로 위임한다. 이 핸들러는 (디폴트) OAuth2AuthorizedClientRepository
로 OAuth2AuthorizedClient
를 저장한다. 리프레시 토큰이 유효하지 않는 등의 이유로 재인가에 실패하면, RemoveAuthorizedClientOAuth2AuthorizationFailureHandler
가 OAuth2AuthorizedClientRepository
에 저장된 OAuth2AuthorizedClient
를 삭제한다. 이 동작은 setAuthorizationSuccessHandler(OAuth2AuthorizationSuccessHandler)
와 setAuthorizationFailureHandler(OAuth2AuthorizationFailureHandler)
로 커스텀할 수 있다.
DefaultOAuth2AuthorizedClientManager
는 Function<OAuth2AuthorizeRequest, Map<String, Object>>
타입의 contextAttributesMapper
도 사용한다. 이 매퍼는 OAuth2AuthorizeRequest
에 있는 속성들을 Map
에 매핑한다. 매핑한 값은 OAuth2AuthorizationContext
에 담긴다. OAuth2AuthorizedClientProvider
에 특정 (지원하는) 속성을 제공해야 할 때 유용하다. 예를 들어 PasswordOAuth2AuthorizedClientProvider
는 OAuth2AuthorizationContext.getAttributes()
로 리소스 소유자의 username
과 password
를 가져와야 한다.
다음은 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;
};
}
DefaultOAuth2AuthorizedClientManager
는 HttpServletRequest
컨텍스트 범위 안에서 사용하도록 설계했다. 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
OAuth2AuthorizationRequestRedirectFilter
는 OAuth2AuthorizationRequestResolver
로 OAuth2AuthorizationRequest
를 리졸브하며, 인가 서버의 인가 엔드포인트로 최종 사용자의 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를 사용한다:
client-secret
을 생략한 경우 (비어있는 경우도 포함)client-authentication-method
를 “none”으로 설정한 경우 (ClientAuthenticationMethod.NONE
)
DefaultOAuth2AuthorizationRequestResolver
는 UriComponentsBuilder
로 redirect-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
포맷을 사용하는 모든 쿼리 파라미터를 포함하는 인가 요청 URIOAuth2AuthorizationRequest.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
의 디폴트 구현체는 HttpSession
에 OAuth2AuthorizationRequest
를 저장하는 HttpSessionOAuth2AuthorizationRequestRepository
다.
직접 구현한 AuthorizationRequestRepository
는 다음 예제처럼 설정할 수 있다:
Example 94. AuthorizationRequestRepository Configuration
@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<?>>
를 설정하면 된다. 디폴트 구현체 OAuth2AuthorizationCodeGrantRequestEntityConverter
는 RequestEntity
로 표준 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
@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<?>>
를 설정하면 된다. 디폴트 구현체 OAuth2RefreshTokenGrantRequestEntityConverter
는 RequestEntity
로 표준 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<?>>
를 설정하면 된다. 디폴트 구현체 OAuth2ClientCredentialsGrantRequestEntityConverter
는 RequestEntity
로 표준 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";
}
}
HttpServletRequest
와HttpServletResponse
속성은 모두 선택사항이다. 지정하지 않으면 디폴트로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<?>>
를 설정하면 된다. 디폴트 구현체 OAuth2PasswordGrantRequestEntityConverter
는 RequestEntity
로 표준 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";
}
}
HttpServletRequest
와HttpServletResponse
속성은 모두 선택사항이다. 지정하지 않으면 디폴트로RequestContextHolder.getRequestAttributes()
의ServletRequestAttributes
를 사용한다.
12.2.3. Additional Features
Resolving an Authorized Client
@RegisteredOAuth2AuthorizedClient
어노테이션은 파라미터를 OAuth2AuthorizedClient
타입 인자로 리졸브해준다. 이 방법은 OAuth2AuthorizedClientManager
나 OAuth2AuthorizedClientService
로 OAuth2AuthorizedClient
에 접근하는 것보다 편리하다.
@Controller
public class OAuth2ClientController {
@GetMapping("/")
public String index(@RegisteredOAuth2AuthorizedClient("okta") OAuth2AuthorizedClient authorizedClient) {
OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
...
return "index";
}
}
@RegisteredOAuth2AuthorizedClient
는 OAuth2AuthorizedClientArgumentResolver
가 처리한다. 이 리졸버는 OAuth2AuthorizedClientManager를 직접 사용하기 때문에 매니저의 모든 기능을 사용할 수 있다.
12.2.4. WebClient integration for Servlet Environments
OAuth 2.0 클라이언트 기능과 WebClient
는 ExchangeFilterFunction
으로 통합한다.
ServletOAuth2AuthorizedClientExchangeFilterFunction
은 OAuth2AuthorizedClient
를 사용해서 보호 중인 리소스를 요청하는 간단한 메커니즘을 제공하며, OAuth2AccessToken
을 Bearer 토큰으로 요청에 추가해 준다. 이 펑션은 OAuth2AuthorizedClientManager를 직접 사용하기 때문에 매니저의 모든 기능을 사용할 수 있다.
- 클라이언트가 아직 권한을 부여받지 않았으면
OAuth2AccessToken
을 요청한다.authorization_code
- 플로우를 시작하는 인가 요청 리다이렉트를 트리거한다client_credentials
- 토큰 엔드포인트에서 직접 액세스 토큰을 가져온다password
- 토큰 엔드포인트에서 직접 액세스 토큰을 가져온다
OAuth2AccessToken
이 만료됐다면, 인가를 수행할OAuth2AuthorizedClientProvider
가 있으면 갱신한다.
다음은 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
ServletOAuth2AuthorizedClientExchangeFilterFunction
은 ClientRequest.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 요청이 액세스 토큰을 받으므로 이 기능은 주의해서 사용하는 것이 좋다.
혹은 유효한 ClientRegistration
과 setDefaultClientRegistrationId("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 토큰 두 종류로 엔드포인트를 보호해 준다:
- JWT
- Opaque 토큰
이 기능은 어플리케이션의 권한 관리를 별도 인가 서버에 (ex. Okta, Ping Identity) 위임하는 경우에 사용할 수 있다. 리소스 서버는 요청을 인가할 때 이 인가 서버에 물어볼 수 있다.
스프링 시큐리티 레포지토리엔 JWT와 Opaque 토큰을 사용한 실전 예제가 모두 있다.
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 토큰을 검증하는 리소스 서버가 자동으로 설정된다.
결정적으로 기동 시점에 아래와 같은 처리를 하기 때문이다:
- Provider 설정 엔드포인트 또는 인가 서버 메타데이터 엔드포인트를 찔러서 응답으로
jwks_url
프로퍼티를 처리한다. jwks_url
에 유효한 공개키를 질의하기 위한 검증 전략을 설정한다.https://idp.example.com
에 대한 각 JWTiss
클레임을 검증할 전략을 설정한다.
이 프로세스대로 리소스 서버를 기동하려면 반드시 인가 서버가 기동돼서 요청을 받을 수 있는 상태여야 한다.
리소스 서버가 질의할 때 인가 서버가 다운돼 있으면 (적절한 타임아웃이 있으면) 기동에 실패한다.
Runtime Expectations
어플리케이션이 기동되고 나면, 리소스 서버는 Authorization: Bearer
헤더를 포함한 모든 요청을 처리한다:
GET / HTTP/1.1
Authorization: Bearer some-token-value # Resource Server will process this
이 스킴만 명시하면 리소스 서버는 Bearer 토큰 스펙에 따라 요청을 처리한다.
JWT 형식에 이상이 없으면 리소스 서버는:
- 기동 시
jwks_url
엔드포인트에서 가져와 JWT에 매칭한 공개키로 서명을 검증한다. - JWT에 있는
exp
,nbf
타임스탬프,iss
클레임을 검증하고, - 각 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
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
@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 메소드로도 자동 설정을 재정의하거나 아예 바꿔버릴 수 있다.
예를 들어 스프링 부트가 생성하는 두 번째 @Bean
은 JwtDecoder
인데, 이 빈은 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
@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
@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(권한)를 나타내는 scope
나 scp
속성이 있다. 예를 들어:
{ …, "scope" : "messages contacts"}
이런 경우 리소스 서버는 각 스코프에 “SCOPE_” 프리픽스를 달아 승인된 권한 리스트를 만든다.
즉, JWT의 scope로 특정 엔드포인트나 메소드를 보호하려면, 프리픽스를 포함한 적절한 표현식을 사용해야 한다:
Example 103. Authorization Configuration
@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()
메소드가 있다. 이 메소드로는 Jwt
를 Authentication
으로 변환하는 컨버터를 설정한다.
먼저, Jwt
를 승인된 권한 Collection
으로 변환하는 하위 컨버터를 설정할 수 있다. 인가 서버가 authorities
란 커스텀 클레임을 사용한다고 가정해보자. 이 경우 JwtAuthenticationConverter
가 변환해야 하는 클레임을 다음과 같이 설정할 수 있다:
Example 104. Authorities Claim Configuration
@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
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 전략같은 정교한 설정은 하지 않고 있다.
리소스 서버가 인가 서버와 통신하는 방식은 NimbusJwtDecoder
의 RestOperations
로 설정한다:
@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-id
와 client-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 토큰이 있으면 리소스 서버는:
- credential과 토큰으로 설정에 있는 introspection 엔드포인트에 질의한다.
- 응답에서
{ 'active' : true }
속성을 찾는다. - 각 scope를
SCOPE_
프리픽스를 달아 권한에 매핑한다.
기본적으로 Authentication#getPrincipal
결과는 스프링 시큐리티의 OAuth2AuthenticatedPrincipal
객체이며, Authentication#getName
은 토큰의 sub
프로퍼티 값이 있으면 이 값을 사용한다.
여기서부턴 바로 다음 챕터로 넘어가도 좋다:
12.3.13. Looking Up Attributes Post-Authentication
토큰을 인증하고 나면 SecurityContext
에 BearerTokenAuthentication
이 세팅된다.
즉, 설정에 @EnableWebMvc
를 추가한 @Controller
메소드에서 이 값을 사용할 수 있다:
@GetMapping("/foo")
public String foo(BearerTokenAuthentication authentication) {
return authentication.getTokenAttributes().get("sub") + " is the subject";
}
BearerTokenAuthentication
엔 OAuth2AuthenticatedPrincipal
이 있기 때문에 이 값도 컨트롤러 메소드에서 사용할 수 있다:
@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
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
@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 메소드로도 자동 설정을 재정의하거나 아예 바꿔버릴 수 있다.
예를 들어 스프링 부트가 생성하는 두 번째 @Bean
은 OpaqueTokenIntrospector
인데, 이 빈은 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
@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
@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
@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 전략같은 정교한 설정은 하지 않고 있다.
리소스 서버가 인가 서버와 통신하는 방식은 NimbusOpaqueTokenIntrospector
의 RestOperations
로 설정한다:
@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
이 경우 Authentication
은 BearerTokenAuthentication
이 될 것이다. 이에 해당하는 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
를 만들 수 있다. 이 구현체는 다음 세 가지 일을 한다:
- introspection 엔드포인트에 토큰의 유효성 검증을 위임한다
/userinfo
엔드포인트와 관련있는 적절한 클라이언트 등록 정보를 검색한다/userinfo
엔드포인트를 실행해서 결과를 반환한다
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
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가 여러 개 있을 수도 있다.
이럴 때 할 수 있는 일은 두 가지가 있으며, 선택하는 방법에 따라 장단점이 있다:
- 테넌트 리졸브
- 테넌트 전파
Resolving the Tenant By Claim
테넌트를 구별하는 한 가지 방법은 issuer 클레임이다. issuer 클레임은 서명한 JWT를 수반하므로 다음과 같이 JwtIssuerAuthenticationManagerResolver
로 테넌트를 구분할 수 있다:
Example 114. Multitenancy Tenant by JWT Claim
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
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
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" 챕터를 한글로 번역한 문서입니다.
전체 목차는 여기에 있습니다.