Jwt Token的刷新机制设计

Intro

前面的文章我们介绍了如何实现一个简单的 Jwt Server,可以实现一个简单 Jwt 服务,但是使用 Jwt token 会有一个缺点就是 token 一旦颁发就不能够进行作废,所以通常 jwt token 的有效期一般会比较短,但是太短了又会比较影响用户的用户体验,所以就有了 refresh token 的参与,一般来说 refresh token 会比实际用的 access token 有效期会长一些,当 access token 失效了,就使用 refresh token 重新获取一个 access token,再使用新的 access_token 来访问服务。.

Sample

我们的示例在前面的基础上增加了 refresh_token,使用示例如下:

注册服务的时候启用 refresh_token 就可以了

services.AddJwtTokenService(options =>
{
    options.SecretKey = Guid.NewGuid().ToString();
    options.Issuer = "https://id.weihanli.xyz";
    options.Audience = "SparkTodo";
    // EnableRefreshToken, disabled by default
    options.EnableRefreshToken = true;
});

启用了 refresh token 之后,在生成 token 的时候就会返回一个带着 refresh token 的 token 对象(TokenEntityWithRefreshToken) 否则就是返回只有 acess token 的对象 (TokenEntity)

public class TokenEntity
{
    public string AccessToken { get; set; }
    public int ExpiresIn { get; set; }
}

public class TokenEntityWithRefreshToken : TokenEntity
{
    public string RefreshToken { get; set; }
}

然后我们就可以使用 refresh token 来获取新的 access token 了,使用方式如下:

[HttpGet("RefreshToken")]
public async Task<IActionResult> RefreshToken(string refreshToken, [FromServices] ITokenService tokenService)
{
    return await tokenService
        .RefreshToken(refreshToken)
        .ContinueWith(r =>
            r.Result.WrapResult().GetRestResult()
        );
}

GetToken 接口和上次的示例相比稍微有一些改动,主要是体现了有没有 refresh token 的差异,ValidateToken 和之前一致

[HttpGet("getToken")]
public async Task<IActionResult> GetToken([Required] string userName, [FromServices] ITokenService tokenService)
{
    var token = await tokenService
        .GenerateToken(new Claim("name", userName));
    if (token is TokenEntityWithRefreshToken tokenEntityWithRefreshToken)
    {
        return tokenEntityWithRefreshToken.WrapResult().GetRestResult();
    }
    return token.WrapResult().GetRestResult();
}

[HttpGet("validateToken")]
public async Task<IActionResult> ValidateToken(string token, [FromServices] ITokenService tokenService)
{
    return await tokenService
        .ValidateToken(token)
        .ContinueWith(r =>
            r.Result.WrapResult().GetRestResult()
        );
}

验证步骤如下:

  • 获取 token

Jwt Token的刷新机制设计 Jwt Token的刷新机制设计

Jwt Token的刷新机制设计

  • 验证 access token

Jwt Token的刷新机制设计

  • 使用 refresh token 验证 token

Jwt Token的刷新机制设计

  • 使用 refresh token 获取新的 access token

Jwt Token的刷新机制设计Jwt Token的刷新机制设计 

  • 验证新的 access token

    Jwt Token的刷新机制设计

Implement

从上面 token 解析出来的内容大概可以看的出来实现的思路,我的实现思路是仍然使用 Jwt 这套机制来生成和验证 refresh token,只是 refresh token 的 audience 和 access token 不同,另外 refresh token 的有效期一般会更长一些,这样我们就不能把 refresh token 直接当作 access token 来使用,因为 token 验证会失败,而之所以利用 Jwt 的机制来实现也是希望能够简化 refresh token,利用 jwt 的无状态,不需要使得无状态的应用变得有状态,有看过一些别的实现是直接使用存储将 refresh token 保存起来,这样 refresh token 就变成有状态的了,还要依赖一个存储,当然如果你希望使用有状态的 refresh token 也是可以自己扩展的,下面来看一些实现代码

ITokenService 提供了 token 服务的抽象,定义如下:

public interface ITokenService
{
    Task<TokenEntity> GenerateToken(params Claim[] claims);

    Task<TokenValidationResult> ValidateToken(string token);

    Task<TokenEntity> RefreshToken(string refreshToken);
}

JwtTokenService 是基于 Jwt 的 Token 服务实现:

public class JwtTokenService : ITokenService
{
    private readonly JwtSecurityTokenHandler _tokenHandler = new();
    private readonly JwtTokenOptions _tokenOptions;

