땃쥐네

[토이프로젝트] 게시판 시스템(board-system) 23. 스프링 시큐리티 OAuth2 Client 를 사용한 소셜 로그인 (2) 인가 엔드포인트 리다이렉트 본문

Project

[토이프로젝트] 게시판 시스템(board-system) 23. 스프링 시큐리티 OAuth2 Client 를 사용한 소셜 로그인 (2) 인가 엔드포인트 리다이렉트

ttasjwi 2024. 11. 30. 16:48

지난 글에서는 스프링 시큐리티 OAuth2 Client 연동을 위한 Client, Provider 설정을 구성했습니다.

이번 글부터는 본격적으로 기능을 하나씩 구현해보겠습니다.


1. 권한부여 요청 URL 구성해보기

여기서는 예시로 구글을 들어보겠습니다.

 

구글 연동 로그인을 위해서는

최종 사용자가 구글에게 '우리 서비스'가 자신의 특정 정보를 사용할 수 있어야한다는 승인을 해야합니다.

 

사용자는 결국 구글측의 어떤 엔드포인트를 호출해야합니다.

 

https://accounts.google.com/o/oauth2/v2/auth

 

실제로 구글은 위의 엔드포인트를 통해 권한부여를 수행할 수 있도록 합니다.

그런데 단지 저 요청만으로는 어느 서비스에게 권한을 부여해야하는 지 정보가 부족합니다.

 

https://accounts.google.com/o/oauth2/v2/auth
?response_type=code
&client_id=xxxxxx

 

여기서 살을 붙여서, 더 파라미터를 붙여봤습니다.

 

response_type (필수)

response_type 은 code 형태로 승인 결과물을 발급해야함을 의미합니다. OAuth2 권한부여는 authorization_code 방식 외에도 여러가지가 있는데 승인과정이 그 중 하나인 authorization_code 방식을 따른다는 것을 알려주기 위함입니다.

 

client_id (필수)

client_id 는 어느 OAuth2 Client 가 자원을 사용할 것인지를 지정하기 위함입니다. 이 자리에는 우리의 client_id 가 들어가야겠죠.

 

https://accounts.google.com/o/oauth2/v2/auth
?response_type=code
&client_id=xxxxxx
&scope=openid%20email
&redirect_uri=http://localhost:3000/auth/login/oauth2/code/google

 

scope(필수)

우리서비스에게 최종사용자의 자원 사용을 어느 범위까지 허용할 것인지에 대한 정보 또한 필요합니다.

open id connect 프로토콜을 따르려면 일단 scope에 openid가 필요해서 open id 를 넣었습니다.

또 이메일도 필요하기 때문에 email 파라미터도 넣었습니다.

 

redirect_uri(필수)

승인 후 어느 위치로 리다이렉트 시켜야할 지 지정해야합니다.

만약 이 값을 다른 악의적인 서비스의 url 로 지정하면 그 사이트로 승인 결과물이 담겨서 리다이렉트 되겠죠?

그래서 oauth2 client 등록 시점에 소셜 서비스들은 허용된 redirect_uri 를 미리 등록해두고 그 uri로만 리다이렉트를 허용합니다.

 

 

실제로 위 형식으로 요청을 작성하고 브라우저를 통해 접근해보면

승인 페이지에 도착하게 됩니다.

 

 

로그인을 하면 승인 페이지가 뜨며,

여기서 사용자 입장에서는 권한사용을 허용하고 싶다고 승인을 해야합니다.

 

http://localhost:3000/auth/login/oauth2/code/google
?code=yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
&scope=email+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+openid
&authuser=1
&prompt=none

 

승인까지 하게 되면 이렇게 리다이렉트 됩니다.

 

리다이렉트 결과에는 승인 결과물인 'code' 가 포함되어 오게 됩니다.

 

저는 이 리다이렉트 페이지를 프론트엔드가 처리한다고 가정하고 redirect_uri 로 설정했습니다.

 

프론트엔드 측에서는 여기서 받은 파라미터를 가지고 백엔드의 소셜 로그인 엔드포인트에 담아 보낸 뒤, 로그인 응답이 오면 후처리를 진행하면 됩니다.

 

그런데 여기까지 놓고보면 잘 된 것 같긴한데 몇 가지 놓친 문제가 있습니다.

 

https://accounts.google.com/o/oauth2/v2/auth
?response_type=code
&client_id=xxxxxx
&scope=openid%20email
&redirect_uri=http://localhost:3000/auth/login/oauth2/code/google

 

어느 누구든지 이런 URL 을 만들어낼 수 있다는 점입니다.

 

브라우저 기반의 애플리케이션에서는 client_id 는 매우 쉽게 노출될 수 있는 값입니다.

브라우저 상에서 동작하는 웹 페이지 특성상 개발자 도구를 통해 어느 위치로 어떤 요청이 가는지, 어떤 파라미터를 포함해서 요청을 보내는지 전부 추측할 수 있습니다.

 

redirect_uri 도 마음대로 작성할 수 있습니다. 물론 대부분의 서비스(제가 설정한 google, kakao, naver 포함)는 우리가 허용한 uri 로만 리다이렉트를 시킬 수 있도록 정책 설정이 되어있긴 합니다.

 

scope도 위와 같은 원리로 쉽게 작성할 수 있습니다.

 

 

 

이렇게 되면 우리 서비스와 상관없는 제3의 악의적인 서비스에서 알지도 못 하는 곳에서 승인장(code)을 먼저 받아오고 우리 서비스에 갖고와서 로그인해달라고 넘기는 상황이 나올 수 있습니다. 이는 보안상 그리 좋지 못 합니다.


2. 동적 파라미터와 보안강화

 

보안의 강화를 위해, 소셜로그인 절차를 수행하기 위해서 최초 요청은 우리 서버를 경유해서 가도록 해야합니다.

사용자가 구글에 승인요청을 받으러 가기 전에 우리 서버를 반드시 경유하게 하는 것을 강제하는 것이죠.

 

백엔드 서버를 통해서 구글의 승인페이지로 주소를 달라고 요청을 하면

백엔드는 동적으로 구글 승인페이지로 가는 URL 을 만들어서 사용자에게 내려주면 됩니다.

그리고, 이 파라미터에다가 동적으로 랜덤한 값을 섞어 넣어서 보안을 강화하도록 할거에요.

 

2.1 State 를 통해 인가 요청을 식별할 수 있게 하고 기억하자

https://accounts.google.com/o/oauth2/v2/auth
?response_type=code
&client_id=xxxxxx
&scope=openid%20email
&redirect_uri=http://localhost:3000/auth/login/oauth2/code/google
&state=yyyyyyyyyyyy

 

state 파라미터가 추가됐습니다.

state 파라미터는 우리 서버에서 동적으로 만들어낸 임의의 값이며, 이 파라미터 역시 OAuth2 표준 파라미터입니다.

 

구글 승인페이지로 이 URL 을 들고가면 구글은 우리가 전달한 redirect_uri 로 리다이렉트 시킬 때 code 도 전달해주지만, state 도 다시 돌려줍니다.(뿐만 아니라 모든 OAuth2 서비스들은 이렇게 state 를 다시 반환해야하는 것을 준수해야합니다.)

 

 

우리가 만약, 최초에 승인페이지 주소 요청이 들어왔을 때 요청 정보를 만들고, 이것을 저장소에 보관해둔다면

나중에 소셜 로그인 시점에 동일한 state 파라미터가 올 때 해당 요청을 state 로 꺼낼 수 있습니디.

이렇게 하면 우리 서비스를 거쳐서 code 를 발급 받았다고 검증을 강화할 수 있습니다.

 

보통 이런 데이터 저장, 조회에 대한 복잡한 기능은 프론트엔드 서버에서 담당하기 보다 백엔드에서 담당하는 경우가 많으므로 결국 승인페이지 URL 생성 및 반환의 책임은 백엔드가 져야합니다.

 

2.2 nonce: Open Id Token 보안 강화

https://accounts.google.com/o/oauth2/v2/auth
?response_type=code
&client_id=xxxxxx
&scope=openid%20email
&redirect_uri=http://localhost:3000/auth/login/oauth2/code/google
&state=yyyyyyyyyyyy
&nonce=zzzzzzzzzzzzzzzzzzz

 

여기에 더해서 openid connect 프로토콜을 따를 경우

nonce 파라미터도 더해서 보안을 강화할 수 있습니다.

 

 

 

최초 승인 요청 시 동적으로 랜덤하게 만들어낸 nonce 파라미터를 구글에게 전달하면

나중에 구글을 통하여 사용자의 open id token 을 발급받을 때 id 토큰 내부 payload 에 nonce 가 포함되어 오게 되는데요.

 

 

이렇게 하면 나중에 우리 서버에서 nonce 를 기억하고 있다가 id token 을 받았을 때

같은 nonce 인지 확인할 수 있어서 보안을 강화할 수 있습니다.

 

2.3 PKCE

https://accounts.google.com/o/oauth2/v2/auth
?response_type=code
&client_id=xxxxxx
&scope=openid%20email
&redirect_uri=http://localhost:3000/auth/login/oauth2/code/google
&state=yyyyyyyyyyyy
&nonce=zzzzzzzzzzzzzzzzzzz
&code_challenge=adfadfadsfadfadfafadafa
&code_challenge_method=S256

 

