65.9K
CodeProject 正在变化。 阅读更多。
Home

ASP.NET 的成员资格存储

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (19投票s)

2014年1月27日

CPOL

19分钟阅读

viewsIcon

80239

downloadIcon

3717

基于服务、多应用程序、ASP.NET 4.0 后异步自定义会员存储,适用于具有分层角色系统的 ASP.NET Identity 2.0。

可选下载

请将包中的所有文档项从根目录解压到数据服务站点的根目录中。但是,如果您不想安装文档,也可以在此处获取。

注意:数据服务现在运行在 .Net 4.5.1 和 Asp.Net Mvc 5 下。数据存储现在支持 ASP.NET Identity 2.0。应将旧数据服务替换为新数据服务,如果尚未升级,则应将客户端应用程序升级到在 ASP.NET Identity 2.0 下运行。

如果您的 IDE 正确安装了 nuget 包管理器,则无需下载引用程序集的包,因为它们将在项目首次构建时从 nuget.org 中检索。但如果确实需要,请将包中的所有项从根目录解压到整个解决方案的根目录中(即,放置解决方案文件 (*.sln) 的目录)。

相关文章

目录

引言

新的 ASP.NET MVC 5(以及未来可能的一些版本)支持的用户身份识别、认证和授权 (UIAA) 系统与上一篇文章《基于服务的 ASP.NET 会员提供程序》中讨论的系统相比发生了显著变化。新系统可以使用松散类型、第三方可验证的、与身份相关的属性包(称为声明)来支持更多样化、异构类型的 UIAA 后端。用户在基于角色的 UIAA 系统中的角色可以映射到一种声明,其类型显然是 Role

新系统不再使用提供程序,而是通过 Microsoft.AspNet.Identity 命名空间下的 UserManager 类,使用依赖注入将会员存储库注入到相应的控制器中。

本文介绍了两个自定义存储,即 UserStoreRoleStore,它们连接到上一篇文章中描述的会员数据服务,以及 UserManager 的一个扩展,可以注入到 .NET 4.0 后 ASP.NET Web 应用程序中,以支持多应用程序 UIAA。

背景

微软最近发布了 Visual Studio 2013。其中,它改进了对异步编程风格的支持。也许是时候进入这种新的编程方式了(原因请参见此处)。

对于 .NET 4.0 后框架的 async/await 语言特性不熟悉的人,他们可能会考虑在前端程序中使用它来帮助提高与 UI 响应性相关的用户体验。在服务器环境中采用异步存储的原因可能会出现。至少我一开始是这样想的。但经过更多思考,我发现它非常有用,因为它实际上可以实现我多年前在 .NET 时代之前的旧想法,这些想法由于涉及的复杂性而未能完全实现或利用。以下是对新 async/await 特性的一种初步解释,遵循这种思路。随着未来收集到或提供更多信息,它将得到改进。

ASP.NET 在 .NET 4.0 后支持异步操作,它在遇到新引入的 asyncawait 修饰符时,在底层异步执行操作的同时,保持了顺序操作的简单流程。尽管“传统”顺序方法仍然受支持,但异步操作在应用于适当问题时可能是有益的。例如,它们可以在繁忙的 IO 密集型环境中提高整体性能,尽管 async/await 可能会引入相当大的开销,尤其是在深层嵌套的调用堆栈中使用时。

应用程序软件和 IO(或任何其他)硬件以不同的速度和顺序运行,后者较慢(纳秒 vs. 毫秒),但能够在一次操作周期内处理多个请求(例如,传统硬盘)。Web 服务器中大量的任务是执行 IO 操作。当应用程序软件采用同步操作模式时,大多数流行的 Web 服务器(如 IIS、Apache)必须分配线程或进程来发布 IO 请求,然后逐个(对于每个核心)检查硬件操作的完成状态,在相对繁忙的服务器中,可能有数百甚至数千个。这为操作系统 (OS) 创建了大量的无所事事任务,同时也没有充分利用硬件的能力。如果使用异步操作模式,应用程序软件可以编写成不等待硬件操作完成,而是立即返回以减轻操作系统的负担,使其能够处理其他任务,例如发布更多待处理的 IO 请求,以便每个硬件周期可以处理更多数据。当相应的硬件操作结果可用时,应用程序软件将被通知,然后它将继续执行下一步,无论它们是什么。

然而,异步操作在应用软件中很难管理,因为处理异步 IO 的 IO 库通常位于调用堆栈深处(从上层调用上下文开始),当采用异步模式时,几乎不可能跟踪调用堆栈上下文信息,尤其是在涉及动态递归操作时,更不用说调试或异常处理了。异步版本的应用软件很可能与其同步版本有很大不同,以至于它及其所依赖的 IO 库必须完全重写。这在生产和维护方面都很昂贵。新的异步框架使原本混乱、复杂、难以管理和调试的延续片段看起来毫不复杂:让编译器完成繁重的工作,你只需 await

尽管当前版本的 .NET 通用 IO 库可能仍然使用线程或线程池来处理异步操作,但新的语言特性可以允许 Microsoft 或第三方开发人员更改或改进底层实现,而无需更改调用约定,因此上层应用程序无需更改。因此,基于异步的实现对我们来说很好用,因为与会员查询相关的计算大多在数据服务服务器内部完成,作为客户端的 Web 服务器更可能是一个后端数据服务的代理,大部分时间都在等待从网络套接字发送或接收的数据完成。

让我们回到会员管理的主题。ASP.NET 4.0 后框架的新 IUserStore 接口和 UserManager 类定义了 ASP.NET 应用程序与会员数据源交互的契约。前者在本文中实现,后者被重写以使用会员数据服务。这些契约只定义了可以在 MVC 5 控制器中直接调用的异步方法。当前版本 ASP.NET WebForms 应用程序的默认表单事件处理程序仍然是非异步的,ASP.NET 处理它的方式是添加扩展方法,将异步调用转换为同步调用。因此它们仍然是基于异步的。

