From b29cfc600cf628fae8b4740bdabc530cbb8f2e21 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Thu, 9 Nov 2023 14:51:07 +0000 Subject: [PATCH] Move Invite and PasswordReset code into backend-core. --- packages/backend-core/src/redis/index.ts | 2 + packages/backend-core/src/redis/invite.ts | 96 ++++++++++ .../backend-core/src/redis/passwordReset.ts | 51 ++++++ packages/backend-core/src/users/lookup.ts | 4 + .../src/api/controllers/global/users.ts | 2 +- packages/worker/src/index.ts | 3 - packages/worker/src/sdk/auth/auth.ts | 4 +- packages/worker/src/sdk/users/users.ts | 7 +- packages/worker/src/utilities/email.ts | 3 +- packages/worker/src/utilities/redis.ts | 172 ------------------ 10 files changed, 159 insertions(+), 185 deletions(-) create mode 100644 packages/backend-core/src/redis/invite.ts create mode 100644 packages/backend-core/src/redis/passwordReset.ts delete mode 100644 packages/worker/src/utilities/redis.ts diff --git a/packages/backend-core/src/redis/index.ts b/packages/backend-core/src/redis/index.ts index 6585d6e4fa..8d153ea5a1 100644 --- a/packages/backend-core/src/redis/index.ts +++ b/packages/backend-core/src/redis/index.ts @@ -4,3 +4,5 @@ export { default as Client } from "./redis" export * as utils from "./utils" export * as clients from "./init" export * as locks from "./redlockImpl" +export * as invite from "./invite" +export * as passwordReset from "./passwordReset" diff --git a/packages/backend-core/src/redis/invite.ts b/packages/backend-core/src/redis/invite.ts new file mode 100644 index 0000000000..db36d3dfa6 --- /dev/null +++ b/packages/backend-core/src/redis/invite.ts @@ -0,0 +1,96 @@ +import { redis, utils, tenancy } from "../" +import env from "../environment" + +const TTL_SECONDS = 60 * 60 * 24 * 7 + +interface Invite { + email: string + info: any +} + +interface InviteWithCode extends Invite { + code: string +} + +let client: redis.Client + +async function getClient(): Promise { + if (!client) { + client = new redis.Client(redis.utils.Databases.INVITATIONS) + await client.init() + } + return client +} + +/** + * Given an invite code and invite body, allow the update an existing/valid invite in redis + * @param inviteCode The invite code for an invite in redis + * @param value The body of the updated user invitation + */ +export async function updateInviteCode(code: string, value: Invite) { + const client = await getClient() + await client.store(code, value, TTL_SECONDS) +} + +/** + * Generates an invitation code and writes it to redis - which can later be checked for user creation. + * @param email the email address which the code is being sent to (for use later). + * @param info Information to be carried along with the invitation. + * @return returns the code that was stored to redis. + */ +export async function createInviteCode( + email: string, + info: any +): Promise { + const client = await getClient() + const code = utils.newid() + await client.store(code, { email, info }, TTL_SECONDS) + return code +} + +/** + * Checks that the provided invite code is valid - will return the email address of user that was invited. + * @param inviteCode the invite code that was provided as part of the link. + * @return If the code is valid then an email address will be returned. + */ +export async function getInviteCode(code: string): Promise { + const client = await getClient() + const value = (await client.get(code)) as Invite | undefined + if (!value) { + throw "Invitation is not valid or has expired, please request a new one." + } + return value +} + +export async function deleteInviteCode(code: string) { + const client = await getClient() + await client.delete(code) +} + +/** + Get all currently available user invitations for the current tenant. +**/ +export async function getInviteCodes(): Promise { + const client = await getClient() + const invites: { key: string; value: Invite }[] = await client.scan() + + const results: InviteWithCode[] = invites.map(invite => { + return { + ...invite.value, + code: invite.key, + } + }) + if (!env.MULTI_TENANCY) { + return results + } + const tenantId = tenancy.getTenantId() + return results.filter(invite => tenantId === invite.info.tenantId) +} + +export async function getExistingInvites( + emails: string[] +): Promise { + return (await getInviteCodes()).filter(invite => + emails.includes(invite.email) + ) +} diff --git a/packages/backend-core/src/redis/passwordReset.ts b/packages/backend-core/src/redis/passwordReset.ts new file mode 100644 index 0000000000..63c3371bba --- /dev/null +++ b/packages/backend-core/src/redis/passwordReset.ts @@ -0,0 +1,51 @@ +import { redis, utils } from "../" + +const TTL_SECONDS = 60 * 60 + +interface PasswordReset { + userId: string + info: any +} + +let client: redis.Client + +async function getClient(): Promise { + if (!client) { + client = new redis.Client(redis.utils.Databases.PW_RESETS) + await client.init() + } + return client +} + +/** + * 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 userId the ID of the user which is to be reset. + * @param info Info about the user/the reset process. + * @return returns the code that was stored to redis. + */ +export async function createResetPasswordCode( + userId: string, + info: any +): Promise { + const client = await getClient() + const code = utils.newid() + await client.store(code, { userId, info }, TTL_SECONDS) + return code +} + +/** + * Given a reset code this will lookup to redis, check if the code is valid. + * @param code The code provided via the email link. + * @return returns the user ID if it is found + */ +export async function getResetPasswordCode( + code: string +): Promise { + const client = await getClient() + const value = (await client.get(code)) as PasswordReset | undefined + if (!value) { + throw "Provided information is not valid, cannot reset password - please try again." + } + return value +} diff --git a/packages/backend-core/src/users/lookup.ts b/packages/backend-core/src/users/lookup.ts index 17d0e91d88..2c0c66276a 100644 --- a/packages/backend-core/src/users/lookup.ts +++ b/packages/backend-core/src/users/lookup.ts @@ -6,6 +6,7 @@ import { } from "@budibase/types" import * as dbUtils from "../db" import { ViewName } from "../constants" +import { getExistingInvites } from "../redis/invite" /** * Apply a system-wide search on emails: @@ -26,6 +27,9 @@ export async function searchExistingEmails(emails: string[]) { const existingAccounts = await getExistingAccounts(emails) matchedEmails.push(...existingAccounts.map(account => account.email)) + const invitedEmails = await getExistingInvites(emails) + matchedEmails.push(...invitedEmails.map(invite => invite.email)) + return [...new Set(matchedEmails.map(email => email.toLowerCase()))] } diff --git a/packages/worker/src/api/controllers/global/users.ts b/packages/worker/src/api/controllers/global/users.ts index fae42acdfe..8fb2b5675d 100644 --- a/packages/worker/src/api/controllers/global/users.ts +++ b/packages/worker/src/api/controllers/global/users.ts @@ -3,7 +3,7 @@ import { deleteInviteCode, getInviteCodes, updateInviteCode, -} from "../../../utilities/redis" +} from "@budibase/backend-core/src/redis/invite" import * as userSdk from "../../../sdk/users" import env from "../../../environment" import { diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 4b1d11ecf7..1c101983df 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -22,7 +22,6 @@ import Koa from "koa" import koaBody from "koa-body" import http from "http" import api from "./api" -import * as redis from "./utilities/redis" const koaSession = require("koa-session") import { userAgent } from "koa-useragent" @@ -72,7 +71,6 @@ server.on("close", async () => { shuttingDown = true console.log("Server Closed") timers.cleanup() - await redis.shutdown() await events.shutdown() await queue.shutdown() if (!env.isTest()) { @@ -88,7 +86,6 @@ const shutdown = () => { export default server.listen(parseInt(env.PORT || "4002"), async () => { console.log(`Worker running on ${JSON.stringify(server.address())}`) await initPro() - await redis.init() // configure events to use the pro audit log write // can't integrate directly into backend-core due to cyclic issues await events.processors.init(proSdk.auditLogs.write) diff --git a/packages/worker/src/sdk/auth/auth.ts b/packages/worker/src/sdk/auth/auth.ts index e25d34fa5e..57131294b3 100644 --- a/packages/worker/src/sdk/auth/auth.ts +++ b/packages/worker/src/sdk/auth/auth.ts @@ -6,12 +6,12 @@ import { sessions, tenancy, utils as coreUtils, + redis, } from "@budibase/backend-core" import { PlatformLogoutOpts, User } from "@budibase/types" import jwt from "jsonwebtoken" import * as userSdk from "../users" import * as emails from "../../utilities/email" -import * as redis from "../../utilities/redis" import { EmailTemplatePurpose } from "../../constants" // LOGIN / LOGOUT @@ -73,7 +73,7 @@ export const reset = async (email: string) => { * Perform the user password update if the provided reset code is valid. */ export const resetUpdate = async (resetCode: string, password: string) => { - const { userId } = await redis.getResetPasswordCode(resetCode) + const { userId } = await redis.passwordReset.getResetPasswordCode(resetCode) let user = await userSdk.db.getUser(userId) user.password = password diff --git a/packages/worker/src/sdk/users/users.ts b/packages/worker/src/sdk/users/users.ts index 4cca0b8fa6..9dc1bac0e9 100644 --- a/packages/worker/src/sdk/users/users.ts +++ b/packages/worker/src/sdk/users/users.ts @@ -6,7 +6,6 @@ import { } from "@budibase/types" import { sendEmail } from "../../utilities/email" import { EmailTemplatePurpose } from "../../constants" -import { getInviteCodes } from "../..//utilities/redis" export async function invite( users: InviteUsersRequest @@ -19,15 +18,11 @@ export async function invite( const matchedEmails = await usersCore.searchExistingEmails( users.map(u => u.email) ) - const invitedEmails = (await getInviteCodes()).map(invite => invite.email) const newUsers: InviteUserRequest[] = [] // separate duplicates from new users for (let user of users) { - if ( - matchedEmails.includes(user.email) || - invitedEmails.includes(user.email) - ) { + if (matchedEmails.includes(user.email)) { // This "Unavailable" is load bearing. The tests and frontend both check for it // specifically response.unsuccessful.push({ email: user.email, reason: "Unavailable" }) diff --git a/packages/worker/src/utilities/email.ts b/packages/worker/src/utilities/email.ts index a0c02d335c..f3f1fd40c4 100644 --- a/packages/worker/src/utilities/email.ts +++ b/packages/worker/src/utilities/email.ts @@ -3,7 +3,8 @@ import { EmailTemplatePurpose, TemplateType } from "../constants" import { getTemplateByPurpose, EmailTemplates } from "../constants/templates" import { getSettingsTemplateContext } from "./templates" import { processString } from "@budibase/string-templates" -import { createResetPasswordCode, createInviteCode } from "./redis" +import { createResetPasswordCode } from "@budibase/backend-core/src/redis/passwordReset" +import { createInviteCode } from "@budibase/backend-core/src/redis/invite" import { User, SendEmailOpts, SMTPInnerConfig } from "@budibase/types" import { configs } from "@budibase/backend-core" import ical from "ical-generator" diff --git a/packages/worker/src/utilities/redis.ts b/packages/worker/src/utilities/redis.ts deleted file mode 100644 index 337d34e376..0000000000 --- a/packages/worker/src/utilities/redis.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { redis, utils, tenancy } from "@budibase/backend-core" -import env from "../environment" - -interface Invite { - email: string - info: any -} - -interface InviteWithCode extends Invite { - code: string -} - -interface PasswordReset { - userId: string - info: any -} - -type RedisDBName = - | redis.utils.Databases.PW_RESETS - | redis.utils.Databases.INVITATIONS -let pwResetClient: redis.Client, invitationClient: redis.Client - -export async function init() { - pwResetClient = new redis.Client(redis.utils.Databases.PW_RESETS) - invitationClient = new redis.Client(redis.utils.Databases.INVITATIONS) - await pwResetClient.init() - await invitationClient.init() -} - -export async function shutdown() { - if (pwResetClient) await pwResetClient.finish() - if (invitationClient) await invitationClient.finish() - // shutdown core clients - await redis.clients.shutdown() - console.log("Redis shutdown") -} - -function getExpirySecondsForDB(db: RedisDBName) { - switch (db) { - case redis.utils.Databases.PW_RESETS: - // a hour - return 3600 - case redis.utils.Databases.INVITATIONS: - // a week - return 604800 - default: - throw new Error(`Unknown redis database: ${db}`) - } -} - -function getClient(db: RedisDBName): redis.Client { - switch (db) { - case redis.utils.Databases.PW_RESETS: - return pwResetClient - case redis.utils.Databases.INVITATIONS: - return invitationClient - default: - throw new Error(`Unknown redis database: ${db}`) - } -} - -async function writeCode(db: RedisDBName, value: Invite | PasswordReset) { - const client = getClient(db) - const code = utils.newid() - await client.store(code, value, getExpirySecondsForDB(db)) - return code -} - -async function updateCode( - db: RedisDBName, - code: string, - value: Invite | PasswordReset -) { - const client = getClient(db) - await client.store(code, value, getExpirySecondsForDB(db)) -} - -/** - * Given an invite code and invite body, allow the update an existing/valid invite in redis - * @param inviteCode The invite code for an invite in redis - * @param value The body of the updated user invitation - */ -export async function updateInviteCode(code: string, value: Invite) { - await updateCode(redis.utils.Databases.INVITATIONS, code, value) -} - -async function deleteCode(db: RedisDBName, code: string) { - const client = getClient(db) - await client.delete(code) -} - -async function getCode(db: RedisDBName, code: string) { - const client = getClient(db) - const value = await client.get(code) - if (!value) { - throw new Error(`Could not find code: ${code}`) - } - return value -} - -/** - * 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 userId the ID of the user which is to be reset. - * @param info Info about the user/the reset process. - * @return returns the code that was stored to redis. - */ -export async function createResetPasswordCode(userId: string, info: any) { - return writeCode(redis.utils.Databases.PW_RESETS, { userId, info }) -} - -/** - * Given a reset code this will lookup to redis, check if the code is valid. - * @param resetCode The code provided via the email link. - * @return returns the user ID if it is found - */ -export async function getResetPasswordCode( - code: string -): Promise { - try { - return getCode(redis.utils.Databases.PW_RESETS, code) - } catch (err) { - throw "Provided information is not valid, cannot reset password - please try again." - } -} - -/** - * Generates an invitation code and writes it to redis - which can later be checked for user creation. - * @param email the email address which the code is being sent to (for use later). - * @param info Information to be carried along with the invitation. - * @return returns the code that was stored to redis. - */ -export async function createInviteCode(email: string, info: any) { - return writeCode(redis.utils.Databases.INVITATIONS, { email, info }) -} - -/** - * Checks that the provided invite code is valid - will return the email address of user that was invited. - * @param inviteCode the invite code that was provided as part of the link. - * @return If the code is valid then an email address will be returned. - */ -export async function getInviteCode(code: string): Promise { - try { - return getCode(redis.utils.Databases.INVITATIONS, code) - } catch (err) { - throw "Invitation is not valid or has expired, please request a new one." - } -} - -export async function deleteInviteCode(code: string) { - return deleteCode(redis.utils.Databases.INVITATIONS, code) -} - -/** - Get all currently available user invitations for the current tenant. -**/ -export async function getInviteCodes(): Promise { - const client = getClient(redis.utils.Databases.INVITATIONS) - const invites: { key: string; value: Invite }[] = await client.scan() - - const results: InviteWithCode[] = invites.map(invite => { - return { - ...invite.value, - code: invite.key, - } - }) - if (!env.MULTI_TENANCY) { - return results - } - const tenantId = tenancy.getTenantId() - return results.filter(invite => tenantId === invite.info.tenantId) -}