@adminforth/two-factors-auth 1.9.2 → 1.10.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/index.ts CHANGED
@@ -2,6 +2,13 @@ import { AdminForthPlugin, Filters } from "adminforth";
2
2
  import type { AdminForthResource, AdminUser, IAdminForth, IHttpServer, IAdminForthAuth, BeforeLoginConfirmationFunction, IAdminForthHttpResponse } from "adminforth";
3
3
  import twofactor from 'node-2fa';
4
4
  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';
5
12
 
6
13
  export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
7
14
  options: PluginOptions;
@@ -47,23 +54,161 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
47
54
  return verified ? { ok: true } : { error: "Wrong or expired OTP code" };
48
55
  }
49
56
 
57
+ public parsePeriod(period?: string): number {
58
+ if (!period) return 0;
59
+
60
+ const match = /^(\d+)([dhms])$/.exec(period.trim());
61
+ if (!match) throw new Error(`Invalid suggestionPeriod format: ${period}`);
62
+
63
+ const value = parseInt(match[1], 10);
64
+ const unit = match[2];
65
+
66
+ switch (unit) {
67
+ case 'd': return value * 24 * 60 * 60 * 1000;
68
+ case 'h': return value * 60 * 60 * 1000;
69
+ case 'm': return value * 60 * 1000;
70
+ case 's': return value * 1000;
71
+ default: return value;
72
+ }
73
+ }
74
+
75
+ public async verifyPasskeyResponse(body: any, user_id: string, cookies: any) {
76
+ const settingsOrigin = this.options.passkeys?.settings.expectedOrigin;
77
+ const expectedOrigin = body.origin;
78
+ const expectedChallenge = cookies.challenge;
79
+ const expectedRPID = this.options.passkeys?.settings?.rp?.id || (new URL(settingsOrigin)).hostname;
80
+ const response = JSON.parse(body.response);
81
+ try {
82
+ if (settingsOrigin !== expectedOrigin) {
83
+ throw new Error('Invalid origin');
84
+ }
85
+ const cred = await this.adminforth.resource(this.options.passkeys.credentialResourceID).get([Filters.EQ(this.options.passkeys.credentialIdFieldName, response.id)]);
86
+ if (!cred) {
87
+ throw new Error('Credential not found.');
88
+ }
89
+ const credMeta = JSON.parse(cred[this.options.passkeys.credentialMetaFieldName]);
90
+ if (!credMeta || !credMeta.public_key) {
91
+ throw new Error('Credential public key not found.');
92
+ }
93
+ const userResourceId = this.adminforth.config.auth.usersResourceId;
94
+ const usersResource = this.adminforth.config.resources.find(r => r.resourceId === userResourceId);
95
+ const usersPrimaryKeyColumn = usersResource.columns.find((col) => col.primaryKey);
96
+ const usersPrimaryKeyFieldName = usersPrimaryKeyColumn.name;
97
+ const user = await this.adminforth.resource(userResourceId).get([Filters.EQ(usersPrimaryKeyFieldName, cred[this.options.passkeys.credentialUserIdFieldName])]);
98
+ if (!user || !user_id || user[usersPrimaryKeyFieldName] !== user_id) {
99
+ throw new Error('User not found.');
100
+ }
101
+ const { verified, authenticationInfo } = await verifyAuthenticationResponse({
102
+ response,
103
+ expectedChallenge,
104
+ expectedOrigin,
105
+ expectedRPID,
106
+ credential: {
107
+ id: cred[this.options.passkeys.credentialIdFieldName],
108
+ publicKey: isoBase64URL.toBuffer(credMeta.public_key),
109
+ counter: credMeta.counter,
110
+ transports: credMeta.transports,
111
+ },
112
+ requireUserVerification: this.options.passkeys?.settings.authenticatorSelection.userVerification === 'discouraged' ? false : true,
113
+ });
114
+
115
+ if (!verified) {
116
+ return { ok: false, error: 'User verification failed.' };
117
+ }
118
+ credMeta.counter = authenticationInfo.newCounter;
119
+ credMeta.last_used_at = new Date().toISOString();
120
+ await this.adminforth.resource(this.options.passkeys.credentialResourceID).update(cred[this.options.passkeys.credentialIdFieldName], { [this.options.passkeys.credentialMetaFieldName]: JSON.stringify(credMeta) });
121
+ return { ok: true, passkeyConfirmed: true };
122
+ } catch (e) {
123
+ return { ok: false, error: 'Error authenticating passkey: ' + e };
124
+ }
125
+ }
126
+
50
127
  modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
