理解ASP.NET Core - 基於JwtBearer的身份認證(Authentication)

xiaoxiaotank發表於2022-02-14

注:本文隸屬於《理解ASP.NET Core》系列文章,請檢視置頂部落格或點選此處檢視全文目錄

在開始之前,如果你還不瞭解基於Cookie的身份認證,那麼建議你先閱讀《基於Cookie的身份認證》後再閱讀本文。

另外,為了方便大家理解並能夠上手操作,我已經準備好了一個示例程式,請訪問XXTk.Auth.Samples.JwtBearer.HttpApi獲取原始碼。文章中的程式碼,基本上在示例程式中均有實現,強烈建議組合食用!

Jwt概述

Jwt是什麼

Jwt是一個開放行業標準(RFC7519),英文為Json Web Token,譯為“Json網路令牌”,它可以以緊湊、URL安全的方式在各方之間傳遞宣告(claims)。

在Jwt中,宣告會被編碼為Json物件,用作Jws(Json Web Signature)結構的負載(payload),或作為Jwe(Json Web Encryption)結構的明文,這就使得宣告可以使用MAC(Message Authentication Code)進行數字簽名或完整性保護和加密。

獲取更多資訊請訪問 https://jwt.io/
對jwt、jws、jwe有疑惑的請參考《一篇文章帶你分清楚JWT,JWS與JWE》

Jwt解決了什麼問題

跨站

傳統的cookie只能實現跨域,而不能實現跨站(如my.abc.com和you.xyz.com),而Jwt原生支援跨域、跨站,因為它要求每次請求時,都要在請求頭中攜帶token。

跨伺服器

在當前應用基本都是叢集部署的情況下,如果使用傳統cookie + session的認證方式,為了實現session跨伺服器共享,還必須引入分散式快取中介軟體。而Jwt不需要分散式快取中介軟體,因為它可以不儲存在伺服器端。

Native App友好

對於原生平臺(如iOS、Android、WP)的App,沒有瀏覽器的支援,Cookie喪失了它的優勢,而使用Jwt就很簡單。

Jwt的結構

先看一個Jwt示例:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJpYXQiOjE2NDI3NDg5OTIsIm5iZiI6MTY0Mjc0ODk5MiwiZXhwIjoxNjQyNzQ4OTkyLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJuYW1lIjoieGlhb3hpYW90YW5rIn0.nqJpZl48gnP4fv7NdsSD9JOn0VWq045Zcbmb91HMhwY

看起來就是很長一段毫無意義的亂碼,不過細心點,你會發現它被符號點(.)分隔為了3個部分,看起來就像這樣:

xxxxx.yyyyy.zzzzz

從左到右這3個部分稱為:頭部(Header)、載荷(Payload)和簽名(Signature)。

頭部(Header)

Header主要用於說明token型別和簽名演算法。

{ 
    "alg": "HS256",
    "typ": "JWT",
}
  • alg:簽名演算法,這裡是 HMAC SHA256
  • typ:token型別,這裡是JWT

對Header去除所有換行和空格後,得到:{"alg":"HS256","typ":"JWT"},接著對其進行Base64Url編碼,即可獲取到Token的第1部分

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

載荷(Payload)

Payload是核心,主要用於儲存宣告資訊,如token簽發者、使用者Id、使用者角色等。

{
	"iss": "http://localhost:5000",
	"iat": 1642748992,
	"nbf": 1642748992,
	"exp": 1642748992,
	"aud": "http://localhost:5000",
	"name": "xiaoxiaotank"
}

其中,前五個是預定義的:

  • iss:Issuer,即token的簽發者。
  • iat:Issued At,即token的簽發時間
  • exp:Expiration Time,即token的過期時間
  • aud:Audience,即受眾,指該token是服務於哪個群體的(群體範圍),或該token所授予的有許可權的資源是哪一塊(資源的uri)
  • nbf:Not Before,即在指定的時間點之前該token不可用

實際上,Jwt中的宣告可以分為以下三種型別:

  • Registered Claim:預定義宣告,雖然並非強制使用,但是推薦使用,包括 iss(Issuer)、sub(Subject)、aud(Audience)、exp(Expiration Time)、nbf(Not Before)、iat(Issued At)和jti(JWT ID)。可以看到,這些宣告名字都很短小,這是因為Jwt的核心目標是使表示緊湊。
  • Public Claim: 公共宣告,Jwt的使用者可以隨便定義,但是要避免和預定義宣告衝突。
  • Private Claim: 私有宣告,不同於公共宣告的是,私有宣告名稱可能會發生衝突,應該謹慎使用。

對Payload(記得去除所有換行和空格)進行Base64Url編碼,即可獲取到Token的第2部分

eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJpYXQiOjE2NDI3NDg5OTIsIm5iZiI6MTY0Mjc0ODk5MiwiZXhwIjoxNjQyNzQ4OTkyLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJuYW1lIjoieGlhb3hpYW90YW5rIn0

不要在Payload中儲存任何敏感資訊,因為Base64Url不是加密,只是編碼,所以這部分對於客戶端來說是明文。

簽名(Signature)

Signature主要用於防止token被篡改。當服務端獲取到token時,會按照如下演算法計算簽名,若計算出的與token中的簽名一致,才認為token沒有被篡改。