    private readonly Lazy<TokenValidationParameters>
        _lazyTokenValidationParameters,
        _lazyRefreshTokenValidationParameters;

    public JwtTokenService(IOptions<JwtTokenOptions> tokenOptions)
    {
        _tokenOptions = tokenOptions.Value;
        _lazyTokenValidationParameters = new(() =>
            _tokenOptions.GetTokenValidationParameters());
        _lazyRefreshTokenValidationParameters = new(() =>
            _tokenOptions.GetTokenValidationParameters(parameters =>
            {
                parameters.ValidAudience = GetRefreshTokenAudience();
            })
        );
    }

    public virtual Task<TokenEntity> GenerateToken(params Claim[] claims)
        => GenerateTokenInternal(_tokenOptions.EnableRefreshToken, claims);

    public virtual Task<TokenValidationResult> ValidateToken(string token)
    {
        return _tokenHandler.ValidateTokenAsync(token, _lazyTokenValidationParameters.Value);
    }

    public virtual async Task<TokenEntity> RefreshToken(string refreshToken)
    {
        var refreshTokenValidateResult = await _tokenHandler.ValidateTokenAsync(refreshToken, _lazyRefreshTokenValidationParameters.Value);
        if (!refreshTokenValidateResult.IsValid)
        {
            throw new InvalidOperationException("Invalid RefreshToken", refreshTokenValidateResult.Exception);
        }
        return await GenerateTokenInternal(false,
            refreshTokenValidateResult.Claims
                .Where(x => x.Key != JwtRegisteredClaimNames.Jti)
                .Select(c => new Claim(c.Key, c.Value.ToString() ?? string.Empty)).ToArray()
            );
    }

    protected virtual Task<string> GetRefreshToken(Claim[] claims, string jti)
    {
        var claimList = new List<Claim>((claims ?? Array.Empty<Claim>())
            .Where(c => c.Type != _tokenOptions.RefreshTokenOwnerClaimType)
            .Union(new[] { new Claim(_tokenOptions.RefreshTokenOwnerClaimType, jti) })
        );

        claimList.RemoveAll(c =>
            JwtInternalClaimTypes.Contains(c.Type)
            || c.Type == JwtRegisteredClaimNames.Jti);
        var jtiNew = _tokenOptions.JtiGenerator?.Invoke() ?? GuidIdGenerator.Instance.NewId();
        claimList.Add(new(JwtRegisteredClaimNames.Jti, jtiNew));
        var now = DateTimeOffset.UtcNow;
        claimList.Add(new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeMilliseconds().ToString(), ClaimValueTypes.Integer64));
        var jwt = new JwtSecurityToken(
            issuer: _tokenOptions.Issuer,
            audience: GetRefreshTokenAudience(),
            claims: claimList,
            notBefore: now.UtcDateTime,
            expires: now.Add(_tokenOptions.RefreshTokenValidFor).UtcDateTime,
            signingCredentials: _tokenOptions.SigningCredentials);
        var encodedJwt = _tokenHandler.WriteToken(jwt);
        return encodedJwt.WrapTask();
    }

    private static readonly HashSet<string> JwtInternalClaimTypes = new()
    {
        "iss",
        "exp",
        "aud",
        "nbf",
        "iat"
    };

    private async Task<TokenEntity> GenerateTokenInternal(bool refreshToken, Claim[] claims)
    {
        var now = DateTimeOffset.UtcNow;
        var claimList = new List<Claim>()
        {
            new (JwtRegisteredClaimNames.Iat, now.ToUnixTimeMilliseconds().ToString(), ClaimValueTypes.Integer64)
        };
        if (claims != null)
        {
            claimList.AddRange(
                claims.Where(x => !JwtInternalClaimTypes.Contains(x.Type))
            );
        }
        var jti = claimList.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Jti)?.Value;
        if (jti.IsNullOrEmpty())
        {
            jti = _tokenOptions.JtiGenerator?.Invoke() ?? GuidIdGenerator.Instance.NewId();
            claimList.Add(new(JwtRegisteredClaimNames.Jti, jti));
        }
        var jwt = new JwtSecurityToken(
            issuer: _tokenOptions.Issuer,
            audience: _tokenOptions.Audience,
            claims: claimList,
            notBefore: now.UtcDateTime,
            expires: now.Add(_tokenOptions.ValidFor).UtcDateTime,
            signingCredentials: _tokenOptions.SigningCredentials);
        var encodedJwt = _tokenHandler.WriteToken(jwt);

        var response = refreshToken ? new TokenEntityWithRefreshToken()
        {
            AccessToken = encodedJwt,
            ExpiresIn = (int)_tokenOptions.ValidFor.TotalSeconds,
            RefreshToken = await GetRefreshToken(claims, jti)
        } : new TokenEntity()
        {
            AccessToken = encodedJwt,
            ExpiresIn = (int)_tokenOptions.ValidFor.TotalSeconds
        };
        return response;
    }

    private string GetRefreshTokenAudience() => $"{_tokenOptions.Audience}_RefreshToken";
}

