Webサイトの画面フローを制御する

office-in-work システム開発全般
office-in-work

WebサイトでA→B→Cという画面遷移しか認めたくない、ブラウザバックで画面遷移した場合の操作を防ぎたいといった要件に対応する方法です。色々なやり方がありますが今回はトークンを利用して制御してみました。

他の方式との比較

他の方式との簡単な比較表です。今回の方式はSpring WebFlowの概念に近く、簡易版になります。

方式二重送信防止複数タブブラウザバック対策実装難易度
本方式(FlowID+Step管理)
PRGパターンのみ
ワンタイムトークン
Spring Web Flow

・PRGパターンのみ:二重送信防止 △:ブラウザバックと絡めると防げないケースあり
・ワンタイムトークン:複数タブ △:複数タブで同時に異なるフローを進めることは不可
・ワンタイムトークン:ブラウザバック対策 △:複数ステップ戻った後の不正な画面遷移

設計ポイント

Stepは現在画面がどのステップにいるかを表します。
FlowIDは複数タブに対応するために利用します。異なるタブで異なるフローを進める場合にステップだけでは区別できないため、FlowIDとセットで管理します。

要素役割生成タイミング格納場所詳細
FlowID各フローを一意に識別フロー開始時(最初の画面表示時)に1回だけ生成セッション内にMap形式で複数保持 + Hidden
※タブ単位
UUID等のユニークな値。フロー完了まで変わらない
Step現在の画面遷移ステップ画面遷移ごとに更新セッション(FlowIDに紐づく) + HiddenYOYAKU → CONFIRM → COMPLETE のように進行

<セッションデータ構造イメージ>

【ユーザー単位のセッション】
Session {
    FlowManager {
        flowSteps: {
            "UUID-001": "CONFIRM",   ← フロー1(タブ1で開いている)
            "UUID-002": "YOYAKU",    ← フロー2(タブ2で開いている)
            "UUID-003": "CONFIRM"    ← フロー3(タブ3で開いている)
        }
    }
}

<処理概要>
1.FlowID, Stepはセッションで保持(FlowManager)
2.画面ではModel経由で取得した値をhiddenとして保持し、リクエスト時に送信
3.サーバー側でセッションの値とリクエストの値を用いて検証(AOPで実装)

正常系シーケンス図

シーケンス図はMermaid Live Editorで作成しています。Mermaid Live Editorのテキストも記載しておきます。

