OAuth 2.0 Authorization Framework 에 설명되어 있는 OAuth 프로토콜을 구성하는 4가지 역할은 아래와 같다.
| 역할 | 설명 |
|---|---|
| resource owner | 보호된 리소스에 대한 접근 권한을 부여할 수 있는 엔티티, 즉 사용자 |
| resource server | 보호된 리소스를 호스팅하는 서버, access token을 이용하여 리소스 요청을 수락하고 응답가능 |
| client | resource owner를 대신하여 해당 권한을 이용하여 보호된 리소스 요청을 하는 애플리케이션 |
| authorization server | resource owner 인증을 통해 권한을 획득한 후, client에게 access token을 발행하는 서버 |
즉, 일반적인 OAuth2 로그인의 작업흐름은 아래와 같다
- client가 authorization server로 인증 요청
- authorization server에서 로그인 폼 출력
- client가 authorization server로 로그인 요청
- authorization server에서 인증 후 access token 발행
- client가 access token을 이용하여 resource server로 리소스 요청
스프링 시큐리티 OAuth 2.0은 client와 resource server를 지원한다.
authorization server 또한 Spring 에서 지원하지만, 스프링 시큐리티 기반의 별도의 프로젝트 형태로 제공한다.

스프링 시큐리티에서 OAuth2 소셜 Login 기능을 이용하기 위해서는 몇가지 작업이 필요하다.
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
spring:
security:
oauth2:
client:
registration:
google:
client-id: google-client-id // 2. 에서 생성
client-secret: google-client-secret // 2. 에서 생성
{baseUrl}/login/oauth2/code/{registrationId}다
이후, 애플리케이션에 접근하면 Google 로그인 페이지로 redirect된다
해당 페이지에서는 Resource Owner인 사용자에게서 접근권한을 허용받는 작업이 이뤄진다.

기본으로 사용되는 authorizationGrantType이 authorization_code이기 때문에 /login/oauth2/code/google로 redirect되어 인가코드가 반환된다. 이후, 해당 인가코드를 이용하여 access token을 발급받는다.
아래 소개하는 property 들은 ClientRegistration 인스턴스 생성에 사용되는 각 정보들에 매칭된다.
| Spring Boot | 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 | redirectUri |
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].issuer-uri | providerDetails.issuerUri |
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 |
Google, GitHub, Facebook, Okta 등 잘 알려진 Provider들의 기본 property 구성들이 저장된 enum이다.
이 곳에 저장되어 property들은 따로 yml에서 건들지 않아도 된다.
public enum CommonOAuth2Provider {
GOOGLE {
@Override
public Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.CLIENT_SECRET_BASIC, DEFAULT_REDIRECT_URL);
builder.scope("openid", "profile", "email");
builder.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth");
builder.tokenUri("https://www.googleapis.com/oauth2/v4/token");
builder.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs");
builder.issuerUri("https://accounts.google.com");
builder.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo");
builder.userNameAttributeName(IdTokenClaimNames.SUB);
builder.clientName("Google");
return builder;
}
}, // ...
예를들어, Google의 기본 구성은 위와 같다.
yml파일에 작성한 spring.security.oauth2.client.registration.[registrationId]의 registrationId에 따라 정해지며, 만약 별도의 registrationId를 사용한다면 [registrationId].provider에 따로 작성해줘야 한다.
Okta와 같은 Provider를 선택했다면 OAuth2 클라이언트에 특정 하위 도메인이 할당된다.
따라서, 사용자 정의 Provider Property들을 작성해야한다.
spring:
security:
oauth2:
client:
registration:
okta:
client-id: okta-client-id
client-secret: okta-client-secret
provider:
okta:
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
Spring Boot에서는 OAuth2 Client Auto-configuration으로 OAuth2ClientAutoConfiguration를 제공한다.
해당 자동설정에서 하는 작업은 아래와 같다
ClientRegistrationRepositoryBean 등록SecurityFilterChainBean을 등록하고,httpSecurity.oauth2Login()으로 OAuth 2.0 로그인 활성화
만약 이러한 자동설정을 재정의하고 싶다면 아래의 방법을 사용할 수 있다
ClientRegistrationRepositoryBean 등록SecurityFilterChainBean 등록- Auto-configuration 전체 재정의
@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.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("{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
@EnableWebSecurity
public class OAuth2LoginSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.oauth2Login(withDefaults());
return http.build();
}
}
@Configuration
public class OAuth2LoginConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.oauth2Login(withDefaults());
return http.build();
}
@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.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("{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
@EnableWebSecurity
public class OAuth2LoginSecurityConfig {
@Bean
public SecurityFilterChain filterChain(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())
)
);
return http.build();
}
}
위 코드는 oauth2Login에 설정가능한 모든 설정들을 설정한 예시이다. 사용된 엔드포인트들을 알아보자.
OAuth 2.0 Authorization Framework에 정의된 Protocol Endpoints에 소개된 Authorization Process에서 사용되는 엔드포인트 목록은 아래와 같다.
| 엔드포인트 | 설명 |
|---|---|
| Authorization Endpoint( 서버 ) | 클라이언트가 user-agent redirection을 통해 authorization을 얻기위한 경로 |
| Token Endpoint( 서버 ) | 클라이언트가 access token을 통해 authorization grant를 얻기위한 경로 |
| Redirection Endpoint( 클라이언트 ) | resource owner user-agen를 통해 권한 인증 정보가 포함된 응답을 반환하기 위한 경로 |
The OpenID Connect Core 1.0 specification에 정의된 UserInfo Endpoints는 아래와 같다
claims을 반환하는 OAuth 2.0 보호된 리소스claims를 얻는다claims들은 JSON 형태로 표시된다기본적으로 생성되는 OAuth2 2.0 Login Page는 DefaultLoginPageGeneratingFilter에 의해 생성된다.


