Spring Securityを用いた認証と認可を説明します。
多くのアプリケーションで必要となる認証(ログイン)処理ですが、Spring BootではSpring Securityという仕組みを準備してくれています。これを使えば、少ないコードで安全な認証と認可を実装可能です。
なお、Spring Security関係の内容はSpring Bootのv2系からv3系になる際に大きく変わりました。v2系の情報が知りたい場合は、こちらの記事を御覧ください。
この記事はSpring Boot入門:Spring Data JPAでデータベース操作の続きとなります。
この記事のソースコード
この記事のソースコードはGithubに公開しています。
GithubからSpring BootプロジェクトをEclipseにインポートする方法は次の記事を参考にしてください。
Spring Bootプロジェクトを作成
前回から更に追加してSpring Securityを依存関係に追加します。すべてのリストは以下のとおりです。
- Spring Boot DevTools
- lombok
- Validation
- Spring Data JPA
- H2 Database
- Thymeleaf
- Spring Web
- Spring Security
この辺の作業は既に問題ないでしょう。
Spring Securityの設定
Spring Securityを使うためにはいくつか設定が必要になります。XMLファイルで設定を行うこともできるようですが、今回はJavaのコードで設定をしましょう。
package blog.tsuchiya.tutorial.step7.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import lombok.RequiredArgsConstructor;
/**
* Spring Securityの設定を行うクラス。
* 1,ConfigurationとEnableWebSecurityアノテーションを付ける
* 2,SecurityFilterChainを返すメソッドにBeanアノテーションを付ける
* の2つが必要。
* パスワードをハッシュ化する場合は
* 3.PasswordEncoderを返すメソッドにBeanアノテーションを付ける
* も行う必要あり。
*
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
/**
* 基本的な設定はここで行う。
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
// アクセス権限に関する設定
http
.authorizeHttpRequests(
// /はアクセス制限をかけない
(requests) -> requests.requestMatchers("/").permitAll()
// /adminはADMINロールを持つユーザだけアクセス可能
.requestMatchers("/admin").hasRole("ADMIN")
// /userはUSERロールを持つユーザだけアクセス可能
.requestMatchers("/user").hasRole("USER")
// それ以外のページは認証が必要
.anyRequest().authenticated()
).formLogin((form) -> form
// ログインを実行するページを指定。
// この設定だと/にPOSTするとログイン処理を行う
.loginProcessingUrl("/")
// ログイン画面の設定
.loginPage("/")
// ログインに失敗した場合の遷移先
.failureUrl("/")
// ユーザIDとパスワードのname設定
.usernameParameter("username")
.passwordParameter("password")
// ログインに成功した場合の遷移先
.defaultSuccessUrl("/common", true)
).logout((form) -> form
// ログアウト処理を行うページ指定、ここにPOSTするとログアウトする
.logoutUrl("/logout")
// ログアウトした場合の遷移先
.logoutSuccessUrl("/")
);
// @formatter:on
return http.build();
}
/**
* パスワードのハッシュ化を行うアルゴリズムを返す
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Spring Securityの設定を行うクラスでは、
- ConfigurationとEnableWebSecurityアノテーションを付ける
- SecurityFilterChainを返すメソッドにBeanアノテーションを付ける
の2つが必要となります。
更に、これはEnableWebSecurityアノテーションを付けたクラス内でなくても良いのですが
- PasswordEncoderを返すメソッドにBeanアノテーションを付ける
もまとめておきます。PasswordEncoderはパスワードのハッシュ化などを行うインタフェースで、なくてもシステムは動きます。しかし、一般的にはDBに保存するパスワードはハッシュ化するのでこれもほぼ必須だと思ってください。BeanアノテーションをつけたメソッドでPasswordEncoderを実装したクラスのインスタンスを返すと、自動的にパスワードのハッシュ化をSpring Bootがしてくれます。
Beanアノテーションはアノテーションを付けたメソッドの戻り値をDIコンテナに保存してください、という目印です。これをつけておくと、SecurityFilterChainやPasswordEncoderをDIしている他のコンテナで自動的に利用可能になります。
Spring Boot入門:ServiceとDI(依存性の注入)では自分で作ったクラスをDIする方法を説明していましたが、Beanアノテーションは既存のクラスのインスタンスをDIする場合に利用します。
securityFilterChainメソッド
ログインに関する諸々の情報を設定しているのがsecurityFilterChainメソッドです。大体コメントのとおりですが、http.authorizeHttpRequestsで認証と認可に関する設定を、http.formLoginでログインに関する設定を、http.logoutでログアウトに関する設定を行っています。
authorizeHttpRequestsではロールごとの認可設定を以下のように行っています
.requestMatchers("/admin").hasRole("ADMIN")
認可設定はrequestMatchersメソッドで対象のURLを指定して、その後のメソッド(permitAllとかhasRoleなど)でどの様な条件でアクセス可能か設定しました。
requestMatchersではワイルドカードも指定可能で、「*」は任意の1階層、「**」は任意の複数階層を意味します。例えば「/user/**」と指定すると、「/user/1/2/3」や「/user/a/b/c」などが対象となるわけです。
passwordEncoderメソッド
passwordEncoderメソッドではパスワードのハッシュ化アルゴリズムを決定します。
今回利用するハッシュ化アルゴリズムはBCryptです。特に自分で実装する必要はなく、BCryptPasswordEncoderのインスタンスを生成するだけなのであまり意識しなくても大丈夫でしょう。
BCryptを利用するので、データベースのパスワードにはハッシュ化したパスワードを登録してあります。例えばですが、「password」という値はハッシュ化すると「$2a$08$OqGyRm3IudPT3rOC9HlvbuHDfdKZqBxXaghzU.pn5xHLt/oIITHSK」です。
ハッシュ値の計算はこのサイトを使いました。
パスワードをそのままデータベースに保存すると、万が一データが外に漏れると重大な問題となりかねません。それを緩和するために、ハッシュ化を行うのはセキュリティ上の常識となっています。
ユーザー情報はどこにあるか
WebSecurityConfigクラスの中にはどんなIDとパスワードならログイン可能なのかという情報が全くありません。ユーザーに関する情報については、UserDetailsServiceを実装したUserDetailsServiceImplで管理することになります。
また、利用するDBのテーブルは自動でSpring Securityが作ってくれます。ただ、今回はどんなテーブルを作るのか明示的にするために、テーブルの自動生成はしないようにしてsrc/main/resources/schema.sqlで明示的に作成するようにしました。
もし自動生成されることを確認したいのなら、application.propertiesで
spring.jpa.hibernate.ddl-auto=none
この行を削除し、src/main/resources/schema.sqlもファイルごと削除してみてください。それでもユーザーのログイン機能は正常に動きます。
ユーザ情報の取得
ユーザ情報をデータベースなどから取得する機能はUserDetailsServiceを実装して作成します。
package blog.tsuchiya.tutorial.step7.service;
import java.util.HashSet;
import java.util.Set;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import blog.tsuchiya.tutorial.step7.repository.UserRepository;
import lombok.RequiredArgsConstructor;
/**
* Spring Bootで使うユーザ情報の取得を行うクラス。
*/
@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
/**
* ユーザ情報を取得する。
* もし引数のユーザ情報が存在しなかったら、UsernameNotFoundExceptionを投げる。
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
var user = this.userRepository.findByName(username);
// もしユーザが見つからなかった場合、例外を投げる
if (user == null) {
throw new UsernameNotFoundException("User [" + username + "] not found.");
}
return createUser(user);
}
/**
* DBから取得したユーザ情報をSpring Bootのユーザ情報に変更する。
* @param user DBから取得したユーザ情報
* @return Spring Bootのユーザ情報
*/
private UserDetails createUser(blog.tsuchiya.tutorial.step7.model.User user) {
Set<GrantedAuthority> auth = new HashSet<>();
// ロールにはROLE_というプレフィックスを付ける
auth.add(new SimpleGrantedAuthority("ROLE_" + user.getRole()));
User userDetails = new User(user.getName(), user.getPassword(), auth);
return userDetails;
}
}
Spring Boot v3系では、UserDetailsServiceを実装したクラスをDIコンテナに保存すると(@Controller、@Service、@Componentを自作クラスにつけるとか、@Beanをつけたメソッドの戻り値にするとか)自動的にそのインスタンスを使ってログイン処理を行ってくれます。
実装上重要なのはServiceアノテーションを付けてDIコンテナに保存すること、ログイン情報として与えられたユーザIDでデータベースを検索した際にユーザが見つからなかったらUsernameNotFoundExceptionを投げることでしょうか。
Spring BootでのUserはロールという値を持つことができます。createUserメソッドで行っているように、Userインスタンスを作る際にGrantedAuthorityのSetを渡す必要があります。
この際、なぜかロールに「ROLE_」というプレフィックスを付ける必要があることに注意しましょう。
ロールごとの表示設定
WebSecurityConfigではロールごとにアクセスできるページを設定しましたが、Thymeleafでロールごとに表示する情報を変更することもできます。
src/main/resources/templates/common.html
<!DOCTYPE html>
<!--/* xmlns:secも定義しておく */-->
<html lang="ja" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<meta charset="UTF-8">
<title>共通</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/css/bootstrap.min.css"
rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-sm navbar-light bg-light"
th:fragment="header">
<div class="container-fluid">
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link active"
aria-current="page" th:href="@{/common}">共通ページ</a></li>
<!--/* 管理者ロールだったら表示 */-->
<li class="nav-item" sec:authorize="hasRole('ADMIN')"><a
class="nav-link active" aria-current="page" th:href="@{/admin}">管理者ページ</a></li>
<!--/* ユーザロールだったら表示 */-->
<li class="nav-item" sec:authorize="hasRole('USER')"><a
class="nav-link active" aria-current="page" th:href="@{/user}">ユーザページ</a></li>
</ul>
<form class="d-flex" method="post" th:action="@{/logout}">
<button class="btn btn-outline-success" type="submit">ログアウト</button>
</form>
</div>
</div>
</nav>
<main>
<h1>共通ページ</h1>
</main>
</body>
</html>
htmlタグに「xmlns:sec=”http://www.thymeleaf.org/extras/spring-security”」という属性を追加した上で、sec:authorizeという属性を利用します。
「sec:authorize=”hasRole(‘ADMIN’)”」はADMINロールを持つユーザにだけ表示、「sec:authorize=”hasRole(‘USER’)”」はUSERロールを持つユーザにだけ表示という意味です。
その他のコード
その他、アプリケーションを動かすために必要なコード類です。
package blog.tsuchiya.tutorial.step7.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class MainController {
@GetMapping("/")
public String index() {
return "index";
}
@GetMapping("/common")
public String common() {
return "common";
}
@GetMapping("/user")
public String user() {
return "user";
}
@GetMapping("/admin")
public String admin() {
return "admin";
}
}
package blog.tsuchiya.tutorial.step7.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
@Table(name="USER_DATA")
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotNull
@Size(max=255)
private String name;
@NotNull
@Size(max=255)
private String password;
@NotNull
@Size(max=10)
private String role;
}
package blog.tsuchiya.tutorial.step7.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import blog.tsuchiya.tutorial.step7.model.User;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
/**
* 名前でユーザを検索。
* 命名規則に従ってインタフェースでメソッドを定義すると、
* JPAが実装を行ってくれる。
* @param name 検索するユーザ名
* @return ユーザ名に対応するユーザ
*/
User findByName(String name);
}
src/main/resources/templates/index.html
Spring Securityがログイン失敗したときに返すエラーメッセージを表示するロジックがあります。あんまり重要ではないと思うので、説明は特にしません。
<!DOCTYPE html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>ログイン</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/css/bootstrap.min.css"
rel="stylesheet">
</head>
<body>
<div class="text-center">
<h1>Login</h1>
<form method="post" th:action="@{/}">
<!--/* エラーメッセージ */-->
<p th:if="${session['SPRING_SECURITY_LAST_EXCEPTION']} != null"
th:text="${session['SPRING_SECURITY_LAST_EXCEPTION'].message}">
ログインエラーメッセージ</p>
<label>ユーザーID</label> <input type="text" name="username" /><br /> <br />
<label>パスワード</label> <input type="password" name="password" /><br />
<br />
<button class="btn btn-primary" type="submit">ログイン</button>
</form>
</div>
</body>
</html>
src/main/resources/templates/admin.html
<!DOCTYPE html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>管理者</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/css/bootstrap.min.css"
rel="stylesheet">
</head>
<body>
<th:block th:replace="common :: header" />
<main>
<h1>管理者ページ</h1>
</main>
</body>
</html>
src/main/resources/templates/user.html
<!DOCTYPE html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>ユーザ</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/css/bootstrap.min.css"
rel="stylesheet">
</head>
<body>
<th:block th:replace="common :: header" />
<main>
<h1>ユーザページ</h1>
</main>
</body>
</html>
src/main/resources/application.properties
# H2の設定
# 利用するドライバ、H2を使う場合はこの値で固定
spring.datasource.driver-class-name=org.h2.Driver
# インメモリで使い、データベース名はtestdbとする
# デフォルト設定だとテーブル名やカラム名が大文字小文字を区別してしまうため、
# CASE_INSENSITIVE_IDENTIFIERS=TRUEをつけて区別しないようにしている
spring.datasource.url=jdbc:h2:mem:testdb;CASE_INSENSITIVE_IDENTIFIERS=TRUE
# ここで指定したユーザが作成される
spring.datasource.username=sa
# 上で指定したユーザのパスワードを指定
spring.datasource.password=
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.sql.init.mode=always
# data.sqlをschema.sqlの後に読み込むように設定
spring.jpa.defer-datasource-initialization=true
## JPAが自動でテーブルを作成しないように
spring.jpa.hibernate.ddl-auto=none
src/main/resources/data.sql
パスワードには「password」をBCryptでハッシュ化した値を入れています。ハッシュ値の計算はこのサイトを使いました。
INSERT INTO USER(NAME, PASSWORD, ROLE) VALUES ('user', '$2a$08$OqGyRm3IudPT3rOC9HlvbuHDfdKZqBxXaghzU.pn5xHLt/oIITHSK', 'USER');
INSERT INTO USER(NAME, PASSWORD, ROLE) VALUES ('admin', '$2a$08$OqGyRm3IudPT3rOC9HlvbuHDfdKZqBxXaghzU.pn5xHLt/oIITHSK', 'ADMIN');
src/main/resources/schema.sql
DROP TABLE IF EXISTS USER;
CREATE TABLE USER (
ID IDENTITY NOT NULL PRIMARY KEY,
NAME VARCHAR(255) NOT NULL,
PASSWORD VARCHAR(255) NOT NULL,
ROLE VARCHAR(10) NOT NULL
);
実際に動かしてみる
全部のコードを入力したら(またはGithubからソースをダウンロードしたら)実際に動かしてみましょう。Spring Bootアプリケーションとして実行したら、http://localhost:8080/にアクセスしましょう。
まずはUSERロールを持つuserでログインしてみます。ログインIDは「user」、パスワードは「password」です。
ログインすると設定したとおり「/common」を表示します。このページはログインしたユーザなら全員が見ることができるページです。
ヘッダ部分には共通ページとユーザページへのリンクのみが表示されています。管理者ページへのリンクはuserがADMINロールを持っていないので表示されません。
URLに直接管理者のみが参照できるURL(/admin)を指定すると、403エラーが発生します。
まとめ:認証と認可はSpring Securityに任せる
Spring Securityの説明をしてきました。
多くのWebアプリケーションが必要とする認証と認可ですが、必要となる多くの機能をSpring Securityが提供してくれます。ちょっと設定するだけでログインなどが利用可能なのはありがたい限りです。
今回の記事では基本的な使い方だけを説明していますが、これだけでも十分に導入する価値があるでしょう。
ここまででSpring Boot入門は終了です。お疲れさまでした。
この記事やこれまでの説明でわからないところがあったら、Twitterやお問い合わせフォームから連絡をください。最近すっかり忘れていましたが、コメント欄もあるのでそちらでも大丈夫です。
Controllerをユニットテストに続きます。
コメント