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