strapi-security-suite 0.2.4 โ†’ 0.3.0

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