くちたと計算記

プログラミングのことを書きます

Spring Securityで自作したAuthenticationFilterとAuthentiactionProviderを使う方法

概要

Spring Security 5.7.xではWebSecurityConfigurerAdapterを拡張する記法が非推奨となり、SecurityFilterChainをBeanとして登録する記法が推奨されています。

そこで自作したAuthenticationFilterAuthenticationProviderを使う設定ファイルを書き直そうと試みました。

@EnableWebSecurity
@Configuration
public class OldConfiguration extends WebSecurityConfigurerAdapter {
  @Override
  protected void configure(AuthenticationProviderBuilder auth) {
    auth.authenticationProvider(new MyAuthenticationProvider());
  }

  @Override
  protected void configure(HttpSecuirty http) {
    MyAuthenticationFilter filter = new MyAuthenticationFilter();
    filter.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/my-login", "POST"));
    filter.setAuthenticationManager(super.authenticationManagerBean())
    http
        .formLogin(formLogin -> formLogin
            .loginPage("/my-login-form")
            .loginProcessingUrl("/my-login")
        )
        .addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class);
  }
}

このときに悩んだこととを二つ紹介します。

1. 自作したAuthenticationProviderAuthenticationManagerに登録する方法が分からない

今までWebSecurityConfigurerAdapter#configure(AuthenticationProviderBuilder)メソッドで自作したMyAuthenticationProviderAuthentcationManagerに登録していました。 WebSecurityConfigurerAdapterクラスを継承しないことに伴い、書き直す必要があります。

UsernamePasswordAuthenticationFilterを使う場合は今まで通りHttpSecuirtyの設定をすれば解決します。

@Bean
public SecurityFilterChain securityFilterChain(HttpSecuirty http) {
  MyAuthenticationFilter filter = new MyAuthenticationFilter();
  filter.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/my-login", "POST"));
  filter.setAuthenticationManager(/* どこからかAuthenticationManagerを取得したい */)

  return http
    .formLogin(formLogin -> formLogin
        .loginPage("/my-login-form")
        .loginProcessingUrl("/my-login")
    )
    .authenticationProvider(new MyAuthenticationProvider())
    .addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class);
    .build();
}

この設定をすると、HttpSecurity#build()を呼び出したときに、AuthenticationManagerインスタンスが生成されます。 それではAuthenticationManagerを自作したAuthenticationFilterに設定できなくなってしまいます。 そこでSpring Securityの仕組みに頼らず、自分でAuthenticationManagerを生成しました。 AuthenticationManagerの実装には、ProviderManagerを使用します。

@Bean
public SecurityFilterChain securityFilterChain(HttpSecuirty http) {
  AuthenticationManager authenticationManager = new ProviderManager(new MyAuthenticationProvider());

  MyAuthenticationFilter filter = new MyAuthenticationFilter();
  filter.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/my-login", "POST"));
  filter.setAuthenticationManager(authenticationManager);

  return http
    .formLogin(formLogin -> formLogin
        .loginPage("/my-login-form")
        .loginProcessingUrl("/my-login")
    )
    .addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class);
    .build();
}

2. formLoginメソッドで設定した内容と自作したAuthenticationFilterにする設定はどちらが優先されるのか

修正前の設定では、filter.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/my-login", "POST"))と、formLogin.loginProcessingUrl("/my-login")の2か所で認証処理を呼び出すパスを設定しています。 どちらかの設定が無駄になっていると思ったので、FormLoginConfigurerのドキュメントを確認しました。

Adds form based authentication. All attributes have reasonable defaults making all parameters are optional. If no loginPage(String) is specified, a default login page will be generated by the framework. Security Filters

The following Filters are populated

UsernamePasswordAuthenticationFilter

Shared Objects Created

The following shared objects are populated

AuthenticationEntryPoint

docs.spring.io

FormLoginConfigurerUsernamePasswordAuthenticationFilterを構築するための設定で、副次的にAuthenticationEntryPointオブジェクトを生成しているとあります。 FormLoginConfigurerでパスを設定しても自作したAuthenticationFilterには設定されませんので、formLoginを使わないことにしました。

