Spring Security + OAuthでデータベースを使用しようとしたら思いのほか面倒だった話

# はじめに
Spring-Securityでユーザーコントロールが出来るということで、良くやるGoogle⁺OpenID Connectをやろうとしたらはまって物凄い時間を使ってしまいました。
納得のいく実装にはなりませんでしたが、一応やり切ったので記事化しました。

この記事を書くに辺り、@kazuki43zooさんの記事がなければもっと時間を浪費してしまっていたかと思います。
素晴らしい記事ですので、Spring-Security + OAuthを使用したいと思っている方は是非とも読んでください。

第3回：Spring Security 5でサポートされたOAuth 2.0 Loginの処理の流れを(深く)理解する
https://qiita.com/kazuki43zoo/items/c70549931a4b0bc67757

# 今回やりたいこと
Spring-SecurityにはRoleの概念があり、Roleによって接続可能なURLの制御を行う事が可能となっています。
今回はOAuthのユーザー情報をデータベースに保存して、あとでRoleを変更出来るようにしようとしました。

デフォルトではメモリ上だけで管理しているため、どこかにユーザー情報を残すことは出来ません。
それをデータベースに保存して次の認証からRoleの値をデーターベースの値で書き換えようという話です。

ちなみに、デフォルトのRoleは`ROLE_USER`で固定となっています。

# 環境
Kotlin
Spring-boot 2.0.1.RELEASE

# pom.xml
今回はMavenを使っているので、必要なパッケージを追加します。
今回はGoogleのOpenIDConnectを使用しますが、OAuth2で大丈夫です。
(勝手に切り替えてくれる)

``` pom.xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-oauth2-client</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
```

# Controller
今回特に何かをさせたい訳ではありませんが、結果ぐらいは見れないということで結果確認用のContollerを作成しておきます。
OAuth2.0もOpenID Connectもこの形で確認可能となっています。

``` kotlin
    @GetMapping("/")
    @ResponseBody
    fun index(principal: Principal): String {
        if (principal is OAuth2AuthenticationToken) {
            return principal.principal.toString()
        }
        return principal.name
    }
```

# Entity
今回のユーザー情報を入れておくEntityを宣言します。
Spring-JPAでやっているので、それ以外を使用している方は読み替えてください。

中身としては特に情報を取っておくものが想定されていないので、ユニーク識別子である`sub`を入れておく`name`とRoleを入れておく`role`だけ用意しています。

``` kotlin
package com.tasogarei.databasetest.entity.postgres

import org.springframework.security.core.userdetails.User
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.GeneratedValue
import javax.persistence.GenerationType
import javax.persistence.Id
import javax.persistence.Table

@Table(name = "user", schema = "test")
@Entity
class UserEntity{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column
    var id: Int = 0

    @Column
    var name: String = ""

    @Column
    var role: String = ""
}
```

# Repository
OAuthを使用する場合は`name`で特定してあげる必要があるので、`findByName(name : String)`を追加で宣言しておきます。

本題と関係ありませんが、JPAの戻り値って`Optional<T>`で包むのとNull許容するのではどちらがいいんですかね。
Javaだと絶対包むのですが、Kotlinの良い回答が良くわかってません。

``` kotlin
package com.tasogarei.databasetest.repository.postgres

import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository

import com.tasogarei.databasetest.entity.postgres.UserEntity
import java.util.*

@Repository
interface UserRepository : JpaRepository<UserEntity, Int> {
    fun findByName(name : String) : Optional<UserEntity>
}
``` 

# OpenID Connect用の設定
ここまでで入れるための準備が出来たので、実際にデータベースの接続部分の実装をやっていきます。
正直ここで1週間ほど、適切な場所を考えに考えた結果としてはResponse結果から実際にユーザーを作成している`OidcUserService()`を拡張するのがまだまともという結論になり、拡張することにしました。

ユーザー作成が完全に隠蔽されていて拡張しにくい上にNimbusAPIがパッケージ内でしか使えないので、簡単には拡張出来ないようなってしまっています。
わざわざここらへんの処理を自分で一から作成するのも馬鹿らしいので、一度既存の`OidcUserService`にユーザーを作成してもらって、それを検査してこちらのやりたいことをやるとしました。
ユーザー取得できなかった場合の動作も含めて良い実装にはなりませんでしたが、`OidcUserService`の拡張はこんな感じで行いました。

