Spring boot 3.1.2 + Spring Boot Starter OAuth2 Authorization Server 適用記

Juhwan Ma2023. 09. 08
リンクをコピーしました。
Spring boot 3.1.2 + Spring Boot Starter OAuth2 Authorization Server 適用記

はじめに

こんにちは、Riiid R.inside バックエンドエンジニアのマ・ジュファンです。最近、新しいB2Bプロジェクトを進めることになり、プロジェクト群を再構成する機会ができたことで Spring Security OAuth2 Authorization Server ライブラリを使うようになりました。この過程での私の経験や考えが少しでも役に立てば、あるいは認証サーバーに関心のある方々のためになればと思い、本記事を書くことにしました。

Spring Security カスタマイジング実装の背景

Spring バージョンアップデートの苦労

Spring boot 3.x バージョンがリリースされた後も、依然として実務では Spring 2.x バージョンが主流です。やはりバージョンアップデートは実務者の立場からするとかなり厄介なことで、主に以下の理由でアップデートを避けています。

既存で 2.x バージョンを使用しており、アップデートの必要性を感じていない。
Java 8~11 メインバージョンから 17 へアップデートする負担が大きすぎる。
ライブラリの成熟度が保証されておらず、リファレンスが不足している。
ライブラリの下位互換性などが検証されておらず、検証のために直接テストを進める必要がある。
ビジネス機能の対応だけでも手一杯で、アップデートまで気を配るのが難しい。

しかし私は新しいプロジェクトを前にして、Spring boot 2.x のサポート期限があまり残っていないこともあり、惰性に引かれて 2 バージョンで開発することになれば、結局は技術的負債を増やすだけではないかと思い、新しいプロジェクトは kotlin 1.8.21 + spring boot 3.1.2 + JVM 17で進めることにしました。
Riiid の B2B プロジェクトでは、ユーザー認証のために Spring security を利用して Authorization server を別途運用しています。Authorization server は OAuth2 プロトコルをベースに製品の状況に合わせてカスタマイズして使用しており、Front-end server、Authorization server、Resource Server がすべて同じシステムで構成されているため、ROPC(Resource Owner Password Credentials) をベースに Authorities や Role などをカスタマイズして提供していました。新しいプロジェクトでもプロジェクト環境の構成が同じであるため、同じ方式で認証サーバーを提供しようとしましたが、以下のような問題が発生しました。

Spring boot 3.x では既存の 2.x で deprecated だったコードをこれ以上サポートしない。
ROPC 仕様は OAuth2.0 において外部システム実装の制約、client credential の露出、二要素認証実装の難しさなどから推奨されない仕様となり、Spring security でも deprecated になった。
既存の認証サーバーコードで使用されていた deprecated class の strikethrough があまりにも気になってしまう。

上記の問題を解決するために代案を探していたところ、Spring boot 3 の公開とほぼ同時期に GA 1.0 へ上がった Spring Security OAuth2 Authorization Server ライブラリを使ってみることにしました。公開されてから間もなく、リファレンスが非常に不足していましたが、GA + 活発なコミュニケーション + 継続的なアップデートとサポート + deprecated code 更新への意志が重なっていたため、進めてみることにしました。
しかしライブラリを適用するために依存性を追加し、公式ドキュメントを参考にして開発を始めた瞬間から問題が発生しました。GA になったライブラリが(Spring Security OAuth2 Authorization Server を依存関係に持つ Spring Boot Starter OAuth2 Authorization Server)OAuth2.1 プロトコルを基準に従っており、OAuth 2.1 では ROPC(+ implicit grant) がそもそも実装されていなかったのです。推奨されない仕様とはいえ、そもそも実装すら不可能だとは、と思ってあちこち検索してみると、Authentication code grant に変更しろ、一方的になくしたのは納得できない、などの意見があるのを見る限り、本当になくなったようでした。(こういうことがあるからバージョンアップグレードはさらに大変なのでは..)
しかし今回のプロジェクトは依然として ROPC プロトコルを実装する要件を持っており、認証プロトコルを変更する理由もなく、継続的に使用する必要があったため、Spring security の基本アーキテクチャの上に、私たちの製品に必要な仕様をすべてカスタマイズして実装することに決めました

