Enable Email as a MFA method/option (#68)

This adds Email as a multi-factor authentication method/option. It reuses the `VerificationCode` for the code generation and validation.

It also refactors the email templating for easier repurposing.

Finally, it has a small Deno version bump.

Closes #25
This commit is contained in:
Bruno Bernardino
2025-06-11 15:53:39 +01:00
committed by GitHub
parent 111321e9c6
commit c7d6b8077b
16 changed files with 405 additions and 81 deletions

2
.dvmrc
View File

@@ -1 +1 @@
2.3.3 2.3.5

View File

@@ -1,4 +1,4 @@
FROM denoland/deno:ubuntu-2.3.3 FROM denoland/deno:ubuntu-2.3.5
EXPOSE 8000 EXPOSE 8000

View File

@@ -7,7 +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 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 SMTP settings below) enableEmailVerification: false, // If true, email verification will be required for signups (using SMTP settings below)
enableForeverSignup: true, // If true, all signups become active for 100 years enableForeverSignup: true, // If true, all signups become active for 100 years
enableMultiFactor: false, // If true, users can enable multi-factor authentication (TOTP or Passkeys) enableMultiFactor: false, // If true, users can enable multi-factor authentication (TOTP, Passkeys, or Email if the SMTP settings below are set)
// allowedCookieDomains: ['example.com', 'example.net'], // Can be set to allow more than the baseUrl's domain for session cookies // 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 // skipCookieDomainSecurity: true, // If true, the cookie domain will not be strictly set and checked against. This skipping slightly reduces security, but is usually necessary for reverse proxies like Cloudflare Tunnel
}, },

View File