簽名演算法:

  • 先將Header和Payload通過點(.)連線起來,即Base64Url編碼的Header.Base64Url編碼的Payload,記為 text
  • 然後使用Header中指明的簽名演算法對text進行加密,得到一個二進位制陣列,記為 signBytes
  • 最後對 signBytes 進行Base64Url編碼,得到signature,即token的第三部分
nqJpZl48gnP4fv7NdsSD9JOn0VWq045Zcbmb91HMhwY

Jwt帶來了什麼問題

不安全

所謂的“不安全”,是指Jwt的Payload是明文(Base64Url編碼),因此其不能儲存敏感資料。

不過,我們可以針對生成的token,再進行一次加密,這樣相對會更加安全一些。不過無論如何,還是不如將資料儲存在服務端安全。

長度太長

通過前面的示例,你也看到了,雖然我們只在token中儲存了少量必要資訊,但是生成的token字串長度仍然很長。而使用者每次傳送請求時,都會攜帶這個token,在一定程度上來看,開銷是較大的,不過我們一般可以忽略這點效能開銷。

無狀態 & 一次性

jwt最大的特點是無狀態和一次性,這也就導致如果我們想要修改裡面的內容,必須重新簽發一個新的token。因此,也就引出了另外的兩個問題:

  • 無法手動過期
    如果我們想要使已簽發的jwt失效,除非達到它的過期時間,否則我們是無法手動讓其失效的。

  • 無法續簽
    假設我們簽發了一個有效時長30分鐘的token,使用者在這30分鐘內持續進行操作,當達到token的有效期時,我們希望能夠延長該token的有效期,而不是讓使用者重新登入。顯然,要實現這個效果,必須要重新簽發一個新的token,而不是在原token上操作。

Bearer概述

HTTP提供了一套標準的身份認證方案:當身份認證不通過時,服務端可以向客戶端傳送質詢(challenge),客戶端根據質詢提供身份驗證憑證進行應答。

質詢與應答的具體工作流程如下:當身份認證不通過時,服務端向客戶端返回HTTP狀態碼401(Unauthorized,未授權),並在WWW-Authenticate頭中新增如何提供認證憑據的資訊,其中至少包含有一種質詢方式。然後客戶端根據質詢,在請求頭中新增Authorization,它的值就是進行身份認證的憑證。

在HTTP標準認證方案中,大家可能比較熟悉的是BasicDigestBasic將使用者名稱密碼使用Base64編碼後作為認證憑證,而DigestBasic的基礎上針對安全性進行了升級,使得使用者密碼更加安全。在前文介紹的Cookie認證屬於Form認證,並不屬於HTTP標準認證方案。

而今天提到的Bearer,也屬於HTTP協議標準認證方案之一,詳見:RFC 6570

     +--------+                               +---------------+
     |        |--(A)- Authorization Request ->|   Resource    |
     |        |                               |     Owner     |
     |        |<-(B)-- Authorization Grant ---|               |
     |        |                               +---------------+
     |        |
     |        |                               +---------------+
     |        |--(C)-- Authorization Grant -->| Authorization |
     | Client |                               |     Server    |
     |        |<-(D)----- Access Token -------|               |
     |        |                               +---------------+
     |        |
     |        |                               +---------------+
     |        |--(E)----- Access Token ------>|    Resource   |
     |        |                               |     Server    |
     |        |<-(F)--- Protected Resource ---|               |
     +--------+                               +---------------+

                     Abstract Protocol Flow

Bearer認證中的憑據稱為Bearer Token,或稱為access token,標準請求格式為(新增到HTTP請求頭中):

Authorization: Bearer [Access Token]

另外,如果你對BasicDigest感興趣,推薦閱讀以下幾篇文章:

身份認證(Authentication)

前文已經講述過的身份認證中介軟體就不贅述了,我們們直接進入JwtBearer。

首先,通過Nuget安裝以下三個包:

Install-Package IdentityModel
Install-Package System.IdentityModel.Tokens.Jwt
Install-Package Microsoft.AspNetCore.Authentication.JwtBearer

接著,通過AddJwtBearer擴充套件方法新增JwtBearer認證方案:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
            {
                // 在這裡對該方案進行詳細配置
            });
    }
}

CookieAuthenticationDefaults類似,JwtBearer也提供了JwtBearerDefaults,不過它比較簡單,就只有一個AuthenticationScheme

public static class JwtBearerDefaults
{
    public const string AuthenticationScheme = "Bearer";
}

同樣地,我們可以通過options針對Jwt的驗證引數、驗證處理器、事件回撥等進行詳細配置。它的型別為JwtBearerOptions,繼承自AuthenticationSchemeOptions。下面會針對一些常用引數進行詳細講解(本文只介紹最簡單的jwt簽發和驗證,不涉及認證授權認證中心)。

在開始之前,先自定義一個選項類JwtOptions,將常用引數配置化:

public class JwtOptions
{
    public const string Name = "Jwt";
    public readonly static Encoding DefaultEncoding = Encoding.UTF8;
    public readonly static double DefaultExpiresMinutes = 30d;

    public string Audience { get; set; }

    public string Issuer { get; set; }
    
    public double ExpiresMinutes { get; set; } = DefaultExpiresMinutes;

