@adminforth/two-factors-auth 2.2.2 → 2.3.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 CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  sending incremental file list
6
6
  custom/
7
+ custom/LoginWithPasskeyButton.vue
7
8
  custom/TwoFAModal.vue
8
9
  custom/TwoFactorsConfirmation.vue
9
10
  custom/TwoFactorsPasskeysSettings.vue
@@ -14,5 +15,5 @@ custom/package.json
14
15
  custom/tsconfig.json
15
16
  custom/utils.js
16
17
 
17
- sent 269,512 bytes received 191 bytes 539,406.00 bytes/sec
18
- total size is 268,776 speedup is 1.00
18
+ sent 271,266 bytes received 210 bytes 542,952.00 bytes/sec
19
+ total size is 270,439 speedup is 1.00
@@ -0,0 +1,55 @@
1
+ <template>
2
+
3
+ <Button
4
+ class="w-full !py-1"
5
+ @click="handleLoginWithPasskey"
6
+ >
7
+ <IconShieldOutline class="w-7 h-8" />
8
+ <span class="ml-2">Continue with Passkey</span>
9
+ </Button>
10
+
11
+ </template>
12
+
13
+
14
+
15
+ <script setup lang="ts">
16
+ import { IconShieldOutline } from '@iconify-prerendered/vue-flowbite';
17
+ import { getPasskey } from "./utils.js";
18
+ import { callAdminForthApi } from '@/utils';
19
+ import { useUserStore } from '@/stores/user';
20
+ import { Button } from '@/afcl';
21
+ import adminforth from '@/adminforth';
22
+ import { useRouter, useRoute } from 'vue-router';
23
+
24
+ const userStore = useUserStore();
25
+ const router = useRouter();
26
+
27
+ async function handleLoginWithPasskey() {
28
+ const passkeyOptions = await getPasskey();
29
+ try {
30
+ const resp = await callAdminForthApi({
31
+ method: 'POST',
32
+ path: '/plugin/twofa/confirmLoginWithPasskey',
33
+ body: {
34
+ passkeyResponse: passkeyOptions
35
+ }
36
+ });
37
+ if ( resp.error ) {
38
+ console.error("Login failed:", resp.error);
39
+ adminforth.alert({message: 'Error: ' + resp.error, variant: 'warning'});
40
+ return;
41
+ }
42
+
43
+ if ( resp.allowedLogin === true ) {
44
+ await userStore.finishLogin();
45
+ } else if (resp.redirectTo) {
46
+ router.push(resp.redirectTo);
47
+ } else {
48
+ console.error("Login not allowed:", resp.error);
49
+ adminforth.alert({message: 'Error: ' + (resp.error || 'Login not allowed'), variant: 'warning'});
50
+ }
51
+ } catch (error) {
52
+ console.error("Error during passkey login:", error);
53
+ }
54
+ }
55
+ </script>
@@ -0,0 +1,55 @@
1
+ <template>
2
+
3
+ <Button
4
+ class="w-full !py-1"
5
+ @click="handleLoginWithPasskey"
6
+ >
7
+ <IconShieldOutline class="w-7 h-8" />
8
+ <span class="ml-2">Continue with Passkey</span>
9
+ </Button>
10
+
11
+ </template>
12
+
13
+
14
+
15
+ <script setup lang="ts">
16
+ import { IconShieldOutline } from '@iconify-prerendered/vue-flowbite';
17
+ import { getPasskey } from "./utils.js";
18
+ import { callAdminForthApi } from '@/utils';
19
+ import { useUserStore } from '@/stores/user';
20
+ import { Button } from '@/afcl';
21
+ import adminforth from '@/adminforth';
22
+ import { useRouter, useRoute } from 'vue-router';
23
+
24
+ const userStore = useUserStore();
25
+ const router = useRouter();
26
+
27
+ async function handleLoginWithPasskey() {
28
+ const passkeyOptions = await getPasskey();
29
+ try {
30
+ const resp = await callAdminForthApi({
31
+ method: 'POST',
32
+ path: '/plugin/twofa/confirmLoginWithPasskey',
33
+ body: {
34
+ passkeyResponse: passkeyOptions
35
+ }
36
+ });
37
+ if ( resp.error ) {
38
+ console.error("Login failed:", resp.error);
39
+ adminforth.alert({message: 'Error: ' + resp.error, variant: 'warning'});
40
+ return;
41
+ }
42
+
43
+ if ( resp.allowedLogin === true ) {
44
+ await userStore.finishLogin();
45
+ } else if (resp.redirectTo) {
46
+ router.push(resp.redirectTo);
47
+ } else {
48
+ console.error("Login not allowed:", resp.error);
49
+ adminforth.alert({message: 'Error: ' + (resp.error || 'Login not allowed'), variant: 'warning'});
50
+ }
51
+ } catch (error) {
52
+ console.error("Error during passkey login:", error);
53
+ }
54
+ }
55
+ </script>
package/dist/index.js CHANGED
@@ -163,6 +163,13 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
163
163
  slug: 'passkeys',
