Adding a redis client to the auth system, as part of work towards the reset password flow.

This commit is contained in:
mike12345567 2021-04-27 17:29:05 +01:00
parent e08df4110a
commit 08c158c121
15 changed files with 394 additions and 115 deletions

View File

@ -59,6 +59,7 @@ services:
container_name: budi-redis-dev container_name: budi-redis-dev
restart: always restart: always
image: redis image: redis
command: redis-server --requirepass ${REDIS_PASSWORD}
ports: ports:
- "${REDIS_PORT}:6379" - "${REDIS_PORT}:6379"
volumes: volumes:

View File

@ -23,6 +23,8 @@ services:
LOG_LEVEL: info LOG_LEVEL: info
SENTRY_DSN: https://a34ae347621946bf8acded18e5b7d4b8@o420233.ingest.sentry.io/5338131 SENTRY_DSN: https://a34ae347621946bf8acded18e5b7d4b8@o420233.ingest.sentry.io/5338131
ENABLE_ANALYTICS: "true" ENABLE_ANALYTICS: "true"
REDIS_URL: redis-service:6379
REDIS_PASSWORD: ${REDIS_PASSWORD}
depends_on: depends_on:
- worker-service - worker-service
@ -43,6 +45,8 @@ services:
COUCH_DB_PASSWORD: ${COUCH_DB_PASSWORD} COUCH_DB_PASSWORD: ${COUCH_DB_PASSWORD}
COUCH_DB_URL: http://${COUCH_DB_USER}:${COUCH_DB_PASSWORD}@couchdb-service:5984 COUCH_DB_URL: http://${COUCH_DB_USER}:${COUCH_DB_PASSWORD}@couchdb-service:5984
SELF_HOST_KEY: ${HOSTING_KEY} SELF_HOST_KEY: ${HOSTING_KEY}
REDIS_URL: redis-service:6379
REDIS_PASSWORD: ${REDIS_PASSWORD}
depends_on: depends_on:
- minio-service - minio-service
- couch-init - couch-init
@ -100,6 +104,7 @@ services:
redis-service: redis-service:
restart: always restart: always
image: redis image: redis
command: redis-server --requirepass ${REDIS_PASSWORD}
ports: ports:
- "${REDIS_PORT}:6379" - "${REDIS_PORT}:6379"
volumes: volumes:

View File

@ -12,6 +12,7 @@ MINIO_ACCESS_KEY=budibase
MINIO_SECRET_KEY=budibase MINIO_SECRET_KEY=budibase
COUCH_DB_PASSWORD=budibase COUCH_DB_PASSWORD=budibase
COUCH_DB_USER=budibase COUCH_DB_USER=budibase
REDIS_PASSWORD=budibase
# This section contains variables that do not need to be altered under normal circumstances # This section contains variables that do not need to be altered under normal circumstances
APP_PORT=4002 APP_PORT=4002

View File

@ -7,6 +7,7 @@
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"ioredis": "^4.27.1",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"koa-passport": "^4.1.4", "koa-passport": "^4.1.4",
"passport-google-auth": "^1.0.2", "passport-google-auth": "^1.0.2",

View File

@ -2,4 +2,6 @@ module.exports = {
JWT_SECRET: process.env.JWT_SECRET, JWT_SECRET: process.env.JWT_SECRET,
COUCH_DB_URL: process.env.COUCH_DB_URL, COUCH_DB_URL: process.env.COUCH_DB_URL,
SALT_ROUNDS: process.env.SALT_ROUNDS, SALT_ROUNDS: process.env.SALT_ROUNDS,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
} }

View File

