6
votes

I have a strange situation that I can't duplicate consistently. I have a MVC website developed in .NET Core 3.0 and authorizes users with .NET Core Identity. When I run the site in a development environment locally everything works just fine (the classic "works on my machine!"). When I deploy it to my staging web server is when I begin to see the issue. Users can log in successfully, be authenticated, and redirected to the home page. Note: all controllers, except the one handling authentication, are decorated with the [Authorize] attribute and the [AutoValidateAntiforgeryToken] attribute. The home page loads just fine. However, there are a couple of ajax calls that run when the page is loaded that callback to the Home controller to load some conditional data and check to see if some Session level variables have been set yet. These ajax calls return a 401 Unauthorized. The problem is I can't get this behavior to repeat consistently. I actually had another user log in concurrently (same application, same server) and it worked just fine for them. I opened the developer console in Chrome and traced what I think is the problem down to one common (or uncommon) factor. The calls (such as loading the home page, or the ajax calls that were successful for the other user) that work have the ".AspNetCore.Antiforgery", ".AspNetCore.Identity.Application", and the ".AspNetCore.Session" cookies set in the request headers. The calls that do not work (my ajax calls) only have the ".AspNetCore.Session" cookie set. Another thing to note is that this behavior happens for every ajax call on the site. All calls made to the controller actions by navigation or form posting work fine.

DOESN'T WORK: enter image description here

WORKS: enter image description here

What is strange to me is that another user can log in, and even i can log in occasionally after a new publish, and have those ajax calls working just fine with cookies set properly.

Here is some of the code to be a little more specific. Not sure if it's something I have set up wrong with Identity or Session configuration.

Startup.cs

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

    public IConfiguration Configuration { get; }
    public IWebHostEnvironment Env { get; set; }

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

        services.AddIdentity<User, UserRole>(options =>
        {
            options.User.RequireUniqueEmail = true;
        }).AddEntityFrameworkStores<QCAuthorizationContext>()
            .AddDefaultTokenProviders(); ;

        services.AddDbContext<QCAuthorizationContext>(cfg =>
        {
            cfg.UseSqlServer(Configuration.GetConnectionString("Authorization"));
        });

        services.AddSingleton<IConfiguration>(Configuration);
        services.AddControllersWithViews();
        services.AddDistributedMemoryCache();

        services.AddSession(options =>
        {
            // Set a short timeout for easy testing.
            options.IdleTimeout = TimeSpan.FromHours(4);
            options.Cookie.HttpOnly = true;
            // Make the session cookie essential
            options.Cookie.IsEssential = true;
        });

        services.Configure<IdentityOptions>(options =>
        {
            options.Password.RequireDigit = true;
            options.Password.RequireLowercase = true;
            options.Password.RequireNonAlphanumeric = true;
            options.Password.RequireUppercase = true;
            options.Password.RequiredLength = 6;
            options.Password.RequiredUniqueChars = 1;

            // Lockout settings
            options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(30);
            options.Lockout.MaxFailedAccessAttempts = 10;
            options.Lockout.AllowedForNewUsers = true;
        });


        services.ConfigureApplicationCookie(options =>
        {
            //cookie settings
            options.ExpireTimeSpan = TimeSpan.FromHours(4);
            options.SlidingExpiration = true;
            options.LoginPath = new Microsoft.AspNetCore.Http.PathString("/Account/Login");
        });
        services.AddHttpContextAccessor();
        //services.TryAddSingleton<IActionContextAccessor, ActionContextAccessor>();
        IMvcBuilder builder = services.AddRazorPages();
    }

    // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
    public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IServiceProvider serviceProvider)
    {

        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        else
        {
            app.UseExceptionHandler("/Error");
            app.UseHsts();
        }

        app.UseStaticFiles();
        app.UseCookiePolicy();
        app.UseRouting();
        app.UseAuthentication();
        app.UseAuthorization();
        app.UseSession();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllerRoute(
                name: "default",
                pattern: "{controller=Home}/{action=Index}/{id?}");
            endpoints.MapControllerRoute(
                name: "auth4",
                pattern: "{controller=Account}/{action=Authenticate}/{id?}");
        });
    }
}

