71 votes

Comment empêcher la soumission multiple de formulaires dans .NET MVC sans utiliser Javascript ?

Je veux empêcher les utilisateurs de soumettre des formulaires plusieurs fois dans .NET MVC. J'ai essayé plusieurs méthodes en utilisant Javascript, mais j'ai eu des difficultés à le faire fonctionner dans tous les navigateurs. Alors, comment puis-je empêcher cela dans mon contrôleur ? Existe-t-il un moyen de détecter les soumissions multiples ?

0 votes

0 votes

La plupart des réponses ci-dessous concernent l'utilisation de l'identifiant de formulaire. Voir ceci pour définir l'identifiant du formulaire : stackoverflow.com/q/2854616/3885927

87voto

Jim Yarbro Points 1201

Réponse actualisée pour ASP.NET Core MVC (.NET Core & .NET 5.0)

Note de mise à jour : N'oubliez pas qu'ASP.NET Core est encore appelé "Core" dans .NET 5.0 .

Comme précédemment, je vais m'en tenir au cas d'utilisation le moins impactant, où vous n'ornez que les actions du contrôleur pour lesquelles vous souhaitez spécifiquement éviter les requêtes en double. Si vous voulez que ce filtre s'exécute à chaque requête, ou si vous voulez utiliser l'asynchronisme, il existe d'autres options. Voir cet article pour plus de détails.

Le nouvel assistant de balise de formulaire inclut désormais automatiquement l'AntiForgeryToken, de sorte que vous ne devez plus l'ajouter manuellement à votre vue.

