mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge pull request #4508 from Infisical/feat/ENG-3666
Allow users to change the email of their accounts
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
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<void> {
|
||||
const hasPayloadCol = await knex.schema.hasColumn(TableName.AuthTokens, "payload");
|
||||
|
||||
if (hasPayloadCol) {
|
||||
await knex.schema.alterTable(TableName.AuthTokens, (t) => {
|
||||
t.dropColumn("payload");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<typeof AuthTokensSchema>;
|
||||
|
||||
@@ -749,6 +749,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-change/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(6)
|
||||
}),
|
||||
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 ** 5, 10 ** 6 - 1));
|
||||
const triesLeft = 1;
|
||||
const expiresAt = new Date(new Date().getTime() + 600000);
|
||||
return { token, triesLeft, expiresAt };
|
||||
}
|
||||
case TokenType.TOKEN_EMAIL_MFA: {
|
||||
// generate random 6-digit code
|
||||
const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1));
|
||||
@@ -75,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);
|
||||
@@ -89,7 +95,8 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu
|
||||
userId,
|
||||
orgId,
|
||||
triesLeft: tkCfg?.triesLeft,
|
||||
aliasId
|
||||
aliasId,
|
||||
payload
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
@@ -15,6 +16,7 @@ export type TCreateTokenForUserDTO = {
|
||||
userId: string;
|
||||
orgId?: string;
|
||||
aliasId?: string;
|
||||
payload?: string;
|
||||
};
|
||||
|
||||
export type TCreateOrgInviteTokenDTO = {
|
||||
|
||||
@@ -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 startTime = new Date();
|
||||
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) {
|
||||
// Generate 6-digit OTP
|
||||
const otpCode = await tokenService.createTokenForUser({
|
||||
type: TokenType.TOKEN_EMAIL_CHANGE_OTP,
|
||||
userId,
|
||||
payload: 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" };
|
||||
});
|
||||
// Force this function to have a minimum execution time of 2 seconds to avoid possible information disclosure about existing users
|
||||
const endTime = new Date();
|
||||
const timeDiff = endTime.getTime() - startTime.getTime();
|
||||
if (timeDiff < 2000) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 2000 - timeDiff);
|
||||
});
|
||||
}
|
||||
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: "You are part of an organization that has SCIM enabled, and email changes are not allowed",
|
||||
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) {
|
||||
throw new BadRequestError({ message: "Invalid verification code", name: "UpdateUserEmail" });
|
||||
}
|
||||
|
||||
// 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
|
||||
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);
|
||||
|
||||
const updatedUser = await userDAL.updateById(
|
||||
userId,
|
||||
{
|
||||
email: newEmail.toLowerCase(),
|
||||
username: newEmail.toLowerCase()
|
||||
},
|
||||
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,
|
||||
|
||||
@@ -16,3 +16,8 @@ export type TUpdateUserMfaDTO = {
|
||||
isMfaEnabled?: boolean;
|
||||
selectedMfaMethod?: MfaMethod;
|
||||
};
|
||||
|
||||
export type TUpdateUserEmailDTO = {
|
||||
userId: string;
|
||||
newEmail: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user