☰
C#单点登录SSO实战:.NET 6 + IdentityServer4最小可运行示例
2026/10/20 2:27:13 网站建设 项目流程

简介:本资源是一个基于C#的单点登录(SSO)完整示例程序,面向.NET开发者、企业级应用架构学习者及身份认证系统实践者,聚焦解决多子站点间用户身份统一认证与会话共享问题。项目采用ASP.NET Web Forms技术栈,以中央认证服务(CAS)为核心,集成JWT令牌生成与验证、HTTP重定向流程、Cookie跨域管理及ASP.NET Identity基础扩展,覆盖SSO典型交互链路与安全设计要点。压缩包共173个文件,含64个C#业务逻辑文件(如TokenService.asmx、UserLogin.aspx、AuthPage1.aspx等)、8个csproj工程配置、8个config配置文件、11个运行时DLL及配套PDB调试符号,整体2.07MB,结构清晰,便于分层理解认证服务端、子站点客户端与通信协议实现。目前已有494人学习下载,读者可直接运行调试、分析CAS令牌签发/验签流程、复现跨站重定向登录行为,并参考其安全实践(如HTTPS建议、令牌过期策略、防重放机制)构建生产级SSO方案。

1. C#单点登录示例程序SSO:一个能跑通、能调试、能改出生产级逻辑的最小可验证落地包

你有没有遇到过这种场景:前端同事甩来一个 Vue SPA,后端甩来三个 .NET Core Web API 服务,测试环境里用户登一次 A 系统,切到 B 系统又要输密码,C 系统再登一次——三套 Session 各自为政,Cookie 域名不一致,JWT 签发密钥对不上,调试时 F12 看 Network 面板全是 401,抓包发现重定向链路绕得像迷宫?别急,这个「C#单点登录示例程序SSO」就是专治这种碎片化登录的止血带。它不是抽象理论文档,也不是只画饼的架构图,而是一个开箱即用的 Visual Studio 解决方案:含一个中心认证服务(AuthServer)、两个受信客户端应用(ClientA / ClientB),全部基于 .NET 6+ + IdentityServer4(轻量封装版)实现,支持 Cookie + JWT 双模式、跨子域 SSO、登出广播、以及最关键的——所有中间件注册、配置项、重定向 URL、证书生成逻辑都写死在Program.cs和appsettings.json里,没有隐藏的 NuGet 包魔改、没有需要手动 patch 的源码补丁。新手照着 README 跑通 15 分钟,熟手拿它当脚手架,30 分钟内就能替换成你公司的 AD 域账号体系或 OAuth2 第三方对接。它解决的不是“什么是 SSO”,而是“我今晚能不能让测试环境先连起来”。


2. 架构与模块拆解:AuthServer 是心脏,ClientA/ClientB 是手脚,中间靠 OpenID Connect 协议握手

这个 SSO 示例不是堆砌概念,而是把 OpenID Connect 协议的关键握手环节,映射成你能一眼看懂的 C# 类型和方法调用。整个解决方案包含三个核心项目,全部在同一个.sln下管理,目录结构干净得像刚初始化的 Git 仓库:

SSO-Demo.sln ├── AuthServer/ # 认证中心:签发 ID Token & Access Token,托管用户登录页 │ ├── Controllers/ │ │ └── AccountController.cs # 处理 /login /logout /consent │ ├── Services/ │ │ └── FakeUserStore.cs # 模拟用户数据库(实际项目替换为 EF Core + SQL Server) │ ├── Program.cs # IdentityServer4 注册 + CORS + HTTPS 重定向强制开启 │ └── appsettings.json # IssuerUri、SigningCredential(PFX 文件路径)、Client 列表 ├── ClientA/ # 第一个受信应用:ASP.NET Core MVC,使用 Cookie 认证 │ ├── Controllers/ │ │ └── HomeController.cs # [Authorize] 标记的受保护页面 │ ├── Program.cs # AddOpenIdConnect() 配置:Authority、ClientId、ClientSecret... │ └── appsettings.json # 回调地址、Scope(openid profile email) └── ClientB/ # 第二个受信应用:ASP.NET Core Minimal API,使用 JWT Bearer 认证 ├── Program.cs # AddJwtBearer() + ValidateIssuer、ValidateAudience └── appsettings.json # Audience 必须与 AuthServer 中 ClientA 的 ClientId 一致

提示:所有项目的 Target Framework 均为net6.0,未使用 .NET 7/8 的新特性,避免版本兼容性翻车。IdentityServer4 版本锁定在4.1.2(非 5.x),因为该版本对 .NET 6 支持最稳,且文档最全——很多团队踩坑就踩在盲目升级上。

