10

I'm using POST in a .NET Core REST API to insert data on database.

In my client application, when the user clicks a button, I disable this button. But sometimes, because some reason, the click of the button may be faster than the function of the disable button. This way, the user can double click on the button and the POST will be sent twice, inserting the data twice.

To do POST I'm using axios on the client side. But how can I avoid this on the server side?

Guilherme Ferreira
  • 1,503
  • 2
  • 18
  • 31
  • 2
    You could send a unique token from the client side, and in your controller, you can check if it's been added to the MemoryCache earlier. If it has, then just exit the from the POST method, otherwise continue with the intended logic. – silkfire Mar 21 '19 at 17:37

4 Answers4

7

I have had this scenario some time ago. I created an Action Filter for it, which is using an Anti Fogery Token:

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class PreventDoublePostAttribute : ActionFilterAttribute
{
    private const string TokenSessionName = "LastProcessedToken";

    public override void OnActionExecuting(ActionExecutingContext context)
    {
        var antiforgeryOptions = context.HttpContext.RequestServices.GetOption<AntiforgeryOptions>();
        var tokenFormName = antiforgeryOptions.FormFieldName;

        if (!context.HttpContext.Request.Form.ContainsKey(tokenFormName))
        {
            return;
        }

        var currentToken = context.HttpContext.Request.Form[tokenFormName].ToString();
        var lastToken = context.HttpContext.Session.GetString(TokenSessionName);

        if (lastToken == currentToken)
        {
            context.ModelState.AddModelError(string.Empty, "Looks like you accidentally submitted the same form twice.");
            return;
        }

        context.HttpContext.Session.SetString(TokenSessionName, currentToken);
    }
}

Simple use it on your method:

[HttpPost]
[PreventDoublePost]
public async Task<IActionResult> Edit(EditViewModel model)
{
    if (!ModelState.IsValid)
    {
        //PreventDoublePost Attribute makes ModelState invalid
    }
    throw new NotImplementedException();
}

Make sure, you generate the Anti Fogery Token, see the documentation on how it works for Javascript or Angular.

