资讯详情

C# 单点登录 SSO 最小骨架:票据、Redis 会话与登出广播实战

📅 2026/10/25 7:39:22 | 华诺云谱 👁 阅读
C# 单点登录 SSO 最小骨架:票据、Redis 会话与登出广播实战
简介这份C#单点登录示例程序面向需要打通多站点身份验证的.NET开发者与学习者围绕CAS中央认证服务演示一次登录、多站点共享登录态的完整实现思路。压缩包共173个文件、约2.07MB其中64个cs源码是核心并配有16个aspx页面、8个csproj工程文件、8个config配置和11个dll依赖另有asmx服务、wsdl描述与数据库文件可完整还原认证中心与子站点的工程结构。已有495人学习。读者可以从中梳理ASP.NET Identity账户管理、JWT令牌生成与校验、子站点回调验证、HTTP重定向与Cookie传递等关键环节并结合代码理解HTTPS通信、令牌过期与防重放等安全考量同时可参考其接口抽象与依赖注入的组织方式以及单元测试与集成测试的落地思路适合作为身份认证方向的项目实践与二次开发底稿。1. 项目定位一套能在本机跑通的 C# 单点登录 SSO 最小骨架手里这套 C# 单点登录 SSO 示例程序我打开前以为又是那种贴几段伪代码、跑起来全靠脑补的仓库。结果它把认证中心和两个业务子系统一起摆在同一个解决方案里两个端口各起一个站登录一次跳到另一个站会话直接通点一次登出两边同时失效。票据怎么发、怎么消费、Cookie 怎么带、登出为什么不会复活代码里全看得见没有黑匣子。它适合两类人一类是手里有若干老系统想在几天内套上统一登录、又被重量级方案劝退的后端另一类是想真正搞明白 SSO 底层那几次 302 到底在干什么的 C# 开发。跑通大概半小时改成能用的东西大概半天。2. 架构拆解认证中心、子系统与票据的三方流转先把骨架认清后面所有配置和代码才有落脚点。这个示例走的是最经典、也最容易讲清楚的票据模式一个认证中心负责认人若干个业务子系统负责用这个“认人结果”中间靠一张短命票据传递身份。它没有一上来就搞 OIDC 那一整套发现文档、JWKS、各种 token 类型而是把 SSO 最核心的三件事——全局会话、票据签发、票据核销——抽出来单独实现了一遍。对二次开发来说这是好事你不需要先啃完一份规范才知道自己在改哪一行。2.1 解决方案目录三个工程各自的职责边界解决方案里是三个工程加一个共享契约层结构很直白SsoDemo/ ├── SsoDemo.sln ├── src/ │ ├── Sso.Shared/ 共享契约层无业务逻辑 │ │ ├── Models/TicketPayload.cs 票据载荷 │ │ ├── Models/SsoUser.cs 用户最小信息 │ │ ├── Options/SsoOptions.cs 各端共用的配置模型 │ │ └── Security/SignHelper.cs HMAC 签名与校验 │ ├── Sso.Server/ 认证中心唯一认人的地方 │ │ ├── Controllers/SsoController.cs authorize/validate/logout │ │ ├── Controllers/AccountController.cs 登录页与提交 │ │ ├── Services/TicketService.cs 票据签发与原子消费 │ │ ├── Services/SessionService.cs 全局会话 │ │ ├── Services/AppRegistry.cs 子系统注册表appId→secret/回调 │ │ └── Program.cs │ └── Sso.Client/ 业务子系统可复制多份 │ ├── Middleware/SsoAuthMiddleware.cs 拦截 回调换会话 │ ├── Controllers/HomeController.cs 演示页面 │ └── Program.cs └── deploy/ └── docker-compose.yml Redis 两个站点职责边界只有一条铁律用户凭据只有认证中心能碰子系统永远拿不到密码只拿票据换来的身份。Sso.Shared里不许出现任何数据库访问或 HTTP 调用它只放模型和签名算法这样认证中心和子系统引的是同一份TicketPayload序列化字段名不会对不上——这类字段名不一致的坑跨端调试时能耗掉你半天。AppRegistry是很多示例程序喜欢偷懒的地方把 appId 和 secret 写死在appsettings.json里谁都能改。这里的做法是配置 内存缓存启动时加载改回调和密钥要重启虽然土但至少把“回调地址白名单”这件事摆到了明面上后面第 5 章讲开放重定向时会用到它。2.2 一次登录的完整时序与协议参数整个流程一共 8 步跑通一遍再回头看代码会顺很多。假设用户先访问子系统 Ahttps://app-a.local:5002的首页A 的中间件发现本地没有会话生成一个随机state写进临时 Cookie302 到认证中心的/sso/authorize?appIdapp-aredirectUri...state...。认证中心校验appId是否存在、redirectUri是否在这个 appId 注册的白名单里不通过直接 400不做任何跳转。认证中心看自己的全局会话 Cookiesso_session。没有302 到登录页把原始请求参数塞进returnUrl。用户提交账号密码认证中心校验通过写全局会话Redis Cookie 双写再 302 回/sso/authorize。这一次全局会话有了认证中心签发一张一次性票据302 回redirectUri?ticketxxxstatexxx。A 的回调处理先比对state与本地 Cookie再在服务端调认证中心/sso/validate带上appId、ticket、timestamp和签名。认证中心原子地消费掉这张票据返回用户信息和会话标识A 用它建立自己的本地 Cookie 会话重定向回用户原本要访问的页面。用户再访问子系统 BB 也跳到认证中心但此时全局会话还在认证中心直接发新票据用户完全无感——这就是“单点”。参数里有三个不能省state防跨站请求伪造redirectUri必须走白名单校验timestamp配合签名防重放。示例里timestamp容忍窗口是 5 分钟超过就拒所以部署机器的时钟必须同步这一点在第 5 章会展开。2.3 票据与全局会话的 Redis 键设计键设计决定了这个程序能不能横向扩容。示例里一共三类键都在 Redis 里认证中心本身不存内存状态键值TTL谁写谁读sso:ticket:{ticketId}票据 JSON60 秒认证中心认证中心校验时删除sso:session:{sessionId}用户 ID 登录时间8 小时滑动认证中心认证中心sso:app:{appId}回调白名单 secret 哈希不过期启动时加载认证中心票据 TTL 给到 60 秒是有讲究的它只需要撑过一次浏览器跳转。给长了等于把重放窗口拉长给短了网络慢的用户会看到“登录失败请重试”。60 秒是这套示例里比较稳妥的折中我一般也这么写。会话 TTL 用滑动续期用户活跃就一直续闲置 8 小时自动掉。这里有个容易忽略的点票据是一次性的全局会话不是。所以同一用户在两台设备上登录会拿到两个sessionId各自独立过期互不影响——这是有意为之登出时才需要按用户维度广播第 4.4 节会讲。3. 跑起来两个端口互跳的完整落地步骤理论讲完就该动手了。这一章的目标很明确两个站点各自起来登录一次能跳过去点登出两边都掉。整个过程卡人的地方基本都在域名和 Cookie 上所以第一步就得把本机域名准备好别用localhost:5001和localhost:5002硬刚。3.1 环境准备与配置项清单需要的东西不多.NET 6 或更高版本的 SDK、一个 Redis本地装或者用容器起都行、以及两个能解析到 127.0.0.1 的域名。域名这步别省后面会解释为什么。# 1. 起一个 Redis示例默认连 6379 docker run -d --name sso-redis -p 6379:6379 redis:7-alpine # 2. 本机域名Windows 改 C:\Windows\System32\drivers\etc\hosts # macOS / Linux 改 /etc/hosts追加两行 127.0.0.1 sso.local 127.0.0.1 app-a.local # 3. 还原并编译先确认能编译过再谈运行 dotnet restore SsoDemo.sln dotnet build SsoDemo.sln -c Debug配置项集中在两份appsettings.json里认证中心那份管全局子系统那份管接入{ Sso: { Authority: https://sso.local:5001, AppId: app-a, AppSecret: 换成认证中心注册时下发的随机串, RedirectUri: https://app-a.local:5002/sso/callback, SessionMinutes: 30, CookieName: sso_client_a }, Redis: { Configuration: 127.0.0.1:6379,defaultDatabase0 } }CookieName这一项必须每个子系统不一样app-a的叫sso_client_a再复制一份给app-b就叫sso_client_b。原因写在 5.1 节这里先记住Cookie 不按端口隔离两个站都用默认名会互相顶掉。AppSecret是认证中心在AppRegistry里登记的那个值子系统拿它算请求签名认证中心拿它验。示例里它是明文配置生产环境要么走密钥管理服务要么至少做一次环境变量注入别提交进仓库。3.2 认证中心的启动与首个账号初始化认证中心用两个端口分别跑 HTTP 和 HTTPS。开发证书先信任一下否则子系统回调时HttpClient会直接抛证书错误这个报错信息很隐晦容易被误判成网络问题。dotnet dev-certs https --trust cd src/Sso.Server dotnet run --urls https://sso.local:5001;http://sso.local:5000首次启动会做两件事连 Redis 并 ping 一次连不上直接崩别让它带病启动以及把AppRegistry里的子系统配置加载进内存。示例的账号体系是内存字典 PBKDF2 哈希两个演示账号写在AccountService的种子方法里用户名和密码都在注释中标了第一次登录后建议直接改掉种子逻辑接你自己的用户表。启动成功的判断标准不是控制台打印了监听地址而是访问https://sso.local:5001/health返回 200。这个健康检查端点会顺带报 Redis 的连通状态比看日志快。提示如果启动时报 Redis 连接超时先确认容器名和端口映射对不对再确认连接串里没有多余空格。127.0.0.1:6379,defaultDatabase0这种写法里逗号后面不能有空格有空格会被当成配置解析错误。3.3 子系统接入与单点跳转验证子系统这边启动前先确认三件事Authority指向认证中心、AppId和AppSecret与认证中心登记的一致、RedirectUri在认证中心的白名单里且逐字符相同协议、域名、端口、路径、结尾有没有斜杠全都算。cd src/Sso.Client dotnet run --urls https://app-a.local:5002验证按这个顺序走一步一确认别一口气点到底浏览器打开https://app-a.local:5002应该被 302 到sso.local:5001的登录页。地址栏能看到appId、redirectUri、state三个参数。登录成功后回到app-a的首页页面显示用户名。此时按 F12 看app-a.local下应该有一个sso_client_aCookie。把Sso.Client复制一份改成app-b改端口、改AppId、改CookieName启动后访问https://app-b.local:5003不应该再出现登录页直接进首页。这一步通了单点登录才算成立。在app-b上点登出然后刷新app-a两边都应该是未登录状态。第 3 步失败是最常见的八成是appId没在认证中心登记或者登记的回调地址和实际不一致。第 4 步失败通常是登出只清了本地没通知认证中心具体修法在第 4.4 节和 5.2 节。4. 核心代码拆解票据签发、校验中间件与登出广播这一章挑四个真正决定成败的地方逐段过。其他代码登录页、布局、演示控制器都是常规 MVC 写法看一眼就懂不值得占篇幅。4.1 TicketService一次性票据的签发与原子消费票据服务的核心诉求只有两个够短命且同一张票只能被消费一次。第二点在并发下最容易翻车。public sealed class TicketService { private readonly IDatabase _redis; private static readonly TimeSpan TicketTtl TimeSpan.FromSeconds(60); public TicketService(IConnectionMultiplexer redis) _redis redis.GetDatabase(); // 签发票据只在 Redis 存一份60 秒后自动消失 public async Taskstring IssueAsync(string userId, string appId, string redirectUri) { var ticketId Guid.NewGuid().ToString(N); var payload new TicketPayload { TicketId ticketId, UserId userId, AppId appId, // 绑定目标子系统防止票据被别的站拿去用 RedirectUri redirectUri, IssuedAt DateTimeOffset.UtcNow.ToUnixTimeSeconds(), ExpiresAt DateTimeOffset.UtcNow.Add(TicketTtl).ToUnixTimeSeconds() }; await _redis.StringSetAsync($sso:ticket:{ticketId}, JsonSerializer.Serialize(payload), TicketTtl); return ticketId; } // 消费GETDEL 一条命令完成读取删除并发下只有一个调用方能拿到值 public async TaskTicketPayload? ConsumeAsync(string ticketId, string appId) { var raw await _redis.StringGetDeleteAsync($sso:ticket:{ticketId}); if (raw.IsNullOrEmpty) return null; // 已消费或已过期 var payload JsonSerializer.DeserializeTicketPayload(raw!); if (payload is null || payload.AppId ! appId) return null; // 票据不属于这个子系统 if (payload.ExpiresAt DateTimeOffset.UtcNow.ToUnixTimeSeconds()) return null; return payload; } }StringGetDeleteAsync对应 Redis 的GETDEL需要较新版本的 StackExchange.Redis 客户端。如果你的依赖版本偏旧没有这个方法就用一段 Lua 脚本兜底效果一样// 旧版客户端的等价写法读和删在同一段脚本里天然原子 private const string GetDelScript local v redis.call(GET, KEYS[1]) if v then redis.call(DEL, KEYS[1]) end return v;几个参数值得说明。AppId绑定校验不能省——少了它攻击者拿 A 站票据去 B 站换会话虽然也是同一用户但会破坏“票据只对签发给它的那个子系统有效”这个前提多子系统场景下会出权限错配。ExpiresAt的二次校验是给旧版客户端兜底的如果用了GETDEL其实 Redis 的 TTL 已经保证了过期即消失但这行留着不亏成本是一次时间戳比较。Guid.ToString(N)生成 32 位无横线字符串比带横线的短也不影响可读性——票据本来就是给人看日志用的。4.2 SsoAuthMiddleware子系统侧的拦截与本地会话建立子系统这边所有逻辑集中在一个中间件里没会话就跳认证中心带着 ticket 回来就换会话其余放行。public sealed class SsoAuthMiddleware { private readonly RequestDelegate _next; private readonly SsoOptions _opt; private readonly IHttpClientFactory _http; private readonly ILoggerSsoAuthMiddleware _log; public SsoAuthMiddleware(RequestDelegate next, IOptionsSsoOptions opt, IHttpClientFactory http, ILoggerSsoAuthMiddleware log) { _next next; _opt opt.Value; _http http; _log log; } public async Task InvokeAsync(HttpContext ctx) { // 回调路径、健康检查、静态资源不拦截否则会自己跳自己形成死循环 if (ctx.Request.Path.StartsWithSegments(/sso/callback) || ctx.Request.Path.StartsWithSegments(/health)) { await _next(ctx); return; } // 回调分支用 ticket 换本地会话 if (ctx.Request.Path.StartsWithSegments(/sso/callback)) { await HandleCallbackAsync(ctx); return; } if (ctx.User.Identity?.IsAuthenticated true) { await _next(ctx); return; } var state Guid.NewGuid().ToString(N); ctx.Response.Cookies.Append(sso_state, state, new CookieOptions { HttpOnly true, Secure true, SameSite SameSiteMode.Lax, MaxAge TimeSpan.FromMinutes(5) }); var redirect Uri.EscapeDataString(_opt.RedirectUri); ctx.Response.Redirect(${_opt.Authority.TrimEnd(/)}/sso/authorize $?appId{_opt.AppId}redirectUri{redirect}state{state}); } }state的存法这里用了临时 Cookie比放 Session 轻。回跳时要做的第一件事是比对state不一致直接 400 并清 Cookie——这一步不做等于把 CSRF 防护送掉了。SameSiteLax是让浏览器在顶级导航也就是 302 跳回来这种时仍然带上 Cookie设成Strict的话回跳会丢设成None又要求必须Secure在纯 HTTP 的内网环境里会直接不生效。HandleCallbackAsync里是最关键的一步用服务端 HTTP 调用换取身份不在浏览器里传身份。票据通过 URL 传回来但校验是后端对后端浏览器拿不到任何用户信息。private async Task HandleCallbackAsync(HttpContext ctx) { var ticket ctx.Request.Query[ticket].ToString(); var state ctx.Request.Query[state].ToString(); var saved ctx.Request.Cookies[sso_state]; if (string.IsNullOrEmpty(ticket) || state ! saved) { ctx.Response.StatusCode 400; return; } var ts DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); var sign SignHelper.Hmac(${_opt.AppId}|{ticket}|{ts}, _opt.AppSecret); var client _http.CreateClient(sso); var resp await client.PostAsJsonAsync(${_opt.Authority.TrimEnd(/)}/sso/validate, new { appId _opt.AppId, ticket, timestamp ts, sign }); if (!resp.IsSuccessStatusCode) { _log.LogWarning(票据校验失败 {Code}, resp.StatusCode); ctx.Response.Redirect(${_opt.Authority}/sso/authorize?appId{_opt.AppId} $redirectUri{Uri.EscapeDataString(_opt.RedirectUri)}); return; } var user await resp.Content.ReadFromJsonAsyncSsoUser(); var claims new ListClaim { new(ClaimTypes.NameIdentifier, user!.UserId), new(ClaimTypes.Name, user.DisplayName), new(sso:session, user.SessionId) }; var identity new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity), new AuthenticationProperties { ExpiresUtc DateTimeOffset.UtcNow.AddMinutes(_opt.SessionMinutes) }); ctx.Response.Redirect(/); }注意校验请求带的是sign而不是明文appSecret签名串里塞了timestamp所以抓包重放也过不了 5 分钟窗口。sso:session这个 claim 存的是认证中心那边的会话 ID登出时要靠它告诉认证中心清哪个会话。4.3 SsoControllerauthorize / validate / logout 三个端点认证中心只有三个端点是别人会调的其余都是页面。[ApiController] public sealed class SsoController : ControllerBase { [HttpGet(/sso/authorize)] public async TaskIActionResult Authorize(string appId, string redirectUri, string state) { // 1) 白名单校验redirectUri 必须与注册值完全一致 if (!_apps.IsValidRedirect(appId, redirectUri)) return BadRequest(redirect_uri 未注册); // 2) 看全局会话 var sessionId Request.Cookies[sso_session]; var user sessionId is null ? null : await _sessions.GetAsync(sessionId); if (user is null) return Redirect($/account/login?returnUrl{Uri.EscapeDataString(Request.Path Request.QueryString)}); // 3) 已有会话直接发票据回跳 var ticket await _tickets.IssueAsync(user.UserId, appId, redirectUri); var sep redirectUri.Contains(?) ? : ?; return Redirect(${redirectUri}{sep}ticket{ticket}state{state}); } [HttpPost(/sso/validate)] public async TaskIActionResult Validate([FromBody] ValidateRequest req) { if (!_apps.TryGetSecret(req.AppId, out var secret)) return Unauthorized(); var expect SignHelper.Hmac(${req.AppId}|{req.Ticket}|{req.Timestamp}, secret); if (!SignHelper.FixedTimeEquals(expect, req.Sign)) return Unauthorized(); if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(req.Timestamp)) 300) return Unauthorized(); var payload await _tickets.ConsumeAsync(req.Ticket, req.AppId); if (payload is null) return Unauthorized(); var user await _accounts.FindAsync(payload.UserId); return Ok(new SsoUser(user.UserId, user.DisplayName, user.SessionId)); } }Authorize里最容易写错的是那个sep回调地址本身带查询串时拼接必须用而不是?否则ticket会变成前一个参数的附加值子系统读不到表现为“无限跳登录页”。Validate里的签名比较用了固定时间比较。普通在字符串比较时会短路理论上能被时序攻击慢慢猜出签名虽然实际利用门槛很高但这是几行代码的成本没有理由省。4.4 全局登出与 back-channel 通知登出之所以容易出问题是因为“登出”在 SSO 里其实有两个动作清认证中心的全局会话和通知所有子系统清各自的本地会话。只做第一个用户回到子系统会发现自己还是登录状态——因为本地 Cookie 还在而它压根不会每次请求都去问认证中心。[HttpPost(/sso/logout)] public async TaskIActionResult Logout([FromForm] string sessionId) { await _sessions.RemoveAsync(sessionId); // 删 Redis 里的全局会话 Response.Cookies.Delete(sso_session); // 清 Cookie // back-channel并行通知所有已注册子系统单个失败不影响整体 var tasks _apps.AllBackchannelUrls().Select(async url { try { using var client _http.CreateClient(sso); client.Timeout TimeSpan.FromSeconds(2); await client.PostAsJsonAsync(url, new { sessionId }); } catch (Exception ex) { _log.LogWarning(ex, 登出通知失败 {Url}, url); } }); await Task.WhenAll(tasks); return Redirect(/account/login); }子系统的 back-channel 端点做的事很简单按sessionId匹配本地会话匹配上就SignOutAsync。匹配不上的直接忽略因为一个用户可能有多个本地会话多标签页、多设备这里只清跟当前全局会话关联的那个。注意back-channel 是尽力而为的。对方站点挂了、超时了全局会话已经被清掉用户下次访问那个站会重新跳认证中心而认证中心此时没有会话会要求重新登录——最终结果仍然正确只是体验上多了一次登录。这就是为什么全局会话必须删干净它是唯一的兜底。5. 排查与避坑上线前必须自己踩一遍的 5 个坑这些坑我按“现象 → 原因 → 解决”整理都是这套流程里绕不开的。提前知道能省掉一整晚。5.1 会话与 Cookie 类问题坑一两个站互相顶掉登录 A 之后 B 显示的用户不对现象用localhost:5001和localhost:5002跑两个站登录 A 之后打开 BB 直接显示已登录而且用户名可能还是错的有时候登录 B 会把 A 顶下线。原因Cookie 的作用域只看域名和路径不看端口。两个站都叫localhost默认 Cookie 名又都是框架默认值浏览器认为是同一个 Cookie后写的覆盖先写的。这不是代码 bug是浏览器规范。解决给每个子系统显式配不同的Cookie.Name示例里就是sso_client_a/sso_client_b生产环境直接用不同子域从根上隔离。开发阶段改 hosts 用app-a.local、app-b.local比死磕端口省事得多。坑二Chrome 里登录后无限跳转Edge 下偶尔正常现象认证中心回跳后子系统又把人踢回认证中心来回几次浏览器报“重定向次数过多”。原因多半是认证中心的sso_sessionCookie 设成了SameSiteStrict。跨站 302 回跳属于跨站请求Strict下浏览器根本不带这个 Cookie认证中心每次都认为你没登录于是再发一次票据循环。解决认证中心的会话 Cookie 用SameSiteLax。只有当子系统与认证中心真正跨站不是同主域的子域且需要 POST 回跳时才考虑SameSiteNone; Secure同时必须保证全站 HTTPS。5.2 票据与跳转类问题坑三同一张票据被用了两次居然都成功现象压测或者用户手快连点两次回跳日志里出现两条相同ticket的校验成功记录。原因如果消费逻辑写成先StringGetAsync再KeyDeleteAsync中间有一个时间窗两个并发请求都能读到值然后都删一次。删除是幂等的所以第二个请求不会报错而是正常拿到身份。解决用StringGetDeleteAsyncRedis 6.2 的GETDEL或者 Lua 脚本把读和删合成一个原子操作。这个改动只有一行但不改就是实打实的重放漏洞。坑四登出之后刷新页面又自动登录了现象点了登出当前页显示未登录但一刷新又进去了甚至换个子系统也是登录态。原因只清了子系统的本地 Cookie认证中心的全局会话还在。子系统下次拦截到未登录请求跳到认证中心认证中心一看全局会话有效二话不说发张新票据用户又回来了。解决登出必须走“先通知认证中心、再清本地”的顺序而且认证中心要负责 back-channel 广播。顺序反了的话本地已经清了通知请求可能因为拿不到会话标识而失败。坑五回调地址被改成外部站点也能通过现象安全扫描报了一个开放重定向构造redirectUrihttps://外部站点也能被认证中心接受并带上票据跳过去。原因redirectUri只做了“非空”校验没做白名单匹配。票据就这样被送到了第三方手里。解决AppRegistry里为每个appId维护一组精确匹配的回调地址比较时用完整字符串相等不要用StartsWith或Contains——https://app-a.local.evil.com能轻松绕过前缀匹配。示例里的IsValidRedirect就是按这个思路写的。5.3 部署与环境类问题坑六本地一切正常上测试环境后签名校验全挂现象/sso/validate全部返回 401日志里签名对不上但代码一个字没改。原因多机部署时机器时钟不同步。签名串里带了timestamp认证中心用自己机器的时间去算两边差超过容忍窗口就永远对不上即使签名对了5 分钟窗口校验也会拒。解决所有节点接同一个时间源容器里别用默认的宿主时间漂移。容忍窗口设 5 分钟是留了余量的正常情况下 NTP 同步误差在毫秒级不该出问题——出问题说明时间源本身就有毛病别去调大窗口掩盖它。6. 二次开发从票据模式切到 JWT 无状态、对接存量账号体系跑通之后八成的人会问同一个问题这套东西要不要换成 JWT。我的答案是看子系统数量和解耦程度不是看时髦。票据模式现在这套的特点是认证中心持有状态好处是登出即时生效、能强制下线、能查在线列表代价是每次换会话都要回调认证中心一次且 Redis 成了关键依赖。JWT 模式把身份签进 token 里子系统本地验签即可认证中心压力小、可用性要求低代价是登出很难即时生效只能靠短过期时间 黑名单兜底想做“踢人下线”就得再引入一份共享状态等于把有状态的复杂度又请回来了。维度票据 全局会话JWT 无状态登出即时性立刻生效依赖过期时间或黑名单认证中心可用性要求高每次换会话都要调低只需签发时可用横向扩容Redis 共享即可天然无状态强制下线 / 在线列表直接支持需要额外状态改造成本基准需要改中间件和校验逻辑我的做法是保留票据做首次登录把子系统本地会话换成短寿命 JWT比如 15 分钟 刷新票据两者混着用既拿到了无状态校验的性能又保住了登出的即时性。改造点集中在HandleCallbackAsync的签发部分和子系统中间件的校验部分TicketService一行不用动。对接存量用户表时改动面比想象中小把AccountService的FindAsync和密码校验换成你自己的仓储与哈希算法注意存量系统常用 MD5 或 SHA1登录成功时顺手升级成 PBKDF2 是常规操作SsoUser里补上你需要的角色、部门这类 claimSsoAuthMiddleware里SignInAsync的 claims 列表跟着加就行。真正要小心的是别把权限判断塞进认证中心——认证中心只负责“你是谁”授权留在各子系统否则一个新业务上线就要改认证中心这条边界一旦破了后面全是耦合。从那以后我每次接这类统一登录都强制先拿两个子域跑一遍完整的登录、跨站跳转、登出、再登录四步再动一行业务代码。顺序错了后面查的都是假问题。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