1. 项目概述与核心需求拆解
1.1 为什么需要用户身份验证?这个标题背后藏着什么
先说个场景。某天接到一个内部管理系统的开发任务,需求很简单:做个登录页,只有录入过系统的同事能进来,其他人都挡在外面。这个“简单”的功能,真正落地时却会牵扯出密码怎么存、登录状态怎么保持、权限怎么区分、接口怎么防刷等一系列问题。
“如何在 ASP.NET Core Identity 中实现用户身份验证”这个标题,实质上是在解决一件非常具体的事:如何用微软官方这套身份框架,把一个“谁都能访问”的Web应用,变成“只有合法用户能访问”的受保护系统。
ASP.NET Core Identity本身不是一个“登录页模板”,而是一整套和身份认证相关的底层能力集合。它帮你搞定了:
- 用户存储:谁来注册、谁在登录,数据存哪张表、字段长什么样。
- 密码哈希:密码怎么加密才能扛住数据库泄露的风险,而不是存明文等着被脱裤。
- 登录状态保持:用户登录成功后,浏览器和服务端之间靠什么机制维持“已登录”这个事实。
- 角色与声明:谁是管理员、谁只是普通成员,认证通过后用户身上带哪些标记,后续授权时拿这些标记做判断。
- 防暴力破解:连续输错密码怎么办,锁定策略怎么配。
适合谁读?如果你正准备给某个ASP.NET Core项目加登录功能,对Identity只知道个大概、真到了一步步配置时发怵,这篇内容会非常对路。已经跑通过基础流程、但想搞明白为什么这么配、遇到怪问题会排查的人,也能在里面找到想要的。
1.2 整体设计思路:官方框架 vs 自己造轮子
拿到这个标题,第一反应可能是:用户身份验证这么基础的能力,我从零手写一个不就行了?这确实是很多人的第一想法,但作为实践过的人来说,我不建议你在正式项目里这么干。
自己造轮子的方案大致是:建一张User表,存用户名和密码——密码做一次哈希存入数据库,登录时校验哈希,再用Session或Cookie记录登录状态。这个方案在demo阶段跑得通,但一旦进入真实场景,你会面临这些衍生需求:
- 用户表要有唯一索引、邮箱确认、手机号绑定。
- 密码要支持重置,要发邮件、发短信。
- 用户可能被禁用,账号可能被锁。
- 应用升级时要保留历史密码哈希格式。
- 要为第三方登录(比如企业微信扫码)预留扩展点。
这些问题每一个都意味着代码量和踩坑成本。而ASP.NET Core Identity从框架层面给出了统一的、经过大量生产环境验证的标准答案。我把它理解为**“身份认证领域的一站式解决方案”**,类似盖房子时你不必从烧砖开始,框架已经把梁柱结构搭好了,你要做的是按图纸填充墙面。
标题里的核心诉求在于“实现用户身份验证”,但在实际操作中,只用默认配置往往不够。项目开发到一定阶段,总会遇到“用户表需要加字段”“登录逻辑里要做额外校验”“Cookie过期时间要定制”这类需求,所以这篇文章会沿着一条实际项目的脉络:先用默认方式跑通,再做定制改造,最后排查那些让人头疼的疑难问题。
2. ASP.NET Core Identity 的核心机制解析
2.1 认证与授权:两个容易混淆的概念
在动手配代码之前,必须把两个概念拎清楚,因为它们决定了代码该怎么写、中间件按什么顺序注册。
**认证(Authentication)**解决的是“你是谁”的问题。用户提交用户名密码,系统验证通过后,给这个用户发一个“通行证”,之后用户请求接口时带上这个通行证,系统就知道“哦,这是刚才那个通过验证的人”。在ASP.NET Core里,认证是由认证中间件配合Identity框架完成的。
**授权(Authorization)**解决的是“你能干什么”的问题。你已经登录,但你是普通用户还是管理员?你能不能访问用户管理页面?这需要拿登录后得到的身份信息去做权限判断。
一句话记忆:认证是验明正身,授权是分配权限。顺序上,必须先认证再授权,这个顺序对应到代码里就是中间件注册顺序:
app.UseAuthentication(); app.UseAuthorization();这个顺序不能颠倒。UseAuthentication必须先注册,因为它会识别请求中携带的身份凭证并填充用户信息;UseAuthorization在后面,它拿前面填充好的用户信息做权限判断。如果反了,授权中间件发现“当前用户为空”,无论什么请求都直接返回未授权。
2.2 Identity的存储模型:AspNetUsers这些表长什么样
Identity框架落库后,会生成一组固定命名的表。默认情况下它们叫:
- AspNetUsers:用户主表
- AspNetRoles:角色表
- AspNetUserRoles:用户和角色的多对多关联表
- AspNetUserClaims:用户的声明表
- AspNetUserLogins:外部登录信息表(比如第三方OAuth登录时用)
- AspNetUserTokens:用户令牌表(用于密码重置、邮箱确认等功能)
初次看到这些表的人可能会有个疑问:登录功能这么点事,为什么拆这么多张表?
这里要说清楚Identity的设计理念。它不是只为你存个用户名和密码,而是要考虑完整的用户生命周期:用户可能拥有多个角色,可能有多个自定义属性(声明),可能通过多种方式登录(账号密码、扫码、外部认证),这些状态如果不拆表,用户表字段会被撑得非常复杂。按领域拆表,一是职责清晰,二是扩展性强——想加一种新的登录方式,不必改原先的用户表结构。
默认建的AspNetUsers表自带这些核心字段:Id(主键)、UserName(用户名)、NormalizedUserName(用户名大写副本,用于不区分大小写的查询)、Email、NormalizedEmail、PasswordHash(密码哈希值)、SecurityStamp(安全戳,用于密码变更时使旧Cookie失效)、ConcurrencyStamp(并发控制戳)。
提到NormalizedUserName这个字段,多说一句。很多人在做登录时习惯在代码里写Where(u => u.UserName == input.UserName),结果查了半天查不到,就是因为忽略了Identity在框架层面用规范化字段做匹配。它把用户名转成大写后存储和索引,目的是让查询不依赖数据库的排序规则,性能也更稳定。日常使用我们不用直接操作这个字段,但排查问题时得知道它的存在。
2.3 PasswordHasher:密码到底是怎么被保护起来的
用户身份验证系统的安全级别,很大程度取决于密码存储方式。ASP.NET Core Identity使用的是PasswordHasher<TUser>,默认采用PBKDF2算法,并且每一次哈希都会生成一个随机的salt(盐值)。
PBKDF2可以理解为一种“故意变慢的哈希算法”。它的思路是:把密码和随机盐值做成千上万次迭代运算,让每一次哈希都消耗可观的CPU时间。攻击者拿到数据库里的哈希后,如果要暴力猜测密码,每一组猜测也要付出同样的计算成本,破解速度被大幅拖慢。
Identity每次生成的哈希字符串格式看起来类似:
AQAAAAIAAYagAAAAELxK...这个哈希串中其实嵌入了版本号、盐值、迭代次数等元数据。好处是,框架在将来升级算法参数时,老用户的老哈希仍然可以被正确识别并正常验证。你不需要关心如何解析它——那是Identity内部的事,但了解到这一点,你就不会犯“把哈希当普通字符串比较”的错误。
有一个常见的坑:某些人试图在OnPostAsync里手动对数据库里的PasswordHash字段做字符串比较,这会导致所有用户永远登录失败,或者更极端地,如果哈希碰巧一致也能登录成功,但你把盐值和迭代机制完全绕开了,等于自己搞了个脆弱的加密方案。正确的做法永远是调用UserManager.CheckPasswordAsync(user, inputPassword)来校验。
2.4 Claims、Roles和Policy:登录之后身份信息怎么组织
用户登录成功后,Identity会构建一个ClaimsPrincipal对象,里面装了一堆Claim。Claim的本质是一组“键值对”,描述用户身上具备的特征。较真一点说:认证的产出物是一个包含若干Claims的身份对象,后续所有授权判断都是围绕Claims展开的。
三者的关系是:
- Claim(声明):最底层,描述“用户是谁/有什么”,比如
Id、UserName、Email、Department。 - Roles(角色):是Claims的一种特殊形式,角色本质上就是一个包含特定类型(
ClaimTypes.Role)的声明。 - Policy(策略):基于Claims或Roles做的一组断言规则。比如“要求用户必须拥有Email声明且Email已验证”。
初次接触这些概念的人容易陷入一个误区:以为授权只能基于角色。其实不是。对于轻量应用,用角色就够了;但项目复杂到一定程度,比如“销售部门的用户能查看订单,但只能修改浙江地区的订单”,这就是基于详细声明的授权场景。Identity没有把这条扩展路径堵死,反而是完整地开放给你的。
从架构设计的角度重新理解标题里的“用户身份验证”,它不是一个孤立的登录功能,而是认证、存储、身份组织三者的合集。搞清楚这个底层心智模型,后续每一步操作你都会感到顺理成章,而不是机械执行。
3. 实操落地:从零搭建身份验证全流程
3.1 准备项目与安装依赖
开始动手之前,确定项目类型。这里以最常规的ASP.NET Core MVC项目(.NET 8/9版本)为例。如果你是Web API项目,Identity同样适用,只是Cookie认证和JWT认证的选择上有所不同,我会在后文专门对比。
创建一个解决方案:
dotnet new mvc -n IdentityDemo cd IdentityDemo然后引入必需的包。如果你是使用Visual Studio或新版.NET CLI创建项目时勾选了“个人用户账户”,这些包已经默认集成,不需要手动添加。如果项目是空模板创建的,需要执行:
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Microsoft.EntityFrameworkCore.Tools核心就这三个。Microsoft.AspNetCore.Identity.EntityFrameworkCore提供了Identity核心类型和EF Core的存储实现;SqlServer是这里使用的数据库驱动;Tools用来在命令行执行迁移指令。
很多人会问:是否只能用EF Core?答案是Identity的官方存储实现就是基于EF Core的,但它有抽象接口,如果你对Dapper这类轻量ORM更熟悉,理论上可以实现自己的存储替代,但对绝大多数场景完全没有必要——你会在框架自带实现上获得最多的社区支持与文档积累。
3.2 配置服务与中间件
在Program.cs里注册Identity服务。两种注册方式,差异不小:
// 方式一:最小配置 builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); builder.Services.AddDefaultIdentity<ApplicationUser>(options => { }) .AddEntityFrameworkStores<ApplicationDbContext>(); // 方式二:完整控制 builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options => { }) .AddEntityFrameworkStores<ApplicationDbContext>() .AddDefaultTokenProviders();AddDefaultIdentity是AddIdentity的简化版,适合角色体系不复杂的场景;AddIdentity<TUser, TRole>则显式引入角色支持,方便后续开发权限管理功能。我这边的实践是,从一开始就用第二种,因为绝大多数项目早晚会遇到角色或权限需求,用第一种起步的话,后面切换角色支持时会牵扯到代码调整。
注意ApplicationUser是自己定义的用实体类,继承IdentityUser:
public class ApplicationUser : IdentityUser { public string Nickname { get; set; } public string AvatarUrl { get; set; } public DateTime CreatedAt { get; set; } }自定义字段加在这里。EF Core会把这些字段自动映射到AspNetUsers表的新列中。
服务注册之后,别忘了把中间件管道接好。这里再强调一次之前的顺序:
var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.MapDefaultControllerRoute(); app.Run();如果是在Visual Studio默认模板上添加,中间件通常已经注册妥当。但如果你是自己搭建的空模板项目,漏掉UseAuthentication()会导致登录状态完全不被识别——Controller里的[Authorize]特性检查时,当前用户始终为空。
3.3 创建数据库与迁移
依赖和配置都就绪后,创建数据库表结构。EF Core的迁移机制会帮我们把实体映射成真实表:
dotnet ef migrations add InitialIdentitySchema dotnet ef database update执行完这两条命令后,数据库里会出现前面提到的那一组AspNet开头的表。如果在这里遇到“No database provider has been configured”之类的错误,99%的情况是AddDbContext配置漏写或连接字符串没从配置文件里正确读取。
连接字符串在appsettings.Development.json里:
{ "ConnectionStrings": { "DefaultConnection": "Server=(localdb)\\MSSQLLocalDB;Database=IdentityDemoDb;Trusted_Connection=True;MultipleActiveResultSets=true" } }提示:超过3个字段的迁移调整(比如给用户表加一个生日字段),修改实体类后重新执行
dotnet ef migrations add AddUserBirthday即可。基础表结构不推荐手工在数据库管理工具里改,一旦和迁移历史不对齐,后续database update会报一大串不一致错误。
3.4 注册与登录的实现细节
先做注册页面。这里直接写核心处理逻辑,不贴完整视图代码,重点讲解背后的处理流程:
[HttpPost] [AllowAnonymous] public async Task<IActionResult> Register(RegisterViewModel model) { if (!ModelState.IsValid) { return View(model); } var user = new ApplicationUser { UserName = model.Email, Email = model.Email, Nickname = model.Nickname, CreatedAt = DateTime.Now }; var result = await _userManager.CreateAsync(user, model.Password); if (result.Succeeded) { await _userManager.AddToRoleAsync(user, "User"); return RedirectToAction("Login"); } foreach (var error in result.Errors) { ModelState.AddModelError(string.Empty, error.Description); } return View(model); }这里有几个容易出错的点:
CreateAsync和直接赋值PasswordHash的区别。正确的做法是把明文密码作为第二个参数传给CreateAsync,让框架负责哈希。如果你试图手动设置user.PasswordHash = model.Password,那等于明文存储,这是项目中绝对不能出现的致命错误。
UserName和Email的关系。上面代码把两个值都设成了同一个邮箱地址。Identity允许两者不同(比如用户名用工号,邮箱用个人邮箱),但默认注册模板通常以邮箱作为登录账号。如果你希望用户名和邮箱区分开,记得在注册页面加上用户名字段,并在登录时用UserName而非Email查找用户。
再说登录处理器:
[HttpPost] [AllowAnonymous] public async Task<IActionResult> Login(LoginViewModel model, string returnUrl = null) { if (!ModelState.IsValid) { return View(model); } var user = await _userManager.FindByNameAsync(model.UserName); if (user == null) { ModelState.AddModelError(string.Empty, "用户名或密码不正确"); return View(model); } var result = await _signInManager.PasswordSignInAsync( user, model.Password, model.RememberMe, lockoutOnFailure: true); if (result.Succeeded) { return RedirectToLocal(returnUrl); } ModelState.AddModelError(string.Empty, "用户名或密码不正确"); return View(model); }PasswordSignInAsync是核心方法。它的内部处理顺序大致是:先检查用户是否存在→检查用户是否被锁定→用PasswordHasher校验密码→更新安全戳(可选)→签发身份Cookie。比起“先查库再自己比较哈希”,这个方法把几步操作集成在一起,并且内置了锁定策略、防止时序攻击等安全细节。
提醒一个细节:登录失败提示信息不要区分“用户不存在”和“密码错误”,否则容易被恶意用户探测已注册的账号。所以上面的代码无论哪种失败都统一提示“用户名或密码不正确”。
3.5 控制器和视图的认证保护
注册和登录逻辑完成后,接下来要做的是“保护”页面。在需要登录才能访问的Controller上打标签:
[Authorize] public class ProfileController : Controller { public IActionResult Index() { return View(); } } [Authorize(Roles = "Admin")] public class AdminDashboardController : Controller { public IActionResult Index() => View(); }第一个标签表示“必须登录”,第二个在登录之上还要求“必须是Admin角色”。如果未登录用户访问这些页面,默认情况下会被重定向到/Account/Login——这个默认路径由Cookie认证中间件配置控制,在Program.cs里可以修改:
builder.Services.ConfigureApplicationCookie(options => { options.LoginPath = "/Account/Login"; options.AccessDeniedPath = "/Account/AccessDenied"; options.Cookie.Name = "MyApp.Identity.Cookie"; options.SlidingExpiration = true; options.ExpireTimeSpan = TimeSpan.FromHours(12); });ExpireTimeSpan决定Cookie的绝对过期时间,SlidingExpiration滑动过期机制的作用是:用户只要在过期前有活动,过期时间会自动向后顺延。这两个配置实践下来非常实用——比如内部管理系统希望员工上班期间保持登录、隔一段时间没操作才要求重新登录,就把ExpireTimeSpan设短一点(如2小时)配合SlidingExpiration使用。
3.6 在视图里获取当前用户
登录后如何在页面里展示用户信息?在Razor视图里:
@using Microsoft.AspNetCore.Identity @inject UserManager<ApplicationUser> UserManager @if (User.Identity.IsAuthenticated) { var user = await UserManager.GetUserAsync(User); <p>当前登录用户:@user.Nickname</p> <p>邮箱:@user.Email</p> }User属性由UseAuthentication中间件填充,里面包含了从Cookie还原的身份信息。User.Identity.IsAuthenticated用来判断“有没有登录”,User.IsInRole("Admin")用来判断“是不是管理员”。
需要理解的是:Cookie里并不会保存用户的Nickname等全部自定义字段,默认只保存身份相关的核心Claim。这也是为什么在视图里想显示昵称时,要通过UserManager.GetUserAsync(User)重新查库——Cookie里拿不到完整对象,但可以根据用户Id找到它。这个查询只在页面渲染时执行一次,性能上完全不是问题。
4. 常见问题与排查技巧实录
4.1 ReturnUrl跳转与开放重定向攻击
登录跳转这个功能看似简单,但有个安全细节极其容易被忽略:returnUrl参数如果处理不当,会被攻击者利用来做开放重定向钓鱼攻击。
攻击原理很简单:用户收到一个链接/Account/Login?returnUrl=https://evil-site.com,登录后如果直接把用户重定向到evil-site.com,用户看到“自己刚输入的账号密码”出现在恶意站点上,很容易误以为对方站点是合法系统的延伸,继而输入更多敏感信息。
正确的处置方式是仅允许相对路径的returnUrl:
private IActionResult RedirectToLocal(string returnUrl) { if (Url.IsLocalUrl(returnUrl)) { return Redirect(returnUrl); } return RedirectToAction(nameof(Index), "Home"); }Url.IsLocalUrl会检查returnUrl是不是以/开头的站内路径。https://evil-site.com这种绝对地址会被直接过滤掉。
4.2 迁移时“已有的Migrations不一致”怎么办
Identity相关开发中,遇到最频繁的异常之一是:
Microsoft.Data.SqlClient.SqlException: There is already an object named 'AspNetUsers' in the database.这通常是因为表已经存在,但迁移历史里没有对应记录(或反之)。解决步骤:
- 打开NuGet控制台或CLI,执行
dotnet ef migrations list查看迁移历史。 - 如果应用尚未上线,最粗暴有效的做法是删除数据库:
dotnet ef database drop --force,然后重新database update。 - 如果数据库里有重要数据,或者已部署到生产环境,则不能直接删除,建议给已存在的Identity表加一个初始迁移快照,使数据库状态与迁移历史对齐。
dotnet ef migrations add InitialIdentitySchema dotnet ef database update如果提示数据库中已有部分表,一个快速核对方式:写个小的查询脚本对比当前库表集合和迁移脚本中的表集合,找出差表,然后手动补执行对应的建表SQL。这种方法虽不优雅,但在紧急排查时非常可靠。
生产环境务必在操作前做数据库备份,这是任何DBA和资深开发者都会坚持的铁律。
4.3 “登录成功但页面不断跳回登录页”的经典灵异事件
这是一种出现频率极高的登录异常:输入正确账号密码后,页面刷新了一下,接着又回到登录页,好像登录完全没生效。
排查这个问题的方向有几层:
第一层:检查Cookie是否成功写入。打开浏览器开发者工具(F12),切到Application或Storage标签页,看有没有名为.AspNetCore.Identity.Application(或自定义的Cookie名)的Cookie。没有看到,说明中间件没执行或Cookie写入失败。此时回看Program.cs里是否调用了app.UseAuthentication()。
第二层:看Cookie是否在响应后被清掉。这种“日志已写入、跳转也很正常,但Cookie就是不留存”的情况,大概率是TempData或某个中间件在请求结束时把响应Cookie覆盖了,或者是ConfigureApplicationCookie里配置了Cookie.ExpireTimeSpan太短。
第三层:检查LoginPath配置循环。如果你在未被授权的Controller上设置了自定义的[Authorize]策略,而该策略要求一个未在登录过程中被补充的Claim或Role,那么Cookie是写入了,但授权仍然不通过,于是系统“反复要求重新登录”。观察地址栏URL:如果跳转地址里带了ReturnUrl,而且总是同一路径,问题多半出在授权环节而不是认证环节。
排查授权环节有一个简单手段:在目标Action内临时注入User.Identity相关代码,打印出所有Claims和Roles。对比页面实际拥有的身份信息和[Authorize]要求的信息,差值就是问题所在:
var claims = User.Claims.Select(c => $"{c.Type}: {c.Value}");4.4 角色数据迁移与初始化的两种场景
在很多项目中,角色并不会通过注册页面创建,而是由系统初始化时自动写入。常见的做法是在应用启动阶段执行种子数据初始化:
public static async Task InitializeAsync(IServiceProvider serviceProvider) { var roleManager = serviceProvider.GetRequiredService<RoleManager<IdentityRole>>(); string[] roles = { "Admin", "User", "Manager" }; foreach (var role in roles) { if (!await roleManager.RoleExistsAsync(role)) { await roleManager.CreateAsync(new IdentityRole(role)); } } }调用位置放在Program.cs的app.Run()之前:
using (var scope = app.Services.CreateScope()) { var services = scope.ServiceProvider; await DbInitializer.InitializeAsync(services); }需要提醒的是,这种初始化逻辑不要写得过于复杂,尤其不要包含“重置所有密码”之类的危险操作。种子数据只在表为空时执行,否则每次启动应用都跑一次全量初始化,一旦代码里不小心写了覆盖逻辑,后果会非常严重。
4.5 密码锁定策略和验证配置
用户身份验证的安全加固中,密码策略和账号锁定是绕不开的两道防线。Identity里通过IdentityOptions配置:
builder.Services.Configure<IdentityOptions>(options => { // 密码策略 options.Password.RequireDigit = true; options.Password.RequireLowercase = true; options.Password.RequireUppercase = true; options.Password.RequireNonAlphanumeric = true; options.Password.RequiredLength = 8; options.Password.RequiredUniqueChars = 4; // 锁定策略 options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); options.Lockout.MaxFailedAccessAttempts = 5; options.Lockout.AllowedForNewUsers = true; // 用户策略 options.User.RequireUniqueEmail = true; });这里要解释Password.RequiredUniqueChars,很多人不理解是干什么的。它要求密码中至少出现多少个不同的字符。比如“aabbccdd”只有4种不同字符,设置RequiredUniqueChars=4时恰好通过;而“aaaaaaaa”只有1种字符,直接不满足。这个配置的作用是防止用户设置“11111111”“abcdefgh”这类极低熵密码。
锁定策略上线后有一个用户体验问题需要权衡:内部系统员工忘记密码时,连续输错5次被锁15分钟,体验非常糟糕。生产实践证明,15分钟是个安全度和体验平衡比较好的阈值;而面向用户的互联网产品,通常会允许更多次数尝试(比如8次)并采用递进式锁定(第一次5分钟、第二次15分钟、第三次30分钟),这个策略Identity默认不支持,需要自行扩展。
4.6 Identity在API场景:Cookie认证还是JWT?
标题里说的是“用户身份验证”,但现在的项目既有传统页面应用(MVC/Razor Pages),也有前后端分离的Web API项目。这两种场景下Identity的使用方式要有区分。
页面应用:直接采用上文所述方案,浏览器自动携带Cookie,认证状态由服务端维护,这是最自然的选择。
API服务:前端是Vue或React单页应用,无法依赖浏览器自动携带Cookie(跨端口时更麻烦),此时一般选择JWT(JSON Web Token)认证方式。
JWT方案下代码有差异:
builder.Services.AddIdentityCore<ApplicationUser>(options => { }) .AddEntityFrameworkStores<ApplicationDbContext>(); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = builder.Configuration["Jwt:Issuer"], ValidateAudience = true, ValidAudience = builder.Configuration["Jwt:Audience"], ValidateLifetime = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"])) }; });登录成功后签发Token:
var token = await _userManager.GenerateUserTokenAsync(user, "Default", "login"); // 实际生产环境建议用JwtSecurityToken类构建Token,并把用户Claims嵌入其中很多新手在API项目里仍然使用Cookie认证,导致前端要手动处理跨域Cookie的withCredentials、服务端要配置SameSite=None; Secure等一堆麻烦的附带条件。我的建议是:Web API优先考虑JWT,Razor Page/MVC优先考虑Cookie。两者都基于ASP.NET Core Identity的用户存储和密码校验机制,只是“登录成功后怎么给前端返回凭证”的差异。
这里不把JWT的细节全部展开,是因为如果没有非常明确的双端分离需求,Cookie方案要省心得多。但你要知道有这个选择,不要等前端同事来问“为什么接口返回未授权”时才措手不及。
5. 进阶:自定义Identity扩展的安全实践
5.1 登录时增加额外业务校验
很多项目要求的并不是“用户名密码对就能登录”,而是叠加业务条件。比如:公司内部系统仅在员工在职状态下允许登录,用户首次登录必须修改初始密码,特定IP段内的用户不需要双重验证。
以“仅当用户启用状态为真且账号尚未过期时允许登录”为例,可以在PasswordSignInAsync之前做一次预检查:
var user = await _userManager.FindByNameAsync(model.UserName); if (user == null) { ModelState.AddModelError(string.Empty, "用户名或密码不正确"); return View(model); } if (!user.IsActive) { ModelState.AddModelError(string.Empty, "账号已被停用"); return View(model); } if (user.ExpireDate.HasValue && user.ExpireDate.Value < DateTime.Now) { ModelState.AddModelError(string.Empty, "账号已过期"); return View(model); } var result = await _signInManager.PasswordSignInAsync( user, model.Password, model.RememberMe, lockoutOnFailure: true);这种预检查写起来不难,但要注意一个安全细节:预检查信息的暴露顺序。如果先检查“用户是否存在”,再检查“是否被停用”,攻击者可以通过错误提示确认账号是否存在。更安全的做法是不区分用户不存在和用户被停用的提示文案,统一返回“无法登录”。安全性和用户体验之间永远存在取舍,这个决定权在你自己手里。
5.2 自定义令牌提供器实现双重验证
两步验证在实际项目中越来越多地被要求。Identity提供了扩展点,可以用IUserTwoFactorTokenProvider<TUser>实现短信验证码发送:
public class SmsTokenProvider : IUserTwoFactorTokenProvider<ApplicationUser> { private readonly ISmsSender _smsSender; public SmsTokenProvider(ISmsSender smsSender) { _smsSender = smsSender; } public async Task<string> GenerateAsync(string purpose, UserManager<ApplicationUser> userManager, ApplicationUser user) { var code = new Random().Next(100000, 999999).ToString(); // 存储code供后续验证,并发送短信 return code; } public async Task<bool> ValidateAsync(string purpose, string token, UserManager<ApplicationUser> userManager, ApplicationUser user) { // 与存储的code比对 } public Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<ApplicationUser> userManager, ApplicationUser user) { return Task.FromResult(user.PhoneNumberConfirmed); } }这个扩展点的设计核心在于:GenerateAsync生成验证码,ValidateAsync校验验证码,CanGenerateTwoFactorTokenAsync判断用户是否具备接收验证码的条件(比如手机号已确认)。
双重验证会在登录密码验证通过后,额外跳转到一个验证页面要求用户输入这6位动态码。它在PasswordSignInAsync返回值中体现为RequiresTwoFactor。如果你的项目里有这个需求,说明已经脱离了“登录”的最基础实现,进入了安全加固层面。
5.3 事件回调:记录每次登录行为
身份验证系统的可审计性,在内部项目中经常被忽略。有一个很好用的机制——Cookie认证中间件提供了一系列事件接口,在关键节点插入自定义逻辑:
builder.Services.ConfigureApplicationCookie(options => { options.Events = new CookieAuthenticationEvents { OnSignedIn = context => { var userId = context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier); // 记录登录时间、IP、设备信息到日志表 return Task.CompletedTask; }, OnSigningIn = context => { // 在签发Cookie之前执行,可以在这里做最后一道校验 return Task.CompletedTask; } }; });OnSignedIn是登录流程完成后触发的事件,在这里记录审计日志非常自然。实际排查安全问题时,有没有“谁在什么时间登录过”的记录,是能不能定位风险的关键依据。没有审计日志的登录系统,形同虚设。
5.4 用户锁定与解锁的运维操作
账号锁定状态是Identity自带的能力。在实践中,运维人员需要在后台“手动解锁某用户”的情况非常常见(比如员工忘记密码连续输错导致锁定)。提供一个轻量管理页面来操作:
public async Task<IActionResult> UnlockUser(string id) { var user = await _userManager.FindByIdAsync(id); if (user != null) { await _userManager.SetLockoutEndDateAsync(user, DateTimeOffset.MinValue); } return RedirectToAction("Index", "UserManagement"); }调用SetLockoutEndDateAsync(user, null)同样是解锁,但需要谨慎使用。DateTimeOffset.MinValue是“从未被锁定”的语义,而null会把锁定结束时间清空。两者实际效果相同,生产实践上我推荐DateTimeOffset.MinValue,因为它是明确值,不容易被后续的通用更新逻辑覆盖。
另外解锁操作不应该单独暴力执行,通常还会附带重置安全戳或强制用户下次登录修改密码:
await _userManager.UpdateSecurityStampAsync(user); await _userManager.SetLockoutEndDateAsync(user, DateTimeOffset.MinValue);UpdateSecurityStampAsync会让该用户已签发的所有Cookie在下次请求时被判定为无效,相当于强制该用户退出全部登录状态。如果你希望“解锁之后让用户重新登录一遍”,这个组合是正确的。
6. 性能与扩展性考量
6.1 用户表数据量增长后的查询优化
用户量小的时候,_userManager.FindByNameAsync这类查询毫无压力。但是当用户量达到几十万、上百万时,登录接口的性能就会开始受到关注。AspNetUsers表的NormalizedUserName和NormalizedEmail列,迁移时默认建了唯一索引,这保证了按用户名查找的效率。需要关注的点在于:
- 避免在LINQ里对UserName做函数式处理(比如
ToLower()),这会导致索引失效,全表扫描。 - 登录接口不要带去查询除用户身份外的其他关联表数据,如
UserRoles、UserClaims。只有在确实需要判断角色时再去加载,尽量延迟加载或使用专门的查询方式。 - 自定义字段如果没有查询需求,不要滥用索引。比如
Nickname是展示用字段,完全不需要建索引。
6.2 多次调用UserManager的开销问题
发现自己代码里频繁调用_userManager.GetUserAsync(User)时,就应该停下来想一想。每次调用都可能触发一次数据库查询。在一个页面里调用三五次,数据库连接就平白多了几次往返。
更好的做法是:用一个页面级别的变量缓存当前用户对象,或用IUserClaimsPrincipalFactory扩展出包含必要信息的Claims,避免每次请求都查库。一个轻量方案:
public class ApplicationUserClaimsPrincipalFactory : UserClaimsPrincipalFactory<ApplicationUser, IdentityRole> { public ApplicationUserClaimsPrincipalFactory( UserManager<ApplicationUser> userManager, RoleManager<IdentityRole> roleManager, IOptions<IdentityOptions> optionsAccessor) : base(userManager, roleManager, optionsAccessor) { } protected override async Task<ClaimsIdentity> GenerateClaimsAsync(ApplicationUser user) { var identity = await base.GenerateClaimsAsync(user); identity.AddClaim(new Claim("Nickname", user.Nickname ?? string.Empty)); return identity; } }注册工厂后,Cookie里会直接携带昵称声明,视图里通过User.FindFirstValue("Nickname")就能拿到昵称,省掉了数据库查询。这样的设计在首页这种高频访问页面中,效果非常明显。
6.3 多应用共享同一套用户体系
组织结构稍大的公司,往往不止一个内部系统。多个系统如果各自实现一套登录,员工就要记住多套账号密码,用户体验差,账号安全也没法统一管控。这时候可以考虑多应用共享用户体系——所有系统指向同一个Identity数据库。
技术层面实现并不复杂:各系统的连接字符串配置指向同一数据库,ApplicationUser实体保持一致即可。真正需要规划的是:
- 日志与审计的集中收集:哪个系统在什么时候登录过同一个账号,需要统一日志平台。
- 统一登出策略:在一个系统中修改密码后,其他系统的Cookie是否要全部失效。Identity在Cookie里带有
SecurityStamp验证,可以配合全局配置做到。 - 部署环境间的差异:数据库改动影响所有系统时,迁移脚本的排期发布,需要更严谨的流程。
多应用共享用户体系是Identity在真实世界最常见的高阶用法之一,因为它极大节省了企业内部的账号管理成本,而框架本身完全支持——这正是它作为“一站式身份解决方案”的价值所在。
6.4 在生产环境中的配置建议
最后把生产环境中比较关键的配置整理成一张清单:
| 配置项 | 建议值 | 原因 |
|---|---|---|
| Cookie使用HTTPS | Cookie.SecurePolicy = CookieSecurePolicy.Always | 避免Cookie在HTTP下明文传输被截获 |
| Cookie的SameSite | SameSiteMode.Lax | 兼顾安全性与站点内跳转的便利性 |
| 密码重置Token有效期 | 默认 | Identity已内置相对合理有效期,不建议随意延长 |
| 账号锁定时间 | 15分钟 | 安全和体验的平衡点 |
| 密码最小长度 | 至少8位且混合字符类型 | 公司内部系统建议12位,含大小写数字符号 |
| 会话绝对过期 | 8~12小时 | 内部系统建议一个工作日,过长会增加风险暴露面 |
生产环境的另一个重要操作是关闭开发模式的异常页面。一旦Identity在运行时报错,开发模式的详细异常会把数据库连接串等敏感信息显示在页面上,绝对是个灾难级的隐患。上线前务必检查appsettings.Production.json中的LogLevel配置,以及builder.Environment.IsDevelopment()分支。
7. 写在最后的经验总结
搞明白“ASP.NET Core Identity中如何实现用户身份验证”这件事,实际包含三层递进:先理解它解决什么问题、由哪些部分组成;再用默认方式跑通一套注册登录;最后根据业务场景做定制和加固。
回看这些年用Identity做过的项目,我自己印象最深的经验有三条。第一条,不要为了“灵活”跳过框架的约定。Identity给了很多扩展点,但在注册登录这个核心流程上,默认实现是经过大量生产验证的安全做法,自己手写密码校验和登录逻辑,大概率会让系统暴露出安全弱点。第二条,配置项要逐个弄清楚含义再改。比如SlidingExpiration与ExpireTimeSpan的关系,RequireUniqueEmail影响的是注册逻辑还是登录逻辑,改错一个配置,排查起来非常耗时。第三条,生产环境的安全审计永远不能缺席。登录日志、Cookie安全策略、账号锁定策略,这些一定要在系统上线前就规划好,否则事后补救的成本远超想象。
如果这篇文章能帮你少踩几个坑,在日志里少翻几次异常,目的就达到了。Identity的学习曲线不算陡,但要做到真正得心应手,还得靠在自己的项目里实打实摸一遍流程。毕竟身份认证是所有系统的地基,地基稳了,上面盖什么都放心。