diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 64c9b3389..c652b1aca 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -739,6 +739,7 @@ export const registerRoutes = async ( const userService = userServiceFactory({ userDAL, + orgDAL, orgMembershipDAL, tokenService, permissionService, diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index f416fe8bb..4a529821a 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -129,6 +129,63 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/me/email/otp", + config: { + rateLimit: smtpRateLimit({ + keyGenerator: (req) => req.permission.id + }) + }, + schema: { + body: z.object({ + newEmail: z.string().email().trim() + }), + response: { + 200: z.object({ + success: z.boolean(), + message: z.string() + }) + } + }, + preHandler: verifyAuth([AuthMode.JWT], { requireOrg: false }), + handler: async (req) => { + const result = await server.services.user.requestEmailChangeOTP({ + userId: req.permission.id, + newEmail: req.body.newEmail + }); + return result; + } + }); + + server.route({ + method: "PATCH", + url: "/me/email", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + newEmail: z.string().email().trim(), + otpCode: z.string().trim().length(8) + }), + response: { + 200: z.object({ + user: UsersSchema + }) + } + }, + preHandler: verifyAuth([AuthMode.JWT], { requireOrg: false }), + handler: async (req) => { + const user = await server.services.user.updateUserEmail({ + userId: req.permission.id, + newEmail: req.body.newEmail, + otpCode: req.body.otpCode + }); + return { user }; + } + }); + server.route({ method: "GET", url: "/me/organizations", diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index c309b3998..40933cea4 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -36,6 +36,12 @@ export const getTokenConfig = (tokenType: TokenType) => { const expiresAt = new Date(new Date().getTime() + 86400000); return { token, triesLeft, expiresAt }; } + case TokenType.TOKEN_EMAIL_CHANGE_OTP: { + const token = String(crypto.randomInt(10 ** 7, 10 ** 8 - 1)); + const triesLeft = 3; + const expiresAt = new Date(new Date().getTime() + 600000); // 10 minutes expiry + return { token, triesLeft, expiresAt }; + } case TokenType.TOKEN_EMAIL_MFA: { // generate random 6-digit code const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1)); diff --git a/backend/src/services/auth-token/auth-token-types.ts b/backend/src/services/auth-token/auth-token-types.ts index 7deb719a9..72604c710 100644 --- a/backend/src/services/auth-token/auth-token-types.ts +++ b/backend/src/services/auth-token/auth-token-types.ts @@ -3,6 +3,7 @@ import { ProjectMembershipRole } from "@app/db/schemas"; export enum TokenType { TOKEN_EMAIL_CONFIRMATION = "emailConfirmation", TOKEN_EMAIL_VERIFICATION = "emailVerification", // unverified -> verified + TOKEN_EMAIL_CHANGE_OTP = "emailChangeOtp", TOKEN_EMAIL_MFA = "emailMfa", TOKEN_EMAIL_ORG_INVITATION = "organizationInvitation", TOKEN_EMAIL_PASSWORD_RESET = "passwordReset", diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index b0d7be0fe..0a33aae33 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -1,4 +1,5 @@ import { ForbiddenError } from "@casl/ability"; +import { Knex } from "knex"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; @@ -7,6 +8,7 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/ import { logger } from "@app/lib/logger"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { TokenType } from "@app/services/auth-token/auth-token-types"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; @@ -15,7 +17,7 @@ import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; import { TUserDALFactory } from "./user-dal"; -import { TListUserGroupsDTO, TUpdateUserMfaDTO } from "./user-types"; +import { TListUserGroupsDTO, TUpdateUserEmailDTO, TUpdateUserMfaDTO } from "./user-types"; type TUserServiceFactoryDep = { userDAL: Pick< @@ -34,18 +36,20 @@ type TUserServiceFactoryDep = { | "findAllMyAccounts" >; groupProjectDAL: Pick; + orgDAL: Pick; orgMembershipDAL: Pick; - tokenService: Pick; + tokenService: Pick; projectMembershipDAL: Pick; smtpService: Pick; permissionService: TPermissionServiceFactory; - userAliasDAL: Pick; + userAliasDAL: Pick; }; export type TUserServiceFactory = ReturnType; export const userServiceFactory = ({ userDAL, + orgDAL, orgMembershipDAL, projectMembershipDAL, groupProjectDAL, @@ -178,6 +182,135 @@ export const userServiceFactory = ({ return updatedUser; }; + const checkUserScimRestriction = async (userId: string, tx?: Knex) => { + const userOrgs = await orgMembershipDAL.find({ userId }, { tx }); + + if (userOrgs.length === 0) { + return false; + } + + const orgIds = userOrgs.map((membership) => membership.orgId); + const organizations = await orgDAL.find({ $in: { id: orgIds } }, { tx }); + + return organizations.some((org) => org.scimEnabled); + }; + + const requestEmailChangeOTP = async ({ userId, newEmail }: TUpdateUserEmailDTO) => { + const changeEmailOTP = await userDAL.transaction(async (tx) => { + const user = await userDAL.findById(userId, tx); + if (!user) + throw new NotFoundError({ message: `User with ID '${userId}' not found`, name: "RequestEmailChangeOTP" }); + + if (user.authMethods?.includes(AuthMethod.LDAP)) { + throw new BadRequestError({ message: "Cannot update email for LDAP users", name: "RequestEmailChangeOTP" }); + } + + const hasScimRestriction = await checkUserScimRestriction(userId, tx); + if (hasScimRestriction) { + throw new BadRequestError({ + message: "Email changes are disabled because SCIM is enabled for one or more of your organizations", + name: "RequestEmailChangeOTP" + }); + } + + // Silently check if another user already has this email - don't send OTP if email is taken + const existingUsers = await userDAL.findUserByUsername(newEmail.toLowerCase(), tx); + const existingUser = existingUsers?.find((u) => u.id !== userId); + if (existingUser) { + // Don't reveal that email is taken - just don't send OTP + // Frontend will show generic "check your email" message + return { success: true, message: "Verification code sent to new email address" }; + } + + // Generate 8-digit OTP and store newEmail in aliasId field temporarily + const otpCode = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_CHANGE_OTP, + userId, + // Use aliasId to store the new email (we'll parse this back later) + aliasId: newEmail.toLowerCase() + }); + + // Send OTP to NEW email address + await smtpService.sendMail({ + template: SmtpTemplates.EmailVerification, + subjectLine: "Infisical email change verification", + recipients: [newEmail.toLowerCase()], + substitutions: { + code: otpCode + } + }); + + return { success: true, message: "Verification code sent to new email address" }; + }); + return changeEmailOTP; + }; + + const updateUserEmail = async ({ userId, newEmail, otpCode }: TUpdateUserEmailDTO & { otpCode: string }) => { + const changedUser = await userDAL.transaction(async (tx) => { + const user = await userDAL.findById(userId, tx); + if (!user) throw new NotFoundError({ message: `User with ID '${userId}' not found`, name: "UpdateUserEmail" }); + + if (user.authMethods?.includes(AuthMethod.LDAP)) { + throw new BadRequestError({ message: "Cannot update email for LDAP users", name: "UpdateUserEmail" }); + } + + const hasScimRestriction = await checkUserScimRestriction(userId, tx); + if (hasScimRestriction) { + throw new BadRequestError({ + message: "Email changes are disabled because SCIM is enabled for one or more of your organizations", + name: "UpdateUserEmail" + }); + } + + // Validate OTP and get the new email from token aliasId field + let tokenData; + try { + tokenData = await tokenService.validateTokenForUser({ + type: TokenType.TOKEN_EMAIL_CHANGE_OTP, + userId, + code: otpCode + }); + } catch (error) { + // For security reasons, always return "Invalid verification code" regardless of the actual error + // This prevents information disclosure about existing emails + throw new BadRequestError({ message: "Invalid verification code", name: "UpdateUserEmail" }); + } + + // Verify the new email matches what was stored in aliasId + const tokenNewEmail = tokenData?.aliasId; + if (!tokenNewEmail || tokenNewEmail !== newEmail.toLowerCase()) { + throw new BadRequestError({ message: "Invalid verification code", name: "UpdateUserEmail" }); + } + + // Final check if another user has this email (in case it was taken between OTP request and verification) + const existingUsers = await userDAL.findUserByUsername(newEmail.toLowerCase(), tx); + const existingUser = existingUsers?.find((u) => u.id !== userId); + if (existingUser) { + throw new BadRequestError({ message: "Email is no longer available", name: "UpdateUserEmail" }); + } + + // Delete all user aliases since the email is changing + await userAliasDAL.delete({ userId }, tx); + + // Update the user's email and KEEP email as verified (as requested) + const updatedUser = await userDAL.updateById( + userId, + { + email: newEmail.toLowerCase(), + username: newEmail.toLowerCase(), + isEmailVerified: true // Keep verified as per requirement + }, + tx + ); + + // Revoke all sessions to force re-login + await tokenService.revokeAllMySessions(userId); + + return updatedUser; + }); + return changedUser; + }; + const getAllMyAccounts = async (email: string, userId: string) => { const users = await userDAL.findAllMyAccounts(email); return users?.map((el) => ({ ...el, isMyAccount: el.id === userId })); @@ -313,6 +446,8 @@ export const userServiceFactory = ({ updateUserMfa, updateUserName, updateAuthMethods, + requestEmailChangeOTP, + updateUserEmail, deleteUser, getMe, createUserAction, diff --git a/backend/src/services/user/user-types.ts b/backend/src/services/user/user-types.ts index cef13f27a..7a974a899 100644 --- a/backend/src/services/user/user-types.ts +++ b/backend/src/services/user/user-types.ts @@ -16,3 +16,8 @@ export type TUpdateUserMfaDTO = { isMfaEnabled?: boolean; selectedMfaMethod?: MfaMethod; }; + +export type TUpdateUserEmailDTO = { + userId: string; + newEmail: string; +}; diff --git a/docs/documentation/platform/auth-methods/email-password.mdx b/docs/documentation/platform/auth-methods/email-password.mdx index db23026b8..8e48430b2 100644 --- a/docs/documentation/platform/auth-methods/email-password.mdx +++ b/docs/documentation/platform/auth-methods/email-password.mdx @@ -7,8 +7,39 @@ description: "Learn how to authenticate into Infisical with email and password." It is currently possible to use the **Email and Password** auth method to authenticate into the Web Dashboard and Infisical CLI. +### Emergency Kit Every **Email and Password** is accompanied by an emergency kit given to users during signup. If the password is lost or forgotten, emergency kit is only way to retrieve the access to your account. It is possible to generate a new emergency kit with the following steps: 1. Open the `Personal Settings` menu. ![open personal settings](../../../images/auth-methods/access-personal-settings.png) 2. Scroll down to the `Emergency Kit` section. 3. Enter your current password and click `Save`. + +### Change Password +You can update your account password at any time: +1. Open the `Personal Settings` menu. +![open personal settings](../../../images/auth-methods/access-personal-settings.png) +2. Navigate to the `Authentication` tab. +![open authentication tab](../../../images/auth-methods/personal-settings-authentication-tab.png) +3. In the `Change Password` section, enter your current password and new password. +![change password section](../../../images/auth-methods/personal-settings-authentication-change-email-password.png) +4. Click `Save` to save your new password. + +### Change Email +You can update your account email address: +1. Open the `Personal Settings` menu. +2. Navigate to the `Authentication` tab. +3. In the `Change Email` section, enter your new email address. +![change email section](../../../images/auth-methods/personal-settings-authentication-change-email-password.png) +4. Click `Send Verification Code` to receive an 8-digit verification code at your new email address. +5. Check your new email inbox and enter the verification code in the form. +![change email section](../../../images/auth-methods/personal-settings-authentication-change-email-confirmation.png) +6. Click `Confirm Email Change` to complete the process. +7. You will be logged out and need to sign in again with your new email address. + + +Changing your email will remove all connected external authentication methods and terminate all active sessions for security. + + + +Email changes are disabled if SCIM is enabled for any of your organizations. Contact your organization administrator if you need to change your email address in a SCIM-enabled environment. + \ No newline at end of file diff --git a/docs/images/auth-methods/access-personal-settings.png b/docs/images/auth-methods/access-personal-settings.png index a5e1989c1..31f6f96c7 100644 Binary files a/docs/images/auth-methods/access-personal-settings.png and b/docs/images/auth-methods/access-personal-settings.png differ diff --git a/docs/images/auth-methods/personal-settings-authentication-change-email-confirmation.png b/docs/images/auth-methods/personal-settings-authentication-change-email-confirmation.png new file mode 100644 index 000000000..07d74cd83 Binary files /dev/null and b/docs/images/auth-methods/personal-settings-authentication-change-email-confirmation.png differ diff --git a/docs/images/auth-methods/personal-settings-authentication-change-email-password.png b/docs/images/auth-methods/personal-settings-authentication-change-email-password.png new file mode 100644 index 000000000..78945e286 Binary files /dev/null and b/docs/images/auth-methods/personal-settings-authentication-change-email-password.png differ diff --git a/docs/images/auth-methods/personal-settings-authentication-tab.png b/docs/images/auth-methods/personal-settings-authentication-tab.png new file mode 100644 index 000000000..5429e97cd Binary files /dev/null and b/docs/images/auth-methods/personal-settings-authentication-tab.png differ diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index d20a15bc8..36bccdb8a 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -1,8 +1,10 @@ export { useAddUserToWsNonE2EE, useRemoveMyDuplicateAccounts, + useRequestEmailChangeOTP, useRevokeMySessionById, useSendEmailVerificationCode, + useUpdateUserEmail, useVerifyEmailVerificationCode } from "./mutation"; export { diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index b108a98c4..b7eb1e218 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -152,3 +152,30 @@ export const useRemoveMyDuplicateAccounts = () => { } }); }; + +export const useRequestEmailChangeOTP = () => { + return useMutation({ + mutationFn: async ({ newEmail }: { newEmail: string }) => { + const { data } = await apiRequest.post("/api/v2/users/me/email/otp", { + newEmail + }); + return data; + } + }); +}; + +export const useUpdateUserEmail = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ newEmail, otpCode }: { newEmail: string; otpCode: string }) => { + const { data } = await apiRequest.patch("/api/v2/users/me/email", { + newEmail, + otpCode + }); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: userKeys.getUser }); + } + }); +}; diff --git a/frontend/src/pages/user/PersonalSettingsPage/components/ChangeEmailSection/ChangeEmailSection.tsx b/frontend/src/pages/user/PersonalSettingsPage/components/ChangeEmailSection/ChangeEmailSection.tsx new file mode 100644 index 000000000..93d110705 --- /dev/null +++ b/frontend/src/pages/user/PersonalSettingsPage/components/ChangeEmailSection/ChangeEmailSection.tsx @@ -0,0 +1,237 @@ +import { useState } from "react"; +import { Controller, useForm, useWatch } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useNavigate } from "@tanstack/react-router"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { useUser } from "@app/context"; +import { useRequestEmailChangeOTP, useUpdateUserEmail } from "@app/hooks/api/users"; +import { clearSession } from "@app/hooks/api/users/queries"; + +const emailSchema = z + .object({ + newEmail: z.string().email("Please enter a valid email") + }) + .required(); + +const otpSchema = z + .object({ + otpCode: z.string().length(8, "OTP code must be exactly 8 digits") + }) + .required(); + +export type EmailFormData = z.infer; +export type OTPFormData = z.infer; + +export const ChangeEmailSection = () => { + const navigate = useNavigate(); + const { user } = useUser(); + const [step, setStep] = useState<"email" | "otp">("email"); + const [pendingEmail, setPendingEmail] = useState(""); + + const emailForm = useForm({ + defaultValues: { newEmail: "" }, + resolver: zodResolver(emailSchema) + }); + + const otpForm = useForm({ + defaultValues: { otpCode: "" }, + resolver: zodResolver(otpSchema) + }); + + const { mutateAsync: requestEmailChangeOTP, isPending: isRequestingOTP } = + useRequestEmailChangeOTP(); + const { mutateAsync: updateUserEmail, isPending: isUpdatingEmail } = useUpdateUserEmail(); + + // Watch the email field to enable/disable the button + const watchedEmail = useWatch({ + control: emailForm.control, + name: "newEmail", + defaultValue: "" + }); + + // Helper function to check if email is valid + const isEmailValid = (email: string): boolean => { + try { + emailSchema.parse({ newEmail: email }); + return true; + } catch { + return false; + } + }; + + const handleEmailSubmit = async ({ newEmail }: EmailFormData) => { + if (newEmail.toLowerCase() === user?.email?.toLowerCase()) { + createNotification({ + text: "New email must be different from current email", + type: "error" + }); + return; + } + + try { + await requestEmailChangeOTP({ newEmail }); + setPendingEmail(newEmail); + setStep("otp"); + + createNotification({ + text: "Verification code sent to your new email address. Check your inbox!", + type: "success" + }); + } catch (err: any) { + console.error(err); + const errorMessage = err?.response?.data?.message || "Failed to send verification code"; + createNotification({ + text: errorMessage, + type: "error" + }); + } + }; + + const handleOTPSubmit = async ({ otpCode }: OTPFormData) => { + try { + await updateUserEmail({ newEmail: pendingEmail, otpCode }); + + createNotification({ + text: "Email updated successfully. You will be redirected to login.", + type: "success" + }); + + // Reset forms + emailForm.reset(); + otpForm.reset(); + setStep("email"); + setPendingEmail(""); + + // Clear frontend session/token to ensure proper logout + clearSession(true); + + // Redirect to login after a short delay + setTimeout(() => { + navigate({ to: "/login" }); + }, 2000); + } catch (err: any) { + console.error(err); + + const errorMessage = err?.response?.data?.message || "Invalid verification code"; + if (errorMessage.includes("Invalid verification code")) { + // Reset to email step so user must request new OTP + setStep("email"); + setPendingEmail(""); + emailForm.reset(); + otpForm.reset(); + + createNotification({ + text: "Invalid verification code. Please request a new one.", + type: "error" + }); + } else { + createNotification({ + text: errorMessage, + type: "error" + }); + } + } + }; + + return ( +
+

Change email

+ + {step === "email" ? ( +
+
+ ( + + + + )} + /> +
+ +

+ We'll send an 8-digit verification code to your new email address. +

+
+ ) : ( +
+
+

+ Enter the 8-digit verification code sent to: {pendingEmail} +

+
+ +
+
+ ( + + + + )} + /> +
+ +
+ + +
+
+ +

+ After confirming, you'll be logged out and need to sign in again. +

+
+ )} +
+ ); +}; diff --git a/frontend/src/pages/user/PersonalSettingsPage/components/ChangeEmailSection/index.tsx b/frontend/src/pages/user/PersonalSettingsPage/components/ChangeEmailSection/index.tsx new file mode 100644 index 000000000..5a8804bde --- /dev/null +++ b/frontend/src/pages/user/PersonalSettingsPage/components/ChangeEmailSection/index.tsx @@ -0,0 +1 @@ +export { ChangeEmailSection } from "./ChangeEmailSection"; diff --git a/frontend/src/pages/user/PersonalSettingsPage/components/PersonalAuthTab/PersonalAuthTab.tsx b/frontend/src/pages/user/PersonalSettingsPage/components/PersonalAuthTab/PersonalAuthTab.tsx index b87d87dbb..49060f166 100644 --- a/frontend/src/pages/user/PersonalSettingsPage/components/PersonalAuthTab/PersonalAuthTab.tsx +++ b/frontend/src/pages/user/PersonalSettingsPage/components/PersonalAuthTab/PersonalAuthTab.tsx @@ -2,6 +2,7 @@ import { useGetUser } from "@app/hooks/api"; import { AuthMethod } from "@app/hooks/api/users/types"; import { AuthMethodSection } from "../AuthMethodSection"; +import { ChangeEmailSection } from "../ChangeEmailSection"; import { ChangePasswordSection } from "../ChangePasswordSection"; import { MFASection } from "../SecuritySection"; @@ -16,6 +17,7 @@ export const PersonalAuthTab = () => { )} + ); };