Spring Securityで認証機能を実装する方法

2021年12月07日

Spring Security を使って認証と認可の機能を実装する方法を紹介します。

目次

実現したいこと

  • ユーザー登録
  • メールアドレスとパスワードによる認証
  • パスワードの不可逆ハッシュ化
  • 権限毎に画面の構成要素の表示・非表示を制御

Webアプリケーションを組むときに必要となることが多い認証管理機能ですが、Spring Security がボイラープレート化してくれています。最初はとっつきにくいですが、一度習得すると使い回しが効くので覚えておくと重宝します。

事前準備

Spring Boot Starter を使ってプロジェクトの初期セットアップ済みであることを前提とします。Spring Initializr を使ってプロジェクトのひな形を生成しておきます。本ブログの過去記事でも Visual Studio Code のプラグインによる Spring Boot プロジェクトのセットアップ方法を紹介しています。

Docker で PostgreSQL を動かす

データベースサーバは Docker 上に構築します。 Docker for Desktop をインストールします。 Docker をインストールすると docker-compose が使えるようになるので、以下の yaml ファイルをプロジェクトディレクトリ直下に作成します。

(path-to-project-root)/docker-compose.yaml
version: '3.1'

services:
  db:
    image: postgres:14
    restart: always
    environment:
      POSTGRES_PASSWORD: mugicha
      POSTGRES_DB: mugidb
      PGDATA: /var/lib/postgresql/data/pgdata
    ports:
      - 5432:5432
    volumes:
      - ./data/postgres/pgdata:/var/lib/postgresql/data/pgdata
      - ./data/postgres/log:/var/log

・POSTGRES_PASSWORD
postgresへ接続するときのパスワードを任意で指定します。

・POSTGRES_DB
Dockerがコンテナをアップするときに、自動で初期DBを作成してくれます。作成して欲しいデータベース名を指定します。

・PGDATA
コンテナ内でpostgresのデータの実体を管理するディレクトリを指定します。

・ports
ポートマッピングを定義します。ホスト側のポートは空いている任意のポートで問題ありませんが、コンテナ側は5432で postgres が接続を待ち受けているので変更しないように気をつけてください。

・volumes
コンテナの中で永続化されたディスクデータをホスト側と共有(マウント)します。 PGDATA で定義したディレクトリにデータベースの物理ファイルが格納されるので、ホスト側のファイルシステムとリンクしておきます。

$ cd <path-to-project-root>

$ docker-compose up -d 

yamlファイルが定義できたら、上記コマンドでコンテナのビルド&起動をします。

Dockerダッシュボード

Docker for Desktop の GUI ダッシュボードを見ると POSTGRES:14 が RUNNING になっていることが確認できます。

$ docker container ls
CONTAINER ID   IMAGE         COMMAND                  CREATED       STATUS         PORTS                    NAMES
12116f3546a8   postgres:14   "docker-entrypoint.s…"   5 hours ago   Up 5 minutes   0.0.0.0:5432->5432/tcp   demo_db_1

コマンドラインで確認する場合は上記となります。

pom.xml に追加するライブラリ

以下の依存を追加します。

データソースを指定する (application.properties)

src/main/resources/application.properties
spring.datasource.url=jdbc:postgresql://localhost:5432/mugidb
spring.datasource.username=postgres
spring.datasource.password=mugicha
spring.datasource.driver-class-name=org.postgresql.Driver

spring-boot-starter-data-jpa が参照するデータベース接続先情報を application.properties に指定します。driver-class-name 以外は自分の環境に合わせてください。

Userエンティティとリポジトリを定義する

データベースにユーザ管理テーブルを作成し、Java 側の ORM とのインタフェースを定義していきます。

テーブル作成

CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    email VARCHAR(128) NOT NULL UNIQUE,
    password VARCHAR(256) NOT NULL,
    roles VARCHAR(32) NOT NULL
);

データベースにユーザ管理テーブルを作成します。 Hibernate の DDL 自動生成機能はクセが強いため、手動で作成します。

$ docker-compose exec db bash

