詳解ASP.NET Core Web Api之JWT刷新Token
前言
如題,本節(jié)我們進入JWT最后一節(jié)內(nèi)容,JWT本質(zhì)上就是從身份認(rèn)證服務(wù)器獲取訪問令牌,繼而對于用戶后續(xù)可訪問受保護資源,但是關(guān)鍵問題是:訪問令牌的生命周期到底設(shè)置成多久呢?見過一些使用JWT的童鞋會將JWT過期時間設(shè)置成很長,有的幾個小時,有的一天,有的甚至一個月,這么做當(dāng)然存在問題,如果被惡意獲得訪問令牌,那么可在整個生命周期中使用訪問令牌,也就是說存在冒充用戶身份,此時身份認(rèn)證服務(wù)器當(dāng)然也就是始終信任該冒牌訪問令牌,若要使得冒牌訪問令牌無效,唯一的方案則是修改密鑰,但是如果我們這么做了,則將使得已授予的訪問令牌都將無效,所以更改密鑰不是最佳方案,我們應(yīng)該從源頭盡量控制這個問題,而不是等到問題呈現(xiàn)再來想解決之道,刷新令牌閃亮登場。
RefreshToken
什么是刷新令牌呢?刷新訪問令牌是用來從身份認(rèn)證服務(wù)器交換獲得新的訪問令牌,有了刷新令牌可以在訪問令牌過期后通過刷新令牌重新獲取新的訪問令牌而無需客戶端通過憑據(jù)重新登錄,如此一來,既保證了用戶訪問令牌過期后的良好體驗,也保證了更高的系統(tǒng)安全性,同時,若通過刷新令牌獲取新的訪問令牌驗證其無效可將受訪者納入黑名單限制其訪問,那么訪問令牌和刷新令牌的生命周期設(shè)置成多久合適呢?這取決于系統(tǒng)要求的安全性,一般來講訪問令牌的生命周期不會太長,比如5分鐘,又比如獲取微信的AccessToken的過期時間為2個小時。接下來我將用兩張表來演示實現(xiàn)刷新令牌的整個過程,可能有更好的方案,歡迎在評論中提出,學(xué)習(xí),學(xué)習(xí)。我們新建一個http://localhost:5000的WebApi用于身份認(rèn)證,再新建一個http://localhost:5001的客戶端,首先點擊【模擬登錄獲取Toen】獲取訪問令牌和刷新令牌,然后點擊【調(diào)用客戶端獲取當(dāng)前時間】,如下:
接下來我們新建一張用戶表(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> /// 驗證刷新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; }
如上可以看到對于刷新令牌的操作我們將其放在用戶實體中,也就是使用EF Core中的Back Fields而不對外暴露。接下來我們將生成的訪問令牌、刷新令牌、驗證訪問令牌、獲取用戶身份封裝成對應(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)用戶點擊登錄,訪問身份認(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 }); }
此時我們回到如上給出的圖,我們點擊【模擬登錄獲取Token】,此時發(fā)出Ajax請求,然后將返回的訪問令牌和刷新令牌存儲到本地localStorage中,如下:
<input type="button" id="btn" value="模擬登錄獲取Token" /> <input type="button" id="btn-currentTime" value="調(diào)用客戶端獲取當(dāng)前時間" />
//模擬登陸 $('#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); }
此時我們再來點擊【調(diào)用客戶端獲取當(dāng)前時間】,同時將登錄返回的訪問令牌設(shè)置到請求頭中,代碼如下:
$('#btn-currentTime').click(function () { GetCurrentTime(); }); //調(diào)用客戶端獲取當(dāng)前時間 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) { } }); }
客戶端請求接口很簡單,為了讓大家一步步看明白,我也給出來,如下:
[Authorize] [HttpGet("api/[controller]")] public string GetCurrentTime() { return DateTime.Now.ToString("yyyy-MM-dd"); }
好了到了這里我們已經(jīng)實現(xiàn)模擬登錄獲取訪問令牌,并能夠調(diào)用客戶端接口獲取到當(dāng)前時間,同時我們也只是返回了刷新令牌并存儲到了本地localStorage中,并未用到。當(dāng)訪問令牌過期后我們需要通過訪問令牌和刷新令牌去獲取新的訪問令牌,對吧。那么問題來了。我們怎么知道訪問令牌已經(jīng)過期了呢?這是其一,其二是為何要發(fā)送舊的訪問令牌去獲取新的訪問令牌呢?直接通過刷新令牌去換取不行嗎?有問題是好的,就怕沒有任何思考,我們一一來解答。我們在客戶端添加JWT中間件時,里面有一個事件可以捕捉到訪問令牌已過期(關(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; } };
通過如上事件并捕捉訪問令牌過期異常,這里我們在響應(yīng)頭添加了一個自定義鍵act,值為expired,因為一個401只能反映未授權(quán),并不能代表訪問令牌已過期。當(dāng)我們在第一張圖中點擊【調(diào)用客戶端獲取當(dāng)前時間】發(fā)出Ajax請求時,如果訪問令牌過期,此時在Ajax請求中的error方法中捕捉到,我們在如上已給出發(fā)出Ajax請求的error方法中繼續(xù)進行如下補充:
error: function (xhr) { if (xhr.status === 401 && xhr.getResponseHeader('act') === 'expired') { // 訪問令牌肯定已過期 } }
到了這里我們已經(jīng)解決如何捕捉到訪問令牌已過期的問題,接下來我們需要做的則是獲取刷新令牌,直接通過刷新令牌換取新的訪問令牌也并非不可,只不過還是為了安全性考慮,我們加上舊的訪問令牌。接下來我們發(fā)出Ajax請求獲取刷新令牌,如下:
//獲取刷新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請求獲取刷新令牌的方法我們傳入了一個函數(shù),這個函數(shù)則是上一次調(diào)用接口訪問令牌過期的請求,點擊【調(diào)用客戶端獲取當(dāng)前時間】按鈕的Ajax請求error方法中,最終演變成如下這般:
error: function (xhr) { if (xhr.status === 401 && xhr.getResponseHeader('act') === 'expired') { /* 訪問令牌肯定已過期,將當(dāng)前請求傳入獲取刷新令牌方法, * 以便獲取刷新令牌換取新的令牌后繼續(xù)當(dāng)前請求 */ GetRefreshToken(GetCurrentTime); } }
接下來則是通過傳入舊的訪問令牌和刷新令牌調(diào)用接口換取新的訪問令牌,如下:
/// <summary> /// 刷新Token /// </summary> /// <returns></returns> [HttpPost("refresh-token")] public async Task<IActionResult> RefreshToken([FromBody] Request request) { //TODO 參數(shù)校驗 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 }); }
如上通過傳入舊的訪問令牌驗證并獲取用戶身份,然后驗證刷新令牌是否已經(jīng)過期,如果未過期則創(chuàng)建新的訪問令牌,同時更新刷新令牌。最終客戶端訪問令牌過期的那一刻,通過刷新令牌獲取新的訪問令牌繼續(xù)調(diào)用上一請求,如下:
到這里關(guān)于JWT實現(xiàn)刷新Token就已結(jié)束,自我感覺此種實現(xiàn)刷新令牌將其存儲到數(shù)據(jù)庫的方案還算可取,將刷新令牌存儲到Redis也可行,看個人選擇吧。上述若刷新令牌驗證無效,可將訪問者添加至黑名單,不過是添加一個屬性罷了。別著急,本節(jié)內(nèi)容結(jié)束前,還留有彩蛋。
EntityFramework Core Back Fields深入探討
無論是看視頻還是看技術(shù)博客也好,一定要動手驗證,看到這里覺得上述我所演示是不是毫無問題,如果閱讀本文的你直接拷貝上述代碼你會發(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é)果又會怎樣呢?
private readonly List<UserRefreshToken> _userRefreshTokens = new List<UserRefreshToken>(); public IEnumerable<UserRefreshToken> UserRefreshTokens => _userRefreshTokens.AsReadOnly();
此時為了解決這個問題,我們必須將其顯式配置成Back Fields,如下:
protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<User>(u => { var navigation = u.Metadata.FindNavigation(nameof(User.UserRefreshTokens)); navigation.SetPropertyAccessMode(PropertyAccessMode.Field); }); }
在我個人著作中也講解到為了性能問題,可將字段進行ToList(),若進行了ToList(),必須顯式配置成Back Fields,否則獲取不到刷新令牌導(dǎo)航屬性,如下:
private readonly List<UserRefreshToken> _userRefreshTokens = new List<UserRefreshToken>(); public IEnumerable<UserRefreshToken> UserRefreshTokens => _userRefreshTokens.ToList();
或者進行如下配置,我想應(yīng)該也可取,不會存在性能問題,如下:
private readonly List<UserRefreshToken> _userRefreshTokens = new List<UserRefreshToken>(); public IReadOnlyCollection<UserRefreshToken> UserRefreshTokens => _userRefreshTokens.AsReadOnly();
這是關(guān)于Back Fields問題之一,問題之二則是上述我們請求獲取刷新令牌中,我們先在刷新令牌的Back Fields中移除掉舊的刷新令牌,而后再創(chuàng)建新的刷新令牌,但是會拋出如下異常:
我們看到在添加刷新令牌時,用戶Id是有值的,對不對,這是為何呢?究其根本問題出在我們移除刷新令牌方法中,如下:
/// <summary> /// 移除刷新token /// </summary> /// <param name="refreshToken"></param> public void RemoveRefreshToken(string refreshToken) { _userRefreshTokens.Remove(_userRefreshTokens.FirstOrDefault(t => t.Token == refreshToken)); }
我們將查詢出來的導(dǎo)航屬性并將其映射到_userRefreshTokens字段中,此時是被上下文所追蹤,上述我們查詢出存在的刷新令牌并在跟蹤的刷新令牌中進行移除,沒毛病,沒找到原因,于是乎,我將上述方法修改成如下看看是否必須需要主鍵才能刪除舊的刷新令牌:
/// <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)建了一個新的刷新令牌,但是舊的刷新令牌卻沒刪除,如下:
至此未找到問題出在哪里,當(dāng)前版本為2.2,難道不能通過Back Fields移除對象?這個問題待解決。
總結(jié)
本節(jié)我們重點講解了如何實現(xiàn)JWT刷新令牌,并也略帶討論了EF Core中Back Fields以及尚未解決的問題,至此關(guān)于JWT已結(jié)束,下節(jié)開始正式進入Docker小白系列,感謝閱讀。
到此這篇關(guān)于詳解ASP.NET Core Web Api之JWT刷新Token的文章就介紹到這了,更多相關(guān)ASP.NET Core Web Api JWT刷新Token內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
asp.net(c#)Enterprise Library 3.0 下載
asp.net(c#)Enterprise Library 3.0 下載...2007-04-04在asp.net中實現(xiàn)datagrid checkbox 全選的方法
在asp.net中實現(xiàn)datagrid checkbox 全選的方法...2006-12-12