ASP.NET Core Web Api之JWT重新整理Token(三)

Jeffcky發表於2019-08-02

前言

如題,本節我們進入JWT最後一節內容,JWT本質上就是從身份認證伺服器獲取訪問令牌,繼而對於使用者後續可訪問受保護資源,但是關鍵問題是:訪問令牌的生命週期到底設定成多久呢?見過一些使用JWT的童鞋會將JWT過期時間設定成很長,有的幾個小時,有的一天,有的甚至一個月,這麼做當然存在問題,如果被惡意獲得訪問令牌,那麼可在整個生命週期中使用訪問令牌,也就是說存在冒充使用者身份,此時身份認證伺服器當然也就是始終信任該冒牌訪問令牌,若要使得冒牌訪問令牌無效,唯一的方案則是修改金鑰,但是如果我們這麼做了,則將使得已授予的訪問令牌都將無效,所以更改金鑰不是最佳方案,我們應該從源頭儘量控制這個問題,而不是等到問題呈現再來想解決之道,重新整理令牌閃亮登場。

 

RefreshToken

什麼是重新整理令牌呢?重新整理訪問令牌是用來從身份認證伺服器交換獲得新的訪問令牌,有了重新整理令牌可以在訪問令牌過期後通過重新整理令牌重新獲取新的訪問令牌而無需客戶端通過憑據重新登入,如此一來,既保證了使用者訪問令牌過期後的良好體驗,也保證了更高的系統安全性,同時,若通過重新整理令牌獲取新的訪問令牌驗證其無效可將受訪者納入黑名單限制其訪問,那麼訪問令牌和重新整理令牌的生命週期設定成多久合適呢?這取決於系統要求的安全性,一般來講訪問令牌的生命週期不會太長,比如5分鐘,又比如獲取微信的AccessToken的過期時間為2個小時。接下來我將用兩張表來演示實現重新整理令牌的整個過程,可能有更好的方案,歡迎在評論中提出,學習,學習。我們新建一個http://localhost:5000的WebApi用於身份認證,再新建一個http://localhost:5001的客戶端,首先點選【模擬登入獲取Toen】獲取訪問令牌和重新整理令牌,然後點選【呼叫客戶端獲取當前時間】,如下:

接下來我們新建一張使用者表(User)和使用者重新整理令牌表(UserRefreshToken),結構如下:

    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>
        /// 建立重新整理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而不對外暴露。接下來我們將生成的訪問令牌、重新整理令牌、驗證訪問令牌、獲取使用者身份封裝成對應方法如下:

        /// <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;
            }
        }

當使用者點選登入,訪問身份認證伺服器,登入成功後我們建立訪問令牌和重新整理令牌並返回,如下:

        [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】,此時發出Ajax請求,然後將返回的訪問令牌和重新整理令牌儲存到本地localStorage中,如下:

<input type="button" id="btn" value="模擬登入獲取Token" />

<input type="button" id="btn-currentTime" value="呼叫客戶端獲取當前時間" />
       //模擬登陸
        $('#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);
        }

此時我們再來點選【呼叫客戶端獲取當前時間】,同時將登入返回的訪問令牌設定到請求頭中,程式碼如下:

       $('#btn-currentTime').click(function () {
            GetCurrentTime();
        });

        //呼叫客戶端獲取當前時間
        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");
        }

好了到了這裡我們已經實現模擬登入獲取訪問令牌,並能夠呼叫客戶端介面獲取到當前時間,同時我們也只是返回了重新整理令牌並儲存到了本地localStorage中,並未用到。當訪問令牌過期後我們需要通過訪問令牌和重新整理令牌去獲取新的訪問令牌,對吧。那麼問題來了。我們怎麼知道訪問令牌已經過期了呢?這是其一,其二是為何要傳送舊的訪問令牌去獲取新的訪問令牌呢?直接通過重新整理令牌去換取不行嗎?有問題是好的,就怕沒有任何思考,我們一一來解答。我們在客戶端新增JWT中介軟體時,裡面有一個事件可以捕捉到訪問令牌已過期(關於客戶端配置JWT中介軟體第一節已講過,這裡不再囉嗦),如下:

                  options.Events = new JwtBearerEvents
                  {
                      OnAuthenticationFailed = context =>
                      {
                          if (context.Exception.GetType() == typeof(SecurityTokenExpiredException))
                          {
                              context.Response.Headers.Add("act", "expired");
                          }
                          return Task.CompletedTask;
                      }
                  };

通過如上事件並捕捉訪問令牌過期異常,這裡我們在響應頭新增了一個自定義鍵act,值為expired,因為一個401只能反映未授權,並不能代表訪問令牌已過期。當我們在第一張圖中點選【呼叫客戶端獲取當前時間】發出Ajax請求時,如果訪問令牌過期,此時在Ajax請求中的error方法中捕捉到,我們在如上已給出發出Ajax請求的error方法中繼續進行如下補充:

                error: function (xhr) {
                    if (xhr.status === 401 && xhr.getResponseHeader('act') === 'expired') {
                        // 訪問令牌肯定已過期
                    }
                }

