×

双 Token 机制到底怎么搭?.NET 8 从登录到刷新一次讲清楚

独孤求败 独孤求败 发表于2026-06-17 09:23:55 浏览34 评论0

抢沙发发表评论

前后端分离的项目里,Token 过期让用户重新登录,这体验太差了。

双 Token 机制就是为了解决这个问题——一个短时效的 Access Token 走接口鉴权,一个长时效的 Refresh Token 专门用来续期。用户甚至感知不到 Token 有过期这回事。

今天用 .NET 8 把整套流程跑通,登录、发 Token、过期刷新、强制下线,全部配齐。

双token机制是什么

先从一个最朴素的问题说起:你把 Web API 接口暴露出去,总不能谁都能随便调吧?

接口鉴权是底线。不加鉴权的 API,等于把数据库的大门敞开,别人想怎么拿数据都行,更别提恶意刷接口、耗尽服务器资源这类攻击了。所以绝大多数 Web API 都会要求客户端在请求时带上一张“通行证”。

这张通行证,目前业界最通用的方案是 JWT(JSON Web Token)。

JWT 本质上是一串经过签名的 Base64Url 编码字符串,里面装了三部分:头部(算法类型)、载荷(用户身份信息、过期时间等)、签名(防止篡改)。服务端收到 JWT 后验签通过,就从载荷里读出“这是谁”,不再需要查一次数据库——这也是它被叫做“无状态认证”的原因。

JWT 官网:


https://jwt.io/
图片

那问题来了:只发一个 JWT,也就是单 Token 方案,会怎样?

流程上很简单:登录成功 -> 服务端生成一个 JWT -> 客户端存起来 -> 后续请求塞到 Authorization Header 里。看似没什么毛病,但实际跑起来有几个绕不过去的坎:

  • 过期时间设短了:

     用户正在填表单,突然 Token 过期,接口全挂,只能跳登录页,体验稀碎。
  • 过期时间设长了:

     Token 有效期拉长到 7 天甚至 30 天,被盗用的风险成倍增加。而且 JWT 一旦签发,在过期之前服务端没法主动让它失效(除非额外做黑名单)。
  • 刷新只能靠重新登录:

     Token 过期后没有任何“续期”手段,用户必须重新输一遍密码。

这就是单 Token 方案的尴尬——安全性和体验之间只能二选一。

双 Token 机制就是冲着这个问题来的。

它的核心思路很朴素:两个 Token,各司其职,分开放

Token 类型
有效期
作用
Access Token
短(15分钟 ~ 2小时)
调用接口时鉴权用
Refresh Token
长(7天 ~ 30天)
专门用来换新的 Access Token

Access Token 短,即使被盗,攻击窗口有限。Refresh Token 长,但只在“换新 Token”这个特定场景下使用,不参与业务接口鉴权,暴露面小很多。

流程上,客户端在 Access Token 过期后,拿着 Refresh Token 去调用刷新接口,服务端验证 Refresh Token 有效后,签发一个新的 Access Token(有时也可以一起刷新 Refresh Token)。整个过程用户无感知,体验上相当于 Token“自动续期”。

图片

工程价值主要体现在这三条:

  1. 安全性提升:

     Access Token 短时效,泄露后可用窗口小;Refresh Token 路径单一,攻击面窄。
  2. 用户体验改善:

     后台静默续期,用户不需要反复登录。
  3. 服务端可管控:

     Refresh Token 存储在服务端(数据库或 Redis),可以随时撤销,实现“强制下线”“踢人”等操作,弥补了 JWT 无状态难以主动失效的短板。

关于“状态”需要多说一句:双 Token 机制本质上是一种混合状态架构——业务接口的鉴权保持绝对无状态(微服务间传递 Access Token 即可完成鉴权,无需查库),同时仅将状态收拢在 Refresh Token 的存储中。用极小的有状态代价,换取了对用户凭证的可控能力和更高的安全水位。

接下来我们把这套机制在 .NET 8 里完整落地。

 怎么引入

需要安装的包:

执行以下命令安装 JWT 处理库:


dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

这个包提供了 JWT 的创建、序列化与验证能力。另外,如果你打算把 Refresh Token 存数据库,顺便把 EF Core 装上;如果存 Redis,装对应的 Redis 客户端即可。正文不展开存储层细节,按你实际选型来。

Program.cs 最小接入配置(必须项 / 可选项):

以下配置以 JWT Bearer 认证为核心,后续所有受保护接口都走这个方案。

