R e i - D r e a m
for Laravel
Guest
login

最終投稿日:2026年08月30日

不正遷移対策

不正遷移対策とは?

[F5][ヒストリーバック]等、不正遷移を管理するのは大変です。
何故ってブラウザ依存の機能だからです。
システム屋からするとこれらブラウザ依存の機能は使われると困ります。
何故って本来いるハズの画面Aから、サーバを通さずに画面Bにいる状態になるからですよ。
こんな状況を想定したシステム構築なんて無理だし、そもそも無意味です。
Webアプリにおいて動作を保証しているのは、あくまで『ドキュメント』の部分なんですよ。(HTMLが描画される範囲)
これは暗黙の了解だし、わざわざクライアントと認識すら合わせないレベルの話です。
もうコレ、テレビを指さして『アプリ起動しないぞっ』て言われてる様なものです(PC必要です)
しかしそんな事をクライアントにストレートには言えません。
なので本質は『ブラウザ動作を保証してるのではなく、ドキュメント動作を保証している』の姿勢は崩さずに、
『とは言え、ブラウザ操作における最低限の管理はしますよ』的な、サービス向上に努める姿勢を見せるのが大事になります。
これでクライアントも『お、サービスしてくれるんだね』と思い、話が丸く収まります。
ではどうやって管理するか?
答えは、トークン管理を使います。
では何処で管理すか?
そりゃWeb通信の入口である『フィルタ』ですよね。

フィルタの作成

フィルタと言えば、以前『バッキングビーン2』で作成しました。
今回は『filter/CharacterEncodingFilter.java』を修正して不正遷移ロジックを作成します。
CharacterEncodingFilter.java

package filter;

import jakarta.faces.application.ViewExpiredException;
import java.io.IOException;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import java.io.Serializable;

import java.util.UUID;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.annotation.WebFilter;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;

@WebFilter("/*")
public class CharacterEncodingFilter implements Filter, Serializable {
    private static final long serialVersionUID = 1L;
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        // リクエストもレスポンスもUTF-8にします
        request.setCharacterEncoding("UTF-8");
        response.setCharacterEncoding("UTF-8");

        
        HttpServletRequest req = (HttpServletRequest) request;
        HttpSession session = req.getSession();
        if ("POST".equalsIgnoreCase(req.getMethod())) {
            String requestToken = req.getParameter("checkToken");
            String sessionToken = (String) session.getAttribute("CHECK_TOKEN");
            // トークン検証
            if (sessionToken == null || !sessionToken.equals(requestToken)) {
                // 不正遷移・二重送信!
                throw new ViewExpiredException(null);
            }
            // トークンエラーなし
            String newToken = UUID.randomUUID().toString();
            session.setAttribute("CHECK_TOKEN", newToken);
        } else if ("GET".equalsIgnoreCase(req.getMethod())) {
            if (session.getAttribute("CHECK_TOKEN") == null) {
                String newToken = UUID.randomUUID().toString();
                session.setAttribute("CHECK_TOKEN", newToken);
            }
        }
        // 次の処理(FacesServletなど)にバトンを渡します
        chain.doFilter(request, response);
    }
}

トークンチェックの流れ(正常)
① GET遷移でアクセス(画面A)
      当然『CHECK_TOKEN』の値は『null』なので、新たに『CHECK_TOKEN』をセッションで発行します。
② 画面Aを描画する際に『CHECK_TOKEN』の内容を『type=hidden』で保持します。
③ 画面Aから画面BへPOST遷移するボタンが押下されます。
④ まずは、画面AのバッキングビーンにPOST遷移としてフィルタが発火します。
⑤ セッションは①で発行しているので『null』でもなく、②で保持した内容と同じなので検証は通過します。
⑥ 通過したら、新たに『CHECK_TOKEN』をセッションで発行し画面Bへ遷移します。
正常遷移の場合は①~⑥をループします。
トークンチェックの流れ(異常)
① GET遷移でアクセス(画面A)
      当然『CHECK_TOKEN』の値は『null』なので、新たに『CHECK_TOKEN』をセッションで発行します。