@Bean
public SecurityFilterChain securityFilterChain(HttpSecuirty http) {
  AuthenticationManager authenticationManager = new ProviderManager(new MyAuthenticationProvider());

  MyAuthenticationFilter filter = new MyAuthenticationFilter();
  filter.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/my-login", "POST"));
  filter.setAuthenticationManager(authenticationManager)

  return http
    .exceptionHandling(exceptionHandling -> exceptionHandling
        .authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/my-login-form"))
    )
    .addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class);
    .build();
}

最後に

フレームワークの設定はかなり小手先の領域にはなりますが、それでも自分が何をやりたくて、フレームワークのどの部分を拡張してくと良いのかを考えるのはとても面白かったです。 そのためにも、使用するフレームワークやライブラリのリファレンスに載っているような内容は理解しておきたいです。

ユーザーの認証認可とアプリケーション分割についてのメモ

概要

複数のロールが関与するWebアプリケーションの認証認可を考えるときに、アプリケーション分割した方がいいのか、ロールベースの認可でアクセス制御をすればいいのか悩みました。

結論

  1. 状態遷移可能なロール同士は同じユーザー分類として分類し、状態遷移不可能なロール同士は別のユーザー分類として分類する。

    平社員から管理職には状態遷移可能なので、一つのユーザー分類の中のロールとして定義すると良さそうです。 他方、社員から顧客には状態遷移しづらいので、別々のユーザー分類として定義すると良さそうです。

  2. 一つのユーザー分類ごとの一つアプリケーションを構築する

    これという理由がありませんが……

    1. 認証認可の設定が単純明快で、設定ミスによるインシデントが起こりづらそう。
    2. スケーリングしやすい。

この文字数はさすがに手抜きなのではと思いましたが、これ以上書くことがありませんでした。

Spring Securityのデフォルトの挙動とSecurityFilterChainを自分で登録した時の挙動

概要

Spring Security 5.7.2において、Servletアプリケーションでは以下のような説明がされていました。

Spring Security form log in is enabled by default. However, as soon as any servlet based configuration is provided, form based log in must be explicitly provided.

Spring Security Reference -Form Login-より

そこで以下の2点について調べました。

  • どんなときにデフォルトの機能が無効化されるのか
  • どんな機能が無効化されてどんな機能が無効化されないのか

今回は以下のようなSecurityFilterChainを登録して調査しました。

package security;

import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;

@EnableWebSecurity
public class WebSecurityConfiguration {
    @Bean
    public  SecurityFilterChain securityFilterChain(HttpSecurity http) {
        return http.build();
    }
}

実際に使う時には色んな設定を自分で定義することになると思います。そのときにどんな設定がなされるかは定義内容によるので、ご自身で確認されることを推奨します。

結論

  • SecurityFilterChain型のBeanを手動で登録した時に、一部のFilterが適用されなくなりました。
  • 匿名で全てのページにアクセス可能ということ以外は、デフォルトと同じ挙動をするみたいです。(デフォルトの挙動はこちらから確認してください。)

調査の経過

1. どんなときにデフォルトの機能が無効化されるのか

そもそも「Servletベースの設定をしたら」と書いてありますが、具体的にどういうコードを書けば挙動が変わるのか分かりません。

特に何も設定しなければ、/loginでログインページにアクセスし、コンソールに出力されるパスワードを使ってログインできます。 何をしたらこの挙動が変化するのかを確認しました。

  1. 以下のような、@EnableWebSecurityアノテーションを付与した設定クラスを作成しても、変わらずログインすることができました。

     package security;
    
     import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
    
     @EnableWebSecurity
     public class WebSecurityConfiguration {
     }
    
  2. 以下のような、HttpSecurityをもとにSecurityFilterChain型のBeanを登録したところ、/loginにアクセスしたときに404エラーが返りました。

     package security;
    
     import org.springframework.context.annotation.Bean;
     import org.springframework.security.config.annotation.web.builders.HttpSecurity;
     import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
     import org.springframework.security.web.SecurityFilterChain;
    
     @EnableWebSecurity
     public class WebSecurityConfiguration {
         @Bean
         public  SecurityFilterChain securityFilterChain(HttpSecurity http) {
             return http.build();
         }
     }
    