@@ -13,6 +13,7 @@ export default function MultiFactorAuthVerifyForm(
) { ) {
const hasPasskey = availableMethods.includes('passkey'); const hasPasskey = availableMethods.includes('passkey');
const hasTotp = availableMethods.includes('totp'); const hasTotp = availableMethods.includes('totp');
const hasEmail = availableMethods.includes('email');
return ( return (
<section class='max-w-md w-full mb-12'> <section class='max-w-md w-full mb-12'>
@@ -34,6 +35,48 @@ export default function MultiFactorAuthVerifyForm(
) )
: null} : null}
{hasEmail
? (
<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'>
Email Verification Code
</label>
<input
type='text'
id='code'
name='code'
placeholder='123456'
class='mt-1 input-field'
autocomplete='off'
required
/>
</fieldset>
<section class='flex justify-center mt-8 mb-4'>
<button
type='submit'
class='button'
>
Verify Code
</button>
</section>
</form>
)
: null}
{hasEmail && hasTotp
? (
<section class='text-center -mt-10 mb-6 block'>
<p class='text-gray-400 text-sm'>or</p>
</section>
)
: null}
{hasTotp {hasTotp
? ( ? (
<form <form
@@ -68,7 +111,7 @@ export default function MultiFactorAuthVerifyForm(
) )
: null} : null}
{hasTotp && hasPasskey {(hasEmail || hasTotp) && hasPasskey
? ( ? (
<section class='text-center -mt-10 mb-6 block'> <section class='text-center -mt-10 mb-6 block'>
<p class='text-gray-400 text-sm'>or</p> <p class='text-gray-400 text-sm'>or</p>

View File

@@ -1,6 +1,6 @@
services: services:
website: website:
image: ghcr.io/bewcloud/bewcloud:v2.0.0 image: ghcr.io/bewcloud/bewcloud:v2.1.0
restart: always restart: always
ports: ports:
- 127.0.0.1:8000:8000 - 127.0.0.1:8000:8000

View File

@@ -6,6 +6,7 @@ import * as $_404 from './routes/_404.tsx';
import * as $_app from './routes/_app.tsx'; import * as $_app from './routes/_app.tsx';
import * as $_middleware from './routes/_middleware.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_disable from './routes/api/auth/multi-factor/disable.ts';
import * as $api_auth_multi_factor_email_setup from './routes/api/auth/multi-factor/email/setup.ts';
import * as $api_auth_multi_factor_enable from './routes/api/auth/multi-factor/enable.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_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_begin from './routes/api/auth/multi-factor/passkey/setup-begin.ts';
@@ -78,6 +79,7 @@ const manifest = {
'./routes/_app.tsx': $_app, './routes/_app.tsx': $_app,
'./routes/_middleware.tsx': $_middleware, './routes/_middleware.tsx': $_middleware,
'./routes/api/auth/multi-factor/disable.ts': $api_auth_multi_factor_disable, './routes/api/auth/multi-factor/disable.ts': $api_auth_multi_factor_disable,
'./routes/api/auth/multi-factor/email/setup.ts': $api_auth_multi_factor_email_setup,
'./routes/api/auth/multi-factor/enable.ts': $api_auth_multi_factor_enable, './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/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-begin.ts': $api_auth_multi_factor_passkey_setup_begin,

View File

@@ -14,6 +14,10 @@ import {
RequestBody as TOTPSetupRequestBody, RequestBody as TOTPSetupRequestBody,
ResponseBody as TOTPSetupResponseBody, ResponseBody as TOTPSetupResponseBody,
} from '/routes/api/auth/multi-factor/totp/setup.ts'; } from '/routes/api/auth/multi-factor/totp/setup.ts';
import {
RequestBody as EmailSetupRequestBody,
ResponseBody as EmailSetupResponseBody,
} from '/routes/api/auth/multi-factor/email/setup.ts';
import { import {
RequestBody as MultiFactorAuthEnableRequestBody, RequestBody as MultiFactorAuthEnableRequestBody,
ResponseBody as MultiFactorAuthEnableResponseBody, ResponseBody as MultiFactorAuthEnableResponseBody,
@@ -48,20 +52,27 @@ interface PasskeySetupData {
type: 'passkey'; type: 'passkey';
} }
interface EmailSetupData {
methodId: string;
type: 'email';
}
const methodTypeLabels: Record<MultiFactorAuthMethodType, string> = { const methodTypeLabels: Record<MultiFactorAuthMethodType, string> = {
totp: 'Authenticator App', totp: 'Authenticator App',
passkey: 'Passkey', passkey: 'Passkey',
email: 'Email',
}; };
const methodTypeDescriptions: Record<MultiFactorAuthMethodType, string> = { const methodTypeDescriptions: Record<MultiFactorAuthMethodType, string> = {
totp: 'Use an authenticator app like Aegis Authenticator or Google Authenticator to generate codes', totp: 'Use an authenticator app like Aegis Authenticator or Google Authenticator to generate codes.',
passkey: 'Use biometric authentication or security keys', passkey: 'Use biometric authentication or security keys.',
email: 'Receive codes in your email.',
}; };
const availableMethodTypes = ['totp', 'passkey'] as MultiFactorAuthMethodType[]; const availableMethodTypes = ['totp', 'passkey', 'email'] as MultiFactorAuthMethodType[];
export default function MultiFactorAuthSettings({ methods }: MultiFactorAuthSettingsProps) { export default function MultiFactorAuthSettings({ methods }: MultiFactorAuthSettingsProps) {
const setupData = useSignal<TOTPSetupData | PasskeySetupData | null>(null); const setupData = useSignal<TOTPSetupData | PasskeySetupData | EmailSetupData | null>(null);
const isLoading = useSignal(false); const isLoading = useSignal(false);
const error = useSignal<string | null>(null); const error = useSignal<string | null>(null);
const success = useSignal<string | null>(null); const success = useSignal<string | null>(null);
@@ -146,6 +157,26 @@ export default function MultiFactorAuthSettings({ methods }: MultiFactorAuthSett
}; };
}; };
const setupEmail = async () => {
const requestBody: EmailSetupRequestBody = {};
const response = await fetch('/api/auth/multi-factor/email/setup', {
method: 'POST',
body: JSON.stringify(requestBody),
});
const data = await response.json() as EmailSetupResponseBody;
if (!data.success || !data.data) {
throw new Error(data.error || 'Failed to setup email multi-factor authentication');
}
setupData.value = {
type: 'email',
methodId: data.data.methodId!,
};
};
const setupMultiFactorAuth = async (type: MultiFactorAuthMethodType) => { const setupMultiFactorAuth = async (type: MultiFactorAuthMethodType) => {
isLoading.value = true; isLoading.value = true;
error.value = null; error.value = null;
@@ -155,6 +186,8 @@ export default function MultiFactorAuthSettings({ methods }: MultiFactorAuthSett
await setupTOTP(); await setupTOTP();
} else if (type === 'passkey') { } else if (type === 'passkey') {
await setupPasskey(); await setupPasskey();
} else if (type === 'email') {
await setupEmail();
} }
} catch (setupError) { } catch (setupError) {
error.value = (setupError as Error).message; error.value = (setupError as Error).message;
@@ -170,7 +203,7 @@ export default function MultiFactorAuthSettings({ methods }: MultiFactorAuthSett
} }
if (setupData.value.type !== 'passkey' && !verificationToken.value) { if (setupData.value.type !== 'passkey' && !verificationToken.value) {
error.value = 'Please enter a verification token'; error.value = 'Please enter a verification code/token';
return; return;
} }
@@ -324,7 +357,7 @@ export default function MultiFactorAuthSettings({ methods }: MultiFactorAuthSett
) )
: null} : null}
{setupData.value && setupData.value.type !== 'passkey' {setupData.value && setupData.value.type === 'totp'
? ( ? (
<section class='mb-6'> <section class='mb-6'>
<h3 class='text-lg font-semibold mb-4'>Setup Authenticator App</h3> <h3 class='text-lg font-semibold mb-4'>Setup Authenticator App</h3>
@@ -421,6 +454,47 @@ export default function MultiFactorAuthSettings({ methods }: MultiFactorAuthSett
) )
: null} : null}
{setupData.value && setupData.value.type === 'email'
? (
<section class='mb-6'>
<h3 class='text-lg font-semibold mb-4'>Setup Email</h3>
<fieldset class='block mb-6'>
<label class='text-slate-300 block pb-1'>
Enter the 6-digit code you received in your email:
</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 Email MFA'}
</button>
</section>
</section>
)
: null}
{hasMultiFactorAuthEnabled && !showDisableForm.value {hasMultiFactorAuthEnabled && !showDisableForm.value
? ( ? (
<section> <section>

View File

@@ -1,4 +1,3 @@
// deno-fmt-ignore-file
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import 'std/dotenv/load.ts'; import 'std/dotenv/load.ts';
@@ -45,28 +44,9 @@ export class EmailModel {
} }
} }
static async sendVerificationEmail( /** Based off of https://github.com/ActiveCampaign/postmark-templates/tree/main/templates-inlined/basic/password-reset */
email: string, private static getHtmlBody(title: string, htmlBody: string) {
verificationCode: string, return `
) {
const emailTitle = 'Verify your email in bewCloud';
const textBody = `
${emailTitle}
------------------------
You or someone who knows your email is trying to verify it in bewCloud.
Here's the verification code:
**${verificationCode}**
===============================
This code will expire in 30 minutes.
`;
/** Based off of https://github.com/ActiveCampaign/postmark-templates/tree/main/templates-inlined/basic/password-reset */
const htmlBody = `
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"> <html xmlns="http://www.w3.org/1999/xhtml">
<head> <head>
@@ -75,7 +55,7 @@ This code will expire in 30 minutes.
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="color-scheme" content="light dark" /> <meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" /> <meta name="supported-color-schemes" content="light dark" />
<title>${escapeHtml(emailTitle)}</title> <title>${escapeHtml(title)}</title>
<style type="text/css" rel="stylesheet" media="all"> <style type="text/css" rel="stylesheet" media="all">
/* Base ------------------------------ */ /* Base ------------------------------ */
@@ -506,7 +486,7 @@ This code will expire in 30 minutes.
<![endif]--> <![endif]-->
</head> </head>
<body> <body>
<span class="preheader">Use this link to reset your password. The link is only valid for 24 hours.</span> <span class="preheader">${escapeHtml(title)}</span>
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation"> <table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr> <tr>
<td align="center"> <td align="center">
@@ -524,23 +504,7 @@ This code will expire in 30 minutes.
<tr> <tr>
<td class="content-cell"> <td class="content-cell">
<div class="f-fallback"> <div class="f-fallback">
<h1>${escapeHtml(emailTitle)}</h1> ${htmlBody}
<p>You or someone who knows your email is trying to verify it in bewCloud.</p>
<p>Here's the verification code:</p>
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr>
<td align="center">
<span class="f-fallback button button--green">${escapeHtml(verificationCode)}</span>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>This code will expire in 30 minutes.</p>
</div> </div>
</td> </td>
</tr> </tr>
@@ -554,6 +518,96 @@ This code will expire in 30 minutes.
</body> </body>
</html> </html>
`; `;
}
static async sendVerificationEmail(
email: string,
verificationCode: string,
) {
const emailTitle = 'Verify your email in bewCloud';
const textBody = `
${emailTitle}
------------------------
You or someone who knows your email is trying to verify it in bewCloud.
Here's the verification code:
**${verificationCode}**
===============================
This code will expire in 30 minutes.
`;
const htmlBody = this.getHtmlBody(
emailTitle,
`
<h1>${escapeHtml(emailTitle)}</h1>
<p>You or someone who knows your email is trying to verify it in bewCloud.</p>
<p>Here's the verification code:</p>
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr>
<td align="center">
<span class="f-fallback button button--green">${escapeHtml(verificationCode)}</span>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>This code will expire in 30 minutes.</p>
`,
);
await this.send(email, emailTitle, htmlBody, textBody);
}
static async sendLoginVerificationEmail(
email: string,
verificationCode: string,
) {
const emailTitle = 'Verify your login in bewCloud';
const textBody = `
${emailTitle}
------------------------
You or someone who knows your email and password is trying to login to bewCloud.
Here's the verification code:
**${verificationCode}**
===============================
This code will expire in 30 minutes.
`;
const htmlBody = this.getHtmlBody(
emailTitle,
`
<h1>${escapeHtml(emailTitle)}</h1>
<p>You or someone who knows your email and password is trying to login to bewCloud.</p>
<p>Here's the verification code:</p>
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr>
<td align="center">
<span class="f-fallback button button--green">${escapeHtml(verificationCode)}</span>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>This code will expire in 30 minutes.</p>
`,
);
await this.send(email, emailTitle, htmlBody, textBody); await this.send(email, emailTitle, htmlBody, textBody);
} }

View File

@@ -1,7 +1,10 @@
import { Cookie, getCookies, setCookie } from 'std/http/cookie.ts'; import { Cookie, getCookies, setCookie } from 'std/http/cookie.ts';
import { MultiFactorAuthMethod, User } from '/lib/types.ts'; import { MultiFactorAuthMethod, User } from '/lib/types.ts';
import { getMultiFactorAuthMethodByIdFromUser } from '/lib/utils/multi-factor-auth.ts'; import {
getEnabledMultiFactorAuthMethodsFromUser,
getMultiFactorAuthMethodByIdFromUser,
} from '/lib/utils/multi-factor-auth.ts';
import { import {
COOKIE_NAME as AUTH_COOKIE_NAME, COOKIE_NAME as AUTH_COOKIE_NAME,
generateKey, generateKey,
@@ -14,6 +17,7 @@ import {
import { isRunningLocally } from '/lib/utils/misc.ts'; import { isRunningLocally } from '/lib/utils/misc.ts';
import { AppConfig } from '/lib/config.ts'; import { AppConfig } from '/lib/config.ts';
import { UserModel } from './user.ts'; import { UserModel } from './user.ts';
import { EmailModel } from './multi-factor-auth/email.ts';
const COOKIE_NAME = `${AUTH_COOKIE_NAME}-mfa`; const COOKIE_NAME = `${AUTH_COOKIE_NAME}-mfa`;
const MFA_SESSION_ID = 'mfa'; const MFA_SESSION_ID = 'mfa';
@@ -70,6 +74,18 @@ export class MultiFactorAuthModel {
}, },
}); });
try {
const enabledMultiFactorAuthMethods = getEnabledMultiFactorAuthMethodsFromUser(user);
const emailMethod = enabledMultiFactorAuthMethods.find((method) => method.type === 'email');
if (emailMethod) {
await EmailModel.createAndSendCode(emailMethod.id, user);
}
} catch (error) {
console.error(error);
}
const responseWithCookie = await this.createSessionCookie(request, user, response); const responseWithCookie = await this.createSessionCookie(request, user, response);
return responseWithCookie; return responseWithCookie;

View File

@@ -0,0 +1,50 @@
import { MultiFactorAuthMethod, User } from '/lib/types.ts';
import { MultiFactorAuthSetup } from '/lib/models/multi-factor-auth.ts';
import { VerificationCodeModel } from '/lib/models/user.ts';
import { EmailModel as EmailTransportModel } from '/lib/models/email.ts';
export class EmailModel {
static async createMethod(
id: string,
name: string,
user: User,
): Promise<MultiFactorAuthSetup> {
const method: MultiFactorAuthMethod = {
type: 'email',
id,
name,
enabled: false,
created_at: new Date(),
metadata: {},
};
await this.createAndSendCode(id, user);
return {
method,
};
}
static async createAndSendCode(
id: string,
user: User,
): Promise<void> {
const code = await VerificationCodeModel.create(user, `${user.email}-${id}`, 'email');
await EmailTransportModel.sendLoginVerificationEmail(user.email, code);
}
static async verifyCode(
methodId: string,
code: string,
user: User,
): Promise<boolean> {
try {
await VerificationCodeModel.validate(user, `${user.email}-${methodId}`, code, 'email');
return true;
} catch {
return false;
}
}
}

View File

@@ -157,7 +157,7 @@ export interface Config {
enableEmailVerification: boolean; enableEmailVerification: boolean;
/** If true, all signups become active for 100 years */ /** If true, all signups become active for 100 years */
enableForeverSignup: boolean; enableForeverSignup: boolean;
/** If true, users can enable multi-factor authentication (TOTP or Passkeys) */ /** If true, users can enable multi-factor authentication (TOTP, Passkeys, or Email if the SMTP settings below are set) */
enableMultiFactor: boolean; enableMultiFactor: boolean;
/** Can be set to allow more than the baseUrl's domain for session cookies */ /** Can be set to allow more than the baseUrl's domain for session cookies */
allowedCookieDomains: string[]; allowedCookieDomains: string[];
@@ -198,7 +198,7 @@ export interface Config {
}; };
} }
export type MultiFactorAuthMethodType = 'totp' | 'passkey'; export type MultiFactorAuthMethodType = 'totp' | 'passkey' | 'email';
export interface MultiFactorAuthMethod { export interface MultiFactorAuthMethod {
type: MultiFactorAuthMethodType; type: MultiFactorAuthMethodType;

View File

@@ -1,8 +1,8 @@
// This file contains some multi-factor authentication utilities that are isomorphic. // This file contains some multi-factor authentication utilities that are isomorphic.
import { MultiFactorAuthMethod, User } from '/lib/types.ts'; import { MultiFactorAuthMethod, MultiFactorAuthMethodType, User } from '/lib/types.ts';
export function getMultiFactorAuthMethodsFromUser( function getMultiFactorAuthMethodsFromUser(
user: { extra: Pick<User['extra'], 'multi_factor_auth_methods'> }, user: { extra: Pick<User['extra'], 'multi_factor_auth_methods'> },
): MultiFactorAuthMethod[] { ): MultiFactorAuthMethod[] {
return user.extra.multi_factor_auth_methods || []; return user.extra.multi_factor_auth_methods || [];

View File

@@ -4,10 +4,7 @@ import { FreshContextState } from '/lib/types.ts';
import { PASSWORD_SALT } from '/lib/auth.ts'; import { PASSWORD_SALT } from '/lib/auth.ts';
import { generateHash } from '/lib/utils/misc.ts'; import { generateHash } from '/lib/utils/misc.ts';
import { UserModel } from '/lib/models/user.ts'; import { UserModel } from '/lib/models/user.ts';
import { import { getMultiFactorAuthMethodByIdFromUser } from '/lib/utils/multi-factor-auth.ts';
getMultiFactorAuthMethodByIdFromUser,
getMultiFactorAuthMethodsFromUser,
} from '/lib/utils/multi-factor-auth.ts';
import { AppConfig } from '/lib/config.ts'; import { AppConfig } from '/lib/config.ts';
import { MultiFactorAuthModel } from '/lib/models/multi-factor-auth.ts'; import { MultiFactorAuthModel } from '/lib/models/multi-factor-auth.ts';
@@ -85,7 +82,6 @@ export const handler: Handlers<unknown, FreshContextState> = {
return new Response(JSON.stringify(responseBody), { status: 400 }); return new Response(JSON.stringify(responseBody), { status: 400 });
} }
const methods = getMultiFactorAuthMethodsFromUser(user);
const method = getMultiFactorAuthMethodByIdFromUser(user, methodId); const method = getMultiFactorAuthMethodByIdFromUser(user, methodId);
if (!method) { if (!method) {

View File

@@ -0,0 +1,58 @@
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 { EmailModel } from '/lib/models/multi-factor-auth/email.ts';
export interface RequestBody {}
export interface ResponseBody {
success: boolean;
error?: string;
data?: {
methodId: 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 methodId = MultiFactorAuthModel.generateMethodId();
const setup = await EmailModel.createMethod(methodId, 'Email', user);
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,
},
};
return new Response(JSON.stringify(responseData));
},
};

View File

@@ -3,6 +3,7 @@ import { Handlers } from 'fresh/server.ts';
import { FreshContextState } from '/lib/types.ts'; import { FreshContextState } from '/lib/types.ts';
import { MultiFactorAuthModel } from '/lib/models/multi-factor-auth.ts'; import { MultiFactorAuthModel } from '/lib/models/multi-factor-auth.ts';
import { TOTPModel } from '/lib/models/multi-factor-auth/totp.ts'; import { TOTPModel } from '/lib/models/multi-factor-auth/totp.ts';
import { EmailModel } from '/lib/models/multi-factor-auth/email.ts';
import { getMultiFactorAuthMethodByIdFromUser } from '/lib/utils/multi-factor-auth.ts'; import { getMultiFactorAuthMethodByIdFromUser } from '/lib/utils/multi-factor-auth.ts';
import { UserModel } from '/lib/models/user.ts'; import { UserModel } from '/lib/models/user.ts';
import { AppConfig } from '/lib/config.ts'; import { AppConfig } from '/lib/config.ts';
@@ -115,6 +116,25 @@ export const handler: Handlers<unknown, FreshContextState> = {
return new Response(JSON.stringify(responseBody), { status: 400 }); return new Response(JSON.stringify(responseBody), { status: 400 });
} }
} else if (method.type === 'email') {
try {
const isValid = await EmailModel.verifyCode(method.id, code, user);
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 verify email verification code',
};
return new Response(JSON.stringify(responseBody), { status: 500 });
}
} }
MultiFactorAuthModel.enableMethodForUser(user, methodId); MultiFactorAuthModel.enableMethodForUser(user, methodId);