root@12116f3546a8:/# psql -U postgres -d mugidb

mugidb=# CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    email VARCHAR(128) NOT NULL UNIQUE,
    password VARCHAR(256) NOT NULL,
    roles VARCHAR(32) NOT NULL
);

PostgreSQL のコンテナに docker-compose exec コマンドで入れます。コンテナの中では postgres クライアント (psql) が使えるので、mugidb データベースに入り、テーブルを作成します。

認可用にユーザーが持つ権限 (roles) を設けています。Spring Security では権限を文字列で表現するので VARCHAR(32) としておきます。一般的に、権限は権限マスタテーブルを作成し、ユーザー管理テーブルと多対多の関係を作りますが、今回はめんどくさいのでユーザー管理テーブルに、カンマ区切りで権限名を保存する(ユーザーは複数の権限を持てます)ようにします。

エンティティ定義

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "email")
    private String email;

    @Column(name = "password")
    private String password;

    @Column(name = "roles")
    private String roles;

    public User() {
    }

    // setters / getters...

Userテーブルに対応するエンティティクラスを実装します。各フィールドとテーブルの列名をマッピングしています。エンティティクラスには 空のコンストラクタ が必要なので実装を忘れないように注意してください。

リポジトリ定義

import java.util.Optional;
import com.mugicha.demo.entities.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByEmail(String email);
    
}

データベースへの CRUD 操作のインターフェースを実装します。 JpaRepository に findAll() や save() メソッドなど、基本的なAPIは定義されています。条件句付きの SELECT 文に相当する検索APIも findBy() や findOne() として用意されていますが、 Query by Example 形式の条件句指定方法が分かりにくいため、独自のメソッドを定義します。

後ほどメールアドレスをキーにユーザをDBから抽出するシーンがあるので、独自のAPIを作成します。 JpaRepository を継承したインタフェースで findBy<Field名> の命名規則でメソッドを定義すると、自動で該当するフィールドをキーにデータ取得をする条件句を生成してくれます。詳細なルールは公式ドキュメントを参照してください。

User操作用の Service クラスを実装する

@Service
public class UserService {
    
    @Autowired
    UserRepository userRepository;

    @Autowired
    PasswordEncoder passwordEncoder;

    // ユーザー登録用API
    public User createUser(String email, String rawPassword, String[] roles) {
        User user = new User();
        user.setEmail(email);
        // パスワードはハッシュ化する
        user.setPassword(passwordEncoder.encode(rawPassword));
        user.setRoles(String.join(",", roles));
        return userRepository.save(user);
    }

}

ビジネスロジックは Service クラスに切り出しておきます。ユーザー登録メソッドでパスワードのハッシュ化をしています。PasswordEncoder は後述する WebSecurityConfigurerAdapter を設定する際に実装クラスを Bean 登録して Autowired できるようになります。

UserDetailsService を実装する

UserDetailsService は Spring Security が提供する認証マネジャーがユーザーを特定するためのインタフェースとなります。ログインID と パスワードの組み合わせで認証機能を構築する際に、ログインID からユーザーを特定し、org.springframework.security.core.userdetails.UserDetails への変換を行うための実装を利用者が実装します。

UserDetails は、Spring Security が認証・認可を管理するための Principal 表現となります(要するに、Spring がユーザーを識別するために定義されたオブジェクト表現です)。実体はインタフェースクラスです。

UserDetailsServiceの実装
@Service
public class DefaultUserDetailsService implements UserDetailsService {

    @Autowired
    UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository
            .findByEmail(username)
            .orElseThrow(() -> new UsernameNotFoundException("user not found"));

        Collection<GrantedAuthority> authority =
            Arrays.stream(user.getRoles().split(","))
                .map((role) -> new SimpleGrantedAuthority(role))
                .collect(Collectors.toList());

        return new org.springframework.security.core.userdetails.User(
            user.getEmail(),
            user.getPassword(),
            authority
        );
    }
    
}

UserDetailsService の実装例です。loadUserByUsername メソッドを継承し、サービスのユーザー管理と Spring Security の UserDetails クラスの橋渡しをします。

