Asp Net Core 5 REST API 使用 RefreshToken 刷新 JWT – Step by Step

翻譯自 Mohamad Lawand 2021年1月25日的文章 《Refresh JWT with Refresh Tokens in Asp Net Core 5 Rest API Step by Step》 [1]

在本文中,我將向您演示如何在 Asp.Net Core REST API 中將 Refresh Token 添加到 JWT 身份驗證。

我們將覆蓋的一些主題包含:Refresh Token、一些新的 Endpoints 功能和 JWT(JSON Web Token)。

你也可以在 YouTube 上觀看完整的視頻[2],還可以下載源代碼[3]

這是 REST API 開發系列的第三部分,前面還有:

Refresh JWT with Refresh Tokens in Asp Net Core 5 Rest API

我將基於在上一篇文章中創建的 Todo REST API 應用程序進行當前的講述。您可以通過閱讀上一篇文章並與我一起構建應用程序,或者可以從 github 下載上一篇中的源代碼

在開始實現 Refresh Token 功能之前,讓我們先來了解一下 Refresh Token 的運行邏輯是怎樣的。

本質上,JWT token 有一個過期時間,時間越短越安全。在 JWT token 過期後,有兩種方法可以獲取新的 token:

  1. 要求用戶重新登錄(這不是一個好的用戶體驗)。
  2. 使用 Refresh Token 自動重新驗證用戶並生成新的 JWT token。

那麼,Refresh Token 是什麼呢?一個 Refresh Token 可以是任何東西,從字符串到 Guid 到任意組合,只要它是唯一的

為什麼短暫生命周期的 JWT token 很重要,這是因為如果有人竊取了我們的 JWT token 並開始請求我們的服務器,那麼該 token 在過期(變得不可用)之前只會持續一小段時間。獲取新 token 的唯一方法是使用 Refresh Token 或登錄。

另一個重點是,如果用戶更改了密碼,則根據之前的用戶憑據生成的所有 token 會怎樣呢。我們並不想使所有會話都失效,我們只需請求刷新 Token,那麼將生成一個基於新憑證的新 JWT token。

另外,實現自動刷新 token 的一個好辦法是,在客戶端發出每個請求之前,都需要檢查 token 的過期時間,如果已過期,我們就請求一個新的 token,否則就使用現有的 token 執行請求。

因此,我們將在應用程序中添加一個 Refresh Token,而不僅僅是在每次授權時都只生成一個 JWT token。

那就讓我們開始吧,首先我們將更新 Startup 類,通過將 TokenValidationParameters 添加到依賴注入容器,使它在整個應用程序中可用。

var key = Encoding.ASCII.GetBytes(Configuration["JwtConfig:Secret"]);

var tokenValidationParams = new TokenValidationParameters
{
    ValidateIssuerSigningKey = true,
    IssuerSigningKey = new SymmetricSecurityKey(key),
    ValidateIssuer = false,
    ValidateAudience = false,
    ValidateLifetime = true,
    RequireExpirationTime = false,
    ClockSkew = TimeSpan.Zero
};

services.AddSingleton(tokenValidationParams);

services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(jwt =>
{
    jwt.SaveToken = true;
    jwt.TokenValidationParameters = tokenValidationParams;
});

更新完 Startup 類以後,我們需要更新 AuthManagementController 中的 GenerateJwtToken 函數,將 TokenDescriptorExpires 值從之前的值更新為 30 秒(比較合理的值為 5~10 分鐘,這裡設置為 30 秒只是作演示用),我們需要把它指定的更短一些。

譯者註:
實際使用時,可以在 appsettings.json 中為 JwtConfig 添加一個代表 token 過期時間的 ExpiryTimeFrame 配置項,對應的在 JwtConfig 類中添加一個 ExpiryTimeFrame 屬性,然後賦值給 TokenDescriptorExpires,這樣 token 的過期時間就變得可配置了。