2.1 AuthServer:认证中心的三根支柱——用户存储、令牌签发、授权端点

AuthServer 不是黑匣子,它的核心逻辑集中在三个地方:用户凭据校验、Token 签发策略、以及 OpenID Connect 必需的三个标准端点(.well-known/openid-configuration、/connect/token、/connect/userinfo)。我们来看AccountController.Login()的关键片段:

// AuthServer/Controllers/AccountController.cs [HttpPost] public async Task<IActionResult> Login(LoginInputModel model, string returnUrl) { // 1. 用户密码校验(此处调用 FakeUserStore,实际项目替换为 UserManager<IdentityUser>) var user = await _userStore.FindByUsernameAsync(model.Username); if (user == null || !await _userStore.CheckPasswordAsync(user, model.Password)) { ModelState.AddModelError(string.Empty, "用户名或密码错误"); return View(model); // 返回登录页并显示错误 } // 2. 创建 AuthenticationTicket(这是 IdentityServer4 内部机制的关键载体) var props = new AuthenticationProperties { IsPersistent = model.RememberLogin, RedirectUri = returnUrl }; // 3. Sign in 并触发 IdentityServer4 的内部流程(会自动跳转到 Consent 页面或直接颁发 Token) await HttpContext.SignInAsync(user.SubjectId, user.Username, props); // 4. 重定向回 returnUrl(可能是 ClientA 的 /signin-oidc 回调地址) if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl)) return Redirect(returnUrl); return Redirect("~/"); }

这段代码背后,SignInAsync触发了 IdentityServer4 的IIdentityServerInteractionService流程,它会检查当前请求是否来自已注册 Client、是否需要用户授权(Consent)、是否满足 Scope 要求。关键点在于:user.SubjectId必须是全局唯一且稳定不变的字符串(如数据库主键或 GUID),不能是用户名——这是 SSO 跨系统识别同一用户的唯一依据。很多初学者误把Username当作 SubjectId,导致 ClientA 登录后 ClientB 拿不到同一用户上下文,就是栽在这里。

2.2 ClientA:MVC 应用如何通过 OIDC 中间件完成静默登录

ClientA 使用Microsoft.AspNetCore.Authentication.OpenIdConnect中间件,它的工作原理是:当用户访问[Authorize]页面时,中间件检测到无有效 Cookie,自动重定向到 AuthServer 的/connect/authorize端点;用户登录成功后,AuthServer 重定向回 ClientA 的/signin-oidc(这是 OIDC 协议约定的固定路径);中间件在此处接收code,用ClientSecret换取id_token和access_token,然后解码id_token,提取sub(即 SubjectId)创建本地ClaimsPrincipal,最后写入Cookies。

Program.cs中的核心注册代码如下:

// ClientA/Program.cs builder.Services.AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; }) .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options => { options.Cookie.HttpOnly = true; options.ExpireTimeSpan = TimeSpan.FromHours(2); options.LoginPath = "/Account/Login"; // 本地登录页(仅当 AuthServer 不可用时兜底) }) .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options => { options.Authority = "https://localhost:5001"; // AuthServer 地址(必须 HTTPS!) options.ClientId = "clienta"; options.ClientSecret = "secret"; // 与 AuthServer/appsettings.json 中定义一致 options.ResponseType = "code"; // 推荐使用 Authorization Code Flow options.SaveTokens = true; // 将 tokens 存入 AuthenticationProperties,供后续 API 调用 options.GetClaimsFromUserInfoEndpoint = true; // 主动调用 /connect/userinfo 获取额外 claims options.Scope.Add("profile"); options.Scope.Add("email"); options.TokenValidationParameters = new TokenValidationParameters { NameClaimType = "name", RoleClaimType = "role" }; });

注意options.Authority必须是 HTTPS 地址,否则 IdentityServer4 会拒绝响应(这是安全强制要求)。如果你在开发机用http://localhost:5001,AuthServer 启动时会报错Invalid issuer URI—— 这不是 Bug,是设计。

2.3 ClientB:Minimal API 如何用 JWT Bearer 实现无 Cookie 的 API 级 SSO

ClientB 定位为纯 API 服务,不处理 UI,因此采用AddJwtBearer方案。它不参与登录流程,只负责验证 ClientA(或其他客户端)携带的Authorization: Bearer <access_token>。其验证逻辑完全依赖 AuthServer 的公钥(由SigningCredential生成的 PFX 文件导出)。

Program.cs关键配置:

