简介:C#单点登录示例程序SSO面向具备一定ASP.NET基础的开发者及身份认证方向的学习者,聚焦多个子站点之间共享登录状态的实际需求,提供一套可运行、可拆解的SSO实现参考。压缩包共173个文件,约2.07MB,以64个cs源码和16个aspx页面为主体,辅以dll程序集、csproj项目文件、config配置、SQL数据库文件及docx说明文档,能够较完整地呈现项目结构、依赖关系与调试环境。目前已有495人学习,适合用于课程设计、毕业设计或企业认证模块的原型验证。源码围绕中央认证服务展开,涉及ASP.NET Identity账户管理、JWT/OAuth2令牌生成与校验、子站点接入、HTTP重定向与Cookie传递,并覆盖HTTPS通信、令牌过期与重放防护、单元测试和集成测试等要点。读者可据此梳理登录跳转、令牌验证与多站点会话共享的完整链路,理解面向接口和依赖注入在SSO中的落地方式,并积累身份认证模块的排错思路。
1. 三套系统三套账号,先别急着敲代码
某公司内部有三个系统:订单、报表、工单。每个系统各有一套账号体系,员工每天上班先登录三次,改密码要改三次,离职销号经常漏掉一个。这类需求落到技术侧,就是一套 C# 单点登录(SSO)方案要回答的问题:一次登录,全部系统通行;一处登出,全部系统失效;新增系统接入只改配置、不改认证中心。这份 C# 单点登录示例程序把认证中心、票据签发与校验、业务系统接入中间件、登出广播拆成几个能独立跑起来的工程,主站和两个子系统可以在一台机器上直接启动联调。它适合两类人:正在做毕设或课设、需要交一套真能演示“单点”效果的同学;以及要给内部老系统补统一登录、又不想引入重型认证框架的从业者。
2. 需求拆解与技术选型:四条硬指标和三条 SSO 路线的取舍
在动手写认证中心之前,我一般会先把“单点登录”这个模糊的词拆成能写进验收单的指标。拆不出来,后面架构怎么画都是空的,答辩时评委问一句“你怎么证明这是单点而不是免密登录”,就容易卡住。
2.1 把“单点登录”翻译成四条可验收的硬指标
第一条是一次登录。用户在任意一个子系统完成认证后,访问其余子系统不再输入密码,整个跳转链路里的 302 重定向不超过两次。这个“不超过两次”不是拍脑袋,而是用户体验的底线:每多一次跳转,页面就多一次白屏,还会带来 Referer 泄露和回跳参数丢失的风险。
第二条是一次登出。用户在任意子系统点击退出,认证中心和所有已登录子系统在 5 秒内全部失效,再访问任意系统都要重新认证。很多示例程序只做了“登出当前站”,用户退回浏览器上一页还能继续操作,这在验收时是硬伤。
第三条是票据不可重放。回跳 URL 上携带的服务票据必须一次性消费,同一个票据被第二次提交时,认证中心要明确拒绝并记录日志。这一条决定了方案能不能叫“安全”,而不仅仅是“方便”。
第四条是接入成本可控。新增一个子系统,只需要在认证中心登记 appId、密钥和回跳白名单,业务代码里加一段中间件注册和一个配置节,不修改认证中心任何一行代码。这条指标直接决定了这套东西是“演示 Demo”还是“能交给别人用的组件”。
四条指标立住之后,后面的选型就不是凭喜好了。哪条路线能同时满足跨域、能登出、能防重放、接入便宜,就选哪条。
2.2 三条 SSO 路线的对比:共享 Cookie、会话共享、票据中心
在实际项目里能见到的实现基本就这三类,我把它们放在同一张表里对比,选型时对着看比翻文档快。
| 对比项 | 共享 Cookie | 会话(Session)共享 | 票据中心(本方案) |
|---|---|---|---|
| 基本原理 | 主域写 Cookie,子域自动携带 | 各站点 Session 存同一个 Redis | 认证中心签发票据,业务后端校验 |
| 跨主域能力 | 无,域名不同直接失效 | 弱,依赖统一的 Session 结构 | 强,跨域跨语言都能接 |
| 单点登出 | 删 Cookie 即可,但各站本地缓存难清 | 删 Session 即可,各站无感知逻辑 | 广播通知,各站主动清本地会话 |
| 防重放 | 无票据概念,谈不上重放 | 无票据概念 | 一次性票据 + 时间窗 + 签名 |
| 异构客户端 | 差,桌面端拿不到浏览器 Cookie | 差 | 好,桌面端走回环端口取票据 |
| 实现复杂度 | 低 | 中 | 中偏高,但边界清晰 |
共享 Cookie 是最省事的做法,把 Cookie 的 Domain 设成.corp.internal这种主域,所有子域共用一份认证 Cookie。它的问题在于“域”这个边界太硬:一旦有系统部署在完全不同的域名下,或者要给桌面客户端用,方案立刻失效。而且它本质上没有票据,登出只能靠删 Cookie,各站内存里的用户对象照样活着。
会话共享的问题在耦合。把 Session 塞进 Redis 让各站共用,听起来巧妙,实际上要求所有站点用同一套 Session 序列化结构、同一个框架版本。一旦有个老系统是 .NET Framework 写的,序列化格式对不上,整条链路就断了。
票据中心走的是另一条路:认证中心只负责“证明你是谁”,业务系统各自维护自己的本地会话,两边靠一张一次性票据对接。耦合点被压缩到一个 HTTP 接口和一份签名算法上,跨语言、跨框架都不是问题。这份示例程序选的就是这条路线,认证中心用 ASP.NET Core Web API 实现,票据存储用 Redis,业务侧提供一个可复用的中间件类库。
2.3 解决方案分层:三个工程各管一摊
示例程序按职责切成四个项目,刻意让“认证”和“接入”在物理上分开,避免有人图省事把认证逻辑直接写进业务系统。
SsoSolution/ ├── Sso.Contracts/ 共享契约:票据模型、签名工具、异常码 ├── Sso.AuthCenter/ 认证中心:登录页、票据签发、校验、登出广播 ├── Sso.Client/ 接入类库:中间件、本地会话存储、回跳处理 └── Sso.DemoApps/ 演示子系统:OrderSystem、ReportSystem 两个站点Sso.Contracts是最薄的一层,只放 DTO 和 HMAC 签名实现,认证中心和客户端都要引用它,所以它不能依赖任何 Web 相关包,否则客户端引用进来会拖一堆东西。Sso.AuthCenter是唯一持有用户密码和应用密钥的地方,它对内提供四个接口:授权跳转、票据签发、票据校验、全局登出。Sso.Client是给业务系统引用的,核心是一个中间件,业务方在Program.cs里app.UseSsoAuthentication()一行接进来。Sso.DemoApps只是两个最小可运行的站点,用来验证“同一浏览器登录一次,两边都放行”。
这个分层的价值在于边界清晰:认证中心的任何改动都不会波及业务系统的编译,业务系统升级也不用重新发布认证中心。带过完整项目的人都知道,这种“物理隔离”比写在文档里的规范管用得多。
3. 认证中心的设计与实现:双票据模型、Redis 存储与登出广播
认证中心是整个方案中唯一需要特别小心处理的部分,它一旦出问题,所有子系统一起失效。设计上我把票据分成两层,不是故意复杂化,而是被 URL 的天然属性逼出来的。
3.1 为什么是两层票据:TGT 与 ST 的分工
回跳 URL 上携带的东西,会进入浏览器历史记录、进入服务器访问日志、还可能通过 Referer 头泄露给第三方资源。所以 URL 上绝对不能放“能长期代表用户身份”的凭证。反过来,如果什么都不放,业务系统每次请求都要回认证中心问一次,认证中心就会变成全站单点瓶颈。
两层票据就是在这个矛盾里找的平衡点。第一层是 TGT(Ticket Granting Ticket,全局票据),它代表“这个浏览器已经登录过”,存放在 HttpOnly + Secure 的 Cookie 里,有效期 30 分钟且带滑动续期,前端脚本读不到。第二层是 ST(Service Ticket,服务票据),它只在一次回跳中有效,有效期 60 秒、用完即删、且绑定目标 appId。ST 泄露了也没关系,攻击者拿到的时候它多半已经过期,或者已经被正常流程消费掉了。
Redis 里的键设计如下,几个键的职责和生命周期各不相同:
| Key 模式 | 类型 | 值内容 | TTL | 作用 |
|---|---|---|---|---|
sso:tgt:{tgtId} | String | 用户信息 JSON | 30 分钟滑动 | 全局会话,登出的判断依据 |
sso:st:{stId} | String | tgtId|appId | 60 秒 | 一次性服务票据 |
sso:user:{userId}:tickets | Set | tgtId 集合 | 与 TGT 同 | 按用户批量清会话,支撑全局登出 |
sso:app:{appId} | Hash | 密钥、回跳白名单 | 永久 | 应用注册表,接入时只改这里 |
sso:local:{sid} | String | 用户信息 JSON | 30 分钟 | 业务系统本地会话(由客户端写) |
最后一行虽然归客户端用,但键的命名规则由认证中心统一约定,避免各子系统各起各的名字,运维排查时找不到。
3.2 票据签发与一次性消费的代码实现
票据模型先定下来,字段不多但每个都有用:
// Sso.Contracts/TicketModels.cs public sealed class SsoTicket { public string TicketId { get; set; } = ""; // GUID("N"),Redis 键的一部分 public string UserId { get; set; } = ""; public string UserName { get; set; } = ""; public string AppId { get; set; } = ""; // 票据归属应用,防跨应用使用 public long IssuedAt { get; set; } // Unix 秒,用于排查超时问题 public long ExpiresAt{ get; set; } public string Signature{ get; set; } = ""; // HMAC-SHA256(appId|ticketId|issuedAt) }签发的核心逻辑在TicketService里,TGT 写入 Redis 并同步维护“用户 → 票据”的索引集合,这一步是为登出准备的:
public async Task<string> IssueTgtAsync(UserInfo user) { var tgtId = Guid.NewGuid().ToString("N"); // 30 分钟有效期,业务侧每次命中本地会话时可调用 Refresh 续期 await _redis.StringSetAsync(TgtPrefix + tgtId, JsonSerializer.Serialize(user), TimeSpan.FromMinutes(30)); // 索引集合:全局登出时按 userId 一次取出所有 TGT await _redis.SetAddAsync($"sso:user:{user.UserId}:tickets", tgtId); return tgtId; } public async Task<string> IssueStAsync(string tgtId, string appId) { var raw = await _redis.StringGetAsync(TgtPrefix + tgtId); if (raw.IsNullOrEmpty) throw new SsoException("TGT_EXPIRED"); var stId = Guid.NewGuid().ToString("N"); // ST 只活 60 秒,值里绑定 tgtId 与 appId,防止票据被挪用到别的系统 await _redis.StringSetAsync(StPrefix + stId, $"{tgtId}|{appId}", TimeSpan.FromSeconds(60)); return stId; }校验接口是整个方案里防守最严的地方,顺序不能乱:先验时间窗,再原子消费票据,最后验签。把验签放在消费之后,是为了让重放攻击连一次签名计算的机会都没有:
public async Task<UserInfo> ConsumeStAsync(string stId, string appId, long timestamp, string sign) { // 1. 时间窗:允许 ±300 秒,超出直接拒绝,挡住隔天重放的旧请求 if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp) > 300) throw new SsoException("CLOCK_SKEW"); // 2. 一次性消费:StringGetDelete 原子取值并删除,并发请求里只有一个能拿到值 var value = await _redis.StringGetDeleteAsync(StPrefix + stId); if (value.IsNullOrEmpty) throw new SsoException("ST_INVALID_OR_USED"); var parts = value.ToString().Split('|'); if (parts.Length != 2 || parts[1] != appId) throw new SsoException("APP_MISMATCH"); // 3. 验签:参与签名的字段必须与调用方完全一致,多一个空格都算失败 if (!SignatureHelper.Verify($"{stId}|{appId}|{timestamp}", sign, GetSecret(appId))) throw new SsoException("SIGN_INVALID"); // 4. 回读 TGT:这中间用户可能已经在别处登出,TGT 已被删除 var tgt = await _redis.StringGetAsync(TgtPrefix + parts[0]); if (tgt.IsNullOrEmpty) throw new SsoException("SESSION_LOGOUT"); return JsonSerializer.Deserialize<UserInfo>(tgt)!; }第 2 步用的StringGetDelete对 Redis 版本有要求,6.2 及以上才支持原子 GETDEL。如果部署环境版本偏低,就退化成 Lua 脚本或者先StringGet再KeyDelete的两步写法,但两步写法在并发下有极小的窗口能重复消费,演示可以接受,真要上线就老老实实升级 Redis。这个坑我在联调里踩过一次,两个浏览器标签同时回跳,两边都登录成功了,日志里同一个 ST 出现了两次。
3.3 全局登出与失效广播
登出动作要完成三件事:删 TGT、删该用户所有 TGT、通知所有子系统清本地会话。前两件在 Redis 里一次搞定,第三件靠 Pub/Sub:认证中心往sso:logout频道发一条userId|tgtId|timestamp的消息,各业务系统的后台订阅者收到后,按 userId 清掉自己本地的会话记录。
// Sso.AuthCenter:登出接口内部 public async Task LogoutAsync(string userId) { var tickets = await _redis.SetMembersAsync($"sso:user:{userId}:tickets"); foreach (var t in tickets) await _redis.KeyDeleteAsync(TgtPrefix + t); await _redis.KeyDeleteAsync($"sso:user:{userId}:tickets"); // 广播出去,订阅方各自清理本地会话,5 秒内传播完成 await _subscriber.PublishAsync("sso:logout", $"{userId}|{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"); }注意:Pub/Sub 是“发出去就不管”的模型,订阅方在消息发出时如果正好重启,这条消息就丢了。更稳的做法是额外维护一个短期失效列表
sso:revoked:{userId},客户端在下次校验本地会话时顺带查一次,作为广播的补救。示例程序里留了这个扩展点,默认没开。
4. 业务系统接入:一个中间件接住三种客户端形态
认证中心再漂亮,业务侧接入要改几十行代码,也没人愿意用。所以Sso.Client的设计目标是:业务系统只提供一个配置节,加一行中间件注册,剩下的都不管。
4.1 中间件的拦截顺序:本地会话优先,票据兜底
中间件在每个请求进来时按固定顺序判断:先看本地会话有没有,有就放行;没有再看请求里带没带 ST,带了就校验并建本地会话;两样都没有,302 到认证中心换票据。顺序反了会出现“每次请求都往认证中心跑”的性能灾难。
public sealed class SsoAuthMiddleware { private readonly RequestDelegate _next; public SsoAuthMiddleware(RequestDelegate next) => _next = next; public async Task InvokeAsync(HttpContext ctx, ISsoClient client) { // 回跳与登出接口本身不拦截,否则自己就把自己拦成死循环 var path = ctx.Request.Path.Value ?? ""; if (path.StartsWith("/sso/callback") || path.StartsWith("/sso/logout")) { await _next(ctx); return; } // 1. 本地会话命中,直接放行,这一步承担 99% 的请求量 var session = await client.GetLocalSessionAsync(ctx); if (session != null) { ctx.User = session.ToPrincipal(); await _next(ctx); return; } // 2. 拿到 ST 说明刚从认证中心跳回来,校验后建本地会话 var st = ctx.Request.Query["st"].ToString(); if (!string.IsNullOrEmpty(st)) { var user = await client.ValidateStAsync(st, ctx); if (user != null) { await client.CreateLocalSessionAsync(ctx, user); // 写 Cookie + Redis ctx.Response.Redirect(client.SafeReturnUrl(ctx) ?? "/"); return; } } // 3. 都没有,去认证中心,returnUrl 带上当前完整路径 ctx.Response.Redirect(client.BuildAuthorizeUrl(ctx.Request.Path + ctx.Request.QueryString)); } }配置节的结构如下,Mode字段决定走共享 Cookie 还是票据回跳,两种模式共用同一份中间件代码:
{ "Sso": { "AuthCenter": "https://sso.auth.internal", "AppId": "order-system", "AppSecret": "开发环境占位值,生产从环境变量注入", "Mode": "TicketRedirect", "ReturnUrl": "http://order.local/sso/callback", "SessionMinutes": 30, "AllowedReturnHosts": [ "order.local", "report.local" ] } }AppSecret绝对不能出现在前端能拿到的任何地方,它只参与服务端到服务端的验签。示例程序在开发环境把它写在appsettings.Development.json里方便跑通,配置文件顶部用注释标了“生产必须走环境变量或密钥管理服务”,这条注释别删,删了就一定有人在生产抄这份配置。
4.2 两种接入模式:同域共享与跨域回跳
同一主域下的子系统(order.local和report.local)其实不需要 ST,直接把认证 Cookie 的 Domain 设成.local就能共享,Mode配成SharedCookie,中间件只做本地会话转换,一跳都不用。这种模式性能最好,但只在同一主域、同一浏览器环境下成立。
跨主域、或者要接桌面端的时候,就必须用Mode: TicketRedirect,走完整的“跳过去 → 拿 ST → 跳回来 → 校验”链路。两种模式共用同一份中间件,切换只改一个配置项,这是刻意的设计:让同一个团队在开发环境用简单模式快速跑通,上线时按部署拓扑切换。
4.3 桌面端的接入:回环端口方案
WinForm 或 WPF 客户端拿不到浏览器 Cookie,最省事的做法是“借浏览器完成登录,再从回环端口把票据捞回来”:桌面程序启动时在本机随机端口起一个临时 HTTP 监听,然后打开系统浏览器访问认证中心的授权地址,把redirect_uri指向http://127.0.0.1:{port}/callback。用户在浏览器里完成登录后,认证中心回跳,桌面端从监听里拿到 ST,再调用校验接口换成本地 Token。
// 桌面端:启动临时监听,等待回跳携带的 st using var listener = new HttpListener(); listener.Prefixes.Add($"http://127.0.0.1:{port}/callback/"); listener.Start(); Process.Start(new ProcessStartInfo(authorizeUrl) { UseShellExecute = true }); var context = await listener.GetContextAsync(); // 阻塞等待回跳 var st = context.Request.QueryString["st"]; // 立刻返回一个“登录成功,请关闭本页”的静态页,避免用户以为卡住 var body = Encoding.UTF8.GetBytes("<h3>登录完成,可以关闭此页面</h3>"); context.Response.OutputStream.Write(body); listener.Stop(); var token = await ssoClient.ExchangeAsync(st); // 换本地 Token这套方案的关键点是:监听只在登录窗口期存在,拿到票据立刻关闭;端口随机分配,避免冲突;回跳地址在认证中心的白名单里按127.0.0.1前缀放行,而不是全域名放开。如果不做这三件事,桌面端的回环端口就是一个可以被本机任意进程抢占的开放入口。
5. 避坑与排查:SSO 联调里翻车最多的五个点
SSO 的坑几乎都不在代码写错,而在“协议层和浏览器行为的组合”。下面五条是按项目里出现频率排序的,写在验收前看一遍,能省掉大半天。
5.1 现象:登录后瞬间被踢回登录页,来回死循环
现象很直观,浏览器地址栏在认证中心和业务系统之间反复跳,最后报“重定向次数过多”。
原因通常有两个。一是returnUrl参数被 URL 编码了两次,认证中心解出来是%2Fhome而不是/home,白名单校验失败,被迫退回登录页。二是回跳地址带了尾部的查询串,白名单只匹配了域名前缀,匹配不上就丢弃。
解决办法是把编码集中到一处处理,中间件里统一用Uri.EscapeDataString编码一次,认证中心统一用Uri.UnescapeDataString解码一次,禁止在上下游各编一次。白名单匹配改成解析Uri后比对 Host 和白名单列表,而不是粗暴的字符串StartsWith:
private static readonly string[] AllowedHosts = { "order.local", "report.local", "ticket.local" }; public static bool IsSafeReturnUrl(string url) { if (string.IsNullOrWhiteSpace(url)) return false; if (!Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri)) return false; if (!uri.IsAbsoluteUri) return url.StartsWith("/") && !url.StartsWith("//"); // 挡掉 //evil.com return AllowedHosts.Contains(uri.Host, StringComparer.OrdinalIgnoreCase); }//evil.com这种写法绕过了StartsWith("/")的判断,浏览器会把它当成协议相对 URL 跳到外部站点,是开放重定向的经典入口,必须单独挡。
5.2 现象:主站退了,子系统还活着
用户在订单系统点退出,回到报表系统刷新一下,居然还在,用户当场就认为“这套单点登录是假的”。
原因是登出只删了认证中心的 TGT 和当前站的本地会话,其他子系统的本地会话还在 Redis 里躺着,它们的中间件只看本地会话,压根没去问认证中心。
解决办法靠 3.3 里的广播:认证中心发sso:logout,各子系统订阅后按 userId 批量删本地会话。同时把本地会话的 TTL 压到比 TGT 短(比如本地 25 分钟、TGT 30 分钟),这样即使广播丢了,最坏情况下 25 分钟后也会自然失效。验收时专门测这一条:登录三个站,在其中任意一个退出,然后在另外两个各刷新一次,必须全部跳到登录页。
5.3 现象:跨域子系统拿不到 Cookie,登录状态丢失
同域模式下一切正常,换成跨主域部署后,用户在订单系统登录成功,跳到报表系统又是未登录。
原因在 Cookie 的作用域和SameSite属性上。Domain 写成了具体的order.local而不是主域,或者SameSite=Strict导致顶层导航回跳时浏览器根本不发送 Cookie。跨站跳转属于“顶层导航”,Lax才放行 GET 请求携带 Cookie,Strict会拦掉。
解决办法是把认证 Cookie 的 Domain 设为共享主域、SameSite=Lax、Secure=true、HttpOnly=true,并且确认回跳链路是 GET 而不是 POST。如果业务必须 POST 回跳,就干脆放弃共享 Cookie 模式,改用票据回跳,别在SameSite上硬凑。
5.4 现象:同一个 ST 被用了两次,两边都登录成功
日志里出现两条相同的 ST 消费记录,两边的用户信息还都是一致的,功能上没报错,安全上已经失守。
原因是校验接口先StringGet再KeyDelete,两个并发请求同时读到了值,都通过了校验。这种问题在单线程压测下永远复现不了,只在多标签页、多设备同时回跳时冒出来。
解决办法是把消费做成原子操作,用StringGetDelete(Redis 6.2+)或 Lua 脚本,一次完成取值和删除。上线前专门写一个并发用例:同一个 ST 用两个线程同时请求校验接口,断言只有一个返回 200,另一个必须返回ST_INVALID_OR_USED。
5.5 现象:验签偶尔失败,重启后自己又好了
同一份配置,早上跑得好好的,下午开始间歇性报SIGN_INVALID,重启一下就恢复,隔天再犯。
原因是两台服务器的系统时间不同步,时间戳校验的 ±300 秒窗口被击穿。这种问题排查起来很玄学,因为报错是随机的,日志里也看不出时间差。解决办法是给时间戳校验失败单独打一条日志,把本机时间和请求时间都打出来,一眼就能看出偏移多少。
var skew = Math.Abs(now - timestamp); if (skew > 300) { _logger.LogWarning("票据时间窗超限 stId={StId} 偏移={Skew}s 本机={Now} 请求={Ts}", stId, skew, now, timestamp); throw new SsoException("CLOCK_SKEW"); }同时把服务器时间同步纳入部署检查清单,别等报错才想起来。血泪经验是:这类问题不解决,联调期间团队会互相怀疑对方代码有问题,浪费的时间远超修它的成本。
6. 验收与答辩:用一张表和三个问题证明它真的“单点”
验收别靠“我点了下感觉是对的”,照着表逐条过,过不了的当场记下来。下面这张表是这套示例程序自带的验收清单,六项全绿才算交付。
| 验收项 | 操作步骤 | 期望结果 |
|---|---|---|
| 单点登录 | 在订单系统登录,直接访问报表系统首页 | 不出现登录页,直接进入 |
| 单点登出 | 三个站各登录一次,在报表系统退出,刷新另两站 | 5 秒内全部跳回登录页 |
| 票据一次性 | 抓一次回跳 URL,重放其中的st参数 | 第二次返回ST_INVALID_OR_USED |
| 票据过期 | 篡改请求时间戳为 10 分钟前 | 返回CLOCK_SKEW |
| 回跳白名单 | 把returnUrl改成外部域名 | 被拒绝,停留在登录页 |
| 未登录拦截 | 清空浏览器 Cookie 直接访问受保护页面 | 302 到认证中心,登录后回到原页面 |
答辩时被问最多的三个问题,提前准备好答案比临场编强。第一个是“为什么不用共享 Cookie 一把梭”,答法是从跨域和桌面端两个场景切入,说明共享 Cookie 在这两种情况下直接失效,而不是说它不好。第二个是“票据被中间人截获怎么办”,答法是 ST 一次性加 60 秒有效期,截获时大概率已被消费或过期,同时说明生产环境全链路 HTTPS 是前提。第三个是“新增一个系统要改什么”,现场打开sso:app:{appId}这个 Hash 的写入代码,指给对方看只有三行:appId、密钥、回跳白名单,其他一律不动。
答辩演示的顺序也有讲究:先演示最顺利的单点登录,建立信心;再演示单点登出,这是最能体现设计深度的一步;最后现场跑一次票据重放,用命令行工具把同一个st提交两次,第二次报错弹出来的瞬间,比讲十分钟原理都管用。演示前把三个站点的浏览器标签按顺序排好,别在台上现找地址。
从那以后我每次交付认证类的东西,都会在演示脚本里强制走一遍“登出后刷新另外两个站”这一步,因为这是唯一一个能同时暴露广播、本地会话、TTL 三处问题的动作,跳过它,问题一定留给用户去发现。希望帮到你。
本文还有配套的精品资源,点击获取