注:本文隸屬於《理解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 SHA256typ
: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標準認證方案中,大家可能比較熟悉的是Basic
和Digest
。Basic
將使用者名稱密碼使用Base64編碼後作為認證憑證,而Digest
在Basic
的基礎上針對安全性進行了升級,使得使用者密碼更加安全。在前文介紹的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]
另外,如果你對Basic
和Digest
感興趣,推薦閱讀以下幾篇文章:
身份認證(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部分的nbf
和exp
。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,那後續驗證就會使用該tokenTokenValidated
: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 token
和refresh token
給客戶端。
其中,為了保證refresh token
的安全性和有效性,除了傳送給客戶端外,還需要在服務端儲存一份,並設定過期時間。這實際上在一定程度上破壞了Jwt的“無狀態”性(個人認為可以接受)。
首先,就先定義要返回給客戶端的資料型別:
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+服務端 vs Cookie + Session
很多人會說,我使用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有效期