var builder = WebApplication.CreateBuilder(args);
// 【必须】添加认证服务,指定默认方案为 JWT Bearer
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        // 从配置文件读取 JWT 参数
        var jwtSettings = builder.Configuration.GetSection("JwtSettings");
        var secretKey = Encoding.UTF8.GetBytes(jwtSettings["SecretKey"] ?? string.Empty);
        
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = jwtSettings["Issuer"],
            ValidateAudience = true,
            ValidAudience = jwtSettings["Audience"],
            ValidateLifetime = true,  // 自动校验过期时间
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(secretKey),
            // 允许 5 分钟的时间偏移容错
            ClockSkew = TimeSpan.FromMinutes(5)
        };
    });
// 【必须】添加授权服务
builder.Services.AddAuthorization();
var app = builder.Build();
// 【必须】启用认证中间件
app.UseAuthentication();
// 【必须】启用授权中间件
app.UseAuthorization();
app.MapControllers();
app.Run();

配置文件(appsettings.json)需要添加 JwtSettings 节点:

{
  "JwtSettings": {
    "SecretKey": "your-super-secret-key-at-least-32-characters-long",
    "Issuer": "your-issuer",
    "Audience": "your-audience",
    "AccessTokenExpireMinutes": 30,
    "RefreshTokenExpireDays": 7
  }
}

如何验证是否接入成功?

新建一个标记 [Authorize] 的 GET 接口,不携带 Authorization Header 请求时返回 401;携带有效 JWT 时返回 200。两个状态码都符合预期,说明中间件已生效。

快速上手

这里给出 4 个由浅入深的示例,覆盖登录发双 Token、接口鉴权、刷新续期、强制注销。

示例 1:登录接口——生成 Access Token + Refresh Token

这是整套机制的入口。登录成功后同时签发两个 Token。

[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
    private readonly IConfiguration _configuration;
    // 生产环境请用数据库或 Redis,ConcurrentDictionary 仅用于演示
    private static readonly ConcurrentDictionary<stringstring> _refreshTokenStore = new();
    public AuthController(IConfiguration configuration)
    {
        _configuration = configuration;
    }
    [HttpPost("login")]
    public IActionResult Login([FromBody] LoginRequest request)
    {
        // 模拟身份校验(实际项目查数据库并比对密码哈希)
        if (request.Username != "admin" || request.Password != "123456")
        {
            return Unauthorized(new { message = "用户名或密码错误" });
        }
        // 生成 Access Token
        var accessToken = GenerateAccessToken(request.Username, "10001");
        // 生成 Refresh Token(随机字符串)
        var refreshToken = GenerateRefreshToken();
        
        // 存储 Refresh Token,关联当前用户
        _refreshTokenStore[refreshToken] = request.Username;
        // 读取过期配置
        var expireMinutes = _configuration.GetValue<int>("JwtSettings:AccessTokenExpireMinutes");
        var expireSeconds = expireMinutes * 60;
        var expireAt = DateTimeOffset.UtcNow.AddMinutes(expireMinutes).ToUnixTimeSeconds();
        return Ok(new
        {
            access_token = accessToken,
            refresh_token = refreshToken,
            expires_in = expireSeconds,      // 相对秒数,兼容旧客户端
            expires_at = expireAt,           // 绝对时间戳(秒),推荐前端使用
            token_type = "Bearer"
        });
    }
    private string GenerateAccessToken(string username, string userId)
    {
        var jwtSettings = _configuration.GetSection("JwtSettings");
        var secretKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings["SecretKey"] ?? string.Empty));
        var credentials = new SigningCredentials(secretKey, SecurityAlgorithms.HmacSha256);
        var claims = new[]
        {
            new Claim(ClaimTypes.Name, username),
            new Claim(ClaimTypes.Role, "Admin"),
            new Claim("user_id", userId)  // 从数据库传入的固定用户 ID
        };
        // 统一使用 _configuration.GetValue 读取,避免 IConfigurationSection 相对路径歧义
        var expireMinutes = _configuration.GetValue<int>("JwtSettings:AccessTokenExpireMinutes");
        var token = new JwtSecurityToken(
            issuer: jwtSettings["Issuer"],
            audience: jwtSettings["Audience"],
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(expireMinutes),
            signingCredentials: credentials
        );
        return new JwtSecurityTokenHandler().WriteToken(token);
    }
    private string GenerateRefreshToken()
    {
        var randomNumber = new byte[32];
        using var rng = RandomNumberGenerator.Create();
        rng.GetBytes(randomNumber);
        return Convert.ToBase64String(randomNumber);
    }
}

预期输出(登录成功):

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "T5xgKlL1Jk4xG2aB9m7P3qFxYwUeN8vR...",
  "expires_in": 1800,
  "expires_at": 1734567890,
  "token_type": "Bearer"
}

