Spring Boot 2(Spring Security 5.7.x)で認可の基本の流れを理解する

Excel
Excel

これまで2回に分けて、API でログイン及び認証チェックを行う方法を説明しました。
この記事は認可です。調べば調べるほどできることがたくさんあり、一度での把握は難しいため、この記事では認可の基本的な流れを紹介します。

  1. 認証の基本的な流れの理解とログインAPIの実装
  2. 認証後に JWT を生成/Authrization ヘッダに付与して、次回以降の API の認証チェックを実施

網羅的な説明は他の記事でたくさんあるので別記事に譲ります。この記事は網羅的な説明ではなくその前段で、そもそもの動きを理解した人に向け手助けになればと思います。

間違いあれば指摘いただけると嬉しいです。

前提

過去の続きです。過去のコードについては上記リンクを参照ください。バージョンは以下です。

  • Java 17
  • Spring 2.7.10

Spring Boot 2.7.10 に含まれる Spring Security Starter は 2023/3/25 時点で v2 系では Spring Security 5.7.7 です。5.8 から追加された機能もあるので、そのあたりは Spring Boot のバージョンを 3 にして別の機会に確認予定です。
今回の範囲においては 5.7.7 でも 5.8 以降でも同じ書き方になる(はずです)。

認証情報 / 認可のための情報

認可の処理の前に、認証情報(ロール情報なども含む)の参照方法です。

今回はもともと springframework が持っている User(org.springframework.security.core.userdetails.User)を継承した MyUserDetails を作りましたが、この User が持つプロパティ authorities に認可のための権限情報(GrantedAuthority を実装したクラスのインスタンスのコレクション: Collection<? extends GrantedAuthority>)が含まれています。

前回の記事では SimpleGrantedAuthority を利用しています。これは標準で用意されている GrantedAuthority の具体クラスの1つで、シンプルに識別文字列(ロール)を持つクラスです。他には LDAP 関連の情報ももつ LdapAuthority などがあります。

公式に書かれている通り、認証が成功した場合 認証情報は SecurityContextHolder に格納され、その後 SecurityContextPersistenceFilter のフィルタ処理によって SecurityContext を HttpSession に保存します。これにより、以下のコードでどこからでも値が取れるようになります。

SecurityContext.Holder.getContext().getAuthentication()

認可の方法

ここから具体的に認可の方法です。Spring Security は、メソッド呼び出しやAPIリクエスト実行時のインターセプターが提供されています。

リソース(URLベース)

