1
0
Fork 0
mirror of synced 2024-06-28 11:00:55 +12:00

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 env from "../environment"
import { Header } from "../constants"
import { CloudAccount } from "@budibase/types"
import { CloudAccount, HealthStatusResponse } from "@budibase/types"
const api = new API(env.ACCOUNT_PORTAL_URL)
/**
* This client is intended to be used in a cloud hosted deploy only.
* Rather than relying on each consumer to perform the necessary environmental checks
* we use the following check to exit early with a undefined response which should be
* handled by the caller.
*/
const EXIT_EARLY = env.SELF_HOSTED || env.DISABLE_ACCOUNT_PORTAL
export const getAccount = async (
email: string
): Promise<CloudAccount | undefined> => {
if (EXIT_EARLY) {
return
}
const payload = {
email,
}
@ -29,6 +40,9 @@ export const getAccount = async (
export const getAccountByTenantId = async (
tenantId: string
): Promise<CloudAccount | undefined> => {
if (EXIT_EARLY) {
return
}
const payload = {
tenantId,
}
@ -47,7 +61,12 @@ export const getAccountByTenantId = async (
return json[0]
}
export const getStatus = async () => {
export const getStatus = async (): Promise<
HealthStatusResponse | undefined
> => {
if (EXIT_EARLY) {
return
}
const response = await api.get(`/api/status`, {
headers: {
[Header.API_KEY]: env.ACCOUNT_PORTAL_API_KEY,

View file

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

View file

@ -1,10 +1,11 @@
const _passport = require("koa-passport")
const LocalStrategy = require("passport-local").Strategy
const JwtStrategy = require("passport-jwt").Strategy
import { getGlobalDB } from "../tenancy"
import { getGlobalDB } from "../context"
const refresh = require("passport-oauth2-refresh")
import { Config } from "../constants"
import { Config, Cookie } from "../constants"
import { getScopedConfig } from "../db"
import { getSessionsForUser, invalidateSessions } from "../security/sessions"
import {
jwt as jwtPassport,
local,
@ -15,8 +16,11 @@ import {
google,
} from "../middleware"
import { invalidateUser } from "../cache/user"
import { User } from "@budibase/types"
import { PlatformLogoutOpts, User } from "@budibase/types"
import { logAlert } from "../logging"
import * as events from "../events"
import * as userCache from "../cache/user"
import { clearCookie, getCookie } from "../utils"
export {
auditLog,
authError,
@ -29,6 +33,7 @@ export {
google,
oidc,
} from "../middleware"
import { ssoSaveUserNoOp } from "../middleware/passport/sso/sso"
export const buildAuthMiddleware = authenticated
export const buildTenancyMiddleware = tenancy
export const buildCsrfMiddleware = csrf
@ -71,7 +76,7 @@ async function refreshOIDCAccessToken(
if (!enrichedConfig) {
throw new Error("OIDC Config contents invalid")
}
strategy = await oidc.strategyFactory(enrichedConfig)
strategy = await oidc.strategyFactory(enrichedConfig, ssoSaveUserNoOp)
} catch (err) {
console.error(err)
throw new Error("Could not refresh OAuth Token")
@ -103,7 +108,11 @@ async function refreshGoogleAccessToken(
let strategy
try {
strategy = await google.strategyFactory(config, callbackUrl)
strategy = await google.strategyFactory(
config,
callbackUrl,
ssoSaveUserNoOp
)
} catch (err: any) {
console.error(err)
throw new Error(
@ -161,6 +170,8 @@ export async function refreshOAuthToken(
return refreshResponse
}
// TODO: Refactor to use user save function instead to prevent the need for
// manually saving and invalidating on callback
export async function updateUserOAuth(userId: string, oAuthConfig: any) {
const details = {
accessToken: oAuthConfig.accessToken,
@ -188,3 +199,32 @@ export async function updateUserOAuth(userId: string, oAuthConfig: any) {
console.error("Could not update OAuth details for current user", e)
}
}
/**
* Logs a user out from budibase. Re-used across account portal and builder.
*/
export async function platformLogout(opts: PlatformLogoutOpts) {
const ctx = opts.ctx
const userId = opts.userId
const keepActiveSession = opts.keepActiveSession
if (!ctx) throw new Error("Koa context must be supplied to logout.")
const currentSession = getCookie(ctx, Cookie.Auth)
let sessions = await getSessionsForUser(userId)
if (keepActiveSession) {
sessions = sessions.filter(
session => session.sessionId !== currentSession.sessionId
)
} else {
// clear cookies
clearCookie(ctx, Cookie.Auth)
clearCookie(ctx, Cookie.CurrentApp)
}
const sessionIds = sessions.map(({ sessionId }) => sessionId)
await invalidateSessions(userId, { sessionIds, reason: "logout" })
await events.auth.logout()
await userCache.invalidateUser(userId)
}

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -2,23 +2,15 @@ import { getAllApps, queryGlobalView } from "../db"
import { options } from "../middleware/passport/jwt"
import {
Header,
Cookie,
MAX_VALID_DATE,
DocumentType,
SEPARATOR,
ViewName,
} from "../constants"
import env from "../environment"
import * as userCache from "../cache/user"
import { getSessionsForUser, invalidateSessions } from "../security/sessions"
import * as events from "../events"
import * as tenancy from "../tenancy"
import {
App,
Ctx,
PlatformLogoutOpts,
TenantResolutionStrategy,
} from "@budibase/types"
import * as context from "../context"
import { App, Ctx, TenantResolutionStrategy } from "@budibase/types"
import { SetOption } from "cookies"
const jwt = require("jsonwebtoken")
@ -38,7 +30,7 @@ export async function resolveAppUrl(ctx: Ctx) {
const appUrl = ctx.path.split("/")[2]
let possibleAppUrl = `/${appUrl.toLowerCase()}`
let tenantId: string | null = tenancy.getTenantId()
let tenantId: string | null = context.getTenantId()
if (env.MULTI_TENANCY) {
// always use the tenant id from the subdomain in multi tenancy
// this ensures the logged-in user tenant id doesn't overwrite
@ -49,7 +41,7 @@ export async function resolveAppUrl(ctx: Ctx) {
}
// search prod apps for a url that matches
const apps: App[] = await tenancy.doInTenant(tenantId, () =>
const apps: App[] = await context.doInTenant(tenantId, () =>
getAllApps({ dev: false })
)
const app = apps.filter(
@ -222,35 +214,6 @@ export async function getBuildersCount() {
return builders.length
}
/**
* Logs a user out from budibase. Re-used across account portal and builder.
*/
export async function platformLogout(opts: PlatformLogoutOpts) {
const ctx = opts.ctx
const userId = opts.userId
const keepActiveSession = opts.keepActiveSession
if (!ctx) throw new Error("Koa context must be supplied to logout.")
const currentSession = getCookie(ctx, Cookie.Auth)
let sessions = await getSessionsForUser(userId)
if (keepActiveSession) {
sessions = sessions.filter(
session => session.sessionId !== currentSession.sessionId
)
} else {
// clear cookies
clearCookie(ctx, Cookie.Auth)
clearCookie(ctx, Cookie.CurrentApp)
}
const sessionIds = sessions.map(({ sessionId }) => sessionId)
await invalidateSessions(userId, { sessionIds, reason: "logout" })
await events.auth.logout()
await userCache.invalidateUser(userId)
}
export function timeout(timeMs: number) {
return new Promise(resolve => setTimeout(resolve, timeMs))
}

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

View file

@ -1,6 +1,15 @@
import { generator, uuid } from "."
import * as db from "../../../src/db/utils"
import { Account, AuthType, CloudAccount, Hosting } from "@budibase/types"
import {
Account,
AccountSSOProvider,
AccountSSOProviderType,
AuthType,
CloudAccount,
Hosting,
SSOAccount,
} from "@budibase/types"
import _ from "lodash"
export const account = (): Account => {
return {
@ -27,3 +36,28 @@ export const cloudAccount = (): CloudAccount => {
budibaseUserId: db.generateGlobalUserID(),
}
}
function providerType(): AccountSSOProviderType {
return _.sample(
Object.values(AccountSSOProviderType)
) as AccountSSOProviderType
}
function provider(): AccountSSOProvider {
return _.sample(Object.values(AccountSSOProvider)) as AccountSSOProvider
}
export function ssoAccount(): SSOAccount {
return {
...cloudAccount(),
authType: AuthType.SSO,
oauth2: {
accessToken: generator.string(),
refreshToken: generator.string(),
},
pictureUrl: generator.url(),
provider: provider(),
providerType: providerType(),
thirdPartyProfile: {},
}
}

View file

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

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
</MenuItem>
<MenuItem icon="Moon" on:click={() => themeModal.show()}>Theme</MenuItem>
<MenuItem icon="LockClosed" on:click={() => updatePasswordModal.show()}>
Update password
</MenuItem>
{#if !$auth.isSSO}
<MenuItem icon="LockClosed" on:click={() => updatePasswordModal.show()}>
Update password
</MenuItem>
{/if}
<MenuItem icon="Key" on:click={() => apiKeyModal.show()}>
View API key
</MenuItem>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,2 +1,3 @@
export * from "./user"
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 "./auth"
export * from "./user"
export * from "./errors"
export * from "./schedule"

View file

@ -1,6 +1,6 @@
import { User } from "../../documents"
export interface CreateUserResponse {
export interface SaveUserResponse {
_id: string
_rev: string
email: string
@ -58,6 +58,25 @@ export interface CreateAdminUserRequest {
tenantId: string
}
export interface CreateAdminUserResponse {
_id: string
_rev: string
email: string
}
export interface AcceptUserInviteRequest {
inviteCode: string
password: string
firstName: string
lastName: string
}
export interface AcceptUserInviteResponse {
_id: string
_rev: string
email: string
}
export interface SyncUserRequest {
previousUser?: User
}

View file

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

View file

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

View file

@ -1,37 +1,44 @@
import { Document } from "../document"
export interface SSOProfile {
id: string
name?: {
givenName?: string
familyName?: string
}
_json: {
email: string
picture: string
}
provider?: string
// SSO
export interface SSOProfileJson {
email?: string
picture?: string
}
export interface ThirdPartyUser extends Document {
thirdPartyProfile?: SSOProfile["_json"]
firstName?: string
lastName?: string
pictureUrl?: string
profile?: SSOProfile
oauth2?: any
provider?: string
providerType?: string
email: string
userId?: string
forceResetPassword?: boolean
userGroups?: string[]
export interface OAuth2 {
accessToken: string
refreshToken?: string
}
export interface User extends ThirdPartyUser {
export enum SSOProviderType {
OIDC = "oidc",
GOOGLE = "google",
}
export interface UserSSO {
provider: string // the individual provider e.g. Okta, Auth0, Google
providerType: SSOProviderType
oauth2?: OAuth2
thirdPartyProfile?: SSOProfileJson
}
export type SSOUser = User & UserSSO
export function isSSOUser(user: User): user is SSOUser {
return !!(user as SSOUser).providerType
}
// USER
export interface User extends Document {
tenantId: string
email: string
userId?: string
firstName?: string
lastName?: string
pictureUrl?: string
forceResetPassword?: boolean
roles: UserRoles
builder?: {
@ -44,9 +51,7 @@ export interface User extends ThirdPartyUser {
status?: string
createdAt?: number // override the default createdAt behaviour - users sdk historically set this to Date.now()
dayPassRecordedAt?: string
account?: {
authType: string
}
userGroups?: string[]
onboardedAt?: string
}
@ -54,7 +59,7 @@ export interface UserRoles {
[key: string]: string
}
// utility types
// UTILITY TYPES
export interface BuilderUser extends User {
builder: {

View file

@ -12,3 +12,5 @@ export * from "./db"
export * from "./middleware"
export * from "./featureFlag"
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",
DEPLOYMENT_ENVIRONMENT: "development",
TENANT_FEATURE_FLAGS: "*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR",
ENABLE_EMAIL_TEST_MODE: 1,
}
let envFile = ""
Object.keys(envFileJson).forEach(key => {

View file

@ -1,49 +1,55 @@
import {
auth,
auth as authCore,
constants,
context,
db as dbCore,
events,
tenancy,
users as usersCore,
utils,
utils as utilsCore,
} from "@budibase/backend-core"
import { EmailTemplatePurpose } from "../../../constants"
import { isEmailConfigured, sendEmail } from "../../../utilities/email"
import { checkResetPasswordCode } from "../../../utilities/redis"
import {
ConfigType,
User,
Ctx,
LoginRequest,
SSOUser,
PasswordResetRequest,
PasswordResetUpdateRequest,
} from "@budibase/types"
import env from "../../../environment"
import sdk from "../../../sdk"
import { ConfigType, User } from "@budibase/types"
const { setCookie, getCookie, clearCookie, hash, platformLogout } = utils
import * as authSdk from "../../../sdk/auth"
import * as userSdk from "../../../sdk/users"
const { Cookie, Header } = constants
const { passport, ssoCallbackUrl, google, oidc } = auth
const { passport, ssoCallbackUrl, google, oidc } = authCore
const { setCookie, getCookie, clearCookie } = utilsCore
export async function googleCallbackUrl(config?: { callbackURL?: string }) {
return ssoCallbackUrl(tenancy.getGlobalDB(), config, ConfigType.GOOGLE)
}
// LOGIN / LOGOUT
export async function oidcCallbackUrl(config?: { callbackURL?: string }) {
return ssoCallbackUrl(tenancy.getGlobalDB(), config, ConfigType.OIDC)
}
async function authInternal(ctx: any, user: any, err: any = null, info = null) {
async function passportCallback(
ctx: Ctx,
user: User,
err: any = null,
info: { message: string } | null = null
) {
if (err) {
console.error("Authentication error")
console.error(err)
console.trace(err)
return ctx.throw(403, info ? info : "Unauthorized")
}
if (!user) {
console.error("Authentication error - no user provided")
return ctx.throw(403, info ? info : "Unauthorized")
}
const token = await authSdk.loginUser(user)
// set a cookie for browser access
setCookie(ctx, user.token, Cookie.Auth, { sign: false })
setCookie(ctx, token, Cookie.Auth, { sign: false })
// set the token in a header as well for APIs
ctx.set(Header.TOKEN, user.token)
ctx.set(Header.TOKEN, token)
// get rid of any app cookies on login
// have to check test because this breaks cypress
if (!env.isTest()) {
@ -51,11 +57,18 @@ async function authInternal(ctx: any, user: any, err: any = null, info = null) {
}
}
export const authenticate = async (ctx: any, next: any) => {
export const login = async (ctx: Ctx<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(
"local",
async (err: any, user: User, info: any) => {
await authInternal(ctx, user, err, info)
await passportCallback(ctx, user, err, info)
await context.identity.doInUserContext(user, async () => {
await events.auth.login("local")
})
@ -64,6 +77,15 @@ export const authenticate = async (ctx: any, next: any) => {
)(ctx, next)
}
export const logout = async (ctx: any) => {
if (ctx.user && ctx.user._id) {
await authSdk.logout({ ctx, userId: ctx.user._id })
}
ctx.body = { message: "User logged out." }
}
// INIT
export const setInitInfo = (ctx: any) => {
const initInfo = ctx.request.body
setCookie(ctx, initInfo, Cookie.Init)
@ -79,32 +101,16 @@ export const getInitInfo = (ctx: any) => {
}
}
// PASSWORD MANAGEMENT
/**
* Reset the user password, used as part of a forgotten password flow.
*/
export const reset = async (ctx: any) => {
export const reset = async (ctx: Ctx<PasswordResetRequest>) => {
const { email } = ctx.request.body
const configured = await isEmailConfigured()
if (!configured) {
ctx.throw(
400,
"Please contact your platform administrator, SMTP is not configured."
)
}
try {
const user = (await usersCore.getGlobalUserByEmail(email)) as User
// only if user exists, don't error though if they don't
if (user) {
await sendEmail(email, EmailTemplatePurpose.PASSWORD_RECOVERY, {
user,
subject: "{{ company }} platform password reset",
})
await events.user.passwordResetRequested(user)
}
} catch (err) {
console.log(err)
// don't throw any kind of error to the user, this might give away something
}
await authSdk.reset(email)
ctx.body = {
message: "Please check your email for a reset link.",
}
@ -113,32 +119,21 @@ export const reset = async (ctx: any) => {
/**
* Perform the user password update if the provided reset code is valid.
*/
export const resetUpdate = async (ctx: any) => {
export const resetUpdate = async (ctx: Ctx<PasswordResetUpdateRequest>) => {
const { resetCode, password } = ctx.request.body
try {
const { userId } = await checkResetPasswordCode(resetCode)
const db = tenancy.getGlobalDB()
const user = await db.get(userId)
user.password = await hash(password)
await db.put(user)
await authSdk.resetUpdate(resetCode, password)
ctx.body = {
message: "password reset successfully.",
}
// remove password from the user before sending events
delete user.password
await events.user.passwordReset(user)
} catch (err) {
console.error(err)
console.warn(err)
// hide any details of the error for security
ctx.throw(400, "Cannot reset password.")
}
}
export const logout = async (ctx: any) => {
if (ctx.user && ctx.user._id) {
await platformLogout({ ctx, userId: ctx.user._id })
}
ctx.body = { message: "User logged out." }
}
// DATASOURCE
export const datasourcePreAuth = async (ctx: any, next: any) => {
const provider = ctx.params.provider
@ -166,6 +161,12 @@ export const datasourceAuth = async (ctx: any, next: any) => {
return handler.postAuth(passport, ctx, next)
}
// GOOGLE SSO
export async function googleCallbackUrl(config?: { callbackURL?: string }) {
return ssoCallbackUrl(tenancy.getGlobalDB(), config, ConfigType.GOOGLE)
}
/**
* The initial call that google authentication makes to take you to the google login screen.
* On a successful login, you will be redirected to the googleAuth callback route.
@ -181,7 +182,7 @@ export const googlePreAuth = async (ctx: any, next: any) => {
const strategy = await google.strategyFactory(
config,
callbackUrl,
sdk.users.save
userSdk.save
)
return passport.authenticate(strategy, {
@ -191,7 +192,7 @@ export const googlePreAuth = async (ctx: any, next: any) => {
})(ctx, next)
}
export const googleAuth = async (ctx: any, next: any) => {
export const googleCallback = async (ctx: any, next: any) => {
const db = tenancy.getGlobalDB()
const config = await dbCore.getScopedConfig(db, {
@ -202,14 +203,14 @@ export const googleAuth = async (ctx: any, next: any) => {
const strategy = await google.strategyFactory(
config,
callbackUrl,
sdk.users.save
userSdk.save
)
return passport.authenticate(
strategy,
{ successRedirect: "/", failureRedirect: "/error" },
async (err: any, user: User, info: any) => {
await authInternal(ctx, user, err, info)
async (err: any, user: SSOUser, info: any) => {
await passportCallback(ctx, user, err, info)
await context.identity.doInUserContext(user, async () => {
await events.auth.login("google-internal")
})
@ -218,6 +219,12 @@ export const googleAuth = async (ctx: any, next: any) => {
)(ctx, next)
}
// OIDC SSO
export async function oidcCallbackUrl(config?: { callbackURL?: string }) {
return ssoCallbackUrl(tenancy.getGlobalDB(), config, ConfigType.OIDC)
}
export const oidcStrategyFactory = async (ctx: any, configId: any) => {
const db = tenancy.getGlobalDB()
const config = await dbCore.getScopedConfig(db, {
@ -233,7 +240,7 @@ export const oidcStrategyFactory = async (ctx: any, configId: any) => {
chosenConfig,
callbackUrl
)
return oidc.strategyFactory(enrichedConfig, sdk.users.save)
return oidc.strategyFactory(enrichedConfig, userSdk.save)
}
/**
@ -265,15 +272,15 @@ export const oidcPreAuth = async (ctx: any, next: any) => {
})(ctx, next)
}
export const oidcAuth = async (ctx: any, next: any) => {
export const oidcCallback = async (ctx: any, next: any) => {
const configId = getCookie(ctx, Cookie.OIDC_CONFIG)
const strategy = await oidcStrategyFactory(ctx, configId)
return passport.authenticate(
strategy,
{ successRedirect: "/", failureRedirect: "/error" },
async (err: any, user: any, info: any) => {
await authInternal(ctx, user, err, info)
async (err: any, user: SSOUser, info: any) => {
await passportCallback(ctx, user, err, info)
await context.identity.doInUserContext(user, async () => {
await events.auth.login("oidc")
})

View file

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

View file

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

View file

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

View file

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

View file

@ -1,13 +1,27 @@
jest.mock("nodemailer")
import { TestConfiguration, mocks } from "../../../../tests"
const sendMailMock = mocks.email.mock()
import { events, tenancy } from "@budibase/backend-core"
import { structures } from "@budibase/backend-core/tests"
import { CloudAccount, SSOUser, User } from "@budibase/types"
const expectSetAuthCookie = (res: any) => {
expect(
res.get("Set-Cookie").find((c: string) => c.startsWith("budibase:auth"))
).toBeDefined()
jest.mock("nodemailer")
import {
TestConfiguration,
mocks,
structures,
generator,
} from "../../../../tests"
const sendMailMock = mocks.email.mock()
import { events, constants } from "@budibase/backend-core"
import { Response } from "superagent"
import * as userSdk from "../../../../sdk/users"
function getAuthCookie(response: Response) {
return response.headers["set-cookie"]
.find((s: string) => s.startsWith(`${constants.Cookie.Auth}=`))
.split("=")[1]
.split(";")[0]
}
const expectSetAuthCookie = (response: Response) => {
expect(getAuthCookie(response).length > 1).toBe(true)
}
describe("/api/global/auth", () => {
@ -25,60 +39,247 @@ describe("/api/global/auth", () => {
jest.clearAllMocks()
})
async function createSSOUser() {
return config.doInTenant(async () => {
return userSdk.save(structures.users.ssoUser(), {
requirePassword: false,
})
})
}
describe("password", () => {
describe("POST /api/global/auth/:tenantId/login", () => {
it("should login", () => {})
it("logs in with correct credentials", async () => {
const tenantId = config.tenantId!
const email = config.user?.email!
const password = config.userPassword
const response = await config.api.auth.login(tenantId, email, password)
expectSetAuthCookie(response)
expect(events.auth.login).toBeCalledTimes(1)
})
it("should return 403 with incorrect credentials", async () => {
const tenantId = config.tenantId!
const email = config.user?.email!
const password = "incorrect"
const response = await config.api.auth.login(
tenantId,
email,
password,
{ status: 403 }
)
expect(response.body).toEqual({
message: "Invalid credentials",
status: 403,
})
})
it("should return 403 when user doesn't exist", async () => {
const tenantId = config.tenantId!
const email = "invaliduser@test.com"
const password = "password"
const response = await config.api.auth.login(
tenantId,
email,
password,
{ status: 403 }
)
expect(response.body).toEqual({
message: "Invalid credentials",
status: 403,
})
})
describe("sso user", () => {
let user: User
async function testSSOUser() {
const tenantId = user.tenantId!
const email = user.email
const password = "test"
const response = await config.api.auth.login(
tenantId,
email,
password,
{ status: 400 }
)
expect(response.body).toEqual({
message: "SSO user cannot login using password",
status: 400,
})
}
describe("budibase sso user", () => {
it("should prevent user from logging in", async () => {
user = await createSSOUser()
await testSSOUser()
})
})
describe("root account sso user", () => {
it("should prevent user from logging in", async () => {
user = await config.createUser()
const account = structures.accounts.ssoAccount() as CloudAccount
mocks.accounts.getAccount.mockReturnValueOnce(
Promise.resolve(account)
)
await testSSOUser()
})
})
})
})
describe("POST /api/global/auth/logout", () => {
it("should logout", async () => {
await config.api.auth.logout()
const response = await config.api.auth.logout()
expect(events.auth.logout).toBeCalledTimes(1)
// TODO: Verify sessions deleted
const authCookie = getAuthCookie(response)
expect(authCookie).toBe("")
})
})
describe("POST /api/global/auth/:tenantId/reset", () => {
it("should generate password reset email", async () => {
await tenancy.doInTenant(config.tenant1User!.tenantId, async () => {
const userEmail = structures.email()
const { res, code } = await config.api.auth.requestPasswordReset(
const user = await config.createUser()
const { res, code } = await config.api.auth.requestPasswordReset(
sendMailMock,
user.email
)
expect(res.body).toEqual({
message: "Please check your email for a reset link.",
})
expect(sendMailMock).toHaveBeenCalled()
expect(code).toBeDefined()
expect(events.user.passwordResetRequested).toBeCalledTimes(1)
expect(events.user.passwordResetRequested).toBeCalledWith(user)
})
describe("sso user", () => {
let user: User
async function testSSOUser() {
const { res } = await config.api.auth.requestPasswordReset(
sendMailMock,
userEmail
user.email,
{ status: 400 }
)
const user = await config.getUser(userEmail)
expect(res.body).toEqual({
message: "Please check your email for a reset link.",
message: "SSO user cannot reset password",
status: 400,
error: {
code: "http",
type: "generic",
},
})
expect(sendMailMock).toHaveBeenCalled()
expect(sendMailMock).not.toHaveBeenCalled()
}
expect(code).toBeDefined()
expect(events.user.passwordResetRequested).toBeCalledTimes(1)
expect(events.user.passwordResetRequested).toBeCalledWith(user)
describe("budibase sso user", () => {
it("should prevent user from generating password reset email", async () => {
user = await createSSOUser()
await testSSOUser()
})
})
describe("root account sso user", () => {
it("should prevent user from generating password reset email", async () => {
user = await config.createUser(structures.users.user())
const account = structures.accounts.ssoAccount() as CloudAccount
mocks.accounts.getAccount.mockReturnValueOnce(
Promise.resolve(account)
)
await testSSOUser()
})
})
})
})
describe("POST /api/global/auth/:tenantId/reset/update", () => {
it("should reset password", async () => {
await tenancy.doInTenant(config.tenant1User!.tenantId, async () => {
const userEmail = structures.email()
const { code } = await config.api.auth.requestPasswordReset(
sendMailMock,
userEmail
let user = await config.createUser()
const { code } = await config.api.auth.requestPasswordReset(
sendMailMock,
user.email
)
delete user.password
const newPassword = "newpassword"
const res = await config.api.auth.updatePassword(code!, newPassword)
user = await config.getUser(user.email)
delete user.password
expect(res.body).toEqual({ message: "password reset successfully." })
expect(events.user.passwordReset).toBeCalledTimes(1)
expect(events.user.passwordReset).toBeCalledWith(user)
// login using new password
await config.api.auth.login(user.tenantId, user.email, newPassword)
})
describe("sso user", () => {
let user: User | SSOUser
async function testSSOUser(code: string) {
const res = await config.api.auth.updatePassword(
code!,
generator.string(),
{ status: 400 }
)
const user = await config.getUser(userEmail)
delete user.password
const res = await config.api.auth.updatePassword(code)
expect(res.body).toEqual({
message: "Cannot reset password.",
status: 400,
})
}
expect(res.body).toEqual({ message: "password reset successfully." })
expect(events.user.passwordReset).toBeCalledTimes(1)
expect(events.user.passwordReset).toBeCalledWith(user)
describe("budibase sso user", () => {
it("should prevent user from generating password reset email", async () => {
user = await config.createUser()
const { code } = await config.api.auth.requestPasswordReset(
sendMailMock,
user.email
)
// convert to sso now that password reset has been requested
const ssoUser = user as SSOUser
ssoUser.providerType = structures.sso.providerType()
delete ssoUser.password
await config.doInTenant(() => userSdk.save(ssoUser))
await testSSOUser(code!)
})
})
describe("root account sso user", () => {
it("should prevent user from generating password reset email", async () => {
user = await config.createUser()
const { code } = await config.api.auth.requestPasswordReset(
sendMailMock,
user.email
)
// convert to account owner now that password has been requested
const account = structures.accounts.ssoAccount() as CloudAccount
mocks.accounts.getAccount.mockReturnValueOnce(
Promise.resolve(account)
)
await testSSOUser(code!)
})
})
// TODO: Login using new password
})
})
})
@ -153,7 +354,7 @@ describe("/api/global/auth", () => {
const location: string = res.get("location")
expect(
location.startsWith(
"http://localhost/auth?response_type=code&client_id=clientId&redirect_uri=http%3A%2F%2Flocalhost%3A10000%2Fapi%2Fglobal%2Fauth%2Fdefault%2Foidc%2Fcallback&scope=openid%20profile%20email%20offline_access"
`http://localhost/auth?response_type=code&client_id=clientId&redirect_uri=http%3A%2F%2Flocalhost%3A10000%2Fapi%2Fglobal%2Fauth%2F${config.tenantId}%2Foidc%2Fcallback&scope=openid%20profile%20email%20offline_access`
)
).toBe(true)
})

View file

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

View file

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

View file

@ -26,6 +26,8 @@ function parseIntSafe(number: any) {
}
}
const selfHosted = !!parseInt(process.env.SELF_HOSTED || "")
const environment = {
// auth
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
@ -49,7 +51,7 @@ const environment = {
CLUSTER_PORT: process.env.CLUSTER_PORT,
// flags
NODE_ENV: process.env.NODE_ENV,
SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED || ""),
SELF_HOSTED: selfHosted,
LOG_LEVEL: process.env.LOG_LEVEL,
MULTI_TENANCY: process.env.MULTI_TENANCY,
DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL,
@ -65,6 +67,18 @@ const environment = {
CHECKLIST_CACHE_TTL: parseIntSafe(process.env.CHECKLIST_CACHE_TTL) || 3600,
SESSION_UPDATE_PERIOD: process.env.SESSION_UPDATE_PERIOD,
ENCRYPTED_TEST_PUBLIC_API_KEY: process.env.ENCRYPTED_TEST_PUBLIC_API_KEY,
/**
* Mock the email service in use - links to ethereal hosted emails are logged instead.
*/
ENABLE_EMAIL_TEST_MODE: process.env.ENABLE_EMAIL_TEST_MODE,
/**
* Enable to allow an admin user to login using a password.
* This can be useful to prevent lockout when configuring SSO.
* However, this should be turned OFF by default for security purposes.
*/
ENABLE_SSO_MAINTENANCE_MODE: selfHosted
? process.env.ENABLE_SSO_MAINTENANCE_MODE
: false,
_set(key: any, value: any) {
process.env[key] = value
// @ts-ignore

View file

@ -25,6 +25,12 @@ const koaSession = require("koa-session")
const logger = require("koa-pino-logger")
import destroyable from "server-destroy"
if (env.ENABLE_SSO_MAINTENANCE_MODE) {
console.warn(
"Warning: ENABLE_SSO_MAINTENANCE_MODE is set. It is recommended this flag is disabled if maintenance is not in progress"
)
}
// this will setup http and https proxies form env variables
bootstrap()

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 {
db,
StaticDatabases,
HTTPError,
DocumentType,
SEPARATOR,
} 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)
}
if (user.password !== existingUser.password) {
await events.user.passwordUpdated(user)
}
} else {
await events.user.created(user)
}

View file

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

View file

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

View file

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

View file

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

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
}
const TEST_MODE = false
const TEST_MODE = env.ENABLE_EMAIL_TEST_MODE && env.isDev()
const TYPE = TemplateType.EMAIL
const FULL_EMAIL_PURPOSES = [
@ -62,8 +62,8 @@ function createSMTPTransport(config: any) {
host: "smtp.ethereal.email",
secure: false,
auth: {
user: "don.bahringer@ethereal.email",
pass: "yCKSH8rWyUPbnhGYk9",
user: "wyatt.zulauf29@ethereal.email",
pass: "tEwDtHBWWxusVWAPfa",
},
}
}