You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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);
                }
            }
        }
    }
}
问题原因分析
  1. 响应已启动无法覆盖状态码:第一次执行_next(context)后,响应流已经开始写入,状态码设为401,此时再次执行_next(context),ASP.NET Core不会覆盖已发送的响应状态码,最终返回的还是第一次的401。
  2. 认证仍读取旧Cookie令牌:JWT认证的OnMessageReceived事件是从Request.Cookies获取令牌,而中间件仅修改了Authorization请求头,Request.Cookies中的旧access token未更新,导致第二次执行认证时仍使用过期令牌,返回401。
  3. 逻辑判断错误(最小复现示例):最小复现中的if (string.IsNullOrEmpty(tokenResult))逻辑完全相反,应该是if (!string.IsNullOrEmpty(tokenResult))才执行重发逻辑,当前代码根本不会进入重发分支。
  4. 无重发防重复机制:未设置标记防止无限重发(比如刷新令牌也无效时),但这不是当前问题的直接原因。
解决方案

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.18 16:59:53