ASP.NET Core Web Api之JWT刷新Token(三)


前言

如題,本節我們進入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小白系列,感謝閱讀。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM