證:生成、驗(yàn)證與踩坑實(shí)戰(zhàn))
前一陣子接手一個(gè)維護(hù)了三年多的 .Net Core 2.1 服務(wù)第一件要補(bǔ)的事就是給接口加身份認(rèn)證。和我預(yù)期的一樣方案繞不開 JWT——生成 Token、驗(yàn)證 Token、把用戶身份安全地傳遞到控制器。JWT 認(rèn)證在 .Net Core 里已經(jīng)被封裝得比較順手但 2.1 這個(gè)版本處在關(guān)鍵的迭代期網(wǎng)上很多教程講的是 3.x 甚至 5.x 的寫法照抄過來經(jīng)常在包版本、中間件順序和 Claim 讀取上翻車。這篇文章是完整的落地記錄覆蓋實(shí)現(xiàn)過程、配置參數(shù)和各類踩坑點(diǎn)正在維護(hù) 2.1 老項(xiàng)目的人可以直接參考新手也能借此建立 JWT 認(rèn)證的整體概念。1. 為什么在 .Net Core 2.1 時(shí)代還是堅(jiān)持用 JWT 做認(rèn)證1.1 從 Session 到 Token認(rèn)證方式變遷的底層邏輯在單機(jī) Web 時(shí)代最常見的認(rèn)證方式是 Session Cookie服務(wù)器內(nèi)存里保存一份會(huì)話狀態(tài)瀏覽器每次請(qǐng)求自動(dòng)帶上 SessionId服務(wù)端拿著 SessionId 查會(huì)話數(shù)據(jù)。這套機(jī)制勝在直觀但在分布式場景下馬上暴露問題請(qǐng)求一旦被負(fù)載均衡轉(zhuǎn)發(fā)到不同實(shí)例會(huì)話狀態(tài)就丟失了。解決辦法要么做 Session 持久化要么開啟負(fù)載均衡粘滯會(huì)話但無論哪種都會(huì)增加架構(gòu)復(fù)雜度和維護(hù)成本。JWT 的思路完全不同。它把“用戶是誰、能干什么、什么時(shí)候失效”這些信息全部編碼進(jìn)一個(gè)自包含的 Token 里服務(wù)器不再需要保存任何會(huì)話狀態(tài)??蛻舳税?Token 保存下來每個(gè)請(qǐng)求都帶上它服務(wù)端拿到后只要驗(yàn)簽通過、時(shí)效沒問題就完全信任這個(gè)身份。這種無狀態(tài)特性讓 JWT 在 Web API、前后端分離、微服務(wù)架構(gòu)里成了事實(shí)標(biāo)準(zhǔn)。.Net Core 2.1 是跨平臺(tái)體系中較早的 LTS 版本很多企業(yè)內(nèi)部服務(wù)到現(xiàn)在依然在線上運(yùn)行。給這些老服務(wù)補(bǔ)上 JWT 身份認(rèn)證是大量開發(fā)者都會(huì)遇到的改造任務(wù)。理解它的原理和落地細(xì)節(jié)不只是對(duì)付 2.1 項(xiàng)目對(duì)后面升級(jí)到新框架也同樣有價(jià)值。1.2 JWT 令牌的三個(gè)構(gòu)成段與一次完整認(rèn)證流程JWT 全稱 JSON Web Token是一個(gè)開放標(biāo)準(zhǔn)RFC 7519。一個(gè) Token 由三段用點(diǎn)分隔的 Base64Url 字符串組成Header、Payload、Signature。Header 聲明簽名算法比如 HS256和 Token 類型Payload 存放聲明信息比如用戶 ID、角色、過期時(shí)間Signature 是用密鑰對(duì)前兩段內(nèi)容做的簽名確保內(nèi)容沒有被篡改。打個(gè)比方Token 就像一張蓋了公章的通行證Header 和 Payload 是證件上印的內(nèi)容Signature 是公章。證件內(nèi)容誰都能讀Base64 只編碼不加密但沒有對(duì)應(yīng)的章密鑰任何人改動(dòng)內(nèi)容后都補(bǔ)不上新章。所以 JWT 適合做認(rèn)證而非保密手機(jī)號(hào)、密碼、身份證這類敏感信息絕不要往 Payload 里塞。一次完整的認(rèn)證流程圍繞下面幾個(gè)動(dòng)作展開客戶端先調(diào)用登錄接口提交用戶名密碼服務(wù)端校驗(yàn)通過后用密鑰把一堆用戶聲明打包簽成 Token 返回客戶端保存 Token后續(xù)請(qǐng)求在 Authorization 請(qǐng)求頭帶上Bearer Token服務(wù)端通過認(rèn)證中間件驗(yàn)證簽名、簽發(fā)者、受眾和有效期通過后把用戶身份掛到 HttpContext.User 上之后的授權(quán)判斷和業(yè)務(wù)代碼直接讀取即可。這套流程看起來簡單但真正落到 .Net Core 2.1 項(xiàng)目里有很多容易栽跟頭的工程細(xì)節(jié)比如 NuGet 包版本、中間件注冊(cè)順序、Claim 類型映射、時(shí)鐘偏移。這些細(xì)節(jié)散落在官方文檔和不同版本的博客里說法還經(jīng)?;ハ嗝苷沓赏暾倪^程記錄比零散搜索效率高得多。下面從環(huán)境準(zhǔn)備開始把每一步代碼和選擇背后的理由講清楚。2. 項(xiàng)目準(zhǔn)備目標(biāo)框架、NuGet 包與配置項(xiàng)設(shè)計(jì)2.1 創(chuàng)建項(xiàng)目與確認(rèn) .Net Core 2.1 的依賴版本很多人在 2.1 項(xiàng)目里加 JWT 失敗第一個(gè)坑就是 NuGet 包版本裝錯(cuò)。.Net Core 2.1 項(xiàng)目文件里TargetFramework要寫成netcoreapp2.1Web API 模板默認(rèn)引用Microsoft.AspNetCore.App元包但 JWT Bearer 認(rèn)證中間件并不在元包里需要單獨(dú)安裝dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 2.1.2如果使用 Visual Studio 的包管理器搜索Microsoft.AspNetCore.Authentication.JwtBearer后一定要注意版本選擇 2.1.x 系列不要順手選了 3.x、5.x 甚至更高版本。裝錯(cuò)版本的直接后果是程序集沖突編譯階段就是一片紅色波浪線而且報(bào)錯(cuò)信息晦澀難懂排查起來非常浪費(fèi)時(shí)間。安裝完成后項(xiàng)目里會(huì)出現(xiàn)兩個(gè)關(guān)鍵依賴Microsoft.AspNetCore.Authentication.JwtBearer提供認(rèn)證中間件System.IdentityModel.Tokens.Jwt提供JwtSecurityTokenHandler這個(gè)生成和解析 Token 的核心類。如果老項(xiàng)目之前裝過System.IdentityModel.Tokens.Jwt的舊版本建議統(tǒng)一升到 5.3.0 以上否則個(gè)別簽名相關(guān) API 會(huì)找不到行為表現(xiàn)也很不穩(wěn)定。2.2 appsettings.json 里的認(rèn)證參數(shù)怎么設(shè)計(jì)JWT 認(rèn)證需要配置的核心參數(shù)有四個(gè)簽發(fā)者 Issuer、受眾 Audience、簽名密鑰 SecretKey、Token 有效期 TokenExpireMinutes。把它們單獨(dú)放進(jìn)一個(gè)配置節(jié)方便后續(xù)替換和讀取{ AuthSettings: { Issuer: MyAuthServer, Audience: MyApiClient, SecretKey: Pssw0rd_Strong_Secret_Key_Replace_Me_2024, TokenExpireMinutes: 30 } }Issuer 表示 Token 由哪個(gè)服務(wù)簽發(fā)驗(yàn)證端的 ValidIssuer 必須和生成端完全一致Audience 表示 Token 面向哪個(gè)客戶端系統(tǒng)前后端分離項(xiàng)目里通常填前端應(yīng)用名或網(wǎng)關(guān)名。這兩個(gè)參數(shù)看似簡單作用卻很關(guān)鍵它們限定 Token 的使用范圍防止某個(gè)服務(wù)簽發(fā)的 Token 被另一個(gè)毫不相關(guān)的服務(wù)無條件接受相當(dāng)于給憑證劃定了活動(dòng)邊界。TokenExpireMinutes 直接影響安全性和體驗(yàn)。內(nèi)部管理后臺(tái)可以放寬到 60 分鐘面向 C 端的接口建議 15 到 30 分鐘。時(shí)間太長Token 泄露后的風(fēng)險(xiǎn)窗口就大時(shí)間太短用戶頻繁被踢下線口碑也受影響。很多團(tuán)隊(duì)后續(xù)會(huì)引入 Refresh Token 機(jī)制平衡這兩點(diǎn)但有效期這個(gè)參數(shù)最好從一開始就做成配置項(xiàng)不要寫死在代碼里。2.3 密鑰的安全生成方法與長度要求密鑰是整個(gè) JWT 方案里最敏感的資源。HS256 算法要求密鑰字節(jié)長度至少 128 位16 個(gè)字節(jié)否則運(yùn)行時(shí)拋異常常見的錯(cuò)誤類型是ArgumentOutOfRangeException提示信息里通常帶IDX10708類似編號(hào)大意是 SecurityKey 的 KeySize 不能小于 128 位。生成 Token 時(shí)報(bào)錯(cuò)的人特別多基本都是隨手敲了一段很短的字符串當(dāng)密鑰。我一般建議用 32 字節(jié)以上的隨機(jī)串既滿足安全強(qiáng)度也避免未來調(diào)整算法時(shí)長度不夠用。生成方式可以這樣在 PowerShell 里執(zhí)行[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 }) -as [byte[]])把產(chǎn)出的 Base64 字符串填入配置項(xiàng)注意不要把它提交到 Git 倉庫。我的習(xí)慣是開發(fā)環(huán)境放在 appsettings.Development.json生產(chǎn)環(huán)境從環(huán)境變量或部署平臺(tái)的密鑰管理服務(wù)讀取比如在讀取配置時(shí)做一層兜底var secretKey Environment.GetEnvironmentVariable(AUTH_SECRET_KEY) ?? Configuration[AuthSettings:SecretKey];還要強(qiáng)調(diào)一點(diǎn)JWT 的 HS256 屬于對(duì)稱簽名簽發(fā)方和驗(yàn)證方共用同一個(gè)密鑰。如果項(xiàng)目里有多個(gè)服務(wù)要互相驗(yàn)證 Token密鑰的分發(fā)、輪換必須提前定好規(guī)范不能把明文密鑰外傳或?qū)懺诖a注釋里否則整個(gè)認(rèn)證體系等于沒有防線。3. 核心實(shí)現(xiàn)Token 生成、認(rèn)證注冊(cè)與中間件接線3.1 登錄接口里的 Token 生成邏輯Token 生成的核心是先用用戶信息構(gòu)造一組 Claim聲明再用密鑰簽名。為了代碼整潔我先定義一個(gè)配置實(shí)體類方便依賴注入和測(cè)試復(fù)用public class AuthSettings { public string Issuer { get; set; } public string Audience { get; set; } public string SecretKey { get; set; } public int TokenExpireMinutes { get; set; } }在登錄接口中用戶密碼校驗(yàn)通過后把用戶信息封裝成 Claim 數(shù)組然后創(chuàng)建并序列化 Tokenprivate string GenerateToken(UserAuthModel user, AuthSettings settings) { var claims new[] { new Claim(ClaimTypes.NameIdentifier, user.UserId.ToString()), new Claim(ClaimTypes.Name, user.UserName), new Claim(ClaimTypes.Role, user.Role), new Claim(department, user.Department), }; var key new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings.SecretKey)); var credentials new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var now DateTime.UtcNow; var token new JwtSecurityToken( issuer: settings.Issuer, audience: settings.Audience, claims: claims, notBefore: now, expires: now.AddMinutes(settings.TokenExpireMinutes), signingCredentials: credentials); return new JwtSecurityTokenHandler().WriteToken(token); }這里有幾個(gè)容易被忽略的細(xì)節(jié)。Claim 相當(dāng)于是 Token 里聲明的字段建議只放認(rèn)證和授權(quán)需要的最小信息比如用戶 ID、用戶名、角色、部門手機(jī)號(hào)身份證這類敏感信息絕對(duì)不要放進(jìn)去因?yàn)?JWT 的 Payload 只是 Base64 編碼任何拿到 Token 的人都能直接解碼讀出內(nèi)容。notBefore表示生效時(shí)間expires表示過期時(shí)間統(tǒng)一用DateTime.UtcNow避免服務(wù)器時(shí)區(qū)差異導(dǎo)致 Token 生效時(shí)間錯(cuò)亂。SecurityAlgorithms.HmacSha256是這次使用的對(duì)稱簽名算法也可以換 HmacSha512但 HS256 在各語言平臺(tái)上的解析庫兼容性最好跨服務(wù)調(diào)用時(shí)不建議為了追求強(qiáng)度隨意換算法。WriteToken負(fù)責(zé)把內(nèi)存里的 Token 對(duì)象序列化成最終字符串這個(gè)字符串就是客戶端要保存并在后續(xù)請(qǐng)求中攜帶的憑證。3.2 Startup.ConfigureServices 里的認(rèn)證服務(wù)注冊(cè)生成 Token 只完成了一半關(guān)鍵的另一半是讓服務(wù)端自動(dòng)驗(yàn)證每個(gè)請(qǐng)求攜帶的 Token。這個(gè)能力要在 Startup 的ConfigureServices里注冊(cè)public void ConfigureServices(IServiceCollection services) { services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1); var authSettings Configuration.GetSection(AuthSettings).GetAuthSettings(); services.AddSingleton(authSettings); var key new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authSettings.SecretKey)); services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options { options.TokenValidationParameters new TokenValidationParameters { ValidateIssuer true, ValidIssuer authSettings.Issuer, ValidateAudience true, ValidAudience authSettings.Audience, ValidateIssuerSigningKey true, IssuerSigningKey key, ValidateLifetime true, ClockSkew TimeSpan.FromSeconds(30) }; }); }AddAuthentication注冊(cè)認(rèn)證框架AddJwtBearer把具體的認(rèn)證處理器掛上去。TokenValidationParameters是驗(yàn)證參數(shù)集合每一行都對(duì)應(yīng)一個(gè)獨(dú)立的校驗(yàn)動(dòng)作。ValidateIssuer、ValidateAudience、ValidateIssuerSigningKey 這三項(xiàng)建議全部顯式打開不要偷懶只留一個(gè)簽名校驗(yàn)否則將來做安全審計(jì)的時(shí)候很難交代清楚。ClockSkew很多人不重視。分布式系統(tǒng)里不同服務(wù)器時(shí)間可能有微小偏差所以認(rèn)證處理默認(rèn)允許長達(dá) 5 分鐘的時(shí)鐘偏移也就是說 Token 過期后最長 5 分鐘內(nèi)依然可能被接受。默認(rèn)值對(duì)普通業(yè)務(wù)無所謂但對(duì)支付、改密這類敏感接口5 分鐘的安全窗口過大了。實(shí)際項(xiàng)目里我統(tǒng)一調(diào)成 30 秒既不敏感也能扛住正常的時(shí)鐘抖動(dòng)。3.3 中間件管道的順序UseAuthentication 到底該放哪光注冊(cè)服務(wù)還不夠還需要在請(qǐng)求管線上啟用認(rèn)證中間件。這一步最容易翻車因?yàn)槠脚_(tái)不會(huì)報(bào)錯(cuò)只是所有帶 Token 的請(qǐng)求都驗(yàn)證不過。.Net Core 2.1 的 Startup 中Configure方法里正確的順序是這樣public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseAuthentication(); app.UseMvc(); }原則一句話UseAuthentication必須在UseMvc之前??梢赃@樣理解中間件是一串排隊(duì)執(zhí)行的關(guān)卡Mvc 一旦接管請(qǐng)求后續(xù)關(guān)卡就沒機(jī)會(huì)執(zhí)行了。把認(rèn)證放在 Mvc 前面請(qǐng)求在進(jìn)入路由和控制器之前Token 就已經(jīng)被解析好并把用戶身份寫進(jìn) HttpContext.User控制器里的[Authorize]判斷身份才有據(jù)可依。如果不寫UseAuthentication()會(huì)出現(xiàn)一個(gè)非常迷惑的現(xiàn)象生成 Token 正常、配置文件看著也沒問題但訪問受保護(hù)接口永遠(yuǎn) 401。原因就是認(rèn)證中間件根本沒有運(yùn)行HttpContext.User 一直是空的授權(quán)過濾器永遠(yuǎn)把請(qǐng)求當(dāng)成匿名訪問處理。排查這種問題時(shí)先檢查中間件順序往往比啃代碼快得多。3.4 抽取 JwtTokenHelper 工具類方便復(fù)用生成 Token 的邏輯如果在登錄接口寫一遍在重置密碼、第三方登錄之類的接口里再寫一遍代碼很快會(huì)散落得到處都是。我的做法是抽一個(gè)靜態(tài)工具類把密鑰計(jì)算和 Token 生成的邏輯收斂到一處public static class JwtTokenHelper { public static string CreateToken( IEnumerableClaim claims, AuthSettings settings) { var key new SymmetricSecurityKey( Encoding.UTF8.GetBytes(settings.SecretKey)); var credentials new SigningCredentials( key, SecurityAlgorithms.HmacSha256); var now DateTime.UtcNow; var token new JwtSecurityToken( issuer: settings.Issuer, audience: settings.Audience, claims: claims, notBefore: now, expires: now.AddMinutes(settings.TokenExpireMinutes), signingCredentials: credentials); return new JwtSecurityTokenHandler().WriteToken(token); } public static SymmetricSecurityKey GetSecurityKey(string secretKey) { return new SymmetricSecurityKey( Encoding.UTF8.GetBytes(secretKey)); } }這樣登錄接口只需要構(gòu)造 Claim 數(shù)組然后調(diào)用JwtTokenHelper.CreateToken(...)其他接口想要生成輔助 Token 時(shí)也一樣。抽成靜態(tài)類在這個(gè)場景下足夠簡單沒有內(nèi)部狀態(tài)需要管理沒必要引入額外的注入服務(wù)。更重要的是后面寫集成測(cè)試時(shí)需要快速生成合法 Token這個(gè)抽取能直接復(fù)用同一套生成邏輯保證測(cè)試數(shù)據(jù)和生產(chǎn)環(huán)境的行為完全一致。4. 保護(hù)接口并在 Postman 里完成實(shí)測(cè)4.1 用 [Authorize] 保護(hù)接口并讀取當(dāng)前用戶信息認(rèn)證注冊(cè)完成后給需要保護(hù)的 Controller 或 Action 加上[Authorize]特性即可。比如一個(gè)查詢用戶資料的接口[Route(api/[controller])] [ApiController] public class UserController : ControllerBase { [HttpGet(profile)] [Authorize] public IActionResult GetProfile() { var userId User.FindFirst(ClaimTypes.NameIdentifier)?.Value; var userName User.Identity.Name; var role User.FindFirst(ClaimTypes.Role)?.Value; var department User.FindFirst(department)?.Value; return Ok(new { UserId userId, UserName userName, Role role, Department department }); } }User.Identity.Name底層就是在讀 Name 類型的 Claim??刂破骼镒x取當(dāng)前用戶身份不再需要查數(shù)據(jù)庫所有信息都來自認(rèn)證中間件解析好的 ClaimsPrincipal這在微服務(wù)場景下非常舒服每個(gè)下游服務(wù)不用維護(hù)用戶表也能知道當(dāng)前操作者是誰、屬于什么角色。同時(shí)要注意[Authorize]只表示必須登錄并不限制誰能訪問。角色維度的限制要用[Authorize(Roles Admin)]。另外登錄接口本身必須顯式加[AllowAnonymous]否則會(huì)形成死循環(huán)沒 Token 進(jìn)不去接口而 Token 正是從接口里拿的。4.2 Postman 加 Token 的標(biāo)準(zhǔn)姿勢(shì)接口寫完后我用 Postman 做驗(yàn)證。第一次跑的時(shí)候最容易犯的錯(cuò)是Token 確實(shí)生成出來了但請(qǐng)求頭沒帶對(duì)。JWT 的標(biāo)準(zhǔn)攜帶方式是 HTTP Authorization 請(qǐng)求頭值必須是Bearer Token格式Bearer 和 Token 之間有一個(gè)空格Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Postman 里也可以直接用 Authorization 選項(xiàng)卡類型選擇 Bearer Token把生成的 Token 粘進(jìn)去發(fā)請(qǐng)求時(shí)它會(huì)自動(dòng)生成正確的請(qǐng)求頭。建議兩種方式都試一遍能加深對(duì) Header 格式的理解。Token 內(nèi)容可以用 jwt.io 這類在線工具解碼檢查一下 Claims 有沒有寫對(duì)但注意只拿調(diào)試環(huán)境的 Token 做這種事生產(chǎn) Token 絕不貼到第三方網(wǎng)站上。4.3 不帶 Token、帶錯(cuò)誤 Token、Token 過期三種場景的響應(yīng)對(duì)比實(shí)測(cè)不能只看成功路徑。我習(xí)慣把典型場景整理成表格逐個(gè)打一遍確認(rèn)整套配置真正生效下面這張表可以直接對(duì)應(yīng)到 Postman 的測(cè)試用例里測(cè)試場景請(qǐng)求頭預(yù)期狀態(tài)碼說明不帶 Token無401匿名訪問被攔截帶有效 TokenBearer 有效 Token200正常通過Token 內(nèi)容被篡改Bearer 改過 Payload 的 Token401簽名校驗(yàn)失敗Token 已過期Bearer 過期 Token401生命周期校驗(yàn)失敗密鑰不一致Bearer 其他密鑰簽發(fā)的 Token401簽名校驗(yàn)失敗Bearer 前綴寫錯(cuò)直接放 Token 值401認(rèn)證處理器識(shí)別不了我在實(shí)測(cè)中遇到過一個(gè)很隱蔽的問題Token 內(nèi)容復(fù)制對(duì)了、Bearer 也寫了結(jié)果還是 401。后來發(fā)現(xiàn)是復(fù)制 Token 時(shí)末尾多帶了一個(gè)換行符命令行和腳本環(huán)境下經(jīng)常出現(xiàn)這種隱形字符。遇到 401 先別急著改代碼把請(qǐng)求頭原樣打印出來逐字符檢查一遍低級(jí)問題往往藏在這種地方。5. 這套 JWT 方案在真實(shí)項(xiàng)目中踩過的坑5.1 Claim 被自動(dòng)重映射DefaultInboundClaimTypeMap 的坑這是 .Net Core 2.x 時(shí)代最有名的一個(gè)坑。默認(rèn)情況下JwtSecurityTokenHandler內(nèi)部維護(hù)著入站 Claim 類型映射表解析 Token 時(shí)會(huì)把短類型名自動(dòng)改寫成ClaimTypes里的長 URI 類型。例如 Token 里的nameid會(huì)映射成ClaimTypes.NameIdentifierunique_name映射成ClaimTypes.Name。這個(gè)映射平時(shí)很省事但一旦你混用兩種風(fēng)格就容易翻車。生成 Token 時(shí)用new Claim(user_id, ...)這類自定義短名解析時(shí)用User.FindFirst(user_id)本來沒問題但如果短名恰好撞進(jìn)映射表系統(tǒng)會(huì)偷偷改成另一個(gè)類型名查詢結(jié)果就是 null。有人排查一整天最后發(fā)現(xiàn)是email被映射成了ClaimTypes.Email而代碼里是按短名取的。實(shí)際項(xiàng)目里我的建議是二選一別兩邊摻著用。要么全程用ClaimTypes常量生成和讀取靠默認(rèn)映射工作要么在 Startup 構(gòu)造階段把兩張映射表清空完全按短名讀寫JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); JwtSecurityTokenHandler.DefaultOutboundClaimTypeMap.Clear();清空之后行為更可預(yù)測(cè)尤其適合多個(gè)語言服務(wù)共用一個(gè) JWT 標(biāo)準(zhǔn)的團(tuán)隊(duì)大家一律按標(biāo)準(zhǔn)短名讀寫不需要了解 .Net 內(nèi)部的 URI 命名空間。還要記住像department這樣的自定義字段不在映射表里讀寫都按寫入時(shí)的字符串進(jìn)行不受這個(gè)坑影響。5.2 時(shí)鐘偏差導(dǎo)致 Token 提前失效或過期不靈敏不配置ClockSkew時(shí)默認(rèn)值是 5 分鐘。這意味著一個(gè)有效期為 30 分鐘的 Token最長實(shí)際有效 35 分鐘反過來notBefore也帶同樣的偏移。普通業(yè)務(wù)里這問題不大但涉及支付、密碼重置等敏感操作時(shí)過期 Token 還能被接受 5 分鐘審計(jì)一般過不了。另一個(gè)關(guān)聯(lián)坑是ValidateLifetime必須顯式設(shè)成 true。有人抄配置時(shí)漏了這行結(jié)果 Token 早已越過過期時(shí)間卻依然被當(dāng)作有效憑證因?yàn)樾r?yàn)器根本沒去查生命周期。這兩個(gè)參數(shù)要一起看缺一個(gè)都會(huì)造成“過期時(shí)間形同虛設(shè)”的結(jié)果。我在生產(chǎn)代碼里統(tǒng)一設(shè)ValidateLifetime true、ClockSkew TimeSpan.FromSeconds(30)既不敏感也不至于因?yàn)榉?wù)器時(shí)鐘的小幅抖動(dòng)影響正常用戶。還有一個(gè)容易被忽視的細(xì)節(jié)生成 Token 時(shí)如果用DateTime.Now而不是DateTime.UtcNow在時(shí)區(qū)設(shè)置不同的服務(wù)器上exp、nbf會(huì)比預(yù)期早或晚幾個(gè)小時(shí)表現(xiàn)就是“Token 明明沒過期卻提示未生效”。這個(gè)問題很隱蔽癥狀還隨機(jī)建議所有時(shí)間相關(guān)操作一律走 UTC。5.3 401、403、500 的區(qū)分與常見定位手段受保護(hù)接口出問題第一件事不是改代碼而是分清返回碼。401 表示未認(rèn)證說明請(qǐng)求根本沒有被識(shí)別成登錄用戶常見原因是沒有UseAuthentication()、請(qǐng)求頭格式不對(duì)、Token 無效或過期403 表示已認(rèn)證但權(quán)限不足通常是角色 Claim 缺失或[Authorize(Roles ...)]配置不對(duì)500 則說明處理時(shí)拋了異常比如密鑰長度不足、讀取 Claim 時(shí)空引用等。定位手段按順序來先從 Postman 確認(rèn)請(qǐng)求頭是不是Bearer Token格式再看 Startup 里認(rèn)證中間件和 Mvc 的順序然后看服務(wù)端日志。JwtBearer 支持通過事件鉤子把認(rèn)證失敗的具體原因暴露出來配置方式如下options.Events new JwtBearerEvents { OnAuthenticationFailed context { if (context.Exception is SecurityTokenExpiredException) { // Token 已過期可以打日志或者單獨(dú)記錄 } return Task.CompletedTask; }, OnChallenge context { // 默認(rèn)返回空 body 的 401這里可以改成統(tǒng)一 JSON 結(jié)構(gòu) return Task.CompletedTask; } };OnAuthenticationFailed里能看到具體異常類型可以快速區(qū)分過期、簽名失敗還是格式錯(cuò)誤。OnChallenge在觸發(fā) 401 時(shí)執(zhí)行很多團(tuán)隊(duì)在這里改寫響應(yīng)結(jié)構(gòu)把默認(rèn)的空 body 換成{ code 401, message unauthorized }這類統(tǒng)一格式前后端聯(lián)調(diào)時(shí)會(huì)省很多溝通成本。5.4 集成測(cè)試?yán)镌趺纯焖偕捎行?Token改造完認(rèn)證后我還會(huì)順手寫兩個(gè)集成測(cè)試防止后續(xù)改動(dòng)把認(rèn)證鏈路弄壞。.Net Core 2.1 項(xiàng)目里可以用Microsoft.AspNetCore.TestHost創(chuàng)建內(nèi)存測(cè)試服務(wù)器不需要真的對(duì)外監(jiān)聽端口。測(cè)試代碼里最關(guān)鍵的就是快速造出合法 Token。既然前面已經(jīng)抽取了JwtTokenHelper這里直接復(fù)用生成邏輯[Fact] public async Task GetProfile_With_ValidToken_ReturnsOk() { var server new TestServer(new WebHostBuilder() .UseStartupStartup()); var client server.CreateClient(); var token JwtTokenHelper.CreateToken( new[] { new Claim(ClaimTypes.NameIdentifier, 10001), new Claim(ClaimTypes.Name, tester), new Claim(ClaimTypes.Role, Admin) }, testSettings); client.DefaultRequestHeaders.Authorization new AuthenticationHeaderValue(Bearer, token); var response await client.GetAsync(/api/user/profile); Assert.Equal(HttpStatusCode.OK, response.StatusCode); }testSettings里配置的密鑰必須和測(cè)試服務(wù)啟動(dòng)時(shí)讀到的配置一致否則簽名一定對(duì)不上。這個(gè)測(cè)試寫法比把網(wǎng)站真正跑起來更快、更穩(wěn)定CI 環(huán)境里也不會(huì)受端口沖突影響。界定好正常路徑、401 攔截路徑、角色校驗(yàn)路徑三組用例認(rèn)證鏈路基本就穩(wěn)了。最后再說一句實(shí)際操作中的體會(huì)JWT 本身不算復(fù)雜復(fù)雜的是圍繞它的工程化細(xì)節(jié)——密鑰管理、過期策略、事件日志、統(tǒng)一響應(yīng)體都要提前想清楚。尤其是 .Net Core 2.1 這類老版本項(xiàng)目資料少、坑又多把基礎(chǔ)鏈路和典型報(bào)錯(cuò)摸透之后后面不管是升級(jí)框架還是對(duì)接新服務(wù)都會(huì)輕松很多。我改造完這套之后又把同樣的模式復(fù)制到另外兩個(gè)老服務(wù)上一次比一次快就是因?yàn)榘芽佣继崆坝浟讼聛怼?