Jwt Token 的刷新机制设计

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

5581a32efc25f129baa85a5e6f1c7076.png

4867fb7721b653f3f63cc50fae76a660.png

access token

754a276d9342a300afb8515e0a985544.pngrefresh token


  • 验证 access token

d8826fc044b9703fbe95996127a2b900.png

  • 使用 refresh token 验证 token

c0e258fbdcb526f5b42a6bb6d2115ecd.png

  • 使用 refresh token 获取新的 access token

64f248e539d4a5bf7a7180ca83307c0c.png

renew token with the refresh token

2f43db6a4433ca76d33187fb1d8b077f.png

new access token
  • 验证新的 access token

88b9f7851b141ed19e9e82ed83fc9f8a.png

validate token with the new access 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 只需要重写 JwtTokenServiceGetRefreshTokenRefreshToken 两个虚方法即可

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 主要就是配置的 JwtBearerOptionsTokenValidationParameters 以使用配置好的一些参数来进行验证,避免了两个地方都要配置

使用示例如下:

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

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

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

1695b9f3412b0b26676b8987506c1795.png

Bearer token test

9adc5655f8559e03e28629440fe61d45.png

No 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

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

References

  • https://github.com/WeihanLi/SparkTodo

  • https://github.com/WeihanLi/SparkTodo/tree/master/SparkTodo.API

  • https://github.com/WeihanLi/WeihanLi.Web.Extensions

  • https://github.com/WeihanLi/WeihanLi.Web.Extensions/tree/dev/samples/WeihanLi.Web.Extensions.Samples

  • 更轻易地实现 Jwt Token

8a55c07cf9f9fe6c4a75548e90252eae.png

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值