一句话导读: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)
还没有评论,来抢沙发。