引数で渡される username は後述する WebSecurityConfigurerAdapter で ログイン画面の HTMLテンプレートの、どのフォーム要素にマッピングするか設定します。引数の名前は username ですが、設定次第でメールアドレスにもできるし、サービスが定義する独自のユーザーID にも設定できます。要するに、ユーザーを識別するユニークな文字列なら何でも良いということです。今回はメールアドレスをユーザーのキーにするため、実装はメールアドレスからユーザーを検索します。

WebSecurityConfigurerAdapter を設定する

さて、ここまでで認証・認可・ユーザ登録をするためのパーツが揃いました。Spring Security の設定をしていきます。

@EnableWebSecurity
@Configuration
public class DemoSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {
    
    // (1) 自前で定義したUserDetailsService
    @Autowired
    UserDetailsService userDetailsService;

    // (2) パスワードハッシュ化する実装をBean登録
    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // (3) アクセス制御
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers("/login").permitAll()
                .antMatchers("/register").permitAll()
                .anyRequest().authenticated();

        http
            .formLogin()
                .usernameParameter("email")
                .passwordParameter("password")
                .loginPage("/login")
                .loginProcessingUrl("/login")
                .failureUrl("/login?error=1")
                .defaultSuccessUrl("/top");
    }

    // (4) 認証マネージャの構築
    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
            .userDetailsService(userDetailsService)
            .passwordEncoder(passwordEncoder());
    }
}

認証マネージャの定義

コードの (1) + (2) + (4) が該当します。

ログイン画面からメールアドレスとパスワードが送られて来たときに、メールアドレスをキーにユーザーを特定し、パスワードを検証する必要があります。Spring Security に認証処理を受け持ってもらうにあたり、それぞれの振る舞いを実装者の私が指定しなければなりません。

・メールアドレスからユーザーを特定
自前で用意したDefaultUserDetailsServiceクラスを指定します。

・パスワードの検証
画面からの入力値をBCryptPasswordEncoder でハッシュ化した文字列で突合させます。

アクセス制御の定義

コードの (3) が該当します。

WebSecurityConfigurerAdapter の configure メソッドを Override すると、URL毎のアクセス制限やログインに関する設定ができます。このメソッドは分かりやすいと思います。

http
    .authorizeRequests()
        .antMatchers("/login").permitAll()
        .antMatchers("/register").permitAll()
        .anyRequest().authenticated();

ログイン画面とユーザー登録画面( /login と /register )は、誰でもアクセスできる一般公開URLとしています。それ以外のURLは認証済みでないとアクセスできないようにします。認証が必要な画面URLが直打ちされた場合、強制的にログイン画面に戻されるようになります。

http
    .formLogin()
        .usernameParameter("email")
        .passwordParameter("password")
        .loginPage("/login")
        .loginProcessingUrl("/login")
        .failureUrl("/login?error=1")
        .defaultSuccessUrl("/top");

後半部分では、ログインの画面制御を指定しています。

usernameParameter()、 passwordParameter()

ログイン画面のHTMLに実装するログインフォームの input タグの name と合わせます。Viewの実装は後で登場しますが、以下のようになります。Spring Security の configure メソッドと HTML を見比べて、「email」「password」が input の name属性と合致しているか確認します。

<form method="POST" action="#" th:action="@{/login}">
    <div>
        <p>メールアドレス</p>
        <p><input type="text" name="email" /></p>
    </div>
    <div>
        <p>パスワード</p>
        <p><input type="password" name="password" /></p>
    </div>
    <div>
        <button type="submit">ログイン</button>
    </div>
</form>

・loginPage()、loginProcessingUrl()、failureUrl()、defaultSuccessUrl()

ログイン画面のURLとログイン成功時、失敗時の遷移先URLを指定します。

ユーザー登録画面を実装する

ユーザー登録画面

ユーザー登録画面を作ります。メールアドレスとパスワードのみのシンプルなフォームです。登録ボタン押下時に POST された入力内容をサーバで受け取り、データベースにエントリを作ります。