Login Controller Action

[HttpPost]
    public async Task<IActionResult> Login(LoginViewModel iViewModel)
    {
        ViewBag.Message = "";
        try
        {
            var result = await signInManager.PasswordSignInAsync(iViewModel.Email, iViewModel.Password, false, false);

            if (result.Succeeded)
            {
                var user = await userManager.FindByNameAsync(iViewModel.Email);
                if (!user.FirstTimeSetupComplete)
                {
                    return RedirectToAction("FirstLogin");
                }
                return RedirectToAction("Index", "Home");
            }
            else
            {
                ViewBag.Message = "Login Failed.";
            }
        }
        catch (Exception ex)
        {
            ViewBag.Message = "Login Failed.";
        }
        return View(new LoginViewModel() { Email = iViewModel.Email });
    }

Home Controller

public class HomeController : BaseController
{
    private readonly ILogger<HomeController> _logger;

    public HomeController(IConfiguration configuration, ILogger<HomeController> logger, UserManager<User> iUserManager) : base(configuration, iUserManager)
    {
        _logger = logger;
    }

    public async Task<IActionResult> Index()
    {
        HomeViewModel vm = HomeService.GetHomeViewModel();

        vm.CurrentProject = HttpContext.Session.GetString("CurrentProject");
        vm.CurrentInstallation = HttpContext.Session.GetString("CurrentInstallation");

        if (!string.IsNullOrEmpty(vm.CurrentProject) && !string.IsNullOrEmpty(vm.CurrentInstallation))
        {
            vm.ProjectAndInstallationSet = true;
        }

        return View(vm);
    }

    public IActionResult CheckSessionVariablesSet()
    {
        var currentProject = HttpContext.Session.GetString("CurrentProject");
        var currentInstallation = HttpContext.Session.GetString("CurrentInstallation");
        return Json(!string.IsNullOrEmpty(currentProject) && !string.IsNullOrEmpty(currentInstallation));
    }

    public IActionResult CheckSidebar()
    {
        try
        {
            var sidebarHidden = bool.Parse(HttpContext.Session.GetString("SidebarHidden"));
            return Json(new { Success = sidebarHidden });
        }
        catch (Exception ex)
        {
            return Json(new { Success = false });
        }
    }
}

Base Controller

[AutoValidateAntiforgeryToken]
[Authorize]
public class BaseController : Controller
{
    protected IConfiguration configurationManager;
    protected SQLDBContext context;
    protected UserManager<User> userManager;


    public BaseController(IConfiguration configuration, UserManager<User> iUserManager)
    {
        userManager = iUserManager;
        configurationManager = configuration;
    }


    public BaseController(IConfiguration configuration)
    {
        configurationManager = configuration;
    }

    protected void EnsureDBConnection(string iProject)
    {


        switch (iProject)
        {
            case "A":
                DbContextOptionsBuilder<SQLDBContext> AOptionsBuilder = new DbContextOptionsBuilder<SQLDBContext>();
                AOptionsBuilder.UseLazyLoadingProxies().UseSqlServer(configurationManager.GetConnectionString("A"));
                context = new SQLDBContext(AOptionsBuilder.Options);
                break;
            case "B":
                DbContextOptionsBuilder<SQLDBContext> BOptionsBuilder = new DbContextOptionsBuilder<SQLDBContext>();
                BOptionsBuilder.UseLazyLoadingProxies().UseSqlServer(configurationManager.GetConnectionString("B"));
                context = new SQLDBContext(BOptionsBuilder.Options);
                break;
            case "C":
                DbContextOptionsBuilder<SQLDBContext> COptionsBuilder = new DbContextOptionsBuilder<SQLDBContext>();
                COptionsBuilder.UseLazyLoadingProxies().UseSqlServer(configurationManager.GetConnectionString("C"));
                context = new SQLDBContext(COptionsBuilder.Options);
                break;
        }
    }
}

_Layout.cshtml Javascript (runs aforementioned ajax calls when the pages are loaded)