또, code_challenge 파라미터와 code_challenge_method 파라미터를 더하여 보안을 더 강화시킬 수 있습니다.

이것에 대해서는 PKCE(Proof Key for Code Exchange) 라는 개념 설명이 필요한데요.

 

PKCE 는 OAuth2 권한부여 과정에서 보안을 강화하기 위한 또 하나의 개념입니다.

 

 

랜덤한 값을 하나 만들어서(code_verifier)

특정 알고리즘을 통해(code_challenge_method)

 

결과값을 하나 만들어냅니다.(code_challenge)

 

여기서 code_challnge_method 는 주로 S256 방식이 많이 쓰입니다. (SHA-256 알고리즘으로 해싱 후 이를 다시 BASE64 URL Safe 인코딩을 통해 만들어내는 방식) plain 으로 하면 code_challenge가 원본값 그대로가 됩니다.

 

 

이걸 어떻게 써먹느냐가 문제겠죠?

 

우선 백엔드에서는 요청정보 생성 시점에

임의의 code_verifier 를 생성하고, code_challenge_method 를 정한 뒤 이를 통해 code_challenge 를 만들어냅니다.

이 정보 역시 따로 요청정보에 포함시켜두고 저장해둬야합니다.

 

그리고 구글 승인페이지 URL 을 만드는 시점에 code_challenge, code_challenge_method 를 파라미터에 포함해 보냅니다.

 

 

구글은 최초 승인요청시 보낸 code_challengecode_challenge_method 를 기억합니다.

 

이후 다시 백엔드가 토큰을 발급받는 시점에, code_verifier 를 추가로 담아 보내게 된다면

구글은 토큰 발급 과정에서 code_verifier 도 추가적인 검증에 사용하게 됩니다.

 

처음에 보냈던 code_challenge_method 와 이번에 보낸 code_verifier 를 이용해 code_challenge 를 또 만들어낼 수 있는데

이를 최초 보냈던 code_challenge 와 비교하여 잘 맞는 지 확인할 수 있습니다.

 

이렇게 보안을 강화할 수 있습니다.


3. 해야할 일

 

이번 글에서 다룰 주제는 사용자를 구글, 네이버, 카카오의 승인페이지로 리다이렉트 시키는 것입니다.

 

그리고 그냥 리다이렉트 시키는 것이 아니라

state / nonce / pkce 를 추가 적용하고 이러한 정보를 서버에서 기억할 수 있게 해야하는 것.

거기까지가 이번 글에서 다룰 목적입니다.

 

물론 이런 작업을 개발자가 직접 구현할 수 있겠지만...

지난 글에서 말씀드린 바와 같이, 바퀴를 굳이 다시 재발명할 필요가 없습니다.

이미 Spring Security OAuth2 Client 에서 대부분의 기능이 구현되어 있으므로 몇 가지 커스터마이징 할 부분만 커스터마이징 하면 됩니다. 차라리 이쪽이 개발비용이 저렴해지니 이 방법을 써보겠습니다.


4. OAuth2LoginConfigurer

		@Bean
		SecurityFilterChain oauth2SecurityFilterChain(HttpSecurity http) throws Exception {
			http.authorizeHttpRequests((requests) -> requests.anyRequest().authenticated());
			http.oauth2Login(withDefaults());
			return http.build();
		}

 

<java 방식>

    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
        http {
        	// 생략

            oauth2Login {}
        }
        return http.build()
    }

 

<kotlin dsl 방식>

 

스프링 시큐리티의 oauth2Login(...) API를 사용하면

  • OAuth2 최초 인가 요청 시 리다이렉트 처리
  • 사용자가 code 를 갖고 왔을 때의 소셜 서비스 회원 식별작업까지의 작업

 

이 두 가지를 모두 수행해줍니다.

다만 이 기능은 그대로 사용하기에는 우리 서비스 회원가입/로그인 까지의 작업을 해주지는 않습니다.

 

그래도 이 API가 많은 부분을 해주기 때문에 전반적으로 어떤 일이 일어나는 지, 어떤 부분을 고쳐야할 지 이해하고 변경할 수 있다면 매우 쉽고 빠르게 OAuth2 로그인/회원가입 기능을 구현할 수 있습니다.

 

kotlin DSL 방식이 편하긴하지만 결국 내부적으로 OAuth2LoginConfigurer 를 사용하는 원리는 같으므로 java dsl 기준으로 설명을 하겠습니다.

 

4.1 HttpSecurity.oauth2Login(...)

	public HttpSecurity oauth2Login(Customizer<OAuth2LoginConfigurer<HttpSecurity>> oauth2LoginCustomizer)
			throws Exception {
		oauth2LoginCustomizer.customize(getOrApply(new OAuth2LoginConfigurer<>()));
		return HttpSecurity.this;
	}

 

 

HttpSecurity의 oauth2Login(...) API 를 사용하면 OAuth2LoginConfigurer 설정을 적용하여

OAuth2 관련 필터기능을 활성화시킬 수 있습니다

 

이를 통해 HttpSecurity 클래스에 OAuth2LoginConfigurer 설정이 추가됩니다.

 

	@Override
	public final O build() throws Exception {
		if (this.building.compareAndSet(false, true)) {
			this.object = doBuild();
			return this.object;
		}
		throw new AlreadyBuiltException("This object has already been built");
	}
	@Override
	protected final O doBuild() throws Exception {
		synchronized (this.configurers) {
			this.buildState = BuildState.INITIALIZING;
			beforeInit();
			init();
			this.buildState = BuildState.CONFIGURING;
			beforeConfigure();
			configure();
			this.buildState = BuildState.BUILDING;
			O result = performBuild();
			this.buildState = BuildState.BUILT;
			return result;
		}
	}

 

이후 HttpSecurity 를 build 하여 SecurityFilterChain 을 하는 시점에

내부적으로 init, configurer, performBuild 작업을 거쳐서 여러가지 설정들이 적용되고 필터체인이 초기화됩니다.

 

	private void init() throws Exception {
		Collection<SecurityConfigurer<O, B>> configurers = getConfigurers();
		for (SecurityConfigurer<O, B> configurer : configurers) {
			configurer.init((B) this);
		}
		for (SecurityConfigurer<O, B> configurer : this.configurersAddedInInitializing) {
			configurer.init((B) this);
		}
	}
	private void configure() throws Exception {
		Collection<SecurityConfigurer<O, B>> configurers = getConfigurers();
		for (SecurityConfigurer<O, B> configurer : configurers) {
			configurer.configure((B) this);
		}
	}

 

이 과정에서는 하위 SecurityConfigurer (저희가 등록한 설정들 포함) 들이 init, configure 됩니다.

결국 OAuth2LoginConfigurer 가 어떤 설정을 해주는 지는 OAuth2LoginConfigurer 의 init, configure 메서드를 보면 됩니다.

 

4.2 OAuth2LoginConfigurer.init

	@Override
	public void init(B http) throws Exception {
		OAuth2LoginAuthenticationFilter authenticationFilter = new OAuth2LoginAuthenticationFilter(
				OAuth2ClientConfigurerUtils.getClientRegistrationRepository(this.getBuilder()),
				OAuth2ClientConfigurerUtils.getAuthorizedClientRepository(this.getBuilder()), this.loginProcessingUrl);
		authenticationFilter.setSecurityContextHolderStrategy(getSecurityContextHolderStrategy());
		
        this.setAuthenticationFilter(authenticationFilter);
		super.loginProcessingUrl(this.loginProcessingUrl);
        
        // 생략
        
		this.initDefaultLoginFilter(http);
	}

 

여기서는 다음 작업들이 일어납니다.

  • OAuth2LoginAuthenticationFilter 설정
    • 이 필터는 인가서버를 통해 사용자 신원 정보를 가져와 해당 서비스의 어느 사용자인지 사용자 신원을 획득 합니다.
    • 기본 Url: "/login/oauth2/code/*"
    • OAuth2LoginAuthenticationProvider
      • code 를 기반으로 access token을 가져오고 이것으로 리소스 서버를 통해 사용자 정보를 가져옵니다.
    • OidcAuthorizationCodeAuthenticationProvider
      • code 를 기반으로 access token 및 open id token을 가져오고 이것으로 사용자 정보를 추출하여 사용자 신원을 획득합니다.
  • DefaultLoginPageGeneratingFilter 추가 설정
    • 기본 로그인 페이지를 만들어 반환하는 필터 DefaultLoginPageGeneratingFilter에 대해 추가 설정합니다.
    • 디폴트 로그인 페이지가 작동할 경우 OAuth2 Login 사양에 맞춰서 기본 로그인 페이지도 변경됩니다.
    • 그런데 저는 ExceptionTranslationFilter 에서 AuthenticationEntryPoint, AccessDeniedHandler를 커스터마이징 하였다보니 이 필터 자체가 등록되지 않았고, 별도의 추가 설정이 일어나지 않게 됩니다(DefaultLoginPageConfigurer)