与旧的默认值相比,ASP.NET MVC 5 的默认会员数据架构甚至更简单
  1. 它不支持多应用程序或在其会员资格中不是特定于应用程序的。但由于接口足够灵活,此处介绍的用户存储仍然可以支持多应用程序。
  2. 默认的 Microsoft.AspNet.Identity.UserManager 类目前不支持任何机制来禁用潜在的暴力密码猜测尝试或保护数据服务免受拒绝服务 (DoS) 攻击。幸运的是,该类没有被密封。默认 Microsoft.AspNet.Identity.UserManager 类的大多数方法可以在派生类中被重写,以定义自定义认证逻辑。

因此,先前发布的会员数据服务可以在不进行更改的情况下支持新框架,只是新框架允许支持通用用户声明。本基于角色的会员管理系统将只支持与标准用户属性(如用户标识符、姓名、电子邮件等)和用户角色相关的声明类型子集。当然,读者可能会发现可以使用 UserProfiles 数据集存储其他类型的更通用声明,但本文将不遵循这条路径。这可以在未来进行探索。

用户存储

与旧的会员提供程序不同,也许因为它仍然很新,目前相关的文档非常简短且不准确,网上可供研究的示例实现也很少。发现有时需要一些猜测才能继续。幸运的是,接口中涉及的方法名称非常具有描述性,因此过程并不非常困难。

用户数据模型

ASP.NET 4.5 中的用户数据模型必须派生自 IUser。在此处扩展为 IApplicationUser

public interface IApplicationUser : Microsoft.AspNet.Identity.IUser
{
    string Email { get; set; }
    string AppMemberStatus { get; set; }
    string PasswordQuestion { get; set; }
    string PasswordAnswer { get; set; }
    ICollection<System.Security.Claims.Claim> Claims { get; }
    void UpdateInstance(User user);
}
 

以包含数据源支持的更多用户属性。数据服务已经为我们定义了一个完整且文档齐全的用户数据模型,最好不要通过添加另一个不必要的层来重复造轮子,因为这些工件往往会带来维护复杂性而没有太多好处。因此引入以下类来表示用户,它也实现了 IIdentity

public class ApplicationUser : User, IApplicationUser, IIdentity
{
    string IUser<string>.Id
    {
        get { return ID; }
    }
 
    string IUser<string>.UserName
    {
        get
        {
            return Username;
        }
        set
        {
            Username = value;
        }
    }
 
    string IIdentity.AuthenticationType
    {
        get { return DefaultAuthenticationTypes.ApplicationCookie; }
    }
 
    string IIdentity.Name
    {
        get { return Username; }
    }
 
    public bool IsAuthenticated
    {
        get;
        set;
    }
 
    public string AppMemberStatus
    {
        get;
        set;
    }
 
    public ICollection<Claim> Claims
    {
        get
        {
            if (_claims.Count == 0)
            {
                _claims.Add(UserStore<ApplicationUser>.CreateClaim(
      "http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider", 
                    UserStore<ApplicationUser>.NameProviderId));
                _claims.Add(UserStore<ApplicationUser>.CreateClaim(
                    Microsoft.IdentityModel.Claims.ClaimTypes.NameIdentifier, 
                    ID));
                _claims.Add(UserStore<ApplicationUser>.CreateClaim(
                    Microsoft.IdentityModel.Claims.ClaimTypes.Name, 
                    Username));
                if (!string.IsNullOrEmpty(Email))
                {
                    _claims.Add(UserStore<ApplicationUser>.CreateClaim(
                        Microsoft.IdentityModel.Claims.ClaimTypes.Email, 
                        Email));
                }
            }
            return _claims;
        }
    }
    private List<Claim> _claims = new List<Claim>();
 
    public void UpdateInstance(User u)
    {
        IsPersisted = false;
        User.MergeChanges(u, this);
        IsPersisted = u.IsPersisted;
    }
}
 

添加的 Claims 属性用于存储会员数据服务支持的用户声明信息。无论用户如何,它都会初始化一个声明,即尚未标准化的名为“http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider”的声明,该声明标识身份提供者。对于任何特定用户,它还会初始化包含用户唯一标识符值的标准 Microsoft.IdentityModel.Claims.ClaimTypes.NameIdentifier(当前 Microsoft 关于此成员的文档要么是错误的,要么具有误导性)。这两个声明是 ASP.NET MVC 5 窗体防伪验证器所需的声明。接下来的两个声明是类型为 Microsoft.IdentityModel.Claims.ClaimTypes.NameMicrosoft.IdentityModel.Claims.ClaimTypes.Email 的标准声明。类型为 Microsoft.IdentityModel.Claims.ClaimTypes.Role 的标准声明将由用户存储在用户记录检索期间附加(请参见下文)。

实现

ASP.NET 的默认会员用户存储实现以下接口

IUserLoginStore<TUser>IUserClaimStore<TUser>IUserRoleStore<TUser>IUserPasswordStore<TUser>IUserSecurityStampStore<TUser>IUserStore<TUser>IDisposable

我们的用户存储未实现以下接口

  • IUserLoginStore<TUser> 包含将用户映射到登录提供程序的方法,即 Google、Facebook、Twitter、Microsoft。目前我们的存储没有实现它。
  • IUserClaimStore<TUser> 包含操作通用用户特定声明的方法。通用声明可以包含用户的任何与身份相关的属性,这不由我们的会员数据服务处理,该服务已包含特定属性,足以支持基本的基于角色的会员系统。这些与基于角色的身份验证相关的声明在用户记录检索期间添加到用户记录中,而无需使用额外的声明存储。
  • IUserSecurityStampStore<TUser> 包含操作用户安全凭证的方法。当前系统尚不支持。

我们的用户存储实现了以下附加接口

  • IPasswordHasher 用于自定义密码哈希方法。它在此处实现,以便当前系统可以与旧的会员提供程序兼容。
  • IIdentityValidator<string> 用于验证实体的字符串属性,特别是密码。当前版本的系统不执行任何操作。
