@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.
Files changed (54) hide show
  1. package/build.log +2 -2
  2. package/custom/TwoFAModal.vue +4 -4
  3. package/custom/TwoFactorsConfirmation.vue +9 -10
  4. package/custom/TwoFactorsPasskeysSettings.vue +10 -10
  5. package/custom/utils.js +7 -8
  6. package/dist/custom/TwoFAModal.vue +4 -4
  7. package/dist/custom/TwoFactorsConfirmation.vue +9 -10
  8. package/dist/custom/TwoFactorsPasskeysSettings.vue +10 -10
  9. package/dist/custom/utils.js +7 -8
  10. package/dist/endpoints/registerPasskeyEndpoints.d.ts +2 -0
  11. package/dist/endpoints/registerPasskeyEndpoints.js +59 -0
  12. package/dist/endpoints/registerTwoFaEndpoints.d.ts +2 -0
  13. package/dist/endpoints/registerTwoFaEndpoints.js +46 -0
  14. package/dist/handlers/createPasskeyHandlers.d.ts +48 -0
  15. package/dist/handlers/createPasskeyHandlers.js +99 -0
  16. package/dist/handlers/createTwoFaHandlers.d.ts +56 -0
  17. package/dist/handlers/createTwoFaHandlers.js +167 -0
  18. package/dist/index.d.ts +37 -0
  19. package/dist/index.js +51 -858
  20. package/dist/repositories/passkeyRepository.d.ts +18 -0
  21. package/dist/repositories/passkeyRepository.js +89 -0
  22. package/dist/repositories/userRepository.d.ts +14 -0
  23. package/dist/repositories/userRepository.js +45 -0
  24. package/dist/services/cookieService.d.ts +52 -0
  25. package/dist/services/cookieService.js +144 -0
  26. package/dist/services/passkeyService.d.ts +84 -0
  27. package/dist/services/passkeyService.js +369 -0
  28. package/dist/services/totpService.d.ts +21 -0
  29. package/dist/services/totpService.js +43 -0
  30. package/dist/services/verifyMfaConfirmation.d.ts +30 -0
  31. package/dist/services/verifyMfaConfirmation.js +67 -0
  32. package/dist/types.d.ts +116 -0
  33. package/dist/utils/errors.d.ts +4 -0
  34. package/dist/utils/errors.js +9 -0
  35. package/dist/utils/parsePeriod.d.ts +1 -0
  36. package/dist/utils/parsePeriod.js +17 -0
  37. package/dist/utils/types.d.ts +13 -0
  38. package/dist/utils/types.js +1 -0
  39. package/endpoints/registerPasskeyEndpoints.ts +61 -0
  40. package/endpoints/registerTwoFaEndpoints.ts +45 -0
  41. package/handlers/createPasskeyHandlers.ts +103 -0
  42. package/handlers/createTwoFaHandlers.ts +172 -0
  43. package/index.ts +57 -886
  44. package/package.json +1 -1
  45. package/repositories/passkeyRepository.ts +78 -0
  46. package/repositories/userRepository.ts +42 -0
  47. package/services/cookieService.ts +165 -0
  48. package/services/passkeyService.ts +375 -0
  49. package/services/totpService.ts +37 -0
  50. package/services/verifyMfaConfirmation.ts +92 -0
  51. package/tsconfig.json +1 -1
  52. package/utils/errors.ts +13 -0
  53. package/utils/parsePeriod.ts +19 -0
  54. package/utils/types.ts +17 -0
package/index.ts CHANGED
@@ -1,23 +1,28 @@
1
- import { AdminForthPlugin, Filters, suggestIfTypo, HttpExtra, convertPeriodToSeconds } from "adminforth";
2
- import type { AdminForthResource, AdminUser, IAdminForth, IHttpServer, IAdminForthAuth, BeforeLoginConfirmationFunction, IAdminForthHttpResponse } from "adminforth";
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
- generateRegistrationOptions,
7
- verifyRegistrationResponse,
8
- generateAuthenticationOptions,
9
- verifyAuthenticationResponse
10
- } from '@simplewebauthn/server';
11
- import { isoUint8Array, isoBase64URL } from '@simplewebauthn/server/helpers';
12
- import aaguids from './custom/aaguid.json';
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 = await this.options.usersFilterToAllowSkipSetup(adminUser); // recieve result of usersFilterToAllowSkipSetup
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
- //recieve user's record
82
- const usersResource = this.adminforth.config.resources.find(r => r.resourceId === this.adminforth.config.auth.usersResourceId);
83
- const usersPrimaryKeyColumn = usersResource.columns.find((col) => col.primaryKey);
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 = userRecord[this.options.twoFaSecretFieldName];
49
+ const users2FASecret = this.userRepository.getSecret(userRecord);
89
50
  //check if user has any passkeys registered
90
- const passkeys = await this.adminforth.resource(this.options.passkeys.credentialResourceID).list( [Filters.EQ(this.options.passkeys.credentialUserIdFieldName, adminUser.dbUser[userPkFieldName])] );
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 || (passkeys && passkeys.length > 0)) {
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: Record<string, any>,
198
- opts?: { adminUser?: AdminUser; userPk?: string; cookies?: any, response?: IAdminForthHttpResponse, extra?: HttpExtra }
104
+ confirmationResult: MfaConfirmationResult,
105
+ opts: VerifyOptions
199
106
  ): Promise<{ ok: true } | { error: string }> {
200
- if (!confirmationResult) return { error: "Confirmation result is required" };
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
- const suggestionPeriod = this.parsePeriod(this.options.passkeys?.suggestionPeriod || "5d");
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[this.options.twoFaSecretFieldName]
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
- if ( this.options.passkeys.allowLoginWithPasskeys !== false ) {
392
- this.options.passkeys.allowLoginWithPasskeys = true;
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[this.options.twoFaSecretFieldName]
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 authResource = adminforth.config.resources.find((res)=>res.resourceId === adminforth.config.auth.usersResourceId )
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
- const tempSecret = twofactor.generateSecret({name: issuerName,account: userName})
532
- newSecret = tempSecret.secret;
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
- const value = this.adminforth.auth.issueJWT({userName, issuer:issuerName, pk:userPk, userCanSkipSetup, sessionDuration }, 'temp2FA', this.options.passkeys?.challengeValidityPeriod || '1m');
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.endpoint({
562
- method: 'POST',
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
  }