Merge pull request #1762 from Infisical/groups-phase-2c

Groups Phase 2B (Trust external SAML/LDAP email option, email verification, SCIM user ID ref update)
This commit is contained in:
Maidul Islam
2024-05-06 23:27:50 -04:00
committed by GitHub
65 changed files with 1488 additions and 515 deletions

View File

@@ -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/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/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/MemberRbacSection.tsx:generic-api-key:206
frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:292 frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:292
docs/self-hosting/configuration/envars.mdx:generic-api-key:106

View File

@@ -0,0 +1,47 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {
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");
});
}
}

View File

@@ -14,7 +14,9 @@ export const SuperAdminSchema = z.object({
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
allowedSignUpDomain: z.string().nullable().optional(), 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<typeof SuperAdminSchema>; export type TSuperAdmin = z.infer<typeof SuperAdminSchema>;

View File

@@ -10,7 +10,7 @@ import { TImmutableDBKeys } from "./models";
export const UserAliasesSchema = z.object({ export const UserAliasesSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
userId: z.string().uuid(), userId: z.string().uuid(),
username: z.string(), username: z.string().nullable().optional(),
aliasType: z.string(), aliasType: z.string(),
externalId: z.string(), externalId: z.string(),
emails: z.string().array().nullable().optional(), emails: z.string().array().nullable().optional(),

View File

@@ -21,7 +21,8 @@ export const UsersSchema = z.object({
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
isGhost: z.boolean().default(false), isGhost: z.boolean().default(false),
username: z.string() username: z.string(),
isEmailVerified: z.boolean().nullable().optional()
}); });
export type TUsers = z.infer<typeof UsersSchema>; export type TUsers = z.infer<typeof UsersSchema>;

View File

@@ -18,6 +18,7 @@ import { LdapConfigsSchema, LdapGroupMapsSchema } from "@app/db/schemas";
import { TLDAPConfig } from "@app/ee/services/ldap-config/ldap-config-types"; import { TLDAPConfig } from "@app/ee/services/ldap-config/ldap-config-types";
import { isValidLdapFilter, searchGroups } from "@app/ee/services/ldap-config/ldap-fns"; import { isValidLdapFilter, searchGroups } from "@app/ee/services/ldap-config/ldap-fns";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
@@ -52,6 +53,7 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => {
// eslint-disable-next-line // eslint-disable-next-line
async (req: IncomingMessage, user, cb) => { async (req: IncomingMessage, user, cb) => {
try { try {
if (!user.email) throw new BadRequestError({ message: "Invalid request. Missing email." });
const ldapConfig = (req as unknown as FastifyRequest).ldapConfig as TLDAPConfig; const ldapConfig = (req as unknown as FastifyRequest).ldapConfig as TLDAPConfig;
let groups: { dn: string; cn: string }[] | undefined; let groups: { dn: string; cn: string }[] | undefined;
@@ -74,7 +76,7 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => {
username: user.uid, username: user.uid,
firstName: user.givenName ?? user.cn ?? "", firstName: user.givenName ?? user.cn ?? "",
lastName: user.sn ?? "", lastName: user.sn ?? "",
emails: user.mail ? [user.mail] : [], email: user.mail,
groups, groups,
relayState: ((req as unknown as FastifyRequest).body as { RelayState?: string }).RelayState, relayState: ((req as unknown as FastifyRequest).body as { RelayState?: string }).RelayState,
orgId: (req as unknown as FastifyRequest).ldapConfig.organization orgId: (req as unknown as FastifyRequest).ldapConfig.organization

View File

@@ -102,12 +102,12 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
if (!profile) throw new BadRequestError({ message: "Missing profile" }); 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 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" }); throw new BadRequestError({ message: "Invalid request. Missing email or first name" });
} }
const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({ const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({
username: profile.nameID ?? email, externalId: profile.nameID,
email, email,
firstName: profile.firstName as string, firstName: profile.firstName as string,
lastName: profile.lastName as string, lastName: profile.lastName as string,

View File

@@ -153,7 +153,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => { handler: async (req) => {
const users = await req.server.services.scim.listScimUsers({ const users = await req.server.services.scim.listScimUsers({
offset: req.query.startIndex, startIndex: req.query.startIndex,
limit: req.query.count, limit: req.query.count,
filter: req.query.filter, filter: req.query.filter,
orgId: req.permission.orgId orgId: req.permission.orgId
@@ -163,11 +163,11 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
}); });
server.route({ server.route({
url: "/Users/:userId", url: "/Users/:orgMembershipId",
method: "GET", method: "GET",
schema: { schema: {
params: z.object({ params: z.object({
userId: z.string().trim() orgMembershipId: z.string().trim()
}), }),
response: { response: {
201: z.object({ 201: z.object({
@@ -193,7 +193,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => { handler: async (req) => {
const user = await req.server.services.scim.getScimUser({ const user = await req.server.services.scim.getScimUser({
userId: req.params.userId, orgMembershipId: req.params.orgMembershipId,
orgId: req.permission.orgId orgId: req.permission.orgId
}); });
return user; return user;
@@ -249,7 +249,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
const primaryEmail = req.body.emails?.find((email) => email.primary)?.value; const primaryEmail = req.body.emails?.find((email) => email.primary)?.value;
const user = await req.server.services.scim.createScimUser({ const user = await req.server.services.scim.createScimUser({
username: req.body.userName, externalId: req.body.userName,
email: primaryEmail, email: primaryEmail,
firstName: req.body.name.givenName, firstName: req.body.name.givenName,
lastName: req.body.name.familyName, lastName: req.body.name.familyName,
@@ -261,11 +261,11 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
}); });
server.route({ server.route({
url: "/Users/:userId", url: "/Users/:orgMembershipId",
method: "DELETE", method: "DELETE",
schema: { schema: {
params: z.object({ params: z.object({
userId: z.string().trim() orgMembershipId: z.string().trim()
}), }),
response: { response: {
200: z.object({}) 200: z.object({})
@@ -274,7 +274,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => { handler: async (req) => {
const user = await req.server.services.scim.deleteScimUser({ const user = await req.server.services.scim.deleteScimUser({
userId: req.params.userId, orgMembershipId: req.params.orgMembershipId,
orgId: req.permission.orgId orgId: req.permission.orgId
}); });
@@ -361,7 +361,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
handler: async (req) => { handler: async (req) => {
const groups = await req.server.services.scim.listScimGroups({ const groups = await req.server.services.scim.listScimGroups({
orgId: req.permission.orgId, orgId: req.permission.orgId,
offset: req.query.startIndex, startIndex: req.query.startIndex,
limit: req.query.count limit: req.query.count
}); });
@@ -416,10 +416,10 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
displayName: z.string().trim(), displayName: z.string().trim(),
members: z.array( members: z.array(
z.object({ z.object({
value: z.string(), // infisical userId value: z.string(), // infisical orgMembershipId
display: z.string() display: z.string()
}) })
) // note: is this where members are added to group? )
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -534,11 +534,11 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
}); });
server.route({ server.route({
url: "/Users/:userId", url: "/Users/:orgMembershipId",
method: "PUT", method: "PUT",
schema: { schema: {
params: z.object({ params: z.object({
userId: z.string().trim() orgMembershipId: z.string().trim()
}), }),
body: z.object({ body: z.object({
schemas: z.array(z.string()), schemas: z.array(z.string()),
@@ -575,7 +575,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => { handler: async (req) => {
const user = await req.server.services.scim.replaceScimUser({ const user = await req.server.services.scim.replaceScimUser({
userId: req.params.userId, orgMembershipId: req.params.orgMembershipId,
orgId: req.permission.orgId, orgId: req.permission.orgId,
active: req.body.active active: req.body.active
}); });

View File

@@ -1,6 +1,6 @@
import { Knex } from "knex"; 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 { decryptAsymmetric, encryptAsymmetric, infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
import { BadRequestError, ScimRequestError } from "@app/lib/errors"; 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 // check if all user(s) are part of the organization
const existingUserOrgMemberships = await orgDAL.findMembership( const existingUserOrgMemberships = await orgDAL.findMembership(
{ {
orgId: group.orgId, [`${TableName.OrgMembership}.orgId` as "orgId"]: group.orgId,
$in: { $in: {
userId: userIds [`${TableName.OrgMembership}.userId` as "userId"]: userIds
} }
}, },
{ tx } { tx }

View File

@@ -1,7 +1,14 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import jwt from "jsonwebtoken"; 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 { TGroupDALFactory } from "@app/ee/services/group/group-dal";
import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "@app/ee/services/group/group-fns"; import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "@app/ee/services/group/group-fns";
import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; 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 { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal";
import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal";
import { TOrgDALFactory } from "@app/services/org/org-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 { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-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 { TUserDALFactory } from "@app/services/user/user-dal";
import { normalizeUsername } from "@app/services/user/user-fns"; import { normalizeUsername } from "@app/services/user/user-fns";
import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; 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 { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
@@ -46,6 +56,7 @@ import { TLdapGroupMapDALFactory } from "./ldap-group-map-dal";
type TLdapConfigServiceFactoryDep = { type TLdapConfigServiceFactoryDep = {
ldapConfigDAL: Pick<TLdapConfigDALFactory, "create" | "update" | "findOne">; ldapConfigDAL: Pick<TLdapConfigDALFactory, "create" | "update" | "findOne">;
ldapGroupMapDAL: Pick<TLdapGroupMapDALFactory, "find" | "create" | "delete" | "findLdapGroupMapsByLdapConfigId">; ldapGroupMapDAL: Pick<TLdapGroupMapDALFactory, "find" | "create" | "delete" | "findLdapGroupMapsByLdapConfigId">;
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "create">;
orgDAL: Pick< orgDAL: Pick<
TOrgDALFactory, TOrgDALFactory,
"createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById"
@@ -75,6 +86,7 @@ export const ldapConfigServiceFactory = ({
ldapConfigDAL, ldapConfigDAL,
ldapGroupMapDAL, ldapGroupMapDAL,
orgDAL, orgDAL,
orgMembershipDAL,
orgBotDAL, orgBotDAL,
groupDAL, groupDAL,
groupProjectDAL, groupProjectDAL,
@@ -379,16 +391,17 @@ export const ldapConfigServiceFactory = ({
username, username,
firstName, firstName,
lastName, lastName,
emails, email,
groups, groups,
orgId, orgId,
relayState relayState
}: TLdapLoginDTO) => { }: TLdapLoginDTO) => {
const appCfg = getConfig(); const appCfg = getConfig();
const serverCfg = await getServerCfg();
let userAlias = await userAliasDAL.findOne({ let userAlias = await userAliasDAL.findOne({
externalId, externalId,
orgId, orgId,
aliasType: AuthMethod.LDAP aliasType: UserAliasType.LDAP
}); });
const organization = await orgDAL.findOrgById(orgId); const organization = await orgDAL.findOrgById(orgId);
@@ -396,7 +409,13 @@ export const ldapConfigServiceFactory = ({
if (userAlias) { if (userAlias) {
await userDAL.transaction(async (tx) => { 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) { if (!orgMembership) {
await orgDAL.createMembership( await orgDAL.createMembership(
{ {
@@ -419,40 +438,75 @@ export const ldapConfigServiceFactory = ({
}); });
} else { } else {
userAlias = await userDAL.transaction(async (tx) => { userAlias = await userDAL.transaction(async (tx) => {
const uniqueUsername = await normalizeUsername(username, userDAL); let newUser: TUsers | undefined;
const newUser = await userDAL.create( if (serverCfg.trustSamlEmails) {
{ newUser = await userDAL.findOne(
username: uniqueUsername, {
email: emails[0], email,
firstName, isEmailVerified: true
lastName, },
authMethods: [AuthMethod.LDAP], tx
isGhost: false );
}, }
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( const newUserAlias = await userAliasDAL.create(
{ {
userId: newUser.id, userId: newUser.id,
username, username,
aliasType: AuthMethod.LDAP, aliasType: UserAliasType.LDAP,
externalId, externalId,
emails, emails: [email],
orgId orgId
}, },
tx tx
); );
await orgDAL.createMembership( const [orgMembership] = await orgDAL.findMembership(
{ {
userId: newUser.id, [`${TableName.OrgMembership}.userId` as "userId"]: newUser.id,
orgId, [`${TableName.OrgMembership}.orgId` as "id"]: orgId
role: OrgMembershipRole.Member,
status: OrgMembershipStatus.Invited
}, },
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; return newUserAlias;
}); });
} }
@@ -543,11 +597,14 @@ export const ldapConfigServiceFactory = ({
authTokenType: AuthTokenType.PROVIDER_TOKEN, authTokenType: AuthTokenType.PROVIDER_TOKEN,
userId: user.id, userId: user.id,
username: user.username, username: user.username,
...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }),
firstName, firstName,
lastName, lastName,
organizationName: organization.name, organizationName: organization.name,
organizationId: organization.id, organizationId: organization.id,
organizationSlug: organization.slug,
authMethod: AuthMethod.LDAP, authMethod: AuthMethod.LDAP,
authType: UserAliasType.LDAP,
isUserCompleted, isUserCompleted,
...(relayState ...(relayState
? { ? {

View File

@@ -51,7 +51,7 @@ export type TLdapLoginDTO = {
username: string; username: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
emails: string[]; email: string;
orgId: string; orgId: string;
groups?: { groups?: {
dn: string; dn: string;

View File

@@ -7,7 +7,8 @@ import {
SecretKeyEncoding, SecretKeyEncoding,
TableName, TableName,
TSamlConfigs, TSamlConfigs,
TSamlConfigsUpdate TSamlConfigsUpdate,
TUsers
} from "@app/db/schemas"; } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { import {
@@ -19,10 +20,18 @@ import {
infisicalSymmetricEncypt infisicalSymmetricEncypt
} from "@app/lib/crypto/encryption"; } from "@app/lib/crypto/encryption";
import { BadRequestError } from "@app/lib/errors"; 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 { TOrgBotDALFactory } from "@app/services/org/org-bot-dal";
import { TOrgDALFactory } from "@app/services/org/org-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 { 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 { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; 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"; import { TCreateSamlCfgDTO, TGetSamlCfgDTO, TSamlLoginDTO, TUpdateSamlCfgDTO } from "./saml-config-types";
type TSamlConfigServiceFactoryDep = { type TSamlConfigServiceFactoryDep = {
samlConfigDAL: TSamlConfigDALFactory; samlConfigDAL: Pick<TSamlConfigDALFactory, "create" | "findOne" | "update" | "findById">;
userDAL: Pick<TUserDALFactory, "create" | "findOne" | "transaction" | "updateById">; userDAL: Pick<TUserDALFactory, "create" | "findOne" | "transaction" | "updateById" | "findById">;
userAliasDAL: Pick<TUserAliasDALFactory, "create" | "findOne">;
orgDAL: Pick< orgDAL: Pick<
TOrgDALFactory, TOrgDALFactory,
"createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById"
>; >;
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "create">;
orgBotDAL: Pick<TOrgBotDALFactory, "findOne" | "create" | "transaction">; orgBotDAL: Pick<TOrgBotDALFactory, "findOne" | "create" | "transaction">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">; permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser">;
smtpService: Pick<TSmtpService, "sendMail">;
}; };
export type TSamlConfigServiceFactory = ReturnType<typeof samlConfigServiceFactory>; export type TSamlConfigServiceFactory = ReturnType<typeof samlConfigServiceFactory>;
@@ -48,9 +61,13 @@ export const samlConfigServiceFactory = ({
samlConfigDAL, samlConfigDAL,
orgBotDAL, orgBotDAL,
orgDAL, orgDAL,
orgMembershipDAL,
userDAL, userDAL,
userAliasDAL,
permissionService, permissionService,
licenseService licenseService,
tokenService,
smtpService
}: TSamlConfigServiceFactoryDep) => { }: TSamlConfigServiceFactoryDep) => {
const createSamlCfg = async ({ const createSamlCfg = async ({
cert, cert,
@@ -305,7 +322,7 @@ export const samlConfigServiceFactory = ({
}; };
const samlLogin = async ({ const samlLogin = async ({
username, externalId,
email, email,
firstName, firstName,
lastName, lastName,
@@ -314,38 +331,40 @@ export const samlConfigServiceFactory = ({
relayState relayState
}: TSamlLoginDTO) => { }: TSamlLoginDTO) => {
const appCfg = getConfig(); 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); const organization = await orgDAL.findOrgById(orgId);
if (!organization) throw new BadRequestError({ message: "Org not found" }); if (!organization) throw new BadRequestError({ message: "Org not found" });
// TODO(dangtony98): remove this after aliases update let user: TUsers;
if (authProvider === AuthMethod.KEYCLOAK_SAML && appCfg.LICENSE_SERVER_KEY) { if (userAlias) {
throw new BadRequestError({ message: "Keycloak SAML is not yet available on Infisical Cloud" }); user = await userDAL.transaction(async (tx) => {
} const foundUser = await userDAL.findById(userAlias.userId, tx);
if (user) {
await userDAL.transaction(async (tx) => {
const [orgMembership] = await orgDAL.findMembership( const [orgMembership] = await orgDAL.findMembership(
{ {
userId: user.id, [`${TableName.OrgMembership}.userId` as "userId"]: foundUser.id,
[`${TableName.OrgMembership}.orgId` as "id"]: orgId [`${TableName.OrgMembership}.orgId` as "id"]: orgId
}, },
{ tx } { tx }
); );
if (!orgMembership) { if (!orgMembership) {
await orgDAL.createMembership( await orgMembershipDAL.create(
{ {
userId: user.id, userId: userAlias.userId,
orgId,
inviteEmail: email, inviteEmail: email,
orgId,
role: OrgMembershipRole.Member, 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 tx
); );
// Only update the membership to Accepted if the user account is already completed. // 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( await orgDAL.updateMembershipById(
orgMembership.id, orgMembership.id,
{ {
@@ -354,40 +373,97 @@ export const samlConfigServiceFactory = ({
tx tx
); );
} }
return foundUser;
}); });
} else { } else {
user = await userDAL.transaction(async (tx) => { 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, userId: newUser.id,
email, aliasType: UserAliasType.SAML,
firstName, externalId,
lastName, emails: email ? [email] : [],
authMethods: [AuthMethod.EMAIL], orgId
isGhost: false
}, },
tx tx
); );
await orgDAL.createMembership({
inviteEmail: email, const [orgMembership] = await orgDAL.findMembership(
orgId, {
role: OrgMembershipRole.Member, [`${TableName.OrgMembership}.userId` as "userId"]: newUser.id,
status: OrgMembershipStatus.Invited [`${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; return newUser;
}); });
} }
const isUserCompleted = Boolean(user.isAccepted); const isUserCompleted = Boolean(user.isAccepted);
const providerAuthToken = jwt.sign( const providerAuthToken = jwt.sign(
{ {
authTokenType: AuthTokenType.PROVIDER_TOKEN, authTokenType: AuthTokenType.PROVIDER_TOKEN,
userId: user.id, userId: user.id,
username: user.username, username: user.username,
...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }),
firstName, firstName,
lastName, lastName,
organizationName: organization.name, organizationName: organization.name,
organizationId: organization.id, organizationId: organization.id,
organizationSlug: organization.slug,
authMethod: authProvider, authMethod: authProvider,
authType: UserAliasType.SAML,
isUserCompleted, isUserCompleted,
...(relayState ...(relayState
? { ? {
@@ -403,6 +479,22 @@ export const samlConfigServiceFactory = ({
await samlConfigDAL.update({ orgId }, { lastUsed: new Date() }); 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 }; return { isUserCompleted, providerAuthToken };
}; };

View File

@@ -45,8 +45,8 @@ export type TGetSamlCfgDTO =
}; };
export type TSamlLoginDTO = { export type TSamlLoginDTO = {
username: string; externalId: string;
email?: string; email: string;
firstName: string; firstName: string;
lastName?: string; lastName?: string;
authProvider: string; authProvider: string;

View File

@@ -2,31 +2,31 @@ import { TListScimGroups, TListScimUsers, TScimGroup, TScimUser } from "./scim-t
export const buildScimUserList = ({ export const buildScimUserList = ({
scimUsers, scimUsers,
offset, startIndex,
limit limit
}: { }: {
scimUsers: TScimUser[]; scimUsers: TScimUser[];
offset: number; startIndex: number;
limit: number; limit: number;
}): TListScimUsers => { }): TListScimUsers => {
return { return {
Resources: scimUsers, Resources: scimUsers,
itemsPerPage: limit, itemsPerPage: limit,
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
startIndex: offset, startIndex,
totalResults: scimUsers.length totalResults: scimUsers.length
}; };
}; };
export const buildScimUser = ({ export const buildScimUser = ({
userId, orgMembershipId,
username, username,
email, email,
firstName, firstName,
lastName, lastName,
active active
}: { }: {
userId: string; orgMembershipId: string;
username: string; username: string;
email?: string | null; email?: string | null;
firstName: string; firstName: string;
@@ -35,7 +35,7 @@ export const buildScimUser = ({
}): TScimUser => { }): TScimUser => {
const scimUser = { const scimUser = {
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
id: userId, id: orgMembershipId,
userName: username, userName: username,
displayName: `${firstName} ${lastName}`, displayName: `${firstName} ${lastName}`,
name: { name: {
@@ -65,18 +65,18 @@ export const buildScimUser = ({
export const buildScimGroupList = ({ export const buildScimGroupList = ({
scimGroups, scimGroups,
offset, startIndex,
limit limit
}: { }: {
scimGroups: TScimGroup[]; scimGroups: TScimGroup[];
offset: number; startIndex: number;
limit: number; limit: number;
}): TListScimGroups => { }): TListScimGroups => {
return { return {
Resources: scimGroups, Resources: scimGroups,
itemsPerPage: limit, itemsPerPage: limit,
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
startIndex: offset, startIndex,
totalResults: scimGroups.length totalResults: scimGroups.length
}; };
}; };

View File

@@ -2,7 +2,7 @@ import { ForbiddenError } from "@casl/ability";
import slugify from "@sindresorhus/slugify"; import slugify from "@sindresorhus/slugify";
import jwt from "jsonwebtoken"; 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 { TGroupDALFactory } from "@app/ee/services/group/group-dal";
import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "@app/ee/services/group/group-fns"; import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "@app/ee/services/group/group-fns";
import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; 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 { BadRequestError, ScimRequestError, UnauthorizedError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
import { TOrgPermission } from "@app/lib/types"; 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 { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal";
import { TOrgDALFactory } from "@app/services/org/org-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 { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal";
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; 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 { 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 { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
@@ -47,24 +52,32 @@ import {
type TScimServiceFactoryDep = { type TScimServiceFactoryDep = {
scimDAL: Pick<TScimDALFactory, "create" | "find" | "findById" | "deleteById">; scimDAL: Pick<TScimDALFactory, "create" | "find" | "findById" | "deleteById">;
userDAL: Pick<TUserDALFactory, "find" | "findOne" | "create" | "transaction" | "findUserEncKeyByUserIdsBatch">; userDAL: Pick<
TUserDALFactory,
"find" | "findOne" | "create" | "transaction" | "findUserEncKeyByUserIdsBatch" | "findById"
>;
userAliasDAL: Pick<TUserAliasDALFactory, "findOne" | "create" | "delete">;
orgDAL: Pick< orgDAL: Pick<
TOrgDALFactory, TOrgDALFactory,
"createMembership" | "findById" | "findMembership" | "deleteMembershipById" | "transaction" "createMembership" | "findById" | "findMembership" | "deleteMembershipById" | "transaction" | "updateMembershipById"
>; >;
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "find" | "findOne" | "create" | "updateById">;
projectDAL: Pick<TProjectDALFactory, "find" | "findProjectGhostUser">; projectDAL: Pick<TProjectDALFactory, "find" | "findProjectGhostUser">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find" | "delete">; projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find" | "delete" | "findProjectMembershipsByUserId">;
groupDAL: Pick< groupDAL: Pick<
TGroupDALFactory, TGroupDALFactory,
"create" | "findOne" | "findAllGroupMembers" | "update" | "delete" | "findGroups" | "transaction" "create" | "findOne" | "findAllGroupMembers" | "update" | "delete" | "findGroups" | "transaction"
>; >;
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">; groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
userGroupMembershipDAL: TUserGroupMembershipDALFactory; // TODO: Pick userGroupMembershipDAL: Pick<
TUserGroupMembershipDALFactory,
"find" | "transaction" | "insertMany" | "filterProjectsByUserMembership" | "delete"
>;
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "findLatestProjectKey" | "insertMany" | "delete">; projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "findLatestProjectKey" | "insertMany" | "delete">;
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">; projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan" | "updateSubscriptionOrgMemberCount">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">; permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
smtpService: TSmtpService; smtpService: Pick<TSmtpService, "sendMail">;
}; };
export type TScimServiceFactory = ReturnType<typeof scimServiceFactory>; export type TScimServiceFactory = ReturnType<typeof scimServiceFactory>;
@@ -73,7 +86,9 @@ export const scimServiceFactory = ({
licenseService, licenseService,
scimDAL, scimDAL,
userDAL, userDAL,
userAliasDAL,
orgDAL, orgDAL,
orgMembershipDAL,
projectDAL, projectDAL,
projectMembershipDAL, projectMembershipDAL,
groupDAL, groupDAL,
@@ -160,7 +175,7 @@ export const scimServiceFactory = ({
}; };
// SCIM server endpoints // SCIM server endpoints
const listScimUsers = async ({ offset, limit, filter, orgId }: TListScimUsersDTO): Promise<TListScimUsers> => { const listScimUsers = async ({ startIndex, limit, filter, orgId }: TListScimUsersDTO): Promise<TListScimUsers> => {
const org = await orgDAL.findById(orgId); const org = await orgDAL.findById(orgId);
if (!org.scimEnabled) if (!org.scimEnabled)
@@ -178,11 +193,11 @@ export const scimServiceFactory = ({
attributeName = "email"; attributeName = "email";
} }
return { [attributeName]: parsedValue }; return { [attributeName]: parsedValue.replace(/"/g, "") };
}; };
const findOpts = { const findOpts = {
...(offset && { offset }), ...(startIndex && { offset: startIndex - 1 }),
...(limit && { limit }) ...(limit && { limit })
}; };
@@ -194,10 +209,10 @@ export const scimServiceFactory = ({
findOpts findOpts
); );
const scimUsers = users.map(({ userId, username, firstName, lastName, email }) => const scimUsers = users.map(({ id, externalId, username, firstName, lastName, email }) =>
buildScimUser({ buildScimUser({
userId: userId ?? "", orgMembershipId: id ?? "",
username, username: externalId ?? username,
firstName: firstName ?? "", firstName: firstName ?? "",
lastName: lastName ?? "", lastName: lastName ?? "",
email, email,
@@ -207,16 +222,16 @@ export const scimServiceFactory = ({
return buildScimUserList({ return buildScimUserList({
scimUsers, scimUsers,
offset, startIndex,
limit limit
}); });
}; };
const getScimUser = async ({ userId, orgId }: TGetScimUserDTO) => { const getScimUser = async ({ orgMembershipId, orgId }: TGetScimUserDTO) => {
const [membership] = await orgDAL const [membership] = await orgDAL
.findMembership({ .findMembership({
userId, [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId,
[`${TableName.OrgMembership}.orgId` as "id"]: orgId [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId
}) })
.catch(() => { .catch(() => {
throw new ScimRequestError({ throw new ScimRequestError({
@@ -238,8 +253,8 @@ export const scimServiceFactory = ({
}); });
return buildScimUser({ return buildScimUser({
userId: membership.userId as string, orgMembershipId: membership.id,
username: membership.username, username: membership.externalId ?? membership.username,
email: membership.email ?? "", email: membership.email ?? "",
firstName: membership.firstName as string, firstName: membership.firstName as string,
lastName: membership.lastName 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); const org = await orgDAL.findById(orgId);
if (!org) if (!org)
@@ -262,67 +279,121 @@ export const scimServiceFactory = ({
status: 403 status: 403
}); });
let user = await userDAL.findOne({ const appCfg = getConfig();
username const serverCfg = await getServerCfg();
const userAlias = await userAliasDAL.findOne({
externalId,
orgId,
aliasType: UserAliasType.SAML
}); });
if (user) { const { user: createdUser, orgMembership: createdOrgMembership } = await userDAL.transaction(async (tx) => {
await userDAL.transaction(async (tx) => { let user: TUsers | undefined;
const [orgMembership] = await orgDAL.findMembership( let orgMembership: TOrgMemberships;
if (userAlias) {
user = await userDAL.findById(userAlias.userId, tx);
orgMembership = await orgMembershipDAL.findOne(
{ {
userId: user.id, 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) { if (!orgMembership) {
await orgDAL.createMembership( orgMembership = await orgMembershipDAL.create(
{ {
userId: user.id, userId: userAlias.userId,
orgId,
inviteEmail: email, inviteEmail: email,
orgId,
role: OrgMembershipRole.Member, 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 tx
); );
} }
}); } else {
} else { if (serverCfg.trustSamlEmails) {
user = await userDAL.transaction(async (tx) => { user = await userDAL.findOne(
const newUser = await userDAL.create( {
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, userId: user.id,
email, aliasType: UserAliasType.SAML,
firstName, externalId,
lastName, emails: email ? [email] : [],
authMethods: [AuthMethod.EMAIL], orgId
isGhost: false
}, },
tx tx
); );
await orgDAL.createMembership( const [foundOrgMembership] = await orgDAL.findMembership(
{ {
inviteEmail: email, [`${TableName.OrgMembership}.userId` as "userId"]: user.id,
orgId, [`${TableName.OrgMembership}.orgId` as "id"]: orgId
userId: newUser.id,
role: OrgMembershipRole.Member,
status: OrgMembershipStatus.Invited
}, },
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) { if (email) {
await smtpService.sendMail({ await smtpService.sendMail({
@@ -337,20 +408,20 @@ export const scimServiceFactory = ({
} }
return buildScimUser({ return buildScimUser({
userId: user.id, orgMembershipId: createdOrgMembership.id,
username: user.username, username: externalId,
firstName: user.firstName as string, firstName: createdUser.firstName as string,
lastName: user.lastName as string, lastName: createdUser.lastName as string,
email: user.email ?? "", email: createdUser.email ?? "",
active: true active: true
}); });
}; };
const updateScimUser = async ({ userId, orgId, operations }: TUpdateScimUserDTO) => { const updateScimUser = async ({ orgMembershipId, orgId, operations }: TUpdateScimUserDTO) => {
const [membership] = await orgDAL const [membership] = await orgDAL
.findMembership({ .findMembership({
userId, [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId,
[`${TableName.OrgMembership}.orgId` as "id"]: orgId [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId
}) })
.catch(() => { .catch(() => {
throw new ScimRequestError({ throw new ScimRequestError({
@@ -386,18 +457,20 @@ export const scimServiceFactory = ({
}); });
if (!active) { if (!active) {
await deleteOrgMembership({ await deleteOrgMembershipFn({
orgMembershipId: membership.id, orgMembershipId: membership.id,
orgId: membership.orgId, orgId: membership.orgId,
orgDAL, orgDAL,
projectDAL, projectMembershipDAL,
projectMembershipDAL projectKeyDAL,
userAliasDAL,
licenseService
}); });
} }
return buildScimUser({ return buildScimUser({
userId: membership.userId as string, orgMembershipId: membership.id,
username: membership.username, username: membership.externalId ?? membership.username,
email: membership.email, email: membership.email,
firstName: membership.firstName as string, firstName: membership.firstName as string,
lastName: membership.lastName 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 const [membership] = await orgDAL
.findMembership({ .findMembership({
userId, [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId,
[`${TableName.OrgMembership}.orgId` as "id"]: orgId [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId
}) })
.catch(() => { .catch(() => {
throw new ScimRequestError({ throw new ScimRequestError({
@@ -431,19 +504,20 @@ export const scimServiceFactory = ({
}); });
if (!active) { if (!active) {
// tx await deleteOrgMembershipFn({
await deleteOrgMembership({
orgMembershipId: membership.id, orgMembershipId: membership.id,
orgId: membership.orgId, orgId: membership.orgId,
orgDAL, orgDAL,
projectDAL, projectMembershipDAL,
projectMembershipDAL projectKeyDAL,
userAliasDAL,
licenseService
}); });
} }
return buildScimUser({ return buildScimUser({
userId: membership.userId as string, orgMembershipId: membership.id,
username: membership.username, username: membership.externalId ?? membership.username,
email: membership.email, email: membership.email,
firstName: membership.firstName as string, firstName: membership.firstName as string,
lastName: membership.lastName as string, lastName: membership.lastName as string,
@@ -451,18 +525,11 @@ export const scimServiceFactory = ({
}); });
}; };
const deleteScimUser = async ({ userId, orgId }: TDeleteScimUserDTO) => { const deleteScimUser = async ({ orgMembershipId, orgId }: TDeleteScimUserDTO) => {
const [membership] = await orgDAL const [membership] = await orgDAL.findMembership({
.findMembership({ [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId,
userId, [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId
[`${TableName.OrgMembership}.orgId` as "id"]: orgId });
})
.catch(() => {
throw new ScimRequestError({
detail: "User not found",
status: 404
});
});
if (!membership) if (!membership)
throw new ScimRequestError({ throw new ScimRequestError({
@@ -477,18 +544,20 @@ export const scimServiceFactory = ({
}); });
} }
await deleteOrgMembership({ await deleteOrgMembershipFn({
orgMembershipId: membership.id, orgMembershipId: membership.id,
orgId: membership.orgId, orgId: membership.orgId,
orgDAL, orgDAL,
projectDAL, projectMembershipDAL,
projectMembershipDAL projectKeyDAL,
userAliasDAL,
licenseService
}); });
return {}; // intentionally return empty object upon success 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); const plan = await licenseService.getPlan(orgId);
if (!plan.groups) if (!plan.groups)
throw new BadRequestError({ throw new BadRequestError({
@@ -509,21 +578,27 @@ export const scimServiceFactory = ({
status: 403 status: 403
}); });
const groups = await groupDAL.findGroups({ const groups = await groupDAL.findGroups(
orgId {
}); orgId
},
{
offset: startIndex - 1,
limit
}
);
const scimGroups = groups.map((group) => const scimGroups = groups.map((group) =>
buildScimGroup({ buildScimGroup({
groupId: group.id, groupId: group.id,
name: group.name, name: group.name,
members: [] members: [] // does this need to be populated?
}) })
); );
return buildScimGroupList({ return buildScimGroupList({
scimGroups, scimGroups,
offset, startIndex,
limit limit
}); });
}; };
@@ -562,9 +637,15 @@ export const scimServiceFactory = ({
); );
if (members && members.length) { if (members && members.length) {
const orgMemberships = await orgMembershipDAL.find({
$in: {
id: members.map((member) => member.value)
}
});
const newMembers = await addUsersToGroupByUserIds({ const newMembers = await addUsersToGroupByUserIds({
group, group,
userIds: members.map((member) => member.value), userIds: orgMemberships.map((membership) => membership.userId as string),
userDAL, userDAL,
userGroupMembershipDAL, userGroupMembershipDAL,
orgDAL, orgDAL,
@@ -581,12 +662,19 @@ export const scimServiceFactory = ({
return { group, newMembers: [] }; 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({ return buildScimGroup({
groupId: newGroup.group.id, groupId: newGroup.group.id,
name: newGroup.group.name, name: newGroup.group.name,
members: newGroup.newMembers.map((member) => ({ members: orgMemberships.map(({ id, firstName, lastName }) => ({
value: member.id, value: id,
display: `${member.firstName} ${member.lastName}` display: `${firstName} ${lastName}`
})) }))
}); });
}; };
@@ -615,15 +703,22 @@ export const scimServiceFactory = ({
groupId: group.id 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({ return buildScimGroup({
groupId: group.id, groupId: group.id,
name: group.name, name: group.name,
members: users members: orgMemberships.map(({ id, firstName, lastName }) => ({
.filter((user) => user.isPartOfGroup) value: id,
.map((user) => ({ display: `${firstName} ${lastName}`
value: user.id, }))
display: `${user.firstName} ${user.lastName}`
}))
}); });
}; };
@@ -667,7 +762,13 @@ export const scimServiceFactory = ({
} }
if (members) { 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 = ( const directMemberUserIds = (
await userGroupMembershipDAL.find({ await userGroupMembershipDAL.find({
@@ -686,13 +787,13 @@ export const scimServiceFactory = ({
const allMembersUserIds = directMemberUserIds.concat(pendingGroupAdditionsUserIds); const allMembersUserIds = directMemberUserIds.concat(pendingGroupAdditionsUserIds);
const allMembersUserIdsSet = new Set(allMembersUserIds); 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)); const toRemoveUserIds = allMembersUserIds.filter((userId) => !membersIdsSet.has(userId));
if (toAddUserIds.length) { if (toAddUserIds.length) {
await addUsersToGroupByUserIds({ await addUsersToGroupByUserIds({
group, group,
userIds: toAddUserIds.map((member) => member.value), userIds: toAddUserIds.map((member) => member.userId as string),
userDAL, userDAL,
userGroupMembershipDAL, userGroupMembershipDAL,
orgDAL, orgDAL,

View File

@@ -12,7 +12,7 @@ export type TDeleteScimTokenDTO = {
// SCIM server endpoint types // SCIM server endpoint types
export type TListScimUsersDTO = { export type TListScimUsersDTO = {
offset: number; startIndex: number;
limit: number; limit: number;
filter?: string; filter?: string;
orgId: string; orgId: string;
@@ -27,12 +27,12 @@ export type TListScimUsers = {
}; };
export type TGetScimUserDTO = { export type TGetScimUserDTO = {
userId: string; orgMembershipId: string;
orgId: string; orgId: string;
}; };
export type TCreateScimUserDTO = { export type TCreateScimUserDTO = {
username: string; externalId: string;
email?: string; email?: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
@@ -40,7 +40,7 @@ export type TCreateScimUserDTO = {
}; };
export type TUpdateScimUserDTO = { export type TUpdateScimUserDTO = {
userId: string; orgMembershipId: string;
orgId: string; orgId: string;
operations: { operations: {
op: string; op: string;
@@ -54,18 +54,18 @@ export type TUpdateScimUserDTO = {
}; };
export type TReplaceScimUserDTO = { export type TReplaceScimUserDTO = {
userId: string; orgMembershipId: string;
active: boolean; active: boolean;
orgId: string; orgId: string;
}; };
export type TDeleteScimUserDTO = { export type TDeleteScimUserDTO = {
userId: string; orgMembershipId: string;
orgId: string; orgId: string;
}; };
export type TListScimGroupsDTO = { export type TListScimGroupsDTO = {
offset: number; startIndex: number;
limit: number; limit: number;
orgId: string; orgId: string;
}; };

View File

@@ -88,6 +88,7 @@ import { orgDALFactory } from "@app/services/org/org-dal";
import { orgRoleDALFactory } from "@app/services/org/org-role-dal"; import { orgRoleDALFactory } from "@app/services/org/org-role-dal";
import { orgRoleServiceFactory } from "@app/services/org/org-role-service"; import { orgRoleServiceFactory } from "@app/services/org/org-role-service";
import { orgServiceFactory } from "@app/services/org/org-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 { projectDALFactory } from "@app/services/project/project-dal";
import { projectQueueFactory } from "@app/services/project/project-queue"; import { projectQueueFactory } from "@app/services/project/project-queue";
import { projectServiceFactory } from "@app/services/project/project-service"; import { projectServiceFactory } from "@app/services/project/project-service";
@@ -155,6 +156,7 @@ export const registerRoutes = async (
const authDAL = authDALFactory(db); const authDAL = authDALFactory(db);
const authTokenDAL = tokenDALFactory(db); const authTokenDAL = tokenDALFactory(db);
const orgDAL = orgDALFactory(db); const orgDAL = orgDALFactory(db);
const orgMembershipDAL = orgMembershipDALFactory(db);
const orgBotDAL = orgBotDALFactory(db); const orgBotDAL = orgBotDALFactory(db);
const incidentContactDAL = incidentContactDALFactory(db); const incidentContactDAL = incidentContactDALFactory(db);
const orgRoleDAL = orgRoleDALFactory(db); const orgRoleDAL = orgRoleDALFactory(db);
@@ -262,13 +264,18 @@ export const registerRoutes = async (
permissionService, permissionService,
secretApprovalPolicyDAL secretApprovalPolicyDAL
}); });
const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL });
const samlService = samlConfigServiceFactory({ const samlService = samlConfigServiceFactory({
permissionService, permissionService,
orgBotDAL, orgBotDAL,
orgDAL, orgDAL,
orgMembershipDAL,
userDAL, userDAL,
userAliasDAL,
samlConfigDAL, samlConfigDAL,
licenseService licenseService,
tokenService,
smtpService
}); });
const groupService = groupServiceFactory({ const groupService = groupServiceFactory({
userDAL, userDAL,
@@ -297,7 +304,9 @@ export const registerRoutes = async (
licenseService, licenseService,
scimDAL, scimDAL,
userDAL, userDAL,
userAliasDAL,
orgDAL, orgDAL,
orgMembershipDAL,
projectDAL, projectDAL,
projectMembershipDAL, projectMembershipDAL,
groupDAL, groupDAL,
@@ -313,6 +322,7 @@ export const registerRoutes = async (
ldapConfigDAL, ldapConfigDAL,
ldapGroupMapDAL, ldapGroupMapDAL,
orgDAL, orgDAL,
orgMembershipDAL,
orgBotDAL, orgBotDAL,
groupDAL, groupDAL,
groupProjectDAL, groupProjectDAL,
@@ -336,8 +346,13 @@ export const registerRoutes = async (
queueService queueService
}); });
const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL }); const userService = userServiceFactory({
const userService = userServiceFactory({ userDAL }); userDAL,
userAliasDAL,
orgMembershipDAL,
tokenService,
smtpService
});
const loginService = authLoginServiceFactory({ userDAL, smtpService, tokenService, orgDAL, tokenDAL: authTokenDAL }); const loginService = authLoginServiceFactory({ userDAL, smtpService, tokenService, orgDAL, tokenDAL: authTokenDAL });
const passwordService = authPaswordServiceFactory({ const passwordService = authPaswordServiceFactory({
tokenService, tokenService,
@@ -346,6 +361,7 @@ export const registerRoutes = async (
userDAL userDAL
}); });
const orgService = orgServiceFactory({ const orgService = orgServiceFactory({
userAliasDAL,
licenseService, licenseService,
samlConfigDAL, samlConfigDAL,
orgRoleDAL, orgRoleDAL,

View File

@@ -42,7 +42,9 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
schema: { schema: {
body: z.object({ body: z.object({
allowSignUp: z.boolean().optional(), allowSignUp: z.boolean().optional(),
allowedSignUpDomain: z.string().optional().nullable() allowedSignUpDomain: z.string().optional().nullable(),
trustSamlEmails: z.boolean().optional(),
trustLdapEmails: z.boolean().optional()
}), }),
response: { response: {
200: z.object({ 200: z.object({

View File

@@ -2,11 +2,52 @@ import { z } from "zod";
import { AuthTokenSessionsSchema, OrganizationsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { AuthTokenSessionsSchema, OrganizationsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas";
import { ApiKeysSchema } from "@app/db/schemas/api-keys"; 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 { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMethod, AuthMode } from "@app/services/auth/auth-type"; import { AuthMethod, AuthMode } from "@app/services/auth/auth-type";
export const registerUserRouter = async (server: FastifyZodProvider) => { 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({ server.route({
method: "PATCH", method: "PATCH",
url: "/me/mfa", url: "/me/mfa",

View File

@@ -27,10 +27,17 @@ export const getTokenConfig = (tokenType: TokenType) => {
const expiresAt = new Date(new Date().getTime() + 86400000); const expiresAt = new Date(new Date().getTime() + 86400000);
return { token, expiresAt }; 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: { case TokenType.TOKEN_EMAIL_MFA: {
// generate random 6-digit code // generate random 6-digit code
const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1)); const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1));
const triesLeft = 5; const triesLeft = 3;
const expiresAt = new Date(new Date().getTime() + 300000); const expiresAt = new Date(new Date().getTime() + 300000);
return { token, triesLeft, expiresAt }; return { token, triesLeft, expiresAt };
} }

View File

@@ -1,5 +1,6 @@
export enum TokenType { export enum TokenType {
TOKEN_EMAIL_CONFIRMATION = "emailConfirmation", TOKEN_EMAIL_CONFIRMATION = "emailConfirmation",
TOKEN_EMAIL_VERIFICATION = "emailVerification", // unverified -> verified
TOKEN_EMAIL_MFA = "emailMfa", TOKEN_EMAIL_MFA = "emailMfa",
TOKEN_EMAIL_ORG_INVITATION = "organizationInvitation", TOKEN_EMAIL_ORG_INVITATION = "organizationInvitation",
TOKEN_EMAIL_PASSWORD_RESET = "passwordReset" TOKEN_EMAIL_PASSWORD_RESET = "passwordReset"

View File

@@ -361,6 +361,7 @@ export const authLoginServiceFactory = ({
user = await userDAL.create({ user = await userDAL.create({
username: email, username: email,
email, email,
isEmailVerified: true,
firstName, firstName,
lastName, lastName,
authMethods: [authMethod], authMethods: [authMethod],
@@ -374,6 +375,8 @@ export const authLoginServiceFactory = ({
authTokenType: AuthTokenType.PROVIDER_TOKEN, authTokenType: AuthTokenType.PROVIDER_TOKEN,
userId: user.id, userId: user.id,
username: user.username, username: user.username,
email: user.email,
isEmailVerified: user.isEmailVerified,
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
authMethod, authMethod,

View File

@@ -1,6 +1,6 @@
import jwt from "jsonwebtoken"; 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 { convertPendingGroupAdditionsToGroupMemberships } from "@app/ee/services/group/group-fns";
import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
@@ -80,7 +80,7 @@ export const authSignupServiceFactory = ({
}); });
await smtpService.sendMail({ await smtpService.sendMail({
template: SmtpTemplates.EmailVerification, template: SmtpTemplates.SignupEmailVerification,
subjectLine: "Infisical confirmation code", subjectLine: "Infisical confirmation code",
recipients: [user.email as string], recipients: [user.email as string],
substitutions: { substitutions: {
@@ -102,6 +102,8 @@ export const authSignupServiceFactory = ({
code code
}); });
await userDAL.updateById(user.id, { isEmailVerified: true });
// generate jwt token this is a temporary token // generate jwt token this is a temporary token
const jwtToken = jwt.sign( const jwtToken = jwt.sign(
{ {
@@ -169,12 +171,11 @@ export const authSignupServiceFactory = ({
tx 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 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({ const [pendingOrgMembership] = await orgDAL.findMembership({
inviteEmail: email, [`${TableName.OrgMembership}.userId` as "userId"]: user.id,
userId: user.id,
status: OrgMembershipStatus.Invited, status: OrgMembershipStatus.Invited,
orgId: organizationId [`${TableName.OrgMembership}.orgId` as "orgId"]: organizationId
}); });
if (pendingOrgMembership) { if (pendingOrgMembership) {

View File

@@ -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<typeof orgMembershipDALFactory>;
export const orgMembershipDALFactory = (db: TDbClient) => {
const orgMembershipOrm = ormify(db, TableName.OrgMembership);
return {
...orgMembershipOrm
};
};

View File

@@ -262,13 +262,19 @@ export const orgDALFactory = (db: TDbClient) => {
.where(buildFindFilter(filter)) .where(buildFindFilter(filter))
.join(TableName.Users, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`) .join(TableName.Users, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`)
.join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`) .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( .select(
selectAllTableCols(TableName.OrgMembership), selectAllTableCols(TableName.OrgMembership),
db.ref("email").withSchema(TableName.Users), db.ref("email").withSchema(TableName.Users),
db.ref("username").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users),
db.ref("firstName").withSchema(TableName.Users), db.ref("firstName").withSchema(TableName.Users),
db.ref("lastName").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 }); .where({ isGhost: false });

View File

@@ -1,41 +1,78 @@
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TOrgDALFactory } from "@app/services/org/org-dal"; 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 { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
type TDeleteOrgMembership = { type TDeleteOrgMembership = {
orgMembershipId: string; orgMembershipId: string;
orgId: string; orgId: string;
orgDAL: Pick<TOrgDALFactory, "findMembership" | "deleteMembershipById" | "transaction">; orgDAL: Pick<TOrgDALFactory, "findMembership" | "deleteMembershipById" | "transaction">;
projectDAL: Pick<TProjectDALFactory, "find">; projectMembershipDAL: Pick<TProjectMembershipDALFactory, "delete" | "findProjectMembershipsByUserId">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find" | "delete">; projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "delete">;
userAliasDAL: Pick<TUserAliasDALFactory, "delete">;
licenseService: Pick<TLicenseServiceFactory, "updateSubscriptionOrgMemberCount">;
}; };
export const deleteOrgMembership = async ({ export const deleteOrgMembershipFn = async ({
orgMembershipId, orgMembershipId,
orgId, orgId,
orgDAL, orgDAL,
projectDAL, projectMembershipDAL,
projectMembershipDAL projectKeyDAL,
userAliasDAL,
licenseService
}: TDeleteOrgMembership) => { }: TDeleteOrgMembership) => {
const membership = await orgDAL.transaction(async (tx) => { const deletedMembership = await orgDAL.transaction(async (tx) => {
// delete org membership
const orgMembership = await orgDAL.deleteMembershipById(orgMembershipId, orgId, 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 userAliasDAL.delete(
await projectMembershipDAL.delete(
{ {
$in: { userId: orgMembership.userId,
projectId: projects.map((project) => project.id) orgId
},
userId: orgMembership.userId as string
}, },
tx 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 orgMembership;
}); });
return membership; return deletedMembership;
}; };

View File

@@ -4,7 +4,7 @@ import crypto from "crypto";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { Knex } from "knex"; 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 { TProjects } from "@app/db/schemas/projects";
import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; 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 { BadRequestError, UnauthorizedError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
import { isDisposableEmail } from "@app/lib/validator"; 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 { ActorAuthMethod, ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type";
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; 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 { TIncidentContactsDALFactory } from "./incident-contacts-dal";
import { TOrgBotDALFactory } from "./org-bot-dal"; import { TOrgBotDALFactory } from "./org-bot-dal";
import { TOrgDALFactory } from "./org-dal"; import { TOrgDALFactory } from "./org-dal";
import { deleteOrgMembershipFn } from "./org-fns";
import { TOrgRoleDALFactory } from "./org-role-dal"; import { TOrgRoleDALFactory } from "./org-role-dal";
import { import {
TDeleteOrgMembershipDTO, TDeleteOrgMembershipDTO,
@@ -43,6 +45,7 @@ import {
} from "./org-types"; } from "./org-types";
type TOrgServiceFactoryDep = { type TOrgServiceFactoryDep = {
userAliasDAL: Pick<TUserAliasDALFactory, "delete">;
orgDAL: TOrgDALFactory; orgDAL: TOrgDALFactory;
orgBotDAL: TOrgBotDALFactory; orgBotDAL: TOrgBotDALFactory;
orgRoleDAL: TOrgRoleDALFactory; orgRoleDAL: TOrgRoleDALFactory;
@@ -65,6 +68,7 @@ type TOrgServiceFactoryDep = {
export type TOrgServiceFactory = ReturnType<typeof orgServiceFactory>; export type TOrgServiceFactory = ReturnType<typeof orgServiceFactory>;
export const orgServiceFactory = ({ export const orgServiceFactory = ({
userAliasDAL,
orgDAL, orgDAL,
userDAL, userDAL,
groupDAL, groupDAL,
@@ -427,7 +431,13 @@ export const orgServiceFactory = ({
if (inviteeUser) { if (inviteeUser) {
// if user already exist means its already part of infisical // if user already exist means its already part of infisical
// Thus the signup flow is not needed anymore // 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) { if (inviteeMembership && inviteeMembership.status === OrgMembershipStatus.Accepted) {
throw new BadRequestError({ throw new BadRequestError({
message: "Failed to invite an existing member of org", 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" }); throw new BadRequestError({ message: "Invalid request", name: "Verify user to org" });
} }
const [orgMembership] = await orgDAL.findMembership({ const [orgMembership] = await orgDAL.findMembership({
userId: user.id, [`${TableName.OrgMembership}.userId` as "userId"]: user.id,
status: OrgMembershipStatus.Invited, status: OrgMembershipStatus.Invited,
orgId [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId
}); });
if (!orgMembership) if (!orgMembership)
throw new BadRequestError({ throw new BadRequestError({
@@ -572,47 +582,14 @@ export const orgServiceFactory = ({
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Member); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Member);
const deletedMembership = await orgDAL.transaction(async (tx) => { const deletedMembership = await deleteOrgMembershipFn({
const orgMembership = await orgDAL.deleteMembershipById(membershipId, orgId, tx); orgMembershipId: membershipId,
orgId,
if (!orgMembership.userId) { orgDAL,
await licenseService.updateSubscriptionOrgMemberCount(orgId); projectMembershipDAL,
return orgMembership; projectKeyDAL,
} userAliasDAL,
licenseService
// 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 deletedMembership; return deletedMembership;

View File

@@ -110,7 +110,7 @@ export const projectMembershipServiceFactory = ({
); );
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member);
const orgMembers = await orgDAL.findMembership({ const orgMembers = await orgDAL.findMembership({
orgId: project.orgId, [`${TableName.OrgMembership}.orgId` as "orgId"]: project.orgId,
$in: { $in: {
[`${TableName.OrgMembership}.id` as "id"]: members.map(({ orgMembershipId }) => orgMembershipId) [`${TableName.OrgMembership}.id` as "id"]: members.map(({ orgMembershipId }) => orgMembershipId)
} }
@@ -119,7 +119,7 @@ export const projectMembershipServiceFactory = ({
const existingMembers = await projectMembershipDAL.find({ const existingMembers = await projectMembershipDAL.find({
projectId, 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" }); 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( const projectMemberships = await projectMembershipDAL.insertMany(
orgMembers.map(({ userId }) => ({ orgMembers.map(({ userId }) => ({
projectId, projectId,
userId: userId as string userId
})), })),
tx tx
); );
@@ -145,12 +145,12 @@ export const projectMembershipServiceFactory = ({
const encKeyGroupByOrgMembId = groupBy(members, (i) => i.orgMembershipId); const encKeyGroupByOrgMembId = groupBy(members, (i) => i.orgMembershipId);
await projectKeyDAL.insertMany( await projectKeyDAL.insertMany(
orgMembers orgMembers
.filter(({ userId }) => !userIdsToExcludeForProjectKeyAddition.has(userId as string)) .filter(({ userId }) => !userIdsToExcludeForProjectKeyAddition.has(userId))
.map(({ userId, id }) => ({ .map(({ userId, id }) => ({
encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey, encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey,
nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce, nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce,
senderId: actorId, senderId: actorId,
receiverId: userId as string, receiverId: userId,
projectId projectId
})), })),
tx tx

View File

@@ -8,6 +8,7 @@ import {
SecretKeyEncoding, SecretKeyEncoding,
SecretsSchema, SecretsSchema,
SecretVersionsSchema, SecretVersionsSchema,
TableName,
TIntegrationAuths, TIntegrationAuths,
TSecretApprovalRequestsSecrets, TSecretApprovalRequestsSecrets,
TSecrets, TSecrets,
@@ -273,7 +274,10 @@ export const projectQueueFactory = ({
for (const key of existingProjectKeys) { for (const key of existingProjectKeys) {
const user = await userDAL.findUserEncKeyByUserId(key.receiverId); 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) { if (!user) {
throw new Error(`User with ID ${key.receiverId} was not found during upgrade.`); throw new Error(`User with ID ${key.receiverId} was not found during upgrade.`);

View File

@@ -17,6 +17,7 @@ export type TSmtpSendMail = {
export type TSmtpService = ReturnType<typeof smtpServiceFactory>; export type TSmtpService = ReturnType<typeof smtpServiceFactory>;
export enum SmtpTemplates { export enum SmtpTemplates {
SignupEmailVerification = "signupEmailVerification.handlebars",
EmailVerification = "emailVerification.handlebars", EmailVerification = "emailVerification.handlebars",
SecretReminder = "secretReminder.handlebars", SecretReminder = "secretReminder.handlebars",
EmailMfa = "emailMfa.handlebars", EmailMfa = "emailMfa.handlebars",

View File

@@ -1,17 +1,15 @@
<!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="ie=edge"> <meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>Code</title> <title>Code</title>
</head> </head>
<body> <body>
<h2>Confirm your email address</h2> <h2>Confirm your email address</h2>
<p>Your confirmation code is below — enter it in the browser window where you've started signing up for Infisical.</p> <p>Your confirmation code is below — enter it in the browser window where you've started confirming your email.</p>
<h1>{{code}}</h1> <h1>{{code}}</h1>
<p>Questions about setting up Infisical? Email us at support@infisical.com</p> </body>
</body>
</html> </html>

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Code</title>
</head>
<body>
<h2>Confirm your email address</h2>
<p>Your confirmation code is below — enter it in the browser window where you've started signing up for Infisical.</p>
<h1>{{code}}</h1>
<p>Questions about setting up Infisical? Email us at support@infisical.com</p>
</body>
</html>

View File

@@ -102,7 +102,8 @@ export const superAdminServiceFactory = ({
superAdmin: true, superAdmin: true,
isGhost: false, isGhost: false,
isAccepted: true, isAccepted: true,
authMethods: [AuthMethod.EMAIL] authMethods: [AuthMethod.EMAIL],
isEmailVerified: true
}, },
tx tx
); );

View File

@@ -0,0 +1,4 @@
export enum UserAliasType {
LDAP = "ldap",
SAML = "saml"
}

View File

@@ -4,7 +4,7 @@ import { alphaNumericNanoId } from "@app/lib/nanoid";
import { TUserDALFactory } from "@app/services/user/user-dal"; import { TUserDALFactory } from "@app/services/user/user-dal";
export const normalizeUsername = async (username: string, userDAL: Pick<TUserDALFactory, "findOne">) => { export const normalizeUsername = async (username: string, userDAL: Pick<TUserDALFactory, "findOne">) => {
let attempt = slugify(username); let attempt = slugify(`${username}-${alphaNumericNanoId(4)}`);
let user = await userDAL.findOne({ username: attempt }); let user = await userDAL.findOne({ username: attempt });
if (!user) return attempt; if (!user) return attempt;

View File

@@ -1,15 +1,151 @@
import { BadRequestError } from "@app/lib/errors"; 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 { AuthMethod } from "../auth/auth-type";
import { TUserDALFactory } from "./user-dal"; import { TUserDALFactory } from "./user-dal";
type TUserServiceFactoryDep = { type TUserServiceFactoryDep = {
userDAL: TUserDALFactory; userDAL: Pick<
TUserDALFactory,
| "find"
| "findOne"
| "findById"
| "transaction"
| "updateById"
| "update"
| "deleteById"
| "findOneUserAction"
| "createUserAction"
| "findUserEncKeyByUserId"
>;
userAliasDAL: Pick<TUserAliasDALFactory, "find" | "insertMany">;
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "find" | "insertMany">;
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser" | "validateTokenForUser">;
smtpService: Pick<TSmtpService, "sendMail">;
}; };
export type TUserServiceFactory = ReturnType<typeof userServiceFactory>; export type TUserServiceFactory = ReturnType<typeof userServiceFactory>;
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 toggleUserMfa = async (userId: string, isMfaEnabled: boolean) => {
const user = await userDAL.findById(userId); const user = await userDAL.findById(userId);
@@ -72,6 +208,8 @@ export const userServiceFactory = ({ userDAL }: TUserServiceFactoryDep) => {
}; };
return { return {
sendEmailVerificationCode,
verifyEmailVerificationCode,
toggleUserMfa, toggleUserMfa,
updateUserName, updateUserName,
updateAuthMethods, updateAuthMethods,

View File

@@ -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) 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.
<Steps> <Steps>
<Step title="Prepare the LDAP configuration in Infisical"> <Step title="Prepare the LDAP configuration in Infisical">
In Infisical, head to your Organization Settings > Security > LDAP and select **Manage**. In Infisical, head to your Organization Settings > Security > LDAP and select **Manage**.

View File

@@ -10,6 +10,10 @@ description: "Learn how to configure JumpCloud LDAP for authenticating into Infi
it. it.
</Info> </Info>
Prerequisites:
- You must have an email address to use LDAP, regardless of whether or not you use that email address to sign in.
<Steps> <Steps>
<Step title="Prepare LDAP in JumpCloud"> <Step title="Prepare LDAP in JumpCloud">
In JumpCloud, head to USER MANAGEMENT > Users and create a new user via the **Manual user entry** option. This user In JumpCloud, head to USER MANAGEMENT > Users and create a new user via the **Manual user entry** option. This user

View File

@@ -3,11 +3,13 @@ title: "LDAP Overview"
sidebarTitle: "Overview" sidebarTitle: "Overview"
description: "Learn how to authenticate into Infisical with LDAP." description: "Learn how to authenticate into Infisical with LDAP."
--- ---
<Info> <Info>
LDAP is a paid feature. 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, 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. then you should contact sales@infisical.com to purchase an enterprise license to use it.
</Info> </Info>
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). 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. 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
<AccordionGroup>
<Accordion title="Why does Infisical require additional email verification for users connected via LDAP?">
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.
</Accordion>
</AccordionGroup>

View File

@@ -4,10 +4,10 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO."
--- ---
<Info> <Info>
Okta SAML SSO is a paid feature. 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
If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, should contact sales@infisical.com to purchase an enterprise license to use
then you should contact sales@infisical.com to purchase an enterprise license to use it. it.
</Info> </Info>
<Steps> <Steps>
@@ -22,24 +22,24 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO."
button. button.
![SAML Okta create app integration](../../../images/sso/okta/create-app-integration.png) ![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: 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) ![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**. 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) ![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. 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) ![SAML Okta configure IdP fields](../../../images/sso/okta/configure-saml.png)
<Note> <Note>
If you're self-hosting Infisical, then you will want to replace If you're self-hosting Infisical, then you will want to replace
`https://app.infisical.com` with your own domain. `https://app.infisical.com` with your own domain.
</Note> </Note>
Also on the Configure SAML screen, configure the **Attribute Statements** to map: Also on the Configure SAML screen, configure the **Attribute Statements** to map:
- `id -> user.id`, - `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) ![SAML Okta attribute statements](../../../images/sso/okta/attribute-statements.png)
Once configured, select **Next** to proceed to the Feedback screen and select **Finish**. Once configured, select **Next** to proceed to the Feedback screen and select **Finish**.
</Step> </Step>
<Step title="Retrieve Identity Provider (IdP) Information from Okta"> <Step title="Retrieve Identity Provider (IdP) Information from Okta">
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: 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. 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) ![SAML Okta IdP values](../../../images/sso/okta/idp-values.png)
</Step> </Step>
<Step title="Finish configuring SAML in Infisical"> <Step title="Finish configuring SAML in Infisical">
Back in Infisical, set **Identity Provider Single Sign-On URL**, **Identity Provider Issuer**, 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. 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) ![SAML Okta paste values into Infisical](../../../images/sso/okta/idp-values-2.png)
</Step> </Step>
<Step title="Assign users in Okta to the application"> <Step title="Assign users in Okta to the application">
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. 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) ![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. At this point, you have configured everything you need within the context of the Okta Admin Portal.
</Step> </Step>
<Step title="Enable SAML SSO in Infisical"> <Step title="Enable SAML SSO in Infisical">
Enabling SAML SSO allows members in your organization to log into Infisical via Okta. Enabling SAML SSO allows members in your organization to log into Infisical via Okta.
![SAML Okta enable SAML](../../../images/sso/okta/enable-saml.png) ![SAML Okta enable SAML](../../../images/sso/okta/enable-saml.png)
</Step> </Step>
<Step title="Enforce SAML SSO in Infisical"> <Step title="Enforce SAML SSO in Infisical">
Enforcing SAML SSO ensures that members in your organization can only access Infisical 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 We recommend ensuring that your account is provisioned the application in Okta
prior to enforcing SAML SSO to prevent any unintended issues. prior to enforcing SAML SSO to prevent any unintended issues.
</Warning> </Warning>
</Step> </Step>
</Steps> </Steps>
<Note> <Note>
If you're configuring SAML SSO on a self-hosted instance of Infisical, make sure to If you're configuring SAML SSO on a self-hosted instance of Infisical, make
set the `AUTH_SECRET` and `SITE_URL` environment variable for it to work: 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
- `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`. can be a random 32-byte base64 string generated with `openssl rand -base64
- `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) 32`. - `SITE_URL`: The URL of your self-hosted instance of Infisical - should
</Note> be an absolute URL including the protocol (e.g. https://app.infisical.com)
</Note>

View File

@@ -5,11 +5,12 @@ description: "Learn how to log in to Infisical via SSO protocols."
--- ---
<Info> <Info>
Infisical offers Google SSO and GitHub SSO for free across both Infisical Cloud and Infisical Self-hosted. Infisical offers Google SSO and GitHub SSO for free across both Infisical
Cloud and Infisical Self-hosted. Infisical also offers SAML SSO authentication
Infisical also offers SAML SSO authentication but as paid features that can be unlocked on Infisical Cloud's **Pro** tier but as paid features that can be unlocked on Infisical Cloud's **Pro** tier or
or via enterprise license on self-hosted instances of Infisical. On this front, we support industry-leading providers including via enterprise license on self-hosted instances of Infisical. On this front,
Okta, Azure AD, and JumpCloud; with any questions, please reach out to team@infisical.com. we support industry-leading providers including Okta, Azure AD, and JumpCloud;
with any questions, please reach out to team@infisical.com.
</Info> </Info>
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). 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) - [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. 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
<AccordionGroup>
<Accordion title="Why does Infisical require additional email verification for users connected via SAML?">
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.
</Accordion>
</AccordionGroup>

View File

@@ -3,30 +3,34 @@ title: "Configurations"
description: "Read how to configure environment variables for self-hosted Infisical." 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. 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 Used to configure platform-specific security and operational settings
<ParamField query="ENCRYPTION_KEY" type="string" default="none" required> <ParamField query="ENCRYPTION_KEY" type="string" default="none" required>
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`
</ParamField> </ParamField>
<ParamField query="AUTH_SECRET" type="string" default="none" required> <ParamField query="AUTH_SECRET" type="string" default="none" required>
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`
</ParamField> </ParamField>
<ParamField query="SITE_URL" type="string" default="none" optional> <ParamField query="SITE_URL" type="string" default="none" optional>
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).
</ParamField> </ParamField>
## Data Layer ## Data Layer
The platform utilizes Postgres to persist all of its data and Redis for caching and backgroud tasks The platform utilizes Postgres to persist all of its data and Redis for caching and backgroud tasks
<ParamField query="DB_CONNECTION_URI" type="string" default="" required> <ParamField query="DB_CONNECTION_URI" type="string" default="" required>
Postgres database connection string. Postgres database connection string.
</ParamField> </ParamField>
<ParamField query="DB_ROOT_CERT" type="string" default="" optional> <ParamField query="DB_ROOT_CERT" type="string" default="" optional>
@@ -39,9 +43,8 @@ The platform utilizes Postgres to persist all of its data and Redis for caching
Redis connection string. Redis connection string.
</ParamField> </ParamField>
## Email service ## 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. 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.
<Accordion title="Generic Configuration"> <Accordion title="Generic Configuration">
@@ -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 Hostname to connect to for establishing SMTP connections
</ParamField> </ParamField>
<ParamField query="SMTP_USERNAME" type="string" default="none" optional> {" "}
Credential to connect to host (e.g. team@infisical.com)
</ParamField>
<ParamField query="SMTP_PASSWORD" type="string" default="none" optional> <ParamField query="SMTP_USERNAME" type="string" default="none" optional>
Credential to connect to host Credential to connect to host (e.g. team@infisical.com)
</ParamField> </ParamField>
<ParamField query="SMTP_PORT" type="string" default="587" optional> {" "}
Port to connect to for establishing SMTP connections
</ParamField>
<ParamField query="SMTP_SECURE" type="string" default="none" optional> <ParamField query="SMTP_PASSWORD" type="string" default="none" optional>
If true, use TLS when connecting to host. If false, TLS will be used if STARTTLS is supported Credential to connect to host
</ParamField> </ParamField>
<ParamField query="SMTP_FROM_ADDRESS" type="string" default="none" optional> {" "}
Email address to be used for sending emails
</ParamField> <ParamField query="SMTP_PORT" type="string" default="587" optional>
Port to connect to for establishing SMTP connections
</ParamField>
{" "}
<ParamField query="SMTP_SECURE" type="string" default="none" optional>
If true, use TLS when connecting to host. If false, TLS will be used if
STARTTLS is supported
</ParamField>
{" "}
<ParamField query="SMTP_FROM_ADDRESS" type="string" default="none" optional>
Email address to be used for sending emails
</ParamField>
<ParamField query="SMTP_FROM_NAME" type="string" default="none" optional> <ParamField query="SMTP_FROM_NAME" type="string" default="none" optional>
Name label to be used in From field (e.g. Team) 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
<Accordion title="Twilio SendGrid"> <Accordion title="Twilio SendGrid">
1. Create an account and configure [SendGrid](https://sendgrid.com) to send emails. 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) 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: 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_HOST=smtp.sendgrid.net
SMTP_USERNAME=apikey SMTP_USERNAME=apikey
SMTP_PASSWORD=SG.rqFsfjxYPiqE1lqZTgD_lz7x8IVLx # your SendGrid API Key from step above SMTP_PASSWORD=SG.rqFsfjxYPiqE1lqZTgD_lz7x8IVLx # your SendGrid API Key from step above
SMTP_PORT=587 SMTP_PORT=587
SMTP_SECURE=true SMTP_SECURE=true
SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails
SMTP_FROM_NAME=Infisical SMTP_FROM_NAME=Infisical
``` ```
<Info> <Info>
Remember that you will need to restart Infisical for this to work properly. 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. 1. Create an account and configure [Mailgun](https://www.mailgun.com) to send emails.
2. Obtain your Mailgun credentials in Sending > Overview > SMTP 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
```
</Accordion> </Accordion>
<Accordion title="AWS SES"> <Accordion title="AWS SES">
@@ -149,6 +164,7 @@ Without email configuration, Infisical's core functions like sign-up/login and s
SMTP_FROM_NAME=Infisical SMTP_FROM_NAME=Infisical
``` ```
</Step> </Step>
</Steps> </Steps>
<Info> <Info>
@@ -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. 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. 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_HOST=smtp.socketlabs.com
SMTP_USERNAME=username # obtained from your credentials SMTP_USERNAME=username # obtained from your credentials
SMTP_PASSWORD=password # obtained from your credentials SMTP_PASSWORD=password # obtained from your credentials
SMTP_PORT=587 SMTP_PORT=587
SMTP_SECURE=true SMTP_SECURE=true
SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails
SMTP_FROM_NAME=Infisical SMTP_FROM_NAME=Infisical
``` ```
<Note> {" "}
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`.
</Note>
![SocketLabs domain management](../../images/self-hosting/configuration/email/email-socketlabs-domains.png) <Note>
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`.
</Note>
![SocketLabs domain management](../../images/self-hosting/configuration/email/email-socketlabs-domains.png)
<Info> <Info>
Remember that you will need to restart Infisical for this to work properly. 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). 1. Create an account on [Resend](https://resend.com).
2. Add a [Domain](https://resend.com/domains). 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
```
<Info> <Info>
Remember that you will need to restart Infisical for this to work properly. Remember that you will need to restart Infisical for this to work properly.
</Info> </Info>
</Accordion> </Accordion>
<Accordion title="Gmail"> <Accordion title="Gmail">
Create an account and enable "less secure app access" in Gmail Account Settings > Security. This will allow 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. 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_HOST=smtp.gmail.com
SMTP_USERNAME=hey@gmail.com # your email SMTP_USERNAME=hey@gmail.com # your email
SMTP_PASSWORD=password # your password SMTP_PASSWORD=password # your password
SMTP_PORT=587 SMTP_PORT=587
SMTP_SECURE=true SMTP_SECURE=true
SMTP_FROM_ADDRESS=hey@gmail.com SMTP_FROM_ADDRESS=hey@gmail.com
SMTP_FROM_NAME=Infisical SMTP_FROM_NAME=Infisical
``` ```
<Warning> <Warning>
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 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. 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.
</Warning> </Warning>
</Accordion> </Accordion>
@@ -250,51 +270,51 @@ Without email configuration, Infisical's core functions like sign-up/login and s
<Accordion title="Office365"> <Accordion title="Office365">
1. Create an account and configure [Office365](https://www.office.com/) to send emails. 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
```
</Accordion> </Accordion>
<Accordion title="Zoho Mail"> <Accordion title="Zoho Mail">
1. Create an account and configure [Zoho Mail](https://www.zoho.com/mail/) to send emails. 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_HOST=smtp.zoho.com
SMTP_USERNAME=username # your email SMTP_USERNAME=username # your email
SMTP_PASSWORD=password # your password SMTP_PASSWORD=password # your password
SMTP_PORT=587 SMTP_PORT=587
SMTP_SECURE=true SMTP_SECURE=true
SMTP_FROM_ADDRESS=hey@example.com # your personal Zoho email or domain-based email linked to Zoho Mail SMTP_FROM_ADDRESS=hey@example.com # your personal Zoho email or domain-based email linked to Zoho Mail
SMTP_FROM_NAME=Infisical SMTP_FROM_NAME=Infisical
``` ```
<Note> {" "}
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 <Note>
domain-based email address, then please make sure that you've configured and You can use either your personal Zoho email address like `you@zohomail.com` or
verified it with Zoho Mail. a domain-based email address like `you@yourdomain.com`. If using a
</Note> domain-based email address, then please make sure that you've configured and
verified it with Zoho Mail.
</Note>
<Info> <Info>
Remember that you will need to restart Infisical for this to work properly. Remember that you will need to restart Infisical for this to work properly.
</Info> </Info>
</Accordion> </Accordion>
## Authentication
## SSO based login
By default, users can only login via email/password based login method. 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. 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
</Accordion> </Accordion>
<Accordion title="Okta SAML"> <Accordion title="Okta SAML">
Requires enterprise license. Please contact team@infisical.com to get more information. Requires enterprise license. Please contact team@infisical.com to get more
information.
</Accordion> </Accordion>
<Accordion title="Azure SAML"> <Accordion title="Azure SAML">
Requires enterprise license. Please contact team@infisical.com to get more information. Requires enterprise license. Please contact team@infisical.com to get more
information.
</Accordion> </Accordion>
<Accordion title="JumpCloud SAML"> <Accordion title="JumpCloud SAML">
Requires enterprise license. Please contact team@infisical.com to get more information. Requires enterprise license. Please contact team@infisical.com to get more
information.
</Accordion> </Accordion>
<ParamField query="NEXT_PUBLIC_SAML_ORG_SLUG" type="string"> <ParamField query="NEXT_PUBLIC_SAML_ORG_SLUG" type="string">
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.
</ParamField> </ParamField>
## Native secret integrations ## 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. To help you sync secrets from Infisical to services such as Github and Gitlab, Infisical provides native integrations out of the box.
<Accordion title="Heroku"> <Accordion title="Heroku">
<ParamField query="CLIENT_ID_HEROKU" type="string" default="none" optional> <ParamField query="CLIENT_ID_HEROKU" type="string" default="none" optional>
OAuth2 client ID for Heroku integration OAuth2 client ID for Heroku integration
</ParamField> </ParamField>
<ParamField query="CLIENT_SECRET_HEROKU" type="string" default="none" optional> <ParamField
query="CLIENT_SECRET_HEROKU"
type="string"
default="none"
optional
>
OAuth2 client secret for Heroku integration OAuth2 client secret for Heroku integration
</ParamField> </ParamField>
</Accordion> </Accordion>
@@ -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 ID for Vercel integration
</ParamField> </ParamField>
<ParamField query="CLIENT_SECRET_VERCEL" type="string" default="none" optional> {" "}
OAuth2 client secret for Vercel integration
</ParamField> <ParamField query="CLIENT_SECRET_VERCEL" type="string" default="none" optional>
OAuth2 client secret for Vercel integration
</ParamField>
<ParamField query="CLIENT_SLUG_VERCEL" type="string" default="none" optional> <ParamField query="CLIENT_SLUG_VERCEL" type="string" default="none" optional>
OAuth2 slug for Vercel integration OAuth2 slug for Vercel integration

View File

@@ -6,6 +6,7 @@ export const publicPaths = [
"/signup", "/signup",
"/signup/sso", "/signup/sso",
"/login", "/login",
"/login/ldap",
"/blog", "/blog",
"/docs", "/docs",
"/changelog", "/changelog",

View File

@@ -3,6 +3,8 @@ export type TServerConfig = {
allowSignUp: boolean; allowSignUp: boolean;
allowedSignUpDomain?: string | null; allowedSignUpDomain?: string | null;
isMigrationModeOn?: boolean; isMigrationModeOn?: boolean;
trustSamlEmails: boolean;
trustLdapEmails: boolean;
}; };
export type TCreateAdminUserDTO = { export type TCreateAdminUserDTO = {

View File

@@ -5,7 +5,6 @@ export {
useSendMfaToken, useSendMfaToken,
useSendPasswordResetEmail, useSendPasswordResetEmail,
useSendVerificationEmail, useSendVerificationEmail,
useVerifyEmailVerificationCode,
useVerifyMfaToken, useVerifyMfaToken,
useVerifyPasswordResetCode useVerifyPasswordResetCode,
} from "./queries"; useVerifySignupEmailVerificationCode} from "./queries";

View File

@@ -164,7 +164,7 @@ export const useSendVerificationEmail = () => {
}); });
}; };
export const useVerifyEmailVerificationCode = () => { export const useVerifySignupEmailVerificationCode = () => {
return useMutation({ return useMutation({
mutationFn: async ({ email, code }: { email: string; code: string }) => { mutationFn: async ({ email, code }: { email: string; code: string }) => {
const { data } = await apiRequest.post("/api/v3/signup/email/verify", { const { data } = await apiRequest.post("/api/v3/signup/email/verify", {

View File

@@ -1,4 +1,9 @@
export { useAddUserToWsE2EE, useAddUserToWsNonE2EE } from "./mutation"; export {
useAddUserToWsE2EE,
useAddUserToWsNonE2EE,
useSendEmailVerificationCode,
useVerifyEmailVerificationCode
} from "./mutation";
export { export {
fetchOrgUsers, fetchOrgUsers,
useAddUserToOrg, useAddUserToOrg,

View File

@@ -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 {};
}
});
};

View File

@@ -27,6 +27,11 @@ export type User = {
id: string; id: string;
}; };
export enum UserAliasType {
LDAP = "ldap",
SAML = "saml"
}
export type UserEnc = { export type UserEnc = {
encryptionVersion?: number; encryptionVersion?: number;
protectedKey?: string; protectedKey?: string;

View File

@@ -7,7 +7,6 @@ import { Login } from "@app/views/Login";
export default function LoginPage() { export default function LoginPage() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="flex max-h-screen min-h-screen flex-col justify-center overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700 px-6"> <div className="flex max-h-screen min-h-screen flex-col justify-center overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700 px-6">
<Head> <Head>

View File

@@ -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 (
<div className="flex h-screen flex-col justify-center bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700 px-6 pb-28 ">
<Head>
<title>{t("common.head-title", { title: t("login.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={t("login.og-title") ?? ""} />
<meta name="og:description" content={t("login.og-description") ?? ""} />
</Head>
<Link href="/">
<div className="mb-4 mt-20 flex justify-center">
<Image src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
</div>
</Link>
<LoginLDAP />
</div>
);
}

View File

@@ -13,7 +13,7 @@ import TeamInviteStep from "@app/components/signup/TeamInviteStep";
import UserInfoStep from "@app/components/signup/UserInfoStep"; import UserInfoStep from "@app/components/signup/UserInfoStep";
import SecurityClient from "@app/components/utilities/SecurityClient"; import SecurityClient from "@app/components/utilities/SecurityClient";
import { useServerConfig } from "@app/context"; 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 { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
@@ -34,7 +34,7 @@ export default function SignUp() {
const [isSignupWithEmail, setIsSignupWithEmail] = useState(false); const [isSignupWithEmail, setIsSignupWithEmail] = useState(false);
const [isCodeInputCheckLoading, setIsCodeInputCheckLoading] = useState(false); const [isCodeInputCheckLoading, setIsCodeInputCheckLoading] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const { mutateAsync } = useVerifyEmailVerificationCode(); const { mutateAsync } = useVerifySignupEmailVerificationCode();
const { config } = useServerConfig(); const { config } = useServerConfig();
useEffect(() => { useEffect(() => {

View File

@@ -3,7 +3,7 @@ import { useRouter } from "next/router";
import { isLoggedIn } from "@app/reactQuery"; import { isLoggedIn } from "@app/reactQuery";
import { InitialStep, LDAPStep, MFAStep, SAMLSSOStep } from "./components"; import { InitialStep, MFAStep, SAMLSSOStep } from "./components";
import { navigateUserToSelectOrg } from "./Login.utils"; import { navigateUserToSelectOrg } from "./Login.utils";
export const Login = () => { export const Login = () => {
@@ -58,8 +58,6 @@ export const Login = () => {
); );
case 2: case 2:
return <SAMLSSOStep setStep={setStep} />; return <SAMLSSOStep setStep={setStep} />;
case 3:
return <LDAPStep setStep={setStep} />;
default: default:
return <div />; return <div />;
} }

View File

@@ -1,24 +1,23 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useRouter } from "next/router";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { Button, Input } from "@app/components/v2"; import { Button, Input } from "@app/components/v2";
import { loginLDAPRedirect } from "@app/hooks/api/auth/queries"; import { loginLDAPRedirect } from "@app/hooks/api/auth/queries";
type Props = { export const LoginLDAP = () => {
setStep: (step: number) => void; 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(passedOrgSlug || "");
const [username, setUsername] = useState(passedUsername || "");
const [organizationSlug, setOrganizationSlug] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const { t } = useTranslation(); const { t } = useTranslation();
// const queryParams = new URLSearchParams(window.location.search);
const handleSubmission = async (e: React.FormEvent) => { const handleSubmission = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
@@ -42,7 +41,6 @@ export const LDAPStep = ({ setStep }: Props) => {
type: "success" type: "success"
}); });
// redirects either to /login/sso or /signup/sso
window.open(nextUrl); window.open(nextUrl);
window.close(); window.close();
} catch (err) { } catch (err) {
@@ -76,6 +74,7 @@ export const LDAPStep = ({ setStep }: Props) => {
autoComplete="email" autoComplete="email"
id="email" id="email"
className="h-12" className="h-12"
isDisabled={passedOrgSlug !== null}
/> />
</div> </div>
</div> </div>
@@ -90,6 +89,7 @@ export const LDAPStep = ({ setStep }: Props) => {
autoComplete="email" autoComplete="email"
id="email" id="email"
className="h-12" className="h-12"
isDisabled={passedUsername !== null}
/> />
</div> </div>
</div> </div>
@@ -122,7 +122,7 @@ export const LDAPStep = ({ setStep }: Props) => {
<div className="mt-4 flex flex-row items-center justify-center"> <div className="mt-4 flex flex-row items-center justify-center">
<button <button
onClick={() => { onClick={() => {
setStep(0); router.push("/login");
}} }}
type="button" type="button"
className="mt-2 cursor-pointer text-sm text-bunker-300 duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4" className="mt-2 cursor-pointer text-sm text-bunker-300 duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4"

View File

@@ -25,7 +25,7 @@ type Props = {
export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: Props) => { export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: Props) => {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [loginError, setLoginError] = useState(false); const [loginError, setLoginError] = useState(false);
@@ -33,7 +33,10 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
const queryParams = new URLSearchParams(window.location.search); const queryParams = new URLSearchParams(window.location.search);
useEffect(() => { useEffect(() => {
if (process.env.NEXT_PUBLIC_SAML_ORG_SLUG && process.env.NEXT_PUBLIC_SAML_ORG_SLUG !== "saml-org-slug-default") { if (
process.env.NEXT_PUBLIC_SAML_ORG_SLUG &&
process.env.NEXT_PUBLIC_SAML_ORG_SLUG !== "saml-org-slug-default"
) {
const callbackPort = queryParams.get("callback_port"); const callbackPort = queryParams.get("callback_port");
window.open( window.open(
`/api/v1/sso/redirect/saml2/organizations/${process.env.NEXT_PUBLIC_SAML_ORG_SLUG}${ `/api/v1/sso/redirect/saml2/organizations/${process.env.NEXT_PUBLIC_SAML_ORG_SLUG}${
@@ -42,7 +45,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
); );
window.close(); window.close();
} }
}, []) }, []);
const handleLogin = async (e: FormEvent<HTMLFormElement>) => { const handleLogin = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
@@ -196,7 +199,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
colorSchema="primary" colorSchema="primary"
variant="outline_bg" variant="outline_bg"
onClick={() => { onClick={() => {
setStep(3); router.push("/login/ldap");
}} }}
leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />} leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />}
className="mx-0 h-10 w-full" className="mx-0 h-10 w-full"

View File

@@ -1 +0,0 @@
export { LDAPStep } from "./LDAPStep";

View File

@@ -1,5 +1,4 @@
export { InitialStep } from "./InitialStep"; export { InitialStep } from "./InitialStep";
export { LDAPStep } from "./LDAPStep";
export { MFAStep } from "./MFAStep"; export { MFAStep } from "./MFAStep";
export { SAMLSSOStep } from "./SAMLSSOStep"; export { SAMLSSOStep } from "./SAMLSSOStep";

View File

@@ -1,2 +1,3 @@
export { Login } from "./Login"; export { Login } from "./Login";
export { LoginLDAP } from "./LoginLDAP";
export { LoginSSO } from "./LoginSSO"; export { LoginSSO } from "./LoginSSO";

View File

@@ -49,7 +49,6 @@ type Props = {
}; };
export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Props) => { export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Props) => {
const { subscription } = useSubscription(); const { subscription } = useSubscription();
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { user } = useUser(); const { user } = useUser();
@@ -218,7 +217,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
variant="outline_bg" variant="outline_bg"
onClick={() => onResendInvite(email)} onClick={() => onResendInvite(email)}
> >
Resend Invite Resend invite
</Button> </Button>
)} )}
</> </>

View File

@@ -1,7 +1,7 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import jwt_decode from "jwt-decode"; import jwt_decode from "jwt-decode";
import { BackupPDFStep, UserInfoSSOStep } from "./components"; import { BackupPDFStep, EmailConfirmationStep, UserInfoSSOStep } from "./components";
type Props = { type Props = {
providerAuthToken: string; providerAuthToken: string;
@@ -11,11 +11,38 @@ export const SignupSSO = ({ providerAuthToken }: Props) => {
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [password, setPassword] = useState(""); 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 = () => { const renderView = () => {
switch (step) { switch (step) {
case 0: case 0:
return (
<EmailConfirmationStep
authType={authType}
username={username}
email={email}
organizationSlug={organizationSlug}
setStep={setStep}
/>
);
case 1:
return ( return (
<UserInfoSSOStep <UserInfoSSOStep
username={username} username={username}
@@ -27,7 +54,7 @@ export const SignupSSO = ({ providerAuthToken }: Props) => {
providerAuthToken={providerAuthToken} providerAuthToken={providerAuthToken}
/> />
); );
case 1: case 2:
return ( return (
<BackupPDFStep email={username} password={password} name={`${firstName} ${lastName}`} /> <BackupPDFStep email={username} password={password} name={`${firstName} ${lastName}`} />
); );

View File

@@ -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 (
<div className="mx-auto h-full w-full pb-4 md:px-8">
<p className="text-md flex justify-center text-bunker-200">
We&apos;ve sent a verification code to {email}
</p>
<div className="mx-auto hidden w-max min-w-[20rem] md:block">
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={6}
onChange={setCode}
{...props}
className="mt-6 mb-2"
/>
</div>
<div className="mx-auto mt-4 block w-max md:hidden">
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={6}
onChange={setCode}
{...propsPhone}
className="mt-2 mb-2"
/>
</div>
{codeError && <Error text="Oops. Your code is wrong. Please try again." />}
<div className="mx-auto mt-2 flex w-1/4 min-w-[20rem] max-w-xs flex-col items-center justify-center text-center text-sm md:max-w-md md:text-left lg:w-[19%]">
<div className="text-l w-full py-1 text-lg">
<Button
type="submit"
onClick={checkCode}
size="sm"
isFullWidth
className="h-14"
colorSchema="primary"
variant="outline_bg"
isLoading={isLoading}
>
{" "}
Verify
</Button>
</div>
</div>
<div className="mx-auto flex max-h-24 w-full max-w-md flex-col items-center justify-center pt-2">
<div className="flex flex-row items-baseline gap-1 text-sm">
<span className="text-bunker-400">Don&apos;t see the code?</span>
<div className="text-md mt-2 flex flex-row text-bunker-400">
<button disabled={isLoading} onClick={resendCode} type="button">
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
{isResendingVerificationEmail ? "Resending..." : "Resend"}
</span>
</button>
</div>
</div>
<p className="pb-2 text-sm text-bunker-400">Make sure to check your spam inbox.</p>
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export { EmailConfirmationStep } from "./EmailConfirmationStep";

View File

@@ -201,7 +201,7 @@ export const UserInfoSSOStep = ({
localStorage.setItem("orgData.id", orgId); localStorage.setItem("orgData.id", orgId);
localStorage.setItem("projectData.id", project.id); localStorage.setItem("projectData.id", project.id);
setStep(1); setStep(2);
} catch (error) { } catch (error) {
setIsLoading(false); setIsLoading(false);
console.error(error); console.error(error);

View File

@@ -1,2 +1,3 @@
export { BackupPDFStep } from "./BackupPDFStep"; export { BackupPDFStep } from "./BackupPDFStep";
export { EmailConfirmationStep } from "./EmailConfirmationStep";
export { UserInfoSSOStep } from "./UserInfoSSOStep"; export { UserInfoSSOStep } from "./UserInfoSSOStep";

View File

@@ -14,11 +14,11 @@ import {
Input, Input,
Select, Select,
SelectItem, SelectItem,
Switch,
Tab, Tab,
TabList, TabList,
TabPanel, TabPanel,
Tabs Tabs} from "@app/components/v2";
} from "@app/components/v2";
import { useOrganization, useServerConfig, useUser } from "@app/context"; import { useOrganization, useServerConfig, useUser } from "@app/context";
import { useUpdateServerConfig } from "@app/hooks/api"; import { useUpdateServerConfig } from "@app/hooks/api";
@@ -33,7 +33,9 @@ enum SignUpModes {
const formSchema = z.object({ const formSchema = z.object({
signUpMode: z.nativeEnum(SignUpModes), signUpMode: z.nativeEnum(SignUpModes),
allowedSignUpDomain: z.string().optional().nullable() allowedSignUpDomain: z.string().optional().nullable(),
trustSamlEmails: z.boolean(),
trustLdapEmails: z.boolean()
}); });
type TDashboardForm = z.infer<typeof formSchema>; type TDashboardForm = z.infer<typeof formSchema>;
@@ -52,7 +54,9 @@ export const AdminDashboardPage = () => {
values: { values: {
// eslint-disable-next-line // eslint-disable-next-line
signUpMode: config.allowSignUp ? SignUpModes.Anyone : SignUpModes.Disabled, 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 { orgs } = useOrganization();
const { mutateAsync: updateServerConfig } = useUpdateServerConfig(); const { mutateAsync: updateServerConfig } = useUpdateServerConfig();
const isNotAllowed = !user?.superAdmin; const isNotAllowed = !user?.superAdmin;
// TODO(akhilmhdh): on nextjs 14 roadmap this will be properly addressed with context split // 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) => { const onFormSubmit = async (formData: TDashboardForm) => {
try { try {
const { signUpMode, allowedSignUpDomain } = formData; const { signUpMode, allowedSignUpDomain, trustSamlEmails, trustLdapEmails } = formData;
await updateServerConfig({ await updateServerConfig({
allowSignUp: signUpMode !== SignUpModes.Disabled, allowSignUp: signUpMode !== SignUpModes.Disabled,
allowedSignUpDomain: signUpMode === SignUpModes.Anyone ? allowedSignUpDomain : null allowedSignUpDomain: signUpMode === SignUpModes.Anyone ? allowedSignUpDomain : null,
trustSamlEmails,
trustLdapEmails
}); });
createNotification({ createNotification({
text: "Successfully changed sign up setting.", text: "Successfully changed sign up setting.",
@@ -123,8 +128,9 @@ export const AdminDashboardPage = () => {
<div className="mb-2 text-xl font-semibold text-mineshaft-100"> <div className="mb-2 text-xl font-semibold text-mineshaft-100">
Allow user signups Allow user signups
</div> </div>
<div className="mb-4 text-sm max-w-sm text-mineshaft-400"> <div className="mb-4 max-w-sm text-sm text-mineshaft-400">
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.
</div> </div>
<Controller <Controller
control={control} control={control}
@@ -176,6 +182,48 @@ export const AdminDashboardPage = () => {
/> />
</div> </div>
)} )}
<div className="mt-8 mb-8 flex flex-col justify-start">
<div className="mb-2 text-xl font-semibold text-mineshaft-100">Trust emails</div>
<div className="mb-4 max-w-sm text-sm text-mineshaft-400">
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.
</div>
<Controller
control={control}
name="trustSamlEmails"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="trust-saml-emails"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Trust SAML emails</p>
</Switch>
</FormControl>
);
}}
/>
<Controller
control={control}
name="trustLdapEmails"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="trust-ldap-emails"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Trust LDAP emails</p>
</Switch>
</FormControl>
);
}}
/>
</div>
<Button <Button
type="submit" type="submit"
isLoading={isSubmitting} isLoading={isSubmitting}