Search code examples
c#asp.netvb.netwebformsasp.net-identity

Claims added in GenerateUserIdentity disappear after login


I have created a C# class library to implement ASP.NET Identity for several web applications that use the same user schema. I am adding custom claims to users' identities in the GenerateIdentity method in the User class:

private ClaimsIdentity GenerateIdentity(UserManager userManager)
{
    var identity = userManager.CreateIdentity(this, DefaultAuthenticationTypes.ApplicationCookie);

    identity.AddClaims(
        new[]
        {
            new Claim(nameof(UserId), UserId.ToString()),
            new Claim(ClaimTypes.Role, UserType.ToString()),
            new Claim(nameof(FullName), FullName),
            new Claim(nameof(FirmName), FirmName),
            new Claim(nameof(HasDebit), HasDebit.ToString()),
            new Claim(nameof(ShouldShowAssistants), ShouldShowAssistants.ToString()),
            new Claim(nameof(IsSuspended), IsSuspended.ToString()),
            new Claim(nameof(FirmId), FirmId.ToString()), 
        });

    if (IsSelfAdmin)
    {
        identity.AddClaim(new Claim(ClaimTypes.Role, SelfAdminRole));
    }

    return identity;
}

public Task<ClaimsIdentity> GenerateIdentityAsync(UserManager userManager)
{
    return Task.FromResult(GenerateIdentity(userManager));
}

The claims are indeed present on the identity object before the method returns during the login process. However, after the user logs in and the request is redirected, all of the custom claims are gone. Here's my login code in one of the web applications (an older VB.NET WebForms app):

Dim signInManager = Context.GetOwinContext().GetUserManager(Of SignInManager)
Dim result = signInManager.PasswordSignIn(UserID_TB.Text, PW_TB.Text, RememberMe_CB.Checked, False)

If result = SignInStatus.Failure
    SetMessage("Failed")
    Return
End If

Response.Redirect("profile.aspx")

The sign in is a success, but on the profile.aspx page, the User IPrincipal property only has the default 4 claims.

Potentially relevant code from the application's startup:

app.UseCookieAuthentication(New CookieAuthenticationOptions With {
    .AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
    .Provider = New CookieAuthenticationProvider With {
        .OnValidateIdentity = SecurityStampValidator.OnValidateIdentity (Of UserManager, User)(
            TimeSpan.FromSeconds(0),
            Function(manager, user) user.GenerateIdentityAsync(manager))
        }
    })

The validation interval is 0 as per a specification for this application.

Please let me know if any other code would be useful to see.

Update

I have found that everything works as intended after a second redirect. When login redirects to profile, the claims are missing, but if profile redirects again, the claims are there. I'm really confused as to why this is.


Solution

  • Update: The Actual Solution

    The issue actually arose from me calling UserManager.CreateIdentity instead of User.GenerateIdentity. My SecurityStamp.OnValidateIdentity used the correct one, but not my sign in code. This should have been very obvious since User.GenerateIdentity contains a call to UserManager.CreateIdentity before adding all the custom claims, but it flew over my head for some time.

    My sign in code is now this:

    Dim userManager = Context.GetOwinContext().GetUserManager(Of UserManager)
    Dim authenticatingUser = userManager.Find(UserID_TB.Text, PW_TB.Text)
    
    If authenticatingUser Is Nothing
        SetMessage("Failed")
        Return
    End If
    
    userManager.UpdateSecurityStamp(authenticatingUser.Id)
    Dim userIdentity = authenticatingUser.GenerateIdentityAsync(userManager).Result
    Dim authenticationManager = HttpContext.Current.GetOwinContext().Authentication
    
    authenticationManager.SignIn(
        New AuthenticationProperties With {
            .IsPersistent = RememberMe_CB.Checked
            },
        userIdentity)
    
    Response.Redirect("profile.aspx", False)
    

    I also wrote this to handle refreshing user claims:

    Public Shared Sub RefreshSignIn()
        Dim authenticationManager = HttpContext.Current.GetOwinContext().Authentication
        authenticationManager.SignOut(DefaultAuthenticationTypes.ApplicationCookie)
        Dim userManager = HttpContext.Current.GetOwinContext().GetUserManager(Of UserManager)
        Dim user = userManager.FindById(HttpContext.Current.User.Identity.GetUserId())
        Dim userIdentity = user.GenerateIdentityAsync(userManager).Result
        authenticationManager.SignIn(userIdentity)
    End Sub
    

    Original

    I solved the problem by adding an additional redirect after the login. In the case of the example application I provided, we always redirect to profile, so I changed profile's Page_Load event handler to this:

    Protected Sub Page_Load(sender As Object, e As EventArgs) Handles Me.Load
        If Not User.IsAuthenticated()
            ' The user actually isn't authenticated; send them to login
            Response.Redirect("login.aspx")
        End If
    
        If User.GetSomeClaimThatShouldBeHere() Is Nothing
            ' The second redirect that somehow makes this work
            Response.Redirect("profile.aspx")
        End If
    
        ' Do the rest of the page load that depends on the user claims
    End Sub
    

    If we didn't already use a catch-all page after the login, we could make a dummy page that handles the redirect logic like this:

    Public Class LoginRedirect
        Inherits Page
    
        Protected Sub Page_Load(sender As Object, e As EventArgs) Handles Me.Load
            If Not User.IsAuthenticated()
                ' The user actually isn't authenticated; send them to login
                Response.Redirect("login.aspx")
            End If
    
            If User.GetSomeClaimThatShouldBeHere() Is Nothing
                ' The second redirect that somehow makes this work
                Response.Redirect("loginredirect.aspx")
            End If
    
            If Not String.IsNullOrWhiteSpace(Request.QueryString("returnUrl"))
                ' Redirect to a return URL you've been passing around
                Response.Redirect(Request.QueryString("returnUrl"))
            End If
    
            ' Do some other logic to determine where the user (with all their claims available) needs to go
        End Sub
    
    End Class
    

    I'm not going to mark this at the accepted answer for a while in case someone comes up with a better solution than to do a seemingly superfluous redirect.