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.
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.