Spring Securityを用いた認証と認可を説明します。
多くのアプリケーションで必要となる認証(ログイン)処理ですが、Spring BootではSpring Securityという仕組みを準備してくれています。これを使えば、少ないコードで安全な認証と認可を実装可能です。
なお、この記事は少し古いSpring Bootのv2系に関する記事になります。もし最新のv3系に関する情報が欲しい場合は、こちらの記事を御覧ください。
この記事はSpring Boot入門:Spring Data JPAでデータベース操作の続きとなります。
この記事のソースコード
この記事のソースコードはGithubに公開しています。
GithubからSpring BootプロジェクトをEclipseにインポートする方法は次の記事を参考にしてください。
なお、このページは古い情報を記載している関係上、最新のコミットではなく古いものにリンクを張っています。なので、git cloneする際にタグ指定するか、最新のコミットを取得したあとにタグ指定してcheckoutする必要があります。
# タグ指定してclone
git clone https://github.com/gsg0222/spring-boot-tutorial-step7.git -b v2.6.3
# cloneしたあとにタグ指定してcheckout
git checkout v2.6.3
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.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import lombok.RequiredArgsConstructor;
/**
* Spring Securityの設定を行うクラス。
* 1,ConfigurationとEnableWebSecurityアノテーションを付ける
* 2,WebSecurityConfigurerAdapterを継承する
* の2つが必要。
*
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
/** ログイン処理を行うインスタンスをDI */
private final UserDetailsService uds;
/**
* 基本的な設定はここで行う。
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
// @formatter:off
// アクセス権限に関する設定
http
// /はアクセス制限をかけない
.authorizeRequests().antMatchers("/").permitAll()
// /adminはADMINロールを持つユーザだけアクセス可能
.antMatchers("/admin").hasRole("ADMIN")
// /userはUSERロールを持つユーザだけアクセス可能
.antMatchers("/user").hasRole("USER")
// それ以外のページは認証が必要
.anyRequest().authenticated();
// ログインに関する設定
http
.formLogin()
// ログインを実行するページを指定。
// この設定だと/にPOSTするとログイン処理を行う
.loginProcessingUrl("/")
// ログイン画面の設定
.loginPage("/")
// ログインに失敗した場合の遷移先
.failureUrl("/")
// ユーザIDとパスワードのname設定
.usernameParameter("username")
.passwordParameter("password")
// ログインに成功した場合の遷移先
.defaultSuccessUrl("/common", true);
// ログアウトに関する設定
http
.logout()
// ログアウト処理を行うページ指定、ここにPOSTするとログアウトする
.logoutUrl("/logout")
// ログアウトした場合の遷移先
.logoutSuccessUrl("/");
// @formatter:on
}
/**
* パスワードのハッシュ化を行うアルゴリズムを返す
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* ログイン処理の設定
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// ログイン処理時のユーザー情報をDBから取得する
auth.userDetailsService(uds).passwordEncoder(passwordEncoder());
}
}
Spring Securityの設定を行うクラスでは、
- ConfigurationとEnableWebSecurityアノテーションを付ける
- WebSecurityConfigurerAdapterを継承する
の2つが必要となります。
大体コード上のコメント通りですが、configureメソッドでログインとログアウト、ロールごとの認可設定を行います。
.antMatchers("/admin").hasRole("ADMIN")
認可設定はantMatchersメソッドで対象のURLを指定して、その後のメソッド(permitAllとかhasRoleなど)でどの様な条件でアクセス可能か設定しました。
antMatchersではワイルドカードも指定可能で、「*」は任意の1階層、「**」は任意の複数階層を意味します。例えば「/user/**」と指定すると、「/user/1/2/3」や「/user/a/b/c」などが対象となるわけです。
/**
* パスワードのハッシュ化を行うアルゴリズムを返す
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* ログイン処理の設定
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// ログイン処理時のユーザー情報をDBから取得する
auth.userDetailsService(uds).passwordEncoder(passwordEncoder());
}
passwordEncoderメソッドではパスワードのハッシュ化アルゴリズムを決定。configureメソッドではユーザ情報の取得方法と、パスワードのハッシュ化アルゴリズムを指定しました。
今回利用するハッシュ化アルゴリズムはBCryptです。特に自分で実装する必要はなく、BCryptPasswordEncoderを利用するだけなのであまり意識しなくても大丈夫でしょう。
BCryptを利用するので、データベースのパスワードにはハッシュ化したパスワードを登録してあります。例えばですが、「password」という値はハッシュ化すると「$2a$08$OqGyRm3IudPT3rOC9HlvbuHDfdKZqBxXaghzU.pn5xHLt/oIITHSK」です。
ハッシュ値の計算はこのサイトを使いました。
パスワードをそのままデータベースに保存すると、万が一データが外に漏れると重大な問題となりかねません。それを緩和するために、ハッシュ化を行うのはセキュリティ上の常識となっています。
この設定だけで、ログインとログアウト、ロールごとにアクセスできるページなどが指定できました。
ユーザ情報の取得
ユーザ情報をデータベースなどから取得する機能は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;
}
}
重要なのはServiceアノテーションを付けること、ログイン情報として与えられたユーザ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 javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.validation.constraints.Size;
import com.sun.istack.NotNull;
import lombok.Data;
@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とする
spring.datasource.url=jdbc:h2:mem:testdb
# ここで指定したユーザが作成される
spring.datasource.username=sa
# 上で指定したユーザのパスワードを指定
spring.datasource.password=
# data.sqlをschema.sqlの後に読み込むように設定
spring.jpa.defer-datasource-initialization=true
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をユニットテストに続きます。
コメント