
简介面向具备一定ASP.NET基础、希望理解跨站点身份验证共享机制的开发者这份C#单点登录示例程序围绕中央认证服务CAS展开演示统一登录、令牌签发与子站点校验的完整链路。压缩包共173个文件以cs源码、aspx页面、dll程序集、csproj项目文件及config配置为主另含asmx服务、wsdl描述和数据库文件整体约2.07MB便于直接打开解决方案梳理结构。已有495人学习适合作为SSO入门与课程设计参考。读者可从中了解ASP.NET Identity管理账户、JWT或令牌生成验证、HTTP重定向与Cookie携带、受保护页面跳转等实现方式并结合代码思考HTTPS通信、令牌过期与重放防护等安全要点还可参考单元测试和模拟CAS服务的思路掌握多站点认证的调试与扩展方法。1. 一套能跑通的 C# SSO 示例从登录一次到三个站点互认三个后台站点各有一套登录页用户切一次系统就要重输一次密码运维还得维护三份账号——这是很多 C# 项目从单体拆成多站点后最先冒出来的问题。C# 单点登录示例程序 SSO 把这件事拆成可运行的三角色一个认证中心、两个业务站点登录一次另外两个站点凭认证中心会话建立本地会话。它适合正在把老系统拆成多子域、又不想直接上重型身份平台的团队。示例的重点不在界面而在授权码怎么发、回调怎么验、注销怎么清。照着把认证中心跑起来再逐个接站点能把 SSO 的链路一次走通也能看清哪些地方只是“看起来通了”实际还留着会话不同步的尾巴。2. 认证中心拆解授权码 PKCE 的四个关键参数认证中心是整个链路的信任源它不负责业务权限只回答两个问题你是谁你当前这次登录会话还有没有效。示例把认证中心压到最小登录页、授权端点、令牌端点、注销端点外加一个全局会话存储。业务站点不碰密码只拿授权码换令牌再用令牌建立自己的本地会话。2.1 为什么用授权码 PKCE而不是共享 Cookie早期做 C# 单点登录很容易想到把认证中心的 Cookie 写到父域让所有子站点都能读到。这个做法在演示环境里很快但落地时会碰到三个硬问题子域站点能直接读到中心会话泄露面变大跨站请求下 SameSite 策略越来越严Cookie 不一定带得过去注销时只能清一个域其他站点仍然拿着旧票据。共享 Cookie 看似省事实际把认证中心和业务站点的边界抹掉了。示例采用授权码模式并把 PKCE 打开。业务站点生成一对临时的code_verifier和code_challenge授权请求只带 challenge回调换令牌时再带 verifier。认证中心比对哈希确认换令牌的客户端就是发起授权的那一个。授权码本身短命、一次性即使被截获没有 verifier 也换不到令牌。对 C# 项目来说这套流程不需要业务站点接触用户密码也不需要把中心会话 Cookie 共享出去。关键参数有四个response_typecode固定授权码模式code_challenge_methodS256指定 PKCE 哈希算法state用来防回调伪造和串会话nonce写进 ID Token业务站点回调时比对防重放。四个参数少一个链路可能还能跑但边界就松了。参数示例值作用常见错误response_typecode指定授权码模式写成 token令牌直接暴露在前端code_challenge_methodS256PKCE 哈希算法用 plain等于没开 PKCEstate随机 32 字节回调防伪与会话绑定固定值或不校验nonce随机 32 字节ID Token 防重放只生成不比对2.2 授权码的签发与一次性兑换授权码不要存明文也不要用自增 ID。示例用 256 位随机字节生成 code缓存里只存 code 的 SHA-256 哈希值里放这次授权绑定的客户端、回调地址、用户主体、PKCE challenge 和 nonce。这样即使缓存被误导出也拿不到可用的 code。public sealed record CodeGrant( string ClientId, string RedirectUri, string Subject, string SessionId, string CodeChallenge, string CodeChallengeMethod, string Nonce); public sealed class AuthorizationCodeStore { private readonly IDistributedCache _cache; public AuthorizationCodeStore(IDistributedCache cache) _cache cache; public async Taskstring CreateAsync(CodeGrant grant, TimeSpan ttl) { // 生成 256 位随机 code只把哈希写进缓存 var bytes RandomNumberGenerator.GetBytes(32); var code ToBase64Url(bytes); var key $sso:code:{ToSha256(code)}; await _cache.SetStringAsync(key, JsonSerializer.Serialize(grant), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow ttl }); return code; } public async TaskCodeGrant? RedeemAsync(string code) { var key $sso:code:{ToSha256(code)}; var payload await _cache.GetStringAsync(key); if (payload is null) return null; // 先删再返回保证同一个 code 只能成功兑换一次 await _cache.RemoveAsync(key); return JsonSerializer.DeserializeCodeGrant(payload); } private static string ToBase64Url(byte[] bytes) Convert.ToBase64String(bytes).Replace(, -).Replace(/, _).TrimEnd(); private static string ToSha256(string value) Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); }ttl建议 60 到 120 秒示例取 90 秒。时间太长会给重放留窗口太短会让慢网络下的回调失败。缓存必须用分布式实现不能各实例各存一份否则用户回调落到另一台机器就换不到令牌。RedeemAsync先删再解析是拿“删除成功”当一次性锁如果后续校验失败这个 code 已经作废用户需要重新登录。这个取舍偏向安全生产里可以用原子脚本把“读取、校验、删除”合成一步减少失败重试。授权端点负责把请求参数收拢成CodeGrant并在用户未登录时跳登录页。回调地址必须和客户端注册值逐字符一致包括尾斜杠和端口。app.MapGet(/connect/authorize, async (HttpContext http, IClientRegistry clients, IUserSession sessions, AuthorizationCodeStore store) { var q http.Request.Query; var client clients.Find(q[client_id]!); // redirect_uri 必须与注册值逐字符一致包括尾斜杠 if (client is null || !client.RedirectUris.Contains(q[redirect_uri]!)) return Results.BadRequest(invalid_redirect_uri); if (q[response_type] ! code || string.IsNullOrEmpty(q[code_challenge])) return Results.BadRequest(invalid_request); var user await sessions.GetUserAsync(http); if (user is null) return Results.Redirect( $/account/login?returnUrl{Uri.EscapeDataString(http.Request.GetEncodedPathAndQuery())}); var grant new CodeGrant(client.ClientId, q[redirect_uri]!, user.Subject, user.SessionId, q[code_challenge]!, q[code_challenge_method]!, q[nonce]!); var code await store.CreateAsync(grant, TimeSpan.FromSeconds(90)); var sep q[redirect_uri]!.Contains(?) ? : ?; return Results.Redirect( ${q[redirect_uri]}{sep}code{Uri.EscapeDataString(code)}state{Uri.EscapeDataString(q[state]!)}); });这段代码里client.RedirectUris是注册白名单不能用请求里的任意地址。returnUrl只允许本地相对路径避免登录后被带到外部地址。state不在认证中心校验只原样回传业务站点自己存一份比对。code_challenge_method只接受S256如果客户端传plain直接拒绝。2.3 令牌端点与 JWT 签名令牌端点做四件事确认 grant_type、兑换 code、校验 PKCE、签发 ID Token 和 Access Token。示例把client_secret校验放在兑换之前实际项目也可以换成私钥签名或双向证书但不要把 secret 写进前端。app.MapPost(/connect/token, async (HttpContext http, AuthorizationCodeStore store, ITokenIssuer issuer) { var form await http.Request.ReadFormAsync(); if (form[grant_type] ! authorization_code) return Results.BadRequest(unsupported_grant_type); var grant await store.RedeemAsync(form[code]!); if (grant is null) return Results.BadRequest(invalid_grant); if (grant.ClientId ! form[client_id] || grant.RedirectUri ! form[redirect_uri]) return Results.BadRequest(invalid_grant); var verifier form[code_verifier].ToString(); var challenge ToBase64Url(SHA256.HashData(Encoding.UTF8.GetBytes(verifier))); if (!CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(challenge), Encoding.UTF8.GetBytes(grant.CodeChallenge))) return Results.BadRequest(invalid_grant); var pair issuer.Create(grant); return Results.Json(new { access_token pair.AccessToken, id_token pair.IdToken, token_type Bearer, expires_in 600 }); });code_verifier是业务站点在发起授权前生成的随机串长度建议 43 到 128 字符。challenge用 S256 计算比对时用固定时间比较避免逐字节比较泄露时序。redirect_uri再校验一次是因为授权请求和令牌请求可能被篡改。grant_type只接受授权码示例不支持刷新令牌业务站点本地会话过期后重新走一次授权链路更短排错也直接。令牌签发时ID Token 的aud是客户端 IDAccess Token 的aud可以是业务 API 标识。sid关联认证中心的全局会话注销时用它反查。nonce只放 ID Token业务站点回调时比对。notBefore往前留 1 分钟是给多台机器的时间偏差留余量。public TokenPair Create(CodeGrant grant) { var now DateTime.UtcNow; var key new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config.SigningKey)); var creds new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var baseClaims new[] { new Claim(JwtRegisteredClaimNames.Sub, grant.Subject), new Claim(sid, grant.SessionId), new Claim(client_id, grant.ClientId) }; var idToken new JwtSecurityToken( issuer: _config.Issuer, audience: grant.ClientId, claims: baseClaims.Concat(new[] { new Claim(nonce, grant.Nonce) }), notBefore: now p a hrefhttps://download.csdn.net/download/guijiguiji/5846651 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p