    public Encoding Encoding { get; set; } = DefaultEncoding;

    public string SymmetricSecurityKeyString { get; set; }

    public SymmetricSecurityKey SymmetricSecurityKey => new(Encoding.GetBytes(SymmetricSecurityKeyString));
}

現在,我們無需關注各個引數的具體值是多少,直接看下方的方案配置:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.Configure<JwtOptions>(Configuration.GetSection(JwtOptions.Name));
        
        var jwtOptions = Configuration.GetSection(JwtOptions.Name).Get<JwtOptions>();
    
        services.AddSingleton(sp => new SigningCredentials(jwtOptions.SymmetricSecurityKey, SecurityAlgorithms.HmacSha256Signature));
    
        services.AddScoped<AppJwtBearerEvents>();
    
        services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
            {
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256, SecurityAlgorithms.RsaSha256 },
                    ValidTypes = new[] { JwtConstants.HeaderType },

                    ValidIssuer = jwtOptions.Issuer,
                    ValidateIssuer = true,

                    ValidAudience = jwtOptions.Audience,
                    ValidateAudience = true,

                    IssuerSigningKey = jwtOptions.SymmetricSecurityKey,
                    ValidateIssuerSigningKey = true,
                    
                    ValidateLifetime = true,
                    
                    RequireSignedTokens = true,
                    RequireExpirationTime = true,
                
                    NameClaimType = JwtClaimTypes.Name,
                    RoleClaimType = JwtClaimTypes.Role,

                    ClockSkew = TimeSpan.Zero,
                };

                options.SaveToken = true;

                options.SecurityTokenValidators.Clear();
                options.SecurityTokenValidators.Add(new JwtSecurityTokenHandler());

                options.EventsType = typeof(AppJwtBearerEvents);
            });
    }
}

其中,TokenValidationParameters是和token驗證有關的引數配置,進行token驗證時需要用到,下面看詳細說明:

  • TokenValidationParameters.ValidAlgorithms:有效的簽名演算法列表,即驗證Jwt的Header部分的alg。預設為null,即所有演算法均可。
  • TokenValidationParameters.ValidTypes:有效的token型別列表,即驗證Jwt的Header部分的typ。預設為null,即算有演算法均可。
  • TokenValidationParameters.ValidIssuer:有效的簽發者,即驗證Jwt的Payload部分的iss。預設為null
  • TokenValidationParameters.ValidIssuers:有效的簽發者列表,可以指定多個簽發者。
  • TokenValidationParameters.ValidateIssuer:是否驗證簽發者。預設為true。注意,如果設定了TokenValidationParameters.IssuerValidator,則該引數無論是何值,都會執行。
  • TokenValidationParameters.ValidAudience:有效的受眾,即驗證Jwt的Payload部分的aud。預設為null
  • TokenValidationParameters.ValidAudiences:有效的受眾列表,可以指定多個受眾。
  • TokenValidationParameters.ValidateAudience:是否驗證受眾。預設為true。注意,如果設定了TokenValidationParameters.AudienceValidator,則該引數無論是何值,都會執行。
  • TokenValidationParameters.IssuerSigningKey:用於驗證Jwt簽名的金鑰。對於對稱加密來說,加簽和驗籤都是使用的同一個金鑰;對於非對稱加密來說,使用私鑰加簽,然後使用公鑰驗籤。
  • TokenValidationParameters.ValidateIssuerSigningKey:是否使用驗證金鑰驗證簽名。預設為false。注意,如果設定了TokenValidationParameters.IssuerSigningKeyValidator,則該引數無論是何值,都會執行。
  • TokenValidationParameters.ValidateLifetime:是否驗證token是否在有效期內,即驗證Jwt的Payload部分的nbfexp
  • TokenValidationParameters.RequireSignedTokens: 是否要求token必須進行簽名。預設為true,即token必須簽名才可能有效。
  • TokenValidationParameters.RequireExpirationTime:是否要求token必須包含過期時間。預設為true,即Jwt的Payload部分必須包含exp且具有有效值。
  • TokenValidationParameters.NameClaimType:設定 HttpContext.User.Identity.NameClaimType,便於 HttpContext.User.Identity.Name 取到正確的值
  • TokenValidationParameters.RoleClaimType:設定 HttpContext.User.Identity.RoleClaimType,便於 HttpContext.User.Identity.IsInRole(xxx) 取到正確的值
  • TokenValidationParameters.ClockSkew:設定時鐘漂移,可以在驗證token有效期時,允許一定的時間誤差(如時間剛達到token中exp,但是允許未來5分鐘內該token仍然有效)。預設為300s,即5min。本例jwt的簽發和驗證均是同一臺伺服器,所以這裡就不需要設定時鐘漂移了。
  • SaveToken:當token驗證通過後,是否儲存到 Microsoft.AspNetCore.Authentication.AuthenticationProperties,預設true。該操作發生在執行完 JwtBearerEvents.TokenValidated之後。
  • SecurityTokenValidators:token驗證器列表,可以指定驗證token的處理器。預設含有1個JwtSecurityTokenHandler
  • EventsType:這裡我重寫了JwtBearerEvents

下面來看事件回撥:

public class AppJwtBearerEvents : JwtBearerEvents
{
    public override Task MessageReceived(MessageReceivedContext context)
    {
        // 從 Http Request Header 中獲取 Authorization
        string authorization = context.Request.Headers[HeaderNames.Authorization];
        if (string.IsNullOrEmpty(authorization))
        {
            context.NoResult();
            return Task.CompletedTask;
        }

        // 必須為 Bearer 認證方案
        if (authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
        {
            // 賦值token
            context.Token = authorization["Bearer ".Length..].Trim();
        }

        if (string.IsNullOrEmpty(context.Token))
        {
            context.NoResult();
            return Task.CompletedTask;
        }
        
        return Task.CompletedTask;
    }

    public override Task TokenValidated(TokenValidatedContext context)
    {
        return Task.CompletedTask;
    }

    public override Task AuthenticationFailed(AuthenticationFailedContext context)
    {
        Console.WriteLine($"Exception: {context.Exception}");

        return Task.CompletedTask;
    }

    public override Task Challenge(JwtBearerChallengeContext context)
    {
        Console.WriteLine($"Authenticate Failure: {context.AuthenticateFailure}");
        Console.WriteLine($"Error: {context.Error}");
        Console.WriteLine($"Error Description: {context.ErrorDescription}");
        Console.WriteLine($"Error Uri: {context.ErrorUri}");

        return Task.CompletedTask;
    }

    public override Task Forbidden(ForbiddenContext context)
    {
        return Task.CompletedTask;
    }
}
  • MessageReceived:當收到請求時回撥,注意,此時還未獲取到token。我們可以在該方法內自定義token的獲取方式,然後將獲取到的token賦值到context.Token(不包含Scheme)。只要我們取到的token既非Null也非Empty,那後續驗證就會使用該token
  • TokenValidated:token驗證通過後回撥。
  • AuthenticationFailed:由於認證過程中丟擲異常,導致身份認證失敗後回撥。
  • Challenge:質詢時回撥。
  • Forbidden:當出現403(Forbidden,禁止)時回撥。

其中,在MessageReceived中,針對預設獲取token的邏輯進行了模擬。

使用者登入和登出

使用者登入

現在,我們來實現使用者登入功能,當登入成功時,向客戶端簽發一個token。

[Route("api/[controller]")]
[ApiController]
public class AccountController : ControllerBase
{
    private readonly JwtBearerOptions _jwtBearerOptions;
    private readonly JwtOptions _jwtOptions;
    private readonly SigningCredentials _signingCredentials;

    public AccountController(
        IOptionsSnapshot<JwtBearerOptions> jwtBearerOptions,
        IOptionsSnapshot<JwtOptions> jwtOptions,
        SigningCredentials signingCredentials)
    {
        _jwtBearerOptions = jwtBearerOptions.Get(JwtBearerDefaults.AuthenticationScheme);
        _jwtOptions = jwtOptions.Value;
        _signingCredentials = signingCredentials;
    }

    [AllowAnonymous]
    [HttpPost("login")]
    public IActionResult Login([FromBody] LoginDto dto)
    {
        if (dto.UserName != dto.Password)
        {
            return Unauthorized();
        }

        var user = new UserDto()
        {
            Id = Guid.NewGuid().ToString("N"),
            UserName = dto.UserName
        };

        var token = CreateJwtToken(user);

        return Ok(new { token });
    }

    [NonAction]
    private string CreateJwtToken(UserDto user)
    {
        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Subject = new ClaimsIdentity(new List<Claim>
            {
                new Claim(JwtClaimTypes.Id, user.Id),
                new Claim(JwtClaimTypes.Name, user.UserName)
            }),
            Issuer = _jwtOptions.Issuer,
            Audience = _jwtOptions.Audience,
            Expires = DateTime.UtcNow.AddMinutes(_jwtOptions.ExpiresMinutes),
            SigningCredentials = _signingCredentials
        };

        var handler = _jwtBearerOptions.SecurityTokenValidators.OfType<JwtSecurityTokenHandler>().FirstOrDefault()
            ?? new JwtSecurityTokenHandler();
        var securityToken = handler.CreateJwtSecurityToken(tokenDescriptor);
        var token = handler.WriteToken(securityToken);

        return token;
    }
}

我們目光直接來到CreateJwtToken方法,可以看到熟悉的Subject、Issuer、Audience、Expires等。其中,Subject可以裝載多個自定義宣告,在生成token時,會將裝載的所有宣告展開平鋪。而另一個需要注意的就是Expires,必須使用基於UTC的時間,預設有效期為1個小時。

下面我們一起生成一個token:

然後我們給WeatherForecastController增加授權(詳細配置過程略),並帶上token進行請求:

使用者登出

當使用JwtBearer認證方案時,由於Jwt的“一次性”和“無狀態”特徵,使用者登出一般是不會在服務端實現的,而是通過客戶端來實現,比如客戶端從localstorage中刪除該token(當然,這只是一種“曲線救國”的實現方式)。

另外,如果你可以接受的話,可以在使用者登出時,服務端將Jwt加入快取黑名單,並將快取過期時間設定為Jwt的過期時間。

優化改進

改用非對稱加密進行Jwt簽名和驗籤

