前言
如題,本節(jié)我們進(jìn)入JWT最后一節(jié)內(nèi)容,JWT本質(zhì)上就是從身份認(rèn)證服務(wù)器獲取訪問(wèn)令牌,繼而對(duì)于用戶后續(xù)可訪問(wèn)受保護(hù)資源,但是關(guān)鍵問(wèn)題是:訪問(wèn)令牌的生命周期到底設(shè)置成多久呢?見(jiàn)過(guò)一些使用JWT的童鞋會(huì)將JWT過(guò)期時(shí)間設(shè)置成很長(zhǎng),有的幾個(gè)小時(shí),有的一天,有的甚至一個(gè)月,這么做當(dāng)然存在問(wèn)題,如果被惡意獲得訪問(wèn)令牌,那么可在整個(gè)生命周期中使用訪問(wèn)令牌,也就是說(shuō)存在冒充用戶身份,此時(shí)身份認(rèn)證服務(wù)器當(dāng)然也就是始終信任該冒牌訪問(wèn)令牌,若要使得冒牌訪問(wèn)令牌無(wú)效,唯一的方案則是修改密鑰,但是如果我們這么做了,則將使得已授予的訪問(wèn)令牌都將無(wú)效,所以更改密鑰不是最佳方案,我們應(yīng)該從源頭盡量控制這個(gè)問(wèn)題,而不是等到問(wèn)題呈現(xiàn)再來(lái)想解決之道,刷新令牌閃亮登場(chǎng)。
?
RefreshToken
什么是刷新令牌呢?刷新訪問(wèn)令牌是用來(lái)從身份認(rèn)證服務(wù)器交換獲得新的訪問(wèn)令牌,有了刷新令牌可以在訪問(wèn)令牌過(guò)期后通過(guò)刷新令牌重新獲取新的訪問(wèn)令牌而無(wú)需客戶端通過(guò)憑據(jù)重新登錄,如此一來(lái),既保證了用戶訪問(wèn)令牌過(guò)期后的良好體驗(yàn),也保證了更高的系統(tǒng)安全性,同時(shí),若通過(guò)刷新令牌獲取新的訪問(wèn)令牌驗(yàn)證其無(wú)效可將受訪者納入黑名單限制其訪問(wèn),那么訪問(wèn)令牌和刷新令牌的生命周期設(shè)置成多久合適呢?這取決于系統(tǒng)要求的安全性,一般來(lái)講訪問(wèn)令牌的生命周期不會(huì)太長(zhǎng),比如5分鐘,又比如獲取微信的AccessToken的過(guò)期時(shí)間為2個(gè)小時(shí)。接下來(lái)我將用兩張表來(lái)演示實(shí)現(xiàn)刷新令牌的整個(gè)過(guò)程,可能有更好的方案,歡迎在評(píng)論中提出,學(xué)習(xí),學(xué)習(xí)。我們新建一個(gè)http://localhost:5000的WebApi用于身份認(rèn)證,再新建一個(gè)http://localhost:5001的客戶端,首先點(diǎn)擊【模擬登錄獲取Toen】獲取訪問(wèn)令牌和刷新令牌,然后點(diǎn)擊【調(diào)用客戶端獲取當(dāng)前時(shí)間】,如下:
接下來(lái)我們新建一張用戶表(User)和用戶刷新令牌表(UserRefreshToken),結(jié)構(gòu)如下:
public class User { public string Id { get; set; } public string Email { get;
set; } public string UserName { get; set; } private readonly
List<UserRefreshToken> _userRefreshTokens =new List<UserRefreshToken>(); public
IEnumerable<UserRefreshToken> UserRefreshTokens => _userRefreshTokens; ///
<summary> /// 驗(yàn)證刷新token是否存在或過(guò)期 /// </summary> /// <param
name="refreshToken"></param> /// <returns></returns> public bool
IsValidRefreshToken(string refreshToken) { return _userRefreshTokens.Any(d =>
d.Token.Equals(refreshToken) && d.Active); } /// <summary> /// 創(chuàng)建刷新Token ///
</summary> /// <param name="token"></param> /// <param name="userId"></param>
/// <param name="minutes"></param> public void CreateRefreshToken(string token,
string userId, double minutes = 1) { _userRefreshTokens.Add(new
UserRefreshToken() { Token = token, UserId = userId, Expires =
DateTime.Now.AddMinutes(minutes) }); }/// <summary> /// 移除刷新token /// </summary>
/// <param name="refreshToken"></param> public void RemoveRefreshToken(string
refreshToken) { _userRefreshTokens.Remove(_userRefreshTokens.FirstOrDefault(t=>
t.Token == refreshToken)); } public class UserRefreshToken { public string Id {
get; private set; } = Guid.NewGuid().ToString(); public string Token { get; set
; }public DateTime Expires { get; set; } public string UserId { get; set; }
public bool Active => DateTime.Now <= Expires; }
如上可以看到對(duì)于刷新令牌的操作我們將其放在用戶實(shí)體中,也就是使用EF Core中的Back
Fields而不對(duì)外暴露。接下來(lái)我們將生成的訪問(wèn)令牌、刷新令牌、驗(yàn)證訪問(wèn)令牌、獲取用戶身份封裝成對(duì)應(yīng)方法如下:
/// <summary> /// 生成訪問(wèn)令牌 /// </summary> /// <param name="claims"></param> ///
<returns></returns> public string GenerateAccessToken(Claim[] claims) { var key
=new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)); var token = new
JwtSecurityToken( issuer:"http://localhost:5000", audience: "
http://localhost:5001", claims: claims, notBefore: DateTime.Now, expires:
DateTime.Now.AddMinutes(1), signingCredentials: new SigningCredentials(key,
SecurityAlgorithms.HmacSha256) );return new
JwtSecurityTokenHandler().WriteToken(token); }/// <summary> /// 生成刷新Token ///
</summary> /// <returns></returns> public string GenerateRefreshToken() { var
randomNumber =new byte[32]; using (var rng = RandomNumberGenerator.Create()) {
rng.GetBytes(randomNumber);return Convert.ToBase64String(randomNumber); } } ///
<summary> /// 從Token中獲取用戶身份 /// </summary> /// <param name="token"></param> ///
<returns></returns> public ClaimsPrincipal GetPrincipalFromAccessToken(string
token) {var handler = new JwtSecurityTokenHandler(); try { return
handler.ValidateToken(token,new TokenValidationParameters { ValidateAudience =
false, ValidateIssuer = false, ValidateIssuerSigningKey = true, IssuerSigningKey
=new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)), ValidateLifetime
=false }, out SecurityToken validatedToken); } catch (Exception) { return null;
} }
當(dāng)用戶點(diǎn)擊登錄,訪問(wèn)身份認(rèn)證服務(wù)器,登錄成功后我們創(chuàng)建訪問(wèn)令牌和刷新令牌并返回,如下:
[HttpPost("login")] public async Task<IActionResult> Login() { var user = new
User() { Id= "D21D099B-B49B-4604-A247-71B0518A0B1C", UserName = "Jeffcky", Email
="[email protected]" }; await context.Users.AddAsync(user); var refreshToken =
GenerateRefreshToken(); user.CreateRefreshToken(refreshToken, user.Id);await
context.SaveChangesAsync();var claims = new Claim[] { new
Claim(ClaimTypes.Name, user.UserName),new Claim(JwtRegisteredClaimNames.Email,
user.Email),new Claim(JwtRegisteredClaimNames.Sub, user.Id), }; return Ok(new
Response() { AccessToken = GenerateAccessToken(claims), RefreshToken =
refreshToken }); }
此時(shí)我們回到如上給出的圖,我們點(diǎn)擊【模擬登錄獲取Token】,此時(shí)發(fā)出Ajax請(qǐng)求,然后將返回的訪問(wèn)令牌和刷新令牌存儲(chǔ)到本地localStorage中,如下:
<input type="button" id="btn" value="模擬登錄獲取Token" /> <input type="button" id="
btn-currentTime" value="調(diào)用客戶端獲取當(dāng)前時(shí)間" /> //模擬登陸 $('#btn').click(function () {
GetTokenAndRefreshToken(); });//獲取Token function GetTokenAndRefreshToken() {
$.post('http://localhost:5000/api/account/login').done(function (data) {
saveAccessToken(data.accessToken); saveRefreshToken(data.refreshToken); }); } //
從localStorage獲取AccessToken function getAccessToken() { return
localStorage.getItem('accessToken'); } //從localStorage獲取RefreshToken function
getRefreshToken() {return localStorage.getItem('refreshToken'); } //
保存AccessToken到localStorage function saveAccessToken(token) {
localStorage.setItem('accessToken', token); } //保存RefreshToken到localStorage
function saveRefreshToken(refreshToken) { localStorage.setItem('refreshToken',
refreshToken); }
此時(shí)我們?cè)賮?lái)點(diǎn)擊【調(diào)用客戶端獲取當(dāng)前時(shí)間】,同時(shí)將登錄返回的訪問(wèn)令牌設(shè)置到請(qǐng)求頭中,代碼如下:
$('#btn-currentTime').click(function () { GetCurrentTime(); }); //調(diào)用客戶端獲取當(dāng)前時(shí)間
function GetCurrentTime() { $.ajax({ type:'get', contentType: 'application/json
', url: 'http://localhost:5001/api/home', beforeSend: function (xhr) {
xhr.setRequestHeader('Authorization', 'Bearer ' + getAccessToken()); },
success: function (data) { alert(data); }, error: function (xhr) { } }); }
客戶端請(qǐng)求接口很簡(jiǎn)單,為了讓大家一步步看明白,我也給出來(lái),如下:
[Authorize] [HttpGet("api/[controller]")] public string GetCurrentTime() {
return DateTime.Now.ToString("yyyy-MM-dd"); }
好了到了這里我們已經(jīng)實(shí)現(xiàn)模擬登錄獲取訪問(wèn)令牌,并能夠調(diào)用客戶端接口獲取到當(dāng)前時(shí)間,同時(shí)我們也只是返回了刷新令牌并存儲(chǔ)到了本地localStorage中,并未用到。當(dāng)訪問(wèn)令牌過(guò)期后我們需要通過(guò)訪問(wèn)令牌和刷新令牌去獲取新的訪問(wèn)令牌,對(duì)吧。那么問(wèn)題來(lái)了。我們?cè)趺粗涝L問(wèn)令牌已經(jīng)過(guò)期了呢?這是其一,其二是為何要發(fā)送舊的訪問(wèn)令牌去獲取新的訪問(wèn)令牌呢?直接通過(guò)刷新令牌去換取不行嗎?有問(wèn)題是好的,就怕沒(méi)有任何思考,我們一一來(lái)解答。我們?cè)诳蛻舳颂砑覬WT中間件時(shí),里面有一個(gè)事件可以捕捉到訪問(wèn)令牌已過(guò)期(關(guān)于客戶端配置JWT中間件第一節(jié)已講過(guò),這里不再啰嗦),如下:
options.Events = new JwtBearerEvents { OnAuthenticationFailed = context => {
if (context.Exception.GetType() == typeof(SecurityTokenExpiredException)) {
context.Response.Headers.Add("act", "expired"); }return Task.CompletedTask; } };
通過(guò)如上事件并捕捉訪問(wèn)令牌過(guò)期異常,這里我們?cè)陧憫?yīng)頭添加了一個(gè)自定義鍵act,值為expired,因?yàn)橐粋€(gè)401只能反映未授權(quán),并不能代表訪問(wèn)令牌已過(guò)期。當(dāng)我們?cè)诘谝粡垐D中點(diǎn)擊【調(diào)用客戶端獲取當(dāng)前時(shí)間】發(fā)出Ajax請(qǐng)求時(shí),如果訪問(wèn)令牌過(guò)期,此時(shí)在Ajax請(qǐng)求中的error方法中捕捉到,我們?cè)谌缟弦呀o出發(fā)出Ajax請(qǐng)求的error方法中繼續(xù)進(jìn)行如下補(bǔ)充:
error: function (xhr) { if (xhr.status === 401 && xhr.getResponseHeader('act'
) ==='expired') { // 訪問(wèn)令牌肯定已過(guò)期 } }
到了這里我們已經(jīng)解決如何捕捉到訪問(wèn)令牌已過(guò)期的問(wèn)題,接下來(lái)我們需要做的則是獲取刷新令牌,直接通過(guò)刷新令牌換取新的訪問(wèn)令牌也并非不可,只不過(guò)還是為了安全性考慮,我們加上舊的訪問(wèn)令牌。接下來(lái)我們發(fā)出Ajax請(qǐng)求獲取刷新令牌,如下:
//獲取刷新Token function GetRefreshToken(func) { var model = { accessToken:
getAccessToken(), refreshToken: getRefreshToken() }; $.ajax({ type:"POST",
contentType:"application/json; charset=utf-8", url: '
http://localhost:5000/api/account/refresh-token', dataType: "json", data:
JSON.stringify(model), success: function (data) {if (!data.accessToken && !
data.refreshToken) {// 跳轉(zhuǎn)至登錄 } else { saveAccessToken(data.accessToken);
saveRefreshToken(data.refreshToken); func(); } } }); }
發(fā)出Ajax請(qǐng)求獲取刷新令牌的方法我們傳入了一個(gè)函數(shù),這個(gè)函數(shù)則是上一次調(diào)用接口訪問(wèn)令牌過(guò)期的請(qǐng)求,點(diǎn)擊【調(diào)用客戶端獲取當(dāng)前時(shí)間】按鈕的Ajax請(qǐng)求error方法中,最終演變成如下這般:
error: function (xhr) { if (xhr.status === 401 && xhr.getResponseHeader('act'
) ==='expired') { /* 訪問(wèn)令牌肯定已過(guò)期,將當(dāng)前請(qǐng)求傳入獲取刷新令牌方法, * 以便獲取刷新令牌換取新的令牌后繼續(xù)當(dāng)前請(qǐng)求 */
GetRefreshToken(GetCurrentTime); } }
接下來(lái)則是通過(guò)傳入舊的訪問(wèn)令牌和刷新令牌調(diào)用接口換取新的訪問(wèn)令牌,如下:
/// <summary> /// 刷新Token /// </summary> /// <returns></returns> [HttpPost("
refresh-token")] public async Task<IActionResult> RefreshToken([FromBody]
Request request) {//TODO 參數(shù)校驗(yàn) var principal =
GetPrincipalFromAccessToken(request.AccessToken);if (principal is null) { return
Ok(false); } var id = principal.Claims.First(c => c.Type ==
JwtRegisteredClaimNames.Sub)?.Value; if (string.IsNullOrEmpty(id)) { return Ok(
false); } var user = await context.Users.Include(d => d.UserRefreshTokens)
.FirstOrDefaultAsync(d=> d.Id == id); if (user is null ||
user.UserRefreshTokens?.Count() <=0) { return Ok(false); } if (!
user.IsValidRefreshToken(request.RefreshToken)) {return Ok(false); }
user.RemoveRefreshToken(request.RefreshToken);var refreshToken =
GenerateRefreshToken(); user.CreateRefreshToken(refreshToken, id);try { await
context.SaveChangesAsync(); }catch (Exception ex) { throw ex; } var claims = new
Claim[] {new Claim(ClaimTypes.Name, user.UserName), new
Claim(JwtRegisteredClaimNames.Email, user.Email),new
Claim(JwtRegisteredClaimNames.Sub, user.Id), };return Ok(new Response() {
AccessToken= GenerateAccessToken(claims), RefreshToken = refreshToken }); }
如上通過(guò)傳入舊的訪問(wèn)令牌驗(yàn)證并獲取用戶身份,然后驗(yàn)證刷新令牌是否已經(jīng)過(guò)期,如果未過(guò)期則創(chuàng)建新的訪問(wèn)令牌,同時(shí)更新刷新令牌。最終客戶端訪問(wèn)令牌過(guò)期的那一刻,通過(guò)刷新令牌獲取新的訪問(wèn)令牌繼續(xù)調(diào)用上一請(qǐng)求,如下:
到這里關(guān)于JWT實(shí)現(xiàn)刷新Token就已結(jié)束,自我感覺(jué)此種實(shí)現(xiàn)刷新令牌將其存儲(chǔ)到數(shù)據(jù)庫(kù)的方案還算可取,將刷新令牌存儲(chǔ)到Redis也可行,看個(gè)人選擇吧。上述若刷新令牌驗(yàn)證無(wú)效,可將訪問(wèn)者添加至黑名單,不過(guò)是添加一個(gè)屬性罷了。別著急,本節(jié)內(nèi)容結(jié)束前,還留有彩蛋。
EntityFramework Core Back Fields深入探討
無(wú)論是看視頻還是看技術(shù)博客也好,一定要?jiǎng)邮烛?yàn)證,看到這里覺(jué)得上述我所演示是不是毫無(wú)問(wèn)題,如果閱讀本文的你直接拷貝上述代碼你會(huì)發(fā)現(xiàn)有問(wèn)題,且聽(tīng)我娓娓道來(lái),讓我們來(lái)復(fù)習(xí)下Back
Fields。Back Fields命名是有約定dei,上述我是根據(jù)約定而命名,所以千萬(wàn)別一意孤行,別亂來(lái),比如如下命名將拋出如下異常:
private readonly List<UserRefreshToken> _refreshTokens = new
List<UserRefreshToken>(); public IEnumerable<UserRefreshToken>
UserRefreshTokens => _refreshTokens;
上述我們配置刷新令牌的Back Fields,代碼如下:
private readonly List<UserRefreshToken> _userRefreshTokens = new
List<UserRefreshToken>(); public IEnumerable<UserRefreshToken>
UserRefreshTokens => _userRefreshTokens;
要是我們配置成如下形式,結(jié)果又會(huì)怎樣呢?
private readonly List<UserRefreshToken> _userRefreshTokens = new
List<UserRefreshToken>(); public IEnumerable<UserRefreshToken>
UserRefreshTokens => _userRefreshTokens.AsReadOnly();
此時(shí)為了解決這個(gè)問(wèn)題,我們必須將其顯式配置成Back Fields,如下:
protected override void OnModelCreating(ModelBuilder modelBuilder) {
modelBuilder.Entity<User>(u => { var navigation =
u.Metadata.FindNavigation(nameof(User.UserRefreshTokens));
navigation.SetPropertyAccessMode(PropertyAccessMode.Field); }); }
在我個(gè)人著作中也講解到為了性能問(wèn)題,可將字段進(jìn)行ToList(),若進(jìn)行了ToList(),必須顯式配置成Back
Fields,否則獲取不到刷新令牌導(dǎo)航屬性,如下:
private readonly List<UserRefreshToken> _userRefreshTokens = new
List<UserRefreshToken>(); public IEnumerable<UserRefreshToken>
UserRefreshTokens => _userRefreshTokens.ToList();
或者進(jìn)行如下配置,我想應(yīng)該也可取,不會(huì)存在性能問(wèn)題,如下:
private readonly List<UserRefreshToken> _userRefreshTokens = new
List<UserRefreshToken>(); public IReadOnlyCollection<UserRefreshToken>
UserRefreshTokens => _userRefreshTokens.AsReadOnly();
這是關(guān)于Back Fields問(wèn)題之一,問(wèn)題之二則是上述我們請(qǐng)求獲取刷新令牌中,我們先在刷新令牌的Back
Fields中移除掉舊的刷新令牌,而后再創(chuàng)建新的刷新令牌,但是會(huì)拋出如下異常:
我們看到在添加刷新令牌時(shí),用戶Id是有值的,對(duì)不對(duì),這是為何呢?究其根本問(wèn)題出在我們移除刷新令牌方法中,如下:
/// <summary> /// 移除刷新token /// </summary> /// <param
name="refreshToken"></param> public void RemoveRefreshToken(string
refreshToken) { _userRefreshTokens.Remove(_userRefreshTokens.FirstOrDefault(t=>
t.Token == refreshToken)); }
我們將查詢出來(lái)的導(dǎo)航屬性并將其映射到_userRefreshTokens字段中,此時(shí)是被上下文所追蹤,上述我們查詢出存在的刷新令牌并在跟蹤的刷新令牌中進(jìn)行移除,沒(méi)毛病,沒(méi)找到原因,于是乎,我將上述方法修改成如下看看是否必須需要主鍵才能刪除舊的刷新令牌:
/// <summary> /// 移除刷新token /// </summary> /// <param
name="refreshToken"></param> public void RemoveRefreshToken(string
refreshToken) {var id = _userRefreshTokens.FirstOrDefault(t => t.Token ==
refreshToken).Id; _userRefreshTokens.Remove(new UserRefreshToken() { Id = id
}); }
倒沒(méi)拋出異常,創(chuàng)建了一個(gè)新的刷新令牌,但是舊的刷新令牌卻沒(méi)刪除,如下:
至此未找到問(wèn)題出在哪里,當(dāng)前版本為2.2,難道不能通過(guò)Back Fields移除對(duì)象?這個(gè)問(wèn)題待解決。
總結(jié)
本節(jié)我們重點(diǎn)講解了如何實(shí)現(xiàn)JWT刷新令牌,并也略帶討論了EF Core中Back
Fields以及尚未解決的問(wèn)題,至此關(guān)于JWT已結(jié)束,下節(jié)開始正式進(jìn)入Docker小白系列,感謝閱讀。
熱門工具 換一換
