资讯动态

C#轻量级SSO单点登录实战:JWT+OWIN免密跳转

发布时间:2026/10/20 10:26:43 来源:尧图企业网站定制
简介本资源是一个基于C#实现的单点登录SSO完整示例程序面向.NET开发者、Web安全初学者及企业级应用架构学习者聚焦解决多子系统间身份认证共享这一典型企业级集成难题。项目采用ASP.NET Web Forms技术栈以中央认证服务CAS为核心整合JWT令牌生成与验证、跨域重定向、Cookie会话管理及ASP.NET Identity基础框架覆盖SSO全流程关键环节。压缩包共173个文件含64个C#业务逻辑文件如TokenService.asmx、UserLogin.aspx、AuthPage1.aspx等、8个csproj工程配置、8个config配置文件、11个DLL依赖库及配套调试符号pdb和文档docx整体2.07MB结构清晰便于分层理解认证服务端与子站点客户端的协同机制。已有494人学习下载读者可直接运行调试、分析CAS令牌签发/验签逻辑、观察HTTP跳转链路并借鉴其安全性设计如HTTPS通信模拟、令牌时效控制与可扩展架构接口抽象、依赖注入雏形快速掌握企业级SSO落地的核心实践。1. 一个登录框三套系统免密跳转C#单点登录示例程序SSO不是Demo是能直接塞进你现有Web项目的生产级骨架某公司内部有三套独立开发的后台系统HR人事系统ASP.NET Web Forms、财务报销平台ASP.NET MVC 5、IT资产管理系统.NET Core 3.1。每次切系统都要重新输账号密码运维抱怨“用户一天光输密码就输崩溃了”。他们试过用共享Cookie结果跨域401满天飞也试过自己写Token中转但登出不同步、Token续期逻辑一改就崩。最后上线的就是这个看起来只有两个项目文件的「C#单点登录示例程序SSO」——它没用IdentityServer这种重型轮子而是用原生System.IdentityModel.Tokens.JwtMicrosoft.Owin.Security.Cookies搭出一套轻量、可调试、无黑匣子的SSO核心链路。它不教你怎么配Azure AD也不讲OAuth2理论就干一件事用户在任意一个应用登录后点击跳转链接其他应用自动完成身份校验并进入首页登出时所有系统同步失效Session。适合正在维护老系统、不想推倒重来、又急需解决多系统重复登录痛点的.NET开发者。代码全在本地没外部依赖连IIS Express都能跑通。2. 架构拆解认证中心AuthCenter与业务系统ClientApp的双向握手协议这个SSO示例不是“一个中心管所有”的单体架构而是明确划分为两个可独立部署的Web应用AuthCenter认证中心负责统一登录、签发Token、管理登出ClientApp客户端应用只做两件事——重定向到AuthCenter、接收并验证回调。二者之间不共享数据库、不共用Session完全靠JWT Token和HTTP重定向通信。这种设计规避了传统共享Cookie方案的跨域限制也比硬编码Token校验更安全可控。整个流程不依赖任何第三方服务所有加密、签名、验证逻辑都在.NET Framework/.NET Core原生类库内完成调试时F11能一路跟到JwtSecurityTokenHandler.ValidateToken()内部——这才是真正能让你睡得着的SSO。2.1 AuthCenter认证中心的核心职责与目录结构AuthCenter是一个独立的ASP.NET MVC 5项目兼容.NET Framework 4.6.1它不处理业务逻辑只专注身份认证。项目结构极简/AuthCenter ├── Controllers/ │ ├── AccountController.cs // 登录/登出主入口 │ └── SsoController.cs // SSO专用接收登录请求、返回Token、处理登出广播 ├── Models/ │ ├── AuthRequestModel.cs // 解析来自ClientApp的跳转参数returnUrl, clientId │ └── JwtTokenGenerator.cs // JWT生成器设置Issuer、Audience、Expiry、Claims ├── App_Start/ │ └── Startup.Auth.cs // OWIN中间件注册Cookie认证 JWT签发配置 └── Views/ └── Account/ └── Login.cshtml // 纯登录表单无JS框架干扰关键不在UI而在Startup.Auth.cs里的OWIN管道配置。它同时启用了两种认证模式CookieAuthentication用于用户在AuthCenter页面登录后的会话保持JwtBearerAuthentication则为后续ClientApp的Token校验埋下伏笔虽然本示例中ClientApp用的是Cookie回传但此配置为未来升级API网关预留了接口。提示AuthCenter的web.config中必须显式关闭httpCookies httpOnlyCookiestrue requireSSLfalse /否则Chrome 80会因SameSite策略拒绝跨站Cookie导致重定向后无法识别登录态。这是2023年后踩坑率最高的配置项。2.2 ClientApp客户端应用的轻量接入模式ClientApp是另一个ASP.NET MVC 5项目代表你要集成SSO的任意业务系统。它不包含任何登录页面、不存密码、不校验用户名密码唯一要做的就是拦截未认证请求重定向到AuthCenter并在回调时解析Token完成本地登录。其核心接入点只有两处Global.asax.cs中的Application_BeginRequest检查当前请求是否已认证未认证则重定向至AuthCenter的登录地址并附带returnUrl参数Controllers/SsoController.cs中的CallbackAction接收AuthCenter重定向回来的JWT Token通过URL Query或Form POST调用JwtSecurityTokenHandler.ValidateToken()验证签名与有效期成功后调用FormsAuthentication.SetAuthCookie()建立本地会话。这种“零登录逻辑”的设计让ClientApp彻底摆脱了认证负担。你甚至可以把ClientApp部署在完全不同的域名下如hr.example.com和finance.example.com只要AuthCenter的Token签发时Audience包含对应域名验证就能通过。2.3 Token设计为什么不用Guid而用JWT且必须自定义Claims本示例放弃简单Session ID传递坚持使用JWT原因很实际可验证、可携带上下文、可跨技术栈。生成的Token结构如下var tokenDescriptor new SecurityTokenDescriptor { Subject new ClaimsIdentity(new Claim[] { new Claim(ClaimTypes.Name, user.Username), new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), new Claim(Department, user.Department), // 自定义业务字段 new Claim(Roles, string.Join(,, user.Roles)) // 角色列表供ClientApp做粗粒度授权 }), Expires DateTime.UtcNow.AddMinutes(30), SigningCredentials new SigningCredentials( new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Your-Super-Secret-Key-Here)), SecurityAlgorithms.HmacSha256Signature), Issuer https://auth.example.com, // 必须与ClientApp验证时的ValidIssuer一致 Audience https://hr.example.com // 必须与ClientApp的ValidAudience匹配 };注意三个硬性要求Issuer和Audience必须是完整HTTPS URL不能是localhost或IP否则.NET Core 3.1的JwtBearerOptions会因ValidateIssuerSigningKey默认开启而校验失败SigningCredentials的密钥必须在AuthCenter和所有ClientApp中完全一致且不能明文写在代码里示例中为演示简化生产环境应从配置中心或环境变量读取Claims中必须包含NameIdentifier因为FormsAuthentication.SetAuthCookie()内部依赖此Claim生成FormsTicket缺失会导致登录成功但User.Identity.IsAuthenticated始终为false。3. 运行实操从零启动AuthCenter与ClientApp的四步闭环别被“SSO”二字吓住——这个示例的运行门槛比你本地跑一个Hello World MVC项目还低。它不依赖IIS、不强制SQL Server、甚至不需要安装Visual StudioVS Code .NET SDK即可。整个流程围绕“重定向-回调-验证”闭环展开每一步都有明确的HTTP状态码和日志输出失败时你能立刻定位是哪一环断了。3.1 环境准备.NET Framework与.NET Core双运行时共存本示例包含两个项目AuthCenter基于.NET Framework 4.7.2ClientApp基于.NET Core 3.1。这意味着你的开发机必须同时安装.NET Framework 4.7.2 Runtime Windows 10 1809已内置旧系统需手动安装.NET Core 3.1 SDK 含Runtime安装后命令行输入dotnet --list-sdks应显示3.1.x。注意不要试图将ClientApp升级到.NET 6。.NET 6的WebApplication.CreateBuilder()会自动注入大量中间件与本示例中手动配置CookieAuthenticationOptions的逻辑冲突导致HttpContext.User.Identity在Callback Action中为空。血泪经验我曾花3小时排查最后发现只是SDK版本不匹配。3.2 启动AuthCenter配置Hosts与SSL绕过AuthCenter默认绑定https://localhost:44300。为模拟真实跨域场景你需要用管理员权限打开C:\Windows\System32\drivers\etc\hosts添加两行127.0.0.1 auth.example.com 127.0.0.1 hr.example.com在Visual Studio中右键AuthCenter项目 → Properties → Web → Servers → 选择“IIS Express”确保SSL启用端口为44300首次运行时浏览器会提示“您的连接不是私密连接”。必须点击“高级”→“继续前往localhost不安全”。这是因为IIS Express自签名证书不被信任但AuthCenter的JWT签发逻辑不校验证书只认域名和密钥所以可安全忽略。启动后访问https://auth.example.com:44300/Account/Login看到简洁的用户名/密码输入框即AuthCenter就绪。3.3 启动ClientApp修改回调地址与密钥同步ClientApp默认尝试访问https://auth.example.com:44300因此必须右键ClientApp项目 → Properties → Debug → App URL 改为https://hr.example.com:44301打开ClientApp/App_Start/Startup.Auth.cs找到JwtTokenValidator类确认ValidIssuer和ValidAudience与AuthCenter中完全一致var validationParameters new TokenValidationParameters { ValidateIssuer true, ValidIssuer https://auth.example.com, // 必须与AuthCenter签发时一致 ValidateAudience true, ValidAudience https://hr.example.com, // 必须与当前ClientApp域名一致 ValidateLifetime true, IssuerSigningKey new SymmetricSecurityKey( Encoding.UTF8.GetBytes(Your-Super-Secret-Key-Here)) // 密钥必须100%相同 };启动ClientApp访问https://hr.example.com:44301/Home/Index。此时会自动重定向至AuthCenter登录页——说明重定向链路已通。3.4 完成首次SSO登录观察三次HTTP跳转与Token流转现在执行一次完整登录在https://auth.example.com:44300/Account/Login输入测试账号示例中默认admin/123456点击登录AuthCenter创建Cookie并重定向回ClientApp的/Sso/Callback?tokeneyJhb...ClientApp的SsoController.Callback()接收到token调用ValidateToken()解析出Claims解析成功后执行FormsAuthentication.SetAuthCookie(user.Name, false)并重定向至/Home/Index此时浏览器地址栏变为https://hr.example.com:44301/Home/Index且User.Identity.IsAuthenticated true。打开浏览器开发者工具F12→ Network标签你会清晰看到三次关键跳转302 Found从ClientApp到AuthCenter带returnUrl参数302 Found从AuthCenter登录成功后回到ClientApp带token参数200 OKClientApp最终渲染首页。这三次跳转就是SSO最本质的“凭证交换”过程。没有Ajax、没有iframe、不依赖LocalStorage——纯粹的HTTP重定向兼容性拉满。4. 避坑指南五个让开发者凌晨三点还在抓头发的真实问题SSO看似只是“多跳两次”但实际落地时90%的失败都卡在环境配置和细节约定上。以下是我在三个不同客户现场复现并解决的典型问题按发生频率排序每条都附带Wireshark抓包证据和修复命令。4.1 现象重定向后AuthCenter登录页显示但输入账号密码点击登录页面刷新却仍停留在登录页Network里看不到第二次POST原因AuthCenter的AccountController.LoginAction中ModelState.IsValid为false但View未显示Html.ValidationSummary()导致错误静默。常见于web.config中httpRuntime maxRequestLength4096 /过小而IE11提交的表单包含大量隐藏字段触发请求长度超限IIS直接返回400 Bad Request但MVC框架捕获后仅设ModelState.AddModelError。解决在Views/Account/Login.cshtml顶部添加Html.ValidationSummary(true)然后检查F12 Console是否有400 (Bad Request)。若确认是长度问题在AuthCenter/web.config中增大system.web httpRuntime maxRequestLength10240 executionTimeout300 / /system.web4.2 现象ClientApp回调时ValidateToken()抛出SecurityTokenInvalidSignatureException原因AuthCenter与ClientApp使用的密钥字符串末尾存在不可见空格。例如复制示例代码时Your-Super-Secret-Key-Here 末尾有空格与Your-Super-Secret-Key-Here被视为不同密钥。.NET的HMAC签名对输入字节零容忍。解决在两边代码中对密钥字符串强制Trimvar key Encoding.UTF8.GetBytes(Your-Super-Secret-Key-Here.Trim());并在AuthCenter的JwtTokenGenerator和ClientApp的TokenValidationParameters中统一使用该Trim后的key变量杜绝硬编码。4.3 现象登录成功后ClientApp首页User.Identity.Name为空但IsAuthenticated为true原因JwtSecurityTokenHandler.ValidateToken()返回的ClaimsPrincipal中ClaimsIdentity的AuthenticationType为Bearer而FormsAuthentication.SetAuthCookie()内部期望Forms。类型不匹配导致HttpContext.User未正确初始化。解决在ClientApp的CallbackAction中手动创建FormsAuthenticationTicketvar claimsPrincipal handler.ValidateToken(token, validationParameters, out validatedToken); var identity new ClaimsIdentity(claimsPrincipal.Identity.Claims, Forms); // 强制设为Forms var ticket new FormsAuthenticationTicket( 1, identity.Name, DateTime.Now, DateTime.Now.AddMinutes(30), false, JsonConvert.SerializeObject(identity.Claims) // 序列化Claims供后续读取 ); string encryptedTicket FormsAuthentication.Encrypt(ticket); HttpCookie cookie new HttpCookie(FormsAuthentication.FormsCookieName, encryptedTicket); Response.Cookies.Add(cookie);4.4 现象Chrome浏览器下从ClientApp跳转AuthCenter再返回ClientApp始终提示未登录Edge/Firefox正常原因Chrome 80默认启用SameSiteLax策略而AuthCenter返回的Cookie未显式声明SameSiteNone; Secure导致跨站重定向时Cookie被浏览器丢弃。解决在AuthCenter的Startup.Auth.cs中配置Cookie认证时强制设置app.UseCookieAuthentication(new CookieAuthenticationOptions { AuthenticationType CookieAuthenticationDefaults.AuthenticationType, LoginPath new PathString(/Account/Login), CookieSameSite SameSiteMode.None, // 关键 CookieSecure CookieSecureOption.Always // 因为用HTTPS必须设为Always });同时确保AuthCenter和ClientApp均使用HTTPShttps://开头HTTP下SameSiteNone会被Chrome拒绝。4.5 现象登出时AuthCenter清除了自己的Cookie但ClientApp的FormsAuthentication.SignOut()未执行用户仍能访问首页原因AuthCenter的登出广播向所有已知ClientApp发送GET请求采用同步HTTP调用若某个ClientApp响应超时如网络抖动整个登出流程被阻塞AuthCenter无法继续执行Session.Abandon()。解决将登出广播改为异步Fire-and-forget// 在AuthCenter的AccountController.LogOff()中 var clientUrls new[] { https://hr.example.com/Sso/SignOut, https://finance.example.com/Sso/SignOut }; foreach (var url in clientUrls) { _ Task.Run(() { try { using var client new HttpClient(); client.Timeout TimeSpan.FromSeconds(3); // 严格超时 var response client.GetAsync(url).Result; } catch { /* 静默失败不影响主流程 */ } }); } Session.Abandon(); // 立即执行不等待广播结果5. 二次开发实战给SSO加上手机号快捷登录与登出确认弹窗现在你已经跑通了基础SSO下一步往往是“让它更像我们自己的系统”。这里不讲大而全的扩展只聚焦两个高频需求用手机号验证码替代账号密码登录以及登出前弹窗确认防止误操作。这两个功能改动小、见效快、不破坏原有流程且代码全部在AuthCenter内完成ClientApp零修改。5.1 手机号快捷登录复用现有JWT签发逻辑只增不删AuthCenter的登录入口AccountController.Login目前只处理用户名密码。我们要新增一个LoginByPhoneAction接受手机号和验证码验证通过后签发完全相同的JWT Token走原有重定向流程。这样ClientApp无需任何改动因为它只认Token不关心Token怎么来的。第一步在Models/LoginByPhoneModel.cs中定义参数public class LoginByPhoneModel { [Required(ErrorMessage 手机号不能为空)] [RegularExpression(^1[3-9]\d{9}$, ErrorMessage 手机号格式不正确)] public string Phone { get; set; } [Required(ErrorMessage 验证码不能为空)] [StringLength(6, MinimumLength 6, ErrorMessage 验证码必须为6位)] public string Code { get; set; } public string ReturnUrl { get; set; } // 保留returnUrl重定向时用 }第二步在AccountController.cs中添加新Action[HttpPost] [AllowAnonymous] public ActionResult LoginByPhone(LoginByPhoneModel model) { if (!ModelState.IsValid) return View(model); // 1. 调用短信平台验证Code此处用内存缓存模拟 var cacheKey $sms:{model.Phone}; var cachedCode HttpContext.Cache[cacheKey] as string; if (cachedCode ! model.Code) { ModelState.AddModelError(, 验证码错误或已过期); return View(model); } // 2. 根据手机号查用户示例中硬编码实际查DB var user new { Id 1001, Username user1001, Department IT, Roles new[] { User } }; // 3. 复用原有JwtTokenGenerator生成Token var token JwtTokenGenerator.CreateToken(user, model.ReturnUrl); // 4. 重定向回ClientApp的Callback与密码登录完全一致 return Redirect(${model.ReturnUrl}?token{Uri.EscapeDataString(token)}); }第三步在Views/Account/Login.cshtml底部添加手机号登录Tabdiv classtab-content div idtab-password classtab-pane fade in active !-- 原有用户名密码表单 -- /div div idtab-phone classtab-pane fade using (Html.BeginForm(LoginByPhone, Account, FormMethod.Post)) { Html.AntiForgeryToken() Html.HiddenFor(m m.ReturnUrl) div classform-group Html.TextBoxFor(m m.Phone, new { class form-control, placeholder 手机号 }) Html.ValidationMessageFor(m m.Phone, , new { class text-danger }) /div div classform-group div classinput-group Html.TextBoxFor(m m.Code, new { class form-control, placeholder 验证码 }) span classinput-group-btn button classbtn btn-default typebutton onclicksendSmsCode()获取验证码/button /span /div Html.ValidationMessageFor(m m.Code, , new { class text-danger }) /div button typesubmit classbtn btn-primary登录/button } /div /div关键点LoginByPhoneAction最终调用的Redirect与原LoginAction完全一致意味着ClientApp的Callback逻辑一行代码都不用改。这就是“协议不变接入方式可插拔”的设计威力。5.2 登出确认弹窗前端拦截后端幂等处理用户点击“退出登录”时直接登出太危险。我们加一层确认且确保即使用户狂点“确定”也只登出一次。前端在_Layout.cshtml的登出链接上加JS拦截a hrefUrl.Action(LogOff, Account) onclickreturn confirmLogout();退出登录/a script function confirmLogout() { if (!confirm(确定要退出登录吗所有系统将同步登出。)) { return false; } // 防止重复提交禁用链接加loading event.target.innerHTML 登出中...; event.target.disabled true; // 发送登出请求GET fetch(Url.Action(LogOff, Account), { method: GET }) .then(response { if (response.ok) { window.location.href Url.Action(Index, Home); // 登出后跳首页 } else { alert(登出失败请重试); event.target.innerHTML 退出登录; event.target.disabled false; } }) .catch(err { console.error(err); alert(网络错误请检查连接); event.target.innerHTML 退出登录; event.target.disabled false; }); return false; // 阻止默认跳转 } /script后端在AccountController.LogOff()中增加幂等控制。AuthCenter的登出本质是清除Session但Session ID由IIS生成我们无法控制。因此改用Redis或内存缓存记录“已登出Token”// 在LogOff Action开头 var sessionId Session.SessionID; var logoutCacheKey $logout:{sessionId}; if (HttpContext.Cache[logoutCacheKey] ! null) { // 已处理过直接跳转 return RedirectToAction(Index, Home); } // 执行原有登出逻辑 Session.Abandon(); HttpContext.Cache.Insert(logoutCacheKey, 1, null, DateTime.Now.AddMinutes(10), TimeSpan.Zero); // 广播登出...这样即使用户在弹窗确认后快速刷新页面再点登出第二次请求也会被缓存拦截避免重复广播。从那以后我每次给客户做SSO集成都会先花15分钟把AuthCenter的LoginByPhone和confirmLogout补上——不是为了炫技而是因为这两个功能能当场打消客户对“SSO太重、不好用”的疑虑。它们证明了SSO不是取代登录而是让登录方式更灵活不是消除交互而是让关键操作更安全。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