Ultimate ASP.NET Core Web API 28 REFRESH TOKEN

28 REFRESH TOKEN
28 刷新令牌

In this chapter, we are going to learn about refresh tokens and their use in modern web application development.‌
在本章中,我们将了解刷新令牌及其在现代 Web 应用程序开发中的使用。

In the previous chapter, we have created a flow where a user logs in, gets an access token to be able to access protected resources, and after the token expires, the user has to log in again to obtain a new valid token:
在上一章中,我们创建了一个流程,用户登录后,获取访问令牌才能访问受保护的资源,令牌过期后,用户必须再次登录才能获取新的有效令牌:

alt text

This flow is great and is used by many enterprise applications.
此流程非常棒,并被许多企业应用程序使用。

But sometimes we have a requirement not to force our users to log in every single time the token expires. For that, we can use a refresh token.
但有时我们要求不要在每次令牌过期时都强制我们的用户登录。为此,我们可以使用 refresh token。

Refresh tokens are credentials that can be used to acquire new access tokens. When an access token expires, we can use a refresh token to get a new access token from the authentication component. The lifetime of a refresh token is usually set much longer compared to the lifetime of an access token.
刷新令牌是可用于获取新访问令牌的凭证。当 access token 过期时,我们可以使用 refresh token 从身份验证组件获取新的访问 Token。与访问令牌的生命周期相比,刷新令牌的生命周期通常要长得多。

Let’s introduce the refresh token to our authentication workflow:
让我们将刷新令牌引入我们的身份验证工作流程:

alt text

  1. First, the client authenticates with the authentication component by providing the credentials.
    首先,客户端通过提供凭证来使用身份验证组件进行身份验证。

  2. Then, the authentication component issues the access token and the refresh token.
    然后,身份验证组件颁发访问令牌和刷新令牌。

  3. After that, the client requests the resource endpoints for a protected resource by providing the access token.
    之后,客户端通过提供访问令牌来请求受保护资源的资源终端节点。

  4. The resource endpoint validates the access token and provides a protected resource.
    资源终端节点验证访问令牌并提供受保护的资源。

  5. Steps 3 & 4 keep on repeating until the access token expires.
    步骤3和4不断重复,直到访问令牌过期。

  6. Once the access token expires, the client requests a new access token by providing the refresh token.
    访问令牌过期后,客户端将通过提供刷新令牌来请求新的访问令牌。

  7. The authentication component issues a new access token and refresh token.
    身份验证组件颁发新的访问令牌和刷新令牌。

  8. Steps 3 through 7 keep on repeating until the refresh token expires.
    步骤 3 到 7 会不断重复,直到刷新令牌过期。

  9. Once the refresh token expires, the client needs to authenticate with the authentication server once again and the flow repeats from step 1.
    刷新令牌过期后,客户端需要再次向身份验证服务器进行身份验证,并且从步骤 1 开始重复该流程。

28.1 Why Do We Need a Refresh Token

为什么需要刷新令牌

So, why do we need both access tokens and refresh tokens? Why don’t we just set a long expiration date, like a month or a year for the access tokens? Because, if we do that and someone manages to get hold of our access token they can use it for a long period, even if we change our password!‌
那么,为什么我们需要访问令牌和刷新令牌呢?为什么我们不直接设置一个较长的到期日期,例如访问令牌的一个月或一年呢?因为,如果我们这样做并且有人设法获得了我们的访问令牌,即使我们更改了密码,他们也可以长时间使用它!

The idea of refresh tokens is that we can make the access token short- lived so that, even if it is compromised, the attacker gets access only for a shorter period. With refresh token-based flow, the authentication server issues a one-time use refresh token along with the access token. The app stores the refresh token safely.
刷新令牌的理念是,我们可以使访问令牌的生存期较短,这样,即使它被泄露,攻击者也只能在较短的时间内获得访问权限。使用基于刷新令牌的流程,身份验证服务器会颁发一次性使用的刷新令牌以及访问令牌。应用程序安全地存储刷新令牌。