② 画面Aを描画する際に『CHECK_TOKEN』の内容を『type=hidden』で保持します。
③ 画面Aから画面BへPOST遷移するボタンが押下されます。
④ まずは、画面AのバッキングビーンにPOST遷移としてフィルタが発火します。
⑤ セッションは①で発行しているので『null』でもなく、②で保持した内容と同じなので検証は通過します。
⑥ 通過したら、新たに『CHECK_TOKEN』をセッションで発行し画面Bへ遷移します。
⑦ ヒストリーバックで画面Aに戻ります。
      この時の『CHECK_TOKEN』は以下となっています。
      ブラウザ側:①で発行されたセッションの値
      サーバ側 :⑥で発行されたセッションの値

⑧ 画面Aから画面BへPOST遷移するボタンが押下されます。
⑨ 画面AのバッキングビーンにPOST遷移としてフィルタが発火します。
⑩ セッションは⑥で発行しているので『null』ではないが、双方の値が①⑥なので値に相違があります。
      この場合を『不正遷移』とし、例外として『ViewExpiredException』を送出します。
    ※例外の処理については『エラーハンドリング』『web.xml』設定などで制御して下さい
それでは次にビュー側に『CHECK_TOKEN』を出力する設定をします。
○ sample.xhtml
<input type="hidden" name="checkToken" value="${sessionScope.CHECK_TOKEN}" />
上記コードを追加するだけです。
それでは動作確認をしてみましょう。
画面にアクセスしたら『自画面』へPOST遷移してみて下さい。
何度遷移ボタンを押下しても、正常に自画面に遷移できます。
しかし、一度でもヒストリーバックすると、以降の『自画面』遷移をするとエラーとなります。
    これが『不正遷移対策』です。

不正遷移対策(フィルターの罠)

先ほどのトークンエラーの場合『ViewExpiredException』をスローする事で画面処理を中断させていました。
色々と動作確認をされた方は画面に大量のエラーメッセージが表示されたと思います。
でもちょっと待ってください。
前回の章で『エラーハンドラ』クラスを作成された方ならお分かりの通り、なんと
    『スローしてもエラーハンドラが発火しない』
発火しないので、例外をスローしてもエラー制御自体ができない状況です。
何故発火しないのでしょうか?
実はフィルター処理ってフレームワークの『管轄外』なんですよね。
PHPなんかを深く経験してる方だと『URL叩いた瞬間からPHPの管理内だろ』って思うかもですが、
そこは悲しきJAVAの『レイヤー構造』の宿命なのです。
色々な処理を色々なレイヤーが担当しています。
そしてJAVAソースとしてフィルターを記述しているため非常に嵌りやすいのですが、
・フィルター  :Servlet コンテナ の管轄
・エラーハンドラ:JSF(FacesServlet)の管轄
上記の関係を混同してしまう事です。
JSFの管轄ではないので『faces-config.xml』で登録したエラーハンドラクラスも反応してくれません。
よって制御を失った『ViewExpiredException』は画面にはけ口を求めて大量のメッセージを吐き出すのです。
また『AJAX』になるともっと厄介で、そもそも画面再描画をしないのがAJAXですので、エラーになっても見た目は何も変わりません。
でも内部的にはエラーになってるんですよ。
    もうその後の挙動が怖くてたまりませんよね。
ではフィルターから多少強引ですが『エラーハンドラ』を発火させる様にフィルタを修正します。
CharacterEncodingFilter.java

package filter;

import java.io.IOException;

import jakarta.faces.FactoryFinder;
import jakarta.faces.application.ViewExpiredException;
import jakarta.faces.context.FacesContext;
import jakarta.faces.context.FacesContextFactory;
import jakarta.faces.lifecycle.Lifecycle;
import jakarta.faces.lifecycle.LifecycleFactory;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import java.io.Serializable;
import java.util.UUID;

import exception.MyExceptionHandler;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;

import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;

@WebFilter("/*")
public class CharacterEncodingFilter implements Filter, Serializable {
    private static final long serialVersionUID = 1L;
    
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        // リクエストもレスポンスもUTF-8にします
        request.setCharacterEncoding("UTF-8");
        response.setCharacterEncoding("UTF-8");
    
