Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

In ASP.NET Identity how can I safely cache the user's password after login?

I have an intranet application where all user operations are conducted by API calls to a remote system (no local tables). A couple of the API calls require the user's password. I can't really ask users to keep reentering their password as they use the site (sometimes seconds after they've just logged in).

So without saving their password to a database, where can I safely cache the password for the duration of the user's login (note: "login", not "session"). I tried storing them in the Session state, but the problem is the session only lasts 20 minutes but the login token is valid for 24 hours.

Ideally I want it linked (somehow) directly to the .AspNet.ApplicationCookie so the login and the cached password cannot get out of sync, but it doesn't see like it's possible to add custom values to that cookie. It can be encrypted if this cookie isn't already encrypted.

EDIT: Due to the "remember me" function, logins can last much longer than the Session.TimeOut value, so I don't want to use the Session for this.

like image 560
NickG Avatar asked Apr 12 '18 09:04

NickG


Video Answer


2 Answers

I had a project where I had to implement exactly the same and ended up with a custom implementation of the ASP.NET Identity interfaces. (In my case the usernames and passwords were managed by an external system with an API.)
I'll explain the idea and main parts of the code.

The required userinfo (eg. username and password) gets stored in memory in a ConcurrentDictionary within a custom IUserStore, by definition the place by which userinfo gets retrieved.
Note; I am going to skip security best practices.

The only place to have access to the password of a user is via the PasswordSignInAsync method of a custom SignInManager.
Here things get different!
In the default/regular flow, the SignInManager uses the IUserStore to retrieve userinfo in order to do the password check. But because the IUserStore's role changed into a passive memory store that isn't possible anymore; this initial lookup must be done via eg. a database lookup.
Then the SignInManager does the password check.
If valid, the userinfo gets added or updated into the custom IUserStore (via a custom method on the CustomUserStore.)
It is important also to do an update every time the user signs in, otherwise the password stays stale, as it is being kept in memory for the duration of the application.

In case the web application gets recycled and the userinfo in the Dictionary gets lost, the ASP.NET identity framework takes care of this by redirecting the user again to the login page, by which the above flow starts again.

Next requirement is a custom UserManager, as my IUserStore does not implement all interfaces required by ASP.NET Identity; see the comments in the code. This may be different for your case.

With all this in place you retrieve a CustomUser via the UserManager; with the user object holding the password:

CustomUser user = this._userManager.FindById(userName); 

Here below are some extracts of the implementation.

The data that gets stored in memory:

public class UserInfo
{
    String Password { get; set; }

    String Id { get; set; }

    String UserName { get; set; }
}

The custom IUser:

public class CustomUser : IUser<String>
{
    public String Id { get; }

    public String Password { get; set; }

    public String UserName { get; set; }
}

The custom IUserStore with a method to write to it:

public interface ICustomUserStore : IUserStore<CustomUser>
{
    void CreateOrUpdate(UserInfo user);
}

The custom UserStore:

public class CustomUserStore : ICustomUserStore
{   
    private readonly ConcurrentDictionary<String, CustomUser> _users = new ConcurrentDictionary<String, CustomUser>(StringComparer.OrdinalIgnoreCase);

    public Task<CustomUser> FindByIdAsync(String userId)
    {
        // UserId and userName are being treated as the same.
        return this.FindByNameAsync(userId);            
    }            

    public Task<CustomUser> FindByNameAsync(String userName)
    {
        if (!this._users.ContainsKey(userName))
        {
            return Task.FromResult(null as CustomUser);
        }

        CustomUser user;
        if (!this._users.TryGetValue(userName, out user))
        {
            return Task.FromResult(null as CustomUser);
        }

        return Task.FromResult(user);
    }        


    public void CreateOrUpdate(UserInfo userInfo)
    {
        if (userInfo != null)    
        {
            this._users.AddOrUpdate(userInfo.UserName,  
                // Add.
                key => new CustomUser { Id = userInfo.Id,  UserName = userInfo.UserName, Password = userInfo.Password) }
                // Update; prevent stale password.
                (key, value) => {
                    value.Password = userInfo.Password;
                    return value
                });
        }
    }
}

The custom UserManager:

public class CustomUserManager : UserManager<CustomUser>
{
    public CustomUserManager(ICustomUserStore userStore)
        : base(userStore)
    {}

    /// Must be overridden because ICustomUserStore does not implement IUserPasswordStore<CustomUser>. 
    public override Task<Boolean> CheckPasswordAsync(CustomUser user, String password)
    {            
        return Task.FromResult(true);
    }

    /// Must be overridden because ICustomUserStore does not implement IUserTwoFactorStore<CustomUser>.         
    public override Task<Boolean> GetTwoFactorEnabledAsync(String userId)
    {
        return Task.FromResult(false);
    }

    /// Must be overridden because ICustomUserStore does not implement IUserLockoutStore<CustomUser>.              
    public override Task<Boolean> IsLockedOutAsync(String userId)
    {
        return Task.FromResult(false);
    }                

