Skip to content
Playground

12.セッションとフラッシュメッセージ

学習目標

  • リクエストスコープ・フラッシュ属性・セッションスコープを、値が残る範囲の違いとして説明できる
  • RedirectAttributes でフラッシュ属性を渡し、PRG のリダイレクト後に結果メッセージを表示できる
  • HttpSession に値を保存し、複数のリクエストにまたがってログイン状態を保持できる
  • 未ログインのアクセスをログイン画面にリダイレクトして、管理操作を保護できる
  • ログアウトでセッションを破棄できる
  • パスワードを平文で扱わず、ハッシュで照合する理由を説明できる

1. リクエストをまたぐ状態の保持

Section titled “1. リクエストをまたぐ状態の保持”

Model による受け渡しModel に渡した値は、その画面を返したあとは残りません。本章では、リクエストをまたいで値を残す 2 つの仕組み、フラッシュ属性とセッションを扱います。

HTTP は ステートレス(状態を持たない)なプロトコルです。サーバーは 1 つのリクエストにレスポンスを返すと、そのやり取りで使った値を保持しません。次のリクエストは、前のリクエストの内容を知らない状態で届きます。

Model の値が 1 回のリクエストの間だけ有効なのも、この性質によるものです。サーブレットの リクエストスコープ と同じで、画面を組み立てて返す処理は 1 回のリクエストで完結します。

一方で、リクエストをまたいで値を残したい場面があります。

  • 本を登録したあと、一覧の画面で「登録しました」と知らせたい。PRG パターン でリダイレクトすると、Model の値は次のリクエストに残らない。
  • 一度ログインした利用者を、その後のリクエストでもログイン済みとして扱いたい。

前者は 1 回のリダイレクトの間だけ、後者は利用者がブラウザーを使っている間、値を残す必要があります。

値が有効な範囲を スコープ と呼びます。本章で扱う 2 つの仕組みは、Model のリクエストスコープより広い範囲に値を残します。

仕組み値が残る範囲渡し方
リクエストスコープ1 回のリクエストの処理の間Model
フラッシュ属性リダイレクト直後の 1 リクエストだけRedirectAttributes
セッションスコープ同じ利用者がブラウザーを使っている間HttpSession

リダイレクト後の結果メッセージにはフラッシュ属性を、ログイン状態にはセッションを使います。フラッシュ属性を フラッシュメッセージ、セッションを セッション で順に扱います。

2-1. リダイレクトで失われるメッセージ

Section titled “2-1. リダイレクトで失われるメッセージ”

本の登録create は、登録したあと一覧へリダイレクトします。

BookController.java(11 章の状態)
@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:/パス";
}

createRedirectAttributes を加え、登録したタイトルをメッセージとして渡します。

BookController.java(create を更新)
@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.RedirectAttributesimport が加わります。addFlashAttribute("message", ...) で入れた値は、リダイレクト先の /books の処理で Model に入った状態で取り出せます。一覧 books.html の先頭に、メッセージを表示する段落を加えます。

templates/books.html(メッセージを表示)
<p th:if="${message}" th:text="${message}">登録しました</p>

th:if="${message}" は、message があるときだけこの段落を表示します。/books/newこころ を登録すると、/books へリダイレクトされ、一覧の上に「『こころ』を登録しました」が表示されます。

更新・削除も同じく、リダイレクトの前に addFlashAttribute でメッセージを渡せます。

操作メッセージの例
登録「こころ」を登録しました
更新「こころ」を更新しました
削除「こころ」を削除しました

addFlashAttribute で渡す、リダイレクト先で 1 回だけ取り出せる値を フラッシュ属性 と呼びます。Spring は、この値をいったんセッションに保存し、リダイレクト先のリクエストで Model に移します。そのリクエストで取り出されると、値はすぐに削除されます。

そのため、メッセージはリダイレクト直後の 1 回だけ表示され、再読み込みすると出ません。結果を一度だけ伝えるのに向いた仕組みです。

フラッシュ属性は、内部ではセッションを使っています。リクエストをまたいで値を保持するセッションを、次の節で扱います。

HTTP はリクエストごとに独立しているため、サーバーは前のリクエストを覚えていません。利用者がログインしても、何もしなければ次のリクエストではその事実が失われます。ログインした状態のように、リクエストをまたいで利用者ごとに値を保持する仕組みが セッション です。

セッションは、利用者ごとにサーバー側で値を保持します。1 つのリクエストが終わっても値が残り、同じ利用者からの次のリクエストで取り出せます。

複数のブラウザーが同時にアクセスしても、サーバーは利用者ごとに別々の保管領域を用意し、それぞれに セッション ID という識別子を割り当てます。セッション ID は最初のレスポンスでブラウザーに渡され、ブラウザーは次からのリクエストで毎回それを送り返します。サーバーは送られた ID を見て、どの利用者の保管領域かを判別します。ID の受け渡しは Cookie(ブラウザーが保存し、リクエストのたびに自動で送り返す小さな値)が担うため、利用者が ID を意識することはありません。

Spring の Controller では、メソッドの引数に HttpSessionjakarta.servlet.http.HttpSession)を書くと、Spring Boot がその利用者のセッションを渡します。サーブレットで使うものと同じ HttpSession です。

HttpSession には、値を保存・取得・破棄する操作があります。ログイン状態の保持は、この 3 つを使います。

操作書き方ログインでの使いみち
保存session.setAttribute("属性名", 値)ログインに成功したとき、「ログイン済み」の印を残す
取得session.getAttribute("属性名")リクエストごとに、ログイン済みかどうかを確かめる
破棄session.invalidate()ログアウトのとき、保持した値をすべて消す

