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/routes/v1/saml-router.ts b/backend/src/ee/routes/v1/saml-router.ts index 8648ade7c..c8395d608 100644 --- a/backend/src/ee/routes/v1/saml-router.ts +++ b/backend/src/ee/routes/v1/saml-router.ts @@ -145,7 +145,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({ externalId: profile.nameID, - email, + email: email.toLowerCase(), firstName, lastName: lastName as string, relayState: (req.body as { RelayState?: string }).RelayState, 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/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index 6accb69e9..d933835e4 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, @@ -717,7 +717,7 @@ export const oidcConfigServiceFactory = ({ const groups = typeof claims.groups === "string" ? [claims.groups] : (claims.groups as string[] | undefined); oidcLogin({ - email: claims.email, + email: claims.email.toLowerCase(), externalId: claims.sub, firstName: claims.given_name ?? "", lastName: claims.family_name ?? "", 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/index.ts b/backend/src/server/routes/index.ts index 5196971f9..cb22c8c3c 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -625,7 +625,6 @@ export const registerRoutes = async ( const userService = userServiceFactory({ userDAL, - userAliasDAL, orgMembershipDAL, tokenService, permissionService, 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..a0c3592f7 100644 --- a/backend/src/server/routes/v1/user-router.ts +++ b/backend/src/server/routes/v1/user-router.ts @@ -46,6 +46,54 @@ 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(), + organizations: z.object({ name: z.string(), slug: z.string() }).array() + }).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..76f1e9c5e 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,8 +103,19 @@ 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), - usernames: z.string().array().default([]).describe(PROJECT_USERS.REMOVE_MEMBER.usernames) + 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) + .refine((val) => val.every((el) => el === el.toLowerCase()), "Username must be lowercase") }), response: { 200: z.object({ diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index fdbd5ccd8..bee85b14c 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.trim().toLowerCase(), + email: email.trim().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 bf3482be5..5e2f8c7b3 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..7e11f25cb 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.trim().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.trim().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.trim().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.trim().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 c966d5ef9..bfd24e639 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -827,7 +827,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) { @@ -1239,10 +1243,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..04dfa253b 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.trim().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.trim().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..b5a29fc8c 100644 --- a/backend/src/services/user/user-dal.ts +++ b/backend/src/services/user/user-dal.ts @@ -8,16 +8,18 @@ import { TUserEncryptionKeys, TUserEncryptionKeysInsert, TUserEncryptionKeysUpdate, - TUsers + TUsers, + UsersSchema } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; 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 +43,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 +67,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" }); } @@ -168,6 +169,38 @@ export const userDALFactory = (db: TDbClient) => { } }; + const findAllMyAccounts = async (email: string) => { + try { + const doc = await db(TableName.Users) + .where({ email }) + .leftJoin(TableName.OrgMembership, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`) + .select(selectAllTableCols(TableName.Users)) + .select( + db.ref("name").withSchema(TableName.Organization).as("orgName"), + db.ref("slug").withSchema(TableName.Organization).as("orgSlug") + ); + const formattedDoc = sqlNestRelationships({ + data: doc, + key: "id", + parentMapper: (el) => UsersSchema.parse(el), + childrenMapper: [ + { + key: "orgSlug", + label: "organizations" as const, + mapper: ({ orgSlug, orgName }) => ({ + slug: orgSlug, + name: orgName + }) + } + ] + }); + return formattedDoc; + } catch (error) { + throw new DatabaseError({ error, name: "Upsert user enc key" }); + } + }; + // USER ACTION FUNCTIONS // --------------------- const findOneUserAction = (filter: TUserActionsUpdate, tx?: Knex) => { @@ -200,6 +233,7 @@ export const userDALFactory = (db: TDbClient) => { createUserEncryption, findOneUserAction, createUserAction, - getUsersByFilter + getUsersByFilter, + findAllMyAccounts }; }; diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 5da5d493c..29f6300d6 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -9,7 +9,6 @@ import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-se import { TokenType } from "@app/services/auth-token/auth-token-types"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; -import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { AuthMethod } from "../auth/auth-type"; import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; @@ -21,7 +20,7 @@ type TUserServiceFactoryDep = { userDAL: Pick< TUserDALFactory, | "find" - | "findOne" + | "findUserByUsername" | "findById" | "transaction" | "updateById" @@ -31,8 +30,8 @@ type TUserServiceFactoryDep = { | "createUserAction" | "findUserEncKeyByUserId" | "delete" + | "findAllMyAccounts" >; - userAliasDAL: Pick; groupProjectDAL: Pick; orgMembershipDAL: Pick; tokenService: Pick; @@ -45,7 +44,6 @@ export type TUserServiceFactory = ReturnType; export const userServiceFactory = ({ userDAL, - userAliasDAL, orgMembershipDAL, projectMembershipDAL, groupProjectDAL, @@ -54,8 +52,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 +78,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" }); @@ -90,84 +94,8 @@ export const userServiceFactory = ({ code }); - const { email } = user; - - await userDAL.transaction(async (tx) => { - await userDAL.updateById( - user.id, - { - isEmailVerified: true - }, - tx - ); - - // check if there are verified users with the same email. - const users = await userDAL.find( - { - email, - isEmailVerified: true - }, - { tx } - ); - - if (users.length > 1) { - // merge users - const mergeUser = users.find((u) => u.id !== user.id); - if (!mergeUser) throw new NotFoundError({ name: "Failed to find merge user" }); - - const mergeUserOrgMembershipSet = new Set( - (await orgMembershipDAL.find({ userId: mergeUser.id }, { tx })).map((m) => m.orgId) - ); - const myOrgMemberships = (await orgMembershipDAL.find({ userId: user.id }, { tx })).filter( - (m) => !mergeUserOrgMembershipSet.has(m.orgId) - ); - - const userAliases = await userAliasDAL.find( - { - userId: user.id - }, - { tx } - ); - await userDAL.deleteById(user.id, tx); - - if (myOrgMemberships.length) { - await orgMembershipDAL.insertMany( - myOrgMemberships.map((orgMembership) => ({ - ...orgMembership, - userId: mergeUser.id - })), - tx - ); - } - - if (userAliases.length) { - await userAliasDAL.insertMany( - userAliases.map((userAlias) => ({ - ...userAlias, - userId: mergeUser.id - })), - tx - ); - } - } else { - await userDAL.delete( - { - email, - isAccepted: false, - isEmailVerified: false - }, - tx - ); - - // update current user's username to [email] - await userDAL.updateById( - user.id, - { - username: email - }, - tx - ); - } + await userDAL.updateById(user.id, { + isEmailVerified: true }); }; @@ -212,6 +140,23 @@ export const userServiceFactory = ({ return updatedUser; }; + const getAllMyAccounts = async (email: string, userId: string) => { + const users = await userDAL.findAllMyAccounts(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.email || myAccount.username).toLowerCase() }, tx); + }); + } + }; + 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 +258,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 +293,9 @@ export const userServiceFactory = ({ getUserAction, unlockUser, getUserPrivateKey, + getAllMyAccounts, getUserProjectFavorites, + removeMyDuplicateAccounts, updateUserProjectFavorites }; }; diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index 0774275f9..715c3532d 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -1,6 +1,7 @@ export { useAddUserToWsE2EE, useAddUserToWsNonE2EE, + useRemoveMyDuplicateAccounts, useRevokeMySessionById, useSendEmailVerificationCode, useVerifyEmailVerificationCode @@ -14,6 +15,7 @@ export { useDeleteOrgMembership, useGetMyAPIKeys, useGetMyAPIKeysV2, + useGetMyDuplicateAccount, useGetMyIp, useGetMyOrganizationProjects, useGetMySessions, diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index 1b873b31c..ee274ab31 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -184,3 +184,12 @@ export const useRevokeMySessionById = () => { } }); }; + +export const useRemoveMyDuplicateAccounts = () => { + return useMutation({ + mutationFn: async () => { + const { data } = await apiRequest.post("/api/v1/user/remove-duplicate-accounts"); + return data; + } + }); +}; diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index 06cde4d34..ea451db02 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -37,6 +37,33 @@ export const useGetUser = () => queryFn: fetchUserDetails }); +export const fetchUserDuplicateAccounts = async () => { + const { data } = await apiRequest.get<{ + users: Array< + User & { + isMyAccount: boolean; + organizations: { name: string; slug: string }[]; + devices: { + ip: string; + userAgent: string; + }[]; + } + >; + }>("/api/v1/user/duplicate-accounts"); + return data.users; +}; + +export const useGetMyDuplicateAccount = () => + useQuery({ + queryKey: userKeys.getMyDuplicateAccount, + staleTime: 60 * 1000, // 1 min in ms + queryFn: fetchUserDuplicateAccounts, + select: (users) => ({ + duplicateAccounts: users.filter((el) => !el.isMyAccount), + myAccount: users?.find((el) => el.isMyAccount) + }) + }); + export const useDeleteMe = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/api/users/query-keys.tsx b/frontend/src/hooks/api/users/query-keys.tsx index 34d969b49..aacbb73b9 100644 --- a/frontend/src/hooks/api/users/query-keys.tsx +++ b/frontend/src/hooks/api/users/query-keys.tsx @@ -1,5 +1,6 @@ export const userKeys = { getUser: ["user"] as const, + getMyDuplicateAccount: ["user-duplicate-account"] as const, getPrivateKey: ["user"] as const, userAction: ["user-action"] as const, userProjectFavorites: (orgId: string) => [{ orgId }, "user-project-favorites"] as const, diff --git a/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx b/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx index 529509b49..91432f9be 100644 --- a/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx +++ b/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx @@ -18,7 +18,8 @@ import { useToggle } from "@app/hooks"; import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api"; import { MfaMethod } from "@app/hooks/api/auth/types"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; -import { fetchMyPrivateKey } from "@app/hooks/api/users/queries"; +import { fetchMyPrivateKey, fetchUserDuplicateAccounts } from "@app/hooks/api/users/queries"; +import { EmailDuplicationConfirmation } from "@app/pages/auth/SelectOrgPage/EmailDuplicationConfirmation"; import { navigateUserToOrg, useNavigateToSelectOrganization } from "../../Login.utils"; @@ -40,6 +41,7 @@ export const PasswordStep = ({ const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation(); const navigate = useNavigate(); + const [removeDuplicateLater, setRemoveDuplicateLater] = useState(true); const { mutateAsync: selectOrganization } = useSelectOrganization(); const { mutateAsync: oauthTokenExchange } = useOauthTokenExchange(); const [shouldShowMfa, toggleShowMfa] = useToggle(false); @@ -109,6 +111,13 @@ export const PasswordStep = ({ return; } + const userDuplicateAccount = await fetchUserDuplicateAccounts(); + const hasDuplicate = userDuplicateAccount?.length > 1; + if (hasDuplicate) { + setRemoveDuplicateLater(false); + return; + } + await navigateUserToOrg(navigate, organizationId); }; @@ -306,6 +315,18 @@ export const PasswordStep = ({ ); } + if (!removeDuplicateLater) { + return ( + + navigateUserToOrg(navigate, organizationId).catch(() => + createNotification({ text: "Failed to navigate user", type: "error" }) + ) + } + /> + ); + } + if (hasExchangedPrivateKey) { return (
diff --git a/frontend/src/pages/auth/SelectOrgPage/EmailDuplicationConfirmation.tsx b/frontend/src/pages/auth/SelectOrgPage/EmailDuplicationConfirmation.tsx new file mode 100644 index 000000000..0347aee39 --- /dev/null +++ b/frontend/src/pages/auth/SelectOrgPage/EmailDuplicationConfirmation.tsx @@ -0,0 +1,164 @@ +import { useCallback } from "react"; +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { format } from "date-fns"; + +import { createNotification } from "@app/components/notifications"; +import { Button, DeleteActionModal, Tooltip } from "@app/components/v2"; +import { usePopUp } from "@app/hooks"; +import { + useGetMyDuplicateAccount, + useLogoutUser, + useRemoveMyDuplicateAccounts +} from "@app/hooks/api"; + +type Props = { + onRemoveDuplicateLater: () => void; +}; + +export const EmailDuplicationConfirmation = ({ onRemoveDuplicateLater }: Props) => { + const duplicateAccounts = useGetMyDuplicateAccount(); + const removeDuplicateEmails = useRemoveMyDuplicateAccounts(); + const { t } = useTranslation(); + const navigate = useNavigate(); + const logout = useLogoutUser(true); + const { popUp, handlePopUpToggle } = usePopUp(["removeDuplicateConfirm"] as const); + const handleLogout = useCallback(async () => { + try { + console.log("Logging out..."); + await logout.mutateAsync(); + navigate({ to: "/login" }); + } catch (error) { + console.error(error); + } + }, [logout, navigate]); + + return ( +
+ + {t("common.head-title", { title: t("login.title") })} + + + + + +
+ +
+ Infisical logo +
+ +
+
+

+ Multiple Accounts Detected +

+

+ You're currently logged in as{" "} + {duplicateAccounts?.data?.myAccount?.username}. +

+
+

+ We've detected multiple accounts using variations of the same email address. +

+
+
+
+ Your other accounts +
+
+ {duplicateAccounts?.data?.duplicateAccounts?.map((el) => { + const lastSession = el.devices?.at(-1); + return ( +
+
+
{el.username}
+
+ Last logged in at {format(new Date(el.updatedAt), "Pp")} +
+
+ Organizations: {el?.organizations?.map((i) => i.slug)?.join(",")} +
+
+
+ +
IP: {lastSession?.ip || "-"}
+
User Agent: {lastSession?.userAgent || "-"}
+
+ } + > + + +
+
+ ); + })} +
+
+
+ + +
+ +
+ +
+
+ handlePopUpToggle("removeDuplicateConfirm", isOpen)} + deleteKey="remove" + buttonText="Confirm" + onDeleteApproved={() => + removeDuplicateEmails.mutateAsync(undefined, { + onSuccess: () => { + createNotification({ + type: "success", + text: "Removed duplicate accounts" + }); + onRemoveDuplicateLater(); + } + }) + } + /> +
+ ); +}; diff --git a/frontend/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx b/frontend/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx index 7dddd1a4b..f66be2d4b 100644 --- a/frontend/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx @@ -1,33 +1,10 @@ -import { useCallback, useEffect, useState } from "react"; -import { Helmet } from "react-helmet"; -import { useTranslation } from "react-i18next"; -import { faArrowRight } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link, useNavigate } from "@tanstack/react-router"; -import axios from "axios"; -import { addSeconds, formatISO } from "date-fns"; -import { jwtDecode } from "jwt-decode"; +import { useState } from "react"; -import { Mfa } from "@app/components/auth/Mfa"; -import { createNotification } from "@app/components/notifications"; -import { IsCliLoginSuccessful } from "@app/components/utilities/attemptCliLogin"; -import SecurityClient from "@app/components/utilities/SecurityClient"; -import { Button, Spinner } from "@app/components/v2"; -import { SessionStorageKeys } from "@app/const"; -import { OrgMembershipRole } from "@app/helpers/roles"; -import { useToggle } from "@app/hooks"; -import { - useGetOrganizations, - useGetUser, - useLogoutUser, - useSelectOrganization -} from "@app/hooks/api"; -import { MfaMethod, UserAgentType } from "@app/hooks/api/auth/types"; -import { getAuthToken, isLoggedIn } from "@app/hooks/api/reactQuery"; -import { Organization } from "@app/hooks/api/types"; -import { AuthMethod } from "@app/hooks/api/users/types"; +import { Spinner } from "@app/components/v2"; +import { useGetMyDuplicateAccount } from "@app/hooks/api"; -import { navigateUserToOrg } from "../LoginPage/Login.utils"; +import { EmailDuplicationConfirmation } from "./EmailDuplicationConfirmation"; +import { SelectOrganizationSection } from "./SelectOrgSection"; const LoadingScreen = () => { return ( @@ -39,253 +16,18 @@ const LoadingScreen = () => { }; export const SelectOrganizationPage = () => { - const navigate = useNavigate(); - const { t } = useTranslation(); + const duplicateAccounts = useGetMyDuplicateAccount(); + const [removeDuplicateLater, setRemoveDuplicateLater] = useState(false); - const organizations = useGetOrganizations(); - const selectOrg = useSelectOrganization(); - const { data: user, isPending: userLoading } = useGetUser(); - const [shouldShowMfa, toggleShowMfa] = useToggle(false); - const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL); - const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true); - - const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); - - const queryParams = new URLSearchParams(window.location.search); - const orgId = queryParams.get("org_id"); - const callbackPort = queryParams.get("callback_port"); - const isAdminLogin = queryParams.get("is_admin_login") === "true"; - const defaultSelectedOrg = organizations.data?.find((org) => org.id === orgId); - - const logout = useLogoutUser(true); - const handleLogout = useCallback(async () => { - try { - console.log("Logging out..."); - await logout.mutateAsync(); - navigate({ to: "/login" }); - } catch (error) { - console.error(error); - } - }, [logout, navigate]); - - const handleSelectOrganization = useCallback( - async (organization: Organization) => { - const canBypassOrgAuth = - organization.bypassOrgAuthEnabled && - organization.userRole === OrgMembershipRole.Admin && - isAdminLogin; - - if (organization.authEnforced && !canBypassOrgAuth) { - // org has an org-level auth method enabled (e.g. SAML) - // -> logout + redirect to SAML SSO - await logout.mutateAsync(); - let url = ""; - if (organization.orgAuthMethod === AuthMethod.OIDC) { - url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${ - callbackPort ? `&callbackPort=${callbackPort}` : "" - }`; - } else { - url = `/api/v1/sso/redirect/saml2/organizations/${organization.slug}`; - - if (callbackPort) { - url += `?callback_port=${callbackPort}`; - } - } - - window.location.href = url; - return; - } - - const { token, isMfaEnabled, mfaMethod } = await selectOrg - .mutateAsync({ - organizationId: organization.id, - userAgent: callbackPort ? UserAgentType.CLI : undefined - }) - .finally(() => setIsInitialOrgCheckLoading(false)); - - if (isMfaEnabled) { - SecurityClient.setMfaToken(token); - if (mfaMethod) { - setRequiredMfaMethod(mfaMethod); - } - toggleShowMfa.on(); - setMfaSuccessCallback(() => () => handleSelectOrganization(organization)); - return; - } - - if (callbackPort) { - const privateKey = localStorage.getItem("PRIVATE_KEY"); - - let error: string | null = null; - - if (!privateKey) error = "Private key not found"; - if (!user?.email) error = "User email not found"; - if (!token) error = "No token found"; - - if (error) { - createNotification({ - text: error, - type: "error" - }); - return; - } - - const payload = { - JTWToken: token, - email: user?.email, - privateKey - } as IsCliLoginSuccessful["loginResponse"]; - - // send request to server endpoint - const instance = axios.create(); - await instance.post(`http://127.0.0.1:${callbackPort}/`, payload).catch(() => { - // if error happens to communicate we set the token with an expiry in sessino storage - // the cli-redirect page has logic to show this to user and ask them to paste it in terminal - sessionStorage.setItem( - SessionStorageKeys.CLI_TERMINAL_TOKEN, - JSON.stringify({ - expiry: formatISO(addSeconds(new Date(), 30)), - data: window.btoa(JSON.stringify(payload)) - }) - ); - }); - navigate({ to: "/cli-redirect" }); - // cli page - } else { - navigateUserToOrg(navigate, organization.id); - } - }, - [selectOrg] - ); - - const handleCliRedirect = useCallback(() => { - const authToken = getAuthToken(); - - if (authToken && !callbackPort) { - const decodedJwt = jwtDecode(authToken) as any; - - if (decodedJwt?.organizationId) { - navigateUserToOrg(navigate, decodedJwt.organizationId); - } - } - - if (!isLoggedIn()) { - navigate({ to: "/login" }); - } - }, []); - - useEffect(() => { - if (callbackPort) { - handleCliRedirect(); - } - }, [navigate]); - - useEffect(() => { - if (organizations.isPending || !organizations.data) return; - - // Case: User has no organizations. - // This can happen if the user was previously a member, but the organization was deleted or the user was removed. - if (organizations.data.length === 0) { - navigate({ to: "/organization/none" }); - } else if (organizations.data.length === 1) { - if (callbackPort) { - handleCliRedirect(); - setIsInitialOrgCheckLoading(false); - } else { - handleSelectOrganization(organizations.data[0]); - } - } else { - setIsInitialOrgCheckLoading(false); - } - }, [organizations.isPending, organizations.data]); - - useEffect(() => { - if (defaultSelectedOrg) { - handleSelectOrganization(defaultSelectedOrg); - } - }, [defaultSelectedOrg]); - - if ( - userLoading || - !user || - ((isInitialOrgCheckLoading || defaultSelectedOrg) && !shouldShowMfa) - ) { + if (duplicateAccounts.isPending) { return ; } - return ( -
- - {t("common.head-title", { title: t("login.title") })} - - - - - - {shouldShowMfa ? ( - - ) : ( -
- -
- Infisical logo -
- -
-
-

- Choose your organization -

+ if (duplicateAccounts.data?.duplicateAccounts?.length && !removeDuplicateLater) { + return ( + setRemoveDuplicateLater(true)} /> + ); + } -
-

- You‘re currently logged in as {user.username} -

-

- Not you?{" "} - -

-
-
-
- {organizations.isPending ? ( - - ) : ( - organizations.data?.map((org) => ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -
handleSelectOrganization(org)} - key={org.id} - className="group flex cursor-pointer items-center justify-between rounded-md bg-mineshaft-700 px-4 py-3 capitalize text-gray-200 shadow-md transition-colors hover:bg-mineshaft-600" - > -

{org.name}

- - -
- )) - )} -
-
-
- )} - -
-
- ); + return ; }; diff --git a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx new file mode 100644 index 000000000..2ca179c87 --- /dev/null +++ b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx @@ -0,0 +1,289 @@ +import { useCallback, useEffect, useState } from "react"; +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; +import { faArrowRight } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link, useNavigate } from "@tanstack/react-router"; +import axios from "axios"; +import { addSeconds, formatISO } from "date-fns"; +import { jwtDecode } from "jwt-decode"; + +import { Mfa } from "@app/components/auth/Mfa"; +import { createNotification } from "@app/components/notifications"; +import { IsCliLoginSuccessful } from "@app/components/utilities/attemptCliLogin"; +import SecurityClient from "@app/components/utilities/SecurityClient"; +import { Button, Spinner } from "@app/components/v2"; +import { SessionStorageKeys } from "@app/const"; +import { OrgMembershipRole } from "@app/helpers/roles"; +import { useToggle } from "@app/hooks"; +import { + useGetOrganizations, + useGetUser, + useLogoutUser, + useSelectOrganization +} from "@app/hooks/api"; +import { MfaMethod, UserAgentType } from "@app/hooks/api/auth/types"; +import { getAuthToken, isLoggedIn } from "@app/hooks/api/reactQuery"; +import { Organization } from "@app/hooks/api/types"; +import { AuthMethod } from "@app/hooks/api/users/types"; + +import { navigateUserToOrg } from "../LoginPage/Login.utils"; + +const LoadingScreen = () => { + return ( +
+ +

Loading, please wait

+
+ ); +}; + +export const SelectOrganizationSection = () => { + const navigate = useNavigate(); + const { t } = useTranslation(); + + const organizations = useGetOrganizations(); + const selectOrg = useSelectOrganization(); + const { data: user, isPending: userLoading } = useGetUser(); + const [shouldShowMfa, toggleShowMfa] = useToggle(false); + const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL); + const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true); + + const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); + + const queryParams = new URLSearchParams(window.location.search); + const orgId = queryParams.get("org_id"); + const callbackPort = queryParams.get("callback_port"); + const isAdminLogin = queryParams.get("is_admin_login") === "true"; + const defaultSelectedOrg = organizations.data?.find((org) => org.id === orgId); + + const logout = useLogoutUser(true); + const handleLogout = useCallback(async () => { + try { + console.log("Logging out..."); + await logout.mutateAsync(); + navigate({ to: "/login" }); + } catch (error) { + console.error(error); + } + }, [logout, navigate]); + + const handleSelectOrganization = useCallback( + async (organization: Organization) => { + const canBypassOrgAuth = + organization.bypassOrgAuthEnabled && + organization.userRole === OrgMembershipRole.Admin && + isAdminLogin; + + if (organization.authEnforced && !canBypassOrgAuth) { + // org has an org-level auth method enabled (e.g. SAML) + // -> logout + redirect to SAML SSO + await logout.mutateAsync(); + let url = ""; + if (organization.orgAuthMethod === AuthMethod.OIDC) { + url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${ + callbackPort ? `&callbackPort=${callbackPort}` : "" + }`; + } else { + url = `/api/v1/sso/redirect/saml2/organizations/${organization.slug}`; + + if (callbackPort) { + url += `?callback_port=${callbackPort}`; + } + } + + window.location.href = url; + return; + } + + const { token, isMfaEnabled, mfaMethod } = await selectOrg + .mutateAsync({ + organizationId: organization.id, + userAgent: callbackPort ? UserAgentType.CLI : undefined + }) + .finally(() => setIsInitialOrgCheckLoading(false)); + + if (isMfaEnabled) { + SecurityClient.setMfaToken(token); + if (mfaMethod) { + setRequiredMfaMethod(mfaMethod); + } + toggleShowMfa.on(); + setMfaSuccessCallback(() => () => handleSelectOrganization(organization)); + return; + } + + if (callbackPort) { + const privateKey = localStorage.getItem("PRIVATE_KEY"); + + let error: string | null = null; + + if (!privateKey) error = "Private key not found"; + if (!user?.email) error = "User email not found"; + if (!token) error = "No token found"; + + if (error) { + createNotification({ + text: error, + type: "error" + }); + return; + } + + const payload = { + JTWToken: token, + email: user?.email, + privateKey + } as IsCliLoginSuccessful["loginResponse"]; + + // send request to server endpoint + const instance = axios.create(); + await instance.post(`http://127.0.0.1:${callbackPort}/`, payload).catch(() => { + // if error happens to communicate we set the token with an expiry in sessino storage + // the cli-redirect page has logic to show this to user and ask them to paste it in terminal + sessionStorage.setItem( + SessionStorageKeys.CLI_TERMINAL_TOKEN, + JSON.stringify({ + expiry: formatISO(addSeconds(new Date(), 30)), + data: window.btoa(JSON.stringify(payload)) + }) + ); + }); + navigate({ to: "/cli-redirect" }); + // cli page + } else { + navigateUserToOrg(navigate, organization.id); + } + }, + [selectOrg] + ); + + const handleCliRedirect = useCallback(() => { + const authToken = getAuthToken(); + + if (authToken && !callbackPort) { + const decodedJwt = jwtDecode(authToken) as any; + + if (decodedJwt?.organizationId) { + navigateUserToOrg(navigate, decodedJwt.organizationId); + } + } + + if (!isLoggedIn()) { + navigate({ to: "/login" }); + } + }, []); + + useEffect(() => { + if (callbackPort) { + handleCliRedirect(); + } + }, [navigate]); + + useEffect(() => { + if (organizations.isPending || !organizations.data) return; + + // Case: User has no organizations. + // This can happen if the user was previously a member, but the organization was deleted or the user was removed. + if (organizations.data.length === 0) { + navigate({ to: "/organization/none" }); + } else if (organizations.data.length === 1) { + if (callbackPort) { + handleCliRedirect(); + setIsInitialOrgCheckLoading(false); + } else { + handleSelectOrganization(organizations.data[0]); + } + } else { + setIsInitialOrgCheckLoading(false); + } + }, [organizations.isPending, organizations.data]); + + useEffect(() => { + if (defaultSelectedOrg) { + handleSelectOrganization(defaultSelectedOrg); + } + }, [defaultSelectedOrg]); + + if ( + userLoading || + !user || + ((isInitialOrgCheckLoading || defaultSelectedOrg) && !shouldShowMfa) + ) { + return ; + } + + return ( +
+ + {t("common.head-title", { title: t("login.title") })} + + + + + + {shouldShowMfa ? ( + + ) : ( +
+ +
+ Infisical logo +
+ +
+
+

+ Choose your organization +

+
+

+ You‘re currently logged in as {user.username} +

+

+ Not you?{" "} + +

+
+
+
+ {organizations.isPending ? ( + + ) : ( + organizations.data?.map((org) => ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
handleSelectOrganization(org)} + key={org.id} + className="group flex cursor-pointer items-center justify-between rounded-md bg-mineshaft-700 px-4 py-3 capitalize text-gray-200 shadow-md transition-colors hover:bg-mineshaft-600" + > +

{org.name}

+ + +
+ )) + )} +
+
+
+ )} +
+
+ ); +};