得益于新的 async/await 特性,发现大多数实现的方法在旧的会员和角色提供程序中都有直接对应的部分。只需从那里复制代码,并通过首先等待相应数据服务代理类的相应“Async”版本方法,然后进行一些显而易见的其他更改以使复制的代码适应新方法,从而使其异步化。例如,用于根据用户的标识符查找用户的方法
public async Task<TUser> FindByIdAsync(string userId)
{
    CallContext cctx = _cctx.CreateCopy();
    UserServiceProxy usvc = new UserServiceProxy();
    var u = await usvc.LoadEntityByKeyAsync(cctx, userId);
    if (u == null)
        return null;
    var user = new TUser();
    user.UpdateInstance(u);
    // New:
    // in addition, try to find all roles the user is in and add them
    // to its Claims property.
    //
    UserAppMemberSet membs = new UserAppMemberSet();
    UserAppMemberServiceProxy mbsvc = new UserAppMemberServiceProxy();
    var memb = await mbsvc.LoadEntityByKeyAsync(cctx, app.ID, user.Id);
    if (memb != null)
    {
        user.AppMemberStatus = memb.MemberStatus;
        if (memb.MemberStatus == membs.MemberStatusValues[0])
        {
            var roles = await GetRolesAsync(user);
            foreach (var r in roles)
            {
                user.Claims.Add(
                     CreateClaim
                     (
                       Microsoft.IdentityModel.Claims.ClaimTypes.Role, 
                       r
                     )
                );
            }
        }
    }
    return user;
}
        

该方法返回一个 Task 对象,使其成为 awaitable。它还具有一个 async 修饰符,这意味着它可以 await 异步的 awaitable 调用。方法主体 await 异步方法 mbsvc.LoadEntityByKeyAsync(...) 来加载用户,以及 GetRolesAsync(...) 来获取用户明确或隐式拥有的所有角色。async 方法 GetRolesAsync(...)

public async Task<System.Collections.Generic.IList<string>> GetRolesAsync(TUser user)
{
    var dic = await _getRolesAsync(user);
    return (from d in dic select d.Value).ToList();
}
 

它包含一行 await 异步方法 _getRolesAsync(...),该方法进一步 await 其他异步方法,这里不显示。

用户创建方法是

public async Task CreateAsync(TUser user)
{
    CallContext cctx = _cctx.CreateCopy();
    var _user = user as User;
    try
    {
        UserSet us = new UserSet();
        UserAppMemberSet ums = new UserAppMemberSet();
        UserServiceProxy usvc = new UserServiceProxy();
        User udata = null;
        List<User> lu = await usvc.LoadEntityByNatureAsync(cctx, 
                                                           user.UserName);
        if (lu == null || lu.Count == 0)
        {
            string id = user.Id;
            if (id != null && 
                     await usvc.LoadEntityByKeyAsync(cctx, id) != null)
                throw new Exception("Duplicate user ID found.");
            if (RequiresUniqueEmail)
            {
                var x = await GetUserNameByEmailAsync(_user.Email);
                if (!string.IsNullOrEmpty(x))
                    throw new Exception("User email exists.");
            }
            DateTime createDate = DateTime.UtcNow;
            if (id == null)
            {
                id = Guid.NewGuid().ToString();
            }
            else
            {
                Guid guid;
                if (!Guid.TryParse(id, out guid))
                    throw new Exception("Invalid user ID found.");
            }
            udata = new User();
            udata.IsPersisted = false;
            udata.ID = id;
            udata.Username = user.UserName;
            udata.Password = (user as User).Password;
            udata.PasswordFormat = "Hashed";
            udata.Email = _user.Email;
            udata.PasswordQuestion = _user.PasswordQuestion;
            udata.PasswordAnswer = _user.PasswordAnswer;
            udata.IsApproved = UserApprovedOnAddition;
            udata.CreateOn = createDate;
            udata.LastPasswordChangedDate = createDate;
            udata.FailedPasswordAttemptCount = 0;
            udata.FailedPasswordAttemptWindowStart = createDate;
            udata.FailedPasswordAnswerAttemptCount = 0;
            udata.FailedPasswordAnswerAttemptWindowStart = createDate;
            udata.Status = us.StatusValues[0];
            UserAppMember memb = new UserAppMember();
            memb.ApplicationID = app.ID;
            memb.UserID = udata.ID;
            memb.MemberStatus = ums.MemberStatusValues[0];
            memb.LastStatusChange = createDate;
            memb.LastActivityDate = createDate;
            memb.Comment = "";
            udata.ChangedUserAppMembers = new UserAppMember[] { memb };
            var v = await usvc.AddOrUpdateEntitiesAsync(cctx, 
                                                        us, 
                                                        new User[] { udata });
            if (v.ChangedEntities.Length == 1 &&  
                                 IsValidUpdate(v.ChangedEntities[0].OpStatus))
            {
                user.UpdateInstance(v.ChangedEntities[0].UpdatedItem);
                return;
            }
            throw new Exception("Add user failed!");
        }
        else if ((user as User).Password == lu[0].Password)
        {
            // case of an existing user trying to join an application
            DateTime createDate = DateTime.UtcNow;
            udata = lu[0];
            if (udata.Email != _user.Email)
            {
                udata.Email = _user.Email;
                udata.IsEmailModified = true;
           // no need to wait since it's already async on the server side.
                usvc.EnqueueNewOrUpdateEntitiesAsync(cctx, us, 
                                                     new User[] { udata });
            }
            UserAppMemberServiceProxy membsvc = new UserAppMemberServiceProxy();
            UserAppMember memb = await membsvc.LoadEntityByKeyAsync(cctx, 
                                                                    app.ID, 
                                                                    udata.ID);
            if (memb == null)
            {
                memb = new UserAppMember();
                memb.IsPersisted = false;
                memb.ApplicationID = app.ID;
                memb.UserID = udata.ID;
                memb.MemberStatus = ums.MemberStatusValues[0];
                memb.LastActivityDate = createDate;
                var v = membsvc.AddOrUpdateEntities(cctx, ums, 
                                    new UserAppMember[] { memb });
                if (v.ChangedEntities.Length == 1 && 
                            IsValidUpdate(v.ChangedEntities[0].OpStatus))
                {
                    user.UpdateInstance(udata);
                    return;
                }
                throw new Exception("Add user membership failed!");
            }
        }
        else
        {
            throw new Exception("User name exists!");
        }
    }
    catch (Exception e)
    {
        if (WriteExceptionsToEventLog)
        {
            WriteToEventLog(e, "CreateUser");
        }
        throw new Exception("error", e);
    }
    finally
    {
    }
}
 

