☰
C#实现票据式SSO单点登录:认证中心、子站接入与全局登出
2026/10/26 23:46:59 网站建设 项目流程

简介:这是一份面向C#/.NET开发者与身份认证学习者的单点登录(SSO)示例程序源码,围绕中央认证服务、令牌签发与子站点验证展开,适合用来理解跨应用统一登录的完整链路与工程落地方式。压缩包共173个文件,约2.07MB,以cs源代码、aspx页面、dll程序集、csproj项目文件及config配置为主,并包含asmx服务、wsdl/disco元数据、数据库文件与说明文档,便于直接运行、调试和按模块阅读。已有495人学习下载,说明其在SSO入门与实战参考方面具有一定关注度。读者可从中梳理ASP.NET Identity账户管理、JWT或Access Token生成校验、HTTP重定向与Cookie携带、子站点鉴权以及HTTPS、令牌过期和重放防护等设计要点,也可结合单元测试与集成测试思路检查各组件交互,从而掌握C#环境下SSO的关键技术与实现细节。

1.

一套 SSO 单点登录系统真正麻烦的地方不在登录页,而在票据怎么发、怎么验、怎么作废。这份 C# 单点登录示例程序 SSO 把认证中心和多个子站之间的完整链路拆开摆出来:用户在任一子站触发登录,跳到认证中心完成认证,拿到一张一次性票据,再回到子站换取本地登录态。它解决的是「一次登录、多站通行」以及对「退出后所有站点同时失效」的诉求。

这套方案适合三类读者:正在做单点登录课程设计、需要一套能跑通并能讲清楚的学生;把公司内部若干后台系统做统一入口的从业者;以及只想弄清票据式 SSO 到底在请求链路上发生了什么的开发者。下面从需求边界、架构职责、两端代码实现一路写到验收与答辩,结构可以直接照搬,代码可以按注释改参数。

2. 需求边界与总体架构:票据式 SSO 的三层职责划分

在动手写代码前,先把「单点」这件事拆成可以验证的行为,否则后面很容易做成「每个站各自登录一次,只是长得像统一入口」。这一章先把选型对比讲清楚,再把认证中心、票据、子站三层的职责和数据表定下来,后面两章的代码才有落脚点。

2.1 从 Session 共享到票据校验的选型对比

最常见的入门做法是把 Session 放 Redis,让所有子站共享同一个 Session 存储。这个方案在小规模内网里能跑,但它有两个隐性问题:一是子站必须能访问同一个 Session 存储,跨网络区域部署时很别扭;二是「登录态」和「会话存储」强绑定,哪天想接一个第三方框架写的老站点,就没法让它只做一次校验。

票据式 SSO(CAS 风格)把「认证」和「会话」解耦:认证中心只负责证明「这个人是谁」,子站自己维护本地会话,登录时拿一张短时效、一次性的票据去认证中心换一次身份信息。子站不需要连认证中心的存储,只需要能发 HTTP 请求。

方案子站改造成本是否要求共享存储跨域友好度适合场景
Session 共享低是一般同一内网、站点数量少
同域 Cookie极低否仅同主域挂在同一域名下的多个路径
票据校验(本方案)中否好多域名、多技术栈子站
OAuth2/OIDC高否好需要对接外部身份提供方
纯 JWT 自包含中否好无状态、不需要主动作废

选票据而不是纯 JWT,关键原因是「主动登出」。JWT 一旦签发,在过期前很难作废,除非再维护一份黑名单,反而把无状态的优势抵消了。票据存在服务端,注销时直接删 Key,立刻失效,这对企业内部系统是硬需求。

2.2 认证中心、票据、子站的职责与流转时序

三层职责要划清楚。认证中心持有用户库、TGT(全局登录态)和票据存储;票据是一张一次性凭证,记录「谁、给哪个子站、什么时候过期」;子站只做三件事——发现没有本地登录态就跳认证中心、拿到票据后校验一次、校验通过后写自己的 Cookie。

