IdentityServer4 统一认证中心:OAuth2.0/OIDC 落地 接手一套跑了两年的业务系统时最先让人头疼的往往不是业务代码而是散落在各处的登录逻辑。三个前端应用各自一套账号体系两个后端服务各写一份令牌校验移动端又是第三种登录方式,改一次密码复杂度策略,前后要发六个版本还得挨个系统做回归测试。真正的解法不是在每个系统里复制粘贴登录代码而是把“你是谁”这件事抽出来交给一个专门的组件统一负责。IdentityServer4 就是这类组件里在 .NET 生态中落地最广的一个它把 OAuth 2.0 与 OpenID Connect 两套规范实现成可以直接挂载的中间件涵盖用户认证、令牌签发、权限声明、令牌校验的完整链路。这篇内容适合正在做多系统统一登录、准备把认证中心从业务代码里剥离出来的开发者也适合已经接了认证中心但被各种 401 折腾过的同行——我会把概念模型、选型逻辑、最小可跑闭环和上线后才暴露的坑一次讲清楚。1. 从三份用户表说起认证中心到底替谁分担了什么1.1 单体登录逻辑失控的典型现场我见过最典型的失控形态是这样的主业务系统有一套Users表后台管理端另外建了一份AdminUsers给外部合作方用的开放平台又搞了一张PartnerAccounts。三张表的密码哈希算法还不一样一张用旧的 MD5 加盐一张用框架默认的 PBKDF2第三张居然存的是明文——不是因为开发者不懂安全而是这三套代码是三个时期、三拨人分别写的谁也没空回头统一。这种结构带来的问题不是“代码难看”这么轻描淡写而是会持续产生维护成本。用户改一次密码要同步三个库运营要给某个角色加权限得确认三个系统各自有没有对应字段审计要查“某个账号最近登录了哪些系统”日志分散在三台机器上字段格式都对不齐。更要命的是一旦某个系统的会话被窃取其他系统完全感知不到因为彼此之间没有共享的令牌状态。认证中心要解决的核心问题就是这三个账号唯一、令牌统一、权限集中。把用户库收到一处把签发令牌的权力收到一处把“这个用户有哪些角色、能访问哪些资源”的定义收到一处。业务系统只需要回答一个问题——“这张令牌是不是有效的、里面写了什么”不需要再关心密码怎么存、登录页怎么画、找回密码的邮件怎么发。这是职责的重新划分不是简单地多部署一个服务。1.2 认证与授权是两件事别混在一起做很多人把 IdentityServer4 当成“权限管理系统”这个理解会带偏后续设计。它主要解决的是认证确认请求方是谁Authentication并把这个结论用一种可验证的格式令牌传递出去。至于“这个用户能不能删订单”那是授权属于业务系统的职责。这个边界划清楚之后配置思路就顺了。认证中心负责校验用户名密码、校验客户端身份、决定令牌里放哪些声明、给令牌签名、维护令牌的生命周期。业务系统负责拿到令牌后验证签名和有效期读出里面的角色或权限声明然后用自己的授权策略判断这次操作放不放行。我在项目里见过把几百条细粒度权限全塞进令牌的做法结果令牌膨胀到 8KB每次请求都带着这个大家伙走网关的头部长度限制直接被撑爆。正确的做法是令牌里只放粗粒度的身份信息用户 ID、角色、租户标识细粒度权限由业务系统自己去查缓存。这个取舍后面第 4 章讲令牌结构时还会展开。提示判断一个信息该不该进令牌问自己一句——这个字段变了之后旧令牌里的值还能不能用能用比如用户 ID就放进去一秒钟都不能容忍过期比如余额、实时权限就别放。2. 把 IdentityServer4 的四类模型摆上桌Client、Scope、Resource、GrantType2.1 Client 不是用户是发起请求的那一方刚接触这套东西最容易犯的错是把Client理解成“客户端应用的用户”。不是。Client指的是发起令牌请求的那个软件实体——一个后台管理门户、一个移动 App、一个跑在服务器上的定时任务都可以是一个 Client。用户是另一维度通过subsubject声明来表示。这个区分至关重要因为 Client 有自己的一套凭证ClientId加ClientSecret。它代表“我是哪个应用”而用户名密码代表“我是哪个用户”。有些场景只有前者没有后者比如服务间的后台调用Client Credentials 模式压根没有用户参与有些场景两者都要比如用户在门户里登录授权码模式。配置 Client 时有几个字段我建议一次就配对new Client { ClientId web-portal, ClientName 后台管理门户, AllowedGrantTypes GrantTypes.Code, RequirePkce true, RequireClientSecret true, ClientSecrets { new Secret(替换成你自己的随机长串.Sha256()) }, RedirectUris { https://portal.example.internal/signin-oidc }, PostLogoutRedirectUris { https://portal.example.internal/signout-callback-oidc }, AllowedScopes { openid, profile, roles, api.order, api.user }, AllowOfflineAccess true, AccessTokenLifetime 3600, AbsoluteRefreshTokenLifetime 2592000, RefreshTokenUsage TokenUsage.OneTimeOnly, RefreshTokenExpiration TokenExpiration.Sliding, SlidingRefreshTokenLifetime 86400 }RedirectUris这一项必须精确到路径不能图省事写成通配。它是防重定向攻击的第一道闸门认证中心在发授权码之前会严格比对多一个斜杠都不放过。这一条我在评审代码时经常看到被写宽属于典型的“本地能跑就上线”的隐患。2.2 Scope 决定“能做什么”不是“能看什么”Scope是授权范围字面意思是“这张令牌被授权访问哪些东西”。IdentityServer4 里有两种 Scope很多人第一次配会被绕晕IdentityScope身份作用域决定令牌里包含哪些用户身份信息比如openid让令牌带上subprofile带上姓名、昵称自定义的roles带上角色。这类 Scope 影响的是ID Token的内容。ApiScope接口作用域决定这张令牌能访问哪些 API。这类 Scope 影响的是Access Token的scope声明业务系统会据此判断该不该放行。这两者的关系不是包含是并列。一个用户可能拿到了profile知道他是谁但只有api.order能下单没有api.user不能查用户列表。设计的时候我习惯把 ApiScope 按业务域切不要按接口切。一个服务几十个接口全配成一个 scope 是粗放一个接口配一个 scope 是自找麻烦——令牌里的 scope 字段会变成一串几十项的列表验签和日志都不好看。按“订单域”“用户域”“报表域”这种粒度切通常够用。2.3 Resource 与 ApiScope 的关联关系以及 aud 是怎么生成的这是我最想强调的一个细节因为它是 401 报错的高频来源。在 IdentityServer4 的 4.x 版本里ApiResource和ApiScope是两个独立的概念Scope 是“权限点”Resource 是“资源实体”。一个 Resource 可以关联多个 Scope表示“这些权限点属于我这个资源”。关键在于audaudience声明是怎么算出来的配置情况Access Token 里的 aud 值只配了 ApiScope没配 ApiResource直接等于 scope 名称如api.order配了 ApiResource 并关联了对应 Scope等于 ApiResource 的 Name如order-api一个 scope 被多个 Resource 关联aud 是数组包含所有关联的 Resource 名这个规则导致的后果是你在业务系统里配的Audience值必须和上面推导出来的结果一致。我就吃过这个亏——认证中心那边为了“结构清晰”把 Resource 建了但忘了把 Scope 挂上去业务系统的Audience还是按 Resource 名配的结果令牌里的aud是 scope 名验签一路失败日志里只报一句笼统的 audience 不匹配。查了半个下午才定位到是配置层面的关联关系漏了一行。public static IEnumerableApiScope ApiScopes new ApiScope[] { new ApiScope(api.order, 订单服务), new ApiScope(api.user, 用户服务) }; public static IEnumerableApiResource ApiResources new ApiResource[] { new ApiResource(order-api, 订单服务) { Scopes { api.order }, UserClaims { role, tenant_id } } };UserClaims这一项也值得单说它不是“自动带上”而是声明这个 Resource 允许接收哪些用户声明。即使你在 ProfileService 里往IssuedClaims里塞了role如果这个 Resource 的UserClaims里没写role最终令牌里照样没有。这个过滤机制经常让人误以为自定义声明没生效。3. 授权模式选型从上手难度而不是从流行度出发3.1 四种模式的实际适用范围用一张表说清OAuth 2.0 定义了多种授权模式IdentityServer4 全部支持。选型的时候别按“哪种更安全”这种笼统标准判断应该按客户端能不能保住密钥来决定。这张表是我自己项目里的选型依据模式适用场景能否保住 ClientSecret令牌是否经过浏览器授权码 PKCE单页应用、移动 App、桌面端否是但只传授权码授权码有后端的传统 Web 应用是是客户端凭证服务间内部调用、定时任务是否密码模式仅限本地自测或极特殊的受信客户端是否注意“密码模式”这一行。它在 OAuth 2.0 的后续规范里已经被明确不建议用于生产原因很直白它要求业务前端直接收集用户密码并转发给认证中心等于把密码暴露在了多一个环节。但它在自测阶段极其好用——不需要浏览器、不需要跳转、一条 curl 就能拿到全套令牌调 API 的时候省了大量来回。我的做法是本地开发环境保留密码模式的 Client配置上打#if DEBUG或者放在独立的开发配置里生产配置文件中根本不出现这个 Client。3.2 单页应用为什么必须上 PKCEPKCEProof Key for Code Exchange的作用是给授权码加一把一次性的锁。原理不复杂前端在发起授权请求之前随机生成一个code_verifier把它做一次 SHA256 哈希得到code_challenge请求里只带code_challenge。换取令牌的时候前端再把原始的code_verifier交上去认证中心自己算一遍哈希对得上才发令牌。这个机制防的是授权码被截获。单页应用跑在浏览器里没有安全的存储空间ClientSecret放在前端代码里等于公开。如果没有 PKCE任何人拿到授权码就能换令牌有了 PKCE光有授权码没用还得有那个只在内存里存在过一瞬间的code_verifier。配置上就一行RequirePkce true但客户端那边也要配合如果你用的是标准库比如 .NET 的Microsoft.AspNetCore.Authentication.OpenIdConnect或者前端的 oidc-client 类库它默认就会处理 PKCE 流程不需要手写。如果客户端是自己拼 URL 的那就要自己实现code_challenge和code_verifier的生成与校验这一步很容易漏。还有一个细节RequireClientSecret在 PKCE 场景下应该设为false。我见过同时开RequirePkce true和RequireClientSecret true的配置前端为了通过校验只好把 secret 硬编码进 JS 包PKCE 的意义就被抵消了一半。4. 令牌里到底装了什么从 JWT 结构到 RS256 验签链路4.1 Header 里的 kid 和 alg 不是装饰拿一张 Access Token 出来它是三段用点分隔的 Base64URL 字符串。第一段解码出来大概长这样{ alg: RS256, kid: A1B2C3D4E5F6, typ: atjwt }alg是签名算法kid是密钥标识。这两个字段合起来解决一个问题验证方怎么知道该用哪把公钥验签。因为签名密钥是会轮换的认证中心可能同时持有新旧两把密钥签新令牌用新的但旧令牌还没过期验证方手里可能还留着旧的公钥。kid就是告诉验证方“这张令牌是用哪把钥匙签的”。RS256是非对称算法认证中心用私钥签名所有验证方用公钥验签。这个设计的关键好处是公钥可以公开分发私钥只留在认证中心。对比之下对称的 HS256 要求所有验证方都持有同一把密钥任何一个业务系统泄露了密钥整个体系就被攻破了。所以跨系统的场景一定是 RS256不要图省事用 HS256。4.2 发现文档与 JWKS 端点客户端为什么不需要提前拿公钥这里有个设计很多人第一次看会觉得神奇业务系统配置里只写了一个Authority地址公钥从哪来答案是自动发现。认证中心暴露了一个发现文档端点/.well-known/openid-configuration里面是一份 JSON列出了所有端点地址和元数据。其中有一项jwks_uri指向公钥集合端点。客户端组件启动时会去拉这份文档找到jwks_uri再把公钥列表拉下来缓存。之后每次验签按令牌 Header 里的kid从缓存里挑对应的公钥。这个机制的实用价值在于密钥轮换不需要协调。认证中心换了签名证书只要把新公钥和旧公钥同时挂在 JWKS 端点上业务系统下次刷新时自动就能拿到新的不需要停机、不需要改配置。前提是业务系统的库要支持后台自动刷新——.NET 的 JwtBearer 中间件默认会定期刷新间隔大概 24 小时也可以手动设置更短。如果哪天你改了签名证书业务系统开始大面积 401先做一件事直接 curl 一下jwks_uri看看新公钥在不在里面kid能不能对上调出来的令牌 Header。这一步能省掉 80% 的排查时间。注意JWKS 缓存是有 TTL 的。轮换密钥之后旧令牌在一段时间内仍然有效是正常的这是设计上的宽容不是漏洞。如果你要求轮换立刻生效需要在业务侧主动触发一次配置刷新别指望它瞬间同步。5. 从零跑通一个最小认证中心代码层面的完整闭环5.1 项目骨架与服务注册先搭一个空的 Web 项目把 IdentityServer4 的服务注册中间件挂上去。最小可跑的配置如下public void ConfigureServices(IServiceCollection services) { services.AddControllers(); services.AddIdentityServer(options { // 事件日志排查时很有用 options.Events.RaiseErrorEvents true; options.Events.RaiseInformationEvents true; options.Events.RaiseFailureEvents true; options.Events.RaiseSuccessEvents true; // 固定签发者标识避免多实例部署时 iss 不一致 options.IssuerUri https://id.example.internal; }) .AddInMemoryIdentityResources(Config.IdentityResources) .AddInMemoryApiScopes(Config.ApiScopes) .AddInMemoryApiResources(Config.ApiResources) .AddInMemoryClients(Config.Clients) .AddTestUserStore(Config.Users) // 仅本地测试 .AddDeveloperSigningCredential(); // 仅本地测试 } public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseStaticFiles(); app.UseRouting(); app.UseIdentityServer(); // 必须放在 UseRouting 之后、UseEndpoints 之前 app.UseAuthorization(); app.UseEndpoints(endpoints endpoints.MapDefaultControllerRoute()); }UseIdentityServer()的位置很讲究它必须在路由中间件之后因为令牌端点要靠路由匹配必须在端点映射之前因为它自己会接管/connect/*这类路径。位置放错了现象是端点全部 404但日志里啥也不报纯靠猜。5.2 客户端与作用域的配置写法配置文件我习惯单独放在一个Config静态类里按资源维度和客户端维度分块方便后面切换成数据库存储时对照public static class Config { public static IEnumerableIdentityResource IdentityResources new IdentityResource[] { new IdentityResources.OpenId(), new IdentityResources.Profile(), new IdentityResource(roles, 用户角色, new[] { role }), new IdentityResource(tenant, 租户信息, new[] { tenant_id }) }; public static IEnumerableApiScope ApiScopes new ApiScope[] { new ApiScope(api.order, 订单服务), new ApiScope(api.user, 用户服务) }; public static IEnumerableApiResource ApiResources new ApiResource[] { new ApiResource(order-api, 订单服务) { Scopes { api.order }, UserClaims { role, tenant_id } }, new ApiResource(user-api, 用户服务) { Scopes { api.user }, UserClaims { role } } }; }IdentityResources.OpenId()和Profile()是框架内置的前者必须开否则令牌里连sub都没有后者按需。自定义的两个 identity resource第三个参数是“这个作用域包含哪些声明类型”写错了同样会导致声明不出现——这是和UserClaims并列的第二个过滤点两个地方都要对。5.3 用密码模式快速自测先验证链路再谈架构配置写完之后别急着写客户端代码先用一条命令验证签发链路通不通curl -k -X POST https://localhost:5001/connect/token \ -H Content-Type: application/x-www-form-urlencoded \ -d grant_typepassword \ -d client_idself-test \ -d client_secret本地测试用的密钥 \ -d usernamealice \ -d passwordPassw0rd! \ -d scopeopenid profile roles api.order offline_access返回的 JSON 里应该包含access_token、id_token因为请求了 openid、refresh_token因为请求了 offline_access、expires_in。拿到access_token之后把它贴到任意 JWT 解码工具里看三段内容重点检查四个地方Header 的alg是不是 RS256kid有没有值Payload 的iss是不是你配的IssuerUriPayload 的aud是不是预期中的资源名回顾第 2.3 节那张表Payload 的scope里是不是包含了你请求的所有 scope。这四项任何一项不对后面接入业务系统必然 401与其在被调用方那边猜不如在发牌方这边先看仔细。6. 接入方改造客户端侧必须做的两件事6.1 服务端 API 的令牌校验配置业务系统接入认证中心加一个认证方案就够了不需要引用认证中心的任何包——它只依赖标准的 OIDC 发现文档services.AddAuthentication(Bearer) .AddJwtBearer(Bearer, options { options.Authority https://id.example.internal; options.Audience order-api; options.RequireHttpsMetadata true; options.TokenValidationParameters new TokenValidationParameters { ValidateIssuer true, ValidIssuer https://id.example.internal, ValidateAudience true, ValidAudience order-api, ValidateLifetime true, ClockSkew TimeSpan.FromSeconds(30), NameClaimType name, RoleClaimType role }; });几个配置项背后的理由值得逐一交代。RequireHttpsMetadata true在生产环境绝不能关因为发现文档和公钥都是通过 HTTP 拉的一旦被中间人替换攻击者可以用自己的密钥签发令牌整套验签机制就形同虚设。本地调试时如果卡在自签证书上正确做法是信任开发证书而不是把这个开关关掉。ClockSkew默认是 5 分钟这个宽容度对分布式系统来说偏大。它意味着一个已经过期的令牌在 5 分钟内仍然能通过校验。我一般调到 30 秒前提是各台机器都配了时间同步——如果没有宁可保持默认别为了“精确”制造偶发的 401。RoleClaimType这一行也不是可选项。如果你的令牌里角色声明叫role但框架默认按http://schemas.microsoft.com/ws/2008/06/identity/claims/role去找那[Authorize(Roles admin)]永远不生效而且不会报错只是静默地拒绝所有请求。这种“不报错但不工作”的问题最难查。6.2 自定义 Claim 的注入位置与时机用户登录成功之后认证中心默认只会从用户存储里拿几个基础字段放进令牌。业务需要的角色、租户、部门这些信息要通过IProfileService注入public class CustomProfileService : IProfileService { private readonly IUserDirectory _directory; public CustomProfileService(IUserDirectory directory) _directory directory; public async Task GetProfileDataAsync(ProfileDataRequestContext context) { var sub context.Subject.FindFirst(sub)?.Value; if (string.IsNullOrEmpty(sub)) return; var extra await _directory.LoadClaimsAsync(sub); // 只发请求方明确要的声明避免无差别灌入 context.IssuedClaims.AddRange( extra.Where(c context.RequestedClaimTypes.Contains(c.Type))); } public async Task IsActiveAsync(IsActiveContext context) { var sub context.Subject.FindFirst(sub)?.Value; context.IsActive await _directory.IsEnabledAsync(sub); } }注册时挂上去.AddProfileServiceCustomProfileService()同时记得在 DI 容器里注册IUserDirectory否则运行时会抛一个相当不友好的解析异常。IsActiveAsync这个方法我建议一定要实现不能留空。它在每次签发令牌和每次刷新令牌时都会被调用是实现“禁用账号立即生效”的关键位置。如果留空不写默认行为是永远返回 active意味着一个被停用的账号只要手里有有效的 refresh token就能一直换新令牌直到绝对过期时间到为止。这个坑我是在一次安全评审里被指出来的代价是补了一轮全量回归。另外提醒一句GetProfileDataAsync里做的查询会被高频调用因为刷新令牌时也会走一遍。如果那里查的是慢查询或者跨库 JOIN很容易把认证中心变成整个链路的性能瓶颈。我的做法是给这个查询加一层短 TTL 的内存缓存比如 60 秒用户名下的基础信息变动本来也不需要秒级生效。7. 上线之后才会遇到的坑一份按现象反查的排查清单7.1 CORS 与 401 伴生的迷惑现象单页应用接入时最常见的组合症状是浏览器控制台报 CORS 错误同时网络面板里那个请求的状态码是 401 或者 400。很多人以为是跨域配置问题拼命在业务系统上加AllowAnyOrigin加完还是不行。真正的根因往往在认证中心那边。浏览器的跨域请求在失败时不会把真实的状态码暴露给前端所以一个认证中心返回的 400比如 scope 不合法、client 未授权在浏览器看来就变成了“CORS 失败”。排查方法是绕过浏览器直接发请求用 curl 或者 Postman 打同一个端点看到真实响应体就明白了。如果确认是跨域配置问题那要配的位置是认证中心不是业务系统services.AddCors(options { options.AddPolicy(spa, policy { policy.WithOrigins(https://portal.example.internal) .AllowAnyHeader() .AllowAnyMethod(); }); });注意WithOrigins里不能带末尾斜杠也不能用通配符加AllowCredentials这是规范限制浏览器会直接拒绝。7.2 三类典型报错的对照表我把这几年遇到的 401 归了归类基本都落在下面这张表里报错关键字大概率原因定位动作invalid signature签名密钥对不上或 JWKS 缓存了旧公钥比对令牌 Header 的 kid 与 jwks_uri 返回的 kid 列表invalid audienceaud 与配置的 Audience 不一致见 2.3 节的推导规则解码令牌看 aud对照 Resource 与 Scope 的关联配置invalid issuerIssuerUri 与实际签发者不一致常见于多实例或反向代理解码令牌看 iss与业务侧 ValidIssuer 逐字符比对The token is expired时钟不同步或 ClockSkew 设得过小核对两台机器的系统时间差检查 NTP 是否生效401 但令牌正常缺少 scope或 RoleClaimType 没配打开认证中间件的详细日志看是哪一步拒绝的401 且无任何日志认证方案名不匹配中间件根本没跑检查[Authorize(AuthenticationSchemes ...)]与注册名是否一致最后一行那个问题我踩过注册的时候方案叫Bearer控制器上的特性写的是AuthenticationSchemes bearer小写框架对方案名是大小写敏感的结果就是请求进来完全没人处理认证一路走到授权环节被拒日志里干干净净。还有一个隐蔽的反向代理场景下如果代理没有透传X-Forwarded-Proto和X-Forwarded-Host认证中心会把发现文档里的端点地址拼成内部的 http 地址客户端按这个地址去请求就直接连不上。解决方式是在认证中心里配上转发头中间件并且把IssuerUri显式写死成对外域名别让它根据请求动态推断。7.3 Refresh Token 轮换与并发刷新的冲突开启RefreshTokenUsage TokenUsage.OneTimeOnly之后每次用 refresh token 换新令牌旧的立即作废。这是更安全的策略但会带来一个很现实的问题并发刷新。想象一个后台页面同时发了 5 个 API 请求全部返回 401前端的拦截器会同时触发 5 次刷新。第一个请求成功refresh token 作废剩下 4 个拿着同一个旧 token 去换全部失败最终用户被踢回登录页。这个 bug 在测试环境很难复现因为网络延迟低、时序凑巧上了生产才开始零星出现。标准解法是前端加一个刷新互斥锁第一个刷新请求发出时置一个标志位后续请求排队等它返回拿到新令牌后统一重放。实现方式各框架不同但核心逻辑都一样——同一时刻只允许一个刷新在飞。如果前端改造困难退一步的配置是TokenUsage.ReUse允许旧 refresh token 在有效期内重复使用。代价是安全性下降一旦 refresh token 泄露攻击者可以一直用它换新令牌直到绝对过期。这个取舍要在项目初期就想清楚不要等到线上出问题再临时改。提示AbsoluteRefreshTokenLifetime我一般设 30 天SlidingRefreshTokenLifetime设 1 天。含义是只要用户每天都用登录状态一直延续连续一天不用就失效但无论怎么用30 天后必须重新登录。这个组合在体验和安全之间比较平衡。8. 生产化收尾签名密钥、配置持久化与多实例部署8.1 用 X509 证书替换开发签名凭据AddDeveloperSigningCredential()会在启动时生成一个临时的密钥文件存在本地重启之后如果文件还在就复用文件丢了就重新生成。这在本地完全没问题上了生产就是灾难——多实例部署时每个实例生成各自的密钥A 实例签的令牌 B 实例验不过负载均衡一转就出现随机 401。生产必须换成固定的 X509 证书var cert new X509Certificate2( /etc/certs/signing.pfx, Environment.GetEnvironmentVariable(SIGNING_CERT_PASSWORD), X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.EphemeralKeySet); services.AddIdentityServer() .AddSigningCredential(cert, RS256);几个实操细节。第一证书密码从环境变量读不要写进代码或配置文件配置文件很容易被提交到版本库。第二MachineKeySet这个标志位在 Windows 服务或容器里跑的时候很关键不加可能在读取私钥时抛权限异常。第三容器化部署时证书用挂载卷注入别打进镜像里——镜像可能被推到公共仓库证书一起泄露。证书有效期要盯住。我习惯在证书到期前 60 天做一次轮换演练流程是把新证书加进AddValidationKey的列表里两把密钥并存一段时间等旧令牌全部过期后再移除旧的。IdentityServer4 支持多把验证密钥正好用来做平滑过渡services.AddIdentityServer() .AddSigningCredential(newCert, RS256) // 新令牌用新证书签 .AddValidationKey(oldCert, RS256); // 旧令牌仍可验8.2 配置库与操作库的分工AddInMemory*那套东西只适合演示。生产环境要换成 EF Core 存储但这里有两个库职责完全不同配置库Configuration Store存 Client、ApiResource、ApiScope、IdentityResource。变更频率低通常由管理员通过后台界面维护或者脚本导入。操作库Operational Store存授权码、refresh token、已发放的 consent、设备码。数据量增长快写入频繁需要定期清理。services.AddIdentityServer() .AddConfigurationStore(options { options.ConfigureDbContext b b.UseSqlServer(connStr, sql sql.MigrationsAssembly(Idsvr.Auth)); }) .AddOperationalStore(options { options.ConfigureDbContext b b.UseSqlServer(connStr, sql sql.MigrationsAssembly(Idsvr.Auth)); options.EnableTokenCleanup true; options.TokenCleanupInterval 3600; // 每小时清理一次过期令牌 });EnableTokenCleanup这个开关一定要打开否则操作库会一直膨胀。我见过跑了半年的环境PersistedGrants表涨到几千万行查询 refresh token 的耗时从毫秒级涨到几百毫秒每次刷新令牌都在拖慢整个登录链路。清理任务在多实例部署时是每个实例各自跑的会有重复执行但因为清理操作是幂等的删除重复执行不影响正确性只是多消耗一点资源。另外配置库和操作库都改成数据库之后首次启动会提示缺少表结构需要先跑一遍dotnet ef database update。这一步别在生产启动脚本里自动执行——多实例同时启动会并发建表冲突概率很高应该作为独立的部署步骤单独跑。8.3 多实例部署时的几个一致性要求认证中心本身可以水平扩展但有几个地方必须保持所有实例一致否则会出现“时好时坏”的诡异问题需要一致的项不一致的后果保证方式签名证书与密码令牌验签随机失败证书挂载到所有实例密码走同一份配置IssuerUriiss 声明不一致客户端校验失败显式配置不依赖请求推断数据保护密钥DataProtection登录会话 Cookie 无法跨实例解密共享密钥环如数据库或共享存储配置库连接串不同实例看到不同的 Client 列表统一配置源与环境变量第三行那个数据保护密钥是最容易被忽略的。认证中心自己的登录页用的是 Cookie 认证Cookie 的加密密钥默认存在实例本地。多实例部署时用户在第 A 台登录下一个请求被路由到第 B 台Cookie 解不开表现就是“一直跳登录页登录了又跳”。这个问题的迷惑性在于接口调用完全正常只有认证中心的交互页面出问题。解决办法是把 DataProtection 的密钥环持久化到共享位置services.AddDataProtection() .PersistKeysToDbContextAppDbContext() .SetApplicationName(identity-center);SetApplicationName也必须一致它是密钥隔离的一个维度不同应用名会去找不同的密钥。最后再分享一个部署上的小经验认证中心的/connect/token端点是整个体系的性能热点每次登录、每次刷新都会打过来而且它涉及密码哈希验证故意设计得很慢和数据库写入。如果业务系统的并发登录量比较大记得把这个端点单独做限流或者在前面加一层缓存来拦截明显无效的请求。我遇到过一次因为某个客户端配置错误把刷新间隔设成了 1 秒导致 token 端点被一个应用刷满了连接池其他所有系统全部登录失败。加限流之后这类问题就变成了局部故障不会扩散成全局事故。