diff --git a/packages/backend-core/tests/core/utilities/structures/users.ts b/packages/backend-core/tests/core/utilities/structures/users.ts index 66d23696e0..68ee29686c 100644 --- a/packages/backend-core/tests/core/utilities/structures/users.ts +++ b/packages/backend-core/tests/core/utilities/structures/users.ts @@ -12,7 +12,7 @@ import { generator } from "./generator" import { tenant } from "." export const newEmail = () => { - return `${uuid()}@test.com` + return `${uuid()}@example.com` } export const user = (userProps?: Partial>): User => { diff --git a/packages/worker/src/api/controllers/global/users.ts b/packages/worker/src/api/controllers/global/users.ts index de1a605890..1f4168c00b 100644 --- a/packages/worker/src/api/controllers/global/users.ts +++ b/packages/worker/src/api/controllers/global/users.ts @@ -1,5 +1,6 @@ import { - checkInviteCode, + getInviteCode, + deleteInviteCode, getInviteCodes, updateInviteCode, } from "../../../utilities/redis" @@ -336,10 +337,11 @@ export const checkInvite = async (ctx: any) => { const { code } = ctx.params let invite try { - invite = await checkInviteCode(code, false) + invite = await getInviteCode(code) } catch (e) { console.warn("Error getting invite from code", e) ctx.throw(400, "There was a problem with the invite") + return } ctx.body = { email: invite.email, @@ -365,12 +367,10 @@ export const updateInvite = async (ctx: any) => { let invite try { - invite = await checkInviteCode(code, false) - if (!invite) { - throw new Error("The invite could not be retrieved") - } + invite = await getInviteCode(code) } catch (e) { ctx.throw(400, "There was a problem with the invite") + return } let updated = { @@ -405,7 +405,8 @@ export const inviteAccept = async ( const { inviteCode, password, firstName, lastName } = ctx.request.body try { // info is an extension of the user object that was stored by global - const { email, info }: any = await checkInviteCode(inviteCode) + const { email, info }: any = await getInviteCode(inviteCode) + await deleteInviteCode(inviteCode) const user = await tenancy.doInTenant(info.tenantId, async () => { let request: any = { firstName, diff --git a/packages/worker/src/api/routes/global/tests/users.spec.ts b/packages/worker/src/api/routes/global/tests/users.spec.ts index 846b98a7ae..adcbca9d29 100644 --- a/packages/worker/src/api/routes/global/tests/users.spec.ts +++ b/packages/worker/src/api/routes/global/tests/users.spec.ts @@ -1,11 +1,12 @@ import { InviteUsersResponse, User } from "@budibase/types" -jest.mock("nodemailer") import { TestConfiguration, mocks, structures } from "../../../../tests" -const sendMailMock = mocks.email.mock() import { events, tenancy, accounts as _accounts } from "@budibase/backend-core" import * as userSdk from "../../../../sdk/users" +jest.mock("nodemailer") +const sendMailMock = mocks.email.mock() + const accounts = jest.mocked(_accounts) describe("/api/global/users", () => { @@ -54,6 +55,22 @@ describe("/api/global/users", () => { expect(events.user.invited).toBeCalledTimes(0) }) + it("should not invite the same user twice", async () => { + const email = structures.users.newEmail() + await config.api.users.sendUserInvite(sendMailMock, email) + + const { code, res } = await config.api.users.sendUserInvite( + sendMailMock, + email, + 400 + ) + + expect(res.body.message).toBe(`Unavailable`) + expect(sendMailMock).toHaveBeenCalledTimes(0) + expect(code).toBeUndefined() + expect(events.user.invited).toBeCalledTimes(0) + }) + it("should be able to create new user from invite", async () => { const email = structures.users.newEmail() const { code } = await config.api.users.sendUserInvite( diff --git a/packages/worker/src/sdk/auth/auth.ts b/packages/worker/src/sdk/auth/auth.ts index d989113c3d..e25d34fa5e 100644 --- a/packages/worker/src/sdk/auth/auth.ts +++ b/packages/worker/src/sdk/auth/auth.ts @@ -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.checkResetPasswordCode(resetCode) + const { userId } = await redis.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 230a8fa146..8f04bb1941 100644 --- a/packages/worker/src/sdk/users/users.ts +++ b/packages/worker/src/sdk/users/users.ts @@ -2,6 +2,7 @@ import { events, tenancy, users as usersCore } from "@budibase/backend-core" import { InviteUsersRequest, InviteUsersResponse } from "@budibase/types" import { sendEmail } from "../../utilities/email" import { EmailTemplatePurpose } from "../../constants" +import { getInviteCodes } from "../..//utilities/redis" export async function invite( users: InviteUsersRequest @@ -14,6 +15,7 @@ export async function invite( const matchedEmails = await usersCore.searchExistingEmails( users.map(u => u.email) ) + const existingInvites = await getInviteCodes() const newUsers = [] // separate duplicates from new users diff --git a/packages/worker/src/utilities/email.ts b/packages/worker/src/utilities/email.ts index c5b1d9d8ab..a0c02d335c 100644 --- a/packages/worker/src/utilities/email.ts +++ b/packages/worker/src/utilities/email.ts @@ -3,7 +3,7 @@ import { EmailTemplatePurpose, TemplateType } from "../constants" import { getTemplateByPurpose, EmailTemplates } from "../constants/templates" import { getSettingsTemplateContext } from "./templates" import { processString } from "@budibase/string-templates" -import { getResetPasswordCode, getInviteCode } from "./redis" +import { createResetPasswordCode, createInviteCode } from "./redis" import { User, SendEmailOpts, SMTPInnerConfig } from "@budibase/types" import { configs } from "@budibase/backend-core" import ical from "ical-generator" @@ -61,9 +61,9 @@ async function getLinkCode( ) { switch (purpose) { case EmailTemplatePurpose.PASSWORD_RECOVERY: - return getResetPasswordCode(user._id!, info) + return createResetPasswordCode(user._id!, info) case EmailTemplatePurpose.INVITATION: - return getInviteCode(email, info) + return createInviteCode(email, info) default: return null } diff --git a/packages/worker/src/utilities/redis.ts b/packages/worker/src/utilities/redis.ts index 993cdf97ce..9852fb0467 100644 --- a/packages/worker/src/utilities/redis.ts +++ b/packages/worker/src/utilities/redis.ts @@ -1,60 +1,24 @@ import { redis, utils, tenancy } from "@budibase/backend-core" import env from "../environment" -function getExpirySecondsForDB(db: string) { - switch (db) { - case redis.utils.Databases.PW_RESETS: - // a hour - return 3600 - case redis.utils.Databases.INVITATIONS: - // a week - return 604800 - } +interface Invite { + email: string + info: any } -let pwResetClient: any, invitationClient: any - -function getClient(db: string) { - switch (db) { - case redis.utils.Databases.PW_RESETS: - return pwResetClient - case redis.utils.Databases.INVITATIONS: - return invitationClient - } +interface InviteWithCode extends Invite { + code: string } -async function writeACode(db: string, value: any) { - const client = await getClient(db) - const code = utils.newid() - await client.store(code, value, getExpirySecondsForDB(db)) - return code +interface PasswordReset { + userId: string + info: any } -async function updateACode(db: string, code: string, value: any) { - const client = await 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(inviteCode: string, value: string) { - await updateACode(redis.utils.Databases.INVITATIONS, inviteCode, value) -} - -async function getACode(db: string, code: string, deleteCode = true) { - const client = await getClient(db) - const value = await client.get(code) - if (!value) { - throw new Error("Invalid code.") - } - if (deleteCode) { - await client.delete(code) - } - return value -} +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) @@ -63,9 +27,6 @@ export async function init() { await invitationClient.init() } -/** - * make sure redis connection is closed. - */ export async function shutdown() { if (pwResetClient) await pwResetClient.finish() if (invitationClient) await invitationClient.finish() @@ -74,6 +35,65 @@ export async function 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: any) { + 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). @@ -81,22 +101,20 @@ export async function shutdown() { * @param info Info about the user/the reset process. * @return returns the code that was stored to redis. */ -export async function getResetPasswordCode(userId: string, info: any) { - return writeACode(redis.utils.Databases.PW_RESETS, { userId, info }) +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 and delete if required. + * Given a reset code this will lookup to redis, check if the code is valid. * @param resetCode The code provided via the email link. - * @param deleteCode If the code is used/finished with this will delete it - defaults to true. * @return returns the user ID if it is found */ -export async function checkResetPasswordCode( - resetCode: string, - deleteCode = true -) { +export async function getResetPasswordCode( + code: string +): Promise { try { - return getACode(redis.utils.Databases.PW_RESETS, resetCode, deleteCode) + return getCode(redis.utils.Databases.PW_RESETS, code) } catch (err) { throw "Provided information is not valid, cannot reset password - please try again." } @@ -108,35 +126,35 @@ export async function checkResetPasswordCode( * @param info Information to be carried along with the invitation. * @return returns the code that was stored to redis. */ -export async function getInviteCode(email: string, info: any) { - return writeACode(redis.utils.Databases.INVITATIONS, { email, info }) +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. - * @param deleteCode whether or not the code should be deleted after retrieval - defaults to true. * @return If the code is valid then an email address will be returned. */ -export async function checkInviteCode( - inviteCode: string, - deleteCode: boolean = true -) { +export async function getInviteCode(code: string): Promise { try { - return getACode(redis.utils.Databases.INVITATIONS, inviteCode, deleteCode) + 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() { - const client = await getClient(redis.utils.Databases.INVITATIONS) - const invites: any[] = await client.scan() +export async function getInviteCodes(): Promise { + const client = getClient(redis.utils.Databases.INVITATIONS) + const invites: { key: string; value: Invite }[] = await client.scan() - const results = invites.map(invite => { + const results: InviteWithCode[] = invites.map(invite => { return { ...invite.value, code: invite.key,