당장 리다이렉트 기능을 구현하는 관점에서 보면 이 부분은 리다이렉트 기능 설정과는 관계가 없습니다.

 

4.3 OAuth2LoginConfigurer.configure

@Override
public void configure(B http) throws Exception {
    OAuth2AuthorizationRequestRedirectFilter authorizationRequestFilter;
    if (this.authorizationEndpointConfig.authorizationRequestResolver != null) {
       authorizationRequestFilter = new OAuth2AuthorizationRequestRedirectFilter(
             this.authorizationEndpointConfig.authorizationRequestResolver);
    }
    else {
       String authorizationRequestBaseUri = this.authorizationEndpointConfig.authorizationRequestBaseUri;
       if (authorizationRequestBaseUri == null) {
          authorizationRequestBaseUri = OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI;
       }
       authorizationRequestFilter = new OAuth2AuthorizationRequestRedirectFilter(
             OAuth2ClientConfigurerUtils.getClientRegistrationRepository(this.getBuilder()),
             authorizationRequestBaseUri);
    }
    if (this.authorizationEndpointConfig.authorizationRequestRepository != null) {
       authorizationRequestFilter
          .setAuthorizationRequestRepository(this.authorizationEndpointConfig.authorizationRequestRepository);
    }
    if (this.authorizationEndpointConfig.authorizationRedirectStrategy != null) {
       authorizationRequestFilter
          .setAuthorizationRedirectStrategy(this.authorizationEndpointConfig.authorizationRedirectStrategy);
    }
    RequestCache requestCache = http.getSharedObject(RequestCache.class);
    if (requestCache != null) {
       authorizationRequestFilter.setRequestCache(requestCache);
    }
    http.addFilter(this.postProcess(authorizationRequestFilter));
    OAuth2LoginAuthenticationFilter authenticationFilter = this.getAuthenticationFilter();
    if (this.redirectionEndpointConfig.authorizationResponseBaseUri != null) {
       authenticationFilter.setFilterProcessesUrl(this.redirectionEndpointConfig.authorizationResponseBaseUri);
    }
    if (this.authorizationEndpointConfig.authorizationRequestRepository != null) {
       authenticationFilter
          .setAuthorizationRequestRepository(this.authorizationEndpointConfig.authorizationRequestRepository);
    }
    configureOidcSessionRegistry(http);
    super.configure(http);
}

 

이 메서드에서는 다음 요소들이 기본값 또는 커스텀 설정에 기반하여 구성됩니다.

 

  • OAuth2AuthorizationRequestRedirectFilter
    • 임시 code 발급을 위한 필터
    • 클라이언트에게서 이 엔드포인트로 요청이 들어오면, code 발급을 위한 서비스 제공자 페이지로 리다이렉트 시킵니다.
    • 기본동작 조건 : '/oauth2/authorization/{registrationId}'
  • OAuth2LoginAuthenticationFilter
    • RedirectionEndPointConfig 설정에서 baseUri 설정이 되어있을 경우 로그인 처리 Uri를 이 값으로 지정합니다.
    • 즉 oauth2Login() 설정에서 loginProcessingUri 설정을 하더라도 RedirectionEndPointConfig.baseUri 를 설정하면 RedirectionEndPointConfig.baseUri 설정이 우선시되어 로그인 처리 Uri 설정이 지정됩니다.

여기서 눈여겨볼 부분은 OAuth2AuthorizationRequestRedirectFilter 여기입니다.

지금 인가서버측을 향한 리다이렉트 기능을 담당하는 부분이 여기가 됩니다.

 

4.4 OAuth2LoginConfigurer의 하위 설정들

public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>>
		extends AbstractAuthenticationFilterConfigurer<B, OAuth2LoginConfigurer<B>, OAuth2LoginAuthenticationFilter> {

	private final AuthorizationEndpointConfig authorizationEndpointConfig = new AuthorizationEndpointConfig();

	private final TokenEndpointConfig tokenEndpointConfig = new TokenEndpointConfig();

	private final RedirectionEndpointConfig redirectionEndpointConfig = new RedirectionEndpointConfig();

	private final UserInfoEndpointConfig userInfoEndpointConfig = new UserInfoEndpointConfig();

	private String loginPage;

	private String loginProcessingUrl = OAuth2LoginAuthenticationFilter.DEFAULT_FILTER_PROCESSES_URI;

 

OAuth2 Login Configurer 에는 여러가지 하위 설정이 모여있습니다.

 

1. AuthorizationEndPointConfig

인가 서버의 권한 부여 엔드포인트로 리다이렉트 시키는 작업에 관련된 설정입니다. 여기에 설정한 것들이 OAuth2AuthorizationRequestRedirectFilter에 반영됩니다.

 

OAuth2AuthorizationRequestResolver, 어느 URL로 들어왔을 때 이 필터에 매칭되게 할지, 리다이렉트 전략은 어떻게 할 것인지, OAuth2AuthorizationRequestRepository 는 어떻게 할 것인지 설정합니다.

 

2. TokenEndpointConfig

인가 서버의 토큰발급 엔드포인트 관련 설정으로서, 실제 인가서버와 통신할 때 토큰 엔드포인트와 통신하는 역할인 

OAuth2AccessTokenResponseClient 를 별도로 커스터마이징해야한다면 여기서 설정합니다.

 

3. RedirectionEndpointConfig

사용자가 code를 갖고왔을 때, 로그인을 처리할 엔드포인트의 baseUri 를 설정하는 부분입니다.

baseUri(...) 를 통해서 설정할 수 있는데

 

이 값도 설정하고 아래의 loginProcessingUrl도 설정하면 이 값이 우선시되긴 합니다.

 

4. UserInfoEndpointConfig

실제 OAuth2 토큰 발급 후 사용자 정보를 가져오는 실질적 작업은 OAuth2UserService, OidcUserService 에서 이루어지는데요. 이 두 부분을 설정하는 작업을 이 설정을 통해 할 수 있습니다.

 

5. loginPage

우리 서버의 로그인페이지 위치를 지정합니다. 그런데 저는 백엔드-프론트엔드 분리를 염두해두고 서버를 설계하고 있으며, 로그인페이지를 관리하지 않으므로 이 값을 설정하지 않았습니다.

 

6. loginProcessingUrl

사용자가 code를 갖고왔을 때, 로그인을 처리할 엔드포인트의 Url 패턴을 설정합니다.

 

이런 부분을 저희가 커스터마이징하면 그 커스타미이징 한 값들이 위에서 언급한 init, configurer 작업에서 반영됩니다.

 

 

이런 설정이 있다는건 확인했지만, 결국 리다이렉트를 실질적으로 처리하는 부분인 OAuth2AuthorizationRequestRedirectFilter가 어떻게 돌아가는 지 확인해야 제대로 설정 API를 쓸 수 있습니다.

 

OAuth2AuthorizationRequestRedirectFilter 를 확인해보겠습니다.

 


5. OAuth2AuthorizationRequestRedirectFilter

Spring Security OAuth2 Client 에서 사용자 요청이 들어왔을 때 사용자를 소셜서비스의 인가 엔드포인트로 리다이렉트 시키는 작업은 OAuth2 AuthorizationRequestRedirectFilter 에서 일어납니다.

 

전체적으로 필터가 어떤 식으로 돌아가는 지 흐름을 보겠습니다.

 

5.1 의존성

public class OAuth2AuthorizationRequestRedirectFilter extends OncePerRequestFilter {


	public static final String DEFAULT_AUTHORIZATION_REQUEST_BASE_URI = "/oauth2/authorization";

	private final ThrowableAnalyzer throwableAnalyzer = new DefaultThrowableAnalyzer();
	private RedirectStrategy authorizationRedirectStrategy = new DefaultRedirectStrategy();
	private OAuth2AuthorizationRequestResolver authorizationRequestResolver;
	private AuthorizationRequestRepository<OAuth2AuthorizationRequest> authorizationRequestRepository = new HttpSessionOAuth2AuthorizationRequestRepository();
	private RequestCache requestCache = new HttpSessionRequestCache();
	private AuthenticationFailureHandler authenticationFailureHandler = this::unsuccessfulRedirectForAuthorization;

 

OAuth2AuthorizationRequestRedirectFilter 의 의존성들입니다.

 

 

OAuth2AuthorizationRequestResolver

사용자 요청이 들어왔을 때

OAuth2 Authorization Server(소셜 서비스의 인가 서버) 측에 리다이렉트 시키는데 사용할

요청 정보(OAuth2AuthorizationRequest)를 만드는 역할을 담당합니다.

 

AuthorizationRequestRepository<OAuth2AuthorizationRequest>

위에서 생성된 요청정보(OAuth2AuthorizationRequest) 를 저장하는 역할입니다.

기본적으로 HttpSession 에 저장하는 방식으로 구현되어 있는데,

혹시 중간에 재배포로 인해서 애플리케이션이 사라지면 HttpSession 안의 값이 사라지는 문제가 있죠.

저는 이걸 외부 저장소인 Redis 에 저장하도록 구현체를 바꿀거에요.

 

RedirectStrategy

실제 리다이렉트 HTTP 응답을 작성하는 역할을 담당합니다.

만약 리다이렉트를 시키고 싶지 않고, JSON 응답에 URL 을 작성해서 보내고 싶다면 이 부분을 커스터마이징 하면 됩니다.

 

RequestCache

예외가 발생했을 때 현재 요청 정보를 캐싱할 목적으로 사용하는 역할이며, 기본 구현체는 HttpSession 에 저장합니다.

저는 현재 요청정보 캐싱이 필요 없어서 나중에 이부분을 캐싱을 하지 않도록 커스터마이징할거에요.

 

AuthenticationFailureHandler

위의 과정에서 실패했을 때 인증예외를 처리하는 역할입니다.

이 부분 역시 제가 커스텀한 응답을 작성할 수 있도록 커스터마이징할거에요.

 

5.2 필터 흐름

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
       throws ServletException, IOException {
    try {
       OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestResolver.resolve(request);
       if (authorizationRequest != null) {
          this.sendRedirectForAuthorization(request, response, authorizationRequest);
          return;
       }
    }
    catch (Exception ex) {
       AuthenticationException wrappedException = new OAuth2AuthorizationRequestException(ex);
       this.authenticationFailureHandler.onAuthenticationFailure(request, response, wrappedException);
       return;
    }
    try {
       filterChain.doFilter(request, response);
    }
	private void sendRedirectForAuthorization(HttpServletRequest request, HttpServletResponse response,
			OAuth2AuthorizationRequest authorizationRequest) throws IOException {
		if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(authorizationRequest.getGrantType())) {
			this.authorizationRequestRepository.saveAuthorizationRequest(authorizationRequest, request, response);
		}
		this.authorizationRedirectStrategy.sendRedirect(request, response,
				authorizationRequest.getAuthorizationRequestUri());
	}

 

필터의 전반 흐름을 보겠습니다.

 

1. OAuth2 인가 요청 획득

맨 처음 요청이 들어오면 사용자 요청을 확인하여 OAuth2AuthorizationRequestResolver 를 통해 OAuth2AuthorizationRequest 를 획득합니다.

 

2. 만들어진 OAuth2AuthorizationRequest가 있을 때

만약 만들어지는 OAuth2AuthorizationRequest 가 있으면, sendRedirectForAuthorization 에서 후속 처리를 수행하는데요.

Authorization GrantType(OAuth2 인가유형)이 code 일경우 OAuth2AuthorizationRequest  AuthorizationRequestRepository 에 저장한뒤 OAuth2AuthorizationRequest 안에 완성되어 있는 authorizationRequestUri(리다이렉트 주소) 로 redirectStrategy 를 통해 요청을 리다이렉트 시킵니다.

 

3. 만들어진 OAuth2AuthorizationRequest가 없을 때

얻어지는 OAuth2AuthorizationRequest 가 없으면 그대로 filterChain.doFilter 를 통해 다음 필터로 통과시킵니다.

 

4. 예외가 발생했을 때

도중에 스프링 시큐리티 인증예외(AuthenticationException)가 발생했을 경우 AuthenticationFailureHandler를 통해 예외 후속처리를 수행시킵니다.

 

여기서 눈여겨 볼 부분은 OAuth2AuthorizationRequestResolver, AuthorizationRequestRepository, AuthenticationFailureHandler 부분이 될 것이며 이 부분을 하나씩 살펴보고 커스터마이징 해보겠습니다.


6. OAuth2AuthorizationRequestResolver

6.1 인터페이스 계약

public interface OAuth2AuthorizationRequestResolver {

	OAuth2AuthorizationRequest resolve(HttpServletRequest request);

	OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId);

}

 

OAuth2AuthorizationRequestResolver 는 사용자 요청이 들어왔을 때

소셜 서비스의 인가서버, OAuth2 Authorization Server 측에 전달할 인가요청(OAuth2AuthorizationRequest) 에 관한 정보를 만드는 역할입니다.

 

6.2 기본구현체 : DefaultOAuth2AuthorizationRequestResolver

OAuth2AuthorizationRequestResolver  의 기본 구현체는 DefaultOAuth2AuthorizationRequestResolver 입니다.

private static final String REGISTRATION_ID_URI_VARIABLE_NAME = "registrationId";

public DefaultOAuth2AuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository,
			String authorizationRequestBaseUri) {
    Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null");
    Assert.hasText(authorizationRequestBaseUri, "authorizationRequestBaseUri cannot be empty");
    this.clientRegistrationRepository = clientRegistrationRepository;
    this.authorizationRequestMatcher = new AntPathRequestMatcher(
            authorizationRequestBaseUri + "/{" + REGISTRATION_ID_URI_VARIABLE_NAME + "}");
}

 