验证步骤: 调用 /api/auth/login,拿到两个 Token。之后受保护接口的请求头中加入 Authorization: Bearer {access_token},就能正常访问。

示例 2:受保护接口 + Access Token 校验

用 [Authorize] 标记一个需要登录才能访问的接口,演示 Token 如何被自动校验。

[Authorize]
[HttpGet("profile")]
public IActionResult GetProfile()
{
    // User.Identity.Name 由 JWT 中的 Claim(ClaimTypes.Name) 自动填充
    var userName = User.Identity?.Name;
    var userId = User.FindFirst("user_id")?.Value;
    return Ok(new
    {
        userName,
        userId,
        message = "这是一个受保护的数据",
        timestamp = DateTime.UtcNow
    });
}

验证步骤:

  1. 不携带 Header 调用,返回 401。
  2. 携带有效 Access Token 调用,返回 200 和用户信息。
  3. 等待 Token 过期(或手动将 exp 设为过去时间),再携带过期 Token 调用,返回 401(具体是 WWW-Authenticate: Bearer error="invalid_token")。

示例 3:刷新接口——用 Refresh Token 换新的 Access Token(安全版)

这是双 Token 机制的核心动作。关键点:刷新接口必须同时校验旧的 Access Token 和 Refresh Token,且两者必须属于同一用户。

[HttpPost("refresh")]
public IActionResult Refresh([FromBody] RefreshRequest request)
{
    // 1. 校验 Refresh Token 是否存在
    if (string.IsNullOrEmpty(request.RefreshToken) || 
        !_refreshTokenStore.TryGetValue(request.RefreshToken, out var storedUsername))
    {
        return Unauthorized(new { message = "Refresh Token 无效" });
    }
    // 2. 解析旧的 Access Token(忽略过期时间,只验证签名和内容)
    var tokenHandler = new JwtSecurityTokenHandler();
    var jwtSettings = _configuration.GetSection("JwtSettings");
    var secretKey = Encoding.UTF8.GetBytes(jwtSettings["SecretKey"] ?? string.Empty);
    
    try
    {
        var validationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = jwtSettings["Issuer"],
            ValidateAudience = true,
            ValidAudience = jwtSettings["Audience"],
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(secretKey),
            ValidateLifetime = false,  // 关键:忽略过期,允许解析已过期的 Token
            ClockSkew = TimeSpan.Zero
        };
        var principal = tokenHandler.ValidateToken(request.AccessToken, validationParameters, out _);
        var usernameFromOldToken = principal.Identity?.Name;
        var userIdFromOldToken = principal.FindFirst("user_id")?.Value;
        // 3. 校验旧 Access Token 中的用户名和 Refresh Token 存储的用户名是否一致
        if (string.IsNullOrEmpty(usernameFromOldToken) || usernameFromOldToken != storedUsername)
        {
            return Unauthorized(new { message = "Access Token 与 Refresh Token 不匹配" });
        }
        // 4. 通过校验,生成新的 Access Token
        var newAccessToken = GenerateAccessToken(usernameFromOldToken, userIdFromOldToken ?? "unknown");
        // 5. 【可选】Refresh Token 轮换:生成新的 Refresh Token 并替换旧的
        // 如果启用轮换,需要将旧的 Refresh Token 从存储中删除,存入新的
        // 示例中暂不启用轮换以保持简洁
        // 读取过期配置
        var expireMinutes = _configuration.GetValue<int>("JwtSettings:AccessTokenExpireMinutes");
        var expireSeconds = expireMinutes * 60;
        var expireAt = DateTimeOffset.UtcNow.AddMinutes(expireMinutes).ToUnixTimeSeconds();
        return Ok(new
        {
            access_token = newAccessToken,
            expires_in = expireSeconds,
            expires_at = expireAt,
            token_type = "Bearer"
        });
    }
    catch (SecurityTokenException)
    {
        // 签名校验失败或 Token 格式非法
        return Unauthorized(new { message = "Access Token 签名无效" });
    }
}

请求示例:

POST /api/auth/refresh
{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...(旧的、已过期的 Access Token)",
  "refreshToken": "T5xgKlL1Jk4xG2aB9m7P3qFxYwUeN8vR"
}

预期输出:

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...(新 Token)",
  "expires_in": 1800,
  "expires_at": 1734567890,
  "token_type": "Bearer"
}

