asp.net-core-2.2

Prevent redirect to /Account/Login in asp.net core 2.2


I am trying to prevent the app to redirect to /Account/Login in asp.net core 2.2 when the user isn't logged in.

Even though i write LoginPath = new PathString("/api/contests"); any unauthorized requests are still redirected to /Account/Login

This is my Startup.cs:

using System;
using System.Reflection;
using AutoMapper;
using Contest.Models;
using Contest.Tokens;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json;
using Swashbuckle.AspNetCore.Swagger;

namespace Contest
{
    public class Startup
    {
        public IConfiguration Configuration { get; }

        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {

            services.AddAutoMapper();

            // In production, the React files will be served from this directory
            services.AddSpaStaticFiles(configuration =>
            {
                configuration.RootPath = "clientapp/build";
            });

            // ===== Add our DbContext ========
            string connection = Configuration.GetConnectionString("DBLocalConnection");
            string migrationAssemblyName = typeof(Startup).GetTypeInfo().Assembly.GetName().Name;
            services.AddDbContext<ContestContext>(options =>
                    options.UseSqlServer(connection,
                    sql => sql.MigrationsAssembly(migrationAssemblyName)));

            // ===== Add Identity ========
            services.AddIdentity<User, IdentityRole>(o =>
            {
                o.User.RequireUniqueEmail = true;
                o.Tokens.EmailConfirmationTokenProvider = "EMAILCONF";
                // I want to be able to resend an `Email` confirmation email
                // o.SignIn.RequireConfirmedEmail = true; 
            }).AddRoles<IdentityRole>()
                .AddEntityFrameworkStores<ContestContext>()
                .AddTokenProvider<EmailConfirmationTokenProvider<User>>("EMAILCONF")
                .AddDefaultTokenProviders();

            services.Configure<DataProtectionTokenProviderOptions>(o =>
                o.TokenLifespan = TimeSpan.FromHours(3)
            );

            services.Configure<EmailConfirmationTokenProviderOptions>(o =>
                o.TokenLifespan = TimeSpan.FromDays(2)
            );

            // ===== Add Authentication ========

            services.AddAuthentication(o => o.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme)
                .AddCookie(options =>
                {
                    options.Cookie.Name = "auth_cookie";
                    options.Cookie.SameSite = SameSiteMode.None;
                    options.LoginPath = new PathString("/api/contests");
                    options.AccessDeniedPath = new PathString("/api/contests");
                    options.Events = new CookieAuthenticationEvents
                    {
                        OnRedirectToLogin = context =>
                        {
                            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
                            return Task.CompletedTask;
                        },
                    };
                });

            // ===== Add Authorization ========

            services.AddAuthorization(o =>
            {

            });

            services.AddCors();

            // ===== Add MVC ========
            services.AddMvc(config =>
            {
                var policy = new AuthorizationPolicyBuilder()
                                    .RequireAuthenticatedUser()
                                    .Build();
                config.Filters.Add(new AuthorizeFilter(policy));
            })
                .AddJsonOptions(options => options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore)
                .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

            // ===== Add Swagger ========
            services.AddSwaggerGen(c =>
            {
                c.SwaggerDoc("v1", new Info
                {
                    Title = "Core API",
                    Description = "Documentation",
                });

                var xmlPath = $"{System.AppDomain.CurrentDomain.BaseDirectory}Contest.xml";
                c.IncludeXmlComments(xmlPath);
            });

        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Error");
                app.UseHsts();
            }

            app.UseHttpsRedirection();
            app.UseSpaStaticFiles(new StaticFileOptions()
            {

            });

            app.UseCors(policy =>
            {
                policy.AllowAnyHeader();
                policy.AllowAnyMethod();
                policy.AllowAnyOrigin();
                policy.AllowCredentials();
            });

            app.UseAuthentication();

            app.UseMvc();

            if (env.IsDevelopment())
            {
                app.UseSwagger();
                app.UseSwaggerUI(c =>
                {
                    c.SwaggerEndpoint("/swagger/v1/swagger.json", "Core API");
                });
            }

            app.UseSpa(spa =>
            {
                spa.Options.SourcePath = "clientapp";

                if (env.IsDevelopment())
                {
                    // spa.UseReactDevelopmentServer(npmScript: "start");
                    spa.UseProxyToSpaDevelopmentServer("http://localhost:3000");
                }
            });

        }

    }
}

I managed to bypass this by creating a controller to handle this route:

[Route("/")]
[ApiController]
public class UnauthorizedController : ControllerBase
{
    public UnauthorizedController()
    {

    }

    [HttpGet("/Account/Login")]
    [AllowAnonymous]
    public IActionResult Login()
    {
        HttpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
        return new ObjectResult(new
        {
            StatusCode = StatusCodes.Status401Unauthorized,
            Message = "Unauthorized",
        });
    }

}

What is wrong with my setup?


Solution

  • The behaviour you experience is linked to the fact that you use ASP.NET Identity. When you call services.AddIdentity, behind the scenes a cookie-based authentication scheme is registered and set as the default challenge scheme, as you can see in the code here on GitHub.

    Even though you registered a cookie authentication scheme yourself and set it as the default scheme, the specific default schemes — like AuthenticateScheme, ChallengeScheme, SignInScheme, etc... — take precendence. DefaultScheme is used by the authentication system only when the specific one is not set.

    To answer your question, you could apply the configuration settings to the ASP.NET Identity cookie options by using the helper method services.ConfigureApplicationCookie, like so:

    // ===== Add Identity ========
    services.AddIdentity<User, IdentityRole>(o =>
    {
        o.User.RequireUniqueEmail = true;
        o.Tokens.EmailConfirmationTokenProvider = "EMAILCONF";
        // I want to be able to resend an `Email` confirmation email
        // o.SignIn.RequireConfirmedEmail = true; 
    }).AddRoles<IdentityRole>()
        .AddEntityFrameworkStores<ContestContext>()
        .AddTokenProvider<EmailConfirmationTokenProvider<User>>("EMAILCONF")
        .AddDefaultTokenProviders();
    
    services.Configure<DataProtectionTokenProviderOptions>(o =>
        o.TokenLifespan = TimeSpan.FromHours(3)
    );
    
    services.Configure<EmailConfirmationTokenProviderOptions>(o =>
        o.TokenLifespan = TimeSpan.FromDays(2)
    );
    
    // ===== Configure Identity =======
    service.ConfigureApplicationCookie(options =>
    {
        options.Cookie.Name = "auth_cookie";
        options.Cookie.SameSite = SameSiteMode.None;
        options.LoginPath = new PathString("/api/contests");
        options.AccessDeniedPath = new PathString("/api/contests");
    
        // Not creating a new object since ASP.NET Identity has created
        // one already and hooked to the OnValidatePrincipal event.
        // See https://github.com/aspnet/AspNetCore/blob/5a64688d8e192cacffda9440e8725c1ed41a30cf/src/Identity/src/Identity/IdentityServiceCollectionExtensions.cs#L56
        options.Events.OnRedirectToLogin = context =>
        {
            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
            return Task.CompletedTask;
        };
    });
    

    It also means that you can safely remove the part where you add a cookie-based authentication scheme since this is taken care of by ASP.NET Identity itself.