sequenceDiagram
    actor User as ユーザー
    participant Browser as ブラウザ
    participant ControllerA as A画面Controller
    participant ControllerB as B画面Controller
    participant ControllerC as C画面Controller
    participant FlowMgr as FlowManager<br/>(Session)
    participant DB as データベース

    Note over User,DB: 正常フロー

    rect rgb(255, 230, 230)
        Note right of User: A画面を開く
        User->>Browser: A画面を開く
        Browser->>ControllerA: GET /pageA
        ControllerA->>FlowMgr: startNewFlow YOYAKU
        FlowMgr-->>ControllerA: flowId=UUID-001
        Note right of FlowMgr: 新規のフローID発行<br/>フローIDとステップをセッションに保存<br/>UUID-001: YOYAKU
        ControllerA-->>Browser: A画面表示<br/>flowId=UUID-001, step=YOYAKU
        Note left of Browser: A画面表示中<br/>Controller→Model→Viewを経由して<br/>セッションの値を反映したHidden<br/>flowId=UUID-001<br/>step=YOYAKU
        User->>Browser: 入力して次へボタン押下
    end

    rect rgb(230, 255, 230)
        Note right of User: B画面へ遷移
        Browser->>ControllerB: POST /pageB<br/>flowId=UUID-001, step=YOYAKU, data
        Note right of Browser: 送信内容<br/>Hidden:  flowId=UUID-001<br/>Hidden: step=YOYAKU<br/>前画面で生成された値
        ControllerB->>FlowMgr: validateAndNextStep<br/>flowId:  UUID-001<br/>currentStep:  YOYAKU<br/>nextStep: CONFIRM
        Note right of FlowMgr:  検証処理<br/>1. リクエストのflowIdが<br/>セッションに存在するか<br/>2. リクエストのstep YOYAKUと<br/>セッションのstep YOYAKUが<br/>一致するか<br/>両方OK
        FlowMgr-->>ControllerB: true 検証OK
        Note right of FlowMgr: ステップ進行<br/>UUID-001: YOYAKU→CONFIRM<br/>セッションを更新
        ControllerB-->>Browser: B画面表示<br/>flowId=UUID-001, step=CONFIRM
        Note left of Browser: B画面表示中<br/>Controller→Model→Viewを経由して<br/>セッションの値を反映したHidden<br/>flowId=UUID-001<br/>step=CONFIRM
        User->>Browser: 確認して登録ボタン押下
    end

    rect rgb(230, 230, 255)
        Note right of User: 登録実行
        Browser->>ControllerC: POST /complete<br/>flowId=UUID-001, step=CONFIRM, data
        Note right of Browser:  送信内容<br/>Hidden:  flowId=UUID-001<br/>Hidden: step=CONFIRM<br/>前画面で生成された値
        ControllerC->>FlowMgr:  validateAndNextStep<br/>flowId: UUID-001<br/>currentStep: CONFIRM<br/>nextStep: COMPLETE
        Note right of FlowMgr: 検証処理<br/>1. flowId存在チェック<br/>2. リクエストのstep CONFIRMと<br/>セッションのstep CONFIRMが<br/>一致するか<br/>両方OK
        FlowMgr-->>ControllerC: true 検証OK
        Note right of FlowMgr: ステップ進行<br/>UUID-001: CONFIRM→COMPLETE<br/>セッションを更新
        ControllerC->>DB: INSERT予約データ
        DB-->>ControllerC: 成功
        ControllerC-->>Browser: 完了画面表示
        Note left of Browser: C完了画面表示中
    end

    Note over User,DB: 正常フローでは常にController→Model→Viewを経由して<br/>セッションの値を反映したHidden値が送信され<br/>validateAndNextStepでリクエストのstepとセッションのstepを比較して<br/>一致すればセッションを次のステップに進める

正常系:画面の「戻る」ボタンのフロー

