首页
/ Strapi 认证体系深度解析:SessionManager、JWT 双令牌与会话轮换机制

Strapi 认证体系深度解析:SessionManager、JWT 双令牌与会话轮换机制

2026-09-05 22:07:58作者:卓艾滢Kingsley

本文基于 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.tscreateSessionManager 默认绑定数据库 provider 并写入隐藏内容类型 admin::session,返回一个"既可调用又可挂方法"的流式 API——strapi.sessionManager('admin') 返回绑定 admin origin 的 OriginSessionManager,同时暴露 generateSessionIddefineOriginhasOrigin 等全局方法。

// 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 有效期
maxRefreshTokenLifespanidleRefreshTokenLifespan(秒) refresh 家族的绝对上限 / 空闲超时
maxSessionLifespanidleSessionLifespan(秒) session 家族(rememberMe=false)的绝对上限 / 空闲超时
algorithm JWT 算法,默认 HS256constants.ts 中的 DEFAULT_ALGORITHM
jwtOptions 透传给 jsonwebtoken 的其他选项(issuer、audience、subject、privateKey 等)

会话数据模型

各 origin 的会话记录统一落在隐藏内容类型 admin::session 中(底层表 strapi_sessions),核心字段包括 userIdsessionIddeviceIdoriginexpiresAtabsoluteExpiresAtstatustype,另有一个自由格式的 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 的流程是:

  1. 按 token 类型选择空闲生命周期与绝对生命周期(refreshidleRefreshTokenLifespan/maxRefreshTokenLifespansessionidleSessionLifespan/maxSessionLifespan);
  2. 在数据库创建根记录childId: nullstatus: 'active'),sessionId 由 16 字节随机数的 hex 生成(generateSessionId);
  3. 用记录的 createdAt 计算 iat/exp,以 noTimestamp: true 方式签名 JWT,payload 为 { userId, sessionId, type: 'refresh', iat, exp }
  4. 返回 { token, sessionId, absoluteExpiresAt }

值得注意的细节:对称算法下签名使用 config.jwtSecret;非对称算法(RS*/ES*/PS* 前缀)则要求 jwtOptions.privateKey(签名)或 jwtOptions.publicKey(验签)存在,否则直接抛错(getJwtKey)。另外,签名前会剔除 expiresIn/privateKey/publicKey 等与 payload、密钥选择冲突的选项,避免 jsonwebtoken 行为歧义。

2. 校验:validateRefreshToken 的四重防线

validateRefreshToken 依次检查:

  1. JWT 验签通过且 payload.type === 'refresh'(拒绝拿 access token 冒充 refresh token);
  2. 数据库中存在对应 sessionId 的记录;
  3. expiresAt(空闲到期)与 absoluteExpiresAt(家族绝对到期)均未过;
  4. 记录 status 仍为 active,且 userId 与 payload 一致。

validateAccessToken 则是纯签名校验(L399-L427),无数据库查询,因此适合放进每个请求的热路径。

3. 轮换:rotateRefreshToken 的"子令牌"模型

rotateRefreshToken 实现的是典型的 refresh token rotation,几个关键行为:

  • 重放检测:若当前记录的父记录已经有 childId,说明旧 token 已被轮换过一次——此时不再创建新记录,而是重新返回同一个子 tokenL611-L650)。这是为了避免客户端并发双发时误伤合法请求;
  • 空闲窗口:从当前 token 记录的 createdAt 起计算,超过 idle 生命周期则返回 idle_window_elapsed
  • 家族窗口absoluteExpiresAt 一旦过去则返回 max_window_elapsed,即无论多活跃,令牌家族达到最大寿命后必须重新登录;
  • 正常路径下创建一个 active 的子记录(继承 deviceIdmetadata 与家族的 absoluteExpiresAt),并把父记录更新为 status: 'rotated'childId: <新 sessionId>
  • 每次轮换都会顺带触发惰性清理:maybeCleanupExpired 每 50 次调用执行一次 deleteExpired(删除 absoluteExpiresAt 已过期的记录,L307-L314)。

generateAccessToken(refreshToken) 则先走 validateRefreshToken,通过后再签发一个以 accessTokenLifespanexpiresIn 的短令牌,payload 为 { userId, sessionId, type: 'access' }L521-L562)。

4. 吊销:invalidateRefreshToken / revokeSessionById

  • invalidateRefreshToken(userId, deviceId?) 直接 deleteBy({ userId, origin, deviceId })——不传 deviceId 即吊销该用户在此 origin 下的全部会话,传了则只吊销该设备家族(L489-L491);
  • revokeSessionById 有归属校验:只有会话的 userIdorigin 都与请求方匹配时才删除并返回 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 交接。若同一父域名下还有其他应用也写 jwtToken cookie,应改名避免冲突。修改后需要重新构建 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 端关键文件:

四、Content API 认证(users-permissions 插件)

UP 插件通过 plugin::users-permissions.jwtManagement 提供两种模式,默认值为 legacy-supportconfig.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 还是双令牌。

jwtManagementrefresh 时:

  • 登录/注册/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 端关键文件:

相关测试可参考 auth-sessions.test.jsjwt.test.js,其中验证了两种模式下的令牌下发、refresh 端点与 404 边界(非 refresh 模式访问 sessions 端点返回 not found,见 validation auth 测试)。

五、凭据变更时的会话自动吊销

出于安全考虑,修改/重置密码会自动吊销该用户所有设备上的活跃 refresh/session token

  • Admin:通过 PUT /admin/users/me(携带 currentPasswordpassword)修改密码时,吊销该管理员的全部会话(包括当前会话),用户必须重新认证;
  • Admin:通过 POST /admin/reset-password 重置密码时,在签发新会话之前先吊销全部既有会话;
  • Content API(refresh 模式):通过 POST /api/auth/change-passwordPOST /api/auth/reset-password 变更/重置密码时,吊销该用户的全部 users-permissions 会话,并为当前请求签发新的 refresh token。

该行为的底层就是 invalidateRefreshToken(userId) 不带 deviceId 时执行的全量 deleteBysession-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
登录后查看全文
热门项目推荐
相关项目推荐