View File

@@ -11,6 +11,7 @@ import {
isMultiFactorAuthEnabledForUser, isMultiFactorAuthEnabledForUser,
} from '/lib/utils/multi-factor-auth.ts'; } from '/lib/utils/multi-factor-auth.ts';
import { TOTPModel } from '/lib/models/multi-factor-auth/totp.ts'; import { TOTPModel } from '/lib/models/multi-factor-auth/totp.ts';
import { EmailModel } from '/lib/models/multi-factor-auth/email.ts';
import MultiFactorAuthVerifyForm from '/components/auth/MultiFactorAuthVerifyForm.tsx'; import MultiFactorAuthVerifyForm from '/components/auth/MultiFactorAuthVerifyForm.tsx';
interface Data { interface Data {
@@ -21,7 +22,6 @@ interface Data {
email?: string; email?: string;
redirectUrl?: string; redirectUrl?: string;
availableMethods?: MultiFactorAuthMethodType[]; availableMethods?: MultiFactorAuthMethodType[];
hasPasskey?: boolean;
} }
export const handler: Handlers<Data, FreshContextState> = { export const handler: Handlers<Data, FreshContextState> = {
@@ -49,13 +49,11 @@ export const handler: Handlers<Data, FreshContextState> = {
const enabledMethods = getEnabledMultiFactorAuthMethodsFromUser(user); const enabledMethods = getEnabledMultiFactorAuthMethodsFromUser(user);
const availableMethods = enabledMethods.map((method) => method.type); const availableMethods = enabledMethods.map((method) => method.type);
const hasPasskey = availableMethods.includes('passkey');
return await context.render({ return await context.render({
email: user.email, email: user.email,
redirectUrl, redirectUrl,
availableMethods, availableMethods,
hasPasskey,
}); });
}, },
async POST(request, context) { async POST(request, context) {
@@ -82,35 +80,49 @@ export const handler: Handlers<Data, FreshContextState> = {
const enabledMethods = getEnabledMultiFactorAuthMethodsFromUser(user); const enabledMethods = getEnabledMultiFactorAuthMethodsFromUser(user);
const availableMethods = enabledMethods.map((method) => method.type); const availableMethods = enabledMethods.map((method) => method.type);
const hasPasskey = availableMethods.includes('passkey');
try { try {
const formData = await request.formData(); const formData = await request.formData();
const code = getFormDataField(formData, 'code');
const token = getFormDataField(formData, 'token'); const token = getFormDataField(formData, 'token');
if (!token) { if (!code && !token) {
throw new Error('Authentication token is required'); throw new Error('Authentication code/token is required');
} }
let isValid = false; let isValid = false;
let updateUser = false; let updateUser = false;
// Passkey verification is handled in a separate process
for (const method of enabledMethods) { for (const method of enabledMethods) {
const verification = await TOTPModel.verifyMethodToken(method.metadata, token); // Passkey verification is handled in a separate process
if (verification.isValid) { if (method.type === 'passkey') {
isValid = true; continue;
}
if (verification.remainingCodes && method.type === 'totp' && method.metadata.totp) { if (method.type === 'totp') {
method.metadata.totp.hashed_backup_codes = verification.remainingCodes; const verification = await TOTPModel.verifyMethodToken(method.metadata, token);
updateUser = true; 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 (method.type === 'email') {
const verification = await EmailModel.verifyCode(method.id, code, user);
if (verification) {
isValid = true;
break;
} }
break;
} }
} }
if (!isValid) { if (!isValid) {
throw new Error('Invalid authentication token or backup code'); throw new Error('Invalid authentication code/token or backup code');
} }
if (updateUser) { if (updateUser) {
@@ -129,7 +141,6 @@ export const handler: Handlers<Data, FreshContextState> = {
email: user.email, email: user.email,
redirectUrl, redirectUrl,
availableMethods, availableMethods,
hasPasskey,
}); });
} }
}, },