@ -0,0 +1,140 @@
const Redis = require("ioredis")
const env = require("../environment")
const { addDbPrefix, removeDbPrefix } = require("./utils")
const CONNECT_TIMEOUT_MS = 10000
const SLOT_REFRESH_MS = 2000
const CLUSTERED = false
let CLIENT
/**
* Inits the system, will error if unable to connect to redis cluster (may take up to 10 seconds) otherwise
* will return the ioredis client which will be ready to use.
* @return {Promise<object>} The ioredis client.
*/
function init() {
return new Promise((resolve, reject) => {
const [ host, port ] = env.REDIS_URL.split(":")
const opts = {
connectTimeout: CONNECT_TIMEOUT_MS
}
if (CLUSTERED) {
opts.redisOptions = {}
opts.redisOptions.tls = {}
opts.redisOptions.password = env.REDIS_PASSWORD
opts.slotsRefreshTimeout = SLOT_REFRESH_MS
opts.dnsLookup = (address, callback) => callback(null, address)
CLIENT = new Redis.Cluster([ { port, host } ])
} else {
opts.password = env.REDIS_PASSWORD
opts.port = port
opts.host = host
CLIENT = new Redis(opts)
}
CLIENT.on("end", err => {
reject(err)
})
CLIENT.on("error", err => {
reject(err)
})
CLIENT.on("connect", () => {
resolve(CLIENT)
})
})
}
/**
* Utility function, takes a redis stream and converts it to a promisified response -
* this can only be done with redis streams because they will have an end.
* @param stream A redis stream, specifically as this type of stream will have an end.
* @return {Promise<object>} The final output of the stream
*/
function promisifyStream(stream) {
return new Promise((resolve, reject) => {
const outputKeys = new Set()
stream.on("data", keys => {
keys.forEach(key => {
outputKeys.add(key)
})
})
stream.on("error", (err) => {
reject(err)
})
stream.on("end", async () => {
const keysArray = Array.from(outputKeys)
try {
let getPromises = []
for (let key of keysArray) {
getPromises.push(CLIENT.get(key))
}
const jsonArray = await Promise.all(getPromises)
resolve(keysArray.map(key => ({
key: removeDbPrefix(key),
value: JSON.parse(jsonArray.shift()),
})))
} catch (err) {
reject(err)
}
})
})
}
class RedisWrapper {
constructor(db) {
this._db = db
}
async init() {
this._client = await init()
return this
}
async scan() {
const db = this._db, client = this._client
let stream
if (CLUSTERED) {
let node = client.nodes("master")
stream = node[0].scanStream({match: db + "-*", count: 100})
} else {
stream = client.scanStream({match: db + "-*", count: 100})
}
return promisifyStream(stream)
}
async get(key) {
const db = this._db, client = this._client
let response = await client.get(addDbPrefix(db, key))
// overwrite the prefixed key
if (response != null && response.key) {
response.key = key
}
return JSON.parse(response)
}
async store(key, value, expirySeconds = null) {
const db = this._db, client = this._client
if (typeof(value) === "object") {
value = JSON.stringify(value)
}
const prefixedKey = addDbPrefix(db, key)
await client.set(prefixedKey, value)
if (expirySeconds) {
await client.expire(prefixedKey, expirySeconds)
}
}
async delete(key) {
const db = this._db, client = this._client
await client.del(addDbPrefix(db, key))
}
async clear() {
const db = this._db
let items = await this.scan(db)
await Promise.all(items.map(obj => this.delete(db, obj.key)))
}
}
module.exports = RedisWrapper

View File

@ -0,0 +1,20 @@
const SEPARATOR = "-"
exports.Databases = {
PW_RESETS: "pwReset",
}
exports.addDbPrefix = (db, key) => {
return `${db}${SEPARATOR}${key}`
}
exports.removeDbPrefix = key => {
let parts = key.split(SEPARATOR)
if (parts.length >= 2) {
parts.shift()
return parts.join(SEPARATOR)
} else {
// return the only part
return parts[0]
}
}

View File

