Spring Security の認証の基本の流れを理解しつつ API でログイン処理を行う

Java
JavaSpringバックエンド

Webシステムを開発する上では認証は避けては通れない機能です。

Spring MVC の例外処理の動きをまとめた時もそうでしたが、Spring Security も多くの記事がヒットしますが、以下のような理由からなかなか理解に至らなかったのでこの記事で自身の理解をまとめています。

  • 歴史が長いことで色々な書き方があること(特に 2022年 に大きく変わったこと)
  • フォーム認証のケースが多く、フォーム認証を使わない(API でのログイン処理)場合の記事があまりない / 見つけられなかった
  • ヒットする記事は今でもその書き方がよいのか?が入門者には判断できない

対象読者

  • Spring Security 入門者
  • Spring Security でフォーム認証をせずに API で認証処理をした人

前提

  • Java 17
  • Spring Boot 2.7.8
  • Spring Security 5.7.6

事前

以下の記事が分かっていると理解しやすい気がします。Spring Security 関係なしにそもそもリクエストが Controller にくるまでどうなっているのかを書いているので先に目を通しておくとよいです。

全体像

概要を掴む程度にまとめています。

イメージはこちらを参考にさせていただきました!ほぼそのままですが、自分の理解のしやすさのため前後含めた一連として記載しています。

全体概要

SecurityFilterChain

上記の記事でも書きましたが、サーブレットフィルタでリクエストに対して任意の処理を適用することが可能です。Spring Security では、この機構を使ってセキュリティ用のフィルタ処理をしており、SecurityFilterChain は適用する Security 用の Filter (Security Filter)のリストを管理するためのインタフェースのようです。

FilterChainProxyGenericFilterBean のサブクラスなのでこの Filter クラスから処理が始まります。

デフォルトで適用される SecurityFilterChain

起動時のログにでています。

2023-02-05 10:25:15.888 INFO 1345 --- [ restartedMain] o.s.s.web.DefaultSecurityFilterChain : Will secure any request with [org.springframework.security.web.session.DisableEncodeUrlFilter@4f41a17d, org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@ce554b5, org.springframework.security.web.context.SecurityContextPersistenceFilter@691957b3, org.springframework.security.web.header.HeaderWriterFilter@7fb71efd, org.springframework.security.web.csrf.CsrfFilter@401c3062, org.springframework.security.web.authentication.logout.LogoutFilter@58322336, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@199d2fe, org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@2a1d950c, org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@63979f4a, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@ac980a5, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@3bf6e581, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@19a7c48a, org.springframework.security.web.session.SessionManagementFilter@6f437b47, org.springframework.security.web.access.ExceptionTranslationFilter@193fa88, org.springframework.security.web.access.intercept.AuthorizationFilter@117a43bb]

該当部分だけ並べるとこちらです。それぞれの意味もどこかでまとめたいところですがここでは割愛。

org.springframework.security.web.session.DisableEncodeUrlFilter
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter
org.springframework.security.web.context.SecurityContextPersistenceFilter
org.springframework.security.web.header.HeaderWriterFilter
org.springframework.security.web.csrf.CsrfFilter
org.springframework.security.web.authentication.logout.LogoutFilter
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter
org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter
org.springframework.security.web.savedrequest.RequestCacheAwareFilter
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter
org.springframework.security.web.authentication.AnonymousAuthenticationFilter
org.springframework.security.web.session.SessionManagementFilter
org.springframework.security.web.access.ExceptionTranslationFilter
org.springframework.security.web.access.intercept.AuthorizationFilter

HttpFireWall

デフォルトの実装クラスは StrictHttpFirewall で、その名の通り Firewall の機能が提供されています。

デフォルトで適用されるルール

HttpFirewall :: Spring Security - リファレンス
Spring Boot の概要から各機能の詳細までが網羅された公式リファレンスドキュメントです。開発者が最初に読むべきドキュメントです。

に記載されています。以下はそのキャプチャです。

https://spring.pleiades.io/spring-security/site/docs/current/api/org/springframework/security/web/firewall/StrictHttpFirewall.html#setAllowedHeaderNames(java.util.function.Predicate)

