strapi-security-suite 0.3.3 → 0.4.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,36 +1,34 @@
1
1
  "use strict";
2
2
  const jwt = require("jsonwebtoken");
3
+ const node_crypto = require("node:crypto");
3
4
  const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
4
5
  const jwt__default = /* @__PURE__ */ _interopDefault(jwt);
5
- const revokedTokenSet = /* @__PURE__ */ new Set();
6
- const revokedConnectionTokens = /* @__PURE__ */ new Map();
7
- const TOKEN_TTL = 30 * 60 * 1e3;
8
- function pruneExpiredTokens() {
9
- const cutoff = Date.now() - TOKEN_TTL;
10
- for (const [token, revokedAt] of revokedConnectionTokens) {
11
- if (revokedAt < cutoff) {
12
- revokedConnectionTokens.delete(token);
13
- }
14
- }
15
- }
16
- const sessionActivityMap = /* @__PURE__ */ new Map();
17
6
  const PLUGIN_ID = "strapi-security-suite";
18
7
  const CONTENT_TYPES = {
19
- SECURITY_SETTINGS: `plugin::${PLUGIN_ID}.security-settings`
8
+ SECURITY_SETTINGS: `plugin::${PLUGIN_ID}.security-settings`,
9
+ ADMIN_SESSION: `plugin::${PLUGIN_ID}.admin-session`,
10
+ LOGIN_LOCK: `plugin::${PLUGIN_ID}.login-lock`,
11
+ WATCHER_LEASE: `plugin::${PLUGIN_ID}.watcher-lease`
20
12
  };
21
13
  const SERVICES = {
22
- AUTO_LOGOUT_CHECKER: "autoLogoutChecker"
14
+ AUTO_LOGOUT_CHECKER: "autoLogoutChecker",
15
+ STATE: "state"
23
16
  };
24
17
  const CTX_ADMIN_USER = Symbol.for("security-suite:adminUser");
18
+ const WATCHER_LEASE_NAME = "autologout-watcher";
25
19
  const CHECK_INTERVAL = 5e3;
26
20
  const DEFAULT_AUTOLOGOUT_TIME = 30;
27
21
  const MS_PER_MINUTE = 6e4;
28
22
  const MS_PER_SECOND = 1e3;
23
+ const ACTIVITY_FLUSH_INTERVAL_MS = 30 * MS_PER_SECOND;
24
+ const LOGIN_LOCK_TTL_MS = 10 * MS_PER_SECOND;
25
+ const WATCHER_LEASE_TTL_MS = 15 * MS_PER_SECOND;
29
26
  const LOGIN_PATH = "/admin/login";
30
27
  const LOGOUT_PATH = "/admin/logout";
31
28
  const ACCESS_TOKEN_PATH = "/access-token";
32
29
  const CONTENT_PATH = "/content";