src/main/resources/templates/register.html
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8" />
</head>
<body>
    <form method="POST" action="#" th:action="@{/register}" th:object="${registerForm}">
        <div>
            <p>メールアドレス</p>
            <p><input type="text" name="email" th:value="*{email}" /></p>
            <p th:if="${#fields.hasErrors('email')}" th:errors="*{email}">
                メールアドレスに関するエラー
            </p>
        </div>
        <div>
            <p>パスワード</p>
            <p><input type="password" name="password" th:value="*{password}" /></p>
            <p th:if="${#fields.hasErrors('password')}" th:errors="*{password}">
                パスワードに関するエラー
            </p>
        </div>
        <div>
            <button type="submit">登録</button>
        </div>
    </form>
</body>
</html>

Thymeleaf の変数や制御文が組み込まれています。実装のポイントは以下です。

・th:action
POST先URLを指定します。

・th:object
画面から POST する内容を Controller で受け取るための Java Bean とのマッピング。

・th:value
Java Bean のフィールドとのマッピング。

・th:if、th:errors
入力Validationの結果エラー表示するための制御用。今回は、メールアドレスとパスワードの入力値の空チェックのみ実装します。空のまま POST すると、フォームの下にエラーメッセージが表示されるようにします。

次に、画面のフォーム要素にマッピングする Java Bean を定義します。

RegisterForm.java
public class RegisterForm {

    @NotBlank
    private String email;

    @NotBlank
    private String password;

    public RegisterForm() {
    }

    // setter, getters ...

各フィールド名を HTML の th:value で指定した値と一致させます。また、フォーム POST 時に空チェックを行いたいので @NotBlank アノテーションを張っておき、Controller側で実装する Validation 処理の準備をしておきます。

最後に Controller の実装です。

Controller
@Controller
public class SampleController {

    @Autowired
    UserService userService;

    // 登録画面
    @GetMapping("/register")
    public String register(Model model) {
        model.addAttribute("registerForm", new RegisterForm());
        return "register";
    }

    // 登録画面の POST 受け付け
    @PostMapping("/register")
    public String postRegister(
        @ModelAttribute @Valid RegisterForm registerForm,
        BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            // エラーがある場合は、エラーメッセージを表示したいので
            // View をレンダリングする。
            return "register";
        }

        userService.createUser(
            registerForm.getEmail(),
            registerForm.getPassword(),
            new String[] { "ADMIN", "USER" });

        // ユーザー登録処理が成功したらログイン画面にリダイレクトする。
        return "redirect:/login";
    }
}

Get 用 Controller では、View に RegisterForm クラスのインスタンスを渡します。model.addAttribute() を使って、任意の値やオブジェクトを View に渡すことが出来ます。

初見だと Controller の引数の model はどこから湧いて来るのか原理が分からず混乱しますが、これは Spring MVC の機能です。変数を Controller から View に渡したいときは、このようなお作法で実装すると覚えておけば大丈夫です。

registerForm というキー名で View に変数を渡したので、先程実装した HTML の th:object で画面の form タグ に紐付けが行われます。

Post 用 Controller では、フォームの POST 内容をメソッドの引数で受け取り、UserSerivce#createUser() でデータベースへの登録を行います。こちらもメソッドの引数が初見殺しですね。registerForm で画面からの内容を Validation しつつ受け取り、bindingResult に Validation エラーの内容が格納(エラーがある場合は)されて渡されます。

ユーザー登録は ADMIN と USER の権限付きで登録しておきます。認証だけであれば権限は不要ですが、今回は後で権限毎に画面表示を出し分けるなどの認可の処理を実装する際に必要です。

ここまででユーザー登録機能の実装が完成です!フォームからユーザー登録すると、データベースにエントリが作られます。

mugidb=# \x
Expanded display is on.

mugidb=# select * from users;
-[ RECORD 1 ]----------------------------------------------------------
id       | 3
email    | hoge@mugicha.com
password | $2a$10$OkJDXDALYl4wxluFDgpQIOreYQ8TomtfZyJG2MT57weFWNngp44Qu
roles    | ADMIN,USER

