皮皮舟印岁月

A JOURNAL OF TRAVEL · CODE · MARKETS
第三十七期 · 第 37 号 二〇二六年十月 · 秋分之后

JWT 登录态可撤销设计与会话表方案

2026-10-05 12 阅读

一句话导读:JWT 是无状态的,签发出去之后服务端就再也收不回来——用户退出、管理员封号、用户改密码,老令牌在到期之前照样畅通无阻。本文承接上一篇的假设平台,用一张服务端会话表给 JWT 补上"可提前作废"的开关,讲清原理、表结构、SQL、服务端代码,以及单账号单会话、强制下线、缓存取舍等落地细节。


场景设定

沿用上一篇的平台:用户端以手机号注册登录,运营端另有管理后台,运营人员用账号密码登录。两类账号并存,但登录之后的诉求是一致的——需要知道"这个人现在还是不是登录状态",并且要能随时把他踢下线。

具体到业务上,平台提出了五条要求。

  • 用户点"退出登录",登录态必须立即失效
  • 用户修改密码后,其它设备上的登录必须立即失效
  • 运营账号被停用时,正在使用的登录态必须立即失效
  • 同一账号在新设备登录,旧设备应当被顶下线
  • 运营后台要能看到"当前登录的设备",并支持定向踢掉某一台

这五条要求有一个共同点:都必须在令牌自然到期之前把它作废。而 JWT 恰好做不到这件事。


无状态令牌的代价

JWT 由三段组成:头部、载荷、签名。服务端把账号标识、签发时间、到期时间写进载荷,用自己的密钥签名,再交给客户端保存。

它的设计初衷是"无状态":服务端不需要保存任何东西,每次请求只要用密钥验一遍签名,就能确认这个令牌确实是自己签发的、且没有被篡改过。好处有两个——服务端不必为登录态维护存储,多实例部署时天然共享。

代价也随之而来:令牌一旦签发,服务端就失去了对它的控制。只要签名有效、时间没到期,它就一直有效,没有任何一个地方可以把它撤回。

一个直观的时间线:

10:00  用户 A 登录,令牌 12:00 到期
10:30  管理员停用了 A 的账号
10:31  A 拿着旧令牌请求接口  ->  验签通过、未到期  ->  放行
12:00  令牌自然到期,才真正失效

账号已经停用,令牌却还有九十分钟的有效期。用户主动退出、修改密码、强制下线,遇到的都是同一个问题。


方案选型

要补上"可提前作废",本质上就是要在服务端留一份可写的记录。几种做法各有取舍。

方案 能否提前作废 服务端是否需存储 每次请求开销 多实例共享 结论
纯 JWT,不留服务端记录 不能 无 仅验签 天然共享 无法满足立即失效
服务端 Session(存内存) 能 有 查内存 需粘性会话或共享存储 多实例下成本高
JWT + Redis 黑名单 能 有 查 Redis 需共享 Redis 可行,但只记"作废",缺设备与活跃信息
JWT + 数据库会话表 能 有 查一次库(可缓存) 天然共享 推荐

选数据库会话表的理由:不需要引入额外中间件,而且会话本身顺带承载了"登录设备、最后活跃时间、失效原因"这些审计与运营需要的信息。代价是每次请求多一次库查询,用短 TTL 的本地缓存可以压掉大部分开销。

Redis 黑名单方案更快,但它只记录"哪些令牌被作废了"。要回答"当前登录了哪些设备""谁还在线",还得另建一套结构。如果平台已经部署了 Redis,且只关心作废、不关心设备管理,用黑名单同样成立。


会话表的原理

核心动作只有一个:在 JWT 里放一个唯一标识,服务端为每次签发留一行记录,两者用这个标识对上号。

JWT 标准里有一个 jti 声明(JWT ID),专门用来标识单个令牌。签发时生成一个随机 jti 写进载荷,同时在会话表里插入一行,把它存下来。之后每次请求的校验分成两步。

