Merge pull request #1401 from Budibase/configuration-management

Configuration management and Google Authentication
This commit is contained in:
Martin McKeaveney 2021-04-22 15:49:04 +01:00 committed by GitHub
commit f18a5ee667
18 changed files with 479 additions and 44 deletions

View File

@ -14,6 +14,7 @@ const DocumentTypes = {
USER: "us", USER: "us",
APP: "app", APP: "app",
GROUP: "group", GROUP: "group",
CONFIG: "config",
TEMPLATE: "template", TEMPLATE: "template",
} }
@ -47,8 +48,8 @@ exports.getGroupParams = (id = "", otherProps = {}) => {
* Generates a new global user ID. * Generates a new global user ID.
* @returns {string} The new user ID which the user doc can be stored under. * @returns {string} The new user ID which the user doc can be stored under.
*/ */
exports.generateGlobalUserID = () => { exports.generateGlobalUserID = id => {
return `${DocumentTypes.USER}${SEPARATOR}${newid()}` return `${DocumentTypes.USER}${SEPARATOR}${id || newid()}`
} }
/** /**
@ -92,3 +93,70 @@ exports.getTemplateParams = (ownerId, templateId, otherProps = {}) => {
endkey: `${final}${UNICODE_MAX}`, 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

View File

@ -2,7 +2,4 @@ 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,
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,
} }

View File

@ -1,14 +1,13 @@
const passport = require("koa-passport") const passport = require("koa-passport")
const LocalStrategy = require("passport-local").Strategy const LocalStrategy = require("passport-local").Strategy
const JwtStrategy = require("passport-jwt").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 { StaticDatabases } = require("./db/utils")
const { jwt, local, authenticated } = require("./middleware") const { jwt, local, authenticated, google } = require("./middleware")
const { setDB, getDB } = require("./db")
// Strategies // Strategies
passport.use(new LocalStrategy(local.options, local.authenticate)) passport.use(new LocalStrategy(local.options, local.authenticate))
passport.use(new JwtStrategy(jwt.options, jwt.authenticate)) passport.use(new JwtStrategy(jwt.options, jwt.authenticate))
// passport.use(new GoogleStrategy(google.options, google.authenticate))
passport.serializeUser((user, done) => done(null, user)) passport.serializeUser((user, done) => done(null, user))
@ -36,6 +35,8 @@ module.exports = {
auth: { auth: {
buildAuthMiddleware: authenticated, buildAuthMiddleware: authenticated,
passport, passport,
google,
}, },
StaticDatabases,
constants: require("./constants"), constants: require("./constants"),
} }

View File

@ -1,5 +1,7 @@
const { Cookies } = require("../constants") const { Cookies } = require("../constants")
const database = require("../db")
const { getCookie } = require("../utils") const { getCookie } = require("../utils")
const { StaticDatabases } = require("../db/utils")
module.exports = (noAuthPatterns = []) => { module.exports = (noAuthPatterns = []) => {
const regex = new RegExp(noAuthPatterns.join("|")) const regex = new RegExp(noAuthPatterns.join("|"))
@ -13,8 +15,11 @@ module.exports = (noAuthPatterns = []) => {
const authCookie = getCookie(ctx, Cookies.Auth) const authCookie = getCookie(ctx, Cookies.Auth)
if (authCookie) { if (authCookie) {
const db = database.getDB(StaticDatabases.GLOBAL.name)
const user = await db.get(authCookie.userId)
delete user.password
ctx.isAuthenticated = true ctx.isAuthenticated = true
ctx.user = authCookie ctx.user = user
} }
return next() return next()

View File

@ -1,12 +1,76 @@
const env = require("../../environment") 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 = { async function authenticate(token, tokenSecret, profile, done) {
clientId: env.GOOGLE_CLIENT_ID, // Check the user exists in the instance DB by email
clientSecret: env.GOOGLE_CLIENT_SECRET, const db = database.getDB(StaticDatabases.GLOBAL.name)
callbackURL: env.GOOGLE_AUTH_CALLBACK_URL,
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 ... * Create an instance of the google passport strategy. This wrapper fetches the configuration
// fetchUser().then(user => done(null, user)) * 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)
}
}

View File

@ -33,8 +33,6 @@ exports.authenticate = async function(email, password, done) {
if (await compare(password, dbUser.password)) { if (await compare(password, dbUser.password)) {
const payload = { const payload = {
userId: dbUser._id, userId: dbUser._id,
builder: dbUser.builder,
email: dbUser.email,
} }
dbUser.token = jwt.sign(payload, env.JWT_SECRET, { dbUser.token = jwt.sign(payload, env.JWT_SECRET, {

View File

@ -39,6 +39,7 @@
<Input outline type="password" on:change bind:value={password} /> <Input outline type="password" on:change bind:value={password} />
<Spacer large /> <Spacer large />
<Button primary on:click={login}>Login</Button> <Button primary on:click={login}>Login</Button>
<a target="_blank" href="/api/admin/auth/google">Sign In With Google</a>
<Button secondary on:click={createTestUser}>Create Test User</Button> <Button secondary on:click={createTestUser}>Create Test User</Button>
</form> </form>

View File

@ -72,7 +72,7 @@ exports.createMetadata = async function(ctx) {
exports.updateSelfMetadata = async function(ctx) { exports.updateSelfMetadata = async function(ctx) {
// overwrite the ID with current users // 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 // make sure no stale rev
delete ctx.request.body._rev delete ctx.request.body._rev
await exports.updateMetadata(ctx) await exports.updateMetadata(ctx)

View File

@ -31,7 +31,7 @@ module.exports = async (ctx, next) => {
appCookie.roleId === BUILTIN_ROLE_IDS.PUBLIC) appCookie.roleId === BUILTIN_ROLE_IDS.PUBLIC)
) { ) {
// Different App ID means cookie needs reset, or if the same public user has logged in // 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) const globalUser = await getGlobalUsers(ctx, requestAppId, globalId)
updateCookie = true updateCookie = true
appId = requestAppId appId = requestAppId
@ -50,7 +50,7 @@ module.exports = async (ctx, next) => {
ctx.appId = appId ctx.appId = appId
if (roleId) { if (roleId) {
ctx.roleId = 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 = {
...ctx.user, ...ctx.user,
// override userID with metadata one // override userID with metadata one

139
packages/worker/.vscode/launch.json vendored Normal file
View File

@ -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"
}
]
}

View File

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

View File

@ -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 { CouchDB } = require("../../../db")
const { TemplatePurposePretty, TemplateTypes, EmailTemplatePurpose, TemplatePurpose } = require("../../../constants") const { TemplatePurposePretty, TemplateTypes, EmailTemplatePurpose, TemplatePurpose } = require("../../../constants")
const { getTemplateByPurpose } = require("../../../constants/templates") const { getTemplateByPurpose } = require("../../../constants/templates")
@ -68,7 +72,7 @@ exports.save = async ctx => {
exports.definitions = async ctx => { exports.definitions = async ctx => {
ctx.body = { ctx.body = {
purpose: TemplatePurposePretty purpose: TemplatePurposePretty,
} }
} }

View File

@ -1,8 +1,13 @@
const authPkg = require("@budibase/auth") 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 { clearCookie } = 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
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) { if (err) {
@ -34,10 +39,55 @@ exports.logout = async ctx => {
ctx.body = { message: "User logged out" } 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 () => { exports.googleAuth = async (ctx, next) => {
// return passport.authenticate("google") 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)
} }

View File

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

View File

@ -21,11 +21,7 @@ function buildTemplateSaveValidation() {
router router
.get("/api/admin/template/definitions", controller.definitions) .get("/api/admin/template/definitions", controller.definitions)
.post( .post("/api/admin/template", buildTemplateSaveValidation(), controller.save)
"/api/admin/template",
buildTemplateSaveValidation(),
controller.save
)
.get("/api/admin/template", controller.fetch) .get("/api/admin/template", controller.fetch)
.get("/api/admin/template/:type", controller.fetchByType) .get("/api/admin/template/:type", controller.fetchByType)
.get("/api/admin/template/:ownerId", controller.fetchByOwner) .get("/api/admin/template/:ownerId", controller.fetchByOwner)

View File

@ -1,19 +1,12 @@
const Router = require("@koa/router") const Router = require("@koa/router")
const { passport } = require("@budibase/auth").auth
const authController = require("../controllers/auth") const authController = require("../controllers/auth")
const router = Router() const router = Router()
router router
.post("/api/admin/auth", authController.authenticate) .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) .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 module.exports = router

View File

@ -1,6 +1,7 @@
const userRoutes = require("./admin/users") const userRoutes = require("./admin/users")
const configRoutes = require("./admin/configs")
const groupRoutes = require("./admin/groups") const groupRoutes = require("./admin/groups")
const authRoutes = require("./auth") const authRoutes = require("./auth")
const appRoutes = require("./app") const appRoutes = require("./app")
exports.routes = [userRoutes, groupRoutes, authRoutes, appRoutes] exports.routes = [configRoutes, userRoutes, groupRoutes, authRoutes, appRoutes]

View File

@ -7,6 +7,13 @@ exports.Groups = {
ALL_USERS: "all_users", ALL_USERS: "all_users",
} }
exports.Configs = {
SETTINGS: "settings",
ACCOUNT: "account",
SMTP: "smtp",
GOOGLE: "google",
}
const TemplateTypes = { const TemplateTypes = {
EMAIL: "email", EMAIL: "email",
} }