        // トークンチェック用フィルタ開始
        HttpServletRequest req = (HttpServletRequest) request;

        HttpServletResponse res = (HttpServletResponse) response;
        HttpSession session = req.getSession();
        if ("POST".equalsIgnoreCase(req.getMethod())) {
            String requestToken = req.getParameter("checkToken");
            String sessionToken = (String) session.getAttribute("CHECK_TOKEN");
            // トークン検証
            if (sessionToken == null || !sessionToken.equals(requestToken)) {
                // 不正遷移・二重送信!

                ViewExpiredException exception = new ViewExpiredException("無効なトークンです。");
                FacesContext context = getFacesContext(req, res);
                MyExceptionHandler handler = new MyExceptionHandler(context.getExceptionHandler());
                handler.processEvent(
                        new jakarta.faces.event.ExceptionQueuedEvent(
                            context,
                            new jakarta.faces.event.ExceptionQueuedEventContext(context, exception)
                        )
                    );
                handler.handle();
            }
            // トークンエラーなし
            String newToken = UUID.randomUUID().toString();
            session.setAttribute("CHECK_TOKEN", newToken);
        } else if ("GET".equalsIgnoreCase(req.getMethod())) {
            if (session.getAttribute("CHECK_TOKEN") == null) {
                String newToken = UUID.randomUUID().toString();
                session.setAttribute("CHECK_TOKEN", newToken);
            }
        }
        // 次の処理(FacesServletなど)にバトンを渡します
        chain.doFilter(request, response);
    }
    // FacesContext を取得・生成するヘルパー

    private FacesContext getFacesContext(HttpServletRequest request, HttpServletResponse response) {
        FacesContext context = FacesContext.getCurrentInstance();
        if (context != null) {
            return context;
        }
        FacesContextFactory contextFactory = (FacesContextFactory) FactoryFinder.getFactory(FactoryFinder.FACES_CONTEXT_FACTORY);
        LifecycleFactory lifecycleFactory = (LifecycleFactory) FactoryFinder.getFactory(FactoryFinder.LIFECYCLE_FACTORY);
        Lifecycle lifecycle = lifecycleFactory.getLifecycle(LifecycleFactory.DEFAULT_LIFECYCLE);
        return contextFactory.getFacesContext(request.getServletContext(), request, response, lifecycle);
    }
}

処理の流れ
① トークンエラーとなった場合『画面の有効期限切れ』などで使われる『ViewExpiredException』を初期化します。
② 作成した『getFacesContext』関数より『FacesContext型インスタンス』を取得する
③ エラーハンドラクラスを初期化します。
④ 初期化した③に①を渡す事で、例外のキューに①を格納した処理となります。
⑤ ここまでの処理で『JSF管轄のエラー情報』となったので、『エラーハンドラ.handle()』関数を呼び出します。
⑥ あとはエラーハンドラで好きなように制御しちゃいましょう。
エラー画面に遷移しない場合は、『動的エラーモードの切替え』を適切に設定できているか確認してください。
    ※『エラーハンドリングクラスを作成する』参照

トークンタグの自動付与(ヘルパー)

トークンタグを手動で各画面に記述するのは手間だし、忘れた場合トークンエラーとなってしまいます。
この辺りの設定って特にチームで開発してると忘れがちなので、いきなりエラーになって『あれ、あれっ?』となったりしますよね。
そこで『<h:form ..』を記述したら自動的にトークンタグも描画する様にできたら便利です。
別にトークンチェックしない運用でも、トークンタグ自体は何の意味も持たないタグですから。
では『h:form..』タグに対応するヘルパー『renderkit/CustomFormRendererWrapper.java』を作成します。
CustomFormRendererWrapper.java

package renderkit;

import jakarta.faces.component.UIComponent;
import jakarta.faces.context.ExternalContext;
import jakarta.faces.context.FacesContext;
import jakarta.faces.render.Renderer;
import jakarta.faces.render.RendererWrapper;
import java.io.IOException;
import java.util.Map;

