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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
94
components/auth/MultiFactorAuthVerifyForm.tsx
Normal file
94
components/auth/MultiFactorAuthVerifyForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -160,7 +160,8 @@ export default function ExpenseModal(
|
||||
}}
|
||||
placeholder='Lunch'
|
||||
/>
|
||||
{showSuggestions.value && suggestions.value.length > 0 && (
|
||||
{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
|
||||
@@ -176,7 +177,8 @@ export default function ExpenseModal(
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
)
|
||||
: null}
|
||||
</fieldset>
|
||||
|
||||
<fieldset class='block mb-2'>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
20
fresh.gen.ts
20
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,
|
||||
|
||||
@@ -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 !== ''
|
||||
|
||||
530
islands/auth/MultiFactorAuthSettings.tsx
Normal file
530
islands/auth/MultiFactorAuthSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
islands/auth/PasswordlessPasskeyLogin.tsx
Normal file
116
islands/auth/PasswordlessPasskeyLogin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
lib/auth.ts
22
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<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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
142
lib/models/multi-factor-auth.ts
Normal file
142
lib/models/multi-factor-auth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
198
lib/models/multi-factor-auth/passkey.ts
Normal file
198
lib/models/multi-factor-auth/passkey.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
227
lib/models/multi-factor-auth/totp.ts
Normal file
227
lib/models/multi-factor-auth/totp.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
27
lib/types.ts
27
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[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
28
lib/utils/multi-factor-auth.ts
Normal file
28
lib/utils/multi-factor-auth.ts
Normal 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;
|
||||
}
|
||||
119
routes/api/auth/multi-factor/disable.ts
Normal file
119
routes/api/auth/multi-factor/disable.ts
Normal 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));
|
||||
},
|
||||
};
|
||||
130
routes/api/auth/multi-factor/enable.ts
Normal file
130
routes/api/auth/multi-factor/enable.ts
Normal 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));
|
||||
},
|
||||
};
|
||||
87
routes/api/auth/multi-factor/passkey/begin.ts
Normal file
87
routes/api/auth/multi-factor/passkey/begin.ts
Normal 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));
|
||||
},
|
||||
};
|
||||
67
routes/api/auth/multi-factor/passkey/setup-begin.ts
Normal file
67
routes/api/auth/multi-factor/passkey/setup-begin.ts
Normal 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));
|
||||
},
|
||||
};
|
||||
102
routes/api/auth/multi-factor/passkey/setup-complete.ts
Normal file
102
routes/api/auth/multi-factor/passkey/setup-complete.ts
Normal 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));
|
||||
},
|
||||
};
|
||||
99
routes/api/auth/multi-factor/passkey/verify.ts
Normal file
99
routes/api/auth/multi-factor/passkey/verify.ts
Normal 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 || '/',
|
||||
});
|
||||
},
|
||||
};
|
||||
66
routes/api/auth/multi-factor/totp/setup.ts
Normal file
66
routes/api/auth/multi-factor/totp/setup.ts
Normal 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));
|
||||
},
|
||||
};
|
||||
@@ -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
151
routes/mfa-verify.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user