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
pom.xml
必要なライブラリを導入するためにpom.xmlを以下のように記述します。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.3</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>login-role-project</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>login-role-project</name>
<description>login-role-project</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.4</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
application.ymlの作成
アプリの設定を行うapplication.ymlを以下のように記述します。
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true
path: /h2-console
logging:
level:
org.springframework.security: DEBUG
security:
login-url: /login
logout-url: /logout
permit-urls:
- /login
- /css/**
- /js/**
- /images/**
users:
- username: admin
password: admin123
role: ADMIN
- username: user1
password: user123
role: MEMBER
- username: user2
password: user456
role: MEMBER
roles:
admin: ADMIN
user: MEMBER
paths:
admin: /admin/**
user: /member/**
h2-console-url: /h2-console/**
mybatis:
type-aliases-package: com.example.loginroleproject.entity
configuration:
map-underscore-to-camel-case: true
以下を指定することでh2をDBとして使用することしてます。h2のDBにログインする時のpasswordは未入力で大丈夫です。
なお、h2は軽量でローカルでの開発には便利ですが、安定性やスケーラビリティに欠けるので商用利用はしないように留意ください。
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password:
以下はh2のDBの中身を見れるようにh2-consoleを有効化してます。
h2:
console:
enabled: true
path: /h2-console
以下はデバッグ用にSpringSecurityのログレベルをDEBUGに指定してます。
org.springframework.security: DEBUG
securityプリフィックス以下にある値は後述で示すSecurityPropertiesクラスで使用してます。
例えばlogin-urlにはログイン用のURLを記載します。
permit-urlsには未認証でもアクセスできるURLを記載します。
CSSやJSや画像などの静的ファイルは未認証でもアクセスできないと使用できないため許可します。
login-url: /login
logout-url: /logout
permit-urls:
- /login
- /css/**
- /js/**
- /images/**
usersにはログインで使用するユーザー名とパスワードを記載します。
users:
- username: admin
password: admin123
role: ADMIN
- username: user1
password: user123
role: MEMBER
- username: user2
password: user456
role: MEMBER
rolesにはどのようなロールがあるかを記載します。
roles:
admin: ADMIN
user: MEMBER
type-aliases-packageで指定したパッケージ配下のクラスはパッケージ名を省略してMyBatisのXMLで使用できます。
map-underscore-to-camel-case: trueを指定することでデータベースのカラム名をキャメルケースに自動変換できるようにします。
mybatis:
type-aliases-package: com.example.loginproject.entity
configuration:
map-underscore-to-camel-case: true
schema.sqlの作成
アプリケーション起動時にDBのテーブルが作成されるようにschema.sqlを作成します。
CREATE TABLE IF NOT EXISTS LOGIN_USERS (
USERNAME VARCHAR (50) PRIMARY KEY,
PASSWORD VARCHAR (100) NOT NULL,
ENABLED BOOLEAN NOT NULL
);
CREATE TABLE IF NOT EXISTS USER_ROLES (
USERNAME VARCHAR (50) NOT NULL,
ROLE VARCHAR (20) NOT NULL,
PRIMARY KEY (USERNAME, ROLE),
FOREIGN KEY (USERNAME) REFERENCES LOGIN_USERS (USERNAME)
);
application.ymlを扱うクラスの作成
application.ymlにlogin-urlといったログイン用のURLなどを定数として定義しました。
それらの定数を扱うためのクラスであるSecurityPropertiesクラスを作成します。
package com.example.loginroleproject.config;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Component
@Validated
@ConfigurationProperties(prefix = "security")
public class SecurityProperties {
@NotBlank(message = "Login URL must be specified")
private String loginUrl;
@NotBlank(message = "Logout URL must be specified")
private String logoutUrl;
@NotEmpty(message = "Permit URLs must be specified")
private List<String> permitUrls;
@NotEmpty(message = "Users must be configured")
private List<UsersConfig> users;
private RolesConfig roles;
private PathsConfig paths;
@NotBlank(message = "H2 console URL must be specified")
private String h2ConsoleUrl;
@Getter
@Setter
public static class UsersConfig {
@NotBlank(message = "Username must be specified")
private String username;
@NotBlank(message = "Password must be specified")
private String password;
@NotBlank(message = "Role must be specified")
private String role;
}
@Getter
@Setter
public static class RolesConfig {
@NotBlank(message = "Admin role must be specified")
private String admin;
@NotBlank(message = "User role must be specified")
private String user;
}
@Getter
@Setter
public static class PathsConfig {
@NotBlank(message = "Admin path must be specified")
private String admin;
@NotBlank(message = "User path must be specified")
private String user;
}
}
lombokのgetterとsetterアノテーションによってgetterとsetterメソッドを自作しないで自動的に生成されるようにしてます。
Validated、NotBlankとNotEmptyアノテーションによってymlファイルから値を読み込んだ時に値がない場合にエラーとなるようにしてます。
アプリ起動時にバリデーションが行われることでアプリ起動に必要な値がすべて揃ってるかをチェックしてます。
なお、コレクションフレームワーク(List型)はNotBlankが使用できないため、NotEmptyでチェックしてます。
ConfigurationPropertiesアノテーションによってapplication.yml上のsecurityプレフィックス配下にあるものをすべてこのクラスの変数に格納するようにしてます。
permit-urlsプレフィックスが以下のようにハイフンを使って複数値を定義しているため、List<String>でpermitUrlsを宣言してます。
permit-urls:
- /login
- /css/**
- /js/**
- /images/**
usersプレフィックスが複数の項目を持つため、UsersConfigというインナークラスを作成しています。
rolesとpathsプレフィックスも複数の項目を持つため、RolesConfigとPathsConfigというインナークラスを作成してます。
users:
- username: admin
password: admin123
roles:
- ADMIN
- username: user1
password: user123
roles:
- MEMBER
- username: user2
password: user456
roles:
- MEMBER
SpringSecurityの設定
SpringSecurityの設定を行うクラスを作成します。
package com.example.loginroleproject.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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import com.example.loginroleproject.security.CustomAuthenticationSuccessHandler;
import lombok.RequiredArgsConstructor;
/**
* Spring Securityの設定クラス 認証・認可の設定とセキュリティ関連の設定を行います
*/
@RequiredArgsConstructor
@Configuration
public class SecurityConfig {
private static final String X_FRAME_OPTIONS_HEADER = "X-Frame-Options";
private static final String X_FRAME_OPTIONS_SAMEORIGIN = "SAMEORIGIN";
private static final String X_FRAME_OPTIONS_DENY = "DENY";
private final SecurityProperties securityProperties;
private final CustomAuthenticationSuccessHandler authenticationSuccessHandler;
/**
* Spring Securityの設定を構成します
*
* @param http HttpSecurityオブジェクト
* @return 設定済みのSecurityFilterChain
* @throws Exception 設定中にエラーが発生した場合
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(securityProperties.getPermitUrls().toArray(String[]::new)).permitAll()
.requestMatchers(securityProperties.getH2ConsoleUrl()).permitAll()
.requestMatchers(securityProperties.getPaths().getAdmin()).hasRole(securityProperties.getRoles().getAdmin())
.requestMatchers(securityProperties.getPaths().getUser()).hasRole(securityProperties.getRoles().getUser())
.anyRequest().authenticated()
)
.formLogin(login -> login
.loginPage(securityProperties.getLoginUrl())
.successHandler(authenticationSuccessHandler)
.permitAll()
)
.logout(logout -> logout
.logoutRequestMatcher(new AntPathRequestMatcher(securityProperties.getLogoutUrl()))
.logoutSuccessUrl(securityProperties.getLoginUrl())
.permitAll()
)
.csrf(csrf -> csrf
.ignoringRequestMatchers(securityProperties.getH2ConsoleUrl())
)
.headers(headers -> headers
.addHeaderWriter((request, response) -> {
if (new AntPathRequestMatcher(securityProperties.getH2ConsoleUrl()).matches(request)) {
response.setHeader(X_FRAME_OPTIONS_HEADER, X_FRAME_OPTIONS_SAMEORIGIN);
} else {
response.setHeader(X_FRAME_OPTIONS_HEADER, X_FRAME_OPTIONS_DENY);
}
})
);
return http.build();
}
/**
* パスワードエンコーダーを提供します
*
* @return BCryptPasswordEncoderのインスタンス
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
RequiredArgsConstructorアノテーションはfinal修飾子が付与されたメンバ変数を引数に持つコンストラクタを自動生成します。
これによって以下のようなコンストラクタを作らずともSecurityPropertiesクラスをインジェクションできるようにし、SecurityConfigクラスでSecurityPropertiesとCustomAuthenticationSuccessHandlerクラスを使用可能としてます。
public SecurityConfig(final SecurityProperties securityProperties, final CustomAuthenticationSuccessHandler authenticationSuccessHandler) {
this.securityProperties = securityProperties;
this.authenticationSuccessHandler = authenticationSuccessHandler;
}
以下によってapplication.ymlのpermit-urlsとh2-console-urlプレフィックスで定義したURLについてはログイン認証をせずにアクセスできるようにしてます。
.requestMatchers(securityProperties.getPermitUrls().toArray(String[]::new)).permitAll()
.requestMatchers(securityProperties.getH2ConsoleUrl()).permitAll()
以下によって「/admin/**」というURLはAdminロールを持つユーザーのみアクセスできるようにしてます。
「/member/**」というURLはMemberロールを持つユーザーのみアクセスできるようにしてます。
この制御によってroleによる認可を実現しています。
.requestMatchers(securityProperties.getPaths().getAdmin()).hasRole(securityProperties.getRoles().getAdmin())
.requestMatchers(securityProperties.getPaths().getUser()).hasRole(securityProperties.getRoles().getUser())
以下によってログイン画面はapplication.ymlのlogin-urlプレフィックスに定義したURLであることを指定してます。
.loginPage(securityProperties.getLoginUrl())
以下によってログイン成功時の処理はCustomAuthenticationSuccessHandlerクラスを実行するように指定してます。
CustomAuthenticationSuccessHandlerは後続で作成するクラスになります。
.successHandler(authenticationSuccessHandler)
logoutRequestMatcherによってログアウトの処理を実行するURLを指定してます。
logoutSuccessUrlによってログアウト処理が成功した後のリダイレクト先のURLを指定してます。
.logoutRequestMatcher(new AntPathRequestMatcher(securityProperties.getLogoutUrl()))
.logoutSuccessUrl(securityProperties.getLoginUrl())
以下によってh2-consoleのみcsrfトークンを無効化してます。
.csrf(csrf -> csrf
.ignoringRequestMatchers(securityProperties.getH2ConsoleUrl())
)
以下によってh2-consoleのみiframeを使用可能にしてます。
.headers(headers -> headers
.addHeaderWriter((request, response) -> {
if (new AntPathRequestMatcher(securityProperties.getH2ConsoleUrl()).matches(request)) {
response.setHeader(X_FRAME_OPTIONS_HEADER, X_FRAME_OPTIONS_SAMEORIGIN);
} else {
response.setHeader(X_FRAME_OPTIONS_HEADER, X_FRAME_OPTIONS_DENY);
}
})
);
以下によってパスワードの暗号化にBCryptPasswordEncoderを使用するように指定してます。
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
ログイン成功時のHandlerクラス作成
ログイン成功時のHandlerクラスを作成します。
package com.example.loginroleproject.security;
import java.io.IOException;
import java.util.Collection;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import com.example.loginroleproject.config.SecurityProperties;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
/**
* ユーザーのロールに基づいて適切なURLにリダイレクトするカスタム認証成功ハンドラー
*/
@Component
@RequiredArgsConstructor
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private static final String ROLE_PREFIX = "ROLE_";
private final SecurityProperties securityProperties;
/**
* 認証成功時に呼び出され、ユーザーのロールに応じた適切なURLにリダイレクトします
*
* @param request リクエスト
* @param response レスポンス
* @param authentication 認証情報
* @throws IOException I/O例外が発生した場合
* @throws ServletException サーブレット例外が発生した場合
*/
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
// 管理者ロールを持つ場合は管理者ホーム画面へリダイレクト
if (authorities.contains(new SimpleGrantedAuthority(
ROLE_PREFIX + securityProperties.getRoles().getAdmin()))) {
response.sendRedirect("/admin/home");
} else {
// それ以外の場合は会員ホーム画面へリダイレクト
response.sendRedirect("/member/home");
}
}
}
AuthenticationSuccessHandlerインターフェースを実装することでログイン成功時に実行される処理をカスタマイズしてます。
SpringSecurityが管理しているセッション情報からauthenticationを取得し、ログイン後に管理者ロールを持つユーザーとそうでないユーザーによって呼び出すコントローラーを分けるようにしてます。
セッション情報の登録は後編に記すLoginUsersDetailsServiceで行っています。
なお、記載内容が多くなったため、記事を2つに分けてます。
後編は以下をご参照ください。
コメント