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 <me@brunobernardino.com>
This commit is contained in:
0xGingi
2025-05-29 12:30:28 -04:00
committed by GitHub
parent 2a77915630
commit 455a7201e9
28 changed files with 2361 additions and 40 deletions

View File

@@ -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

View File

@@ -7,6 +7,7 @@ const config: PartialDeep<Config> = {
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
},

View File

@@ -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 (
<section class='max-w-md w-full mb-12'>
<section class='mb-6'>
<h2 class='mt-6 text-center text-3xl font-extrabold text-white'>
Multi-Factor Authentication
</h2>
<p class='mt-2 text-center text-sm text-gray-300'>
You are required to authenticate with an additional method
</p>
</section>
{error
? (
<section class='bg-red-900 border border-red-700 text-red-100 px-4 py-3 rounded relative mb-4'>
<strong class='font-bold'>{error.title}:</strong>
<span class='block sm:inline'>{error.message}</span>
</section>
)
: null}
{hasTotp
? (
<form
class='mb-6'
method='POST'
action={`/mfa-verify?redirect=${encodeURIComponent(redirectUrl)}`}
>
<fieldset class='block mb-4'>
<label class='text-slate-300 block pb-1' for='token'>
Authentication Token or Backup Code
</label>
<input
type='text'
id='token'
name='token'
placeholder='123456 or backup code'
class='mt-1 input-field'
autocomplete='one-time-code'
required
/>
</fieldset>
<section class='flex justify-center mt-8 mb-4'>
<button
type='submit'
class='button'
>
Verify Code
</button>
</section>
</form>
)
: null}
{hasTotp && hasPasskey
? (
<section class='text-center -mt-10 mb-6 block'>
<p class='text-gray-400 text-sm'>or</p>
</section>
)
: null}
{hasPasskey && email
? (
<section class='mb-8'>
<PasswordlessPasskeyLogin email={email} redirectUrl={redirectUrl} />
</section>
)
: null}
<section class='text-center mt-6'>
<a href='/login' class='text-blue-400 hover:text-blue-300 text-sm'>
Back to Login
</a>
</section>
</section>
);
}

View File

@@ -160,23 +160,25 @@ export default function ExpenseModal(
}}
placeholder='Lunch'
/>
{showSuggestions.value && suggestions.value.length > 0 && (
<ul class='absolute z-50 w-full bg-slate-700 rounded-md mt-1 max-h-40 overflow-y-auto ring-1 ring-slate-800 shadow-lg'>
{suggestions.value.map((suggestion) => (
<li
key={suggestion}
class='px-4 py-2 hover:bg-slate-600 cursor-pointer'
onClick={() => {
newExpenseDescription.value = suggestion;
showSuggestions.value = false;
suggestions.value = [];
}}
>
{suggestion}
</li>
))}
</ul>
)}
{showSuggestions.value && suggestions.value.length > 0
? (
<ul class='absolute z-50 w-full bg-slate-700 rounded-md mt-1 max-h-40 overflow-y-auto ring-1 ring-slate-800 shadow-lg'>
{suggestions.value.map((suggestion) => (
<li
key={suggestion}
class='px-4 py-2 hover:bg-slate-600 cursor-pointer'
onClick={() => {
newExpenseDescription.value = suggestion;
showSuggestions.value = false;
suggestions.value = [];
}}
>
{suggestion}
</li>
))}
</ul>
)
: null}
</fieldset>
<fieldset class='block mb-2'>

View File

@@ -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"
}
}

View File

@@ -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,

View File

