概要
SpringSecurityを使用してログイン認証を実装する際、spring-boot-starter-securityを使用していますが、近年のアップデート(たぶんバージョン6から)で、色々と変更があったようです。
特にWebSecurityConfigurerAdapterが廃止されたことで、Configの書き方が結構変わっています。そんなわけで、データベース認証の実装をやってみました。
開発環境
項目 | バージョン |
---|---|
Pleiades all in One | 2023-12 |
MySQL Community Server | 8.3 |
Spring Boot | 3.2.5 |
ユーザーテーブルの準備
利用しているデータベースはMySQL Community Server 8.3です。rootユーザーのパスワードは仮にpasswordとしておきました。
最初に、ユーザーアカウント用のデータベースとテーブルを作成しておきます。
CREATE DATABASE springdb;
USE springdb;
CREATE TABLE accounts (
username VARCHAR(50) PRIMARY KEY,
password VARCHAR(500) NOT NULL,
role VARCHAR(50) NOT NULL,
enabled BOOLEAN NOT NULL);
パスワードをハッシュ化して登録するため、初期状態ではユーザーは登録しません。
プロジェクトの作成
eclipseで新規Spring スターター・プロジェクトを作成します。
依存関係で必要なのは以下の通りです。
- Spring Security
- Spring Web
- ThymeLeaf
- MySQL Driver
- Spring Data JPA
build.gradleの依存設定
ビルドツールはGradle(Groovy)を選択していますので、依存関係はbuild.gradleファイルに記載されます。今回の依存関係設定は以下の通りです。
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.5'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '21'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'}
tasks.named('test') {
useJUnitPlatform()
}
アプリケーション設定
Spring Data JPAを使用する場合、application.properties にデータベース接続パラメータを登録しておく必要があります。データベースの接続パラメータにタイムゾーンを指定するのは、以前のバージョンでこれを指定しないとうまく接続できなかったことに由来していたと思います。
spring.application.name=sec-mvc
spring.datasource.url=jdbc:mysql://localhost:3306/springdb?Timezone=Asia/Tokyo
spring.datasource.username=root
spring.datasource.password=password
データソース(リポジトリ)の定義
Spring Data JPAでデータベースに接続するため、エンティティクラスとリポジトリインタフェースを作成します。
package com.example.auth;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "accounts")
public class Account {
@Id
private String username;
private String password;
private String role;
private boolean enabled;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
}
package com.example.auth;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AccountsRepository extends JpaRepository<Account, String> {
}
SecurityConfig定義
JavaConfigクラスを定義します。ここがSpring Securityバージョン6の肝の部分です。
バージョン5までは、WebSecurityConfigurerAdapterを継承し、configureメソッドをオーバーライドしましたが、5.5から非推奨になり、SecurityFilterChainをBean定義してDIする方式が推奨されています。
バージョン6では、WebSecurityConfigurerAdapterが削除されてしまいました。展開はやっ!
というわけで作成したConfigクラス。以下の設定としています。
- ログインページを /login とし、誰でもアクセスできる
- メンバー専用ページを /member 配下とし、USERロールを持つアカウント飲みがアクセスできる
- パスワードをハッシュ化するコントローラを /makehash に実装し、誰でもアクセスできる
- 上記以外は、認証済みの全てのユーザーがアクセスできる
package com.example.auth;
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.password.PasswordEncoder;
import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.formLogin(login->{
login.loginPage("/login").permitAll(); // ログインページは誰でもアクセスできる
})
.authorizeHttpRequests(auth->{
auth.requestMatchers("/makehash").permitAll() // 誰でもアクセスできる(※)
.requestMatchers("/member/**").hasRole("USER") // USER専用ページ
.anyRequest().authenticated(); // 認証済みの全員がアクセスできる
}).logout(logout->logout.logoutUrl("/logout")); // ログアウトページ
return http.build();
}
// パスワードのハッシュ化コンポーネントをBean定義する
@Bean
public PasswordEncoder passwordEncoder() {
// PBKDF2アルゴリズムによるパスワード暗号化定義
Pbkdf2PasswordEncoder pbkdf2PasswordEncoder =
Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8();
pbkdf2PasswordEncoder.setEncodeHashAsBase64(true);
return pbkdf2PasswordEncoder;
}
}
認証定義の方法
SecurityFilterChainインタフェースは、HttpSecurity型の引数httpを受け取ります。
httpのオブジェクトに対してformLoginメソッドを呼び出して、引数にラムダ式を渡します。ラムダ式の引数loginに対してメソッドチェーンでログインページの定義を記述します。
同様に、authorizeHttpRequestsメソッドを呼び出して、引数にラムダ式を渡し、ラムダ式の引数authに対してメソッドチェーンでリクエストに対する認可の定義を記述します。
http.formLogin(ラムダ式);
【ラムダ式】
(login)->{
login.loginPage(エントリポイント).permitAll();
}
http.authorizeHttpRequests(ラムダ式);
【ラムダ式】
(auth)->{
auth.requestMatchers(エントリポイント).hasRole(ロール名) ← ロールへアクセス権を付与するページ
.requestMatchers(エントリポイント).permitAll() ← 誰でもアクセスできるページ
.anyRequest().authenticated(); ← 全てのディレクトリに認証済みユーザーのアクセス権を付与
}
公式ではラムダ式を省略形で記述していますが、個人的には{}を省略するのはJavaらしくなくて好きじゃないのでこの形にしてます。
パスワードハッシュ化方式の定義
このクラスで、同時にパスワードのハッシュ化コンポーネントをBean定義しています。
フレームワークがHttpリクエストを受け付けて、ユーザー認証処理を実行する際、ここでていぎしたPasswordEncoderインタフェースのBeanを注入してパスワードをデコードします。
ちなみに、BCryptPasswordEncoderを使用する場合は以下のように記述します。
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
ユーザー認証定義
ユーザー認証のためのコンポーネントを定義します。
UserDetailsServiceインタフェースを実装したクラスをコンポーネント定義することにより、Spring Securityの認証処理に注入されます。
先に作成した、accountsテーブル用のリポジトリからアカウントを検索し、取り出したデータ(Accountエンティティに格納されている)からユーザー名、パスワード、ロール名を取り出して認証情報を生成します。
生成した認証情報(UserDetails)をがコンポーネントとなるので、ユーザー認証処理に注入されるという仕組みです。
package com.example.auth;
import org.springframework.beans.factory.annotation.Autowired;
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.Component;
import jakarta.transaction.Transactional;
@Component
@Transactional
public class UserDetailsServiceImpl implements UserDetailsService {
// ユーザー情報テーブル用のリポジトリを注入
@Autowired
private AccountsRepository accountsRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// DBからユーザー情報をSELECTする。
// ユーザーが見つからない場合は例外をスローする
Account account = accountsRepository.findById(username)
.orElseThrow(() -> {
System.out.println("ユーザー:" + username + "が見つかりません。");
throw new UsernameNotFoundException(username);
});
// 見つかったユーザー情報により認証情報を生成する
return User.withUsername(
account.getUsername())
.password(account.getPassword())
.roles(account.getRole())
.disabled(!account.isEnabled())
.build();
}
}
ログイン画面
ログイン画面を作成します。
コントローラー
package com.example.auth;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class LoginController {
@GetMapping("/login")
public String login() {
return "login";
}
}
Thymeleafテンプレート
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<head>
<title>LOGIN</title>
<meta charset="UTF-8">
</head>
<body>
<h1>ログインページ</h1>
<div th:if="${param.error}">
ユーザー名かパスワードが違います。</div>
<div th:if="${param.logout}">
ログアウト済みです。</div>
<form th:action="@{/login}" method="post">
<div>
<input type="text" name="username" placeholder="Username"/>
</div>
<div>
<input type="password" name="password" placeholder="Password"/>
</div>
<input type="submit" value="ログイン" />
</form>
</body>
</html>
ログアウトコントローラー
/logout をログアウトコントローラとして定義していますので、コントローラーを作成しておきます。
package com.example.auth;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@Controller
public class LogoutController {
// ログアウト処理用のハンドラをDI注入する
SecurityContextLogoutHandler logoutHandler = new SecurityContextLogoutHandler();
// ログアウトコントローラー
@GetMapping("/logout")
public String logout(Authentication authentication, HttpServletRequest request, HttpServletResponse response) {
// ログアウト処理(セッション破棄など)
this.logoutHandler.logout(request, response, authentication);
// ログアウト後はログインページにリダイレクトする
return "redirect:/login";
}
}
アクセス拒否時の画面
アクセスが拒否された際に表示する画面を作っておきます。
コントローラ
package com.example.auth;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class AccessDeniedController {
@GetMapping("/access-denied")
public String accessDenied() {
return "access-denied";
}
}
テンプレート
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>ACCESS DENIED</title>
</head>
<body>
<h1>エラーページ</h1>
<p>このページへのアクセス権限はありません。</p>
<form action="/logout" method="get">
<input type="submit" value="ログアウト">
</form>
</body>
</html>
ログイン後に見る画面
今回は認証だけが目的ですので、認証後に表示するページは静的HTMLで作成します。
- index.html - トップページですが、認証済みのユーザーが表示できることにします。
- member/index.html - メンバー専用ページ。USERロールを持つユーザーが表示できることにします。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>TOP PAGE</title>
</head>
<body>
<h1>ようこそ!</h1>
<p>このトップページは認証済みユーザーが閲覧できます。</p>
<hr>
<a href="/logout">ログアウト</a>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>MEMBER PAGE</title>
</head>
<body>
<h1>メンバー専用</h1>
<p>このページを見られるのは登録ユーザーだけです。</p>
<hr>
<a href="/logout">ログアウト</a>
</body>
</html>
以上で、認証の設定ができましたので、eclipseのメニューから「実行」-「Spring Boot アプリケーション」を選択してアプリを起動し、 http://localhost:8080/ にアクセスすると認証画面が表示されます。
ただし、まだユーザーを作成していないのでログインできません。
ユーザーを登録するには、ユーザー名、パスワード、ロール名が必要ですが、パスワードはハッシュ化してaccountsテーブルに登録しなければいけません。
今回は Spring Security に実装されているSBKDF2アルゴリズムを使ってハッシュ化するため、作成したSpring Boot Webアプリにハッシュ化用のコントローラーを追加しましょう。このコントローラは認証されていないユーザーでもアクセスできる必要があるので、あらかじめ /makehash というエントリポイントに認証なしでアクセスできるようにSecurityConfigを設定してあります。
パスワードのハッシュ化
以下のRestコントローラを作成します。
package com.example.auth;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MakeHashController {
// SecurityConfigでBean定義しているPasswordEncoderを注入する
@Autowired
PasswordEncoder encoder;
@GetMapping(value="/makehash", params="str")
public String index(@RequestParam("str")String str) {
// パスワードエンコーダーを使用してパラメータをハッシュ化する
String encodedPassword = encoder.encode(str);
System.out.println(encodedPassword);
// ハッシュ化したパスワードを出力する
return encodedPassword;
}
}
以下のように、strパラメータを渡して呼び出すと、PBKDF2ハッシュ化後のデータが出力されます。
http://localhost:8080/makehash?str=password
【結果】
40cn1ME3L+TFOV1gdNLqgTS2ei4nnjkSSEc/J3z4HECbq58RRaT2o+m+Aqx0pSDw
ユーザーの作成
USERロールを持つユーザーと、GUESTユーザーを作成します。
INSERT INTO accounts VALUES ('user1', '40cn1ME3L+TFOV1gdNLqgTS2ei4nnjkSSEc/J3z4HECbq58RRaT2o+m+Aqx0pSDw', 'USER', true);
INSERT INTO accounts VALUES ('guest', '40cn1ME3L+TFOV1gdNLqgTS2ei4nnjkSSEc/J3z4HECbq58RRaT2o+m+Aqx0pSDw', 'GUEST', true);
確認
user1でログインすると、各画面にアクセスできます。
http://localhost:8080/index.html
http://localhost:8080/member/index.html
memberの後ろの/index.htmlは省略できません
guestでログインし、http://localhost:8080/member/index.htmlにアクセスすると
まとめ
Spring Security の認証を利用しようとすると、色々と面倒ですよね。でもセキュリティ機構は自作してしまうと脆弱性と自分で闘わなければならなくなるので、なるべくフレームワークの機能を使いたい物です。
実際、Adapterが非推奨になってからあっという間に削除されてしまったのも、使うべきでないというフレームワーク側の強い思いがあるのでしょう。本記事が参考になれば幸甚です。
参考
以下の記事を参考にさせていただきました。感謝。
公式も参考になりました。