第一步验签,确认令牌是自己签发的、没有被篡改、也没有过期。第二步查账,用 jti 去会话表确认这一行没有被作废。

第二步就是纯 JWT 缺失的那一环。revoked_at 一旦被写上时间,这个令牌立刻失效,不管它离到期还有多久。

flowchart TD
    A["客户端请求<br/>Authorization: Bearer token"] --> B["服务端验签<br/>校验签名与 exp"]
    B -->|失败| Z["401 未登录或登录态无效"]
    B -->|通过| C["从载荷取 jti"]
    C --> D{"会话表查询<br/>token_id = jti<br/>revoked_at 为空 且 未过期"}
    D -->|无记录 / 已作废 / 已过期| Z
    D -->|有效| E["刷新 last_active_at<br/>注入账号身份"]
    E --> F["放行,进入业务处理"]

会话表只存 jti,不存令牌本体。这样即使会话表被拖走,攻击者也拿不到任何一个可用的令牌。


数据结构设计

会话表命名为 login_session。用户端账号是上一篇的 user_account,运营端另有 admin_account,两者共用这一张会话表,用 account_type 区分归属。

字段 类型 约束 说明
id bigserial 主键 会话主键
account_type varchar(10) 非空 USER / ADMIN
account_id bigint 非空 对应 user_account.id 或 admin_account.id
token_id varchar(64) 非空 令牌唯一标识 jti,与 JWT 载荷一致
issued_at timestamptz 非空 签发时间
expires_at timestamptz 非空 到期时间,签发时间加上有效期
last_active_at timestamptz 最后活跃时间
revoked_at timestamptz 提前作废时间
revoke_reason varchar(30) 作废原因
client_ip varchar(64) 登录 IP
user_agent varchar(255) 客户端标识
created_at timestamptz 非空 创建时间
updated_at timestamptz 非空 更新时间

有三点设计说明值得展开。

账号分两张表、会话合一张。用户端账号以手机号为唯一标识、走验证码登录;运营账号有密码策略、角色与状态机。两者的账号字段差异很大,合表会塞进大量互斥的空字段。而会话的字段两边完全一致——签发、到期、作废、客户端信息都一样,拆开只会把"单账号单会话""超时判定"这类逻辑实现两遍。所以账号分表、会话合表,用 account_type 区分即可。

会话表不使用软删除。会话的"消失"由业务语义表达:自然到期看 expires_at,人为作废看 revoked_at。再叠一个 deleted_at 只会让判定条件多一个维度,没有收益。

revoked_at 只记录人为提前作废。自然到期不需要写 revoked_at,判定时用 expires_at > now() 就够了。如果审计上需要区分"到期失效"和"人为失效",可以由清理任务在删除前回填一条 EXPIRED。

索引按三类访问路径设计:

-- 令牌标识唯一,校验时精确定位到一行
CREATE UNIQUE INDEX uk_login_session_token_id
    ON login_session (token_id);

-- 按账号取未失效会话:单账号单会话、强制下线、设备列表都走它
CREATE INDEX idx_login_session_account
    ON login_session (account_type, account_id)
    WHERE revoked_at IS NULL;

-- 过期清理任务使用
CREATE INDEX idx_login_session_expires
    ON login_session (expires_at);

SQL 实现

建表

CREATE TABLE login_session (
    id             BIGSERIAL    PRIMARY KEY,
    account_type   VARCHAR(10)  NOT NULL,
    account_id     BIGINT       NOT NULL,
    token_id       VARCHAR(64)  NOT NULL,
    issued_at      TIMESTAMPTZ  NOT NULL,
    expires_at     TIMESTAMPTZ  NOT NULL,
    last_active_at TIMESTAMPTZ,
    revoked_at     TIMESTAMPTZ,
    revoke_reason  VARCHAR(30),
    client_ip      VARCHAR(64),
    user_agent     VARCHAR(255),
    created_at     TIMESTAMPTZ  NOT NULL DEFAULT now(),
    updated_at     TIMESTAMPTZ  NOT NULL DEFAULT now()
);