在前面的示例中,我們使用的對稱加密演算法HmacSha256計算的簽名。試想一下,公司內的多個業務專案都會使用該token,因此,為了讓每個專案都可以進行身份認證,就需要將金鑰分發給所有專案,這就產生了較大的風險。因此,使用非對稱加密來計算簽名,是一個更加合理地選擇:我們使用私鑰進行簽名,然後只需要將公鑰暴露出去用於驗籤,即可驗證token是有效的(沒有被篡改)。下面,我們就以RsaSha256為例改進我們的程式。

首先,我們先生成Rsa的金鑰對,參考以下示例程式碼(可在原始碼AccountController中找到):

public void GenerateRsaKeyParies(IWebHostEnvironment env)
{
    RSAParameters privateKey, publicKey;

    // >= 2048 否則長度太短不安全
    using (var rsa = new RSACryptoServiceProvider(2048))
    {
        try
        {
            privateKey = rsa.ExportParameters(true);
            publicKey = rsa.ExportParameters(false);
        }
        finally
        {
            rsa.PersistKeyInCsp = false;
        }
    }

    var dir = Path.Combine(env.ContentRootPath, "Rsa");
    if (!Directory.Exists(dir))
    {
        Directory.CreateDirectory(dir);
    }

    System.IO.File.WriteAllText(Path.Combine(dir, "key.private.json"), JsonConvert.SerializeObject(privateKey));
    System.IO.File.WriteAllText(Path.Combine(dir, "key.public.json"), JsonConvert.SerializeObject(publicKey));
}

具體細節不必多說,然後就來改進我們的JwtOptions

public class JwtOptions
{
    public const string Name = "Jwt";
    public readonly static double DefaultExpiresMinutes = 30d;

    public string Audience { get; set; }

    public string Issuer { get; set; }
    
    public double ExpiresMinutes { get; set; } = DefaultExpiresMinutes;
}

由於RSA簽名演算法的私鑰和公鑰都儲存在另外一個檔案中,而且一般這個也不會輕易更改,所以就不把它們加入到選項中了。

接著,修改我們的簽名演算法和驗籤演算法:

public class Startup
{
    public Startup(IConfiguration configuration, IWebHostEnvironment env)
    {
        Configuration = configuration;
        Env = env;
    }

    public IConfiguration Configuration { get; }

    public IWebHostEnvironment Env { get; set; }

    public void ConfigureServices(IServiceCollection services)
    {
        services.Configure<JwtOptions>(Configuration.GetSection(JwtOptions.Name));

        var jwtOptions = Configuration.GetSection(JwtOptions.Name).Get<JwtOptions>();
        
        var rsaSecurityPrivateKeyString = File.ReadAllText(Path.Combine(Env.ContentRootPath, "Rsa", "key.private.json"));
        var rsaSecurityPublicKeyString = File.ReadAllText(Path.Combine(Env.ContentRootPath, "Rsa", "key.public.json"));
        RsaSecurityKey rsaSecurityPrivateKey = new(JsonConvert.DeserializeObject<RSAParameters>(rsaSecurityPrivateKeyString));
        RsaSecurityKey rsaSecurityPublicKey = new(JsonConvert.DeserializeObject<RSAParameters>(rsaSecurityPublicKeyString));
        
        // 使用私鑰加簽
        services.AddSingleton(sp => new SigningCredentials(rsaSecurityPrivateKey, SecurityAlgorithms.RsaSha256Signature));

        services.AddScoped<AppJwtBearerEvents>();
        
        services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
            {
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    // ...
                    
                    // 使用公鑰驗籤
                    IssuerSigningKey = rsaSecurityPublicKey,
                }
            }
    }
}

至此,就OK了,其他全部都不需要改,以下是一個簽發的Jwt示例,缺點是簽名部分會比對稱加密的長很多(畢竟安全嘛,我們可以忍受O(∩_∩)O哈哈~):

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Ijk4NTUxMDE3YjBjYTRjOTU5NzNmMTM3Mjk2MWZlZWM2IiwibmFtZSI6InN0cmluZyIsIm5iZiI6MTY0MzIwOTIwNiwiZXhwIjoxNjQzMjA5ODA2LCJpYXQiOjE2NDMyMDkyMDYsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTAwMCIsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTAwMCJ9.GUCYTBytxv5yqGQFB6B6rlARF3F37CJh27e-qBCKApJShSr8vq-RkPu_o0dtCONKx0y1mb2Aq5hddFQYRFaMICQMeUeCJfaVoi96chsvwahnvx1_Snz4vvaiHSmTGCXm-WAkMJdpFny0zsicegLOrJJyHFecHGENGfWee28xYSi9R70bFJjVLxR965UJzOisi5pIXjemdlipaRhdITAWz-B4iKH_2-sv6j_drkJv2CNsEjOdHxHITN6oVUpP3i4i4PmXhRM7x4O0lKeKGQE9ezZIBtXa16nUCJo0VWDD2QAwWr1akzu99wtOSoJf2MoRETwK7vOOKIbTrNQOQ1WYUQ

對jwt進行加密

我們知道,Jwt中的Header和Payload都是明文,特別是Payload中我們務必不要放置敏感資訊。如果你覺得Jwt明文不妥,那你可以選擇針對它加一層加密,也就是Jwt標準的另一種實現Jwe。

