12.セッションとフラッシュメッセージ
学習目標
- リクエストスコープ・フラッシュ属性・セッションスコープを、値が残る範囲の違いとして説明できる
RedirectAttributesでフラッシュ属性を渡し、PRG のリダイレクト後に結果メッセージを表示できるHttpSessionに値を保存し、複数のリクエストにまたがってログイン状態を保持できる- 未ログインのアクセスをログイン画面にリダイレクトして、管理操作を保護できる
- ログアウトでセッションを破棄できる
- パスワードを平文で扱わず、ハッシュで照合する理由を説明できる
1. リクエストをまたぐ状態の保持
Section titled “1. リクエストをまたぐ状態の保持”Model による受け渡し で Model に渡した値は、その画面を返したあとは残りません。本章では、リクエストをまたいで値を残す 2 つの仕組み、フラッシュ属性とセッションを扱います。
1-1. リクエストの独立性
Section titled “1-1. リクエストの独立性”HTTP は ステートレス(状態を持たない)なプロトコルです。サーバーは 1 つのリクエストにレスポンスを返すと、そのやり取りで使った値を保持しません。次のリクエストは、前のリクエストの内容を知らない状態で届きます。
Model の値が 1 回のリクエストの間だけ有効なのも、この性質によるものです。サーブレットの リクエストスコープ と同じで、画面を組み立てて返す処理は 1 回のリクエストで完結します。
一方で、リクエストをまたいで値を残したい場面があります。
- 本を登録したあと、一覧の画面で「登録しました」と知らせたい。PRG パターン でリダイレクトすると、
Modelの値は次のリクエストに残らない。 - 一度ログインした利用者を、その後のリクエストでもログイン済みとして扱いたい。
前者は 1 回のリダイレクトの間だけ、後者は利用者がブラウザーを使っている間、値を残す必要があります。
1-2. 値が残る範囲
Section titled “1-2. 値が残る範囲”値が有効な範囲を スコープ と呼びます。本章で扱う 2 つの仕組みは、Model のリクエストスコープより広い範囲に値を残します。
| 仕組み | 値が残る範囲 | 渡し方 |
|---|---|---|
| リクエストスコープ | 1 回のリクエストの処理の間 | Model |
| フラッシュ属性 | リダイレクト直後の 1 リクエストだけ | RedirectAttributes |
| セッションスコープ | 同じ利用者がブラウザーを使っている間 | HttpSession |
リダイレクト後の結果メッセージにはフラッシュ属性を、ログイン状態にはセッションを使います。フラッシュ属性を フラッシュメッセージ、セッションを セッション で順に扱います。
2. フラッシュメッセージ
Section titled “2. フラッシュメッセージ”2-1. リダイレクトで失われるメッセージ
Section titled “2-1. リダイレクトで失われるメッセージ”本の登録 の create は、登録したあと一覧へリダイレクトします。
@PostMapping("/books")public String create(@ModelAttribute BookForm form) { bookService.register(form); return "redirect:/books";}登録は一覧に反映されますが、「登録しました」という知らせは画面に出ません。model.addAttribute で渡しても、PRG パターン のリダイレクト先 /books は別のリクエストなので、値は引き継がれません。
2-2. RedirectAttributes による受け渡し
Section titled “2-2. RedirectAttributes による受け渡し”リダイレクト先に値を渡すには、引数に RedirectAttributes を受け取り、addFlashAttribute で値を入れます。
public String メソッド名(..., RedirectAttributes redirectAttributes) { redirectAttributes.addFlashAttribute("属性名", 値); return "redirect:/パス";}create に RedirectAttributes を加え、登録したタイトルをメッセージとして渡します。
@PostMapping("/books")public String create(@ModelAttribute BookForm form, RedirectAttributes redirectAttributes) { bookService.register(form); redirectAttributes.addFlashAttribute("message", "「" + form.getTitle() + "」を登録しました"); return "redirect:/books";}org.springframework.web.servlet.mvc.support.RedirectAttributes の import が加わります。addFlashAttribute("message", ...) で入れた値は、リダイレクト先の /books の処理で Model に入った状態で取り出せます。一覧 books.html の先頭に、メッセージを表示する段落を加えます。
<p th:if="${message}" th:text="${message}">登録しました</p>th:if="${message}" は、message があるときだけこの段落を表示します。/books/new で こころ を登録すると、/books へリダイレクトされ、一覧の上に「『こころ』を登録しました」が表示されます。
更新・削除も同じく、リダイレクトの前に addFlashAttribute でメッセージを渡せます。
| 操作 | メッセージの例 |
|---|---|
| 登録 | 「こころ」を登録しました |
| 更新 | 「こころ」を更新しました |
| 削除 | 「こころ」を削除しました |
2-3. フラッシュ属性
Section titled “2-3. フラッシュ属性”addFlashAttribute で渡す、リダイレクト先で 1 回だけ取り出せる値を フラッシュ属性 と呼びます。Spring は、この値をいったんセッションに保存し、リダイレクト先のリクエストで Model に移します。そのリクエストで取り出されると、値はすぐに削除されます。
そのため、メッセージはリダイレクト直後の 1 回だけ表示され、再読み込みすると出ません。結果を一度だけ伝えるのに向いた仕組みです。
フラッシュ属性は、内部ではセッションを使っています。リクエストをまたいで値を保持するセッションを、次の節で扱います。
3. セッション
Section titled “3. セッション”HTTP はリクエストごとに独立しているため、サーバーは前のリクエストを覚えていません。利用者がログインしても、何もしなければ次のリクエストではその事実が失われます。ログインした状態のように、リクエストをまたいで利用者ごとに値を保持する仕組みが セッション です。
3-1. セッションの仕組み
Section titled “3-1. セッションの仕組み”セッションは、利用者ごとにサーバー側で値を保持します。1 つのリクエストが終わっても値が残り、同じ利用者からの次のリクエストで取り出せます。
複数のブラウザーが同時にアクセスしても、サーバーは利用者ごとに別々の保管領域を用意し、それぞれに セッション ID という識別子を割り当てます。セッション ID は最初のレスポンスでブラウザーに渡され、ブラウザーは次からのリクエストで毎回それを送り返します。サーバーは送られた ID を見て、どの利用者の保管領域かを判別します。ID の受け渡しは Cookie(ブラウザーが保存し、リクエストのたびに自動で送り返す小さな値)が担うため、利用者が ID を意識することはありません。
Spring の Controller では、メソッドの引数に HttpSession(jakarta.servlet.http.HttpSession)を書くと、Spring Boot がその利用者のセッションを渡します。サーブレットで使うものと同じ HttpSession です。
3-2. 値の保存・取得・破棄
Section titled “3-2. 値の保存・取得・破棄”HttpSession には、値を保存・取得・破棄する操作があります。ログイン状態の保持は、この 3 つを使います。
| 操作 | 書き方 | ログインでの使いみち |
|---|---|---|
| 保存 | session.setAttribute("属性名", 値) | ログインに成功したとき、「ログイン済み」の印を残す |
| 取得 | session.getAttribute("属性名") | リクエストごとに、ログイン済みかどうかを確かめる |
| 破棄 | session.invalidate() | ログアウトのとき、保持した値をすべて消す |
まず、簡単な例でセッションの動きを確かめます。アクセスのたびに訪問回数を 1 増やして表示する VisitController です。戻り値をそのまま本文として返す @RestController を使います。
package com.example.bookstore;
import jakarta.servlet.http.HttpSession;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RestController;
@RestControllerpublic class VisitController { @GetMapping("/visit") public String visit(HttpSession session) { Integer count = (Integer) session.getAttribute("count"); if (count == null) { count = 0; } count = count + 1; session.setAttribute("count", count); return "訪問回数: " + count; }}初回のアクセスでは count がまだ保存されていないため、getAttribute は null を返します。そのときは 0 として扱い、1 を足して保存します。2 回目以降は前回保存した値が取り出せるので、/visit をリロードするたびに数が増えます。リクエストは独立していても、セッションが値を残すためです。
ログイン状態も、同じ仕組みで保持します。利用者を表す値(ユーザー名)を setAttribute で保存し、その有無を getAttribute で調べ、invalidate で消します。これらを組み立てたログインは、ログイン認証 で作ります。
4. ログイン認証
Section titled “4. ログイン認証”ログインは、2 つの処理を組み合わせます。1 つは、ユーザー名とパスワードが正しいかを確かめる 認証。もう 1 つは、認証に成功したことを セッション に記録し、以降のリクエストでもログイン済みとして扱うことです。前者を 4-1、後者を 4-2 で扱います。
4-1. 認証(資格情報の照合)
Section titled “4-1. 認証(資格情報の照合)”認証は、入力されたユーザー名とパスワードを、登録済みの資格情報と照合します。資格情報は users テーブルに保存します。このとき、パスワードを 平文(そのままの文字列)で保存してはいけません。保存先が漏れると、パスワードがそのまま流出するためです。
パスワードは ハッシュ(元に戻せない一方向の変換をした値)にして保存し、照合では入力値を同じ方法でハッシュ化して突き合わせます。ハッシュ化には、Spring が提供する BCryptPasswordEncoder を使います。これは spring-security-crypto ライブラリーに含まれます。
<dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-crypto</artifactId></dependency>資格情報を保存する users テーブルを作り、管理者を 1 件登録します。password_hash には、平文ではなくハッシュを入れます。
CREATE TABLE users ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(255) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL);
INSERT INTO users (username, password_hash)VALUES ('admin', '$2a$10$......(生成したハッシュ)......');ユーザー名からハッシュを取り出す UserRepository を、JdbcClient で書きます。該当する利用者がいないこともあるため、結果は optional() で受け取ります。
package com.example.bookstore;
import java.util.Optional;import org.springframework.jdbc.core.simple.JdbcClient;import org.springframework.stereotype.Repository;
@Repositorypublic class UserRepository { private final JdbcClient jdbcClient;
public UserRepository(JdbcClient jdbcClient) { this.jdbcClient = jdbcClient; }
public Optional<String> findPasswordHash(String username) { return jdbcClient.sql("SELECT password_hash FROM users WHERE username = :username") .param("username", username) .query(String.class) .optional(); }}照合のロジックは UserService に置きます。BCryptPasswordEncoder の matches は、入力した平文と保存済みのハッシュが対応するかを判定します。
package com.example.bookstore;
import java.util.Optional;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;import org.springframework.security.crypto.password.PasswordEncoder;import org.springframework.stereotype.Service;
@Servicepublic class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
public UserService(UserRepository userRepository) { this.userRepository = userRepository; }
public boolean authenticate(String username, String password) { Optional<String> hash = userRepository.findPasswordHash(username); if (hash.isEmpty()) { return false; } return passwordEncoder.matches(password, hash.get()); }}findPasswordHash が空(該当ユーザーなし)なら false、ハッシュがあれば matches の結果を返します。AuthController → UserService → UserRepository という 3 層 は、本の BookController → BookService → BookRepository と同じ並びです。
authenticate は、資格情報が正しいかを true/false で返すだけで、ログインした状態は覚えていません。状態を保持するのは セッション の役割です。
4-2. ログインとログアウト
Section titled “4-2. ログインとログアウト”ログインとは、認証に成功したことをセッションに記録すること です。記録はセッションに残るため、次のリクエストでも「ログイン済み」として扱えます。ログアウトは、その記録を消すことです。次の 3 つのリクエストで実現します。
| パス | メソッド | 役割 |
|---|---|---|
/login | GET | ログインフォームを表示する |
/login | POST | 認証し、成功なら「ログイン済み」をセッションに記録する |
/logout | POST | セッションを破棄する |
入力をまとめて受け取るため、フォームクラス の LoginForm を作ります。
package com.example.bookstore;
public class LoginForm { private String username; private String password;
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; }}GET /login が返すフォーム login.html です。<input> の name を LoginForm のフィールドに合わせます。
<!DOCTYPE html><html lang="ja" xmlns:th="http://www.thymeleaf.org"><head th:replace="~{fragments/layout :: head('ログイン')}"></head><body> <h1>ログイン</h1> <p th:if="${message}" th:text="${message}">メッセージ</p> <form action="/login" method="post"> <label for="username">ユーザー名:</label> <input type="text" id="username" name="username"> <label for="password">パスワード:</label> <input type="password" id="password" name="password"> <button type="submit">ログイン</button> </form></body></html><input type="password"> は、入力した文字を伏せ字で表示する入力欄です。<p th:if="${message}"> には、照合に失敗したときのメッセージが表示されます。
3 つのリクエストを処理する AuthController です。照合は UserService に任せます。
package com.example.bookstore;
import jakarta.servlet.http.HttpSession;import org.springframework.stereotype.Controller;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.ModelAttribute;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controllerpublic class AuthController { private final UserService userService;
public AuthController(UserService userService) { this.userService = userService; }
@GetMapping("/login") public String loginForm() { return "login"; }
@PostMapping("/login") public String login(@ModelAttribute LoginForm form, HttpSession session, RedirectAttributes redirectAttributes) { if (userService.authenticate(form.getUsername(), form.getPassword())) { session.setAttribute("username", form.getUsername()); redirectAttributes.addFlashAttribute("message", "ログインしました"); return "redirect:/books"; } redirectAttributes.addFlashAttribute("message", "ユーザー名かパスワードが違います"); return "redirect:/login"; }
@PostMapping("/logout") public String logout(HttpSession session) { session.invalidate(); return "redirect:/books"; }}GET /login を処理する loginForm は、ビュー名 "login" を返してフォームを表示するだけです。中心は、フォームの送信先である POST /login を処理する login メソッドで、次の順で動きます。
- フォームが送った値を、
@ModelAttributeでLoginFormに受け取る。 userService.authenticateで、ユーザー名とパスワードが正しいかを確かめる。- 正しければ、
session.setAttribute("username", ...)でユーザー名をセッションに記録する。これが「ログイン済み」の印になり、以降のリクエストに残る。そのうえで一覧/booksへリダイレクトする。 - 正しくなければ、
/loginへリダイレクトしてフォームを再表示する。
成功も失敗もリダイレクトを挟むので、画面に出すメッセージは フラッシュ属性 で渡します。記録したユーザー名は以降のリクエストで取り出せ、ログイン状態による保護 でログイン済みかの判定に使います。
POST /logout を処理する logout メソッドは、session.invalidate() でセッションを破棄します。記録したユーザー名が消え、利用者は未ログインに戻ります。ログアウトはデータを変える操作なので、削除 と同じくフォームの POST で送ります。
4-3. ログイン状態による保護
Section titled “4-3. ログイン状態による保護”管理操作のメソッドの先頭で、セッションにログイン状態が記録されているかを確認します。なければログイン画面へリダイレクトします。登録フォームを表示する newBook です。
@GetMapping("/books/new")public String newBook(HttpSession session, RedirectAttributes redirectAttributes) { if (session.getAttribute("username") == null) { redirectAttributes.addFlashAttribute("message", "ログインしてください"); return "redirect:/login"; } return "books/new";}session.getAttribute("username") が null なら未ログインです。フラッシュ属性でメッセージを渡し、/login へリダイレクトします。同じ確認を、登録の create・編集フォームの editForm・更新の update・削除の delete の先頭にも加えます。これらのメソッドにも、newBook と同じく引数に HttpSession と RedirectAttributes を加えます。一覧の books と詳細の detail には加えず、公開のままにします。
ログインの状態とログアウトは、どのページからも見えるよう、全ページ共通のヘッダーに置きます。各ページに同じ内容を書く代わりに、フラグメント として 1 か所に定義します。fragments/layout.html に header フラグメントを加えます。
<header th:fragment="header"> <span th:if="${session.username}" th:text="${session.username} + ' さん'">admin さん</span> <form th:if="${session.username}" action="/logout" method="post" style="display:inline"> <button type="submit">ログアウト</button> </form> <a th:if="${session.username == null}" href="/login">ログイン</a></header>${session.username} は、セッションに保存した username を参照します。ログイン中はユーザー名とログアウトボタンを、未ログインのときはログインへのリンクを表示します。
各ページの <body> 先頭で、このヘッダーを挿入します。
<body> <header th:replace="~{fragments/layout :: header}"></header> <!-- 一覧の内容 --></body>詳細や編集のページにも同じ <header> を挿入すると、どのページでもログイン状態が見え、ログアウトできます。未ログインで /books/new を開くと /login にリダイレクトされ、ログイン後は登録フォームを開けます。
管理操作のメソッドごとに書いたログイン確認は、リクエストを横断して挟む HandlerInterceptor(Spring MVC の HandlerInterceptor)で 1 か所にまとめられます。認証・認可・CSRF 対策などを体系的に担う Spring Security は、本番のアプリで使います。