上述代码的各个部分已在上一篇文章中详细解释。

在分层角色系统中查找用户是否属于特定角色,该方法从上一篇文章的角色提供程序复制而来,实现如下:

public async Task<bool> IsInRoleAsync(TUser user, string role)
{
    CallContext cctx = _cctx.CreateCopy();
    try
    {
        UserServiceProxy usvc = new UserServiceProxy();
        var lu = await usvc.LoadEntityByNatureAsync(cctx, user.UserName);
        if (lu == null || lu.Count == 0)
            return false;
        User u = lu[0];
        Role r = await findRoleAsync(role);
        if (r == null)
            return false;
        UsersInRoleServiceProxy uisvc = new UsersInRoleServiceProxy();
        UsersInRole x = await uisvc.LoadEntityByKeyAsync(cctx, r.ID, u.ID);
        if (x != null)
            return true;
        else
        {
            RoleServiceProxy rsvc = new RoleServiceProxy();
            var ra = await rsvc.LoadEntityHierarchyRecursAsync(cctx, r, 0, -1);
            //for a given role, the users in it also include the ones in all 
            //its child roles, recursively (see above), in addition to its own ...
            List<string> uns = new List<string>();
            await _getUserInRoleAsync(cctx, ra, uns);
            return (from d in uns where d == user.UserName select d).Any();
        }
    }
    finally
    {
    }
}
 
private async Task _getUserInRoleAsync(CallContext cctx, 
                                       EntityAbs<Role> ra, 
                                       List<string> usersinrole)
{
    UserServiceProxy usvc = new UserServiceProxy();
    QueryExpresion qexpr = new QueryExpresion();
    qexpr.OrderTks = new List<QToken>(new QToken[]{ 
            new QToken { TkName = "Username" } 
        });
    qexpr.FilterTks = new List<QToken>(new QToken[]{
            new QToken { TkName = "UsersInRole." },
            new QToken { TkName = "RoleID" },
            new QToken { TkName = "==" },
            new QToken { TkName = "" + ra.DataBehind.ID + "" }
        });
    var users = await usvc.QueryDatabaseAsync(cctx, new UserSet(), qexpr);
    foreach (User u in users)
        usersinrole.Add(u.Username);
    if (ra.ChildEntities != null)
    {
        foreach (var c in ra.ChildEntities)
            await _getUserInRoleAsync(cctx, c, usersinrole);
    }
}
 

请注意,async 方法 _getUserInRoleAsync(...) 可以 await 自身。这意味着我们现在能够递归调用异步方法。有关代码功能的进一步解释,请访问此处

角色存储

尽管角色存储已实现,但目前实际上并未用到。

角色提供程序中的一些方法已移至上述用户存储。角色存储中剩下的方法相对简单,例如

public async Task DeleteAsync(TRole role)
{
    CallContext cctx = _cctx.CreateCopy();
    RoleServiceProxy rsvc = new RoleServiceProxy();
    try
    {
        string rolename = role.Name;
        var find = await findRoleAsync(rolename);
        Role r = find == null ? null : find.Item1;
        if (r != null)
        {
            if (!ThrowOnPopulatedRole)
                rsvc.DeleteEntities(cctx, new RoleSet(), new Role[] { r });
            else
            {
                var rus = await GetUsersInRoleAsync(rolename);
                if (rus == null || rus.Length == 0)
                    rsvc.DeleteEntities(cctx, new RoleSet(), new Role[] { r });
                else
                    throw new Exception("Cannot delete a populated role.");
            }
        }
    }
    catch (Exception e)
    {
        if (WriteExceptionsToEventLog)
        {
            WriteToEventLog(e, "DeleteRole");
        }
        throw new Exception("error", e);
    }
    finally
    {
    }
}
 

这里不再详细讨论。感兴趣的读者可以查看代码,并在我们之前的文章中找到相应的代码解释。

用户管理器

默认 Microsoft.AspNet.Identity.UserManager 类中的某些方法需要被重写,以实现会员数据服务支持的更复杂的身份验证逻辑。

