반응형

실무에서 OAuth2 로그인 구현을 위해 spring-security 그룹의 spring-security-oauth2-client 프레임워크를 이용하고 있습니다. spring-security-oauth2-client 프레임워크는 추후 자세히 다뤄보겠으며 이번엔 실무를 하면서 겪은 Authorization Endpoint 커스터마이징 내용을 중점적으로 다루려 합니다.

본 포스트에 언급된 프레임워크의 dependency는 아래와 같습니다.

  • org.springframework.boot:spring-boot-starter-security:2.2.1.RELEASE
  • org.springframework.security:spring-security-oauth2-client:5.2.1.RELEASE

작업 개요

사용자가 배틀넷 연동을 시도하면 현재 브라우저의 배틀넷 접속 계정을 로그아웃 시킨 후 즉시 로그인 페이지를 노출시켜야 하는 요구사항이 있었습니다. 사용자에게는 로그아웃하는 화면이 노출되지 않고 바로 배틀넷 로그인 페이지를 노출시켜야했는데 다행히 블리자드 로그아웃 페이지에서 'ref' 쿼리 파라미터를 이용한 자체 302 리다이렉트 기능이 지원됨을 확인했습니다.

* 요구사항을 충족하는 URI : https://kr.battle.net/login/logout?ref={URL Encoded Authorization Endpoint URI

하지만 기본 OAuth2 Client 환경 설정을 뒤져봐도 Authorization Endpoint에 중간 다리 역할을 할 URI를 끼워넣는 옵션이 없었기 때문에 커스터마이징이 필요했습니다. 다행히 AuthorizationEndpointConfig의 OAuth2AuthorizationRequestResolver 커스텀 구현체를 등록할 수 있어 쉽게 해결할 수 있었습니다.

OAuth2 환경 설정

spring:
  security:
    oauth2:
      client:
        provider:
          battlenet:
            authorizationUri: https://kr.battle.net/oauth/authorize
            tokenUri: https://kr.battle.net/oauth/token
            userInfoUri: https://kr.battle.net/oauth/userinfo
            userNameAttribute: id
        registration:
          battlenet:
            clientId: .. 
            clientSecret: .. 
            redirectUri: .. 
            authorizationGrantType: .. 
            clientAuthenticationMethod: .. 

HttpSecurity - OAuth2LoginConfigurer 설정

http.oauth2Login()
            .authorizationEndpoint()
            .baseUri("/login")
            .authorizationRequestResolver(
                CustomAuthorizationRequestResolver(
                    clientRegistrationRepository, "/login", battlenetLogoutUri
                )
            )
            // 기타 설정

AuthorizationEndpointConfig에서 OAuth2AuthorizationRequestResolver 커스텀 구현체를 등록할 수 있습니다.

OAuth2AuthorizationRequestResolver 인터페이스

public interface OAuth2AuthorizationRequestResolver {

	/**
	 * Returns the {@link OAuth2AuthorizationRequest} resolved from
	 * the provided {@code HttpServletRequest} or {@code null} if not available.
	 *
	 * @param request the {@code HttpServletRequest}
	 * @return the resolved {@link OAuth2AuthorizationRequest} or {@code null} if not available
	 */
	OAuth2AuthorizationRequest resolve(HttpServletRequest request);
    
	/**
	* ...
	*/
	OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId);

OAuth2AuthorizationRequestResolver는 HttpServletRequest를 인자로 받아 OAuth2AuthorizationRequest 객체를 생성(resolve)하는 역할을 하며 OAuth2AuthorizationRequest 객체는 OAuth2 Authorization Endpoint 값을 담아 다음 과정에 전달시키는 역할을 합니다.

OAuth2AuthorizationRequest

// OAuth2AuthorizationRequest 빌드
public OAuth2AuthorizationRequest build() {
	/* Assertions 코드.. */

	OAuth2AuthorizationRequest authorizationRequest = new OAuth2AuthorizationRequest();

	/* authorizationRequest 값들 할당하는 코드.. */

	authorizationRequest.authorizationRequestUri =
	StringUtils.hasText(this.authorizationRequestUri) ?
	this.authorizationRequestUri : this.buildAuthorizationRequestUri();

	return authorizationRequest;
}

OAuth2AuthorizationRequest의 Builder 객체는 배틀넷 환경 설정 값을 주입받으며 build 될 때 OAuth2 인증/인가 플로우에 필요한 authorizationRequestUri 값을 완성시킵니다. 문제는 이 URI 값을 바로 이용할 수 없고 배틀넷 로그아웃 URI로 한번 더 감싸줘야 합니다.

OAuth2AuthorizationRequest 객체의 필드는 setter가 없기 때문에 새로운 객체를 만들어 해결할 수 있습니다. 이 절차는 자체 CustomAuthorizationRequestResolver를 구현하여 해결하였습니다.

CustomAuthorizationRequestResolver(Kotlin)

class CustomAuthorizationRequestResolver(
    clientRegistrationRepository: ClientRegistrationRepository,
    authorizationRequestBaseUri: String,
    private val battlenetLogoutUri: String
) : OAuth2AuthorizationRequestResolver {
    private val defaultResolver: OAuth2AuthorizationRequestResolver =
        DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository, authorizationRequestBaseUri)

    override fun resolve(request: HttpServletRequest?): OAuth2AuthorizationRequest? {
        val oAuth2AuthorizationRequest = defaultResolver.resolve(request)
            ?: return null

        return customResolve(oAuth2AuthorizationRequest)
    }

    override fun resolve(request: HttpServletRequest?, clientRegistrationId: String?): OAuth2AuthorizationRequest? {
        val oAuth2AuthorizationRequest = defaultResolver.resolve(request, clientRegistrationId)
            ?: return null

        return customResolve(oAuth2AuthorizationRequest)
    }

    private fun customResolve(oAuth2AuthorizationRequest: OAuth2AuthorizationRequest): OAuth2AuthorizationRequest? {
        if (!oAuth2AuthorizationRequest.attributes.containsKey("registration_id")) {
            return oAuth2AuthorizationRequest
        }

        val registrationId = oAuth2AuthorizationRequest.attributes["registration_id"]
        if (registrationId != "battlenet") {
            return oAuth2AuthorizationRequest
        }

        val encodedRedirectionUri =
            URLEncoder.encode(oAuth2AuthorizationRequest.authorizationRequestUri, "UTF-8")
        val battlenetUriIncludingLogout = battlenetLogoutUri + encodedRedirectionUri

        return OAuth2AuthorizationRequest.authorizationCode()
            .clientId(oAuth2AuthorizationRequest.clientId)
            .authorizationUri(oAuth2AuthorizationRequest.authorizationUri)
            .authorizationRequestUri(battlenetUriIncludingLogout)
            .redirectUri(oAuth2AuthorizationRequest.redirectUri)
            .scopes(oAuth2AuthorizationRequest.scopes)
            .state(oAuth2AuthorizationRequest.state)
            .attributes(oAuth2AuthorizationRequest.attributes)
            .build()
    }
}

프레임워크 기본 전략으로 이용되는 DefaultOAuth2AuthorizationRequestResolver를 이용하여 기본 플로우는 기존과 동일하게 유지시키고 battlenet인 경우에만 OAuth2AuthorizationRequest를 새로 생성하여 해결했습니다.

반응형

'Spring > Spring Security' 카테고리의 다른 글

Spring Security 기초 공부  (0) 2023.03.12
  • 네이버 블러그 공유하기
  • 네이버 밴드에 공유하기
  • 페이스북 공유하기
  • shared트위터 공유하기
  • shared
  • 카카오스토리 공유하기