strapi-security-suite 0.2.4 โ†’ 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,178 +1,214 @@
1
1
  import jwt from "jsonwebtoken";
2
2
  const revokedTokenSet = /* @__PURE__ */ new Set();
3
- const revokedConnectionTokens = /* @__PURE__ */ new Set();
3
+ const revokedConnectionTokens = /* @__PURE__ */ new Map();
4
+ const TOKEN_TTL = 30 * 60 * 1e3;
5
+ function pruneExpiredTokens() {
6
+ const cutoff = Date.now() - TOKEN_TTL;
7
+ for (const [token, revokedAt] of revokedConnectionTokens) {
8
+ if (revokedAt < cutoff) {
9
+ revokedConnectionTokens.delete(token);
10
+ }
11
+ }
12
+ }
13
+ const sessionActivityMap = /* @__PURE__ */ new Map();
14
+ const PLUGIN_ID = "strapi-security-suite";
15
+ const CONTENT_TYPES = {
16
+ SECURITY_SETTINGS: `plugin::${PLUGIN_ID}.security-settings`
17
+ };
18
+ const SERVICES = {
19
+ AUTO_LOGOUT_CHECKER: "autoLogoutChecker"
20
+ };
21
+ const CTX_ADMIN_USER = Symbol.for("security-suite:adminUser");
4
22
  const CHECK_INTERVAL = 5e3;
23
+ const DEFAULT_AUTOLOGOUT_TIME = 30;
24
+ const MS_PER_MINUTE = 6e4;
25
+ const MS_PER_SECOND = 1e3;
5
26
  const LOGIN_PATH = "/admin/login";
6
27
  const LOGOUT_PATH = "/admin/logout";
