株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。
Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
▶コーポレートサイト
はじめに
前回(第6回)では、RESTful API設計の原則を学び、TODOアプリのAPIをリソース指向で再設計しました。
しかし、ここまで作ってきたAPIや画面には致命的な問題があります。
誰でもアクセスできる。
TODOの作成・削除はもちろん、管理者向けの操作まで、URLさえ知っていれば誰でも実行できてしまいます。実際のWebアプリケーションでは、「誰がアクセスしているのか」「その人に操作する権限があるのか」を必ず確認する必要があります。
第7回では、Spring Security を使って、認証(ログイン)と認可(アクセス制御)の仕組みを学びます。
今回学ぶこと
- 認証(Authentication)と認可(Authorization)の違い
- Spring Security の仕組み(SecurityFilterChain)
- Servlet/JSP のフィルターとの対比
-
SecurityFilterChainBean によるセキュリティ設定 - URL別のアクセス制御(
permitAll(),authenticated(),hasRole()) - インメモリ認証とパスワードエンコーディング(BCrypt)
- フォームログイン・ログアウト(Thymeleafカスタムページ)
- 実践例:TODO アプリにログイン機能+ロールベースの権限制御を追加
本記事のコードはすべて第1回で作成した hello-spring プロジェクト(com.example.hellospring パッケージ)上で動作します。環境構築がまだの方は第1回を先にご覧ください。
本記事では Spring Boot 3.5.x(Spring Security 6.x)の新APIを使用しています。Spring Security 5.x 以前で使われていた WebSecurityConfigurerAdapter を継承する方式は 非推奨・削除済み のため、本記事では扱いません。
1. 認証と認可
認証(Authentication)と認可(Authorization)
セキュリティを学ぶ上で最も重要な概念は、認証 と 認可 の区別です。
| 概念 | 英語 | 意味 | 例 |
|---|---|---|---|
| 認証 | Authentication | 「あなたは誰か?」を確認する | ログイン(ユーザー名+パスワード) |
| 認可 | Authorization | 「あなたに権限があるか?」を確認する | 管理者だけが削除できる |
認証と認可は必ずこの順番で行われます。「誰か」がわからなければ「権限があるか」を判断できないからです。
① 認証:ユーザー名 + パスワードでログイン → 「田中さんだ」と判明
② 認可:田中さんのロールは USER → 削除権限なし → 403 Forbidden
Servlet/JSP のフィルターによる認証との対比
Servlet/JSP入門⑨では、jakarta.servlet.Filter を使って認証チェックを自作しました。
// Servlet/JSP入門⑨で学んだ認証フィルター(概要)
@WebFilter("/*")
public class AuthFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpSession session = request.getSession(false);
// ログインページはスキップ
if (request.getRequestURI().endsWith("/login")) {
chain.doFilter(req, res);
return;
}
// セッションにユーザー情報がなければリダイレクト
if (session == null || session.getAttribute("user") == null) {
((HttpServletResponse) res).sendRedirect(request.getContextPath() + "/login");
return;
}
chain.doFilter(req, res);
}
}
この方法には問題がありました。
| 課題 | 説明 |
|---|---|
| すべて自前実装 | パスワードのハッシュ化、セッション管理、CSRF対策を自分で書く必要がある |
| ロールベース認可がない | 「管理者だけ許可」のようなルールを自力で実装する必要がある |
| セキュリティホールのリスク | 実装漏れや誤りがそのまま脆弱性になる |
Spring Security は、これらをすべてフレームワークとして提供します。
2. Spring Security の仕組み
SecurityFilterChain
Spring Security の中核は SecurityFilterChain です。Servlet/JSP で学んだ FilterChain と同じ概念ですが、セキュリティに特化した複数のフィルターが自動的にチェーンとして登録されます。
ブラウザ
↓
[DelegatingFilterProxy] ← Servlet Filter(Spring との橋渡し)
↓
[FilterChainProxy] ← Spring Security のエントリーポイント
↓
[SecurityFilterChain] ← セキュリティフィルターの連鎖
├── CsrfFilter ← CSRF トークンの検証
├── UsernamePasswordAuthenticationFilter ← フォームログイン処理
├── AuthorizationFilter ← URL別のアクセス制御
└── ...(約15個のフィルター)
↓
Controller
Servlet/JSP の FilterChain は、開発者がフィルターを1つずつ作って @WebFilter で登録しました。Spring Security では、spring-boot-starter-security を追加するだけで、セキュリティに必要なフィルター群が自動的に構成されます。
Servlet Filter との関係
Spring Security は Servlet Filter の仕組みの上に構築されています。
Servlet Filter の世界
┌──────────────────────────────────────────┐
│ EncodingFilter(文字コード設定) │ ← 自前のフィルター
│ DelegatingFilterProxy │ ← Spring Security が登録
│ └── FilterChainProxy │
│ └── SecurityFilterChain │
│ ├── CsrfFilter │
│ ├── Authentication Filter │
│ └── Authorization Filter │
│ 他のServlet Filter ... │
└──────────────────────────────────────────┘
↓
DispatcherServlet → Controller
つまり、Servlet/JSP入門⑨で学んだ Filter → FilterChain → doFilter() の知識がそのまま活きています。Spring Security は、その仕組みを「セキュリティ用に体系化・自動化したもの」です。
3. 環境構築
pom.xml に依存関係を追加
pom.xml の <dependencies> セクションに以下を追加します。
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Thymeleaf + Spring Security 連携(sec:authorize 等) -->
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>
spring-boot-starter-security を追加するだけで、Spring Security の全機能(認証、認可、CSRF 対策、セッション管理など)が有効になります。Servlet/JSP のように個々の機能を自前で実装する必要はありません。
デフォルト動作を体験する
依存関係を追加しただけで、何も設定を書かなくても以下の動作が有効になります。
- すべてのURLがログイン必須になる
- デフォルトのログインページ(
/login)が自動生成される - デフォルトユーザー名は
user - パスワードはアプリ起動時にコンソールに出力される
アプリケーションを起動して、任意のURL(例:http://localhost:8080/)にアクセスしてみてください。自動的にログインページにリダイレクトされます。
コンソールに以下のようなログが出力されます。
Using generated security password: 8a2c4e6f-1234-5678-9abc-def012345678
このパスワードと、ユーザー名 user を入力するとログインできます。
デフォルトのパスワードはアプリ起動ごとに変わります。開発用の一時的なものであり、本番環境では使いません。
4. SecurityFilterChain の設定
設定クラスの作成
Spring Security 6.x では、@Configuration クラスに SecurityFilterChain を @Bean として定義します。
package com.example.hellospring.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.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/css/**", "/js/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/todos")
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/login?logout")
.permitAll()
);
return http.build();
}
}
各設定の詳細
authorizeHttpRequests ― URL別のアクセス制御
| メソッド | 意味 |
|---|---|
requestMatchers("/", "/css/**") |
指定したURLパターンに一致するリクエストを対象にする |
.permitAll() |
認証なし(誰でも)アクセスできる |
.authenticated() |
認証済み(ログイン済み)ユーザーのみアクセスできる |
.hasRole("ADMIN") |
指定したロールを持つユーザーのみアクセスできる |
.anyRequest() |
上記のルールに一致しなかったすべてのリクエスト |
ルールの順序が重要です。 requestMatchers() は上から順に評価され、最初に一致したルールが適用されます。anyRequest() は必ず最後に書いてください。
formLogin ― フォーム認証の設定
| メソッド | 意味 |
|---|---|
loginPage("/login") |
カスタムログインページのURLを指定 |
defaultSuccessUrl("/todos") |
ログイン成功後のリダイレクト先 |
failureUrl("/login?error") |
ログイン失敗時のリダイレクト先(デフォルト) |
permitAll() |
ログインページ自体は認証不要にする |
logout ― ログアウトの設定
| メソッド | 意味 |
|---|---|
logoutSuccessUrl("/login?logout") |
ログアウト成功後のリダイレクト先 |
permitAll() |
ログアウト処理は認証不要にする |
CSRF(Cross-Site Request Forgery)の扱い
Spring Security はデフォルトで CSRF 対策が有効 です。
| 用途 | CSRF設定 |
|---|---|
| Thymeleaf画面(フォーム送信) | 有効のまま(Thymeleafが自動でCSRFトークンを埋め込む) |
| REST API(JSON通信) | 無効化する場合が多い |
REST API で CSRF を無効化する場合は、以下のように設定します。
@Bean
public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatchers(matchers -> matchers
.requestMatchers("/api/**")
)
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults());
return http.build();
}
CSRF 保護の無効化は、ステートレスな REST API(トークンベース認証)に限定してください。フォームを使う画面では CSRF 保護を有効にしておくべきです。
複数の SecurityFilterChain Bean を定義する場合は、@Order アノテーションで評価順序を指定する必要があります。より限定的なパターン(/api/**)を持つ方に @Order(1) を付けてください。
5. インメモリ認証(簡易ユーザー管理)
UserDetailsService と PasswordEncoder の設定
開発・学習段階では、データベースを使わずにメモリ上にユーザー情報を定義できます。
package com.example.hellospring.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.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/css/**", "/js/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/todos")
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/login?logout")
.permitAll()
);
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails user = User.builder()
.username("user")
.password(passwordEncoder().encode("password123"))
.roles("USER")
.build();
UserDetails admin = User.builder()
.username("admin")
.password(passwordEncoder().encode("admin123"))
.roles("USER", "ADMIN")
.build();
return new InMemoryUserDetailsManager(user, admin);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
各コンポーネントの役割
| コンポーネント | 役割 |
|---|---|
UserDetailsService |
ユーザー情報を提供するインターフェース。Spring Security が認証時にこの Bean を使ってユーザーを検索する |
InMemoryUserDetailsManager |
UserDetailsService の実装。メモリ上にユーザー情報を保持する |
UserDetails |
ユーザーの認証情報(ユーザー名、パスワード、ロール等)を保持するインターフェース |
PasswordEncoder |
パスワードのハッシュ化と検証を行う。BCryptPasswordEncoder が推奨 |
ロール(Role)とは
ロールは、ユーザーに付与する権限のグループです。
admin ユーザー → ROLE_USER, ROLE_ADMIN(一般操作 + 管理操作が可能)
user ユーザー → ROLE_USER(一般操作のみ可能)
Spring Security では、ロール名に自動的に ROLE_ プレフィックスが付与されます。roles("ADMIN") と書くと、内部的には ROLE_ADMIN として扱われます。hasRole("ADMIN") で判定する際も、ROLE_ プレフィックスは自動的に補完されるため、書く必要はありません。
パスワードのハッシュ化
パスワードを平文で保存するのは絶対にNGです。BCrypt はパスワードをハッシュ化するアルゴリズムで、同じパスワードでも毎回異なるハッシュ値を生成します(ソルトが自動付与されるため)。
平文: password123
BCryptハッシュ: $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
User.withDefaultPasswordEncoder() はテスト用のメソッドであり、本番環境では使用しないでください。本記事のように BCryptPasswordEncoder を明示的に使用するのが正しい方法です。
6. フォームログイン(Thymeleaf カスタムページ)
ログインページの Controller
package com.example.hellospring.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class LoginController {
@GetMapping("/login")
public String loginPage() {
return "login";
}
}
ログインページ(login.html)
src/main/resources/templates/login.html に作成します。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>ログイン</title>
<style>
body { font-family: sans-serif; max-width: 400px; margin: 80px auto; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; font-weight: bold; }
input[type="text"], input[type="password"] {
width: 100%; padding: 8px; box-sizing: border-box;
border: 1px solid #ccc; border-radius: 4px;
}
.btn { padding: 10px 20px; background: #007bff; color: white;
border: none; border-radius: 4px; cursor: pointer; width: 100%; }
.btn:hover { background: #0056b3; }
.error { color: red; background: #ffe0e0; padding: 10px; border-radius: 4px; }
.success { color: green; background: #e0ffe0; padding: 10px; border-radius: 4px; }
</style>
</head>
<body>
<h2>ログイン</h2>
<!-- ログイン失敗時のメッセージ -->
<div th:if="${param.error}" class="error">
ユーザー名またはパスワードが正しくありません。
</div>
<!-- ログアウト成功時のメッセージ -->
<div th:if="${param.logout}" class="success">
ログアウトしました。
</div>
<!-- ログインフォーム -->
<form th:action="@{/login}" method="post">
<div class="form-group">
<label for="username">ユーザー名</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">パスワード</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn">ログイン</button>
</form>
</body>
</html>
th:action="@{/login}" を使うと、Thymeleaf が自動的に CSRF トークンを hidden フィールドとして埋め込みます。Servlet/JSP では CSRF 対策を自前で実装する必要がありましたが、Spring Security + Thymeleaf ではフレームワークが自動処理します。
ログインフォームのポイント
| 要素 | 説明 |
|---|---|
method="post" |
ログイン情報は必ず POST で送信(GET だとURLにパスワードが露出する) |
name="username" |
Spring Security がデフォルトで期待するパラメータ名 |
name="password" |
Spring Security がデフォルトで期待するパラメータ名 |
th:action="@{/login}" |
CSRF トークンが自動付与される |
param.error |
URLに ?error が含まれるかを判定(ログイン失敗時にSpring Securityがリダイレクト) |
param.logout |
URLに ?logout が含まれるかを判定(ログアウト成功時にリダイレクト) |
ログアウト
Spring Security はデフォルトで /logout エンドポイントを提供します。POST /logout でログアウトできます。
画面にログアウトボタンを配置するには、以下のようにフォームを追加します。
<form th:action="@{/logout}" method="post">
<button type="submit">ログアウト</button>
</form>
ログアウトも POST で行います。これは CSRF 対策のためです。GET でログアウトすると、悪意のあるサイトから <img src="https://example.com/logout"> のようにログアウトさせられる可能性があります。
7. 実践例:TODO アプリにログイン機能を追加
第5回で作成した TODO アプリに、以下の要件でセキュリティを追加します。
要件:
- ログインしないと TODO 画面にアクセスできない
- 一般ユーザー(USER)は TODO の閲覧・作成ができる
- 管理者(ADMIN)のみ TODO の削除ができる
- カスタムログインページを使用する
- 403エラー(アクセス拒否)ページを用意する
7.1 SecurityConfig(完全版)
package com.example.hellospring.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.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
// 静的リソースとログインページは誰でもアクセス可能
.requestMatchers("/", "/login", "/css/**", "/js/**").permitAll()
// TODO削除は管理者のみ
.requestMatchers("/todos/*/delete").hasRole("ADMIN")
// その他のリクエストは認証必須
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/todos", true)
.failureUrl("/login?error")
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/login?logout")
.permitAll()
)
.exceptionHandling(ex -> ex
.accessDeniedPage("/access-denied")
);
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails user = User.builder()
.username("user")
.password(passwordEncoder().encode("password123"))
.roles("USER")
.build();
UserDetails admin = User.builder()
.username("admin")
.password(passwordEncoder().encode("admin123"))
.roles("USER", "ADMIN")
.build();
return new InMemoryUserDetailsManager(user, admin);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
7.2 LoginController
package com.example.hellospring.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class LoginController {
@GetMapping("/login")
public String loginPage() {
return "login";
}
@GetMapping("/access-denied")
public String accessDenied() {
return "access-denied";
}
}
7.3 TodoController(ロール制御を反映)
package com.example.hellospring.controller;
import com.example.hellospring.entity.Todo;
import com.example.hellospring.service.TodoService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.security.Principal;
import java.util.List;
@Controller
@RequestMapping("/todos")
public class TodoController {
private final TodoService todoService;
public TodoController(TodoService todoService) {
this.todoService = todoService;
}
@GetMapping
public String list(Model model, Principal principal) {
List<Todo> todos = todoService.findAll();
model.addAttribute("todos", todos);
model.addAttribute("username", principal.getName());
return "todos";
}
@PostMapping
public String create(@RequestParam String title) {
todoService.create(title);
return "redirect:/todos";
}
@PostMapping("/{id}/delete")
public String delete(@PathVariable Long id) {
// SecurityConfigで ADMIN のみに制限済み
todoService.delete(id);
return "redirect:/todos";
}
}
Principal は、認証済みユーザーの情報を表すインターフェースです。principal.getName() でログイン中のユーザー名を取得できます。Spring Security が認証完了後に自動的にセットするため、Controllerのメソッド引数に書くだけで使えます。
7.4 TODO画面(todos.html)
src/main/resources/templates/todos.html に作成します。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<meta charset="UTF-8">
<title>TODOリスト</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 40px auto; }
.header { display: flex; justify-content: space-between; align-items: center; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background: #f5f5f5; }
.btn { padding: 5px 15px; border: none; border-radius: 4px; cursor: pointer; }
.btn-add { background: #28a745; color: white; }
.btn-delete { background: #dc3545; color: white; }
.btn-logout { background: #6c757d; color: white; }
.add-form { margin-top: 20px; }
.add-form input { padding: 8px; width: 300px; }
</style>
</head>
<body>
<div class="header">
<h2>TODOリスト</h2>
<div>
<span th:text="'ログイン中: ' + ${username}"></span>
<!-- ロール表示 -->
<span sec:authorize="hasRole('ADMIN')">(管理者)</span>
<form th:action="@{/logout}" method="post" style="display:inline;">
<button type="submit" class="btn btn-logout">ログアウト</button>
</form>
</div>
</div>
<!-- TODO追加フォーム -->
<div class="add-form">
<form th:action="@{/todos}" method="post">
<input type="text" name="title" placeholder="新しいTODOを入力" required>
<button type="submit" class="btn btn-add">追加</button>
</form>
</div>
<!-- TODOリスト -->
<table th:if="${!#lists.isEmpty(todos)}">
<thead>
<tr>
<th>ID</th>
<th>タイトル</th>
<th sec:authorize="hasRole('ADMIN')">操作</th>
</tr>
</thead>
<tbody>
<tr th:each="todo : ${todos}">
<td th:text="${todo.id}"></td>
<td th:text="${todo.title}"></td>
<!-- ADMIN ロールのユーザーにのみ削除ボタンを表示 -->
<td sec:authorize="hasRole('ADMIN')">
<form th:action="@{/todos/{id}/delete(id=${todo.id})}" method="post"
style="display:inline;">
<button type="submit" class="btn btn-delete">削除</button>
</form>
</td>
</tr>
</tbody>
</table>
<p th:if="${#lists.isEmpty(todos)}">TODOはまだ登録されていません。</p>
</body>
</html>
sec:authorize 属性
thymeleaf-extras-springsecurity6 を追加すると、Thymeleaf テンプレートで sec:authorize 属性が使えるようになります。
| 属性 | 意味 |
|---|---|
sec:authorize="hasRole('ADMIN')" |
ADMIN ロールを持つユーザーにのみ表示する |
sec:authorize="isAuthenticated()" |
認証済みユーザーにのみ表示する |
sec:authorize="isAnonymous()" |
未認証ユーザーにのみ表示する |
sec:authorize は画面表示の制御です。サーバーサイドのアクセス制御(SecurityFilterChain の hasRole())とは独立しています。sec:authorize で削除ボタンを非表示にしても、直接 POST リクエストを送れば削除できてしまいます。必ず SecurityFilterChain でもアクセス制御を設定してください(本記事の SecurityConfig では .requestMatchers("/todos/*/delete").hasRole("ADMIN") で設定済みです)。
7.5 アクセス拒否ページ(access-denied.html)
認証済みだが権限がない場合(403 Forbidden)に表示されるページです。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>アクセス拒否</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 80px auto;
text-align: center; }
.error-code { font-size: 72px; color: #dc3545; margin-bottom: 0; }
.error-message { font-size: 24px; color: #333; }
a { color: #007bff; text-decoration: none; }
</style>
</head>
<body>
<p class="error-code">403</p>
<p class="error-message">アクセスが拒否されました</p>
<p>このページにアクセスする権限がありません。</p>
<a th:href="@{/todos}">TODOリストに戻る</a>
</body>
</html>
7.6 動作確認
アプリケーションを起動して、以下の動作を確認してください。
| 操作 | 期待される結果 |
|---|---|
http://localhost:8080/todos にアクセス |
ログインページにリダイレクト |
user / password123 でログイン |
TODO画面に遷移。削除ボタンは表示されない |
| TODO を追加する | 正常に追加される |
admin / admin123 でログイン |
TODO画面に遷移。削除ボタンが表示される |
admin で TODO を削除する |
正常に削除される |
| ログアウトボタンをクリック | ログインページにリダイレクト。「ログアウトしました」メッセージが表示される |
7.7 認証の流れ(全体像)
① ユーザーが /todos にアクセス
↓
② AuthorizationFilter: 「認証されていない」 → /login にリダイレクト
↓
③ ログインフォームに username / password を入力して POST /login
↓
④ UsernamePasswordAuthenticationFilter:
- UserDetailsService から username でユーザーを検索
- PasswordEncoder でパスワードを検証
- 認証成功 → SecurityContext にユーザー情報を保存
↓
⑤ defaultSuccessUrl("/todos") にリダイレクト
↓
⑥ AuthorizationFilter: 「認証済み + 権限OK」 → Controller へ
↓
⑦ TodoController.list() が実行される
8. 練習問題
問題1 ⭐(基本): URL別のアクセス制御
以下の要件を満たす SecurityFilterChain を作成してください。
要件:
-
/と/public/**は誰でもアクセスできる -
/mypageは認証済みユーザーのみアクセスできる - その他のURLは認証必須
模範解答
package com.example.hellospring.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.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/public/**").permitAll()
.requestMatchers("/mypage").authenticated()
.anyRequest().authenticated()
)
.formLogin(form -> form
.permitAll()
)
.logout(logout -> logout
.permitAll()
);
return http.build();
}
}
ポイント:
-
permitAll()を指定したURLは、ログインしていなくてもアクセスできる -
/mypageとanyRequest()はどちらもauthenticated()だが、明示的に分けることで可読性が向上する -
formLogin().permitAll()を忘れると、ログインページ自体にアクセスできなくなる
問題2 ⭐⭐(応用): ロールベースのアクセス制御
以下の要件を満たす SecurityConfig を作成してください。ユーザー定義(UserDetailsService)と PasswordEncoder も含めること。
要件:
-
/reports/**はADMINロールのみアクセスできる -
/dashboardはUSERまたはADMINロールでアクセスできる -
/と/loginは誰でもアクセスできる - ユーザーは以下の2名(パスワードは BCrypt でハッシュ化すること)
-
tanaka/pass1234/ ロール:USER -
suzuki/admin5678/ ロール:USER,ADMIN
-
模範解答
package com.example.hellospring.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.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login").permitAll()
.requestMatchers("/reports/**").hasRole("ADMIN")
.requestMatchers("/dashboard").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/dashboard")
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/login?logout")
.permitAll()
);
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails tanaka = User.builder()
.username("tanaka")
.password(passwordEncoder().encode("pass1234"))
.roles("USER")
.build();
UserDetails suzuki = User.builder()
.username("suzuki")
.password(passwordEncoder().encode("admin5678"))
.roles("USER", "ADMIN")
.build();
return new InMemoryUserDetailsManager(tanaka, suzuki);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
ポイント:
-
hasAnyRole("USER", "ADMIN")は、いずれかのロールを持っていればアクセスを許可する -
hasRole("ADMIN")は ADMIN ロールのみ許可。/reports/**のルールを/dashboardより前に書くことで、ADMIN 限定の制約が優先される -
suzukiにはUSERとADMINの両方を付与することで、全画面にアクセスできる
問題3 ⭐⭐⭐(チャレンジ): カスタムログインページ + ログアウト + 403エラーページ
以下のファイルをすべて作成し、TODO アプリにセキュリティ機能を実装してください。
要件:
-
SecurityConfigクラス-
/,/login,/css/**は誰でもアクセス可能 -
/todosは認証済みユーザーがアクセス可能 -
/todos/*/deleteはADMINのみ -
/admin/**はADMINのみ - ログイン成功後は
/todosにリダイレクト - 403エラー時は
/access-deniedページを表示 - ユーザー:
guest/guest123/USER,manager/manager123/USER, ADMIN
-
-
LoginControllerクラス-
/login→login.html -
/access-denied→access-denied.html
-
-
login.html(Thymeleaf)- ログイン失敗メッセージの表示
- ログアウト成功メッセージの表示
-
access-denied.html(Thymeleaf)- 「アクセス権限がありません」メッセージ
- TODOリストへの戻りリンク
模範解答
SecurityConfig.java:
package com.example.hellospring.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.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/css/**").permitAll()
.requestMatchers("/todos/*/delete").hasRole("ADMIN")
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/todos").authenticated()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/todos", true)
.failureUrl("/login?error")
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/login?logout")
.permitAll()
)
.exceptionHandling(ex -> ex
.accessDeniedPage("/access-denied")
);
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails guest = User.builder()
.username("guest")
.password(passwordEncoder().encode("guest123"))
.roles("USER")
.build();
UserDetails manager = User.builder()
.username("manager")
.password(passwordEncoder().encode("manager123"))
.roles("USER", "ADMIN")
.build();
return new InMemoryUserDetailsManager(guest, manager);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
LoginController.java:
package com.example.hellospring.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class LoginController {
@GetMapping("/login")
public String loginPage() {
return "login";
}
@GetMapping("/access-denied")
public String accessDenied() {
return "access-denied";
}
}
login.html:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>ログイン</title>
<style>
body { font-family: sans-serif; max-width: 400px; margin: 80px auto; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; font-weight: bold; }
input[type="text"], input[type="password"] {
width: 100%; padding: 8px; box-sizing: border-box;
border: 1px solid #ccc; border-radius: 4px;
}
.btn { padding: 10px 20px; background: #007bff; color: white;
border: none; border-radius: 4px; cursor: pointer; width: 100%; }
.btn:hover { background: #0056b3; }
.error { color: red; background: #ffe0e0; padding: 10px;
border-radius: 4px; margin-bottom: 15px; }
.success { color: green; background: #e0ffe0; padding: 10px;
border-radius: 4px; margin-bottom: 15px; }
</style>
</head>
<body>
<h2>ログイン</h2>
<div th:if="${param.error}" class="error">
ユーザー名またはパスワードが正しくありません。
</div>
<div th:if="${param.logout}" class="success">
ログアウトしました。
</div>
<form th:action="@{/login}" method="post">
<div class="form-group">
<label for="username">ユーザー名</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">パスワード</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn">ログイン</button>
</form>
</body>
</html>
access-denied.html:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>アクセス拒否</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 80px auto;
text-align: center; }
.error-code { font-size: 72px; color: #dc3545; margin-bottom: 0; }
.error-message { font-size: 24px; color: #333; }
a { color: #007bff; text-decoration: none; }
</style>
</head>
<body>
<p class="error-code">403</p>
<p class="error-message">アクセス権限がありません</p>
<p>このページを表示する権限がありません。<br>
必要な権限がある場合は、管理者にお問い合わせください。</p>
<a th:href="@{/todos}">TODOリストに戻る</a>
</body>
</html>
ポイント:
-
SecurityFilterChainのexceptionHandling()で 403 エラー時のページを設定している - ログインフォームの
name属性(username,password)は Spring Security が期待するデフォルト値と一致させる -
th:action="@{/login}"により CSRF トークンが自動的に埋め込まれる - ルールの順序に注意:
/todos/*/delete(ADMIN限定)を/todos(認証済み)より前に書く
まとめ
Servlet/JSP vs Spring Security の対比表
| 比較項目 | Servlet/JSP(自前実装) | Spring Security |
|---|---|---|
| 認証の仕組み | Filter + Session で自前実装 |
SecurityFilterChain がフレームワークとして提供 |
| パスワード管理 | 平文 or 自前でハッシュ化 |
BCryptPasswordEncoder が標準提供 |
| URL別アクセス制御 | Filter 内で if 文で分岐 |
requestMatchers().hasRole() で宣言的に記述 |
| CSRF 対策 | 自前でトークン生成・検証 | デフォルトで有効(Thymeleaf連携で自動埋め込み) |
| ロール管理 | Session 属性を使って自前管理 |
UserDetails + GrantedAuthority で統一管理 |
| ログインページ | JSP で自作 + Servlet でセッション管理 |
formLogin() で設定するだけ |
| ログアウト | Session を invalidate して自前実装 |
logout() で設定するだけ(セッション破棄も自動) |
| 設定量 | すべて手動で実装 | Bean 定義のみで完結 |
Spring Security の主要コンポーネント
┌─────────────────────────────────────────────────────┐
│ SecurityFilterChain │
│ - URL別のアクセス制御ルールを定義 │
│ - フォームログイン / ログアウトの設定 │
│ - CSRF・例外ハンドリングの設定 │
├─────────────────────────────────────────────────────┤
│ UserDetailsService │
│ - ユーザー情報を提供するインターフェース │
│ - InMemoryUserDetailsManager(メモリ) │
│ - JdbcUserDetailsManager(DB)※今後学習 │
├─────────────────────────────────────────────────────┤
│ PasswordEncoder │
│ - パスワードのハッシュ化・検証 │
│ - BCryptPasswordEncoder が推奨 │
└─────────────────────────────────────────────────────┘
次回予告
次回(第8回)は 例外処理とエラーハンドリング を学びます。@ExceptionHandler、@ControllerAdvice によるグローバル例外処理、カスタムエラーページの作成、REST API のエラーレスポンス設計などを扱います。
Spring Boot入門シリーズ 全10回(予定):
- Servlet/JSPからの移行と環境構築
- コントローラとルーティング
- Thymeleafによるビュー
- フォーム処理とバリデーション
- Spring Data JPA(データベース連携)
- RESTful API設計
- 👉 Spring Security(認証・認可)(本記事)
- 例外処理とエラーハンドリング
- テストの書き方(JUnit + MockMvc)
- 総合演習:掲示板アプリをSpring Bootで再構築
参考
- Spring Security 公式リファレンス
- Spring Security - Servlet Architecture
- Spring Security - Authorize HttpServletRequests
- Spring Security - In-Memory Authentication
- Spring Security - Form Login
- Spring Security - Handling Logouts
- Thymeleaf + Spring Security 連携
@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!