DefaultOAuth2AuthorizationRequestResolver 는 생성시점에 ClientRegistrationRepository 와 authorizationRequestBaseUri(베이스가 되는 Uri) 파라미터를 필요로 합니다.

 

OAuth2LoginConfigurer 의 설정 코드를 따라가다보면 

ClientRegistrationRepository 부분은 스프링이 빈 등록한 ClientRegistration 이 주입되고

baseUri 자리에는 기본적으로 "/oauth2/authorization" 이 주입됩니다.

 

그리고 AuthorizationRequestBaseUri 와 "/", 그리고 "{registrationId}" 가 합쳐져서

AuthorizationRequestMatcher(요청 매칭기) 가 만들어지는데,

이 요청 매칭기는 "/oauth2/authorization/{registrationId}" 패턴에 일치될 때 매칭되도록 설정됩니다.

 

	@Override
	public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
		String registrationId = resolveRegistrationId(request);
		if (registrationId == null) {
			return null;
		}
		String redirectUriAction = getAction(request, "login");
		return resolve(request, registrationId, redirectUriAction);
	}

	@Override
	public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String registrationId) {
		if (registrationId == null) {
			return null;
		}
		String redirectUriAction = getAction(request, "authorize");
		return resolve(request, registrationId, redirectUriAction);
	}
	private String resolveRegistrationId(HttpServletRequest request) {
		if (this.authorizationRequestMatcher.matches(request)) {
			return this.authorizationRequestMatcher.matcher(request)
				.getVariables()
				.get(REGISTRATION_ID_URI_VARIABLE_NAME);
		}
		return null;
	}

 

기본 구현체는 DefaultOAuth2AuthorizationRequestResolver 입니다.

 

먼저 요청이 들어왔을 때 registrationId 를 추출하는 메서드 resolveRegistrationId 메서드가 작동하는데요.

여기서 요청 매칭기(authorizationRequestMatcher)와 매칭하여,  "baseUri/{registrationId}" 패턴으로부터 {registrationId} 자리에 오는 값을 추출합니다. (여기서의 registrationId 라 함은 우리 서버에서 이전 글에서 등록한 ClientRegistration의 id에 해당하는 값이라 보시면 됩니다.) 만약 매칭되지 않는다면 null 이 반환됩니다.

 

예를 들어 baseUri 를 "/api/v1/auth/oauth2/authorization" 으로 설정했다 치면,

 

/api/v1/oauth2/authorization/google 에서 google 이

/api/v1/oauth2/authorization/naver 에서 naver 가

/api/v1/oauth2/authorization/kakao 에서 kakao가

추출되는거죠.

 

그리고 이런 패턴이 맞지 않다? 그러면 null 이 반환되는겁니다.

 

	private OAuth2AuthorizationRequest resolve(HttpServletRequest request, String registrationId,
			String redirectUriAction) {
		if (registrationId == null) {
			return null;
		}
		ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId);
		if (clientRegistration == null) {
			throw new InvalidClientRegistrationIdException("Invalid Client Registration with Id: " + registrationId);
		}
		OAuth2AuthorizationRequest.Builder builder = getBuilder(clientRegistration);

		String redirectUriStr = expandRedirectUri(request, clientRegistration, redirectUriAction);


		builder.clientId(clientRegistration.getClientId())
				.authorizationUri(clientRegistration.getProviderDetails().getAuthorizationUri())
				.redirectUri(redirectUriStr)
				.scopes(clientRegistration.getScopes())
				.state(DEFAULT_STATE_GENERATOR.generateKey());

		this.authorizationRequestCustomizer.accept(builder);

		return builder.build();
	}

 

그후 내부의 resolve 메서드에서 실제 OAuth2AuthorizationRequest 작성이 진행됩니다.

 

1. registrationId가 null 이면 작업을 처리하지 않고 null 을 반환합니다. (OAuth2 인가 요청이 아님)

 

2. registrationId 를 통해 ClientRegistrationRepository에서 ClientRegistration 을 조회합니다. 이 때 조회되는 ClientRegistration 이 없으면 예외가 발생합니다. 예를 들면 /api/v1/oauth2/authorization/facebook 처럼 우리 서비스에서 지원되지 않는 registrationId가 경로에 있을 때 예외가 발생한다 보면 되겠네요.

 

3. ClientRegistration 이 잘 조회됐다면 이 정보를 기반으로 OAuth2Authorization 작성이 작성이 진행됩니다.

getBuilder 메서드를 통해 builder를 가져오고 여기에 여러가지 부가적인 정보를 더 덧붙여서 OAuth2Authorization 을 build 해 나갑니다.

 