@ -73,6 +73,11 @@ caseless@~0.12.0:
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
cluster-key-slot@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz#30474b2a981fb12172695833052bc0d01336d10d"
integrity sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw==
combined-stream@^1.0.6, combined-stream@~1.0.6: combined-stream@^1.0.6, combined-stream@~1.0.6:
version "1.0.8" version "1.0.8"
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
@ -92,11 +97,23 @@ dashdash@^1.12.0:
dependencies: dependencies:
assert-plus "^1.0.0" assert-plus "^1.0.0"
debug@^4.3.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee"
integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==
dependencies:
ms "2.1.2"
delayed-stream@~1.0.0: delayed-stream@~1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
denque@^1.1.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/denque/-/denque-1.5.0.tgz#773de0686ff2d8ec2ff92914316a47b73b1c73de"
integrity sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ==
ecc-jsbn@~0.1.1: ecc-jsbn@~0.1.1:
version "0.1.2" version "0.1.2"
resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
@ -216,6 +233,22 @@ http-signature@~1.2.0:
jsprim "^1.2.2" jsprim "^1.2.2"
sshpk "^1.7.0" sshpk "^1.7.0"
ioredis@^4.27.1:
version "4.27.1"
resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-4.27.1.tgz#4ef947b455a1b995baa4b0d7e2c4e4f75f746421"
integrity sha512-PaFNFeBbOcEYHXAdrJuy7uesJcyvzStTM1aYMchTuky+VgKqDbXhnTJHaDsjAwcTwPx8Asatx+l2DW8zZ2xlsQ==
dependencies:
cluster-key-slot "^1.1.0"
debug "^4.3.1"
denque "^1.1.0"
lodash.defaults "^4.2.0"
lodash.flatten "^4.4.0"
p-map "^2.1.0"
redis-commands "1.7.0"
redis-errors "^1.2.0"
redis-parser "^3.0.0"
standard-as-callback "^2.1.0"
is-typedarray@~1.0.0: is-typedarray@~1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
@ -296,6 +329,16 @@ koa-passport@^4.1.4:
dependencies: dependencies:
passport "^0.4.0" passport "^0.4.0"
lodash.defaults@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=
lodash.flatten@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f"
integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=
lodash.includes@^4.3.0: lodash.includes@^4.3.0:
version "4.3.0" version "4.3.0"
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
@ -358,6 +401,11 @@ mime@^1.4.1:
resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
ms@2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
ms@^2.1.1: ms@^2.1.1:
version "2.1.3" version "2.1.3"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
@ -378,6 +426,11 @@ oauth@0.9.x:
resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1" resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1"
integrity sha1-vR/vr2hslrdUda7VGWQS/2DPucE= integrity sha1-vR/vr2hslrdUda7VGWQS/2DPucE=
p-map@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175"
integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==
passport-google-auth@^1.0.2: passport-google-auth@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/passport-google-auth/-/passport-google-auth-1.0.2.tgz#8b300b5aa442ef433de1d832ed3112877d0b2938" resolved "https://registry.yarnpkg.com/passport-google-auth/-/passport-google-auth-1.0.2.tgz#8b300b5aa442ef433de1d832ed3112877d0b2938"
@ -481,6 +534,23 @@ qs@~6.5.2:
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
redis-commands@1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.7.0.tgz#15a6fea2d58281e27b1cd1acfb4b293e278c3a89"
integrity sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==
redis-errors@^1.0.0, redis-errors@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad"
integrity sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=
redis-parser@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4"
integrity sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=
dependencies:
redis-errors "^1.0.0"
request@^2.72.0, request@^2.74.0: request@^2.72.0, request@^2.74.0:
version "2.88.2" version "2.88.2"
resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
@ -537,6 +607,11 @@ sshpk@^1.7.0:
safer-buffer "^2.0.2" safer-buffer "^2.0.2"
tweetnacl "~0.14.0" tweetnacl "~0.14.0"
standard-as-callback@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45"
integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==
string-template@~1.0.0: string-template@~1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/string-template/-/string-template-1.0.0.tgz#9e9f2233dc00f218718ec379a28a5673ecca8b96" resolved "https://registry.yarnpkg.com/string-template/-/string-template-1.0.0.tgz#9e9f2233dc00f218718ec379a28a5673ecca8b96"

View File

@ -20,6 +20,7 @@ MINIO_ACCESS_KEY=${randomString.generate()}
MINIO_SECRET_KEY=${randomString.generate()} MINIO_SECRET_KEY=${randomString.generate()}
COUCH_DB_PASSWORD=${randomString.generate()} COUCH_DB_PASSWORD=${randomString.generate()}
COUCH_DB_USER=${randomString.generate()} COUCH_DB_USER=${randomString.generate()}
REDIS_PASSWORD=${randomString.generate()}
# This section contains variables that do not need to be altered under normal circumstances # This section contains variables that do not need to be altered under normal circumstances
APP_PORT=4002 APP_PORT=4002

View File

