User management events

This commit is contained in:
Rory Powell 2022-04-12 12:34:36 +01:00
parent 957e90fe86
commit d99f579bf6
12 changed files with 174 additions and 53 deletions

View File

@ -3,7 +3,6 @@ exports.Events = {
USER_CREATED: "user:created", USER_CREATED: "user:created",
USER_UPDATED: "user:updated", USER_UPDATED: "user:updated",
USER_DELETED: "user:deleted", USER_DELETED: "user:deleted",
USER_PASSWORD_FORCE_RESET: "user:password:force:reset",
// USER / PERMISSIONS // USER / PERMISSIONS
USER_PERMISSION_ADMIN_ASSIGNED: "user:admin:assigned", USER_PERMISSION_ADMIN_ASSIGNED: "user:admin:assigned",
@ -15,9 +14,9 @@ exports.Events = {
USER_INVITED: "user:invited", USER_INVITED: "user:invited",
USER_INVITED_ACCEPTED: "user:invite:accepted", USER_INVITED_ACCEPTED: "user:invite:accepted",
// USER / SELF // USER / PASSWORD
USER_SELF_UPDATED: "user:self:updated", USER_PASSWORD_FORCE_RESET: "user:password:force:reset",
USER_SELF_PASSWORD_UPDATED: "user:self:password:updated", USER_PASSWORD_UPDATED: "user:password:updated",
USER_PASSWORD_RESET_REQUESTED: "user:password:reset:requested", USER_PASSWORD_RESET_REQUESTED: "user:password:reset:requested",
USER_PASSWORD_RESET: "user:password:reset", USER_PASSWORD_RESET: "user:password:reset",

View File

@ -18,12 +18,6 @@ exports.deleted = user => {
events.processEvent(Events.USER_DELETED, properties) events.processEvent(Events.USER_DELETED, properties)
} }
// TODO
exports.passwordForceReset = user => {
const properties = {}
events.processEvent(Events.USER_PASSWORD_FORCE_RESET, properties)
}
// PERMISSIONS // PERMISSIONS
exports.permissionAdminAssigned = user => { exports.permissionAdminAssigned = user => {
@ -48,39 +42,33 @@ exports.permissionBuilderRemoved = user => {
// INVITE // INVITE
// TODO exports.invited = userInfo => {
exports.invited = user => {
const properties = {} const properties = {}
events.processEvent(Events.USER_INVITED, properties) events.processEvent(Events.USER_INVITED, properties)
} }
// TODO
exports.inviteAccepted = user => { exports.inviteAccepted = user => {
const properties = {} const properties = {}
events.processEvent(Events.USER_INVITED_ACCEPTED, properties) events.processEvent(Events.USER_INVITED_ACCEPTED, properties)
} }
// SELF // PASSWORD
// TODO exports.passwordForceReset = user => {
exports.selfUpdated = user => {
const properties = {} const properties = {}
events.processEvent(Events.USER_SELF_UPDATED, properties) events.processEvent(Events.USER_PASSWORD_FORCE_RESET, properties)
} }
// TODO exports.passwordUpdated = user => {
exports.selfPasswordUpdated = user => {
const properties = {} const properties = {}
events.processEvent(Events.USER_SELF_PASSWORD_UPDATED, properties) events.processEvent(Events.USER_PASSWORD_UPDATED, properties)
} }
// TODO
exports.passwordResetRequested = user => { exports.passwordResetRequested = user => {
const properties = {} const properties = {}
events.processEvent(Events.USER_PASSWORD_RESET_REQUESTED, properties) events.processEvent(Events.USER_PASSWORD_RESET_REQUESTED, properties)
} }
// TODO
exports.passwordReset = user => { exports.passwordReset = user => {
const properties = {} const properties = {}
events.processEvent(Events.USER_PASSWORD_RESET, properties) events.processEvent(Events.USER_PASSWORD_RESET, properties)

View File

@ -80,15 +80,14 @@ jest.mock("../../../events", () => {
created: jest.fn(), created: jest.fn(),
updated: jest.fn(), updated: jest.fn(),
deleted: jest.fn(), deleted: jest.fn(),
passwordForceReset: jest.fn(),
permissionAdminAssigned: jest.fn(), permissionAdminAssigned: jest.fn(),
permissionAdminRemoved: jest.fn(), permissionAdminRemoved: jest.fn(),
permissionBuilderAssigned: jest.fn(), permissionBuilderAssigned: jest.fn(),
permissionBuilderRemoved: jest.fn(), permissionBuilderRemoved: jest.fn(),
invited: jest.fn(), invited: jest.fn(),
inviteAccepted: jest.fn(), inviteAccepted: jest.fn(),
selfUpdated: jest.fn(), passwordForceReset: jest.fn(),
selfPasswordUpdated: jest.fn(), passwordUpdated: jest.fn(),
passwordResetRequested: jest.fn(), passwordResetRequested: jest.fn(),
passwordReset: jest.fn(), passwordReset: jest.fn(),
}, },

View File

@ -12,8 +12,20 @@ exports.getGlobalUserByEmail = async email => {
throw "Must supply an email address to view" 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(), key: email.toLowerCase(),
include_docs: true, include_docs: true,
}) })
if (response) {
if (Array.isArray(response)) {
for (let user of response) {
delete user.password
}
} else {
delete response.password
}
}
return response
} }

View File

@ -112,6 +112,7 @@ export const reset = async (ctx: any) => {
user, user,
subject: "{{ company }} platform password reset", subject: "{{ company }} platform password reset",
}) })
events.user.passwordResetRequested(user)
} }
} catch (err) { } catch (err) {
console.log(err) console.log(err)
@ -136,6 +137,9 @@ export const resetUpdate = async (ctx: any) => {
ctx.body = { ctx.body = {
message: "password reset successfully.", message: "password reset successfully.",
} }
// remove password from the user before sending events
delete user.password
events.user.passwordReset(user)
} catch (err) { } catch (err) {
ctx.throw(400, "Cannot reset password.") ctx.throw(400, "Cannot reset password.")
} }

View File

@ -15,6 +15,7 @@ const { encrypt } = require("@budibase/backend-core/encryption")
const { newid } = require("@budibase/backend-core/utils") const { newid } = require("@budibase/backend-core/utils")
const { users } = require("../../../sdk") const { users } = require("../../../sdk")
const { Cookies } = require("@budibase/backend-core/constants") const { Cookies } = require("@budibase/backend-core/constants")
const { events } = require("@budibase/backend-core")
function newApiKey() { function newApiKey() {
return encrypt(`${getTenantId()}${SEPARATOR}${newid()}`) return encrypt(`${getTenantId()}${SEPARATOR}${newid()}`)
@ -110,8 +111,10 @@ exports.getSelf = async ctx => {
exports.updateSelf = async ctx => { exports.updateSelf = async ctx => {
const db = getGlobalDB() const db = getGlobalDB()
const user = await db.get(ctx.user._id) const user = await db.get(ctx.user._id)
let passwordChange = false
if (ctx.request.body.password) { if (ctx.request.body.password) {
// changing password // changing password
passwordChange = true
ctx.request.body.password = await hash(ctx.request.body.password) ctx.request.body.password = await hash(ctx.request.body.password)
// Log all other sessions out apart from the current one // Log all other sessions out apart from the current one
await platformLogout({ await platformLogout({
@ -134,4 +137,11 @@ exports.updateSelf = async ctx => {
_id: response.id, _id: response.id,
_rev: response.rev, _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)
}
} }

View File

@ -8,6 +8,7 @@ const {
users: usersCore, users: usersCore,
tenancy, tenancy,
db: dbUtils, db: dbUtils,
events,
} = require("@budibase/backend-core") } = require("@budibase/backend-core")
export const save = async (ctx: any) => { export const save = async (ctx: any) => {
@ -121,6 +122,7 @@ export const invite = async (ctx: any) => {
ctx.body = { ctx.body = {
message: "Invitation has been sent.", message: "Invitation has been sent.",
} }
events.user.invited(userInfo)
} }
export const inviteAccept = async (ctx: any) => { export const inviteAccept = async (ctx: any) => {
@ -128,14 +130,16 @@ export const inviteAccept = async (ctx: any) => {
try { try {
// info is an extension of the user object that was stored by global // 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 checkInviteCode(inviteCode)
ctx.body = await tenancy.doInTenant(info.tenantId, () => { ctx.body = await tenancy.doInTenant(info.tenantId, async () => {
return users.save({ const user = await users.save({
firstName, firstName,
lastName, lastName,
password, password,
email, email,
...info, ...info,
}) })
events.user.inviteAccepted(user)
return user
}) })
} catch (err: any) { } catch (err: any) {
if (err.code === errors.codes.USAGE_LIMIT_EXCEEDED) { if (err.code === errors.codes.USAGE_LIMIT_EXCEEDED) {

View File

@ -6,7 +6,6 @@ const { events } = require("@budibase/backend-core")
const TENANT_ID = structures.TENANT_ID const TENANT_ID = structures.TENANT_ID
describe("/api/global/auth", () => { describe("/api/global/auth", () => {
let code
beforeAll(async () => { beforeAll(async () => {
await config.beforeAll() await config.beforeAll()
@ -20,16 +19,7 @@ describe("/api/global/auth", () => {
jest.clearAllMocks() jest.clearAllMocks()
}) })
it("should logout", async () => { const requestPasswordReset = 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
await config.saveSmtpConfig() await config.saveSmtpConfig()
await config.saveSettingsConfig() await config.saveSettingsConfig()
await config.createUser() await config.createUser()
@ -40,16 +30,36 @@ describe("/api/global/auth", () => {
}) })
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(200) .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(res.body).toEqual({ message: "Please check your email for a reset link." })
expect(sendMailMock).toHaveBeenCalled() 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(code).toBeDefined()
expect(events.user.passwordResetRequested).toBeCalledTimes(1)
expect(events.user.passwordResetRequested).toBeCalledWith(user)
}) })
it("should allow resetting user password with code", async () => { 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 const res = await request
.post(`/api/global/auth/${TENANT_ID}/reset/update`) .post(`/api/global/auth/${TENANT_ID}/reset/update`)
.send({ .send({
@ -59,6 +69,8 @@ describe("/api/global/auth", () => {
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body).toEqual({ message: "password reset successfully." }) expect(res.body).toEqual({ message: "password reset successfully." })
expect(events.user.passwordReset).toBeCalledTimes(1)
expect(events.user.passwordReset).toBeCalledWith(user)
}) })
describe("oidc", () => { describe("oidc", () => {

View File

@ -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)
})
})
})

View File

@ -4,7 +4,6 @@ const sendMailMock = mocks.email.mock()
const { events } = require("@budibase/backend-core") const { events } = require("@budibase/backend-core")
describe("/api/global/users", () => { describe("/api/global/users", () => {
let code
beforeAll(async () => { beforeAll(async () => {
await config.beforeAll() await config.beforeAll()
@ -14,8 +13,7 @@ describe("/api/global/users", () => {
await config.afterAll() await config.afterAll()
}) })
it("should be able to generate an invitation", async () => { const sendUserInvite = async () => {
// initially configure settings
await config.saveSmtpConfig() await config.saveSmtpConfig()
await config.saveSettingsConfig() await config.saveSettingsConfig()
const res = await request const res = await request
@ -26,16 +24,27 @@ describe("/api/global/users", () => {
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body).toEqual({ message: "Invitation has been sent." })
expect(sendMailMock).toHaveBeenCalled()
const emailCall = sendMailMock.mock.calls[0][0] const emailCall = sendMailMock.mock.calls[0][0]
// after this URL there should be a code // after this URL there should be a code
const parts = emailCall.html.split("http://localhost:10000/builder/invite?code=") const parts = emailCall.html.split("http://localhost:10000/builder/invite?code=")
code = parts[1].split("\"")[0].split("&")[0] 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()
expect(code).toBeDefined() 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 () => { it("should be able to create new user from invite", async () => {
const { code } = await sendUserInvite()
const res = await request const res = await request
.post(`/api/global/users/invite/accept`) .post(`/api/global/users/invite/accept`)
.send({ .send({
@ -48,6 +57,8 @@ describe("/api/global/users", () => {
const user = await config.getUser("invite@test.com") const user = await config.getUser("invite@test.com")
expect(user).toBeDefined() expect(user).toBeDefined()
expect(user._id).toEqual(res.body._id) expect(user._id).toEqual(res.body._id)
expect(events.user.inviteAccepted).toBeCalledTimes(1)
expect(events.user.inviteAccepted).toBeCalledWith(res.body)
}) })
const createUser = async (user) => { const createUser = async (user) => {
@ -70,7 +81,7 @@ describe("/api/global/users", () => {
.send(user) .send(user)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
// .expect(200) .expect(200)
return res.body return res.body
} }
@ -149,6 +160,23 @@ describe("/api/global/users", () => {
expect(events.user.updated).toBeCalledTimes(1) expect(events.user.updated).toBeCalledTimes(1)
expect(events.user.permissionBuilderAssigned).not.toBeCalled() expect(events.user.permissionBuilderAssigned).not.toBeCalled()
expect(events.user.permissionAdminAssigned).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 () => { it("should be able to update a basic user to an admin user", async () => {

View File

@ -52,6 +52,14 @@ export const handleSaveEvents = (user: any, existingUser: any) => {
if (isRemovingAdmin(user, existingUser)) { if (isRemovingAdmin(user, existingUser)) {
events.user.permissionAdminRemoved(user) events.user.permissionAdminRemoved(user)
} }
if (
!existingUser.forceResetPassword &&
user.forceResetPassword &&
user.password
) {
events.user.passwordForceReset(user)
}
} else { } else {
events.user.created(user) events.user.created(user)
} }

View File

@ -1,7 +1,6 @@
import env from "../../environment" import env from "../../environment"
import { quotas } from "@budibase/pro" import { quotas } from "@budibase/pro"
import * as apps from "../../utilities/appService" import * as apps from "../../utilities/appService"
const { events } = require("@budibase/backend-core")
import * as eventHelpers from "./events" import * as eventHelpers from "./events"
const { const {
@ -125,7 +124,7 @@ export const save = async (
const putUserFn = () => { const putUserFn = () => {
return db.put(user) return db.put(user)
} }
if (await eventHelpers.isAddingBuilder(user, dbUser)) { if (eventHelpers.isAddingBuilder(user, dbUser)) {
response = await quotas.addDeveloper(putUserFn) response = await quotas.addDeveloper(putUserFn)
} else { } else {
response = await putUserFn() response = await putUserFn()