在生成 refresh token 的时候会把关联的 access token 的 jti(jwt token 的 id,默认是一个 guid 可以通过option 自定义)写到 access token 中,claim type 可以通过 option 自定义,这样如果想要实现 refresh token 所属的 access token 的匹配校验也是可以实现的。

生成 refresh token 的时候会把生成 access token 时的 claims 信息也会生成在 refresh token 中,这样做的好处在于使用 refresh token 刷新 access token 的时候就可以直接根据 refresh token 生成 access token 无需别的信息,刷新得到的 access-token 中会有之前的 access token 的一个 id,如果想要记录所有 token 的颁发过程也是可以实现的。

如果想要实现有状态的 Refresh token 只需要重写 JwtTokenService 中 GetRefreshToken 和 RefreshToken 两个虚方法即可

Integration with JwtBearerAuth

如何和 asp.net core 的 JwtBearerAuthentication 进行集成呢?为了方便集成,提供了一个扩展来方便的集成,只需要使用 AddJwtTokenServiceWithJwtBearerAuth 来注册即可,实现代码如下:

public static IServiceCollection AddJwtTokenServiceWithJwtBearerAuth(this IServiceCollection serviceCollection, Action<JwtTokenOptions> optionsAction, Action<JwtBearerOptions> jwtBearerOptionsSetup = null)
{
    Guard.NotNull(serviceCollection);
    Guard.NotNull(optionsAction);
    if (jwtBearerOptionsSetup is not null)
    {
        serviceCollection.Configure(jwtBearerOptionsSetup);
    }
    serviceCollection.ConfigureOptions<JwtBearerOptionsPostSetup>();
    return serviceCollection.AddJwtTokenService(optionsAction);
}

JwtBearerOptionsPostSetup 实现如下:

internal sealed class JwtBearerOptionsPostSetup :
    IPostConfigureOptions<JwtBearerOptions>
{
    private readonly IOptions<JwtTokenOptions> _options;

    public JwtBearerOptionsPostSetup(IOptions<JwtTokenOptions> options)
    {
        _options = options;
    }

    public void PostConfigure(string name, JwtBearerOptions options)
    {
        options.Audience = _options.Value.Audience;
        options.ClaimsIssuer = _options.Value.Issuer;
        options.TokenValidationParameters = _options.Value.GetTokenValidationParameters();
    }
}

JwtBearerOptionsPostSetup 主要就是配置的 JwtBearerOptions 的 TokenValidationParameters 以使用配置好的一些参数来进行验证,避免了两个地方都要配置

使用示例如下:

首先我们准备一个 API 来验证 Auth 是否成功,API 很简单,定义如下:

[HttpGet("[action]")]
[Authorize(AuthenticationSchemes = "Bearer")]
public IActionResult BearerAuthTest()
{
    return Ok();
}

我们先获取一个 access token,然后调用接口来验证 Auth 能否成功

Jwt Token的刷新机制设计

Jwt Token的刷新机制设计

More

除了上面的示例,你也可以参考这个项目 https://github.com/WeihanLi/SparkTodo/tree/master/SparkTodo.API,之前独立使用 Jwt token 的,现在也使用了上面的实现

目前的实现基于可以满足我自己的需要了,还有一些可以优化的点

  • 现在对于 refresh token 的校验可以优化一下,目前只是验证了一个 refresh token 的合法性,验证 owner jwt token id 虽然可以实现,但是有些不太方便,可以优化一下

  • 现在 refresh token 签名用到的 key 和 access token 是同一个,应该允许用户分开配置

  • 使用 refresh token 获取新的 token 时只返回 access token,可以支持返回新的 token 时返回 refresh_token

你觉得还有哪些需要改进的地方呢?