Jwt隱藏大坑,通過原始碼幫你揭祕

gui.h發表於2022-05-25

前言

JWT是目前最為流行的介面認證方案之一,有關JWT協議的詳細內容,請參考:https://jwt.io/introduction

今天分享一下在使用JWT在專案中遇到的一個問題,主要是一個協議的細節,非常容易被忽略,如果不是自己遇到,或者去看原始碼的實現,我估計至少80%的人都會栽在這裡,下面來還原一下這個問題的過程,由於這個問題出現有一定的概率,不是每次都會出現,所以才容易掉坑裡。

整合JWT

在Asp.Net Core中整合JWT認證的方式在網路上隨便一搜就能找到一堆,主要有兩個步驟:

  1. 在IOC容器中注入依賴
public void ConfigureServices(IServiceCollection services)
{
    // 新增這一行新增jwt驗證:
    services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options => {
            options.TokenValidationParameters = new TokenValidationParameters
            {
                ValidateIssuer = true,//是否驗證Issuer
                ValidateAudience = true,//是否驗證Audience
                ValidateLifetime = true,//是否驗證失效時間
                ClockSkew = TimeSpan.FromSeconds(30),
                ValidateIssuerSigningKey = true,//是否驗證SecurityKey
                ValidAudience = Const.Domain,//Audience
                ValidIssuer = Const.Domain,//Issuer,這兩項和前面簽發jwt的設定一致
                IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Const.SecurityKey))//拿到SecurityKey
            };
        });

}
  1. 應用認證中介軟體
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?}");
    });
}
  1. 在Controller
[Route("api/[controller]")]
[ApiController] // 新增這一行
public class MyBaseController : ControllerBase
{

}
  1. 提供一個認證的介面,用於前端獲取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拿出來進行解密了,之前也都沒問題。

  1. 是不是token有問題?
    經過測試,呼叫介面完全不會有問題,只是前端解密時報錯,排除token不合法。
  2. 前端的atob函式存在bug?
    那我們在後端用c#的base64解密一下看看:

    居然後端解密也報錯了,頭部解密成功,載荷部分解密異常,和前端報錯一樣都是說字串不是合法的base64內容,不知道你是不是偶爾遇到過這個問題,如果沒有,那你更要往下看了,不然以後遇到了,要耽誤不少時間去排查了。

檢視原始碼探索問題原因

上面遇到的問題曾經花了我不少時間去排查,關鍵是有工具能解密的還有工具不能解密,一時不知道到底是誰的問題了,抱著試試看的態度,看看原始碼生成token三部分的字串過程。

  1. 既然token是這個函式生成的,那就直接看它的實現,直接F12即可,這個包是不是框架自帶的,所以能直接通過vs看原始碼,比較方便的。

  2. 原始碼如下,encodedPayload根據它的命名不難看出是機密後的載荷,我們需要看的是它如何加密的

  3. 檢視jwtToken.EncodedPayload這個屬性怎麼來的(F12)

    圖中標記了三個數字:

    1. 上一步我們逆向找到加密後的屬性EncodedPayload
    1. EncodedPayload屬性裡面用到了另一個屬性Payload,我們需要找Payload哪裡賦值的
    1. Payload是在建構函式中根據傳參內容進行初始化的。
  1. 上一步我們已經鎖定進加密的邏輯在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加密後出現字元+/時,才會有所不同,希望對大家有幫助。

相關文章