UNPKG

strapi-security-suite

Version:

All-in-one authentication and session security plugin for Strapi v5

1,096 lines (1,095 loc) 35.4 kB
import jwt from "jsonwebtoken"; import { randomUUID } from "node:crypto"; const PLUGIN_ID = "strapi-security-suite"; const CONTENT_TYPES = { SECURITY_SETTINGS: `plugin::${PLUGIN_ID}.security-settings`, ADMIN_SESSION: `plugin::${PLUGIN_ID}.admin-session`, LOGIN_LOCK: `plugin::${PLUGIN_ID}.login-lock`, WATCHER_LEASE: `plugin::${PLUGIN_ID}.watcher-lease` }; const SERVICES = { AUTO_LOGOUT_CHECKER: "autoLogoutChecker", STATE: "state" }; const CTX_ADMIN_USER = Symbol.for("security-suite:adminUser"); const WATCHER_LEASE_NAME = "autologout-watcher"; const CHECK_INTERVAL = 5e3; const DEFAULT_AUTOLOGOUT_TIME = 30; const MS_PER_MINUTE = 6e4; const MS_PER_SECOND = 1e3; const ACTIVITY_FLUSH_INTERVAL_MS = 30 * MS_PER_SECOND; const LOGIN_LOCK_TTL_MS = 10 * MS_PER_SECOND; const WATCHER_LEASE_TTL_MS = 15 * MS_PER_SECOND; const LOGIN_PATH = "/admin/login"; const LOGOUT_PATH = "/admin/logout"; const ACCESS_TOKEN_PATH = "/access-token"; const CONTENT_PATH = "/content"; const HTTP_STATUS = { NO_CONTENT: 204, BAD_REQUEST: 400, FORBIDDEN: 403, CONFLICT: 409 }; const COOKIES = { SESSION: "koa.sess", SESSION_SIG: "koa.sess.sig", /** Strapi v5 admin refresh-token cookie (managed by session manager). */ REFRESH_TOKEN: "strapi_admin_refresh", /** JWT access-token cookie set by Strapi EE SSO authentication flow. */ JWT_TOKEN: "jwtToken" }; const HEADERS = { /** Header that signals the frontend to force-reload (session revoked). */ ADMIN_TOKEN_SIGNAL: "app.admin.tk", /** Required so the browser exposes custom headers in fetch responses. */ EXPOSE_HEADERS: "Access-Control-Expose-Headers" }; const ERROR_MESSAGES = { SETTINGS_NOT_FOUND: "Security settings not found.", INSUFFICIENT_PERMISSIONS: "Insufficient permissions.", NOT_AUTHENTICATED: "User is not authenticated.", UNKNOWN_ERROR: "An unexpected error occurred.", MULTIPLE_SESSIONS: "Multiple sessions are not allowed. You are already logged in elsewhere.", TOKEN_REVOKED: "Forbidden. Your token has been revoked.", PERMISSION_CHECK_FAILED: "Failed to verify permissions.", INVALID_SETTINGS: "Invalid settings payload." }; const PERMISSIONS = { VIEW_CONFIGS: `plugin::${PLUGIN_ID}.view-configs`, MANAGE_CONFIGS: `plugin::${PLUGIN_ID}.manage-configs` }; const DEFAULT_SETTINGS = { autoLogoutTime: 30, multipleSessionsControl: true, passwordExpiryDays: 30, nonReusablePassword: true, enablePasswordManagement: true }; const VALID_SETTINGS_KEYS = new Set(Object.keys(DEFAULT_SETTINGS)); class PluginError extends Error { /** * @param {string} message - Internal message (for logs) * @param {string} sanitizedMessage - Safe message for the client * @param {number} [statusCode=400] - HTTP status code */ constructor(message, sanitizedMessage, statusCode = HTTP_STATUS.BAD_REQUEST) { super(message); this.name = "PluginError"; this.sanitizedMessage = sanitizedMessage; this.statusCode = statusCode; } } class ValidationError extends PluginError { /** * @param {string} message - Internal message * @param {string} [sanitizedMessage='Validation failed.'] - Client-safe message */ constructor(message, sanitizedMessage = "Validation failed.") { super(message, sanitizedMessage, HTTP_STATUS.BAD_REQUEST); this.name = "ValidationError"; } } function clearSessionCookies(ctx) { if (ctx.session !== void 0) { ctx.session = null; } const expireOpts = { expires: /* @__PURE__ */ new Date(0), path: "/", httpOnly: true }; ctx.cookies.set(COOKIES.SESSION, "", expireOpts); ctx.cookies.set(COOKIES.SESSION_SIG, "", expireOpts); ctx.cookies.set(COOKIES.REFRESH_TOKEN, "", { expires: /* @__PURE__ */ new Date(0), path: "/admin", httpOnly: true }); const configuredSecure = strapi.config.get("admin.auth.cookie.secure"); const isProduction = process.env.NODE_ENV === "production"; const jwtClearOpts = { expires: /* @__PURE__ */ new Date(0), httpOnly: false, secure: typeof configuredSecure === "boolean" ? configuredSecure : isProduction, domain: strapi.config.get("admin.auth.domain"), overwrite: true }; ctx.cookies.set(COOKIES.JWT_TOKEN, "", jwtClearOpts); } async function trackActivity(ctx, next) { const adminUser = ctx.state[CTX_ADMIN_USER]; const state2 = strapi.plugin(PLUGIN_ID).service(SERVICES.STATE); if (adminUser?.sessionId && await state2.isRevoked(adminUser.sessionId)) { clearSessionCookies(ctx); ctx.status = HTTP_STATUS.FORBIDDEN; ctx.body = { error: { status: HTTP_STATUS.FORBIDDEN, title: "Forbidden", message: ERROR_MESSAGES.TOKEN_REVOKED } }; return; } if (ctx.path.includes(LOGOUT_PATH)) { clearSessionCookies(ctx); return await next(); } if (adminUser?.sessionId && adminUser?.id && adminUser?.email) { await state2.touch({ sessionId: adminUser.sessionId, userId: adminUser.id, email: adminUser.email }); strapi.log.debug(`[${PLUGIN_ID}] Activity touched: ${adminUser.id}:${adminUser.email}`); } await next(); } async function cleanupLoginState(ctx) { const email = ctx.request.body?.email; if (!email) return; const state2 = strapi.plugin(PLUGIN_ID).service(SERVICES.STATE); await state2.releaseLoginLock({ email }); } async function preventMultipleSessions(ctx, next) { const isLoginPost = ctx.path === LOGIN_PATH && ctx.method === "POST"; if (!isLoginPost) { return await next(); } if (ctx.state[CTX_ADMIN_USER]) { strapi.log.debug( `[${PLUGIN_ID}] Skipping session lock. ${JSON.stringify(ctx.state[CTX_ADMIN_USER])}` ); return await next(); } const { email } = ctx.request.body ?? {}; if (!email) { strapi.log.warn(`[${PLUGIN_ID}] Email missing in login request. Skipping session lock.`); return await next(); } const state2 = strapi.plugin(PLUGIN_ID).service(SERVICES.STATE); let lockAcquired = false; try { const settings = await strapi.documents(CONTENT_TYPES.SECURITY_SETTINGS).findFirst({}); if (!settings?.multipleSessionsControl) return await next(); const idleThresholdMs = (settings?.autoLogoutTime ?? DEFAULT_AUTOLOGOUT_TIME) * MS_PER_MINUTE; const hasActive = await state2.hasActiveSession({ email, idleThresholdMs }); if (hasActive) { strapi.log.warn(`[${PLUGIN_ID}] Login blocked for ${email}: already logged in.`); ctx.status = HTTP_STATUS.CONFLICT; ctx.body = { error: { status: HTTP_STATUS.CONFLICT, message: ERROR_MESSAGES.MULTIPLE_SESSIONS } }; return; } lockAcquired = await state2.acquireLoginLock({ email }); if (!lockAcquired) { strapi.log.warn(`[${PLUGIN_ID}] Login blocked for ${email}: another login is in flight.`); ctx.status = HTTP_STATUS.CONFLICT; ctx.body = { error: { status: HTTP_STATUS.CONFLICT, message: ERROR_MESSAGES.MULTIPLE_SESSIONS } }; return; } } catch (err) { strapi.log.error(`[${PLUGIN_ID}] Error in preventMultipleSessions:`, err); } try { await next(); } finally { if (lockAcquired) { await cleanupLoginState(ctx); } } } async function rejectRevokedTokens(ctx, next) { const adminUser = ctx.state[CTX_ADMIN_USER]; if (!adminUser?.sessionId) return await next(); const state2 = strapi.plugin(PLUGIN_ID).service(SERVICES.STATE); try { const revoked = await state2.isRevoked(adminUser.sessionId); if (!revoked) return await next(); ctx.set(HEADERS.ADMIN_TOKEN_SIGNAL, adminUser.email || ""); ctx.set(HEADERS.EXPOSE_HEADERS, HEADERS.ADMIN_TOKEN_SIGNAL); clearSessionCookies(ctx); try { if (strapi.sessionManager && adminUser.id) { await strapi.sessionManager("admin").invalidateRefreshToken(String(adminUser.id)); } } catch (err) { strapi.log.error( `[${PLUGIN_ID}] Failed to invalidate DB session for ${adminUser.email}:`, err ); } strapi.log.info( `[${PLUGIN_ID}] Session revoked: ${adminUser.email} (session ${adminUser.sessionId})` ); } catch (err) { strapi.log.error(`[${PLUGIN_ID}] Error in rejectRevokedTokens middleware:`, err); } await next(); } async function interceptRenewToken(ctx, next) { if (ctx.path.includes(LOGOUT_PATH)) { const adminUser = ctx.state[CTX_ADMIN_USER]; strapi.log.debug(`[${PLUGIN_ID}] Logout captured: ${JSON.stringify(adminUser)}`); if (adminUser?.sessionId) { const state2 = strapi.plugin(PLUGIN_ID).service(SERVICES.STATE); await state2.revokeSession({ sessionId: adminUser.sessionId }); } clearSessionCookies(ctx); await next(); return; } if (ctx.path.includes(ACCESS_TOKEN_PATH) || ctx.path.includes(CONTENT_PATH)) { const adminUser = ctx.state[CTX_ADMIN_USER]; if (adminUser?.email) { strapi.log.debug(`[${PLUGIN_ID}] Token renewal intercepted for ${adminUser.email}`); } } await next(); } async function seedUserInfos(ctx, next) { const authHeader = ctx.get("authorization"); if (!authHeader || authHeader.includes("null")) { return await next(); } try { const token = authHeader.split("Bearer ")[1]; if (!token) return await next(); const decodedToken = jwt.decode(token); const adminId = decodedToken?.userId; const sessionId = decodedToken?.sessionId; if (!adminId || ctx.state[CTX_ADMIN_USER]?.id) { return await next(); } const adminUser = await strapi.db.query("admin::user").findOne({ where: { id: adminId }, populate: ["roles"] }); if (!adminUser) { strapi.log.debug(`[${PLUGIN_ID}] No admin user found with ID ${adminId}`); return await next(); } ctx.state[CTX_ADMIN_USER] = { id: adminUser.id, sessionId, email: adminUser.email, firstname: adminUser.firstname, lastname: adminUser.lastname, roles: adminUser.roles }; strapi.log.debug(`[${PLUGIN_ID}] Admin identified: ${adminUser.email} (session ${sessionId})`); return await next(); } catch (error) { strapi.log.error(`[${PLUGIN_ID}] Failed to decode or hydrate admin token:`, error); } await next(); } const middlewares = { seedUserInfos, trackActivity, rejectRevokedTokens, preventMultipleSessions, interceptRenewToken }; const bootstrap = async ({ strapi: strapi2 }) => { try { const actions = [ { section: "plugins", displayName: "Access Security Suite Plugin", uid: "access", pluginName: PLUGIN_ID }, { section: "plugins", displayName: "View Configs", uid: "view-configs", pluginName: PLUGIN_ID }, { section: "plugins", displayName: "Manage Configs", uid: "manage-configs", pluginName: PLUGIN_ID } ]; await strapi2.admin.services.permission.actionProvider.registerMany(actions); } catch (error) { strapi2.log.error(`[${PLUGIN_ID}] Failed to register permissions:`, error); } await ensureDefaultSecuritySettings(strapi2); strapi2.server.use(middlewares.preventMultipleSessions); strapi2.plugin(PLUGIN_ID).service(SERVICES.AUTO_LOGOUT_CHECKER).startAutoLogoutWatcher(); }; async function ensureDefaultSecuritySettings(strapi2) { try { const existing = await strapi2.documents(CONTENT_TYPES.SECURITY_SETTINGS).findFirst({}); if (existing) { strapi2.log.info(`[${PLUGIN_ID}] Default security settings already exist.`); return; } await strapi2.documents(CONTENT_TYPES.SECURITY_SETTINGS).create({ data: DEFAULT_SETTINGS }); strapi2.log.info(`[${PLUGIN_ID}] Default security settings created successfully.`); } catch (error) { strapi2.log.error(`[${PLUGIN_ID}] Failed to ensure default security settings:`, error); } } const destroy = async ({ strapi: strapi2 }) => { await strapi2.plugin(PLUGIN_ID).service(SERVICES.AUTO_LOGOUT_CHECKER).stopAutoLogoutWatcher(); }; const register = ({ strapi: strapi2 }) => { strapi2.server.use(middlewares.seedUserInfos); strapi2.server.use(middlewares.interceptRenewToken); strapi2.server.use(middlewares.trackActivity); strapi2.server.use(middlewares.rejectRevokedTokens); strapi2.log.info(`[${PLUGIN_ID}] Plugin registered successfully`); }; const config = { /** @type {object} Default plugin configuration (empty — all config lives in the DB). */ default: {}, /** * Validates the plugin configuration object at startup. * Currently a no-op; add schema checks here if external config is introduced. * * @param {object} _config - The configuration object to validate */ validator(_config) { } }; const kind$3 = "singleType"; const info$3 = { singularName: "security-settings", pluralName: "security-settings", displayName: "Security Settings", description: "Stores security and session settings" }; const options$3 = { draftAndPublish: false }; const pluginOptions$3 = { "content-manager": { visible: false }, "content-type-builder": { visible: false } }; const attributes$3 = { autoLogoutTime: { type: "integer", required: true, "default": 30 }, multipleSessionsControl: { type: "boolean", "default": true }, passwordExpiryDays: { type: "integer", required: true, "default": 30 }, nonReusablePassword: { type: "boolean", "default": true }, enablePasswordManagement: { type: "boolean", "default": true } }; const schema$3 = { kind: kind$3, info: info$3, options: options$3, pluginOptions: pluginOptions$3, attributes: attributes$3 }; const securitySettings = { schema: schema$3 }; const kind$2 = "collectionType"; const collectionName$2 = "sss_admin_sessions"; const info$2 = { singularName: "admin-session", pluralName: "admin-sessions", displayName: "Admin Session", description: "Per-session activity and revocation state for admin users (managed by strapi-security-suite)." }; const options$2 = { draftAndPublish: false }; const pluginOptions$2 = { "content-manager": { visible: false }, "content-type-builder": { visible: false } }; const attributes$2 = { sessionId: { type: "string", required: true, unique: true }, userId: { type: "integer", required: true }, email: { type: "string", required: true }, lastActiveAt: { type: "datetime", required: true }, revokedAt: { type: "datetime" } }; const schema$2 = { kind: kind$2, collectionName: collectionName$2, info: info$2, options: options$2, pluginOptions: pluginOptions$2, attributes: attributes$2 }; const adminSession = { schema: schema$2 }; const kind$1 = "collectionType"; const collectionName$1 = "sss_login_locks"; const info$1 = { singularName: "login-lock", pluralName: "login-locks", displayName: "Login Lock", description: "Cross-pod login lock keyed by email (managed by strapi-security-suite)." }; const options$1 = { draftAndPublish: false }; const pluginOptions$1 = { "content-manager": { visible: false }, "content-type-builder": { visible: false } }; const attributes$1 = { email: { type: "string", required: true, unique: true }, lockedUntil: { type: "datetime", required: true } }; const schema$1 = { kind: kind$1, collectionName: collectionName$1, info: info$1, options: options$1, pluginOptions: pluginOptions$1, attributes: attributes$1 }; const loginLock = { schema: schema$1 }; const kind = "collectionType"; const collectionName = "sss_watcher_leases"; const info = { singularName: "watcher-lease", pluralName: "watcher-leases", displayName: "Watcher Lease", description: "Cross-pod leader-election lease for the auto-logout watcher (managed by strapi-security-suite)." }; const options = { draftAndPublish: false }; const pluginOptions = { "content-manager": { visible: false }, "content-type-builder": { visible: false } }; const attributes = { name: { type: "string", required: true, unique: true }, holder: { type: "string" }, expiresAt: { type: "datetime" } }; const schema = { kind, collectionName, info, options, pluginOptions, attributes }; const watcherLease = { schema }; const contentTypes = { "security-settings": securitySettings, "admin-session": adminSession, "login-lock": loginLock, "watcher-lease": watcherLease }; const validateSettingsPayload = (body) => { if (!body || typeof body !== "object" || Array.isArray(body)) { throw new ValidationError( `Invalid payload type: ${typeof body}`, ERROR_MESSAGES.INVALID_SETTINGS ); } const TYPE_RULES = { autoLogoutTime: "number", multipleSessionsControl: "boolean", passwordExpiryDays: "number", nonReusablePassword: "boolean", enablePasswordManagement: "boolean" }; const sanitized = {}; for (const key of VALID_SETTINGS_KEYS) { if (!(key in body)) continue; const value = body[key]; const expected = TYPE_RULES[key]; if (expected && typeof value !== expected) { throw new ValidationError( `Invalid type for "${key}": expected ${expected}, got ${typeof value}`, ERROR_MESSAGES.INVALID_SETTINGS ); } sanitized[key] = value; } return sanitized; }; const adminSecurityController = ({ strapi: strapi2 }) => ({ /** * Returns the current security settings. * * @param {import('koa').Context} ctx - Koa context */ async getSettings(ctx) { try { const doc = await strapi2.documents(CONTENT_TYPES.SECURITY_SETTINGS).findFirst({}); const settings = {}; for (const key of VALID_SETTINGS_KEYS) { if (doc && key in doc) settings[key] = doc[key]; } ctx.body = { data: settings }; } catch (err) { strapi2.log.error(`[${PLUGIN_ID}] getSettings error:`, err); ctx.throw( err.statusCode || HTTP_STATUS.BAD_REQUEST, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR ); } }, /** * Validates and persists updated security settings. * * If a settings record already exists it is updated; otherwise a new record * is created. The request body is validated against allowed keys and types * before any database write. * * @param {import('koa').Context} ctx - Koa context */ async saveSettings(ctx) { try { const data = validateSettingsPayload(ctx.request.body); const existing = await strapi2.documents(CONTENT_TYPES.SECURITY_SETTINGS).findFirst({}); if (existing?.documentId) { await strapi2.documents(CONTENT_TYPES.SECURITY_SETTINGS).update({ documentId: existing.documentId, data }); } else { await strapi2.documents(CONTENT_TYPES.SECURITY_SETTINGS).create({ data }); } ctx.body = { data: { message: "Settings saved successfully" } }; } catch (err) { strapi2.log.error(`[${PLUGIN_ID}] saveSettings error:`, err); ctx.throw( err.statusCode || HTTP_STATUS.BAD_REQUEST, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR ); } }, /** * Lightweight keep-alive endpoint hit by the admin client while the user * is actively interacting with the UI (mouse / keyboard). The middleware * chain (`seedUserInfos` → `trackActivity`) does the actual work of * persisting `lastActiveAt` for the current session, so the handler * itself only needs to return 204. * * @param {import('koa').Context} ctx - Koa context */ heartbeat(ctx) { ctx.status = HTTP_STATUS.NO_CONTENT; } }); const controllers = { adminSecurityController }; const hasAdminPermission = async (policyContext, config2, { strapi: strapi2 }) => { const user = policyContext.state.user; if (!user) { return false; } const requiredPermission = config2?.permission; if (!requiredPermission) { return true; } const [roleId] = user.roles.map((role) => role.id); const permissions = await strapi2.admin.services.permission.findMany({ where: { role: roleId, action: requiredPermission } }); return permissions.length > 0; }; const policies = { "has-admin-permission": hasAdminPermission }; const admin = [ { method: "POST", path: "/heartbeat", handler: "adminSecurityController.heartbeat", config: { // No policy: any authenticated admin may keep their session alive } }, { method: "GET", path: "/admin/settings", handler: "adminSecurityController.getSettings", config: { policies: [ { name: "plugin::strapi-security-suite.has-admin-permission", config: { permission: PERMISSIONS.VIEW_CONFIGS } } ] } }, { method: "POST", path: "/admin/settings", handler: "adminSecurityController.saveSettings", config: { policies: [ { name: "plugin::strapi-security-suite.has-admin-permission", config: { permission: PERMISSIONS.MANAGE_CONFIGS } } ] } } ]; const routes = { admin: { type: "admin", routes: admin } }; let interval = null; const autoLogoutChecker = ({ strapi: strapi2 }) => ({ /** * Starts the auto-logout watcher interval. Idempotent — calling twice * leaves the existing interval intact. */ startAutoLogoutWatcher() { if (interval) { strapi2.log.warn(`[${PLUGIN_ID}] AutoLogoutWatcher already running.`); return; } const state2 = strapi2.plugin(PLUGIN_ID).service(SERVICES.STATE); interval = setInterval(async () => { try { const isLeader = await state2.acquireWatcherLease(); if (!isLeader) return; await state2.pruneExpiredLocks(); const settings = await strapi2.documents(CONTENT_TYPES.SECURITY_SETTINGS).findFirst({}); const idleThresholdMs = (settings?.autoLogoutTime ?? DEFAULT_AUTOLOGOUT_TIME) * MS_PER_MINUTE; const idleSessions = await state2.listIdleSessions({ idleThresholdMs }); if (idleSessions.length === 0) return; for (const session of idleSessions) { await state2.revokeSession({ sessionId: session.sessionId }); if (strapi2.sessionManager && session.userId) { await strapi2.sessionManager("admin").invalidateRefreshToken(String(session.userId)).catch( (e) => strapi2.log.error( `[${PLUGIN_ID}] Failed to invalidate DB session for ${session.email}:`, e ) ); } const idleSeconds = Math.floor( (Date.now() - new Date(session.lastActiveAt).getTime()) / MS_PER_SECOND ); strapi2.log.info( `[${PLUGIN_ID}] Auto-logged out admin "${session.email}" after ${idleSeconds}s of inactivity.` ); } } catch (err) { strapi2.log.error(`[${PLUGIN_ID}] AutoLogoutWatcher tick failed:`, err); } }, CHECK_INTERVAL); }, /** * Stops the auto-logout watcher interval and releases the lease so another * pod can pick it up immediately rather than waiting for TTL expiry. * * @returns {Promise<void>} */ async stopAutoLogoutWatcher() { if (!interval) return; clearInterval(interval); interval = null; const state2 = strapi2.plugin(PLUGIN_ID).service(SERVICES.STATE); await state2.releaseWatcherLease(); strapi2.log.info(`[${PLUGIN_ID}] AutoLogoutWatcher stopped.`); } }); const lastDbWriteAt = /* @__PURE__ */ new Map(); const POD_ID = `${process.env.HOSTNAME ?? "pod"}-${randomUUID()}`; const isUniqueConstraintError = (err) => { if (!err) return false; const msg = String(err.message ?? ""); return err.code === "23505" || err.code === "ER_DUP_ENTRY" || err.errno === 1062 || err.errno === 19 || /unique/i.test(msg) || /duplicate/i.test(msg); }; const columnFor = (strapi2, uid, attribute) => { try { const metadata = strapi2.db.metadata.get(uid); const attr = metadata?.attributes?.[attribute]; if (attr?.columnName) return attr.columnName; } catch { } return attribute.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); }; const state = ({ strapi: strapi2 }) => { const sessionUid = CONTENT_TYPES.ADMIN_SESSION; const lockUid = CONTENT_TYPES.LOGIN_LOCK; const leaseUid = CONTENT_TYPES.WATCHER_LEASE; const tableOf = (uid) => strapi2.db.metadata.get(uid).tableName; return { /** Stable per-pod identifier; exposed for diagnostics. */ POD_ID, /** * Persists the user's last-active timestamp, write-coalesced to once * per {@link ACTIVITY_FLUSH_INTERVAL_MS} per session. * * On first call for a session, performs an INSERT; subsequent calls * UPDATE the existing row. A unique-constraint conflict (two pods * inserting concurrently) falls back to UPDATE. * * @param {object} params * @param {string} params.sessionId - JWT sessionId * @param {number|string} params.userId - Admin user ID * @param {string} params.email - Admin email * @returns {Promise<void>} */ async touch({ sessionId, userId, email }) { if (!sessionId || !userId || !email) return; const now = Date.now(); const lastWrite = lastDbWriteAt.get(sessionId) ?? 0; if (now - lastWrite < ACTIVITY_FLUSH_INTERVAL_MS) return; lastDbWriteAt.set(sessionId, now); const lastActiveAt = new Date(now); try { const existing = await strapi2.db.query(sessionUid).findOne({ where: { sessionId }, select: ["id"] }); if (existing) { await strapi2.db.query(sessionUid).update({ where: { id: existing.id }, data: { lastActiveAt } }); return; } await strapi2.db.query(sessionUid).create({ data: { sessionId, userId, email, lastActiveAt } }); } catch (err) { if (isUniqueConstraintError(err)) { await strapi2.db.query(sessionUid).update({ where: { sessionId }, data: { lastActiveAt } }).catch((e) => strapi2.log.error(`[${PLUGIN_ID}] touch fallback update failed:`, e)); return; } strapi2.log.error(`[${PLUGIN_ID}] touch failed for ${email}:`, err); } }, /** * Returns true if the given session has been revoked. * * @param {string} sessionId - JWT sessionId * @returns {Promise<boolean>} */ async isRevoked(sessionId) { if (!sessionId) return false; const row = await strapi2.db.query(sessionUid).findOne({ where: { sessionId }, select: ["revokedAt"] }); return Boolean(row?.revokedAt); }, /** * Marks a session as revoked. * * @param {object} params * @param {string} params.sessionId - JWT sessionId * @returns {Promise<void>} */ async revokeSession({ sessionId }) { if (!sessionId) return; lastDbWriteAt.delete(sessionId); await strapi2.db.query(sessionUid).updateMany({ where: { sessionId }, data: { revokedAt: /* @__PURE__ */ new Date() } }); }, /** * Marks all sessions belonging to the given email as revoked. * Used by the auto-logout watcher and email-level revocation paths. * * @param {object} params * @param {string} params.email - Admin email * @returns {Promise<number>} Number of sessions revoked */ async revokeAllForEmail({ email }) { if (!email) return 0; const result = await strapi2.db.query(sessionUid).updateMany({ where: { email, revokedAt: { $null: true } }, data: { revokedAt: /* @__PURE__ */ new Date() } }); return result?.count ?? 0; }, /** * Returns true if the email has an active (non-revoked, recent) session. * Used by preventMultipleSessions. * * @param {object} params * @param {string} params.email - Admin email * @param {number} params.idleThresholdMs - Idle threshold in milliseconds * @returns {Promise<boolean>} */ async hasActiveSession({ email, idleThresholdMs }) { if (!email) return false; const cutoff = new Date(Date.now() - idleThresholdMs); const row = await strapi2.db.query(sessionUid).findOne({ where: { email, revokedAt: { $null: true }, lastActiveAt: { $gte: cutoff } }, select: ["id"] }); return Boolean(row); }, /** * Lists session rows that have been idle past the given threshold and * are not yet revoked. Returns a small projection only. * * @param {object} params * @param {number} params.idleThresholdMs - Idle threshold in milliseconds * @returns {Promise<Array<{ id: number, sessionId: string, userId: number, email: string, lastActiveAt: Date }>>} */ async listIdleSessions({ idleThresholdMs }) { const cutoff = new Date(Date.now() - idleThresholdMs); return strapi2.db.query(sessionUid).findMany({ where: { revokedAt: { $null: true }, lastActiveAt: { $lt: cutoff } }, select: ["id", "sessionId", "userId", "email", "lastActiveAt"] }); }, /** * Atomically attempts to acquire a login lock for the given email. * Returns true on success, false if another pod holds an unexpired lock. * * Uses `SELECT … FOR UPDATE` inside a transaction for cross-DB * mutual exclusion (Postgres / MySQL / SQLite). * * @param {object} params * @param {string} params.email - Admin email * @returns {Promise<boolean>} True if the lock was acquired */ async acquireLoginLock({ email }) { if (!email) return true; const tableName = tableOf(lockUid); const emailCol = columnFor(strapi2, lockUid, "email"); const lockedUntilCol = columnFor(strapi2, lockUid, "lockedUntil"); const documentIdCol = "document_id"; const createdAtCol = "created_at"; const updatedAtCol = "updated_at"; const now = /* @__PURE__ */ new Date(); const lockedUntil = new Date(now.getTime() + LOGIN_LOCK_TTL_MS); const knex = strapi2.db.connection; try { return await knex.transaction(async (trx) => { const row = await trx(tableName).where({ [emailCol]: email }).first().forUpdate(); if (!row) { try { await trx(tableName).insert({ [documentIdCol]: randomUUID(), [emailCol]: email, [lockedUntilCol]: lockedUntil, [createdAtCol]: now, [updatedAtCol]: now }); return true; } catch (err) { if (isUniqueConstraintError(err)) return false; throw err; } } const heldUntil = row[lockedUntilCol] ? new Date(row[lockedUntilCol]) : null; if (heldUntil && heldUntil > now) return false; await trx(tableName).where({ id: row.id }).update({ [lockedUntilCol]: lockedUntil, [updatedAtCol]: now }); return true; }); } catch (err) { strapi2.log.error(`[${PLUGIN_ID}] acquireLoginLock failed for ${email}:`, err); return false; } }, /** * Releases the login lock for the given email. Idempotent. * * @param {object} params * @param {string} params.email - Admin email * @returns {Promise<void>} */ async releaseLoginLock({ email }) { if (!email) return; try { await strapi2.db.query(lockUid).deleteMany({ where: { email } }); } catch (err) { strapi2.log.error(`[${PLUGIN_ID}] releaseLoginLock failed for ${email}:`, err); } }, /** * Removes login-lock rows whose `lockedUntil` has passed. * * @returns {Promise<number>} Number of rows deleted */ async pruneExpiredLocks() { const result = await strapi2.db.query(lockUid).deleteMany({ where: { lockedUntil: { $lt: /* @__PURE__ */ new Date() } } }); return result?.count ?? 0; }, /** * Atomically attempts to acquire (or renew) the auto-logout watcher lease. * Only the holding pod runs the periodic watcher body cluster-wide. * * @returns {Promise<boolean>} True if this pod now holds the lease */ async acquireWatcherLease() { const tableName = tableOf(leaseUid); const nameCol = columnFor(strapi2, leaseUid, "name"); const holderCol = columnFor(strapi2, leaseUid, "holder"); const expiresAtCol = columnFor(strapi2, leaseUid, "expiresAt"); const documentIdCol = "document_id"; const createdAtCol = "created_at"; const updatedAtCol = "updated_at"; const now = /* @__PURE__ */ new Date(); const expiresAt = new Date(now.getTime() + WATCHER_LEASE_TTL_MS); const knex = strapi2.db.connection; try { return await knex.transaction(async (trx) => { const row = await trx(tableName).where({ [nameCol]: WATCHER_LEASE_NAME }).first().forUpdate(); if (!row) { try { await trx(tableName).insert({ [documentIdCol]: randomUUID(), [nameCol]: WATCHER_LEASE_NAME, [holderCol]: POD_ID, [expiresAtCol]: expiresAt, [createdAtCol]: now, [updatedAtCol]: now }); return true; } catch (err) { if (isUniqueConstraintError(err)) return false; throw err; } } const heldUntil = row[expiresAtCol] ? new Date(row[expiresAtCol]) : null; const isOwner = row[holderCol] === POD_ID; const expired = !heldUntil || heldUntil <= now; if (!isOwner && !expired) return false; await trx(tableName).where({ id: row.id }).update({ [holderCol]: POD_ID, [expiresAtCol]: expiresAt, [updatedAtCol]: now }); return true; }); } catch (err) { strapi2.log.error(`[${PLUGIN_ID}] acquireWatcherLease failed:`, err); return false; } }, /** * Releases the watcher lease, but only if this pod currently holds it. * * @returns {Promise<void>} */ async releaseWatcherLease() { const tableName = tableOf(leaseUid); const nameCol = columnFor(strapi2, leaseUid, "name"); const holderCol = columnFor(strapi2, leaseUid, "holder"); const expiresAtCol = columnFor(strapi2, leaseUid, "expiresAt"); const updatedAtCol = "updated_at"; try { await strapi2.db.connection(tableName).where({ [nameCol]: WATCHER_LEASE_NAME, [holderCol]: POD_ID }).update({ [holderCol]: "", [expiresAtCol]: /* @__PURE__ */ new Date(0), [updatedAtCol]: /* @__PURE__ */ new Date() }); } catch (err) { strapi2.log.error(`[${PLUGIN_ID}] releaseWatcherLease failed:`, err); } }, /** * Test-only: clears the per-pod write-coalescing cache. * Exposed for unit tests so each test starts from a clean slate. * * @returns {void} */ _resetActivityCache() { lastDbWriteAt.clear(); } }; }; const services = { autoLogoutChecker, state }; const index = { bootstrap, destroy, register, config, controllers, contentTypes, middlewares, policies, routes, services }; export { index as default };