【ASP.NET Core】双Token机制在ASP.NET Core中的实现

发布于:2025-09-02 ⋅ 阅读:(21) ⋅ 点赞:(0)


前言

现代前后端分离的模式中,一般都是采用token的方式实现API的鉴权,而不是传统Web应用中依赖服务器端的Session存储和客户端Cookie的自动传递匹配机制。前端发起的请求时,在其请求头内传入“Authorization:token”,后端解析请求头中的token, 获取载荷信息过期时间等状态信息,验证Token是否有效,实现鉴权。

本篇文章聚焦于在ASP.NET Core中实现身份验证中双Token(accessToken + refreshToken)的颁发,来满足前端无感刷新。

前端部分的设计可以参考小程序的这篇文章 链接: 【微信小程序】微信小程序基于双token的API请求封装与无感刷新实现方案


一、设计思路

本文采用双token的方式(accessToken + refreshToken)。accessToken生命周期短,前端作为请求头写入请求传给后端用于鉴权,refreshToken生命周期长,用于刷新accessToken。本方案核心目标是解决accessToken过期后,前端将refreshToken传入后端,后端能通过refreshToken用,返回一个新的accessToken供前端使用,而不是重复登录。

并且还将完善refreshToken泄露导致的安全风险,将accessToken和refreshToken匹配。也就是说执行刷新token的时候,服务端需要同时在请求中获取accessToken和refreshToken。(考虑到安全性refreshToken可以采取RSA加密)

二、执行流程

2.1 登录成功生成双Token

双token中accessToken使用jwt的方案生成,不需要保存在服务器端。前端发起的请求头中携带accessToken,后端根据标准的jwt解析流程鉴权。refreshToken作为一个键值对的键,需要保存到服务器,推荐使用redis。refreshToken这个键是一个Guid,保证其唯一性,并且refreshToken对应的值里需要有一个标识符,用于确定这个refreshToken是否能刷新生成新的accessToken。

本方案采用一个为Guid的SessionUId,将accessToken和refreshToken匹配。

首先是生成双token前,初始化sessionUId。然后生成JWT的时候在载荷里添加sessionUId。再生成refreshToken的时候,也将SessionUId传入实例后的RefreshTokenInfo对象。

accessToken生命周期的,refreshToken生命周期长。在refreshToken生命周期内,它可以刷新其匹配的accessToken。其中refreshToken也可以采取滑动过期策略,每一次刷新accessToken都会延长refreshToken过期时间。

refreshToken作为键对应的值对象

public class RefreshTokenInfo
{
    /// <summary>
    /// 当前用户ID
    /// </summary>
    public Guid SessionUId { get; set; }
    /// <summary>
    /// 当前用户ID
    /// </summary>
    public int UserID { get; set; }
    /// <summary>
    /// 刷新令牌的创建时间
    /// </summary>
    public DateTime CreatedAt { get; set; }
}

生成JWT的时候传入sessionUId

public string GenerateJWT(CurrentUser currentUser,string sessionUId)
{
    var claims = new List<Claim>() {
        new Claim(ClaimTypes.Name, currentUser.Name),
        new Claim("UserId",currentUser.UserId.ToString()),
        new Claim("SessionUId",sessionUId)
    };
    foreach (var roles in currentUser.RoleList)
    {
        claims.Add(new Claim(ClaimTypes.Role, roles));
    }
    //准备加密key
    SymmetricSecurityKey key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.SecurityKey));
    //HmacSha256加密方式
    SigningCredentials credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
    JwtSecurityToken token = new JwtSecurityToken(
        issuer: _jwtOptions.Issuer,
        audience: _jwtOptions.Audience,
        claims: claims,
        expires: DateTime.Now.AddSeconds(_jwtOptions.ExpireTime),
        signingCredentials: credentials
        );
    return new JwtSecurityTokenHandler().WriteToken(token);
}

返回双token