@@ -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<string, any>;
@@ -14,7 +16,11 @@ interface SettingsProps {
};
currency?: SupportedCurrencySymbol;
isExpensesAppEnabled: boolean;
isMultiFactorAuthEnabled: boolean;
helpEmail: string;
user: {
extra: Pick<User['extra'], 'multi_factor_auth_methods'>;
};
}
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(
<form method='POST' class='mb-12'>
{formFields(
action === 'change-email' && notice?.message.includes('verify') ? 'verify-change-email' : 'change-email',
'change-email',
formData,
).map((field) => generateFieldHtml(field, formData))}
<section class='flex justify-end mt-8 mb-4'>
@@ -195,6 +210,20 @@ export default function Settings(
)
: null}
{isMultiFactorAuthEnabled
? (
<MultiFactorAuthSettings
methods={multiFactorAuthMethods.map((method) => ({
type: method.type,
id: method.id,
name: method.name,
enabled: method.enabled,
backupCodesCount: method.metadata.totp?.hashed_backup_codes?.length,
}))}
/>
)
: null}
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Delete your account</h2>
<p class='text-left mt-2 mb-6 px-4 max-w-screen-md mx-auto lg:min-w-96'>
Deleting your account is instant and deletes all your data. {helpEmail !== ''

View File

@@ -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<MultiFactorAuthMethodType, string> = {
totp: 'Authenticator App',
passkey: 'Passkey',
};
const methodTypeDescriptions: Record<MultiFactorAuthMethodType, string> = {
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<TOTPSetupData | PasskeySetupData | null>(null);
const isLoading = useSignal(false);
const error = useSignal<string | null>(null);
const success = useSignal<string | null>(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 (
<section class='mb-16'>
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>
Multi-Factor Authentication (MFA)
</h2>
<section class='px-4 max-w-screen-md mx-auto lg:min-w-96'>
{error.value
? (
<section class='notification-error mb-4'>
<p>{error.value}</p>
</section>
)
: null}
{success.value
? (
<section class='notification-success mb-4'>
<p>{success.value}</p>
</section>
)
: null}
<p class='mb-6'>
Multi-factor authentication adds an extra layer of security to your account by requiring additional
verification beyond your password.
</p>
{availableMethodTypes
.filter((type) => !enabledMethods.some((method) => method.type === type)).length > 0
? (
<section class='mb-6 mt-4'>
<h3 class='text-lg font-semibold mb-4'>
Available Authentication Methods
</h3>
<section class='space-y-4'>
{availableMethodTypes
.filter((type) =>
!enabledMethods.some((method) => method.type === type) && setupData.value?.type !== type
)
.map((type) => (
<section key={type} class='border rounded-lg p-4'>
<section class='flex items-center justify-between'>
<section>
<h4 class='font-medium'>{methodTypeLabels[type]}</h4>
<p class='text-sm text-gray-400'>{methodTypeDescriptions[type]}</p>
</section>
<button
type='button'
onClick={() => setupMultiFactorAuth(type)}
disabled={isLoading.value}
class='button-secondary'
>
{isLoading.value ? '...' : 'Add'}
</button>
</section>
</section>
))}
</section>
</section>
)
: null}
{setupData.value && setupData.value.type !== 'passkey'
? (
<section class='mb-6'>
<h3 class='text-lg font-semibold mb-4'>Setup Authenticator App</h3>
<section class='mb-6'>
<p class='mb-4'>
1. Scan this QR code with your authenticator app (Aegis Authenticator, Google Authenticator, etc.):
</p>
<section class='flex justify-center mb-4'>
<img src={setupData.value.qrCodeUrl} alt='TOTP QR Code' class='border' />
</section>
<p class='text-sm text-gray-400 mb-4'>
Or manually enter this secret:{' '}
<code class='bg-gray-200 px-2 py-1 rounded text-gray-900'>{setupData.value.secret}</code>
</p>
</section>
<section class='mb-6'>
<p class='mb-4'>
2. Save these backup codes <strong class='font-bold text-sky-500'>NOW</strong> in a safe place:
</p>
<section class='bg-gray-200 border rounded p-4 font-mono text-sm text-gray-900'>
{setupData.value.backupCodes.map((code, index) => <section key={index} class='mb-1'>{code}</section>)}
</section>
<p class='text-sm text-gray-400 mt-2'>
These codes can be used to access your account if you lose your authenticator device.{' '}
<strong class='font-bold text-sky-500'>They won't be visible again</strong>.
</p>
</section>
<fieldset class='block mb-6'>
<label class='text-slate-300 block pb-1'>
3. Enter the 6-digit code from your authenticator app:
</label>
<input
type='text'
value={verificationToken.value}
onInput={(event) => verificationToken.value = (event.target as HTMLInputElement).value}
placeholder='123456'
class='mt-1 input-field'
maxLength={6}
/>
</fieldset>
<section class='flex justify-end gap-2 mt-8 mb-4'>
<button
type='button'
onClick={cancelSetup}
disabled={isLoading.value}
class='button-secondary'
>
Cancel
</button>
<button
type='button'
onClick={enableMultiFactorAuth}
disabled={isLoading.value || !verificationToken.value}
class='button'
>
{isLoading.value ? 'Enabling...' : 'Enable TOTP MFA'}
</button>
</section>
</section>
)
: null}
{setupData.value && setupData.value.type === 'passkey'
? (
<section class='mb-6'>
<h3 class='text-lg font-semibold mb-4'>Passkey Setup Complete</h3>
<p class='mb-4'>
Your passkey has been successfully registered! You can now enable it for multi-factor authentication.
</p>
<section class='flex justify-end gap-2 mt-8 mb-4'>
<button
type='button'
onClick={cancelSetup}
disabled={isLoading.value}
class='button-secondary'
>
Cancel
</button>
<button
type='button'
onClick={enableMultiFactorAuth}
disabled={isLoading.value}
class='button'
>
{isLoading.value ? 'Enabling...' : 'Enable Passkey MFA'}
</button>
</section>
</section>
)
: null}
{hasMultiFactorAuthEnabled && !showDisableForm.value
? (
<section>
<section class='mb-6'>
<h3 class='text-lg font-semibold mb-4'>Active Authentication Methods</h3>
{enabledMethods.map((method) => (
<section key={method.id} class='border rounded-lg p-4 mb-4'>
<section class='flex items-center justify-between'>
<section>
<section
class={`flex items-center ${
method.type === 'totp' && typeof method.backupCodesCount !== 'undefined' ? 'mb-2' : ''
}`}
>
<span class='inline-block w-3 h-3 bg-green-500 rounded-full mr-2'></span>
<span class='font-medium'>{method.name}</span>
</section>
{method.type === 'totp' && typeof method.backupCodesCount !== 'undefined'
? (
<p class='text-sm text-gray-600'>
{method.backupCodesCount > 0
? `${method.backupCodesCount} backup codes remaining`
: 'No backup codes remaining'}
</p>
)
: null}
</section>
<button
type='button'
onClick={() => showDisableForm.value = method.id}
class='button-secondary'
>
Disable
</button>
</section>
</section>
))}
</section>
<section class='flex justify-end mt-8 mb-4'>
<button
type='button'
onClick={() => showDisableForm.value = 'all'}
class='button-danger'
>
Disable All MFA
</button>
</section>
</section>
)
: null}
{showDisableForm.value
? (
<section class='mb-6'>
<h3 class='text-lg font-semibold mb-4'>
{showDisableForm.value === 'all'
? 'Disable All Multi-Factor Authentication'
: 'Disable Authentication Method'}
</h3>
<p class='mb-4'>
{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.
</p>
<fieldset class='block mb-4'>
<label class='text-slate-300 block pb-1'>Password</label>
<input
type='password'
value={disablePassword.value}
onInput={(event) => disablePassword.value = (event.target as HTMLInputElement).value}
placeholder='Enter your password'
class='mt-1 input-field'
/>
</fieldset>
<section class='flex justify-end gap-2 mt-8 mb-4'>
<button
type='button'
onClick={cancelDisable}
disabled={isLoading.value}
class='button-secondary'
>
Cancel
</button>
<button
type='button'
onClick={() =>
disableMultiFactorAuth(
showDisableForm.value === 'all' ? undefined : showDisableForm.value || undefined,
showDisableForm.value === 'all',
)}
disabled={isLoading.value || !disablePassword.value}
class='button-danger'
>
{isLoading.value ? 'Disabling...' : 'Disable'}
</button>
</section>
</section>
)
: null}
</section>
</section>
);
}

View File

@@ -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<string | null>(providedEmail || null);
const error = useSignal<string | null>(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 (
<section class='space-y-4'>
<section class='flex justify-center mt-2 mb-4'>
<button
type='button'
onClick={handlePasswordlessLogin}
class='button-secondary'
>
{isLoading.value ? 'Authenticating...' : 'Login with Passkey'}
</button>
</section>
{error.value
? (
<section class='notification-error'>
<p>{error.value}</p>
</section>
)
: null}
</section>
);
}

View File

@@ -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<CryptoKey> =>
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<string> {
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<JwtData> {
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<string> {
const key = await generateKey(JWT_SECRET);
const token = await signAuthJwt(key, { data: tokenData });

View File

@@ -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<boolean> {
await this.loadConfig();
return this.config.auth.enableMultiFactor;
}
static async getFilesRootPath(): Promise<string> {
await this.loadConfig();

View File

@@ -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;
}
}
}

View File

@@ -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<User['extra'], 'multi_factor_auth_methods'> },
methodId: string,
): void {
const method = getMultiFactorAuthMethodByIdFromUser(user, methodId);
if (method) {
method.enabled = true;
}
}
static disableMethodFromUser(
user: { extra: Pick<User['extra'], 'multi_factor_auth_methods'> },
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;
}
}

View File

@@ -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<PublicKeyCredentialCreationOptionsJSON> {
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<VerifiedRegistrationResponse> {
const verification = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin,
expectedRPID,
supportedAlgorithmIDs: SUPPORTED_ALGORITHM_IDS,
});
return verification;
}
static async generateAuthenticationOptions(
baseUrl: string,
allowedCredentials?: PasskeyCredential[],
): Promise<PublicKeyCredentialCreationOptionsJSON> {
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<VerifiedAuthenticationResponse> {
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<User['extra'], 'multi_factor_auth_methods'> },
): 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<User['extra'], 'multi_factor_auth_methods'> },
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;
}
}
}

