@adminforth/two-factors-auth 3.3.9 → 4.0.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 +4 -4
- package/custom/TwoFactorsConfirmation.vue +9 -10
- package/custom/TwoFactorsPasskeysSettings.vue +10 -10
- package/custom/utils.js +7 -8
- package/dist/custom/TwoFAModal.vue +4 -4
- package/dist/custom/TwoFactorsConfirmation.vue +9 -10
- package/dist/custom/TwoFactorsPasskeysSettings.vue +10 -10
- package/dist/custom/utils.js +7 -8
- package/dist/endpoints/registerPasskeyEndpoints.d.ts +2 -0
- package/dist/endpoints/registerPasskeyEndpoints.js +59 -0
- package/dist/endpoints/registerTwoFaEndpoints.d.ts +2 -0
- package/dist/endpoints/registerTwoFaEndpoints.js +46 -0
- package/dist/handlers/createPasskeyHandlers.d.ts +48 -0
- package/dist/handlers/createPasskeyHandlers.js +99 -0
- package/dist/handlers/createTwoFaHandlers.d.ts +56 -0
- package/dist/handlers/createTwoFaHandlers.js +167 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +51 -858
- package/dist/repositories/passkeyRepository.d.ts +18 -0
- package/dist/repositories/passkeyRepository.js +89 -0
- package/dist/repositories/userRepository.d.ts +14 -0
- package/dist/repositories/userRepository.js +45 -0
- package/dist/services/cookieService.d.ts +52 -0
- package/dist/services/cookieService.js +144 -0
- package/dist/services/passkeyService.d.ts +84 -0
- package/dist/services/passkeyService.js +369 -0
- package/dist/services/totpService.d.ts +21 -0
- package/dist/services/totpService.js +43 -0
- package/dist/services/verifyMfaConfirmation.d.ts +30 -0
- package/dist/services/verifyMfaConfirmation.js +67 -0
- package/dist/types.d.ts +116 -0
- package/dist/utils/errors.d.ts +4 -0
- package/dist/utils/errors.js +9 -0
- package/dist/utils/parsePeriod.d.ts +1 -0
- package/dist/utils/parsePeriod.js +17 -0
- package/dist/utils/types.d.ts +13 -0
- package/dist/utils/types.js +1 -0
- package/endpoints/registerPasskeyEndpoints.ts +61 -0
- package/endpoints/registerTwoFaEndpoints.ts +45 -0
- package/handlers/createPasskeyHandlers.ts +103 -0
- package/handlers/createTwoFaHandlers.ts +172 -0
- package/index.ts +57 -886
- package/package.json +1 -1
- package/repositories/passkeyRepository.ts +78 -0
- package/repositories/userRepository.ts +42 -0
- package/services/cookieService.ts +165 -0
- package/services/passkeyService.ts +375 -0
- package/services/totpService.ts +37 -0
- package/services/verifyMfaConfirmation.ts +92 -0
- package/tsconfig.json +1 -1
- package/utils/errors.ts +13 -0
- package/utils/parsePeriod.ts +19 -0
- package/utils/types.ts +17 -0
package/index.ts
CHANGED
|
@@ -1,23 +1,28 @@
|
|
|
1
|
-
import { AdminForthPlugin,
|
|
2
|
-
import type { AdminForthResource, AdminUser, IAdminForth, IHttpServer, IAdminForthAuth,
|
|
3
|
-
import twofactor from 'node-2fa';
|
|
1
|
+
import { AdminForthPlugin, suggestIfTypo, HttpExtra } from "adminforth";
|
|
2
|
+
import type { AdminForthResource, AdminUser, IAdminForth, IHttpServer, IAdminForthAuth, IAdminForthHttpResponse } from "adminforth";
|
|
4
3
|
import { PluginOptions } from "./types.js"
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
} from
|
|
11
|
-
import {
|
|
12
|
-
import
|
|
4
|
+
import { PasskeyService } from "./services/passkeyService.js";
|
|
5
|
+
import { TotpService } from "./services/totpService.js";
|
|
6
|
+
import { verifyMfaConfirmation, type MfaConfirmationResult, type VerifyOptions } from "./services/verifyMfaConfirmation.js";
|
|
7
|
+
import { CookieService } from "./services/cookieService.js";
|
|
8
|
+
import { UserRepository } from "./repositories/userRepository.js";
|
|
9
|
+
import { registerPasskeyEndpoints } from "./endpoints/registerPasskeyEndpoints.js";
|
|
10
|
+
import { registerTwoFaEndpoints } from "./endpoints/registerTwoFaEndpoints.js";
|
|
11
|
+
import { createPasskeyHandlers } from "./handlers/createPasskeyHandlers.js";
|
|
12
|
+
import { createTwoFaHandlers } from "./handlers/createTwoFaHandlers.js";
|
|
13
|
+
import { parsePeriod } from "./utils/parsePeriod.js";
|
|
13
14
|
import crypto from 'crypto';
|
|
14
15
|
|
|
15
16
|
export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
16
17
|
options: PluginOptions;
|
|
17
18
|
adminforth: IAdminForth;
|
|
18
19
|
authResource: AdminForthResource;
|
|
19
|
-
connectors: any;
|
|
20
20
|
adminForthAuth: IAdminForthAuth;
|
|
21
|
+
passkeyService: PasskeyService;
|
|
22
|
+
totpService: TotpService;
|
|
23
|
+
cookieService: CookieService;
|
|
24
|
+
userRepository: UserRepository;
|
|
25
|
+
private ctx: any;
|
|
21
26
|
|
|
22
27
|
constructor(options: PluginOptions) {
|
|
23
28
|
super(options, import.meta.url);
|
|
@@ -29,68 +34,24 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
29
34
|
return `single`;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
|
-
private parseIfNeeded(value: Record<string, any> | string): Record<string, any> {
|
|
33
|
-
if (typeof value === 'string') {
|
|
34
|
-
try {
|
|
35
|
-
return JSON.parse(value);
|
|
36
|
-
} catch (e) {
|
|
37
|
-
throw new Error('Failed to parse JSON string: ' + e.message);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
return value;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
private useChellenge(challenge: string, expiresIn?: string): void {
|
|
44
|
-
const expiresInSeconds = expiresIn ? convertPeriodToSeconds(expiresIn) : undefined;
|
|
45
|
-
this.options.passkeys.keyValueAdapter.set(challenge, 'stub_value', expiresInSeconds);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
private async checkIfChellengeNotUsed(challenge: string): Promise<boolean> {
|
|
49
|
-
const res = await this.options.passkeys.keyValueAdapter.get(challenge);
|
|
50
|
-
if (!res) {
|
|
51
|
-
return true;
|
|
52
|
-
}
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
private async validateCookiesForPasskeyLogin(cookies: any): Promise<{ok: boolean, decodedPasskeysCookies?: any, error?: string}> {
|
|
57
|
-
const passkeysCookies = this.adminforth.auth.getCustomCookie({cookies: cookies, name: `passkeyLoginTemporaryJWT`});
|
|
58
|
-
if (!passkeysCookies) {
|
|
59
|
-
return { ok: false, error: 'Passkey token is required' };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const decodedPasskeysCookies = await this.adminforth.auth.verify(passkeysCookies, 'tempLoginPasskeyChallenge', false);
|
|
63
|
-
const isChallangeValid = await this.checkIfChellengeNotUsed(decodedPasskeysCookies.challenge);
|
|
64
|
-
if (isChallangeValid) {
|
|
65
|
-
this.useChellenge(decodedPasskeysCookies.challenge, this.options.passkeys?.challengeValidityPeriod || '2m');
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
if (!decodedPasskeysCookies || !isChallangeValid) {
|
|
69
|
-
return { ok: false, error: 'Invalid passkey' };
|
|
70
|
-
}
|
|
71
|
-
return { ok: true, decodedPasskeysCookies };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
37
|
public async checkIfSkipSetupAllowSkipVerify(adminUser: AdminUser): Promise<{ skipAllowed: boolean }> {
|
|
75
38
|
if (this.options.usersFilterToAllowSkipSetup) {
|
|
76
|
-
const res =
|
|
39
|
+
const res = this.options.usersFilterToAllowSkipSetup(adminUser); // receive result of usersFilterToAllowSkipSetup
|
|
77
40
|
if (res === false) { // if false, user is not allowed to skip anyway, so doesn't matter if they have 2FA set up or not
|
|
78
41
|
return { skipAllowed: false };
|
|
79
42
|
}
|
|
80
43
|
|
|
81
|
-
//
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
const userPkFieldName = usersPrimaryKeyColumn.name;
|
|
85
|
-
const userRecord = await this.adminforth.resource(this.adminforth.config.auth.usersResourceId).get([Filters.EQ(userPkFieldName, adminUser.pk)])
|
|
44
|
+
//receive user's record
|
|
45
|
+
const userPkFieldName = this.userRepository.getUserPkField();
|
|
46
|
+
const userRecord = await this.userRepository.getAuthUser(adminUser.pk);
|
|
86
47
|
|
|
87
48
|
//check if user has 2FA set up
|
|
88
|
-
const users2FASecret =
|
|
49
|
+
const users2FASecret = this.userRepository.getSecret(userRecord);
|
|
89
50
|
//check if user has any passkeys registered
|
|
90
|
-
const
|
|
51
|
+
const userHasPasskeys = await this.passkeyService.hasPasskeysForUser(adminUser.dbUser[userPkFieldName]);
|
|
91
52
|
|
|
92
53
|
// If user has either 2FA secret or any passkeys, they cannot skip
|
|
93
|
-
if (users2FASecret ||
|
|
54
|
+
if (users2FASecret || userHasPasskeys) {
|
|
94
55
|
return { skipAllowed: false };
|
|
95
56
|
}
|
|
96
57
|
return { skipAllowed: res };
|
|
@@ -98,60 +59,6 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
98
59
|
return { skipAllowed: false };
|
|
99
60
|
}
|
|
100
61
|
|
|
101
|
-
private generateHashForStepUpMfaGraceCookie(headers, cookies): string {
|
|
102
|
-
const ip = this.adminforth.auth.getClientIp(headers);
|
|
103
|
-
const userAgent = headers['user-agent'] || '';
|
|
104
|
-
const acceptLanguage = headers['accept-language'] || '';
|
|
105
|
-
const session_cookie = this.adminforth.auth.getCustomCookie({cookies: cookies, name: "jwt"});
|
|
106
|
-
if (!ip || !userAgent || !acceptLanguage || !session_cookie) {
|
|
107
|
-
console.error("❗️❗️❗️ Cannot set step-up MFA grace cookie: missing required request headers to identify client ❗️❗️❗️");
|
|
108
|
-
return null;
|
|
109
|
-
} else {
|
|
110
|
-
const hmac = crypto.createHmac('sha256', process.env.ADMINFORTH_SECRET)
|
|
111
|
-
.update(`${acceptLanguage}_${userAgent}_${ip}_${session_cookie}`)
|
|
112
|
-
.digest('hex');
|
|
113
|
-
return hmac;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
private issueTempSkip2FAGraceJWT(opts, cookies, response): void {
|
|
118
|
-
if (response) {
|
|
119
|
-
if (opts.extra.headers) {
|
|
120
|
-
const hash = this.generateHashForStepUpMfaGraceCookie(opts.extra.headers, cookies);
|
|
121
|
-
if (!hash) {
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
const jwt = this.adminforth.auth.issueJWT({ hash: hash }, 'MfaGrace', `${this.options.stepUpMfaGracePeriodSeconds}s`);
|
|
125
|
-
//TODO: fix ts-ignore after releasing new version of adminforth with updated types
|
|
126
|
-
//@ts-ignore
|
|
127
|
-
this.adminforth.auth.setCustomCookie({response: response, payload: {name: "TempSkip2FA_Modal_JWT", sessionBased: true, value: jwt, httpOnly: true}});
|
|
128
|
-
}
|
|
129
|
-
} else {
|
|
130
|
-
console.error("❗️❗️❗️ Cannot set step-up MFA grace cookie: response object is missing. You probably called verify() method without response parameter ❗️❗️❗️");
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
private async isTempSkip2FAGraceValid(headers, cookies, checkIfJWTAboutToExpire: boolean = false): Promise<boolean> {
|
|
135
|
-
const hash = this.generateHashForStepUpMfaGraceCookie(headers, cookies);
|
|
136
|
-
if (!hash) {
|
|
137
|
-
return false;
|
|
138
|
-
}
|
|
139
|
-
const jwt = this.adminforth.auth.getCustomCookie({cookies: cookies, name: "TempSkip2FA_Modal_JWT"});
|
|
140
|
-
const jwtVerificationResult = await this.adminforth.auth.verify(jwt, 'MfaGrace', false)
|
|
141
|
-
if (!jwtVerificationResult) {
|
|
142
|
-
return false;
|
|
143
|
-
}
|
|
144
|
-
const jwtHash = jwtVerificationResult['hash'];
|
|
145
|
-
if (checkIfJWTAboutToExpire && (jwtVerificationResult["exp"] - ( Date.now() / 1000)) < 30 ) {
|
|
146
|
-
console.error("❗️❗️❗️ Cannot validate step-up MFA grace cookie: token is expired or about to expire ❗️❗️❗️");
|
|
147
|
-
return false;
|
|
148
|
-
}
|
|
149
|
-
if (hash === jwtHash) {
|
|
150
|
-
return true;
|
|
151
|
-
}
|
|
152
|
-
return false;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
62
|
private pending = new Map<string, (value: any) => void>();
|
|
156
63
|
|
|
157
64
|
private waitForResponse(id: string, timeoutMs: number): Promise<any> {
|
|
@@ -194,167 +101,36 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
194
101
|
}
|
|
195
102
|
|
|
196
103
|
public async verify(
|
|
197
|
-
confirmationResult:
|
|
198
|
-
opts
|
|
104
|
+
confirmationResult: MfaConfirmationResult,
|
|
105
|
+
opts: VerifyOptions
|
|
199
106
|
): Promise<{ ok: true } | { error: string }> {
|
|
200
|
-
|
|
201
|
-
if (!opts.adminUser) return { error: "Admin user is required" };
|
|
202
|
-
if (!opts.userPk) return { error: "User PK is required" };
|
|
203
|
-
const cookies = opts.extra?.cookies || opts.cookies;
|
|
204
|
-
const response = opts.extra?.response || opts.response;
|
|
205
|
-
if (this.options.usersFilterToApply) {
|
|
206
|
-
const res = this.options.usersFilterToApply(opts.adminUser);
|
|
207
|
-
if ( res === false ) {
|
|
208
|
-
return { ok: true };
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
if (this.options.usersFilterToAllowSkipSetup) {
|
|
212
|
-
const res = await this.checkIfSkipSetupAllowSkipVerify(opts.adminUser);
|
|
213
|
-
if ( res.skipAllowed === true ) {
|
|
214
|
-
return { ok: true };
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
if ( this.options.stepUpMfaGracePeriodSeconds && opts.extra?.headers && !confirmationResult.mode) {
|
|
218
|
-
const verificationResult = await this.isTempSkip2FAGraceValid(opts.extra.headers, cookies);
|
|
219
|
-
if ( verificationResult === true ) {
|
|
220
|
-
return { ok: true };
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
if (confirmationResult.mode === "totp") {
|
|
225
|
-
const code = confirmationResult.result;
|
|
226
|
-
const authRes = this.adminforth.config.resources
|
|
227
|
-
.find(r => r.resourceId === this.adminforth.config.auth.usersResourceId);
|
|
228
|
-
|
|
229
|
-
if (!authRes) return { error: "Auth resource not found" };
|
|
230
|
-
|
|
231
|
-
const connector = this.adminforth.connectors[authRes.dataSource];
|
|
232
|
-
const pkName = authRes.columns.find(c => c.primaryKey)?.name;
|
|
233
|
-
if (!pkName) return { error: "Primary key not found on auth resource" };
|
|
234
|
-
|
|
235
|
-
const pk = opts?.userPk ?? opts?.adminUser?.dbUser?.[pkName];
|
|
236
|
-
if (!pk) return { error: "User PK is required" };
|
|
237
|
-
|
|
238
|
-
const user = await connector.getRecordByPrimaryKey(authRes, pk);
|
|
239
|
-
if (!user) return { error: "User not found" };
|
|
240
|
-
|
|
241
|
-
const secret = user[this.options.twoFaSecretFieldName];
|
|
242
|
-
if (!secret) return { error: "2FA is not set up for this user" };
|
|
243
|
-
|
|
244
|
-
const verified = twofactor.verifyToken(secret, code, this.options.timeStepWindow);
|
|
245
|
-
|
|
246
|
-
//* SET GRACE COOKIE *//
|
|
247
|
-
if ( verified ) {
|
|
248
|
-
if (this.options.stepUpMfaGracePeriodSeconds) {
|
|
249
|
-
this.issueTempSkip2FAGraceJWT(opts, cookies, response);
|
|
250
|
-
}
|
|
251
|
-
return { ok: true }
|
|
252
|
-
} else {
|
|
253
|
-
return { error: "Wrong or expired OTP code" }
|
|
254
|
-
};
|
|
255
|
-
} else if (confirmationResult.mode === "passkey") {
|
|
256
|
-
const cookiesValidationResult = await this.validateCookiesForPasskeyLogin(cookies);
|
|
257
|
-
if (!cookiesValidationResult.ok) {
|
|
258
|
-
return { error: cookiesValidationResult.error };
|
|
259
|
-
}
|
|
260
|
-
const verificationResult = await this.verifyPasskeyResponse(confirmationResult.result, opts.userPk, cookiesValidationResult.decodedPasskeysCookies );
|
|
261
|
-
|
|
262
|
-
if (verificationResult.ok && verificationResult.passkeyConfirmed) {
|
|
263
|
-
|
|
264
|
-
//* SET GRACE COOKIE *//
|
|
265
|
-
if (this.options.stepUpMfaGracePeriodSeconds) {
|
|
266
|
-
this.issueTempSkip2FAGraceJWT(opts, cookies, response);
|
|
267
|
-
}
|
|
268
|
-
return { ok: true }
|
|
269
|
-
}
|
|
270
|
-
return { error: "Invalid passkey" }
|
|
271
|
-
}
|
|
272
|
-
return { error: "Unsupported confirmation mode" };
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
public parsePeriod(period?: string): number {
|
|
276
|
-
if (!period) return 0;
|
|
277
|
-
|
|
278
|
-
const match = /^(\d+)([dhms])$/.exec(period.trim());
|
|
279
|
-
if (!match) throw new Error(`Invalid suggestionPeriod format: ${period}`);
|
|
280
|
-
|
|
281
|
-
const value = parseInt(match[1], 10);
|
|
282
|
-
const unit = match[2];
|
|
283
|
-
|
|
284
|
-
switch (unit) {
|
|
285
|
-
case 'd': return value * 24 * 60 * 60 * 1000;
|
|
286
|
-
case 'h': return value * 60 * 60 * 1000;
|
|
287
|
-
case 'm': return value * 60 * 1000;
|
|
288
|
-
case 's': return value * 1000;
|
|
289
|
-
default: return value;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
public async verifyPasskeyResponse(body: any, user_id: string, cookies: any) {
|
|
294
|
-
const settingsOrigin = this.options.passkeys?.settings.expectedOrigin;
|
|
295
|
-
const expectedOrigin = body.origin;
|
|
296
|
-
const expectedChallenge = cookies.challenge;
|
|
297
|
-
const expectedRPID = this.options.passkeys?.settings?.rp?.id || (new URL(settingsOrigin)).hostname;
|
|
298
|
-
const response = JSON.parse(body.response);
|
|
299
|
-
try {
|
|
300
|
-
if (settingsOrigin !== expectedOrigin) {
|
|
301
|
-
throw new Error(`Origin mismatch. Allowed in settings: ${settingsOrigin}, received from client: ${expectedOrigin}`);
|
|
302
|
-
}
|
|
303
|
-
const cred = await this.adminforth.resource(this.options.passkeys.credentialResourceID).get([Filters.EQ(this.options.passkeys.credentialIdFieldName, response.id)]);
|
|
304
|
-
if (!cred) {
|
|
305
|
-
throw new Error('Credential not found.');
|
|
306
|
-
}
|
|
307
|
-
const credMeta = this.parseIfNeeded(cred[this.options.passkeys.credentialMetaFieldName]);
|
|
308
|
-
if (!credMeta || !credMeta.public_key) {
|
|
309
|
-
throw new Error('Credential public key not found.');
|
|
310
|
-
}
|
|
311
|
-
const userResourceId = this.adminforth.config.auth.usersResourceId;
|
|
312
|
-
const usersResource = this.adminforth.config.resources.find(r => r.resourceId === userResourceId);
|
|
313
|
-
const usersPrimaryKeyColumn = usersResource.columns.find((col) => col.primaryKey);
|
|
314
|
-
const usersPrimaryKeyFieldName = usersPrimaryKeyColumn.name;
|
|
315
|
-
const user = await this.adminforth.resource(userResourceId).get([Filters.EQ(usersPrimaryKeyFieldName, cred[this.options.passkeys.credentialUserIdFieldName])]);
|
|
316
|
-
if (!user || !user_id || user[usersPrimaryKeyFieldName] !== user_id) {
|
|
317
|
-
throw new Error('User not found.');
|
|
318
|
-
}
|
|
319
|
-
const { verified, authenticationInfo } = await verifyAuthenticationResponse({
|
|
320
|
-
response,
|
|
321
|
-
expectedChallenge,
|
|
322
|
-
expectedOrigin: settingsOrigin,
|
|
323
|
-
expectedRPID,
|
|
324
|
-
credential: {
|
|
325
|
-
id: cred[this.options.passkeys.credentialIdFieldName],
|
|
326
|
-
publicKey: isoBase64URL.toBuffer(credMeta.public_key),
|
|
327
|
-
counter: credMeta.counter,
|
|
328
|
-
transports: credMeta.transports,
|
|
329
|
-
},
|
|
330
|
-
requireUserVerification: this.options.passkeys?.settings.authenticatorSelection.userVerification === 'discouraged' ? false : true,
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
if (!verified) {
|
|
334
|
-
return { ok: false, error: 'User verification failed.' };
|
|
335
|
-
}
|
|
336
|
-
credMeta.counter = authenticationInfo.newCounter;
|
|
337
|
-
credMeta.last_used_at = new Date().toISOString();
|
|
338
|
-
const credResource = this.adminforth.config.resources.find(r => r.resourceId === this.options.passkeys.credentialResourceID);
|
|
339
|
-
const credResourcePKColumn = credResource?.columns.find(c => c.primaryKey);
|
|
340
|
-
if (!credResource || !credResourcePKColumn) {
|
|
341
|
-
throw new Error('Credential resource or its primary key is not configured correctly');
|
|
342
|
-
}
|
|
343
|
-
const credResourcePKName = credResourcePKColumn.name;
|
|
344
|
-
await this.adminforth
|
|
345
|
-
.resource(this.options.passkeys.credentialResourceID)
|
|
346
|
-
.update(cred[credResourcePKName], { [this.options.passkeys.credentialMetaFieldName]: JSON.stringify(credMeta) });
|
|
347
|
-
return { ok: true, passkeyConfirmed: true };
|
|
348
|
-
} catch (e) {
|
|
349
|
-
return { ok: false, error: 'Error authenticating passkey: ' + e };
|
|
350
|
-
}
|
|
107
|
+
return verifyMfaConfirmation(this.ctx, confirmationResult, opts);
|
|
351
108
|
}
|
|
352
109
|
|
|
353
110
|
modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
|
|
354
111
|
super.modifyResourceConfig(adminforth, resourceConfig);
|
|
355
112
|
this.adminforth = adminforth;
|
|
356
113
|
this.adminForthAuth = adminforth.auth;
|
|
357
|
-
|
|
114
|
+
this.userRepository = new UserRepository(this.adminforth, this.options);
|
|
115
|
+
this.cookieService = new CookieService(this.adminforth, this.options);
|
|
116
|
+
this.totpService = new TotpService(this.options, this.userRepository);
|
|
117
|
+
this.passkeyService = new PasskeyService(this.options, this.adminforth, this.userRepository, undefined, this.cookieService);
|
|
118
|
+
this.ctx = {
|
|
119
|
+
adminforth: this.adminforth,
|
|
120
|
+
options: this.options,
|
|
121
|
+
authResource: resourceConfig,
|
|
122
|
+
userRepository: this.userRepository,
|
|
123
|
+
totpService: this.totpService,
|
|
124
|
+
passkeyService: this.passkeyService,
|
|
125
|
+
cookieService: this.cookieService,
|
|
126
|
+
verifyMfaConfirmation: (confirmationResult, opts) => verifyMfaConfirmation(this.ctx, confirmationResult, opts),
|
|
127
|
+
checkIfSkipSetupAllowSkipVerify: this.checkIfSkipSetupAllowSkipVerify.bind(this),
|
|
128
|
+
autoVerify: {
|
|
129
|
+
verifyAuto: this.verifyAuto.bind(this),
|
|
130
|
+
resolveResponse: this.resolveResponse.bind(this),
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
const suggestionPeriod = parsePeriod(this.options.passkeys?.suggestionPeriod || "5d");
|
|
358
134
|
const isPasskeysEnabled = this.options.passkeys ? true : false;
|
|
359
135
|
|
|
360
136
|
const customPages = this.adminforth.config.customization.customPages
|
|
@@ -380,7 +156,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
380
156
|
slug: 'passkeys',
|
|
381
157
|
component: this.componentPath('TwoFactorsPasskeysSettings.vue'),
|
|
382
158
|
isVisible: (adminUser: AdminUser) => {
|
|
383
|
-
const secret = adminUser.dbUser
|
|
159
|
+
const secret = this.userRepository.getSecret(adminUser.dbUser)
|
|
384
160
|
if (!secret || secret.length === 0) {
|
|
385
161
|
return false;
|
|
386
162
|
}
|
|
@@ -388,8 +164,8 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
388
164
|
}
|
|
389
165
|
});
|
|
390
166
|
|
|
391
|
-
|
|
392
|
-
|
|
167
|
+
const allowLoginWithPasskeys = this.options.passkeys.allowLoginWithPasskeys ?? true;
|
|
168
|
+
if ( allowLoginWithPasskeys ) {
|
|
393
169
|
if ( !this.adminforth.config.customization.loginPageInjections ) {
|
|
394
170
|
this.adminforth.config.customization.loginPageInjections = { underLoginButton: [], panelHeader: [], underInputs: [] };
|
|
395
171
|
}
|
|
@@ -509,16 +285,14 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
509
285
|
if ( extra?.meta?.loginAllowedByPasskeyDirectSignIn === true) {
|
|
510
286
|
return { body: { loginAllowed: true }, ok: true };
|
|
511
287
|
}
|
|
512
|
-
const secret = adminUser.dbUser
|
|
288
|
+
const secret = this.userRepository.getSecret(adminUser.dbUser)
|
|
513
289
|
const userName = adminUser.dbUser[adminforth.config.auth.usernameField]
|
|
514
290
|
const brandName = adminforth.config.customization.brandName;
|
|
515
291
|
const brandNameSlug = adminforth.config.customization.brandNameSlug;
|
|
516
292
|
const issuerName = (this.options.customBrandPrefix && this.options.customBrandPrefix.trim())
|
|
517
293
|
? this.options.customBrandPrefix.trim()
|
|
518
294
|
: brandName;
|
|
519
|
-
const
|
|
520
|
-
const authPk = authResource.columns.find((col)=>col.primaryKey).name
|
|
521
|
-
const userPk = adminUser.dbUser[authPk]
|
|
295
|
+
const userPk = this.userRepository.getUserPk(adminUser)
|
|
522
296
|
let newSecret = null;
|
|
523
297
|
|
|
524
298
|
const userNeeds2FA = this.options.usersFilterToApply ? this.options.usersFilterToApply(adminUser) : true;
|
|
@@ -528,10 +302,8 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
528
302
|
const userCanSkipSetup = this.options.usersFilterToAllowSkipSetup ? this.options.usersFilterToAllowSkipSetup(adminUser) : false;
|
|
529
303
|
|
|
530
304
|
if (!secret){
|
|
531
|
-
|
|
532
|
-
newSecret
|
|
533
|
-
const totpTemporaryJWT = this.adminforth.auth.issueJWT({userName, newSecret, issuer:issuerName, pk:userPk, userCanSkipSetup, sessionDuration }, 'temp2FA', this.options.passkeys?.challengeValidityPeriod || '1m');
|
|
534
|
-
this.adminforth.auth.setCustomCookie({response, payload: {name: "2FaTemporaryJWT", value: totpTemporaryJWT, expiry: undefined, expirySeconds: 10 * 60, httpOnly: true}});
|
|
305
|
+
newSecret = this.totpService.generateSecret(issuerName, userName);
|
|
306
|
+
this.cookieService.setTotpTemporary(response, {userName, newSecret, issuer:issuerName, pk:userPk, userCanSkipSetup, sessionDuration });
|
|
535
307
|
|
|
536
308
|
return {
|
|
537
309
|
body:{
|
|
@@ -542,8 +314,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
542
314
|
}
|
|
543
315
|
|
|
544
316
|
} else {
|
|
545
|
-
|
|
546
|
-
this.adminforth.auth.setCustomCookie({response, payload: {name: "2FaTemporaryJWT", value: value, expiry: undefined, expirySeconds: 10 * 60, httpOnly: true}});
|
|
317
|
+
this.cookieService.setTotpTemporary(response, {userName, issuer:issuerName, pk:userPk, userCanSkipSetup, sessionDuration });
|
|
547
318
|
|
|
548
319
|
return {
|
|
549
320
|
body:{
|
|
@@ -558,607 +329,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
558
329
|
}
|
|
559
330
|
|
|
560
331
|
setupEndpoints(server: IHttpServer): void {
|
|
561
|
-
server.
|
|
562
|
-
|
|
563
|
-
path: `/plugin/twofa/initSetup`,
|
|
564
|
-
noAuth: true,
|
|
565
|
-
handler: async (server) => {
|
|
566
|
-
const toReturn = {totpJWT:null,status:'ok',}
|
|
567
|
-
const totpTemporaryJWT = this.adminforth.auth.getCustomCookie({cookies: server.cookies, name: "2FaTemporaryJWT"});
|
|
568
|
-
if (totpTemporaryJWT){
|
|
569
|
-
toReturn.totpJWT = totpTemporaryJWT
|
|
570
|
-
}
|
|
571
|
-
return toReturn
|
|
572
|
-
}
|
|
573
|
-
})
|
|
574
|
-
|
|
575
|
-
// this enpoints sets final login cookie if 2nd factor is confirmed
|
|
576
|
-
server.endpoint({
|
|
577
|
-
method: 'POST',
|
|
578
|
-
path: `/plugin/twofa/confirmLogin`,
|
|
579
|
-
noAuth: true,
|
|
580
|
-
handler: async ({ body, adminUser, response, cookies }) => {
|
|
581
|
-
const brandNameSlug = this.adminforth.config.customization.brandNameSlug;
|
|
582
|
-
const totpTemporaryJWT = this.adminforth.auth.getCustomCookie({cookies: cookies, name: "2FaTemporaryJWT"});
|
|
583
|
-
if (!totpTemporaryJWT) {
|
|
584
|
-
return { error: 'Login session expired. Please log in again.' }
|
|
585
|
-
}
|
|
586
|
-
const decoded = await this.adminforth.auth.verify(totpTemporaryJWT, 'temp2FA');
|
|
587
|
-
if (!decoded) {
|
|
588
|
-
return { error: 'Login session expired. Please log in again.' }
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
if (decoded.newSecret) {
|
|
592
|
-
// set up standard TOTP request - ensured by presence of newSecret in temp2FA token
|
|
593
|
-
const verified = body.skip && decoded.userCanSkipSetup ? true : twofactor.verifyToken(decoded.newSecret, body.code, this.options.timeStepWindow);
|
|
594
|
-
if (verified) {
|
|
595
|
-
this.connectors = this.adminforth.connectors
|
|
596
|
-
if (!body.skip) {
|
|
597
|
-
const connector = this.connectors[this.authResource.dataSource];
|
|
598
|
-
await connector.updateRecord({resource:this.authResource, recordId:decoded.pk, newValues:{[this.options.twoFaSecretFieldName]: decoded.newSecret}})
|
|
599
|
-
}
|
|
600
|
-
this.adminforth.auth.removeCustomCookie({response, name:'2FaTemporaryJWT'})
|
|
601
|
-
this.adminforth.auth.setAuthCookie({expireInDuration: decoded.sessionDuration, response, username:decoded.userName, pk:decoded.pk})
|
|
602
|
-
return { status: 'ok', allowedLogin: true }
|
|
603
|
-
} else {
|
|
604
|
-
return {error: 'Wrong or expired OTP code'}
|
|
605
|
-
}
|
|
606
|
-
} else {
|
|
607
|
-
// login with confirming existing TOTP or Passkey
|
|
608
|
-
let verified = null;
|
|
609
|
-
if (body.usePasskey && this.options.passkeys) {
|
|
610
|
-
// passkeys are enabled and user wants to use them
|
|
611
|
-
const cookiesValidationResult = await this.validateCookiesForPasskeyLogin(cookies);
|
|
612
|
-
if (!cookiesValidationResult.ok) {
|
|
613
|
-
return { error: cookiesValidationResult.error };
|
|
614
|
-
}
|
|
615
|
-
const res = await this.verifyPasskeyResponse(body.passkeyOptions, decoded.pk, cookiesValidationResult.decodedPasskeysCookies);
|
|
616
|
-
if (res.ok && res.passkeyConfirmed) {
|
|
617
|
-
verified = true;
|
|
618
|
-
}
|
|
619
|
-
} else {
|
|
620
|
-
// user already has TOTP secret, get it
|
|
621
|
-
this.connectors = this.adminforth.connectors
|
|
622
|
-
const connector = this.connectors[this.authResource.dataSource];
|
|
623
|
-
const user = await connector.getRecordByPrimaryKey(this.authResource, decoded.pk)
|
|
624
|
-
verified = twofactor.verifyToken(user[this.options.twoFaSecretFieldName], body.code, this.options.timeStepWindow);
|
|
625
|
-
}
|
|
626
|
-
if (verified) {
|
|
627
|
-
this.adminforth.auth.removeCustomCookie({response, name:'2FaTemporaryJWT'})
|
|
628
|
-
this.adminforth.auth.setAuthCookie({expireInDuration: decoded.sessionDuration, response, username:decoded.userName, pk:decoded.pk})
|
|
629
|
-
return { status: 'ok', allowedLogin: true }
|
|
630
|
-
} else {
|
|
631
|
-
response.setStatus(403, "Wrong or expired TOTP code");
|
|
632
|
-
return {error: 'Wrong or expired TOTP code', }
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
})
|
|
637
|
-
server.endpoint({
|
|
638
|
-
method: 'POST',
|
|
639
|
-
path: `/plugin/twofa/confirmLoginWithPasskey`,
|
|
640
|
-
noAuth: true,
|
|
641
|
-
handler: async ({ body, response, cookies, headers, requestUrl, query }) => {
|
|
642
|
-
if ( this.options.passkeys.allowLoginWithPasskeys !== true ) {
|
|
643
|
-
return { error: 'Login with passkeys is not allowed' };
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
const passkeyResponse = body.passkeyResponse;
|
|
647
|
-
if (!passkeyResponse) {
|
|
648
|
-
return { error: 'Passkey response is required' };
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
const cookiesValidationResult = await this.validateCookiesForPasskeyLogin(cookies);
|
|
652
|
-
if (!cookiesValidationResult.ok) {
|
|
653
|
-
return { error: cookiesValidationResult.error };
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
let parsedPasskeyResponse;
|
|
657
|
-
try {
|
|
658
|
-
parsedPasskeyResponse = JSON.parse(passkeyResponse.response);
|
|
659
|
-
} catch (e) {
|
|
660
|
-
return { error: 'Malformed passkey response' };
|
|
661
|
-
}
|
|
662
|
-
const credential_id = parsedPasskeyResponse.id;
|
|
663
|
-
if (!credential_id) {
|
|
664
|
-
return { error: 'Credential ID is required' };
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
const passkeyRecord = await this.adminforth.resource(this.options.passkeys.credentialResourceID).get([Filters.EQ(this.options.passkeys.credentialIdFieldName, credential_id)]);
|
|
668
|
-
if (!passkeyRecord) {
|
|
669
|
-
return { error: 'No such passkey found, most likeley it was removed on this website but you still have it on your device' };
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
const userPk = passkeyRecord[this.options.passkeys.credentialUserIdFieldName];
|
|
673
|
-
if (!userPk) {
|
|
674
|
-
return { error: 'User ID not found in passkey record' };
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
const userResourceId = this.adminforth.config.auth.usersResourceId;
|
|
678
|
-
const usersResource = this.adminforth.config.resources.find(r => r.resourceId === userResourceId);
|
|
679
|
-
const usersPrimaryKeyColumn = usersResource.columns.find((col) => col.primaryKey);
|
|
680
|
-
const usersPrimaryKeyFieldName = usersPrimaryKeyColumn.name;
|
|
681
|
-
const user = await this.adminforth.resource(userResourceId).get([Filters.EQ(usersPrimaryKeyFieldName, userPk)]);
|
|
682
|
-
if (!user) {
|
|
683
|
-
return { error: 'User not found' };
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
const verificationResult = await this.verifyPasskeyResponse(passkeyResponse, userPk, cookiesValidationResult.decodedPasskeysCookies);
|
|
687
|
-
if (!verificationResult.ok || !verificationResult.passkeyConfirmed) {
|
|
688
|
-
return { error: 'Passkey verification failed' };
|
|
689
|
-
}
|
|
690
|
-
const username = user[this.adminforth.config.auth.usernameField];
|
|
691
|
-
|
|
692
|
-
const adminUser = {
|
|
693
|
-
dbUser: user,
|
|
694
|
-
pk: user.id,
|
|
695
|
-
username,
|
|
696
|
-
};
|
|
697
|
-
|
|
698
|
-
const toReturn = { allowedLogin: true, error: '' };
|
|
699
|
-
const rememberMe = body?.rememberMe || false;
|
|
700
|
-
|
|
701
|
-
await this.adminforth.restApi.processLoginCallbacks(
|
|
702
|
-
adminUser,
|
|
703
|
-
toReturn,
|
|
704
|
-
response,
|
|
705
|
-
{
|
|
706
|
-
headers,
|
|
707
|
-
cookies,
|
|
708
|
-
requestUrl,
|
|
709
|
-
query,
|
|
710
|
-
body: {},
|
|
711
|
-
response,
|
|
712
|
-
meta: {
|
|
713
|
-
loginAllowedByPasskeyDirectSignIn: true
|
|
714
|
-
},
|
|
715
|
-
},
|
|
716
|
-
rememberMe ? this.adminforth.config.auth.rememberMeDuration || '30d' : '1d',
|
|
717
|
-
|
|
718
|
-
);
|
|
719
|
-
|
|
720
|
-
const rememberDaysAfterPasskeyLogin = (this.options.passkeys.rememberDaysAfterPasskeyLogin ? this.options.passkeys.rememberDaysAfterPasskeyLogin.toString().concat('d') : null);
|
|
721
|
-
if ( toReturn.allowedLogin === true ) {
|
|
722
|
-
this.adminforth.auth.setAuthCookie({
|
|
723
|
-
response,
|
|
724
|
-
username,
|
|
725
|
-
pk: user.id,
|
|
726
|
-
expireInDuration: rememberDaysAfterPasskeyLogin ? rememberDaysAfterPasskeyLogin : this.adminforth.config.auth.rememberMeDuration,
|
|
727
|
-
});
|
|
728
|
-
}
|
|
729
|
-
return toReturn;
|
|
730
|
-
}
|
|
731
|
-
}),
|
|
732
|
-
server.endpoint({
|
|
733
|
-
method: "GET",
|
|
734
|
-
path: "/plugin/twofa/skip-allow",
|
|
735
|
-
noAuth: true,
|
|
736
|
-
handler: async ({ cookies }) => {
|
|
737
|
-
const totpTemporaryJWT = this.adminforth.auth.getCustomCookie({
|
|
738
|
-
cookies,
|
|
739
|
-
name: `2FaTemporaryJWT`,
|
|
740
|
-
});
|
|
741
|
-
const decoded = await this.adminforth.auth.verify(
|
|
742
|
-
totpTemporaryJWT,
|
|
743
|
-
"temp2FA",
|
|
744
|
-
);
|
|
745
|
-
if (!decoded) {
|
|
746
|
-
return { status: "error", message: "Invalid token" };
|
|
747
|
-
}
|
|
748
|
-
if (!decoded.newSecret) {
|
|
749
|
-
return { status: "ok", skipAllowed: false };
|
|
750
|
-
} else {
|
|
751
|
-
return {
|
|
752
|
-
status: "ok",
|
|
753
|
-
skipAllowed: decoded.userCanSkipSetup,
|
|
754
|
-
};
|
|
755
|
-
}
|
|
756
|
-
},
|
|
757
|
-
});
|
|
758
|
-
server.endpoint({
|
|
759
|
-
method: "GET",
|
|
760
|
-
path: "/plugin/twofa/skip-allow-modal",
|
|
761
|
-
handler: async ({ adminUser, headers, cookies }) => {
|
|
762
|
-
if ( this.options.usersFilterToApply ) {
|
|
763
|
-
const res = this.options.usersFilterToApply(adminUser);
|
|
764
|
-
if ( res === false ) {
|
|
765
|
-
return { skipAllowed: true };
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
if ( this.options.usersFilterToAllowSkipSetup ) {
|
|
769
|
-
const res = await this.checkIfSkipSetupAllowSkipVerify(adminUser);
|
|
770
|
-
if ( res.skipAllowed === true ) {
|
|
771
|
-
return { skipAllowed: true };
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
if ( this.options.stepUpMfaGracePeriodSeconds ) {
|
|
776
|
-
const verificationResult = await this.isTempSkip2FAGraceValid(headers, cookies, true);
|
|
777
|
-
if ( verificationResult === true ) {
|
|
778
|
-
return { skipAllowed: true };
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
return { skipAllowed: false };
|
|
783
|
-
},
|
|
784
|
-
});
|
|
785
|
-
server.endpoint({
|
|
786
|
-
method: 'POST',
|
|
787
|
-
path: `/plugin/twofa/verify`,
|
|
788
|
-
noAuth: false,
|
|
789
|
-
handler: async ({ adminUser, body }) => {
|
|
790
|
-
if (!body?.code) return { error: 'Code is required' };
|
|
791
|
-
|
|
792
|
-
const authRes = this.adminforth.config.resources
|
|
793
|
-
.find(r => r.resourceId === this.adminforth.config.auth.usersResourceId);
|
|
794
|
-
const connector = this.adminforth.connectors[authRes.dataSource];
|
|
795
|
-
const pkName = authRes.columns.find(c => c.primaryKey).name;
|
|
796
|
-
const user = await connector.getRecordByPrimaryKey(authRes, adminUser.dbUser[pkName]);
|
|
797
|
-
|
|
798
|
-
const secret = user[this.options.twoFaSecretFieldName];
|
|
799
|
-
if (!secret) return { error: '2FA is not set up for this user' };
|
|
800
|
-
|
|
801
|
-
const verified = twofactor.verifyToken(secret, body.code, this.options.timeStepWindow);
|
|
802
|
-
return verified ? { ok: true } : { error: 'Wrong or expired OTP code' };
|
|
803
|
-
}
|
|
804
|
-
});
|
|
805
|
-
server.endpoint({
|
|
806
|
-
method: 'POST',
|
|
807
|
-
path: `/plugin/passkeys/registerPasskeyRequest`,
|
|
808
|
-
noAuth: false,
|
|
809
|
-
handler: async ({ body, adminUser, response, cookies, headers }) => {
|
|
810
|
-
const mode = body?.mode;
|
|
811
|
-
|
|
812
|
-
const confirmationResult = body?.confirmationResult;
|
|
813
|
-
const verificationResult = await this.verify(confirmationResult, {
|
|
814
|
-
adminUser: adminUser,
|
|
815
|
-
userPk: adminUser.pk,
|
|
816
|
-
cookies: cookies,
|
|
817
|
-
response: response,
|
|
818
|
-
extra: {
|
|
819
|
-
headers,
|
|
820
|
-
} as HttpExtra
|
|
821
|
-
});
|
|
822
|
-
if (!verificationResult || !('ok' in verificationResult)) {
|
|
823
|
-
return { ok: false, error: 'error' in verificationResult ? verificationResult.error : 'Verification failed' };
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
const settingsOrigin = this.options.passkeys?.settings.expectedOrigin;
|
|
827
|
-
const rp = {
|
|
828
|
-
name: this.options.passkeys?.settings.rp.name || this.adminforth.config.customization.brandName,
|
|
829
|
-
id: this.options.passkeys?.settings?.rp?.id || (new URL(settingsOrigin)).hostname,
|
|
830
|
-
};
|
|
831
|
-
const userResourceId = this.adminforth.config.auth.usersResourceId;
|
|
832
|
-
const usersResource = this.adminforth.config.resources.find(r => r.resourceId === userResourceId);
|
|
833
|
-
const usersPrimaryKeyColumn = usersResource.columns.find((col) => col.primaryKey);
|
|
834
|
-
const usersPrimaryKeyFieldName = usersPrimaryKeyColumn.name;
|
|
835
|
-
const userInfo = await this.adminforth.resource(userResourceId).get( [Filters.EQ(usersPrimaryKeyFieldName, adminUser.pk)] );
|
|
836
|
-
const user = {
|
|
837
|
-
id: adminUser.pk,
|
|
838
|
-
name: userInfo[this.options.passkeys?.settings.user.nameField],
|
|
839
|
-
displayName: userInfo[this.options.passkeys?.settings.user.displayNameField] ? userInfo[this.options.passkeys?.settings.user.displayNameField] : userInfo[this.options.passkeys?.settings.user.nameField],
|
|
840
|
-
};
|
|
841
|
-
const excludeCredentials = [];
|
|
842
|
-
const temp = await this.adminforth.resource(this.options.passkeys.credentialResourceID).list([Filters.EQ(this.options.passkeys.credentialUserIdFieldName, adminUser.pk)]);
|
|
843
|
-
for (const rec of temp) {
|
|
844
|
-
if (rec.credential_id && rec.credential_id.length > 0) {
|
|
845
|
-
const meta = this.parseIfNeeded(rec[this.options.passkeys.credentialMetaFieldName]);
|
|
846
|
-
excludeCredentials.push({
|
|
847
|
-
id: rec.credential_id,
|
|
848
|
-
type: "public-key",
|
|
849
|
-
transports: JSON.parse(meta.transports || '[]')
|
|
850
|
-
});
|
|
851
|
-
}
|
|
852
|
-
}
|
|
853
|
-
const options = await generateRegistrationOptions({
|
|
854
|
-
rpName: rp.name,
|
|
855
|
-
rpID: rp.id,
|
|
856
|
-
userID: isoUint8Array.fromUTF8String(user.id),
|
|
857
|
-
userName: user.name,
|
|
858
|
-
userDisplayName: user.displayName,
|
|
859
|
-
excludeCredentials,
|
|
860
|
-
authenticatorSelection: {
|
|
861
|
-
authenticatorAttachment: mode,
|
|
862
|
-
requireResidentKey: this.options.passkeys?.settings.authenticatorSelection.requireResidentKey || true,
|
|
863
|
-
userVerification: this.options.passkeys?.settings.authenticatorSelection.userVerification || "required"
|
|
864
|
-
},
|
|
865
|
-
});
|
|
866
|
-
const value = this.adminforth.auth.issueJWT({ "challenge": options.challenge, "user_id": adminUser.pk }, 'registerTempPasskeyChallenge', this.options.passkeys?.challengeValidityPeriod || '1m');
|
|
867
|
-
this.adminforth.auth.setCustomCookie({response, payload: {name: "registerPasskeyTemporaryJWT", value: value, expiry: undefined, expirySeconds: 10 * 60, httpOnly: true}});
|
|
868
|
-
return { ok: true, data: options };
|
|
869
|
-
}
|
|
870
|
-
});
|
|
871
|
-
server.endpoint({
|
|
872
|
-
method: 'POST',
|
|
873
|
-
path: `/plugin/passkeys/finishRegisteringPasskey`,
|
|
874
|
-
noAuth: false,
|
|
875
|
-
handler: async ({body, adminUser, cookies }) => {
|
|
876
|
-
const passkeysCookies = this.adminforth.auth.getCustomCookie({cookies: cookies, name: "registerPasskeyTemporaryJWT"});
|
|
877
|
-
if (!passkeysCookies) {
|
|
878
|
-
return { error: 'Passkey token is required' };
|
|
879
|
-
}
|
|
880
|
-
const decodedPasskeysCookies = await this.adminforth.auth.verify(passkeysCookies, 'registerTempPasskeyChallenge', false);
|
|
881
|
-
if (!decodedPasskeysCookies) {
|
|
882
|
-
return { error: 'Invalid passkey token' };
|
|
883
|
-
}
|
|
884
|
-
if (decodedPasskeysCookies.user_id !== adminUser.pk) {
|
|
885
|
-
return { error: 'Invalid user' };
|
|
886
|
-
}
|
|
887
|
-
const secret = adminUser.dbUser[this.options.twoFaSecretFieldName]
|
|
888
|
-
if (!secret || secret.length === 0) {
|
|
889
|
-
return { error: 'TOTP must be set up before registering a passkey' };
|
|
890
|
-
}
|
|
891
|
-
const settingsOrigin = this.options.passkeys?.settings.expectedOrigin;
|
|
892
|
-
const expectedOrigin = body.origin;
|
|
893
|
-
const expectedChallenge = decodedPasskeysCookies.challenge;
|
|
894
|
-
const expectedRPID = this.options.passkeys?.settings?.rp?.id || (new URL(settingsOrigin)).hostname;
|
|
895
|
-
const response = JSON.parse(body.credential);
|
|
896
|
-
try {
|
|
897
|
-
if (settingsOrigin !== expectedOrigin) {
|
|
898
|
-
throw new Error('Invalid origin');
|
|
899
|
-
}
|
|
900
|
-
const { verified, registrationInfo } = await verifyRegistrationResponse({
|
|
901
|
-
response,
|
|
902
|
-
expectedChallenge,
|
|
903
|
-
expectedOrigin,
|
|
904
|
-
expectedRPID,
|
|
905
|
-
requireUserVerification: this.options.passkeys?.settings.authenticatorSelection.userVerification === 'discouraged' ? false : true,
|
|
906
|
-
});
|
|
907
|
-
|
|
908
|
-
if (!verified) {
|
|
909
|
-
throw new Error('Verification failed.');
|
|
910
|
-
}
|
|
911
|
-
const {
|
|
912
|
-
aaguid,
|
|
913
|
-
credential,
|
|
914
|
-
credentialBackedUp
|
|
915
|
-
} = registrationInfo;
|
|
916
|
-
const provider_name = aaguids[aaguid]?.name || 'Unknown provider';
|
|
917
|
-
const credentialPublicKey = credential.publicKey;
|
|
918
|
-
const credentialID = credential.id;
|
|
919
|
-
|
|
920
|
-
const base64CredentialID = credentialID;
|
|
921
|
-
const base64PublicKey = isoBase64URL.fromBuffer(credentialPublicKey);
|
|
922
|
-
|
|
923
|
-
const credResource = this.adminforth.config.resources.find(r => r.resourceId === this.options.passkeys.credentialResourceID);
|
|
924
|
-
if (!credResource) {
|
|
925
|
-
throw new Error('Credential resource not found.');
|
|
926
|
-
}
|
|
927
|
-
await this.adminforth.createResourceRecord({
|
|
928
|
-
resource: credResource,
|
|
929
|
-
record: {
|
|
930
|
-
[this.options.passkeys.credentialIdFieldName] : base64CredentialID,
|
|
931
|
-
[this.options.passkeys.credentialUserIdFieldName] : adminUser.pk,
|
|
932
|
-
[this.options.passkeys.credentialMetaFieldName] : JSON.stringify({
|
|
933
|
-
public_key : base64PublicKey,
|
|
934
|
-
public_key_algorithm : response.response.publicKeyAlgorithm,
|
|
935
|
-
sign_count : 0,
|
|
936
|
-
transports : JSON.stringify(response.response.transports),
|
|
937
|
-
created_at : new Date().toISOString(),
|
|
938
|
-
last_used_at : new Date().toISOString(),
|
|
939
|
-
aaguid : aaguid,
|
|
940
|
-
name : provider_name,
|
|
941
|
-
}),
|
|
942
|
-
},
|
|
943
|
-
adminUser: adminUser
|
|
944
|
-
});
|
|
945
|
-
} catch (error) {
|
|
946
|
-
console.error(error);
|
|
947
|
-
return { error: 'Error registering passkey: ' + error.message };
|
|
948
|
-
}
|
|
949
|
-
return { ok: true };
|
|
950
|
-
}
|
|
951
|
-
});
|
|
952
|
-
server.endpoint({
|
|
953
|
-
method: 'POST',
|
|
954
|
-
path: `/plugin/passkeys/signInRequest`,
|
|
955
|
-
noAuth: true,
|
|
956
|
-
handler: async ({ response }) => {
|
|
957
|
-
try {
|
|
958
|
-
const options = await generateAuthenticationOptions({
|
|
959
|
-
rpID: this.options.passkeys?.settings.rp.id,
|
|
960
|
-
userVerification: this.options.passkeys?.settings.authenticatorSelection.userVerification || "required"
|
|
961
|
-
});
|
|
962
|
-
const value = this.adminforth.auth.issueJWT({ "challenge": options.challenge }, 'tempLoginPasskeyChallenge', this.options.passkeys?.challengeValidityPeriod || '1m');
|
|
963
|
-
this.adminforth.auth.setCustomCookie({response, payload: {name: `passkeyLoginTemporaryJWT`, value: value, expiry: undefined, expirySeconds: 10 * 60, httpOnly: true}});
|
|
964
|
-
return { ok: true, data: options };
|
|
965
|
-
} catch (e) {
|
|
966
|
-
return { ok: false, error: e };
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
|
-
});
|
|
970
|
-
server.endpoint({
|
|
971
|
-
method: 'GET',
|
|
972
|
-
path: `/plugin/passkeys/getPasskeys`,
|
|
973
|
-
noAuth: false,
|
|
974
|
-
handler: async ({ adminUser }) => {
|
|
975
|
-
let passkeys;
|
|
976
|
-
try {
|
|
977
|
-
passkeys = await this.adminforth.resource(this.options.passkeys.credentialResourceID).list( [Filters.EQ(this.options.passkeys.credentialUserIdFieldName, adminUser.pk)] );
|
|
978
|
-
} catch (error) {
|
|
979
|
-
return { ok: false, error: 'Error fetching passkeys: ' + error.message };
|
|
980
|
-
}
|
|
981
|
-
let dataToReturn = [];
|
|
982
|
-
for (const pk of passkeys) {
|
|
983
|
-
const parsedKey = this.parseIfNeeded(pk[this.options.passkeys.credentialMetaFieldName]);
|
|
984
|
-
dataToReturn.push({
|
|
985
|
-
name: parsedKey.name,
|
|
986
|
-
light_icon: aaguids[parsedKey.aaguid]?.icon_light || null,
|
|
987
|
-
dark_icon: aaguids[parsedKey.aaguid]?.icon_dark || null,
|
|
988
|
-
created_at: parsedKey.created_at,
|
|
989
|
-
last_used_at: parsedKey.last_used_at,
|
|
990
|
-
id: pk[this.options.passkeys.credentialIdFieldName],
|
|
991
|
-
});
|
|
992
|
-
}
|
|
993
|
-
return { ok: true, data: dataToReturn, authenticatorAttachment: this.options.passkeys?.settings.authenticatorSelection.authenticatorAttachment || 'both' };
|
|
994
|
-
}
|
|
995
|
-
});
|
|
996
|
-
server.endpoint({
|
|
997
|
-
method: 'DELETE',
|
|
998
|
-
path: `/plugin/passkeys/deletePasskey`,
|
|
999
|
-
noAuth: false,
|
|
1000
|
-
handler: async ({body, adminUser }) => {
|
|
1001
|
-
const credentialID = body.passkeyId;
|
|
1002
|
-
if (!credentialID) {
|
|
1003
|
-
return { ok: false, error: 'Passkey ID is required' };
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
const passkeyRecord = await this.adminforth.resource(this.options.passkeys.credentialResourceID).get([Filters.EQ(this.options.passkeys.credentialIdFieldName, credentialID), Filters.EQ(this.options.passkeys.credentialUserIdFieldName, adminUser.pk)]);
|
|
1007
|
-
if (!passkeyRecord) {
|
|
1008
|
-
return { ok: false, error: 'Passkey not found' };
|
|
1009
|
-
}
|
|
1010
|
-
try {
|
|
1011
|
-
const credResource = this.adminforth.config.resources.find(r => r.resourceId === this.options.passkeys.credentialResourceID);
|
|
1012
|
-
if (!credResource) {
|
|
1013
|
-
throw new Error('Credential resource not found.');
|
|
1014
|
-
}
|
|
1015
|
-
const credResourcePKColumn = credResource.columns.find(c => c.primaryKey);
|
|
1016
|
-
if (!credResourcePKColumn) {
|
|
1017
|
-
throw new Error('Credential resource primary key not found.');
|
|
1018
|
-
}
|
|
1019
|
-
const credResourcePKName = credResourcePKColumn.name;
|
|
1020
|
-
const passkeyRecord = await this.adminforth.resource(this.options.passkeys.credentialResourceID).get([Filters.EQ(this.options.passkeys.credentialIdFieldName, credentialID), Filters.EQ(this.options.passkeys.credentialUserIdFieldName, adminUser.pk)]);
|
|
1021
|
-
if (!passkeyRecord) {
|
|
1022
|
-
return { ok: false, error: 'Passkey not found' };
|
|
1023
|
-
}
|
|
1024
|
-
await this.adminforth.deleteResourceRecord({
|
|
1025
|
-
resource: credResource,
|
|
1026
|
-
recordId: passkeyRecord[credResourcePKName],
|
|
1027
|
-
record: passkeyRecord,
|
|
1028
|
-
adminUser: adminUser,
|
|
1029
|
-
});
|
|
1030
|
-
} catch (error) {
|
|
1031
|
-
return { ok: false, error: 'Error deleting passkey: ' + error.message };
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
return { ok: true };
|
|
1035
|
-
}
|
|
1036
|
-
});
|
|
1037
|
-
server.endpoint({
|
|
1038
|
-
method: 'POST',
|
|
1039
|
-
path: `/plugin/passkeys/renamePasskey`,
|
|
1040
|
-
noAuth: false,
|
|
1041
|
-
handler: async ({body, adminUser }) => {
|
|
1042
|
-
const credentialID = body.passkeyId;
|
|
1043
|
-
const newName = body.newName;
|
|
1044
|
-
if (!credentialID) {
|
|
1045
|
-
return { ok: false, error: 'Passkey ID is required' };
|
|
1046
|
-
}
|
|
1047
|
-
if (!newName) {
|
|
1048
|
-
return { ok: false, error: 'New name is required' };
|
|
1049
|
-
}
|
|
1050
|
-
const credResource = this.adminforth.config.resources.find(r => r.resourceId === this.options.passkeys.credentialResourceID);
|
|
1051
|
-
if (!credResource) {
|
|
1052
|
-
throw new Error('Credential resource not found.');
|
|
1053
|
-
}
|
|
1054
|
-
const credResourcePKColumn = credResource.columns.find(c => c.primaryKey);
|
|
1055
|
-
if (!credResourcePKColumn) {
|
|
1056
|
-
throw new Error('Credential resource primary key not found.');
|
|
1057
|
-
}
|
|
1058
|
-
const credResourcePKName = credResourcePKColumn.name;
|
|
1059
|
-
const passkeyRecord = await this.adminforth.resource(this.options.passkeys.credentialResourceID).get([Filters.EQ(this.options.passkeys.credentialIdFieldName, credentialID), Filters.EQ(this.options.passkeys.credentialUserIdFieldName, adminUser.pk)]);
|
|
1060
|
-
if (!passkeyRecord) {
|
|
1061
|
-
return { ok: false, error: 'Passkey not found' };
|
|
1062
|
-
}
|
|
1063
|
-
const meta = this.parseIfNeeded(passkeyRecord[this.options.passkeys.credentialMetaFieldName]);
|
|
1064
|
-
meta.name = newName;
|
|
1065
|
-
const newRecord = { ...passkeyRecord, [this.options.passkeys.credentialMetaFieldName]: JSON.stringify(meta) };
|
|
1066
|
-
try {
|
|
1067
|
-
const credResource = this.adminforth.config.resources.find(r => r.resourceId === this.options.passkeys.credentialResourceID);
|
|
1068
|
-
if (!credResource) {
|
|
1069
|
-
throw new Error('Credential resource not found.');
|
|
1070
|
-
}
|
|
1071
|
-
await this.adminforth.updateResourceRecord({
|
|
1072
|
-
resource: credResource,
|
|
1073
|
-
recordId: passkeyRecord[credResourcePKName],
|
|
1074
|
-
oldRecord: passkeyRecord,
|
|
1075
|
-
record: newRecord,
|
|
1076
|
-
adminUser: adminUser
|
|
1077
|
-
});
|
|
1078
|
-
} catch (error) {
|
|
1079
|
-
return { ok: false, error: 'Error renaming passkey: ' + error.message };
|
|
1080
|
-
}
|
|
1081
|
-
return { ok: true };
|
|
1082
|
-
}
|
|
1083
|
-
});
|
|
1084
|
-
server.endpoint({
|
|
1085
|
-
method: 'POST',
|
|
1086
|
-
path: `/plugin/passkeys/checkIfUserHasPasskeys`,
|
|
1087
|
-
noAuth: true,
|
|
1088
|
-
handler: async ({ cookies }) => {
|
|
1089
|
-
if (!this.options.passkeys) {
|
|
1090
|
-
return { ok: false, hasPasskeys: false };
|
|
1091
|
-
}
|
|
1092
|
-
const totpTemporaryJWT = this.adminforth.auth.getCustomCookie({cookies: cookies, name: "2FaTemporaryJWT"});
|
|
1093
|
-
const decoded = await this.adminforth.auth.verify(totpTemporaryJWT, 'temp2FA');
|
|
1094
|
-
if (!decoded)
|
|
1095
|
-
return { ok: false, error:'Invalid token'}
|
|
1096
|
-
if (decoded.newSecret) {
|
|
1097
|
-
return { ok: true, hasPasskeys: false };
|
|
1098
|
-
}
|
|
1099
|
-
const passkeys = await this.adminforth.resource(this.options.passkeys.credentialResourceID).list( [Filters.EQ(this.options.passkeys.credentialUserIdFieldName, decoded.pk)] );
|
|
1100
|
-
if (passkeys && passkeys.length > 0) {
|
|
1101
|
-
return { ok: true, hasPasskeys: true };
|
|
1102
|
-
} else {
|
|
1103
|
-
return { ok: true, hasPasskeys: false };
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
});
|
|
1107
|
-
server.endpoint({
|
|
1108
|
-
method: 'POST',
|
|
1109
|
-
path: `/plugin/passkeys/resolveVerifyAuto`,
|
|
1110
|
-
noAuth: false,
|
|
1111
|
-
handler: async ({ body, adminUser, response, cookies, headers }) => {
|
|
1112
|
-
const sessionsIds = body?.sessionsIds;
|
|
1113
|
-
const confirmationResult = body?.confirmationResult;
|
|
1114
|
-
const idsToResolve = Array.isArray(sessionsIds) ? sessionsIds : [];
|
|
1115
|
-
|
|
1116
|
-
const resolveAllIdsAsFailed = (message) => {
|
|
1117
|
-
for (const id of idsToResolve) {
|
|
1118
|
-
this.resolveResponse(id, { ok: false, error: message });
|
|
1119
|
-
}
|
|
1120
|
-
return { ok: false, error: message };
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
try {
|
|
1124
|
-
if (!idsToResolve.length || !confirmationResult) {
|
|
1125
|
-
return(resolveAllIdsAsFailed('Confirmation window was closed or did not return required data'));
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
for (const id of idsToResolve) {
|
|
1129
|
-
const validationResult = await this.adminforth.auth.verify(id, 'auto2FA', false);
|
|
1130
|
-
if (!validationResult) {
|
|
1131
|
-
return(resolveAllIdsAsFailed('Invalid session ID or confirmation result'));
|
|
1132
|
-
}
|
|
1133
|
-
if (validationResult.adminUserPk !== adminUser.pk) {
|
|
1134
|
-
return(resolveAllIdsAsFailed('Session does not belong to the authenticated user'));
|
|
1135
|
-
}
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
const verificationResult = await this.verify(confirmationResult, {
|
|
1139
|
-
adminUser: adminUser,
|
|
1140
|
-
userPk: adminUser.pk,
|
|
1141
|
-
cookies: cookies,
|
|
1142
|
-
response: response,
|
|
1143
|
-
extra: {
|
|
1144
|
-
headers: headers,
|
|
1145
|
-
} as HttpExtra
|
|
1146
|
-
});
|
|
1147
|
-
if ( !verificationResult || !('ok' in verificationResult) ) {
|
|
1148
|
-
return(resolveAllIdsAsFailed('Verification failed'));
|
|
1149
|
-
}
|
|
1150
|
-
if ('ok' in verificationResult && verificationResult.ok){
|
|
1151
|
-
for (const id of idsToResolve) {
|
|
1152
|
-
this.resolveResponse(id, { ok: true, passkeyConfirmed: verificationResult });
|
|
1153
|
-
}
|
|
1154
|
-
return { ok: true };
|
|
1155
|
-
}
|
|
1156
|
-
return(resolveAllIdsAsFailed('Verification failed'));
|
|
1157
|
-
} catch (error) {
|
|
1158
|
-
console.error('[AdminForth 2FA] Error resolving automatic 2FA verification', error);
|
|
1159
|
-
return(resolveAllIdsAsFailed(error instanceof Error ? error.message : 'Verification failed'));
|
|
1160
|
-
}
|
|
1161
|
-
}
|
|
1162
|
-
});
|
|
332
|
+
registerTwoFaEndpoints(server, createTwoFaHandlers(this.ctx));
|
|
333
|
+
registerPasskeyEndpoints(server, createPasskeyHandlers(this.ctx));
|
|
1163
334
|
}
|
|
1164
335
|
}
|