はじめに
1年前Spring x SAML認証にチャレンジしました。
ほとんどバックエンドの知識しかない中でやった結果、とてもとても苦労したものの何とか形に出来ました。
アウトプットあまり出来ていなかったので学んだことをアウトプットします。
内容的にはSAMLの実現方法ってよりSpringSecurityの解説みたいになっちゃうと思いますが、SpringSecurityよくわからんって人にはいい感じかもです。
Spring環境
Spring Boot 3.3.11
Spring Security 6.3.9
最終的なSecurityConfigのFilterChain設定
@Bean
@Order(2)
fun adminLoginFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.securityMatcher {
it.requestURI.equals("/api/v1/admin/login") ||
it.requestURI.startsWith("/saml2/authenticate/") ||
it.requestURI.equals("/login/saml2/sso")
}
.csrf {
it.disable()
}
.cors {
it.configurationSource(corsConfigurationSource())
}
.authorizeHttpRequests {
it.requestMatchers("/login/saml2/sso", "/saml2/authenticate/**").permitAll()
it.requestMatchers("/api/v1/admin/login").authenticated()
}
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) }
.saml2Login { it ->
it.relyingPartyRegistrationRepository(relyingPartyRegistrationRepository())
.successHandler { request, response, authentication ->
中略
}
.failureHandler { request, _, exception ->
中略
}
}
.addFilterBefore(SetRedirectFilter(), Saml2WebSsoAuthenticationRequestFilter::class.java)
return http.build()
}
@Bean
@Order(3)
fun adminSecurityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.securityMatcher {
it.requestURI.startsWith("/api/v1/admin/") &&
!it.requestURI.equals("/api/v1/admin/login")
}
.authorizeHttpRequests {
it.anyRequest().authenticated()
}
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.csrf { it.disable() }
.addFilter(tokenValidationFilter)
中略
return http.build()
}
@Bean
@Order(4)
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.authorizeHttpRequests { it.anyRequest().permitAll() }
.sessionManagement { session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.csrf { it.disable() }
.httpBasic { it.disable() }
.anonymous { it.disable() }
return http.build()
}
何やってるか
SpringSecurityでは、
- securityMatcherでマッチするパスに対して
- authorizeHttpRequests内で指定したパスに認証かけるかかけないか
- どのような認証(filter)をかけるか
でエンドポイントを保護する事が出来ます。
SAML拡張
SAML認証そのものについては省きますが、SpringSecurityのSAML拡張がどのように進行するかだけ簡単に書くと
- SAMLフィルターを通したいリクエストを受け取る
- リクエストをセッションに保存して、SAMLリクエストを作成する機能を持つエンドポイントにリダイレクトする(/saml2/authenticate/**)
- SAMLリクエストを作成して、IdPにリダイレクト
- IdPがリクエストを処理して、IdP側で設定したエンドポイントにリダイレクトする(/login/saml2/sso)
- 2で保存したリクエストを取り出し、SAMLが完了した情報つきで2のリクエストを再送する
ざっくりこんな感じで進行します。
困ったこと
1. SAML拡張が作るエンドポイントが許可されない
一番最初は動作確認したかったので、とりあえず全部保護すればいいじゃないということで
http
.authorizeHttpRequests {
it.anyRequest().authenticated()
}
.saml2Login { it ->
...
こんな感じで全リクエスト捌くようにしてました。すると、対象APIにリクエストした際にリダイレクトループが発生しました。
何じゃこりゃって思って頑張って追跡したところ、/saml2/authenticate/に初手で飛んじゃうけど、このエンドポイントが許可されていないからじゃないか?という予測が立ちました。
http
.authorizeHttpRequests {
it.anyRequest().authenticated() &&
it.requestMatchers("/saml2/authenticate/**").permitAll()
}
.saml2Login { it ->
...
正解でした!!この変更でGWSのログイン画面に辿り着けました!!
よっしゃログイン!!→リダイレクトループです。
この流れだと何となくわかるかもですが、着地後の/login/saml2/ssoが保護されているからです。
/login/saml2/sso→未認証のためsamlフロー開始→/saml2/authenticate→IdPに到達→IdP目線では認証済みのためログインスキップ→/login/saml2/ssoのリダイレクトループになるわけです。
というわけで
http
.authorizeHttpRequests {
it.anyRequest().authenticated() &&
it.requestMatchers("/login/saml2/sso", "/saml2/authenticate/**").permitAll()
}
.saml2Login { it ->
...
はい、これでSAML認証でAPI保護出来ました!!
とは行きませんでした。
2. リダイレクトによる弊害
今回のプロジェクトでは、フロントエンドはNext.jsで実装されており、APIリクエストはクライアントではなくNext.jsのサーバーサイドから叩かれる形になっていました。
結果、リダイレクトがバックエンドサーバー起点ではなくNextサーバー起点になってしまい、SpringのSAML拡張が正しく機能せず認証フローが完了しなくなりました・
ローカルでの動作確認時は直接ブラウザからAPIエンドポイントを叩いていた為この現象に気づけませんでした。また、フロントがサーバーを持つという形にこれまで触れてこなかったため、意識の外でした。Next.jsを使うっていうのはわかっていたのに、何も考えてなかった証拠だと反省しています。。。
SAMLで保護する事は叶ったもののこのままではAPIが使えないということで、悩んだ結果SAMLログインに成功したらバックエンドがJWTを発行し、そのJWTでAPIを保護する方針としました。悔しい(泣
その結果が冒頭載せたSecurityConfigになります。
- フロント側でJWTを持っていない場合または期限切れの際、/api/v1/admin/loginにリクエストを送る
- SAML認証フローを通り、SAML拡張のsuccessHandlerでJWTを発行し渡す
- 改めてフロントからJWT付きのリクエストを受け取る
- /api/v1/admin/login以外の/api/v1/admin/**でJWTを検証できるように、JWTを検証するためのtokenValidationFilterを作成し、認証時にこのフィルターを通るようaddFilterでフィルター追加
※/api/v1/**というユーザ向けのAPIもありますが、こちらは別のフィルターで保護しています。他メンバーの成果物のためここでは割愛しますが、そちらではもっと綺麗に保護されています。
最後に
簡単にまとめていますが、他にも山程問題に直面し、2ヶ月以上この実装に向き合っていた記憶があります。
プロジェクトメンバーにはとんでもない迷惑をかけましたし、調査や周辺との関わりの大事さを学んだ良い経験になりました。チャレンジさせてくれたみんなに感謝でいっぱいです!