View File

@@ -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<CryptoKey> {
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<string[]> {
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<string> {
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<string> {
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<string> {
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<MultiFactorAuthSetup> {
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);
}
}

View File

@@ -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[];
};
};
}

View File

@@ -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<User['extra'], 'multi_factor_auth_methods'> },
): MultiFactorAuthMethod[] {
return user.extra.multi_factor_auth_methods || [];
}
export function getEnabledMultiFactorAuthMethodsFromUser(
user: { extra: Pick<User['extra'], 'multi_factor_auth_methods'> },
): MultiFactorAuthMethod[] {
return getMultiFactorAuthMethodsFromUser(user).filter((method) => method.enabled);
}
export function getMultiFactorAuthMethodByIdFromUser(
user: { extra: Pick<User['extra'], 'multi_factor_auth_methods'> },
id: string,
): MultiFactorAuthMethod | undefined {
return getMultiFactorAuthMethodsFromUser(user).find((method) => method.id === id);
}
export function isMultiFactorAuthEnabledForUser(
user: { extra: Pick<User['extra'], 'multi_factor_auth_methods'> },
): boolean {
return getEnabledMultiFactorAuthMethodsFromUser(user).length > 0;
}

View File

@@ -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<unknown, FreshContextState> = {
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));
},
};

View File