164
164
  component: this.componentPath('TwoFactorsPasskeysSettings.vue'),
165
165
  });
166
+ if (this.options.passkeys.allowLoginWithPasskeys !== false) {
167
+ this.options.passkeys.allowLoginWithPasskeys = true;
168
+ if (!this.adminforth.config.customization.loginPageInjections) {
169
+ this.adminforth.config.customization.loginPageInjections = { underInputs: [], panelHeader: [] };
170
+ }
171
+ this.adminforth.config.customization.loginPageInjections.underInputs.push({ file: this.componentPath('LoginWithPasskeyButton.vue'), meta: {} });
172
+ }
166
173
  }
167
174
  }
168
175
  validateConfigAfterDiscover(adminforth, resourceConfig) {
@@ -248,7 +255,10 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
248
255
  const beforeLoginConfirmation = this.adminforth.config.auth.beforeLoginConfirmation;
249
256
  const beforeLoginConfirmationArray = Array.isArray(beforeLoginConfirmation) ? beforeLoginConfirmation : [beforeLoginConfirmation];
250
257
  beforeLoginConfirmationArray.push((_a) => __awaiter(this, [_a], void 0, function* ({ adminUser, response, extra }) {
251
- var _b;
258
+ var _b, _c;
259
+ if (((_b = extra === null || extra === void 0 ? void 0 : extra.body) === null || _b === void 0 ? void 0 : _b.loginAllowedByPasskeyDirectSignIn) === true) {
260
+ return { body: { loginAllowed: true }, ok: true };
261
+ }
252
262
  const secret = adminUser.dbUser[this.options.twoFaSecretFieldName];
253
263
  const userName = adminUser.dbUser[adminforth.config.auth.usernameField];
254
264
  const brandName = adminforth.config.customization.brandName;
@@ -259,7 +269,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
259
269
  const authResource = adminforth.config.resources.find((res) => res.resourceId === adminforth.config.auth.usersResourceId);
260
270
  const authPk = authResource.columns.find((col) => col.primaryKey).name;
261
271
  const userPk = adminUser.dbUser[authPk];
262
- const rememberMe = ((_b = extra === null || extra === void 0 ? void 0 : extra.body) === null || _b === void 0 ? void 0 : _b.rememberMe) || false;
272
+ const rememberMe = ((_c = extra === null || extra === void 0 ? void 0 : extra.body) === null || _c === void 0 ? void 0 : _c.rememberMe) || false;
263
273
  const rememberMeDays = rememberMe ? adminforth.config.auth.rememberMeDays || 30 : 1;
264
274
  let newSecret = null;
265
275
  const userNeeds2FA = this.options.usersFilterToApply ? this.options.usersFilterToApply(adminUser) : true;
@@ -376,29 +386,107 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
376
386
  })
377
387
  });
