SpringSecurity6系は5.3までと比べるとSecurityConfigの書き方が変わってるので勉強を兼ねて独自のログイン画面を用意しroleを使った認可機能をjavaで作成してみました。
またMyBatisとH2 Databaseを使うことでDB構築をせずとも、手軽にDBと連携できるようにしてみました。
なお、記載内容が多くなったため、記事を2つに分けてます。
前編は以下をご参照ください。
開発環境
私が構築した時の開発環境は以下の通りとなります。
環境 | バージョン |
---|---|
Java | 17 |
SpringBoot | 3.4.3 |
SpringSecurity | 6.4.3 |
thymeleaf | 3.1.3.RELEASE |
MyBatis | 3.0.4 |
H2 Database | 2.3.232 |
lombok | 1.18.36 |
ソース構成
ソース構成は以下の通りです。
login-role-project
├── src
│ ├── main
│ │ ├── java
│ │ │ └── com
│ │ │ └── example
│ │ │ └── loginroleproject
│ │ │ ├── config
│ │ │ │ ├── SecurityConfig.java
│ │ │ │ └── SecurityProperties.java
│ │ │ ├── controller
│ │ │ │ ├── AdminController.java
│ │ │ │ ├── LoginController.java
│ │ │ │ └── MemberController.java
│ │ │ ├── entity
│ │ │ │ ├── LoginUsers.java
│ │ │ │ └── UserRole.java
│ │ │ ├── repository
│ │ │ │ ├── LoginUsersMapper.java
│ │ │ │ └── UserRoleMapper.java
│ │ │ ├── security
│ │ │ │ ├── CustomAuthenticationSuccessHandler.java
│ │ │ │ ├── LoginUsersDataInitializer.java
│ │ │ │ └── LoginUsersDetailsService.java
│ │ │ └── LoginRoleProjectApplication.java
│ │ └── resources
│ │ ├── com
│ │ │ └── example
│ │ │ └── loginroleproject
│ │ │ └── repository
│ │ │ ├── LoginUsersMapper.xml
│ │ │ └── UserRoleMapper.xml
│ │ ├── static
│ │ │ └── css
│ │ │ └── styles.css
│ │ ├── templates
│ │ │ ├── admin
│ │ │ │ └── home.html
│ │ │ ├── member
│ │ │ │ └── home.html
│ │ │ └── login.html
│ │ ├── application.yml
│ │ └── schema.sql
└── pom.xml
SQLの作成
MyBatisのmapper.xmlを使ってSQLを使用します。
LoginUsersMapper.xmlを作成します。
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.loginroleproject.repository.LoginUsersMapper">
<select id="findUserByUsername" parameterType="string" resultType="LoginUsers">
SELECT username, password
FROM login_users
WHERE username = #{username}
</select>
<insert id="insertUser" parameterType="LoginUsers">
INSERT INTO login_users (username, password, enabled)
VALUES (#{username}, #{password}, true);
</insert>
</mapper>
namespace=”com.example.loginroleproject.repository.LoginUsersMapper”と指定することで後述のLoginUsersMapper.javaに用意されたメソッドとLoginUsersMapper.xmlに用意された各SQLのid名がリンクします。
リンクすることでLoginUsersMapper.javaからLoginUsersMapper.xmlのSQLが実行できるようになります。
resultType=”LoginUsers”はSELECTの結果をcom.example.loginroleproject.entity.LoginUsersクラスに設定するために指定してます。
parameterType=”LoginUsers”と指定することでLoginUsersクラスのusernameやpassword変数に格納した値がINSERT文のVALUES句のusernameやpasswordに設定されるようにしてます。
複数の値をSQLに渡す必要がある時に便利です。
対してparameterType=”string”は文字列型の値をusernameに設定できるようにしてます。
findUserByUsernameはログイン時などに入力したユーザー名が存在するかをチェックするためのSQLです。
insertUserはアプリ起動時にログインアカウントを生成するためのSQLです。
続いてUserRoleMapper.xmlを作成します。
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.loginroleproject.repository.UserRoleMapper">
<select id="findRolesByUsername" parameterType="string" resultType="UserRole">
SELECT username, role
FROM user_roles
WHERE username = #{username}
</select>
<insert id="insertUserRole" parameterType="UserRole">
INSERT INTO user_roles (username, role)
VALUES (#{username}, #{role});
</insert>
</mapper>
namespace=”com.example.loginroleproject.repository.UserRoleMapper”と指定することで後述のUserRoleMapper.javaに用意されたメソッドとUserRoleMapper.xmlに用意された各SQLのid名がリンクします。
findRolesByUsernameはログインしたユーザーのロールを取得するためのSQLです。
insertUserRoleはアプリ起動時にログインアカウントのロールを設定するためのSQLです。
Entityクラスの作成
後述のMapperインターフェースで使用するEntityクラスを作成します。
LoginUsersクラスを作成します。
package com.example.loginroleproject.entity;
import lombok.Getter;
import lombok.Setter;
/**
* ログインユーザーを表すエンティティクラス
*/
@Getter
@Setter
public class LoginUsers {
/**
* ユーザー名(主キー)
*/
private String username;
/**
* パスワード(ハッシュ化済み)
*/
private String password;
}
UserRoleクラスを作成します。
package com.example.loginroleproject.entity;
import lombok.Getter;
import lombok.Setter;
/**
* ユーザーロールを表すエンティティクラス
*/
@Getter
@Setter
public class UserRole {
/**
* ユーザー名(外部キー)
*/
private String username;
/**
* ロール名
*/
private String role;
}
Mapperインターフェースの作成
各Mapper.xmlを使うMapperインターフェースを作成します。
LoginUsersMapperクラスを作成します。
package com.example.loginroleproject.repository;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import com.example.loginroleproject.entity.LoginUsers;
/**
* ログインユーザーのデータアクセスを担当するインターフェース
*/
@Mapper
public interface LoginUsersMapper {
/**
* ユーザー名に基づいてユーザー情報を取得します
*
* @param username ユーザー名
* @return ユーザー情報。存在しない場合はnull
*/
LoginUsers findUserByUsername(@Param("username") String username);
/**
* 新しいユーザーを登録します
*
* @param loginUsers 登録するユーザー情報
*/
void insertUser(LoginUsers loginUsers);
}
MapperアノテーションによってMyBatisがLoginUsersMapperをMapperインターフェースとして自動的に検出します。
こうすることでLoginUsersMapper.javaを通じてLoginUsersMapper.xmlのSQLを使うことができます。
続いてUserRoleMapperクラスを作成し、UserRoleMapper.javaを通じてUserRoleMapper.xmlのSQLを使用します。
package com.example.loginroleproject.repository;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import com.example.loginroleproject.entity.UserRole;
/**
* ユーザーロールのデータアクセスを担当するインターフェース
*/
@Mapper
public interface UserRoleMapper {
/**
* 指定されたユーザー名に紐づくロール一覧を取得します
*
* @param username ユーザー名
* @return ロール一覧
*/
List<UserRole> findRolesByUsername(@Param("username") String username);
/**
* ユーザーにロールを割り当てます
*
* @param userRole ユーザーロール情報
*/
void insertUserRole(UserRole userRole);
}
ログインアカウントを生成するクラスの作成
今回はdata.sqlを使ってログインアカウントを生成しません。
H2のDBにデータを生成するのはdata.sqlを使うのが一般的だと思います。
しかし、data.sqlを使ってデータを生成しても、BCryptPasswordEncoderを使っているためにハッシュが一致せずに正しいパスワードを入力してもエラーとなってしまいます。
そのためアプリ起動時にデータをインサートする処理を実行するためにLoginUsersDataInitializerクラスを作成します。
package com.example.loginroleproject.security;
import java.util.List;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.example.loginroleproject.config.SecurityProperties;
import com.example.loginroleproject.config.SecurityProperties.UsersConfig;
import com.example.loginroleproject.entity.LoginUsers;
import com.example.loginroleproject.entity.UserRole;
import com.example.loginroleproject.repository.LoginUsersMapper;
import com.example.loginroleproject.repository.UserRoleMapper;
import lombok.RequiredArgsConstructor;
/**
* アプリケーション起動時にユーザーデータを初期化する設定クラス。 application.ymlに定義されたユーザー情報をデータベースに登録します。
*/
@RequiredArgsConstructor
@Component
@Configuration
public class LoginUsersDataInitializer {
private final LoginUsersMapper loginUsersMapper;
private final UserRoleMapper userRoleMapper;
private final PasswordEncoder passwordEncoder;
private final SecurityProperties securityProperties;
/**
* アプリケーション起動時に実行されるBean。 設定されたユーザー情報をデータベースに登録します。
*
* @return ApplicationRunner ユーザー初期化を実行するBean
*/
@Bean
@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
public ApplicationRunner initializeUsers() {
return args -> {
List<UsersConfig> users = securityProperties.getUsers();
if (users == null || users.isEmpty()) {
return;
}
users.forEach(this::initializeUser);
};
}
/**
* 個別のユーザーを初期化します。
*
* @param userConfig ユーザー設定情報
*/
private void initializeUser(UsersConfig userConfig) {
String username = userConfig.getUsername();
if (isUserExists(username)) {
return;
}
createUserWithRoles(username, userConfig.getPassword(), userConfig.getRole());
}
/**
* ユーザーが既に存在するか確認します。
*
* @param username 確認するユーザー名
* @return ユーザーが存在する場合はtrue
*/
private boolean isUserExists(String username) {
return loginUsersMapper.findUserByUsername(username) != null;
}
/**
* ユーザーとロールを作成します。
*
* @param username ユーザー名
* @param password パスワード
* @param role 割り当てるロール
*/
private void createUserWithRoles(String username, String password, String role) {
createUser(username, password);
assignRoleToUser(username, role);
}
/**
* ユーザーを作成します。
*
* @param username ユーザー名
* @param password パスワード
*/
private void createUser(String username, String password) {
LoginUsers loginUsers = new LoginUsers();
loginUsers.setUsername(username);
loginUsers.setPassword(passwordEncoder.encode(password));
loginUsersMapper.insertUser(loginUsers);
}
/**
* ユーザーにロールを割り当てます。
*
* @param username ユーザー名
* @param role 割り当てるロール
*/
private void assignRoleToUser(String username, String role) {
UserRole userRole = new UserRole();
userRole.setUsername(username);
userRole.setRole(role);
userRoleMapper.insertUserRole(userRole);
}
}
initializeUsersメソッドではBeanアノテーションを付与することでBeanとしてApplicationRunnerを登録するようにしてます。
また以下の処理をApplicationRunnerとして返却することでApplicationRunnerクラスのrunメソッドを実行した時に以下の処理が実行されるようにしてます。
return args -> {
List<UsersConfig> users = securityProperties.getUsers();
if (users == null || users.isEmpty()) {
return;
}
users.forEach(this::initializeUser);
};
Springはアプリ起動時にApplicationRunner.runを実行します。
そのため、アプリケーション起動時にBeanとして登録された上記のログインアカウントを生成するインサート処理が実行されるようになります。
以下はDBのトランザクション管理をするためのアノテーションです。
@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
propagation = Propagation.REQUIREDはすでにトランザクションが存在する場合はそのトランザクションを使い、そうでない場合は新しくトランザクションを作るようにする設定となります。
このため、トランザクションを無駄に作成することがないため、パフォーマンスが向上します。
また複数のトランザクションが作成されることにより部分的にコミットされてしまうといったことがありません。
rollbackFor = Exception.classはExceptionクラスとExceptionクラスのサブクラス(つまり何らかの例外)が発生したらロールバックを行うという設定になります。
LoginUsersDataInitializerの全体の流れは以下の通りとなります。
+-------------------------------------------+
| アプリケーション起動 |
+-------------------+-----------------------+
|
▼
+-------------------------------------------+
| ApplicationRunner#run 実行 |
| (initializeUsers()メソッド内) |
+-------------------+-----------------------+
|
▼
+-------------------------------------------+
| SecurityPropertiesからユーザー設定取得 |
| List<UsersConfig> users = ... |
+-------------------+-----------------------+
|
▼
+----------------------------+
| ユーザーリストが空? |---はい---> 終了
+------------+--------------+
|
| いいえ
▼
+----------------------------+
| ユーザーごとに処理実行 |
| users.forEach() |
+------------+--------------+
|
▼
+-------------------------------------------+
| initializeUser(UsersConfig) |
+-------------------+-----------------------+
|
▼
+----------------------------+
| ユーザーが既に存在する? |---はい---> 処理スキップ
| isUserExists(username) | (次のユーザーへ)
+------------+--------------+
|
| いいえ
▼
+-------------------------------------------+
| createUserWithRoles(username, |
| password, role) |
+-------------------+-----------------------+
|
▼
+-------------------------------------------+
| createUser(username, password) |
| - LoginUsers エンティティ作成 |
| - パスワードをハッシュ化 |
| - loginUsersMapper.insertUser() |
+-------------------+-----------------------+
|
▼
+-------------------------------------------+
| assignRoleToUser(username, role) |
| - UserRole エンティティ作成 |
| - userRoleMapper.insertUserRole() |
+-------------------+-----------------------+
|
▼
+----------------------------+
| 次のユーザーに進む |
| (もしあれば) |
+----------------------------+
認証用のサービスクラス作成
MyBatisを使う場合、UserDetailsServiceクラスを実装して独自の認証用サービスクラスを作成する必要があります。
そのため、LoginUsersDetailsServiceクラスを作成します。
package com.example.loginroleproject.security;
import java.util.List;
import java.util.stream.Collectors;
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 org.springframework.transaction.annotation.Transactional;
import com.example.loginroleproject.entity.LoginUsers;
import com.example.loginroleproject.entity.UserRole;
import com.example.loginroleproject.repository.LoginUsersMapper;
import com.example.loginroleproject.repository.UserRoleMapper;
import lombok.RequiredArgsConstructor;
/**
* Spring Securityの認証に必要なユーザー情報を提供するサービスクラスです。 データベースに格納されたユーザー情報を取得し、Spring Securityで使用可能な
* UserDetailsオブジェクトに変換する役割を担います。 トランザクション管理を行い、データの整合性を保ちます。
*/
@RequiredArgsConstructor
@Service
@Transactional(readOnly = true)
public class LoginUsersDetailsService implements UserDetailsService {
private final LoginUsersMapper loginUsersMapper;
private final UserRoleMapper userRoleMapper;
private static final String ROLE_PREFIX = "ROLE_";
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
LoginUsers loginUsers = loginUsersMapper.findUserByUsername(username);
if (loginUsers == null) {
throw new UsernameNotFoundException("User not found: " + username);
}
List<UserRole> userRoles = userRoleMapper.findRolesByUsername(username);
List<SimpleGrantedAuthority> authorities = userRoles.stream()
.map(role -> new SimpleGrantedAuthority(ROLE_PREFIX + role.getRole()))
.collect(Collectors.toList());
return User.builder().username(loginUsers.getUsername()).password(loginUsers.getPassword())
.authorities(authorities).build();
}
}
以下の処理によってログイン画面で入力されたユーザー名がDBに存在するかチェックしてます。
LoginUsers loginUsers = loginUsersMapper.findUserByUsername(username);
if (loginUsers == null) {
throw new UsernameNotFoundException("User not found: " + username);
}
以下の処理によってログインするユーザーのロールを取得し、SpringSecurityがロールを管理するためのクラスであるSimpleGrantedAuthorityの変数に設定をします。
SpringSecurityでロールを管理するためにはプレフィックスに”ROLE_”という文字列を付与する必要があるのでSimpleGrantedAuthorityの変数に設定する際に付与してます。
List<UserRole> userRoles = userRoleMapper.findRolesByUsername(username);
List<SimpleGrantedAuthority> authorities = userRoles.stream()
.map(role -> new SimpleGrantedAuthority(ROLE_PREFIX + role.getRole()))
.collect(Collectors.toList());
ログイン画面で入力されたユーザー名、パスワードと先ほど取得したロールを使ってUserDetailsインスタンスを生成して返却します。
return User.builder().username(loginUsers.getUsername()).password(loginUsers.getPassword())
.authorities(authorities).build();
loadUserByUsernameメソッドでUserDetailsを返却することで、生成されたUserDetailsインスタンスを使ってSpringが提供するAuthenticationManagerクラスがDBに登録されているパスワードと一致してるか認証を行います。
また、返却したUserDetailsはSpringSecurityによってセッション情報に登録されます。
ログイン画面用のコントローラー作成
ログイン画面用のコントローラーを作成します。
ログイン画面を表示するloginメソッドを用意してます。
package com.example.loginroleproject.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import lombok.RequiredArgsConstructor;
/**
* ログイン関連の画面遷移を制御するコントローラー
*/
@Controller
public class LoginController {
/**
* ログイン画面を表示します
*
* @return ログイン画面のビュー名
*/
@GetMapping("/login")
public String login() {
return "login";
}
}
ログイン後画面のコントローラー作成
ログイン後に表示する画面用のコントローラーを作成します。
ロールがADMIN権限のユーザーのみがアクセスできるAdminControllerを作成します。
前編のSecurityConfigによってADMIN権限のみ「/admin」というURLにアクセスできるようにしているため、AdminControllerはADMIN権限専用のコントローラーとなってます。
AdminControllerではADMIN権限のみがアクセスできる「/admin/home.html」を表示するメソッドを用意してます。
package com.example.loginroleproject.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
/**
* 管理者専用機能を提供するコントローラークラス /admin/以下のパスにマッピングされたエンドポイントを管理します
*/
@Controller
@RequestMapping("/admin")
public class AdminController {
/**
* 管理者用ホーム画面を表示します
*
* @return 管理者ホーム画面のビュー名
*/
@GetMapping("/home")
public String home() {
return "admin/home";
}
}
続いてロールがMEMBER権限のユーザーのみがアクセスできるMemberControllerを作成します。
前編のSecurityConfigによってADMIN権限のみ「/member」というURLにアクセスできるようにしているため、MemberControllerはADMIN権限専用のコントローラーとなってます。
MemberControllerではMEMBER権限のみがアクセスできる「/member/home.html」を表示するメソッドを用意してます。
package com.example.loginroleproject.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
/**
* 一般会員専用機能を提供するコントローラークラス /member/以下のパスにマッピングされたエンドポイントを管理します
*/
@Controller
@RequestMapping("/member")
public class MemberController {
/**
* 会員用ホーム画面を表示します
*
* @return 会員ホーム画面のビュー名
*/
@GetMapping("/home")
public String home() {
return "member/home";
}
}
ログイン画面とログイン後の画面作成
ログイン画面とログイン後の画面を作成します。
まずはログイン画面であるlogin.htmlです。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>ログイン - MyApp</title>
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body>
<div class="login-container">
<h2>ログイン</h2>
<div class="error-message" th:if="${param.error}">
ユーザー名またはパスワードが間違っています。
</div>
<form method="post" th:action="@{/login}">
<div class="input-group">
<label for="username">ユーザー名</label>
<input id="username" name="username" required type="text">
</div>
<div class="input-group">
<label for="password">パスワード</label>
<input id="password" name="password" required type="password">
</div>
<button class="login-button" type="submit">ログイン</button>
</form>
</div>
</body>
</html>
ログイン画面で使用するCSSとしてstyle.cssを作成します。
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.login-container {
background-color: #fff;
padding: 20px;
border-radius: 10px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
width: 300px;
text-align: center;
}
.login-container h2 {
margin-bottom: 20px;
}
.input-group {
margin-bottom: 15px;
text-align: left;
}
.input-group label {
display: block;
margin-bottom: 5px;
}
.input-group input {
width: 95%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 5px;
}
.login-button {
background-color: #007bff;
color: #fff;
border: none;
padding: 10px;
width: 100%;
border-radius: 5px;
cursor: pointer;
}
.login-button:hover {
background-color: #0056b3;
}
.error-message {
color: red;
margin-bottom: 10px;
}
ADMIN権限のみがアクセスできる「/admin/home.html」を作成します。
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>管理者ホーム</title>
</head>
<body>
<h1>管理者ページ</h1>
<p>ようこそ、管理者ユーザーさん!</p>
<div>
<a href="/logout">ログアウト</a>
</div>
</body>
</html>
MEMBER権限のみがアクセスできる「/member/home.html」を作成します。
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>会員ホーム</title>
</head>
<body>
<h1>会員ページ</h1>
<p>ようこそ、会員ユーザーさん!</p>
<div>
<a href="/logout">ログアウト</a>
</div>
</body>
</html>
アプリ起動用のクラス作成
最後にアプリ起動用のクラスを作成します。
package com.example.loginroleproject;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* ログイン認証・認可機能を持つSpring Bootアプリケーションのメインクラスです。 このクラスはアプリケーションのエントリーポイントとして機能し、Spring
* Boot環境の初期化を行います。
*/
@SpringBootApplication
public class LoginRoleProjectApplication {
/**
* アプリケーションのエントリーポイントとなるメインメソッドです。 Spring Boot環境を初期化し、アプリケーションを起動します。
*
* @param args コマンドライン引数
*/
public static void main(String[] args) {
SpringApplication.run(LoginRoleProjectApplication.class, args);
}
}
ログイン画面へアクセス
ブラウザでhttp://localhost:8080/loginにアクセスすると以下のようなログイン画面が表示されます。

ユーザー名とパスワードはapplication.ymlの以下の値となります。
users:
- username: admin
password: admin123
role: ADMIN
- username: user1
password: user123
role: MEMBER
- username: user2
password: user456
role: MEMBER
ADMIN権限でログインしたい場合はユーザー名にadmin、パスワードにadmin123と入力し、ログインボタンを押下すればログインが成功します。
h2-consoleへアクセス
ブラウザでhttp://localhost:8080/h2-consoleへアクセスすると以下のような画面へアクセスできます。

Connectボタンを押すと以下のような画面が表示できます。

この画面では上記のようにSQLを実行でき、H2上の各テーブルのデータを見ることができるので便利です。
なお、記載内容が多くなったため、記事を2つに分けてます。
前編は以下をご参照ください。
コメント