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>(
viewName: ViewName,
params: DatabaseQueryOpts,
opts?: QueryViewOptions
): Promise<T[] | T> => {
params: DatabaseQueryOpts
): Promise<T[]> => {
const CreateFuncByName: any = {
[ViewName.ACCOUNT_BY_EMAIL]: createPlatformAccountEmailView,
[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) => {
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)
}
export async function updateUserDoc(platformUser: PlatformUserById) {
const db = getPlatformDB()
await db.put(platformUser)
}
// CREATE
function newUserIdDoc(id: string, tenantId: string): PlatformUserById {

View File

@ -18,6 +18,9 @@ import {
User,
UserStatus,
UserGroup,
PlatformUserBySsoId,
PlatformUserById,
AnyDocument,
} from "@budibase/types"
import {
getAccountHolderFromUserIds,
@ -25,7 +28,11 @@ import {
isCreator,
validateUniqueUser,
} from "./utils"
import { searchExistingEmails } from "./lookup"
import {
getFirstPlatformUser,
getPlatformUsers,
searchExistingEmails,
} from "./lookup"
import { hash } from "../utils"
import { validatePassword } from "../security"
@ -446,9 +453,32 @@ export class UserDB {
creator => !!creator
).length
const ssoUsersToDelete: AnyDocument[] = []
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)
}
// Delete any associated SSO user docs
await platform.getPlatformDB().bulkDocs(ssoUsersToDelete)
await UserDB.quotas.removeUsers(toDelete.length, creatorsToDeleteCount)
// 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
export async function getPlatformUser(
export async function getPlatformUsers(
identifier: string
): Promise<PlatformUser | null> {
): Promise<PlatformUser[]> {
// use the view here and allow to find anyone regardless of casing
// 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()],
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(
@ -74,15 +81,10 @@ export async function getExistingPlatformUsers(
keys: lcEmails,
include_docs: true,
}
const opts = {
arrayResponse: true,
}
return (await dbUtils.queryPlatformView(
return await dbUtils.queryPlatformView(
ViewName.PLATFORM_USERS_LOWERCASE,
params,
opts
)) as PlatformUserByEmail[]
params
)
}
export async function getExistingAccounts(
@ -93,14 +95,5 @@ export async function getExistingAccounts(
keys: lcEmails,
include_docs: true,
}
const opts = {
arrayResponse: true,
}
return (await dbUtils.queryPlatformView(
ViewName.ACCOUNT_BY_EMAIL,
params,
opts
)) as AccountMetadata[]
return await dbUtils.queryPlatformView(ViewName.ACCOUNT_BY_EMAIL, params)
}

View File

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

View File

@ -1,20 +1,32 @@
<script>
import { Layout, Input } from "@budibase/bbui"
import { FancyForm, FancyInput } from "@budibase/bbui"
import { createValidationStore, requiredValidator } from "helpers/validation"
export let password
export let passwordForm
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(
"",
requiredValidator
)
const [repeatPassword, _, repeatTouched] = createValidationStore(
"",
requiredValidator
requiredValidator,
validatePassword
)
$: password = $firstPassword
$: firstPasswordError =
($firstTouched && $passwordError) ||
($repeatTouched && validatePassword(password))
$: error =
!$firstPassword ||
!$firstTouched ||
@ -22,19 +34,19 @@
$firstPassword !== $repeatPassword
</script>
<Layout gap="XS" noPadding>
<Input
<FancyForm bind:this={passwordForm}>
<FancyInput
label="Password"
type="password"
error={$firstTouched && $passwordError}
error={firstPasswordError}
bind:value={$firstPassword}
/>
<Input
label="Repeat Password"
<FancyInput
label="Repeat password"
type="password"
error={$repeatTouched &&
$firstPassword !== $repeatPassword &&
"Passwords must match"}
bind:value={$repeatPassword}
/>
</Layout>
</FancyForm>

View File

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

View File

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

View File

@ -62,7 +62,7 @@ export const addSsoSupport = async (ctx: Ctx<AddSSoUserRequest>) => {
const { email, ssoId } = ctx.request.body
try {
// 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
)) as PlatformUserByEmail
await platform.users.addSsoUser(
@ -71,6 +71,13 @@ export const addSsoSupport = async (ctx: Ctx<AddSSoUserRequest>) => {
userByEmail.userId,
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
} catch (err: any) {
ctx.throw(err.status || 400, err)
@ -268,7 +275,7 @@ export const find = async (ctx: any) => {
export const tenantUserLookup = async (ctx: any) => {
const id = ctx.params.id
const user = await userSdk.core.getPlatformUser(id)
const user = await userSdk.core.getFirstPlatformUser(id)
if (user) {
ctx.body = user
} else {

View File

@ -8005,7 +8005,20 @@ caseless@~0.12.0:
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
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"
resolved "https://registry.yarnpkg.com/chai/-/chai-4.4.1.tgz#3603fa6eba35425b0f2ac91a009fe924106e50d1"
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"
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:
version "0.13.1"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.13.1.tgz#0172cb5bce80b0bd542ea348db50c7e21834d934"