概要
前回作成したSpringBootのアプリケーションに実装したSpringSecurityのログイン画面を独自のログイン画面が使えるようにする
また、メモリで管理していたユーザー情報をDBで管理するように変更する。
SpringSecurityについてあまり理解はできていないが、ひとまず実装したい方向けに記事を書いています。
目次
カスタムログインページの実装
ログインページ(login.html)の作成
まず、ログインページを実装するためにlogin.htmlを作成します
今回のログインページのURLは「/toLogin」としています。
<!DOCTYPE html>
<html lang="ja" 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 href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="login-container">
<h2>ログイン</h2>
<form method="post" th:action="@{/toLogin}">
<div class="form-group">
<label for="username">ユーザー名</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">パスワード</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="login-button">ログイン</button>
</form>
</div>
</body>
</html>
入力値チェックを実装するには、formタグ内に以下を追加します
<div th:if="${param.error}">
<div class="alert alert-danger">
<span th:text="${session.SPRING_SECURITY_LAST_EXCEPTION?.message ?: 'ユーザー情報が存在しません'}"></span>
</div>
</div>
解説:
ログイン失敗(「param.error」が存在)時に、エラーメッセージを出力するようにします。
認証失敗時はSpringSecurityにより「SPRING_SECURITY_LAST_EXCEPTION.message」にエラーメッセージが格納されているため、そのメッセージを出力します。
もし、「SPRING_SECURITY_LAST_EXCEPTION」が存在しない場合は「ユーザー情報が存在しません」と返します。
実際に作成したlogin.html
<!DOCTYPE html>
<html lang="ja" 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 href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background-color: #f5f5f5;
font-family: Arial, sans-serif;
}
.login-container {
background-color: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 600px;
}
.login-container h2 {
margin-bottom: 1.5rem;
color: #333;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #555;
}
.form-group input {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.form-group input:focus {
border-color: #007bff;
outline: none;
}
.login-button {
width: 100%;
padding: 0.75rem;
border: none;
border-radius: 4px;
background-color: #007bff;
color: white;
font-size: 1rem;
cursor: pointer;
}
.login-button:hover {
background-color: #0056b3;
}
.login-container p {
margin-top: 1rem;
text-align: center;
}
.login-container p a {
color: #007bff;
text-decoration: none;
}
.login-container p a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="login-container">
<h2>ログイン</h2>
<form method="post" th:action="@{/toLogin}">
<div th:if="${param.error}">
<div class="alert alert-danger">
<span th:text="${session.SPRING_SECURITY_LAST_EXCEPTION.message ?: 'ユーザー情報が存在しません'}"></span>
</div>
</div>
<div class="form-group">
<label for="username">ユーザー名</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">パスワード</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="login-button">ログイン</button>
</form>
</div>
</body>
</html>
Controllerの修正
/login を表示させるための記述を追加します
URLは「/toLogin」とします
@GetMapping("/toLogin")
public String toLogin(){
return "/login";
}
SecurityConfigの修正
ログインページが「/toLogin」になるように設定します。
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.formLogin(login -> login
.loginPage("/toLogin") // ログインページの設定 〜〜追加箇所〜〜
.defaultSuccessUrl("/user")
.permitAll())
ログアウト処理
独自のログインページを実装すると、HTMLを以下のように設定していた場合にログアウトが機能しなくなります。
<a href="./logout.html" th:href="@{/logout}">ログアウト</a>
これは、カスタムページを使用することでCSRF保護が有効になったため発生します。
SecurityConfigに「.csrf().disable()」を追加するとCSRF保護を無効になり、aタグのログアウトでも機能しますが、非推奨
CSRF保護を有効にしたログアウトを行うためにControllerのログアウトボタンを以下の通り変更します。
<form th:action="@{/logout}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<button type="submit">ログアウト</button>
</form>
解説:
トークンを送信する必要があるため、フォームの送信にはPOSTメソッドを利用します。
th:name="\${_csrf.parameterName}" : CSRFトークンのパラメーター名を取得する
th:value="${_csrf.token}" : CSRFトークンの実際の値
DBに登録したユーザーでのログイン
-
InMemoryUserDetailsManagerで管理をしていたユーザー情報削除
SecurityConfig.java の InMemoryUserDetailsManager userDetailsService() はコメントアウトもしくは削除します
-
依存関係の追加
DBと連携するために、以下の依存関係を追加します。pom.xml<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency>
-
ymlにDBとの接続情報を設定
application.ymldatasource: driver-class-name: org.postgresql.Driver url: jdbc:postgresql://localhost:5432/spring-security username: postgres password: postgres
-
Domain の実装
id, userName, password, role 属性が定義されたドメインクラスを作成
(Java 14 以降で導入されたrecordクラスを利用しています)User.javapackage com.example.demo.domain; public record User(int id, String username, String password, String role) { }
-
Repositoryの実装
入力されたusernameをデータベースで検索するリポジトリクラスを作成LoginRepository.java@Repository public class LoginRepository { @Autowired private NamedParameterJdbcTemplate template; private RowMapper<User> USER_ROW_MAPPER = (rs, row) -> new User( rs.getInt("id"), rs.getString("username"), rs.getString("password"), rs.getString("role")); public Optional<User> findByUsername(String username) { SqlParameterSource param = new MapSqlParameterSource().addValue("username", username); String sql = "SELECT * FROM users WHERE username = :username"; return Optional.ofNullable(template.queryForObject(sql, param, USER_ROW_MAPPER)); } }
-
Service の実装
UserDetailsServiceインターフェースを実装したCustomUserDetailsService.javaを作成します。CustomUserDetailsService.java@Service public class CustomUserDetailsService implements UserDetailsService { @Autowired private LoginRepository repository; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = repository.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException("User not found for username : " + username)); return new LoginUserDetails(user); } }
-
UserDetails を実装したクラスを作成
UserDetailsクラスでは、SpringSecurityでの認証認可に必要な
ユーザー情報(ユーザー名、パスワード、権限など)が格納されます。
package com.example.demo.common;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import com.example.demo.domain.User;
import java.util.Collection;
import java.util.Collections;
public class CustomUserDetails implements UserDetails {
private final User user;
private final Collection<GrantedAuthority> authorities;
public CustomUserDetails(User user) {
this.user = user;
this.authorities = Collections.singletonList(new SimpleGrantedAuthority(user.role()));
}
// ユーザーに付与された権限を返す
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
// ユーザー名を返す
@Override
public String getUsername() {
return user.username();
}
// ユーザーパスワードを返す
@Override
public String getPassword() {
return user.password();
}
// アカウントが期限切れでないかを示す
@Override
public boolean isAccountNonExpired() {
return true;
}
// アカウントがロックされていないかを示す
@Override
public boolean isAccountNonLocked() {
return true;
}
// 資格情報が期限切れでないかを示す
@Override
public boolean isCredentialsNonExpired() {
return true;
}
// アカウントが有効かを示す
@Override
public boolean isEnabled() {
return true;
}
}
今回、DBのカラムにはユーザー名, パスワード, 権限しかないため、他の項目についてはtrueで返しています。
実際の認証の流れ
SpringSecurity で実際にどのような流れで認証が行われるかをざっくり解説します
図の参照1:Figure 4. SecurityFilterChain
図の参照2:DaoAuthenticationProvider(リファレンス)
大まかな流れ
-
リクエストが FilterChain に渡され処理が開始
-
DelegatingFilterProxy によって 認証と認可を処理するSecurityFilterChain を呼び出す
-
UsernamePasswordAuthenticationFilter でリクエスト情報からユーザー名とパスワードを抽出しUsernamePasswordAuthenticationToken(トークン) を作成
-
トークン をAuthenticationManagerに渡し認証処理を委譲
-
DaoAuthenticationProvider から UserDetailsService を呼び出しデータベース上のユーザー名とパスワードをUserDetailsオブジェクトに格納
-
DaoAuthenticationProvider でユーザー名とパスワードの認証に成功した場合、トークンがSecurityContextHolderに格納され認証処理終了
(PasswordEncoderでリクエストから取得したパスワードをハッシュ化し比較検証)
参考記事
Spring SecurityのSecurity Filterについて
Spring Security 使い方メモ 認証・認可