public override async Task<TUser> FindAsync(string userName, 
                                            string password)
{
    if (HttpContext.Current != null)
    {
        string cacheKey = "userLoginState:" + userName;
        var error = 
           HttpContext.Current.Cache[cacheKey] as AuthFailedEventArg;
        if (error != null)
        {
            ErrorsHandler(userName, error);
            return null;
        }
    }
    CallContext cctx = _cctx.CreateCopy();
    UserServiceProxy usvc = new UserServiceProxy();
    UserSet us = new UserSet();
    var lu = await usvc.LoadEntityByNatureAsync(cctx, userName);
    if (lu == null || lu.Count == 0)
    {
        var err = new AuthFailedEventArg { 
            FailType = AuthFailedTypes.UnknownUser, 
            FailMessage = 
            "Your don't have an account in the present system, please register!" };
        ErrorsHandler(userName, err);
        return null;
    }
    var u = lu[0];
    if (!u.IsApproved)
    {
        var err = new AuthFailedEventArg { 
            FailType = AuthFailedTypes.ApprovalNeeded, 
            FailMessage = 
            "Your account is pending for approval, please wait!" };
        ErrorsHandler(userName, err);
        return null;
    }
    if (u.Status != us.StatusValues[0])
    {
        var err = new AuthFailedEventArg { 
            FailType = AuthFailedTypes.UserAccountBlocked, 
            FailMessage = string.Format(
"Your account is in the state of being [{0}], please contact an administrator!", 
            u.Status) };
        ErrorsHandler(userName, err);
        return null;
    }
    UserAppMemberSet membs = new UserAppMemberSet();
    UserAppMemberServiceProxy mbsvc = new UserAppMemberServiceProxy();
    var memb = await mbsvc.LoadEntityByKeyAsync(cctx, app.ID, u.ID);
    if (memb == null)
    {
        var err = new AuthFailedEventArg { 
            FailType = AuthFailedTypes.MemberNotFound, 
            FailMessage = string.Format(
            "You are not currently a member of \"{0}\", please register", 
            string.IsNullOrEmpty(app.DisplayName) ? app.Name : app.DisplayName) };
        ErrorsHandler(userName, err);
        return null;
    }
    if (memb.MemberStatus != membs.MemberStatusValues[0])
    {
        if (memb.MemberStatus != membs.MemberStatusValues[3])
        {
            var err = new AuthFailedEventArg { 
                FailType = AuthFailedTypes.MembershipBlocked, 
                FailMessage = string.Format(
 "Your membership in \"{0}\" is in the state of being [{1}], please contact ... ", 
                string.IsNullOrEmpty(app.DisplayName) ? app.Name : app.DisplayName, 
                memb.MemberStatus) };
            ErrorsHandler(userName, err);
            return null;
        }
        else
        {
            var windowStart = 
                u.FailedPasswordAttemptWindowStart.HasValue ? 
                u.FailedPasswordAttemptWindowStart.Value : DateTime.MinValue;
            DateTime windowEnd = windowStart.AddSeconds(
                  (Store as UserStore<TUser>).PasswordAttemptWindow);
            if (DateTime.UtcNow <= windowEnd)
            {
                var err = new AuthFailedEventArg { 
                    FailType = AuthFailedTypes.MembershipFrozen, 
                    FailMessage = string.Format(
 "Maximum login attemps for \"{0}\" exceeded, please try again later!", 
                           string.IsNullOrEmpty(app.DisplayName) ? 
                                            app.Name : app.DisplayName) };
                ErrorsHandler(userName, err, false);
                return null;
            }
            else
            {
                memb.MemberStatus = membs.MemberStatusValues[0];
                memb.IsMemberStatusModified = true;
                memb.LastStatusChange = DateTime.UtcNow;
                memb.IsLastStatusChangeModified = true;
                await mbsvc.AddOrUpdateEntitiesAsync(cctx, membs, 
                                                     new UserAppMember[] { memb });
                var err = new AuthFailedEventArg { 
                    FailType = AuthFailedTypes.MembershipRecovered, 
                    FailMessage = 
"Your membership status is automatically restored, please try again in a few seconds!" };
                ErrorsHandler(userName, err, false);
                return null;
            }
        }
    }
    TUser user = new TUser();
    user.UpdateInstance(u);
    var found = await base.FindAsync(userName, password);
    if (found == null)
    {
        await (Store as UserStore<TUser>).UpdateFailureCountAsync(cctx, 
                                                                  user, 
                                                                  "password");
        var err = new AuthFailedEventArg { 
            FailType = AuthFailedTypes.InvalidCredential, 
            FailMessage = "Invalid username or password." };
        ErrorsHandler(userName, err, false);
    }
    else
    {
        u.LastLoginDate = DateTime.UtcNow;
        u.IsLastLoginDateModified = true;
        usvc.EnqueueNewOrUpdateEntities(cctx, new UserSet(), new User[] { u });
        memb.LastActivityDate = u.LastLoginDate;
        memb.IsLastActivityDateModified = true;
        mbsvc.EnqueueNewOrUpdateEntities(cctx, membs, new UserAppMember[] { memb });
        if (u.FailedPasswordAttemptCount != 0)
        {
            u.FailedPasswordAttemptCount = 0;
            usvc.EnqueueNewOrUpdateEntities(cctx, us, new User[] { u });
        }
    }
    return found;
}
 

除了成功的用户(基于密码的)身份验证情况外,它还处理按以下方式分类的各种身份验证失败场景

public enum AuthFailedTypes
{
    Unknown,             // initial value, unknown
    UnknownUser,         // the user is not found
    InvalidCredential,   // the user's credential is not valid
    ApprovalNeeded,      // the user's account is not yet approved
    UserAccountBlocked,  // the user's account is currently being blocked from login
    MemberNotFound,      // the user is not a member of the current application
    MembershipBlocked,   // the user's membership login is currently blocked
    MembershipFrozen,    // the user's membership login is temporarily frozen
    MembershipRecovered, // the user's membership login is recovered
    ActionTip            // tip for the next actions to be performed
}
 

对于当前应用程序的现有成员的每次认证失败,该方法调用

await (Store as UserStore<TUser>).UpdateFailureCountAsync(cctx, user, "password");

它记录了登录失败的时间和次数

public async Task UpdateFailureCountAsync(CallContext cctx, TUser user, string failureType)
{
    bool b = cctx.DirectDataAccess;
    cctx.DirectDataAccess = true;
    UserServiceProxy usvc = new UserServiceProxy();
    UserAppMemberServiceProxy umsvc = new UserAppMemberServiceProxy();
    try
    {
        User u = new User();
        u.IsPersisted = false;
        User.MergeChanges(user as User, u);
        u.IsPersisted = user.IsPersisted;
        DateTime windowStart = new DateTime();
        int failureCount = 0;
        if (failureType == "password")
        {
            failureCount = u.FailedPasswordAttemptCount.HasValue ? 
                           u.FailedPasswordAttemptCount.Value : 0;
            windowStart = u.FailedPasswordAttemptWindowStart.HasValue ? 
                          u.FailedPasswordAttemptWindowStart.Value : DateTime.MinValue;
        }
        else if (failureType == "passwordAnswer")
        {
            failureCount = u.FailedPasswordAnswerAttemptCount.HasValue ? 
                           u.FailedPasswordAnswerAttemptCount.Value : 0;
            windowStart = u.FailedPasswordAnswerAttemptWindowStart.HasValue ? 
                    u.FailedPasswordAnswerAttemptWindowStart.Value : DateTime.MinValue;
        }
        DateTime windowEnd = windowStart.AddSeconds(PasswordAttemptWindow);
        //repo.BeginRepoTransaction(cctx);
        if (failureCount == 0 || DateTime.UtcNow > windowEnd)
        {
            if (failureType == "password")
            {
                u.FailedPasswordAttemptCount = 1;
                u.IsFailedPasswordAttemptCountModified = true;
                u.FailedPasswordAttemptWindowStart = DateTime.UtcNow;
                u.IsFailedPasswordAttemptWindowStartModified = true;
            }
            else if (failureType == "passwordAnswer")
            {
                u.FailedPasswordAnswerAttemptCount = 1;
                u.IsFailedPasswordAnswerAttemptCountModified = true;
                u.FailedPasswordAnswerAttemptWindowStart = DateTime.UtcNow;
                u.IsFailedPasswordAnswerAttemptWindowStartModified = true;
            }
            await usvc.AddOrUpdateEntitiesAsync(cctx, new UserSet(), 
                                                      new User[] { u as User });
        }
        else
        {
            if (++failureCount >= MaxInvalidPasswordAttempts)
            {
                UserAppMemberSet us = new UserAppMemberSet();
                UserAppMember um = await umsvc.LoadEntityByKeyAsync(cctx, app.ID, u.ID);
                if (um != null)
                {
                    um.MemberStatus = us.MemberStatusValues[3];
                    um.IsMemberStatusModified = true;
                    um.LastStatusChange = DateTime.UtcNow;
                    um.IsLastStatusChangeModified = true;
                    await umsvc.AddOrUpdateEntitiesAsync(cctx, us, 
                                                           new UserAppMember[] { um });
                }
            }
            else
            {
                if (failureType == "password")
                {
                    u.FailedPasswordAttemptCount = failureCount;
                    u.IsFailedPasswordAttemptCountModified = true;
                    u.FailedPasswordAttemptWindowStart = DateTime.UtcNow;
                    u.IsFailedPasswordAttemptWindowStartModified = true;
                }
                else if (failureType == "passwordAnswer")
                {
                    u.FailedPasswordAnswerAttemptCount = failureCount;
                    u.IsFailedPasswordAnswerAttemptCountModified = true;
                    u.FailedPasswordAnswerAttemptWindowStart = DateTime.UtcNow;
                    u.IsFailedPasswordAnswerAttemptWindowStartModified = true;
                }
                await usvc.AddOrUpdateEntitiesAsync(cctx, new UserSet(), 
                                                         new User[] { u as User });
            }
        }
    }
    catch (Exception e)
    {
        if (WriteExceptionsToEventLog)
        {
            WriteToEventLog(e, "UpdateFailureCount");
        }
        throw new Exception("error", e);
    }
    finally
    {
        cctx.DirectDataAccess = b;
    }
}
 