Spring Boot OAuth2 Authorization Server 実装

依存性の追加

まず、Spring boot OAuth2 Authorization Server を使用できるように依存性を追加します。

plugins {
    id("org.springframework.boot") version "3.1.2"
    id("io.spring.dependency-management") version "1.1.2"
    kotlin("jvm") version "1.8.21"
}
repositories {
    mavenCentral()
}
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    /* .... */
    implementation("org.springframework.boot:spring-boot-starter-oauth2-authorization-server:3.1.2")
}
tasks {
    compileKotlin {
        kotlinOptions {
            freeCompilerArgs = listOf("-Xjsr305=strict", "-Xjvm-default=all-compatibility")
            jvmTarget = "17"
        }
        dependsOn(processResources)
    }
    compileTestKotlin {
        kotlinOptions {
            freeCompilerArgs = listOf("-Xjsr305=strict", "-Xjvm-default=all-compatibility")
            jvmTarget = "17"
        }
    }
    test {
        useJUnitPlatform()
    }
}

Spring Security の認証プロセス

依存性を追加して本格的に開発する前に、基本的な Spring Security の流れについて知っておく必要があります。Spring Security の認証プロセスはやや複雑な流れを経ますが、(非常に)抽象化されたダイアグラムを見ると理解に大いに役立ちます。上のダイアグラムを順番に確認すると

ユーザーのリクエストを AuthenticationFilter が受け取ります。
受け取ったリクエストは認証されていない Authentication 実装体を生成します。
AuthenticationManager に認証を委譲します。
AuthenticationManager を実装した ProviderManager で適切な AuthenticationProvider を探して認証を試みます。
AuthenticationProvider は Database へのアクセスなどの方法を用いて認証を確認します。
認証プロセスが完了すると、認証済みの Authentication(よく耳にする UserDetails とともに)を生成して AuthenticationManager に渡します。
認証プロセスが完了した Authentication が SecurityContextHolder に保存されます。

認証プロセスを確認すると、認証プロセスをカスタマイズするためには Authentication、AuthenticationProvider を実装して認証プロセスに注入する必要があることが分かります。

Security Configuration設定

Spring Authorization server は公式 document で認証プロセスに実装体を設定する方法と例を提供しています。以下に公式 document の例があります。

@Bean
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
    OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
    new OAuth2AuthorizationServerConfigurer();
    http.apply(authorizationServerConfigurer);
    authorizationServerConfigurer
      .registeredClientRepository(registeredClientRepository)
      .authorizationService(authorizationService)
      .authorizationConsentService(authorizationConsentService)
      .authorizationServerSettings(authorizationServerSettings)
      .tokenGenerator(tokenGenerator)
      .clientAuthentication(clientAuthentication -> { })
      .authorizationEndpoint(authorizationEndpoint -> { })
      .deviceAuthorizationEndpoint(deviceAuthorizationEndpoint -> { })
      .deviceVerificationEndpoint(deviceVerificationEndpoint -> { })
      .tokenEndpoint(tokenEndpoint -> { })
      .tokenIntrospectionEndpoint(tokenIntrospectionEndpoint -> { })
      .tokenRevocationEndpoint(tokenRevocationEndpoint -> { })
      .authorizationServerMetadataEndpoint(authorizationServerMetadataEndpoint -> { })
      .oidc(oidc -> oidc
       .providerConfigurationEndpoint(providerConfigurationEndpoint -> { })
       .logoutEndpoint(logoutEndpoint -> { })
       .userInfoEndpoint(userInfoEndpoint -> { })
       .clientRegistrationEndpoint(clientRegistrationEndpoint -> { })
    );
   return http.build();
}