验证步骤:

  1. 正常登录拿到 Access Token 和 Refresh Token,Refresh_token 有效期有两种处理方式:1. 接口动态返回;2. 前后端约定固定时长
  2. 等待 Access Token 过期(或手动调系统时间)。
  3. 用过期的 Access Token 调用 /api/auth/profile,返回 401。
  4. 调用 /api/auth/refresh 同时传入过期的 Access Token 和 Refresh Token,拿到新的 Access Token。
  5. 用新 Access Token 再次调用 /api/auth/profile,恢复正常。
  6. 尝试用篡改过的 Access Token + 正确的 Refresh Token 调用刷新接口,返回 401(签名校验失败)。
  7. 尝试用正确的 Access Token + 不匹配的 Refresh Token 调用刷新接口,返回 401(用户不匹配)。

这个设计堵住了一个常见的安全漏洞:即使 Refresh Token 泄露,攻击者如果没有同时拿到对应的 Access Token(且不知道其中的用户身份),也无法成功刷新。

示例 4:强制注销(Refresh Token 主动失效)

这是双 Token 相比单 Token 的另一个优势——服务端可以主动让 Refresh Token 失效,从而实现“踢人下号”或“修改密码后全端下线”。

[Authorize]
[HttpPost("logout")]
public IActionResult Logout([FromBody] LogoutRequest request)
{
    var refreshToken = request.RefreshToken;
    
    // 从存储中移除 Refresh Token
    if (!string.IsNullOrEmpty(refreshToken))
    {
        _refreshTokenStore.TryRemove(refreshToken, out _);
    }
    
    return Ok(new { message = "已注销" });
}

验证步骤:

  1. 登录后调用 /api/auth/logout 传入 Refresh Token。
  2. 服务端将该 Refresh Token 从存储中删除。
  3. 再拿着这个 Refresh Token 去调用 /api/auth/refresh,返回 401(因为已找不到)。

生产环境说明:ConcurrentDictionary 只是演示。真实场景建议用 Redis(设置过期时间,自然过期)或数据库(增加 is_revoked 字段,手动标记失效)。

实战中最常见的 2~3 个报错:

报错现象
触发条件
修复方式
401 Unauthorized
,响应头带 WWW-Authenticate: Bearer error="invalid_token"
Access Token 过期或签名不一致
检查 JWT 生成和验证的 SecretKey 是否一致;确认系统时间是否正常
401 Unauthorized
,无额外错误信息
客户端请求头未携带 Authorization: Bearer xxx
前端确保每个请求都带 Header,检查拦截器逻辑
SecurityTokenInvalidAudienceException
JWT 的 Audience 和验证参数不匹配
核对生成时的 Audience 与 ValidAudience 是否一致
刷新接口返回 “Access Token 与 Refresh Token 不匹配”
传入的旧 Access Token 中的用户名和 Refresh Token 关联的用户名不一致
检查客户端是否正确传入了对应的 Access Token(即使是过期的也要传原值)

适用场景

单体和一般 Web 应用:

双 Token 机制完全适用。如果你的应用是标准的浏览器/移动端 + Web API,用户需要登录且对体验有一定要求,这套方案直接落地即可。前置条件是服务端需要有存储 Refresh Token 的地方(数据库或 Redis)。

分布式和微服务:

在这类场景下优势更明显。Access Token 的无状态特性使得它可以在各个微服务间轻松传递,无需共享 Session。Refresh Token 集中存储在一个地方(比如用户中心服务或 Redis),各服务通过统一的认证网关校验 Access Token 的有效性即可。边界在于:如果服务间调用链很长,Access Token 的验证开销会累积,此时可以考虑在网关层统一做验证,内部服务间用更轻量的机制。

高并发场景:

Access Token 的验证是 CPU 密集操作(非对称加密验签成本较高),如果采用对称签名(HMAC),性能会好很多。Refresh Token 的请求量远低于普通业务接口,一般不会成为瓶颈。但如果用户量极大(千万级),Refresh Token 的存储查询可能成为热点,需要做好缓存和索引。

风险:

  • 客户端(尤其是 SPA 应用)如果直接把 Refresh Token 存 LocalStorage,仍然面临 XSS 风险,这个和单 Token 一样。更稳妥的方式是结合 HttpOnly Cookie 存放 Refresh Token(后续单独聊)。
  • Refresh Token 的存储引入了状态,不再是纯粹的无状态方案,架构上需要额外考虑存储的可用性和一致性。

 实战建议

以下 3 个坑位来自真实项目经验,值得提前规避。