위 코드를 잘 보시면 DEFAULT_STATE_GENERATOR 를 통해 state 값을 생성해서 요청에 포함시키는 것을 볼 수 있습니다. 동적으로 우리 서버에서 랜덤한 state 값을 생성하는 것이죠.

 

	private OAuth2AuthorizationRequest.Builder getBuilder(ClientRegistration clientRegistration) {
		if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(clientRegistration.getAuthorizationGrantType())) {

			OAuth2AuthorizationRequest.Builder builder = OAuth2AuthorizationRequest.authorizationCode()
					.attributes((attrs) ->
							attrs.put(OAuth2ParameterNames.REGISTRATION_ID, clientRegistration.getRegistrationId()));

			if (!CollectionUtils.isEmpty(clientRegistration.getScopes())
					&& clientRegistration.getScopes().contains(OidcScopes.OPENID)) {
				applyNonce(builder);
			}
			if (ClientAuthenticationMethod.NONE.equals(clientRegistration.getClientAuthenticationMethod())) {
				DEFAULT_PKCE_APPLIER.accept(builder);
			}
			return builder;
		}
		throw new IllegalArgumentException(
				"Invalid Authorization Grant Type (" + clientRegistration.getAuthorizationGrantType().getValue()
						+ ") for Client Registration with Id: " + clientRegistration.getRegistrationId());
	}

 

getBuilder 메서드에서는 AuthorizationGranrtType 이 authorization_code 인지 확인하고(우리 서버의 ClientRegistration 들은 모두 authorization_code 방식의 인가를 사용하도록 설정되어 있습니다.) Builder 생성을 수행합니다.

 

만약 ClientRegistration의 scope 목록에 "openid"가 있다면 applyNonce 를 통해 nonce 값도 생성합니다.

 

그런데 ClientRegistrationClientAuthenticationMethod(액세스 토큰 획득 시, 클라이언트 인증방법)이 none 인 경우에만 PKCE를 적용한다고 되어 있습니다. 저희 서버는 client_id, client_secret 을 자격증명으로 하여 인가서버에 토큰을 얻어오고 있으므로 이 분기문에 해당하지 않게되고, PKCE가 적용되지 않게 됩니다.

 

모든 경우에 항상 PKCE 를 적용하게 바꾸려면 OAuth2AuthorizationRequestResolver를 커스터마이징 해야합니다.

6.3 CustomOAuth2AuthorizationRequestResolver 구현

class CustomOAuth2AuthorizationRequestResolver
private constructor(
    private val defaultOAuth2AuthorizationRequestResolver: DefaultOAuth2AuthorizationRequestResolver,
    private val requestMatcher: RequestMatcher
) : OAuth2AuthorizationRequestResolver {

    companion object {
        private const val REGISTRATION_ID_URI_VARIABLE_NAME = "registrationId"
        private val DEFAULT_PKCE_APPLIER: Consumer<OAuth2AuthorizationRequest.Builder> =
            OAuth2AuthorizationRequestCustomizers.withPkce()

        fun of(clientRegistrationRepository: ClientRegistrationRepository,
               authorizationRequestBaseUri: String): CustomOAuth2AuthorizationRequestResolver {
            return CustomOAuth2AuthorizationRequestResolver(
                defaultOAuth2AuthorizationRequestResolver = DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository, authorizationRequestBaseUri),
                requestMatcher = AntPathRequestMatcher("${authorizationRequestBaseUri}/{$REGISTRATION_ID_URI_VARIABLE_NAME}")
            )
        }
    }

    override fun resolve(request: HttpServletRequest): OAuth2AuthorizationRequest? {
        return resolve(request, resolveClientRegistrationId(request))
    }

    override fun resolve(request: HttpServletRequest, clientRegistrationId: String?): OAuth2AuthorizationRequest? {
        if (clientRegistrationId == null) {
            return null
        }

        // 예외가 발생하거나(provider id 안 맞아서)
        // null 이 아닌 request 가 만들어짐
        val defaultResolverResolvedRequest = defaultOAuth2AuthorizationRequestResolver.resolve(request, clientRegistrationId)!!
        val builder = OAuth2AuthorizationRequest.from(defaultResolverResolvedRequest)

        // pkce 적용
        DEFAULT_PKCE_APPLIER.accept(builder)

        return builder.build()
    }

    private fun resolveClientRegistrationId(request: HttpServletRequest): String? {
        if (requestMatcher.matches(request)) {
            return requestMatcher.matcher(request).variables[REGISTRATION_ID_URI_VARIABLE_NAME]
        }
        return null
    }
}

 

OAuth2AuthorizatonRequestResolver를 커스터마이징하여 CustomOAuth2AuthorizationRequestResolver를 만들었습니다.

 

내부 의존성으로 DefaultOAuth2AuthorizationRequestResolver 와 RequestMatcher를 두었고 전체적인 흐름을 DefaultOAuth2AuthorizationRequestResolver 와 비슷하게 만들었습니다.

 

"authorizationRequestBaseUri/{registrationId}" 에 매칭될 때 작동하는 것까지는 기존의 기본 로직과는 같은데

내부적으로 DefaultOAuth2AuthorizationRequestResolver 에 처리를 위임하고나서 그 결과를 다시 builder로 만들어서

여기에 PKCE를 적용하는 식으로 커스텀 로직을 구현하였습니다.

 

실질적인 OAuth2AuthorizationRequest 대부분의 작성은 DefaultOAuth2AuthorizationRequestResolver 에게 맡기고 거기에 추가적으로 PKCE 를 적용하도록 바귀었습니다.

6.4 설정

package com.ttasjwi.board.system.core.config

// 생략

@Configuration
class FilterChainConfig(
	// 생략
    private val clientRegistrationRepository: ClientRegistrationRepository,
) {

    companion object {
        private const val OAUTH2_AUTHORIZATION_REQUEST_BASE_URI = "/api/v1/auth/oauth2/authorization"
    }
    
    // 생략

    private fun customOAuth2AuthorizationRequestResolver(): OAuth2AuthorizationRequestResolver {
        return CustomOAuth2AuthorizationRequestResolver.of(
            clientRegistrationRepository = clientRegistrationRepository,
            authorizationRequestBaseUri = OAUTH2_AUTHORIZATION_REQUEST_BASE_URI
        )
    }


}

 

실제 CustomOAuth2AuthorizationRequestResolver 를 생성하는 부분은 FilterChainConfig 의 메서드에 따로 두었습니다.

의존성으로는 스프링 빈으로 등록된 ClientRegistrationRepository 를 받고,

baseUri 설정으로 "/api/v1/auth/oauth2/authorization" 으로 삼았습니다.


7. AuthorizationRequestRepository

7.1 인터페이스 계약

public interface AuthorizationRequestRepository<T extends OAuth2AuthorizationRequest> {

	T loadAuthorizationRequest(HttpServletRequest request);
	void saveAuthorizationRequest(T authorizationRequest, HttpServletRequest request, HttpServletResponse response);
	T removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response);

}

 

OAuth2AuthorizationRequest 저장을 담당하는 AuthorizationRequestRepository 입니다.

이 인터페이스의 구현체는 다음을 준수해야합니다.

 

  • saveAuthorizationRequest : 저장
    • OAuth2AuthorizationRequest를 저장합니다. 만약 이 자리에 null 이 있다면 삭제해야합니다.
  • removeAuthorizatonRequest : 삭제
    • OAuth2AuthorizationRequest를 조회하고 삭제해야한 뒤 조회한 결과물을 반환해야하는 계약을 준수해야합니다. (리스코프 치환원칙) 저는 이 부분을 모르고 그냥 삭제하는 식으로 구현했다가 문제가 발생했었어요. 나중에 인터페이스 위에 작성된 주석을 보고서야 구현을 고쳤습니다... 실제 토큰 인증을 하는 코드쪽에서 삭제하면서 조회 결과물을 이용하는 부분이 있더라구요.
  • loadAuthorizatonRequest : 조회
    • OAuth2AuthorizationRequest 를 조회합니다.

7.2 기본 구현체 HttpSessionOAuth2AuthorizationRequestRepository

public final class HttpSessionOAuth2AuthorizationRequestRepository
		implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {

	private static final String DEFAULT_AUTHORIZATION_REQUEST_ATTR_NAME = HttpSessionOAuth2AuthorizationRequestRepository.class
		.getName() + ".AUTHORIZATION_REQUEST";

	private final String sessionAttributeName = DEFAULT_AUTHORIZATION_REQUEST_ATTR_NAME;

	@Override
	public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
		Assert.notNull(request, "request cannot be null");
		String stateParameter = getStateParameter(request);
		if (stateParameter == null) {
			return null;
		}
		OAuth2AuthorizationRequest authorizationRequest = getAuthorizationRequest(request);
		return (authorizationRequest != null && stateParameter.equals(authorizationRequest.getState()))
				? authorizationRequest : null;
	}

	@Override
	public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request,
			HttpServletResponse response) {
		Assert.notNull(request, "request cannot be null");
		Assert.notNull(response, "response cannot be null");
		if (authorizationRequest == null) {
			removeAuthorizationRequest(request, response);
			return;
		}
		String state = authorizationRequest.getState();
		Assert.hasText(state, "authorizationRequest.state cannot be empty");
		request.getSession().setAttribute(this.sessionAttributeName, authorizationRequest);
	}

 