完整链路按下面几步走,可以在答辩时直接画在纸上:

  1. 用户访问子站 A 的受保护页面,子站 A 发现没有本地 Cookie,302 跳到认证中心的登录入口,带上自己的站点标识service。
  2. 认证中心发现没有 TGT Cookie,展示登录页;用户提交账号密码,认证通过后生成 TGT,写入认证中心域下的 HttpOnly Cookie。
  3. 认证中心生成一张绑定子站 A 的票据,302 回到子站 A 的回调地址,URL 上带ticket。
  4. 子站 A 拿到票据,后端发一次 POST 到认证中心的校验接口,带上ticket和service。
  5. 认证中心校验票据存在、未过期、未被使用、service匹配,删除票据,返回用户标识。
  6. 子站 A 写入自己的本地 Cookie,跳回用户最初想访问的地址。
  7. 用户再访问子站 B,重复第 1 步,但因为已有 TGT,认证中心跳过登录页,直接发票据,用户无感。

提示:第 5 步的「删除票据」必须和「校验通过」是原子操作,先查后删的写法在并发下会被重放,具体写法在第 3 章给出。

2.3 数据表与关键字段设计

用户表和票据存储分开。用户表落在关系库,票据落在 Redis,因为票据是高频读写的短生命周期数据,用关系库反而增加清理负担。

-- 用户表:只保留认证需要的最小字段 CREATE TABLE sso_user ( id VARCHAR(32) PRIMARY KEY, -- 用户唯一标识 user_name VARCHAR(64) NOT NULL UNIQUE, -- 登录名 display_name VARCHAR(64) NOT NULL, -- 展示名 password_hash VARCHAR(256) NOT NULL, -- 加盐哈希,禁止明文 enabled TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL ); -- 子站注册表:回跳地址必须走白名单,防止被拼出任意跳转 CREATE TABLE sso_service ( service_id VARCHAR(64) PRIMARY KEY, -- 子站标识,如 portal、report service_name VARCHAR(64) NOT NULL, callback_url VARCHAR(256) NOT NULL, -- 允许的回跳地址 enabled TINYINT NOT NULL DEFAULT 1 );

Redis 里两个 Key 前缀:sso:tgt:{tgtId}存用户标识,有效期等于全局会话时长(例:30 分钟滑动续期);sso:ticket:{ticketId}存票据 JSON,有效期默认 2 分钟。票据有效期短是有意为之——它只在「跳转那一瞬间」有用,窗口越短越安全。

callback_url单独建表而不是写在配置文件里,是为了让接入新子站时不用改代码重启认证中心,同时把「允许跳到哪里」变成一条可审计的记录。这一点在验收时很好用,直接展示白名单表就能说明跳转是受控的。

3. 认证中心实现:登录入口、票据签发与校验接口

认证中心是整个方案的核心,代码量不大,但有三个地方必须写对:票据模型要自带service绑定、校验要做成原子操作、登出要能通知到子站。下面按模块给实现,C# 部分基于 ASP.NET Core 的最小 API 写法,换成 MVC 控制器结构也一致。

3.1 票据模型与 Redis 存储封装

先定数据结构。票据不是简单的字符串,它要能回答「这张票是发给谁的、什么时候作废、有没有被用过」三个问题。

// 一次性票据:绑定目标子站,有效期极短 public sealed class SsoTicket { public string TicketId { get; init; } = Guid.NewGuid().ToString("N"); public string UserId { get; init; } = default!; public string DisplayName { get; init; } = default!; public string Service { get; init; } = default!; // 必须与校验请求一致 public DateTimeOffset IssuedAt { get; init; } = DateTimeOffset.UtcNow; public DateTimeOffset ExpiresAt { get; init; } = DateTimeOffset.UtcNow.AddMinutes(2); } public interface ITicketStore { Task<string> IssueAsync(SsoTicket ticket); // 返回 ticketId Task<SsoTicket?> ConsumeAsync(string ticketId, string service); // 校验并销毁 Task RevokeByUserAsync(string userId); // 全局登出用 }

