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 <adria@revityapp.com>

* 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 <adria@revityapp.com>
This commit is contained in:
Rory Powell 2023-02-21 08:23:53 +00:00 committed by GitHub
parent a57f0c9dea
commit cacf275a99
68 changed files with 1803 additions and 1120 deletions

View File

@ -1,13 +1,24 @@
import API from "./api" import API from "./api"
import env from "../environment" import env from "../environment"
import { Header } from "../constants" import { Header } from "../constants"
import { CloudAccount } from "@budibase/types" import { CloudAccount, HealthStatusResponse } from "@budibase/types"
const api = new API(env.ACCOUNT_PORTAL_URL) 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 ( export const getAccount = async (
email: string email: string
): Promise<CloudAccount | undefined> => { ): Promise<CloudAccount | undefined> => {
if (EXIT_EARLY) {
return
}
const payload = { const payload = {
email, email,
} }
@ -29,6 +40,9 @@ export const getAccount = async (
export const getAccountByTenantId = async ( export const getAccountByTenantId = async (
tenantId: string tenantId: string
): Promise<CloudAccount | undefined> => { ): Promise<CloudAccount | undefined> => {
if (EXIT_EARLY) {
return
}
const payload = { const payload = {
tenantId, tenantId,
} }
@ -47,7 +61,12 @@ export const getAccountByTenantId = async (
return json[0] 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`, { const response = await api.get(`/api/status`, {
headers: { headers: {
[Header.API_KEY]: env.ACCOUNT_PORTAL_API_KEY, [Header.API_KEY]: env.ACCOUNT_PORTAL_API_KEY,

View File

@ -0,0 +1 @@
export * from "./accounts"

View File

@ -1,10 +1,11 @@
const _passport = require("koa-passport") const _passport = require("koa-passport")
const LocalStrategy = require("passport-local").Strategy const LocalStrategy = require("passport-local").Strategy
const JwtStrategy = require("passport-jwt").Strategy const JwtStrategy = require("passport-jwt").Strategy
import { getGlobalDB } from "../tenancy" import { getGlobalDB } from "../context"
const refresh = require("passport-oauth2-refresh") const refresh = require("passport-oauth2-refresh")
import { Config } from "../constants" import { Config, Cookie } from "../constants"
import { getScopedConfig } from "../db" import { getScopedConfig } from "../db"
import { getSessionsForUser, invalidateSessions } from "../security/sessions"
import { import {
jwt as jwtPassport, jwt as jwtPassport,
local, local,
@ -15,8 +16,11 @@ import {
google, google,
} from "../middleware" } from "../middleware"
import { invalidateUser } from "../cache/user" import { invalidateUser } from "../cache/user"
import { User } from "@budibase/types" import { PlatformLogoutOpts, User } from "@budibase/types"
import { logAlert } from "../logging" import { logAlert } from "../logging"
import * as events from "../events"
import * as userCache from "../cache/user"
import { clearCookie, getCookie } from "../utils"
export { export {
auditLog, auditLog,
authError, authError,
@ -29,6 +33,7 @@ export {
google, google,
oidc, oidc,
} from "../middleware" } from "../middleware"
import { ssoSaveUserNoOp } from "../middleware/passport/sso/sso"
export const buildAuthMiddleware = authenticated export const buildAuthMiddleware = authenticated
export const buildTenancyMiddleware = tenancy export const buildTenancyMiddleware = tenancy
export const buildCsrfMiddleware = csrf export const buildCsrfMiddleware = csrf
@ -71,7 +76,7 @@ async function refreshOIDCAccessToken(
if (!enrichedConfig) { if (!enrichedConfig) {
throw new Error("OIDC Config contents invalid") throw new Error("OIDC Config contents invalid")
} }
strategy = await oidc.strategyFactory(enrichedConfig) strategy = await oidc.strategyFactory(enrichedConfig, ssoSaveUserNoOp)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
throw new Error("Could not refresh OAuth Token") throw new Error("Could not refresh OAuth Token")
@ -103,7 +108,11 @@ async function refreshGoogleAccessToken(
let strategy let strategy
try { try {
strategy = await google.strategyFactory(config, callbackUrl) strategy = await google.strategyFactory(
config,
callbackUrl,
ssoSaveUserNoOp
)
} catch (err: any) { } catch (err: any) {
console.error(err) console.error(err)
throw new Error( throw new Error(
@ -161,6 +170,8 @@ export async function refreshOAuthToken(
return refreshResponse 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) { export async function updateUserOAuth(userId: string, oAuthConfig: any) {
const details = { const details = {
accessToken: oAuthConfig.accessToken, 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) 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)
}

View File

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

View File

@ -16,6 +16,7 @@ import {
InstallationGroup, InstallationGroup,
UserContext, UserContext,
Group, Group,
isSSOUser,
} from "@budibase/types" } from "@budibase/types"
import { processors } from "./processors" import { processors } from "./processors"
import * as dbUtils from "../db/utils" import * as dbUtils from "../db/utils"
@ -166,7 +167,10 @@ const identifyUser = async (
const type = IdentityType.USER const type = IdentityType.USER
let builder = user.builder?.global || false let builder = user.builder?.global || false
let admin = user.admin?.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 accountHolder = account?.budibaseUserId === user._id || false
const verified = const verified =
account && account?.budibaseUserId === user._id ? account.verified : false account && account?.budibaseUserId === user._id ? account.verified : false

View File

@ -1,10 +1,11 @@
import * as google from "../google" import * as google from "../sso/google"
import { Cookie, Config } from "../../../constants" import { Cookie, Config } from "../../../constants"
import { clearCookie, getCookie } from "../../../utils" import { clearCookie, getCookie } from "../../../utils"
import { getScopedConfig, getPlatformUrl, doWithDB } from "../../../db" import { getScopedConfig, getPlatformUrl, doWithDB } from "../../../db"
import environment from "../../../environment" import environment from "../../../environment"
import { getGlobalDB } from "../../../tenancy" import { getGlobalDB } from "../../../context"
import { BBContext, Database, SSOProfile } from "@budibase/types" import { BBContext, Database, SSOProfile } from "@budibase/types"
import { ssoSaveUserNoOp } from "../sso/sso"
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy
type Passport = { type Passport = {
@ -36,7 +37,11 @@ export async function preAuth(
const platformUrl = await getPlatformUrl({ tenantAware: false }) const platformUrl = await getPlatformUrl({ tenantAware: false })
let callbackUrl = `${platformUrl}/api/global/auth/datasource/google/callback` 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) { if (!ctx.query.appId || !ctx.query.datasourceId) {
ctx.throw(400, "appId and datasourceId query params not present.") ctx.throw(400, "appId and datasourceId query params not present.")

View File

@ -1,15 +1,10 @@
import { UserStatus } from "../../constants" import { UserStatus } from "../../constants"
import { compare, newid } from "../../utils" import { compare } from "../../utils"
import env from "../../environment"
import * as users from "../../users" import * as users from "../../users"
import { authError } from "./utils" import { authError } from "./utils"
import { createASession } from "../../security/sessions"
import { getTenantId } from "../../tenancy"
import { BBContext } from "@budibase/types" import { BBContext } from "@budibase/types"
const jwt = require("jsonwebtoken")
const INVALID_ERR = "Invalid credentials" 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" const EXPIRED = "This account has expired. Please reset your password"
export const options = { export const options = {
@ -35,50 +30,25 @@ export async function authenticate(
const dbUser = await users.getGlobalUserByEmail(email) const dbUser = await users.getGlobalUserByEmail(email)
if (dbUser == null) { if (dbUser == null) {
return authError(done, `User not found: [${email}]`) console.info(`user=${email} could not be found`)
}
// check that the user is currently inactive, if this is the case throw invalid
if (dbUser.status === UserStatus.INACTIVE) {
return authError(done, INVALID_ERR) return authError(done, INVALID_ERR)
} }
// check that the user has a stored password before proceeding if (dbUser.status === UserStatus.INACTIVE) {
if (!dbUser.password) { console.info(`user=${email} is inactive`, dbUser)
if ( return authError(done, INVALID_ERR)
(dbUser.account && dbUser.account.authType === "sso") || // root account sso }
dbUser.thirdPartyProfile // internal sso
) {
return authError(done, SSO_NO_PASSWORD)
}
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) return authError(done, EXPIRED)
} }
// authenticate if (!(await compare(password, dbUser.password))) {
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 {
return authError(done, INVALID_ERR) return authError(done, INVALID_ERR)
} }
// intentionally remove the users password in payload
delete dbUser.password
return done(null, dbUser)
} }

View File

@ -1,18 +1,26 @@
import { ssoCallbackUrl } from "./utils" import { ssoCallbackUrl } from "../utils"
import { authenticateThirdParty, SaveUserFunction } from "./third-party-common" import * as sso from "./sso"
import { ConfigType, GoogleConfig, Database, SSOProfile } from "@budibase/types" import {
ConfigType,
GoogleConfig,
Database,
SSOProfile,
SSOAuthDetails,
SSOProviderType,
SaveSSOUserFunction,
} from "@budibase/types"
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy
export function buildVerifyFn(saveUserFn?: SaveUserFunction) { export function buildVerifyFn(saveUserFn: SaveSSOUserFunction) {
return ( return (
accessToken: string, accessToken: string,
refreshToken: string, refreshToken: string,
profile: SSOProfile, profile: SSOProfile,
done: Function done: Function
) => { ) => {
const thirdPartyUser = { const details: SSOAuthDetails = {
provider: profile.provider, // should always be 'google' provider: "google",
providerType: "google", providerType: SSOProviderType.GOOGLE,
userId: profile.id, userId: profile.id,
profile: profile, profile: profile,
email: profile._json.email, email: profile._json.email,
@ -22,8 +30,8 @@ export function buildVerifyFn(saveUserFn?: SaveUserFunction) {
}, },
} }
return authenticateThirdParty( return sso.authenticate(
thirdPartyUser, details,
true, // require local accounts to exist true, // require local accounts to exist
done, done,
saveUserFn saveUserFn
@ -39,7 +47,7 @@ export function buildVerifyFn(saveUserFn?: SaveUserFunction) {
export async function strategyFactory( export async function strategyFactory(
config: GoogleConfig["config"], config: GoogleConfig["config"],
callbackUrl: string, callbackUrl: string,
saveUserFn?: SaveUserFunction saveUserFn: SaveSSOUserFunction
) { ) {
try { try {
const { clientID, clientSecret } = config const { clientID, clientSecret } = config

View File

@ -1,22 +1,20 @@
import fetch from "node-fetch" import fetch from "node-fetch"
import { authenticateThirdParty, SaveUserFunction } from "./third-party-common" import * as sso from "./sso"
import { ssoCallbackUrl } from "./utils" import { ssoCallbackUrl } from "../utils"
import { import {
ConfigType, ConfigType,
OIDCInnerCfg, OIDCInnerConfig,
Database, Database,
SSOProfile, SSOProfile,
ThirdPartyUser, OIDCStrategyConfiguration,
OIDCConfiguration, SSOAuthDetails,
SSOProviderType,
JwtClaims,
SaveSSOUserFunction,
} from "@budibase/types" } from "@budibase/types"
const OIDCStrategy = require("@techpass/passport-openidconnect").Strategy const OIDCStrategy = require("@techpass/passport-openidconnect").Strategy
type JwtClaims = { export function buildVerifyFn(saveUserFn: SaveSSOUserFunction) {
preferred_username: string
email: string
}
export function buildVerifyFn(saveUserFn?: SaveUserFunction) {
/** /**
* @param {*} issuer The identity provider base URL * @param {*} issuer The identity provider base URL
* @param {*} sub The user ID * @param {*} sub The user ID
@ -39,10 +37,10 @@ export function buildVerifyFn(saveUserFn?: SaveUserFunction) {
params: any, params: any,
done: Function done: Function
) => { ) => {
const thirdPartyUser: ThirdPartyUser = { const details: SSOAuthDetails = {
// store the issuer info to enable sync in future // store the issuer info to enable sync in future
provider: issuer, provider: issuer,
providerType: "oidc", providerType: SSOProviderType.OIDC,
userId: profile.id, userId: profile.id,
profile: profile, profile: profile,
email: getEmail(profile, jwtClaims), email: getEmail(profile, jwtClaims),
@ -52,8 +50,8 @@ export function buildVerifyFn(saveUserFn?: SaveUserFunction) {
}, },
} }
return authenticateThirdParty( return sso.authenticate(
thirdPartyUser, details,
false, // don't require local accounts to exist false, // don't require local accounts to exist
done, done,
saveUserFn saveUserFn
@ -104,8 +102,8 @@ function validEmail(value: string) {
* @returns Dynamically configured Passport OIDC Strategy * @returns Dynamically configured Passport OIDC Strategy
*/ */
export async function strategyFactory( export async function strategyFactory(
config: OIDCConfiguration, config: OIDCStrategyConfiguration,
saveUserFn?: SaveUserFunction saveUserFn: SaveSSOUserFunction
) { ) {
try { try {
const verify = buildVerifyFn(saveUserFn) const verify = buildVerifyFn(saveUserFn)
@ -119,14 +117,14 @@ export async function strategyFactory(
} }
export async function fetchStrategyConfig( export async function fetchStrategyConfig(
enrichedConfig: OIDCInnerCfg, oidcConfig: OIDCInnerConfig,
callbackUrl?: string callbackUrl?: string
): Promise<OIDCConfiguration> { ): Promise<OIDCStrategyConfiguration> {
try { try {
const { clientID, clientSecret, configUrl } = enrichedConfig const { clientID, clientSecret, configUrl } = oidcConfig
if (!clientID || !clientSecret || !callbackUrl || !configUrl) { if (!clientID || !clientSecret || !callbackUrl || !configUrl) {
//check for remote config and all required elements // check for remote config and all required elements
throw new Error( throw new Error(
"Configuration invalid. Must contain clientID, clientSecret, callbackUrl and configUrl" "Configuration invalid. Must contain clientID, clientSecret, callbackUrl and configUrl"
) )

View File

@ -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<SSOUser> {
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,
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<any>
/**
* 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
}

View File

@ -8,6 +8,7 @@ import {
} from "./db" } from "./db"
import { BulkDocsResponse, User } from "@budibase/types" import { BulkDocsResponse, User } from "@budibase/types"
import { getGlobalDB } from "./context" import { getGlobalDB } from "./context"
import * as context from "./context"
export const bulkGetGlobalUsersById = async (userIds: string[]) => { export const bulkGetGlobalUsersById = async (userIds: string[]) => {
const db = getGlobalDB() const db = getGlobalDB()
@ -24,6 +25,11 @@ export const bulkUpdateGlobalUsers = async (users: User[]) => {
return (await db.bulkDocs(users)) as BulkDocsResponse return (await db.bulkDocs(users)) as BulkDocsResponse
} }
export async function getById(id: string): Promise<User> {
const db = context.getGlobalDB()
return db.get(id)
}
/** /**
* Given an email address this will use a view to search through * Given an email address this will use a view to search through
* all the users to find one with this email address. * all the users to find one with this email address.

View File

@ -2,23 +2,15 @@ import { getAllApps, queryGlobalView } from "../db"
import { options } from "../middleware/passport/jwt" import { options } from "../middleware/passport/jwt"
import { import {
Header, Header,
Cookie,
MAX_VALID_DATE, MAX_VALID_DATE,
DocumentType, DocumentType,
SEPARATOR, SEPARATOR,
ViewName, ViewName,
} from "../constants" } from "../constants"
import env from "../environment" 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 * as tenancy from "../tenancy"
import { import * as context from "../context"
App, import { App, Ctx, TenantResolutionStrategy } from "@budibase/types"
Ctx,
PlatformLogoutOpts,
TenantResolutionStrategy,
} from "@budibase/types"
import { SetOption } from "cookies" import { SetOption } from "cookies"
const jwt = require("jsonwebtoken") const jwt = require("jsonwebtoken")
@ -38,7 +30,7 @@ export async function resolveAppUrl(ctx: Ctx) {
const appUrl = ctx.path.split("/")[2] const appUrl = ctx.path.split("/")[2]
let possibleAppUrl = `/${appUrl.toLowerCase()}` let possibleAppUrl = `/${appUrl.toLowerCase()}`
let tenantId: string | null = tenancy.getTenantId() let tenantId: string | null = context.getTenantId()
if (env.MULTI_TENANCY) { if (env.MULTI_TENANCY) {
// always use the tenant id from the subdomain in multi tenancy // always use the tenant id from the subdomain in multi tenancy
// this ensures the logged-in user tenant id doesn't overwrite // 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 // 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 }) getAllApps({ dev: false })
) )
const app = apps.filter( const app = apps.filter(
@ -222,35 +214,6 @@ export async function getBuildersCount() {
return builders.length 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) { export function timeout(timeMs: number) {
return new Promise(resolve => setTimeout(resolve, timeMs)) return new Promise(resolve => setTimeout(resolve, timeMs))
} }

View File

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

View File

@ -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 date from "./date"
export * as licenses from "./licenses" export * as licenses from "./licenses"
export { default as fetch } from "./fetch" export { default as fetch } from "./fetch"

View File

@ -1,6 +1,15 @@
import { generator, uuid } from "." import { generator, uuid } from "."
import * as db from "../../../src/db/utils" 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 => { export const account = (): Account => {
return { return {
@ -27,3 +36,28 @@ export const cloudAccount = (): CloudAccount => {
budibaseUserId: db.generateGlobalUserID(), 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: {},
}
}

View File

@ -5,8 +5,10 @@ export const generator = new Chance()
export * as accounts from "./accounts" export * as accounts from "./accounts"
export * as apps from "./apps" export * as apps from "./apps"
export * as db from "./db"
export * as koa from "./koa" export * as koa from "./koa"
export * as licenses from "./licenses" export * as licenses from "./licenses"
export * as plugins from "./plugins" export * as plugins from "./plugins"
export * as sso from "./sso"
export * as tenant from "./tenants" export * as tenant from "./tenants"
export * as db from "./db" export * as users from "./users"

View File

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

View File

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

View File

@ -30,9 +30,11 @@
My profile My profile
</MenuItem> </MenuItem>
<MenuItem icon="Moon" on:click={() => themeModal.show()}>Theme</MenuItem> <MenuItem icon="Moon" on:click={() => themeModal.show()}>Theme</MenuItem>
<MenuItem icon="LockClosed" on:click={() => updatePasswordModal.show()}> {#if !$auth.isSSO}
Update password <MenuItem icon="LockClosed" on:click={() => updatePasswordModal.show()}>
</MenuItem> Update password
</MenuItem>
{/if}
<MenuItem icon="Key" on:click={() => apiKeyModal.show()}> <MenuItem icon="Key" on:click={() => apiKeyModal.show()}>
View API key View API key
</MenuItem> </MenuItem>

View File

@ -81,6 +81,7 @@
let user let user
let loaded = false let loaded = false
$: isSSO = !!user?.provider
$: readonly = !$auth.isAdmin $: readonly = !$auth.isAdmin
$: fullName = user?.firstName ? user?.firstName + " " + user?.lastName : "" $: fullName = user?.firstName ? user?.firstName + " " + user?.lastName : ""
$: privileged = user?.admin?.global || user?.builder?.global $: privileged = user?.admin?.global || user?.builder?.global
@ -246,9 +247,11 @@
<span slot="control"> <span slot="control">
<Icon hoverable name="More" /> <Icon hoverable name="More" />
</span> </span>
<MenuItem on:click={resetPasswordModal.show} icon="Refresh"> {#if !isSSO}
Force password reset <MenuItem on:click={resetPasswordModal.show} icon="Refresh">
</MenuItem> Force password reset
</MenuItem>
{/if}
<MenuItem on:click={deleteModal.show} icon="Delete"> <MenuItem on:click={deleteModal.show} icon="Delete">
Delete Delete
</MenuItem> </MenuItem>

View File

@ -41,6 +41,7 @@ export function createAuthStore() {
initials, initials,
isAdmin, isAdmin,
isBuilder, isBuilder,
isSSO: !!$store.user?.provider,
} }
}) })

View File

@ -817,7 +817,6 @@
"type": "string", "type": "string",
"enum": [ "enum": [
"string", "string",
"barcodeqr",
"longform", "longform",
"options", "options",
"number", "number",
@ -829,7 +828,8 @@
"formula", "formula",
"auto", "auto",
"json", "json",
"internal" "internal",
"barcodeqr"
], ],
"description": "Defines the type of the column, most explain themselves, a link column is a relationship." "description": "Defines the type of the column, most explain themselves, a link column is a relationship."
}, },
@ -1021,7 +1021,6 @@
"type": "string", "type": "string",
"enum": [ "enum": [
"string", "string",
"barcodeqr",
"longform", "longform",
"options", "options",
"number", "number",
@ -1033,7 +1032,8 @@
"formula", "formula",
"auto", "auto",
"json", "json",
"internal" "internal",
"barcodeqr"
], ],
"description": "Defines the type of the column, most explain themselves, a link column is a relationship." "description": "Defines the type of the column, most explain themselves, a link column is a relationship."
}, },
@ -1236,7 +1236,6 @@
"type": "string", "type": "string",
"enum": [ "enum": [
"string", "string",
"barcodeqr",
"longform", "longform",
"options", "options",
"number", "number",
@ -1248,7 +1247,8 @@
"formula", "formula",
"auto", "auto",
"json", "json",
"internal" "internal",
"barcodeqr"
], ],
"description": "Defines the type of the column, most explain themselves, a link column is a relationship." "description": "Defines the type of the column, most explain themselves, a link column is a relationship."
}, },

View File

@ -603,7 +603,6 @@ components:
type: string type: string
enum: enum:
- string - string
- barcodeqr
- longform - longform
- options - options
- number - number
@ -616,6 +615,7 @@ components:
- auto - auto
- json - json
- internal - internal
- barcodeqr
description: Defines the type of the column, most explain themselves, a link description: Defines the type of the column, most explain themselves, a link
column is a relationship. column is a relationship.
constraints: constraints:
@ -766,7 +766,6 @@ components:
type: string type: string
enum: enum:
- string - string
- barcodeqr
- longform - longform
- options - options
- number - number
@ -779,6 +778,7 @@ components:
- auto - auto
- json - json
- internal - internal
- barcodeqr
description: Defines the type of the column, most explain themselves, a link description: Defines the type of the column, most explain themselves, a link
column is a relationship. column is a relationship.
constraints: constraints:
@ -936,7 +936,6 @@ components:
type: string type: string
enum: enum:
- string - string
- barcodeqr
- longform - longform
- options - options
- number - number
@ -949,6 +948,7 @@ components:
- auto - auto
- json - json
- internal - internal
- barcodeqr
description: Defines the type of the column, most explain themselves, a link description: Defines the type of the column, most explain themselves, a link
column is a relationship. column is a relationship.
constraints: constraints:

View File

@ -1,5 +1,3 @@
import { DatabaseWithConnection } from "@budibase/backend-core/src/db"
jest.mock("@budibase/backend-core", () => { jest.mock("@budibase/backend-core", () => {
const core = jest.requireActual("@budibase/backend-core") const core = jest.requireActual("@budibase/backend-core")
return { return {

View File

@ -1,2 +1,3 @@
export * from "./user" export * from "./user"
export * from "./license" export * from "./license"
export * from "./status"

View File

@ -0,0 +1,7 @@
export interface HealthStatusResponse {
passing: boolean
checks: {
login: boolean
search: boolean
}
}

View File

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

View File

@ -1,4 +1,5 @@
export * from "./analytics" export * from "./analytics"
export * from "./auth"
export * from "./user" export * from "./user"
export * from "./errors" export * from "./errors"
export * from "./schedule" export * from "./schedule"

View File

@ -1,6 +1,6 @@
import { User } from "../../documents" import { User } from "../../documents"
export interface CreateUserResponse { export interface SaveUserResponse {
_id: string _id: string
_rev: string _rev: string
email: string email: string
@ -58,6 +58,25 @@ export interface CreateAdminUserRequest {
tenantId: string 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 { export interface SyncUserRequest {
previousUser?: User previousUser?: User
} }

View File

@ -79,14 +79,24 @@ export const isSelfHostAccount = (account: Account) =>
export const isSSOAccount = (account: Account): account is SSOAccount => export const isSSOAccount = (account: Account): account is SSOAccount =>
account.authType === AuthType.SSO account.authType === AuthType.SSO
export interface SSOAccount extends Account { export enum AccountSSOProviderType {
pictureUrl?: string GOOGLE = "google",
provider?: string }
providerType?: string
export enum AccountSSOProvider {
GOOGLE = "google",
}
export interface AccountSSO {
provider: AccountSSOProvider
providerType: AccountSSOProviderType
oauth2?: OAuthTokens oauth2?: OAuthTokens
pictureUrl?: string
thirdPartyProfile: any // TODO: define what the google profile looks like thirdPartyProfile: any // TODO: define what the google profile looks like
} }
export type SSOAccount = (Account | CloudAccount) & AccountSSO
export enum AuthType { export enum AuthType {
SSO = "sso", SSO = "sso",
PASSWORD = "password", PASSWORD = "password",

View File

@ -27,15 +27,17 @@ export interface SettingsConfig extends Config {
} }
} }
export interface GoogleConfig extends Config { export interface GoogleInnerConfig {
config: { clientID: string
clientID: string clientSecret: string
clientSecret: string activated: boolean
activated: boolean
}
} }
export interface OIDCConfiguration { export interface GoogleConfig extends Config {
config: GoogleInnerConfig
}
export interface OIDCStrategyConfiguration {
issuer: string issuer: string
authorizationURL: string authorizationURL: string
tokenURL: string tokenURL: string
@ -45,7 +47,7 @@ export interface OIDCConfiguration {
callbackURL: string callbackURL: string
} }
export interface OIDCInnerCfg { export interface OIDCInnerConfig {
configUrl: string configUrl: string
clientID: string clientID: string
clientSecret: string clientSecret: string
@ -57,10 +59,17 @@ export interface OIDCInnerCfg {
export interface OIDCConfig extends Config { export interface OIDCConfig extends Config {
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 => export const isSettingsConfig = (config: Config): config is SettingsConfig =>
config.type === ConfigType.SETTINGS config.type === ConfigType.SETTINGS

View File

@ -1,37 +1,44 @@
import { Document } from "../document" import { Document } from "../document"
export interface SSOProfile { // SSO
id: string
name?: { export interface SSOProfileJson {
givenName?: string email?: string
familyName?: string picture?: string
}
_json: {
email: string
picture: string
}
provider?: string
} }
export interface ThirdPartyUser extends Document { export interface OAuth2 {
thirdPartyProfile?: SSOProfile["_json"] accessToken: string
firstName?: string refreshToken?: string
lastName?: string
pictureUrl?: string
profile?: SSOProfile
oauth2?: any
provider?: string
providerType?: string
email: string
userId?: string
forceResetPassword?: boolean
userGroups?: 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 tenantId: string
email: string email: string
userId?: string userId?: string
firstName?: string
lastName?: string
pictureUrl?: string
forceResetPassword?: boolean forceResetPassword?: boolean
roles: UserRoles roles: UserRoles
builder?: { builder?: {
@ -44,9 +51,7 @@ export interface User extends ThirdPartyUser {
status?: string status?: string
createdAt?: number // override the default createdAt behaviour - users sdk historically set this to Date.now() createdAt?: number // override the default createdAt behaviour - users sdk historically set this to Date.now()
dayPassRecordedAt?: string dayPassRecordedAt?: string
account?: { userGroups?: string[]
authType: string
}
onboardedAt?: string onboardedAt?: string
} }
@ -54,7 +59,7 @@ export interface UserRoles {
[key: string]: string [key: string]: string
} }
// utility types // UTILITY TYPES
export interface BuilderUser extends User { export interface BuilderUser extends User {
builder: { builder: {

View File

@ -12,3 +12,5 @@ export * from "./db"
export * from "./middleware" export * from "./middleware"
export * from "./featureFlag" export * from "./featureFlag"
export * from "./environmentVariables" export * from "./environmentVariables"
export * from "./sso"
export * from "./user"

View File

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

View File

@ -0,0 +1,12 @@
export interface UpdateSelf {
firstName?: string
lastName?: string
password?: string
forceResetPassword?: boolean
}
export interface SaveUserOpts {
hashPassword?: boolean
requirePassword?: boolean
currentUserId?: string
}

View File

@ -29,6 +29,7 @@ async function init() {
SERVICE: "worker-service", SERVICE: "worker-service",
DEPLOYMENT_ENVIRONMENT: "development", DEPLOYMENT_ENVIRONMENT: "development",
TENANT_FEATURE_FLAGS: "*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR", TENANT_FEATURE_FLAGS: "*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR",
ENABLE_EMAIL_TEST_MODE: 1,
} }
let envFile = "" let envFile = ""
Object.keys(envFileJson).forEach(key => { Object.keys(envFileJson).forEach(key => {

View File

@ -1,49 +1,55 @@
import { import {
auth, auth as authCore,
constants, constants,
context, context,
db as dbCore, db as dbCore,
events, events,
tenancy, tenancy,
users as usersCore, utils as utilsCore,
utils,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { EmailTemplatePurpose } from "../../../constants" import {
import { isEmailConfigured, sendEmail } from "../../../utilities/email" ConfigType,
import { checkResetPasswordCode } from "../../../utilities/redis" User,
Ctx,
LoginRequest,
SSOUser,
PasswordResetRequest,
PasswordResetUpdateRequest,
} from "@budibase/types"
import env from "../../../environment" 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 { 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 }) { // LOGIN / LOGOUT
return ssoCallbackUrl(tenancy.getGlobalDB(), config, ConfigType.GOOGLE)
}
export async function oidcCallbackUrl(config?: { callbackURL?: string }) { async function passportCallback(
return ssoCallbackUrl(tenancy.getGlobalDB(), config, ConfigType.OIDC) ctx: Ctx,
} user: User,
err: any = null,
async function authInternal(ctx: any, user: any, err: any = null, info = null) { info: { message: string } | null = null
) {
if (err) { if (err) {
console.error("Authentication error") console.error("Authentication error")
console.error(err) console.error(err)
console.trace(err) console.trace(err)
return ctx.throw(403, info ? info : "Unauthorized") return ctx.throw(403, info ? info : "Unauthorized")
} }
if (!user) { if (!user) {
console.error("Authentication error - no user provided") console.error("Authentication error - no user provided")
return ctx.throw(403, info ? info : "Unauthorized") return ctx.throw(403, info ? info : "Unauthorized")
} }
const token = await authSdk.loginUser(user)
// set a cookie for browser access // 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 // 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 // get rid of any app cookies on login
// have to check test because this breaks cypress // have to check test because this breaks cypress
if (!env.isTest()) { 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<LoginRequest>, 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( return passport.authenticate(
"local", "local",
async (err: any, user: User, info: any) => { 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 context.identity.doInUserContext(user, async () => {
await events.auth.login("local") await events.auth.login("local")
}) })
@ -64,6 +77,15 @@ export const authenticate = async (ctx: any, next: any) => {
)(ctx, next) )(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) => { export const setInitInfo = (ctx: any) => {
const initInfo = ctx.request.body const initInfo = ctx.request.body
setCookie(ctx, initInfo, Cookie.Init) 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. * Reset the user password, used as part of a forgotten password flow.
*/ */
export const reset = async (ctx: any) => { export const reset = async (ctx: Ctx<PasswordResetRequest>) => {
const { email } = ctx.request.body const { email } = ctx.request.body
const configured = await isEmailConfigured()
if (!configured) { await authSdk.reset(email)
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
}
ctx.body = { ctx.body = {
message: "Please check your email for a reset link.", 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. * Perform the user password update if the provided reset code is valid.
*/ */
export const resetUpdate = async (ctx: any) => { export const resetUpdate = async (ctx: Ctx<PasswordResetUpdateRequest>) => {
const { resetCode, password } = ctx.request.body const { resetCode, password } = ctx.request.body
try { try {
const { userId } = await checkResetPasswordCode(resetCode) await authSdk.resetUpdate(resetCode, password)
const db = tenancy.getGlobalDB()
const user = await db.get(userId)
user.password = await hash(password)
await db.put(user)
ctx.body = { ctx.body = {
message: "password reset successfully.", message: "password reset successfully.",
} }
// remove password from the user before sending events
delete user.password
await events.user.passwordReset(user)
} catch (err) { } catch (err) {
console.error(err) console.warn(err)
// hide any details of the error for security
ctx.throw(400, "Cannot reset password.") ctx.throw(400, "Cannot reset password.")
} }
} }
export const logout = async (ctx: any) => { // DATASOURCE
if (ctx.user && ctx.user._id) {
await platformLogout({ ctx, userId: ctx.user._id })
}
ctx.body = { message: "User logged out." }
}
export const datasourcePreAuth = async (ctx: any, next: any) => { export const datasourcePreAuth = async (ctx: any, next: any) => {
const provider = ctx.params.provider const provider = ctx.params.provider
@ -166,6 +161,12 @@ export const datasourceAuth = async (ctx: any, next: any) => {
return handler.postAuth(passport, ctx, next) 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. * 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. * 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( const strategy = await google.strategyFactory(
config, config,
callbackUrl, callbackUrl,
sdk.users.save userSdk.save
) )
return passport.authenticate(strategy, { return passport.authenticate(strategy, {
@ -191,7 +192,7 @@ export const googlePreAuth = async (ctx: any, next: any) => {
})(ctx, next) })(ctx, next)
} }
export const googleAuth = async (ctx: any, next: any) => { export const googleCallback = async (ctx: any, next: any) => {
const db = tenancy.getGlobalDB() const db = tenancy.getGlobalDB()
const config = await dbCore.getScopedConfig(db, { const config = await dbCore.getScopedConfig(db, {
@ -202,14 +203,14 @@ export const googleAuth = async (ctx: any, next: any) => {
const strategy = await google.strategyFactory( const strategy = await google.strategyFactory(
config, config,
callbackUrl, callbackUrl,
sdk.users.save userSdk.save
) )
return passport.authenticate( return passport.authenticate(
strategy, strategy,
{ successRedirect: "/", failureRedirect: "/error" }, { successRedirect: "/", failureRedirect: "/error" },
async (err: any, user: User, info: any) => { async (err: any, user: SSOUser, info: any) => {
await authInternal(ctx, user, err, info) await passportCallback(ctx, user, err, info)
await context.identity.doInUserContext(user, async () => { await context.identity.doInUserContext(user, async () => {
await events.auth.login("google-internal") await events.auth.login("google-internal")
}) })
@ -218,6 +219,12 @@ export const googleAuth = async (ctx: any, next: any) => {
)(ctx, next) )(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) => { export const oidcStrategyFactory = async (ctx: any, configId: any) => {
const db = tenancy.getGlobalDB() const db = tenancy.getGlobalDB()
const config = await dbCore.getScopedConfig(db, { const config = await dbCore.getScopedConfig(db, {
@ -233,7 +240,7 @@ export const oidcStrategyFactory = async (ctx: any, configId: any) => {
chosenConfig, chosenConfig,
callbackUrl 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) })(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 configId = getCookie(ctx, Cookie.OIDC_CONFIG)
const strategy = await oidcStrategyFactory(ctx, configId) const strategy = await oidcStrategyFactory(ctx, configId)
return passport.authenticate( return passport.authenticate(
strategy, strategy,
{ successRedirect: "/", failureRedirect: "/error" }, { successRedirect: "/", failureRedirect: "/error" },
async (err: any, user: any, info: any) => { async (err: any, user: SSOUser, info: any) => {
await authInternal(ctx, user, err, info) await passportCallback(ctx, user, err, info)
await context.identity.doInUserContext(user, async () => { await context.identity.doInUserContext(user, async () => {
await events.auth.login("oidc") await events.auth.login("oidc")
}) })

View File

@ -1,18 +1,22 @@
import sdk from "../../../sdk" import * as userSdk from "../../../sdk/users"
import { import {
events,
featureFlags, featureFlags,
tenancy, tenancy,
constants, constants,
db as dbCore, db as dbCore,
utils, utils,
cache,
encryption, encryption,
auth as authCore,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import env from "../../../environment" import env from "../../../environment"
import { groups } from "@budibase/pro" import { groups } from "@budibase/pro"
const { hash, platformLogout, getCookie, clearCookie, newid } = utils import {
const { user: userCache } = cache UpdateSelfRequest,
UpdateSelfResponse,
UpdateSelf,
UserCtx,
} from "@budibase/types"
const { getCookie, clearCookie, newid } = utils
function newTestApiKey() { function newTestApiKey() {
return env.ENCRYPTED_TEST_PUBLIC_API_KEY return env.ENCRYPTED_TEST_PUBLIC_API_KEY
@ -93,17 +97,6 @@ const addSessionAttributesToUser = (ctx: any) => {
ctx.body.csrfToken = ctx.user.csrfToken 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) { export async function getSelf(ctx: any) {
if (!ctx.user) { if (!ctx.user) {
ctx.throw(403, "User not logged in") ctx.throw(403, "User not logged in")
@ -116,7 +109,7 @@ export async function getSelf(ctx: any) {
checkCurrentApp(ctx) checkCurrentApp(ctx)
// get the main body of the user // 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) ctx.body = await groups.enrichUserRolesFromGroups(user)
// add the feature flags for this tenant // add the feature flags for this tenant
@ -126,39 +119,30 @@ export async function getSelf(ctx: any) {
addSessionAttributesToUser(ctx) addSessionAttributesToUser(ctx)
} }
export async function updateSelf(ctx: any) { export async function updateSelf(
const db = tenancy.getGlobalDB() ctx: UserCtx<UpdateSelfRequest, UpdateSelfResponse>
const user = await db.get(ctx.user._id) ) {
let passwordChange = false const body = ctx.request.body
const update: UpdateSelf = {
firstName: body.firstName,
lastName: body.lastName,
password: body.password,
forceResetPassword: body.forceResetPassword,
}
const userUpdateObj = sanitiseUserUpdate(ctx) const user = await userSdk.updateSelf(ctx.user._id!, update)
if (userUpdateObj.password) {
// changing password if (update.password) {
passwordChange = true
userUpdateObj.password = await hash(userUpdateObj.password)
// Log all other sessions out apart from the current one // Log all other sessions out apart from the current one
await platformLogout({ await authCore.platformLogout({
ctx, ctx,
userId: ctx.user._id, userId: ctx.user._id!,
keepActiveSession: true, keepActiveSession: true,
}) })
} }
const response = await db.put({
...user,
...userUpdateObj,
})
await userCache.invalidateUser(user._id)
ctx.body = { ctx.body = {
_id: response.id, _id: user._id!,
_rev: response.rev, _rev: user._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)
} }
} }

View File

@ -2,15 +2,21 @@ import { checkInviteCode } from "../../../utilities/redis"
import * as userSdk from "../../../sdk/users" import * as userSdk from "../../../sdk/users"
import env from "../../../environment" import env from "../../../environment"
import { import {
AcceptUserInviteRequest,
AcceptUserInviteResponse,
BulkUserRequest, BulkUserRequest,
BulkUserResponse, BulkUserResponse,
CloudAccount, CloudAccount,
CreateAdminUserRequest, CreateAdminUserRequest,
CreateAdminUserResponse,
Ctx,
InviteUserRequest, InviteUserRequest,
InviteUsersRequest, InviteUsersRequest,
MigrationType, MigrationType,
SaveUserResponse,
SearchUsersRequest, SearchUsersRequest,
User, User,
UserCtx,
} from "@budibase/types" } from "@budibase/types"
import { import {
accounts, accounts,
@ -25,10 +31,18 @@ import { checkAnyUserExists } from "../../../utilities/users"
const MAX_USERS_UPLOAD_LIMIT = 1000 const MAX_USERS_UPLOAD_LIMIT = 1000
export const save = async (ctx: any) => { export const save = async (ctx: UserCtx<User, SaveUserResponse>) => {
try { try {
const currentUserId = ctx.user._id 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) { } catch (err: any) {
ctx.throw(err.status || 400, err) ctx.throw(err.status || 400, err)
} }
@ -71,9 +85,10 @@ const parseBooleanParam = (param: any) => {
return !(param && param === "false") return !(param && param === "false")
} }
export const adminUser = async (ctx: any) => { export const adminUser = async (
const { email, password, tenantId } = ctx.request ctx: Ctx<CreateAdminUserRequest, CreateAdminUserResponse>
.body as CreateAdminUserRequest ) => {
const { email, password, tenantId } = ctx.request.body
if (await platform.tenants.exists(tenantId)) { if (await platform.tenants.exists(tenantId)) {
ctx.throw(403, "Organisation already exists.") ctx.throw(403, "Organisation already exists.")
@ -131,7 +146,11 @@ export const adminUser = async (ctx: any) => {
} }
await events.identification.identifyTenantGroup(tenantId, account) await events.identification.identifyTenantGroup(tenantId, account)
ctx.body = finalUser ctx.body = {
_id: finalUser._id!,
_rev: finalUser._rev!,
email: finalUser.email,
}
} catch (err: any) { } catch (err: any) {
ctx.throw(err.status || 400, err) 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<AcceptUserInviteRequest, AcceptUserInviteResponse>
) => {
const { inviteCode, password, firstName, lastName } = ctx.request.body const { inviteCode, password, firstName, lastName } = ctx.request.body
try { try {
// info is an extension of the user object that was stored by global // info is an extension of the user object that was stored by global
const { email, info }: any = await checkInviteCode(inviteCode) 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({ const saved = await userSdk.save({
firstName, firstName,
lastName, lastName,
@ -254,6 +275,12 @@ export const inviteAccept = async (ctx: any) => {
await events.user.inviteAccepted(user) await events.user.inviteAccepted(user)
return saved return saved
}) })
ctx.body = {
_id: user._id,
_rev: user._rev,
email: user.email,
}
} catch (err: any) { } catch (err: any) {
if (err.code === errors.codes.USAGE_LIMIT_EXCEEDED) { if (err.code === errors.codes.USAGE_LIMIT_EXCEEDED) {
// explicitly re-throw limit exceeded errors // explicitly re-throw limit exceeded errors

View File

@ -1,21 +1,23 @@
import { Account, AccountMetadata } from "@budibase/types" import { Account, AccountMetadata } from "@budibase/types"
import sdk from "../../../sdk" import * as accounts from "../../../sdk/accounts"
export const save = async (ctx: any) => { export const save = async (ctx: any) => {
const account = ctx.request.body as Account const account = ctx.request.body as Account
let metadata: AccountMetadata = { let metadata: AccountMetadata = {
_id: sdk.accounts.formatAccountMetadataId(account.accountId), _id: accounts.metadata.formatAccountMetadataId(account.accountId),
email: account.email, email: account.email,
} }
metadata = await sdk.accounts.saveMetadata(metadata) metadata = await accounts.metadata.saveMetadata(metadata)
ctx.body = metadata ctx.body = metadata
ctx.status = 200 ctx.status = 200
} }
export const destroy = async (ctx: any) => { export const destroy = async (ctx: any) => {
const accountId = sdk.accounts.formatAccountMetadataId(ctx.params.accountId) const accountId = accounts.metadata.formatAccountMetadataId(
await sdk.accounts.destroyMetadata(accountId) ctx.params.accountId
)
await accounts.metadata.destroyMetadata(accountId)
ctx.status = 204 ctx.status = 204
} }

View File

@ -33,7 +33,7 @@ router
.post( .post(
"/api/global/auth/:tenantId/login", "/api/global/auth/:tenantId/login",
buildAuthValidation(), buildAuthValidation(),
authController.authenticate authController.login
) )
.post("/api/global/auth/logout", authController.logout) .post("/api/global/auth/logout", authController.logout)
.post( .post(
@ -68,21 +68,24 @@ router
// GOOGLE - MULTI TENANT // GOOGLE - MULTI TENANT
.get("/api/global/auth/:tenantId/google", authController.googlePreAuth) .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 // GOOGLE - SINGLE TENANT - DEPRECATED
.get("/api/global/auth/google/callback", authController.googleAuth) .get("/api/global/auth/google/callback", authController.googleCallback)
.get("/api/admin/auth/google/callback", authController.googleAuth) .get("/api/admin/auth/google/callback", authController.googleCallback)
// OIDC - MULTI TENANT // OIDC - MULTI TENANT
.get( .get(
"/api/global/auth/:tenantId/oidc/configs/:configId", "/api/global/auth/:tenantId/oidc/configs/:configId",
authController.oidcPreAuth authController.oidcPreAuth
) )
.get("/api/global/auth/:tenantId/oidc/callback", authController.oidcAuth) .get("/api/global/auth/:tenantId/oidc/callback", authController.oidcCallback)
// OIDC - SINGLE TENANT - DEPRECATED // OIDC - SINGLE TENANT - DEPRECATED
.get("/api/global/auth/oidc/callback", authController.oidcAuth) .get("/api/global/auth/oidc/callback", authController.oidcCallback)
.get("/api/admin/auth/oidc/callback", authController.oidcAuth) .get("/api/admin/auth/oidc/callback", authController.oidcCallback)
export default router export default router

View File

@ -1,13 +1,27 @@
jest.mock("nodemailer") import { CloudAccount, SSOUser, User } from "@budibase/types"
import { TestConfiguration, mocks } from "../../../../tests"
const sendMailMock = mocks.email.mock()
import { events, tenancy } from "@budibase/backend-core"
import { structures } from "@budibase/backend-core/tests"
const expectSetAuthCookie = (res: any) => { jest.mock("nodemailer")
expect( import {
res.get("Set-Cookie").find((c: string) => c.startsWith("budibase:auth")) TestConfiguration,
).toBeDefined() 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", () => { describe("/api/global/auth", () => {
@ -25,60 +39,247 @@ describe("/api/global/auth", () => {
jest.clearAllMocks() jest.clearAllMocks()
}) })
async function createSSOUser() {
return config.doInTenant(async () => {
return userSdk.save(structures.users.ssoUser(), {
requirePassword: false,
})
})
}
describe("password", () => { describe("password", () => {
describe("POST /api/global/auth/:tenantId/login", () => { 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", () => { describe("POST /api/global/auth/logout", () => {
it("should logout", async () => { it("should logout", async () => {
await config.api.auth.logout() const response = await config.api.auth.logout()
expect(events.auth.logout).toBeCalledTimes(1) expect(events.auth.logout).toBeCalledTimes(1)
// TODO: Verify sessions deleted const authCookie = getAuthCookie(response)
expect(authCookie).toBe("")
}) })
}) })
describe("POST /api/global/auth/:tenantId/reset", () => { describe("POST /api/global/auth/:tenantId/reset", () => {
it("should generate password reset email", async () => { it("should generate password reset email", async () => {
await tenancy.doInTenant(config.tenant1User!.tenantId, async () => { const user = await config.createUser()
const userEmail = structures.email()
const { res, code } = await config.api.auth.requestPasswordReset( 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, sendMailMock,
userEmail user.email,
{ status: 400 }
) )
const user = await config.getUser(userEmail)
expect(res.body).toEqual({ 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() describe("budibase sso user", () => {
expect(events.user.passwordResetRequested).toBeCalledTimes(1) it("should prevent user from generating password reset email", async () => {
expect(events.user.passwordResetRequested).toBeCalledWith(user) 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", () => { describe("POST /api/global/auth/:tenantId/reset/update", () => {
it("should reset password", async () => { it("should reset password", async () => {
await tenancy.doInTenant(config.tenant1User!.tenantId, async () => { let user = await config.createUser()
const userEmail = structures.email() const { code } = await config.api.auth.requestPasswordReset(
const { code } = await config.api.auth.requestPasswordReset( sendMailMock,
sendMailMock, user.email
userEmail )
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." }) describe("budibase sso user", () => {
expect(events.user.passwordReset).toBeCalledTimes(1) it("should prevent user from generating password reset email", async () => {
expect(events.user.passwordReset).toBeCalledWith(user) 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") const location: string = res.get("location")
expect( expect(
location.startsWith( 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) ).toBe(true)
}) })

View File

@ -30,7 +30,7 @@ describe("/api/global/self", () => {
user.dayPassRecordedAt = mocks.date.MOCK_DATE.toISOString() user.dayPassRecordedAt = mocks.date.MOCK_DATE.toISOString()
expect(res.body._id).toBe(user._id) expect(res.body._id).toBe(user._id)
expect(events.user.updated).toBeCalledTimes(1) expect(events.user.updated).toBeCalledTimes(1)
expect(events.user.updated).toBeCalledWith(user) expect(events.user.updated).toBeCalledWith(dbUser)
expect(events.user.passwordUpdated).not.toBeCalled() expect(events.user.passwordUpdated).not.toBeCalled()
}) })
@ -44,12 +44,11 @@ describe("/api/global/self", () => {
const dbUser = await config.getUser(user.email) const dbUser = await config.getUser(user.email)
user._rev = dbUser._rev user._rev = dbUser._rev
user.dayPassRecordedAt = mocks.date.MOCK_DATE.toISOString() user.dayPassRecordedAt = mocks.date.MOCK_DATE.toISOString()
delete user.password
expect(res.body._id).toBe(user._id) expect(res.body._id).toBe(user._id)
expect(events.user.updated).toBeCalledTimes(1) 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).toBeCalledTimes(1)
expect(events.user.passwordUpdated).toBeCalledWith(user) expect(events.user.passwordUpdated).toBeCalledWith(dbUser)
}) })
}) })
}) })

View File

@ -1,4 +1,4 @@
import sdk from "../../../../sdk" import * as accounts from "../../../../sdk/accounts"
import { TestConfiguration, structures } from "../../../../tests" import { TestConfiguration, structures } from "../../../../tests"
import { v4 as uuid } from "uuid" import { v4 as uuid } from "uuid"
@ -24,8 +24,8 @@ describe("accounts", () => {
const response = await config.api.accounts.saveMetadata(account) const response = await config.api.accounts.saveMetadata(account)
const id = sdk.accounts.formatAccountMetadataId(account.accountId) const id = accounts.metadata.formatAccountMetadataId(account.accountId)
const metadata = await sdk.accounts.getMetadata(id) const metadata = await accounts.metadata.getMetadata(id)
expect(response).toStrictEqual(metadata) expect(response).toStrictEqual(metadata)
}) })
}) })
@ -37,7 +37,7 @@ describe("accounts", () => {
await config.api.accounts.destroyMetadata(account.accountId) 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) expect(deleted).toBe(undefined)
}) })

View File

@ -26,6 +26,8 @@ function parseIntSafe(number: any) {
} }
} }
const selfHosted = !!parseInt(process.env.SELF_HOSTED || "")
const environment = { const environment = {
// auth // auth
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY, MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
@ -49,7 +51,7 @@ const environment = {
CLUSTER_PORT: process.env.CLUSTER_PORT, CLUSTER_PORT: process.env.CLUSTER_PORT,
// flags // flags
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,
SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED || ""), SELF_HOSTED: selfHosted,
LOG_LEVEL: process.env.LOG_LEVEL, LOG_LEVEL: process.env.LOG_LEVEL,
MULTI_TENANCY: process.env.MULTI_TENANCY, MULTI_TENANCY: process.env.MULTI_TENANCY,
DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL, DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL,
@ -65,6 +67,18 @@ const environment = {
CHECKLIST_CACHE_TTL: parseIntSafe(process.env.CHECKLIST_CACHE_TTL) || 3600, CHECKLIST_CACHE_TTL: parseIntSafe(process.env.CHECKLIST_CACHE_TTL) || 3600,
SESSION_UPDATE_PERIOD: process.env.SESSION_UPDATE_PERIOD, SESSION_UPDATE_PERIOD: process.env.SESSION_UPDATE_PERIOD,
ENCRYPTED_TEST_PUBLIC_API_KEY: process.env.ENCRYPTED_TEST_PUBLIC_API_KEY, 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) { _set(key: any, value: any) {
process.env[key] = value process.env[key] = value
// @ts-ignore // @ts-ignore

View File

@ -25,6 +25,12 @@ const koaSession = require("koa-session")
const logger = require("koa-pino-logger") const logger = require("koa-pino-logger")
import destroyable from "server-destroy" 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 // this will setup http and https proxies form env variables
bootstrap() bootstrap()

View File

@ -1 +1,2 @@
export * from "./accounts" export * as metadata from "./metadata"
export { accounts as api } from "@budibase/backend-core"

View File

@ -2,7 +2,6 @@ import { AccountMetadata } from "@budibase/types"
import { import {
db, db,
StaticDatabases, StaticDatabases,
HTTPError,
DocumentType, DocumentType,
SEPARATOR, SEPARATOR,
} from "@budibase/backend-core" } from "@budibase/backend-core"

View File

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

View File

@ -0,0 +1 @@
export * from "./auth"

View File

@ -84,6 +84,10 @@ export const handleSaveEvents = async (
) { ) {
await events.user.passwordForceReset(user) await events.user.passwordForceReset(user)
} }
if (user.password !== existingUser.password) {
await events.user.passwordUpdated(user)
}
} else { } else {
await events.user.created(user) await events.user.created(user)
} }

View File

@ -1 +1,2 @@
export * from "./users" export * from "./users"
export { users as core } from "@budibase/backend-core"

View File

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

View File

@ -6,12 +6,11 @@ import {
cache, cache,
constants, constants,
db as dbUtils, db as dbUtils,
deprovisioning,
events, events,
HTTPError, HTTPError,
migrations,
sessions, sessions,
tenancy, tenancy,
platform,
users as usersCore, users as usersCore,
utils, utils,
ViewName, ViewName,
@ -21,21 +20,22 @@ import {
AllDocsResponse, AllDocsResponse,
BulkUserResponse, BulkUserResponse,
CloudAccount, CloudAccount,
CreateUserResponse,
InviteUsersRequest, InviteUsersRequest,
InviteUsersResponse, InviteUsersResponse,
MigrationType, isSSOAccount,
isSSOUser,
PlatformUser, PlatformUser,
PlatformUserByEmail, PlatformUserByEmail,
RowResponse, RowResponse,
SearchUsersRequest, SearchUsersRequest,
UpdateSelf,
User, User,
ThirdPartyUser, SaveUserOpts,
isUser,
} from "@budibase/types" } from "@budibase/types"
import { sendEmail } from "../../utilities/email" import { sendEmail } from "../../utilities/email"
import { EmailTemplatePurpose } from "../../constants" import { EmailTemplatePurpose } from "../../constants"
import { groups as groupsSdk } from "@budibase/pro" import { groups as groupsSdk } from "@budibase/pro"
import * as accountSdk from "../accounts"
const PAGE_LIMIT = 8 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. * Gets a user by ID from the global database, based on the current tenancy.
*/ */
export const getUser = async (userId: string) => { export const getUser = async (userId: string) => {
const db = tenancy.getGlobalDB() const user = await usersCore.getById(userId)
let user = await db.get(userId)
if (user) { if (user) {
delete user.password delete user.password
} }
return user return user
} }
export interface SaveUserOpts {
hashPassword?: boolean
requirePassword?: boolean
currentUserId?: string
}
const buildUser = async ( const buildUser = async (
user: User | ThirdPartyUser, user: User,
opts: SaveUserOpts = { opts: SaveUserOpts = {
hashPassword: true, hashPassword: true,
requirePassword: true, requirePassword: true,
@ -121,11 +118,13 @@ const buildUser = async (
tenantId: string, tenantId: string,
dbUser?: any dbUser?: any
): Promise<User> => { ): Promise<User> => {
let fullUser = user as User let { password, _id } = user
let { password, _id } = fullUser
let hashedPassword let hashedPassword
if (password) { if (password) {
if (await isPreventSSOPasswords(user)) {
throw new HTTPError("SSO user cannot set password", 400)
}
hashedPassword = opts.hashPassword ? await utils.hash(password) : password hashedPassword = opts.hashPassword ? await utils.hash(password) : password
} else if (dbUser) { } else if (dbUser) {
hashedPassword = dbUser.password hashedPassword = dbUser.password
@ -135,10 +134,10 @@ const buildUser = async (
_id = _id || dbUtils.generateGlobalUserID() _id = _id || dbUtils.generateGlobalUserID()
fullUser = { const fullUser = {
createdAt: Date.now(), createdAt: Date.now(),
...dbUser, ...dbUser,
...fullUser, ...user,
_id, _id,
password: hashedPassword, password: hashedPassword,
tenantId, 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 ( export const save = async (
user: User | ThirdPartyUser, user: User,
opts: SaveUserOpts = {} opts: SaveUserOpts = {}
): Promise<CreateUserResponse> => { ): Promise<User> => {
// default booleans to true // default booleans to true
if (opts.hashPassword == null) { if (opts.hashPassword == null) {
opts.hashPassword = true opts.hashPassword = true
@ -264,7 +289,7 @@ export const save = async (
builtUser._rev = response.rev builtUser._rev = response.rev
await eventHelpers.handleSaveEvents(builtUser, dbUser) 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) await cache.user.invalidateUser(response.id)
// let server know to sync user // let server know to sync user
@ -272,11 +297,8 @@ export const save = async (
await Promise.all(groupPromises) await Promise.all(groupPromises)
return { // finally returned the saved user from the db
_id: response.id, return db.get(builtUser._id!)
_rev: response.rev,
email,
}
} catch (err: any) { } catch (err: any) {
if (err.status === 409) { if (err.status === 409) {
throw "User exists already" 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<User[]> => { const getExistingTenantUsers = async (emails: string[]): Promise<User[]> => {
const lcEmails = emails.map(email => email.toLowerCase()) const lcEmails = emails.map(email => email.toLowerCase())
const params = { const params = {
@ -432,7 +439,7 @@ export const bulkCreate = async (
for (const user of usersToBulkSave) { for (const user of usersToBulkSave) {
// TODO: Refactor to bulk insert users into the info db // TODO: Refactor to bulk insert users into the info db
// instead of relying on looping tenant creation // 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 eventHelpers.handleSaveEvents(user, undefined)
await apps.syncUserInApps(user._id) 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) await db.remove(userId, dbUser._rev)
@ -579,7 +586,7 @@ export const destroy = async (id: string, currentUser: any) => {
const bulkDeleteProcessing = async (dbUser: User) => { const bulkDeleteProcessing = async (dbUser: User) => {
const userId = dbUser._id as string const userId = dbUser._id as string
await deprovisioning.removeUserFromInfoDB(dbUser) await platform.users.removeUser(dbUser)
await eventHelpers.handleDeleteEvents(dbUser) await eventHelpers.handleDeleteEvents(dbUser)
await cache.user.invalidateUser(userId) await cache.user.invalidateUser(userId)
await sessions.invalidateSessions(userId, { reason: "bulk-deletion" }) await sessions.invalidateSessions(userId, { reason: "bulk-deletion" })

View File

@ -22,7 +22,7 @@ import {
env as coreEnv, env as coreEnv,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import structures, { CSRF_TOKEN } from "./structures" import structures, { CSRF_TOKEN } from "./structures"
import { CreateUserResponse, User, AuthToken } from "@budibase/types" import { SaveUserResponse, User, AuthToken } from "@budibase/types"
import API from "./api" import API from "./api"
class TestConfiguration { class TestConfiguration {
@ -226,7 +226,7 @@ class TestConfiguration {
user = structures.users.user() user = structures.users.user()
} }
const response = await this._req(user, null, controllers.users.save) 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) return this.getUser(body.email)
} }

View File

@ -1,21 +1,39 @@
import structures from "../structures"
import TestConfiguration from "../TestConfiguration" import TestConfiguration from "../TestConfiguration"
import { TestAPI } from "./base" import { TestAPI, TestAPIOpts } from "./base"
export class AuthAPI extends TestAPI { export class AuthAPI extends TestAPI {
constructor(config: TestConfiguration) { constructor(config: TestConfiguration) {
super(config) super(config)
} }
updatePassword = (code: string) => { updatePassword = (
resetCode: string,
password: string,
opts?: TestAPIOpts
) => {
return this.request return this.request
.post(`/api/global/auth/${this.config.getTenantId()}/reset/update`) .post(`/api/global/auth/${this.config.getTenantId()}/reset/update`)
.send({ .send({
password: "newpassword", password,
resetCode: code, resetCode,
}) })
.expect("Content-Type", /json/) .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 = () => { logout = () => {
@ -25,25 +43,31 @@ export class AuthAPI extends TestAPI {
.expect(200) .expect(200)
} }
requestPasswordReset = async (sendMailMock: any, userEmail: string) => { requestPasswordReset = async (
sendMailMock: any,
email: string,
opts?: TestAPIOpts
) => {
await this.config.saveSmtpConfig() await this.config.saveSmtpConfig()
await this.config.saveSettingsConfig() await this.config.saveSettingsConfig()
await this.config.createUser({
...structures.users.user(),
email: userEmail,
})
const res = await this.request const res = await this.request
.post(`/api/global/auth/${this.config.getTenantId()}/reset`) .post(`/api/global/auth/${this.config.getTenantId()}/reset`)
.send({ .send({
email: userEmail, email: email,
}) })
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(200) .expect(opts?.status ? opts.status : 200)
const emailCall = sendMailMock.mock.calls[0][0]
const parts = emailCall.html.split( let code: string | undefined
`http://localhost:10000/builder/auth/reset?code=` if (res.status === 200) {
) const emailCall = sendMailMock.mock.calls[0][0]
const code = parts[1].split('"')[0].split("&")[0] const parts = emailCall.html.split(
`http://localhost:10000/builder/auth/reset?code=`
)
code = parts[1].split('"')[0].split("&")[0]
}
return { code, res } return { code, res }
} }
} }

