В ASP.NET Identity я могу безопасно кэшировать пароль пользователя после входа в систему?

У меня есть приложение для интрасети, где все пользовательские операции выполняются вызовами API в удаленной системе (без локальных таблиц). Для нескольких вызовов API требуется пароль пользователя. Я не могу попросить пользователей продолжать повторно вводить пароль, поскольку они используют сайт (иногда через несколько секунд после того, как они только вошли в систему).

Таким образом, не сохраняя свой пароль в базе данных, где я могу безопасно кэшировать пароль на время входа пользователя (примечание: "login", а не "session"). Я попытался сохранить их в состоянии сеанса, но проблема в том, что сеанс длится 20 минут, но токен входа действителен в течение 24 часов.

В идеале я хочу, чтобы он был связан (как-то) напрямую с.AspNet.ApplicationCookie, поэтому логин и кешированный пароль не могут выйти из синхронизации, но он не видит, как можно добавлять пользовательские значения в этот файл cookie. Он может быть зашифрован, если этот файл cookie еще не зашифрован.

EDIT: из-за функции "запомнить меня" логины могут длиться намного дольше, чем значение Session.TimeOut, поэтому я не хочу использовать Session для этого.

Ответ 1

У меня был проект, в котором мне пришлось реализовать точно то же самое, и в итоге была создана пользовательская реализация интерфейсов ASP.NET Identity. (В моем случае имена пользователей и пароли управлялись внешней системой с API.)
Я объясню идею и основные части кода.

Необходимая пользовательская информация (например, имя пользователя и пароль) сохраняется в памяти в ConcurrentDictionary в пользовательском IUserStore, по определению место, по которому извлекается userinfo.
Заметка; Я собираюсь пропустить рекомендации по безопасности.

Единственное место для доступа к паролю пользователя - это метод PasswordSignInAsync пользовательского SignInManager.
Здесь все по-другому!
В стандартном/регулярном потоке SignInManager использует IUserStore для извлечения userinfo для проверки пароля. Но поскольку роль IUserStore превратилась в хранилище пассивной памяти, которое больше невозможно; этот первоначальный поиск должен быть выполнен, например. поиск базы данных.
Затем SignInManager выполняет проверку пароля.
Если он действителен, пользовательская информация добавляется или обновляется в пользовательский IUserStore (с помощью настраиваемого метода в CustomUserStore.)
Важно также делать обновление каждый раз, когда пользователь подписывается, иначе пароль остается устаревшим, поскольку он хранится в памяти в течение всего срока действия приложения.

В случае, если веб-приложение будет переработано и пользовательская информация в Dictionary будет потеряна, структура идентификации ASP.NET позаботится об этом, перенаправив пользователя снова на страницу входа, с помощью которой приведенный выше поток начнется снова.

Следующим требованием является пользовательский UserManager, поскольку мой IUserStore не реализует все интерфейсы, требуемые идентификатором ASP.NET; см. комментарии в коде. Это может быть иначе для вашего дела.

При всем этом вы получаете CustomUser через UserManager; с объектом пользователя, содержащим пароль:

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

Ниже приведены некоторые выдержки из реализации.

Данные, которые хранятся в памяти:

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

    String Id { get; set; }

    String UserName { get; set; }
}

Пользовательский IUser:

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

    public String Password { get; set; }

    public String UserName { get; set; }
}

Пользовательский IUserStore с помощью метода для написания:

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

Пользовательский 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
                });
        }
    }
}

Пользовательский 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);
    }
}

Пользовательский 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);
    }
}

Ответ 2

Отказ от ответственности: здесь вы вводите пароль в файл cookie. Зашифрованный файл cookie, но пароль. Это не лучшая практика с точки зрения безопасности. Поэтому принимайте решение самостоятельно, если это приемлемо для вашей системы или нет.

Я думаю, что лучшим способом для этого было бы хранить пароль в качестве претензий к файлу cookie аутентификации. Auth cookie шифруется при передаче, но вам не нужно разбираться с самим шифрованием - это делается OWIN для вас. И это требует гораздо меньше сантехники.

Сначала перепишите действие для входа в систему следующим образом:

    [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);
    }

Это берет пароль при входе в систему и добавляет его в качестве требования к Identity, которое, в свою очередь, становится сериализованным и зашифрованным в файл cookie.

Обратите внимание, что здесь не указана много логики - если вам нужно проверить, отключен ли пользователь, заблокирован или нет подтвержденного письма, вам нужно добавить это самостоятельно. Я подозреваю, что вам это не понадобится, поскольку вы упомянули, что это внутренний сайт.

Затем вам понадобится метод расширения для извлечения пароля:

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;
    }
}

Это в значительной степени. Теперь в каждом действии вы можете применить этот метод к свойству User:

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

        return View((object)myPassword);
    }

Соответствующий вид будет таким:

@model String

<h2>Password is @Model</h2>

Однако, в зависимости от ваших требований, эта заявка на пароль может быть убита с течением времени или сохранена. Шаблон Identity по умолчанию позволяет SecurityStampInvalidator который выполняется каждые 30 минут в файле cookie и перезаписывает его из базы данных. Обычно ad-hoc заявления, подобные этому, не выдерживают этого переписывания.

Чтобы сохранить значение пароля за последние 30 минут в возрасте печенья, возьмите этот класс:

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 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 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);
                    }
                }
            }
        };
    }
}

Обратите внимание, что это прямая копия исходного кода Identity с незначительной модификацией для сохранения заявки на получение пароля.

И чтобы активировать этот класс, в вашем Startup.Auth.cs выполните следующее:

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))
    }
});

Вот пример рабочего кода