private string GenerateJwtToken(IdentityUser user)
{
    var jwtTokenHandler = new JwtSecurityTokenHandler();

    var key = Encoding.ASCII.GetBytes(_jwtConfig.Secret);

    var tokenDescriptor = new SecurityTokenDescriptor
    {
        Subject = new ClaimsIdentity(new[]
        {
            new Claim("Id", user.Id),
            new Claim(JwtRegisteredClaimNames.Email, user.Email),
            new Claim(JwtRegisteredClaimNames.Sub, user.Email),
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
        }),
        Expires = DateTime.UtcNow.AddSeconds(30), // 比較合理的值為 5~10 分鐘,這裡設置 30 秒只是作演示用
        SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
    };

    var token = jwtTokenHandler.CreateToken(tokenDescriptor);
    var jwtToken = jwtTokenHandler.WriteToken(token);

    return jwtToken;
}

接下來的步驟是更新 Configuration 文件夾中的 AuthResult,我們需要為 Refresh Token 添加一個新屬性:

// Configuration\AuthResult.cs

public class AuthResult
{
    public string Token { get; set; }
    public string RefreshToken { get; set; }
    public bool Success { get; set; }
    public List<string> Errors { get; set; }
}

我們將在 Models/DTOs/Requests 中添加一個名為 TokenRequest 的新類,該類負責接收稍後我們將創建的新 Endpoint 的請求參數,用於管理刷新 Token。

// Models\DTOs\Requests\TokenRequest.cs

public class TokenRequest
{
    /// <summary>
    /// 原 Token
    /// </summary>
    [Required]
    public string Token { get; set; }
    /// <summary>
    /// Refresh Token
    /// </summary>
    [Required]
    public string RefreshToken { get; set; }
}

下一步是在我們的 Models 文件夾中創建一個名為 RefreshToken 的新模型。

// Models\RefreshToken.cs

public class RefreshToken
{
    public int Id { get; set; }
    public string UserId { get; set; } // 連接到 ASP.Net Identity User Id
    public string Token { get; set; }  // Refresh Token
    public string JwtId { get; set; } // 使用 JwtId 映射到對應的 token
    public bool IsUsed { get; set; } // 如果已經使用過它,我們不想使用相同的 refresh token 生成新的 JWT token
    public bool IsRevorked { get; set; } // 是否出於安全原因已將其撤銷
    public DateTime AddedDate { get; set; }
    public DateTime ExpiryDate { get; set; } // refresh token 的生命周期很長,可以持續數月

    [ForeignKey(nameof(UserId))]
    public IdentityUser User {get;set;}
}

添加 RefreshToken 模型後,我們需要更新 ApiDbContext 類:

public virtual DbSet<RefreshToken> RefreshTokens { get; set; }

現在讓我們為 ApiDbContext 創建數據庫遷移,以便可以反映數據庫中的更改:

dotnet ef migrations add "Added refresh tokens table"
dotnet ef database update

下一步是在 AuthManagementController 中創建一個新的名為 RefreshToken 的 Endpoind。需要做的第一件事是注入 TokenValidationParameters

private readonly UserManager<IdentityUser> _userManager;
private readonly JwtConfig _jwtConfig;
private readonly TokenValidationParameters _tokenValidationParams;
private readonly ApiDbContext _apiDbContext;

public AuthManagementController(
    UserManager<IdentityUser> userManager,
    IOptionsMonitor<JwtConfig> optionsMonitor,
    TokenValidationParameters tokenValidationParams,
    ApiDbContext apiDbContext)
{
    _userManager = userManager;
    _jwtConfig = optionsMonitor.CurrentValue;
    _tokenValidationParams = tokenValidationParams;
    _apiDbContext = apiDbContext;
}

注入所需的參數後,我們需要更新 GenerateJwtToken 函數以包含 Refresh Token:

private async Task<AuthResult> GenerateJwtToken(IdentityUser user)
{
    var jwtTokenHandler = new JwtSecurityTokenHandler();

    var key = Encoding.ASCII.GetBytes(_jwtConfig.Secret);

    var tokenDescriptor = new SecurityTokenDescriptor
    {
        Subject = new ClaimsIdentity(new[]
        {
            new Claim("Id", user.Id),
            new Claim(JwtRegisteredClaimNames.Email, user.Email),
            new Claim(JwtRegisteredClaimNames.Sub, user.Email),
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
        }),
        Expires = DateTime.UtcNow.AddSeconds(30), // 比較合理的值為 5~10 分鐘,這裡設置 30 秒只是作演示用
        SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
    };

    var token = jwtTokenHandler.CreateToken(tokenDescriptor);
    var jwtToken = jwtTokenHandler.WriteToken(token);

    var refreshToken = new RefreshToken()
    {
        JwtId = token.Id,
        IsUsed = false,
        IsRevorked = false,
        UserId = user.Id,
        AddedDate = DateTime.UtcNow,
        ExpiryDate = DateTime.UtcNow.AddMonths(6),
        Token = RandomString(25) + Guid.NewGuid()
    };

    await _apiDbContext.RefreshTokens.AddAsync(refreshToken);
    await _apiDbContext.SaveChangesAsync();

    return new AuthResult()
    {
        Token = jwtToken,
        Success = true,
        RefreshToken = refreshToken.Token
    };
}

private string RandomString(int length)
{
    var random = new Random();
    var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
    return new string(Enumerable.Repeat(chars, length)
        .Select(x => x[random.Next(x.Length)]).ToArray());
}

現在,讓我們更新兩個現有 Action 的返回值,因為我們已經更改了 GenerateJwtToken 的返回類型

Login Action:

return Ok(await GenerateJwtToken(existingUser));

Register Action:

return Ok(await GenerateJwtToken(newUser));

然後,我們可以開始構建 RefreshToken Action:

[HttpPost]
[Route("RefreshToken")]
public async Task<IActionResult> RefreshToken([FromBody] TokenRequest tokenRequest)
{
    if (ModelState.IsValid)
    {
        var result = await VerifyAndGenerateToken(tokenRequest);

        if (result == null)
        {
            return BadRequest(new RegistrationResponse()
            {
                Errors = new List<string>() 
                {
                    "Invalid tokens"
                },
                Success = false
            });
        }

        return Ok(result);
    }

    return BadRequest(new RegistrationResponse()
    {
        Errors = new List<string>() 
        {
            "Invalid payload"
        },
        Success = false
    });
}

