詳解ASP.NET Core Web Api之JWT刷新Token
前言
如題,本節(jié)我們進(jìn)入JWT最后一節(jié)內(nèi)容,JWT本質(zhì)上就是從身份認(rèn)證服務(wù)器獲取訪問令牌,繼而對(duì)于用戶后續(xù)可訪問受保護(hù)資源,但是關(guān)鍵問題是:訪問令牌的生命周期到底設(shè)置成多久呢?見過一些使用JWT的童鞋會(huì)將JWT過期時(shí)間設(shè)置成很長(zhǎng),有的幾個(gè)小時(shí),有的一天,有的甚至一個(gè)月,這么做當(dāng)然存在問題,如果被惡意獲得訪問令牌,那么可在整個(gè)生命周期中使用訪問令牌,也就是說存在冒充用戶身份,此時(shí)身份認(rèn)證服務(wù)器當(dāng)然也就是始終信任該冒牌訪問令牌,若要使得冒牌訪問令牌無效,唯一的方案則是修改密鑰,但是如果我們這么做了,則將使得已授予的訪問令牌都將無效,所以更改密鑰不是最佳方案,我們應(yīng)該從源頭盡量控制這個(gè)問題,而不是等到問題呈現(xiàn)再來想解決之道,刷新令牌閃亮登場(chǎng)。
RefreshToken
什么是刷新令牌呢?刷新訪問令牌是用來從身份認(rèn)證服務(wù)器交換獲得新的訪問令牌,有了刷新令牌可以在訪問令牌過期后通過刷新令牌重新獲取新的訪問令牌而無需客戶端通過憑據(jù)重新登錄,如此一來,既保證了用戶訪問令牌過期后的良好體驗(yàn),也保證了更高的系統(tǒng)安全性,同時(shí),若通過刷新令牌獲取新的訪問令牌驗(yàn)證其無效可將受訪者納入黑名單限制其訪問,那么訪問令牌和刷新令牌的生命周期設(shè)置成多久合適呢?這取決于系統(tǒng)要求的安全性,一般來講訪問令牌的生命周期不會(huì)太長(zhǎng),比如5分鐘,又比如獲取微信的AccessToken的過期時(shí)間為2個(gè)小時(shí)。接下來我將用兩張表來演示實(shí)現(xiàn)刷新令牌的整個(gè)過程,可能有更好的方案,歡迎在評(píng)論中提出,學(xué)習(xí),學(xué)習(xí)。我們新建一個(gè)http://localhost:5000的WebApi用于身份認(rèn)證,再新建一個(gè)http://localhost:5001的客戶端,首先點(diǎn)擊【模擬登錄獲取Toen】獲取訪問令牌和刷新令牌,然后點(diǎn)擊【調(diào)用客戶端獲取當(dāng)前時(shí)間】,如下:

接下來我們新建一張用戶表(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是否存在或過期
/// </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ì)外暴露。接下來我們將生成的訪問令牌、刷新令牌、驗(yàn)證訪問令牌、獲取用戶身份封裝成對(duì)應(yīng)方法如下:
/// <summary>
/// 生成訪問令牌
/// </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)擊登錄,訪問身份認(rèn)證服務(wù)器,登錄成功后我們創(chuàng)建訪問令牌和刷新令牌并返回,如下:
[HttpPost("login")]
public async Task<IActionResult> Login()
{
var user = new User()
{
Id = "D21D099B-B49B-4604-A247-71B0518A0B1C",
UserName = "Jeffcky",
Email = "2752154844@qq.com"
};
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)求,然后將返回的訪問令牌和刷新令牌存儲(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è)賮睃c(diǎn)擊【調(diào)用客戶端獲取當(dāng)前時(shí)間】,同時(shí)將登錄返回的訪問令牌設(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)單,為了讓大家一步步看明白,我也給出來,如下:
[Authorize]
[HttpGet("api/[controller]")]
public string GetCurrentTime()
{
return DateTime.Now.ToString("yyyy-MM-dd");
}
好了到了這里我們已經(jīng)實(shí)現(xiàn)模擬登錄獲取訪問令牌,并能夠調(diào)用客戶端接口獲取到當(dāng)前時(shí)間,同時(shí)我們也只是返回了刷新令牌并存儲(chǔ)到了本地localStorage中,并未用到。當(dāng)訪問令牌過期后我們需要通過訪問令牌和刷新令牌去獲取新的訪問令牌,對(duì)吧。那么問題來了。我們?cè)趺粗涝L問令牌已經(jīng)過期了呢?這是其一,其二是為何要發(fā)送舊的訪問令牌去獲取新的訪問令牌呢?直接通過刷新令牌去換取不行嗎?有問題是好的,就怕沒有任何思考,我們一一來解答。我們?cè)诳蛻舳颂砑覬WT中間件時(shí),里面有一個(gè)事件可以捕捉到訪問令牌已過期(關(guān)于客戶端配置JWT中間件第一節(jié)已講過,這里不再啰嗦),如下:
options.Events = new JwtBearerEvents
{
OnAuthenticationFailed = context =>
{
if (context.Exception.GetType() == typeof(SecurityTokenExpiredException))
{
context.Response.Headers.Add("act", "expired");
}
return Task.CompletedTask;
}
};
通過如上事件并捕捉訪問令牌過期異常,這里我們?cè)陧憫?yīng)頭添加了一個(gè)自定義鍵act,值為expired,因?yàn)橐粋€(gè)401只能反映未授權(quán),并不能代表訪問令牌已過期。當(dāng)我們?cè)诘谝粡垐D中點(diǎn)擊【調(diào)用客戶端獲取當(dāng)前時(shí)間】發(fā)出Ajax請(qǐng)求時(shí),如果訪問令牌過期,此時(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') {
// 訪問令牌肯定已過期
}
}
到了這里我們已經(jīng)解決如何捕捉到訪問令牌已過期的問題,接下來我們需要做的則是獲取刷新令牌,直接通過刷新令牌換取新的訪問令牌也并非不可,只不過還是為了安全性考慮,我們加上舊的訪問令牌。接下來我們發(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)用接口訪問令牌過期的請(qǐng)求,點(diǎn)擊【調(diào)用客戶端獲取當(dāng)前時(shí)間】按鈕的Ajax請(qǐng)求error方法中,最終演變成如下這般:
error: function (xhr) {
if (xhr.status === 401 && xhr.getResponseHeader('act') === 'expired') {
/* 訪問令牌肯定已過期,將當(dāng)前請(qǐng)求傳入獲取刷新令牌方法,
* 以便獲取刷新令牌換取新的令牌后繼續(xù)當(dāng)前請(qǐng)求
*/
GetRefreshToken(GetCurrentTime);
}
}
接下來則是通過傳入舊的訪問令牌和刷新令牌調(diào)用接口換取新的訪問令牌,如下:
/// <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
});
}
如上通過傳入舊的訪問令牌驗(yàn)證并獲取用戶身份,然后驗(yàn)證刷新令牌是否已經(jīng)過期,如果未過期則創(chuàng)建新的訪問令牌,同時(shí)更新刷新令牌。最終客戶端訪問令牌過期的那一刻,通過刷新令牌獲取新的訪問令牌繼續(xù)調(diào)用上一請(qǐng)求,如下:

到這里關(guān)于JWT實(shí)現(xiàn)刷新Token就已結(jié)束,自我感覺此種實(shí)現(xiàn)刷新令牌將其存儲(chǔ)到數(shù)據(jù)庫(kù)的方案還算可取,將刷新令牌存儲(chǔ)到Redis也可行,看個(gè)人選擇吧。上述若刷新令牌驗(yàn)證無效,可將訪問者添加至黑名單,不過是添加一個(gè)屬性罷了。別著急,本節(jié)內(nèi)容結(jié)束前,還留有彩蛋。
EntityFramework Core Back Fields深入探討
無論是看視頻還是看技術(shù)博客也好,一定要?jiǎng)邮烛?yàn)證,看到這里覺得上述我所演示是不是毫無問題,如果閱讀本文的你直接拷貝上述代碼你會(huì)發(fā)現(xiàn)有問題,且聽我娓娓道來,讓我們來復(fù)習(xí)下Back Fields。Back Fields命名是有約定dei,上述我是根據(jù)約定而命名,所以千萬別一意孤行,別亂來,比如如下命名將拋出如下異常:
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è)問題,我們必須將其顯式配置成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è)人著作中也講解到為了性能問題,可將字段進(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ì)存在性能問題,如下:
private readonly List<UserRefreshToken> _userRefreshTokens = new List<UserRefreshToken>(); public IReadOnlyCollection<UserRefreshToken> UserRefreshTokens => _userRefreshTokens.AsReadOnly();
這是關(guān)于Back Fields問題之一,問題之二則是上述我們請(qǐng)求獲取刷新令牌中,我們先在刷新令牌的Back Fields中移除掉舊的刷新令牌,而后再創(chuàng)建新的刷新令牌,但是會(huì)拋出如下異常:


我們看到在添加刷新令牌時(shí),用戶Id是有值的,對(duì)不對(duì),這是為何呢?究其根本問題出在我們移除刷新令牌方法中,如下:
/// <summary>
/// 移除刷新token
/// </summary>
/// <param name="refreshToken"></param>
public void RemoveRefreshToken(string refreshToken)
{
_userRefreshTokens.Remove(_userRefreshTokens.FirstOrDefault(t => t.Token == refreshToken));
}
我們將查詢出來的導(dǎo)航屬性并將其映射到_userRefreshTokens字段中,此時(shí)是被上下文所追蹤,上述我們查詢出存在的刷新令牌并在跟蹤的刷新令牌中進(jìn)行移除,沒毛病,沒找到原因,于是乎,我將上述方法修改成如下看看是否必須需要主鍵才能刪除舊的刷新令牌:
/// <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 });
}
倒沒拋出異常,創(chuàng)建了一個(gè)新的刷新令牌,但是舊的刷新令牌卻沒刪除,如下:

至此未找到問題出在哪里,當(dāng)前版本為2.2,難道不能通過Back Fields移除對(duì)象?這個(gè)問題待解決。
總結(jié)
本節(jié)我們重點(diǎn)講解了如何實(shí)現(xiàn)JWT刷新令牌,并也略帶討論了EF Core中Back Fields以及尚未解決的問題,至此關(guān)于JWT已結(jié)束,下節(jié)開始正式進(jìn)入Docker小白系列,感謝閱讀。
到此這篇關(guān)于詳解ASP.NET Core Web Api之JWT刷新Token的文章就介紹到這了,更多相關(guān)ASP.NET Core Web Api JWT刷新Token內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
使用ASP.NET創(chuàng)建線程實(shí)例教程
這篇文章主要介紹了使用ASP.NET創(chuàng)建線程的方法,需要的朋友可以參考下2014-07-07
asp.net(c#)Enterprise Library 3.0 下載
asp.net(c#)Enterprise Library 3.0 下載...2007-04-04
Visual Studio尋找C#程序必要的運(yùn)行庫(kù)文件
這篇文章主要為大家詳細(xì)介紹了Visual Studio尋找C#程序必要的運(yùn)行庫(kù)文件,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-05-05
在asp.net中實(shí)現(xiàn)datagrid checkbox 全選的方法
在asp.net中實(shí)現(xiàn)datagrid checkbox 全選的方法...2006-12-12