ログイン画面を実装する

ログイン画面

ログイン画面を実装します。メールアドレスとパスワードで認証させます。事前に UserDetailsService や WebSecurityConfigurerAdapter の実装は終わっているので、ここから先は簡単です。

src/main/resources/templates/login.html
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8" />
</head>
<body>
    <form method="POST" action="#" th:action="@{/login}">
        <div>
            <p>メールアドレス</p>
            <p><input type="text" name="email" /></p>
        </div>
        <div>
            <p>パスワード</p>
            <p><input type="password" name="password" /></p>
        </div>
        <div>
            <button type="submit">ログイン</button>
        </div>
    </form>
</body>
</html>

View です。フォームの name属性 をさきほど WebSecurityConfigurerAdapter に実装したusernameParameter()、passwordParameter() と一致させる必要があるのでご注意ください。

    @GetMapping("/login")
    public String login() {
        return "login";
    }

Controller にログイン画面用のメソッドを追記します。POST については Spring Security の方で処理してくれるので実装不要です。WebSecurityConfigurerAdapter で loginProcessingUrl() や defaultSuccessUrl() で画面遷移のポリシーを指定済みで、認証処理は AuthenticationManagerBuilder で構築した認証マネージャがよしなに処理してくれます。

ここまででログイン画面の実装が完了です。

権限別に表示を出し分ける

最後に、ログイン成功後のリダイレクト先となるトップ画面を実装します。トップでは Spring Security の認可の機能を実装してみます。ADMIN権限にしか表示させない要素、USER権限にしか表示させない要素を実装して動作確認します。

Controllerに追記するコード
    @GetMapping("/top")
    public String top() {
        return "top";
    }
トップ画面のView
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org"
  xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
    <meta charset="UTF-8" />
</head>
<body>
    <div sec:authorize="hasAuthority('ADMIN')">
        管理者のみ見えるdivブロックです。
    </div>
    <div sec:authorize="hasAuthority('USER')">
        ユーザーのみ見えるdivブロックです。
    </div>
    <div>
        誰でも見えるdivブロックです。
    </div>
</body>
</html>

sec:authorize=hasAuthority(ロール名) で特定の権限に対してのみ表示するように制御できます。

ADMIN,USER両方の権限を持つ場合の表示

UserService#createUser() の実装ではユーザー登録時に ADMIN と USER 両方の権限を付けてデータベースに保存しているので、全ての div ブロックが表示されます。

では、ログインユーザーの権限を USER のみに修正してみます。画面にユーザーの権限を変更する機能を実装していないので、データベースを直接変更します。

mugidb=# UPDATE users SET roles = 'USER' WHERE id = 3;
UPDATE 1

mugidb=# SELECT * FROM users WHERE id = 3;
-[ RECORD 1 ]----------------------------------------------------------
id       | 3
email    | hoge@mugicha.com
password | $2a$10$OkJDXDALYl4wxluFDgpQIOreYQ8TomtfZyJG2MT57weFWNngp44Qu
roles    | USER

再度ログインします。

USER権限のみの場合の表示

管理者しか見えない div ブロックが表示されなくなりました。

実際のビジネスアプリケーションで画面のメニューの出し分けを権限毎に制御したい要求がよく出てきます。Spring Boot を使う場合は上記のテクニックで実装します。

まとめ

本記事では Spring Security の基礎を紹介しました。いかがでしたでしょうか?

Spring の作法が随所随所に出てきたので、慣れるまでとっつきにくいと感じると思います(Spring に限らず、そもそも Java の思想は規約をベースとした実装を強制する側面が強い)

Spring Security を覚えておくとエンタープライズ開発界隈での活躍の幅も広がると思います。OpenID Connect や OAuth2.0 への適合もできるので、AWS Cognito や Keycloak などの認証コンポーネントとの相性もバッチリです。

それでは、今回はここまでとします。良き Java ライフを!


Web系エンジニアでPython好き。バックエンド/フロントエンド問わずマルチな方面でエンジニアリングしています。