7
- const sessionActivityMap = /* @__PURE__ */ new Map();
28
+ const ACCESS_TOKEN_PATH = "/access-token";
29
+ const CONTENT_PATH = "/content";
30
+ const HTTP_STATUS = {
31
+ BAD_REQUEST: 400,
32
+ FORBIDDEN: 403,
33
+ CONFLICT: 409
34
+ };
35
+ const COOKIES = {
36
+ SESSION: "koa.sess",
37
+ SESSION_SIG: "koa.sess.sig",
38
+ /** Strapi v5 admin refresh-token cookie (managed by session manager). */
39
+ REFRESH_TOKEN: "strapi_admin_refresh"
40
+ };
41
+ const HEADERS = {
42
+ /** Header that signals the frontend to force-reload (session revoked). */
43
+ ADMIN_TOKEN_SIGNAL: "app.admin.tk",
44
+ /** Required so the browser exposes custom headers in fetch responses. */
45
+ EXPOSE_HEADERS: "Access-Control-Expose-Headers"
46
+ };
47
+ const ERROR_MESSAGES = {
48
+ SETTINGS_NOT_FOUND: "Security settings not found.",
49
+ INSUFFICIENT_PERMISSIONS: "Insufficient permissions.",
50
+ NOT_AUTHENTICATED: "User is not authenticated.",
51
+ UNKNOWN_ERROR: "An unexpected error occurred.",
52
+ MULTIPLE_SESSIONS: "Multiple sessions are not allowed. You are already logged in elsewhere.",
53
+ TOKEN_REVOKED: "Forbidden. Your token has been revoked.",
54
+ PERMISSION_CHECK_FAILED: "Failed to verify permissions.",
55
+ INVALID_SETTINGS: "Invalid settings payload."
56
+ };
57
+ const PERMISSIONS = {
58
+ VIEW_CONFIGS: `plugin::${PLUGIN_ID}.view-configs`,
59
+ MANAGE_CONFIGS: `plugin::${PLUGIN_ID}.manage-configs`
60
+ };
61
+ const DEFAULT_SETTINGS = {
62
+ autoLogoutTime: 30,
63
+ multipleSessionsControl: true,
64
+ passwordExpiryDays: 30,
65
+ nonReusablePassword: true,
66
+ enablePasswordManagement: true
67
+ };
68
+ const VALID_SETTINGS_KEYS = new Set(Object.keys(DEFAULT_SETTINGS));
8
69
  async function trackActivity(ctx, next) {
9
- const adminUser = ctx.session?.user;
70
+ const adminUser = ctx.state[CTX_ADMIN_USER];
10
71
  let key = adminUser?.id ? `${adminUser.id}:${adminUser.email}` : null;
11
- const auth = ctx.request.headers?.authorization;
12
- if (auth && revokedConnectionTokens.has(auth.split(" ")[1]) || revokedConnectionTokens.has(ctx.cookies.get("jwtToken"))) {
13
- ctx.cookies.set("jwtToken", "", { expires: /* @__PURE__ */ new Date(0), path: "/" });
14
- ctx.status = 403;
72
+ const bearerToken = ctx.get("authorization")?.split("Bearer ")[1];
73
+ if (bearerToken && revokedConnectionTokens.has(bearerToken)) {
74
+ ctx.status = HTTP_STATUS.FORBIDDEN;
15
75
  ctx.body = {
16
76
  error: {
17
- status: 403,
77
+ status: HTTP_STATUS.FORBIDDEN,
18
78
  title: "Forbidden",
19
- message: "Forbidden. Your token has been revoked."
79
+ message: ERROR_MESSAGES.TOKEN_REVOKED
20
80
  }
21
81
  };
22
82
  return;
23
83
  }
24
84
  if (ctx.path.includes(LOGOUT_PATH)) {
25
- ctx.session = null;
85
+ if (ctx.session !== void 0) {
86
+ ctx.session = null;
87
+ }
26
88
  key = null;
27
89
  }
28
- if (key && key !== null) {
90
+ if (key) {
29
91
  sessionActivityMap.set(key, Date.now());
30
- strapi.log.debug(`๐ŸŸข Activity updated: ${key}`);
92
+ strapi.log.debug(`[${PLUGIN_ID}] Activity updated: ${key}`);
31
93
  }
32
94
  await next();
33
95
  }
34
96
  const loginLocks = /* @__PURE__ */ new Set();
97
+ function cleanupLoginState(ctx) {
98
+ const email = ctx.request.body?.email;
99
+ loginLocks.delete(email);
100
+ if (email && ctx.status < 400) {
101
+ revokedTokenSet.delete(email);
102
+ }
103
+ }
35
104
  async function preventMultipleSessions(ctx, next) {
36
105
  const isLoginPost = ctx.path === LOGIN_PATH && ctx.method === "POST";
37
- const alreadyAdmin = ctx.session?.user;
38
106
  if (!isLoginPost) {
39
107
  return await next();
40
108
  }
41
- if (alreadyAdmin) {
42
- strapi.log.debug(`Skipping session lock. ${JSON.stringify(alreadyAdmin)}`);
109
+ if (ctx.state[CTX_ADMIN_USER]) {
110
+ strapi.log.debug(
111
+ `[${PLUGIN_ID}] Skipping session lock. ${JSON.stringify(ctx.state[CTX_ADMIN_USER])}`
112
+ );
43
113
  return await next();
44
114
  }
45
115
  try {
46
116
  const { email } = ctx.request.body ?? {};
47
117
  if (!email) {
48
- strapi.log.warn("โš ๏ธ Email missing in login request. Skipping session lock.");
118
+ strapi.log.warn(`[${PLUGIN_ID}] Email missing in login request. Skipping session lock.`);
49
119
  return await next();
50
120
  }
51
- const settings = await strapi.entityService.findMany("plugin::strapi-security-suite.security-settings", {});
121
+ const settings = await strapi.documents(CONTENT_TYPES.SECURITY_SETTINGS).findFirst({});
52
122
  if (!settings?.multipleSessionsControl) return await next();
53
123
  const hasActiveSession = Array.from(sessionActivityMap.keys()).some(
54
124
  (key) => key.endsWith(`:${email}`)
55
125
  );
56
126
  if (hasActiveSession || loginLocks.has(email)) {
57
- strapi.log.warn(`โ›” Login blocked for ${email}: already logged in or logging in.`);
58
- ctx.status = 409;
127
+ strapi.log.warn(
128
+ `[${PLUGIN_ID}] Login blocked for ${email}: already logged in or logging in.`
129
+ );
130
+ ctx.status = HTTP_STATUS.CONFLICT;
59
131
  ctx.body = {
60
132
  error: {
61
- status: 409,
62
- message: "Multiple sessions are not allowed. You are already logged in elsewhere."
133
+ status: HTTP_STATUS.CONFLICT,
134
+ message: ERROR_MESSAGES.MULTIPLE_SESSIONS
63
135
  }
64
136
  };
65
137
  return;
66
138
  }
67
139
  loginLocks.add(email);
68
140
  } catch (err) {
69
- strapi.log.error("๐Ÿ›‘ Error in preventMultipleSessions:", err);
141
+ strapi.log.error(`[${PLUGIN_ID}] Error in preventMultipleSessions:`, err);
70
142
  }
71
143
  try {
72
144
  await next();
73
145
  } finally {
74
- if (ctx.path === LOGIN_PATH && ctx.method === "POST") {
75
- const email = ctx.request.body?.email;
76
- loginLocks.delete(email);
77
- }
146
+ cleanupLoginState(ctx);
78
147
  }
79
148
  }
80
- const checkAdminPermission = (requiredPermission) => async (ctx, next) => {
81
- try {
82
- const adminUser = ctx.session.user;
83
- if (!adminUser) {
84
- return ctx.unauthorized("User is not authenticated.");
85
- }
86
- const [roleId] = adminUser.roles.map((role) => role.id);
87
- const adminPermissions = await strapi.admin.services.permission.findMany({
88
- where: {
89
- role: roleId,
90
- action: requiredPermission
91
- }
92
- });
93
- if (adminPermissions.length === 0) {
94
- return ctx.forbidden(`Access denied. Missing permission: ${requiredPermission}`);
95
- }
96
- await next();
97
- } catch (error) {
98
- strapi.log.error("๐Ÿ”ด Error checking admin permission:", error);
99
- return ctx.internalServerError("Failed to verify permissions.");
100
- }
101
- };
102
- const forceExpireAdmin = async (ctx, userId) => {
103
- const ADMIN_SECRET = strapi.config.get("admin.auth.secret");
104
- const token = jwt.sign(
105
- {
106
- id: userId,
107
- iat: Math.floor(Date.now() / 1e3),
108
- exp: Math.floor(Date.now() / 1e3) + 1
109
- // Expires in 1s
110
- },
111
- ADMIN_SECRET
112
- );
113
- ctx.cookies.set("jwtToken", token, {
114
- httpOnly: true,
115
- path: "/",
116
- expires: new Date(Date.now() + 1e3)
117
- });
118
- strapi.log.info(`๐Ÿ’ฃ Force-expired token for admin ${userId}`);
119
- };
120
149
  async function rejectRevokedTokens(ctx, next) {
121
- const sessionCookie = ctx.cookies.get("koa.sess");
122
- if (!sessionCookie) return await next();
150
+ const adminUser = ctx.state[CTX_ADMIN_USER];
151
+ if (!adminUser?.email) return await next();
152
+ const { id, email: adminEmail } = adminUser;
153
+ const key = id && adminEmail ? `${id}:${adminEmail}` : null;
123
154
  try {
124
- const decoded = Buffer.from(sessionCookie, "base64").toString("utf8");
125
- const sessionData = JSON.parse(decoded);
126
- const { id, email: adminEmail } = sessionData?.user || {};
127
- const key = id && adminEmail ? `${id}:${adminEmail}` : null;
128
155
  if (adminEmail && revokedTokenSet.has(adminEmail)) {
129
- ctx.set("app.admin.tk", adminEmail);
130
- ctx.set("Access-Control-Expose-Headers", "app.admin.tk");
131
- ctx.cookies.set("koa.sess", "", { expires: /* @__PURE__ */ new Date(0), path: "/" });
132
- ctx.cookies.set("koa.sess.sig", "", { expires: /* @__PURE__ */ new Date(0), path: "/" });
156
+ ctx.set(HEADERS.ADMIN_TOKEN_SIGNAL, adminEmail);
157
+ ctx.set(HEADERS.EXPOSE_HEADERS, HEADERS.ADMIN_TOKEN_SIGNAL);
158
+ if (ctx.session !== void 0) {
159
+ ctx.session = null;
160
+ }
161
+ ctx.cookies.set(COOKIES.REFRESH_TOKEN, "", {
162
+ expires: /* @__PURE__ */ new Date(0),
163
+ path: "/admin",
164
+ httpOnly: true
165
+ });
166
+ const bearerToken = ctx.get("authorization")?.split("Bearer ")[1];
167
+ if (bearerToken) {
168
+ revokedConnectionTokens.set(bearerToken, Date.now());
169
+ }
133
170
  sessionActivityMap.delete(key);
134
171
  revokedTokenSet.delete(adminEmail);
135
- const userToken = ctx.cookies.get("jwtToken");
136
- if (userToken) {
137
- revokedConnectionTokens.add(ctx.cookies.get("jwtToken"));
172
+ strapi.plugin(PLUGIN_ID).service(SERVICES.AUTO_LOGOUT_CHECKER).clearSessionActivity(id, adminEmail);
173
+ try {
174
+ if (strapi.sessionManager) {
175
+ await strapi.sessionManager("admin").invalidateRefreshToken(String(id));
176
+ }
177
+ } catch (err) {
178
+ strapi.log.error(`[${PLUGIN_ID}] Failed to invalidate DB session for ${adminEmail}:`, err);
138
179
  }
139
- strapi.service("plugin::strapi-security-suite.autoLogoutChecker").clearSessionActivity(id, adminEmail);
140
- forceExpireAdmin(ctx, id);
141
- strapi.log.info(`๐Ÿ”’ Session revoked: ${adminEmail} app.admin.logout`);
180
+ strapi.log.info(`[${PLUGIN_ID}] Session revoked: ${adminEmail}`);
142
181
  await next();
143
182
  return;
144
183
  }
145
184
  } catch (err) {
146
- strapi.log.error("๐Ÿ›‘ Error in rejectRevokedTokens middleware:", err);
185
+ strapi.log.error(`[${PLUGIN_ID}] Error in rejectRevokedTokens middleware:`, err);
147
186
  }
148
187
  await next();
149
188
  }
150
189
  async function interceptRenewToken(ctx, next) {
151
190
  if (ctx.path.includes(LOGOUT_PATH)) {
152
- const adminUser = ctx.session?.user;
153
- strapi.log.debug(`๐ŸŸข Logout captured updated: ${JSON.stringify(adminUser)}`);
154
- ctx.cookies.set("koa.sess", "", { expires: /* @__PURE__ */ new Date(0), path: "/" });
155
- ctx.cookies.set("koa.sess.sig", "", { expires: /* @__PURE__ */ new Date(0), path: "/" });
191
+ const adminUser = ctx.state[CTX_ADMIN_USER];
192
+ strapi.log.debug(`[${PLUGIN_ID}] Logout captured: ${JSON.stringify(adminUser)}`);
156
193
  if (adminUser?.id) {
157
- strapi.service("plugin::strapi-security-suite.autoLogoutChecker").clearSessionActivity(adminUser?.id, adminUser?.email);
158
- const userToken = ctx.cookies.get("jwtToken");
159
- if (userToken) {
160
- revokedConnectionTokens.add(ctx.cookies.get("jwtToken"));
194
+ strapi.plugin(PLUGIN_ID).service(SERVICES.AUTO_LOGOUT_CHECKER).clearSessionActivity(adminUser.id, adminUser.email);
195
+ const bearerToken = ctx.get("authorization")?.split("Bearer ")[1];
196
+ if (bearerToken) {
197
+ revokedConnectionTokens.set(bearerToken, Date.now());
161
198
  }
162
- ctx.session = null;
163
- sessionActivityMap.delete(`${adminUser?.id}:${adminUser?.email}`);
164
- return;
199
+ if (ctx.session !== void 0) {
200
+ ctx.session = null;
201
+ }
202
+ sessionActivityMap.delete(`${adminUser.id}:${adminUser.email}`);
165
203
  }
204
+ await next();
205
+ return;
166
206
  }
167
- if (ctx.path.includes("/renew-token") || ctx.path.includes("/content")) {
168
- const { email } = ctx.session?.user || {};
169
- if (!email) {
170
- ctx.set("app.admin.tk", "email.admin");
171
- ctx.set("Access-Control-Expose-Headers", "app.admin.tk");
172
- await next();
173
- return;
207
+ if (ctx.path.includes(ACCESS_TOKEN_PATH) || ctx.path.includes(CONTENT_PATH)) {
208
+ const adminUser = ctx.state[CTX_ADMIN_USER];
209
+ if (adminUser?.email) {
210
+ strapi.log.debug(`[${PLUGIN_ID}] Token renewal intercepted for ${adminUser.email}`);
174
211
  }
175
- strapi.log.debug(`๐ŸŸข Renew token intercepted for ${email}`);
176
212
  }
177
213
  await next();
178
214
  }
@@ -185,33 +221,33 @@ async function seedUserInfos(ctx, next) {
185
221
  const token = authHeader.split("Bearer ")[1];
186
222
  if (!token) return await next();
187
223
  const decodedToken = jwt.decode(token);
188
- const session = ctx.session?.user ?? null;
189
- const adminId = decodedToken?.id;
190
- if (!adminId || session?.id) {
191
- strapi.log.debug("๐Ÿ” No actions needed.");
224
+ const adminId = decodedToken?.userId;
225
+ if (!adminId || ctx.state[CTX_ADMIN_USER]?.id) {
192
226
  return await next();
193
227
  }
194
- const adminUser = await strapi.query("admin::user").findOne({ where: { id: adminId } });
228
+ const adminUser = await strapi.db.query("admin::user").findOne({
229
+ where: { id: adminId },
230
+ populate: ["roles"]
231
+ });
195
232
  if (!adminUser) {
196
- strapi.log.debug(`๐Ÿ‘ป No admin user found with ID ${adminId}`);
197
- return await next();
198
- }
199
- if (!sessionActivityMap.has(adminUser.email)) {
200
- strapi.log.debug(`๐Ÿ‘ป Admin ${adminUser.email} has a revoked token`);
233
+ strapi.log.debug(`[${PLUGIN_ID}] No admin user found with ID ${adminId}`);
201
234
  return await next();
202
235
  }
203
- const userInfos = {
236
+ ctx.state[CTX_ADMIN_USER] = {
204
237
  id: adminUser.id,
205
238
  email: adminUser.email,
206
239
  firstname: adminUser.firstname,
207
240
  lastname: adminUser.lastname,
208
241
  roles: adminUser.roles
209
242
  };
210
- ctx.session.user = userInfos;
211
- strapi.log.debug(`๐Ÿง  Session hydrated for admin ${adminUser.email}`);
243
+ const key = `${adminUser.id}:${adminUser.email}`;
244
+ if (!sessionActivityMap.has(key)) {
245
+ sessionActivityMap.set(key, Date.now());
246
+ }
247
+ strapi.log.debug(`[${PLUGIN_ID}] Admin identified: ${adminUser.email}`);
212
248
  return await next();
213
249
  } catch (error) {
214
- strapi.log.error("๐Ÿ›‘ Failed to decode or hydrate admin token:", error);
250
+ strapi.log.error(`[${PLUGIN_ID}] Failed to decode or hydrate admin token:`, error);
215
251
  }
216
252
  await next();
217
253
  }
@@ -220,8 +256,7 @@ const middlewares = {
220
256
  trackActivity,
221
257
  rejectRevokedTokens,
222
258
  preventMultipleSessions,
223
- interceptRenewToken,
224
- checkAdminPermission
259
+ interceptRenewToken
225
260
  };
226
261
  const bootstrap = async ({ strapi: strapi2 }) => {
227
262
  try {
@@ -230,66 +265,65 @@ const bootstrap = async ({ strapi: strapi2 }) => {
230
265
  section: "plugins",
231
266
  displayName: "Access Security Suite Plugin",
232
267
  uid: "access",
233
- pluginName: "strapi-security-suite"
268
+ pluginName: PLUGIN_ID
234
269
  },
235
270
  {
236
271
  section: "plugins",
237
272
  displayName: "View Configs",
238
273
  uid: "view-configs",
239
- pluginName: "strapi-security-suite"
274
+ pluginName: PLUGIN_ID
240
275
  },
241
276
  {
242
277
  section: "plugins",
243
278
  displayName: "Manage Configs",
244
279
  uid: "manage-configs",
245
- pluginName: "strapi-security-suite"
280
+ pluginName: PLUGIN_ID
246
281
  }
247
282
  ];
248
283
  await strapi2.admin.services.permission.actionProvider.registerMany(actions);
249
284
  } catch (error) {
250
- strapi2.log.error("โŒ Failed to register SecSuite Plugin permissions:", error);
285
+ strapi2.log.error(`[${PLUGIN_ID}] Failed to register permissions:`, error);
251
286
  }
252
287
  await ensureDefaultSecuritySettings(strapi2);
253
288
  strapi2.server.use(middlewares.preventMultipleSessions);
254
- strapi2.service("plugin::strapi-security-suite.autoLogoutChecker").startAutoLogoutWatcher();
289
+ strapi2.plugin(PLUGIN_ID).service(SERVICES.AUTO_LOGOUT_CHECKER).startAutoLogoutWatcher();
255
290
  };
256
291
  async function ensureDefaultSecuritySettings(strapi2) {
257
292
  try {
258
- const existing = await strapi2.entityService.findMany("plugin::strapi-security-suite.security-settings");
259
- if (Array.isArray(existing) && existing.length > 0) {
260
- strapi2.log.info("โœ… Default security settings already exist.");
293
+ const existing = await strapi2.documents(CONTENT_TYPES.SECURITY_SETTINGS).findFirst({});
294
+ if (existing) {
295
+ strapi2.log.info(`[${PLUGIN_ID}] Default security settings already exist.`);
261
296
  return;
262
297
  }
263
- const DEFAULT_SETTINGS = {
264
- autoLogoutTime: 30,
265
- multipleSessionsControl: true,
266
- passwordExpiryDays: 30,
267
- nonReusablePassword: true,
268
- enablePasswordManagement: true
269
- };
270
- await strapi2.db.query("plugin::strapi-security-suite.security-settings").create({ data: DEFAULT_SETTINGS });
271
- strapi2.log.info("โœ… Default security settings created successfully.");
298
+ await strapi2.documents(CONTENT_TYPES.SECURITY_SETTINGS).create({ data: DEFAULT_SETTINGS });
299
+ strapi2.log.info(`[${PLUGIN_ID}] Default security settings created successfully.`);
272
300
  } catch (error) {
273
- console.log(error);
274
- strapi2.log.error("โŒ Failed to ensure default security settings:", error);
301
+ strapi2.log.error(`[${PLUGIN_ID}] Failed to ensure default security settings:`, error);
275
302
  }
276
303
  }
277
304
  const destroy = ({ strapi: strapi2 }) => {
278
- strapi2.service("plugin::strapi-security-suite.autoLogoutChecker").stopAutoLogoutWatcher();
305
+ strapi2.plugin(PLUGIN_ID).service(SERVICES.AUTO_LOGOUT_CHECKER).stopAutoLogoutWatcher();
279
306
  };
280
307
  const register = ({ strapi: strapi2 }) => {
281
308
  strapi2.server.use(middlewares.seedUserInfos);
282
309
  strapi2.server.use(middlewares.interceptRenewToken);
283
310
  strapi2.server.use(middlewares.trackActivity);
284
311
  strapi2.server.use(middlewares.rejectRevokedTokens);
312
+ strapi2.log.info(`[${PLUGIN_ID}] Plugin registered successfully`);
285
313
  };
286
314
  const config = {
315
+ /** @type {object} Default plugin configuration (empty โ€” all config lives in the DB). */
287
316
  default: {},
288
- validator() {
317
+ /**
318
+ * Validates the plugin configuration object at startup.
319
+ * Currently a no-op; add schema checks here if external config is introduced.
320
+ *
321
+ * @param {object} _config - The configuration object to validate
322
+ */
323
+ validator(_config) {
289
324
  }
290
325
  };
291
326
  const kind = "singleType";
292
- const uid = "plugin::strapi-security-suite.security_settings";
293
327
  const info = {
294
328
  singularName: "security-settings",
295
329
  pluralName: "security-settings",
@@ -299,6 +333,14 @@ const info = {
299
333
  const options = {
300
334
  draftAndPublish: false
301
335
  };
336
+ const pluginOptions = {
337
+ "content-manager": {
338
+ visible: false
339
+ },
340
+ "content-type-builder": {
341
+ visible: false
342
+ }
343
+ };
302
344
  const attributes = {
303
345
  autoLogoutTime: {
304
346
  type: "integer",
@@ -325,9 +367,9 @@ const attributes = {
325
367
  };
326
368
  const schema = {
327
369
  kind,
328
- uid,
329
370
  info,
330
371
  options,
372
+ pluginOptions,
331
373
  attributes
332
374
  };
333
375
  const securitySettings = {
@@ -336,37 +378,137 @@ const securitySettings = {
336
378
  const contentTypes = {
337
379
  "security-settings": securitySettings
338
380
  };
381
+ class PluginError extends Error {
382
+ /**
383
+ * @param {string} message - Internal message (for logs)
384
+ * @param {string} sanitizedMessage - Safe message for the client
385
+ * @param {number} [statusCode=400] - HTTP status code
386
+ */
387
+ constructor(message, sanitizedMessage, statusCode = HTTP_STATUS.BAD_REQUEST) {
388
+ super(message);
389
+ this.name = "PluginError";
390
+ this.sanitizedMessage = sanitizedMessage;
391
+ this.statusCode = statusCode;
392
+ }
393
+ }
394
+ class ValidationError extends PluginError {
395
+ /**
396
+ * @param {string} message - Internal message
397
+ * @param {string} [sanitizedMessage='Validation failed.'] - Client-safe message
398
+ */
399
+ constructor(message, sanitizedMessage = "Validation failed.") {
400
+ super(message, sanitizedMessage, HTTP_STATUS.BAD_REQUEST);
401
+ this.name = "ValidationError";
402
+ }
403
+ }
404
+ const validateSettingsPayload = (body) => {
405
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
406
+ throw new ValidationError(
407
+ `Invalid payload type: ${typeof body}`,
408
+ ERROR_MESSAGES.INVALID_SETTINGS
409
+ );
410
+ }
411
+ const TYPE_RULES = {
412
+ autoLogoutTime: "number",
413
+ multipleSessionsControl: "boolean",
414
+ passwordExpiryDays: "number",
415
+ nonReusablePassword: "boolean",
416
+ enablePasswordManagement: "boolean"
417
+ };
418
+ const sanitized = {};
419
+ for (const key of VALID_SETTINGS_KEYS) {
420
+ if (!(key in body)) continue;
421
+ const value = body[key];
422
+ const expected = TYPE_RULES[key];
423
+ if (expected && typeof value !== expected) {
424
+ throw new ValidationError(
425
+ `Invalid type for "${key}": expected ${expected}, got ${typeof value}`,
426
+ ERROR_MESSAGES.INVALID_SETTINGS
427
+ );
428
+ }
429
+ sanitized[key] = value;
430
+ }
431
+ return sanitized;
432
+ };
339
433
  const adminSecurityController = ({ strapi: strapi2 }) => ({
434
+ /**
435
+ * Returns the current security settings.
436
+ *
437
+ * @param {import('koa').Context} ctx - Koa context
438
+ */
340
439
  async getSettings(ctx) {
341
- const settings = await strapi2.entityService.findMany("plugin::strapi-security-suite.security-settings");
342
- ctx.send(settings);
440
+ try {
441
+ const doc = await strapi2.documents(CONTENT_TYPES.SECURITY_SETTINGS).findFirst({});
442
+ const settings = {};
443
+ for (const key of VALID_SETTINGS_KEYS) {
444
+ if (doc && key in doc) settings[key] = doc[key];
445
+ }
446
+ ctx.body = { data: settings };
447
+ } catch (err) {
448
+ strapi2.log.error(`[${PLUGIN_ID}] getSettings error:`, err);
449
+ ctx.throw(
450
+ err.statusCode || HTTP_STATUS.BAD_REQUEST,
451
+ err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR
452
+ );
453
+ }
343
454
  },
455
+ /**
456
+ * Validates and persists updated security settings.
457
+ *
458
+ * If a settings record already exists it is updated; otherwise a new record
459
+ * is created. The request body is validated against allowed keys and types
460
+ * before any database write.
461
+ *
462
+ * @param {import('koa').Context} ctx - Koa context
463
+ */
344
464
  async saveSettings(ctx) {
345
- const data = ctx.request.body;
346
- const existing = await strapi2.entityService.findMany("plugin::strapi-security-suite.security-settings");
347
- if (existing?.id) {
348
- await strapi2.entityService.update("plugin::strapi-security-suite.security-settings", existing.id, { data });
349
- } else {
350
- await strapi2.entityService.create("plugin::strapi-security-suite.security-settings", { data });
465
+ try {
466
+ const data = validateSettingsPayload(ctx.request.body);
467
+ const existing = await strapi2.documents(CONTENT_TYPES.SECURITY_SETTINGS).findFirst({});
468
+ if (existing?.documentId) {
469
+ await strapi2.documents(CONTENT_TYPES.SECURITY_SETTINGS).update({ documentId: existing.documentId, data });
470
+ } else {
471
+ await strapi2.documents(CONTENT_TYPES.SECURITY_SETTINGS).create({ data });
472
+ }
473
+ ctx.body = { data: { message: "Settings saved successfully" } };
474
+ } catch (err) {
475
+ strapi2.log.error(`[${PLUGIN_ID}] saveSettings error:`, err);
476
+ ctx.throw(
477
+ err.statusCode || HTTP_STATUS.BAD_REQUEST,
478
+ err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR
479
+ );
351
480
  }
352
- ctx.send({ message: "Settings saved successfully" });
353
481
  }
354
482
  });
355
483
  const controllers = {
356
484
  adminSecurityController
357
485
  };
358
- const policies = {};
359
- const routes = [
486
+ const hasAdminPermission = async (policyContext, config2, { strapi: strapi2 }) => {
487
+ const user = policyContext.state.user;
488
+ if (!user) {
489
+ return false;
490
+ }
491
+ const requiredPermission = config2?.permission;
492
+ if (!requiredPermission) {
493
+ return true;
494
+ }
495
+ const [roleId] = user.roles.map((role) => role.id);
496
+ const permissions = await strapi2.admin.services.permission.findMany({
497
+ where: {
498
+ role: roleId,
499
+ action: requiredPermission
500
+ }
501
+ });
502
+ return permissions.length > 0;
503
+ };
504
+ const policies = {
505
+ "has-admin-permission": hasAdminPermission
506
+ };
507
+ const admin = [
360
508
  {
361
509
  method: "GET",
362
510
  path: "/health",
363
- handler: async (ctx) => {
364
- ctx.send({
365
- status: "ok",
366
- uptime: process.uptime(),
367
- timestamp: Date.now()
368
- });
369
- },
511
+ handler: "adminSecurityController.getSettings",
370
512
  config: {
371
513
  auth: false
372
514
  }
@@ -376,9 +518,12 @@ const routes = [
376
518
  path: "/admin/settings",
377
519
  handler: "adminSecurityController.getSettings",
378
520
  config: {
379
- policies: [],
380
- auth: false,
381
- middlewares: [middlewares.checkAdminPermission("plugin::strapi-security-suite.view-configs")]
521
+ policies: [
522
+ {
523
+ name: "plugin::strapi-security-suite.has-admin-permission",
524
+ config: { permission: PERMISSIONS.VIEW_CONFIGS }
525
+ }
526
+ ]
382
527
  }
383
528
  },
384
529
  {
@@ -386,68 +531,87 @@ const routes = [
386
531
  path: "/admin/settings",
387
532
  handler: "adminSecurityController.saveSettings",
388
533
  config: {
389
- policies: [],
390
- auth: false,
391
- middlewares: [middlewares.checkAdminPermission("plugin::strapi-security-suite.manage-configs")]
534
+ policies: [
535
+ {
536
+ name: "plugin::strapi-security-suite.has-admin-permission",
537
+ config: { permission: PERMISSIONS.MANAGE_CONFIGS }
538
+ }
539
+ ]
392
540
  }
393
541
  }
394
542
  ];
543
+ const routes = {
544
+ admin: {
545
+ type: "admin",
546
+ routes: admin
547
+ }
548
+ };
395
549
  let interval = null;
396
550
  const autoLogoutChecker = ({ strapi: strapi2 }) => ({
397
551
  /**
398
- * Starts the auto-logout watcher that checks session activity every X ms.
552
+ * Starts the auto-logout watcher interval.
553
+ *
554
+ * Runs every {@link CHECK_INTERVAL} ms, reads the configured `autoLogoutTime`
555
+ * from the database, and revokes tokens for sessions that have been idle
556
+ * beyond the threshold.
399
557
  */
400
558
  startAutoLogoutWatcher() {
401
559
  if (interval) {
402
- strapi2.log.warn("โš ๏ธ AutoLogoutWatcher already running.");
560
+ strapi2.log.warn(`[${PLUGIN_ID}] AutoLogoutWatcher already running.`);
403
561
  return;
404
562
  }
405
563
  interval = setInterval(async () => {
406
564
  try {
407
- const settings = await strapi2.entityService.findMany("plugin::strapi-security-suite.security-settings", {});
408
- const autoLogoutTime = (settings?.autoLogoutTime ?? 30) * 6e4;
565
+ pruneExpiredTokens();
566
+ const settings = await strapi2.documents(CONTENT_TYPES.SECURITY_SETTINGS).findFirst({});
567
+ const autoLogoutTime = (settings?.autoLogoutTime ?? DEFAULT_AUTOLOGOUT_TIME) * MS_PER_MINUTE;
409
568
  const now = Date.now();
410
569
  for (const [key, lastActive] of sessionActivityMap.entries()) {
411
570
  const [adminId, email] = key.split(":");
412
571
  const idleDuration = now - lastActive;
413
- if (idleDuration > autoLogoutTime) {
414
- if (!revokedTokenSet.has(email)) {
415
- revokedTokenSet.add(email);
416
- sessionActivityMap.delete(key);
417
- strapi2.log.info(`๐Ÿ”’ Auto-logged out admin "${email}" after ${Math.floor(idleDuration / 1e3)}s of inactivity.`);
418
- }
572
+ if (idleDuration <= autoLogoutTime || revokedTokenSet.has(email)) continue;
573
+ revokedTokenSet.add(email);
574
+ sessionActivityMap.delete(key);
575
+ if (strapi2.sessionManager) {
576
+ await strapi2.sessionManager("admin").invalidateRefreshToken(String(adminId)).catch(
577
+ (e) => strapi2.log.error(`[${PLUGIN_ID}] Failed to invalidate DB session for ${email}:`, e)
578
+ );
419
579
  }
580
+ strapi2.log.info(
581
+ `[${PLUGIN_ID}] Auto-logged out admin "${email}" after ${Math.floor(idleDuration / MS_PER_SECOND)}s of inactivity.`
582
+ );
420
583
  }
421
584
  } catch (err) {
422
- strapi2.log.error("โŒ AutoLogoutWatcher failed:", err);
585
+ strapi2.log.error(`[${PLUGIN_ID}] AutoLogoutWatcher failed:`, err);
423
586
  }
424
587
  }, CHECK_INTERVAL);
425
588
  },
426
589
  /**
427
- * Stops the auto-logout watcher.
590
+ * Stops the auto-logout watcher interval and releases the timer reference.
428
591
  */
429
592
  stopAutoLogoutWatcher() {
430
593
  if (interval) {
431
594
  clearInterval(interval);
432
595
  interval = null;
433
- strapi2.log.info("๐Ÿ›‘ AutoLogoutWatcher stopped.");
596
+ strapi2.log.info(`[${PLUGIN_ID}] AutoLogoutWatcher stopped.`);
434
597
  }
435
598
  },
436
599
  /**
437
- * Manually clear session activity for a user.
438
- * @param {string} adminId
439
- * @param {string} email
440
- * @param {string} reason
600
+ * Manually clears session activity and revoked state for a user.
601
+ *
602
+ * @param {string} adminId - Admin user ID
603
+ * @param {string} email - Admin email address
604
+ * @param {string} [reason='manual'] - Reason for clearing (used in log messages)
441
605
  */
442
606
  clearSessionActivity(adminId, email, reason = "manual") {
443
607
  const key = `${adminId}:${email}`;
444
608
  if (sessionActivityMap.has(key)) {
445
609
  sessionActivityMap.delete(key);
446
- strapi2.log.info(`๐Ÿงน Session cleared for ${key} (${reason})`);
610
+ strapi2.log.info(`[${PLUGIN_ID}] Session cleared for ${key} (${reason})`);
447
611
  }
448
612
  if (revokedTokenSet.has(email)) {
449
613
  revokedTokenSet.delete(email);
450
- strapi2.log.info(`โœ… Revoked token cleared for ${email}`);
614
+ strapi2.log.info(`[${PLUGIN_ID}] Revoked token cleared for ${email}`);
451
615
  }
452
616
  }
453
617
  });