ASP.NET Core MVC 5:JWT刷新后AntiForgery引发400错误的解决咨询
ASP.NET Core MVC 5环境,采用Cookie认证,将JWT Token存入Claims实现登录验证。所有控制器均添加AutoValidateAntiForgeryToken特性处理XSRF问题。
JWT Token设置30分钟过期,在CookieAuthenticationEvents.ValidatePrincipal事件处理器中,Token过期时会刷新Token,并通过设置CookieValidatePrincipalContext.ShouldRenew = true重新生成认证Cookie,核心代码如下:
var identity = new ClaimsIdentity(context.Principal.Identity); var sid = identity.Claims.FirstOrDefault(x => x.Type == ClaimTypes.Sid); identity.RemoveClaim(sid); identity.AddClaim(new Claim(ClaimTypes.Sid, newJwtToken)); var newPrincipal = new ClaimsPrincipal(identity); context.ReplacePrincipal(newPrincipal); context.ShouldRenew = true;
问题现象
登录30分钟后,浏览器保存的AntiForgeryRequestToken失效,携带该Token发起POST请求会返回400 Bad Request。
补充测试情况
JWT Token过期前,携带AntiForgeryRequestToken的AJAX POST请求均可正常执行:
- 登录20分钟后点击“更新”按钮,请求正常;
- 停留在同一页面,21分钟后再次点击“更新”按钮,请求仍正常;
- 重复操作至登录30分钟后点击按钮,触发400错误。
相关代码示例
cshtml代码
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Xsrf @{ var xsrf = Xsrf.GetTokens(ViewContext.HttpContext); // GetAndStoreTokens are already called in a login page. } const ret = await $.post( '@Url.Action("Update")', { 'id': 1234, 'data1': "abc", '@(xsrf.FormFieldName)': '@(xsrf.RequestToken)' } ).promise();
ConfigureServices.cs
services.AddScoped<CookieAuthenticationEventHandler>(); services .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(x => { x.LoginPath = "/Home"; x.AccessDeniedPath = "/Home"; x.LogoutPath = "/Signout"; x.EventsType = typeof(CookieAuthenticationEventHandler); x.ExpireTimeSpan = timeout; x.SlidingExpiration = true; });
SigninController.SignIn(signinModel)
/* Check ID and Password */ var jwtToken = Authorize(signinModel); Claim[] claims = { new Claim(ClaimTypes.Sid, jwtToken), }; var claimsIdentity = new ClaimsIdentity( claims, CookieAuthenticationDefaults.AuthenticationScheme ); var claimsPrincipal = new ClaimsPrincipal(claimsIdentity); var timeout = config.GetValue<TimeSpan>("Timeout"); await HttpContext.SignInAsync( CookieAuthenticationDefaults.AuthenticationScheme, claimsPrincipal ); HttpContext.User = claimsPrincipal;
JWT Token生成代码(Authorize方法内)
public string CreateToken(IEnumerable<Claim> claims) { var timeStamp = DateTime.Now; var timeout = new TimeSpan("0.00:30:00"); var token = new JwtSecurityToken( "https://unique-url", "https://unique-url", claims, timeStamp, expires: DateTime.Now.Add(timeout), signingCredentials: SigningCredentials ); return new JwtSecurityTokenHandler().WriteToken(token); }
CookieAuthenticationEventHandler
public override async Task ValidatePrincipal(CookieValidatePrincipalContext context) { if(!await Validate(context)) { context.RejectPrincipal(); await context.HttpContext.SignOutAsync(); } } public async Task<bool> Validate(CookieValidatePrincipalContext context) { var jwtToken = context.Principal.Claims?.FirstOrDefault(x => x.Type == ClaimTypes.Sid)?.Value; var valid = await ValidatetokenAsync(jwtToken); if(valid) { return true; } var newJwtToken = await RefreshtokenAsync(jwtToken); if(string.IsNullOrEmpty(newJwtToken)) { return false; } var identity = new ClaimsIdentity(context.Principal.Identity); var sid = identity.Claims.FirstOrDefault(x => x.Type == ClaimTypes.Sid); identity.RemoveClaim(sid); identity.AddClaim(new Claim(ClaimTypes.Sid, newJwtToken)); var newPrincipal = new ClaimsPrincipal(identity); context.ReplacePrincipal(newPrincipal); context.ShouldRenew = true; return true; }
根因分析
- 登录时传递给
HttpContext.SignInAsync的ClaimsPrincipal包含JWT Token; - 登录会话(Cookie)的过期时间与JWT Token的过期时间不同,Cookie开启滑动过期每次访问都会延长有效期,但JWT仅在过期时才刷新;
- JWT Token过期刷新后,
HttpContext的ClaimsPrincipal被更新; - AntiForgery验证默认会绑定
ClaimsPrincipal的标识信息,当ClaimsPrincipal中的Claim(此处为存储JWT的Sid)发生变化时,之前生成的AntiForgery Token会失效; - 因此JWT过期时发起POST请求会触发AntiForgery验证失败,返回400错误。
解决方案
方案一:调整AntiForgery验证依赖的标识(推荐)
AntiForgery默认会使用ClaimsPrincipal中的所有标识来生成验证Token,我们可以配置它仅依赖固定不变的用户标识(比如用户ID),而不是会随JWT刷新变化的Sid Claim。
在ConfigureServices中添加AntiForgery配置:
services.AddAntiforgery(options => { // 指定使用用户ID作为AntiForgery验证的标识依据,确保JWT刷新时该标识不变 options.IdentityClaimType = ClaimTypes.NameIdentifier; // 需确保登录时已添加该Claim });
同时,在登录时添加固定的用户ID Claim:
// 在SigninController.SignIn中,补充用户ID标识 Claim[] claims = { new Claim(ClaimTypes.Sid, jwtToken), new Claim(ClaimTypes.NameIdentifier, signinModel.UserId) // 添加固定的用户ID };
方案二:前端主动刷新AntiForgery Token
在JWT即将过期或刷新后,前端主动获取最新的AntiForgery Token,替换页面中存储的Token。
前端定时刷新逻辑:
// 每25分钟(提前JWT过期时间5分钟)获取新的AntiForgery Token setInterval(async () => { const response = await fetch('@Url.Action("GetXsrfToken")', { credentials: 'include' }); const data = await response.json(); window.currentXsrfToken = data.requestToken; }, 25 * 60 * 1000);
后端新增获取AntiForgery Token的接口:
[HttpGet] public IActionResult GetXsrfToken() { var tokens = _antiforgery.GetTokens(HttpContext); return Json(new { requestToken = tokens.RequestToken }); }
发起POST请求时使用最新Token:
const ret = await $.post( '@Url.Action("Update")', { 'id': 1234, 'data1': "abc", '@(xsrf.FormFieldName)': window.currentXsrfToken } ).promise();
方案三:同步JWT与Cookie的过期策略
将JWT的过期时间设置为与Cookie一致,并开启JWT的滑动刷新,每次请求时若JWT即将过期则自动刷新,避免用户在操作时突然触发Token过期。
添加JWT刷新ActionFilter:
public class JwtRefreshFilter : IActionFilter { private readonly IConfiguration _config; public JwtRefreshFilter(IConfiguration config) { _config = config; } public void OnActionExecuting(ActionExecutingContext context) { var jwtToken = context.HttpContext.User.Claims.FirstOrDefault(x => x.Type == ClaimTypes.Sid)?.Value; if (!string.IsNullOrEmpty(jwtToken)) { var handler = new JwtSecurityTokenHandler(); var token = handler.ReadJwtToken(jwtToken); // 若Token剩余时间小于5分钟,则刷新 if (token.ValidTo - DateTime.UtcNow < TimeSpan.FromMinutes(5)) { var newJwtToken = RefreshtokenAsync(jwtToken).Result; // 更新ClaimsPrincipal与Cookie var identity = new ClaimsIdentity(context.HttpContext.User.Identity); var sid = identity.Claims.FirstOrDefault(x => x.Type == ClaimTypes.Sid); identity.RemoveClaim(sid); identity.AddClaim(new Claim(ClaimTypes.Sid, newJwtToken)); context.HttpContext.User = new ClaimsPrincipal(identity); context.HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, context.HttpContext.User).Wait(); } } } public void OnActionExecuted(ActionExecutedContext context) { } }
注册该Filter:
services.AddControllersWithViews(options => { options.Filters.Add<JwtRefreshFilter>(); });
诉求回应
当前实现的核心问题在于AntiForgery验证依赖了会随JWT刷新而变化的Claim,上述方案均可解决该问题。其中方案一从根源上隔离了AntiForgery验证与JWT的关联,是最稳定的解决方案;方案二和方案三则从前端或JWT刷新策略上规避了Token失效的场景,可根据实际业务需求选择。
内容的提问来源于stack exchange,提问作者Jun1s