sequenceDiagram
    actor User as ユーザー
    participant Browser as ブラウザ
    participant ControllerA as A画面Controller
    participant ControllerB as B画面Controller
    participant ControllerC as C画面Controller
    participant FlowMgr as FlowManager<br/>(Session)
    participant DB as データベース

    Note over User,DB:  許可される戻る 画面の戻るボタン

    rect rgb(255, 230, 230)
        User->>Browser: A画面を開く
        Browser->>ControllerA: GET /pageA
        ControllerA->>FlowMgr: startNewFlow YOYAKU
        FlowMgr-->>ControllerA: flowId=UUID-002
        Note right of FlowMgr: 新規のフローID発行<br/>フローIDとステップをセッションに保存<br/>UUID-002: YOYAKU
        ControllerA-->>Browser: A画面表示<br/>flowId=UUID-002, step=YOYAKU
        Note left of Browser: A画面表示中<br/>Controller→Model→Viewを経由して<br/>セッションの値を反映したHidden<br/>flowId=UUID-002<br/>step=YOYAKU
        User->>Browser: 入力して次へボタン押下
    end

    rect rgb(230, 255, 230)
        Browser->>ControllerB: POST /pageB<br/>flowId=UUID-002, step=YOYAKU, data
        Note right of Browser: 送信内容<br/>Hidden: flowId=UUID-002<br/>Hidden: step=YOYAKU<br/>前画面で生成された値
        ControllerB->>FlowMgr:  validateAndNextStep<br/>flowId: UUID-002<br/>currentStep: YOYAKU<br/>nextStep: CONFIRM
        Note right of FlowMgr: 検証処理<br/>1. リクエストのflowIdが<br/>セッションに存在するか<br/>2. リクエストのstep YOYAKUと<br/>セッションのstep YOYAKUが<br/>一致するか<br/>両方OK
        FlowMgr-->>ControllerB: true 検証OK
        Note right of FlowMgr: ステップ進行<br/>UUID-002: YOYAKU→CONFIRM<br/>セッションを更新
        ControllerB-->>Browser: B画面表示<br/>flowId=UUID-002, step=CONFIRM<br/>戻るボタンあり
        Note left of Browser:  B画面表示中<br/>Controller→Model→Viewを経由して<br/>セッションの値を反映したHidden<br/>flowId=UUID-002<br/>step=CONFIRM
        Note right of User: 入力誤り発生<br/>画面の戻るボタンを押す
        User->>Browser: 画面の戻るボタン押下
    end

    rect rgb(255, 230, 230)
        Browser->>ControllerA: POST /pageA/back<br/>flowId=UUID-002, step=CONFIRM
        Note right of Browser: 送信内容<br/>Hidden: flowId=UUID-002<br/>Hidden: step=CONFIRM<br/>前画面で生成された値
        ControllerA->>FlowMgr: stepBack<br/>flowId: UUID-002<br/>currentStep:  CONFIRM<br/>previousStep: YOYAKU
        Note right of FlowMgr: 検証処理<br/>1. リクエストのflowIdが<br/>セッションに存在するか<br/>2. リクエストのstep CONFIRMと<br/>セッションのstep CONFIRMが<br/>一致するか<br/>両方OK<br/>ステップを戻す処理
        FlowMgr-->>ControllerA: true 検証OK
        Note right of FlowMgr: ステップを戻す<br/>UUID-002: CONFIRM→YOYAKU<br/>セッションを更新
        ControllerA-->>Browser: A画面表示<br/>flowId=UUID-002, step=YOYAKU<br/>入力データ復元
        Note left of Browser:  A画面に戻った<br/>Controller→Model→Viewを経由して<br/>セッションの値を反映したHidden<br/>flowId=UUID-002<br/>step=YOYAKU<br/>入力データも復元済み
        Note right of User: 入力内容を修正
        User->>Browser: 修正して次へボタン押下
    end

    rect rgb(230, 255, 230)
        Browser->>ControllerB: POST /pageB<br/>flowId=UUID-002, step=YOYAKU, data
        Note right of Browser:  送信内容<br/>Hidden: flowId=UUID-002<br/>Hidden: step=YOYAKU<br/>前画面で生成された値
        ControllerB->>FlowMgr: validateAndNextStep<br/>flowId: UUID-002<br/>currentStep:  YOYAKU<br/>nextStep: CONFIRM
        Note right of FlowMgr: 検証処理<br/>1. リクエストのflowIdが<br/>セッションに存在するか<br/>2. リクエストのstep YOYAKUと<br/>セッションのstep YOYAKUが<br/>一致するか<br/>両方OK
        FlowMgr-->>ControllerB: true 検証OK
        Note right of FlowMgr: ステップ進行<br/>UUID-002: YOYAKU→CONFIRM<br/>セッションを更新
        ControllerB-->>Browser: B画面表示<br/>flowId=UUID-002, step=CONFIRM
        Note left of Browser: B画面表示中<br/>Controller→Model→Viewを経由して<br/>セッションの値を反映したHidden<br/>flowId=UUID-002<br/>step=CONFIRM<br/>修正後の内容で確認
        User->>Browser: 確認して登録ボタン押下
    end

    rect rgb(230, 230, 255)
        Browser->>ControllerC: POST /complete<br/>flowId=UUID-002, step=CONFIRM, data
        Note right of Browser: 送信内容<br/>Hidden: flowId=UUID-002<br/>Hidden:  step=CONFIRM<br/>前画面で生成された値
        ControllerC->>FlowMgr: validateAndNextStep<br/>flowId: UUID-002<br/>currentStep:  CONFIRM<br/>nextStep:  COMPLETE
        Note right of FlowMgr: 検証処理<br/>1. flowId存在チェック<br/>2. リクエストのstep CONFIRMと<br/>セッションのstep CONFIRMが<br/>一致するか<br/>両方OK
        FlowMgr-->>ControllerC: true 検証OK
        Note right of FlowMgr: ステップ進行<br/>UUID-002: CONFIRM→COMPLETE<br/>セッションを更新
        ControllerC->>DB: INSERT予約データ
        DB-->>ControllerC: 成功
        ControllerC-->>Browser: 完了画面表示
        Note left of Browser: C完了画面表示中
    end

