@adminforth/two-factors-auth 4.4.9 → 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/endpoints/registerPasskeyEndpoints.js +24 -0
- package/dist/endpoints/registerTwoFaEndpoints.js +18 -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/endpoints/registerPasskeyEndpoints.ts +24 -0
- package/endpoints/registerTwoFaEndpoints.ts +18 -0
- package/handlers/createPasskeyHandlers.ts +30 -16
- package/index.ts +28 -7
- package/package.json +3 -3
- 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,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) {
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { AdminUser, HttpExtra, IAdminForthHttpResponse } from "adminforth";
|
|
2
|
-
import type { CookieList } from "../utils/types.js";
|
|
2
|
+
import type { CookieList, VerificationResult } from "../utils/types.js";
|
|
3
3
|
type TotpConfirmationResult = {
|
|
4
4
|
mode: "totp";
|
|
5
5
|
result: string;
|
|
6
6
|
};
|
|
7
|
+
type VerifiedTotpConfirmationResult = {
|
|
8
|
+
mode: "verifiedTotp";
|
|
9
|
+
result: string;
|
|
10
|
+
};
|
|
7
11
|
type PasskeyConfirmationResult = {
|
|
8
12
|
mode: "passkey";
|
|
9
13
|
result: {
|
|
@@ -14,7 +18,7 @@ type PasskeyConfirmationResult = {
|
|
|
14
18
|
type GraceConfirmationResult = {
|
|
15
19
|
mode?: undefined;
|
|
16
20
|
};
|
|
17
|
-
export type MfaConfirmationResult = TotpConfirmationResult | PasskeyConfirmationResult | GraceConfirmationResult;
|
|
21
|
+
export type MfaConfirmationResult = TotpConfirmationResult | VerifiedTotpConfirmationResult | PasskeyConfirmationResult | GraceConfirmationResult;
|
|
18
22
|
export type VerifyOptions = {
|
|
19
23
|
adminUser: AdminUser;
|
|
20
24
|
userPk: string;
|
|
@@ -22,9 +26,5 @@ export type VerifyOptions = {
|
|
|
22
26
|
response?: IAdminForthHttpResponse;
|
|
23
27
|
extra?: HttpExtra;
|
|
24
28
|
};
|
|
25
|
-
export declare function verifyMfaConfirmation(ctx: any, confirmationResult: MfaConfirmationResult, opts: VerifyOptions): Promise<
|
|
26
|
-
ok: true;
|
|
27
|
-
} | {
|
|
28
|
-
error: string;
|
|
29
|
-
}>;
|
|
29
|
+
export declare function verifyMfaConfirmation(ctx: any, confirmationResult: MfaConfirmationResult | undefined, opts: VerifyOptions): Promise<VerificationResult>;
|
|
30
30
|
export {};
|
|
@@ -7,11 +7,11 @@ 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 { TwoFactorErrorCode } from "../utils/errors.js";
|
|
10
11
|
export function verifyMfaConfirmation(ctx, confirmationResult, opts) {
|
|
11
12
|
return __awaiter(this, void 0, void 0, function* () {
|
|
12
|
-
var _a, _b, _c
|
|
13
|
-
|
|
14
|
-
return { error: "Confirmation result is required" };
|
|
13
|
+
var _a, _b, _c;
|
|
14
|
+
const mode = confirmationResult === null || confirmationResult === void 0 ? void 0 : confirmationResult.mode;
|
|
15
15
|
const cookies = ((_a = opts.extra) === null || _a === void 0 ? void 0 : _a.cookies) || opts.cookies;
|
|
16
16
|
const response = ((_b = opts.extra) === null || _b === void 0 ? void 0 : _b.response) || opts.response;
|
|
17
17
|
if (ctx.options.usersFilterToApply) {
|
|
@@ -26,42 +26,45 @@ export function verifyMfaConfirmation(ctx, confirmationResult, opts) {
|
|
|
26
26
|
return { ok: true };
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
-
if (ctx.options.stepUpMfaGracePeriodSeconds && ((_c = opts.extra) === null || _c === void 0 ? void 0 : _c.headers) && !
|
|
29
|
+
if (ctx.options.stepUpMfaGracePeriodSeconds && ((_c = opts.extra) === null || _c === void 0 ? void 0 : _c.headers) && !mode) {
|
|
30
30
|
const verificationResult = yield ctx.cookieService.isMfaGraceValid(opts.extra.headers, cookies);
|
|
31
31
|
if (verificationResult === true) {
|
|
32
32
|
return { ok: true };
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
-
const mode = confirmationResult.mode;
|
|
36
35
|
if (!mode) {
|
|
37
|
-
return { error: "
|
|
36
|
+
return { error: "Confirmation result is required", code: TwoFactorErrorCode.REQUIRED };
|
|
38
37
|
}
|
|
38
|
+
const confirmed = () => {
|
|
39
|
+
var _a;
|
|
40
|
+
if (ctx.options.stepUpMfaGracePeriodSeconds) {
|
|
41
|
+
ctx.cookieService.issueMfaGrace({ headers: (_a = opts.extra) === null || _a === void 0 ? void 0 : _a.headers, response }, cookies);
|
|
42
|
+
}
|
|
43
|
+
return { ok: true };
|
|
44
|
+
};
|
|
39
45
|
if (mode === "totp") {
|
|
40
46
|
const { result: code } = confirmationResult;
|
|
41
47
|
const verificationResult = yield ctx.totpService.verifyUserCode(opts.userPk, code);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
return 'error' in verificationResult ? verificationResult : confirmed();
|
|
49
|
+
}
|
|
50
|
+
else if (mode === "verifiedTotp") {
|
|
51
|
+
const { result: token } = confirmationResult;
|
|
52
|
+
return (yield ctx.totpService.isVerifiedCodeToken(token, opts.userPk))
|
|
53
|
+
? confirmed()
|
|
54
|
+
: { error: "The code confirmation expired. Enter the code again.", code: TwoFactorErrorCode.INVALID };
|
|
49
55
|
}
|
|
50
56
|
else if (mode === "passkey") {
|
|
51
57
|
const { result } = confirmationResult;
|
|
52
58
|
const cookiesValidationResult = yield ctx.passkeyService.validateLoginChallengeCookie(cookies);
|
|
53
59
|
if (!cookiesValidationResult.ok) {
|
|
54
|
-
return { error: cookiesValidationResult.error };
|
|
60
|
+
return { error: cookiesValidationResult.error, code: TwoFactorErrorCode.INVALID };
|
|
55
61
|
}
|
|
56
62
|
const verificationResult = yield ctx.passkeyService.verifyPasskeyResponse(result, opts.userPk, cookiesValidationResult.decodedPasskeysCookies);
|
|
57
63
|
if (verificationResult.ok && verificationResult.passkeyConfirmed) {
|
|
58
|
-
|
|
59
|
-
ctx.cookieService.issueMfaGrace({ headers: (_e = opts.extra) === null || _e === void 0 ? void 0 : _e.headers, response }, cookies);
|
|
60
|
-
}
|
|
61
|
-
return { ok: true };
|
|
64
|
+
return confirmed();
|
|
62
65
|
}
|
|
63
|
-
return { error: "Invalid passkey" };
|
|
66
|
+
return { error: "Invalid passkey", code: TwoFactorErrorCode.INVALID };
|
|
64
67
|
}
|
|
65
|
-
return { error: "Unsupported confirmation mode" };
|
|
68
|
+
return { error: "Unsupported confirmation mode", code: TwoFactorErrorCode.UNSUPPORTED_MODE };
|
|
66
69
|
});
|
|
67
70
|
}
|
package/dist/utils/errors.d.ts
CHANGED
|
@@ -7,6 +7,19 @@ export declare enum HttpStatus {
|
|
|
7
7
|
TOO_MANY_REQUESTS = 429,
|
|
8
8
|
INTERNAL_SERVER_ERROR = 500
|
|
9
9
|
}
|
|
10
|
+
export declare enum TwoFactorErrorCode {
|
|
11
|
+
REQUIRED = "TWO_FACTOR_REQUIRED",
|
|
12
|
+
INVALID = "TWO_FACTOR_INVALID",
|
|
13
|
+
RATE_LIMITED = "TWO_FACTOR_RATE_LIMITED",
|
|
14
|
+
NOT_SET_UP = "TWO_FACTOR_NOT_SET_UP",
|
|
15
|
+
USER_NOT_FOUND = "USER_NOT_FOUND",
|
|
16
|
+
UNSUPPORTED_MODE = "UNSUPPORTED_CONFIRMATION_MODE",
|
|
17
|
+
BROWSER_CONTEXT_REQUIRED = "BROWSER_CONTEXT_REQUIRED",
|
|
18
|
+
CANCELLED = "VERIFICATION_CANCELLED",
|
|
19
|
+
TIMEOUT = "VERIFICATION_TIMEOUT",
|
|
20
|
+
INVALID_SESSION = "INVALID_VERIFICATION_SESSION",
|
|
21
|
+
INTERNAL_ERROR = "INTERNAL_ERROR"
|
|
22
|
+
}
|
|
10
23
|
export declare function respondWithStatus<T>(body: T, response: IAdminForthHttpResponse, status: HttpStatus): T;
|
|
11
24
|
export declare function errorMessage(error: unknown): string;
|
|
12
25
|
export declare function errorResult(message: string): ErrorResponse;
|
package/dist/utils/errors.js
CHANGED
|
@@ -6,6 +6,20 @@ export var HttpStatus;
|
|
|
6
6
|
HttpStatus[HttpStatus["TOO_MANY_REQUESTS"] = 429] = "TOO_MANY_REQUESTS";
|
|
7
7
|
HttpStatus[HttpStatus["INTERNAL_SERVER_ERROR"] = 500] = "INTERNAL_SERVER_ERROR";
|
|
8
8
|
})(HttpStatus || (HttpStatus = {}));
|
|
9
|
+
export var TwoFactorErrorCode;
|
|
10
|
+
(function (TwoFactorErrorCode) {
|
|
11
|
+
TwoFactorErrorCode["REQUIRED"] = "TWO_FACTOR_REQUIRED";
|
|
12
|
+
TwoFactorErrorCode["INVALID"] = "TWO_FACTOR_INVALID";
|
|
13
|
+
TwoFactorErrorCode["RATE_LIMITED"] = "TWO_FACTOR_RATE_LIMITED";
|
|
14
|
+
TwoFactorErrorCode["NOT_SET_UP"] = "TWO_FACTOR_NOT_SET_UP";
|
|
15
|
+
TwoFactorErrorCode["USER_NOT_FOUND"] = "USER_NOT_FOUND";
|
|
16
|
+
TwoFactorErrorCode["UNSUPPORTED_MODE"] = "UNSUPPORTED_CONFIRMATION_MODE";
|
|
17
|
+
TwoFactorErrorCode["BROWSER_CONTEXT_REQUIRED"] = "BROWSER_CONTEXT_REQUIRED";
|
|
18
|
+
TwoFactorErrorCode["CANCELLED"] = "VERIFICATION_CANCELLED";
|
|
19
|
+
TwoFactorErrorCode["TIMEOUT"] = "VERIFICATION_TIMEOUT";
|
|
20
|
+
TwoFactorErrorCode["INVALID_SESSION"] = "INVALID_VERIFICATION_SESSION";
|
|
21
|
+
TwoFactorErrorCode["INTERNAL_ERROR"] = "INTERNAL_ERROR";
|
|
22
|
+
})(TwoFactorErrorCode || (TwoFactorErrorCode = {}));
|
|
9
23
|
export function respondWithStatus(body, response, status) {
|
|
10
24
|
response.setStatus(status);
|
|
11
25
|
return body;
|
package/dist/utils/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { HttpExtra } from "adminforth";
|
|
2
|
+
import type { TwoFactorErrorCode } from "./errors.js";
|
|
2
3
|
export type AnyRecord = Record<string, any>;
|
|
3
4
|
export type CookieList = HttpExtra["cookies"];
|
|
4
5
|
export type HttpHeaders = HttpExtra["headers"];
|
|
@@ -11,3 +12,10 @@ export type OkResponse<T extends AnyRecord = AnyRecord> = {
|
|
|
11
12
|
ok: true;
|
|
12
13
|
} & T;
|
|
13
14
|
export type PluginResponse<T extends AnyRecord = AnyRecord> = OkResponse<T> | ErrorResponse;
|
|
15
|
+
export type VerificationFailure = {
|
|
16
|
+
error: string;
|
|
17
|
+
code: TwoFactorErrorCode;
|
|
18
|
+
};
|
|
19
|
+
export type VerificationResult = {
|
|
20
|
+
ok: true;
|
|
21
|
+
} | VerificationFailure;
|
|
@@ -11,6 +11,9 @@ export function registerPasskeyEndpoints(server: IHttpServer, handlers: any): vo
|
|
|
11
11
|
server.endpoint({
|
|
12
12
|
method: 'POST',
|
|
13
13
|
path: `/plugin/passkeys/registrationOptions`,
|
|
14
|
+
agent: {
|
|
15
|
+
hiddenFromAgents: true,
|
|
16
|
+
},
|
|
14
17
|
noAuth: false,
|
|
15
18
|
request_schema: registrationOptionsBodySchema,
|
|
16
19
|
handler: async ({ body, adminUser, response, cookies, headers }) => {
|
|
@@ -21,6 +24,9 @@ export function registerPasskeyEndpoints(server: IHttpServer, handlers: any): vo
|
|
|
21
24
|
server.endpoint({
|
|
22
25
|
method: 'POST',
|
|
23
26
|
path: `/plugin/passkeys/finishRegistration`,
|
|
27
|
+
agent: {
|
|
28
|
+
hiddenFromAgents: true,
|
|
29
|
+
},
|
|
24
30
|
noAuth: false,
|
|
25
31
|
request_schema: finishRegistrationBodySchema,
|
|
26
32
|
handler: async ({ body, adminUser, cookies, response }) => {
|
|
@@ -31,6 +37,9 @@ export function registerPasskeyEndpoints(server: IHttpServer, handlers: any): vo
|
|
|
31
37
|
server.endpoint({
|
|
32
38
|
method: 'POST',
|
|
33
39
|
path: `/plugin/passkeys/loginOptions`,
|
|
40
|
+
agent: {
|
|
41
|
+
hiddenFromAgents: true,
|
|
42
|
+
},
|
|
34
43
|
noAuth: true,
|
|
35
44
|
handler: async ({ response, headers }) => handlers.createLoginOptions({ response, headers }),
|
|
36
45
|
});
|
|
@@ -38,6 +47,9 @@ export function registerPasskeyEndpoints(server: IHttpServer, handlers: any): vo
|
|
|
38
47
|
server.endpoint({
|
|
39
48
|
method: 'GET',
|
|
40
49
|
path: `/plugin/passkeys/getPasskeys`,
|
|
50
|
+
agent: {
|
|
51
|
+
hiddenFromAgents: true,
|
|
52
|
+
},
|
|
41
53
|
noAuth: false,
|
|
42
54
|
handler: async ({ adminUser, response }) => handlers.getPasskeys({ adminUser, response }),
|
|
43
55
|
});
|
|
@@ -45,6 +57,9 @@ export function registerPasskeyEndpoints(server: IHttpServer, handlers: any): vo
|
|
|
45
57
|
server.endpoint({
|
|
46
58
|
method: 'DELETE',
|
|
47
59
|
path: `/plugin/passkeys/deletePasskey`,
|
|
60
|
+
agent: {
|
|
61
|
+
hiddenFromAgents: true,
|
|
62
|
+
},
|
|
48
63
|
noAuth: false,
|
|
49
64
|
request_schema: deletePasskeyBodySchema,
|
|
50
65
|
handler: async ({ body, adminUser, response }) => {
|
|
@@ -55,6 +70,9 @@ export function registerPasskeyEndpoints(server: IHttpServer, handlers: any): vo
|
|
|
55
70
|
server.endpoint({
|
|
56
71
|
method: 'POST',
|
|
57
72
|
path: `/plugin/passkeys/renamePasskey`,
|
|
73
|
+
agent: {
|
|
74
|
+
hiddenFromAgents: true,
|
|
75
|
+
},
|
|
58
76
|
noAuth: false,
|
|
59
77
|
request_schema: renamePasskeyBodySchema,
|
|
60
78
|
handler: async ({ body, adminUser, response }) => {
|
|
@@ -65,6 +83,9 @@ export function registerPasskeyEndpoints(server: IHttpServer, handlers: any): vo
|
|
|
65
83
|
server.endpoint({
|
|
66
84
|
method: 'POST',
|
|
67
85
|
path: `/plugin/passkeys/checkIfUserHasPasskeys`,
|
|
86
|
+
agent: {
|
|
87
|
+
hiddenFromAgents: true,
|
|
88
|
+
},
|
|
68
89
|
noAuth: true,
|
|
69
90
|
handler: async ({ cookies, response }) => handlers.checkIfUserHasPasskeys({ cookies, response }),
|
|
70
91
|
});
|
|
@@ -72,6 +93,9 @@ export function registerPasskeyEndpoints(server: IHttpServer, handlers: any): vo
|
|
|
72
93
|
server.endpoint({
|
|
73
94
|
method: 'POST',
|
|
74
95
|
path: `/plugin/passkeys/resolveVerifyAuto`,
|
|
96
|
+
agent: {
|
|
97
|
+
hiddenFromAgents: true,
|
|
98
|
+
},
|
|
75
99
|
noAuth: false,
|
|
76
100
|
request_schema: resolveVerifyAutoBodySchema,
|
|
77
101
|
handler: async ({ body, adminUser, response, cookies, headers }) => {
|
|
@@ -9,6 +9,9 @@ export function registerTwoFaEndpoints(server: IHttpServer, handlers: any): void
|
|
|
9
9
|
server.endpoint({
|
|
10
10
|
method: 'POST',
|
|
11
11
|
path: `/plugin/twofa/initSetup`,
|
|
12
|
+
agent: {
|
|
13
|
+
hiddenFromAgents: true,
|
|
14
|
+
},
|
|
12
15
|
noAuth: true,
|
|
13
16
|
handler: async ({ cookies, response }) => handlers.initSetup({ cookies, response }),
|
|
14
17
|
});
|
|
@@ -16,6 +19,9 @@ export function registerTwoFaEndpoints(server: IHttpServer, handlers: any): void
|
|
|
16
19
|
server.endpoint({
|
|
17
20
|
method: 'POST',
|
|
18
21
|
path: `/plugin/twofa/confirmLogin`,
|
|
22
|
+
agent: {
|
|
23
|
+
hiddenFromAgents: true,
|
|
24
|
+
},
|
|
19
25
|
noAuth: true,
|
|
20
26
|
request_schema: confirmLoginBodySchema,
|
|
21
27
|
handler: async ({ body, response, cookies, headers, requestUrl, query }) => {
|
|
@@ -26,6 +32,9 @@ export function registerTwoFaEndpoints(server: IHttpServer, handlers: any): void
|
|
|
26
32
|
server.endpoint({
|
|
27
33
|
method: 'POST',
|
|
28
34
|
path: `/plugin/twofa/confirmLoginWithPasskey`,
|
|
35
|
+
agent: {
|
|
36
|
+
hiddenFromAgents: true,
|
|
37
|
+
},
|
|
29
38
|
noAuth: true,
|
|
30
39
|
request_schema: confirmLoginWithPasskeyBodySchema,
|
|
31
40
|
handler: async ({ body, response, cookies, headers, requestUrl, query }) => {
|
|
@@ -36,6 +45,9 @@ export function registerTwoFaEndpoints(server: IHttpServer, handlers: any): void
|
|
|
36
45
|
server.endpoint({
|
|
37
46
|
method: "GET",
|
|
38
47
|
path: "/plugin/twofa/skip-allow",
|
|
48
|
+
agent: {
|
|
49
|
+
hiddenFromAgents: true,
|
|
50
|
+
},
|
|
39
51
|
noAuth: true,
|
|
40
52
|
handler: async ({ cookies, response }) => handlers.skipAllow({ cookies, response }),
|
|
41
53
|
});
|
|
@@ -43,12 +55,18 @@ export function registerTwoFaEndpoints(server: IHttpServer, handlers: any): void
|
|
|
43
55
|
server.endpoint({
|
|
44
56
|
method: "GET",
|
|
45
57
|
path: "/plugin/twofa/skip-allow-modal",
|
|
58
|
+
agent: {
|
|
59
|
+
hiddenFromAgents: true,
|
|
60
|
+
},
|
|
46
61
|
handler: async ({ adminUser, headers, cookies, response }) => handlers.skipAllowModal({ adminUser, headers, cookies, response }),
|
|
47
62
|
});
|
|
48
63
|
|
|
49
64
|
server.endpoint({
|
|
50
65
|
method: 'POST',
|
|
51
66
|
path: `/plugin/twofa/verify`,
|
|
67
|
+
agent: {
|
|
68
|
+
hiddenFromAgents: true,
|
|
69
|
+
},
|
|
52
70
|
noAuth: false,
|
|
53
71
|
request_schema: verifyTotpBodySchema,
|
|
54
72
|
handler: async ({ adminUser, body, response }) => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { HttpExtra } from "adminforth";
|
|
2
|
-
import { HttpStatus, errorMessage, respondWithStatus } from "../utils/errors.js";
|
|
2
|
+
import { HttpStatus, TwoFactorErrorCode, errorMessage, respondWithStatus } from "../utils/errors.js";
|
|
3
|
+
import type { VerificationResult } from "../utils/types.js";
|
|
3
4
|
|
|
4
5
|
export function createPasskeyHandlers(ctx: any) {
|
|
5
6
|
return {
|
|
@@ -83,8 +84,8 @@ export function createPasskeyHandlers(ctx: any) {
|
|
|
83
84
|
const confirmationResult = body?.confirmationResult;
|
|
84
85
|
const idsToResolve = Array.isArray(sessionsIds) ? sessionsIds : [];
|
|
85
86
|
|
|
86
|
-
const resolveAllIdsAsFailed = (message, code
|
|
87
|
-
const payload =
|
|
87
|
+
const resolveAllIdsAsFailed = (message: string, code: TwoFactorErrorCode) => {
|
|
88
|
+
const payload = { ok: false, code, error: message };
|
|
88
89
|
for (const id of idsToResolve) {
|
|
89
90
|
ctx.autoVerify.resolveResponse(id, payload);
|
|
90
91
|
}
|
|
@@ -94,7 +95,7 @@ export function createPasskeyHandlers(ctx: any) {
|
|
|
94
95
|
try {
|
|
95
96
|
if (!idsToResolve.length || !confirmationResult) {
|
|
96
97
|
return respondWithStatus(
|
|
97
|
-
resolveAllIdsAsFailed('Confirmation window was closed',
|
|
98
|
+
resolveAllIdsAsFailed('Confirmation window was closed', TwoFactorErrorCode.CANCELLED),
|
|
98
99
|
response,
|
|
99
100
|
HttpStatus.BAD_REQUEST,
|
|
100
101
|
);
|
|
@@ -103,14 +104,22 @@ export function createPasskeyHandlers(ctx: any) {
|
|
|
103
104
|
for (const id of idsToResolve) {
|
|
104
105
|
const validationResult = await ctx.adminforth.auth.verify(id, 'auto2FA', false);
|
|
105
106
|
if (!validationResult) {
|
|
106
|
-
return respondWithStatus(
|
|
107
|
+
return respondWithStatus(
|
|
108
|
+
resolveAllIdsAsFailed('Invalid session ID or confirmation result', TwoFactorErrorCode.INVALID_SESSION),
|
|
109
|
+
response,
|
|
110
|
+
HttpStatus.FORBIDDEN,
|
|
111
|
+
);
|
|
107
112
|
}
|
|
108
113
|
if (validationResult.adminUserPk !== adminUser.pk) {
|
|
109
|
-
return respondWithStatus(
|
|
114
|
+
return respondWithStatus(
|
|
115
|
+
resolveAllIdsAsFailed('Session does not belong to the authenticated user', TwoFactorErrorCode.INVALID_SESSION),
|
|
116
|
+
response,
|
|
117
|
+
HttpStatus.FORBIDDEN,
|
|
118
|
+
);
|
|
110
119
|
}
|
|
111
120
|
}
|
|
112
121
|
|
|
113
|
-
const verificationResult = await ctx.verifyMfaConfirmation(confirmationResult, {
|
|
122
|
+
const verificationResult: VerificationResult = await ctx.verifyMfaConfirmation(confirmationResult, {
|
|
114
123
|
adminUser: adminUser,
|
|
115
124
|
userPk: adminUser.pk,
|
|
116
125
|
cookies: cookies,
|
|
@@ -119,19 +128,24 @@ export function createPasskeyHandlers(ctx: any) {
|
|
|
119
128
|
headers: headers,
|
|
120
129
|
} as HttpExtra
|
|
121
130
|
});
|
|
122
|
-
if (
|
|
123
|
-
return respondWithStatus(
|
|
131
|
+
if ('error' in verificationResult) {
|
|
132
|
+
return respondWithStatus(
|
|
133
|
+
resolveAllIdsAsFailed(verificationResult.error, verificationResult.code),
|
|
134
|
+
response,
|
|
135
|
+
HttpStatus.FORBIDDEN,
|
|
136
|
+
);
|
|
124
137
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
ctx.autoVerify.resolveResponse(id, { ok: true, passkeyConfirmed: verificationResult });
|
|
128
|
-
}
|
|
129
|
-
return { ok: true };
|
|
138
|
+
for (const id of idsToResolve) {
|
|
139
|
+
ctx.autoVerify.resolveResponse(id, { ok: true, passkeyConfirmed: verificationResult });
|
|
130
140
|
}
|
|
131
|
-
return
|
|
141
|
+
return { ok: true };
|
|
132
142
|
} catch (error) {
|
|
133
143
|
console.error('[AdminForth 2FA] Error resolving automatic 2FA verification', error);
|
|
134
|
-
return respondWithStatus(
|
|
144
|
+
return respondWithStatus(
|
|
145
|
+
resolveAllIdsAsFailed(errorMessage(error), TwoFactorErrorCode.INTERNAL_ERROR),
|
|
146
|
+
response,
|
|
147
|
+
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
148
|
+
);
|
|
135
149
|
}
|
|
136
150
|
},
|
|
137
151
|
};
|
package/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { PluginOptions } from "./types.js"
|
|
|
5
5
|
import { PasskeyService } from "./services/passkeyService.js";
|
|
6
6
|
import { TotpService } from "./services/totpService.js";
|
|
7
7
|
import { verifyMfaConfirmation, type MfaConfirmationResult, type VerifyOptions } from "./services/verifyMfaConfirmation.js";
|
|
8
|
+
import { MfaRequestContext } from './services/mfaRequestContext.js';
|
|
8
9
|
import { CookieService } from "./services/cookieService.js";
|
|
9
10
|
import { UserRepository } from "./repositories/userRepository.js";
|
|
10
11
|
import { registerPasskeyEndpoints } from "./endpoints/registerPasskeyEndpoints.js";
|
|
@@ -12,8 +13,13 @@ import { registerTwoFaEndpoints } from "./endpoints/registerTwoFaEndpoints.js";
|
|
|
12
13
|
import { createPasskeyHandlers } from "./handlers/createPasskeyHandlers.js";
|
|
13
14
|
import { createTwoFaHandlers } from "./handlers/createTwoFaHandlers.js";
|
|
14
15
|
import { parsePeriod } from "./utils/parsePeriod.js";
|
|
16
|
+
import { TwoFactorErrorCode } from "./utils/errors.js";
|
|
17
|
+
import type { VerificationResult } from "./utils/types.js";
|
|
15
18
|
import crypto from 'crypto';
|
|
16
19
|
|
|
20
|
+
export { TwoFactorErrorCode };
|
|
21
|
+
export type { VerificationResult };
|
|
22
|
+
|
|
17
23
|
const getRequestWebsocketClientId = (AdminForthRuntime as typeof AdminForthRuntime & {
|
|
18
24
|
getRequestWebsocketClientId?: () => string | undefined;
|
|
19
25
|
}).getRequestWebsocketClientId;
|
|
@@ -29,6 +35,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
29
35
|
userRepository: UserRepository;
|
|
30
36
|
passkeyLoginRateLimiters: RateLimiter[] = [];
|
|
31
37
|
private ctx: any;
|
|
38
|
+
private readonly mfaRequestContext = new MfaRequestContext();
|
|
32
39
|
|
|
33
40
|
constructor(options: PluginOptions) {
|
|
34
41
|
super(options, import.meta.url);
|
|
@@ -78,7 +85,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
78
85
|
};
|
|
79
86
|
timeout = setTimeout(() => {
|
|
80
87
|
cleanup();
|
|
81
|
-
resolve({ ok: false, error: 'Verification timed out' });
|
|
88
|
+
resolve({ ok: false, error: 'Verification timed out', code: TwoFactorErrorCode.TIMEOUT });
|
|
82
89
|
}, timeoutMs);
|
|
83
90
|
this.pending.set(id, (value: any) => {
|
|
84
91
|
cleanup();
|
|
@@ -94,16 +101,30 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
94
101
|
}
|
|
95
102
|
}
|
|
96
103
|
|
|
97
|
-
public
|
|
104
|
+
public withVerificationContext<T>(confirmationResult: MfaConfirmationResult | undefined, extra: HttpExtra, callback: () => Promise<T>) {
|
|
105
|
+
return this.mfaRequestContext.run(confirmationResult, extra, callback);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
public async verifyAuto(adminUser: AdminUser, actionDescription?: string) {
|
|
109
|
+
const contextualResult = await this.mfaRequestContext.verify(adminUser, actionDescription, (confirmationResult, extra) => this.verify(confirmationResult, {
|
|
110
|
+
adminUser,
|
|
111
|
+
userPk: adminUser.pk!,
|
|
112
|
+
extra,
|
|
113
|
+
}));
|
|
114
|
+
if (contextualResult) return contextualResult;
|
|
98
115
|
const websocketClientId = getRequestWebsocketClientId?.();
|
|
99
116
|
if (!websocketClientId) {
|
|
100
|
-
return {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
error: '2FA auto verification requires an active browser request context',
|
|
120
|
+
code: TwoFactorErrorCode.BROWSER_CONTEXT_REQUIRED,
|
|
121
|
+
};
|
|
101
122
|
}
|
|
102
123
|
const sessionId = crypto.randomUUID();
|
|
103
124
|
const jwt = this.adminforth.auth.issueJWT({sessionId, adminUserPk: adminUser.pk}, 'auto2FA', '5m');
|
|
104
125
|
const resultPromise = this.waitForResponse(jwt, 5 * 60 * 1000);
|
|
105
126
|
|
|
106
|
-
this.adminforth.websocket.publish(`/user2fa/${adminUser.pk}/${websocketClientId}`, { sessionId: jwt });
|
|
127
|
+
this.adminforth.websocket.publish(`/user2fa/${adminUser.pk}/${websocketClientId}`, { sessionId: jwt, actionDescription });
|
|
107
128
|
|
|
108
129
|
const result = await resultPromise;
|
|
109
130
|
|
|
@@ -113,9 +134,9 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
113
134
|
}
|
|
114
135
|
|
|
115
136
|
public async verify(
|
|
116
|
-
confirmationResult: MfaConfirmationResult,
|
|
137
|
+
confirmationResult: MfaConfirmationResult | undefined,
|
|
117
138
|
opts: VerifyOptions
|
|
118
|
-
): Promise<
|
|
139
|
+
): Promise<VerificationResult> {
|
|
119
140
|
return verifyMfaConfirmation(this.ctx, confirmationResult, opts);
|
|
120
141
|
}
|
|
121
142
|
|
|
@@ -125,7 +146,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
125
146
|
this.adminForthAuth = adminforth.auth;
|
|
126
147
|
this.userRepository = new UserRepository(this.adminforth, this.options);
|
|
127
148
|
this.cookieService = new CookieService(this.adminforth, this.options);
|
|
128
|
-
this.totpService = new TotpService(this.options, this.userRepository);
|
|
149
|
+
this.totpService = new TotpService(this.options, this.userRepository, this.adminforth.auth);
|
|
129
150
|
this.passkeyService = new PasskeyService(this.options, this.adminforth, this.userRepository, undefined, this.cookieService);
|
|
130
151
|
this.passkeyLoginRateLimiters = (adminforth.config.auth as any).rateLimit.map((rate) => new RateLimiter(rate));
|
|
131
152
|
this.ctx = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adminforth/two-factors-auth",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.6.0",
|
|
4
4
|
"description": "AdminForth plugin for two factors authentication",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -29,11 +29,11 @@
|
|
|
29
29
|
"zod": "^4.3.6"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
|
-
"adminforth": "^3.
|
|
32
|
+
"adminforth": "^3.32.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/node": "^22.10.7",
|
|
36
|
-
"adminforth": "^3.
|
|
36
|
+
"adminforth": "^3.32.0",
|
|
37
37
|
"semantic-release": "^24.2.1",
|
|
38
38
|
"semantic-release-slack-bot": "^4.0.2",
|
|
39
39
|
"typescript": "^5.7.3"
|