strapi-plugin-oidc 1.5.3 → 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 +620 -174
- package/dist/server/index.mjs +621 -175
- 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";
|
|
@@ -217,7 +238,130 @@ function clearAuthCookies(strapi2, ctx) {
|
|
|
217
238
|
ctx.cookies.set("strapi_admin_refresh", "", options2);
|
|
218
239
|
ctx.cookies.set("oidc_authenticated", "", { ...options2, path: "/" });
|
|
219
240
|
ctx.cookies.set("oidc_access_token", "", { ...options2, path: "/" });
|
|
241
|
+
ctx.cookies.set("oidc_user_email", "", { ...options2, path: "/" });
|
|
220
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
|
+
};
|
|
221
365
|
const REQUIRED_CONFIG_KEYS = [
|
|
222
366
|
"OIDC_CLIENT_ID",
|
|
223
367
|
"OIDC_CLIENT_SECRET",
|
|
@@ -255,15 +399,16 @@ async function oidcSignIn(ctx) {
|
|
|
255
399
|
ctx.cookies.set("oidc_code_verifier", codeVerifier, cookieOptions);
|
|
256
400
|
ctx.cookies.set("oidc_state", state, cookieOptions);
|
|
257
401
|
ctx.cookies.set("oidc_nonce", nonce, cookieOptions);
|
|
258
|
-
const params = new URLSearchParams(
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
+
});
|
|
267
412
|
const authorizationUrl = `${OIDC_AUTHORIZATION_ENDPOINT}?${params.toString()}`;
|
|
268
413
|
ctx.set("Location", authorizationUrl);
|
|
269
414
|
return ctx.send({}, 302);
|
|
@@ -301,14 +446,35 @@ async function exchangeTokenAndFetchUserInfo(config2, params, expectedNonce) {
|
|
|
301
446
|
const userInfo = await userResponse.json();
|
|
302
447
|
return { userInfo, accessToken: tokenData.access_token };
|
|
303
448
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
if (
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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 [];
|
|
311
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) {
|
|
312
478
|
const defaultLocale = oauthService2.localeFindByHeader(
|
|
313
479
|
ctx.request.headers
|
|
314
480
|
);
|
|
@@ -323,21 +489,106 @@ async function registerNewUser(userService, oauthService2, roleService2, email,
|
|
|
323
489
|
return activateUser;
|
|
324
490
|
}
|
|
325
491
|
async function handleUserAuthentication(userService, oauthService2, roleService2, whitelistService2, userResponseData, config2, ctx) {
|
|
326
|
-
const
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
);
|
|
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
|
+
}
|
|
338
534
|
const jwtToken = await oauthService2.generateToken(activateUser, ctx);
|
|
339
535
|
oauthService2.triggerSignInSuccess(activateUser);
|
|
340
|
-
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
|
+
};
|
|
341
592
|
}
|
|
342
593
|
async function oidcSignInCallback(ctx) {
|
|
343
594
|
const config2 = configValidation();
|
|
@@ -345,8 +596,10 @@ async function oidcSignInCallback(ctx) {
|
|
|
345
596
|
const oauthService2 = strapi.plugin("strapi-plugin-oidc").service("oauth");
|
|
346
597
|
const roleService2 = strapi.plugin("strapi-plugin-oidc").service("role");
|
|
347
598
|
const whitelistService2 = strapi.plugin("strapi-plugin-oidc").service("whitelist");
|
|
599
|
+
const auditLog2 = strapi.plugin("strapi-plugin-oidc").service("auditLog");
|
|
348
600
|
if (!ctx.query.code) {
|
|
349
|
-
|
|
601
|
+
await auditLog2.log({ action: "missing_code", ip: ctx.ip });
|
|
602
|
+
return ctx.send(oauthService2.renderSignUpError(userFacingMessages.missing_code));
|
|
350
603
|
}
|
|
351
604
|
const oidcState = ctx.cookies.get("oidc_state");
|
|
352
605
|
const codeVerifier = ctx.cookies.get("oidc_code_verifier");
|
|
@@ -355,21 +608,22 @@ async function oidcSignInCallback(ctx) {
|
|
|
355
608
|
ctx.cookies.set("oidc_code_verifier", null);
|
|
356
609
|
ctx.cookies.set("oidc_nonce", null);
|
|
357
610
|
if (!ctx.query.state || ctx.query.state !== oidcState) {
|
|
358
|
-
|
|
611
|
+
await auditLog2.log({ action: "state_mismatch", ip: ctx.ip });
|
|
612
|
+
return ctx.send(oauthService2.renderSignUpError(userFacingMessages.invalid_state));
|
|
359
613
|
}
|
|
360
|
-
const params = new URLSearchParams(
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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;
|
|
367
623
|
try {
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
oidcNonce ?? ""
|
|
372
|
-
);
|
|
624
|
+
const exchangeResult = await exchangeTokenAndFetchUserInfo(config2, params, oidcNonce ?? "");
|
|
625
|
+
userInfo = exchangeResult.userInfo;
|
|
626
|
+
const accessToken = exchangeResult.accessToken;
|
|
373
627
|
const isProduction = strapi.config.get("environment") === "production";
|
|
374
628
|
ctx.cookies.set("oidc_access_token", accessToken, {
|
|
375
629
|
httpOnly: true,
|
|
@@ -378,7 +632,7 @@ async function oidcSignInCallback(ctx) {
|
|
|
378
632
|
secure: isProduction && ctx.request.secure,
|
|
379
633
|
sameSite: "lax"
|
|
380
634
|
});
|
|
381
|
-
const { activateUser, jwtToken } = await handleUserAuthentication(
|
|
635
|
+
const { activateUser, jwtToken, userCreated, rolesUpdated, resolvedRoleNames } = await handleUserAuthentication(
|
|
382
636
|
userService,
|
|
383
637
|
oauthService2,
|
|
384
638
|
roleService2,
|
|
@@ -387,21 +641,61 @@ async function oidcSignInCallback(ctx) {
|
|
|
387
641
|
config2,
|
|
388
642
|
ctx
|
|
389
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
|
+
});
|
|
390
667
|
const nonce = randomUUID();
|
|
391
668
|
const html = oauthService2.renderSignUpSuccess(jwtToken, activateUser, nonce);
|
|
392
669
|
ctx.set("Content-Security-Policy", `script-src 'nonce-${nonce}'`);
|
|
393
670
|
ctx.send(html);
|
|
394
671
|
} catch (e) {
|
|
395
|
-
|
|
396
|
-
|
|
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));
|
|
397
689
|
}
|
|
398
690
|
}
|
|
399
691
|
async function logout(ctx) {
|
|
400
692
|
const config2 = strapi.config.get("plugin::strapi-plugin-oidc");
|
|
693
|
+
const auditLog2 = strapi.plugin("strapi-plugin-oidc").service("auditLog");
|
|
401
694
|
const logoutUrl = config2.OIDC_END_SESSION_ENDPOINT;
|
|
402
695
|
const adminPanelUrl = strapi.config.get("admin.url", "/admin");
|
|
403
696
|
const isOidcSession = !!ctx.cookies.get("oidc_authenticated");
|
|
404
697
|
const accessToken = ctx.cookies.get("oidc_access_token");
|
|
698
|
+
const userEmail = ctx.cookies.get("oidc_user_email") ?? void 0;
|
|
405
699
|
clearAuthCookies(strapi, ctx);
|
|
406
700
|
if (logoutUrl && isOidcSession && accessToken) {
|
|
407
701
|
try {
|
|
@@ -409,11 +703,22 @@ async function logout(ctx) {
|
|
|
409
703
|
headers: { Authorization: `Bearer ${accessToken}` }
|
|
410
704
|
});
|
|
411
705
|
if (response.ok) {
|
|
706
|
+
if (userEmail)
|
|
707
|
+
auditLog2.log({ action: "logout", email: userEmail, ip: ctx.ip }).catch(() => {
|
|
708
|
+
});
|
|
412
709
|
return ctx.redirect(logoutUrl);
|
|
413
710
|
}
|
|
711
|
+
if (userEmail)
|
|
712
|
+
await auditLog2.log({ action: "session_expired", email: userEmail, ip: ctx.ip });
|
|
713
|
+
return ctx.redirect(`${adminPanelUrl}/auth/login`);
|
|
414
714
|
} catch {
|
|
715
|
+
if (userEmail)
|
|
716
|
+
await auditLog2.log({ action: "session_expired", email: userEmail, ip: ctx.ip });
|
|
717
|
+
return ctx.redirect(`${adminPanelUrl}/auth/login`);
|
|
415
718
|
}
|
|
416
|
-
|
|
719
|
+
}
|
|
720
|
+
if (isOidcSession && userEmail) {
|
|
721
|
+
await auditLog2.log({ action: "logout", email: userEmail, ip: ctx.ip });
|
|
417
722
|
}
|
|
418
723
|
if (logoutUrl && isOidcSession) {
|
|
419
724
|
return ctx.redirect(logoutUrl);
|
|
@@ -425,7 +730,7 @@ const oidc = {
|
|
|
425
730
|
oidcSignInCallback,
|
|
426
731
|
logout
|
|
427
732
|
};
|
|
428
|
-
async function find(ctx) {
|
|
733
|
+
async function find$1(ctx) {
|
|
429
734
|
const roleService2 = strapi.plugin("strapi-plugin-oidc").service("role");
|
|
430
735
|
const roles2 = await roleService2.find();
|
|
431
736
|
const oidcConstants = roleService2.getOidcRoles();
|
|
@@ -444,14 +749,23 @@ async function update(ctx) {
|
|
|
444
749
|
await roleService2.update(roles2);
|
|
445
750
|
ctx.send({}, 204);
|
|
446
751
|
} catch (e) {
|
|
447
|
-
|
|
752
|
+
strapi.log.error(e);
|
|
448
753
|
ctx.send({}, 400);
|
|
449
754
|
}
|
|
450
755
|
}
|
|
451
756
|
const role = {
|
|
452
|
-
find,
|
|
757
|
+
find: find$1,
|
|
453
758
|
update
|
|
454
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
|
+
}
|
|
455
769
|
function getWhitelistService() {
|
|
456
770
|
return strapi.plugin("strapi-plugin-oidc").service("whitelist");
|
|
457
771
|
}
|
|
@@ -463,7 +777,8 @@ async function info(ctx) {
|
|
|
463
777
|
useWhitelist: settings.useWhitelist,
|
|
464
778
|
enforceOIDC: resolveEnforceOIDC(strapi, settings.enforceOIDC),
|
|
465
779
|
enforceOIDCConfig: getEnforceOIDCConfig(strapi),
|
|
466
|
-
whitelistUsers
|
|
780
|
+
whitelistUsers,
|
|
781
|
+
auditLogEnabled: isAuditLogEnabled()
|
|
467
782
|
};
|
|
468
783
|
}
|
|
469
784
|
async function updateSettings(ctx) {
|
|
@@ -489,32 +804,25 @@ async function publicSettings(ctx) {
|
|
|
489
804
|
};
|
|
490
805
|
}
|
|
491
806
|
async function register(ctx) {
|
|
492
|
-
const { email
|
|
807
|
+
const { email } = ctx.request.body;
|
|
493
808
|
if (!email) {
|
|
494
809
|
ctx.body = { message: "Please enter a valid email address" };
|
|
495
810
|
return;
|
|
496
811
|
}
|
|
497
812
|
const rawEmails = Array.isArray(email) ? email : email.split(",");
|
|
498
813
|
const emailList = rawEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean);
|
|
499
|
-
const existingUsers = await strapi.query("admin::user").findMany({
|
|
500
|
-
where: { email: { $in: emailList } },
|
|
501
|
-
populate: ["roles"]
|
|
502
|
-
});
|
|
503
|
-
const existingUsersByEmail = new Map(existingUsers.map((u) => [u.email, u]));
|
|
504
814
|
const whitelistService2 = getWhitelistService();
|
|
505
815
|
let matchedExistingUsersCount = 0;
|
|
506
816
|
for (const singleEmail of emailList) {
|
|
507
|
-
const existingUser =
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
matchedExistingUsersCount++;
|
|
512
|
-
}
|
|
817
|
+
const existingUser = await strapi.query("admin::user").findOne({
|
|
818
|
+
where: { email: singleEmail }
|
|
819
|
+
});
|
|
820
|
+
if (existingUser) matchedExistingUsersCount++;
|
|
513
821
|
const alreadyWhitelisted = await strapi.query("plugin::strapi-plugin-oidc.whitelists").findOne({
|
|
514
822
|
where: { email: singleEmail }
|
|
515
823
|
});
|
|
516
824
|
if (!alreadyWhitelisted) {
|
|
517
|
-
await whitelistService2.registerUser(singleEmail
|
|
825
|
+
await whitelistService2.registerUser(singleEmail);
|
|
518
826
|
}
|
|
519
827
|
}
|
|
520
828
|
ctx.body = { matchedExistingUsersCount };
|
|
@@ -529,81 +837,52 @@ async function deleteAll(ctx) {
|
|
|
529
837
|
await strapi.query("plugin::strapi-plugin-oidc.whitelists").deleteMany({});
|
|
530
838
|
ctx.body = {};
|
|
531
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
|
+
}
|
|
532
848
|
async function importUsers(ctx) {
|
|
533
849
|
const { users } = ctx.request.body;
|
|
534
850
|
if (!Array.isArray(users)) {
|
|
535
851
|
ctx.status = 400;
|
|
536
|
-
ctx.body = { error: "Expected { users: [{email
|
|
852
|
+
ctx.body = { error: "Expected { users: [{email}] }" };
|
|
537
853
|
return;
|
|
538
854
|
}
|
|
539
|
-
const
|
|
540
|
-
const
|
|
541
|
-
const resolveRole = (nameOrId) => roleNameToId.get(nameOrId) ?? nameOrId;
|
|
542
|
-
const normalized = users.filter((u) => u?.email).map((u) => ({
|
|
543
|
-
email: String(u.email).trim().toLowerCase(),
|
|
544
|
-
roles: (Array.isArray(u.roles) ? u.roles : []).map(resolveRole)
|
|
545
|
-
}));
|
|
546
|
-
const seen = /* @__PURE__ */ new Set();
|
|
547
|
-
const deduped = normalized.filter((u) => {
|
|
548
|
-
if (seen.has(u.email)) return false;
|
|
549
|
-
seen.add(u.email);
|
|
550
|
-
return true;
|
|
551
|
-
});
|
|
552
|
-
const strapiUsers = await strapi.query("admin::user").findMany({
|
|
553
|
-
where: { email: { $in: deduped.map((u) => u.email) } },
|
|
554
|
-
populate: ["roles"]
|
|
555
|
-
});
|
|
556
|
-
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)];
|
|
557
857
|
const whitelistService2 = getWhitelistService();
|
|
558
858
|
const existing = await whitelistService2.getUsers();
|
|
559
859
|
const existingEmails = new Set(existing.map((u) => u.email));
|
|
560
860
|
let importedCount = 0;
|
|
561
|
-
for (const
|
|
562
|
-
if (existingEmails.has(
|
|
563
|
-
|
|
564
|
-
const finalRoles = strapiUser?.roles?.length ? strapiUser.roles.map((r) => String(r.id)) : user.roles;
|
|
565
|
-
await whitelistService2.registerUser(user.email, finalRoles);
|
|
861
|
+
for (const email of deduped) {
|
|
862
|
+
if (existingEmails.has(email)) continue;
|
|
863
|
+
await whitelistService2.registerUser(email);
|
|
566
864
|
importedCount++;
|
|
567
865
|
}
|
|
568
866
|
ctx.body = { importedCount };
|
|
569
867
|
}
|
|
570
868
|
async function syncUsers(ctx) {
|
|
571
869
|
const { users: rawUsers } = ctx.request.body;
|
|
572
|
-
const
|
|
870
|
+
const emails = rawUsers.map((u) => String(u.email).toLowerCase()).filter((e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e));
|
|
573
871
|
const whitelistService2 = getWhitelistService();
|
|
574
872
|
const currentUsers = await whitelistService2.getUsers();
|
|
575
|
-
|
|
576
|
-
const emailsToSync = users.map((u) => u.email);
|
|
577
|
-
const existingStrapiUsers = await strapi.query("admin::user").findMany({
|
|
578
|
-
where: { email: { $in: emailsToSync } },
|
|
579
|
-
populate: ["roles"]
|
|
580
|
-
});
|
|
581
|
-
const syncEmailSet = new Set(emailsToSync);
|
|
873
|
+
const syncEmailSet = new Set(emails);
|
|
582
874
|
const currentUsersByEmail = new Map(currentUsers.map((u) => [u.email, u]));
|
|
583
|
-
const strapiUsersByEmail = new Map(existingStrapiUsers.map((u) => [u.email, u]));
|
|
584
875
|
for (const currUser of currentUsers) {
|
|
585
876
|
if (!syncEmailSet.has(currUser.email)) {
|
|
586
877
|
await whitelistService2.removeUser(currUser.id);
|
|
587
878
|
}
|
|
588
879
|
}
|
|
589
|
-
for (const
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
if (!currUser) {
|
|
593
|
-
const existingStrapiUser = strapiUsersByEmail.get(user.email);
|
|
594
|
-
if (existingStrapiUser?.roles) {
|
|
595
|
-
finalRoles = existingStrapiUser.roles.map((r) => String(r.id));
|
|
596
|
-
matchedExistingUsersCount++;
|
|
597
|
-
}
|
|
598
|
-
await whitelistService2.registerUser(user.email, finalRoles);
|
|
599
|
-
} else {
|
|
600
|
-
await strapi.query("plugin::strapi-plugin-oidc.whitelists").update({
|
|
601
|
-
where: { id: currUser.id },
|
|
602
|
-
data: { roles: finalRoles }
|
|
603
|
-
});
|
|
880
|
+
for (const email of emails) {
|
|
881
|
+
if (!currentUsersByEmail.has(email)) {
|
|
882
|
+
await whitelistService2.registerUser(email);
|
|
604
883
|
}
|
|
605
884
|
}
|
|
606
|
-
ctx.body = { matchedExistingUsersCount };
|
|
885
|
+
ctx.body = { matchedExistingUsersCount: 0 };
|
|
607
886
|
}
|
|
608
887
|
const whitelist = {
|
|
609
888
|
info,
|
|
@@ -613,39 +892,99 @@ const whitelist = {
|
|
|
613
892
|
removeEmail,
|
|
614
893
|
deleteAll,
|
|
615
894
|
syncUsers,
|
|
616
|
-
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
|
|
617
945
|
};
|
|
618
946
|
const controllers = {
|
|
619
947
|
oidc,
|
|
620
948
|
role,
|
|
621
|
-
whitelist
|
|
949
|
+
whitelist,
|
|
950
|
+
auditLog
|
|
622
951
|
};
|
|
623
952
|
const rateLimitMap = /* @__PURE__ */ new Map();
|
|
624
953
|
const RATE_LIMIT_WINDOW = 6e4;
|
|
625
|
-
const MAX_REQUESTS =
|
|
626
|
-
|
|
954
|
+
const MAX_REQUESTS = 1e3;
|
|
955
|
+
function getRateLimitKey(ctx) {
|
|
627
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);
|
|
628
963
|
const now = Date.now();
|
|
629
964
|
const windowStart = now - RATE_LIMIT_WINDOW;
|
|
630
|
-
const requestStamps = (rateLimitMap.get(
|
|
965
|
+
const requestStamps = (rateLimitMap.get(key) || []).filter(
|
|
966
|
+
(timestamp) => timestamp > windowStart
|
|
967
|
+
);
|
|
631
968
|
if (requestStamps.length >= MAX_REQUESTS) {
|
|
632
969
|
ctx.status = 429;
|
|
633
970
|
ctx.body = "Too Many Requests";
|
|
634
971
|
return;
|
|
635
972
|
}
|
|
636
973
|
requestStamps.push(now);
|
|
637
|
-
rateLimitMap.set(
|
|
638
|
-
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
+
}
|
|
649
988
|
const routes = {
|
|
650
989
|
admin: {
|
|
651
990
|
type: "admin",
|
|
@@ -727,6 +1066,30 @@ const routes = {
|
|
|
727
1066
|
path: "/whitelist",
|
|
728
1067
|
handler: "whitelist.deleteAll",
|
|
729
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"] }
|
|
730
1093
|
}
|
|
731
1094
|
]
|
|
732
1095
|
},
|
|
@@ -760,6 +1123,26 @@ const routes = {
|
|
|
760
1123
|
method: "DELETE",
|
|
761
1124
|
path: "/whitelist",
|
|
762
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"
|
|
763
1146
|
}
|
|
764
1147
|
]
|
|
765
1148
|
}
|
|
@@ -1130,9 +1513,9 @@ function whitelistService({ strapi: strapi2 }) {
|
|
|
1130
1513
|
async getUsers() {
|
|
1131
1514
|
return getWhitelistQuery().findMany();
|
|
1132
1515
|
},
|
|
1133
|
-
async registerUser(email
|
|
1516
|
+
async registerUser(email) {
|
|
1134
1517
|
await getWhitelistQuery().create({
|
|
1135
|
-
data: { email
|
|
1518
|
+
data: { email }
|
|
1136
1519
|
});
|
|
1137
1520
|
},
|
|
1138
1521
|
async removeUser(id) {
|
|
@@ -1155,10 +1538,73 @@ function whitelistService({ strapi: strapi2 }) {
|
|
|
1155
1538
|
}
|
|
1156
1539
|
};
|
|
1157
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
|
+
}
|
|
1158
1603
|
const services = {
|
|
1159
1604
|
oauth: oauthService,
|
|
1160
1605
|
role: roleService,
|
|
1161
|
-
whitelist: whitelistService
|
|
1606
|
+
whitelist: whitelistService,
|
|
1607
|
+
auditLog: auditLogService
|
|
1162
1608
|
};
|
|
1163
1609
|
const index = {
|
|
1164
1610
|
register: register$1,
|