Fleshed out fully all redis interactions for invitations and password resets.

This commit is contained in:
mike12345567 2021-05-05 12:11:06 +01:00
parent 1cf778845a
commit b372d2a824
8 changed files with 154 additions and 23 deletions

View File

@ -29,7 +29,7 @@ module.exports = {
}, },
db: require("./db/utils"), db: require("./db/utils"),
redis: { redis: {
client: require("./redis"), Client: require("./redis"),
utils: require("./redis/utils"), utils: require("./redis/utils"),
}, },
utils: { utils: {

View File

@ -12,6 +12,11 @@ let CLIENT
*/ */
function init() { function init() {
return new Promise((resolve, reject) => { 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) const { opts, host, port } = getRedisOptions(CLUSTERED)
if (CLUSTERED) { if (CLUSTERED) {
CLIENT = new Redis.Cluster([{ host, port }], opts) CLIENT = new Redis.Cluster([{ host, port }], opts)
@ -78,6 +83,10 @@ class RedisWrapper {
return this return this
} }
async finish() {
this._client.disconnect()
}
async scan() { async scan() {
const db = this._db, const db = this._db,
client = this._client client = this._client

View File

@ -6,6 +6,7 @@ const SEPARATOR = "-"
exports.Databases = { exports.Databases = {
PW_RESETS: "pwReset", PW_RESETS: "pwReset",
INVITATIONS: "invitation",
} }
exports.getRedisOptions = (clustered = false) => { exports.getRedisOptions = (clustered = false) => {

View File

@ -2,7 +2,7 @@ const authPkg = require("@budibase/auth")
const { google } = require("@budibase/auth/src/middleware") const { google } = require("@budibase/auth/src/middleware")
const { Configs } = require("../../../constants") const { Configs } = require("../../../constants")
const CouchDB = require("../../../db") const CouchDB = require("../../../db")
const { sendEmail } = require("../../../utilities/email") const { sendEmail, isEmailConfigured } = require("../../../utilities/email")
const { clearCookie, getGlobalUserByEmail } = authPkg.utils const { clearCookie, getGlobalUserByEmail } = authPkg.utils
const { Cookies } = authPkg.constants const { Cookies } = authPkg.constants
const { passport } = authPkg.auth const { passport } = authPkg.auth
@ -44,14 +44,19 @@ exports.authenticate = async (ctx, next) => {
*/ */
exports.reset = async ctx => { exports.reset = async ctx => {
const { email } = ctx.request.body const { email } = ctx.request.body
try { const configured = await isEmailConfigured()
const user = getGlobalUserByEmail(email) if (!configured) {
if (user) { throw "Please contact your platform administrator, SMTP is not configured."
} }
try {
const user = await getGlobalUserByEmail(email)
sendEmail()
} catch (err) { } catch (err) {
// don't throw any kind of error to the user, this might give away something // 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 => { exports.logout = async ctx => {

View File

@ -45,6 +45,8 @@ const TemplateBindings = {
LOGIN_URL: "loginUrl", LOGIN_URL: "loginUrl",
CURRENT_YEAR: "currentYear", CURRENT_YEAR: "currentYear",
CURRENT_DATE: "currentDate", CURRENT_DATE: "currentDate",
RESET_CODE: "resetCode",
INVITE_CODE: "inviteCode",
} }
const TemplateMetadata = { const TemplateMetadata = {

View File

@ -5,6 +5,7 @@ const { EmailTemplatePurpose, TemplateTypes, Configs } = require("../constants")
const { getTemplateByPurpose } = require("../constants/templates") const { getTemplateByPurpose } = require("../constants/templates")
const { getSettingsTemplateContext } = require("./templates") const { getSettingsTemplateContext } = require("./templates")
const { processString } = require("@budibase/string-templates") const { processString } = require("@budibase/string-templates")
const { getResetPasswordCode, getInviteCode } = require("../utilities/redis")
const GLOBAL_DB = StaticDatabases.GLOBAL.name const GLOBAL_DB = StaticDatabases.GLOBAL.name
const TYPE = TemplateTypes.EMAIL const TYPE = TemplateTypes.EMAIL
@ -30,6 +31,24 @@ function createSMTPTransport(config) {
return nodemailer.createTransport(options) 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<string>} returns the built email HTML if all provided parameters were valid.
*/
async function buildEmail(purpose, email, user) { async function buildEmail(purpose, email, user) {
// this isn't a full email // this isn't a full email
if (FULL_EMAIL_PURPOSES.indexOf(purpose) === -1) { if (FULL_EMAIL_PURPOSES.indexOf(purpose) === -1) {
@ -47,9 +66,10 @@ async function buildEmail(purpose, email, user) {
styles = styles.contents styles = styles.contents
body = body.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 = { const context = {
...(await getSettingsTemplateContext()), ...(await getSettingsTemplateContext(purpose, code)),
email, email,
user: user || {}, 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<object|null>} returns the SMTP configuration if it exists
*/
async function getSmtpConfiguration(db, groupId = null) {
const params = { const params = {
type: Configs.SMTP, type: Configs.SMTP,
} }
if (groupId) { if (groupId) {
params.group = groupId params.group = groupId
} }
let user = {} return determineScopedConfig(db, params)
if (userId) {
user = db.get(userId)
} }
const config = await determineScopedConfig(db, params)
/**
* Checks if a SMTP config exists based on passed in parameters.
* @param groupId
* @return {Promise<boolean>} 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<object>} 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) { if (!config) {
throw "Unable to find SMTP configuration" throw "Unable to find SMTP configuration"
} }
@ -90,6 +137,11 @@ exports.sendEmail = async (email, purpose, { groupId, userId }) => {
return transport.sendMail(message) 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<boolean>} returns true if the configuration is valid.
*/
exports.verifyConfig = async config => { exports.verifyConfig = async config => {
const transport = createSMTPTransport(config) const transport = createSMTPTransport(config)
await transport.verify() await transport.verify()

View File

@ -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<string>} 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<string>} 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<string>} returns the code that was stored to redis.
*/
exports.getInviteCode = async email => {
return writeACode(utils.Databases.INVITATIONS, email)
}

View File

@ -1,13 +1,13 @@
const CouchDB = require("../db") const CouchDB = require("../db")
const { getConfigParams, StaticDatabases } = require("@budibase/auth").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 { checkSlashesInUrl } = require("./index")
const env = require("../environment") const env = require("../environment")
const LOCAL_URL = `http://localhost:${env.PORT}` const LOCAL_URL = `http://localhost:${env.PORT}`
const BASE_COMPANY = "Budibase" const BASE_COMPANY = "Budibase"
exports.getSettingsTemplateContext = async () => { exports.getSettingsTemplateContext = async (purpose, code = null) => {
const db = new CouchDB(StaticDatabases.GLOBAL.name) const db = new CouchDB(StaticDatabases.GLOBAL.name)
const response = await db.allDocs( const response = await db.allDocs(
getConfigParams(Configs.SETTINGS, { getConfigParams(Configs.SETTINGS, {
@ -18,15 +18,10 @@ exports.getSettingsTemplateContext = async () => {
if (!settings.platformUrl) { if (!settings.platformUrl) {
settings.platformUrl = LOCAL_URL settings.platformUrl = LOCAL_URL
} }
// TODO: need to fully spec out the context
const URL = settings.platformUrl const URL = settings.platformUrl
return { const context = {
[TemplateBindings.LOGO_URL]: settings.logoUrl || LOGO_URL, [TemplateBindings.LOGO_URL]: settings.logoUrl || LOGO_URL,
[TemplateBindings.PLATFORM_URL]: URL, [TemplateBindings.PLATFORM_URL]: URL,
[TemplateBindings.REGISTRATION_URL]: checkSlashesInUrl(
`${URL}/registration`
),
[TemplateBindings.RESET_URL]: checkSlashesInUrl(`${URL}/reset`),
[TemplateBindings.COMPANY]: settings.company || BASE_COMPANY, [TemplateBindings.COMPANY]: settings.company || BASE_COMPANY,
[TemplateBindings.DOCS_URL]: [TemplateBindings.DOCS_URL]:
settings.docsUrl || "https://docs.budibase.com/", settings.docsUrl || "https://docs.budibase.com/",
@ -34,4 +29,20 @@ exports.getSettingsTemplateContext = async () => {
[TemplateBindings.CURRENT_DATE]: new Date().toISOString(), [TemplateBindings.CURRENT_DATE]: new Date().toISOString(),
[TemplateBindings.CURRENT_YEAR]: new Date().getFullYear(), [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
} }