0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Spring Boot入門⑦】Spring Security入門 ― 認証・認可でアプリを守る

0
Posted at

株式会社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 のフィルターとの対比
  • SecurityFilterChain Bean によるセキュリティ設定
  • 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入門⑨で学んだ FilterFilterChaindoFilter() の知識がそのまま活きています。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 のように個々の機能を自前で実装する必要はありません。

デフォルト動作を体験する

依存関係を追加しただけで、何も設定を書かなくても以下の動作が有効になります。

  1. すべてのURLがログイン必須になる
  2. デフォルトのログインページ(/login)が自動生成される
  3. デフォルトユーザー名は user
  4. パスワードはアプリ起動時にコンソールに出力される

アプリケーションを起動して、任意の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画面表示の制御です。サーバーサイドのアクセス制御(SecurityFilterChainhasRole())とは独立しています。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は、ログインしていなくてもアクセスできる
  • /mypageanyRequest() はどちらも authenticated() だが、明示的に分けることで可読性が向上する
  • formLogin().permitAll() を忘れると、ログインページ自体にアクセスできなくなる

問題2 ⭐⭐(応用): ロールベースのアクセス制御

以下の要件を満たす SecurityConfig を作成してください。ユーザー定義(UserDetailsService)と PasswordEncoder も含めること。

要件:

  • /reports/**ADMIN ロールのみアクセスできる
  • /dashboardUSER または 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 には USERADMIN の両方を付与することで、全画面にアクセスできる

問題3 ⭐⭐⭐(チャレンジ): カスタムログインページ + ログアウト + 403エラーページ

以下のファイルをすべて作成し、TODO アプリにセキュリティ機能を実装してください。

要件:

  1. SecurityConfig クラス

    • /, /login, /css/** は誰でもアクセス可能
    • /todos は認証済みユーザーがアクセス可能
    • /todos/*/deleteADMIN のみ
    • /admin/**ADMIN のみ
    • ログイン成功後は /todos にリダイレクト
    • 403エラー時は /access-denied ページを表示
    • ユーザー: guest / guest123 / USER, manager / manager123 / USER, ADMIN
  2. LoginController クラス

    • /loginlogin.html
    • /access-deniedaccess-denied.html
  3. login.html(Thymeleaf)

    • ログイン失敗メッセージの表示
    • ログアウト成功メッセージの表示
  4. 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>

ポイント:

  • SecurityFilterChainexceptionHandling() で 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回(予定):

  1. Servlet/JSPからの移行と環境構築
  2. コントローラとルーティング
  3. Thymeleafによるビュー
  4. フォーム処理とバリデーション
  5. Spring Data JPA(データベース連携)
  6. RESTful API設計
  7. 👉 Spring Security(認証・認可)(本記事)
  8. 例外処理とエラーハンドリング
  9. テストの書き方(JUnit + MockMvc)
  10. 総合演習:掲示板アプリをSpring Bootで再構築

参考


@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?