@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
package/build.log
CHANGED
|
@@ -17,5 +17,5 @@ custom/tsconfig.json
|
|
|
17
17
|
custom/use2faApi.ts
|
|
18
18
|
custom/utils.js
|
|
19
19
|
|
|
20
|
-
sent
|
|
21
|
-
total size is
|
|
20
|
+
sent 312,646 bytes received 248 bytes 625,788.00 bytes/sec
|
|
21
|
+
total size is 311,686 speedup is 1.00
|
package/custom/TwoFAModal.vue
CHANGED
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
@on-complete="handleOnComplete"
|
|
38
38
|
/>
|
|
39
39
|
</div>
|
|
40
|
+
<p v-if="codeError" class="af-2fa-code-error text-center text-xs text-red-600 dark:text-red-400">{{ codeError }}</p>
|
|
40
41
|
<p v-if="doesUserHavePasskeys" class="af-2fa-totp-footer text-center text-xs text-gray-500 dark:text-gray-400">
|
|
41
42
|
{{$t('Having trouble?')}}
|
|
42
43
|
<button type="button" class="af-2fa-switch-to-passkey text-lightPrimary dark:text-white hover:underline cursor-pointer" @click="modalMode = 'passkey'">{{$t('Use passkey instead')}}</button>
|
|
@@ -141,7 +142,7 @@
|
|
|
141
142
|
if (props.adminUser) {
|
|
142
143
|
websocket.unsubscribeByPrefix(`/user2fa/`);
|
|
143
144
|
const user2FaTopic = `/user2fa/${props.adminUser.pk}/${getAdminForthClientId()}`;
|
|
144
|
-
websocket.subscribe(user2FaTopic, async (data: {sessionId: string}) => {
|
|
145
|
+
websocket.subscribe(user2FaTopic, async (data: {sessionId: string; actionDescription?: string}) => {
|
|
145
146
|
if (!allowAddNewSessions) {
|
|
146
147
|
alert({message: t('Some process or user tries to add new actions to confirm. Action was blocked'), variant: 'warning'});
|
|
147
148
|
return;
|
|
@@ -153,7 +154,7 @@
|
|
|
153
154
|
}
|
|
154
155
|
try {
|
|
155
156
|
isAwaiting2FAResult.value = true;
|
|
156
|
-
confirmationResult = await twoFactorsAuthApi.get2FaConfirmationResult();
|
|
157
|
+
confirmationResult = await twoFactorsAuthApi.get2FaConfirmationResult(data.actionDescription);
|
|
157
158
|
} catch (error) {
|
|
158
159
|
if (!isTwoFaCancel(error)) {
|
|
159
160
|
console.error('[AdminForth 2FA] Error during 2FA confirmation', error);
|
|
@@ -271,7 +272,8 @@
|
|
|
271
272
|
const isLoading = ref(false);
|
|
272
273
|
const isFetchingPasskey = ref(false);
|
|
273
274
|
const customDialogTitle = ref("");
|
|
274
|
-
|
|
275
|
+
const codeError = ref("");
|
|
276
|
+
|
|
275
277
|
async function usePasskeyButtonClick() {
|
|
276
278
|
isFetchingPasskey.value = true;
|
|
277
279
|
let passkeyData;
|
|
@@ -322,32 +324,41 @@
|
|
|
322
324
|
|
|
323
325
|
async function sendConfirmationResult(value: string) {
|
|
324
326
|
if (!resolveFn) throw new Error(t('Modal is not initialized properly'));
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
327
|
+
let checked: { error: string } | { result: TwoFaConfirmationResult };
|
|
328
|
+
try {
|
|
329
|
+
checked = await checkCode(value);
|
|
330
|
+
} catch (err) {
|
|
331
|
+
rejectFn?.(err);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if ('error' in checked) {
|
|
335
|
+
codeError.value = checked.error;
|
|
336
|
+
bindValue.value = '';
|
|
337
|
+
confirmationResult.value?.clearInput();
|
|
338
|
+
return;
|
|
336
339
|
}
|
|
337
340
|
|
|
338
341
|
modelShow.value = false;
|
|
339
|
-
|
|
340
|
-
mode: "totp",
|
|
341
|
-
result: value
|
|
342
|
-
}
|
|
342
|
+
codeError.value = "";
|
|
343
343
|
customDialogTitle.value = "";
|
|
344
344
|
removeEventListenerForOTPInput();
|
|
345
|
-
resolveFn(
|
|
345
|
+
resolveFn(checked.result);
|
|
346
346
|
}
|
|
347
347
|
|
|
348
348
|
|
|
349
|
+
async function checkCode(code: string): Promise<{ error: string } | { result: TwoFaConfirmationResult }> {
|
|
350
|
+
if (verifyFn) {
|
|
351
|
+
return (await verifyFn(code)) ? { result: { mode: 'totp', result: code } } : { error: t('Wrong or expired OTP code') };
|
|
352
|
+
}
|
|
353
|
+
const response = await callAdminForthApi({ method: 'POST', path: '/plugin/twofa/verify', body: { code } });
|
|
354
|
+
return response?.ok
|
|
355
|
+
? { result: { mode: 'verifiedTotp', result: response.verifiedCodeToken } }
|
|
356
|
+
: { error: response?.error ?? t('Wrong or expired OTP code') };
|
|
357
|
+
}
|
|
358
|
+
|
|
349
359
|
function onCancel() {
|
|
350
360
|
modelShow.value = false;
|
|
361
|
+
codeError.value = "";
|
|
351
362
|
bindValue.value = '';
|
|
352
363
|
confirmationResult.value?.clearInput();
|
|
353
364
|
removeEventListenerForOTPInput();
|
|
@@ -130,7 +130,7 @@
|
|
|
130
130
|
import { Dialog, Tooltip, Spinner, Input } from '@/afcl';
|
|
131
131
|
import { IconTrashBinSolid, IconPenSolid, IconPlusOutline, IconInfoCircleOutline } from '@iconify-prerendered/vue-flowbite';
|
|
132
132
|
import { useCoreStore } from '@/stores/core';
|
|
133
|
-
import { useTwoFactorsAuth } from './use2faApi';
|
|
133
|
+
import { useTwoFactorsAuth, type TwoFaConfirmationResult } from './use2faApi';
|
|
134
134
|
|
|
135
135
|
const { t } = useI18n();
|
|
136
136
|
const coreStore = useCoreStore();
|
|
@@ -215,9 +215,7 @@
|
|
|
215
215
|
}
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
async function requestPasskeyChallenge(confirmationResult: ConfirmationResult) {
|
|
218
|
+
async function requestPasskeyChallenge(confirmationResult: TwoFaConfirmationResult) {
|
|
221
219
|
try {
|
|
222
220
|
const response = await callAdminForthApi({
|
|
223
221
|
path: '/plugin/passkeys/registrationOptions',
|
package/custom/use2faApi.ts
CHANGED
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
@on-complete="handleOnComplete"
|
|
38
38
|
/>
|
|
39
39
|
</div>
|
|
40
|
+
<p v-if="codeError" class="af-2fa-code-error text-center text-xs text-red-600 dark:text-red-400">{{ codeError }}</p>
|
|
40
41
|
<p v-if="doesUserHavePasskeys" class="af-2fa-totp-footer text-center text-xs text-gray-500 dark:text-gray-400">
|
|
41
42
|
{{$t('Having trouble?')}}
|
|
42
43
|
<button type="button" class="af-2fa-switch-to-passkey text-lightPrimary dark:text-white hover:underline cursor-pointer" @click="modalMode = 'passkey'">{{$t('Use passkey instead')}}</button>
|
|
@@ -141,7 +142,7 @@
|
|
|
141
142
|
if (props.adminUser) {
|
|
142
143
|
websocket.unsubscribeByPrefix(`/user2fa/`);
|
|
143
144
|
const user2FaTopic = `/user2fa/${props.adminUser.pk}/${getAdminForthClientId()}`;
|
|
144
|
-
websocket.subscribe(user2FaTopic, async (data: {sessionId: string}) => {
|
|
145
|
+
websocket.subscribe(user2FaTopic, async (data: {sessionId: string; actionDescription?: string}) => {
|
|
145
146
|
if (!allowAddNewSessions) {
|
|
146
147
|
alert({message: t('Some process or user tries to add new actions to confirm. Action was blocked'), variant: 'warning'});
|
|
147
148
|
return;
|
|
@@ -153,7 +154,7 @@
|
|
|
153
154
|
}
|
|
154
155
|
try {
|
|
155
156
|
isAwaiting2FAResult.value = true;
|
|
156
|
-
confirmationResult = await twoFactorsAuthApi.get2FaConfirmationResult();
|
|
157
|
+
confirmationResult = await twoFactorsAuthApi.get2FaConfirmationResult(data.actionDescription);
|
|
157
158
|
} catch (error) {
|
|
158
159
|
if (!isTwoFaCancel(error)) {
|
|
159
160
|
console.error('[AdminForth 2FA] Error during 2FA confirmation', error);
|
|
@@ -271,7 +272,8 @@
|
|
|
271
272
|
const isLoading = ref(false);
|
|
272
273
|
const isFetchingPasskey = ref(false);
|
|
273
274
|
const customDialogTitle = ref("");
|
|
274
|
-
|
|
275
|
+
const codeError = ref("");
|
|
276
|
+
|
|
275
277
|
async function usePasskeyButtonClick() {
|
|
276
278
|
isFetchingPasskey.value = true;
|
|
277
279
|
let passkeyData;
|
|
@@ -322,32 +324,41 @@
|
|
|
322
324
|
|
|
323
325
|
async function sendConfirmationResult(value: string) {
|
|
324
326
|
if (!resolveFn) throw new Error(t('Modal is not initialized properly'));
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
327
|
+
let checked: { error: string } | { result: TwoFaConfirmationResult };
|
|
328
|
+
try {
|
|
329
|
+
checked = await checkCode(value);
|
|
330
|
+
} catch (err) {
|
|
331
|
+
rejectFn?.(err);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if ('error' in checked) {
|
|
335
|
+
codeError.value = checked.error;
|
|
336
|
+
bindValue.value = '';
|
|
337
|
+
confirmationResult.value?.clearInput();
|
|
338
|
+
return;
|
|
336
339
|
}
|
|
337
340
|
|
|
338
341
|
modelShow.value = false;
|
|
339
|
-
|
|
340
|
-
mode: "totp",
|
|
341
|
-
result: value
|
|
342
|
-
}
|
|
342
|
+
codeError.value = "";
|
|
343
343
|
customDialogTitle.value = "";
|
|
344
344
|
removeEventListenerForOTPInput();
|
|
345
|
-
resolveFn(
|
|
345
|
+
resolveFn(checked.result);
|
|
346
346
|
}
|
|
347
347
|
|
|
348
348
|
|
|
349
|
+
async function checkCode(code: string): Promise<{ error: string } | { result: TwoFaConfirmationResult }> {
|
|
350
|
+
if (verifyFn) {
|
|
351
|
+
return (await verifyFn(code)) ? { result: { mode: 'totp', result: code } } : { error: t('Wrong or expired OTP code') };
|
|
352
|
+
}
|
|
353
|
+
const response = await callAdminForthApi({ method: 'POST', path: '/plugin/twofa/verify', body: { code } });
|
|
354
|
+
return response?.ok
|
|
355
|
+
? { result: { mode: 'verifiedTotp', result: response.verifiedCodeToken } }
|
|
356
|
+
: { error: response?.error ?? t('Wrong or expired OTP code') };
|
|
357
|
+
}
|
|
358
|
+
|
|
349
359
|
function onCancel() {
|
|
350
360
|
modelShow.value = false;
|
|
361
|
+
codeError.value = "";
|
|
351
362
|
bindValue.value = '';
|
|
352
363
|
confirmationResult.value?.clearInput();
|
|
353
364
|
removeEventListenerForOTPInput();
|
|
@@ -130,7 +130,7 @@
|
|
|
130
130
|
import { Dialog, Tooltip, Spinner, Input } from '@/afcl';
|
|
131
131
|
import { IconTrashBinSolid, IconPenSolid, IconPlusOutline, IconInfoCircleOutline } from '@iconify-prerendered/vue-flowbite';
|
|
132
132
|
import { useCoreStore } from '@/stores/core';
|
|
133
|
-
import { useTwoFactorsAuth } from './use2faApi';
|
|
133
|
+
import { useTwoFactorsAuth, type TwoFaConfirmationResult } from './use2faApi';
|
|
134
134
|
|
|
135
135
|
const { t } = useI18n();
|
|
136
136
|
const coreStore = useCoreStore();
|
|
@@ -215,9 +215,7 @@
|
|
|
215
215
|
}
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
async function requestPasskeyChallenge(confirmationResult: ConfirmationResult) {
|
|
218
|
+
async function requestPasskeyChallenge(confirmationResult: TwoFaConfirmationResult) {
|
|
221
219
|
try {
|
|
222
220
|
const response = await callAdminForthApi({
|
|
223
221
|
path: '/plugin/passkeys/registrationOptions',
|
package/dist/custom/use2faApi.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TwoFactorErrorCode } from "../utils/errors.js";
|
|
1
2
|
export declare function createPasskeyHandlers(ctx: any): {
|
|
2
3
|
createRegistrationOptions: ({ body, adminUser, response, cookies, headers }: {
|
|
3
4
|
body: any;
|
|
@@ -41,12 +42,8 @@ export declare function createPasskeyHandlers(ctx: any): {
|
|
|
41
42
|
headers: any;
|
|
42
43
|
}) => Promise<{
|
|
43
44
|
ok: boolean;
|
|
44
|
-
code:
|
|
45
|
-
error:
|
|
46
|
-
} | {
|
|
47
|
-
ok: boolean;
|
|
48
|
-
error: any;
|
|
49
|
-
code?: undefined;
|
|
45
|
+
code: TwoFactorErrorCode;
|
|
46
|
+
error: string;
|
|
50
47
|
} | {
|
|
51
48
|
ok: boolean;
|
|
52
49
|
}>;
|
|
@@ -7,7 +7,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
7
7
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
8
|
});
|
|
9
9
|
};
|
|
10
|
-
import { HttpStatus, errorMessage, respondWithStatus } from "../utils/errors.js";
|
|
10
|
+
import { HttpStatus, TwoFactorErrorCode, errorMessage, respondWithStatus } from "../utils/errors.js";
|
|
11
11
|
export function createPasskeyHandlers(ctx) {
|
|
12
12
|
return {
|
|
13
13
|
createRegistrationOptions: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, response, cookies, headers }) {
|
|
@@ -73,12 +73,11 @@ export function createPasskeyHandlers(ctx) {
|
|
|
73
73
|
return checkResult;
|
|
74
74
|
}),
|
|
75
75
|
resolveVerifyAuto: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, response, cookies, headers }) {
|
|
76
|
-
var _b;
|
|
77
76
|
const sessionsIds = body === null || body === void 0 ? void 0 : body.sessionsIds;
|
|
78
77
|
const confirmationResult = body === null || body === void 0 ? void 0 : body.confirmationResult;
|
|
79
78
|
const idsToResolve = Array.isArray(sessionsIds) ? sessionsIds : [];
|
|
80
79
|
const resolveAllIdsAsFailed = (message, code) => {
|
|
81
|
-
const payload =
|
|
80
|
+
const payload = { ok: false, code, error: message };
|
|
82
81
|
for (const id of idsToResolve) {
|
|
83
82
|
ctx.autoVerify.resolveResponse(id, payload);
|
|
84
83
|
}
|
|
@@ -86,15 +85,15 @@ export function createPasskeyHandlers(ctx) {
|
|
|
86
85
|
};
|
|
87
86
|
try {
|
|
88
87
|
if (!idsToResolve.length || !confirmationResult) {
|
|
89
|
-
return respondWithStatus(resolveAllIdsAsFailed('Confirmation window was closed',
|
|
88
|
+
return respondWithStatus(resolveAllIdsAsFailed('Confirmation window was closed', TwoFactorErrorCode.CANCELLED), response, HttpStatus.BAD_REQUEST);
|
|
90
89
|
}
|
|
91
90
|
for (const id of idsToResolve) {
|
|
92
91
|
const validationResult = yield ctx.adminforth.auth.verify(id, 'auto2FA', false);
|
|
93
92
|
if (!validationResult) {
|
|
94
|
-
return respondWithStatus(resolveAllIdsAsFailed('Invalid session ID or confirmation result'), response, HttpStatus.FORBIDDEN);
|
|
93
|
+
return respondWithStatus(resolveAllIdsAsFailed('Invalid session ID or confirmation result', TwoFactorErrorCode.INVALID_SESSION), response, HttpStatus.FORBIDDEN);
|
|
95
94
|
}
|
|
96
95
|
if (validationResult.adminUserPk !== adminUser.pk) {
|
|
97
|
-
return respondWithStatus(resolveAllIdsAsFailed('Session does not belong to the authenticated user'), response, HttpStatus.FORBIDDEN);
|
|
96
|
+
return respondWithStatus(resolveAllIdsAsFailed('Session does not belong to the authenticated user', TwoFactorErrorCode.INVALID_SESSION), response, HttpStatus.FORBIDDEN);
|
|
98
97
|
}
|
|
99
98
|
}
|
|
100
99
|
const verificationResult = yield ctx.verifyMfaConfirmation(confirmationResult, {
|
|
@@ -106,20 +105,17 @@ export function createPasskeyHandlers(ctx) {
|
|
|
106
105
|
headers: headers,
|
|
107
106
|
}
|
|
108
107
|
});
|
|
109
|
-
if (
|
|
110
|
-
return respondWithStatus(resolveAllIdsAsFailed(
|
|
108
|
+
if ('error' in verificationResult) {
|
|
109
|
+
return respondWithStatus(resolveAllIdsAsFailed(verificationResult.error, verificationResult.code), response, HttpStatus.FORBIDDEN);
|
|
111
110
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
ctx.autoVerify.resolveResponse(id, { ok: true, passkeyConfirmed: verificationResult });
|
|
115
|
-
}
|
|
116
|
-
return { ok: true };
|
|
111
|
+
for (const id of idsToResolve) {
|
|
112
|
+
ctx.autoVerify.resolveResponse(id, { ok: true, passkeyConfirmed: verificationResult });
|
|
117
113
|
}
|
|
118
|
-
return
|
|
114
|
+
return { ok: true };
|
|
119
115
|
}
|
|
120
116
|
catch (error) {
|
|
121
117
|
console.error('[AdminForth 2FA] Error resolving automatic 2FA verification', error);
|
|
122
|
-
return respondWithStatus(resolveAllIdsAsFailed(errorMessage(error)), response, HttpStatus.INTERNAL_SERVER_ERROR);
|
|
118
|
+
return respondWithStatus(resolveAllIdsAsFailed(errorMessage(error), TwoFactorErrorCode.INTERNAL_ERROR), response, HttpStatus.INTERNAL_SERVER_ERROR);
|
|
123
119
|
}
|
|
124
120
|
}),
|
|
125
121
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AdminForthPlugin, RateLimiter } from "adminforth";
|
|
1
|
+
import { AdminForthPlugin, HttpExtra, RateLimiter } from "adminforth";
|
|
2
2
|
import type { AdminForthResource, AdminUser, IAdminForth, IHttpServer, IAdminForthAuth } from "adminforth";
|
|
3
3
|
import { PluginOptions } from "./types.js";
|
|
4
4
|
import { PasskeyService } from "./services/passkeyService.js";
|
|
@@ -6,6 +6,10 @@ import { TotpService } from "./services/totpService.js";
|
|
|
6
6
|
import { type MfaConfirmationResult, type VerifyOptions } from "./services/verifyMfaConfirmation.js";
|
|
7
7
|
import { CookieService } from "./services/cookieService.js";
|
|
8
8
|
import { UserRepository } from "./repositories/userRepository.js";
|
|
9
|
+
import { TwoFactorErrorCode } from "./utils/errors.js";
|
|
10
|
+
import type { VerificationResult } from "./utils/types.js";
|
|
11
|
+
export { TwoFactorErrorCode };
|
|
12
|
+
export type { VerificationResult };
|
|
9
13
|
export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
10
14
|
options: PluginOptions;
|
|
11
15
|
adminforth: IAdminForth;
|
|
@@ -17,6 +21,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
17
21
|
userRepository: UserRepository;
|
|
18
22
|
passkeyLoginRateLimiters: RateLimiter[];
|
|
19
23
|
private ctx;
|
|
24
|
+
private readonly mfaRequestContext;
|
|
20
25
|
constructor(options: PluginOptions);
|
|
21
26
|
instanceUniqueRepresentation(pluginOptions: any): string;
|
|
22
27
|
checkIfSkipSetupAllowSkipVerify(adminUser: AdminUser): Promise<{
|
|
@@ -25,12 +30,11 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
25
30
|
private pending;
|
|
26
31
|
private waitForResponse;
|
|
27
32
|
private resolveResponse;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}>;
|
|
33
|
+
withVerificationContext<T>(confirmationResult: MfaConfirmationResult | undefined, extra: HttpExtra, callback: () => Promise<T>): Promise<(import("./utils/types.js").VerificationFailure & {
|
|
34
|
+
actionDescription?: string;
|
|
35
|
+
}) | T>;
|
|
36
|
+
verifyAuto(adminUser: AdminUser, actionDescription?: string): Promise<any>;
|
|
37
|
+
verify(confirmationResult: MfaConfirmationResult | undefined, opts: VerifyOptions): Promise<VerificationResult>;
|
|
34
38
|
modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource): void;
|
|
35
39
|
validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource): void;
|
|
36
40
|
activate(resourceConfig: AdminForthResource, adminforth: IAdminForth): void;
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import * as AdminForthRuntime from "adminforth";
|
|
|
12
12
|
import { PasskeyService } from "./services/passkeyService.js";
|
|
13
13
|
import { TotpService } from "./services/totpService.js";
|
|
14
14
|
import { verifyMfaConfirmation } from "./services/verifyMfaConfirmation.js";
|
|
15
|
+
import { MfaRequestContext } from './services/mfaRequestContext.js';
|
|
15
16
|
import { CookieService } from "./services/cookieService.js";
|
|
16
17
|
import { UserRepository } from "./repositories/userRepository.js";
|
|
17
18
|
import { registerPasskeyEndpoints } from "./endpoints/registerPasskeyEndpoints.js";
|
|
@@ -19,12 +20,15 @@ import { registerTwoFaEndpoints } from "./endpoints/registerTwoFaEndpoints.js";
|
|
|
19
20
|
import { createPasskeyHandlers } from "./handlers/createPasskeyHandlers.js";
|
|
20
21
|
import { createTwoFaHandlers } from "./handlers/createTwoFaHandlers.js";
|
|
21
22
|
import { parsePeriod } from "./utils/parsePeriod.js";
|
|
23
|
+
import { TwoFactorErrorCode } from "./utils/errors.js";
|
|
22
24
|
import crypto from 'crypto';
|
|
25
|
+
export { TwoFactorErrorCode };
|
|
23
26
|
const getRequestWebsocketClientId = AdminForthRuntime.getRequestWebsocketClientId;
|
|
24
27
|
export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
25
28
|
constructor(options) {
|
|
26
29
|
super(options, import.meta.url);
|
|
27
30
|
this.passkeyLoginRateLimiters = [];
|
|
31
|
+
this.mfaRequestContext = new MfaRequestContext();
|
|
28
32
|
this.pending = new Map();
|
|
29
33
|
this.options = options;
|
|
30
34
|
this.shouldHaveSingleInstancePerWholeApp = () => true;
|
|
@@ -66,7 +70,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
66
70
|
};
|
|
67
71
|
timeout = setTimeout(() => {
|
|
68
72
|
cleanup();
|
|
69
|
-
resolve({ ok: false, error: 'Verification timed out' });
|
|
73
|
+
resolve({ ok: false, error: 'Verification timed out', code: TwoFactorErrorCode.TIMEOUT });
|
|
70
74
|
}, timeoutMs);
|
|
71
75
|
this.pending.set(id, (value) => {
|
|
72
76
|
cleanup();
|
|
@@ -80,16 +84,30 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
80
84
|
resolve(data);
|
|
81
85
|
}
|
|
82
86
|
}
|
|
83
|
-
|
|
87
|
+
withVerificationContext(confirmationResult, extra, callback) {
|
|
88
|
+
return this.mfaRequestContext.run(confirmationResult, extra, callback);
|
|
89
|
+
}
|
|
90
|
+
verifyAuto(adminUser, actionDescription) {
|
|
84
91
|
return __awaiter(this, void 0, void 0, function* () {
|
|
92
|
+
const contextualResult = yield this.mfaRequestContext.verify(adminUser, actionDescription, (confirmationResult, extra) => this.verify(confirmationResult, {
|
|
93
|
+
adminUser,
|
|
94
|
+
userPk: adminUser.pk,
|
|
95
|
+
extra,
|
|
96
|
+
}));
|
|
97
|
+
if (contextualResult)
|
|
98
|
+
return contextualResult;
|
|
85
99
|
const websocketClientId = getRequestWebsocketClientId === null || getRequestWebsocketClientId === void 0 ? void 0 : getRequestWebsocketClientId();
|
|
86
100
|
if (!websocketClientId) {
|
|
87
|
-
return {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
error: '2FA auto verification requires an active browser request context',
|
|
104
|
+
code: TwoFactorErrorCode.BROWSER_CONTEXT_REQUIRED,
|
|
105
|
+
};
|
|
88
106
|
}
|
|
89
107
|
const sessionId = crypto.randomUUID();
|
|
90
108
|
const jwt = this.adminforth.auth.issueJWT({ sessionId, adminUserPk: adminUser.pk }, 'auto2FA', '5m');
|
|
91
109
|
const resultPromise = this.waitForResponse(jwt, 5 * 60 * 1000);
|
|
92
|
-
this.adminforth.websocket.publish(`/user2fa/${adminUser.pk}/${websocketClientId}`, { sessionId: jwt });
|
|
110
|
+
this.adminforth.websocket.publish(`/user2fa/${adminUser.pk}/${websocketClientId}`, { sessionId: jwt, actionDescription });
|
|
93
111
|
const result = yield resultPromise;
|
|
94
112
|
this.adminforth.websocket.publish(`/user2fa/${adminUser.pk}/${websocketClientId}/resolve`, { sessionId: jwt });
|
|
95
113
|
return result;
|
|
@@ -107,7 +125,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
107
125
|
this.adminForthAuth = adminforth.auth;
|
|
108
126
|
this.userRepository = new UserRepository(this.adminforth, this.options);
|
|
109
127
|
this.cookieService = new CookieService(this.adminforth, this.options);
|
|
110
|
-
this.totpService = new TotpService(this.options, this.userRepository);
|
|
128
|
+
this.totpService = new TotpService(this.options, this.userRepository, this.adminforth.auth);
|
|
111
129
|
this.passkeyService = new PasskeyService(this.options, this.adminforth, this.userRepository, undefined, this.cookieService);
|
|
112
130
|
this.passkeyLoginRateLimiters = adminforth.config.auth.rateLimit.map((rate) => new RateLimiter(rate));
|
|
113
131
|
this.ctx = {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AdminUser, HttpExtra } from 'adminforth';
|
|
2
|
+
import type { MfaConfirmationResult } from './verifyMfaConfirmation.js';
|
|
3
|
+
import type { VerificationFailure, VerificationResult } from '../utils/types.js';
|
|
4
|
+
type ContextualVerificationFailure = VerificationFailure & {
|
|
5
|
+
actionDescription?: string;
|
|
6
|
+
};
|
|
7
|
+
/** Keeps verification within one external request, including every CRUD hook it invokes. */
|
|
8
|
+
export declare class MfaRequestContext {
|
|
9
|
+
private readonly storage;
|
|
10
|
+
run<T>(confirmationResult: MfaConfirmationResult | undefined, extra: HttpExtra, callback: () => Promise<T>): Promise<T | ContextualVerificationFailure>;
|
|
11
|
+
verify(adminUser: AdminUser, actionDescription: string | undefined, verifier: (confirmationResult: MfaConfirmationResult | undefined, extra: HttpExtra) => Promise<VerificationResult>): Promise<VerificationResult | undefined>;
|
|
12
|
+
}
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
11
|
+
class MfaVerificationError extends Error {
|
|
12
|
+
constructor(result) {
|
|
13
|
+
super(result.error);
|
|
14
|
+
this.result = result;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Keeps verification within one external request, including every CRUD hook it invokes. */
|
|
18
|
+
export class MfaRequestContext {
|
|
19
|
+
constructor() {
|
|
20
|
+
this.storage = new AsyncLocalStorage();
|
|
21
|
+
}
|
|
22
|
+
run(confirmationResult, extra, callback) {
|
|
23
|
+
return this.storage.run({ confirmationResult, extra, results: new Map() }, () => __awaiter(this, void 0, void 0, function* () {
|
|
24
|
+
try {
|
|
25
|
+
return yield callback();
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error instanceof MfaVerificationError)
|
|
29
|
+
return error.result;
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
verify(adminUser, actionDescription, verifier) {
|
|
35
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
36
|
+
const context = this.storage.getStore();
|
|
37
|
+
if (!context)
|
|
38
|
+
return undefined;
|
|
39
|
+
let result = context.results.get(adminUser.pk);
|
|
40
|
+
if (!result) {
|
|
41
|
+
result = verifier(context.confirmationResult, context.extra);
|
|
42
|
+
context.results.set(adminUser.pk, result);
|
|
43
|
+
}
|
|
44
|
+
const verified = yield result;
|
|
45
|
+
if ('error' in verified)
|
|
46
|
+
throw new MfaVerificationError(Object.assign(Object.assign({}, verified), { actionDescription }));
|
|
47
|
+
return verified;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -1,21 +1,20 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type AdminUser, type IAdminForthAuth } from "adminforth";
|
|
2
2
|
import type { PluginOptions } from "../types.js";
|
|
3
3
|
import { UserRepository } from "../repositories/userRepository.js";
|
|
4
|
+
import type { VerificationFailure, VerificationResult } from "../utils/types.js";
|
|
4
5
|
export declare class TotpService {
|
|
5
6
|
private readonly options;
|
|
6
7
|
private readonly userRepository;
|
|
7
|
-
|
|
8
|
+
private readonly auth;
|
|
9
|
+
private readonly codeAttemptLimiters;
|
|
10
|
+
constructor(options: PluginOptions, userRepository: UserRepository, auth: IAdminForthAuth);
|
|
8
11
|
generateSecret(issuerName: string, userName: string): string;
|
|
9
12
|
verifySecret(secret: string, code: string): boolean;
|
|
10
|
-
verifyUserCode(userPk: string, code: string): Promise<
|
|
11
|
-
ok: true;
|
|
12
|
-
} | {
|
|
13
|
-
error: string;
|
|
14
|
-
}>;
|
|
13
|
+
verifyUserCode(userPk: string, code: string): Promise<VerificationResult>;
|
|
15
14
|
verifyAdminUserCode(adminUser: AdminUser, code: string): Promise<{
|
|
16
15
|
ok: true;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
verifiedCodeToken: string;
|
|
17
|
+
} | VerificationFailure>;
|
|
18
|
+
isVerifiedCodeToken(token: string, userPk: string): Promise<boolean>;
|
|
20
19
|
saveSecret(userPk: string, secret: string): Promise<void>;
|
|
21
20
|
}
|
|
@@ -7,11 +7,18 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
7
7
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
8
|
});
|
|
9
9
|
};
|
|
10
|
+
import { RateLimiter } from "adminforth";
|
|
10
11
|
import twofactor from 'node-2fa';
|
|
12
|
+
import { TwoFactorErrorCode } from "../utils/errors.js";
|
|
13
|
+
const CODE_ATTEMPT_RATE_LIMITS = ['30/1m', '300/1h', '1000/1d'];
|
|
14
|
+
const VERIFIED_CODE_JWT_TYPE = 'verifiedTotpCode';
|
|
15
|
+
const VERIFIED_CODE_TOKEN_TTL = '1m';
|
|
11
16
|
export class TotpService {
|
|
12
|
-
constructor(options, userRepository) {
|
|
17
|
+
constructor(options, userRepository, auth) {
|
|
13
18
|
this.options = options;
|
|
14
19
|
this.userRepository = userRepository;
|
|
20
|
+
this.auth = auth;
|
|
21
|
+
this.codeAttemptLimiters = CODE_ATTEMPT_RATE_LIMITS.map((rate) => new RateLimiter(rate));
|
|
15
22
|
}
|
|
16
23
|
generateSecret(issuerName, userName) {
|
|
17
24
|
return twofactor.generateSecret({ name: issuerName, account: userName }).secret;
|
|
@@ -21,18 +28,33 @@ export class TotpService {
|
|
|
21
28
|
}
|
|
22
29
|
verifyUserCode(userPk, code) {
|
|
23
30
|
return __awaiter(this, void 0, void 0, function* () {
|
|
31
|
+
const attemptsAllowed = yield Promise.all(this.codeAttemptLimiters.map((limiter) => limiter.consume(userPk)));
|
|
32
|
+
if (!attemptsAllowed.every(Boolean)) {
|
|
33
|
+
return { error: "Too many attempts to enter the code. Try again later.", code: TwoFactorErrorCode.RATE_LIMITED };
|
|
34
|
+
}
|
|
24
35
|
const user = yield this.userRepository.getAuthUser(userPk);
|
|
25
36
|
if (!user)
|
|
26
|
-
return { error: "User not found" };
|
|
37
|
+
return { error: "User not found", code: TwoFactorErrorCode.USER_NOT_FOUND };
|
|
27
38
|
const secret = this.userRepository.getSecret(user);
|
|
28
39
|
if (!secret)
|
|
29
|
-
return { error: "2FA is not set up for this user" };
|
|
30
|
-
return this.verifySecret(secret, code)
|
|
40
|
+
return { error: "2FA is not set up for this user", code: TwoFactorErrorCode.NOT_SET_UP };
|
|
41
|
+
return this.verifySecret(secret, code)
|
|
42
|
+
? { ok: true }
|
|
43
|
+
: { error: "Wrong or expired OTP code", code: TwoFactorErrorCode.INVALID };
|
|
31
44
|
});
|
|
32
45
|
}
|
|
33
46
|
verifyAdminUserCode(adminUser, code) {
|
|
34
47
|
return __awaiter(this, void 0, void 0, function* () {
|
|
35
|
-
|
|
48
|
+
const result = yield this.verifyUserCode(this.userRepository.getUserPk(adminUser), code);
|
|
49
|
+
if ('error' in result)
|
|
50
|
+
return result;
|
|
51
|
+
return { ok: true, verifiedCodeToken: this.auth.issueJWT({ pk: adminUser.pk }, VERIFIED_CODE_JWT_TYPE, VERIFIED_CODE_TOKEN_TTL) };
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
isVerifiedCodeToken(token, userPk) {
|
|
55
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
56
|
+
const payload = yield this.auth.verify(token, VERIFIED_CODE_JWT_TYPE, false);
|
|
57
|
+
return (payload === null || payload === void 0 ? void 0 : payload.pk) === userPk;
|
|
36
58
|
});
|
|
37
59
|
}
|
|
38
60
|
saveSecret(userPk, secret) {
|