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

Blazor Server(.NET6)中JWT令牌的安全存储方案问询

Blazor Server (.NET 6) 身份认证安全优化指南

问题背景

基于.NET 6开发Blazor Server应用时,在身份认证环节面临以下困惑:

  • 当前实现了1分钟有效期的Access Token和14天有效期的Refresh Token;
  • Access Token过期时,通过验证数据库中Refresh Token的有效性来刷新令牌;
  • 目前将两个令牌存储在localStorage中,不确定该方式的安全性;
  • 纠结于localStorage、安全Cookie、内存存储三种方案,希望了解如何避免CSRF、XSS攻击,以及是否需要分开存储两个令牌;
  • 已实现CustomAuthenticationStateProvider类,功能正常但不确定安全性是否达标。

核心安全分析与方案建议

1. 令牌存储方案对比与安全风险

localStorage存储

  • 优势:持久化存储、跨标签页共享,无需服务器端额外处理;
  • 风险:极易遭受XSS攻击——前端JavaScript可直接读取localStorage内容,一旦页面被注入恶意脚本,令牌会被窃取,进而导致用户会话被劫持。

安全Cookie存储

  • 配置要点:设置HttpOnly=true(禁止JavaScript访问)、Secure=true(仅HTTPS传输)、SameSite=Strict/Lax(防止CSRF攻击),并指定过期时间;
  • 优势:从根源上防范XSS攻击(JS无法读取),SameSite属性可有效阻断CSRF请求;
  • 适配Blazor Server:Blazor Server依赖SignalR连接,默认会自动携带Cookie,无需额外配置即可实现会话绑定。

内存存储(服务器端)

  • 实现方式:通过Scoped生命周期的服务存储令牌(每个用户会话对应一个实例);
  • 优势:完全隔离前端JavaScript,彻底避免XSS风险;
  • 劣势:页面刷新或标签页关闭后令牌丢失,用户需重新登录,体验较差。

2. 令牌分离存储策略(强制推荐)

必须分开存储两个令牌,针对不同令牌的风险等级采取不同方案:

  • Refresh Token:有效期长、风险高,必须存储在HttpOnly Secure Cookie中,禁止前端任何方式访问;
  • Access Token:有效期短、风险低,可存储在服务器端Scoped内存中(无需暴露给前端),若需调用外部API需携带令牌,可临时存储在内存或短生命周期的非HttpOnly Cookie中(不建议存localStorage)。

3. 防CSRF/XSS的关键措施

  • XSS防护:
    • 核心:不让敏感令牌(尤其是Refresh Token)暴露给前端JavaScript;
    • 辅助:启用CSP(内容安全策略)、过滤用户输入、定期更新依赖库避免漏洞。
  • CSRF防护:
    • 核心:使用SameSite=Strict的Cookie;
    • 辅助:Refresh Token刷新接口验证用户Agent、IP地址(你的代码已实现此逻辑,值得保留),API接口启用AntiForgery验证。

CustomAuthenticationStateProvider代码安全优化

你的现有功能逻辑可行,但存在以下安全隐患,需针对性修改:

现有问题

  1. 将Refresh Token存储在localStorage并写入ClaimsPrincipal,存在XSS泄露风险;
  2. Claims中包含敏感令牌内容,不必要且增加泄露风险;
  3. 依赖localStorage存储会话信息,未利用Cookie的安全特性。

优化后的代码示例

public class CustomAuthenticationStateProvider : AuthenticationStateProvider
{
    private readonly ITokenService _tokenService;
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly ISessionTokenStore _sessionTokenStore; // 自定义Scoped服务,存储Access Token
    private ClaimsPrincipal _anonymous = new ClaimsPrincipal(new ClaimsIdentity());

    public CustomAuthenticationStateProvider(ITokenService tokenService, 
                                            IHttpContextAccessor httpContextAccessor,
                                            ISessionTokenStore sessionTokenStore)
    {
        _tokenService = tokenService;
        _httpContextAccessor = httpContextAccessor;
        _sessionTokenStore = sessionTokenStore;
    }

