spring.svg

【Spring Security】認証・認可の基礎

SpringBoot
2024/03/06

はじめに

この記事では、Spring Security を用いた認証と認可の基本的なところを説明していきます。 前回記事を理解していることを前提としていますので、あらかじめご了承ください。

認可設定

設定方法の基本

前回の記事で簡単に紹介していますが、対象のパスへアクセスするための認可の設定は、以下のような設定用のクラスを作成して行います。 この場合、/helloへのアクセスは認可不要で、その他へのアクセスは認可が必要という内容になります。

WebSecurityConfig.java
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(authorize -> authorize
          .requestMatchers("/hello").permitAll()
          .anyRequest().authenticated());
    return http.build();
  }
}

例を見れば何となくわかるかもしれませんが、1 つずつ見ていきましょう。

パスの設定

設定対象となるパスを指定するには、requestMatchersを使用します。 例のように文字列でパスを指定すれば、そのパスを対象として認可の設定を行うことができます。 複数のパスを対象としたい場合、方法は 2 つあります。

1 つは可変長引数で指定する方法です。 これは以下のように対象のパスをカンマ区切りで指定すれば良いだけです。

requestMatchers("/hoge", "/fuga", "/piyo").permitAll()

もう 1 つはワイルドカードを使用する方法です。 ワイルドカードには***があり、それぞれ違いがあります。 まずは以下の表を見てください。

/hoge/fuga/hoge/piyo/hoge/fuga/piyo/hoge/piyo/fuga
/hoge
/hoge/*
/hoge/f*
/hoge/**
/hoge/*/piyo

