news 2026/10/16 9:20:05

C#实现SSO单点登录:认证中心与票据校验实战解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C#实现SSO单点登录:认证中心与票据校验实战解析

简介:一份由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时就不用再输入密码了——因为浏览器里已经有认证中心的Cookie,B把用户重定向到认证中心时,认证中心发现这个浏览器已经登录过,就直接签发第二张票据跳回B,B再走一遍校验流程。整个过程用户无感知,这就是“一次登录,到处运行”的本质。

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 List<Claim> { 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 Task<IActionResult> 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}&service=client-a"); var payload = JsonSerializer.Deserialize<ValidateResult>(result); if (payload.success) { // 建立本地会话,写入业务系统自己的Cookie var claims = new List<Claim> { 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带了一长串ticket=xxx,手动刷新这个带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?ticket=xxx&tab=1这种错误格式。

5.2 业务系统间的登录态互相“串台”

现象:登录完A系统,再访问B系统,B直接显示当前登录的是A的用户。原因:两个客户端用了同一个Cookie名称和认证Scheme,浏览器把A写入的Cookie也发给了B。解决:每个客户端在配置Cookie认证时,必须指定不同的Cookie.Name——A叫ClientA.Auth,B叫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机制,希望这套拆解也能帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 9:18:05

gflags-2.1.1手动编译安装指南:解决Caffe链接undefined reference

简介&#xff1a;gflags-2.1.1 是 Google 开源的轻量级命令行标志处理库 C 实现&#xff0c;专为需要灵活配置参数的中高级 C 开发者设计&#xff0c;尤其适用于深度学习项目&#xff08;如 Caffe&#xff09;的训练与调试场景。资源包共 50 个文件&#xff0c;涵盖 9 个核心源…

作者头像 李华
网站建设 2026/10/16 9:17:42

深度学习环境搭建指南:CUDA、cuDNN、PyTorch 及 TensorRT 配置

深度学习环境搭建指南&#xff1a;CUDA、cuDNN、PyTorch 及 TensorRT 配置本文档旨在指导如何在Windows环境下从零搭建深度学习推理环境&#xff0c;重点包含显卡驱动更新、CUDA/cuDNN安装、PyTorch部署以及 TensorRT 的详细配置流程。1. 更新显卡驱动在安装任何CUDA工具包之前…

作者头像 李华
网站建设 2026/10/16 9:15:48

DLSS Swapper 快速入门:免费切换与回滚游戏内 DLSS 版本

DLSS Swapper 快速入门&#xff1a;免费切换与回滚游戏内 DLSS 版本 【免费下载链接】dlss-swapper 项目地址: https://gitcode.com/GitHub_Trending/dl/dlss-swapper 游戏更新后 DLSS 画质变差&#xff0c;想回退却发现原始文件早没了踪影&#xff1f;DLSS Swapper 就…

作者头像 李华
网站建设 2026/10/16 9:15:28

【链表-7】19.删除链表的倒数第N个结点

题目描述&#xff1a;给你一个链表&#xff0c;删除链表的倒数第 n 个结点&#xff0c;并且返回链表的头结点。示例 1&#xff1a;输入&#xff1a;head [1,2,3,4,5], n 2 输出&#xff1a;[1,2,3,5]示例 2&#xff1a;输入&#xff1a;head [1], n 1 输出&#xff1a;[]示…

作者头像 李华
网站建设 2026/10/16 9:14:58

DataForSEO MCP 服务说明文档

1. 服务概述一句话简介&#xff1a;一个提供标准化接口访问多个SEO相关API的MCP服务器&#xff0c;用于全面的数据检索和分析&#xff0c;支持SERP、关键词研究、反向链接分析等功能。服务名称&#xff1a;DataForSEO MCP Server版本号&#xff1a;最新版本开发者/提供方&#…

作者头像 李华
网站建设 2026/10/16 9:14:32

Apollo Portal批量变更Item源码解析:从校验到发布的完整链路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华