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