1. 退出登录

我们可以先把这件事想得朴素一点。

很多人刚开始写退出登录,第一反应都差不多:把本地 token 删掉,跳回登录页,这件事也就结束了。

放在前端里,这样想很自然。可一旦站到服务端这一边,事情就没有这么轻了。

因为服务端真正关心的,从来不是浏览器里那串字符串删没删,而是这次登录建立起来的那条会话状态,到底有没有真的失效。

所以退出登录这件事,最后落到服务端时,核心动作不会是单纯删 token,而是撤销 session。只要 session 被撤销了,它下面那条 refresh token 链也就跟着一起失效了。

这也是这段 admin logout service 的意思。它很短,但要做的事情很集中:先验 refresh token,再找到它属于哪条 session,最后把整条 session 一次性撤销。

2. 完整代码

admin-logout.ts
01
import type { AdminLogoutRequest } from '@repo/contracts'
02
import type { Context } from 'hono'
03
import type { ApiBindings } from '@/bindings'
04
import { getDb } from '@/db/client'
05
import { getApiEnv } from '@/env'
06
import { refreshTokenInvalidError } from '@/auth/errors'
07
import { verifyRefreshToken } from '@/auth/jwt'
08
import { findRefreshTokenForSession, revokeSession } from '@/auth/repository'
09
import { hashTokenJti } from '@/auth/token-hash'
10
11
export async function handleAdminLogout(params: {
12
c: Context<{ Bindings: ApiBindings }>
13
payload: AdminLogoutRequest
14
}) {
15
const { c, payload } = params
16
const db = getDb(c.env.DB)
17
const env = getApiEnv(c.env)
18
19
let claims
20
21
try {
22
claims = await verifyRefreshToken({
23
token: payload.refreshToken,
24
secret: env.JWT_REFRESH_SECRET,
25
})
26
} catch {
27
throw refreshTokenInvalidError()
28
}
29
30
const currentToken = await findRefreshTokenForSession({
31
db: db,
32
jtiHash: await hashTokenJti(claims.jti),
33
sessionId: claims.sid,
34
})
35
36
if (!currentToken) {
37
throw refreshTokenInvalidError()
38
}
39
40
await revokeSession({
41
db: db,
42
sessionId: currentToken.sessionId,
43
revokedAtMs: Date.now(),
44
reason: 'logout',
45
})
46
47
return {
48
success: true as const,
49
}
50
}

3. 这段代码用到了什么

这段代码不长,所以 import 区已经把结构说得很清楚了。

getDbgetApiEnv 负责把数据库连接和环境配置准备出来。

verifyRefreshToken 来自 JWT 模块,负责先把前端带来的 refresh token 验一遍。

findRefreshTokenForSessionrevokeSession 来自 repository,说明这段 service 自己不直接写 SQL,而是把数据库读写交给 repository。

hashTokenJti 用来把 token 里的 jti 变成数据库里要查的 hash 值。

refreshTokenInvalidError 则把失败情况统一收成一类错误。

所以这段 service 的位置也就很清楚了。它不做底层细节,而是把几层能力顺着退出登录这条逻辑串起来。

4. 先把上下文准备好

函数开头还是熟悉的写法:

admin-logout.ts
1
const { c, payload } = params
2
const db = getDb(c.env.DB)
3
const env = getApiEnv(c.env)

先把参数拆开,再把后面会用到的两个东西准备好。

payload 里装的是前端传上来的 refresh token。

db 是后面 repository 查询和更新要用的数据库客户端。

env 则把 JWT refresh secret 拿出来,后面验 token 时会直接用到。

到这里为止,service 还没有真正进入退出逻辑,它只是先把这条逻辑要用的上下文摆好。

5. 先验 refresh token

真正的动作是从这里开始的:

admin-logout.ts
01
let claims
02
03
try {
04
claims = await verifyRefreshToken({
05
token: payload.refreshToken,
06
secret: env.JWT_REFRESH_SECRET,
07
})
08
} catch {
09
throw refreshTokenInvalidError()
10
}

这一段和 refresh token 刷新那条线有些相似。

前端把 refresh token 带上来之后,服务端不会直接拿它去查数据库,而是先从 JWT 这一层把它验一遍。

