실무에서 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 |
---|