From 455a7201e9867fad4917b43284c8a206d0fa6d13 Mon Sep 17 00:00:00 2001 From: 0xGingi <104647854+0xGingi@users.noreply.github.com> Date: Thu, 29 May 2025 12:30:28 -0400 Subject: [PATCH] Add Optional 2FA Support (#61) * Add TOTP MFA Support * Add Passkey MFA Support It's not impossible I missed some minor cleanup, but most things make sense and there isn't a lot of obvious duplication anymore. --------- Co-authored-by: Bruno Bernardino --- .env.sample | 5 +- bewcloud.config.sample.ts | 1 + components/auth/MultiFactorAuthVerifyForm.tsx | 94 ++++ components/expenses/ExpenseModal.tsx | 36 +- deno.json | 9 +- fresh.gen.ts | 20 + islands/Settings.tsx | 39 +- islands/auth/MultiFactorAuthSettings.tsx | 530 ++++++++++++++++++ islands/auth/PasswordlessPasskeyLogin.tsx | 116 ++++ lib/auth.ts | 22 +- lib/config.ts | 7 + lib/interfaces/database.ts | 14 +- lib/models/multi-factor-auth.ts | 142 +++++ lib/models/multi-factor-auth/passkey.ts | 198 +++++++ lib/models/multi-factor-auth/totp.ts | 227 ++++++++ lib/types.ts | 27 + lib/utils/multi-factor-auth.ts | 28 + routes/api/auth/multi-factor/disable.ts | 119 ++++ routes/api/auth/multi-factor/enable.ts | 130 +++++ routes/api/auth/multi-factor/passkey/begin.ts | 87 +++ .../auth/multi-factor/passkey/setup-begin.ts | 67 +++ .../multi-factor/passkey/setup-complete.ts | 102 ++++ .../api/auth/multi-factor/passkey/verify.ts | 99 ++++ routes/api/auth/multi-factor/totp/setup.ts | 66 +++ routes/login.tsx | 45 +- routes/mfa-verify.tsx | 151 +++++ routes/settings.tsx | 16 +- routes/signup.tsx | 4 +- 28 files changed, 2361 insertions(+), 40 deletions(-) create mode 100644 components/auth/MultiFactorAuthVerifyForm.tsx create mode 100644 islands/auth/MultiFactorAuthSettings.tsx create mode 100644 islands/auth/PasswordlessPasskeyLogin.tsx create mode 100644 lib/models/multi-factor-auth.ts create mode 100644 lib/models/multi-factor-auth/passkey.ts create mode 100644 lib/models/multi-factor-auth/totp.ts create mode 100644 lib/utils/multi-factor-auth.ts create mode 100644 routes/api/auth/multi-factor/disable.ts create mode 100644 routes/api/auth/multi-factor/enable.ts create mode 100644 routes/api/auth/multi-factor/passkey/begin.ts create mode 100644 routes/api/auth/multi-factor/passkey/setup-begin.ts create mode 100644 routes/api/auth/multi-factor/passkey/setup-complete.ts create mode 100644 routes/api/auth/multi-factor/passkey/verify.ts create mode 100644 routes/api/auth/multi-factor/totp/setup.ts create mode 100644 routes/mfa-verify.tsx diff --git a/.env.sample b/.env.sample index 25c7835..0a7c831 100644 --- a/.env.sample +++ b/.env.sample @@ -10,4 +10,7 @@ POSTGRESQL_CAFILE="" JWT_SECRET="fake" PASSWORD_SALT="fake" -BREVO_API_KEY="fake" +MFA_KEY="fake" # optional, if you want to enable multi-factor authentication +MFA_SALT="fake" # optional, if you want to enable multi-factor authentication + +BREVO_API_KEY="fake" # optional, if you want to enable signup email verification diff --git a/bewcloud.config.sample.ts b/bewcloud.config.sample.ts index de929f3..09a3141 100644 --- a/bewcloud.config.sample.ts +++ b/bewcloud.config.sample.ts @@ -7,6 +7,7 @@ const config: PartialDeep = { allowSignups: false, // If true, anyone can sign up for an account. Note that it's always possible to sign up for the first user, and they will be an admin enableEmailVerification: false, // If true, email verification will be required for signups (using Brevo) enableForeverSignup: true, // If true, all signups become active for 100 years + enableMultiFactor: false, // If true, users can enable multi-factor authentication (TOTP or Passkeys) // allowedCookieDomains: ['example.com', 'example.net'], // Can be set to allow more than the baseUrl's domain for session cookies // skipCookieDomainSecurity: true, // If true, the cookie domain will not be strictly set and checked against. This skipping slightly reduces security, but is usually necessary for reverse proxies like Cloudflare Tunnel }, diff --git a/components/auth/MultiFactorAuthVerifyForm.tsx b/components/auth/MultiFactorAuthVerifyForm.tsx new file mode 100644 index 0000000..9030bec --- /dev/null +++ b/components/auth/MultiFactorAuthVerifyForm.tsx @@ -0,0 +1,94 @@ +import { MultiFactorAuthMethodType } from '/lib/types.ts'; +import PasswordlessPasskeyLogin from '/islands/auth/PasswordlessPasskeyLogin.tsx'; + +interface MultiFactorAuthVerifyFormProps { + email: string; + redirectUrl: string; + availableMethods: MultiFactorAuthMethodType[]; + error?: { title: string; message: string }; +} + +export default function MultiFactorAuthVerifyForm( + { email, redirectUrl, availableMethods, error }: MultiFactorAuthVerifyFormProps, +) { + const hasPasskey = availableMethods.includes('passkey'); + const hasTotp = availableMethods.includes('totp'); + + return ( +
+
+

+ Multi-Factor Authentication +

+

+ You are required to authenticate with an additional method +

+
+ + {error + ? ( +
+ {error.title}: + {error.message} +
+ ) + : null} + + {hasTotp + ? ( +
+
+ + +
+ +
+ +
+
+ ) + : null} + + {hasTotp && hasPasskey + ? ( +
+

or

+
+ ) + : null} + + {hasPasskey && email + ? ( +
+ +
+ ) + : null} + +
+ + Back to Login + +
+
+ ); +} diff --git a/components/expenses/ExpenseModal.tsx b/components/expenses/ExpenseModal.tsx index b2fdc74..a7dc10f 100644 --- a/components/expenses/ExpenseModal.tsx +++ b/components/expenses/ExpenseModal.tsx @@ -160,23 +160,25 @@ export default function ExpenseModal( }} placeholder='Lunch' /> - {showSuggestions.value && suggestions.value.length > 0 && ( - - )} + {showSuggestions.value && suggestions.value.length > 0 + ? ( + + ) + : null}
diff --git a/deno.json b/deno.json index 4bb1d31..d8ffead 100644 --- a/deno.json +++ b/deno.json @@ -14,7 +14,7 @@ "lint": { "rules": { "tags": ["fresh", "recommended"], - "exclude": ["no-explicit-any", "no-empty-interface", "ban-types", "no-window", "no-unused-vars"] + "exclude": ["no-explicit-any", "no-empty-interface", "no-window", "no-unused-vars"] } }, "exclude": ["./_fresh/*", "./node_modules/*", "**/_fresh/*"], @@ -36,6 +36,11 @@ "tailwindcss/plugin": "npm:/tailwindcss@3.4.15/plugin.js", "std/": "https://deno.land/std@0.224.0/", "$std/": "https://deno.land/std@0.224.0/", - "chart.js": "https://esm.sh/chart.js@4.4.7/auto" + "chart.js": "https://esm.sh/chart.js@4.4.9/auto", + "otpauth": "https://esm.sh/otpauth@9.4.0", + "qrcode": "https://esm.sh/qrcode@1.5.4", + "@simplewebauthn/server": "jsr:@simplewebauthn/server@13.1.1", + "@simplewebauthn/server/helpers": "jsr:@simplewebauthn/server@13.1.1/helpers", + "@simplewebauthn/browser": "jsr:@simplewebauthn/browser@13.1.0" } } diff --git a/fresh.gen.ts b/fresh.gen.ts index ab51818..a151e32 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -5,6 +5,13 @@ import * as $_404 from './routes/_404.tsx'; import * as $_app from './routes/_app.tsx'; import * as $_middleware from './routes/_middleware.tsx'; +import * as $api_auth_multi_factor_disable from './routes/api/auth/multi-factor/disable.ts'; +import * as $api_auth_multi_factor_enable from './routes/api/auth/multi-factor/enable.ts'; +import * as $api_auth_multi_factor_passkey_begin from './routes/api/auth/multi-factor/passkey/begin.ts'; +import * as $api_auth_multi_factor_passkey_setup_begin from './routes/api/auth/multi-factor/passkey/setup-begin.ts'; +import * as $api_auth_multi_factor_passkey_setup_complete from './routes/api/auth/multi-factor/passkey/setup-complete.ts'; +import * as $api_auth_multi_factor_passkey_verify from './routes/api/auth/multi-factor/passkey/verify.ts'; +import * as $api_auth_multi_factor_totp_setup from './routes/api/auth/multi-factor/totp/setup.ts'; import * as $api_dashboard_save_links from './routes/api/dashboard/save-links.tsx'; import * as $api_dashboard_save_notes from './routes/api/dashboard/save-notes.tsx'; import * as $api_expenses_add_budget from './routes/api/expenses/add-budget.tsx'; @@ -41,6 +48,7 @@ import * as $files_open_fileName_ from './routes/files/open/[fileName].tsx'; import * as $index from './routes/index.tsx'; import * as $login from './routes/login.tsx'; import * as $logout from './routes/logout.tsx'; +import * as $mfa_verify from './routes/mfa-verify.tsx'; import * as $news from './routes/news.tsx'; import * as $news_feeds from './routes/news/feeds.tsx'; import * as $notes from './routes/notes.tsx'; @@ -50,6 +58,8 @@ import * as $photos_thumbnail_fileName_ from './routes/photos/thumbnail/[fileNam import * as $settings from './routes/settings.tsx'; import * as $signup from './routes/signup.tsx'; import * as $Settings from './islands/Settings.tsx'; +import * as $auth_MultiFactorAuthSettings from './islands/auth/MultiFactorAuthSettings.tsx'; +import * as $auth_PasswordlessPasskeyLogin from './islands/auth/PasswordlessPasskeyLogin.tsx'; import * as $dashboard_Links from './islands/dashboard/Links.tsx'; import * as $dashboard_Notes from './islands/dashboard/Notes.tsx'; import * as $expenses_ExpensesWrapper from './islands/expenses/ExpensesWrapper.tsx'; @@ -66,6 +76,13 @@ const manifest = { './routes/_404.tsx': $_404, './routes/_app.tsx': $_app, './routes/_middleware.tsx': $_middleware, + './routes/api/auth/multi-factor/disable.ts': $api_auth_multi_factor_disable, + './routes/api/auth/multi-factor/enable.ts': $api_auth_multi_factor_enable, + './routes/api/auth/multi-factor/passkey/begin.ts': $api_auth_multi_factor_passkey_begin, + './routes/api/auth/multi-factor/passkey/setup-begin.ts': $api_auth_multi_factor_passkey_setup_begin, + './routes/api/auth/multi-factor/passkey/setup-complete.ts': $api_auth_multi_factor_passkey_setup_complete, + './routes/api/auth/multi-factor/passkey/verify.ts': $api_auth_multi_factor_passkey_verify, + './routes/api/auth/multi-factor/totp/setup.ts': $api_auth_multi_factor_totp_setup, './routes/api/dashboard/save-links.tsx': $api_dashboard_save_links, './routes/api/dashboard/save-notes.tsx': $api_dashboard_save_notes, './routes/api/expenses/add-budget.tsx': $api_expenses_add_budget, @@ -102,6 +119,7 @@ const manifest = { './routes/index.tsx': $index, './routes/login.tsx': $login, './routes/logout.tsx': $logout, + './routes/mfa-verify.tsx': $mfa_verify, './routes/news.tsx': $news, './routes/news/feeds.tsx': $news_feeds, './routes/notes.tsx': $notes, @@ -113,6 +131,8 @@ const manifest = { }, islands: { './islands/Settings.tsx': $Settings, + './islands/auth/MultiFactorAuthSettings.tsx': $auth_MultiFactorAuthSettings, + './islands/auth/PasswordlessPasskeyLogin.tsx': $auth_PasswordlessPasskeyLogin, './islands/dashboard/Links.tsx': $dashboard_Links, './islands/dashboard/Notes.tsx': $dashboard_Notes, './islands/expenses/ExpensesWrapper.tsx': $expenses_ExpensesWrapper, diff --git a/islands/Settings.tsx b/islands/Settings.tsx index 3826147..236e619 100644 --- a/islands/Settings.tsx +++ b/islands/Settings.tsx @@ -1,6 +1,8 @@ -import { convertObjectToFormData } from '/lib/utils/misc.ts'; import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx'; -import { currencyMap, SupportedCurrencySymbol } from '/lib/types.ts'; +import { convertObjectToFormData } from '/lib/utils/misc.ts'; +import { currencyMap, SupportedCurrencySymbol, User } from '/lib/types.ts'; +import MultiFactorAuthSettings from '/islands/auth/MultiFactorAuthSettings.tsx'; +import { getEnabledMultiFactorAuthMethodsFromUser } from '/lib/utils/multi-factor-auth.ts'; interface SettingsProps { formData: Record; @@ -14,7 +16,11 @@ interface SettingsProps { }; currency?: SupportedCurrencySymbol; isExpensesAppEnabled: boolean; + isMultiFactorAuthEnabled: boolean; helpEmail: string; + user: { + extra: Pick; + }; } export type Action = @@ -121,11 +127,20 @@ function formFields(action: Action, formData: FormData, currency?: SupportedCurr } export default function Settings( - { formData: formDataObject, error, notice, currency, isExpensesAppEnabled, helpEmail }: SettingsProps, + { + formData: formDataObject, + error, + notice, + currency, + isExpensesAppEnabled, + isMultiFactorAuthEnabled, + helpEmail, + user, + }: SettingsProps, ) { const formData = convertObjectToFormData(formDataObject); - const action = getFormDataField(formData, 'action') as Action; + const multiFactorAuthMethods = getEnabledMultiFactorAuthMethodsFromUser(user); return ( <> @@ -151,7 +166,7 @@ export default function Settings(
{formFields( - action === 'change-email' && notice?.message.includes('verify') ? 'verify-change-email' : 'change-email', + 'change-email', formData, ).map((field) => generateFieldHtml(field, formData))}
@@ -195,6 +210,20 @@ export default function Settings( ) : null} + {isMultiFactorAuthEnabled + ? ( + ({ + type: method.type, + id: method.id, + name: method.name, + enabled: method.enabled, + backupCodesCount: method.metadata.totp?.hashed_backup_codes?.length, + }))} + /> + ) + : null} +

