登錄示例:授權(quán)碼+PKCE 打通多站點(diǎn)互認(rèn))
簡(jiǎn)介面向具備一定ASP.NET基礎(chǔ)、希望理解跨站點(diǎn)身份驗(yàn)證共享機(jī)制的開(kāi)發(fā)者這份C#單點(diǎn)登錄示例程序圍繞中央認(rèn)證服務(wù)CAS展開(kāi)演示統(tǒng)一登錄、令牌簽發(fā)與子站點(diǎn)校驗(yàn)的完整鏈路。壓縮包共173個(gè)文件以cs源碼、aspx頁(yè)面、dll程序集、csproj項(xiàng)目文件及config配置為主另含asmx服務(wù)、wsdl描述和數(shù)據(jù)庫(kù)文件整體約2.07MB便于直接打開(kāi)解決方案梳理結(jié)構(gòu)。已有495人學(xué)習(xí)適合作為SSO入門(mén)與課程設(shè)計(jì)參考。讀者可從中了解ASP.NET Identity管理賬戶、JWT或令牌生成驗(yàn)證、HTTP重定向與Cookie攜帶、受保護(hù)頁(yè)面跳轉(zhuǎn)等實(shí)現(xiàn)方式并結(jié)合代碼思考HTTPS通信、令牌過(guò)期與重放防護(hù)等安全要點(diǎn)還可參考單元測(cè)試和模擬CAS服務(wù)的思路掌握多站點(diǎn)認(rèn)證的調(diào)試與擴(kuò)展方法。1. 一套能跑通的 C# SSO 示例從登錄一次到三個(gè)站點(diǎn)互認(rèn)三個(gè)后臺(tái)站點(diǎn)各有一套登錄頁(yè)用戶切一次系統(tǒng)就要重輸一次密碼運(yùn)維還得維護(hù)三份賬號(hào)——這是很多 C# 項(xiàng)目從單體拆成多站點(diǎn)后最先冒出來(lái)的問(wèn)題。C# 單點(diǎn)登錄示例程序 SSO 把這件事拆成可運(yùn)行的三角色一個(gè)認(rèn)證中心、兩個(gè)業(yè)務(wù)站點(diǎn)登錄一次另外兩個(gè)站點(diǎn)憑認(rèn)證中心會(huì)話建立本地會(huì)話。它適合正在把老系統(tǒng)拆成多子域、又不想直接上重型身份平臺(tái)的團(tuán)隊(duì)。示例的重點(diǎn)不在界面而在授權(quán)碼怎么發(fā)、回調(diào)怎么驗(yàn)、注銷怎么清。照著把認(rèn)證中心跑起來(lái)再逐個(gè)接站點(diǎn)能把 SSO 的鏈路一次走通也能看清哪些地方只是“看起來(lái)通了”實(shí)際還留著會(huì)話不同步的尾巴。2. 認(rèn)證中心拆解授權(quán)碼 PKCE 的四個(gè)關(guān)鍵參數(shù)認(rèn)證中心是整個(gè)鏈路的信任源它不負(fù)責(zé)業(yè)務(wù)權(quán)限只回答兩個(gè)問(wèn)題你是誰(shuí)你當(dāng)前這次登錄會(huì)話還有沒(méi)有效。示例把認(rèn)證中心壓到最小登錄頁(yè)、授權(quán)端點(diǎn)、令牌端點(diǎn)、注銷端點(diǎn)外加一個(gè)全局會(huì)話存儲(chǔ)。業(yè)務(wù)站點(diǎn)不碰密碼只拿授權(quán)碼換令牌再用令牌建立自己的本地會(huì)話。2.1 為什么用授權(quán)碼 PKCE而不是共享 Cookie早期做 C# 單點(diǎn)登錄很容易想到把認(rèn)證中心的 Cookie 寫(xiě)到父域讓所有子站點(diǎn)都能讀到。這個(gè)做法在演示環(huán)境里很快但落地時(shí)會(huì)碰到三個(gè)硬問(wèn)題子域站點(diǎn)能直接讀到中心會(huì)話泄露面變大跨站請(qǐng)求下 SameSite 策略越來(lái)越嚴(yán)Cookie 不一定帶得過(guò)去注銷時(shí)只能清一個(gè)域其他站點(diǎn)仍然拿著舊票據(jù)。共享 Cookie 看似省事實(shí)際把認(rèn)證中心和業(yè)務(wù)站點(diǎn)的邊界抹掉了。示例采用授權(quán)碼模式并把 PKCE 打開(kāi)。業(yè)務(wù)站點(diǎn)生成一對(duì)臨時(shí)的code_verifier和code_challenge授權(quán)請(qǐng)求只帶 challenge回調(diào)換令牌時(shí)再帶 verifier。認(rèn)證中心比對(duì)哈希確認(rèn)換令牌的客戶端就是發(fā)起授權(quán)的那一個(gè)。授權(quán)碼本身短命、一次性即使被截獲沒(méi)有 verifier 也換不到令牌。對(duì) C# 項(xiàng)目來(lái)說(shuō)這套流程不需要業(yè)務(wù)站點(diǎn)接觸用戶密碼也不需要把中心會(huì)話 Cookie 共享出去。關(guān)鍵參數(shù)有四個(gè)response_typecode固定授權(quán)碼模式code_challenge_methodS256指定 PKCE 哈希算法state用來(lái)防回調(diào)偽造和串會(huì)話nonce寫(xiě)進(jìn) ID Token業(yè)務(wù)站點(diǎn)回調(diào)時(shí)比對(duì)防重放。四個(gè)參數(shù)少一個(gè)鏈路可能還能跑但邊界就松了。參數(shù)示例值作用常見(jiàn)錯(cuò)誤response_typecode指定授權(quán)碼模式寫(xiě)成 token令牌直接暴露在前端code_challenge_methodS256PKCE 哈希算法用 plain等于沒(méi)開(kāi) PKCEstate隨機(jī) 32 字節(jié)回調(diào)防偽與會(huì)話綁定固定值或不校驗(yàn)nonce隨機(jī) 32 字節(jié)ID Token 防重放只生成不比對(duì)2.2 授權(quán)碼的簽發(fā)與一次性兌換授權(quán)碼不要存明文也不要用自增 ID。示例用 256 位隨機(jī)字節(jié)生成 code緩存里只存 code 的 SHA-256 哈希值里放這次授權(quán)綁定的客戶端、回調(diào)地址、用戶主體、PKCE challenge 和 nonce。這樣即使緩存被誤導(dǎo)出也拿不到可用的 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 位隨機(jī) code只把哈希寫(xiě)進(jìn)緩存 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; // 先刪再返回保證同一個(gè) 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 秒。時(shí)間太長(zhǎng)會(huì)給重放留窗口太短會(huì)讓慢網(wǎng)絡(luò)下的回調(diào)失敗。緩存必須用分布式實(shí)現(xiàn)不能各實(shí)例各存一份否則用戶回調(diào)落到另一臺(tái)機(jī)器就換不到令牌。RedeemAsync先刪再解析是拿“刪除成功”當(dāng)一次性鎖如果后續(xù)校驗(yàn)失敗這個(gè) code 已經(jīng)作廢用戶需要重新登錄。這個(gè)取舍偏向安全生產(chǎn)里可以用原子腳本把“讀取、校驗(yàn)、刪除”合成一步減少失敗重試。授權(quán)端點(diǎn)負(fù)責(zé)把請(qǐng)求參數(shù)收攏成CodeGrant并在用戶未登錄時(shí)跳登錄頁(yè)。回調(diào)地址必須和客戶端注冊(cè)值逐字符一致包括尾斜杠和端口。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 必須與注冊(cè)值逐字符一致包括尾斜杠 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是注冊(cè)白名單不能用請(qǐng)求里的任意地址。returnUrl只允許本地相對(duì)路徑避免登錄后被帶到外部地址。state不在認(rèn)證中心校驗(yàn)只原樣回傳業(yè)務(wù)站點(diǎn)自己存一份比對(duì)。code_challenge_method只接受S256如果客戶端傳plain直接拒絕。2.3 令牌端點(diǎn)與 JWT 簽名令牌端點(diǎn)做四件事確認(rèn) grant_type、兌換 code、校驗(yàn) PKCE、簽發(fā) ID Token 和 Access Token。示例把client_secret校驗(yàn)放在兌換之前實(shí)際項(xiàng)目也可以換成私鑰簽名或雙向證書(shū)但不要把 secret 寫(xiě)進(jìn)前端。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是業(yè)務(wù)站點(diǎn)在發(fā)起授權(quán)前生成的隨機(jī)串長(zhǎng)度建議 43 到 128 字符。challenge用 S256 計(jì)算比對(duì)時(shí)用固定時(shí)間比較避免逐字節(jié)比較泄露時(shí)序。redirect_uri再校驗(yàn)一次是因?yàn)槭跈?quán)請(qǐng)求和令牌請(qǐng)求可能被篡改。grant_type只接受授權(quán)碼示例不支持刷新令牌業(yè)務(wù)站點(diǎn)本地會(huì)話過(guò)期后重新走一次授權(quán)鏈路更短排錯(cuò)也直接。令牌簽發(fā)時(shí)ID Token 的aud是客戶端 IDAccess Token 的aud可以是業(yè)務(wù) API 標(biāo)識(shí)。sid關(guān)聯(lián)認(rèn)證中心的全局會(huì)話注銷時(shí)用它反查。nonce只放 ID Token業(yè)務(wù)站點(diǎn)回調(diào)時(shí)比對(duì)。notBefore往前留 1 分鐘是給多臺(tái)機(jī)器的時(shí)間偏差留余量。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; 本文還有配套的精品資源點(diǎn)擊獲取 /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