这一步过了,后面才能继续往下走。

这一步没过,就直接统一收口成:

admin-logout.ts
1
throw refreshTokenInvalidError()

这里的意思其实很直白。退出登录虽然动作短,但也不能拿一张格式不对、签名不对,或者根本不属于当前系统的 refresh token,继续往下做 session 撤销。

所以第一步先验 token,本身就很合理。

6. 再找到它属于哪条 session

JWT 验过去之后,service 手里拿到的是 claims。这时候它知道的还只是 token 自己带出来的内容,比如 sidjti

下一步要做的,就是把这张 token 在数据库里的记录找出来:

admin-logout.ts
1
const currentToken = await findRefreshTokenForSession({
2
db: db,
3
jtiHash: await hashTokenJti(claims.jti),
4
sessionId: claims.sid,
5
})

这里有两个地方值得注意。

第一个地方是 claims.jti 没有直接拿去查库,而是先走了一次:

admin-logout.ts
1
await hashTokenJti(claims.jti)

也就是说,数据库里真正存的不是原始 jti,而是它的 hash。

第二个地方是查询条件不只看 jtiHash,还把 sessionId 一起带上了。

这说明服务端不是只想确认这张 token 存不存在,还想确认它到底是不是挂在这条 session 下面

7. 找不到就停下

查完之后,立刻有一个判断:

admin-logout.ts
1
if (!currentToken) {
2
throw refreshTokenInvalidError()
3
}

这一步也很自然。

JWT 这一层验过了,只能说明这张 token 从格式和签名上看是成立的。可数据库里如果根本找不到这张 token 的记录,那服务端依旧不会继续往下做退出。

所以这里又把它收口回 refreshTokenInvalidError()

这段代码没有把“JWT 验不过”和“数据库里找不到记录”拆成两种对外错误,而是统一当成 refresh token 无效来处理。这样边界会更干净一些。

8. 真正的退出动作

前面那些准备、验 token、查数据库,都是在为最后这个动作铺路:

admin-logout.ts
1
await revokeSession({
2
db: db,
3
sessionId: currentToken.sessionId,
4
revokedAtMs: Date.now(),
5
reason: 'logout',
6
})

这里就是整段 logout service 真正的核心。

退出登录时,系统没有去做什么“把这张 JWT 字符串标记删除”的动作,而是直接去撤销这条 session。

这件事很关键。

因为 session 一旦被撤销,它下面那条 refresh token 链也会跟着一起收掉。这样才是完整的退出登录。

如果只删前端本地 token,而不撤销服务端 session,那这次登录在服务端看来其实还活着,这就不算真正退出。

这里还顺手写了一个撤销原因:

admin-logout.ts
1
reason: 'logout'

这也很实用。以后回头看 session 为什么被撤销,就能直接知道这是用户主动退出,不是 replay,也不是别的安全原因。

9. 返回值

撤销动作做完之后,这段 service 的返回值非常短:

admin-logout.ts
1
return {
2
success: true as const,
3
}

这也很符合 logout 这件事本身。

登录和 refresh 都要带回很多信息,因为它们要给前端新的 token、新的 session、新的过期时间。

退出登录不用。它只需要告诉调用方,这次退出动作已经成功完成了。

所以这里没有再返回 token,没有再返回 session,只回了一个很轻的成功标记。

10. 它为什么会这么短

这段 service 看起来短,不是因为它做的事轻,而是因为前面的分层已经铺好了。

JWT 验证被放进了 verifyRefreshToken(...)

数据库查询和 session 撤销被放进了 repository。

错误对象也已经提前收口好了。

所以到了 logout service 这里,剩下的工作就很纯粹:先验 refresh token,再找到它归属的 session,最后撤销这条 session。

逻辑本身没有绕来绕去,所以文件自然就短。

11. 总结

如果把这段 handleAdminLogout 压成一句话,它做的就是:前端带着 refresh token 过来,服务端先确认这张 token 是真的、数据库里也能找到它,然后不去纠结删哪一个字符串,而是直接把它所属的整条 session 撤销掉,最后回一个成功结果。

所以这篇真正值得记住的点也就一个:退出登录在服务端的核心动作,不是删 token,而是撤销 session。