<script type="text/javascript">
    var afvToken;

    $(function () {


        afvToken = $("input[name='__RequestVerificationToken']").val();

        $.ajax({
            url: VirtualDirectory + '/Home/CheckSidebar',
            headers:
            {
                "RequestVerificationToken": afvToken
            },
            complete: function (data) {
                console.log(data);
                if (data.responseJSON.success) {
                    toggleSidebar();
                }
            }
        });

        $.ajax({
            url: VirtualDirectory + '/Home/CheckSessionVariablesSet',
            headers:
            {
                "RequestVerificationToken": afvToken
            },
            complete: function (data) {
                console.log(data);
                if (data.responseJSON) {
                    $('#sideBarContent').attr('style', '');
                }
                else {
                    $('#sideBarContent').attr('style', 'display:none;');
                }
            }
        });

        $.ajax({
            url: VirtualDirectory + '/Account/UserRoles',
            headers:
            {
                "RequestVerificationToken": afvToken
            },
            complete: function (data) {
                if (data.responseJSON) {
                    var levels = data.responseJSON;
                    if (levels.includes('Admin')) {
                        $('.adminSection').attr('style', '');
                    }
                    else {
                        $('.adminSection').attr('style', 'display:none;');
                    }
                }
            }
        });
    });
</script>

EDIT:

What I've found is the "Cookie" header with ".AspNetCore.Antiforgery", ".AspNetCore.Identity.Application", and the ".AspNetCore.Session" attributes is always set correctly in the ajax requests when running locally. When deployed it only sets the cookie with the session attribute. I found a setting I have in my Startup.cs that sets the cookie to HttpOnly: options.Cookie.HttpOnly = true; Could this be causing my issue? Would setting it to false work? If that is unsafe, what are some work-arounds/alternate methods to my approach. I still need to implement the basic principal of user authentication AND be able to trigger ajax requests.

ANOTHER EDIT:

Today after I deployed the site again, I ran the site concurrently in Firefox and Chrome. Firefox sent the correct cookie after authenticating and is running fine. However, Chrome is still displaying the 401 behavior.

3
Hard to say. But since Firefox behaves well and Chrome doesn't I do wonder if it has something todo with the samesite changes in Chrome ? They stopped the roll-out (because corona), but some versions already implement it. Could you try to specify options.Cookie.SameSite = SameSiteMode.None; in your configureservices, (where you already set options.Cookie.HttpOnly = true;)AardVark71
Ran into something very similar a few weeks ago after the (then) latest Chrome update. As you (and others) suggest, this is all about differences in cookie handling on the client.Jason Weber
When you deploy you have the same URL ? Try always to run your tests on Incognito browser mode, I suspect the browser send the dev cookie to the prod server.Max

3 Answers

2
votes

It seems to me that your problem could be because of different behavior of cookies in http vs https scenarios!

Secured cookies that set in https mode can't be retrieved when posted back to http.

See this for more info.

I also saw this part in your Startup which increases the chance of my guess:

if (env.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

In your development environment everything works well on http. But in deployment environment https comes in and if some requests go to http and some go to https, some cookies don't return and you can face this issue.

0
votes

As you found out, this is ajax call difference in various browsers. Server side programming works fine and can't have casual response unless it faces different requests which come from a browser (here google chome). I believe that using an assertion in ajax call should solve the problem like employing withcredentials : true. Let me know if problem persists or not.

0
votes

This looks like a session management issue,use of services.AddDistributedMemoryCache() sometimes brings about session problems especially in a shared hosting environment. Could you try caching to a db.

e.g.

services.AddDistributedSqlServerCache(options =>
        {
            options.ConnectionString = connectionString;
            options.SchemaName = "dbo";
            options.TableName = "DistributedCache"; 
        });

Make sure you handle GDPR issues, which affect session cookie from .Net core > 2.0. These came about to help developers conform to GDPR regulations.

e.g. In your app, as one of the available options, you could make the session cookie essential, to enable it to be written to even before the user accepts cookie terms i.e.

services.AddSession(options => 
{
    options.Cookie.IsEssential = true; // make the session cookie Essential
});