Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Partial login workflow using AspNetCore 2.0 Identity integrated with IdentityServer4

With IdentityServer3 and AspNet Identity we were able to take advantage of PostAuthenticateLocalAsync and return an AuthenticateResult which allowed us to support different login workflows such as two-factor, EULA acceptance, and forcing a user to change their local password. So a "real" authenticated cookie was not issued until these workflows were complete. I think this process was called a partial login process.

Moving on to IdentityServer4 and AspNet Core 2.0 Identity we now expect the AspNet Identity components to manage the login workflow completely, thus removing IS4 of responsibility for supporting a partial login process.

I looked at the code for AspNet Identity's SignInManager to see how 2fa sign in works as I'd expect that to behave in a partial login way, i.e. not issue a proper cookie until the 2fa handshake is complete. It appears to do so by storing 2fa info temporarily using IdentityConstants.TwoFactorUserIdScheme.

The local and external sign in processes both call SignInOrTwoFactorAsync so I think I could override this method in a custom SignInManager and execute behaviour similar to IdentityServer3's original partial login (and then work out a way of continuing the login once the partial flows are complete). In other words, I'd prevent SignInAsync from producing a real cookie until partial flows are done.

Has any one attempted this with the current AspNetCore 2.0 Identity? If so, are there any open source examples?

If this is the correct or preferred way of achieving the partial login flow I'd expect the name of the method I'm talking about overriding to be less coupled to just 2fa.

like image 250
Mark Avatar asked Oct 11 '17 05:10

Mark


1 Answers

I did solve this myself so to help others I'll post this answer. I'll use the example of forcing a user to change their local password after they login.

Create a class that derives from ApplicationSigninManager and override SignInOrTwoFactorAsync. This is where any constraint logic should run to check if a login flow other than local or external logins should occur - our partial logins.

So for a password change check I might do the following:

protected override async Task<SignInResult> SignInOrTwoFactorAsync(ApplicationUser user, bool isPersistent, string loginProvider = null, bool bypassTwoFactor = false)
{
   var userId = await UserManager.GetUserIdAsync(user);
   var hasPassword = await UserManager.HasPasswordAsync(user);
   if (hasPassword && user.PasswordChangeRequired)
   {
      // Store the userId for use after password change
      await Context.SignInAsync(PasswordChangeScheme, StorePasswordChangeInfo(userId));

      return ApplicationSignInResult.RequiresPasswordChange;
   }

   return await base.SignInOrTwoFactorAsync(user, isPersistent, loginProvider, bypassTwoFactor);
}

The ApplicationSignInResult is just a custom derived SignInResult that allows tracking extended signin state; password change required, terms acceptance required, etc. When a login is posted by the user, the login controller action/razor page would check for this state to determine if a redirect to a custom page is required. LoginWithChangePassword will be our custom page in our example case.

The StorePasswordChangeInfo method works similarly to the SigninManager's internal StoreTwoFactorInfo method in that it just obtains the required scheme's identity, adds whatever temporary info is required to support the partial flow (a user Id in our example), and returns a new ClaimsPrincipal from the extended identity.

internal ClaimsPrincipal StorePasswordChangeInfo(string userId)
{
   var identity = new ClaimsIdentity(PasswordChangeScheme);
   identity.AddClaim(new Claim(ClaimTypes.Name, userId));
   return new ClaimsPrincipal(identity);
}

Notice that we are signing in to a custom authentication scheme called PasswordChangeScheme. We also need a cookie specific to this scheme so that we can maintain state specific to the partial flow. This is setup like any other cookie authentication during the app's service configuration:

// Authentication schemes and cookies
services.AddAuthentication()
  // Support partial sign in workflow to force user to change their password
  .AddCookie(PasswordChangeScheme, options =>
  {
    // We don't want these cookies to last for a long time.
    options.ExpireTimeSpan = TimeSpan.FromMinutes(5);
    options.Cookie.Name = PasswordChangeScheme;
    options.Cookie.HttpOnly = true;
    options.Cookie.IsEssential = true;
  });

So far we are able to redirect the user to a custom page when we want the password change to occur, and the signin will create a cookie representing our partial login.

Our custom change password page now needs to process the user form data and persist the change to the user profile.

Set the page (assuming Razor pages here but the same applies to controllers) [AllowAnonymous] attribute to override any other authentication policies that may be set. Most of the "Account" pages are anonymous by default anyway.

The page's Get and Post handlers must authenticate the user using our custom scheme before doing anything with form data. In a similar fashion to how 2fa does it, we provide some more functions in our custom SignInManager:

private async Task<PasswordChangeAuthenticationInfo> RetrievePasswordChangeInfoAsync()
{
   var result = await Context.AuthenticateAsync(PasswordChangeScheme);
   if (result?.Principal != null)
   {
      return new PasswordChangeAuthenticationInfo
      {
         UserId = result.Principal.FindFirstValue(ClaimTypes.Name),
      };
   }
   return null;
}

public virtual async Task<ApplicationUser> GetPasswordChangeAuthenticationUserAsync()
{
   var info = await RetrievePasswordChangeInfoAsync();
   if (info == null)
   {
      return null;
   }

   return await UserManager.FindByIdAsync(info.UserId);
}

...and a custom type just to hold the user id - a bit verbose but this is just replicating existing identity code.

internal class PasswordChangeAuthenticationInfo
{
   public string UserId { get; set; }
}

LoginWithChangePassword calls GetPasswordChangeAuthenticationUserAsync to obtain a user authenticated using the custom scheme. If the return value is null then authentication failed and the user should be redirected somewhere else. If we have an authenticated user (check the User.Identity.AuthenticationType to be sure it came from our scheme) accept the user's change. Once successfully changed the data, you call SignOutAysnc on the SignInManager, for example:

if (HttpContext.User.Identity.AuthenticationType == PasswordChangeScheme)
{
   await _signInManager.SignOutAsync();
}

This forces the user back to the login page to try out their new password.

Hope it helps.

like image 70
Mark Avatar answered Dec 07 '22 18:12

Mark