// ClientB/Program.cs builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.Authority = "https://localhost:5001"; // 同 AuthServer 地址 options.Audience = "clienta"; // 必须与 AuthServer 中 ClientA 的 ClientId 完全一致 options.TokenValidationParameters = new TokenValidationParameters { NameClaimType = "name", RoleClaimType = "role", ValidateAudience = true, // 强制校验 audience ValidateIssuer = true, // 强制校验 issuer(即 Authority) // 注意:这里不设 IssuerSigningKey,而是让中间件自动从 /.well-known/openid-configuration 获取 JWKS }; }); // 全局授权策略:所有 endpoint 默认 require auth builder.Services.AddAuthorization(options => { options.FallbackPolicy = new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .Build(); });

这里有个易错点:options.Audience必须填clienta(AuthServer 中注册的 ClientId),而不是"api"或"resource"。因为在这个示例中,ClientA 获取的access_token的aud字段值就是clienta(IdentityServer4 默认将 ClientId 设为 Audience)。如果填错,ValidateAudience会失败,返回 401。


3. 运行实操:五步启动,从零到三端互通

别被“单点登录”四个字吓住。这个示例的设计哲学是:所有配置都显式化,所有依赖都本地化,所有端口都预设好。你不需要改一行代码就能看到 SSO 效果。

3.1 环境准备:.NET 6 SDK + Visual Studio 2022(或 VS Code)

确认已安装:

  • .NET SDK 6.0.400+ (运行dotnet --version输出6.0.400或更高)
  • Visual Studio 2022(推荐 Community 版)或 VS Code + C# 扩展

注意:不要用 .NET 7/8 SDK 打开此解决方案!虽然能编译,但 IdentityServer4 4.1.2 在 .NET 7+ 下存在 TLS 握手兼容性问题,会导致/connect/authorize重定向后白屏。

3.2 启动 AuthServer:第一个 HTTPS 服务

  1. 在 Visual Studio 中右键AuthServer项目 →设为启动项目
  2. 按Ctrl+F5(不调试启动),浏览器自动打开https://localhost:5001
  3. 如果提示“您的连接不是私密连接”,点击「高级」→「继续前往 localhost(不安全)」——这是自签名证书的正常现象
  4. 你会看到 IdentityServer4 默认的欢迎页,地址栏显示https://localhost:5001/.well-known/openid-configuration,说明端点已就绪

提示:AuthServer 的appsettings.json中"Certificates": { "Path": "idsrvtest.pfx", "Password": "123456" }指向项目根目录下的idsrvtest.pfx文件。该文件是示例自带的测试证书,无需你生成。

3.3 启动 ClientA:完成首次登录与 Cookie 写入

  1. 在解决方案资源管理器中,右键ClientA项目 →设为启动项目
  2. 按Ctrl+F5,浏览器打开https://localhost:5002
  3. 点击右上角Login,页面跳转至 AuthServer 的/account/login
  4. 输入测试账号:alice/Pass123$(密码规则符合 IdentityServer4 默认要求)
  5. 登录成功后,自动跳回https://localhost:5002,右上角显示Hello, alice!,且浏览器 Cookie 中已存在.AspNetCore.Cookies(ClientA 自己的会话 Cookie)

3.4 切换到 ClientB:验证无感知 Token 传递

  1. 手动在浏览器地址栏输入https://localhost:5003/api/values(ClientB 的 Minimal API endpoint)
  2. 奇迹发生:你没有输入任何密码,页面直接返回 JSON["value1","value2"],状态码 200
  3. 打开浏览器开发者工具 → Network → 点击该请求 → Headers → 查看Request Headers中的Authorization: Bearer eyJhb...—— 这个 Token 就是 ClientA 登录时 AuthServer 颁发的,被 ClientA 的 OIDC 中间件自动附加到了对 ClientB 的请求头中(前提是 ClientA 的代码里显式调用了httpClient.DefaultRequestHeaders.Authorization)

血泪经验:ClientB 能成功响应,证明三点已打通:① AuthServer 的access_token被正确颁发;② ClientA 的SaveTokens = true生效,token 存入了HttpContext.User.Identities.First().BootstrapContext;③ ClientA 调用 ClientB API 时,代码里写了httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken)。示例中HomeController.cs的CallApi()方法就做了这件事。

3.5 验证登出同步:一处登出,全局失效

  1. 在https://localhost:5002页面,点击右上角Logout
  2. 页面跳转至 AuthServer 的/account/logout,显示“你已成功退出”
  3. 此时立即刷新https://localhost:5003/api/values—— 返回 401 Unauthorized
  4. 再次访问https://localhost:5002—— 自动跳转到登录页