Roleの書き換えは機能作るというのが面倒という理由で手で書き換えています。

``` kotlin
package com.tasogarei.databasetest.config

import com.tasogarei.databasetest.entity.postgres.UserEntity
import com.tasogarei.databasetest.repository.postgres.UserRepository
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser
import org.springframework.security.oauth2.core.oidc.user.OidcUser
import org.springframework.security.oauth2.core.user.DefaultOAuth2User
import org.springframework.security.oauth2.core.user.OAuth2User
import org.springframework.security.oauth2.core.user.OAuth2UserAuthority
import org.springframework.stereotype.Service

@Service
class OpenIdUserServiceJdbcService : OidcUserService() {

    @Autowired
    lateinit var userRepository: UserRepository

    override fun loadUser(userRequest : OidcUserRequest) : OidcUser {
        val user = super.loadUser(userRequest)
        val entity = userRepository.findByName(user.name)
        if(!entity.isPresent) {
            saveNewUser(user)
            return user
        }

        val authority = OAuth2UserAuthority(
                entity.get().role,
                user.attributes
        )
        return DefaultOidcUser(setOf(authority), userRequest.idToken, user.userInfo, userRequest.clientRegistration.providerDetails.userInfoEndpoint.userNameAttributeName)
    }

    private fun saveNewUser(user : OAuth2User) {
        var newEntity = UserEntity().apply {
            name = user.name
            role = "ROLE_USER"
        }
        userRepository.save(newEntity)
    }
}
```

最後に作成したサービスを読んで貰う設定とGoogleを使用するための設定を行って終わりです。
`configure()`に`oauth2Login()`を追加して`oidcUserService()`に今回作成した拡張クラスを指定するのと、ClientRegistrationRepositoryのBeanにGoogleの設定を追加しています。

前者は`openidLogin()`というのも存在していますが、たぶん違う用途で使われるので`oauth2Login()`を使用しましょう。
また、今回はGoogleのOpenID Connectのため`oidcUserService()`でサービスを設定していますが、OAuth2.0の場合は２`userService()`となるので使い分けが必要です。
ScopeにopenidあってGoogleの場合は勝手にOpenID Connectに切り替えてくれます。

後者はきちんとpropertiesに書けば大丈夫という話をよく見かけたのですが、上手く動いてくれなかったので記載しました。
Google側のOpenID設定についてはここでは割愛してますので、別の記事を参照ください。
なお、ここでの設定はSession情報をどこに確保しておくかという設定です。
特に何もなければメモリで問題ないと思います。

``` kotlin
package com.tasogarei.databasetest.config

import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter
import org.springframework.security.config.oauth2.client.CommonOAuth2Provider
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository


@EnableWebSecurity
@ConfigurationProperties(prefix="google")
class SecurityConfig : WebSecurityConfigurerAdapter() {

    lateinit var clientId: String

    lateinit var clientSecret : String

    lateinit var scope : String

    @Autowired
    lateinit var openIdUserServiceJdbcService : OpenIdUserServiceJdbcService

    override fun configure(http: HttpSecurity) {
        http.authorizeRequests().anyRequest().authenticated().and().oauth2Login().userInfoEndpoint()
                .oidcUserService(openIdUserServiceJdbcService)
    }

    @Bean
    fun clientRegistrationRepository() : ClientRegistrationRepository {
        return InMemoryClientRegistrationRepository(CommonOAuth2Provider.GOOGLE.getBuilder("google")
                .clientId(clientId)
                .clientSecret(clientSecret)
                .build())
    }

}
```

# 最後に
Spring-Securityを使ってOAuthを使用する場合は、ユーザー情報本体をデータベースとやり取りするのは余り良いプラクティスであるとは言えないのがやってみた感想です。
ユーザー情報本体は持たずにユニーク識別子でRelationさせてサービスとして必要な情報を持つようにする方が良さそうです。

ただ、そうなるとRoleの概念についてはどうするんだってことになりますが、現状だとRoleの概念の持たせるなら`ROLE_USER`固定を許容するかこの記事のように少し無理矢理な感じでの拡張をすることになると思っています。