378
388
  server.endpoint({
379
- method: "GET",
380
- path: "/plugin/twofa/skip-allow",
389
+ method: 'POST',
390
+ path: `/plugin/twofa/confirmLoginWithPasskey`,
381
391
  noAuth: true,
382
- handler: (_a) => __awaiter(this, [_a], void 0, function* ({ cookies }) {
383
- const totpTemporaryJWT = this.adminforth.auth.getCustomCookie({
384
- cookies,
385
- name: `2FaTemporaryJWT`,
386
- });
387
- const decoded = yield this.adminforth.auth.verify(totpTemporaryJWT, "temp2FA");
392
+ handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, response, cookies, headers, requestUrl, query }) {
393
+ if (this.options.passkeys.allowLoginWithPasskeys !== true) {
394
+ return { error: 'Login with passkeys is not allowed' };
395
+ }
396
+ const passkeyResponse = body.passkeyResponse;
397
+ if (!passkeyResponse) {
398
+ return { error: 'Passkey response is required' };
399
+ }
400
+ const totpTemporaryJWT = this.adminforth.auth.getCustomCookie({ cookies: cookies, name: "passkeyLoginTemporaryJWT" });
401
+ if (!totpTemporaryJWT) {
402
+ return { error: 'Authentication session is expired. Please, try again' };
403
+ }
404
+ const decoded = yield this.adminforth.auth.verify(totpTemporaryJWT, 'tempLoginPasskeyChallenge', false);
388
405
  if (!decoded) {
389
- return { status: "error", message: "Invalid token" };
406
+ return { error: 'Authentication session is expired. Please, try again' };
390
407
  }
391
- if (!decoded.newSecret) {
392
- return { status: "ok", skipAllowed: false };
408
+ let parsedPasskeyResponse;
409
+ try {
410
+ parsedPasskeyResponse = JSON.parse(passkeyResponse.response);
393
411
  }
394
- else {
395
- return {
396
- status: "ok",
397
- skipAllowed: decoded.userCanSkipSetup,
398
- };
412
+ catch (e) {
413
+ return { error: 'Malformed passkey response' };
399
414
  }
400
- }),
401
- });
415
+ const credential_id = parsedPasskeyResponse.id;
416
+ if (!credential_id) {
417
+ return { error: 'Credential ID is required' };
418
+ }
419
+ const passkeyRecord = yield this.adminforth.resource(this.options.passkeys.credentialResourceID).get([Filters.EQ(this.options.passkeys.credentialIdFieldName, credential_id)]);
420
+ if (!passkeyRecord) {
421
+ return { error: 'Passkey not found' };
422
+ }
423
+ const userPk = passkeyRecord[this.options.passkeys.credentialUserIdFieldName];
424
+ if (!userPk) {
425
+ return { error: 'User ID not found in passkey record' };
426
+ }
427
+ const userResourceId = this.adminforth.config.auth.usersResourceId;
428
+ const usersResource = this.adminforth.config.resources.find(r => r.resourceId === userResourceId);
429
+ const usersPrimaryKeyColumn = usersResource.columns.find((col) => col.primaryKey);
430
+ const usersPrimaryKeyFieldName = usersPrimaryKeyColumn.name;
431
+ const user = yield this.adminforth.resource(userResourceId).get([Filters.EQ(usersPrimaryKeyFieldName, userPk)]);
432
+ if (!user) {
433
+ return { error: 'User not found' };
434
+ }
435
+ const verificationResult = yield this.verifyPasskeyResponse(passkeyResponse, userPk, decoded);
436
+ if (!verificationResult.ok || !verificationResult.passkeyConfirmed) {
437
+ return { error: 'Passkey verification failed' };
438
+ }
439
+ const username = user[this.adminforth.config.auth.usernameField];
440
+ const adminUser = {
441
+ dbUser: user,
442
+ pk: user.id,
443
+ username,
444
+ };
445
+ const toReturn = { allowedLogin: true, error: '' };
446
+ yield this.adminforth.restApi.processLoginCallbacks(adminUser, toReturn, response, {
447
+ headers,
448
+ cookies,
449
+ requestUrl,
450
+ query,
451
+ body: {
452
+ loginAllowedByPasskeyDirectSignIn: true
453
+ },
454
+ });
455
+ if (toReturn.allowedLogin === true) {
456
+ this.adminforth.auth.setAuthCookie({
457
+ response,
458
+ username,
459
+ pk: user.id,
460
+ expireInDays: this.options.passkeys.rememberDaysAfterPasskeyLogin ? this.options.passkeys.rememberDaysAfterPasskeyLogin : this.adminforth.config.auth.rememberMeDays,
461
+ });
462
+ }
463
+ return toReturn;
464
+ })
465
+ }),
466
+ server.endpoint({
467
+ method: "GET",
468
+ path: "/plugin/twofa/skip-allow",
469
+ noAuth: true,
470
+ handler: (_a) => __awaiter(this, [_a], void 0, function* ({ cookies }) {
471
+ const totpTemporaryJWT = this.adminforth.auth.getCustomCookie({
472
+ cookies,
473
+ name: `2FaTemporaryJWT`,
474
+ });
475
+ const decoded = yield this.adminforth.auth.verify(totpTemporaryJWT, "temp2FA");
476
+ if (!decoded) {
477
+ return { status: "error", message: "Invalid token" };
478
+ }
479
+ if (!decoded.newSecret) {
480
+ return { status: "ok", skipAllowed: false };
481
+ }
482
+ else {
483
+ return {
484
+ status: "ok",
485
+ skipAllowed: decoded.userCanSkipSetup,
486
+ };
487
+ }
488
+ }),
489
+ });
402
490
  server.endpoint({
403
491
  method: 'POST',
404
492
  path: `/plugin/twofa/verify`,
package/index.ts CHANGED
@@ -174,6 +174,14 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
174
174
  slug: 'passkeys',
175
175
  component: this.componentPath('TwoFactorsPasskeysSettings.vue'),
176
176
  });