    public override async Task<AuthenticationState> GetAuthenticationStateAsync()
    {
        try
        {
            var httpContext = _httpContextAccessor.HttpContext;
            if (httpContext == null)
                return new AuthenticationState(_anonymous);

            // 从HttpOnly Cookie获取Refresh Token
            var refreshToken = httpContext.Request.Cookies["RefreshToken"];
            if (string.IsNullOrEmpty(refreshToken))
                return new AuthenticationState(_anonymous);

            // 从Scoped存储获取Access Token
            var accessToken = await _sessionTokenStore.GetAccessTokenAsync();
            bool isAccessTokenValid = !string.IsNullOrEmpty(accessToken) && _tokenService.ValidateToken(accessToken);

            if (!isAccessTokenValid)
            {
                // 验证Refresh Token的有效性(含数据库校验)
                if (!_tokenService.ValidateToken(refreshToken))
                {
                    await ClearAuthenticationState();
                    return new AuthenticationState(_anonymous);
                }

                var refreshTokenValidInDb = await _tokenService.CheckIfRefreshTokenIsValid(refreshToken);
                if (!refreshTokenValidInDb)
                {
                    await ClearAuthenticationState();
                    return new AuthenticationState(_anonymous);
                }

                // 刷新令牌
                var userAgent = GetUserAgent(httpContext);
                var ipAddress = GetIpAddress(httpContext);
                var (newAccessToken, newRefreshToken) = await _tokenService.RefreshAuthTokens(refreshToken, userAgent, ipAddress);

                // 更新Scoped存储的Access Token
                await _sessionTokenStore.SetAccessTokenAsync(newAccessToken);

                // 更新HttpOnly Cookie的Refresh Token
                httpContext.Response.Cookies.Append("RefreshToken", newRefreshToken, new CookieOptions
                {
                    HttpOnly = true,
                    Secure = true,
                    SameSite = SameSiteMode.Strict,
                    Expires = DateTimeOffset.UtcNow.AddDays(14),
                    Path = "/"
                });
            }

            // 从数据库或令牌解析用户信息(此处假设从令牌解析,或从数据库查询)
            var userClaims = await _tokenService.GetUserClaimsFromToken(accessToken ?? await _sessionTokenStore.GetAccessTokenAsync());
            var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(userClaims, "Auth"));

            return new AuthenticationState(claimsPrincipal);
        }
        catch
        {
            await ClearAuthenticationState();
            return new AuthenticationState(_anonymous);
        }
    }

    public async Task UpdateAuthenticationState(UserSession userSession)
    {
        var httpContext = _httpContextAccessor.HttpContext;
        if (httpContext == null)
            return;

        ClaimsPrincipal claimsPrincipal;

        if (userSession != null)
        {
            // 存储Access Token到Scoped服务
            await _sessionTokenStore.SetAccessTokenAsync(userSession.AuthToken);

            // 设置Refresh Token到HttpOnly Cookie
            httpContext.Response.Cookies.Append("RefreshToken", userSession.RefreshToken, new CookieOptions
            {
                HttpOnly = true,
                Secure = true,
                SameSite = SameSiteMode.Strict,
                Expires = DateTimeOffset.UtcNow.AddDays(14),
                Path = "/"
            });

            // 构建Claims(仅包含用户标识信息,不包含令牌)
            var claims = new List<Claim>
            {
                new Claim(ClaimTypes.NameIdentifier, userSession.Id.ToString()),
                new Claim(ClaimTypes.Name, userSession.Name),
                new Claim("useragent", userSession.UserAgent),
                new Claim("ipv4", userSession.IPv4)
            };
            claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims, "Auth"));
        }
        else
        {
            await ClearAuthenticationState();
            claimsPrincipal = _anonymous;
        }

        NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(claimsPrincipal)));
    }

    private async Task ClearAuthenticationState()
    {
        var httpContext = _httpContextAccessor.HttpContext;
        if (httpContext == null)
            return;

        await _sessionTokenStore.ClearAccessTokenAsync();
        httpContext.Response.Cookies.Delete("RefreshToken", new CookieOptions { Path = "/" });
    }

    private string GetIpAddress(HttpContext httpContext)
    {
        var ipNullable = httpContext.Connection.RemoteIpAddress;
        return ipNullable?.ToString() ?? string.Empty;
    }

    private string GetUserAgent(HttpContext httpContext)
    {
        return httpContext.Request.Headers["User-Agent"].ToString();
    }
}

// 自定义Scoped服务,用于存储Access Token
public interface ISessionTokenStore
{
    Task SetAccessTokenAsync(string accessToken);
    Task<string?> GetAccessTokenAsync();
    Task ClearAccessTokenAsync();
}

public class SessionTokenStore : ISessionTokenStore
{
    private string? _accessToken;

    public Task SetAccessTokenAsync(string accessToken)
    {
        _accessToken = accessToken;
        return Task.CompletedTask;
    }

    public Task<string?> GetAccessTokenAsync()
    {
        return Task.FromResult(_accessToken);
    }

    public Task ClearAccessTokenAsync()
    {
        _accessToken = null;
        return Task.CompletedTask;
    }
}

优化说明

  1. 新增ISessionTokenStore Scoped服务,专门存储Access Token,避免暴露给前端;
  2. 将Refresh Token存储转移到HttpOnly Secure Cookie,彻底阻断XSS窃取路径;
  3. 移除Claims中的敏感令牌内容,仅保留用户标识信息;
  4. 统一清理会话的逻辑到ClearAuthenticationState方法,避免冗余;
  5. 保留原有的用户Agent、IP验证逻辑,强化Refresh Token的安全性。

总结

最优安全方案为:

  • Refresh Token:存储在HttpOnly + Secure + SameSite=Strict的Cookie中,配合数据库校验实现会话管理;
  • Access Token:存储在服务器端Scoped内存中,仅在需要调用API时临时传递;
  • 始终遵循“最小权限”原则,不暴露敏感令牌给前端,同时启用XSS/CSRF防护措施。

内容的提问来源于stack exchange,提问作者Pietrek

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.13 11:01:01