ASP.NET Core 8刷新令牌中间件401问题排查
问题描述
在ASP.NET Core 8中通过中间件实现access token与refresh token自动更新:发送API请求时从上下文获取令牌并添加至Authorization头,配置JWT认证后,期望access token过期返回401时,自动获取新令牌并重发原请求。
已实现RefreshTokenMiddleware、TokenService及相关配置,当前问题:access token过期触发401后,可成功生成并存储新令牌,但中间件重发请求仍返回401,手动重发则得到200响应,需排查该问题原因。
相关代码
认证配置
services.AddAuthentication(opt => { opt.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; opt.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }).AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, opt => { opt.SaveToken = true; opt.TokenValidationParameters = new TokenValidationParameters() { ValidateIssuer = true, ValidateAudience = true, ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["TokenSettings:Secret"]!)), ValidateLifetime = true, ValidIssuer = configuration["TokenSettings:Issuer"], ValidAudience = configuration["TokenSettings:Audience"], ClockSkew = TimeSpan.Zero, NameClaimType = ClaimTypes.Name }; opt.Events = new JwtBearerEvents { OnMessageReceived = context => { context.Token = context.Request.Cookies[configuration["TokenSettings:CookieName"]!]; return Task.CompletedTask; } }; });
RefreshTokenMiddleware
public sealed class RefreshTokenMiddleware(RequestDelegate Next, IOptions<TokenSetting> TokenSettings) { private readonly RequestDelegate _next = Next; private readonly TokenSetting _tokenSettings = TokenSettings.Value; public async Task InvokeAsync(HttpContext context) { await _next(context); if (context.Response.StatusCode == (int)HttpStatusCode.Unauthorized) { var refreshToken = context.Request.Cookies[_tokenSettings.RefreshCookieName]; if (refreshToken is not null) { var tokenService = context.RequestServices.GetRequiredService<ITokenService>(); var tokenResult = await tokenService.GenerateTokenFromRefreshToken(refreshToken); if (tokenResult.IsSuccess) { context.Request.Headers["Authorization"] = $"Bearer {tokenResult.Value}"; await _next(context); } } } } }
TokenService
using Application.Services.TokenService; namespace Infrastructure.Services.TokenService { public sealed class TokenService( IHttpContextAccessor contextAccessor, UserManager<AppUser> userManager, IOptions<TokenSetting> tokenSettings) : ITokenService { private readonly HttpContext _context = contextAccessor.HttpContext!; private readonly UserManager<AppUser> _userManager = userManager ?? throw new ArgumentNullException(nameof(userManager)); private readonly TokenSetting _tokenSettings = tokenSettings?.Value ?? throw new ArgumentNullException(nameof(tokenSettings)); public async Task<Result<string>> GenerateToken(AppUser user, IList<string> roles) { if (user == null) return Result<string>.Failure(Error.BadRequest(UserTaskErrors.UserTaskNotFound)); if (roles == null) return Result<string>.Failure(Error.BadRequest(TokenErrors.InvalidRole)); var claims = GetClaims(user, roles); var token = CreateJwtToken(claims); var tokenHandler = new JwtSecurityTokenHandler(); var addClaimsResult = await _userManager.AddClaimsAsync(user, claims); if (!addClaimsResult.Succeeded) { return Result<string>.Failure(Error.InvalidRequest(TokenErrors.FailedToAddClaim)); } string accessToken = tokenHandler.WriteToken(token); AppendCookie(accessToken); await GenerateRefreshToken(user); return Result<string>.Success(accessToken); } public async Task<Result<string>> GenerateRefreshToken(AppUser user) { var randomNumber = new byte[32]; using var rng = RandomNumberGenerator.Create(); rng.GetBytes(randomNumber); string uniqueData = $"{user.Id}-{user.Email}-{DateTime.UtcNow.Ticks}"; byte[] uniqueBytes = Encoding.UTF8.GetBytes(uniqueData); byte[] combinedBytes = randomNumber.Concat(uniqueBytes).ToArray(); using var sha256 = SHA256.Create(); byte[] hashBytes = sha256.ComputeHash(combinedBytes); string refreshToken = Convert.ToBase64String(hashBytes); await _userManager.Users.Where(u => u.Id == user.Id) .ExecuteUpdateAsync(x => x.SetProperty(u => u.RefreshToken, refreshToken)); AppendCookie(refreshToken, true); return Result<string>.Success(refreshToken); } public Result<ClaimsPrincipal> GetPrincipalFromExpiredToken(string token) { try { var tokenValidationParameters = new TokenValidationParameters { ValidateAudience = true, ValidateIssuer = true, ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_tokenSettings.Secret)), ValidateLifetime = false }; var tokenHandler = new JwtSecurityTokenHandler(); var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out var securityToken); var jwtSecurityToken = securityToken as JwtSecurityToken; if (jwtSecurityToken == null || !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase)) { return Result<ClaimsPrincipal>.Failure(Error.InvalidRequest(TokenErrors.InvalidToken)); } return Result<ClaimsPrincipal>.Success(principal); } catch (Exception) { return Result<ClaimsPrincipal>.Failure(Error.InvalidRequest(TokenErrors.InvalidToken)); } } public async Task<Result<string>> GenerateTokenFromRefreshToken(string refreshToken) { var user = await ValidateRefreshToken(refreshToken); if (user == null) { return Result<string>.Failure(Error.InvalidRequest(TokenErrors.InvalidRefreshToken)); } var roles = await _userManager.GetRolesAsync(user); var result = await GenerateToken(user, roles); if (result.IsFailure) { return Result<string>.Failure(result.Error); } return Result<string>.Success(result.Value); } private List<Claim> GetClaims(AppUser user, IList<string> roles) { var claims = new List<Claim> { new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim("UserId", user.Id.ToString()), new Claim(JwtRegisteredClaimNames.Email, user.Email!), new Claim(ClaimTypes.Name, user.UserName!) }; claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); return claims; } private JwtSecurityToken CreateJwtToken(List<Claim> claims) { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_tokenSettings.Secret)); return new JwtSecurityToken( issuer: _tokenSettings.Issuer, audience: _tokenSettings.Audience, expires: DateTime.Now.AddDays(_tokenSettings.Expiration), claims: claims, signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256) ); } private void AppendCookie(string token, bool isRefresh = false) { if (!isRefresh) { CookieOptions option = new() { HttpOnly = true, Expires = DateTime.Now.AddSeconds(_tokenSettings.Expiration), SameSite = SameSiteMode.Strict, Secure = true }; _context?.Response.Cookies.Append(_tokenSettings.CookieName, token, option); return; } CookieOptions refreshTokenOption = new() { HttpOnly = true, Expires = DateTime.Now.AddDays(_tokenSettings.ExpirationRefresh), SameSite = SameSiteMode.Strict, Secure = true }; _context?.Response.Cookies.Append(_tokenSettings.RefreshCookieName, token, refreshTokenOption); } private async Task<AppUser?> ValidateRefreshToken(string refreshToken) { return await _userManager.Users.FirstOrDefaultAsync(u => u.RefreshToken == refreshToken); } } }
最小复现示例
TokenService
public sealed class TokenService( IHttpContextAccessor contextAccessor) : ITokenService { private readonly IHttpContextAccessor _contextAccessor = contextAccessor; public async Task<string> CreateAccess() { //生成access和refresh令牌并存入响应Cookie _contextAccessor.HttpContext?.Response.Cookies.Append("access_token", "access_token"); await CreateRefresh(); //返回令牌用于中间件重发请求,正常场景无需返回 return "access_token"; } public Task<string> CreateRefresh() { _contextAccessor.HttpContext?.Response.Cookies.Append("refresh_token", "refresh_token"); //将refresh token存入数据库AppUser的refreshToken字段 return Task.FromResult("refresh_token"); } public async Task<string> GenerateTokenFromRefreshToken(string refreshToken) { //验证数据库中AppUser的refreshToken字段是否匹配 //验证通过则生成新的access和refresh令牌 var acc = await CreateAccess(); //生成时已将新令牌存入响应Cookie return acc; } }
Program.cs
builder.Services.AddAuthentication().AddJwtBearer("Bearer", opt => { opt.Events = new JwtBearerEvents { //接收请求时从Cookie中获取令牌 OnMessageReceived = context => { context.Token = context.Request.Cookies["access_token"]; return Task.CompletedTask; } }; });
RefreshTokenMiddleware
public sealed class RefreshTokenMiddleware(RequestDelegate Next) { private readonly RequestDelegate _next = Next; public async Task InvokeAsync(HttpContext context) { await _next(context); if (context.Response.StatusCode == (int)HttpStatusCode.Unauthorized) { var refreshToken = context.Request.Cookies["refresh_token"]; if (refreshToken is not null) { var tokenService = context.RequestServices.GetRequiredService<ITokenService>(); //生成新令牌并存入响应Cookie var tokenResult = await tokenService.GenerateTokenFromRefreshToken(refreshToken); if (string.IsNullOrEmpty(tokenResult)) { //令牌生成成功,但重发请求仍返回401;手动重发则返回200,说明令牌已正确存储但中间件重发逻辑有误 context.Request.Headers["Authorization"] = $"Bearer {tokenResult}"; await _next(context); } } } } }
问题原因分析
- 响应已启动无法覆盖状态码:第一次执行
_next(context)后,响应流已经开始写入,状态码设为401,此时再次执行_next(context),ASP.NET Core不会覆盖已发送的响应状态码,最终返回的还是第一次的401。 - 认证仍读取旧Cookie令牌:JWT认证的
OnMessageReceived事件是从Request.Cookies获取令牌,而中间件仅修改了Authorization请求头,Request.Cookies中的旧access token未更新,导致第二次执行认证时仍使用过期令牌,返回401。 - 逻辑判断错误(最小复现示例):最小复现中的
if (string.IsNullOrEmpty(tokenResult))逻辑完全相反,应该是if (!string.IsNullOrEmpty(tokenResult))才执行重发逻辑,当前代码根本不会进入重发分支。 - 无重发防重复机制:未设置标记防止无限重发(比如刷新令牌也无效时),但这不是当前问题的直接原因。
解决方案
1. 重置响应状态与内容
在重发请求前,需要重置响应,清除已写入的内容和状态码:
// 重置响应 context.Response.Clear();
2. 更新Request的令牌来源
有两种方式确保认证使用新令牌:
方式一:修改JWT认证事件优先读取Authorization头
调整OnMessageReceived事件,优先读取Authorization头的令牌,再回退到Cookie:
opt.Events = new JwtBearerEvents { OnMessageReceived = context => { // 优先读取Authorization头 var authHeader = context.Request.Headers.Authorization.ToString(); if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer ")) { context.Token = authHeader.Substring(7); } else { // 否则从Cookie读取 context.Token = context.Request.Cookies["access_token"]; } return Task.CompletedTask; } };
方式二:模拟更新Request.Cookies
由于Request.Cookies是只读集合,可以通过添加新的Cookie头来覆盖旧值:
// 添加新的Cookie头,覆盖旧的access_token context.Request.Headers.Append("Cookie", $"access_token={tokenResult.Value}");
3. 修正中间件逻辑判断
修正判断逻辑,同时添加防重复标记避免无限循环:
public async Task InvokeAsync(HttpContext context) { // 标记是否已经重发过,防止无限循环 if (context.Items.ContainsKey("HasRetried")) { await _next(context); return; } await _next(context); if (context.Response.StatusCode == (int)HttpStatusCode.Unauthorized) { var refreshToken = context.Request.Cookies["refresh_token"]; if (refreshToken is not null) { var tokenService = context.RequestServices.GetRequiredService<ITokenService>(); var tokenResult = await tokenService.GenerateTokenFromRefreshToken(refreshToken); if (!string.IsNullOrEmpty(tokenResult)) { // 重置响应 context.Response.Clear(); // 添加重发标记 context.Items["HasRetried"] = true; // 设置Authorization头 context.Request.Headers["Authorization"] = $"Bearer {tokenResult}"; // 重发请求 await _next(context); } } } }
4. 确保中间件注册顺序
必须将RefreshTokenMiddleware注册在认证中间件之前:
app.UseMiddleware<RefreshTokenMiddleware>(); app.UseAuthentication(); app.UseAuthorization();
内容的提问来源于stack exchange,提问作者Alıyev Rufet
相关产品推荐
相关产品推荐

