BEACHSIDE BLOG

MicrosoftとかC#を好むレンジャーの個人的メモ

Double SubmissionをActionFilterで制御する

仕事が派手にドッタンバッタンしたので更新が途切れましたなー。

WebでSubmitボタンを連打された時の防止策についてメモしておきます。

まず、この記載での開発環境は、ざっくり

  • Visual Studio2013update4
  • .NET Framework4.5
  • ASP.NET MVC5

です。


制御は、ActionFilterだけでよいといえばよい気もしますが、個人的には、クライアント側でもjavascriptで制御してます。この実装についてはここでは触れませんが、ここらへんを参考にしてやってます。
http://technoesis.net/prevent-double-form-submission-using-jquery/technoesis.net

Reference

さて、本題ですが、ActionFilterの実装は、この方のサイトを参考にしています。
rion.io


まず、ActionFilterの基本については、こちらのサイトを参照くらいでしょうか。
https://msdn.microsoft.com/ja-jp/library/dd381609(v=vs.100).aspx

ざっくりまとめると、

  • クラスを作ってActionFilterAttributeを継承させると、ActionFilterクラスの出来上がり。4つのオーバーライドメソッドをオーバーライドすることで制御を行う。
  • OnActionExecutingメソッドはActionが呼び出される直前に動く
  • OnActionExecutedメソッドは、Actionが終了したときに動く
  • OnResultExecuting メソッドは、アクションによって返された ActionResult インスタンスが呼び出される直前に動く
  • OnResultExecuted メソッドは、結果が実行された直後に動く

Overview

やりたいことは、Submitボタンを押された後、処理が終わる前にsubmitを連打されれいたら、検知して、無視するとかエラー返すとかです。

ActionFilterのOnActionExecutingメソッドで、以下のことを実装して実現します。

  • Requestがあったユーザーを特定する仕組みをつくる
  • ユーザーが最初のアクセス(=submit)から特定秒数の間にアクセスがあったら、検知して処理(エラーとか無視とか)を行う

Implimentation : 1

まず、検証するControllerとView作っちゃいます。

Controllerはこんな感じでざっくり。

public class DemoController : Controller
{
    public ActionResult Index()
    {
        return View();
    }

    [HttpPost]
    public async Task<ActionResult> DemoSubmit()
    {
        await Task.Delay(3000);

        TempData["SummittedTime"] = DateTime.Now.ToString("hh:MM:ss fff");
        return RedirectToAction("Index");
    }
}

submitしたら、3秒待って、Viewを表示するだけです。ただ、submitした時間を表示します。
今回の話題とは全く関係ない小ネタですが、理由なくRedirectToActionさせて時間を表示するのでViewBagではなくTempDataに時間を入れて、表示できるようにしています。
ViewBagに入れるとRedirectしたらデータ消えちゃいますよね...。

Viewは、Index.cshtmlをこんな感じでまったり作りました。

<h2>demo prevent doubl submittion</h2>

@if (TempData["SummittedTime"] != null)
{
    <p>@TempData["SummittedTime"] </p>
}

@using (Html.BeginForm("DemoSubmit", "Demo"))
{
    <input class="btn btn-warning" type="submit" name="DemoSubmit" value="3秒以内の連打禁止だおー" />
}

Implimentation : 2

では、本題のActionFilterです。
Propertyをいくつか用意してます。
まず、「DelayTimer」。同一ユーザーから一度Requestを受けたら、delayする時間を設定するプロパティ。秒で設定します。今回はデフォルトで3秒を設定しました。
後は、エラーメッセージだったり、RedirectするUrl(今回は全然触れてない...)。

using System;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Web.Caching;
using System.Web.Mvc;

public class PreventDoubleSubmitAttribute : ActionFilterAttribute
{
    #region class header

    private int _delayTimer = 3;
    public int DelayTimer
    {
        get { return _delayTimer; }
        set { _delayTimer = value; }
    }

    private string _err = "DoubleSubmit occurred";
    public string ErrorMessage
    {
        get { return _err; }
        set { _err = value; }
    }

    public string RedirectUrl { get; set; }

    #endregion
}

では、コアとなるOnActionExecutingの処理です。
まず、ユーザーを一意に認識させるために、リクエストの中の"HTTP_X_FORWARDED_FOR"を取得します。nullだったら、UserHostAddressを取得します。
その情報に、リクエストのUserAgentを追加しています。
あと、リスエストのUrlとパラメータを取得します。
それらの情報からMD5ハッシュを生成して、ユーザーの一意となるキーを作ります。
そして、ハッシュがCacheに存在するかを確認します。
存在する場合は、Prevent対象と判断し、なんらかの処理を入れます。今回は、BadRequestを返してしまっています。

存在しなければ、初回のリクエストと判断し、Cacheに値をセットします。その際に、そのCacheの有効期限にDelayTimerプロパティの秒数をセットします。

public override void OnActionExecuting(ActionExecutingContext filterContext)
{
    var request = filterContext.HttpContext.Request;
    var cache = filterContext.HttpContext.Cache;

    //一意となるデータを取得
    var firstrequest = request.ServerVariables["HTTP_X_FORWARDED_FOR"] ?? request.UserHostAddress;
    firstrequest += request.UserAgent;

    var current = request.RawUrl + request.QueryString;

  //ハッシュ生成
    var hash = string.Join("", MD5.Create().ComputeHash(Encoding.ASCII.GetBytes(firstrequest + current)).Select(s => s.ToString("x2")));
    if (cache[hash] != null)
    {
        //今回は、BadRequestをなげます。
        filterContext.Result = new HttpStatusCodeResult(HttpStatusCode.BadRequest);
        base.OnActionExecuting(filterContext);

        //おもむろにログを出してみたり...。
        var controller = filterContext.RouteData.Values["controller"].ToString();
        var action = filterContext.RouteData.Values["action"].ToString();
        Trace.TraceWarning("Double Submit Occured: {0} - {1}", controller, action);
    }
    else
    {
    //存在しなければ、キャッシュに値をセットする
        cache.Add(hash, string.Empty, null, DateTime.Now.AddSeconds(DelayTimer), Cache.NoSlidingExpiration, CacheItemPriority.Default, null);
    }

    base.OnActionExecuting(filterContext);
}

Implimentation : 3

最後にControllerにAttributeを追加してあげれば完了です。

[HttpPost]
[PreventDoubleSubmit]
public async Task<ActionResult> DemoSubmit()
{
....

これで、一度Submitした後、3秒以内に連打するとBadRequestが出力されます。

DelayTimerの時間など、プロパティにアクセスしたい場合は、こんな感じで書いてあげればよいです。

[HttpPost]
[PreventDoubleSubmit(DelayTimer = 5)]
public async Task<ActionResult> DemoSubmit()

今回は、javascriptで制御しているのに、それを掻い潜って連打攻撃してきたのであればBadRequestで沈める想定なのですが、ModelStateにエラーメッセージを追加したいとかもあるでしょうか。

if (cache[hash] != null)
{
    filterContext.Controller.ViewData.ModelState.AddModelError("DoubleSubmit", ErrorMessage);
}
else
{
...

こうした場合、controller側でModelstateのエラーを拾ってあげればよいです。

if (ModelState.Values.Select(vals => ModelState["DoubleSubmit"]).Any(v => v != null))
{
    //なんかエラー処理を...
}


なんか色々ざっくり書いてしまったメモですが、今回はこの辺で...。