기본 구현체는 HttpSession(세션) 에 OAuth2AuthorizationRequest 를 저장하는 구현체입니다.

그런데 기본적으로 세션은 애플리케이션 내부의 메모리 기반으로 동작하는 문제가 있습니다.

 

우리 애플리케이션은 1대만 배포되고 있긴하지만, 도중에 무중단 배포를 거치면서 애플리케이션을 종료시키고 새 애플리케이션을 구동시키는 경우가 빈번하다보니 메모리에 OAuth2AuthorizationRequest를 저장하게 되면, 애플리케이션 재 구동시 데이터가 유실되는 문제가 있습니다.

 

또, 애플리케이션을 2대-3대 이상 관리하는 상황에 대해서도 메모리 방식은 사용이 어렵습니다.

 

7.3 RedisOAuth2AuthorizationRequestRepository

그래서, 애플리케이션의 외부에 위치한 저장소인 Redis 에 OAuth2AuthorizationRequest 정보를 저장해보겠습니다.

전체적인 Redis 저장 구현 방식은 기존에 작성한 Redis 저장 기법과 거의 동일하게 갑니다.

 

 

external-redis 모듈에서 구현을 해보겠습니다.

dependencies {
    implementation(Dependencies.SPRING_BOOT_DATA_REDIS.fullName)
    implementation(Dependencies.SPRING_SECURITY_OAUTH2_CLIENT.fullName)
    implementation(Dependencies.SPRING_BOOT_WEB.fullName)
    implementation(Dependencies.KOTLIN_JACKSON.fullName)
    implementation(Dependencies.JACKSON_DATETIME.fullName)
    implementation(project(":board-system-domain:domain-core"))
    implementation(project(":board-system-domain:domain-member"))
    implementation(project(":board-system-domain:domain-auth"))

    testImplementation(testFixtures(project(":board-system-domain:domain-core")))
    testImplementation(testFixtures(project(":board-system-domain:domain-member")))
    testImplementation(testFixtures(project(":board-system-domain:domain-auth")))
}
enum class Dependencies(
    private val groupId: String,
    private val artifactId: String,
    private val version: String? = null,
    private val classifier: String? = null,
) {

    KOTLIN_JACKSON(groupId = "com.fasterxml.jackson.module", artifactId = "jackson-module-kotlin"),
    
    SPRING_BOOT_WEB(groupId = "org.springframework.boot", artifactId = "spring-boot-starter-web"),
    SPRING_BOOT_DATA_REDIS(groupId = "org.springframework.boot", artifactId = "spring-boot-starter-data-redis"),
    SPRING_SECURITY_OAUTH2_CLIENT(groupId = "org.springframework.security", artifactId="spring-security-oauth2-client"),

    // jackson date time
    JACKSON_DATETIME(groupId = "com.fasterxml.jackson.datatype", artifactId ="jackson-datatype-jsr310"),
    
    val fullName: String
        get() {
            if (version == null) {
                return "$groupId:$artifactId"
            }
            if (classifier == null) {
                return "$groupId:$artifactId:$version"
            }
            return "$groupId:$artifactId:$version:$classifier"
        }
}

 

 스프링 시큐리티의 AuthorizationRequestRepository 및 OAuth2AuthorizationRequest 최소한의 의존이 필요하므로 build.gradle.kts 쪽에 spring-security-oauth2-client 의존성을 추가합니다. (스프링 부트 모듈인 spring-boot-starter-oauth2-client 가 아닙니다.)

package com.ttasjwi.board.system.external.spring.security.oauth2.redis

import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest

class RedisOAuth2AuthorizationRequest(
    val authorizationUri: String,
    val authorizationGrantType: String,
    val responseType: String,
    val clientId: String,
    val redirectUri: String,
    val scopes: Set<String>,
    val state: String,
    val additionalParameters: Map<String, Any>,
    val authorizationRequestUri: String,
    val attributes: Map<String, Any>
) {

    companion object {
        fun from(oauth2AuthorizationRequest: OAuth2AuthorizationRequest): RedisOAuth2AuthorizationRequest {
            return RedisOAuth2AuthorizationRequest(
                authorizationUri = oauth2AuthorizationRequest.authorizationUri,
                authorizationGrantType = oauth2AuthorizationRequest.grantType.value,
                responseType = oauth2AuthorizationRequest.responseType.value,
                clientId = oauth2AuthorizationRequest.clientId,
                redirectUri = oauth2AuthorizationRequest.redirectUri,
                scopes = oauth2AuthorizationRequest.scopes,
                state = oauth2AuthorizationRequest.state,
                additionalParameters = oauth2AuthorizationRequest.additionalParameters,
                authorizationRequestUri = oauth2AuthorizationRequest.authorizationRequestUri,
                attributes = oauth2AuthorizationRequest.attributes
            )
        }
    }


    fun toOAuth2AuthorizationRequest(): OAuth2AuthorizationRequest {
        return OAuth2AuthorizationRequest.authorizationCode()
            .authorizationUri(this.authorizationUri)
            .clientId(this.clientId)
            .redirectUri(this.redirectUri)
            .scopes(this.scopes)
            .state(this.state)
            .additionalParameters(this.additionalParameters)
            .authorizationRequestUri(this.authorizationRequestUri)
            .attributes(this.attributes)
            .build()
    }
}

 

Redis에 저장하기 위한 모델 RedisOAuth2AuthorizationRequest 입니다.

 

OAuth2AuthorizationReqeust 로부터 생성하는 코드와, 역으로 OAuth2AuthorizationRequest 로 만드는 코드 양쪽을 두었습니다.

 

package com.ttasjwi.board.system.external.spring.security.oauth2.redis

import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.data.redis.serializer.RedisSerializer

class RedisOAuth2AuthorizationRequestSerializer(
    private val objectMapper: ObjectMapper
) : RedisSerializer<RedisOAuth2AuthorizationRequest> {

    override fun serialize(value: RedisOAuth2AuthorizationRequest?): ByteArray? {
        return objectMapper.writeValueAsBytes(value)
    }

    override fun deserialize(bytes: ByteArray?): RedisOAuth2AuthorizationRequest? {
        if (bytes == null) return null
        return objectMapper.readValue(bytes, RedisOAuth2AuthorizationRequest::class.java)
    }
}

 

Redis 저장을 위해서 RedisSerializer 구현체를 지정해야하는데요.

저는 json으로 데이터를 직렬화하고, 데이터 조회시 json 으로부터 역직렬화를 위해 ObjectMapper를 사용해서 작성했습니다. 이 구현방식은 기존에 작성해둔 RedisSerializer 들과 같습니다.

 

@Configuration
class RedisOAuth2AuthorizationTemplateConfig {

    @Bean
    fun redisOAuth2AuthorizationTemplate(redisConnectionFactory: RedisConnectionFactory): RedisTemplate<String, RedisOAuth2AuthorizationRequest> {
        val redisTemplate = RedisTemplate<String, RedisOAuth2AuthorizationRequest>()
        redisTemplate.connectionFactory = redisConnectionFactory
        redisTemplate.keySerializer = StringRedisSerializer()
        redisTemplate.valueSerializer = redisOAuth2AuthorizationRequestSerializer()
        redisTemplate.setEnableTransactionSupport(true)

        return redisTemplate
    }

    private fun redisOAuth2AuthorizationRequestSerializer(): RedisOAuth2AuthorizationRequestSerializer {
        val objectMapper = jacksonObjectMapper()
        objectMapper.registerModules(JavaTimeModule())
            .configure(SerializationFeature.WRITE_DATES_WITH_ZONE_ID, true)

        return RedisOAuth2AuthorizationRequestSerializer(objectMapper)
    }
}

 

RedisTemplate 설정코드입니다.

기존 만들어둔 RedisTemplate 와 마찬가지의 방법으로 설정했습니다.

 

package com.ttasjwi.board.system.external.spring.security.oauth2

import com.ttasjwi.board.system.external.spring.security.oauth2.redis.RedisOAuth2AuthorizationRequest
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames
import org.springframework.stereotype.Component
import java.time.Duration

