diff --git a/backend/src/db/migrations/20250911133926_add-auth-token-payload-column.ts b/backend/src/db/migrations/20250911133926_add-auth-token-payload-column.ts new file mode 100644 index 000000000..4a1e3f352 --- /dev/null +++ b/backend/src/db/migrations/20250911133926_add-auth-token-payload-column.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasPayloadCol = await knex.schema.hasColumn(TableName.AuthTokens, "payload"); + + if (!hasPayloadCol) { + await knex.schema.alterTable(TableName.AuthTokens, (t) => { + t.text("payload").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasPayloadCol = await knex.schema.hasColumn(TableName.AuthTokens, "payload"); + + if (hasPayloadCol) { + await knex.schema.alterTable(TableName.AuthTokens, (t) => { + t.dropColumn("payload"); + }); + } +} diff --git a/backend/src/db/schemas/auth-tokens.ts b/backend/src/db/schemas/auth-tokens.ts index 0d3e93219..396c06f13 100644 --- a/backend/src/db/schemas/auth-tokens.ts +++ b/backend/src/db/schemas/auth-tokens.ts @@ -18,7 +18,8 @@ export const AuthTokensSchema = z.object({ updatedAt: z.date(), userId: z.string().uuid().nullable().optional(), orgId: z.string().uuid().nullable().optional(), - aliasId: z.string().nullable().optional() + aliasId: z.string().nullable().optional(), + payload: z.string().nullable().optional() }); export type TAuthTokens = z.infer; diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index 4a529821a..397967fa2 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -131,7 +131,7 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", - url: "/me/email/otp", + url: "/me/email-change/otp", config: { rateLimit: smtpRateLimit({ keyGenerator: (req) => req.permission.id @@ -167,7 +167,7 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { schema: { body: z.object({ newEmail: z.string().email().trim(), - otpCode: z.string().trim().length(8) + otpCode: z.string().trim().length(6) }), response: { 200: z.object({ diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 40933cea4..613aa0766 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -37,9 +37,9 @@ export const getTokenConfig = (tokenType: TokenType) => { 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 + const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1)); + const triesLeft = 1; + const expiresAt = new Date(new Date().getTime() + 600000); return { token, triesLeft, expiresAt }; } case TokenType.TOKEN_EMAIL_MFA: { @@ -81,7 +81,7 @@ export const getTokenConfig = (tokenType: TokenType) => { }; export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAuthTokenServiceFactoryDep) => { - const createTokenForUser = async ({ type, userId, orgId, aliasId }: TCreateTokenForUserDTO) => { + const createTokenForUser = async ({ type, userId, orgId, aliasId, payload }: TCreateTokenForUserDTO) => { const { token, ...tkCfg } = getTokenConfig(type); const appCfg = getConfig(); const tokenHash = await crypto.hashing().createHash(token, appCfg.SALT_ROUNDS); @@ -95,7 +95,8 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu userId, orgId, triesLeft: tkCfg?.triesLeft, - aliasId + aliasId, + payload }, tx ); diff --git a/backend/src/services/auth-token/auth-token-types.ts b/backend/src/services/auth-token/auth-token-types.ts index 72604c710..3255fbbbc 100644 --- a/backend/src/services/auth-token/auth-token-types.ts +++ b/backend/src/services/auth-token/auth-token-types.ts @@ -16,6 +16,7 @@ export type TCreateTokenForUserDTO = { userId: string; orgId?: string; aliasId?: string; + payload?: string; }; export type TCreateOrgInviteTokenDTO = { diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 0a33aae33..7255788fe 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -217,17 +217,18 @@ export const userServiceFactory = ({ 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 + // Don't reveal that email is taken - just don't send OTP. + await new Promise((resolve) => { + setTimeout(resolve, 2000); + }); return { success: true, message: "Verification code sent to new email address" }; } - // Generate 8-digit OTP and store newEmail in aliasId field temporarily + // Generate 6-digit OTP 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() + payload: newEmail.toLowerCase() }); // Send OTP to NEW email address @@ -271,18 +272,16 @@ export const userServiceFactory = ({ 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; + // Verify the new email matches what was stored in payload + const tokenNewEmail = tokenData?.payload; 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) + // Final check if another user has this email const existingUsers = await userDAL.findUserByUsername(newEmail.toLowerCase(), tx); const existingUser = existingUsers?.find((u) => u.id !== userId); if (existingUser) { @@ -292,13 +291,11 @@ export const userServiceFactory = ({ // 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 + username: newEmail.toLowerCase() }, tx ); diff --git a/docs/documentation/platform/auth-methods/email-password.mdx b/docs/documentation/platform/auth-methods/email-password.mdx index 8e48430b2..f5cb0e6f5 100644 --- a/docs/documentation/platform/auth-methods/email-password.mdx +++ b/docs/documentation/platform/auth-methods/email-password.mdx @@ -30,8 +30,8 @@ You can update your account email address: 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. +4. Click `Send Verification Code` to receive an 6-digit verification code at your new email address. +5. Check your new email inbox and enter the verification code. ![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. 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 index 07d74cd83..1a5986c3c 100644 Binary files a/docs/images/auth-methods/personal-settings-authentication-change-email-confirmation.png and b/docs/images/auth-methods/personal-settings-authentication-change-email-confirmation.png differ diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index b7eb1e218..1f3fdf3ad 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -156,7 +156,7 @@ export const useRemoveMyDuplicateAccounts = () => { export const useRequestEmailChangeOTP = () => { return useMutation({ mutationFn: async ({ newEmail }: { newEmail: string }) => { - const { data } = await apiRequest.post("/api/v2/users/me/email/otp", { + const { data } = await apiRequest.post("/api/v2/users/me/email-change/otp", { newEmail }); return data; diff --git a/frontend/src/pages/user/PersonalSettingsPage/components/ChangeEmailSection/ChangeEmailSection.tsx b/frontend/src/pages/user/PersonalSettingsPage/components/ChangeEmailSection/ChangeEmailSection.tsx index 92480d5bd..723ed7498 100644 --- a/frontend/src/pages/user/PersonalSettingsPage/components/ChangeEmailSection/ChangeEmailSection.tsx +++ b/frontend/src/pages/user/PersonalSettingsPage/components/ChangeEmailSection/ChangeEmailSection.tsx @@ -103,9 +103,9 @@ export const ChangeEmailSection = () => { const [typedOTP, setTypedOTP] = useState(""); const handleOTPSubmit = async () => { - if (typedOTP.length !== 8) { + if (typedOTP.length !== 6) { createNotification({ - text: "Please enter the complete 8-digit verification code", + text: "Please enter the complete 6-digit verification code", type: "error" }); return; @@ -197,7 +197,7 @@ export const ChangeEmailSection = () => { Send Verification Code

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

@@ -210,7 +210,7 @@ export const ChangeEmailSection = () => { >
@@ -218,7 +218,7 @@ export const ChangeEmailSection = () => { name="otp-input" inputMode="tel" type="text" - fields={8} + fields={6} onChange={setTypedOTP} value={typedOTP} {...otpInputProps} @@ -232,7 +232,7 @@ export const ChangeEmailSection = () => {