We have an internal ASP.NET MVC application that requires a logon. Log on works great and does what's expected. We have a session expiration of 15 minutes. After sitting on a single page for that period of time, the user has lost the session. If they attempt to refresh the current page or browse to another, they will get a log on page. We keep their request stored so once they've logged in they can continue on to the page that they've requested. This works great.
However, my issue is that on some pages there are AJAX calls. For example, they may fill out part of a form, wander off and let their session expire. When they come back, the screen is still displayed. If they simply fill in a box (which will make an AJAX call) the AJAX call will return the Logon page (inside of whatever div the AJAX should have simply returned the actual results). This looks horrible.
I think that the solution is to make the page itself expire (so that when a session is terminated, they automatically are returned to the logon screen without any action by them). However, I'm wondering if there are opinions/ideas on how best to implement this specifically in regards to best practices in ASP.NET MVC.
Update:
So I went ahead and implemented this in my OnActionExecuting (per Keltex's suggestion)
if (!filterContext.HttpContext.User.Identity.IsAuthenticated)
{
if (filterContext.HttpContext.Request.IsAjaxRequest())
{
filterContext.HttpContext.Response.Write("Invalid session -- please login!");
filterContext.HttpContext.Response.End();
}
else
{
...
}
}
This definitely makes things better -- now even if they have two tabs (one with some AJAX calls that they can trigger) and they log out explicitly in the second tab, they will immediately get something that makes more sense rather than a bunch of screwed up AJAX data.
I still think I will implement the Javascript countdown as well that womp suggested.
Specifically, I don't know that there are any best practices regarding it, but I'm doing this right now for our app. We've opted for a client-side solution where we output the Session timeout value into some javascript in the master page, and calculate when the session will expire.
5 minutes before-hand, we pop up a modal dialog box saying "Are you still there?" with a countdown timer. Once the timer hits 0:00, we redirect the browser to the login page.
It's implemented with a minimal amount of javascript to do the time and timer calculations, and a simple .ashx handler that will refresh the session if the user clicks "I'm back!" on the dialog box before the session expires. That way if they return in time, they can refresh the session without any navigation.
I asked similar question yesterday. Here is my solution:
Modified Authorize attribute:
public class OptionalAuthorizeAttribute : AuthorizeAttribute
{
private class Http403Result : ActionResult
{
public override void ExecuteResult(ControllerContext context)
{
// Set the response code to 403.
context.HttpContext.Response.StatusCode = 403;
context.HttpContext.Response.Write(CTRes.AuthorizationLostPleaseLogOutAndLogInAgainToContinue);
}
}
private readonly bool _authorize;
public OptionalAuthorizeAttribute()
{
_authorize = true;
}
//OptionalAuthorize is turned on on base controller class, so it has to be turned off on some controller.
//That is why parameter is introduced.
public OptionalAuthorizeAttribute(bool authorize)
{
_authorize = authorize;
}
protected override bool AuthorizeCore(HttpContextBase httpContext)
{
//When authorize parameter is set to false, not authorization should be performed.
if (!_authorize)
return true;
var result = base.AuthorizeCore(httpContext);
return result;
}
protected override void HandleUnauthorizedRequest(AuthorizationContext filterContext)
{
if (filterContext.RequestContext.HttpContext.Request.IsAjaxRequest())
{
//Ajax request doesn't return to login page, it just returns 403 error.
filterContext.Result = new Http403Result();
}
else
base.HandleUnauthorizedRequest(filterContext);
}
}
HandleUnauthorizedRequest is overridden, so it returns Http403Result when using Ajax. Http403Result changes StatusCode to 403 and returns message to the user in response. There is some additional logic in attribute (authorize parameter), because I turn on [Authorize] in base controller and disable it in some pages.
Other important part is global handling of this response on client side. This is what I placed in Site.Master:
<script type="text/javascript">
$(document).ready(
function() {
$("body").ajaxError(
function(e,request) {
if (request.status == 403) {
alert(request.responseText);
window.location = '/Logout';
}
}
);
}
);
</script>
I place GLOBAL ajax error handler and when evert $.post fails with 403 error, response message is alerted and user is redirected to logout page. Now I don't have to handle error in every $.post request, because it is handled globally.
Why 403, not 401? 401 is handled internally by MVC framework (that is why redirection to login page is done after failed authorization).
What do you think about it?
EDIT:
About resigning from [Authorize] attribute: [Authorize] is not only about checking Identity.IsAuthenticated. It also handles page caching (so you don't cache material that requires authentication) and redirection. There is no need to copy this code.
You might look into the AjaxOptions that can be set in Ajax.BeginForm(). There is an OnBegin setting that you can associate with a javascript function, which could call a Controller method to confirm that the session is still valid, and if not, redirect to the login page using window.location.
Part of the problem appears to be that you're letting the framework do everything. I wouldn't decorate your AJAX method with the [Authorize] attribute. Instead check User.Identity.IsAuthenticated and if it returns false, create sensible error message.
My solution uses one meta-tag on login form and a bit of Javascript/jQuery.
LogOn.cshtml
<html>
<head>
<meta data-name="__loginform__" content="true" />
...
</head>
...
</html>
Common.js
var Common = {
IsLoginForm: function (data) {
var res = false;
if (data.indexOf("__loginform__") > 0) {
// Do a meta-test for login form
var temp =
$("<div>")
.html(data)
.find("meta[data-name='__loginform__']")
.attr("content");
res = !!temp;
}
return res;
}
};
AJAX code
$.get(myUrl, myData, function (serverData) {
if (Common.IsLoginForm(serverData)) {
location.reload();
return;
}
// Proceed with filling your placeholder or whatever you do with serverData response
// ...
});
Here's how I did it...
In my base controller
protected override void OnActionExecuting(ActionExecutingContext filterContext)
{
if (!filterContext.HttpContext.User.Identity.IsAuthenticated)
{
if (filterContext.HttpContext.Request.IsAjaxRequest())
{
filterContext.HttpContext.Response.StatusCode = 403;
filterContext.HttpContext.Response.Write(SessionTimeout);
filterContext.HttpContext.Response.End();
}
}
}
Then in my global .js file
$.ajaxSetup({
error: function (x, status, error) {
if (x.status == 403) {
alert("Sorry, your session has expired. Please login again to continue");
window.location.href = "/Account/Login";
}
else {
alert("An error occurred: " + status + "nError: " + error);
}
}
});
The SessionTimeout variable is a noty string. I omitted the implementation for brevity.
Related
I have an ASP.NET MVC application with ActionFilters for Authentication and no Forms Authentication. "SegurancaAction" is the attribute responsible for validating authentication and exists in every controller endpoint except in the login ones (as expected).
I'm facing a problem in which sometimes I try to access one of my controllers and the GET request goes to my login endpoint. In the method Application_BeginRequest at Global.asax, I can see the very first attempt is at 'security/login' (the route to my login endpoint) instead of the one I want. I can also see this endpoint being called in debugging apps such as Fiddler, or ASP.NET Trace or Glimpse MVC5.
Besides calling the wrong action, once I login again this issue keeps happening for the same endpoint I was trying to access, redirecting my site to the login page over and over.
SegurancaAction:
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
Autenticacoes autenticacao = _authApp.IsAutenticado(filterContext.HttpContext.Session.SessionID);
if (autenticacao == null)
{
if (filterContext.HttpContext.Request.IsAjaxRequest())
filterContext.Result = new HttpStatusCodeResult(System.Net.HttpStatusCode.Unauthorized);
else
{
filterContext.HttpContext.Response.RedirectPermanent("/security/login");
return;
}
}
else
{
// other stuff
}
}
SecurityController:
[HttpPost]
[ConfigAction]
public ActionResult Login(vm_Login login)
{
if (ModelState.IsValid)
{
if (!String.IsNullOrEmpty(login.Login) && !String.IsNullOrEmpty(login.Senha))
{
Entidades entidade = _entidadeApp.GetByUsuarioSenha(login.Login, login.Senha);
if (entidade == null)
{
ViewBag.FalhaAutenticacao = "As credenciais informadas não conferem!";
return View("Login");
}
else
{
string encryptionKey = System.Configuration.ConfigurationManager.AppSettings["EncryptionKey"];
var a = _autenticacaoApp.Autenticar(entidade.Id, encryptionKey, login.Senha, HttpContext.Session.SessionID);
}
Response.RedirectPermanent("~/principal/index");
}
}
else
{
ViewBag.FalhaAutenticacao = "É necessário informar o usuario e a senha!";
}
return View();
}
All _autenticacaoApp.Autenticar(...) method does is to create an authentication entry on the database, it's a completely custom code.
Does anyone know why this issue happens? Sometimes I can reproduce it by deleting the cookies that contain ASP.NET_Session ID and RequestVerificationToken. So far I know those cookies are automatically generated and I notice that sometimes when I login again they are not re-generated.
I figured out the issue. It was this "RedirectPermanent" method being used here:
filterContext.HttpContext.Response.RedirectPermanent("/security/login");
It tells the browser that the resource I'm trying to access is no longer available and is now located at this new Url. The browser records this information and always redirects to the new resource.
I just changed it to use "Redirect" instead.
I am working on an ASP.NET MVC web service. In a web page, when a user clicks on a button, this triggers a complex method that takes a bit of time to finish. I want to redirect the user to a waiting page and then, when the process is finished, to redirect the user to a new page.
When the process is done it raises an event, which I can listen to from the controller. But I cannot make the last step to work (the controller redirecting to the new page upon receiving the event).
Here is my very naïve attempt at doing it (with simpler names):
public MyController()
{
EventsControllerClass.ProcessComplete += new EventHandler<MyArgsClass>(OnEventReceived);
}
private void OnEventReceived(object sender, MyArgsClass eventArguments)
{
RedirectToPage();
}
private ActionResult RedirectToPage()
{
return RedirectToAction("PageName");
}
After many days working on this, I have a viable solution. It may not be pretty, but it works, and maybe some ideas can be useful for other people, so here it goes:
I will explain the solution to my particular problem: I need a button to redirect to a "waiting" page while a longer process runs in the background and raises an event when it is finished. When this event is received, we want to redirect the user (automatically) to a final page.
First, I created a class to listen to the event. I tried doing this directly in the controller, but you need to be careful about signing and unsigning, because apparently controllers get created and destroyed at each request. In this "listener class" I have a bool property that is set to "true" when the event is received.
When the first action is triggered, the controller normally redirects to the "wait" page, where I have this simple java script redirecting to the new action:
<script type="text/javascript">
window.location = "#Url.Action("WaitThenRedirect", "AuxiliaryControllerName")";
</script>
This sets in motion the long process (through another event). The key is that I do this with an asynchronous action (this controller inherits from AsyncController). (Note I used an auxiliary controller. This is to keep all asynchronous stuff apart.) This is how this looks (more info here):
public static event EventHandler<AuxiliaryEventsArgs> ProcessReady;
public void WaitThenRedirectAsync()
{
AsyncManager.OutstandingOperations.Increment();
ProcessReady += (sender, e) =>
{
AsyncManager.Parameters["success"] = e.success;
AsyncManager.OutstandingOperations.Decrement();
};
WaitForEvent();
}
public ActionResult WaitThenRedirectCompleted(bool success)
{
if (success)
{
return RedirectToAction("RedirectToView", "ControllerName");
}
else
{
return RedirectToAction("UnexpectedError", "ControllerName");
}
}
private void WaitForEvent()
{
bool isWaitSuccessful = true;
int waitingLoops = 0;
int waitingThreshold = 200;
int sleepPeriod = 100; // (milliseconds)
while (!EventsListener.IsTheThingReady())
{
System.Threading.Thread.Sleep(sleepPeriod);
++waitingLoops;
if (waitingLoops > waitingThreshold)
{
System.Diagnostics.Debug.WriteLine("Waiting timed out!");
isWaitSuccessful = false;
break;
}
}
isWaitSuccessful = true;
if (null != ProcessReady)
{
AuxiliaryEventsArgs arguments = new AuxiliaryEventsArgs();
arguments.success = isWaitSuccessful;
try
{
ProcessReady(null, arguments);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine("Error in event ProcessReady" + ex);
}
}
}
I believe it is possible to use ajax syntax for alternative solutions, but this is what I have and it works nicely. I believe this is not a very common need, but hopefully someone will benefit!
On our MVC website, we secure our endpoints with the Authorize attribute, however I would like to redirect the user to the login screen when this occurs. My first solution was to redirect on Application_EndRequest in Global.asax.cs:
protected void Application_EndRequest(object sender, EventArgs e)
{
if (Response.StatusCode == (int) HttpStatusCode.Unauthorized)
{
Response.ClearContent();
Response.Redirect("/Account/Login");
}
}
The problem with this is that some of our views are loaded by AJAX, in which case we do not want to show the login screen within dynamically-loaded div (this happens for example when the back button is pressed after logging out). It would be better if the request still returned the login screen, but with a 401 Unauthorized status code so that we can detect this event. Obviously this will not work with this approach because the redirection relies on a 302 status code.
Instead, I would rather 'insert' the login screen content inside Application_EndRequest with a 401 status code. So, instead of the Response.Redirect, I tried this:
...
Response.ClearContent();
Server.TransferRequest("~/Account/Login");
Response.StatusCode = (int) HttpStatusCode.Unauthorized;
...
The redirect works nicely, but because this performs a new request, I can't set the status code in this way, and I get a 200. Still no good for detecting an unauthorized request.
I also tried:
...
Response.ClearContent();
Server.Transfer("~/Account/Login");
Response.StatusCode = (int) HttpStatusCode.Unauthorized;
...
But I get an exception "Error executing child request for /Account/Login."
I also tried:
...
Response.ClearContent();
HttpContext.Current.RewritePath("~/Account/Login");
Response.StatusCode = (int) HttpStatusCode.Unauthorized;
Response.End();
...
But this throws an exception "Thread was being aborted". I'm not sure if the Response.End() is necessary but without it, I just get a standard IIS 401 error.
How can I return the content of my login screen but with a 401 status code?
Sending a 401 AND a ViewObject or RedirectObject is not how the MVC Framework was designed to work: -- a few posts have cleverly shown that returning a 401 with a View witn an authorization filter..
I specify the MVC Framework, but this is not even the prefered way to use the HTTP Protocol.
When you redirect in your MVC application, you have created a RedirectResult
When your MVC application returns with a call to the Controller's View() method, a ViewResult is created
Whether a Redirect or a View eventually reaches the client browser either,
an HTTP Return Code of 200 -- Success will be returned
Or
an HTTP Return Code of 302 -- Redirect will be returned
The reason for this convention is that, the server is "handling" the redirect. If the user is not authorized, the server will redirect the user to the login page.
Simlarly, in AJAX or asynchronous HTTP requests, so long as an HTML View is returned, the HTTP request is considered a success. Redirect (302) is not returned for asynchronous requests.
Sending Error Codes to client:
If you want to return a 401 unauthorized to the client, that usually means that you want to the client to "handle" the unauthorized request by navigating to the Login or Error View.
$.ajax({
url: loginURL,
data: $("#form").serialize(),
type: "POST",
dataType: "html"
})
.success(function (result, status) {
/*additional logic*/
})
.error(function (xhr, status) {
if (xhr.status == 401)
{
window.location = unauthorizedUrl; //redirect to unauthorized url
}
});
What if you want to Redirect to Login Page AND Display an Error
your question makes most sense if you want to redirect to a Login Page AND display an Error
Then, the convention is to Add your error message into Viewbag or a ModelState Error
such as:
var user = await signInManager.UserManager.FindByNameAsync(model.UserName);
ModelState.AddModelError("", "Invalid username or password.");
return View(model);
In your View, to have code that looks for an error:
A ValidationSummary would pickup the Model error
#Html.ValidationSummary(true)
This specific example of Bad Login Attempt doesn't exactly speak to your example.
I couldn't find an image for a user redirected because they were not authorized, but, in this case, the logic is similar. Error Msg can be populated with a QueryString, Viewbag, etc.
You would ideally want to use a custom AuthorizeAtrribute and then hook into the HandleUnauthorizedRequest method and from that, return a view to your login page by setting the controller and view to call in the AuthorizationContext.
public class MyAuthorizeAttribute : AuthorizeAttribute
{
protected override void HandleUnauthorizedRequest(AuthorizationContext filterContext)
{
if (!filterContext.HttpContext.User.Identity.IsAuthenticated)
{
filterContext.RouteData.Values["controller"] = "home";
filterContext.RouteData.Values["action"] = "login";
filterContext.Result = new ViewResult ();
filterContext.HttpContext.Response.Clear();
filterContext.HttpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
filterContext.HttpContext.Response.TrySkipIisCustomErrors = true;
filterContext.HttpContext.Response.SuppressFormsAuthenticationRedirect = true;
}
}
}
note: The trick for this is to make sure you set the following value to true to avoid forms authentication taking over the response once you've set the 401 status.
HttpContext.Response.SuppressFormsAuthenticationRedirect = true;
I tried quite a few different methods all of which had their own sneaky problems. Not sure how your solution is setup but if you need to perform a transfer you can use the following:
Custom AuthorizeAttribute
public class CustomAuthorizeAttribute : AuthorizeAttribute
{
protected override void HandleUnauthorizedRequest(AuthorizationContext filterContext)
{
filterContext.RequestContext.HttpContext.Server.TransferRequest("/Account/Login/?NOT_AUTHORISED=TRUE", false);
}
}
You need to pass a message to the next request, using the QueryString seemed to do the trick. Although you need to remember that anyone can set a QS parameter.
Set Status Code
protected void Application_EndRequest(object sender, EventArgs e)
{
if (Request["NOT_AUTHORISED"] == "TRUE")
{
Response.StatusCode = (int) HttpStatusCode.Unauthorized;
}
}
Demo
There's no redirect and the HTTP Status code is set to 401.
For my website i want following behaviors for secured controller(or action)
if a user makes a normal request redirect to login page (which i have easily able to do)
if request is Ajax type Request.IsAjaxRequest()==true, return status code 401
How can i create a filter for this??
public class MyCustomAuthorize : AuthorizeAttribute
{
protected override void HandleUnauthorizedRequest(AuthorizationContext filterContext)
{
//if ajax request set status code and end Response
if (filterContext.HttpContext.Request.IsAjaxRequest())
{
filterContext.HttpContext.Response.StatusCode = 401;
filterContext.HttpContext.Response.End();
}
base.HandleUnauthorizedRequest(filterContext);
}
}
Create a filter like above, it will return status code 401 for unauthorized request if request is made thru ajax.
If you are using jQuery you can do as below
jQuery.ajax({
statusCode: {
401: function() {
alert('unauthrized');
},
/*other options*/
});
In addition to the accepted answer, I needed to put this line of code in to prevent FormsAuthentication from redirecting to the login page..
filterContext.HttpContext.Response.SuppressFormsAuthenticationRedirect = true;
I then removed
filterContext.HttpContext.Response.End();
var unauthorizedResult = new JsonResult
{
Data = new ErrorResult() {Success = 0, Error = "Forbidden"},
JsonRequestBehavior = JsonRequestBehavior.AllowGet
};
// status code
filterContext.HttpContext.Response.StatusCode = (int) HttpStatusCode.Unauthorized;
// return data
filterContext.Result = unauthorizedResult;
filterContext.HttpContext.Response.SuppressFormsAuthenticationRedirect = true;
}
Your problem is not with AJAX request, your problem is returning HTTP 401 Unauthorized response, because you use forms authentication. This response code tells the framework that it should redirect the user-agent to your login page with a HTTP 302 response instead. That's why it was easy to setup the "normal" request redirect - it's done automatically.
To answer your question, I had similar problem and the solution I ended up with was not using forms authentication. I implemented a custom authorization attribute that handles both cases manually instead. I'm not sure if this is the best approach, but it does work. I'm interested in what others think of this solution or what other solutions there are.
Fortunately, you can still use the FormsAuthentication class to handle cookies for you, but you have to delete the forms authentication configuration from your Web.config file. When the user logs in you use FormsAuthentication.SetAuthCookie to, well, set a cookie (you are probably doing this already). Second, in your authorization attribute, you get the cookie from the request and use FormsAuthentication.Decrypt to decrypt it. If it exists and is valid, you set the user in the HttpContext based on this cookie, because forms authentication won't do it for you anymore. If it doesn't you either redirect to the login page or return 401, depending on whether it's an AJAX call or not.
You can use ajaxonly to restrain access to ajax actionresult
You can just return a HttpUnauthorizedResult.
Note: This could cause the MVC framework to return you to the login page.
public ActionResult FailResult()
{
return new HttpUnauthorizedResult();
}
a simple way is to do a check in the SignIn action
public ActionResult SignIn()
{
if (Request.IsAjaxRequest())
{
// you could return a partial view that has this script instead
return Content("<script>window.location = '" + Url.Action("SignIn", "Account") + "'</script>");
}
...
return View();
I want to prevent users submitting forms multiple times in .NET MVC. I've tried several methods using Javascript but have had difficulties getting it to work in all browsers. So, how can I prevent this in my controller? It there some way that multiple submissions can be detected?
Updated answer for ASP.NET Core MVC (.NET Core & .NET 5.0)
Update note: Remember ASP.NET Core is still called "Core" in .NET 5.0.
I'm going to stick to the least-impact use case like before, where you're only adorning those controller actions that you specifically want to prevent duplicate requests on. If you want to have this filter run on every request, or want to use async, there are other options. See this article for more details.
The new form tag helper now automatically includes the AntiForgeryToken so you no longer need to manually add that to your view.
Create a new ActionFilterAttribute like this example. You can do many additional things with this, for example including a time delay check to make sure that even if the user presents two different tokens, they aren't submitting multiple times per 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);
}
}
}
}
By request, I also wrote an asynchronous version which can be found here.
Here's a contrived usage example of the custom PreventDuplicateRequest attribute.
[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);
}
A note on testing: simply hitting back in a browser does not use the same AntiForgeryToken. On faster computers where you can't physically double click the button twice, you'll need to use a tool like Fiddler to replay your request with the same token multiple times.
A note on setup: Core MVC does not have sessions enabled by default. You'll need to add the Microsoft.AspNet.Session package to your project, and configure your Startup.cs properly. Please read this article for more details.
Short version of Session setup is:
In Startup.ConfigureServices() you need to add:
services.AddDistributedMemoryCache();
services.AddSession();
In Startup.Configure() you need to add (before app.UseMvc() !!):
app.UseSession();
Original answer for ASP.NET MVC (.NET Framework 4.x)
First, make sure you're using the AntiForgeryToken on your form.
Then you can make a custom ActionFilter:
[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;
}
}
}
And on your controller action you just...
[HttpPost]
[ValidateAntiForgeryToken]
[PreventDuplicateRequest]
public ActionResult CreatePost(InputModel input) {
...
}
You'll notice this doesn't prevent the request altogether. Instead it returns an error in the modelstate, so when your action checks if ModelState.IsValid then it will see that it is not, and will return with your normal error handling.
I've tried several methods using Javascript but have had difficulties getting it to work in all browsers
Have you tried using jquery?
$('#myform').submit(function() {
$(this).find(':submit').attr('disabled', 'disabled');
});
This should take care of the browser differences.
Just to complete the answer of #Darin, if you want to handle the client validation (if the form has required fields), you can check if there's input validation error before disabling the submit button :
$('#myform').submit(function () {
if ($(this).find('.input-validation-error').length == 0) {
$(this).find(':submit').attr('disabled', 'disabled');
}
});
What if we use $(this).valid()?
$('form').submit(function () {
if ($(this).valid()) {
$(this).find(':submit').attr('disabled', 'disabled');
}
});
Strategy
The truth is that you need several lines of attack for this problem:
The Post/Redirect/Get (PRG) pattern is not enough by itself. Still, it should always be used to provide the user with good experiences when using back, refresh, etc.
Using JavaScript to prevent the user from clicking the submit button multiple times is a must because it provides a much less jarring user experience compared to server-side solutions.
Blocking duplicate posts solely on the client side doesn't protect against bad actors and does not help with transient connection problems. (What if your first request made it to the server but the response did not make it back to the client, causing your browser to automatically resend the request?)
I'm not going to cover PRG, but here are my answers for the other two topics. They build upon the other answers here. FYI I'm using .NET Core 3.1.
Client-Side
Assuming you are using jQuery validation, I believe this is the cleanest/most efficient way to prevent your form submit button from being double-clicked. Note that submitHandler is only called after validation has passed, so there is no need to re-validate.
$submitButton = $('#submitButton');
$('#mainForm').data('validator').settings.submitHandler = function (form) {
form.submit();
$submitButton.prop('disabled', true);
};
An alternative to disabling the submit button is to show an overlay in front of the form during submission to 1) block any further interaction with the form and 2) communicate that the page is "doing something." See this article for more detail.
Server-Side
I started off with Jim Yarbro's great answer above, but then I noticed Mark Butler's answer pointing out how Jim's method fails if someone submits forms via multiple browser tabs (because each tab has a different token and posts from different tabs can be interlaced). I confirmed that such a problem really does exist and then decided to upgrade from tracking just the last token to tracking the last x tokens.
To facilitate that, I made a couple of helper classes: one for storing the last x tokens and one for making it easy to store/retrieve objects to/from session storage. The main code now checks that the current token is not found in the token history. Other than that, the code is pretty much the same. I just made some little tweaks to suit my tastes. I included both the regular and asynchronous versions. The full code is below, but these are the critical lines:
var history = session.Get<RotatingHistory<string>>(HistoryKey) ?? new RotatingHistory<string>(HistoryCapacity);
if (history.Contains(token))
{
context.ModelState.AddModelError("", DuplicateSubmissionErrorMessage);
}
else
{
history.Add(token);
}
Sadly, the fatal flaw of this approach is that the feedback from the first post (before any duplicates) gets lost. A better (but much more complex) solution would be to store the result of each unique request by GUID, and then handle duplicate requests by not only skipping doing the work again but also returning the same result from the first request, giving the user a seamless experience. This thorough article detailing Air BnB's methods of avoiding duplicate payments will give you an idea of the 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);
}
}
You could include a hidden (random or counter) value in the form post, a controller could track these values in an 'open' list or something similar; every time your controller hands out a form it embeds a value, which it tracks allowing one post use of it.
In its self, no, however depending on what the controller is actually doing, you should be able to work out a way.
Is a record being created in the database that you can check for to see if they've already submitted the form?
Just add this code at the end of your page. I am using "jquery-3.3.1.min.js" and "bootstrap 4.3.1"
<script type="text/javascript">
$('form').submit(function () {
if ($(this).valid()) {
$(this).find(':submit').attr('disabled', 'disabled');
}
});
</script>
Use the Post/Redirect/Get design pattern.
PS:
It looks to me that the answer by Jim Yarbro could have a fundamental flaw in that the __RequestVerificationToken stored in the HttpContext.Current.Session["LastProcessedToken"] will be replaced when a second form is submitted (from say another browser window). At this point, it is possible to re-submit the first form without it being recognized as a duplicate submission. For the proposed model to work, wouldn’t a history of __RequestVerificationToken be required? This doesn't seem feasible.
Dont reinvent the wheel :)
Use the Post/Redirect/Get design pattern.
Here you can find a question and an answer giving some suggestions on how to implement it in ASP.NET MVC.
You can also pass some sort of token in a hidden field and validate this in the controller.
Or you work with redirects after submitting values. But this get's difficult if you take heavily advantage of ajax.
This works on every browser
document.onkeydown = function () {
switch (event.keyCode) {
case 116: //F5 button
event.returnValue = false;
event.keyCode = 0;
return false;
case 82: //R button
if (event.ctrlKey) {
event.returnValue = false;
event.keyCode = 0;
return false;
}
}
}
You can do this by creating some sort of static entry flag that is user specific, or specific to whatever way you want to protect the resource. I use a ConcurrentDictionary to track entrance. The key is basically the name of the resource I'm protecting combined with the User ID. The trick is figuring out how to block the request when you know it's currently processing.
public async Task<ActionResult> SlowAction()
{
if(!CanEnterResource(nameof(SlowAction)) return new HttpStatusCodeResult(204);
try
{
// Do slow process
return new SlowProcessActionResult();
}
finally
{
ExitedResource(nameof(SlowAction));
}
}
Returning a 204 is a response to the double-click request that will do nothing on the browser side. When the slow process is done, the browser will receive the correct response for the original request and act accordingly.
Use this simple jquery input field and will work awesomely even if you have multiple submit buttons in a single form.
$('input[type=submit]').click(function () {
var clickedBtn = $(this)
setTimeout(function () {
clickedBtn.attr('disabled', 'disabled');
}, 1);
});