UNPKG

strapi-security-suite

Version:

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

414 lines (413 loc) โ€ข 13 kB
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 };