@Component
class RedisOAuth2AuthorizationRequestRepository(
    private val redisTemplate: RedisTemplate<String, RedisOAuth2AuthorizationRequest>
) : AuthorizationRequestRepository<OAuth2AuthorizationRequest> {

    companion object {
        private const val PREFIX = "Board-System:OAuth2AuthorizationRequest:"
        private const val VALIDITY_MINUTE = 5L
    }

    override fun loadAuthorizationRequest(request: HttpServletRequest): OAuth2AuthorizationRequest? {
        val state = getStateParameter(request) ?: return null
        val key = makeKey(state)
        return redisTemplate.opsForValue().get(key)?.toOAuth2AuthorizationRequest()
    }

    override fun removeAuthorizationRequest(
        request: HttpServletRequest,
        response: HttpServletResponse
    ): OAuth2AuthorizationRequest? {
        val oAuth2AuthorizationRequest = loadAuthorizationRequest(request) ?: return null

        val key = makeKey(oAuth2AuthorizationRequest.state)
        redisTemplate.delete(key)
        return oAuth2AuthorizationRequest
    }

    override fun saveAuthorizationRequest(
        authorizationRequest: OAuth2AuthorizationRequest?,
        request: HttpServletRequest,
        response: HttpServletResponse
    ) {
        if (authorizationRequest == null) {
            removeAuthorizationRequest(request, response)
            return
        }
        val key = makeKey(authorizationRequest.state)
        val redisAuthorizationRequest = RedisOAuth2AuthorizationRequest.from(authorizationRequest)

        redisTemplate.opsForValue().set(key, redisAuthorizationRequest)
        redisTemplate.expire(key, Duration.ofMinutes(VALIDITY_MINUTE))
    }

    private fun getStateParameter(request: HttpServletRequest): String? {
        return request.getParameter(OAuth2ParameterNames.STATE)
    }

    private fun makeKey(state: String): String {
        return PREFIX + state
    }

}

 

그리고 실제 RedisOAuth2AuthorizationRequestRepository 입니다. 내부적으로 RestTemplate 를 의존하고 있습니다.

 

key 생성

OAuth2AuthorizationRequest 또는 요청 파라미터에 위치한 state 값을 추출하여 이 값으로 key를 생성합니다.

 

저장

key, value 를 지정해야하는데요.

 

key 는 "Board-System:OAuth2AuthorizationRequest:" 뒤에 OAuth2AuthorizationRequest 안의 state 를 붙여서 만들었습니다. 이렇게 저장하면 이후에는 Board-System:OAuth2AuthorizationRequest:{state값} 으로 OAuth2AuthorizationRequet 를 조회할 수 있습니다.

value는 OAuth2AuthorizationRequest로부터 RedisOAuth2AuthorizationRequest 로 변환하여 value로 저장합니다.

만료시간은 5분 정도로 잡았습니다.

 

조회

현재 HttpServletRequest에서 'state' 파라미터값을 추출해서, 다시 key 를 구성하고

key로 OAuth2AuthorizationRequest를 조회합니다.

 

삭제

현재 HttpServletRequest에서 'state' 파라미터값을 추출해서, 다시 key 를 구성하고

조회한 뒤, 실제 저장소에서 지우고 조회 결과물을 반환합니다.

 

7.4 설정

@Configuration
class FilterChainConfig(
	// 생략
    private val oauth2AuthorizationRequestRepository: AuthorizationRequestRepository<OAuth2AuthorizationRequest>
) {

// 생략

}

 

이제 FitlerChainConfig 쪽에서 AuthorizationRequestRepository 빈을 주입받아서 사용할 수 있습니다.


8. AuthenticationFailureHandler

8.1 인터페이스 계약

public interface AuthenticationFailureHandler {

    void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
          AuthenticationException exception) throws IOException, ServletException;

}

 

OAuth2AuthorizationRequestRedirect 필터에서, 인증 실패시 스프링 인증예외인 AuthenticationException 처리는

AuthenticationFailureHandler 에서 담당합니다.

 

기본 구현체가 있긴한데 기본 구현체에서 예외 처리 로직대로 사용자에게 응답이 나가게 되면 제가 의도하지 않은 형태의 응답이 나가게 될 수 있으므로, 이 부분을 통제하고 싶어요.

 

8.2 커스텀 AuthenticationFailureHandler

class CustomAuthenticationFailureHandler(
    private val handlerExceptionResolver: HandlerExceptionResolver
) : AuthenticationFailureHandler {

    companion object {
        private val INVALID_CLIENT_REGISTRATION_ID_MESSAGE_REGEX = Regex("Invalid Client Registration with Id: (.+)")
    }

    override fun onAuthenticationFailure(
        request: HttpServletRequest,
        response: HttpServletResponse,
        authException: AuthenticationException,
    ) {
        var ex: Exception = authException
        val message = authException.message

        if (message != null && INVALID_CLIENT_REGISTRATION_ID_MESSAGE_REGEX.matches(message)) {
            val providerId = INVALID_CLIENT_REGISTRATION_ID_MESSAGE_REGEX.find(message)!!.groups[1]!!.value
            ex = InvalidOAuth2ProviderIdException(providerId, authException)
        }
        handlerExceptionResolver.resolveException(request, response, null, ex)
    }
}
class InvalidOAuth2ProviderIdException(
    providerId: String,
    cause: Throwable,
): CustomException(
    status = ErrorStatus.BAD_REQUEST,
    code = "Error.InvalidOAuth2ProviderId",
    args = listOf(providerId),
    source = "providerId",
    debugMessage = "우리 서비스에서 연동하는 소셜서비스 제공자 id가 아닙니다. (providerId= ${providerId})",
    cause = cause,
)

 

커스텀하게 만든 AuthenticationFailureHandler 입니다.

외부에서 HandlerExceptionResolver를 주입받아서, 이쪽으로 예외처리를 위임하도록 할거에요.

 

AuthenticationException 은 제가 만든 커스텀 예외가 아닌 문제가 있습니다. 구체적으로 뭔 예외인지 알 수 없고 무슨 메시지가 나갈 지 잘 모르는 예외입니다.

 

그래서 예외를 추적할 수 있도록 이 위치에서 필요에 따라 커스텀 예외로 변환하고 다시 HandlerExceptionResolver 쪽에 넘겨서 제가 정의한 표준 예외 응답 형태로 응답이 나갈 수 있도록 할거에요.

 

여기서는 InvalidOAuth2ProviderIdException을 만들어서, /api/v1/auth/oauth2/authorization/{...} 에서 {...} 이 잘못됐을 때 커스텀 예외 응답이 나가도록 해 봤습니다.

 

8.3 설정

@Configuration
class FilterChainConfig(
	// 생략
    
    @Qualifier(value = "handlerExceptionResolver")
    private val handlerExceptionResolver: HandlerExceptionResolver,

) {


    @Bean
    fun customAuthenticationFailureHandler(): AuthenticationFailureHandler {
        return CustomAuthenticationFailureHandler(handlerExceptionResolver)
    }
    
    // 생략
}

 

FilterChainConfig 쪽에서 CustomAuthenticationFailureHandler 설정시 handleExceptionResolver 빈을 주입받아서 빈 등록이 되도록 했습니다. 

 