まずはSecurityFilterChain型のBeanを登録したら、いくつかの機能が無効化されることが分かりました。

2. どんな機能が無効化されてどんな機能が無効化されないのか

これで調査を終えてもよかったのですが、何の機能が無効化されるのかがとても不安になったので、念の為確認してみました。

起動時のログ

  1. 設定なしでアプリケーションを起動したとき

    2022-07-24 21:50:25.374 INFO 4457 --- [ main] o.s.s.web.DefaultSecurityFilterChain : Will secure any request with [org.springframework.security.web.session.DisableEncodeUrlFilter@4ac86d6a, org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@3d904e9c, org.springframework.security.web.context.SecurityContextPersistenceFilter@65c33b92, org.springframework.security.web.header.HeaderWriterFilter@42373389, org.springframework.security.web.csrf.CsrfFilter@1c7f96b1, org.springframework.security.web.authentication.logout.LogoutFilter@6c008c24, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@48d293ee, org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@5ec4ff02, org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@508a65bf, org.springframework.security.web.authentication.www.BasicAuthenticationFilter@261ea657, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@4e08acf9, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@42ea287, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@17f2dd85, org.springframework.security.web.session.SessionManagementFilter@a62c7cd, org.springframework.security.web.access.ExceptionTranslationFilter@35267fd4, org.springframework.security.web.access.intercept.FilterSecurityInterceptor@2af69643]

  2. SecurityFilterChain型のBeanを登録して、アプリケーションを起動したとき

    2022-07-24 22:47:26.441 INFO 4703 --- [ main] o.s.s.web.DefaultSecurityFilterChain : Will secure any request with [org.springframework.security.web.session.DisableEncodeUrlFilter@41eb94bc, org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@378cfecf, org.springframework.security.web.context.SecurityContextPersistenceFilter@599e4d41, org.springframework.security.web.header.HeaderWriterFilter@7100dea, org.springframework.security.web.csrf.CsrfFilter@611a990b, org.springframework.security.web.authentication.logout.LogoutFilter@68ab0936, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@10f7c76, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@fc807c1, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@97d0c06, org.springframework.security.web.session.SessionManagementFilter@2b680207, org.springframework.security.web.access.ExceptionTranslationFilter@5624657a]

アプリケーション起動時に登録されるDefaultSecurityFilterChainが丸ごと無視されるのではと思いましたが、実際にはDefaultSecurityFilterChainの中の一部のFilterが無視されるだけでした。SecurityFilterChain型のBeanを登録するだけでCSRF対策などが無効になっていたら怖いなと思いましたが、杞憂に終わってよかったです。

差分

差分を確認してみます。

クラス名 概要
UsernamePasswordAuthenticationFilter ユーザー名とパスワードをもとにユーザーを認証するフィルター
DefaultLoginPageGeneratingFilter ログインページを表示するフィルター
DefaultLogoutPageGeneratingFilter ログアウト確認ページを表示するフィルター
BasicAuthenticationFilter Basic認証をするフィルター
FilterSecurityInterceptor ユーザーの認証情報とパスをもとにリクエストを認可するインターセプター

つまり全てのページに匿名認証でアクセスできちゃうってことかしら。

import org.springframework.security.core.annotation.CurrentSecurityContext;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TestController {
    @GetMapping("/")
    public String test(@CurrentSecurityContext SecurityContext securityContext) {
        System.out.println(securityContext.authentication);
        return "success";
    }
}

無事にリクエスト成功しました。SecurityContextHolderに保存されている認証情報は以下のような情報でした。

AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[ROLE_ANONYMOUS]]

匿名認証されている状態ですね。

ちなみに匿名認証ではハンドラメソッドにAuthentication型の引数を追加しても、nullになってしまうそうです。

The reason is that Spring MVC resolves the parameter using HttpServletRequest#getPrincipal, which is null when the request is anonymous.

If you’d like to obtain the Authentication in anonymous requests, use @CurrentSecurityContext instead:

Spring Security Reference -Anonymous-より