Every time the app sends a request to the server it sends the access token in the Authorization header and the server can identify the app using it. Once the access token expires, the server will send a token expired response. Once the app receives the token expired response, it sends the expired access token and the refresh token to obtain a new access token and a refresh token.
每次应用程序向服务器发送请求时,它都会在 Authorization 标头中发送访问令牌,服务器可以使用它来识别应用程序。一旦访问令牌过期,服务器将发送令牌过期响应。应用程序收到令牌过期响应后,它会发送过期的访问令牌和刷新令牌,以获取新的访问令牌和刷新令牌。

If something goes wrong, the refresh token can be revoked which means that when the app tries to use it to get a new access token, that request will be rejected and the user will have to enter credentials once again and authenticate.
如果出现问题,可以撤销刷新令牌,这意味着当应用程序尝试使用它来获取新的访问令牌时,该请求将被拒绝,用户将不得不再次输入凭据并进行身份验证。

Thus, refresh tokens help in a smooth authentication workflow without the need for users to submit their credentials frequently, and at the same time, without compromising the security of the app.
因此,刷新令牌有助于实现顺畅的身份验证工作流程,而无需用户频繁提交其凭证,同时不会影响应用程序的安全性。

28.2 Refresh Token Implementation

28.2 刷新令牌实现

So far we have learned the concept of refresh tokens. Now, let’s dig into‌ the implementation part.
到目前为止,我们已经了解了刷新令牌的概念。现在,让我们深入研究实现部分。

The first thing we have to do is to modify the User class:
我们要做的第一件事是修改 User 类:

public class User : IdentityUser { public string? FirstName { get; set; } public string? LastName { get; set; } public string? RefreshToken { get; set; } public DateTime RefreshTokenExpiryTime { get; set; } }

Here we add two additional properties, which we are going to add to the AspNetUsers table.
在这里,我们添加了两个附加属性,我们将将它们添加到 AspNetUsers 表中。

To do that, we have to create and execute another migration:
为此,我们必须创建并执行另一个迁移:

Add-Migration AdditionalUserFiledsForRefreshToken

If for some reason you get the message that you need to review your migration due to possible data loss, you should inspect the migration file and leave only the code that adds and removes our additional columns:
如果出于某种原因,您收到一条消息,指出由于可能的数据丢失而需要检查迁移,则应检查迁移文件,并仅保留添加和删除其他列的代码:

protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.AddColumn<string>( name: "RefreshToken", table: "AspNetUsers", type: "nvarchar(max)", nullable: true); migrationBuilder.AddColumn<DateTime>( name: "RefreshTokenExpiryTime", table: "AspNetUsers", type: "datetime2", nullable: false, defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); } protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropColumn( name: "RefreshToken", table: "AspNetUsers"); migrationBuilder.DropColumn( name: "RefreshTokenExpiryTime", table: "AspNetUsers"); }

Also, you should open the RepositoryContextModelSnapshot file, find the AspNetRoles part and revert the Ids of both roles to the previous values:
此外,还应打开 RepositoryContextModelSnapshot 文件,找到 AspNetRoles 部分,并将两个角色的 ID 还原为以前的值:

b.ToTable("AspNetRoles", (string)null); b.HasData( new { Id = "4ac8240a-8498-4869-bc86-60e5dc982d27", ConcurrencyStamp = "ec511bd4-4853-426a-a2fc-751886560c9a", Name = "Manager", NormalizedName = "MANAGER" }, new { Id = "562419f5-eed1-473b-bcc1-9f2dbab182b4", ConcurrencyStamp = "937e9988-9f49-4bab-a545-b422dde85016", Name = "Administrator", NormalizedName = "ADMINISTRATOR" });

After that is done, we can execute our migration with the Update- Database command. This will add two additional columns in the AspNetUsers table.
完成后,我们可以使用 Update- Database 命令执行迁移。这将在 AspNetUsers 表中添加两个附加列。

To continue, let’s create a new record in the Shared/DataTransferObjects folder:
要继续,让我们在 Shared/DataTransferObjects 文件夹中创建一个新记录:

public record TokenDto(string AccessToken, string RefreshToken);

Next, we are going to modify the IAuthenticationService interface:
接下来,我们将修改 IAuthenticationService 接口:

public interface IAuthenticationService { Task<IdentityResult> RegisterUser(UserForRegistrationDto userForRegistration); Task<bool> ValidateUser(UserForAuthenticationDto userForAuth); Task<TokenDto> CreateToken(bool populateExp); }

Then, we have to implement two new methods in the AuthenticationService class:
然后,我们必须在 AuthenticationService 类中实现两个新方法:

private string GenerateRefreshToken() { var randomNumber = new byte[32]; using (var rng = RandomNumberGenerator.Create()) { rng.GetBytes(randomNumber); return Convert.ToBase64String(randomNumber);} } private ClaimsPrincipal GetPrincipalFromExpiredToken(string token) { var jwtSettings = _configuration.GetSection("JwtSettings"); var tokenValidationParameters = new TokenValidationParameters { ValidateAudience = true, ValidateIssuer = true, ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("SECRET"))), ValidateLifetime = true, ValidIssuer = jwtSettings["validIssuer"], ValidAudience = jwtSettings["validAudience"] }; var tokenHandler = new JwtSecurityTokenHandler(); SecurityToken securityToken; var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out securityToken); var jwtSecurityToken = securityToken as JwtSecurityToken; if (jwtSecurityToken == null || !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase)) { throw new SecurityTokenException("Invalid token"); } return principal; }

GenerateRefreshToken contains the logic to generate the refresh token. We use the RandomNumberGenerator class to generate a cryptographic random number for this purpose.
GenerateRefreshToken 包含生成刷新令牌的逻辑。为此,我们使用 RandomNumberGenerator 类生成加密随机数。

GetPrincipalFromExpiredToken is used to get the user principal from the expired access token. We make use of the ValidateToken method from the JwtSecurityTokenHandler class for this purpose. This method validates the token and returns the ClaimsPrincipal object.
GetPrincipalFromExpiredToken 用于从过期的访问令牌中获取用户主体。为此,我们使用 JwtSecurityTokenHandler 类中的 ValidateToken 方法。此方法验证令牌并返回 ClaimsPrincipal 对象。

After that, to generate a refresh token and the expiry date for the logged- in user, and to return both the access token and refresh token to the caller, we have to modify the CreateToken method in the same class:
之后,要为已登录用户生成刷新令牌和到期日期,并将访问令牌和刷新令牌返回给调用方,我们必须修改同一类中的 CreateToken 方法:

public async Task<TokenDto> CreateToken(bool populateExp) { var signingCredentials = GetSigningCredentials();var claims = await GetClaims(); var tokenOptions = GenerateTokenOptions(signingCredentials, claims); var refreshToken = GenerateRefreshToken(); _user.RefreshToken = refreshToken; if(populateExp) _user.RefreshTokenExpiryTime = DateTime.Now.AddDays(7); await _userManager.UpdateAsync(_user); var accessToken = new JwtSecurityTokenHandler().WriteToken(tokenOptions); return new TokenDto(accessToken, refreshToken); }

Finally, we have to modify the Authenticate action:
最后,我们必须修改 Authenticate作:

[HttpPost("login")] [ServiceFilter(typeof(ValidationFilterAttribute))] public async Task<IActionResult> Authenticate([FromBody] UserForAuthenticationDto user) { if (!await _service.AuthenticationService.ValidateUser(user)) return Unauthorized(); var tokenDto = await _service.AuthenticationService .CreateToken(populateExp: true); return Ok(tokenDto); }

That’s it regarding the action modification.
这就是关于动作修改的内容。

Now, we can test this by sending the POST request from Postman:
现在,我们可以通过从 Postman 发送 POST 请求来测试这一点:

https://localhost:5001/api/authentication/login

alt text

We can see the successful authentication and both our tokens. Additionally, if we inspect the database, we are going to find populated RefreshToken and Expiry columns for JDoe:
我们可以看到成功的身份验证和我们的令牌。此外,如果我们检查数据库,我们将找到填充的 JDoe 的 RefreshToken 和 Expiry 列:

alt text

It is a good practice to have a separate endpoint for the refresh token‌ action, and that’s exactly what we are going to do now.
最好为刷新令牌作设置一个单独的终端节点,这正是我们现在要做的事情。

Let’s start by creating a new TokenController in the Presentation project:
让我们首先在 Presentation 项目中创建一个新的 TokenController:

