aspnetcore icon indicating copy to clipboard operation
aspnetcore copied to clipboard

ApiController redirects to login page

Open vanillajonathan opened this issue 6 years ago • 66 comments

Describe the bug

When using ASP.NET Identity cookie authentication to protect a API controller that is decorated with the ApiController attribute and performing an unauthorized HTTP request, ASP.NET redirects to /Identity/Account/Login?ReturnUrl=%2Fapi%2FFoo instead of just returning a 401 Unauthorized status code and problem details in JSON format.

To Reproduce

Steps to reproduce the behavior:

  1. Configure ASP.NET Core to use ASP.NET Identity with default identity and default UI which uses cookies.
  2. Create a API controller that you decorate with the ApiController attribute.
  3. Perform a HTTP request (Content-Type can even be application/json) against the controller.

Using ASP.NET Core 6.0.

[ApiController] // <-- I want this controller to behave like an API. 
[Authorize]     // <-- Authenticate using cookies.
[Route("api/[controller]")]
public class FooController : ControllerBase
{
    [HttpGet]
    public string Get()
    {
        return "string";
    }
}
// Add services to the container.
/// ...
builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>();
// ...

// Configure the HTTP request pipeline.
// ...
app.UseAuthentication();
// ...

Expected behavior

I expected the API to behave as an API since I explicitly decorated the controller with the ApiController attribute. I expected it to return the status code 401 Unauthorized. Maybe with a link to: https://httpstatuses.com/401

Additional context

My SPA authenticates using cookie authentication then uses fetch() to do HTTP requests against the API. The fetch call sets the credentials option to include cookies with the request.

vanillajonathan avatar Apr 03 '19 18:04 vanillajonathan

Override the cookie authentication events.

As you're also using identity, it'd be something like

services.ConfigureApplicationCookie(o =>
        {
            o.Events = new CookieAuthenticationEvents()
            {
                OnRedirectToLogin = (ctx) =>
                {
                    if (ctx.Request.Path.StartsWithSegments("/api") && ctx.Response.StatusCode == 200)
                    {
                        ctx.Response.StatusCode = 401;
                    }

                    return Task.CompletedTask;
                },
                OnRedirectToAccessDenied = (ctx) =>
                {
                    if (ctx.Request.Path.StartsWithSegments("/api") && ctx.Response.StatusCode == 200)
                    {
                        ctx.Response.StatusCode = 403;
                    }

                    return Task.CompletedTask;
                }
            };
        });

blowdart avatar Apr 03 '19 21:04 blowdart

cc @DamianEdwards and @davidfowl since this is similar to the diagnostics \ error handling that you recently investigated.

pranavkm avatar Apr 04 '19 04:04 pranavkm

Thank you @blowdart for the workaround.

I still think that controllers intended for use as APIs and explicitly decorated with the ApiController should return a status code (possibly along with JSON) instead of redirecting though.

vanillajonathan avatar Apr 04 '19 09:04 vanillajonathan

That's an interesting idea @vanillajonathan. That responsibility lies outside of the authentication pieces, and in MVC, so I'll demure to @rynowak et al for that.

blowdart avatar Apr 04 '19 17:04 blowdart

@glennc

danroth27 avatar Apr 10 '19 16:04 danroth27

@vanillajonathan Actually this is happening due to a complete miss-design of introducing Razor Page Identity in the MVC application. And they are sticking to this miss-design and trying to fit this in everywhere. In Angular application they are providing login page from Razor Page Identity. How comical is this! :) What will happen if my Client App and API are in two different apps?

This hodgepodge would not have happened if they didn't introduce Razor Page Identity in the MVC application. By the way, I completely hate this Razor Page Identity in an MVC application and I am fully irritated with this.

TanvirArjel avatar Apr 18 '19 06:04 TanvirArjel

+1 Would be great if we could stop using that workaround suggested by @blowdart. I lost the count in how many applications I've added that piece of code.

