我正在处理JWT及其刷新令牌,却找不到一个可以同时提供性能和安全性的有效示例。
性能:: 每次刷新令牌时,它都不能访问数据库。
安全性:: 由于使用寿命长,刷新令牌应比访问令牌更具机密性。
因此,我尝试结合使用内存缓存和过期的令牌声明来实现自己的目标:
第1步。
a)成功登录后,将在JwtRegisteredClaimNames.Jti声明类型中生成具有唯一GUID的访问令牌。
b)然后生成刷新令牌,并以关联的jti访问令牌值(唯一的GUID)作为键保存在memoryCache中
c)都发送到客户端应用程序并存储在localStorage中。
第2步。
a)访问令牌过期后,访问令牌和刷新令牌都发送到刷新控制器。
b)然后,jti要求将已过期的令牌作为缓存键发送到memoryCache中,以从内存中获取刷新令牌。
c)在检查了-send refresh-token和-in-memory refresh-token的相等性之后,如果相等,则将生成一个访问令牌和refresh-token的新实例,并将其发送回客户端应用程序。
AuthService.cs
private readonly IConfiguration _configuration;
private readonly IMemoryCache _memoryCache;
private readonly Claim _jtiClaim;
public AuthService(IConfiguration configuration, IMemoryCache memoryCache)
{
_configuration = configuration;
_memoryCache = memoryCache;
_jtiClaim = new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString());
}
public string GenerateAccessToken(IList<Claim> claims)
{
claims.Add(_jtiClaim);
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JwtConfiguration:JwtKey"]));
var jwtToken = new JwtSecurityToken(
issuer: _configuration["JwtConfiguration:JwtIssuer"],
audience: _configuration["JwtConfiguration:JwtIssuer"],
claims: claims,
notBefore: DateTime.UtcNow,
expires: DateTime.UtcNow.AddMinutes(int.Parse(_configuration["JwtConfiguration:JwtExpireMins"])),
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256)
);
return new JwtSecurityTokenHandler().WriteToken(jwtToken);
}
public string GenerateRefreshToken(ClientType clientType)
{
var randomNumber = new byte[32];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(randomNumber);
var token = Convert.ToBase64String(randomNumber);
var refreshToken = JsonConvert.SerializeObject(new RefreshToken(token, _jtiClaim.Value, clientType));
_memoryCache.Set(_jtiClaim.Value, refreshToken, new MemoryCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromDays(7)));
return token;
}
}
public RefreshToken GetRefreshToken(string jtiKey)
{
if (!_memoryCache.TryGetValue(jtiKey, out string refreshToken)) return null;
_memoryCache.Remove(jtiKey);
return JsonConvert.DeserializeObject<RefreshToken>(refreshToken);
}
public ClaimsPrincipal GetPrincipalFromExpiredToken(string accessToken)
{
var tokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = false,
ValidateIssuer = false,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JwtConfiguration:JwtKey"])),
ValidateLifetime = false //here we are saying that we don't care about the token's expiration date
};
var tokenHandler = new JwtSecurityTokenHandler();
var principal = tokenHandler.ValidateToken(accessToken, tokenValidationParameters, out var securityToken);
if (!(securityToken is JwtSecurityToken jwtSecurityToken) || !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase))
throw new SecurityTokenException("Invalid token");
return principal;
}
AuthController.cs
private readonly SignInManager<User> _signInManager;
private readonly UserManager<User> _userManager;
private readonly AuthService _authService;
private readonly IMemoryCache _memoryCache;
private readonly DataContext _context;
public AuthController(UserManager<User> userManager, AuthService authService,
SignInManager<User> signInManager, DataContext context)
{
_userManager = userManager;
_authService = authService;
_signInManager = signInManager;
_context = context;
}
[HttpPost]
public async Task<ActionResult> Login([FromBody] LoginDto model)
{
var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, false, false);
if (!result.Succeeded) return BadRequest(new { isSucceeded = result.Succeeded, errors= "INVALID_LOGIN_ATTEMPT" });
var appUser = _userManager.Users.Single(r => r.Email == model.Email);
return Ok(new
{
isSucceeded = result.Succeeded,
accessToken = _authService.GenerateAccessToken(GetClaims(appUser)),
refreshToken = _authService.GenerateRefreshToken(model.ClientType)
});
}
[HttpPost]
public ActionResult RefreshToken([FromBody] RefreshTokenDto model)
{
var principal = _authService.GetPrincipalFromExpiredToken(model.AccessToken);
var jtiKey = principal.Claims.Single(a => a.Type == JwtRegisteredClaimNames.Jti).Value;
var refreshToken = _authService.GetRefreshToken(jtiKey);
if (refreshToken == null)
return BadRequest("Expired Refresh Token");
if (refreshToken.Token != model.RefreshToken)
return BadRequest("Invalid Refresh Token");
return Ok(new
{
isSucceeded = true,
accessToken = _authService.GenerateAccessToken(principal.Claims.SkipLast(1).ToList()),
refreshToken = _authService.GenerateRefreshToken(model.ClientType)
});
}
我不确定这是否适用于刷新令牌,因为刷新令牌可能会在客户端应用中被破坏。
您可以为此建议我一个更好的解决方案吗?
答案 0 :(得分:1)
如果涉及安全性,那么性能就不那么重要了。但是对于一个刷新令牌,由于寿命长,因此忽略数据库命中的时间。
内存缓存不是存储刷新令牌的地方。在关机的情况下,所有刷新令牌将变为无效。因此,无论如何您都需要保留令牌。
一项策略可以是一次仅允许一个刷新令牌(持久化在数据库中),并且在登录或刷新时将刷新令牌替换为新令牌,这会使使用的刷新令牌失效。
可以使事情变得更安全的一件事是为刷新令牌使用固定的到期时间。在这种情况下,您将强制用户在固定时间后登录。限制令牌可以被泄露的窗口。
另一种方法是使令牌的生存期减少,并使用滑动过期,这意味着每次使用刷新令牌时,都会重置过期。在这种情况下,用户可能永远不必再次登录,而在刷新时,您可以进行一些检查。
同时需要访问令牌和刷新令牌不会使事情变得更安全。由于访问令牌可能已经过期(并且已受损),因此可以存在多个访问令牌。请求新的访问令牌不会使当前令牌失效,并且您也不想在每次呼叫中验证访问令牌。
您不能简单地信任自己的令牌。您需要定义规则以检测对任何令牌的可疑使用。就像检查每分钟的通话次数之类的。
或者您可以检查当前的IP地址。为此,包括IP地址作为声明。如果当前IP地址与访问令牌中的IP地址不匹配,则拒绝访问,以强制客户端刷新访问令牌。
刷新时,如果IP地址未知(不在该用户的已知IP地址列表中),则该用户需要登录。如果成功,则可以将IP地址添加到经过验证的IP地址列表中。然后,您可以向用户发送一封邮件,说明存在另一个IP地址的登录信息。
您可以使用内存缓存来检测对访问令牌的怀疑使用。在这种情况下,您可以撤消刷新令牌(只需将其从数据库中删除),让用户再次登录。