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"
}
/**
* 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 => {
const db = getDB(StaticDatabases.GLOBAL.name)
try {

View File

@ -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." }
}
/**

View File

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

View File

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

View File

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

View File

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

View File

@ -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<object>} 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 = {

View File

@ -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<string>} 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<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."
}
}