Add test connection btn for LDAP, update group search filter impl, update group search filter examples in docs

This commit is contained in:
Tuan Dang
2024-04-24 16:50:23 -07:00
parent 9992fbf3dd
commit 99d59a38d5
10 changed files with 192 additions and 42 deletions

View File

@@ -55,11 +55,9 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => {
const ldapConfig = (req as unknown as FastifyRequest).ldapConfig as TLDAPConfig; const ldapConfig = (req as unknown as FastifyRequest).ldapConfig as TLDAPConfig;
const groupFilter = "(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))"; const groupFilter = "(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))";
const searchFilter = const searchFilter = (ldapConfig.groupSearchFilter || groupFilter)
ldapConfig.groupSearchFilter || .replace(/{{\.Username}}/g, user.uid)
groupFilter.replace("{{.Username}}", user.uid).replace("{{.UserDN}}", user.dn); .replace(/{{\.UserDN}}/g, user.dn);
const shouldProcessGroups = ldapConfig.groupSearchFilter && ldapConfig.groupSearchBase;
const { isUserCompleted, providerAuthToken } = await server.services.ldap.ldapLogin({ const { isUserCompleted, providerAuthToken } = await server.services.ldap.ldapLogin({
ldapConfigId: ldapConfig.id, ldapConfigId: ldapConfig.id,
@@ -68,7 +66,7 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => {
firstName: user.givenName ?? user.cn ?? "", firstName: user.givenName ?? user.cn ?? "",
lastName: user.sn ?? "", lastName: user.sn ?? "",
emails: user.mail ? [user.mail] : [], emails: user.mail ? [user.mail] : [],
groups: shouldProcessGroups groups: ldapConfig.groupSearchBase
? await searchGroups(ldapConfig, searchFilter, ldapConfig.groupSearchBase) ? await searchGroups(ldapConfig, searchFilter, ldapConfig.groupSearchBase)
: undefined, : undefined,
relayState: ((req as unknown as FastifyRequest).body as { RelayState?: string }).RelayState, relayState: ((req as unknown as FastifyRequest).body as { RelayState?: string }).RelayState,
@@ -327,4 +325,32 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => {
return ldapGroupMap; 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;
}
});
}; };

View File

@@ -37,8 +37,10 @@ import {
TGetLdapCfgDTO, TGetLdapCfgDTO,
TGetLdapGroupMapsDTO, TGetLdapGroupMapsDTO,
TLdapLoginDTO, TLdapLoginDTO,
TTestLdapConnectionDTO,
TUpdateLdapCfgDTO TUpdateLdapCfgDTO
} from "./ldap-config-types"; } from "./ldap-config-types";
import { testLDAPConfig } from "./ldap-fns";
import { TLdapGroupMapDALFactory } from "./ldap-group-map-dal"; import { TLdapGroupMapDALFactory } from "./ldap-group-map-dal";
type TLdapConfigServiceFactoryDep = { type TLdapConfigServiceFactoryDep = {
@@ -650,6 +652,23 @@ export const ldapConfigServiceFactory = ({
return deletedGroupMap; 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 { return {
createLdapCfg, createLdapCfg,
updateLdapCfg, updateLdapCfg,
@@ -660,6 +679,7 @@ export const ldapConfigServiceFactory = ({
bootLdap, bootLdap,
getLdapGroupMaps, getLdapGroupMaps,
createLdapGroupMap, createLdapGroupMap,
deleteLdapGroupMap deleteLdapGroupMap,
testLDAPConnection
}; };
}; };

View File

@@ -72,3 +72,7 @@ export type TDeleteLdapGroupMapDTO = {
ldapConfigId: string; ldapConfigId: string;
ldapGroupMapId: string; ldapGroupMapId: string;
} & TOrgPermission; } & TOrgPermission;
export type TTestLdapConnectionDTO = {
ldapConfigId: string;
} & TOrgPermission;

View File

@@ -4,6 +4,54 @@ import { logger } from "@app/lib/logger";
import { TLDAPConfig } from "./ldap-config-types"; 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<boolean> => {
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 ( export const searchGroups = async (
ldapConfig: TLDAPConfig, ldapConfig: TLDAPConfig,
filter: string, filter: string,
@@ -31,11 +79,7 @@ export const searchGroups = async (
}, },
(err, res) => { (err, res) => {
if (err) { if (err) {
ldapClient.unbind((unbindError) => { ldapClient.unbind();
if (unbindError) {
logger.error("Error unbinding LDAP client:", unbindError);
}
});
return reject(err); return reject(err);
} }
@@ -51,19 +95,11 @@ export const searchGroups = async (
groups.push({ dn, cn }); groups.push({ dn, cn });
}); });
res.on("error", (error) => { res.on("error", (error) => {
ldapClient.unbind((unbindError) => { ldapClient.unbind();
if (unbindError) {
logger.error("Error unbinding LDAP client:", unbindError);
}
});
reject(error); reject(error);
}); });
res.on("end", () => { res.on("end", () => {
ldapClient.unbind((unbindError) => { ldapClient.unbind();
if (unbindError) {
logger.error("Error unbinding LDAP client:", unbindError);
}
});
resolve(groups); resolve(groups);
}); });
} }

View File

@@ -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. - 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` - 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 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. - CA Certificate: The CA certificate to use when verifying the LDAP server certificate.
<Note> <Note>

View File

@@ -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. - 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=<your-org-id>,dc=jumpcloud,dc=com`). - Search Base / User DN: Base DN under which to perform user search (`ou=Users,o=<your-org-id>,dc=jumpcloud,dc=com`).
- Group Search Base / Group DN (optional): LDAP search base to use for group membership search (`ou=Users,o=<your-org-id>,dc=jumpcloud,dc=com`). - Group Search Base / Group DN (optional): LDAP search base to use for group membership search (`ou=Users,o=<your-org-id>,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=<your-org-id>,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)). - 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)).
<Tip> <Tip>

View File

@@ -2,5 +2,6 @@ export {
useCreateLDAPConfig, useCreateLDAPConfig,
useCreateLDAPGroupMapping, useCreateLDAPGroupMapping,
useDeleteLDAPGroupMapping, useDeleteLDAPGroupMapping,
useTestLDAPConnection,
useUpdateLDAPConfig} from "./mutations"; useUpdateLDAPConfig} from "./mutations";
export { useGetLDAPConfig, useGetLDAPGroupMaps } from "./queries"; export { useGetLDAPConfig, useGetLDAPGroupMaps } from "./queries";

View File

@@ -136,3 +136,14 @@ export const useDeleteLDAPGroupMapping = () => {
} }
}); });
}; };
export const useTestLDAPConnection = () => {
return useMutation({
mutationFn: async (ldapConfigId: string) => {
const { data } = await apiRequest.post<boolean>(
`/api/v1/ldap/config/${ldapConfigId}/test-connection`
);
return data;
}
});
};

View File

@@ -6,7 +6,11 @@ import { z } from "zod";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Modal, ModalContent, TextArea } from "@app/components/v2"; import { Button, FormControl, Input, Modal, ModalContent, TextArea } from "@app/components/v2";
import { useOrganization } from "@app/context"; 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"; import { UsePopUpState } from "@app/hooks/usePopUp";
const LDAPFormSchema = z.object({ const LDAPFormSchema = z.object({
@@ -32,12 +36,21 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props)
const { mutateAsync: createMutateAsync, isLoading: createIsLoading } = useCreateLDAPConfig(); const { mutateAsync: createMutateAsync, isLoading: createIsLoading } = useCreateLDAPConfig();
const { mutateAsync: updateMutateAsync, isLoading: updateIsLoading } = useUpdateLDAPConfig(); const { mutateAsync: updateMutateAsync, isLoading: updateIsLoading } = useUpdateLDAPConfig();
const { mutateAsync: testLDAPConnection } = useTestLDAPConnection();
const { data } = useGetLDAPConfig(currentOrg?.id ?? ""); const { data } = useGetLDAPConfig(currentOrg?.id ?? "");
const { control, handleSubmit, reset } = useForm<TLDAPFormData>({ const { control, handleSubmit, reset, watch } = useForm<TLDAPFormData>({
resolver: zodResolver(LDAPFormSchema) 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(() => { useEffect(() => {
if (data) { if (data) {
reset({ reset({
@@ -59,8 +72,9 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props)
searchBase, searchBase,
groupSearchBase, groupSearchBase,
groupSearchFilter, groupSearchFilter,
caCert caCert,
}: TLDAPFormData) => { shouldCloseModal = true
}: TLDAPFormData & { shouldCloseModal?: boolean }) => {
try { try {
if (!currentOrg) return; if (!currentOrg) return;
@@ -90,7 +104,9 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props)
}); });
} }
handlePopUpClose("addLDAP"); if (shouldCloseModal) {
handlePopUpClose("addLDAP");
}
createNotification({ createNotification({
text: `Successfully ${!data ? "added" : "updated"} LDAP configuration`, 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 ( return (
<Modal <Modal
isOpen={popUp?.addLDAP?.isOpen} isOpen={popUp?.addLDAP?.isOpen}
@@ -203,12 +257,8 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props)
> >
{!data ? "Add" : "Update"} {!data ? "Add" : "Update"}
</Button> </Button>
<Button <Button colorSchema="secondary" onClick={handleTestLDAPConnection}>
colorSchema="secondary" Test Connection
variant="plain"
onClick={() => handlePopUpClose("addLDAP")}
>
Cancel
</Button> </Button>
</div> </div>
</form> </form>

View File

@@ -97,13 +97,15 @@ export const OrgLDAPSection = (): JSX.Element => {
<div className="py-4"> <div className="py-4">
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">LDAP</h2> <h2 className="text-md text-mineshaft-100">LDAP</h2>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Ldap}> <div className="flex">
{(isAllowed) => ( <OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Ldap}>
<Button onClick={addLDAPBtnClick} colorSchema="secondary" isDisabled={!isAllowed}> {(isAllowed) => (
Manage <Button onClick={addLDAPBtnClick} colorSchema="secondary" isDisabled={!isAllowed}>
</Button> Manage
)} </Button>
</OrgPermissionCan> )}
</OrgPermissionCan>
</div>
</div> </div>
<p className="text-sm text-mineshaft-300">Manage LDAP authentication configuration</p> <p className="text-sm text-mineshaft-300">Manage LDAP authentication configuration</p>
</div> </div>