177
+
178
+ if ( this.options.passkeys.allowLoginWithPasskeys !== false ) {
179
+ this.options.passkeys.allowLoginWithPasskeys = true;
180
+ if ( !this.adminforth.config.customization.loginPageInjections ) {
181
+ this.adminforth.config.customization.loginPageInjections = { underInputs: [], panelHeader: [] };
182
+ }
183
+ this.adminforth.config.customization.loginPageInjections.underInputs.push({ file: this.componentPath('LoginWithPasskeyButton.vue'), meta: {} });
184
+ }
177
185
  }
178
186
  }
179
187
 
@@ -281,6 +289,9 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
281
289
  const beforeLoginConfirmationArray = Array.isArray(beforeLoginConfirmation) ? beforeLoginConfirmation : [beforeLoginConfirmation];
282
290
  beforeLoginConfirmationArray.push(
283
291
  async({ adminUser, response, extra }: { adminUser: AdminUser, response: IAdminForthHttpResponse, extra?: any} )=> {
292
+ if (extra?.body?.loginAllowedByPasskeyDirectSignIn === true) {
293
+ return { body: { loginAllowed: true }, ok: true };
294
+ }
284
295
  const secret = adminUser.dbUser[this.options.twoFaSecretFieldName]
285
296
  const userName = adminUser.dbUser[adminforth.config.auth.usernameField]
286
297
  const brandName = adminforth.config.customization.brandName;
@@ -299,7 +310,6 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
299
310
  if (!userNeeds2FA){
300
311
  return { body:{loginAllowed: true}, ok: true}
301
312
  }
302
-
303
313
  const userCanSkipSetup = this.options.usersFilterToAllowSkipSetup ? this.options.usersFilterToAllowSkipSetup(adminUser) : false;
304
314
 
305
315
  if (!secret){
@@ -413,6 +423,95 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
413
423
  }
414
424
  }
415
425
  })