Configurer が builder pattern を用いて、基本的に認証プロセスをサポートするための多くの設定方法を提供しています。私たちは ROPC プロトコルを活用してトークン生成リクエスト、トークン検証リクエストをカスタマイズする必要があるため、上で 2 つ、tokenEndpoint と tokenIntrospectionEndpoint を設定する予定です。

tokenEndpoint

@Bean
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
 OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
  new OAuth2AuthorizationServerConfigurer();
 http.apply(authorizationServerConfigurer);

 authorizationServerConfigurer
  .tokenEndpoint(tokenEndpoint ->
   tokenEndpoint
    .accessTokenRequestConverter(accessTokenRequestConverter)   
    .accessTokenRequestConverters(accessTokenRequestConvertersConsumer) 
    .authenticationProvider(authenticationProvider) 
    .authenticationProviders(authenticationProvidersConsumer)   
    .accessTokenResponseHandler(accessTokenResponseHandler) 
    .errorResponseHandler(errorResponseHandler) 
  );

 return http.build();
}

tokenEndpoint の builder の例です。Converter、Provider、Handler などを登録できることが分かります。初期開発ではすべての handling を converter と provider で直接実行する予定であるため、Converter と Provider を実装して設定すればよいです。

tokenIntrospectionEndpoint

@Bean
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
 OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
  new OAuth2AuthorizationServerConfigurer();
 http.apply(authorizationServerConfigurer);

 authorizationServerConfigurer
  .tokenIntrospectionEndpoint(tokenIntrospectionEndpoint ->
   tokenIntrospectionEndpoint
    .introspectionRequestConverter(introspectionRequestConverter)   
    .introspectionRequestConverters(introspectionRequestConvertersConsumer) 
    .authenticationProvider(authenticationProvider) 
    .authenticationProviders(authenticationProvidersConsumer)   
    .introspectionResponseHandler(introspectionResponseHandler) 
    .errorResponseHandler(errorResponseHandler) 
  );

 return http.build();
}

tokenIntrospectionEndpoint は基本的に tokenEndpoint と似ているため、検証のための Converter、Provider を実装して設定すればよいです。公式 document の例は Java を基準に作成されており、私たちは kotlin を使用しているため、適切に変換してコードを作成しました。

Configuration

    @Bean
    fun authorizationServerSecurityFilterChain(
        http: HttpSecurity,
        authorizationService: OAuth2AuthorizationService,
        authorizationContentService: OAuth2AuthorizationConsentService,
        tokenGenerator: OAuth2TokenGenerator<*>,
        userService: CustomAuthUserService,
        encoder: PasswordEncoder,
        registeredClientRepository: RegisteredClientRepository
    ): SecurityFilterChain? {
        val authorizationServerConfigurer = OAuth2AuthorizationServerConfigurer()
        authorizationServerConfigurer
            .tokenEndpoint { tokenEndpoint: OAuth2TokenEndpointConfigurer ->
                tokenEndpoint
                    .accessTokenRequestConverter(
                        CustomGrantAuthenticationConverter()
                    )
                    .authenticationProvider(
                        CustomGrantAuthenticationProvider(
                            authorizationService,
                            tokenGenerator,
                            userService
                        )
                    )
            }.tokenIntrospectionEndpoint { tokenIntrospectEndpoint ->
                tokenIntrospectEndpoint
                    .introspectionRequestConverter(CustomIntrospectAuthenticationConverter())
                    .authenticationProvider(
                        CustomIntrospectAuthenticationProvider(
                            authorizationService,
                            registeredClientRepository
                        )
                    )
            }

        val endpointsMatcher = authorizationServerConfigurer.endpointsMatcher
        http
            .securityMatcher(endpointsMatcher)
            .authorizeHttpRequests { authorize ->
                authorize
                    .requestMatchers(HttpMethod.GET, "/actuator/health").permitAll()
                    .requestMatchers(HttpMethod.GET, "/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**")
                    .permitAll()
                    .requestMatchers(HttpMethod.POST, "/oauth2/introspect").permitAll()
                    .anyRequest().authenticated()
            }
            .csrf { csrf -> csrf.ignoringRequestMatchers(endpointsMatcher) }
            .apply(authorizationServerConfigurer)
        authorizationServerConfigurer
            .authorizationService(authorizationService)
            .authorizationConsentService(authorizationContentService)

        return http.build()
    }

