DOTNET 鉴权系列- JWT 会话撤销与主动登出

写这篇的主要原因是记录一下jwt无状态特点下的主动撤销和登出的解决方案,因为之前负责做过类似的用户相关的系统,对于这一块的方案和实现比较粗糙的,登录成功后,计算完整 JWT 字符串的 Hash,将tokenHash + userId存入Redis,每次鉴权时计算请求 Token 的 Hash,去存储中比对是否存在,这套方案虽然实现了登出撤销,但把所有有效 Token 全部落地存储,JWT 只剩下签名验签的能力,几乎退化成传统 Session 模式,丢失了 JWT 无状态的设计初衷。

最近特意看了下这一块一些开源框架的实现思路,顺便记录梳理一下各类可行方案、优缺点以及如何落地。

web系统多半是前后端分离的架构,而JWT是目前Web 应用最流行的身份认证方案。对于JWT它最大的优势就是无状态。登录成功后,认证服务颁发一个Token给到客户端,客户端业务操作请求带上 Token,认证服务只需要验签、验过期时间,验证通过就可以确认身份了。

有做过开发经验的小伙伴,对于上面描述的这部分理解起来应该没啥难度,服务器只做2件事

++1.验证账密颁发一定期限的Token++

++2.校验Token对不对,过没过期++

标准流程下服务端是完全没有存会话的,但这套机制放到真实业务中,就会碰到一些实实在在的问题,「小王上午 9 点登录系统,拿到一个有效期 8 小时Token」例如:

10 点他主动退出登录了,但是旧 Token 还没过期。
他在操作时管理员需要把他踢下线。
9点30他在另外一个地方登了系统,我需要过期掉9点这次登录的Token。

如果使用标准流程下实现,哪怕主动退出,强制踢出,或者顶掉了登录,我们再次拿着旧的Token去请求接口,是依然能通过鉴权的,从真实业务和系统安全上来说,我都主动退出了,拿去请求接口怎么还可以,服务端不是应该拒绝的吗?

如果使用纯 JWT是拒绝不了的。只要 签名 /iss/aud 正确、没有过期,JwtBearer 就会认为它有效。就算浏览器客户端把 Token 从本地删掉,那也只表示这个浏览器不再使用它,不代表别人复制走的 Token 也失效。其实基于上面这3点真实业务需求,本质上就是要实现,无论何种方式的注销登录,我们应该要使打开大门的钥匙(token)也要失效

那么接下来就聊一下如何在实际业务中使用 JWT + 可撤销会话 来实现解决这个问题,先说如何做:

给 JWT 加 session_id,然后在每个请求查询服务端会话状态,可以实现主动下线和会话撤销,但是这样搞了就不是严格意义上的无状态认证了,而是在 JWT 验签后额外引入了一次状态校验。

上面说的这句话很重要。不要既想要立刻踢人,又要服务端完全无状态,它们两不可能同时成立。

1.JWT 验证的是什么

我们通常理解的就是一个登录凭证,但更准确的理解它证明的是:

  1. token是哪个服务颁发

  2. token有没有过期

  3. token里面的数据有没有被篡改

因为这三点都依赖于 JWT 的第三段签名。它用服务端的密钥对前两段内容做了加密签名,验证时只要签名校验通过,就能确认签发方、有效和数据完整性。但是它不包含:

  1. 这次登录是否仍被允许。

  2. 用户是否刚被禁用。

  3. 这台设备是否已被踢下线。

2.常见方案梳理

那么我们怎么解决上面这个问题呢,知道了问题所在,那解决这个问题就好办了,是token本身的问题我们就从token入手,通常有如下方案网上一搜,都差不多介绍,但是没有绝对的好坏,选用合适的就行

方案 Token 中放什么 服务端保存什么 更适合什么场景
jti 黑名单 每个 Token 一个 jti 已作废 Token 的 jti,直到 Token 过期 主动退出登录
Token Version / Security Stamp 用户级版本号 用户当前版本号 改密码、禁用用户、全端下线
session_id 白名单 一次登录一个会话 ID 活跃会话记录 在线设备列表、踢指定设备、单设备登录

1. jti 黑名单

签发 Token 时加入随机 jti,注销登录时把 jti 放进 Redis,过期时间和 Token 对齐,每个请求验签后再查它是否在黑名单。优点就是它只记录已经作废的少数 Token。但如果需要展示在线设备、踢某一台设备、黑名单很快会变得绕。因为它只记录了谁被干掉了,不知道哪些还活着。

1.登录成功后,生成一个全局唯一的 jti,写入 JWT 的 Payload 中,然后把 Token 返回给客户端。这样每张 Token 都有一个唯一的标识号了。

