From cacf275a997fa1274a05a98b3bc86637ec69a8dd Mon Sep 17 00:00:00 2001 From: Rory Powell Date: Tue, 21 Feb 2023 08:23:53 +0000 Subject: [PATCH] Prevent SSO users from setting / resetting a password (#9672) * Prevent SSO users from setting / resetting a password * Add support for ENABLE_SSO_MAINTENANCE_MODE * Add typing to self api and build out user update sdk * Integrate sso checks with user sdk. Integrate user sdk with self api * Test fixes * Move self update into SDK * Lock down maintenance mode to admin user * Fix typo * Add health status response and return type signature to accounts.getStatus * Remove some unnecessary comments * Make sso save user function non optional * Remove redundant check on sso auth details provider * Update syncProfilePicture function name to getProfilePictureUrl * Update packages/worker/src/sdk/users/events.ts Co-authored-by: Adria Navarro * Add ENABLE_EMAIL_TEST_MODE flag * Fix for logging in as sso user when existing user has password already * Hide password update and force reset from ui for sso users * Always disable sso maintenance mode in cloud --------- Co-authored-by: Adria Navarro --- .../src/{cloud => accounts}/accounts.ts | 23 +- .../src/{cloud => accounts}/api.ts | 0 packages/backend-core/src/accounts/index.ts | 1 + packages/backend-core/src/auth/auth.ts | 50 +++- .../backend-core/src/auth/tests/auth.spec.ts | 13 + .../backend-core/src/events/identification.ts | 6 +- .../middleware/passport/datasource/google.ts | 11 +- .../src/middleware/passport/local.ts | 56 +--- .../middleware/passport/{ => sso}/google.ts | 28 +- .../src/middleware/passport/{ => sso}/oidc.ts | 40 ++- .../src/middleware/passport/sso/sso.ts | 165 +++++++++++ .../passport/sso/tests/google.spec.ts | 67 +++++ .../passport/sso/tests/oidc.spec.ts | 152 ++++++++++ .../middleware/passport/sso/tests/sso.spec.ts | 196 +++++++++++++ .../middleware/passport/tests/google.spec.js | 79 ----- .../middleware/passport/tests/oidc.spec.js | 144 ---------- .../tests/third-party-common.seq.spec.js | 178 ------------ .../passport/tests/utilities/mock-data.js | 54 ---- .../middleware/passport/third-party-common.ts | 177 ------------ packages/backend-core/src/users.ts | 6 + packages/backend-core/src/utils/utils.ts | 45 +-- .../tests/utilities/mocks/accounts.ts | 13 - .../tests/utilities/mocks/index.ts | 5 +- .../tests/utilities/structures/accounts.ts | 36 ++- .../tests/utilities/structures/index.ts | 4 +- .../tests/utilities/structures/sso.ts | 100 +++++++ .../tests/utilities/structures/users.ts | 70 +++++ .../portal/_components/UserDropdown.svelte | 8 +- .../portal/users/users/[userId].svelte | 9 +- packages/builder/src/stores/portal/auth.js | 1 + packages/server/specs/openapi.json | 12 +- packages/server/specs/openapi.yaml | 6 +- .../src/integrations/tests/couchdb.spec.ts | 2 - packages/types/src/api/account/index.ts | 1 + packages/types/src/api/account/status.ts | 7 + packages/types/src/api/web/auth.ts | 25 ++ packages/types/src/api/web/index.ts | 1 + packages/types/src/api/web/user.ts | 21 +- .../types/src/documents/account/account.ts | 18 +- packages/types/src/documents/global/config.ts | 27 +- packages/types/src/documents/global/user.ts | 63 ++-- packages/types/src/sdk/index.ts | 2 + packages/types/src/sdk/sso.ts | 37 +++ packages/types/src/sdk/user.ts | 12 + packages/worker/scripts/dev/manage.js | 1 + .../worker/src/api/controllers/global/auth.ts | 147 +++++----- .../worker/src/api/controllers/global/self.ts | 70 ++--- .../src/api/controllers/global/users.ts | 43 ++- .../src/api/controllers/system/accounts.ts | 12 +- packages/worker/src/api/routes/global/auth.ts | 17 +- .../src/api/routes/global/tests/auth.spec.ts | 271 +++++++++++++++--- .../src/api/routes/global/tests/self.spec.ts | 7 +- .../api/routes/system/tests/accounts.spec.ts | 8 +- packages/worker/src/environment.ts | 16 +- packages/worker/src/index.ts | 6 + packages/worker/src/sdk/accounts/index.ts | 3 +- .../sdk/accounts/{accounts.ts => metadata.ts} | 1 - packages/worker/src/sdk/auth/auth.ts | 86 ++++++ packages/worker/src/sdk/auth/index.ts | 1 + packages/worker/src/sdk/users/events.ts | 4 + packages/worker/src/sdk/users/index.ts | 1 + .../worker/src/sdk/users/tests/users.spec.ts | 52 ++++ packages/worker/src/sdk/users/users.ts | 97 ++++--- .../worker/src/tests/TestConfiguration.ts | 4 +- packages/worker/src/tests/api/auth.ts | 60 ++-- packages/worker/src/tests/structures/index.ts | 2 - packages/worker/src/tests/structures/users.ts | 37 --- packages/worker/src/utilities/email.ts | 6 +- 68 files changed, 1803 insertions(+), 1120 deletions(-) rename packages/backend-core/src/{cloud => accounts}/accounts.ts (69%) rename packages/backend-core/src/{cloud => accounts}/api.ts (100%) create mode 100644 packages/backend-core/src/accounts/index.ts create mode 100644 packages/backend-core/src/auth/tests/auth.spec.ts rename packages/backend-core/src/middleware/passport/{ => sso}/google.ts (76%) rename packages/backend-core/src/middleware/passport/{ => sso}/oidc.ts (85%) create mode 100644 packages/backend-core/src/middleware/passport/sso/sso.ts create mode 100644 packages/backend-core/src/middleware/passport/sso/tests/google.spec.ts create mode 100644 packages/backend-core/src/middleware/passport/sso/tests/oidc.spec.ts create mode 100644 packages/backend-core/src/middleware/passport/sso/tests/sso.spec.ts delete mode 100644 packages/backend-core/src/middleware/passport/tests/google.spec.js delete mode 100644 packages/backend-core/src/middleware/passport/tests/oidc.spec.js delete mode 100644 packages/backend-core/src/middleware/passport/tests/third-party-common.seq.spec.js delete mode 100644 packages/backend-core/src/middleware/passport/tests/utilities/mock-data.js delete mode 100644 packages/backend-core/src/middleware/passport/third-party-common.ts delete mode 100644 packages/backend-core/tests/utilities/mocks/accounts.ts create mode 100644 packages/backend-core/tests/utilities/structures/sso.ts create mode 100644 packages/backend-core/tests/utilities/structures/users.ts create mode 100644 packages/types/src/api/account/status.ts create mode 100644 packages/types/src/api/web/auth.ts create mode 100644 packages/types/src/sdk/sso.ts create mode 100644 packages/types/src/sdk/user.ts rename packages/worker/src/sdk/accounts/{accounts.ts => metadata.ts} (99%) create mode 100644 packages/worker/src/sdk/auth/auth.ts create mode 100644 packages/worker/src/sdk/auth/index.ts create mode 100644 packages/worker/src/sdk/users/tests/users.spec.ts delete mode 100644 packages/worker/src/tests/structures/users.ts diff --git a/packages/backend-core/src/cloud/accounts.ts b/packages/backend-core/src/accounts/accounts.ts similarity index 69% rename from packages/backend-core/src/cloud/accounts.ts rename to packages/backend-core/src/accounts/accounts.ts index 90fa7ab824..a16d0f1074 100644 --- a/packages/backend-core/src/cloud/accounts.ts +++ b/packages/backend-core/src/accounts/accounts.ts @@ -1,13 +1,24 @@ import API from "./api" import env from "../environment" import { Header } from "../constants" -import { CloudAccount } from "@budibase/types" +import { CloudAccount, HealthStatusResponse } from "@budibase/types" const api = new API(env.ACCOUNT_PORTAL_URL) +/** + * This client is intended to be used in a cloud hosted deploy only. + * Rather than relying on each consumer to perform the necessary environmental checks + * we use the following check to exit early with a undefined response which should be + * handled by the caller. + */ +const EXIT_EARLY = env.SELF_HOSTED || env.DISABLE_ACCOUNT_PORTAL + export const getAccount = async ( email: string ): Promise => { + if (EXIT_EARLY) { + return + } const payload = { email, } @@ -29,6 +40,9 @@ export const getAccount = async ( export const getAccountByTenantId = async ( tenantId: string ): Promise => { + if (EXIT_EARLY) { + return + } const payload = { tenantId, } @@ -47,7 +61,12 @@ export const getAccountByTenantId = async ( return json[0] } -export const getStatus = async () => { +export const getStatus = async (): Promise< + HealthStatusResponse | undefined +> => { + if (EXIT_EARLY) { + return + } const response = await api.get(`/api/status`, { headers: { [Header.API_KEY]: env.ACCOUNT_PORTAL_API_KEY, diff --git a/packages/backend-core/src/cloud/api.ts b/packages/backend-core/src/accounts/api.ts similarity index 100% rename from packages/backend-core/src/cloud/api.ts rename to packages/backend-core/src/accounts/api.ts diff --git a/packages/backend-core/src/accounts/index.ts b/packages/backend-core/src/accounts/index.ts new file mode 100644 index 0000000000..f2ae03040e --- /dev/null +++ b/packages/backend-core/src/accounts/index.ts @@ -0,0 +1 @@ +export * from "./accounts" diff --git a/packages/backend-core/src/auth/auth.ts b/packages/backend-core/src/auth/auth.ts index bbefb2933d..bee245a3ae 100644 --- a/packages/backend-core/src/auth/auth.ts +++ b/packages/backend-core/src/auth/auth.ts @@ -1,10 +1,11 @@ const _passport = require("koa-passport") const LocalStrategy = require("passport-local").Strategy const JwtStrategy = require("passport-jwt").Strategy -import { getGlobalDB } from "../tenancy" +import { getGlobalDB } from "../context" const refresh = require("passport-oauth2-refresh") -import { Config } from "../constants" +import { Config, Cookie } from "../constants" import { getScopedConfig } from "../db" +import { getSessionsForUser, invalidateSessions } from "../security/sessions" import { jwt as jwtPassport, local, @@ -15,8 +16,11 @@ import { google, } from "../middleware" import { invalidateUser } from "../cache/user" -import { User } from "@budibase/types" +import { PlatformLogoutOpts, User } from "@budibase/types" import { logAlert } from "../logging" +import * as events from "../events" +import * as userCache from "../cache/user" +import { clearCookie, getCookie } from "../utils" export { auditLog, authError, @@ -29,6 +33,7 @@ export { google, oidc, } from "../middleware" +import { ssoSaveUserNoOp } from "../middleware/passport/sso/sso" export const buildAuthMiddleware = authenticated export const buildTenancyMiddleware = tenancy export const buildCsrfMiddleware = csrf @@ -71,7 +76,7 @@ async function refreshOIDCAccessToken( if (!enrichedConfig) { throw new Error("OIDC Config contents invalid") } - strategy = await oidc.strategyFactory(enrichedConfig) + strategy = await oidc.strategyFactory(enrichedConfig, ssoSaveUserNoOp) } catch (err) { console.error(err) throw new Error("Could not refresh OAuth Token") @@ -103,7 +108,11 @@ async function refreshGoogleAccessToken( let strategy try { - strategy = await google.strategyFactory(config, callbackUrl) + strategy = await google.strategyFactory( + config, + callbackUrl, + ssoSaveUserNoOp + ) } catch (err: any) { console.error(err) throw new Error( @@ -161,6 +170,8 @@ export async function refreshOAuthToken( return refreshResponse } +// TODO: Refactor to use user save function instead to prevent the need for +// manually saving and invalidating on callback export async function updateUserOAuth(userId: string, oAuthConfig: any) { const details = { accessToken: oAuthConfig.accessToken, @@ -188,3 +199,32 @@ export async function updateUserOAuth(userId: string, oAuthConfig: any) { console.error("Could not update OAuth details for current user", e) } } + +/** + * Logs a user out from budibase. Re-used across account portal and builder. + */ +export async function platformLogout(opts: PlatformLogoutOpts) { + const ctx = opts.ctx + const userId = opts.userId + const keepActiveSession = opts.keepActiveSession + + if (!ctx) throw new Error("Koa context must be supplied to logout.") + + const currentSession = getCookie(ctx, Cookie.Auth) + let sessions = await getSessionsForUser(userId) + + if (keepActiveSession) { + sessions = sessions.filter( + session => session.sessionId !== currentSession.sessionId + ) + } else { + // clear cookies + clearCookie(ctx, Cookie.Auth) + clearCookie(ctx, Cookie.CurrentApp) + } + + const sessionIds = sessions.map(({ sessionId }) => sessionId) + await invalidateSessions(userId, { sessionIds, reason: "logout" }) + await events.auth.logout() + await userCache.invalidateUser(userId) +} diff --git a/packages/backend-core/src/auth/tests/auth.spec.ts b/packages/backend-core/src/auth/tests/auth.spec.ts new file mode 100644 index 0000000000..307f6a63c8 --- /dev/null +++ b/packages/backend-core/src/auth/tests/auth.spec.ts @@ -0,0 +1,13 @@ +import { structures, testEnv } from "../../../tests" +import * as auth from "../auth" +import * as events from "../../events" + +describe("platformLogout", () => { + it("should call platform logout", async () => { + await testEnv.withTenant(async () => { + const ctx = structures.koa.newContext() + await auth.platformLogout({ ctx, userId: "test" }) + expect(events.auth.logout).toBeCalledTimes(1) + }) + }) +}) diff --git a/packages/backend-core/src/events/identification.ts b/packages/backend-core/src/events/identification.ts index 8ac22b471c..7cade9e14b 100644 --- a/packages/backend-core/src/events/identification.ts +++ b/packages/backend-core/src/events/identification.ts @@ -16,6 +16,7 @@ import { InstallationGroup, UserContext, Group, + isSSOUser, } from "@budibase/types" import { processors } from "./processors" import * as dbUtils from "../db/utils" @@ -166,7 +167,10 @@ const identifyUser = async ( const type = IdentityType.USER let builder = user.builder?.global || false let admin = user.admin?.global || false - let providerType = user.providerType + let providerType + if (isSSOUser(user)) { + providerType = user.providerType + } const accountHolder = account?.budibaseUserId === user._id || false const verified = account && account?.budibaseUserId === user._id ? account.verified : false diff --git a/packages/backend-core/src/middleware/passport/datasource/google.ts b/packages/backend-core/src/middleware/passport/datasource/google.ts index 65620d7aa3..112f8d2096 100644 --- a/packages/backend-core/src/middleware/passport/datasource/google.ts +++ b/packages/backend-core/src/middleware/passport/datasource/google.ts @@ -1,10 +1,11 @@ -import * as google from "../google" +import * as google from "../sso/google" import { Cookie, Config } from "../../../constants" import { clearCookie, getCookie } from "../../../utils" import { getScopedConfig, getPlatformUrl, doWithDB } from "../../../db" import environment from "../../../environment" -import { getGlobalDB } from "../../../tenancy" +import { getGlobalDB } from "../../../context" import { BBContext, Database, SSOProfile } from "@budibase/types" +import { ssoSaveUserNoOp } from "../sso/sso" const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy type Passport = { @@ -36,7 +37,11 @@ export async function preAuth( const platformUrl = await getPlatformUrl({ tenantAware: false }) let callbackUrl = `${platformUrl}/api/global/auth/datasource/google/callback` - const strategy = await google.strategyFactory(googleConfig, callbackUrl) + const strategy = await google.strategyFactory( + googleConfig, + callbackUrl, + ssoSaveUserNoOp + ) if (!ctx.query.appId || !ctx.query.datasourceId) { ctx.throw(400, "appId and datasourceId query params not present.") diff --git a/packages/backend-core/src/middleware/passport/local.ts b/packages/backend-core/src/middleware/passport/local.ts index 8b85d3734c..e198032532 100644 --- a/packages/backend-core/src/middleware/passport/local.ts +++ b/packages/backend-core/src/middleware/passport/local.ts @@ -1,15 +1,10 @@ import { UserStatus } from "../../constants" -import { compare, newid } from "../../utils" -import env from "../../environment" +import { compare } from "../../utils" import * as users from "../../users" import { authError } from "./utils" -import { createASession } from "../../security/sessions" -import { getTenantId } from "../../tenancy" import { BBContext } from "@budibase/types" -const jwt = require("jsonwebtoken") const INVALID_ERR = "Invalid credentials" -const SSO_NO_PASSWORD = "SSO user does not have a password set" const EXPIRED = "This account has expired. Please reset your password" export const options = { @@ -35,50 +30,25 @@ export async function authenticate( const dbUser = await users.getGlobalUserByEmail(email) if (dbUser == null) { - return authError(done, `User not found: [${email}]`) - } - - // check that the user is currently inactive, if this is the case throw invalid - if (dbUser.status === UserStatus.INACTIVE) { + console.info(`user=${email} could not be found`) return authError(done, INVALID_ERR) } - // check that the user has a stored password before proceeding - if (!dbUser.password) { - if ( - (dbUser.account && dbUser.account.authType === "sso") || // root account sso - dbUser.thirdPartyProfile // internal sso - ) { - return authError(done, SSO_NO_PASSWORD) - } + if (dbUser.status === UserStatus.INACTIVE) { + console.info(`user=${email} is inactive`, dbUser) + return authError(done, INVALID_ERR) + } - console.error("Non SSO usser has no password set", dbUser) + if (!dbUser.password) { + console.info(`user=${email} has no password set`, dbUser) return authError(done, EXPIRED) } - // authenticate - if (await compare(password, dbUser.password)) { - const sessionId = newid() - const tenantId = getTenantId() - - await createASession(dbUser._id!, { sessionId, tenantId }) - - const token = jwt.sign( - { - userId: dbUser._id, - sessionId, - tenantId, - }, - env.JWT_SECRET - ) - // Remove users password in payload - delete dbUser.password - - return done(null, { - ...dbUser, - token, - }) - } else { + if (!(await compare(password, dbUser.password))) { return authError(done, INVALID_ERR) } + + // intentionally remove the users password in payload + delete dbUser.password + return done(null, dbUser) } diff --git a/packages/backend-core/src/middleware/passport/google.ts b/packages/backend-core/src/middleware/passport/sso/google.ts similarity index 76% rename from packages/backend-core/src/middleware/passport/google.ts rename to packages/backend-core/src/middleware/passport/sso/google.ts index dd3dc8b86d..d26d7d6a8d 100644 --- a/packages/backend-core/src/middleware/passport/google.ts +++ b/packages/backend-core/src/middleware/passport/sso/google.ts @@ -1,18 +1,26 @@ -import { ssoCallbackUrl } from "./utils" -import { authenticateThirdParty, SaveUserFunction } from "./third-party-common" -import { ConfigType, GoogleConfig, Database, SSOProfile } from "@budibase/types" +import { ssoCallbackUrl } from "../utils" +import * as sso from "./sso" +import { + ConfigType, + GoogleConfig, + Database, + SSOProfile, + SSOAuthDetails, + SSOProviderType, + SaveSSOUserFunction, +} from "@budibase/types" const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy -export function buildVerifyFn(saveUserFn?: SaveUserFunction) { +export function buildVerifyFn(saveUserFn: SaveSSOUserFunction) { return ( accessToken: string, refreshToken: string, profile: SSOProfile, done: Function ) => { - const thirdPartyUser = { - provider: profile.provider, // should always be 'google' - providerType: "google", + const details: SSOAuthDetails = { + provider: "google", + providerType: SSOProviderType.GOOGLE, userId: profile.id, profile: profile, email: profile._json.email, @@ -22,8 +30,8 @@ export function buildVerifyFn(saveUserFn?: SaveUserFunction) { }, } - return authenticateThirdParty( - thirdPartyUser, + return sso.authenticate( + details, true, // require local accounts to exist done, saveUserFn @@ -39,7 +47,7 @@ export function buildVerifyFn(saveUserFn?: SaveUserFunction) { export async function strategyFactory( config: GoogleConfig["config"], callbackUrl: string, - saveUserFn?: SaveUserFunction + saveUserFn: SaveSSOUserFunction ) { try { const { clientID, clientSecret } = config diff --git a/packages/backend-core/src/middleware/passport/oidc.ts b/packages/backend-core/src/middleware/passport/sso/oidc.ts similarity index 85% rename from packages/backend-core/src/middleware/passport/oidc.ts rename to packages/backend-core/src/middleware/passport/sso/oidc.ts index 7caa177cf0..1fb44b84a3 100644 --- a/packages/backend-core/src/middleware/passport/oidc.ts +++ b/packages/backend-core/src/middleware/passport/sso/oidc.ts @@ -1,22 +1,20 @@ import fetch from "node-fetch" -import { authenticateThirdParty, SaveUserFunction } from "./third-party-common" -import { ssoCallbackUrl } from "./utils" +import * as sso from "./sso" +import { ssoCallbackUrl } from "../utils" import { ConfigType, - OIDCInnerCfg, + OIDCInnerConfig, Database, SSOProfile, - ThirdPartyUser, - OIDCConfiguration, + OIDCStrategyConfiguration, + SSOAuthDetails, + SSOProviderType, + JwtClaims, + SaveSSOUserFunction, } from "@budibase/types" const OIDCStrategy = require("@techpass/passport-openidconnect").Strategy -type JwtClaims = { - preferred_username: string - email: string -} - -export function buildVerifyFn(saveUserFn?: SaveUserFunction) { +export function buildVerifyFn(saveUserFn: SaveSSOUserFunction) { /** * @param {*} issuer The identity provider base URL * @param {*} sub The user ID @@ -39,10 +37,10 @@ export function buildVerifyFn(saveUserFn?: SaveUserFunction) { params: any, done: Function ) => { - const thirdPartyUser: ThirdPartyUser = { + const details: SSOAuthDetails = { // store the issuer info to enable sync in future provider: issuer, - providerType: "oidc", + providerType: SSOProviderType.OIDC, userId: profile.id, profile: profile, email: getEmail(profile, jwtClaims), @@ -52,8 +50,8 @@ export function buildVerifyFn(saveUserFn?: SaveUserFunction) { }, } - return authenticateThirdParty( - thirdPartyUser, + return sso.authenticate( + details, false, // don't require local accounts to exist done, saveUserFn @@ -104,8 +102,8 @@ function validEmail(value: string) { * @returns Dynamically configured Passport OIDC Strategy */ export async function strategyFactory( - config: OIDCConfiguration, - saveUserFn?: SaveUserFunction + config: OIDCStrategyConfiguration, + saveUserFn: SaveSSOUserFunction ) { try { const verify = buildVerifyFn(saveUserFn) @@ -119,14 +117,14 @@ export async function strategyFactory( } export async function fetchStrategyConfig( - enrichedConfig: OIDCInnerCfg, + oidcConfig: OIDCInnerConfig, callbackUrl?: string -): Promise { +): Promise { try { - const { clientID, clientSecret, configUrl } = enrichedConfig + const { clientID, clientSecret, configUrl } = oidcConfig if (!clientID || !clientSecret || !callbackUrl || !configUrl) { - //check for remote config and all required elements + // check for remote config and all required elements throw new Error( "Configuration invalid. Must contain clientID, clientSecret, callbackUrl and configUrl" ) diff --git a/packages/backend-core/src/middleware/passport/sso/sso.ts b/packages/backend-core/src/middleware/passport/sso/sso.ts new file mode 100644 index 0000000000..2fc1184722 --- /dev/null +++ b/packages/backend-core/src/middleware/passport/sso/sso.ts @@ -0,0 +1,165 @@ +import { generateGlobalUserID } from "../../../db" +import { authError } from "../utils" +import * as users from "../../../users" +import * as context from "../../../context" +import fetch from "node-fetch" +import { + SaveSSOUserFunction, + SaveUserOpts, + SSOAuthDetails, + SSOUser, + User, +} from "@budibase/types" + +// no-op function for user save +// - this allows datasource auth and access token refresh to work correctly +// - prefer no-op over an optional argument to ensure function is provided to login flows +export const ssoSaveUserNoOp: SaveSSOUserFunction = ( + user: SSOUser, + opts: SaveUserOpts +) => Promise.resolve(user) + +/** + * Common authentication logic for third parties. e.g. OAuth, OIDC. + */ +export async function authenticate( + details: SSOAuthDetails, + requireLocalAccount: boolean = true, + done: any, + saveUserFn: SaveSSOUserFunction +) { + if (!saveUserFn) { + throw new Error("Save user function must be provided") + } + if (!details.userId) { + return authError(done, "sso user id required") + } + if (!details.email) { + return authError(done, "sso user email required") + } + + // use the third party id + const userId = generateGlobalUserID(details.userId) + + let dbUser: User | undefined + + // try to load by id + try { + dbUser = await users.getById(userId) + } catch (err: any) { + // abort when not 404 error + if (!err.status || err.status !== 404) { + return authError( + done, + "Unexpected error when retrieving existing user", + err + ) + } + } + + // fallback to loading by email + if (!dbUser) { + dbUser = await users.getGlobalUserByEmail(details.email) + } + + // exit early if there is still no user and auto creation is disabled + if (!dbUser && requireLocalAccount) { + return authError( + done, + "Email does not yet exist. You must set up your local budibase account first." + ) + } + + // first time creation + if (!dbUser) { + // setup a blank user using the third party id + dbUser = { + _id: userId, + email: details.email, + roles: {}, + tenantId: context.getTenantId(), + } + } + + let ssoUser = await syncUser(dbUser, details) + // never prompt for password reset + ssoUser.forceResetPassword = false + + try { + // don't try to re-save any existing password + delete ssoUser.password + // create or sync the user + ssoUser = (await saveUserFn(ssoUser, { + hashPassword: false, + requirePassword: false, + })) as SSOUser + } catch (err: any) { + return authError(done, "Error saving user", err) + } + + return done(null, ssoUser) +} + +async function getProfilePictureUrl(user: User, details: SSOAuthDetails) { + const pictureUrl = details.profile?._json.picture + if (pictureUrl) { + const response = await fetch(pictureUrl) + if (response.status === 200) { + const type = response.headers.get("content-type") as string + if (type.startsWith("image/")) { + return pictureUrl + } + } + } +} + +/** + * @returns a user that has been sync'd with third party information + */ +async function syncUser(user: User, details: SSOAuthDetails): Promise { + let firstName + let lastName + let pictureUrl + let oauth2 + let thirdPartyProfile + + if (details.profile) { + const profile = details.profile + + if (profile.name) { + const name = profile.name + // first name + if (name.givenName) { + firstName = name.givenName + } + // last name + if (name.familyName) { + lastName = name.familyName + } + } + + pictureUrl = await getProfilePictureUrl(user, details) + + thirdPartyProfile = { + ...profile._json, + } + } + + // oauth tokens for future use + if (details.oauth2) { + oauth2 = { + ...details.oauth2, + } + } + + return { + ...user, + provider: details.provider, + providerType: details.providerType, + firstName, + lastName, + thirdPartyProfile, + pictureUrl, + oauth2, + } +} diff --git a/packages/backend-core/src/middleware/passport/sso/tests/google.spec.ts b/packages/backend-core/src/middleware/passport/sso/tests/google.spec.ts new file mode 100644 index 0000000000..eb8ffc9b71 --- /dev/null +++ b/packages/backend-core/src/middleware/passport/sso/tests/google.spec.ts @@ -0,0 +1,67 @@ +import { generator, structures } from "../../../../../tests" +import { SSOProviderType } from "@budibase/types" + +jest.mock("passport-google-oauth") +const mockStrategy = require("passport-google-oauth").OAuth2Strategy + +jest.mock("../sso") +import * as _sso from "../sso" +const sso = jest.mocked(_sso) + +const mockSaveUserFn = jest.fn() +const mockDone = jest.fn() + +import * as google from "../google" + +describe("google", () => { + describe("strategyFactory", () => { + const googleConfig = structures.sso.googleConfig() + const callbackUrl = generator.url() + + it("should create successfully create a google strategy", async () => { + await google.strategyFactory(googleConfig, callbackUrl) + + const expectedOptions = { + clientID: googleConfig.clientID, + clientSecret: googleConfig.clientSecret, + callbackURL: callbackUrl, + } + + expect(mockStrategy).toHaveBeenCalledWith( + expectedOptions, + expect.anything() + ) + }) + }) + + describe("authenticate", () => { + const details = structures.sso.authDetails() + details.provider = "google" + details.providerType = SSOProviderType.GOOGLE + + const profile = details.profile! + profile.provider = "google" + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("delegates authentication to third party common", async () => { + const authenticate = await google.buildVerifyFn(mockSaveUserFn) + + await authenticate( + details.oauth2.accessToken, + details.oauth2.refreshToken!, + profile, + mockDone + ) + + expect(sso.authenticate).toHaveBeenCalledWith( + details, + true, + mockDone, + mockSaveUserFn + ) + }) + }) +}) diff --git a/packages/backend-core/src/middleware/passport/sso/tests/oidc.spec.ts b/packages/backend-core/src/middleware/passport/sso/tests/oidc.spec.ts new file mode 100644 index 0000000000..a705739bd6 --- /dev/null +++ b/packages/backend-core/src/middleware/passport/sso/tests/oidc.spec.ts @@ -0,0 +1,152 @@ +import { generator, mocks, structures } from "../../../../../tests" +import { + JwtClaims, + OIDCInnerConfig, + SSOAuthDetails, + SSOProviderType, +} from "@budibase/types" +import * as _sso from "../sso" +import * as oidc from "../oidc" + +jest.mock("@techpass/passport-openidconnect") +const mockStrategy = require("@techpass/passport-openidconnect").Strategy + +jest.mock("../sso") +const sso = jest.mocked(_sso) + +const mockSaveUser = jest.fn() +const mockDone = jest.fn() + +describe("oidc", () => { + const callbackUrl = generator.url() + const oidcConfig: OIDCInnerConfig = structures.sso.oidcConfig() + const wellKnownConfig = structures.sso.oidcWellKnownConfig() + + function mockRetrieveWellKnownConfig() { + // mock the request to retrieve the oidc configuration + mocks.fetch.mockReturnValue({ + ok: true, + json: () => wellKnownConfig, + }) + } + + beforeEach(() => { + mockRetrieveWellKnownConfig() + }) + + describe("strategyFactory", () => { + it("should create successfully create an oidc strategy", async () => { + const strategyConfiguration = await oidc.fetchStrategyConfig( + oidcConfig, + callbackUrl + ) + await oidc.strategyFactory(strategyConfiguration, mockSaveUser) + + expect(mocks.fetch).toHaveBeenCalledWith(oidcConfig.configUrl) + + const expectedOptions = { + issuer: wellKnownConfig.issuer, + authorizationURL: wellKnownConfig.authorization_endpoint, + tokenURL: wellKnownConfig.token_endpoint, + userInfoURL: wellKnownConfig.userinfo_endpoint, + clientID: oidcConfig.clientID, + clientSecret: oidcConfig.clientSecret, + callbackURL: callbackUrl, + } + expect(mockStrategy).toHaveBeenCalledWith( + expectedOptions, + expect.anything() + ) + }) + }) + + describe("authenticate", () => { + const details: SSOAuthDetails = structures.sso.authDetails() + details.providerType = SSOProviderType.OIDC + const profile = details.profile! + const issuer = profile.provider + + const sub = generator.string() + const idToken = generator.string() + const params = {} + + let authenticateFn: any + let jwtClaims: JwtClaims + + beforeEach(async () => { + jest.clearAllMocks() + authenticateFn = await oidc.buildVerifyFn(mockSaveUser) + }) + + async function authenticate() { + await authenticateFn( + issuer, + sub, + profile, + jwtClaims, + details.oauth2.accessToken, + details.oauth2.refreshToken, + idToken, + params, + mockDone + ) + } + + it("passes auth details to sso module", async () => { + await authenticate() + + expect(sso.authenticate).toHaveBeenCalledWith( + details, + false, + mockDone, + mockSaveUser + ) + }) + + it("uses JWT email to get email", async () => { + delete profile._json.email + + jwtClaims = { + email: details.email, + } + + await authenticate() + + expect(sso.authenticate).toHaveBeenCalledWith( + details, + false, + mockDone, + mockSaveUser + ) + }) + + it("uses JWT username to get email", async () => { + delete profile._json.email + + jwtClaims = { + email: details.email, + } + + await authenticate() + + expect(sso.authenticate).toHaveBeenCalledWith( + details, + false, + mockDone, + mockSaveUser + ) + }) + + it("uses JWT invalid username to get email", async () => { + delete profile._json.email + + jwtClaims = { + preferred_username: "invalidUsername", + } + + await expect(authenticate()).rejects.toThrow( + "Could not determine user email from profile" + ) + }) + }) +}) diff --git a/packages/backend-core/src/middleware/passport/sso/tests/sso.spec.ts b/packages/backend-core/src/middleware/passport/sso/tests/sso.spec.ts new file mode 100644 index 0000000000..ae42fc01ea --- /dev/null +++ b/packages/backend-core/src/middleware/passport/sso/tests/sso.spec.ts @@ -0,0 +1,196 @@ +import { structures, testEnv, mocks } from "../../../../../tests" +import { SSOAuthDetails, User } from "@budibase/types" + +import { HTTPError } from "../../../../errors" +import * as sso from "../sso" +import * as context from "../../../../context" + +const mockDone = jest.fn() +const mockSaveUser = jest.fn() + +jest.mock("../../../../users") +import * as _users from "../../../../users" +const users = jest.mocked(_users) + +const getErrorMessage = () => { + return mockDone.mock.calls[0][2].message +} + +describe("sso", () => { + describe("authenticate", () => { + beforeEach(() => { + jest.clearAllMocks() + testEnv.singleTenant() + }) + + describe("validation", () => { + const testValidation = async ( + details: SSOAuthDetails, + message: string + ) => { + await sso.authenticate(details, false, mockDone, mockSaveUser) + + expect(mockDone.mock.calls.length).toBe(1) + expect(getErrorMessage()).toContain(message) + } + + it("user id fails", async () => { + const details = structures.sso.authDetails() + details.userId = undefined! + + await testValidation(details, "sso user id required") + }) + + it("email fails", async () => { + const details = structures.sso.authDetails() + details.email = undefined! + + await testValidation(details, "sso user email required") + }) + }) + + function mockGetProfilePicture() { + mocks.fetch.mockReturnValueOnce( + Promise.resolve({ + status: 200, + headers: { get: () => "image/" }, + }) + ) + } + + describe("when the user doesn't exist", () => { + let user: User + let details: SSOAuthDetails + + beforeEach(() => { + users.getById.mockImplementationOnce(() => { + throw new HTTPError("", 404) + }) + mockGetProfilePicture() + + user = structures.users.user() + delete user._rev + delete user._id + + details = structures.sso.authDetails(user) + details.userId = structures.uuid() + }) + + describe("when a local account is required", () => { + it("returns an error message", async () => { + const details = structures.sso.authDetails() + + await sso.authenticate(details, true, mockDone, mockSaveUser) + + expect(mockDone.mock.calls.length).toBe(1) + expect(getErrorMessage()).toContain( + "Email does not yet exist. You must set up your local budibase account first." + ) + }) + }) + + describe("when a local account isn't required", () => { + it("creates and authenticates the user", async () => { + const ssoUser = structures.users.ssoUser({ user, details }) + mockSaveUser.mockReturnValueOnce(ssoUser) + + await sso.authenticate(details, false, mockDone, mockSaveUser) + + // default roles for new user + ssoUser.roles = {} + + // modified external id to match user format + ssoUser._id = "us_" + details.userId + + // new sso user won't have a password + delete ssoUser.password + + // new user isn't saved with rev + delete ssoUser._rev + + // tenant id added + ssoUser.tenantId = context.getTenantId() + + expect(mockSaveUser).toBeCalledWith(ssoUser, { + hashPassword: false, + requirePassword: false, + }) + expect(mockDone).toBeCalledWith(null, ssoUser) + }) + }) + }) + + describe("when the user exists", () => { + let existingUser: User + let details: SSOAuthDetails + + beforeEach(() => { + existingUser = structures.users.user() + existingUser._id = structures.uuid() + details = structures.sso.authDetails(existingUser) + mockGetProfilePicture() + }) + + describe("exists by email", () => { + beforeEach(() => { + users.getById.mockImplementationOnce(() => { + throw new HTTPError("", 404) + }) + users.getGlobalUserByEmail.mockReturnValueOnce( + Promise.resolve(existingUser) + ) + }) + + it("syncs and authenticates the user", async () => { + const ssoUser = structures.users.ssoUser({ + user: existingUser, + details, + }) + mockSaveUser.mockReturnValueOnce(ssoUser) + + await sso.authenticate(details, true, mockDone, mockSaveUser) + + // roles preserved + ssoUser.roles = existingUser.roles + + // existing id preserved + ssoUser._id = existingUser._id + + expect(mockSaveUser).toBeCalledWith(ssoUser, { + hashPassword: false, + requirePassword: false, + }) + expect(mockDone).toBeCalledWith(null, ssoUser) + }) + }) + + describe("exists by id", () => { + beforeEach(() => { + users.getById.mockReturnValueOnce(Promise.resolve(existingUser)) + }) + + it("syncs and authenticates the user", async () => { + const ssoUser = structures.users.ssoUser({ + user: existingUser, + details, + }) + mockSaveUser.mockReturnValueOnce(ssoUser) + + await sso.authenticate(details, true, mockDone, mockSaveUser) + + // roles preserved + ssoUser.roles = existingUser.roles + + // existing id preserved + ssoUser._id = existingUser._id + + expect(mockSaveUser).toBeCalledWith(ssoUser, { + hashPassword: false, + requirePassword: false, + }) + expect(mockDone).toBeCalledWith(null, ssoUser) + }) + }) + }) + }) +}) diff --git a/packages/backend-core/src/middleware/passport/tests/google.spec.js b/packages/backend-core/src/middleware/passport/tests/google.spec.js deleted file mode 100644 index c5580ea309..0000000000 --- a/packages/backend-core/src/middleware/passport/tests/google.spec.js +++ /dev/null @@ -1,79 +0,0 @@ -// Mock data - -const { data } = require("./utilities/mock-data") - -const TENANT_ID = "default" - -const googleConfig = { - clientID: data.clientID, - clientSecret: data.clientSecret, -} - -const profile = { - id: "mockId", - _json: { - email : data.email - }, - provider: "google" -} - -const user = data.buildThirdPartyUser("google", "google", profile) - -describe("google", () => { - describe("strategyFactory", () => { - // mock passport strategy factory - jest.mock("passport-google-oauth") - const mockStrategy = require("passport-google-oauth").OAuth2Strategy - - it("should create successfully create a google strategy", async () => { - const google = require("../google") - - const callbackUrl = `/api/global/auth/${TENANT_ID}/google/callback` - await google.strategyFactory(googleConfig, callbackUrl) - - const expectedOptions = { - clientID: googleConfig.clientID, - clientSecret: googleConfig.clientSecret, - callbackURL: callbackUrl, - } - - expect(mockStrategy).toHaveBeenCalledWith( - expectedOptions, - expect.anything() - ) - }) - }) - - describe("authenticate", () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - // mock third party common authentication - jest.mock("../third-party-common") - const authenticateThirdParty = require("../third-party-common").authenticateThirdParty - - // mock the passport callback - const mockDone = jest.fn() - - it("delegates authentication to third party common", async () => { - const google = require("../google") - const mockSaveUserFn = jest.fn() - const authenticate = await google.buildVerifyFn(mockSaveUserFn) - - await authenticate( - data.accessToken, - data.refreshToken, - profile, - mockDone - ) - - expect(authenticateThirdParty).toHaveBeenCalledWith( - user, - true, - mockDone, - mockSaveUserFn) - }) - }) -}) - diff --git a/packages/backend-core/src/middleware/passport/tests/oidc.spec.js b/packages/backend-core/src/middleware/passport/tests/oidc.spec.js deleted file mode 100644 index 4c8aa94ddf..0000000000 --- a/packages/backend-core/src/middleware/passport/tests/oidc.spec.js +++ /dev/null @@ -1,144 +0,0 @@ -// Mock data -const mockFetch = require("node-fetch") -const { data } = require("./utilities/mock-data") -const issuer = "mockIssuer" -const sub = "mockSub" -const profile = { - id: "mockId", - _json: { - email : data.email - } -} -let jwtClaims = {} -const idToken = "mockIdToken" -const params = {} - -const callbackUrl = "http://somecallbackurl" - -// response from .well-known/openid-configuration -const oidcConfigUrlResponse = { - issuer: issuer, - authorization_endpoint: "mockAuthorizationEndpoint", - token_endpoint: "mockTokenEndpoint", - userinfo_endpoint: "mockUserInfoEndpoint" -} - -const oidcConfig = { - configUrl: "http://someconfigurl", - clientID: data.clientID, - clientSecret: data.clientSecret, -} - -const user = data.buildThirdPartyUser(issuer, "oidc", profile) - -describe("oidc", () => { - describe("strategyFactory", () => { - // mock passport strategy factory - jest.mock("@techpass/passport-openidconnect") - const mockStrategy = require("@techpass/passport-openidconnect").Strategy - - // mock the request to retrieve the oidc configuration - mockFetch.mockReturnValue({ - ok: true, - json: () => oidcConfigUrlResponse - }) - - it("should create successfully create an oidc strategy", async () => { - const oidc = require("../oidc") - const enrichedConfig = await oidc.fetchStrategyConfig(oidcConfig, callbackUrl) - await oidc.strategyFactory(enrichedConfig, callbackUrl) - - expect(mockFetch).toHaveBeenCalledWith(oidcConfig.configUrl) - - const expectedOptions = { - issuer: oidcConfigUrlResponse.issuer, - authorizationURL: oidcConfigUrlResponse.authorization_endpoint, - tokenURL: oidcConfigUrlResponse.token_endpoint, - userInfoURL: oidcConfigUrlResponse.userinfo_endpoint, - clientID: oidcConfig.clientID, - clientSecret: oidcConfig.clientSecret, - callbackURL: callbackUrl, - } - expect(mockStrategy).toHaveBeenCalledWith( - expectedOptions, - expect.anything() - ) - }) - }) - - describe("authenticate", () => { - afterEach(() => { - jest.clearAllMocks() - }); - - // mock third party common authentication - jest.mock("../third-party-common") - const authenticateThirdParty = require("../third-party-common").authenticateThirdParty - - // mock the passport callback - const mockDone = jest.fn() - const mockSaveUserFn = jest.fn() - - async function doAuthenticate() { - const oidc = require("../oidc") - const authenticate = await oidc.buildVerifyFn(mockSaveUserFn) - - await authenticate( - issuer, - sub, - profile, - jwtClaims, - data.accessToken, - data.refreshToken, - idToken, - params, - mockDone - ) - } - - async function doTest() { - await doAuthenticate() - - expect(authenticateThirdParty).toHaveBeenCalledWith( - user, - false, - mockDone, - mockSaveUserFn, - ) - } - - it("delegates authentication to third party common", async () => { - await doTest() - }) - - it("uses JWT email to get email", async () => { - delete profile._json.email - jwtClaims = { - email : "mock@budibase.com" - } - - await doTest() - }) - - it("uses JWT username to get email", async () => { - delete profile._json.email - jwtClaims = { - preferred_username : "mock@budibase.com" - } - - await doTest() - }) - - it("uses JWT invalid username to get email", async () => { - delete profile._json.email - - jwtClaims = { - preferred_username : "invalidUsername" - } - - await expect(doAuthenticate()).rejects.toThrow("Could not determine user email from profile"); - }) - - }) -}) - diff --git a/packages/backend-core/src/middleware/passport/tests/third-party-common.seq.spec.js b/packages/backend-core/src/middleware/passport/tests/third-party-common.seq.spec.js deleted file mode 100644 index d377d602f1..0000000000 --- a/packages/backend-core/src/middleware/passport/tests/third-party-common.seq.spec.js +++ /dev/null @@ -1,178 +0,0 @@ -require("../../../../tests") -const { authenticateThirdParty } = require("../third-party-common") -const { data } = require("./utilities/mock-data") -const { DEFAULT_TENANT_ID } = require("../../../constants") - -const { generateGlobalUserID } = require("../../../db/utils") -const { newid } = require("../../../utils") -const { doWithGlobalDB, doInTenant } = require("../../../tenancy") - -const done = jest.fn() - -const getErrorMessage = () => { - return done.mock.calls[0][2].message -} - -const saveUser = async (user) => { - return doWithGlobalDB(DEFAULT_TENANT_ID, async db => { - return await db.put(user) - }) -} - -function authenticate(user, requireLocal, saveFn) { - return doInTenant(DEFAULT_TENANT_ID, () => { - return authenticateThirdParty(user, requireLocal, done, saveFn) - }) -} - -describe("third party common", () => { - describe("authenticateThirdParty", () => { - let thirdPartyUser - - beforeEach(() => { - thirdPartyUser = data.buildThirdPartyUser() - }) - - afterEach(async () => { - return doWithGlobalDB(DEFAULT_TENANT_ID, async db => { - jest.clearAllMocks() - await db.destroy() - }) - }) - - describe("validation", () => { - const testValidation = async (message) => { - await authenticate(thirdPartyUser, false, saveUser) - expect(done.mock.calls.length).toBe(1) - expect(getErrorMessage()).toContain(message) - } - - it("provider fails", async () => { - delete thirdPartyUser.provider - await testValidation("third party user provider required") - }) - - it("user id fails", async () => { - delete thirdPartyUser.userId - await testValidation("third party user id required") - }) - - it("email fails", async () => { - delete thirdPartyUser.email - await testValidation("third party user email required") - }) - }) - - const expectUserIsAuthenticated = () => { - const user = done.mock.calls[0][1] - expect(user).toBeDefined() - expect(user._id).toBeDefined() - expect(user._rev).toBeDefined() - expect(user.token).toBeDefined() - return user - } - - const expectUserIsSynced = (user, thirdPartyUser) => { - expect(user.provider).toBe(thirdPartyUser.provider) - expect(user.firstName).toBe(thirdPartyUser.profile.name.givenName) - expect(user.lastName).toBe(thirdPartyUser.profile.name.familyName) - expect(user.thirdPartyProfile).toStrictEqual(thirdPartyUser.profile._json) - expect(user.oauth2).toStrictEqual(thirdPartyUser.oauth2) - } - - describe("when the user doesn't exist", () => { - describe("when a local account is required", () => { - it("returns an error message", async () => { - await authenticate(thirdPartyUser, true, saveUser) - expect(done.mock.calls.length).toBe(1) - expect(getErrorMessage()).toContain("Email does not yet exist. You must set up your local budibase account first.") - }) - }) - - describe("when a local account isn't required", () => { - it("creates and authenticates the user", async () => { - await authenticate(thirdPartyUser, false, saveUser) - const user = expectUserIsAuthenticated() - expectUserIsSynced(user, thirdPartyUser) - expect(user.roles).toStrictEqual({}) - }) - }) - }) - - describe("when the user exists", () => { - let dbUser - let id - let email - - const createUser = async () => { - return doWithGlobalDB(DEFAULT_TENANT_ID, async db => { - dbUser = { - _id: id, - email: email, - } - const response = await db.put(dbUser) - dbUser._rev = response.rev - return dbUser - }) - } - - const expectUserIsUpdated = (user) => { - // id is unchanged - expect(user._id).toBe(id) - // user is updated - expect(user._rev).not.toBe(dbUser._rev) - } - - describe("exists by email", () => { - beforeEach(async () => { - id = generateGlobalUserID(newid()) // random id - email = thirdPartyUser.email // matching email - await createUser() - }) - - it("syncs and authenticates the user", async () => { - await authenticate(thirdPartyUser, true, saveUser) - - const user = expectUserIsAuthenticated() - expectUserIsSynced(user, thirdPartyUser) - expectUserIsUpdated(user) - }) - }) - - describe("exists by email with different casing", () => { - beforeEach(async () => { - id = generateGlobalUserID(newid()) // random id - email = thirdPartyUser.email.toUpperCase() // matching email except for casing - await createUser() - }) - - it("syncs and authenticates the user", async () => { - await authenticate(thirdPartyUser, true, saveUser) - - const user = expectUserIsAuthenticated() - expectUserIsSynced(user, thirdPartyUser) - expectUserIsUpdated(user) - expect(user.email).toBe(thirdPartyUser.email.toUpperCase()) - }) - }) - - - describe("exists by id", () => { - beforeEach(async () => { - id = generateGlobalUserID(thirdPartyUser.userId) // matching id - email = "test@test.com" // random email - await createUser() - }) - - it("syncs and authenticates the user", async () => { - await authenticate(thirdPartyUser, true, saveUser) - - const user = expectUserIsAuthenticated() - expectUserIsSynced(user, thirdPartyUser) - expectUserIsUpdated(user) - }) - }) - }) - }) -}) - diff --git a/packages/backend-core/src/middleware/passport/tests/utilities/mock-data.js b/packages/backend-core/src/middleware/passport/tests/utilities/mock-data.js deleted file mode 100644 index 00ae82e47e..0000000000 --- a/packages/backend-core/src/middleware/passport/tests/utilities/mock-data.js +++ /dev/null @@ -1,54 +0,0 @@ -// Mock Data - -const mockClientID = "mockClientID" -const mockClientSecret = "mockClientSecret" - -const mockEmail = "mock@budibase.com" -const mockAccessToken = "mockAccessToken" -const mockRefreshToken = "mockRefreshToken" - -const mockProvider = "mockProvider" -const mockProviderType = "mockProviderType" - -const mockProfile = { - id: "mockId", - name: { - givenName: "mockGivenName", - familyName: "mockFamilyName", - }, - _json: { - email: mockEmail, - }, -} - -const buildOauth2 = ( - accessToken = mockAccessToken, - refreshToken = mockRefreshToken -) => ({ - accessToken: accessToken, - refreshToken: refreshToken, -}) - -const buildThirdPartyUser = ( - provider = mockProvider, - providerType = mockProviderType, - profile = mockProfile, - email = mockEmail, - oauth2 = buildOauth2() -) => ({ - provider: provider, - providerType: providerType, - userId: profile.id, - profile: profile, - email: email, - oauth2: oauth2, -}) - -exports.data = { - clientID: mockClientID, - clientSecret: mockClientSecret, - email: mockEmail, - accessToken: mockAccessToken, - refreshToken: mockRefreshToken, - buildThirdPartyUser, -} diff --git a/packages/backend-core/src/middleware/passport/third-party-common.ts b/packages/backend-core/src/middleware/passport/third-party-common.ts deleted file mode 100644 index 9d7b93f370..0000000000 --- a/packages/backend-core/src/middleware/passport/third-party-common.ts +++ /dev/null @@ -1,177 +0,0 @@ -import env from "../../environment" -import { generateGlobalUserID } from "../../db" -import { authError } from "./utils" -import { newid } from "../../utils" -import { createASession } from "../../security/sessions" -import * as users from "../../users" -import { getGlobalDB, getTenantId } from "../../tenancy" -import fetch from "node-fetch" -import { ThirdPartyUser } from "@budibase/types" -const jwt = require("jsonwebtoken") - -type SaveUserOpts = { - requirePassword?: boolean - hashPassword?: boolean - currentUserId?: string -} - -export type SaveUserFunction = ( - user: ThirdPartyUser, - opts: SaveUserOpts -) => Promise - -/** - * Common authentication logic for third parties. e.g. OAuth, OIDC. - */ -export async function authenticateThirdParty( - thirdPartyUser: ThirdPartyUser, - requireLocalAccount: boolean = true, - done: Function, - saveUserFn?: SaveUserFunction -) { - if (!saveUserFn) { - throw new Error("Save user function must be provided") - } - if (!thirdPartyUser.provider) { - return authError(done, "third party user provider required") - } - if (!thirdPartyUser.userId) { - return authError(done, "third party user id required") - } - if (!thirdPartyUser.email) { - return authError(done, "third party user email required") - } - - // use the third party id - const userId = generateGlobalUserID(thirdPartyUser.userId) - const db = getGlobalDB() - - let dbUser - - // try to load by id - try { - dbUser = await db.get(userId) - } catch (err: any) { - // abort when not 404 error - if (!err.status || err.status !== 404) { - return authError( - done, - "Unexpected error when retrieving existing user", - err - ) - } - } - - // fallback to loading by email - if (!dbUser) { - dbUser = await users.getGlobalUserByEmail(thirdPartyUser.email) - } - - // exit early if there is still no user and auto creation is disabled - if (!dbUser && requireLocalAccount) { - return authError( - done, - "Email does not yet exist. You must set up your local budibase account first." - ) - } - - // first time creation - if (!dbUser) { - // setup a blank user using the third party id - dbUser = { - _id: userId, - email: thirdPartyUser.email, - roles: {}, - } - } - - dbUser = await syncUser(dbUser, thirdPartyUser) - - // never prompt for password reset - dbUser.forceResetPassword = false - - // create or sync the user - try { - await saveUserFn(dbUser, { hashPassword: false, requirePassword: false }) - } catch (err: any) { - return authError(done, "Error saving user", err) - } - - // now that we're sure user exists, load them from the db - dbUser = await db.get(dbUser._id) - - // authenticate - const sessionId = newid() - const tenantId = getTenantId() - await createASession(dbUser._id, { sessionId, tenantId }) - - dbUser.token = jwt.sign( - { - userId: dbUser._id, - sessionId, - }, - env.JWT_SECRET - ) - - return done(null, dbUser) -} - -async function syncProfilePicture( - user: ThirdPartyUser, - thirdPartyUser: ThirdPartyUser -) { - const pictureUrl = thirdPartyUser.profile?._json.picture - if (pictureUrl) { - const response = await fetch(pictureUrl) - - if (response.status === 200) { - const type = response.headers.get("content-type") as string - if (type.startsWith("image/")) { - user.pictureUrl = pictureUrl - } - } - } - - return user -} - -/** - * @returns a user that has been sync'd with third party information - */ -async function syncUser(user: ThirdPartyUser, thirdPartyUser: ThirdPartyUser) { - // provider - user.provider = thirdPartyUser.provider - user.providerType = thirdPartyUser.providerType - - if (thirdPartyUser.profile) { - const profile = thirdPartyUser.profile - - if (profile.name) { - const name = profile.name - // first name - if (name.givenName) { - user.firstName = name.givenName - } - // last name - if (name.familyName) { - user.lastName = name.familyName - } - } - - user = await syncProfilePicture(user, thirdPartyUser) - - // profile - user.thirdPartyProfile = { - ...profile._json, - } - } - - // oauth tokens for future use - if (thirdPartyUser.oauth2) { - user.oauth2 = { - ...thirdPartyUser.oauth2, - } - } - - return user -} diff --git a/packages/backend-core/src/users.ts b/packages/backend-core/src/users.ts index 1720a79a83..ef76af390d 100644 --- a/packages/backend-core/src/users.ts +++ b/packages/backend-core/src/users.ts @@ -8,6 +8,7 @@ import { } from "./db" import { BulkDocsResponse, User } from "@budibase/types" import { getGlobalDB } from "./context" +import * as context from "./context" export const bulkGetGlobalUsersById = async (userIds: string[]) => { const db = getGlobalDB() @@ -24,6 +25,11 @@ export const bulkUpdateGlobalUsers = async (users: User[]) => { return (await db.bulkDocs(users)) as BulkDocsResponse } +export async function getById(id: string): Promise { + const db = context.getGlobalDB() + return db.get(id) +} + /** * Given an email address this will use a view to search through * all the users to find one with this email address. diff --git a/packages/backend-core/src/utils/utils.ts b/packages/backend-core/src/utils/utils.ts index c608686431..3731e134ad 100644 --- a/packages/backend-core/src/utils/utils.ts +++ b/packages/backend-core/src/utils/utils.ts @@ -2,23 +2,15 @@ import { getAllApps, queryGlobalView } from "../db" import { options } from "../middleware/passport/jwt" import { Header, - Cookie, MAX_VALID_DATE, DocumentType, SEPARATOR, ViewName, } from "../constants" import env from "../environment" -import * as userCache from "../cache/user" -import { getSessionsForUser, invalidateSessions } from "../security/sessions" -import * as events from "../events" import * as tenancy from "../tenancy" -import { - App, - Ctx, - PlatformLogoutOpts, - TenantResolutionStrategy, -} from "@budibase/types" +import * as context from "../context" +import { App, Ctx, TenantResolutionStrategy } from "@budibase/types" import { SetOption } from "cookies" const jwt = require("jsonwebtoken") @@ -38,7 +30,7 @@ export async function resolveAppUrl(ctx: Ctx) { const appUrl = ctx.path.split("/")[2] let possibleAppUrl = `/${appUrl.toLowerCase()}` - let tenantId: string | null = tenancy.getTenantId() + let tenantId: string | null = context.getTenantId() if (env.MULTI_TENANCY) { // always use the tenant id from the subdomain in multi tenancy // this ensures the logged-in user tenant id doesn't overwrite @@ -49,7 +41,7 @@ export async function resolveAppUrl(ctx: Ctx) { } // search prod apps for a url that matches - const apps: App[] = await tenancy.doInTenant(tenantId, () => + const apps: App[] = await context.doInTenant(tenantId, () => getAllApps({ dev: false }) ) const app = apps.filter( @@ -222,35 +214,6 @@ export async function getBuildersCount() { return builders.length } -/** - * Logs a user out from budibase. Re-used across account portal and builder. - */ -export async function platformLogout(opts: PlatformLogoutOpts) { - const ctx = opts.ctx - const userId = opts.userId - const keepActiveSession = opts.keepActiveSession - - if (!ctx) throw new Error("Koa context must be supplied to logout.") - - const currentSession = getCookie(ctx, Cookie.Auth) - let sessions = await getSessionsForUser(userId) - - if (keepActiveSession) { - sessions = sessions.filter( - session => session.sessionId !== currentSession.sessionId - ) - } else { - // clear cookies - clearCookie(ctx, Cookie.Auth) - clearCookie(ctx, Cookie.CurrentApp) - } - - const sessionIds = sessions.map(({ sessionId }) => sessionId) - await invalidateSessions(userId, { sessionIds, reason: "logout" }) - await events.auth.logout() - await userCache.invalidateUser(userId) -} - export function timeout(timeMs: number) { return new Promise(resolve => setTimeout(resolve, timeMs)) } diff --git a/packages/backend-core/tests/utilities/mocks/accounts.ts b/packages/backend-core/tests/utilities/mocks/accounts.ts deleted file mode 100644 index e40d32b276..0000000000 --- a/packages/backend-core/tests/utilities/mocks/accounts.ts +++ /dev/null @@ -1,13 +0,0 @@ -const mockGetAccount = jest.fn() -const mockGetAccountByTenantId = jest.fn() -const mockGetStatus = jest.fn() - -jest.mock("../../../src/cloud/accounts", () => ({ - getAccount: mockGetAccount, - getAccountByTenantId: mockGetAccountByTenantId, - getStatus: mockGetStatus, -})) - -export const getAccount = mockGetAccount -export const getAccountByTenantId = mockGetAccountByTenantId -export const getStatus = mockGetStatus diff --git a/packages/backend-core/tests/utilities/mocks/index.ts b/packages/backend-core/tests/utilities/mocks/index.ts index 401fd7d7a7..f5f45c0342 100644 --- a/packages/backend-core/tests/utilities/mocks/index.ts +++ b/packages/backend-core/tests/utilities/mocks/index.ts @@ -1,4 +1,7 @@ -export * as accounts from "./accounts" +jest.mock("../../../src/accounts") +import * as _accounts from "../../../src/accounts" +export const accounts = jest.mocked(_accounts) + export * as date from "./date" export * as licenses from "./licenses" export { default as fetch } from "./fetch" diff --git a/packages/backend-core/tests/utilities/structures/accounts.ts b/packages/backend-core/tests/utilities/structures/accounts.ts index f1718aecc0..6bfeedf196 100644 --- a/packages/backend-core/tests/utilities/structures/accounts.ts +++ b/packages/backend-core/tests/utilities/structures/accounts.ts @@ -1,6 +1,15 @@ import { generator, uuid } from "." import * as db from "../../../src/db/utils" -import { Account, AuthType, CloudAccount, Hosting } from "@budibase/types" +import { + Account, + AccountSSOProvider, + AccountSSOProviderType, + AuthType, + CloudAccount, + Hosting, + SSOAccount, +} from "@budibase/types" +import _ from "lodash" export const account = (): Account => { return { @@ -27,3 +36,28 @@ export const cloudAccount = (): CloudAccount => { budibaseUserId: db.generateGlobalUserID(), } } + +function providerType(): AccountSSOProviderType { + return _.sample( + Object.values(AccountSSOProviderType) + ) as AccountSSOProviderType +} + +function provider(): AccountSSOProvider { + return _.sample(Object.values(AccountSSOProvider)) as AccountSSOProvider +} + +export function ssoAccount(): SSOAccount { + return { + ...cloudAccount(), + authType: AuthType.SSO, + oauth2: { + accessToken: generator.string(), + refreshToken: generator.string(), + }, + pictureUrl: generator.url(), + provider: provider(), + providerType: providerType(), + thirdPartyProfile: {}, + } +} diff --git a/packages/backend-core/tests/utilities/structures/index.ts b/packages/backend-core/tests/utilities/structures/index.ts index e74751e479..d0073ba851 100644 --- a/packages/backend-core/tests/utilities/structures/index.ts +++ b/packages/backend-core/tests/utilities/structures/index.ts @@ -5,8 +5,10 @@ export const generator = new Chance() export * as accounts from "./accounts" export * as apps from "./apps" +export * as db from "./db" export * as koa from "./koa" export * as licenses from "./licenses" export * as plugins from "./plugins" +export * as sso from "./sso" export * as tenant from "./tenants" -export * as db from "./db" +export * as users from "./users" diff --git a/packages/backend-core/tests/utilities/structures/sso.ts b/packages/backend-core/tests/utilities/structures/sso.ts new file mode 100644 index 0000000000..ad5e8e87ef --- /dev/null +++ b/packages/backend-core/tests/utilities/structures/sso.ts @@ -0,0 +1,100 @@ +import { + GoogleInnerConfig, + JwtClaims, + OIDCInnerConfig, + OIDCWellKnownConfig, + SSOAuthDetails, + SSOProfile, + SSOProviderType, + User, +} from "@budibase/types" +import { uuid, generator, users, email } from "./index" +import _ from "lodash" + +export function providerType(): SSOProviderType { + return _.sample(Object.values(SSOProviderType)) as SSOProviderType +} + +export function ssoProfile(user?: User): SSOProfile { + if (!user) { + user = users.user() + } + return { + id: user._id!, + name: { + givenName: user.firstName, + familyName: user.lastName, + }, + _json: { + email: user.email, + picture: "http://test.com", + }, + provider: generator.string(), + } +} + +export function authDetails(user?: User): SSOAuthDetails { + if (!user) { + user = users.user() + } + + const userId = user._id || uuid() + const provider = generator.string() + + const profile = ssoProfile(user) + profile.provider = provider + profile.id = userId + + return { + email: user.email, + oauth2: { + refreshToken: generator.string(), + accessToken: generator.string(), + }, + profile, + provider, + providerType: providerType(), + userId, + } +} + +// OIDC + +export function oidcConfig(): OIDCInnerConfig { + return { + uuid: uuid(), + activated: true, + logo: "", + name: generator.string(), + configUrl: "http://someconfigurl", + clientID: generator.string(), + clientSecret: generator.string(), + } +} + +// response from .well-known/openid-configuration +export function oidcWellKnownConfig(): OIDCWellKnownConfig { + return { + issuer: generator.string(), + authorization_endpoint: generator.url(), + token_endpoint: generator.url(), + userinfo_endpoint: generator.url(), + } +} + +export function jwtClaims(): JwtClaims { + return { + email: email(), + preferred_username: email(), + } +} + +// GOOGLE + +export function googleConfig(): GoogleInnerConfig { + return { + activated: true, + clientID: generator.string(), + clientSecret: generator.string(), + } +} diff --git a/packages/backend-core/tests/utilities/structures/users.ts b/packages/backend-core/tests/utilities/structures/users.ts new file mode 100644 index 0000000000..332c27ca12 --- /dev/null +++ b/packages/backend-core/tests/utilities/structures/users.ts @@ -0,0 +1,70 @@ +import { generator } from "../" +import { + AdminUser, + BuilderUser, + SSOAuthDetails, + SSOUser, + User, +} from "@budibase/types" +import { v4 as uuid } from "uuid" +import * as sso from "./sso" + +export const newEmail = () => { + return `${uuid()}@test.com` +} + +export const user = (userProps?: any): User => { + return { + email: newEmail(), + password: "test", + roles: { app_test: "admin" }, + firstName: generator.first(), + lastName: generator.last(), + pictureUrl: "http://test.com", + ...userProps, + } +} + +export const adminUser = (userProps?: any): AdminUser => { + return { + ...user(userProps), + admin: { + global: true, + }, + builder: { + global: true, + }, + } +} + +export const builderUser = (userProps?: any): BuilderUser => { + return { + ...user(userProps), + builder: { + global: true, + }, + } +} + +export function ssoUser( + opts: { user?: any; details?: SSOAuthDetails } = {} +): SSOUser { + const base = user(opts.user) + delete base.password + + if (!opts.details) { + opts.details = sso.authDetails(base) + } + + return { + ...base, + forceResetPassword: false, + oauth2: opts.details?.oauth2, + provider: opts.details?.provider!, + providerType: opts.details?.providerType!, + thirdPartyProfile: { + email: base.email, + picture: base.pictureUrl, + }, + } +} diff --git a/packages/builder/src/pages/builder/portal/_components/UserDropdown.svelte b/packages/builder/src/pages/builder/portal/_components/UserDropdown.svelte index d396edd4e2..935d69812f 100644 --- a/packages/builder/src/pages/builder/portal/_components/UserDropdown.svelte +++ b/packages/builder/src/pages/builder/portal/_components/UserDropdown.svelte @@ -30,9 +30,11 @@ My profile themeModal.show()}>Theme - updatePasswordModal.show()}> - Update password - + {#if !$auth.isSSO} + updatePasswordModal.show()}> + Update password + + {/if} apiKeyModal.show()}> View API key diff --git a/packages/builder/src/pages/builder/portal/users/users/[userId].svelte b/packages/builder/src/pages/builder/portal/users/users/[userId].svelte index b14e3420cc..1c26273a8d 100644 --- a/packages/builder/src/pages/builder/portal/users/users/[userId].svelte +++ b/packages/builder/src/pages/builder/portal/users/users/[userId].svelte @@ -81,6 +81,7 @@ let user let loaded = false + $: isSSO = !!user?.provider $: readonly = !$auth.isAdmin $: fullName = user?.firstName ? user?.firstName + " " + user?.lastName : "" $: privileged = user?.admin?.global || user?.builder?.global @@ -246,9 +247,11 @@ - - Force password reset - + {#if !isSSO} + + Force password reset + + {/if} Delete diff --git a/packages/builder/src/stores/portal/auth.js b/packages/builder/src/stores/portal/auth.js index b10cd05e00..d039bb6eec 100644 --- a/packages/builder/src/stores/portal/auth.js +++ b/packages/builder/src/stores/portal/auth.js @@ -41,6 +41,7 @@ export function createAuthStore() { initials, isAdmin, isBuilder, + isSSO: !!$store.user?.provider, } }) diff --git a/packages/server/specs/openapi.json b/packages/server/specs/openapi.json index 9a0d69e352..cf4eef75a9 100644 --- a/packages/server/specs/openapi.json +++ b/packages/server/specs/openapi.json @@ -817,7 +817,6 @@ "type": "string", "enum": [ "string", - "barcodeqr", "longform", "options", "number", @@ -829,7 +828,8 @@ "formula", "auto", "json", - "internal" + "internal", + "barcodeqr" ], "description": "Defines the type of the column, most explain themselves, a link column is a relationship." }, @@ -1021,7 +1021,6 @@ "type": "string", "enum": [ "string", - "barcodeqr", "longform", "options", "number", @@ -1033,7 +1032,8 @@ "formula", "auto", "json", - "internal" + "internal", + "barcodeqr" ], "description": "Defines the type of the column, most explain themselves, a link column is a relationship." }, @@ -1236,7 +1236,6 @@ "type": "string", "enum": [ "string", - "barcodeqr", "longform", "options", "number", @@ -1248,7 +1247,8 @@ "formula", "auto", "json", - "internal" + "internal", + "barcodeqr" ], "description": "Defines the type of the column, most explain themselves, a link column is a relationship." }, diff --git a/packages/server/specs/openapi.yaml b/packages/server/specs/openapi.yaml index 69e44d881c..414efe7adb 100644 --- a/packages/server/specs/openapi.yaml +++ b/packages/server/specs/openapi.yaml @@ -603,7 +603,6 @@ components: type: string enum: - string - - barcodeqr - longform - options - number @@ -616,6 +615,7 @@ components: - auto - json - internal + - barcodeqr description: Defines the type of the column, most explain themselves, a link column is a relationship. constraints: @@ -766,7 +766,6 @@ components: type: string enum: - string - - barcodeqr - longform - options - number @@ -779,6 +778,7 @@ components: - auto - json - internal + - barcodeqr description: Defines the type of the column, most explain themselves, a link column is a relationship. constraints: @@ -936,7 +936,6 @@ components: type: string enum: - string - - barcodeqr - longform - options - number @@ -949,6 +948,7 @@ components: - auto - json - internal + - barcodeqr description: Defines the type of the column, most explain themselves, a link column is a relationship. constraints: diff --git a/packages/server/src/integrations/tests/couchdb.spec.ts b/packages/server/src/integrations/tests/couchdb.spec.ts index 0d744cd343..66735d7b74 100644 --- a/packages/server/src/integrations/tests/couchdb.spec.ts +++ b/packages/server/src/integrations/tests/couchdb.spec.ts @@ -1,5 +1,3 @@ -import { DatabaseWithConnection } from "@budibase/backend-core/src/db" - jest.mock("@budibase/backend-core", () => { const core = jest.requireActual("@budibase/backend-core") return { diff --git a/packages/types/src/api/account/index.ts b/packages/types/src/api/account/index.ts index 0cbc487bcc..4be610d1b3 100644 --- a/packages/types/src/api/account/index.ts +++ b/packages/types/src/api/account/index.ts @@ -1,2 +1,3 @@ export * from "./user" export * from "./license" +export * from "./status" diff --git a/packages/types/src/api/account/status.ts b/packages/types/src/api/account/status.ts new file mode 100644 index 0000000000..f6a0db7a5e --- /dev/null +++ b/packages/types/src/api/account/status.ts @@ -0,0 +1,7 @@ +export interface HealthStatusResponse { + passing: boolean + checks: { + login: boolean + search: boolean + } +} diff --git a/packages/types/src/api/web/auth.ts b/packages/types/src/api/web/auth.ts new file mode 100644 index 0000000000..e31a151c48 --- /dev/null +++ b/packages/types/src/api/web/auth.ts @@ -0,0 +1,25 @@ +export interface LoginRequest { + username: string + password: string +} + +export interface PasswordResetRequest { + email: string +} + +export interface PasswordResetUpdateRequest { + resetCode: string + password: string +} + +export interface UpdateSelfRequest { + firstName?: string + lastName?: string + password?: string + forceResetPassword?: boolean +} + +export interface UpdateSelfResponse { + _id: string + _rev: string +} diff --git a/packages/types/src/api/web/index.ts b/packages/types/src/api/web/index.ts index 9688a89c7b..8ed5b0aad4 100644 --- a/packages/types/src/api/web/index.ts +++ b/packages/types/src/api/web/index.ts @@ -1,4 +1,5 @@ export * from "./analytics" +export * from "./auth" export * from "./user" export * from "./errors" export * from "./schedule" diff --git a/packages/types/src/api/web/user.ts b/packages/types/src/api/web/user.ts index 6acaf6912d..a435808f7e 100644 --- a/packages/types/src/api/web/user.ts +++ b/packages/types/src/api/web/user.ts @@ -1,6 +1,6 @@ import { User } from "../../documents" -export interface CreateUserResponse { +export interface SaveUserResponse { _id: string _rev: string email: string @@ -58,6 +58,25 @@ export interface CreateAdminUserRequest { tenantId: string } +export interface CreateAdminUserResponse { + _id: string + _rev: string + email: string +} + +export interface AcceptUserInviteRequest { + inviteCode: string + password: string + firstName: string + lastName: string +} + +export interface AcceptUserInviteResponse { + _id: string + _rev: string + email: string +} + export interface SyncUserRequest { previousUser?: User } diff --git a/packages/types/src/documents/account/account.ts b/packages/types/src/documents/account/account.ts index a8684f8427..cacdc4e9a2 100644 --- a/packages/types/src/documents/account/account.ts +++ b/packages/types/src/documents/account/account.ts @@ -79,14 +79,24 @@ export const isSelfHostAccount = (account: Account) => export const isSSOAccount = (account: Account): account is SSOAccount => account.authType === AuthType.SSO -export interface SSOAccount extends Account { - pictureUrl?: string - provider?: string - providerType?: string +export enum AccountSSOProviderType { + GOOGLE = "google", +} + +export enum AccountSSOProvider { + GOOGLE = "google", +} + +export interface AccountSSO { + provider: AccountSSOProvider + providerType: AccountSSOProviderType oauth2?: OAuthTokens + pictureUrl?: string thirdPartyProfile: any // TODO: define what the google profile looks like } +export type SSOAccount = (Account | CloudAccount) & AccountSSO + export enum AuthType { SSO = "sso", PASSWORD = "password", diff --git a/packages/types/src/documents/global/config.ts b/packages/types/src/documents/global/config.ts index 9b05069c9a..99dec534b6 100644 --- a/packages/types/src/documents/global/config.ts +++ b/packages/types/src/documents/global/config.ts @@ -27,15 +27,17 @@ export interface SettingsConfig extends Config { } } -export interface GoogleConfig extends Config { - config: { - clientID: string - clientSecret: string - activated: boolean - } +export interface GoogleInnerConfig { + clientID: string + clientSecret: string + activated: boolean } -export interface OIDCConfiguration { +export interface GoogleConfig extends Config { + config: GoogleInnerConfig +} + +export interface OIDCStrategyConfiguration { issuer: string authorizationURL: string tokenURL: string @@ -45,7 +47,7 @@ export interface OIDCConfiguration { callbackURL: string } -export interface OIDCInnerCfg { +export interface OIDCInnerConfig { configUrl: string clientID: string clientSecret: string @@ -57,10 +59,17 @@ export interface OIDCInnerCfg { export interface OIDCConfig extends Config { config: { - configs: OIDCInnerCfg[] + configs: OIDCInnerConfig[] } } +export interface OIDCWellKnownConfig { + issuer: string + authorization_endpoint: string + token_endpoint: string + userinfo_endpoint: string +} + export const isSettingsConfig = (config: Config): config is SettingsConfig => config.type === ConfigType.SETTINGS diff --git a/packages/types/src/documents/global/user.ts b/packages/types/src/documents/global/user.ts index aef36c3469..2ef13a7412 100644 --- a/packages/types/src/documents/global/user.ts +++ b/packages/types/src/documents/global/user.ts @@ -1,37 +1,44 @@ import { Document } from "../document" -export interface SSOProfile { - id: string - name?: { - givenName?: string - familyName?: string - } - _json: { - email: string - picture: string - } - provider?: string +// SSO + +export interface SSOProfileJson { + email?: string + picture?: string } -export interface ThirdPartyUser extends Document { - thirdPartyProfile?: SSOProfile["_json"] - firstName?: string - lastName?: string - pictureUrl?: string - profile?: SSOProfile - oauth2?: any - provider?: string - providerType?: string - email: string - userId?: string - forceResetPassword?: boolean - userGroups?: string[] +export interface OAuth2 { + accessToken: string + refreshToken?: string } -export interface User extends ThirdPartyUser { +export enum SSOProviderType { + OIDC = "oidc", + GOOGLE = "google", +} + +export interface UserSSO { + provider: string // the individual provider e.g. Okta, Auth0, Google + providerType: SSOProviderType + oauth2?: OAuth2 + thirdPartyProfile?: SSOProfileJson +} + +export type SSOUser = User & UserSSO + +export function isSSOUser(user: User): user is SSOUser { + return !!(user as SSOUser).providerType +} + +// USER + +export interface User extends Document { tenantId: string email: string userId?: string + firstName?: string + lastName?: string + pictureUrl?: string forceResetPassword?: boolean roles: UserRoles builder?: { @@ -44,9 +51,7 @@ export interface User extends ThirdPartyUser { status?: string createdAt?: number // override the default createdAt behaviour - users sdk historically set this to Date.now() dayPassRecordedAt?: string - account?: { - authType: string - } + userGroups?: string[] onboardedAt?: string } @@ -54,7 +59,7 @@ export interface UserRoles { [key: string]: string } -// utility types +// UTILITY TYPES export interface BuilderUser extends User { builder: { diff --git a/packages/types/src/sdk/index.ts b/packages/types/src/sdk/index.ts index f8f9d9cb97..be12d45527 100644 --- a/packages/types/src/sdk/index.ts +++ b/packages/types/src/sdk/index.ts @@ -12,3 +12,5 @@ export * from "./db" export * from "./middleware" export * from "./featureFlag" export * from "./environmentVariables" +export * from "./sso" +export * from "./user" diff --git a/packages/types/src/sdk/sso.ts b/packages/types/src/sdk/sso.ts new file mode 100644 index 0000000000..2141204ccb --- /dev/null +++ b/packages/types/src/sdk/sso.ts @@ -0,0 +1,37 @@ +import { + OAuth2, + SSOProfileJson, + SSOProviderType, + SSOUser, + User, +} from "../documents" +import { SaveUserOpts } from "./user" + +export interface JwtClaims { + preferred_username?: string + email?: string +} + +export interface SSOAuthDetails { + oauth2: OAuth2 + provider: string + providerType: SSOProviderType + userId: string + email?: string + profile?: SSOProfile +} + +export interface SSOProfile { + id: string + name?: { + givenName?: string + familyName?: string + } + _json: SSOProfileJson + provider?: string +} + +export type SaveSSOUserFunction = ( + user: SSOUser, + opts: SaveUserOpts +) => Promise diff --git a/packages/types/src/sdk/user.ts b/packages/types/src/sdk/user.ts new file mode 100644 index 0000000000..1602eeb6c8 --- /dev/null +++ b/packages/types/src/sdk/user.ts @@ -0,0 +1,12 @@ +export interface UpdateSelf { + firstName?: string + lastName?: string + password?: string + forceResetPassword?: boolean +} + +export interface SaveUserOpts { + hashPassword?: boolean + requirePassword?: boolean + currentUserId?: string +} diff --git a/packages/worker/scripts/dev/manage.js b/packages/worker/scripts/dev/manage.js index 01cdef08ff..21a9eab9c6 100644 --- a/packages/worker/scripts/dev/manage.js +++ b/packages/worker/scripts/dev/manage.js @@ -29,6 +29,7 @@ async function init() { SERVICE: "worker-service", DEPLOYMENT_ENVIRONMENT: "development", TENANT_FEATURE_FLAGS: "*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR", + ENABLE_EMAIL_TEST_MODE: 1, } let envFile = "" Object.keys(envFileJson).forEach(key => { diff --git a/packages/worker/src/api/controllers/global/auth.ts b/packages/worker/src/api/controllers/global/auth.ts index 738b67c553..948a98cf3a 100644 --- a/packages/worker/src/api/controllers/global/auth.ts +++ b/packages/worker/src/api/controllers/global/auth.ts @@ -1,49 +1,55 @@ import { - auth, + auth as authCore, constants, context, db as dbCore, events, tenancy, - users as usersCore, - utils, + utils as utilsCore, } from "@budibase/backend-core" -import { EmailTemplatePurpose } from "../../../constants" -import { isEmailConfigured, sendEmail } from "../../../utilities/email" -import { checkResetPasswordCode } from "../../../utilities/redis" +import { + ConfigType, + User, + Ctx, + LoginRequest, + SSOUser, + PasswordResetRequest, + PasswordResetUpdateRequest, +} from "@budibase/types" import env from "../../../environment" -import sdk from "../../../sdk" -import { ConfigType, User } from "@budibase/types" -const { setCookie, getCookie, clearCookie, hash, platformLogout } = utils +import * as authSdk from "../../../sdk/auth" +import * as userSdk from "../../../sdk/users" + const { Cookie, Header } = constants -const { passport, ssoCallbackUrl, google, oidc } = auth +const { passport, ssoCallbackUrl, google, oidc } = authCore +const { setCookie, getCookie, clearCookie } = utilsCore -export async function googleCallbackUrl(config?: { callbackURL?: string }) { - return ssoCallbackUrl(tenancy.getGlobalDB(), config, ConfigType.GOOGLE) -} +// LOGIN / LOGOUT -export async function oidcCallbackUrl(config?: { callbackURL?: string }) { - return ssoCallbackUrl(tenancy.getGlobalDB(), config, ConfigType.OIDC) -} - -async function authInternal(ctx: any, user: any, err: any = null, info = null) { +async function passportCallback( + ctx: Ctx, + user: User, + err: any = null, + info: { message: string } | null = null +) { if (err) { console.error("Authentication error") console.error(err) console.trace(err) return ctx.throw(403, info ? info : "Unauthorized") } - if (!user) { console.error("Authentication error - no user provided") return ctx.throw(403, info ? info : "Unauthorized") } + const token = await authSdk.loginUser(user) + // set a cookie for browser access - setCookie(ctx, user.token, Cookie.Auth, { sign: false }) + setCookie(ctx, token, Cookie.Auth, { sign: false }) // set the token in a header as well for APIs - ctx.set(Header.TOKEN, user.token) + ctx.set(Header.TOKEN, token) // get rid of any app cookies on login // have to check test because this breaks cypress if (!env.isTest()) { @@ -51,11 +57,18 @@ async function authInternal(ctx: any, user: any, err: any = null, info = null) { } } -export const authenticate = async (ctx: any, next: any) => { +export const login = async (ctx: Ctx, next: any) => { + const email = ctx.request.body.username + + const user = await userSdk.getUserByEmail(email) + if (user && (await userSdk.isPreventSSOPasswords(user))) { + ctx.throw(400, "SSO user cannot login using password") + } + return passport.authenticate( "local", async (err: any, user: User, info: any) => { - await authInternal(ctx, user, err, info) + await passportCallback(ctx, user, err, info) await context.identity.doInUserContext(user, async () => { await events.auth.login("local") }) @@ -64,6 +77,15 @@ export const authenticate = async (ctx: any, next: any) => { )(ctx, next) } +export const logout = async (ctx: any) => { + if (ctx.user && ctx.user._id) { + await authSdk.logout({ ctx, userId: ctx.user._id }) + } + ctx.body = { message: "User logged out." } +} + +// INIT + export const setInitInfo = (ctx: any) => { const initInfo = ctx.request.body setCookie(ctx, initInfo, Cookie.Init) @@ -79,32 +101,16 @@ export const getInitInfo = (ctx: any) => { } } +// PASSWORD MANAGEMENT + /** * Reset the user password, used as part of a forgotten password flow. */ -export const reset = async (ctx: any) => { +export const reset = async (ctx: 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 usersCore.getGlobalUserByEmail(email)) as User - // only if user exists, don't error though if they don't - if (user) { - await sendEmail(email, EmailTemplatePurpose.PASSWORD_RECOVERY, { - user, - subject: "{{ company }} platform password reset", - }) - await events.user.passwordResetRequested(user) - } - } catch (err) { - console.log(err) - // don't throw any kind of error to the user, this might give away something - } + + await authSdk.reset(email) + ctx.body = { message: "Please check your email for a reset link.", } @@ -113,32 +119,21 @@ export const reset = async (ctx: any) => { /** * Perform the user password update if the provided reset code is valid. */ -export const resetUpdate = async (ctx: any) => { +export const resetUpdate = async (ctx: Ctx) => { const { resetCode, password } = ctx.request.body try { - const { userId } = await checkResetPasswordCode(resetCode) - const db = tenancy.getGlobalDB() - const user = await db.get(userId) - user.password = await hash(password) - await db.put(user) + await authSdk.resetUpdate(resetCode, password) ctx.body = { message: "password reset successfully.", } - // remove password from the user before sending events - delete user.password - await events.user.passwordReset(user) } catch (err) { - console.error(err) + console.warn(err) + // hide any details of the error for security ctx.throw(400, "Cannot reset password.") } } -export const logout = async (ctx: any) => { - if (ctx.user && ctx.user._id) { - await platformLogout({ ctx, userId: ctx.user._id }) - } - ctx.body = { message: "User logged out." } -} +// DATASOURCE export const datasourcePreAuth = async (ctx: any, next: any) => { const provider = ctx.params.provider @@ -166,6 +161,12 @@ export const datasourceAuth = async (ctx: any, next: any) => { return handler.postAuth(passport, ctx, next) } +// GOOGLE SSO + +export async function googleCallbackUrl(config?: { callbackURL?: string }) { + return ssoCallbackUrl(tenancy.getGlobalDB(), config, ConfigType.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. @@ -181,7 +182,7 @@ export const googlePreAuth = async (ctx: any, next: any) => { const strategy = await google.strategyFactory( config, callbackUrl, - sdk.users.save + userSdk.save ) return passport.authenticate(strategy, { @@ -191,7 +192,7 @@ export const googlePreAuth = async (ctx: any, next: any) => { })(ctx, next) } -export const googleAuth = async (ctx: any, next: any) => { +export const googleCallback = async (ctx: any, next: any) => { const db = tenancy.getGlobalDB() const config = await dbCore.getScopedConfig(db, { @@ -202,14 +203,14 @@ export const googleAuth = async (ctx: any, next: any) => { const strategy = await google.strategyFactory( config, callbackUrl, - sdk.users.save + userSdk.save ) return passport.authenticate( strategy, { successRedirect: "/", failureRedirect: "/error" }, - async (err: any, user: User, info: any) => { - await authInternal(ctx, user, err, info) + async (err: any, user: SSOUser, info: any) => { + await passportCallback(ctx, user, err, info) await context.identity.doInUserContext(user, async () => { await events.auth.login("google-internal") }) @@ -218,6 +219,12 @@ export const googleAuth = async (ctx: any, next: any) => { )(ctx, next) } +// OIDC SSO + +export async function oidcCallbackUrl(config?: { callbackURL?: string }) { + return ssoCallbackUrl(tenancy.getGlobalDB(), config, ConfigType.OIDC) +} + export const oidcStrategyFactory = async (ctx: any, configId: any) => { const db = tenancy.getGlobalDB() const config = await dbCore.getScopedConfig(db, { @@ -233,7 +240,7 @@ export const oidcStrategyFactory = async (ctx: any, configId: any) => { chosenConfig, callbackUrl ) - return oidc.strategyFactory(enrichedConfig, sdk.users.save) + return oidc.strategyFactory(enrichedConfig, userSdk.save) } /** @@ -265,15 +272,15 @@ export const oidcPreAuth = async (ctx: any, next: any) => { })(ctx, next) } -export const oidcAuth = async (ctx: any, next: any) => { +export const oidcCallback = async (ctx: any, next: any) => { const configId = getCookie(ctx, Cookie.OIDC_CONFIG) const strategy = await oidcStrategyFactory(ctx, configId) return passport.authenticate( strategy, { successRedirect: "/", failureRedirect: "/error" }, - async (err: any, user: any, info: any) => { - await authInternal(ctx, user, err, info) + async (err: any, user: SSOUser, info: any) => { + await passportCallback(ctx, user, err, info) await context.identity.doInUserContext(user, async () => { await events.auth.login("oidc") }) diff --git a/packages/worker/src/api/controllers/global/self.ts b/packages/worker/src/api/controllers/global/self.ts index 06906f1e8e..889a3e6a27 100644 --- a/packages/worker/src/api/controllers/global/self.ts +++ b/packages/worker/src/api/controllers/global/self.ts @@ -1,18 +1,22 @@ -import sdk from "../../../sdk" +import * as userSdk from "../../../sdk/users" import { - events, featureFlags, tenancy, constants, db as dbCore, utils, - cache, encryption, + auth as authCore, } from "@budibase/backend-core" import env from "../../../environment" import { groups } from "@budibase/pro" -const { hash, platformLogout, getCookie, clearCookie, newid } = utils -const { user: userCache } = cache +import { + UpdateSelfRequest, + UpdateSelfResponse, + UpdateSelf, + UserCtx, +} from "@budibase/types" +const { getCookie, clearCookie, newid } = utils function newTestApiKey() { return env.ENCRYPTED_TEST_PUBLIC_API_KEY @@ -93,17 +97,6 @@ const addSessionAttributesToUser = (ctx: any) => { ctx.body.csrfToken = ctx.user.csrfToken } -const sanitiseUserUpdate = (ctx: any) => { - const allowed = ["firstName", "lastName", "password", "forceResetPassword"] - const resp: { [key: string]: any } = {} - for (let [key, value] of Object.entries(ctx.request.body)) { - if (allowed.includes(key)) { - resp[key] = value - } - } - return resp -} - export async function getSelf(ctx: any) { if (!ctx.user) { ctx.throw(403, "User not logged in") @@ -116,7 +109,7 @@ export async function getSelf(ctx: any) { checkCurrentApp(ctx) // get the main body of the user - const user = await sdk.users.getUser(userId) + const user = await userSdk.getUser(userId) ctx.body = await groups.enrichUserRolesFromGroups(user) // add the feature flags for this tenant @@ -126,39 +119,30 @@ export async function getSelf(ctx: any) { addSessionAttributesToUser(ctx) } -export async function updateSelf(ctx: any) { - const db = tenancy.getGlobalDB() - const user = await db.get(ctx.user._id) - let passwordChange = false +export async function updateSelf( + ctx: UserCtx +) { + const body = ctx.request.body + const update: UpdateSelf = { + firstName: body.firstName, + lastName: body.lastName, + password: body.password, + forceResetPassword: body.forceResetPassword, + } - const userUpdateObj = sanitiseUserUpdate(ctx) - if (userUpdateObj.password) { - // changing password - passwordChange = true - userUpdateObj.password = await hash(userUpdateObj.password) + const user = await userSdk.updateSelf(ctx.user._id!, update) + + if (update.password) { // Log all other sessions out apart from the current one - await platformLogout({ + await authCore.platformLogout({ ctx, - userId: ctx.user._id, + userId: ctx.user._id!, keepActiveSession: true, }) } - const response = await db.put({ - ...user, - ...userUpdateObj, - }) - await userCache.invalidateUser(user._id) ctx.body = { - _id: response.id, - _rev: response.rev, - } - - // remove the old password from the user before sending events - user._rev = response.rev - delete user.password - await events.user.updated(user) - if (passwordChange) { - await events.user.passwordUpdated(user) + _id: user._id!, + _rev: user._rev!, } } diff --git a/packages/worker/src/api/controllers/global/users.ts b/packages/worker/src/api/controllers/global/users.ts index 43ec23eade..c722d27faa 100644 --- a/packages/worker/src/api/controllers/global/users.ts +++ b/packages/worker/src/api/controllers/global/users.ts @@ -2,15 +2,21 @@ import { checkInviteCode } from "../../../utilities/redis" import * as userSdk from "../../../sdk/users" import env from "../../../environment" import { + AcceptUserInviteRequest, + AcceptUserInviteResponse, BulkUserRequest, BulkUserResponse, CloudAccount, CreateAdminUserRequest, + CreateAdminUserResponse, + Ctx, InviteUserRequest, InviteUsersRequest, MigrationType, + SaveUserResponse, SearchUsersRequest, User, + UserCtx, } from "@budibase/types" import { accounts, @@ -25,10 +31,18 @@ import { checkAnyUserExists } from "../../../utilities/users" const MAX_USERS_UPLOAD_LIMIT = 1000 -export const save = async (ctx: any) => { +export const save = async (ctx: UserCtx) => { try { const currentUserId = ctx.user._id - ctx.body = await userSdk.save(ctx.request.body, { currentUserId }) + const requestUser = ctx.request.body + + const user = await userSdk.save(requestUser, { currentUserId }) + + ctx.body = { + _id: user._id!, + _rev: user._rev!, + email: user.email, + } } catch (err: any) { ctx.throw(err.status || 400, err) } @@ -71,9 +85,10 @@ const parseBooleanParam = (param: any) => { return !(param && param === "false") } -export const adminUser = async (ctx: any) => { - const { email, password, tenantId } = ctx.request - .body as CreateAdminUserRequest +export const adminUser = async ( + ctx: Ctx +) => { + const { email, password, tenantId } = ctx.request.body if (await platform.tenants.exists(tenantId)) { ctx.throw(403, "Organisation already exists.") @@ -131,7 +146,11 @@ export const adminUser = async (ctx: any) => { } await events.identification.identifyTenantGroup(tenantId, account) - ctx.body = finalUser + ctx.body = { + _id: finalUser._id!, + _rev: finalUser._rev!, + email: finalUser.email, + } } catch (err: any) { ctx.throw(err.status || 400, err) } @@ -236,12 +255,14 @@ export const checkInvite = async (ctx: any) => { } } -export const inviteAccept = async (ctx: any) => { +export const inviteAccept = async ( + ctx: Ctx +) => { const { inviteCode, password, firstName, lastName } = ctx.request.body try { // info is an extension of the user object that was stored by global const { email, info }: any = await checkInviteCode(inviteCode) - ctx.body = await tenancy.doInTenant(info.tenantId, async () => { + const user = await tenancy.doInTenant(info.tenantId, async () => { const saved = await userSdk.save({ firstName, lastName, @@ -254,6 +275,12 @@ export const inviteAccept = async (ctx: any) => { await events.user.inviteAccepted(user) return saved }) + + ctx.body = { + _id: user._id, + _rev: user._rev, + email: user.email, + } } catch (err: any) { if (err.code === errors.codes.USAGE_LIMIT_EXCEEDED) { // explicitly re-throw limit exceeded errors diff --git a/packages/worker/src/api/controllers/system/accounts.ts b/packages/worker/src/api/controllers/system/accounts.ts index 0aa5f25785..10b809c0b5 100644 --- a/packages/worker/src/api/controllers/system/accounts.ts +++ b/packages/worker/src/api/controllers/system/accounts.ts @@ -1,21 +1,23 @@ import { Account, AccountMetadata } from "@budibase/types" -import sdk from "../../../sdk" +import * as accounts from "../../../sdk/accounts" export const save = async (ctx: any) => { const account = ctx.request.body as Account let metadata: AccountMetadata = { - _id: sdk.accounts.formatAccountMetadataId(account.accountId), + _id: accounts.metadata.formatAccountMetadataId(account.accountId), email: account.email, } - metadata = await sdk.accounts.saveMetadata(metadata) + metadata = await accounts.metadata.saveMetadata(metadata) ctx.body = metadata ctx.status = 200 } export const destroy = async (ctx: any) => { - const accountId = sdk.accounts.formatAccountMetadataId(ctx.params.accountId) - await sdk.accounts.destroyMetadata(accountId) + const accountId = accounts.metadata.formatAccountMetadataId( + ctx.params.accountId + ) + await accounts.metadata.destroyMetadata(accountId) ctx.status = 204 } diff --git a/packages/worker/src/api/routes/global/auth.ts b/packages/worker/src/api/routes/global/auth.ts index b13cef2fc6..503182a418 100644 --- a/packages/worker/src/api/routes/global/auth.ts +++ b/packages/worker/src/api/routes/global/auth.ts @@ -33,7 +33,7 @@ router .post( "/api/global/auth/:tenantId/login", buildAuthValidation(), - authController.authenticate + authController.login ) .post("/api/global/auth/logout", authController.logout) .post( @@ -68,21 +68,24 @@ router // GOOGLE - MULTI TENANT .get("/api/global/auth/:tenantId/google", authController.googlePreAuth) - .get("/api/global/auth/:tenantId/google/callback", authController.googleAuth) + .get( + "/api/global/auth/:tenantId/google/callback", + authController.googleCallback + ) // GOOGLE - SINGLE TENANT - DEPRECATED - .get("/api/global/auth/google/callback", authController.googleAuth) - .get("/api/admin/auth/google/callback", authController.googleAuth) + .get("/api/global/auth/google/callback", authController.googleCallback) + .get("/api/admin/auth/google/callback", authController.googleCallback) // OIDC - MULTI TENANT .get( "/api/global/auth/:tenantId/oidc/configs/:configId", authController.oidcPreAuth ) - .get("/api/global/auth/:tenantId/oidc/callback", authController.oidcAuth) + .get("/api/global/auth/:tenantId/oidc/callback", authController.oidcCallback) // OIDC - SINGLE TENANT - DEPRECATED - .get("/api/global/auth/oidc/callback", authController.oidcAuth) - .get("/api/admin/auth/oidc/callback", authController.oidcAuth) + .get("/api/global/auth/oidc/callback", authController.oidcCallback) + .get("/api/admin/auth/oidc/callback", authController.oidcCallback) export default router diff --git a/packages/worker/src/api/routes/global/tests/auth.spec.ts b/packages/worker/src/api/routes/global/tests/auth.spec.ts index ee753d49dc..84f8ce1b0a 100644 --- a/packages/worker/src/api/routes/global/tests/auth.spec.ts +++ b/packages/worker/src/api/routes/global/tests/auth.spec.ts @@ -1,13 +1,27 @@ -jest.mock("nodemailer") -import { TestConfiguration, mocks } from "../../../../tests" -const sendMailMock = mocks.email.mock() -import { events, tenancy } from "@budibase/backend-core" -import { structures } from "@budibase/backend-core/tests" +import { CloudAccount, SSOUser, User } from "@budibase/types" -const expectSetAuthCookie = (res: any) => { - expect( - res.get("Set-Cookie").find((c: string) => c.startsWith("budibase:auth")) - ).toBeDefined() +jest.mock("nodemailer") +import { + TestConfiguration, + mocks, + structures, + generator, +} from "../../../../tests" +const sendMailMock = mocks.email.mock() +import { events, constants } from "@budibase/backend-core" +import { Response } from "superagent" + +import * as userSdk from "../../../../sdk/users" + +function getAuthCookie(response: Response) { + return response.headers["set-cookie"] + .find((s: string) => s.startsWith(`${constants.Cookie.Auth}=`)) + .split("=")[1] + .split(";")[0] +} + +const expectSetAuthCookie = (response: Response) => { + expect(getAuthCookie(response).length > 1).toBe(true) } describe("/api/global/auth", () => { @@ -25,60 +39,247 @@ describe("/api/global/auth", () => { jest.clearAllMocks() }) + async function createSSOUser() { + return config.doInTenant(async () => { + return userSdk.save(structures.users.ssoUser(), { + requirePassword: false, + }) + }) + } + describe("password", () => { describe("POST /api/global/auth/:tenantId/login", () => { - it("should login", () => {}) + it("logs in with correct credentials", async () => { + const tenantId = config.tenantId! + const email = config.user?.email! + const password = config.userPassword + + const response = await config.api.auth.login(tenantId, email, password) + + expectSetAuthCookie(response) + expect(events.auth.login).toBeCalledTimes(1) + }) + + it("should return 403 with incorrect credentials", async () => { + const tenantId = config.tenantId! + const email = config.user?.email! + const password = "incorrect" + + const response = await config.api.auth.login( + tenantId, + email, + password, + { status: 403 } + ) + expect(response.body).toEqual({ + message: "Invalid credentials", + status: 403, + }) + }) + + it("should return 403 when user doesn't exist", async () => { + const tenantId = config.tenantId! + const email = "invaliduser@test.com" + const password = "password" + + const response = await config.api.auth.login( + tenantId, + email, + password, + { status: 403 } + ) + expect(response.body).toEqual({ + message: "Invalid credentials", + status: 403, + }) + }) + + describe("sso user", () => { + let user: User + + async function testSSOUser() { + const tenantId = user.tenantId! + const email = user.email + const password = "test" + + const response = await config.api.auth.login( + tenantId, + email, + password, + { status: 400 } + ) + + expect(response.body).toEqual({ + message: "SSO user cannot login using password", + status: 400, + }) + } + + describe("budibase sso user", () => { + it("should prevent user from logging in", async () => { + user = await createSSOUser() + await testSSOUser() + }) + }) + + describe("root account sso user", () => { + it("should prevent user from logging in", async () => { + user = await config.createUser() + const account = structures.accounts.ssoAccount() as CloudAccount + mocks.accounts.getAccount.mockReturnValueOnce( + Promise.resolve(account) + ) + + await testSSOUser() + }) + }) + }) }) describe("POST /api/global/auth/logout", () => { it("should logout", async () => { - await config.api.auth.logout() + const response = await config.api.auth.logout() expect(events.auth.logout).toBeCalledTimes(1) - // TODO: Verify sessions deleted + const authCookie = getAuthCookie(response) + expect(authCookie).toBe("") }) }) describe("POST /api/global/auth/:tenantId/reset", () => { it("should generate password reset email", async () => { - await tenancy.doInTenant(config.tenant1User!.tenantId, async () => { - const userEmail = structures.email() - const { res, code } = await config.api.auth.requestPasswordReset( + const user = await config.createUser() + + const { res, code } = await config.api.auth.requestPasswordReset( + sendMailMock, + user.email + ) + + expect(res.body).toEqual({ + message: "Please check your email for a reset link.", + }) + expect(sendMailMock).toHaveBeenCalled() + expect(code).toBeDefined() + expect(events.user.passwordResetRequested).toBeCalledTimes(1) + expect(events.user.passwordResetRequested).toBeCalledWith(user) + }) + + describe("sso user", () => { + let user: User + + async function testSSOUser() { + const { res } = await config.api.auth.requestPasswordReset( sendMailMock, - userEmail + user.email, + { status: 400 } ) - const user = await config.getUser(userEmail) expect(res.body).toEqual({ - message: "Please check your email for a reset link.", + message: "SSO user cannot reset password", + status: 400, + error: { + code: "http", + type: "generic", + }, }) - expect(sendMailMock).toHaveBeenCalled() + expect(sendMailMock).not.toHaveBeenCalled() + } - expect(code).toBeDefined() - expect(events.user.passwordResetRequested).toBeCalledTimes(1) - expect(events.user.passwordResetRequested).toBeCalledWith(user) + describe("budibase sso user", () => { + it("should prevent user from generating password reset email", async () => { + user = await createSSOUser() + await testSSOUser() + }) + }) + + describe("root account sso user", () => { + it("should prevent user from generating password reset email", async () => { + user = await config.createUser(structures.users.user()) + const account = structures.accounts.ssoAccount() as CloudAccount + mocks.accounts.getAccount.mockReturnValueOnce( + Promise.resolve(account) + ) + + await testSSOUser() + }) }) }) }) describe("POST /api/global/auth/:tenantId/reset/update", () => { it("should reset password", async () => { - await tenancy.doInTenant(config.tenant1User!.tenantId, async () => { - const userEmail = structures.email() - const { code } = await config.api.auth.requestPasswordReset( - sendMailMock, - userEmail + let user = await config.createUser() + const { code } = await config.api.auth.requestPasswordReset( + sendMailMock, + user.email + ) + delete user.password + + const newPassword = "newpassword" + const res = await config.api.auth.updatePassword(code!, newPassword) + + user = await config.getUser(user.email) + delete user.password + + expect(res.body).toEqual({ message: "password reset successfully." }) + expect(events.user.passwordReset).toBeCalledTimes(1) + expect(events.user.passwordReset).toBeCalledWith(user) + + // login using new password + await config.api.auth.login(user.tenantId, user.email, newPassword) + }) + + describe("sso user", () => { + let user: User | SSOUser + + async function testSSOUser(code: string) { + const res = await config.api.auth.updatePassword( + code!, + generator.string(), + { status: 400 } ) - const user = await config.getUser(userEmail) - delete user.password - const res = await config.api.auth.updatePassword(code) + expect(res.body).toEqual({ + message: "Cannot reset password.", + status: 400, + }) + } - expect(res.body).toEqual({ message: "password reset successfully." }) - expect(events.user.passwordReset).toBeCalledTimes(1) - expect(events.user.passwordReset).toBeCalledWith(user) + describe("budibase sso user", () => { + it("should prevent user from generating password reset email", async () => { + user = await config.createUser() + const { code } = await config.api.auth.requestPasswordReset( + sendMailMock, + user.email + ) + + // convert to sso now that password reset has been requested + const ssoUser = user as SSOUser + ssoUser.providerType = structures.sso.providerType() + delete ssoUser.password + await config.doInTenant(() => userSdk.save(ssoUser)) + + await testSSOUser(code!) + }) + }) + + describe("root account sso user", () => { + it("should prevent user from generating password reset email", async () => { + user = await config.createUser() + const { code } = await config.api.auth.requestPasswordReset( + sendMailMock, + user.email + ) + + // convert to account owner now that password has been requested + const account = structures.accounts.ssoAccount() as CloudAccount + mocks.accounts.getAccount.mockReturnValueOnce( + Promise.resolve(account) + ) + + await testSSOUser(code!) + }) }) - // TODO: Login using new password }) }) }) @@ -153,7 +354,7 @@ describe("/api/global/auth", () => { const location: string = res.get("location") expect( location.startsWith( - "http://localhost/auth?response_type=code&client_id=clientId&redirect_uri=http%3A%2F%2Flocalhost%3A10000%2Fapi%2Fglobal%2Fauth%2Fdefault%2Foidc%2Fcallback&scope=openid%20profile%20email%20offline_access" + `http://localhost/auth?response_type=code&client_id=clientId&redirect_uri=http%3A%2F%2Flocalhost%3A10000%2Fapi%2Fglobal%2Fauth%2F${config.tenantId}%2Foidc%2Fcallback&scope=openid%20profile%20email%20offline_access` ) ).toBe(true) }) diff --git a/packages/worker/src/api/routes/global/tests/self.spec.ts b/packages/worker/src/api/routes/global/tests/self.spec.ts index d253a7f24e..74d24c7c31 100644 --- a/packages/worker/src/api/routes/global/tests/self.spec.ts +++ b/packages/worker/src/api/routes/global/tests/self.spec.ts @@ -30,7 +30,7 @@ describe("/api/global/self", () => { user.dayPassRecordedAt = mocks.date.MOCK_DATE.toISOString() expect(res.body._id).toBe(user._id) expect(events.user.updated).toBeCalledTimes(1) - expect(events.user.updated).toBeCalledWith(user) + expect(events.user.updated).toBeCalledWith(dbUser) expect(events.user.passwordUpdated).not.toBeCalled() }) @@ -44,12 +44,11 @@ describe("/api/global/self", () => { const dbUser = await config.getUser(user.email) user._rev = dbUser._rev user.dayPassRecordedAt = mocks.date.MOCK_DATE.toISOString() - delete user.password expect(res.body._id).toBe(user._id) expect(events.user.updated).toBeCalledTimes(1) - expect(events.user.updated).toBeCalledWith(user) + expect(events.user.updated).toBeCalledWith(dbUser) expect(events.user.passwordUpdated).toBeCalledTimes(1) - expect(events.user.passwordUpdated).toBeCalledWith(user) + expect(events.user.passwordUpdated).toBeCalledWith(dbUser) }) }) }) diff --git a/packages/worker/src/api/routes/system/tests/accounts.spec.ts b/packages/worker/src/api/routes/system/tests/accounts.spec.ts index fd54dd2b0a..7df2950212 100644 --- a/packages/worker/src/api/routes/system/tests/accounts.spec.ts +++ b/packages/worker/src/api/routes/system/tests/accounts.spec.ts @@ -1,4 +1,4 @@ -import sdk from "../../../../sdk" +import * as accounts from "../../../../sdk/accounts" import { TestConfiguration, structures } from "../../../../tests" import { v4 as uuid } from "uuid" @@ -24,8 +24,8 @@ describe("accounts", () => { const response = await config.api.accounts.saveMetadata(account) - const id = sdk.accounts.formatAccountMetadataId(account.accountId) - const metadata = await sdk.accounts.getMetadata(id) + const id = accounts.metadata.formatAccountMetadataId(account.accountId) + const metadata = await accounts.metadata.getMetadata(id) expect(response).toStrictEqual(metadata) }) }) @@ -37,7 +37,7 @@ describe("accounts", () => { await config.api.accounts.destroyMetadata(account.accountId) - const deleted = await sdk.accounts.getMetadata(account.accountId) + const deleted = await accounts.metadata.getMetadata(account.accountId) expect(deleted).toBe(undefined) }) diff --git a/packages/worker/src/environment.ts b/packages/worker/src/environment.ts index 52fec210bc..71fd89f276 100644 --- a/packages/worker/src/environment.ts +++ b/packages/worker/src/environment.ts @@ -26,6 +26,8 @@ function parseIntSafe(number: any) { } } +const selfHosted = !!parseInt(process.env.SELF_HOSTED || "") + const environment = { // auth MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY, @@ -49,7 +51,7 @@ const environment = { CLUSTER_PORT: process.env.CLUSTER_PORT, // flags NODE_ENV: process.env.NODE_ENV, - SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED || ""), + SELF_HOSTED: selfHosted, LOG_LEVEL: process.env.LOG_LEVEL, MULTI_TENANCY: process.env.MULTI_TENANCY, DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL, @@ -65,6 +67,18 @@ const environment = { CHECKLIST_CACHE_TTL: parseIntSafe(process.env.CHECKLIST_CACHE_TTL) || 3600, SESSION_UPDATE_PERIOD: process.env.SESSION_UPDATE_PERIOD, ENCRYPTED_TEST_PUBLIC_API_KEY: process.env.ENCRYPTED_TEST_PUBLIC_API_KEY, + /** + * Mock the email service in use - links to ethereal hosted emails are logged instead. + */ + ENABLE_EMAIL_TEST_MODE: process.env.ENABLE_EMAIL_TEST_MODE, + /** + * Enable to allow an admin user to login using a password. + * This can be useful to prevent lockout when configuring SSO. + * However, this should be turned OFF by default for security purposes. + */ + ENABLE_SSO_MAINTENANCE_MODE: selfHosted + ? process.env.ENABLE_SSO_MAINTENANCE_MODE + : false, _set(key: any, value: any) { process.env[key] = value // @ts-ignore diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 1eff6c06fb..1e3ff3cbdf 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -25,6 +25,12 @@ const koaSession = require("koa-session") const logger = require("koa-pino-logger") import destroyable from "server-destroy" +if (env.ENABLE_SSO_MAINTENANCE_MODE) { + console.warn( + "Warning: ENABLE_SSO_MAINTENANCE_MODE is set. It is recommended this flag is disabled if maintenance is not in progress" + ) +} + // this will setup http and https proxies form env variables bootstrap() diff --git a/packages/worker/src/sdk/accounts/index.ts b/packages/worker/src/sdk/accounts/index.ts index f2ae03040e..72db8c3d85 100644 --- a/packages/worker/src/sdk/accounts/index.ts +++ b/packages/worker/src/sdk/accounts/index.ts @@ -1 +1,2 @@ -export * from "./accounts" +export * as metadata from "./metadata" +export { accounts as api } from "@budibase/backend-core" diff --git a/packages/worker/src/sdk/accounts/accounts.ts b/packages/worker/src/sdk/accounts/metadata.ts similarity index 99% rename from packages/worker/src/sdk/accounts/accounts.ts rename to packages/worker/src/sdk/accounts/metadata.ts index e43285087b..64065e8b78 100644 --- a/packages/worker/src/sdk/accounts/accounts.ts +++ b/packages/worker/src/sdk/accounts/metadata.ts @@ -2,7 +2,6 @@ import { AccountMetadata } from "@budibase/types" import { db, StaticDatabases, - HTTPError, DocumentType, SEPARATOR, } from "@budibase/backend-core" diff --git a/packages/worker/src/sdk/auth/auth.ts b/packages/worker/src/sdk/auth/auth.ts new file mode 100644 index 0000000000..15a4f3c7e7 --- /dev/null +++ b/packages/worker/src/sdk/auth/auth.ts @@ -0,0 +1,86 @@ +import { + auth as authCore, + tenancy, + utils as coreUtils, + sessions, + events, + HTTPError, +} from "@budibase/backend-core" +import { PlatformLogoutOpts, User } from "@budibase/types" +import jwt from "jsonwebtoken" +import env from "../../environment" +import * as userSdk from "../users" +import * as emails from "../../utilities/email" +import * as redis from "../../utilities/redis" +import { EmailTemplatePurpose } from "../../constants" + +// LOGIN / LOGOUT + +export async function loginUser(user: User) { + const sessionId = coreUtils.newid() + const tenantId = tenancy.getTenantId() + await sessions.createASession(user._id!, { sessionId, tenantId }) + const token = jwt.sign( + { + userId: user._id, + sessionId, + tenantId, + }, + env.JWT_SECRET! + ) + return token +} + +export async function logout(opts: PlatformLogoutOpts) { + // TODO: This should be moved out of core and into worker only + // account-portal can call worker endpoint + return authCore.platformLogout(opts) +} + +// PASSWORD MANAGEMENT + +/** + * Reset the user password, used as part of a forgotten password flow. + */ +export const reset = async (email: string) => { + const configured = await emails.isEmailConfigured() + if (!configured) { + throw new HTTPError( + "Please contact your platform administrator, SMTP is not configured.", + 400 + ) + } + + const user = await userSdk.core.getGlobalUserByEmail(email) + // exit if user doesn't exist + if (!user) { + return + } + + // exit if user has sso + if (await userSdk.isPreventSSOPasswords(user)) { + throw new HTTPError("SSO user cannot reset password", 400) + } + + // send password reset + await emails.sendEmail(email, EmailTemplatePurpose.PASSWORD_RECOVERY, { + user, + subject: "{{ company }} platform password reset", + }) + await events.user.passwordResetRequested(user) +} + +/** + * Perform the user password update if the provided reset code is valid. + */ +export const resetUpdate = async (resetCode: string, password: string) => { + const { userId } = await redis.checkResetPasswordCode(resetCode) + + let user = await userSdk.getUser(userId) + user.password = password + user = await userSdk.save(user) + + // remove password from the user before sending events + delete user.password + await events.user.passwordReset(user) +} diff --git a/packages/worker/src/sdk/auth/index.ts b/packages/worker/src/sdk/auth/index.ts new file mode 100644 index 0000000000..306751af96 --- /dev/null +++ b/packages/worker/src/sdk/auth/index.ts @@ -0,0 +1 @@ +export * from "./auth" diff --git a/packages/worker/src/sdk/users/events.ts b/packages/worker/src/sdk/users/events.ts index 17c4748dff..7d86182a3c 100644 --- a/packages/worker/src/sdk/users/events.ts +++ b/packages/worker/src/sdk/users/events.ts @@ -84,6 +84,10 @@ export const handleSaveEvents = async ( ) { await events.user.passwordForceReset(user) } + + if (user.password !== existingUser.password) { + await events.user.passwordUpdated(user) + } } else { await events.user.created(user) } diff --git a/packages/worker/src/sdk/users/index.ts b/packages/worker/src/sdk/users/index.ts index 056d6e5675..2eaa0e68a2 100644 --- a/packages/worker/src/sdk/users/index.ts +++ b/packages/worker/src/sdk/users/index.ts @@ -1 +1,2 @@ export * from "./users" +export { users as core } from "@budibase/backend-core" diff --git a/packages/worker/src/sdk/users/tests/users.spec.ts b/packages/worker/src/sdk/users/tests/users.spec.ts new file mode 100644 index 0000000000..41d9298997 --- /dev/null +++ b/packages/worker/src/sdk/users/tests/users.spec.ts @@ -0,0 +1,52 @@ +import { structures } from "../../../tests" +import * as users from "../users" +import env from "../../../environment" +import { mocks } from "@budibase/backend-core/tests" +import { CloudAccount } from "@budibase/types" + +describe("users", () => { + describe("isPreventSSOPasswords", () => { + it("returns true for sso account user", async () => { + const user = structures.users.user() + mocks.accounts.getAccount.mockReturnValue( + Promise.resolve(structures.accounts.ssoAccount() as CloudAccount) + ) + const result = await users.isPreventSSOPasswords(user) + expect(result).toBe(true) + }) + + it("returns true for sso user", async () => { + const user = structures.users.ssoUser() + const result = await users.isPreventSSOPasswords(user) + expect(result).toBe(true) + }) + + describe("sso maintenance mode", () => { + beforeEach(() => { + env._set("ENABLE_SSO_MAINTENANCE_MODE", true) + }) + + afterEach(() => { + env._set("ENABLE_SSO_MAINTENANCE_MODE", false) + }) + + describe("non-admin user", () => { + it("returns true", async () => { + const user = structures.users.ssoUser() + const result = await users.isPreventSSOPasswords(user) + expect(result).toBe(true) + }) + }) + + describe("admin user", () => { + it("returns false", async () => { + const user = structures.users.ssoUser({ + user: structures.users.adminUser(), + }) + const result = await users.isPreventSSOPasswords(user) + expect(result).toBe(false) + }) + }) + }) + }) +}) diff --git a/packages/worker/src/sdk/users/users.ts b/packages/worker/src/sdk/users/users.ts index 1d05f6d84f..7d4a2f04f0 100644 --- a/packages/worker/src/sdk/users/users.ts +++ b/packages/worker/src/sdk/users/users.ts @@ -6,12 +6,11 @@ import { cache, constants, db as dbUtils, - deprovisioning, events, HTTPError, - migrations, sessions, tenancy, + platform, users as usersCore, utils, ViewName, @@ -21,21 +20,22 @@ import { AllDocsResponse, BulkUserResponse, CloudAccount, - CreateUserResponse, InviteUsersRequest, InviteUsersResponse, - MigrationType, + isSSOAccount, + isSSOUser, PlatformUser, PlatformUserByEmail, RowResponse, SearchUsersRequest, + UpdateSelf, User, - ThirdPartyUser, - isUser, + SaveUserOpts, } from "@budibase/types" import { sendEmail } from "../../utilities/email" import { EmailTemplatePurpose } from "../../constants" import { groups as groupsSdk } from "@budibase/pro" +import * as accountSdk from "../accounts" const PAGE_LIMIT = 8 @@ -94,26 +94,23 @@ export const paginatedUsers = async ({ }) } +export async function getUserByEmail(email: string) { + return usersCore.getGlobalUserByEmail(email) +} + /** * Gets a user by ID from the global database, based on the current tenancy. */ export const getUser = async (userId: string) => { - const db = tenancy.getGlobalDB() - let user = await db.get(userId) + const user = await usersCore.getById(userId) if (user) { delete user.password } return user } -export interface SaveUserOpts { - hashPassword?: boolean - requirePassword?: boolean - currentUserId?: string -} - const buildUser = async ( - user: User | ThirdPartyUser, + user: User, opts: SaveUserOpts = { hashPassword: true, requirePassword: true, @@ -121,11 +118,13 @@ const buildUser = async ( tenantId: string, dbUser?: any ): Promise => { - let fullUser = user as User - let { password, _id } = fullUser + let { password, _id } = user let hashedPassword if (password) { + if (await isPreventSSOPasswords(user)) { + throw new HTTPError("SSO user cannot set password", 400) + } hashedPassword = opts.hashPassword ? await utils.hash(password) : password } else if (dbUser) { hashedPassword = dbUser.password @@ -135,10 +134,10 @@ const buildUser = async ( _id = _id || dbUtils.generateGlobalUserID() - fullUser = { + const fullUser = { createdAt: Date.now(), ...dbUser, - ...fullUser, + ...user, _id, password: hashedPassword, tenantId, @@ -189,10 +188,36 @@ const validateUniqueUser = async (email: string, tenantId: string) => { } } +export async function isPreventSSOPasswords(user: User) { + // when in maintenance mode we allow sso users with the admin role + // to perform any password action - this prevents lockout + if (env.ENABLE_SSO_MAINTENANCE_MODE && user.admin?.global) { + return false + } + + // Check local sso + if (isSSOUser(user)) { + return true + } + + // Check account sso + const account = await accountSdk.api.getAccount(user.email) + return !!(account && isSSOAccount(account)) +} + +export async function updateSelf(id: string, data: UpdateSelf) { + let user = await getUser(id) + user = { + ...user, + ...data, + } + return save(user) +} + export const save = async ( - user: User | ThirdPartyUser, + user: User, opts: SaveUserOpts = {} -): Promise => { +): Promise => { // default booleans to true if (opts.hashPassword == null) { opts.hashPassword = true @@ -264,7 +289,7 @@ export const save = async ( builtUser._rev = response.rev await eventHelpers.handleSaveEvents(builtUser, dbUser) - await addTenant(tenantId, _id, email) + await platform.users.addUser(tenantId, builtUser._id!, builtUser.email) await cache.user.invalidateUser(response.id) // let server know to sync user @@ -272,11 +297,8 @@ export const save = async ( await Promise.all(groupPromises) - return { - _id: response.id, - _rev: response.rev, - email, - } + // finally returned the saved user from the db + return db.get(builtUser._id!) } catch (err: any) { if (err.status === 409) { throw "User exists already" @@ -286,21 +308,6 @@ export const save = async ( } } -export const addTenant = async ( - tenantId: string, - _id: string, - email: string -) => { - if (env.MULTI_TENANCY) { - const afterCreateTenant = () => - migrations.backPopulateMigrations({ - type: MigrationType.GLOBAL, - tenantId, - }) - await tenancy.tryAddTenant(tenantId, _id, email, afterCreateTenant) - } -} - const getExistingTenantUsers = async (emails: string[]): Promise => { const lcEmails = emails.map(email => email.toLowerCase()) const params = { @@ -432,7 +439,7 @@ export const bulkCreate = async ( for (const user of usersToBulkSave) { // TODO: Refactor to bulk insert users into the info db // instead of relying on looping tenant creation - await addTenant(tenantId, user._id, user.email) + await platform.users.addUser(tenantId, user._id, user.email) await eventHelpers.handleSaveEvents(user, undefined) await apps.syncUserInApps(user._id) } @@ -566,7 +573,7 @@ export const destroy = async (id: string, currentUser: any) => { } } - await deprovisioning.removeUserFromInfoDB(dbUser) + await platform.users.removeUser(dbUser) await db.remove(userId, dbUser._rev) @@ -579,7 +586,7 @@ export const destroy = async (id: string, currentUser: any) => { const bulkDeleteProcessing = async (dbUser: User) => { const userId = dbUser._id as string - await deprovisioning.removeUserFromInfoDB(dbUser) + await platform.users.removeUser(dbUser) await eventHelpers.handleDeleteEvents(dbUser) await cache.user.invalidateUser(userId) await sessions.invalidateSessions(userId, { reason: "bulk-deletion" }) diff --git a/packages/worker/src/tests/TestConfiguration.ts b/packages/worker/src/tests/TestConfiguration.ts index 7d075e7fef..3004d0aed4 100644 --- a/packages/worker/src/tests/TestConfiguration.ts +++ b/packages/worker/src/tests/TestConfiguration.ts @@ -22,7 +22,7 @@ import { env as coreEnv, } from "@budibase/backend-core" import structures, { CSRF_TOKEN } from "./structures" -import { CreateUserResponse, User, AuthToken } from "@budibase/types" +import { SaveUserResponse, User, AuthToken } from "@budibase/types" import API from "./api" class TestConfiguration { @@ -226,7 +226,7 @@ class TestConfiguration { user = structures.users.user() } const response = await this._req(user, null, controllers.users.save) - const body = response as CreateUserResponse + const body = response as SaveUserResponse return this.getUser(body.email) } diff --git a/packages/worker/src/tests/api/auth.ts b/packages/worker/src/tests/api/auth.ts index ccbf747273..bd0471ca74 100644 --- a/packages/worker/src/tests/api/auth.ts +++ b/packages/worker/src/tests/api/auth.ts @@ -1,21 +1,39 @@ -import structures from "../structures" import TestConfiguration from "../TestConfiguration" -import { TestAPI } from "./base" +import { TestAPI, TestAPIOpts } from "./base" export class AuthAPI extends TestAPI { constructor(config: TestConfiguration) { super(config) } - updatePassword = (code: string) => { + updatePassword = ( + resetCode: string, + password: string, + opts?: TestAPIOpts + ) => { return this.request .post(`/api/global/auth/${this.config.getTenantId()}/reset/update`) .send({ - password: "newpassword", - resetCode: code, + password, + resetCode, }) .expect("Content-Type", /json/) - .expect(200) + .expect(opts?.status ? opts.status : 200) + } + + login = ( + tenantId: string, + email: string, + password: string, + opts?: TestAPIOpts + ) => { + return this.request + .post(`/api/global/auth/${tenantId}/login`) + .send({ + username: email, + password: password, + }) + .expect(opts?.status ? opts.status : 200) } logout = () => { @@ -25,25 +43,31 @@ export class AuthAPI extends TestAPI { .expect(200) } - requestPasswordReset = async (sendMailMock: any, userEmail: string) => { + requestPasswordReset = async ( + sendMailMock: any, + email: string, + opts?: TestAPIOpts + ) => { await this.config.saveSmtpConfig() await this.config.saveSettingsConfig() - await this.config.createUser({ - ...structures.users.user(), - email: userEmail, - }) + const res = await this.request .post(`/api/global/auth/${this.config.getTenantId()}/reset`) .send({ - email: userEmail, + email: email, }) .expect("Content-Type", /json/) - .expect(200) - const emailCall = sendMailMock.mock.calls[0][0] - const parts = emailCall.html.split( - `http://localhost:10000/builder/auth/reset?code=` - ) - const code = parts[1].split('"')[0].split("&")[0] + .expect(opts?.status ? opts.status : 200) + + let code: string | undefined + if (res.status === 200) { + const emailCall = sendMailMock.mock.calls[0][0] + const parts = emailCall.html.split( + `http://localhost:10000/builder/auth/reset?code=` + ) + code = parts[1].split('"')[0].split("&")[0] + } + return { code, res } } } diff --git a/packages/worker/src/tests/structures/index.ts b/packages/worker/src/tests/structures/index.ts index dad055f7a7..bec15df6dd 100644 --- a/packages/worker/src/tests/structures/index.ts +++ b/packages/worker/src/tests/structures/index.ts @@ -1,6 +1,5 @@ import { structures } from "@budibase/backend-core/tests" import * as configs from "./configs" -import * as users from "./users" import * as groups from "./groups" import { v4 as uuid } from "uuid" @@ -11,7 +10,6 @@ const pkg = { ...structures, uuid, configs, - users, TENANT_ID, CSRF_TOKEN, groups, diff --git a/packages/worker/src/tests/structures/users.ts b/packages/worker/src/tests/structures/users.ts deleted file mode 100644 index 3348670b7d..0000000000 --- a/packages/worker/src/tests/structures/users.ts +++ /dev/null @@ -1,37 +0,0 @@ -export const email = "test@test.com" -import { AdminUser, BuilderUser, User } from "@budibase/types" -import { v4 as uuid } from "uuid" - -export const newEmail = () => { - return `${uuid()}@test.com` -} - -export const user = (userProps?: any): User => { - return { - email: newEmail(), - password: "test", - roles: { app_test: "admin" }, - ...userProps, - } -} - -export const adminUser = (userProps?: any): AdminUser => { - return { - ...user(userProps), - admin: { - global: true, - }, - builder: { - global: true, - }, - } -} - -export const builderUser = (userProps?: any): BuilderUser => { - return { - ...user(userProps), - builder: { - global: true, - }, - } -} diff --git a/packages/worker/src/utilities/email.ts b/packages/worker/src/utilities/email.ts index 7ec3447707..66e860edcb 100644 --- a/packages/worker/src/utilities/email.ts +++ b/packages/worker/src/utilities/email.ts @@ -26,7 +26,7 @@ type SendEmailOpts = { automation?: boolean } -const TEST_MODE = false +const TEST_MODE = env.ENABLE_EMAIL_TEST_MODE && env.isDev() const TYPE = TemplateType.EMAIL const FULL_EMAIL_PURPOSES = [ @@ -62,8 +62,8 @@ function createSMTPTransport(config: any) { host: "smtp.ethereal.email", secure: false, auth: { - user: "don.bahringer@ethereal.email", - pass: "yCKSH8rWyUPbnhGYk9", + user: "wyatt.zulauf29@ethereal.email", + pass: "tEwDtHBWWxusVWAPfa", }, } }