@SuppressWarnings("rawtypes")
public class CustomFormRendererWrapper extends RendererWrapper {
    private final Renderer wrapped;
    @SuppressWarnings("deprecation")
    public CustomFormRendererWrapper(Renderer wrapped) {
        this.wrapped = wrapped;
    }
    public Renderer getWrapped() {
        return wrapped;
    }
    @Override
    public void encodeBegin(FacesContext context, UIComponent component) throws IOException {
        super.encodeBegin(context, component);
        // formタグの後に追記した内容を作成する
        FacesContext facesContext = FacesContext.getCurrentInstance();
        ExternalContext externalContext = facesContext.getExternalContext();
        Map<String, Object> sessionMap = externalContext.getSessionMap();
        String tokenTag
            = "<input type='hidden' id='checkToken' name='checkToken' value='" + sessionMap.getOrDefault("CHECK_TOKEN", "") + "' />";
        // タグを追記します
        context.getResponseWriter().write(tokenTag);
    }
    @Override
    public void encodeEnd(FacesContext context, UIComponent component) throws IOException {
        super.encodeEnd(context, component);
    }
}

一旦『super.encodeBegin(context, component)』で、フレームワークが本来作成するタグを処理させます。
その後任意の内容を作成し『context.getResponseWriter().write(任意の内容)』で追記できます。
次は『renderkit/CustomRenderKit.java』へ呼び出し元クラスを作成します。
CustomRenderKit.java

package renderkit;

import jakarta.faces.render.RenderKit;
import jakarta.faces.render.RenderKitWrapper;
import jakarta.faces.render.Renderer;

public class CustomRenderKit extends RenderKitWrapper {
    private final RenderKit wrapped;
    @SuppressWarnings("deprecation")
    public CustomRenderKit(RenderKit wrapped) {
        this.wrapped = wrapped;
    }
    @Override
    public RenderKit getWrapped() {
        return wrapped;
    }
    @SuppressWarnings("rawtypes")
    @Override
    public Renderer getRenderer(String family, String rendererType) {
        Renderer original = wrapped.getRenderer(family, rendererType);
        if ("jakarta.faces.Form".equals(family) &&
                "jakarta.faces.Form".equals(rendererType)) {
            return new CustomFormRendererWrapper(original);
        }
        return original;
    }
}

メソッド『getRenderer』で、現在どんなタグが処理中か確認できます。
『Formタグ』を処理する場合は、本来『original』を返却します。
しかし『h:form』タグの場合に限り『CustomFormRendererWrapper』クラスの処理結果を返却します。
次はファクトリクラス『renderkit/CustomRenderKitFactory.java』を作成します。
CustomRenderKitFactory.java

package renderkit;

import java.util.Iterator;
import jakarta.faces.render.RenderKit;
import jakarta.faces.render.RenderKitFactory;

public class CustomRenderKitFactory extends RenderKitFactory {
    private final RenderKitFactory wrapped;
    public CustomRenderKitFactory(RenderKitFactory wrapped) {
        super(wrapped);
        this.wrapped = wrapped;
    }
    @Override
    public RenderKit getRenderKit(jakarta.faces.context.FacesContext context, String renderKitId) {
        RenderKit original = wrapped.getRenderKit(context, renderKitId);
        return new CustomRenderKit(original);
    }
    @Override
    public void addRenderKit(String renderKitId, RenderKit renderKit) {
        wrapped.addRenderKit(renderKitId, renderKit);
    }
    @Override
    public Iterator<String> getRenderKitIds() {
        return null;
    }
}

最後に『src/main/webapp/WEB-INF/faces-config.xml』へ『CustomRenderKitFactory』を登録します。
faces-config.xml

<?xml version="1.0" encoding="UTF-8"?>
<faces-config
    xmlns="https://jakarta.ee/xml/ns/jakartaee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-facesconfig_4_0.xsd"
    version="4.0">
    
    <factory>
        <exception-handler-factory>exception.MyExceptionHandlerFactory</exception-handler-factory>
    </factory>

    <factory>
        <render-kit-factory>renderkit.CustomRenderKitFactory</render-kit-factory>
    </factory>
