diff --git a/.infisicalignore b/.infisicalignore index 348f9e3277..d5cc9f15df 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -2,4 +2,5 @@ frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx:generic-api-key:206 frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:304 frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx:generic-api-key:206 -frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:292 \ No newline at end of file +frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:292 +docs/self-hosting/configuration/envars.mdx:generic-api-key:106 diff --git a/backend/src/db/migrations/20240506163405_trusted-saml-ldap-emails.ts b/backend/src/db/migrations/20240506163405_trusted-saml-ldap-emails.ts new file mode 100644 index 0000000000..63aa75ad8f --- /dev/null +++ b/backend/src/db/migrations/20240506163405_trusted-saml-ldap-emails.ts @@ -0,0 +1,47 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const isUsersTablePresent = await knex.schema.hasTable(TableName.Users); + if (isUsersTablePresent) { + await knex.schema.alterTable(TableName.Users, (t) => { + t.boolean("isEmailVerified"); + }); + } + + const isUserAliasTablePresent = await knex.schema.hasTable(TableName.UserAliases); + if (isUserAliasTablePresent) { + await knex.schema.alterTable(TableName.UserAliases, (t) => { + t.string("username").nullable().alter(); + }); + } + + const isSuperAdminTablePresent = await knex.schema.hasTable(TableName.SuperAdmin); + if (isSuperAdminTablePresent) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.boolean("trustSamlEmails").defaultTo(false); + t.boolean("trustLdapEmails").defaultTo(false); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.Users, "isEmailVerified")) { + await knex.schema.alterTable(TableName.Users, (t) => { + t.dropColumn("isEmailVerified"); + }); + } + + if (await knex.schema.hasColumn(TableName.SuperAdmin, "trustSamlEmails")) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.dropColumn("trustSamlEmails"); + }); + } + + if (await knex.schema.hasColumn(TableName.SuperAdmin, "trustLdapEmails")) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.dropColumn("trustLdapEmails"); + }); + } +} diff --git a/backend/src/db/schemas/super-admin.ts b/backend/src/db/schemas/super-admin.ts index 958fed0abf..417d4e05e6 100644 --- a/backend/src/db/schemas/super-admin.ts +++ b/backend/src/db/schemas/super-admin.ts @@ -14,7 +14,9 @@ export const SuperAdminSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), allowedSignUpDomain: z.string().nullable().optional(), - instanceId: z.string().uuid().default("00000000-0000-0000-0000-000000000000") + instanceId: z.string().uuid().default("00000000-0000-0000-0000-000000000000"), + trustSamlEmails: z.boolean().default(false).nullable().optional(), + trustLdapEmails: z.boolean().default(false).nullable().optional() }); export type TSuperAdmin = z.infer; diff --git a/backend/src/db/schemas/user-aliases.ts b/backend/src/db/schemas/user-aliases.ts index d8712fe751..14147abf8d 100644 --- a/backend/src/db/schemas/user-aliases.ts +++ b/backend/src/db/schemas/user-aliases.ts @@ -10,7 +10,7 @@ import { TImmutableDBKeys } from "./models"; export const UserAliasesSchema = z.object({ id: z.string().uuid(), userId: z.string().uuid(), - username: z.string(), + username: z.string().nullable().optional(), aliasType: z.string(), externalId: z.string(), emails: z.string().array().nullable().optional(), diff --git a/backend/src/db/schemas/users.ts b/backend/src/db/schemas/users.ts index 86ee2fb74e..3eee2683f0 100644 --- a/backend/src/db/schemas/users.ts +++ b/backend/src/db/schemas/users.ts @@ -21,7 +21,8 @@ export const UsersSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), isGhost: z.boolean().default(false), - username: z.string() + username: z.string(), + isEmailVerified: z.boolean().nullable().optional() }); export type TUsers = z.infer; diff --git a/backend/src/ee/routes/v1/ldap-router.ts b/backend/src/ee/routes/v1/ldap-router.ts index 6730e9101b..e146668c24 100644 --- a/backend/src/ee/routes/v1/ldap-router.ts +++ b/backend/src/ee/routes/v1/ldap-router.ts @@ -18,6 +18,7 @@ import { LdapConfigsSchema, LdapGroupMapsSchema } from "@app/db/schemas"; import { TLDAPConfig } from "@app/ee/services/ldap-config/ldap-config-types"; import { isValidLdapFilter, searchGroups } from "@app/ee/services/ldap-config/ldap-fns"; import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -52,6 +53,7 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { // eslint-disable-next-line async (req: IncomingMessage, user, cb) => { try { + if (!user.email) throw new BadRequestError({ message: "Invalid request. Missing email." }); const ldapConfig = (req as unknown as FastifyRequest).ldapConfig as TLDAPConfig; let groups: { dn: string; cn: string }[] | undefined; @@ -74,7 +76,7 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { username: user.uid, firstName: user.givenName ?? user.cn ?? "", lastName: user.sn ?? "", - emails: user.mail ? [user.mail] : [], + email: user.mail, groups, relayState: ((req as unknown as FastifyRequest).body as { RelayState?: string }).RelayState, orgId: (req as unknown as FastifyRequest).ldapConfig.organization diff --git a/backend/src/ee/routes/v1/saml-router.ts b/backend/src/ee/routes/v1/saml-router.ts index 6cae30f7a8..6001b8b6ec 100644 --- a/backend/src/ee/routes/v1/saml-router.ts +++ b/backend/src/ee/routes/v1/saml-router.ts @@ -102,12 +102,12 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { if (!profile) throw new BadRequestError({ message: "Missing profile" }); const email = profile?.email ?? (profile?.emailAddress as string); // emailRippling is added because in Rippling the field `email` reserved - if (!profile.email || !profile.firstName) { + if (!email || !profile.firstName) { throw new BadRequestError({ message: "Invalid request. Missing email or first name" }); } const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({ - username: profile.nameID ?? email, + externalId: profile.nameID, email, firstName: profile.firstName as string, lastName: profile.lastName as string, diff --git a/backend/src/ee/routes/v1/scim-router.ts b/backend/src/ee/routes/v1/scim-router.ts index dea0e3d70a..8965c28f3b 100644 --- a/backend/src/ee/routes/v1/scim-router.ts +++ b/backend/src/ee/routes/v1/scim-router.ts @@ -153,7 +153,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { const users = await req.server.services.scim.listScimUsers({ - offset: req.query.startIndex, + startIndex: req.query.startIndex, limit: req.query.count, filter: req.query.filter, orgId: req.permission.orgId @@ -163,11 +163,11 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/Users/:userId", + url: "/Users/:orgMembershipId", method: "GET", schema: { params: z.object({ - userId: z.string().trim() + orgMembershipId: z.string().trim() }), response: { 201: z.object({ @@ -193,7 +193,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { const user = await req.server.services.scim.getScimUser({ - userId: req.params.userId, + orgMembershipId: req.params.orgMembershipId, orgId: req.permission.orgId }); return user; @@ -249,7 +249,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { const primaryEmail = req.body.emails?.find((email) => email.primary)?.value; const user = await req.server.services.scim.createScimUser({ - username: req.body.userName, + externalId: req.body.userName, email: primaryEmail, firstName: req.body.name.givenName, lastName: req.body.name.familyName, @@ -261,11 +261,11 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/Users/:userId", + url: "/Users/:orgMembershipId", method: "DELETE", schema: { params: z.object({ - userId: z.string().trim() + orgMembershipId: z.string().trim() }), response: { 200: z.object({}) @@ -274,7 +274,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { const user = await req.server.services.scim.deleteScimUser({ - userId: req.params.userId, + orgMembershipId: req.params.orgMembershipId, orgId: req.permission.orgId }); @@ -361,7 +361,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { handler: async (req) => { const groups = await req.server.services.scim.listScimGroups({ orgId: req.permission.orgId, - offset: req.query.startIndex, + startIndex: req.query.startIndex, limit: req.query.count }); @@ -416,10 +416,10 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { displayName: z.string().trim(), members: z.array( z.object({ - value: z.string(), // infisical userId + value: z.string(), // infisical orgMembershipId display: z.string() }) - ) // note: is this where members are added to group? + ) }), response: { 200: z.object({ @@ -534,11 +534,11 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/Users/:userId", + url: "/Users/:orgMembershipId", method: "PUT", schema: { params: z.object({ - userId: z.string().trim() + orgMembershipId: z.string().trim() }), body: z.object({ schemas: z.array(z.string()), @@ -575,7 +575,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { const user = await req.server.services.scim.replaceScimUser({ - userId: req.params.userId, + orgMembershipId: req.params.orgMembershipId, orgId: req.permission.orgId, active: req.body.active }); diff --git a/backend/src/ee/services/group/group-fns.ts b/backend/src/ee/services/group/group-fns.ts index e308891f92..4f96ddbf09 100644 --- a/backend/src/ee/services/group/group-fns.ts +++ b/backend/src/ee/services/group/group-fns.ts @@ -1,6 +1,6 @@ import { Knex } from "knex"; -import { SecretKeyEncoding, TUsers } from "@app/db/schemas"; +import { SecretKeyEncoding, TableName, TUsers } from "@app/db/schemas"; import { decryptAsymmetric, encryptAsymmetric, infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; import { BadRequestError, ScimRequestError } from "@app/lib/errors"; @@ -188,9 +188,9 @@ export const addUsersToGroupByUserIds = async ({ // check if all user(s) are part of the organization const existingUserOrgMemberships = await orgDAL.findMembership( { - orgId: group.orgId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: group.orgId, $in: { - userId: userIds + [`${TableName.OrgMembership}.userId` as "userId"]: userIds } }, { tx } 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 85c5376845..6773c9486f 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -1,7 +1,14 @@ import { ForbiddenError } from "@casl/ability"; import jwt from "jsonwebtoken"; -import { OrgMembershipRole, OrgMembershipStatus, SecretKeyEncoding, TLdapConfigsUpdate } from "@app/db/schemas"; +import { + OrgMembershipRole, + OrgMembershipStatus, + SecretKeyEncoding, + TableName, + TLdapConfigsUpdate, + TUsers +} from "@app/db/schemas"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "@app/ee/services/group/group-fns"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; @@ -19,12 +26,15 @@ import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; +import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; import { normalizeUsername } from "@app/services/user/user-fns"; import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; +import { UserAliasType } from "@app/services/user-alias/user-alias-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; @@ -46,6 +56,7 @@ import { TLdapGroupMapDALFactory } from "./ldap-group-map-dal"; type TLdapConfigServiceFactoryDep = { ldapConfigDAL: Pick; ldapGroupMapDAL: Pick; + orgMembershipDAL: Pick; orgDAL: Pick< TOrgDALFactory, "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" @@ -75,6 +86,7 @@ export const ldapConfigServiceFactory = ({ ldapConfigDAL, ldapGroupMapDAL, orgDAL, + orgMembershipDAL, orgBotDAL, groupDAL, groupProjectDAL, @@ -379,16 +391,17 @@ export const ldapConfigServiceFactory = ({ username, firstName, lastName, - emails, + email, groups, orgId, relayState }: TLdapLoginDTO) => { const appCfg = getConfig(); + const serverCfg = await getServerCfg(); let userAlias = await userAliasDAL.findOne({ externalId, orgId, - aliasType: AuthMethod.LDAP + aliasType: UserAliasType.LDAP }); const organization = await orgDAL.findOrgById(orgId); @@ -396,7 +409,13 @@ export const ldapConfigServiceFactory = ({ if (userAlias) { await userDAL.transaction(async (tx) => { - const [orgMembership] = await orgDAL.findMembership({ userId: userAlias.userId }, { tx }); + const [orgMembership] = await orgDAL.findMembership( + { + [`${TableName.OrgMembership}.userId` as "userId"]: userAlias.userId, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId + }, + { tx } + ); if (!orgMembership) { await orgDAL.createMembership( { @@ -419,40 +438,75 @@ export const ldapConfigServiceFactory = ({ }); } else { userAlias = await userDAL.transaction(async (tx) => { - const uniqueUsername = await normalizeUsername(username, userDAL); - const newUser = await userDAL.create( - { - username: uniqueUsername, - email: emails[0], - firstName, - lastName, - authMethods: [AuthMethod.LDAP], - isGhost: false - }, - tx - ); + let newUser: TUsers | undefined; + if (serverCfg.trustSamlEmails) { + newUser = await userDAL.findOne( + { + email, + isEmailVerified: true + }, + tx + ); + } + + if (!newUser) { + const uniqueUsername = await normalizeUsername(username, userDAL); + newUser = await userDAL.create( + { + username: serverCfg.trustLdapEmails ? email : uniqueUsername, + email, + isEmailVerified: serverCfg.trustLdapEmails, + firstName, + lastName, + authMethods: [], + isGhost: false + }, + tx + ); + } + const newUserAlias = await userAliasDAL.create( { userId: newUser.id, username, - aliasType: AuthMethod.LDAP, + aliasType: UserAliasType.LDAP, externalId, - emails, + emails: [email], orgId }, tx ); - await orgDAL.createMembership( + const [orgMembership] = await orgDAL.findMembership( { - userId: newUser.id, - orgId, - role: OrgMembershipRole.Member, - status: OrgMembershipStatus.Invited + [`${TableName.OrgMembership}.userId` as "userId"]: newUser.id, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId }, - tx + { tx } ); + if (!orgMembership) { + await orgMembershipDAL.create( + { + userId: userAlias.userId, + inviteEmail: email, + orgId, + role: OrgMembershipRole.Member, + status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later + }, + tx + ); + // Only update the membership to Accepted if the user account is already completed. + } else if (orgMembership.status === OrgMembershipStatus.Invited && newUser.isAccepted) { + await orgDAL.updateMembershipById( + orgMembership.id, + { + status: OrgMembershipStatus.Accepted + }, + tx + ); + } + return newUserAlias; }); } @@ -543,11 +597,14 @@ export const ldapConfigServiceFactory = ({ authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, + ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), firstName, lastName, organizationName: organization.name, organizationId: organization.id, + organizationSlug: organization.slug, authMethod: AuthMethod.LDAP, + authType: UserAliasType.LDAP, isUserCompleted, ...(relayState ? { diff --git a/backend/src/ee/services/ldap-config/ldap-config-types.ts b/backend/src/ee/services/ldap-config/ldap-config-types.ts index b7e9feb7b8..aa4aa8da70 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-types.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-types.ts @@ -51,7 +51,7 @@ export type TLdapLoginDTO = { username: string; firstName: string; lastName: string; - emails: string[]; + email: string; orgId: string; groups?: { dn: string; diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index ff5f4bc3f7..7dfd211e13 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -7,7 +7,8 @@ import { SecretKeyEncoding, TableName, TSamlConfigs, - TSamlConfigsUpdate + TSamlConfigsUpdate, + TUsers } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { @@ -19,10 +20,18 @@ import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; -import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; +import { AuthTokenType } from "@app/services/auth/auth-type"; +import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { TokenType } from "@app/services/auth-token/auth-token-types"; import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; 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"; +import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { normalizeUsername } from "@app/services/user/user-fns"; +import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; +import { UserAliasType } from "@app/services/user-alias/user-alias-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; @@ -31,15 +40,19 @@ import { TSamlConfigDALFactory } from "./saml-config-dal"; import { TCreateSamlCfgDTO, TGetSamlCfgDTO, TSamlLoginDTO, TUpdateSamlCfgDTO } from "./saml-config-types"; type TSamlConfigServiceFactoryDep = { - samlConfigDAL: TSamlConfigDALFactory; - userDAL: Pick; + samlConfigDAL: Pick; + userDAL: Pick; + userAliasDAL: Pick; orgDAL: Pick< TOrgDALFactory, "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" >; + orgMembershipDAL: Pick; orgBotDAL: Pick; permissionService: Pick; licenseService: Pick; + tokenService: Pick; + smtpService: Pick; }; export type TSamlConfigServiceFactory = ReturnType; @@ -48,9 +61,13 @@ export const samlConfigServiceFactory = ({ samlConfigDAL, orgBotDAL, orgDAL, + orgMembershipDAL, userDAL, + userAliasDAL, permissionService, - licenseService + licenseService, + tokenService, + smtpService }: TSamlConfigServiceFactoryDep) => { const createSamlCfg = async ({ cert, @@ -305,7 +322,7 @@ export const samlConfigServiceFactory = ({ }; const samlLogin = async ({ - username, + externalId, email, firstName, lastName, @@ -314,38 +331,40 @@ export const samlConfigServiceFactory = ({ relayState }: TSamlLoginDTO) => { const appCfg = getConfig(); - let user = await userDAL.findOne({ username }); + const serverCfg = await getServerCfg(); + const userAlias = await userAliasDAL.findOne({ + externalId, + orgId, + aliasType: UserAliasType.SAML + }); const organization = await orgDAL.findOrgById(orgId); if (!organization) throw new BadRequestError({ message: "Org not found" }); - // TODO(dangtony98): remove this after aliases update - if (authProvider === AuthMethod.KEYCLOAK_SAML && appCfg.LICENSE_SERVER_KEY) { - throw new BadRequestError({ message: "Keycloak SAML is not yet available on Infisical Cloud" }); - } - - if (user) { - await userDAL.transaction(async (tx) => { + let user: TUsers; + if (userAlias) { + user = await userDAL.transaction(async (tx) => { + const foundUser = await userDAL.findById(userAlias.userId, tx); const [orgMembership] = await orgDAL.findMembership( { - userId: user.id, + [`${TableName.OrgMembership}.userId` as "userId"]: foundUser.id, [`${TableName.OrgMembership}.orgId` as "id"]: orgId }, { tx } ); if (!orgMembership) { - await orgDAL.createMembership( + await orgMembershipDAL.create( { - userId: user.id, - orgId, + userId: userAlias.userId, inviteEmail: email, + orgId, role: OrgMembershipRole.Member, - status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later + status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later }, tx ); // Only update the membership to Accepted if the user account is already completed. - } else if (orgMembership.status === OrgMembershipStatus.Invited && user.isAccepted) { + } else if (orgMembership.status === OrgMembershipStatus.Invited && foundUser.isAccepted) { await orgDAL.updateMembershipById( orgMembership.id, { @@ -354,40 +373,97 @@ export const samlConfigServiceFactory = ({ tx ); } + + return foundUser; }); } else { user = await userDAL.transaction(async (tx) => { - const newUser = await userDAL.create( + let newUser: TUsers | undefined; + if (serverCfg.trustSamlEmails) { + newUser = await userDAL.findOne( + { + email, + isEmailVerified: true + }, + tx + ); + } + + if (!newUser) { + const uniqueUsername = await normalizeUsername(`${firstName ?? ""}-${lastName ?? ""}`, userDAL); + newUser = await userDAL.create( + { + username: serverCfg.trustSamlEmails ? email : uniqueUsername, + email, + isEmailVerified: serverCfg.trustSamlEmails, + firstName, + lastName, + authMethods: [], + isGhost: false + }, + tx + ); + } + + await userAliasDAL.create( { - username, - email, - firstName, - lastName, - authMethods: [AuthMethod.EMAIL], - isGhost: false + userId: newUser.id, + aliasType: UserAliasType.SAML, + externalId, + emails: email ? [email] : [], + orgId }, tx ); - await orgDAL.createMembership({ - inviteEmail: email, - orgId, - role: OrgMembershipRole.Member, - status: OrgMembershipStatus.Invited - }); + + const [orgMembership] = await orgDAL.findMembership( + { + [`${TableName.OrgMembership}.userId` as "userId"]: newUser.id, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId + }, + { tx } + ); + + if (!orgMembership) { + await orgMembershipDAL.create( + { + userId: newUser.id, + inviteEmail: email, + orgId, + role: OrgMembershipRole.Member, + status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later + }, + tx + ); + // Only update the membership to Accepted if the user account is already completed. + } else if (orgMembership.status === OrgMembershipStatus.Invited && newUser.isAccepted) { + await orgDAL.updateMembershipById( + orgMembership.id, + { + status: OrgMembershipStatus.Accepted + }, + tx + ); + } + return newUser; }); } + const isUserCompleted = Boolean(user.isAccepted); const providerAuthToken = jwt.sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, + ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), firstName, lastName, organizationName: organization.name, organizationId: organization.id, + organizationSlug: organization.slug, authMethod: authProvider, + authType: UserAliasType.SAML, isUserCompleted, ...(relayState ? { @@ -403,6 +479,22 @@ export const samlConfigServiceFactory = ({ await samlConfigDAL.update({ orgId }, { lastUsed: new Date() }); + if (user.email && !user.isEmailVerified) { + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_VERIFICATION, + userId: user.id + }); + + await smtpService.sendMail({ + template: SmtpTemplates.EmailVerification, + subjectLine: "Infisical confirmation code", + recipients: [user.email], + substitutions: { + code: token + } + }); + } + return { isUserCompleted, providerAuthToken }; }; diff --git a/backend/src/ee/services/saml-config/saml-config-types.ts b/backend/src/ee/services/saml-config/saml-config-types.ts index df76949203..92ee32b5c6 100644 --- a/backend/src/ee/services/saml-config/saml-config-types.ts +++ b/backend/src/ee/services/saml-config/saml-config-types.ts @@ -45,8 +45,8 @@ export type TGetSamlCfgDTO = }; export type TSamlLoginDTO = { - username: string; - email?: string; + externalId: string; + email: string; firstName: string; lastName?: string; authProvider: string; diff --git a/backend/src/ee/services/scim/scim-fns.ts b/backend/src/ee/services/scim/scim-fns.ts index e816cffcf5..ec54a4d1fc 100644 --- a/backend/src/ee/services/scim/scim-fns.ts +++ b/backend/src/ee/services/scim/scim-fns.ts @@ -2,31 +2,31 @@ import { TListScimGroups, TListScimUsers, TScimGroup, TScimUser } from "./scim-t export const buildScimUserList = ({ scimUsers, - offset, + startIndex, limit }: { scimUsers: TScimUser[]; - offset: number; + startIndex: number; limit: number; }): TListScimUsers => { return { Resources: scimUsers, itemsPerPage: limit, schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - startIndex: offset, + startIndex, totalResults: scimUsers.length }; }; export const buildScimUser = ({ - userId, + orgMembershipId, username, email, firstName, lastName, active }: { - userId: string; + orgMembershipId: string; username: string; email?: string | null; firstName: string; @@ -35,7 +35,7 @@ export const buildScimUser = ({ }): TScimUser => { const scimUser = { schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], - id: userId, + id: orgMembershipId, userName: username, displayName: `${firstName} ${lastName}`, name: { @@ -65,18 +65,18 @@ export const buildScimUser = ({ export const buildScimGroupList = ({ scimGroups, - offset, + startIndex, limit }: { scimGroups: TScimGroup[]; - offset: number; + startIndex: number; limit: number; }): TListScimGroups => { return { Resources: scimGroups, itemsPerPage: limit, schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - startIndex: offset, + startIndex, totalResults: scimGroups.length }; }; diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index d56c00a0ca..9a084c6d71 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -2,7 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; import jwt from "jsonwebtoken"; -import { OrgMembershipRole, OrgMembershipStatus, TableName, TGroups } from "@app/db/schemas"; +import { OrgMembershipRole, OrgMembershipStatus, TableName, TGroups, TOrgMemberships, TUsers } from "@app/db/schemas"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "@app/ee/services/group/group-fns"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; @@ -11,16 +11,21 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ScimRequestError, UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TOrgPermission } from "@app/lib/types"; -import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; +import { AuthTokenType } from "@app/services/auth/auth-type"; import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; -import { deleteOrgMembership } from "@app/services/org/org-fns"; +import { deleteOrgMembershipFn } from "@app/services/org/org-fns"; +import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { normalizeUsername } from "@app/services/user/user-fns"; +import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; +import { UserAliasType } from "@app/services/user-alias/user-alias-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; @@ -47,24 +52,32 @@ import { type TScimServiceFactoryDep = { scimDAL: Pick; - userDAL: Pick; + userDAL: Pick< + TUserDALFactory, + "find" | "findOne" | "create" | "transaction" | "findUserEncKeyByUserIdsBatch" | "findById" + >; + userAliasDAL: Pick; orgDAL: Pick< TOrgDALFactory, - "createMembership" | "findById" | "findMembership" | "deleteMembershipById" | "transaction" + "createMembership" | "findById" | "findMembership" | "deleteMembershipById" | "transaction" | "updateMembershipById" >; + orgMembershipDAL: Pick; projectDAL: Pick; - projectMembershipDAL: Pick; + projectMembershipDAL: Pick; groupDAL: Pick< TGroupDALFactory, "create" | "findOne" | "findAllGroupMembers" | "update" | "delete" | "findGroups" | "transaction" >; groupProjectDAL: Pick; - userGroupMembershipDAL: TUserGroupMembershipDALFactory; // TODO: Pick + userGroupMembershipDAL: Pick< + TUserGroupMembershipDALFactory, + "find" | "transaction" | "insertMany" | "filterProjectsByUserMembership" | "delete" + >; projectKeyDAL: Pick; projectBotDAL: Pick; - licenseService: Pick; + licenseService: Pick; permissionService: Pick; - smtpService: TSmtpService; + smtpService: Pick; }; export type TScimServiceFactory = ReturnType; @@ -73,7 +86,9 @@ export const scimServiceFactory = ({ licenseService, scimDAL, userDAL, + userAliasDAL, orgDAL, + orgMembershipDAL, projectDAL, projectMembershipDAL, groupDAL, @@ -160,7 +175,7 @@ export const scimServiceFactory = ({ }; // SCIM server endpoints - const listScimUsers = async ({ offset, limit, filter, orgId }: TListScimUsersDTO): Promise => { + const listScimUsers = async ({ startIndex, limit, filter, orgId }: TListScimUsersDTO): Promise => { const org = await orgDAL.findById(orgId); if (!org.scimEnabled) @@ -178,11 +193,11 @@ export const scimServiceFactory = ({ attributeName = "email"; } - return { [attributeName]: parsedValue }; + return { [attributeName]: parsedValue.replace(/"/g, "") }; }; const findOpts = { - ...(offset && { offset }), + ...(startIndex && { offset: startIndex - 1 }), ...(limit && { limit }) }; @@ -194,10 +209,10 @@ export const scimServiceFactory = ({ findOpts ); - const scimUsers = users.map(({ userId, username, firstName, lastName, email }) => + const scimUsers = users.map(({ id, externalId, username, firstName, lastName, email }) => buildScimUser({ - userId: userId ?? "", - username, + orgMembershipId: id ?? "", + username: externalId ?? username, firstName: firstName ?? "", lastName: lastName ?? "", email, @@ -207,16 +222,16 @@ export const scimServiceFactory = ({ return buildScimUserList({ scimUsers, - offset, + startIndex, limit }); }; - const getScimUser = async ({ userId, orgId }: TGetScimUserDTO) => { + const getScimUser = async ({ orgMembershipId, orgId }: TGetScimUserDTO) => { const [membership] = await orgDAL .findMembership({ - userId, - [`${TableName.OrgMembership}.orgId` as "id"]: orgId + [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId }) .catch(() => { throw new ScimRequestError({ @@ -238,8 +253,8 @@ export const scimServiceFactory = ({ }); return buildScimUser({ - userId: membership.userId as string, - username: membership.username, + orgMembershipId: membership.id, + username: membership.externalId ?? membership.username, email: membership.email ?? "", firstName: membership.firstName as string, lastName: membership.lastName as string, @@ -247,7 +262,9 @@ export const scimServiceFactory = ({ }); }; - const createScimUser = async ({ username, email, firstName, lastName, orgId }: TCreateScimUserDTO) => { + const createScimUser = async ({ externalId, email, firstName, lastName, orgId }: TCreateScimUserDTO) => { + if (!email) throw new ScimRequestError({ detail: "Invalid request. Missing email.", status: 400 }); + const org = await orgDAL.findById(orgId); if (!org) @@ -262,67 +279,121 @@ export const scimServiceFactory = ({ status: 403 }); - let user = await userDAL.findOne({ - username + const appCfg = getConfig(); + const serverCfg = await getServerCfg(); + + const userAlias = await userAliasDAL.findOne({ + externalId, + orgId, + aliasType: UserAliasType.SAML }); - if (user) { - await userDAL.transaction(async (tx) => { - const [orgMembership] = await orgDAL.findMembership( + const { user: createdUser, orgMembership: createdOrgMembership } = await userDAL.transaction(async (tx) => { + let user: TUsers | undefined; + let orgMembership: TOrgMemberships; + if (userAlias) { + user = await userDAL.findById(userAlias.userId, tx); + orgMembership = await orgMembershipDAL.findOne( { userId: user.id, - [`${TableName.OrgMembership}.orgId` as "id"]: orgId + orgId }, - { tx } + tx ); - if (orgMembership) - throw new ScimRequestError({ - detail: "User already exists in the database", - status: 409 - }); if (!orgMembership) { - await orgDAL.createMembership( + orgMembership = await orgMembershipDAL.create( { - userId: user.id, - orgId, + userId: userAlias.userId, inviteEmail: email, + orgId, role: OrgMembershipRole.Member, - status: OrgMembershipStatus.Invited + status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later + }, + tx + ); + } else if (orgMembership.status === OrgMembershipStatus.Invited && user.isAccepted) { + orgMembership = await orgMembershipDAL.updateById( + orgMembership.id, + { + status: OrgMembershipStatus.Accepted }, tx ); } - }); - } else { - user = await userDAL.transaction(async (tx) => { - const newUser = await userDAL.create( + } else { + if (serverCfg.trustSamlEmails) { + user = await userDAL.findOne( + { + email, + isEmailVerified: true + }, + tx + ); + } + + if (!user) { + const uniqueUsername = await normalizeUsername(`${firstName}-${lastName}`, userDAL); + user = await userDAL.create( + { + username: serverCfg.trustSamlEmails ? email : uniqueUsername, + email, + isEmailVerified: serverCfg.trustSamlEmails, + firstName, + lastName, + authMethods: [], + isGhost: false + }, + tx + ); + } + + await userAliasDAL.create( { - username, - email, - firstName, - lastName, - authMethods: [AuthMethod.EMAIL], - isGhost: false + userId: user.id, + aliasType: UserAliasType.SAML, + externalId, + emails: email ? [email] : [], + orgId }, tx ); - await orgDAL.createMembership( + const [foundOrgMembership] = await orgDAL.findMembership( { - inviteEmail: email, - orgId, - userId: newUser.id, - role: OrgMembershipRole.Member, - status: OrgMembershipStatus.Invited + [`${TableName.OrgMembership}.userId` as "userId"]: user.id, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId }, - tx + { tx } ); - return newUser; - }); - } - const appCfg = getConfig(); + orgMembership = foundOrgMembership; + + if (!orgMembership) { + orgMembership = await orgMembershipDAL.create( + { + userId: user.id, + inviteEmail: email, + orgId, + role: OrgMembershipRole.Member, + status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later + }, + tx + ); + // Only update the membership to Accepted if the user account is already completed. + } else if (orgMembership.status === OrgMembershipStatus.Invited && user.isAccepted) { + orgMembership = await orgDAL.updateMembershipById( + orgMembership.id, + { + status: OrgMembershipStatus.Accepted + }, + tx + ); + } + } + + return { user, orgMembership }; + }); if (email) { await smtpService.sendMail({ @@ -337,20 +408,20 @@ export const scimServiceFactory = ({ } return buildScimUser({ - userId: user.id, - username: user.username, - firstName: user.firstName as string, - lastName: user.lastName as string, - email: user.email ?? "", + orgMembershipId: createdOrgMembership.id, + username: externalId, + firstName: createdUser.firstName as string, + lastName: createdUser.lastName as string, + email: createdUser.email ?? "", active: true }); }; - const updateScimUser = async ({ userId, orgId, operations }: TUpdateScimUserDTO) => { + const updateScimUser = async ({ orgMembershipId, orgId, operations }: TUpdateScimUserDTO) => { const [membership] = await orgDAL .findMembership({ - userId, - [`${TableName.OrgMembership}.orgId` as "id"]: orgId + [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId }) .catch(() => { throw new ScimRequestError({ @@ -386,18 +457,20 @@ export const scimServiceFactory = ({ }); if (!active) { - await deleteOrgMembership({ + await deleteOrgMembershipFn({ orgMembershipId: membership.id, orgId: membership.orgId, orgDAL, - projectDAL, - projectMembershipDAL + projectMembershipDAL, + projectKeyDAL, + userAliasDAL, + licenseService }); } return buildScimUser({ - userId: membership.userId as string, - username: membership.username, + orgMembershipId: membership.id, + username: membership.externalId ?? membership.username, email: membership.email, firstName: membership.firstName as string, lastName: membership.lastName as string, @@ -405,11 +478,11 @@ export const scimServiceFactory = ({ }); }; - const replaceScimUser = async ({ userId, active, orgId }: TReplaceScimUserDTO) => { + const replaceScimUser = async ({ orgMembershipId, active, orgId }: TReplaceScimUserDTO) => { const [membership] = await orgDAL .findMembership({ - userId, - [`${TableName.OrgMembership}.orgId` as "id"]: orgId + [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId }) .catch(() => { throw new ScimRequestError({ @@ -431,19 +504,20 @@ export const scimServiceFactory = ({ }); if (!active) { - // tx - await deleteOrgMembership({ + await deleteOrgMembershipFn({ orgMembershipId: membership.id, orgId: membership.orgId, orgDAL, - projectDAL, - projectMembershipDAL + projectMembershipDAL, + projectKeyDAL, + userAliasDAL, + licenseService }); } return buildScimUser({ - userId: membership.userId as string, - username: membership.username, + orgMembershipId: membership.id, + username: membership.externalId ?? membership.username, email: membership.email, firstName: membership.firstName as string, lastName: membership.lastName as string, @@ -451,18 +525,11 @@ export const scimServiceFactory = ({ }); }; - const deleteScimUser = async ({ userId, orgId }: TDeleteScimUserDTO) => { - const [membership] = await orgDAL - .findMembership({ - userId, - [`${TableName.OrgMembership}.orgId` as "id"]: orgId - }) - .catch(() => { - throw new ScimRequestError({ - detail: "User not found", - status: 404 - }); - }); + const deleteScimUser = async ({ orgMembershipId, orgId }: TDeleteScimUserDTO) => { + const [membership] = await orgDAL.findMembership({ + [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId + }); if (!membership) throw new ScimRequestError({ @@ -477,18 +544,20 @@ export const scimServiceFactory = ({ }); } - await deleteOrgMembership({ + await deleteOrgMembershipFn({ orgMembershipId: membership.id, orgId: membership.orgId, orgDAL, - projectDAL, - projectMembershipDAL + projectMembershipDAL, + projectKeyDAL, + userAliasDAL, + licenseService }); return {}; // intentionally return empty object upon success }; - const listScimGroups = async ({ orgId, offset, limit }: TListScimGroupsDTO) => { + const listScimGroups = async ({ orgId, startIndex, limit }: TListScimGroupsDTO) => { const plan = await licenseService.getPlan(orgId); if (!plan.groups) throw new BadRequestError({ @@ -509,21 +578,27 @@ export const scimServiceFactory = ({ status: 403 }); - const groups = await groupDAL.findGroups({ - orgId - }); + const groups = await groupDAL.findGroups( + { + orgId + }, + { + offset: startIndex - 1, + limit + } + ); const scimGroups = groups.map((group) => buildScimGroup({ groupId: group.id, name: group.name, - members: [] + members: [] // does this need to be populated? }) ); return buildScimGroupList({ scimGroups, - offset, + startIndex, limit }); }; @@ -562,9 +637,15 @@ export const scimServiceFactory = ({ ); if (members && members.length) { + const orgMemberships = await orgMembershipDAL.find({ + $in: { + id: members.map((member) => member.value) + } + }); + const newMembers = await addUsersToGroupByUserIds({ group, - userIds: members.map((member) => member.value), + userIds: orgMemberships.map((membership) => membership.userId as string), userDAL, userGroupMembershipDAL, orgDAL, @@ -581,12 +662,19 @@ export const scimServiceFactory = ({ return { group, newMembers: [] }; }); + const orgMemberships = await orgDAL.findMembership({ + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, + $in: { + [`${TableName.OrgMembership}.userId` as "userId"]: newGroup.newMembers.map((member) => member.id) + } + }); + return buildScimGroup({ groupId: newGroup.group.id, name: newGroup.group.name, - members: newGroup.newMembers.map((member) => ({ - value: member.id, - display: `${member.firstName} ${member.lastName}` + members: orgMemberships.map(({ id, firstName, lastName }) => ({ + value: id, + display: `${firstName} ${lastName}` })) }); }; @@ -615,15 +703,22 @@ export const scimServiceFactory = ({ groupId: group.id }); + const orgMemberships = await orgDAL.findMembership({ + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, + $in: { + [`${TableName.OrgMembership}.userId` as "userId"]: users + .filter((user) => user.isPartOfGroup) + .map((user) => user.id) + } + }); + return buildScimGroup({ groupId: group.id, name: group.name, - members: users - .filter((user) => user.isPartOfGroup) - .map((user) => ({ - value: user.id, - display: `${user.firstName} ${user.lastName}` - })) + members: orgMemberships.map(({ id, firstName, lastName }) => ({ + value: id, + display: `${firstName} ${lastName}` + })) }); }; @@ -667,7 +762,13 @@ export const scimServiceFactory = ({ } if (members) { - const membersIdsSet = new Set(members.map((member) => member.value)); + const orgMemberships = await orgMembershipDAL.find({ + $in: { + id: members.map((member) => member.value) + } + }); + + const membersIdsSet = new Set(orgMemberships.map((orgMembership) => orgMembership.userId)); const directMemberUserIds = ( await userGroupMembershipDAL.find({ @@ -686,13 +787,13 @@ export const scimServiceFactory = ({ const allMembersUserIds = directMemberUserIds.concat(pendingGroupAdditionsUserIds); const allMembersUserIdsSet = new Set(allMembersUserIds); - const toAddUserIds = members.filter((member) => !allMembersUserIdsSet.has(member.value)); + const toAddUserIds = orgMemberships.filter((member) => !allMembersUserIdsSet.has(member.userId as string)); const toRemoveUserIds = allMembersUserIds.filter((userId) => !membersIdsSet.has(userId)); if (toAddUserIds.length) { await addUsersToGroupByUserIds({ group, - userIds: toAddUserIds.map((member) => member.value), + userIds: toAddUserIds.map((member) => member.userId as string), userDAL, userGroupMembershipDAL, orgDAL, diff --git a/backend/src/ee/services/scim/scim-types.ts b/backend/src/ee/services/scim/scim-types.ts index 73d0ebe786..46ab90b8f0 100644 --- a/backend/src/ee/services/scim/scim-types.ts +++ b/backend/src/ee/services/scim/scim-types.ts @@ -12,7 +12,7 @@ export type TDeleteScimTokenDTO = { // SCIM server endpoint types export type TListScimUsersDTO = { - offset: number; + startIndex: number; limit: number; filter?: string; orgId: string; @@ -27,12 +27,12 @@ export type TListScimUsers = { }; export type TGetScimUserDTO = { - userId: string; + orgMembershipId: string; orgId: string; }; export type TCreateScimUserDTO = { - username: string; + externalId: string; email?: string; firstName: string; lastName: string; @@ -40,7 +40,7 @@ export type TCreateScimUserDTO = { }; export type TUpdateScimUserDTO = { - userId: string; + orgMembershipId: string; orgId: string; operations: { op: string; @@ -54,18 +54,18 @@ export type TUpdateScimUserDTO = { }; export type TReplaceScimUserDTO = { - userId: string; + orgMembershipId: string; active: boolean; orgId: string; }; export type TDeleteScimUserDTO = { - userId: string; + orgMembershipId: string; orgId: string; }; export type TListScimGroupsDTO = { - offset: number; + startIndex: number; limit: number; orgId: string; }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 25e807cc27..aeb66d93f9 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -88,6 +88,7 @@ import { orgDALFactory } from "@app/services/org/org-dal"; import { orgRoleDALFactory } from "@app/services/org/org-role-dal"; import { orgRoleServiceFactory } from "@app/services/org/org-role-service"; import { orgServiceFactory } from "@app/services/org/org-service"; +import { orgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { projectDALFactory } from "@app/services/project/project-dal"; import { projectQueueFactory } from "@app/services/project/project-queue"; import { projectServiceFactory } from "@app/services/project/project-service"; @@ -155,6 +156,7 @@ export const registerRoutes = async ( const authDAL = authDALFactory(db); const authTokenDAL = tokenDALFactory(db); const orgDAL = orgDALFactory(db); + const orgMembershipDAL = orgMembershipDALFactory(db); const orgBotDAL = orgBotDALFactory(db); const incidentContactDAL = incidentContactDALFactory(db); const orgRoleDAL = orgRoleDALFactory(db); @@ -262,13 +264,18 @@ export const registerRoutes = async ( permissionService, secretApprovalPolicyDAL }); + const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL }); const samlService = samlConfigServiceFactory({ permissionService, orgBotDAL, orgDAL, + orgMembershipDAL, userDAL, + userAliasDAL, samlConfigDAL, - licenseService + licenseService, + tokenService, + smtpService }); const groupService = groupServiceFactory({ userDAL, @@ -297,7 +304,9 @@ export const registerRoutes = async ( licenseService, scimDAL, userDAL, + userAliasDAL, orgDAL, + orgMembershipDAL, projectDAL, projectMembershipDAL, groupDAL, @@ -313,6 +322,7 @@ export const registerRoutes = async ( ldapConfigDAL, ldapGroupMapDAL, orgDAL, + orgMembershipDAL, orgBotDAL, groupDAL, groupProjectDAL, @@ -336,8 +346,13 @@ export const registerRoutes = async ( queueService }); - const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL }); - const userService = userServiceFactory({ userDAL }); + const userService = userServiceFactory({ + userDAL, + userAliasDAL, + orgMembershipDAL, + tokenService, + smtpService + }); const loginService = authLoginServiceFactory({ userDAL, smtpService, tokenService, orgDAL, tokenDAL: authTokenDAL }); const passwordService = authPaswordServiceFactory({ tokenService, @@ -346,6 +361,7 @@ export const registerRoutes = async ( userDAL }); const orgService = orgServiceFactory({ + userAliasDAL, licenseService, samlConfigDAL, orgRoleDAL, diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index e70822128c..4882411d81 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -42,7 +42,9 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { schema: { body: z.object({ allowSignUp: z.boolean().optional(), - allowedSignUpDomain: z.string().optional().nullable() + allowedSignUpDomain: z.string().optional().nullable(), + trustSamlEmails: z.boolean().optional(), + trustLdapEmails: z.boolean().optional() }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index d1e80702f2..1f15008c7b 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -2,11 +2,52 @@ import { z } from "zod"; import { AuthTokenSessionsSchema, OrganizationsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { ApiKeysSchema } from "@app/db/schemas/api-keys"; -import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMethod, AuthMode } from "@app/services/auth/auth-type"; export const registerUserRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/me/emails/code", + config: { + rateLimit: authRateLimit + }, + schema: { + body: z.object({ + username: z.string().trim() + }), + response: { + 200: z.object({}) + } + }, + handler: async (req) => { + await server.services.user.sendEmailVerificationCode(req.body.username); + return {}; + } + }); + + server.route({ + method: "POST", + url: "/me/emails/verify", + config: { + rateLimit: authRateLimit + }, + schema: { + body: z.object({ + username: z.string().trim(), + code: z.string().trim() + }), + response: { + 200: z.object({}) + } + }, + handler: async (req) => { + await server.services.user.verifyEmailVerificationCode(req.body.username, req.body.code); + return {}; + } + }); + server.route({ method: "PATCH", url: "/me/mfa", diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 59f336e5a2..5d68a4e947 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -27,10 +27,17 @@ export const getTokenConfig = (tokenType: TokenType) => { const expiresAt = new Date(new Date().getTime() + 86400000); return { token, expiresAt }; } + case TokenType.TOKEN_EMAIL_VERIFICATION: { + // generate random 6-digit code + const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1)); + const triesLeft = 3; + const expiresAt = new Date(new Date().getTime() + 86400000); + return { token, triesLeft, expiresAt }; + } case TokenType.TOKEN_EMAIL_MFA: { // generate random 6-digit code const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1)); - const triesLeft = 5; + const triesLeft = 3; const expiresAt = new Date(new Date().getTime() + 300000); return { token, triesLeft, expiresAt }; } diff --git a/backend/src/services/auth-token/auth-token-types.ts b/backend/src/services/auth-token/auth-token-types.ts index 74787f4acb..630e363101 100644 --- a/backend/src/services/auth-token/auth-token-types.ts +++ b/backend/src/services/auth-token/auth-token-types.ts @@ -1,5 +1,6 @@ export enum TokenType { TOKEN_EMAIL_CONFIRMATION = "emailConfirmation", + TOKEN_EMAIL_VERIFICATION = "emailVerification", // unverified -> verified TOKEN_EMAIL_MFA = "emailMfa", TOKEN_EMAIL_ORG_INVITATION = "organizationInvitation", TOKEN_EMAIL_PASSWORD_RESET = "passwordReset" diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 5d81eaae14..4d2a302c60 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -361,6 +361,7 @@ export const authLoginServiceFactory = ({ user = await userDAL.create({ username: email, email, + isEmailVerified: true, firstName, lastName, authMethods: [authMethod], @@ -374,6 +375,8 @@ export const authLoginServiceFactory = ({ authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, + email: user.email, + isEmailVerified: user.isEmailVerified, firstName: user.firstName, lastName: user.lastName, authMethod, diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 9475a3f56c..be7f5777db 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -1,6 +1,6 @@ import jwt from "jsonwebtoken"; -import { OrgMembershipStatus } from "@app/db/schemas"; +import { OrgMembershipStatus, TableName } from "@app/db/schemas"; import { convertPendingGroupAdditionsToGroupMemberships } from "@app/ee/services/group/group-fns"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -80,7 +80,7 @@ export const authSignupServiceFactory = ({ }); await smtpService.sendMail({ - template: SmtpTemplates.EmailVerification, + template: SmtpTemplates.SignupEmailVerification, subjectLine: "Infisical confirmation code", recipients: [user.email as string], substitutions: { @@ -102,6 +102,8 @@ export const authSignupServiceFactory = ({ code }); + await userDAL.updateById(user.id, { isEmailVerified: true }); + // generate jwt token this is a temporary token const jwtToken = jwt.sign( { @@ -169,12 +171,11 @@ export const authSignupServiceFactory = ({ tx ); // If it's SAML Auth and the organization ID is present, we should check if the user has a pending invite for this org, and accept it - if (isAuthMethodSaml(authMethod) && organizationId) { + if ((isAuthMethodSaml(authMethod) || authMethod === AuthMethod.LDAP) && organizationId) { const [pendingOrgMembership] = await orgDAL.findMembership({ - inviteEmail: email, - userId: user.id, + [`${TableName.OrgMembership}.userId` as "userId"]: user.id, status: OrgMembershipStatus.Invited, - orgId: organizationId + [`${TableName.OrgMembership}.orgId` as "orgId"]: organizationId }); if (pendingOrgMembership) { diff --git a/backend/src/services/org-membership/org-membership-dal.ts b/backend/src/services/org-membership/org-membership-dal.ts new file mode 100644 index 0000000000..9990d9c3dd --- /dev/null +++ b/backend/src/services/org-membership/org-membership-dal.ts @@ -0,0 +1,13 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TOrgMembershipDALFactory = ReturnType; + +export const orgMembershipDALFactory = (db: TDbClient) => { + const orgMembershipOrm = ormify(db, TableName.OrgMembership); + + return { + ...orgMembershipOrm + }; +}; diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index 4dc76b6128..1e52053b2a 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -262,13 +262,19 @@ export const orgDALFactory = (db: TDbClient) => { .where(buildFindFilter(filter)) .join(TableName.Users, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`) .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`) + .leftJoin(TableName.UserAliases, function joinUserAlias() { + this.on(`${TableName.UserAliases}.userId`, "=", `${TableName.OrgMembership}.userId`) + .andOn(`${TableName.UserAliases}.orgId`, "=", `${TableName.OrgMembership}.orgId`) + .andOn(`${TableName.UserAliases}.aliasType`, "=", (tx || db).raw("?", ["saml"])); + }) .select( selectAllTableCols(TableName.OrgMembership), db.ref("email").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users), db.ref("firstName").withSchema(TableName.Users), db.ref("lastName").withSchema(TableName.Users), - db.ref("scimEnabled").withSchema(TableName.Organization) + db.ref("scimEnabled").withSchema(TableName.Organization), + db.ref("externalId").withSchema(TableName.UserAliases) ) .where({ isGhost: false }); diff --git a/backend/src/services/org/org-fns.ts b/backend/src/services/org/org-fns.ts index ec6d4cb2d0..a63ffabee8 100644 --- a/backend/src/services/org/org-fns.ts +++ b/backend/src/services/org/org-fns.ts @@ -1,41 +1,78 @@ +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TOrgDALFactory } from "@app/services/org/org-dal"; -import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; +import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; type TDeleteOrgMembership = { orgMembershipId: string; orgId: string; orgDAL: Pick; - projectDAL: Pick; - projectMembershipDAL: Pick; + projectMembershipDAL: Pick; + projectKeyDAL: Pick; + userAliasDAL: Pick; + licenseService: Pick; }; -export const deleteOrgMembership = async ({ +export const deleteOrgMembershipFn = async ({ orgMembershipId, orgId, orgDAL, - projectDAL, - projectMembershipDAL + projectMembershipDAL, + projectKeyDAL, + userAliasDAL, + licenseService }: TDeleteOrgMembership) => { - const membership = await orgDAL.transaction(async (tx) => { - // delete org membership + const deletedMembership = await orgDAL.transaction(async (tx) => { const orgMembership = await orgDAL.deleteMembershipById(orgMembershipId, orgId, tx); - const projects = await projectDAL.find({ orgId }, { tx }); + if (!orgMembership.userId) { + await licenseService.updateSubscriptionOrgMemberCount(orgId); + return orgMembership; + } - // delete associated project memberships - await projectMembershipDAL.delete( + await userAliasDAL.delete( { - $in: { - projectId: projects.map((project) => project.id) - }, - userId: orgMembership.userId as string + userId: orgMembership.userId, + orgId }, tx ); + // Get all the project memberships of the user in the organization + const projectMemberships = await projectMembershipDAL.findProjectMembershipsByUserId(orgId, orgMembership.userId); + + // Delete all the project memberships of the user in the organization + await projectMembershipDAL.delete( + { + $in: { + id: projectMemberships.map((membership) => membership.id) + } + }, + tx + ); + + // Get all the project keys of the user in the organization + const projectKeys = await projectKeyDAL.find({ + $in: { + projectId: projectMemberships.map((membership) => membership.projectId) + }, + receiverId: orgMembership.userId + }); + + // Delete all the project keys of the user in the organization + await projectKeyDAL.delete( + { + $in: { + id: projectKeys.map((key) => key.id) + } + }, + tx + ); + + await licenseService.updateSubscriptionOrgMemberCount(orgId); return orgMembership; }); - return membership; + return deletedMembership; }; diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 996a08c4d9..d7ee1ce933 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -4,7 +4,7 @@ import crypto from "crypto"; import jwt from "jsonwebtoken"; import { Knex } from "knex"; -import { OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas"; +import { OrgMembershipRole, OrgMembershipStatus, TableName } from "@app/db/schemas"; import { TProjects } from "@app/db/schemas/projects"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -18,6 +18,7 @@ import { generateUserSrpKeys } from "@app/lib/crypto/srp"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { isDisposableEmail } from "@app/lib/validator"; +import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { ActorAuthMethod, ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; @@ -30,6 +31,7 @@ import { TUserDALFactory } from "../user/user-dal"; import { TIncidentContactsDALFactory } from "./incident-contacts-dal"; import { TOrgBotDALFactory } from "./org-bot-dal"; import { TOrgDALFactory } from "./org-dal"; +import { deleteOrgMembershipFn } from "./org-fns"; import { TOrgRoleDALFactory } from "./org-role-dal"; import { TDeleteOrgMembershipDTO, @@ -43,6 +45,7 @@ import { } from "./org-types"; type TOrgServiceFactoryDep = { + userAliasDAL: Pick; orgDAL: TOrgDALFactory; orgBotDAL: TOrgBotDALFactory; orgRoleDAL: TOrgRoleDALFactory; @@ -65,6 +68,7 @@ type TOrgServiceFactoryDep = { export type TOrgServiceFactory = ReturnType; export const orgServiceFactory = ({ + userAliasDAL, orgDAL, userDAL, groupDAL, @@ -427,7 +431,13 @@ export const orgServiceFactory = ({ if (inviteeUser) { // if user already exist means its already part of infisical // Thus the signup flow is not needed anymore - const [inviteeMembership] = await orgDAL.findMembership({ orgId, userId: inviteeUser.id }, { tx }); + const [inviteeMembership] = await orgDAL.findMembership( + { + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, + [`${TableName.OrgMembership}.userId` as "userId"]: inviteeUser.id + }, + { tx } + ); if (inviteeMembership && inviteeMembership.status === OrgMembershipStatus.Accepted) { throw new BadRequestError({ message: "Failed to invite an existing member of org", @@ -519,9 +529,9 @@ export const orgServiceFactory = ({ throw new BadRequestError({ message: "Invalid request", name: "Verify user to org" }); } const [orgMembership] = await orgDAL.findMembership({ - userId: user.id, + [`${TableName.OrgMembership}.userId` as "userId"]: user.id, status: OrgMembershipStatus.Invited, - orgId + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId }); if (!orgMembership) throw new BadRequestError({ @@ -572,47 +582,14 @@ export const orgServiceFactory = ({ const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Member); - const deletedMembership = await orgDAL.transaction(async (tx) => { - const orgMembership = await orgDAL.deleteMembershipById(membershipId, orgId, tx); - - if (!orgMembership.userId) { - await licenseService.updateSubscriptionOrgMemberCount(orgId); - return orgMembership; - } - - // Get all the project memberships of the user in the organization - const projectMemberships = await projectMembershipDAL.findProjectMembershipsByUserId(orgId, orgMembership.userId); - - // Delete all the project memberships of the user in the organization - await projectMembershipDAL.delete( - { - $in: { - id: projectMemberships.map((membership) => membership.id) - } - }, - tx - ); - - // Get all the project keys of the user in the organization - const projectKeys = await projectKeyDAL.find({ - $in: { - projectId: projectMemberships.map((membership) => membership.projectId) - }, - receiverId: orgMembership.userId - }); - - // Delete all the project keys of the user in the organization - await projectKeyDAL.delete( - { - $in: { - id: projectKeys.map((key) => key.id) - } - }, - tx - ); - - await licenseService.updateSubscriptionOrgMemberCount(orgId); - return orgMembership; + const deletedMembership = await deleteOrgMembershipFn({ + orgMembershipId: membershipId, + orgId, + orgDAL, + projectMembershipDAL, + projectKeyDAL, + userAliasDAL, + licenseService }); return deletedMembership; diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 6d148d03b6..e12114feff 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -110,7 +110,7 @@ export const projectMembershipServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member); const orgMembers = await orgDAL.findMembership({ - orgId: project.orgId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: project.orgId, $in: { [`${TableName.OrgMembership}.id` as "id"]: members.map(({ orgMembershipId }) => orgMembershipId) } @@ -119,7 +119,7 @@ export const projectMembershipServiceFactory = ({ const existingMembers = await projectMembershipDAL.find({ projectId, - $in: { userId: orgMembers.map(({ userId }) => userId).filter(Boolean) as string[] } + $in: { userId: orgMembers.map(({ userId }) => userId).filter(Boolean) } }); if (existingMembers.length) throw new BadRequestError({ message: "Some users are already part of project" }); @@ -134,7 +134,7 @@ export const projectMembershipServiceFactory = ({ const projectMemberships = await projectMembershipDAL.insertMany( orgMembers.map(({ userId }) => ({ projectId, - userId: userId as string + userId })), tx ); @@ -145,12 +145,12 @@ export const projectMembershipServiceFactory = ({ const encKeyGroupByOrgMembId = groupBy(members, (i) => i.orgMembershipId); await projectKeyDAL.insertMany( orgMembers - .filter(({ userId }) => !userIdsToExcludeForProjectKeyAddition.has(userId as string)) + .filter(({ userId }) => !userIdsToExcludeForProjectKeyAddition.has(userId)) .map(({ userId, id }) => ({ encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey, nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce, senderId: actorId, - receiverId: userId as string, + receiverId: userId, projectId })), tx diff --git a/backend/src/services/project/project-queue.ts b/backend/src/services/project/project-queue.ts index 81ecd6da12..8f1e3fc3f0 100644 --- a/backend/src/services/project/project-queue.ts +++ b/backend/src/services/project/project-queue.ts @@ -8,6 +8,7 @@ import { SecretKeyEncoding, SecretsSchema, SecretVersionsSchema, + TableName, TIntegrationAuths, TSecretApprovalRequestsSecrets, TSecrets, @@ -273,7 +274,10 @@ export const projectQueueFactory = ({ for (const key of existingProjectKeys) { const user = await userDAL.findUserEncKeyByUserId(key.receiverId); - const [orgMembership] = await orgDAL.findMembership({ userId: key.receiverId, orgId: project.orgId }); + const [orgMembership] = await orgDAL.findMembership({ + [`${TableName.OrgMembership}.userId` as "userId"]: key.receiverId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: project.orgId + }); if (!user) { throw new Error(`User with ID ${key.receiverId} was not found during upgrade.`); diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 7ebeaa227a..0b43ffb908 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -17,6 +17,7 @@ export type TSmtpSendMail = { export type TSmtpService = ReturnType; export enum SmtpTemplates { + SignupEmailVerification = "signupEmailVerification.handlebars", EmailVerification = "emailVerification.handlebars", SecretReminder = "secretReminder.handlebars", EmailMfa = "emailMfa.handlebars", diff --git a/backend/src/services/smtp/templates/emailVerification.handlebars b/backend/src/services/smtp/templates/emailVerification.handlebars index fc738d2023..ad9694d5c5 100644 --- a/backend/src/services/smtp/templates/emailVerification.handlebars +++ b/backend/src/services/smtp/templates/emailVerification.handlebars @@ -1,17 +1,15 @@ - - - - + + + Code - + - +

Confirm your email address

-

Your confirmation code is below — enter it in the browser window where you've started signing up for Infisical.

+

Your confirmation code is below — enter it in the browser window where you've started confirming your email.

{{code}}

-

Questions about setting up Infisical? Email us at support@infisical.com

- + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/signupEmailVerification.handlebars b/backend/src/services/smtp/templates/signupEmailVerification.handlebars new file mode 100644 index 0000000000..fc738d2023 --- /dev/null +++ b/backend/src/services/smtp/templates/signupEmailVerification.handlebars @@ -0,0 +1,17 @@ + + + + + + + Code + + + +

Confirm your email address

+

Your confirmation code is below — enter it in the browser window where you've started signing up for Infisical.

+

{{code}}

+

Questions about setting up Infisical? Email us at support@infisical.com

+ + + \ No newline at end of file diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 07fc2e9917..bec8f3f377 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -102,7 +102,8 @@ export const superAdminServiceFactory = ({ superAdmin: true, isGhost: false, isAccepted: true, - authMethods: [AuthMethod.EMAIL] + authMethods: [AuthMethod.EMAIL], + isEmailVerified: true }, tx ); diff --git a/backend/src/services/user-alias/user-alias-types.ts b/backend/src/services/user-alias/user-alias-types.ts index e69de29bb2..09204644f4 100644 --- a/backend/src/services/user-alias/user-alias-types.ts +++ b/backend/src/services/user-alias/user-alias-types.ts @@ -0,0 +1,4 @@ +export enum UserAliasType { + LDAP = "ldap", + SAML = "saml" +} diff --git a/backend/src/services/user/user-fns.ts b/backend/src/services/user/user-fns.ts index 23789df1bd..639320e243 100644 --- a/backend/src/services/user/user-fns.ts +++ b/backend/src/services/user/user-fns.ts @@ -4,7 +4,7 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TUserDALFactory } from "@app/services/user/user-dal"; export const normalizeUsername = async (username: string, userDAL: Pick) => { - let attempt = slugify(username); + let attempt = slugify(`${username}-${alphaNumericNanoId(4)}`); let user = await userDAL.findOne({ username: attempt }); if (!user) return attempt; diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index c85e40eb3c..089f3b8c68 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -1,15 +1,151 @@ import { BadRequestError } from "@app/lib/errors"; +import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +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 { TUserDALFactory } from "./user-dal"; type TUserServiceFactoryDep = { - userDAL: TUserDALFactory; + userDAL: Pick< + TUserDALFactory, + | "find" + | "findOne" + | "findById" + | "transaction" + | "updateById" + | "update" + | "deleteById" + | "findOneUserAction" + | "createUserAction" + | "findUserEncKeyByUserId" + >; + userAliasDAL: Pick; + orgMembershipDAL: Pick; + tokenService: Pick; + smtpService: Pick; }; export type TUserServiceFactory = ReturnType; -export const userServiceFactory = ({ userDAL }: TUserServiceFactoryDep) => { +export const userServiceFactory = ({ + userDAL, + userAliasDAL, + orgMembershipDAL, + tokenService, + smtpService +}: TUserServiceFactoryDep) => { + const sendEmailVerificationCode = async (username: string) => { + const user = await userDAL.findOne({ username }); + if (!user) throw new BadRequestError({ name: "Failed to find user" }); + if (!user.email) + throw new BadRequestError({ name: "Failed to send email verification code due to no email on user" }); + if (user.isEmailVerified) + throw new BadRequestError({ name: "Failed to send email verification code due to email already verified" }); + + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_VERIFICATION, + userId: user.id + }); + + await smtpService.sendMail({ + template: SmtpTemplates.EmailVerification, + subjectLine: "Infisical confirmation code", + recipients: [user.email], + substitutions: { + code: token + } + }); + }; + + const verifyEmailVerificationCode = async (username: string, code: string) => { + const user = await userDAL.findOne({ username }); + if (!user) throw new BadRequestError({ name: "Failed to find user" }); + if (!user.email) + throw new BadRequestError({ name: "Failed to verify email verification code due to no email on user" }); + if (user.isEmailVerified) + throw new BadRequestError({ name: "Failed to verify email verification code due to email already verified" }); + + await tokenService.validateTokenForUser({ + type: TokenType.TOKEN_EMAIL_VERIFICATION, + userId: user.id, + code + }); + + const { email } = user; + + await userDAL.transaction(async (tx) => { + await userDAL.updateById( + user.id, + { + isEmailVerified: true + }, + tx + ); + + // check if there are 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 BadRequestError({ 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 { + // update current user's username to [email] + await userDAL.updateById( + user.id, + { + username: email + }, + tx + ); + } + }); + }; + const toggleUserMfa = async (userId: string, isMfaEnabled: boolean) => { const user = await userDAL.findById(userId); @@ -72,6 +208,8 @@ export const userServiceFactory = ({ userDAL }: TUserServiceFactoryDep) => { }; return { + sendEmailVerificationCode, + verifyEmailVerificationCode, toggleUserMfa, updateUserName, updateAuthMethods, diff --git a/docs/documentation/platform/ldap/general.mdx b/docs/documentation/platform/ldap/general.mdx index aa4841625b..5e4253a344 100644 --- a/docs/documentation/platform/ldap/general.mdx +++ b/docs/documentation/platform/ldap/general.mdx @@ -12,6 +12,10 @@ description: "Learn how to log in to Infisical with LDAP." You can configure your organization in Infisical to have members authenticate with the platform via [LDAP](https://en.wikipedia.org/wiki/Lightweight_Directory_Access_Protocol) +Prerequisites: + +- You must have an email address to use LDAP, regardless of whether or not you use that email address to sign in. + In Infisical, head to your Organization Settings > Security > LDAP and select **Manage**. diff --git a/docs/documentation/platform/ldap/jumpcloud.mdx b/docs/documentation/platform/ldap/jumpcloud.mdx index 0b40d8b3af..b92b52bb90 100644 --- a/docs/documentation/platform/ldap/jumpcloud.mdx +++ b/docs/documentation/platform/ldap/jumpcloud.mdx @@ -10,6 +10,10 @@ description: "Learn how to configure JumpCloud LDAP for authenticating into Infi it. +Prerequisites: + +- You must have an email address to use LDAP, regardless of whether or not you use that email address to sign in. + In JumpCloud, head to USER MANAGEMENT > Users and create a new user via the **Manual user entry** option. This user diff --git a/docs/documentation/platform/ldap/overview.mdx b/docs/documentation/platform/ldap/overview.mdx index 2423be8c06..4d6c75e153 100644 --- a/docs/documentation/platform/ldap/overview.mdx +++ b/docs/documentation/platform/ldap/overview.mdx @@ -3,11 +3,13 @@ title: "LDAP Overview" sidebarTitle: "Overview" description: "Learn how to authenticate into Infisical with LDAP." --- + LDAP is a paid feature. - If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, - then you should contact sales@infisical.com to purchase an enterprise license to use it. +If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, +then you should contact sales@infisical.com to purchase an enterprise license to use it. + You can configure your organization in Infisical to have members authenticate with the platform via [LDAP](https://en.wikipedia.org/wiki/Lightweight_Directory_Access_Protocol). @@ -25,3 +27,18 @@ Read the general instructions for configuring LDAP [here](/documentation/platfor If the documentation for your required identity provider is not shown in the list above, please reach out to [team@infisical.com](mailto:team@infisical.com) for assistance. +## FAQ + + + + By default, Infisical Cloud is configured to not trust emails from external + identity providers to prevent any malicious account takeover attempts via + email spoofing. Accordingly, Infisical creates a new user for anyone provisioned + through an external identity provider and requires an additional email + verification step upon their first login. + + If you're running a self-hosted instance of Infisical and would like it to trust emails from external identity providers, + you can configure this behavior in the admin panel. + + + diff --git a/docs/documentation/platform/sso/okta.mdx b/docs/documentation/platform/sso/okta.mdx index c81141c92b..b0ac046d03 100644 --- a/docs/documentation/platform/sso/okta.mdx +++ b/docs/documentation/platform/sso/okta.mdx @@ -4,10 +4,10 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO." --- - Okta SAML SSO is a paid feature. - - If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, - then you should contact sales@infisical.com to purchase an enterprise license to use it. + Okta SAML SSO is a paid feature. If you're using Infisical Cloud, then it is + available under the **Pro Tier**. If you're self-hosting Infisical, then you + should contact sales@infisical.com to purchase an enterprise license to use + it. @@ -22,24 +22,24 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO." button. ![SAML Okta create app integration](../../../images/sso/okta/create-app-integration.png) - + In the Create a New Application Integration dialog, select the **SAML 2.0** radio button: ![SAML Okta create SAML 2.0 integration](../../../images/sso/okta/create-saml-app.png) - + On the General Settings screen, give the application a unique name like Infisical and select **Next**. - + ![SAML Okta create SAML 2.0 integration](../../../images/sso/okta/general-settings.png) - + On the Configure SAML screen, set the **Single sign-on URL** and **Audience URI (SP Entity ID)** from step 1. ![SAML Okta configure IdP fields](../../../images/sso/okta/configure-saml.png) - + If you're self-hosting Infisical, then you will want to replace `https://app.infisical.com` with your own domain. - + Also on the Configure SAML screen, configure the **Attribute Statements** to map: - `id -> user.id`, @@ -50,6 +50,7 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO." ![SAML Okta attribute statements](../../../images/sso/okta/attribute-statements.png) Once configured, select **Next** to proceed to the Feedback screen and select **Finish**. + Once your application is created, select the **Sign On** tab for the app and select the **View Setup Instructions** button located on the right side of the screen: @@ -59,12 +60,14 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO." Copy the **Identity Provider Single Sign-On URL**, the **Identity Provider Issuer**, and the **X.509 Certificate** to use when finishing configuring Okta SAML in Infisical. ![SAML Okta IdP values](../../../images/sso/okta/idp-values.png) + Back in Infisical, set **Identity Provider Single Sign-On URL**, **Identity Provider Issuer**, and **Certificate** to **X.509 Certificate** from step 3. Once you've done that, press **Update** to complete the required configuration. ![SAML Okta paste values into Infisical](../../../images/sso/okta/idp-values-2.png) + Back in Okta, navigate to the **Assignments** tab and select **Assign**. You can assign access to the application on a user-by-user basis using the Assign to People option, or in-bulk using the Assign to Groups option. @@ -72,11 +75,13 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO." ![SAML Okta assignment](../../../images/sso/okta/assignment.png) At this point, you have configured everything you need within the context of the Okta Admin Portal. + Enabling SAML SSO allows members in your organization to log into Infisical via Okta. ![SAML Okta enable SAML](../../../images/sso/okta/enable-saml.png) + Enforcing SAML SSO ensures that members in your organization can only access Infisical @@ -89,13 +94,15 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO." We recommend ensuring that your account is provisioned the application in Okta prior to enforcing SAML SSO to prevent any unintended issues. + - If you're configuring SAML SSO on a self-hosted instance of Infisical, make sure to - set the `AUTH_SECRET` and `SITE_URL` environment variable for it to work: - - - `AUTH_SECRET`: A secret key used for signing and verifying JWT. This can be a random 32-byte base64 string generated with `openssl rand -base64 32`. - - `SITE_URL`: The URL of your self-hosted instance of Infisical - should be an absolute URL including the protocol (e.g. https://app.infisical.com) - \ No newline at end of file + If you're configuring SAML SSO on a self-hosted instance of Infisical, make + sure to set the `AUTH_SECRET` and `SITE_URL` environment variable for it to + work: - `AUTH_SECRET`: A secret key used for signing and verifying JWT. This + can be a random 32-byte base64 string generated with `openssl rand -base64 + 32`. - `SITE_URL`: The URL of your self-hosted instance of Infisical - should + be an absolute URL including the protocol (e.g. https://app.infisical.com) + diff --git a/docs/documentation/platform/sso/overview.mdx b/docs/documentation/platform/sso/overview.mdx index 6064f26e8a..9ab0acc3ae 100644 --- a/docs/documentation/platform/sso/overview.mdx +++ b/docs/documentation/platform/sso/overview.mdx @@ -5,11 +5,12 @@ description: "Learn how to log in to Infisical via SSO protocols." --- - Infisical offers Google SSO and GitHub SSO for free across both Infisical Cloud and Infisical Self-hosted. - - Infisical also offers SAML SSO authentication but as paid features that can be unlocked on Infisical Cloud's **Pro** tier - or via enterprise license on self-hosted instances of Infisical. On this front, we support industry-leading providers including - Okta, Azure AD, and JumpCloud; with any questions, please reach out to team@infisical.com. + Infisical offers Google SSO and GitHub SSO for free across both Infisical + Cloud and Infisical Self-hosted. Infisical also offers SAML SSO authentication + but as paid features that can be unlocked on Infisical Cloud's **Pro** tier or + via enterprise license on self-hosted instances of Infisical. On this front, + we support industry-leading providers including Okta, Azure AD, and JumpCloud; + with any questions, please reach out to team@infisical.com. You can configure your organization in Infisical to have members authenticate with the platform via protocols like [SAML 2.0](https://en.wikipedia.org/wiki/SAML_2.0). @@ -31,3 +32,19 @@ Infisical supports these and many other identity providers: - [Google SAML](/documentation/platform/sso/google-saml) If your required identity provider is not shown in the list above, please reach out to [team@infisical.com](mailto:team@infisical.com) for assistance. + +## FAQ + + + + By default, Infisical Cloud is configured to not trust emails from external + identity providers to prevent any malicious account takeover attempts via + email spoofing. Accordingly, Infisical creates a new user for anyone provisioned + through an external identity provider and requires an additional email + verification step upon their first login. + + If you're running a self-hosted instance of Infisical and would like it to trust emails from external identity providers, + you can configure this behavior in the admin panel. + + + diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 4c1456d3b5..5233ae9105 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -3,30 +3,34 @@ title: "Configurations" description: "Read how to configure environment variables for self-hosted Infisical." --- - -Infisical accepts all configurations via environment variables. For a minimal self-hosted instance, at least `ENCRYPTION_KEY`, `AUTH_SECRET`, `DB_CONNECTION_URI` and `REDIS_URL` must be defined. +Infisical accepts all configurations via environment variables. For a minimal self-hosted instance, at least `ENCRYPTION_KEY`, `AUTH_SECRET`, `DB_CONNECTION_URI` and `REDIS_URL` must be defined. However, you can configure additional settings to activate more features as needed. -## General platform +## General platform + Used to configure platform-specific security and operational settings - Must be a random 16 byte hex string. Can be generated with `openssl rand -hex 16` + Must be a random 16 byte hex string. Can be generated with `openssl rand -hex + 16` - Must be a random 32 byte base64 string. Can be generated with `openssl rand -base64 32` + Must be a random 32 byte base64 string. Can be generated with `openssl rand + -base64 32` - Must be an absolute URL including the protocol (e.g. https://app.infisical.com). + Must be an absolute URL including the protocol (e.g. + https://app.infisical.com). -## Data Layer +## Data Layer + The platform utilizes Postgres to persist all of its data and Redis for caching and backgroud tasks - Postgres database connection string. + Postgres database connection string. @@ -39,9 +43,8 @@ The platform utilizes Postgres to persist all of its data and Redis for caching Redis connection string. - - ## Email service + Without email configuration, Infisical's core functions like sign-up/login and secret operations work, but this disables multi-factor authentication, email invites for projects, alerts for suspicious logins, and all other email-dependent features. @@ -49,25 +52,36 @@ Without email configuration, Infisical's core functions like sign-up/login and s Hostname to connect to for establishing SMTP connections - - Credential to connect to host (e.g. team@infisical.com) - +{" "} - - Credential to connect to host - + + Credential to connect to host (e.g. team@infisical.com) + - - Port to connect to for establishing SMTP connections - +{" "} - - If true, use TLS when connecting to host. If false, TLS will be used if STARTTLS is supported - + + Credential to connect to host + - - Email address to be used for sending emails - +{" "} + + + Port to connect to for establishing SMTP connections + + +{" "} + + + If true, use TLS when connecting to host. If false, TLS will be used if + STARTTLS is supported + + +{" "} + + + Email address to be used for sending emails + Name label to be used in From field (e.g. Team) @@ -76,25 +90,25 @@ Without email configuration, Infisical's core functions like sign-up/login and s - 1. Create an account and configure [SendGrid](https://sendgrid.com) to send emails. - 2. Create a SendGrid API Key under Settings > [API Keys](https://app.sendgrid.com/settings/api_keys) - 3. Set a name for your API Key, we recommend using "Infisical," and select the "Restricted Key" option. You will need to enable the "Mail Send" permission as shown below: +1. Create an account and configure [SendGrid](https://sendgrid.com) to send emails. +2. Create a SendGrid API Key under Settings > [API Keys](https://app.sendgrid.com/settings/api_keys) +3. Set a name for your API Key, we recommend using "Infisical," and select the "Restricted Key" option. You will need to enable the "Mail Send" permission as shown below: - ![creating sendgrid api key](../../images/self-hosting/configuration/email/email-sendgrid-create-key.png) +![creating sendgrid api key](../../images/self-hosting/configuration/email/email-sendgrid-create-key.png) - ![setting sendgrid api key restriction](../../images/self-hosting/configuration/email/email-sendgrid-restrictions.png) +![setting sendgrid api key restriction](../../images/self-hosting/configuration/email/email-sendgrid-restrictions.png) - 4. With the API Key, you can now set your SMTP environment variables: +4. With the API Key, you can now set your SMTP environment variables: - ``` - SMTP_HOST=smtp.sendgrid.net - SMTP_USERNAME=apikey - SMTP_PASSWORD=SG.rqFsfjxYPiqE1lqZTgD_lz7x8IVLx # your SendGrid API Key from step above - SMTP_PORT=587 - SMTP_SECURE=true - SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails - SMTP_FROM_NAME=Infisical - ``` +``` +SMTP_HOST=smtp.sendgrid.net +SMTP_USERNAME=apikey +SMTP_PASSWORD=SG.rqFsfjxYPiqE1lqZTgD_lz7x8IVLx # your SendGrid API Key from step above +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails +SMTP_FROM_NAME=Infisical +``` Remember that you will need to restart Infisical for this to work properly. @@ -105,19 +119,20 @@ Without email configuration, Infisical's core functions like sign-up/login and s 1. Create an account and configure [Mailgun](https://www.mailgun.com) to send emails. 2. Obtain your Mailgun credentials in Sending > Overview > SMTP - ![obtain mailhog api key estriction](../../images/self-hosting/configuration/email/email-mailhog-credentials.png) +![obtain mailhog api key estriction](../../images/self-hosting/configuration/email/email-mailhog-credentials.png) - 3. With your Mailgun credentials, you can now set up your SMTP environment variables: +3. With your Mailgun credentials, you can now set up your SMTP environment variables: + +``` +SMTP_HOST=smtp.mailgun.org # obtained from credentials page +SMTP_USERNAME=postmaster@example.mailgun.org # obtained from credentials page +SMTP_PASSWORD=password # obtained from credentials page +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails +SMTP_FROM_NAME=Infisical +``` - ``` - SMTP_HOST=smtp.mailgun.org # obtained from credentials page - SMTP_USERNAME=postmaster@example.mailgun.org # obtained from credentials page - SMTP_PASSWORD=password # obtained from credentials page - SMTP_PORT=587 - SMTP_SECURE=true - SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails - SMTP_FROM_NAME=Infisical - ``` @@ -149,6 +164,7 @@ Without email configuration, Infisical's core functions like sign-up/login and s SMTP_FROM_NAME=Infisical ``` + @@ -160,30 +176,32 @@ Without email configuration, Infisical's core functions like sign-up/login and s 1. Create an account and configure [SocketLabs](https://www.socketlabs.com/) to send emails. 2. From the dashboard, navigate to SMTP Credentials > SMTP & APIs > SMTP Credentials to obtain your SocketLabs SMTP credentials. - ![opening SocketLabs dashboard](../../images/self-hosting/configuration/email/email-socketlabs-dashboard.png) +![opening SocketLabs dashboard](../../images/self-hosting/configuration/email/email-socketlabs-dashboard.png) - ![obtaining SocketLabs credentials](../../images/self-hosting/configuration/email/email-socketlabs-credentials.png) +![obtaining SocketLabs credentials](../../images/self-hosting/configuration/email/email-socketlabs-credentials.png) - 3. With your SocketLabs SMTP credentials, you can now set up your SMTP environment variables: +3. With your SocketLabs SMTP credentials, you can now set up your SMTP environment variables: - ``` - SMTP_HOST=smtp.socketlabs.com - SMTP_USERNAME=username # obtained from your credentials - SMTP_PASSWORD=password # obtained from your credentials - SMTP_PORT=587 - SMTP_SECURE=true - SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails - SMTP_FROM_NAME=Infisical - ``` +``` +SMTP_HOST=smtp.socketlabs.com +SMTP_USERNAME=username # obtained from your credentials +SMTP_PASSWORD=password # obtained from your credentials +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails +SMTP_FROM_NAME=Infisical +``` - - The `SMTP_FROM_ADDRESS` environment variable should be an email for an - authenticated domain under Configuration > Domain Management in SocketLabs. - For example, if you're using SocketLabs in sandbox mode, then you may use an - email like `team@sandbox.socketlabs.dev`. - +{" "} - ![SocketLabs domain management](../../images/self-hosting/configuration/email/email-socketlabs-domains.png) + + The `SMTP_FROM_ADDRESS` environment variable should be an email for an + authenticated domain under Configuration > Domain Management in SocketLabs. + For example, if you're using SocketLabs in sandbox mode, then you may use an + email like `team@sandbox.socketlabs.dev`. + + +![SocketLabs domain management](../../images/self-hosting/configuration/email/email-socketlabs-domains.png) Remember that you will need to restart Infisical for this to work properly. @@ -194,55 +212,57 @@ Without email configuration, Infisical's core functions like sign-up/login and s 1. Create an account on [Resend](https://resend.com). 2. Add a [Domain](https://resend.com/domains). - ![adding resend domain](../../images/self-hosting/configuration/email/email-resend-create-domain.png) +![adding resend domain](../../images/self-hosting/configuration/email/email-resend-create-domain.png) - 3. Create an [API Key](https://resend.com/api-keys). +3. Create an [API Key](https://resend.com/api-keys). - ![creating resend api key](../../images/self-hosting/configuration/email/email-resend-create-key.png) +![creating resend api key](../../images/self-hosting/configuration/email/email-resend-create-key.png) - 4. Go to the [SMTP page](https://resend.com/settings/smtp) and copy the values. +4. Go to the [SMTP page](https://resend.com/settings/smtp) and copy the values. - ![go to resend smtp settings](../../images/self-hosting/configuration/email/email-resend-smtp-settings.png) +![go to resend smtp settings](../../images/self-hosting/configuration/email/email-resend-smtp-settings.png) - 5. With the API Key, you can now set your SMTP environment variables variables: +5. With the API Key, you can now set your SMTP environment variables variables: + +``` +SMTP_HOST=smtp.resend.com +SMTP_USERNAME=resend +SMTP_PASSWORD=YOUR_API_KEY +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails +SMTP_FROM_NAME=Infisical +``` - ``` - SMTP_HOST=smtp.resend.com - SMTP_USERNAME=resend - SMTP_PASSWORD=YOUR_API_KEY - SMTP_PORT=587 - SMTP_SECURE=true - SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails - SMTP_FROM_NAME=Infisical - ``` Remember that you will need to restart Infisical for this to work properly. + Create an account and enable "less secure app access" in Gmail Account Settings > Security. This will allow applications like Infisical to authenticate with Gmail via your username and password. - ![Gmail secure app access](../../images/self-hosting/configuration/email/email-gmail-app-access.png) +![Gmail secure app access](../../images/self-hosting/configuration/email/email-gmail-app-access.png) - With your Gmail username and password, you can set your SMTP environment variables: +With your Gmail username and password, you can set your SMTP environment variables: - ``` - SMTP_HOST=smtp.gmail.com - SMTP_USERNAME=hey@gmail.com # your email - SMTP_PASSWORD=password # your password - SMTP_PORT=587 - SMTP_SECURE=true - SMTP_FROM_ADDRESS=hey@gmail.com - SMTP_FROM_NAME=Infisical - ``` +``` +SMTP_HOST=smtp.gmail.com +SMTP_USERNAME=hey@gmail.com # your email +SMTP_PASSWORD=password # your password +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@gmail.com +SMTP_FROM_NAME=Infisical +``` As per the [notice](https://support.google.com/accounts/answer/6010255?hl=en) by Google, you should note that using Gmail credentials for SMTP configuration will only work for Google Workspace or Google Cloud Identity customers as of May 30, 2022. - Put differently, the SMTP configuration is only possible with business (not personal) Gmail credentials. +Put differently, the SMTP configuration is only possible with business (not personal) Gmail credentials. @@ -250,51 +270,51 @@ Without email configuration, Infisical's core functions like sign-up/login and s 1. Create an account and configure [Office365](https://www.office.com/) to send emails. - 2. With your login credentials, you can now set up your SMTP environment variables: +2. With your login credentials, you can now set up your SMTP environment variables: + +``` +SMTP_HOST=smtp.office365.com +SMTP_USERNAME=username@yourdomain.com # your username +SMTP_PASSWORD=password # your password +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=username@yourdomain.com +SMTP_FROM_NAME=Infisical +``` - ``` - SMTP_HOST=smtp.office365.com - SMTP_USERNAME=username@yourdomain.com # your username - SMTP_PASSWORD=password # your password - SMTP_PORT=587 - SMTP_SECURE=true - SMTP_FROM_ADDRESS=username@yourdomain.com - SMTP_FROM_NAME=Infisical - ``` 1. Create an account and configure [Zoho Mail](https://www.zoho.com/mail/) to send emails. - 2. With your email credentials, you can now set up your SMTP environment variables: +2. With your email credentials, you can now set up your SMTP environment variables: - ``` - SMTP_HOST=smtp.zoho.com - SMTP_USERNAME=username # your email - SMTP_PASSWORD=password # your password - SMTP_PORT=587 - SMTP_SECURE=true - SMTP_FROM_ADDRESS=hey@example.com # your personal Zoho email or domain-based email linked to Zoho Mail - SMTP_FROM_NAME=Infisical - ``` +``` +SMTP_HOST=smtp.zoho.com +SMTP_USERNAME=username # your email +SMTP_PASSWORD=password # your password +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@example.com # your personal Zoho email or domain-based email linked to Zoho Mail +SMTP_FROM_NAME=Infisical +``` - - You can use either your personal Zoho email address like `you@zohomail.com` or - a domain-based email address like `you@yourdomain.com`. If using a - domain-based email address, then please make sure that you've configured and - verified it with Zoho Mail. - +{" "} + + + You can use either your personal Zoho email address like `you@zohomail.com` or + a domain-based email address like `you@yourdomain.com`. If using a + domain-based email address, then please make sure that you've configured and + verified it with Zoho Mail. + Remember that you will need to restart Infisical for this to work properly. +## Authentication - - - -## SSO based login By default, users can only login via email/password based login method. To login into Infisical with OAuth providers such as Google, configure the associated variables. @@ -335,33 +355,39 @@ To login into Infisical with OAuth providers such as Google, configure the assoc - Requires enterprise license. Please contact team@infisical.com to get more information. + Requires enterprise license. Please contact team@infisical.com to get more + information. - Requires enterprise license. Please contact team@infisical.com to get more information. + Requires enterprise license. Please contact team@infisical.com to get more + information. - Requires enterprise license. Please contact team@infisical.com to get more information. + Requires enterprise license. Please contact team@infisical.com to get more + information. - Configure SAML organization slug to automatically redirect all users of your Infisical instance to the identity provider. + Configure SAML organization slug to automatically redirect all users of your + Infisical instance to the identity provider. - - - - ## Native secret integrations + To help you sync secrets from Infisical to services such as Github and Gitlab, Infisical provides native integrations out of the box. OAuth2 client ID for Heroku integration - + OAuth2 client secret for Heroku integration @@ -371,9 +397,11 @@ To help you sync secrets from Infisical to services such as Github and Gitlab, I OAuth2 client ID for Vercel integration - - OAuth2 client secret for Vercel integration - +{" "} + + + OAuth2 client secret for Vercel integration + OAuth2 slug for Vercel integration diff --git a/frontend/src/const.ts b/frontend/src/const.ts index 67340780d7..68ba1c4972 100644 --- a/frontend/src/const.ts +++ b/frontend/src/const.ts @@ -6,6 +6,7 @@ export const publicPaths = [ "/signup", "/signup/sso", "/login", + "/login/ldap", "/blog", "/docs", "/changelog", diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index c7022a1d72..f5aaabc83e 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -3,6 +3,8 @@ export type TServerConfig = { allowSignUp: boolean; allowedSignUpDomain?: string | null; isMigrationModeOn?: boolean; + trustSamlEmails: boolean; + trustLdapEmails: boolean; }; export type TCreateAdminUserDTO = { diff --git a/frontend/src/hooks/api/auth/index.tsx b/frontend/src/hooks/api/auth/index.tsx index 8b918c7ab5..505f7b05f0 100644 --- a/frontend/src/hooks/api/auth/index.tsx +++ b/frontend/src/hooks/api/auth/index.tsx @@ -5,7 +5,6 @@ export { useSendMfaToken, useSendPasswordResetEmail, useSendVerificationEmail, - useVerifyEmailVerificationCode, useVerifyMfaToken, - useVerifyPasswordResetCode -} from "./queries"; + useVerifyPasswordResetCode, + useVerifySignupEmailVerificationCode} from "./queries"; diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index 4d05fb9639..20209df710 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -164,7 +164,7 @@ export const useSendVerificationEmail = () => { }); }; -export const useVerifyEmailVerificationCode = () => { +export const useVerifySignupEmailVerificationCode = () => { return useMutation({ mutationFn: async ({ email, code }: { email: string; code: string }) => { const { data } = await apiRequest.post("/api/v3/signup/email/verify", { diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index 9c51948f27..a8ad89f4cf 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -1,4 +1,9 @@ -export { useAddUserToWsE2EE, useAddUserToWsNonE2EE } from "./mutation"; +export { + useAddUserToWsE2EE, + useAddUserToWsNonE2EE, + useSendEmailVerificationCode, + useVerifyEmailVerificationCode +} from "./mutation"; export { fetchOrgUsers, useAddUserToOrg, diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index a5c77b15fb..20e986aab3 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -61,3 +61,30 @@ export const useAddUserToWsNonE2EE = () => { } }); }; + +export const sendEmailVerificationCode = async (username: string) => { + return apiRequest.post("/api/v2/users/me/emails/code", { + username + }); +}; + +export const useSendEmailVerificationCode = () => { + return useMutation({ + mutationFn: async (username: string) => { + await sendEmailVerificationCode(username); + return {}; + } + }); +}; + +export const useVerifyEmailVerificationCode = () => { + return useMutation({ + mutationFn: async ({ username, code }: { username: string; code: string }) => { + await apiRequest.post("/api/v2/users/me/emails/verify", { + username, + code + }); + return {}; + } + }); +}; diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 572296e75b..649af434cf 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -27,6 +27,11 @@ export type User = { id: string; }; +export enum UserAliasType { + LDAP = "ldap", + SAML = "saml" +} + export type UserEnc = { encryptionVersion?: number; protectedKey?: string; diff --git a/frontend/src/pages/login/index.tsx b/frontend/src/pages/login/index.tsx index dd8068dd23..8fdb23f69c 100644 --- a/frontend/src/pages/login/index.tsx +++ b/frontend/src/pages/login/index.tsx @@ -7,7 +7,6 @@ import { Login } from "@app/views/Login"; export default function LoginPage() { const { t } = useTranslation(); - return (
diff --git a/frontend/src/pages/login/ldap/index.tsx b/frontend/src/pages/login/ldap/index.tsx new file mode 100644 index 0000000000..5d5300e6f5 --- /dev/null +++ b/frontend/src/pages/login/ldap/index.tsx @@ -0,0 +1,27 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; +import Image from "next/image"; +import Link from "next/link"; + +import { LoginLDAP } from "@app/views/Login"; + +export default function LoginLDAPPage() { + const { t } = useTranslation(); + return ( +
+ + {t("common.head-title", { title: t("login.title") })} + + + + + + +
+ Infisical logo +
+ + +
+ ); +} diff --git a/frontend/src/pages/signup/index.tsx b/frontend/src/pages/signup/index.tsx index 17316e1e6a..0719111d6f 100644 --- a/frontend/src/pages/signup/index.tsx +++ b/frontend/src/pages/signup/index.tsx @@ -13,7 +13,7 @@ import TeamInviteStep from "@app/components/signup/TeamInviteStep"; import UserInfoStep from "@app/components/signup/UserInfoStep"; import SecurityClient from "@app/components/utilities/SecurityClient"; import { useServerConfig } from "@app/context"; -import { useVerifyEmailVerificationCode } from "@app/hooks/api"; +import { useVerifySignupEmailVerificationCode } from "@app/hooks/api"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; @@ -34,7 +34,7 @@ export default function SignUp() { const [isSignupWithEmail, setIsSignupWithEmail] = useState(false); const [isCodeInputCheckLoading, setIsCodeInputCheckLoading] = useState(false); const { t } = useTranslation(); - const { mutateAsync } = useVerifyEmailVerificationCode(); + const { mutateAsync } = useVerifySignupEmailVerificationCode(); const { config } = useServerConfig(); useEffect(() => { diff --git a/frontend/src/views/Login/Login.tsx b/frontend/src/views/Login/Login.tsx index ac56c28c33..04a24d233c 100644 --- a/frontend/src/views/Login/Login.tsx +++ b/frontend/src/views/Login/Login.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/router"; import { isLoggedIn } from "@app/reactQuery"; -import { InitialStep, LDAPStep, MFAStep, SAMLSSOStep } from "./components"; +import { InitialStep, MFAStep, SAMLSSOStep } from "./components"; import { navigateUserToSelectOrg } from "./Login.utils"; export const Login = () => { @@ -58,8 +58,6 @@ export const Login = () => { ); case 2: return ; - case 3: - return ; default: return
; } diff --git a/frontend/src/views/Login/components/LDAPStep/LDAPStep.tsx b/frontend/src/views/Login/LoginLDAP.tsx similarity index 88% rename from frontend/src/views/Login/components/LDAPStep/LDAPStep.tsx rename to frontend/src/views/Login/LoginLDAP.tsx index e23f16e04d..021ac73342 100644 --- a/frontend/src/views/Login/components/LDAPStep/LDAPStep.tsx +++ b/frontend/src/views/Login/LoginLDAP.tsx @@ -1,24 +1,23 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { useRouter } from "next/router"; import { createNotification } from "@app/components/notifications"; import { Button, Input } from "@app/components/v2"; import { loginLDAPRedirect } from "@app/hooks/api/auth/queries"; -type Props = { - setStep: (step: number) => void; -}; +export const LoginLDAP = () => { + const router = useRouter(); + const queryParams = new URLSearchParams(window.location.search); + const passedOrgSlug = queryParams.get("organizationSlug"); + const passedUsername = queryParams.get("username"); -export const LDAPStep = ({ setStep }: Props) => { - - const [organizationSlug, setOrganizationSlug] = useState(""); - const [username, setUsername] = useState(""); + const [organizationSlug, setOrganizationSlug] = useState(passedOrgSlug || ""); + const [username, setUsername] = useState(passedUsername || ""); const [password, setPassword] = useState(""); const { t } = useTranslation(); - // const queryParams = new URLSearchParams(window.location.search); - const handleSubmission = async (e: React.FormEvent) => { e.preventDefault(); try { @@ -42,7 +41,6 @@ export const LDAPStep = ({ setStep }: Props) => { type: "success" }); - // redirects either to /login/sso or /signup/sso window.open(nextUrl); window.close(); } catch (err) { @@ -76,6 +74,7 @@ export const LDAPStep = ({ setStep }: Props) => { autoComplete="email" id="email" className="h-12" + isDisabled={passedOrgSlug !== null} />
@@ -90,6 +89,7 @@ export const LDAPStep = ({ setStep }: Props) => { autoComplete="email" id="email" className="h-12" + isDisabled={passedUsername !== null} /> @@ -122,7 +122,7 @@ export const LDAPStep = ({ setStep }: Props) => {
)} diff --git a/frontend/src/views/Signup/SignupSSO.tsx b/frontend/src/views/Signup/SignupSSO.tsx index 141865026e..2510c9fa43 100644 --- a/frontend/src/views/Signup/SignupSSO.tsx +++ b/frontend/src/views/Signup/SignupSSO.tsx @@ -1,7 +1,7 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import jwt_decode from "jwt-decode"; -import { BackupPDFStep, UserInfoSSOStep } from "./components"; +import { BackupPDFStep, EmailConfirmationStep, UserInfoSSOStep } from "./components"; type Props = { providerAuthToken: string; @@ -11,11 +11,38 @@ export const SignupSSO = ({ providerAuthToken }: Props) => { const [step, setStep] = useState(0); const [password, setPassword] = useState(""); - const { username, organizationName, firstName, lastName } = jwt_decode(providerAuthToken) as any; + const { + username, + email, + organizationName, + organizationSlug, + firstName, + lastName, + authType, + isEmailVerified + } = jwt_decode(providerAuthToken) as any; + + useEffect(() => { + if (!isEmailVerified) { + setStep(0); + } else { + setStep(1); + } + }, []); const renderView = () => { switch (step) { case 0: + return ( + + ); + case 1: return ( { providerAuthToken={providerAuthToken} /> ); - case 1: + case 2: return ( ); diff --git a/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx b/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx new file mode 100644 index 0000000000..c608fb99c7 --- /dev/null +++ b/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx @@ -0,0 +1,185 @@ +// confirm email +// if same email exists, then trigger fn to merge automatically +import { useState } from "react"; +import ReactCodeInput from "react-code-input"; +import { useRouter } from "next/router"; + +import Error from "@app/components/basic/Error"; +import { createNotification } from "@app/components/notifications"; +import { Button } from "@app/components/v2"; +import { useSendEmailVerificationCode, useVerifyEmailVerificationCode } from "@app/hooks/api"; +import { UserAliasType } from "@app/hooks/api/users/types"; + +type Props = { + authType?: UserAliasType; + username: string; + email: string; + organizationSlug: string; + setStep: (step: number) => void; +}; + +// The style for the verification code input +const props = { + inputStyle: { + fontFamily: "monospace", + margin: "4px", + MozAppearance: "textfield", + width: "55px", + borderRadius: "5px", + fontSize: "24px", + height: "55px", + paddingLeft: "7", + backgroundColor: "#0d1117", + color: "white", + border: "1px solid #2d2f33", + textAlign: "center", + outlineColor: "#8ca542", + borderColor: "#2d2f33" + } +} as const; +const propsPhone = { + inputStyle: { + fontFamily: "monospace", + margin: "4px", + MozAppearance: "textfield", + width: "40px", + borderRadius: "5px", + fontSize: "24px", + height: "40px", + paddingLeft: "7", + backgroundColor: "#0d1117", + color: "white", + border: "1px solid #2d2f33", + textAlign: "center", + outlineColor: "#8ca542", + borderColor: "#2d2f33" + } +} as const; + +export const EmailConfirmationStep = ({ + authType, + username, + email, + organizationSlug, + setStep +}: Props) => { + const router = useRouter(); + const [code, setCode] = useState(""); + const [codeError, setCodeError] = useState(false); + const [isResendingVerificationEmail] = useState(false); + const [isLoading] = useState(false); + + const { mutateAsync: sendEmailVerificationCode } = useSendEmailVerificationCode(); + const { mutateAsync: verifyEmailVerificationCode } = useVerifyEmailVerificationCode(); + + const checkCode = async () => { + try { + await verifyEmailVerificationCode({ username, code }); + setCodeError(false); + + createNotification({ + text: "Successfully verified code", + type: "success" + }); + + switch (authType) { + case UserAliasType.SAML: { + window.open(`/api/v1/sso/redirect/saml2/organizations/${organizationSlug}`); + window.close(); + break; + } + case UserAliasType.LDAP: { + router.push(`/login/ldap?organizationSlug=${organizationSlug}`); + break; + } + default: { + setStep(1); + break; + } + } + } catch (err) { + createNotification({ + text: "Failed to verify code", + type: "error" + }); + } + + setCode(""); + }; + + const resendCode = async () => { + try { + await sendEmailVerificationCode(username); + createNotification({ + text: "Successfully resent code", + type: "success" + }); + } catch (err) { + createNotification({ + text: "Failed to resend code", + type: "error" + }); + } + }; + + return ( +
+

+ We've sent a verification code to {email} +

+
+ +
+
+ +
+ {codeError && } +
+
+ +
+
+
+
+ Don't see the code? +
+ +
+
+

Make sure to check your spam inbox.

+
+
+ ); +}; diff --git a/frontend/src/views/Signup/components/EmailConfirmationStep/index.tsx b/frontend/src/views/Signup/components/EmailConfirmationStep/index.tsx new file mode 100644 index 0000000000..32f3a636e1 --- /dev/null +++ b/frontend/src/views/Signup/components/EmailConfirmationStep/index.tsx @@ -0,0 +1 @@ +export { EmailConfirmationStep } from "./EmailConfirmationStep"; diff --git a/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx b/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx index 1cef143d53..69168e0aff 100644 --- a/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx +++ b/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx @@ -201,7 +201,7 @@ export const UserInfoSSOStep = ({ localStorage.setItem("orgData.id", orgId); localStorage.setItem("projectData.id", project.id); - setStep(1); + setStep(2); } catch (error) { setIsLoading(false); console.error(error); diff --git a/frontend/src/views/Signup/components/index.tsx b/frontend/src/views/Signup/components/index.tsx index a4628de355..7ab3d853cc 100644 --- a/frontend/src/views/Signup/components/index.tsx +++ b/frontend/src/views/Signup/components/index.tsx @@ -1,2 +1,3 @@ export { BackupPDFStep } from "./BackupPDFStep"; +export { EmailConfirmationStep } from "./EmailConfirmationStep"; export { UserInfoSSOStep } from "./UserInfoSSOStep"; diff --git a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx index d5bc4097eb..ce1b117f3b 100644 --- a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx @@ -14,11 +14,11 @@ import { Input, Select, SelectItem, + Switch, Tab, TabList, TabPanel, - Tabs -} from "@app/components/v2"; + Tabs} from "@app/components/v2"; import { useOrganization, useServerConfig, useUser } from "@app/context"; import { useUpdateServerConfig } from "@app/hooks/api"; @@ -33,7 +33,9 @@ enum SignUpModes { const formSchema = z.object({ signUpMode: z.nativeEnum(SignUpModes), - allowedSignUpDomain: z.string().optional().nullable() + allowedSignUpDomain: z.string().optional().nullable(), + trustSamlEmails: z.boolean(), + trustLdapEmails: z.boolean() }); type TDashboardForm = z.infer; @@ -52,7 +54,9 @@ export const AdminDashboardPage = () => { values: { // eslint-disable-next-line signUpMode: config.allowSignUp ? SignUpModes.Anyone : SignUpModes.Disabled, - allowedSignUpDomain: config.allowedSignUpDomain + allowedSignUpDomain: config.allowedSignUpDomain, + trustSamlEmails: config.trustSamlEmails, + trustLdapEmails: config.trustLdapEmails } }); @@ -62,8 +66,6 @@ export const AdminDashboardPage = () => { const { orgs } = useOrganization(); const { mutateAsync: updateServerConfig } = useUpdateServerConfig(); - - const isNotAllowed = !user?.superAdmin; // TODO(akhilmhdh): on nextjs 14 roadmap this will be properly addressed with context split @@ -78,10 +80,13 @@ export const AdminDashboardPage = () => { const onFormSubmit = async (formData: TDashboardForm) => { try { - const { signUpMode, allowedSignUpDomain } = formData; + const { signUpMode, allowedSignUpDomain, trustSamlEmails, trustLdapEmails } = formData; + await updateServerConfig({ allowSignUp: signUpMode !== SignUpModes.Disabled, - allowedSignUpDomain: signUpMode === SignUpModes.Anyone ? allowedSignUpDomain : null + allowedSignUpDomain: signUpMode === SignUpModes.Anyone ? allowedSignUpDomain : null, + trustSamlEmails, + trustLdapEmails }); createNotification({ text: "Successfully changed sign up setting.", @@ -123,8 +128,9 @@ export const AdminDashboardPage = () => {
Allow user signups
-
- Select if you want users to be able to signup freely into your Infisical instance. +
+ Select if you want users to be able to signup freely into your Infisical + instance.
{ />
)} +
+
Trust emails
+
+ Select if you want Infisical to trust external emails from SAML/LDAP identity + providers. If set to false, then Infisical will prompt SAML/LDAP provisioned + users to verify their email upon their first login. +
+ { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Trust SAML emails

+
+
+ ); + }} + /> + { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Trust LDAP emails

+
+
+ ); + }} + /> +