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,11 +117,11 @@ 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

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>
{#if !$auth.isSSO}
<MenuItem icon="LockClosed" on:click={() => updatePasswordModal.show()}> <MenuItem icon="LockClosed" on:click={() => updatePasswordModal.show()}>
Update password Update password
</MenuItem> </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>
{#if !isSSO}
<MenuItem on:click={resetPasswordModal.show} icon="Refresh"> <MenuItem on:click={resetPasswordModal.show} icon="Refresh">
Force password reset Force password reset
</MenuItem> </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 GoogleConfig extends Config {
config: GoogleInnerConfig
} }
export interface OIDCConfiguration { 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, sendMailMock,
userEmail user.email
) )
const user = await config.getUser(userEmail)
expect(res.body).toEqual({ expect(res.body).toEqual({
message: "Please check your email for a reset link.", message: "Please check your email for a reset link.",
}) })
expect(sendMailMock).toHaveBeenCalled() expect(sendMailMock).toHaveBeenCalled()
expect(code).toBeDefined() expect(code).toBeDefined()
expect(events.user.passwordResetRequested).toBeCalledTimes(1) expect(events.user.passwordResetRequested).toBeCalledTimes(1)
expect(events.user.passwordResetRequested).toBeCalledWith(user) expect(events.user.passwordResetRequested).toBeCalledWith(user)
}) })
describe("sso user", () => {
let user: User
async function testSSOUser() {
const { res } = await config.api.auth.requestPasswordReset(
sendMailMock,
user.email,
{ status: 400 }
)
expect(res.body).toEqual({
message: "SSO user cannot reset password",
status: 400,
error: {
code: "http",
type: "generic",
},
})
expect(sendMailMock).not.toHaveBeenCalled()
}
describe("budibase sso user", () => {
it("should prevent user from generating password reset email", async () => {
user = await createSSOUser()
await testSSOUser()
})
})
describe("root account sso user", () => {
it("should prevent user from generating password reset email", async () => {
user = await config.createUser(structures.users.user())
const account = structures.accounts.ssoAccount() as CloudAccount
mocks.accounts.getAccount.mockReturnValueOnce(
Promise.resolve(account)
)
await testSSOUser()
})
})
}) })
}) })
describe("POST /api/global/auth/:tenantId/reset/update", () => { 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,
userEmail user.email
) )
const user = await config.getUser(userEmail)
delete user.password delete user.password
const res = await config.api.auth.updatePassword(code) 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(res.body).toEqual({ message: "password reset successfully." })
expect(events.user.passwordReset).toBeCalledTimes(1) expect(events.user.passwordReset).toBeCalledTimes(1)
expect(events.user.passwordReset).toBeCalledWith(user) 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 }
)
expect(res.body).toEqual({
message: "Cannot reset password.",
status: 400,
})
}
describe("budibase sso user", () => {
it("should prevent user from generating password reset email", async () => {
user = await config.createUser()
const { code } = await config.api.auth.requestPasswordReset(
sendMailMock,
user.email
)
// convert to sso now that password reset has been requested
const ssoUser = user as SSOUser
ssoUser.providerType = structures.sso.providerType()
delete ssoUser.password
await config.doInTenant(() => userSdk.save(ssoUser))
await testSSOUser(code!)
})
})
describe("root account sso user", () => {
it("should prevent user from generating password reset email", async () => {
user = await config.createUser()
const { code } = await config.api.auth.requestPasswordReset(
sendMailMock,
user.email
)
// convert to account owner now that password has been requested
const account = structures.accounts.ssoAccount() as CloudAccount
mocks.accounts.getAccount.mockReturnValueOnce(
Promise.resolve(account)
)
await testSSOUser(code!)
})
}) })
// TODO: Login using new password
}) })
}) })
}) })
@ -153,7 +354,7 @@ describe("/api/global/auth", () => {
const location: string = res.get("location") 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)
let code: string | undefined
if (res.status === 200) {
const emailCall = sendMailMock.mock.calls[0][0] const emailCall = sendMailMock.mock.calls[0][0]
const parts = emailCall.html.split( const parts = emailCall.html.split(
`http://localhost:10000/builder/auth/reset?code=` `http://localhost:10000/builder/auth/reset?code=`
) )
const code = parts[1].split('"')[0].split("&")[0] 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",
}, },
} }
} }