8

Using Windows Authentication in an Intranet web application I want to achieve the following:

  • Gather additional attributes from AD (name, employee number)
  • Gather additional attributes from a database table (working hours, pay)
  • Authorize based on application roles (not AD groups)
  • Authorize based on an AD attribute (has direct reports)
  • User not provide a username/password

In my search for an answer it is suggested that I need to add ClaimsTransformation to my application:

How do I use Windows Authentication with users in database

Populate custom claim from SQL with Windows Authenticated app in .Net Core

Caching Claims in .net core 2.0

Though I don't fully understand the solution and why ClaimsTransformation happens on every request so I'm looking for answers to the following:

  1. Is ASP.NET Core Identity required for ClaimsTransformation to work?
  2. Does ClaimsTransformation happen on every request with just Windows Authentication or also with form based authentication?
  3. Does this have to happen on every request?
  4. Caching claims like GivenName, Surname seem simple but what about roles? What steps need to be taken to ensure the database isn't hit every time but roles do get updated when there are changes.
  5. Is there a simpler alternative for what I'm trying to do?
mheptinstall
  • 2,109
  • 3
  • 24
  • 44
  • 1
    1. No, 2. Every request. 5. It seems like you could use windows auth for the initial login into Identity, do your db lookup, and issue an Identity cookie for use on subsequent requests. This world be more efficient than doing claims transformation on every request. – Tratcher Apr 10 '18 at 11:20
  • Could you point me in the right direction as to how your suggestion could be achieved? I'm assuming the user would have to login to create the cookie but not provide any credentials? – mheptinstall Apr 10 '18 at 11:27

2 Answers2

2

Here is an alternative which does use IClaimsTransformation (using .NET 6)

A few notes:

In the ClaimsTransformer class it's essential to clone the existing ClaimsPrincipal and add your Claims to that, rather than trying to modify the existing one. It must then be registered as a singleton in ConfigureServices().

The technique used in mheptinstall's answer to set the AccessDeniedPath won't work here, instead I had to use the UseStatusCodePages() method in order to redirect to a custom page for 403 errors.

The new claim must be created with type newIdentity.RoleClaimType, NOT System.Security.Claims.ClaimTypes.Role, otherwise the AuthorizeAttribute (e.g. [Authorize(Roles = "Admin")]) will not work

Obviously the application will be set up to use Windows Authentication.

ClaimsTransformer.cs

public class ClaimsTransformer : IClaimsTransformation
{
    // Can consume services from DI as needed, including scoped DbContexts
    public ClaimsTransformer(IHttpContextAccessor httpAccessor) { }

    public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
    {
        // Clone current identity
        var clone = principal.Clone();
        var newIdentity = (ClaimsIdentity)clone.Identity;

        // Get the username
        var username = principal.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier || c.Type == ClaimTypes.Name).Value;

        if (username == null)
        {
            return principal;
        }

        // Get the user roles from the database using the username we've just obtained
        // Ideally these would be cached where possible
        
        // ...

        // Add role claims to cloned identity
        foreach (var roleName in roleNamesFromDatabase)
        {
            var claim = new Claim(newIdentity.RoleClaimType, roleName);
            newIdentity.AddClaim(claim);
        }

        return clone;
    }
}

Startup.cs

public void ConfigureServices(IServiceCollection services)
{
    services.AddAuthentication(IISDefaults.AuthenticationScheme);
    services.AddAuthorization();
    services.AddSingleton<IClaimsTransformation, ClaimsTransformer>();

    services.AddMvc().AddRazorRuntimeCompilation();

    // ...
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.UseStatusCodePages(async context => {
        if (context.HttpContext.Response.StatusCode == 403)
        {
            context.HttpContext.Response.Redirect("/Home/AccessDenied");
        }
    });

    app.UseHttpsRedirection();
    app.UseStaticFiles();

    app.UseRouting();

    app.UseAuthentication();
    app.UseAuthorization();

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

Example HomeController.cs

[Authorize]
public class HomeController : Controller
{
    public HomeController()
    { }

    public IActionResult Index()
    {
        return View();
    }

    [Authorize(Roles = "Admin")]
    public IActionResult AdminOnly()
    {
        return View();
    }

    [AllowAnonymous]
    public IActionResult AccessDenied()
    {
        return View();
    }
}
John M
  • 2,510
  • 6
  • 23
  • 31
1

This article gave me some ideas and here is a possible solution.

Controllers would inherit from a base controller which has a policy that requires the Authenticated claim. When this isn't present it goes to the AccessDeniedPath and silently performs the login adding the Authenticated claim along with any other claims, if this is already present then the Access Denied message would appear.

When creating the new ClaimsIdentity I've had to strip most of the Claims in the original identity as I was getting a HTTP 400 - Bad Request (Request Header too long) error message.

Are there any obvious issues with this approach?

Startup.cs

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

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc();

        services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
            .AddCookie(options =>
            {
                options.LoginPath = "/Home/Login";
                options.AccessDeniedPath = "/Home/AccessDenied";
            });

        services.AddAuthorization(options =>
        {
            options.AddPolicy("Authenticated",
                policy => policy.RequireClaim("Authenticated"));
            options.AddPolicy("Admin",
                policy => policy.RequireClaim("Admin"));
        });
    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseBrowserLink();
            app.UseDeveloperExceptionPage();
        }
        else
        {
            app.UseExceptionHandler("/Home/Error");
        }

        app.UseStaticFiles();
        app.UseAuthentication();

        app.UseMvc(routes =>
        {
            routes.MapRoute(
                name: "default",
                template: "{controller=Home}/{action=Index}/{id?}");
        });
    }
}

Controller

[Authorize(Policy = "Authenticated")]
public class HomeController : Controller
{
    public IActionResult Index()
    {
        return View();
    }

    [Authorize(Policy = "Admin")]
    public IActionResult About()
    {
        return View();
    }

    [AllowAnonymous]
    public async Task<IActionResult> Login(string returnUrl)
    {
        var identity = ((ClaimsIdentity)HttpContext.User.Identity);

        var claims = new List<Claim>
        {
            new Claim("Authenticated", "True"),
            new Claim(ClaimTypes.Name,
                identity.FindFirst(c => c.Type == ClaimTypes.Name).Value),
            new Claim(ClaimTypes.PrimarySid,
                identity.FindFirst(c => c.Type == ClaimTypes.PrimarySid).Value)
        };

        var claimsIdentity = new ClaimsIdentity(
            claims,
            identity.AuthenticationType,
            identity.NameClaimType,
            identity.RoleClaimType);

        await HttpContext.SignInAsync(
            CookieAuthenticationDefaults.AuthenticationScheme,
            new ClaimsPrincipal(claimsIdentity),
            new AuthenticationProperties());

        return Redirect(returnUrl);
    }

    [AllowAnonymous]
    public IActionResult AccessDenied(string returnUrl)
    {
        if (User.FindFirst("Authenticated") == null)
            return RedirectToAction("Login", new { returnUrl });

        return View();
    }
}
spottedmahn
  • 14,823
  • 13
  • 108
  • 178
mheptinstall
  • 2,109
  • 3
  • 24
  • 44