【ASP.NET Core】双Token机制在ASP.NET Core中的实现
文章目录
- 前言
- 一、设计思路
- 二、执行流程
- 2.1 登录成功生成双Token
- 2.2 根据refreshToken刷新accessToken
- 总结
前言
现代前后端分离的模式中,一般都是采用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));}//准备加密keySymmetricSecurityKey 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鉴权的安全性与用户体验。