異常系①:登録実行時にシステムエラー発生→ブラウザバック

sequenceDiagram
    actor User as ユーザー
    participant Browser as ブラウザ
    participant ControllerA as A画面Controller
    participant ControllerB as B画面Controller
    participant ControllerC as C画面Controller
    participant FlowMgr as FlowManager<br/>(Session)
    participant DB as データベース

    Note over User,DB: エラー発生時 C画面 登録実行 でシステムエラー 最初からやり直すで復旧

    rect rgb(255, 230, 230)
        User->>Browser: A画面を開く
        Browser->>ControllerA: GET /pageA
        ControllerA->>FlowMgr: startNewFlow YOYAKU
        FlowMgr-->>ControllerA: flowId=UUID-100
        Note right of FlowMgr: 新規のフローID発行<br/>フローIDとステップをセッションに保存<br/>UUID-100: YOYAKU
        ControllerA-->>Browser: A画面表示<br/>flowId=UUID-100, step=YOYAKU
        Note left of Browser: A画面表示中<br/>Controller→Model→Viewを経由して<br/>セッションの値を反映したHidden<br/>flowId=UUID-100<br/>step=YOYAKU
        User->>Browser: 入力して次へボタン押下
    end

    rect rgb(230, 255, 230)
        Browser->>ControllerB: POST /pageB<br/>flowId=UUID-100, step=YOYAKU, data
        Note right of Browser: 送信内容<br/>Hidden: flowId=UUID-100<br/>Hidden: step=YOYAKU<br/>前画面で生成された値
        ControllerB->>FlowMgr: validateAndNextStep<br/>flowId: UUID-100<br/>currentStep:  YOYAKU<br/>nextStep: CONFIRM
        Note right of FlowMgr: 検証処理<br/>1. リクエストのflowIdが<br/>セッションに存在するか<br/>2. リクエストのstep YOYAKUと<br/>セッションのstep YOYAKUが<br/>一致するか<br/>両方OK
        FlowMgr-->>ControllerB: true 検証OK
        Note right of FlowMgr: ステップ進行<br/>UUID-100: YOYAKU→CONFIRM<br/>セッションを更新
        ControllerB-->>Browser: B画面表示<br/>flowId=UUID-100, step=CONFIRM
        Note left of Browser: B画面表示中<br/>Controller→Model→Viewを経由して<br/>セッションの値を反映したHidden<br/>flowId=UUID-100<br/>step=CONFIRM
        User->>Browser: 確認して登録ボタン押下
    end

    rect rgb(255, 200, 200)
        Note right of User: 登録実行を試みる
        Browser->>ControllerC: POST /complete<br/>flowId=UUID-100, step=CONFIRM, data
        Note right of Browser: 送信内容<br/>Hidden: flowId=UUID-100<br/>Hidden:  step=CONFIRM<br/>前画面で生成された値
        ControllerC->>FlowMgr: validateAndNextStep<br/>flowId: UUID-100<br/>currentStep:  CONFIRM<br/>nextStep:  COMPLETE
        Note right of FlowMgr: 検証処理<br/>1. flowId存在チェック<br/>2. リクエストのstep CONFIRMと<br/>セッションのstep CONFIRMが<br/>一致するか<br/>両方OK
        FlowMgr-->>ControllerC: true 検証OK
        Note right of FlowMgr: ステップ進行<br/>UUID-100: CONFIRM→COMPLETE<br/>セッションを更新
        ControllerC->>DB: INSERT予約データ
        Note right of DB: トランザクションエラー発生<br/>または業務ロジックでエラー
        DB-->>ControllerC: NG 何らかの例外が発生
        ControllerC-->>Browser: エラー画面<br/>システムエラーが発生しました<br/>最初からやり直すリンク
        Note left of Browser: エラー画面表示中
        Note right of FlowMgr: セッションの状態<br/>UUID-100: COMPLETE<br/>既に進んでいる
    end

    rect rgb(230, 255, 230)
        Note right of User: ブラウザバックで戻ってみる
        User->>Browser: ブラウザバックボタン押下
        Note left of Browser: B画面が表示された<br/>ブラウザキャッシュから復元したHidden<br/>flowId=UUID-100<br/>step=CONFIRM
        Browser-->>User: B画面が表示される
        Note right of User: もう一度登録してみる
        User->>Browser: 再度登録ボタン押下
    end

    rect rgb(255, 200, 200)
        Browser->>ControllerC: POST /complete<br/>flowId=UUID-100, step=CONFIRM, data
        Note right of Browser: 送信内容<br/>Hidden: flowId=UUID-100<br/>Hidden:  step=CONFIRM<br/>キャッシュから復元された古い値
        ControllerC->>FlowMgr: validateAndNextStep<br/>flowId: UUID-100<br/>currentStep: CONFIRM<br/>nextStep: COMPLETE
        Note right of FlowMgr: 検証処理<br/>1. flowId存在チェック<br/>2. リクエストのstep CONFIRMと<br/>セッションのstep COMPLETEが<br/>一致するか<br/>NG 不一致
        FlowMgr-->>ControllerC: false 検証NG
        ControllerC-->>Browser: エラー画面<br/>不正な画面遷移です<br/>最初からやり直すリンク
        Note left of Browser:  NG エラー画面表示<br/>不正な画面遷移を検出
        Note right of DB: OK エラー時の再実行を防止
    end

    rect rgb(255, 230, 230)
        Note right of User: 最初からやり直すを選択
        User->>Browser:  最初からやり直すクリック
        Browser->>ControllerA: GET /pageA restart=true
        ControllerA->>FlowMgr: startNewFlow YOYAKU
        FlowMgr-->>ControllerA: flowId=UUID-101
        Note right of FlowMgr: 新規のフローID発行<br/>フローIDとステップをセッションに保存<br/>UUID-101: YOYAKU
        ControllerA-->>Browser: A画面表示<br/>flowId=UUID-101, step=YOYAKU
        Note left of Browser: A画面表示中<br/>Controller→Model→Viewを経由して<br/>セッションの値を反映したHidden<br/>flowId=UUID-101<br/>step=YOYAKU<br/>最初から入力し直す
    end

    Note over User,DB: OK 登録実行時にエラーが発生した場合 サーバー側のステップは進んでいる状態 COMPLETE<br/>ブラウザバックで戻った場合 画面のhiddenはキャッシュから復元され 古い値 CONFIRM となるため<br/>リクエスト時にサーバー側で不一致を検出可能