33
30
  const HTTP_STATUS = {
31
+ NO_CONTENT: 204,
34
32
  BAD_REQUEST: 400,
35
33
  FORBIDDEN: 403,
36
34
  CONFLICT: 409
@@ -119,9 +117,9 @@ function clearSessionCookies(ctx) {
119
117
  }
120
118
  async function trackActivity(ctx, next) {
121
119
  const adminUser = ctx.state[CTX_ADMIN_USER];
122
- let key = adminUser?.id ? `${adminUser.id}:${adminUser.email}` : null;
123
- const bearerToken = ctx.get("authorization")?.split("Bearer ")[1];
124
- if (bearerToken && revokedConnectionTokens.has(bearerToken)) {
120
+ const state2 = strapi.plugin(PLUGIN_ID).service(SERVICES.STATE);
121
+ if (adminUser?.sessionId && await state2.isRevoked(adminUser.sessionId)) {
122
+ clearSessionCookies(ctx);
125
123
  ctx.status = HTTP_STATUS.FORBIDDEN;
126
124
  ctx.body = {
127
125
  error: {
@@ -134,21 +132,23 @@ async function trackActivity(ctx, next) {
134
132
  }
135
133
  if (ctx.path.includes(LOGOUT_PATH)) {
136
134
  clearSessionCookies(ctx);
137
- key = null;
135
+ return await next();
138
136
  }
139
- if (key) {
140
- sessionActivityMap.set(key, Date.now());
141
- strapi.log.debug(`[${PLUGIN_ID}] Activity updated: ${key}`);
137
+ if (adminUser?.sessionId && adminUser?.id && adminUser?.email) {
138
+ await state2.touch({
139
+ sessionId: adminUser.sessionId,
140
+ userId: adminUser.id,
141
+ email: adminUser.email
142
+ });
143
+ strapi.log.debug(`[${PLUGIN_ID}] Activity touched: ${adminUser.id}:${adminUser.email}`);
142
144
  }
143
145
  await next();
144
146
  }
145
- const loginLocks = /* @__PURE__ */ new Set();
146
- function cleanupLoginState(ctx) {
147
+ async function cleanupLoginState(ctx) {
147
148
  const email = ctx.request.body?.email;
148
- loginLocks.delete(email);
149
- if (email && ctx.status < 400) {
150
- revokedTokenSet.delete(email);
151
- }
149
+ if (!email) return;
150
+ const state2 = strapi.plugin(PLUGIN_ID).service(SERVICES.STATE);
151
+ await state2.releaseLoginLock({ email });
152
152
  }
153
153
  async function preventMultipleSessions(ctx, next) {
154
154
  const isLoginPost = ctx.path === LOGIN_PATH && ctx.method === "POST";
@@ -161,21 +161,32 @@ async function preventMultipleSessions(ctx, next) {
161
161
  );
162
162
  return await next();
163
163
  }
164
+ const { email } = ctx.request.body ?? {};
165
+ if (!email) {
166
+ strapi.log.warn(`[${PLUGIN_ID}] Email missing in login request. Skipping session lock.`);
167
+ return await next();
168
+ }
169
+ const state2 = strapi.plugin(PLUGIN_ID).service(SERVICES.STATE);
170
+ let lockAcquired = false;
164
171
  try {
165
- const { email } = ctx.request.body ?? {};
166
- if (!email) {
167
- strapi.log.warn(`[${PLUGIN_ID}] Email missing in login request. Skipping session lock.`);
168
- return await next();
169
- }
170
172
  const settings = await strapi.documents(CONTENT_TYPES.SECURITY_SETTINGS).findFirst({});
171
173
  if (!settings?.multipleSessionsControl) return await next();
172
- const hasActiveSession = Array.from(sessionActivityMap.keys()).some(
173
- (key) => key.endsWith(`:${email}`)
174
- );
175
- if (hasActiveSession || loginLocks.has(email)) {
176
- strapi.log.warn(
177
- `[${PLUGIN_ID}] Login blocked for ${email}: already logged in or logging in.`
178
- );
174
+ const idleThresholdMs = (settings?.autoLogoutTime ?? DEFAULT_AUTOLOGOUT_TIME) * MS_PER_MINUTE;
175
+ const hasActive = await state2.hasActiveSession({ email, idleThresholdMs });
176
+ if (hasActive) {
177
+ strapi.log.warn(`[${PLUGIN_ID}] Login blocked for ${email}: already logged in.`);
178
+ ctx.status = HTTP_STATUS.CONFLICT;
179
+ ctx.body = {
180
+ error: {
181
+ status: HTTP_STATUS.CONFLICT,
182
+ message: ERROR_MESSAGES.MULTIPLE_SESSIONS
183
+ }
184
+ };
185
+ return;
186
+ }
187
+ lockAcquired = await state2.acquireLoginLock({ email });
188
+ if (!lockAcquired) {
189
+ strapi.log.warn(`[${PLUGIN_ID}] Login blocked for ${email}: another login is in flight.`);
179
190
  ctx.status = HTTP_STATUS.CONFLICT;
180
191
  ctx.body = {
181
192
  error: {
@@ -185,44 +196,40 @@ async function preventMultipleSessions(ctx, next) {
185
196
  };
186
197
  return;
187
198
  }
188
- loginLocks.add(email);
189
199
  } catch (err) {
190
200
  strapi.log.error(`[${PLUGIN_ID}] Error in preventMultipleSessions:`, err);
191
201
  }
192
202
  try {
193
203
  await next();
194
204
  } finally {
195
- cleanupLoginState(ctx);
205
+ if (lockAcquired) {
206
+ await cleanupLoginState(ctx);
207
+ }
196
208
  }
197
209
  }
198
210
  async function rejectRevokedTokens(ctx, next) {
199
211
  const adminUser = ctx.state[CTX_ADMIN_USER];
200
- if (!adminUser?.email) return await next();
201
- const { id, email: adminEmail } = adminUser;
202
- const key = id && adminEmail ? `${id}:${adminEmail}` : null;
212
+ if (!adminUser?.sessionId) return await next();
213
+ const state2 = strapi.plugin(PLUGIN_ID).service(SERVICES.STATE);
203
214
  try {
204
- if (adminEmail && revokedTokenSet.has(adminEmail)) {
205
- ctx.set(HEADERS.ADMIN_TOKEN_SIGNAL, adminEmail);
206
- ctx.set(HEADERS.EXPOSE_HEADERS, HEADERS.ADMIN_TOKEN_SIGNAL);
207
- clearSessionCookies(ctx);
208
- const bearerToken = ctx.get("authorization")?.split("Bearer ")[1];
209
- if (bearerToken) {
210
- revokedConnectionTokens.set(bearerToken, Date.now());
211
- }
212
- sessionActivityMap.delete(key);
213
- revokedTokenSet.delete(adminEmail);
214
- strapi.plugin(PLUGIN_ID).service(SERVICES.AUTO_LOGOUT_CHECKER).clearSessionActivity(id, adminEmail);
215
- try {
216
- if (strapi.sessionManager) {
217
- await strapi.sessionManager("admin").invalidateRefreshToken(String(id));
218
- }
219
- } catch (err) {
220
- strapi.log.error(`[${PLUGIN_ID}] Failed to invalidate DB session for ${adminEmail}:`, err);
215
+ const revoked = await state2.isRevoked(adminUser.sessionId);
216
+ if (!revoked) return await next();
217
+ ctx.set(HEADERS.ADMIN_TOKEN_SIGNAL, adminUser.email || "");
218
+ ctx.set(HEADERS.EXPOSE_HEADERS, HEADERS.ADMIN_TOKEN_SIGNAL);
219
+ clearSessionCookies(ctx);
220
+ try {
221
+ if (strapi.sessionManager && adminUser.id) {
222
+ await strapi.sessionManager("admin").invalidateRefreshToken(String(adminUser.id));
221
223
  }
222
- strapi.log.info(`[${PLUGIN_ID}] Session revoked: ${adminEmail}`);
223
- await next();
224
- return;
224
+ } catch (err) {
225
+ strapi.log.error(
226
+ `[${PLUGIN_ID}] Failed to invalidate DB session for ${adminUser.email}:`,
227
+ err
228
+ );
225
229
  }
230
+ strapi.log.info(
231
+ `[${PLUGIN_ID}] Session revoked: ${adminUser.email} (session ${adminUser.sessionId})`
232
+ );
226
233
  } catch (err) {
227
234
  strapi.log.error(`[${PLUGIN_ID}] Error in rejectRevokedTokens middleware:`, err);
228
235
  }
@@ -232,15 +239,11 @@ async function interceptRenewToken(ctx, next) {
232
239
  if (ctx.path.includes(LOGOUT_PATH)) {
233
240
  const adminUser = ctx.state[CTX_ADMIN_USER];
234
241
  strapi.log.debug(`[${PLUGIN_ID}] Logout captured: ${JSON.stringify(adminUser)}`);
235
- if (adminUser?.id) {
236
- strapi.plugin(PLUGIN_ID).service(SERVICES.AUTO_LOGOUT_CHECKER).clearSessionActivity(adminUser.id, adminUser.email);
237
- const bearerToken = ctx.get("authorization")?.split("Bearer ")[1];
238
- if (bearerToken) {
239
- revokedConnectionTokens.set(bearerToken, Date.now());
240
- }
241
- clearSessionCookies(ctx);
242
- sessionActivityMap.delete(`${adminUser.id}:${adminUser.email}`);
242
+ if (adminUser?.sessionId) {
243
+ const state2 = strapi.plugin(PLUGIN_ID).service(SERVICES.STATE);
244
+ await state2.revokeSession({ sessionId: adminUser.sessionId });
243
245
  }
246
+ clearSessionCookies(ctx);
244
247
  await next();
245
248
  return;
246
249
  }
@@ -262,6 +265,7 @@ async function seedUserInfos(ctx, next) {
262
265
  if (!token) return await next();
263
266
  const decodedToken = jwt__default.default.decode(token);
264
267
  const adminId = decodedToken?.userId;
268
+ const sessionId = decodedToken?.sessionId;
265
269
  if (!adminId || ctx.state[CTX_ADMIN_USER]?.id) {
266
270
  return await next();
267
271
  }
@@ -275,16 +279,13 @@ async function seedUserInfos(ctx, next) {
275
279
  }
276
280
  ctx.state[CTX_ADMIN_USER] = {
277
281
  id: adminUser.id,
282
+ sessionId,
278
283
  email: adminUser.email,
279
284
  firstname: adminUser.firstname,
280
285
  lastname: adminUser.lastname,
281
286
  roles: adminUser.roles
282
287
  };
283
- const key = `${adminUser.id}:${adminUser.email}`;
284
- if (!sessionActivityMap.has(key)) {
285
- sessionActivityMap.set(key, Date.now());
286
- }
287
- strapi.log.debug(`[${PLUGIN_ID}] Admin identified: ${adminUser.email}`);
288
+ strapi.log.debug(`[${PLUGIN_ID}] Admin identified: ${adminUser.email} (session ${sessionId})`);
288
289
  return await next();
289
290
  } catch (error) {
290
291
  strapi.log.error(`[${PLUGIN_ID}] Failed to decode or hydrate admin token:`, error);
@@ -341,8 +342,8 @@ async function ensureDefaultSecuritySettings(strapi2) {
341
342
  strapi2.log.error(`[${PLUGIN_ID}] Failed to ensure default security settings:`, error);
342
343
  }
343
344
  }
344
- const destroy = ({ strapi: strapi2 }) => {
345
- strapi2.plugin(PLUGIN_ID).service(SERVICES.AUTO_LOGOUT_CHECKER).stopAutoLogoutWatcher();
345
+ const destroy = async ({ strapi: strapi2 }) => {
346
+ await strapi2.plugin(PLUGIN_ID).service(SERVICES.AUTO_LOGOUT_CHECKER).stopAutoLogoutWatcher();
346
347
  };
347
348
  const register = ({ strapi: strapi2 }) => {
348
349
  strapi2.server.use(middlewares.seedUserInfos);
@@ -363,17 +364,17 @@ const config = {
363
364
  validator(_config) {
364
365
  }
365
366
  };
366
- const kind = "singleType";
367
- const info = {
367
+ const kind$3 = "singleType";
368
+ const info$3 = {
368
369
  singularName: "security-settings",
369
370
  pluralName: "security-settings",
370
371
  displayName: "Security Settings",
371
372
  description: "Stores security and session settings"
372
373
  };
373
- const options = {
374
+ const options$3 = {
374
375
  draftAndPublish: false
375
376
  };
376
- const pluginOptions = {
377
+ const pluginOptions$3 = {
377
378
  "content-manager": {
378
379
  visible: false
379
380
  },
@@ -381,7 +382,7 @@ const pluginOptions = {
381
382
  visible: false
382
383
  }
383
384
  };
384
- const attributes = {
385
+ const attributes$3 = {
385
386
  autoLogoutTime: {
386
387
  type: "integer",
387
388
  required: true,
@@ -405,18 +406,157 @@ const attributes = {
405
406
  "default": true
406
407
  }
407
408
  };
409
+ const schema$3 = {
410
+ kind: kind$3,
411
+ info: info$3,
412
+ options: options$3,
413
+ pluginOptions: pluginOptions$3,
414
+ attributes: attributes$3
415
+ };
416
+ const securitySettings = {
417
+ schema: schema$3
418
+ };
419
+ const kind$2 = "collectionType";
420
+ const collectionName$2 = "sss_admin_sessions";
421
+ const info$2 = {
422
+ singularName: "admin-session",
423
+ pluralName: "admin-sessions",
424
+ displayName: "Admin Session",
425
+ description: "Per-session activity and revocation state for admin users (managed by strapi-security-suite)."
426
+ };
427
+ const options$2 = {
428
+ draftAndPublish: false
429
+ };
430
+ const pluginOptions$2 = {
431
+ "content-manager": {
432
+ visible: false
433
+ },
434
+ "content-type-builder": {
435
+ visible: false
436
+ }
437
+ };
438
+ const attributes$2 = {
439
+ sessionId: {
440
+ type: "string",
441
+ required: true,
442
+ unique: true
443
+ },
444
+ userId: {
445
+ type: "integer",
446
+ required: true
447
+ },
448
+ email: {
449
+ type: "string",
450
+ required: true
451
+ },
452
+ lastActiveAt: {
453
+ type: "datetime",
454
+ required: true
455
+ },
456
+ revokedAt: {
457
+ type: "datetime"
458
+ }
459
+ };
460
+ const schema$2 = {
461
+ kind: kind$2,
462
+ collectionName: collectionName$2,
463
+ info: info$2,
464
+ options: options$2,
465
+ pluginOptions: pluginOptions$2,
466
+ attributes: attributes$2
467
+ };
468
+ const adminSession = {
469
+ schema: schema$2
470
+ };
471
+ const kind$1 = "collectionType";
472
+ const collectionName$1 = "sss_login_locks";
473
+ const info$1 = {
474
+ singularName: "login-lock",
475
+ pluralName: "login-locks",
476
+ displayName: "Login Lock",
477
+ description: "Cross-pod login lock keyed by email (managed by strapi-security-suite)."
478
+ };
479
+ const options$1 = {
480
+ draftAndPublish: false
481
+ };
482
+ const pluginOptions$1 = {
483
+ "content-manager": {
484
+ visible: false
485
+ },
486
+ "content-type-builder": {
487
+ visible: false
488
+ }
489
+ };
490
+ const attributes$1 = {
491
+ email: {
492
+ type: "string",
493
+ required: true,
494
+ unique: true
495
+ },
496
+ lockedUntil: {
497
+ type: "datetime",
498
+ required: true
499
+ }
500
+ };
501
+ const schema$1 = {
502
+ kind: kind$1,
503
+ collectionName: collectionName$1,
504
+ info: info$1,
505
+ options: options$1,
506
+ pluginOptions: pluginOptions$1,
507
+ attributes: attributes$1
508
+ };
509
+ const loginLock = {
510
+ schema: schema$1
511
+ };
512
+ const kind = "collectionType";
513
+ const collectionName = "sss_watcher_leases";
514
+ const info = {
515
+ singularName: "watcher-lease",
516
+ pluralName: "watcher-leases",
517
+ displayName: "Watcher Lease",
518
+ description: "Cross-pod leader-election lease for the auto-logout watcher (managed by strapi-security-suite)."
519
+ };
520
+ const options = {
521
+ draftAndPublish: false
522
+ };
523
+ const pluginOptions = {
524
+ "content-manager": {
525
+ visible: false
526
+ },
527
+ "content-type-builder": {
528
+ visible: false
529
+ }
530
+ };
531
+ const attributes = {
532
+ name: {
533
+ type: "string",
534
+ required: true,
535
+ unique: true
536
+ },
537
+ holder: {
538
+ type: "string"
539
+ },
540
+ expiresAt: {
541
+ type: "datetime"
542
+ }
543
+ };
408
544
  const schema = {
409
545
  kind,
546
+ collectionName,
410
547
  info,
411
548
  options,
412
549
  pluginOptions,
413
550
  attributes
414
551
  };
415
- const securitySettings = {
552
+ const watcherLease = {
416
553
  schema
417
554
  };
418
555
  const contentTypes = {
419
- "security-settings": securitySettings
556
+ "security-settings": securitySettings,
557
+ "admin-session": adminSession,
558
+ "login-lock": loginLock,
559
+ "watcher-lease": watcherLease
420
560
  };
421
561
  const validateSettingsPayload = (body) => {
422
562
  if (!body || typeof body !== "object" || Array.isArray(body)) {
@@ -495,6 +635,18 @@ const adminSecurityController = ({ strapi: strapi2 }) => ({
495
635
  err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR
496
636
  );
497
637
  }
638
+ },
639
+ /**
640
+ * Lightweight keep-alive endpoint hit by the admin client while the user
641
+ * is actively interacting with the UI (mouse / keyboard). The middleware
642
+ * chain (`seedUserInfos` → `trackActivity`) does the actual work of
643
+ * persisting `lastActiveAt` for the current session, so the handler
644
+ * itself only needs to return 204.
645
+ *
646
+ * @param {import('koa').Context} ctx - Koa context
647
+ */
648
+ heartbeat(ctx) {
649
+ ctx.status = HTTP_STATUS.NO_CONTENT;
498
650
  }
499
651
  });
500
652
  const controllers = {
@@ -523,11 +675,11 @@ const policies = {
523
675
  };
524
676
  const admin = [
525
677
  {
526
- method: "GET",
527
- path: "/health",
528
- handler: "adminSecurityController.getSettings",
678
+ method: "POST",
679
+ path: "/heartbeat",
680
+ handler: "adminSecurityController.heartbeat",
529
681
  config: {
530
- auth: false
682
+ // No policy: any authenticated admin may keep their session alive
531
683
  }
532
684
  },
533
685
  {
@@ -566,74 +718,368 @@ const routes = {
566
718
  let interval = null;
567
719
  const autoLogoutChecker = ({ strapi: strapi2 }) => ({
568
720
  /**
569
- * Starts the auto-logout watcher interval.
570
- *
571
- * Runs every {@link CHECK_INTERVAL} ms, reads the configured `autoLogoutTime`
572
- * from the database, and revokes tokens for sessions that have been idle
573
- * beyond the threshold.
721
+ * Starts the auto-logout watcher interval. Idempotent — calling twice
722
+ * leaves the existing interval intact.
574
723
  */
575
724
  startAutoLogoutWatcher() {
576
725
  if (interval) {
577
726
  strapi2.log.warn(`[${PLUGIN_ID}] AutoLogoutWatcher already running.`);
578
727
  return;
579
728
  }
729
+ const state2 = strapi2.plugin(PLUGIN_ID).service(SERVICES.STATE);
580
730
  interval = setInterval(async () => {
581
731
  try {
582
- pruneExpiredTokens();
732
+ const isLeader = await state2.acquireWatcherLease();
733
+ if (!isLeader) return;
734
+ await state2.pruneExpiredLocks();
583
735
  const settings = await strapi2.documents(CONTENT_TYPES.SECURITY_SETTINGS).findFirst({});
584
- const autoLogoutTime = (settings?.autoLogoutTime ?? DEFAULT_AUTOLOGOUT_TIME) * MS_PER_MINUTE;
585
- const now = Date.now();
586
- for (const [key, lastActive] of sessionActivityMap.entries()) {
587
- const [adminId, email] = key.split(":");
588
- const idleDuration = now - lastActive;
589
- if (idleDuration <= autoLogoutTime || revokedTokenSet.has(email)) continue;
590
- revokedTokenSet.add(email);
591
- sessionActivityMap.delete(key);
592
- if (strapi2.sessionManager) {
593
- await strapi2.sessionManager("admin").invalidateRefreshToken(String(adminId)).catch(
594
- (e) => strapi2.log.error(`[${PLUGIN_ID}] Failed to invalidate DB session for ${email}:`, e)
736
+ const idleThresholdMs = (settings?.autoLogoutTime ?? DEFAULT_AUTOLOGOUT_TIME) * MS_PER_MINUTE;
737
+ const idleSessions = await state2.listIdleSessions({ idleThresholdMs });
738
+ if (idleSessions.length === 0) return;
739
+ for (const session of idleSessions) {
740
+ await state2.revokeSession({ sessionId: session.sessionId });
741
+ if (strapi2.sessionManager && session.userId) {
742
+ await strapi2.sessionManager("admin").invalidateRefreshToken(String(session.userId)).catch(
743
+ (e) => strapi2.log.error(
744
+ `[${PLUGIN_ID}] Failed to invalidate DB session for ${session.email}:`,
745
+ e
746
+ )
595
747
  );
596
748
  }
749
+ const idleSeconds = Math.floor(
750
+ (Date.now() - new Date(session.lastActiveAt).getTime()) / MS_PER_SECOND
751
+ );
597
752
  strapi2.log.info(
598
- `[${PLUGIN_ID}] Auto-logged out admin "${email}" after ${Math.floor(idleDuration / MS_PER_SECOND)}s of inactivity.`
753
+ `[${PLUGIN_ID}] Auto-logged out admin "${session.email}" after ${idleSeconds}s of inactivity.`
599
754
  );
600
755
  }
601
756
  } catch (err) {
602
- strapi2.log.error(`[${PLUGIN_ID}] AutoLogoutWatcher failed:`, err);
757
+ strapi2.log.error(`[${PLUGIN_ID}] AutoLogoutWatcher tick failed:`, err);
603
758
  }
604
759
  }, CHECK_INTERVAL);
605
760
  },
606
761
  /**
607
- * Stops the auto-logout watcher interval and releases the timer reference.
608
- */
609
- stopAutoLogoutWatcher() {
610
- if (interval) {
611
- clearInterval(interval);
612
- interval = null;
613
- strapi2.log.info(`[${PLUGIN_ID}] AutoLogoutWatcher stopped.`);
614
- }
615
- },
616
- /**
617
- * Manually clears session activity and revoked state for a user.
762
+ * Stops the auto-logout watcher interval and releases the lease so another
763
+ * pod can pick it up immediately rather than waiting for TTL expiry.
618
764
  *
619
- * @param {string} adminId - Admin user ID
620
- * @param {string} email - Admin email address
621
- * @param {string} [reason='manual'] - Reason for clearing (used in log messages)
765
+ * @returns {Promise<void>}
622
766
  */
623
- clearSessionActivity(adminId, email, reason = "manual") {
624
- const key = `${adminId}:${email}`;
625
- if (sessionActivityMap.has(key)) {
626
- sessionActivityMap.delete(key);
627
- strapi2.log.info(`[${PLUGIN_ID}] Session cleared for ${key} (${reason})`);
628
- }
629
- if (revokedTokenSet.has(email)) {
630
- revokedTokenSet.delete(email);
631
- strapi2.log.info(`[${PLUGIN_ID}] Revoked token cleared for ${email}`);
632
- }
767
+ async stopAutoLogoutWatcher() {
768
+ if (!interval) return;
769
+ clearInterval(interval);
770
+ interval = null;
771
+ const state2 = strapi2.plugin(PLUGIN_ID).service(SERVICES.STATE);
772
+ await state2.releaseWatcherLease();
773
+ strapi2.log.info(`[${PLUGIN_ID}] AutoLogoutWatcher stopped.`);
633
774
  }
634
775
  });
776
+ const lastDbWriteAt = /* @__PURE__ */ new Map();
777
+ const POD_ID = `${process.env.HOSTNAME ?? "pod"}-${node_crypto.randomUUID()}`;
778
+ const isUniqueConstraintError = (err) => {
779
+ if (!err) return false;
780
+ const msg = String(err.message ?? "");
781
+ return err.code === "23505" || err.code === "ER_DUP_ENTRY" || err.errno === 1062 || err.errno === 19 || /unique/i.test(msg) || /duplicate/i.test(msg);
782
+ };
783
+ const columnFor = (strapi2, uid, attribute) => {
784
+ try {
785
+ const metadata = strapi2.db.metadata.get(uid);
786
+ const attr = metadata?.attributes?.[attribute];
787
+ if (attr?.columnName) return attr.columnName;
788
+ } catch {
789
+ }
790
+ return attribute.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
791
+ };
792
+ const state = ({ strapi: strapi2 }) => {
793
+ const sessionUid = CONTENT_TYPES.ADMIN_SESSION;
794
+ const lockUid = CONTENT_TYPES.LOGIN_LOCK;
795
+ const leaseUid = CONTENT_TYPES.WATCHER_LEASE;
796
+ const tableOf = (uid) => strapi2.db.metadata.get(uid).tableName;
797
+ return {
798
+ /** Stable per-pod identifier; exposed for diagnostics. */
799
+ POD_ID,
800
+ /**
801
+ * Persists the user's last-active timestamp, write-coalesced to once
802
+ * per {@link ACTIVITY_FLUSH_INTERVAL_MS} per session.
803
+ *
804
+ * On first call for a session, performs an INSERT; subsequent calls
805
+ * UPDATE the existing row. A unique-constraint conflict (two pods
806
+ * inserting concurrently) falls back to UPDATE.
807
+ *
808
+ * @param {object} params
809
+ * @param {string} params.sessionId - JWT sessionId
810
+ * @param {number|string} params.userId - Admin user ID
811
+ * @param {string} params.email - Admin email
812
+ * @returns {Promise<void>}
813
+ */
814
+ async touch({ sessionId, userId, email }) {
815
+ if (!sessionId || !userId || !email) return;
816
+ const now = Date.now();
817
+ const lastWrite = lastDbWriteAt.get(sessionId) ?? 0;
818
+ if (now - lastWrite < ACTIVITY_FLUSH_INTERVAL_MS) return;
819
+ lastDbWriteAt.set(sessionId, now);
820
+ const lastActiveAt = new Date(now);
821
+ try {
822
+ const existing = await strapi2.db.query(sessionUid).findOne({ where: { sessionId }, select: ["id"] });
823
+ if (existing) {
824
+ await strapi2.db.query(sessionUid).update({
825
+ where: { id: existing.id },
826
+ data: { lastActiveAt }
827
+ });
828
+ return;
829
+ }
830
+ await strapi2.db.query(sessionUid).create({
831
+ data: { sessionId, userId, email, lastActiveAt }
832
+ });
833
+ } catch (err) {
834
+ if (isUniqueConstraintError(err)) {
835
+ await strapi2.db.query(sessionUid).update({ where: { sessionId }, data: { lastActiveAt } }).catch((e) => strapi2.log.error(`[${PLUGIN_ID}] touch fallback update failed:`, e));
836
+ return;
837
+ }
838
+ strapi2.log.error(`[${PLUGIN_ID}] touch failed for ${email}:`, err);
839
+ }
840
+ },
841
+ /**
842
+ * Returns true if the given session has been revoked.
843
+ *
844
+ * @param {string} sessionId - JWT sessionId
845
+ * @returns {Promise<boolean>}
846
+ */
847
+ async isRevoked(sessionId) {
848
+ if (!sessionId) return false;
849
+ const row = await strapi2.db.query(sessionUid).findOne({ where: { sessionId }, select: ["revokedAt"] });
850
+ return Boolean(row?.revokedAt);
851
+ },
852
+ /**
853
+ * Marks a session as revoked.
854
+ *
855
+ * @param {object} params
856
+ * @param {string} params.sessionId - JWT sessionId
857
+ * @returns {Promise<void>}
858
+ */
859
+ async revokeSession({ sessionId }) {
860
+ if (!sessionId) return;
861
+ lastDbWriteAt.delete(sessionId);
862
+ await strapi2.db.query(sessionUid).updateMany({ where: { sessionId }, data: { revokedAt: /* @__PURE__ */ new Date() } });
863
+ },
864
+ /**
865
+ * Marks all sessions belonging to the given email as revoked.
866
+ * Used by the auto-logout watcher and email-level revocation paths.
867
+ *
868
+ * @param {object} params
869
+ * @param {string} params.email - Admin email
870
+ * @returns {Promise<number>} Number of sessions revoked
871
+ */
872
+ async revokeAllForEmail({ email }) {
873
+ if (!email) return 0;
874
+ const result = await strapi2.db.query(sessionUid).updateMany({
875
+ where: { email, revokedAt: { $null: true } },
876
+ data: { revokedAt: /* @__PURE__ */ new Date() }
877
+ });
878
+ return result?.count ?? 0;
879
+ },
880
+ /**
881
+ * Returns true if the email has an active (non-revoked, recent) session.
882
+ * Used by preventMultipleSessions.
883
+ *
884
+ * @param {object} params
885
+ * @param {string} params.email - Admin email
886
+ * @param {number} params.idleThresholdMs - Idle threshold in milliseconds
887
+ * @returns {Promise<boolean>}
888
+ */
889
+ async hasActiveSession({ email, idleThresholdMs }) {
890
+ if (!email) return false;
891
+ const cutoff = new Date(Date.now() - idleThresholdMs);
892
+ const row = await strapi2.db.query(sessionUid).findOne({
893
+ where: {
894
+ email,
895
+ revokedAt: { $null: true },
896
+ lastActiveAt: { $gte: cutoff }
897
+ },
898
+ select: ["id"]
899
+ });
900
+ return Boolean(row);
901
+ },
902
+ /**
903
+ * Lists session rows that have been idle past the given threshold and
904
+ * are not yet revoked. Returns a small projection only.
905
+ *
906
+ * @param {object} params
907
+ * @param {number} params.idleThresholdMs - Idle threshold in milliseconds
908
+ * @returns {Promise<Array<{ id: number, sessionId: string, userId: number, email: string, lastActiveAt: Date }>>}
909
+ */
910
+ async listIdleSessions({ idleThresholdMs }) {
911
+ const cutoff = new Date(Date.now() - idleThresholdMs);
912
+ return strapi2.db.query(sessionUid).findMany({
913
+ where: {
914
+ revokedAt: { $null: true },
915
+ lastActiveAt: { $lt: cutoff }
916
+ },
917
+ select: ["id", "sessionId", "userId", "email", "lastActiveAt"]
918
+ });
919
+ },
920
+ /**
921
+ * Atomically attempts to acquire a login lock for the given email.
922
+ * Returns true on success, false if another pod holds an unexpired lock.
923
+ *
924
+ * Uses `SELECT … FOR UPDATE` inside a transaction for cross-DB
925
+ * mutual exclusion (Postgres / MySQL / SQLite).
926
+ *
927
+ * @param {object} params
928
+ * @param {string} params.email - Admin email
929
+ * @returns {Promise<boolean>} True if the lock was acquired
930
+ */
931
+ async acquireLoginLock({ email }) {
932
+ if (!email) return true;
933
+ const tableName = tableOf(lockUid);
934
+ const emailCol = columnFor(strapi2, lockUid, "email");
935
+ const lockedUntilCol = columnFor(strapi2, lockUid, "lockedUntil");
936
+ const documentIdCol = "document_id";
937
+ const createdAtCol = "created_at";
938
+ const updatedAtCol = "updated_at";
939
+ const now = /* @__PURE__ */ new Date();
940
+ const lockedUntil = new Date(now.getTime() + LOGIN_LOCK_TTL_MS);
941
+ const knex = strapi2.db.connection;
942
+ try {
943
+ return await knex.transaction(async (trx) => {
944
+ const row = await trx(tableName).where({ [emailCol]: email }).first().forUpdate();
945
+ if (!row) {
946
+ try {
947
+ await trx(tableName).insert({
948
+ [documentIdCol]: node_crypto.randomUUID(),
949
+ [emailCol]: email,
950
+ [lockedUntilCol]: lockedUntil,
951
+ [createdAtCol]: now,
952
+ [updatedAtCol]: now
953
+ });
954
+ return true;
955
+ } catch (err) {
956
+ if (isUniqueConstraintError(err)) return false;
957
+ throw err;
958
+ }
959
+ }
960
+ const heldUntil = row[lockedUntilCol] ? new Date(row[lockedUntilCol]) : null;
961
+ if (heldUntil && heldUntil > now) return false;
962
+ await trx(tableName).where({ id: row.id }).update({ [lockedUntilCol]: lockedUntil, [updatedAtCol]: now });
963
+ return true;
964
+ });
965
+ } catch (err) {
966
+ strapi2.log.error(`[${PLUGIN_ID}] acquireLoginLock failed for ${email}:`, err);
967
+ return false;
968
+ }
969
+ },
970
+ /**
971
+ * Releases the login lock for the given email. Idempotent.
972
+ *
973
+ * @param {object} params
974
+ * @param {string} params.email - Admin email
975
+ * @returns {Promise<void>}
976
+ */
977
+ async releaseLoginLock({ email }) {
978
+ if (!email) return;
979
+ try {
980
+ await strapi2.db.query(lockUid).deleteMany({ where: { email } });
981
+ } catch (err) {
982
+ strapi2.log.error(`[${PLUGIN_ID}] releaseLoginLock failed for ${email}:`, err);
983
+ }
984
+ },
985
+ /**
986
+ * Removes login-lock rows whose `lockedUntil` has passed.
987
+ *
988
+ * @returns {Promise<number>} Number of rows deleted
989
+ */
990
+ async pruneExpiredLocks() {
991
+ const result = await strapi2.db.query(lockUid).deleteMany({
992
+ where: { lockedUntil: { $lt: /* @__PURE__ */ new Date() } }
993
+ });
994
+ return result?.count ?? 0;
995
+ },
996
+ /**
997
+ * Atomically attempts to acquire (or renew) the auto-logout watcher lease.
998
+ * Only the holding pod runs the periodic watcher body cluster-wide.
999
+ *
1000
+ * @returns {Promise<boolean>} True if this pod now holds the lease
1001
+ */
1002
+ async acquireWatcherLease() {
1003
+ const tableName = tableOf(leaseUid);
1004
+ const nameCol = columnFor(strapi2, leaseUid, "name");
1005
+ const holderCol = columnFor(strapi2, leaseUid, "holder");
1006
+ const expiresAtCol = columnFor(strapi2, leaseUid, "expiresAt");
1007
+ const documentIdCol = "document_id";
1008
+ const createdAtCol = "created_at";
1009
+ const updatedAtCol = "updated_at";
1010
+ const now = /* @__PURE__ */ new Date();
1011
+ const expiresAt = new Date(now.getTime() + WATCHER_LEASE_TTL_MS);
1012
+ const knex = strapi2.db.connection;
1013
+ try {
1014
+ return await knex.transaction(async (trx) => {
1015
+ const row = await trx(tableName).where({ [nameCol]: WATCHER_LEASE_NAME }).first().forUpdate();
1016
+ if (!row) {
1017
+ try {
1018
+ await trx(tableName).insert({
1019
+ [documentIdCol]: node_crypto.randomUUID(),
1020
+ [nameCol]: WATCHER_LEASE_NAME,
1021
+ [holderCol]: POD_ID,
1022
+ [expiresAtCol]: expiresAt,
1023
+ [createdAtCol]: now,
1024
+ [updatedAtCol]: now
1025
+ });
1026
+ return true;
1027
+ } catch (err) {
1028
+ if (isUniqueConstraintError(err)) return false;
1029
+ throw err;
1030
+ }
1031
+ }
1032
+ const heldUntil = row[expiresAtCol] ? new Date(row[expiresAtCol]) : null;
1033
+ const isOwner = row[holderCol] === POD_ID;
1034
+ const expired = !heldUntil || heldUntil <= now;
1035
+ if (!isOwner && !expired) return false;
1036
+ await trx(tableName).where({ id: row.id }).update({
1037
+ [holderCol]: POD_ID,
1038
+ [expiresAtCol]: expiresAt,
1039
+ [updatedAtCol]: now
1040
+ });
1041
+ return true;
1042
+ });
1043
+ } catch (err) {
1044
+ strapi2.log.error(`[${PLUGIN_ID}] acquireWatcherLease failed:`, err);
1045
+ return false;
1046
+ }
1047
+ },
1048
+ /**
1049
+ * Releases the watcher lease, but only if this pod currently holds it.
1050
+ *
1051
+ * @returns {Promise<void>}
1052
+ */
1053
+ async releaseWatcherLease() {
1054
+ const tableName = tableOf(leaseUid);
1055
+ const nameCol = columnFor(strapi2, leaseUid, "name");
1056
+ const holderCol = columnFor(strapi2, leaseUid, "holder");
1057
+ const expiresAtCol = columnFor(strapi2, leaseUid, "expiresAt");
1058
+ const updatedAtCol = "updated_at";
1059
+ try {
1060
+ await strapi2.db.connection(tableName).where({ [nameCol]: WATCHER_LEASE_NAME, [holderCol]: POD_ID }).update({
1061
+ [holderCol]: "",
1062
+ [expiresAtCol]: /* @__PURE__ */ new Date(0),
1063
+ [updatedAtCol]: /* @__PURE__ */ new Date()
1064
+ });
1065
+ } catch (err) {
1066
+ strapi2.log.error(`[${PLUGIN_ID}] releaseWatcherLease failed:`, err);
1067
+ }
1068
+ },
1069
+ /**
1070
+ * Test-only: clears the per-pod write-coalescing cache.
1071
+ * Exposed for unit tests so each test starts from a clean slate.
1072
+ *
1073
+ * @returns {void}
1074
+ */
1075
+ _resetActivityCache() {
1076
+ lastDbWriteAt.clear();
1077
+ }
1078
+ };
1079
+ };
635
1080
  const services = {
636
- autoLogoutChecker
1081
+ autoLogoutChecker,
1082
+ state
637
1083
  };
638
1084
  const index = {
639
1085
  bootstrap,