2.每次请求解析出 jti,查 Redis 黑名单,如果存在就拒绝

3.当需要撤销某个Token ,例如用户主动登出从 Token 中解析出将 jti 写入 Redis,TTL 设为 Token 剩余有效期。

4.写入黑名单的时候设置了 TTL,所以 Token 本身的有效期到期后,Redis 会自动删除对应的黑名单条目。黑名单不会一直增长,也不需要手动维护。

2.Token Version

在用户表里面存一个 token_versionsecurity_stamp。生成的Token 里面也带一份,每次请求比较用户表和token中的版本号或guid随机的tag,不一样就拒绝,比较适合改密码后让所有旧登录失效,账号禁用后全部下线。不过它是用户维度的,一般做不到精确踢掉某台设备而不影响其他设备。

1.用户表中新增字段 token_version,默认值为 1。字段记录当前用户 Token 的版本号,每次需要使旧 Token 失效时,版本号 +1。

2.用户登录成功后,从数据库查询用户当前的版本,然后写入 JWT 的 Payload 中,然后签发 Token 返回给客户端。这样每张 Token 都记住了发token时的版本号。

3.客户端携带 Token 请求受保护资源时,服务端先验签、校验过期时间,通过后从 Token 中解析出 token_version,再去数据库查询该用户当前的 token_version。两者一致则放行,不一致则返回 401,要求客户端重新登录。

4.当使某个用户的所有旧 Token 失效时,如修改密码、退出所有设备,只需将用户表的版本号加 1。由于旧 Token 中记录的版本号已经落后,下次请求时会比对失败,所有旧 Token 立即失效。

3. session_id 白名单

session_id 白名单每次登录生成一个唯一的 session_id,写入 JWT 的同时在数据库里面存储,验证时检查该 session_id 是否存在于数据库白名单中,不存在则拒绝。与黑名单记录已失效的不同,白名单只认登记过的,所以对支持会话管理和设备控制更方便直接。

  1. 生成唯一 session_id,写入 JWT,同时在数据库插入一条会话记录。
  2. 每次请求解析出 session_id,查数据库是否存在且未过期,存在放行然后续期,不存在就拒绝。
  3. 删除指定 session_id 的会话记录, Token 立即就失效了,其他设备不受影响。

JWT验证只依赖签名密钥和 Token 本身,任意的服务实例都能实现支持独立完成校验。加入会话白名单以后,请求会变成:

  1. JWT 验签。
  2. 查询 session_id 是否有效。
  3. 接受或拒绝本次身份。

4.方案对比

把前面三个放一起对着看看,定位就清晰了

  • jti 黑名单:适合只做登出或单个 Token 撤销的场景,成本比较低,但看不到全局在线设备,也无法定点踢用户。

  • Token Version:动静比较大用户改密码、封号一键生效,简单粗暴,但粒度太粗,做不到设备级隔离。

  • session_id 白名单:能查在线列表、可以精确踢设备,代价是每次请求都要查存储,会破坏了JWT无状态的特点。

实际选型时,主要看需要管到什么程度,如果只是让用户自己登出、改密码后失效,黑名单或版本号都够用,但如果管理员要看到谁在登录、从哪登录、并能单独踢掉某个设备,只能选择白名单了。

到这里你可能会问,那有没有其他方法呢?有的!!我第一次知道这个方法,是在极客时间上听一个老师讲的,瞬间豁然开朗,因为颁发和校验token时都是使用秘钥来校验的,换密钥能让所有已签发的 Token 瞬间全部失效,但它属于终极大招,太猛了不分敌我,全部干掉的,实际中应该没人这么做吧,除非密钥泄露,一些重大安全事件、需要全站强制登出才会这么干。

3.落地白名单

方案聊完了,下面开始实现,Demo中用个内存字典来作为持久化角色的容器,生产换成 Redis 或数据库就行。先把这块存储写出来,三个很简单的方法 Save 写入、IsValid 查询、Revoke 删除。

cs 复制代码
public class UserSession
{
    public required string SessionId { get; init; }
    public required string Username { get; init; }
    public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
    public DateTimeOffset LastAccessed { get; set; } = DateTimeOffset.UtcNow;
}

public class UserSessionStore
{
    private readonly ConcurrentDictionary<string, UserSession> _sessions = new();

    public void Save(string sessionId, string username) =>
        _sessions[sessionId] = new UserSession { SessionId = sessionId, Username = username };

    public bool IsValid(string? sessionId)
    {
        if (string.IsNullOrEmpty(sessionId) || !_sessions.TryGetValue(sessionId, out var session))
            return false;

        session.LastAccessed = DateTimeOffset.UtcNow; // 续期:记下最后活动时间
        return true;
    }