認証の流れ

認証処理部分を追加した絵です。

図の通り、認証処理は上で説明した Security Filter クラスの1つです。

認証の流れ

Authentication Filter

Authentication Filter は認証方式に対する実装を提供。これ以外に、ブラウザーベースの HTTP ベースの認証リクエストの処理クラスとして AbstractAuthenticationProcessingFilter があり、そのサブクラスの実装として以下の 1 – 3 があるようです。なお、4番目のように認証済みのための AuthenticationFilter もあります。

Filterクラス概要
1OAuth2LoginAuthenticationFilterOAuth用の認証フィルタ
2Saml2WebSsoAuthenticationFilterSaml2用の認証フィルタ
3UsernamePasswordAuthenticationFilterusername / password で認証する場合のフィルタ。デフォルトで パラメータ名が username, password の情報をリクエストから取得して認証処理に実行依頼する。
4RequestHeaderAuthenticationFilterリクエストヘッダーからユーザー名を取得する単純な事前認証済みフィルター。すでに到達時点で認証済みであることが前提。

通常は3つ目の UsernamePasswordAuthenticationFilter かと思います。上述したデフォルトの FIilter一覧にもありますね。

ただ、UsernamePasswordAuthenticationFilter はフォーム認証用の Filter クラスなので、画面とは独立した、JSON を IF とした ログイン API を作る場合にはこれを拡張する必要がありそうです。

Authentication Provider

ProviderManager から呼び出される、実際の認証処理を行う部分です。
Spring Security には様々な実装クラスが用意されているようです。気になったものだけでも以下がありました。

Provider クラス概要
DaoAuthenticationProviderUserDetailsService からユーザの詳細を取得する。UserDetailsService はインタフェースなので実際には実装クラスを作り、Filter から渡ってきた情報を元に DB などからユーザ情報を取得し、UserDetails (認証処理で必要な情報や各種ユーザ情報インタフェース)を生成して返す。

DaoAuthenticationProvider はリクエストパラメータのパスワードと、上の処理で取得したパスワードが一致するかを確認しOKなら認証成功。
LdapAuthenticationProviderLDAP サーバーに対して認証する AuthenticationProvider 実装。
PreAuthenticatedAuthenticationProvider事前認証された認証リクエストを処理します。DaoAuthenticationProvider とは異なり、認証チェックはしない。

認証されたユーザーの UserDetails のロードに使用される AuthenticatedUserDetailsService (UserDetail を返すメソッド loadUserDetails のみ定義されたインタフェース)を設定することで、Filterから渡ってきた情報を元に UserDetail を生成する。

上記を踏まえると以下の2パターンになるのかなと思いました。

ユーザ名 / パスワード認証時

これは ID/PW を投げて認証するパターン。
SecurityFilterChain で MyUsernamePasswordAuthenticationFilter としているのは、名前はともかく、api 用に拡張した独自クラスにするためです。

ログイン時の処理の流れ

認証済みの場合

認証した後のチェック機構。 ※ ここは記載時点で未検証です。
ここでは認証後に HTTP Header に認証済みを表す何かしらの情報が付与され、それを検証することを想定しています。また、REST API を想定しているので、セッションは使用しない想定です。

他の記事だと以下のような実装方法がありましたので必ずしも SecurityFilterChain を使う必要はなさそうです。

  • AbstractPreAuthenticatedProcessingFilter を利用している場合(RequestHeaderAuthenticationFIlter はこのサブクラスなので実質同じ)
  • OncePerRequestFilter を使ってヘッダ検証をしている場合
認証済みの場合の処理の流れ

認証処理

ここからは実際に認証を行うまでをやってみた結果です。認可だったり、認証後の JWT 生成などはしていません。今回はあくまで、ログイン API 認証をするところまで、です。
それ以降は別途。

やることは大きく以下です。

  • DBまわり(テーブル作成、Repository の実装)
  • 認証用のカスタム Security Filter の作成
  • 認証のためにDBからユーザ情報取得
  • 認証機構が動作するよう Spring Security 設定用クラス作成

プロジェクトベース

Spring Initializr

