C#单点登录示例拆解:认证中心+票据实现一次登录多系统 简介这是一份基于C#实现单点登录SSO的示例程序面向.NET开发人员主要解决多个子站点之间统一身份认证与登录状态共享的问题。压缩包共173个文件包含64个.cs核心逻辑源码、16个.aspx页面、.config配置文件、wsdl/disco服务描述、DLL引用库以及示例数据库mdf/ldf等整体大小约2.07MB目录分区清晰用户登录、令牌服务、受保护页面等均有对应模块便于按需查阅。资源围绕中央认证服务CAS展开完整演示了利用ASP.NET Identity实现用户管理、JWT令牌生成与验证、子站点令牌信任、HTTP重定向与Cookie会话保持等SSO关键环节并考虑了通信加密、令牌有效期及防重放等安全细节。目前已有494人学习下载。通过研读源码、配置及数据库脚本可快速理解从CAS服务端到受保护子站点的完整交互流程同时掌握项目分层、接口抽象和依赖注入等设计手法。项目中还展示了如何通过配置管理子站点地址以及为令牌验证编写测试用例的方法适合用来改造现有系统或搭建轻量级统一身份认证平台。1. C#单点登录示例程序拆解一次登录全部系统放行的核心思路做C#单点登录示例程序拆解前先给一个反直觉的结论SSO单点登录真正共享的不是账号密码而是“登录状态”这个东西。很多刚接触的开发者以为让多个系统共用一张用户表就算单点登录结果登录态仍然各自独立——在A系统登录后进B系统又得输一次密码。我拆过一套内网C#多系统场景主站、报表、后台三个Web应用用户每天要在每个系统各登录一次体验极差管理员也抱怨账号改密后各系统不同步。这个C# SSO示例解决的就是这件事由认证中心统一校验身份登录一次生成一张全局票据其他系统凭票据换自己的本地会话。适合刚接手企业门户、内部管理系统或者想把认证跳转完整链路搞清楚再动手写代码的C#开发者。2. 先拆原理认证中心与客户端的分工 三种形态对比2.1 认证中心是核心登录状态统一收敛先讲清楚SSO为什么能成立。业务系统各自登录、各自建会话那叫“多次登录”不叫单点登录。单点登录的核心思维是把“校验用户名密码”这个动作收敛到一个独立站点其余系统只消费认证结果不重复验证账号密码。这里最关键的一个架构决策是认证中心不直接给客户端系统发“用户密码”而是发一张临时凭证客户端系统拿凭证换自己的本地会话。身份验证只发生一次后续系统之间传递的是凭证和会话引用不是敏感明文。一个完整的跳转流程是这样浏览器访问客户端应用A的受保护资源客户端发现本地没有登录态响应302跳转到认证中心登录页带上client_id、redirect_uri、state。认证中心检查自己的会话发现未登录展示登录页用户输入账号密码认证中心验证通过创建全局会话。认证中心拼接一个回跳地址redirect_uri?ticketxxxstateyyy浏览器302跳回客户端A。客户端A收到ticket后端调用认证中心的校验接口验证ticket有效后建立本地登录会话返回受保护资源。用户再访问客户端B同样没有本地会话但认证中心已经有了全局会话所以不再让用户登录而是直接生成新ticket回跳客户端B校验后建立自己的会话。这就是网上经常说的“认证中心是唯一信任锚点”。理解了这个链路你在看示例代码的时候就不会迷路客户端系统里几乎不会出现“验证密码”的代码验证密码只出现在认证中心项目里。2.2 三种实现形态对比同域Cookie、跨域跳转、Token/OIDC同样是SSO落地的技术形态差别很大。常见的有三种形态实现方式适用场景复杂度典型安全边界同域Cookie共享认证中心和所有子系统共用父域登录Cookie写在统一域名下各系统直接读同域名下的几个Web应用低任何子系统被注入脚本Cookie都会泄露信任边界太宽跨域跳转票据认证中心独立域名子系统各自校验票据并换取会话不同域名/端口的内部系统传统Web应用中票据需要防篡改、防重放、回跳地址需要白名单Token/OIDC认证中心签发JWT或OAuth2授权码客户端验签或按协议换token前后端分离、移动端、开放平台高密钥管理、token有效期、scope最小化是重点我拆的这个示例程序采用的是第二行“跨域跳转票据”。原因很直接它最贴近企业内部多系统场景既不像同域Cookie那样过度信任也不引入JWT的密钥分发负担代码量适中正好能把SSO原理讲透。如果你要接的是现代前端项目后面提到的跳转思路仍然成立只是把“ticket”换成“授权码”把“Cookie”换成“Access Token”。2.3 C#侧选型为什么要把“认证中心”做成独立ASP.NET Core项目示例程序里一般会有三个项目认证中心AuthCenter、客户端AClientA、客户端BClientB。客户端项目不引用认证中心的类库也不共享数据库只通过HTTP重定向和校验接口交互。这样做的目的是防止开发者图省事直接把认证逻辑拷贝进每个业务系统——一旦那样做了后续改密码策略、加二次验证你就得每个系统都改一遍SSO的意义就没了。在C#生态里常见做法是认证中心基于ASP.NET Core MVC Cookie认证写一个精简版客户端基于ASP.NET Core的认证中间件做拦截。更重的方案可以上IdentityServer4、OpenIddict这一类框架但对于学习SSO链路先把精简实现看明白再套框架否则会像看黑匣子登录请求发出去中间件帮你跳转、帮你验票但你不知道中间件替你做了什么出了问题只能抓瞎。2.4 会话和票据的区别两个易混词很多人在示例代码里把“Session”和“Ticket”混着说排障时就出问题。我的理解是这样会话Session是服务端保存在内存、Redis或数据库里的状态它不经过浏览器URL传递只通过Cookie里的一个SessionId索引票据Ticket是认证中心生成的一段临时凭证它要经过浏览器URL传递所以必须短命、一次性、并且只能被认证中心校验。会话是“长期身份”票据是“临时通行证”。认证中心登录成功建立会话客户端登录成功建立自己的会话票据只在“认证中心 - 客户端”这一段路上出现。你把这两者分清后看代码里的“存储”和“过期时间”设置就不会一头雾水。3. 把示例跑起来解决方案结构、数据库表与关键配置3.1 解决方案结构与项目职责拿到示例程序后第一件事是先看解决方案目录。一个典型的C# SSO示例结构大概是这样的项目/目录职责关键文件AuthCenter认证中心负责登录页面、票据发放、票据校验接口Controllers/AccountController.cs、Views/Login.cshtmlClientA客户端A演示受保护页面Controllers/HomeController.cs、Startup.csClientB客户端B验证一次登录多点可用Controllers/HomeController.cs、Startup.csShared公共票据模型和常量定义TicketConstants.cs客户端项目之间、客户端与认证中心之间没有直接的项目引用。各自启动在独立端口上通过HTTP通信这模拟了真实生产环境里的跨站点跳转。如果你打开解决方案发现只有一个Web项目那多半是示例被简化成了“认证中心内置客户端”看原理可以但复现完整跳转就差点意思。3.2 数据库表结构与模型定义认证中心的核心数据是用户账号和票据。常见的示例会给出两张表SsoUser和SsoTicket。SsoUser存用户名、密码哈希、显示名SsoTicket存票据ID、用户ID、过期时间、来源回跳地址、是否使用过。示例为了演示方便有时也直接用内存字典但生产环境别这么干内存数据一经重启就全部消失。下面这组是典型的Entity Framework Core实体定义public class SsoUser { public int Id { get; set; } public string UserName { get; set; } public string PasswordHash { get; set; } public string DisplayName { get; set; } } public class SsoTicket { public string Id { get; set; } // 票据ID直接用GUID字符串 public int UserId { get; set; } // 关联SsoUser public DateTime ExpiresAt { get; set; } // 过期时间建议UTC public bool Used { get; set; } // 是否已被消费防重放 public string IssueIp { get; set; } // 记录签发IP便于排障 }字段含义拆开说PasswordHash不能存明文示例里多半用PBKDF2或类似算法做哈希Used这个字段是关键很多初学者会忘记在“消费票据”后把它置为true结果一张票据能反复登录所有客户端这是最典型的安全漏洞。IssueIp不是必须的但用它做审计日志时很有用。3.3 关键配置appsettings.json参数详解示例程序给你跑通主要就是改两个配置文件。认证中心的appsettings.json长这样{ ConnectionStrings: { AuthDB: Serverlocalhost;DatabaseSsoAuthCenter;User Idsa;Passwordyour_password;TrustServerCertificateTrue }, SsoSettings: { CookieName: AuthCenter.Session, CookieDomain: .example.local, TicketExpireMinutes: 5, AllowedClientUrls: [ http://clienta.example.local:5001/login-callback, http://clientb.example.local:5002/login-callback ] }, Logging: { LogLevel: { Default: Information } } }参数说明如下CookieName认证中心全局会话的Cookie名。名字要起得和业务系统自己的登录Cookie区分开避免相互覆盖。CookieDomain如果采用“跨域跳转票据”方案这个Domain通常不需要配成父域按认证中心自己的域名写即可。假如只用localhost多端口调试这里留空。TicketExpireMinutes临时票据有效期。设5分钟是给网络慢、调试留余量生产环境我一般用2分钟越短越安全。AllowedClientUrls回跳地址白名单。这是整个配置里最重要的项只允许信任的客户端回跳地址出现在这个列表里防止开放重定向被利用。客户端项目的appsettings.json则要配AuthCenterUrl认证中心地址和LocalCookieName本地登录态Cookie名。这两个值一个决定了往哪跳一个决定了自己怎么看“是否已登录”。注意不要图方便把认证中心地址直接写在Controller里否则后面换环境部署要改一堆文件。3.4 启动与初始化步骤从建库到三个站点并行我第一次跑这套示例时踩了“只启动了认证中心”的坑在ClientA上访问页面一直跳转因为端口根本没起来。正确做法是先建库再按顺序把所有站点都拉起来。# 假设示例已经还原好依赖先建数据库 dotnet ef database update -p AuthCenter -s AuthCenter # 分别启动认证中心、客户端A、客户端B每个站点独立端口 dotnet run --project AuthCenter --urls http://localhost:5000 dotnet run --project ClientA --urls http://localhost:5001 dotnet run --project ClientB --urls http://localhost:5002命令含义拆开说dotnet ef database update是把迁移脚本应用到目标数据库生产环境需要把连接字符串改成真实数据库地址--urls指定站点监听端口不同客户端必须用不同端口否则Session和Cookie会互相覆盖表现出的现象就是“登录态错乱”。三个命令行窗口分别跑不要试图放到同一个控制台后台运行因为ASP.NET Core站点默认是前台阻塞的CtrlC可以快速停掉某个站点做测试。启动之后用未登录状态访问ClientA的受保护页面浏览器地址栏会发生两次跳转http://localhost:5001-http://localhost:5000/Account/Login?redirect_uri...输入账号后 -http://localhost:5001/login-callback?ticket...state...如果这两次跳转中间卡住多半是配置里的端口、域名或回调地址没对齐后面避坑章会专门列排查方法。4. 核心代码链路登录回调与票据校验4.1 认证中心的登录Action只做验证不做业务认证中心的登录接口承担的是“校验密码 创建全局会话 发放临时票据”。一个精简实现如下[HttpPost] public async TaskIActionResult Login(LoginViewModel model) { // 1. 校验用户名密码这是认证中心的核心职责 var user await _userRepo.FindByNameAsync(model.UserName); if (user null || !_userRepo.VerifyPassword(user, model.Password)) { return View(model); // 返回登录页并显示错误 } // 2. 创建认证中心全局会话 var sessionId Guid.NewGuid().ToString(N); await _sessionStore.SaveAsync(sessionId, user.Id, TimeSpan.FromHours(8)); Response.Cookies.Append(AuthCenter.Session, sessionId, new CookieOptions { HttpOnly true, Expires DateTimeOffset.Now.AddHours(8) }); // 3. 生成一次性票据票据不是“用户信息”只是换取会话的凭证 var ticket new SsoTicket { Id Guid.NewGuid().ToString(N), UserId user.Id, ExpiresAt DateTime.UtcNow.AddMinutes(5), Used false }; await _ticketRepo.CreateAsync(ticket); // 4. 回跳客户端带上票据和state var state _antiForgeryService.GenerateState(); return Redirect(${model.RedirectUri}?ticket{ticket.Id}state{state}); }逻辑说明第1步是唯一校验密码的地方客户端项目里不会出现VerifyPassword。第2步是给认证中心自己的会话“打个标记”这样用户再访问ClientB时认证中心发现已有AuthCenter.Session就不需要重新输密码。第3步生成的票据不包含密码或token只是一串随机Id真正的用户信息由客户端再调用接口换取避免票据在URL里被截获后直接泄露用户身份。第4步的state必须和发起时生成的state一致这是防CSRF和防跳转伪造的关键。参数你要注意RedirectUri不能直接接收前端传过来的任意值必须在进入登录页前就用服务端配置的AllowedClientUrls做白名单校验。示例放在Login的GET入口里。4.2 客户端如何拦截未登录请求拦截器只做跳转客户端不能把“认证中心地址”硬编码进每个Controller。常规做法是在一个认证中间件或全局ActionFilter里统一判断。下面的代码是ClientA里的SsoRedirectMiddlewarepublic async Task Invoke(HttpContext context) { var path context.Request.Path; // 回调接口不拦截否则会形成死循环 if (path.StartsWithSegments(/login-callback)) { await _next(context); return; } // 判断本地会话是否已存在 var localSession context.Request.Cookies[ClientA.Session]; if (string.IsNullOrEmpty(localSession)) { var returnUrl Uri.EscapeDataString( ${context.Request.Scheme}://{context.Request.Host}{context.Request.Path}); var loginUrl _authCenterUrl /Account/Login? $redirect_uri{returnUrl}client_idclientastatexyz; context.Response.Redirect(loginUrl); return; } await _next(context); }逻辑说明中间件把“是否已登录”的判断收敛到一个位置任何受保护请求进来先看本地Cookie没有再统一跳认证中心。/login-callback路径放行是必须的否则认证中心回跳的请求又被中间件截住直接形成“跳出去又跳回来”的循环。state参数在这里先写死是“xyz”生产环境应该是每次跳转前生成、存在服务端或带签名回跳时校验。很多示例为了演示简洁会忽略它但正式项目必须补。4.3 票据校验与会话回写一次性使用拒绝重放客户端收到ticket之后不能直接信任要调认证中心校验接口。示例里通常有两种做法同步调用认证中心的ValidateTicket接口或客户端直接读共享票据表。生产中我更倾向接口方式因为客户端不会碰到认证中心的数据库。[HttpGet(/login-callback)] public async TaskIActionResult Callback(string ticket, string state) { // 1. 校验state防止跨站请求伪造 if (!_stateStore.Validate(clienta, state)) { return BadRequest(state mismatch); } // 2. 调用认证中心接口换用户信息 var userInfo await _authCenterClient.ValidateTicketAsync(ticket); if (userInfo null) { return Redirect(/home/denied); } // 3. 创建本地会话 var localSessionId Guid.NewGuid().ToString(N); Response.Cookies.Append(ClientA.Session, localSessionId, new CookieOptions { HttpOnly true, SameSite SameSiteMode.Lax, Expires DateTimeOffset.Now.AddHours(8) }); await _localSessionStore.SaveAsync(localSessionId, userInfo.UserId); return Redirect(userInfo.ReturnUrl ?? /home/index); }校验接口ValidateTicketAsync内部遵循的规则是票据在数据库里存在、未超过ExpiresAt、且Usedfalse查到后立即把Used置为true。这一步非常关键它保证了一张票据只能换一次会话。如果不做一次性消费攻击者截获了一次跳转URL就能反复用同一张票据登录所有客户端。在这里票据有效期短、一次性使用、state明确是三重防线。4.4 认证中心校验接口的返回结构接着定义校验接口的返回格式这个格式会被所有客户端复用。我的建议是返回一个最小化的JSON{ success: true, userId: 1001, userName: zhangsan, displayName: 张三, returnUrl: /home/index }字段说明success表示票据校验是否通过userId和userName是客户端建立本地会话需要的最小信息displayName用于界面展示returnUrl是客户端登录前想去的原始地址由认证中心在签发ticket时原样带回来。不要把密码哈希、手机号、邮箱等敏感字段塞进这个返回值客户端只需要“这个用户是谁”不需要知道更多。返回结构里没有角色权限信息角色应该由客户端自己的数据库管理不要在认证中心里做“全家桶”。5. 避坑常见SSO集成问题这一章都是我在实际环境里踩过、也帮别人排查过的坑。每一条都按“现象 → 原因 → 解决”记录。5.1 跳转死循环登录成功后又被弹回登录页现象输入账号密码后认证中心已经显示登录成功但浏览器反复跳到客户端、再跳回认证中心页面一直刷新不停。原因最常见的有两种。一是认证中心判断“已登录”的Cookie写入失败比如AuthCenter.Session设置了Securetrue而测试环境用的是HTTP浏览器直接拒绝写入二是客户端回调接口没有放行回调请求又被重定向到认证中心。解决先在浏览器开发者工具里看两个关键响应头认证中心的Set-Cookie是否出现客户端/login-callback的响应是200还是302。如果是Set-Cookie没有生效把CookieOptions.Secure改成false生产环境用HTTPS时保留true如果是回调被拦截在中间件路径判断里把/login-callback明确放行。5.2 state校验总是失败用户的登录流程时好时坏现象部分用户登录失败日志里出现“state mismatch”但重试一次又好了。原因state的生成和校验不在同一个存储容器里。常见做法是把state存Session但客户端站点重启后Session清空或者访问ClientA生成的state在回调时又被识别为另一个会话。原因也可能来自浏览器同时开多个标签页两次跳转互相覆盖state。解决不要把state存在内存Session里。改成带签名的state即把{clientId}:{随机数}:{时间戳}用密钥做签名也可以把state放进分布式缓存。回调时只验签名不依赖服务端Session状态。这样既防止CSRF又不怕服务器重启。5.3 票据过期导致偶发登录失败现象用户在登录页停留太久输入完密码提示票据过期或无效。原因票据有效期设得太长安全风险高设得太短用户输密码慢了就过期。我在示例里默认把TicketExpireMinutes设为5分钟但某些慢网络环境里登录页静态资源加载都在3秒以上往返几跳之后就接近边缘时间。此外认证中心和客户端服务器时间不同步也会放大这个问题。解决把票据有效期设为2分钟校验端留30秒时钟偏移容忍生产环境一定要做时间同步。如果业务上必须允许用户长时间停留登录页可以把“创建票据”的时机后移等到用户点击登录、服务端确认密码正确后再生成新票据回跳而不是打开登录页时就生成。5.4 Cookie作用域与回调域名不一致登录成功但本地会话没建立现象认证中心登录页正常回跳URL带了ticket客户端也校验通过但跳回原页面后依旧显示未登录。原因本地会话Cookie的作用域不对。比如客户端A部署在clienta.example.local但代码里CookieOptions.Domain误写成了.example.local或者反过来只写了localhost而浏览器实际访问的是127.0.0.1Cookie不会下发。解决把客户端本地会话Cookie的Domain留空让Cookie跟随当前请求的域名调试多域名时用hosts文件做映射保持访问域名和Cookie域名一致。打开浏览器开发者工具的“Application - Cookies”看本地Session是否存在一目了然。5.5 回跳地址白名单总是匹配不上现象客户端传过来的redirect_uri校验不通过但明明已经把地址写进AllowedClientUrls了。原因URL规范化差异。常见有末尾斜杠不一样、http和https混用、参数顺序被重排、Uri.EscapeDataString导致大小写编码变化。有时候是负载均衡器在中间改写Host头服务端拿到的Request.Host和用户实际访问的域名不一致。解决白名单校验前把URI规范化去掉末尾斜杠、统一Scheme、只取出{scheme}://{host}{path}部分做比较不参与query比较。调试时把实际传入的redirect_uri原样存日志与白名单对比一眼能看出差异。6. 进阶把示例改成分布式会话并验证单点登出6.1 从内存Session改成Redis共享示例程序为了方便单机演示把会话和票据存储都放在内存里。一旦要部署多实例认证中心问题就来了第一次请求落在实例A登录会话写在实例A内存里下一次请求被负载均衡分到实例B实例B查不到会话又把用户踢回登录页。破解办法是把会话存储外移到Redis。// 认证中心项目里增加分布式缓存支持 builder.Services.AddStackExchangeRedisCache(options { options.Configuration server:6379,passwordyour_redis_pwd,abortConnectfalse; options.InstanceName SsoAuthCenter:; }); // 原先的内存会话存储换成IDistributedCache public class RedisSessionStore : ISessionStore { private readonly IDistributedCache _cache; public async Task SaveAsync(string sessionId, string userId, TimeSpan expire) { await _cache.SetStringAsync(sessionId, userId, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow expire }); } public async Taskstring GetUserIdAsync(string sessionId) { return await _cache.GetStringAsync(sessionId); } }参数说明abortConnectfalse表示Redis暂时不可用时不抛异常强制断连而是让命令排队重试避免认证中心一启动就崩。InstanceName加前缀是为了在同一个Redis实例里区分不同应用的数据避免key冲突。换掉内存存储后还有一个隐藏问题票据表的Used标记也必须走Redis或数据库。我的建议是票据走数据库理由是一张票据只使用一次天然适合数据库事务会话走Redis因为要高频读写。两者别混在一个存储里否则排障时你分不清到底是会话丢了还是票据重复消费。6.2 单点登出的实现思路与补偿登录难做登出更难做。所谓单点登出是用户在认证中心点了退出所有客户端的本地会话一起作废。常见做法有三种做法思路优点缺点反向通知认证中心登录退出后拿着客户端注册的登出地址逐个通知会话立即失效可控性强客户端必须保证在线可通知通知失败要补偿前端集中跳转登出页依次iframe或fetch访问各客户端登出接口实现简单浏览器第三方Cookie限制会影响跨域登出只清认证中心客户端会话等自然过期最简单不是真正意义的单点登出用户换系统仍能进我通常生产环境用第一种同时把“通知失败的客户端”放进重试队列。示例程序里面你可以在认证中心的Logout里遍历已经登录过的客户端列表逐个调用/logout-callback。注意通知接口要做到“幂等”重复调用也不能报错因为重试队列可能对一个客户端发多次请求。6.3 验证清单拿到示例后照这个表过一遍单点登录功能不是“能登录”就算完建议按下面这个清单验收场景操作预期结果首次登录访问ClientA受保护页跳转认证中心输入密码后回到ClientA二次免登已登录状态直接访问ClientB不出现登录页直接进入ClientB票据重放从浏览器历史里复制ticket再次请求提示票据无效或已被使用会话过期认证中心会话过期后访问客户端重新跳转登录页单点登出在认证中心点退出后访问所有客户端各客户端都回到未登录状态每一个场景我都实际跑过。最容易被验收忽视的是“票据重放”这一行很多示例代码如果漏了Usedtrue这一笔更新重放请求会稳稳通过安全问题就这么出现了。说一个我自己的习惯从那以后我每次接SSO需求第一件事不是写代码而是先画一遍完整的跳转路径图标出哪一步写Cookie、哪一步校验票据、哪一步可能循环确认redirect_uri白名单和Cookie作用域再动工。这套路帮我少踩了至少一半认证相关的坑也希望帮到你。本文还有配套的精品资源点击获取