diff --git a/packages/auth/src/db/utils.js b/packages/auth/src/db/utils.js index deff7e1519..393e03e492 100644 --- a/packages/auth/src/db/utils.js +++ b/packages/auth/src/db/utils.js @@ -14,6 +14,7 @@ const DocumentTypes = { USER: "us", APP: "app", GROUP: "group", + CONFIG: "config", TEMPLATE: "template", } @@ -47,8 +48,8 @@ exports.getGroupParams = (id = "", otherProps = {}) => { * Generates a new global user ID. * @returns {string} The new user ID which the user doc can be stored under. */ -exports.generateGlobalUserID = () => { - return `${DocumentTypes.USER}${SEPARATOR}${newid()}` +exports.generateGlobalUserID = id => { + return `${DocumentTypes.USER}${SEPARATOR}${id || newid()}` } /** @@ -92,3 +93,70 @@ exports.getTemplateParams = (ownerId, templateId, otherProps = {}) => { endkey: `${final}${UNICODE_MAX}`, } } + +/** + * Generates a new configuration ID. + * @returns {string} The new configuration ID which the config doc can be stored under. + */ +const generateConfigID = ({ type, group, user }) => { + const scope = [type, group, user].filter(Boolean).join(SEPARATOR) + + return `${DocumentTypes.CONFIG}${SEPARATOR}${scope}` +} + +/** + * Gets parameters for retrieving configurations. + */ +const getConfigParams = ({ type, group, user }, otherProps = {}) => { + const scope = [type, group, user].filter(Boolean).join(SEPARATOR) + + return { + ...otherProps, + startkey: `${DocumentTypes.CONFIG}${SEPARATOR}${scope}`, + endkey: `${DocumentTypes.CONFIG}${SEPARATOR}${scope}${UNICODE_MAX}`, + } +} + +/** + * Returns the most granular configuration document from the DB based on the type, group and userID passed. + * @param {Object} db - db instance to query + * @param {Object} scopes - the type, group and userID scopes of the configuration. + * @returns The most granular configuration document based on the scope. + */ +const determineScopedConfig = async function(db, { type, user, group }) { + const response = await db.allDocs( + getConfigParams( + { type, user, group }, + { + include_docs: true, + } + ) + ) + const configs = response.rows.map(row => { + const config = row.doc + + // Config is specific to a user and a group + if (config._id.includes(generateConfigID({ type, user, group }))) { + config.score = 4 + } else if (config._id.includes(generateConfigID({ type, user }))) { + // Config is specific to a user only + config.score = 3 + } else if (config._id.includes(generateConfigID({ type, group }))) { + // Config is specific to a group only + config.score = 2 + } else if (config._id.includes(generateConfigID({ type }))) { + // Config is specific to a type only + config.score = 1 + } + return config + }) + + // Find the config with the most granular scope based on context + const scopedConfig = configs.sort((a, b) => b.score - a.score)[0] + + return scopedConfig +} + +exports.generateConfigID = generateConfigID +exports.getConfigParams = getConfigParams +exports.determineScopedConfig = determineScopedConfig diff --git a/packages/auth/src/environment.js b/packages/auth/src/environment.js index e6d7ddda65..3a5c81ea8b 100644 --- a/packages/auth/src/environment.js +++ b/packages/auth/src/environment.js @@ -2,7 +2,4 @@ module.exports = { JWT_SECRET: process.env.JWT_SECRET, COUCH_DB_URL: process.env.COUCH_DB_URL, SALT_ROUNDS: process.env.SALT_ROUNDS, - GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, - GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, - GOOGLE_AUTH_CALLBACK_URL: process.env.GOOGLE_AUTH_CALLBACK_URL, } diff --git a/packages/auth/src/index.js b/packages/auth/src/index.js index 0961e15232..c1e0a08242 100644 --- a/packages/auth/src/index.js +++ b/packages/auth/src/index.js @@ -1,14 +1,13 @@ const passport = require("koa-passport") const LocalStrategy = require("passport-local").Strategy const JwtStrategy = require("passport-jwt").Strategy -// const GoogleStrategy = require("passport-google-oauth").Strategy -const { setDB, getDB } = require("./db") const { StaticDatabases } = require("./db/utils") -const { jwt, local, authenticated } = require("./middleware") +const { jwt, local, authenticated, google } = require("./middleware") +const { setDB, getDB } = require("./db") + // Strategies passport.use(new LocalStrategy(local.options, local.authenticate)) passport.use(new JwtStrategy(jwt.options, jwt.authenticate)) -// passport.use(new GoogleStrategy(google.options, google.authenticate)) passport.serializeUser((user, done) => done(null, user)) @@ -36,6 +35,8 @@ module.exports = { auth: { buildAuthMiddleware: authenticated, passport, + google, }, + StaticDatabases, constants: require("./constants"), } diff --git a/packages/auth/src/middleware/authenticated.js b/packages/auth/src/middleware/authenticated.js index fc3a5b177e..443384ee76 100644 --- a/packages/auth/src/middleware/authenticated.js +++ b/packages/auth/src/middleware/authenticated.js @@ -1,5 +1,7 @@ const { Cookies } = require("../constants") +const database = require("../db") const { getCookie } = require("../utils") +const { StaticDatabases } = require("../db/utils") module.exports = (noAuthPatterns = []) => { const regex = new RegExp(noAuthPatterns.join("|")) @@ -13,8 +15,11 @@ module.exports = (noAuthPatterns = []) => { const authCookie = getCookie(ctx, Cookies.Auth) if (authCookie) { + const db = database.getDB(StaticDatabases.GLOBAL.name) + const user = await db.get(authCookie.userId) + delete user.password ctx.isAuthenticated = true - ctx.user = authCookie + ctx.user = user } return next() diff --git a/packages/auth/src/middleware/passport/google.js b/packages/auth/src/middleware/passport/google.js index 05b435aedd..968dfa3e93 100644 --- a/packages/auth/src/middleware/passport/google.js +++ b/packages/auth/src/middleware/passport/google.js @@ -1,12 +1,76 @@ const env = require("../../environment") +const jwt = require("jsonwebtoken") +const database = require("../../db") +const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy +const { StaticDatabases, generateGlobalUserID } = require("../../db/utils") -exports.options = { - clientId: env.GOOGLE_CLIENT_ID, - clientSecret: env.GOOGLE_CLIENT_SECRET, - callbackURL: env.GOOGLE_AUTH_CALLBACK_URL, +async function authenticate(token, tokenSecret, profile, done) { + // Check the user exists in the instance DB by email + const db = database.getDB(StaticDatabases.GLOBAL.name) + + let dbUser + const userId = generateGlobalUserID(profile.id) + + try { + // use the google profile id + dbUser = await db.get(userId) + } catch (err) { + console.error("Google user not found. Creating..") + // create the user + const user = { + _id: userId, + provider: profile.provider, + roles: {}, + builder: { + global: true, + }, + ...profile._json, + } + const response = await db.post(user) + + dbUser = user + dbUser._rev = response.rev + } + + // authenticate + const payload = { + userId: dbUser._id, + builder: dbUser.builder, + email: dbUser.email, + } + + dbUser.token = jwt.sign(payload, env.JWT_SECRET, { + expiresIn: "1 day", + }) + + return done(null, dbUser) } -// exports.authenticate = async function(token, tokenSecret, profile, done) { -// // retrieve user ... -// fetchUser().then(user => done(null, user)) -// } +/** + * Create an instance of the google passport strategy. This wrapper fetches the configuration + * from couchDB rather than environment variables, using this factory is necessary for dynamically configuring passport. + * @returns Dynamically configured Passport Google Strategy + */ +exports.strategyFactory = async function(config) { + try { + const { clientID, clientSecret, callbackURL } = config + + if (!clientID || !clientSecret || !callbackURL) { + throw new Error( + "Configuration invalid. Must contain google clientID, clientSecret and callbackURL" + ) + } + + return new GoogleStrategy( + { + clientID: config.clientID, + clientSecret: config.clientSecret, + callbackURL: config.callbackURL, + }, + authenticate + ) + } catch (err) { + console.error(err) + throw new Error("Error constructing google authentication strategy", err) + } +} diff --git a/packages/auth/src/middleware/passport/local.js b/packages/auth/src/middleware/passport/local.js index 1942d0c424..5b8bf307d7 100644 --- a/packages/auth/src/middleware/passport/local.js +++ b/packages/auth/src/middleware/passport/local.js @@ -33,8 +33,6 @@ exports.authenticate = async function(email, password, done) { if (await compare(password, dbUser.password)) { const payload = { userId: dbUser._id, - builder: dbUser.builder, - email: dbUser.email, } dbUser.token = jwt.sign(payload, env.JWT_SECRET, { diff --git a/packages/builder/src/components/login/LoginForm.svelte b/packages/builder/src/components/login/LoginForm.svelte index 2222ebbdf7..888054df1b 100644 --- a/packages/builder/src/components/login/LoginForm.svelte +++ b/packages/builder/src/components/login/LoginForm.svelte @@ -39,6 +39,7 @@ + Sign In With Google diff --git a/packages/server/src/api/controllers/user.js b/packages/server/src/api/controllers/user.js index 4b6c65736a..1f41acc754 100644 --- a/packages/server/src/api/controllers/user.js +++ b/packages/server/src/api/controllers/user.js @@ -72,7 +72,7 @@ exports.createMetadata = async function(ctx) { exports.updateSelfMetadata = async function(ctx) { // overwrite the ID with current users - ctx.request.body._id = ctx.user.userId + ctx.request.body._id = ctx.user._id // make sure no stale rev delete ctx.request.body._rev await exports.updateMetadata(ctx) diff --git a/packages/server/src/middleware/currentapp.js b/packages/server/src/middleware/currentapp.js index f429c74267..d85d2158c2 100644 --- a/packages/server/src/middleware/currentapp.js +++ b/packages/server/src/middleware/currentapp.js @@ -31,7 +31,7 @@ module.exports = async (ctx, next) => { appCookie.roleId === BUILTIN_ROLE_IDS.PUBLIC) ) { // Different App ID means cookie needs reset, or if the same public user has logged in - const globalId = getGlobalIDFromUserMetadataID(ctx.user.userId) + const globalId = getGlobalIDFromUserMetadataID(ctx.user._id) const globalUser = await getGlobalUsers(ctx, requestAppId, globalId) updateCookie = true appId = requestAppId @@ -50,7 +50,7 @@ module.exports = async (ctx, next) => { ctx.appId = appId if (roleId) { ctx.roleId = roleId - const userId = ctx.user ? generateUserMetadataID(ctx.user.userId) : null + const userId = ctx.user ? generateUserMetadataID(ctx.user._id) : null ctx.user = { ...ctx.user, // override userID with metadata one diff --git a/packages/worker/.vscode/launch.json b/packages/worker/.vscode/launch.json new file mode 100644 index 0000000000..7417938376 --- /dev/null +++ b/packages/worker/.vscode/launch.json @@ -0,0 +1,139 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Start Server", + "program": "${workspaceFolder}/src/index.js" + }, + { + "type": "node", + "request": "launch", + "name": "Jest - All", + "program": "${workspaceFolder}/node_modules/.bin/jest", + "args": [], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "disableOptimisticBPs": true, + "windows": { + "program": "${workspaceFolder}/node_modules/jest-cli/bin/jest", + } + }, + { + "type": "node", + "request": "launch", + "name": "Jest - Users", + "program": "${workspaceFolder}/node_modules/.bin/jest", + "args": ["user.spec", "--runInBand"], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "disableOptimisticBPs": true, + "windows": { + "program": "${workspaceFolder}/node_modules/jest-cli/bin/jest", + } + }, + { + "type": "node", + "request": "launch", + "name": "Jest - Instances", + "program": "${workspaceFolder}/node_modules/.bin/jest", + "args": ["instance.spec", "--runInBand"], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "disableOptimisticBPs": true, + "windows": { + "program": "${workspaceFolder}/node_modules/jest-cli/bin/jest", + } + }, + { + "type": "node", + "request": "launch", + "name": "Jest - Roles", + "program": "${workspaceFolder}/node_modules/.bin/jest", + "args": ["role.spec", "--runInBand"], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "disableOptimisticBPs": true, + "windows": { + "program": "${workspaceFolder}/node_modules/jest-cli/bin/jest", + } + }, + { + "type": "node", + "request": "launch", + "name": "Jest - Records", + "program": "${workspaceFolder}/node_modules/.bin/jest", + "args": ["record.spec", "--runInBand"], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "disableOptimisticBPs": true, + "windows": { + "program": "${workspaceFolder}/node_modules/jest-cli/bin/jest", + } + }, + { + "type": "node", + "request": "launch", + "name": "Jest - Models", + "program": "${workspaceFolder}/node_modules/.bin/jest", + "args": ["table.spec", "--runInBand"], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "disableOptimisticBPs": true, + "windows": { + "program": "${workspaceFolder}/node_modules/jest-cli/bin/jest", + } + }, + { + "type": "node", + "request": "launch", + "name": "Jest - Views", + "program": "${workspaceFolder}/node_modules/.bin/jest", + "args": ["view.spec", "--runInBand"], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "disableOptimisticBPs": true, + "windows": { + "program": "${workspaceFolder}/node_modules/jest-cli/bin/jest", + } + }, + { + "type": "node", + "request": "launch", + "name": "Jest - Applications", + "program": "${workspaceFolder}/node_modules/.bin/jest", + "args": ["application.spec", "--runInBand"], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "disableOptimisticBPs": true, + "windows": { + "program": "${workspaceFolder}/node_modules/jest-cli/bin/jest", + } + }, + { + "type": "node", + "request": "launch", + "name": "Jest Builder", + "program": "${workspaceFolder}/node_modules/.bin/jest", + "args": ["builder", "--runInBand"], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "disableOptimisticBPs": true, + "windows": { + "program": "${workspaceFolder}/node_modules/jest-cli/bin/jest", + } + }, + { + "type": "node", + "request": "launch", + "name": "Initialise Budibase", + "program": "yarn", + "args": ["run", "initialise"], + "console": "externalTerminal" + } + ] +} diff --git a/packages/worker/src/api/controllers/admin/configs.js b/packages/worker/src/api/controllers/admin/configs.js new file mode 100644 index 0000000000..67f3405fa4 --- /dev/null +++ b/packages/worker/src/api/controllers/admin/configs.js @@ -0,0 +1,89 @@ +const CouchDB = require("../../../db") +const authPkg = require("@budibase/auth") +const { utils, StaticDatabases } = authPkg + +const GLOBAL_DB = StaticDatabases.GLOBAL.name + +exports.save = async function(ctx) { + const db = new CouchDB(GLOBAL_DB) + const configDoc = ctx.request.body + const { type, group, user } = configDoc + + // Config does not exist yet + if (!configDoc._id) { + configDoc._id = utils.generateConfigID({ + type, + group, + user, + }) + } + + try { + const response = await db.post(configDoc) + ctx.body = { + type, + _id: response.id, + _rev: response.rev, + } + } catch (err) { + ctx.throw(err.status, err) + } +} + +exports.fetch = async function(ctx) { + const db = new CouchDB(GLOBAL_DB) + const response = await db.allDocs( + utils.getConfigParams(undefined, { + include_docs: true, + }) + ) + const groups = response.rows.map(row => row.doc) + ctx.body = groups +} + +/** + * Gets the most granular config for a particular configuration type. + * The hierarchy is type -> group -> user. + */ +exports.find = async function(ctx) { + const db = new CouchDB(GLOBAL_DB) + const userId = ctx.params.user && ctx.params.user._id + + const { group } = ctx.query + if (group) { + const group = await db.get(group) + const userInGroup = group.users.some(groupUser => groupUser === userId) + if (!ctx.user.admin && !userInGroup) { + ctx.throw(400, `User is not in specified group: ${group}.`) + } + } + + try { + // Find the config with the most granular scope based on context + const scopedConfig = await authPkg.db.determineScopedConfig(db, { + type: ctx.params.type, + user: userId, + group, + }) + + if (scopedConfig) { + ctx.body = scopedConfig + } else { + ctx.throw(400, "No configuration exists.") + } + } catch (err) { + ctx.throw(err.status, err) + } +} + +exports.destroy = async function(ctx) { + const db = new CouchDB(GLOBAL_DB) + const { id, rev } = ctx.params + + try { + await db.remove(id, rev) + ctx.body = { message: "Config deleted successfully" } + } catch (err) { + ctx.throw(err.status, err) + } +} diff --git a/packages/worker/src/api/controllers/admin/templates.js b/packages/worker/src/api/controllers/admin/templates.js index b18bc4e5bb..ccf057c485 100644 --- a/packages/worker/src/api/controllers/admin/templates.js +++ b/packages/worker/src/api/controllers/admin/templates.js @@ -1,4 +1,8 @@ -const { generateTemplateID, getTemplateParams, StaticDatabases } = require("@budibase/auth").db +const { + generateTemplateID, + getTemplateParams, + StaticDatabases, +} = require("@budibase/auth").db const { CouchDB } = require("../../../db") const { TemplatePurposePretty, TemplateTypes, EmailTemplatePurpose, TemplatePurpose } = require("../../../constants") const { getTemplateByPurpose } = require("../../../constants/templates") @@ -68,7 +72,7 @@ exports.save = async ctx => { exports.definitions = async ctx => { ctx.body = { - purpose: TemplatePurposePretty + purpose: TemplatePurposePretty, } } diff --git a/packages/worker/src/api/controllers/auth.js b/packages/worker/src/api/controllers/auth.js index 96ab8e73f0..bcda523a93 100644 --- a/packages/worker/src/api/controllers/auth.js +++ b/packages/worker/src/api/controllers/auth.js @@ -1,8 +1,13 @@ 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) { @@ -34,10 +39,55 @@ exports.logout = async ctx => { ctx.body = { message: "User logged out" } } -exports.googleAuth = async () => { - // return passport.authenticate("google") +/** + * 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 () => { - // return passport.authenticate("google") +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) } diff --git a/packages/worker/src/api/routes/admin/configs.js b/packages/worker/src/api/routes/admin/configs.js new file mode 100644 index 0000000000..c6ac04619e --- /dev/null +++ b/packages/worker/src/api/routes/admin/configs.js @@ -0,0 +1,22 @@ +const Router = require("@koa/router") +const controller = require("../../controllers/admin/configs") +const joiValidator = require("../../../middleware/joi-validator") +const Joi = require("joi") +const { Configs } = require("../../../constants") + +const router = Router() + +function buildConfigSaveValidation() { + // prettier-ignore + return joiValidator.body(Joi.object({ + type: Joi.string().valid(...Object.values(Configs)).required(), + }).required().unknown(true)) +} + +router + .post("/api/admin/configs", buildConfigSaveValidation(), controller.save) + .delete("/api/admin/configs/:id", controller.destroy) + .get("/api/admin/configs", controller.fetch) + .get("/api/admin/configs/:type", controller.find) + +module.exports = router diff --git a/packages/worker/src/api/routes/admin/templates.js b/packages/worker/src/api/routes/admin/templates.js index 6f089cc0f3..81bd814cbc 100644 --- a/packages/worker/src/api/routes/admin/templates.js +++ b/packages/worker/src/api/routes/admin/templates.js @@ -21,11 +21,7 @@ function buildTemplateSaveValidation() { router .get("/api/admin/template/definitions", controller.definitions) - .post( - "/api/admin/template", - buildTemplateSaveValidation(), - controller.save - ) + .post("/api/admin/template", buildTemplateSaveValidation(), controller.save) .get("/api/admin/template", controller.fetch) .get("/api/admin/template/:type", controller.fetchByType) .get("/api/admin/template/:ownerId", controller.fetchByOwner) diff --git a/packages/worker/src/api/routes/auth.js b/packages/worker/src/api/routes/auth.js index 5ce1b69860..72fddec399 100644 --- a/packages/worker/src/api/routes/auth.js +++ b/packages/worker/src/api/routes/auth.js @@ -1,19 +1,12 @@ const Router = require("@koa/router") -const { passport } = require("@budibase/auth").auth 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) - .get("/api/auth/google", passport.authenticate("google")) - .get( - "/api/auth/google/callback", - passport.authenticate("google", { - successRedirect: "/app", - failureRedirect: "/", - }) - ) module.exports = router diff --git a/packages/worker/src/api/routes/index.js b/packages/worker/src/api/routes/index.js index 5c6b088443..aa1c6874e3 100644 --- a/packages/worker/src/api/routes/index.js +++ b/packages/worker/src/api/routes/index.js @@ -1,6 +1,7 @@ const userRoutes = require("./admin/users") +const configRoutes = require("./admin/configs") const groupRoutes = require("./admin/groups") const authRoutes = require("./auth") const appRoutes = require("./app") -exports.routes = [userRoutes, groupRoutes, authRoutes, appRoutes] +exports.routes = [configRoutes, userRoutes, groupRoutes, authRoutes, appRoutes] diff --git a/packages/worker/src/constants/index.js b/packages/worker/src/constants/index.js index ec9ce7b013..8edba58fda 100644 --- a/packages/worker/src/constants/index.js +++ b/packages/worker/src/constants/index.js @@ -7,6 +7,13 @@ exports.Groups = { ALL_USERS: "all_users", } +exports.Configs = { + SETTINGS: "settings", + ACCOUNT: "account", + SMTP: "smtp", + GOOGLE: "google", +} + const TemplateTypes = { EMAIL: "email", }