[Route("api/token")] [ApiController] public class TokenController : ControllerBase { private readonly IServiceManager _service; public TokenController(IServiceManager service) => _service = service; }

Before we continue with the controller modification, we are going to modify the IAuthenticationService interface:
在继续修改控制器之前,我们将修改 IAuthenticationService 接口:

public interface IAuthenticationService { Task<IdentityResult> RegisterUser(UserForRegistrationDto userForRegistration); Task<bool> ValidateUser(UserForAuthenticationDto userForAuth); Task<TokenDto> CreateToken(bool populateExp); Task<TokenDto> RefreshToken(TokenDto tokenDto); }

And to implement this method:
要实现此方法:

public async Task<TokenDto> RefreshToken(TokenDto tokenDto) { var principal = GetPrincipalFromExpiredToken(tokenDto.AccessToken); var user = await _userManager.FindByNameAsync(principal.Identity.Name); if (user == null || user.RefreshToken != tokenDto.RefreshToken || user.RefreshTokenExpiryTime <= DateTime.Now) throw new RefreshTokenBadRequest(); _user = user; return await CreateToken(populateExp: false); }

We first extract the principal from the expired token and use the Identity.Name property, which is the username of the user, to fetch that user from the database. If the user doesn’t exist, or the refresh tokens are not equal, or the refresh token has expired, we stop the flow returning the BadRequest response to the user. Then we just populate the _user variable and call the CreateToken method to generate new Access and Refresh tokens. This time, we don’t want to update the expiry time of the refresh token thus sending false as a parameter.
我们首先从过期的令牌中提取主体,并使用 Identity.Name 属性(即用户的用户名)从数据库中获取该用户。如果用户不存在,或者刷新令牌不相等,或者刷新令牌已过期,我们将停止向用户返回 BadRequest 响应的流。然后,我们只需填充 _user 变量并调用 CreateToken 方法以生成新的 Access 和 Refresh 令牌。这一次,我们不想更新刷新令牌的到期时间,因此发送 false 作为参数。

Since we don’t have the RefreshTokenBadRequest class, let’s create it in the Entities\Exceptions folder:
由于我们没有 RefreshTokenBadRequest 类,因此让我们在 Entities\Exceptions 文件夹中创建它:

public sealed class RefreshTokenBadRequest : BadRequestException { public RefreshTokenBadRequest() : base("Invalid client request. The tokenDto has some invalid values.") { } }

And add a required using directive in the AuthenticationService class to remove the present error.
并在 AuthenticationService 类中添加必需的 using 指令以删除当前错误。

Finally, let’s add one more action in the TokenController:
最后,让我们在 TokenController 中再添加一个作:

[HttpPost("refresh")] [ServiceFilter(typeof(ValidationFilterAttribute))] public async Task<IActionResult> Refresh([FromBody]TokenDto tokenDto) { var tokenDtoToReturn = await _service.AuthenticationService.RefreshToken(tokenDto); return Ok(tokenDtoToReturn); }

That’s it.
就是这样。

Our refresh token logic is prepared and ready for testing.
我们的刷新令牌逻辑已准备就绪,可以进行测试。

Let’s first send the POST authentication request:
让我们首先发送 POST 身份验证请求:

https://localhost:5001/api/authentication/login

alt text

As before, we have both tokens in the response body.
和以前一样,我们在响应正文中有两个标记。

Now, let’s send the POST refresh request with these tokens as the request body:
现在,让我们发送 POST 刷新请求,并将这些令牌作为请求正文:

https://localhost:5001/api/token/refresh

alt text

And we can see new tokens in the response body. Additionally, if we inspect the database, we will find the same refresh token value:
我们可以在响应正文中看到新标记。此外,如果我们检查数据库,我们将发现相同的刷新令牌值:

alt text

Usually, in your client application, you would inspect the exp claim of the access token and if it is about to expire, your client app would send the request to the api/token endpoint and get a new set of valid tokens.
通常,在您的客户端应用程序中,您将检查访问令牌的 exp 声明,如果它即将过期,您的客户端应用程序会将请求发送到 api/token 终端节点并获取一组新的有效令牌。

Leave a Reply

Your email address will not be published. Required fields are marked *