# server config server.port=8080 spring.profiles.active=jwt logging.level.root=debug # datasource config spring.datasource.url=jdbc:h2:mem:test;MODE=MYSQL; spring.datasource.driverClassName=org.h2.Driver spring.datasource.username=sa spring.datasource.password= #spring data jpa config spring.jpa.database-platform=org.hibernate.dialect.H2Dialect #h2 console access spring.h2.console.enabled=true spring.h2.console.path=/h2-console # 타 authorization server client id/secret naver.oauth.client.id=TkIAw1v... naver.oauth.client.secret=Pjz... kakao.oauth.client.id=90f9c9a... kakao.oauth.client.secret=L4a... spring.jwt.secret=liytQ9XESwlBLtAReEvUv8f5fApFvHZLvYgvA8Pd9t1May0xQTokUdKrrY46tlJ0 # oauth2-client-schema 생성을 위한 설정, access token, refresh token 저장용 spring.sql.init.mode=always spring.sql.init.schema-locations=classpath:org/springframework/security/oauth2/client/oauth2-client-schema.sql
naver, kakao 의 Authorization Server, 그리고 자체구축할 Spring Authorization Server 를 사용하기 위해 3개의 ClientRegistration 등록. Resource Client 에 실시간으로 새로운 Authorization Server 가 추가될일은 없기에 InMemoryClientRegistrationRepository 로 구성.
@Bean public ClientRegistrationRepository clientRegistrationRepository() { // oidc 를 지원할 경우 /.well-known/openid-configuration URL 을 통해 // auth code, token, userinfo 를 가져오는 url 을 자동으로 등록한다 ClientRegistrationspringAuthDemoClient= ClientRegistrations.fromIssuerLocation("http://authorization-server") .registrationId("oauth-demo-registration-id") .clientId("oauth-demo-client-id") .clientSecret("secret") .clientName("Resource Client Demo") .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) // PKCE 방식, code_challenge code_verifier 를 사용 .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .redirectUri("http://127.0.0.1:8080/login/oauth2/code/oauth-client-redirect") .userNameAttributeName(IdTokenClaimNames.SUB) // default 가 sub .scope(OidcScopes.OPENID, OidcScopes.PROFILE, OidcScopes.EMAIL) .build();
// naver 에선 oauth 2.0 만 지원, 각종 oauth 관련 url 을 수기로 작성해줘야 한다. ClientRegistrationnaverAuthClient= ClientRegistration.withRegistrationId("naver-auth-registration-id") .authorizationUri("https://nid.naver.com/oauth2.0/authorize") .tokenUri("https://nid.naver.com/oauth2.0/token") .userInfoUri("https://openapi.naver.com/v1/nid/me") .clientId(naverOAuthClientId) .clientSecret(naverOAuthClientSecret) .clientName("Naver OAuth Client Demo") .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) // HTTP Basic 인증 헤더사용, Authorization 헤더에 client secret Base64 .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .redirectUri("http://127.0.0.1:8080/login/oauth2/code/naver-oauth-redirect") .userNameAttributeName("response") // naver 에선 sub 가 없고 id 역할을 하는 username 을 response 로 감싸고 있어 부득이하기 부모 key 값을 써야함 .scope("name", "email") .build(); /* { resultcode=00, message=success, response={id=xqmroU.., name=홍길동, email=kouzie@naver.com} } */
// kakao 에선 oidc 를 지원한다. // https://kauth.kakao.com/.well-known/openid-configuration ClientRegistrationkakaoAuthClient= ClientRegistrations.fromIssuerLocation("https://kauth.kakao.com") .registrationId("kakao-auth-registration-id") .clientId(kakaoOAuthClientId) .clientSecret(kakaoOAuthClientSecret) .clientName("Kakao OAuth Client Demo") .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) // Form 데이터 형태로 client secret Base64 .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .redirectUri("http://127.0.0.1:8080/login/oauth2/code/kakao-auth-redirect") .scope(OidcScopes.OPENID) // https://developers.kakao.com/docs/latest/ko/kakaologin/utilize#scope-user .build(); returnnewInMemoryClientRegistrationRepository(springAuthDemoClient, naverAuthClient, kakaoAuthClient); }
oauth2-client-schema.sql 로 access token, id token 을 관리해야 함으로 OAuth2AuthorizedClientService 의 JDBC 구현체인 JdbcOAuth2AuthorizedClientService 생성.
@PostConstruct publicvoidprintSecurityFilters() { List<SecurityFilterChain> filterChains = filterChainProxy.getFilterChains(); for (SecurityFilterChain chain : filterChains) { List<Filter> filters = chain.getFilters(); System.out.println("Security Filter Chain: " + chain); for (Filter filter : filters) { System.out.println(filter.getClass()); } } } /* class o.sf.sec.web.session.DisableEncodeUrlFilter class o.sf.sec.web.context.request.async.WebAsyncManagerIntegrationFilter class o.sf.sec.web.context.SecurityContextHolderFilter class o.sf.sec.web.header.HeaderWriterFilter class o.sf.web.filter.CorsFilter class o.sf.sec.web.csrf.CsrfFilter class o.sf.sec.web.authentication.logout.LogoutFilter class o.sf.sec.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter class o.sf.sec.oauth2.client.web.OAuth2LoginAuthenticationFilter class o.sf.sec.web.authentication.ui.DefaultLoginPageGeneratingFilter class o.sf.sec.web.authentication.ui.DefaultLogoutPageGeneratingFilter class o.sf.sec.web.savedrequest.RequestCacheAwareFilter class o.sf.sec.web.servletapi.SecurityContextHolderAwareRequestFilter class o.sf.sec.web.authentication.AnonymousAuthenticationFilter class o.sf.sec.web.access.ExceptionTranslationFilter class o.sf.sec.web.access.intercept.AuthorizationFilter */
OAuth2AuthorizedClientService
Resource Owner 의 access_token 을 확인하여 유효기간이 남아있는 로그인인지 파악한다. 동일한 Resource Client 가 여러개 띄어져 있는 상황에서 해당 데이터를 InMemory 가 아닌 DB 에 저장하고 관리할 수 있어야 한다.
1 2 3 4 5 6
// Resource Client 가 여러개 띄어져 있어도 JDBC 를 통해 DB 에서 access token, refresh token 등을 검색하기 때문에 로그인이 풀리지 않음 @Bean public OAuth2AuthorizedClientService authorizedClientService(DataSource dataSource, ClientRegistrationRepository clientRegistrationRepository) { returnnewJdbcOAuth2AuthorizedClientService(newJdbcTemplate(dataSource), clientRegistrationRepository); }
userinfo 조회 후 해당 객체로 Authentication 인증객체를 생성하고, JWT 까지 생성해야 하기 때문에 CustomOAuth2User 같은 Resource Client 전용 인증객체를 생성하는것을 권장한다.
JWT 기반 Resource Client
MSA 구조, 모바일 앱 환경에선 session 을 잘 사용하지 않고 JWT 와 같은 stateless 한 방식을 주로 사용한다.
쿠키를 사용하지 않을경우 JWT 를 별도로 저장하고 있다 응답하는 API 를 작성해야 하는데 번거로워 OAuth 에선 쿠키 사용방식을 권장한다. session 이 없기 때문에 Access Denied 되었던 url 로 이동하고 싶다면 프론트엔드에서 별도로 처리해주어야함.
http .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) .oauth2Login(oauth2 -> oauth2 .successHandler(oAuthLoginSuccessHandler) // 로그인완료 후 호출되는 success handler 구성 .clientRegistrationRepository(clientRegistrationRepository) .authorizedClientService(oAuth2AuthorizedClientService) // jdbc authorizedClientService 사용하도록 변경 ... ) http.addFilterBefore(newJwtAuthenticationFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
Spring OAuth2 Client 인증과정
OIDC 프로토콜을 지원하는 Authorization Server 의 경우 /.well-known/oauth-authorization-server API 를 지원하며 아래 3개 URI 를 자동으로 가져올 수 있다. OidcProviderConfigurationEndpointFilter 에서 해당 url 을 처리한다.
authorizationUri: authorization code 를 발급받을 수 있는 로그인 페이지 redirect url
tokenUri: access token 을 발급받을 수 있는 API url(REST, Formdata)
// OidcAuthorizationCodeAuthenticationProvider::authenticate public Authentication authenticate(Authentication authentication)throws AuthenticationException { OAuth2LoginAuthenticationTokenauthorizationCodeAuthentication= (OAuth2LoginAuthenticationToken) authentication; // REQUIRED. OpenID Connect requests MUST contain the "openid" scope value. if (!authorizationCodeAuthentication.getAuthorizationExchange() .getAuthorizationRequest() .getScopes() .contains(OidcScopes.OPENID)) { // This is NOT an OpenID Connect Authentication Request so return null // and let OAuth2LoginAuthenticationProvider handle it instead returnnull; } ... /* /oauth2/token 요청 */ OAuth2AccessTokenResponseaccessTokenResponse= getResponse(authorizationCodeAuthentication); ClientRegistrationclientRegistration= authorizationCodeAuthentication.getClientRegistration(); ... /* /oauth2/jwks 요청, 서명값 인증 */ OidcIdTokenidToken= createOidcToken(clientRegistration, accessTokenResponse); validateNonce(authorizationRequest, idToken); /* /userinfo 요청, 사용자 정보 흭득, OAuth2UserService 사용 */ OidcUseroidcUser=this.userService.loadUser(newOidcUserRequest(clientRegistration, accessTokenResponse.getAccessToken(), idToken, additionalParameters)); ... OAuth2LoginAuthenticationTokenauthenticationResult=newOAuth2LoginAuthenticationToken( authorizationCodeAuthentication.getClientRegistration(), authorizationCodeAuthentication.getAuthorizationExchange(), oidcUser, mappedAuthorities, accessTokenResponse.getAccessToken(), accessTokenResponse.getRefreshToken()); return authenticationResult; }
// OAuth2LoginAuthenticationProvider::authenticate public Authentication authenticate(Authentication authentication)throws AuthenticationException { OAuth2LoginAuthenticationTokenloginAuthenticationToken= (OAuth2LoginAuthenticationToken) authentication; // REQUIRED. OpenID Connect requests MUST contain the "openid" scope value. if (loginAuthenticationToken.getAuthorizationExchange() .getAuthorizationRequest() .getScopes() .contains("openid")) { // This is an OpenID Connect Authentication Request so return null // and let OidcAuthorizationCodeAuthenticationProvider handle it instead returnnull; } ... /* https://nid.naver.com/oauth2.0/token, access_token 요청 */ OAuth2AccessTokenaccessToken= authorizationCodeAuthenticationToken.getAccessToken(); /* https://openapi.naver.com/v1/nid/me 요청, user info 요청 */ OAuth2Useroauth2User=this.userService.loadUser(newOAuth2UserRequest( loginAuthenticationToken.getClientRegistration(), accessToken, additionalParameters)); Collection<? extendsGrantedAuthority> mappedAuthorities = this.authoritiesMapper .mapAuthorities(oauth2User.getAuthorities()); OAuth2LoginAuthenticationTokenauthenticationResult=newOAuth2LoginAuthenticationToken( loginAuthenticationToken.getClientRegistration(), loginAuthenticationToken.getAuthorizationExchange(), oauth2User, mappedAuthorities, accessToken, authorizationCodeAuthenticationToken.getRefreshToken()); authenticationResult.setDetails(loginAuthenticationToken.getDetails()); return authenticationResult; }
반환된 인증객체는 OAuth2LoginAuthenticationFilter 에서 이어서 처리하며 DB 에 인증객체를 저장하고 session 에도 저장될 수 있도록 인증객체를 반환한다.
1 2 3 4 5 6 7 8 9 10 11
// OAuth2LoginAuthenticationFilter::attemptAuthentication // token 내용을 뺀 사용자 인증데이터(세션 저장용) 생성 OAuth2AuthenticationTokenoauth2Authentication=this.authenticationResultConverter.convert(authenticationResult); // access_token, refresh_token, 사용자 정보 등 생성 OAuth2AuthorizedClientauthorizedClient=newOAuth2AuthorizedClient( authenticationResult.getClientRegistration(), oauth2Authentication.getName(), authenticationResult.getAccessToken(), authenticationResult.getRefreshToken()); // 토큰정보는 OAuth2AuthorizedClientRepository 저장소(inmemory, jdbc) 에 저장 this.authorizedClientRepository.saveAuthorizedClient(authorizedClient, oauth2Authentication, request, response); return oauth2Authentication; // 반환된 oauth2Authentication 은 밖의 필터에 의해 session 에 저장됨
Spring Authorization Server
Spring Boot 에서 OAuth 2.1, OpenID Connect 를 지원하는 Authorization Server 라이브러리는 아래와 같다.
server: port:9000 spring: security: user: name:user password:password oauth2: authorizationserver: client: my-demo-resource-client:# resource client 설정 시작 require-authorization-consent:true# 사용자가 인증 요청을 받을 때 동의 화면을 볼지 여부 registration: client-id:"oidc-client"# resource client id client-secret:"{noop}secret"# resource client secret client-authentication-methods:# client_id와 client_secret을 사용해 기본 인증을 수행 -"client_secret_basic" authorization-grant-types:# auth server 가 부여하는 데이터 -"authorization_code"# access token 을 얻을 때 사용 -"refresh_token"# access token 만료시 사용 redirect-uris: -"http://127.0.0.1:8080/login/oauth2/code/oauth-client-redirect" post-logout-redirect-uris: -"http://127.0.0.1:8080/" scopes: -"openid" -"profile"
Spring Security 와 연계되어 다수의 Spring Security FilterSpring Security Provider 가 등록되어 OAuth 를 구현할 수 있다.
실제 Spring Security Filter 에 등록된 Filter 들을 출력하면 아래와 같다.
@PostConstruct publicvoidprintSecurityFilters() { List<SecurityFilterChain> filterChains = filterChainProxy.getFilterChains(); for (SecurityFilterChain chain : filterChains) { List<Filter> filters = chain.getFilters(); System.out.println("Security Filter Chain: " + chain); for (Filter filter : filters) { System.out.println(filter.getClass()); } } } /* class o.sf.sec.oauth2.server.authorization.oidc.web.OidcLogoutEndpointFilter class o.sf.sec.web.authentication.logout.LogoutFilter class o.sf.sec.oauth2.server.authorization.web.OAuth2AuthorizationServerMetadataEndpointFilter class o.sf.sec.oauth2.server.authorization.web.OAuth2AuthorizationEndpointFilter class o.sf.sec.oauth2.server.authorization.web.OAuth2DeviceVerificationEndpointFilter class o.sf.sec.oauth2.server.authorization.oidc.web.OidcProviderConfigurationEndpointFilter class o.sf.sec.oauth2.server.authorization.web.NimbusJwkSetEndpointFilter class o.sf.sec.oauth2.server.authorization.web.OAuth2ClientAuthenticationFilter class o.sf.sec.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter class o.sf.sec.web.savedrequest.RequestCacheAwareFilter class o.sf.sec.web.servletapi.SecurityContextHolderAwareRequestFilter class o.sf.sec.web.authentication.AnonymousAuthenticationFilter class o.sf.sec.web.access.ExceptionTranslationFilter class o.sf.sec.web.access.intercept.AuthorizationFilter class o.sf.sec.oauth2.server.authorization.web.OAuth2TokenEndpointFilter class o.sf.sec.oauth2.server.authorization.web.OAuth2TokenIntrospectionEndpointFilter class o.sf.sec.oauth2.server.authorization.web.OAuth2TokenRevocationEndpointFilter class o.sf.sec.oauth2.server.authorization.web.OAuth2DeviceAuthorizationEndpointFilter class o.sf.sec.oauth2.server.authorization.oidc.web.OidcUserInfoEndpointFilter class o.sf.sec.oauth2.server.authorization.oidc.web.OidcClientRegistrationEndpointFilter */
// Ahotirzation Server 의 Resource Client 등록을 위한 코드 @PostConstruct voidinit() { // Ahotirzation Server 의 Resource Client 등록을 위한 코드 RegisteredClient.Builderregistration= RegisteredClient.withId("oauth-client-demo") .clientId("oauth-demo-client-id") // plaintext is secret It is encoded with BCrypt from EncodedSecretTests // do not include secrets in the source code because bad actors can get access to your secrets .clientSecret(passwordEncoder.encode("secret")) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantTypes(types -> { types.add(AuthorizationGrantType.AUTHORIZATION_CODE); types.add(AuthorizationGrantType.CLIENT_CREDENTIALS); types.add(AuthorizationGrantType.REFRESH_TOKEN); }) .redirectUri("http://127.0.0.1:8080/login/oauth2/code/oauth-client-redirect") .scopes(scopes -> { scopes.add("openid"); scopes.add("profile"); scopes.add("email"); }) .clientSettings(ClientSettings.builder() .requireAuthorizationConsent(true) .build()); this.save(registration.build()); }
OAuth2Authorization
Resource Owner 와 Resource Client 간의 매핑, 권한 부여관련 데이터
RegisteredClientoidcClient= RegisteredClient.withId("oauth-client-demo") .clientId("oauth-demo-client-id") // plaintext is secret It is encoded with BCrypt from EncodedSecretTests // do not include secrets in the source code because bad actors can get access to your secrets .clientSecret(passwordEncoder.encode("secret")) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantTypes(types -> { types.add(AuthorizationGrantType.AUTHORIZATION_CODE); types.add(AuthorizationGrantType.CLIENT_CREDENTIALS); types.add(AuthorizationGrantType.REFRESH_TOKEN); }) .redirectUri("http://127.0.0.1:8080/login/oauth2/code/oauth-client-redirect") .scopes(scopes -> { scopes.add("openid"); scopes.add("profile"); scopes.add("email"); }) .clientSettings(ClientSettings.builder() .requireAuthorizationConsent(true) .build()); .build();
여기서는 spring doc 링크에서 제공해준 JPA 구현체를 사용.
JPA 를 통해 RegisteredClient, OAuth2Authorization, OAuth2AuthorizationConsent 3가지 Core Model 를 관리하는 예제를 제공한다.
클래스 파일을 추가하기 싫다면 라이브러리에서 제공하는 JDBC 로 구현한 Core Model 을 사용하는것도 가능하다.
@Bean @Order(2) public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests((authorize) -> authorize .requestMatchers("/login", "/error").permitAll() .anyRequest().authenticated() ) // Form login handles the redirect to the login page from the // authorization server filter chain .formLogin(login -> login.loginPage("/login")) .cors(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable);
return http.build(); }
/oauth2/** 로 시작하는 OAuth Endpoint URL 처리를 위해 Spring Security Filter 의 requestMatchers 에 등록해준다.
각 엔드포인트들의 커스텀한 처리를 위해 아래 함수 및 AbstractOAuth2Configurer 구현클래스를 사용할 수 있다.
authorizationEndpoint OAuth2 인증 요청을 처리하는 엔드포인트,
tokenEndpoint OAuth2 액세스 토큰을 발급하는 엔드포인트
tokenIntrospectionEndpoint OAuth2 토큰 상태를 확인하는 엔드포인트
tokenRevocationEndpoint 토큰 무효화(Revocation) 처리하는 엔드포인트
jwkSetEndpoint JSON Web Key Set(JWKS)를 제공하는 엔드포인트
oidcLogoutEndpoint OpenID Connect 로그아웃을 처리하는 엔드포인트
oidcUserInfoEndpoint OpenID Connect 사용자 정보를 제공하는 엔드포인트
oidcClientRegistrationEndpoint OpenID Connect 클라이언트 등록을 처리하는 엔드포인트
deviceAuthorizationEndpoint 디바이스가 사용자 코드와 디바이스 코드를 요청하는 엔드포인트 iot, smart tv 같이 입력이 제한된 환경에서 사용하는 device 인증 flow 에서 사용
deviceVerificationEndpoint 사용자가 브라우저를 통해 코드 입력을 완료하는 엔드포인트
AbstractOAuth2Configurer 에선 OAuth Endpoint URL 를 처리할 Filter 클래스의 생성, 인증과정에 사용할 Providers 를 Filter 에 적용 등의 작업을 수행한다.
AbstractOAuth2Configurer(ObjectPostProcessor<Object> objectPostProcessor) { this.objectPostProcessor = objectPostProcessor; } // AuthenticationProvider 를 정의하고 Spring Security 에 등록 abstractvoidinit(HttpSecurity httpSecurity); // AuthenticationFilter 에서 인증과정을 수행할 필터 등록 abstractvoidconfigure(HttpSecurity httpSecurity); // 인증과정을 수행할 GET POST url 등록 abstract RequestMatcher getRequestMatcher();
protectedfinal <T> T postProcess(T object) { returnthis.objectPostProcessor.postProcess(object); }
publicfinalclassOAuth2TokenEndpointConfigurerextendsAbstractOAuth2Configurer { ... voidinit(HttpSecurity httpSecurity) { // 인증과정 POST url 등록 AuthorizationServerSettingsauthorizationServerSettings= OAuth2ConfigurerUtils.getAuthorizationServerSettings(httpSecurity); this.requestMatcher = newAntPathRequestMatcher(authorizationServerSettings.getTokenEndpoint(), HttpMethod.POST.name()); // 인증과정을 수행항 AuthenticationProvider 을 정의 List<AuthenticationProvider> authenticationProviders = createDefaultAuthenticationProviders(httpSecurity); if (!this.authenticationProviders.isEmpty()) { authenticationProviders.addAll(0, this.authenticationProviders); }
this.authenticationProvidersConsumer.accept(authenticationProviders); // 정의한 AuthenticationProvider 을 Spring Security 목록에 등록 authenticationProviders.forEach((authenticationProvider) -> { httpSecurity.authenticationProvider((AuthenticationProvider)this.postProcess(authenticationProvider)); }); }
voidconfigure(HttpSecurity httpSecurity) { AuthenticationManagerauthenticationManager= (AuthenticationManager)httpSecurity.getSharedObject(AuthenticationManager.class); AuthorizationServerSettingsauthorizationServerSettings= OAuth2ConfigurerUtils.getAuthorizationServerSettings(httpSecurity); // AuthenticationFilter 에 등록할 Filter 생성 OAuth2TokenEndpointFiltertokenEndpointFilter=newOAuth2TokenEndpointFilter(authenticationManager, authorizationServerSettings.getTokenEndpoint()); // Http 요청에서 Authentication 인증객체로 컨버팅하는 객체 정의 및 필터에 추가 List<AuthenticationConverter> authenticationConverters = createDefaultAuthenticationConverters(); if (!this.accessTokenRequestConverters.isEmpty()) { authenticationConverters.addAll(0, this.accessTokenRequestConverters); } this.accessTokenRequestConvertersConsumer.accept(authenticationConverters); tokenEndpointFilter.setAuthenticationConverter(newDelegatingAuthenticationConverter(authenticationConverters)); if (this.accessTokenResponseHandler != null) { tokenEndpointFilter.setAuthenticationSuccessHandler(this.accessTokenResponseHandler); }
if (this.errorResponseHandler != null) { tokenEndpointFilter.setAuthenticationFailureHandler(this.errorResponseHandler); } // 정의한 Filter 를 AuthenticationFilter 목록에 등록 httpSecurity.addFilterAfter((Filter)this.postProcess(tokenEndpointFilter), AuthorizationFilter.class); } }
init 메서드에서 호출하는 createDefaultAuthenticationProviders 메서드를 보면 Core Module 에서 등록한 객체들을 가져와 AuthenticationProvider 인증과정을 객체를 생성한다.
인증정보가 없기때문에 Spring Security 기본 설정에 의해 로그인 페이지로 이동하게 된다.
로그인 완료 후 url(1) 에 대한 처리를 이어서 수행한다.
Authorization Code
OAuth2AuthorizationEndpointFilter 에서 url(1) 을 처리.
로그인 사용자 consent 확인 후 없다면 consent 동의페이지로 redirect 하도록 OAuth2AuthorizationConsentAuthenticationToken 타입의 Authentication 인증객체 생성.
로그인 사용자 consent 확인 후 있다면 Resource Client 가 등록한 redirectUrl 로 Authorization Code 와 함께 redirect 하도록 OAuth2AuthorizationCodeRequestAuthenticationToken 타입의 Authorization 인증객체 생성.
로그인 완료된 사용자는 Resource Client 가 등록한 아래 url 로 Authorization Code 와 함께 redirect 된다.
OAuth2ClientAuthenticationFilter 에서 url(3) 요청 처리. 올바른 요청인 검증한다.
ClientSecretAuthenticationProvider
CLIENT_SECRET_BASIC, CLIENT_SECRET_POST 인증 요청일 경우 사용 Provider.
Authorization Code, Redirection URL, Resource Client Secret 이 기존에 등록된 RegisteredClient 와 일치하는지 검증.
PublicClientAuthenticationProvider
NONE 인증 요청일 경우 사용 Provider, secret 키를 사용하지 않는 Public Client 일 경우 사용.
code_challenge, code_verifier 검증.
Public Client 의 경우 refresh token 은 반환하지 않는다.
Provider 는 OAuth2ClientAuthenticationToken 타입의 Authentiaction 객체 생성.
OAuth2TokenEndpointFilter 에서 url(3) 요청을 이어서 처리. access token 을 생성한다.
Access Token, Refresh Token 등이 포함된 OAuth2AccessTokenAuthenticationToken 생성 및 response 에 write.
ID Token
NimbusJwkSetEndpointFilter 에서 /oauth2/jwks 요청 처리.
access token, id token 모두 Authorization Server 의 비밀키로 서명되어 /oauth2/jwks 에서 출력되는 공개키로 인증가능하다.
OidcUserInfoEndpointFilter 에서 /userinfo 요청 처리.
OidcUserInfoAuthenticationProvider 에서 사용자의 token 유효성 확인(DB, InMemory),
DefaultOidcUserInfoMapper 를 통해 scope 확인 후 필요한 데이터만 반환.
인증, 인가에 필요한 각종 도메인을 CRUD 하기 위한 API 를 개발하는건 피곤한 일이다. Spring Auth Server 를 통해 직접 구축할 수 도 있지만 Keycloak 과 같은 오픈소스를 사용하는 것도 좋은 방법이다.
인증과정 중 요청/응답 값
Resource Client 가 Authorization Server 에게 인증요청하기 위해 생성하는 redirect url 은 아래와 같다.
1 2 3 4 5 6
http://authorization-server/oauth2/authorize?response_type=code& client_id=oauth-demo-client-id& scope=openid%20profile%20email& redirect_uri=http://127.0.0.1:8080/login/oauth2/code/oauth-client-redirect& state=jtkQ2aipINvyG...& # CSRF 공격을 방지하기 위한 임의의 값 nonce=glNhNNX2m5yuwu_.. # ID 토큰 replay 공격을 방지하기 위한 임의의 값
만약 Resource Client 가 Public Client 일 경우 code_challenge code_verifier 를 위한 매개변수가 추가된다.
Resource Client에선 Authorization Server 로 redirect 될때 state 값을 세션에 저장해놓고, 로그인 완료 후 Resource Client 로 다시 redirect 될때 세션에 저장되어있는 state 값을 비교해서 일치하는지 확인한다,
위 /oauth2/authorize url 을 통해 로그인페이지로 이동, 로그인을 수행한다.
Authorization Server 에선 사용자의 consent 수행여부를 확인하고, consent 가 처리되어 있지 않다면 consent 페이지로 redirect 시킨다.
Authorization Server 에서 제공한 Login page 에서 로그인 수행, Consent Page 에서 동의처리 수행.
1 2 3 4
# consent 페이지로 redirect 할 수조 http://authorization-server/oauth2/consent?client_id=oauth-demo-client-id& scope=openid%20profile& state=9hy4RG-nb9ldssBvsKuI4YcEsYlLUvphUS9z4vcWahI
consent 가 이미 되어있거나 consent 처리를 완료한 사용자는 Resource Client 가 등록한 redirect url 로 Authorization Code 와 함께 redirect 된다.
1 2 3 4
# Resource Client 가 등록한 redirectUrl 로 redirect 할 주소 http://resource-client/login/oauth2/code/oauth-client-redirect? code=EvsIFciLUuK_HYD3...& state=XAFdONvLhAYe7DO...
Resource Client 는 전달받은 Authorization Code 를 사용해 access token 을 요청한다. NONE 타입으로 요청하기에 code_verifier 를 설정해서 요청한다.
1 2 3 4 5 6 7 8 9 10
# Spring Authorization Server 요청 POST http://authorization-server/oauth2/token # HEADER Authorization: Basic b2F1dGgtZGVtby1jbGllbnQtaWQ6c2VjcmV0 #(Resource Client Secret 을 base64 로 변환) # BODY grant_type=authorization_code code=bwibdMroYP9i... redirect_uri=http://resource-client/login/oauth2/code/oauth-client-redirect client_id=oauth-demo-client-id code_verifier=Wzv5cdcfWYxm9...
응답값은 아래와 같다. NONE 타입이다 보니 refresh token 을 전달하지 않는다. access_token 과 더불어 OIDC 의 id_token 도 JWT 형태로 같이 반환된다.
아래는 naver OAuth 2.0 에서 access token 을 요청할 때 사용하는 HTTP Request 형식 CLIENT_SECRET_BASIC 요청하기에 HTTP Header 에 Authorization: Basic {base64 secret} 값을 설정해서 요청한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
# Naver Authorization Server 요청 POST https://nid.naver.com/oauth2.0/token # HEADER Authorization: Basic VGtJQX... #(Resource Client Secret 을 base64 로 변환) # Body grant_type=authorization_code code=XeI8R9... redirect_uri=http://127.0.0.1:8080/login/oauth2/code/naver-oauth-redirect # https://nid.naver.com/oauth2.0/token 응답 json body # { # "access_token": "AAAANyN...", # "refresh_token": "ey7ipDq9...", # "token_type": "bearer", # "expires_in": "3600" # } # naver 의 token response 에는 scope 가 없어 DB 에 scope 가 누락되어 저장된다
@Bean public SecurityFilterChain resourceServer(HttpSecurity http)throws Exception { // 공개키 조회 및 jwtDecoder 등록 http.oauth2ResourceServer(resourceServer -> resourceServer .jwt(jwtConfigurer -> jwtConfigurer.jwkSetUri("http://authorization-server/oauth2/jwks")));
http.authorizeHttpRequests(auth -> auth .requestMatchers("/userinfo").hasAuthority("SCOPE_profile") // 해당 권한이 있어야 /userinfo 접근 가능 .anyRequest().authenticated() ); http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); return http.build(); }
@PostConstruct publicvoidprintSecurityFilters() { List<SecurityFilterChain> filterChains = filterChainProxy.getFilterChains(); for (SecurityFilterChain chain : filterChains) { List<Filter> filters = chain.getFilters(); log.info("Security Filter Chain: " + chain); for (Filter filter : filters) { log.info(filter.getClass().toString()); } } } /* Security Filter Chain: DefaultSecurityFilterChain class org.springframework.security.web.session.DisableEncodeUrlFilter class org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter class org.springframework.security.web.context.SecurityContextHolderFilter class org.springframework.security.web.header.HeaderWriterFilter class org.springframework.web.filter.CorsFilter class org.springframework.security.web.csrf.CsrfFilter class org.springframework.security.web.authentication.logout.LogoutFilter class org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter class org.springframework.security.web.savedrequest.RequestCacheAwareFilter class org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter class org.springframework.security.web.authentication.AnonymousAuthenticationFilter class org.springframework.security.web.session.SessionManagementFilter class org.springframework.security.web.access.ExceptionTranslationFilter class org.springframework.security.web.access.intercept.AuthorizationFilter */
BearerTokenAuthenticationFilter
등록된 여러 Filter 객체중 BearerTokenAuthenticationFilter 에서 access token 검증을 수행한다.
HTTP Header 에 저장된 access token 을 검증한다.
Authorization: Bearer {access_token}
Spring Authorizaion Server 의 /oauth/jwks 에서 얻은 공개키를 사용해 검증한다.
내부적으론 JwtAuthenticationProvider 를 사용하여 공개키가 등록된 jwtDecoder 로 access token 을 검증한다.
AuthenticationManagerauthenticationManager=this.authenticationManagerResolver.resolve(request); // JwtAuthenticationProvider AuthenticationauthenticationResult= authenticationManager.authenticate(authenticationRequest); SecurityContextcontext=this.securityContextHolderStrategy.createEmptyContext(); context.setAuthentication(authenticationResult); // security context 에 저장 this.securityContextHolderStrategy.setContext(context); this.securityContextRepository.saveContext(context, request, response); if (this.logger.isDebugEnabled()) { this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authenticationResult)); } filterChain.doFilter(request, response); } }
Spring OAuth Opaque Token
Opaque Token 불투명 토큰, Authorization Server 에서 식별자 용도로 사용하는 랜덤 문자열 형태의 토큰 OAuth 2.0 프로토콜에서 사용한다.
JWT 의 경우 Authorization Server 의 공개키를 통해 Resource Server 에서도 자체적으로 검증이 가능하기 때문에 추가적인 Authorization Server 의 개입을 필요로 하지 않는다. 하지만 만료시간으로 토큰 유효성을 검증할 경우 Authorization Server 에서 토큰을 비활성화(수동삭제 등) 해도 Resource Server 에서 반영되지 않는다.
Opaque Token 는 매 요청마다 토큰을 Authorization Server 로부터 검증받아야 하기 때문에 실시간 유효성 검증이 가능하다.
Spring Authorization Server 에서도 OAuth 2.0 기반의 Opaque Token 을 지원한다.
Authorization Server 에서의 Opaque
Opqeue Token 의 경우 유효성을 확인하기 위해선 항상 Authorization Server 에 요청이 필요하다. Authorization Server 의 개입이 각 API 마다 발생하지만 즉각적인 토큰의 유효성 체크가 가능하다.
// token claim 에 추가적인 정보를 삽입하기 위해 사용, // email 정보가 Resource Server 에 존재하거나 email 자체가 필요하지 않다면 추가할필요없다. @Bean public OAuth2TokenCustomizer<OAuth2TokenClaimsContext> accessTokenCustomizer() { return context -> { AuthUserEntityauthUser= authUserService.findByUname(context.getPrincipal().getName()).orElseThrow(); context.getClaims().claims(claims -> claims.put("email", authUser.getEmail())); }; }
Resour Server 가 access token(Opaque Token) 과 함께 userinfo 요청을 받으면 Opaque Token 의 유효성을 Authorization Server 로부터 검증받아야 한다.
OAuth2TokenIntrospectionEndpointFilter 에서 /oauth2/introspect url 을 처리하며 로 Opaque Token 형태의 access token 검증을 수행한다.
OAuth2TokenIntrospectionAuthenticationProvider 에서 Authorization Entity 를 DB 에서 조회, 해당 토큰이 유효한지 확인 후 OAuth2TokenIntrospectionAuthenticationToken 을 반환한다.