它所做的是记录在 PasswordAttemptWindow(秒)属性指定的时间窗口内的失败次数。如果计数超过 MaxInvalidPasswordAttempts 属性指定的数字,则用户的会员状态将被设置为冻结状态(即 us.MemberStatusValues[3])。在该时间窗口内,进一步的身份验证尝试将被阻止。在 PasswordAttemptWindow 秒后,用户可以再次尝试。对于那些不太可能由用户自行解决的身份验证问题,使用 HTTP 缓存来存储状态,以便在 PasswordAttemptWindow 跨越的时间窗口内不再调用会员数据服务。

使用用户存储

项目修改

以下是一系列删除、添加和修改编辑,将更改默认的 MVC 5 项目,以便可以使用会员数据服务。

Removed

  1. 移除对 EntityFrameworkEntityFramework.SqlServerMicrosoft.AspNet.Identity.EntityFramework 的引用。当然,如果您的站点确实将 EntityFramework 用于其他任务,请忽略此项。
  2. 移除对 Microsoft.Owin.Security.FacebookMicrosoft.Owin.Security.OAuthMicrosoft.Owin.Security.GoogleMicrosoft.Owin.Security.TwitterMicrosoft.Owin.Security.MicrosoftAccount 的引用。
  3. 从 Web.config 中删除 <configSections> 节点下的 <section name="entityFramework" ... /> 相关节点。注释掉或删除“DefaultConnection”节点,因为它目前未使用。当然,如果您的网站确实将 EntityFramework 用于其他任务,请忽略此项。
  4. 从 Web.config 中删除根节点下的 <entityFramework> 节点。当然,如果您的站点确实将 EntityFramework 用于其他任务,请忽略此项。
  5. 删除或注释掉 App_Start 子目录中 Setup_Auth.cs 文件中的 app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie); 行。
  6. IdentityModels.cs 文件位于 Models 子目录中。相关数据模型现在定义在实现用户存储的项目中。
  7. Controllers\AccountController.cs 文件中的 using Microsoft.AspNet.Identity.EntityFramework; 行。
  8. Controllers\AccountController.cs 文件中删除 DisassociateExternalLoginExternalLoginCallbackLinkLoginLinkLoginCallbackExternalLoginConfirmationExternalLoginFailureRemoveAccountListDispose 方法以及 XsrfKey 属性。AccountController 类中的 ChallengeResult 类也应删除。
  9. 删除 Views\Account 子目录下的视图:_ExternalLoginsListPartial.cshtm_RemoveAccountPartial.cshtmlExternalLoginConfirmation.cshtmlExternalLoginFailure.cshtml
  10. 移除以下块

        <div class="col-md-4">
            <section id="socialLoginForm">
                @Html.Partial("_ExternalLoginsListPartial", 
                       new { Action = "ExternalLogin", ReturnUrl = ViewBag.ReturnUrl })
            </section>
        </div>

    Login.cshtml 文件中。删除以下块

        <section id="externalLogins">
            @Html.Action("RemoveAccountList")
            @Html.Partial("_ExternalLoginsListPartial", 
                      new { Action = "LinkLogin", ReturnUrl = ViewBag.ReturnUrl })
        </section>

    Manage.cshtml 文件中。