huysentruitw avatar May 04 '19 11:05 huysentruitw

If you send the head "X-Requested-With: XMLHttpRequest" the redirection is not done and only 401 is send cf https://github.com/aspnet/AspNetCore/blob/master/src/Security/Authentication/Cookies/src/CookieAuthenticationEvents.cs.

I tried it with a Blazor client-side app and it is working

RemiBou avatar Jun 13 '19 11:06 RemiBou

Thank you @RemiBou, I was not aware of that. I just did a plain fetch request without setting any additional HTTP headers.

A thing to take into consideration is that ASP.NET Core MVC no longer have the IsAjaxRequest method that was present in the old ASP.NET MVC 5, so arguably have been moving away from the X-Requested-With header which is a non-standard (but de facto standard) used by jQuery (and some other JavaScript frameworks and libraries).

Another thing to note is that I believe including the X-Requested-With header would or could trigger an additional CORS preflight request.

vanillajonathan avatar Jun 13 '19 12:06 vanillajonathan

Why do you think the headers won't change the CORS preflight ?

RemiBou avatar Jun 13 '19 12:06 RemiBou

My understanding is that adding non-standard HTTP headers to a fetch causes it perform a CORS preflight request.

Why do you think the headers won't change the CORS preflight ?

See: https://developers.google.com/web/ilt/pwa/working-with-the-fetch-api#example_post_requests

The server in this example would need to be configured to accept the X-Custom-Header header in order for the fetch to succeed. When a custom header is set, the browser performs a preflight check. This means that the browser first sends an OPTIONS request to the server to determine what HTTP methods and headers are allowed by the server.

vanillajonathan avatar Jun 13 '19 14:06 vanillajonathan