private async Task<AuthResult> VerifyAndGenerateToken(TokenRequest tokenRequest)
{
    var jwtTokenHandler = new JwtSecurityTokenHandler();

    try
    {
        // Validation 1 - Validation JWT token format
        // 此驗證功能將確保 Token 滿足驗證參數,並且它是一個真正的 token 而不僅僅是隨機字符串
        var tokenInVerification = jwtTokenHandler.ValidateToken(tokenRequest.Token, _tokenValidationParams, out var validatedToken);

        // Validation 2 - Validate encryption alg
        // 檢查 token 是否有有效的安全算法
        if (validatedToken is JwtSecurityToken jwtSecurityToken)
        {
            var result = jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase);

            if (result == false)
            {
                return null;
            }
        }

        // Validation 3 - validate expiry date
        // 驗證原 token 的過期時間,得到 unix 時間戳
        var utcExpiryDate = long.Parse(tokenInVerification.Claims.FirstOrDefault(x => x.Type == JwtRegisteredClaimNames.Exp).Value);

        var expiryDate = UnixTimeStampToDateTime(utcExpiryDate);

        if (expiryDate > DateTime.UtcNow)
        {
            return new AuthResult()
            {
                Success = false,
                Errors = new List<string>() 
                {
                    "Token has not yet expired"
                }
            };
        }

        // validation 4 - validate existence of the token
        // 驗證 refresh token 是否存在,是否是保存在數據庫的 refresh token
        var storedRefreshToken = await _apiDbContext.RefreshTokens.FirstOrDefaultAsync(x => x.Token == tokenRequest.RefreshToken);

        if (storedRefreshToken == null)
        {
            return new AuthResult()
            {
                Success = false,
                Errors = new List<string>() 
                {
                    "Refresh Token does not exist"
                }
            };
        }

        // Validation 5 - 檢查存儲的 RefreshToken 是否已過期
        // Check the date of the saved refresh token if it has expired
        if (DateTime.UtcNow > storedRefreshToken.ExpiryDate)
        {
            return new AuthResult()
            {
                Errors = new List<string>() { "Refresh Token has expired, user needs to re-login" },
                Success = false
            };
        }

        // Validation 6 - validate if used
        // 驗證 refresh token 是否已使用
        if (storedRefreshToken.IsUsed)
        {
            return new AuthResult()
            {
                Success = false,
                Errors = new List<string>() 
                {
                    "Refresh Token has been used"
                }
            };
        }

        // Validation 7 - validate if revoked
        // 檢查 refresh token 是否被撤銷
        if (storedRefreshToken.IsRevorked)
        {
            return new AuthResult()
            {
                Success = false,
                Errors = new List<string>() 
                {
                    "Refresh Token has been revoked"
                }
            };
        }

        // Validation 8 - validate the id
        // 這裡獲得原 JWT token Id
        var jti = tokenInVerification.Claims.FirstOrDefault(x => x.Type == JwtRegisteredClaimNames.Jti).Value;

        // 根據數據庫中保存的 Id 驗證收到的 token 的 Id
        if (storedRefreshToken.JwtId != jti)
        {
            return new AuthResult()
            {
                Success = false,
                Errors = new List<string>() 
                {
                    "The token doesn't mateched the saved token"
                }
            };
        }

        // update current token 
        // 將該 refresh token 設置為已使用
        storedRefreshToken.IsUsed = true;
        _apiDbContext.RefreshTokens.Update(storedRefreshToken);
        await _apiDbContext.SaveChangesAsync();

        // 生成一個新的 token
        var dbUser = await _userManager.FindByIdAsync(storedRefreshToken.UserId);
        return await GenerateJwtToken(dbUser);
    }
    catch (Exception ex)
    {
        if (ex.Message.Contains("Lifetime validation failed. The token is expired."))
        {
            return new AuthResult()
            {
                Success = false,
                Errors = new List<string>() 
                {
                    "Token has expired please re-login"
                }
            };
        }
        else
        {
            return new AuthResult()
            {
                Success = false,
                Errors = new List<string>() 
                {
                    "Something went wrong."
                }
            };
        }
    }
}

private DateTime UnixTimeStampToDateTime(long unixTimeStamp)
{
    var dateTimeVal = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc);
    dateTimeVal = dateTimeVal.AddSeconds(unixTimeStamp).ToLocalTime();
    return dateTimeVal;
}

最後,我們需要確保一切可以正常構建和運行。

dotnet build
dotnet run

當我們確定一切 OK 後,我們可以使用 Postman 測試應用程序,測試場景如下所示:

  • 登錄,生成帶有刷新令牌的 JWT 令牌 ⇒ 成功
  • 不等待令牌過期而直接嘗試刷新令牌 ⇒ 失敗
  • 等待 JWT 令牌過期然後請求刷新令牌 ⇒ 成功
  • 重新使用相同的刷新令牌 ⇒ 失敗

感謝您花時間閱讀本文。

本文是 API 開發系列的第三部分,你可以通過下面鏈接閱讀前兩部分:

作者 : Mohamad Lawand
譯者 : 技術譯民
出品 : 技術譯站
鏈接 : 英文原文


  1. //dev.to/moe23/refresh-jwt-with-refresh-tokens-in-asp-net-core-5-rest-api-step-by-step-3en5 Refresh JWT with Refresh Tokens in Asp Net Core 5 Rest API Step by Step ↩︎

  2. //youtu.be/T_Hla1WzaZQ ↩︎

  3. //github.com/mohamadlawand087/v8-refreshtokenswithJWT ↩︎