diff --git a/index.ts b/index.ts index 551482e..f192a20 100644 --- a/index.ts +++ b/index.ts @@ -1,4 +1,4 @@ -import { AdminForthPlugin, Filters, suggestIfTypo, HttpExtra } from "adminforth"; +import { AdminForthPlugin, Filters, suggestIfTypo, HttpExtra, convertPeriodToSeconds } from "adminforth"; import type { AdminForthResource, AdminUser, IAdminForth, IHttpServer, IAdminForthAuth, BeforeLoginConfirmationFunction, IAdminForthHttpResponse } from "adminforth"; import twofactor from 'node-2fa'; import { PluginOptions } from "./types.js" @@ -29,6 +29,34 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { return `single`; } + private saveChallengeToStorage(challenge: string, expiresIn?: string): void { + const expiresInSeconds = expiresIn ? convertPeriodToSeconds(expiresIn) : undefined; + this.options.passkeys.keyValueAdapter.set(challenge, 'stub_value', expiresInSeconds); + } + + private async checkIfChallengeInBlackList(challenge: string): Promise { + const res = await this.options.passkeys.keyValueAdapter.get(challenge); + if (!res) { + this.saveChallengeToStorage(challenge, this.options.passkeys?.challengeValidityPeriod || '2m'); + return true; + } + return false; + } + + private async validateCookiesForPasskeyLogin(cookies: any): Promise<{ok: boolean, decodedPasskeysCookies?: any, error?: string}> { + const passkeysCookies = this.adminforth.auth.getCustomCookie({cookies: cookies, name: `passkeyLoginTemporaryJWT`}); + if (!passkeysCookies) { + return { ok: false, error: 'Passkey token is required' }; + } + + const decodedPasskeysCookies = await this.adminforth.auth.verify(passkeysCookies, 'tempLoginPasskeyChallenge', false); + const isChallangeValid = await this.checkIfChallengeInBlackList(decodedPasskeysCookies.challenge); + + if (!decodedPasskeysCookies || !isChallangeValid) { + return { ok: false, error: 'Invalid passkey' }; + } + return { ok: true, decodedPasskeysCookies }; + } public async checkIfSkipSetupAllowSkipVerify(adminUser: AdminUser): Promise<{ skipAllowed: boolean }> { if (this.options.usersFilterToAllowSkipSetup) { @@ -171,18 +199,11 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { return { error: "Wrong or expired OTP code" } }; } else if (confirmationResult.mode === "passkey") { - //TODO: fix ts-ignore after releasing new version of adminforth with updated types - //@ts-ignore - const passkeysCookies = this.adminforth.auth.getCustomCookie({cookies: cookies, name: `passkeyLoginTemporaryJWT`}); - if (!passkeysCookies) { - return { error: 'Passkey token is required' }; - } - - const decodedPasskeysCookies = await this.adminforth.auth.verify(passkeysCookies, 'tempLoginPasskeyChallenge', false); - if (!decodedPasskeysCookies) { - return { error: 'Invalid passkey' }; + const cookiesValidationResult = await this.validateCookiesForPasskeyLogin(cookies); + if (!cookiesValidationResult.ok) { + return { error: cookiesValidationResult.error }; } - const verificationResult = await this.verifyPasskeyResponse(confirmationResult.result, opts.userPk, decodedPasskeysCookies ); + const verificationResult = await this.verifyPasskeyResponse(confirmationResult.result, opts.userPk, cookiesValidationResult.decodedPasskeysCookies ); if (verificationResult.ok && verificationResult.passkeyConfirmed) { @@ -340,6 +361,10 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { throw new Error('Passkeys credentialIdFieldName is required'); } + if (!this.options.passkeys.keyValueAdapter) { + throw new Error('Passkeys keyValueAdapter is required'); + } + const credentialResource = adminforth.config.resources.find(r => r.resourceId === this.options.passkeys.credentialResourceID); const credentialIDField = credentialResource.columns.find(c => c.name === this.options.passkeys.credentialIdFieldName); if ( !credentialIDField ) { @@ -529,15 +554,11 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { let verified = null; if (body.usePasskey && this.options.passkeys) { // passkeys are enabled and user wants to use them - const passkeysCookies = this.adminforth.auth.getCustomCookie({cookies: cookies, name: `passkeyLoginTemporaryJWT`}); - if (!passkeysCookies) { - return { error: 'Passkey token is required' }; + const cookiesValidationResult = await this.validateCookiesForPasskeyLogin(cookies); + if (!cookiesValidationResult.ok) { + return { error: cookiesValidationResult.error }; } - const decodedPasskeysCookies = await this.adminforth.auth.verify(passkeysCookies, 'tempLoginPasskeyChallenge', false); - if (!decodedPasskeysCookies) { - return { error: 'Invalid passkey' }; - } - const res = await this.verifyPasskeyResponse(body.passkeyOptions, decoded.pk, decodedPasskeysCookies); + const res = await this.verifyPasskeyResponse(body.passkeyOptions, decoded.pk, cookiesValidationResult.decodedPasskeysCookies); if (res.ok && res.passkeyConfirmed) { verified = true; } @@ -572,14 +593,9 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { return { error: 'Passkey response is required' }; } - const totpTemporaryJWT = this.adminforth.auth.getCustomCookie({cookies: cookies, name: "passkeyLoginTemporaryJWT"}); - if (!totpTemporaryJWT) { - return { error: 'Authentication session is expired. Please, try again' } - } - - const decoded = await this.adminforth.auth.verify(totpTemporaryJWT, 'tempLoginPasskeyChallenge', false); - if (!decoded) { - return { error: 'Authentication session is expired. Please, try again' } + const cookiesValidationResult = await this.validateCookiesForPasskeyLogin(cookies); + if (!cookiesValidationResult.ok) { + return { error: cookiesValidationResult.error }; } let parsedPasskeyResponse; @@ -612,7 +628,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { return { error: 'User not found' }; } - const verificationResult = await this.verifyPasskeyResponse(passkeyResponse, userPk, decoded); + const verificationResult = await this.verifyPasskeyResponse(passkeyResponse, userPk, cookiesValidationResult.decodedPasskeysCookies); if (!verificationResult.ok || !verificationResult.passkeyConfirmed) { return { error: 'Passkey verification failed' }; } diff --git a/types.ts b/types.ts index 9ce4588..349c16d 100644 --- a/types.ts +++ b/types.ts @@ -1,4 +1,4 @@ -import { AdminUser } from "adminforth"; +import { AdminUser, KeyValueAdapter } from "adminforth"; export type PluginOptions = { @@ -33,6 +33,13 @@ export type PluginOptions = { credentialMetaFieldName: string, credentialUserIdFieldName: string, + + /** + * KeyValueAdapter is required to make sure that generated challenge can't be reused more than once + */ + keyValueAdapter: KeyValueAdapter, + + /** * Allow login with Passkeys even if 2FA is not set up. Default is true. */