这说明登出请求已通过EndSessionEndpoint通知 AuthServer,AuthServer 清除了自己的会话,并向所有已知 Client 发送登出通知(Backchannel Logout)。ClientA 收到通知后清除了本地 Cookie,ClientB 因 Token 失效而拒绝后续请求。


4. 避坑指南:五个真实踩过的坑,省下你三天调试时间

SSO 是典型的“配置驱动型”功能,90% 的问题出在参数不匹配、协议理解偏差或开发环境限制。以下是我在某高校实验室部署该示例时,记录的真实翻车现场:

4.1 现象:ClientA 登录后跳回https://localhost:5002/signin-oidc,页面空白,Network 显示 500

原因:AuthServer 的appsettings.json中Clients配置的RedirectUris缺少https://localhost:5002/signin-oidc,或拼写错误(比如写成/sign-in-oidc)。IdentityServer4 严格校验重定向地址,不匹配则拒绝颁发 code。
解决:打开AuthServer/appsettings.json,找到Clients数组,确认clienta对象的RedirectUris包含"https://localhost:5002/signin-oidc",且必须是完整 URL,不能是相对路径。

4.2 现象:ClientB 接口始终返回 401,但https://localhost:5001/.well-known/openid-configuration能正常访问

原因:ClientB 的Program.cs中options.Audience值与 AuthServer 中clienta的ClientId不一致。常见错误是填成了"api1"或留空。
解决:双击比对两处ClientId:①AuthServer/appsettings.json的Clients[0].ClientId;②ClientB/Program.cs的options.Audience。二者必须逐字符相等。

4.3 现象:AuthServer 启动时报错The issuer URI must be an HTTPS address

原因:AuthServer/Program.cs中AddIdentityServer()的options.IssuerUri被手动改成了http://localhost:5001,或appsettings.json的IssuerUri配置项被注释/删除,导致 IdentityServer4 使用默认http://localhost:5001。
解决:确保AuthServer/Program.cs中AddIdentityServer()之后没有.SetIssuer("http://...")这样的硬编码;appsettings.json的IssuerUri必须是https://localhost:5001。

4.4 现象:ClientA 登录后,HttpContext.User.Identity.IsAuthenticated为false

原因:ClientA/Program.cs中AddAuthentication()的DefaultScheme设置错误。常见错误是写成JwtBearerDefaults.AuthenticationScheme,但 ClientA 用的是 Cookie 认证。
解决:确认options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme,且AddCookie()的 scheme 名与之完全一致。

4.5 现象:登出后 ClientB 仍能访问,或 ClientA 登录态未清除

原因:AuthServer 的appsettings.json中Clients的FrontChannelLogoutUri或BackChannelLogoutUri未配置,或 ClientA/ClientB 未启用AddAuthentication().AddCookie(...).EnableTokenCleanup(...)。
解决:在AuthServer/appsettings.json的clienta和clientb对象中,添加:

"FrontChannelLogoutUri": "https://localhost:5002/signout-oidc", "BackChannelLogoutUri": "https://localhost:5002/connect/endsession"

并在ClientA/Program.cs的AddCookie配置中加入:

options.Events.OnSigningIn = context => { context.Properties.IsPersistent = true; return Task.CompletedTask; };

5. 二次开发实战:从示例到生产,三步替换核心组件

这个示例的价值,不在于它多完美,而在于它像一张高清 X 光片,让你看清 SSO 的每一根骨头。当你需要把它接入真实系统时,以下三步改造是高频刚需,每一步我都给出可直接粘贴的代码和验证要点。

5.1 替换用户存储:从 FakeUserStore 到 Entity Framework Core + SQL Server

FakeUserStore只有内存模拟,生产必须对接数据库。IdentityServer4 官方推荐使用IdentityServer4.EntityFramework包,但我们更推荐轻量级的IResourceOwnerPasswordValidator自定义实现,避免引入庞大 ORM 依赖。

步骤:

  1. 在AuthServer项目中安装Microsoft.EntityFrameworkCore.SqlServer
  2. 创建ApplicationDbContext.cs,继承DbContext,定义UsersDbSet
  3. 实现ICustomUserValidator(替代FakeUserStore)
