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

注:本文隸屬於《理解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有效期
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章