Wicket の AjaxButton で mail 送信要求を送る 問い合わせフォームを実装していたのですが、
ボタン連打で連続リクエスト送信ができてしまったので、
その回避方法を考えます。  
前提. 使用している Wicket, EclipseLink の Version
- Wicket 7.5
    <dependency>
        <groupId>org.apache.wicket</groupId>
        <artifactId>wicket-core</artifactId>
        <version>7.5.0</version>
    </dependency>
エラーの発生しているフォーム
Wicket Form についてよくわかっていなかったので調べてみました。 | Monotalk
で作成したフォームでエラーが発生しています。  
エラーの発生から考える実装時に考慮しなければならないポイント(個人調べ)
以下の考慮ポイントがあるように感じました。
各考慮ポイントを実装していきます。
- 
AjaxButton に対しての連打対策 (client 側) 現在の実装だと連打可能なので、連打ができないようにする必要があります。 
 これは client 側へ実装します。
- 
AjaxButton に対しての連打対策 (server 側) client 側の対応だけだと、直接 Submit には対応できないので、 
 サーバ側のプログラムで、連続リクエストに対する対応を実施します。2
 [2] Wicket は Form の URL が自動生成されるので、この攻撃自体が結構難しいかと思いますが、
 不可能ではないため対策をしておきます。
AjaxButton に対しての連打対策
以下の記事が見つかりました。
AjaxButton#updateAjaxAttributes をオーバーライドして、
以下の処理を実装します。  
- 
Validation の実行後に、確認ダイアログを出力する。 
- 
確認ダイアログ実行後は、処理完了まで、ボタンは非活性化する。 
Validation の実行後に、確認ダイアログを出力する。
Validation の後に確認ダイアログを出したいですが、
Validation の実行タイミングを、add(new AjaxFormValidatingBehavior("blur", Duration.ONE_SECOND)); で指定していたので、
Button クリック前の入力フィールドへの入力タイミングで実行されます。
このため、特に考慮不要でした。
Qiita の記事の通り、onPrecondition()で設定すれば、OK でした。  
    @Override
    protected void updateAjaxAttributes(AjaxRequestAttributes attributes) {
        super.updateAjaxAttributes(attributes);
        final AjaxCallListener ajaxCallListener = new AjaxCallListener();
        ajaxCallListener.onPrecondition("return confirm('" + getString("messageMailSendConfirm") + "');");
        attributes.getAjaxCallListeners().add(ajaxCallListener);
    }
確認ダイアログ実行後は、処理完了まで、ボタンは非活性化する。
Request 送信直前 onBeforeSend で Button を非活性、
Request 送信完了後 onComplete で Button を活性化し、再フォーカス 
するようにしました。  
Validation でエラーの場合、ボタンを非活性にするとかを実装していないので、
エラー発生時も、Button クリック可能で、 Button クリックで、再度 Validation が走りエラーとなります。
UI 的にはアレな感じもしますが、とりあえずの目的は果たせたので、一旦これでいきます。  
    @Override
    protected void updateAjaxAttributes(AjaxRequestAttributes attributes) {
        super.updateAjaxAttributes(attributes);
        // AjaxButton は (というかButtonは) setOutputMarkupId(true) なので、特に呼び出さなくてOK
        final AjaxCallListener ajaxCallListener = new AjaxCallListener();
        ajaxCallListener.onPrecondition("return confirm('" + getString("messageMailSendConfirm") + "');");
        ajaxCallListener.onBeforeSend("$('#" + getMarkupId() + "').prop('disabled',true);");
        ajaxCallListener.onComplete("$('#" + getMarkupId() + "').prop('disabled',false);$('#" + getMarkupId() + "').focus();");
        attributes.getAjaxCallListeners().add(ajaxCallListener);
    }
以下の記事を参考にしました。
Github で、 Jqueryを使ったもっと頑張っている実装がありましたので、
そちらのリンク先も貼り付けておきます。  
補足. AjaxCallListener のメソッド呼び出し順序
AjaxCallListener には、ライフサイクルメソッド? が多数あり、
実行順序がよくわからなかったので、確認した結果を以下に記載します。
感覚的には、メソッド名通りの動作となります。  
| メソッド名 | 呼び出し順序 | 説明 | 
|---|---|---|
| onInit(final CharSequence init) | 1 | AjaxButton のクリック直後に呼び出されます。 | 
| onBefore(final CharSequence before) | 2 | onInit()の呼び出し後に呼び出されます。 | 
| onPrecondition(final CharSequence before) | 3 | onBefore()呼び出し後に呼び出されます。確認ダイアログを挿入すると、キャンセルクリックで後処理の中断ができます。 | 
| onPrecondition(final CharSequence before) | 4 | onBefore()呼び出し後に呼び出されます。確認ダイアログを挿入すると、キャンセルクリックで後処理の中断ができます。 | 
| onBeforeSend(final CharSequence beforeSend) | 5 | onPrecondition()の呼び出し後に呼び出されます。リクエスト送信直前 | 
| onAfter(final CharSequence beforeSend) | 5 | onBeforeSend()の呼び出し後に呼び出されます。リクエスト送信直後 | 
| onSuccess(final CharSequence beforeSend) | 6 | onAfter()の呼び出し後、リクエストが成功した場合に呼び出されます。 | 
| onFailure(final CharSequence beforeSend) | 6 | onAfter()の呼び出し後、リクエストが失敗した場合に呼び出されます。 | 
| onComplete(final CharSequence beforeSend) | 7 | onSuccess()、onFailure()の呼び出し後、呼び出されます。 | 
| onDone(final CharSequence beforeSend) | 8 | onComplete()の呼び出し後、呼び出されます。また、onPrecondition()がFalseの場合にも呼び出しが行われます。 | 
補足. TextField の EnterKey クリックで、Submit するのを防ぐ
AjaxPreventSubmitBehavior.java を TextField の Behavior として追加すると、EnterKeyクリックでSubmitするのを防いでくれます。
ので、TextField に 追加しました。
    // eMail
    TextField eMail = new RequiredTextField("email");
    eMail.setLabel(Model.of(getString("eMailLabelText")));
    eMail.add(StringValidator.maximumLength(150));
    eMail.add(EmailAddressValidator.getInstance());
    eMail.add(new AjaxPreventSubmitBehavior());
    add(eMail);