URLベースのアクセス制御です(/admin/** は admin権限の場合のみと言ったもの)。
以前(Spring 徹底入門の書籍)に書かれているものは AccessDecisionManagerとAccessDecisionVoterでしたが、今は AuthorizationManager を利用するようです。

そして、この AuthorizationManager は AuthorizationFilter で呼び出されます。

AuthorizationFilter はどこで呼ばれるかというと、こちらも 公式 にありますが SecurityFilterChain を追加するときに、 authorizeRequests を使用する代わりに authorizeHttpRequests を使用すると、AuthorizationFilter が呼ばれるとのことです(下に例を書いてます)。

AuthorizationFilter が呼ばれると、SecurityContextHolder から認証情報を取得し、RequestMatcherDelegatingAuthorizationManager が実行されます。この中で authorizaHttpRequests で設定した内容に従ってチェックを行い、アクセス制御結果(OK / NG) を返します。

以下は、前回までの記事の http.quthorizaHttpRequests を2行変えています。/api/hello は hoge ロールを必要とし、それ以外は拒否します。

        http.authorizeHttpRequests(authz -> authz
                .antMatchers("/").permitAll()
                .antMatchers("/api/login").permitAll()
                .antMatchers("/api/hello").hasRole("hoge")
                .anyRequest().denyAll()
        );

RequestMatcherDelegatingAuthorizationManager の実行では具体的には check というメソッドが呼ばれます。以下のスクリーンショットを見てわかる通り、mappings の値が上で定義したものになっていることがわかります。これをぐるぐる回して、今回アクセスされたリソースが mappings にマッチするかをチェックをしています。

mappings にマッチするものがあった場合、さらに以下の check メソッド(クラスは同じ)がよばれます。ここではパターンが一致したものに対して authority のチェックを行います。

今回は hasRole(“hoge”) を指定したので、ROLE_hoge が authority にあればアクセスOKになります。が、今回のユーザは持っていないためNGになるはずです。

実際に 403 になりました。

HTTP/1.1 403 
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 0
Date: Sat, 25 Mar 2023 06:00:21 GMT
Connection: close

一応、hasRole(“ADMIN”) にするとOKでした。というのも確認済み。

HTTP/1.1 200 
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: text/plain;charset=UTF-8
Content-Length: 3
Date: Sat, 25 Mar 2023 06:39:08 GMT
Connection: close

foo

今回は hasRole だけですが、他にも色々なメソッドが用意されています。

主な種類意味
hasRole指定したロールを持っているか(ROLE_XXXX の XXXX 部分を指定する)
こちら によると、ROLE_ から始まってない場合は ROLE_ が補完される
hasAnyRole複数のロールが指定でき、いずれかを持っていればOK
hasAuthority指定した Authority を持っている場合
(ROLE_XXXX の場合、ROLE_XXXX を書かないといけない
hasAnyAuthority複数の Autorityが指定でき、いずれかを持っていればOK
permitAll常にOK
denyAll常にNG
autenticated認証されていればOK
accessカスタム処理を定義(booleanを返却)することができる ※

※ access は、5.7 の公式ドキュメントを見ると、String を引数にとりカスタム処理を含む式ベースの指定ができるようですが、実際に試そうとすると String の引数は許可されておらず試せませんでした。5.8以降であれば WebExpressionAuthorizationManager が提供されており、これを利用する旨の記述になっていました。
v6系でも同様なので、WebExpressionAuthorizationManager を使ったやり方が正しそうですが、5.7 ではこのクラスも提供されていないのでご注意を。

access を利用すると簡単にカスタム処理が使え、機能としては便利と思うので別途検証できればと思います。

URLベースでロールあるいはカスタム処理でアクセス制御が簡単にできますね。

メソッド

メソッドレベルでも認可チェックができます。@EnableMethodSecurity を指定することで機能を有効にでき、以下が用意されているようです。利用時はアノテーションをつけるだけでいけます。

アノテーション説明
@PreAuthorizeメソッド実行前にチェック。OKならメソッドが実行される。
実際の処理は PreAuthorizeAuthorizationManager が実行される模様。
@PostAuthorizeメソッド実行後にチェック。OKならメソッド実行結果が返却される。
実際の処理は PostAuthorizeAuthorizationManager が実行される模様。
@PreFilterメソッド実行前にチェック。
引数となるオブジェクトのリストのうち、指定した条件に合致するもののみが処理の対象となる。
実際の処理は PreFilterAuthorizationMethodInterceptor が実行される模様。
@PostFilterメソッド実行後にチェック。
戻り値となるオブジェクトのリストのうち、指定した条件に合致するもののみが返却の対象となる。
実際の処理は PostFilterAuthorizationMethodInterceptor が実行される模様。

PreFilter と PostFilter はこちら に具体的な説明もあります。

以下はコード例です。
動作確認結果は 403 になったかならないかだけの違いなのでここでは載せていません。
コメントに書いている通り、引数や戻り値、ユーザ情報といったものを利用してチェックを行うことができます。

@Slf4j
@RestController
public class MyRestController {

    // PreAuthorize
    //   ADMIN2 というロールはもってないのでメソッドは実行されず、403 になる
    //   hasRole を ADMINなどユーザが持っているロールと一致する場合はメソッドが実行される
    //  この例では言及していないが、引数の情報を式に入れることもできる。「#引数名」とすることで
    //   API 実行時のパラメータにアクセスし、Pre判定を行うことができる。
    // PostAutorize
    //  「returnObject」は戻りのオブジェクトを指す。ここでは "foo"を返しているので、
    //  「returnObject」は foo。 foo であれば結果が返る。
    //   もし条件式を hoge などにして条件が false になる場合は同じく 403 が帰る。 
    // いずれも principal を利用すると、ユーザ情報にアクセスもできる
    // 使えるものは以下参照
    // https://spring.pleiades.io/spring-security/reference/5.7.0/servlet/authorization/expression-based.html
    // 
    // このうち、hasPermission について、詳細は以下
    // https://spring.pleiades.io/spring-security/reference/servlet/authorization/expression-based.html#el-permission-evaluator
    @PreAuthorize("hasRole('ADMIN2')")
    @PostAuthorize("returnObject == 'foo'")
    @GetMapping("/api/hello")
    public String hello() {
        log.info("hello");
        return "foo";
    }

なお、このアノテーションの中は SpEL (Spring Expression Language)という記法で書くことができ、and や or といったものもかけます。各種型の呼び出し方含め以下にまとまっています。

Just a moment...

終わりに

今回は未確認だけど、追加で調べたいこと

  • access を用いたカスタム処理(リソースベースのカスタム認可チェック)
  • hasPermission のカスタム処理(メソッドベースのカスタム認可チェック)
  • 認可エラー時のレスポンスのハンドリング
  • ドメインオブジェクトのACL
  • ロールの階層化