はじめに
前回の記事では、バリデーションの追加とパスワードをデータベースに登録する際に暗号化する処理についてお話をしてきました。今回は認証されたユーザーに権限を与える処理についてお話をしたいと思います。ファイル構成
.
├── .gradle
├── .idea
├── build
├── gradle
└── src
├── main
│ ├── java
│ │ └── com
│ │ └── example
│ │ └── practice
│ │ ├── config
│ │ │ │
│ │ │ ├──
│ │ │ ├── PasswordEncoderConfig.java
│ │ │ └── SecurityConfig.java
│ │ ├── web
│ │ │ ├── user
│ │ │ │ ├── UserController.java
│ │ │ │ └── userForm.java
│ │ │ ├── order
│ │ │ │ ├── OrderController.java
│ │ │ │ └── OrderForm.java
│ │ │ └── IndexController.java
│ │ └── domain
│ │ ├── authentication
│ │ │ ├──
CustomUserDetails.java
│ │ │ ├── CustomUserDetailsService.java
│ │ │ ├── User.java
│ │ │ ├── UserService.java
│ │ │ └── UserRepository
│ │ └── order
│ │ ├── OrderEntity.java
│ │ ├── OrderService.java
│ │ └── OrderRepository.java
│ │
│ └── resources
│ ├── static
│ ├── templates
│ │ ├── error
│ │ └── error.html
│ │ ├── users
│ │ │ ├── creationForm.html
│ │ │ └── list.html
│ │ ├── order
│ │ │ ├── delete_confirmation.html
│ │ │ ├── detail.html
│ │ │ ├── form.html
│ │ │ └── list.html
│ │ ├── login.html
│ │ └── index.html
│ ├── schema.sql
│ ├── data.sql
│ └── application.properties
└── test
.gitignore
build.gradle
gradlew.bat
HELP.md
settings.gradle
認可
今回は認証されたユーザーに権限を与える処理を追加していきますが、このことを『認可』と言います。認証がユーザーの正当性を確認するのに対して、認可はユーザーが行える操作をアクセス制御をすることです。
特定のURLへのアクセスを限定する
アプリケーション内の特定のURLへのアクセスを制御することで、ユーザーが必要な権限を持っている場合にのみ、そのページやリソースにアクセスできるようになります。そうすることで、一部の機能やページを特定のユーザーグループにのみ公開することができます。
ユーザーとロールを紐づける
ユーザーには一般的にロールが割り当てられます。
ロールはユーザーの権限をグループ化し、特定のアクセス権を持たせるために必要なものです。
今回の実装では、管理者、ユーザー、ゲストなどのロールなどが与えられた場合、例えば、ユーザーがログインすると、そのユーザーに割り当てられたロールに基づいて、アクセス権限が与えられるように追加をしていきます。
データベースに権限を追加する
まずは、データベースに権限を追加していきます。 ユーザー情報テーブルと初期データに権限を追加しましょう。やりたい実装としては、以下の画像のように、権限のカラムを追加することです。
では、まず、ユーザー情報テーブルにauthorityのカラムを追加します。
--ユーザー情報テーブル
CREATE TABLE USERS (
username varchar(256) not null primary key,
password varchar(256) not null,
authority enum('ADMIN','USER') not null
);
次にINSERT文も以下のように書き換えていきましょう。
INSERT INTO USERS (username, password,authority) values ('taro', '91b80650ca9c0929e67863c1dc9fac87540bec25fda27fe60dfe9d7abd4395dd5b8aaf5b9b55931b','ADMIN');
INSERT INTO USERS (username, password,authority) values ('yamada', '014c92c83017287090931c8548c3a4cc071732a31b5bd5eb6aa88f059e92d90fef24aca9c9276a5d','USER');
次に、データベースをユーザー情報クラスに反映させます。
package com.example.practice.domain.authentication;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class User {
private String username;
private String password;
private Authority authority;
//ユーザーの権限を表すための列挙型フィールド
public enum Authority {
ADMIN, USER
}
}
現在の仕様では、権限をCollections.emptyList()として、空のリストを返す処理をしていますが、GrantedAuthorityのインターフェースを使い、ユーザーの権限情報を正しく利用できるようにしていきましょう。
package com.example.practice.domain.authentication;
import lombok.RequiredArgsConstructor; // lombokのRequiredArgsConstructorをインポート
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails; // Spring SecurityのUserDetailsをインポート
import org.springframework.security.core.userdetails.UserDetailsService; // Spring SecurityのUserDetailsServiceをインポート
import org.springframework.security.core.userdetails.UsernameNotFoundException; // ユーザーが見つからなかった場合の例外をインポート
import org.springframework.stereotype.Service; // SpringのServiceアノテーションをインポート
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import java.util.Collections;
import java.util.List;
@Service // サービスクラスであることを示す@Serviceアノテーション
@RequiredArgsConstructor // コンストラクタインジェクションのためのアノテーション
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository; // UserRepositoryの依存を注入
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByUsername(username) // ユーザー名でユーザー情報を検索
.map( // Optional型からカスタムなユーザー情報を作成
user -> new CustomUserDetails(
user.getUsername(),
user.getPassword(),
toGrantedAuthorityList(user.getAuthority()) //追加
)
)
.orElseThrow( // ユーザーが見つからない場合は例外をスロー
() -> new UsernameNotFoundException(
"ユーザー情報の認証に失敗しました。 (username = '" + username + "')"
)
);
}
private List<GrantedAuthority> toGrantedAuthorityList(User.Authority authority) {
return Collections.singletonList(new SimpleGrantedAuthority(authority.name()));
}
}
以上の処理で、h2コンソールを開くと、データベースに権限のカラムを追加されていることがわかります。
権限によって表示する画面を制御しよう
次に、権限によって表示する画面を制御するために、Thymeleaf内でSpring Securityの機能を使用できるために、「xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5"
」を追加し、ユーザー一覧を「ADMIN」の権限のみにしたいと思います。
以下のようにするために、index.htmlを以下にします。
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>トップページ | 業務用アプリケーション</title>
<!-- Bootstrap CSS link -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
</head>
<body>
<div class="container mt-4">
<!-- ヘッダー部分 -->
<header class="mb-4 bg-light p-3 rounded">
<div class="d-flex justify-content-between align-items-center">
<h1 class="mb-0"><a href="../index.html" th:href="@{/}" class="text-dark text-decoration-none">業務管理アプリケーション</a></h1>
<a href="#" th:href="@{/logout}" th:attr="data-method='post'" class="btn btn-dark" style="font-size: 18px;">ログアウト</a>
</div>
</header>
<!-- ヘッダー部分の最後 -->
<ul class="list-group">
<li class="list-group-item"><a href="./order/list.html" th:href="@{/orders}" class="btn btn-primary">注文リスト一覧</a></li>
<li class="list-group-item" sec:authorize="hasAuthority('ADMIN')"><a href="./users/list.html" th:href="@{/users}" class="btn btn-primary">ユーザー一覧</a></li>
</ul>
</div>
</body>
</html>
権限によってメソッドを制御しよう
しかし、このままではURLが表示されていないというだけで、URLを手打ちすると、ユーザー情報が追加されてしまいます。次に、権限によってメソッドを制御する処理を追加していきます。
まずは設定ファイルを管理するconfig配下にメソッドを制御する処理を記述していきましょう。
以下の処理はSpringアプリケーションでグローバルなメソッドレベルのセキュリティ設定を有効にし、役割や権限に基づいてメソッドをセキュリティで保護するための処理になります。
package com.example.practice.config;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig {
}
先ほど追加したprePostEnabledをUserServiceに反映させていきます。
hasAuthority('ADMIN')は、指定された権限(ロール)がユーザーに与えられている場合に条件が真になります。この場合、ADMINという権限を持つユーザーにのみ、アノテーションが付与されたメソッドへのアクセスが許可されます。他の権限を持つユーザーはアクセスできません。
package com.example.practice.domain.authentication;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class UserService {
private final Logger logger = LoggerFactory.getLogger(UserService.class); // ロガーのインスタンスを取得
// UserRepositoryのインスタンスを注入
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@PreAuthorize("hasAuthority('ADMIN')")
// ユーザー一覧を取得するメソッド
public List<User> findAll() {
// userRepositoryを使ってデータベースからユーザー一覧を取得し、返す
return userRepository.findAll();
}
@PreAuthorize("hasAuthority('ADMIN')")
public void create(String username, String password) {
var encodedPassword = passwordEncoder.encode(password);
logger.debug("encodedPassword:" + encodedPassword); // パスワードをエンコードしたものをログで出力
userRepository.insert(username,encodedPassword);
}
// ユーザー名の重複をチェックするメソッド
public boolean isUsernameTaken(String username) {
// ユーザー名が重複しているかをチェック
logger.debug("isUsernameTaken_username:" + username); // ユーザー名をログ出力
Optional<User> existingUser = userRepository.findByUsername(username); // 既存のユーザーを取得
logger.debug("isUsernameTaken_existingUser:" + existingUser); // 既存のユーザーをログ出力
return existingUser.isPresent(); // 既存のユーザーが存在するかを返す
}
}
最後に、例えば、ADMIN権限を持たないユーザーが特定のメソッドを実行をしようとしたときにエラーページに遷移する処理を記述していきましょう。
<!DOCTYPE html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>エラーページ | 業務用アプリケーション</title>
<!-- Bootstrap CSS link -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
</head>
<body>
<div class="container mt-4">
<!-- ヘッダー部分 -->
<header class="mb-4 bg-light p-3 rounded">
<div class="d-flex justify-content-between align-items-center">
<h1 class="mb-0"><a href="../index.html" th:href="@{/}" class="text-dark text-decoration-none">業務管理アプリケーション</a></h1>
<a href="#" th:href="@{/logout}" th:attr="data-method='post'" class="btn btn-dark" style="font-size: 18px;">ログアウト</a>
</div>
</header>
<!-- ヘッダー部分の最後 -->
<div class="alert alert-danger" role="alert">
<h4 class="alert-heading">ページが見つかりません</h4>
<p>お探しのページは見つかりませんでした。</p>
<p class="mb-0">トップページに戻るか、正しいURLを入力して再試行してください。</p>
</div>
</div>
</body>
</html>
以上がアクセス制御の処理になります。
ユーザー登録画面から権限も追加する
ここからはユーザー登録画面から権限も追加できるように設定をしていきたいと思います。では、まずはModelから整理していきたいと思います。
ユーザー作成するデータモデルに権限を追加しましょう。
package com.example.practice.web.user;
import lombok.AllArgsConstructor;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
@Data
@AllArgsConstructor
public class UserForm {
@NotBlank(message = "ユーザー名を入力してください")
private String username;
@NotBlank(message = "パスワードを入力してください")
@Size(min = 4, max = 12, message = "パスワードは4文字以上12文字以下で登録してください")
private String password;
@NotBlank(message = "権限を入力してください")
private String authority;
}
次に、INSERTする際に権限を追加していきます。
package com.example.practice.domain.authentication;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Optional;
@Mapper
public interface UserRepository {
@Select("select * from users where username = #{username}")
Optional<User> findByUsername(String username);
@Select("select * from users")
List<User> findAll();
@Insert("insert into users (username, password, authority) values (#{param1}, #{param2}, #{param3})")
void insert(String username, String password, String authority);
}
次に、UserServiceのcreateメソッドに権限を追加していきましょう。
package com.example.practice.domain.authentication;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class UserService {
private final Logger logger = LoggerFactory.getLogger(UserService.class); // ロガーのインスタンスを取得
// UserRepositoryのインスタンスを注入
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@PreAuthorize("hasAuthority('ADMIN')")
// ユーザー一覧を取得するメソッド
public List<User> findAll() {
// userRepositoryを使ってデータベースからユーザー一覧を取得し、返す
return userRepository.findAll();
}
@PreAuthorize("hasAuthority('ADMIN')")
public void create(String username, String password, String authority) {
logger.debug("create_username:" + username); // ユーザー名をログ出力
var encodedPassword = passwordEncoder.encode(password);
logger.debug("create_encodedPassword:" + encodedPassword); // パスワードをエンコードしたものをログで出力
logger.debug("create_authority:" + authority); // 権限をログ出力
userRepository.insert(username, encodedPassword, authority);
}
// ユーザー名の重複をチェックするメソッド
public boolean isUsernameTaken(String username) {
// ユーザー名が重複しているかをチェック
logger.debug("isUsernameTaken_username:" + username); // ユーザー名をログ出力
Optional<User> existingUser = userRepository.findByUsername(username); // 既存のユーザーを取得
logger.debug("isUsernameTaken_existingUser:" + existingUser); // 既存のユーザーをログ出力
return existingUser.isPresent(); // 既存のユーザーが存在するかを返す
}
}
最後に、VIEWの追加をします。ユーザ登録画面とユーザー一覧画面に権限を追加していきましょう。
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ユーザー作成 | 業務用アプリケーション</title>
<!-- Bootstrap CSS link -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
</head>
<body>
<div class="container mt-4">
<!-- ヘッダー部分 -->
<header class="mb-4 bg-light p-3 rounded">
<div class="d-flex justify-content-between align-items-center">
<h1 class="mb-0"><a href="../index.html" th:href="@{/}" class="text-dark text-decoration-none">業務管理アプリケーション</a></h1>
<a href="#" th:href="@{/logout}" th:attr="data-method='post'" class="btn btn-dark" style="font-size: 18px;">ログアウト</a>
</div>
</header>
<!-- ユーザー作成フォーム -->
<form action="#" th:action="@{/users}" method="post" th:object="${userForm}" style="width: 500px; margin: 0 auto;">
<!-- ユーザー名入力 -->
<div class="mt-3">
<label for="usernameInput" class="form-label">ユーザー名</label>
<input type="text" id="usernameInput" class="form-control" th:field="*{username}">
<!-- ユーザー名のバリデーションエラー表示 -->
<small class="form-text text-danger" th:if="${#fields.hasErrors('username')}" th:errors="*{username}"></small>
</div>
<!-- パスワード入力 -->
<div class="mt-3">
<label for="passwordInput" class="form-label">パスワード</label>
<input type="password" id="passwordInput" class="form-control" th:field="*{password}">
<!-- パスワードのバリデーションエラー表示 -->
<small class="form-text text-danger" th:if="${#fields.hasErrors('password')}" th:errors="*{password}"></small>
</div>
<!-- 権限ラジオボタン -->
<div class="mt-3">
<label class="form-label" for="authorityRadio">権限</label>
<div id="authorityRadio">
<!-- ADMIN権限選択 -->
<div class="form-check" th:classappend="${#fields.hasErrors('authority')} ? is-invalid">
<input class="form-check-input" type="radio" id="authorityAdminRadio"
th:field="*{authority}" value="ADMIN"
th:classappend="${#fields.hasErrors('authority')} ? is-invalid">
<label class="form-check-label" for="authorityAdminRadio">ADMIN</label>
</div>
<!-- USER権限選択 -->
<div class="form-check" th:classappend="${#fields.hasErrors('authority')} ? is-invalid">
<input class="form-check-input" type="radio" id="authorityUserRadio"
th:field="*{authority}" value="USER" checked="${userForm.authority == 'USER'}"
th:classappend="${#fields.hasErrors('authority')} ? is-invalid">
<label class="form-check-label" for="authorityUserRadio">USER</label>
</div>
<!-- 権限のバリデーションエラー表示 -->
<p class="invalid-feedback" th:if="${#fields.hasErrors('authority')}" th:errors="*{authority}">(errors)</p>
</div>
</div>
<!-- 作成ボタンと取消ボタン -->
<div class="mt-3">
<button type="submit" class="btn btn-primary">作成</button>
<a href="./list.html" th:href="@{/users}" class="btn btn-secondary">取消</a>
</div>
</form>
<!-- エラーメッセージ表示 -->
<div th:if="${param.error}" class="alert alert-danger mt-3" style="width: 500px; margin: 0 auto;">
ユーザー名はすでに使われています。
</div>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>トップページ | 業務用アプリケーション</title>
<!-- Bootstrap CSS link -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
</head>
<body>
<div class="container mt-2">
<!-- ヘッダー部分 -->
<header class="mb-4 bg-light p-3 rounded">
<div class="d-flex justify-content-between align-items-center">
<h1 class="mb-0"><a href="../index.html" th:href="@{/}" class="text-dark text-decoration-none">業務管理アプリケーション</a></h1>
<a href="#" th:href="@{/logout}" th:attr="data-method='post'" class="btn btn-dark" style="font-size: 18px;">ログアウト</a>
</div>
</header>
<!-- ヘッダー部分の最後 -->
<div class="row">
<div class="col-md-6 offset-md-3">
<h2 class="text-center">ユーザー一覧</h2>
<a href="../index.html" th:href="@{/}" class="btn btn-primary">トップページ</a>
<a href="../creationForm.html" th:href="@{/users/creationForm}" class="btn btn-success">作成</a>
<table class="table table-striped">
<thead>
<tr>
<th>ユーザー名</th>
<th>権限</th>
</tr>
</thead>
<tbody>
<tr th:each="user : ${userList}">
<td th:text="${user.username}"></td>
<td th:text="${user.authority}">(authority)</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>