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:
parent
a57f0c9dea
commit
cacf275a99
|
@ -1,13 +1,24 @@
|
||||||
import API from "./api"
|
import API from "./api"
|
||||||
import env from "../environment"
|
import env from "../environment"
|
||||||
import { Header } from "../constants"
|
import { Header } from "../constants"
|
||||||
import { CloudAccount } from "@budibase/types"
|
import { CloudAccount, HealthStatusResponse } from "@budibase/types"
|
||||||
|
|
||||||
const api = new API(env.ACCOUNT_PORTAL_URL)
|
const api = new API(env.ACCOUNT_PORTAL_URL)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This client is intended to be used in a cloud hosted deploy only.
|
||||||
|
* Rather than relying on each consumer to perform the necessary environmental checks
|
||||||
|
* we use the following check to exit early with a undefined response which should be
|
||||||
|
* handled by the caller.
|
||||||
|
*/
|
||||||
|
const EXIT_EARLY = env.SELF_HOSTED || env.DISABLE_ACCOUNT_PORTAL
|
||||||
|
|
||||||
export const getAccount = async (
|
export const getAccount = async (
|
||||||
email: string
|
email: string
|
||||||
): Promise<CloudAccount | undefined> => {
|
): Promise<CloudAccount | undefined> => {
|
||||||
|
if (EXIT_EARLY) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
email,
|
email,
|
||||||
}
|
}
|
||||||
|
@ -29,6 +40,9 @@ export const getAccount = async (
|
||||||
export const getAccountByTenantId = async (
|
export const getAccountByTenantId = async (
|
||||||
tenantId: string
|
tenantId: string
|
||||||
): Promise<CloudAccount | undefined> => {
|
): Promise<CloudAccount | undefined> => {
|
||||||
|
if (EXIT_EARLY) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
tenantId,
|
tenantId,
|
||||||
}
|
}
|
||||||
|
@ -47,7 +61,12 @@ export const getAccountByTenantId = async (
|
||||||
return json[0]
|
return json[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getStatus = async () => {
|
export const getStatus = async (): Promise<
|
||||||
|
HealthStatusResponse | undefined
|
||||||
|
> => {
|
||||||
|
if (EXIT_EARLY) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const response = await api.get(`/api/status`, {
|
const response = await api.get(`/api/status`, {
|
||||||
headers: {
|
headers: {
|
||||||
[Header.API_KEY]: env.ACCOUNT_PORTAL_API_KEY,
|
[Header.API_KEY]: env.ACCOUNT_PORTAL_API_KEY,
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./accounts"
|
|
@ -1,10 +1,11 @@
|
||||||
const _passport = require("koa-passport")
|
const _passport = require("koa-passport")
|
||||||
const LocalStrategy = require("passport-local").Strategy
|
const LocalStrategy = require("passport-local").Strategy
|
||||||
const JwtStrategy = require("passport-jwt").Strategy
|
const JwtStrategy = require("passport-jwt").Strategy
|
||||||
import { getGlobalDB } from "../tenancy"
|
import { getGlobalDB } from "../context"
|
||||||
const refresh = require("passport-oauth2-refresh")
|
const refresh = require("passport-oauth2-refresh")
|
||||||
import { Config } from "../constants"
|
import { Config, Cookie } from "../constants"
|
||||||
import { getScopedConfig } from "../db"
|
import { getScopedConfig } from "../db"
|
||||||
|
import { getSessionsForUser, invalidateSessions } from "../security/sessions"
|
||||||
import {
|
import {
|
||||||
jwt as jwtPassport,
|
jwt as jwtPassport,
|
||||||
local,
|
local,
|
||||||
|
@ -15,8 +16,11 @@ import {
|
||||||
google,
|
google,
|
||||||
} from "../middleware"
|
} from "../middleware"
|
||||||
import { invalidateUser } from "../cache/user"
|
import { invalidateUser } from "../cache/user"
|
||||||
import { User } from "@budibase/types"
|
import { PlatformLogoutOpts, User } from "@budibase/types"
|
||||||
import { logAlert } from "../logging"
|
import { logAlert } from "../logging"
|
||||||
|
import * as events from "../events"
|
||||||
|
import * as userCache from "../cache/user"
|
||||||
|
import { clearCookie, getCookie } from "../utils"
|
||||||
export {
|
export {
|
||||||
auditLog,
|
auditLog,
|
||||||
authError,
|
authError,
|
||||||
|
@ -29,6 +33,7 @@ export {
|
||||||
google,
|
google,
|
||||||
oidc,
|
oidc,
|
||||||
} from "../middleware"
|
} from "../middleware"
|
||||||
|
import { ssoSaveUserNoOp } from "../middleware/passport/sso/sso"
|
||||||
export const buildAuthMiddleware = authenticated
|
export const buildAuthMiddleware = authenticated
|
||||||
export const buildTenancyMiddleware = tenancy
|
export const buildTenancyMiddleware = tenancy
|
||||||
export const buildCsrfMiddleware = csrf
|
export const buildCsrfMiddleware = csrf
|
||||||
|
@ -71,7 +76,7 @@ async function refreshOIDCAccessToken(
|
||||||
if (!enrichedConfig) {
|
if (!enrichedConfig) {
|
||||||
throw new Error("OIDC Config contents invalid")
|
throw new Error("OIDC Config contents invalid")
|
||||||
}
|
}
|
||||||
strategy = await oidc.strategyFactory(enrichedConfig)
|
strategy = await oidc.strategyFactory(enrichedConfig, ssoSaveUserNoOp)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
throw new Error("Could not refresh OAuth Token")
|
throw new Error("Could not refresh OAuth Token")
|
||||||
|
@ -103,7 +108,11 @@ async function refreshGoogleAccessToken(
|
||||||
|
|
||||||
let strategy
|
let strategy
|
||||||
try {
|
try {
|
||||||
strategy = await google.strategyFactory(config, callbackUrl)
|
strategy = await google.strategyFactory(
|
||||||
|
config,
|
||||||
|
callbackUrl,
|
||||||
|
ssoSaveUserNoOp
|
||||||
|
)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
@ -161,6 +170,8 @@ export async function refreshOAuthToken(
|
||||||
return refreshResponse
|
return refreshResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Refactor to use user save function instead to prevent the need for
|
||||||
|
// manually saving and invalidating on callback
|
||||||
export async function updateUserOAuth(userId: string, oAuthConfig: any) {
|
export async function updateUserOAuth(userId: string, oAuthConfig: any) {
|
||||||
const details = {
|
const details = {
|
||||||
accessToken: oAuthConfig.accessToken,
|
accessToken: oAuthConfig.accessToken,
|
||||||
|
@ -188,3 +199,32 @@ export async function updateUserOAuth(userId: string, oAuthConfig: any) {
|
||||||
console.error("Could not update OAuth details for current user", e)
|
console.error("Could not update OAuth details for current user", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs a user out from budibase. Re-used across account portal and builder.
|
||||||
|
*/
|
||||||
|
export async function platformLogout(opts: PlatformLogoutOpts) {
|
||||||
|
const ctx = opts.ctx
|
||||||
|
const userId = opts.userId
|
||||||
|
const keepActiveSession = opts.keepActiveSession
|
||||||
|
|
||||||
|
if (!ctx) throw new Error("Koa context must be supplied to logout.")
|
||||||
|
|
||||||
|
const currentSession = getCookie(ctx, Cookie.Auth)
|
||||||
|
let sessions = await getSessionsForUser(userId)
|
||||||
|
|
||||||
|
if (keepActiveSession) {
|
||||||
|
sessions = sessions.filter(
|
||||||
|
session => session.sessionId !== currentSession.sessionId
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// clear cookies
|
||||||
|
clearCookie(ctx, Cookie.Auth)
|
||||||
|
clearCookie(ctx, Cookie.CurrentApp)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionIds = sessions.map(({ sessionId }) => sessionId)
|
||||||
|
await invalidateSessions(userId, { sessionIds, reason: "logout" })
|
||||||
|
await events.auth.logout()
|
||||||
|
await userCache.invalidateUser(userId)
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -16,6 +16,7 @@ import {
|
||||||
InstallationGroup,
|
InstallationGroup,
|
||||||
UserContext,
|
UserContext,
|
||||||
Group,
|
Group,
|
||||||
|
isSSOUser,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { processors } from "./processors"
|
import { processors } from "./processors"
|
||||||
import * as dbUtils from "../db/utils"
|
import * as dbUtils from "../db/utils"
|
||||||
|
@ -166,7 +167,10 @@ const identifyUser = async (
|
||||||
const type = IdentityType.USER
|
const type = IdentityType.USER
|
||||||
let builder = user.builder?.global || false
|
let builder = user.builder?.global || false
|
||||||
let admin = user.admin?.global || false
|
let admin = user.admin?.global || false
|
||||||
let providerType = user.providerType
|
let providerType
|
||||||
|
if (isSSOUser(user)) {
|
||||||
|
providerType = user.providerType
|
||||||
|
}
|
||||||
const accountHolder = account?.budibaseUserId === user._id || false
|
const accountHolder = account?.budibaseUserId === user._id || false
|
||||||
const verified =
|
const verified =
|
||||||
account && account?.budibaseUserId === user._id ? account.verified : false
|
account && account?.budibaseUserId === user._id ? account.verified : false
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import * as google from "../google"
|
import * as google from "../sso/google"
|
||||||
import { Cookie, Config } from "../../../constants"
|
import { Cookie, Config } from "../../../constants"
|
||||||
import { clearCookie, getCookie } from "../../../utils"
|
import { clearCookie, getCookie } from "../../../utils"
|
||||||
import { getScopedConfig, getPlatformUrl, doWithDB } from "../../../db"
|
import { getScopedConfig, getPlatformUrl, doWithDB } from "../../../db"
|
||||||
import environment from "../../../environment"
|
import environment from "../../../environment"
|
||||||
import { getGlobalDB } from "../../../tenancy"
|
import { getGlobalDB } from "../../../context"
|
||||||
import { BBContext, Database, SSOProfile } from "@budibase/types"
|
import { BBContext, Database, SSOProfile } from "@budibase/types"
|
||||||
|
import { ssoSaveUserNoOp } from "../sso/sso"
|
||||||
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy
|
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy
|
||||||
|
|
||||||
type Passport = {
|
type Passport = {
|
||||||
|
@ -36,7 +37,11 @@ export async function preAuth(
|
||||||
const platformUrl = await getPlatformUrl({ tenantAware: false })
|
const platformUrl = await getPlatformUrl({ tenantAware: false })
|
||||||
|
|
||||||
let callbackUrl = `${platformUrl}/api/global/auth/datasource/google/callback`
|
let callbackUrl = `${platformUrl}/api/global/auth/datasource/google/callback`
|
||||||
const strategy = await google.strategyFactory(googleConfig, callbackUrl)
|
const strategy = await google.strategyFactory(
|
||||||
|
googleConfig,
|
||||||
|
callbackUrl,
|
||||||
|
ssoSaveUserNoOp
|
||||||
|
)
|
||||||
|
|
||||||
if (!ctx.query.appId || !ctx.query.datasourceId) {
|
if (!ctx.query.appId || !ctx.query.datasourceId) {
|
||||||
ctx.throw(400, "appId and datasourceId query params not present.")
|
ctx.throw(400, "appId and datasourceId query params not present.")
|
||||||
|
|
|
@ -1,15 +1,10 @@
|
||||||
import { UserStatus } from "../../constants"
|
import { UserStatus } from "../../constants"
|
||||||
import { compare, newid } from "../../utils"
|
import { compare } from "../../utils"
|
||||||
import env from "../../environment"
|
|
||||||
import * as users from "../../users"
|
import * as users from "../../users"
|
||||||
import { authError } from "./utils"
|
import { authError } from "./utils"
|
||||||
import { createASession } from "../../security/sessions"
|
|
||||||
import { getTenantId } from "../../tenancy"
|
|
||||||
import { BBContext } from "@budibase/types"
|
import { BBContext } from "@budibase/types"
|
||||||
const jwt = require("jsonwebtoken")
|
|
||||||
|
|
||||||
const INVALID_ERR = "Invalid credentials"
|
const INVALID_ERR = "Invalid credentials"
|
||||||
const SSO_NO_PASSWORD = "SSO user does not have a password set"
|
|
||||||
const EXPIRED = "This account has expired. Please reset your password"
|
const EXPIRED = "This account has expired. Please reset your password"
|
||||||
|
|
||||||
export const options = {
|
export const options = {
|
||||||
|
@ -35,50 +30,25 @@ export async function authenticate(
|
||||||
|
|
||||||
const dbUser = await users.getGlobalUserByEmail(email)
|
const dbUser = await users.getGlobalUserByEmail(email)
|
||||||
if (dbUser == null) {
|
if (dbUser == null) {
|
||||||
return authError(done, `User not found: [${email}]`)
|
console.info(`user=${email} could not be found`)
|
||||||
}
|
|
||||||
|
|
||||||
// check that the user is currently inactive, if this is the case throw invalid
|
|
||||||
if (dbUser.status === UserStatus.INACTIVE) {
|
|
||||||
return authError(done, INVALID_ERR)
|
return authError(done, INVALID_ERR)
|
||||||
}
|
}
|
||||||
|
|
||||||
// check that the user has a stored password before proceeding
|
if (dbUser.status === UserStatus.INACTIVE) {
|
||||||
if (!dbUser.password) {
|
console.info(`user=${email} is inactive`, dbUser)
|
||||||
if (
|
return authError(done, INVALID_ERR)
|
||||||
(dbUser.account && dbUser.account.authType === "sso") || // root account sso
|
}
|
||||||
dbUser.thirdPartyProfile // internal sso
|
|
||||||
) {
|
|
||||||
return authError(done, SSO_NO_PASSWORD)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error("Non SSO usser has no password set", dbUser)
|
if (!dbUser.password) {
|
||||||
|
console.info(`user=${email} has no password set`, dbUser)
|
||||||
return authError(done, EXPIRED)
|
return authError(done, EXPIRED)
|
||||||
}
|
}
|
||||||
|
|
||||||
// authenticate
|
if (!(await compare(password, dbUser.password))) {
|
||||||
if (await compare(password, dbUser.password)) {
|
|
||||||
const sessionId = newid()
|
|
||||||
const tenantId = getTenantId()
|
|
||||||
|
|
||||||
await createASession(dbUser._id!, { sessionId, tenantId })
|
|
||||||
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
userId: dbUser._id,
|
|
||||||
sessionId,
|
|
||||||
tenantId,
|
|
||||||
},
|
|
||||||
env.JWT_SECRET
|
|
||||||
)
|
|
||||||
// Remove users password in payload
|
|
||||||
delete dbUser.password
|
|
||||||
|
|
||||||
return done(null, {
|
|
||||||
...dbUser,
|
|
||||||
token,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
return authError(done, INVALID_ERR)
|
return authError(done, INVALID_ERR)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// intentionally remove the users password in payload
|
||||||
|
delete dbUser.password
|
||||||
|
return done(null, dbUser)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,26 @@
|
||||||
import { ssoCallbackUrl } from "./utils"
|
import { ssoCallbackUrl } from "../utils"
|
||||||
import { authenticateThirdParty, SaveUserFunction } from "./third-party-common"
|
import * as sso from "./sso"
|
||||||
import { ConfigType, GoogleConfig, Database, SSOProfile } from "@budibase/types"
|
import {
|
||||||
|
ConfigType,
|
||||||
|
GoogleConfig,
|
||||||
|
Database,
|
||||||
|
SSOProfile,
|
||||||
|
SSOAuthDetails,
|
||||||
|
SSOProviderType,
|
||||||
|
SaveSSOUserFunction,
|
||||||
|
} from "@budibase/types"
|
||||||
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy
|
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy
|
||||||
|
|
||||||
export function buildVerifyFn(saveUserFn?: SaveUserFunction) {
|
export function buildVerifyFn(saveUserFn: SaveSSOUserFunction) {
|
||||||
return (
|
return (
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
refreshToken: string,
|
refreshToken: string,
|
||||||
profile: SSOProfile,
|
profile: SSOProfile,
|
||||||
done: Function
|
done: Function
|
||||||
) => {
|
) => {
|
||||||
const thirdPartyUser = {
|
const details: SSOAuthDetails = {
|
||||||
provider: profile.provider, // should always be 'google'
|
provider: "google",
|
||||||
providerType: "google",
|
providerType: SSOProviderType.GOOGLE,
|
||||||
userId: profile.id,
|
userId: profile.id,
|
||||||
profile: profile,
|
profile: profile,
|
||||||
email: profile._json.email,
|
email: profile._json.email,
|
||||||
|
@ -22,8 +30,8 @@ export function buildVerifyFn(saveUserFn?: SaveUserFunction) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return authenticateThirdParty(
|
return sso.authenticate(
|
||||||
thirdPartyUser,
|
details,
|
||||||
true, // require local accounts to exist
|
true, // require local accounts to exist
|
||||||
done,
|
done,
|
||||||
saveUserFn
|
saveUserFn
|
||||||
|
@ -39,7 +47,7 @@ export function buildVerifyFn(saveUserFn?: SaveUserFunction) {
|
||||||
export async function strategyFactory(
|
export async function strategyFactory(
|
||||||
config: GoogleConfig["config"],
|
config: GoogleConfig["config"],
|
||||||
callbackUrl: string,
|
callbackUrl: string,
|
||||||
saveUserFn?: SaveUserFunction
|
saveUserFn: SaveSSOUserFunction
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { clientID, clientSecret } = config
|
const { clientID, clientSecret } = config
|
|
@ -1,22 +1,20 @@
|
||||||
import fetch from "node-fetch"
|
import fetch from "node-fetch"
|
||||||
import { authenticateThirdParty, SaveUserFunction } from "./third-party-common"
|
import * as sso from "./sso"
|
||||||
import { ssoCallbackUrl } from "./utils"
|
import { ssoCallbackUrl } from "../utils"
|
||||||
import {
|
import {
|
||||||
ConfigType,
|
ConfigType,
|
||||||
OIDCInnerCfg,
|
OIDCInnerConfig,
|
||||||
Database,
|
Database,
|
||||||
SSOProfile,
|
SSOProfile,
|
||||||
ThirdPartyUser,
|
OIDCStrategyConfiguration,
|
||||||
OIDCConfiguration,
|
SSOAuthDetails,
|
||||||
|
SSOProviderType,
|
||||||
|
JwtClaims,
|
||||||
|
SaveSSOUserFunction,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
const OIDCStrategy = require("@techpass/passport-openidconnect").Strategy
|
const OIDCStrategy = require("@techpass/passport-openidconnect").Strategy
|
||||||
|
|
||||||
type JwtClaims = {
|
export function buildVerifyFn(saveUserFn: SaveSSOUserFunction) {
|
||||||
preferred_username: string
|
|
||||||
email: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildVerifyFn(saveUserFn?: SaveUserFunction) {
|
|
||||||
/**
|
/**
|
||||||
* @param {*} issuer The identity provider base URL
|
* @param {*} issuer The identity provider base URL
|
||||||
* @param {*} sub The user ID
|
* @param {*} sub The user ID
|
||||||
|
@ -39,10 +37,10 @@ export function buildVerifyFn(saveUserFn?: SaveUserFunction) {
|
||||||
params: any,
|
params: any,
|
||||||
done: Function
|
done: Function
|
||||||
) => {
|
) => {
|
||||||
const thirdPartyUser: ThirdPartyUser = {
|
const details: SSOAuthDetails = {
|
||||||
// store the issuer info to enable sync in future
|
// store the issuer info to enable sync in future
|
||||||
provider: issuer,
|
provider: issuer,
|
||||||
providerType: "oidc",
|
providerType: SSOProviderType.OIDC,
|
||||||
userId: profile.id,
|
userId: profile.id,
|
||||||
profile: profile,
|
profile: profile,
|
||||||
email: getEmail(profile, jwtClaims),
|
email: getEmail(profile, jwtClaims),
|
||||||
|
@ -52,8 +50,8 @@ export function buildVerifyFn(saveUserFn?: SaveUserFunction) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return authenticateThirdParty(
|
return sso.authenticate(
|
||||||
thirdPartyUser,
|
details,
|
||||||
false, // don't require local accounts to exist
|
false, // don't require local accounts to exist
|
||||||
done,
|
done,
|
||||||
saveUserFn
|
saveUserFn
|
||||||
|
@ -104,8 +102,8 @@ function validEmail(value: string) {
|
||||||
* @returns Dynamically configured Passport OIDC Strategy
|
* @returns Dynamically configured Passport OIDC Strategy
|
||||||
*/
|
*/
|
||||||
export async function strategyFactory(
|
export async function strategyFactory(
|
||||||
config: OIDCConfiguration,
|
config: OIDCStrategyConfiguration,
|
||||||
saveUserFn?: SaveUserFunction
|
saveUserFn: SaveSSOUserFunction
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const verify = buildVerifyFn(saveUserFn)
|
const verify = buildVerifyFn(saveUserFn)
|
||||||
|
@ -119,14 +117,14 @@ export async function strategyFactory(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchStrategyConfig(
|
export async function fetchStrategyConfig(
|
||||||
enrichedConfig: OIDCInnerCfg,
|
oidcConfig: OIDCInnerConfig,
|
||||||
callbackUrl?: string
|
callbackUrl?: string
|
||||||
): Promise<OIDCConfiguration> {
|
): Promise<OIDCStrategyConfiguration> {
|
||||||
try {
|
try {
|
||||||
const { clientID, clientSecret, configUrl } = enrichedConfig
|
const { clientID, clientSecret, configUrl } = oidcConfig
|
||||||
|
|
||||||
if (!clientID || !clientSecret || !callbackUrl || !configUrl) {
|
if (!clientID || !clientSecret || !callbackUrl || !configUrl) {
|
||||||
//check for remote config and all required elements
|
// check for remote config and all required elements
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Configuration invalid. Must contain clientID, clientSecret, callbackUrl and configUrl"
|
"Configuration invalid. Must contain clientID, clientSecret, callbackUrl and configUrl"
|
||||||
)
|
)
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
|
@ -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");
|
|
||||||
})
|
|
||||||
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
|
@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
|
@ -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,
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
} from "./db"
|
} from "./db"
|
||||||
import { BulkDocsResponse, User } from "@budibase/types"
|
import { BulkDocsResponse, User } from "@budibase/types"
|
||||||
import { getGlobalDB } from "./context"
|
import { getGlobalDB } from "./context"
|
||||||
|
import * as context from "./context"
|
||||||
|
|
||||||
export const bulkGetGlobalUsersById = async (userIds: string[]) => {
|
export const bulkGetGlobalUsersById = async (userIds: string[]) => {
|
||||||
const db = getGlobalDB()
|
const db = getGlobalDB()
|
||||||
|
@ -24,6 +25,11 @@ export const bulkUpdateGlobalUsers = async (users: User[]) => {
|
||||||
return (await db.bulkDocs(users)) as BulkDocsResponse
|
return (await db.bulkDocs(users)) as BulkDocsResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getById(id: string): Promise<User> {
|
||||||
|
const db = context.getGlobalDB()
|
||||||
|
return db.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given an email address this will use a view to search through
|
* Given an email address this will use a view to search through
|
||||||
* all the users to find one with this email address.
|
* all the users to find one with this email address.
|
||||||
|
|
|
@ -2,23 +2,15 @@ import { getAllApps, queryGlobalView } from "../db"
|
||||||
import { options } from "../middleware/passport/jwt"
|
import { options } from "../middleware/passport/jwt"
|
||||||
import {
|
import {
|
||||||
Header,
|
Header,
|
||||||
Cookie,
|
|
||||||
MAX_VALID_DATE,
|
MAX_VALID_DATE,
|
||||||
DocumentType,
|
DocumentType,
|
||||||
SEPARATOR,
|
SEPARATOR,
|
||||||
ViewName,
|
ViewName,
|
||||||
} from "../constants"
|
} from "../constants"
|
||||||
import env from "../environment"
|
import env from "../environment"
|
||||||
import * as userCache from "../cache/user"
|
|
||||||
import { getSessionsForUser, invalidateSessions } from "../security/sessions"
|
|
||||||
import * as events from "../events"
|
|
||||||
import * as tenancy from "../tenancy"
|
import * as tenancy from "../tenancy"
|
||||||
import {
|
import * as context from "../context"
|
||||||
App,
|
import { App, Ctx, TenantResolutionStrategy } from "@budibase/types"
|
||||||
Ctx,
|
|
||||||
PlatformLogoutOpts,
|
|
||||||
TenantResolutionStrategy,
|
|
||||||
} from "@budibase/types"
|
|
||||||
import { SetOption } from "cookies"
|
import { SetOption } from "cookies"
|
||||||
const jwt = require("jsonwebtoken")
|
const jwt = require("jsonwebtoken")
|
||||||
|
|
||||||
|
@ -38,7 +30,7 @@ export async function resolveAppUrl(ctx: Ctx) {
|
||||||
const appUrl = ctx.path.split("/")[2]
|
const appUrl = ctx.path.split("/")[2]
|
||||||
let possibleAppUrl = `/${appUrl.toLowerCase()}`
|
let possibleAppUrl = `/${appUrl.toLowerCase()}`
|
||||||
|
|
||||||
let tenantId: string | null = tenancy.getTenantId()
|
let tenantId: string | null = context.getTenantId()
|
||||||
if (env.MULTI_TENANCY) {
|
if (env.MULTI_TENANCY) {
|
||||||
// always use the tenant id from the subdomain in multi tenancy
|
// always use the tenant id from the subdomain in multi tenancy
|
||||||
// this ensures the logged-in user tenant id doesn't overwrite
|
// this ensures the logged-in user tenant id doesn't overwrite
|
||||||
|
@ -49,7 +41,7 @@ export async function resolveAppUrl(ctx: Ctx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// search prod apps for a url that matches
|
// search prod apps for a url that matches
|
||||||
const apps: App[] = await tenancy.doInTenant(tenantId, () =>
|
const apps: App[] = await context.doInTenant(tenantId, () =>
|
||||||
getAllApps({ dev: false })
|
getAllApps({ dev: false })
|
||||||
)
|
)
|
||||||
const app = apps.filter(
|
const app = apps.filter(
|
||||||
|
@ -222,35 +214,6 @@ export async function getBuildersCount() {
|
||||||
return builders.length
|
return builders.length
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Logs a user out from budibase. Re-used across account portal and builder.
|
|
||||||
*/
|
|
||||||
export async function platformLogout(opts: PlatformLogoutOpts) {
|
|
||||||
const ctx = opts.ctx
|
|
||||||
const userId = opts.userId
|
|
||||||
const keepActiveSession = opts.keepActiveSession
|
|
||||||
|
|
||||||
if (!ctx) throw new Error("Koa context must be supplied to logout.")
|
|
||||||
|
|
||||||
const currentSession = getCookie(ctx, Cookie.Auth)
|
|
||||||
let sessions = await getSessionsForUser(userId)
|
|
||||||
|
|
||||||
if (keepActiveSession) {
|
|
||||||
sessions = sessions.filter(
|
|
||||||
session => session.sessionId !== currentSession.sessionId
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// clear cookies
|
|
||||||
clearCookie(ctx, Cookie.Auth)
|
|
||||||
clearCookie(ctx, Cookie.CurrentApp)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionIds = sessions.map(({ sessionId }) => sessionId)
|
|
||||||
await invalidateSessions(userId, { sessionIds, reason: "logout" })
|
|
||||||
await events.auth.logout()
|
|
||||||
await userCache.invalidateUser(userId)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function timeout(timeMs: number) {
|
export function timeout(timeMs: number) {
|
||||||
return new Promise(resolve => setTimeout(resolve, timeMs))
|
return new Promise(resolve => setTimeout(resolve, timeMs))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
|
@ -1,4 +1,7 @@
|
||||||
export * as accounts from "./accounts"
|
jest.mock("../../../src/accounts")
|
||||||
|
import * as _accounts from "../../../src/accounts"
|
||||||
|
export const accounts = jest.mocked(_accounts)
|
||||||
|
|
||||||
export * as date from "./date"
|
export * as date from "./date"
|
||||||
export * as licenses from "./licenses"
|
export * as licenses from "./licenses"
|
||||||
export { default as fetch } from "./fetch"
|
export { default as fetch } from "./fetch"
|
||||||
|
|
|
@ -1,6 +1,15 @@
|
||||||
import { generator, uuid } from "."
|
import { generator, uuid } from "."
|
||||||
import * as db from "../../../src/db/utils"
|
import * as db from "../../../src/db/utils"
|
||||||
import { Account, AuthType, CloudAccount, Hosting } from "@budibase/types"
|
import {
|
||||||
|
Account,
|
||||||
|
AccountSSOProvider,
|
||||||
|
AccountSSOProviderType,
|
||||||
|
AuthType,
|
||||||
|
CloudAccount,
|
||||||
|
Hosting,
|
||||||
|
SSOAccount,
|
||||||
|
} from "@budibase/types"
|
||||||
|
import _ from "lodash"
|
||||||
|
|
||||||
export const account = (): Account => {
|
export const account = (): Account => {
|
||||||
return {
|
return {
|
||||||
|
@ -27,3 +36,28 @@ export const cloudAccount = (): CloudAccount => {
|
||||||
budibaseUserId: db.generateGlobalUserID(),
|
budibaseUserId: db.generateGlobalUserID(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function providerType(): AccountSSOProviderType {
|
||||||
|
return _.sample(
|
||||||
|
Object.values(AccountSSOProviderType)
|
||||||
|
) as AccountSSOProviderType
|
||||||
|
}
|
||||||
|
|
||||||
|
function provider(): AccountSSOProvider {
|
||||||
|
return _.sample(Object.values(AccountSSOProvider)) as AccountSSOProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ssoAccount(): SSOAccount {
|
||||||
|
return {
|
||||||
|
...cloudAccount(),
|
||||||
|
authType: AuthType.SSO,
|
||||||
|
oauth2: {
|
||||||
|
accessToken: generator.string(),
|
||||||
|
refreshToken: generator.string(),
|
||||||
|
},
|
||||||
|
pictureUrl: generator.url(),
|
||||||
|
provider: provider(),
|
||||||
|
providerType: providerType(),
|
||||||
|
thirdPartyProfile: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -5,8 +5,10 @@ export const generator = new Chance()
|
||||||
|
|
||||||
export * as accounts from "./accounts"
|
export * as accounts from "./accounts"
|
||||||
export * as apps from "./apps"
|
export * as apps from "./apps"
|
||||||
|
export * as db from "./db"
|
||||||
export * as koa from "./koa"
|
export * as koa from "./koa"
|
||||||
export * as licenses from "./licenses"
|
export * as licenses from "./licenses"
|
||||||
export * as plugins from "./plugins"
|
export * as plugins from "./plugins"
|
||||||
|
export * as sso from "./sso"
|
||||||
export * as tenant from "./tenants"
|
export * as tenant from "./tenants"
|
||||||
export * as db from "./db"
|
export * as users from "./users"
|
||||||
|
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -30,9 +30,11 @@
|
||||||
My profile
|
My profile
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem icon="Moon" on:click={() => themeModal.show()}>Theme</MenuItem>
|
<MenuItem icon="Moon" on:click={() => themeModal.show()}>Theme</MenuItem>
|
||||||
<MenuItem icon="LockClosed" on:click={() => updatePasswordModal.show()}>
|
{#if !$auth.isSSO}
|
||||||
Update password
|
<MenuItem icon="LockClosed" on:click={() => updatePasswordModal.show()}>
|
||||||
</MenuItem>
|
Update password
|
||||||
|
</MenuItem>
|
||||||
|
{/if}
|
||||||
<MenuItem icon="Key" on:click={() => apiKeyModal.show()}>
|
<MenuItem icon="Key" on:click={() => apiKeyModal.show()}>
|
||||||
View API key
|
View API key
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|
|
@ -81,6 +81,7 @@
|
||||||
let user
|
let user
|
||||||
let loaded = false
|
let loaded = false
|
||||||
|
|
||||||
|
$: isSSO = !!user?.provider
|
||||||
$: readonly = !$auth.isAdmin
|
$: readonly = !$auth.isAdmin
|
||||||
$: fullName = user?.firstName ? user?.firstName + " " + user?.lastName : ""
|
$: fullName = user?.firstName ? user?.firstName + " " + user?.lastName : ""
|
||||||
$: privileged = user?.admin?.global || user?.builder?.global
|
$: privileged = user?.admin?.global || user?.builder?.global
|
||||||
|
@ -246,9 +247,11 @@
|
||||||
<span slot="control">
|
<span slot="control">
|
||||||
<Icon hoverable name="More" />
|
<Icon hoverable name="More" />
|
||||||
</span>
|
</span>
|
||||||
<MenuItem on:click={resetPasswordModal.show} icon="Refresh">
|
{#if !isSSO}
|
||||||
Force password reset
|
<MenuItem on:click={resetPasswordModal.show} icon="Refresh">
|
||||||
</MenuItem>
|
Force password reset
|
||||||
|
</MenuItem>
|
||||||
|
{/if}
|
||||||
<MenuItem on:click={deleteModal.show} icon="Delete">
|
<MenuItem on:click={deleteModal.show} icon="Delete">
|
||||||
Delete
|
Delete
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|
|
@ -41,6 +41,7 @@ export function createAuthStore() {
|
||||||
initials,
|
initials,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isBuilder,
|
isBuilder,
|
||||||
|
isSSO: !!$store.user?.provider,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -817,7 +817,6 @@
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"string",
|
"string",
|
||||||
"barcodeqr",
|
|
||||||
"longform",
|
"longform",
|
||||||
"options",
|
"options",
|
||||||
"number",
|
"number",
|
||||||
|
@ -829,7 +828,8 @@
|
||||||
"formula",
|
"formula",
|
||||||
"auto",
|
"auto",
|
||||||
"json",
|
"json",
|
||||||
"internal"
|
"internal",
|
||||||
|
"barcodeqr"
|
||||||
],
|
],
|
||||||
"description": "Defines the type of the column, most explain themselves, a link column is a relationship."
|
"description": "Defines the type of the column, most explain themselves, a link column is a relationship."
|
||||||
},
|
},
|
||||||
|
@ -1021,7 +1021,6 @@
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"string",
|
"string",
|
||||||
"barcodeqr",
|
|
||||||
"longform",
|
"longform",
|
||||||
"options",
|
"options",
|
||||||
"number",
|
"number",
|
||||||
|
@ -1033,7 +1032,8 @@
|
||||||
"formula",
|
"formula",
|
||||||
"auto",
|
"auto",
|
||||||
"json",
|
"json",
|
||||||
"internal"
|
"internal",
|
||||||
|
"barcodeqr"
|
||||||
],
|
],
|
||||||
"description": "Defines the type of the column, most explain themselves, a link column is a relationship."
|
"description": "Defines the type of the column, most explain themselves, a link column is a relationship."
|
||||||
},
|
},
|
||||||
|
@ -1236,7 +1236,6 @@
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"string",
|
"string",
|
||||||
"barcodeqr",
|
|
||||||
"longform",
|
"longform",
|
||||||
"options",
|
"options",
|
||||||
"number",
|
"number",
|
||||||
|
@ -1248,7 +1247,8 @@
|
||||||
"formula",
|
"formula",
|
||||||
"auto",
|
"auto",
|
||||||
"json",
|
"json",
|
||||||
"internal"
|
"internal",
|
||||||
|
"barcodeqr"
|
||||||
],
|
],
|
||||||
"description": "Defines the type of the column, most explain themselves, a link column is a relationship."
|
"description": "Defines the type of the column, most explain themselves, a link column is a relationship."
|
||||||
},
|
},
|
||||||
|
|
|
@ -603,7 +603,6 @@ components:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
- string
|
- string
|
||||||
- barcodeqr
|
|
||||||
- longform
|
- longform
|
||||||
- options
|
- options
|
||||||
- number
|
- number
|
||||||
|
@ -616,6 +615,7 @@ components:
|
||||||
- auto
|
- auto
|
||||||
- json
|
- json
|
||||||
- internal
|
- internal
|
||||||
|
- barcodeqr
|
||||||
description: Defines the type of the column, most explain themselves, a link
|
description: Defines the type of the column, most explain themselves, a link
|
||||||
column is a relationship.
|
column is a relationship.
|
||||||
constraints:
|
constraints:
|
||||||
|
@ -766,7 +766,6 @@ components:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
- string
|
- string
|
||||||
- barcodeqr
|
|
||||||
- longform
|
- longform
|
||||||
- options
|
- options
|
||||||
- number
|
- number
|
||||||
|
@ -779,6 +778,7 @@ components:
|
||||||
- auto
|
- auto
|
||||||
- json
|
- json
|
||||||
- internal
|
- internal
|
||||||
|
- barcodeqr
|
||||||
description: Defines the type of the column, most explain themselves, a link
|
description: Defines the type of the column, most explain themselves, a link
|
||||||
column is a relationship.
|
column is a relationship.
|
||||||
constraints:
|
constraints:
|
||||||
|
@ -936,7 +936,6 @@ components:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
- string
|
- string
|
||||||
- barcodeqr
|
|
||||||
- longform
|
- longform
|
||||||
- options
|
- options
|
||||||
- number
|
- number
|
||||||
|
@ -949,6 +948,7 @@ components:
|
||||||
- auto
|
- auto
|
||||||
- json
|
- json
|
||||||
- internal
|
- internal
|
||||||
|
- barcodeqr
|
||||||
description: Defines the type of the column, most explain themselves, a link
|
description: Defines the type of the column, most explain themselves, a link
|
||||||
column is a relationship.
|
column is a relationship.
|
||||||
constraints:
|
constraints:
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import { DatabaseWithConnection } from "@budibase/backend-core/src/db"
|
|
||||||
|
|
||||||
jest.mock("@budibase/backend-core", () => {
|
jest.mock("@budibase/backend-core", () => {
|
||||||
const core = jest.requireActual("@budibase/backend-core")
|
const core = jest.requireActual("@budibase/backend-core")
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
export * from "./user"
|
export * from "./user"
|
||||||
export * from "./license"
|
export * from "./license"
|
||||||
|
export * from "./status"
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
export interface HealthStatusResponse {
|
||||||
|
passing: boolean
|
||||||
|
checks: {
|
||||||
|
login: boolean
|
||||||
|
search: boolean
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
export * from "./analytics"
|
export * from "./analytics"
|
||||||
|
export * from "./auth"
|
||||||
export * from "./user"
|
export * from "./user"
|
||||||
export * from "./errors"
|
export * from "./errors"
|
||||||
export * from "./schedule"
|
export * from "./schedule"
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { User } from "../../documents"
|
import { User } from "../../documents"
|
||||||
|
|
||||||
export interface CreateUserResponse {
|
export interface SaveUserResponse {
|
||||||
_id: string
|
_id: string
|
||||||
_rev: string
|
_rev: string
|
||||||
email: string
|
email: string
|
||||||
|
@ -58,6 +58,25 @@ export interface CreateAdminUserRequest {
|
||||||
tenantId: string
|
tenantId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateAdminUserResponse {
|
||||||
|
_id: string
|
||||||
|
_rev: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcceptUserInviteRequest {
|
||||||
|
inviteCode: string
|
||||||
|
password: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcceptUserInviteResponse {
|
||||||
|
_id: string
|
||||||
|
_rev: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface SyncUserRequest {
|
export interface SyncUserRequest {
|
||||||
previousUser?: User
|
previousUser?: User
|
||||||
}
|
}
|
||||||
|
|
|
@ -79,14 +79,24 @@ export const isSelfHostAccount = (account: Account) =>
|
||||||
export const isSSOAccount = (account: Account): account is SSOAccount =>
|
export const isSSOAccount = (account: Account): account is SSOAccount =>
|
||||||
account.authType === AuthType.SSO
|
account.authType === AuthType.SSO
|
||||||
|
|
||||||
export interface SSOAccount extends Account {
|
export enum AccountSSOProviderType {
|
||||||
pictureUrl?: string
|
GOOGLE = "google",
|
||||||
provider?: string
|
}
|
||||||
providerType?: string
|
|
||||||
|
export enum AccountSSOProvider {
|
||||||
|
GOOGLE = "google",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountSSO {
|
||||||
|
provider: AccountSSOProvider
|
||||||
|
providerType: AccountSSOProviderType
|
||||||
oauth2?: OAuthTokens
|
oauth2?: OAuthTokens
|
||||||
|
pictureUrl?: string
|
||||||
thirdPartyProfile: any // TODO: define what the google profile looks like
|
thirdPartyProfile: any // TODO: define what the google profile looks like
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SSOAccount = (Account | CloudAccount) & AccountSSO
|
||||||
|
|
||||||
export enum AuthType {
|
export enum AuthType {
|
||||||
SSO = "sso",
|
SSO = "sso",
|
||||||
PASSWORD = "password",
|
PASSWORD = "password",
|
||||||
|
|
|
@ -27,15 +27,17 @@ export interface SettingsConfig extends Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GoogleConfig extends Config {
|
export interface GoogleInnerConfig {
|
||||||
config: {
|
clientID: string
|
||||||
clientID: string
|
clientSecret: string
|
||||||
clientSecret: string
|
activated: boolean
|
||||||
activated: boolean
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OIDCConfiguration {
|
export interface GoogleConfig extends Config {
|
||||||
|
config: GoogleInnerConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OIDCStrategyConfiguration {
|
||||||
issuer: string
|
issuer: string
|
||||||
authorizationURL: string
|
authorizationURL: string
|
||||||
tokenURL: string
|
tokenURL: string
|
||||||
|
@ -45,7 +47,7 @@ export interface OIDCConfiguration {
|
||||||
callbackURL: string
|
callbackURL: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OIDCInnerCfg {
|
export interface OIDCInnerConfig {
|
||||||
configUrl: string
|
configUrl: string
|
||||||
clientID: string
|
clientID: string
|
||||||
clientSecret: string
|
clientSecret: string
|
||||||
|
@ -57,10 +59,17 @@ export interface OIDCInnerCfg {
|
||||||
|
|
||||||
export interface OIDCConfig extends Config {
|
export interface OIDCConfig extends Config {
|
||||||
config: {
|
config: {
|
||||||
configs: OIDCInnerCfg[]
|
configs: OIDCInnerConfig[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OIDCWellKnownConfig {
|
||||||
|
issuer: string
|
||||||
|
authorization_endpoint: string
|
||||||
|
token_endpoint: string
|
||||||
|
userinfo_endpoint: string
|
||||||
|
}
|
||||||
|
|
||||||
export const isSettingsConfig = (config: Config): config is SettingsConfig =>
|
export const isSettingsConfig = (config: Config): config is SettingsConfig =>
|
||||||
config.type === ConfigType.SETTINGS
|
config.type === ConfigType.SETTINGS
|
||||||
|
|
||||||
|
|
|
@ -1,37 +1,44 @@
|
||||||
import { Document } from "../document"
|
import { Document } from "../document"
|
||||||
|
|
||||||
export interface SSOProfile {
|
// SSO
|
||||||
id: string
|
|
||||||
name?: {
|
export interface SSOProfileJson {
|
||||||
givenName?: string
|
email?: string
|
||||||
familyName?: string
|
picture?: string
|
||||||
}
|
|
||||||
_json: {
|
|
||||||
email: string
|
|
||||||
picture: string
|
|
||||||
}
|
|
||||||
provider?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThirdPartyUser extends Document {
|
export interface OAuth2 {
|
||||||
thirdPartyProfile?: SSOProfile["_json"]
|
accessToken: string
|
||||||
firstName?: string
|
refreshToken?: string
|
||||||
lastName?: string
|
|
||||||
pictureUrl?: string
|
|
||||||
profile?: SSOProfile
|
|
||||||
oauth2?: any
|
|
||||||
provider?: string
|
|
||||||
providerType?: string
|
|
||||||
email: string
|
|
||||||
userId?: string
|
|
||||||
forceResetPassword?: boolean
|
|
||||||
userGroups?: string[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface User extends ThirdPartyUser {
|
export enum SSOProviderType {
|
||||||
|
OIDC = "oidc",
|
||||||
|
GOOGLE = "google",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserSSO {
|
||||||
|
provider: string // the individual provider e.g. Okta, Auth0, Google
|
||||||
|
providerType: SSOProviderType
|
||||||
|
oauth2?: OAuth2
|
||||||
|
thirdPartyProfile?: SSOProfileJson
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SSOUser = User & UserSSO
|
||||||
|
|
||||||
|
export function isSSOUser(user: User): user is SSOUser {
|
||||||
|
return !!(user as SSOUser).providerType
|
||||||
|
}
|
||||||
|
|
||||||
|
// USER
|
||||||
|
|
||||||
|
export interface User extends Document {
|
||||||
tenantId: string
|
tenantId: string
|
||||||
email: string
|
email: string
|
||||||
userId?: string
|
userId?: string
|
||||||
|
firstName?: string
|
||||||
|
lastName?: string
|
||||||
|
pictureUrl?: string
|
||||||
forceResetPassword?: boolean
|
forceResetPassword?: boolean
|
||||||
roles: UserRoles
|
roles: UserRoles
|
||||||
builder?: {
|
builder?: {
|
||||||
|
@ -44,9 +51,7 @@ export interface User extends ThirdPartyUser {
|
||||||
status?: string
|
status?: string
|
||||||
createdAt?: number // override the default createdAt behaviour - users sdk historically set this to Date.now()
|
createdAt?: number // override the default createdAt behaviour - users sdk historically set this to Date.now()
|
||||||
dayPassRecordedAt?: string
|
dayPassRecordedAt?: string
|
||||||
account?: {
|
userGroups?: string[]
|
||||||
authType: string
|
|
||||||
}
|
|
||||||
onboardedAt?: string
|
onboardedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -54,7 +59,7 @@ export interface UserRoles {
|
||||||
[key: string]: string
|
[key: string]: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// utility types
|
// UTILITY TYPES
|
||||||
|
|
||||||
export interface BuilderUser extends User {
|
export interface BuilderUser extends User {
|
||||||
builder: {
|
builder: {
|
||||||
|
|
|
@ -12,3 +12,5 @@ export * from "./db"
|
||||||
export * from "./middleware"
|
export * from "./middleware"
|
||||||
export * from "./featureFlag"
|
export * from "./featureFlag"
|
||||||
export * from "./environmentVariables"
|
export * from "./environmentVariables"
|
||||||
|
export * from "./sso"
|
||||||
|
export * from "./user"
|
||||||
|
|
|
@ -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>
|
|
@ -0,0 +1,12 @@
|
||||||
|
export interface UpdateSelf {
|
||||||
|
firstName?: string
|
||||||
|
lastName?: string
|
||||||
|
password?: string
|
||||||
|
forceResetPassword?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveUserOpts {
|
||||||
|
hashPassword?: boolean
|
||||||
|
requirePassword?: boolean
|
||||||
|
currentUserId?: string
|
||||||
|
}
|
|
@ -29,6 +29,7 @@ async function init() {
|
||||||
SERVICE: "worker-service",
|
SERVICE: "worker-service",
|
||||||
DEPLOYMENT_ENVIRONMENT: "development",
|
DEPLOYMENT_ENVIRONMENT: "development",
|
||||||
TENANT_FEATURE_FLAGS: "*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR",
|
TENANT_FEATURE_FLAGS: "*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR",
|
||||||
|
ENABLE_EMAIL_TEST_MODE: 1,
|
||||||
}
|
}
|
||||||
let envFile = ""
|
let envFile = ""
|
||||||
Object.keys(envFileJson).forEach(key => {
|
Object.keys(envFileJson).forEach(key => {
|
||||||
|
|
|
@ -1,49 +1,55 @@
|
||||||
import {
|
import {
|
||||||
auth,
|
auth as authCore,
|
||||||
constants,
|
constants,
|
||||||
context,
|
context,
|
||||||
db as dbCore,
|
db as dbCore,
|
||||||
events,
|
events,
|
||||||
tenancy,
|
tenancy,
|
||||||
users as usersCore,
|
utils as utilsCore,
|
||||||
utils,
|
|
||||||
} from "@budibase/backend-core"
|
} from "@budibase/backend-core"
|
||||||
import { EmailTemplatePurpose } from "../../../constants"
|
import {
|
||||||
import { isEmailConfigured, sendEmail } from "../../../utilities/email"
|
ConfigType,
|
||||||
import { checkResetPasswordCode } from "../../../utilities/redis"
|
User,
|
||||||
|
Ctx,
|
||||||
|
LoginRequest,
|
||||||
|
SSOUser,
|
||||||
|
PasswordResetRequest,
|
||||||
|
PasswordResetUpdateRequest,
|
||||||
|
} from "@budibase/types"
|
||||||
import env from "../../../environment"
|
import env from "../../../environment"
|
||||||
import sdk from "../../../sdk"
|
|
||||||
import { ConfigType, User } from "@budibase/types"
|
|
||||||
|
|
||||||
const { setCookie, getCookie, clearCookie, hash, platformLogout } = utils
|
import * as authSdk from "../../../sdk/auth"
|
||||||
|
import * as userSdk from "../../../sdk/users"
|
||||||
|
|
||||||
const { Cookie, Header } = constants
|
const { Cookie, Header } = constants
|
||||||
const { passport, ssoCallbackUrl, google, oidc } = auth
|
const { passport, ssoCallbackUrl, google, oidc } = authCore
|
||||||
|
const { setCookie, getCookie, clearCookie } = utilsCore
|
||||||
|
|
||||||
export async function googleCallbackUrl(config?: { callbackURL?: string }) {
|
// LOGIN / LOGOUT
|
||||||
return ssoCallbackUrl(tenancy.getGlobalDB(), config, ConfigType.GOOGLE)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function oidcCallbackUrl(config?: { callbackURL?: string }) {
|
async function passportCallback(
|
||||||
return ssoCallbackUrl(tenancy.getGlobalDB(), config, ConfigType.OIDC)
|
ctx: Ctx,
|
||||||
}
|
user: User,
|
||||||
|
err: any = null,
|
||||||
async function authInternal(ctx: any, user: any, err: any = null, info = null) {
|
info: { message: string } | null = null
|
||||||
|
) {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("Authentication error")
|
console.error("Authentication error")
|
||||||
console.error(err)
|
console.error(err)
|
||||||
console.trace(err)
|
console.trace(err)
|
||||||
return ctx.throw(403, info ? info : "Unauthorized")
|
return ctx.throw(403, info ? info : "Unauthorized")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
console.error("Authentication error - no user provided")
|
console.error("Authentication error - no user provided")
|
||||||
return ctx.throw(403, info ? info : "Unauthorized")
|
return ctx.throw(403, info ? info : "Unauthorized")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const token = await authSdk.loginUser(user)
|
||||||
|
|
||||||
// set a cookie for browser access
|
// set a cookie for browser access
|
||||||
setCookie(ctx, user.token, Cookie.Auth, { sign: false })
|
setCookie(ctx, token, Cookie.Auth, { sign: false })
|
||||||
// set the token in a header as well for APIs
|
// set the token in a header as well for APIs
|
||||||
ctx.set(Header.TOKEN, user.token)
|
ctx.set(Header.TOKEN, token)
|
||||||
// get rid of any app cookies on login
|
// get rid of any app cookies on login
|
||||||
// have to check test because this breaks cypress
|
// have to check test because this breaks cypress
|
||||||
if (!env.isTest()) {
|
if (!env.isTest()) {
|
||||||
|
@ -51,11 +57,18 @@ async function authInternal(ctx: any, user: any, err: any = null, info = null) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authenticate = async (ctx: any, next: any) => {
|
export const login = async (ctx: Ctx<LoginRequest>, next: any) => {
|
||||||
|
const email = ctx.request.body.username
|
||||||
|
|
||||||
|
const user = await userSdk.getUserByEmail(email)
|
||||||
|
if (user && (await userSdk.isPreventSSOPasswords(user))) {
|
||||||
|
ctx.throw(400, "SSO user cannot login using password")
|
||||||
|
}
|
||||||
|
|
||||||
return passport.authenticate(
|
return passport.authenticate(
|
||||||
"local",
|
"local",
|
||||||
async (err: any, user: User, info: any) => {
|
async (err: any, user: User, info: any) => {
|
||||||
await authInternal(ctx, user, err, info)
|
await passportCallback(ctx, user, err, info)
|
||||||
await context.identity.doInUserContext(user, async () => {
|
await context.identity.doInUserContext(user, async () => {
|
||||||
await events.auth.login("local")
|
await events.auth.login("local")
|
||||||
})
|
})
|
||||||
|
@ -64,6 +77,15 @@ export const authenticate = async (ctx: any, next: any) => {
|
||||||
)(ctx, next)
|
)(ctx, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const logout = async (ctx: any) => {
|
||||||
|
if (ctx.user && ctx.user._id) {
|
||||||
|
await authSdk.logout({ ctx, userId: ctx.user._id })
|
||||||
|
}
|
||||||
|
ctx.body = { message: "User logged out." }
|
||||||
|
}
|
||||||
|
|
||||||
|
// INIT
|
||||||
|
|
||||||
export const setInitInfo = (ctx: any) => {
|
export const setInitInfo = (ctx: any) => {
|
||||||
const initInfo = ctx.request.body
|
const initInfo = ctx.request.body
|
||||||
setCookie(ctx, initInfo, Cookie.Init)
|
setCookie(ctx, initInfo, Cookie.Init)
|
||||||
|
@ -79,32 +101,16 @@ export const getInitInfo = (ctx: any) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PASSWORD MANAGEMENT
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset the user password, used as part of a forgotten password flow.
|
* Reset the user password, used as part of a forgotten password flow.
|
||||||
*/
|
*/
|
||||||
export const reset = async (ctx: any) => {
|
export const reset = async (ctx: Ctx<PasswordResetRequest>) => {
|
||||||
const { email } = ctx.request.body
|
const { email } = ctx.request.body
|
||||||
const configured = await isEmailConfigured()
|
|
||||||
if (!configured) {
|
await authSdk.reset(email)
|
||||||
ctx.throw(
|
|
||||||
400,
|
|
||||||
"Please contact your platform administrator, SMTP is not configured."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const user = (await usersCore.getGlobalUserByEmail(email)) as User
|
|
||||||
// only if user exists, don't error though if they don't
|
|
||||||
if (user) {
|
|
||||||
await sendEmail(email, EmailTemplatePurpose.PASSWORD_RECOVERY, {
|
|
||||||
user,
|
|
||||||
subject: "{{ company }} platform password reset",
|
|
||||||
})
|
|
||||||
await events.user.passwordResetRequested(user)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err)
|
|
||||||
// don't throw any kind of error to the user, this might give away something
|
|
||||||
}
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
message: "Please check your email for a reset link.",
|
message: "Please check your email for a reset link.",
|
||||||
}
|
}
|
||||||
|
@ -113,32 +119,21 @@ export const reset = async (ctx: any) => {
|
||||||
/**
|
/**
|
||||||
* Perform the user password update if the provided reset code is valid.
|
* Perform the user password update if the provided reset code is valid.
|
||||||
*/
|
*/
|
||||||
export const resetUpdate = async (ctx: any) => {
|
export const resetUpdate = async (ctx: Ctx<PasswordResetUpdateRequest>) => {
|
||||||
const { resetCode, password } = ctx.request.body
|
const { resetCode, password } = ctx.request.body
|
||||||
try {
|
try {
|
||||||
const { userId } = await checkResetPasswordCode(resetCode)
|
await authSdk.resetUpdate(resetCode, password)
|
||||||
const db = tenancy.getGlobalDB()
|
|
||||||
const user = await db.get(userId)
|
|
||||||
user.password = await hash(password)
|
|
||||||
await db.put(user)
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
message: "password reset successfully.",
|
message: "password reset successfully.",
|
||||||
}
|
}
|
||||||
// remove password from the user before sending events
|
|
||||||
delete user.password
|
|
||||||
await events.user.passwordReset(user)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.warn(err)
|
||||||
|
// hide any details of the error for security
|
||||||
ctx.throw(400, "Cannot reset password.")
|
ctx.throw(400, "Cannot reset password.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const logout = async (ctx: any) => {
|
// DATASOURCE
|
||||||
if (ctx.user && ctx.user._id) {
|
|
||||||
await platformLogout({ ctx, userId: ctx.user._id })
|
|
||||||
}
|
|
||||||
ctx.body = { message: "User logged out." }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const datasourcePreAuth = async (ctx: any, next: any) => {
|
export const datasourcePreAuth = async (ctx: any, next: any) => {
|
||||||
const provider = ctx.params.provider
|
const provider = ctx.params.provider
|
||||||
|
@ -166,6 +161,12 @@ export const datasourceAuth = async (ctx: any, next: any) => {
|
||||||
return handler.postAuth(passport, ctx, next)
|
return handler.postAuth(passport, ctx, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GOOGLE SSO
|
||||||
|
|
||||||
|
export async function googleCallbackUrl(config?: { callbackURL?: string }) {
|
||||||
|
return ssoCallbackUrl(tenancy.getGlobalDB(), config, ConfigType.GOOGLE)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The initial call that google authentication makes to take you to the google login screen.
|
* The initial call that google authentication makes to take you to the google login screen.
|
||||||
* On a successful login, you will be redirected to the googleAuth callback route.
|
* On a successful login, you will be redirected to the googleAuth callback route.
|
||||||
|
@ -181,7 +182,7 @@ export const googlePreAuth = async (ctx: any, next: any) => {
|
||||||
const strategy = await google.strategyFactory(
|
const strategy = await google.strategyFactory(
|
||||||
config,
|
config,
|
||||||
callbackUrl,
|
callbackUrl,
|
||||||
sdk.users.save
|
userSdk.save
|
||||||
)
|
)
|
||||||
|
|
||||||
return passport.authenticate(strategy, {
|
return passport.authenticate(strategy, {
|
||||||
|
@ -191,7 +192,7 @@ export const googlePreAuth = async (ctx: any, next: any) => {
|
||||||
})(ctx, next)
|
})(ctx, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const googleAuth = async (ctx: any, next: any) => {
|
export const googleCallback = async (ctx: any, next: any) => {
|
||||||
const db = tenancy.getGlobalDB()
|
const db = tenancy.getGlobalDB()
|
||||||
|
|
||||||
const config = await dbCore.getScopedConfig(db, {
|
const config = await dbCore.getScopedConfig(db, {
|
||||||
|
@ -202,14 +203,14 @@ export const googleAuth = async (ctx: any, next: any) => {
|
||||||
const strategy = await google.strategyFactory(
|
const strategy = await google.strategyFactory(
|
||||||
config,
|
config,
|
||||||
callbackUrl,
|
callbackUrl,
|
||||||
sdk.users.save
|
userSdk.save
|
||||||
)
|
)
|
||||||
|
|
||||||
return passport.authenticate(
|
return passport.authenticate(
|
||||||
strategy,
|
strategy,
|
||||||
{ successRedirect: "/", failureRedirect: "/error" },
|
{ successRedirect: "/", failureRedirect: "/error" },
|
||||||
async (err: any, user: User, info: any) => {
|
async (err: any, user: SSOUser, info: any) => {
|
||||||
await authInternal(ctx, user, err, info)
|
await passportCallback(ctx, user, err, info)
|
||||||
await context.identity.doInUserContext(user, async () => {
|
await context.identity.doInUserContext(user, async () => {
|
||||||
await events.auth.login("google-internal")
|
await events.auth.login("google-internal")
|
||||||
})
|
})
|
||||||
|
@ -218,6 +219,12 @@ export const googleAuth = async (ctx: any, next: any) => {
|
||||||
)(ctx, next)
|
)(ctx, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OIDC SSO
|
||||||
|
|
||||||
|
export async function oidcCallbackUrl(config?: { callbackURL?: string }) {
|
||||||
|
return ssoCallbackUrl(tenancy.getGlobalDB(), config, ConfigType.OIDC)
|
||||||
|
}
|
||||||
|
|
||||||
export const oidcStrategyFactory = async (ctx: any, configId: any) => {
|
export const oidcStrategyFactory = async (ctx: any, configId: any) => {
|
||||||
const db = tenancy.getGlobalDB()
|
const db = tenancy.getGlobalDB()
|
||||||
const config = await dbCore.getScopedConfig(db, {
|
const config = await dbCore.getScopedConfig(db, {
|
||||||
|
@ -233,7 +240,7 @@ export const oidcStrategyFactory = async (ctx: any, configId: any) => {
|
||||||
chosenConfig,
|
chosenConfig,
|
||||||
callbackUrl
|
callbackUrl
|
||||||
)
|
)
|
||||||
return oidc.strategyFactory(enrichedConfig, sdk.users.save)
|
return oidc.strategyFactory(enrichedConfig, userSdk.save)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -265,15 +272,15 @@ export const oidcPreAuth = async (ctx: any, next: any) => {
|
||||||
})(ctx, next)
|
})(ctx, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const oidcAuth = async (ctx: any, next: any) => {
|
export const oidcCallback = async (ctx: any, next: any) => {
|
||||||
const configId = getCookie(ctx, Cookie.OIDC_CONFIG)
|
const configId = getCookie(ctx, Cookie.OIDC_CONFIG)
|
||||||
const strategy = await oidcStrategyFactory(ctx, configId)
|
const strategy = await oidcStrategyFactory(ctx, configId)
|
||||||
|
|
||||||
return passport.authenticate(
|
return passport.authenticate(
|
||||||
strategy,
|
strategy,
|
||||||
{ successRedirect: "/", failureRedirect: "/error" },
|
{ successRedirect: "/", failureRedirect: "/error" },
|
||||||
async (err: any, user: any, info: any) => {
|
async (err: any, user: SSOUser, info: any) => {
|
||||||
await authInternal(ctx, user, err, info)
|
await passportCallback(ctx, user, err, info)
|
||||||
await context.identity.doInUserContext(user, async () => {
|
await context.identity.doInUserContext(user, async () => {
|
||||||
await events.auth.login("oidc")
|
await events.auth.login("oidc")
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,18 +1,22 @@
|
||||||
import sdk from "../../../sdk"
|
import * as userSdk from "../../../sdk/users"
|
||||||
import {
|
import {
|
||||||
events,
|
|
||||||
featureFlags,
|
featureFlags,
|
||||||
tenancy,
|
tenancy,
|
||||||
constants,
|
constants,
|
||||||
db as dbCore,
|
db as dbCore,
|
||||||
utils,
|
utils,
|
||||||
cache,
|
|
||||||
encryption,
|
encryption,
|
||||||
|
auth as authCore,
|
||||||
} from "@budibase/backend-core"
|
} from "@budibase/backend-core"
|
||||||
import env from "../../../environment"
|
import env from "../../../environment"
|
||||||
import { groups } from "@budibase/pro"
|
import { groups } from "@budibase/pro"
|
||||||
const { hash, platformLogout, getCookie, clearCookie, newid } = utils
|
import {
|
||||||
const { user: userCache } = cache
|
UpdateSelfRequest,
|
||||||
|
UpdateSelfResponse,
|
||||||
|
UpdateSelf,
|
||||||
|
UserCtx,
|
||||||
|
} from "@budibase/types"
|
||||||
|
const { getCookie, clearCookie, newid } = utils
|
||||||
|
|
||||||
function newTestApiKey() {
|
function newTestApiKey() {
|
||||||
return env.ENCRYPTED_TEST_PUBLIC_API_KEY
|
return env.ENCRYPTED_TEST_PUBLIC_API_KEY
|
||||||
|
@ -93,17 +97,6 @@ const addSessionAttributesToUser = (ctx: any) => {
|
||||||
ctx.body.csrfToken = ctx.user.csrfToken
|
ctx.body.csrfToken = ctx.user.csrfToken
|
||||||
}
|
}
|
||||||
|
|
||||||
const sanitiseUserUpdate = (ctx: any) => {
|
|
||||||
const allowed = ["firstName", "lastName", "password", "forceResetPassword"]
|
|
||||||
const resp: { [key: string]: any } = {}
|
|
||||||
for (let [key, value] of Object.entries(ctx.request.body)) {
|
|
||||||
if (allowed.includes(key)) {
|
|
||||||
resp[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSelf(ctx: any) {
|
export async function getSelf(ctx: any) {
|
||||||
if (!ctx.user) {
|
if (!ctx.user) {
|
||||||
ctx.throw(403, "User not logged in")
|
ctx.throw(403, "User not logged in")
|
||||||
|
@ -116,7 +109,7 @@ export async function getSelf(ctx: any) {
|
||||||
checkCurrentApp(ctx)
|
checkCurrentApp(ctx)
|
||||||
|
|
||||||
// get the main body of the user
|
// get the main body of the user
|
||||||
const user = await sdk.users.getUser(userId)
|
const user = await userSdk.getUser(userId)
|
||||||
ctx.body = await groups.enrichUserRolesFromGroups(user)
|
ctx.body = await groups.enrichUserRolesFromGroups(user)
|
||||||
|
|
||||||
// add the feature flags for this tenant
|
// add the feature flags for this tenant
|
||||||
|
@ -126,39 +119,30 @@ export async function getSelf(ctx: any) {
|
||||||
addSessionAttributesToUser(ctx)
|
addSessionAttributesToUser(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSelf(ctx: any) {
|
export async function updateSelf(
|
||||||
const db = tenancy.getGlobalDB()
|
ctx: UserCtx<UpdateSelfRequest, UpdateSelfResponse>
|
||||||
const user = await db.get(ctx.user._id)
|
) {
|
||||||
let passwordChange = false
|
const body = ctx.request.body
|
||||||
|
const update: UpdateSelf = {
|
||||||
|
firstName: body.firstName,
|
||||||
|
lastName: body.lastName,
|
||||||
|
password: body.password,
|
||||||
|
forceResetPassword: body.forceResetPassword,
|
||||||
|
}
|
||||||
|
|
||||||
const userUpdateObj = sanitiseUserUpdate(ctx)
|
const user = await userSdk.updateSelf(ctx.user._id!, update)
|
||||||
if (userUpdateObj.password) {
|
|
||||||
// changing password
|
if (update.password) {
|
||||||
passwordChange = true
|
|
||||||
userUpdateObj.password = await hash(userUpdateObj.password)
|
|
||||||
// Log all other sessions out apart from the current one
|
// Log all other sessions out apart from the current one
|
||||||
await platformLogout({
|
await authCore.platformLogout({
|
||||||
ctx,
|
ctx,
|
||||||
userId: ctx.user._id,
|
userId: ctx.user._id!,
|
||||||
keepActiveSession: true,
|
keepActiveSession: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await db.put({
|
|
||||||
...user,
|
|
||||||
...userUpdateObj,
|
|
||||||
})
|
|
||||||
await userCache.invalidateUser(user._id)
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
_id: response.id,
|
_id: user._id!,
|
||||||
_rev: response.rev,
|
_rev: user._rev!,
|
||||||
}
|
|
||||||
|
|
||||||
// remove the old password from the user before sending events
|
|
||||||
user._rev = response.rev
|
|
||||||
delete user.password
|
|
||||||
await events.user.updated(user)
|
|
||||||
if (passwordChange) {
|
|
||||||
await events.user.passwordUpdated(user)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,15 +2,21 @@ import { checkInviteCode } from "../../../utilities/redis"
|
||||||
import * as userSdk from "../../../sdk/users"
|
import * as userSdk from "../../../sdk/users"
|
||||||
import env from "../../../environment"
|
import env from "../../../environment"
|
||||||
import {
|
import {
|
||||||
|
AcceptUserInviteRequest,
|
||||||
|
AcceptUserInviteResponse,
|
||||||
BulkUserRequest,
|
BulkUserRequest,
|
||||||
BulkUserResponse,
|
BulkUserResponse,
|
||||||
CloudAccount,
|
CloudAccount,
|
||||||
CreateAdminUserRequest,
|
CreateAdminUserRequest,
|
||||||
|
CreateAdminUserResponse,
|
||||||
|
Ctx,
|
||||||
InviteUserRequest,
|
InviteUserRequest,
|
||||||
InviteUsersRequest,
|
InviteUsersRequest,
|
||||||
MigrationType,
|
MigrationType,
|
||||||
|
SaveUserResponse,
|
||||||
SearchUsersRequest,
|
SearchUsersRequest,
|
||||||
User,
|
User,
|
||||||
|
UserCtx,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import {
|
import {
|
||||||
accounts,
|
accounts,
|
||||||
|
@ -25,10 +31,18 @@ import { checkAnyUserExists } from "../../../utilities/users"
|
||||||
|
|
||||||
const MAX_USERS_UPLOAD_LIMIT = 1000
|
const MAX_USERS_UPLOAD_LIMIT = 1000
|
||||||
|
|
||||||
export const save = async (ctx: any) => {
|
export const save = async (ctx: UserCtx<User, SaveUserResponse>) => {
|
||||||
try {
|
try {
|
||||||
const currentUserId = ctx.user._id
|
const currentUserId = ctx.user._id
|
||||||
ctx.body = await userSdk.save(ctx.request.body, { currentUserId })
|
const requestUser = ctx.request.body
|
||||||
|
|
||||||
|
const user = await userSdk.save(requestUser, { currentUserId })
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
_id: user._id!,
|
||||||
|
_rev: user._rev!,
|
||||||
|
email: user.email,
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ctx.throw(err.status || 400, err)
|
ctx.throw(err.status || 400, err)
|
||||||
}
|
}
|
||||||
|
@ -71,9 +85,10 @@ const parseBooleanParam = (param: any) => {
|
||||||
return !(param && param === "false")
|
return !(param && param === "false")
|
||||||
}
|
}
|
||||||
|
|
||||||
export const adminUser = async (ctx: any) => {
|
export const adminUser = async (
|
||||||
const { email, password, tenantId } = ctx.request
|
ctx: Ctx<CreateAdminUserRequest, CreateAdminUserResponse>
|
||||||
.body as CreateAdminUserRequest
|
) => {
|
||||||
|
const { email, password, tenantId } = ctx.request.body
|
||||||
|
|
||||||
if (await platform.tenants.exists(tenantId)) {
|
if (await platform.tenants.exists(tenantId)) {
|
||||||
ctx.throw(403, "Organisation already exists.")
|
ctx.throw(403, "Organisation already exists.")
|
||||||
|
@ -131,7 +146,11 @@ export const adminUser = async (ctx: any) => {
|
||||||
}
|
}
|
||||||
await events.identification.identifyTenantGroup(tenantId, account)
|
await events.identification.identifyTenantGroup(tenantId, account)
|
||||||
|
|
||||||
ctx.body = finalUser
|
ctx.body = {
|
||||||
|
_id: finalUser._id!,
|
||||||
|
_rev: finalUser._rev!,
|
||||||
|
email: finalUser.email,
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ctx.throw(err.status || 400, err)
|
ctx.throw(err.status || 400, err)
|
||||||
}
|
}
|
||||||
|
@ -236,12 +255,14 @@ export const checkInvite = async (ctx: any) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const inviteAccept = async (ctx: any) => {
|
export const inviteAccept = async (
|
||||||
|
ctx: Ctx<AcceptUserInviteRequest, AcceptUserInviteResponse>
|
||||||
|
) => {
|
||||||
const { inviteCode, password, firstName, lastName } = ctx.request.body
|
const { inviteCode, password, firstName, lastName } = ctx.request.body
|
||||||
try {
|
try {
|
||||||
// info is an extension of the user object that was stored by global
|
// info is an extension of the user object that was stored by global
|
||||||
const { email, info }: any = await checkInviteCode(inviteCode)
|
const { email, info }: any = await checkInviteCode(inviteCode)
|
||||||
ctx.body = await tenancy.doInTenant(info.tenantId, async () => {
|
const user = await tenancy.doInTenant(info.tenantId, async () => {
|
||||||
const saved = await userSdk.save({
|
const saved = await userSdk.save({
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
|
@ -254,6 +275,12 @@ export const inviteAccept = async (ctx: any) => {
|
||||||
await events.user.inviteAccepted(user)
|
await events.user.inviteAccepted(user)
|
||||||
return saved
|
return saved
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
_id: user._id,
|
||||||
|
_rev: user._rev,
|
||||||
|
email: user.email,
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.code === errors.codes.USAGE_LIMIT_EXCEEDED) {
|
if (err.code === errors.codes.USAGE_LIMIT_EXCEEDED) {
|
||||||
// explicitly re-throw limit exceeded errors
|
// explicitly re-throw limit exceeded errors
|
||||||
|
|
|
@ -1,21 +1,23 @@
|
||||||
import { Account, AccountMetadata } from "@budibase/types"
|
import { Account, AccountMetadata } from "@budibase/types"
|
||||||
import sdk from "../../../sdk"
|
import * as accounts from "../../../sdk/accounts"
|
||||||
|
|
||||||
export const save = async (ctx: any) => {
|
export const save = async (ctx: any) => {
|
||||||
const account = ctx.request.body as Account
|
const account = ctx.request.body as Account
|
||||||
let metadata: AccountMetadata = {
|
let metadata: AccountMetadata = {
|
||||||
_id: sdk.accounts.formatAccountMetadataId(account.accountId),
|
_id: accounts.metadata.formatAccountMetadataId(account.accountId),
|
||||||
email: account.email,
|
email: account.email,
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata = await sdk.accounts.saveMetadata(metadata)
|
metadata = await accounts.metadata.saveMetadata(metadata)
|
||||||
|
|
||||||
ctx.body = metadata
|
ctx.body = metadata
|
||||||
ctx.status = 200
|
ctx.status = 200
|
||||||
}
|
}
|
||||||
|
|
||||||
export const destroy = async (ctx: any) => {
|
export const destroy = async (ctx: any) => {
|
||||||
const accountId = sdk.accounts.formatAccountMetadataId(ctx.params.accountId)
|
const accountId = accounts.metadata.formatAccountMetadataId(
|
||||||
await sdk.accounts.destroyMetadata(accountId)
|
ctx.params.accountId
|
||||||
|
)
|
||||||
|
await accounts.metadata.destroyMetadata(accountId)
|
||||||
ctx.status = 204
|
ctx.status = 204
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,7 +33,7 @@ router
|
||||||
.post(
|
.post(
|
||||||
"/api/global/auth/:tenantId/login",
|
"/api/global/auth/:tenantId/login",
|
||||||
buildAuthValidation(),
|
buildAuthValidation(),
|
||||||
authController.authenticate
|
authController.login
|
||||||
)
|
)
|
||||||
.post("/api/global/auth/logout", authController.logout)
|
.post("/api/global/auth/logout", authController.logout)
|
||||||
.post(
|
.post(
|
||||||
|
@ -68,21 +68,24 @@ router
|
||||||
|
|
||||||
// GOOGLE - MULTI TENANT
|
// GOOGLE - MULTI TENANT
|
||||||
.get("/api/global/auth/:tenantId/google", authController.googlePreAuth)
|
.get("/api/global/auth/:tenantId/google", authController.googlePreAuth)
|
||||||
.get("/api/global/auth/:tenantId/google/callback", authController.googleAuth)
|
.get(
|
||||||
|
"/api/global/auth/:tenantId/google/callback",
|
||||||
|
authController.googleCallback
|
||||||
|
)
|
||||||
|
|
||||||
// GOOGLE - SINGLE TENANT - DEPRECATED
|
// GOOGLE - SINGLE TENANT - DEPRECATED
|
||||||
.get("/api/global/auth/google/callback", authController.googleAuth)
|
.get("/api/global/auth/google/callback", authController.googleCallback)
|
||||||
.get("/api/admin/auth/google/callback", authController.googleAuth)
|
.get("/api/admin/auth/google/callback", authController.googleCallback)
|
||||||
|
|
||||||
// OIDC - MULTI TENANT
|
// OIDC - MULTI TENANT
|
||||||
.get(
|
.get(
|
||||||
"/api/global/auth/:tenantId/oidc/configs/:configId",
|
"/api/global/auth/:tenantId/oidc/configs/:configId",
|
||||||
authController.oidcPreAuth
|
authController.oidcPreAuth
|
||||||
)
|
)
|
||||||
.get("/api/global/auth/:tenantId/oidc/callback", authController.oidcAuth)
|
.get("/api/global/auth/:tenantId/oidc/callback", authController.oidcCallback)
|
||||||
|
|
||||||
// OIDC - SINGLE TENANT - DEPRECATED
|
// OIDC - SINGLE TENANT - DEPRECATED
|
||||||
.get("/api/global/auth/oidc/callback", authController.oidcAuth)
|
.get("/api/global/auth/oidc/callback", authController.oidcCallback)
|
||||||
.get("/api/admin/auth/oidc/callback", authController.oidcAuth)
|
.get("/api/admin/auth/oidc/callback", authController.oidcCallback)
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|
|
@ -1,13 +1,27 @@
|
||||||
jest.mock("nodemailer")
|
import { CloudAccount, SSOUser, User } from "@budibase/types"
|
||||||
import { TestConfiguration, mocks } from "../../../../tests"
|
|
||||||
const sendMailMock = mocks.email.mock()
|
|
||||||
import { events, tenancy } from "@budibase/backend-core"
|
|
||||||
import { structures } from "@budibase/backend-core/tests"
|
|
||||||
|
|
||||||
const expectSetAuthCookie = (res: any) => {
|
jest.mock("nodemailer")
|
||||||
expect(
|
import {
|
||||||
res.get("Set-Cookie").find((c: string) => c.startsWith("budibase:auth"))
|
TestConfiguration,
|
||||||
).toBeDefined()
|
mocks,
|
||||||
|
structures,
|
||||||
|
generator,
|
||||||
|
} from "../../../../tests"
|
||||||
|
const sendMailMock = mocks.email.mock()
|
||||||
|
import { events, constants } from "@budibase/backend-core"
|
||||||
|
import { Response } from "superagent"
|
||||||
|
|
||||||
|
import * as userSdk from "../../../../sdk/users"
|
||||||
|
|
||||||
|
function getAuthCookie(response: Response) {
|
||||||
|
return response.headers["set-cookie"]
|
||||||
|
.find((s: string) => s.startsWith(`${constants.Cookie.Auth}=`))
|
||||||
|
.split("=")[1]
|
||||||
|
.split(";")[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectSetAuthCookie = (response: Response) => {
|
||||||
|
expect(getAuthCookie(response).length > 1).toBe(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("/api/global/auth", () => {
|
describe("/api/global/auth", () => {
|
||||||
|
@ -25,60 +39,247 @@ describe("/api/global/auth", () => {
|
||||||
jest.clearAllMocks()
|
jest.clearAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function createSSOUser() {
|
||||||
|
return config.doInTenant(async () => {
|
||||||
|
return userSdk.save(structures.users.ssoUser(), {
|
||||||
|
requirePassword: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
describe("password", () => {
|
describe("password", () => {
|
||||||
describe("POST /api/global/auth/:tenantId/login", () => {
|
describe("POST /api/global/auth/:tenantId/login", () => {
|
||||||
it("should login", () => {})
|
it("logs in with correct credentials", async () => {
|
||||||
|
const tenantId = config.tenantId!
|
||||||
|
const email = config.user?.email!
|
||||||
|
const password = config.userPassword
|
||||||
|
|
||||||
|
const response = await config.api.auth.login(tenantId, email, password)
|
||||||
|
|
||||||
|
expectSetAuthCookie(response)
|
||||||
|
expect(events.auth.login).toBeCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return 403 with incorrect credentials", async () => {
|
||||||
|
const tenantId = config.tenantId!
|
||||||
|
const email = config.user?.email!
|
||||||
|
const password = "incorrect"
|
||||||
|
|
||||||
|
const response = await config.api.auth.login(
|
||||||
|
tenantId,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
message: "Invalid credentials",
|
||||||
|
status: 403,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return 403 when user doesn't exist", async () => {
|
||||||
|
const tenantId = config.tenantId!
|
||||||
|
const email = "invaliduser@test.com"
|
||||||
|
const password = "password"
|
||||||
|
|
||||||
|
const response = await config.api.auth.login(
|
||||||
|
tenantId,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
message: "Invalid credentials",
|
||||||
|
status: 403,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("sso user", () => {
|
||||||
|
let user: User
|
||||||
|
|
||||||
|
async function testSSOUser() {
|
||||||
|
const tenantId = user.tenantId!
|
||||||
|
const email = user.email
|
||||||
|
const password = "test"
|
||||||
|
|
||||||
|
const response = await config.api.auth.login(
|
||||||
|
tenantId,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
message: "SSO user cannot login using password",
|
||||||
|
status: 400,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("budibase sso user", () => {
|
||||||
|
it("should prevent user from logging in", async () => {
|
||||||
|
user = await createSSOUser()
|
||||||
|
await testSSOUser()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("root account sso user", () => {
|
||||||
|
it("should prevent user from logging in", async () => {
|
||||||
|
user = await config.createUser()
|
||||||
|
const account = structures.accounts.ssoAccount() as CloudAccount
|
||||||
|
mocks.accounts.getAccount.mockReturnValueOnce(
|
||||||
|
Promise.resolve(account)
|
||||||
|
)
|
||||||
|
|
||||||
|
await testSSOUser()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("POST /api/global/auth/logout", () => {
|
describe("POST /api/global/auth/logout", () => {
|
||||||
it("should logout", async () => {
|
it("should logout", async () => {
|
||||||
await config.api.auth.logout()
|
const response = await config.api.auth.logout()
|
||||||
expect(events.auth.logout).toBeCalledTimes(1)
|
expect(events.auth.logout).toBeCalledTimes(1)
|
||||||
|
|
||||||
// TODO: Verify sessions deleted
|
const authCookie = getAuthCookie(response)
|
||||||
|
expect(authCookie).toBe("")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("POST /api/global/auth/:tenantId/reset", () => {
|
describe("POST /api/global/auth/:tenantId/reset", () => {
|
||||||
it("should generate password reset email", async () => {
|
it("should generate password reset email", async () => {
|
||||||
await tenancy.doInTenant(config.tenant1User!.tenantId, async () => {
|
const user = await config.createUser()
|
||||||
const userEmail = structures.email()
|
|
||||||
const { res, code } = await config.api.auth.requestPasswordReset(
|
const { res, code } = await config.api.auth.requestPasswordReset(
|
||||||
|
sendMailMock,
|
||||||
|
user.email
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(res.body).toEqual({
|
||||||
|
message: "Please check your email for a reset link.",
|
||||||
|
})
|
||||||
|
expect(sendMailMock).toHaveBeenCalled()
|
||||||
|
expect(code).toBeDefined()
|
||||||
|
expect(events.user.passwordResetRequested).toBeCalledTimes(1)
|
||||||
|
expect(events.user.passwordResetRequested).toBeCalledWith(user)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("sso user", () => {
|
||||||
|
let user: User
|
||||||
|
|
||||||
|
async function testSSOUser() {
|
||||||
|
const { res } = await config.api.auth.requestPasswordReset(
|
||||||
sendMailMock,
|
sendMailMock,
|
||||||
userEmail
|
user.email,
|
||||||
|
{ status: 400 }
|
||||||
)
|
)
|
||||||
const user = await config.getUser(userEmail)
|
|
||||||
|
|
||||||
expect(res.body).toEqual({
|
expect(res.body).toEqual({
|
||||||
message: "Please check your email for a reset link.",
|
message: "SSO user cannot reset password",
|
||||||
|
status: 400,
|
||||||
|
error: {
|
||||||
|
code: "http",
|
||||||
|
type: "generic",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
expect(sendMailMock).toHaveBeenCalled()
|
expect(sendMailMock).not.toHaveBeenCalled()
|
||||||
|
}
|
||||||
|
|
||||||
expect(code).toBeDefined()
|
describe("budibase sso user", () => {
|
||||||
expect(events.user.passwordResetRequested).toBeCalledTimes(1)
|
it("should prevent user from generating password reset email", async () => {
|
||||||
expect(events.user.passwordResetRequested).toBeCalledWith(user)
|
user = await createSSOUser()
|
||||||
|
await testSSOUser()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("root account sso user", () => {
|
||||||
|
it("should prevent user from generating password reset email", async () => {
|
||||||
|
user = await config.createUser(structures.users.user())
|
||||||
|
const account = structures.accounts.ssoAccount() as CloudAccount
|
||||||
|
mocks.accounts.getAccount.mockReturnValueOnce(
|
||||||
|
Promise.resolve(account)
|
||||||
|
)
|
||||||
|
|
||||||
|
await testSSOUser()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("POST /api/global/auth/:tenantId/reset/update", () => {
|
describe("POST /api/global/auth/:tenantId/reset/update", () => {
|
||||||
it("should reset password", async () => {
|
it("should reset password", async () => {
|
||||||
await tenancy.doInTenant(config.tenant1User!.tenantId, async () => {
|
let user = await config.createUser()
|
||||||
const userEmail = structures.email()
|
const { code } = await config.api.auth.requestPasswordReset(
|
||||||
const { code } = await config.api.auth.requestPasswordReset(
|
sendMailMock,
|
||||||
sendMailMock,
|
user.email
|
||||||
userEmail
|
)
|
||||||
|
delete user.password
|
||||||
|
|
||||||
|
const newPassword = "newpassword"
|
||||||
|
const res = await config.api.auth.updatePassword(code!, newPassword)
|
||||||
|
|
||||||
|
user = await config.getUser(user.email)
|
||||||
|
delete user.password
|
||||||
|
|
||||||
|
expect(res.body).toEqual({ message: "password reset successfully." })
|
||||||
|
expect(events.user.passwordReset).toBeCalledTimes(1)
|
||||||
|
expect(events.user.passwordReset).toBeCalledWith(user)
|
||||||
|
|
||||||
|
// login using new password
|
||||||
|
await config.api.auth.login(user.tenantId, user.email, newPassword)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("sso user", () => {
|
||||||
|
let user: User | SSOUser
|
||||||
|
|
||||||
|
async function testSSOUser(code: string) {
|
||||||
|
const res = await config.api.auth.updatePassword(
|
||||||
|
code!,
|
||||||
|
generator.string(),
|
||||||
|
{ status: 400 }
|
||||||
)
|
)
|
||||||
const user = await config.getUser(userEmail)
|
|
||||||
delete user.password
|
|
||||||
|
|
||||||
const res = await config.api.auth.updatePassword(code)
|
expect(res.body).toEqual({
|
||||||
|
message: "Cannot reset password.",
|
||||||
|
status: 400,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
expect(res.body).toEqual({ message: "password reset successfully." })
|
describe("budibase sso user", () => {
|
||||||
expect(events.user.passwordReset).toBeCalledTimes(1)
|
it("should prevent user from generating password reset email", async () => {
|
||||||
expect(events.user.passwordReset).toBeCalledWith(user)
|
user = await config.createUser()
|
||||||
|
const { code } = await config.api.auth.requestPasswordReset(
|
||||||
|
sendMailMock,
|
||||||
|
user.email
|
||||||
|
)
|
||||||
|
|
||||||
|
// convert to sso now that password reset has been requested
|
||||||
|
const ssoUser = user as SSOUser
|
||||||
|
ssoUser.providerType = structures.sso.providerType()
|
||||||
|
delete ssoUser.password
|
||||||
|
await config.doInTenant(() => userSdk.save(ssoUser))
|
||||||
|
|
||||||
|
await testSSOUser(code!)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("root account sso user", () => {
|
||||||
|
it("should prevent user from generating password reset email", async () => {
|
||||||
|
user = await config.createUser()
|
||||||
|
const { code } = await config.api.auth.requestPasswordReset(
|
||||||
|
sendMailMock,
|
||||||
|
user.email
|
||||||
|
)
|
||||||
|
|
||||||
|
// convert to account owner now that password has been requested
|
||||||
|
const account = structures.accounts.ssoAccount() as CloudAccount
|
||||||
|
mocks.accounts.getAccount.mockReturnValueOnce(
|
||||||
|
Promise.resolve(account)
|
||||||
|
)
|
||||||
|
|
||||||
|
await testSSOUser(code!)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
// TODO: Login using new password
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -153,7 +354,7 @@ describe("/api/global/auth", () => {
|
||||||
const location: string = res.get("location")
|
const location: string = res.get("location")
|
||||||
expect(
|
expect(
|
||||||
location.startsWith(
|
location.startsWith(
|
||||||
"http://localhost/auth?response_type=code&client_id=clientId&redirect_uri=http%3A%2F%2Flocalhost%3A10000%2Fapi%2Fglobal%2Fauth%2Fdefault%2Foidc%2Fcallback&scope=openid%20profile%20email%20offline_access"
|
`http://localhost/auth?response_type=code&client_id=clientId&redirect_uri=http%3A%2F%2Flocalhost%3A10000%2Fapi%2Fglobal%2Fauth%2F${config.tenantId}%2Foidc%2Fcallback&scope=openid%20profile%20email%20offline_access`
|
||||||
)
|
)
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
|
@ -30,7 +30,7 @@ describe("/api/global/self", () => {
|
||||||
user.dayPassRecordedAt = mocks.date.MOCK_DATE.toISOString()
|
user.dayPassRecordedAt = mocks.date.MOCK_DATE.toISOString()
|
||||||
expect(res.body._id).toBe(user._id)
|
expect(res.body._id).toBe(user._id)
|
||||||
expect(events.user.updated).toBeCalledTimes(1)
|
expect(events.user.updated).toBeCalledTimes(1)
|
||||||
expect(events.user.updated).toBeCalledWith(user)
|
expect(events.user.updated).toBeCalledWith(dbUser)
|
||||||
expect(events.user.passwordUpdated).not.toBeCalled()
|
expect(events.user.passwordUpdated).not.toBeCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -44,12 +44,11 @@ describe("/api/global/self", () => {
|
||||||
const dbUser = await config.getUser(user.email)
|
const dbUser = await config.getUser(user.email)
|
||||||
user._rev = dbUser._rev
|
user._rev = dbUser._rev
|
||||||
user.dayPassRecordedAt = mocks.date.MOCK_DATE.toISOString()
|
user.dayPassRecordedAt = mocks.date.MOCK_DATE.toISOString()
|
||||||
delete user.password
|
|
||||||
expect(res.body._id).toBe(user._id)
|
expect(res.body._id).toBe(user._id)
|
||||||
expect(events.user.updated).toBeCalledTimes(1)
|
expect(events.user.updated).toBeCalledTimes(1)
|
||||||
expect(events.user.updated).toBeCalledWith(user)
|
expect(events.user.updated).toBeCalledWith(dbUser)
|
||||||
expect(events.user.passwordUpdated).toBeCalledTimes(1)
|
expect(events.user.passwordUpdated).toBeCalledTimes(1)
|
||||||
expect(events.user.passwordUpdated).toBeCalledWith(user)
|
expect(events.user.passwordUpdated).toBeCalledWith(dbUser)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import sdk from "../../../../sdk"
|
import * as accounts from "../../../../sdk/accounts"
|
||||||
import { TestConfiguration, structures } from "../../../../tests"
|
import { TestConfiguration, structures } from "../../../../tests"
|
||||||
import { v4 as uuid } from "uuid"
|
import { v4 as uuid } from "uuid"
|
||||||
|
|
||||||
|
@ -24,8 +24,8 @@ describe("accounts", () => {
|
||||||
|
|
||||||
const response = await config.api.accounts.saveMetadata(account)
|
const response = await config.api.accounts.saveMetadata(account)
|
||||||
|
|
||||||
const id = sdk.accounts.formatAccountMetadataId(account.accountId)
|
const id = accounts.metadata.formatAccountMetadataId(account.accountId)
|
||||||
const metadata = await sdk.accounts.getMetadata(id)
|
const metadata = await accounts.metadata.getMetadata(id)
|
||||||
expect(response).toStrictEqual(metadata)
|
expect(response).toStrictEqual(metadata)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -37,7 +37,7 @@ describe("accounts", () => {
|
||||||
|
|
||||||
await config.api.accounts.destroyMetadata(account.accountId)
|
await config.api.accounts.destroyMetadata(account.accountId)
|
||||||
|
|
||||||
const deleted = await sdk.accounts.getMetadata(account.accountId)
|
const deleted = await accounts.metadata.getMetadata(account.accountId)
|
||||||
expect(deleted).toBe(undefined)
|
expect(deleted).toBe(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -26,6 +26,8 @@ function parseIntSafe(number: any) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selfHosted = !!parseInt(process.env.SELF_HOSTED || "")
|
||||||
|
|
||||||
const environment = {
|
const environment = {
|
||||||
// auth
|
// auth
|
||||||
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
|
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
|
||||||
|
@ -49,7 +51,7 @@ const environment = {
|
||||||
CLUSTER_PORT: process.env.CLUSTER_PORT,
|
CLUSTER_PORT: process.env.CLUSTER_PORT,
|
||||||
// flags
|
// flags
|
||||||
NODE_ENV: process.env.NODE_ENV,
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED || ""),
|
SELF_HOSTED: selfHosted,
|
||||||
LOG_LEVEL: process.env.LOG_LEVEL,
|
LOG_LEVEL: process.env.LOG_LEVEL,
|
||||||
MULTI_TENANCY: process.env.MULTI_TENANCY,
|
MULTI_TENANCY: process.env.MULTI_TENANCY,
|
||||||
DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL,
|
DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL,
|
||||||
|
@ -65,6 +67,18 @@ const environment = {
|
||||||
CHECKLIST_CACHE_TTL: parseIntSafe(process.env.CHECKLIST_CACHE_TTL) || 3600,
|
CHECKLIST_CACHE_TTL: parseIntSafe(process.env.CHECKLIST_CACHE_TTL) || 3600,
|
||||||
SESSION_UPDATE_PERIOD: process.env.SESSION_UPDATE_PERIOD,
|
SESSION_UPDATE_PERIOD: process.env.SESSION_UPDATE_PERIOD,
|
||||||
ENCRYPTED_TEST_PUBLIC_API_KEY: process.env.ENCRYPTED_TEST_PUBLIC_API_KEY,
|
ENCRYPTED_TEST_PUBLIC_API_KEY: process.env.ENCRYPTED_TEST_PUBLIC_API_KEY,
|
||||||
|
/**
|
||||||
|
* Mock the email service in use - links to ethereal hosted emails are logged instead.
|
||||||
|
*/
|
||||||
|
ENABLE_EMAIL_TEST_MODE: process.env.ENABLE_EMAIL_TEST_MODE,
|
||||||
|
/**
|
||||||
|
* Enable to allow an admin user to login using a password.
|
||||||
|
* This can be useful to prevent lockout when configuring SSO.
|
||||||
|
* However, this should be turned OFF by default for security purposes.
|
||||||
|
*/
|
||||||
|
ENABLE_SSO_MAINTENANCE_MODE: selfHosted
|
||||||
|
? process.env.ENABLE_SSO_MAINTENANCE_MODE
|
||||||
|
: false,
|
||||||
_set(key: any, value: any) {
|
_set(key: any, value: any) {
|
||||||
process.env[key] = value
|
process.env[key] = value
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|
|
@ -25,6 +25,12 @@ const koaSession = require("koa-session")
|
||||||
const logger = require("koa-pino-logger")
|
const logger = require("koa-pino-logger")
|
||||||
import destroyable from "server-destroy"
|
import destroyable from "server-destroy"
|
||||||
|
|
||||||
|
if (env.ENABLE_SSO_MAINTENANCE_MODE) {
|
||||||
|
console.warn(
|
||||||
|
"Warning: ENABLE_SSO_MAINTENANCE_MODE is set. It is recommended this flag is disabled if maintenance is not in progress"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// this will setup http and https proxies form env variables
|
// this will setup http and https proxies form env variables
|
||||||
bootstrap()
|
bootstrap()
|
||||||
|
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
export * from "./accounts"
|
export * as metadata from "./metadata"
|
||||||
|
export { accounts as api } from "@budibase/backend-core"
|
||||||
|
|
|
@ -2,7 +2,6 @@ import { AccountMetadata } from "@budibase/types"
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
StaticDatabases,
|
StaticDatabases,
|
||||||
HTTPError,
|
|
||||||
DocumentType,
|
DocumentType,
|
||||||
SEPARATOR,
|
SEPARATOR,
|
||||||
} from "@budibase/backend-core"
|
} from "@budibase/backend-core"
|
|
@ -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)
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./auth"
|
|
@ -84,6 +84,10 @@ export const handleSaveEvents = async (
|
||||||
) {
|
) {
|
||||||
await events.user.passwordForceReset(user)
|
await events.user.passwordForceReset(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user.password !== existingUser.password) {
|
||||||
|
await events.user.passwordUpdated(user)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await events.user.created(user)
|
await events.user.created(user)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
export * from "./users"
|
export * from "./users"
|
||||||
|
export { users as core } from "@budibase/backend-core"
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -6,12 +6,11 @@ import {
|
||||||
cache,
|
cache,
|
||||||
constants,
|
constants,
|
||||||
db as dbUtils,
|
db as dbUtils,
|
||||||
deprovisioning,
|
|
||||||
events,
|
events,
|
||||||
HTTPError,
|
HTTPError,
|
||||||
migrations,
|
|
||||||
sessions,
|
sessions,
|
||||||
tenancy,
|
tenancy,
|
||||||
|
platform,
|
||||||
users as usersCore,
|
users as usersCore,
|
||||||
utils,
|
utils,
|
||||||
ViewName,
|
ViewName,
|
||||||
|
@ -21,21 +20,22 @@ import {
|
||||||
AllDocsResponse,
|
AllDocsResponse,
|
||||||
BulkUserResponse,
|
BulkUserResponse,
|
||||||
CloudAccount,
|
CloudAccount,
|
||||||
CreateUserResponse,
|
|
||||||
InviteUsersRequest,
|
InviteUsersRequest,
|
||||||
InviteUsersResponse,
|
InviteUsersResponse,
|
||||||
MigrationType,
|
isSSOAccount,
|
||||||
|
isSSOUser,
|
||||||
PlatformUser,
|
PlatformUser,
|
||||||
PlatformUserByEmail,
|
PlatformUserByEmail,
|
||||||
RowResponse,
|
RowResponse,
|
||||||
SearchUsersRequest,
|
SearchUsersRequest,
|
||||||
|
UpdateSelf,
|
||||||
User,
|
User,
|
||||||
ThirdPartyUser,
|
SaveUserOpts,
|
||||||
isUser,
|
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { sendEmail } from "../../utilities/email"
|
import { sendEmail } from "../../utilities/email"
|
||||||
import { EmailTemplatePurpose } from "../../constants"
|
import { EmailTemplatePurpose } from "../../constants"
|
||||||
import { groups as groupsSdk } from "@budibase/pro"
|
import { groups as groupsSdk } from "@budibase/pro"
|
||||||
|
import * as accountSdk from "../accounts"
|
||||||
|
|
||||||
const PAGE_LIMIT = 8
|
const PAGE_LIMIT = 8
|
||||||
|
|
||||||
|
@ -94,26 +94,23 @@ export const paginatedUsers = async ({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUserByEmail(email: string) {
|
||||||
|
return usersCore.getGlobalUserByEmail(email)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets a user by ID from the global database, based on the current tenancy.
|
* Gets a user by ID from the global database, based on the current tenancy.
|
||||||
*/
|
*/
|
||||||
export const getUser = async (userId: string) => {
|
export const getUser = async (userId: string) => {
|
||||||
const db = tenancy.getGlobalDB()
|
const user = await usersCore.getById(userId)
|
||||||
let user = await db.get(userId)
|
|
||||||
if (user) {
|
if (user) {
|
||||||
delete user.password
|
delete user.password
|
||||||
}
|
}
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SaveUserOpts {
|
|
||||||
hashPassword?: boolean
|
|
||||||
requirePassword?: boolean
|
|
||||||
currentUserId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildUser = async (
|
const buildUser = async (
|
||||||
user: User | ThirdPartyUser,
|
user: User,
|
||||||
opts: SaveUserOpts = {
|
opts: SaveUserOpts = {
|
||||||
hashPassword: true,
|
hashPassword: true,
|
||||||
requirePassword: true,
|
requirePassword: true,
|
||||||
|
@ -121,11 +118,13 @@ const buildUser = async (
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
dbUser?: any
|
dbUser?: any
|
||||||
): Promise<User> => {
|
): Promise<User> => {
|
||||||
let fullUser = user as User
|
let { password, _id } = user
|
||||||
let { password, _id } = fullUser
|
|
||||||
|
|
||||||
let hashedPassword
|
let hashedPassword
|
||||||
if (password) {
|
if (password) {
|
||||||
|
if (await isPreventSSOPasswords(user)) {
|
||||||
|
throw new HTTPError("SSO user cannot set password", 400)
|
||||||
|
}
|
||||||
hashedPassword = opts.hashPassword ? await utils.hash(password) : password
|
hashedPassword = opts.hashPassword ? await utils.hash(password) : password
|
||||||
} else if (dbUser) {
|
} else if (dbUser) {
|
||||||
hashedPassword = dbUser.password
|
hashedPassword = dbUser.password
|
||||||
|
@ -135,10 +134,10 @@ const buildUser = async (
|
||||||
|
|
||||||
_id = _id || dbUtils.generateGlobalUserID()
|
_id = _id || dbUtils.generateGlobalUserID()
|
||||||
|
|
||||||
fullUser = {
|
const fullUser = {
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
...dbUser,
|
...dbUser,
|
||||||
...fullUser,
|
...user,
|
||||||
_id,
|
_id,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
tenantId,
|
tenantId,
|
||||||
|
@ -189,10 +188,36 @@ const validateUniqueUser = async (email: string, tenantId: string) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function isPreventSSOPasswords(user: User) {
|
||||||
|
// when in maintenance mode we allow sso users with the admin role
|
||||||
|
// to perform any password action - this prevents lockout
|
||||||
|
if (env.ENABLE_SSO_MAINTENANCE_MODE && user.admin?.global) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check local sso
|
||||||
|
if (isSSOUser(user)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check account sso
|
||||||
|
const account = await accountSdk.api.getAccount(user.email)
|
||||||
|
return !!(account && isSSOAccount(account))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSelf(id: string, data: UpdateSelf) {
|
||||||
|
let user = await getUser(id)
|
||||||
|
user = {
|
||||||
|
...user,
|
||||||
|
...data,
|
||||||
|
}
|
||||||
|
return save(user)
|
||||||
|
}
|
||||||
|
|
||||||
export const save = async (
|
export const save = async (
|
||||||
user: User | ThirdPartyUser,
|
user: User,
|
||||||
opts: SaveUserOpts = {}
|
opts: SaveUserOpts = {}
|
||||||
): Promise<CreateUserResponse> => {
|
): Promise<User> => {
|
||||||
// default booleans to true
|
// default booleans to true
|
||||||
if (opts.hashPassword == null) {
|
if (opts.hashPassword == null) {
|
||||||
opts.hashPassword = true
|
opts.hashPassword = true
|
||||||
|
@ -264,7 +289,7 @@ export const save = async (
|
||||||
builtUser._rev = response.rev
|
builtUser._rev = response.rev
|
||||||
|
|
||||||
await eventHelpers.handleSaveEvents(builtUser, dbUser)
|
await eventHelpers.handleSaveEvents(builtUser, dbUser)
|
||||||
await addTenant(tenantId, _id, email)
|
await platform.users.addUser(tenantId, builtUser._id!, builtUser.email)
|
||||||
await cache.user.invalidateUser(response.id)
|
await cache.user.invalidateUser(response.id)
|
||||||
|
|
||||||
// let server know to sync user
|
// let server know to sync user
|
||||||
|
@ -272,11 +297,8 @@ export const save = async (
|
||||||
|
|
||||||
await Promise.all(groupPromises)
|
await Promise.all(groupPromises)
|
||||||
|
|
||||||
return {
|
// finally returned the saved user from the db
|
||||||
_id: response.id,
|
return db.get(builtUser._id!)
|
||||||
_rev: response.rev,
|
|
||||||
email,
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.status === 409) {
|
if (err.status === 409) {
|
||||||
throw "User exists already"
|
throw "User exists already"
|
||||||
|
@ -286,21 +308,6 @@ export const save = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addTenant = async (
|
|
||||||
tenantId: string,
|
|
||||||
_id: string,
|
|
||||||
email: string
|
|
||||||
) => {
|
|
||||||
if (env.MULTI_TENANCY) {
|
|
||||||
const afterCreateTenant = () =>
|
|
||||||
migrations.backPopulateMigrations({
|
|
||||||
type: MigrationType.GLOBAL,
|
|
||||||
tenantId,
|
|
||||||
})
|
|
||||||
await tenancy.tryAddTenant(tenantId, _id, email, afterCreateTenant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getExistingTenantUsers = async (emails: string[]): Promise<User[]> => {
|
const getExistingTenantUsers = async (emails: string[]): Promise<User[]> => {
|
||||||
const lcEmails = emails.map(email => email.toLowerCase())
|
const lcEmails = emails.map(email => email.toLowerCase())
|
||||||
const params = {
|
const params = {
|
||||||
|
@ -432,7 +439,7 @@ export const bulkCreate = async (
|
||||||
for (const user of usersToBulkSave) {
|
for (const user of usersToBulkSave) {
|
||||||
// TODO: Refactor to bulk insert users into the info db
|
// TODO: Refactor to bulk insert users into the info db
|
||||||
// instead of relying on looping tenant creation
|
// instead of relying on looping tenant creation
|
||||||
await addTenant(tenantId, user._id, user.email)
|
await platform.users.addUser(tenantId, user._id, user.email)
|
||||||
await eventHelpers.handleSaveEvents(user, undefined)
|
await eventHelpers.handleSaveEvents(user, undefined)
|
||||||
await apps.syncUserInApps(user._id)
|
await apps.syncUserInApps(user._id)
|
||||||
}
|
}
|
||||||
|
@ -566,7 +573,7 @@ export const destroy = async (id: string, currentUser: any) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await deprovisioning.removeUserFromInfoDB(dbUser)
|
await platform.users.removeUser(dbUser)
|
||||||
|
|
||||||
await db.remove(userId, dbUser._rev)
|
await db.remove(userId, dbUser._rev)
|
||||||
|
|
||||||
|
@ -579,7 +586,7 @@ export const destroy = async (id: string, currentUser: any) => {
|
||||||
|
|
||||||
const bulkDeleteProcessing = async (dbUser: User) => {
|
const bulkDeleteProcessing = async (dbUser: User) => {
|
||||||
const userId = dbUser._id as string
|
const userId = dbUser._id as string
|
||||||
await deprovisioning.removeUserFromInfoDB(dbUser)
|
await platform.users.removeUser(dbUser)
|
||||||
await eventHelpers.handleDeleteEvents(dbUser)
|
await eventHelpers.handleDeleteEvents(dbUser)
|
||||||
await cache.user.invalidateUser(userId)
|
await cache.user.invalidateUser(userId)
|
||||||
await sessions.invalidateSessions(userId, { reason: "bulk-deletion" })
|
await sessions.invalidateSessions(userId, { reason: "bulk-deletion" })
|
||||||
|
|
|
@ -22,7 +22,7 @@ import {
|
||||||
env as coreEnv,
|
env as coreEnv,
|
||||||
} from "@budibase/backend-core"
|
} from "@budibase/backend-core"
|
||||||
import structures, { CSRF_TOKEN } from "./structures"
|
import structures, { CSRF_TOKEN } from "./structures"
|
||||||
import { CreateUserResponse, User, AuthToken } from "@budibase/types"
|
import { SaveUserResponse, User, AuthToken } from "@budibase/types"
|
||||||
import API from "./api"
|
import API from "./api"
|
||||||
|
|
||||||
class TestConfiguration {
|
class TestConfiguration {
|
||||||
|
@ -226,7 +226,7 @@ class TestConfiguration {
|
||||||
user = structures.users.user()
|
user = structures.users.user()
|
||||||
}
|
}
|
||||||
const response = await this._req(user, null, controllers.users.save)
|
const response = await this._req(user, null, controllers.users.save)
|
||||||
const body = response as CreateUserResponse
|
const body = response as SaveUserResponse
|
||||||
return this.getUser(body.email)
|
return this.getUser(body.email)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,21 +1,39 @@
|
||||||
import structures from "../structures"
|
|
||||||
import TestConfiguration from "../TestConfiguration"
|
import TestConfiguration from "../TestConfiguration"
|
||||||
import { TestAPI } from "./base"
|
import { TestAPI, TestAPIOpts } from "./base"
|
||||||
|
|
||||||
export class AuthAPI extends TestAPI {
|
export class AuthAPI extends TestAPI {
|
||||||
constructor(config: TestConfiguration) {
|
constructor(config: TestConfiguration) {
|
||||||
super(config)
|
super(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePassword = (code: string) => {
|
updatePassword = (
|
||||||
|
resetCode: string,
|
||||||
|
password: string,
|
||||||
|
opts?: TestAPIOpts
|
||||||
|
) => {
|
||||||
return this.request
|
return this.request
|
||||||
.post(`/api/global/auth/${this.config.getTenantId()}/reset/update`)
|
.post(`/api/global/auth/${this.config.getTenantId()}/reset/update`)
|
||||||
.send({
|
.send({
|
||||||
password: "newpassword",
|
password,
|
||||||
resetCode: code,
|
resetCode,
|
||||||
})
|
})
|
||||||
.expect("Content-Type", /json/)
|
.expect("Content-Type", /json/)
|
||||||
.expect(200)
|
.expect(opts?.status ? opts.status : 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
login = (
|
||||||
|
tenantId: string,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
opts?: TestAPIOpts
|
||||||
|
) => {
|
||||||
|
return this.request
|
||||||
|
.post(`/api/global/auth/${tenantId}/login`)
|
||||||
|
.send({
|
||||||
|
username: email,
|
||||||
|
password: password,
|
||||||
|
})
|
||||||
|
.expect(opts?.status ? opts.status : 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
logout = () => {
|
logout = () => {
|
||||||
|
@ -25,25 +43,31 @@ export class AuthAPI extends TestAPI {
|
||||||
.expect(200)
|
.expect(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
requestPasswordReset = async (sendMailMock: any, userEmail: string) => {
|
requestPasswordReset = async (
|
||||||
|
sendMailMock: any,
|
||||||
|
email: string,
|
||||||
|
opts?: TestAPIOpts
|
||||||
|
) => {
|
||||||
await this.config.saveSmtpConfig()
|
await this.config.saveSmtpConfig()
|
||||||
await this.config.saveSettingsConfig()
|
await this.config.saveSettingsConfig()
|
||||||
await this.config.createUser({
|
|
||||||
...structures.users.user(),
|
|
||||||
email: userEmail,
|
|
||||||
})
|
|
||||||
const res = await this.request
|
const res = await this.request
|
||||||
.post(`/api/global/auth/${this.config.getTenantId()}/reset`)
|
.post(`/api/global/auth/${this.config.getTenantId()}/reset`)
|
||||||
.send({
|
.send({
|
||||||
email: userEmail,
|
email: email,
|
||||||
})
|
})
|
||||||
.expect("Content-Type", /json/)
|
.expect("Content-Type", /json/)
|
||||||
.expect(200)
|
.expect(opts?.status ? opts.status : 200)
|
||||||
const emailCall = sendMailMock.mock.calls[0][0]
|
|
||||||
const parts = emailCall.html.split(
|
let code: string | undefined
|
||||||
`http://localhost:10000/builder/auth/reset?code=`
|
if (res.status === 200) {
|
||||||
)
|
const emailCall = sendMailMock.mock.calls[0][0]
|
||||||
const code = parts[1].split('"')[0].split("&")[0]
|
const parts = emailCall.html.split(
|
||||||
|
`http://localhost:10000/builder/auth/reset?code=`
|
||||||
|
)
|
||||||
|
code = parts[1].split('"')[0].split("&")[0]
|
||||||
|
}
|
||||||
|
|
||||||
return { code, res }
|
return { code, res }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { structures } from "@budibase/backend-core/tests"
|
import { structures } from "@budibase/backend-core/tests"
|
||||||
import * as configs from "./configs"
|
import * as configs from "./configs"
|
||||||
import * as users from "./users"
|
|
||||||
import * as groups from "./groups"
|
import * as groups from "./groups"
|
||||||
import { v4 as uuid } from "uuid"
|
import { v4 as uuid } from "uuid"
|
||||||
|
|
||||||
|
@ -11,7 +10,6 @@ const pkg = {
|
||||||
...structures,
|
...structures,
|
||||||
uuid,
|
uuid,
|
||||||
configs,
|
configs,
|
||||||
users,
|
|
||||||
TENANT_ID,
|
TENANT_ID,
|
||||||
CSRF_TOKEN,
|
CSRF_TOKEN,
|
||||||
groups,
|
groups,
|
||||||
|
|
|
@ -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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -26,7 +26,7 @@ type SendEmailOpts = {
|
||||||
automation?: boolean
|
automation?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const TEST_MODE = false
|
const TEST_MODE = env.ENABLE_EMAIL_TEST_MODE && env.isDev()
|
||||||
const TYPE = TemplateType.EMAIL
|
const TYPE = TemplateType.EMAIL
|
||||||
|
|
||||||
const FULL_EMAIL_PURPOSES = [
|
const FULL_EMAIL_PURPOSES = [
|
||||||
|
@ -62,8 +62,8 @@ function createSMTPTransport(config: any) {
|
||||||
host: "smtp.ethereal.email",
|
host: "smtp.ethereal.email",
|
||||||
secure: false,
|
secure: false,
|
||||||
auth: {
|
auth: {
|
||||||
user: "don.bahringer@ethereal.email",
|
user: "wyatt.zulauf29@ethereal.email",
|
||||||
pass: "yCKSH8rWyUPbnhGYk9",
|
pass: "tEwDtHBWWxusVWAPfa",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue