Allow users to change the email of their accounts

This commit is contained in:
Carlos Monastyrski
2025-09-09 16:02:52 -03:00
parent fe9ca7a3ee
commit 45ee9a60b2
16 changed files with 508 additions and 3 deletions
+1
View File
@@ -739,6 +739,7 @@ export const registerRoutes = async (
const userService = userServiceFactory({
userDAL,
orgDAL,
orgMembershipDAL,
tokenService,
permissionService,
@@ -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",
@@ -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));
@@ -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",
+138 -3
View File
@@ -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<TGroupProjectDALFactory, "findByUserId">;
orgDAL: Pick<TOrgDALFactory, "findById" | "find">;
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "find" | "insertMany" | "findOne" | "updateById">;
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser" | "validateTokenForUser">;
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser" | "validateTokenForUser" | "revokeAllMySessions">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find">;
smtpService: Pick<TSmtpService, "sendMail">;
permissionService: TPermissionServiceFactory;
userAliasDAL: Pick<TUserAliasDALFactory, "findOne" | "find" | "updateById">;
userAliasDAL: Pick<TUserAliasDALFactory, "findOne" | "find" | "updateById" | "delete">;
};
export type TUserServiceFactory = ReturnType<typeof userServiceFactory>;
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,
+5
View File
@@ -16,3 +16,8 @@ export type TUpdateUserMfaDTO = {
isMfaEnabled?: boolean;
selectedMfaMethod?: MfaMethod;
};
export type TUpdateUserEmailDTO = {
userId: string;
newEmail: string;
};