forked from tsai/budibase
60 changed files with 4271 additions and 375 deletions
@ -1,5 +1,16 @@ |
|||||
|
function isTest() { |
||||
|
return ( |
||||
|
process.env.NODE_ENV === "jest" || |
||||
|
process.env.NODE_ENV === "cypress" || |
||||
|
process.env.JEST_WORKER_ID != null |
||||
|
) |
||||
|
} |
||||
|
|
||||
module.exports = { |
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, |
||||
|
isTest, |
||||
} |
} |
||||
|
|||||
@ -0,0 +1,152 @@ |
|||||
|
const env = require("../environment") |
||||
|
// ioredis mock is all in memory
|
||||
|
const Redis = env.isTest() ? require("ioredis-mock") : require("ioredis") |
||||
|
const { addDbPrefix, removeDbPrefix, getRedisOptions } = require("./utils") |
||||
|
|
||||
|
const CLUSTERED = false |
||||
|
|
||||
|
// for testing just generate the client once
|
||||
|
let CLIENT = env.isTest() ? new Redis(getRedisOptions()) : null |
||||
|
|
||||
|
/** |
||||
|
* 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) => { |
||||
|
// testing uses a single in memory client
|
||||
|
if (env.isTest()) { |
||||
|
return resolve(CLIENT) |
||||
|
} |
||||
|
// if a connection existed, close it and re-create it
|
||||
|
if (CLIENT) { |
||||
|
CLIENT.disconnect() |
||||
|
CLIENT = null |
||||
|
} |
||||
|
const { opts, host, port } = getRedisOptions(CLUSTERED) |
||||
|
if (CLUSTERED) { |
||||
|
CLIENT = new Redis.Cluster([{ host, port }], opts) |
||||
|
} else { |
||||
|
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 finish() { |
||||
|
this._client.disconnect() |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
} |
||||
|
// if its not an object just return the response
|
||||
|
try { |
||||
|
return JSON.parse(response) |
||||
|
} catch (err) { |
||||
|
return 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 |
||||
@ -0,0 +1,46 @@ |
|||||
|
const env = require("../environment") |
||||
|
|
||||
|
const SLOT_REFRESH_MS = 2000 |
||||
|
const CONNECT_TIMEOUT_MS = 10000 |
||||
|
const SEPARATOR = "-" |
||||
|
const REDIS_URL = !env.REDIS_URL ? "localhost:6379" : env.REDIS_URL |
||||
|
const REDIS_PASSWORD = !env.REDIS_PASSWORD ? "budibase" : env.REDIS_PASSWORD |
||||
|
|
||||
|
exports.Databases = { |
||||
|
PW_RESETS: "pwReset", |
||||
|
INVITATIONS: "invitation", |
||||
|
} |
||||
|
|
||||
|
exports.getRedisOptions = (clustered = false) => { |
||||
|
const [host, port] = REDIS_URL.split(":") |
||||
|
const opts = { |
||||
|
connectTimeout: CONNECT_TIMEOUT_MS, |
||||
|
} |
||||
|
if (clustered) { |
||||
|
opts.redisOptions = {} |
||||
|
opts.redisOptions.tls = {} |
||||
|
opts.redisOptions.password = REDIS_PASSWORD |
||||
|
opts.slotsRefreshTimeout = SLOT_REFRESH_MS |
||||
|
opts.dnsLookup = (address, callback) => callback(null, address) |
||||
|
} else { |
||||
|
opts.host = host |
||||
|
opts.port = port |
||||
|
opts.password = REDIS_PASSWORD |
||||
|
} |
||||
|
return { opts, host, port } |
||||
|
} |
||||
|
|
||||
|
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] |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
<script> |
||||
|
export let wide = false |
||||
|
</script> |
||||
|
|
||||
|
<div class:wide> |
||||
|
<slot /> |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
div { |
||||
|
display: grid; |
||||
|
grid-template-columns: 1fr; |
||||
|
max-width: 80ch; |
||||
|
margin: 0 auto; |
||||
|
padding: calc(var(--spacing-xl) * 2); |
||||
|
} |
||||
|
|
||||
|
.wide { |
||||
|
max-width: none; |
||||
|
margin: 0; |
||||
|
padding: var(--spacing-xl) calc(var(--spacing-xl) * 2); |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,7 @@ |
|||||
|
<script> |
||||
|
import { Page } from "@budibase/bbui" |
||||
|
</script> |
||||
|
|
||||
|
<Page wide> |
||||
|
<slot /> |
||||
|
</Page> |
||||
@ -0,0 +1,27 @@ |
|||||
|
<script> |
||||
|
import { Heading, Layout } from "@budibase/bbui" |
||||
|
</script> |
||||
|
|
||||
|
<Layout noPadding> |
||||
|
<div> |
||||
|
<Heading>Apps</Heading> |
||||
|
</div> |
||||
|
<div class="appList"> |
||||
|
{#each new Array(10) as _} |
||||
|
<div class="app" /> |
||||
|
{/each} |
||||
|
</div> |
||||
|
</Layout> |
||||
|
|
||||
|
<style> |
||||
|
.appList { |
||||
|
display: grid; |
||||
|
grid-gap: 50px; |
||||
|
grid-template-columns: repeat(auto-fill, 300px); |
||||
|
} |
||||
|
.app { |
||||
|
height: 130px; |
||||
|
border-radius: 4px; |
||||
|
background-color: var(--spectrum-global-color-gray-200); |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,4 @@ |
|||||
|
<script> |
||||
|
import { goto } from "@roxi/routify" |
||||
|
$goto("./apps") |
||||
|
</script> |
||||
@ -0,0 +1,7 @@ |
|||||
|
<script> |
||||
|
import { Page } from "@budibase/bbui" |
||||
|
</script> |
||||
|
|
||||
|
<Page> |
||||
|
<slot /> |
||||
|
</Page> |
||||
File diff suppressed because it is too large
File diff suppressed because it is too large
@ -0,0 +1,5 @@ |
|||||
|
const env = require("../src/environment") |
||||
|
|
||||
|
env._set("NODE_ENV", "jest") |
||||
|
env._set("JWT_SECRET", "test-jwtsecret") |
||||
|
env._set("LOG_LEVEL", "silent") |
||||
@ -0,0 +1,125 @@ |
|||||
|
const authPkg = require("@budibase/auth") |
||||
|
const { google } = require("@budibase/auth/src/middleware") |
||||
|
const { Configs, EmailTemplatePurpose } = require("../../../constants") |
||||
|
const CouchDB = require("../../../db") |
||||
|
const { sendEmail, isEmailConfigured } = require("../../../utilities/email") |
||||
|
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 |
||||
|
|
||||
|
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) => { |
||||
|
return passport.authenticate("local", async (err, user) => { |
||||
|
authInternal(ctx, user, err) |
||||
|
|
||||
|
delete user.token |
||||
|
|
||||
|
ctx.body = { user } |
||||
|
})(ctx, next) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Reset the user password, used as part of a forgotten password flow. |
||||
|
*/ |
||||
|
exports.reset = async ctx => { |
||||
|
const { email } = ctx.request.body |
||||
|
const configured = await isEmailConfigured() |
||||
|
if (!configured) { |
||||
|
ctx.throw( |
||||
|
400, |
||||
|
"Please contact your platform administrator, SMTP is not configured." |
||||
|
) |
||||
|
} |
||||
|
try { |
||||
|
const user = await getGlobalUserByEmail(email) |
||||
|
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: "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 |
||||
|
try { |
||||
|
const userId = await checkResetPasswordCode(resetCode) |
||||
|
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.", |
||||
|
} |
||||
|
} catch (err) { |
||||
|
ctx.throw(400, "Cannot reset password.") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
exports.logout = async ctx => { |
||||
|
clearCookie(ctx, Cookies.Auth) |
||||
|
ctx.body = { message: "User logged out." } |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* The initial call that google authentication makes to take you to the google login screen. |
||||
|
* On a successful login, you will be redirected to the googleAuth callback route. |
||||
|
*/ |
||||
|
exports.googlePreAuth = async (ctx, next) => { |
||||
|
const db = new CouchDB(GLOBAL_DB) |
||||
|
const config = await authPkg.db.getScopedConfig(db, { |
||||
|
type: Configs.GOOGLE, |
||||
|
group: ctx.query.group, |
||||
|
}) |
||||
|
const strategy = await google.strategyFactory(config) |
||||
|
|
||||
|
return passport.authenticate(strategy, { |
||||
|
scope: ["profile", "email"], |
||||
|
})(ctx, next) |
||||
|
} |
||||
|
|
||||
|
exports.googleAuth = async (ctx, next) => { |
||||
|
const db = new CouchDB(GLOBAL_DB) |
||||
|
|
||||
|
const config = await authPkg.db.getScopedConfig(db, { |
||||
|
type: Configs.GOOGLE, |
||||
|
group: ctx.query.group, |
||||
|
}) |
||||
|
const strategy = await google.strategyFactory(config) |
||||
|
|
||||
|
return passport.authenticate( |
||||
|
strategy, |
||||
|
{ successRedirect: "/", failureRedirect: "/error" }, |
||||
|
async (err, user) => { |
||||
|
authInternal(ctx, user, err) |
||||
|
|
||||
|
ctx.redirect("/") |
||||
|
} |
||||
|
)(ctx, next) |
||||
|
} |
||||
@ -1,93 +0,0 @@ |
|||||
const authPkg = require("@budibase/auth") |
|
||||
const { google } = require("@budibase/auth/src/middleware") |
|
||||
const { Configs } = require("../../constants") |
|
||||
const CouchDB = require("../../db") |
|
||||
const { clearCookie } = authPkg.utils |
|
||||
const { Cookies } = authPkg.constants |
|
||||
const { passport } = authPkg.auth |
|
||||
|
|
||||
const GLOBAL_DB = authPkg.StaticDatabases.GLOBAL.name |
|
||||
|
|
||||
exports.authenticate = async (ctx, next) => { |
|
||||
return passport.authenticate("local", async (err, user) => { |
|
||||
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, |
|
||||
}) |
|
||||
|
|
||||
delete user.token |
|
||||
|
|
||||
ctx.body = { user } |
|
||||
})(ctx, next) |
|
||||
} |
|
||||
|
|
||||
exports.logout = async ctx => { |
|
||||
clearCookie(ctx, Cookies.Auth) |
|
||||
ctx.body = { message: "User logged out" } |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* The initial call that google authentication makes to take you to the google login screen. |
|
||||
* On a successful login, you will be redirected to the googleAuth callback route. |
|
||||
*/ |
|
||||
exports.googlePreAuth = async (ctx, next) => { |
|
||||
const db = new CouchDB(GLOBAL_DB) |
|
||||
const config = await authPkg.db.determineScopedConfig(db, { |
|
||||
type: Configs.GOOGLE, |
|
||||
group: ctx.query.group, |
|
||||
}) |
|
||||
const strategy = await google.strategyFactory(config) |
|
||||
|
|
||||
return passport.authenticate(strategy, { |
|
||||
scope: ["profile", "email"], |
|
||||
})(ctx, next) |
|
||||
} |
|
||||
|
|
||||
exports.googleAuth = async (ctx, next) => { |
|
||||
const db = new CouchDB(GLOBAL_DB) |
|
||||
|
|
||||
const config = await authPkg.db.determineScopedConfig(db, { |
|
||||
type: Configs.GOOGLE, |
|
||||
group: ctx.query.group, |
|
||||
}) |
|
||||
const strategy = await google.strategyFactory(config) |
|
||||
|
|
||||
return passport.authenticate( |
|
||||
strategy, |
|
||||
{ successRedirect: "/", failureRedirect: "/error" }, |
|
||||
async (err, user) => { |
|
||||
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, |
|
||||
}) |
|
||||
|
|
||||
ctx.redirect("/") |
|
||||
} |
|
||||
)(ctx, next) |
|
||||
} |
|
||||
@ -0,0 +1,43 @@ |
|||||
|
const Router = require("@koa/router") |
||||
|
const authController = require("../../controllers/admin/auth") |
||||
|
const joiValidator = require("../../../middleware/joi-validator") |
||||
|
const Joi = require("joi") |
||||
|
|
||||
|
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)) |
||||
|
} |
||||
|
|
||||
|
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) |
||||
|
|
||||
|
module.exports = router |
||||
@ -1,12 +0,0 @@ |
|||||
const Router = require("@koa/router") |
|
||||
const authController = require("../controllers/auth") |
|
||||
|
|
||||
const router = Router() |
|
||||
|
|
||||
router |
|
||||
.post("/api/admin/auth", authController.authenticate) |
|
||||
.get("/api/admin/auth/google", authController.googlePreAuth) |
|
||||
.get("/api/admin/auth/google/callback", authController.googleAuth) |
|
||||
.post("/api/admin/auth/logout", authController.logout) |
|
||||
|
|
||||
module.exports = router |
|
||||
@ -0,0 +1,49 @@ |
|||||
|
const setup = require("./utilities") |
||||
|
|
||||
|
jest.mock("nodemailer") |
||||
|
const sendMailMock = setup.emailMock() |
||||
|
|
||||
|
describe("/api/admin/auth", () => { |
||||
|
let request = setup.getRequest() |
||||
|
let config = setup.getConfig() |
||||
|
let code |
||||
|
|
||||
|
beforeAll(async () => { |
||||
|
await config.init() |
||||
|
}) |
||||
|
|
||||
|
afterAll(setup.afterAll) |
||||
|
|
||||
|
it("should be able to generate password reset email", async () => { |
||||
|
// initially configure settings
|
||||
|
await config.saveSmtpConfig() |
||||
|
await config.saveSettingsConfig() |
||||
|
await config.createUser("test@test.com") |
||||
|
const res = await request |
||||
|
.post(`/api/admin/auth/reset`) |
||||
|
.send({ |
||||
|
email: "test@test.com", |
||||
|
}) |
||||
|
.expect("Content-Type", /json/) |
||||
|
.expect(200) |
||||
|
expect(res.body).toEqual({ message: "Please check your email for a reset link." }) |
||||
|
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/reset?code=") |
||||
|
code = parts[1].split("\"")[0] |
||||
|
expect(code).toBeDefined() |
||||
|
}) |
||||
|
|
||||
|
it("should allow resetting user password with code", async () => { |
||||
|
const res = await request |
||||
|
.post(`/api/admin/auth/reset/update`) |
||||
|
.send({ |
||||
|
password: "newpassword", |
||||
|
resetCode: code, |
||||
|
}) |
||||
|
.expect("Content-Type", /json/) |
||||
|
.expect(200) |
||||
|
expect(res.body).toEqual({ message: "password reset successfully." }) |
||||
|
}) |
||||
|
}) |
||||
@ -0,0 +1,52 @@ |
|||||
|
const setup = require("./utilities") |
||||
|
|
||||
|
jest.mock("nodemailer") |
||||
|
const sendMailMock = setup.emailMock() |
||||
|
|
||||
|
describe("/api/admin/users", () => { |
||||
|
let request = setup.getRequest() |
||||
|
let config = setup.getConfig() |
||||
|
let code |
||||
|
|
||||
|
beforeAll(async () => { |
||||
|
await config.init() |
||||
|
}) |
||||
|
|
||||
|
afterAll(setup.afterAll) |
||||
|
|
||||
|
it("should be able to generate an invitation", async () => { |
||||
|
// initially configure settings
|
||||
|
await config.saveSmtpConfig() |
||||
|
await config.saveSettingsConfig() |
||||
|
const res = await request |
||||
|
.post(`/api/admin/users/invite`) |
||||
|
.send({ |
||||
|
email: "invite@test.com", |
||||
|
}) |
||||
|
.set(config.defaultHeaders()) |
||||
|
.expect("Content-Type", /json/) |
||||
|
.expect(200) |
||||
|
expect(res.body).toEqual({ message: "Invitation has been sent." }) |
||||
|
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/invite?code=") |
||||
|
code = parts[1].split("\"")[0] |
||||
|
expect(code).toBeDefined() |
||||
|
}) |
||||
|
|
||||
|
it("should be able to create new user from invite", async () => { |
||||
|
const res = await request |
||||
|
.post(`/api/admin/users/invite/accept`) |
||||
|
.send({ |
||||
|
password: "newpassword", |
||||
|
inviteCode: code, |
||||
|
}) |
||||
|
.expect("Content-Type", /json/) |
||||
|
.expect(200) |
||||
|
expect(res.body._id).toBeDefined() |
||||
|
const user = await config.getUser("invite@test.com") |
||||
|
expect(user).toBeDefined() |
||||
|
expect(user._id).toEqual(res.body._id) |
||||
|
}) |
||||
|
}) |
||||
@ -0,0 +1,85 @@ |
|||||
|
const { Client, utils } = require("@budibase/auth").redis |
||||
|
const { newid } = require("@budibase/auth").utils |
||||
|
|
||||
|
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() |
||||
|
} |
||||
|
|
||||
|
async function writeACode(db, value) { |
||||
|
const client = await getClient(db) |
||||
|
const code = newid() |
||||
|
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). |
||||
|
* @param {string} userId the ID of the user which is to be reset. |
||||
|
* @return {Promise<string>} returns the code that was stored to redis. |
||||
|
*/ |
||||
|
exports.getResetPasswordCode = async userId => { |
||||
|
return writeACode(utils.Databases.PW_RESETS, 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 - defaults to true. |
||||
|
* @return {Promise<string>} returns the user ID if it is found |
||||
|
*/ |
||||
|
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." |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Generates an invitation code and writes it to redis - which can later be checked for user creation. |
||||
|
* @param {string} email the email address which the code is being sent to (for use later). |
||||
|
* @return {Promise<string>} returns the code that was stored to redis. |
||||
|
*/ |
||||
|
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." |
||||
|
} |
||||
|
} |
||||
File diff suppressed because it is too large
Loading…
Reference in new issue