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