Créer un nouveau ActionFilterAttribute comme cet exemple. Vous pouvez faire beaucoup d'autres choses avec cela, par exemple inclure une vérification du délai pour s'assurer que même si l'utilisateur présente deux jetons différents, il ne soumet pas plusieurs fois par minute.

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
public class PreventDuplicateRequestAttribute : ActionFilterAttribute {
    public override void OnActionExecuting(ActionExecutingContext context) {
        if (context.HttpContext.Request.HasFormContentType && context.HttpContext.Request.Form.ContainsKey("__RequestVerificationToken")) {
            var currentToken = context.HttpContext.Request.Form["__RequestVerificationToken"].ToString();
            var lastToken = context.HttpContext.Session.GetString("LastProcessedToken");

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

A la demande, j'ai également écrit une version asynchrone. que l'on peut trouver ici .

Voici un exemple artificiel d'utilisation de la coutume PreventDuplicateRequest attribut.

[HttpPost]
[ValidateAntiForgeryToken]
[PreventDuplicateRequest]
public IActionResult Create(InputModel input) {
    if (ModelState.IsValid) {
        // ... do something with input

        return RedirectToAction(nameof(SomeAction));
    }

    // ... repopulate bad input model data into a fresh viewmodel

    return View(viewModel);
}

Une remarque sur les tests : le simple fait de cliquer sur le bouton "Retour" dans un navigateur n'utilise pas le même AntiForgeryToken. Sur les ordinateurs plus rapides où vous ne pouvez pas physiquement double-cliquer deux fois sur le bouton, vous devrez utiliser un outil tel que Fiddler pour rejouer votre demande avec le même jeton plusieurs fois.

Une note sur la configuration : Core MVC n'a pas de sessions activées par défaut. Vous devrez ajouter l'option Microsoft.AspNet.Session à votre projet, et configurez votre Startup.cs correctement. Veuillez lire cet article pour plus de détails.

La version courte de la configuration de la session est : Dans Startup.ConfigureServices() que vous devez ajouter :

services.AddDistributedMemoryCache();
services.AddSession();

En Startup.Configure() vous devez ajouter ( avant app.UseMvc() ! !):

app.UseSession();

Réponse originale pour ASP.NET MVC (.NET Framework 4.x)

Tout d'abord, assurez-vous que vous utilisez l'AntiForgeryToken sur votre formulaire.

Vous pouvez ensuite créer un ActionFilter personnalisé :

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public class PreventDuplicateRequestAttribute : ActionFilterAttribute {
    public override void OnActionExecuting(ActionExecutingContext filterContext) {
        if (HttpContext.Current.Request["__RequestVerificationToken"] == null)
            return;

        var currentToken = HttpContext.Current.Request["__RequestVerificationToken"].ToString();

        if (HttpContext.Current.Session["LastProcessedToken"] == null) {
            HttpContext.Current.Session["LastProcessedToken"] = currentToken;
            return;
        }

        lock (HttpContext.Current.Session["LastProcessedToken"]) {
            var lastToken = HttpContext.Current.Session["LastProcessedToken"].ToString();

            if (lastToken == currentToken) {
                filterContext.Controller.ViewData.ModelState.AddModelError("", "Looks like you accidentally tried to double post.");
                return;
            }

            HttpContext.Current.Session["LastProcessedToken"] = currentToken;
        }
    }
}

Et sur votre action de contrôle, vous avez juste...

[HttpPost]
[ValidateAntiForgeryToken]
[PreventDuplicateRequest]
public ActionResult CreatePost(InputModel input) {
   ...
}

Vous remarquerez que cela n'empêche pas complètement la demande. Au lieu de cela, il renvoie une erreur dans l'état du modèle, de sorte que lorsque votre action vérifie si les conditions suivantes sont remplies ModelState.IsValid alors il verra que ce n'est pas le cas, et reviendra avec votre traitement normal des erreurs.

44voto

Darin Dimitrov Points 528142

J'ai essayé plusieurs méthodes utilisant Javascript mais j'ai eu des difficultés à le faire fonctionner dans tous les navigateurs.

Avez-vous essayé d'utiliser jquery ?

$('#myform').submit(function() {
    $(this).find(':submit').attr('disabled', 'disabled');
});

Cela devrait permettre de régler les différences entre les navigateurs.

0 votes

Oui, j'ai essayé cette solution particulière et elle ne fonctionne pas dans certaines versions d'IE. C'est pourquoi j'essaie de trouver une solution sans Javascript.

0 votes

Je m'excuse, cette solution fonctionne ! Je n'ai pas remarqué que votre réponse était légèrement différente de celle que j'avais utilisée précédemment et elle fonctionne dans toutes les versions du navigateur que j'ai testées.

4 votes

Vous pourriez marquer comme réponse, ainsi j'aurais lu ceci avant l'autre ;)

32voto

VinnyG Points 2996

Pour compléter la réponse de @Darin, si vous voulez gérer la validation du client (si le formulaire comporte des champs obligatoires), vous pouvez vérifier s'il y a une erreur de validation des entrées avant de désactiver le bouton d'envoi :

$('#myform').submit(function () {
    if ($(this).find('.input-validation-error').length == 0) {
        $(this).find(':submit').attr('disabled', 'disabled');
    }
});

12 votes

Merci, FYI : cette solution a également fonctionné avec une validation discrète (y compris côté serveur). J'ai essayé de cliquer rapidement sur le bouton "upvote" pour vous donner plus de points, mais StackOverflow a bloqué les soumissions multiples... :-)

1 votes

Et si nous utilisons $(this).valid()<br/> $('form').submit(function () { ('.input-validation-error').length) ; if ($(this).valid()) { $(this).find(':submit').attr('disabled', 'disabled') ; }. }) ;

0 votes

Grande amélioration... une autre note au cas où les gens ont plusieurs boutons d'envoi avec des valeurs différentes (sauvegarder/supprimer), qui les contrôles désactivés ne soumettent pas leurs valeurs . Pour y remédier, vous pouvez soit a) enregistrer la valeur en tant qu'entrée cachée, soit b) imiter l'attribut "disabled" avec une CSS pour le griser et un JS pour empêcher les clics.

11voto

aamir sajjad Points 1101

Et si nous utilisions $(this).valid() ?

$('form').submit(function () {
    if ($(this).valid()) {
        $(this).find(':submit').attr('disabled', 'disabled');
    }
});

1 votes