まず、簡単な例でセッションの動きを確かめます。アクセスのたびに訪問回数を 1 増やして表示する VisitController です。戻り値をそのまま本文として返す @RestController を使います。

src/main/java/com/example/bookstore/VisitController.java
package com.example.bookstore;
import jakarta.servlet.http.HttpSession;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public 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 がまだ保存されていないため、getAttributenull を返します。そのときは 0 として扱い、1 を足して保存します。2 回目以降は前回保存した値が取り出せるので、/visit をリロードするたびに数が増えます。リクエストは独立していても、セッションが値を残すためです。

ログイン状態も、同じ仕組みで保持します。利用者を表す値(ユーザー名)を setAttribute で保存し、その有無を getAttribute で調べ、invalidate で消します。これらを組み立てたログインは、ログイン認証 で作ります。

ログインは、2 つの処理を組み合わせます。1 つは、ユーザー名とパスワードが正しいかを確かめる 認証。もう 1 つは、認証に成功したことを セッション に記録し、以降のリクエストでもログイン済みとして扱うことです。前者を 4-1、後者を 4-2 で扱います。

認証は、入力されたユーザー名とパスワードを、登録済みの資格情報と照合します。資格情報は users テーブルに保存します。このとき、パスワードを 平文(そのままの文字列)で保存してはいけません。保存先が漏れると、パスワードがそのまま流出するためです。

パスワードは ハッシュ(元に戻せない一方向の変換をした値)にして保存し、照合では入力値を同じ方法でハッシュ化して突き合わせます。ハッシュ化には、Spring が提供する BCryptPasswordEncoder を使います。これは spring-security-crypto ライブラリーに含まれます。

pom.xml
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>

資格情報を保存する users テーブルを作り、管理者を 1 件登録します。password_hash には、平文ではなくハッシュを入れます。

users テーブル
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() で受け取ります。

src/main/java/com/example/bookstore/UserRepository.java
package com.example.bookstore;
import java.util.Optional;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
@Repository
public 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 に置きます。BCryptPasswordEncodermatches は、入力した平文と保存済みのハッシュが対応するかを判定します。

src/main/java/com/example/bookstore/UserService.java
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;
@Service
public 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 の結果を返します。AuthControllerUserServiceUserRepository という 3 層 は、本の BookControllerBookServiceBookRepository と同じ並びです。

authenticate は、資格情報が正しいかを truefalse で返すだけで、ログインした状態は覚えていません。状態を保持するのは セッション の役割です。

ログインとは、認証に成功したことをセッションに記録すること です。記録はセッションに残るため、次のリクエストでも「ログイン済み」として扱えます。ログアウトは、その記録を消すことです。次の 3 つのリクエストで実現します。

パスメソッド役割
/loginGETログインフォームを表示する
/loginPOST認証し、成功なら「ログイン済み」をセッションに記録する
/logoutPOSTセッションを破棄する

入力をまとめて受け取るため、フォームクラスLoginForm を作ります。

src/main/java/com/example/bookstore/LoginForm.java
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>nameLoginForm のフィールドに合わせます。

templates/login.html
<!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 に任せます。

src/main/java/com/example/bookstore/AuthController.java
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;
@Controller
public 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 メソッドで、次の順で動きます。

  1. フォームが送った値を、@ModelAttributeLoginForm に受け取る。
  2. userService.authenticate で、ユーザー名とパスワードが正しいかを確かめる。
  3. 正しければ、session.setAttribute("username", ...) でユーザー名をセッションに記録する。これが「ログイン済み」の印になり、以降のリクエストに残る。そのうえで一覧 /books へリダイレクトする。
  4. 正しくなければ、/login へリダイレクトしてフォームを再表示する。

成功も失敗もリダイレクトを挟むので、画面に出すメッセージは フラッシュ属性 で渡します。記録したユーザー名は以降のリクエストで取り出せ、ログイン状態による保護 でログイン済みかの判定に使います。

POST /logout を処理する logout メソッドは、session.invalidate() でセッションを破棄します。記録したユーザー名が消え、利用者は未ログインに戻ります。ログアウトはデータを変える操作なので、削除 と同じくフォームの POST で送ります。

管理操作のメソッドの先頭で、セッションにログイン状態が記録されているかを確認します。なければログイン画面へリダイレクトします。登録フォームを表示する newBook です。

BookController.java(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 と同じく引数に HttpSessionRedirectAttributes を加えます。一覧の books と詳細の detail には加えず、公開のままにします。

ログインの状態とログアウトは、どのページからも見えるよう、全ページ共通のヘッダーに置きます。各ページに同じ内容を書く代わりに、フラグメント として 1 か所に定義します。fragments/layout.htmlheader フラグメントを加えます。

templates/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> 先頭で、このヘッダーを挿入します。

templates/books.html(ヘッダーを挿入)
<body>
<header th:replace="~{fragments/layout :: header}"></header>
<!-- 一覧の内容 -->
</body>

詳細や編集のページにも同じ <header> を挿入すると、どのページでもログイン状態が見え、ログアウトできます。未ログインで /books/new を開くと /login にリダイレクトされ、ログイン後は登録フォームを開けます。

管理操作のメソッドごとに書いたログイン確認は、リクエストを横断して挟む HandlerInterceptorSpring MVC の HandlerInterceptor)で 1 か所にまとめられます。認証・認可・CSRF 対策などを体系的に担う Spring Security は、本番のアプリで使います。