上記の Configuration を簡単に説明すると

authorizationService: OAuth2AuthorizationService の実装体 Bean です。Access token の保存と管理を担当し、必要に応じて InMemory、Database、Redis などのストレージを活用できます。ライブラリが基本的に提供する実装体を使用することもでき、開発時には認証サーバークラスターを構成する必要があるため、ストレージを内部に接続しました。
authorizationContentService: 認証同意 Service の実装体 Bean です。単純な認証のみに活用し、認証時に同意委任などの設定が必要ない場合は使用しなくても構いません。
tokenGenerator: OAuth2TokenGenerator の実装体 Bean です。ライブラリでは基本的に Access token Generator、Refresh Token Generator などが含まれた DelegatingOAuth2TokenGenerator を使用して、リクエストに応じて token を生成します。後述する Customizer を活用するため、別途実装して注入しました。
userService: UserDetailService 実装体です。実際の認証に活用されるユーザー情報へアクセスするために注入します。
encoder: Password Encoder を注入します。
registeredClientRepository: client サービス認証のための repository です。例では client がすべて同じシステムに属しており、動的に client が追加されないため、基本的な InMemoryRegisteredClientRepository を活用して Bean として登録した後に注入しました。

SecurityFilterChain に設定した実装体が構成されました。次に、実際のカスタマイズのための Converter と Provider を実装します。

CustomGrantAuthenticationConverter

class CustomGrantAuthenticationConverter : AuthenticationConverter {
    override fun convert(request: HttpServletRequest): Authentication? {
        // grant_type (REQUIRED)
        val grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE)
        if (SecurityConfiguration.CUSTOM_GRANT_TYPE != grantType && SecurityConfiguration.REFRESH_TOKEN_GRANT_TYPE != grantType) {
            return null
        }
        val clientPrincipal = SecurityContextHolder.getContext().authentication
        val parameters: MultiValueMap<String, String> = getParameters(request)
        val additionalParameters: MutableMap<String, Any> = HashMap()
        parameters.forEach { (key: String, value: List<String>) ->
            if (key != OAuth2ParameterNames.GRANT_TYPE &&
                key != OAuth2ParameterNames.CLIENT_ID &&
                key != OAuth2ParameterNames.CODE &&
                key != OAuth2ParameterNames.CLIENT_SECRET
            ) {
                additionalParameters[key] = value[0]
            }
        }
        return CustomGrantAuthenticationToken(AuthorizationGrantType(grantType), clientPrincipal, additionalParameters)
    }
    private fun getParameters(request: HttpServletRequest): MultiValueMap<String, String> {
        val parameterMap = request.parameterMap
        val parameters: MultiValueMap<String, String> = LinkedMultiValueMap(parameterMap.size)
        parameterMap.forEach { (key: String, values: Array<String?>) ->
            if (values.isNotEmpty()) {
                for (value in values) {
                    parameters.add(key, value)
                }
            }
        }
        return parameters
    }
}

Converterでは、Authenticationが委譲されてProviderへ渡される前に、基本的なリクエスト検証および変換を行うようにしました。Grant Typeを基本タイプとは別に宣言しているため、Custom Grant TypeとRefresh Token Grant typeであるかどうか程度のみを確認し、Provider検証用に変換して、OAuth2AuthorizationGrantAuthenticationTokenを実装したCustomGrantAuthenticationTokenとしてProviderへ渡します。