実装サンプル

Controller

/**
 * 予約画面Controller
 * FlowManagerへの直接的な依存はAOPで分離
 */
@Controller
public class ReservationController {
    
    @Autowired
    private FlowManager flowManager;
    
    @Autowired
    private ReservationService reservationService;
    
    /**
     * A画面(予約入力画面)表示
     * フロー開始 - 新しいFlowIDを発行
     * 
     * @param model ビューに渡すデータ
     * @return A画面のビュー名
     */
    @GetMapping("/pageA")
    public String pageA(Model model) {
        
        // 新しいフローを開始 - FlowID発行
        String flowId = flowManager.startNewFlow("YOYAKU");
        
        // セッションから生成したHidden値をビューに渡す
        model.addAttribute("flowId", flowId);
        model.addAttribute("step", "YOYAKU");
        
        // 空のフォームオブジェクト
        model.addAttribute("reservationForm", new ReservationForm());
        
        return "pageA";
    }
    
    /**
     * B画面(確認画面)へ遷移
     * AOP(@ValidateFlow)で自動的に検証
     * @param flowId リクエストから送信されたFlowID(Hidden)
     * @param step リクエストから送信されたステップ(Hidden)
     * @param form 予約フォーム(ユーザー入力データ)
     * @param model ビューに渡すデータ
     * @return B画面のビュー名
     */
    @PostMapping("/pageB")
    @ValidateFlow(currentStep = "YOYAKU", nextStep = "CONFIRM")
    public String pageB(@RequestParam String flowId,
                       @RequestParam String step,
                       @ModelAttribute ReservationForm form,
                       Model model) {
        
        // ★ ここに到達した時点で、AOPによる検証は完了している
        // ★ セッションのステップはYOYAKU → CONFIRM に更新されている
        
        // 業務処理
        
        // 次の画面用のHidden値をビューに渡す
        model.addAttribute("flowId", flowId);
        model.addAttribute("step", "CONFIRM");
        model.addAttribute("reservationForm", form);
        
        return "pageB";
    }
    