下面是部分程式碼實現:

private string CreateJwtToken(UserDto user)
{
    var tokenDescriptor = new SecurityTokenDescriptor
    {
        // ...
        
        EncryptingCredentials = new EncryptingCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes("Total Bytes Length At Least 256!")), JwtConstants.DirectKeyUseAlg, SecurityAlgorithms.Aes128CbcHmacSha256)
    };

    // ...
}

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // ...
        
        services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
            {
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    // ...
                    
                    // 如果設定了 ValidAlgorithms,則加上 Aes128CbcHmacSha256
                    ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256, SecurityAlgorithms.RsaSha256, SecurityAlgorithms.Aes128CbcHmacSha256 },
                    
                    // token解密金鑰
                    TokenDecryptionKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("Total Bytes Length At Least 256!"))
                }
            }
    }
}

下方是一個Jwe示例:

eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwidHlwIjoiSldUIn0..KsIPh-Wx8TOpgNBZ5xINSA.zgqErSkpnTaWJ1TsPoIKrgpP_2uR-Orjbn54Wo4FeGmIPczk2X8N8qx4zWe9CGztrFLxeoWvYLlfRwclfglmKE9372delByVwK_C-u7cFN2TaZ183JTWYTyJVPANTC1WtuEzSe3NEKjfRoC9QN7SN4z9cJ-CtIPb1t17XB0gG0fc7T9UARZ1eIUIfnCXROAyX96qB6ABJ5Xy8wrrYkA2m5OqqLyAd8FbZfcK_rii_lbXNZsbcfgNPBQGEO6lOdBg4I3nQv9A6cqGj9qTnsIH89Dx7mBnkx0W7C9UHtZQsNTG71VSzG8g_KVifC-oO62wrOYeh48y5l4czeIWlAl4GCZpnUQmq4Y_2cw2brgG4WV7FRYPch4RMeTB6y9qrm6Rj8TvZbf_hZ51yvDYvPPVUjMiM1xo5_KLXVZa3w5aEGB4jGynVXwuGDV8XwS8sTjEkziFfA85TWPq_N-ENm4R9K_HUzwfgpGYzM-Nrf54GV8BXpnpapTc-jWij3MOpsjeyzqXdG5t-JB9_Xt7-BadjMakiU1WihiigiYMGQBmkG30r8e6bGcoL58Ytb6PQZ3NfHGCakV5LRGWFOjRUSP7X_xC0xWhrH2R6LhD1QESoE8GsTU-YS9JUREECcD2b9gXx0JxYp2mGdCkKRspajhEj4b04PV-hpr0bNSf59GkSMu_KhHuF5AcWfLSqwzACMvsvW6QvIQTzm6gXy8Ui2N80JCGkp_LzW23RFwCPSlQQ7c7S3A-Ltd_AaDQJ9C5B-To_PHESy9bUKhU-MV2tbfSST-vBeJkSn4kz4feEWcG59A.KULA_w3_XEIIKhAHKuFpsw

它的頭部就是:

{
  "alg": "dir",
  "enc": "A128CBC-HS256",
  "typ": "JWT"
}

藉助服務端增強Jwt認證方案

雖然無狀態的Jwt使用非常方便快捷,但是適用場景非常有限。為了能夠實現更多功能,就需要藉助服務端,從而導致Jwt的無狀態性被破壞。

在進入該主題之前,請先確認一下,前面所提到的Jwt的用法已經完全符合你的要求,如果是,那麼恭喜你,Jwt絕對是最適合的方案。如果不是,且你認為需要服務端,那麼你應該考慮一下,你是否真的需要服務端。因為這樣會使得認證行為趨向於cookie + session,從而使得認證方案的複雜性大幅增加。

Jwt靜默重新整理實現自動續租

試想一下以下場景:使用者登入後獲得了一個有效期為30分鐘的token,然後填寫一個表單時,花費了40分鐘,點選提交後,系統要求他重新登入並重新填寫表單,你猜他會不會很開心?因此,就像我們之前基於Cookie進行身份認證時一樣,在基於Jwt的認證方案中,我們也需要一種類似滑動過期的機制來實現自動續租。

那該如何設計這個自動續租方案呢?你可能會想到以下的方案:

  • 方案一:每次通過認證的請求都會重新簽發Jwt來重置過期時間。該方案雖然能夠解決問題,但是太過暴力,也有嚴重的效能問題。
  • 方案二:jwt即將過期時才重新簽發Jwt。乍一看,這方案看起來可行,但是實際上Jwt能否重新整理完全是看運氣。假設簽發了一個有效期為30分鐘的Jwt,我們打算在它有效期僅剩5分鐘時重新簽發。如果使用者在最後5分鐘內請求了,那會重新整理Jwt,但是如果沒有請求,那就需要使用者重新登入,體驗大打折扣。
  • 方案三:簽發的Jwt中忽略過期時間,而將Jwt(或JwtId)記錄在服務端的分散式快取,並設定過期時間。然後,在初次進行Jwt校驗時,不使用預設的校驗器校驗過期時間,校驗通過後,再與快取中的過期時間進行比對,如果有效則重置過期時間。該方案確實可行,不過這要求Jwt在有效期內才能進行重新整理。

