@adminforth/two-factors-auth 4.5.0 → 4.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/build.log +2 -2
- package/custom/TwoFAModal.vue +30 -19
- package/custom/TwoFactorsPasskeysSettings.vue +2 -4
- package/custom/use2faApi.ts +1 -0
- package/dist/custom/TwoFAModal.vue +30 -19
- package/dist/custom/TwoFactorsPasskeysSettings.vue +2 -4
- package/dist/custom/use2faApi.ts +1 -0
- package/dist/handlers/createPasskeyHandlers.d.ts +3 -6
- package/dist/handlers/createPasskeyHandlers.js +11 -15
- package/dist/index.d.ts +11 -7
- package/dist/index.js +23 -5
- package/dist/services/mfaRequestContext.d.ts +13 -0
- package/dist/services/mfaRequestContext.js +50 -0
- package/dist/services/totpService.d.ts +9 -10
- package/dist/services/totpService.js +27 -5
- package/dist/services/verifyMfaConfirmation.d.ts +7 -7
- package/dist/services/verifyMfaConfirmation.js +23 -20
- package/dist/utils/errors.d.ts +13 -0
- package/dist/utils/errors.js +14 -0
- package/dist/utils/types.d.ts +8 -0
- package/handlers/createPasskeyHandlers.ts +30 -16
- package/index.ts +28 -7
- package/package.json +1 -1
- package/services/mfaRequestContext.ts +51 -0
- package/services/totpService.ts +30 -7
- package/services/verifyMfaConfirmation.ts +30 -24
- package/tests/mfaRequestContext.test.mjs +79 -0
- package/tests/totpService.test.mjs +38 -0
- package/utils/errors.ts +14 -0
- package/utils/types.ts +4 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { MfaRequestContext } from '../dist/services/mfaRequestContext.js';
|
|
4
|
+
const user = { pk: 'user-1' };
|
|
5
|
+
|
|
6
|
+
test('without external context normal browser verification is unaffected', async () => {
|
|
7
|
+
const context = new MfaRequestContext();
|
|
8
|
+
assert.equal(await context.verify(user, undefined, () => assert.fail('verification must not run')), undefined);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('missing verification stops a CRUD hook before it can strip the error code', async () => {
|
|
12
|
+
const context = new MfaRequestContext();
|
|
13
|
+
let written = false;
|
|
14
|
+
const result = await context.run(undefined, {}, async () => {
|
|
15
|
+
await context.verify(user, undefined, async () => ({ error: 'Required', code: 'TWO_FACTOR_REQUIRED' }));
|
|
16
|
+
written = true;
|
|
17
|
+
return { ok: true };
|
|
18
|
+
});
|
|
19
|
+
assert.equal(written, false);
|
|
20
|
+
assert.equal(result.code, 'TWO_FACTOR_REQUIRED');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('a failed verification carries the action description for the confirmation page', async () => {
|
|
24
|
+
const context = new MfaRequestContext();
|
|
25
|
+
const result = await context.run(undefined, {}, async () => {
|
|
26
|
+
await context.verify(user, 'Approve $100,000 bonus', async () => ({ error: 'Required', code: 'TWO_FACTOR_REQUIRED' }));
|
|
27
|
+
});
|
|
28
|
+
assert.deepEqual(result, { error: 'Required', code: 'TWO_FACTOR_REQUIRED', actionDescription: 'Approve $100,000 bonus' });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('invalid TOTP or passkey cannot reach the mutation', async () => {
|
|
32
|
+
for (const mode of ['totp', 'passkey']) {
|
|
33
|
+
const context = new MfaRequestContext();
|
|
34
|
+
const result = await context.run({ mode, result: 'invalid' }, {}, async () => {
|
|
35
|
+
await context.verify(user, undefined, async () => ({ error: 'Invalid', code: 'TWO_FACTOR_INVALID' }));
|
|
36
|
+
assert.fail('mutation must not execute');
|
|
37
|
+
});
|
|
38
|
+
assert.equal(result.code, 'TWO_FACTOR_INVALID');
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('valid verification is reused only within the same request and user', async () => {
|
|
43
|
+
const context = new MfaRequestContext();
|
|
44
|
+
const token = { mode: 'totp', result: '123456' };
|
|
45
|
+
const extra = { cookies: [{ key: 'session', value: 'browser' }] };
|
|
46
|
+
let checks = 0;
|
|
47
|
+
const verify = async (result, passedExtra) => { checks++; assert.equal(result, token); assert.equal(passedExtra, extra); return { ok: true }; };
|
|
48
|
+
const run = () => context.run(token, extra, async () => {
|
|
49
|
+
await context.verify(user, undefined, verify);
|
|
50
|
+
await context.verify(user, undefined, verify);
|
|
51
|
+
await context.verify({ pk: 'other-user' }, undefined, verify);
|
|
52
|
+
return { ok: true };
|
|
53
|
+
});
|
|
54
|
+
await run();
|
|
55
|
+
assert.equal(checks, 2);
|
|
56
|
+
await run();
|
|
57
|
+
assert.equal(checks, 4);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('concurrent requests do not share verification or cookies', async () => {
|
|
61
|
+
const context = new MfaRequestContext();
|
|
62
|
+
const run = (token, cookie, code) => context.run(token, { cookie }, async () => {
|
|
63
|
+
await context.verify(user, undefined, async (received, extra) => {
|
|
64
|
+
await new Promise(resolve => setTimeout(resolve, 1));
|
|
65
|
+
assert.equal(received, token);
|
|
66
|
+
assert.equal(extra.cookie, cookie);
|
|
67
|
+
return code ? { error: 'Required', code } : { ok: true };
|
|
68
|
+
});
|
|
69
|
+
return { ok: true };
|
|
70
|
+
});
|
|
71
|
+
const [accepted, rejected] = await Promise.all([run('valid', 'a'), run(undefined, 'b', 'TWO_FACTOR_REQUIRED')]);
|
|
72
|
+
assert.equal(accepted.ok, true);
|
|
73
|
+
assert.equal(rejected.code, 'TWO_FACTOR_REQUIRED');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('unrelated execution errors propagate to MCP', async () => {
|
|
77
|
+
const context = new MfaRequestContext();
|
|
78
|
+
await assert.rejects(context.run(undefined, {}, async () => { throw new Error('DB error'); }), /DB error/);
|
|
79
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import twofactor from 'node-2fa';
|
|
4
|
+
import { TotpService } from '../dist/services/totpService.js';
|
|
5
|
+
import { TwoFactorErrorCode } from '../dist/utils/errors.js';
|
|
6
|
+
|
|
7
|
+
const SECRET = 'JBSWY3DPEHPK3PXP';
|
|
8
|
+
const userRepository = { getAuthUser: async (pk) => ({ pk }), getSecret: () => SECRET, getUserPk: (adminUser) => adminUser.pk };
|
|
9
|
+
|
|
10
|
+
function createService() {
|
|
11
|
+
const jwts = new Map();
|
|
12
|
+
const auth = {
|
|
13
|
+
issueJWT: (payload, type) => {
|
|
14
|
+
const token = `jwt-${jwts.size}`;
|
|
15
|
+
jwts.set(token, { ...payload, type });
|
|
16
|
+
return token;
|
|
17
|
+
},
|
|
18
|
+
verify: async (token, type) => (jwts.get(token)?.type === type ? jwts.get(token) : null),
|
|
19
|
+
};
|
|
20
|
+
return new TotpService({ timeStepWindow: 1 }, userRepository, auth);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('limits code attempts of each user to stop brute force of a stolen session', async () => {
|
|
24
|
+
const service = createService();
|
|
25
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
26
|
+
assert.equal((await service.verifyUserCode('user-1', 'wrong')).code, TwoFactorErrorCode.INVALID);
|
|
27
|
+
}
|
|
28
|
+
assert.equal((await service.verifyUserCode('user-1', 'wrong')).code, TwoFactorErrorCode.RATE_LIMITED);
|
|
29
|
+
assert.equal((await service.verifyUserCode('user-2', 'wrong')).code, TwoFactorErrorCode.INVALID);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('a correct code gives a token that confirms the code only for its user', async () => {
|
|
33
|
+
const service = createService();
|
|
34
|
+
const { verifiedCodeToken } = await service.verifyAdminUserCode({ pk: 'user-1' }, twofactor.generateToken(SECRET).token);
|
|
35
|
+
assert.equal(await service.isVerifiedCodeToken(verifiedCodeToken, 'user-1'), true);
|
|
36
|
+
assert.equal(await service.isVerifiedCodeToken(verifiedCodeToken, 'user-2'), false);
|
|
37
|
+
assert.equal(await service.isVerifiedCodeToken('forged', 'user-1'), false);
|
|
38
|
+
});
|
package/utils/errors.ts
CHANGED
|
@@ -9,6 +9,20 @@ export enum HttpStatus {
|
|
|
9
9
|
INTERNAL_SERVER_ERROR = 500,
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export enum TwoFactorErrorCode {
|
|
13
|
+
REQUIRED = 'TWO_FACTOR_REQUIRED',
|
|
14
|
+
INVALID = 'TWO_FACTOR_INVALID',
|
|
15
|
+
RATE_LIMITED = 'TWO_FACTOR_RATE_LIMITED',
|
|
16
|
+
NOT_SET_UP = 'TWO_FACTOR_NOT_SET_UP',
|
|
17
|
+
USER_NOT_FOUND = 'USER_NOT_FOUND',
|
|
18
|
+
UNSUPPORTED_MODE = 'UNSUPPORTED_CONFIRMATION_MODE',
|
|
19
|
+
BROWSER_CONTEXT_REQUIRED = 'BROWSER_CONTEXT_REQUIRED',
|
|
20
|
+
CANCELLED = 'VERIFICATION_CANCELLED',
|
|
21
|
+
TIMEOUT = 'VERIFICATION_TIMEOUT',
|
|
22
|
+
INVALID_SESSION = 'INVALID_VERIFICATION_SESSION',
|
|
23
|
+
INTERNAL_ERROR = 'INTERNAL_ERROR',
|
|
24
|
+
}
|
|
25
|
+
|
|
12
26
|
export function respondWithStatus<T>(body: T, response: IAdminForthHttpResponse, status: HttpStatus): T {
|
|
13
27
|
response.setStatus(status);
|
|
14
28
|
return body;
|
package/utils/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { HttpExtra } from "adminforth";
|
|
2
|
+
import type { TwoFactorErrorCode } from "./errors.js";
|
|
2
3
|
|
|
3
4
|
export type AnyRecord = Record<string, any>;
|
|
4
5
|
export type CookieList = HttpExtra["cookies"];
|
|
@@ -15,3 +16,6 @@ export type OkResponse<T extends AnyRecord = AnyRecord> = {
|
|
|
15
16
|
} & T;
|
|
16
17
|
|
|
17
18
|
export type PluginResponse<T extends AnyRecord = AnyRecord> = OkResponse<T> | ErrorResponse;
|
|
19
|
+
|
|
20
|
+
export type VerificationFailure = { error: string; code: TwoFactorErrorCode };
|
|
21
|
+
export type VerificationResult = { ok: true } | VerificationFailure;
|