이 글은 이전 글 에서 이어지는 내용이다.
이번 글에서는 Type Cast issue 가 발생하는 근본적인 원인을 기록하고, 기존에 작성했던 코드도 리팩토링해보려고 한다.
요약
역시나 코드가 필요하거나, 자세한 내용이 필요 없는 사람들을 위해 먼저 요약 정리한다. OAuth Server의 전체 코드는 Spring-stack 여기서 확인할 수 있다.
1. OAuth 인증을 하기 위해서는 Client를 미리 등록해야 한다. 만약 필자처럼 이 Client 정보를 저장하는 Repository를 InMemoryRegisteredClientRepository 를 사용하지 않고, Database 에 저장하게 구현했다면, 인증 요청이 왔을 때 Client 정보를 Database의 Read 연산을 통해 확인해야 한다.
2. 그런데, SQL Database 에는 객체 자체를 저장할 수 없으니, 어떤 식으로든 직렬화 해서 저장하게 되고, 이 정보를 다시 객체로 역직렬화 하는 함수에서 ClassCastException이 발생한 것이다.
3. Docs 의 JPA Implementation 을 그대로 구현한다면, 토큰에 관한 설정값인 TokenSettings 객체를 DB에서 읽을 때 문제가 생긴다. TokenSettings 객체는 내부에 또 다른 객체인 Duration ( 이전 글에서 문제가 생겼던 Duration 객체도 이 TokenSettings 객체 내부의 Field 값이다. )과 OAuth2TokenFormat 를 가지고 있는데, 이 두 값이 Database 에 작성될 때 Duration 은 Double 값으로, OAuth2TokenFormat 은 LinkedHashMap 형태로 저장되기 때문에 단순 형변환 하면 문제가 생기게 된다.
따라서, JPA Entity 에서 RegisteredClient 객체로 변환 할 때 ( Docs 의 toObject 함수가 이 역할을 하고 있다. ) 문제가 생기는 TokenSettings 를 역직렬화 하는 객체를 직접 만들어 해결했다.
TokenSettingsSerializer 코드는 이렇게 작성했다.
public final class TokenSettingsSerializer {
private final TokenSettings tokenSettings;
...
private TokenSettings buildTokenSettings(Map<String, Object> settings){
return TokenSettings.builder()
//Convert Duration type.
.authorizationCodeTimeToLive(
this.durationConverter(getSetting(ConfigurationSettingNames.Token.AUTHORIZATION_CODE_TIME_TO_LIVE, settings))
)
.accessTokenTimeToLive(
this.durationConverter(getSetting(ConfigurationSettingNames.Token.ACCESS_TOKEN_TIME_TO_LIVE, settings))
)
.deviceCodeTimeToLive(
this.durationConverter(getSetting(ConfigurationSettingNames.Token.DEVICE_CODE_TIME_TO_LIVE, settings))
)
.refreshTokenTimeToLive(
this.durationConverter(getSetting(ConfigurationSettingNames.Token.REFRESH_TOKEN_TIME_TO_LIVE,settings))
)
//Others
.reuseRefreshTokens(
Boolean.TRUE.equals(getSetting(ConfigurationSettingNames.Token.REUSE_REFRESH_TOKENS, settings))
)
.idTokenSignatureAlgorithm(
SignatureAlgorithm.from(getSetting(ConfigurationSettingNames.Token.ID_TOKEN_SIGNATURE_ALGORITHM, settings))
)
.accessTokenFormat(
this.tokenFormatConverter(getSetting(ConfigurationSettingNames.Token.ACCESS_TOKEN_FORMAT, settings),null)
)
.build();
}
private Duration durationConverter(Double value){
return Duration.ofSeconds(Math.round(value));
}
private OAuth2TokenFormat tokenFormatConverter(Map<String, Object> map, @Nullable String keyName){
//in my case, value from database is LinkedHashMap.
Assert.notEmpty(map, "Map object is empty.");
keyName = Objects.requireNonNullElse(keyName, "value");
if(OAuth2TokenFormat.SELF_CONTAINED.getValue().equals(getSetting(keyName, map))) return OAuth2TokenFormat.SELF_CONTAINED;
else if(OAuth2TokenFormat.REFERENCE.getValue().equals(getSetting(keyName, map))) return OAuth2TokenFormat.REFERENCE;
throw new IllegalArgumentException("Cannot convert "+getSetting(keyName, map)+"to OAuth2TokenFormat.");
}
private <T> T getSetting(String name, Map<String, Object> settings){
Assert.hasText(name, "name cannot be empty");
Assert.notEmpty(settings,"Map object is empty.");
Assert.notNull(settings.get(name),"Value not exist.");
return (T) settings.get(name);
}
}
그리고 이 객체를 이용해서 Entity 를 변환할 때 올바르게 변환할 수 있게 변경했다.
private RegisteredClient toObject(Client client) {
...
//RegisteredClient 객체 만들 때, 올바르게 역직렬화 해서 객체를 넣어야 한다.
TokenSettingsSerializer serializer = new TokenSettingsSerializer(parseMap(client.getTokenSettings()));
builder.tokenSettings(serializer.getTokenSettings());
return builder.build();
}
본론
OAuth 인증을 하기 위해서는 인증할 Client에 대한 정보를 미리 등록해야 한다. 만약 필자처럼 이 Client 정보를 저장하는 Repository를 InMemoryRegisteredClientRepository 를 사용하지 않고, Database 에 저장하게 구현했다면, 그 정보는 Database 에 저장될 것이다. 이때 몇몇 정보들은 객체를 그대로 저장할 수 없기 때문에 다른 형태로 저장하게 된다. 예를들면, Duration 객체는 Double로, 복잡한 Object 는 Json String 형태로 저장하는 식이다. 아래는 실제 Docs에서 구현된 Client Entity 의 모습이다.
@NoArgsConstructor
public class Client {
@Id
@GeneratedValue(
strategy = GenerationType.SEQUENCE,
generator = "CLIENT_SEQ"
)
private Long id;
private String clientId;
private Instant clientIdIssuedAt;
private String clientSecret;
private Instant clientSecretExpiresAt;
private String clientName;
@Column(length = 1000)
private String clientAuthenticationMethods;
@Column(length = 1000)
private String authorizationGrantTypes;
@Column(length = 1000)
private String redirectUris;
@Column(length = 1000)
private String postLogoutRedirectUris;
@Column(length = 1000)
private String scopes;
@Column(length = 2000)
private String clientSettings;
@Column(length = 2000)
private String tokenSettings;
...
}
그리고 이 Entity 는 이후 OAuth-Client 로 부터 인증 요청을 받을 때 식별자 ( Client ID value )를 받아 Database에서 조회하여 등록된 Client 정보를 읽어서 사용하게 된다.
이 Client 객체는 OAuth-Server 에서 RegisteredClient 라는 객체로 변경되어서 사용된다. 따라서, 이 Client 객체를 RegisteredClient 객체로 변환해주는 과정이 필요하다.
public class RegisteredClient implements Serializable {
private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID;
private String id;
private String clientId;
private Instant clientIdIssuedAt;
private String clientSecret;
private Instant clientSecretExpiresAt;
private String clientName;
private Set<ClientAuthenticationMethod> clientAuthenticationMethods;
private Set<AuthorizationGrantType> authorizationGrantTypes;
private Set<String> redirectUris;
private Set<String> postLogoutRedirectUris;
private Set<String> scopes;
private ClientSettings clientSettings;
private TokenSettings tokenSettings;
...
}
이제 이 Client 객체를 RegisteredClient 로 변환할 때, 기존에 변환되어 DB에 저장된 값들이 ClientAuthenticationMethod 나 AuthorizationGrantType, ClientSettings, TokenSettings 와 같은 객체 값들로 올바르게 변환되는지 확인해야 한다.
//Entity Client 를 RegisteredClient 로 변환한다.
RegisteredClient.Builder builder = RegisteredClient.withId(String.valueOf(client.getId()))
.clientId(client.getClientId())
.clientIdIssuedAt(client.getClientIdIssuedAt())
.clientSecret(client.getClientSecret())
.clientSecretExpiresAt(client.getClientSecretExpiresAt())
.clientName(client.getClientName())
.clientAuthenticationMethods(authenticationMethods ->
clientAuthenticationMethods.forEach(authenticationMethod ->
authenticationMethods.add(resolveClientAuthenticationMethod(authenticationMethod))))
.authorizationGrantTypes((grantTypes) ->
authorizationGrantTypes.forEach(grantType ->
grantTypes.add(resolveAuthorizationGrantType(grantType))))
.redirectUris((uris) -> uris.addAll(redirectUris))
.postLogoutRedirectUris((uris) -> uris.addAll(postLogoutRedirectUris))
.scopes((scopes) -> scopes.addAll(clientScopes));
...
이렇게 다른 값들은 잘 변환되는것을 알 수 있다. 하지만, 여기서 TokenSettings 를 변환할 때 문제가 발생했다. TokenSettings 값을 변환하는 코드는 String 을 읽어, ObjectMapper 를 통해서 Map<String, Object> 형태로 변환한 뒤, 이값을 그대로 TokenSettings 에 값을 추가하는 식이다.
//변환 코드. Map으로 변환할 때, String 객체에서
Map<String, Object> tokenSettingsMap = parseMap(client.getTokenSettings());
builder.tokenSettings(TokenSettings.withSettings(tokenSettingsMap).build());
//String 객체를 읽어 Map 오브젝트로 변경한다.
private Map<String, Object> parseMap(String data) {
try {
log.info("data : {}", data);
return new ObjectMapper().readValue(data, new TypeReference<>() {});
} catch (Exception ex) {
throw new IllegalArgumentException(ex.getMessage(), ex);
}
}
//Map 객체를 읽어 해당하는 지정된 key 값에 그대로 매핑한다.
public static TokenSettings.Builder withSettings(Map<String, Object> settings) {
Assert.notEmpty(settings, "settings cannot be empty");
return new TokenSettings.Builder()
.settings(s -> s.putAll(settings));
}
아래는 실제 Database 에 저장된 TokenSettings 값이다.
{
"settings.token.reuse-refresh-tokens":true,
"settings.token.id-token-signature-algorithm":"RS256",
"settings.token.access-token-time-to-live":300.000000000,
"settings.token.access-token-format":{
"value":"self-contained"
},
"settings.token.refresh-token-time-to-live":3600.000000000,
"settings.token.authorization-code-time-to-live":300.000000000,
"settings.token.device-code-time-to-live":300.000000000
}
위 코드를 동작시켜 String 값을 Map<String, Object> 로 변환했을 때, 다음과 같이 저장될것이다.
//OAuth Server에서 요구하는 TokenSettings 의 자료형은 아래와 같다.
{
"refresh-token-time-to-live" : (Boolean),
"id-token-signature-algorithm" : (String),
"access-token-time-to-live" : (Duration),
"refresh-token-time-to-live" : (Duration),
"authorization-code-time-to-live" : (Duration),
"device-code-time-to-live" : (Duration),
"access-token-format" : (OAuth2TokenFormat)
}
//하지만 위 로직을 따라서 그대로 변환하면 이러한 결과가 얻어진다.
{
"refresh-token-time-to-live" : (Boolean) True,
"id-token-signature-algorithm" : (String) "RS256",
//여기서부터 문제가 생긴다.
"access-token-time-to-live" : (Double) 300.0,
"refresh-token-time-to-live" : (Double) 3600.0,
"authorization-code-time-to-live" : (Double) 300.0,
"device-code-time-to-live" : (Double) 300.0,
"access-token-format" : (LinkedHashMap) { ... }
}
이런 식으로 변환되기 때문에, 원래 의도했던 TokenSettings 의 자료형과 달라지게 되고, Duration <-> Double 과 OAuth2TokenFormat <-> LinkedHashMap 사이에 변환이 진행되지 않아, 이 TokenSettings 객체의 값을 읽어들일 때 에러가 발생하게 된다.
직전 포스팅에서 형변환 문제가 생겼던 이유도 다시 살펴보면, Double 값이 형변환 되지 않았던 이유도 마찬가지로 RegisteredClient 객체에서 Duration값이라 예상되는데 실제 값은 Double 값이 저장되었기 때문에 문제가 발생한다.
@Nullable
@Override
public OAuth2AuthorizationCode generate(OAuth2TokenContext context) {
if (context.getTokenType() == null ||
!OAuth2ParameterNames.CODE.equals(context.getTokenType().getValue())) {
return null;
}
Instant issuedAt = Instant.now();
//tokenSettings 에서 Duration 타입 값을 요청하는데, 실제로 저장된 값은 Double 값이다.
Instant expiresAt = issuedAt.plus(context.getRegisteredClient().getTokenSettings().getAuthorizationCodeTimeToLive());
return new OAuth2AuthorizationCode(this.authorizationCodeGenerator.generateKey(), issuedAt, expiresAt);
}
이 문제는 직전 포스팅에서 다뤘던 CustomOAuthAuthorizationCodeGenerator 에서만 발생하는게 아니다. 예상대로라면 TokenSettings 에서 변환되지 않은 5가지 값을 읽어들이는 모든 곳에서 에러가 발생할 것이다. 그리고 실제로도 동일하게 문제가 발생한다.
따라서, 이 문제를 근본적으로 해결하기 위해서는 Client 를 RegisteredClient 로 변환할 때, 올바르게 변환되지 않는 TokenSettings 객체만 똑바로 변환시켜 입력하게 하면 근본적인 문제를 해결할 수 있게 된다.
public final class TokenSettingsSerializer {
private final TokenSettings tokenSettings;
...
private TokenSettings buildTokenSettings(Map<String, Object> settings){
return TokenSettings.builder()
//Convert Duration type.
.authorizationCodeTimeToLive(
this.durationConverter(getSetting(ConfigurationSettingNames.Token.AUTHORIZATION_CODE_TIME_TO_LIVE, settings))
)
.accessTokenTimeToLive(
this.durationConverter(getSetting(ConfigurationSettingNames.Token.ACCESS_TOKEN_TIME_TO_LIVE, settings))
)
.deviceCodeTimeToLive(
this.durationConverter(getSetting(ConfigurationSettingNames.Token.DEVICE_CODE_TIME_TO_LIVE, settings))
)
.refreshTokenTimeToLive(
this.durationConverter(getSetting(ConfigurationSettingNames.Token.REFRESH_TOKEN_TIME_TO_LIVE,settings))
)
//Others
.reuseRefreshTokens(
Boolean.TRUE.equals(getSetting(ConfigurationSettingNames.Token.REUSE_REFRESH_TOKENS, settings))
)
.idTokenSignatureAlgorithm(
SignatureAlgorithm.from(getSetting(ConfigurationSettingNames.Token.ID_TOKEN_SIGNATURE_ALGORITHM, settings))
)
.accessTokenFormat(
this.tokenFormatConverter(getSetting(ConfigurationSettingNames.Token.ACCESS_TOKEN_FORMAT, settings),null)
)
.build();
}
private Duration durationConverter(Double value){
return Duration.ofSeconds(Math.round(value));
}
private OAuth2TokenFormat tokenFormatConverter(Map<String, Object> map, @Nullable String keyName){
//in my case, value from database is LinkedHashMap.
Assert.notEmpty(map, "Map object is empty.");
keyName = Objects.requireNonNullElse(keyName, "value");
if(OAuth2TokenFormat.SELF_CONTAINED.getValue().equals(getSetting(keyName, map))) return OAuth2TokenFormat.SELF_CONTAINED;
else if(OAuth2TokenFormat.REFERENCE.getValue().equals(getSetting(keyName, map))) return OAuth2TokenFormat.REFERENCE;
throw new IllegalArgumentException("Cannot convert "+getSetting(keyName, map)+"to OAuth2TokenFormat.");
}
...
}
따라서, 올바르지 않게 변환된 Map 객체를 TokenSettings 객체로 올바르게 변경해주는 TokenSettingsSerializer 객체를 새로 만들었고, RegisteredClient 를 생성할 때에 역직렬화 로직을 변경했다.
//TokenSettings 를 올바르게 변환한다.
TokenSettingsSerializer serializer = new TokenSettingsSerializer(parseMap(client.getTokenSettings()));
builder.tokenSettings(serializer.getTokenSettings());
이제 기존에 FilterChain 에 등록했던 CustomOAuthAuthorizationCodeGenerator 는 더이상 필요하지 않으므로, 삭제한다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity,
...) throws Exception {
...
//더 이상 이런 코드는 필요 없다.
//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();
}
사실 이 TokenSettings 는 OAuth2TokenGenerator interface를 Implements 하는 객체들에서 모두 사용한다.
앞선 포스팅에서 에러가 났었던 OAuth2AuthorizationCodeGenerator도, 위에 첨부된 에러 이미지의 JwtGenerator 도 모두 다 포함되어 있음을 알 수 있다.