
简介一份由C#实现的单点登录示例工程适合.NET初、中级开发者学习SSO原理与基于CAS的跨站点身份验证方案。项目以ASP.NET Identity为用户体系基座演示了中央认证服务如何生成JWT/OAuth2令牌、子站点如何通过Token校验与HTTP重定向完成登录态共享同时涵盖Cookie会话管理、HTTPS传输、防重放攻击等安全设计并对扩展性、可维护性及测试调试给出了实现思路。压缩包共173个文件主体为64个.cs源码、16个.aspx页面并配有8个config配置文件、8个工程文件及DLL、PDB等运行库还包含MDF/LDF数据库和Word说明文档整包约2.07MB模块划分清晰可直接在Visual Studio中打开研究。目前已有494人学习下载通过阅读登录、令牌验证与站点授权等模块可以梳理从登录发起到跨域授权的完整链路为自主开发或二次改造SSO系统提供具体参考。1. SSO单点登录一次登录通行所有子系统的C#落地实现有这样一个很常见的场景公司内部有OA、CRM、人事系统三套Web应用每套都有自己的登录页和用户表员工每天要记三套账号密码忘一个就得走一遍找回流程。更让人头疼的是领导想从OA跳到CRM看数据还得重新登录一次。如果你也遇到过这种“多系统重复登录”的困境那这份C#单点登录示例程序SSO就是给你准备的。它实现的是最经典的单点登录模型一个独立的认证中心负责统一登录所有业务系统不再自己验证用户身份而是把登录动作交给认证中心处理拿到通行凭证后放行。这套代码适合想在自己的.NET项目里引入SSO机制但又不想直接上IdentityServer4这类重量级框架的开发者——麻雀虽小五脏俱全你既能看清SSO的完整链路也能直接改造成生产可用。2. 整体架构拆解认证中心、客户端与共享会话的三角关系2.1 部署拓扑三个站点各司其职这套示例程序由三个独立的Web项目组成逻辑上必须分开部署不能揉在一个进程里。第一个是SSO认证中心它只做一件事处理用户名密码登录、生成全局票据、提供票据校验接口。第二个是业务系统A它模拟一个普通的内部管理系统比如简易的OA。第三个是业务系统B再模拟一个CRM系统。A和B本身不存用户密码也不维护登录状态——它们只负责在用户未登录时把请求重定向到认证中心在拿到合法票据后建立本地会话。目录结构上三个项目分别位于sso-server、client-a、client-b三个文件夹。认证中心用ASP.NET Core MVC实现客户端用ASP.NET Core Web应用含MVC和Cookie认证中间件。这种划分方式很容易理解你把它映射到一个真实的公司内网场景无非就是把认证中心单独部署到一台内网服务器A和B分别部署到各自的应用服务器上。2.2 票据流转链路从重定向到会话建立整个SSO流程最核心的就是票据Ticket的生命周期。用户在没登录的状态下访问业务系统A的受保护页面A的认证中间件发现没有本地Cookie就把请求重定向到认证中心的登录接口同时带上returnUrl参数——这个参数记录了用户原本想访问的地址。认证中心展示登录页用户输入账号密码认证通过后认证中心做两件事第一件事是在浏览器里写入认证中心的Cookie表示“这个浏览器已经在中心登录过了”第二件事是生成一个一次性票据Ticket拼到重定向URL里让浏览器带着票据跳回业务系统A。业务系统A拿到票据后不能直接信任它要调用认证中心提供的票据校验接口把Ticket发过去认证中心确认这张票据有效并且没有被消费过才返回用户的基本信息比如用户ID和姓名。A拿到这些信息后在自己的进程里建立会话写入自己的Cookie然后跳转到用户原本想访问的页面。这一套流程走完用户在访问业务系统B时就不用再输入密码了——因为浏览器里已经有认证中心的CookieB把用户重定向到认证中心时认证中心发现这个浏览器已经登录过就直接签发第二张票据跳回BB再走一遍校验流程。整个过程用户无感知这就是“一次登录到处运行”的本质。2.3 为什么不用JWT直接解决很多新手会问业务系统拿到Token后自己验签不就行了为什么要搞一套回调校验这里有个关键区别。JWT方案适合API网关和前后端分离场景但用在传统的服务端渲染MVC架构里有一个痛点签发和校验都在客户端这边认证中心失去了对“用户是否还在登录状态”的控制。比如管理员在认证中心强制下线某个用户JWT只要没过期就依然有效而这套示例程序用的Ticket机制校验必须经过认证中心认证中心说这张票作废业务系统立刻就不认了。这就是集中管控的价值。3. 核心模块实战认证中心的登录签发与客户端的票据校验3.1 认证中心的登录接口实现登录接口是整套系统的核心入口它接收用户名密码、校验身份、写入中心Cookie、生成票据并跳转。看代码前先明确一点示例程序把用户数据写死在内存里实际项目要替换成查询用户表。下面这段是登录成功后的核心逻辑[HttpPost] public IActionResult Login(LoginViewModel model) { // 模拟用户校验生产环境替换为数据库验证 if (model.Username ! admin || model.Password ! 123456) { ModelState.AddModelError(, 用户名或密码错误); return View(model); } // 在认证中心写入全局登录Cookie标记该浏览器已通过中心认证 var claims new ListClaim { new Claim(ClaimTypes.Name, model.Username), new Claim(UserId, 10001) }; var identity new ClaimsIdentity(claims, SSO.Auth); var principal new ClaimsPrincipal(identity); HttpContext.SignInAsync(SSO.Cookie, principal).Wait(); // 生成一次性票据并存入内存字典Key为票据内容Value为用户名 string ticket Guid.NewGuid().ToString(N); TicketStore.Tickets[ticket] model.Username; // 跳转回业务系统附带票据参数 return Redirect(model.ReturnUrl (model.ReturnUrl.Contains(?) ? : ?) ticket ticket); }这段代码里有三个点值得细看。HttpContext.SignInAsync是ASP.NET Core的Cookie认证机制它会把用户信息加密后写入认证中心域下的Cookie这个Cookie是用户下次访问其他系统时“免登录”的凭证。TicketStore.Tickets是个静态字典示例里用它存储未消费的票据生产环境要换成Redis并设置过期时间。最后的Redirect带着ticket参数跳回业务系统注意代码里对ReturnUrl做了拼接处理防止原有的URL本来就有查询参数导致?冲突。3.2 票据校验接口一次性消费与防重用业务系统拿到票据后会回调认证中心的一个接口来确认票据真伪。这个接口必须是HTTP接口因为A和B与认证中心是独立部署的跨进程调用只能走网络。校验接口设计成两个作用验证票据存在同时把它从存储中删除——这就是“一次性消费”的含义。看实现[HttpGet] public IActionResult Validate(string ticket, string service) { // 从票据存储中取出用户名并删除记录实现一次性消费 if (TicketStore.Tickets.TryGetValue(ticket, out string username)) { TicketStore.Tickets.Remove(ticket); return Json(new { success true, username username }); } return Json(new { success false, message Ticket无效或已被消费 }); }参数里的service是当前业务系统的标识用于区分票据是发给哪个应用的。严格的生产实现里认证中心要校验service参数是否在已注册的客户端列表里防止任意站点来蹭登录状态。这个接口本身没有鉴权因为在内网环境中每个业务系统都是可信的但如果你要部署到公网就得对这个接口加上IP白名单或共享密钥签名否则别人可以伪造请求换取用户信息。3.3 客户端中间件拦截未登录请求并携带票据回调业务系统侧不能每个Controller都写一遍“判断是否登录”所以示例程序在Startup.cs里配置了全局的Cookie认证中间件。这个中间件负责两件事检测本地登录Cookie没有就跳转认证中心处理认证中心跳回来的票据完成校验和会话建立。核心配置如下services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options { // 未登录时跳转到认证中心登录页并带上当前地址作为回跳地址 options.LoginPath /Account/Login; options.Events new CookieAuthenticationEvents { OnRedirectToLogin context { var returnUrl context.Request.Path context.Request.QueryString.Value; context.Response.Redirect( $http://sso-server:5000/Account/Login?returnUrl{Uri.EscapeDataString(returnUrl)}); return Task.CompletedTask; }, OnValidatePrincipal async context { // 这里可以扩展调用认证中心检查用户是否仍有效 } }; }); services.AddAuthorization(); services.AddControllersWithViews();这里有个设计细节值得学习示例把LoginPath指向了本地的/Account/Login但实际的页面渲染发生在认证中心本地这个路径并不存在Controller。真正起跳转作用的是OnRedirectToLogin事件它拦截了中间件的默认跳转行为改成跳到认证中心的URL。这种写法让所有未登录请求在中间件层就被拦下并甩到认证中心不需要业务系统自己写任何重定向代码。单独处理票据回跳逻辑时需要在认证中心签发票据后业务系统回调认证中心。业务系统的AccountController要加一个接收ticket参数的方法[HttpGet] public async TaskIActionResult Login(string ticket) { // 如果URL里带ticket说明认证中心已经验证过用户正在跳回 if (!string.IsNullOrEmpty(ticket)) { // 调用认证中心校验票据 using var http new HttpClient(); var result await http.GetStringAsync( $http://sso-server:5000/api/Validate?ticket{ticket}serviceclient-a); var payload JsonSerializer.DeserializeValidateResult(result); if (payload.success) { // 建立本地会话写入业务系统自己的Cookie var claims new ListClaim { new Claim(ClaimTypes.Name, payload.username) }; var identity new ClaimsIdentity(claims, Client.Auth); await HttpContext.SignInAsync(Client.Auth, new ClaimsPrincipal(identity)); // 跳转到用户原本想访问的页面 return Redirect(/Home/Index); } } return Redirect(http://sso-server:5000/Account/Login?returnUrl Uri.EscapeDataString(/Home/Index)); }校验票据时用HttpClient同步等待结果在示例里没问题但真实项目中GetStringAsync后面不加await会导致中间件提前返回所以一定要确保异步链路完整。建立本地会话的SignInAsync用的是业务系统自己的认证Scheme名Client.Auth跟认证中心的SSO.Cookie是不同的互不覆盖这个细节决定了两个系统能否同时保持登录态。3.4 会话保持本地Cookie与中心Cookie的协同业务系统登录成功后本地Cookie的过期时间默认是会话级别关掉浏览器就失效。实际内网系统一般希望保持一周甚至更久调整方式是在客户端启动配置里加options.ExpireTimeSpan TimeSpan.FromDays(7); options.SlidingExpiration true;SlidingExpiration是滑动过期用户只要在第二天继续访问过期时间就会自动延长不会出现“一周后强制下线”的突兀感。认证中心的Cookie也建议设置一个较长的过期时间这样用户在公司里一天之内访问多个系统永远只登录一次。4. 运行与调试三站点启动配置和验证路径4.1 修改端口与本地域名映射示例程序默认设计了三个站点分别监听5000、5001、5002端口但如果你本机有服务占用了这些端口就得去改launchSettings.json或Properties/launchSettings.json。改端口的同时要注意两个地方的联动认证中心签发票据后跳回的URL地址写死在客户端代码里客户端回调认证中心的地址也写死在代码里。示例里我用的是http://sso-server:5000这个机器名而不是localhost原因后面避坑章节会细说。如果你不打算做域名映射可以把所有引用地址统一改成http://localhost:5000这种形式。4.2 按顺序启动三个项目验证建议先启动认证中心再启动业务系统A和B。直接F5运行整个解决方案的话Visual Studio默认会同时启动多个项目但启动顺序不受控可能导致客户端先启动时连不上认证中心。稳妥做法是在解决方案属性里设置“多个启动项目”把认证中心排在第一位。启动完成后的验证路径是这样的先访问http://localhost:5001业务系统A此时应该被重定向到认证中心的登录页用admin/123456登录成功后浏览器回到业务系统A首页然后新开标签页访问http://localhost:5002业务系统B观察它是否自动登录直接进入首页不再弹出登录框。验证“一次性票据”的效果也很直观登录成功后在浏览器地址栏里能看到URL带了一长串ticketxxx手动刷新这个带ticket的URL业务系统会显示登录失败——因为ticket已经被消费掉了。这符合安全预期但也解释了为什么业务系统校验完票据后要立即跳转到干净URL否则用户按F5就会看到校验失败的报错页面。4.3 常见绕不开的坑验证失败时看什么日志如果A能正常登录但B一直跳回登录页先用浏览器的开发者工具看网络请求。重点看认证中心返回302时的Set-Cookie头确认中心Cookie有没有种在正确的域名下。如果中心Cookie种在localhost下而B站点通过127.0.0.1访问浏览器不会把这个Cookie发给认证中心结果就是用户在B上永远处于“未登录”状态。另外注意业务系统调用票据校验接口时报的异常——如果认证中心尚未启动或者地址写错HttpClient会抛出连接异常页面表现为一直转圈最后超时这类问题在业务系统的输出窗口里能看到完整堆栈。5. 避坑指南SSO联调中的五个高频翻车现场5.1 登录成功后跳不回原页面现象用户输入正确账号密码认证中心也签发了票据但页面停留在认证中心或者跳到空白页。原因returnUrl参数没有正确编码或者传递过程中丢失了认证中心拿到的回跳地址是空值或乱码。解决在客户端跳转认证中心时对returnUrl做Uri.EscapeDataString编码认证中心端接收到后再做解码。如果returnUrl里本身有查询参数还要注意拼接ticket时的分隔符一不小心就会拼出returnUrl/Home?ticketxxxtab1这种错误格式。5.2 业务系统间的登录态互相“串台”现象登录完A系统再访问B系统B直接显示当前登录的是A的用户。原因两个客户端用了同一个Cookie名称和认证Scheme浏览器把A写入的Cookie也发给了B。解决每个客户端在配置Cookie认证时必须指定不同的Cookie.Name——A叫ClientA.AuthB叫ClientB.Auth这样Cookie在浏览器里按域名和名称双重隔离互不干扰。5.3 内网部署后所有站点都登录不了现象代码在本机运行一切正常部署到公司内网服务器后用户访问任何系统都跳登录页登录成功后马上又跳回登录页循环往复。原因认证中心与客户端的域名配置不一致最常见的是客户端代码里写死了localhost而用户实际通过内网IP访问。解决全局搜索所有localhost引用替换成实际的内网主机名或负载均衡地址。这个坑非常隐蔽因为本机测试时一切正常难就难在你不会第一时间想到是地址写死的问题。5.4 票据被篡改或被重放现象用户登录后把URL里的ticket参数复制给另一个同事同事居然也能登录成功。原因票据没有绑定客户端标识和IP且有效期内可以多次使用。解决认证中心签发票据时存入的服务字段service参数要参与存储校验时检查票据的service是否跟当前请求方一致既然要实现一次性消费删票据的动作要在存储层保证原子性——示例里用的是字典TryGetValue加Remove生产环境用Redis的GETDEL命令一步完成取和删。5.5 登录后业务系统偶发401现象业务系统刚登录成功操作几分钟后突然跳回登录页。原因本地Cookie过期时间设置太短或者用了默认的会话级Cookie浏览器关闭就被清理。解决在客户端认证配置里显式设置ExpireTimeSpan和SlidingExpiration并确认Cookie的SecurePolicy——如果你的站点跑在HTTP下把SecurePolicy设成SameAsRequest不要在HTTPS环境下强制要求Secure属性否则Cookie不会被持久化保存。6. 二次开发改造把示例程序变成生产级SSO的四个方向这套示例最让我满意的地方是它把SSO的精髓——票据集中认证和一次性消费——用最少的代码讲清楚了。第一个改造方向是接入数据库替换内存用户。认证中心的登录接口不要写死账号密码改为查询用户表校验密码时用PasswordHasher做哈希比对同时在用户表里加一个Status字段实现管理员禁用账号后该用户在所有业务系统立即失效的效果——这正是SSO集中管控的价值。第二个改造方向是把票据存储从静态字典换成Redis。静态字典在进程重启后票据全部丢失用户会被强制重新登录而Redis集群可以做到认证中心多实例部署任意一台节点签发的票据另一台节点也能校验通过。第三个方向是引入共享会话方案。如果你想更进一步可以让客户端的本地会话不再用Cookie而是把用户信息直接放到Redis里客户端拿着SessionId去Redis取用户数据。这样做的好处是业务系统A和B之间可以共享用户资料比如A系统更新了用户头像B系统立刻就能看到。最后一个改造方向是增加三方登录入口。认证中心的登录页上可以加一个“企业微信扫码”按钮用户扫码后通过企业微信的OAuth接口拿到用户信息然后走跟账号密码登录一样的票据签发流程——这个方案的改动量很小核心只是把“校验用户”这一步从model.Username换成从企业微信接口返回的数据。每次我在新项目里接入SSO都会把这套示例程序跑一遍看看登录跳转链路是否顺畅。从那以后我养成了一个习惯任何多系统项目在开工前先花半小时把SSO的认证中心建起来而不是等到三个系统都开发完了再回头补登录。现在这套代码已经帮我在两个内部项目里落地了SSO机制希望这套拆解也能帮到你。本文还有配套的精品资源点击获取