From 99d59a38d5d15faa23b218b53ed124f046ab883d Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 24 Apr 2024 16:50:23 -0700 Subject: [PATCH] Add test connection btn for LDAP, update group search filter impl, update group search filter examples in docs --- backend/src/ee/routes/v1/ldap-router.ts | 38 ++++++++-- .../ldap-config/ldap-config-service.ts | 22 +++++- .../services/ldap-config/ldap-config-types.ts | 4 ++ .../src/ee/services/ldap-config/ldap-fns.ts | 66 +++++++++++++---- docs/documentation/platform/ldap/general.mdx | 2 +- .../documentation/platform/ldap/jumpcloud.mdx | 2 +- frontend/src/hooks/api/ldapConfig/index.tsx | 1 + .../src/hooks/api/ldapConfig/mutations.tsx | 11 +++ .../components/OrgAuthTab/LDAPModal.tsx | 72 ++++++++++++++++--- .../components/OrgAuthTab/OrgLDAPSection.tsx | 16 +++-- 10 files changed, 192 insertions(+), 42 deletions(-) diff --git a/backend/src/ee/routes/v1/ldap-router.ts b/backend/src/ee/routes/v1/ldap-router.ts index 09819dde8..c94538425 100644 --- a/backend/src/ee/routes/v1/ldap-router.ts +++ b/backend/src/ee/routes/v1/ldap-router.ts @@ -55,11 +55,9 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { const ldapConfig = (req as unknown as FastifyRequest).ldapConfig as TLDAPConfig; const groupFilter = "(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))"; - const searchFilter = - ldapConfig.groupSearchFilter || - groupFilter.replace("{{.Username}}", user.uid).replace("{{.UserDN}}", user.dn); - - const shouldProcessGroups = ldapConfig.groupSearchFilter && ldapConfig.groupSearchBase; + const searchFilter = (ldapConfig.groupSearchFilter || groupFilter) + .replace(/{{\.Username}}/g, user.uid) + .replace(/{{\.UserDN}}/g, user.dn); const { isUserCompleted, providerAuthToken } = await server.services.ldap.ldapLogin({ ldapConfigId: ldapConfig.id, @@ -68,7 +66,7 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { firstName: user.givenName ?? user.cn ?? "", lastName: user.sn ?? "", emails: user.mail ? [user.mail] : [], - groups: shouldProcessGroups + groups: ldapConfig.groupSearchBase ? await searchGroups(ldapConfig, searchFilter, ldapConfig.groupSearchBase) : undefined, relayState: ((req as unknown as FastifyRequest).body as { RelayState?: string }).RelayState, @@ -327,4 +325,32 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { return ldapGroupMap; } }); + + server.route({ + method: "POST", + url: "/config/:configId/test-connection", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + configId: z.string().trim() + }), + response: { + 200: z.boolean() + } + }, + handler: async (req) => { + const result = await server.services.ldap.testLDAPConnection({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ldapConfigId: req.params.configId + }); + return result; + } + }); }; diff --git a/backend/src/ee/services/ldap-config/ldap-config-service.ts b/backend/src/ee/services/ldap-config/ldap-config-service.ts index 90c641109..d175fc5e8 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -37,8 +37,10 @@ import { TGetLdapCfgDTO, TGetLdapGroupMapsDTO, TLdapLoginDTO, + TTestLdapConnectionDTO, TUpdateLdapCfgDTO } from "./ldap-config-types"; +import { testLDAPConfig } from "./ldap-fns"; import { TLdapGroupMapDALFactory } from "./ldap-group-map-dal"; type TLdapConfigServiceFactoryDep = { @@ -650,6 +652,23 @@ export const ldapConfigServiceFactory = ({ return deletedGroupMap; }; + const testLDAPConnection = async ({ actor, actorId, orgId, actorAuthMethod, actorOrgId }: TTestLdapConnectionDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Ldap); + + const plan = await licenseService.getPlan(orgId); + if (!plan.ldap) + throw new BadRequestError({ + message: "Failed to test LDAP connection due to plan restriction. Upgrade plan to test the LDAP connection." + }); + + const ldapConfig = await getLdapCfg({ + orgId + }); + + return testLDAPConfig(ldapConfig); + }; + return { createLdapCfg, updateLdapCfg, @@ -660,6 +679,7 @@ export const ldapConfigServiceFactory = ({ bootLdap, getLdapGroupMaps, createLdapGroupMap, - deleteLdapGroupMap + deleteLdapGroupMap, + testLDAPConnection }; }; diff --git a/backend/src/ee/services/ldap-config/ldap-config-types.ts b/backend/src/ee/services/ldap-config/ldap-config-types.ts index 03254b92f..1018ec5fa 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-types.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-types.ts @@ -72,3 +72,7 @@ export type TDeleteLdapGroupMapDTO = { ldapConfigId: string; ldapGroupMapId: string; } & TOrgPermission; + +export type TTestLdapConnectionDTO = { + ldapConfigId: string; +} & TOrgPermission; diff --git a/backend/src/ee/services/ldap-config/ldap-fns.ts b/backend/src/ee/services/ldap-config/ldap-fns.ts index 922fcb3f5..accd79e29 100644 --- a/backend/src/ee/services/ldap-config/ldap-fns.ts +++ b/backend/src/ee/services/ldap-config/ldap-fns.ts @@ -4,6 +4,54 @@ import { logger } from "@app/lib/logger"; import { TLDAPConfig } from "./ldap-config-types"; +/** + * Test the LDAP configuration by attempting to bind to the LDAP server + * @param ldapConfig - The LDAP configuration to test + * @returns {Boolean} isConnected - Whether or not the connection was successful + */ +export const testLDAPConfig = async (ldapConfig: TLDAPConfig): Promise => { + return new Promise((resolve) => { + const ldapClient = ldapjs.createClient({ + url: ldapConfig.url, + bindDN: ldapConfig.bindDN, + bindCredentials: ldapConfig.bindPass, + ...(ldapConfig.caCert !== "" + ? { + tlsOptions: { + ca: [ldapConfig.caCert] + } + } + : {}) + }); + + ldapClient.on("error", (err) => { + logger.error("LDAP client error:", err); + logger.error(err); + resolve(false); + }); + + ldapClient.bind(ldapConfig.bindDN, ldapConfig.bindPass, (err) => { + if (err) { + logger.error("Error binding to LDAP"); + logger.error(err); + ldapClient.unbind(); + resolve(false); + } else { + logger.info("Successfully connected and bound to LDAP."); + ldapClient.unbind(); + resolve(true); + } + }); + }); +}; + +/** + * Search for groups in the LDAP server + * @param ldapConfig - The LDAP configuration to use + * @param filter - The filter to use when searching for groups + * @param base - The base to search from + * @returns + */ export const searchGroups = async ( ldapConfig: TLDAPConfig, filter: string, @@ -31,11 +79,7 @@ export const searchGroups = async ( }, (err, res) => { if (err) { - ldapClient.unbind((unbindError) => { - if (unbindError) { - logger.error("Error unbinding LDAP client:", unbindError); - } - }); + ldapClient.unbind(); return reject(err); } @@ -51,19 +95,11 @@ export const searchGroups = async ( groups.push({ dn, cn }); }); res.on("error", (error) => { - ldapClient.unbind((unbindError) => { - if (unbindError) { - logger.error("Error unbinding LDAP client:", unbindError); - } - }); + ldapClient.unbind(); reject(error); }); res.on("end", () => { - ldapClient.unbind((unbindError) => { - if (unbindError) { - logger.error("Error unbinding LDAP client:", unbindError); - } - }); + ldapClient.unbind(); resolve(groups); }); } diff --git a/docs/documentation/platform/ldap/general.mdx b/docs/documentation/platform/ldap/general.mdx index 535cce734..e13749910 100644 --- a/docs/documentation/platform/ldap/general.mdx +++ b/docs/documentation/platform/ldap/general.mdx @@ -27,7 +27,7 @@ You can configure your organization in Infisical to have members authenticate wi - Bind Pass: The password to use along with `Bind DN` when performing the user search. - Search Base / User DN: Base DN under which to perform user search such as `ou=Users,dc=acme,dc=com` - Group Search Base / Group DN (optional): LDAP search base to use for group membership search such as `ou=Groups,dc=acme,dc=com`. - - Group Filter (optional): Template used when constructing the group membership query such as `(objectClass=posixGroup)`. The template can access the following context variables: [`UserDN`, `UserUID`, `UserName`]. The default is `(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))` which is compatible with several common directory schemas. + - Group Filter (optional): Template used when constructing the group membership query such as `(&(objectClass=posixGroup)(memberUid={{.Username}}))`. The template can access the following context variables: [`UserDN`, `UserName`]. The default is `(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))` which is compatible with several common directory schemas. - CA Certificate: The CA certificate to use when verifying the LDAP server certificate. diff --git a/docs/documentation/platform/ldap/jumpcloud.mdx b/docs/documentation/platform/ldap/jumpcloud.mdx index 32b253eb7..16b924617 100644 --- a/docs/documentation/platform/ldap/jumpcloud.mdx +++ b/docs/documentation/platform/ldap/jumpcloud.mdx @@ -37,7 +37,7 @@ description: "Learn how to configure JumpCloud LDAP for authenticating into Infi - Bind Pass: The password to use along with `Bind DN` when performing the user search. - Search Base / User DN: Base DN under which to perform user search (`ou=Users,o=,dc=jumpcloud,dc=com`). - Group Search Base / Group DN (optional): LDAP search base to use for group membership search (`ou=Users,o=,dc=jumpcloud,dc=com`). - - Group Filter (optional): Template used when constructing the group membership query (`(objectClass=groupOfNames)`). + - Group Filter (optional): Template used when constructing the group membership query (`(&(objectClass=groupOfNames)(member=uid={{.Username}},ou=Users,o=,dc=jumpcloud,dc=com))`) - CA Certificate: The CA certificate to use when verifying the LDAP server certificate (instructions to obtain the certificate for JumpCloud [here](https://jumpcloud.com/support/connect-to-ldap-with-tls-ssl)). diff --git a/frontend/src/hooks/api/ldapConfig/index.tsx b/frontend/src/hooks/api/ldapConfig/index.tsx index 26af75089..20ba1469a 100644 --- a/frontend/src/hooks/api/ldapConfig/index.tsx +++ b/frontend/src/hooks/api/ldapConfig/index.tsx @@ -2,5 +2,6 @@ export { useCreateLDAPConfig, useCreateLDAPGroupMapping, useDeleteLDAPGroupMapping, + useTestLDAPConnection, useUpdateLDAPConfig} from "./mutations"; export { useGetLDAPConfig, useGetLDAPGroupMaps } from "./queries"; diff --git a/frontend/src/hooks/api/ldapConfig/mutations.tsx b/frontend/src/hooks/api/ldapConfig/mutations.tsx index 50651b367..d80c51c55 100644 --- a/frontend/src/hooks/api/ldapConfig/mutations.tsx +++ b/frontend/src/hooks/api/ldapConfig/mutations.tsx @@ -136,3 +136,14 @@ export const useDeleteLDAPGroupMapping = () => { } }); }; + +export const useTestLDAPConnection = () => { + return useMutation({ + mutationFn: async (ldapConfigId: string) => { + const { data } = await apiRequest.post( + `/api/v1/ldap/config/${ldapConfigId}/test-connection` + ); + return data; + } + }); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/LDAPModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/LDAPModal.tsx index 773cba3f4..aa729cf88 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/LDAPModal.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/LDAPModal.tsx @@ -6,7 +6,11 @@ import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, FormControl, Input, Modal, ModalContent, TextArea } from "@app/components/v2"; import { useOrganization } from "@app/context"; -import { useCreateLDAPConfig, useGetLDAPConfig, useUpdateLDAPConfig } from "@app/hooks/api"; +import { + useCreateLDAPConfig, + useGetLDAPConfig, + useTestLDAPConnection, + useUpdateLDAPConfig} from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; const LDAPFormSchema = z.object({ @@ -32,12 +36,21 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) const { mutateAsync: createMutateAsync, isLoading: createIsLoading } = useCreateLDAPConfig(); const { mutateAsync: updateMutateAsync, isLoading: updateIsLoading } = useUpdateLDAPConfig(); + const { mutateAsync: testLDAPConnection } = useTestLDAPConnection(); const { data } = useGetLDAPConfig(currentOrg?.id ?? ""); - const { control, handleSubmit, reset } = useForm({ + const { control, handleSubmit, reset, watch } = useForm({ resolver: zodResolver(LDAPFormSchema) }); + const watchUrl = watch("url"); + const watchBindDN = watch("bindDN"); + const watchBindPass = watch("bindPass"); + const watchSearchBase = watch("searchBase"); + const watchGroupSearchBase = watch("groupSearchBase"); + const watchGroupSearchFilter = watch("groupSearchFilter"); + const watchCaCert = watch("caCert"); + useEffect(() => { if (data) { reset({ @@ -59,8 +72,9 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) searchBase, groupSearchBase, groupSearchFilter, - caCert - }: TLDAPFormData) => { + caCert, + shouldCloseModal = true + }: TLDAPFormData & { shouldCloseModal?: boolean }) => { try { if (!currentOrg) return; @@ -90,7 +104,9 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) }); } - handlePopUpClose("addLDAP"); + if (shouldCloseModal) { + handlePopUpClose("addLDAP"); + } createNotification({ text: `Successfully ${!data ? "added" : "updated"} LDAP configuration`, @@ -105,6 +121,44 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) } }; + const handleTestLDAPConnection = async () => { + try { + await onSSOModalSubmit({ + url: watchUrl, + bindDN: watchBindDN, + bindPass: watchBindPass, + searchBase: watchSearchBase, + groupSearchBase: watchGroupSearchBase, + groupSearchFilter: watchGroupSearchFilter, + caCert: watchCaCert, + shouldCloseModal: false + }); + + if (!data) return; + + const result = await testLDAPConnection(data.id); + + if (!result) { + createNotification({ + text: "Failed to test the LDAP connection: Bind operation was unsuccessful", + type: "error" + }); + return; + } + + createNotification({ + text: "Successfully tested the LDAP connection: Bind operation was successful", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to test the LDAP connection", + type: "error" + }); + } + }; + return ( {!data ? "Add" : "Update"} - diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgLDAPSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgLDAPSection.tsx index 4c2d4009e..ae82b9882 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgLDAPSection.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgLDAPSection.tsx @@ -97,13 +97,15 @@ export const OrgLDAPSection = (): JSX.Element => {

LDAP

- - {(isAllowed) => ( - - )} - +
+ + {(isAllowed) => ( + + )} + +

Manage LDAP authentication configuration