首页 / 资讯中心 / 文章详情

C#单点登录SSO实战:.NET 6 + IdentityServer4最小可运行示例

C#单点登录SSO实战:.NET 6 + IdentityServer4最小可运行示例 ★ FEATURED ARTICLE
简介本资源是一个基于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、SigningCredentialPFX 文件路径、Client 列表 ├── ClientA/ # 第一个受信应用ASP.NET Core MVC使用 Cookie 认证 │ ├── Controllers/ │ │ └── HomeController.cs # [Authorize] 标记的受保护页面 │ ├── Program.cs # AddOpenIdConnect() 配置Authority、ClientId、ClientSecret... │ └── appsettings.json # 回调地址、Scopeopenid 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 TaskIActionResult Login(LoginInputModel model, string returnUrl) { // 1. 用户密码校验此处调用 FakeUserStore实际项目替换为 UserManagerIdentityUser 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 ClientAMVC 应用如何通过 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:5001AuthServer 启动时会报错Invalid issuer URI—— 这不是 Bug是设计。2.3 ClientBMinimal API 如何用 JWT Bearer 实现无 Cookie 的 API 级 SSOClientB 定位为纯 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必须填clientaAuthServer 中注册的 ClientId而不是api或resource。因为在这个示例中ClientA 获取的access_token的aud字段值就是clientaIdentityServer4 默认将 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 服务在 Visual Studio 中右键AuthServer项目 →设为启动项目按CtrlF5不调试启动浏览器自动打开https://localhost:5001如果提示“您的连接不是私密连接”点击「高级」→「继续前往 localhost不安全」——这是自签名证书的正常现象你会看到 IdentityServer4 默认的欢迎页地址栏显示https://localhost:5001/.well-known/openid-configuration说明端点已就绪提示AuthServer 的appsettings.json中Certificates: { Path: idsrvtest.pfx, Password: 123456 }指向项目根目录下的idsrvtest.pfx文件。该文件是示例自带的测试证书无需你生成。3.3 启动 ClientA完成首次登录与 Cookie 写入在解决方案资源管理器中右键ClientA项目 →设为启动项目按CtrlF5浏览器打开https://localhost:5002点击右上角Login页面跳转至 AuthServer 的/account/login输入测试账号alice/Pass123$密码规则符合 IdentityServer4 默认要求登录成功后自动跳回https://localhost:5002右上角显示Hello, alice!且浏览器 Cookie 中已存在.AspNetCore.CookiesClientA 自己的会话 Cookie3.4 切换到 ClientB验证无感知 Token 传递手动在浏览器地址栏输入https://localhost:5003/api/valuesClientB 的 Minimal API endpoint奇迹发生你没有输入任何密码页面直接返回 JSON[value1,value2]状态码 200打开浏览器开发者工具 → 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 验证登出同步一处登出全局失效在https://localhost:5002页面点击右上角Logout页面跳转至 AuthServer 的/account/logout显示“你已成功退出”此时立即刷新https://localhost:5003/api/values—— 返回 401 Unauthorized再次访问https://localhost:5002—— 自动跳转到登录页这说明登出请求已通过EndSessionEndpoint通知 AuthServerAuthServer 清除了自己的会话并向所有已知 Client 发送登出通知Backchannel Logout。ClientA 收到通知后清除了本地 CookieClientB 因 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 ServerFakeUserStore只有内存模拟生产必须对接数据库。IdentityServer4 官方推荐使用IdentityServer4.EntityFramework包但我们更推荐轻量级的IResourceOwnerPasswordValidator自定义实现避免引入庞大 ORM 依赖。步骤在AuthServer项目中安装Microsoft.EntityFrameworkCore.SqlServer创建ApplicationDbContext.cs继承DbContext定义UsersDbSet实现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.AddSingletonFakeUserStore(); builder.Services.AddScopedIResourceOwnerPasswordValidator, 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/DCcorp,DCexample,DCcom; public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context) { try { using var entry new DirectoryEntry(_adPath, context.UserName, context.Password); using var search new DirectorySearcher(entry) { Filter $((objectClassuser)(sAMAccountName{context.UserName})) }; var result await Task.Run(() search.FindOne()); // 避免阻塞主线程 if (result null) { context.Result new ValidationResult(AD 认证失败用户不存在); return; } // 提取 AD 属性构造 claims var claims new ListClaim { 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 TaskIActionResult 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 TaskIActionResult 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启动 AuthServercurl/.well-known/openid-configuration确认端点② 用 Postman 模拟POST /connect/token传client_id/client_secret/username/password看能否拿到 token③ 在 ClientA 的 Controller 里加一行Console.WriteLine(User.Identity.Name)确认 Claims 已注入。这三步走完80% 的集成问题当场暴露。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站