    public void Revoke(string sessionId) => _sessions.TryRemove(sessionId, out _);

    public int RevokeAll(string username)
    {
        var keys = _sessions.Where(x => x.Value.Username == username).Select(x => x.Key).ToList();
        foreach (var key in keys)
            _sessions.TryRemove(key, out _);
        return keys.Count;
    }
}

1. 签发

登录成功后要生成 session_id 然后写入白名单,再把同一份 claim 写进 JWT

cs 复制代码
[AllowAnonymous]
[HttpPost("login")]
public IActionResult Login([FromBody] LoginRequest request)
{
    if (request.Username != "admin" || request.Password != "123456")
        return Unauthorized();

    var sessionId = Guid.NewGuid().ToString("N");
    _sessions.Save(sessionId, request.Username);

    var claims = new[]
    {
        new Claim(ClaimTypes.Name, request.Username),
        new Claim("session_id", sessionId)
    };

    var (token, expiresUtc) = _tokenService.GenerateToken(claims);
    return Ok(new { token, sessionId, expiresUtc });
}

2.请求校验

每个已认证请求走两步。Jwt验签这一步是无状态的,再查白名单有状态。会话失效时不要改 JWT,只需要把 HttpContext.User 清空就好了,后面的Authorize 会 401。我们选择定义一个中间件 SessionValidationMiddleware

cs 复制代码
public class SessionValidationMiddleware
{
    private readonly RequestDelegate _next;
    public SessionValidationMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext context, UserSessionStore sessions)
    {
        if (context.User.Identity?.IsAuthenticated == true)
        {
            var sessionId = context.User.FindFirst("session_id")?.Value;
            if (sessionId is not null && !sessions.IsValid(sessionId))
            {
                // 验签已过,但这次登录已被撤销
                context.User = new ClaimsPrincipal(new ClaimsIdentity());
            }
        }

        await _next(context);
    }
}

但是注入的位置要放在认证和鉴权的中间, 先UseAuthentication() → 会话中间件 SessionValidationMiddleware UseAuthorization()

cs 复制代码
app.UseAuthentication();
app.UseMiddleware<SessionValidationMiddleware>();
app.UseAuthorization();

3.会话撤销

撤销不会改已经发出的 Token,但是客户端仍可拿旧 JWT 来访问接口,由于无状态只要没过期验签会过,但是走到IsValid 失败后变成 401

cs 复制代码
[Authorize]
[HttpPost("logout")]
public IActionResult Logout()
{
    var sessionId = User.FindFirst("session_id")?.Value;
    if (string.IsNullOrEmpty(sessionId))
        return BadRequest();

    _sessions.Revoke(sessionId);   // 主动退出:只废当前这把钥匙
    return Ok();
}

[HttpPost("sessions/{sessionId}/revoke")]
public IActionResult RevokeSession(string sessionId)
{
    _sessions.Revoke(sessionId);   // 踢指定设备
    return Ok();
}

[HttpPost("users/{username}/revoke-all")]
public IActionResult RevokeAll(string username)
{
    _sessions.RevokeAll(username); // 全端下线
    return Ok();
}

所以按照这几步下来,开始那三个问题就能被解决了,主动退出调 logout,管理员踢人调 revoke,换地方登录把旧 session 干掉。旧 Token 就算存在也没用,下次请求 IsValid 过不去,Authorize 直接就 401了。

相关推荐
a187927218318 小时前
从一条直线到大模型输出一个token(九):输出矩阵与多层堆叠
深度学习·ai·transformer·token·注意力机制·deepseek·多层堆叠
小七-七牛开发者8 小时前
拆解 DeepSeek Harness:Profile 与 Bundle 如何装配运行时
ai·大模型·agent·token·工作流·claudecode·ai coding
小七-七牛开发者1 天前
61 亿次请求背后:LLM Serving 的 Cache 与调度难题
ai·大模型·agent·token·工作流·claudecode·ai coding
正儿八经的少年2 天前
sa-token,jwt,security 生成 token 的区别
sa-token·security·jwt
xiaokcehui2 天前
deepseek harness安装和使用入门
token
XLYcmy4 天前
小红书 算法一面 十一
google·meta·llm·负载均衡·token·moe·glam
小七-七牛开发者5 天前
dsh 拆解系列 Vol.01:没有特权内核的 Agent 运行时
ai·大模型·agent·claude·token·工作流·skill·claudecode·ai coding
孙启超6 天前
【大模型应用开发】LLM 到底是什么,以及它是怎么训练的
人工智能·lora·llm·微调·sft·token·rlhf