Christian Gollhardt
  • 16,510
  • 17
  • 74
  • 111
  • the method need to be async otherwise it will not work as expected – M.Ali El-Sayed Feb 28 '20 at 05:09
  • also there is no GetOption in .net core 2.2 – M.Ali El-Sayed Feb 28 '20 at 05:09
  • What about using this filter in different endpoints? E.g. two different POST methods. Token session name will be the same for both, so they will definitely overlap since session storage is shared across the app instance. I was thinking of storing the token value with a generated request hash. For example `{HttpMethod}_{RequestUrl}`. Any alternative? – Efthymios Aug 18 '22 at 15:31
  • Well, keep in mind, this way you are blow up the session with every unique RequestUrl data. As an alternivate, do it client side (by disabling the form) or validate the incoming data against the data you have persisted. Probably you could also hold a collection of the last 5 request values as hashes and validate against this. Other than that, I have no quick idea @Efthymios – Christian Gollhardt Aug 18 '22 at 19:29
  • See also [Chris Pratt's Answer](https://stackoverflow.com/a/55286613/2441442) @Efthymios – Christian Gollhardt Aug 18 '22 at 19:33
  • By "blowing up the session" you refer to memory leaks or exessive memory usage? If that's the case, one way around is to clear the token in `OnActionExecuted`. Also `ISession` has a built-in timeout property which by default is 20 minutes. This could somehow help. I'm currently testing workarounds for this. If I reach someting concrete, I'll let you know. Too bad there is no out-of-the-box solution for such an important issue... – Efthymios Aug 19 '22 at 06:27
6

Handling concurrency with inserts is hard, frankly. Things like updates and deletes are relatively trivial as you can use concurrency tokens. When doing an update for instance, a WHERE clause is added to check the row that is about to be updated concurrency token value. If it doesn't match, that means it was updated since the data was last queried, and you can then implement some sort of recovery stategy.

Inserts don't work the same way because there's obviously nothing there yet to compare to. Your best bet is a somewhat convoluted strategy of assigning some id to a particular insertion. This will have to be persisted on a column in your table, and that column will need to be unique. When you display the form, you set a hidden input with a unique-ish value, such as Guid.NewGuid(). This will then be posted back when the user submits. This then gets added to your entity, and when you save it will be set on the row that's created.

Now let's say the user double-click the submit button firing off two nearly simultaneous requests. Because the same form data is being submit for both requests, the same id is present in both submissions. The one that makes it first ends up saving the record to the database, while the next will end up throwing an exception. Since the column the id is being saved to is unique, and the same id was sent for both requests, the second one will fail to save. At this point, you can catch the exception and recover some how.

My personal recommendation is to make it seamless to the user. When you hit the catch, you query the row that was actually inserted with that id, and return that id/data instead. For example, let's say this was for a checkout page and you were creating orders. You're likely going to redirect the user to an order confirmation page after completion. So, on the request that fails, you look up the order that was actually created, and then you just redirect to the order confirmation page immediately with that order number/id. As far as the user is concerned, they just went to directly to the confirmation page, and your app ended up only inserting one order. Seamless.

Chris Pratt
  • 232,153
  • 36
  • 385
  • 444
0

If you use a relational database the simplest way is to add unique constraint to the table(s) where data is populated. If it's impossible or database isn't relational and you have single server instance you can use synchronization inside the application code, that is keep single instance of an entity to be populated into db and modify this instance quintessentially by using synchronization primitives like lock, etc. But this approach has significant drawback - it doesn't work if there multiple instance of your web application (on different servers for example). Another approach you can apply is using versioning approach - that is you can keep version of modification along with your data and do read before write into a database (in order to increment version) with turned on optimistic locking on the db side (most of dbs support this).

-2

This answer inspired by @Christian Gollhardt answer First you need to enable session in your stratup.cs add

    services.Configure<CookiePolicyOptions>(options =>
                    {
                        // This lambda determines whether user consent for non-essential cookies is needed for a given request.
                        options.CheckConsentNeeded = Context => false;
                        options.MinimumSameSitePolicy = SameSiteMode.None;
                    });

services.AddMemoryCache();
                    services.AddSession(options => {
                        // Set a short timeout for easy testing.
                        options.IdleTimeout = TimeSpan.FromMinutes(10);
                        options.Cookie.HttpOnly = true;
                        // Make the session cookie essential
                        options.Cookie.IsEssential = true;
                    });

and then

   app.UseSession();

then your class

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class PreventDoublePostAttribute : ActionFilterAttribute
{
    private const string UniqFormuId = "LastProcessedToken";
    public override async void OnActionExecuting(ActionExecutingContext context)
    {

        IAntiforgery antiforgery = (IAntiforgery)context.HttpContext.RequestServices.GetService(typeof(IAntiforgery));
        AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(context.HttpContext);

        if (!context.HttpContext.Request.Form.ContainsKey(tokens.FormFieldName))
        {
            return;
        }

        var currentFormId = context.HttpContext.Request.Form[tokens.FormFieldName].ToString();
        var lastToken = "" + context.HttpContext.Session.GetString(UniqFormuId);

        if (lastToken.Equals(currentFormId))
        {
            context.ModelState.AddModelError(string.Empty, "Looks like you accidentally submitted the same form twice.");
            return;
        }
        context.HttpContext.Session.Remove(UniqFormuId);
        context.HttpContext.Session.SetString(UniqFormuId, currentFormId);
        await context.HttpContext.Session.CommitAsync();

    }

}

usage

[HttpPost]
[PreventDoublePost]
public async Task<IActionResult> Edit(EditViewModel model)
{
    if (!ModelState.IsValid)
    {
        //PreventDoublePost Attribute makes ModelState invalid
    }
    throw new NotImplementedException();
}
M.Ali El-Sayed
  • 1,608
  • 20
  • 23