*は文字を補完する役割があります。 単純に*だけであればすべての文字列を対象することになり、f*とすれば先頭はfで残りの文字は何でも OK といった具合です。 ただし、/hoge/*/hoge/fuga/piyoが ✕ となることからわかるように、*は階層に作用するものではありません。

そこで、この階層以降すべてを対象するための設定として**を使用します。 /hoge/**の結果がすべて ● となっているように、/hoge/が先頭に来るパスはすべて対象にするものとなります。

他にも 1 文字を表す?があります。/hog?とすれば、/hoge/hogiというようなパスを対象にすることができます。

これら可変長引数とワイルドカードを組み合わせて適切なパス設定を行います。

またrequestMatchers()で指定していないパスを対象としたい場合は、anyRequest()を使用します。

認可有無の設定

上記パスの設定に対し、認可が必要であるかを設定します。 基本となるのは以下の 3 つです。

メソッド説明
permitAll()認可を必要とせず、対象パスへのアクセスを許可する。
authenticated()認可を必要とする。正しく認可された場合に対象パスへのアクセスを許可する。
denyAll()認可を必要とせず、対象パスへのアクセスを拒否する。

denyAll()は対象パスへアクセスをさせたくない場合に使用します。 この時、レスポンスとして 403 Forbidden が返されます。 その他ユーザーの権限による設定(hasRole(), hasAuthority())などもありますが、ここでは触れません。

この設定で重要なのは順序です。例えば以下の例を考えてみます。

http.authorizeHttpRequests(authorize -> authorize
      .requestMatchers("/hoge").permitAll()
      .requestMatchers("/hoge/**").autenticated()
      .anyRequest().denyAll());

/hogeにアクセスした場合、permitAll()authenticated()のどちらも対象になるのですが、先に指定した方が優先されます。 つまり、/hogepermitAll()/hoge/fugaなどはauthenticated()となります。 この指定順を入れ替えた場合は、/hogeauthenticated()となります。

HTTP メソッドの設定

実は上記パスの設定だけだと、GETPOSTHEADOPTIONSの HTTP メソッドが対象となります。 Thymeleaf などのテンプレートエンジンを用いた WEB アプリを公開する場合は問題ありませんが、REST API でPUTDELETEを使用する場合に困ります。

requestMatchers()はパスの設定だけでなく、以下のように HTTP メソッドを指定することができます。

requestMatchers(HttpMethod.GET)
requestMatchers(HttpMethod.GET, "/hello")

対象の HTTP メソッドすべてを対象とする指定方法と、パスと HTTP メソッドを組み合わせた指定方法があります。 2 つ目の場合は、GETでアクセスした/helloのみが対象となります。 つまり上記の記述では、POSTでアクセスする/helloは対象となっていない点に注意してください。

またパスの設定のところで、anyRequest()requestMatchers()で指定していないパスを対象とすると記載していますが、 正確には HTTP メソッドも含まれます。 この点にも注意してください。

POST,PUT,DELETEは、デフォルトで CSRF のチェックが行われる点に注意してください。 API として動作確認を行う場合は、http.csrf().disable()で無効にすることをおススメします。

追記:Spring Security 6.1以降では引数無しのhttp.csrf()が非推奨となっています。代わりにhttp.csrf(Customizer<CsrfConfigurer<HttpSecurity>> csrfCustomizer)を使用します。 CSRFを無効にする場合は、http.csrf(csrf -> csrf.disable())とします。

インメモリによる認証

前回記事では静的な認証として、設定ファイルにusernamepasswordを設定した認証について説明しました。 ここでは、メモリ内に登録したユーザー情報から認証する方法について説明をします。

実際に本番でこの方法を使用することは稀だとは思いますが、認証の流れを理解するために使用します。

ユーザー情報の登録

ユーザー情報を登録するには、上記WebSecurityConfigクラスにUserDetailsServiceを Bean として登録するためのメソッドを作成します。

WebSecurityConfig.java
@Bean
public UserDetailsService users() {
  UserDetails admin = User.builder()
                          .username("admin")
                          .password("{noop}password")
                          .authorities("ADMIN")
                          .build();
  UserDetails user = User.builder()
                         .username("user")
                         .password("{noop}password")
                         .authorities("USER")
                         .build();
  return new InMemoryUserDetailsManager(admin, user);
}

見ての通りですが、UserDetailsとしてユーザーを作成し、InMemoryUserDetailsManagerのコンストラクタに作成したユーザーを渡すことで、インメモリによる認証が実現できます。 ユーザーの作成には、認証に必要なusernamepasswordの他に権限が必須の項目になります。

これら詳細についてはひとまずおいておきます。 まずは上記で登録したadminuserでログインできることを確認してみてください。 ログインできれば設定としては完了です。

PasswordEncoder

ユーザー作成時に指定するパスワードは、"{id}encodedPassword"のような形式で指定します。 {id}はエンコード方法を表しており、以下表のものが使用できます。 認証時に{id}からエンコード方法を特定することで、パスワードの照合を行います。 {id}がない場合はエラーとなります。

id対応クラスアルゴリズム
noopNoOpPasswordEncoderエンコードなし
bcryptBCryptPasswordEncoderbcrypt
pbkdf2Pbkdf2PasswordEncoderPBKDF2(Password-Based Key Derivation Function 2)
scryptSCryptPasswordEncoderscrypt
sha256StandardPasswordEncoderSHA-256

例では{noop}としているため、平文のpasswordを指定したことになります。 例えばこれをbcryptとする場合は、上表の対応するクラスを使用してエンコードしたパスワードを生成します。

WebSecurityConfig.java
PasswordEncoder encoder = new BCryptPasswordEncoder();
String encodedPassword = encoder.encode("password");

UserDetails admin = User.builder()
                        .username("admin")
                        .password("{bcrypt}" + encodedPassword)
                        .authorities("ADMIN")
                        .build();

この{id}を指定する方法はあくまで個別にエンコード方法を指定するものです。 以下のようにPasswordEncoderを Bean として登録すれば、アプリケーション共通のエンコード方法を指定することとなり、{id}の指定が不要になります。

WebSecurityConfig.java
@Bean
public PasswordEncoder passwordEncoder() {
  return new BCryptPasswordEncoder();
}

@Bean
public UserDetailsService users() {
  UserDetails admin = User.builder()
                          .username("admin")
                          .password(passwordEncoder().encode("password"))
                          .authorities("ADMIN")
                          .build();
  UserDetails user = User.builder()
                         .username("user")
                         .password(passwordEncoder().encode("password"))
                         .authorities("USER")
                         .build();
  return new InMemoryUserDetailsManager(admin, user);
}

権限(Authority, Role)

上述していますが、ユーザーの作成には権限の設定が必要になります。 上例では、authorities()を用いてそれぞれADMINUSERという権限を設定しています。

権限の設定は、特定のパスへのアクセス制御に使用することができます。 例えば、/hoge/fugaADMINのみアクセスさせたい場合は以下のようにhasAuthority()を使用します。

http.authorizeHttpRequests(authorize -> authorize
      .requestMatchers("/hoge/fuga").hasAuthority("ADMIN")
      .requestMatchers("/hoge/**").autenticated()
      .anyRequest().denyAll());

権限の設定については、authorities()の他にroles()を使用する場合もあります。 使用方法はauthorities()と同じで、アクセス制限を行う場合はhasRole()を用います。

UserDetails admin = User.builder()
                        .username("admin")
                        .password("{noop}password")
                        .roles("ADMIN")
                        .build();
http.authorizeHttpRequests(authorize -> authorize
      .requestMatchers("/hoge/fuga").hasRole("ADMIN")
      .requestMatchers("/hoge/**").autenticated()
      .anyRequest().denyAll());

Authority と Role については、さほど大きな違いはありません。 どちらもGrantedAuthorityとして同じフィールドに保存されるのですが、Role の場合は権限の先頭にROLE_が付与されます。

つまりroles("ADMIN")authorities("ROLE_ADMIN")は同じ権限を付与したことになります。 またhasRoles("ADMIN")hasAuthority("ROLE_ADMIN")は同じ権限の制御となります。

このように仕組み上あまり大きな違いはないのですが、Authority は権限、Role は役割と、本来の意味を考慮して使い分けるのが良いかと思います。 例えば、同じ管理者という役割でも書き込み権限や読み込み権限を付与したい場合は以下のようにします。

UserDetails admin = User.builder()
                        .username("admin")
                        .password("{noop}password")
                        .roles("ADMIN")
                        .authorities("WRITE", "READ")
                        .build();
http.authorizeHttpRequests(authorize -> authorize
      .requestMatchers("/hoge/fuga").hasAnyAuthority("ROLE_ADMIN", "WRITE")
      .requestMatchers("/hoge/**").autenticated()
      .anyRequest().denyAll());

hasAnyAuthority()は指定した権限をすべて持つ場合のみアクセス可能となります。 つまり上記は、管理者かつ書き込み権限がある場合にアクセス可能という設定になります。

認証の仕組み

概要

前回の記事で紹介だけしましたが、以下ページに Form ログインにおける認証フローについて記載があります。

参照:https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/form.html

簡単に一部だけ抜粋すると、以下のような流れになります。

  1. UsernamePasswordAuthenticationFilter内で以降の認証処理が行われる。
  2. リクエスト情報からusernamepasswordを取得し、UsernamePasswordAuthenticationTokenとして保存する。
  3. AuthenticationManagerauthenticate()により認証を行う。
  4. 認証結果の真偽により、それぞれ該当の処理を行う。

ここで着目するのは、認証処理を行うAuthenticationManagerです。 しかしAuthenticationManagerはインターフェースのため、実際に Form ログインで使用されるのはProviderManagerという実装クラスになります。

さらに深堀すると、ProviderManagerは複数のAuthenticationProviderを用いて認証を行い、今回はその中でもDaoAuthenticationProviderが使用されます。 これ以上の詳細については割愛しますが、基本的には以下の流れで認証が行われていると考えてください。

  1. UsernamePasswordAuthenticationTokenからusernameを取得する。
  2. UserDetailsServiceloadUserByUsername()により対象ユーザー(UserDetails)を取得する。
  3. PasswordEncodermatches()により、UsernamePasswordAuthenticationTokenpasswordと取得したユーザーのパスワードとを比較する。

これについて、上記のインメモリによる認証の内容を踏まえて補足していきます。

UserDetails

Spring Security では、ユーザーはUserDetailsとして扱われます。 UserDetailsはインターフェースとなっており、これを実装したクラスのインスタンスがユーザー情報となります。

つまり、インメモリによる認証ではUserという実装クラスを使用しましたが、UserDetailsを実装したクラスであれば自作したクラスでも良いわけです。 本来ユーザーの情報はアプリケーションごとに異なるので、自作したクラスを使うのは当たり前なんですよね…。

UserDetailsでは以下のメソッドの実装が必須となります。 言い直すと、これがユーザーを作成するための最低要件となります。 あとはアプリケーションに合わせて必要なフィールドやメソッドを追加すれば OK です。

メソッド名戻値説明
getAuthorities()Collection<? extends GrantedAuthority>権限情報を取得する。
getPassword()String(エンコード済みの)パスワードを取得する。
getUsername()Stringユーザー名を取得する。
isAccountNonExpired()booleanアカウントの期限が切れていないことを確認する。
isAccountNonLocked()booleanアカウントがロックされていないことを確認する。
isCredentialsNonExpired()boolean資格情報の期限が切れていないことを確認する。
isEnabled()booleanアカウントが有効であることを確認する。

例えば以下のようなMyUserクラスを作成すれば、上記のインメモリによる認証に使用することが可能です。 isAccountNonExpired()などをfalseに設定すれば、それぞれのエラーメッセージも確認できるので、お時間あれば試してみてください。

MyUser.java
public class MyUser implements UserDetails {

  private String username;
  private String password;
  private List<GrantedAuthority> authorities = new ArrayList<>();

  public MyUser(String username, String password, String authority) {
    this.username = username;
    this.password = password;
    this.authorities = AuthorityUtils.createAuthorityList(authority);
  }

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    return authorities;
  }

  @Override
  public String getPassword() {
    return password;
  }

  @Override
  public String getUsername() {
    return username;
  }

  @Override
  public boolean isAccountNonExpired() {
    return true;
  }

  @Override
  public boolean isAccountNonLocked() {
    return true;
  }

  @Override
  public boolean isCredentialsNonExpired() {
    return true;
  }

  @Override
  public boolean isEnabled() {
    return true;
  }
}
WebSecurityConfig.java
@Bean
public PasswordEncoder passwordEncoder() {
  return new BCryptPasswordEncoder();
}

@Bean
public UserDetailsService users() {
  UserDetails admin = new MyUser("admin", passwordEncoder().encode("password"), "ADMIN");
  UserDetails user = new MyUser("user", passwordEncoder().encode("password"), "USER");
  return new InMemoryUserDetailsManager(admin, user);
}

UserDetailsService

UserDetailsServiceは概要に記載した通り、認証時にloadUserByUsername()を用いて該当のUserDetailsを取得するためのものです。 こちらもインタフェースとなっており、インメモリによる認証ではInMemoryUserDetailsManagerがその実装クラスとなります。

実は認証のことだけを考えるならば、loadUserByUsername()だけ実装していればよいので、以下のような簡単な内容でも動作します。

MyUserDetailsService.java
public class MyUserDetailsService implements UserDetailsService {

  Map<String, UserDetails> users = new HashMap<>();

  public MyUserDetailsService(UserDetails...users) {
    for (UserDetails u : users) {
      this.users.put(u.getUsername(), u);
    }
  }

  @Override
  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    UserDetails u = this.users.get(username);
    if (u == null) {
      throw new UsernameNotFoundException(username);
    }
    return u;
  }
}
WebSecurityConfig.java
@Bean
public PasswordEncoder passwordEncoder() {
  return new BCryptPasswordEncoder();
}

@Bean
public UserDetailsService users() {
  UserDetails admin = new MyUser("admin", passwordEncoder().encode("password"), "ADMIN");
  UserDetails user = new MyUser("user", passwordEncoder().encode("password"), "USER");
  return new MyUserDetailsService(admin, user);
}

あとはアプリケーションに合わせて、loadUserByUasername()の実装を DB 参照にしたりと変更すれば OK です。

ちなみにですが、インメモリによる認証で使用しているInMemoryUserDetailsServiceは、UserDetailsServiceを直接実装しているわけではなく、これを継承してるUserDetailsManagerを実装しています。 UserDetailsManagerには以下のメソッドが定義されており、これを実装するようになります。

  • createUser(UserDetails user)
  • updateUser(UserDetails user)
  • deleteUser(String username)
  • changePassword(String oldPassword, String newPassword)
  • userExists(String username)

また詳細は割愛しますが、InMemoryUserDetailsManager以外にもJdbcUserDetailsManagerLdapUserDetailsManagerといったUserDetailsManagerの実装クラスが用意されています。

パスワードの照合について

パスワードの照合については上述した通りで、細かなことをいうことはしません。 ただあまりないかもしれませんが、パスワードの照合方法をカスタマイズしたい場合などは、PasswordEncoderを実装したクラスを作成して、DI コンテナに登録します。

おわりに

簡単ではありますが、Spring Security の認証と認可についての基本的なところを説明しました。

認証については最初は理解するのが難しいかもしれません。 ポイントとして、UserDetailsがユーザー情報であること、UserDetailsServiceによって認証時にユーザー情報を取得していること、PasswordEncoderによってパスワードの照合を行っていることが分かればよいと思っています。

次回以降で DB による認証などができればよいかなと思っています。