Spring module Project 에서 OAuth 관련 모듈을 개발하던 중에 생겼던 Duration 타입의 Type casting issue 를 해결한 과정을 공유하려 한다.
Spring-modules 프로젝트에서 자주 사용되는 인증 방식을 미리 Application으로 구현하는데, 요새는 누구나 다 지원할만큼 보편적으로 사용되는 OAuth 인증 방식을 빼먹을 수 없어서 함께 구현하게 되었다. 필자는 OAuth2 인증 방식을 이해하기 위해서 OAuth 인증을 담당하는 Client, Authorization Server, Resource Server 를 모두 직접 구현하면서 인증/인가 과정이 동작하는 방법을 이해하려 했고, 그중에서 문제가 발생했던 Server 부분에 대해 기록한다.
Dependencies
Spring Boot 3 버전이고 기존에 사용되던 Spring-security-oauth 가 EOL( End-Of-Life ) 에 도달해서, 더이상 사용할 수 없다. 따라서 2023.9 기준으로 활발하게 개발되고 있는 org.springframework.security:spring-security-oauth2-authorization-server 를 사용하여 Authorization Server 를 이용하여 구현한다.
Client의 Grant-Type 은 authorization_code 이고, Client-Authentication-Methods 는 client_secret_basic 을 기본값으로 사용했다.
dependencies {
...
//EOL
//implementation "org.springframework.security.oauth:spring-security-oauth2"
//Authorization-Server
implementation "org.springframework.boot:spring-boot-starter-oauth2-authorization-server"
...
}
이 Starter 에는 spring-boot-starter-web 과 spring-security-oauth2-authorization-server 두가지 의존성이 함께 포함된다.
요약
코드가 필요하거나, 자세한 내용이 필요 없는 사람들을 위해 먼저 요약 정리한다. OAuth Server의 전체 코드는 Spring-stack 여기서 확인할 수 있다. 구현은 Docs에 나와있는 JPA Implementation을 구현했다.
1. OAuth2AuthorizationConsentAuthenticationProvider ( 만약 Client의 Consent 옵션이 True 인 경우에 포함된다. ) 와 OAuth2AuthorizationCodeRequestAuthenticationProvider 에서 사용되는 OAuth2AuthorizationCodeGenerator 에서 Authorization Code 를 생성하는 generate() 함수의 getAuthorizationCodeTimeToLive() 에서 Type casting issue 가 생겼던 것이다.
@Nullable
@Override
public OAuth2AuthorizationCode generate(OAuth2TokenContext context) {
if (context.getTokenType() == null ||
!OAuth2ParameterNames.CODE.equals(context.getTokenType().getValue())) {
return null;
}
Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(context.getRegisteredClient().getTokenSettings().getAuthorizationCodeTimeToLive());
return new OAuth2AuthorizationCode(this.authorizationCodeGenerator.generateKey(), issuedAt, expiresAt);
}
2. 필자의 경우 JPA Implementation 을 Oracle Database 로 구현했는데, 여기에서는 “settings.token.access-token-time-to-live” 값이 300.000000000 (Double 값) 으로 설정되어 class java.lang.Double cannot be cast to class java.time.Duration (java.lang.Double and java.time.Duration are in module java.base of loader ‘bootstrap’) 문제가 생겼다
3. 간단하게 OAuth2TokenGenerator 를 구현한 Custom Code Generator 를 만들어 Provider 에 주입하여 해결한다.
public class CustomOAuthAuthorizationCodeGenerator implements OAuth2TokenGenerator<OAuth2AuthorizationCode> {
private final StringKeyGenerator authorizationCodeGenerator =
new Base64StringKeyGenerator(Base64.getUrlEncoder().withoutPadding(), 96);
@Nullable
@Override
public OAuth2AuthorizationCode generate(OAuth2TokenContext context) {
if (context.getTokenType() == null ||
!OAuth2ParameterNames.CODE.equals(context.getTokenType().getValue())) {
return null;
}
Instant issuedAt = Instant.now();
try {
Instant expiresAt = issuedAt.plus(context.getRegisteredClient().getTokenSettings().getAuthorizationCodeTimeToLive());
return new OAuth2AuthorizationCode(this.authorizationCodeGenerator.generateKey(), issuedAt, expiresAt);
} catch (ClassCastException e){
//Cannot type cast
Map<String, Object> settings = context.getRegisteredClient().getTokenSettings().getSettings();
// In my case (Oracle 19c) It's double value with seconds.
long authorizationCodeTimeToLive = Math.round((double) settings.get(ConfigurationSettingNames.Token.AUTHORIZATION_CODE_TIME_TO_LIVE));
Duration authorizationCodeDuration = Duration.ofSeconds(authorizationCodeTimeToLive);
Instant expiresAt = issuedAt.plus(authorizationCodeDuration);
return new OAuth2AuthorizationCode(this.authorizationCodeGenerator.generateKey(), issuedAt, expiresAt);
}
}
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity){
//Provider 객체 가져오기. Bean으로 주입하거나, 새로 생성하기.
OAuth2AuthorizationCodeRequestAuthenticationProvider provider = ...
OAuth2AuthorizationConsentAuthenticationProvider consentProvider = ...
//Custom Code Generator 를 주입하고, Authentication Providers list 에 등록하기.
provider.setAuthorizationCodeGenerator(new CustomOAuthAuthorizationCodeGenerator());
consentProvider.setAuthorizationCodeGenerator(new CustomOAuthAuthorizationCodeGenerator());
httpSecurity.authenticationProvider(provider);
httpSecurity.authenticationProvider(consentProvider);
...
}
본론
필자는 Client 를 In memory repository 가 아닌 Docs에 나와있는 JPA Implementation 을 구현하고 있었다. OAuth2 인증에서 사용될 Client정보를 Rest-API 를 통해서 Database 에 기록하는 방식이다.
@Builder
protected Client(RegisterOAuthClient oauthClient){
this.clientId = this.generateClientId();
this.clientIdIssuedAt = Instant.now();
this.clientSecret = new BCryptPasswordEncoder().encode(this.generatePassword());
this.clientSecretExpiresAt = Instant.now().plus(24, ChronoUnit.HOURS);// 24Hours later.
this.clientName = oauthClient.getClientName();
this.clientAuthenticationMethods = ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue(); // Default.
this.authorizationGrantTypes = AuthorizationGrantType.AUTHORIZATION_CODE.getValue();
this.redirectUris = oauthClient.getRedirectUris();
this.postLogoutRedirectUris = oauthClient.getPostLogoutRedirectUris();
this.scopes = StringUtils.collectionToCommaDelimitedString(oauthClient.getScopes());
if (this.clientSettings == null) {
ClientSettings.Builder builder = ClientSettings.builder();
builder.requireAuthorizationConsent(true);
this.clientSettings = writeMap(builder.build().getSettings());
}
if (this.tokenSettings == null) {
this.tokenSettings = writeMap(TokenSettings.builder().build().getSettings());
}
}
TokenSettings 를 기본값으로 저장하는데, 이 기본값에 Duration 객체가 포함된다. 문제는 이 Duration 객체를 Database 에 저장할 때 ( 필자 기준 Oracle )는 Double 값으로 저장되었다.
Duration 을 Authorization Code 를 생성할 때 사용한다. 그래서, OAuth2AuthorizationCodeRequestAuthenticationProvider 에서 이 Duration 을 사용하는데, 그 방식이 단순 형변환 하는 방식이라, Double 타입을 Duration 형식으로 형변환하려고 해서 ClassCastException 이 발생한다.
Custom Generator 가 아닌 원본 OAuth2AuthorizationCodeGenerator 를 살펴보자.
@Nullable
@Override
public OAuth2AuthorizationCode generate(OAuth2TokenContext context) {
if (context.getTokenType() == null ||
!OAuth2ParameterNames.CODE.equals(context.getTokenType().getValue())) {
return null;
}
Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(context.getRegisteredClient().getTokenSettings().getAuthorizationCodeTimeToLive());
return new OAuth2AuthorizationCode(this.authorizationCodeGenerator.generateKey(), issuedAt, expiresAt);
}
여기에서 문제가 생기는 부분은 getAuthorizationCodeTimeToLive() 부분이다. 이부분에서 사용되는 코드를 보면, 단순 형변환을 이용해서 처리하는것을 볼 수 있다.
// 문제가 생겼던 원본 함수이다.
public Duration getAuthorizationCodeTimeToLive() {
return getSetting(ConfigurationSettingNames.Token.AUTHORIZATION_CODE_TIME_TO_LIVE);
}
public <T> T getSetting(String name) {
Assert.hasText(name, "name cannot be empty");
//단순 형변환. (Duration) Double
return (T) getSettings().get(name);
}
generate() 함수만 변경하면 해결할 수 있을것이라 생각했다. 따라서, 필자는 Custom Authorization Code Generator 를 생성하여 Provider 에 주입해주는 방식을 선택했다.
@Nullable
@Override
public OAuth2AuthorizationCode generate(OAuth2TokenContext context) {
...
// In my case (Oracle 19c) It's double value with seconds.
long authorizationCodeTimeToLive = Math.round((double) settings.get(ConfigurationSettingNames.Token.AUTHORIZATION_CODE_TIME_TO_LIVE));
Duration authorizationCodeDuration = Duration.ofSeconds(authorizationCodeTimeToLive);
Instant expiresAt = issuedAt.plus(authorizationCodeDuration);
return new OAuth2AuthorizationCode(this.authorizationCodeGenerator.generateKey(), issuedAt, expiresAt);
}
CustomOAuthAuthorizationCodeGenerator 클래스를 생성하고, generate 함수에서 Double 값을 올바르게 Duration 객체로 변경할 수 있게끔 형변환 코드를 작성하고, Provider 에 Setter 를 이용해서 주입하여 문제를 해결했다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity,
...) throws Exception {
...
//Provider's
OAuth2AuthorizationCodeRequestAuthenticationProvider provider =
new OAuth2AuthorizationCodeRequestAuthenticationProvider(
registeredClientRepository, authorizationService, oAuth2AuthorizationConsentService);
OAuth2AuthorizationConsentAuthenticationProvider consentProvider =
new OAuth2AuthorizationConsentAuthenticationProvider(
registeredClientRepository, authorizationService, oAuth2AuthorizationConsentService);
//Custom Code Generator 주입
provider.setAuthorizationCodeGenerator(new CustomOAuthAuthorizationCodeGenerator());
consentProvider.setAuthorizationCodeGenerator(new CustomOAuthAuthorizationCodeGenerator());
httpSecurity.authenticationProvider(provider);
httpSecurity.authenticationProvider(consentProvider);
return httpSecurity.build();
}
의외로 간단하게 해결되는 문제였다. 더 나아가서, CustomAuthorizationCodeGenerator 객체를 Profile 에 따라서 각각 다르게 등록되게 한 뒤, 의존성을 주입받아 사용해도 좋겠다는 생각이 들었다.
만약, Oracle Database 가 아닌, 다른 Database 에서 사용할 때 에러가 난다면 이 방법으로 변경해봐야겠다.
소스의 전체 코드는 Spring-stack 여기서 확인할 수 있다.