C# SSO单点登录实现:JWT Token与认证中心实战指南 简介这是一份基于C#实现单点登录SSO的示例程序源码包面向.NET开发人员与正在学习身份认证机制的工程师解决多子站点间登录状态共享与统一身份验证问题实现一次登录、全网通行。包内共173个文件压缩后约2.07MB以cs源码64个、aspx页面16个、dll程序集11个及config、wsdl、asmx、asax、sln等工程配置为核心另有mdf数据库文件与docx说明文档整体结构清晰便于直接附加数据库并打开解决方案进行调试与二次开发。已有494人学习下载。资源涵盖中央认证服务、TokenService令牌服务、用户登录、页面授权等完整模块重点演示ASP.NET Identity框架下的用户管理与JWT令牌生成验证流程同时包含CacheList缓存列表、AuthPage1受保护页面等示例便于理解HTTP重定向、Cookie管理及安全性处理的实际写法。拿到后可对照源码梳理SSO关键链路掌握从认证中心搭建到子站点接入的完整实现细节并快速迁移应用到自己的项目中。 SSO这玩意儿做C#开发的基本都会遇到。无论是公司内部OA、ERP系统还是面向客户的多产品矩阵只要有多个应用需要共用一套账号体系单点登录就是绕不开的坎。最近刚好在整理自己写的C# SSO示例程序把踩过的坑和核心实现一并分享出来。先说清楚这个示例程序是什么它解决的是“用户只登录一次就能在多个独立系统中自由切换”的问题。比如你们公司有OA、CRM、报表系统三套程序如果每套都要登录一次体验很差SSO就是做一次登录三个系统全部免登录进入。这套程序的价值在于它把认证逻辑抽离成独立的认证中心各业务系统不再各自维护用户名密码降低重复建设成本也统一了账号安全管理。适合刚接触SSO概念的C#开发者也适合需要在内部快速搭建统一认证流程的团队参考。1. 项目整体设计与思路拆解1.1 核心概念认证中心与客户端先铺一下基础概念。SSO全称Single Sign On单点登录。它包含两个关键角色一个是认证中心通常叫Identity Provider简称IdP另一个是各个业务系统Service Provider简称SP。认证中心负责所有用户的登录验证业务系统自己不保存密码只信任认证中心签发的登录凭证。打个比方认证中心就像小区大门口的保安业务系统就像楼里的各个房间。你在门口刷一次脸确认身份后保安会给你发一张临时通行证拿着这张通行证去一栋楼、二栋楼都不用再刷脸保安通过查验通行证确认你确实登记过。1.2 技术选型为什么用Token方案而不是传统Session共享很多初学者做C#单点登录第一反应是“共享Session”。确实早期的ASP.NET时代微软官方支持SQL Server或Redis存储Session多个站点配置相同的机器密钥就能实现Session共享。这个方案在小规模内网应用中能用但缺陷也很明显。Session共享的问题在于所有业务系统都要强依赖同一个Session存储服务一旦存储服务宕机所有系统全部无法登录。同时Session状态是服务端保存如果业务系统是跨域、跨机房部署网络延迟和带宽占用都是麻烦。我在实际测试中对比过使用Session共享方案时认证中心到客户端的请求需要额外携带Session ID在Cookie被浏览器严格限制的情况下比如Chrome的SameSite策略跨域场景直接失效。所以我最终的示例选择了Token方案具体是JWTJSON Web Token加Cookie组合。JWT把用户信息和过期时间加密后放进Token里业务系统拿到Token后自行解析验证不需要回认证中心再查一次会话。这样认证中心即使临时不可用已登录用户的正常访问也不会立即中断。注意JWT是无状态的服务端无法主动让某个Token失效。所以单点退出就涉及一个关键难点后面我会专门讲怎么处理。2. 核心细节解析与实操要点2.1 JWT生成和验证的完整逻辑先上最核心的代码片段这是认证中心签发Token的核心逻辑public string GenerateToken(UserModel user) { var claims new ListClaim { new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), new Claim(ClaimTypes.Name, user.UserName), new Claim(Department, user.Department), new Claim(Role, user.Role) }; var key new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration[Jwt:SecretKey])); var credentials new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token new JwtSecurityToken( issuer: _configuration[Jwt:Issuer], audience: _configuration[Jwt:Audience], claims: claims, expires: DateTime.Now.AddMinutes(30), signingCredentials: credentials ); return new JwtSecurityTokenHandler().WriteToken(token); }这里有几个容易踩坑的地方。一是SecretKey的长度HmacSha256要求密钥至少32字节也就是16个字符以上有些人图省事写了个“abc123”程序直接报错。我建议用随机生成的64字节字符串上线后放到环境变量或密钥管理服务里千万不要硬编码在代码中。二是Issuer和Audience的设置。Issuer表示Token是由谁签发的Audience表示Token预留给哪个应用使用。多客户端场景下每个客户端可以有独立的Audience认证中心签发时指定验证时也按Audience过滤这样可以防止A应用拿着Token去B应用冒充。2.2 认证成功后的令牌传递策略Token生成后怎么带给客户端也是关键设计。常见的做法有三种。第一种是URL参数传递适合内网简单场景但Token会暴露在浏览器历史记录里安全性差。第二种是放在Authorization请求头里适合前后端分离的API调用场景。第三种是写在Cookie里适合传统MVC项目浏览器自动携带。我的示例程序采用的是Cookie方式但不是直接存Token字符串而是加密后再写入。因为JWT只是一种编码默认是Base64Url编码任何人都可以解码看到里面的内容只是无法篡改。所以敏感信息不要丢进JWT的payload里。如果确实需要存一些信息但不想暴露建议用对称加密算法把整个Token加密后再放Cookie。2.3 客户端如何验证Token客户端收到Token后不是直接信任而是要在本地验证签名和过期时间public bool ValidateToken(string token) { var tokenHandler new JwtSecurityTokenHandler(); var key Encoding.UTF8.GetBytes(_configuration[Jwt:SecretKey]); try { tokenHandler.ValidateToken(token, new TokenValidationParameters { ValidateIssuerSigningKey true, IssuerSigningKey new SymmetricSecurityKey(key), ValidateIssuer true, ValidIssuer _configuration[Jwt:Issuer], ValidateAudience true, ValidAudience _configuration[Jwt:Audience], ValidateLifetime true, ClockSkew TimeSpan.FromSeconds(30) }, out _); return true; } catch (Exception ex) { _logger.LogError(ex, Token验证失败); return false; } }这里有个细节值得多说一句ClockSkew参数。因为认证中心和业务系统可能部署在不同的服务器上系统时间存在几秒偏差。JWT的过期时间是基于UTC的如果客户端系统时间比认证中心慢了一分钟那么Token在客户端看来还有效但实际上已经过期了就会出现“明明没登录多久却提示重新登录”的诡异问题。设置30秒的容错区间可以有效缓解。3. 实操过程与核心环节实现3.1 认证中心的登录页面与回调逻辑登录页本身没什么特殊的就是标准的用户名密码提交。核心在提交之后的分支逻辑[HttpPost] public async TaskIActionResult Login(LoginViewModel model) { if (!ModelState.IsValid) { return View(model); } var user await _userService.ValidateUser(model.UserName, model.Password); if (user null) { ModelState.AddModelError(, 用户名或密码错误); return View(model); } var token GenerateToken(user); var encryptedToken _encryptionHelper.Encrypt(token); Response.Cookies.Append(SSO_TOKEN, encryptedToken, new CookieOptions { HttpOnly true, SameSite SameSiteMode.Lax, Expires DateTime.Now.AddMinutes(30) }); // 用户没有指明要访问哪个业务系统时默认跳到门户首页 if (string.IsNullOrEmpty(model.ReturnUrl)) { return RedirectToAction(Index, Home); } // 校验ReturnUrl防止开放重定向漏洞 if (!Uri.IsWellFormedUriString(model.ReturnUrl, UriKind.Relative)) { return RedirectToAction(Index, Home); } return Redirect(model.ReturnUrl); }开放重定向漏洞是个常见的坑。攻击者构造一个ReturnUrl指向钓鱼网站用户登录成功后就会跳转到钓鱼站点。所以在跳转前必须校验ReturnUrl只允许相对路径或者白名单内的域名。3.2 客户端如何识别已登录用户业务系统在用户访问受保护页面时需要先检查本地是否已经存在有效会话如果没有则重定向到认证中心登录并携带自身的回跳地址。这里用中间件实现更为优雅public class SsoAuthenticationMiddleware { private readonly RequestDelegate _next; private readonly IConfiguration _configuration; public SsoAuthenticationMiddleware(RequestDelegate next, IConfiguration configuration) { _next next; _configuration configuration; } public async Task InvokeAsync(HttpContext context) { var token context.Request.Cookies[SSO_TOKEN]; if (string.IsNullOrEmpty(token)) { var ssoServerUrl _configuration[Sso:ServerUrl]; var returnUrl ${context.Request.Path}{context.Request.QueryString}; var loginUrl ${ssoServerUrl}/Account/Login?ReturnUrl{Uri.EscapeDataString(returnUrl)}; // 这里需要根据实际场景决定是全站跳转还是API返回401 context.Response.Redirect(loginUrl); return; } // 解密并验证Token var plainToken _encryptionHelper.Decrypt(token); if (!_tokenValidator.ValidateToken(plainToken)) { context.Response.Redirect(${_configuration[Sso:ServerUrl]}/Account/Login); return; } // 将Token解析出的用户信息存入HttpContext.User context.User _tokenValidator.ParseToken(plainToken); await _next(context); } }这里有一点容易忽略如果业务系统同时提供Web页面和Web API那么API接口不能使用重定向策略因为前端Ajax请求接收到302会跟随跳转但跨域请求的Cookie策略会导致登录失效。比较好的做法是中间件判断请求类型页面请求直接重定向API请求则返回401状态码由前端统一跳转登录页。3.3 单点退出把难题解决掉前面提到JWT无状态服务端无法主动让Token失效。单点退出就成了SSO方案中的老大难问题。我采用的方案是“黑名单过期时间”配合“前端清Cookie”。认证中心维护一个Redis缓存存储“被强制退出的Token过期时间”。用户点击退出时认证中心把当前Token的jtiJWT唯一标识写入Redis黑名单并标记失效时间。清除认证中心的Cookie。通知并跳转到各业务系统通过JavaScript向各个已注册的回调地址发送请求业务系统收到请求后清除本地Cookie。这个方案的前提是业务系统在每次请求时都来认证中心检查Token是否被拉黑。但由于认证中心是我自己控制的而Token又是无状态的为了减少网络交互可以设置一个“每隔60秒同步一次黑名单”的策略或者更简单直接业务系统中维护一个本地的会话表退出时实时清掉所有接入端。说到底最稳妥的做法其实是降低单点退出实时性要求允许最长1~2分钟的会话残留。这在绝大多数企业内部场景是可以接受的。如果确实要求秒级退出就得引入分布式Session或者全局会话存储这与JWT的初衷相悖需要权衡。3.4 完整示例的启动与跑通我将示例程序分成了三个项目Sso.Server认证中心、Sso.AppA业务系统A、Sso.AppB业务系统B三者都是独立的ASP.NET Core Web应用端口分别设为5001、5002、5003。启动后访问任何一个业务系统都会被重定向到认证中心登录页登录成功后跳转回原业务系统。此时再访问另一个业务系统会直接进入不需要登录。退出时点击认证中心的退出按钮再回到业务系统会提示重新登录。这里有一个很容易踩的坑浏览器Cookie跨域。Chrome从80版本开始默认将未指定SameSite的Cookie视为SameSiteLax这意味着跨站请求比如从auth.example.com跳转到app.example.com不会携带Cookie。解决办法认证中心和业务系统使用同一顶级域名下的不同子域名并把Token Cookie的Domain设为顶级域名同时SameSite设为Lax如果必须跨域就要考虑用PostMessage或自定义事件传递Token然后写入各自的Cookie。4. 常见问题与排查技巧实录4.1 Token明明没过期却总是跳回登录页这个问题我在测试时反复遇到最后发现是时钟偏差导致的。认证中心和业务系统的服务器时间不一致JWT校验时严格执行了过期时间。我临时写的验证代码没有设置ClockSkew导致业务系统服务器时间比认证中心慢了几十秒时Token一直被判定为过期。排查方法很简单在客户端项目里临时打印ValidateToken返回的异常信息重点看tokenValidationParameters.ValidTo和DateTime.UtcNow的对比。也可以直接用一个在线JWT解码工具查看Token的exp字段和当前UTC时间的差异。建议生产环境统一使用NTP时间同步同时验证代码中保留30~60秒的ClockSkew。4.2 跨域环境下Cookie丢失如果你把认证中心和业务系统分别部署在不同的顶级域名比如auth.company.com和app.other.com那么第三步中写入Cookie的方式就不适用了。实操中比较靠谱的跨域方案是认证中心登录成功后返回一个一次性票据授权码把票据拼接在重定向URL上业务系统拿到票据后调用认证中心的后端接口用票据换取JWT并写入自己的Cookie。这个流程就是OAuth2.0授权码模式的口语化版本虽然步骤多但适用范围广。4.3 多客户端如何避免串号如果所有业务系统共用一个AudienceA系统能拿着B系统的Token直接访问B系统吗答案是可以只要Token不过期。这在实际项目中可能导致越权。解决办法是在JWT中增加一个专属的client_id声明在客户端的验证逻辑中强制检查该声明必须等于自己的客户端标识。var clientIdClaim principal.FindFirst(client_id)?.Value; if (clientIdClaim ! _configuration[Sso:ClientId]) { return false; }4.4 频繁跳转、死循环怎么办最常见的死循环场景Cookie里存的Token无效比如密钥换了客户端验证失败后重定向到认证中心认证中心又发现本地Cookie有效直接跳回业务系统业务系统又验证失败再重定向……如此往复直到浏览器报错“重定向次数过多”。排查技巧浏览器F12打开开发者工具查看Network面板里的请求链。如果看到业务系统和认证中心之间反复302基本可以锁定是Token验证不一致。我遇到过一次是认证中心更新了密钥但业务系统加载的是旧配置重启业务系统后解决。4.5 常见问题速查表问题现象可能原因排查办法登录后跳回原页面仍然显示未登录Token Cookie未正确写入跨域域名检查Cookie的Domain、SameSite设置过一会儿后突然全部系统需要重新登录ClockSkew过大或Token有效期设置过短检查服务器时间校准NTP业务系统A能访问业务系统B的数据Token未校验client_id添加客户端标识校验退出后其他系统仍能访问1分钟左右黑名单同步有延迟可接受则忽略不可接受则换全局会话方案密钥更新后所有用户被迫下线客户端配置未同步先更新客户端再更新认证中心密钥5. 个人实操体会与优化建议这套SSO示例程序是我在两周内从零搭建的断断续续迭代了四个版本最深刻的感受是SSO的难点不在写代码上而在方案选型和边界情况处理上。一开始我自信满满选了纯JWT方案代码写得很顺手结果上线测试时被Cookie的SameSite策略搞得很狼狈后来又补了黑名单和时间同步的机制才把方案补齐。如果给后来者一个建议先去梳理你的应用拓扑搞清楚哪些业务系统是同一个一级域名下的子域名哪些是完全独立域名。这个决定了你直接copy Cookie方案还是需要走授权码模式。千万别一上来就套OAuth2.0很多内部系统根本用不着那么重。另外验证JWT的NuGet包它的版本差异比较大。我现在用的是System.IdentityModel.Tokens.Jwt版本7.x旧版在.NET Core 3.1下跑没问题但升级到.NET 8之后有些方法标记成了obsolete编译时一堆警告。如果项目是新写的建议直接用Microsoft.AspNetCore.Authentication.JwtBearer它和ASP.NET Core框架集成得更紧密很多细节不用自己手动处理。最后分享一个自己在实际调试中很管用的小技巧在认证中心和业务系统的日志中都输出当前请求的完整Cookie内容给每个HTTP请求分配一个唯一的TraceId。排查多系统联动问题时用TraceId把一次完整的登录流程串联起来看是哪个环节弄丢了Cookie或验证失败。这个比盯着浏览器F12一个个翻请求高效多了尤其当业务系统有七八个的时候前后端联调时基本靠TraceId定位问题。SSO这块水还是挺深的以上是我完整跑通的示例程序和排坑记录希望能给你在项目落地时省下一些排查时间。本文还有配套的精品资源点击获取