COMMENT ON COLUMN login_session.token_id      IS '令牌唯一标识 jti,与 JWT 载荷一致,不存令牌本体';
COMMENT ON COLUMN login_session.revoked_at    IS '提前作废时间:登出 / 被顶下线 / 强制下线';
COMMENT ON COLUMN login_session.revoke_reason IS '作废原因:LOGOUT / KICKED_BY_NEW_LOGIN / ADMIN_REVOKE / PASSWORD_CHANGED';

登记新会话

INSERT INTO login_session
    (account_type, account_id, token_id, issued_at, expires_at, client_ip, user_agent)
VALUES
    (:accountType, :accountId, :jti, :issuedAt, :expiresAt, :clientIp, :userAgent);

校验会话是否有效

SELECT COUNT(1)
FROM login_session
WHERE token_id = :jti
  AND revoked_at IS NULL
  AND expires_at > now();

刷新最后活跃时间

UPDATE login_session
SET last_active_at = now(), updated_at = now()
WHERE token_id = :jti;

登出

只作废当前这一个会话,不影响该账号在其它设备上的登录:

UPDATE login_session
SET revoked_at = now(), revoke_reason = 'LOGOUT', updated_at = now()
WHERE token_id = :jti
  AND revoked_at IS NULL;

作废某账号的全部未失效会话

强制下线、修改密码、停用账号都走这条语句:

UPDATE login_session
SET revoked_at = now(), revoke_reason = :reason, updated_at = now()
WHERE account_type = :accountType
  AND account_id = :accountId
  AND revoked_at IS NULL;

登录设备列表

SELECT id, issued_at, last_active_at, client_ip, user_agent
FROM login_session
WHERE account_type = :accountType
  AND account_id = :accountId
  AND revoked_at IS NULL
  AND expires_at > now()
ORDER BY last_active_at DESC NULLS LAST;

定向踢掉某台设备

UPDATE login_session
SET revoked_at = now(), revoke_reason = 'ADMIN_REVOKE', updated_at = now()
WHERE id = :sessionId
  AND account_id = :accountId
  AND revoked_at IS NULL;

清理历史会话

DELETE FROM login_session
WHERE expires_at < now() - INTERVAL '7 days';

保留一段时间再删,是为了让审计和异常登录排查有据可查,具体保留多久取决于合规要求。


服务端实现

组件划分

  • JwtIssuer:签发与解析令牌
  • LoginSessionService:会话的登记、校验与作废
  • AuthInterceptor:每个请求的校验入口

配置

签名密钥通过环境变量注入,不写进代码仓库:

auth:
  jwt:
    signing-key: ${AUTH_JWT_KEY}   # Base64,32 字节
    ttl: 2h
@ConfigurationProperties(prefix = "auth.jwt")
public class JwtProperties {

    /** 签名密钥,Base64 编码,32 字节 */
    private String signingKey;

    /** 登录态有效期,默认 2 小时 */
    private Duration ttl = Duration.ofHours(2);

    // getters / setters 略
}

签发令牌

@Component
public class JwtIssuer {

    private final SecretKey signingKey;
    private final Duration ttl;

    public JwtIssuer(JwtProperties props) {
        this.signingKey = Keys.hmacShaKeyFor(
                Base64.getDecoder().decode(props.getSigningKey()));
        this.ttl = props.getTtl();
    }

    /** 签发令牌,返回令牌本体与登记会话所需的元信息 */
    public IssuedToken issue(String accountType, long accountId) {
        Instant issuedAt = Instant.now();
        Instant expiresAt = issuedAt.plus(ttl);
        String jti = UUID.randomUUID().toString().replace("-", "");

        String token = Jwts.builder()
                .id(jti)
                .subject(String.valueOf(accountId))
                .claim("typ", accountType)
                .issuedAt(Date.from(issuedAt))
                .expiration(Date.from(expiresAt))
                .signWith(signingKey, Jwts.SIG.HS256)
                .compact();

        return new IssuedToken(token, jti, issuedAt, expiresAt);
    }