DBまわり(テーブル作成、Repository の実装)

DB設定 / テーブル定義 / テストデータ

テーブルの定義です。

DROP TABLE IF EXISTS person;

create table person (
    id  VARCHAR(8)  PRIMARY KEY,
    username VARCHAR(255),
    password VARCHAR(255),
    email VARCHAR(255),
    age INTEGER,
    enabled BOOLEAN,
    roles VARCHAR(255)
 );

こちらは流すデータ。パスワードは hoge です。BCrypt を適用してハッシュ化しています。

INSERT INTO person (id, username, password, email, age, enabled, roles) VALUES
    ('10000000', 'test1', '$2a$08$7563MWncbW/oenHWfZSKE.jWeMARqdO/WZajbstas2xUamOBXDWz6', 'test1@example.com', 30, 'true', 'ROLE_ADMIN,ROLE_GENERAL'),
    ('10000001', 'test2', '$2a$08$7563MWncbW/oenHWfZSKE.jWeMARqdO/WZajbstas2xUamOBXDWz6', 'test2@example.com', 16, 'true', 'ROLE_GENERAL'),
    ('10000002', 'test3', '$2a$08$7563MWncbW/oenHWfZSKE.jWeMARqdO/WZajbstas2xUamOBXDWz6', 'test3@example.com', 49, 'true', 'ROLE_ADMIN');

今回は h2 を使います。上の sql が自動で実行されるよう設定。

# H2 を利用する
spring.datasource.driver-class-name=org.h2.Driver

# データソースの場所 mem を指定するとインメモリになります。
# jdbc:h2:~/userdb などとすると、ファイルになります。
spring.datasource.url=jdbc:h2:mem:userdb
spring.datasource.username=
spring.datasource.password=

# 起動時に SQL 実行をします。以下が実行されます。
#   /src/main/resources/schema.sql
#   /src/main/resources/data.sql
spring.datasource.initialization-mode=always
# これを書かないと実行されません
spring.jpa.hibernate.ddl-auto=none

# h2 のコンソールが開けるように
# Spring boot 起動後、以下にアクセスするとコンソールを開くことができます。
# http://localhost:8080/h2-console
spring.h2.console.enabled=true

これで DB とテーブル、テストデータができました。

Entity / Repository

対応するモデルクラスです。今回 Person のデータを返すようにしますが、password は除外したいので JsonIgnore をつけています。

@Data
@Entity
public class Person {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private long id;
    private String username;
    @JsonIgnore
    private String password;
    private String email;
    private Integer age;
    private Boolean enabled;
    private String roles;
}

Repository はこちら。username から引っ張るだけなので以下のみ。

@Repository
public interface PersonRepository extends JpaRepository<Person, String> {
    public Optional<Person> findByUsername(String username);
}

これで DB 周り完了です。

認証用のカスタム Security Filter の作成

UsernamePasswordAuthenticationFilter を継承したクラスを作り、認証を実行するパスを設定します。あとは、認証をおこなう attemptAuthentication をオーバライドし、必要な情報を取得し、認証処理を実行します。

@Slf4j
public class MyUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final AuthenticationManager authenticationManager;

    public MyUsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;

        // "/api/login" の場合に認証を行うよう設定
        setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/api/login", "POST"));

        // 成功した場合の処理, 今回は取得した person 情報を返しています。
        this.setAuthenticationSuccessHandler((req, res, ex) -> {
            res.setStatus(200);
            MyUserDetails user = (MyUserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
            res.getWriter().write((new ObjectMapper()).writeValueAsString(user.getPerson()));
        });
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
        try {
            // リクエストのデータを LoginForm として取り出す
            LoginForm principal = new ObjectMapper().readValue(request.getInputStream(), LoginForm.class);
            // 認証処理を実行する
            return authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(principal.getUsername(), principal.getPassword())
            );
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}
@Getter
@Setter
public class LoginForm {
    private String username;
    private String password;
}

なお、デフォルトでは以下のキャプチャの通り、上で説明した DaoAuthenticationProvider が Provider として設定されています。ので、リクエストで渡された ID/PW でこのクラスの認証処理が実行されます。

