strapi-plugin-oidc 1.5.2 → 1.6.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 +143 -26
- package/dist/admin/{index-DWm8oOJF.js → index-DTOcUHZi.js} +342 -177
- package/dist/admin/{index-Dz3WlTpL.mjs → index-DmJadA2p.mjs} +344 -179
- package/dist/admin/{index-Dzf0bJC1.mjs → index-P9HriRms.mjs} +31 -6
- package/dist/admin/{index-CqgjBmJ5.js → index-f3cmU_tE.js} +31 -6
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/server/index.js +647 -175
- package/dist/server/index.mjs +648 -176
- package/package.json +1 -1
package/dist/server/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID, randomBytes } from "node:crypto";
|
|
1
|
+
import { randomUUID, randomBytes, createHash } from "node:crypto";
|
|
2
2
|
import pkceChallenge from "pkce-challenge";
|
|
3
3
|
import strapiUtils from "@strapi/utils";
|
|
4
4
|
import generator from "generate-password";
|
|
@@ -18,18 +18,31 @@ function resolveEnforceOIDC(strapi2, dbValue) {
|
|
|
18
18
|
if (configValue !== null) return configValue;
|
|
19
19
|
return dbValue ?? false;
|
|
20
20
|
}
|
|
21
|
+
const PLUGIN_UID = "plugin::strapi-plugin-oidc";
|
|
22
|
+
const DEFAULT_RETENTION_DAYS = 90;
|
|
23
|
+
function getPluginConfig() {
|
|
24
|
+
return strapi.config.get(PLUGIN_UID);
|
|
25
|
+
}
|
|
26
|
+
function getRetentionDays() {
|
|
27
|
+
const config2 = getPluginConfig();
|
|
28
|
+
return Number(config2.AUDIT_LOG_RETENTION_DAYS ?? DEFAULT_RETENTION_DAYS);
|
|
29
|
+
}
|
|
30
|
+
function isAuditLogEnabled() {
|
|
31
|
+
return getRetentionDays() !== 0;
|
|
32
|
+
}
|
|
21
33
|
async function bootstrap({ strapi: strapi2 }) {
|
|
34
|
+
const adminUrl = strapi2.config.get("admin.url", "/admin");
|
|
35
|
+
const authRoutes = [
|
|
36
|
+
`${adminUrl}/login`,
|
|
37
|
+
`${adminUrl}/register`,
|
|
38
|
+
`${adminUrl}/register-admin`,
|
|
39
|
+
`${adminUrl}/forgot-password`,
|
|
40
|
+
`${adminUrl}/reset-password`
|
|
41
|
+
];
|
|
42
|
+
const tokenRefreshPath = `${adminUrl}/token/refresh`;
|
|
22
43
|
const enforceOidcMiddleware = async (ctx, next) => {
|
|
23
|
-
const adminUrl = strapi2.config.get("admin.url", "/admin");
|
|
24
|
-
const authRoutes = [
|
|
25
|
-
`${adminUrl}/login`,
|
|
26
|
-
`${adminUrl}/register`,
|
|
27
|
-
`${adminUrl}/register-admin`,
|
|
28
|
-
`${adminUrl}/forgot-password`,
|
|
29
|
-
`${adminUrl}/reset-password`
|
|
30
|
-
];
|
|
31
44
|
const isPostAuth = authRoutes.includes(ctx.request.path) && ctx.request.method === "POST";
|
|
32
|
-
const isTokenRefresh = ctx.request.path ===
|
|
45
|
+
const isTokenRefresh = ctx.request.path === tokenRefreshPath && ctx.request.method === "POST";
|
|
33
46
|
if (isPostAuth || isTokenRefresh) {
|
|
34
47
|
try {
|
|
35
48
|
const whitelistService2 = strapi2.plugin("strapi-plugin-oidc").service("whitelist");
|
|
@@ -110,14 +123,12 @@ async function bootstrap({ strapi: strapi2 }) {
|
|
|
110
123
|
where: { oauth_type: "4" }
|
|
111
124
|
});
|
|
112
125
|
if (oidcRoleCount === 0) {
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
});
|
|
116
|
-
if (editorRole) {
|
|
126
|
+
const defaultRole = await strapi2.query("admin::role").findOne({ where: { code: "strapi-editor" } }) ?? await strapi2.query("admin::role").findOne({});
|
|
127
|
+
if (defaultRole) {
|
|
117
128
|
await strapi2.query("plugin::strapi-plugin-oidc.roles").create({
|
|
118
129
|
data: {
|
|
119
130
|
oauth_type: "4",
|
|
120
|
-
roles: [
|
|
131
|
+
roles: [defaultRole.id.toString()]
|
|
121
132
|
}
|
|
122
133
|
});
|
|
123
134
|
}
|
|
@@ -125,25 +136,18 @@ async function bootstrap({ strapi: strapi2 }) {
|
|
|
125
136
|
} catch (err) {
|
|
126
137
|
strapi2.log.warn("Could not initialize default OIDC role:", err.message);
|
|
127
138
|
}
|
|
128
|
-
strapi2.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (userWithRoles?.roles) {
|
|
141
|
-
const roleIds = userWithRoles.roles.map((r) => r.id.toString());
|
|
142
|
-
await query.update({
|
|
143
|
-
where: { id: whitelistEntry.id },
|
|
144
|
-
data: { roles: roleIds }
|
|
145
|
-
});
|
|
146
|
-
}
|
|
139
|
+
strapi2.cron.add({
|
|
140
|
+
"strapi-plugin-oidc-audit-log-cleanup": {
|
|
141
|
+
task: async () => {
|
|
142
|
+
try {
|
|
143
|
+
const retentionDays = getRetentionDays();
|
|
144
|
+
await strapi2.plugin("strapi-plugin-oidc").service("auditLog").cleanup(retentionDays);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
strapi2.log.warn("[strapi-plugin-oidc] Audit log cleanup failed:", err.message);
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
options: { rule: "0 0 * * *" }
|
|
150
|
+
// daily at midnight
|
|
147
151
|
}
|
|
148
152
|
});
|
|
149
153
|
}
|
|
@@ -164,41 +168,58 @@ const config = {
|
|
|
164
168
|
OIDC_GIVEN_NAME_FIELD: "given_name",
|
|
165
169
|
OIDC_END_SESSION_ENDPOINT: "",
|
|
166
170
|
OIDC_SSO_BUTTON_TEXT: "Login via SSO",
|
|
167
|
-
OIDC_ENFORCE: null
|
|
171
|
+
OIDC_ENFORCE: null,
|
|
168
172
|
// null = use DB setting; true/false = override DB (useful for lockout recovery)
|
|
173
|
+
AUDIT_LOG_RETENTION_DAYS: 90,
|
|
174
|
+
OIDC_GROUP_FIELD: "groups",
|
|
175
|
+
OIDC_GROUP_ROLE_MAP: "{}"
|
|
169
176
|
},
|
|
170
177
|
validator() {
|
|
171
178
|
}
|
|
172
179
|
};
|
|
173
|
-
const info$
|
|
180
|
+
const info$3 = { "singularName": "roles", "pluralName": "oidc-roles", "collectionName": "oidc-roles", "displayName": "oidc-role", "description": "" };
|
|
181
|
+
const options$2 = { "draftAndPublish": false };
|
|
182
|
+
const pluginOptions$2 = { "content-manager": { "visible": false }, "content-type-builder": { "visible": false } };
|
|
183
|
+
const attributes$2 = { "oauth_type": { "type": "string", "configurable": false, "required": true }, "roles": { "type": "json", "configurable": false } };
|
|
184
|
+
const schema$2 = {
|
|
185
|
+
info: info$3,
|
|
186
|
+
options: options$2,
|
|
187
|
+
pluginOptions: pluginOptions$2,
|
|
188
|
+
attributes: attributes$2
|
|
189
|
+
};
|
|
190
|
+
const roles = {
|
|
191
|
+
schema: schema$2
|
|
192
|
+
};
|
|
193
|
+
const info$2 = { "singularName": "whitelists", "pluralName": "whitelists", "collectionName": "whitelists", "displayName": "whitelist", "description": "" };
|
|
174
194
|
const options$1 = { "draftAndPublish": false };
|
|
175
195
|
const pluginOptions$1 = { "content-manager": { "visible": false }, "content-type-builder": { "visible": false } };
|
|
176
|
-
const attributes$1 = { "
|
|
196
|
+
const attributes$1 = { "email": { "type": "string", "configurable": false, "required": true, "unique": true } };
|
|
177
197
|
const schema$1 = {
|
|
178
198
|
info: info$2,
|
|
179
199
|
options: options$1,
|
|
180
200
|
pluginOptions: pluginOptions$1,
|
|
181
201
|
attributes: attributes$1
|
|
182
202
|
};
|
|
183
|
-
const
|
|
203
|
+
const whitelists = {
|
|
184
204
|
schema: schema$1
|
|
185
205
|
};
|
|
186
|
-
const info$1 = { "singularName": "
|
|
206
|
+
const info$1 = { "singularName": "audit-log", "pluralName": "audit-logs", "collectionName": "audit_logs", "displayName": "OIDC Audit Log" };
|
|
187
207
|
const options = { "draftAndPublish": false };
|
|
188
208
|
const pluginOptions = { "content-manager": { "visible": false }, "content-type-builder": { "visible": false } };
|
|
189
|
-
const attributes = { "
|
|
209
|
+
const attributes = { "action": { "type": "string", "required": true }, "email": { "type": "string" }, "ip": { "type": "string" }, "detailsKey": { "type": "string" }, "detailsParams": { "type": "json" } };
|
|
190
210
|
const schema = {
|
|
191
211
|
info: info$1,
|
|
192
212
|
options,
|
|
193
213
|
pluginOptions,
|
|
194
214
|
attributes
|
|
195
215
|
};
|
|
196
|
-
const
|
|
216
|
+
const auditLog$1 = {
|
|
197
217
|
schema
|
|
198
218
|
};
|
|
199
219
|
const contentTypes = {
|
|
200
220
|
roles,
|
|
201
|
-
whitelists
|
|
221
|
+
whitelists,
|
|
222
|
+
"audit-log": auditLog$1
|
|
202
223
|
};
|
|
203
224
|
function getExpiredCookieOptions(strapi2, ctx) {
|
|
204
225
|
const isProduction = strapi2.config.get("environment") === "production";
|
|
@@ -216,7 +237,131 @@ function clearAuthCookies(strapi2, ctx) {
|
|
|
216
237
|
const options2 = getExpiredCookieOptions(strapi2, ctx);
|
|
217
238
|
ctx.cookies.set("strapi_admin_refresh", "", options2);
|
|
218
239
|
ctx.cookies.set("oidc_authenticated", "", { ...options2, path: "/" });
|
|
240
|
+
ctx.cookies.set("oidc_access_token", "", { ...options2, path: "/" });
|
|
241
|
+
ctx.cookies.set("oidc_user_email", "", { ...options2, path: "/" });
|
|
219
242
|
}
|
|
243
|
+
const errorCodes = {
|
|
244
|
+
TOKEN_EXCHANGE_FAILED: "TOKEN_EXCHANGE_FAILED",
|
|
245
|
+
USERINFO_FETCH_FAILED: "USERINFO_FETCH_FAILED",
|
|
246
|
+
ID_TOKEN_PARSE_FAILED: "ID_TOKEN_PARSE_FAILED",
|
|
247
|
+
NONCE_MISMATCH: "NONCE_MISMATCH",
|
|
248
|
+
ROLE_UPDATE_FAILED: "ROLE_UPDATE_FAILED",
|
|
249
|
+
USER_CREATION_FAILED: "USER_CREATION_FAILED",
|
|
250
|
+
WHITELIST_CHECK_FAILED: "WHITELIST_CHECK_FAILED"
|
|
251
|
+
};
|
|
252
|
+
function getErrorDetail(key, params) {
|
|
253
|
+
switch (key) {
|
|
254
|
+
case "token_exchange_failed":
|
|
255
|
+
return `Token exchange failed with HTTP status ${params?.status ?? "unknown"}`;
|
|
256
|
+
case "userinfo_fetch_failed":
|
|
257
|
+
return `UserInfo endpoint returned HTTP ${params?.status ?? "unknown"}`;
|
|
258
|
+
case "role_update_failed":
|
|
259
|
+
return `Role update failed for user ${params?.userId}: ${params?.error ?? "unknown"}`;
|
|
260
|
+
case "user_creation_failed":
|
|
261
|
+
return `User creation failed for ${params?.email}: ${params?.error ?? "unknown"}`;
|
|
262
|
+
case "id_token_parse_failed":
|
|
263
|
+
return `ID token parse failed: ${params?.error ?? "unknown"}`;
|
|
264
|
+
case "sign_in_unknown":
|
|
265
|
+
return `Unknown sign-in error: ${params?.error ?? "unknown"}`;
|
|
266
|
+
default:
|
|
267
|
+
return void 0;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const en = {
|
|
271
|
+
"global.plugins.strapi-plugin-oidc": "OIDC Plugin",
|
|
272
|
+
"page.title": "Configure OIDC default role(s) and access controls.",
|
|
273
|
+
"roles.notes": "Select the default role(s) assigned to new users upon their first login. This setting does not affect existing users.",
|
|
274
|
+
"page.save": "Save Changes",
|
|
275
|
+
"page.save.success": "Updated settings",
|
|
276
|
+
"page.save.error": "Update failed.",
|
|
277
|
+
"page.add": "Add",
|
|
278
|
+
"page.cancel": "Cancel",
|
|
279
|
+
"page.ok": "OK",
|
|
280
|
+
"roles.title": "Default Role(s)",
|
|
281
|
+
"roles.placeholder": "Select default role(s)",
|
|
282
|
+
"whitelist.title": "Whitelist",
|
|
283
|
+
"whitelist.error.unique": "Already registered email address.",
|
|
284
|
+
"whitelist.description": "Restrict OIDC authentication to specific email addresses. Users receive roles from the default OIDC role mapping or their OIDC group.",
|
|
285
|
+
"alert.title.success": "Success",
|
|
286
|
+
"alert.title.error": "Error",
|
|
287
|
+
"alert.title.info": "Info",
|
|
288
|
+
"pagination.previous": "Go to previous page",
|
|
289
|
+
"pagination.page": "Go to page {page}",
|
|
290
|
+
"pagination.next": "Go to next page",
|
|
291
|
+
"whitelist.table.no": "No.",
|
|
292
|
+
"whitelist.table.email": "Email",
|
|
293
|
+
"whitelist.table.created": "Created At",
|
|
294
|
+
"whitelist.delete.title": "Confirmation",
|
|
295
|
+
"whitelist.delete.description": "Are you sure you want to delete:",
|
|
296
|
+
"whitelist.delete.note": "This will not delete the user account in Strapi.",
|
|
297
|
+
"whitelist.toggle.enabled": "Enabled",
|
|
298
|
+
"whitelist.toggle.disabled": "Disabled",
|
|
299
|
+
"whitelist.email.placeholder": "Email address",
|
|
300
|
+
"whitelist.roles.placeholder": "Select specific role(s)",
|
|
301
|
+
"whitelist.table.roles": "Role(s)",
|
|
302
|
+
"whitelist.table.empty": "No email addresses",
|
|
303
|
+
"whitelist.delete.label": "Delete",
|
|
304
|
+
"page.title.oidc": "OIDC",
|
|
305
|
+
"enforce.title": "Enforce OIDC Login",
|
|
306
|
+
"enforce.toggle.enabled": "Enabled",
|
|
307
|
+
"enforce.toggle.disabled": "Disabled",
|
|
308
|
+
"enforce.warning": "Make sure OIDC is setup correctly before saving changes, you won't be able to login normally.",
|
|
309
|
+
"enforce.config.info": "Enforcement is controlled by the OIDC_ENFORCE config variable and cannot be changed here.",
|
|
310
|
+
"login.settings.title": "Login Settings",
|
|
311
|
+
"login.sso": "Login via SSO",
|
|
312
|
+
"whitelist.count": "{count, plural, one {# entry} other {# entries}}",
|
|
313
|
+
"whitelist.import": "Import",
|
|
314
|
+
"whitelist.export": "Export",
|
|
315
|
+
"whitelist.delete.all.label": "Delete All",
|
|
316
|
+
"whitelist.delete.all.title": "Delete All Entries",
|
|
317
|
+
"whitelist.delete.all.description": "This will permanently remove all {count, plural, one {# entry} other {# entries}} from the whitelist. Unsaved changes will be lost.",
|
|
318
|
+
"whitelist.import.error": "Invalid file — expected a JSON array of objects with an email field.",
|
|
319
|
+
"whitelist.import.success": "Imported {count, plural, one {# new entry} other {# new entries}}.",
|
|
320
|
+
"whitelist.import.none": "No new entries — all emails are already in the whitelist.",
|
|
321
|
+
"unsaved.title": "Unsaved Changes",
|
|
322
|
+
"unsaved.description": "You have unsaved changes that will be lost if you leave. Do you want to continue?",
|
|
323
|
+
"unsaved.confirm": "Leave",
|
|
324
|
+
"unsaved.cancel": "Stay",
|
|
325
|
+
"whitelist.table.roles.default": "(Default)",
|
|
326
|
+
"auditlog.title": "Audit Logs",
|
|
327
|
+
"auditlog.export": "Download",
|
|
328
|
+
"auditlog.table.timestamp": "Timestamp",
|
|
329
|
+
"auditlog.table.action": "Action",
|
|
330
|
+
"auditlog.table.email": "Email",
|
|
331
|
+
"auditlog.table.ip": "IP",
|
|
332
|
+
"auditlog.table.details": "Details",
|
|
333
|
+
"auditlog.table.empty": "No audit log entries",
|
|
334
|
+
"auditlog.clear": "Clear Logs",
|
|
335
|
+
"auditlog.clear.title": "Clear All Logs",
|
|
336
|
+
"auditlog.clear.description": "This will permanently delete all {count, plural, one {# audit log entry} other {# audit log entries}}. This action cannot be undone.",
|
|
337
|
+
"auditlog.clear.success": "Audit logs cleared",
|
|
338
|
+
"auditlog.clear.error": "Failed to clear audit logs",
|
|
339
|
+
"auditlog.export.error": "Failed to export audit logs",
|
|
340
|
+
"auditlog.action.login_success": "User successfully authenticated via OIDC and was granted access.",
|
|
341
|
+
"auditlog.action.user_created": "A new Strapi admin account was created for this user on their first OIDC login.",
|
|
342
|
+
"auditlog.action.logout": "User logged out and their OIDC session was ended.",
|
|
343
|
+
"auditlog.action.session_expired": "The OIDC access token had expired by the time the user logged out, so the provider session could not be terminated remotely.",
|
|
344
|
+
"auditlog.action.login_failure": "An unexpected error occurred during the OIDC login flow.",
|
|
345
|
+
"auditlog.action.missing_code": "The OIDC callback was received without an authorisation code. This may indicate a misconfigured provider or a tampered request.",
|
|
346
|
+
"auditlog.action.state_mismatch": "The state parameter in the callback did not match the one stored in the session. This may indicate a CSRF attempt or an expired login session.",
|
|
347
|
+
"auditlog.action.nonce_mismatch": "The nonce in the ID token did not match the one generated at login. This may indicate a token replay attack.",
|
|
348
|
+
"auditlog.action.token_exchange_failed": "The authorisation code could not be exchanged for tokens. The OIDC provider rejected the request.",
|
|
349
|
+
"auditlog.action.whitelist_rejected": "The user's email address is not on the whitelist. Access was denied.",
|
|
350
|
+
"user.missing_code": "Authorisation code was not received from the OIDC provider.",
|
|
351
|
+
"user.invalid_state": "State parameter mismatch. Please restart the login flow.",
|
|
352
|
+
"user.signInError": "Authentication failed. Please try again."
|
|
353
|
+
};
|
|
354
|
+
const userFacingMessages = {
|
|
355
|
+
get missing_code() {
|
|
356
|
+
return en["user.missing_code"];
|
|
357
|
+
},
|
|
358
|
+
get invalid_state() {
|
|
359
|
+
return en["user.invalid_state"];
|
|
360
|
+
},
|
|
361
|
+
get signInError() {
|
|
362
|
+
return en["user.signInError"];
|
|
363
|
+
}
|
|
364
|
+
};
|
|
220
365
|
const REQUIRED_CONFIG_KEYS = [
|
|
221
366
|
"OIDC_CLIENT_ID",
|
|
222
367
|
"OIDC_CLIENT_SECRET",
|
|
@@ -254,15 +399,16 @@ async function oidcSignIn(ctx) {
|
|
|
254
399
|
ctx.cookies.set("oidc_code_verifier", codeVerifier, cookieOptions);
|
|
255
400
|
ctx.cookies.set("oidc_state", state, cookieOptions);
|
|
256
401
|
ctx.cookies.set("oidc_nonce", nonce, cookieOptions);
|
|
257
|
-
const params = new URLSearchParams(
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
402
|
+
const params = new URLSearchParams({
|
|
403
|
+
response_type: "code",
|
|
404
|
+
client_id: OIDC_CLIENT_ID,
|
|
405
|
+
redirect_uri: OIDC_REDIRECT_URI,
|
|
406
|
+
scope: OIDC_SCOPE,
|
|
407
|
+
code_challenge: codeChallenge,
|
|
408
|
+
code_challenge_method: "S256",
|
|
409
|
+
state,
|
|
410
|
+
nonce
|
|
411
|
+
});
|
|
266
412
|
const authorizationUrl = `${OIDC_AUTHORIZATION_ENDPOINT}?${params.toString()}`;
|
|
267
413
|
ctx.set("Location", authorizationUrl);
|
|
268
414
|
return ctx.send({}, 302);
|
|
@@ -297,16 +443,38 @@ async function exchangeTokenAndFetchUserInfo(config2, params, expectedNonce) {
|
|
|
297
443
|
if (!userResponse.ok) {
|
|
298
444
|
throw new Error("Failed to fetch user info");
|
|
299
445
|
}
|
|
300
|
-
|
|
446
|
+
const userInfo = await userResponse.json();
|
|
447
|
+
return { userInfo, accessToken: tokenData.access_token };
|
|
301
448
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
if (
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
449
|
+
function resolveRolesFromGroups(userInfo, config2, availableRoles) {
|
|
450
|
+
const rawGroups = userInfo[config2.OIDC_GROUP_FIELD];
|
|
451
|
+
if (!Array.isArray(rawGroups) || rawGroups.length === 0) return [];
|
|
452
|
+
const groups = rawGroups.filter((g) => typeof g === "string");
|
|
453
|
+
const raw = config2.OIDC_GROUP_ROLE_MAP;
|
|
454
|
+
let groupRoleMap;
|
|
455
|
+
try {
|
|
456
|
+
groupRoleMap = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
457
|
+
} catch {
|
|
458
|
+
return [];
|
|
309
459
|
}
|
|
460
|
+
const roleIdSet = /* @__PURE__ */ new Set();
|
|
461
|
+
for (const group of groups) {
|
|
462
|
+
const roleNames = groupRoleMap[group];
|
|
463
|
+
if (!roleNames) continue;
|
|
464
|
+
for (const name of roleNames) {
|
|
465
|
+
const match = availableRoles.find((r) => r.name === name);
|
|
466
|
+
if (match) roleIdSet.add(String(match.id));
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return [...roleIdSet];
|
|
470
|
+
}
|
|
471
|
+
async function resolveRoles(userInfo, config2, roleService2, availableRoles) {
|
|
472
|
+
const groupRoles = resolveRolesFromGroups(userInfo, config2, availableRoles);
|
|
473
|
+
if (groupRoles.length > 0) return groupRoles;
|
|
474
|
+
const oidcRoles = await roleService2.oidcRoles();
|
|
475
|
+
return oidcRoles?.roles || [];
|
|
476
|
+
}
|
|
477
|
+
async function registerNewUser(oauthService2, email, userResponseData, config2, ctx, roles2) {
|
|
310
478
|
const defaultLocale = oauthService2.localeFindByHeader(
|
|
311
479
|
ctx.request.headers
|
|
312
480
|
);
|
|
@@ -321,21 +489,106 @@ async function registerNewUser(userService, oauthService2, roleService2, email,
|
|
|
321
489
|
return activateUser;
|
|
322
490
|
}
|
|
323
491
|
async function handleUserAuthentication(userService, oauthService2, roleService2, whitelistService2, userResponseData, config2, ctx) {
|
|
324
|
-
const
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
);
|
|
492
|
+
const rawEmail = String(userResponseData.email ?? "");
|
|
493
|
+
const email = rawEmail.toLowerCase();
|
|
494
|
+
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
495
|
+
throw new Error("Invalid email address received from OIDC provider");
|
|
496
|
+
}
|
|
497
|
+
await whitelistService2.checkWhitelistForEmail(email);
|
|
498
|
+
const allRoles = await strapi.db.query("admin::role").findMany();
|
|
499
|
+
const roles2 = await resolveRoles(userResponseData, config2, roleService2, allRoles);
|
|
500
|
+
const resolvedRoleNames = allRoles.filter((r) => roles2.includes(String(r.id))).map((r) => r.name);
|
|
501
|
+
let userCreated = false;
|
|
502
|
+
let rolesUpdated = false;
|
|
503
|
+
let activateUser = await userService.findOneByEmail(email);
|
|
504
|
+
if (!activateUser) {
|
|
505
|
+
activateUser = await registerNewUser(oauthService2, email, userResponseData, config2, ctx, roles2);
|
|
506
|
+
userCreated = true;
|
|
507
|
+
} else if (roles2.length > 0) {
|
|
508
|
+
const currentRoleIds = new Set((activateUser.roles ?? []).map((r) => String(r.id)));
|
|
509
|
+
const newRoleIds = new Set(roles2);
|
|
510
|
+
const rolesChanged = currentRoleIds.size !== newRoleIds.size || [...newRoleIds].some((id) => !currentRoleIds.has(id));
|
|
511
|
+
if (rolesChanged) {
|
|
512
|
+
try {
|
|
513
|
+
strapi.log.info(
|
|
514
|
+
`[OIDC] Roles updated for user ${activateUser.id}: [${[...currentRoleIds].join(",")}] -> [${roles2.join(",")}]`
|
|
515
|
+
);
|
|
516
|
+
await strapi.db.query("admin::user").update({
|
|
517
|
+
where: { id: activateUser.id },
|
|
518
|
+
data: { roles: roles2 }
|
|
519
|
+
});
|
|
520
|
+
rolesUpdated = true;
|
|
521
|
+
} catch (updateErr) {
|
|
522
|
+
strapi.log.error({
|
|
523
|
+
code: errorCodes.ROLE_UPDATE_FAILED,
|
|
524
|
+
userId: activateUser.id,
|
|
525
|
+
detail: getErrorDetail("role_update_failed", {
|
|
526
|
+
userId: activateUser.id,
|
|
527
|
+
error: updateErr.message
|
|
528
|
+
})
|
|
529
|
+
});
|
|
530
|
+
throw updateErr;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
336
534
|
const jwtToken = await oauthService2.generateToken(activateUser, ctx);
|
|
337
535
|
oauthService2.triggerSignInSuccess(activateUser);
|
|
338
|
-
return { activateUser, jwtToken };
|
|
536
|
+
return { activateUser, jwtToken, userCreated, rolesUpdated, resolvedRoleNames };
|
|
537
|
+
}
|
|
538
|
+
function classifyOidcError(msg, userInfo) {
|
|
539
|
+
const errorMap = [
|
|
540
|
+
{
|
|
541
|
+
test: (m) => m.includes("whitelist"),
|
|
542
|
+
result: {
|
|
543
|
+
action: "whitelist_rejected",
|
|
544
|
+
code: errorCodes.WHITELIST_CHECK_FAILED,
|
|
545
|
+
key: "whitelist_rejected"
|
|
546
|
+
}
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
test: (m) => m === "Nonce mismatch",
|
|
550
|
+
result: { action: "nonce_mismatch", code: errorCodes.NONCE_MISMATCH }
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
test: (m) => m === "Token exchange failed",
|
|
554
|
+
result: { action: "token_exchange_failed", code: errorCodes.TOKEN_EXCHANGE_FAILED }
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
test: (m) => m === "Failed to fetch user info",
|
|
558
|
+
result: {
|
|
559
|
+
action: "login_failure",
|
|
560
|
+
code: errorCodes.USERINFO_FETCH_FAILED,
|
|
561
|
+
key: "userinfo_fetch_failed"
|
|
562
|
+
}
|
|
563
|
+
},
|
|
564
|
+
{
|
|
565
|
+
test: (m) => m === "Failed to parse ID token",
|
|
566
|
+
result: {
|
|
567
|
+
action: "login_failure",
|
|
568
|
+
code: errorCodes.ID_TOKEN_PARSE_FAILED,
|
|
569
|
+
key: "id_token_parse_failed",
|
|
570
|
+
params: { error: msg }
|
|
571
|
+
}
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
test: (m) => m === "User creation failed" || m.includes("createUser"),
|
|
575
|
+
result: {
|
|
576
|
+
action: "login_failure",
|
|
577
|
+
code: errorCodes.USER_CREATION_FAILED,
|
|
578
|
+
key: "user_creation_failed",
|
|
579
|
+
params: userInfo?.email ? { email: userInfo.email, error: msg } : void 0
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
];
|
|
583
|
+
for (const { test, result } of errorMap) {
|
|
584
|
+
if (test(msg)) return result;
|
|
585
|
+
}
|
|
586
|
+
return {
|
|
587
|
+
action: "login_failure",
|
|
588
|
+
code: errorCodes.TOKEN_EXCHANGE_FAILED,
|
|
589
|
+
key: "sign_in_unknown",
|
|
590
|
+
params: { error: msg || "unknown" }
|
|
591
|
+
};
|
|
339
592
|
}
|
|
340
593
|
async function oidcSignInCallback(ctx) {
|
|
341
594
|
const config2 = configValidation();
|
|
@@ -343,8 +596,10 @@ async function oidcSignInCallback(ctx) {
|
|
|
343
596
|
const oauthService2 = strapi.plugin("strapi-plugin-oidc").service("oauth");
|
|
344
597
|
const roleService2 = strapi.plugin("strapi-plugin-oidc").service("role");
|
|
345
598
|
const whitelistService2 = strapi.plugin("strapi-plugin-oidc").service("whitelist");
|
|
599
|
+
const auditLog2 = strapi.plugin("strapi-plugin-oidc").service("auditLog");
|
|
346
600
|
if (!ctx.query.code) {
|
|
347
|
-
|
|
601
|
+
await auditLog2.log({ action: "missing_code", ip: ctx.ip });
|
|
602
|
+
return ctx.send(oauthService2.renderSignUpError(userFacingMessages.missing_code));
|
|
348
603
|
}
|
|
349
604
|
const oidcState = ctx.cookies.get("oidc_state");
|
|
350
605
|
const codeVerifier = ctx.cookies.get("oidc_code_verifier");
|
|
@@ -353,53 +608,129 @@ async function oidcSignInCallback(ctx) {
|
|
|
353
608
|
ctx.cookies.set("oidc_code_verifier", null);
|
|
354
609
|
ctx.cookies.set("oidc_nonce", null);
|
|
355
610
|
if (!ctx.query.state || ctx.query.state !== oidcState) {
|
|
356
|
-
|
|
611
|
+
await auditLog2.log({ action: "state_mismatch", ip: ctx.ip });
|
|
612
|
+
return ctx.send(oauthService2.renderSignUpError(userFacingMessages.invalid_state));
|
|
357
613
|
}
|
|
358
|
-
const params = new URLSearchParams(
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
614
|
+
const params = new URLSearchParams({
|
|
615
|
+
code: ctx.query.code,
|
|
616
|
+
client_id: config2.OIDC_CLIENT_ID,
|
|
617
|
+
client_secret: config2.OIDC_CLIENT_SECRET,
|
|
618
|
+
redirect_uri: config2.OIDC_REDIRECT_URI,
|
|
619
|
+
grant_type: config2.OIDC_GRANT_TYPE,
|
|
620
|
+
code_verifier: codeVerifier ?? ""
|
|
621
|
+
});
|
|
622
|
+
let userInfo;
|
|
365
623
|
try {
|
|
366
|
-
const
|
|
367
|
-
|
|
624
|
+
const exchangeResult = await exchangeTokenAndFetchUserInfo(config2, params, oidcNonce ?? "");
|
|
625
|
+
userInfo = exchangeResult.userInfo;
|
|
626
|
+
const accessToken = exchangeResult.accessToken;
|
|
627
|
+
const isProduction = strapi.config.get("environment") === "production";
|
|
628
|
+
ctx.cookies.set("oidc_access_token", accessToken, {
|
|
629
|
+
httpOnly: true,
|
|
630
|
+
maxAge: 3e5,
|
|
631
|
+
// 5 minutes — matches typical provider access token lifetime
|
|
632
|
+
secure: isProduction && ctx.request.secure,
|
|
633
|
+
sameSite: "lax"
|
|
634
|
+
});
|
|
635
|
+
const { activateUser, jwtToken, userCreated, rolesUpdated, resolvedRoleNames } = await handleUserAuthentication(
|
|
368
636
|
userService,
|
|
369
637
|
oauthService2,
|
|
370
638
|
roleService2,
|
|
371
639
|
whitelistService2,
|
|
372
|
-
|
|
640
|
+
userInfo,
|
|
373
641
|
config2,
|
|
374
642
|
ctx
|
|
375
643
|
);
|
|
644
|
+
const identityCookieOptions = {
|
|
645
|
+
httpOnly: true,
|
|
646
|
+
path: "/",
|
|
647
|
+
secure: isProduction && ctx.request.secure,
|
|
648
|
+
sameSite: "lax"
|
|
649
|
+
};
|
|
650
|
+
ctx.cookies.set("oidc_user_email", activateUser.email, identityCookieOptions);
|
|
651
|
+
if (userCreated) {
|
|
652
|
+
await auditLog2.log({
|
|
653
|
+
action: "user_created",
|
|
654
|
+
email: activateUser.email,
|
|
655
|
+
ip: ctx.ip,
|
|
656
|
+
detailsKey: "user_created",
|
|
657
|
+
detailsParams: { roles: resolvedRoleNames.join(", ") }
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
await auditLog2.log({
|
|
661
|
+
action: "login_success",
|
|
662
|
+
email: activateUser.email,
|
|
663
|
+
ip: ctx.ip,
|
|
664
|
+
detailsKey: rolesUpdated ? "roles_updated" : void 0,
|
|
665
|
+
detailsParams: rolesUpdated ? { roles: resolvedRoleNames.join(", ") } : void 0
|
|
666
|
+
});
|
|
376
667
|
const nonce = randomUUID();
|
|
377
668
|
const html = oauthService2.renderSignUpSuccess(jwtToken, activateUser, nonce);
|
|
378
669
|
ctx.set("Content-Security-Policy", `script-src 'nonce-${nonce}'`);
|
|
379
670
|
ctx.send(html);
|
|
380
671
|
} catch (e) {
|
|
381
|
-
|
|
382
|
-
|
|
672
|
+
const msg = e.message ?? "";
|
|
673
|
+
const errorInfo = classifyOidcError(msg, userInfo);
|
|
674
|
+
await auditLog2.log({
|
|
675
|
+
action: errorInfo.action,
|
|
676
|
+
email: userInfo?.email,
|
|
677
|
+
ip: ctx.ip,
|
|
678
|
+
detailsKey: errorInfo.action,
|
|
679
|
+
detailsParams: errorInfo.action === "login_failure" ? { message: msg } : void 0
|
|
680
|
+
});
|
|
681
|
+
strapi.log.error({
|
|
682
|
+
code: errorInfo.code,
|
|
683
|
+
phase: "oidc_callback",
|
|
684
|
+
message: msg || "Unknown sign-in error",
|
|
685
|
+
detail: errorInfo.key ? getErrorDetail(errorInfo.key, errorInfo.params) : void 0,
|
|
686
|
+
email: userInfo?.email
|
|
687
|
+
});
|
|
688
|
+
ctx.send(oauthService2.renderSignUpError(userFacingMessages.signInError));
|
|
383
689
|
}
|
|
384
690
|
}
|
|
385
691
|
async function logout(ctx) {
|
|
386
692
|
const config2 = strapi.config.get("plugin::strapi-plugin-oidc");
|
|
693
|
+
const auditLog2 = strapi.plugin("strapi-plugin-oidc").service("auditLog");
|
|
387
694
|
const logoutUrl = config2.OIDC_END_SESSION_ENDPOINT;
|
|
695
|
+
const adminPanelUrl = strapi.config.get("admin.url", "/admin");
|
|
388
696
|
const isOidcSession = !!ctx.cookies.get("oidc_authenticated");
|
|
697
|
+
const accessToken = ctx.cookies.get("oidc_access_token");
|
|
698
|
+
const userEmail = ctx.cookies.get("oidc_user_email") ?? void 0;
|
|
389
699
|
clearAuthCookies(strapi, ctx);
|
|
700
|
+
if (logoutUrl && isOidcSession && accessToken) {
|
|
701
|
+
try {
|
|
702
|
+
const response = await fetch(config2.OIDC_USERINFO_ENDPOINT, {
|
|
703
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
704
|
+
});
|
|
705
|
+
if (response.ok) {
|
|
706
|
+
if (userEmail)
|
|
707
|
+
auditLog2.log({ action: "logout", email: userEmail, ip: ctx.ip }).catch(() => {
|
|
708
|
+
});
|
|
709
|
+
return ctx.redirect(logoutUrl);
|
|
710
|
+
}
|
|
711
|
+
if (userEmail)
|
|
712
|
+
await auditLog2.log({ action: "session_expired", email: userEmail, ip: ctx.ip });
|
|
713
|
+
return ctx.redirect(`${adminPanelUrl}/auth/login`);
|
|
714
|
+
} catch {
|
|
715
|
+
if (userEmail)
|
|
716
|
+
await auditLog2.log({ action: "session_expired", email: userEmail, ip: ctx.ip });
|
|
717
|
+
return ctx.redirect(`${adminPanelUrl}/auth/login`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
if (isOidcSession && userEmail) {
|
|
721
|
+
await auditLog2.log({ action: "logout", email: userEmail, ip: ctx.ip });
|
|
722
|
+
}
|
|
390
723
|
if (logoutUrl && isOidcSession) {
|
|
391
|
-
ctx.redirect(logoutUrl);
|
|
392
|
-
} else {
|
|
393
|
-
const adminPanelUrl = strapi.config.get("admin.url", "/admin");
|
|
394
|
-
ctx.redirect(`${adminPanelUrl}/auth/login`);
|
|
724
|
+
return ctx.redirect(logoutUrl);
|
|
395
725
|
}
|
|
726
|
+
ctx.redirect(`${adminPanelUrl}/auth/login`);
|
|
396
727
|
}
|
|
397
728
|
const oidc = {
|
|
398
729
|
oidcSignIn,
|
|
399
730
|
oidcSignInCallback,
|
|
400
731
|
logout
|
|
401
732
|
};
|
|
402
|
-
async function find(ctx) {
|
|
733
|
+
async function find$1(ctx) {
|
|
403
734
|
const roleService2 = strapi.plugin("strapi-plugin-oidc").service("role");
|
|
404
735
|
const roles2 = await roleService2.find();
|
|
405
736
|
const oidcConstants = roleService2.getOidcRoles();
|
|
@@ -418,14 +749,23 @@ async function update(ctx) {
|
|
|
418
749
|
await roleService2.update(roles2);
|
|
419
750
|
ctx.send({}, 204);
|
|
420
751
|
} catch (e) {
|
|
421
|
-
|
|
752
|
+
strapi.log.error(e);
|
|
422
753
|
ctx.send({}, 400);
|
|
423
754
|
}
|
|
424
755
|
}
|
|
425
756
|
const role = {
|
|
426
|
-
find,
|
|
757
|
+
find: find$1,
|
|
427
758
|
update
|
|
428
759
|
};
|
|
760
|
+
function formatDatetimeForFilename(date) {
|
|
761
|
+
const year = date.getFullYear();
|
|
762
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
763
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
764
|
+
const hours = String(date.getHours()).padStart(2, "0");
|
|
765
|
+
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
766
|
+
const seconds = String(date.getSeconds()).padStart(2, "0");
|
|
767
|
+
return `${year}${month}${day}_${hours}${minutes}${seconds}`;
|
|
768
|
+
}
|
|
429
769
|
function getWhitelistService() {
|
|
430
770
|
return strapi.plugin("strapi-plugin-oidc").service("whitelist");
|
|
431
771
|
}
|
|
@@ -437,7 +777,8 @@ async function info(ctx) {
|
|
|
437
777
|
useWhitelist: settings.useWhitelist,
|
|
438
778
|
enforceOIDC: resolveEnforceOIDC(strapi, settings.enforceOIDC),
|
|
439
779
|
enforceOIDCConfig: getEnforceOIDCConfig(strapi),
|
|
440
|
-
whitelistUsers
|
|
780
|
+
whitelistUsers,
|
|
781
|
+
auditLogEnabled: isAuditLogEnabled()
|
|
441
782
|
};
|
|
442
783
|
}
|
|
443
784
|
async function updateSettings(ctx) {
|
|
@@ -463,32 +804,25 @@ async function publicSettings(ctx) {
|
|
|
463
804
|
};
|
|
464
805
|
}
|
|
465
806
|
async function register(ctx) {
|
|
466
|
-
const { email
|
|
807
|
+
const { email } = ctx.request.body;
|
|
467
808
|
if (!email) {
|
|
468
809
|
ctx.body = { message: "Please enter a valid email address" };
|
|
469
810
|
return;
|
|
470
811
|
}
|
|
471
812
|
const rawEmails = Array.isArray(email) ? email : email.split(",");
|
|
472
813
|
const emailList = rawEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean);
|
|
473
|
-
const existingUsers = await strapi.query("admin::user").findMany({
|
|
474
|
-
where: { email: { $in: emailList } },
|
|
475
|
-
populate: ["roles"]
|
|
476
|
-
});
|
|
477
|
-
const existingUsersByEmail = new Map(existingUsers.map((u) => [u.email, u]));
|
|
478
814
|
const whitelistService2 = getWhitelistService();
|
|
479
815
|
let matchedExistingUsersCount = 0;
|
|
480
816
|
for (const singleEmail of emailList) {
|
|
481
|
-
const existingUser =
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
matchedExistingUsersCount++;
|
|
486
|
-
}
|
|
817
|
+
const existingUser = await strapi.query("admin::user").findOne({
|
|
818
|
+
where: { email: singleEmail }
|
|
819
|
+
});
|
|
820
|
+
if (existingUser) matchedExistingUsersCount++;
|
|
487
821
|
const alreadyWhitelisted = await strapi.query("plugin::strapi-plugin-oidc.whitelists").findOne({
|
|
488
822
|
where: { email: singleEmail }
|
|
489
823
|
});
|
|
490
824
|
if (!alreadyWhitelisted) {
|
|
491
|
-
await whitelistService2.registerUser(singleEmail
|
|
825
|
+
await whitelistService2.registerUser(singleEmail);
|
|
492
826
|
}
|
|
493
827
|
}
|
|
494
828
|
ctx.body = { matchedExistingUsersCount };
|
|
@@ -503,81 +837,52 @@ async function deleteAll(ctx) {
|
|
|
503
837
|
await strapi.query("plugin::strapi-plugin-oidc.whitelists").deleteMany({});
|
|
504
838
|
ctx.body = {};
|
|
505
839
|
}
|
|
840
|
+
async function exportWhitelist(ctx) {
|
|
841
|
+
const datetime = formatDatetimeForFilename(/* @__PURE__ */ new Date());
|
|
842
|
+
ctx.set("Content-Type", "application/json");
|
|
843
|
+
ctx.set("Content-Disposition", `attachment; filename="strapi-oidc-whitelist-${datetime}.json"`);
|
|
844
|
+
const whitelistService2 = getWhitelistService();
|
|
845
|
+
const users = await whitelistService2.getUsers();
|
|
846
|
+
ctx.body = users.map((u) => ({ email: u.email }));
|
|
847
|
+
}
|
|
506
848
|
async function importUsers(ctx) {
|
|
507
849
|
const { users } = ctx.request.body;
|
|
508
850
|
if (!Array.isArray(users)) {
|
|
509
851
|
ctx.status = 400;
|
|
510
|
-
ctx.body = { error: "Expected { users: [{email
|
|
852
|
+
ctx.body = { error: "Expected { users: [{email}] }" };
|
|
511
853
|
return;
|
|
512
854
|
}
|
|
513
|
-
const
|
|
514
|
-
const
|
|
515
|
-
const resolveRole = (nameOrId) => roleNameToId.get(nameOrId) ?? nameOrId;
|
|
516
|
-
const normalized = users.filter((u) => u?.email).map((u) => ({
|
|
517
|
-
email: String(u.email).trim().toLowerCase(),
|
|
518
|
-
roles: (Array.isArray(u.roles) ? u.roles : []).map(resolveRole)
|
|
519
|
-
}));
|
|
520
|
-
const seen = /* @__PURE__ */ new Set();
|
|
521
|
-
const deduped = normalized.filter((u) => {
|
|
522
|
-
if (seen.has(u.email)) return false;
|
|
523
|
-
seen.add(u.email);
|
|
524
|
-
return true;
|
|
525
|
-
});
|
|
526
|
-
const strapiUsers = await strapi.query("admin::user").findMany({
|
|
527
|
-
where: { email: { $in: deduped.map((u) => u.email) } },
|
|
528
|
-
populate: ["roles"]
|
|
529
|
-
});
|
|
530
|
-
const strapiUserMap = new Map(strapiUsers.map((u) => [u.email, u]));
|
|
855
|
+
const normalized = users.filter((u) => u?.email).map((u) => String(u.email).trim().toLowerCase()).filter((email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email));
|
|
856
|
+
const deduped = [...new Set(normalized)];
|
|
531
857
|
const whitelistService2 = getWhitelistService();
|
|
532
858
|
const existing = await whitelistService2.getUsers();
|
|
533
859
|
const existingEmails = new Set(existing.map((u) => u.email));
|
|
534
860
|
let importedCount = 0;
|
|
535
|
-
for (const
|
|
536
|
-
if (existingEmails.has(
|
|
537
|
-
|
|
538
|
-
const finalRoles = strapiUser?.roles?.length ? strapiUser.roles.map((r) => String(r.id)) : user.roles;
|
|
539
|
-
await whitelistService2.registerUser(user.email, finalRoles);
|
|
861
|
+
for (const email of deduped) {
|
|
862
|
+
if (existingEmails.has(email)) continue;
|
|
863
|
+
await whitelistService2.registerUser(email);
|
|
540
864
|
importedCount++;
|
|
541
865
|
}
|
|
542
866
|
ctx.body = { importedCount };
|
|
543
867
|
}
|
|
544
868
|
async function syncUsers(ctx) {
|
|
545
869
|
const { users: rawUsers } = ctx.request.body;
|
|
546
|
-
const
|
|
870
|
+
const emails = rawUsers.map((u) => String(u.email).toLowerCase()).filter((e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e));
|
|
547
871
|
const whitelistService2 = getWhitelistService();
|
|
548
872
|
const currentUsers = await whitelistService2.getUsers();
|
|
549
|
-
|
|
550
|
-
const emailsToSync = users.map((u) => u.email);
|
|
551
|
-
const existingStrapiUsers = await strapi.query("admin::user").findMany({
|
|
552
|
-
where: { email: { $in: emailsToSync } },
|
|
553
|
-
populate: ["roles"]
|
|
554
|
-
});
|
|
555
|
-
const syncEmailSet = new Set(emailsToSync);
|
|
873
|
+
const syncEmailSet = new Set(emails);
|
|
556
874
|
const currentUsersByEmail = new Map(currentUsers.map((u) => [u.email, u]));
|
|
557
|
-
const strapiUsersByEmail = new Map(existingStrapiUsers.map((u) => [u.email, u]));
|
|
558
875
|
for (const currUser of currentUsers) {
|
|
559
876
|
if (!syncEmailSet.has(currUser.email)) {
|
|
560
877
|
await whitelistService2.removeUser(currUser.id);
|
|
561
878
|
}
|
|
562
879
|
}
|
|
563
|
-
for (const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
if (!currUser) {
|
|
567
|
-
const existingStrapiUser = strapiUsersByEmail.get(user.email);
|
|
568
|
-
if (existingStrapiUser?.roles) {
|
|
569
|
-
finalRoles = existingStrapiUser.roles.map((r) => String(r.id));
|
|
570
|
-
matchedExistingUsersCount++;
|
|
571
|
-
}
|
|
572
|
-
await whitelistService2.registerUser(user.email, finalRoles);
|
|
573
|
-
} else {
|
|
574
|
-
await strapi.query("plugin::strapi-plugin-oidc.whitelists").update({
|
|
575
|
-
where: { id: currUser.id },
|
|
576
|
-
data: { roles: finalRoles }
|
|
577
|
-
});
|
|
880
|
+
for (const email of emails) {
|
|
881
|
+
if (!currentUsersByEmail.has(email)) {
|
|
882
|
+
await whitelistService2.registerUser(email);
|
|
578
883
|
}
|
|
579
884
|
}
|
|
580
|
-
ctx.body = { matchedExistingUsersCount };
|
|
885
|
+
ctx.body = { matchedExistingUsersCount: 0 };
|
|
581
886
|
}
|
|
582
887
|
const whitelist = {
|
|
583
888
|
info,
|
|
@@ -587,39 +892,99 @@ const whitelist = {
|
|
|
587
892
|
removeEmail,
|
|
588
893
|
deleteAll,
|
|
589
894
|
syncUsers,
|
|
590
|
-
importUsers
|
|
895
|
+
importUsers,
|
|
896
|
+
exportWhitelist
|
|
897
|
+
};
|
|
898
|
+
function getAuditLogService() {
|
|
899
|
+
return strapi.plugin("strapi-plugin-oidc").service("auditLog");
|
|
900
|
+
}
|
|
901
|
+
async function find(ctx) {
|
|
902
|
+
const page = Math.max(1, Number(ctx.query.page) || 1);
|
|
903
|
+
const pageSize = Math.min(100, Math.max(1, Number(ctx.query.pageSize) || 25));
|
|
904
|
+
ctx.body = await getAuditLogService().find({ page, pageSize });
|
|
905
|
+
}
|
|
906
|
+
async function exportLogs(ctx) {
|
|
907
|
+
const datetime = formatDatetimeForFilename(/* @__PURE__ */ new Date());
|
|
908
|
+
ctx.set("Content-Type", "application/json");
|
|
909
|
+
ctx.set("Content-Disposition", `attachment; filename="strapi-oidc-audit-log-${datetime}.json"`);
|
|
910
|
+
const service = getAuditLogService();
|
|
911
|
+
const PAGE_SIZE = 1e3;
|
|
912
|
+
const allRows = [];
|
|
913
|
+
let page = 1;
|
|
914
|
+
while (true) {
|
|
915
|
+
const { results } = await service.find({ page, pageSize: PAGE_SIZE });
|
|
916
|
+
for (const row of results) {
|
|
917
|
+
allRows.push({
|
|
918
|
+
id: row.id,
|
|
919
|
+
createdAt: row.createdAt,
|
|
920
|
+
action: row.action,
|
|
921
|
+
email: row.email ?? null,
|
|
922
|
+
ip: row.ip ?? null,
|
|
923
|
+
details: row.details
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
if (results.length < PAGE_SIZE) break;
|
|
927
|
+
page++;
|
|
928
|
+
}
|
|
929
|
+
ctx.body = allRows.map((row) => ({
|
|
930
|
+
datetime: row.createdAt,
|
|
931
|
+
action: row.action,
|
|
932
|
+
email: row.email,
|
|
933
|
+
ip: row.ip,
|
|
934
|
+
details: row.details
|
|
935
|
+
}));
|
|
936
|
+
}
|
|
937
|
+
async function clearAll(ctx) {
|
|
938
|
+
await getAuditLogService().clearAll();
|
|
939
|
+
ctx.status = 204;
|
|
940
|
+
}
|
|
941
|
+
const auditLog = {
|
|
942
|
+
find,
|
|
943
|
+
export: exportLogs,
|
|
944
|
+
clearAll
|
|
591
945
|
};
|
|
592
946
|
const controllers = {
|
|
593
947
|
oidc,
|
|
594
948
|
role,
|
|
595
|
-
whitelist
|
|
949
|
+
whitelist,
|
|
950
|
+
auditLog
|
|
596
951
|
};
|
|
597
952
|
const rateLimitMap = /* @__PURE__ */ new Map();
|
|
598
953
|
const RATE_LIMIT_WINDOW = 6e4;
|
|
599
|
-
const MAX_REQUESTS =
|
|
600
|
-
|
|
954
|
+
const MAX_REQUESTS = 1e3;
|
|
955
|
+
function getRateLimitKey(ctx) {
|
|
601
956
|
const ip = ctx.request.ip;
|
|
957
|
+
const ua = ctx.request.header["user-agent"] ?? "";
|
|
958
|
+
const uaHash = createHash("sha256").update(ua).digest("hex").slice(0, 16);
|
|
959
|
+
return `${ip}:${uaHash}`;
|
|
960
|
+
}
|
|
961
|
+
function rateLimitMiddleware(ctx, next) {
|
|
962
|
+
const key = getRateLimitKey(ctx);
|
|
602
963
|
const now = Date.now();
|
|
603
964
|
const windowStart = now - RATE_LIMIT_WINDOW;
|
|
604
|
-
const requestStamps = (rateLimitMap.get(
|
|
965
|
+
const requestStamps = (rateLimitMap.get(key) || []).filter(
|
|
966
|
+
(timestamp) => timestamp > windowStart
|
|
967
|
+
);
|
|
605
968
|
if (requestStamps.length >= MAX_REQUESTS) {
|
|
606
969
|
ctx.status = 429;
|
|
607
970
|
ctx.body = "Too Many Requests";
|
|
608
971
|
return;
|
|
609
972
|
}
|
|
610
973
|
requestStamps.push(now);
|
|
611
|
-
rateLimitMap.set(
|
|
612
|
-
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
974
|
+
rateLimitMap.set(key, requestStamps);
|
|
975
|
+
return next();
|
|
976
|
+
}
|
|
977
|
+
function adminPolicies(action) {
|
|
978
|
+
return {
|
|
979
|
+
policies: [
|
|
980
|
+
"admin::isAuthenticatedAdmin",
|
|
981
|
+
{
|
|
982
|
+
name: "admin::hasPermissions",
|
|
983
|
+
config: { actions: [`plugin::strapi-plugin-oidc.${action}`] }
|
|
984
|
+
}
|
|
985
|
+
]
|
|
986
|
+
};
|
|
987
|
+
}
|
|
623
988
|
const routes = {
|
|
624
989
|
admin: {
|
|
625
990
|
type: "admin",
|
|
@@ -701,6 +1066,30 @@ const routes = {
|
|
|
701
1066
|
path: "/whitelist",
|
|
702
1067
|
handler: "whitelist.deleteAll",
|
|
703
1068
|
config: adminPolicies("update")
|
|
1069
|
+
},
|
|
1070
|
+
{
|
|
1071
|
+
method: "GET",
|
|
1072
|
+
path: "/whitelist/export",
|
|
1073
|
+
handler: "whitelist.exportWhitelist",
|
|
1074
|
+
config: adminPolicies("read")
|
|
1075
|
+
},
|
|
1076
|
+
{
|
|
1077
|
+
method: "GET",
|
|
1078
|
+
path: "/audit-logs",
|
|
1079
|
+
handler: "auditLog.find",
|
|
1080
|
+
config: { policies: ["admin::isAuthenticatedAdmin"] }
|
|
1081
|
+
},
|
|
1082
|
+
{
|
|
1083
|
+
method: "GET",
|
|
1084
|
+
path: "/audit-logs/export",
|
|
1085
|
+
handler: "auditLog.export",
|
|
1086
|
+
config: { policies: ["admin::isAuthenticatedAdmin"] }
|
|
1087
|
+
},
|
|
1088
|
+
{
|
|
1089
|
+
method: "DELETE",
|
|
1090
|
+
path: "/audit-logs",
|
|
1091
|
+
handler: "auditLog.clearAll",
|
|
1092
|
+
config: { policies: ["admin::isAuthenticatedAdmin"] }
|
|
704
1093
|
}
|
|
705
1094
|
]
|
|
706
1095
|
},
|
|
@@ -734,6 +1123,26 @@ const routes = {
|
|
|
734
1123
|
method: "DELETE",
|
|
735
1124
|
path: "/whitelist",
|
|
736
1125
|
handler: "whitelist.deleteAll"
|
|
1126
|
+
},
|
|
1127
|
+
{
|
|
1128
|
+
method: "GET",
|
|
1129
|
+
path: "/whitelist/export",
|
|
1130
|
+
handler: "whitelist.exportWhitelist"
|
|
1131
|
+
},
|
|
1132
|
+
{
|
|
1133
|
+
method: "GET",
|
|
1134
|
+
path: "/audit-logs",
|
|
1135
|
+
handler: "auditLog.find"
|
|
1136
|
+
},
|
|
1137
|
+
{
|
|
1138
|
+
method: "GET",
|
|
1139
|
+
path: "/audit-logs/export",
|
|
1140
|
+
handler: "auditLog.export"
|
|
1141
|
+
},
|
|
1142
|
+
{
|
|
1143
|
+
method: "DELETE",
|
|
1144
|
+
path: "/audit-logs",
|
|
1145
|
+
handler: "auditLog.clearAll"
|
|
737
1146
|
}
|
|
738
1147
|
]
|
|
739
1148
|
}
|
|
@@ -1104,9 +1513,9 @@ function whitelistService({ strapi: strapi2 }) {
|
|
|
1104
1513
|
async getUsers() {
|
|
1105
1514
|
return getWhitelistQuery().findMany();
|
|
1106
1515
|
},
|
|
1107
|
-
async registerUser(email
|
|
1516
|
+
async registerUser(email) {
|
|
1108
1517
|
await getWhitelistQuery().create({
|
|
1109
|
-
data: { email
|
|
1518
|
+
data: { email }
|
|
1110
1519
|
});
|
|
1111
1520
|
},
|
|
1112
1521
|
async removeUser(id) {
|
|
@@ -1129,10 +1538,73 @@ function whitelistService({ strapi: strapi2 }) {
|
|
|
1129
1538
|
}
|
|
1130
1539
|
};
|
|
1131
1540
|
}
|
|
1541
|
+
function interpolate(str, params) {
|
|
1542
|
+
if (!params) return str;
|
|
1543
|
+
return str.replace(/\{(\w+)\}/g, (_, key) => String(params[key] ?? `{${key}}`));
|
|
1544
|
+
}
|
|
1545
|
+
function translateDetails(key, params) {
|
|
1546
|
+
const translation = en[`audit.${key}`];
|
|
1547
|
+
if (!translation) return null;
|
|
1548
|
+
return interpolate(translation, params);
|
|
1549
|
+
}
|
|
1550
|
+
function auditLogService({ strapi: strapi2 }) {
|
|
1551
|
+
return {
|
|
1552
|
+
async log({ action, email, ip, detailsKey, detailsParams }) {
|
|
1553
|
+
if (!isAuditLogEnabled()) return;
|
|
1554
|
+
await strapi2.db.query("plugin::strapi-plugin-oidc.audit-log").create({
|
|
1555
|
+
data: {
|
|
1556
|
+
action,
|
|
1557
|
+
email: email ?? null,
|
|
1558
|
+
ip: ip ?? null,
|
|
1559
|
+
detailsKey: detailsKey ?? null,
|
|
1560
|
+
detailsParams: detailsParams ?? null
|
|
1561
|
+
}
|
|
1562
|
+
});
|
|
1563
|
+
const eventHub = strapi2.serviceMap?.get?.("eventHub") ?? strapi2.eventHub;
|
|
1564
|
+
if (eventHub) {
|
|
1565
|
+
eventHub.emit(`strapi-plugin-oidc::auth.${action}`, {
|
|
1566
|
+
email,
|
|
1567
|
+
ip,
|
|
1568
|
+
provider: "strapi-plugin-oidc"
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
},
|
|
1572
|
+
async find({ page = 1, pageSize = 25 } = {}) {
|
|
1573
|
+
const result = await strapi2.db.query("plugin::strapi-plugin-oidc.audit-log").findPage({
|
|
1574
|
+
sort: { createdAt: "desc" },
|
|
1575
|
+
page,
|
|
1576
|
+
pageSize
|
|
1577
|
+
});
|
|
1578
|
+
const results = result.results.map((row) => ({
|
|
1579
|
+
...row,
|
|
1580
|
+
details: row.detailsKey ? translateDetails(row.detailsKey, row.detailsParams) : null
|
|
1581
|
+
}));
|
|
1582
|
+
return {
|
|
1583
|
+
results,
|
|
1584
|
+
pagination: result.pagination
|
|
1585
|
+
};
|
|
1586
|
+
},
|
|
1587
|
+
async clearAll() {
|
|
1588
|
+
const BATCH_SIZE = 1e3;
|
|
1589
|
+
let deletedCount;
|
|
1590
|
+
do {
|
|
1591
|
+
const result = await strapi2.db.query("plugin::strapi-plugin-oidc.audit-log").deleteMany({ limit: BATCH_SIZE });
|
|
1592
|
+
deletedCount = result.count;
|
|
1593
|
+
} while (deletedCount === BATCH_SIZE);
|
|
1594
|
+
},
|
|
1595
|
+
async cleanup(retentionDays) {
|
|
1596
|
+
const cutoff = new Date(Date.now() - retentionDays * 864e5);
|
|
1597
|
+
await strapi2.db.query("plugin::strapi-plugin-oidc.audit-log").deleteMany({
|
|
1598
|
+
where: { createdAt: { $lt: cutoff } }
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1132
1603
|
const services = {
|
|
1133
1604
|
oauth: oauthService,
|
|
1134
1605
|
role: roleService,
|
|
1135
|
-
whitelist: whitelistService
|
|
1606
|
+
whitelist: whitelistService,
|
|
1607
|
+
auditLog: auditLogService
|
|
1136
1608
|
};
|
|
1137
1609
|
const index = {
|
|
1138
1610
|
register: register$1,
|