51
128
  super.modifyResourceConfig(adminforth, resourceConfig);
52
129
  this.adminforth = adminforth;
53
130
  this.adminForthAuth = adminforth.auth;
131
+ const suggestionPeriod = this.parsePeriod(this.options.passkeys?.suggestionPeriod || "5d");
132
+ const isPasskeysEnabled = this.options.passkeys ? true : false;
54
133
 
55
134
  const customPages = this.adminforth.config.customization.customPages
56
135
  customPages.push({
57
136
  path:'/confirm2fa',
58
- component: { file: this.componentPath('TwoFactorsConfirmation.vue'), meta: { customLayout: true }}
137
+ component: { file: this.componentPath('TwoFactorsConfirmation.vue'), meta: { customLayout: true, suggestionPeriod: suggestionPeriod, isPasskeysEnabled: isPasskeysEnabled } }
59
138
  })
60
139
  customPages.push({
61
140
  path:'/setup2fa',
62
- component: { file: this.componentPath('TwoFactorsSetup.vue'), meta: { title: 'Setup 2FA', customLayout: true }}
141
+ component: { file: this.componentPath('TwoFactorsSetup.vue'), meta: { title: 'Setup 2FA', customLayout: true, suggestionPeriod: suggestionPeriod, isPasskeysEnabled: isPasskeysEnabled } }
63
142
  })
64
143
  const everyPageBottomInjections = this.adminforth.config.customization.globalInjections.everyPageBottom || []
65
144
  everyPageBottomInjections.push({ file: this.componentPath('TwoFAModal.vue'), meta: {} })
66
145
  this.activate( resourceConfig, adminforth )
146
+
147
+ if ( this.options.passkeys ) {
148
+ if( !this.adminforth.config.auth.userMenuSettingsPages ){
149
+ this.adminforth.config.auth.userMenuSettingsPages = [];
150
+ }
151
+ this.adminforth.config.auth.userMenuSettingsPages.push({
152
+ icon: 'flowbite:lock-solid',
153
+ pageLabel: 'Passkeys',
154
+ slug: 'passkeys',
155
+ component: this.componentPath('TwoFactorsPasskeysSettings.vue'),
156
+ });
157
+ }
158
+ }
159
+
160
+ validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
161
+ if (this.options.passkeys) {
162
+
163
+ const adminForthResources = [];
164
+ for (const res of adminforth.config.resources) {
165
+ adminForthResources.push(res.resourceId);
166
+ }
167
+ if (!this.options.passkeys.credentialResourceID) {
168
+ throw new Error('Passkeys credentialResourceID is required');
169
+ }
170
+ if ( !(adminForthResources.includes(this.options.passkeys.credentialResourceID)) ) {
171
+ throw new Error('Passkeys credentialResourceID is not valid');
172
+ }
173
+ if (!this.options.passkeys.credentialIdFieldName) {
174
+ throw new Error('Passkeys credentialIdFieldName is required');
175
+ }
176
+ if (!this.options.passkeys.credentialMetaFieldName) {
177
+ throw new Error('Passkeys credentialMetaFieldName is required');
178
+ }
179
+ if (!this.options.passkeys.credentialUserIdFieldName) {
180
+ throw new Error('Passkeys credentialUserIdFieldName is required');
181
+ }
182
+ if (!this.options.passkeys.settings) {
183
+ throw new Error('Passkeys settings are required when passkeys option is enabled');
184
+ }
185
+ if (!this.options.passkeys.settings.expectedOrigin) {
186
+ throw new Error('Passkeys settings.expectedOrigin is required');
187
+ }
188
+ const origin = new URL( this.options.passkeys.settings.expectedOrigin ).origin;
189
+ if ( origin !== this.options.passkeys.settings.expectedOrigin ) {
190
+ throw new Error('Passkeys settings.expectedOrigin is not valid');
191
+ }
192
+ if (this.options.passkeys.settings.authenticatorSelection) {
193
+ if (this.options.passkeys.settings.authenticatorSelection.authenticatorAttachment) {
194
+ if ( !['platform', 'cross-platform', 'both'].includes(this.options.passkeys.settings.authenticatorSelection.authenticatorAttachment) ) {
195
+ throw new Error('Passkeys settings.authenticatorSelection.authenticatorAttachment is not valid');
196
+ }
197
+ }
198
+ if (this.options.passkeys.settings.authenticatorSelection.userVerification) {
199
+ if ( !['required', 'discouraged'].includes(this.options.passkeys.settings.authenticatorSelection.userVerification) ) {
200
+ throw new Error('Passkeys settings.authenticatorSelection.userVerification is not valid');
201
+ }
202
+ }
203
+ }
204
+
205
+ if (!this.options.passkeys.settings.user) {
206
+ throw new Error('Passkeys settings.user is required');
207
+ }
208
+ if (!this.options.passkeys.settings.user.nameField) {
209
+ throw new Error('Passkeys settings.user.nameField is required');
210
+ }
211
+ }
67
212
  }