サーバ側の Submit URL に対する連打対策
続いて Submit URL に対する連打対策について、考えます。
DOS アタックを防ぐ
Dos Attack 的なものを防ぐだと、HTTP サーバ、Servlet Filter あたりでまずやるものなイメージがあります。
AP Server? は Dropwizard を使っているので、 Dropwizard で、そこあたりを実装することを考えました。
この記事内で書くのは、Wicket から外れるので、別記事にしました。  
また、Wicket の RequestCycleListener で実装したものを公開している方がいましたので、
そちらのリンクを記載しておきます。  
DOS FIlter を Wicket に適用する
通常のリクエストについては、org.eclipse.jetty.servlets.DoSFilter による制御で上手く動きましたが、
Ajax Request については、WicketFilter の処理が行われないため、Wicket の javascript で想定外のエラーが発生しました。3
[3] Servlet Filter として、DoSFilter を WicketFilterよりも、先に定義しているためです。
このため、DoS 制御の主処理は、DoSFilter でやりつつも、エラーハンドリングは、RequestCycleListener で行うようにしました。  
以下、作成した、ソースコードの抜粋です。
- Application.java
    // RequestCycleListener の登録
    getRequestCycleListeners().add(new DoSPreventRequestCycleListener());
- 
DoSPreventRequestCycleListener.java - 
onBeginRequest で、実装を行いましたが、throw しても、エラーとして認識されず、onRequestHandlerResolved で処理を行うようにしました。 
- 
Ajax リクエストでない場合のみ、Exceptionをスロー、Ajax の処理は、AjaxButton内で記載するようにしました。 
 実は、onRequestHandlerResolved内で記載してもうまくいくかもしれません。
- 
HTTP ERROR CODE 429 は、 Too Many Requestsです。
 DoSFilter のデフォルトのエラーコードが、429 のため踏襲しています。
- 
チェックエラー時は、 httpServletRequest.removeAttribute("DoSFilter.Tracker");で属性削除しています。
 これは、削除しないと、無限ループに陥り、StackOverFlowが発生したためです。4
 [4] 処理として、不安定な処理かと、、もっといい方法があるかもしれません。
 
- 
import org.apache.wicket.request.IRequestHandler;
import org.apache.wicket.request.cycle.AbstractRequestCycleListener;
import org.apache.wicket.request.cycle.RequestCycle;
import org.apache.wicket.request.http.WebRequest;
import org.apache.wicket.request.http.flow.AbortWithHttpErrorCodeException;
import javax.servlet.http.HttpServletRequest;
/**
 * DoSPreventRequestCycleListener
 */
public class DoSPreventRequestCycleListener extends AbstractRequestCycleListener {
    @Override
    public void onBeginRequest(RequestCycle cycle) {
        // Do Nothing...
    }
    @Override
    public void onRequestHandlerResolved(RequestCycle cycle, IRequestHandler
            handler) {
        // Cast WebRequest
        WebRequest webRequest = (WebRequest) cycle.getRequest();
        // Ajax リクエストではない通常のリクエストの場合、DoSFilter.Trackerが設定されている場合、Denyする。
        if (!webRequest.isAjax()) {
            HttpServletRequest httpServletRequest = (HttpServletRequest) webRequest.getContainerRequest();
            Object tracker = httpServletRequest.getAttribute("DoSFilter.Tracker");
            if (tracker != null) {
                httpServletRequest.removeAttribute("DoSFilter.Tracker");
                throw new AbortWithHttpErrorCodeException(429, "Too Many Requests");
            }
        }
    }
}
- AjaxButton.java- Form に追加したAjaxButtonの処理で、onSubmit() で、DoSFilter.Trackerが存在したら、エラーとする処理を追加しました。
 
