From f1650105f4a59d05bc6855109108aaa47d96d3b8 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Wed, 5 May 2021 15:10:28 +0100 Subject: [PATCH] Finalising the usage of redis in the password reset and invitation systems. --- packages/auth/src/utils.js | 6 +++ .../worker/src/api/controllers/admin/auth.js | 29 ++++++++-- .../worker/src/api/controllers/admin/users.js | 20 ++++++- packages/worker/src/api/index.js | 13 +++-- packages/worker/src/api/routes/admin/auth.js | 9 ++++ packages/worker/src/api/routes/admin/users.js | 19 ++++++- packages/worker/src/utilities/email.js | 6 +-- packages/worker/src/utilities/redis.js | 54 +++++++++++++++---- 8 files changed, 133 insertions(+), 23 deletions(-) diff --git a/packages/auth/src/utils.js b/packages/auth/src/utils.js index 10507410b1..a0ba0d25b5 100644 --- a/packages/auth/src/utils.js +++ b/packages/auth/src/utils.js @@ -105,6 +105,12 @@ exports.isClient = ctx => { return ctx.headers["x-budibase-type"] === "client" } +/** + * Given an email address this will use a view to search through + * all the users to find one with this email address. + * @param {string} email the email to lookup the user by. + * @return {Promise} + */ exports.getGlobalUserByEmail = async email => { const db = getDB(StaticDatabases.GLOBAL.name) try { diff --git a/packages/worker/src/api/controllers/admin/auth.js b/packages/worker/src/api/controllers/admin/auth.js index 6318e523bf..c565974d28 100644 --- a/packages/worker/src/api/controllers/admin/auth.js +++ b/packages/worker/src/api/controllers/admin/auth.js @@ -1,11 +1,12 @@ const authPkg = require("@budibase/auth") const { google } = require("@budibase/auth/src/middleware") -const { Configs } = require("../../../constants") +const { Configs, EmailTemplatePurpose } = require("../../../constants") const CouchDB = require("../../../db") const { sendEmail, isEmailConfigured } = require("../../../utilities/email") -const { clearCookie, getGlobalUserByEmail } = authPkg.utils +const { clearCookie, getGlobalUserByEmail, hash } = authPkg.utils const { Cookies } = authPkg.constants const { passport } = authPkg.auth +const { checkResetPasswordCode } = require("../../../utilities/redis") const GLOBAL_DB = authPkg.StaticDatabases.GLOBAL.name @@ -50,18 +51,36 @@ exports.reset = async ctx => { } try { const user = await getGlobalUserByEmail(email) - sendEmail() + await sendEmail(email, EmailTemplatePurpose.PASSWORD_RECOVERY, { user }) } catch (err) { // don't throw any kind of error to the user, this might give away something } ctx.body = { - message: "If user exists an email has been sent.", + message: "Please check your email for a reset link.", + } +} + +/** + * Perform the user password update if the provided reset code is valid. + */ +exports.resetUpdate = async ctx => { + const { resetCode, password } = ctx.request.body + const userId = await checkResetPasswordCode(resetCode) + if (!userId) { + throw "Cannot reset password." + } + const db = new CouchDB(GLOBAL_DB) + const user = await db.get(userId) + user.password = await hash(password) + await db.put(user) + ctx.body = { + message: "password reset successfully.", } } exports.logout = async ctx => { clearCookie(ctx, Cookies.Auth) - ctx.body = { message: "User logged out" } + ctx.body = { message: "User logged out." } } /** diff --git a/packages/worker/src/api/controllers/admin/users.js b/packages/worker/src/api/controllers/admin/users.js index e9e6b64ac0..d141ca88e9 100644 --- a/packages/worker/src/api/controllers/admin/users.js +++ b/packages/worker/src/api/controllers/admin/users.js @@ -6,6 +6,7 @@ const { } = require("@budibase/auth").db const { hash, getGlobalUserByEmail } = require("@budibase/auth").utils const { UserStatus } = require("../../../constants") +const { checkResetPasswordCode, checkInviteCode } = require("../../../utilities/redis") const FIRST_USER_EMAIL = "test@test.com" const FIRST_USER_PASSWORD = "test" @@ -42,7 +43,7 @@ exports.save = async ctx => { user.status = UserStatus.ACTIVE } try { - const response = await db.post({ + const response = await db.put({ password: hashedPassword, ...user, }) @@ -121,3 +122,20 @@ exports.find = async ctx => { } ctx.body = user } + +exports.invite = async ctx => { + +} + +exports.inviteAccept = async ctx => { + const { inviteCode } = ctx.request.body + const email = await checkInviteCode(inviteCode) + if (!email) { + throw "Unable to create new user, invitation invalid." + } + // redirect the request + delete ctx.request.body.inviteCode + ctx.request.body.email = email + // this will flesh out the body response + await exports.save(ctx) +} diff --git a/packages/worker/src/api/index.js b/packages/worker/src/api/index.js index c4877beabf..eb87c0e2fa 100644 --- a/packages/worker/src/api/index.js +++ b/packages/worker/src/api/index.js @@ -4,11 +4,15 @@ const zlib = require("zlib") const { routes } = require("./routes") const { buildAuthMiddleware } = require("@budibase/auth").auth -const NO_AUTH_ENDPOINTS = [ +const PUBLIC_ENDPOINTS = [ { route: "/api/admin/users/first", method: "POST", }, + { + route: "/api/admin/users/invite/accept", + method: "POST", + }, { route: "/api/admin/auth", method: "POST", @@ -21,10 +25,13 @@ const NO_AUTH_ENDPOINTS = [ route: "/api/admin/auth/google/callback", method: "GET", }, + { + route: "/api/admin/auth/reset", + method: "POST", + }, ] const router = new Router() - router .use( compress({ @@ -39,7 +46,7 @@ router }) ) .use("/health", ctx => (ctx.status = 200)) - .use(buildAuthMiddleware(NO_AUTH_ENDPOINTS)) + .use(buildAuthMiddleware(PUBLIC_ENDPOINTS)) // for now no public access is allowed to worker (bar health check) .use((ctx, next) => { if (!ctx.isAuthenticated) { diff --git a/packages/worker/src/api/routes/admin/auth.js b/packages/worker/src/api/routes/admin/auth.js index 60d2f4ae7d..8f109417ff 100644 --- a/packages/worker/src/api/routes/admin/auth.js +++ b/packages/worker/src/api/routes/admin/auth.js @@ -20,9 +20,18 @@ function buildResetValidation() { }).required().unknown(false)) } +function buildResetUpdateValidation() { + // prettier-ignore + return joiValidator.body(Joi.object({ + resetCode: Joi.string().required(), + password: Joi.string().required(), + }).required().unknown(false)) +} + router .post("/api/admin/auth", buildAuthValidation(), authController.authenticate) .post("/api/admin/auth/reset", buildResetValidation(), authController.reset) + .post("/api/admin/auth/reset/update", buildResetUpdateValidation(), authController.resetUpdate) .post("/api/admin/auth/logout", authController.logout) .get("/api/admin/auth/google", authController.googlePreAuth) .get("/api/admin/auth/google/callback", authController.googleAuth) diff --git a/packages/worker/src/api/routes/admin/users.js b/packages/worker/src/api/routes/admin/users.js index d4bc4b6b62..a5e2d9f87e 100644 --- a/packages/worker/src/api/routes/admin/users.js +++ b/packages/worker/src/api/routes/admin/users.js @@ -21,7 +21,22 @@ function buildUserSaveValidation() { .pattern(/.*/, Joi.string()) .required() .unknown(true) - }).required().unknown(true).optional()) + }).required().unknown(true)) +} + +function buildInviteValidation() { + // prettier-ignore + return joiValidator.body(Joi.object({ + email: Joi.string().required(), + }).required()) +} + +function buildInviteAcceptValidation() { + // prettier-ignore + return joiValidator.body(Joi.object({ + inviteCode: Joi.string().required(), + password: Joi.string().required(), + }).required().unknown(true)) } router @@ -30,5 +45,7 @@ router .post("/api/admin/users/first", controller.firstUser) .delete("/api/admin/users/:id", controller.destroy) .get("/api/admin/users/:id", controller.find) + .post("/api/admin/users/invite", buildInviteValidation(), controller.invite) + .post("/api/admin/users/invite/accept", buildInviteAcceptValidation(), controller.inviteAccept) module.exports = router diff --git a/packages/worker/src/utilities/email.js b/packages/worker/src/utilities/email.js index 524d305899..745abdf7a2 100644 --- a/packages/worker/src/utilities/email.js +++ b/packages/worker/src/utilities/email.js @@ -116,8 +116,8 @@ exports.isEmailConfigured = async (groupId = null) => { * 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. + * @param {string|undefined} groupId If finer grain controls being used then this will lookup config for group. + * @param {object|undefined} user if sending to an existing user the object can be provided, this is used in the context. * @return {Promise} returns details about the attempt to send email, e.g. if it is successful; based on * nodemailer response. */ @@ -125,7 +125,7 @@ exports.sendEmail = async (email, purpose, { groupId, user }) => { const db = new CouchDB(GLOBAL_DB) const config = await getSmtpConfiguration(db, groupId) if (!config) { - throw "Unable to find SMTP configuration" + throw "Unable to find SMTP configuration." } const transport = createSMTPTransport(config) const message = { diff --git a/packages/worker/src/utilities/redis.js b/packages/worker/src/utilities/redis.js index c78060df52..73ef8a0d43 100644 --- a/packages/worker/src/utilities/redis.js +++ b/packages/worker/src/utilities/redis.js @@ -1,7 +1,16 @@ const { Client, utils } = require("@budibase/auth").redis const { newid } = require("@budibase/auth").utils -const EXPIRE_TOKEN_SECONDS = 3600 +function getExpirySecondsForDB(db) { + switch (db) { + case utils.Databases.PW_RESETS: + // a hour + return 3600 + case utils.Databases.INVITATIONS: + // a day + return 86400 + } +} async function getClient(db) { return await new Client(db).init() @@ -10,11 +19,24 @@ async function getClient(db) { async function writeACode(db, value) { const client = await getClient(db) const code = newid() - await client.store(code, value, EXPIRE_TOKEN_SECONDS) + await client.store(code, value, getExpirySecondsForDB(db)) client.finish() return code } +async function getACode(db, code, deleteCode = true) { + const client = await getClient(db) + const value = await client.get(code) + if (!value) { + throw "Invalid code." + } + if (deleteCode) { + await client.delete(code) + } + client.finish() + 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). @@ -28,17 +50,15 @@ exports.getResetPasswordCode = async 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. + * @param {boolean} deleteCode If the code is used/finished with this will delete it - defaults to true. * @return {Promise} 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) +exports.checkResetPasswordCode = async (resetCode, deleteCode = true) => { + try { + return getACode(utils.Databases.PW_RESETS, resetCode, deleteCode) + } catch (err) { + throw "Provided information is not valid, cannot reset password - please try again." } - client.finish() - return userId } /** @@ -49,3 +69,17 @@ exports.checkResetPasswordCode = async (resetCode, deleteCode = false) => { exports.getInviteCode = async email => { return writeACode(utils.Databases.INVITATIONS, email) } + +/** + * Checks that the provided invite code is valid - will return the email address of user that was invited. + * @param {string} inviteCode the invite code that was provided as part of the link. + * @param {boolean} deleteCode whether or not the code should be deleted after retrieval - defaults to true. + * @return {Promise} If the code is valid then an email address will be returned. + */ +exports.checkInviteCode = async (inviteCode, deleteCode = true) => { + try { + return getACode(utils.Databases.INVITATIONS, inviteCode, deleteCode) + } catch (err) { + throw "Invitation is not valid or has expired, please request a new one." + } +}