@ -32,6 +32,8 @@ module.exports = {
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY, MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY, MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY,
USE_QUOTAS: process.env.USE_QUOTAS, USE_QUOTAS: process.env.USE_QUOTAS,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
// environment // environment
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,
JEST_WORKER_ID: process.env.JEST_WORKER_ID, JEST_WORKER_ID: process.env.JEST_WORKER_ID,

View File

@ -1,82 +1,8 @@
const CouchDB = require("../../../db") const { sendEmail } = require("../../../utilities/email")
const { StaticDatabases, determineScopedConfig } = require("@budibase/auth").db
const {
EmailTemplatePurpose,
TemplateTypes,
Configs,
} = require("../../../constants")
const { getTemplateByPurpose } = require("../../../constants/templates")
const { getSettingsTemplateContext } = require("../../../utilities/templates")
const { processString } = require("@budibase/string-templates")
const { createSMTPTransport } = require("../../../utilities/email")
const GLOBAL_DB = StaticDatabases.GLOBAL.name
const TYPE = TemplateTypes.EMAIL
const FULL_EMAIL_PURPOSES = [
EmailTemplatePurpose.INVITATION,
EmailTemplatePurpose.PASSWORD_RECOVERY,
EmailTemplatePurpose.WELCOME,
]
async function buildEmail(purpose, email, user) {
// this isn't a full email
if (FULL_EMAIL_PURPOSES.indexOf(purpose) === -1) {
throw `Unable to build an email of type ${purpose}`
}
let [base, styles, body] = await Promise.all([
getTemplateByPurpose(TYPE, EmailTemplatePurpose.BASE),
getTemplateByPurpose(TYPE, EmailTemplatePurpose.STYLES),
getTemplateByPurpose(TYPE, purpose),
])
if (!base || !styles || !body) {
throw "Unable to build email, missing base components"
}
base = base.contents
styles = styles.contents
body = body.contents
// TODO: need to extend the context as much as possible
const context = {
...(await getSettingsTemplateContext()),
email,
user: user || {},
}
body = await processString(body, context)
styles = await processString(styles, context)
// this should now be the complete email HTML
return processString(base, {
...context,
styles,
body,
})
}
exports.sendEmail = async ctx => { exports.sendEmail = async ctx => {
const { groupId, email, userId, purpose } = ctx.request.body const { groupId, email, userId, purpose } = ctx.request.body
const db = new CouchDB(GLOBAL_DB) const response = await sendEmail(email, purpose, { groupId, userId })
const params = {}
if (groupId) {
params.group = groupId
}
params.type = Configs.SMTP
let user = {}
if (userId) {
user = db.get(userId)
}
const config = await determineScopedConfig(db, params)
if (!config) {
ctx.throw(400, "Unable to find SMTP configuration")
}
const transport = createSMTPTransport(config)
const message = {
from: config.from,
subject: config.subject,
to: email,
html: await buildEmail(purpose, email, user),
}
const response = await transport.sendMail(message)
ctx.body = { ctx.body = {
...response, ...response,
message: `Email sent to ${email}.`, message: `Email sent to ${email}.`,

View File

@ -2,31 +2,36 @@ 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 } = require("../../constants")
const CouchDB = require("../../db") const CouchDB = require("../../db")
const { clearCookie } = authPkg.utils const { sendEmail } = require("../../utilities/email")
const { clearCookie, getGlobalUserByEmail } = authPkg.utils
const { Cookies } = authPkg.constants const { Cookies } = authPkg.constants
const { passport } = authPkg.auth const { passport } = authPkg.auth
const GLOBAL_DB = authPkg.StaticDatabases.GLOBAL.name const GLOBAL_DB = authPkg.StaticDatabases.GLOBAL.name
function authInternal(ctx, user, err = null) {
if (err) {
return ctx.throw(403, "Unauthorized")
}
const expires = new Date()
expires.setDate(expires.getDate() + 1)
if (!user) {
return ctx.throw(403, "Unauthorized")
}
ctx.cookies.set(Cookies.Auth, user.token, {
expires,
path: "/",
httpOnly: false,
overwrite: true,
})
}
exports.authenticate = async (ctx, next) => { exports.authenticate = async (ctx, next) => {
return passport.authenticate("local", async (err, user) => { return passport.authenticate("local", async (err, user) => {
if (err) { authInternal(ctx, err, user)
return ctx.throw(403, "Unauthorized")
}
const expires = new Date()
expires.setDate(expires.getDate() + 1)
if (!user) {
return ctx.throw(403, "Unauthorized")
}
ctx.cookies.set(Cookies.Auth, user.token, {
expires,
path: "/",
httpOnly: false,
overwrite: true,
})
delete user.token delete user.token
@ -34,6 +39,22 @@ exports.authenticate = async (ctx, next) => {
})(ctx, next) })(ctx, next)
} }
/**
* Reset the user password, used as part of a forgotten password flow.
*/
exports.reset = async ctx => {
const { email } = ctx.request.body
try {
const user = getGlobalUserByEmail(email)
if (user) {
}
} catch (err) {
// don't throw any kind of error to the user, this might give away something
}
ctx.body = {}
}
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" }
@ -69,23 +90,7 @@ exports.googleAuth = async (ctx, next) => {
strategy, strategy,
{ successRedirect: "/", failureRedirect: "/error" }, { successRedirect: "/", failureRedirect: "/error" },
async (err, user) => { async (err, user) => {
if (err) { authInternal(ctx, user, err)
return ctx.throw(403, "Unauthorized")
}
const expires = new Date()
expires.setDate(expires.getDate() + 1)
if (!user) {
return ctx.throw(403, "Unauthorized")
}
ctx.cookies.set(Cookies.Auth, user.token, {
expires,
path: "/",
httpOnly: false,
overwrite: true,
})
ctx.redirect("/") ctx.redirect("/")
} }

View File

@ -1,12 +1,30 @@
const Router = require("@koa/router") const Router = require("@koa/router")
const authController = require("../controllers/auth") const authController = require("../controllers/auth")
const joiValidator = require("../../middleware/joi-validator")
const Joi = require("joi")
const router = Router() const router = Router()
function buildAuthValidation() {
// prettier-ignore
return joiValidator.body(Joi.object({
username: Joi.string().required(),
password: Joi.string().required(),
}).required().unknown(false))
}
function buildResetValidation() {
// prettier-ignore
return joiValidator.body(Joi.object({
email: Joi.string().required(),
}).required().unknown(false))
}
router router
.post("/api/admin/auth", authController.authenticate) .post("/api/admin/auth", buildAuthValidation(), authController.authenticate)
.post("/api/admin/auth/reset", buildResetValidation(), authController.reset)
.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)
.post("/api/admin/auth/logout", authController.logout)
module.exports = router module.exports = router

View File

@ -26,6 +26,8 @@ module.exports = {
LOG_LEVEL: process.env.LOG_LEVEL, LOG_LEVEL: process.env.LOG_LEVEL,
JWT_SECRET: process.env.JWT_SECRET, JWT_SECRET: process.env.JWT_SECRET,
SALT_ROUNDS: process.env.SALT_ROUNDS, SALT_ROUNDS: process.env.SALT_ROUNDS,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
/* TODO: to remove - once deployment removed */ /* TODO: to remove - once deployment removed */
SELF_HOST_KEY: process.env.SELF_HOST_KEY, SELF_HOST_KEY: process.env.SELF_HOST_KEY,
COUCH_DB_USERNAME: process.env.COUCH_DB_USERNAME, COUCH_DB_USERNAME: process.env.COUCH_DB_USERNAME,

View File

@ -1,6 +1,25 @@
const nodemailer = require("nodemailer") const nodemailer = require("nodemailer")
const CouchDB = require("../db")
const { StaticDatabases, determineScopedConfig } = require("@budibase/auth").db
const {
EmailTemplatePurpose,
TemplateTypes,
Configs,
} = require("../constants")
const { getTemplateByPurpose } = require("../constants/templates")
const { getSettingsTemplateContext } = require("./templates")
const { processString } = require("@budibase/string-templates")
exports.createSMTPTransport = config => { const GLOBAL_DB = StaticDatabases.GLOBAL.name
const TYPE = TemplateTypes.EMAIL
const FULL_EMAIL_PURPOSES = [
EmailTemplatePurpose.INVITATION,
EmailTemplatePurpose.PASSWORD_RECOVERY,
EmailTemplatePurpose.WELCOME,
]
function createSMTPTransport(config) {
const options = { const options = {
port: config.port, port: config.port,
host: config.host, host: config.host,
@ -15,7 +34,68 @@ exports.createSMTPTransport = config => {
return nodemailer.createTransport(options) return nodemailer.createTransport(options)
} }
async function buildEmail(purpose, email, user) {
// this isn't a full email
if (FULL_EMAIL_PURPOSES.indexOf(purpose) === -1) {
throw `Unable to build an email of type ${purpose}`
}
let [base, styles, body] = await Promise.all([
getTemplateByPurpose(TYPE, EmailTemplatePurpose.BASE),
getTemplateByPurpose(TYPE, EmailTemplatePurpose.STYLES),
getTemplateByPurpose(TYPE, purpose),
])
if (!base || !styles || !body) {
throw "Unable to build email, missing base components"
}
base = base.contents
styles = styles.contents
body = body.contents
// TODO: need to extend the context as much as possible
const context = {
...(await getSettingsTemplateContext()),
email,
user: user || {},
}
body = await processString(body, context)
styles = await processString(styles, context)
// this should now be the complete email HTML
return processString(base, {
...context,
styles,
body,
})
}
exports.sendEmail = async (email, purpose, { groupId, userId }) => {
const db = new CouchDB(GLOBAL_DB)
const params = {
type: Configs.SMTP,
}
if (groupId) {
params.group = groupId
}
let user = {}
if (userId) {
user = db.get(userId)
}
const config = await determineScopedConfig(db, params)
if (!config) {
throw "Unable to find SMTP configuration"
}
const transport = createSMTPTransport(config)
const message = {
from: config.from,
subject: config.subject,
to: email,
html: await buildEmail(purpose, email, user),
}
return transport.sendMail(message)
}
exports.verifyConfig = async config => { exports.verifyConfig = async config => {
const transport = exports.createSMTPTransport(config) const transport = createSMTPTransport(config)
await transport.verify() await transport.verify()
} }