はじめに
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で大丈夫です。
(勝手に切り替えてくれる)
<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もこの形で確認可能となっています。
@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
だけ用意しています。
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の良い回答が良くわかってません。
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の書き換えは機能作るというのが面倒という理由で手で書き換えています。
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の場合は2userService()
となるので使い分けが必要です。
ScopeにopenidあってGoogleの場合は勝手にOpenID Connectに切り替えてくれます。
後者はきちんとpropertiesに書けば大丈夫という話をよく見かけたのですが、上手く動いてくれなかったので記載しました。
Google側のOpenID設定についてはここでは割愛してますので、別の記事を参照ください。
なお、ここでの設定はSession情報をどこに確保しておくかという設定です。
特に何もなければメモリで問題ないと思います。
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
固定を許容するかこの記事のように少し無理矢理な感じでの拡張をすることになると思っています。