ASP.NET MVC 应用程序中的自定义基于角色的访问控制 (RBAC) - 第三部分(扩展 ASP.NET Identity 2.0)






4.98/5 (65投票s)
重构 RBAC 框架以进行用户身份验证
目录
引言
在本系列最后一篇文章中,我将把我们最初的基于角色的访问控制 (RBAC) 设计从基于 Windows 的身份验证修改为基于表单的身份验证,该身份验证围绕 ASP.NET Identity 构建。ASP.NET Identity 目前是用于向 ASP.NET 网站添加身份验证和授权功能的主要框架,并且旨在取代以前的 ASP.NET Membership 和 Simple Membership 系统。ASP.NET Identity 包含大多数功能,用于在您的网站上“注册”和“登录”,包括配置文件支持、OAuth 集成、与 OWIN 配合使用,并且随 Visual Studio 2013 附带的 ASP.NET 模板一起提供,从而提供一个功能齐全的身份验证和授权平台。我们开箱即用的功能是创建用户、创建角色并将用户分配给角色的方法。Microsoft 的实现不提供从数据库获取权限级别的方法,而是设计为使用任何可用的基于角色的机制。
但是,我们可以扩展 Microsoft 的系统,使我们的自定义安全措施能够满足我们的所有需求。缺少的是一种将应用程序权限分配给角色而不是单个用户的方法。大多数开发人员和项目在此阶段都遵循已成为惯例的模式;我们检查用户是否属于某个角色,如果是,则允许执行该特定操作。在 ASP.NET 中,这是使用 Authorize
属性完成的(如果当前用户属于某个角色,则允许执行操作)。然而,正如在第一部分中所强调的,使用 Authorize
属性的默认方法存在各种问题。我们基本上在问“用户是否属于此角色”,并根据答案对应用程序代码进行硬编码行为。我们需要问的问题是“当前用户是否具有访问此功能的所需资源权限?”我们需要自定义框架的行为,这可能是大多数项目仍坚持默认行为的主要原因,因为它更简单。
本质上,我们需要两全其美。为各种职位创建角色,并且随着应用程序部署时间的推移,引入新角色并不罕见。应用程序的系统管理员必须能够创建新角色并通过应用程序权限动态绑定它们与控制器方法,而无需每次都重新编译和重新部署应用程序,否则,当应用程序开发人员本质上成为该应用程序的用户/角色管理员时,这将是一个耗时且昂贵的过程。执行某些操作的权限分配给特定角色。用户被分配特定的角色,并通过这些角色分配获得执行特定计算机系统功能的应用程序权限。由于用户不直接分配权限,而仅通过其角色(或角色)获得权限,因此管理单个用户权限只需将适当的角色分配给用户的帐户即可。系统中的每个用户都可以根据其在业务流程中的职责被分配零个、一个或多个角色。
本文将重构 RBAC 框架,以使用 ASP.NET Identity 的框架功能通过用户名/密码组合进行用户身份验证,同时保留在第一部分中引入的基于角色的权限。
背景
ASP.NET Identity 框架最初于 2013 年推出,作为 ASP.NET Membership 系统的后续产品,后者是 MVC 应用程序多年的基本组成部分,但已开始显露老态。最初,ASP.NET Identity 提供了一个有用的、尽管有些简化的 API,用于在以 ASP.NET 构建的面向公众的 Web 应用程序的上下文中管理安全和授权。Identity 框架引入了现代功能,例如社交网络登录集成和易于扩展的用户模型定义,我们将利用这些功能。此后,ASP.NET Identity 2.0 框架已发布,其中包含对原始 1.0 版本功能的重要补充。
在我们之前的示例中,我们实现了一个内网应用程序,并假定登录用户访问我们的应用程序必须已成功登录 Windows,并且该用户是公司网络上的受信任用户。因此,我们无需关心用户身份验证,因为这已由 Windows 处理;只需登录组织域即可确定用户是受信任的。开发基于用户名/密码身份验证的互联网应用程序会带来许多问题,而 ASP.NET Identity 包含许多功能来尝试解决其中一些问题。本文将在扩展 ASP.NET Identity 的数据模型以包含基于角色的权限的上下文中介绍其中一些功能,使我们能够重构原始框架,公开新的扩展方法以根据应用程序配置文件驱动的逻辑注册和登录帐户。
身份验证类型
当您对用户进行身份验证时,您是在验证该用户的身份。当我们在构建一个只有特定用户才能访问我们站点的应用程序时,该用户的身份变得至关重要。第一步是识别用户,以确保您知道他们是谁。您可以通过三种方式在 ASP.NET 中对用户进行身份验证:表单、OAuth 和 Windows。
表单身份验证
基于表单的身份验证是指用户必须明确提供其用户凭据才能继续。当用户单击“登录”按钮时,我们的应用程序负责确保他们输入了正确的密码。Microsoft ASP.NET MVC 已包含许多表单身份验证的功能。在互联网场景中,我们应始终使用“基于表单的身份验证”。
我们通过 Web.config 更改身份验证模型,如下所示:
<system.web>
<authentication mode="Forms">
<forms loginurl="~/Account/Login" timeout="50">
</forms>
</authentication>
loginUrl
指定在找不到有效的身份验证 cookie 时,请求将重定向到的登录 URL。
默认情况下,基于表单的身份验证在未指定 timeout 属性的情况下使用 20 分钟的超时值。超时值最多可以设置为 360 分钟。但是,还有其他超时值会影响您应用程序的会话超时。IIS 中的工作进程默认超时值为 20 分钟;如果您的网站 20 分钟内没有任何活动,工作进程将结束,导致您的会话结束(如果您使用 InProc 模式的会话)。如果您通过 Web.config 文件将应用程序的超时时间增加到 20 分钟以上,您还需要通过 IIS 增加工作进程超时值,否则您的应用程序将在 20 分钟后超时,无论如何。了解会话超时如何工作很重要,因为配置不正确的环境会看起来像 RBAC 未正确运行,如果您设置的会话超时值过低和/或 slidingExpiration
属性设置为 false
,您可能会被持续提示登录。
Windows 身份验证
Windows 身份验证最适用于内网应用程序,其中您的所有用户都已在 Active Directory 服务器上注册,并在公司防火墙范围内工作。Windows 提供的服务会向您的应用程序提供有关当前登录用户的信息。这使用户能够获得单一登录体验,因为一旦他们登录到他们的桌面,他们就可以将此身份用于我们的应用程序以及其他内网应用程序和网络共享。因此,我们无需关心用户身份验证,因为这已由 Windows 处理。有关基于 Windows 身份验证的内网应用程序的示例,请参考第一部分中提供的原始 RBAC 解决方案。内网应用程序的缺点是,除非创建虚拟专用网络 (VPN) 隧道,否则它们无法轻松从组织防火墙外部访问。
OpenId/OAuth
这些是分别用于身份验证和授权的开放标准。采用这些标准意味着我们的用户无需创建或与我们的网站共享密码,也无需存储或验证用户的密码。相反,我们依赖第三方(如 Google 或 Microsoft)来验证用户,并通过颁发身份验证令牌来告知我们他们是谁。
自定义 ASP.NET Identity 数据库表
Microsoft ASP.NET Identity 需要在后台使用几个数据库表。这些表用于存储有关用户、帐户锁定、登录尝试等的信息。ASP.NET Identity 创建的默认数据库表如下:
表名以及这些表之间的 Entity Framework 关系配置在 IdentityDbContext
的 OnModelCreating
方法中配置。如果我们想使用不同的表名或在数据库中以不同的方式映射,我们需要提供自己的 DbContext
。我们可以创建一个全新的 DbContext
类,其中包含所需的五个 DbSets
(每个模型类一个),或者我们可以简单地继承 IdentityDbContext
并重写 OnModelCreating
方法,在该方法中我们可以根据需要配置 EF 映射,如下一节所示。
但是,我们的 RBAC 框架已经包含几个与 ASP.NET Identity 相同的表。与其拥有实际上做同样事情的独立表,不如扩展默认的 ASP.NET Identity 基本表以包含 RBAC 框架所需的其他字段,从而使 ASP.NET Identity 和 RBAC 框架能够利用相同的表。为了保持我们的 RBAC 框架一致,我们将使用我们原始的表命名约定(例如 USERS
、ROLES
和 LNK_USER_ROLE
)将扩展的 ASP.NET Identity 表重命名为其对应的 RBAC 实体。
因此,在 RBAC 数据库初始化期间将创建以下数据库架构,这类似于我们原始的数据库架构。
默认情况下,AspNetUser
表的主键类型由 ASP.NET Identity 框架定义为 string
,使用 GUID 作为表索引键。这可能不符合每个人的喜好,包括我自己在内,但该框架的设计考虑了“可扩展主键”,允许更改主键类型。
GUID 似乎是主键的自然选择,如果您真的必须的话,您可能会争辩使用它作为表的主键。SQL Server 默认情况下(除非您明确指示不要这样做)会将 GUID 列用作聚集键,这并非最优,并且由于其随机性,它将导致大量的页和索引碎片,以及普遍较差的性能。
提前选择正确的聚集键非常重要,并且可以避免长期积累问题。因此,对于本文和可下载的示例项目,AspNetUser
表的主键类型被定义为 integer
。
自定义 IdentityUser 表
要自定义默认的 ASP.NET Identity IdentityUser
表,我们需要定义一个新类,该类继承自 IdentityUser
。继承的类将定义 RBAC 框架所需的属性。当 EF 首次创建 IdentityUser
数据库表时,我们继承类中定义的属性将表示为扩展表中的新字段。因此,新创建的数据库表将包含 ASP.NET Identity 所需的默认 IdentityUser
表中的所有表字段,以及表示我们自定义类属性的新表字段。
public class ApplicationUser : IdentityUser<int, ApplicationUserLogin,
ApplicationUserRole, ApplicationUserClaim>
{
public string Firstname { get; set; }
public string Lastname { get; set; }
public bool? Inactive { get; set; }
public string Address1 { get; set; }
public string Address2 { get; set; }
...
public bool IsPermissionInUserRoles(string _permission)
{
bool _retVal = false;
try
{
foreach (ApplicationUserRole _role in this.Roles)
{
if (_role.IsPermissionInRole(_permission))
{
_retVal = true;
break;
}
}
}
catch (Exception)
{
}
return _retVal;
}
public bool IsSysAdmin()
{
bool _retVal = false;
try
{
foreach (ApplicationUserRole _role in this.Roles)
{
if (_role.IsSysAdmin)
{
_retVal = true;
break;
}
}
}
catch (Exception)
{
}
return _retVal;
}
}
但是,向类添加新属性需要扩展默认的“用户注册”功能以捕获其他表字段;默认情况下,ASP.NET Identity 开箱即用地允许用户使用“电子邮件”和“密码”组合进行“注册”,这两个属性被认为是最低要求。显然,“电子邮件”和“密码”组合不能完全定义一个用户,您的业务需求将定义哪些字段定义您解决方案中的用户,从而定义您应用程序的“用户注册”过程。例如,如果您正在实现一个需要用户为您的在线购物网站注册帐户的解决方案,您显然需要一个与用户电子支付地址关联的送货地址。因此,在“用户注册”过程中捕获用户的送货地址可能是必要的,因为这可能需要作为付款授权过程的一部分进行传递。如果是这种情况,只需在您的自定义 ApplicationUser
类中将其他字段需求声明为类属性,如上所示。EF 将负责为您创建表字段。
自定义 IdentityRole 表
由于 RBAC 框架使用比默认 ASP.NET Identity 角色定义更多的字段来定义角色,因此我们需要自定义默认的 ASP.NET Identity IdentityRole
表以包含这些额外字段。字段为‘RoleDescription
’和‘IsSysAdmin
’;前者可以忽略,因为其目的是存储角色的描述,而后者是 RBAC 框架确定资源授权的强制性要求。
无论如何,我们需要定义一个继承自 IdentityRole
的新类,并声明我们解决方案所需的其他属性。我们还需要实现一个角色可以包含权限的概念,通过定义属性 ICollection<PERMISSION PERMISSIONS
并包含一个类方法来检查与角色关联的权限。角色/权限概念仍然是 RBAC 的基本组成部分,并为我们的框架提供了授权部分。我们可以扩展我们的 ApplicationRole
类以包含基于角色的报告(在第二部分中介绍)或其他基于角色的功能,从而保持 RBAC 框架的可扩展性。
public class ApplicationRole : IdentityRole<int, ApplicationUserRole>
{
public string RoleDescription { get; set; }
public bool IsSysAdmin { get; set; }
...
public virtual ICollection<PERMISSION> PERMISSIONS { get; set; }
public bool IsPermissionInRole(string _permission)
{
bool _retVal = false;
try
{
foreach (PERMISSION _perm in this.PERMISSIONS)
{
if (_perm.PermissionDescription == _permission)
{
_retVal = true;
break;
}
}
}
catch (Exception)
{
}
return _retVal;
}
}
权限表
PERMISSION
类根据我们最初的 RBAC 设计定义了应用程序权限,并在我们的自定义 DbContext
模型中定义了 Permissions
表。
[Table("PERMISSIONS")]
public class PERMISSION
{
[Key]
public int Permission_Id { get; set; }
[Required]
[StringLength(50)]
public string PermissionDescription { get; set; }
public virtual List<applicationrole> ROLES { get; set; }
}
PERMISSION
通过 List<ApplicationRole><applicationrole>
声明反向引用到 ROLE
。
ApplicationUserRole 类
ApplicationUserRole
类继承自 IdentityUserRole
,并定义了我们解决方案所需的其他属性和方法。
public class ApplicationUserRole : IdentityUserRole<int>
{
public ApplicationRole Role { get; set; }
public bool IsSysAdmin { get { return this.Role.IsSysAdmin; } }
public ApplicationUserRole() : base()
{ }
public bool IsPermissionInRole(string _permission)
{
bool _retVal = false;
try
{
_retVal = this.Role.IsPermissionInRole(_permission);
}
catch (Exception)
{
}
return _retVal;
}
public bool IsSysAdmin { get { return this.Role.IsSysAdmin; } }
}
自定义 IdentityDbContext 数据库
为了自定义默认的 ASP.NET IdentityDbContext
模型并更改表关系,我们将定义一个名为 RBACDbContext
的新 DbContext
类,该类继承自默认的 IdentityDbContext
类。新类将定义新表,重写模型的 OnModelCreating
,在那里我们将配置 EF 映射和表关系。
public class RBACDbContext : IdentityDbContext<ApplicationUser,
ApplicationRole, int, ApplicationUserLogin, ApplicationUserRole, ApplicationUserClaim>
{
public DbSet<PERMISSION> PERMISSIONS { get; set; }
public RBACDbContext() : base("DefaultConnection")
{ }
public static RBACDbContext Create()
{
return new RBACDbContext();
}
protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<ApplicationUser>().ToTable("USERS").Property
(p => p.Id).HasColumnName("UserId");
modelBuilder.Entity<ApplicationRole>().ToTable("ROLES").Property
(p => p.Id).HasColumnName("RoleId");
modelBuilder.Entity<ApplicationUserRole>().ToTable("LNK_USER_ROLE");
modelBuilder.Entity<ApplicationRole>().
HasMany(c => c.PERMISSIONS).
WithMany(p => p.ROLES).
Map(
m =>
{
m.MapLeftKey("RoleId");
m.MapRightKey("PermissionId");
m.ToTable("LNK_ROLE_PERMISSION");
});
}
}
除了修改后的默认 ASP.NET Identity
表外,我们还声明了 RBAC 框架所需的其他 PERMISSIONS
表,并在重写的 OnModelCreating
方法中定义了表关系。
注意:如果您希望使用不同的表命名约定或使用不同的主键字段名,请在 OnModelCreating
方法中指定您首选的表名和/或主键字段名,如上所示。
ApplicationUserManager
现在我们已经定义了 RBACDbContext
,我们需要实现工作函数来提供数据库的 CRUD(创建、读取、更新和删除)操作。然后,这些函数可以在我们的控制器中使用,以构建围绕用户维护的功能。ApplicationUserManager
类继承自 UserManager
类,并为“系统管理 > 用户”屏幕构建 CRUD 操作。
public class ApplicationUserManager : UserManager<ApplicationUser, int>
{
public ApplicationUserManager(IUserStore<ApplicationUser, int> store) : base(store)
{ }
public static ApplicationUser GetUser(int _userId)
{
...
}
public static ApplicationUser GetUser(RBACDbContext db, int _userId)
{
...
}
public static List<ApplicationUser> GetUsers()
{
...
}
public static bool UpdateUser(UserViewModel _user)
{
...
}
public static bool DeleteUser(int _userId)
{
...
}
public static bool AddUser2Role(int _userId, int _roleId)
{
...
}
public static bool RemoveUser4Role(int _userId, int _roleId)
{
...
}
...
}
我们现在可以为用户的维护以及用户到角色的分配构建 CRUD 屏幕;我们只需使用 ApplicationUserManager
类公开的函数。
注意:我们的 ApplicationUserManager
类中没有 CreateUser
方法,因为我们依赖于 ASP.NET Identity 的“注册”功能来创建用户;我们无需担心加密用户的密码,因为 ASP.NET Identity 框架会处理此问题。
ApplicationRoleManager
ApplicationRoleManager
类继承自 RoleManager
类,并为“系统管理 > 角色/权限”屏幕构建 CRUD 操作。
public class ApplicationRoleManager : RoleManager<ApplicationRole, int>
{
public ApplicationRoleManager(IRoleStore<ApplicationRole, int> store) : base(store)
{ }
public static List<ApplicationRole> GetRoles()
{
...
}
public static ApplicationRole GetRole(int _roleId)
{
...
}
public static bool CreateRole(ApplicationRole _role)
{
...
}
public static bool UpdateRole(RoleViewModel _modifiedRole)
{
...
}
public static bool DeleteRole(int _roleId)
{
...
}
public static bool AddPermission2Role(int _roleId, int _permissionId)
{
...
}
...
}
我们现在可以为角色和权限的维护构建 CRUD 屏幕;我们只需使用 ApplicationRoleManager
类公开的函数。有关一个功能齐全的基于角色的访问控制应用程序及其维护屏幕,请参考可下载的示例项目。
[HttpGet]
[OutputCache(NoStore = true, Duration = 0, VaryByParam = "*")]
public PartialViewResult AddPermission2RoleReturnPartialView(int id, int permissionId)
{
ApplicationRoleManager.AddPermission2Role(id, permissionId);
return PartialView("_ListPermissions", ApplicationRoleManager.GetRole(id));
}
整合
现在我们已经自定义了 IdentityDbContext
数据库上下文,并创建了操作数据库以维护用户、角色和权限的类,我们将把所有这些整合起来,作为一个单一的身份验证/授权框架。
在花费更多时间讨论如何扩展授权框架之前,我们需要了解现有的框架本身。 .NET(不仅仅是 ASP.NET)中的安全基于主体的概念。主体对象代表代码运行所在的用户的安全上下文。因此,如果我正在运行一个程序,那么该主体就是我在此程序运行期间的安全上下文。比主体低一个级别的是标识。标识代表正在执行代码的用户。因此,每个主体都有一个标识。这种共同的主体和标识的概念用于基于表单的身份验证、Windows 身份验证以及在 .NET 编程的其他方面向网站和远程主机传递凭据。我们不必实现自己的安全系统,而可以简单地扩展 Microsoft 现有的安全框架。为了方便我们调整现有安全系统,Microsoft 提供了 IPrincipal
和 IIdentity
接口。
我们将使用扩展方法扩展 IPrincipal
,以扩展 ASP.NET Identity 现有的角色功能。我们的扩展方法将提供新功能,通过 HasPermission
和 IsSysAdmin
方法检查角色权限和系统管理员状态,如我们更新的接口的 IntelliSense 屏幕截图所示。
总而言之,IIdentity
用于用户的已验证身份,无论他们拥有什么角色,以及 IsAuthenticated
作为此类的属性实现的原因。IPrincipal
用于在给定的安全上下文中将用户的身份与他们拥有的授权角色结合起来。因此,我们已经扩展了 IPrincipal
对象,并基于授权添加了新功能。这是拥有一个广泛、可继承的框架的一个优点——一旦我们熟悉了该框架,我们就会发现我们的大部分工作已经完成了。
此外,您还可以使用第三方登录提供商,如 Facebook 或 Google,来获取用户的身份,但您不会从这些提供商那里获得主体,因为它们不提供任何角色。但是,我们可以使用我们自己应用程序的基于角色的授权来分配角色,例如 FacebookIdentity
或 GoogleIdentity
。因此,另一个应用程序可以预期一个不同的主体,具有其自己的角色。
现在我们有了 ASP.NET Identity 提供的身份验证功能和我们扩展的授权功能,我们可以按照以下流程图重构我们的 RBACAttribute
类。
Principal Identity 扩展方法
由于我们的 ApplicationUser
和 ApplicationUserManager
类封装了评估用户角色/权限的功能,因此我们需要将此功能暴露给控制器操作方法和视图,同时尽量将代码更改降至最低。最简单的方法是将我们的自定义方法公开为 IPrincipal
对象的可“扩展方法”。
public static class RBAC_ExtendedMethods_4_Principal
{
public static int GetUserId(this IIdentity _identity)
{
int _retVal = 0;
try
{
if (_identity != null && _identity.IsAuthenticated)
{
var ci = _identity as ClaimsIdentity;
string _userId = ci != null ?
ci.FindFirstValue(ClaimTypes.NameIdentifier) : null;
if (!string.IsNullOrEmpty(_userId))
{
_retVal = int.Parse(_userId);
}
}
}
catch (Exception)
{
throw;
}
return _retVal;
}
public static bool HasPermission(this IPrincipal _principal, string _requiredPermission)
{
bool _retVal = false;
try
{
if (_principal != null && _principal.Identity.IsAuthenticated)
{
var ci = _principal.Identity as ClaimsIdentity;
string _userId = ci != null ?
ci.FindFirstValue(ClaimTypes.NameIdentifier) : null;
if (!string.IsNullOrEmpty(_userId))
{
ApplicationUser _authenticatedUser =
ApplicationUserManager.GetUser(int.Parse(_userId));
_retVal = _authenticatedUser.IsPermissionInUserRoles(_requiredPermission);
}
}
}
catch (Exception)
{
}
return _retVal;
}
public static bool IsSysAdmin(this IPrincipal _principal)
{
bool _retVal = false;
try
{
if (_principal != null && _principal.Identity.IsAuthenticated)
{
var ci = _principal.Identity as ClaimsIdentity;
string _userId = ci != null ?
ci.FindFirstValue(ClaimTypes.NameIdentifier) : null;
if (!string.IsNullOrEmpty(_userId))
{
ApplicationUser _authenticatedUser =
ApplicationUserManager.GetUser(int.Parse(_userId));
_retVal = _authenticatedUser.IsSysAdmin();
}
}
}
catch (Exception)
{
}
return _retVal;
}
public static string FindFirstValue(this ClaimsIdentity identity, string claimType)
{
string _retVal = string.Empty;
try
{
if (identity != null)
{
var claim = identity.FindFirst(claimType);
_retVal = claim != null ? claim.Value : null;
}
}
catch (Exception)
{
}
return _retVal;
}
}
重构的 RBACAttribute 类
现在我们已经扩展了 IPrincipal
对象,我们需要对 RBACAttribute
类进行更改,如下文所述。
public class RBACAttribute : AuthorizeAttribute
{
public override void OnAuthorization(AuthorizationContext filterContext)
{
try
{
if (!filterContext.HttpContext.Request.IsAuthenticated)
{
//Redirect user to login page if not yet authenticated.
//This is a protected resource!
filterContext.Result = new RedirectToRouteResult(new RouteValueDictionary(
new { controller = "Account",
action = "Login",
returnUrl = ... }));
}
else
{
//Create permission string based on the requested controller name and
//action name in the format 'controllername-action'
string requiredPermission = String.Format("{0}-{1}",
filterContext.ActionDescriptor.
ControllerDescriptor.ControllerName,
filterContext.ActionDescriptor.ActionName);
if (!filterContext.HttpContext.User.HasPermission(requiredPermission)
& !filterContext.HttpContext.User.IsSysAdmin())
{
//User doesn't have the required permission and is not a SysAdmin,
//return our custom 401 Unauthorised access error. Since we are setting
//filterContext.Result to contain an ActionResult page, the controller's
//action will not be run. The custom 401 Unauthorised access error will
//be returned to the browser in response to the initial request.
filterContext.Result = new RedirectToRouteResult(
new RouteValueDictionary { { "action", "Index" },
{ "controller", "Unauthorised" } });
}
//If the user has the permission to run the controller's action, the
//filterContext.Result will be uninitialized and executing the controller's
//action is dependant on whether filterContext.Result is uninitialized.
}
}
catch (Exception ex)
{
filterContext.Result = new RedirectToRouteResult(new RouteValueDictionary(
new { controller = "Unauthorised",
action = "Error", _errorMsg = ex.Message }));
}
}
}
与我们之前的示例一样,对受保护资源的访问请求会根据请求用户的权限进行检查;任何未与所需权限关联的用户都将被重定向到“未经授权的请求”页面。
然而,在检查用户的资源权限之前,RBACAttribute
类需要检查请求用户是否已获得身份验证。未获得身份验证的用户将被重定向到登录页面;我们为内网环境实现的原始 RBACAttribute
类假定用户必须已通过 Windows 身份验证才能到达这一点。表单身份验证要求我们的应用程序进行必要的身份验证检查,而这部分由 ASP.NET Identity 完成。
注意:只有当控制器或控制器操作使用 RBACAttribute
属性进行修饰时,RBACAttribute
类才会被触发。任何未用该属性进行修饰的资源都允许未经身份验证的用户访问。对于某些控制器或控制器操作,这可能是期望的结果。
限制对 MVC 控制器操作方法的访问
现在,只需将您的控制器操作方法用 RBACAttribute
授权过滤器进行修饰即可保护资源;MVC 允许您在修饰控制器操作时省略授权属性的 Attribute
动词,以便那些喜欢这种命名约定的用户可以简单地使用 RBAC
。授权属性指示 MVC 在调用操作方法之前执行逻辑,其中逻辑检查请求用户是否已获得身份验证并拥有执行控制器操作方法所需的应用程序权限。
[RBAC]
public ActionResult Reports()
{
return View();
}
或者
[RBACAttribute]
public ActionResult Reports()
{
return View();
}
如果用户尝试访问用此属性修饰的控制器操作方法,将在执行操作方法之前进行如下检查:
- 如果用户未获得身份验证,则用户将被重定向到“用户登录”页面。
- 如果用户已获得身份验证,并且用户的角色不包含所需的资源权限,则用户将被重定向到“未经授权”页面,如下所示。
- 如果用户已获得身份验证,并且用户的角色包含所需的资源权限,则用户被“允许”访问该资源。
身份验证检查
要从控制器视图中检查用户是否已获得身份验证,我们只需引用 IIdentity
对象公开的 IsAuthenticated
属性。此功能由 ASP.NET Identity 提供。
上面的示例显示了用户未登录应用程序时的“注册”和“登录”菜单链接,否则将显示用户名以及“登出”菜单链接。LoginPartial.cshtml 部分视图引用了 IsAuthenticated
属性。
授权检查
要检查已获得身份验证的用户是否具有资源权限或是否被分配为“系统管理员”的角色,我们只需调用我们扩展方法公开的 HasPermission
和 IsSysAdmin
方法。此功能由 RBAC 框架为我们提供。
如果用户登录应用程序并被分配了标记为“系统管理员”的角色(通过我们自定义的 ApplicationRole
类中的 IsSysAdmin
属性识别),则将显示“系统管理”菜单链接,如示例所示。此检查通过 User.Identity.IsSysAdmin()
进行,但我们也可以通过 User.Identity.HasPermission("controller-action")
检查资源访问,并通过 User.IsInRole("Supervisor")
检查用户是否属于某个角色。
示例项目
可下载的示例项目是使用 **Visual Studio 2013** 创建的,目标框架设置为 **.NET Framework 4.5.1**;该项目未在更高版本的 Visual Studio 中针对更新的框架进行测试,但如果将目标框架设置为 **.NET Framework 4.5.1**,则可以使用更高版本的 Visual Studio。该项目实现了 AdminController
,该控制器提供了用户、角色和权限所需的 RBAC 管理。
RBAC 管理通过“系统管理”菜单公开,如下所示,并且仅对具有 IsSysAdmin
选项启用的角色的用户可见。
有关示例项目的操作性概述或如何将 RBAC 功能添加到现有应用程序,请参阅 第一部分 – 示例项目。
RBAC 扩展方法
用于注册和登录已注册帐户的 RBAC 功能已在 RBAC_ExtendedMethods
类中定义为 ControllerBase
类的扩展方法。这将使我们能够通过简单地调用 this.Register(...)
和 this.Login(...)
来在我们的 MVC 控制器方法中调用该功能,每个调用将返回一个 RBACStatus
状态码。逻辑由应用程序配置文件中定义的设置驱动。
public enum RBACStatus
{
Success = 0,
LockedOut = 1,
RequiresVerification = 2,
Failure = 3,
EmailVerification = 4,
PhoneVerification = 5,
RequiresAccountActivation = 6,
EmailUnconfirmed = 7,
PhoneNumberUnconfirmed = 8,
InvalidToken = 9,
}
public static class RBAC_ExtendedMethods
{
public static RBACStatus Register
(this ControllerBase controller, RegisterViewModel model, ...)
{
RBACStatus _retVal = RBACStatus.Failure;
try
{
//Logic driven by settings defined in the application’s configuration file...
int _userId = RBAC_ExtendedMethods.RegisterUser
(controller, model, userMngr, out _errors);
if (_userId > -1)
{
model.Id = _userId;
if (userMngr != null)
{
//Check if we require an Account Verification Email as part of our
//registration process...
bool IsAccountVerificationRequired =
GetConfigSettingAsBool(cKey_AccountVerificationRequired);
bool Is2FAEnabled = GetConfigSettingAsBool(cKey_2FAEnabled);
string DeviceType = GetConfigSetting(cKey_2FADeviceType);
if ((IsAccountVerificationRequired) || (Is2FAEnabled && DeviceType == c_EmailCode))
{
//Generate Email Confirmation Token
_retVal = RBACStatus.Failure;
if (SendOTP2Email(controller, userMngr, _userId, model.Email))
_retVal = RBACStatus.RequiresAccountActivation;
return _retVal;
}
else if (Is2FAEnabled && DeviceType == c_PhoneCode)
{
_retVal = RBACStatus.Failure;
if (SendOTP2Phone(controller, userMngr, _userId, model.Mobile))
_retVal = RBACStatus.PhoneVerification;
return _retVal;
}
}
_retVal = RBACStatus.Success;
}
}
catch (Exception ex)
{
throw ex;
}
return _retVal;
}
public static RBACStatus Login(this ControllerBase controller, LoginViewModel model, ...)
{
RBACStatus _retVal = RBACStatus.Failure;
_errors = new List<string>();
try
{
var user = userMngr.FindByName(model.UserName);
if (user != null)
{
var validCredentials = userMngr.Find(model.UserName, model.Password);
if (userMngr.IsLockedOut(user.Id))
{
_errors.Add(string.Format(c_AccountLockout,
GetConfigSettingAsDouble(cKey_AccountLockoutTimeSpan)));
return RBACStatus.LockedOut;
}
else if (userMngr.GetLockoutEnabled(user.Id) && validCredentials == null)
{
userMngr.AccessFailed(user.Id);
if (userMngr.IsLockedOut(user.Id))
{
_errors.Add(string.Format(c_AccountLockout,
GetConfigSettingAsDouble(cKey_AccountLockoutTimeSpan)));
return RBACStatus.LockedOut;
}
else
{
int _attemptsLeftB4Lockout = (GetConfigSettingAsInt
(cKey_MaxFailedAccessAttemptsBeforeLockout) –
userMngr.GetAccessFailedCount(user.Id));
_errors.Add(string.Format(c_InvalidCredentials, _attemptsLeftB4Lockout));
return _retVal;
}
}
else if (validCredentials == null)
{
_errors.Add(c_InvalidLogin);
return _retVal;
}
else
{
//Valid credentials entered,
//we need to check whether email verification is required...
bool IsAccountVerificationRequired =
GetConfigSettingAsBool(cKey_AccountVerificationRequired);
bool Is2FAEnabled = GetConfigSettingAsBool(cKey_2FAEnabled);
string DeviceType = GetConfigSetting(cKey_2FADeviceType);
if ((IsAccountVerificationRequired) || (Is2FAEnabled && DeviceType == c_EmailCode))
{
//Check if email verification has been confirmed!
if (!userMngr.IsEmailConfirmed(user.Id))
{
//Display error message on login page, take no further action...
_errors.Add(c_AccountEmailUnconfirmed);
return RBACStatus.EmailUnconfirmed;
}
}
else if (Is2FAEnabled && DeviceType == c_PhoneCode)
{
if (!userMngr.IsPhoneNumberConfirmed(user.Id))
{
_errors.Add(c_AccountPhoneNumberUnconfirmed);
return RBACStatus.PhoneNumberUnconfirmed;
}
}
bool _userLockoutEnabled = GetConfigSettingAsBool(cKey_UserLockoutEnabled);
//Before we signin, check that our 2FAEnabled config setting agrees with
//the database setting for this user
if (Is2FAEnabled != userMngr.GetTwoFactorEnabled(user.Id))
{
userMngr.SetTwoFactorEnabled(user.Id, Is2FAEnabled);
}
_retVal = (RBACStatus)signInMngr.PasswordSignIn(model.UserName,
model.Password, model.RememberMe,
shouldLockout: _userLockoutEnabled);
switch (_retVal)
{
case RBACStatus.Success:
{
userMngr.ResetAccessFailedCount(user.Id);
break;
}
default:
{
_errors.Add(c_InvalidLogin);
break;
}
}
}
}
else
{
_errors.Add(c_InvalidUser);
}
}
catch (Exception ex)
{
throw ex;
}
return _retVal;
}
public static bool SendOTP2Phone(this ControllerBase controller,
ApplicationUserManager _userMngr, int _userId,
string _phoneNumber)
{
bool _retVal = false;
if (_userMngr.SmsService != null)
{
//Generate security code for phone confirmation
var code = _userMngr.GenerateChangePhoneNumberToken(_userId, _phoneNumber);
var message = new IdentityMessage
{
Destination = _phoneNumber,
Body = "Your security code is: " + code
};
//Send the security code
_userMngr.SmsService.Send(message);
_retVal = true;
}
else
{
throw new Exception
("SMS Service has not been configured, unable to text notification...");
}
return _retVal;
}
...
}
数据库初始化
样本应用程序在第一次运行时将默认自动创建数据库。但是,有四种不同的数据库初始化方法:
CreateDatabaseIfNotExists
- 这是默认的初始化程序。顾名思义,它会根据配置创建不存在的数据库。但是,如果您更改模型类然后运行此初始化程序,则会抛出异常。DropCreateDatabaseIfModelChanges
- 如果您的模型类(实体类)已更改,此初始化程序将删除现有数据库并创建一个新数据库。因此,当您的模型类发生更改时,您无需担心维护数据库架构。DropCreateDatabaseAlways
- 顾名思义,此初始化程序每次运行应用程序时都会删除现有数据库,而不管您的模型类是否已更改。这在您每次运行应用程序时都想要一个新数据库时很有用,例如在开发应用程序时。CustomDBInitializer
- 如果以上任何方法不满足您的要求,或者您想执行其他初始化数据库的过程,您也可以创建自己的自定义初始化程序。
要使用以上数据库初始化方法之一,您必须通过 Database.SetInitializer
设置数据库初始化程序类型,如下例所示:
填充具有初始用户的数据库
我们可以在数据库初始化过程中将数据插入数据库表中。如果您想为您的应用程序提供一些测试数据或为您的应用程序提供一些默认主数据,这将很重要。
为了将数据填充到数据库中,我们必须创建一个自定义数据库初始化程序并重写 Seed
方法。以下示例显示了如何在 RBAC 数据库初始化期间为 Users/Roles/Permissions 表提供默认数据。
public class RBACDatabaseInitializer : CreateDatabaseIfNotExists<RBACDbContext>
{
private readonly string c_SysAdmin = "System Administrator";
private readonly string c_DefaultUser = "Default User";
protected override void Seed(RBACDbContext context)
{
//Create Default Roles...
IList<ApplicationRole> defaultRoles = new List<ApplicationRole>();
defaultRoles.Add(new ApplicationRole { Name = c_SysAdmin, RoleDescription = "..." });
defaultRoles.Add(new ApplicationRole { Name = c_DefaultUser, RoleDescription = "..." });
ApplicationRoleManager RoleManager = new ApplicationRoleManager(
new ApplicationRoleStore(context));
foreach (ApplicationRole role in defaultRoles)
{
RoleManager.Create(role);
}
//Create Admin User...
var user = new ApplicationUser { UserName = "Admin", ... };
ApplicationUserManager UserManager = new ApplicationUserManager(
new ApplicationUserStore(context));
var result = UserManager.Create(user, "Pa55w0rd");
if (result.Succeeded)
{
//Add User to Admin Role...
UserManager.AddToRole(user.Id, c_SysAdmin);
}
base.Seed(context);
}
}
可下载的示例项目使用上述方法向数据库插入了几个用户。这使得可以使用与不同角色/权限关联的不同用户登录应用程序以进行演示。示例项目使用以下用户及其关联的角色/权限初始化数据库。
应用程序用户 | ||||
用户名 | 密码 | 分配的角色 | IsSysAdmin | 分配的权限 |
Admin | Pa55w0rd | 系统管理员 | 是 | 无 |
Guest | Gu3st12 | 默认用户 | 否 | Home-Reports |
GuestNoRoles | Us3rNoRol3s | 无 | 否 | 无 |
“系统管理员”可以创建具有关联权限的应用程序角色,然后将用户分配给这些角色。根据您的应用程序需求,您可以允许用户通过应用程序的“注册”功能自行注册,在注册过程中分配默认角色,或者“系统管理员”根据请求注册用户并分配所需角色。上面的代码片段说明了通过 ApplicationUserManager
和 ApplicationRoleManager
类创建用户并将角色分配给该用户。
身份验证模型
在查看示例代码片段之前,我们需要了解不同的身份验证模型。
传统身份验证
“传统身份验证”模型提供了最简单的身份验证机制,但安全性最低。传统用户名和密码登录的最大问题之一是需要维护密码数据库。无论是加密还是未加密,如果数据库被捕获,它将为攻击者提供一个验证其猜测的来源,其速度仅受其硬件资源的限制。随着 CPU 处理速度的提高,暴力攻击已成为真正的威胁。只要有足够的时间,捕获的密码数据库就会被破解。
帐户锁定(也称为入侵者检测)是密码安全的一项功能,该功能可在一定时间间隔内输入错误密码导致一定次数的登录失败后禁用用户帐户。帐户锁定的目的是防止攻击者使用暴力尝试来猜测用户的密码;过多的错误猜测会导致帐户被锁定。一旦帐户被锁定,该用户将不允许进行身份验证。锁定可能是暂时的(在指定时间段后自动结束)或永久的(直到管理员重置用户密码)。
如果您选择应用程序的传统身份验证安全系统,强烈建议使用 ASP.NET Identity 的帐户锁定功能,否则您的应用程序将成为暴力攻击的目标。
双因素身份验证 (2FA)
双因素身份验证(也称为双步身份验证)是指通过两种不同类别的身份验证元素(“你拥有的东西”(拥有)、“你是什么”(内在)和“你知道的东西”(知识))进行的身份验证机制。
一个很好的例子是 Gmail 所需的双因素身份验证。在提供您记住了的密码后,您还需要提供手机上显示的单次代码。虽然手机可能看起来是“你拥有的东西”,但从安全角度来看,它仍然是“你知道的东西”。这是因为身份验证的关键不是设备本身,而是存储在设备上的信息,理论上可以被攻击者复制。因此,通过复制您记住的密码和 OTP 配置,攻击者就可以在不实际窃取任何物理设备的情况下成功冒充您。另一个很好的例子是从自动取款机取钱。只有银行卡(“你拥有的东西”)和 PIN 码(“你知道的东西”)的正确组合才允许进行交易。同样,这种身份验证机制可能被 ATM 欺诈和网络钓鱼所破坏,在这种情况下,攻击者可以在不实际窃取您的物理银行卡的情况下冒充您。
移动电话双因素身份验证是为了提供一种可以避免这些问题的替代方法而开发的。这种方法使用移动设备(如手机和智能手机)作为“你拥有的东西”。如果用户希望验证自己的身份,他们可以使用他们记住的密码加上一个一次性有效、动态的数字代码。代码可以通过 SMS 发送到他们的移动设备。这种方法的优点是不需要额外的专用令牌,因为用户倾向于一直随身携带他们的移动设备。一些专业的双因素身份验证解决方案还确保始终为用户提供有效的密码。如果用户已经使用了一系列数字(密码),该序列将被自动删除,系统会将新代码发送到移动设备。此外,如果在指定时间限制内未输入新代码,系统将自动替换它。这确保了移动设备上不会保留旧的、以前使用的代码。为了增加安全性,可以指定在系统阻止访问之前允许的最大错误输入次数。
要在示例项目中实现“双因素身份验证”功能,请在项目的 Web.config 文件中配置以下详细设置。
多因素身份验证 (MFA)
多因素身份验证是一种安全系统,它需要来自不同凭证类别的多种身份验证方法来验证用户登录或其他交易的身份。
多因素身份验证的要点,以及严格区分的原因是,攻击者必须成功完成两种不同类型的盗窃才能冒充您:例如,他必须同时获取您的知识和您的物理设备。在多步(但非多因素)的情况下,攻击者只需要完成一种类型的盗窃,只是多次。因此,例如,他需要窃取两条信息,但没有物理对象。
Google、Facebook 或 Twitter 提供的多步身份验证类型仍然足够强大,可以阻止大多数攻击者,但从纯粹主义者的角度来看,它在技术上并非多因素身份验证。
用户注册
“传统身份验证”模型提供了最简单的注册过程,但安全性最低。如果您认真创建在线网站并保持其安全,那么您就不应忽略安全性,特别是如果您计划使用协作博客或群组博客功能,因为这将对您的在线社区构成严重的垃圾邮件发送者和垃圾博客发送者的风险。
仅仅允许用户在您的网站上注册帐户而不进行任何验证检查,例如使用确认电子邮件来验证电子邮件地址的所有权,将导致大规模帐户注册。大多数此类注册将来自僵尸网络、受感染的计算机、恶意软件和黑客。如果没有采取保护措施,您新建立的社区将面临被试图销售假冒产品的垃圾邮件发送者淹没的风险。由于虚假用户很容易构成所有新用户帐户请求的 98%,因此制定一个阻止或减缓这些虚假注册的计划是很有用的。
您至少应要求用户通过注册时提供的地址发送的电子邮件来激活其帐户。此简单检查将剔除使用从网上抓取的、他们无权访问的电子邮件地址的垃圾博客发送者和垃圾邮件发送者,但不会阻止拥有有效电子邮件地址的用户。
除了使用确认电子邮件验证电子邮件地址的所有权外,您还可以实施其他安全措施来帮助识别垃圾邮件机器人,如下文所述。
- 诱饵通过在注册表单上创建垃圾邮件机器人无法抗拒的隐藏字段,然后在表单提交时检查这些字段的输入来工作。
- “人类测试”要求用户回答随机问题,例如“太空是什么颜色的?”,这应该会让垃圾邮件机器人感到困惑。
- 验证码无处不在,并要求用户辨认扭曲图像中的字母。
在您的注册表单上实施这些额外的安全功能超出了本文的范围。但是,您选择哪个选项(理论上可以全部采用)取决于您的偏好。诱饵的优点是侵入性最低;“真实”用户甚至不知道它们在那里。人类测试可能会让您的用户感到惊讶,因为它们不寻常,但可以有效地阻止垃圾邮件机器人。验证码随处可见,因此,虽然它们可能很烦人,但至少它们以一种熟悉的方式惹恼了您的用户。这三种策略只会阻止垃圾邮件机器人,因为人工垃圾邮件发送者将能够绕过任何一种。
工作流程逻辑
RBAC 框架实现了以下用户注册工作流程逻辑,其中帐户锁定和双因素身份验证等功能可通过项目的 Web.config 文件进行配置。
![]() | 用户注册过程的第一步是验证检查,以确认是否已正确输入密码等数据字段。默认情况下,ASP.NET Identity 中的密码策略要求密码至少包含 6 个字符,至少包含一个非字母或数字字符,至少包含一个数字('0'-'9')和至少一个大写字母('A'-'Z')。验证检查强制使用强密码。 密码策略可以通过以Password 关键字(例如, 帐户检查将确保用户指定的用户名和电子邮件地址尚未被先前注册的帐户使用。验证和帐户检查都由 ASP.NET Identity 在通过 所有检查成功后,通过调用 ASP.NET Identity 的 最后,用户被邀请通过单击电子邮件中包含的链接来确认在注册过程中指定的电子邮件地址。确认电子邮件地址将激活帐户。 |
在帐户通过电子邮件中的唯一激活链接激活之前,用户无法登录网站。用于验证电子邮件地址所有权的电子邮件验证功能将剔除使用虚假电子邮件地址或他们无权访问的从网上抓取的电子邮件地址的虚假用户。
Register 扩展方法
“用户注册”表单将表单的 RegisterViewModel
发布到帐户控制器中的“Register
” 方法。Register
方法调用 RBAC 框架的一部分 Register
扩展方法;该方法创建一个帐户,并(取决于您应用程序的配置设置)发送验证请求。
以下代码示例说明了调用扩展方法和处理返回的 RBACStatus
的相应操作结果。
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Register(RegisterViewModel model)
{
if (ModelState.IsValid)
{
List<string> _errors = new List<string>();
try
{
RBACStatus _retVal = this.Register(model, this.UserManager,
this.SignInManager, out _errors);
switch (_retVal)
{
case RBACStatus.Success:
{
ViewBag.Message = "Your account has been created successfully.
You can now continue and login...";
return View("Confirmation");
}
case RBACStatus.RequiresAccountActivation:
{
ViewBag.Username = model.UserName;
ViewBag.Email = model.Email;
return View("ConfirmEmailSent");
}
case RBACStatus.EmailVerification:
{
return RedirectToAction
("RequestEmailVerification", new { Username = model.UserName });
}
case RBACStatus.PhoneVerification:
{
return RedirectToAction("OTP4PhoneVerification",
new { UserId = model.Id, phoneNumber = model.Mobile }); }
}
}
catch (Exception ex)
{
AddErrors(new IdentityResult(ex.Message));
}
if (_errors.Count() > 0)
{
AddErrors(new IdentityResult(_errors));
}
}
//If we got this far, something failed, redisplay form
//Errors will be displayed back to the user
//because we have set the ModelState object with our _errors list...
return View(model);
}
通过电子邮件验证进行帐户激活
要在示例项目中配置电子邮件验证功能以验证电子邮件地址的所有权,请在项目的 Web.config 文件中配置以下详细设置。
<appSettings>
...
<add key="AccountVerificationRequired" value="true" />
</appSettings>
通过将 AccountVerificationRequired
设置为 true
,我们正在全局配置应用程序,要求每个创建的用户都必须通过电子邮件确认来激活帐户,以验证其电子邮件地址的所有权。电子邮件包含一个嵌入在激活链接中的令牌,该链接指向我们的网站。要禁用此功能,请将设置配置为 false
或完全从项目的 Web.config 文件中删除该设置。
注意:如果启用了双因素身份验证,并且双因素身份验证设备类型配置为电子邮件,则无需配置此选项,因为对于此配置,电子邮件验证是强制性的。
在帐户注册过程中,将向指定的电子邮件地址发送帐户激活电子邮件。激活链接将包含帐户的 userId
和唯一生成的令牌代码。单击激活链接后,这两个参数都将传递到我们的 ASP.NET 站点,调用 Account
控制器中的 ConfirmEmail
操作方法。
尝试在未激活帐户的情况下登录将导致错误,如下所示。
http://.../Account/ConfirmEmail?userId=104&code=p%2F3IHpCeF%2BajSQ70ghGE4mKWGn...
单击电子邮件中的激活链接会将 USERS
表中定义的 EmailConfirmed
字段设置为该用户帐户的 true
,并显示插图中的确认屏幕。
要配置电子邮件验证功能的 SMTP 服务器设置,请在项目的 Web.config 文件中配置以下详细设置。
<appsettings>
...
<!-- Smtp Server Settings -->
<add key="SmtpEMailFrom" value="admin@somedomain.com">
<add key="SmtpServer" value="smtp.live.com">
<add key="SmtpPort" value="587">
<add key="SmtpUsername" value="youremailaddress">
<add key="SmtpPassword" value="youraccountpassword">
<add key="SmtpNetworkDeliveryMethodEnabled" value="true">
</appsettings>
<add key="SmtpEMailFrom" value="admin@somedomain.com"><add key="SmtpServer" value="smtp.live.com"><add key="SmtpPort" value="587"><add key="SmtpUsername" value="youremailaddress"><add key="SmtpPassword" value="youraccountpassword"><add key="SmtpNetworkDeliveryMethodEnabled" value="true">
SmtpEMailFrom
的值将定义发送的任何电子邮件的发件人地址。
SmtpServer
设置指定用于 SMTP 事务的主机 SMTP 邮件服务器的名称或 IP 地址。这可以是您公司网络上的 SMTP 邮件服务器,也可以是您的电子邮件服务提供商(如 Hotmail 或 Gmail)提供的 SMTP 邮件服务器。
SmtpPort
的整数值指定连接到 SMTP 邮件服务器使用的端口号。
SmtpUsername
设置指定用于向 SMTP 邮件服务器进行身份验证的用户名。同样,SmtpPassword
设置存储用于向 SMTP 邮件服务器进行身份验证的密码。
SmtpNetworkDeliveryMethodEnabled
设置指定电子邮件是否通过网络发送到 SMTP 服务器。
如果您打算使用 Hotmail、GMail 或 Yahoo 作为您的 Smpt 提供商,而不是使用您的组织的邮件服务器,您可以使用表中提供的以下 SmtpServer
和 SmtpPort
设置:
Smtp 服务器设置 | ||||
提供商 | Smtp 服务器 | Smtp 端口 | SSL | |
Hotmail | smtp.live.com | 587 | 是 | |
GMail | smtp.gmail.com | 587 | 是 | |
Yahoo | smtp.mail.yahoo.com | 587 | 是 |
通过基于时间的单次密码 (TOTP) 令牌使用 2FA 进行帐户验证
要在示例项目中配置基于时间的单次密码 (TOTP) 和双因素身份验证 (2FA) 功能,请在项目的 Web.config 文件中配置以下详细设置。
<appsettings>
...
<!-- 2FA Settings -->
<add key="2FAEnabled" value="true">
<!-- 'Email Code' or 'Phone Code' -->
<add key="2FADeviceType" value="Phone Code">
</appsettings>
2FAEnabled
设置定义了在用户登录过程中是否强制执行 2FA。将值设置为 true
将全局强制要求每个创建的用户都必须进行 2FA。
2FADeviceType
设置定义了要发送基于时间的单次密码 (TOPT) 令牌的设备类型。目前,示例项目已实现支持电子邮件或移动设备。设置“Email Code”将导致令牌发送到指定的电子邮件,设置“Phone Code”将通过 SMS 将 TOPT 令牌发送到指定的移动设备。您需要根据解决方案中选择的传递方法,在注册过程中捕获电子邮件地址或移动电话号码。
注意:ASP.NET Identity 使用 RFC 6238 基于时间的单次密码算法 (TOTP) 的实现来生成双因素身份验证使用的 PIN。生成的 PIN 为 6 位数字,有效期为 180 秒(3 分钟)。如果为某个帐户请求了多个 PIN(又名安全代码),则只要在时间限制内输入 PIN,就可以使用生成的任何 PIN 进行双因素身份验证过程。
在输入有效的安全代码之前,注册过程不会继续。 输入有效的安全代码将完成注册过程并使该用户登录到网站。ASP.NET Identity 框架会将 | ![]() |
如果用户尝试在未通过发送到其移动设备的密码验证身份的情况下登录,用户将被重定向到“验证电话号码”页面,并显示错误消息(下图所示)。将向与帐户关联的电话号码发送新的安全代码(请记住,安全代码是基于时间的,发送到用户手机的原始安全代码可能已过期)。
用户登录
许多登录页面都成为黑客的目标,他们想访问您的网站,甚至可能将其关闭。就像您离家前锁门一样,您也应该有一个安全系统,作为您网站抵御黑客攻击的第一道防线。
切换到 HTTPS
HTTPS 或 Hyper Text Transfer Protocol Secure 是一种安全通信协议,用于在网站和 Web 服务器之间传输敏感信息。确保您的网站使用 HTTPS 协议,基本上意味着在您的 HTTP 上添加一个 **TLS**(传输层安全)或 **SSL**(安全套接字层)的加密层,使您的用户和您自己的数据免受黑客攻击。
虽然 HTTPS 对于所有在线交易都是必需的,但网站的其余部分通常是 HTTP。然而,随着 Google 最近宣布 HTTPS 将成为搜索排名因素,这一切都将发生改变。除了安全性之外,将您的整个网站迁移到 HTTPS 以同时提高搜索排名现在更有意义了。
密码强度
过去几年,试图猜测用户名和密码组合的暴力攻击呈指数级增长,每天在网上检测到数千次攻击。使用强密码是限制(如果不是完全消除)暴力攻击和字典攻击的有效方法。密码强度是密码抵抗猜测和暴力攻击有效性的衡量标准。确保您的密码策略是字母数字字符、符号、大小写字母的组合,并且至少有 12 个字符长,以防止暴力攻击。使用强密码可降低安全漏洞的总体风险,但强密码不能取代其他有效安全控制的需要,例如在发生多次失败登录尝试时强制执行的帐户锁定。
工作流程逻辑
RBAC 框架实现了以下用户登录工作流程逻辑,其中帐户锁定等功能可通过项目的 Web.config 文件进行配置。但是,如果工作流程不符合您的要求,您可以自由定制工作流程并实施由您的业务要求定义的定制逻辑,但对于大多数场景,以下工作流程已足够。
![]() | 在用户登录过程中,工作流程将通过 userMngr.FindByName(UserName) 方法定位帐户记录。在验证输入的密码并在报告验证检查结果之前,将调用 userMngr.IsLockedOut(user.Id) 方法来检查帐户是否已被锁定。此功能可通过项目 Web.config 文件中定义的 UserLockoutEnabled 配置设置禁用。如果帐户被锁定,将向用户报告错误消息。在锁定到期(定时锁定)或由帐户管理员手动重置(永久锁定)之前,将不允许访问该站点。 如果输入了错误的密码并且配置了帐户锁定功能,失败的登录尝试计数器将递增,剩余的登录尝试次数将报告给用户。在锁定帐户之前允许用户重试的失败登录尝试次数通过 如果输入了正确的密码并且帐户未被锁定,则通过 如果帐户已验证并且双因素身份验证已启用,将生成一个安全 PIN 并发送到已验证的设备类型(即电子邮件或电话)。生成的 PIN 为 6 位数字,有效期为 180 秒(3 分钟)。有效的设备类型通过 |
验证用户提交的 PIN 是工作流程中的最后一步。我们验证 cookie 中是否存在 userId
,这表明这是一个有效请求。我们调用 SignInMngr.TwoFactorSignInAsync(…)
方法来检查这是否是此用户的有效 PIN。如果是,我们将用户登录到应用程序;否则,我们将报告无效代码错误。用户有 3 分钟的时间输入正确的 PIN,否则需要通过再次登录过程重新生成新的 PIN。
Login 扩展方法
“用户登录”表单将表单的 LoginViewModel
发布到帐户控制器中的“Login
”方法。Login
方法调用 RBAC 框架的一部分 Login
扩展方法;该方法执行前面登录工作流程中定义的检查。成功登录会将 IIdentity
对象公开的 IsAuthenticated
属性设置为“true”,用于确定当前用户的身份验证状态。
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<actionresult> Login(LoginViewModel model, string returnUrl)
{
if (ModelState.IsValid)
{
List<string> _errors = new List<string>();
try
{
RBACStatus _retVal =
this.Login(model, this.UserManager, this.SignInManager, out _errors);
switch (_retVal)
{
case RBACStatus.Success:
return RedirectToLocal(returnUrl);
case RBACStatus.EmailUnconfirmed:
{
//Do nothing, message will be display on login page...
break;
}
case RBACStatus.PhoneNumberUnconfirmed:
{
var user = UserManager.FindByName(model.UserName);
if (user != null)
{
if (this.SendOTP2Phone(this.UserManager, user.Id, user.PhoneNumber))
return RedirectToAction
("OTP4PhoneVerification", new { UserId = user.Id, phoneNumber = ... });
}
break;
}
case RBACStatus.RequiresVerification:
return RedirectToAction("SendSecurityCode",
new { ReturnUrl = returnUrl, RememberMe = model.RememberMe });
}
}
...
}
// If we reach this point, something failed, redisplay form displaying error message(s)...
return View(model);
}</string>
郑重警告……重要的是要理解,到达登录控制器操作的 Http 请求将在 Http 请求头中包含明文的用户名和密码。任何嗅探您与 Web 服务器之间网络流量的人都可以看到您的用户名和密码的明文。这显然不安全,而且是重大的安全漏洞!任何信誉良好的网站都会强制其用户通过 SSL 登录。网站所有者将获取并安装 SSL 证书到他们的 Web 服务器上,从而强制登录过程通过 Https 进行。要强制请求通过 Https 进行,我们只需在函数前面加上 [RequireHttps]
属性。这是一个操作过滤器,它将确保到达的请求通过安全的加密连接发送,从而保证密码不会以明文发送。
[HttpPost]
[AllowAnonymous]
[RequireHttps]
public ActionResult Login(LoginModel model)
{
...
}
注意:在 Web 服务器上未安装 SSL 证书的情况下,在函数前面加上 <tt>[RequireHttps]</tt>
属性将导致访问安全资源时出错。获取和安装 SSL 证书不在本文档的范围内,因为这是一个独立的话题。
帐户锁定已启用
要在示例项目中实现“帐户锁定”功能,请在项目的 Web.config 文件中配置以下详细设置。
<appsettings>
...
<!-- Account Lockout Settings -->
<add key="UserLockoutEnabled" value="true">
<add key="AccountLockoutTimeSpan" value="15">
<add key="MaxFailedAccessAttemptsBeforeLockout" value="3">
</appsettings>
通过将 UserLockoutEnabled
设置为 true
,我们正在全局配置应用程序,要求每个创建的用户在达到最大失败登录尝试次数时都将受到锁定。
AccountLockoutTimeSpan
的值以分钟为单位,表示在允许用户再次登录之前的等待时间(在达到最大失败登录尝试次数后)。
MaxFailedAccessAttemptsBeforeLockout
设置是允许用户在被锁定之前重试的登录次数。
如果用户在指定尝试次数后继续输入错误的密码,帐户将被锁定,并出现以下错误消息。帐户锁定期间可以缩短或延长,或者无限期锁定直到帐户管理员进行密码重置。
已启用 2FA
要在示例项目中实现“双因素身份验证”功能,请在项目的 Web.config 文件中配置以下详细设置。
<appsettings>
...
<!-- 2FA Settings -->
<add key="2FAEnabled" value="true">
<!-- 'Email Code' or 'Phone Code' -->
<add key="2FADeviceType" value="Email Code">
</appsettings>
在登录过程中,用户正确输入用户名和密码后,将显示以下 2FA 屏幕,提供已注册的双因素身份验证设备类型。双因素身份验证提供商下拉列表最初将包含 2FADeviceType
配置设置中定义的提供商。但是,在用户通过安全代码成功验证 2FA 后,他们可以通过其帐户管理功能为自己的帐户添加新设备类型。一旦新设备类型得到验证,它将在将来的登录中作为双因素身份验证提供商可用。
public async Task<ActionResult> SendSecurityCode(string returnUrl, bool rememberMe)
{
var userId = await SignInManager.GetVerifiedUserIdAsync();
if (userId == null)
{
return View("Error");
}
var userFactors = await UserManager.GetValidTwoFactorProvidersAsync(userId);
var factorOptions = userFactors.Select
(purpose => new SelectListItem { Text = purpose, Value = purpose }).ToList();
return View(new SendCodeViewModel
{ Providers = factorOptions, ReturnUrl = returnUrl, RememberMe = rememberMe });
}
未为帐户验证的设备类型不会显示在双因素身份验证提供商下拉列表中。
如前一节所述,用于通过电子邮件发送安全代码的电子邮件服务器帐户设置在项目 Web.config 文件中的 Smtp 服务器设置部分定义。同样,用于通过 SMS 发送安全代码的 SMS 服务器帐户设置在 SMS 帐户设置部分定义,如下所示。
<appsettings>
...
<!-- SMS Account Settings -->
<add key="SMSSid" value="CB857cc79915e645eca614b79422de127f">
<add key="SMSToken" value="f10a4768e71d008b68a61599297780bc">
<add key="SMSFromPhone" value="+441784609586">
</appsettings>
通过电子邮件发送的安全代码
如果选择“Email Code”作为双因素身份验证提供商,安全代码将通过电子邮件发送。
通过 SMS 发送的安全代码
如果选择“Phone Code”作为双因素身份验证提供商,安全代码将通过 SMS 发送到您的手机号码。移动号码可以是注册过程中必需的强制要求,包括设备验证,或者可以在注册后添加到帐户。系统的配置将由您的业务流程驱动。 如果您要求帐户注册通过手机号码进行验证而不是通过电子邮件链接,请将应用程序的配置设置如下: |
<appsettings>
...
<add key="2FAEnabled" value="true">
<add key="2FADeviceType" value="Phone Code">
<add key="AccountVerificationRequired" value="true">
</appsettings>
注意:唯一可用的 2FA 提供商将是 Phone Code。您需要更改注册视图 Register.cshtml 以包含数据模型 RegisterViewModel
中定义的移动号码字段 @Html.TextBoxFor(m => m.Mobile)
,该字段需要强制填写。通过传递到视图的数据模型中数据属性的 [Required]
属性使字段成为强制性的。
另一方面,如果您要求帐户注册通过电子邮件链接进行验证,并且用户可以选择稍后为 2FA 添加移动号码,请将应用程序的配置设置如下:
<appsettings>
...
<add key="2FAEnabled" value="true">
<add key="2FADeviceType" value="Email Code">
<add key="AccountVerificationRequired" value="true">
</appsettings>
安全代码验证
需要在选定的传递方式(SMS 或电子邮件)发送的安全代码才能验证用户的身份。
选中记住此浏览器复选框将使登录该计算机和浏览器的用户无需使用 2FA 即可登录。启用 2FA 并选中记住此浏览器将在用户不访问用户计算机的情况下,为用户提供强大的 2FA 保护,以防恶意用户尝试访问其帐户。这可以在任何常用的私有计算机上完成。通过不在不经常使用的计算机上设置“记住此浏览器”,他们可以从 2FA 的附加安全中受益,并且可以方便地无需在自己的计算机上执行 2FA。
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<actionresult> VerifySecurityCode(VerifyCodeViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var result = await SignInManager.TwoFactorSignInAsync
(model.Provider, model.Code, isPersistent: model.RememberMe,
rememberBrowser: model.RememberBrowser);
switch (result)
{
case SignInStatus.Success:
return RedirectToLocal(model.ReturnUrl);
case SignInStatus.LockedOut:
return View("Lockout");
case SignInStatus.Failure:
default:
ModelState.AddModelError("", "Invalid code.");
return View(model);
}
}
}
注册用于开发目的的试用 SMS 帐户
为了开发目的,您可以注册为 Twilio 的软件开发人员,以通过其 Web 服务 API 以编程方式拨打/接听电话和收发短信。Twilio 是一家总部位于加利福尼亚州旧金山的云通信公司。Twilio 的服务通过 HTTP 访问,并按使用量计费(软件开发人员试用帐户是免费的,但短信仅发送到帐户注册期间指定的电话)。访问 twilio 注册试用帐户。
注册帐户后,您将获得一个帐户 SID、一个身份验证令牌和一个用于发送和接收呼叫和消息的电话号码。“帐户 SID”充当用户名,“身份验证令牌”充当密码。将这些设置复制到项目 Web.config 文件中的 SMS 帐户设置部分。如果您注册了试用帐户,您需要在示例项目中将注册的电话号码用作用户的手机号码,因为短信将不会发送到注册电话号码以外的任何号码。
管理用户帐户
用户登录其帐户后,管理帐户设置部分允许用户更改帐户密码并添加/删除与 2FA 关联的电话号码,前提是 2FA 在应用程序配置设置中已启用。
可以通过项目 Web.config 文件中以 Password
关键字(详见下文)为前缀的配置设置来定义密码策略。将嵌入在 IdentityConfig.cs 文件中的硬编码值移出,可以避免在密码策略发生更改时重新编译和重新部署项目。
<!—Password Policy Settings -->
<add key="PasswordRequiredLength" value="6">
<add key="PasswordRequireNonLetterOrDigit" value="true">
<add key="PasswordRequireDigit" value="true">
<add key="PasswordRequireLowercase" value="true">
<add key="PasswordRequireUppercase" value="true">
更改密码
“更改密码”选项允许用户更改其密码。密码策略规则将被强制执行。
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> ChangePassword(ChangePasswordViewModel model)
{
...
var _retVal = await UserManager.ChangePasswordAsync(User.Identity.GetUserId(),
model.OldPassword,
model.NewPassword);
if (_retVal.Succeeded)
{
var user = await UserManager.FindByIdAsync(User.Identity.GetUserId());
if (user != null)
{
await SignInManager.SignInAsync(user, isPersistent: false, rememberBrowser: false);
}
return RedirectToAction
("Index", new { Message = ManageMessageId.ChangePasswordSuccess });
}
AddErrors(_retVal);
return View(model);
}
默认情况下,ASP.NET Identity 中的密码策略要求密码至少包含 6 个字符,其中至少包含一个非字母或数字字符、至少一个数字('0'-'9')和至少一个大写字母('A'-'Z')。验证检查强制使用强密码。
为双因素身份验证 (2FA) 添加电话号码
启用两步验证后,您的帐户将受到额外的安全保护。登录时,您需要输入密码以及来自您移动设备的唯一代码。即使有人获得了您的密码,也能防止冒名顶替者访问您的帐户。
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult AddPhoneNumber(AddPhoneNumberViewModel model)
{
...
// Generate token...
try
{
if (this.SendOTP2Phone(UserManager, User.Identity.GetUserId(), model.Number))
return RedirectToAction("VerifyPhoneNumber", new { PhoneNumber = model.Number });
}
catch (Exception ex)
{
AddErrors(new IdentityResult(ex.Message));
}
// If we reach this point, something failed, redisplay form displaying error message(s)...
return View(model);
}
public static bool SendOTP2Phone
(this ControllerBase controller, ApplicationUserManager _userMngr,
int _userId, string _phoneNumber)
{
bool _retVal = false;
if (_userMngr.SmsService != null)
{
//Generate security code for phone confirmation
var code = _userMngr.GenerateChangePhoneNumberToken(_userId, _phoneNumber);
var message = new IdentityMessage
{
Destination = _phoneNumber,
Body = "Your security code is: " + code
};
//Send the security code
_userMngr.SmsService.Send(message);
_retVal = true;
}
else
{
throw new Exception("SMS Service has not been configured,
unable to text notification...");
}
return _retVal;
}
启用两步验证后,您的帐户将受到额外的安全保护。登录时,您需要输入密码以及来自您移动设备的唯一代码。即使有人获得了您的密码,也能防止冒名顶替者访问您的帐户。
public async Task<ActionResult> VerifyPhoneNumber(VerifyPhoneNumberViewModel model)
{
...
var result = await UserManager.ChangePhoneNumberAsync
(User.Identity.GetUserId(), model.PhoneNumber, model.Code);
if (result.Succeeded)
{
var user = await UserManager.FindByIdAsync(User.Identity.GetUserId());
if (user != null)
{
await SignInManager.SignInAsync(user, isPersistent: false, rememberBrowser: false);
}
return RedirectToAction("Index", new { Message = ManageMessageId.AddPhoneSuccess });
}
//If we got this far, something failed, redisplay form
ModelState.AddModelError("", "Failed to verify phone");
return View(model);
}
RBAC 身份验证/授权概述
用户身份验证过程由我们的应用程序执行,以提供一层安全性。由于 Web 应用程序是基于 Internet 的,因此应用程序被配置为表单身份验证,IIS 不会检查发出入站请求的用户是否已通过身份验证。相反,入站请求会路由到 MVC Web 应用程序,并在那里由我们的应用程序执行授权检查。由我们的应用程序负责检查用户是否已通过身份验证,然后才能继续访问“受保护”资源,否则将向请求用户显示登录对话框。
下图说明了 Internet 应用程序的 RBAC 身份验证/授权过程。
对我们 Web 应用程序的入站请求最初由 IIS 处理,IIS 将请求路由到我们的 MVC Web 应用程序。如果用户已通过身份验证,则会根据用户的权限和角色检查该请求。用户的权限将决定是否可以处理请求的控制器/操作。未通过身份验证的用户将被重定向到登录页面。但是,未经身份验证的用户仍然可以访问未受保护的资源,因为授权过滤器不会被调用;授权过滤器执行身份验证和授权检查。例如,您可能有一个显示产品信息并提供客户帐户管理的 Internet 站点。任何人都可以查看公司的产品信息,但只有注册客户才能访问其帐户信息。因此,与公司产品信息相关的 Web 资源不需要受保护,从而允许任何用户查看该资源;而与客户帐户信息相关的 Web 资源绝对需要受保护。
任何直接导航到客户帐户资源的用户的都将被立即重定向到登录页面以进行身份验证。要保护我们 MVC 应用程序中的资源,请记住我们通过 RBACAttribute
属性来修饰我们控制器的方法或整个控制器。
结论
此解决方案为任何需要基于动态的、自包含的、特定于应用程序的角色基础访问控制 (RBAC) 的用户身份验证/授权的 Internet 应用程序提供了理想的框架。
框架的逻辑由应用程序配置文件中定义的设置驱动。在应用程序配置文件中引入驱动框架逻辑的设置,可以避免在决定更改框架行为时重新编译和重新部署应用程序。当应用程序开发人员每次需要进行更改时都要重新编译和重新部署应用程序时,这会变得耗时且成本高昂。该框架的设计考虑到了可扩展性,能够添加新功能和/或修改现有功能。
该框架可以添加到现有项目以及新项目中,一旦部署,应用程序角色/权限将通过应用程序系统管理员进行自我维护和管理,而对应用程序开发人员的依赖很少或根本没有。
最后,此解决方案特别适用于面向公众的 Internet 应用程序,其中 MVC 应用程序内的资源需要在精细级别上进行保护。
历史
- 2016 年 5 月 22 日:初始版本