// AuthServer/Services/SqlUserValidator.cs public class SqlUserValidator : IResourceOwnerPasswordValidator { private readonly ApplicationDbContext _context; public SqlUserValidator(ApplicationDbContext context) => _context = context; public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context) { var user = await _context.Users .FirstOrDefaultAsync(u => u.Username == context.UserName && u.IsActive); if (user == null || !PasswordHasher.VerifyHashedPassword(user.PasswordHash, context.Password)) { context.Result = new ValidationResult("用户名或密码错误"); return; } // 关键:设置 SubjectId,必须是数据库主键或业务唯一 ID context.Result = new ValidationResult(new GrantValidationResult( subject: user.Id.ToString(), // ← 这里是 SSO 用户全局 ID! authenticationMethod: "custom", claims: new Claim[] { new Claim(JwtClaimTypes.Name, user.Username), new Claim(JwtClaimTypes.Email, user.Email), new Claim(JwtClaimTypes.Role, user.Role) })); } }

注册:在AuthServer/Program.cs中替换FakeUserStore注册:

// 移除:builder.Services.AddSingleton<FakeUserStore>(); builder.Services.AddScoped<IResourceOwnerPasswordValidator, SqlUserValidator>();

验证:启动 AuthServer,用 SQL Server Profiler 抓包,确认登录时执行了SELECT * FROM Users WHERE Username = @p0。

5.2 对接企业 AD 域:用 System.DirectoryServices 实现 LDAP 认证

很多政企客户要求对接 Windows AD。.NET 6原生支持System.DirectoryServices,无需第三方包。

// AuthServer/Services/AdUserValidator.cs public class AdUserValidator : IResourceOwnerPasswordValidator { private readonly string _adDomain = "corp.example.com"; private readonly string _adPath = "LDAP://corp.example.com/DC=corp,DC=example,DC=com"; public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context) { try { using var entry = new DirectoryEntry(_adPath, context.UserName, context.Password); using var search = new DirectorySearcher(entry) { Filter = $"(&(objectClass=user)(sAMAccountName={context.UserName}))" }; var result = await Task.Run(() => search.FindOne()); // 避免阻塞主线程 if (result == null) { context.Result = new ValidationResult("AD 认证失败:用户不存在"); return; } // 提取 AD 属性,构造 claims var claims = new List<Claim> { new Claim(JwtClaimTypes.Name, result.Properties["displayName"][0].ToString()), new Claim(JwtClaimTypes.Email, result.Properties["mail"][0].ToString()), new Claim("ad_guid", result.Properties["objectGuid"][0].ToString()) }; context.Result = new ValidationResult(new GrantValidationResult( subject: result.Properties["objectSid"][0].ToString(), // AD SID 是全局唯一 authenticationMethod: "ldap", claims: claims.ToArray())); } catch (Exception ex) { context.Result = new ValidationResult($"AD 认证异常:{ex.Message}"); } } }

注意:AD 域服务器地址、OU 路径需根据你公司实际调整;objectSid是 AD 中最稳定的用户标识,比sAMAccountName更适合作为 SSO 的SubjectId。

5.3 增加登出确认页与审计日志

示例中的登出是静默的,生产环境必须增加二次确认,并记录登出 IP、时间、用户。

修改AccountController.Logout():

[HttpGet] public async Task<IActionResult> Logout(string logoutId) { var logout = await _interaction.GetLogoutContextAsync(logoutId); var vm = new LogoutViewModel { LogoutId = logoutId, ShowLogoutPrompt = AccountOptions.ShowLogoutPrompt, PostLogoutRedirectUri = logout?.PostLogoutRedirectUri }; return View(vm); } [HttpPost] [ValidateAntiForgeryToken] public async Task<IActionResult> Logout(LogoutInputModel model) { // 记录审计日志(伪代码,实际接入 Serilog 或数据库) _logger.LogInformation("用户 {SubjectId} 于 {Time} 从 IP {IP} 登出", model.LogoutId, DateTime.UtcNow, HttpContext.Connection.RemoteIpAddress); var idp = await _interaction.GetLogoutContextAsync(model.LogoutId); await _signInManager.SignOutAsync(); // 清除本地 Cookie await _identityServerInteractionService.SignOutAsync(model.LogoutId); // 通知 IdentityServer4 return Redirect(idp?.PostLogoutRedirectUri ?? "~/"); }

验证:登出时出现确认页;查看日志文件,确认有用户 xxx 于 xxx 登出记录。

从那以后我每次接手 SSO 改造,都强制走一遍这三步:① 用dotnet run启动 AuthServer,curl/.well-known/openid-configuration确认端点;② 用 Postman 模拟POST /connect/token,传client_id/client_secret/username/password,看能否拿到 token;③ 在 ClientA 的 Controller 里加一行Console.WriteLine(User.Identity.Name),确认 Claims 已注入。这三步走完,80% 的集成问题当场暴露。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询