    /**
     * A画面へ戻る(画面の「戻る」ボタン)
     * AOP(@StepBack)で自動的にステップを戻す
     * 
     * @param flowId リクエストから送信されたFlowID
     * @param step リクエストから送信されたステップ(CONFIRM)
     * @param form 予約フォーム
     * @param model ビューに渡すデータ
     * @return A画面のビュー名
     */
    @PostMapping("/pageA/back")
    @StepBack(currentStep = "CONFIRM", previousStep = "YOYAKU")
    public String pageABack(@RequestParam String flowId,
                           @RequestParam String step,
                           @ModelAttribute ReservationForm form,
                           Model model) {
        
        // ★ AOPでstepBack(flowId, "CONFIRM", "YOYAKU")実行済み
        // ★ セッションのステップは既に CONFIRM → YOYAKU に戻されている
        
        // 入力データを復元してA画面に戻す
        model.addAttribute("flowId", flowId);
        model.addAttribute("step", "YOYAKU");
        model.addAttribute("reservationForm", form);  // 入力データ復元
        
        return "pageA";
    }
}

アノテーション

package com.example.reservation.annotation;

import java.lang.annotation.ElementType;
import java.lang. annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang. annotation.Target;

/**
 * フロー検証アノテーション
 * Controllerメソッドに付与することで、画面遷移の妥当性を検証する
 */
@Target(ElementType.METHOD)  // メソッドに対してのみ付与可能
@Retention(RetentionPolicy.RUNTIME)  // 実行時にアノテーション情報を保持
public @interface ValidateFlow {
    
    /**
     * 現在のステップ
     * リクエストで送信されるべきステップ値
     * @return ステップ名(例:  "YOYAKU", "CONFIRM")
     */
    String currentStep();
    
    /**
     * 次のステップ
     * 検証OKの場合に進むべきステップ
     * @return ステップ名(例: "CONFIRM", "COMPLETE")
     */
    String nextStep();
}
package com.example.reservation.annotation;

import java.lang.annotation.ElementType;
import java.lang. annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang. annotation.Target;

/**
 * ステップバック(戻る)アノテーション
 * 画面の「戻る」ボタン用
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface StepBack {
    
    /**
     * 現在のステップ
     */
    String currentStep();
    
    /**
     * 戻り先のステップ
     */
    String previousStep();
}

フロー管理

package com.example.reservation.flow;

import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.stereotype.Component;

import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

/**
 * フロー管理クラス
 */
@Component
@Scope(value = "session", proxyMode = ScopedProxyMode. TARGET_CLASS)
public class FlowManager {
    
    /**
     * FlowIDとステップのマッピング
     * Key: FlowID (UUID)
     * Value: 現在のステップ (例: "YOYAKU", "CONFIRM", "COMPLETE")
     */
    private Map<String, String> flowSteps = new ConcurrentHashMap<>();
    
    /**
     * 新しいフローを開始
     * 
     * シーケンス図での呼び出し:
     * - ControllerA->>FlowMgr: startNewFlow YOYAKU
     * - FlowMgr-->>ControllerA: flowId=UUID-001
     * 
     * @param initialStep 初期ステップ(例: "YOYAKU")
     * @return 生成されたFlowID
     */
    public String startNewFlow(String initialStep) {
        // UUIDで一意なFlowIDを生成
        String flowId = UUID.randomUUID().toString();
        
        // セッション内のMapに保存
        // 例: { "UUID-001": "YOYAKU" }
        flowSteps.put(flowId, initialStep);
        
        return flowId;
    }
    
