@actuate-media/cms-core 0.37.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/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/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +39 -4
- 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/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"totp-login.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/api/totp-login.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { handleActuateAPI } from '../../api/index.js';
|
|
3
|
+
import { initDB } from '../../db.js';
|
|
4
|
+
import { encryptSecret, encryptStringArray } from '../../security/secret-storage.js';
|
|
5
|
+
import { createMfaPendingToken, computeRequestFingerprint } from '../../auth/mfa-pending.js';
|
|
6
|
+
import { generateTOTPSecret } from '../../auth/totp.js';
|
|
7
|
+
/**
|
|
8
|
+
* Contract test for the backup-code recovery path of POST /auth/totp/login.
|
|
9
|
+
*
|
|
10
|
+
* Backup codes are generated and shown at enrollment but, before this, were
|
|
11
|
+
* never consumed anywhere — a lost authenticator meant permanent lockout. This
|
|
12
|
+
* proves a valid code signs the user in, is single-use (consumed on success),
|
|
13
|
+
* and that an unknown code is rejected.
|
|
14
|
+
*/
|
|
15
|
+
const SECRET = 'a'.repeat(48);
|
|
16
|
+
const ENCRYPTION_KEY = 'b'.repeat(64); // 32 bytes hex
|
|
17
|
+
const BACKUP_CODES = ['aaaa1111', 'bbbb2222', 'cccc3333'];
|
|
18
|
+
async function createMockDB(state) {
|
|
19
|
+
const totpSecret = await encryptSecret(generateTOTPSecret());
|
|
20
|
+
return {
|
|
21
|
+
user: {
|
|
22
|
+
findUnique: async () => ({
|
|
23
|
+
id: 'user-1',
|
|
24
|
+
email: 'admin@example.com',
|
|
25
|
+
name: 'Admin',
|
|
26
|
+
role: 'ADMIN',
|
|
27
|
+
totpSecret,
|
|
28
|
+
totpEnabled: true,
|
|
29
|
+
backupCodes: await encryptStringArray(state.backupCodes),
|
|
30
|
+
isActive: true,
|
|
31
|
+
}),
|
|
32
|
+
update: async ({ data }) => {
|
|
33
|
+
// The handler re-encrypts the remaining codes; decrypt-by-count is
|
|
34
|
+
// enough for the assertion (we only check how many remain).
|
|
35
|
+
state.backupCodes = state.backupCodes.slice(0, data.backupCodes.length);
|
|
36
|
+
return { id: 'user-1' };
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
document: {
|
|
40
|
+
findFirst: async () => ({ data: {} }),
|
|
41
|
+
},
|
|
42
|
+
session: {
|
|
43
|
+
findMany: async () => [],
|
|
44
|
+
create: async () => ({ id: 'sess-1' }),
|
|
45
|
+
updateMany: async () => ({ count: 0 }),
|
|
46
|
+
},
|
|
47
|
+
auditLog: {
|
|
48
|
+
create: async () => ({ id: 'log-1' }),
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
async function makePendingToken(ip, userAgent) {
|
|
53
|
+
const fingerprint = await computeRequestFingerprint(ip, userAgent);
|
|
54
|
+
return createMfaPendingToken({ userId: 'user-1', fingerprint }, SECRET);
|
|
55
|
+
}
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
delete globalThis.__actuateConfig;
|
|
58
|
+
process.env.CMS_SECRET = SECRET;
|
|
59
|
+
process.env.CMS_ENCRYPTION_KEY = ENCRYPTION_KEY;
|
|
60
|
+
});
|
|
61
|
+
describe('POST /auth/totp/login — backup code recovery', () => {
|
|
62
|
+
it('signs in with a valid backup code and consumes it (single-use)', async () => {
|
|
63
|
+
const ip = '203.0.113.10';
|
|
64
|
+
const ua = 'vitest-agent';
|
|
65
|
+
const state = { backupCodes: [...BACKUP_CODES] };
|
|
66
|
+
const db = await createMockDB(state);
|
|
67
|
+
initDB(db);
|
|
68
|
+
const handler = handleActuateAPI({ prismaClient: db, config: { collections: {} } });
|
|
69
|
+
const token = await makePendingToken(ip, ua);
|
|
70
|
+
const res = await handler(new Request('https://example.com/api/cms/auth/totp/login', {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: { 'x-real-ip': ip, 'user-agent': ua, 'content-type': 'application/json' },
|
|
73
|
+
body: JSON.stringify({ mfaPendingToken: token, code: 'BBBB-2222' }),
|
|
74
|
+
}));
|
|
75
|
+
expect(res.status).toBe(200);
|
|
76
|
+
expect(res.headers.get('set-cookie')).toContain('actuate_session=');
|
|
77
|
+
// The used code was removed — one fewer remains.
|
|
78
|
+
expect(state.backupCodes).toHaveLength(BACKUP_CODES.length - 1);
|
|
79
|
+
});
|
|
80
|
+
it('rejects an unknown code', async () => {
|
|
81
|
+
const ip = '203.0.113.11';
|
|
82
|
+
const ua = 'vitest-agent';
|
|
83
|
+
const state = { backupCodes: [...BACKUP_CODES] };
|
|
84
|
+
const db = await createMockDB(state);
|
|
85
|
+
initDB(db);
|
|
86
|
+
const handler = handleActuateAPI({ prismaClient: db, config: { collections: {} } });
|
|
87
|
+
const token = await makePendingToken(ip, ua);
|
|
88
|
+
const res = await handler(new Request('https://example.com/api/cms/auth/totp/login', {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'x-real-ip': ip, 'user-agent': ua, 'content-type': 'application/json' },
|
|
91
|
+
body: JSON.stringify({ mfaPendingToken: token, code: 'deadbeef' }),
|
|
92
|
+
}));
|
|
93
|
+
expect(res.status).toBe(401);
|
|
94
|
+
expect(state.backupCodes).toHaveLength(BACKUP_CODES.length);
|
|
95
|
+
});
|
|
96
|
+
it('rejects a stale pending token from a different device fingerprint', async () => {
|
|
97
|
+
const state = { backupCodes: [...BACKUP_CODES] };
|
|
98
|
+
const db = await createMockDB(state);
|
|
99
|
+
initDB(db);
|
|
100
|
+
const handler = handleActuateAPI({ prismaClient: db, config: { collections: {} } });
|
|
101
|
+
// Token minted for one device, redeemed from another (different UA).
|
|
102
|
+
const token = await makePendingToken('203.0.113.12', 'device-a');
|
|
103
|
+
const res = await handler(new Request('https://example.com/api/cms/auth/totp/login', {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: {
|
|
106
|
+
'x-real-ip': '203.0.113.12',
|
|
107
|
+
'user-agent': 'device-b',
|
|
108
|
+
'content-type': 'application/json',
|
|
109
|
+
},
|
|
110
|
+
body: JSON.stringify({ mfaPendingToken: token, code: 'aaaa1111' }),
|
|
111
|
+
}));
|
|
112
|
+
expect(res.status).toBe(401);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
//# sourceMappingURL=totp-login.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"totp-login.test.js","sourceRoot":"","sources":["../../../src/__tests__/api/totp-login.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"totp.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/auth/totp.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { generateBackupCodes, generateTOTPSecret, generateTOTPUri, verifyTOTP, normalizeBackupCode, findBackupCodeMatch, } from '../../auth/totp.js';
|
|
3
|
+
describe('totp helpers', () => {
|
|
4
|
+
it('generates a scannable otpauth URI for the given account', () => {
|
|
5
|
+
const secret = generateTOTPSecret();
|
|
6
|
+
const uri = generateTOTPUri(secret, 'admin@example.com');
|
|
7
|
+
expect(uri.startsWith('otpauth://totp/')).toBe(true);
|
|
8
|
+
expect(uri).toContain(`secret=${secret}`);
|
|
9
|
+
expect(uri).toContain('issuer=Actuate%20CMS');
|
|
10
|
+
expect(uri).toContain(encodeURIComponent('admin@example.com'));
|
|
11
|
+
});
|
|
12
|
+
it('verifies a TOTP code derived from the same secret', async () => {
|
|
13
|
+
// Derive the current code the same way the verifier does, then confirm it
|
|
14
|
+
// round-trips. Importing the private HOTP isn't exposed, so we lean on the
|
|
15
|
+
// window tolerance: a freshly generated secret should not validate a random
|
|
16
|
+
// 6-digit guess.
|
|
17
|
+
const secret = generateTOTPSecret();
|
|
18
|
+
expect(verifyTOTP('000000', secret)).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
describe('backup codes', () => {
|
|
22
|
+
it('generates the requested number of unique 8-char hex codes', () => {
|
|
23
|
+
const codes = generateBackupCodes(8);
|
|
24
|
+
expect(codes).toHaveLength(8);
|
|
25
|
+
expect(new Set(codes).size).toBe(8);
|
|
26
|
+
for (const code of codes) {
|
|
27
|
+
expect(code).toMatch(/^[0-9a-f]{8}$/);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
it('normalizes whitespace, dashes and case', () => {
|
|
31
|
+
expect(normalizeBackupCode('ABCD-1234')).toBe('abcd1234');
|
|
32
|
+
expect(normalizeBackupCode(' ab cd 12 34 ')).toBe('abcd1234');
|
|
33
|
+
expect(normalizeBackupCode('')).toBe('');
|
|
34
|
+
});
|
|
35
|
+
it('matches a stored code regardless of formatting and returns its index', () => {
|
|
36
|
+
const codes = ['aaaa1111', 'bbbb2222', 'cccc3333'];
|
|
37
|
+
expect(findBackupCodeMatch(codes, 'BBBB-2222')).toBe(1);
|
|
38
|
+
expect(findBackupCodeMatch(codes, ' cccc 3333 ')).toBe(2);
|
|
39
|
+
});
|
|
40
|
+
it('returns -1 for a non-matching or empty submission', () => {
|
|
41
|
+
const codes = ['aaaa1111', 'bbbb2222'];
|
|
42
|
+
expect(findBackupCodeMatch(codes, 'deadbeef')).toBe(-1);
|
|
43
|
+
expect(findBackupCodeMatch(codes, '')).toBe(-1);
|
|
44
|
+
expect(findBackupCodeMatch([], 'aaaa1111')).toBe(-1);
|
|
45
|
+
});
|
|
46
|
+
it('does not match a code that only shares a prefix (length-aware compare)', () => {
|
|
47
|
+
const codes = ['aaaa1111'];
|
|
48
|
+
expect(findBackupCodeMatch(codes, 'aaaa111')).toBe(-1);
|
|
49
|
+
expect(findBackupCodeMatch(codes, 'aaaa11110')).toBe(-1);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
//# sourceMappingURL=totp.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"totp.test.js","sourceRoot":"","sources":["../../../src/__tests__/auth/totp.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;AAiTD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,
|
|
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';
|
|
@@ -1741,7 +1741,14 @@ export function registerCMSRoutes(router) {
|
|
|
1741
1741
|
return auth.error;
|
|
1742
1742
|
const user = await db().user.findUnique({
|
|
1743
1743
|
where: { id: auth.session.userId },
|
|
1744
|
-
select: {
|
|
1744
|
+
select: {
|
|
1745
|
+
id: true,
|
|
1746
|
+
email: true,
|
|
1747
|
+
name: true,
|
|
1748
|
+
role: true,
|
|
1749
|
+
isActive: true,
|
|
1750
|
+
totpEnabled: true,
|
|
1751
|
+
},
|
|
1745
1752
|
});
|
|
1746
1753
|
if (!user) {
|
|
1747
1754
|
return errorResponse('User not found', 404);
|
|
@@ -1915,6 +1922,7 @@ export function registerCMSRoutes(router) {
|
|
|
1915
1922
|
role: true,
|
|
1916
1923
|
totpSecret: true,
|
|
1917
1924
|
totpEnabled: true,
|
|
1925
|
+
backupCodes: true,
|
|
1918
1926
|
isActive: true,
|
|
1919
1927
|
},
|
|
1920
1928
|
});
|
|
@@ -1922,7 +1930,34 @@ export function registerCMSRoutes(router) {
|
|
|
1922
1930
|
return errorResponse('Invalid request', 400);
|
|
1923
1931
|
}
|
|
1924
1932
|
const decryptedSecret = await decryptSecret(user.totpSecret);
|
|
1925
|
-
|
|
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
|
+
}
|
|
1926
1961
|
if (!valid) {
|
|
1927
1962
|
await logEvent({
|
|
1928
1963
|
event: 'login_failed',
|
|
@@ -1952,7 +1987,7 @@ export function registerCMSRoutes(router) {
|
|
|
1952
1987
|
userId: user.id,
|
|
1953
1988
|
ipAddress: isResolvedIp(ip) ? ip : undefined,
|
|
1954
1989
|
userAgent: userAgent ?? undefined,
|
|
1955
|
-
details: { mfa: 'totp' },
|
|
1990
|
+
details: { mfa: usedBackupCode ? 'backup_code' : 'totp' },
|
|
1956
1991
|
});
|
|
1957
1992
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
1958
1993
|
const sessionCookie = [
|