    public Claims parse(String token) {
        return Jwts.parser()
                .verifyWith(signingKey)
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }
}
public record IssuedToken(String token, String jti, Instant issuedAt, Instant expiresAt) {
}

会话服务

@Service
public class LoginSessionService {

    private final LoginSessionMapper mapper;

    public LoginSessionService(LoginSessionMapper mapper) {
        this.mapper = mapper;
    }

    /** 登记会话:同一账号的旧会话在新会话落库之前被作废 */
    @Transactional
    public void register(String accountType, long accountId, IssuedToken issued,
                         String clientIp, String userAgent) {
        mapper.revokeAllActive(accountType, accountId, "KICKED_BY_NEW_LOGIN");
        mapper.insert(accountType, accountId, issued.jti(),
                issued.issuedAt(), issued.expiresAt(), clientIp, userAgent);
    }

    public boolean isActive(String jti) {
        return mapper.countActiveByTokenId(jti) > 0;
    }

    public void touch(String jti) {
        mapper.touchLastActive(jti);
    }

    public void logout(String jti) {
        mapper.revokeByTokenId(jti, "LOGOUT");
    }

    public void revokeAll(String accountType, long accountId, String reason) {
        mapper.revokeAllActive(accountType, accountId, reason);
    }
}

数据访问层

@Mapper
public interface LoginSessionMapper {

    void insert(@Param("accountType") String accountType,
                @Param("accountId") long accountId,
                @Param("jti") String jti,
                @Param("issuedAt") Instant issuedAt,
                @Param("expiresAt") Instant expiresAt,
                @Param("clientIp") String clientIp,
                @Param("userAgent") String userAgent);

    int countActiveByTokenId(@Param("jti") String jti);

    void touchLastActive(@Param("jti") String jti);

    int revokeByTokenId(@Param("jti") String jti, @Param("reason") String reason);

    int revokeAllActive(@Param("accountType") String accountType,
                        @Param("accountId") long accountId,
                        @Param("reason") String reason);
}
<insert id="insert">
    INSERT INTO login_session
        (account_type, account_id, token_id, issued_at, expires_at, client_ip, user_agent)
    VALUES
        (#{accountType}, #{accountId}, #{jti}, #{issuedAt}, #{expiresAt},
         #{clientIp}, #{userAgent})
</insert>

<select id="countActiveByTokenId" resultType="int">
    SELECT COUNT(1)
    FROM login_session
    WHERE token_id = #{jti}
      AND revoked_at IS NULL
      AND expires_at > now()
</select>

<update id="touchLastActive">
    UPDATE login_session
    SET last_active_at = now(), updated_at = now()
    WHERE token_id = #{jti}
</update>

<update id="revokeByTokenId">
    UPDATE login_session
    SET revoked_at = now(), revoke_reason = #{reason}, updated_at = now()
    WHERE token_id = #{jti}
      AND revoked_at IS NULL
</update>

<update id="revokeAllActive">
    UPDATE login_session
    SET revoked_at = now(), revoke_reason = #{reason}, updated_at = now()
    WHERE account_type = #{accountType}
      AND account_id = #{accountId}
      AND revoked_at IS NULL
</update>

请求校验拦截器

@Component
public class AuthInterceptor implements HandlerInterceptor {

    private final JwtIssuer jwtIssuer;
    private final LoginSessionService sessions;

    public AuthInterceptor(JwtIssuer jwtIssuer, LoginSessionService sessions) {
        this.jwtIssuer = jwtIssuer;
        this.sessions = sessions;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                             Object handler) throws IOException {
        String header = request.getHeader("Authorization");
        if (header == null || !header.startsWith("Bearer ")) {
            return reject(response, "未登录");
        }

        Claims claims;
        try {
            claims = jwtIssuer.parse(header.substring(7));
        } catch (JwtException | IllegalArgumentException e) {
            return reject(response, "登录态无效");
        }

        String jti = claims.getId();
        if (!sessions.isActive(jti)) {
            return reject(response, "登录已失效,请重新登录");
        }

        sessions.touch(jti);
        request.setAttribute("accountType", claims.get("typ", String.class));
        request.setAttribute("accountId", Long.valueOf(claims.getSubject()));
        request.setAttribute("jti", jti);
        return true;
    }

    private boolean reject(HttpServletResponse response, String message) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("{\"message\":\"" + message + "\"}");
        return false;
    }
}