    /**
     * フロー検証 & 次ステップへ進行
     * 
     * シーケンス図での呼び出し:
     * - ControllerB->>FlowMgr:  validateAndNextStep
     *     flowId:  UUID-001
     *     currentStep: YOYAKU
     *     nextStep: CONFIRM
     * - FlowMgr-->>ControllerB: true 検証OK
     * 
     * 検証内容:
     * 1. FlowIDの存在チェック(セッションタイムアウト検出)
     * 2. リクエストのstepとセッションのstepの一致確認(ブラウザバック検出)
     * 
     * @param flowId リクエストから送信されたFlowID
     * @param currentStep リクエストから送信された現在のステップ
     * @param nextStep 次に進むべきステップ
     * @return true:  検証OK(ステップ更新済み), false: 検証NG
     */
    public boolean validateAndNextStep(String flowId, String currentStep, String nextStep) {
        // 1. FlowIDの存在チェック
        if (!flowSteps.containsKey(flowId)) {
            // セッションタイムアウトまたは不正なFlowID
            return false;
        }
        
        // 2. セッション内のステップを取得
        String sessionStep = flowSteps.get(flowId);
        
        // 3. リクエストのステップとセッションのステップが一致するか確認
        if (!currentStep.equals(sessionStep)) {
            // ブラウザバックや不正な画面遷移を検出
            // 例: リクエスト=CONFIRM, セッション=COMPLETE → NG(二重送信)
            return false;
        }
        
        // 4. 検証OKの場合、セッションを次のステップに更新
        // 例: UUID-001: YOYAKU → CONFIRM
        flowSteps.put(flowId, nextStep);
        
        return true;
    }
    
    /**
     * ステップを戻す(画面の「戻る」ボタン用)
     * 
     * シーケンス図での呼び出し:
     * - ControllerA->>FlowMgr: stepBack
     *     flowId:  UUID-002
     *     currentStep: CONFIRM
     *     previousStep: YOYAKU
     * - FlowMgr-->>ControllerA: true 検証OK
     * 
     * @param flowId リクエストから送信されたFlowID
     * @param currentStep リクエストから送信された現在のステップ
     * @param previousStep 戻り先のステップ
     * @return true: 検証OK(ステップ更新済み), false: 検証NG
     */
    public boolean stepBack(String flowId, String currentStep, String previousStep) {
        // 1. FlowIDの存在チェック
        if (!flowSteps.containsKey(flowId)) {
            return false;
        }
        
        // 2. セッション内のステップを取得
        String sessionStep = flowSteps.get(flowId);
        
        // 3. リクエストのステップとセッションのステップが一致するか確認
        if (! currentStep.equals(sessionStep)) {
            return false;
        }
        
        // 4. 検証OKの場合、セッションを前のステップに戻す
        // 例: UUID-002: CONFIRM → YOYAKU
        flowSteps.put(flowId, previousStep);
        
        return true;
    }
    
    /**
     * FlowIDを削除(フロー完了時やクリーンアップ時)
     * 
     * @param flowId 削除するFlowID
     */
    public void removeFlow(String flowId) {
        flowSteps.remove(flowId);
    }
    
    /**
     * 現在のステップを取得(デバッグ用)
     * 
     * @param flowId FlowID
     * @return 現在のステップ、存在しない場合はnull
     */
    public String getCurrentStep(String flowId) {
        return flowSteps.get(flowId);
    }
}

フロー検証

package com.example.reservation. aop;

import com.example. reservation.annotation.StepBack;
import com.example.reservation.annotation.ValidateFlow;
import com.example.reservation.exception.InvalidFlowException;
import com.example.reservation.flow.FlowManager;
import org.aspectj. lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang. annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;