Added

  1. 在 Web.config 的 <appSettings> 节点下添加 <add key="ApplicationName" value="YourApplicationName"/> 节点。这里的 "YourApplicationName" 是为使用会员数据服务的应用程序组中的 Web 应用程序选择的唯一名称。

  2. 在 Web.config 的 <appSettings> 节点下添加以下键节点。

        <add key="WriteAuthExceptionsToEventLog" value="false" />
        <add key="RequiresUniqueUserEmail" value="true" />
        <add key="UserApprovedOnAddition" value="true" />
        <add key="ThrowOnDeletePopulatedRole" value="true"/>
        <add key="DeleteUserMembershipOnly" value="true"/>
        <add key="PasswordAttemptWindow" value="20" />
        <add key="MaxInvalidPasswordAttempts" value ="5" />
        <add key="UserStoreAutoCleanupRoles" value="true"/>     
    

    它们用于控制用户存储的行为。

  3. App_Start 子目录中的 Setup_Auth.cs 文件中引用命名空间 using CryptoGateway.RDB.Data.AspNetMember;

  4. 添加以下属性

    internal static CallContext ClientContext
    {
        get;
        set;
    }
     
    internal static Application_ App
    {
        get;
        set;
    }
    

    App_Start 子目录中的 Setup_Auth.cs 文件。ClientContext 将作为代表 Web 应用程序在远程数据服务中的身份的单个对象。App 是一个数据结构,用于标识会员数据服务所服务的应用程序类型。它们在添加到默认 ConfigureAuth 方法的以下代码块中初始化

    public void ConfigureAuth(IAppBuilder app)
    {
        // Enable the application to use a cookie to store information 
        // for the signed in user
        app.UseCookieAuthentication(new CookieAuthenticationOptions
        {
            AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
            LoginPath = new PathString("/Account/Login")
        });
        
        AspNetMemberServiceProxy svc = new AspNetMemberServiceProxy();
        if (ClientContext == null)
            ClientContext = svc.SignInService(new CallContext(), null);
        CallContext cctx = ClientContext.CreateCopy();
        // Get encryption and decryption key information from the configuration
        Configuration cfg = WebConfigurationManager.OpenWebConfiguration(
                    System.Web.Hosting.HostingEnvironment.ApplicationVirtualPath);
        var machineKey = (MachineKeySection)cfg.GetSection("system.web/machineKey");
        if (machineKey.ValidationKey.Contains("AutoGenerate"))
        {
            throw new Exception("Hashed or Encrypted passwords " +
                        "are not supported with auto-generated keys.");
        }
        string ApplicationName = ConfigurationManager.AppSettings["ApplicationName"];
        cctx.DirectDataAccess = true;
        Application_ServiceProxy apprepo = new Application_ServiceProxy();
        List<Application_> apps = apprepo.LoadEntityByNature(cctx, ApplicationName);
        if (apps == null || apps.Count == 0)
        {
            cctx.OverrideExisting = true;
            var tuple = apprepo.AddOrUpdateEntities(
                                   cctx, new Application_Set(), 
                                   new Application_[] { 
                                           new Application_ { Name = ApplicationName } 
                                   );
            App = tuple.ChangedEntities.Length == 1 &&
                                   IsValidUpdate(tuple.ChangedEntities[0].OpStatus) ? 
                                   tuple.ChangedEntities[0].UpdatedItem : null;
            cctx.OverrideExisting = false;
        }
        else
            App = apps[0];
    }
    
    public static bool IsValidUpdate(int status)
    {
        return (status & (int)EntityOpStatus.Added) > 0 || 
               (status & (int)EntityOpStatus.Updated) > 0 || 
               (status & (int)EntityOpStatus.NoOperation) > 0;
    }
  5. 将 nuget 包 Microsoft.IdentityModel 安装到 Web 应用程序项目。添加对 System.ServiceModel 程序集的引用。

  6. Controllers\AccountController.cs 文件顶部插入 using Archymeta.Web.Security; 行。

  7. Register 方法的用户创建语句之后添加 user.Email = model.Email; 行。

  8. 将以下用户电子邮件输入字段添加到 Views\Account\Register.cshtml 文件中

        
        <div class="form-group">
            @Html.LabelFor(m => m.Email, new { @class = "col-md-2 control-label" })
            <div class="col-md-10">
                @Html.TextBoxFor(m => m.Email, new { @class = "form-control" })
            </div>
        </div>
        

Modified

  1. 应修改 Contollers\AccountControler.cs 文件。将无参数构造函数从

    public AccountController()
            : this(new UserManager<ApplicationUser>(
                          new UserStore<ApplicationUser>(       
                                    new ApplicationDbContext()
                              )
                       )
              )
    {
     
    }

    to

    public AccountController()
            : this(new UserManagerEx<ApplicationUser>(
                            new UserStore<ApplicationUser>(
                                  Startup.ClientContext, 
                                  Startup.App
                            ), 
                            Startup.ClientContext, 
                            Startup.App
                       )
              )
    {
        var manager = UserManager as UserManagerEx<ApplicationUser>;
        manager.ExternalErrorsHandler = err => ModelState.AddModelError(
                                                      err.FailType.ToString(), 
                                                      serr.FailMessage
                                               );
    }  
  2. 在同一文件中,HasPassword 方法从

    private bool HasPassword()
    {
        var user = UserManager.FindById(User.Identity.GetUserId());
        if (user != null)
        {
            return user.PasswordHash != null;
        }
        return false;
    }   

    to

    private bool HasPassword()
    {
        var user = UserManager.FindById(User.Identity.GetUserId());
        if (user != null)
        {
            return user.Password != null;
        }
        return false;
    }   

    Register 方法已更改。更改行

    var user = new ApplicationUser() { UserName = model.UserName };

    to

    var user = new ApplicationUser() { Username = model.UserName };
  3. Views\Account\Login.cshtml 文件已更改:将包含 @Html.ValidationSummary(true) 的行更改为 @Html.ValidationSummary(),以便可以显示登录错误。

更新包

Javascript 包不是最新版本。使用 nuget 更新 jQuery 相关包并添加。演示项目包含 2014 年 1 月 1 日之前的最新版本。此外,添加 knockoutjs 包可能很有用。

在创建本文时,ASP.NET MVC 5 已更新到 ASP.NET MVC 5.1。演示项目已更新到该版本。演示项目的 WebGrease 也已更新到 1.6.0。

使用演示项目

演示 web 应用程序还包含 Controllers\AdminController.cs 和相应的空视图 Views\Admin\Index.cshtml,以演示基于角色的身份验证方法仍然可以使用。

为了演示分层角色系统,“Contact”页面已授权给拥有 Administrators 角色的用户,而“Admin”页面已授权给拥有 Administrators.System 角色的用户。预定义用户 sysadmin 明确属于 Administrators.System 角色,由于 Administrators.System 角色是 Administrators 角色的子角色,因此 sysadmin 也隐式拥有 Administrators 角色。预定义的 demo-user-a 属于 Administrators 角色。因此,“Contact”页面可以由 sysadmindemo-user-a 访问。但“Admin”页面只能由 sysadmin 单独访问。

与其遵循上述步骤修改默认的 ASP.NET MVC 5 项目,这个几乎为空的项目实际上可以用作起始项目。

配置存储

需要将站点设置为数据服务的客户端。以下是一组基本设置

<system.serviceModel>
   <bindings>
      <basicHttpBinding>
         <binding name="basicHttpBinding_DataService" 
                        allowCookies="true" maxBufferSize="6553600"
                        maxBufferPoolSize="5242880" 
                        maxReceivedMessageSize="6553600" >
            <security mode="None" />
         </binding>
      </basicHttpBinding>
   </bindings>
   <behaviors>
      <endpointBehaviors>
         <behavior name="ImpersonateEndpointBehavior">
            <clientCredentials>
               <windows allowedImpersonationLevel="Delegation" allowNtlm="true" />
            </clientCredentials>
         </behavior>
      </endpointBehaviors>
   </behaviors>
   <client>
      <endpoint name="HTTP" 
              address="http://_domain_/Services/DataService/AspNetMember/AspNetMemberDatabase2.svc" 
              binding="basicHttpBinding" 
              bindingConfiguration="basicHttpBinding_DataService" 
              contract="CryptoGateway.RDB.Data.AspNetMember.IAspNetMemberService2" />
      <endpoint name="HTTP" 
              address="http://_domain_/Services/DataService/AspNetMember/Application_Set2.svc" 
              binding="basicHttpBinding" 
              bindingConfiguration="basicHttpBinding_DataService" 
              contract="CryptoGateway.RDB.Data.AspNetMember.IApplication_Service2" />
      <endpoint name="HTTP" 
              address="http://_domain_/Services/DataService/AspNetMember/RoleSet2.svc" 
              binding="basicHttpBinding" 
              bindingConfiguration="basicHttpBinding_DataService" 
              contract="CryptoGateway.RDB.Data.AspNetMember.IRoleService2" />
      <endpoint name="HTTP" 
              address="http://_domain_/Services/DataService/AspNetMember/UserAppMemberSet2.svc" 
              binding="basicHttpBinding" 
              bindingConfiguration="basicHttpBinding_DataService" 
              contract="CryptoGateway.RDB.Data.AspNetMember.IUserAppMemberService2" />
      <endpoint name="HTTP" 
              address="http://_domain_/Services/DataService/AspNetMember/UserProfileSet2.svc" 
              binding="basicHttpBinding" 
              bindingConfiguration="basicHttpBinding_DataService" 
              contract="CryptoGateway.RDB.Data.AspNetMember.IUserProfileService2" />
      <endpoint name="HTTP" 
              address="http://_domain_/Services/DataService/AspNetMember/UserProfileTypeSet2.svc" 
              binding="basicHttpBinding" 
              bindingConfiguration="basicHttpBinding_DataService" 
              contract="CryptoGateway.RDB.Data.AspNetMember.IUserProfileTypeService2" />
      <endpoint name="HTTP" 
              address="http://_domain_/Services/DataService/AspNetMember/UserSet2.svc" 
              binding="basicHttpBinding" 
              bindingConfiguration="basicHttpBinding_DataService" 
              contract="CryptoGateway.RDB.Data.AspNetMember.IUserService2" />
      <endpoint name="HTTP" 
              address="http://_domain_/Services/DataService/AspNetMember/UsersInRoleSet2.svc" 
              binding="basicHttpBinding" 
              bindingConfiguration="basicHttpBinding_DataService" 
              contract="CryptoGateway.RDB.Data.AspNetMember.IUsersInRoleService2" />
   </client>
</system.serviceModel>

此处,每个端点节点地址属性中的“_domain_”表示域名或 IP 地址(如果不是 80,则加上端口号),应将其更改为指向数据服务托管服务器的正确地址。

Web.config 文件的 <connectionStrings> 部分包含一个 name="DefaultConnection" add 节点,它最初指向一个 ASP.NET 创建的会员数据库。如果不再使用,可以删除该节点,或者更改其内容以指向站点将使用的其他数据库。

测试项目

测试需要一些配置才能运行。测试项目中的 app.config 文件中,数据服务端点未完全初始化。参数 __servicedomain__ 应替换为数据服务所在的域名(如果不是 80,则加上端口号)。

测试项目随附的解决方案既可以用于测试异步会员存储和管理器,也可以作为一种信息来源,提供另一种视角来研究实现和相关假设。它还演示了如何在客户端使用数据服务。这是因为它们涵盖的细节比演示 Web 应用程序中包含的要多得多。

测试项目根目录中的示例文件 User.xml 中大约有 15K 用户,每次测试随机选择其中约 1.5% 的用户。由于涉及的测试用户数量相当多,测试速度将显著取决于从测试计算机到托管数据服务的计算机的网络速度。

注意:不要针对数据服务的实例进行测试,如果多个代理(包括其他测试代理)可能同时访问它。这是因为服务无法处理多个用户的 CRUD 操作,而是因为测试代码会不断重置(未锁定)数据源的状态,因此如果其他代理进行乱序重置,则其他代理和当前测试代理的结果将不可预测。

设置数据服务

如果数据服务在测试或使用上一篇文章此处给出的会员提供程序时已安装,请忽略此部分。否则请继续阅读。

将会员数据服务包中的文件解压到一个文件夹中,为其配置一个网站(它是一个 ASP.NET MVC 4 web 应用程序)。在您的系统中启用 WCF 的 HTTP 激活。基本上就是这样。

如果您需要持久化您的更改,至少服务站点下的“App_Data\AspNetMember\Data”子目录需要具有适当的权限。它应该允许用户 IIS_IUSRS 写入权限。

警告:这是一个用于评估目的的系统演示版本。它基于瞬态序列化方法。请勿将其用于长期数据持久性或支持生产系统。

历史

  • V1.0.0。首次发布。
  • V1.0.1。改进了测试项目中边缘案例的处理和异常处理的一致性。
  • V1.5.0。数据服务现在运行在 .NET 4.5.1 和 ASP.NET MVC 5 下,改进了许多功能并添加了新功能,例如支持 SignalR 或基于 WCF 的实体更改事件订阅端口等。用户存储现在支持 ASP.NET Identity 2.0。
© . All rights reserved.