关键在ConsumeAsync:它必须一次完成「读、比对、删」。如果先Get再Delete,两个并发请求可能都通过校验。用 Redis 的 Lua 脚本把三步打包成一个原子命令:

public sealed class RedisTicketStore : ITicketStore { private readonly IDatabase _db; // KEYS[1]=ticket key, ARGV[1]=期望的 service private const string ConsumeScript = @" local raw = redis.call('GET', KEYS[1]) if not raw then return nil end redis.call('DEL', KEYS[1]) return raw"; public RedisTicketStore(IConnectionMultiplexer redis) => _db = redis.GetDatabase(); public async Task<string> IssueAsync(SsoTicket ticket) { var key = $"sso:ticket:{ticket.TicketId}"; // 过期时间交给 Redis 管理,避免应用层漏删 await _db.StringSetAsync(key, JsonSerializer.Serialize(ticket), ticket.ExpiresAt - DateTimeOffset.UtcNow); return ticket.TicketId; } public async Task<SsoTicket?> ConsumeAsync(string ticketId, string service) { var raw = (string?)await _db.ScriptEvaluateAsync( ConsumeScript, new RedisKey[] { $"sso:ticket:{ticketId}" }); if (raw is null) return null; // 不存在或已被消费 var ticket = JsonSerializer.Deserialize<SsoTicket>(raw); if (ticket is null || ticket.ExpiresAt < DateTimeOffset.UtcNow) return null; if (!string.Equals(ticket.Service, service, StringComparison.Ordinal)) return null; return ticket; } }

ConsumeAsync的返回值为null有四种可能:票据不存在、已过期、已被消费、service不匹配。对调用方来说都归为「校验失败」,但对排查来说必须能区分——所以实际项目中会在脚本里返回不同标记,再写一条结构化日志,第 5 章会讲到这个坑。

3.2 登录入口与票据签发

登录入口分成两个端点:/sso/login负责判断有没有 TGT、要不要展示登录页;/sso/ticket负责在已有 TGT 的前提下发票据。拆开的好处是「已登录用户」的路径完全跳过一次表单提交,体验上是无感的。

// 入口:没有 TGT 就回登录页,有 TGT 直接进发票据环节 app.MapGet("/sso/login", (HttpContext ctx, string service, IServiceRegistry registry) => { if (!registry.IsAllowed(service, out var callback)) return Results.BadRequest("unknown service"); // 不在白名单直接拒绝 var tgt = ctx.Request.Cookies["sso_tgt"]; var target = $"/sso/ticket?service={Uri.EscapeDataString(service)}"; return string.IsNullOrEmpty(tgt) || !tgtStore.Exists(tgt) ? Results.Redirect($"/sso/loginpage?returnUrl={Uri.EscapeDataString(target)}") : Results.Redirect(target); }); // 发票据:生成一次性票据并携带回跳 app.MapGet("/sso/ticket", async (HttpContext ctx, string service, IServiceRegistry registry) => { var tgt = ctx.Request.Cookies["sso_tgt"]; if (string.IsNullOrEmpty(tgt)) return Results.Redirect("/sso/login?service=" + service); var user = await tgtStore.GetUserAsync(tgt); // TGT 里恢复用户信息 if (user is null) return Results.Redirect("/sso/login?service=" + service); registry.IsAllowed(service, out var callback); var ticket = new SsoTicket { UserId = user.Id, DisplayName = user.Name, Service = service }; var id = await ticketStore.IssueAsync(ticket); var sep = callback.Contains('?') ? "&" : "?"; return Results.Redirect($"{callback}{sep}ticket={id}"); // 只跳白名单地址 });

service参数只能当作「查表的主键」,绝不能直接当作跳转目标拼接。上面代码里最终跳的是registry.IsAllowed返回的callback,service本身不参与 URL 构造,这样即使有人传service=//evil.example,也只会得到 400。

/sso/ticket这一层还要加限流:同一个 TGT 在短时间内反复请求发票据,正常用户不会这么做,出现了大概率是脚本在刷。用 ASP.NET Core 的限流中间件按 TGT 哈希做每分钟 20 次的窗口,够用且不误伤。

3.3 校验接口与全局登出

校验接口是子站唯一会调用的接口,设计上要满足三点:POST 传参(避免票据出现在访问日志里)、返回结构稳定、失败原因可区分。

public record ValidateRequest(string Ticket, string Service); public record ValidateResponse(bool Ok, string? UserId, string? DisplayName, string? Reason); app.MapPost("/sso/validate", async (ValidateRequest req, ITicketStore store) => { if (string.IsNullOrWhiteSpace(req.Ticket) || string.IsNullOrWhiteSpace(req.Service)) return Results.Ok(new ValidateResponse(false, null, null, "bad_request")); var ticket = await store.ConsumeAsync(req.Ticket, req.Service); return ticket is null ? Results.Ok(new ValidateResponse(false, null, null, "invalid_ticket")) : Results.Ok(new ValidateResponse(true, ticket.UserId, ticket.DisplayName, null)); });

注意:校验接口不要做成 GET。票据在 URL 上会被浏览器历史、反向代理日志、Referer 三处记录下来,等于把凭证到处散发。示例里的/sso/ticket只是把票交给浏览器带一程,真正使用必须走 POST。

全局登出要解决「子站本地 Cookie 还在」的问题。做法是登出时把该用户的所有 TGT 和票据删掉,同时记录一个登出时间戳;子站每次校验本地会话时,本地会话的有效期不能超过这个时间戳。更彻底的做法是登出后由认证中心依次回调各子站的登出地址,但子站可能离线,所以通常以「本地会话短期化 + 校验时核对」为主,回调为辅。

app.MapPost("/sso/logout", async (HttpContext ctx) => { var tgt = ctx.Request.Cookies["sso_tgt"]; if (!string.IsNullOrEmpty(tgt)) { var user = await tgtStore.GetUserAsync(tgt); if (user is not null) await ticketStore.RevokeByUserAsync(user.Id); // 清票据 + 记登出时间 await tgtStore.RemoveAsync(tgt); } ctx.Response.Cookies.Delete("sso_tgt"); return Results.Redirect("/sso/loggedout"); });

本地会话设 20 分钟、每 10 分钟回认证中心校一次,这样「已登出但子站还有 Cookie」的窗口最大不超过 10 分钟。这个数字是权衡出来的:太短会让认证中心压力变大,太长会让登出不彻底。

4. 子站接入实现:客户端封装、票据校验与登录态回写

子站侧的代码决定了这套 SSO 好不好用。理想状态下,接入一个新子站只需要改配置和加几行中间件,不应该要求业务代码知道「票据」的存在。这一章给出接入清单、客户端封装和登出落地的具体写法。

4.1 子站接入清单与配置项

接入前先确认四件事:子站标识已注册进sso_service表、回跳地址可被浏览器访问、子站能出网访问认证中心、子站的 Cookie 名称不与认证中心冲突。四项里最容易漏的是最后一项——如果认证中心的 Cookie 叫.AUTH,子站也叫.AUTH且在同一父域下,会互相覆盖,症状是「登录完立刻掉线」。

配置建议集中放一段,不要散落在各处:

{ "Sso": { "Authority": "http://auth.internal.example", "ServiceId": "report", "LocalCookie": "report_auth", "SessionMinutes": 20, "RevalidateMinutes": 10, "IgnorePaths": [ "/health", "/css", "/js", "/favicon.ico" ] } }

IgnorePaths必须配。静态资源和健康检查如果也走一遍跳转判断,页面上会出现大量 302,前端开发者会以为接口挂了。SessionMinutes和RevalidateMinutes的差值,就是登出后最长的「残留窗口」。

4.2 认证中间件与本地会话回写

中间件按「已登录 → 有票据 → 都没有」三个分支写,顺序不能反。有票据的分支里,校验通过后要做两件事:写本地 Cookie、把 URL 上的ticket参数去掉再重定向,否则用户刷新页面会拿着一张已经作废的票再校验一次,直接失败。

app.Use(async (ctx, next) => { var opt = ctx.RequestServices.GetRequiredService<IOptions<SsoOptions>>().Value; if (opt.IgnorePaths.Any(p => ctx.Request.Path.StartsWithSegments(p))) { await next(); return; } // 1) 本地会话有效,直接放行 if (ctx.User.Identity?.IsAuthenticated == true) { await next(); return; } // 2) URL 上带票据,校验一次 var ticket = ctx.Request.Query["ticket"].ToString(); if (!string.IsNullOrEmpty(ticket)) { var ok = await ssoClient.ValidateAsync(ticket, opt.ServiceId); if (ok.Success) { await LocalSession.SignInAsync(ctx, ok.UserId!, ok.DisplayName!, opt.SessionMinutes); var clean = ctx.Request.Path + ctx.Request.QueryString.Value! .Replace($"ticket={ticket}", "").TrimEnd('?', '&'); ctx.Response.Redirect(clean); return; } // 校验失败不要停在这里,回到认证中心重新走一遍 } // 3) 都没有,跳认证中心,并记住原地址 var returnUrl = Uri.EscapeDataString(ctx.Request.Path + ctx.Request.QueryString); ctx.Response.Redirect($"{opt.Authority}/sso/login?service={opt.ServiceId}&returnUrl={returnUrl}"); });

returnUrl只用于「登录完成后回原位」,绝不参与最终跳转目标——真正的跳转目标始终是配置里的回跳地址加一个票据参数。这个区分在评审时会被问到,答清楚能加分:用户可控的地址只能作为参数传递,不能作为重定向目标。

LocalSession.SignInAsync里对接 ASP.NET Core 的 Cookie 认证方案即可,注意设置HttpOnly = true、SameSite = Lax、生产环境Secure = true。SameSite=None只在子站被跨站 iframe 嵌入时才需要,而那种场景通常还得配Secure,配置不当会直接被浏览器丢弃 Cookie。

4.3 全局登出在子站的落地

子站要提供一个登出入口,但点击后不应该只清本地 Cookie,否则用户回到别的子站还是登录状态。正确顺序是:先跳认证中心登出,再回来清本地。

app.MapGet("/logout", (HttpContext ctx, IOptions<SsoOptions> opt) => { // 先让认证中心作废 TGT 与票据,再回到本地的清理端点 var back = Uri.EscapeDataString($"{ctx.Request.Scheme}://{ctx.Request.Host}/logout-local"); return Results.Redirect($"{opt.Value.Authority}/sso/logout?returnUrl={back}"); }); app.MapGet("/logout-local", async (HttpContext ctx) => { await ctx.SignOutAsync(); // 清本地 Cookie return Results.Redirect("/"); // 回到匿名可访问页 });

再加一道保险:子站每次回认证中心校验本地会话时,如果认证中心返回「用户已登出」,子站就主动清掉本地 Cookie。这条路径覆盖了「用户只在一个子站点了登出、其他子站页面还开着」的情况,代价是子站每隔 10 分钟有一次请求。

5. 避坑与常见问题排查:五类最容易翻车的场景

下面五条不是理论风险,是票据式 SSO 在联调阶段最常卡住人的地方。每条按「现象 → 原因 → 解决」写,遇到时可以直接对照排查。

5.1 票据被重放:同一张票能换两次登录态

现象:用抓包工具把回调 URL 复制出来,在另一个浏览器里粘贴,仍然能登录成功。原因:校验接口先Get再Delete,两个请求并发到了「Get 成功、还没 Delete」的窗口里;或者票据只校验了存在性,没有在校验成功后立即删除。解决:把读、比对、删合并成一次 Redis Lua 调用(第 3.1 节的写法),并对同一张票据做幂等标记。另外给票据加service绑定,即使泄露也只能用于原本那个子站。

5.2 回跳地址被拼成任意跳转

现象:把登录链接里的service改成外部地址,登录完成后浏览器跳到了站外。原因:代码里直接把请求参数当作重定向目标,Results.Redirect(service)这类写法最常见。解决:service永远只当查表主键,跳转目标从白名单表里取。取不到就返回 400,不要「容错跳首页」——容错会让问题被掩盖到上线后才发现。

5.3 Cookie 跨域失效:登录成功但一刷新又跳登录

现象:回调地址上明显带了票据,校验也返回成功,但页面刷新后又是未登录。原因:三选一——Cookie 的Domain没设对,只对认证中心域生效;SameSite在跨站跳转场景下被浏览器拦截;子站用了http而 Cookie 标了Secure。解决:抓响应头看Set-Cookie实际下发的属性;同一父域下明确写Domain=.example.internal;跨站跳转场景用SameSite=Lax而不是None;本地调试统一用一套协议,别一边 http 一边 https。

5.4 时间不同步导致票据「刚发就过期」

现象:偶尔出现「票据无效」,重试一次又好了,日志里票据时间看起来正常。原因:认证中心和子站不在同一台机器,系统时间差超过票据有效期(默认 2 分钟)就会命中;或者用的是本地时间而不是 UTC,跨时区部署时直接错开。解决:全链路统一DateTimeOffset.UtcNow,服务器接入统一时间源;票据有效期不要压到 30 秒以内,2 分钟是比较稳的值。日志里同时打印签发端和校验端的时间,出现问题时一眼能看出偏差。

5.5 登出只清了当前站

现象:在子站 A 点登出,再打开子站 B,还是登录状态。原因:登出只删了子站 A 的本地 Cookie,没有回认证中心作废 TGT;或者作废了 TGT,但子站 B 的本地会话还在有效期内且从不回查。解决:登出链路必须走「先认证中心、后本地」;子站本地会话做短期化并定期回查;认证中心侧记录登出时间戳,校验时比对。

提示:这五条里有三条是「先查后改」引发的并发问题。凡是涉及票据状态变更的地方,都按原子操作来设计,能省掉大量联调时间。

6. 验收与答辩:用一条请求链路证明「单点」是真的

验收这套东西,别演示「能登录」,要演示「只有一个登录态」。我的做法是准备一份验收清单,逐项当场跑,跑完再讲设计取舍。

验收项操作方式通过标准
首次登录匿名访问子站 A 受保护页跳认证中心,登录后回到原地址
跨站免登紧接着访问子站 B不出现登录页,直接进入
票据一次性复制回调 URL 二次访问第二次校验失败,回到登录流程
越权跳转篡改service参数返回 400,不发生站外跳转
全局登出在子站 A 登出后访问子站 B10 分钟内失效,跳认证中心
会话过期静置超过全局会话时长重新要求登录,无异常报错

跑清单时把浏览器开发者工具的 Network 面板打开,让评审看到 302 的跳转顺序:子站 → 认证中心 → 子站回调 → 校验 POST → 最终页面。这条链路本身就是最好的说明,比讲十分钟概念有效。

答辩时最容易被追问的三个问题,提前准备好答案:为什么票据有效期是 2 分钟而不是 2 小时(因为它只承担一次跳转,窗口越短泄露面越小);为什么不用 JWT(需要主动作废能力);子站离线了怎么办(本地会话短期化 + 恢复后回查,认证中心不阻塞子站启动)。回答时把参数和取舍讲出来,比背概念分高。

还有个细节:演示前把日志级别调到能打印票据 ID 的前 8 位,但别打印完整票据和密码哈希。有一次我在演示环境把完整票据打进日志,被指出「日志本身就是泄露渠道」,当场改的。从那以后我每次做认证相关的方案,都强制走一遍「日志里有没有凭证」这条检查,再开始讲架构。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询