strapi-security-suite
Version:
All-in-one authentication and session security plugin for Strapi v5
414 lines (413 loc) โข 13 kB
JavaScript
import jwt from "jsonwebtoken";
const CHECK_INTERVAL = 5e3;
const LOGIN_PATH = "/admin/login";
const LOGOUT_PATH = "/admin/logout";
const sessionActivityMap = /* @__PURE__ */ new Map();
async function trackActivity(ctx, next) {
const adminUser = ctx.session?.user;
let key = adminUser?.id ? `${adminUser.id}:${adminUser.email}` : null;
if (ctx.path.includes(LOGOUT_PATH)) {
ctx.session = null;
key = null;
}
if (key && key !== null) {
sessionActivityMap.set(key, Date.now());
strapi.log.debug(`๐ข Activity updated: ${key}`);
}
await next();
}
const loginLocks = /* @__PURE__ */ new Set();
async function preventMultipleSessions(ctx, next) {
const isLoginPost = ctx.path === LOGIN_PATH && ctx.method === "POST";
const alreadyAdmin = ctx.session?.user;
if (!isLoginPost) {
return await next();
}
if (alreadyAdmin) {
strapi.log.debug(`Skipping session lock. ${JSON.stringify(alreadyAdmin)}`);
return await next();
}
try {
const { email } = ctx.request.body ?? {};
if (!email) {
strapi.log.warn("โ ๏ธ Email missing in login request. Skipping session lock.");
return await next();
}
const settings = await strapi.entityService.findMany("plugin::strapi-security-suite.security-settings", {});
if (!settings?.multipleSessionsControl) return await next();
const hasActiveSession = Array.from(sessionActivityMap.keys()).some(
(key) => key.endsWith(`:${email}`)
);
if (hasActiveSession || loginLocks.has(email)) {
strapi.log.warn(`โ Login blocked for ${email}: already logged in or logging in.`);
ctx.status = 409;
ctx.body = {
error: {
status: 409,
message: "Multiple sessions are not allowed. You are already logged in elsewhere."
}
};
return;
}
loginLocks.add(email);
} catch (err) {
strapi.log.error("๐ Error in preventMultipleSessions:", err);
}
try {
await next();
} finally {
if (ctx.path === LOGIN_PATH && ctx.method === "POST") {
const email = ctx.request.body?.email;
loginLocks.delete(email);
}
}
}
const checkAdminPermission = (requiredPermission) => async (ctx, next) => {
try {
const adminUser = ctx.session.user;
if (!adminUser) {
return ctx.unauthorized("User is not authenticated.");
}
const [roleId] = adminUser.roles.map((role) => role.id);
const adminPermissions = await strapi.admin.services.permission.findMany({
where: {
role: roleId,
action: requiredPermission
}
});
if (adminPermissions.length === 0) {
return ctx.forbidden(`Access denied. Missing permission: ${requiredPermission}`);
}
await next();
} catch (error) {
strapi.log.error("๐ด Error checking admin permission:", error);
return ctx.internalServerError("Failed to verify permissions.");
}
};
const revokedTokenSet = /* @__PURE__ */ new Set();
const forceExpireAdmin = async (ctx, userId) => {
const ADMIN_SECRET = strapi.config.get("admin.auth.secret");
const token = jwt.sign(
{
id: userId,
iat: Math.floor(Date.now() / 1e3),
exp: Math.floor(Date.now() / 1e3) + 1
// Expires in 1s
},
ADMIN_SECRET
);
ctx.cookies.set("jwtToken", token, {
httpOnly: true,
path: "/",
expires: new Date(Date.now() + 1e3)
});
strapi.log.info(`๐ฃ Force-expired token for admin ${userId}`);
};
async function rejectRevokedTokens(ctx, next) {
const sessionCookie = ctx.cookies.get("koa.sess");
if (!sessionCookie) return await next();
try {
const decoded = Buffer.from(sessionCookie, "base64").toString("utf8");
const sessionData = JSON.parse(decoded);
const { id, email: adminEmail } = sessionData?.user || {};
const key = id && adminEmail ? `${id}:${adminEmail}` : null;
if (adminEmail && revokedTokenSet.has(adminEmail)) {
ctx.set("app.admin.tk", adminEmail);
ctx.set("Access-Control-Expose-Headers", "app.admin.tk");
ctx.cookies.set("koa.sess", "", { expires: /* @__PURE__ */ new Date(0), path: "/" });
ctx.cookies.set("koa.sess.sig", "", { expires: /* @__PURE__ */ new Date(0), path: "/" });
sessionActivityMap.delete(key);
revokedTokenSet.delete(adminEmail);
strapi.service("plugin::strapi-security-suite.autoLogoutChecker").clearSessionActivity(id, adminEmail);
forceExpireAdmin(ctx, id);
strapi.log.info(`๐ Session revoked: ${adminEmail} app.admin.logout`);
await next();
return;
}
} catch (err) {
strapi.log.error("๐ Error in rejectRevokedTokens middleware:", err);
}
await next();
}
async function interceptRenewToken(ctx, next) {
if (ctx.path.includes(LOGOUT_PATH)) {
const adminUser = ctx.session?.user;
strapi.log.debug(`๐ข Logout captured updated: ${JSON.stringify(adminUser)}`);
ctx.cookies.set("koa.sess", "", { expires: /* @__PURE__ */ new Date(0), path: "/" });
ctx.cookies.set("koa.sess.sig", "", { expires: /* @__PURE__ */ new Date(0), path: "/" });
if (adminUser?.id) {
strapi.service("plugin::strapi-security-suite.autoLogoutChecker").clearSessionActivity(adminUser?.id, adminUser?.email);
ctx.session = null;
sessionActivityMap.delete(`${adminUser?.id}:${adminUser?.email}`);
return;
}
}
if (ctx.path === "/admin/renew-token" || ctx.path.includes("/content")) {
const { email } = ctx.session?.user || {};
if (!email) {
ctx.set("app.admin.tk", "email.admin");
ctx.set("Access-Control-Expose-Headers", "app.admin.tk");
await next();
return;
}
strapi.log.debug(`๐ข Renew token intercepted for ${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 session = ctx.session?.user ?? null;
const adminId = decodedToken?.id;
if (!adminId || session?.id) {
strapi.log.debug("๐ No actions needed.");
return await next();
}
const adminUser = await strapi.query("admin::user").findOne({ where: { id: adminId } });
if (!adminUser) {
strapi.log.debug(`๐ป No admin user found with ID ${adminId}`);
return await next();
}
if (!sessionActivityMap.has(adminUser.email)) {
strapi.log.debug(`๐ป Admin ${adminUser.email} has a revoked token`);
return await next();
}
const userInfos = {
id: adminUser.id,
email: adminUser.email,
firstname: adminUser.firstname,
lastname: adminUser.lastname,
roles: adminUser.roles
};
ctx.session.user = userInfos;
strapi.log.debug(`๐ง Session hydrated for admin ${adminUser.email}`);
return await next();
} catch (error) {
strapi.log.error("๐ Failed to decode or hydrate admin token:", error);
}
await next();
}
const middlewares = {
seedUserInfos,
trackActivity,
rejectRevokedTokens,
preventMultipleSessions,
interceptRenewToken,
checkAdminPermission
};
const bootstrap = async ({ strapi: strapi2 }) => {
try {
const actions = [
{
section: "plugins",
displayName: "Access Security Suite Plugin",
uid: "access",
pluginName: "strapi-security-suite"
},
{
section: "plugins",
displayName: "View Configs",
uid: "view-configs",
pluginName: "strapi-security-suite"
},
{
section: "plugins",
displayName: "Manage Configs",
uid: "manage-configs",
pluginName: "strapi-security-suite"
}
];
await strapi2.admin.services.permission.actionProvider.registerMany(actions);
} catch (error) {
strapi2.log.error("โ Failed to register SecSuite Plugin permissions:", error);
}
strapi2.server.use(middlewares.preventMultipleSessions);
strapi2.service("plugin::strapi-security-suite.autoLogoutChecker").startAutoLogoutWatcher();
};
const destroy = ({ strapi: strapi2 }) => {
strapi2.service("plugin::strapi-security-suite.autoLogoutChecker").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);
};
const config = {
default: {},
validator() {
}
};
const kind = "singleType";
const uid = "plugin::strapi-security-suite.security_settings";
const info = {
singularName: "security-settings",
pluralName: "security-settings",
displayName: "Security Settings",
description: "Stores security and session settings"
};
const options = {
draftAndPublish: false
};
const attributes = {
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 = {
kind,
uid,
info,
options,
attributes
};
const securitySettings = {
schema
};
const contentTypes = {
"security-settings": securitySettings
};
const adminSecurityController = ({ strapi: strapi2 }) => ({
async getSettings(ctx) {
const settings = await strapi2.entityService.findMany("plugin::strapi-security-suite.security-settings");
ctx.send(settings);
},
async saveSettings(ctx) {
const data = ctx.request.body;
const existing = await strapi2.entityService.findMany("plugin::strapi-security-suite.security-settings");
if (existing?.id) {
await strapi2.entityService.update("plugin::strapi-security-suite.security-settings", existing.id, { data });
} else {
await strapi2.entityService.create("plugin::strapi-security-suite.security-settings", { data });
}
ctx.send({ message: "Settings saved successfully" });
}
});
const controllers = {
adminSecurityController
};
const policies = {};
const routes = [
{
method: "GET",
path: "/admin/settings",
handler: "adminSecurityController.getSettings",
config: {
policies: [],
auth: false,
middlewares: [middlewares.checkAdminPermission("plugin::strapi-security-suite.view-configs")]
}
},
{
method: "POST",
path: "/admin/settings",
handler: "adminSecurityController.saveSettings",
config: {
policies: [],
auth: false,
middlewares: [middlewares.checkAdminPermission("plugin::strapi-security-suite.manage-configs")]
}
}
];
let interval = null;
const autoLogoutChecker = ({ strapi: strapi2 }) => ({
/**
* Starts the auto-logout watcher that checks session activity every X ms.
*/
startAutoLogoutWatcher() {
if (interval) {
strapi2.log.warn("โ ๏ธ AutoLogoutWatcher already running.");
return;
}
interval = setInterval(async () => {
try {
const settings = await strapi2.entityService.findMany("plugin::strapi-security-suite.security-settings", {});
const autoLogoutTime = (settings?.autoLogoutTime ?? 30) * 6e4;
const now = Date.now();
for (const [key, lastActive] of sessionActivityMap.entries()) {
const [adminId, email] = key.split(":");
const idleDuration = now - lastActive;
if (idleDuration > autoLogoutTime) {
if (!revokedTokenSet.has(email)) {
revokedTokenSet.add(email);
sessionActivityMap.delete(key);
strapi2.log.info(`๐ Auto-logged out admin "${email}" after ${Math.floor(idleDuration / 1e3)}s of inactivity.`);
}
}
}
} catch (err) {
strapi2.log.error("โ AutoLogoutWatcher failed:", err);
}
}, CHECK_INTERVAL);
},
/**
* Stops the auto-logout watcher.
*/
stopAutoLogoutWatcher() {
if (interval) {
clearInterval(interval);
interval = null;
strapi2.log.info("๐ AutoLogoutWatcher stopped.");
}
},
/**
* Manually clear session activity for a user.
* @param {string} adminId
* @param {string} email
* @param {string} reason
*/
clearSessionActivity(adminId, email, reason = "manual") {
const key = `${adminId}:${email}`;
if (sessionActivityMap.has(key)) {
sessionActivityMap.delete(key);
strapi2.log.info(`๐งน Session cleared for ${key} (${reason})`);
}
if (revokedTokenSet.has(email)) {
revokedTokenSet.delete(email);
strapi2.log.info(`โ
Revoked token cleared for ${email}`);
}
}
});
const services = {
autoLogoutChecker
};
const index = {
bootstrap,
destroy,
register,
config,
controllers,
contentTypes,
middlewares,
policies,
routes,
services
};
export {
index as default
};