做过几个后台管理系统之后,你会发现一个规律:无论业务多简单,“登录登出”这一关永远绕不开。我第一次在实际项目里用 ASP.NET Core 的 Identity 框架时,其实纠结了很久——网上资料多,但多数只教你“点几下就生成好了”,真正讲到框架内部怎么流转、为什么这么设计的内容很少。这篇文章以 ASP.NET Core 7 为背景,把自己从项目搭建到线上踩坑的完整过程整理出来,尤其是那些文档不会写、只有真的做过一遍才踩得到的细节。
我接手过一个内部培训管理系统,需求调研会上老板拍板“登录注册给我做出来,一周上线”。我一听还把用户表、密码加密、Session 都列了个计划,最后被同事劝住:“直接用 Identity 吧,别自己造轮子了。”后来证明这句话至少帮我省了一周。如果你也正卡在“该不该用 Identity”或者“用了之后怎么改”这个路口,这篇文章应该能给你省下不少试错时间。
1. 先搞清楚Identity到底做了哪些事
1.1 为什么“自己写一个登录”不划算
很多人第一反应是:登录系统不就是一张用户表加一个密码比对吗?我刚开始也这么干过,直到发现要补的东西越来越多。存密码不能存明文吧?那得做哈希加盐。用户忘记密码要重置,光生成重置链接就能写一整天。连续输错密码要锁账号,防止暴力破解。还要记住密码、记住登录状态、支持第三方登录、支持角色权限控制、让管理员能封禁某个用户……
这些东西单拆开来都不难,但合在一起就是一个完整的身份认证体系,任何一个环节考虑不周,上线之后都可能变成安全事故。
ASP.NET Core 7 内置的 Identity 框架,恰好把这些标准功能全部做出来了。它解决了两个核心问题:一是“你是谁”——通过用户名、邮箱、密码登录来确认身份;二是“你能干什么”——通过角色(Role)和声明(Claim)控制系统里的各种权限。你不需要从零设计用户表结构,不需要自己写密码哈希逻辑,也不需要担心 Cookie 认证流程出错,框架已经帮你处理好了。
1.2 Identity的组成结构:User、Role、Claim
刚开始接触 Identity 时,我最晕的就是“User、Role、Claim、Token”这几个概念的关系。后来我自己画了个图才理顺,这里用大白话给你解释。
User 就是用户本身,对应一张 Users 表。默认字段包含用户名、Email、手机号、密码哈希、锁定信息等。Role 是角色,比如“管理员”“编辑”“访客”,对应 Roles 表。User 和 Role 是多对多关系,中间表叫 UserRoles。Claim 是声明,可以理解成“用户身上带的一个个标签”,比如“部门=技术部”“工号=00123”。Claim 比 Role 更细粒度,适合做类似“只允许技术部的员工访问这个页面”这种授权逻辑。
这三者配合起来,就形成了一套完整的认证授权体系:用户登录成功后,系统会把用户包含的角色和声明打包在一个“身份”里,后续每次请求都带上这个身份,框架通过它判断你能不能访问某个接口或页面。理解了这条主线,后面所有配置都不会觉得乱。
2. 从零搭建:把登录功能跑起来
2.1 项目创建与Identity脚手架
我在 .NET 7 里用的还是传统的 MVC 模板,因为 Identity 默认带的 UI 页面(注册、登录、忘记密码)就是给 MVC 准备的。打开终端执行:
dotnet new mvc -n IdentityDemo cd IdentityDemo接下来要加几个包。这里容易踩坑:网上很多教程会让你直接安装Microsoft.AspNetCore.Identity.EntityFrameworkCore,但别忘了它依赖 EF Core,你还需要指定使用哪种数据库。我图省事用了 SQLite,一条命令全搞定:
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore dotnet add package Microsoft.EntityFrameworkCore.Sqlite dotnet add package Microsoft.EntityFrameworkCore.Design dotnet add package Microsoft.AspNetCore.Identity.UI然后运行脚手架命令,让它把 Identity 相关页面自动生成到项目里:
dotnet aspnet-codegenerator identity --useDefaultUI --dbContext ApplicationDbContext --force这条命令会做几件事:生成ApplicationDbContext,它继承自IdentityDbContext;生成 Areas/Identity 目录,里面放的是管理登录注册的 Razor 页面;还会在Program.cs里补上部分配置。如果你的 VS Code 命令行找不到aspnet-codegenerator,说明缺少全局工具,先执行:
dotnet tool install -g dotnet-aspnet-codegenerator生成完之后别急着跑,还有几个配置要手工检查,不然启动就会报错。
2.2 注册服务与中间件配置
打开Program.cs,核对下面几行是否齐全。我的写法是显式加上了角色服务,因为很多场景都需要用到“管理员”这个概念:
using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Identity; using IdentityDemo.Data; var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllersWithViews(); builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlite("Data Source=app.db")); builder.Services.AddDefaultIdentity<IdentityUser>(options => { options.SignIn.RequireConfirmedAccount = false; }) .AddRoles<IdentityRole>() .AddEntityFrameworkStores<ApplicationDbContext>(); var app = builder.Build(); if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Home/Error"); app.UseHsts(); } app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.MapRazorPages(); app.MapDefaultControllerRoute(); app.Run();几个要留意的细节:
UseAuthentication()和UseAuthorization()的顺序不能反,先有认证才能做授权,这是中间件管道的铁律。启动项目前记得执行数据库迁移:
dotnet ef migrations add InitialIdentity dotnet ef database update不执行这两步,运行时会抛“no such table: AspNetUsers”之类的错误。这里多说一句:AddDefaultIdentity默认会注册掉SignInManager、UserManager、RoleManager这些服务,依赖注入直接在构造函数里拿就行,不需要额外 new。
2.3 在 VS Code 里把项目跑起来
经常有人问“VS Code 怎么运行 ASP.NET Core 项目”。其实 .NET 的命令行工具和 IDE 是解耦的,VS Code 只是编辑器,运行靠的是dotnetCLI。三步走:
dotnet restore dotnet build dotnet run看到 “Now listening on: https://localhost:xxxx” 就说明起来了。浏览器打开对应地址,你会发现右上角自动出现了“Register”和“Login”链接,这就是 Identity 自带 UI 页面。
如果你想让 VS Code 支持断点调试,需要安装 C# Dev Kit 插件。装好之后,打开.csproj文件,VS Code 会提示生成launch.json,选“C#”这个调试配置,按 F5 就能命中断点。用习惯之后我觉得比 Visual Studio 还轻便,尤其适合写小项目和做演示。
3. 核心机制拆解:三个Manager撑起整个框架
3.1 UserManager:用户数据的CRUD
Identity 的价值一半体现在UserManager<TUser>上。这个类封装了几乎所有用户管理的原子操作:创建用户、删除用户、修改密码、确认邮箱、锁定账号、添加删除 Claim 和角色。我平时用得最多的是这个:
public class UserService { private readonly UserManager<IdentityUser> _userManager; public UserService(UserManager<IdentityUser> userManager) { _userManager = userManager; } public async Task<IdentityResult> CreateUserAsync(string username, string email, string password) { var user = new IdentityUser { UserName = username, Email = email, EmailConfirmed = true }; var result = await _userManager.CreateAsync(user, password); return result; } }注意CreateAsync的第二个参数是明文密码,框架内部会自动生成哈希存储,你永远不需要自己写 MD5 或 SHA256,那是给自己挖坑。拿到IdentityResult之后,记得检查result.Succeeded,如果失败,result.Errors里会有具体原因,比如密码强度不够、用户名已存在。我在项目中一定会把这些错误逐条显示到前端,不这么干用户都不知道自己错哪了。
另外,UserManager还提供FindByIdAsync、FindByNameAsync、FindByEmailAsync、GetRolesAsync、IsInRoleAsync这些查询方法,基本覆盖了日常所有查询场景。它的设计意图很清晰:你的控制器尽可能只依赖 UserManager,不直接操作数据库上下文,这样用户数据的一致性有框架保证,也不会写出“把密码哈希直接 update 进表里”的骚操作。
3.2 SignInManager:登录、登出与Cookie认证
SignInManager<TUser>管的是“你怎么证明你登录过”。每个请求进来时,框架会读取 Cookie 里的身份票据,验证票据的有效性,然后恢复用户信息。默认情况下这些逻辑都在UseAuthentication()中间件里完成了,但“验证密码并签发票据”的动作,必须由你主动调用。
一个标准登录流程长这样:
public class AccountController : Controller { private readonly SignInManager<IdentityUser> _signInManager; private readonly UserManager<IdentityUser> _userManager; public AccountController( SignInManager<IdentityUser> signInManager, UserManager<IdentityUser> userManager) { _signInManager = signInManager; _userManager = userManager; } [HttpPost] [ValidateAntiForgeryToken] public async Task<IActionResult> Login(LoginViewModel model) { if (!ModelState.IsValid) return View(model); var result = await _signInManager.PasswordSignInAsync( model.UserName, model.Password, isPersistent: model.RememberMe, lockoutOnFailure: true); if (result.Succeeded) return RedirectToAction("Index", "Home"); if (result.IsLockedOut) ModelState.AddModelError(string.Empty, "账号已被锁定,请稍后再试"); else ModelState.AddModelError(string.Empty, "用户名或密码错误"); return View(model); } [HttpPost] public async Task<IActionResult> Logout() { await _signInManager.SignOutAsync(); return RedirectToAction("Index", "Home"); } }PasswordSignInAsync的第四个参数lockoutOnFailure值得讲讲,我见过不少人这里直接传false,等于白白放弃了防暴力破解的能力。它的作用是:连续输错密码超过阈值后,账号进入锁定状态。锁定时间在AddDefaultIdentity的选项里配置,默认是 5 次失败锁 5 分钟。生产环境我一定开这个,不然弱密码用户会被撞库撞到怀疑人生。
登出调用的SignOutAsync所做的核心操作,就是清除当前 HTTP 上下文里的登录 Cookie。弄懂了这一点,你遇到“退出登录后按浏览器返回还能看到页面”的情况,就不会慌了——那只是浏览器本地缓存,刷新一下就会跳回登录页。
3.3 RoleManager与Claims:权限从哪来
权限控制我分成两级来用。第一级是 Role,适合“整个模块”的粗粒度控制。比如后台管理页必须“管理员”才能进,直接用特性标签:
[Authorize(Roles = "Admin")] public IActionResult AdminDashboard() { return View(); }角色数据的维护靠RoleManager<IdentityRole>。初始化角色是项目启动时必做的一件事,我习惯写成一个种子方法:
public static async Task EnsureRolesAsync(IServiceProvider serviceProvider) { var roleManager = serviceProvider.GetRequiredService<RoleManager<IdentityRole>>(); string[] roleNames = { "Admin", "User" }; foreach (var roleName in roleNames) { if (!await roleManager.RoleExistsAsync(roleName)) { await roleManager.CreateAsync(new IdentityRole(roleName)); } } }第二级是 Claim,适合“数据级别”的细节控制。比如某个报表页面,只有“部门=A”的员工能看。这时业务字段不应该塞进 Role 名字里面,否则角色会爆炸。正确做法是在注册或登录的时候给用户附加一个自定义 Claim:
await _userManager.AddClaimAsync(user, new Claim("Department", "A"));然后写一个授权策略:
builder.Services.AddAuthorization(options => { options.AddPolicy("DepartmentAOnly", policy => policy.RequireClaim("Department", "A")); });用法就是[Authorize(Policy = "DepartmentAOnly")]。这种用“角色管大面、Claim 管细节”的方式,在项目里非常顺手,逻辑也清晰。
4. 踩坑记录:几个让我头痛的问题
4.1 登录页面无限重定向
刚把项目部署到测试环境时,我遇到过一个极其诡异的问题:访问任何需要登录的页面,全部跳转到登录页,填完账号密码提交,又跳回登录页,像一个死循环。排查了大半天,最后发现是 HTTPS 和 Cookie 的安全策略打架了。
默认情况下,UseHttpsRedirection()会把 HTTP 请求重定向到 HTTPS。但测试环境直接用 IP 访问,证书是自签的,浏览器根本不信任。Cookie 又设置了Secure属性,只在 HTTPS 连接下传输。登录成功后浏览器拿到新 Cookie,结果当前连接是 HTTP,Cookie 发不回去,服务端每次看到的都是“未登录”,于是死循环。
解决方案有两种,看你部署环境怎么搭:一种是测试环境就别启用 HTTPS 重定向,直接在开发环境用 HTTP 调;另一种是搭一个可信的反向代理,终止 TLS 之后内部走 HTTP,同时把ForwardedHeaders中间件打开。我后来用的第二种,正式环境也稳定。
4.2 密码策略太严导致用户流失
Identity 默认密码策略要求至少 6 位、大小写和数字混合、还有特殊字符。这对内部系统来说简直劝退。有一次给客户演示,现场输密码我设了个Admin@123,结果因为里面没有小写字母被拒了,场面一度非常尴尬。
这个配置在AddDefaultIdentity的选项里,可以在不影响安全性的前提下适当放宽:
builder.Services.AddDefaultIdentity<IdentityUser>(options => { options.Password.RequiredLength = 6; options.Password.RequireDigit = true; options.Password.RequireLowercase = true; options.Password.RequireUppercase = false; options.Password.RequireNonAlphanumeric = false; options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); options.Lockout.MaxFailedAccessAttempts = 5; }) .AddRoles<IdentityRole>() .AddEntityFrameworkStores<ApplicationDbContext>();我的原则是:长度必须够,至少要 8 位;大小写和特殊字符可以适当放宽。尤其对内部管理系统,用户都是同事,密码策略太变态,最后只能大家都往标签上贴密码。
4.3 常见问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 运行时报 “no such table: AspNetUsers” | 没执行数据库迁移 | 执行dotnet ef database update |
| 登录后访问页面仍跳回登录页 | Cookie 未正确写入(HTTPS 配置不一致) | 检查 HTTPS 重定向与 Cookie Secure 属性 |
| 注册时提示 “The password must have at least one uppercase” | 密码策略过于严格 | 调整Password.RequireUppercase等选项 |
[Authorize(Roles = "Admin")]不生效 | 用户虽然角色匹配,但登录时角色未加载 | 确保重写IUserClaimsPrincipalFactory时加载角色 |
| 多环境部署时登录状态反复丢失 | 应用数据保护密钥不一致 | 在AddDataProtection().PersistKeysToFileSystem(...)中固定密钥存储位置 |
使用自定义用户类后IdentityUser方法报错 | 依赖注入类型没换成自定义用户 | 统一使用UserManager<ApplicationUser>并注册AddIdentity<ApplicationUser, IdentityRole> |
有两项需要额外说明。第一项“角色未加载”的问题,常见于你重写了登录用户的信息生成逻辑。默认框架在用户登录后,会把用户所属角色加进 Claims 里,授权时才能判断。如果你自己实现了IUserClaimsPrincipalFactory,一定要调用_userManager.GetRolesAsync(user)并把角色转成Claim加进去,否则角色权限全部失效。
第二项“数据保护密钥不一致”是我做过一次多机部署后才发现的。默认情况下密钥存储在本地文件,开发机、服务器各存各的,一台机器上登录,换到另一台机器就提示要重新登录。解决方法是把密钥统一存在一个共享目录:
builder.Services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(@"D:\shared-keys")) .SetApplicationName("IdentityDemo");同一个应用名、同一份密钥,登录状态在多机之间就能共享。
4.4 外部登录和 JWT 场景的一点扩展
很多项目现在不是传统 MVC,而是前后端分离。这时候 Identity 默认的 Cookie 认证就不太够用,你需要签发 JWT 而不是 Cookie。Identity 的核心逻辑仍然可以复用,只是把“验证成功之后签发 Cookie”替换成“验证成功之后签发 Token”。
我做过的一个小程序项目就是这么干的:前端 Vue 拿账号密码调登录接口,后端用SignInManager验证身份,验证通过后你自己生成一个 JWT 返回给前端。JWT 的生成可以用System.IdentityModel.Tokens.Jwt包,代码核心就是创建一个包含Claim的安全令牌,然后设置有效期和密钥。Identity 在这套架构里负责的是“用户的存储和密码验证”,JWT 负责的是“后续请求的身份传递”,两者不冲突,搭在一起反而各司其职。
5. 聊聊 VS Code 与命令行跑项目
这节我单独拿出来,是因为很多从 Visual Studio 转过来的人对命令行又爱又怕。其实 ASP.NET Core 的项目结构、配置、构建过程,本质都是围绕.csproj文件展开的,跟 IDE 没有强绑定。VS Code 只是帮你写代码、看代码的工具,编译和运行全交给dotnetCLI。
所以如果你的电脑只需要能跑 .NET 7 SDK,加一个 VS Code,再装 C# Dev Kit 插件,开发体验已经非常好了。我用这个组合写过一个完整的小型 SaaS 后台,除了数据库管理器用单独的工具之外,全程没有打开过一次 Visual Studio。
多提一句:如果你后续要配合 Docker 部署,这个工作流会更舒服。因为dotnet publish打出来的产物、dotnet ef migrations生成的 SQL 脚本,都可以直接在命令行集成进 CI/CD 管道里。我最后发布到服务器时,用的就是一条命令构建镜像、一条命令启动容器,整套流程不用点任何图形界面。
6. 写在最后的一些心得
兜兜转转用了这么久 Identity,我最大的感受是:它不是一个“可以躺平”的黑盒。你仍然需要理解用户、角色、Claim 之间的关系,需要知道 Cookie 认证的流程,需要在合适的场景换成 JWT。但 Identity 替你把最容易被写错、最容易有安全漏洞的那部分——密码存储、尝试次数锁定、身份票据校验——做成了一套经过大量生产环境验证的机制。
按照行业里的成功案例,身份认证这块是最不该自己造轮子的地方。我见过不少代码库因为“想给用户加个字段”,最后把整张AspNetUsers表都改得面目全非,迁移时惨不忍睹。如果你遇到扩展用户信息的需求,正路是新建一张表,用外键关联到AspNetUsers的 Id,而不是去动框架的原生表结构。
最后分享一个小技巧:Identity 自带 UI 页面的表单样式可能不够好看,但你不需要一上来就重写整个页面。先跑起来、把业务逻辑打通,之后随便挑一个页面局部修改它的 Razor 文件即可。开发时永远让“能跑”优先于“好看”,这套组合拳打下来,你的系统第一周就能有个能登录、能退出的基本盘,后面加功能也就从容多了。