CustomGrantAuthenticationProvider

class CustomGrantAuthenticationProvider(
    private val authorizationService: OAuth2AuthorizationService,
    private val tokenGenerator: OAuth2TokenGenerator<out OAuth2Token>,
    private val userService: CustomAuthUserService
) : AuthenticationProvider {

    init {
        Assert.notNull(authorizationService, "authorizationService cannot be null")
        Assert.notNull(tokenGenerator, "tokenGenerator cannot be null")
    }

    @Throws(AuthenticationException::class)
    override fun authenticate(authentication: Authentication): Authentication {
        val customGrantAuthenticationToken: CustomGrantAuthenticationToken =
            authentication as CustomGrantAuthenticationToken

        val clientPrincipal = getAuthenticatedClientElseThrowInvalidClient(customGrantAuthenticationToken)
        val registeredClient = clientPrincipal.registeredClient

        if (!registeredClient!!.authorizationGrantTypes.contains(customGrantAuthenticationToken.grantType)) {
            throw OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT)
        }
        val additionalParameters = customGrantAuthenticationToken.additionalParameters
        val scope = additionalParameters[OAuth2ParameterNames.SCOPE] as String?
        var userDetails: UserDetails? = null
        when (authentication.grantType.value) {
            SecurityConfiguration.CUSTOM_GRANT_TYPE -> {
                val username = additionalParameters[OAuth2ParameterNames.USERNAME] as String?
                    ?: throw OAuth2AuthenticationException("username not found.")
                val password = additionalParameters[OAuth2ParameterNames.PASSWORD] as String?
                    ?: throw OAuth2AuthenticationException("password not found.")

                userDetails = userService.loadUserByUsername(username, registeredClient.clientId)
                if (SecurityHelper.hashSHA512(password).trim() != (userDetails.password)) {
                    throw OAuth2AuthenticationException("password not matched.")
                }
            }

            SecurityConfiguration.REFRESH_TOKEN_GRANT_TYPE -> {
                val givenRefreshToken = additionalParameters[OAuth2ParameterNames.REFRESH_TOKEN] as String?
                authorizationService.findByToken(givenRefreshToken, OAuth2TokenType.REFRESH_TOKEN)
                    ?: throw OAuth2AuthenticationException("refresh token not matched.")
            }

            else -> throw OAuth2AuthenticationException("Not supported grant type.")
        }

        val tokenContext: OAuth2TokenContext = DefaultOAuth2TokenContext.builder()
            .registeredClient(registeredClient)
            .principal(clientPrincipal)
            .authorizationServerContext(AuthorizationServerContextHolder.getContext())
            .tokenType(OAuth2TokenType.ACCESS_TOKEN)
            .authorizationGrantType(customGrantAuthenticationToken.grantType)
            .authorizationGrant(customGrantAuthenticationToken)
            .authorizedScopes(setOf(scope))
            .build()
        userDetails?.let {
            (tokenGenerator as CustomDelegatingOAuth2TokenGenerator)
                .setCustomizer(
                    CustomDelegatingOAuth2TokenGenerator.GeneratorType.ACCESS_TOKEN,
                    CustomAccessTokenCustomizer((it as CustomUser))
                )
        }
        val generatedAccessToken = tokenGenerator.generate(tokenContext)
        if (generatedAccessToken == null) {
            val error = OAuth2Error(
                OAuth2ErrorCodes.SERVER_ERROR,
                "Token generator failed to generate the access token.",
                null
            )
            throw OAuth2AuthenticationException(error)
        }

        val accessToken = OAuth2AccessToken(
            OAuth2AccessToken.TokenType.BEARER,
            generatedAccessToken.tokenValue,
            generatedAccessToken.issuedAt,
            generatedAccessToken.expiresAt,
            setOf(scope)
        )
        val refreshTokenContext: OAuth2TokenContext = DefaultOAuth2TokenContext.builder()
            .registeredClient(registeredClient)
            .principal(clientPrincipal)
            .authorizationServerContext(AuthorizationServerContextHolder.getContext())
            .tokenType(OAuth2TokenType.REFRESH_TOKEN)
            .authorizationGrantType(customGrantAuthenticationToken.grantType)
            .authorizationGrant(customGrantAuthenticationToken)
            .authorizedScopes(setOf(scope))
            .build()
        val generatedRefreshToken = tokenGenerator.generate(refreshTokenContext)
        if (generatedRefreshToken == null) {
            val error = OAuth2Error(
                OAuth2ErrorCodes.SERVER_ERROR,
                "The token generator failed to generate the access token.",
                null
            )
            throw OAuth2AuthenticationException(error)
        }

        val refreshToken = OAuth2RefreshToken(
            generatedRefreshToken.tokenValue,
            generatedRefreshToken.issuedAt,
            generatedRefreshToken.expiresAt
        )

        val authorizationBuilder = OAuth2Authorization.withRegisteredClient(registeredClient)
            .principalName(clientPrincipal.name)
            .authorizationGrantType(customGrantAuthenticationToken.grantType)
        if (generatedAccessToken is ClaimAccessor) {
            authorizationBuilder.token(
                accessToken
            ) { metadata: MutableMap<String?, Any?> ->
                metadata[OAuth2Authorization.Token.CLAIMS_METADATA_NAME] =
                    (generatedAccessToken as ClaimAccessor).claims
            }.refreshToken(refreshToken).attribute("refresh_expires_in", refreshToken.expiresAt)
        } else {
            authorizationBuilder.accessToken(accessToken)
                .refreshToken(refreshToken).attribute("refresh_expires_in", refreshToken.expiresAt)
        }

        val authorization = authorizationBuilder.build()

        authorizationService.save(authorization)
        val attrib: MutableMap<String, Any?> = mutableMapOf(
            "refresh_expires_in" to refreshToken.expiresAt?.let {
                val currentInstant = Instant.now()
                val duration = Duration.between(currentInstant, it)
                duration.seconds
            }
        )
        userDetails?.let {
            attrib["x_custom_userinfo"] = (it as CustomUser).toDto()
        }
        return OAuth2AccessTokenAuthenticationToken(
            registeredClient,
            clientPrincipal,
            accessToken,
            refreshToken,
            attrib
        )
    }

    override fun supports(authentication: Class<*>): Boolean {
        return CustomGrantAuthenticationToken::class.java.isAssignableFrom(authentication)
    }

    private fun getAuthenticatedClientElseThrowInvalidClient(authentication: Authentication): OAuth2ClientAuthenticationToken {
        var clientPrincipal: OAuth2ClientAuthenticationToken? = null
        if (OAuth2ClientAuthenticationToken::class.java.isAssignableFrom(
                authentication.principal::class.java
            )
        ) {
            clientPrincipal = authentication.principal as OAuth2ClientAuthenticationToken?
        }
        if (clientPrincipal != null && clientPrincipal.isAuthenticated) {
            return clientPrincipal
        }
        throw OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT)
    }
}