/// <summary>
/// 生成双Token
/// </summary>
/// <param name="openId">用户唯一标识</param>
/// <returns>Token响应</returns>
public async Task<TokenResponse> GenerateTokensAsync(CurrentUser currentUser)
{
    //会话ID,每次登录生成一个,用于将accessToken和refeshToken匹配
    Guid sessionUId = Guid.NewGuid();
    string accessToken = _jwtService.GenerateJWT(currentUser, sessionUId.ToString());
    string refreshToken = GenerateRefreshToken();
    string refreshTokenExpireTime = _configuration["RefreshTokenOptions:ExpireTime"];
    if (refreshTokenExpireTime == null || refreshTokenExpireTime == "" ||
        !int.TryParse(refreshTokenExpireTime, out int expireTime))
    {
        throw new ConfigException("未配置刷新Token的过期时间");
    }
    RefreshTokenInfo refreshTokenInfo = new RefreshTokenInfo
    {
        SessionUId = sessionUId,
        UserID = currentUser.UserId,
        CreatedAt = DateTime.UtcNow
    };
    await _redisdb.StringSetAsync($"{_redisKeyPrefix}refreshToken:{refreshToken}", JsonSerializer.Serialize(refreshTokenInfo), TimeSpan.FromSeconds(expireTime));
    return new TokenResponse
    {
        AccessToken = accessToken,
        RefreshToken = refreshToken
    };
}

2.2 根据refreshToken刷新accessToken

前端发起刷新accessToken的时候需要把accessToken和refreshToken一并带上。其中accessToken还是采用请求头,refreshToken可以作为FromBody传入。

首先我们需要一个分析refreshToken的函数来判断refreshToken是否在redis(内存)中存在,主要是解析并获取到sessionUId。

/// <summary>
/// 分析RefreshToken
/// </summary>
/// <param name="refreshToken"></param>
/// <returns></returns>
/// <exception cref="LoginFailedException"></exception>
public async Task<RefreshTokenInfo> AnalysisRefreshToken(string refreshToken)
{
    string? tokenValue = await _redisdb.StringGetAsync($"{_redisKeyPrefix}refreshToken:{refreshToken}");
    if (tokenValue is null || tokenValue == "")
    {
        throw new LoginFailedException("登录信息失效,请重新登录");
    }
    RefreshTokenInfo refreshTokenInfo = JsonSerializer.Deserialize<RefreshTokenInfo>(tokenValue);
    if (refreshTokenInfo == null || refreshTokenInfo.SessionUId == Guid.Empty)
    {
        throw new LoginFailedException("登录信息失效,请重新登录");
    }
    return refreshTokenInfo;
}

然后获取HttpContext,请求头中的accessToken

var authHeader = HttpContext.Request.Headers["Authorization"].FirstOrDefault();
if (!(authHeader != null && authHeader.StartsWith("Bearer")))
{
    throw new ArgumentException("请求获取refeshToken的时候未携带有效的token");
}
string? token = authHeader?.Substring("Bearer ".Length).Trim();

并且解析,获取载荷中的sessionUId

public ClaimsPrincipal ParseTokenClaims(string token)
{
    var tokenHandler = new JwtSecurityTokenHandler();

    // 令牌已过期,但我们仍然尝试解析它(只验证签名)
    var jwtToken = tokenHandler.ReadJwtToken(token);

    // 验证签名
    var validationParameters = new TokenValidationParameters
    {
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.SecurityKey)),
        ValidateIssuer = true,
        ValidateAudience = false,
        ValidIssuer = _jwtOptions.Issuer,
        ValidAudience = _jwtOptions.Audience,
        ValidateLifetime = false // 不验证有效期
    };

    try
    {
        // 只验证签名和发行者等信息,不验证有效期
        var claimsPrincipal = tokenHandler.ValidateToken(token, validationParameters, out _);
        return claimsPrincipal;
    }
    catch
    {
        // 签名验证失败,令牌无效
        return null;
    }
}

最后仅仅是匹配成功的情况下,才能执行刷新accessToken的逻辑。


总结

该方案通过生成关联的accessToken与refreshToken,利用SessionUId实现二者匹配验证,在refreshToken有效期内支持安全刷新 accessToken,同时采用Redis存储refreshToken并可实施滑动过期策略,增强了API鉴权的安全性与用户体验。


网站公告

今日签到

点亮在社区的每一天
去签到