Search code examples
c#asp.net-coreasp.net-core-webapiwindows-authenticationntlm-authentication

Why does HttpContext.User.Identity.Name appear empty for async controller equivalent of working sync controller when using ASP.NET Core?


TLDR; I have near identical controllers that differ materially only by use of async/await (and of course Task). The non-async version returns the current user's username, as expected, but the async equivalent does not.

What I'd like to discover are

  1. The circumstances under which this might hold true
  2. That tools (besides Fiddler) available to me for investigating this

Note: I'm unable to debug against the ASP.NET Core source because permission hasn't been granted for running Set-ExecutionPolicy (which appears required for building the solution)

This works:

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

namespace Baffled.API.Controllers
{
    [Authorize]
    [ApiController]
    [Route("[controller]")]
    public class IdentityController : ControllerBase
    {
        private readonly string userName;

        public IdentityController(IHttpContextAccessor httpContextAccessor)
        {
            userName = httpContextAccessor?.HttpContext?.User.Identity?.Name;
        }

        [HttpGet]
        public IActionResultGetCurrentUser()
        {
            return Ok(new { userName });
        }
    }
}

This does not work:

using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Baffled.Services;

namespace Baffled.API.Controllers
{
    [Authorize]
    [ApiController]
    [Route("[controller]")]
    public class IssueReproductionController : ControllerBase
    {
        private readonly HeartbeatService heartbeatService;
        private readonly ILogger<IssueReproductionController> logger;
        private readonly string userName;

        public IssueReproductionController(
            HeartbeatService heartbeatService,
            ILogger<IssueReproductionController> logger,
            IHttpContextAccessor httpContextAccessor)
        {
            this.heartbeatService = heartbeatService;
            this.logger = logger;

            userName = httpContextAccessor?.HttpContext?.User?.Identity?.Name;
        }

        [HttpGet]
        public async Task<IActionResult> ShowProblem()
        {
            var heartbeats = await heartbeatService.GetLatest();

            logger.LogInformation("Issue reproduction", new { heartbeats });

            var currentUser = userName ?? HttpContext?.User.Identity?.Name;

            return Ok(new { currentUser, heartbeats.Data });
        }
    }
}

I think the configuration is correct but for completeness it's included below:

Program.cs

using System;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.HttpSys;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;

namespace Baffled.API
{
    public class Program
    {
        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureAppConfiguration((_, config) =>
                {
                    config.AddJsonFile("appsettings.json", true, true);
                    config.AddEnvironmentVariables();
                })
                .UseWindowsService()
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.UseUrls("http://*:5002");
                    webBuilder.UseStartup<Startup>().UseHttpSys(Options);
                });

        private static void Options(HttpSysOptions options)
        {
            options.Authentication.AllowAnonymous = true;
            options.Authentication.Schemes =
                AuthenticationSchemes.NTLM | 
                AuthenticationSchemes.Negotiate | 
                AuthenticationSchemes.None;
        }
    }
}

Startup.cs

using System;
using System.Net.Http;
using Baffled.API.Configuration;
using Baffled.API.Hubs;
using Baffled.API.Services;
using Baffled.EF;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.HttpSys;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Serialization;
using Serilog;
using Serilog.Events;

namespace Baffled.API
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddScoped<HeartbeatService, HeartbeatService>();

            services.AddHttpContextAccessor();
            services.AddAuthentication(HttpSysDefaults.AuthenticationScheme).AddNegotiate();
            services.AddHttpClient("Default").ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { UseDefaultCredentials = true });
            services.AddControllers();

            var corsOrigins = Configuration.GetSection("CorsAllowedOrigins").Get<string[]>();

            services.AddCors(_ => _.AddPolicy("AllOriginPolicy", builder =>
            {
                builder.WithOrigins(corsOrigins)
                    .AllowAnyMethod()
                    .AllowAnyHeader()
                    .AllowCredentials();
            }));

            services.AddSwagger();
            services.AddSignalR();
            services.AddHostedService<Worker>();
            services.AddResponseCompression();

            // Logging correctly configured here
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            app.UseRouting();
            app.UseSwagger();
            app.UseAuthentication();
            app.UseAuthorization();
            app.UseResponseCompression();
            app.UseCors("AllOriginPolicy");

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
                endpoints.MapHub<UpdateHub>("/updatehub");
            });
        }
    }
}

I've been sitting on this for weeks hoping a solution might occur. None has. Please help.

Edit

I have this hosted via a windows service. The odd behaviour seems to be inconsistent and non-deterministic. On occasion I see my username being returned but my colleagues never do when hitting the problematic endpoint.


Solution

  • You shouldn’t attempt to access HttpContext-specific data within your controller constructor. The framework makes no guarantees when the constructor gets constructed, so it is very possible that the constructor is created here before the IHttpContextAccessor has access to the current HttpContext. Instead, you should access context-specific data only within your controller action itself since that is guaranteed to run as part of the request.

    When using the IHttpContextAcccessor, you should always access its HttpContext only in that exact moment, when you need to look at the context. Otherwise, it is very likely that you are working with an outdated state which can cause all kinds of problems.

    However, when using a controller, you don’t actually need to use IHttpContextAccessor at all. Instead, the framework will provide multiple properties for you so that you can access the HttpContext or even the user principal directly on the controller (with Razor pages and in Razor views, there are similar properties available to use):

    Note though that these properties are only available within a controller action, and not the constructor. For more details on that, see this related post.


    Coming to your example, your controller action should work just like this:

    [HttpGet]
    public async Task<IActionResult> ShowProblem()
    {
        var heartbeats = await heartbeatService.GetLatest();
        logger.LogInformation("Issue reproduction", new { heartbeats });
    
        var currentUser = User.Identity.Name;
    
        return Ok(new { currentUser, heartbeats.Data });
    }