目前使用最廣泛的一種方式是引入一個稱為refresh token的引數。大概流程是在簽發access token時,同時生成一個refresh token,並且refresh token的有效期要比access token長很多。然後,客戶端將兩個token都儲存下來。當客戶端請求服務端使用,若發現服務端返回“access token過期”的錯誤,那麼就加上之前儲存下來的refresh token請求服務端重新整理token,服務端會簽發一套全新的access tokenrefresh token給客戶端。

其中,為了保證refresh token的安全性和有效性,除了傳送給客戶端外,還需要在服務端儲存一份,並設定過期時間。這實際上在一定程度上破壞了Jwt的“無狀態”性(個人認為可以接受)。

具體程式碼請參考XXTk.Auth.Samples.JwtBearerWithRefresh.HttpApi

首先,就先定義要返回給客戶端的資料型別:

public class AuthTokenDto
{
    // jwt token
    public string AccessToken { get; set; }

    // 用於重新整理token的重新整理令牌
    public string RefreshToken { get; set; }
}

接下來定義token的服務介面IAuthTokenService和服務實現AuthTokenService

public interface IAuthTokenService
{
    Task<AuthTokenDto> CreateAuthTokenAsync(UserDto user);

    Task<AuthTokenDto> RefreshAuthTokenAsync(AuthTokenDto token);
}

public class AuthTokenService : IAuthTokenService
{
    private const string RefreshTokenIdClaimType = "refresh_token_id";

    private readonly JwtBearerOptions _jwtBearerOptions;
    private readonly JwtOptions _jwtOptions;
    private readonly SigningCredentials _signingCredentials;
    private readonly IDistributedCache _distributedCache;
    private readonly ILogger<AuthTokenService> _logger;

    public AuthTokenService(
       IOptionsSnapshot<JwtBearerOptions> jwtBearerOptions,
       IOptionsSnapshot<JwtOptions> jwtOptions,
       SigningCredentials signingCredentials,
       IDistributedCache distributedCache,
       ILogger<AuthTokenService> logger)
    {
        _jwtBearerOptions = jwtBearerOptions.Get(JwtBearerDefaults.AuthenticationScheme);
        _jwtOptions = jwtOptions.Value;
        _signingCredentials = signingCredentials;
        _distributedCache = distributedCache;
        _logger = logger;
    }
}

接下來,我們來實現CreateAuthTokenAsync方法:

public async Task<AuthTokenDto> CreateAuthTokenAsync(UserDto user)
{
    var result = new AuthTokenDto();
    
    // 先建立refresh token
    var (refreshTokenId, refreshToken) = await CreateRefreshTokenAsync(user.Id);
    result.RefreshToken = refreshToken;
    // 再簽發Jwt
    result.AccessToken = CreateJwtToken(user, refreshTokenId);

    return result;
}

private async Task<(string refreshTokenId, string refreshToken)> CreateRefreshTokenAsync(string userId)
{
    // refresh token id作為快取Key
    var tokenId = Guid.NewGuid().ToString("N");

    // 生成refresh token
    var rnBytes = new byte[32];
    using var rng = RandomNumberGenerator.Create();
    rng.GetBytes(rnBytes);
    var token = Convert.ToBase64String(rnBytes);

    // 設定refresh token的過期時間
    var options = new DistributedCacheEntryOptions();
    options.SetAbsoluteExpiration(TimeSpan.FromDays(_jwtOptions.RefreshTokenExpiresDays));
    
    // 快取 refresh token
    await _distributedCache.SetStringAsync(GetRefreshTokenKey(userId, tokenId), token, options);

    return (tokenId, token);
}

private string CreateJwtToken(UserDto user, string refreshTokenId)
{
    if (user is null) throw new ArgumentNullException(nameof(user));
    if (string.IsNullOrEmpty(refreshTokenId)) throw new ArgumentNullException(nameof(refreshTokenId));

    var tokenDescriptor = new SecurityTokenDescriptor
    {
        Subject = new ClaimsIdentity(new List<Claim>
        {
            new Claim(JwtClaimTypes.Id, user.Id),
            new Claim(JwtClaimTypes.Name, user.UserName),
            // 將 refresh token id 記錄下來
            new Claim(RefreshTokenIdClaimType, refreshTokenId)
        }),
        Issuer = _jwtBearerOptions.TokenValidationParameters.ValidIssuer,
        Audience = _jwtBearerOptions.TokenValidationParameters.ValidAudience,
        Expires = DateTime.UtcNow.AddMinutes(_jwtOptions.AccessTokenExpiresMinutes),
        SigningCredentials = _signingCredentials,
    };

    var handler = _jwtBearerOptions.SecurityTokenValidators.OfType<JwtSecurityTokenHandler>().FirstOrDefault()
        ?? new JwtSecurityTokenHandler();
    var securityToken = handler.CreateJwtSecurityToken(tokenDescriptor);
    var token = handler.WriteToken(securityToken);

    return token;
}

private string GetRefreshTokenKey(string userId, string refreshTokenId)
{
    if (string.IsNullOrEmpty(userId)) throw new ArgumentNullException(nameof(userId));
    if (string.IsNullOrEmpty(refreshTokenId)) throw new ArgumentNullException(nameof(refreshTokenId));

    return $"{userId}:{refreshTokenId}";
}