68
213
 
69
214
  activate ( resourceConfig: AdminForthResource, adminforth: IAdminForth ){
@@ -93,8 +238,8 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
93
238
  const userName = adminUser.dbUser[adminforth.config.auth.usernameField]
94
239
  const brandName = adminforth.config.customization.brandName;
95
240
  const brandNameSlug = adminforth.config.customization.brandNameSlug;
96
- const issuerName = (this.options.customBrendPrefix && this.options.customBrendPrefix.trim())
97
- ? this.options.customBrendPrefix.trim()
241
+ const issuerName = (this.options.customBrandPrefix && this.options.customBrandPrefix.trim())
242
+ ? this.options.customBrandPrefix.trim()
98
243
  : brandName;
99
244
  const authResource = adminforth.config.resources.find((res)=>res.resourceId === adminforth.config.auth.usersResourceId )
100
245
  const authPk = authResource.columns.find((col)=>col.primaryKey).name
@@ -112,10 +257,22 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
112
257
 
113
258
  if (!secret){
114
259
  const tempSecret = twofactor.generateSecret({name: issuerName,account: userName})
115
- newSecret = tempSecret.secret
260
+ newSecret = tempSecret.secret;
261
+
262
+ const totpTemporaryJWT = this.adminforth.auth.issueJWT({userName, newSecret, issuer:issuerName, pk:userPk, userCanSkipSetup, rememberMeDays }, 'temp2FA', '2h');
263
+ this.adminforth.auth.setCustomCookie({response, payload: {name: "2FaTemporaryJWT", value: totpTemporaryJWT, expiry: 1 * 60 * 60 * 1000, httpOnly: true}});
264
+
265
+ return {
266
+ body:{
267
+ loginAllowed: false,
268
+ redirectTo: secret ? '/confirm2fa' : '/setup2fa',
269
+ },
270
+ ok: true
271
+ }
272
+
116
273
  } else {
117
- const value = this.adminforth.auth.issueJWT({userName, issuer:issuerName, pk:userPk, userCanSkipSetup, rememberMeDays }, 'tempTotp', '2h');
118
- response.setHeader('Set-Cookie', `adminforth_${brandNameSlug}_totpTemporaryJWT=${value}; Path=${this.adminforth.config.baseUrl || '/'}; HttpOnly; SameSite=Strict; max-age=3600; `);
274
+ const value = this.adminforth.auth.issueJWT({userName, issuer:issuerName, pk:userPk, userCanSkipSetup, rememberMeDays }, 'temp2FA', '2h');
275
+ this.adminforth.auth.setCustomCookie({response, payload: {name: "2FaTemporaryJWT", value: value, expiry: 1 * 60 * 60 * 1000, httpOnly: true}});
119
276
 
120
277
  return {
121
278
  body:{
@@ -125,16 +282,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
125
282
  ok: true
126
283
  }
127
284
  }
128
- const totpTemporaryJWT = this.adminforth.auth.issueJWT({userName, newSecret, issuer:issuerName, pk:userPk, userCanSkipSetup, rememberMeDays }, 'tempTotp', '2h');
129
- response.setHeader('Set-Cookie', `adminforth_${brandNameSlug}_totpTemporaryJWT=${totpTemporaryJWT}; Path=${this.adminforth.config.baseUrl || '/'}; HttpOnly; SameSite=Strict; Expires=${new Date(Date.now() + '1h').toUTCString() } `);
130
-
131
- return {
132
- body:{
133
- loginAllowed: false,
134
- redirectTo: secret ? '/confirm2fa' : '/setup2fa',
135
- },
136
- ok: true
137
- }
285
+
138
286
  })