J'ai trouvé que c'était la bonne méthode. L'utilisation de "($(this).find('.input-validation-error').length == 0)" d'une autre réponse ne fonctionne pas pour moi. Cette option désactive le bouton d'envoi même lorsqu'il y a des erreurs de validation. Cela pourrait être dû au fait que "($(this).find('.input-validation-error').length == 0)" est appelé avant la validation.

0 votes

Quelqu'un l'a utilisé et a trouvé des scénarios où cela n'a pas fonctionné pour lui ?

0 votes

El :submit ne fonctionnait pas avec un sélecteur de pseudo-éléments. <button type="submit"> J'ai donc fini par utiliser "button[type=submit]" à la place. Je ne suis pas sûr que le pseudo-sélecteur d'élément puisse fonctionner, car je ne le vois nulle part dans la documentation.

6voto

MarredCheese Points 2206

Stratégie

La vérité est que vous avez besoin de plusieurs lignes d'attaque pour ce problème :

  • El Modèle Post/Redirect/Get (PRG) n'est pas suffisant en soi. Cependant, elle doit toujours être utilisée pour offrir à l'utilisateur une bonne expérience lorsqu'il utilise les fonctions retour, rafraîchissement, etc.
  • L'utilisation de JavaScript pour empêcher l'utilisateur de cliquer plusieurs fois sur le bouton d'envoi est indispensable car elle offre une expérience utilisateur beaucoup moins pénible que les solutions côté serveur.
  • Le blocage des messages en double uniquement du côté client ne protège pas contre les mauvais acteurs et n'aide pas à résoudre les problèmes de connexion transitoires. (Et si votre première requête parvenait au serveur mais que la réponse ne revenait pas au client, ce qui amènerait votre navigateur à renvoyer automatiquement la requête) ?

Je ne vais pas couvrir le PRG, mais voici mes réponses pour les deux autres sujets. Elles s'appuient sur les autres réponses données ici. Pour info, j'utilise .NET Core 3.1.

Côté client

En supposant que vous utilisez la validation jQuery, je pense que c'est le moyen le plus propre et le plus efficace d'empêcher le double-clic sur le bouton d'envoi de votre formulaire. Notez que submitHandler n'est appelé qu'une fois la validation passée, il n'est donc pas nécessaire de procéder à une nouvelle validation.

$submitButton = $('#submitButton');
$('#mainForm').data('validator').settings.submitHandler = function (form) {
  form.submit();
  $submitButton.prop('disabled', true);
};

Une alternative à la désactivation du bouton d'envoi est d'afficher une superposition devant le formulaire pendant l'envoi pour 1) bloquer toute interaction ultérieure avec le formulaire et 2) indiquer que la page "fait quelque chose". Voir cet article pour plus de détails.

Côté serveur

J'ai commencé par La grande réponse de Jim Yarbro ci-dessus, mais ensuite j'ai remarqué La réponse de Mark Butler en soulignant que la méthode de Jim échoue si quelqu'un soumet des formulaires via plusieurs onglets de navigateur (parce que chaque onglet a un jeton différent et que les messages de différents onglets peuvent être entrelacés). J'ai confirmé qu'un tel problème existait réellement et j'ai décidé de passer du suivi du dernier jeton au suivi des x derniers jetons.

Pour faciliter cela, j'ai créé quelques classes d'aide : une pour stocker les x derniers tokens et une pour faciliter le stockage/la récupération d'objets vers/depuis le stockage de session. Le code principal vérifie maintenant que le jeton actuel n'est pas trouvé dans l'historique des jetons. A part cela, le code est à peu près le même. J'ai juste fait quelques petits ajustements pour répondre à mes goûts. J'ai inclus les deux versions, régulière et asynchrone. Le code complet est ci-dessous, mais ce sont les lignes critiques :

var history = session.Get<RotatingHistory<string>>(HistoryKey) ?? new RotatingHistory<string>(HistoryCapacity);