9. oauth2Login(...) API의 한계

	public final class AuthorizationEndpointConfig {

		private String authorizationRequestBaseUri;

		private OAuth2AuthorizationRequestResolver authorizationRequestResolver;

		private AuthorizationRequestRepository<OAuth2AuthorizationRequest> authorizationRequestRepository;

		private RedirectStrategy authorizationRedirectStrategy;

		private AuthorizationEndpointConfig() {
		}

 

 

이제 위에서 만든 의존성들을 기반으로 OAuth2AuthorizationRequestRedirectFilter 커스터마이징을 해봐야하는데요.

 

문제는 oauth2Login(...) API 사양에서는 OAuth2AuthorizationRequestRedirectFilter 구성에 사용되는

OAuth2AuthorizationRequestResolver, AuthorizationRequestRepository 설정, RedirectStrategy 설정은 할 수 있어도

AuthenticationFailureHandler 설정을 할 수 없었습니다.

 

 

	@Override
	public void configure(B http) throws Exception {
		OAuth2AuthorizationRequestRedirectFilter authorizationRequestFilter;
		if (this.authorizationEndpointConfig.authorizationRequestResolver != null) {
			authorizationRequestFilter = new OAuth2AuthorizationRequestRedirectFilter(
					this.authorizationEndpointConfig.authorizationRequestResolver);
		}
		else {
			String authorizationRequestBaseUri = this.authorizationEndpointConfig.authorizationRequestBaseUri;
			if (authorizationRequestBaseUri == null) {
				authorizationRequestBaseUri = OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI;
			}
			authorizationRequestFilter = new OAuth2AuthorizationRequestRedirectFilter(
					OAuth2ClientConfigurerUtils.getClientRegistrationRepository(this.getBuilder()),
					authorizationRequestBaseUri);
		}
		if (this.authorizationEndpointConfig.authorizationRequestRepository != null) {
			authorizationRequestFilter
				.setAuthorizationRequestRepository(this.authorizationEndpointConfig.authorizationRequestRepository);
		}
		if (this.authorizationEndpointConfig.authorizationRedirectStrategy != null) {
			authorizationRequestFilter
				.setAuthorizationRedirectStrategy(this.authorizationEndpointConfig.authorizationRedirectStrategy);
		}
		RequestCache requestCache = http.getSharedObject(RequestCache.class);
		if (requestCache != null) {
			authorizationRequestFilter.setRequestCache(requestCache);
		}
		http.addFilter(this.postProcess(authorizationRequestFilter));

 

실제로 OAuth2LoginConfigure의 configure 쪽에서는 OAuth2AuthorizationRequestRedirectFilter의 설정작업이 실제로 진행되는데, 여기서도 AuthenticationFailureHandler 설정은 따로 진행되지 않았습니다. 구현체 변경을 oauth2Login(...) 를 통해 할 수 없는 문제가 있습니다.

 

제가 원하는 만큼 커스터마이징을 하려면 아예 새로 OAuth2AuthorizationRequestRedirectFilter 를 새로 만들어서 등록하는 수밖에 없습니다.


10. 필터 생성 및 추가

@Configuration
class FilterChainConfig(
	// 생략
    
    @Qualifier(value = "handlerExceptionResolver")
    private val handlerExceptionResolver: HandlerExceptionResolver,
    private val clientRegistrationRepository: ClientRegistrationRepository,
    private val oauth2AuthorizationRequestRepository: AuthorizationRequestRepository<OAuth2AuthorizationRequest>
) {

    companion object {
        private const val OAUTH2_AUTHORIZATION_REQUEST_BASE_URI = "/api/v1/auth/oauth2/authorization"
    }

	
    // 생략

    @Bean
    fun customOauth2AuthorizationRequestRedirectFilter(): OAuth2AuthorizationRequestRedirectFilter {
        val oauth2AuthorizationRequestRedirectFilter = OAuth2AuthorizationRequestRedirectFilter(
            customOAuth2AuthorizationRequestResolver()
        )
        oauth2AuthorizationRequestRedirectFilter.setAuthorizationRequestRepository(oauth2AuthorizationRequestRepository)
        oauth2AuthorizationRequestRedirectFilter.setRequestCache(NullRequestCache())
        oauth2AuthorizationRequestRedirectFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler())
        return oauth2AuthorizationRequestRedirectFilter
    }

    private fun customOAuth2AuthorizationRequestResolver(): OAuth2AuthorizationRequestResolver {
        return CustomOAuth2AuthorizationRequestResolver.of(
            clientRegistrationRepository = clientRegistrationRepository,
            authorizationRequestBaseUri = OAUTH2_AUTHORIZATION_REQUEST_BASE_URI
        )
    }

    @Bean
    fun customAuthenticationFailureHandler(): AuthenticationFailureHandler {
        return CustomAuthenticationFailureHandler(handlerExceptionResolver)
    }
    
    // 생략

}

 

위의 문제를 해결하기 위해 OAuth2AuthorizationRequestRedirectFilter 를 새로 만들었습니다.

OAuth2AuthorizationRequestResolver, RequestCache 설정, AuthorizationRequestRepository 설정, AuthenticationFailureHandler 을 이 곳에서 합니다.

 

companion object {
    private const val OAUTH2_AUTHORIZATION_REQUEST_BASE_URI = "/api/v1/auth/oauth2/authorization"
}

@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
    http {
        // 생략

        oauth2Login {
            authorizationEndpoint {
                baseUri = OAUTH2_AUTHORIZATION_REQUEST_BASE_URI
            }
        }

        // OAuth2 인가요청 리다이렉트 필터
        addFilterBefore<OAuth2AuthorizationRequestRedirectFilter>(customOauth2AuthorizationRequestRedirectFilter())

        // 생략
    }
    return http.build()
}

 

기존 필터 체인 설정에서 oauth2Login(...) 설정 및 커스텀 OAuth2AuthorizationRequestRedirectFilter 추가 설정을 넣어줬습니다.

 

사실 리다이렉트 기능만 추가하는 관점에서는 위의 oauth2Login(...) 설정은 필요가 없긴 한데

뒤에서 구현할 토큰 인증 및 로그인 기능을 위해서는 oauth2Login 기능을 열어두고 이 안에서 추가 설정을 해야합니다.

 

 

 

그런데 이렇게 하면 oauth2Login API 에 의해서 기본 OAuth2AuthorizationRequestRedirectFilter 가 추가됩니다.

그래서 기본으로 생성되는 필터가 매칭되는 조건을 제가 만든 커스텀 OAuth2AuthorizationRequestRedirectFilter 와 동일하게 만들도록 baseUri 패턴을 통일시켰습니다.

 

그 후 뒤에서 이어지는 설정에서 addFilterBefore<OAuth2AuthorizationRequestRedirectFilter> 설정을 해뒀기 때문에 제가 만든 커스텀 OAuth2AuthorizationRequestRedirectFilter 가 좀 더 앞쪽에 필터가 놓이게 되서, 같은 엔드포인트 요청이 들어와도, oauth2Login 에서 설정된 리다이렉트 필터가 요청을 가로챌 일은 없습니다.(정확히는 내부에서 사용하는 OAuth2AuthorizationRequestResolver가 요청의 경로를 읽고 Auth2AuthorizationRequest 를 생성해주고, 이 생성된 결과가 있을 때 저장, 리다이렉트 처리가 일어나는 것이긴 합니다.)


11. 실행

 

실제 OAuth2AuthorizationRequestRedirectFilter 에서, RedirectStrategy 가 실행되는 부분에 디버거를 걸어보고 실행하겠습니다.

 

 

localhost:8080/api/v1/oauth2/authorization/google 로 접속해보겠습니다.

 

 

OAuth2AuthorizationRequestRedirectFilter에서 디버깅이 걸리는데

이 필터가 가진 의존성이 제가 등록한 커스텀한 의존성들이 등록된 것을 볼 수 있습니다.

 

AuthorizationRequestResolver 자리에 CustomOAuth2AuthorizationRequestResolver가 있고

AuthorizationRequestRepository 자리에 RedisOAuth2AuthorizationRequestRepository가 있으며

AuthenticationFailureHandler 자리에 CustomAuthenticationFailureHandler가 있습니다.

 

또, RequestCache 도 NullRequestCache 가 되어 있어요.

 

 

OAuth2AuthorizationRequest 를 살펴보면 state, nonce, code_challenge, code_challenge_method, code_verifier 값도 상태로 가진 것을 볼 수 있어요.

 

 

실제 리다이렉트 위치는 구글의 승인페이지로 잡혀있고 여러가지 우리 서버에서 만들어준 파라미터가 쿼리파라미터에 달려있습니다.

 

https://accounts.google.com/o/oauth2/v2/auth
?response_type=code
&client_id=xxxx
&scope=openid%20email
&state=2fTV2O6f16_mUW7ldugreTAwr07IqcioyOE4-tHRrTM%3D
&redirect_uri=http://localhost:3000/auth/login/oauth2/code/google
&nonce=5HdboPTDbuasCozm_EHoyvMscEiukx42QFh0EaN3MNQ
&code_challenge=uBXFkoiaENqPad9PD39L9QgzzBDKsxpFyfTCqNIhznY
&code_challenge_method=S256

 

이 값을 펼쳐보면 제가 서버에서 지정해뒀던 redirect_uri 가 지정되어 있고

여러가지 동적으로 만들어진 state, nonce, code_challenge_method, code_challenge 가 함께 포함되어 있습니다.

 

브라우저는 리다이렉트 응답을 받고 저희가 보내준 URL로 GET 요청을 보냅니다.

결국 구글의 승인페이지로 이동됐어요.

 

 

여기서 로그인을 하고 승인을 해보겠습니다.

 

 

 

승인 후 구글은 제가 지정한 localhost:3000/auth/login/oauth2/code/google 로 리다이렉트 시킵니다.

여기에 보시면 제가 보낸 code 값이 그대로 다시 왔고

code 값도 발급된 것을 볼 수 있어요.

 

 

 

localhost:8080/api/v1/oauth2/authorization/kakao 로 요청을 해도 카카오쪽 승인페이지쪽에 리다이렉트 되는 것을 볼 수 있습니다.(여기서 제가 여러번 승인을 했기 떄문에 로그인하자마자 바로 코드가 발급되는데, 사실 로그인을 하고나서 승인 창이 뜨는 과정이 더 있습니다.)

 

localhost:8080/api/v1/oauth2/authorization/naver 로 요청을 하면 네이버 승인페이지로 리다이렉트되는 것을 볼 수 있습니다.

(위와 마찬가지로 여기서 제가 여러번 승인을 했기 떄문에 로그인하자마자 바로 코드가 발급되는데, 사실 로그인을 하고나서 승인 창이 뜨는 과정이 더 있습니다.)

 

추가적으로 localhost:8080/api/v1/oauth2/authorization/gigle 로 요청을 하면 예외 API 응답이 옵니다.

제가 CustomAuthenticationFailureHandler 를 설정해둔 것이 잘 작동한 것입니다.

 


 

이렇게 해서, 사용자 요청이 들어왔을 때 사용자를 소셜 서비스 인가서버(OAuth2 Authorization Server)의 인가 엔드포인트(승인 페이지)쪽으로 리다이렉트 시키는 것을 처리했습니다.

 

이렇게 하고나서 발급된 code, state 등 값을 다시 사용해서, 소셜 로그인 처리를 실제 해야하는데 이 부분은 뒤에 이어지는 글에서 이어서 해보도록 하겠습니다.

 

읽어주셔서 감사합니다.


GitHub 리포지토리

https://github.com/ttasjwi/board-system

 

GitHub - ttasjwi/board-system

Contribute to ttasjwi/board-system development by creating an account on GitHub.

github.com

 

 

 

Comments