strapi-security-suite 0.3.0 → 0.3.2
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.
- package/dist/_chunks/{App-t96Cfein.mjs → App-CBOxzfqu.mjs} +1 -1
- package/dist/_chunks/{App-CfPg1Thn.js → App-ConqHB2Q.js} +1 -1
- package/dist/_chunks/{index-ub4Bl9QF.js → index-BGBd43He.js} +2 -3
- package/dist/_chunks/{index-CAEB836L.mjs → index-ZKJuPZEH.mjs} +2 -3
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/server/index.js +84 -66
- package/dist/server/index.mjs +84 -66
- package/package.json +1 -1
|
@@ -3,7 +3,7 @@ import { useFetchClient, Page } from "@strapi/strapi/admin";
|
|
|
3
3
|
import { Routes, Route } from "react-router-dom";
|
|
4
4
|
import { useState, useEffect } from "react";
|
|
5
5
|
import { Box, Typography, Alert, Flex, Divider, NumberInput, Switch, Button } from "@strapi/design-system";
|
|
6
|
-
import { A as API_BASE_PATH, S as SUCCESS_ALERT_DURATION } from "./index-
|
|
6
|
+
import { A as API_BASE_PATH, S as SUCCESS_ALERT_DURATION } from "./index-ZKJuPZEH.mjs";
|
|
7
7
|
const HomePage = () => {
|
|
8
8
|
const client = useFetchClient();
|
|
9
9
|
const [config, setConfig] = useState({
|
|
@@ -5,7 +5,7 @@ const admin = require("@strapi/strapi/admin");
|
|
|
5
5
|
const reactRouterDom = require("react-router-dom");
|
|
6
6
|
const react = require("react");
|
|
7
7
|
const designSystem = require("@strapi/design-system");
|
|
8
|
-
const index = require("./index-
|
|
8
|
+
const index = require("./index-BGBd43He.js");
|
|
9
9
|
const HomePage = () => {
|
|
10
10
|
const client = admin.useFetchClient();
|
|
11
11
|
const [config, setConfig] = react.useState({
|
|
@@ -49,9 +49,8 @@ const index = {
|
|
|
49
49
|
const response = await originalFetch(...args);
|
|
50
50
|
const captured = response.headers.get(ADMIN_TOKEN_HEADER);
|
|
51
51
|
if (captured) {
|
|
52
|
-
window.stop();
|
|
53
52
|
window.location.reload();
|
|
54
|
-
return;
|
|
53
|
+
return new Response(null, { status: 401 });
|
|
55
54
|
}
|
|
56
55
|
return response;
|
|
57
56
|
};
|
|
@@ -71,7 +70,7 @@ const index = {
|
|
|
71
70
|
id: getTrad("settings.title"),
|
|
72
71
|
defaultMessage: "Security Suite"
|
|
73
72
|
},
|
|
74
|
-
Component: () => Promise.resolve().then(() => require("./App-
|
|
73
|
+
Component: () => Promise.resolve().then(() => require("./App-ConqHB2Q.js")),
|
|
75
74
|
permissions: [
|
|
76
75
|
{
|
|
77
76
|
action: "plugin::strapi-security-suite.access",
|
|
@@ -48,9 +48,8 @@ const index = {
|
|
|
48
48
|
const response = await originalFetch(...args);
|
|
49
49
|
const captured = response.headers.get(ADMIN_TOKEN_HEADER);
|
|
50
50
|
if (captured) {
|
|
51
|
-
window.stop();
|
|
52
51
|
window.location.reload();
|
|
53
|
-
return;
|
|
52
|
+
return new Response(null, { status: 401 });
|
|
54
53
|
}
|
|
55
54
|
return response;
|
|
56
55
|
};
|
|
@@ -70,7 +69,7 @@ const index = {
|
|
|
70
69
|
id: getTrad("settings.title"),
|
|
71
70
|
defaultMessage: "Security Suite"
|
|
72
71
|
},
|
|
73
|
-
Component: () => import("./App-
|
|
72
|
+
Component: () => import("./App-CBOxzfqu.mjs"),
|
|
74
73
|
permissions: [
|
|
75
74
|
{
|
|
76
75
|
action: "plugin::strapi-security-suite.access",
|
package/dist/admin/index.js
CHANGED
package/dist/admin/index.mjs
CHANGED
package/dist/server/index.js
CHANGED
|
@@ -3,7 +3,16 @@ const jwt = require("jsonwebtoken");
|
|
|
3
3
|
const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
|
|
4
4
|
const jwt__default = /* @__PURE__ */ _interopDefault(jwt);
|
|
5
5
|
const revokedTokenSet = /* @__PURE__ */ new Set();
|
|
6
|
-
const revokedConnectionTokens = /* @__PURE__ */ new
|
|
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
|
+
}
|
|
7
16
|
const sessionActivityMap = /* @__PURE__ */ new Map();
|
|
8
17
|
const PLUGIN_ID = "strapi-security-suite";
|
|
9
18
|
const CONTENT_TYPES = {
|
|
@@ -12,6 +21,7 @@ const CONTENT_TYPES = {
|
|
|
12
21
|
const SERVICES = {
|
|
13
22
|
AUTO_LOGOUT_CHECKER: "autoLogoutChecker"
|
|
14
23
|
};
|
|
24
|
+
const CTX_ADMIN_USER = Symbol.for("security-suite:adminUser");
|
|
15
25
|
const CHECK_INTERVAL = 5e3;
|
|
16
26
|
const DEFAULT_AUTOLOGOUT_TIME = 30;
|
|
17
27
|
const MS_PER_MINUTE = 6e4;
|
|
@@ -29,7 +39,9 @@ const COOKIES = {
|
|
|
29
39
|
SESSION: "koa.sess",
|
|
30
40
|
SESSION_SIG: "koa.sess.sig",
|
|
31
41
|
/** Strapi v5 admin refresh-token cookie (managed by session manager). */
|
|
32
|
-
REFRESH_TOKEN: "strapi_admin_refresh"
|
|
42
|
+
REFRESH_TOKEN: "strapi_admin_refresh",
|
|
43
|
+
/** JWT access-token cookie set by Strapi EE SSO authentication flow. */
|
|
44
|
+
JWT_TOKEN: "jwtToken"
|
|
33
45
|
};
|
|
34
46
|
const HEADERS = {
|
|
35
47
|
/** Header that signals the frontend to force-reload (session revoked). */
|
|
@@ -37,7 +49,6 @@ const HEADERS = {
|
|
|
37
49
|
/** Required so the browser exposes custom headers in fetch responses. */
|
|
38
50
|
EXPOSE_HEADERS: "Access-Control-Expose-Headers"
|
|
39
51
|
};
|
|
40
|
-
const ADMIN_TOKEN_FALLBACK = "email.admin";
|
|
41
52
|
const ERROR_MESSAGES = {
|
|
42
53
|
SETTINGS_NOT_FOUND: "Security settings not found.",
|
|
43
54
|
INSUFFICIENT_PERMISSIONS: "Insufficient permissions.",
|
|
@@ -60,8 +71,49 @@ const DEFAULT_SETTINGS = {
|
|
|
60
71
|
enablePasswordManagement: true
|
|
61
72
|
};
|
|
62
73
|
const VALID_SETTINGS_KEYS = new Set(Object.keys(DEFAULT_SETTINGS));
|
|
74
|
+
class PluginError extends Error {
|
|
75
|
+
/**
|
|
76
|
+
* @param {string} message - Internal message (for logs)
|
|
77
|
+
* @param {string} sanitizedMessage - Safe message for the client
|
|
78
|
+
* @param {number} [statusCode=400] - HTTP status code
|
|
79
|
+
*/
|
|
80
|
+
constructor(message, sanitizedMessage, statusCode = HTTP_STATUS.BAD_REQUEST) {
|
|
81
|
+
super(message);
|
|
82
|
+
this.name = "PluginError";
|
|
83
|
+
this.sanitizedMessage = sanitizedMessage;
|
|
84
|
+
this.statusCode = statusCode;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
class ValidationError extends PluginError {
|
|
88
|
+
/**
|
|
89
|
+
* @param {string} message - Internal message
|
|
90
|
+
* @param {string} [sanitizedMessage='Validation failed.'] - Client-safe message
|
|
91
|
+
*/
|
|
92
|
+
constructor(message, sanitizedMessage = "Validation failed.") {
|
|
93
|
+
super(message, sanitizedMessage, HTTP_STATUS.BAD_REQUEST);
|
|
94
|
+
this.name = "ValidationError";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function clearSessionCookies(ctx) {
|
|
98
|
+
if (ctx.session !== void 0) {
|
|
99
|
+
ctx.session = null;
|
|
100
|
+
}
|
|
101
|
+
const expireOpts = { expires: /* @__PURE__ */ new Date(0), path: "/", httpOnly: true };
|
|
102
|
+
ctx.cookies.set(COOKIES.SESSION, "", expireOpts);
|
|
103
|
+
ctx.cookies.set(COOKIES.SESSION_SIG, "", expireOpts);
|
|
104
|
+
ctx.cookies.set(COOKIES.REFRESH_TOKEN, "", {
|
|
105
|
+
expires: /* @__PURE__ */ new Date(0),
|
|
106
|
+
path: "/admin",
|
|
107
|
+
httpOnly: true
|
|
108
|
+
});
|
|
109
|
+
ctx.cookies.set(COOKIES.JWT_TOKEN, "", {
|
|
110
|
+
expires: /* @__PURE__ */ new Date(0),
|
|
111
|
+
path: "/",
|
|
112
|
+
httpOnly: false
|
|
113
|
+
});
|
|
114
|
+
}
|
|
63
115
|
async function trackActivity(ctx, next) {
|
|
64
|
-
const adminUser = ctx.
|
|
116
|
+
const adminUser = ctx.state[CTX_ADMIN_USER];
|
|
65
117
|
let key = adminUser?.id ? `${adminUser.id}:${adminUser.email}` : null;
|
|
66
118
|
const bearerToken = ctx.get("authorization")?.split("Bearer ")[1];
|
|
67
119
|
if (bearerToken && revokedConnectionTokens.has(bearerToken)) {
|
|
@@ -76,7 +128,7 @@ async function trackActivity(ctx, next) {
|
|
|
76
128
|
return;
|
|
77
129
|
}
|
|
78
130
|
if (ctx.path.includes(LOGOUT_PATH)) {
|
|
79
|
-
ctx
|
|
131
|
+
clearSessionCookies(ctx);
|
|
80
132
|
key = null;
|
|
81
133
|
}
|
|
82
134
|
if (key) {
|
|
@@ -86,14 +138,22 @@ async function trackActivity(ctx, next) {
|
|
|
86
138
|
await next();
|
|
87
139
|
}
|
|
88
140
|
const loginLocks = /* @__PURE__ */ new Set();
|
|
141
|
+
function cleanupLoginState(ctx) {
|
|
142
|
+
const email = ctx.request.body?.email;
|
|
143
|
+
loginLocks.delete(email);
|
|
144
|
+
if (email && ctx.status < 400) {
|
|
145
|
+
revokedTokenSet.delete(email);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
89
148
|
async function preventMultipleSessions(ctx, next) {
|
|
90
149
|
const isLoginPost = ctx.path === LOGIN_PATH && ctx.method === "POST";
|
|
91
|
-
const alreadyAdmin = ctx.session?.user;
|
|
92
150
|
if (!isLoginPost) {
|
|
93
151
|
return await next();
|
|
94
152
|
}
|
|
95
|
-
if (
|
|
96
|
-
strapi.log.debug(
|
|
153
|
+
if (ctx.state[CTX_ADMIN_USER]) {
|
|
154
|
+
strapi.log.debug(
|
|
155
|
+
`[${PLUGIN_ID}] Skipping session lock. ${JSON.stringify(ctx.state[CTX_ADMIN_USER])}`
|
|
156
|
+
);
|
|
97
157
|
return await next();
|
|
98
158
|
}
|
|
99
159
|
try {
|
|
@@ -127,30 +187,22 @@ async function preventMultipleSessions(ctx, next) {
|
|
|
127
187
|
try {
|
|
128
188
|
await next();
|
|
129
189
|
} finally {
|
|
130
|
-
|
|
131
|
-
const email = ctx.request.body?.email;
|
|
132
|
-
loginLocks.delete(email);
|
|
133
|
-
}
|
|
190
|
+
cleanupLoginState(ctx);
|
|
134
191
|
}
|
|
135
192
|
}
|
|
136
193
|
async function rejectRevokedTokens(ctx, next) {
|
|
137
|
-
const
|
|
138
|
-
if (!
|
|
139
|
-
const { id, email: adminEmail } =
|
|
194
|
+
const adminUser = ctx.state[CTX_ADMIN_USER];
|
|
195
|
+
if (!adminUser?.email) return await next();
|
|
196
|
+
const { id, email: adminEmail } = adminUser;
|
|
140
197
|
const key = id && adminEmail ? `${id}:${adminEmail}` : null;
|
|
141
198
|
try {
|
|
142
199
|
if (adminEmail && revokedTokenSet.has(adminEmail)) {
|
|
143
200
|
ctx.set(HEADERS.ADMIN_TOKEN_SIGNAL, adminEmail);
|
|
144
201
|
ctx.set(HEADERS.EXPOSE_HEADERS, HEADERS.ADMIN_TOKEN_SIGNAL);
|
|
145
|
-
ctx
|
|
146
|
-
ctx.cookies.set(COOKIES.REFRESH_TOKEN, "", {
|
|
147
|
-
expires: /* @__PURE__ */ new Date(0),
|
|
148
|
-
path: "/admin",
|
|
149
|
-
httpOnly: true
|
|
150
|
-
});
|
|
202
|
+
clearSessionCookies(ctx);
|
|
151
203
|
const bearerToken = ctx.get("authorization")?.split("Bearer ")[1];
|
|
152
204
|
if (bearerToken) {
|
|
153
|
-
revokedConnectionTokens.
|
|
205
|
+
revokedConnectionTokens.set(bearerToken, Date.now());
|
|
154
206
|
}
|
|
155
207
|
sessionActivityMap.delete(key);
|
|
156
208
|
revokedTokenSet.delete(adminEmail);
|
|
@@ -173,29 +225,25 @@ async function rejectRevokedTokens(ctx, next) {
|
|
|
173
225
|
}
|
|
174
226
|
async function interceptRenewToken(ctx, next) {
|
|
175
227
|
if (ctx.path.includes(LOGOUT_PATH)) {
|
|
176
|
-
const adminUser = ctx.
|
|
228
|
+
const adminUser = ctx.state[CTX_ADMIN_USER];
|
|
177
229
|
strapi.log.debug(`[${PLUGIN_ID}] Logout captured: ${JSON.stringify(adminUser)}`);
|
|
178
230
|
if (adminUser?.id) {
|
|
179
231
|
strapi.plugin(PLUGIN_ID).service(SERVICES.AUTO_LOGOUT_CHECKER).clearSessionActivity(adminUser.id, adminUser.email);
|
|
180
232
|
const bearerToken = ctx.get("authorization")?.split("Bearer ")[1];
|
|
181
233
|
if (bearerToken) {
|
|
182
|
-
revokedConnectionTokens.
|
|
234
|
+
revokedConnectionTokens.set(bearerToken, Date.now());
|
|
183
235
|
}
|
|
184
|
-
ctx
|
|
236
|
+
clearSessionCookies(ctx);
|
|
185
237
|
sessionActivityMap.delete(`${adminUser.id}:${adminUser.email}`);
|
|
186
238
|
}
|
|
187
239
|
await next();
|
|
188
240
|
return;
|
|
189
241
|
}
|
|
190
242
|
if (ctx.path.includes(ACCESS_TOKEN_PATH) || ctx.path.includes(CONTENT_PATH)) {
|
|
191
|
-
const
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
ctx.set(HEADERS.EXPOSE_HEADERS, HEADERS.ADMIN_TOKEN_SIGNAL);
|
|
195
|
-
await next();
|
|
196
|
-
return;
|
|
243
|
+
const adminUser = ctx.state[CTX_ADMIN_USER];
|
|
244
|
+
if (adminUser?.email) {
|
|
245
|
+
strapi.log.debug(`[${PLUGIN_ID}] Token renewal intercepted for ${adminUser.email}`);
|
|
197
246
|
}
|
|
198
|
-
strapi.log.debug(`[${PLUGIN_ID}] Token renewal intercepted for ${email}`);
|
|
199
247
|
}
|
|
200
248
|
await next();
|
|
201
249
|
}
|
|
@@ -208,9 +256,8 @@ async function seedUserInfos(ctx, next) {
|
|
|
208
256
|
const token = authHeader.split("Bearer ")[1];
|
|
209
257
|
if (!token) return await next();
|
|
210
258
|
const decodedToken = jwt__default.default.decode(token);
|
|
211
|
-
const session = ctx.session?.user ?? null;
|
|
212
259
|
const adminId = decodedToken?.userId;
|
|
213
|
-
if (!adminId ||
|
|
260
|
+
if (!adminId || ctx.state[CTX_ADMIN_USER]?.id) {
|
|
214
261
|
return await next();
|
|
215
262
|
}
|
|
216
263
|
const adminUser = await strapi.db.query("admin::user").findOne({
|
|
@@ -221,25 +268,18 @@ async function seedUserInfos(ctx, next) {
|
|
|
221
268
|
strapi.log.debug(`[${PLUGIN_ID}] No admin user found with ID ${adminId}`);
|
|
222
269
|
return await next();
|
|
223
270
|
}
|
|
224
|
-
|
|
225
|
-
strapi.log.debug(
|
|
226
|
-
`[${PLUGIN_ID}] Admin ${adminUser.email} is in revoked set — skipping hydration`
|
|
227
|
-
);
|
|
228
|
-
return await next();
|
|
229
|
-
}
|
|
230
|
-
const userInfos = {
|
|
271
|
+
ctx.state[CTX_ADMIN_USER] = {
|
|
231
272
|
id: adminUser.id,
|
|
232
273
|
email: adminUser.email,
|
|
233
274
|
firstname: adminUser.firstname,
|
|
234
275
|
lastname: adminUser.lastname,
|
|
235
276
|
roles: adminUser.roles
|
|
236
277
|
};
|
|
237
|
-
ctx.session.user = userInfos;
|
|
238
278
|
const key = `${adminUser.id}:${adminUser.email}`;
|
|
239
279
|
if (!sessionActivityMap.has(key)) {
|
|
240
280
|
sessionActivityMap.set(key, Date.now());
|
|
241
281
|
}
|
|
242
|
-
strapi.log.debug(`[${PLUGIN_ID}]
|
|
282
|
+
strapi.log.debug(`[${PLUGIN_ID}] Admin identified: ${adminUser.email}`);
|
|
243
283
|
return await next();
|
|
244
284
|
} catch (error) {
|
|
245
285
|
strapi.log.error(`[${PLUGIN_ID}] Failed to decode or hydrate admin token:`, error);
|
|
@@ -373,29 +413,6 @@ const securitySettings = {
|
|
|
373
413
|
const contentTypes = {
|
|
374
414
|
"security-settings": securitySettings
|
|
375
415
|
};
|
|
376
|
-
class PluginError extends Error {
|
|
377
|
-
/**
|
|
378
|
-
* @param {string} message - Internal message (for logs)
|
|
379
|
-
* @param {string} sanitizedMessage - Safe message for the client
|
|
380
|
-
* @param {number} [statusCode=400] - HTTP status code
|
|
381
|
-
*/
|
|
382
|
-
constructor(message, sanitizedMessage, statusCode = HTTP_STATUS.BAD_REQUEST) {
|
|
383
|
-
super(message);
|
|
384
|
-
this.name = "PluginError";
|
|
385
|
-
this.sanitizedMessage = sanitizedMessage;
|
|
386
|
-
this.statusCode = statusCode;
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
class ValidationError extends PluginError {
|
|
390
|
-
/**
|
|
391
|
-
* @param {string} message - Internal message
|
|
392
|
-
* @param {string} [sanitizedMessage='Validation failed.'] - Client-safe message
|
|
393
|
-
*/
|
|
394
|
-
constructor(message, sanitizedMessage = "Validation failed.") {
|
|
395
|
-
super(message, sanitizedMessage, HTTP_STATUS.BAD_REQUEST);
|
|
396
|
-
this.name = "ValidationError";
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
416
|
const validateSettingsPayload = (body) => {
|
|
400
417
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
401
418
|
throw new ValidationError(
|
|
@@ -557,6 +574,7 @@ const autoLogoutChecker = ({ strapi: strapi2 }) => ({
|
|
|
557
574
|
}
|
|
558
575
|
interval = setInterval(async () => {
|
|
559
576
|
try {
|
|
577
|
+
pruneExpiredTokens();
|
|
560
578
|
const settings = await strapi2.documents(CONTENT_TYPES.SECURITY_SETTINGS).findFirst({});
|
|
561
579
|
const autoLogoutTime = (settings?.autoLogoutTime ?? DEFAULT_AUTOLOGOUT_TIME) * MS_PER_MINUTE;
|
|
562
580
|
const now = Date.now();
|
package/dist/server/index.mjs
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import jwt from "jsonwebtoken";
|
|
2
2
|
const revokedTokenSet = /* @__PURE__ */ new Set();
|
|
3
|
-
const revokedConnectionTokens = /* @__PURE__ */ new
|
|
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
|
+
}
|
|
4
13
|
const sessionActivityMap = /* @__PURE__ */ new Map();
|
|
5
14
|
const PLUGIN_ID = "strapi-security-suite";
|
|
6
15
|
const CONTENT_TYPES = {
|
|
@@ -9,6 +18,7 @@ const CONTENT_TYPES = {
|
|
|
9
18
|
const SERVICES = {
|
|
10
19
|
AUTO_LOGOUT_CHECKER: "autoLogoutChecker"
|
|
11
20
|
};
|
|
21
|
+
const CTX_ADMIN_USER = Symbol.for("security-suite:adminUser");
|
|
12
22
|
const CHECK_INTERVAL = 5e3;
|
|
13
23
|
const DEFAULT_AUTOLOGOUT_TIME = 30;
|
|
14
24
|
const MS_PER_MINUTE = 6e4;
|
|
@@ -26,7 +36,9 @@ const COOKIES = {
|
|
|
26
36
|
SESSION: "koa.sess",
|
|
27
37
|
SESSION_SIG: "koa.sess.sig",
|
|
28
38
|
/** Strapi v5 admin refresh-token cookie (managed by session manager). */
|
|
29
|
-
REFRESH_TOKEN: "strapi_admin_refresh"
|
|
39
|
+
REFRESH_TOKEN: "strapi_admin_refresh",
|
|
40
|
+
/** JWT access-token cookie set by Strapi EE SSO authentication flow. */
|
|
41
|
+
JWT_TOKEN: "jwtToken"
|
|
30
42
|
};
|
|
31
43
|
const HEADERS = {
|
|
32
44
|
/** Header that signals the frontend to force-reload (session revoked). */
|
|
@@ -34,7 +46,6 @@ const HEADERS = {
|
|
|
34
46
|
/** Required so the browser exposes custom headers in fetch responses. */
|
|
35
47
|
EXPOSE_HEADERS: "Access-Control-Expose-Headers"
|
|
36
48
|
};
|
|
37
|
-
const ADMIN_TOKEN_FALLBACK = "email.admin";
|
|
38
49
|
const ERROR_MESSAGES = {
|
|
39
50
|
SETTINGS_NOT_FOUND: "Security settings not found.",
|
|
40
51
|
INSUFFICIENT_PERMISSIONS: "Insufficient permissions.",
|
|
@@ -57,8 +68,49 @@ const DEFAULT_SETTINGS = {
|
|
|
57
68
|
enablePasswordManagement: true
|
|
58
69
|
};
|
|
59
70
|
const VALID_SETTINGS_KEYS = new Set(Object.keys(DEFAULT_SETTINGS));
|
|
71
|
+
class PluginError extends Error {
|
|
72
|
+
/**
|
|
73
|
+
* @param {string} message - Internal message (for logs)
|
|
74
|
+
* @param {string} sanitizedMessage - Safe message for the client
|
|
75
|
+
* @param {number} [statusCode=400] - HTTP status code
|
|
76
|
+
*/
|
|
77
|
+
constructor(message, sanitizedMessage, statusCode = HTTP_STATUS.BAD_REQUEST) {
|
|
78
|
+
super(message);
|
|
79
|
+
this.name = "PluginError";
|
|
80
|
+
this.sanitizedMessage = sanitizedMessage;
|
|
81
|
+
this.statusCode = statusCode;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
class ValidationError extends PluginError {
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} message - Internal message
|
|
87
|
+
* @param {string} [sanitizedMessage='Validation failed.'] - Client-safe message
|
|
88
|
+
*/
|
|
89
|
+
constructor(message, sanitizedMessage = "Validation failed.") {
|
|
90
|
+
super(message, sanitizedMessage, HTTP_STATUS.BAD_REQUEST);
|
|
91
|
+
this.name = "ValidationError";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function clearSessionCookies(ctx) {
|
|
95
|
+
if (ctx.session !== void 0) {
|
|
96
|
+
ctx.session = null;
|
|
97
|
+
}
|
|
98
|
+
const expireOpts = { expires: /* @__PURE__ */ new Date(0), path: "/", httpOnly: true };
|
|
99
|
+
ctx.cookies.set(COOKIES.SESSION, "", expireOpts);
|
|
100
|
+
ctx.cookies.set(COOKIES.SESSION_SIG, "", expireOpts);
|
|
101
|
+
ctx.cookies.set(COOKIES.REFRESH_TOKEN, "", {
|
|
102
|
+
expires: /* @__PURE__ */ new Date(0),
|
|
103
|
+
path: "/admin",
|
|
104
|
+
httpOnly: true
|
|
105
|
+
});
|
|
106
|
+
ctx.cookies.set(COOKIES.JWT_TOKEN, "", {
|
|
107
|
+
expires: /* @__PURE__ */ new Date(0),
|
|
108
|
+
path: "/",
|
|
109
|
+
httpOnly: false
|
|
110
|
+
});
|
|
111
|
+
}
|
|
60
112
|
async function trackActivity(ctx, next) {
|
|
61
|
-
const adminUser = ctx.
|
|
113
|
+
const adminUser = ctx.state[CTX_ADMIN_USER];
|
|
62
114
|
let key = adminUser?.id ? `${adminUser.id}:${adminUser.email}` : null;
|
|
63
115
|
const bearerToken = ctx.get("authorization")?.split("Bearer ")[1];
|
|
64
116
|
if (bearerToken && revokedConnectionTokens.has(bearerToken)) {
|
|
@@ -73,7 +125,7 @@ async function trackActivity(ctx, next) {
|
|
|
73
125
|
return;
|
|
74
126
|
}
|
|
75
127
|
if (ctx.path.includes(LOGOUT_PATH)) {
|
|
76
|
-
ctx
|
|
128
|
+
clearSessionCookies(ctx);
|
|
77
129
|
key = null;
|
|
78
130
|
}
|
|
79
131
|
if (key) {
|
|
@@ -83,14 +135,22 @@ async function trackActivity(ctx, next) {
|
|
|
83
135
|
await next();
|
|
84
136
|
}
|
|
85
137
|
const loginLocks = /* @__PURE__ */ new Set();
|
|
138
|
+
function cleanupLoginState(ctx) {
|
|
139
|
+
const email = ctx.request.body?.email;
|
|
140
|
+
loginLocks.delete(email);
|
|
141
|
+
if (email && ctx.status < 400) {
|
|
142
|
+
revokedTokenSet.delete(email);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
86
145
|
async function preventMultipleSessions(ctx, next) {
|
|
87
146
|
const isLoginPost = ctx.path === LOGIN_PATH && ctx.method === "POST";
|
|
88
|
-
const alreadyAdmin = ctx.session?.user;
|
|
89
147
|
if (!isLoginPost) {
|
|
90
148
|
return await next();
|
|
91
149
|
}
|
|
92
|
-
if (
|
|
93
|
-
strapi.log.debug(
|
|
150
|
+
if (ctx.state[CTX_ADMIN_USER]) {
|
|
151
|
+
strapi.log.debug(
|
|
152
|
+
`[${PLUGIN_ID}] Skipping session lock. ${JSON.stringify(ctx.state[CTX_ADMIN_USER])}`
|
|
153
|
+
);
|
|
94
154
|
return await next();
|
|
95
155
|
}
|
|
96
156
|
try {
|
|
@@ -124,30 +184,22 @@ async function preventMultipleSessions(ctx, next) {
|
|
|
124
184
|
try {
|
|
125
185
|
await next();
|
|
126
186
|
} finally {
|
|
127
|
-
|
|
128
|
-
const email = ctx.request.body?.email;
|
|
129
|
-
loginLocks.delete(email);
|
|
130
|
-
}
|
|
187
|
+
cleanupLoginState(ctx);
|
|
131
188
|
}
|
|
132
189
|
}
|
|
133
190
|
async function rejectRevokedTokens(ctx, next) {
|
|
134
|
-
const
|
|
135
|
-
if (!
|
|
136
|
-
const { id, email: adminEmail } =
|
|
191
|
+
const adminUser = ctx.state[CTX_ADMIN_USER];
|
|
192
|
+
if (!adminUser?.email) return await next();
|
|
193
|
+
const { id, email: adminEmail } = adminUser;
|
|
137
194
|
const key = id && adminEmail ? `${id}:${adminEmail}` : null;
|
|
138
195
|
try {
|
|
139
196
|
if (adminEmail && revokedTokenSet.has(adminEmail)) {
|
|
140
197
|
ctx.set(HEADERS.ADMIN_TOKEN_SIGNAL, adminEmail);
|
|
141
198
|
ctx.set(HEADERS.EXPOSE_HEADERS, HEADERS.ADMIN_TOKEN_SIGNAL);
|
|
142
|
-
ctx
|
|
143
|
-
ctx.cookies.set(COOKIES.REFRESH_TOKEN, "", {
|
|
144
|
-
expires: /* @__PURE__ */ new Date(0),
|
|
145
|
-
path: "/admin",
|
|
146
|
-
httpOnly: true
|
|
147
|
-
});
|
|
199
|
+
clearSessionCookies(ctx);
|
|
148
200
|
const bearerToken = ctx.get("authorization")?.split("Bearer ")[1];
|
|
149
201
|
if (bearerToken) {
|
|
150
|
-
revokedConnectionTokens.
|
|
202
|
+
revokedConnectionTokens.set(bearerToken, Date.now());
|
|
151
203
|
}
|
|
152
204
|
sessionActivityMap.delete(key);
|
|
153
205
|
revokedTokenSet.delete(adminEmail);
|
|
@@ -170,29 +222,25 @@ async function rejectRevokedTokens(ctx, next) {
|
|
|
170
222
|
}
|
|
171
223
|
async function interceptRenewToken(ctx, next) {
|
|
172
224
|
if (ctx.path.includes(LOGOUT_PATH)) {
|
|
173
|
-
const adminUser = ctx.
|
|
225
|
+
const adminUser = ctx.state[CTX_ADMIN_USER];
|
|
174
226
|
strapi.log.debug(`[${PLUGIN_ID}] Logout captured: ${JSON.stringify(adminUser)}`);
|
|
175
227
|
if (adminUser?.id) {
|
|
176
228
|
strapi.plugin(PLUGIN_ID).service(SERVICES.AUTO_LOGOUT_CHECKER).clearSessionActivity(adminUser.id, adminUser.email);
|
|
177
229
|
const bearerToken = ctx.get("authorization")?.split("Bearer ")[1];
|
|
178
230
|
if (bearerToken) {
|
|
179
|
-
revokedConnectionTokens.
|
|
231
|
+
revokedConnectionTokens.set(bearerToken, Date.now());
|
|
180
232
|
}
|
|
181
|
-
ctx
|
|
233
|
+
clearSessionCookies(ctx);
|
|
182
234
|
sessionActivityMap.delete(`${adminUser.id}:${adminUser.email}`);
|
|
183
235
|
}
|
|
184
236
|
await next();
|
|
185
237
|
return;
|
|
186
238
|
}
|
|
187
239
|
if (ctx.path.includes(ACCESS_TOKEN_PATH) || ctx.path.includes(CONTENT_PATH)) {
|
|
188
|
-
const
|
|
189
|
-
if (
|
|
190
|
-
|
|
191
|
-
ctx.set(HEADERS.EXPOSE_HEADERS, HEADERS.ADMIN_TOKEN_SIGNAL);
|
|
192
|
-
await next();
|
|
193
|
-
return;
|
|
240
|
+
const adminUser = ctx.state[CTX_ADMIN_USER];
|
|
241
|
+
if (adminUser?.email) {
|
|
242
|
+
strapi.log.debug(`[${PLUGIN_ID}] Token renewal intercepted for ${adminUser.email}`);
|
|
194
243
|
}
|
|
195
|
-
strapi.log.debug(`[${PLUGIN_ID}] Token renewal intercepted for ${email}`);
|
|
196
244
|
}
|
|
197
245
|
await next();
|
|
198
246
|
}
|
|
@@ -205,9 +253,8 @@ async function seedUserInfos(ctx, next) {
|
|
|
205
253
|
const token = authHeader.split("Bearer ")[1];
|
|
206
254
|
if (!token) return await next();
|
|
207
255
|
const decodedToken = jwt.decode(token);
|
|
208
|
-
const session = ctx.session?.user ?? null;
|
|
209
256
|
const adminId = decodedToken?.userId;
|
|
210
|
-
if (!adminId ||
|
|
257
|
+
if (!adminId || ctx.state[CTX_ADMIN_USER]?.id) {
|
|
211
258
|
return await next();
|
|
212
259
|
}
|
|
213
260
|
const adminUser = await strapi.db.query("admin::user").findOne({
|
|
@@ -218,25 +265,18 @@ async function seedUserInfos(ctx, next) {
|
|
|
218
265
|
strapi.log.debug(`[${PLUGIN_ID}] No admin user found with ID ${adminId}`);
|
|
219
266
|
return await next();
|
|
220
267
|
}
|
|
221
|
-
|
|
222
|
-
strapi.log.debug(
|
|
223
|
-
`[${PLUGIN_ID}] Admin ${adminUser.email} is in revoked set — skipping hydration`
|
|
224
|
-
);
|
|
225
|
-
return await next();
|
|
226
|
-
}
|
|
227
|
-
const userInfos = {
|
|
268
|
+
ctx.state[CTX_ADMIN_USER] = {
|
|
228
269
|
id: adminUser.id,
|
|
229
270
|
email: adminUser.email,
|
|
230
271
|
firstname: adminUser.firstname,
|
|
231
272
|
lastname: adminUser.lastname,
|
|
232
273
|
roles: adminUser.roles
|
|
233
274
|
};
|
|
234
|
-
ctx.session.user = userInfos;
|
|
235
275
|
const key = `${adminUser.id}:${adminUser.email}`;
|
|
236
276
|
if (!sessionActivityMap.has(key)) {
|
|
237
277
|
sessionActivityMap.set(key, Date.now());
|
|
238
278
|
}
|
|
239
|
-
strapi.log.debug(`[${PLUGIN_ID}]
|
|
279
|
+
strapi.log.debug(`[${PLUGIN_ID}] Admin identified: ${adminUser.email}`);
|
|
240
280
|
return await next();
|
|
241
281
|
} catch (error) {
|
|
242
282
|
strapi.log.error(`[${PLUGIN_ID}] Failed to decode or hydrate admin token:`, error);
|
|
@@ -370,29 +410,6 @@ const securitySettings = {
|
|
|
370
410
|
const contentTypes = {
|
|
371
411
|
"security-settings": securitySettings
|
|
372
412
|
};
|
|
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
413
|
const validateSettingsPayload = (body) => {
|
|
397
414
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
398
415
|
throw new ValidationError(
|
|
@@ -554,6 +571,7 @@ const autoLogoutChecker = ({ strapi: strapi2 }) => ({
|
|
|
554
571
|
}
|
|
555
572
|
interval = setInterval(async () => {
|
|
556
573
|
try {
|
|
574
|
+
pruneExpiredTokens();
|
|
557
575
|
const settings = await strapi2.documents(CONTENT_TYPES.SECURITY_SETTINGS).findFirst({});
|
|
558
576
|
const autoLogoutTime = (settings?.autoLogoutTime ?? DEFAULT_AUTOLOGOUT_TIME) * MS_PER_MINUTE;
|
|
559
577
|
const now = Date.now();
|
package/package.json
CHANGED