if (history.Contains(token))
{
    context.ModelState.AddModelError("", DuplicateSubmissionErrorMessage);
}
else
{
    history.Add(token);
}

Malheureusement, le défaut fatal de cette approche est que le retour d'information du premier message (avant les doublons) est perdu. Une meilleure solution (mais beaucoup plus complexe) consisterait à stocker le résultat de chaque demande unique par GUID, puis à traiter les demandes en double non seulement en évitant de refaire le travail, mais aussi en renvoyant le même résultat que celui de la première demande, offrant ainsi à l'utilisateur une expérience transparente. Cet article détaillé détaillant les méthodes d'Air BnB pour éviter les paiements en double vous donnera une idée des concepts.

PreventDuplicateFormSubmissionAttribute.cs

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Filters;

// This class provides an attribute for controller actions that flags duplicate form submissions
// by adding a model error if the request's verification token has already been seen on a prior
// form submission.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
public class PreventDuplicateFormSubmissionAttribute: ActionFilterAttribute
{
    const string TokenKey = "__RequestVerificationToken";
    const string HistoryKey = "RequestVerificationTokenHistory";
    const int HistoryCapacity = 5;

    const string DuplicateSubmissionErrorMessage =
        "Your request was received more than once (either due to a temporary problem with the network or a " +
        "double button press). Any submissions after the first one have been rejected, but the status of the " +
        "first one is unclear. It may or may not have succeeded. Please check elsewhere to verify that your " +
        "request had the intended effect. You may need to resubmit it.";

    public override void OnActionExecuting(ActionExecutingContext context)
    {
        HttpRequest request = context.HttpContext.Request;

        if (request.HasFormContentType && request.Form.ContainsKey(TokenKey))
        {
            string token = request.Form[TokenKey].ToString();

            ISession session = context.HttpContext.Session;
            var history = session.Get<RotatingHistory<string>>(HistoryKey) ?? new RotatingHistory<string>(HistoryCapacity);

            if (history.Contains(token))
            {
                context.ModelState.AddModelError("", DuplicateSubmissionErrorMessage);
            }
            else
            {
                history.Add(token);
                session.Put(HistoryKey, history);
            }
        }
    }

    public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        HttpRequest request = context.HttpContext.Request;

        if (request.HasFormContentType && request.Form.ContainsKey(TokenKey))
        {
            string token = request.Form[TokenKey].ToString();

            ISession session = context.HttpContext.Session;
            await session.LoadAsync();
            var history = session.Get<RotatingHistory<string>>(HistoryKey) ?? new RotatingHistory<string>(HistoryCapacity);

            if (history.Contains(token))
            {
                context.ModelState.AddModelError("", DuplicateSubmissionErrorMessage);
            }
            else
            {
                history.Add(token);
                session.Put(HistoryKey, history);
                await session.CommitAsync();
            }
            await next();
        }
    }
}

RotatingHistory.cs

using System.Linq;

// This class stores the last x items in an array.  Adding a new item overwrites the oldest item
// if there is no more empty space.  For the purpose of being JSON-serializable, its data is
// stored via public properties and it has a parameterless constructor.
public class RotatingHistory<T>
{
    public T[] Items { get; set; }
    public int Index { get; set; }

    public RotatingHistory() {}

    public RotatingHistory(int capacity)
    {
        Items = new T[capacity];
    }

    public void Add(T item)
    {
        Items[Index] = item;
        Index = ++Index % Items.Length;
    }

    public bool Contains(T item)
    {
        return Items.Contains(item);
    }
}

SessonExtensions.cs

using System.Text.Json;
using Microsoft.AspNetCore.Http;

// This class is for storing (serializable) objects in session storage and retrieving them from it.
public static class SessonExtensions
{
    public static void Put<T>(this ISession session, string key, T value) where T : class
    {
        session.SetString(key, JsonSerializer.Serialize(value));
    }

    public static T Get<T>(this ISession session, string key) where T : class
    {
        string s = session.GetString(key);
        return s == null ? null : JsonSerializer.Deserialize<T>(s);
    }
}

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X