SpringSecurityの実装で躓いたポイントを残しておく。実装については中途半端なコードを残しても仕方ないので、ここを見ればわかる、というようにドキュメントのリンクを記載するのみとする。
後半では、Jwtを使用した実装についても触れる。
まず、SpringSecurityはバージョンによって実装のしかたが大きく異なるので、ドキュメントを探す際には注意する。
そこまで古いバージョンを使用していることは稀かと思うが、特に5.3以前(オーバーライドで実装)と5.4以降(@Beanで実装)では書き方がかなり変わる。
実装基礎
ミニマムの要件は、『サイトにアクセスする際にはユーザーIDとパスワードの入力を求める』となるだろう。
初動は送信されたリクエストのパスの検証から始まる。imageなど、静的ディレクトリのパスにまで認証がかかっていては、サイトが動かなくなるためだ。
SpringSecurityでは例えば以下のようなフィルターで認証をかけることができる。
- 除外パスの指定:例)/image/ のパスを含む場合は認証から除外する
- USER、ADMINなどのロールによってかける認証を分岐する。パスを指定して、対応するロールを割り当てることもできる。
認証が必要と判断されたリクエストには、何かしらの認証がかけられることとなる。一番シンプルなのは、ログイン画面に誘導されIDとパスワードを要求するBasic認証となるだろう。
ドキュメント
SpringSecurityのドキュメントは非常に丁寧に書かれているので、読めばたいていの話は理解できる。
テーマ別に、ポイントとなるリンクを残しておく。
- ファイル名とファイルパスは?:[apiroot]/core/config/WebSecurityConfig.java とかで良いと思う。
- ログインフォームにどうやって飛ばす?
- パス別にアクセスできるロールを設定するには?
特定のURLのみで認証したい
認証はアプリ共通で使用することが一般的であるため、すべてのリクエストに対して認証をかけ、一部を認証の対象から除外するという実装が一般的。ドキュメントも、これを前提としたものがほとんど。
しかし、社内アプリケーションなどでは逆に、社内イントラネットの接続はすでに認証基盤があり、外部の接続に対してのみ特別な認証を実施する、というパターンがある。
その場合は、authorizeHttpRequests(このメソッドはすべてのリクエストに対して認証をかける)を使用せず、以下のように実装する。
参考: stack overflow
認証部分のロジックをデバッグしたい
ドキュメントではラムダ式で記載されているので、デバッグできないし、ログ出力処理を挟んだりすることができない。
これを実現するには、ラムダ式を分解してコーディングする必要がある。
■分解前
http.formLogin(form ->
form.loginPage("/login").permitAll()
);
■分解後
http.formLogin(form -> {
// ここに処理を書けるようになる
form.loginPage("/login").permitAll();
});
Jwtを使用したSpringSecurityでの認証
Jwtとは?
Jwtは、ヘッダーとペイロードに署名情報を付与したトークン、または、このトークンをリクエスト時にリソースサーバーに問い合わせることによって通信の安全性を担保する認証機能を指す。
ベストプラクティスは、CookieにJwtを仕込み、SameSite=StrictとHttpOnly属性をを設定することで、XSSとCSRF対策を実装する。
SameSite=Strictを指定できない場合は、別途CSRF対策が必要となる。
そもそもCookieを使用しない場合、AuthorizationヘッダーのBearerトークンとしてJwtアクセストークンを指定する方法があるが、これはCSRF対策にはなるが、XSSの対策にはならない。
Jwtの仕組み
XSSによる攻撃を受けた際に、改ざん検知をする仕組みである。
XSSでは、ユーザー端末でスクリプトが実行(…①)され、脆弱性のあるサイトに攻撃者の仕組んだリクエストが送信されることにより、そのサイトの情報(認証情報等)が第3者に取得され、使用される(…②)などするもの。
しかし、Jwtによりリクエスト送信者を一貫しているサイトであれば、①と②のリクエストでは送信元が異なるため、Jwtに保持されている情報と照合し改ざんを検知できる。
Jwtの構造
アクセストークンはドットで3つの値に区切られており、ヘッダー部.ペイロード部.署名部となっている。これらをそれぞれ取り出し、 base64でデコードすると中身を確認することができる。
Jwtの公式HPでは、Jwtをそのまま貼り付けるだけで、中身のデコードまで一手に確認できる。
Jwtの中身がエラーになると、例えば以下のようなエラーログが出力されるので、このようなJwtに関するエラーが出た時は、とりあえずデコードしてみるとエラーの糸口がつかみやすい。
[o.s.s.o.j.JwtClaimValidator.validate] The iss claim is not valid
[o.s.s.o.s.r.a.JwtAuthenticationProvider.getJwt] Failed to authenticate since the JWT was invalid
Jwt + SpringSecurityの実装
Jwtを使用したSpringSecurityでの認証フローは大きく以下のようになる。
- 認可サーバー(ForgerockやKeyCloakなど)にアクセストークンの発行をリクエストする
- アクセストークンが手に入ったら、リクエストヘッダーのBearerトークンとして設定する
- このヘッダーがあると、SpringSecuriyは、リクエストを送る際にアクセストークンの検証を実施する
- SpringSecurityは、認可サーバー(リソースサーバー)に公開鍵を要求する
- SpringSecurityは、公開鍵と、アクセストークンを検証し、正しい場合のみリクエストが送信できる
※認可サーバーはソースコード上ではissureと表現される。認可サーバーとリソースサーバーは、同一である場合と、エンドポイントが異なる場合がある。
パッケージのインポート
springbootを使用している場合と、springframeworkで実装する場合とで、importするライブラリが異なるので注意する。(ここでめっちゃハマった)
以下のパッケージをbuild.gradle上でimportする。
■springboootの場合
- org.springframework.boot:spring-boot-starter-oauth2-resource-server
■springframeworkの場合
- spring-security-oauth2-resource-server
- spring-security-oauth2-jose
認可サーバーとリソースサーバーの指定
application.env.ymlで指定する。参考:認可サーバーの指定
認可サーバー(issure)と、リソースサーバーが異なる場合は、異なるエンドポイントを指定する。参考:認可サーバー JWK セット Uri を直接指定する
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: 認可サーバーURL
jwk-set-uri: リソースサーバーURL
SpringSecurityでJwtを使用した認証を実装する
基本形 ※参考:デフォルトの JWT 設定
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
}
認証のカスタマイズ
アクセストークン検証でエラーだった場合の処理
エラー時はログ出力したり何かしたい場合、エントリーポイントを追加して、処理を付与することができる。
■WebSecurityConfig.java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// …省略
.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults())
.authenticationEntryPoint(customAuthenticationEntryPoint()); // エントリーポイントを追加
return http.build();
}
// エントリーポイントで行う処理を追加
public AuthenticationEntryPoint customAuthenticationEntryPoint() {
return (request, response, exception) -> {
// ログ出力処理など記述可能
LOGGER.error(
"unauthorized: {}, Message: {}, ", request.getRequestURI(), exception.getMessage());
response.setStatus(HttpServletResponse_UNAUTHORIZED);
}
}
プロキシを経由したい
認可サーバーに公開鍵を要求する際は、多くの場合、外部へのリクエストとなるため、プロキシの設定が必要となることが多い。
プロキシの設定を追加したい場合は、Jwtのデコーダーを設定してカスタマイズする。
■WebSecurityConfig.java
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// …省略
.oauth2ResourceServer((oauth2) -> oauth2.jwt(jwt -> {
jwt.decoder(this.jwtDedoder()); // デコーダーを追加
}).authenticationEntryPoint(customAuthenticationEntryPoint());
return http.build();
}
public JwtDecoder jwtDedoder() {
// Proxy設定(主題と外れるので詳細は割愛)............................................................
HttpClientRequestFactory requestFactory = new HttpClientRequestFactory();
Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(ip, Integer.valueOf(port)));
requestFactory.setProxy(proxy);
RestTemplate restTemplate = new RestTemplate(requestFactory);
// ............................................................
// 公開鍵を要求するリクエストは、NimbusJwtDecoderなので、これをカスタマイズする。
NimbusJwtDecoder jwtDedoder = NimbusJwtDecoder.witJwtSetUri(jwkSetUri).restOperations(restTemplate).build();
rerurn jwtDedoder;
}
Jwtデコーダーの設定方法はいろいろあるので、ドキュメントを参照。
■Proxyのエラー
プロキシでのエラーは、一般的に407レスポンスが返ってくる。これが返ってこない場合は、そもそもプロキシサーバーまでリクエストが届いていない可能性が高い。
Caused by: java.io.IOException: Unable to tunnel through proxy. Proxy returns "HTTP/1.1 407 Proxy Authentication Required"
Appendix
用語集
- 認可サーバー: アクセストークンを発行するサーバー
- リソースサーバー:アクセストークンを検証し、正しい場合はユーザーのデータを返す
- Oauth2:上記、 認可の要求と承認を標準化したもの。通常は認可サーバーがトークンを発行する際、 ユーザの承認が必要となるが、これを省略するための仕組み。
- JWT : JSON WEB TOKENの略称。 OAuth2のトークンの実質的な標準仕様
- JWK-Set: JSON Web key Set。 アクセストークンを開錠するための公開鍵のセット
- スコープ: 認証サーバーの受け入れるネームスペースみたいなもの。 アクセストークン作成の際のscopename と、 SpringSecurityで検証するSCOPE_scopenameがー致する
OAuth2の仕組み
OAuthはOpen Authorizationの略称で、オープンな (外部ネットワークとの) 通信を安全に行うための認証機能のこと。
■アクセストークンの取得
- 【クライアント> AP> 認可サーバー】 認証に必要なデータ (クライアントのクレデンシャルやスコープ)を認可サーバー(アクセストークンを発行する機関。ForgeRockなど)に送信する。
- 【認可サーバー】 データに対し、 署名用鍵 (秘密鍵のようなもの)を用いて署名を生成する。
- 【認可サーバー> AP>クライアント】 データと署名をセットにしてアクセストークンとし、クライアントに送り返す
■アクセストークンを使用してAPIリクエストを送信
- 【クライアント> AP】 アクセストークンをヘッダーにセットしてAPに送信
- 【AP】 SpringSecurityのFilterでフック
- 【AP】 AP内で指定している認可サーバーのJWK Set エンドポイントにアクセスし、JWK-Set(公開鍵のセット) を取得 SpringSecurityが勝手にやる
- 【AP】 ヘッダーのアクセストークンを取得したJWKで開錠> 認証が完了
コメント