oauth2 clientを実装する機会って結構ありますよね。
私の所属会社でもSSOのフェデレーションプロトコルにoidcを採用しているため、割と最近spring securityのoauth2 clientを見ながらクライアント機能の実装を行いました。
しかし、日本語情報をあまり見かけず苦労したので知見をまとめて残しておきます。
前提知識
- Authorization Code Grant Flowについて理解していること
この記事のスコープ
書くこと
- spring securityを利用したoauth2 clientの実装サンプルとその解説
- kotlinの言語仕様について一部解説する
- kotlinで実装したが、利用FWがSpring BootのためJavaユーザからも読まれることを想定しているため。
書かないこと
- oauth2やopenid connectに関する解説
- 認証フローの種類や各種エンドポイントの詳しい説明などはしない
技術スタック
- kotlin 1.3.61
- spring boot 2.2.4.RELEASE
- spring security 5.2.1.RELEASE
- gradle 6.0.1
- 採用ライブラリの全体像はbuild.gradleのここをみてください
実装機能
- 複数のIdentity Providerを利用したログイン
- configuration endpointを利用した設定
- userinfoエンドポイント等、各エンドポイントの個別設定
- ログイン時にユーザ情報永続化(の参照実装)
- Bearer tokenとrefresh token flowに対応したRestTemplateのラッパークラス
サンプルコード
場所
実装機能の解説
1. 複数のIdentity Providerを利用したログイン
結論から述べると、application.ymlの設定を行うだけです。
設定サンプル
まず最初に、設定の全体と各設定項目の説明コメントを記載します。1
spring:
security:
oauth2:
client:
registration:
google: # registrationId
# providerは、spring.security.oauth2.client.provider.{id}をregistrationIdと違う名前を付けたい場合に定義する。
# 今回はログインに利用したプロバイダの永続化時に使用する文字列にも採用している。
provider: google
client-id: replace-with-your-client-id
client-secret: replace-with-your-client-secret
client-authentication-method: post # 通常はpostを指定する。Financial-grade APIだとpostとbasicは使用してはいけないらしい。
authorization-grant-type: authorization_code # https://docs.spring.io/spring-security/site/docs/current/reference/html/oauth2.html#oauth2Client-client-registration
redirect-uri: http://localhost:8080/login/oauth2/code/google # googleの場合、指定しなくてもこれと同じものが自動生成される
scope:
- openid
- profile
- email
client-name: google # 任意設定項目。自動生成されるログインページのリンク名に使われる。ログインページをカスタマイズするか無効にするのであれば、設定しても意味がない(と思う)。
github:
provider: github
client-id: replace-with-your-client-id
client-secret: replace-with-your-client-secret
client-authentication-method: post
authorization-grant-type: authorization_code
redirect-uri: http://localhost:8080/login/oauth2/code/github # githubの場合、指定しなくてもこれと同じものが自動生成される
scope: # githubはopenid connectをサポートしてないのでscopeにopenidを指定できない
- read:user
client-name: github
facebook:
provider: facebook
client-id: replace-with-your-client-id
client-secret: replace-with-your-client-secret
client-authentication-method: post
authorization-grant-type: authorization_code
redirect-uri: http://localhost:8080/login/oauth2/code/facebook # facebookの場合、指定しなくてもこれと同じものが自動生成される
scope: # facebookはopenid connectをサポートしてないのでscopeにopenidを指定できない
- email
client-name: facebook
provider:
google:
# oidcのdiscovery endpointやoauthのmetadata endpointが提供されている場合、
# issuer-uriを設定すると必要な情報が自動的に設定される。
issuer-uri: https://accounts.google.com
# discovery endpointにより、以下のコメントアウト部分の同じ内容が自動的に設定される
# authorization-uri: "https://accounts.google.com/o/oauth2/v2/auth
# token-uri: https://oauth2.googleapis.com/token
# user-info-uri: https://openidconnect.googleapis.com/v1/userinfo
# user-info-authentication-method: post
# refresh tokenを発行させるためにaccess_typeクエリも指定している。access_typeはgoogleの独自仕様だと思われる。
# https://developers.google.com/identity/protocols/OAuth2WebServer#offline
authorization-uri: "https://accounts.google.com/o/oauth2/v2/auth?access_type=offline"
jwt-set-uri: https://www.googleapis.com/oauth2/v3/certs
user-name-attribute: name #ユーザ名をどの属性から取得するかを指定する
github:
authorization-uri: https://github.com/login/oauth/authorize
token-uri: https://github.com/login/oauth/access_token
user-info-uri: https://api.github.com/user
user-info-authentication-method: post
user-name-attribute: name
# githubはmetadata endpointを提供してない(っぽい)ので、issuer-uriは使えない
facebook:
authorization-uri: https://www.facebook.com/v6.0/dialog/oauth
token-uri: https://graph.facebook.com/v6.0/oauth/access_token
user-info-uri: https://graph.facebook.com/me?fields=id,name,email
user-info-authentication-method: post
user-name-attribute: name
# facebookはmetadata endpointを提供してない(っぽい)ので、issuer-uriは使えない
spring.security.oauth2.client.registration
とspring.security.oauth2.client.provider
を設定すればあとはよしなにspring bootが設定してくれます。
設定のみで利用可能となるのはとても便利なのですが、各設定項目の意味をわからずに動かすのは思わぬトラブルを引き起こすかもしれません。
そのため、各設定項目について、簡単に調べてみました。
各設定項目の説明2
設定 | 説明 |
---|---|
spring.security.oauth2.client.registration.[registrationId] | 設定を一意に特定するためのID。次項のproviderを設定していない場合、providerIdとしても使われる。 |
spring.security.oauth2.client.registration.[registrationId].provider | providerIdとしても使われる。未設定の場合、registrionIdがproviderIdとして採用される。 |
spring.security.oauth2.client.registration.[registrationId].client-id | クライアントID |
spring.security.oauth2.client.registration.[registrationId].client-secret | クライアントシークレット |
spring.security.oauth2.client.registration.[registrationId].client-authentication-method | クライアント認証の方法を指定する。一般的にはpostが使われる。FAPIの場合はpostとbasicは使わないらしい |
spring.security.oauth2.client.registration.[registrationId].authorization-grant-type | 認可の種類。spring securityではauthorization_code, client_credentials, password,implicitがサポートされている。今回は認可コードフローを例にとるため、authorization_codeを指定する。 |
spring.security.oauth2.client.registration.[registrationId].redirect-uri | 認証後にリダイレクトされる先のURL |
spring.security.oauth2.client.registration.[registrationId].scope | スコープ |
spring.security.oauth2.client.registration.[registrationId].client-name | sprng securityが自動生成するログインページのリンクに使われる。ログインページをカスタマイズするか無効にするのであれば、設定しても意味がない(と思う)。任意設定項目。 |
spring.security.oauth2.client.provider.[providerId].authorization-uri | 認可エンドポイント |
spring.security.oauth2.client.provider.[providerId].issuer-uri | Identity ProviderのURLを設定すると自動的にディスカバリーエンドポイントを検出して、認可エンドポイントなどの各エンドポイントを自動設定してくれる。 https://openid.net/specs/openid-connect-discovery-1_0.html |
spring.security.oauth2.client.provider.[providerId].token-uri | トークンエンドポイント |
spring.security.oauth2.client.provider.[providerId].jwk-set-uri | JWK Setエンドポイント。https://tools.ietf.org/html/rfc7517 ID Tokenの検証に必要となる暗号化方式や公開鍵を入手できるエンドポイント。 |
spring.security.oauth2.client.provider.[providerId].user-info-uri | ユーザ情報エンドポイント |
spring.security.oauth2.client.provider.[providerId].user-info-authentication-method | form,header,queryが選択できるが、FWの中を覗いて見た感じqueryは動作しなさそう。 デフォルトはheader。 |
spring.security.oauth2.client.provider.[providerId].userNameAttribute | ユーザ名をユーザ情報どの属性から取得するかを指定する |
なお、公式はSpring Boot 2.x Property MappingsとClientRegistrationの項が参考になるかと思います。
discovery endpointについて補足説明
OpenID Connectにはdiscovery endpointという仕様があります。
このエンドポイントを設定しておくと、Spring SecurityがIdPを利用するのに必要なエンドポイントの設定を検出し、自動で設定してくれます。
discovery endpointと同様な役割のエンドポイントがOAuth2用にも存在する3ようです。
2. ログイン時にユーザ情報永続化(の参照実装4)
IdPのIDをそのまま使わずに、自分たちが採番した一意キーを使いたいケースや、あるいはIdPから取得したユーザ情報を自分たちのDBにキャッシュしておきたいケースはあると思います。
そんなときのために、ユーザ情報の永続化について解説します。
概要
- アプリ用のユーザ情報(必要とする属性を集めたドメインオブジェクト)を定義する
- ユーザ情報用のリポジトリを定義する
- 保存したい属性を持たせるために、OidcUserとOAuth2Userを拡張する
- (3)を利用するようにOidcUserServiceとOAuth2UserServiceを拡張する
- Spring Securityを(4)を利用するように設定する
-
InteractiveAuthenticationSuccessEvent
を購読し、(2)を使ってユーザ情報を永続化する
解説
1. アプリ用のユーザ情報(必要とする属性を集めたドメインオブジェクト)を定義する
フレームワークやDBを使うためのコードとビジネスロジックが混ざることを防ぐために、今回はMyUserPrincipalという適当な名前をつけてそれを実現してみます。
アーキテクチャにはレイヤードアーキテクチャやオニオンアーキテクチャやクリーンアーキテクチャのいずれかを採用することが多いと思いますが、今回はオニオンアーキテクチャ風にしました。
コードはただのdata classですが、一応紹介しておきます。
package sample.domain.model.user
import java.util.UUID
// これ自体はごくごく普通のdata class
data class MyUserPrincipal(
val userId: UUID, // アプリで新規に採番した、自分たちで管理する一意キーが入る
val provider: String, // IdPの名前が入る。本番コードではたぶんenumにする。
val providerId: String, // IpPの一意キー(subject)が入る
val name: String,
val email: String
)
2. ユーザ情報用のリポジトリを定義する
今回は参照実装のため、InMemoryな実装としています。実際にはinterfaceを定義しますし、多くの場合にはDBが永続化先になると想定しています。
package sample.domain.model.user
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
// DIPを前提としたアーキテクチャ(クリーンアーキテクチャなど)を採用する場合は
// interfaceにするところだがサンプルアプリなのでInMemoryな実装で良しとする
@Component // DBを使う実装の場合は@Repositoryにする
class InMemoryMyUserPrincipalRepository {
companion object {
private val logger = LoggerFactory.getLogger(InMemoryMyUserPrincipalRepository::class.java)
private val inmemory = ConcurrentHashMap<UUID, MyUserPrincipal>()
}
fun resolveBy(providerId: String): MyUserPrincipal? {
val user = inmemory.values.find { it.providerId == providerId }
logger.info("{} is fetched by providerId({})", user, providerId)
return user
}
fun save(user: MyUserPrincipal) {
logger.info("{} is saved", user)
inmemory[user.userId] = user
}
}
3. 保存したい属性を持たせるために、OidcUserとOAuth2Userを拡張する
認可後にIdPからユーザ情報を取得することになりますが、アプリが独自に定義するユーザ情報とは属性に過不足があるはずです。
OidcUserとOAuth2Userを拡張してその過不足を解消(属性の詰め直し+α)する処理を盛り込みます。
なお、OidcUserはOAuth2Userを再利用するため、OAuth2User -> OidcUserの順に説明します。
OAuth2Userの拡張
package sample.adapter.infrastructure.spring.oauth2
import org.springframework.security.oauth2.core.user.OAuth2User
import sample.domain.model.user.MyUserPrincipal
import java.io.Serializable
import java.util.UUID
// 次で紹介するOidcUserの拡張クラスに継承させるため、openにしておく。kotlinはデフォルトでfinalのため、継承可能とするにはopenが必要。
open class CustomOAuth2User(
private val userId: UUID, // 自分たちのアプリの一意キー
private val provider: String, // 連携したIdPの名前
private val providerId: String, // 連携したIdPの一意キー
private val oAuth2User: OAuth2User // デフォルト実装のインスタンス保持用プロパティ
) : OAuth2User by oAuth2User /* 元の振る舞いを変更する必要がないので、Delegationパターンを適用する。kotlinはby説でネイティブにサポートしている */,
Serializable /* セッションをredisにキャッシュするのでSerializableが必要 */ {
companion object {
private const val serialVersionUID = -123L // セッションをredisにキャッシュするのでserialVersionUIDが必要
}
// FWに依存させないために、アプリ専用のユーザ情報へ変換するためのファクトリメソッドを用意する
fun toMyUserPrincipal(): MyUserPrincipal = MyUserPrincipal(
this.userId,
this.provider,
this.providerId,
oAuth2User.name,
oAuth2User.attributes["email"] as String // IdPによってはattribute名が違うかもしれない
)
// OAuth2User#toStringの出力内容が不要という場合は、こんな感じで独自実装する必要がある。
override fun toString(): String {
return "CustomOAuth2User(userId=$userId, provider='$provider', providerId='$providerId', oAuth2User=$oAuth2User)"
}
}
OidcUserの拡張
package sample.adapter.infrastructure.spring.oauth2
import org.springframework.security.oauth2.core.oidc.user.OidcUser
import java.io.Serializable
import java.util.UUID
class CustomOidcUser(
userId: UUID,
provider: String,
providerId: String,
oidcUser: OidcUser
) : CustomOAuth2User(userId, provider, providerId, oidcUser),
OidcUser by oidcUser /* 元の振る舞いを変更する必要がないので、Delegationパターンを適用する。*/,
Serializable {
companion object {
private const val serialVersionUID = -128L // セッションをredisにキャッシュするのでserialVersionUIDが必要
}
}
4. (3)を利用するようにOidcUserServiceとOAuth2UserServiceを拡張する
OAuth2UserService
package sample.adapter.infrastructure.spring.oauth2
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest
import org.springframework.security.oauth2.core.OAuth2AuthenticationException
import org.springframework.security.oauth2.core.user.OAuth2User
import sample.domain.model.user.InMemoryMyUserPrincipalRepository
import java.util.UUID
class CustomOAuth2UserService(private val repository: InMemoryMyUserPrincipalRepository) : DefaultOAuth2UserService() {
@Throws(OAuth2AuthenticationException::class)
override fun loadUser(userRequest: OAuth2UserRequest): OAuth2User {
val oauth2User = super.loadUser(userRequest) // デフォルト実装を利用してOAuth2Userを取得
// 各IdPのsubjectを文字列として取得する
val subject = when (userRequest.clientRegistration.clientName) {
"github" -> oauth2User.attributes["id"].toString()
"facebook" -> oauth2User.attributes["id"].toString()
else -> throw IllegalArgumentException("there is no such identity provider. cannot login by ${userRequest.clientRegistration.clientName}")
}
// IdPの一意キーでユーザを検索する
val existing = repository.resolveBy(subject)
// 存在すればそのID、存在しなければ新規でIDを採番する
val userId = existing?.userId ?: UUID.randomUUID()
// 認証したユーザの情報を返す。このインスタンスがセッションに保存される。
// 新規ユーザの場合、この時点ではDBには未登録。この後発行されるInteractiveAuthenticationSuccessEventのサブスクライバーが永続化する。
return CustomOAuth2User(userId, userRequest.clientRegistration.clientName, subject, oauth2User)
}
}
OidcUserService
やってることはほぼOAuth2UserServiceの拡張クラスと一緒です。
package sample.adapter.infrastructure.spring.oauth2
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService
import org.springframework.security.oauth2.core.OAuth2AuthenticationException
import org.springframework.security.oauth2.core.oidc.user.OidcUser
import sample.domain.model.user.InMemoryMyUserPrincipalRepository
import java.util.UUID
class CustomOidcUserService(private val repository: InMemoryMyUserPrincipalRepository) : OidcUserService() {
@Throws(OAuth2AuthenticationException::class)
override fun loadUser(userRequest: OidcUserRequest): OidcUser {
// デフォルト実装を利用してOAuth2Userを取得
val oidcUser = super.loadUser(userRequest)
// IdPの一意キーでユーザを検索する
val existing = repository.resolveBy(oidcUser.subject)
// 存在すればそのID、存在しなければ新規でIDを採番する
val userId = existing?.userId ?: UUID.randomUUID()
// 認証したユーザの情報を返す。このインスタンスがセッションに保存される。
// 新規ユーザの場合、この時点ではDBには未登録。この後発行されるInteractiveAuthenticationSuccessEventのサブスクライバーが永続化する。
return CustomOidcUser(userId, userRequest.clientRegistration.clientName, oidcUser.subject, oidcUser)
}
}
5. Spring Securityを(4)を利用するように設定する
oauth2を有効にして、UserServiceを設定するだけです。
package sample.adapter.infrastructure.spring
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService
import org.springframework.security.oauth2.core.user.OAuth2User
import sample.adapter.infrastructure.spring.oauth2.CustomOAuth2UserService
import sample.adapter.infrastructure.spring.oauth2.CustomOidcUserService
import sample.domain.model.user.InMemoryMyUserPrincipalRepository
@Configuration
class SampleWebConfiguration : WebSecurityConfigurerAdapter() {
override fun configure(http: HttpSecurity) {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.oauth2Login() // oauth2を有効にする
.userInfoEndpoint()
.oidcUserService(oidcUserService()) // openid connect用のOidcUserServiceを設定する
.userService(oAuth2UserService()) // oauth2用のOAUth2UserServiceを設定する
}
@Bean
fun oidcUserService(): OidcUserService = CustomOidcUserService(repository())
@Bean
fun oAuth2UserService(): OAuth2UserService<OAuth2UserRequest, OAuth2User> = CustomOAuth2UserService(repository())
@Bean
fun repository(): InMemoryMyUserPrincipalRepository = InMemoryMyUserPrincipalRepository()
}
6. InteractiveAuthenticationSuccessEvent
を購読し、(2)を使ってユーザ情報を永続化する
リポジトリをEventListner(InteractiveAuthenticationSuccessEventのサブスクライバ)から直接呼び出していますが、サボっているだけです。
ユーザ情報永続化のユースケースをアプリケーション層で表現してその中でリポジトリを呼ぶ実装がより適切だと思うので、参考にされる際には読み替えてください。
なお、後述するresttemplateのラッパーを利用する場合、このタイミングだとaccess tokenがセッションに永続化されているので、ユーザ情報エンドポイントから取れない情報を追加でラッパーを使って別のエンドポイントに取得しにいく実装がしやすいです。
package sample.adapter.eventlistener
import org.slf4j.LoggerFactory
import org.springframework.context.event.EventListener
import org.springframework.security.authentication.event.InteractiveAuthenticationSuccessEvent
import org.springframework.stereotype.Component
import sample.adapter.infrastructure.spring.oauth2.CustomOAuth2User
import sample.domain.model.user.InMemoryMyUserPrincipalRepository
@Component
class AuthenticationEventListener(private val repository: InMemoryMyUserPrincipalRepository) {
companion object {
val logger = LoggerFactory.getLogger(AuthenticationEventListener::class.java)
}
// InteractiveAuthenticationSuccessEventをlistenするのが周りくどいのであれば、
// OidcUserServiceやOAuth2UserServiceを拡張したクラスで永続化するやり方もある
@EventListener
fun listen(event: InteractiveAuthenticationSuccessEvent) {
logger.debug("event {}", event)
// 今回の実装ではかならずCustomOAuth2UserかCustomOidcUserのはずなのでCustomOAuth2Userにcastしても安全
val user = event.authentication.principal as CustomOAuth2User
// アプリ独自定義のユーザに変換して、それを永続化する
repository.save(user.toMyUserPrincipal())
}
}
なお、ユーザ情報永続化について、参照実装ではInteractiveAuthenticationSuccessEventを利用する設計としましたがOAuth2UserService#loadUser
の中で実現する方法もありえます。5
3. Bearer tokenとrefresh token flowに対応したRestTemplateのラッパークラス
ログイン時にユーザ情報を永続化して以降はリソースサーバにアクセスしないというケースばかりでは無いと思います。
あるいは、自前のDBに永続化しておいたユーザ情報を何らかのタイミングで最新化(IdPのものと同期)したいケースがあったりするかもしれません。
そんなときのために、リソースサーバの各種エンドポイントやユーザ情報エンドポイントを叩きやすいRestTemplateのラッパーを実装しておきたいと思います。
OAuth2AuthorizedClientのファサードを用意する
これから作成するクラスからOAuth2AuthorizedClientを利用するのですが、SecurityContextHolderとOAuth2AuthorizedClientRepositoryを組み合わせる必要があり、少々面倒くさいので使いやすくするためのファサードを用意しておきます。
package sample.adapter.infrastructure.spring.oauth2
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository
import javax.servlet.http.HttpServletRequest
// OAuth2AuthorizedClientをあちこちで利用するが呼び出すのがちょっと面倒なのでFacadeを用意しておく。
class OAuth2AuthorizedClientFacade(
private val httpSessionOAuth2AuthorizedClientRepository: OAuth2AuthorizedClientRepository,
private val request: HttpServletRequest
) {
val userInfoEndpoint: String
get() = authorizedClient().clientRegistration.providerDetails.userInfoEndpoint.uri
val accessToken: String
get() = authorizedClient().accessToken.tokenValue
val registrationId: String
get() = authorizedClient().clientRegistration.registrationId
fun authorizedClient(): OAuth2AuthorizedClient {
// 未ログインの状態で呼び出されることを考慮するなら、`as? OAuth2AuthenticationToken ?: throw XxxExceptin`として、未ログインを例外で通知する
val oAuth2AuthenticationToken = SecurityContextHolder.getContext().authentication as OAuth2AuthenticationToken
return httpSessionOAuth2AuthorizedClientRepository.loadAuthorizedClient(
oAuth2AuthenticationToken.authorizedClientRegistrationId,
oAuth2AuthenticationToken,
request
)
}
}
bearer tokenのヘッダー付与とリフレッシュトークンフローの自動実行を担う部分の実装
この節では、この部分の実装が一番大切になります。
クライアントコードからリソースサーバの呼び出しに必要なOAuthのお作法を気にせずに呼び出せるように、bearer tokenの定義やリフレッシュトークンフローの自動実行を実現するためのインターセプターを実装しました。
しかし、1点問題点がありました。
OAuth2AuthorizedClientService
を使えば呼び出しや永続化が可能というコンセプトで作られているようなのですが、現時点ではInMemoryな実装しか存在せず、冗長化構成では使い勝手が悪いものとなっていました。そのため今回はOAuth2AuthorizedClientRepository
を利用して実装しています。
実用性を取るためにこの決断をしましたが、フレームワークへの依存度が高くなってしまう欠点があります。今後、FWのバージョンアップ等によりそのままでは動作しなくなる(どころかコンパイルすら通らなくなる)可能性がありますので、その点にご留意ください。
package sample.adapter.infrastructure.spring.oauth2
import org.slf4j.LoggerFactory
import org.springframework.http.HttpRequest
import org.springframework.http.HttpStatus
import org.springframework.http.client.ClientHttpRequestExecution
import org.springframework.http.client.ClientHttpRequestInterceptor
import org.springframework.http.client.ClientHttpResponse
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.oauth2.client.OAuth2AuthorizationContext
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient
import org.springframework.security.oauth2.client.RefreshTokenOAuth2AuthorizedClientProvider
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken
import org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizedClientRepository
import javax.servlet.http.HttpSession
class OAuth2RestTemplateInterceptor(
// access tokenやOAuth2AuthorizedClientの取得処理はfacade経由で行う
private val oAuth2AuthorizedClientFacade: OAuth2AuthorizedClientFacade,
private val session: HttpSession
) : ClientHttpRequestInterceptor {
private val refreshTokenOAuth2AuthorizedClientProvider = RefreshTokenOAuth2AuthorizedClientProvider()
companion object {
private val log = LoggerFactory.getLogger(OAuth2RestTemplateInterceptor::class.java)
// HttpSessionOAuth2AuthorizedClientRepositoryの実装でこの値でセッションに保存しているのを見てハックした。
// FWの内部構造に依存している(つまりFWと密結合)となっており、本来は望ましくないが他に良い設計が思いつかなかった。
private val SESSION_ATTRIBUTE_NAME = "${HttpSessionOAuth2AuthorizedClientRepository::class.java.name}.AUTHORIZED_CLIENTS"
}
override fun intercept(request: HttpRequest, body: ByteArray, execution: ClientHttpRequestExecution): ClientHttpResponse {
// Identity Providerにはたまに"Bearer"を小文字(つまり"bearer")しか受け入れない実装が存在するので、そういうときはこう書く。
// request.headers.set(HttpHeaders.AUTHORIZATION, "bearer ${oAuth2AuthorizedClientFacade.accessToken}")
// 補足)
// RFCに準拠するならばcase sensitiveに実装すべきなため、送受信双方ともヘッダ名を"Authorization"で値のプレフィックスを"Bearer"としなければいけない。
// RFC 6750の https://tools.ietf.org/html/rfc6750#section-1.1 では、特に断りがない場合はプロトコルは原則case sensitiveと書かれているし、
// さらに https://tools.ietf.org/html/rfc6750#section-2.1 では、文中において、わざわざダブルクォーテーションをつけてcase sensitiveであることを強調しているし、例も示している。
// しかし、補足への補足になるが、RFCに完全に準拠するよりも「送信は厳格に、受信は寛大に」の原則に従って、
// 送信側は常に"Bearer"とし、入力側は大文字小文字を区別せずに"Bearer"でも"bearer"でも受け入れるのが望ましいと考え方もある。
request.headers.setBearerAuth(oAuth2AuthorizedClientFacade.accessToken)
val response = execution.execute(request, body)
// レスポンスステータスが401の場合、access tokenをrefreshする(refresh token flowを実行する)必要がある
if (response.statusCode == HttpStatus.UNAUTHORIZED) {
log.debug("identity provider returned unauthorized response. payload =>.", response.body)
log.debug("trying to execute refresh token flow")
val oAuth2AuthenticationToken = SecurityContextHolder.getContext().authentication as OAuth2AuthenticationToken
val context: OAuth2AuthorizationContext = OAuth2AuthorizationContext.withAuthorizedClient(oAuth2AuthorizedClientFacade.authorizedClient())
.principal(oAuth2AuthenticationToken)
.build()
// RefreshTokenOAuth2AuthorizedClientProvider#authorizeがrefresh token flowを実行してくれるので、交換後のaccess tokenとrefresh tokenを受け取る
val refreshed = refreshTokenOAuth2AuthorizedClientProvider.authorize(context)
if (refreshed == null) {
log.debug("don't have refresh token")
return response
}
log.debug("succeeded refreshing token flow.")
// 交換したaccess tokenとrefresh tokenをセッションに保存する
val authorizedClients = session.getAttribute(SESSION_ATTRIBUTE_NAME) as MutableMap<String, OAuth2AuthorizedClient>
authorizedClients[oAuth2AuthorizedClientFacade.registrationId] = refreshed
session.setAttribute(SESSION_ATTRIBUTE_NAME, authorizedClients)
log.debug("stored new access token and refresh token in session.")
// 新しいaccess tokenをヘッダーに詰め直して、再リクエストする
request.headers.setBearerAuth(refreshed.accessToken.tokenValue)
return execution.execute(request, body)
}
log.debug("refresh token flow is not executed for this time.")
return response
}
}
任意のヘッダーを付与する
たまにbearer token以外にも別のヘッダーを要求してくるIdPが存在します。
そういったケースのために任意のヘッダーを付与するごく簡単な例も記載しておきます。
そのヘッダーを付与するClientHttpRequestInterceptorを実装しましょう、というだけの話ではあります。
@Bean
fun additionalHeaderInterceptor(): ClientHttpRequestInterceptor =
ClientHttpRequestInterceptor { request, body, execution ->
request.headers.set("additional-header-name", "additional-header-value") // e.g. x-xxx-client-id
execution.execute(request, body)
}
IdPに応じてユーザ情報エンドポイントを使い分けるRestTemplateのラッパーを実装する
利用しているIdPに対応したユーザ情報エンドポイントを呼び出すような実装のサンプルです。
これは、動作確認に必要なため実装しただけで今回の主題とは違います。つまり、おまけみたいなものですので、読み飛ばして頂いても良いかと思います。
package sample.adapter.infrastructure.spring.oauth2
import org.springframework.web.client.RestTemplate
// kotlinはデフォルトでfinalになるので、SpringのAOPを利用する必要がある(request scopeにしたり@Transactionalをつけたりする)場合は、openをつける必要がある。
// ただし、@Serviceや@Componentといったアノテーションを使ってDIコンテナに登録する場合は、
// org.jetbrains.kotlin.plugin.springが自動的にクラスとメソッドをopenに変えてくれるので不要だったりする。
open class OAuth2Client(
private val oAuth2AuthorizedClientFacade: OAuth2AuthorizedClientFacade,
private val oAuth2RestTemplate: RestTemplate
) {
open fun userInfo(): String {
return oAuth2RestTemplate.getForObject(oAuth2AuthorizedClientFacade.userInfoEndpoint, String::class.java)!!
}
}
今まで実装したクラスを設定する
まだ必要となるクラスを定義しただけで、実際に利用するインスタンスを組み立ててDIコンテナに登録していません。
最後のコードはそのためのJava Configurationになります。
package sample.adapter.infrastructure.spring.oauth2
import org.springframework.boot.web.client.RestTemplateBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.client.ClientHttpRequestInterceptor
import org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizedClientRepository
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository
import org.springframework.web.client.RestTemplate
import org.springframework.web.context.annotation.RequestScope
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpSession
@Configuration
class OAuthConfiguration(
private val builder: RestTemplateBuilder,
private val session: HttpSession,
private val request: HttpServletRequest
) {
@RequestScope // 本当はsession scopeにしたいが、injectionしているインスタンスにserializableじゃないものが含まれているのでできない。
@Bean
fun oAuth2Client(): OAuth2Client = OAuth2Client(oAuth2AuthorizedClientFacade(), oAuth2RestTemplate())
// resttemplateはメンテナンスモードだったりはする => https://docs.spring.io/spring/docs/current/spring-framework-reference/web.html#webmvc-resttemplate
// しかし、webclientを利用するにはwebfluxを依存に追加する必要があるが、 webclientのためだけに追加したくない。
// そのため、今回はOauth2Clientでresttemplateの呼び出しをラップしておく。
// ラップしておくことで置き換えるコストを小さくできるはず。
@RequestScope
@Bean
fun oAuth2RestTemplate(): RestTemplate =
builder
.additionalInterceptors(oAuth2RestTemplateInterceptor())
.additionalInterceptors(additionalHeaderInterceptor()) // 特定のCloud Providerを利用している場合などに、ヘッダーの追加設定が必要な場合がある。
.build()
// thread safeな実装になっているので、シングルトンで問題ない(@RequestSession不要)。
@Bean
fun oAuth2RestTemplateInterceptor(): ClientHttpRequestInterceptor =
OAuth2RestTemplateInterceptor(oAuth2AuthorizedClientFacade(), session)
@Bean
fun additionalHeaderInterceptor(): ClientHttpRequestInterceptor =
ClientHttpRequestInterceptor { request, body, execution ->
request.headers.set("additional-header-name", "additional-header-value") // e.g. x-xxx-client-id
execution.execute(request, body)
}
@Bean
fun oAuth2AuthorizedClientFacade(): OAuth2AuthorizedClientFacade =
OAuth2AuthorizedClientFacade(httpSessionOAuth2AuthorizedClientRepository(), request)
@Bean
fun httpSessionOAuth2AuthorizedClientRepository(): OAuth2AuthorizedClientRepository =
HttpSessionOAuth2AuthorizedClientRepository()
}
最後に
Spring Security 5.2.1のOAuth2 Client機能実装サンプル紹介は以上になります。
どこかの誰かの役に立つことを願います。
-
サンプルコードの方ではclient-idとclient-secretを晒していますが、いずれもlocalhostしか許可していないので基本的には悪用不可です。ただし、client credentials Grant で利用できる機能がある場合は悪用されるリスクがあるそうなのでご注意ください。(@ritou 様ご助言ありがとうございました) ↩
-
公式ドキュメントとある程度重複する内容になります。 ↩
-
どのIdentity Providerが提供しているかまでは把握していません。2020年3月17日時点では、GithubとFacebookは提供していなさそうでした。 ↩
-
プロダクションコードとして耐えられるレベルで書いたつもりではありますが、あくまで参照実装です。 ↩
-
具体的には、DBの自動採番(オートインクリメント)を利用する場合は、そのほうが実装しやすいかもしれません(私自身はDBの自動採番を利用するとコードが不自然になりがちなので、なるべくなら利用したくないと考えています)。OAuth2UserServiceの責務ではない処理なので永続化までやらせるのは望ましくないという考え方もありますが、アーキテクチャと(自動採番のような)DBの都合の折り合いがつかない場合の丁度良い妥協点になりえると思います。あるいは、今回の例のようにInteractiveAuthenticationSuccessEventを利用する設計とした場合、
Event
というパラダイムを知らない開発メンバーしか周りにいない場合は設計者が抜けた後の保守に苦労することが予想されるため、やはりOAuth2UserService内で実現したほうが良いかもしれません。 ↩