ASP.NET Core Identity 实战:用户认证、授权与登录注册全解析 1. 先搞清楚 Identity 解决的是哪一类问题1.1 自己写登录要面对多少坑很多刚接触 ASP.NET Core 的同学在做登录功能的时候第一反应都是这还不简单——建一张用户表存账号密码登录的时候比对一下通过就跳转。我一开始也是这么干的直到真的把一个能用的登录做成能上线的登录才发现坑比想象中多得多。首先是密码存储。明文存肯定不行那就哈希吧。用 MD5不安全。用 SHA256加盐的事情还得自己处理Salt 存哪、怎么生成、什么时候轮换这些都是细节。就算你用了 BCrypt 或者 PBKDF2怎么跟现有用户体系平滑切换又是另一个问题。然后是会话管理。登录成功之后怎么记住用户用 Cookie 的话Cookie 里放什么放 UserId 的话别人伪造怎么办你八成会想到签名那签名算法、密钥管理、过期时间、滑动过期这些又得从头写一遍。哪怕这些都搞定了还有记住我、密码错误次数锁定、邮箱确认、找回密码、两步验证……一套下来没有两三周根本做不完而且每一处都可能埋着安全漏洞。1.2 Identity 帮你做好了哪些事Identity 是微软在 ASP.NET Core 里内置的成员身份框架它不是一个简单的登录函数而是一整套围绕用户、角色、登录态的可扩展体系。它帮你把上面说的那些脏活累活全干了密码哈希存储内置 PBKDF2 迭代默认配置就足够安全基于 Cookie 的认证流程签发、刷新、注销全部托管用户表、角色表、声明表等一整套 EF Core 实体映射用户锁定、邮箱/手机号确认、找回密码、两步验证基于 Claim 的授权模型可以直接配合[Authorize]特性使用UserManager、SignInManager、RoleManager 三个核心服务让你不用写 SQL 就能操作用户和角色说白了Identity 就是一个开箱即用的账号体系全家桶你只需要把它配好然后把你自己的业务字段挂上去。这篇文章我就以 .NET 6/7/8 通用写法为例带你从零搭一个最小可用的 Identity 应用同时把背后的运行机制讲透。2. Identity 的核心组件与运行机制2.1 三个管家UserManager、SignInManager、RoleManagerIdentity 给你的不是一堆静态方法而是三个依赖注入的服务类理解它们是理解整个框架的钥匙。UserManager 管的是用户数据本身。创建用户、改密码、查用户、加声明、加角色都是它。它的泛型参数就是你的用户实体类型默认是IdentityUser你可以继承它扩展字段。注意 UserManager 本身不处理登录动作它只负责用户这个对象的增删改查。SignInManager 管的是登录态。PasswordSignInAsync负责校验密码并签发 CookieSignOutAsync负责注销IsSignedIn判断当前请求是否已登录。它依赖 UserManager 做密码校验但职责更集中一个人管人事档案一个人管门禁闸机互不越权。RoleManager 管角色。创建角色、给用户分配角色、查角色列表。如果你的业务根本用不到角色这玩意儿可以暂时不碰但大部分系统迟早会用到。这三个服务全部通过构造函数注入拿不要自己 new。因为它们的很多行为受配置影响比如密码策略、锁定策略这些配置在你调用AddIdentity的时候就注册进 DI 容器了自己 new 一个等于绕过所有配置。2.2 基于 Claim 的授权模型Identity 的授权模型核心是 Claim——声明。一个 Claim 就是一条关于用户的事实比如ClaimTypes.Name是用户名ClaimTypes.Role是角色名你也可以自己造一条Department: RD这样的声明。登录成功后用户的声明会被写进认证票据解密之后变成ClaimsPrincipal控制器里用User属性就能读。[Authorize]特性检查的其实就是这个票据是否存在且有效[Authorize(Roles Admin)]检查的就是票据里有没有对应角色的声明。这个模型理解起来可能有点绕我换个说法传统的登录是我认识你是张三Claim 模型是我有文件证明你是张三、你属于研发部、你有管理员权限。授权的时候不看人看文件里的记录项。好处是扩展性强——你以后想加只允许查看自己数据这种细粒度权限只需要往里加 Claim不用动登录机制。2.3 认证中间件的执行顺序在 Program.cs 里中间件注册顺序是有讲究的app.UseAuthentication(); // 识别是谁 app.UseAuthorization(); // 决定能不能干这个顺序不能反。UseAuthentication会尝试解析当前请求携带的 Cookie或 JWT把 ClaimsPrincipal 挂到HttpContext.User上UseAuthorization才根据这个 User 去检查[Authorize]的要求。如果顺序反了授权中间件跑的时候 User 还是空的你会发现所有请求都被 401/403 拦下来连登录页都进不去。而且要注意UseAuthentication必须放在UseRouting之后、UseEndpoints之前。虽然新版模板里中间件顺序已经帮你排好了但你要是手动搭项目这个位置错了我见过好多次。3. 从零搭建一个带 Identity 的最小项目3.1 环境与包引用先用熟悉的模板创建一个空项目我这边以 MVC 为例dotnet new mvc -n IdentityDemo cd IdentityDemo然后安装需要的包。用 EF Core 存用户数据的场景需要这三个dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Microsoft.EntityFrameworkCore.Design第一个包把 Identity 跟 EF Core 打通第二个是数据库驱动想用 SQLite 或 PostgreSQL 也可以思路一样第三个是为了在开发环境跑迁移命令。这里多说一句Identity 其实不强制用 EF Core它有个存根接口IUserStoreTUser你可以自己实现一套存到 MongoDB、Redis 或者随便什么存储里。但 99% 的场景直接用 EF Core 就够了别自己折腾存储实现不是说你实现不了而是没必要在入门阶段增加复杂度。3.2 配置 DbContext 和 Identity 服务新建一个ApplicationDbContext继承IdentityDbContextIdentityUserusing Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; public class ApplicationDbContext : IdentityDbContextIdentityUser { public ApplicationDbContext(DbContextOptionsApplicationDbContext options) : base(options) { } }注意这里继承的是IdentityDbContext不是普通的DbContext。这个类内部已经把用户表、角色表、用户角色关联表等一系列实体都注册进模型了你不需要手动建DbSetIdentityUser。如果你自定义用户类把泛型参数替换掉就行比如IdentityDbContextAppUser。然后在 Program.cs 里注册服务builder.Services.AddDbContextApplicationDbContext(options options.UseSqlServer(builder.Configuration.GetConnectionString(Default))); builder.Services.AddIdentityIdentityUser, IdentityRole(options { // 密码策略这里先放宽便于演示 options.Password.RequireDigit false; options.Password.RequireLowercase false; options.Password.RequireUppercase false; options.Password.RequireNonAlphanumeric false; options.Password.RequiredLength 6; }) .AddEntityFrameworkStoresApplicationDbContext() .AddDefaultTokenProviders();AddDefaultTokenProviders很多人会忽略它是给找回密码、邮箱确认、两步验证生成安全令牌用的建议默认加上。令牌是一次性的、有有效期不是 JWT别搞混。接着注册认证中间件注意我前面说的顺序app.UseAuthentication(); app.UseAuthorization();最后跑一下迁移让数据库把 Identity 的表建出来dotnet ef migrations add InitIdentity dotnet ef database update看到数据库里出现七张 AspNet 开头的表说明这一步成了。3.3 注册登录的控制器实现接下来是正餐。写一个 AccountController先看注册的 Actionusing Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; public class AccountController : Controller { private readonly UserManagerIdentityUser _userManager; private readonly SignInManagerIdentityUser _signInManager; public AccountController( UserManagerIdentityUser userManager, SignInManagerIdentityUser signInManager) { _userManager userManager; _signInManager signInManager; } [HttpGet] public IActionResult Register() View(); [HttpPost] [ValidateAntiForgeryToken] public async TaskIActionResult Register(RegisterViewModel model) { if (!ModelState.IsValid) return View(model); var user new IdentityUser { UserName model.Email, Email model.Email }; var result await _userManager.CreateAsync(user, model.Password); if (result.Succeeded) { await _signInManager.SignInAsync(user, isPersistent: false); return RedirectToAction(Index, Home); } foreach (var error in result.Errors) { ModelState.AddModelError(string.Empty, error.Description); } return View(model); } }注册的核心就三行CreateAsync建用户成功之后SignInAsync直接登录然后跳转。CreateAsync内部会自动做密码哈希你不用操心盐和迭代次数。登录的 Action 长这样[HttpPost] [ValidateAntiForgeryToken] public async TaskIActionResult Login(LoginViewModel model, string? returnUrl null) { if (!ModelState.IsValid) return View(model); var result await _signInManager.PasswordSignInAsync( model.Email, model.Password, model.RememberMe, lockoutOnFailure: false); if (result.Succeeded) { if (!string.IsNullOrEmpty(returnUrl) Url.IsLocalUrl(returnUrl)) { return Redirect(returnUrl); } return RedirectToAction(Index, Home); } ModelState.AddModelError(string.Empty, 用户名或密码错误。); return View(model); }returnUrl的处理值得注意——登录成功后跳回之前想访问的页面是很自然的体验但Redirect(returnUrl)必须做本地 URL 校验否则容易被开放重定向攻击。这里用Url.IsLocalUrl判断新手最常犯的错误就是直接重定向。3.4 页面视图与防伪令牌视图本身不复杂一个表单、两个输入框、一个提交按钮。但记住一点POST 表单里必须有防伪令牌。model RegisterViewModel form asp-controllerAccount asp-actionRegister methodpost input asp-forEmail / span asp-validation-forEmail/span input asp-forPassword / span asp-validation-forPassword/span button typesubmit注册/button /form用asp-系列 Tag Helper 生成的表单会自动携带隐藏的__RequestVerificationToken配合控制器上的[ValidateAntiForgeryToken]就是完整的 CSRF 防护链路。如果你手写form action... methodpostToken 是不会自动带上的控制器那边就直接报 400 了。这是新手排查半天也找不到原因的高频坑。ViewModel 也贴一下方便你直接抄public class RegisterViewModel { [Required] [EmailAddress] public string Email { get; set; } string.Empty; [Required] [DataType(DataType.Password)] public string Password { get; set; } string.Empty; }4. 注册登录背后的完整请求链路4.1 注册时的密码哈希流程很多人好奇CreateAsync(user, password)到底干了什么。简单说它做了这几件事先校验密码是否符合策略就是你注册时配置的那几个 Password 选项。默认策略要求大写小写数字特殊字符各一个长度至少 6 位不符合直接返回错误不会进数据库。校验通过后PasswordHasherIdentityUser登场。默认实现是 PBKDF2它会用随机 Salt 对密码做十万次级别的迭代哈希结果转成 Base64 存到AspNetUsers.PasswordHash字段里。同一密码在不同用户身上存出来的哈希是完全不同的因为 Salt 不同。这带来的一个结果是你没法从数据库里反推密码也没法直接改库重置密码。真想重置密码要么走GeneratePasswordResetTokenAsyncResetPasswordAsync的流程要么临时改需求允许管理员用某些工具生成哈希。我见过有运维直接 UPDATE 密码字段结果用户永远登录不上去就是这个原因。4.2 登录时 Cookie 是怎么种下去的PasswordSignInAsync校验密码通过后会调用内部签名逻辑把用户的 Claims 打包成一个认证票据序列化后用应用的 Data Protection 密钥加密加密最终写进一个名为.AspNetCore.Cookies的 Cookie。这个 Cookie 不是明文存 UserId 的而是一个加密的票据。密钥托管在appsettings.Development.json之外开发环境默认存在用户本地密钥环里部署到服务器时如果不配置密钥持久化重启进程会导致所有已登录用户全部失效。这个问题我在生产环境踩过后面我会单独讲讲。isPersistent: false对应的是会话 Cookie浏览器关了就没true对应持久 Cookie会设置过期时间相当于记住我。注销时SignOutAsync会把 Cookie 清除同时服务器端不保留状态所以 Identity 的 Cookie 认证本身是无状态的——校验全部靠加密签名不需要 Session。4.3 后续请求如何被识别之后的每个请求带着 Cookie 过来UseAuthentication中间的 CookieHandler 会按顺序做四件事检查 Cookie 是否存在解密并验证签名防止篡改检查票据是否过期把票据里的 Claims 恢复成 ClaimsPrincipal 挂到HttpContext.User。之后你访问[Authorize]的接口时UseAuthorization检查 User 是否满足要求满足就放行不满足就 401未登录或者 403没权限。整个流程里几乎不查数据库所以性能上完全不用担心。有个细节默认 Cookie 的过期策略是滑动过期——用户持续活动就自动续期固定时间不活动才失效。你可以在AddIdentity之后用ConfigureApplicationCookie调整builder.Services.ConfigureApplicationCookie(options { options.LoginPath /Account/Login; options.AccessDeniedPath /Account/AccessDenied; options.ExpireTimeSpan TimeSpan.FromHours(2); options.SlidingExpiration true; });LoginPath也很关键——未登录用户访问[Authorize]页面时会被重定向到这里不配的话默认是/Account/Login如果你的路由不一样就得靠这行配置指过去。5. 默认表结构与扩展思路5.1 七张默认表各自管什么跑完迁移后数据库里出现七张表很多人看着一脸懵。我用大白话给你翻译一下表名中文名管什么AspNetUsers用户表用户的基本信息、密码哈希、锁定信息AspNetRoles角色表角色名和角色声明AspNetUserRoles用户角色关联表哪个用户属于哪个角色AspNetUserClaims用户声明表用户额外的声明数据AspNetRoleClaims角色声明表整个角色的声明数据该角色下所有用户共享AspNetUserLogins外部登录表第三方登录绑定微信、GitHub 等AspNetUserTokens用户令牌表找回密码、邮箱确认等一次性令牌你们家数据库里已经有的其他表完全不影响Identity 的表只是你数据库中的一部分。而且这些表之间有外键约束比如AspNetUserRoles引用了AspNetUsers.Id和AspNetRoles.Id删除用户时相关关联会自动清理或报错取决于你怎么配置级联删除。还有一点值得注意主键默认是字符串类型的 GUID而不是自增 int。这样设计是为了方便跨系统合并数据、避免暴露用户数量但如果你习惯 int 主键也可以自定义代价是要自己改实体和存储配置。入门阶段我建议别折腾GUID 够用。5.2 扩展字段与自定义 User 类需求里几乎必然有用户表要存手机号/昵称/头像这类情况。Identity 的方式是继承IdentityUser自己加字段public class AppUser : IdentityUser { public string? NickName { get; set; } public string? AvatarUrl { get; set; } public DateTime CreatedAt { get; set; } DateTime.Now; }然后 DbContext 和注册代码同步替换泛型参数public class ApplicationDbContext : IdentityDbContextAppUser { // 注意迁移时 Add-Migration 即可EF 会自动给 AspNetUsers 表加字段 } builder.Services.AddIdentityAppUser, IdentityRole() .AddEntityFrameworkStoresApplicationDbContext();别去改默认的IdentityUser类它在 NuGet 包里也别建一张额外的 Profile 表跟用户表搞一对一——能加字段就直接加Identity 的迁移机制会平滑处理。但注意已经有生产数据后加字段没问题改类型要谨慎比如把UserName从 string 改成别的那基本是灾难现场。5.3 角色的三种玩法角色在 Identity 里是可选组件但一旦用了就会发现它是管理权限最顺手的手段。最基础的用法// 创建角色 await _roleManager.CreateAsync(new IdentityRole(Admin)); // 给用户加角色 await _userManager.AddToRoleAsync(user, Admin); // 控制器上限制 [Authorize(Roles Admin)] public IActionResult AdminPanel() View(); // 页面里判断 if (User.IsInRole(Admin)) { a href/admin管理入口/a }角色也可以挂声明那就不只是判断属于哪个角色而是角色携带权限点。比如Admin角色挂一条Permission: DeleteUser的声明然后授权时检查声明。这种做法在系统功能点很多的时候特别好用——角色是壳权限是权利要求书两者分离管理粒度更细。不过我得提醒一句每加一个[Authorize(Roles ...)]之前先想想业务上是不是真的需要角色这个维度。角色是静态的、有限的组合很多场景其实用 Claim 就够了。角色泛滥之后改一个权限要同时动多个角色维护成本会直线上升。6. 进阶JWT 与 API 场景、常见误区6.1 Identity 在 API 项目里的姿势到这里为止上面讲的都是传统 MVC 场景——后端渲染页面Cookie 认证。但你要是用 ASP.NET Core 写前后端分离项目Identity 就不应该用 Cookie 那套了而是配合 JWT。别慌Cookie 和 JWT 不是互斥的只是签发校验方式不同。做法是注册 Identity 服务不变但认证中间件换成 JWT Bearerdotnet add package Microsoft.AspNetCore.Authentication.JwtBearerbuilder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options { options.TokenValidationParameters new TokenValidationParameters { ValidateIssuer true, ValidIssuer yourIssuer, ValidateAudience true, ValidAudience yourAudience, ValidateLifetime true, IssuerSigningKey new SymmetricSecurityKey(Encoding.UTF8.GetBytes(your-very-long-secret-key)) }; });登录时自己生成 Tokenvar user await _userManager.FindByNameAsync(model.Email); var passwordValid await _userManager.CheckPasswordAsync(user, model.Password); if (!passwordValid) { return Unauthorized(); } // 签发 JWT var claims new ListClaim { new Claim(ClaimTypes.NameIdentifier, user.Id), new Claim(ClaimTypes.Name, user.UserName), }; var userRoles await _userManager.GetRolesAsync(user); claims.AddRange(userRoles.Select(r new Claim(ClaimTypes.Role, r))); var key new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config[Jwt:Key])); var token new JwtSecurityToken( issuer: config[Jwt:Issuer], audience: config[Jwt:Audience], claims: claims, expires: DateTime.Now.AddHours(2), signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256)); return Ok(new { token new JwtSecurityTokenHandler().WriteToken(token), expiresAt DateTime.Now.AddHours(2) });用户管理的部分——注册、改密码、加角色——依然是 UserManager 那套一点不用改。变的主要是登录成功之后不再写 Cookie而是把 Token 返回给前端前端在后续请求头里带Authorization: Bearer xxx。这里有个常见的理解误区JWT 不是 Identity 的一部分Identity 管的是用户数据 密码策略 用户关系JWT 管的是认证票据的格式和签发校验。两者配合Identity 提供身份来源JWT 提供无状态的携带方式。6.2 新手最容易踩的坑我把教别人时反复出现的坑汇总一下建议你照着检查自己的项目没加UseAuthentication就加UseAuthorization。现象是登录后访问[Authorize]还是 401或者登录请求本身直接 500。检查 Program.cs 中间件顺序。POST 表单报 400 防伪令牌错误。多半是手写了 HTML 表单没用 Tag Helper或者忘了在 Action 上加[ValidateAntiForgeryToken]。改了用户实体但没迁移。现象是运行时报 SQL 列不存在。记住每次改 AppUser 后紧接着 Add-Migration。连接字符串没配。默认模板的appsettings.json里连接字符串是空的启动时直接抛异常说无法连接数据库很多人第一反应是Identity 好难其实只是没配 SQL Server。密码策略过严导致注册永远失败。默认策略要大小写数字符号全齐在测试阶段建议像我上面那样放宽几项。开发环境令牌密钥每次重启失效。这是生产环境登录被频繁踢掉的经典原因需要在 Program.cs 里配置密钥持久化到证书或某个固定存储。6.3 排查 Identity 问题的三板斧Identity 最大的排查难点在于错误都是间接的——不是编译器报错而是行为不对。我通常按这个顺序排查先开日志。在appsettings.Development.json里把 LogLevel 调到Information甚至临时把 Microsoft.AspNetCore.Authentication 调成 Debug。认证中间件会把每个阶段的决策写到日志里是哪个步骤拒绝的、因为什么拒绝的一目了然。很多人不看日志凭感觉猜效率太低。再查 Cookies。浏览器开发者工具的 Application/Storage 面板里看有没有.AspNetCore.Cookies。没有说明登录压根没成功有但访问还是 401多半是解密失败或者票据过期。最后看数据库。查AspNetUsers里用户是否存在、PasswordHash是否非空、LockoutEnd是不是被设了值。安全锁定策略其实也在AddIdentity的配置里默认连续五次失败会锁五分钟你要是反复测试密码很可能不是密码错了是被锁了。7. 项目落地的收尾检查清单到这里一个能跑通注册登录、带角色管理、还能按需切换 JWT 的 Identity 入门项目就成型了。我说说在实际项目里除了跑通之外还要过的几道关。一个是密码策略的平衡。别把生产环境的密码策略设得跟演示环境一样宽松也别最后上线发现完全没法改策略——Identity 的策略全在启动配置里随时能调整但记住一点改策略只影响后续创建和修改密码存量用户不受影响。第二个是 Remember Me 的真实语义。默认的持久 Cookie 有效时长我建议别设太长两周已经算久了。而且要跟用户讲清楚公共电脑上别勾。第三个是外部登录。Identity 留好了AspNetUserLogins这张表就是为了对接外部账号体系。等你需要接第三方登录时记得把外部登录和本地账号做绑定逻辑别让一个邮箱能注册出两个账号。第四个是 HTTPS。无论 Cookie 还是 JWT明文传输都有被截获的风险。开发环境可以用 HTTP生产环境一定保证全链路 HTTPSCookie 配置里把SecurePolicy设为 Always。最后是密钥管理。生产环境的 Data Protection 密钥和 JWT 签名密钥都不能写死在代码里也别提交到仓库。配置文件里用环境变量、用户机密或者部署平台的密钥服务来注入。这件事等出问题的时候再补救代价很高一开始就按正确姿势来。我在实际项目里遇到的 Identity 相关故障十有八九不是框架本身的问题而是配置、顺序、密钥这类小问题。Identity 作为一个被成千上万项目验证过的框架稳定性是值得信赖的你只需要把上面这张清单走一遍就能避开绝大多数坑。剩下那部分等你在真实项目里用到权限扩展、多租户、复杂声明逻辑的时候再回来翻看这套核心机制会理解得比今天更深。