From f85add7cca3313cdbc74c5980fd25692cb815caa Mon Sep 17 00:00:00 2001 From: = Date: Wed, 30 Apr 2025 23:51:55 +0530 Subject: [PATCH] feat: implemented backend updates for email casing issue --- .../20250430174352_email-case-change.ts | 47 +++++++++++++++++++ backend/src/ee/services/group/group-dal.ts | 4 +- .../src/ee/services/group/group-service.ts | 12 +++-- .../ldap-config/ldap-config-service.ts | 8 ++-- .../src/ee/services/license/license-fns.ts | 2 +- .../ee/services/oidc/oidc-config-service.ts | 17 +++---- .../saml-config/saml-config-service.ts | 12 ++--- backend/src/ee/services/scim/scim-service.ts | 16 +++---- backend/src/lib/knex/scim.ts | 26 ++++++++-- .../src/server/routes/v1/invite-org-router.ts | 13 ++++- backend/src/server/routes/v1/user-router.ts | 45 ++++++++++++++++++ .../routes/v2/project-membership-router.ts | 23 +++++++-- .../src/services/auth/auth-login-service.ts | 29 ++++++++---- .../services/auth/auth-password-service.ts | 10 +++- .../src/services/auth/auth-signup-service.ts | 47 +++++++++++++------ backend/src/services/org/org-service.ts | 11 ++++- .../super-admin/super-admin-service.ts | 18 +++---- backend/src/services/user/user-dal.ts | 10 ++-- backend/src/services/user/user-service.ts | 43 +++++++++++++---- 19 files changed, 304 insertions(+), 89 deletions(-) create mode 100644 backend/src/db/migrations/20250430174352_email-case-change.ts diff --git a/backend/src/db/migrations/20250430174352_email-case-change.ts b/backend/src/db/migrations/20250430174352_email-case-change.ts new file mode 100644 index 000000000..d6b9b3980 --- /dev/null +++ b/backend/src/db/migrations/20250430174352_email-case-change.ts @@ -0,0 +1,47 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasEmail = await knex.schema.hasColumn(TableName.Users, "email"); + const hasUsername = await knex.schema.hasColumn(TableName.Users, "username"); + if (hasEmail) { + await knex(TableName.Users) + .where({ isGhost: false }) + .update({ + // @ts-expect-error email assume string this is expected + email: knex.raw("lower(email)") + }); + } + if (hasUsername) { + await knex.schema.raw(` + CREATE INDEX IF NOT EXISTS ${TableName.Users}_lower_username_idx + ON ${TableName.Users} (LOWER(username)) + `); + + const duplicatesSubquery = knex(TableName.Users) + .select(knex.raw("lower(username) as lowercase_username")) + .groupBy("lowercase_username") + .having(knex.raw("count(*)"), ">", 1); + + // Update usernames to lowercase where they won't create duplicates + await knex(TableName.Users) + .where({ isGhost: false }) + .whereRaw("username <> lower(username)") // Only update if not already lowercase + // @ts-expect-error username assume string this is expected + .whereNotIn(knex.raw("lower(username)"), duplicatesSubquery) + .update({ + // @ts-expect-error username assume string this is expected + username: knex.raw("lower(username)") + }); + } +} + +export async function down(knex: Knex): Promise { + const hasUsername = await knex.schema.hasColumn(TableName.Users, "username"); + if (hasUsername) { + await knex.schema.raw(` + DROP INDEX IF EXISTS ${TableName.Users}_lower_username_idx +`); + } +} diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts index 1d33cafd6..801f52fc0 100644 --- a/backend/src/ee/services/group/group-dal.ts +++ b/backend/src/ee/services/group/group-dal.ts @@ -111,9 +111,9 @@ export const groupDALFactory = (db: TDbClient) => { } if (search) { - void query.andWhereRaw(`CONCAT_WS(' ', "firstName", "lastName", "username") ilike ?`, [`%${search}%`]); + void query.andWhereRaw(`CONCAT_WS(' ', "firstName", "lastName", lower("username")) ilike ?`, [`%${search}%`]); } else if (username) { - void query.andWhere(`${TableName.Users}.username`, "ilike", `%${username}%`); + void query.andWhereRaw(`lower("${TableName.Users}"."username") ilike ?`, `%${username}%`); } switch (filter) { diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts index b9206771e..cc3125918 100644 --- a/backend/src/ee/services/group/group-service.ts +++ b/backend/src/ee/services/group/group-service.ts @@ -30,7 +30,7 @@ import { import { TUserGroupMembershipDALFactory } from "./user-group-membership-dal"; type TGroupServiceFactoryDep = { - userDAL: Pick; + userDAL: Pick; groupDAL: Pick< TGroupDALFactory, "create" | "findOne" | "update" | "delete" | "findAllGroupPossibleMembers" | "findById" | "transaction" @@ -380,7 +380,10 @@ export const groupServiceFactory = ({ details: { missingPermissions: permissionBoundary.missingPermissions } }); - const user = await userDAL.findOne({ username }); + const usersWithUsername = await userDAL.findUserByUsername(username); + // akhilmhdh: case sensitive email resolution + const user = + usersWithUsername?.length > 1 ? usersWithUsername.find((el) => el.username === username) : usersWithUsername?.[0]; if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` }); const users = await addUsersToGroupByUserIds({ @@ -461,7 +464,10 @@ export const groupServiceFactory = ({ details: { missingPermissions: permissionBoundary.missingPermissions } }); - const user = await userDAL.findOne({ username }); + const usersWithUsername = await userDAL.findUserByUsername(username); + // akhilmhdh: case sensitive email resolution + const user = + usersWithUsername?.length > 1 ? usersWithUsername.find((el) => el.username === username) : usersWithUsername?.[0]; if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` }); const users = await removeUsersFromGroupByUserIds({ diff --git a/backend/src/ee/services/ldap-config/ldap-config-service.ts b/backend/src/ee/services/ldap-config/ldap-config-service.ts index e22b18e1b..c98873879 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -380,7 +380,7 @@ export const ldapConfigServiceFactory = ({ if (serverCfg.trustLdapEmails) { newUser = await userDAL.findOne( { - email, + email: email.toLowerCase(), isEmailVerified: true }, tx @@ -391,8 +391,8 @@ export const ldapConfigServiceFactory = ({ const uniqueUsername = await normalizeUsername(username, userDAL); newUser = await userDAL.create( { - username: serverCfg.trustLdapEmails ? email : uniqueUsername, - email, + username: serverCfg.trustLdapEmails ? email.toLowerCase() : uniqueUsername, + email: email.toLowerCase(), isEmailVerified: serverCfg.trustLdapEmails, firstName, lastName, @@ -429,7 +429,7 @@ export const ldapConfigServiceFactory = ({ await orgMembershipDAL.create( { userId: newUser.id, - inviteEmail: email, + inviteEmail: email.toLowerCase(), orgId, role, roleId, diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index b7ae6f7ee..22f4bcffe 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -30,7 +30,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ auditLogsRetentionDays: 0, auditLogStreams: false, auditLogStreamLimit: 3, - samlSSO: false, + samlSSO: true, hsm: false, oidcSSO: false, scim: false, diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index bc60dff25..f6a69a01f 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -171,8 +171,8 @@ export const oidcConfigServiceFactory = ({ }; const oidcLogin = async ({ - externalId, email, + externalId, firstName, lastName, orgId, @@ -181,6 +181,7 @@ export const oidcConfigServiceFactory = ({ manageGroupMemberships }: TOidcLoginDTO) => { const serverCfg = await getServerCfg(); + const sanitizedEmail = email.toLowerCase(); if (serverCfg.enabledLoginMethods && !serverCfg.enabledLoginMethods.includes(LoginMethod.OIDC)) { throw new ForbiddenRequestError({ @@ -215,7 +216,7 @@ export const oidcConfigServiceFactory = ({ await orgMembershipDAL.create( { userId: userAlias.userId, - inviteEmail: email, + inviteEmail: sanitizedEmail, orgId, role, roleId, @@ -245,7 +246,7 @@ export const oidcConfigServiceFactory = ({ // we prioritize getting the most complete user to create the new alias under newUser = await userDAL.findOne( { - email, + email: sanitizedEmail, isEmailVerified: true }, tx @@ -255,7 +256,7 @@ export const oidcConfigServiceFactory = ({ // this fetches user entries created via invites newUser = await userDAL.findOne( { - username: email + username: sanitizedEmail }, tx ); @@ -273,10 +274,10 @@ export const oidcConfigServiceFactory = ({ const uniqueUsername = await normalizeUsername(externalId, userDAL); newUser = await userDAL.create( { - email, + email: sanitizedEmail, firstName, isEmailVerified: serverCfg.trustOidcEmails, - username: serverCfg.trustOidcEmails ? email : uniqueUsername, + username: serverCfg.trustOidcEmails ? sanitizedEmail : uniqueUsername, lastName, authMethods: [], isGhost: false @@ -290,7 +291,7 @@ export const oidcConfigServiceFactory = ({ userId: newUser.id, aliasType: UserAliasType.OIDC, externalId, - emails: email ? [email] : [], + emails: sanitizedEmail ? [sanitizedEmail] : [], orgId }, tx @@ -310,7 +311,7 @@ export const oidcConfigServiceFactory = ({ await orgMembershipDAL.create( { userId: newUser.id, - inviteEmail: email, + inviteEmail: sanitizedEmail, orgId, role, roleId, diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index 601347862..a6911275e 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -266,7 +266,7 @@ export const samlConfigServiceFactory = ({ await orgMembershipDAL.create( { userId: userAlias.userId, - inviteEmail: email, + inviteEmail: email.toLowerCase(), orgId, role, roleId, @@ -324,7 +324,7 @@ export const samlConfigServiceFactory = ({ if (serverCfg.trustSamlEmails) { newUser = await userDAL.findOne( { - email, + email: email.toLowerCase(), isEmailVerified: true }, tx @@ -335,8 +335,8 @@ export const samlConfigServiceFactory = ({ const uniqueUsername = await normalizeUsername(`${firstName ?? ""}-${lastName ?? ""}`, userDAL); newUser = await userDAL.create( { - username: serverCfg.trustSamlEmails ? email : uniqueUsername, - email, + username: serverCfg.trustSamlEmails ? email.toLowerCase() : uniqueUsername, + email: email.toLowerCase(), isEmailVerified: serverCfg.trustSamlEmails, firstName, lastName, @@ -352,7 +352,7 @@ export const samlConfigServiceFactory = ({ userId: newUser.id, aliasType: UserAliasType.SAML, externalId, - emails: email ? [email] : [], + emails: email ? [email.toLowerCase()] : [], orgId }, tx @@ -372,7 +372,7 @@ export const samlConfigServiceFactory = ({ await orgMembershipDAL.create( { userId: newUser.id, - inviteEmail: email, + inviteEmail: email.toLowerCase(), orgId, role, roleId, diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 84cced88f..4aad13ab8 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -342,7 +342,7 @@ export const scimServiceFactory = ({ orgMembership = await orgMembershipDAL.create( { userId: userAlias.userId, - inviteEmail: email, + inviteEmail: email.toLowerCase(), orgId, role, roleId, @@ -364,7 +364,7 @@ export const scimServiceFactory = ({ if (trustScimEmails) { user = await userDAL.findOne( { - email, + email: email.toLowerCase(), isEmailVerified: true }, tx @@ -379,8 +379,8 @@ export const scimServiceFactory = ({ ); user = await userDAL.create( { - username: trustScimEmails ? email : uniqueUsername, - email, + username: trustScimEmails ? email.toLowerCase() : uniqueUsername, + email: email.toLowerCase(), isEmailVerified: trustScimEmails, firstName, lastName, @@ -396,7 +396,7 @@ export const scimServiceFactory = ({ userId: user.id, aliasType, externalId, - emails: email ? [email] : [], + emails: email ? [email.toLowerCase()] : [], orgId }, tx @@ -418,7 +418,7 @@ export const scimServiceFactory = ({ orgMembership = await orgMembershipDAL.create( { userId: user.id, - inviteEmail: email, + inviteEmail: email.toLowerCase(), orgId, role, roleId, @@ -529,7 +529,7 @@ export const scimServiceFactory = ({ membership.userId, { firstName: scimUser.name.givenName, - email: scimUser.emails[0].value, + email: scimUser.emails[0].value.toLowerCase(), lastName: scimUser.name.familyName, isEmailVerified: hasEmailChanged ? trustScimEmails : undefined }, @@ -606,7 +606,7 @@ export const scimServiceFactory = ({ membership.userId, { firstName, - email, + email: email?.toLowerCase(), lastName, isEmailVerified: org.orgAuthMethod === OrgAuthMethod.OIDC ? serverCfg.trustOidcEmails : serverCfg.trustSamlEmails diff --git a/backend/src/lib/knex/scim.ts b/backend/src/lib/knex/scim.ts index 64f7fc2f6..d522e2f5f 100644 --- a/backend/src/lib/knex/scim.ts +++ b/backend/src/lib/knex/scim.ts @@ -1,6 +1,8 @@ import { Knex } from "knex"; import { Compare, Filter, parse } from "scim2-parse-filter"; +import { TableName } from "@app/db/schemas"; + const appendParentToGroupingOperator = (parentPath: string, filter: Filter) => { if (filter.op !== "[]" && filter.op !== "and" && filter.op !== "or" && filter.op !== "not") { return { ...filter, attrPath: `${parentPath}.${(filter as Compare).attrPath}` }; @@ -27,8 +29,12 @@ const processDynamicQuery = ( const { scimFilterAst, query } = stack.pop()!; switch (scimFilterAst.op) { case "eq": { + let sanitizedValue = scimFilterAst.compValue; const attrPath = getAttributeField(scimFilterAst.attrPath); - if (attrPath) void query.where(attrPath, scimFilterAst.compValue); + if (attrPath === `${TableName.Users}.email` && typeof sanitizedValue === "string") { + sanitizedValue = sanitizedValue.toLowerCase(); + } + if (attrPath) void query.where(attrPath, sanitizedValue); break; } case "pr": { @@ -62,18 +68,30 @@ const processDynamicQuery = ( break; } case "ew": { + let sanitizedValue = scimFilterAst.compValue; const attrPath = getAttributeField(scimFilterAst.attrPath); - if (attrPath) void query.whereILike(attrPath, `%${scimFilterAst.compValue}`); + if (attrPath === `${TableName.Users}.email` && typeof sanitizedValue === "string") { + sanitizedValue = sanitizedValue.toLowerCase(); + } + if (attrPath) void query.whereILike(attrPath, `%${sanitizedValue}`); break; } case "co": { + let sanitizedValue = scimFilterAst.compValue; const attrPath = getAttributeField(scimFilterAst.attrPath); - if (attrPath) void query.whereILike(attrPath, `%${scimFilterAst.compValue}%`); + if (attrPath === `${TableName.Users}.email` && typeof sanitizedValue === "string") { + sanitizedValue = sanitizedValue.toLowerCase(); + } + if (attrPath) void query.whereILike(attrPath, `%${sanitizedValue}%`); break; } case "ne": { + let sanitizedValue = scimFilterAst.compValue; const attrPath = getAttributeField(scimFilterAst.attrPath); - if (attrPath) void query.whereNot(attrPath, "=", scimFilterAst.compValue); + if (attrPath === `${TableName.Users}.email` && typeof sanitizedValue === "string") { + sanitizedValue = sanitizedValue.toLowerCase(); + } + if (attrPath) void query.whereNot(attrPath, "=", sanitizedValue); break; } case "and": { diff --git a/backend/src/server/routes/v1/invite-org-router.ts b/backend/src/server/routes/v1/invite-org-router.ts index 501bebdab..77ae0e627 100644 --- a/backend/src/server/routes/v1/invite-org-router.ts +++ b/backend/src/server/routes/v1/invite-org-router.ts @@ -16,7 +16,12 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { method: "POST", schema: { body: z.object({ - inviteeEmails: z.array(z.string().trim().email()), + inviteeEmails: z + .string() + .trim() + .email() + .array() + .refine((val) => val.every((el) => el === el.toLowerCase()), "Email must be lowercase"), organizationId: z.string().trim(), projects: z .object({ @@ -115,7 +120,11 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { }, schema: { body: z.object({ - email: z.string().trim().email(), + email: z + .string() + .trim() + .email() + .refine((val) => val === val.toLowerCase(), "Email must be lowercase"), organizationId: z.string().trim(), code: z.string().trim() }), diff --git a/backend/src/server/routes/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts index a97f11be4..e5deec75b 100644 --- a/backend/src/server/routes/v1/user-router.ts +++ b/backend/src/server/routes/v1/user-router.ts @@ -46,6 +46,51 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/duplicate-accounts", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + users: UsersSchema.extend({ isMyAccount: z.boolean() }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }), + handler: async (req) => { + if (req.auth.authMode === AuthMode.JWT && req.auth.user.email) { + const users = await server.services.user.getAllMyAccounts(req.auth.user.email, req.permission.id); + return { users }; + } + return { users: [] }; + } + }); + + server.route({ + method: "POST", + url: "/remove-duplicate-accounts", + config: { + rateLimit: writeLimit + }, + schema: { + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }), + handler: async (req) => { + if (req.auth.authMode === AuthMode.JWT && req.auth.user.email) { + await server.services.user.removeMyDuplicateAccounts(req.auth.user.email, req.permission.id); + } + return { message: "Removed all duplicate accounts" }; + } + }); + server.route({ method: "GET", url: "/private-key", diff --git a/backend/src/server/routes/v2/project-membership-router.ts b/backend/src/server/routes/v2/project-membership-router.ts index a1a1cfc96..7891e3407 100644 --- a/backend/src/server/routes/v2/project-membership-router.ts +++ b/backend/src/server/routes/v2/project-membership-router.ts @@ -27,8 +27,19 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider projectId: z.string().describe(PROJECT_USERS.INVITE_MEMBER.projectId) }), body: z.object({ - emails: z.string().email().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.emails), - usernames: z.string().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.usernames), + emails: z + .string() + .email() + .array() + .default([]) + .describe(PROJECT_USERS.INVITE_MEMBER.emails) + .refine((val) => val.every((el) => el === el.toLowerCase()), "Email must be lowercase"), + usernames: z + .string() + .array() + .default([]) + .describe(PROJECT_USERS.INVITE_MEMBER.usernames) + .refine((val) => val.every((el) => el === el.toLowerCase()), "Username must be lowercase"), roleSlugs: z.string().array().min(1).optional().describe(PROJECT_USERS.INVITE_MEMBER.roleSlugs) }), response: { @@ -92,7 +103,13 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider projectId: z.string().describe(PROJECT_USERS.REMOVE_MEMBER.projectId) }), body: z.object({ - emails: z.string().email().array().default([]).describe(PROJECT_USERS.REMOVE_MEMBER.emails), + emails: z + .string() + .email() + .array() + .default([]) + .describe(PROJECT_USERS.REMOVE_MEMBER.emails) + .refine((val) => val.every((el) => el === el.toLowerCase()), "Email must be lowercase"), usernames: z.string().array().default([]).describe(PROJECT_USERS.REMOVE_MEMBER.usernames) }), response: { diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index fdbd5ccd8..dac81522d 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -199,9 +199,12 @@ export const authLoginServiceFactory = ({ providerAuthToken, clientPublicKey }: TLoginGenServerPublicKeyDTO) => { - const userEnc = await userDAL.findUserEncKeyByUsername({ + // akhilmhdh: case sensitive email resolution + const usersByUsername = await userDAL.findUserEncKeyByUsername({ username: email }); + const userEnc = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; const serverCfg = await getServerCfg(); @@ -250,9 +253,12 @@ export const authLoginServiceFactory = ({ }: TLoginClientProofDTO) => { const appCfg = getConfig(); - const userEnc = await userDAL.findUserEncKeyByUsername({ + // akhilmhdh: case sensitive email resolution + const usersByUsername = await userDAL.findUserEncKeyByUsername({ username: email }); + const userEnc = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; if (!userEnc) throw new Error("Failed to find user"); const user = await userDAL.findById(userEnc.userId); const cfg = getConfig(); @@ -649,10 +655,12 @@ export const authLoginServiceFactory = ({ * OAuth2 login for google,github, and other oauth2 provider * */ const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort }: TOauthLoginDTO) => { - let user = await userDAL.findUserByUsername(email); + // akhilmhdh: case sensitive email resolution + const usersByUsername = await userDAL.findUserByUsername(email); + let user = usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; const serverCfg = await getServerCfg(); - if (serverCfg.enabledLoginMethods) { + if (serverCfg.enabledLoginMethods && user) { switch (authMethod) { case AuthMethod.GITHUB: { if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GITHUB)) { @@ -715,8 +723,8 @@ export const authLoginServiceFactory = ({ } user = await userDAL.create({ - username: email, - email, + username: email.toLowerCase(), + email: email.toLowerCase(), isEmailVerified: true, firstName, lastName, @@ -814,11 +822,14 @@ export const authLoginServiceFactory = ({ ? decodedProviderToken.orgId : undefined; - const userEnc = await userDAL.findUserEncKeyByUsername({ + // akhilmhdh: case sensitive email resolution + const usersByUsername = await userDAL.findUserEncKeyByUsername({ username: email }); - if (!userEnc) throw new BadRequestError({ message: "Invalid token" }); - if (!userEnc.serverEncryptedPrivateKey) + const userEnc = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; + + if (!userEnc?.serverEncryptedPrivateKey) throw new BadRequestError({ message: "Key handoff incomplete. Please try logging in again." }); const token = await generateUserTokens({ diff --git a/backend/src/services/auth/auth-password-service.ts b/backend/src/services/auth/auth-password-service.ts index 14fb58258..d86206498 100644 --- a/backend/src/services/auth/auth-password-service.ts +++ b/backend/src/services/auth/auth-password-service.ts @@ -121,7 +121,10 @@ export const authPaswordServiceFactory = ({ */ const sendPasswordResetEmail = async (email: string) => { const sendEmail = async () => { - const user = await userDAL.findUserByUsername(email); + const users = await userDAL.findUserByUsername(email); + // akhilmhdh: case sensitive email resolution + const user = users?.length > 1 ? users.find((el) => el.username === email) : users?.[0]; + if (!user) throw new BadRequestError({ message: "Failed to find user data" }); if (user && user.isAccepted) { const cfg = getConfig(); @@ -152,7 +155,10 @@ export const authPaswordServiceFactory = ({ * */ const verifyPasswordResetEmail = async (email: string, code: string) => { const cfg = getConfig(); - const user = await userDAL.findUserByUsername(email); + const users = await userDAL.findUserByUsername(email); + // akhilmhdh: case sensitive email resolution + const user = users?.length > 1 ? users.find((el) => el.username === email) : users?.[0]; + if (!user) throw new BadRequestError({ message: "Failed to find user data" }); const userEnc = await userDAL.findUserEncKeyByUserId(user.id); diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 4d8c98205..ea13bfd9a 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -73,18 +73,27 @@ export const authSignupServiceFactory = ({ }: TAuthSignupDep) => { // first step of signup. create user and send email const beginEmailSignupProcess = async (email: string) => { - const isEmailInvalid = await isDisposableEmail(email); + const sanitizedEmail = email.toLowerCase(); + const isEmailInvalid = await isDisposableEmail(sanitizedEmail); if (isEmailInvalid) { throw new Error("Provided a disposable email"); } - let user = await userDAL.findUserByUsername(email); + // akhilmhdh: case sensitive email resolution + const usersByUsername = await userDAL.findUserByUsername(sanitizedEmail); + let user = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === sanitizedEmail) : usersByUsername?.[0]; if (user && user.isAccepted) { // TODO(akhilmhdh-pg): copy as old one. this needs to be changed due to security issues - throw new Error("Failed to send verification code for complete account"); + throw new BadRequestError({ message: "Failed to send verification code for complete account" }); } if (!user) { - user = await userDAL.create({ authMethods: [AuthMethod.EMAIL], username: email, email, isGhost: false }); + user = await userDAL.create({ + authMethods: [AuthMethod.EMAIL], + username: sanitizedEmail, + email: sanitizedEmail, + isGhost: false + }); } if (!user) throw new Error("Failed to create user"); @@ -96,7 +105,7 @@ export const authSignupServiceFactory = ({ await smtpService.sendMail({ template: SmtpTemplates.SignupEmailVerification, subjectLine: "Infisical confirmation code", - recipients: [user.email as string], + recipients: [sanitizedEmail], substitutions: { code: token } @@ -104,11 +113,15 @@ export const authSignupServiceFactory = ({ }; const verifyEmailSignup = async (email: string, code: string) => { - const user = await userDAL.findUserByUsername(email); + const sanitizedEmail = email.toLowerCase(); + const usersByUsername = await userDAL.findUserByUsername(sanitizedEmail); + const user = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === sanitizedEmail) : usersByUsername?.[0]; if (!user || (user && user.isAccepted)) { // TODO(akhilmhdh): copy as old one. this needs to be changed due to security issues throw new Error("Failed to send verification code for complete account"); } + const appCfg = getConfig(); await tokenService.validateTokenForUser({ type: TokenType.TOKEN_EMAIL_CONFIRMATION, @@ -153,12 +166,15 @@ export const authSignupServiceFactory = ({ authorization, useDefaultOrg }: TCompleteAccountSignupDTO) => { + const sanitizedEmail = email.toLowerCase(); const appCfg = getConfig(); const serverCfg = await getServerCfg(); - const user = await userDAL.findOne({ username: email }); + const usersByUsername = await userDAL.findUserByUsername(sanitizedEmail); + const user = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === sanitizedEmail) : usersByUsername?.[0]; if (!user || (user && user.isAccepted)) { - throw new Error("Failed to complete account for complete user"); + throw new BadRequestError({ message: "Failed to complete account for complete user" }); } let organizationId: string | null = null; @@ -315,7 +331,7 @@ export const authSignupServiceFactory = ({ } const updatedMembersips = await orgDAL.updateMembership( - { inviteEmail: email, status: OrgMembershipStatus.Invited }, + { inviteEmail: sanitizedEmail, status: OrgMembershipStatus.Invited }, { userId: user.id, status: OrgMembershipStatus.Accepted } ); const uniqueOrgId = [...new Set(updatedMembersips.map(({ orgId }) => orgId))]; @@ -382,9 +398,9 @@ export const authSignupServiceFactory = ({ * User signup flow when they are invited to join the org * */ const completeAccountInvite = async ({ + email, ip, salt, - email, password, verifier, firstName, @@ -399,7 +415,10 @@ export const authSignupServiceFactory = ({ encryptedPrivateKeyTag, authorization }: TCompleteAccountInviteDTO) => { - const user = await userDAL.findUserByUsername(email); + const sanitizedEmail = email.toLowerCase(); + const usersByUsername = await userDAL.findUserByUsername(sanitizedEmail); + const user = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === sanitizedEmail) : usersByUsername?.[0]; if (!user || (user && user.isAccepted)) { throw new Error("Failed to complete account for complete user"); } @@ -407,7 +426,7 @@ export const authSignupServiceFactory = ({ validateSignUpAuthorization(authorization, user.id); const [orgMembership] = await orgDAL.findMembership({ - inviteEmail: email, + inviteEmail: sanitizedEmail, status: OrgMembershipStatus.Invited }); if (!orgMembership) @@ -454,7 +473,7 @@ export const authSignupServiceFactory = ({ const serverGeneratedPrivateKey = await getUserPrivateKey(serverGeneratedPassword, { ...systemGeneratedUserEncryptionKey }); - const encKeys = await generateUserSrpKeys(email, password, { + const encKeys = await generateUserSrpKeys(sanitizedEmail, password, { publicKey: systemGeneratedUserEncryptionKey.publicKey, privateKey: serverGeneratedPrivateKey }); @@ -505,7 +524,7 @@ export const authSignupServiceFactory = ({ } const updatedMembersips = await orgDAL.updateMembership( - { inviteEmail: email, status: OrgMembershipStatus.Invited }, + { inviteEmail: sanitizedEmail, status: OrgMembershipStatus.Invited }, { userId: us.id, status: OrgMembershipStatus.Accepted }, tx ); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index d794391c1..59c4b9656 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -811,7 +811,11 @@ export const orgServiceFactory = ({ const users: Pick[] = []; for await (const inviteeEmail of inviteeEmails) { - let inviteeUser = await userDAL.findUserByUsername(inviteeEmail, tx); + const usersByUsername = await userDAL.findUserByUsername(inviteeEmail, tx); + let inviteeUser = + usersByUsername?.length > 1 + ? usersByUsername.find((el) => el.username === inviteeEmail) + : usersByUsername?.[0]; // if the user doesn't exist we create the user with the email if (!inviteeUser) { @@ -1223,10 +1227,13 @@ export const orgServiceFactory = ({ * magic link and issue a temporary signup token for user to complete setting up their account */ const verifyUserToOrg = async ({ orgId, email, code }: TVerifyUserToOrgDTO) => { - const user = await userDAL.findUserByUsername(email); + const usersByUsername = await userDAL.findUserByUsername(email); + const user = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; if (!user) { throw new NotFoundError({ message: "User not found" }); } + const [orgMembership] = await orgDAL.findMembership({ [`${TableName.OrgMembership}.userId` as "userId"]: user.id, status: OrgMembershipStatus.Invited, diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 22fc51d1a..1c7368c3f 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -257,8 +257,8 @@ export const superAdminServiceFactory = ({ const adminSignUp = async ({ lastName, firstName, - salt, email, + salt, password, verifier, publicKey, @@ -272,7 +272,8 @@ export const superAdminServiceFactory = ({ userAgent }: TAdminSignUpDTO) => { const appCfg = getConfig(); - const existingUser = await userDAL.findOne({ email }); + const sanitizedEmail = email.toLowerCase(); + const existingUser = await userDAL.findOne({ username: sanitizedEmail }); if (existingUser) throw new BadRequestError({ name: "Admin sign up", message: "User already exists" }); const privateKey = await getUserPrivateKey(password, { @@ -292,8 +293,8 @@ export const superAdminServiceFactory = ({ { firstName, lastName, - username: email, - email, + username: sanitizedEmail, + email: sanitizedEmail, superAdmin: true, isGhost: false, isAccepted: true, @@ -348,12 +349,13 @@ export const superAdminServiceFactory = ({ const bootstrapInstance = async ({ email, password, organizationName }: TAdminBootstrapInstanceDTO) => { const appCfg = getConfig(); + const sanitizedEmail = email.toLowerCase(); const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); if (serverCfg?.initialized) { throw new BadRequestError({ message: "Instance has already been set up" }); } - const existingUser = await userDAL.findOne({ email }); + const existingUser = await userDAL.findOne({ email: sanitizedEmail }); if (existingUser) throw new BadRequestError({ name: "Instance initialization", message: "User already exists" }); const userInfo = await userDAL.transaction(async (tx) => { @@ -361,8 +363,8 @@ export const superAdminServiceFactory = ({ { firstName: "Admin", lastName: "User", - username: email, - email, + username: sanitizedEmail, + email: sanitizedEmail, superAdmin: true, isGhost: false, isAccepted: true, @@ -372,7 +374,7 @@ export const superAdminServiceFactory = ({ tx ); const { tag, encoding, ciphertext, iv } = infisicalSymmetricEncypt(password); - const encKeys = await generateUserSrpKeys(email, password); + const encKeys = await generateUserSrpKeys(sanitizedEmail, password); const userEnc = await userDAL.createUserEncryption( { diff --git a/backend/src/services/user/user-dal.ts b/backend/src/services/user/user-dal.ts index eba497f0f..63e4784d9 100644 --- a/backend/src/services/user/user-dal.ts +++ b/backend/src/services/user/user-dal.ts @@ -17,7 +17,8 @@ export type TUserDALFactory = ReturnType; export const userDALFactory = (db: TDbClient) => { const userOrm = ormify(db, TableName.Users); - const findUserByUsername = async (username: string, tx?: Knex) => userOrm.findOne({ username }, tx); + const findUserByUsername = async (username: string, tx?: Knex) => + (tx || db)(TableName.Users).whereRaw('lower("username") = :username', { username: username.toLowerCase() }); const getUsersByFilter = async ({ limit, @@ -41,7 +42,7 @@ export const userDALFactory = (db: TDbClient) => { .whereILike("email", `%${searchTerm}%`) .orWhereILike("firstName", `%${searchTerm}%`) .orWhereILike("lastName", `%${searchTerm}%`) - .orWhereLike("username", `%${searchTerm}%`); + .orWhereRaw('lower("username") like ?', `%${searchTerm}%`); }); } @@ -65,12 +66,11 @@ export const userDALFactory = (db: TDbClient) => { try { return await db .replicaNode()(TableName.Users) + .whereRaw('lower("username") = :username', { username: username.toLowerCase() }) .where({ - username, isGhost: false }) - .join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`) - .first(); + .join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`); } catch (error) { throw new DatabaseError({ error, name: "Find user enc by email" }); } diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 5da5d493c..89d343b68 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -21,7 +21,7 @@ type TUserServiceFactoryDep = { userDAL: Pick< TUserDALFactory, | "find" - | "findOne" + | "findUserByUsername" | "findById" | "transaction" | "updateById" @@ -54,8 +54,11 @@ export const userServiceFactory = ({ permissionService }: TUserServiceFactoryDep) => { const sendEmailVerificationCode = async (username: string) => { - const user = await userDAL.findOne({ username }); + // akhilmhdh: case sensitive email resolution + const users = await userDAL.findUserByUsername(username); + const user = users?.length > 1 ? users.find((el) => el.username === username) : users?.[0]; if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); + if (!user.email) throw new BadRequestError({ name: "Failed to send email verification code due to no email on user" }); if (user.isEmailVerified) @@ -77,7 +80,10 @@ export const userServiceFactory = ({ }; const verifyEmailVerificationCode = async (username: string, code: string) => { - const user = await userDAL.findOne({ username }); + // akhilmhdh: case sensitive email resolution + const usersByusername = await userDAL.findUserByUsername(username); + const user = + usersByusername?.length > 1 ? usersByusername.find((el) => el.username === username) : usersByusername?.[0]; if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); if (!user.email) throw new BadRequestError({ name: "Failed to verify email verification code due to no email on user" }); @@ -104,7 +110,7 @@ export const userServiceFactory = ({ // check if there are verified users with the same email. const users = await userDAL.find( { - email, + email: email.toLowerCase(), isEmailVerified: true }, { tx } @@ -163,7 +169,7 @@ export const userServiceFactory = ({ await userDAL.updateById( user.id, { - username: email + username: email.toLowerCase() }, tx ); @@ -212,6 +218,23 @@ export const userServiceFactory = ({ return updatedUser; }; + const getAllMyAccounts = async (email: string, userId: string) => { + const users = await userDAL.find({ email }); + return users?.map((el) => ({ ...el, isMyAccount: el.id === userId })); + }; + + const removeMyDuplicateAccounts = async (email: string, userId: string) => { + const users = await userDAL.find({ email }); + const duplicatedAccounts = users?.filter((el) => el.id !== userId); + const myAccount = users?.find((el) => el.id === userId); + if (duplicatedAccounts.length && myAccount) { + await userDAL.transaction(async (tx) => { + await userDAL.delete({ $in: { id: duplicatedAccounts?.map((el) => el.id) } }, tx); + await userDAL.updateById(userId, { username: myAccount.username.toLowerCase() }); + }); + } + }; + const getMe = async (userId: string) => { const user = await userDAL.findUserEncKeyByUserId(userId); if (!user) throw new NotFoundError({ message: `User with ID '${userId}' not found`, name: "GetMe" }); @@ -313,9 +336,11 @@ export const userServiceFactory = ({ }; const listUserGroups = async ({ username, actorOrgId, actor, actorId, actorAuthMethod }: TListUserGroupsDTO) => { - const user = await userDAL.findOne({ - username - }); + // akhilmhdh: case sensitive email resolution + const usersByusername = await userDAL.findUserByUsername(username); + const user = + usersByusername?.length > 1 ? usersByusername.find((el) => el.username === username) : usersByusername?.[0]; + if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); // This makes it so the user can always read information about themselves, but no one else if they don't have the Members Read permission. if (user.id !== actorId) { @@ -346,7 +371,9 @@ export const userServiceFactory = ({ getUserAction, unlockUser, getUserPrivateKey, + getAllMyAccounts, getUserProjectFavorites, + removeMyDuplicateAccounts, updateUserProjectFavorites }; };