@actuate-media/cms-core 0.36.0 → 0.38.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/dist/__tests__/api/security-routes.test.d.ts +2 -0
- package/dist/__tests__/api/security-routes.test.d.ts.map +1 -0
- package/dist/__tests__/api/security-routes.test.js +212 -0
- package/dist/__tests__/api/security-routes.test.js.map +1 -0
- package/dist/__tests__/api/totp-login.test.d.ts +2 -0
- package/dist/__tests__/api/totp-login.test.d.ts.map +1 -0
- package/dist/__tests__/api/totp-login.test.js +115 -0
- package/dist/__tests__/api/totp-login.test.js.map +1 -0
- package/dist/__tests__/auth/totp.test.d.ts +2 -0
- package/dist/__tests__/auth/totp.test.d.ts.map +1 -0
- package/dist/__tests__/auth/totp.test.js +52 -0
- package/dist/__tests__/auth/totp.test.js.map +1 -0
- package/dist/__tests__/security/center.test.d.ts +2 -0
- package/dist/__tests__/security/center.test.d.ts.map +1 -0
- package/dist/__tests__/security/center.test.js +226 -0
- package/dist/__tests__/security/center.test.js.map +1 -0
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +240 -8
- package/dist/api/handlers.js.map +1 -1
- package/dist/auth/totp.d.ts +12 -0
- package/dist/auth/totp.d.ts.map +1 -1
- package/dist/auth/totp.js +24 -0
- package/dist/auth/totp.js.map +1 -1
- package/dist/security/center/gather.d.ts +12 -0
- package/dist/security/center/gather.d.ts.map +1 -0
- package/dist/security/center/gather.js +185 -0
- package/dist/security/center/gather.js.map +1 -0
- package/dist/security/center/index.d.ts +13 -0
- package/dist/security/center/index.d.ts.map +1 -0
- package/dist/security/center/index.js +4 -0
- package/dist/security/center/index.js.map +1 -0
- package/dist/security/center/settings.d.ts +38 -0
- package/dist/security/center/settings.d.ts.map +1 -0
- package/dist/security/center/settings.js +121 -0
- package/dist/security/center/settings.js.map +1 -0
- package/dist/security/center/status.d.ts +10 -0
- package/dist/security/center/status.d.ts.map +1 -0
- package/dist/security/center/status.js +249 -0
- package/dist/security/center/status.js.map +1 -0
- package/dist/security/center/summaries.d.ts +21 -0
- package/dist/security/center/summaries.d.ts.map +1 -0
- package/dist/security/center/summaries.js +65 -0
- package/dist/security/center/summaries.js.map +1 -0
- package/dist/security/center/types.d.ts +127 -0
- package/dist/security/center/types.d.ts.map +1 -0
- package/dist/security/center/types.js +12 -0
- package/dist/security/center/types.js.map +1 -0
- package/package.json +6 -1
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { DEFAULT_SECURITY_SETTINGS, computeSecurityStatus, mfaEnableLockoutError, parseSecuritySettings, summarizeApiKeys, summarizeUsers, validateIpRange, validateSecuritySettings, } from '../../security/center/index.js';
|
|
3
|
+
function baseInput(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
isProduction: true,
|
|
6
|
+
settings: {
|
|
7
|
+
mfa: { required: true, gracePeriodDays: 7 },
|
|
8
|
+
session: { maxConcurrentSessions: 5 },
|
|
9
|
+
},
|
|
10
|
+
users: {
|
|
11
|
+
totalUsers: 3,
|
|
12
|
+
totalAdmins: 2,
|
|
13
|
+
withMfa: 2,
|
|
14
|
+
withoutMfa: 1,
|
|
15
|
+
adminsWithoutMfa: 0,
|
|
16
|
+
inactive: 0,
|
|
17
|
+
},
|
|
18
|
+
apiKeys: { active: 1, withoutExpiration: 0, broadScope: 0, staleUnused90d: 0 },
|
|
19
|
+
secrets: {
|
|
20
|
+
cmsSecret: 'pass',
|
|
21
|
+
encryptionKey: 'pass',
|
|
22
|
+
rateLimitBackend: 'distributed',
|
|
23
|
+
apiKeysHashed: true,
|
|
24
|
+
webhookSecretsEncrypted: true,
|
|
25
|
+
},
|
|
26
|
+
ipAllowlist: {
|
|
27
|
+
enabled: false,
|
|
28
|
+
source: 'none',
|
|
29
|
+
ranges: [],
|
|
30
|
+
currentIp: '1.2.3.4',
|
|
31
|
+
currentIpAllowed: null,
|
|
32
|
+
},
|
|
33
|
+
failedLogins24h: 0,
|
|
34
|
+
activeSessions: 1,
|
|
35
|
+
csrfEnabled: true,
|
|
36
|
+
uploadValidation: true,
|
|
37
|
+
auditEnabled: true,
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
describe('computeSecurityStatus', () => {
|
|
42
|
+
it('reports secure when all controls pass', () => {
|
|
43
|
+
const status = computeSecurityStatus(baseInput());
|
|
44
|
+
expect(status.status).toBe('secure');
|
|
45
|
+
expect(status.score).toBe(100);
|
|
46
|
+
expect(status.warnings).toHaveLength(0);
|
|
47
|
+
// Every advertised check is present.
|
|
48
|
+
const keys = status.checks.map((c) => c.key);
|
|
49
|
+
expect(keys).toEqual(expect.arrayContaining([
|
|
50
|
+
'mfa',
|
|
51
|
+
'sessions',
|
|
52
|
+
'api_keys',
|
|
53
|
+
'csrf',
|
|
54
|
+
'rate_limit',
|
|
55
|
+
'cms_secret',
|
|
56
|
+
'audit',
|
|
57
|
+
]));
|
|
58
|
+
});
|
|
59
|
+
it('is at_risk when CSRF is disabled', () => {
|
|
60
|
+
const status = computeSecurityStatus(baseInput({ csrfEnabled: false }));
|
|
61
|
+
expect(status.status).toBe('at_risk');
|
|
62
|
+
expect(status.warnings.some((w) => w.id === 'csrf-disabled' && w.severity === 'critical')).toBe(true);
|
|
63
|
+
expect(status.score).toBeLessThan(100);
|
|
64
|
+
});
|
|
65
|
+
it('is at_risk when MFA is required but an admin has not enrolled', () => {
|
|
66
|
+
const status = computeSecurityStatus(baseInput({
|
|
67
|
+
users: {
|
|
68
|
+
totalUsers: 3,
|
|
69
|
+
totalAdmins: 2,
|
|
70
|
+
withMfa: 1,
|
|
71
|
+
withoutMfa: 1,
|
|
72
|
+
adminsWithoutMfa: 1,
|
|
73
|
+
inactive: 0,
|
|
74
|
+
},
|
|
75
|
+
}));
|
|
76
|
+
expect(status.status).toBe('at_risk');
|
|
77
|
+
expect(status.warnings.some((w) => w.id === 'mfa-required-gap')).toBe(true);
|
|
78
|
+
const mfaCheck = status.checks.find((c) => c.key === 'mfa');
|
|
79
|
+
expect(mfaCheck?.status).toBe('fail');
|
|
80
|
+
});
|
|
81
|
+
it('is at_risk in production when CMS_SECRET is missing', () => {
|
|
82
|
+
const status = computeSecurityStatus(baseInput({ secrets: { ...baseInput().secrets, cmsSecret: 'fail' } }));
|
|
83
|
+
expect(status.status).toBe('at_risk');
|
|
84
|
+
expect(status.warnings.some((w) => w.id === 'cms-secret-missing')).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
it('needs_attention when MFA is not required in production', () => {
|
|
87
|
+
const status = computeSecurityStatus(baseInput({
|
|
88
|
+
settings: {
|
|
89
|
+
mfa: { required: false, gracePeriodDays: 7 },
|
|
90
|
+
session: { maxConcurrentSessions: 5 },
|
|
91
|
+
},
|
|
92
|
+
}));
|
|
93
|
+
expect(status.status).toBe('needs_attention');
|
|
94
|
+
expect(status.warnings.some((w) => w.id === 'mfa-not-required')).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
it('warns (not critical) about in-memory rate limiting in production', () => {
|
|
97
|
+
const status = computeSecurityStatus(baseInput({ secrets: { ...baseInput().secrets, rateLimitBackend: 'in_memory' } }));
|
|
98
|
+
expect(status.status).toBe('needs_attention');
|
|
99
|
+
expect(status.warnings.some((w) => w.id === 'rate-limit-in-memory')).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
it('is unknown when secret state cannot be determined', () => {
|
|
102
|
+
const status = computeSecurityStatus(baseInput({ secrets: { ...baseInput().secrets, cmsSecret: 'unknown' } }));
|
|
103
|
+
expect(status.status).toBe('unknown');
|
|
104
|
+
});
|
|
105
|
+
it('flags broad-scope API keys as a failing check', () => {
|
|
106
|
+
const status = computeSecurityStatus(baseInput({ apiKeys: { active: 2, withoutExpiration: 0, broadScope: 1, staleUnused90d: 0 } }));
|
|
107
|
+
expect(status.checks.find((c) => c.key === 'api_keys')?.status).toBe('fail');
|
|
108
|
+
expect(status.warnings.some((w) => w.id === 'api-keys-broad')).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
describe('validateSecuritySettings', () => {
|
|
112
|
+
it('accepts valid settings', () => {
|
|
113
|
+
expect(validateSecuritySettings(DEFAULT_SECURITY_SETTINGS)).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
it('rejects an out-of-range grace period', () => {
|
|
116
|
+
const issues = validateSecuritySettings({
|
|
117
|
+
mfa: { required: true, gracePeriodDays: 999 },
|
|
118
|
+
session: { maxConcurrentSessions: 5 },
|
|
119
|
+
});
|
|
120
|
+
expect(issues.some((i) => i.field === 'mfaGracePeriodDays' && i.severity === 'error')).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
it('rejects an out-of-range concurrent session cap', () => {
|
|
123
|
+
const issues = validateSecuritySettings({
|
|
124
|
+
mfa: { required: false, gracePeriodDays: 0 },
|
|
125
|
+
session: { maxConcurrentSessions: -1 },
|
|
126
|
+
});
|
|
127
|
+
expect(issues.some((i) => i.field === 'maxConcurrentSessions')).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
describe('parseSecuritySettings', () => {
|
|
131
|
+
it('returns defaults for an empty blob', () => {
|
|
132
|
+
expect(parseSecuritySettings({})).toEqual(DEFAULT_SECURITY_SETTINGS);
|
|
133
|
+
});
|
|
134
|
+
it('coerces and clamps values', () => {
|
|
135
|
+
const parsed = parseSecuritySettings({
|
|
136
|
+
security: {
|
|
137
|
+
mfa: { required: 'yes', gracePeriodDays: 9999 },
|
|
138
|
+
session: { maxConcurrentSessions: -5 },
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
expect(parsed.mfa.required).toBe(true);
|
|
142
|
+
expect(parsed.mfa.gracePeriodDays).toBe(365);
|
|
143
|
+
expect(parsed.session.maxConcurrentSessions).toBe(0);
|
|
144
|
+
});
|
|
145
|
+
it('preserves requiredAt when present', () => {
|
|
146
|
+
const parsed = parseSecuritySettings({
|
|
147
|
+
security: {
|
|
148
|
+
mfa: { required: true, gracePeriodDays: 1, requiredAt: '2026-01-01T00:00:00.000Z' },
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
expect(parsed.mfa.requiredAt).toBe('2026-01-01T00:00:00.000Z');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
describe('validateIpRange', () => {
|
|
155
|
+
it.each([
|
|
156
|
+
'192.168.1.1',
|
|
157
|
+
'10.0.0.0/8',
|
|
158
|
+
'172.16.0.0/12',
|
|
159
|
+
'0.0.0.0/0',
|
|
160
|
+
'2001:db8::1',
|
|
161
|
+
'2001:db8::/32',
|
|
162
|
+
'::1',
|
|
163
|
+
])('accepts %s', (entry) => {
|
|
164
|
+
expect(validateIpRange(entry)).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
it.each(['', 'not-an-ip', '999.1.1.1', '10.0.0.0/33', '1.2.3.4/abc', '2001:db8::/129', '1.2.3'])('rejects %s', (entry) => {
|
|
167
|
+
expect(validateIpRange(entry)).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
describe('mfaEnableLockoutError', () => {
|
|
171
|
+
it('blocks enabling MFA when the acting admin has no MFA', () => {
|
|
172
|
+
expect(mfaEnableLockoutError({ enabling: true, viewerHasMfa: false, totalAdmins: 1 })).toMatch(/lock yourself out/i);
|
|
173
|
+
});
|
|
174
|
+
it('allows enabling when the acting admin has MFA', () => {
|
|
175
|
+
expect(mfaEnableLockoutError({ enabling: true, viewerHasMfa: true, totalAdmins: 1 })).toBeNull();
|
|
176
|
+
});
|
|
177
|
+
it('never blocks when not enabling', () => {
|
|
178
|
+
expect(mfaEnableLockoutError({ enabling: false, viewerHasMfa: false, totalAdmins: 3 })).toBeNull();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
describe('summarizeUsers', () => {
|
|
182
|
+
it('counts admin MFA coverage and inactive accounts', () => {
|
|
183
|
+
const summary = summarizeUsers([
|
|
184
|
+
{ role: 'ADMIN', isActive: true, totpEnabled: true },
|
|
185
|
+
{ role: 'ADMIN', isActive: true, totpEnabled: false },
|
|
186
|
+
{ role: 'EDITOR', isActive: true, totpEnabled: false },
|
|
187
|
+
{ role: 'ADMIN', isActive: false, totpEnabled: false },
|
|
188
|
+
]);
|
|
189
|
+
expect(summary.totalUsers).toBe(4);
|
|
190
|
+
expect(summary.totalAdmins).toBe(3);
|
|
191
|
+
expect(summary.withMfa).toBe(1);
|
|
192
|
+
expect(summary.adminsWithoutMfa).toBe(1); // inactive admin excluded
|
|
193
|
+
expect(summary.inactive).toBe(1);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
describe('summarizeApiKeys', () => {
|
|
197
|
+
const now = Date.parse('2026-06-01T00:00:00.000Z');
|
|
198
|
+
it('classifies risk and excludes revoked/expired keys', () => {
|
|
199
|
+
const summary = summarizeApiKeys([
|
|
200
|
+
{ scopes: { admin: true }, expiresAt: null, lastUsedAt: null, revokedAt: null },
|
|
201
|
+
{
|
|
202
|
+
scopes: { media: true },
|
|
203
|
+
expiresAt: '2026-12-01T00:00:00.000Z',
|
|
204
|
+
lastUsedAt: '2026-01-01T00:00:00.000Z',
|
|
205
|
+
revokedAt: null,
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
scopes: { media: true },
|
|
209
|
+
expiresAt: '2020-01-01T00:00:00.000Z',
|
|
210
|
+
lastUsedAt: null,
|
|
211
|
+
revokedAt: null,
|
|
212
|
+
}, // expired
|
|
213
|
+
{
|
|
214
|
+
scopes: { admin: true },
|
|
215
|
+
expiresAt: null,
|
|
216
|
+
lastUsedAt: null,
|
|
217
|
+
revokedAt: '2026-01-01T00:00:00.000Z',
|
|
218
|
+
}, // revoked
|
|
219
|
+
], now);
|
|
220
|
+
expect(summary.active).toBe(2);
|
|
221
|
+
expect(summary.broadScope).toBe(1);
|
|
222
|
+
expect(summary.withoutExpiration).toBe(1);
|
|
223
|
+
expect(summary.staleUnused90d).toBe(1); // key 2 last used > 90d before `now`
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
//# sourceMappingURL=center.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"center.test.js","sourceRoot":"","sources":["../../../src/__tests__/security/center.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"handlers.d.ts","sourceRoot":"","sources":["../../src/api/handlers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AA+/B5C,iFAAiF;AACjF,wBAAgB,wBAAwB,IAAI,IAAI,CAE/C;AA8JD,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAS9E;
|
|
1
|
+
{"version":3,"file":"handlers.d.ts","sourceRoot":"","sources":["../../src/api/handlers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AA+/B5C,iFAAiF;AACjF,wBAAgB,wBAAwB,IAAI,IAAI,CAE/C;AA8JD,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAS9E;AAiTD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAi/UzD"}
|
package/dist/api/handlers.js
CHANGED
|
@@ -33,7 +33,7 @@ import { sendFormNotifications } from '../forms/notify.js';
|
|
|
33
33
|
import { checkForUpdates } from '../upgrade/version-check.js';
|
|
34
34
|
import { createUpgradePR } from '../upgrade/upgrade-pr.js';
|
|
35
35
|
import { encryptField, decryptField } from '../security/encrypted-fields.js';
|
|
36
|
-
import { encryptSecret, decryptSecret, encryptStringArray, } from '../security/secret-storage.js';
|
|
36
|
+
import { encryptSecret, decryptSecret, encryptStringArray, decryptStringArray, } from '../security/secret-storage.js';
|
|
37
37
|
import { createRateLimiter } from '../security/rate-limit.js';
|
|
38
38
|
import { generateOpenAPISpec } from './openapi.js';
|
|
39
39
|
import { createSSEPresenceAdapter } from '../presence/index.js';
|
|
@@ -1133,10 +1133,34 @@ const MAX_CONCURRENT_SESSIONS = 5;
|
|
|
1133
1133
|
async function enforceSessionLimitsForUser(d, userId) {
|
|
1134
1134
|
if (!hasModel(d, 'session'))
|
|
1135
1135
|
return;
|
|
1136
|
+
// Precedence: explicitly-saved Security setting (database) ▸ auth config ▸
|
|
1137
|
+
// default. We read the stored value directly (rather than the parsed
|
|
1138
|
+
// defaults) so an unsaved policy doesn't shadow a config-provided override.
|
|
1139
|
+
let storedMax;
|
|
1140
|
+
try {
|
|
1141
|
+
if (hasModel(d, 'document')) {
|
|
1142
|
+
const row = await d.document.findFirst({
|
|
1143
|
+
where: { collection: 'settings', deletedAt: null },
|
|
1144
|
+
select: { data: true },
|
|
1145
|
+
});
|
|
1146
|
+
const stored = row?.data?.security;
|
|
1147
|
+
const v = stored?.session?.maxConcurrentSessions;
|
|
1148
|
+
if (typeof v === 'number' && Number.isInteger(v) && v >= 0)
|
|
1149
|
+
storedMax = v;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
catch {
|
|
1153
|
+
/* fall back to config/default below */
|
|
1154
|
+
}
|
|
1136
1155
|
const auth = getActuateConfig()?.auth;
|
|
1137
|
-
const max =
|
|
1138
|
-
?
|
|
1139
|
-
:
|
|
1156
|
+
const max = storedMax !== undefined
|
|
1157
|
+
? storedMax
|
|
1158
|
+
: typeof auth?.maxConcurrentSessions === 'number' && auth.maxConcurrentSessions > 0
|
|
1159
|
+
? auth.maxConcurrentSessions
|
|
1160
|
+
: MAX_CONCURRENT_SESSIONS;
|
|
1161
|
+
// 0 ⇒ unlimited: skip pruning entirely.
|
|
1162
|
+
if (max <= 0)
|
|
1163
|
+
return;
|
|
1140
1164
|
const active = await d.session.findMany({
|
|
1141
1165
|
where: { userId, revokedAt: null, expiresAt: { gt: new Date() } },
|
|
1142
1166
|
select: { id: true, createdAt: true },
|
|
@@ -1445,10 +1469,29 @@ export function registerCMSRoutes(router) {
|
|
|
1445
1469
|
userAgent: userAgent ?? null,
|
|
1446
1470
|
},
|
|
1447
1471
|
});
|
|
1472
|
+
// Surface whether org-wide MFA is required but this account hasn't
|
|
1473
|
+
// enrolled yet (past its grace window). The admin shell uses this to
|
|
1474
|
+
// prompt enrollment. Computed best-effort; never blocks login (the
|
|
1475
|
+
// account can still set up TOTP from inside the admin).
|
|
1476
|
+
let mfaSetupRequired = false;
|
|
1477
|
+
try {
|
|
1478
|
+
const { getStoredSecuritySettings } = await import('../security/center/gather.js');
|
|
1479
|
+
const sec = await getStoredSecuritySettings(d);
|
|
1480
|
+
if (sec.mfa.required && !user.totpEnabled) {
|
|
1481
|
+
const graceMs = sec.mfa.gracePeriodDays * 24 * 60 * 60 * 1000;
|
|
1482
|
+
const requiredAt = sec.mfa.requiredAt ? Date.parse(sec.mfa.requiredAt) : 0;
|
|
1483
|
+
const createdAt = user.createdAt ? new Date(user.createdAt).getTime() : 0;
|
|
1484
|
+
mfaSetupRequired = Date.now() >= Math.max(requiredAt || 0, createdAt || 0) + graceMs;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
catch {
|
|
1488
|
+
/* policy unavailable — do not block login */
|
|
1489
|
+
}
|
|
1448
1490
|
const response = json({
|
|
1449
1491
|
data: {
|
|
1450
1492
|
token,
|
|
1451
1493
|
user: { id: user.id, email: user.email, name: user.name, role: user.role },
|
|
1494
|
+
mfaSetupRequired,
|
|
1452
1495
|
},
|
|
1453
1496
|
});
|
|
1454
1497
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
@@ -1698,7 +1741,14 @@ export function registerCMSRoutes(router) {
|
|
|
1698
1741
|
return auth.error;
|
|
1699
1742
|
const user = await db().user.findUnique({
|
|
1700
1743
|
where: { id: auth.session.userId },
|
|
1701
|
-
select: {
|
|
1744
|
+
select: {
|
|
1745
|
+
id: true,
|
|
1746
|
+
email: true,
|
|
1747
|
+
name: true,
|
|
1748
|
+
role: true,
|
|
1749
|
+
isActive: true,
|
|
1750
|
+
totpEnabled: true,
|
|
1751
|
+
},
|
|
1702
1752
|
});
|
|
1703
1753
|
if (!user) {
|
|
1704
1754
|
return errorResponse('User not found', 404);
|
|
@@ -1872,6 +1922,7 @@ export function registerCMSRoutes(router) {
|
|
|
1872
1922
|
role: true,
|
|
1873
1923
|
totpSecret: true,
|
|
1874
1924
|
totpEnabled: true,
|
|
1925
|
+
backupCodes: true,
|
|
1875
1926
|
isActive: true,
|
|
1876
1927
|
},
|
|
1877
1928
|
});
|
|
@@ -1879,7 +1930,34 @@ export function registerCMSRoutes(router) {
|
|
|
1879
1930
|
return errorResponse('Invalid request', 400);
|
|
1880
1931
|
}
|
|
1881
1932
|
const decryptedSecret = await decryptSecret(user.totpSecret);
|
|
1882
|
-
|
|
1933
|
+
let valid = verifyTOTP(body.code, decryptedSecret);
|
|
1934
|
+
let usedBackupCode = false;
|
|
1935
|
+
// Backup-code fallback. A user who has lost their authenticator can sign
|
|
1936
|
+
// in with one of the one-time recovery codes issued at enrollment. Each
|
|
1937
|
+
// code is single-use: on a match we remove it and re-persist the
|
|
1938
|
+
// remaining codes so it can never be replayed. We compare against every
|
|
1939
|
+
// stored code in constant time to avoid leaking which/whether one matched.
|
|
1940
|
+
const storedBackups = Array.isArray(user.backupCodes) ? user.backupCodes : [];
|
|
1941
|
+
if (!valid && storedBackups.length > 0) {
|
|
1942
|
+
try {
|
|
1943
|
+
const { findBackupCodeMatch } = await import('../auth/totp.js');
|
|
1944
|
+
const codes = await decryptStringArray(storedBackups);
|
|
1945
|
+
const matchIdx = findBackupCodeMatch(codes, body.code);
|
|
1946
|
+
if (matchIdx !== -1) {
|
|
1947
|
+
valid = true;
|
|
1948
|
+
usedBackupCode = true;
|
|
1949
|
+
const remaining = codes.filter((_, i) => i !== matchIdx);
|
|
1950
|
+
await db().user.update({
|
|
1951
|
+
where: { id: user.id },
|
|
1952
|
+
data: { backupCodes: await encryptStringArray(remaining) },
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
catch {
|
|
1957
|
+
// Decryption failure (e.g. the encryption key was rotated) — treat
|
|
1958
|
+
// as no match rather than throwing a 500 at the login screen.
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1883
1961
|
if (!valid) {
|
|
1884
1962
|
await logEvent({
|
|
1885
1963
|
event: 'login_failed',
|
|
@@ -1909,7 +1987,7 @@ export function registerCMSRoutes(router) {
|
|
|
1909
1987
|
userId: user.id,
|
|
1910
1988
|
ipAddress: isResolvedIp(ip) ? ip : undefined,
|
|
1911
1989
|
userAgent: userAgent ?? undefined,
|
|
1912
|
-
details: { mfa: 'totp' },
|
|
1990
|
+
details: { mfa: usedBackupCode ? 'backup_code' : 'totp' },
|
|
1913
1991
|
});
|
|
1914
1992
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
1915
1993
|
const sessionCookie = [
|
|
@@ -8949,6 +9027,160 @@ export function registerCMSRoutes(router) {
|
|
|
8949
9027
|
return internalError(err, 'appearance GET');
|
|
8950
9028
|
}
|
|
8951
9029
|
});
|
|
9030
|
+
// ---------------------------------------------------------------------------
|
|
9031
|
+
// Security Center
|
|
9032
|
+
// GET /security — computed posture + summaries
|
|
9033
|
+
// PUT /security — persist enforced policy (lockout-safe)
|
|
9034
|
+
// POST /security/sessions/revoke-all — revoke the caller's other sessions
|
|
9035
|
+
// ---------------------------------------------------------------------------
|
|
9036
|
+
router.get('/security', async (request) => {
|
|
9037
|
+
try {
|
|
9038
|
+
const auth = await requireAuth(request);
|
|
9039
|
+
if (auth.error)
|
|
9040
|
+
return auth.error;
|
|
9041
|
+
const adminErr = requireAdminScope(auth.session);
|
|
9042
|
+
if (adminErr)
|
|
9043
|
+
return adminErr;
|
|
9044
|
+
const { getSecurityCenterData } = await import('../security/center/gather.js');
|
|
9045
|
+
const data = await getSecurityCenterData({
|
|
9046
|
+
db: db(),
|
|
9047
|
+
userId: auth.session.userId,
|
|
9048
|
+
currentIp: getClientIp(request),
|
|
9049
|
+
});
|
|
9050
|
+
return json({ data });
|
|
9051
|
+
}
|
|
9052
|
+
catch (err) {
|
|
9053
|
+
return internalError(err, 'security GET');
|
|
9054
|
+
}
|
|
9055
|
+
});
|
|
9056
|
+
router.put('/security', async (request) => {
|
|
9057
|
+
try {
|
|
9058
|
+
const auth = await requireAuth(request);
|
|
9059
|
+
if (auth.error)
|
|
9060
|
+
return auth.error;
|
|
9061
|
+
const adminErr = requireAdminScope(auth.session);
|
|
9062
|
+
if (adminErr)
|
|
9063
|
+
return adminErr;
|
|
9064
|
+
const body = (await request.json());
|
|
9065
|
+
const { parseSecuritySettings, validateSecuritySettings, mfaEnableLockoutError } = await import('../security/center/settings.js');
|
|
9066
|
+
const { getStoredSecuritySettings, getSecurityCenterData } = await import('../security/center/gather.js');
|
|
9067
|
+
const incoming = parseSecuritySettings({ security: body });
|
|
9068
|
+
const issues = validateSecuritySettings(incoming);
|
|
9069
|
+
const errors = issues.filter((i) => i.severity === 'error');
|
|
9070
|
+
if (errors.length > 0) {
|
|
9071
|
+
return json({ error: errors[0].message, issues }, 400);
|
|
9072
|
+
}
|
|
9073
|
+
const d = db();
|
|
9074
|
+
const current = await getStoredSecuritySettings(d);
|
|
9075
|
+
// Lockout-safe: enabling org-wide MFA requires the acting admin to have
|
|
9076
|
+
// their own MFA configured. Enforced server-side — never trust the client.
|
|
9077
|
+
const enabling = incoming.mfa.required && !current.mfa.required;
|
|
9078
|
+
if (enabling) {
|
|
9079
|
+
let viewerHasMfa = false;
|
|
9080
|
+
let totalAdmins = 0;
|
|
9081
|
+
if (hasModel(d, 'user')) {
|
|
9082
|
+
const viewer = await d.user
|
|
9083
|
+
.findUnique({ where: { id: auth.session.userId }, select: { totpEnabled: true } })
|
|
9084
|
+
.catch(() => null);
|
|
9085
|
+
viewerHasMfa = Boolean(viewer?.totpEnabled);
|
|
9086
|
+
totalAdmins = await d.user
|
|
9087
|
+
.count({ where: { role: 'ADMIN', isActive: true } })
|
|
9088
|
+
.catch(() => 0);
|
|
9089
|
+
}
|
|
9090
|
+
const lockErr = mfaEnableLockoutError({ enabling: true, viewerHasMfa, totalAdmins });
|
|
9091
|
+
if (lockErr) {
|
|
9092
|
+
return json({ error: lockErr, code: 'MFA_LOCKOUT_RISK' }, 409);
|
|
9093
|
+
}
|
|
9094
|
+
}
|
|
9095
|
+
// Stamp `requiredAt` when MFA requirement flips on (drives the grace
|
|
9096
|
+
// window); preserve it across saves while it stays on; clear when off.
|
|
9097
|
+
const requiredAt = incoming.mfa.required
|
|
9098
|
+
? current.mfa.required
|
|
9099
|
+
? current.mfa.requiredAt
|
|
9100
|
+
: new Date().toISOString()
|
|
9101
|
+
: undefined;
|
|
9102
|
+
const securityBlob = {
|
|
9103
|
+
mfa: {
|
|
9104
|
+
required: incoming.mfa.required,
|
|
9105
|
+
gracePeriodDays: incoming.mfa.gracePeriodDays,
|
|
9106
|
+
...(requiredAt ? { requiredAt } : {}),
|
|
9107
|
+
},
|
|
9108
|
+
session: { maxConcurrentSessions: incoming.session.maxConcurrentSessions },
|
|
9109
|
+
updatedAt: new Date().toISOString(),
|
|
9110
|
+
updatedBy: auth.session.userId,
|
|
9111
|
+
};
|
|
9112
|
+
const ctx = buildActionContext(auth.session, d);
|
|
9113
|
+
const prevGlobal = (await getGlobal('settings', ctx).catch(() => ({}))) ?? {};
|
|
9114
|
+
await updateGlobal('settings', { ...prevGlobal, security: securityBlob }, ctx);
|
|
9115
|
+
const changes = [];
|
|
9116
|
+
if (current.mfa.required !== incoming.mfa.required)
|
|
9117
|
+
changes.push({ key: 'mfa.required', from: current.mfa.required, to: incoming.mfa.required });
|
|
9118
|
+
if (current.mfa.gracePeriodDays !== incoming.mfa.gracePeriodDays)
|
|
9119
|
+
changes.push({
|
|
9120
|
+
key: 'mfa.gracePeriodDays',
|
|
9121
|
+
from: current.mfa.gracePeriodDays,
|
|
9122
|
+
to: incoming.mfa.gracePeriodDays,
|
|
9123
|
+
});
|
|
9124
|
+
if (current.session.maxConcurrentSessions !== incoming.session.maxConcurrentSessions)
|
|
9125
|
+
changes.push({
|
|
9126
|
+
key: 'session.maxConcurrentSessions',
|
|
9127
|
+
from: current.session.maxConcurrentSessions,
|
|
9128
|
+
to: incoming.session.maxConcurrentSessions,
|
|
9129
|
+
});
|
|
9130
|
+
if (changes.length > 0) {
|
|
9131
|
+
void logEvent({
|
|
9132
|
+
event: 'security_settings_changed',
|
|
9133
|
+
userId: auth.session.userId,
|
|
9134
|
+
ipAddress: getClientIp(request),
|
|
9135
|
+
details: {
|
|
9136
|
+
source: 'database',
|
|
9137
|
+
environment: process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? null,
|
|
9138
|
+
changes,
|
|
9139
|
+
},
|
|
9140
|
+
});
|
|
9141
|
+
}
|
|
9142
|
+
const data = await getSecurityCenterData({
|
|
9143
|
+
db: d,
|
|
9144
|
+
userId: auth.session.userId,
|
|
9145
|
+
currentIp: getClientIp(request),
|
|
9146
|
+
});
|
|
9147
|
+
return json({ data });
|
|
9148
|
+
}
|
|
9149
|
+
catch (err) {
|
|
9150
|
+
return internalError(err, 'security PUT');
|
|
9151
|
+
}
|
|
9152
|
+
});
|
|
9153
|
+
router.post('/security/sessions/revoke-all', async (request) => {
|
|
9154
|
+
try {
|
|
9155
|
+
const auth = await requireAuth(request);
|
|
9156
|
+
if (auth.error)
|
|
9157
|
+
return auth.error;
|
|
9158
|
+
const d = db();
|
|
9159
|
+
if (!hasModel(d, 'session'))
|
|
9160
|
+
return json({ data: { revoked: 0 } });
|
|
9161
|
+
// Revoke every OTHER active session for the caller — their current
|
|
9162
|
+
// session stays valid so they aren't logged out of the admin they're in.
|
|
9163
|
+
const result = await d.session.updateMany({
|
|
9164
|
+
where: {
|
|
9165
|
+
userId: auth.session.userId,
|
|
9166
|
+
id: { not: auth.session.sessionId },
|
|
9167
|
+
revokedAt: null,
|
|
9168
|
+
},
|
|
9169
|
+
data: { revokedAt: new Date() },
|
|
9170
|
+
});
|
|
9171
|
+
const revoked = typeof result?.count === 'number' ? result.count : 0;
|
|
9172
|
+
void logEvent({
|
|
9173
|
+
event: 'sessions_revoked_all',
|
|
9174
|
+
userId: auth.session.userId,
|
|
9175
|
+
ipAddress: getClientIp(request),
|
|
9176
|
+
details: { count: revoked, scope: 'self_other' },
|
|
9177
|
+
});
|
|
9178
|
+
return json({ data: { revoked } });
|
|
9179
|
+
}
|
|
9180
|
+
catch (err) {
|
|
9181
|
+
return internalError(err, 'security/sessions/revoke-all');
|
|
9182
|
+
}
|
|
9183
|
+
});
|
|
8952
9184
|
router.get('/globals/:slug', async (request, params) => {
|
|
8953
9185
|
try {
|
|
8954
9186
|
const auth = await requireAuth(request);
|
|
@@ -9004,7 +9236,7 @@ export function registerCMSRoutes(router) {
|
|
|
9004
9236
|
try {
|
|
9005
9237
|
const changes = diffScalarKeys(previous ?? {}, body);
|
|
9006
9238
|
const prev = (previous ?? {});
|
|
9007
|
-
for (const key of ['appearance', 'brand']) {
|
|
9239
|
+
for (const key of ['appearance', 'brand', 'security']) {
|
|
9008
9240
|
if (key in body && JSON.stringify(prev[key]) !== JSON.stringify(body[key])) {
|
|
9009
9241
|
changes.push({ key, from: '[changed]', to: '[changed]' });
|
|
9010
9242
|
}
|