Fix/verify sso bug (#14253)

* Delete associated SSO user

* Update reset password screen

* Partial type removed

* lint

* Split getFirstPlatformUser from getPlatformUsers

* lint

* Use correct method

* Code review comments

* lint
This commit is contained in:
melohagan 2024-07-29 13:25:46 +01:00 committed by GitHub
parent cf31c7ba3f
commit 60f7c03e17
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 141 additions and 123 deletions

View File

@ -199,9 +199,8 @@ export const createPlatformUserView = async () => {
export const queryPlatformView = async <T extends Document>( export const queryPlatformView = async <T extends Document>(
viewName: ViewName, viewName: ViewName,
params: DatabaseQueryOpts, params: DatabaseQueryOpts
opts?: QueryViewOptions ): Promise<T[]> => {
): Promise<T[] | T> => {
const CreateFuncByName: any = { const CreateFuncByName: any = {
[ViewName.ACCOUNT_BY_EMAIL]: createPlatformAccountEmailView, [ViewName.ACCOUNT_BY_EMAIL]: createPlatformAccountEmailView,
[ViewName.PLATFORM_USERS_LOWERCASE]: createPlatformUserView, [ViewName.PLATFORM_USERS_LOWERCASE]: createPlatformUserView,
@ -209,7 +208,9 @@ export const queryPlatformView = async <T extends Document>(
return doWithDB(StaticDatabases.PLATFORM_INFO.name, async (db: Database) => { return doWithDB(StaticDatabases.PLATFORM_INFO.name, async (db: Database) => {
const createFn = CreateFuncByName[viewName] const createFn = CreateFuncByName[viewName]
return queryView(viewName, params, db, createFn, opts) return queryView(viewName, params, db, createFn, {
arrayResponse: true,
}) as Promise<T[]>
}) })
} }

View File

@ -25,6 +25,11 @@ export async function getUserDoc(emailOrId: string): Promise<PlatformUser> {
return db.get(emailOrId) return db.get(emailOrId)
} }
export async function updateUserDoc(platformUser: PlatformUserById) {
const db = getPlatformDB()
await db.put(platformUser)
}
// CREATE // CREATE
function newUserIdDoc(id: string, tenantId: string): PlatformUserById { function newUserIdDoc(id: string, tenantId: string): PlatformUserById {

View File

@ -18,6 +18,9 @@ import {
User, User,
UserStatus, UserStatus,
UserGroup, UserGroup,
PlatformUserBySsoId,
PlatformUserById,
AnyDocument,
} from "@budibase/types" } from "@budibase/types"
import { import {
getAccountHolderFromUserIds, getAccountHolderFromUserIds,
@ -25,7 +28,11 @@ import {
isCreator, isCreator,
validateUniqueUser, validateUniqueUser,
} from "./utils" } from "./utils"
import { searchExistingEmails } from "./lookup" import {
getFirstPlatformUser,
getPlatformUsers,
searchExistingEmails,
} from "./lookup"
import { hash } from "../utils" import { hash } from "../utils"
import { validatePassword } from "../security" import { validatePassword } from "../security"
@ -446,9 +453,32 @@ export class UserDB {
creator => !!creator creator => !!creator
).length ).length
const ssoUsersToDelete: AnyDocument[] = []
for (let user of usersToDelete) { for (let user of usersToDelete) {
const platformUser = (await getFirstPlatformUser(
user._id!
)) as PlatformUserById
const ssoId = platformUser.ssoId
if (ssoId) {
// Need to get the _rev of the SSO user doc to delete it. The view also returns docs that have the ssoId property, so we need to ignore those.
const ssoUsers = (await getPlatformUsers(
ssoId
)) as PlatformUserBySsoId[]
ssoUsers
.filter(user => user.ssoId == null)
.forEach(user => {
ssoUsersToDelete.push({
...user,
_deleted: true,
})
})
}
await bulkDeleteProcessing(user) await bulkDeleteProcessing(user)
} }
// Delete any associated SSO user docs
await platform.getPlatformDB().bulkDocs(ssoUsersToDelete)
await UserDB.quotas.removeUsers(toDelete.length, creatorsToDeleteCount) await UserDB.quotas.removeUsers(toDelete.length, creatorsToDeleteCount)
// Build Response // Build Response

View File

@ -34,15 +34,22 @@ export async function searchExistingEmails(emails: string[]) {
} }
// lookup, could be email or userId, either will return a doc // lookup, could be email or userId, either will return a doc
export async function getPlatformUser( export async function getPlatformUsers(
identifier: string identifier: string
): Promise<PlatformUser | null> { ): Promise<PlatformUser[]> {
// use the view here and allow to find anyone regardless of casing // use the view here and allow to find anyone regardless of casing
// Use lowercase to ensure email login is case insensitive // Use lowercase to ensure email login is case insensitive
return (await dbUtils.queryPlatformView(ViewName.PLATFORM_USERS_LOWERCASE, { return await dbUtils.queryPlatformView(ViewName.PLATFORM_USERS_LOWERCASE, {
keys: [identifier.toLowerCase()], keys: [identifier.toLowerCase()],
include_docs: true, include_docs: true,
})) as PlatformUser })
}
export async function getFirstPlatformUser(
identifier: string
): Promise<PlatformUser | null> {
const platformUserDocs = await getPlatformUsers(identifier)
return platformUserDocs[0] ?? null
} }
export async function getExistingTenantUsers( export async function getExistingTenantUsers(
@ -74,15 +81,10 @@ export async function getExistingPlatformUsers(
keys: lcEmails, keys: lcEmails,
include_docs: true, include_docs: true,
} }
return await dbUtils.queryPlatformView(
const opts = {
arrayResponse: true,
}
return (await dbUtils.queryPlatformView(
ViewName.PLATFORM_USERS_LOWERCASE, ViewName.PLATFORM_USERS_LOWERCASE,
params, params
opts )
)) as PlatformUserByEmail[]
} }
export async function getExistingAccounts( export async function getExistingAccounts(
@ -93,14 +95,5 @@ export async function getExistingAccounts(
keys: lcEmails, keys: lcEmails,
include_docs: true, include_docs: true,
} }
return await dbUtils.queryPlatformView(ViewName.ACCOUNT_BY_EMAIL, params)
const opts = {
arrayResponse: true,
}
return (await dbUtils.queryPlatformView(
ViewName.ACCOUNT_BY_EMAIL,
params,
opts
)) as AccountMetadata[]
} }

View File

@ -1,7 +1,7 @@
import { CloudAccount, ContextUser, User, UserGroup } from "@budibase/types" import { CloudAccount, ContextUser, User, UserGroup } from "@budibase/types"
import * as accountSdk from "../accounts" import * as accountSdk from "../accounts"
import env from "../environment" import env from "../environment"
import { getPlatformUser } from "./lookup" import { getFirstPlatformUser } from "./lookup"
import { EmailUnavailableError } from "../errors" import { EmailUnavailableError } from "../errors"
import { getTenantId } from "../context" import { getTenantId } from "../context"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
@ -51,7 +51,7 @@ async function isCreatorByGroupMembership(user?: User | ContextUser) {
export async function validateUniqueUser(email: string, tenantId: string) { export async function validateUniqueUser(email: string, tenantId: string) {
// check budibase users in other tenants // check budibase users in other tenants
if (env.MULTI_TENANCY) { if (env.MULTI_TENANCY) {
const tenantUser = await getPlatformUser(email) const tenantUser = await getFirstPlatformUser(email)
if (tenantUser != null && tenantUser.tenantId !== tenantId) { if (tenantUser != null && tenantUser.tenantId !== tenantId) {
throw new EmailUnavailableError(email) throw new EmailUnavailableError(email)
} }

View File

@ -1,20 +1,32 @@
<script> <script>
import { Layout, Input } from "@budibase/bbui" import { FancyForm, FancyInput } from "@budibase/bbui"
import { createValidationStore, requiredValidator } from "helpers/validation" import { createValidationStore, requiredValidator } from "helpers/validation"
export let password export let password
export let passwordForm
export let error export let error
const validatePassword = value => {
if (!value || value.length < 12) {
return "Please enter at least 12 characters. We recommend using machine generated or random passwords."
}
return null
}
const [firstPassword, passwordError, firstTouched] = createValidationStore( const [firstPassword, passwordError, firstTouched] = createValidationStore(
"", "",
requiredValidator requiredValidator
) )
const [repeatPassword, _, repeatTouched] = createValidationStore( const [repeatPassword, _, repeatTouched] = createValidationStore(
"", "",
requiredValidator requiredValidator,
validatePassword
) )
$: password = $firstPassword $: password = $firstPassword
$: firstPasswordError =
($firstTouched && $passwordError) ||
($repeatTouched && validatePassword(password))
$: error = $: error =
!$firstPassword || !$firstPassword ||
!$firstTouched || !$firstTouched ||
@ -22,19 +34,19 @@
$firstPassword !== $repeatPassword $firstPassword !== $repeatPassword
</script> </script>
<Layout gap="XS" noPadding> <FancyForm bind:this={passwordForm}>
<Input <FancyInput
label="Password" label="Password"
type="password" type="password"
error={$firstTouched && $passwordError} error={firstPasswordError}
bind:value={$firstPassword} bind:value={$firstPassword}
/> />
<Input <FancyInput
label="Repeat Password" label="Repeat password"
type="password" type="password"
error={$repeatTouched && error={$repeatTouched &&
$firstPassword !== $repeatPassword && $firstPassword !== $repeatPassword &&
"Passwords must match"} "Passwords must match"}
bind:value={$repeatPassword} bind:value={$repeatPassword}
/> />
</Layout> </FancyForm>

View File

@ -4,47 +4,45 @@
Button, Button,
Heading, Heading,
Layout, Layout,
ProgressCircle,
notifications, notifications,
FancyForm,
FancyInput,
} from "@budibase/bbui" } from "@budibase/bbui"
import { goto, params } from "@roxi/routify" import { goto, params } from "@roxi/routify"
import { auth, organisation } from "stores/portal" import { auth, organisation } from "stores/portal"
import Logo from "assets/bb-emblem.svg" import Logo from "assets/bb-emblem.svg"
import { TestimonialPage } from "@budibase/frontend-core/src/components" import { TestimonialPage } from "@budibase/frontend-core/src/components"
import { onMount } from "svelte" import { onMount } from "svelte"
import { handleError, passwordsMatch } from "./_components/utils" import PasswordRepeatInput from "../../../components/common/users/PasswordRepeatInput.svelte"
const resetCode = $params["?code"] const resetCode = $params["?code"]
let form let form
let formData = {}
let errors = {}
let loaded = false let loaded = false
let loading = false
let password
let passwordError
$: submitted = false
$: forceResetPassword = $auth?.user?.forceResetPassword $: forceResetPassword = $auth?.user?.forceResetPassword
async function reset() { async function reset() {
form.validate() if (!form.validate() || passwordError) {
if (Object.keys(errors).length > 0) {
return return
} }
submitted = true
try { try {
loading = true
if (forceResetPassword) { if (forceResetPassword) {
await auth.updateSelf({ await auth.updateSelf({
password: formData.password, password,
forceResetPassword: false, forceResetPassword: false,
}) })
$goto("../portal/") $goto("../portal/")
} else { } else {
await auth.resetPassword(formData.password, resetCode) await auth.resetPassword(password, resetCode)
notifications.success("Password reset successfully") notifications.success("Password reset successfully")
// send them to login if reset successful // send them to login if reset successful
$goto("./login") $goto("./login")
} }
} catch (err) { } catch (err) {
submitted = false loading = false
notifications.error(err.message || "Unable to reset password") notifications.error(err.message || "Unable to reset password")
} }
} }
@ -58,86 +56,37 @@
} }
loaded = true loaded = true
}) })
const handleKeydown = evt => {
if (evt.key === "Enter") {
reset()
}
}
</script> </script>
<svelte:window on:keydown={handleKeydown} />
<TestimonialPage enabled={$organisation.testimonialsEnabled}> <TestimonialPage enabled={$organisation.testimonialsEnabled}>
<Layout gap="S" noPadding> <Layout gap="S" noPadding>
{#if loaded} {#if loaded}
<img alt="logo" src={$organisation.logoUrl || Logo} /> <img alt="logo" src={$organisation.logoUrl || Logo} />
{/if} {/if}
<Layout gap="XS" noPadding>
<Heading size="M">Reset your password</Heading>
<Body size="M">Please enter the new password you'd like to use.</Body>
</Layout>
<Layout gap="S" noPadding> <Layout gap="S" noPadding>
<FancyForm bind:this={form}> <Heading size="M">Reset your password</Heading>
<FancyInput <Body size="M">Must contain at least 12 characters</Body>
label="Password" <PasswordRepeatInput
value={formData.password} bind:passwordForm={form}
type="password" bind:password
on:change={e => { bind:error={passwordError}
formData = { />
...formData, <Button secondary cta on:click={reset}>
password: e.detail, {#if loading}
} <ProgressCircle overBackground={true} size="S" />
}} {:else}
validate={() => { Reset
let fieldError = {} {/if}
</Button>
fieldError["password"] = !formData.password
? "Please enter a password"
: undefined
fieldError["confirmationPassword"] =
!passwordsMatch(
formData.password,
formData.confirmationPassword
) && formData.confirmationPassword
? "Passwords must match"
: undefined
errors = handleError({ ...errors, ...fieldError })
}}
error={errors.password}
disabled={submitted}
/>
<FancyInput
label="Repeat Password"
value={formData.confirmationPassword}
type="password"
on:change={e => {
formData = {
...formData,
confirmationPassword: e.detail,
}
}}
validate={() => {
const isValid =
!passwordsMatch(
formData.password,
formData.confirmationPassword
) && formData.password
let fieldError = {
confirmationPassword: isValid ? "Passwords must match" : null,
}
errors = handleError({ ...errors, ...fieldError })
}}
error={errors.confirmationPassword}
disabled={submitted}
/>
</FancyForm>
</Layout> </Layout>
<div> <div />
<Button
disabled={Object.keys(errors).length > 0 ||
(forceResetPassword ? false : !resetCode)}
cta
on:click={reset}>Reset your password</Button
>
</div>
</Layout> </Layout>
</TestimonialPage> </TestimonialPage>

View File

@ -13,6 +13,8 @@ export interface PlatformUserByEmail extends Document {
*/ */
export interface PlatformUserById extends Document { export interface PlatformUserById extends Document {
tenantId: string tenantId: string
email?: string
ssoId?: string
} }
/** /**
@ -22,6 +24,7 @@ export interface PlatformUserBySsoId extends Document {
tenantId: string tenantId: string
userId: string userId: string
email: string email: string
ssoId?: string
} }
export type PlatformUser = export type PlatformUser =

View File

@ -62,7 +62,7 @@ export const addSsoSupport = async (ctx: Ctx<AddSSoUserRequest>) => {
const { email, ssoId } = ctx.request.body const { email, ssoId } = ctx.request.body
try { try {
// Status is changed to 404 from getUserDoc if user is not found // Status is changed to 404 from getUserDoc if user is not found
let userByEmail = (await platform.users.getUserDoc( const userByEmail = (await platform.users.getUserDoc(
email email
)) as PlatformUserByEmail )) as PlatformUserByEmail
await platform.users.addSsoUser( await platform.users.addSsoUser(
@ -71,6 +71,13 @@ export const addSsoSupport = async (ctx: Ctx<AddSSoUserRequest>) => {
userByEmail.userId, userByEmail.userId,
userByEmail.tenantId userByEmail.tenantId
) )
// Need to get the _rev of the user doc to update
const userById = await platform.users.getUserDoc(userByEmail.userId)
await platform.users.updateUserDoc({
...userById,
email,
ssoId,
})
ctx.status = 200 ctx.status = 200
} catch (err: any) { } catch (err: any) {
ctx.throw(err.status || 400, err) ctx.throw(err.status || 400, err)
@ -268,7 +275,7 @@ export const find = async (ctx: any) => {
export const tenantUserLookup = async (ctx: any) => { export const tenantUserLookup = async (ctx: any) => {
const id = ctx.params.id const id = ctx.params.id
const user = await userSdk.core.getPlatformUser(id) const user = await userSdk.core.getFirstPlatformUser(id)
if (user) { if (user) {
ctx.body = user ctx.body = user
} else { } else {

View File

@ -8005,7 +8005,20 @@ caseless@~0.12.0:
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw== integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==
chai@^4.3.10, chai@^4.3.7: chai@^4.3.10:
version "4.5.0"
resolved "https://registry.yarnpkg.com/chai/-/chai-4.5.0.tgz#707e49923afdd9b13a8b0b47d33d732d13812fd8"
integrity sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==
dependencies:
assertion-error "^1.1.0"
check-error "^1.0.3"
deep-eql "^4.1.3"
get-func-name "^2.0.2"
loupe "^2.3.6"
pathval "^1.1.1"
type-detect "^4.1.0"
chai@^4.3.7:
version "4.4.1" version "4.4.1"
resolved "https://registry.yarnpkg.com/chai/-/chai-4.4.1.tgz#3603fa6eba35425b0f2ac91a009fe924106e50d1" resolved "https://registry.yarnpkg.com/chai/-/chai-4.4.1.tgz#3603fa6eba35425b0f2ac91a009fe924106e50d1"
integrity sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g== integrity sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==
@ -21204,6 +21217,11 @@ type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.8:
resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==
type-detect@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.1.0.tgz#deb2453e8f08dcae7ae98c626b13dddb0155906c"
integrity sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==
type-fest@^0.13.1: type-fest@^0.13.1:
version "0.13.1" version "0.13.1"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.13.1.tgz#0172cb5bce80b0bd542ea348db50c7e21834d934" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.13.1.tgz#0172cb5bce80b0bd542ea348db50c7e21834d934"