Hydra と Spring Security を連携してOAuth 2.0 のアクセストークンによる認可をする
Hydra と Spring Security を連携
Spring Security でログインを実装したアプリケーション(ユーザーが ID とパスワードを入力して、認証すると各機能が使える)と Hydra を連携して、ユーザーのアクセストークンを発行し、アクセストークンを利用して Spring で実装した API を呼びだす、をやります。
シーケンスは以下となります。
実装
コード全体はこちらを参照ください。
Spring 側
認可リクエスト〜ログイン
username と パスワードで認証可能なアプリケーションを設定します。
@EnableWebSecurity
@Configuration
class WebSecurityConfig(private val restTemplate: RestTemplate) : WebSecurityConfigurerAdapter() {
@Throws(Exception::class) // @formatter:off
override fun configure(http: HttpSecurity) {
http.authorizeRequests { requests ->
requests
.antMatchers(HttpMethod.GET, "/")
.authenticated()
.antMatchers("/consent")
.authenticated()
.antMatchers(HttpMethod.GET, "/login")
.authenticated()
}.formLogin()
.successHandler(HydraAuthenticationSuccessHandler(restTemplate))
.loginPage("/login")
.permitAll()
}
override fun configure(auth: AuthenticationManagerBuilder) {
auth.inMemoryAuthentication()
.withUser("user1").password("{noop}password").roles("USER")
.and()
.withUser("user2").password("{noop}password").roles("USER")
}
}
Hydra からリダイレクトされたリクエストに対してユーザーがログインを完了したら、再度 Hydra にリダイレクトする必要がありますが、今回は SavedRequestAwareAuthenticationSuccessHandler を拡張する形で実装してみます。
/**
* AuthenticationSuccessHandler for redirect to Hydra
*/
class HydraAuthenticationSuccessHandler(private val restTemplate: RestTemplate) : SavedRequestAwareAuthenticationSuccessHandler() {
private val requestCache: RequestCache = HttpSessionRequestCache()
override fun onAuthenticationSuccess(request: HttpServletRequest, response: HttpServletResponse, authentication: Authentication) {
val savedRequest = this.requestCache.getRequest(request, response)
logger.info("savedRequest:${savedRequest}")
if (savedRequest != null) {
if (savedRequest.parameterMap["login_challenge"] != null) {
// Hydra にログイン成功を通知
val builder = UriComponentsBuilder.fromHttpUrl("http://localhost:9001/oauth2/auth/requests/login/accept")
.queryParam("login_challenge", savedRequest.parameterMap["login_challenge"]!![0])
val req = RequestEntity.put(builder.toUriString())
.body(HydraAcceptLoginRequest(authentication.name))
logger.info(req.toString())
var res = restTemplate.exchange(req, HydraAcceptLoginResponse::class.java)
logger.info("Hydra response: ${res.body.toString()}")
this.requestCache.removeRequest(request, response)
// Hydra にリダイレクト
redirectStrategy.sendRedirect(request, response, res.body?.redirectTo)
return
}
}
super.onAuthenticationSuccess(request, response, authentication)
}
}
Hydra に認可リクエストを送るとログイン画面にリダイレクトされるのですが、この際 login_challenge
というパラメーターが付与されます。
本実装では、login_challenge
が付与されたリクエストを受け取った場合、ログイン完了後に Hydra の API をコールし、Hydra から取得したリダイレクト URI にリダイレクトしています。
同意の取得
同意用のコントローラーを定義します。
@Controller
class ConsentController(private val restTemplate: RestTemplate, private val session: HttpSession) {
private val logger = org.apache.commons.logging.LogFactory.getLog(this.javaClass)
@GetMapping("consent")
fun consentForm(@RequestParam("consent_challenge") challenge: String, model: Model): String {
// https://www.ory.sh/hydra/docs/reference/api#operation/getConsentRequest
val builder = UriComponentsBuilder.fromHttpUrl("http://localhost:9001/oauth2/auth/requests/consent")
.queryParam("consent_challenge", challenge)
var res = restTemplate.getForEntity(builder.toUriString(), HydraConsentRequestInformationResponse::class.java)
logger.info("Hydra response: ${res.body}")
model.addAttribute("requestedScope", res.body?.requestedScope)
model.addAttribute("clientId", res.body?.client?.clientId)
session.setAttribute("consent_challenge", challenge)
session.setAttribute("requested_scope", res.body?.requestedScope)
return "consent"
}
@PostMapping("consent")
fun consent(): String {
var challenge = session.getAttribute("consent_challenge")
session.removeAttribute("consent_challenge")
var requestedScope = session.getAttribute("requested_scope")
session.removeAttribute("requested_scope")
val builder = UriComponentsBuilder.fromHttpUrl("http://localhost:9001/oauth2/auth/requests/consent/accept")
.queryParam("consent_challenge", challenge)
val reqBody = HydraAcceptConsentRequest()
reqBody.grantScope = requestedScope as List<String>?
val req = RequestEntity.put(builder.toUriString())
.body(reqBody)
var res = restTemplate.exchange(req, HydraAcceptConsentResponse::class.java)
return "redirect:${res.body?.redirectTo}"
}
}
Hydra から同意取得のためにリダイレクトしてくるリクエストには、consent_challenge
が含まれています。
この値を再度 Hydra にわたすことで、クライアントやクライアントが同意を得ようとしているスコープなどの情報を取得できます。
これらを使って同意取得画面を描画します。ちなみにログインのタイミングでも同じようなことができますが、今回はやっていません。
ユーザーが同意したら、同意画面描画時にセッションに入れておいた consent_challenge
やスコープの値を Hydra に渡します。
ログインのときのように Hydra へのリダイレクト URI が返ってくるので、ユーザーをその URI にリダイレクトさせます。
結果、Hydra は認可リクエストで指定したリダイレクト URI に認可レスポンスを返します。
リソースサーバーの設定
Introspection したいので、NimbusOpaqueTokenIntrospector の Bean を登録します。
@Bean
fun introspector(): OpaqueTokenIntrospector {
return NimbusOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret)
}
WebSecurityConfig にリソースサーバーの設定を追加します。
api
スコープで /api/
以下の API を呼び出せるようにしておきます。
http.authorizeRequests { requests ->
requests
.antMatchers("/api/**").hasAnyAuthority("SCOPE_api")
}.oauth2ResourceServer { oauth2 -> oauth2.opaqueToken() }
適当に API を実装します。
@RestController
class ApiController {
@GetMapping("/api")
fun sub(authentication: BearerTokenAuthentication): String {
return authentication.tokenAttributes["sub"].toString()
}
}
Hydra の設定
CLI のインストール
今回はマイグレーションのために Hydra をインストールします。
brew tap ory/hydra
brew install ory/hydra/hydra
PostgreSQL
立てて hydra データベースを作っておきます。
docker run -p 5432:5432 --name hydra-postgres -e POSTGRES_PASSWORD=pass -d postgres
psql -h localhost -p 5432 -U postgres
create database hydra;
quit
以下のコマンドで Hydra 用に DB の準備を行います。
export DSN=postgres://postgres:pass@localhost:5432/hydra?sslmode=disable
hydra migrate sql --yes $DSN
Hydra の起動
Hydra を起動します。
docker network create hydra-for-spring
docker pull oryd/hydra:v1.10.5-pre.1
# Run Hydra
export DSN=postgres://postgres:pass@host.docker.internal:5432/hydra?sslmode=disable
docker run -d \
--name hydra-for-spring \
--network hydra-for-spring \
-p 9000:4444 \
-p 9001:4445 \
-e SECRETS_SYSTEM=hydra-secret-123456 \
-e DSN=$DSN \
-e URLS_SELF_ISSUER=http://localhost:9000/ \
-e URLS_CONSENT=http://localhost:8080/consent \
-e URLS_LOGIN=http://localhost:8080/login \
oryd/hydra:v1.10.5-pre.1 serve all --dangerous-force-http
docker logs hydra-for-spring
Hydra にクライアントを登録しておきます。
hydra clients create --id hydra-for-spring --secret secret --scope api --callbacks https://example.com/callback --endpoint=http://localhost:9001
動かしてみる
Spring 側のアプリケーションも起動して認可コードグラントを試してみます。
以下の URI にアクセスして認可リクエストをはじめます。
http://localhost:9000/oauth2/auth?client_id=hydra-for-spring&response_type=code&state=3cf22d86-fbde-11eb-9a03-0242ac130003&scope=api&redirect_uri=https://example.com/callback
Spring 側のログイン画面にリダイレクトします。
ログインします。
同意画面に遷移するので、同意します。
同意すると以下のような認可レスポンスが返ってきます。
https://example.com/callback?code=Z6kPXaFZerxjzFPf_LzHIp8pvotCTOgGOMtPzOn5ytk.HG7X884LANAZCl8mjIc3yxrb6nUUGn_dJ7A6Y8u9rO8&scope=api&state=3cf22d86-fbde-11eb-9a03-0242ac130003
Hydra にトークンリクエストをします。
curl -X POST -u "hydra-for-spring:secret" -d "grant_type=authorization_code" -d "code=Z6kPXaFZerxjzFPf_LzHIp8pvotCTOgGOMtPzOn5ytk.HG7X884LANAZCl8mjIc3yxrb6nUUGn_dJ7A6Y8u9rO8" -d "redirect_uri=https://example.com/callback" http://localhost:9000/oauth2/token
アクセストークンが返ってきます。
{"access_token":"LBE1ihNYOo1vQIYoAOFIuPROcU1n8U56LUGMCvrJNUM.z8R41KeRGkPmOxftVNPcaZPDWoz_GF4MmV01HvvrB5Q","expires_in":3600,"scope":"api","token_type":"bearer"}
Spring の API を呼んでみます。
curl -H GET 'http://localhost:8080/api' -H 'Content-Type:application/json;charset=utf-8' -H 'Authorization: Bearer LBE1ihNYOo1vQIYoAOFIuPROcU1n8U56LUGMCvrJNUM.z8R41KeRGkPmOxftVNPcaZPDWoz_GF4MmV01HvvrB5Q'
アクセストークン発行時にログインしたユーザーの username が返ってきました。
user1
ログインとか同意完了時に呼び出す Hydra の API の認証は?
この辺の API はインターネットに露出しちゃ駄目とのことです。
Hydra の API は public と admin に別れていて、この辺の API は admin に分類されます。
Introspection も Admin なので露出したい場合どうするか考える必要がありそうです。
ちなみに上記の手順で構築すると public が 9000、admin が 9001 のポートで起動します。
まとめ
- Hydra はログインや同意を行うアプリケーションと連携して OAuth 2.0 や OIDC のプロトコルを提供するための OSS です
- 本記事では Spring Security でログインを実装したアプリと Hydra を連携してみました
- 全然本番で動かせる構成じゃないので参考程度です
参考
- Hydra
- 5 Minute Tutorial | ORY Hydra
- Run ORY Hydra in Docker | ORY Hydra
- Database Setup and Configuration | ORY Hydra
- HTTP API Documentation | ORY Hydra
- Spring Security
- SavedRequestAwareAuthenticationSuccessHandler
- NimbusOpaqueTokenIntrospector
- Spring Security Reference | 12.3.15. How Opaque Token Authentication Works
- Spring Security 5.3.3で Resource Server を構成する
- OAuth 2.0 Resource Server With Spring Security 5 | Baeldung
Discussion