생성되는 로그인 페이지는 ClientRegistration.clientName로 표현되는 각 OAuth 클라이언트가 표시된다. ( 이미지의 Google )
이 로그인 페이지를 변경하고 싶다면 oauth2Login().loginPage() 와oauth2Login().authorizationEndpoint().baseUri(). 를 이용하면 된다.
@Configuration
@EnableWebSecurity
public class OAuth2LoginSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.oauth2Login(oauth2 -> oauth2
.loginPage("/login/oauth2")
);
return http.build();
}
}
예를들어, 위와 같이 설정하고 @Controller에 loginPage에 해당하는 커스텀 페이지를 랜더링 하도록 설정하면 로그인 페이지를 변경할 수 있다.
Authorization Server가 Authorization Response를 클라이언트에게 반환하기 위해 사용되는 엔드포인트다.
OAuth2 로그인에 사용되는 자격증명 방식은 Authorization Code다.

기본적으로 반환되는 uri는 OAuth2LoginAuthenticationFilter.DEFAULT_FILTER_PROCESSES_URI에 설정된 /login/oauth2/code/* 다.
만약, 이를 변경하고 싶다면 아래 코드와 같은 방식을 이용하면 된다
@Configuration
@EnableWebSecurity
public class OAuth2LoginSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.oauth2Login(oauth2 -> oauth2
.redirectionEndpoint(redirection -> redirection
.baseUri("/login/oauth2/callback/*")
...
)
);
return http.build();
}
}
property에 설정하여 ClientRegistration에 등록되는 redirectUri와 설정한 uri가 동일해야 한다는 점을 주의하자.
UserInfo Endpoint는 사용자 인증 이후, access token을 이용해 Resource Server에서 허가받은 정보들을 얻어오기 위한 엔드포인트다. access token에 정의된 scope에 따라 사용자 권한을 SCOPE 형태로 가져오며, OAuth2User 또는 OidcUser에 저장한다. 이렇게 가져온 SCOPE들은 GrantedAuthority set으로 만들어 인증과정 동안 OAuth2AuthenticationToken에 저장된다.
이 과정에서 2가지의 방법이 사용된다.
GrantedAuthoritiesMapperOAuth2UserService를 이용한 위임기반 전략

GrantedAuthoritiesMapper는 부여된 권한들을 OAUTH2_USER 권한 문자열과 함께 OAuth2UserAuthority 타입으로 만들거나 OIDC_USER 권한 문자열과 함께 OidcUserAuthority 타입으로 만든다.
이는 GrantedAuthoritiesMapper의 구현체를 직접 등록하거나 Bean으로 등록하여 수정이 가능하다.
`
OAuth2UserService는 앞선 방법보다 좀 더 유연한 방법으로, OAuth2UserRequest와 OAuth2User에 대한 접근이 가능하다.
( OIDC UserService라면 OidcUserRequest와 OidcUser )
OAuth2AccessToken에 대한 접근을 통해 사용자 정의 권한을 매핑하기 전에 보호된 리소스의 인가정보에 접근하는데 효과적이다.
표준 OAuth 2.0 Provider 들에서 사용하는 OAuth2UserService는 DefaultOAuth2UserService다.
OAuth2UserService는 access token을 이용하여 UserInfo 엔드포인트로 부터 Resource Owner의 사용자 속성들을 가져오고OAuth2User의 형태의AuthenticatedPrincipal를 저장한다.
DefaultOAuth2UserService는 RestOperations를 사용하여 UserInfo 엔드포인트로의 요청을 진행한다.
이 요청의 전처리 작업을 변경하고 싶다면 setRequestEntityConverter()를 이용하여 Converter를 등록하면 되며,
사후 작업을 변경하고 싶다면 setRestOperations()에 RestOperations을 등록하면 된다.
@Configuration
@EnableWebSecurity
public class OAuth2LoginSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(userInfo -> userInfo
.userService(this.oauth2UserService())
...
)
);
return http.build();
}
private OAuth2UserService<OAuth2UserRequest, OAuth2User> oauth2UserService() {
DefaultOAuth2UserService oAuth2UserService = new DefaultOAuth2UserService();
oAuth2UserService.setRequestEntityConverter( ... ); // 전처리
oAuth2UserService.setRestOperations( ... ); // 사후처리
z
return oAuth2UserService;
}
}
OIDC 인증의 Id Token은 최종 사용자 인증에 대한 Claim을 포함하는 보안 토큰이다.

이 토큰에는 JWS를 통해 서명된 JWT가 존재한다
서명을 검증하기 위해 OidcIdTokenDecoderFactory는 JwtDecoder를 이용하며, 토큰의 기본 알고리즘은 RS256이다.
서명 알고리즘은 client registration이 할당되는 동안 변경될 수 있다.
이러한 경우, resolver를 구성하여 특정 클라이언트에 할당된 서명 알고리즘을 반환하도록 할 수 있다.
@Bean
public JwtDecoderFactory<ClientRegistration> idTokenDecoderFactory() {
OidcIdTokenDecoderFactory idTokenDecoderFactory = new OidcIdTokenDecoderFactory();
idTokenDecoderFactory.setJwsAlgorithmResolver(clientRegistration -> MacAlgorithm.HS256);
return idTokenDecoderFactory;
}
OIDC 로그인을 구현한 경우, 로그아웃 방식에는 3가지의 옵션을 생각해볼 수 있다.
- 내 애플리케이션에서만 로그아웃
- 내 애플리케이션을 통한 내 애플리케이션과 OIDC Provider 모두 로그아웃
- OIDC Provider를 통한 내 애플리케이션과 OIDC Provider 모두 로그아웃
1번 방법은 OIDC 관련 설정없이 사용하던 방법대로 logout() DSL를 통해 로그아웃을 구현하면 된다.
2번 방법은 RP-Initiated Logout 전략을 이용할 수 있다.
OpenID Provider의 Discovery Metadata에서 end_session_endpoint URL을 얻어내야 한다.
이는ClientRegistration을 생성하는 Property 설정의 provider 부분에서 issuer-uri가 설정되어야 한다.
이후, OidcClientInitiatedLogoutSuccessHandler에 관련설정을 작성하고 등록하면 된다
@Configuration
@EnableWebSecurity
public class OAuth2LoginSecurityConfig {
@Autowired
private ClientRegistrationRepository clientRegistrationRepository;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.oauth2Login(withDefaults())
.logout(logout -> logout
.logoutSuccessHandler(oidcLogoutSuccessHandler())
);
return http.build();
}
private LogoutSuccessHandler oidcLogoutSuccessHandler() {
OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler =
new OidcClientInitiatedLogoutSuccessHandler(this.clientRegistrationRepository);
// 로그아웃 수행 이후, 동작할 위치설정
oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}");
return oidcLogoutSuccessHandler;
}
}
3번 방법은 OIDC Back-Channel Logout 이라고 불린다.
Provider에서 Client쪽으로 API 호출을 할 수 있도록 하여, 로그아웃 시키는 방식이다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
.anyRequest().authenticated()
)
.oauth2Login(withDefaults())
.oidcLogout((logout) -> logout
.backChannel(Customizer.withDefaults())
);
return http.build();
}
이를 위해서는 위 코드처럼 oidcLogout() DSL로 설정을 켜줘야한다.
이후에는 스프링 시큐리티가 발생시킨 이벤트를 핸들링하여 오래된 OidcSessionInformation를 제거한다
@Bean
public HttpSessionEventPublisher sessionEventPublisher() {
return new HttpSessionEventPublisher(); // 이벤트 수신
}
이렇게 Bean을 등록하면 HttpSession#invalidate가 호출되었을 때, 세션을 메모리에서 제거한다.
이러면 Provider가 /logout/connect/back-channel/{registrationId} 엔드포인트를 이용하여 클라이언트에 세션을 지워달라는 요청이 가능해진다.
전체 로그아웃 흐름은 아래와 같다
- 로그인 시, 스프링 시큐리티에서 Id Token, CSRF Token, Provider의 Session ID를
OidcSessionStrategy에 따라 애플리케이션의 Session ID와 연관지음- 로그아웃 시, OIDC Provider에서
/logout/connect/back-channel/registrationId로의 API 요청 전송
a. API 요청 시에sub( 최종 사용자 )또는sid( Provider Session ID )를 포함한 Logout Token을 함께 전송- 스프링 시큐리티에서 Logout Token 검증
- 토큰에 sid가 포함되어 있다면, Provider 세션과 관련된 클라이언트 세션만 종료
- 토큰에 sub가 포함되어 있다면, 해당 최종 사용자와 관련된 모든 클라이언트 세션 종료
스프링 시큐리티는 기본적으로 In-memory 방식으로 Provider Session과 클라이언트 Session의 관계를 저장한다.
만약, 이를 DB와 같이 별도의 위치에 저장하고 싶다면 아래와 같이 설정이 가능하다.
@Component
public final class MySpringDataOidcSessionStrategy implements OidcSessionStrategy {
private final OidcProviderSessionRepository sessions;
// ...
@Override
public void saveSessionInformation(OidcSessionInformation info) {
this.sessions.save(info);
}
@Override
public OidcSessionInformation(String clientSessionId) {
return this.sessions.removeByClientSessionId(clientSessionId);
}
@Override
public Iterable<OidcSessionInformation> removeSessionInformation(OidcLogoutToken token) {
return token.getSessionId() != null ?
this.sessions.removeBySessionIdAndIssuerAndAudience(...) :
this.sessions.removeBySubjectAndIssuerAndAudience(...);
}
}