@@ -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<unknown, FreshContextState> = {
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));
},
};

View File

@@ -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<unknown, FreshContextState> = {
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));
},
};

View File

@@ -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<unknown, FreshContextState> = {
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));
},
};

View File

@@ -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<unknown, FreshContextState> = {
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));
},
};

View File

@@ -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<unknown, FreshContextState> = {
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 || '/',
});
},
};

View File

@@ -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<unknown, FreshContextState> = {
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));
},
};

View File

@@ -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<Data, FreshContextState> = {
}
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<Data, FreshContextState> = {
}
}
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<Data, FreshContextState> = {
}
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<Data, FreshContextState> = {
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<Data, FreshContextState> = {
}
}
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<Data, FreshContextState>) {
)
: null}
<form method='POST' class='mb-12'>
<form method='POST' class='mb-4'>
{formFields(
data?.email,
data?.notice?.includes('verify your email') && data?.isEmailVerificationEnabled,
@@ -178,6 +203,18 @@ export default function Login({ data }: PageProps<Data, FreshContextState>) {
<section class='flex justify-center mt-8 mb-4'>
<button class='button' type='submit'>Login</button>
</section>
{data?.isMultiFactorAuthEnabled
? (
<section class='mb-12 max-w-sm mx-auto'>
<section class='text-center'>
<p class='text-gray-400 text-sm mb-3'>or</p>
</section>
<PasswordlessPasskeyLogin />
</section>
)
: null}
</form>
<h2 class='text-2xl mb-4 text-center'>Need an account?</h2>

151
routes/mfa-verify.tsx Normal file
View File

@@ -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<Data, FreshContextState> = {
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<Data, FreshContextState>) {
return (
<main>
<section class='max-w-screen-md mx-auto flex flex-col items-center justify-center'>
<MultiFactorAuthVerifyForm
email={data.email || ''}
redirectUrl={data.redirectUrl || '/'}
availableMethods={data.availableMethods || []}
error={data.error}
/>
</section>
</main>
);
}

View File

@@ -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<string, any>;
currency?: SupportedCurrencySymbol;
isExpensesAppEnabled: boolean;
isMultiFactorAuthEnabled: boolean;
helpEmail: string;
user: {
extra: Pick<User['extra'], 'multi_factor_auth_methods'>;
};
}
export const handler: Handlers<Data, FreshContextState> = {
@@ -32,12 +36,15 @@ export const handler: Handlers<Data, FreshContextState> = {
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<Data, FreshContextState> = {
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<Data, FreshContextState> = {
currency: user.extra.expenses_currency,
isExpensesAppEnabled,
helpEmail,
isMultiFactorAuthEnabled,
user: user,
});
} catch (error) {
console.error(error);
@@ -202,6 +212,8 @@ export const handler: Handlers<Data, FreshContextState> = {
currency: user.extra.expenses_currency,
isExpensesAppEnabled,
helpEmail,
isMultiFactorAuthEnabled,
user: user,
});
}
},
@@ -216,7 +228,9 @@ export default function SettingsPage({ data }: PageProps<Data, FreshContextState
notice={data?.notice}
currency={data?.currency}
isExpensesAppEnabled={data?.isExpensesAppEnabled}
isMultiFactorAuthEnabled={data?.isMultiFactorAuthEnabled}
helpEmail={data?.helpEmail}
user={data?.user}
/>
</main>
);

View File

@@ -69,7 +69,9 @@ export const handler: Handlers<Data, FreshContextState> = {
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);