273

In ASP.NET MVC, you can mark up a controller method with AuthorizeAttribute, like this:

[Authorize(Roles = "CanDeleteTags")]
public void Delete(string tagName)
{
    // ...
}

This means that, if the currently logged-in user is not in the "CanDeleteTags" role, the controller method will never be called.

Unfortunately, for failures, AuthorizeAttribute returns HttpUnauthorizedResult, which always returns HTTP status code 401. This causes a redirection to the login page.

If the user isn't logged in, this makes perfect sense. However, if the user is already logged in, but isn't in the required role, it's confusing to send them back to the login page.

It seems that AuthorizeAttribute conflates authentication and authorization.

This seems like a bit of an oversight in ASP.NET MVC, or am I missing something?

I've had to cook up a DemandRoleAttribute that separates the two. When the user isn't authenticated, it returns HTTP 401, sending them to the login page. When the user is logged in, but isn't in the required role, it creates a NotAuthorizedResult instead. Currently this redirects to an error page.

Surely I didn't have to do this?

Roger Lipscombe
  • 89,048
  • 55
  • 235
  • 380
  • 10
    Excellent question and I agree, it should be throwing an HTTP Not Authorized status. – Pure.Krome Jun 06 '10 at 04:13
  • 3
    I like your solution, Roger. Even if you don't. – Jon Davis Jan 07 '11 at 11:43
  • 1
    My Login page has a check to simply redirect the user to the ReturnUrl, if s/he is already autnenticated. So I managed to create an infinite loop of 302 redirects :D woot. – juhan_h Aug 24 '11 at 12:14
  • 1
    Check out [this](http://prideparrot.com/blog/archive/2012/6/customizing_authorize_attribute). – Jogi Jan 22 '16 at 23:26
  • Roger, good article on your solution -- https://www.red-gate.com/simple-talk/dotnet/asp-net/thoughts-on-asp-net-mvc-authorization-and-security/ It seems your solution is the only way to do this cleanly – Craig Jan 14 '18 at 18:36

7 Answers7

313

When it was first developed, System.Web.Mvc.AuthorizeAttribute was doing the right thing - older revisions of the HTTP specification used status code 401 for both "unauthorized" and "unauthenticated".

From the original specification:

If the request already included Authorization credentials, then the 401 response indicates that authorization has been refused for those credentials.

In fact, you can see the confusion right there - it uses the word "authorization" when it means "authentication". In everyday practice, however, it makes more sense to return a 403 Forbidden when the user is authenticated but not authorized. It's unlikely the user would have a second set of credentials that would give them access - bad user experience all around.

Consider most operating systems - when you attempt to read a file you don't have permission to access, you aren't shown a login screen!

Thankfully, the HTTP specifications were updated (June 2014) to remove the ambiguity.

From "Hyper Text Transport Protocol (HTTP/1.1): Authentication" (RFC 7235):

The 401 (Unauthorized) status code indicates that the request has not been applied because it lacks valid authentication credentials for the target resource.

From "Hypertext Transfer Protocol (HTTP/1.1): Semantics and Content" (RFC 7231):

The 403 (Forbidden) status code indicates that the server understood the request but refuses to authorize it.

Interestingly enough, at the time ASP.NET MVC 1 was released the behavior of AuthorizeAttribute was correct. Now, the behavior is incorrect - the HTTP/1.1 specification was fixed.

Rather than attempt to change ASP.NET's login page redirects, it's easier just to fix the problem at the source. You can create a new attribute with the same name (AuthorizeAttribute) in your website's default namespace (this is very important) then the compiler will automatically pick it up instead of MVC's standard one. Of course, you could always give the attribute a new name if you'd rather take that approach.

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true, AllowMultiple = true)]
public class AuthorizeAttribute : System.Web.Mvc.AuthorizeAttribute
{
    protected override void HandleUnauthorizedRequest(System.Web.Mvc.AuthorizationContext filterContext)
    {
        if (filterContext.HttpContext.Request.IsAuthenticated)
        {
            filterContext.Result = new System.Web.Mvc.HttpStatusCodeResult((int)System.Net.HttpStatusCode.Forbidden);
        }
        else
        {
            base.HandleUnauthorizedRequest(filterContext);
        }
    }
}
ShadowChaser
  • 5,520
  • 2
  • 31
  • 33
  • 53
    +1 Very good approach. A small suggestion: instead of checking `filterContext.HttpContext.User.Identity.IsAuthenticated`, you can just check `filterContext.HttpContext.Request.IsAuthenticated`, which comes with null checks built in. See http://stackoverflow.com/questions/1379566/what-is-the-difference-between-httpcontext-current-request-isauthenticated-and-ht/1379601#1379601 – Daniel Liuzzi Jul 27 '11 at 06:46
  • > You can create a new attribute with the same name (AuthorizeAttribute) in your website's default namespace then the compiler will automatically pick it up instead of MVC's standard one. This results in an error: The type or namespace 'Authorize' could not be found ( are you missing a directive or an assembly reference?) Both using System.Web.Mvc; and the namespace for my custom AuthorizeAttribute class are referenced in the controller. To solve this I had to use [MyNamepace.Authorize] – stormwild Nov 13 '11 at 05:23
  • 2
    @DePeter the spec never says anything about a redirect so why is a redirect a better solution? This alone kills ajax requests without a hack in place to solve it. – Adam Tuliper Sep 13 '12 at 14:13
  • 1
    That should be logged on MS Connect because it is clearly a behavioural bug. Thanks. – Tony Wall Feb 05 '13 at 09:21
  • BTW, why are we *redirected* to the login page? Why not just output a 401 code and the login page directly within the same request? – SandRock Jan 09 '14 at 00:07
  • Actually, from my limited experience with limited accounts on Windows and Ubuntu, I remember seeing the window for inputing credentials show up when I tried doing something that I was not allowed to do. But it still is different from what Simple Membership. In those operating systems, you could authorize a single operation by inputing those credentials, but here your authentication will change entirely (simply put you'll relogin as a different user). – jahu Mar 10 '15 at 11:30
  • Incidentally the cast is not necessary, at least now (there is a constructor for HttpStatusCodeResult that takes HttpStatusCode) – Mark Sowul Sep 14 '15 at 21:08
  • Love the references to protocol standard. I've always wondered why there wasn't a behavior distinction between authn and authz in ASP.NET, so your explanation is spot on. And following written standards is a good thing. – Craig Boland Jan 22 '16 at 16:15
  • I also added a custom HTTP error rule in web.config to override IIS's behavior for a 403: – Craig Boland Jan 22 '16 at 16:19
  • Only problem I have with this approach is it only helps when permissions are simple enough that an AuthorizeAttribute gets the job done. In more complex scenarios where you throw an UnauthorizedException in specific scenarios programmatically such as in the business layer, you need to handle it in a HandleErrorAttribute instead. Not really a criticism, but just unfortunately the framework doesn't provide one all inclusive place to handle authorization failures. – AaronLS Apr 22 '16 at 20:52
  • I tried this (and this http://stackoverflow.com/questions/34941877/redirect-loop-with-net-mvc-authorize-attribute-with-adfs-claims) and it didn't fix the redirect loop. I've obviously done something wrong but I don't know what. I put a breakpoint in my code and it was never reached – Nick.Mc Apr 15 '17 at 13:49
24

Add this to your Login Page_Load function:

// User was redirected here because of authorization section
if (User.Identity != null && User.Identity.IsAuthenticated)
    Response.Redirect("Unauthorized.aspx");

When the user is redirected there but is already logged in, it shows the unauthorized page. If they are not logged in, it falls through and shows the login page.

Alan Jackson
  • 6,361
  • 2
  • 31
  • 32
  • 18
    Page_Load is a webforms mojo – Chance Feb 03 '10 at 02:16
  • 2
    @Chance - then do that in the default ActionMethod for the controller that is called where FormsAuthencation has been setup to call. – Pure.Krome Jun 06 '10 at 04:13
  • This actually works really good though for MVC it should be something like `if (User.Identity != null && User.Identity.IsAuthenticated) return RedirectToRoute("Unauthorized");` where *Unauthorized* is a defined route name. – Moses Machua Dec 26 '13 at 04:51
  • So you ask a resource, you get redirected to a login page and you get redirected *again* to a 403 page? Seems bad to me. I even can't tolerate one redirect at all. IMO this thing is very badly built anyway. – SandRock Jan 09 '14 at 00:06
  • 3
    According to your solution, If you have already logged in and go to Login page by typing the URL ... this would throw you to Unauthorized page. which is not right. – Rajshekar Reddy Apr 08 '15 at 08:33
  • As @Reddy has stated, this is not good if the user bookmarked the login page for example, and he hits that even though he has already authenticated. – MoonStom Jul 14 '15 at 16:13
  • I use Kendo UI Controls and the Menu widget use the default behavior of AuthorizeAttribute to show/hide the menu items so using this approach I show a warning to the user "The current user isn't authorized to access this option" – vcRobe May 06 '17 at 13:41
4

Unfortunately, you're dealing with the default behavior of ASP.NET forms authentication. There is a workaround (I haven't tried it) discussed here:

http://www.codeproject.com/KB/aspnet/Custon401Page.aspx

(It's not specific to MVC)

I think in most cases the best solution is to restrict access to unauthorized resources prior to the user trying to get there. By removing/graying out the link or button that might take them to this unauthorized page.

It probably would be nice to have an additional parameter on the attribute to specify where to redirect an unauthorized user. But in the meantime, I look at the AuthorizeAttribute as a safety net.

Keltex
  • 26,220
  • 11
  • 79
  • 111
  • I plan on removing the link based on authorization as well (I saw a question on here about that somewhere), so I'll code an HtmlHelper extension method up later. – Roger Lipscombe Oct 27 '08 at 08:52
  • 1
    I still have to prevent the user from going directly to the URL, which is what this attribute is all about. I'm not too happy with the Custom 401 solution (seems a bit global), so I'll try modelling my NotAuthorizedResult on RedirectToRouteResult... – Roger Lipscombe Oct 27 '08 at 08:55
4

I always thought this did make sense. If you're logged in and you try to hit a page that requires a role you don't have, you get forwarded to the login screen asking you to log in with a user who does have the role.

You might add logic to the login page that checks to see if the user is already authenticated. You could add a friendly message that explains why they've been bumbed back there again.

Rob
  • 1,983
  • 2
  • 20
  • 29
  • 4
    It's my feeling that most people don't tend to have more than one identity for a given web app. If they do, then they're smart enough to think "my current ID doesn't have mojo, I'll log back in as the other one". – Roger Lipscombe Oct 27 '08 at 17:31
  • 1
    Although your other point about displaying something on the login page is a good one. Thanks. – Roger Lipscombe Oct 27 '08 at 17:33
0

Try this in your in the Application_EndRequest handler of your Global.ascx file

if (HttpContext.Current.Response.Status.StartsWith("302") && HttpContext.Current.Request.Url.ToString().Contains("/<restricted_path>/"))
{
    HttpContext.Current.Response.ClearContent();
    Response.Redirect("~/AccessDenied.aspx");
}
Mohamad Shiralizadeh
  • 8,329
  • 6
  • 58
  • 93
0

If your using aspnetcore 2.0, use this:

using System;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;

namespace Core
{
    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true, AllowMultiple = true)]
    public class AuthorizeApiAttribute : Microsoft.AspNetCore.Authorization.AuthorizeAttribute, IAuthorizationFilter
    {
        public void OnAuthorization(AuthorizationFilterContext context)
        {
            var user = context.HttpContext.User;

            if (!user.Identity.IsAuthenticated)
            {
                context.Result = new UnauthorizedResult();
                return;
            }
        }
    }
}
Greg Gum
  • 33,478
  • 39
  • 162
  • 233
0

In my case the problem was "HTTP specification used status code 401 for both "unauthorized" and "unauthenticated"". As ShadowChaser said.

This solution works for me:

if (User != null &&  User.Identity.IsAuthenticated && Response.StatusCode == 401)
{
    //Do whatever

    //In my case redirect to error page
    Response.RedirectToRoute("Default", new { controller = "Home", action = "ErrorUnauthorized" });
}
César León
  • 2,941
  • 1
  • 21
  • 18