Finalising the usage of redis in the password reset and invitation systems.

This commit is contained in:
mike12345567 2021-05-05 15:10:28 +01:00
parent f781e8b350
commit f1650105f4
8 changed files with 133 additions and 23 deletions

View File

@ -105,6 +105,12 @@ exports.isClient = ctx => {
return ctx.headers["x-budibase-type"] === "client" 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<object|null>}
*/
exports.getGlobalUserByEmail = async email => { exports.getGlobalUserByEmail = async email => {
const db = getDB(StaticDatabases.GLOBAL.name) const db = getDB(StaticDatabases.GLOBAL.name)
try { try {

View File

@ -1,11 +1,12 @@
const authPkg = require("@budibase/auth") const authPkg = require("@budibase/auth")
const { google } = require("@budibase/auth/src/middleware") const { google } = require("@budibase/auth/src/middleware")
const { Configs } = require("../../../constants") const { Configs, EmailTemplatePurpose } = require("../../../constants")
const CouchDB = require("../../../db") const CouchDB = require("../../../db")
const { sendEmail, isEmailConfigured } = require("../../../utilities/email") const { sendEmail, isEmailConfigured } = require("../../../utilities/email")
const { clearCookie, getGlobalUserByEmail } = authPkg.utils const { clearCookie, getGlobalUserByEmail, hash } = authPkg.utils
const { Cookies } = authPkg.constants const { Cookies } = authPkg.constants
const { passport } = authPkg.auth const { passport } = authPkg.auth
const { checkResetPasswordCode } = require("../../../utilities/redis")
const GLOBAL_DB = authPkg.StaticDatabases.GLOBAL.name const GLOBAL_DB = authPkg.StaticDatabases.GLOBAL.name
@ -50,18 +51,36 @@ exports.reset = async ctx => {
} }
try { try {
const user = await getGlobalUserByEmail(email) const user = await getGlobalUserByEmail(email)
sendEmail() await sendEmail(email, EmailTemplatePurpose.PASSWORD_RECOVERY, { user })
} catch (err) { } catch (err) {
// don't throw any kind of error to the user, this might give away something // don't throw any kind of error to the user, this might give away something
} }
ctx.body = { 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 => { exports.logout = async ctx => {
clearCookie(ctx, Cookies.Auth) clearCookie(ctx, Cookies.Auth)
ctx.body = { message: "User logged out" } ctx.body = { message: "User logged out." }
} }
/** /**

View File

@ -6,6 +6,7 @@ const {
} = require("@budibase/auth").db } = require("@budibase/auth").db
const { hash, getGlobalUserByEmail } = require("@budibase/auth").utils const { hash, getGlobalUserByEmail } = require("@budibase/auth").utils
const { UserStatus } = require("../../../constants") const { UserStatus } = require("../../../constants")
const { checkResetPasswordCode, checkInviteCode } = require("../../../utilities/redis")
const FIRST_USER_EMAIL = "test@test.com" const FIRST_USER_EMAIL = "test@test.com"
const FIRST_USER_PASSWORD = "test" const FIRST_USER_PASSWORD = "test"
@ -42,7 +43,7 @@ exports.save = async ctx => {
user.status = UserStatus.ACTIVE user.status = UserStatus.ACTIVE
} }
try { try {
const response = await db.post({ const response = await db.put({
password: hashedPassword, password: hashedPassword,
...user, ...user,
}) })
@ -121,3 +122,20 @@ exports.find = async ctx => {
} }
ctx.body = user 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)
}

View File

@ -4,11 +4,15 @@ const zlib = require("zlib")
const { routes } = require("./routes") const { routes } = require("./routes")
const { buildAuthMiddleware } = require("@budibase/auth").auth const { buildAuthMiddleware } = require("@budibase/auth").auth
const NO_AUTH_ENDPOINTS = [ const PUBLIC_ENDPOINTS = [
{ {
route: "/api/admin/users/first", route: "/api/admin/users/first",
method: "POST", method: "POST",
}, },
{
route: "/api/admin/users/invite/accept",
method: "POST",
},
{ {
route: "/api/admin/auth", route: "/api/admin/auth",
method: "POST", method: "POST",
@ -21,10 +25,13 @@ const NO_AUTH_ENDPOINTS = [
route: "/api/admin/auth/google/callback", route: "/api/admin/auth/google/callback",
method: "GET", method: "GET",
}, },
{
route: "/api/admin/auth/reset",
method: "POST",
},
] ]
const router = new Router() const router = new Router()
router router
.use( .use(
compress({ compress({
@ -39,7 +46,7 @@ router
}) })
) )
.use("/health", ctx => (ctx.status = 200)) .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) // for now no public access is allowed to worker (bar health check)
.use((ctx, next) => { .use((ctx, next) => {
if (!ctx.isAuthenticated) { if (!ctx.isAuthenticated) {

View File

@ -20,9 +20,18 @@ function buildResetValidation() {
}).required().unknown(false)) }).required().unknown(false))
} }
function buildResetUpdateValidation() {
// prettier-ignore
return joiValidator.body(Joi.object({
resetCode: Joi.string().required(),
password: Joi.string().required(),
}).required().unknown(false))
}
router router
.post("/api/admin/auth", buildAuthValidation(), authController.authenticate) .post("/api/admin/auth", buildAuthValidation(), authController.authenticate)
.post("/api/admin/auth/reset", buildResetValidation(), authController.reset) .post("/api/admin/auth/reset", buildResetValidation(), authController.reset)
.post("/api/admin/auth/reset/update", buildResetUpdateValidation(), authController.resetUpdate)
.post("/api/admin/auth/logout", authController.logout) .post("/api/admin/auth/logout", authController.logout)
.get("/api/admin/auth/google", authController.googlePreAuth) .get("/api/admin/auth/google", authController.googlePreAuth)
.get("/api/admin/auth/google/callback", authController.googleAuth) .get("/api/admin/auth/google/callback", authController.googleAuth)

View File

@ -21,7 +21,22 @@ function buildUserSaveValidation() {
.pattern(/.*/, Joi.string()) .pattern(/.*/, Joi.string())
.required() .required()
.unknown(true) .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 router
@ -30,5 +45,7 @@ router
.post("/api/admin/users/first", controller.firstUser) .post("/api/admin/users/first", controller.firstUser)
.delete("/api/admin/users/:id", controller.destroy) .delete("/api/admin/users/:id", controller.destroy)
.get("/api/admin/users/:id", controller.find) .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 module.exports = router

View File

@ -116,8 +116,8 @@ exports.isEmailConfigured = async (groupId = null) => {
* send an email using it. * send an email using it.
* @param {string} email The email address to send to. * @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} 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 {string|undefined} 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 {object|undefined} user if sending to an existing user the object can be provided, this is used in the context.
* @return {Promise<object>} returns details about the attempt to send email, e.g. if it is successful; based on * @return {Promise<object>} returns details about the attempt to send email, e.g. if it is successful; based on
* nodemailer response. * nodemailer response.
*/ */
@ -125,7 +125,7 @@ exports.sendEmail = async (email, purpose, { groupId, user }) => {
const db = new CouchDB(GLOBAL_DB) const db = new CouchDB(GLOBAL_DB)
const config = await getSmtpConfiguration(db, groupId) const config = await getSmtpConfiguration(db, groupId)
if (!config) { if (!config) {
throw "Unable to find SMTP configuration" throw "Unable to find SMTP configuration."
} }
const transport = createSMTPTransport(config) const transport = createSMTPTransport(config)
const message = { const message = {

View File

@ -1,7 +1,16 @@
const { Client, utils } = require("@budibase/auth").redis const { Client, utils } = require("@budibase/auth").redis
const { newid } = require("@budibase/auth").utils 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) { async function getClient(db) {
return await new Client(db).init() return await new Client(db).init()
@ -10,11 +19,24 @@ async function getClient(db) {
async function writeACode(db, value) { async function writeACode(db, value) {
const client = await getClient(db) const client = await getClient(db)
const code = newid() const code = newid()
await client.store(code, value, EXPIRE_TOKEN_SECONDS) await client.store(code, value, getExpirySecondsForDB(db))
client.finish() client.finish()
return code 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. * 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). * 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. * 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 {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<string>} returns the user ID if it is found * @return {Promise<string>} returns the user ID if it is found
*/ */
exports.checkResetPasswordCode = async (resetCode, deleteCode = false) => { exports.checkResetPasswordCode = async (resetCode, deleteCode = true) => {
const client = await getClient(utils.Databases.PW_RESETS) try {
const userId = await client.get(resetCode) return getACode(utils.Databases.PW_RESETS, resetCode, deleteCode)
if (deleteCode) { } catch (err) {
await client.delete(resetCode) 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 => { exports.getInviteCode = async email => {
return writeACode(utils.Databases.INVITATIONS, 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<string>} 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."
}
}