</faces-config>

それでは『sample.xhtml』にある手動で作成した『checkToken』タグを削除してみましょう。
同じ挙動となるハズです。
ポイント

今回は『h:form』タグを拡張しましたが、基本は全てのタグで同じです。
『CustomRenderKit.java』の以下条件によって処理を振り分けています。
    if ("jakarta.faces.Form".equals(family) && "jakarta.faces.Form".equals(rendererType))

ここでの条件を追加する事で、様々なタグの拡張を作成できます。

注意

AJAXによる非同期通信の場合『token』の値は書き換わりません。
ただし『f:ajax execute="@form" render="@form"』とする事で書き換わります。

当然ですが、上記の様なAJAX処理は、AJAXにする意味が微妙となります。
この場合は自動で『checkToken』タグを作成するのを諦めましょう。
ただし例えば以下の様に記述すると『form』タグ内の任意の属性の値を取得できます。
component.getAttributes().get("addToken");
上記は『h:form addToken="false"』の様に「.xhtml」内に記述すると『false』が取得できます。
つまり、バッキングビーン単位で動的にトークンタグの《表示|非表示》の制御が可能となります。
非表示にした場合は「.xhtml」に手作業でトークンタグを記述し、バッキングビーンで値を設定する必要があります。
値は当然以下セッションの値となります。
    (String) getSessionMap().getOrDefault("CHECK_TOKEN", "")
「.xhtml」は以下の様に記述します。
<h:inputText type='hidden' id='checkToken' name='checkToken' value='#{sampleBean.checkToken}' />
<h:commandButton ~
    <f:ajax execute="hoge checkToken" render="msg hoge checkToken" />

動的設定によるトークンチェック

もうお分かりですね。
そう『appinfo.properties』から動的にトークンチェック有無を制御します。
    ※『動的環境設定』参照
それでは以下のキーで制御してみます。
TOKEN_WHITE_LIST・・・ブランクの場合チェックなし。『*』の場合全画面チェック。『index|next』の様に複数選択可とする。
TOKEN_BLACK_LIST・・・ブランクの場合チェックなし。『*』の場合全画面チェック。『index|next』の様に複数選択可とする。
名前の通り「ホワイトリスト」「ブラックリスト」で設定します。
評価は「ホワイトリスト」⇒「ブラックリスト」の順番とします。
つまりどちらも『*』としたらトークンチェックはスキップされます。
またいくら「ブラックリスト」に設定しても『TOKEN_WHITE_LIST=』だと無意味です。([TOKEN_WHITE_LIST=*]とする事)
一番オーソドックスなのは以下設定ですね(全画面チェック)
TOKEN_WHITE_LIST=*
TOKEN_BLACK_LIST=
トークンチェック処理を制御する『filter/CharacterEncodingFilter.java』を修正します。
CharacterEncodingFilter.java

package filter;

import java.io.IOException;
import jakarta.faces.FactoryFinder;
import jakarta.faces.application.ViewExpiredException;
import jakarta.faces.context.FacesContext;
import jakarta.faces.context.FacesContextFactory;
import jakarta.faces.lifecycle.Lifecycle;
import jakarta.faces.lifecycle.LifecycleFactory;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import java.io.Serializable;
import java.util.UUID;

import config.AppConfig;
import exception.MyExceptionHandler;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;

@WebFilter("/*")
public class CharacterEncodingFilter implements Filter, Serializable {
    private static final long serialVersionUID = 1L;
    
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        // リクエストもレスポンスもUTF-8にします
        request.setCharacterEncoding("UTF-8");
        response.setCharacterEncoding("UTF-8");
        // トークンチェック用フィルタ開始
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse res = (HttpServletResponse) response;
        // 対象画面名取得

