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,3 @@
|
||||
// deno-fmt-ignore-file
|
||||
import nodemailer from 'nodemailer';
|
||||
import 'std/dotenv/load.ts';
|
||||
|
||||
@@ -25,7 +24,7 @@ export class EmailModel {
|
||||
pass: SMTP_PASSWORD,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
const transporter = nodemailer.createTransport(transporterConfig);
|
||||
|
||||
const mailOptions = {
|
||||
@@ -45,28 +44,9 @@ export class EmailModel {
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
`;
|
||||
|
||||
/** Based off of https://github.com/ActiveCampaign/postmark-templates/tree/main/templates-inlined/basic/password-reset */
|
||||
const htmlBody = `
|
||||
/** Based off of https://github.com/ActiveCampaign/postmark-templates/tree/main/templates-inlined/basic/password-reset */
|
||||
private static getHtmlBody(title: string, htmlBody: string) {
|
||||
return `
|
||||
<!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">
|
||||
<head>
|
||||
@@ -75,7 +55,7 @@ This code will expire in 30 minutes.
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="color-scheme" 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">
|
||||
/* Base ------------------------------ */
|
||||
|
||||
@@ -506,7 +486,7 @@ This code will expire in 30 minutes.
|
||||
<![endif]-->
|
||||
</head>
|
||||
<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">
|
||||
<tr>
|
||||
<td align="center">
|
||||
@@ -524,23 +504,7 @@ This code will expire in 30 minutes.
|
||||
<tr>
|
||||
<td class="content-cell">
|
||||
<div class="f-fallback">
|
||||
<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>
|
||||
${htmlBody}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -554,7 +518,97 @@ This code will expire in 30 minutes.
|
||||
</body>
|
||||
</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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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 {
|
||||
getEnabledMultiFactorAuthMethodsFromUser,
|
||||
getMultiFactorAuthMethodByIdFromUser,
|
||||
} from '/lib/utils/multi-factor-auth.ts';
|
||||
import {
|
||||
COOKIE_NAME as AUTH_COOKIE_NAME,
|
||||
generateKey,
|
||||
@@ -14,6 +17,7 @@ import {
|
||||
import { isRunningLocally } from '/lib/utils/misc.ts';
|
||||
import { AppConfig } from '/lib/config.ts';
|
||||
import { UserModel } from './user.ts';
|
||||
import { EmailModel } from './multi-factor-auth/email.ts';
|
||||
|
||||
const COOKIE_NAME = `${AUTH_COOKIE_NAME}-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);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user