下面看一下效果:

接著,實現RefreshAuthTokenAsync方法:

public async Task<AuthTokenDto> RefreshAuthTokenAsync(AuthTokenDto token)
{
    var validationParameters = _jwtBearerOptions.TokenValidationParameters.Clone();
    // 不校驗生命週期
    validationParameters.ValidateLifetime = false;

    var handler = _jwtBearerOptions.SecurityTokenValidators.OfType<JwtSecurityTokenHandler>().FirstOrDefault()
        ?? new JwtSecurityTokenHandler();
    ClaimsPrincipal principal = null;
    try
    {
        // 先驗證一下,jwt是否真的有效
        principal = handler.ValidateToken(token.AccessToken, validationParameters, out _);
    }
    catch (Exception ex)
    {
        _logger.LogWarning(ex.ToString());
        throw new BadHttpRequestException("Invalid access token");
    }

    var identity = principal.Identities.First();
    var userId = identity.Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Id).Value;
    var refreshTokenId = identity.Claims.FirstOrDefault(c => c.Type == RefreshTokenIdClaimType).Value;
    var refreshTokenKey = GetRefreshTokenKey(userId, refreshTokenId);
    var refreshToken = await _distributedCache.GetStringAsync(refreshTokenKey);
    // 驗證refresh token是否有效
    if (refreshToken != token.RefreshToken)
    {
        throw new BadHttpRequestException("Invalid refresh token");
    }

    // refresh token用過了記得清除掉
    await _distributedCache.RemoveAsync(refreshTokenKey);

    // 這裡應該是從資料庫中根據 userId 獲取使用者資訊
    var user = new UserDto()
    {
        Id = userId,
        UserName = principal.Identity.Name
    };

    return await CreateAuthTokenAsync(user);
}

下面看一下效果:

注意:引入重新整理令牌後,要記得在使用者登出將當前Jwt的重新整理令牌清除,或修改密碼後將該使用者的重新整理令牌清空。

最後,解釋幾個問題:

  • 為什麼Jwt中儲存了refresh token id?直接儲存refresh token不行嗎?

    儲存refresh token id是為了實現一個使用者對應多個refresh token,這適用於同一使用者在多客戶端登入的情況。

    不能直接儲存refresh token,由於Jwt是明文,所以這容易導致refresh token洩漏,從而導致他人可以在使用者不知情的情況下申請access token。

  • 為什麼要設計為一個使用者對應多個refresh token?

    這適用於同一使用者在多客戶端登入的情況,防止其中一個客戶端重新整理了token,導致其他客戶端無法重新整理。

處理不同系統要求Jwt認證資訊中儲存不同的欄位資訊

假設有以下場景:商城採購系統和收貨系統屬於同一電商平臺,使用的均是同一套基於JwtBearer的認證方案,現在,收貨系統需要在認證資訊中新增角色資訊和每日最大收貨次數資訊,便於快速獲取。

方案可能多種多樣,比如就在Jwt簽發時,將角色資訊和每日最大收貨次數儲存到Jwt中,雖然這能夠解決問題,但顯然會使得Jwt儲存很多冗餘資料,在系統越來越多的情況下,就顯得無法接受。

以下是我所想到的一種較為合理的方案:首先,角色資訊較為通用,大部分系統都會用到,所以建議將角色資訊加入到Jwt中儲存,而對於每日最大收貨次數,更傾向於收貨系統使用,所以這條資訊由收貨系統在服務端進行維護,例如以使用者Id為Key,記入分散式快取中。

很多人會說,我使用Jwt就是因為它的無狀態性,既然它也要結合服務端,那我為啥不乾脆就使用Cookie + Session

確實,如果你的系統前端是H5,客戶端均是瀏覽器,且後續也基本不可能發生改變,那你可以把扇Jwt倆大耳刮子,並把它踢出家門,因為Cookie + Session絕對是你的首選。

但是,如果你的系統包含了H5、小程式、Native App等,由於其中某些客戶端不支援Cookie,所以Cookie就喪失了它的優勢,此時使用Cookie還是Jwt貌似差別都不大,但是Jwt可以實現自動續租。實際上,我比較推薦的做法是Jwt + Cookie,即將Jwt儲存在Cookie中,這樣,在H5應用中,仍然利用Cookie機制傳遞認證資訊,而在其他不支援Cookie的客戶端中,則直接使用Jwt(通過Authorization Header),這樣可以保證認證行為的統一。

防止Jwt洩露

文章最後,我們就來看一下如何防止Jwt洩漏吧。

假設Jwt洩露了,那麼他人就可以使用你的身份訪問伺服器進行敏感操作,不過這相對來說,還好,因為Jwt過期了也就失效了。但是,如果refresh token也洩露了,那就會產生更加嚴重的後果,他人就可以通過refresh token無限制的獲取到最新的token。

看完上面這段話,是不是不敢用Jwt了?別怕,任何認證方案都會有導致這種情況出現的可能,例如,通過使用者名稱和密碼登入時,不還是在請求過程中有使用者名稱和密碼被竊取的可能。

既然沒有絕對的安全保護措施,那我們只有儘量讓它安全,以下是兩點建議:

  • 使用Https協議
  • 設定較短的Jwt有效期

相關文章