首页 / 资讯中心 / 文章详情

ASP.NET Core Identity实战:从Session登录验证到Cookie与Claims权限管理

ASP.NET Core Identity实战:从Session登录验证到Cookie与Claims权限管理 ★ FEATURED ARTICLE
1. 自己用 Session 做登录验证问题出在哪我最早做 Web 开发那会儿身份验证这块见过很多同事拿 Session 硬拼。用户登录成功了就往 Session 里塞一个 UserId每个请求进来自己判断 Session 里有没有值没有就跳回登录页。这套做法在 demo 和小工具里确实能跑但项目一旦多几个用户、多几个接口问题就冒出来了Session 失效、重启、多机负载均衡时 Session 不同步用户动不动就掉线密码只能拿 MD5 或 SHA 存撞库风险高完全没有加盐和迭代设计忘了做锁定策略爆破攻击可以无限试密码角色、权限、用户信息全部散落在业务代码里每个控制器自己写一套判断逻辑。后来切到ASP.NET Core Identity之后这些东西全部变成了框架能力密码哈希由它管、Cookie 票据由它管、锁定策略由它管、角色和声明由它管甚至邮箱确认、手机号确认、外部登录的 Token 存储都有现成的抽象。这篇文章我不会去复述官方文档而是按我实际改造一个模拟项目 X 的过程来讲怎么从零接上 Identity、跑通注册登录再把容易踩的坑挨个说清楚。这套内容适合谁刚入手 ASP.NET Core、准备把用户系统从自研 Session 方案换成框架方案的开发者以及已经在用 Identity 但只停留在“能登录”阶段、想搞清楚 Cookie 和 Claims 原理的人。2. 最小可跑的搭建流程从空项目到注册登录2.1 创建项目与引入身份相关包先建一个 ASP.NET Core MVC 项目我用的是 .NET 8。模板自带空的结构就行不需要勾选“个人用户账户”因为那个模板帮你把一堆代码生成了反而看不清每部分在干什么。需要引入的 NuGet 包有这些Microsoft.AspNetCore.Identity.EntityFrameworkCoreMicrosoft.EntityFrameworkCore.SqlServer如果用 SQL Server别的库也支持Microsoft.AspNetCore.Identity.UI提供默认的 Razor 身份页面但后面我会讲为什么我一般不直接用很多人会漏掉第二个包结果写UseSqlServer时编译报错一脸懵。EntityFrameworkCore 容器扩展方法在Microsoft.EntityFrameworkCore.SqlServer这个包里不是身份包自带的。2.2 配置数据库上下文与 Identity 服务注册我习惯的做法是新建一个用户类继承IdentityUser哪怕现在不扩展任何字段也要留好扩展口子public class ApplicationUser : IdentityUser { }再建数据库上下文继承IdentityDbContextApplicationUserpublic class AppDbContext : IdentityDbContextApplicationUser { public AppDbContext(DbContextOptionsAppDbContext options) : base(options) { } }然后在Program.cs里注册builder.Services.AddDbContextAppDbContext(options options.UseSqlServer(builder.Configuration.GetConnectionString(DefaultConnection))); builder.Services.AddIdentityApplicationUser, IdentityRole(options { }) .AddEntityFrameworkStoresAppDbContext() .AddDefaultTokenProviders();AddDefaultTokenProviders容易被忽略但 email 确认、忘记密码、双因素验证的 Token 全靠它生成。不加的话后面用到GenerateEmailConfirmationTokenAsync时会直接抛异常。中间件的顺序也很有讲究app.UseAuthentication(); app.UseAuthorization();必须在UseAuthorization之前先UseAuthentication否则[Authorize]拿到的HttpContext.User一直是空的。这个顺序错了最常见的问题就是所有标记了[Authorize]的页面全部 302 到登录页因为框架根本不认识当前用户。2.3 注册和登录的控制器与页面我不直接用 Identity UI 的 Razor 页面而是自己写控制器操作。原因有两个一是方便看执行流程二是很多项目需要定制字段和跳转逻辑自己写比改生成代码更干净。注册操作核心逻辑用UserManager[HttpPost] public async TaskIActionResult Register(RegisterViewModel model) { if (!ModelState.IsValid) { return View(model); } var user new ApplicationUser { UserName model.Email, Email model.Email }; var result await _userManager.CreateAsync(user, model.Password); if (!result.Succeeded) { foreach (var error in result.Errors) { ModelState.AddModelError(string.Empty, error.Description); } return View(model); } return RedirectToAction(Login); }登录用SignInManager[HttpPost] public async TaskIActionResult Login(LoginViewModel model) { if (!ModelState.IsValid) { return View(model); } var result await _signInManager.PasswordSignInAsync( model.Email, model.Password, model.RememberMe, lockoutOnFailure: true); if (result.Succeeded) { return RedirectToAction(Index, Home); } if (result.IsLockedOut) { ModelState.AddModelError(string.Empty, 账号已被锁定请稍后再试。); return View(model); } ModelState.AddModelError(string.Empty, 用户名或密码错误。); return View(model); }这里要说清楚UserManager和SignInManager的分工这是我见过很多新人最容易搞混的地方。简单来说UserManager管的是用户数据的增删改查和密码相关操作SignInManager管的是“登录状态”的建立和销毁。注册用户、改密码、确认邮箱用UserManager登录、登出、判断当前用户是什么状态用SignInManager。2.4 跑通之后会看到什么数据库迁移建好、跑起来之后你会发现 Identity 自动生成了这些表AspNetUsers用户主表里面已经有Id、UserName、PasswordHash、SecurityStamp、ConcurrencyStamp等列AspNetRoles、AspNetRoleClaims角色定义和角色声明AspNetUserClaims用户声明AspNetUserRoles用户与角色的多对多关系AspNetUserLogins外部登录信息AspNetUserTokens用于存储认证 Token第一次看到这么多表不用慌大部分在常规项目里只用得到其中三四张。后面我们会逐个说它们的使用场景。登录成功后打开浏览器开发者工具能看到一个名为.AspNetCore.Identity.Application的 Cookie。这个 Cookie 是加密的你直接看内容看不到用户名和角色但这正是框架安全设计的一部分。3. 核心机制拆解Claim、Cookie、中间件到底是怎么协作的3.1 Claims 是什么和传统角色判断有什么区别早期做权限最常见的写法是if (user.Role Admin)。这种硬编码在角色一多就变得很痛苦每个控制器都要写一遍判断而且权限粒度只能到角色没法说“这个用户只能编辑他自己创建的文章”。Identity 把权限判断的粒度下沉到了Claim层面。Claim 翻译过来就是“声明”是一条一条的键值对比如name 张三、role Admin、department 技术部。一个用户的全部 Claim 组合在一起就是他的身份。当用户登录成功后框架会把用户的一些关键 Claim 写入加密 Cookie。后续每个请求进来框架解密 Cookie把这些 Claim 读出来组装成一个ClaimsPrincipal身份主体挂在HttpContext.User上。所以你在控制器里写var name User.FindFirstValue(ClaimTypes.Name); var role User.FindFirstValue(ClaimTypes.Role);这个User对象不是实时查数据库得到的而是从 Cookie 里的 Claims 还原出来的。这也是为什么修改了用户的角色后要等 Cookie 过期或者重新登录才生效——不是框架傻是你当前这个请求的身份票据里还写着旧角色。3.2 中间件的执行时机认证链路的完整过程是这样的请求进入UseAuthentication中间件。中间件查看请求是否携带认证 Cookie。如果有用数据保护 API 解密并验证签名。验证通过后把 Cookie 里的 Claims 组装成ClaimsPrincipal赋给HttpContext.User。请求继续往下走进入UseAuthorization。UseAuthorization根据端点上的[Authorize]特性判断当前用户是否满足访问条件。曾经有个同事问我“为什么我明明在OnPostLoginAsync里已经登录成功了但是HttpContext.User还是空的”答案就藏在步骤 4 和步骤 2 的时序里。登录成功的那个请求本身并没有携带认证 CookieCookie 是在响应时写回浏览器的。所以只有下一个请求进来UseAuthentication才能解析到身份。登录请求当前这一次的HttpContext.User自然就是空的了。我把这个原理解释清楚你以后排查类似问题能少走很多弯路。3.3 Cookie 票据的格式与用途Authentication Cookie 里写入的其实是受保护的数据包含 ClaimsPrincipal、票据签发时间、过期时间以及一个用于防篡改的签名。它在每次请求往返时都会被服务端解密、校验签名一旦中间有人试图修改 Cookie 内容签名校验立刻失败该请求会被视为未认证。有个设计细节很值得注意Identity 默认不会在 Cookie 里存储整个用户对象。它存的是一个序列化后的 ClaimsPrincipal。这意味着如果你往ClaimsPrincipal里塞了一个非常大的自定义 Claim每次请求都会多花时间在序列化和反序列化上请求体积也会增大。我见过有人把一个上万字符的文本塞进自定义 Claim结果每次请求多了几十毫秒。遇到这种情况正确方案是在数据库里存数据Claim 里只存一个主键或标识。3.4 [Authorize] 特性背后发生了什么[Authorize]在 MVC 里是一个过滤器在端点路由里是一个授权约定。它的核心逻辑往简单了说就是两步检查当前用户是否已认证HttpContext.User.Identity.IsAuthenticated是否为真。如果认证了再检查是否满足指定的角色或策略。但有个细节容易让人困惑如果你对着一个 API 接口标了[Authorize]而未认证用户访问它会返回什么默认行为不是 401而是302 跳转到登录页。因为 Cookie 认证方案默认的OnRedirectToLogin事件会把所有未授权请求重定向到登录路径。对于 MVC 页面这是合理的但对 API 项目这就很恼人了。后面专门有一节讲怎么处理。4. 实际项目里真正会碰到的扩展需求4.1 给用户表增加自定义字段Identity 最方便的地方在于它的扩展性。比如我们要给用户加一个“昵称”和“头像地址”字段public class ApplicationUser : IdentityUser { public string NickName { get; set; } public string AvatarUrl { get; set; } }改了用户类数据库上下文不用大动IdentityDbContextApplicationUser已经把你的用户类接进了模型。然后做一次迁移dotnet ef migrations add AddNickNameAndAvatarToUser dotnet ef database update表结构就多出两列。这里有个实操细节新增字段后旧的注册逻辑里如果没有给NickName赋值默认是 null页面上就位上显示空白。我习惯在注册时强制要求填昵称再配合IValidatableObject做校验。否则每次还要在前端做判空处理麻烦。4.2 角色管理与基于角色的授权角色在 Identity 里有专属的一张表AspNetRoles用户和角色的关系在AspNetUserRoles。注册用户的代码假设我们已有Admin角色if (!await _roleManager.RoleExistsAsync(Admin)) { await _roleManager.CreateAsync(new IdentityRole(Admin)); } await _userManager.AddToRoleAsync(user, Admin);然后在控制器方法上[Authorize(Roles Admin)] public IActionResult ManageUsers() { return View(); }或者放在控制器类上面标记为整个控制器都需要 Admin 角色。这里要注意[Authorize]是可以叠加的类级别写一个方法级别再写一个逻辑上是 AND 关系。也就是说满足的角色集合是交集而不是并集。这个特性在我的项目里用得很频繁。比如后台管理页整组接口都要求管理员但里面单个导出接口又要求必须是超级管理员这样一组合就非常灵活。4.3 Claims 授权、策略授权以及 JWT 混合认证角色能管住粗粒度的权限但要实现“超过 18 岁才能访问”或“编辑者只能改自己的文章”这种需求角色就不够细了。Identity 的现代解法是策略授权。先在Program.cs里定义一个策略builder.Services.AddAuthorization(options { options.AddPolicy(Over18, policy policy.RequireClaim(Age, 18, 19, 20)); });然后在控制器上使用策略[Authorize(Policy Over18)] public IActionResult AdultSection() { return View(); }这里展示的是从 Claim 里直接判断年龄是否符合实际项目里更推荐的做法是把年龄换算成出生日期范围的 Claim这只是一个示意。再说说 JWT 混合。现在很多项目是前后端分离接口层用 JWTWeb 管理端用 Cookie。Identity 完全支持两个认证方案并存。注册的时候这样写builder.Services.AddAuthentication() .AddIdentityCookies(options { }) .AddJwtBearer(options { options.TokenValidationParameters new TokenValidationParameters { ValidateIssuer true, ValidIssuer builder.Configuration[Jwt:Issuer], ValidateAudience true, ValidAudience builder.Configuration[Jwt:Audience], ValidateIssuerSigningKey true, IssuerSigningKey new SymmetricSecurityKey( Encoding.UTF8.GetBytes(builder.Configuration[Jwt:Key])) }; });这样配置完成后同一个项目里有些端点接受 JWT有些端点接受 Cookie[Authorize]特性会自动根据请求携带的认证方式选择对应的处理方案。默认策略是“任何一个认证方案成功就算认证通过”。4.4 外部登录的接入思路说句实话外部登录在代码层面并不复杂Identity 已经把 Google、微信、GitHub 之类的外部登录抽象成了AddGoogle、AddWeChat这类扩展方法。每个外部登录本质上是三件事引导用户跳转到第三方授权页。第三方授权成功后回调到你指定的 URL。框架用拿到的用户信息找本地用户找不到就自动创建新用户并绑定外部登录信息。这里有一个经常让新同事困惑的点为什么外部登录回调之后明明第三方返回了用户邮箱但本地的AspNetUsers表里却没建出用户因为默认配置下外部登录返回的信息不会自动写入本地用户表需要你在回调事件里手动把ExternalLoginInfo落到UserManager中。如果你的项目允许外部登录记得注册一个事件处理器来处理OnCreatingTicket或回调后的用户创建逻辑。5. 部署上线的配置陷阱和常见坑5.1 API 请求被 302 到登录页的修复这是我在前后端分离项目里被坑得最惨的一次。前端调登录后的某个 API正常情况下应该返回 401让前端提示用户重新登录。结果服务端返回的是一个 302 的 HTML 页面前端拿到响应直接找不到数据。如果你也用 Cookie 认证需要在配置 Cookie 时改掉默认的重定向行为builder.Services.ConfigureApplicationCookie(options { options.Events.OnRedirectToLogin context { if (context.Request.Path.StartsWithSegments(/api)) { context.Response.StatusCode StatusCodes.Status401Unauthorized; return Task.CompletedTask; } context.Response.Redirect(context.RedirectUri); return Task.CompletedTask; }; });改完之后API 请求未认证时返回 401而 MVC 页面仍然正常跳转登录。这个细节没有写进任何显眼文档里但几乎每个 API 项目都会踩到。5.2 数据保护密钥导致的“上线第二天全掉线”Identity 的登录 Cookie 是用 ASP.NET Core 的数据保护 API加密的。这个 API 需要一个持久化的密钥环。开发环境下密钥环默认放在本机用户目录的临时文件夹里重启一次机器密钥可能就变了但开发机器基本无感。到了生产环境就麻烦了。假如你的应用部署在容器里容器的临时文件系统是内存盘每次重启容器密钥环就丢掉。结果就是应用一重启所有用户手上的 Cookie 全部失效所有人都被迫重新登录。我第一次在生产环境遇到用户集体掉线排查了很久才找到原因。后来统一做了配置把密钥环持久化到共享存储builder.Services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(/secure/keys)) .SetApplicationName(myapp);SetApplicationName也很关键。同一个机器上部署多个应用时如果应用名不同解密时用的是各自的密钥环不会串。但如果你把 Cookie 方案名改了或者想把测试环境和生产环境的密钥共用应用名不一致就会导致解密失败。5.3 密码策略过严导致的用户体验问题Identity 默认的密码策略要求长度至少 6 位、必须包含数字、必须包含非字母数字字符、必须包含大小写字母。安全性当然没问题但面对的是普通用户群体时这个策略会让注册转化率直线下降。我通常会按业务场景微调builder.Services.AddIdentityApplicationUser, IdentityRole(options { options.Password.RequiredLength 8; options.Password.RequireDigit true; options.Password.RequireNonAlphanumeric false; options.Password.RequireUppercase true; options.Password.RequireLowercase true; options.Lockout.DefaultLockoutTimeSpan TimeSpan.FromMinutes(5); options.Lockout.MaxFailedAccessAttempts 5; })这里拆开说一下。RequireNonAlphanumeric false意思是允许纯字母数字组合密码对很多非技术用户来说他们很难理解为什么不能设置一个“只有字母和数字”的密码。但保留大小写和数字组合安全性依然够用。锁定策略默认是 5 次失败锁 5 分钟这个对 SaaS 类产品偏短对后台管理系统偏宽松根据你的业务负载来定。5.4 登录状态异常丢失的排查思路不少开发者在实际项目里会遇到“用户明明登录了过了两三分钟又让我重新登录”的情况。这类问题排查时我一般先看三处第一Cookie 的过期时间。默认配置下 Cookie 是会话级别的只有 14 天内的滑动过期行为。具体来说只要用户在有效期内有操作过期时间就自动延长。如果你配置了较短的ExpireTimeSpan用户闲置一会就会掉线。这个参数要按业务来调。第二SecurityStamp机制。Identity 里有一种“强制注销所有会话”的设计当用户改密码、被管理员禁用、或者角色发生变化时SecurityStamp会更新。而 Cookie 里的 stamp 和数据库里的 stamp 不一致时下一个请求就会触发重新验证导致登录状态失效。如果你频繁修改用户数据可能会让用户一直在“刚登录→状态失效→再登录”的循环里。第三多域名部署。Cookie 是有域限制的如果你应用同时跑在www.example.com和admin.example.com下它们之间默认不共享 Cookie。需要正确配置Cookie.Domain或者把两个站点放在同一个父域下。这个坑非常隐蔽因为本机开发时完全看不出来。5.5 用户被锁定后的提示要友好PasswordSignInAsync的lockoutOnFailure: true参数打开后如果用户连续输错密码账号会被锁定。但是默认的行为是锁定期内继续输错仍然提示“用户名或密码错误”不会明确告诉你账号锁定了。这个设计是出于安全考虑——避免泄露账号锁定状态给攻击者。但对真实用户来说一直提示密码错误会让人以为是自己记错了然后反复尝试越试越锁。我的处理方式是在登录页面加一个提示锁定期内再次尝试就给一个明显的提示比如“账号已锁定请 X 分钟后再试”。毕竟对正常用户来说明确告知并不会降低多少安全性但体验提升非常明显。6. 上线前要做的几个收尾动作与我的最终建议跑通注册登录只是开始真正上线前还要把几件事做掉。第一邮箱确认。我见过不少项目直接省略邮箱确认功能。如果你是做面向公众的产品用户用假邮箱注册会带来很多问题垃圾账号、找回密码失败、营销触达质量差。Identity 里邮箱确认的实现并不复杂注册后生成确认 Token发邮件点击链接后ConfirmEmailAsync。默认登录逻辑里还可以配置成“未确认邮箱的用户不能登录”options.SignIn.RequireConfirmedEmail true;第二密码重置流程。不要自己写 Token 生成逻辑。Identity 的GeneratePasswordResetTokenAsync生成的 Token 里内置了防猜测和防篡改机制而且是绑定到用户安全戳上的。如果你自己搞一个随机字符串安全性大概率不如框架现成方案。第三启用审计日志。用户登录成功、失败、被锁定这些事件默认没有任何记录。线上出问题时你能拿到的数据非常少。我是用中间件把这些事件接出来写进结构化日志里。后面排查问题时登录日志几乎是第一手证据。最后说一下我个人的经验总结吧。用 Identity 这几年我最深的感受是不要贪多把 Cookie 认证这一套核心机制彻底吃透比什么都管用。外部登录、JWT、双因素认证这些东西都是在一个稳固的认证基座上加的模块。你只要搞清楚 Claims 怎么流转、Cookie 票据什么时候写入和校验、SecurityStamp 的触发条件遇到任何奇怪问题都能自己排查不会每次都在社区里做伸手党。项目初期我给模拟项目 X 接 Identity 花了两天后来再给另一个系统接只花了半天。区别就在于第一遍踩坑之后把每个配置项背后的原理都摸了一遍。这篇东西如果能把你在 Identity 上的试错成本降低哪怕一半我觉得就没有白写。
阅读完成 · 觉得有帮助?
咨询建站