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:
@@ -1,4 +1,4 @@
|
|||||||
FROM denoland/deno:ubuntu-2.3.3
|
FROM denoland/deno:ubuntu-2.3.5
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -25,7 +24,7 @@ export class EmailModel {
|
|||||||
pass: SMTP_PASSWORD,
|
pass: SMTP_PASSWORD,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport(transporterConfig);
|
const transporter = nodemailer.createTransport(transporterConfig);
|
||||||
|
|
||||||
const mailOptions = {
|
const mailOptions = {
|
||||||
@@ -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,7 +518,97 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
50
lib/models/multi-factor-auth/email.ts
Normal file
50
lib/models/multi-factor-auth/email.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 || [];
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
58
routes/api/auth/multi-factor/email/setup.ts
Normal file
58
routes/api/auth/multi-factor/email/setup.ts
Normal 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));
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user