Delete your account

Deleting your account is instant and deletes all your data. {helpEmail !== '' diff --git a/islands/auth/MultiFactorAuthSettings.tsx b/islands/auth/MultiFactorAuthSettings.tsx new file mode 100644 index 0000000..03aa0cb --- /dev/null +++ b/islands/auth/MultiFactorAuthSettings.tsx @@ -0,0 +1,530 @@ +import { useSignal } from '@preact/signals'; +import { startRegistration } from '@simplewebauthn/browser'; + +import { MultiFactorAuthMethodType } from '/lib/types.ts'; +import { + RequestBody as PasskeySetupBeginRequestBody, + ResponseBody as PasskeySetupBeginResponseBody, +} from '/routes/api/auth/multi-factor/passkey/setup-begin.ts'; +import { + RequestBody as PasskeySetupCompleteRequestBody, + ResponseBody as PasskeySetupCompleteResponseBody, +} from '/routes/api/auth/multi-factor/passkey/setup-complete.ts'; +import { + RequestBody as TOTPSetupRequestBody, + ResponseBody as TOTPSetupResponseBody, +} from '/routes/api/auth/multi-factor/totp/setup.ts'; +import { + RequestBody as MultiFactorAuthEnableRequestBody, + ResponseBody as MultiFactorAuthEnableResponseBody, +} from '/routes/api/auth/multi-factor/enable.ts'; +import { + RequestBody as MultiFactorAuthDisableRequestBody, + ResponseBody as MultiFactorAuthDisableResponseBody, +} from '/routes/api/auth/multi-factor/disable.ts'; + +interface MultiFactorAuthMethod { + type: MultiFactorAuthMethodType; + id: string; + name: string; + enabled: boolean; + backupCodesCount?: number; +} + +interface MultiFactorAuthSettingsProps { + methods: MultiFactorAuthMethod[]; +} + +interface TOTPSetupData { + type: 'totp'; + secret: string; + qrCodeUrl: string; + backupCodes: string[]; + methodId: string; +} + +interface PasskeySetupData { + methodId: string; + type: 'passkey'; +} + +const methodTypeLabels: Record = { + totp: 'Authenticator App', + passkey: 'Passkey', +}; + +const methodTypeDescriptions: Record = { + totp: 'Use an authenticator app like Aegis Authenticator or Google Authenticator to generate codes', + passkey: 'Use biometric authentication or security keys', +}; + +const availableMethodTypes = ['totp', 'passkey'] as MultiFactorAuthMethodType[]; + +export default function MultiFactorAuthSettings({ methods }: MultiFactorAuthSettingsProps) { + const setupData = useSignal(null); + const isLoading = useSignal(false); + const error = useSignal(null); + const success = useSignal(null); + const showDisableForm = useSignal<'all' | string | null>(null); + const verificationToken = useSignal(''); + const disablePassword = useSignal(''); + + const enabledMethods = methods.filter((method) => method.enabled); + const hasMultiFactorAuthEnabled = enabledMethods.length > 0; + + const setupPasskey = async () => { + const beginRequestBody: PasskeySetupBeginRequestBody = {}; + + const beginResponse = await fetch('/api/auth/multi-factor/passkey/setup-begin', { + method: 'POST', + body: JSON.stringify(beginRequestBody), + }); + + if (!beginResponse.ok) { + throw new Error( + `Failed to begin passkey registration! ${beginResponse.statusText} ${await beginResponse.text()}`, + ); + } + + const beginData = await beginResponse.json() as PasskeySetupBeginResponseBody; + + if (!beginData.success) { + throw new Error(beginData.error || 'Failed to begin passkey registration'); + } + + const registrationResponse = await startRegistration({ optionsJSON: beginData.options! }); + + const completeRequestBody: PasskeySetupCompleteRequestBody = { + methodId: beginData.sessionData!.methodId, + challenge: beginData.sessionData!.challenge, + registrationResponse, + }; + + const completeResponse = await fetch('/api/auth/multi-factor/passkey/setup-complete', { + method: 'POST', + body: JSON.stringify(completeRequestBody), + }); + + if (!completeResponse.ok) { + throw new Error( + `Failed to complete passkey registration! ${completeResponse.statusText} ${await completeResponse.text()}`, + ); + } + + const completeData = await completeResponse.json() as PasskeySetupCompleteResponseBody; + + if (!completeData.success) { + throw new Error(completeData.error || 'Failed to complete passkey registration'); + } + + setupData.value = { + methodId: beginData.sessionData!.methodId, + type: 'passkey', + }; + }; + + const setupTOTP = async () => { + const requestBody: TOTPSetupRequestBody = {}; + + const response = await fetch('/api/auth/multi-factor/totp/setup', { + method: 'POST', + body: JSON.stringify(requestBody), + }); + + const data = await response.json() as TOTPSetupResponseBody; + + if (!data.success || !data.data) { + throw new Error(data.error || 'Failed to setup TOTP multi-factor authentication'); + } + + setupData.value = { + type: 'totp', + secret: data.data.secret!, + qrCodeUrl: data.data.qrCodeUrl!, + backupCodes: data.data.backupCodes!, + methodId: data.data.methodId!, + }; + }; + + const setupMultiFactorAuth = async (type: MultiFactorAuthMethodType) => { + isLoading.value = true; + error.value = null; + + try { + if (type === 'totp') { + await setupTOTP(); + } else if (type === 'passkey') { + await setupPasskey(); + } + } catch (setupError) { + error.value = (setupError as Error).message; + } finally { + isLoading.value = false; + } + }; + + const enableMultiFactorAuth = async () => { + if (!setupData.value) { + error.value = 'No setup data available'; + return; + } + + if (setupData.value.type !== 'passkey' && !verificationToken.value) { + error.value = 'Please enter a verification token'; + return; + } + + isLoading.value = true; + error.value = null; + + try { + const requestBody: MultiFactorAuthEnableRequestBody = { + methodId: setupData.value.methodId, + code: setupData.value.type === 'passkey' ? 'passkey-verified' : verificationToken.value, + }; + + const response = await fetch('/api/auth/multi-factor/enable', { + method: 'POST', + body: JSON.stringify(requestBody), + }); + + const data = await response.json() as MultiFactorAuthEnableResponseBody; + + if (!data.success) { + throw new Error(data.error || 'Failed to enable multi-factor authentication'); + } + + success.value = 'Multi-factor authentication has been enabled successfully! Reloading...'; + setupData.value = null; + verificationToken.value = ''; + + setTimeout(() => { + window.location.reload(); + }, 2000); + } catch (enableError) { + error.value = (enableError as Error).message; + } finally { + isLoading.value = false; + } + }; + + const disableMultiFactorAuth = async (methodId?: string, disableAll = false) => { + if (!disablePassword.value) { + error.value = 'Please enter your password'; + return; + } + + isLoading.value = true; + error.value = null; + + try { + const requestBody: MultiFactorAuthDisableRequestBody = { + methodId, + password: disablePassword.value, + disableAll, + }; + + const response = await fetch('/api/auth/multi-factor/disable', { + method: 'POST', + body: JSON.stringify(requestBody), + }); + + const data = await response.json() as MultiFactorAuthDisableResponseBody; + + if (!data.success) { + throw new Error(data.error || 'Failed to disable multi-factor authentication'); + } + + success.value = 'Multi-factor authentication has been disabled successfully! Reloading...'; + showDisableForm.value = null; + disablePassword.value = ''; + + setTimeout(() => { + window.location.reload(); + }, 2000); + } catch (disableError) { + error.value = (disableError as Error).message; + } finally { + isLoading.value = false; + } + }; + + const cancelSetup = () => { + setupData.value = null; + verificationToken.value = ''; + error.value = null; + }; + + const cancelDisable = () => { + showDisableForm.value = null; + disablePassword.value = ''; + error.value = null; + }; + + return ( +

+

+ Multi-Factor Authentication (MFA) +

+ +
+ {error.value + ? ( +
+

{error.value}

+
+ ) + : null} + + {success.value + ? ( +
+

{success.value}

+
+ ) + : null} + +

+ Multi-factor authentication adds an extra layer of security to your account by requiring additional + verification beyond your password. +

+ + {availableMethodTypes + .filter((type) => !enabledMethods.some((method) => method.type === type)).length > 0 + ? ( +
+

+ Available Authentication Methods +

+
+ {availableMethodTypes + .filter((type) => + !enabledMethods.some((method) => method.type === type) && setupData.value?.type !== type + ) + .map((type) => ( +
+
+
+

{methodTypeLabels[type]}

+

{methodTypeDescriptions[type]}

+
+ +
+
+ ))} +
+
+ ) + : null} + + {setupData.value && setupData.value.type !== 'passkey' + ? ( +
+

Setup Authenticator App

+ +
+

+ 1. Scan this QR code with your authenticator app (Aegis Authenticator, Google Authenticator, etc.): +

+
+ TOTP QR Code +
+

+ Or manually enter this secret:{' '} + {setupData.value.secret} +

+
+ +
+

+ 2. Save these backup codes NOW in a safe place: +

+
+ {setupData.value.backupCodes.map((code, index) =>
{code}
)} +
+

+ These codes can be used to access your account if you lose your authenticator device.{' '} + They won't be visible again. +

+
+ +
+ + verificationToken.value = (event.target as HTMLInputElement).value} + placeholder='123456' + class='mt-1 input-field' + maxLength={6} + /> +
+ +
+ + +
+
+ ) + : null} + + {setupData.value && setupData.value.type === 'passkey' + ? ( +
+

Passkey Setup Complete

+

+ Your passkey has been successfully registered! You can now enable it for multi-factor authentication. +

+ +
+ + +
+
+ ) + : null} + + {hasMultiFactorAuthEnabled && !showDisableForm.value + ? ( +
+
+

Active Authentication Methods

+ + {enabledMethods.map((method) => ( +
+
+
+
+ + {method.name} +
+ {method.type === 'totp' && typeof method.backupCodesCount !== 'undefined' + ? ( +

+ {method.backupCodesCount > 0 + ? `${method.backupCodesCount} backup codes remaining` + : 'No backup codes remaining'} +

+ ) + : null} +
+ +
+
+ ))} +
+ +
+ +
+
+ ) + : null} + + {showDisableForm.value + ? ( +
+

+ {showDisableForm.value === 'all' + ? 'Disable All Multi-Factor Authentication' + : 'Disable Authentication Method'} +

+

+ {showDisableForm.value === 'all' + ? 'This will disable all multi-factor authentication methods and make your account less secure.' + : 'This will disable this authentication method.'} Please enter your password to confirm. +

+ +
+ + disablePassword.value = (event.target as HTMLInputElement).value} + placeholder='Enter your password' + class='mt-1 input-field' + /> +
+ +
+ + +
+
+ ) + : null} +
+
+ ); +} diff --git a/islands/auth/PasswordlessPasskeyLogin.tsx b/islands/auth/PasswordlessPasskeyLogin.tsx new file mode 100644 index 0000000..e29a5a6 --- /dev/null +++ b/islands/auth/PasswordlessPasskeyLogin.tsx @@ -0,0 +1,116 @@ +import { useSignal } from '@preact/signals'; +import { startAuthentication } from '@simplewebauthn/browser'; + +import { + RequestBody as PasskeyBeginRequestBody, + ResponseBody as PasskeyBeginResponseBody, +} from '/routes/api/auth/multi-factor/passkey/begin.ts'; + +import { + RequestBody as PasskeyVerifyRequestBody, + ResponseBody as PasskeyVerifyResponseBody, +} from '/routes/api/auth/multi-factor/passkey/verify.ts'; + +interface PasswordlessPasskeyLoginProps { + email?: string; + redirectUrl?: string; +} + +export default function PasswordlessPasskeyLogin({ email: providedEmail, redirectUrl }: PasswordlessPasskeyLoginProps) { + const isLoading = useSignal(false); + const email = useSignal(providedEmail || null); + const error = useSignal(null); + + const handlePasswordlessLogin = async () => { + if (isLoading.value) { + return; + } + + if (!email.value) { + const promptEmail = prompt('Please enter your email'); + if (!promptEmail) { + throw new Error('Email is required to login with Passkey'); + } + email.value = promptEmail; + } + + isLoading.value = true; + error.value = null; + + try { + const beginRequestBody: PasskeyBeginRequestBody = { + email: email.value, + }; + + const beginResponse = await fetch('/api/auth/multi-factor/passkey/begin', { + method: 'POST', + body: JSON.stringify(beginRequestBody), + }); + + if (!beginResponse.ok) { + throw new Error( + `Failed to begin passwordless login! ${beginResponse.statusText} ${await beginResponse.text()}`, + ); + } + + const beginData = await beginResponse.json() as PasskeyBeginResponseBody; + + if (!beginData.success) { + throw new Error(beginData.error || 'Failed to begin passwordless login'); + } + + const authenticationResponse = await startAuthentication({ + optionsJSON: beginData.options!, + }); + + const verifyRequestBody: PasskeyVerifyRequestBody = { + email: email.value, + challenge: beginData.sessionData!.challenge, + authenticationResponse, + redirectUrl: redirectUrl || '/', + }; + + const verifyResponse = await fetch('/api/auth/multi-factor/passkey/verify', { + method: 'POST', + body: JSON.stringify(verifyRequestBody), + }); + + if (verifyResponse.ok) { + window.location.href = redirectUrl || '/'; + return; + } + + const verifyData = await verifyResponse.json() as PasskeyVerifyResponseBody; + throw new Error( + verifyData.error || `Authentication failed! ${verifyResponse.statusText} ${await verifyResponse.text()}`, + ); + } catch (handleError) { + console.error('Passwordless passkey login failed:', handleError); + error.value = (handleError as Error).message; + } finally { + isLoading.value = false; + } + }; + + return ( +
+
+ +
+ + {error.value + ? ( +
+

{error.value}

+
+ ) + : null} +
+ ); +} diff --git a/lib/auth.ts b/lib/auth.ts index e8abc3e..de59036 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -8,8 +8,10 @@ import { User, UserSession } from './types.ts'; import { UserModel, UserSessionModel, validateUserAndSession } from './models/user.ts'; import { AppConfig } from './config.ts'; -const JWT_SECRET = Deno.env.get('JWT_SECRET') || ''; +export const JWT_SECRET = Deno.env.get('JWT_SECRET') || ''; export const PASSWORD_SALT = Deno.env.get('PASSWORD_SALT') || ''; +export const MFA_KEY = Deno.env.get('MFA_KEY') || ''; +export const MFA_SALT = Deno.env.get('MFA_SALT') || ''; export const COOKIE_NAME = 'bewcloud-app-v1'; export interface JwtData { @@ -25,10 +27,10 @@ const textToData = (text: string) => new TextEncoder().encode(text); export const dataToText = (data: Uint8Array) => new TextDecoder().decode(data); -const generateKey = async (key: string) => +export const generateKey = async (key: string): Promise => await crypto.subtle.importKey('raw', textToData(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']); -async function signAuthJwt(key: CryptoKey, data: JwtData) { +async function signAuthJwt(key: CryptoKey, data: JwtData): Promise { const payload = encodeBase64Url(textToData(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))) + '.' + encodeBase64Url(textToData(JSON.stringify(data) || '')); const signature = encodeBase64Url( @@ -37,7 +39,7 @@ async function signAuthJwt(key: CryptoKey, data: JwtData) { return `${payload}.${signature}`; } -async function verifyAuthJwt(key: CryptoKey, jwt: string) { +export async function verifyAuthJwt(key: CryptoKey, jwt: string): Promise { const jwtParts = jwt.split('.'); if (jwtParts.length !== 3) { throw new Error('Malformed JWT'); @@ -51,7 +53,7 @@ async function verifyAuthJwt(key: CryptoKey, jwt: string) { throw new Error('Invalid JWT'); } -async function resolveCookieDomain(request: Request) { +export async function resolveCookieDomain(request: Request) { const config = await AppConfig.getConfig(); const baseUrl = config.auth.baseUrl; @@ -65,7 +67,9 @@ async function resolveCookieDomain(request: Request) { return ''; } -export async function getDataFromRequest(request: Request) { +export async function getDataFromRequest( + request: Request, +): Promise<{ user: User; session: UserSession | undefined; tokenData?: JwtData['data'] } | null> { const cookies = getCookies(request.headers); const authorizationHeader = request.headers.get('authorization'); @@ -119,7 +123,9 @@ async function getDataFromAuthorizationHeader(authorizationHeader: string) { return null; } -async function getDataFromCookie(cookieValue: string) { +async function getDataFromCookie( + cookieValue: string, +): Promise<{ user: User; session: UserSession | undefined; tokenData?: JwtData['data'] } | null> { if (!cookieValue) { return null; } @@ -139,7 +145,7 @@ async function getDataFromCookie(cookieValue: string) { return null; } -export async function generateToken(tokenData: JwtData['data']) { +export async function generateToken(tokenData: JwtData['data']): Promise { const key = await generateKey(JWT_SECRET); const token = await signAuthJwt(key, { data: tokenData }); diff --git a/lib/config.ts b/lib/config.ts index f402971..003a134 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -11,6 +11,7 @@ export class AppConfig { allowSignups: false, enableEmailVerification: false, enableForeverSignup: true, + enableMultiFactor: false, allowedCookieDomains: [], skipCookieDomainSecurity: false, }, @@ -193,6 +194,12 @@ export class AppConfig { return this.config.auth.enableForeverSignup; } + static async isMultiFactorAuthEnabled(): Promise { + await this.loadConfig(); + + return this.config.auth.enableMultiFactor; + } + static async getFilesRootPath(): Promise { await this.loadConfig(); diff --git a/lib/interfaces/database.ts b/lib/interfaces/database.ts index bb1aff7..393ff96 100644 --- a/lib/interfaces/database.ts +++ b/lib/interfaces/database.ts @@ -61,7 +61,19 @@ export default class Database { this.db = postgresClient; } else { - throw error; + console.log('Failed to connect to Postgres!'); + console.error(error); + + // This allows tests (and the app) to work even if Postgres is not available + const mockPostgresClient = { + queryObject: () => { + return { + rows: [], + }; + }, + } as unknown as Client; + + this.db = mockPostgresClient; } } } diff --git a/lib/models/multi-factor-auth.ts b/lib/models/multi-factor-auth.ts new file mode 100644 index 0000000..bc7216e --- /dev/null +++ b/lib/models/multi-factor-auth.ts @@ -0,0 +1,142 @@ +import { Cookie, getCookies, setCookie } from 'std/http/cookie.ts'; + +import { MultiFactorAuthMethod, User } from '/lib/types.ts'; +import { getMultiFactorAuthMethodByIdFromUser } from '/lib/utils/multi-factor-auth.ts'; +import { + COOKIE_NAME as AUTH_COOKIE_NAME, + generateKey, + generateToken, + JWT_SECRET, + JwtData, + resolveCookieDomain, + verifyAuthJwt, +} from '/lib/auth.ts'; +import { isRunningLocally } from '/lib/utils/misc.ts'; +import { AppConfig } from '/lib/config.ts'; +import { UserModel } from './user.ts'; + +const COOKIE_NAME = `${AUTH_COOKIE_NAME}-mfa`; +const MFA_SESSION_ID = 'mfa'; + +export interface MultiFactorAuthSetup { + method: MultiFactorAuthMethod; + qrCodeUrl?: string; + plainTextSecret?: string; + plainTextBackupCodes?: string[]; +} + +export class MultiFactorAuthModel { + static generateMethodId(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + + return Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); + } + + static enableMethodForUser( + user: { extra: Pick }, + methodId: string, + ): void { + const method = getMultiFactorAuthMethodByIdFromUser(user, methodId); + if (method) { + method.enabled = true; + } + } + + static disableMethodFromUser( + user: { extra: Pick }, + methodId: string, + ): void { + const method = getMultiFactorAuthMethodByIdFromUser(user, methodId); + if (method) { + method.enabled = false; + } + } + + static async createSessionResponse( + request: Request, + user: User, + { urlToRedirectTo = '/' }: { + urlToRedirectTo?: string; + } = {}, + ) { + const response = new Response('MFA Required', { + status: 303, + headers: { + 'Location': `/mfa-verify?user=${user.id}&redirect=${encodeURIComponent(urlToRedirectTo)}`, + 'Content-Type': 'text/html; charset=utf-8', + }, + }); + + const responseWithCookie = await this.createSessionCookie(request, user, response); + + return responseWithCookie; + } + + private static async createSessionCookie( + request: Request, + user: User, + response: Response, + ) { + const token = await generateToken({ user_id: user.id, session_id: MFA_SESSION_ID }); + + const cookie: Cookie = { + name: COOKIE_NAME, + value: token, + expires: new Date(Date.now() + 1000 * 60 * 30), // 30 minutes + path: '/', + secure: isRunningLocally(request) ? false : true, + httpOnly: true, + sameSite: 'Lax', + domain: await resolveCookieDomain(request), + }; + + if (await AppConfig.isCookieDomainSecurityDisabled()) { + delete cookie.domain; + } + + setCookie(response.headers, cookie); + + return response; + } + + static async getDataFromRequest(request: Request): Promise<{ user: User } | null> { + const cookies = getCookies(request.headers); + + if (cookies[COOKIE_NAME]) { + const result = await this.getDataFromCookie(cookies[COOKIE_NAME]); + + if (result) { + return result; + } + } + + return null; + } + + private static async getDataFromCookie(cookieValue: string): Promise<{ user: User } | null> { + if (!cookieValue) { + return null; + } + + const key = await generateKey(JWT_SECRET); + + try { + const token = await verifyAuthJwt(key, cookieValue) as JwtData; + + const user = await UserModel.getById(token.data.user_id); + + if (!user || token.data.session_id !== MFA_SESSION_ID) { + throw new Error('Not Found'); + } + + return { user }; + } catch (error) { + console.error(error); + } + + return null; + } +} diff --git a/lib/models/multi-factor-auth/passkey.ts b/lib/models/multi-factor-auth/passkey.ts new file mode 100644 index 0000000..e62b63c --- /dev/null +++ b/lib/models/multi-factor-auth/passkey.ts @@ -0,0 +1,198 @@ +import { + AuthenticationResponseJSON, + generateAuthenticationOptions, + generateRegistrationOptions, + PublicKeyCredentialCreationOptionsJSON, + RegistrationResponseJSON, + VerifiedAuthenticationResponse, + VerifiedRegistrationResponse, + verifyAuthenticationResponse, + verifyRegistrationResponse, +} from '@simplewebauthn/server'; +import { isoBase64URL } from '@simplewebauthn/server/helpers'; + +import { MultiFactorAuthMethod, User } from '/lib/types.ts'; + +export interface PasskeyCredential { + credentialID: string; + credentialPublicKey: string; + counter: number; + credentialDeviceType: string; + credentialBackedUp: boolean; + transports?: AuthenticatorTransport[]; +} + +export interface PasskeySetupData { + methodId: string; + options: PublicKeyCredentialCreationOptionsJSON; +} + +export interface PasskeyAuthenticationData { + options: PublicKeyCredentialCreationOptionsJSON; +} + +const RP_NAME = 'bewCloud'; +const RP_ID = (baseUrl: string) => { + try { + return new URL(baseUrl).hostname; + } catch { + return 'localhost'; + } +}; + +/** + * Excludes Ed25519 as per https://simplewebauthn.dev/docs/packages/server#domexception-notsupportederror-unrecognized-name + */ +const SUPPORTED_ALGORITHM_IDS = [-7, -257]; + +export class PasskeyModel { + static async generateRegistrationOptions( + userId: string, + email: string, + baseUrl: string, + existingCredentials: PasskeyCredential[] = [], + ): Promise { + const options = await generateRegistrationOptions({ + rpName: RP_NAME, + rpID: RP_ID(baseUrl), + userID: new TextEncoder().encode(userId), + userName: email, + userDisplayName: email, + attestationType: 'none', + excludeCredentials: existingCredentials.map((credential) => ({ + id: credential.credentialID, + type: 'public-key', + transports: credential.transports || [], + })), + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'preferred', + authenticatorAttachment: 'platform', + }, + supportedAlgorithmIDs: SUPPORTED_ALGORITHM_IDS, + }); + + return options; + } + + static async verifyRegistration( + response: RegistrationResponseJSON, + expectedChallenge: string, + expectedOrigin: string, + expectedRPID: string, + ): Promise { + const verification = await verifyRegistrationResponse({ + response, + expectedChallenge, + expectedOrigin, + expectedRPID, + supportedAlgorithmIDs: SUPPORTED_ALGORITHM_IDS, + }); + + return verification; + } + + static async generateAuthenticationOptions( + baseUrl: string, + allowedCredentials?: PasskeyCredential[], + ): Promise { + const options = await generateAuthenticationOptions({ + rpID: RP_ID(baseUrl), + allowCredentials: allowedCredentials?.map((credential) => ({ + id: credential.credentialID, + type: 'public-key', + transports: credential.transports, + })), + userVerification: 'preferred', + }); + + return options as PublicKeyCredentialCreationOptionsJSON; + } + + static async verifyAuthentication( + response: AuthenticationResponseJSON, + expectedChallenge: string, + expectedOrigin: string, + expectedRPID: string, + credential: PasskeyCredential, + ): Promise { + const verification = await verifyAuthenticationResponse({ + response, + expectedChallenge, + expectedOrigin, + expectedRPID, + credential: { + id: credential.credentialID, + publicKey: isoBase64URL.toBuffer(credential.credentialPublicKey), + counter: credential.counter, + transports: credential.transports, + }, + }); + + return verification; + } + + static createMethod( + id: string, + name: string, + credentialID: string, + credentialPublicKey: string, + counter: number, + credentialDeviceType: string, + credentialBackedUp: boolean, + transports?: AuthenticatorTransport[], + ): MultiFactorAuthMethod { + return { + type: 'passkey', + id, + name, + enabled: false, + created_at: new Date(), + metadata: { + passkey: { + credential_id: credentialID, + public_key: credentialPublicKey, + counter, + device_type: credentialDeviceType, + backed_up: credentialBackedUp, + transports, + }, + }, + }; + } + + static getCredentialsFromUser( + user: { extra: Pick }, + ): PasskeyCredential[] { + if (!user.extra.multi_factor_auth_methods) return []; + + return user.extra.multi_factor_auth_methods + .filter((method) => method.type === 'passkey' && method.enabled && method.metadata.passkey) + .map((method) => ({ + credentialID: method.metadata.passkey!.credential_id, + credentialPublicKey: method.metadata.passkey!.public_key, + counter: method.metadata.passkey!.counter || 0, + credentialDeviceType: method.metadata.passkey!.device_type || 'unknown', + credentialBackedUp: method.metadata.passkey!.backed_up || false, + transports: method.metadata.passkey!.transports, + })); + } + + static updateCounterForUser( + user: { extra: Pick }, + credentialID: string, + newCounter: number, + ): void { + if (!user.extra.multi_factor_auth_methods) { + return; + } + + const method = user.extra.multi_factor_auth_methods.find( + (method) => method.type === 'passkey' && method.metadata.passkey?.credential_id === credentialID, + ); + + if (method?.metadata.passkey) { + method.metadata.passkey.counter = newCounter; + } + } +} diff --git a/lib/models/multi-factor-auth/totp.ts b/lib/models/multi-factor-auth/totp.ts new file mode 100644 index 0000000..3d09419 --- /dev/null +++ b/lib/models/multi-factor-auth/totp.ts @@ -0,0 +1,227 @@ +import { Secret, TOTP } from 'otpauth'; +import QRCode from 'qrcode'; +import { encodeBase32 } from 'std/encoding/base32.ts'; +import { decodeBase64, encodeBase64 } from 'std/encoding/base64.ts'; + +import { MultiFactorAuthMethod } from '/lib/types.ts'; +import { MFA_KEY, MFA_SALT } from '/lib/auth.ts'; +import { generateHash } from '/lib/utils/misc.ts'; +import { MultiFactorAuthSetup } from '/lib/models/multi-factor-auth.ts'; + +export class TOTPModel { + private static async getEncryptionKey(): Promise { + const keyMaterial = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(MFA_KEY), + { name: 'PBKDF2' }, + false, + ['deriveKey'], + ); + + return await crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: new TextEncoder().encode(MFA_SALT), + iterations: 100000, + hash: 'SHA-256', + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); + } + + private static generateBackupCodes(count = 8): string[] { + const codes: string[] = []; + for (let i = 0; i < count; i++) { + const bytes = new Uint8Array(4); + crypto.getRandomValues(bytes); + const code = Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join('') + .substring(0, 8); + codes.push(code); + } + return codes; + } + + private static async hashBackupCodes(codes: string[]): Promise { + const hashedCodes: string[] = []; + for (const code of codes) { + const hashedCode = await generateHash(`${code}:${MFA_SALT}`, 'SHA-256'); + hashedCodes.push(hashedCode); + } + return hashedCodes; + } + + private static async verifyBackupCodeHash( + code: string, + hashedCodes: string[], + ): Promise<{ isValid: boolean; codeIndex: number }> { + const hashedInput = await generateHash(`${code}:${MFA_SALT}`, 'SHA-256'); + const codeIndex = hashedCodes.indexOf(hashedInput); + return { isValid: codeIndex !== -1, codeIndex }; + } + + private static async verifyBackupCodeHashed( + hashedBackupCodes: string[], + providedCode: string, + ): Promise<{ isValid: boolean; remainingCodes: string[] }> { + const { isValid, codeIndex } = await this.verifyBackupCodeHash(providedCode, hashedBackupCodes); + + if (!isValid) { + return { isValid: false, remainingCodes: hashedBackupCodes }; + } + + const remainingCodes = [...hashedBackupCodes]; + remainingCodes.splice(codeIndex, 1); + + return { isValid: true, remainingCodes }; + } + + private static async encryptTOTPSecret(secret: string): Promise { + const key = await this.getEncryptionKey(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encodedSecret = new TextEncoder().encode(secret); + + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + encodedSecret, + ); + + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv); + combined.set(new Uint8Array(encrypted), iv.length); + + return encodeBase64(combined); + } + + static async decryptTOTPSecret(encryptedSecret: string): Promise { + const key = await this.getEncryptionKey(); + const combined = decodeBase64(encryptedSecret); + const iv = combined.slice(0, 12); + const encrypted = combined.slice(12); + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + encrypted, + ); + + return new TextDecoder().decode(decrypted); + } + + private static generateTOTPSecret(): string { + const bytes = new Uint8Array(20); + crypto.getRandomValues(bytes); + return encodeBase32(bytes); + } + + private static createTOTP(secret: string, issuer: string, accountName: string): TOTP { + return new TOTP({ + issuer, + label: accountName, + algorithm: 'SHA1', + digits: 6, + period: 30, + secret: Secret.fromBase32(secret), + }); + } + + private static async generateQRCodeDataURL(secret: string, issuer: string, accountName: string): Promise { + const totp = this.createTOTP(secret, issuer, accountName); + const uri = totp.toString(); + return await QRCode.toDataURL(uri); + } + + private static verifyTOTPToken(secret: string, token: string, window = 1): boolean { + const totp = new TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret: Secret.fromBase32(secret), + }); + + const currentTime = Math.floor(Date.now() / 1000); + + for (let i = -window; i <= window; i++) { + const testTime = currentTime + (i * 30); + const expectedToken = totp.generate({ timestamp: testTime * 1000 }); + if (expectedToken === token) { + return true; + } + } + + return false; + } + + static async createMethod( + id: string, + name: string, + issuer: string, + accountName: string, + ): Promise { + const secret = this.generateTOTPSecret(); + const backupCodes = this.generateBackupCodes(); + const qrCodeUrl = await this.generateQRCodeDataURL(secret, issuer, accountName); + + const encryptedSecret = await this.encryptTOTPSecret(secret); + const hashedBackupCodes = await this.hashBackupCodes(backupCodes); + + const method: MultiFactorAuthMethod = { + type: 'totp', + id, + name, + enabled: false, + created_at: new Date(), + metadata: { + totp: { + hashed_secret: encryptedSecret, + hashed_backup_codes: hashedBackupCodes, + }, + }, + }; + + return { + method, + qrCodeUrl, + plainTextSecret: secret, + plainTextBackupCodes: backupCodes, + }; + } + + static async verifyMethodToken( + metadata: MultiFactorAuthMethod['metadata'], + token: string, + ): Promise<{ isValid: boolean; remainingCodes?: string[] }> { + if (!metadata.totp) { + return { isValid: false }; + } + + const { totp } = metadata; + + if (token.length === 6 && /^\d+$/.test(token)) { // Try the TOTP first + try { + const decryptedSecret = await this.decryptTOTPSecret(totp.hashed_secret); + const isValid = this.verifyTOTPToken(decryptedSecret, token); + return { isValid }; + } catch { + return { isValid: false }; + } + } else if (token.length === 8 && /^[a-fA-F0-9]+$/.test(token)) { // Otherwise, try the backup codes + const { isValid, remainingCodes } = await this.verifyBackupCodeHashed( + totp.hashed_backup_codes, + token.toLowerCase(), + ); + return { isValid, remainingCodes }; + } + + return { isValid: false }; + } + + static verifyTOTP(secret: string, token: string): boolean { + return this.verifyTOTPToken(secret, token); + } +} diff --git a/lib/types.ts b/lib/types.ts index b0f2aa5..bca1340 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -13,6 +13,7 @@ export interface User { is_admin?: boolean; dav_hashed_password?: string; expenses_currency?: SupportedCurrencySymbol; + multi_factor_auth_methods?: MultiFactorAuthMethod[]; }; created_at: Date; } @@ -156,6 +157,8 @@ export interface Config { enableEmailVerification: boolean; /** If true, all signups become active for 100 years */ enableForeverSignup: boolean; + /** If true, users can enable multi-factor authentication (TOTP or Passkeys) */ + enableMultiFactor: boolean; /** Can be set to allow more than the baseUrl's domain for session cookies */ allowedCookieDomains: string[]; /** If true, the cookie domain will not be strictly set and checked against. This skipping slightly reduces security, but is usually necessary for reverse proxies like Cloudflare Tunnel. */ @@ -178,3 +181,27 @@ export interface Config { helpEmail: string; }; } + +export type MultiFactorAuthMethodType = 'totp' | 'passkey'; + +export interface MultiFactorAuthMethod { + type: MultiFactorAuthMethodType; + id: string; + name: string; + enabled: boolean; + created_at: Date; + metadata: { + totp?: { + hashed_secret: string; + hashed_backup_codes: string[]; + }; + passkey?: { + credential_id: string; + public_key: string; + counter?: number; + device_type?: string; + backed_up?: boolean; + transports?: AuthenticatorTransport[]; + }; + }; +} diff --git a/lib/utils/multi-factor-auth.ts b/lib/utils/multi-factor-auth.ts new file mode 100644 index 0000000..4efc606 --- /dev/null +++ b/lib/utils/multi-factor-auth.ts @@ -0,0 +1,28 @@ +// This file contains some multi-factor authentication utilities that are isomorphic. + +import { MultiFactorAuthMethod, User } from '/lib/types.ts'; + +export function getMultiFactorAuthMethodsFromUser( + user: { extra: Pick }, +): MultiFactorAuthMethod[] { + return user.extra.multi_factor_auth_methods || []; +} + +export function getEnabledMultiFactorAuthMethodsFromUser( + user: { extra: Pick }, +): MultiFactorAuthMethod[] { + return getMultiFactorAuthMethodsFromUser(user).filter((method) => method.enabled); +} + +export function getMultiFactorAuthMethodByIdFromUser( + user: { extra: Pick }, + id: string, +): MultiFactorAuthMethod | undefined { + return getMultiFactorAuthMethodsFromUser(user).find((method) => method.id === id); +} + +export function isMultiFactorAuthEnabledForUser( + user: { extra: Pick }, +): boolean { + return getEnabledMultiFactorAuthMethodsFromUser(user).length > 0; +} diff --git a/routes/api/auth/multi-factor/disable.ts b/routes/api/auth/multi-factor/disable.ts new file mode 100644 index 0000000..26129c6 --- /dev/null +++ b/routes/api/auth/multi-factor/disable.ts @@ -0,0 +1,119 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { PASSWORD_SALT } from '/lib/auth.ts'; +import { generateHash } from '/lib/utils/misc.ts'; +import { UserModel } from '/lib/models/user.ts'; +import { + getMultiFactorAuthMethodByIdFromUser, + getMultiFactorAuthMethodsFromUser, +} from '/lib/utils/multi-factor-auth.ts'; +import { AppConfig } from '/lib/config.ts'; +import { MultiFactorAuthModel } from '/lib/models/multi-factor-auth.ts'; + +export interface RequestBody { + methodId?: string; + password: string; + disableAll?: boolean; +} + +export interface ResponseBody { + success: boolean; + error?: string; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled(); + + if (!isMultiFactorAuthEnabled) { + const responseBody: ResponseBody = { + success: false, + error: 'Multi-factor authentication is not enabled on this server', + }; + + return new Response(JSON.stringify(responseBody), { status: 403 }); + } + + const { user } = context.state; + + const body = await request.clone().json() as RequestBody; + const { methodId, password, disableAll } = body; + + if (!password) { + const responseBody: ResponseBody = { + success: false, + error: 'Password is required', + }; + + return new Response(JSON.stringify(responseBody), { status: 400 }); + } + + const hashedPassword = await generateHash(`${password}:${PASSWORD_SALT}`, 'SHA-256'); + + if (user.hashed_password !== hashedPassword) { + const responseBody: ResponseBody = { + success: false, + error: 'Invalid password', + }; + + return new Response(JSON.stringify(responseBody), { status: 400 }); + } + + if (disableAll) { + user.extra.multi_factor_auth_methods = []; + + await UserModel.update(user); + + const responseBody: ResponseBody = { + success: true, + }; + + return new Response(JSON.stringify(responseBody)); + } + + if (!methodId) { + const responseBody: ResponseBody = { + success: false, + error: 'Method ID is required when not disabling all methods', + }; + + return new Response(JSON.stringify(responseBody), { status: 400 }); + } + + const methods = getMultiFactorAuthMethodsFromUser(user); + const method = getMultiFactorAuthMethodByIdFromUser(user, methodId); + + if (!method) { + const responseBody: ResponseBody = { + success: false, + error: 'Multi-factor authentication method not found', + }; + + return new Response(JSON.stringify(responseBody), { status: 404 }); + } + + if (!method.enabled) { + const responseBody: ResponseBody = { + success: false, + error: 'Multi-factor authentication method is not enabled', + }; + + return new Response(JSON.stringify(responseBody), { status: 400 }); + } + + MultiFactorAuthModel.disableMethodFromUser(user, methodId); + + await UserModel.update(user); + + const responseBody: ResponseBody = { + success: true, + }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/auth/multi-factor/enable.ts b/routes/api/auth/multi-factor/enable.ts new file mode 100644 index 0000000..26752e7 --- /dev/null +++ b/routes/api/auth/multi-factor/enable.ts @@ -0,0 +1,130 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { MultiFactorAuthModel } from '/lib/models/multi-factor-auth.ts'; +import { TOTPModel } from '/lib/models/multi-factor-auth/totp.ts'; +import { getMultiFactorAuthMethodByIdFromUser } from '/lib/utils/multi-factor-auth.ts'; +import { UserModel } from '/lib/models/user.ts'; +import { AppConfig } from '/lib/config.ts'; + +export interface RequestBody { + methodId: string; + code: string | 'passkey-verified'; +} + +export interface ResponseBody { + success: boolean; + error?: string; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled(); + + if (!isMultiFactorAuthEnabled) { + const responseBody: ResponseBody = { + success: false, + error: 'Multi-factor authentication is not enabled on this server', + }; + + return new Response(JSON.stringify(responseBody), { status: 403 }); + } + + const { user } = context.state; + + const body = await request.clone().json() as RequestBody; + const { methodId, code } = body; + + if (!methodId || !code) { + const responseBody: ResponseBody = { + success: false, + error: 'Method ID and verification code are required', + }; + + return new Response(JSON.stringify(responseBody), { status: 400 }); + } + + const method = getMultiFactorAuthMethodByIdFromUser(user, methodId); + if (!method) { + const responseBody: ResponseBody = { + success: false, + error: 'Multi-factor authentication method not found', + }; + + return new Response(JSON.stringify(responseBody), { status: 404 }); + } + + if (method.enabled) { + const responseBody: ResponseBody = { + success: false, + error: 'Multi-factor authentication method is already enabled', + }; + + return new Response(JSON.stringify(responseBody), { status: 400 }); + } + + if (method.type === 'totp') { + const hashedSecret = method.metadata.totp?.hashed_secret; + if (!hashedSecret) { + const responseBody: ResponseBody = { + success: false, + error: 'TOTP secret not found', + }; + + return new Response(JSON.stringify(responseBody), { status: 400 }); + } + + try { + const secret = await TOTPModel.decryptTOTPSecret(hashedSecret); + const isValid = TOTPModel.verifyTOTP(secret, code); + if (!isValid) { + const responseBody: ResponseBody = { + success: false, + error: 'Invalid verification code', + }; + + return new Response(JSON.stringify(responseBody), { status: 400 }); + } + } catch { + const responseBody: ResponseBody = { + success: false, + error: 'Failed to decrypt TOTP secret', + }; + + return new Response(JSON.stringify(responseBody), { status: 500 }); + } + } else if (method.type === 'passkey') { + if (code !== 'passkey-verified') { + const responseBody: ResponseBody = { + success: false, + error: 'Passkey not properly verified', + }; + + return new Response(JSON.stringify(responseBody), { status: 400 }); + } + + if (!method.metadata.passkey?.credential_id || !method.metadata.passkey?.public_key) { + const responseBody: ResponseBody = { + success: false, + error: 'Passkey credentials not found', + }; + + return new Response(JSON.stringify(responseBody), { status: 400 }); + } + } + + MultiFactorAuthModel.enableMethodForUser(user, methodId); + + await UserModel.update(user); + + const responseBody: ResponseBody = { + success: true, + }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/auth/multi-factor/passkey/begin.ts b/routes/api/auth/multi-factor/passkey/begin.ts new file mode 100644 index 0000000..ff3f444 --- /dev/null +++ b/routes/api/auth/multi-factor/passkey/begin.ts @@ -0,0 +1,87 @@ +import { Handlers } from 'fresh/server.ts'; +import { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/server'; + +import { FreshContextState } from '/lib/types.ts'; +import { PasskeyModel } from '/lib/models/multi-factor-auth/passkey.ts'; +import { UserModel } from '/lib/models/user.ts'; +import { AppConfig } from '/lib/config.ts'; + +export interface RequestBody { + email: string; +} + +export interface ResponseBody { + success: boolean; + error?: string; + options?: PublicKeyCredentialCreationOptionsJSON; + sessionData?: { + challenge: string; + methodId: string; + }; +} + +export const handler: Handlers = { + async POST(request) { + const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled(); + + if (!isMultiFactorAuthEnabled) { + const responseBody: ResponseBody = { + success: false, + error: 'Passwordless passkey login requires multi-factor authentication to be enabled.', + }; + + return new Response(JSON.stringify(responseBody), { status: 403 }); + } + + const body = await request.clone().json() as RequestBody; + const { email } = body; + + if (!email) { + const responseBody: ResponseBody = { + success: false, + error: 'Email is required', + }; + + return new Response(JSON.stringify(responseBody), { status: 400 }); + } + + const user = await UserModel.getByEmail(email); + + if (!user) { + const responseBody: ResponseBody = { + success: false, + error: 'User not found', + }; + + return new Response(JSON.stringify(responseBody), { status: 404 }); + } + + const config = await AppConfig.getConfig(); + const allowedCredentials = PasskeyModel.getCredentialsFromUser(user); + + if (allowedCredentials.length === 0) { + const responseBody: ResponseBody = { + success: false, + error: 'No passkeys registered for this user', + }; + + return new Response(JSON.stringify(responseBody), { status: 400 }); + } + + const options = await PasskeyModel.generateAuthenticationOptions( + config.auth.baseUrl, + allowedCredentials, + ); + + const responseBody: ResponseBody = { + success: true, + options, + sessionData: { + challenge: options.challenge, + methodId: options.challenge, + }, + }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/auth/multi-factor/passkey/setup-begin.ts b/routes/api/auth/multi-factor/passkey/setup-begin.ts new file mode 100644 index 0000000..cc47753 --- /dev/null +++ b/routes/api/auth/multi-factor/passkey/setup-begin.ts @@ -0,0 +1,67 @@ +import { Handlers } from 'fresh/server.ts'; +import { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/server'; + +import { FreshContextState } from '/lib/types.ts'; +import { AppConfig } from '/lib/config.ts'; +import { PasskeyModel } from '/lib/models/multi-factor-auth/passkey.ts'; +import { MultiFactorAuthModel } from '/lib/models/multi-factor-auth.ts'; + +export interface RequestBody {} + +export interface ResponseBody { + success: boolean; + error?: string; + options?: PublicKeyCredentialCreationOptionsJSON; + sessionData?: { + challenge: string; + methodId: string; + userId: string; + }; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled(); + + if (!isMultiFactorAuthEnabled) { + const responseBody: ResponseBody = { + success: false, + error: 'Passwordless passkey login requires multi-factor authentication to be enabled.', + }; + + return new Response(JSON.stringify(responseBody), { status: 403 }); + } + + const { user } = context.state; + + const methodId = MultiFactorAuthModel.generateMethodId(); + + const config = await AppConfig.getConfig(); + const existingCredentials = PasskeyModel.getCredentialsFromUser(user); + + const options = await PasskeyModel.generateRegistrationOptions( + user.id, + user.email, + config.auth.baseUrl, + existingCredentials, + ); + + const sessionData: ResponseBody['sessionData'] = { + challenge: options.challenge, + methodId, + userId: user.id, + }; + + const responseBody: ResponseBody = { + success: true, + options, + sessionData, + }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/auth/multi-factor/passkey/setup-complete.ts b/routes/api/auth/multi-factor/passkey/setup-complete.ts new file mode 100644 index 0000000..c3bb73e --- /dev/null +++ b/routes/api/auth/multi-factor/passkey/setup-complete.ts @@ -0,0 +1,102 @@ +import { Handlers } from 'fresh/server.ts'; +import { isoBase64URL } from '@simplewebauthn/server/helpers'; +import { RegistrationResponseJSON } from '@simplewebauthn/server'; + +import { FreshContextState } from '/lib/types.ts'; +import { PasskeyModel } from '/lib/models/multi-factor-auth/passkey.ts'; +import { UserModel } from '/lib/models/user.ts'; +import { AppConfig } from '/lib/config.ts'; + +export interface RequestBody { + methodId: string; + challenge: string; + registrationResponse: RegistrationResponseJSON; +} + +export interface ResponseBody { + success: boolean; + error?: string; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled(); + + if (!isMultiFactorAuthEnabled) { + const responseBody: ResponseBody = { + success: false, + error: 'Passwordless passkey login requires multi-factor authentication to be enabled.', + }; + + return new Response(JSON.stringify(responseBody), { status: 403 }); + } + + const { user } = context.state; + + const body = await request.clone().json() as RequestBody; + const { methodId, challenge, registrationResponse } = body; + + if (!methodId || !challenge || !registrationResponse) { + const responseBody: ResponseBody = { + success: false, + error: 'Method ID, challenge, and registration response are required', + }; + + return new Response(JSON.stringify(responseBody), { status: 400 }); + } + + const config = await AppConfig.getConfig(); + const expectedOrigin = config.auth.baseUrl; + const expectedRPID = new URL(config.auth.baseUrl).hostname; + + const verification = await PasskeyModel.verifyRegistration( + registrationResponse, + challenge, + expectedOrigin, + expectedRPID, + ); + + if (!verification.verified || !verification.registrationInfo) { + const responseBody: ResponseBody = { + success: false, + error: 'Passkey registration verification failed', + }; + + return new Response(JSON.stringify(responseBody), { status: 400 }); + } + + const { registrationInfo } = verification; + const credentialID = registrationInfo.credential.id; + const credentialPublicKey = isoBase64URL.fromBuffer(registrationInfo.credential.publicKey); + + const method = PasskeyModel.createMethod( + methodId, + 'Passkey', + credentialID, + credentialPublicKey, + registrationInfo.credential.counter, + registrationInfo.credentialDeviceType, + registrationInfo.credentialBackedUp, + // @ts-expect-error SimpleWebAuthn supports a few more transports, and that's OK + registrationResponse.response?.transports || [], + ); + + if (!user.extra.multi_factor_auth_methods) { + user.extra.multi_factor_auth_methods = []; + } + + user.extra.multi_factor_auth_methods.push(method); + + await UserModel.update(user); + + const responseBody: ResponseBody = { + success: true, + }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/auth/multi-factor/passkey/verify.ts b/routes/api/auth/multi-factor/passkey/verify.ts new file mode 100644 index 0000000..f69dffa --- /dev/null +++ b/routes/api/auth/multi-factor/passkey/verify.ts @@ -0,0 +1,99 @@ +import { Handlers } from 'fresh/server.ts'; +import { AuthenticationResponseJSON } from '@simplewebauthn/server'; + +import { FreshContextState } from '/lib/types.ts'; +import { PasskeyModel } from '/lib/models/multi-factor-auth/passkey.ts'; +import { UserModel } from '/lib/models/user.ts'; +import { AppConfig } from '/lib/config.ts'; +import { createSessionResponse } from '/lib/auth.ts'; + +export interface RequestBody { + email: string; + challenge: string; + authenticationResponse: AuthenticationResponseJSON; + redirectUrl?: string; +} + +export interface ResponseBody { + success: boolean; + error?: string; +} + +export const handler: Handlers = { + async POST(request) { + const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled(); + + if (!isMultiFactorAuthEnabled) { + const responseBody: ResponseBody = { + success: false, + error: 'Multi-factor authentication is not enabled on this server', + }; + + return new Response(JSON.stringify(responseBody), { status: 403 }); + } + + const body = await request.clone().json() as RequestBody; + const { email, challenge, authenticationResponse, redirectUrl } = body; + + if (!email || !challenge || !authenticationResponse) { + const responseBody: ResponseBody = { + success: false, + error: 'Email, challenge, and authentication response are required', + }; + + return new Response(JSON.stringify(responseBody), { status: 400 }); + } + + const user = await UserModel.getByEmail(email); + if (!user) { + const responseBody: ResponseBody = { + success: false, + error: 'User not found', + }; + + return new Response(JSON.stringify(responseBody), { status: 404 }); + } + + const config = await AppConfig.getConfig(); + const expectedOrigin = config.auth.baseUrl; + const expectedRPID = new URL(config.auth.baseUrl).hostname; + + const userCredentials = PasskeyModel.getCredentialsFromUser(user); + const credentialID = authenticationResponse.id; + + const credential = userCredentials.find((credential) => credential.credentialID === credentialID); + if (!credential) { + const responseBody: ResponseBody = { + success: false, + error: 'Credential not found for this user', + }; + + return new Response(JSON.stringify(responseBody), { status: 400 }); + } + + const verification = await PasskeyModel.verifyAuthentication( + authenticationResponse, + challenge, + expectedOrigin, + expectedRPID, + credential, + ); + + if (!verification.verified) { + const responseBody: ResponseBody = { + success: false, + error: 'Passkey authentication verification failed', + }; + + return new Response(JSON.stringify(responseBody), { status: 400 }); + } + + // Update the counter to protect against replay attacks + PasskeyModel.updateCounterForUser(user, credentialID, verification.authenticationInfo.newCounter); + await UserModel.update(user); + + return await createSessionResponse(request, user, { + urlToRedirectTo: redirectUrl || '/', + }); + }, +}; diff --git a/routes/api/auth/multi-factor/totp/setup.ts b/routes/api/auth/multi-factor/totp/setup.ts new file mode 100644 index 0000000..79b8d15 --- /dev/null +++ b/routes/api/auth/multi-factor/totp/setup.ts @@ -0,0 +1,66 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { UserModel } from '/lib/models/user.ts'; +import { AppConfig } from '/lib/config.ts'; +import { MultiFactorAuthModel } from '/lib/models/multi-factor-auth.ts'; +import { TOTPModel } from '/lib/models/multi-factor-auth/totp.ts'; + +export interface RequestBody {} + +export interface ResponseBody { + success: boolean; + error?: string; + data?: { + methodId: string; + secret?: string; + qrCodeUrl?: string; + backupCodes?: string[]; + }; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled(); + + if (!isMultiFactorAuthEnabled) { + const responseBody: ResponseBody = { + success: false, + error: 'Multi-factor authentication is not enabled on this server', + }; + + return new Response(JSON.stringify(responseBody), { status: 403 }); + } + + const { user } = context.state; + + const config = await AppConfig.getConfig(); + const issuer = new URL(config.auth.baseUrl).hostname; + const methodId = MultiFactorAuthModel.generateMethodId(); + const setup = await TOTPModel.createMethod(methodId, 'Authenticator App', issuer, user.email); + + if (!user.extra.multi_factor_auth_methods) { + user.extra.multi_factor_auth_methods = []; + } + + user.extra.multi_factor_auth_methods.push(setup.method); + + await UserModel.update(user); + + const responseData: ResponseBody = { + success: true, + data: { + methodId: setup.method.id, + secret: setup.plainTextSecret, + qrCodeUrl: setup.qrCodeUrl, + backupCodes: setup.plainTextBackupCodes, + }, + }; + + return new Response(JSON.stringify(responseData)); + }, +}; diff --git a/routes/login.tsx b/routes/login.tsx index c4bf345..33e0b43 100644 --- a/routes/login.tsx +++ b/routes/login.tsx @@ -7,6 +7,9 @@ import { UserModel, VerificationCodeModel } from '/lib/models/user.ts'; import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts'; import { FreshContextState } from '/lib/types.ts'; import { AppConfig } from '/lib/config.ts'; +import { isMultiFactorAuthEnabledForUser } from '/lib/utils/multi-factor-auth.ts'; +import { MultiFactorAuthModel } from '/lib/models/multi-factor-auth.ts'; +import PasswordlessPasskeyLogin from '/islands/auth/PasswordlessPasskeyLogin.tsx'; interface Data { error?: string; @@ -14,6 +17,7 @@ interface Data { email?: string; formData?: FormData; isEmailVerificationEnabled: boolean; + isMultiFactorAuthEnabled: boolean; helpEmail: string; } @@ -24,6 +28,7 @@ export const handler: Handlers = { } const isEmailVerificationEnabled = await AppConfig.isEmailVerificationEnabled(); + const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled() && await UserModel.isThereAnAdmin(); const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail; const searchParams = new URL(request.url).searchParams; @@ -43,7 +48,14 @@ export const handler: Handlers = { } } - return await context.render({ notice, email, formData, isEmailVerificationEnabled, helpEmail }); + return await context.render({ + notice, + email, + formData, + isEmailVerificationEnabled, + isMultiFactorAuthEnabled, + helpEmail, + }); }, async POST(request, context) { if (context.state.user) { @@ -51,11 +63,16 @@ export const handler: Handlers = { } const isEmailVerificationEnabled = await AppConfig.isEmailVerificationEnabled(); + const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled() && await UserModel.isThereAnAdmin(); const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail; + const searchParams = new URL(request.url).searchParams; + const formData = await request.clone().formData(); const email = getFormDataField(formData, 'email'); + const redirectUrl = searchParams.get('redirect') || '/'; + try { if (!validateEmail(email)) { throw new Error(`Invalid email.`); @@ -75,7 +92,9 @@ export const handler: Handlers = { throw new Error('Email not found or invalid password.'); } - if (!(await AppConfig.isEmailVerificationEnabled()) && !user.extra.is_email_verified) { + const isEmailVerificationEnabled = await AppConfig.isEmailVerificationEnabled(); + + if (!isEmailVerificationEnabled && !user.extra.is_email_verified) { user.extra.is_email_verified = true; await UserModel.update(user); @@ -99,14 +118,20 @@ export const handler: Handlers = { } } - return createSessionResponse(request, user, { urlToRedirectTo: `/` }); + if (user.extra.is_email_verified && isMultiFactorAuthEnabled && isMultiFactorAuthEnabledForUser(user)) { + return MultiFactorAuthModel.createSessionResponse(request, user, { urlToRedirectTo: redirectUrl }); + } + + return createSessionResponse(request, user, { urlToRedirectTo: redirectUrl }); } catch (error) { console.error(error); + return await context.render({ error: (error as Error).toString(), email, formData, isEmailVerificationEnabled, + isMultiFactorAuthEnabled, helpEmail, }); } @@ -170,7 +195,7 @@ export default function Login({ data }: PageProps) { ) : null} - + {formFields( data?.email, data?.notice?.includes('verify your email') && data?.isEmailVerificationEnabled, @@ -178,6 +203,18 @@ export default function Login({ data }: PageProps) {
+ + {data?.isMultiFactorAuthEnabled + ? ( +
+
+

or

+
+ + +
+ ) + : null}

Need an account?

diff --git a/routes/mfa-verify.tsx b/routes/mfa-verify.tsx new file mode 100644 index 0000000..b2e357b --- /dev/null +++ b/routes/mfa-verify.tsx @@ -0,0 +1,151 @@ +import { Handlers, PageProps } from 'fresh/server.ts'; + +import { FreshContextState, MultiFactorAuthMethodType } from '/lib/types.ts'; +import { UserModel } from '/lib/models/user.ts'; +import { createSessionResponse } from '/lib/auth.ts'; +import { getFormDataField } from '/lib/form-utils.tsx'; +import { AppConfig } from '/lib/config.ts'; +import { MultiFactorAuthModel } from '/lib/models/multi-factor-auth.ts'; +import { + getEnabledMultiFactorAuthMethodsFromUser, + isMultiFactorAuthEnabledForUser, +} from '/lib/utils/multi-factor-auth.ts'; +import { TOTPModel } from '/lib/models/multi-factor-auth/totp.ts'; +import MultiFactorAuthVerifyForm from '/components/auth/MultiFactorAuthVerifyForm.tsx'; + +interface Data { + error?: { + title: string; + message: string; + }; + email?: string; + redirectUrl?: string; + availableMethods?: MultiFactorAuthMethodType[]; + hasPasskey?: boolean; +} + +export const handler: Handlers = { + async GET(request, context) { + const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled(); + + if (!isMultiFactorAuthEnabled) { + return new Response('Redirect', { status: 303, headers: { 'Location': '/login' } }); + } + + const searchParams = new URL(request.url).searchParams; + const redirectUrl = searchParams.get('redirect') || '/'; + + const { user } = (await MultiFactorAuthModel.getDataFromRequest(request)) || {}; + + if (!user) { + return new Response('Redirect', { status: 303, headers: { 'Location': '/login' } }); + } + + const hasMultiFactorAuthEnabled = isMultiFactorAuthEnabledForUser(user); + + if (!hasMultiFactorAuthEnabled) { + return new Response('Redirect', { status: 303, headers: { 'Location': '/login' } }); + } + + const enabledMethods = getEnabledMultiFactorAuthMethodsFromUser(user); + const availableMethods = enabledMethods.map((method) => method.type); + const hasPasskey = availableMethods.includes('passkey'); + + return await context.render({ + email: user.email, + redirectUrl, + availableMethods, + hasPasskey, + }); + }, + async POST(request, context) { + const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled(); + + if (!isMultiFactorAuthEnabled) { + return new Response('Redirect', { status: 303, headers: { 'Location': '/login' } }); + } + + const searchParams = new URL(request.url).searchParams; + const redirectUrl = searchParams.get('redirect') || '/'; + + const { user } = (await MultiFactorAuthModel.getDataFromRequest(request)) || {}; + + if (!user) { + return new Response('Redirect', { status: 303, headers: { 'Location': '/login' } }); + } + + const hasMultiFactorAuthEnabled = isMultiFactorAuthEnabledForUser(user); + + if (!hasMultiFactorAuthEnabled) { + return new Response('Redirect', { status: 303, headers: { 'Location': '/login' } }); + } + + const enabledMethods = getEnabledMultiFactorAuthMethodsFromUser(user); + const availableMethods = enabledMethods.map((method) => method.type); + const hasPasskey = availableMethods.includes('passkey'); + + try { + const formData = await request.formData(); + const token = getFormDataField(formData, 'token'); + + if (!token) { + throw new Error('Authentication token is required'); + } + + let isValid = false; + let updateUser = false; + + // Passkey verification is handled in a separate process + for (const method of enabledMethods) { + const verification = await TOTPModel.verifyMethodToken(method.metadata, token); + if (verification.isValid) { + isValid = true; + + if (verification.remainingCodes && method.type === 'totp' && method.metadata.totp) { + method.metadata.totp.hashed_backup_codes = verification.remainingCodes; + updateUser = true; + } + break; + } + } + + if (!isValid) { + throw new Error('Invalid authentication token or backup code'); + } + + if (updateUser) { + await UserModel.update(user); + } + + return await createSessionResponse(request, user, { urlToRedirectTo: redirectUrl }); + } catch (error) { + console.error('Multi-factor authentication verification error:', error); + + return await context.render({ + error: { + title: 'Verification Failed', + message: (error as Error).message, + }, + email: user.email, + redirectUrl, + availableMethods, + hasPasskey, + }); + } + }, +}; + +export default function MultiFactorAuthVerifyPage({ data }: PageProps) { + return ( +
+
+ +
+
+ ); +} diff --git a/routes/settings.tsx b/routes/settings.tsx index 86ce4d4..12a2f61 100644 --- a/routes/settings.tsx +++ b/routes/settings.tsx @@ -1,6 +1,6 @@ import { Handlers, PageProps } from 'fresh/server.ts'; -import { currencyMap, FreshContextState, SupportedCurrencySymbol } from '/lib/types.ts'; +import { currencyMap, FreshContextState, SupportedCurrencySymbol, User } from '/lib/types.ts'; import { PASSWORD_SALT } from '/lib/auth.ts'; import { UserModel, VerificationCodeModel } from '/lib/models/user.ts'; import { convertFormDataToObject, generateHash, validateEmail } from '/lib/utils/misc.ts'; @@ -21,7 +21,11 @@ interface Data { formData: Record; currency?: SupportedCurrencySymbol; isExpensesAppEnabled: boolean; + isMultiFactorAuthEnabled: boolean; helpEmail: string; + user: { + extra: Pick; + }; } export const handler: Handlers = { @@ -32,12 +36,15 @@ export const handler: Handlers = { const isExpensesAppEnabled = await AppConfig.isAppEnabled('expenses'); const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail; + const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled(); return await context.render({ formData: {}, currency: context.state.user.extra.expenses_currency, isExpensesAppEnabled, helpEmail, + isMultiFactorAuthEnabled, + user: context.state.user, }); }, async POST(request, context) { @@ -47,6 +54,7 @@ export const handler: Handlers = { const isExpensesAppEnabled = await AppConfig.isAppEnabled('expenses'); const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail; + const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled(); let action: Action = 'change-email'; let errorTitle = ''; @@ -190,6 +198,8 @@ export const handler: Handlers = { currency: user.extra.expenses_currency, isExpensesAppEnabled, helpEmail, + isMultiFactorAuthEnabled, + user: user, }); } catch (error) { console.error(error); @@ -202,6 +212,8 @@ export const handler: Handlers = { currency: user.extra.expenses_currency, isExpensesAppEnabled, helpEmail, + isMultiFactorAuthEnabled, + user: user, }); } }, @@ -216,7 +228,9 @@ export default function SettingsPage({ data }: PageProps ); diff --git a/routes/signup.tsx b/routes/signup.tsx index 9aa2ad2..8e0182d 100644 --- a/routes/signup.tsx +++ b/routes/signup.tsx @@ -69,7 +69,9 @@ export const handler: Handlers = { const user = await UserModel.create(email, hashedPassword); - if (await AppConfig.isEmailVerificationEnabled()) { + const isEmailVerificationEnabled = await AppConfig.isEmailVerificationEnabled(); + + if (isEmailVerificationEnabled) { const verificationCode = await VerificationCodeModel.create(user, user.email, 'email'); await sendVerifyEmailEmail(user.email, verificationCode);