認証情報を取得するには、以下の方法が考えられます。

  1. @CurrentSecurityContext SecurityContext securityContextをハンドラメソッドの引数に指定する
  2. @AuthenticationPrincipal Object principalをハンドラメソッドの引数に指定する

最後に

自分でSecurityFilterChainを定義した時に無効になる機能が限られたもので安心しました。

参考URL

フロントエンドとバックエンドをモノリポ構成で開発する時にESLintについて調べたこと

解決したいこと

フロントエンドをAngular、バックエンドをNestJSで開発します。規模が小さいので一つのソースリポジトリで管理します。リポジトリ内ではESLintの設定とPrettierの設定を統一したいです。

結論

eslintrc.jsonprettierrc.jsonはルートパッケージで管理するのがよさそうでした。フロントエンドとバックエンドで異なる設定を定義するには、overridesキーを使います。

ESLintの設定を共通化する方法

ESLintの設定を共通化する方法はいくつかあります。この記事では3つの方法を紹介します。 結論で書いた内容は、方法3で説明します。

方法1. ルートパッケージとサブパッケージにそれぞれ設定ファイルを配置する

ESLintはLint対象のファイルからファイルパスを遡ってESLintの設定ファイルを探し、複数の設定ファイルが見つかったら設定をマージします。同じキーの設定内容はLint対象のファイルに近いディレクトリの設定内容が優先されます。root: trueと定義された設定ファイルを見つけたら、それ以上設定ファイルを探索しません。この仕様を利用し、以下のように設定ファイルを定義します。

.
+-- eslintrc.js (root: true)
+-- backend/
|   `-- eslintrc.js (root: false)
`-- frontend/
    `-- eslintrc.js (root: false)

パッケージ固有の設定をサブパッケージで定義して、共通の設定をルートパッケージで定義します。サブパッケージの設定ファイルにroot: falseと定義すれば、サブパッケージの設定ファイルとルートパッケージの設定ファイルの両方を参照してLintを実行してくれます。

方法2. ルートパッケージの設定をサブパッケージで拡張する

方法1ではESLintがLint対象のファイルパスを遡って設定ファイルを探索するのに任せていました。方法2では、以下のようにサブパッケージでルートパッケージの設定を拡張するように明示します。

modules.exports = {
  "root": true,
  "extends": [ "../.eslintrc.js" ]
}

サブパッケージでルートパッケージの設定を拡張するように明示しているので、サブパッケージの設定にもroot: trueと書けます。方法1より冗長ですが、明示的で分かりやすいと感じました。

方法3. ルートパッケージの設定にディレクトリごとの設定を記述する

方法1方法2では、共通の設定とサブパッケージ固有の設定をそれぞれの別のファイルに定義しました。方法3では、共通の設定とサブパッケージ固有の設定をともにルートパッケージにの設定ファイルに定義します。

modules.exports = {
  "overrides": [
    {
      "files": "backend/**/*.ts",
      "exntends": [
        "plugin:@typescript-eslint/recommended",
        "plugin:prettier/recommended"
      ]
    },
    {
      "files": "frontend/**/*.ts",
      "extends": [
        "plugin:@angular-eslint/recommended",
        "plugin:@angular-eslint/template/process-inline-templates",
        "plugin:prettier/recommended"
      ]
    },
    {
      "files": "frontend/**/*.html",
      "extends": [
        "plugin:@angular-eslint/template/recommended"
      ]
    }
  ]
}

設定が一つのファイルにまとまって管理しやすくなりました。複数のファイルに設定が分かれていると無秩序な設定上書きが不安になります。

フレームワーク固有のESLintプラグインをルートパッケージでインストールしたり、1ファイルの設定内容が多くなったりするので、好みは分かれそうです。

まとめ

わたしのケースでは以下の背景から方法3を選択しました。

  • プロジェクトの規模がとても小さいこと
  • バックエンドとフロントエンドの2つしかサブパッケージがないこと
  • Linterの設定を緩いルールに上書きしてほしくないこと

ESLintの設定を共通化する方法はいくつか用意されているので、場合によって最適と思われる方法を選択すると良いと思います。