坑位 1:Refresh Token 无有效期约束,永久有效

  • 触发条件:

     生成 Refresh Token 时只存了字符串,没有设置过期时间,也没有在存储层加 TTL。
  • 错误表现:

     Refresh Token 永不失效,即使 Access Token 不断刷新,Refresh Token 始终可用。一旦泄露,等于永久后门。
  • 修复策略:

    1. Refresh Token 在服务端存储时附带过期时间字段,验证时同时检查是否超期。
    2. 存 Redis 时直接设置 TTL(例如 7 天)。
    3. 做 Refresh Token 轮换——每次刷新时同时换发新的 Refresh Token,旧的一律失效。

坑位 2:并发刷新导致 Refresh Token 被提前消耗(仅出现在启用轮换时)

  • 触发条件:

     当你开启了更高级的安全策略——Refresh Token 轮换(每次刷新都换发新 Refresh Token)时,前端多个请求同时发现 Access Token 过期,同时发起刷新请求,都带着同一个 Refresh Token。
  • 错误表现:

     第一个刷新请求成功后,服务端轮换了 Refresh Token,后续的刷新请求拿着旧 Refresh Token 被拒绝,导致用户被强制下线。
  • 修复策略:

    1. 前端做请求队列或锁机制:同时只有一个请求执行刷新,其他请求等待并复用新 Token。
    2. 服务端支持“一次刷新,所有旧 Refresh Token 仍然有效 30 秒”的宽容窗口(需要设计 Token 版本号或父级 Token 关联)。
    3. 如果不启用轮换,Refresh Token 复用的情况下不会触发此问题,但安全性略低。

坑位 3:服务器时间不同步导致 Token 被误判过期

  • 触发条件:

     多台服务器部署,其中一台系统时间比另一台慢了几分钟。客户端请求轮询到慢服务器上生成的 Token,在快服务器上验证时被判定为已过期。
  • 错误表现:

     同样的 Token,有时候有效有时候无效,排查极其困难。
  • 修复策略:

    1. 所有服务器统一配置 NTP 时间同步。
    2. 设置 ClockSkew = TimeSpan.FromMinutes(5)(已在 Program.cs 中配置),容错时间偏移。

 选型建议

什么场景更适合采用双 Token 机制:

  • 面向 C 端用户的产品,登录体验是核心考量指标之一。
  • Token 有效期需要设置得比较短(安全要求高),但又不希望用户频繁登录。
  • 有服务端强制用户离线的需求(如管理后台踢人、用户修改密码后全端登出)。
  • 团队有 Redis 或数据库等存储设施可用,能承接 Refresh Token 的存储需求。

什么场景不太适合:

  • 纯内部系统、用户量极少、安全要求不高,单 Token 设长一点就够用了。
  • 无状态服务的极致追求者——双 Token 的 Refresh Token 存储引入了状态,打破了 JWT“完全无状态”的初衷。
  • 客户端是服务间调用(Server-to-Server),没有“用户感知”问题,且调用频率固定,单 Token 设合理时长即可。
  • 小团队短期项目,开发资源紧张,双 Token 的客户端逻辑(过期检测、刷新拦截、队列控制)需要一定的开发量。

参考建议:

团队规模 3~5 人、并发量不高、上线周期紧张的项目,可以先用单 Token 设较长时间(如 24 小时),后期再升级到双 Token。团队 10 人以上、面向线上用户的正式产品,建议第一版就上双 Token——后续切换成本比一开始就做要高得多。

 总结收尾

单 Token 方案最大的问题,不是 JWT 本身不行,而是它在“安全”和“体验”之间让你做选择题。双 Token 把这两个目标解耦了——Access Token 保安全,Refresh Token 保体验,各司其职。

可以直接执行的落地建议: 下周要上线的项目,把文中示例 1 到示例 3 的代码直接粘过去,改一下 SecretKey 和配置文件,Refresh Token 存储换成 Redis(设好 TTL),前端配合做一下 401 拦截刷新逻辑,整套双 Token 机制一天之内就能跑通。

额外多说一句: 这套双 Token 的认证骨架,一旦搭好、跑通、踩完坑,它就是一笔可以反复复用的资产。下一个新项目来了,把 AuthController、JWT 配置、Refresh Token 存储层这堆东西整个目录拷贝过去,改改配置文件和 Claim 字段,十分钟认证体系就有了。你不必每次都从零开始搭一遍。

所以有空的话,还是争取把这块基础架构认真写一写、沉淀下来。花一天时间打磨好模板,后面每一个项目都能省出两到三天的前期搭建时间,这笔账怎么算都不亏。

以上代码基于 .NET 8 编写,在你的项目里微调下 Claim 和存储层实现,基本能无缝复用。如果你在接入过程中遇到并发刷新、Refresh Token 轮换、或者跨域携带 Header 的问题,欢迎在评论区留言,我看到都会回复。


群贤毕至

访客