139
287
  }
140
288
 
@@ -145,27 +293,29 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
145
293
  noAuth: true,
146
294
  handler: async (server) => {
147
295
  const toReturn = {totpJWT:null,status:'ok',}
148
- const brandNameSlug = this.adminforth.config.customization.brandNameSlug;
149
-
150
- const totpTemporaryJWT = server.cookies.find((cookie)=>cookie.key === `adminforth_${brandNameSlug}_totpTemporaryJWT`)?.value;
296
+ const totpTemporaryJWT = this.adminforth.auth.getCustomCookie({cookies: server.cookies, name: "2FaTemporaryJWT"});
151
297
  if (totpTemporaryJWT){
152
298
  toReturn.totpJWT = totpTemporaryJWT
153
299
  }
154
300
  return toReturn
155
301
  }
156
302
  })
303
+
304
+ // this enpoints sets final login cookie if 2nd factor is confirmed
157
305
  server.endpoint({
158
306
  method: 'POST',
159
- path: `/plugin/twofa/confirmSetup`,
307
+ path: `/plugin/twofa/confirmLogin`,
160
308
  noAuth: true,
161
309
  handler: async ({ body, adminUser, response, cookies }) => {
162
310
  const brandNameSlug = this.adminforth.config.customization.brandNameSlug;
163
- const totpTemporaryJWT = cookies.find((cookie)=>cookie.key === `adminforth_${brandNameSlug}_totpTemporaryJWT`)?.value;
164
- const decoded = await this.adminforth.auth.verify(totpTemporaryJWT, 'tempTotp');
165
- if (!decoded)
166
- return {status:'error',message:'Invalid token'}
311
+ const totpTemporaryJWT = this.adminforth.auth.getCustomCookie({cookies: cookies, name: "2FaTemporaryJWT"});
312
+ const decoded = await this.adminforth.auth.verify(totpTemporaryJWT, 'temp2FA');
313
+ if (!decoded) {
314
+ return { status:'error', message:'Invalid token' }
315
+ }
167
316
 
168
317
  if (decoded.newSecret) {
318
+ // set up standard TOTP request - ensured by presence of newSecret in temp2FA token
169
319
  const verified = body.skip && decoded.userCanSkipSetup ? true : twofactor.verifyToken(decoded.newSecret, body.code, this.options.timeStepWindow);
170
320
  if (verified) {
171
321
  this.connectors = this.adminforth.connectors
@@ -173,20 +323,38 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
173
323
  const connector = this.connectors[this.authResource.dataSource];
174
324
  await connector.updateRecord({resource:this.authResource, recordId:decoded.pk, newValues:{[this.options.twoFaSecretFieldName]: decoded.newSecret}})
175
325
  }
176
- this.adminforth.auth.removeCustomCookie({response, name:'totpTemporaryJWT'})
326
+ this.adminforth.auth.removeCustomCookie({response, name:'2FaTemporaryJWT'})
177
327
  this.adminforth.auth.setAuthCookie({expireInDays: decoded.rememberMeDays, response, username:decoded.userName, pk:decoded.pk})
178
328
  return { status: 'ok', allowedLogin: true }
179
329
  } else {
180
330
  return {error: 'Wrong or expired OTP code'}
181
331
  }
182
332
  } else {
183
- // user already has secret, get it
184
- this.connectors = this.adminforth.connectors
185
- const connector = this.connectors[this.authResource.dataSource];
186
- const user = await connector.getRecordByPrimaryKey(this.authResource, decoded.pk)
187
- const verified = twofactor.verifyToken(user[this.options.twoFaSecretFieldName], body.code, this.options.timeStepWindow);
333
+ // login with confirming existing TOTP or Passkey
334
+ let verified = null;
335
+ if (body.usePasskey && this.options.passkeys) {
336
+ // passkeys are enabled and user wants to use them
337
+ const passkeysCookies = this.adminforth.auth.getCustomCookie({cookies: cookies, name: `passkeyTemporaryJWT`});
338
+ if (!passkeysCookies) {
339
+ return { error: 'Passkey token is required' };
340
+ }
341
+ const decodedPasskeysCookies = await this.adminforth.auth.verify(passkeysCookies, 'tempPasskeyChallenge', false);
342
+ if (!decodedPasskeysCookies) {
343
+ return { error: 'Invalid passkey token' };
344
+ }
345
+ const res = await this.verifyPasskeyResponse(body.passkeyOptions, decoded.pk, decodedPasskeysCookies);
346
+ if (res.ok && res.passkeyConfirmed) {
347
+ verified = true;
348
+ }
349
+ } else {
350
+ // user already has TOTP secret, get it
351
+ this.connectors = this.adminforth.connectors
352
+ const connector = this.connectors[this.authResource.dataSource];
353
+ const user = await connector.getRecordByPrimaryKey(this.authResource, decoded.pk)
354
+ verified = twofactor.verifyToken(user[this.options.twoFaSecretFieldName], body.code, this.options.timeStepWindow);
355
+ }
188
356
  if (verified) {
189
- this.adminforth.auth.removeCustomCookie({response, name:'totpTemporaryJWT'})
357
+ this.adminforth.auth.removeCustomCookie({response, name:'2FaTemporaryJWT'})
190
358
  this.adminforth.auth.setAuthCookie({expireInDays: decoded.rememberMeDays, response, username:decoded.userName, pk:decoded.pk})
191
359
  return { status: 'ok', allowedLogin: true }
192
360
  } else {
@@ -200,13 +368,13 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
200
368
  path: "/plugin/twofa/skip-allow",
201
369
  noAuth: true,
202
370
  handler: async ({ cookies }) => {
203
- const brandNameSlug = this.adminforth.config.customization.brandNameSlug;
204
- const totpTemporaryJWT = cookies.find(
205
- (cookie) => cookie.key === `adminforth_${brandNameSlug}_totpTemporaryJWT`
206
- )?.value;
371
+ const totpTemporaryJWT = this.adminforth.auth.getCustomCookie({
372
+ cookies,
373
+ name: `2FaTemporaryJWT`,
374
+ });
207
375
  const decoded = await this.adminforth.auth.verify(
208
376
  totpTemporaryJWT,
209
- "tempTotp",
377
+ "temp2FA",
210
378
  );
211
379
  if (!decoded) {
212
380
  return { status: "error", message: "Invalid token" };
@@ -241,5 +409,263 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
241
409
  return verified ? { ok: true } : { error: 'Wrong or expired OTP code' };
242
410
  }
243
411
  });
412
+ server.endpoint({
413
+ method: 'POST',
414
+ path: `/plugin/passkeys/registerPasskeyRequest`,
415
+ noAuth: false,
416
+ handler: async ({ body, adminUser, response }) => {
417
+ const mode = body?.mode;
418
+ const settingsOrigin = this.options.passkeys?.settings.expectedOrigin;
419
+ const rp = {
420
+ name: this.options.passkeys?.settings.rp.name || this.adminforth.config.customization.brandName,
421
+ id: this.options.passkeys?.settings?.rp?.id || (new URL(settingsOrigin)).hostname,
422
+ };
423
+ const userResourceId = this.adminforth.config.auth.usersResourceId;
424
+ const usersResource = this.adminforth.config.resources.find(r => r.resourceId === userResourceId);
425
+ const usersPrimaryKeyColumn = usersResource.columns.find((col) => col.primaryKey);
426
+ const usersPrimaryKeyFieldName = usersPrimaryKeyColumn.name;
427
+ const userInfo = await this.adminforth.resource(userResourceId).get( [Filters.EQ(usersPrimaryKeyFieldName, adminUser.pk)] );
428
+ const user = {
429
+ id: adminUser.pk,
430
+ name: userInfo[this.options.passkeys?.settings.user.nameField],
431
+ displayName: userInfo[this.options.passkeys?.settings.user.displayNameField] ? userInfo[this.options.passkeys?.settings.user.displayNameField] : userInfo[this.options.passkeys?.settings.user.nameField],
432
+ };
433
+ const excludeCredentials = [];
434
+ const temp = await this.adminforth.resource(this.options.passkeys.credentialResourceID).list([Filters.EQ(this.options.passkeys.credentialUserIdFieldName, adminUser.pk)]);
435
+ for (const rec of temp) {
436
+ if (rec.credential_id && rec.credential_id.length > 0) {
437
+ const meta = JSON.parse(rec[this.options.passkeys.credentialMetaFieldName]);
438
+ excludeCredentials.push({
439
+ id: rec.credential_id,
440
+ type: "public-key",
441
+ transports: JSON.parse(meta.transports || '[]')
442
+ });
443
+ }
444
+ }
445
+ const options = await generateRegistrationOptions({
446
+ rpName: rp.name,
447
+ rpID: rp.id,
448
+ userID: isoUint8Array.fromUTF8String(user.id),
449
+ userName: user.name,
450
+ userDisplayName: user.displayName,
451
+ excludeCredentials,
452
+ authenticatorSelection: {
453
+ authenticatorAttachment: mode,
454
+ requireResidentKey: this.options.passkeys?.settings.authenticatorSelection.requireResidentKey || true,
455
+ userVerification: this.options.passkeys?.settings.authenticatorSelection.userVerification || "required"
456
+ },
457
+ });
458
+ const value = this.adminforth.auth.issueJWT({ "challenge": options.challenge }, 'tempPasskeyChallenge', '5m');
459
+ this.adminforth.auth.setCustomCookie({response, payload: {name: "passkeyTemporaryJWT", value: value, expiry: 1 * 60 * 60 * 1000, httpOnly: true}});
460
+ return { ok: true, data: options };
461
+ }
462
+ });
463
+ server.endpoint({
464
+ method: 'POST',
465
+ path: `/plugin/passkeys/finishRegisteringPasskey`,
466
+ noAuth: false,
467
+ handler: async ({body, adminUser, cookies }) => {
468
+ const passkeysCookies = this.adminforth.auth.getCustomCookie({cookies: cookies, name: "passkeyTemporaryJWT"});
469
+ if (!passkeysCookies) {
470
+ return { error: 'Passkey token is required' };
471
+ }
472
+ const decodedPasskeysCookies = await this.adminforth.auth.verify(passkeysCookies, 'tempPasskeyChallenge', false);
473
+ if (!decodedPasskeysCookies) {
474
+ return { error: 'Invalid passkey token' };
475
+ }
476
+ const settingsOrigin = this.options.passkeys?.settings.expectedOrigin;
477
+ const expectedOrigin = body.origin;
478
+ const expectedChallenge = decodedPasskeysCookies.challenge;
479
+ const expectedRPID = this.options.passkeys?.settings?.rp?.id || (new URL(settingsOrigin)).hostname;
480
+ const response = JSON.parse(body.credential);
481
+ try {
482
+ if (settingsOrigin !== expectedOrigin) {
483
+ throw new Error('Invalid origin');
484
+ }
485
+ const { verified, registrationInfo } = await verifyRegistrationResponse({
486
+ response,
487
+ expectedChallenge,
488
+ expectedOrigin,
489
+ expectedRPID,
490
+ requireUserVerification: this.options.passkeys?.settings.authenticatorSelection.userVerification === 'discouraged' ? false : true,
491
+ });
492
+
493
+ if (!verified) {
494
+ throw new Error('Verification failed.');
495
+ }
496
+ const {
497
+ aaguid,
498
+ credential,
499
+ credentialBackedUp
500
+ } = registrationInfo;
501
+
502
+ const credentialPublicKey = credential.publicKey;
503
+ const credentialID = credential.id;
504
+
505
+ const base64CredentialID = credentialID;
506
+ const base64PublicKey = isoBase64URL.fromBuffer(credentialPublicKey);
507
+
508
+ const credResource = this.adminforth.config.resources.find(r => r.resourceId === this.options.passkeys.credentialResourceID);
509
+ if (!credResource) {
510
+ throw new Error('Credential resource not found.');
511
+ }
512
+ await this.adminforth.createResourceRecord({
513
+ resource: credResource,
514
+ record: {
515
+ [this.options.passkeys.credentialIdFieldName] : base64CredentialID,
516
+ [this.options.passkeys.credentialUserIdFieldName] : adminUser.pk,
517
+ [this.options.passkeys.credentialMetaFieldName] : JSON.stringify({
518
+ public_key : base64PublicKey,
519
+ public_key_algorithm : response.response.publicKeyAlgorithm,
520
+ sign_count : 0,
521
+ transports : JSON.stringify(response.response.transports),
522
+ created_at : new Date().toISOString(),
523
+ last_used_at : new Date().toISOString(),
524
+ aaguid : aaguid,
525
+ name : `Passkey ${adminUser.username}`,
526
+ }),
527
+ },
528
+ adminUser: adminUser
529
+ });
530
+ } catch (error) {
531
+ console.error(error);
532
+ return { error: 'Error registering passkey: ' + error.message };
533
+ }
534
+ return { ok: true };
535
+ }
536
+ });
537
+ server.endpoint({
538
+ method: 'POST',
539
+ path: `/plugin/passkeys/signInRequest`,
540
+ noAuth: true,
541
+ handler: async ({ response }) => {
542
+ try {
543
+ const options = await generateAuthenticationOptions({
544
+ rpID: this.options.passkeys?.settings.rp.id,
545
+ userVerification: this.options.passkeys?.settings.authenticatorSelection.userVerification || "required"
546
+ });
547
+ const value = this.adminforth.auth.issueJWT({ "challenge": options.challenge }, 'tempPasskeyChallenge', '5m');
548
+ this.adminforth.auth.setCustomCookie({response, payload: {name: `passkeyTemporaryJWT`, value: value, expiry: 1 * 60 * 60 * 1000, httpOnly: true}});
549
+ return { ok: true, data: options };
550
+ } catch (e) {
551
+ return { ok: false, error: e };
552
+ }
553
+ }
554
+ });
555
+ server.endpoint({
556
+ method: 'GET',
557
+ path: `/plugin/passkeys/getPasskeys`,
558
+ noAuth: false,
559
+ handler: async ({ adminUser }) => {
560
+ let passkeys;
561
+ try {
562
+ passkeys = await this.adminforth.resource(this.options.passkeys.credentialResourceID).list( [Filters.EQ(this.options.passkeys.credentialUserIdFieldName, adminUser.pk)] );
563
+ } catch (error) {
564
+ return { ok: false, error: 'Error fetching passkeys: ' + error.message };
565
+ }
566
+ let dataToReturn = [];
567
+ for (const pk of passkeys) {
568
+ const parsedKey = JSON.parse(pk[this.options.passkeys.credentialMetaFieldName]);
569
+ dataToReturn.push({
570
+ name: parsedKey.name,
571
+ created_at: parsedKey.created_at,
572
+ last_used_at: parsedKey.last_used_at,
573
+ id: pk[this.options.passkeys.credentialIdFieldName],
574
+ });
575
+ }
576
+ return { ok: true, data: dataToReturn, authenticatorAttachment: this.options.passkeys?.settings.authenticatorSelection.authenticatorAttachment || 'both' };
577
+ }
578
+ });
579
+ server.endpoint({
580
+ method: 'DELETE',
581
+ path: `/plugin/passkeys/deletePasskey`,
582
+ noAuth: false,
583
+ handler: async ({body, adminUser }) => {
584
+ const passkeyId = body.passkeyId;
585
+ if (!passkeyId) {
586
+ return { ok: false, error: 'Passkey ID is required' };
587
+ }
588
+
589
+ const passkeyRecord = await this.adminforth.resource(this.options.passkeys.credentialResourceID).get([Filters.EQ(this.options.passkeys.credentialIdFieldName, passkeyId), Filters.EQ(this.options.passkeys.credentialUserIdFieldName, adminUser.pk)]);
590
+ if (!passkeyRecord) {
591
+ return { ok: false, error: 'Passkey not found' };
592
+ }
593
+ try {
594
+ const credResource = this.adminforth.config.resources.find(r => r.resourceId === this.options.passkeys.credentialResourceID);
595
+ if (!credResource) {
596
+ throw new Error('Credential resource not found.');
597
+ }
598
+ await this.adminforth.deleteResourceRecord({
599
+ resource: credResource,
600
+ recordId: passkeyId,
601
+ record: passkeyRecord,
602
+ adminUser: adminUser,
603
+ });
604
+ } catch (error) {
605
+ return { ok: false, error: 'Error deleting passkey: ' + error.message };
606
+ }
607
+
608
+ return { ok: true };
609
+ }
610
+ });
611
+ server.endpoint({
612
+ method: 'POST',
613
+ path: `/plugin/passkeys/renamePasskey`,
614
+ noAuth: false,
615
+ handler: async ({body, adminUser }) => {
616
+ const passkeyId = body.passkeyId;
617
+ const newName = body.newName;
618
+ if (!passkeyId) {
619
+ return { ok: false, error: 'Passkey ID is required' };
620
+ }
621
+ if (!newName) {
622
+ return { ok: false, error: 'New name is required' };
623
+ }
624
+
625
+ const passkeyRecord = await this.adminforth.resource(this.options.passkeys.credentialResourceID).get([Filters.EQ(this.options.passkeys.credentialIdFieldName, passkeyId), Filters.EQ(this.options.passkeys.credentialUserIdFieldName, adminUser.pk)]);
626
+ if (!passkeyRecord) {
627
+ return { ok: false, error: 'Passkey not found' };
628
+ }
629
+ const meta = JSON.parse(passkeyRecord[this.options.passkeys.credentialMetaFieldName]);
630
+ meta.name = newName;
631
+ const newRecord = { ...passkeyRecord, [this.options.passkeys.credentialMetaFieldName]: JSON.stringify(meta) };
632
+ try {
633
+ const credResource = this.adminforth.config.resources.find(r => r.resourceId === this.options.passkeys.credentialResourceID);
634
+ if (!credResource) {
635
+ throw new Error('Credential resource not found.');
636
+ }
637
+ await this.adminforth.updateResourceRecord({
638
+ resource: credResource,
639
+ recordId: passkeyId,
640
+ oldRecord: passkeyRecord,
641
+ record: newRecord,
642
+ adminUser: adminUser
643
+ });
644
+ } catch (error) {
645
+ return { ok: false, error: 'Error renaming passkey: ' + error.message };
646
+ }
647
+ return { ok: true };
648
+ }
649
+ });
650
+ server.endpoint({
651
+ method: 'POST',
652
+ path: `/plugin/passkeys/checkIfUserHasPasskeys`,
653
+ noAuth: true,
654
+ handler: async ({ cookies }) => {
655
+ const totpTemporaryJWT = this.adminforth.auth.getCustomCookie({cookies: cookies, name: "2FaTemporaryJWT"});
656
+ const decoded = await this.adminforth.auth.verify(totpTemporaryJWT, 'temp2FA');
657
+ if (!decoded)
658
+ return { ok: false, error:'Invalid token'}
659
+ if (decoded.newSecret) {
660
+ return { ok: true, hasPasskeys: false };
661
+ }
662
+ const passkeys = await this.adminforth.resource(this.options.passkeys.credentialResourceID).list( [Filters.EQ(this.options.passkeys.credentialUserIdFieldName, decoded.pk)] );
663
+ if (passkeys && passkeys.length > 0) {
664
+ return { ok: true, hasPasskeys: true };
665
+ } else {
666
+ return { ok: true, hasPasskeys: false };
667
+ }
668
+ }
669
+ });
244
670
  }
245
671
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/two-factors-auth",
3
- "version": "1.9.2",
3
+ "version": "1.10.0",
4
4
  "description": "AdminForth plugin for two factors authentication",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -25,6 +25,8 @@
25
25
  "author": "devforth",
26
26
  "license": "ISC",
27
27
  "dependencies": {
28
+ "@simplewebauthn/server": "^13.2.1",
29
+ "adminforth": "^2.4.0-next.193",
28
30
  "node-2fa": "^2.0.3"
29
31
  },
30
32
  "devDependencies": {