Provider の実装は、ライブラリでさまざまに提供されている複数の AuthenticationProvider 実装クラスのコードを参考にすれば、それほど難しくなく構成できます。Provider には、検証および refresh token の同時発行、そのほかユーザー情報の注入などの内容が実装されています。大きな特殊事項はありませんが、重要な実装ポイントが 1 つあります。

TokenGenerator 実装

userDetails?.let {
            (tokenGenerator as CustomDelegatingOAuth2TokenGenerator)
                .setCustomizer(
                    CustomDelegatingOAuth2TokenGenerator.GeneratorType.ACCESS_TOKEN,
                    CustomAccessTokenCustomizer((it as CustomUser))
                )
        }

OAuth2AccessTokenGenerator は、OAuth2TokenCustomizer の実装体リストをフィールドとして持ち、access token に対して適切なカスタマイズ処理を実行できます。しかし問題は、Spring Authorization Server が使用する DelegatingOAuth2TokenGenator の場合、OAuth2TokenCustomizer の実装体を動的に注入するための注入ポイントがなかったことです! (どうやって使えっていうんだ.. 😂 ) Access token response とは別に、Access token の情報に比べて機微ではないその他のユーザー情報を入れて Resource API で活用したかったため、Customizer を注入できる TokenGenerator を上で別途実装して注入しました。