/**
 * フロー検証Aspect
 * @ValidateFlow および @StepBack アノテーションが付与されたメソッドに対して
 * 画面遷移の妥当性を検証する
 * 
 * - Controllerメソッド実行前に validateAndNextStep または stepBack を実行
 * - 検証NGの場合はControllerメソッドを実行せず、エラー画面を返す
 */
@Aspect
@Component
public class FlowValidationAspect {
    
    @Autowired
    private FlowManager flowManager;
    
    /**
     * @ValidateFlow アノテーションが付与されたメソッドの実行前後に処理を行う
     * 
     * @param joinPoint 実行対象のメソッド情報
     * @param validateFlow アノテーション情報
     * @return Controllerメソッドの戻り値
     * @throws Throwable メソッド実行時の例外
     */
    @Around("@annotation(validateFlow)")
    public Object validateFlow(ProceedingJoinPoint joinPoint, ValidateFlow validateFlow) throws Throwable {
        
        // 1. HTTPリクエストを取得
        ServletRequestAttributes attributes = 
            (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        
        if (attributes == null) {
            throw new InvalidFlowException("リクエスト情報の取得に失敗しました");
        }
        
        HttpServletRequest request = attributes. getRequest();
        
        // 2. リクエストパラメータからflowIdとstepを取得
        String flowId = request.getParameter("flowId");
        String step = request.getParameter("step");
        
        // 3. パラメータの存在チェック
        if (flowId == null || step == null) {
            throw new InvalidFlowException("必須パラメータが不足しています");
        }
        
        // 4. アノテーションから期待するステップを取得
        String expectedCurrentStep = validateFlow.currentStep();
        String nextStep = validateFlow.nextStep();
        
        // 5. FlowManagerで検証 & ステップ更新
        boolean valid = flowManager.validateAndNextStep(flowId, expectedCurrentStep, nextStep);
        
        if (! valid) {
            // 検証NG:  ブラウザバックや不正な画面遷移を検出
            // 例: リクエストstep=CONFIRM, セッションstep=COMPLETE
            //     → 二重送信を検出
            
            // Controllerメソッドを実行せず、エラー画面を返す
            return "error";
        }
        
        // 6. 検証OK:  Controllerメソッドを実行        
        Object result = joinPoint.proceed();
        
        return result;
    }
    
    /**
     * @StepBack アノテーションが付与されたメソッドの実行前後に処理を行う
     * 画面の「戻る」ボタン用
     * 
     * @param joinPoint 実行対象のメソッド情報
     * @param stepBack アノテーション情報
     * @return Controllerメソッドの戻り値
     * @throws Throwable メソッド実行時の例外
     */
    @Around("@annotation(stepBack)")
    public Object stepBack(ProceedingJoinPoint joinPoint, StepBack stepBack) throws Throwable {
        
        // 1. HTTPリクエストを取得
        ServletRequestAttributes attributes = 
            (ServletRequestAttributes) RequestContextHolder. getRequestAttributes();
        
        if (attributes == null) {
            throw new InvalidFlowException("リクエスト情報の取得に失敗しました");
        }
        
        HttpServletRequest request = attributes.getRequest();
        
        // 2. リクエストパラメータからflowIdとstepを取得
        String flowId = request.getParameter("flowId");
        String step = request.getParameter("step");
        
        // 3. パラメータの存在チェック
        if (flowId == null || step == null) {
            throw new InvalidFlowException("必須パラメータが不足しています");
        }
        
        // 4. アノテーションから期待するステップを取得
        String expectedCurrentStep = stepBack. currentStep();
        String previousStep = stepBack.previousStep();
                
        // 5. FlowManagerでステップを戻す
        // シーケンス図:
        // - ControllerA->>FlowMgr:  stepBack
        // - 検証処理
        // - FlowMgr-->>ControllerA: true 検証OK
        // - セッション更新: UUID-002: CONFIRM → YOYAKU
        boolean valid = flowManager. stepBack(flowId, expectedCurrentStep, previousStep);
        
        if (!valid) {
            return "error";
        }
        
        // 6. 検証OK:  Controllerメソッドを実行
        Object result = joinPoint.proceed();
        
        return result;
    }
}

以上

タイトルとURLをコピーしました