    /// Must be overridden because ICustomUserStore does not implement IUserLockoutStore<CustomUser>.         
    public override Task<IdentityResult> ResetAccessFailedCountAsync(String userId)
    {
        Task.FromResult(IdentityResult.Success);
    }
}

The custom SignInManager:

public class CustomSignInManager : SignInManager<CustomUser, String>
{   
    private readonly ICustomUserStore _userStore;

    public CustomSignInManager(
        CustomUserManager userManager, 
        IAuthenticationManager authenticationManager
        ICustomUserStore userStore
        ) 
        : base(userManager, authenticationManager)
    {            
        this._userStore = userStore;
    }


    /// Provided by the  ASP.NET MVC template.        
    public override Task<ClaimsIdentity> CreateUserIdentityAsync(CustomUser user)
    {
        return user.GenerateUserIdentityAsync(this.UserManager);            
    }


    public override Task<SignInStatus> PasswordSignInAsync(String userName, String password, Boolean isPersistent, Boolean shouldLockout)
    {
        UserInfo userInfo = // Call method the retrieve user info from eg. the database.
        if (null == userInfo)
        {
            return Task.FromResult(SignInStatus.Failure);
        }

        // Do password check; if not OK:
        // return Task.FromResult(SignInStatus.Failure);

        // Password is OK; set data to the store.            
        this._userStore.CreateOrUpdate(userInfo);

        // Execute the default flow, which will now use the IUserStore with the user present.
        return base.PasswordSignInAsync(userName, password, isPersistent, shouldLockout);
    }
}
like image 178
pfx Avatar answered Oct 21 '22 05:10

pfx


Disclaimer: Here you are putting a password into a cookie. An encrypted cookie, yet a password. It is not the best practice from security point of view. So make a decision yourself if this is acceptable for your system or not.

I think the best way for this would be to store the password as a claim on the authentication cookie. Auth cookie is encrypted when transmitted but you don't have to deal with the encryption yourself - this is done by OWIN for you. And this requires a lot less plumbing.

First rewrite your login action as follows:

    [HttpPost]
    [AllowAnonymous]
    [ValidateAntiForgeryToken]
    public async Task<ActionResult> Login(LoginViewModel model, string returnUrl)
    {
        if (!ModelState.IsValid)
        {
            return View(model);
        }

        var user = await UserManager.FindAsync(model.Email, model.Password);

        if (user == null)
        {
            // user with this username/password not found
            ModelState.AddModelError("", "Invalid login attempt.");
            return View(model);
        }

        // BEWARE this does not check if user is disabled, locked or does not have a confirmed user
        // I'll leave this for you to implement if needed.

        var userIdentity = await UserManager.CreateIdentityAsync(user, DefaultAuthenticationTypes.ApplicationCookie);
        userIdentity.AddClaim(new Claim("MyApplication:Password", model.Password));

        AuthenticationManager.SignIn(new AuthenticationProperties() { IsPersistent = true }, userIdentity);

        return RedirectToLocal(returnUrl);
    }

This takes password on login and adds it as a claim on the Identity that in turn gets serialised and encrypted into a cookie.

Note that a lot of logic has been omitted here - if you need to check if user is disabled, locked or without a confirmed email, you'll need to add that yourself. I suspect you won't need that as you mentioned that this is an internal only site.

Next you'll need an extension method to extract the password out:

using System;
using System.Security.Claims;
using System.Security.Principal;
public static class PrincipalExtensions
{
    public static String GetStoredPassword(this IPrincipal principal)
    {
        var claimsPrincipal = principal as ClaimsPrincipal;
        if (claimsPrincipal == null)
        {
            throw new Exception("Expecting ClaimsPrincipal");
        }

        var passwordClaim = claimsPrincipal.FindFirst("MyApplication:Password");

        if (passwordClaim == null)
        {
            throw new Exception("Password is not stored");
        }

        var password = passwordClaim.Value;

        return password;
    }
}

That is pretty much it. Now in every action you can apply that method on User property:

    [Authorize]
    public ActionResult MyPassword()
    {
        var myPassword = User.GetStoredPassword();

        return View((object)myPassword);
    }

And corresponding view will be like this:

@model String

<h2>Password is @Model</h2>

However, depending on your requirements this password claim can be killed over time or preserved. Default Identity template enables SecurityStampInvalidator that is executed every 30 minutes on the cookie and rewrites it fresh from the DB. Usually ad-hoc claims added like this do not survive this rewrite.

To preserve the password value past 30 minutes of cookie age take this class:

using System;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.Owin.Security.Cookies;
using Microsoft.AspNet.Identity;
using Microsoft.AspNet.Identity.Owin;

