
简介这是一套基于C#实现的单点登录SSO示例程序面向.NET开发人员演示如何借助ASP.NET Identity、JWT令牌和中央认证服务实现跨多个子站点的统一身份验证。压缩包共173个文件包含64个C#源文件、16个ASPX页面以及ASMX服务、Web.config配置、DLL程序集、PDB调试符号等整体仅2.07MB结构紧凑、类型覆盖全面。已有494人学习下载适合正在研究多应用统一认证、希望快速理解SSO落地流程的开发者参考。通过源码可完整梳理CAS服务端与各子站点的交互链条重点观察TokenService令牌验证、UserLogin登录页、AuthPage1受保护页及CacheList缓存管理等模块直观掌握HTTP重定向、Cookie传递、令牌有效期与安全校验等关键设计项目内附数据库文件可配合示例在本地还原完整的认证演示环境。1. 单点登录实战C# SSO 示例程序如何终结多系统重复登录多系统重复登录这件事大多数团队第一次撞上都是懵的三个后台三个账号密码找回流程各写一套。我维护的内部系统就是这样直到我把一套 C# SSO 单点登录示例程序跑通才真正理解认证中心的职责边界。这套源码用 ASP.NET Core 实现认证中心加多个业务端的完整结构业务系统只需要保留登录跳转和票据校验改改配置就能接入。适合被多系统登录问题困住、想直接拿可运行方案的人也适合想看 Cookie 认证在 .NET 里如何跨应用共用的开发者——读完你会清楚中间件注册、票据签发、共享密钥三个环节分别发生在哪。2. 技术选型与整体架构认证中心 多业务端的标准闭环2.1 技术栈与版本选择这套示例没有引入 IdentityServer4 这类重量级框架而是用 ASP.NET Core 自带的 CookieAuthentication 中间件配合 Data Protection 密钥机制实现 SSO 最小闭环。为什么这么选第一示例的目标是把 SSO 的核心链路讲透——登录发生在哪、票据长什么样、不同应用之间靠什么互相信任引入过重框架反而把这一切封进黑匣子。第二Cookie 认证本身足够表达 SSO 的本质认证中心替用户完成登录签发一个所有业务端都能验证的加密票据后续每个应用拿着票据识别用户。按这个项目场景下合格开发最可能用的做法技术栈是这样一组ASP.NET Core 6.0 及以上版本的 Web 应用模板认证中心与业务端保持同版本避免运行时行为差异CookieAuthenticationDefaults.AuthenticationScheme 作为三端统一的认证方案Data Protection API 负责票据的加密与解密密钥持久化到共享文件目录业务端用 [Authorize] 特性或者自定义中间件拦截未认证请求触发跳转三端职责先用一张表看清工程职责关键机制AuthCenter登录、签发票据、注销SignInAsync 写 CookieClientAppA业务系统 A未登录跳认证中心Cookie 校验中间件ClientAppB业务系统 B验证多端票据复用与 A 相同Shared常量与公共校验工具无为什么不用 IdentityServer4不是因为它不好而是示例的目标定位不一样。IdentityServer4 是完整的 OIDC 和 OAuth 2.0 服务器实现学习曲线陡配置项非常多。示例程序想展示的是一次最小可运行的 SSO 闭环认证中心签票据、业务端验票据、用户免登录穿梭多端。用自带的 Cookie 中间件加 Data Protection把黑匣子拆开每一步都暴露在代码里才适合拿来当教材和脚手架。生产环境要不要升级成 JWT我在最后一章会单独讲那属于改造层面而不是理解层面的事。2.2 项目结构与目录解析拿到源码后先看工程结构典型布局如下SSO.Solution/ ├── AuthCenter/ # 认证中心 │ ├── Controllers/ │ │ ├── AccountController.cs # 登录、注销 │ │ └── HomeController.cs # 认证中心首页 │ ├── Models/ │ │ └── LoginViewModel.cs # 登录表单模型 │ ├── Views/ │ │ ├── Account/Login.cshtml │ │ └── Home/Index.cshtml │ ├── appsettings.json │ └── Program.cs ├── ClientAppA/ # 业务端 A │ ├── Controllers/ │ │ ├── DashboardController.cs │ │ └── HomeController.cs │ ├── Views/Dashboard/Index.cshtml │ ├── appsettings.json │ └── Program.cs ├── ClientAppB/ # 业务端 B结构同 A │ └── ... └── Shared/ └── SsoConstants.cs # Cookie 名、认证方案名常量AuthCenter 是认证的入口AccountController 里的 Login 和 Logout 是两个核心动作。ClientAppA 和 ClientAppB 结构几乎一致差异只体现在端口和跳转地址上这种冗余是故意的——方便对比同一个方案在两个独立应用里的配置差异你在改第二个客户端时就能确认哪些配置是通用的、哪些是应用私有的。Shared 目录容易被忽略但它很关键。Cookie 名称、认证方案名、密钥环的 ApplicationName 这些字符串三端任何一处写错都会导致认证链路断裂。示例里把它们收敛成常量类联调时不用满项目搜字符串这个习惯建议保留到生产代码里。登录页的 Login.cshtml 核心就是一个表单用户名、密码、隐藏的 ReturnUrl 三个字段POST 回 AccountController.Login。ReturnUrl 用隐藏字段而不是 QueryString 传回能避免日志和 Referer 里泄露业务端地址——这个细节不算起眼但我在生产环境见过不少项目因为图省事把 ReturnUrl 放 URL 里导致跳转地址被浏览器历史记录留下来。认证中心自己的 Program.cs 也要注册 Cookie 认证只是它不需要配置跳转地址——登录页就在它自己这里。同时它同样要做 SetApplicationName 和密钥环持久化因为是它先把密钥环初始化出来的。3. 核心代码拆解从登录请求到跨应用票据校验的完整链路3.1 认证中心登录接口与票据签发先从认证中心的 AccountController 说起。登录分 GET 和 POST 两个动作[HttpGet] [AllowAnonymous] public IActionResult Login(string returnUrl null) { // 已经登录过的用户直接跳回避免重复登录 if (User.Identity.IsAuthenticated) { return Redirect(SafeReturnUrl(returnUrl)); } ViewData[ReturnUrl] returnUrl; return View(); } [HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] public async TaskIActionResult Login(LoginViewModel model) { // 示例用硬编码账号校验生产环境替换为数据库查询 哈希比对 if (model.UserName ! demo || model.Password ! 123456) { ModelState.AddModelError(, 用户名或密码错误); return View(model); } // 组装用户声明业务端通过 Claims 识别用户身份和附加属性 var claims new ListClaim { new Claim(ClaimTypes.Name, model.UserName), new Claim(login_source, auth_center) }; var identity new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); var principal new ClaimsPrincipal(identity); // 关键一SignInAsync 会把 claims 序列化、加密后写入响应 Cookie await HttpContext.SignInAsync( CookieAuthenticationDefaults.AuthenticationScheme, principal, new AuthenticationProperties { IsPersistent true, ExpiresUtc DateTime.UtcNow.AddMinutes(30) }); // 跳回发起登录的业务端 return Redirect(SafeReturnUrl(model.ReturnUrl)); }逻辑说明GET Login 负责展示登录页同时检查当前是否已认证——这个检查在 SSO 里很重要否则用户从业务端 A 跳到认证中心登录后回到 A再被其他页面抛回来时又要重新登录。POST Login 是核心动作先校验账号再把用户身份包装成 ClaimsIdentity调用 SignInAsync 让 Cookie 中间件生成加密票据最后按 ReturnUrl 跳回。参数说明IsPersistent对应 Cookie 的持久化标记true 表示浏览器关闭后票据仍有效一般配合 ExpiresUtc 使用ExpiresUtc票据绝对过期时间用 UTC 时间别用 DateTime.Now跨时区部署时容易埋雷ReturnUrl业务端传过来的回跳地址它的安全校验放在 3.3 节专门讲再补一个注销动作同样在 AccountController 里[HttpPost] public async TaskIActionResult Logout() { // 清除认证 Cookie票据即刻失效 await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); return Redirect(/); }注销代码很短但有一个边界陷阱要留意示例里每个业务端如果要做业务内注销需要跳回认证中心调用这个接口而不是自己在本地清 Cookie。因为票据由认证中心签发业务端清掉本地 Cookie 只能保证自己不再识别用户认证中心那边的票据还活着用户下次带票据访问其他端依然能通过校验。初次接触 SSO 的开发者常在这里翻车以为本地清掉 Cookie 就算全局注销了。3.2 业务端共享密钥与 Cookie 校验配置业务端能解密认证中心的 Cookie靠的是 Data Protection 密钥环。打开 ClientAppA 的 Program.csvar builder WebApplication.CreateBuilder(args); // 从配置里读取密钥环目录不存在则用公共数据目录兜底 var keyPath builder.Configuration[Sso:KeyRingPath] ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), sso-keys); // 关键二所有应用必须使用同一个 ApplicationName 和同一个密钥环目录 builder.Services.AddDataProtection() .SetApplicationName(SsoDemo.SharedTicket) .PersistKeysToFileSystem(new DirectoryInfo(keyPath)) .SetDefaultKeyLifetime(TimeSpan.FromDays(90)); // 关键三Cookie 认证方案与认证中心保持完全一致 builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options { options.Cookie.Name SsoDemo.AuthCookie; options.Cookie.SameSite SameSiteMode.Lax; options.Cookie.SecurePolicy CookieSecurePolicy.SameAsRequest; // 登录路径需要指向认证中心的登录入口 options.LoginPath /Account/Login; options.ReturnUrlParameter ReturnUrl; }); builder.Services.AddControllersWithViews(); var app builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.MapDefaultControllerRoute(); app.Run();逻辑说明AddDataProtection 这一段决定了 Cookie 加解密的能力。SetApplicationName 设置逻辑应用名PersistKeysToFileSystem 把密钥环持久化到共享目录。三端指向同一目录后认证中心加密票据用的密钥业务端能读到同一把解密自然成功。这里有个容易混淆的点LoginPath 在单应用场景下通常指向本应用的登录页但在 SSO 结构里它最终要被替换成认证中心的完整地址。示例的做法是配置层拼接在 appsettings.json 里配认证中心地址启动时动态拼出完整登录地址避免在代码里写死。如果直接把 LoginPath 写成 /Account/Login业务端会尝试访问自己的登录页而那个页面根本不存在。参数说明SameSiteMode.Lax允许跨站 GET 导航携带 Cookie正好覆盖「认证中心登录后 302 跳回业务端」的场景CookieSecurePolicy.SameAsRequest按请求协议决定是否给 Cookie 加 Secure 标记适合本地 http 与 https 混用的联调环境ReturnUrlParameter认证中间件往登录地址追加回跳参数用的键名默认就是 ReturnUrl显式声明更清晰3.3 业务端拦截与 ReturnUrl 安全校验业务端控制器加一行 [Authorize] 就完成拦截[Authorize] public class DashboardController : Controller { public IActionResult Index() { // 认证成功后User 对象里就是认证中心签发的票据内容 ViewBag.UserName User.Identity?.Name; ViewBag.LoginSource User.FindFirst(login_source)?.Value; return View(); } }当未认证用户访问 /Dashboard/Index 时Cookie 认证中间件按 3.2 节的配置拼接跳转地址大致长这样https://localhost:7001/Account/Login?ReturnUrlhttps%3A%2F%2Flocalhost%3A7002%2FDashboard%2FIndex用户登录成功后认证中心解码 ReturnUrl 并跳回。这里的安全校验是示例里容易被忽略但生产必改的点——认证中心不能无条件信任 ReturnUrl。常见做法是维护一份业务端白名单或者至少做前缀校验防止开放重定向被钓鱼利用private string SafeReturnUrl(string returnUrl) { if (string.IsNullOrEmpty(returnUrl)) { return /; } // 生产环境应改为白名单集合或数据库配置而不是写死 if (returnUrl.StartsWith(https://localhost:7002) || returnUrl.StartsWith(https://localhost:7003)) { return returnUrl; } return /; }到这里完整链路已经闭环业务端未登录 → 中间件拼地址重定向 → 认证中心登录 → 签发票据写 Cookie → 302 跳回业务端 → 业务端用共享密钥解密 Cookie → User 身份就绪。接下来把这个链路真正跑起来。4. 本地跑通三端联调的关键步骤与验证方法4.1 环境准备与端口规划需要的东西不多.NET SDK6.0 或更高、一个编辑器、一个浏览器。三端要占三个端口我按最小冲突原则规划如下工程端口说明AuthCenter7001只处理认证相关请求ClientAppA7002业务端 AClientAppB7003业务端 B打开三份 appsettings.json把 Sso 节配置统一。以 ClientAppA 为例{ Urls: https://localhost:7002, Sso: { AuthCenterBase: https://localhost:7001, KeyRingPath: C:\\sso-shared-keys, CookieName: SsoDemo.AuthCookie }, Logging: { LogLevel: { Default: Information } } }参数说明Urls控制应用监听端口也可以放在 launchSettings.json 里但显式写在配置里便于命令行启动AuthCenterBase业务端拼接认证中心登录地址的根路径KeyRingPath密钥环共享目录三端必须指向同一个绝对路径目录需要提前创建好并给当前用户读写权限CookieName与 3.2 节 options.Cookie.Name 保持一致三端统一本地开发端口冲突是高频问题。启动前先确认 7001 到 7003 没有被占用Windows 下可以用 netstat -ano | findstr 7001 查。如果机器上同时跑着多个 .NET 项目建议直接用 dotnet run --urls 指定端口绕过 launchSettings 里的默认值。4.2 启动顺序与完整验证流程启动顺序有讲究我的习惯是先认证中心再业务端。# 终端 1启动认证中心 cd SSO.Solution/AuthCenter dotnet run # 终端 2启动业务端 A cd SSO.Solution/ClientAppA dotnet run # 终端 3启动业务端 B cd SSO.Solution/ClientAppB dotnet run三端都起来后按下面这套流程验证打开浏览器隐身窗口访问 https://localhost:7002/Dashboard/Index观察地址栏是否被重定向到 https://localhost:7001/Account/Login在登录页输入 demo / 123456提交后应自动跳回 https://localhost:7002/Dashboard/Index页面显示用户名 demo在同一个窗口直接访问 https://localhost:7003/Dashboard/Index应该不再出现登录页直接进入——这就是单点登录的效果一次认证多端通行打开 DevTools 的 Application 面板在 Cookies 里找到 SsoDemo.AuthCookie确认它挂在认证中心域名下第 3 步是这个示例最直观的验收点。如果访问 ClientAppB 时又被踢回登录页说明三端配置有某处不一致优先检查密钥目录、Cookie 名称和 ApplicationName 三个配置项。如果浏览器提示开发证书不受信任本地开发可以直接点「继续前往」换机器或换用户后需要重新执行 dotnet dev-certs https --trust 信任一次否则 https 端口访问会被浏览器拦得很死。5. 避坑记录SSO 联调最容易翻车的 5 个问题5.1 重定向死循环登录页要求登录现象访问业务端被抛到认证中心认证中心又抛回业务端地址栏来回弹最后浏览器报 ERR_TOO_MANY_REDIRECTS。原因认证中心的 AccountController 没有标记 [AllowAnonymous]默认全局授权策略把登录页本身也拦截了。Cookie 认证中间件发现登录页未认证又去触发跳转形成闭环。解决确认认证中心的登录相关接口全部标记 [AllowAnonymous]包括 GET Login 和 POST Login。如果认证中心设了全局过滤器要单独排除 Account 控制的这两个动作。排查时先看认证中心自己的日志有没有出现重定向判定相关的警告。5.2 跨域 Cookie 被浏览器丢弃现象认证中心登录成功302 跳回业务端后依然显示未登录。DevTools 里能看到 Cookie 已经写入但请求业务端接口时没有携带。原因认证中心运行在 7001 端口业务端在 7002 端口浏览器按同站规则判断这是跨站请求。三端如果对 SameSite 策略没做统一设置或者把 Lax 改成了 None 又没配合 Secure 标记部分浏览器版本会直接拒绝写入或携带。解决三端统一设置 options.Cookie.SameSite SameSiteMode.Lax。Lax 模式下跨站文档的 GET 导航会携带 Cookie正好覆盖登录后跳转回业务端的场景。不要为了省事把所有场景改成 None除非业务确实有跨站 POST 携带票据的需求并且全站已经上了 HTTPS。5.3 业务端解密报错密钥环不一致现象认证中心登录正常业务端抛 CryptographicException日志里出现 Unable to decrypt 或者 KeyNotFound 字样。原因三端的 Data Protection 配置不一致。最常见的是 SetApplicationName 字符串有细微差异比如大小写、尾随空格或者某一端没有 PersistKeysToFileSystem用了默认的独立密钥存储位置。两种情况下三端各自生成主密钥互相解不开。解决先对比三端 Program.cs 里的 ApplicationName 和密钥环路径必须完全一致。再把密钥目录删掉重新生成一次确认三端启动后目录里生成的是同一组密钥文件。目录权限也要检查IIS 环境下要确保应用程序池账户对目录有读写权限否则密钥环拉取会静默失败症状就是时好时坏非常玄学。5.4 本地 http 能跑、https 就丢登录态现象改成 https 端口联调后登录成功但 Cookie 写不进去业务端永远认为未认证。切回 http 运行完全正常。原因某端把 CookieSecurePolicy 配成了 Always或者浏览器安全策略强制要求 Secure 标记。在 http 站点上带 Secure 标记的 Cookie 不会被浏览器保存登录态自然丢失。解决本地联调阶段统一用 CookieSecurePolicy.SameAsRequest让中间件根据当前请求协议自动决定是否添加 Secure 标记。生产环境全站 https 时再改成 Always并且不要写死在代码里放到配置项里按环境切换。改完记得清理浏览器缓存 Cookie旧标记的残留会影响验证。5.5 登录后回不到业务端ReturnUrl 校验过严或过松现象登录成功后落在认证中心首页或者跳到一个 404 地址。更危险的情况是有人构造恶意 ReturnUrl利用认证中心做钓鱼跳转。原因认证中心对 ReturnUrl 的校验方式不对。用 Url.IsLocalUrl 校验会把跨应用的绝对地址全部拦掉——业务端地址对认证中心来说不是本地地址完全不校验又会带来开放重定向漏洞。解决示例里用前缀匹配白名单生产环境建议把允许跳转的业务端地址维护到配置表或数据库校验时做精确前缀匹配而不是 IsLocalUrl。端口规划变更时白名单和业务端配置要同步更新否则就会出现登录后跳错地址、跟端口对不上这类排查起来很耗耐心的问题。6. 二次开发思路从示例到生产级 SSO 的三步改造6.1 改造一硬编码用户校验换成真实身份源示例里的 demo / 123456 是写死的生产环境第一步就是换掉它。把 POST Login 的账号校验改为数据库查询加密码哈希比对推荐 BCrypt 或 PBKDF2 存储登录成功后按业务需要补充 claims例如部门、角色、租户 ID。验证方法建两个不同角色用户分别登录访问受保护的接口检查 User 里的 claims 是否按预期注入。6.2 改造二密钥环从本地目录迁到集中存储共享本地目录在三五台机器时够用应用多了之后密钥分发和轮换会变成运维负担。Data Protection 支持把密钥持久化到 Redis 或数据库新业务端启动时自动拉取已有密钥环而不是各自生成。改造后验证停掉所有节点删掉本地密钥目录重新启动业务端确认登录跳转流程依然正常。6.3 改造三评估票据形态是否升级到 JWTCookie 共享方案在应用数量增多、前后端分离之后会变得别扭比如跨域 POST 场景、移动端没有 Cookie 概念。常见做法是升级为 JWT认证中心登录后签发短期 access token业务端配置 JwtBearer 中间件校验签名token 里带用户标识和权限。改造时保留 ReturnUrl 跳转流程只替换票据生成与校验两端。改造顺序我建议固定为先做真实身份源再做密钥收敛最后评估 JWT。用户校验是安全根基密钥收敛解决的是运维问题JWT 升级影响面最大放在最后有足够时间做兼容验证。当初我把示例跑通后做的第一件事不是加功能而是把三端的端口、密钥目录、Cookie 名逐项核对了一遍生怕漏掉某个不一致项。从那以后我每次接入新的业务端都强制走一遍「启动认证中心 → 启动新客户端 → 未登录访问 → 登录跳回 → 检查其他端免登录」的完整流程这套手工用例沿用到现在。希望帮到你。本文还有配套的精品资源点击获取