From 6a346d465000f1576cce1e52a6ae9e578006b310 Mon Sep 17 00:00:00 2001 From: Rory Powell Date: Tue, 12 Apr 2022 12:34:36 +0100 Subject: [PATCH] User management events --- packages/backend-core/src/events/constants.js | 7 +-- .../backend-core/src/events/handlers/user.js | 24 ++------ .../src/tests/utilities/mocks/events.js | 5 +- packages/backend-core/src/users.js | 14 ++++- .../worker/src/api/controllers/global/auth.ts | 4 ++ .../worker/src/api/controllers/global/self.js | 10 ++++ .../src/api/controllers/global/users.ts | 8 ++- .../worker/src/api/routes/tests/auth.spec.js | 42 +++++++++----- .../worker/src/api/routes/tests/self.spec.js | 58 +++++++++++++++++++ .../worker/src/api/routes/tests/users.spec.js | 44 +++++++++++--- packages/worker/src/sdk/users/events.ts | 8 +++ packages/worker/src/sdk/users/users.ts | 3 +- 12 files changed, 174 insertions(+), 53 deletions(-) create mode 100644 packages/worker/src/api/routes/tests/self.spec.js diff --git a/packages/backend-core/src/events/constants.js b/packages/backend-core/src/events/constants.js index 6be537b8de..99bd8a6ada 100644 --- a/packages/backend-core/src/events/constants.js +++ b/packages/backend-core/src/events/constants.js @@ -3,7 +3,6 @@ exports.Events = { USER_CREATED: "user:created", USER_UPDATED: "user:updated", USER_DELETED: "user:deleted", - USER_PASSWORD_FORCE_RESET: "user:password:force:reset", // USER / PERMISSIONS USER_PERMISSION_ADMIN_ASSIGNED: "user:admin:assigned", @@ -15,9 +14,9 @@ exports.Events = { USER_INVITED: "user:invited", USER_INVITED_ACCEPTED: "user:invite:accepted", - // USER / SELF - USER_SELF_UPDATED: "user:self:updated", - USER_SELF_PASSWORD_UPDATED: "user:self:password:updated", + // USER / PASSWORD + USER_PASSWORD_FORCE_RESET: "user:password:force:reset", + USER_PASSWORD_UPDATED: "user:password:updated", USER_PASSWORD_RESET_REQUESTED: "user:password:reset:requested", USER_PASSWORD_RESET: "user:password:reset", diff --git a/packages/backend-core/src/events/handlers/user.js b/packages/backend-core/src/events/handlers/user.js index 8cbb627797..ca6d541a7d 100644 --- a/packages/backend-core/src/events/handlers/user.js +++ b/packages/backend-core/src/events/handlers/user.js @@ -18,12 +18,6 @@ exports.deleted = user => { events.processEvent(Events.USER_DELETED, properties) } -// TODO -exports.passwordForceReset = user => { - const properties = {} - events.processEvent(Events.USER_PASSWORD_FORCE_RESET, properties) -} - // PERMISSIONS exports.permissionAdminAssigned = user => { @@ -48,39 +42,33 @@ exports.permissionBuilderRemoved = user => { // INVITE -// TODO -exports.invited = user => { +exports.invited = userInfo => { const properties = {} events.processEvent(Events.USER_INVITED, properties) } -// TODO exports.inviteAccepted = user => { const properties = {} events.processEvent(Events.USER_INVITED_ACCEPTED, properties) } -// SELF +// PASSWORD -// TODO -exports.selfUpdated = user => { +exports.passwordForceReset = user => { const properties = {} - events.processEvent(Events.USER_SELF_UPDATED, properties) + events.processEvent(Events.USER_PASSWORD_FORCE_RESET, properties) } -// TODO -exports.selfPasswordUpdated = user => { +exports.passwordUpdated = user => { const properties = {} - events.processEvent(Events.USER_SELF_PASSWORD_UPDATED, properties) + events.processEvent(Events.USER_PASSWORD_UPDATED, properties) } -// TODO exports.passwordResetRequested = user => { const properties = {} events.processEvent(Events.USER_PASSWORD_RESET_REQUESTED, properties) } -// TODO exports.passwordReset = user => { const properties = {} events.processEvent(Events.USER_PASSWORD_RESET, properties) diff --git a/packages/backend-core/src/tests/utilities/mocks/events.js b/packages/backend-core/src/tests/utilities/mocks/events.js index 5c5738adab..4ec2da5835 100644 --- a/packages/backend-core/src/tests/utilities/mocks/events.js +++ b/packages/backend-core/src/tests/utilities/mocks/events.js @@ -80,15 +80,14 @@ jest.mock("../../../events", () => { created: jest.fn(), updated: jest.fn(), deleted: jest.fn(), - passwordForceReset: jest.fn(), permissionAdminAssigned: jest.fn(), permissionAdminRemoved: jest.fn(), permissionBuilderAssigned: jest.fn(), permissionBuilderRemoved: jest.fn(), invited: jest.fn(), inviteAccepted: jest.fn(), - selfUpdated: jest.fn(), - selfPasswordUpdated: jest.fn(), + passwordForceReset: jest.fn(), + passwordUpdated: jest.fn(), passwordResetRequested: jest.fn(), passwordReset: jest.fn(), }, diff --git a/packages/backend-core/src/users.js b/packages/backend-core/src/users.js index 07a60f2884..7f18205c2b 100644 --- a/packages/backend-core/src/users.js +++ b/packages/backend-core/src/users.js @@ -12,8 +12,20 @@ exports.getGlobalUserByEmail = async email => { throw "Must supply an email address to view" } - return queryGlobalView(ViewNames.USER_BY_EMAIL, { + const response = await queryGlobalView(ViewNames.USER_BY_EMAIL, { key: email.toLowerCase(), include_docs: true, }) + + if (response) { + if (Array.isArray(response)) { + for (let user of response) { + delete user.password + } + } else { + delete response.password + } + } + + return response } diff --git a/packages/worker/src/api/controllers/global/auth.ts b/packages/worker/src/api/controllers/global/auth.ts index bbd35f96d0..247a45c0a3 100644 --- a/packages/worker/src/api/controllers/global/auth.ts +++ b/packages/worker/src/api/controllers/global/auth.ts @@ -112,6 +112,7 @@ export const reset = async (ctx: any) => { user, subject: "{{ company }} platform password reset", }) + events.user.passwordResetRequested(user) } } catch (err) { console.log(err) @@ -136,6 +137,9 @@ export const resetUpdate = async (ctx: any) => { ctx.body = { message: "password reset successfully.", } + // remove password from the user before sending events + delete user.password + events.user.passwordReset(user) } catch (err) { ctx.throw(400, "Cannot reset password.") } diff --git a/packages/worker/src/api/controllers/global/self.js b/packages/worker/src/api/controllers/global/self.js index fd615ba795..1c85c511cb 100644 --- a/packages/worker/src/api/controllers/global/self.js +++ b/packages/worker/src/api/controllers/global/self.js @@ -15,6 +15,7 @@ const { encrypt } = require("@budibase/backend-core/encryption") const { newid } = require("@budibase/backend-core/utils") const { users } = require("../../../sdk") const { Cookies } = require("@budibase/backend-core/constants") +const { events } = require("@budibase/backend-core") function newApiKey() { return encrypt(`${getTenantId()}${SEPARATOR}${newid()}`) @@ -110,8 +111,10 @@ exports.getSelf = async ctx => { exports.updateSelf = async ctx => { const db = getGlobalDB() const user = await db.get(ctx.user._id) + let passwordChange = false if (ctx.request.body.password) { // changing password + passwordChange = true ctx.request.body.password = await hash(ctx.request.body.password) // Log all other sessions out apart from the current one await platformLogout({ @@ -134,4 +137,11 @@ exports.updateSelf = async ctx => { _id: response.id, _rev: response.rev, } + + // remove the old password from the user before sending events + delete user.password + events.user.updated(user) + if (passwordChange) { + events.user.passwordUpdated(user) + } } diff --git a/packages/worker/src/api/controllers/global/users.ts b/packages/worker/src/api/controllers/global/users.ts index 9f5bf08155..5f38b659d2 100644 --- a/packages/worker/src/api/controllers/global/users.ts +++ b/packages/worker/src/api/controllers/global/users.ts @@ -8,6 +8,7 @@ const { users: usersCore, tenancy, db: dbUtils, + events, } = require("@budibase/backend-core") export const save = async (ctx: any) => { @@ -121,6 +122,7 @@ export const invite = async (ctx: any) => { ctx.body = { message: "Invitation has been sent.", } + events.user.invited(userInfo) } export const inviteAccept = async (ctx: any) => { @@ -128,14 +130,16 @@ export const inviteAccept = async (ctx: any) => { try { // info is an extension of the user object that was stored by global const { email, info }: any = await checkInviteCode(inviteCode) - ctx.body = await tenancy.doInTenant(info.tenantId, () => { - return users.save({ + ctx.body = await tenancy.doInTenant(info.tenantId, async () => { + const user = await users.save({ firstName, lastName, password, email, ...info, }) + events.user.inviteAccepted(user) + return user }) } catch (err: any) { if (err.code === errors.codes.USAGE_LIMIT_EXCEEDED) { diff --git a/packages/worker/src/api/routes/tests/auth.spec.js b/packages/worker/src/api/routes/tests/auth.spec.js index 9426b9f779..1c33ea750a 100644 --- a/packages/worker/src/api/routes/tests/auth.spec.js +++ b/packages/worker/src/api/routes/tests/auth.spec.js @@ -6,7 +6,6 @@ const { events } = require("@budibase/backend-core") const TENANT_ID = structures.TENANT_ID describe("/api/global/auth", () => { - let code beforeAll(async () => { await config.beforeAll() @@ -20,16 +19,7 @@ describe("/api/global/auth", () => { jest.clearAllMocks() }) - it("should logout", async () => { - await request - .post("/api/global/auth/logout") - .set(config.defaultHeaders()) - .expect(200) - expect(events.auth.logout.mock.calls.length).toBe(1) - }) - - it("should be able to generate password reset email", async () => { - // initially configure settings + const requestPasswordReset = async () => { await config.saveSmtpConfig() await config.saveSettingsConfig() await config.createUser() @@ -40,16 +30,36 @@ describe("/api/global/auth", () => { }) .expect("Content-Type", /json/) .expect(200) + const emailCall = sendMailMock.mock.calls[0][0] + const parts = emailCall.html.split(`http://localhost:10000/builder/auth/reset?code=`) + const code = parts[1].split("\"")[0].split("&")[0] + return { code, res } + } + + it("should logout", async () => { + await request + .post("/api/global/auth/logout") + .set(config.defaultHeaders()) + .expect(200) + expect(events.auth.logout.mock.calls.length).toBe(1) + }) + + it("should be able to generate password reset email", async () => { + const { res, code } = await requestPasswordReset() + const user = await config.getUser("test@test.com") + expect(res.body).toEqual({ message: "Please check your email for a reset link." }) expect(sendMailMock).toHaveBeenCalled() - const emailCall = sendMailMock.mock.calls[0][0] - // after this URL there should be a code - const parts = emailCall.html.split(`http://localhost:10000/builder/auth/reset?code=`) - code = parts[1].split("\"")[0].split("&")[0] + expect(code).toBeDefined() + expect(events.user.passwordResetRequested).toBeCalledTimes(1) + expect(events.user.passwordResetRequested).toBeCalledWith(user) }) it("should allow resetting user password with code", async () => { + const { code } = await requestPasswordReset() + const user = await config.getUser("test@test.com") + const res = await request .post(`/api/global/auth/${TENANT_ID}/reset/update`) .send({ @@ -59,6 +69,8 @@ describe("/api/global/auth", () => { .expect("Content-Type", /json/) .expect(200) expect(res.body).toEqual({ message: "password reset successfully." }) + expect(events.user.passwordReset).toBeCalledTimes(1) + expect(events.user.passwordReset).toBeCalledWith(user) }) describe("oidc", () => { diff --git a/packages/worker/src/api/routes/tests/self.spec.js b/packages/worker/src/api/routes/tests/self.spec.js new file mode 100644 index 0000000000..471dbff98d --- /dev/null +++ b/packages/worker/src/api/routes/tests/self.spec.js @@ -0,0 +1,58 @@ +jest.mock("nodemailer") +const { config, request } = require("../../../tests") +const { events, utils } = require("@budibase/backend-core") + +describe("/api/global/self", () => { + + beforeAll(async () => { + await config.beforeAll() + }) + + afterAll(async () => { + await config.afterAll() + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + const updateSelf = async (user) => { + const res = await request + .post(`/api/global/self`) + .send(user) + .set(config.defaultHeaders()) + .expect("Content-Type", /json/) + .expect(200) + return res + } + + describe("update", () => { + + it("should update self", async () => { + const user = await config.createUser() + + const res = await updateSelf(user) + + delete user.password + expect(res.body._id).toBe(user._id) + expect(events.user.updated).toBeCalledTimes(1) + expect(events.user.updated).toBeCalledWith(user) + expect(events.user.passwordUpdated).not.toBeCalled() + }) + + it("should update password", async () => { + const user = await config.createUser() + const password = "newPassword" + user.password = password + + const res = await updateSelf(user) + + delete user.password + expect(res.body._id).toBe(user._id) + expect(events.user.updated).toBeCalledTimes(1) + expect(events.user.updated).toBeCalledWith(user) + expect(events.user.passwordUpdated).toBeCalledTimes(1) + expect(events.user.passwordUpdated).toBeCalledWith(user) + }) + }) +}) \ No newline at end of file diff --git a/packages/worker/src/api/routes/tests/users.spec.js b/packages/worker/src/api/routes/tests/users.spec.js index 4d94aa0148..cf4426665b 100644 --- a/packages/worker/src/api/routes/tests/users.spec.js +++ b/packages/worker/src/api/routes/tests/users.spec.js @@ -4,7 +4,6 @@ const sendMailMock = mocks.email.mock() const { events } = require("@budibase/backend-core") describe("/api/global/users", () => { - let code beforeAll(async () => { await config.beforeAll() @@ -14,8 +13,7 @@ describe("/api/global/users", () => { await config.afterAll() }) - it("should be able to generate an invitation", async () => { - // initially configure settings + const sendUserInvite = async () => { await config.saveSmtpConfig() await config.saveSettingsConfig() const res = await request @@ -26,16 +24,27 @@ describe("/api/global/users", () => { .set(config.defaultHeaders()) .expect("Content-Type", /json/) .expect(200) + + const emailCall = sendMailMock.mock.calls[0][0] + // after this URL there should be a code + const parts = emailCall.html.split("http://localhost:10000/builder/invite?code=") + const code = parts[1].split("\"")[0].split("&")[0] + return { code, res } + } + + it("should be able to generate an invitation", async () => { + const { code, res } = await sendUserInvite() + expect(res.body).toEqual({ message: "Invitation has been sent." }) expect(sendMailMock).toHaveBeenCalled() - const emailCall = sendMailMock.mock.calls[0][0] - // after this URL there should be a code - const parts = emailCall.html.split("http://localhost:10000/builder/invite?code=") - code = parts[1].split("\"")[0].split("&")[0] expect(code).toBeDefined() + expect(events.user.invited).toBeCalledTimes(1) + expect(events.user.invited).toBeCalledWith({ tenantId: structures.TENANT_ID }) }) it("should be able to create new user from invite", async () => { + const { code } = await sendUserInvite() + const res = await request .post(`/api/global/users/invite/accept`) .send({ @@ -48,6 +57,8 @@ describe("/api/global/users", () => { const user = await config.getUser("invite@test.com") expect(user).toBeDefined() expect(user._id).toEqual(res.body._id) + expect(events.user.inviteAccepted).toBeCalledTimes(1) + expect(events.user.inviteAccepted).toBeCalledWith(res.body) }) const createUser = async (user) => { @@ -70,7 +81,7 @@ describe("/api/global/users", () => { .send(user) .set(config.defaultHeaders()) .expect("Content-Type", /json/) - // .expect(200) + .expect(200) return res.body } @@ -149,6 +160,23 @@ describe("/api/global/users", () => { expect(events.user.updated).toBeCalledTimes(1) expect(events.user.permissionBuilderAssigned).not.toBeCalled() expect(events.user.permissionAdminAssigned).not.toBeCalled() + expect(events.user.passwordForceReset).not.toBeCalled() + }) + + it("should be able to force reset password", async () => { + let user = structures.users.user({ email: "basic-password-update@test.com" }) + await createUser(user) + jest.clearAllMocks() + + user.forceResetPassword = true + user.password = "tempPassword" + await updateUser(user) + + expect(events.user.created).not.toBeCalled() + expect(events.user.updated).toBeCalledTimes(1) + expect(events.user.permissionBuilderAssigned).not.toBeCalled() + expect(events.user.permissionAdminAssigned).not.toBeCalled() + expect(events.user.passwordForceReset).toBeCalledTimes(1) }) it("should be able to update a basic user to an admin user", async () => { diff --git a/packages/worker/src/sdk/users/events.ts b/packages/worker/src/sdk/users/events.ts index adebfbff46..98fb68efda 100644 --- a/packages/worker/src/sdk/users/events.ts +++ b/packages/worker/src/sdk/users/events.ts @@ -52,6 +52,14 @@ export const handleSaveEvents = (user: any, existingUser: any) => { if (isRemovingAdmin(user, existingUser)) { events.user.permissionAdminRemoved(user) } + + if ( + !existingUser.forceResetPassword && + user.forceResetPassword && + user.password + ) { + events.user.passwordForceReset(user) + } } else { events.user.created(user) } diff --git a/packages/worker/src/sdk/users/users.ts b/packages/worker/src/sdk/users/users.ts index dbaee8baaa..bf871b732b 100644 --- a/packages/worker/src/sdk/users/users.ts +++ b/packages/worker/src/sdk/users/users.ts @@ -1,7 +1,6 @@ import env from "../../environment" import { quotas } from "@budibase/pro" import * as apps from "../../utilities/appService" -const { events } = require("@budibase/backend-core") import * as eventHelpers from "./events" const { @@ -125,7 +124,7 @@ export const save = async ( const putUserFn = () => { return db.put(user) } - if (await eventHelpers.isAddingBuilder(user, dbUser)) { + if (eventHelpers.isAddingBuilder(user, dbUser)) { response = await quotas.addDeveloper(putUserFn) } else { response = await putUserFn()