// This is mostly copy of original security stamp validator, only with addition to keep hold of password claim
// https://github.com/aspnet/AspNetIdentity/blob/a24b776676f12cf7f0e13944783cf8e379b3ef70/src/Microsoft.AspNet.Identity.Owin/SecurityStampValidator.cs#L1
public class MySecurityStampValidator
{
    /// <summary>
    ///     Can be used as the ValidateIdentity method for a CookieAuthenticationProvider which will check a user's security
    ///     stamp after validateInterval
    ///     Rejects the identity if the stamp changes, and otherwise will call regenerateIdentity to sign in a new
    ///     ClaimsIdentity
    /// </summary>
    /// <typeparam name="TManager"></typeparam>
    /// <typeparam name="TUser"></typeparam>
    /// <param name="validateInterval"></param>
    /// <param name="regenerateIdentity"></param>
    /// <returns></returns>
    public static Func<CookieValidateIdentityContext, Task> OnValidateIdentity<TManager, TUser>(
        TimeSpan validateInterval, Func<TManager, TUser, Task<ClaimsIdentity>> regenerateIdentity)
        where TManager : UserManager<TUser, string>
        where TUser : class, IUser<string>
    {
        return OnValidateIdentity(validateInterval, regenerateIdentity, id => id.GetUserId());
    }

    /// <summary>
    ///     Can be used as the ValidateIdentity method for a CookieAuthenticationProvider which will check a user's security
    ///     stamp after validateInterval
    ///     Rejects the identity if the stamp changes, and otherwise will call regenerateIdentity to sign in a new
    ///     ClaimsIdentity
    /// </summary>
    /// <typeparam name="TManager"></typeparam>
    /// <typeparam name="TUser"></typeparam>
    /// <typeparam name="TKey"></typeparam>
    /// <param name="validateInterval"></param>
    /// <param name="regenerateIdentityCallback"></param>
    /// <param name="getUserIdCallback"></param>
    /// <returns></returns>
    public static Func<CookieValidateIdentityContext, Task> OnValidateIdentity<TManager, TUser, TKey>(
        TimeSpan validateInterval, Func<TManager, TUser, Task<ClaimsIdentity>> regenerateIdentityCallback,
        Func<ClaimsIdentity, TKey> getUserIdCallback)
        where TManager : UserManager<TUser, TKey>
        where TUser : class, IUser<TKey>
        where TKey : IEquatable<TKey>
    {
        if (getUserIdCallback == null)
        {
            throw new ArgumentNullException("getUserIdCallback");
        }
        return async context =>
        {
            var currentUtc = DateTimeOffset.UtcNow;
            if (context.Options != null && context.Options.SystemClock != null)
            {
                currentUtc = context.Options.SystemClock.UtcNow;
            }
            var issuedUtc = context.Properties.IssuedUtc;

            // Only validate if enough time has elapsed
            var validate = (issuedUtc == null);
            if (issuedUtc != null)
            {
                var timeElapsed = currentUtc.Subtract(issuedUtc.Value);
                validate = timeElapsed > validateInterval;
            }
            if (validate)
            {
                var manager = context.OwinContext.GetUserManager<TManager>();
                var userId = getUserIdCallback(context.Identity);
                if (manager != null && userId != null)
                {
                    var user = await manager.FindByIdAsync(userId);
                    var reject = true;
                    // Refresh the identity if the stamp matches, otherwise reject
                    if (user != null && manager.SupportsUserSecurityStamp)
                    {
                        var securityStamp =
                            context.Identity.FindFirstValue(Constants.DefaultSecurityStampClaimType);
                        if (securityStamp == await manager.GetSecurityStampAsync(userId))
                        {
                            reject = false;
                            // Regenerate fresh claims if possible and resign in
                            if (regenerateIdentityCallback != null)
                            {
                                var identity = await regenerateIdentityCallback.Invoke(manager, user);
                                if (identity != null)
                                {
                                    var passwordClaim = context.Identity.FindFirst("MyApplication:Password");
                                    if (passwordClaim != null)
                                    {
                                        identity.AddClaim(passwordClaim);
                                    }

                                    // Fix for regression where this value is not updated
                                    // Setting it to null so that it is refreshed by the cookie middleware
                                    context.Properties.IssuedUtc = null;
                                    context.Properties.ExpiresUtc = null;
                                    context.OwinContext.Authentication.SignIn(context.Properties, identity);
                                }
                            }
                        }
                    }
                    if (reject)
                    {
                        context.RejectIdentity();
                        context.OwinContext.Authentication.SignOut(context.Options.AuthenticationType);
                    }
                }
            }
        };
    }
}

Note that this is a direct copy of original Identity code with minor modification to preserve the password claim.

And to activate this class, in your Startup.Auth.cs do this:

app.UseCookieAuthentication(new CookieAuthenticationOptions
{
    AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
    LoginPath = new PathString("/Account/Login"),
    Provider = new CookieAuthenticationProvider
    {
        // use MySecurityStampValidator here
        OnValidateIdentity = MySecurityStampValidator.OnValidateIdentity<ApplicationUserManager, ApplicationUser>(
            validateInterval: TimeSpan.FromMinutes(10), // adjust time as required
            regenerateIdentity: (manager, user) => user.GenerateUserIdentityAsync(manager))
    }
});

Here is a working sample code

like image 28
trailmax Avatar answered Oct 21 '22 07:10

trailmax