前言
JWT是目前最為流行的接口認證方案之一,有關JWT協議的詳細內容,請參考:https://jwt.io/introduction
今天分享一下在使用JWT
在項目中遇到的一個問題,主要是一個協議的細節,非常容易被忽略,如果不是自己遇到,或者去看源碼的實現,我估計至少80%的人都會栽在這里,下面來還原一下這個問題的過程,由于這個問題出現有一定的概率,不是每次都會出現,所以才容易掉坑里。
集成JWT
在Asp.Net Core中集成JWT
認證的方式在網絡上隨便一搜就能找到一堆,主要有兩個步驟:
在IOC容器中注入依賴
public?void?ConfigureServices(IServiceCollection?services)
{//?添加這一行添加jwt驗證:services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options?=>?{options.TokenValidationParameters?=?new?TokenValidationParameters{ValidateIssuer?=?true,//是否驗證IssuerValidateAudience?=?true,//是否驗證AudienceValidateLifetime?=?true,//是否驗證失效時間ClockSkew?=?TimeSpan.FromSeconds(30),ValidateIssuerSigningKey?=?true,//是否驗證SecurityKeyValidAudience?=?Const.Domain,//AudienceValidIssuer?=?Const.Domain,//Issuer,這兩項和前面簽發jwt的設置一致IssuerSigningKey?=?new?SymmetricSecurityKey(Encoding.UTF8.GetBytes(Const.SecurityKey))//拿到SecurityKey};});}
應用認證中間件
public?void?Configure(IApplicationBuilder?app,?IHostingEnvironment?env)
{//?添加這一行?使用認證中間件app.UseAuthentication();if?(env.IsDevelopment()){app.UseDeveloperExceptionPage();}app.UseMvc(routes?=>{routes.MapRoute(name:?"default",template:?"{controller=Home}/{action=Index}/{id?}");});
}
在Controller
[Route("api/[controller]")]
[ApiController]?//?添加這一行
public?class?MyBaseController?:?ControllerBase
{}
提供一個認證的接口,用于前端獲取token
[AllowAnonymous]
[HttpGet]
public?IActionResult?Get(string?userName,?string?pwd)
{if?(!string.IsNullOrEmpty(userName)?&&?!string.IsNullOrEmpty(pwd)){var?claims?=?new[]{new?Claim(JwtRegisteredClaimNames.Nbf,$"{new?DateTimeOffset(DateTime.Now).ToUnixTimeSeconds()}")?,new?Claim?(JwtRegisteredClaimNames.Exp,$"{new?DateTimeOffset(DateTime.Now.AddMinutes(30)).ToUnixTimeSeconds()}"),new?Claim(ClaimTypes.Name,?userName)};var?key?=?new?SymmetricSecurityKey(Encoding.UTF8.GetBytes(Const.SecurityKey));var?creds?=?new?SigningCredentials(key,?SecurityAlgorithms.HmacSha256);var?token?=?new?JwtSecurityToken(issuer:?Const.Domain,audience:?Const.Domain,claims:?claims,expires:?DateTime.Now.AddMinutes(30),signingCredentials:?creds);return?Ok(new{token?=?new?JwtSecurityTokenHandler().WriteToken(token)});}else{return?BadRequest(new?{?message?=?"username?or?password?is?incorrect."?});}
}
至此,你的應用已經完成了集成JWT
認證。
本文為
Gui.H
原創文章,更多高質量博文,歡迎關注公眾號dotnet之美
。
坑在哪里
直接上代碼,下面這段代碼是我用來能復現該大坑的示例,有空的可以按照該代碼重現下面的問題。
using?Microsoft.IdentityModel.Tokens;
using?System.IdentityModel.Tokens.Jwt;
using?System.Security.Claims;
using?System.Text;var?SecurityKey?=?"MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDI2a2EJ7m872v0afyoSDJT2o1+SitIeJSWtLJU8/Wz2m7gStexajkeD+Lka6DSTy8gt9UwfgVQo6uKjVLG5Ex7PiGOODVqAEghBuS7JzIYU5RvI543nNDAPfnJsas96mSA7L/mD7RTE2drj6hf3oZjJpMPZUQI/B1Qjb5H3K3PNwIDAQAB";
var?Domain?=?"http://localhost:5000";var?email?=?"username@qq.com";
var?userName?=?"阿哈";var?claims?=?new[]
{new?Claim(JwtRegisteredClaimNames.Nbf,$"{new?DateTimeOffset(DateTime.Now).ToUnixTimeSeconds()}")?,new?Claim?(JwtRegisteredClaimNames.Exp,$"{new?DateTimeOffset(DateTime.Now.AddMinutes(30)).ToUnixTimeSeconds()}"),new?Claim("Name",?userName),new?Claim("Email",?email),};var?key?=?new?SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecurityKey));
var?creds?=?new?SigningCredentials(key,?SecurityAlgorithms.HmacSha256);
var?token?=?new?JwtSecurityToken(issuer:?Domain,audience:?Domain,claims:?claims,expires:?DateTime.Now.AddMinutes(30),signingCredentials:?creds);var?JWTToken?=?new?JwtSecurityTokenHandler().WriteToken(token);Console.WriteLine(JWTToken);Console.ReadLine();
上面代碼運行的結果是:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImN0eSI6IkpXVCJ9.eyJuYmYiOiIxNjUzNDAwNjk0IiwiZXhwIjoxNjUzNDAyNDk0LCJOYW1lIjoi6Zi_5ZOIIiwiRW1haWwiOiJ1c2VybmFtZUBxcS5jb20iLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAifQ.RBtP7zroK7YueGlDdZNHGy3tT8-xcGkf8ZyiTL81w2I
我們知道Token由三部分組成,使用.
分割,如果是標準的Jwt協議加密的,那這三部分均為Base64加密(此處不準確,下文解釋為什么),也可以說就是明文,我們將三部分內容進行Base64解密看看。
我們在線驗證一下我們的Jwt是否符合標準:打開網站:https://jwt.io/
,選擇頂部菜單的Debugger
,將我們的token填進去:
然后將代碼中用的
SecurityKey
填到圖中標記的位置
顯示簽名認證通過。
頭
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImN0eSI6IkpXVCJ9
{??"alg":?"HS256",??"typ":?"JWT",??"cty":?"JWT"?}
載荷
eyJuYmYiOiIxNjUzNDAwNjk0IiwiZXhwIjoxNjUzNDAyNDk0LCJOYW1lIjoi6Zi_5ZOIIiwiRW1haWwiOiJ1c2VybmFtZUBxcS5jb20iLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAifQ
{"nbf":?"1653400694","exp":?1653402494,"Name":?"阿哈","Email":?"username@qq.com","iss":?"http://localhost:5000","aud":?"http://localhost:5000"
}
簽名
RBtP7zroK7YueGlDdZNHGy3tT8-xcGkf8ZyiTL81w2I
到目前為止一切都十分順利。
既然Token的內容前端直接可以通過base64解密出來,那在需要展示用戶名的地方,我們就可以直接解析token的載荷,然后獲得Name
,下面是使用在線base64工具解密上面的token載荷內容,可以看到用戶名為啊哈
。邏輯沒有任何問題,那就開始前端進行解析token中的用戶名用于展示在個人中心吧。下面是在
Vue3
框架和Piana
中的演示,window.atob
是瀏覽器自帶base64decode的方法
export?const?useUserStore?=?defineStore({id:?'user',state:?()?=>?{return?{token:?'',}},getters:?{accessToken:?(state)?=>?{return?state.accesstoken?||?localStorage.getItem("accesstoken");},/***?獲取token中解密后的用戶信息*/userInfo(state)?{var?token?=?state.token?||?localStorage.getItem("accesstoken");if?(!token?||?token?==?'')?{return?null;}var?json?=?window.atob(token.split(".")[1]);return?JSON.parse(json);}}
})
在需要獲取用戶名的地方使用
computed:{...mapState(useUserStore,?["userInfo"]),
}
感覺一切都很優雅的寫完了代碼,但是實際運行會報錯:這里為了方便是直接在瀏覽器的調式器中執行的報錯的意思來看是說我們的字符串沒用正確的加密(就是它說咱這個字符串不是合法的base64加密)。可是我們通過一些在線base64解密工具,還有Jwt的debugger工具都能解密出來明文。而且這不是我第一次將token拿出來進行解密了,之前也都沒問題。
是不是token有問題?經過測試,調用接口完全不會有問題,只是前端解密時報錯,排除token不合法。
前端的atob函數存在bug?那我們在后端用c#的base64解密一下看看:
居然后端解密也報錯了,頭部解密成功,載荷部分解密異常,和前端報錯一樣都是說字符串不是合法的base64內容,不知道你是不是偶爾遇到過這個問題,如果沒有,那你更要往下看了,不然以后遇到了,要耽誤不少時間去排查了。
查看源碼探索問題原因
上面遇到的問題曾經花了我不少時間去排查,關鍵是有工具能解密的還有工具不能解密,一時不知道到底是誰的問題了,抱著試試看的態度,看看源碼生成token三部分的字符串過程。
既然token是這個函數生成的,那就直接看它的實現,直接F12即可,這個包是不是框架自帶的,所以能直接通過vs看源碼,比較方便的。
源碼如下,
encodedPayload
根據它的命名不難看出是機密后的載荷,我們需要看的是它如何加密的查看
jwtToken.EncodedPayload
這個屬性怎么來的(F12)圖中標記了三個數字:
上一步我們逆向找到加密后的屬性
EncodedPayload
EncodedPayload
屬性里面用到了另一個屬性Payload
,我們需要找Payload
哪里賦值的
Payload
是在構造函數中根據傳參內容進行初始化的。
上一步我們已經鎖定進加密的邏輯在
Payload.Base64UrlEncode()
中,看JwtPayload
的類定義
可以看出,載荷的加密和我們想象的一樣簡單,把
JwtPayload
對象轉成Json
,然后進行Base64Url
加密 5. 現在只剩Base64UrlEncoder.Encode
的實現能為我們揭秘了整體看下類定義,我們調用的
Encode
按標記順序,依次調用了三個重載方法,最終實現都標記為3的那個方法。6. 不知道你有沒有注意到這些內容看到這里我恍然大悟了一點,再看看他這里面的decode方法
看見了吧,我們因為是單純的Base64加解密,其實不然,在進行
Convert.FromBase64String(decodedString)
解密前還需要進行一些字符串的替換,我趕緊看下上面出問題的載荷內容,發現其中有_
這個字符,我趕緊將其進行替換成+
,再次嘗試:
eyJuYmYiOiIxNjUzNDAwNjk0IiwiZXhwIjoxNjUzNDAyNDk0LCJOYW1lIjoi6Zi_5ZOIIiwiRW1haWwiOiJ1c2VybmFtZUBxcS5jb20iLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAifQ//?替換后
eyJuYmYiOiIxNjUzNDAwNjk0IiwiZXhwIjoxNjUzNDAyNDk0LCJOYW1lIjoi6Zi+5ZOIIiwiRW1haWwiOiJ1c2VybmFtZUBxcS5jb20iLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAifQ
果然如此,替換后解密成功了,只有一個漢字的編碼問題。
這下找到問題了,優化下前端的解密代碼
userInfo(state)?{var?token?=?state.token?||?localStorage.getItem("accesstoken");if?(!token?||?token?==?'')?{return?null;}token?=?token.replace("_",?"/").replace("-",?"+")?//?添加這一行var?json?=?window.atob(token.split(".")[1]);return?JSON.parse(json);}
問題解決了。
注意官方對加密過程的描述
哈哈,是不是草率了,并不是
Base64
加密~~
總結
我們都以為Jwt三部分是用Base64
加密,其實不完全對,因為他確切的加密方式是Base64Url
加密,沒有深入理解的我們只以為就是純粹的base64,而且在大部分情況下確實是這樣,更加堅定了我們這種錯誤認知。而只有當Base64加密后出現字符+
或/
時,才會有所不同,希望對大家有幫助。