strapi-security-suite
Version:
All-in-one authentication and session security plugin for Strapi v5
1,096 lines (1,095 loc) • 35.4 kB
JavaScript
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
};