到了這裡我們已經解決如何捕捉到訪問令牌已過期的問題,接下來我們需要做的則是獲取重新整理令牌,直接通過重新整理令牌換取新的訪問令牌也並非不可,只不過還是為了安全性考慮,我們加上舊的訪問令牌。接下來我們發出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) {
                        // 跳轉至登入
                    } else {
                        saveAccessToken(data.accessToken);
                        saveRefreshToken(data.refreshToken);
                        func();
                    }
                }
            });
        }

發出Ajax請求獲取重新整理令牌的方法我們傳入了一個函式,這個函式則是上一次呼叫介面訪問令牌過期的請求,點選【呼叫客戶端獲取當前時間】按鈕的Ajax請求error方法中,最終演變成如下這般:

              error: function (xhr) {
                    if (xhr.status === 401 && xhr.getResponseHeader('act') === 'expired') {

                        /* 訪問令牌肯定已過期,將當前請求傳入獲取重新整理令牌方法,
                         * 以便獲取重新整理令牌換取新的令牌後繼續當前請求
                        */
                        GetRefreshToken(GetCurrentTime);
                    }
                }

接下來則是通過傳入舊的訪問令牌和重新整理令牌呼叫介面換取新的訪問令牌,如下:

        /// <summary>
        /// 重新整理Token
        /// </summary>
        /// <returns></returns>
        [HttpPost("refresh-token")]
        public async Task<IActionResult> RefreshToken([FromBody] Request request)
        {
            //TODO 引數校驗

            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
            });
        }

如上通過傳入舊的訪問令牌驗證並獲取使用者身份,然後驗證重新整理令牌是否已經過期,如果未過期則建立新的訪問令牌,同時更新重新整理令牌。最終客戶端訪問令牌過期的那一刻,通過重新整理令牌獲取新的訪問令牌繼續呼叫上一請求,如下:

到這裡關於JWT實現重新整理Token就已結束,自我感覺此種實現重新整理令牌將其儲存到資料庫的方案還算可取,將重新整理令牌儲存到Redis也可行,看個人選擇吧。上述若重新整理令牌驗證無效,可將訪問者新增至黑名單,不過是新增一個屬性罷了。彆著急,本節內容結束前,還留有彩蛋。

EntityFramework Core Back Fields深入探討

無論是看視訊還是看技術部落格也好,一定要動手驗證,看到這裡覺得上述我所演示是不是毫無問題,如果閱讀本文的你直接拷貝上述程式碼你會發現有問題,且聽我娓娓道來,讓我們來複習下Back Fields。Back Fields命名是有約定dei,上述我是根據約定而命名,所以千萬別一意孤行,別亂來,比如如下命名將丟擲如下異常:

 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;

要是我們配置成如下形式,結果又會怎樣呢?

 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,否則獲取不到重新整理令牌導航屬性,如下:

private readonly List<UserRefreshToken> _userRefreshTokens = new List<UserRefreshToken>();
public IEnumerable<UserRefreshToken> UserRefreshTokens => _userRefreshTokens.ToList();

或者進行如下配置,我想應該也可取,不會存在效能問題,如下:

  private readonly List<UserRefreshToken> _userRefreshTokens = new List<UserRefreshToken>();
  public IReadOnlyCollection<UserRefreshToken> UserRefreshTokens => _userRefreshTokens.AsReadOnly();

這是關於Back Fields問題之一,問題之二則是上述我們請求獲取重新整理令牌中,我們先在重新整理令牌的Back Fields中移除掉舊的重新整理令牌,而後再建立新的重新整理令牌,但是會丟擲如下異常:

我們看到在新增重新整理令牌時,使用者Id是有值的,對不對,這是為何呢?究其根本問題出在我們移除重新整理令牌方法中,如下:

        /// <summary>
        /// 移除重新整理token
        /// </summary>
        /// <param name="refreshToken"></param>
        public void RemoveRefreshToken(string refreshToken)
        {
            _userRefreshTokens.Remove(_userRefreshTokens.FirstOrDefault(t => t.Token == refreshToken));
        }

我們將查詢出來的導航屬性並將其對映到_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 });
        }

倒沒丟擲異常,建立了一個新的重新整理令牌,但是舊的重新整理令牌卻沒刪除,如下:

至此未找到問題出在哪裡,當前版本為2.2,難道不能通過Back Fields移除物件?這個問題待解決。

總結

本節我們重點講解了如何實現JWT重新整理令牌,並也略帶討論了EF Core中Back Fields以及尚未解決的問題,至此關於JWT已結束,下節開始正式進入Docker小白系列,感謝閱讀。

相關文章