426
+ server.endpoint({
427
+ method: 'POST',
428
+ path: `/plugin/twofa/confirmLoginWithPasskey`,
429
+ noAuth: true,
430
+ handler: async ({ body, response, cookies, headers, requestUrl, query }) => {
431
+ if ( this.options.passkeys.allowLoginWithPasskeys !== true ) {
432
+ return { error: 'Login with passkeys is not allowed' };
433
+ }
434
+
435
+ const passkeyResponse = body.passkeyResponse;
436
+ if (!passkeyResponse) {
437
+ return { error: 'Passkey response is required' };
438
+ }
439
+
440
+ const totpTemporaryJWT = this.adminforth.auth.getCustomCookie({cookies: cookies, name: "passkeyLoginTemporaryJWT"});
441
+ if (!totpTemporaryJWT) {
442
+ return { error: 'Authentication session is expired. Please, try again' }
443
+ }
444
+
445
+ const decoded = await this.adminforth.auth.verify(totpTemporaryJWT, 'tempLoginPasskeyChallenge', false);
446
+ if (!decoded) {
447
+ return { error: 'Authentication session is expired. Please, try again' }
448
+ }
449
+
450
+ let parsedPasskeyResponse;
451
+ try {
452
+ parsedPasskeyResponse = JSON.parse(passkeyResponse.response);
453
+ } catch (e) {
454
+ return { error: 'Malformed passkey response' };
455
+ }
456
+ const credential_id = parsedPasskeyResponse.id;
457
+ if (!credential_id) {
458
+ return { error: 'Credential ID is required' };
459
+ }
460
+
461
+ const passkeyRecord = await this.adminforth.resource(this.options.passkeys.credentialResourceID).get([Filters.EQ(this.options.passkeys.credentialIdFieldName, credential_id)]);
462
+ if (!passkeyRecord) {
463
+ return { error: 'Passkey not found' };
464
+ }
465
+
466
+ const userPk = passkeyRecord[this.options.passkeys.credentialUserIdFieldName];
467
+ if (!userPk) {
468
+ return { error: 'User ID not found in passkey record' };
469
+ }
470
+
471
+ const userResourceId = this.adminforth.config.auth.usersResourceId;
472
+ const usersResource = this.adminforth.config.resources.find(r => r.resourceId === userResourceId);
473
+ const usersPrimaryKeyColumn = usersResource.columns.find((col) => col.primaryKey);
474
+ const usersPrimaryKeyFieldName = usersPrimaryKeyColumn.name;
475
+ const user = await this.adminforth.resource(userResourceId).get([Filters.EQ(usersPrimaryKeyFieldName, userPk)]);
476
+ if (!user) {
477
+ return { error: 'User not found' };
478
+ }
479
+
480
+ const verificationResult = await this.verifyPasskeyResponse(passkeyResponse, userPk, decoded);
481
+ if (!verificationResult.ok || !verificationResult.passkeyConfirmed) {
482
+ return { error: 'Passkey verification failed' };
483
+ }
484
+ const username = user[this.adminforth.config.auth.usernameField];
485
+
486
+ const adminUser = {
487
+ dbUser: user,
488
+ pk: user.id,
489
+ username,
490
+ };
491
+
492
+ const toReturn = { allowedLogin: true, error: '' };
493
+
494
+ await this.adminforth.restApi.processLoginCallbacks(adminUser, toReturn, response, {
495
+ headers,
496
+ cookies,
497
+ requestUrl,
498
+ query,
499
+ body: {
500
+ loginAllowedByPasskeyDirectSignIn: true
501
+ },
502
+ });
503
+
504
+ if ( toReturn.allowedLogin === true ) {
505
+ this.adminforth.auth.setAuthCookie({
506
+ response,
507
+ username,
508
+ pk: user.id,
509
+ expireInDays: this.options.passkeys.rememberDaysAfterPasskeyLogin ? this.options.passkeys.rememberDaysAfterPasskeyLogin : this.adminforth.config.auth.rememberMeDays,
510
+ });
511
+ }
512
+ return toReturn;
513
+ }
514
+ }),
416
515
  server.endpoint({
417
516
  method: "GET",
418
517
  path: "/plugin/twofa/skip-allow",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/two-factors-auth",
3
- "version": "2.2.2",
3
+ "version": "2.3.0",
4
4
  "description": "AdminForth plugin for two factors authentication",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/types.ts CHANGED
@@ -26,6 +26,16 @@ export type PluginOptions = {
26
26
  credentialIdFieldName: string,
27
27
  credentialMetaFieldName: string,
28
28
  credentialUserIdFieldName: string,
29
+
30
+ /**
31
+ * Allow login with Passkeys even if 2FA is not set up. Default is true.
32
+ */
33
+ allowLoginWithPasskeys?: boolean;
34
+
35
+ /**
36
+ * Remember user for number of days after login with Passkey, so that user won't be challenged with 2FA on every login.
37
+ */
38
+ rememberDaysAfterPasskeyLogin?: number;
29
39
  /**
30
40
  * Period between showing alert suggesting to set up Passkeys if not set up yet.
31
41
  */