View File

@ -1,6 +1,5 @@
import { structures } from "@budibase/backend-core/tests" import { structures } from "@budibase/backend-core/tests"
import * as configs from "./configs" import * as configs from "./configs"
import * as users from "./users"
import * as groups from "./groups" import * as groups from "./groups"
import { v4 as uuid } from "uuid" import { v4 as uuid } from "uuid"
@ -11,7 +10,6 @@ const pkg = {
...structures, ...structures,
uuid, uuid,
configs, configs,
users,
TENANT_ID, TENANT_ID,
CSRF_TOKEN, CSRF_TOKEN,
groups, groups,

View File

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

View File

@ -26,7 +26,7 @@ type SendEmailOpts = {
automation?: boolean automation?: boolean
} }
const TEST_MODE = false const TEST_MODE = env.ENABLE_EMAIL_TEST_MODE && env.isDev()
const TYPE = TemplateType.EMAIL const TYPE = TemplateType.EMAIL
const FULL_EMAIL_PURPOSES = [ const FULL_EMAIL_PURPOSES = [
@ -62,8 +62,8 @@ function createSMTPTransport(config: any) {
host: "smtp.ethereal.email", host: "smtp.ethereal.email",
secure: false, secure: false,
auth: { auth: {
user: "don.bahringer@ethereal.email", user: "wyatt.zulauf29@ethereal.email",
pass: "yCKSH8rWyUPbnhGYk9", pass: "tEwDtHBWWxusVWAPfa",
}, },
} }
} }