@blowdart solution doesn't work in .NET Core 3.x (preview) anymore. I tried a ton of different approaches but can't seem to find a working solution. Annoying. A Web API project redirecting "automagically" (huge design mistake) to a login page which does not exist and therefore ends up in returning a 404 (Not Found) whilst actually the request was UnAuthenticated (which should be the real name of 401 UnAuthorized. Authentication -> Who are you? And do you have a valid login with password? Authorization -> Hey we know you, welcome! But do you have the right to perform this request? Well let's see which you roles / claims you own.

Concerning the flaw in design I agree with @TanvirArjel completely.

Bernoulli-IT avatar Aug 01 '19 11:08 Bernoulli-IT

I just found a solution and described it here it was this SO answer which brought light.

Bernoulli-IT avatar Aug 01 '19 11:08 Bernoulli-IT

@Bernoulli-IT, well, I have this issue even for Bearer auth in ASP.NET Core 3

services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(opt =>
    {
        opt.TokenValidationParameters = new TokenValidationParameters
        {
            ClockSkew = TimeSpan.FromMinutes(5),
            RequireSignedTokens = true,
            RequireExpirationTime = true,
            ValidateLifetime = true,
            ValidateAudience = false,
            ValidIssuer = issuer,
            IssuerSigningKey = new X509SecurityKey(certificate),
            ValidateIssuerSigningKey = true,
            NameClaimType = "sub"
        };
        opt.IncludeErrorDetails = true;
    });

And I saw a redirect instead of 401 for the controller marked as ApiController

icrosoft.AspNetCore.Hosting.Diagnostics: Information: Request starting HTTP/1.1 GET http://localhost:53775/identity/api/claims/158f6e60-cab9-47a2-9e91-7b124c45945b
Microsoft.AspNetCore.HostFiltering.HostFilteringMiddleware: Trace: All hosts are allowed. Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware: Debug: The request path does not match the path filter Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler: Debug: AuthenticationScheme: idsrv was not authenticated. Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler: Debug: AuthenticationScheme: idsrv was not authenticated. IdentityServer4.Hosting.EndpointRouter: Trace: No endpoint entry found for request path: /api/claims/158f6e60-cab9-47a2-9e91-7b124c45945b Microsoft.AspNetCore.Routing.Matching.DfaMatcher: Debug: 1 candidate(s) found for the request path '/api/claims/158f6e60-cab9-47a2-9e91-7b124c45945b' Microsoft.AspNetCore.Routing.Matching.DfaMatcher: Debug: Endpoint 'Luscii.Identity.Service.Controllers.ClaimController.Get (Luscii.Identity.Service)' with route pattern 'api/claims/{connectId}' is valid for the request path '/api/claims/158f6e60-cab9-47a2-9e91-7b124c45945b' Microsoft.AspNetCore.Routing.EndpointRoutingMiddleware: Debug: Request matched endpoint 'Luscii.Identity.Service.Controllers.ClaimController.Get (Luscii.Identity.Service)' Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler: Debug: AuthenticationScheme: idsrv was not authenticated. Microsoft.AspNetCore.Authorization.DefaultAuthorizationService: Information: Authorization failed. Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler: Information: AuthenticationScheme: idsrv was challenged. Microsoft.AspNetCore.Hosting.Diagnostics: Information: Request finished in 440.71200000000005ms 302 Microsoft.AspNetCore.Server.IIS.Core.IISHttpServer: Debug: Connection ID "18230571301796315137" disconnecting. Microsoft.AspNetCore.Hosting.Diagnostics: Information: Request starting HTTP/1.1 GET http://localhost:53775/identity/Account/Login?ReturnUrl=%2Fidentity%2Fapi%2Fclaims%2F158f6e60-cab9-47a2-9e91-7b124c45945b
Microsoft.AspNetCore.HostFiltering.HostFilteringMiddleware: Trace: All hosts are allowed. Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware: Debug: The request path does not match the path filter Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler: Debug: AuthenticationScheme: idsrv was not authenticated. Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler: Debug: AuthenticationScheme: idsrv was not authenticated. IdentityServer4.Hosting.EndpointRouter: Trace: No endpoint entry found for request path: /Account/Login Microsoft.AspNetCore.Routing.Matching.DfaMatcher: Debug: No candidates found for the request path '/Account/Login' Microsoft.AspNetCore.Routing.EndpointRoutingMiddleware: Debug: Request did not match any endpoints Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler: Debug: AuthenticationScheme: idsrv was not authenticated. Microsoft.AspNetCore.Hosting.Diagnostics: Information: Request finished in 117.48580000000001ms 404

But what was really suspicious is idsrv scheme. It appeared, that I was adding (!!!) bearer as default auth scheme (I use [Authorize] without specifying scheme), and only then I was adding IdentityServer services where default auth scheme gets silently overridden.

When I changed the order of services registration, 401 was returned instead of redirect.

Redirect happens.

AddJwtAuthentication(services);
AddIdentityServer(services);

401 is returned

AddIdentityServer(services);
AddJwtAuthentication(services);

I would like to see warnings, if default scheme changes multiple times.

Maybe @leastprivilege and @brockallen are willing to comment on this as well.

voroninp avatar Oct 15 '19 14:10 voroninp

I don't need to use IdentityServer. This is what worked for me:

services
.AddAuthentication()
.AddOpenIdConnect()
.AddJwtBearer()
.AddCookie(options =>
   {
           options.Events.OnRedirectToAccessDenied =
           options.Events.OnRedirectToLogin = c =>
                {
                   c.Response.StatusCode = StatusCodes.Status401Unauthorized;
                   return Task.FromResult<object>(null);
                };
    });

altretya-microsoft avatar Dec 17 '19 22:12 altretya-microsoft

The framework is great but when I start working with security it is a total miss this is what make me avoid asp .net for many years ... you need so much workarounds to accomplish your work.

I think specially the Security part in asp .net should be re designed is there any alternative to it?

baselbj avatar Jan 22 '20 21:01 baselbj

Hi there! I just wasted way too much time on this issue. In my case, depending on which the configuration of the project, Authentification is necessary or not etc., and I don't use IdentityServer etc.

I was using netcore3.1

// you have to assign any scheme, not assigning a scheme doesn't throw but also doesn't work
app.AddAuthentication(options =>
                {
                    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
                    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
                })
// necessary, even if you don't use BearerAuth.
.AddJwtBearer();
           app.UseRouting();

            // UseAuthentification & UseAuthorization must be called after Routing and before Endpoints!
            app.UseAuthentication();
            app.UseAuthorization();

            app.UseEndpoints(...);

For standalone operation without Authentification, you need to write a class and implement IAuthorizationService and return AuthorizationResult.Success() in every case. Of course, you need to add the service manually to services (e.g. with AddTransient).

Either way, it took way too long to get my backend working. To me, there's a huge usability problem for developers because the default behavior isn't really expectable and there's not really much helpful documentation out there. Due to AspNetCore being OpenSource at least, I was able to somewhat try and guess how to get it working.

raspyweather avatar Feb 25 '20 15:02 raspyweather

I don't need to use IdentityServer. This is what worked for me:

services
.AddAuthentication()
.AddOpenIdConnect()
.AddJwtBearer()
.AddCookie(options =>
   {
           options.Events.OnRedirectToAccessDenied =
           options.Events.OnRedirectToLogin = c =>
                {
                   c.Response.StatusCode = StatusCodes.Status401Unauthorized;
                   return Task.FromResult<object>(null);
                };
    });

👍Suppressed redirecting using this method. Nice!

iamNCJ avatar May 16 '20 09:05 iamNCJ

@RemiBou unfortunately I cannot reproduce that setting the X-Requested-With: XMLHttpRequest header causes a 401. I have created a fresh "ASP.NET Core Web Application" project (the "ASP.NET Core Web API" template) and applied this diff:

index 0e4d663..f7dade8 100644
--- a/NoRedirectForXHRTest/Controllers/WeatherForecastController.cs
+++ b/NoRedirectForXHRTest/Controllers/WeatherForecastController.cs
@@ -1,4 +1,5 @@
-<EF><BB><BF>using Microsoft.AspNetCore.Mvc;
+<EF><BB><BF>using Microsoft.AspNetCore.Authorization;^M
+using Microsoft.AspNetCore.Mvc;^M
 using Microsoft.Extensions.Logging;
 using System;
 using System.Collections.Generic;
@@ -7,6 +8,7 @@ using System.Threading.Tasks;

 namespace NoRedirectForXHRTest.Controllers
 {
+    [Authorize]^M
     [ApiController]
     [Route("[controller]")]
     public class WeatherForecastController : ControllerBase
diff --git a/NoRedirectForXHRTest/Startup.cs b/NoRedirectForXHRTest/Startup.cs
index e1960f1..16ffd18 100644
--- a/NoRedirectForXHRTest/Startup.cs
+++ b/NoRedirectForXHRTest/Startup.cs
@@ -1,3 +1,4 @@
+using Microsoft.AspNetCore.Authentication.Cookies;^M
 using Microsoft.AspNetCore.Builder;
 using Microsoft.AspNetCore.Hosting;
 using Microsoft.AspNetCore.Mvc;
@@ -24,7 +25,9 @@ namespace NoRedirectForXHRTest
         // This method gets called by the runtime. Use this method to add services to the container.
         public void ConfigureServices(IServiceCollection services)
         {
-
+            services.AddAuthentication(options =>^M
+                options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme)^M
+                .AddCookie();^M
             services.AddControllers();
         }

When I query the forecast controller with curl and the header set, I get redirected to the (not existing) loginpage:

~$ curl -X GET "http://localhost:5000/weatherforecast"  -v -H "X-Requested-With: XMLHttpRequest"
Note: Unnecessary use of -X or --request, GET is already inferred.
*   Trying 127.0.0.1:5000...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 5000 (#0)
> GET /weatherforecast HTTP/1.1
> Host: localhost:5000
> User-Agent: curl/7.68.0
> Accept: */*
> X-Requested-With: XMLHttpRequest
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 401 Unauthorized
< Date: Wed, 18 Nov 2020 16:02:06 GMT
< Server: Kestrel
< Content-Length: 0
< Location: http://localhost:5000/Account/Login?ReturnUrl=%2Fweatherforecast
<
* Connection #0 to host localhost left intact

Am I missing something obvious? The header is parsed correctly, a test controller like this echos "foobar true":

[HttpGet]
public ActionResult Test()
{
    var foo = string.Equals(HttpContext.Request.Query[HeaderNames.XRequestedWith], "XMLHttpRequest", StringComparison.Ordinal) ||
        string.Equals(HttpContext.Request.Headers[HeaderNames.XRequestedWith], "XMLHttpRequest", StringComparison.Ordinal);
    return Ok($"foobar {foo}");
}

Trolldemorted avatar Nov 18 '20 16:11 Trolldemorted

Maybe if we patch the file CookieAuthenticationEvents.cs file:

Checking if the Accept request header contains "application/json".

        /// <summary>
        /// Invoked when the client needs to be redirected to the sign in url.
        /// </summary>
        public Func<RedirectContext<CookieAuthenticationOptions>, Task> OnRedirectToLogin { get; set; } = context =>
        {
+           if (context.Request.Headers.TryGetValue(HeaderNames.Accept, out StringValues accept) && accept.Any(x => x.Contains("application/json")))
+           {
+               context.Response.StatusCode = 401;
+               return Task.CompletedTask;
+           }
            if (IsAjaxRequest(context.Request))
            {
                context.Response.Headers[HeaderNames.Location] = context.RedirectUri;
                context.Response.StatusCode = 401;
            }
            else
            {
                context.Response.Redirect(context.RedirectUri);
            }
            return Task.CompletedTask;
        };

Or by checking if the path starts with /api/:

        /// <summary>
        /// Invoked when the client needs to be redirected to the sign in url.
        /// </summary>
        public Func<RedirectContext<CookieAuthenticationOptions>, Task> OnRedirectToLogin { get; set; } = context =>
        {
+           if (context.Request.Path.StartsWithSegments("/api"))
+           {
+               context.Response.StatusCode = 401;
+               return Task.CompletedTask;
+           }
            if (IsAjaxRequest(context.Request))
            {
                context.Response.Headers[HeaderNames.Location] = context.RedirectUri;
                context.Response.StatusCode = 401;
            }
            else
            {
                context.Response.Redirect(context.RedirectUri);
            }
            return Task.CompletedTask;
        };

@blowdart, @rynowak, @DamianEdwards, @davidfowl, @glennc, @pranavkm, @mkArtakMSFT

vanillajonathan avatar Nov 19 '20 14:11 vanillajonathan

Hey @blowdart, @rynowak, @DamianEdwards, @davidfowl, @glennc, @pranavkm, @mkArtakMSFT What do you think of the patch above? Does it look reasonable? Can this be a way forward?

vanillajonathan avatar Nov 30 '20 10:11 vanillajonathan

In my opinion, both proposals are too application specific.

  1. What if it's a static translation json file or static sensitive json data that needs a redirect to login?
  2. I can imagine not everyone uses the /api prefix, it would be too much magic.

I wonder why the IsAjaxRequest check isn't sufficient for your application?

huysentruitw avatar Nov 30 '20 12:11 huysentruitw

Good points.

I use fetch in JavaScript and by default it does not include the non-standard X-Requested-With header. See also: https://github.com/dotnet/aspnetcore/issues/9039#issuecomment-501720620

vanillajonathan avatar Nov 30 '20 13:11 vanillajonathan

At some point you'll need to wrap the calls to fetch in your own method where you would also handle the 401 that comes back. Otherwise, you'll have to handle the 401 at all places, am I correct?

Once you have that wrapping method, it's easy to include the de-facto X-Requested-With which isn't going away anytime soon IMO. 🙂

huysentruitw avatar Dec 05 '20 07:12 huysentruitw

yes I want RedirectToLogin on View Controller and 401 on Api Controller

whSwitching avatar Dec 15 '20 11:12 whSwitching

You can also specify the AuthenticationScheme on the controller attribute like this:

[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]

gobyn avatar Dec 16 '20 14:12 gobyn

It would make sense for the ApiController attribute to adjust this behavior, but looking at CookieAuthenticationHandler.cs, SignInAuthenticationHandler.cs, and AuthenticationHandler.cs I have found no obvious way of doing so, I was looking for references to "controller" so I could determine if it was decorated with the ApiController attribute.

vanillajonathan avatar Dec 17 '20 11:12 vanillajonathan

allright, I just did it by myself, I just want a simple different authorize on webapi

[AllowAnonymous]
[Route("connect/token")]
[ApiController]
public class TokenController: ControllerBase
{
    // issue access_token, asymmetric encryption, or save something in db
}

[ApiAuthorize]
[Route("api/[controller]")]
[ApiController]
public class ValuesController: ControllerBase
{
    // protected api controller
}

public class ApiAuthorizeAttribute : Attribute, IAuthorizationFilter, IFilterFactory
{
    private AppDbContext _db;
    public bool IsReusable => false;
    public string AuthenticationSchemes { get; set; }
    public string Policy { get; set; }
    public string Roles { get; set; }

    public IFilterMetadata CreateInstance(IServiceProvider serviceProvider)
    {
        return new ApiAuthorizeAttribute()
        {
            _db = (AppDbContext)serviceProvider.GetService(typeof(AppDbContext));
        };
    }

    public void OnAuthorization(AuthorizationFilterContext context)
    {
        // check Authorization header
        if (context.HttpContext.Request.Headers.ContainsKey("Authorization"))
        {
            var authHeader = context.HttpContext.Request.Headers["Authorization"][0];
            // validate access_token you just issued
            // check token expiration
            // check token identity valid
            // check AuthenticationSchemes
            // check Policy
            // check Roles
            if ( all pass )
                return;
        }
        context.Result = new Microsoft.AspNetCore.Mvc.UnauthorizedResult();
    }
}

whSwitching avatar Dec 17 '20 11:12 whSwitching

It was not mentioned, but you can also use the ForbidResult and specify the authentication scheme. So if your API is using Bearer tokens, just use Forbid("Bearer"); You wont get redirected.

[ApiController, Authorize("Bearer")]
public class MyApiController : ControllerBase
{
  public IActionResult TestMethod()
  {
     if (DoSpecialPermissionCheckAfterAuthentication() == false)
    {
      return Forbid("Bearer");
    }

    return Ok;
  }
}

It does not return the same json object as StatusCode(403), but it does not redirect to the cookies login page.

Roy

RoySalisbury avatar Jan 08 '21 20:01 RoySalisbury

I found that @raspyweather's suggestion above worked for me in .NET 5 hosted Blazor Web Assembly (without identity server). Specifically setting the default auth and challenge schemes:

services
    .AddAuthentication(o => {
        o.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
        o.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    })
    .AddJwtBearer(o => o.TokenValidationParameters = new TokenValidationParameters() {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer = this.configuration["JWT:Issuer"],
        ValidAudience = this.configuration["JWT:Audience"],
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(this.configuration["JWT:Key"])),
    })

Previously I simply had services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(//jwt sfuff) which resulted in a 302 redirect to a non-existent login page, but changing to the above means I now get the desired 401.

If any one stumbling upon this needs a guide to API auth with hosted Blazor WASM without the rather confusing identity server implementation in the standard template I can't recommend this enough:

https://chrissainty.com/securing-your-blazor-apps-authentication-with-clientside-blazor-using-webapi-aspnet-core-identity/

benm-eras avatar Feb 04 '21 15:02 benm-eras