Strapi 认证体系深度解析:SessionManager、JWT 双令牌与会话轮换机制
本文基于 Strapi 仓库的认证文档与核心源码,系统讲解 Strapi 中 Admin(后台)与 Content API(users-permissions 插件)两套认证体系的会话管理实现:从 SessionManager 的 origin 多租户模型、access token / refresh token 双令牌设计,到刷新令牌轮换(rotation)、空闲/绝对生命周期、设备维度吊销,以及密码变更时自动失效所有会话的安全策略。读完本文,你可以完整理解 Strapi 中一条 refresh token 从签发、轮换到吊销的完整生命周期,并能正确配置 admin.auth.sessions.* 与 plugin::users-permissions 相关的认证参数。
一、整体架构:两条认证链路共用一个 SessionManager
Strapi 仓库内的认证分为两个来源(origin):
- Admin(后台):管理员登录、注册、重置密码等,走
admin.auth.*配置; - Content API:经由 users-permissions 插件(下文简称 UP),面向内容 API 的终端用户认证,走
plugin::users-permissions.*配置。
两条链路在 v5 架构下共享同一套核心会话服务。Core 提供的 SessionManager 统一负责签发:
- 短时效 access token(JWT,客户端以
Authorization: Bearer <token>携带); - 长时效 refresh/session token(JWT,按来源(origin)以不同方式存储——Admin 端存 httpOnly cookie,Content API 端直接下发给客户端)。
其实现入口位于 session-manager.ts:createSessionManager 默认绑定数据库 provider 并写入隐藏内容类型 admin::session,返回一个"既可调用又可挂方法"的流式 API——strapi.sessionManager('admin') 返回绑定 admin origin 的 OriginSessionManager,同时暴露 generateSessionId、defineOrigin、hasOrigin 等全局方法。
// packages/core/core/src/services/session-manager.ts
const createSessionManager = ({ db }: { db: Database }) => {
const provider = createDatabaseProvider(db, 'admin::session');
const sessionManager = new SessionManager(provider);
// Add callable functionality
const fluentApi = (origin: string): OriginSessionManager => {
if (!origin || typeof origin !== 'string') {
throw new Error(
'SessionManager: Origin parameter is required and must be a non-empty string'
);
}
return new OriginSessionManager(sessionManager, origin);
};
// ... 挂载 defineOrigin / hasOrigin / generateSessionId
};
Origin 的注册时机
每个 origin 必须在 bootstrap 阶段完成配置注册。以 Admin 为例,admin/server/src/bootstrap.ts 中执行:
strapi.sessionManager.defineOrigin('admin', {
jwtSecret: strapi.config.get('admin.auth.secret'),
accessTokenLifespan: strapi.config.get('admin.auth.sessions.accessTokenLifespan', 30 * 60),
maxRefreshTokenLifespan: strapi.config.get(
'admin.auth.sessions.maxRefreshTokenLifespan',
legacyMaxRefreshFallback
),
idleRefreshTokenLifespan: strapi.config.get(
'admin.auth.sessions.idleRefreshTokenLifespan',
DEFAULT_IDLE_REFRESH_TOKEN_LIFESPAN
),
maxSessionLifespan: strapi.config.get(
'admin.auth.sessions.maxSessionLifespan',
legacyMaxSessionFallback
),
idleSessionLifespan: strapi.config.get(
'admin.auth.sessions.idleSessionLifespan',
DEFAULT_IDLE_SESSION_LIFESPAN
),
algorithm: options?.algorithm,
// Pass through all JWT options (includes privateKey, publicKey, and any other options)
jwtOptions: options,
});
UP 插件同理,在 users-permissions/server/src/bootstrap/index.js 中以 sessionManager.defineOrigin('users-permissions', {...}) 注册自己的 origin。若对未注册的 origin 发起操作,getConfigForOrigin 会直接抛出 SessionManager: Origin '<origin>' is not defined 错误(见 session-manager.ts),这保证了配置在启动期就暴露问题。
每个 origin 需要提供的配置项如下(bootstrap 时定义):
| 配置项 | 含义 |
|---|---|
jwtSecret |
对称算法(HS256/HS384/HS512)的签名密钥 |
accessTokenLifespan(秒) |
access token 有效期 |
maxRefreshTokenLifespan、idleRefreshTokenLifespan(秒) |
refresh 家族的绝对上限 / 空闲超时 |
maxSessionLifespan、idleSessionLifespan(秒) |
session 家族(rememberMe=false)的绝对上限 / 空闲超时 |
algorithm |
JWT 算法,默认 HS256(constants.ts 中的 DEFAULT_ALGORITHM) |
jwtOptions |
透传给 jsonwebtoken 的其他选项(issuer、audience、subject、privateKey 等) |
会话数据模型
各 origin 的会话记录统一落在隐藏内容类型 admin::session 中(底层表 strapi_sessions),核心字段包括 userId、sessionId、deviceId、origin、expiresAt、absoluteExpiresAt、status、type,另有一个自由格式的 metadata 字段供各 origin 存放自定义数据(如设备名、登录时间),SessionManager 只负责原样存取、不做解释(SessionData 定义)。
关键字段的语义:
type: 'refresh' | 'session'——refresh是长期令牌家族(rememberMe),session是绑定浏览器会话的短家族;status: 'active' | 'rotated' | 'revoked'——只有active状态的记录才有资格换取 access token(validateRefreshToken 校验);childId——轮换链的父子指针,父记录被标记rotated并指向子记录,这是实现"一次有效"轮换的关键结构;expiresAt是空闲到期时间,absoluteExpiresAt是整个令牌家族的绝对到期时间。
对外公开 API(按 origin)
OriginSessionManager 暴露的方法(类型定义见 types/src/modules/session-manager.ts):
generateRefreshToken(userId, deviceId?, { type?: 'refresh' | 'session' })
rotateRefreshToken(refreshToken)
generateAccessToken(refreshToken)
validateAccessToken(token)
validateRefreshToken(token)
invalidateRefreshToken(userId, deviceId?)
listSessions(userId)
revokeSessionById(userId, sessionId)
isSessionActive(sessionId)
主要实现文件:
二、令牌生命周期与轮换机制(源码级解析)
1. 签发:generateRefreshToken
generateRefreshToken 的流程是:
- 按 token 类型选择空闲生命周期与绝对生命周期(
refresh用idleRefreshTokenLifespan/maxRefreshTokenLifespan,session用idleSessionLifespan/maxSessionLifespan); - 在数据库创建根记录(
childId: null、status: 'active'),sessionId由 16 字节随机数的 hex 生成(generateSessionId); - 用记录的
createdAt计算iat/exp,以noTimestamp: true方式签名 JWT,payload 为{ userId, sessionId, type: 'refresh', iat, exp }; - 返回
{ token, sessionId, absoluteExpiresAt }。
值得注意的细节:对称算法下签名使用 config.jwtSecret;非对称算法(RS*/ES*/PS* 前缀)则要求 jwtOptions.privateKey(签名)或 jwtOptions.publicKey(验签)存在,否则直接抛错(getJwtKey)。另外,签名前会剔除 expiresIn/privateKey/publicKey 等与 payload、密钥选择冲突的选项,避免 jsonwebtoken 行为歧义。
2. 校验:validateRefreshToken 的四重防线
validateRefreshToken 依次检查:
- JWT 验签通过且
payload.type === 'refresh'(拒绝拿 access token 冒充 refresh token); - 数据库中存在对应
sessionId的记录; expiresAt(空闲到期)与absoluteExpiresAt(家族绝对到期)均未过;- 记录
status仍为active,且userId与 payload 一致。
validateAccessToken 则是纯签名校验(L399-L427),无数据库查询,因此适合放进每个请求的热路径。
3. 轮换:rotateRefreshToken 的"子令牌"模型
rotateRefreshToken 实现的是典型的 refresh token rotation,几个关键行为:
- 重放检测:若当前记录的父记录已经有
childId,说明旧 token 已被轮换过一次——此时不再创建新记录,而是重新返回同一个子 token(L611-L650)。这是为了避免客户端并发双发时误伤合法请求; - 空闲窗口:从当前 token 记录的
createdAt起计算,超过 idle 生命周期则返回idle_window_elapsed; - 家族窗口:
absoluteExpiresAt一旦过去则返回max_window_elapsed,即无论多活跃,令牌家族达到最大寿命后必须重新登录; - 正常路径下创建一个
active的子记录(继承deviceId、metadata与家族的absoluteExpiresAt),并把父记录更新为status: 'rotated'、childId: <新 sessionId>; - 每次轮换都会顺带触发惰性清理:
maybeCleanupExpired每 50 次调用执行一次deleteExpired(删除absoluteExpiresAt已过期的记录,L307-L314)。
generateAccessToken(refreshToken) 则先走 validateRefreshToken,通过后再签发一个以 accessTokenLifespan 为 expiresIn 的短令牌,payload 为 { userId, sessionId, type: 'access' }(L521-L562)。
4. 吊销:invalidateRefreshToken / revokeSessionById
invalidateRefreshToken(userId, deviceId?)直接deleteBy({ userId, origin, deviceId })——不传deviceId即吊销该用户在此 origin 下的全部会话,传了则只吊销该设备家族(L489-L491);revokeSessionById有归属校验:只有会话的userId和origin都与请求方匹配时才删除并返回true,防止跨用户越权吊销(L503-L519);listSessions仅返回status: 'active'的记录,按createdAt倒序,因此每个"活跃登录家族"只产生一条条目。
三、Admin 端认证:登录、Cookie 与会话管理端点
Admin 定义了 admin origin,配置位于 admin.auth.sessions.*。
端点清单
| 端点 | 说明 |
|---|---|
POST /admin/login |
登录,签发 refresh/session token(写入 httpOnly cookie strapi_admin_refresh),响应体 data.token 为短时效 access token |
POST /admin/register / POST /admin/register-admin |
注册/创建首个管理员,行为同上 |
POST /admin/reset-password |
重置密码:先吊销该用户全部既有会话,再签发新会话 |
POST /admin/access-token |
读取 refresh cookie,轮换后返回 { data: { token } }(新 access token) |
POST /admin/logout |
清除 cookie 并吊销 refresh token;body 可带 { deviceId } 只吊销单设备家族 |
GET /admin/users/me/sessions |
列出当前管理员的活跃会话(设备标签、登录时间、最近使用) |
DELETE /admin/users/me/sessions/:sessionId |
吊销当前用户拥有的单个会话 |
DELETE /admin/users/me/sessions |
吊销全部会话;query ?keepCurrent=true 保留当前请求所依赖的会话("退出其他设备") |
登录/注册时的可选请求字段:
deviceId(UUID):提供后启用设备维度的吊销能力;rememberMe(boolean):为true时使用长时效 refresh 家族并写入持久化 cookie;否则使用 session 家族(session cookie)。
从 authentication.ts 控制器 可以看到各端点统一通过 buildCookieOptionsWithExpiry 构造 cookie 选项后写入 REFRESH_COOKIE_NAME(即 strapi_admin_refresh);/admin/access-token 分支会调用 rotateRefreshToken 并用返回的新 token 覆写 cookie(L300-L332),而 logout 无论 token 是否有效都会先清空 cookie(L344-L345)。
配置项
| 配置键 | 默认值 | 说明 |
|---|---|---|
admin.auth.secret |
应用 secret | 对称算法(HS256/HS384/HS512)的 JWT 密钥 |
admin.auth.sessions.options.algorithm |
HS256 |
JWT 算法 |
admin.auth.sessions.options.privateKey |
— | 非对称算法(RS256/RS512/ES256 等)私钥 |
admin.auth.sessions.options.publicKey |
— | 非对称算法公钥 |
admin.auth.sessions.options.* |
— | 其余 JWT 选项透传(issuer、audience、subject 等) |
admin.auth.sessions.accessTokenLifespan |
1800 秒 | 源码中回退值即 30 * 60(见 bootstrap.ts) |
admin.auth.sessions.maxRefreshTokenLifespan |
30 天 | refresh 家族绝对上限 |
admin.auth.sessions.idleRefreshTokenLifespan |
14 天 | refresh 家族空闲超时 |
admin.auth.sessions.maxSessionLifespan |
1 天 | session 家族绝对上限 |
admin.auth.sessions.idleSessionLifespan |
2 小时 | session 家族空闲超时 |
已废弃(Deprecated)
admin.auth.options.*:请改用admin.auth.sessions.options.*。bootstrap 中保留了兼容逻辑:当用户仍配置了旧的admin.auth.options.expiresIn且未设置新的maxRefreshTokenLifespan/maxSessionLifespan时,会打印警告提示该配置将在 Strapi 6 中移除(bootstrap.ts),旧值会被折算后作为回退默认值。- Cookie 相关选项:
admin.auth.cookie.name(默认jwtToken)——session 登录(rememberMe=false)时非 httpOnly access-token cookie 的名称,也用于 EE SSO 交接。若同一父域名下还有其他应用也写jwtTokencookie,应改名避免冲突。修改后需要重新构建 admin(该值在构建期内联进 admin bundle)。admin.auth.cookie.domain(或admin.auth.domain)——作用于strapi_admin_refresh与非 httpOnly access-token cookie。未设置时默认 host-only cookie。设置父域名可让子域间共享 admin 会话;不设置则各主机隔离。修改后需要重新构建 admin。admin.auth.cookie.path(默认/admin)——同上两个 cookie 的路径,同父域名下托管多个 Strapi 实例时应按实例区分。修改后需要重新构建 admin。admin.auth.cookie.sameSite(默认lax)——作用于strapi_admin_refresh。
Admin 端关键文件:
- Bootstrap/配置:bootstrap.ts
- 路由:routes/authentication.ts、routes/users.ts
- 控制器:controllers/authentication.ts、controllers/authenticated-session.ts
- Bearer access token 校验策略:strategies/admin.ts
四、Content API 认证(users-permissions 插件)
UP 插件通过 plugin::users-permissions.jwtManagement 提供两种模式,默认值为 legacy-support(config.js):
| 模式 | 行为 |
|---|---|
legacy-support(默认) |
签发由 plugin::users-permissions.jwt 配置决定的长时效 jwt,无 refresh/rotation 机制 |
refresh |
接入 SessionManager:签发短时效 access token(jwt)+ 独立的 refreshToken,支持轮换与会话管理 |
auth.js 控制器 中每个关键动作(login、register、change-password、reset-password 等)都会先读取 jwtManagement 再分叉处理,jwt.js 服务 则根据模式决定生成一次性 JWT 还是双令牌。
当 jwtManagement 为 refresh 时:
- 登录/注册/Provider 回调的响应体包含
{ jwt, refreshToken }; - 新增端点:
POST /api/auth/refresh——body 为{ refreshToken },返回{ jwt }并完成 refresh token 轮换;POST /api/auth/logout——默认只吊销当前请求所依赖的会话;可选 body:{ scope: 'all' }——吊销该用户的全部会话(会话管理引入前的旧默认行为);{ deviceId }——吊销指定设备家族的全部会话;
GET /api/auth/sessions——列出当前认证用户的活跃会话;DELETE /api/auth/sessions/:sessionId——吊销认证用户拥有的单个会话。
配置键:
// config/plugins.js 示例
plugin::users-permissions: {
jwtManagement: 'refresh', // 'legacy-support' | 'refresh'
sessions: {
accessTokenLifespan: 1800, // 秒
maxRefreshTokenLifespan: 30 * 24 * 3600,
idleRefreshTokenLifespan: 14 * 24 * 3600,
maxSessionLifespan: 24 * 3600,
idleSessionLifespan: 2 * 3600,
},
}
UP 端关键文件:
- 插件 bootstrap/配置:bootstrap/index.js、config.js
- 控制器:controllers/auth.js
- 路由:routes/content-api/auth.js
- JWT 服务:services/jwt.js
相关测试可参考 auth-sessions.test.js 与 jwt.test.js,其中验证了两种模式下的令牌下发、refresh 端点与 404 边界(非 refresh 模式访问 sessions 端点返回 not found,见 validation auth 测试)。
五、凭据变更时的会话自动吊销
出于安全考虑,修改/重置密码会自动吊销该用户所有设备上的活跃 refresh/session token:
- Admin:通过
PUT /admin/users/me(携带currentPassword与password)修改密码时,吊销该管理员的全部会话(包括当前会话),用户必须重新认证; - Admin:通过
POST /admin/reset-password重置密码时,在签发新会话之前先吊销全部既有会话; - Content API(refresh 模式):通过
POST /api/auth/change-password或POST /api/auth/reset-password变更/重置密码时,吊销该用户的全部 users-permissions 会话,并为当前请求签发新的 refresh token。
该行为的底层就是 invalidateRefreshToken(userId) 不带 deviceId 时执行的全量 deleteBy(session-manager.ts)。其安全意义在于:即使 refresh token 已被窃取,只要用户完成一次密码变更,攻击者手中的旧令牌立即失效,可显著缓解持久化会话劫持(persistent session hijacking)攻击。
六、实践要点与注意事项
- access token 一律通过
Authorization: Bearer <token>头传递,不落在 cookie 或 localStorage(session 登录场景的非 httpOnly access-token cookie 是兼容/SSO 用途的例外,见上文废弃章节); - Admin 的 refresh token 存放在 httpOnly cookie
strapi_admin_refresh中,JavaScript 不可读取,天然规避 XSS 窃取 refresh token; - 设备绑定会话支持按
deviceId精确登出单个设备家族,deviceId在轮换时会从父记录继承到子记录,保证整个家族始终归属同一设备; - 会话列表接口返回的
lastActiveAt表示该会话最近一次 refresh token 轮换的时间戳,而不是逐请求的活动跟踪——未轮换的活跃会话不会刷新该值。
七、延伸阅读:关键源码索引
| 模块 | 路径 |
|---|---|
| 核心 SessionManager 实现 | packages/core/core/src/services/session-manager.ts |
| origin 服务类型定义 | packages/core/types/src/modules/session-manager.ts |
| Admin bootstrap 与 origin 注册 | packages/core/admin/server/src/bootstrap.ts |
| Admin 认证控制器 | packages/core/admin/server/src/controllers/authentication.ts |
| Admin Bearer 校验策略 | packages/core/admin/server/src/strategies/admin.ts |
| UP 插件配置默认值 | packages/plugins/users-permissions/server/src/config.js |
| UP 认证控制器 | packages/plugins/users-permissions/server/src/controllers/auth.js |
| 认证机制文档原文 | docs/docs/docs/01-core/authentication/00-sessions-and-jwt.md |
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00