        String servletPath = req.getServletPath();
        String pageName = servletPath.substring(servletPath.lastIndexOf('/') + 1);
        HttpSession session = req.getSession();
        if ("POST".equalsIgnoreCase(req.getMethod())) {
            String requestToken = req.getParameter("checkToken");
            String sessionToken = (String) session.getAttribute("CHECK_TOKEN");
            // トークンチェックの有無を「appinfo」から確認

            if (checkAppInfo(pageName)) {
                // トークン検証
                if (sessionToken == null || !sessionToken.equals(requestToken)) {
                    // 不正遷移・二重送信!
                    ViewExpiredException exception = new ViewExpiredException("無効なトークンです。");
                    FacesContext context = getFacesContext(req, res);
                    MyExceptionHandler handler = new MyExceptionHandler(context.getExceptionHandler());
                    handler.processEvent(
                            new jakarta.faces.event.ExceptionQueuedEvent(
                                context,
                                new jakarta.faces.event.ExceptionQueuedEventContext(context, exception)
                            )
                        );
                    handler.handle();
                }

            }
            // トークンエラーなし
            String newToken = UUID.randomUUID().toString();
            session.setAttribute("CHECK_TOKEN", newToken);
        } else if ("GET".equalsIgnoreCase(req.getMethod())) {
            if (session.getAttribute("CHECK_TOKEN") == null) {
                String newToken = UUID.randomUUID().toString();
                session.setAttribute("CHECK_TOKEN", newToken);
            }
        }
        // 次の処理(FacesServletなど)にバトンを渡します
        chain.doFilter(request, response);
    }
    // FacesContext を取得・生成するヘルパー
    private FacesContext getFacesContext(HttpServletRequest request, HttpServletResponse response) {
        FacesContext context = FacesContext.getCurrentInstance();
        if (context != null) {
            return context;
        }
        FacesContextFactory contextFactory = (FacesContextFactory) FactoryFinder.getFactory(FactoryFinder.FACES_CONTEXT_FACTORY);
        LifecycleFactory lifecycleFactory = (LifecycleFactory) FactoryFinder.getFactory(FactoryFinder.LIFECYCLE_FACTORY);
        Lifecycle lifecycle = lifecycleFactory.getLifecycle(LifecycleFactory.DEFAULT_LIFECYCLE);
        return contextFactory.getFacesContext(request.getServletContext(), request, response, lifecycle);
    }
    // トークンチェックの有無を「appinfo」から確認

    private boolean checkAppInfo(String pageName) {
        if (pageName == null || pageName.trim().isEmpty()) {
            return false;
        }
        String[] page = pageName.split("\\.");
        String whiteList = AppConfig.get("TOKEN_WHITE_LIST", "");
        String blackList = AppConfig.get("TOKEN_BLACK_LIST", "");
        // TOKEN_WHITE_LIST確認
        if (whiteList.trim().isEmpty()) {
            return false;
        }
        // TOKEN_BLACK_LIST確認
        if (blackList.trim().equals("*")) {
            return false;
        }
        // TOKEN_BLACK_LIST分解
        String[] blacks = blackList.split("\\|");
        for (String black : blacks) {
            if (black.equals(page[0])) {
                return false;
            }
        }
        // TOKEN_WHITE_LIST確認
        if (whiteList.trim().equals("*")) {
            return true;
        }
        // TOKEN_WHITE_LIST分解
        String[] whites = whiteList.split("\\|");
        for (String white : whites) {
            if (white.equals(page[0])) {
                return true;
            }
        }
        return false;
    }
}

処理の流れ
① 現在処理している拡張子を除く画面名(pageName)を取得する。
② トークンチェックの有無を関数『checkAppInfo』で判定する。
③ ②の判定で《真》の場合は、トークンチェックを実施する
最後に

不正遷移処理って意外と重要です。
良く例に上がるケースに『2重送信問題』があります。
簡易な対策として、送信後リダイレクトする方法があります。
でもこれってヒストリーバックすれば『2重送信』できますよね?

これが原因でDBに不具合が発生し、緊急メンテなんて洒落になりません。
こういった制御を適切に設定する事でシステムはより強固となります。
今後システムをグランドデザインする際には是非参考にしていただければ幸いです。

きっぷる
ログインしてコメントを残そう!!


きっぷる