From af6f4273106971740baecffd7dc175d9eaa14872 Mon Sep 17 00:00:00 2001 From: Maksym Pipkun Date: Thu, 7 May 2026 17:36:28 +0300 Subject: [PATCH] feat: refactor two-factor authentication plugin by modularizing passkey and TOTP functionalities --- index.ts | 307 ++------------------------------- internals/config-validation.ts | 86 +++++++++ internals/mfa-grace.ts | 55 ++++++ internals/passkeys.ts | 96 +++++++++++ internals/totp.ts | 61 +++++++ 5 files changed, 314 insertions(+), 291 deletions(-) create mode 100644 internals/config-validation.ts create mode 100644 internals/mfa-grace.ts create mode 100644 internals/passkeys.ts create mode 100644 internals/totp.ts diff --git a/index.ts b/index.ts index dda0d29..91336cb 100644 --- a/index.ts +++ b/index.ts @@ -1,16 +1,19 @@ -import { AdminForthPlugin, Filters, suggestIfTypo, HttpExtra, convertPeriodToSeconds } from "adminforth"; +import { AdminForthPlugin, Filters, HttpExtra } from "adminforth"; import type { AdminForthResource, AdminUser, IAdminForth, IHttpServer, IAdminForthAuth, BeforeLoginConfirmationFunction, IAdminForthHttpResponse } from "adminforth"; import twofactor from 'node-2fa'; import { PluginOptions } from "./types.js" import { generateRegistrationOptions, verifyRegistrationResponse, - generateAuthenticationOptions, - verifyAuthenticationResponse + generateAuthenticationOptions } from '@simplewebauthn/server'; import { isoUint8Array, isoBase64URL } from '@simplewebauthn/server/helpers'; import aaguids from './custom/aaguid.json'; import crypto from 'crypto'; +import { issueTempSkip2FAGraceJWT, isTempSkip2FAGraceValid } from "./internals/mfa-grace.js"; +import { validateCookiesForPasskeyLogin, verifyPasskeyResponse } from "./internals/passkeys.js"; +import { confirmLogin } from "./internals/totp.js"; +import { validatePluginConfig } from "./internals/config-validation.js"; export default class TwoFactorsAuthPlugin extends AdminForthPlugin { options: PluginOptions; @@ -29,37 +32,6 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { return `single`; } - private useChellenge(challenge: string, expiresIn?: string): void { - const expiresInSeconds = expiresIn ? convertPeriodToSeconds(expiresIn) : undefined; - this.options.passkeys.keyValueAdapter.set(challenge, 'stub_value', expiresInSeconds); - } - - private async checkIfChellengeNotUsed(challenge: string): Promise { - const res = await this.options.passkeys.keyValueAdapter.get(challenge); - if (!res) { - 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.checkIfChellengeNotUsed(decodedPasskeysCookies.challenge); - if (isChallangeValid) { - this.useChellenge(decodedPasskeysCookies.challenge, this.options.passkeys?.challengeValidityPeriod || '2m'); - } - - 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) { const res = await this.options.usersFilterToAllowSkipSetup(adminUser); // recieve result of usersFilterToAllowSkipSetup @@ -87,60 +59,6 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { return { skipAllowed: false }; } - private generateHashForStepUpMfaGraceCookie(headers, cookies): string { - const ip = this.adminforth.auth.getClientIp(headers); - const userAgent = headers['user-agent'] || ''; - const acceptLanguage = headers['accept-language'] || ''; - const session_cookie = this.adminforth.auth.getCustomCookie({cookies: cookies, name: "jwt"}); - if (!ip || !userAgent || !acceptLanguage || !session_cookie) { - console.error("❗️❗️❗️ Cannot set step-up MFA grace cookie: missing required request headers to identify client ❗️❗️❗️"); - return null; - } else { - const hmac = crypto.createHmac('sha256', process.env.ADMINFORTH_SECRET) - .update(`${acceptLanguage}_${userAgent}_${ip}_${session_cookie}`) - .digest('hex'); - return hmac; - } - } - - private issueTempSkip2FAGraceJWT(opts, cookies, response): void { - if (response) { - if (opts.extra.headers) { - const hash = this.generateHashForStepUpMfaGraceCookie(opts.extra.headers, cookies); - if (!hash) { - return; - } - const jwt = this.adminforth.auth.issueJWT({ hash: hash }, 'MfaGrace', `${this.options.stepUpMfaGracePeriodSeconds}s`); - //TODO: fix ts-ignore after releasing new version of adminforth with updated types - //@ts-ignore - this.adminforth.auth.setCustomCookie({response: response, payload: {name: "TempSkip2FA_Modal_JWT", sessionBased: true, value: jwt, httpOnly: true}}); - } - } else { - console.error("❗️❗️❗️ Cannot set step-up MFA grace cookie: response object is missing. You probably called verify() method without response parameter ❗️❗️❗️"); - } - } - - private async isTempSkip2FAGraceValid(headers, cookies, checkIfJWTAboutToExpire: boolean = false): Promise { - const hash = this.generateHashForStepUpMfaGraceCookie(headers, cookies); - if (!hash) { - return false; - } - const jwt = this.adminforth.auth.getCustomCookie({cookies: cookies, name: "TempSkip2FA_Modal_JWT"}); - const jwtVerificationResult = await this.adminforth.auth.verify(jwt, 'MfaGrace', false) - if (!jwtVerificationResult) { - return false; - } - const jwtHash = jwtVerificationResult['hash']; - if (checkIfJWTAboutToExpire && (jwtVerificationResult["exp"] - ( Date.now() / 1000)) < 30 ) { - console.error("❗️❗️❗️ Cannot validate step-up MFA grace cookie: token is expired or about to expire ❗️❗️❗️"); - return false; - } - if (hash === jwtHash) { - return true; - } - return false; - } - private pending = new Map void>(); private waitForResponse(id: string, timeoutMs: number): Promise { @@ -204,7 +122,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { } } if ( this.options.stepUpMfaGracePeriodSeconds && opts.extra?.headers && !confirmationResult.mode) { - const verificationResult = await this.isTempSkip2FAGraceValid(opts.extra.headers, cookies); + const verificationResult = await isTempSkip2FAGraceValid(this, opts.extra.headers, cookies); if ( verificationResult === true ) { return { ok: true }; } @@ -235,24 +153,24 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { //* SET GRACE COOKIE *// if ( verified ) { if (this.options.stepUpMfaGracePeriodSeconds) { - this.issueTempSkip2FAGraceJWT(opts, cookies, response); + issueTempSkip2FAGraceJWT(this, opts, cookies, response); } return { ok: true } } else { return { error: "Wrong or expired OTP code" } }; } else if (confirmationResult.mode === "passkey") { - const cookiesValidationResult = await this.validateCookiesForPasskeyLogin(cookies); + const cookiesValidationResult = await validateCookiesForPasskeyLogin(this, cookies); if (!cookiesValidationResult.ok) { return { error: cookiesValidationResult.error }; } - const verificationResult = await this.verifyPasskeyResponse(confirmationResult.result, opts.userPk, cookiesValidationResult.decodedPasskeysCookies ); + const verificationResult = await verifyPasskeyResponse(this, confirmationResult.result, opts.userPk, cookiesValidationResult.decodedPasskeysCookies ); if (verificationResult.ok && verificationResult.passkeyConfirmed) { //* SET GRACE COOKIE *// if (this.options.stepUpMfaGracePeriodSeconds) { - this.issueTempSkip2FAGraceJWT(opts, cookies, response); + issueTempSkip2FAGraceJWT(this, opts, cookies, response); } return { ok: true } } @@ -279,66 +197,6 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { } } - public async verifyPasskeyResponse(body: any, user_id: string, cookies: any) { - const settingsOrigin = this.options.passkeys?.settings.expectedOrigin; - const expectedOrigin = body.origin; - const expectedChallenge = cookies.challenge; - const expectedRPID = this.options.passkeys?.settings?.rp?.id || (new URL(settingsOrigin)).hostname; - const response = JSON.parse(body.response); - try { - if (settingsOrigin !== expectedOrigin) { - throw new Error(`Origin mismatch. Allowed in settings: ${settingsOrigin}, received from client: ${expectedOrigin}`); - } - const cred = await this.adminforth.resource(this.options.passkeys.credentialResourceID).get([Filters.EQ(this.options.passkeys.credentialIdFieldName, response.id)]); - if (!cred) { - throw new Error('Credential not found.'); - } - const credMeta = JSON.parse(cred[this.options.passkeys.credentialMetaFieldName]); - if (!credMeta || !credMeta.public_key) { - throw new Error('Credential public key not found.'); - } - const userResourceId = this.adminforth.config.auth.usersResourceId; - const usersResource = this.adminforth.config.resources.find(r => r.resourceId === userResourceId); - const usersPrimaryKeyColumn = usersResource.columns.find((col) => col.primaryKey); - const usersPrimaryKeyFieldName = usersPrimaryKeyColumn.name; - const user = await this.adminforth.resource(userResourceId).get([Filters.EQ(usersPrimaryKeyFieldName, cred[this.options.passkeys.credentialUserIdFieldName])]); - if (!user || !user_id || user[usersPrimaryKeyFieldName] !== user_id) { - throw new Error('User not found.'); - } - const { verified, authenticationInfo } = await verifyAuthenticationResponse({ - response, - expectedChallenge, - expectedOrigin: settingsOrigin, - expectedRPID, - credential: { - id: cred[this.options.passkeys.credentialIdFieldName], - publicKey: isoBase64URL.toBuffer(credMeta.public_key), - counter: credMeta.counter, - transports: credMeta.transports, - }, - requireUserVerification: this.options.passkeys?.settings.authenticatorSelection.userVerification === 'discouraged' ? false : true, - }); - - if (!verified) { - return { ok: false, error: 'User verification failed.' }; - } - credMeta.counter = authenticationInfo.newCounter; - credMeta.last_used_at = new Date().toISOString(); - const credResource = this.adminforth.config.resources.find(r => r.resourceId === this.options.passkeys.credentialResourceID); - const credResourcePKColumn = credResource?.columns.find(c => c.primaryKey); - if (!credResource || !credResourcePKColumn) { - throw new Error('Credential resource or its primary key is not configured correctly'); - } - const credResourcePKName = credResourcePKColumn.name; - await this.adminforth - .resource(this.options.passkeys.credentialResourceID) - .update(cred[credResourcePKName], { [this.options.passkeys.credentialMetaFieldName]: JSON.stringify(credMeta) }); - return { ok: true, passkeyConfirmed: true }; - } catch (e) { - return { ok: false, error: 'Error authenticating passkey: ' + e }; - } - } - modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) { super.modifyResourceConfig(adminforth, resourceConfig); this.adminforth = adminforth; @@ -388,88 +246,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { } validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource) { - if (this.options.passkeys) { - - const adminForthResources = []; - for (const res of adminforth.config.resources) { - adminForthResources.push(res.resourceId); - } - if (!this.options.passkeys.credentialResourceID) { - throw new Error('Passkeys credentialResourceID is required'); - } - if ( !(adminForthResources.includes(this.options.passkeys.credentialResourceID)) ) { - throw new Error('Passkeys credentialResourceID is not valid'); - } - if (!this.options.passkeys.credentialIdFieldName) { - 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 ) { - const similar = suggestIfTypo(credentialResource.columns.map(c => c.name), this.options.passkeys.credentialIdFieldName); - throw new Error( - `Passkeys credentialIdFieldName '${this.options.passkeys.credentialIdFieldName}' not found in resource '${this.options.passkeys.credentialResourceID}'. ${ - similar ? `Did you mean '${similar}'?` : '' - }` - ); - } - credentialIDField.backendOnly = true; - - if (!this.options.passkeys.credentialMetaFieldName) { - throw new Error('Passkeys credentialMetaFieldName is required'); - } - - const metaResource = adminforth.config.resources.find(r => r.resourceId === this.options.passkeys.credentialMetaFieldName); - const metaField = credentialResource.columns.find(c => c.name === this.options.passkeys.credentialMetaFieldName); - if ( !metaField ) { - const similar = suggestIfTypo(metaResource.columns.map(c => c.name), this.options.passkeys.credentialMetaFieldName); - throw new Error( - `Passkeys credentialMetaFieldName '${this.options.passkeys.credentialMetaFieldName}' not found in resource '${this.options.passkeys.credentialMetaFieldName}'. ${ - similar ? `Did you mean '${similar}'?` : '' - }` - ); - } - metaField.backendOnly = true; - - - if (!this.options.passkeys.credentialUserIdFieldName) { - throw new Error('Passkeys credentialUserIdFieldName is required'); - } - if (!this.options.passkeys.settings) { - throw new Error('Passkeys settings are required when passkeys option is enabled'); - } - if (!this.options.passkeys.settings.expectedOrigin) { - throw new Error('Passkeys settings.expectedOrigin is required'); - } - const origin = new URL( this.options.passkeys.settings.expectedOrigin ).origin; - if ( origin !== this.options.passkeys.settings.expectedOrigin ) { - throw new Error('Passkeys settings.expectedOrigin is not valid'); - } - if (this.options.passkeys.settings.authenticatorSelection) { - if (this.options.passkeys.settings.authenticatorSelection.authenticatorAttachment) { - if ( !['platform', 'cross-platform', 'both'].includes(this.options.passkeys.settings.authenticatorSelection.authenticatorAttachment) ) { - throw new Error('Passkeys settings.authenticatorSelection.authenticatorAttachment is not valid'); - } - } - if (this.options.passkeys.settings.authenticatorSelection.userVerification) { - if ( !['required', 'discouraged'].includes(this.options.passkeys.settings.authenticatorSelection.userVerification) ) { - throw new Error('Passkeys settings.authenticatorSelection.userVerification is not valid'); - } - } - } - - if (!this.options.passkeys.settings.user) { - throw new Error('Passkeys settings.user is required'); - } - if (!this.options.passkeys.settings.user.nameField) { - throw new Error('Passkeys settings.user.nameField is required'); - } - } + validatePluginConfig(this, adminforth); } activate ( resourceConfig: AdminForthResource, adminforth: IAdminForth ){ @@ -567,59 +344,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { path: `/plugin/twofa/confirmLogin`, noAuth: true, handler: async ({ body, adminUser, response, cookies }) => { - const brandNameSlug = this.adminforth.config.customization.brandNameSlug; - const totpTemporaryJWT = this.adminforth.auth.getCustomCookie({cookies: cookies, name: "2FaTemporaryJWT"}); - if (!totpTemporaryJWT) { - return { error: 'Login session expired. Please log in again.' } - } - const decoded = await this.adminforth.auth.verify(totpTemporaryJWT, 'temp2FA'); - if (!decoded) { - return { error: 'Login session expired. Please log in again.' } - } - - if (decoded.newSecret) { - // set up standard TOTP request - ensured by presence of newSecret in temp2FA token - const verified = body.skip && decoded.userCanSkipSetup ? true : twofactor.verifyToken(decoded.newSecret, body.code, this.options.timeStepWindow); - if (verified) { - this.connectors = this.adminforth.connectors - if (!body.skip) { - const connector = this.connectors[this.authResource.dataSource]; - await connector.updateRecord({resource:this.authResource, recordId:decoded.pk, newValues:{[this.options.twoFaSecretFieldName]: decoded.newSecret}}) - } - this.adminforth.auth.removeCustomCookie({response, name:'2FaTemporaryJWT'}) - this.adminforth.auth.setAuthCookie({expireInDuration: decoded.sessionDuration, response, username:decoded.userName, pk:decoded.pk}) - return { status: 'ok', allowedLogin: true } - } else { - return {error: 'Wrong or expired OTP code'} - } - } else { - // login with confirming existing TOTP or Passkey - let verified = null; - if (body.usePasskey && this.options.passkeys) { - // passkeys are enabled and user wants to use them - const cookiesValidationResult = await this.validateCookiesForPasskeyLogin(cookies); - if (!cookiesValidationResult.ok) { - return { error: cookiesValidationResult.error }; - } - const res = await this.verifyPasskeyResponse(body.passkeyOptions, decoded.pk, cookiesValidationResult.decodedPasskeysCookies); - if (res.ok && res.passkeyConfirmed) { - verified = true; - } - } else { - // user already has TOTP secret, get it - this.connectors = this.adminforth.connectors - const connector = this.connectors[this.authResource.dataSource]; - const user = await connector.getRecordByPrimaryKey(this.authResource, decoded.pk) - verified = twofactor.verifyToken(user[this.options.twoFaSecretFieldName], body.code, this.options.timeStepWindow); - } - if (verified) { - this.adminforth.auth.removeCustomCookie({response, name:'2FaTemporaryJWT'}) - this.adminforth.auth.setAuthCookie({expireInDuration: decoded.sessionDuration, response, username:decoded.userName, pk:decoded.pk}) - return { status: 'ok', allowedLogin: true } - } else { - return {error: 'Verification failed'} - } - } + return confirmLogin(this, body, response, cookies); } }) server.endpoint({ @@ -636,7 +361,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { return { error: 'Passkey response is required' }; } - const cookiesValidationResult = await this.validateCookiesForPasskeyLogin(cookies); + const cookiesValidationResult = await validateCookiesForPasskeyLogin(this, cookies); if (!cookiesValidationResult.ok) { return { error: cookiesValidationResult.error }; } @@ -671,7 +396,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { return { error: 'User not found' }; } - const verificationResult = await this.verifyPasskeyResponse(passkeyResponse, userPk, cookiesValidationResult.decodedPasskeysCookies); + const verificationResult = await verifyPasskeyResponse(this, passkeyResponse, userPk, cookiesValidationResult.decodedPasskeysCookies); if (!verificationResult.ok || !verificationResult.passkeyConfirmed) { return { error: 'Passkey verification failed' }; } @@ -761,7 +486,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { } if ( this.options.stepUpMfaGracePeriodSeconds ) { - const verificationResult = await this.isTempSkip2FAGraceValid(headers, cookies, true); + const verificationResult = await isTempSkip2FAGraceValid(this, headers, cookies, true); if ( verificationResult === true ) { return { skipAllowed: true }; } diff --git a/internals/config-validation.ts b/internals/config-validation.ts new file mode 100644 index 0000000..5d81ed7 --- /dev/null +++ b/internals/config-validation.ts @@ -0,0 +1,86 @@ +import { suggestIfTypo } from "adminforth"; + +export function validatePluginConfig(plugin: any, adminforth: any): void { + if (plugin.options.passkeys) { + + const adminForthResources = []; + for (const res of adminforth.config.resources) { + adminForthResources.push(res.resourceId); + } + if (!plugin.options.passkeys.credentialResourceID) { + throw new Error('Passkeys credentialResourceID is required'); + } + if ( !(adminForthResources.includes(plugin.options.passkeys.credentialResourceID)) ) { + throw new Error('Passkeys credentialResourceID is not valid'); + } + if (!plugin.options.passkeys.credentialIdFieldName) { + throw new Error('Passkeys credentialIdFieldName is required'); + } + + if (!plugin.options.passkeys.keyValueAdapter) { + throw new Error('Passkeys keyValueAdapter is required'); + } + + const credentialResource = adminforth.config.resources.find(r => r.resourceId === plugin.options.passkeys.credentialResourceID); + const credentialIDField = credentialResource.columns.find(c => c.name === plugin.options.passkeys.credentialIdFieldName); + if ( !credentialIDField ) { + const similar = suggestIfTypo(credentialResource.columns.map(c => c.name), plugin.options.passkeys.credentialIdFieldName); + throw new Error( + `Passkeys credentialIdFieldName '${plugin.options.passkeys.credentialIdFieldName}' not found in resource '${plugin.options.passkeys.credentialResourceID}'. ${ + similar ? `Did you mean '${similar}'?` : '' + }` + ); + } + credentialIDField.backendOnly = true; + + if (!plugin.options.passkeys.credentialMetaFieldName) { + throw new Error('Passkeys credentialMetaFieldName is required'); + } + + const metaResource = adminforth.config.resources.find(r => r.resourceId === plugin.options.passkeys.credentialMetaFieldName); + const metaField = credentialResource.columns.find(c => c.name === plugin.options.passkeys.credentialMetaFieldName); + if ( !metaField ) { + const similar = suggestIfTypo(metaResource.columns.map(c => c.name), plugin.options.passkeys.credentialMetaFieldName); + throw new Error( + `Passkeys credentialMetaFieldName '${plugin.options.passkeys.credentialMetaFieldName}' not found in resource '${plugin.options.passkeys.credentialMetaFieldName}'. ${ + similar ? `Did you mean '${similar}'?` : '' + }` + ); + } + metaField.backendOnly = true; + + + if (!plugin.options.passkeys.credentialUserIdFieldName) { + throw new Error('Passkeys credentialUserIdFieldName is required'); + } + if (!plugin.options.passkeys.settings) { + throw new Error('Passkeys settings are required when passkeys option is enabled'); + } + if (!plugin.options.passkeys.settings.expectedOrigin) { + throw new Error('Passkeys settings.expectedOrigin is required'); + } + const origin = new URL( plugin.options.passkeys.settings.expectedOrigin ).origin; + if ( origin !== plugin.options.passkeys.settings.expectedOrigin ) { + throw new Error('Passkeys settings.expectedOrigin is not valid'); + } + if (plugin.options.passkeys.settings.authenticatorSelection) { + if (plugin.options.passkeys.settings.authenticatorSelection.authenticatorAttachment) { + if ( !['platform', 'cross-platform', 'both'].includes(plugin.options.passkeys.settings.authenticatorSelection.authenticatorAttachment) ) { + throw new Error('Passkeys settings.authenticatorSelection.authenticatorAttachment is not valid'); + } + } + if (plugin.options.passkeys.settings.authenticatorSelection.userVerification) { + if ( !['required', 'discouraged'].includes(plugin.options.passkeys.settings.authenticatorSelection.userVerification) ) { + throw new Error('Passkeys settings.authenticatorSelection.userVerification is not valid'); + } + } + } + + if (!plugin.options.passkeys.settings.user) { + throw new Error('Passkeys settings.user is required'); + } + if (!plugin.options.passkeys.settings.user.nameField) { + throw new Error('Passkeys settings.user.nameField is required'); + } + } +} diff --git a/internals/mfa-grace.ts b/internals/mfa-grace.ts new file mode 100644 index 0000000..9e2aabf --- /dev/null +++ b/internals/mfa-grace.ts @@ -0,0 +1,55 @@ +import crypto from 'crypto'; + +export function generateHashForStepUpMfaGraceCookie(plugin: any, headers: any, cookies: any): string { + const ip = plugin.adminforth.auth.getClientIp(headers); + const userAgent = headers['user-agent'] || ''; + const acceptLanguage = headers['accept-language'] || ''; + const session_cookie = plugin.adminforth.auth.getCustomCookie({cookies: cookies, name: "jwt"}); + if (!ip || !userAgent || !acceptLanguage || !session_cookie) { + console.error("❗️❗️❗️ Cannot set step-up MFA grace cookie: missing required request headers to identify client ❗️❗️❗️"); + return null; + } else { + const hmac = crypto.createHmac('sha256', process.env.ADMINFORTH_SECRET) + .update(`${acceptLanguage}_${userAgent}_${ip}_${session_cookie}`) + .digest('hex'); + return hmac; + } +} + +export function issueTempSkip2FAGraceJWT(plugin: any, opts: any, cookies: any, response: any): void { + if (response) { + if (opts.extra.headers) { + const hash = generateHashForStepUpMfaGraceCookie(plugin, opts.extra.headers, cookies); + if (!hash) { + return; + } + const jwt = plugin.adminforth.auth.issueJWT({ hash: hash }, 'MfaGrace', `${plugin.options.stepUpMfaGracePeriodSeconds}s`); + //TODO: fix ts-ignore after releasing new version of adminforth with updated types + //@ts-ignore + plugin.adminforth.auth.setCustomCookie({response: response, payload: {name: "TempSkip2FA_Modal_JWT", sessionBased: true, value: jwt, httpOnly: true}}); + } + } else { + console.error("❗️❗️❗️ Cannot set step-up MFA grace cookie: response object is missing. You probably called verify() method without response parameter ❗️❗️❗️"); + } +} + +export async function isTempSkip2FAGraceValid(plugin: any, headers: any, cookies: any, checkIfJWTAboutToExpire: boolean = false): Promise { + const hash = generateHashForStepUpMfaGraceCookie(plugin, headers, cookies); + if (!hash) { + return false; + } + const jwt = plugin.adminforth.auth.getCustomCookie({cookies: cookies, name: "TempSkip2FA_Modal_JWT"}); + const jwtVerificationResult = await plugin.adminforth.auth.verify(jwt, 'MfaGrace', false) + if (!jwtVerificationResult) { + return false; + } + const jwtHash = jwtVerificationResult['hash']; + if (checkIfJWTAboutToExpire && (jwtVerificationResult["exp"] - ( Date.now() / 1000)) < 30 ) { + console.error("❗️❗️❗️ Cannot validate step-up MFA grace cookie: token is expired or about to expire ❗️❗️❗️"); + return false; + } + if (hash === jwtHash) { + return true; + } + return false; +} diff --git a/internals/passkeys.ts b/internals/passkeys.ts new file mode 100644 index 0000000..3bcceb1 --- /dev/null +++ b/internals/passkeys.ts @@ -0,0 +1,96 @@ +import { Filters, convertPeriodToSeconds } from "adminforth"; +import { + verifyAuthenticationResponse +} from '@simplewebauthn/server'; +import { isoBase64URL } from '@simplewebauthn/server/helpers'; + +function useChellenge(plugin: any, challenge: string, expiresIn?: string): void { + const expiresInSeconds = expiresIn ? convertPeriodToSeconds(expiresIn) : undefined; + plugin.options.passkeys.keyValueAdapter.set(challenge, 'stub_value', expiresInSeconds); +} + +async function checkIfChellengeNotUsed(plugin: any, challenge: string): Promise { + const res = await plugin.options.passkeys.keyValueAdapter.get(challenge); + if (!res) { + return true; + } + return false; +} + +export async function validateCookiesForPasskeyLogin(plugin: any, cookies: any): Promise<{ok: boolean, decodedPasskeysCookies?: any, error?: string}> { + const passkeysCookies = plugin.adminforth.auth.getCustomCookie({cookies: cookies, name: `passkeyLoginTemporaryJWT`}); + if (!passkeysCookies) { + return { ok: false, error: 'Passkey token is required' }; + } + + const decodedPasskeysCookies = await plugin.adminforth.auth.verify(passkeysCookies, 'tempLoginPasskeyChallenge', false); + const isChallangeValid = await checkIfChellengeNotUsed(plugin, decodedPasskeysCookies.challenge); + if (isChallangeValid) { + useChellenge(plugin, decodedPasskeysCookies.challenge, plugin.options.passkeys?.challengeValidityPeriod || '2m'); + } + + if (!decodedPasskeysCookies || !isChallangeValid) { + return { ok: false, error: 'Invalid passkey' }; + } + return { ok: true, decodedPasskeysCookies }; +} + +export async function verifyPasskeyResponse(plugin: any, body: any, user_id: string, cookies: any) { + const settingsOrigin = plugin.options.passkeys?.settings.expectedOrigin; + const expectedOrigin = body.origin; + const expectedChallenge = cookies.challenge; + const expectedRPID = plugin.options.passkeys?.settings?.rp?.id || (new URL(settingsOrigin)).hostname; + const response = JSON.parse(body.response); + try { + if (settingsOrigin !== expectedOrigin) { + throw new Error(`Origin mismatch. Allowed in settings: ${settingsOrigin}, received from client: ${expectedOrigin}`); + } + const cred = await plugin.adminforth.resource(plugin.options.passkeys.credentialResourceID).get([Filters.EQ(plugin.options.passkeys.credentialIdFieldName, response.id)]); + if (!cred) { + throw new Error('Credential not found.'); + } + const credMeta = JSON.parse(cred[plugin.options.passkeys.credentialMetaFieldName]); + if (!credMeta || !credMeta.public_key) { + throw new Error('Credential public key not found.'); + } + const userResourceId = plugin.adminforth.config.auth.usersResourceId; + const usersResource = plugin.adminforth.config.resources.find(r => r.resourceId === userResourceId); + const usersPrimaryKeyColumn = usersResource.columns.find((col) => col.primaryKey); + const usersPrimaryKeyFieldName = usersPrimaryKeyColumn.name; + const user = await plugin.adminforth.resource(userResourceId).get([Filters.EQ(usersPrimaryKeyFieldName, cred[plugin.options.passkeys.credentialUserIdFieldName])]); + if (!user || !user_id || user[usersPrimaryKeyFieldName] !== user_id) { + throw new Error('User not found.'); + } + const { verified, authenticationInfo } = await verifyAuthenticationResponse({ + response, + expectedChallenge, + expectedOrigin: settingsOrigin, + expectedRPID, + credential: { + id: cred[plugin.options.passkeys.credentialIdFieldName], + publicKey: isoBase64URL.toBuffer(credMeta.public_key), + counter: credMeta.counter, + transports: credMeta.transports, + }, + requireUserVerification: plugin.options.passkeys?.settings.authenticatorSelection.userVerification === 'discouraged' ? false : true, + }); + + if (!verified) { + return { ok: false, error: 'User verification failed.' }; + } + credMeta.counter = authenticationInfo.newCounter; + credMeta.last_used_at = new Date().toISOString(); + const credResource = plugin.adminforth.config.resources.find(r => r.resourceId === plugin.options.passkeys.credentialResourceID); + const credResourcePKColumn = credResource?.columns.find(c => c.primaryKey); + if (!credResource || !credResourcePKColumn) { + throw new Error('Credential resource or its primary key is not configured correctly'); + } + const credResourcePKName = credResourcePKColumn.name; + await plugin.adminforth + .resource(plugin.options.passkeys.credentialResourceID) + .update(cred[credResourcePKName], { [plugin.options.passkeys.credentialMetaFieldName]: JSON.stringify(credMeta) }); + return { ok: true, passkeyConfirmed: true }; + } catch (e) { + return { ok: false, error: 'Error authenticating passkey: ' + e }; + } +} diff --git a/internals/totp.ts b/internals/totp.ts new file mode 100644 index 0000000..d40942d --- /dev/null +++ b/internals/totp.ts @@ -0,0 +1,61 @@ +import type { IAdminForthHttpResponse } from "adminforth"; +import twofactor from 'node-2fa'; +import { validateCookiesForPasskeyLogin, verifyPasskeyResponse } from "./passkeys.js"; + +function finishTotpLogin(plugin: any, response: IAdminForthHttpResponse, decoded: any) { + plugin.adminforth.auth.removeCustomCookie({response, name:'2FaTemporaryJWT'}) + plugin.adminforth.auth.setAuthCookie({expireInDuration: decoded.sessionDuration, response, username:decoded.userName, pk:decoded.pk}) + return { status: 'ok', allowedLogin: true } +} + +export async function confirmLogin(plugin: any, body: any, response: IAdminForthHttpResponse, cookies: any) { + const brandNameSlug = plugin.adminforth.config.customization.brandNameSlug; + const totpTemporaryJWT = plugin.adminforth.auth.getCustomCookie({cookies: cookies, name: "2FaTemporaryJWT"}); + if (!totpTemporaryJWT) { + return { error: 'Login session expired. Please log in again.' } + } + const decoded = await plugin.adminforth.auth.verify(totpTemporaryJWT, 'temp2FA'); + if (!decoded) { + return { error: 'Login session expired. Please log in again.' } + } + + if (decoded.newSecret) { + // set up standard TOTP request - ensured by presence of newSecret in temp2FA token + const verified = body.skip && decoded.userCanSkipSetup ? true : twofactor.verifyToken(decoded.newSecret, body.code, plugin.options.timeStepWindow); + if (verified) { + plugin.connectors = plugin.adminforth.connectors + if (!body.skip) { + const connector = plugin.connectors[plugin.authResource.dataSource]; + await connector.updateRecord({resource:plugin.authResource, recordId:decoded.pk, newValues:{[plugin.options.twoFaSecretFieldName]: decoded.newSecret}}) + } + return finishTotpLogin(plugin, response, decoded); + } else { + return {error: 'Wrong or expired OTP code'} + } + } else { + // login with confirming existing TOTP or Passkey + let verified = null; + if (body.usePasskey && plugin.options.passkeys) { + // passkeys are enabled and user wants to use them + const cookiesValidationResult = await validateCookiesForPasskeyLogin(plugin, cookies); + if (!cookiesValidationResult.ok) { + return { error: cookiesValidationResult.error }; + } + const res = await verifyPasskeyResponse(plugin, body.passkeyOptions, decoded.pk, cookiesValidationResult.decodedPasskeysCookies); + if (res.ok && res.passkeyConfirmed) { + verified = true; + } + } else { + // user already has TOTP secret, get it + plugin.connectors = plugin.adminforth.connectors + const connector = plugin.connectors[plugin.authResource.dataSource]; + const user = await connector.getRecordByPrimaryKey(plugin.authResource, decoded.pk) + verified = twofactor.verifyToken(user[plugin.options.twoFaSecretFieldName], body.code, plugin.options.timeStepWindow); + } + if (verified) { + return finishTotpLogin(plugin, response, decoded); + } else { + return {error: 'Verification failed'} + } + } +}