Strapi 认证体系深度解析:SessionManager、JWT 双令牌与会话轮换机制
【免费下载链接】strapi🚀 Strapi is the leading open-source headless CMS. It’s 100% JavaScript/TypeScript, fully customizable, and developer-first.项目地址: https://gitcode.com/GitHub_Trending/st/strapi
本文基于 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')返回绑定adminorigin 的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)主要实现文件:
- Core 服务:session-manager.ts
- 类型:types/src/modules/session-manager.ts
二、令牌生命周期与轮换机制(源码级解析)
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 定义了adminorigin,配置位于admin.auth.sessions.*。
端点清单
| 端点 | 说明 |
|---|---|
POST /admin/login | 登录,签发 refresh/session token(写入 httpOnly cookiestrapi_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 |
【免费下载链接】strapi🚀 Strapi is the leading open-source headless CMS. It’s 100% JavaScript/TypeScript, fully customizable, and developer-first.项目地址: https://gitcode.com/GitHub_Trending/st/strapi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考