CustomDelegatingOAuth2TokenGenerator & CustomAccessTokenCustomizer

class CustomDelegatingOAuth2TokenGenerator<T : OAuth2Token>(
    vararg tokenGenerators: OAuth2TokenGenerator<out T>
) : OAuth2TokenGenerator<T> {

    private val tokenGenerators: List<OAuth2TokenGenerator<out T>>

    init {
        Assert.notEmpty(tokenGenerators, "tokenGenerators cannot be empty")
        Assert.noNullElements(tokenGenerators, "tokenGenerators cannot contain null elements")
        this.tokenGenerators = tokenGenerators.toList()
    }

    @Nullable
    override fun generate(context: OAuth2TokenContext): T? {
        for (tokenGenerator in this.tokenGenerators) {
            val token = tokenGenerator.generate(context)
            if (token != null) {
                return token
            }
        }
        return null
    }

    fun setCustomizer(type: GeneratorType, customizer: OAuth2TokenCustomizer<OAuth2TokenClaimsContext>) {
        when (type) {
            GeneratorType.ACCESS_TOKEN -> {
                for (tokenGenerator in this.tokenGenerators) {
                    if (tokenGenerator is OAuth2AccessTokenGenerator) {
                        tokenGenerator.setAccessTokenCustomizer(customizer)
                    }
                }
            }

            else -> {}
        }
    }

    enum class GeneratorType {
        ACCESS_TOKEN,
        REFRESH_TOKEN
    }
}
class CustomAccessTokenCustomizer(
    private val customUser: CustomUser
) : OAuth2TokenCustomizer<OAuth2TokenClaimsContext> {
    override fun customize(context: OAuth2TokenClaimsContext?) {
        val claimsBuilder = context?.claims
        claimsBuilder?.claim("x_custom_userinfo", customUser.toDto())
    }
}

アクセストークンのclaimsにmap情報を追加するようcustomizerを作成し、動的にclaimを注入できるように実装しました。Providerでの検証後にsetCustomizerを呼び出して、xcustomuserinfo を入れられるようになりました。
tokenIntrospectionEndpointにも同様の方式で、token検証のためのCustomIntrospectAuthenticationConverterとCustomIntrospectAuthenticationProviderを実装して設定すれば、基本的な認証サーバーを稼働させる準備が完了します。
scopeまたは権限範囲設定など、残っている工程はまだ多いですが、上記のプロセスを経れば、開発チームで独自に認証プロトコルを構成できるようになります。
必ず注意しなければならない点は、認証のカスタマイズは公式プロトコルに準拠しなくなるため、セキュリティ脆弱性などを開発チーム自身で検証しなければならないということです。綿密なコードレビューとテストを必ず経てこそ、本番環境でようやく運用できるようになります。

終わりに

リファレンスがあまりない状態で、ライブラリを一つずつ確認しながらカスタマイズしていくのは少し大変でしたが、リファレンスがなかったからこそ、むしろより詳しく学べる機会になり、基本的な枠組みを整えてからはSpring Securityについてさらに明確に見通せるようになりました。それだけでなく、リファレンス不足で難関に直面したり、Spring securityを活用して国際プラットフォーム認証の実装や他機関認証との連携など、多様な要件を柔軟に受け入れようとする開発者にとっても役立てばうれしく思います。それでは、この記事をここで締めくくります。お読みいただきありがとうございました。

ストーリー一覧

最新ストーリー