- Form に追加したAjaxButtonの処理で、onSubmit() で、
    AjaxButton sendMessage = new AjaxButton("sendMessage", Model.of(getString("messageButtonLabelText"))) {
        private static final long serialVersionUID = -8204140666393922700L;
        @Override
        protected void onSubmit(AjaxRequestTarget target, Form<?> form) {
            HttpServletRequest httpServletRequest = (HttpServletRequest) form.getRequest().getContainerRequest();
            Object tracker = httpServletRequest.getAttribute("DoSFilter.Tracker");
            if (tracker != null) {
                httpServletRequest.removeAttribute("DoSFilter.Tracker");
                error(getString("messageMailDoubleSendError"));
                target.add(form);
                return;
            }
            // メール送信要求を登録
            ContactMailTaskManage contactMailTaskManage = (ContactMailTaskManage) form.getModelObject();
            mailTaskService.execute(contactMailTaskManage);
            // Formに対してのオペレーション
            token.regenerateToken();
            success(getString("messageMailSendSucceed"));
            target.add(form);
        }
    }
- 
DoSFilter の定義値 Wicket 側で制御させるために、プロパティ値を以下のような設定としました。 
- 
delayMsを 0 にする。delayMsを 0 にすることで、リクエストを遅延させる処理も行わず、Filter でException を送出しない設定となります。
 この際、属性DoSFilter.Trackerが設定されるため、この属性値の有無で、DoS検知にヒットしたかどうかを、判断するようにしました。
単純な2重送信を防止する
連打防止ではなく、単純な2重送信防止として、tokenを発行するHidden Filed の拡張クラスを作成しました。5
[5] 戻るボタンクリック後の制御もできるかもですが、そちらは試しておりません。   
- SynchTokenField
public class SynchTokenField extends HiddenField {
    private static final long serialVersionUID = 5172245669993637549L;
    private String token;
    /**
     * Construct.
     *
     * @param id
     */
    public SynchTokenField(String id) {
        super(id);
    }
    @Override
    protected void onInitialize() {
        super.onInitialize();
        setType(String.class);
        setRequired(true);
        setModel(Model.of(regenerateToken()));
        add(new IValidator<String>() {
            @Override
            public void validate(IValidatable<String> validatable) {
                log.info("validatable.getValue() = {} || token = {}", validatable.getValue(), token);
                if (token == null || !token.equals(validatable.getValue())) {
                    ValidationError validation = new ValidationError(getString("messageMailDoubleSendError"));
                    error(validation);
                }
            }
        });
    }
    protected final void onComponentTag(final ComponentTag tag) {
        super.onComponentTag(tag);
        tag.put("value", token);
    }
    public String regenerateToken() {
        token = UUID.randomUUID().toString();
        return token;
    }
}
- AjaxButton#onSubmit()
onSubmit()内で、メイン処理終了後に、regenerateToken()でtokenを再生成します。
 2度押しの場合、2度目はtokenが異なるので、validation エラーとなります。
 タイミング的に、2回走る可能性はありえるので、同期化は行ったほうがよさそうです。6
 [6] 要は、以下のコードだとバグりそうに思ってます。。もうちょっといい感じにできないかなと
    @Override
    protected void onSubmit(AjaxRequestTarget target, Form<?> form) {
        HttpServletRequest httpServletRequest = (HttpServletRequest) form.getRequest().getContainerRequest();
        Object tracker = httpServletRequest.getAttribute("DoSFilter.Tracker");
        if (tracker != null) {
            httpServletRequest.removeAttribute("DoSFilter.Tracker");
            error(getString("messageMailDoubleSendError"));
            target.add(form);
            return;
        }
        // メール送信要求を登録
        ContactMailTaskManage contactMailTaskManage = (ContactMailTaskManage) form.getModelObject();
        mailTaskService.execute(contactMailTaskManage);
        // Formに対してのオペレーション
        token.regenerateToken();
        success(getString("messageMailSendSucceed"));
        target.add(form);
    }
- 
以下の記事を参考に作成しました。 
まとめ
Wicket で、2度押し制御を実装しました。以下、まとめます。
- 
通常のリクエストでは、PRG パターンで、F5 更新は防げるが、戻るボタンクリックで再送信、ボタン連打は防げないので、考慮が必要。 
 また、Submitされた側での制御よりも、戻ってきたら、[有効期限切れです。]にしたほうがいいかもしれない。
- 
Ajax リクエストだと、より、Wicket の制御で守られないので、独自で実装が必要。 
- 
DosFilter などを使うと、WicketのFilterの処理とぶつかる、Wicket の Ajax の部品の想定外の処理とぶつかって、制御が若干面倒くさい。 
- 
Wicket6 から、CsrfPreventionRequestCycleListener.java が追加されて、これはこのような用途にも使えるかもしれない。 
- 
Form でのToken発行は、基本的に、Stateless なページで使うもののような気はするが、Submit時に、tokenをNullにしておけば、 
 戻った後は、Session上のtokenはNullだけど、HTML上のtokenはあるになって、戻る制御は効きそうな気はする。
だらだらと書きましたが、以上です。
コメント