From b372d2a824801fdb0dfef79f8ae353825816a4b7 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Wed, 5 May 2021 12:11:06 +0100 Subject: [PATCH] Fleshed out fully all redis interactions for invitations and password resets. --- packages/auth/src/index.js | 2 +- packages/auth/src/redis/index.js | 9 +++ packages/auth/src/redis/utils.js | 1 + .../worker/src/api/controllers/admin/auth.js | 15 ++-- packages/worker/src/constants/index.js | 2 + packages/worker/src/utilities/email.js | 70 ++++++++++++++++--- packages/worker/src/utilities/redis.js | 51 ++++++++++++++ packages/worker/src/utilities/templates.js | 27 ++++--- 8 files changed, 154 insertions(+), 23 deletions(-) create mode 100644 packages/worker/src/utilities/redis.js diff --git a/packages/auth/src/index.js b/packages/auth/src/index.js index 5b3de3e336..e330684197 100644 --- a/packages/auth/src/index.js +++ b/packages/auth/src/index.js @@ -29,7 +29,7 @@ module.exports = { }, db: require("./db/utils"), redis: { - client: require("./redis"), + Client: require("./redis"), utils: require("./redis/utils"), }, utils: { diff --git a/packages/auth/src/redis/index.js b/packages/auth/src/redis/index.js index cdd51fc2a4..0cb92c4622 100644 --- a/packages/auth/src/redis/index.js +++ b/packages/auth/src/redis/index.js @@ -12,6 +12,11 @@ let CLIENT */ function init() { return new Promise((resolve, reject) => { + // if a connection existed, close it and re-create it + if (CLIENT) { + CLIENT.disconnect() + CLIENT = null + } const { opts, host, port } = getRedisOptions(CLUSTERED) if (CLUSTERED) { CLIENT = new Redis.Cluster([{ host, port }], opts) @@ -78,6 +83,10 @@ class RedisWrapper { return this } + async finish() { + this._client.disconnect() + } + async scan() { const db = this._db, client = this._client diff --git a/packages/auth/src/redis/utils.js b/packages/auth/src/redis/utils.js index 54b0c882cd..a29f786c41 100644 --- a/packages/auth/src/redis/utils.js +++ b/packages/auth/src/redis/utils.js @@ -6,6 +6,7 @@ const SEPARATOR = "-" exports.Databases = { PW_RESETS: "pwReset", + INVITATIONS: "invitation", } exports.getRedisOptions = (clustered = false) => { diff --git a/packages/worker/src/api/controllers/admin/auth.js b/packages/worker/src/api/controllers/admin/auth.js index 94fb8e0ece..1d6981d0ed 100644 --- a/packages/worker/src/api/controllers/admin/auth.js +++ b/packages/worker/src/api/controllers/admin/auth.js @@ -2,7 +2,7 @@ const authPkg = require("@budibase/auth") const { google } = require("@budibase/auth/src/middleware") const { Configs } = require("../../../constants") const CouchDB = require("../../../db") -const { sendEmail } = require("../../../utilities/email") +const { sendEmail, isEmailConfigured } = require("../../../utilities/email") const { clearCookie, getGlobalUserByEmail } = authPkg.utils const { Cookies } = authPkg.constants const { passport } = authPkg.auth @@ -44,14 +44,19 @@ exports.authenticate = async (ctx, next) => { */ exports.reset = async ctx => { const { email } = ctx.request.body + const configured = await isEmailConfigured() + if (!configured) { + throw "Please contact your platform administrator, SMTP is not configured." + } try { - const user = getGlobalUserByEmail(email) - if (user) { - } + const user = await getGlobalUserByEmail(email) + sendEmail() } catch (err) { // don't throw any kind of error to the user, this might give away something } - ctx.body = {} + ctx.body = { + message: "If user exists an email has been sent." + } } exports.logout = async ctx => { diff --git a/packages/worker/src/constants/index.js b/packages/worker/src/constants/index.js index f5702d3e96..618e8e036d 100644 --- a/packages/worker/src/constants/index.js +++ b/packages/worker/src/constants/index.js @@ -45,6 +45,8 @@ const TemplateBindings = { LOGIN_URL: "loginUrl", CURRENT_YEAR: "currentYear", CURRENT_DATE: "currentDate", + RESET_CODE: "resetCode", + INVITE_CODE: "inviteCode", } const TemplateMetadata = { diff --git a/packages/worker/src/utilities/email.js b/packages/worker/src/utilities/email.js index 23af1ab623..524d305899 100644 --- a/packages/worker/src/utilities/email.js +++ b/packages/worker/src/utilities/email.js @@ -5,6 +5,7 @@ const { EmailTemplatePurpose, TemplateTypes, Configs } = require("../constants") const { getTemplateByPurpose } = require("../constants/templates") const { getSettingsTemplateContext } = require("./templates") const { processString } = require("@budibase/string-templates") +const { getResetPasswordCode, getInviteCode } = require("../utilities/redis") const GLOBAL_DB = StaticDatabases.GLOBAL.name const TYPE = TemplateTypes.EMAIL @@ -30,6 +31,24 @@ function createSMTPTransport(config) { return nodemailer.createTransport(options) } +async function getLinkCode(purpose, email, user) { + switch (purpose) { + case EmailTemplatePurpose.PASSWORD_RECOVERY: + return getResetPasswordCode(user._id) + case EmailTemplatePurpose.INVITATION: + return getInviteCode(email) + default: + return null + } +} + +/** + * Builds an email using handlebars and the templates found in the system (default or otherwise). + * @param {string} purpose the purpose of the email being built, e.g. invitation, password reset. + * @param {string} email the address which it is being sent to for contextual purposes. + * @param {object|null} user If being sent to an existing user then the object can be provided for context. + * @return {Promise} returns the built email HTML if all provided parameters were valid. + */ async function buildEmail(purpose, email, user) { // this isn't a full email if (FULL_EMAIL_PURPOSES.indexOf(purpose) === -1) { @@ -47,9 +66,10 @@ async function buildEmail(purpose, email, user) { styles = styles.contents body = body.contents - // TODO: need to extend the context as much as possible + // if there is a link code needed this will retrieve it + const code = await getLinkCode(purpose, email, user) const context = { - ...(await getSettingsTemplateContext()), + ...(await getSettingsTemplateContext(purpose, code)), email, user: user || {}, } @@ -64,19 +84,46 @@ async function buildEmail(purpose, email, user) { }) } -exports.sendEmail = async (email, purpose, { groupId, userId }) => { - const db = new CouchDB(GLOBAL_DB) +/** + * Utility function for finding most valid SMTP configuration. + * @param {object} db The CouchDB database which is to be looked up within. + * @param {string|null} groupId If using finer grain control of configs a group can be used. + * @return {Promise} returns the SMTP configuration if it exists + */ +async function getSmtpConfiguration(db, groupId = null) { const params = { type: Configs.SMTP, } if (groupId) { params.group = groupId } - let user = {} - if (userId) { - user = db.get(userId) - } - const config = await determineScopedConfig(db, params) + return determineScopedConfig(db, params) +} + +/** + * Checks if a SMTP config exists based on passed in parameters. + * @param groupId + * @return {Promise} returns true if there is a configuration that can be used. + */ +exports.isEmailConfigured = async (groupId = null) => { + const db = new CouchDB(GLOBAL_DB) + const config = await getSmtpConfiguration(db, groupId) + return config != null +} + +/** + * Given an email address and an email purpose this will retrieve the SMTP configuration and + * send an email using it. + * @param {string} email The email address to send to. + * @param {string} purpose The purpose of the email being sent (e.g. reset password). + * @param {string|null} groupId If finer grain controls being used then this will lookup config for group. + * @param {object|null} if sending to an existing user the object can be provided, this is used in the context. + * @return {Promise} returns details about the attempt to send email, e.g. if it is successful; based on + * nodemailer response. + */ +exports.sendEmail = async (email, purpose, { groupId, user }) => { + const db = new CouchDB(GLOBAL_DB) + const config = await getSmtpConfiguration(db, groupId) if (!config) { throw "Unable to find SMTP configuration" } @@ -90,6 +137,11 @@ exports.sendEmail = async (email, purpose, { groupId, userId }) => { return transport.sendMail(message) } +/** + * Given an SMTP configuration this runs it through nodemailer to see if it is infact functional. + * @param {object} config an SMTP configuration - this is based on the nodemailer API. + * @return {Promise} returns true if the configuration is valid. + */ exports.verifyConfig = async config => { const transport = createSMTPTransport(config) await transport.verify() diff --git a/packages/worker/src/utilities/redis.js b/packages/worker/src/utilities/redis.js new file mode 100644 index 0000000000..7e40af6b65 --- /dev/null +++ b/packages/worker/src/utilities/redis.js @@ -0,0 +1,51 @@ +const { Client, utils } = require("@budibase/auth").redis +const { newid } = require("@budibase/auth").utils + +const EXPIRE_TOKEN_SECONDS = 3600 + +async function getClient(db) { + return await (new Client(db)).init() +} + +async function writeACode(db, value) { + const client = await getClient(db) + const code = newid() + await client.store(code, value, EXPIRE_TOKEN_SECONDS) + client.finish() + return code +} + +/** + * Given a user ID this will store a code (that is returned) for an hour in redis. + * The user can then return this code for resetting their password (through their reset link). + * @param {string} userId the ID of the user which is to be reset. + * @return {Promise} returns the code that was stored to redis. + */ +exports.getResetPasswordCode = async userId => { + return writeACode(utils.Databases.PW_RESETS, userId) +} + +/** + * Given a reset code this will lookup to redis, check if the code is valid and delete if required. + * @param {string} resetCode The code provided via the email link. + * @param {boolean} deleteCode If the code is used/finished with this will delete it. + * @return {Promise} returns the user ID if it is found + */ +exports.checkResetPasswordCode = async (resetCode, deleteCode = false) => { + const client = await getClient(utils.Databases.PW_RESETS) + const userId = await client.get(resetCode) + if (deleteCode) { + await client.delete(resetCode) + } + client.finish() + return userId +} + +/** + * Generates an invitation code and writes it to redis - which can later be checked for user creation. + * @param {string} email the email address which the code is being sent to (for use later). + * @return {Promise} returns the code that was stored to redis. + */ +exports.getInviteCode = async email => { + return writeACode(utils.Databases.INVITATIONS, email) +} \ No newline at end of file diff --git a/packages/worker/src/utilities/templates.js b/packages/worker/src/utilities/templates.js index 3035dc2bbc..1c32461245 100644 --- a/packages/worker/src/utilities/templates.js +++ b/packages/worker/src/utilities/templates.js @@ -1,13 +1,13 @@ const CouchDB = require("../db") const { getConfigParams, StaticDatabases } = require("@budibase/auth").db -const { Configs, TemplateBindings, LOGO_URL } = require("../constants") +const { Configs, TemplateBindings, LOGO_URL, EmailTemplatePurpose } = require("../constants") const { checkSlashesInUrl } = require("./index") const env = require("../environment") const LOCAL_URL = `http://localhost:${env.PORT}` const BASE_COMPANY = "Budibase" -exports.getSettingsTemplateContext = async () => { +exports.getSettingsTemplateContext = async (purpose, code = null) => { const db = new CouchDB(StaticDatabases.GLOBAL.name) const response = await db.allDocs( getConfigParams(Configs.SETTINGS, { @@ -18,15 +18,10 @@ exports.getSettingsTemplateContext = async () => { if (!settings.platformUrl) { settings.platformUrl = LOCAL_URL } - // TODO: need to fully spec out the context const URL = settings.platformUrl - return { + const context = { [TemplateBindings.LOGO_URL]: settings.logoUrl || LOGO_URL, [TemplateBindings.PLATFORM_URL]: URL, - [TemplateBindings.REGISTRATION_URL]: checkSlashesInUrl( - `${URL}/registration` - ), - [TemplateBindings.RESET_URL]: checkSlashesInUrl(`${URL}/reset`), [TemplateBindings.COMPANY]: settings.company || BASE_COMPANY, [TemplateBindings.DOCS_URL]: settings.docsUrl || "https://docs.budibase.com/", @@ -34,4 +29,20 @@ exports.getSettingsTemplateContext = async () => { [TemplateBindings.CURRENT_DATE]: new Date().toISOString(), [TemplateBindings.CURRENT_YEAR]: new Date().getFullYear(), } + // attach purpose specific context + switch (purpose) { + case EmailTemplatePurpose.PASSWORD_RECOVERY: + context[TemplateBindings.RESET_CODE] = code + context[TemplateBindings.RESET_URL] = checkSlashesInUrl( + `${URL}/reset/${code}` + ) + break + case EmailTemplatePurpose.INVITATION: + context[TemplateBindings.INVITE_CODE] = code + context[TemplateBindings.REGISTRATION_URL] = checkSlashesInUrl( + `${URL}/registration/${code}` + ) + break + } + return context }