Search code examples
c#asp.net-mvcasp.net-core-mvcasp.net-identity

ASP.Net Core MVC Identity - Add temporary (session) claim


For starters, this question was also asked here (Temporary Session Based Claims in ASP.NET Core Identity) and here (How to add claim to user dynamically?), but neither question received an answer so I am trying to revive it...

I am creating a multi-tenant web app. A user logs in, and they can see data related to their own company (but not the data of other companies who use the app). Because of franchise groups with multiple storefronts, etc, it is not uncommon for a single user to require access to several different companies, but in this case, they must choose a single company when logging in.

Nearly all data queries require a company ID as a parameter, so I need a convenient way of checking which company the user is currently logged into. If they log out and log into a different company, I need to see a different company ID. I would like to store it as an identity claim that I can see for the duration of the session, but I don't necessarily want to store it to the database since it may change with every login.

Here is my Login action (based on the standard ASP.Net Core MVC template):

[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(LoginViewModel model, string returnUrl = null)
{
    ViewData["ReturnUrl"] = returnUrl;
    if (ModelState.IsValid)
    {
        var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: false);
        if (result.Succeeded)
        {
            // BEGIN CUSTOM CODE - Check which companies a user can access
            IEnumerable<int> allCompanies = _myDbService.GetUserCompanies(model.Email);

            if (allCompanies.Count() == 1)
            {
                // then they can only access one company - log them into it automatically.
                // I need easy access to its ID with the User since it is used with almost every db query.

                int companyID = allCompanies[0];
                ((ClaimsIdentity)User.Identity).AddClaim(new Claim("CompanyID", companyID.ToString())); // this shows as null on future controller actions.

                Debug.WriteLine("Set breakpoint here to examine User"); // I see 1 claim (my custom claim) in the debugger, but it is not in the database yet.

                // future controller actions show me 3 claims (nameidentifier, name, and security stamp) but User.Claims.First(c => c.Type == "CompanyID").Value throws error.

                return RedirectToLocal(returnUrl);
            }
            else
            {
                // the user has access to several companies - make them choose one to complete login process
                RedirectToAction("ChooseCompany", new { companyList = allCompanies });
            }
            // END CUSTOM CODE
        }

        // REMAINING CODE OMITTED FOR BREVITY
    }

    // If we got this far, something failed, redisplay form
    return View(model);
}

So my questions are: why doesn't the custom claim "stick" so that it can be seen in future controller actions? Why isn't it saved to the database if that is the standard behavior? Is there a way to keep this value around for the duration of the session without using AddSession()? If I implement this as a claim, am I making a round trip to the database every time I access the value?


Solution

  • For anyone else with this issue, here's where I am so far... To permanently store the claim in the database and automatically load it at login, you must use the following and the change will not take effect until the next login:

    await userManager.AddClaimAsync(user, new Claim("your-claim", "your-value"));
    

    Using the following will only store the claim for the current session, so I was on the right track with that:

    ((ClaimsIdentity)User.Identity).AddClaim(new Claim("your-claim", "your-value"));
    

    The only way I have found to get the claim to "stick" between controller actions is to override UserClaimsPrincipalFactory so that ASP.Net is using your custom code as part of the login process. The problem here is that you only have access to the ApplicationUser in the relevant method, and you can't really pass in any custom parameters AFAIK. So I first modified by ApplicationUser class like this:

    public class ApplicationUser : IdentityUser
    {
        public long ActiveDealershipID { get; set; }
    } 
    

    (You must apply the EF migration). Now I create a class that derives from UserClaimsPrincipalFactory like this:

    public class MyClaimsPrincipalFactory : UserClaimsPrincipalFactory<ApplicationUser, IdentityRole>
    {
        public MyClaimsPrincipalFactory(UserManager<ApplicationUser> userManager, RoleManager<IdentityRole> roleManager, IOptions<IdentityOptions> options) : base(userManager, roleManager, options)
        {
        }
    
        public async override Task<ClaimsPrincipal> CreateAsync(ApplicationUser user)
        {
            var principal = await base.CreateAsync(user);
    
            long dealershipID = user.ActiveDealershipID;
            ((ClaimsIdentity)principal.Identity).AddClaim(new Claim("DealershipID", dealershipID.ToString()));
    
            return principal;
        }
    }
    

    (You must register the service in startup.cs ConfigureServices):

    services.AddScoped<IUserClaimsPrincipalFactory<ApplicationUser>, MyClaimsPrincipalFactory>();
    

    And then in my Login action, I set the new user property from the model data BEFORE the sign-in so that it will be available to the UserClaimsPrincipalFactory when it is setting the claims:

    public async Task<IActionResult> Login(LoginViewModel model, string returnUrl = null)
    {
        ViewData["ReturnUrl"] = returnUrl;
        if (ModelState.IsValid)
        {
            ApplicationUser user = await _userManager.FindByEmailAsync(model.Email);
            user.ActiveDealershipID = model.ChosenDealership
            await _userManager.UpdateAsync(user);
    
            var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: false);
            if (result.Succeeded)
            {
                _logger.LogInformation("User logged in.");
                ...
    

    I am still open to better suggestions or other ideas, but this seems to be working for me.