拦截器里最关键的是这一句判断——验签通过只是第一步,必须再查一次账:

if (!sessions.isActive(jti)) {
    return reject(response, "登录已失效,请重新登录");
}

只验签不查账,等于整套撤销机制没有生效。

登录与登出

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AccountService accountService;
    private final JwtIssuer jwtIssuer;
    private final LoginSessionService sessions;

    public AuthController(AccountService accountService, JwtIssuer jwtIssuer,
                          LoginSessionService sessions) {
        this.accountService = accountService;
        this.jwtIssuer = jwtIssuer;
        this.sessions = sessions;
    }

    @PostMapping("/login")
    public LoginResponse login(@RequestBody LoginRequest req, HttpServletRequest http) {
        UserAccount account = accountService.verifyByPassword(req.username(), req.password());
        IssuedToken issued = jwtIssuer.issue("USER", account.getId());
        sessions.register("USER", account.getId(), issued,
                http.getRemoteAddr(), http.getHeader("User-Agent"));
        return new LoginResponse(issued.token(), issued.expiresAt());
    }

    @PostMapping("/logout")
    public void logout(@RequestAttribute("jti") String jti) {
        sessions.logout(jti);
    }
}

修改密码、停用账号这类操作,在业务处理完成后顺手作废该账号的全部会话:

accountService.changePassword(accountId, newPassword);
sessions.revokeAll("USER", accountId, "PASSWORD_CHANGED");

单账号单会话

"新登录顶掉旧登录"这条规则,实现上是在登记新会话之前,把该账号所有未失效会话先置为作废:

mapper.revokeAllActive(accountType, accountId, "KICKED_BY_NEW_LOGIN");
mapper.insert(...);

两句话必须落在同一个事务里。中途失败会出现"旧的已作废、新的没建成"的局面,用户直接被踢出且无法登录。

还有一个并发细节值得注意。两个设备同时发起登录时,两个事务都会执行"作废旧会话",而此时账号下可能没有任何未失效会话,UPDATE 影响行数为 0,也就不会产生行锁。随后两个事务各自插入一行,结果是账号下同时存在两个有效会话,"单会话"约束被悄悄绕过。

要在数据库层兜住这一点,可以在事务开始时对账号行加锁,把同一账号的登录串行化:

SELECT id FROM user_account WHERE id = :accountId FOR UPDATE;

这样两次登录必然一前一后执行,后一次会正确作废前一次。

如果希望约束更硬,还可以加一条部分唯一索引:

CREATE UNIQUE INDEX uk_login_session_active_account
    ON login_session (account_type, account_id)
    WHERE revoked_at IS NULL;

它保证一个账号在任意时刻最多只有一行未作废会话。代价是自然到期的会话如果没被及时置为失效,就会挡住新登录,因此需要配套的清理或惰性回填机制。用不用这条索引,取决于业务上更在意约束强度还是实现简单。


会话失效与清理

一条会话从产生到消失,会经历下面这几种状态迁移。

stateDiagram-v2
    [*] --> Issued: 登录成功
    Issued --> Active: 首次请求校验通过
    Active --> Active: 每次请求刷新 last_active_at
    Active --> Revoked: 登出 / 被顶下线 / 强制下线 / 改密码
    Issued --> Revoked: 同上
    Active --> Expired: 到达 expires_at
    Issued --> Expired: 到达 expires_at
    Revoked --> [*]
    Expired --> [*]