認証のためにDBからユーザ情報取得

DaoAuthenticationProvider の認証処理の中で呼ばれる loadUserByUsername を実装します。
やっているのは username をキーに DB から情報取得です。

@Slf4j
@Service
public class MyUserDetailsService implements UserDetailsService {
    private final PersonRepository repository;

    public MyUserDetailsService(PersonRepository repository) {
        this.repository = repository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Person person = repository.findByUsername(username).orElseThrow(
                () -> new UsernameNotFoundException("User not found"));
        return new MyUserDetails(person);
    }
}

このメソッドは UserDetails インタフェースを実装したクラスが必要です。Spring Security には org.springframework.security.core.userdetails.User という実体クラスがあります。今回はこれをベースにしています。

@Getter
public class MyUserDetails extends User {

    private final Person person;

    public MyUserDetails(Person person) {
        super(person.getUsername(),
                person.getPassword(),
                Arrays.asList(person.getRoles().split(",")).stream().map(
                        role -> new SimpleGrantedAuthority((role))).toList());
        this.person = person;
    }
}

これで、作成した Filter から処理が流れてきて、DB から person を取得し、リクエストパラメータで渡されたパスワードの検証が行われます。一致すれば認証成功となります。

認証処理が動作するよう Spring Security 設定用クラス作成

リクエストごとの認証認可の設定です。”/api/login” は誰でも実行できるようにし、それ以外は認証済みの場合のみアクセス可能としています。

なお、Spring Security 5.4〜6.0でセキュリティ設定の書き方が大幅に変わる件 で、antMatcher の書き方が変わるとありますが、記事作成時点での spring-boot-starter-parent の 2系の最新は 2.7.8 で、こちらに依存している Spring Security は 5.7.6 でした。このバージョンでは requestMatchers(String) は提供されていないので、そのまま antMatcher を使っています。バージョンに応じて記述を変更してください。

@EnableWebSecurity
@Configuration
public class MySecurityConfig {

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(authz -> authz
                // h2-console を利用する場合は必要
                // .antMatchers("/h2-console/**").permitAll()
                .antMatchers("/").permitAll()
                .antMatchers("/api/login").permitAll()
                .anyRequest().authenticated()
        );
        var authManager = authenticationManager(http.getSharedObject(AuthenticationConfiguration.class));
        http.addFilter(new MyUsernamePasswordAuthenticationFilter(authManager));

        // セッションを使用しないためステートレスに設定
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        http.csrf().disable();

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(8);
    }
}

動作確認

成功、失敗それぞれの場合です。成功時は期待したとおり 200 で person の情報が返ってきています。失敗の場合は 401 エラーです。

認証成功

 % curl -X POST --include -H "Content-Type: application/json" -d '{"username": "test1", "password": "hoge"}' localhost:8080/api/login
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-Length: 120
Date: Sun, 05 Feb 2023 06:44:54 GMT

{"id":10000000,"username":"test1","email":"test1@example.com","age":30,"enabled":true,"roles":"ROLE_ADMIN,ROLE_GENERAL"

認証失敗

% curl -X POST --include -H "Content-Type: application/json" -d '{"username": "test5", "password": "hoge"}' localhost:8080/api/login
HTTP/1.1 401 
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: Sun, 05 Feb 2023 06:46:13 GMT

まとめ

今回は認証の中でもログインに絞ったものにはありますが、API でのログイン処理も少ないコード量で書くことができました(ここに至るまで結構試行錯誤がありましたが)。

Spring Security は本当に色々な機能があることや、歴史も長いことで便利な半面取っ掛かりが難しいなという印象ですが、理解できると楽しいですね。量が多くなったので、以下は別途検証します。

  • JWT を生成し Authrization Header につめ、他の api リクエストで認証チェックを行う
  • api 単位で認可する

最後に。以下の書籍は出版されて年数が経っており最新ではない部分はありますが、それでもベースを理解する上では今でも有用でした(自分も何度も買うか悩んだ末 2023/1 に買いましたがやはり体系だったものごとが見れるのは助かります)。Spring は歴史があるぶん奥が深いので、迷っている方は読んで見ることをおすすめです。

コメント