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