作废原因用 revoke_reason 记录,便于事后区分失效的性质:

取值 触发场景
LOGOUT 用户主动退出
KICKED_BY_NEW_LOGIN 同账号在新设备登录,旧会话被顶下线
ADMIN_REVOKE 管理员停用账号,或强制下线某台设备
PASSWORD_CHANGED 修改密码后作废其它设备上的登录
EXPIRED 由清理任务为自然到期的会话回填,可选

清理任务按 expires_at 删除历史行。已作废的行不必立刻删,保留一段时间可以支撑"最近登录记录""异常登录排查"这类需求。


校验开销与缓存取舍

每次请求多一次数据库查询,是这套方案唯一明显的成本。会话表按 token_id 建了唯一索引,查询是等值索引查找,单次开销很小,但对高并发接口来说仍然是笔账。

常见的优化是在应用侧加一层短 TTL 的本地缓存,例如 30 秒,把"该会话是否有效"的结果缓存起来。代价是作废会有最长一个 TTL 的延迟——用户点了退出,在缓存过期前旧令牌可能还能用几十秒。

如果业务对"立即失效"要求严格,比如安全事件处置、管理员强制下线,就不要缓存;或者在会话被作废时主动清掉对应的缓存键。这个取舍要在实现之前想清楚,因为它直接决定"踢下线"是秒级生效还是分钟级生效。

last_active_at 的刷新也不必每个请求都写库。它只用于展示最后活跃时间,按分钟级节流或异步批量更新即可,避免把一次高频写放大到每个请求上。


安全边界

能防住的

  • 账号被停用、密码被修改后,老令牌立即失效,不必等它自然到期
  • 用户主动退出后,令牌立刻不可用
  • 同账号多设备登录时,可以顶掉旧设备或定向踢掉某一台
  • 能列出当前登录设备与来源 IP,为异常登录排查提供依据
  • 会话表只存 jti 不存令牌本体,表泄露不等于令牌泄露

防不住的

  • 令牌在有效期内被窃取。只要会话还没被作废,持有令牌的人就能以原身份访问,服务端无法区分"本人"和"窃取者"。要缓解这一点,只能叠加设备指纹、二次校验或更短的有效期
  • 未启用 HTTPS 时的中间人截获。令牌是明文放在请求头里的,传输层不加密,前面所有设计都失去意义
  • 服务端时钟与客户端不一致带来的判定偏差。签名与过期时间统一使用 UTC 可以规避大部分问题

签名密钥的轮换也需要额外注意:更换 JWT 签名密钥会让所有存量令牌验签失败,等于一次性全量登出。如果需要平滑轮换,可以在令牌头部带上密钥标识 kid,服务端同时保留新旧两把密钥,验签时按 kid 选择。


与权限控制的分工

会话表回答的是"你是谁、这次登录现在还有效吗",不回答"你能做什么"。用户能不能删除数据、能看哪些范围的数据,属于角色与权限模型的职责,两者应当解耦。

这样拆分的好处是:登录态的失效逻辑(登出、踢下线、超时)可以独立演进,权限规则调整时不需要动会话表;反过来,会话表里也不应该出现任何与角色相关的字段。


小结

这套设计没有试图把 JWT 改造成"有状态",而是在它旁边加了一张很轻的账本。JWT 继续负责快速确认身份没有被篡改,会话表负责随时可以收回。两者用 jti 关联,校验时先验签、再查账,缺一不可。

真正需要在工程上守住的是三点:验签通过之后必须查账,只验签等于没做撤销;登记与作废要在同一个事务内完成,否则会出现状态不一致;以及想清楚缓存的取舍,它直接决定作废的生效速度。

分享此文

评论 (0)

还没有评论,来抢沙发。

发表评论