Futa Arai 2 лет назад
Родитель
Сommit
4c8b3aa224

+ 1 - 0
apps/app/package.json

@@ -120,6 +120,7 @@
     "i18next-localstorage-backend": "^4.0.0",
     "i18next-localstorage-backend": "^4.0.0",
     "is-absolute-url": "^4.0.1",
     "is-absolute-url": "^4.0.1",
     "is-iso-date": "^0.0.1",
     "is-iso-date": "^0.0.1",
+    "ldapjs": "^3.0.2",
     "lucene-query-parser": "^1.2.0",
     "lucene-query-parser": "^1.2.0",
     "markdown-table": "^1.1.1",
     "markdown-table": "^1.1.1",
     "md5": "^2.2.1",
     "md5": "^2.2.1",

+ 1 - 1
apps/app/public/static/locales/en_US/admin.json

@@ -1042,7 +1042,7 @@
     "ldap": {
     "ldap": {
       "group_sync_settings": "LDAP Group Sync Settings",
       "group_sync_settings": "LDAP Group Sync Settings",
       "group_search_base_DN": "Group Search Base DN",
       "group_search_base_DN": "Group Search Base DN",
-      "group_search_base_dn_detail": "The base DN from which to search for groups",
+      "group_search_base_dn_detail": "The base DN for searching groups. The value set in security settings will be used if not set here.",
       "membership_attribute": "Membership Attribute",
       "membership_attribute": "Membership Attribute",
       "membership_attribute_detail": "Attribute of the group object which indicates user membership info",
       "membership_attribute_detail": "Attribute of the group object which indicates user membership info",
       "membership_attribute_type": "Membership Attribute Type",
       "membership_attribute_type": "Membership Attribute Type",

+ 1 - 1
apps/app/public/static/locales/ja_JP/admin.json

@@ -1050,7 +1050,7 @@
     "ldap": {
     "ldap": {
       "group_sync_settings": "LDAP グループ同期設定",
       "group_sync_settings": "LDAP グループ同期設定",
       "group_search_base_DN": "グループ検索ベース DN",
       "group_search_base_DN": "グループ検索ベース DN",
-      "group_search_base_dn_detail": "グループ検索をするベース DN",
+      "group_search_base_dn_detail": "グループ検索をするベース DN。設定されていない場合、セキュリティ設定で設定されたものが利用されます。",
       "membership_attribute": "所属メンバーを表す LDAP 属性",
       "membership_attribute": "所属メンバーを表す LDAP 属性",
       "membership_attribute_detail": "グループの所属メンバーを表すグループオブジェクトの属性",
       "membership_attribute_detail": "グループの所属メンバーを表すグループオブジェクトの属性",
       "membership_attribute_type": "「所属メンバーを表す LDAP 属性」値の種類",
       "membership_attribute_type": "「所属メンバーを表す LDAP 属性」値の種類",

+ 1 - 1
apps/app/public/static/locales/zh_CN/admin.json

@@ -1050,7 +1050,7 @@
     "ldap": {
     "ldap": {
       "group_sync_settings": "LDAP Group Sync Settings",
       "group_sync_settings": "LDAP Group Sync Settings",
       "group_search_base_DN": "Group Search Base DN",
       "group_search_base_DN": "Group Search Base DN",
-      "group_search_base_dn_detail": "The base DN from which to search for groups",
+      "group_search_base_dn_detail": "The base DN for searching groups. The value set in security settings will be used if not set here.",
       "membership_attribute": "Membership Attribute",
       "membership_attribute": "Membership Attribute",
       "membership_attribute_detail": "Attribute of the group object which indicates user membership info",
       "membership_attribute_detail": "Attribute of the group object which indicates user membership info",
       "membership_attribute_type": "Membership Attribute Type",
       "membership_attribute_type": "Membership Attribute Type",

+ 2 - 2
apps/app/src/components/Admin/UserGroup/ExternalGroup/ExternalGroupManagement.tsx

@@ -5,7 +5,7 @@ import { TabContent, TabPane } from 'reactstrap';
 
 
 import CustomNav from '~/components/CustomNavigation/CustomNav';
 import CustomNav from '~/components/CustomNavigation/CustomNav';
 
 
-import { LDAPGroupSyncSettingsForm } from './LDAPGroupSyncSettingsForm';
+import { LdapGroupManagement } from './LdapGroupManagement';
 
 
 export const ExternalGroupManagement: FC = () => {
 export const ExternalGroupManagement: FC = () => {
   const [activeTab, setActiveTab] = useState('ldap');
   const [activeTab, setActiveTab] = useState('ldap');
@@ -37,7 +37,7 @@ export const ExternalGroupManagement: FC = () => {
     />
     />
     <TabContent activeTab={activeTab} className="p-5">
     <TabContent activeTab={activeTab} className="p-5">
       <TabPane tabId="ldap">
       <TabPane tabId="ldap">
-        {activeComponents.has('ldap') && <LDAPGroupSyncSettingsForm />}
+        {activeComponents.has('ldap') && <LdapGroupManagement />}
       </TabPane>
       </TabPane>
     </TabContent>
     </TabContent>
   </>;
   </>;

+ 27 - 26
apps/app/src/components/Admin/UserGroup/ExternalGroup/LDAPGroupSyncSettingsForm.tsx

@@ -6,21 +6,21 @@ import { useTranslation } from 'react-i18next';
 
 
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import { LDAPGroupSyncSettings } from '~/interfaces/external-user-group';
-import { useSWRxLDAPGroupSyncSettings } from '~/stores/external-user-group';
+import { LdapGroupSyncSettings } from '~/interfaces/external-user-group';
+import { useSWRxLdapGroupSyncSettings } from '~/stores/external-user-group';
 
 
-export const LDAPGroupSyncSettingsForm: FC = () => {
+export const LdapGroupSyncSettingsForm: FC = () => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
-  const { data: ldapGroupSyncSettings } = useSWRxLDAPGroupSyncSettings();
+  const { data: ldapGroupSyncSettings } = useSWRxLdapGroupSyncSettings();
 
 
-  const [formValues, setFormValues] = useState<LDAPGroupSyncSettings>({
-    ldapGroupsDN: '',
+  const [formValues, setFormValues] = useState<LdapGroupSyncSettings>({
+    ldapGroupSearchBase: '',
     ldapGroupMembershipAttribute: '',
     ldapGroupMembershipAttribute: '',
     ldapGroupMembershipAttributeType: '',
     ldapGroupMembershipAttributeType: '',
     ldapGroupChildGroupAttribute: '',
     ldapGroupChildGroupAttribute: '',
-    autoGenerateUserOnLDAPGroupSync: false,
-    preserveDeletedLDAPGroups: false,
+    autoGenerateUserOnLdapGroupSync: false,
+    preserveDeletedLdapGroups: false,
     ldapGroupNameAttribute: '',
     ldapGroupNameAttribute: '',
     ldapGroupDescriptionAttribute: '',
     ldapGroupDescriptionAttribute: '',
   });
   });
@@ -43,19 +43,18 @@ export const LDAPGroupSyncSettingsForm: FC = () => {
   }, [formValues, t]);
   }, [formValues, t]);
 
 
   return <>
   return <>
-    <h3 className="border-bottom">{t('external_group.ldap.group_sync_settings')}</h3>
+    <h3 className="border-bottom mb-3">{t('external_group.ldap.group_sync_settings')}</h3>
     <form onSubmit={submitHandler}>
     <form onSubmit={submitHandler}>
       <div className="row form-group">
       <div className="row form-group">
-        <label htmlFor="ldapGroupsDN" className="text-left text-md-right col-md-3 col-form-label">{t('external_group.ldap.group_search_base_DN')}</label>
+        <label htmlFor="ldapGroupSearchBase" className="text-left text-md-right col-md-3 col-form-label">{t('external_group.ldap.group_search_base_DN')}</label>
         <div className="col-md-6">
         <div className="col-md-6">
           <input
           <input
             className="form-control"
             className="form-control"
-            required
             type="text"
             type="text"
-            name="ldapGroupsDN"
-            id="ldapGroupsDN"
-            value={formValues.ldapGroupsDN}
-            onChange={e => setFormValues({ ...formValues, ldapGroupsDN: e.target.value })}
+            name="ldapGroupSearchBase"
+            id="ldapGroupSearchBase"
+            value={formValues.ldapGroupSearchBase}
+            onChange={e => setFormValues({ ...formValues, ldapGroupSearchBase: e.target.value })}
           />
           />
           <p className="form-text text-muted">
           <p className="form-text text-muted">
             <small>{t('external_group.ldap.group_search_base_dn_detail')}</small>
             <small>{t('external_group.ldap.group_search_base_dn_detail')}</small>
@@ -138,14 +137,14 @@ export const LDAPGroupSyncSettingsForm: FC = () => {
             <input
             <input
               type="checkbox"
               type="checkbox"
               className="custom-control-input"
               className="custom-control-input"
-              name="autoGenerateUserOnLDAPGroupSync"
-              id="autoGenerateUserOnLDAPGroupSync"
-              checked={formValues.autoGenerateUserOnLDAPGroupSync}
-              onChange={() => setFormValues({ ...formValues, autoGenerateUserOnLDAPGroupSync: !formValues.autoGenerateUserOnLDAPGroupSync })}
+              name="autoGenerateUserOnLdapGroupSync"
+              id="autoGenerateUserOnLdapGroupSync"
+              checked={formValues.autoGenerateUserOnLdapGroupSync}
+              onChange={() => setFormValues({ ...formValues, autoGenerateUserOnLdapGroupSync: !formValues.autoGenerateUserOnLdapGroupSync })}
             />
             />
             <label
             <label
               className="custom-control-label"
               className="custom-control-label"
-              htmlFor="autoGenerateUserOnLDAPGroupSync"
+              htmlFor="autoGenerateUserOnLdapGroupSync"
             >
             >
               {t('external_group.ldap.auto_generate_user_on_sync')}
               {t('external_group.ldap.auto_generate_user_on_sync')}
             </label>
             </label>
@@ -163,21 +162,23 @@ export const LDAPGroupSyncSettingsForm: FC = () => {
             <input
             <input
               type="checkbox"
               type="checkbox"
               className="custom-control-input"
               className="custom-control-input"
-              name="preserveDeletedLDAPGroups"
-              id="preserveDeletedLDAPGroups"
-              checked={formValues.preserveDeletedLDAPGroups}
-              onChange={() => setFormValues({ ...formValues, preserveDeletedLDAPGroups: !formValues.preserveDeletedLDAPGroups })}
+              name="preserveDeletedLdapGroups"
+              id="preserveDeletedLdapGroups"
+              checked={formValues.preserveDeletedLdapGroups}
+              onChange={() => setFormValues({ ...formValues, preserveDeletedLdapGroups: !formValues.preserveDeletedLdapGroups })}
             />
             />
             <label
             <label
               className="custom-control-label"
               className="custom-control-label"
-              htmlFor="preserveDeletedLDAPGroups"
+              htmlFor="preserveDeletedLdapGroups"
             >
             >
               {t('external_group.ldap.preserve_deleted_ldap_groups')}
               {t('external_group.ldap.preserve_deleted_ldap_groups')}
             </label>
             </label>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
-      <h3 className="border-bottom">Attribute Mapping ({t('optional')})</h3>
+      <div className="px-5">
+        <h4 className="border-bottom mb-3">Attribute Mapping ({t('optional')})</h4>
+      </div>
       <div className="row form-group">
       <div className="row form-group">
         <label htmlFor="ldapGroupNameAttribute" className="text-left text-md-right col-md-3 col-form-label">{t('Name')}</label>
         <label htmlFor="ldapGroupNameAttribute" className="text-left text-md-right col-md-3 col-form-label">{t('Name')}</label>
         <div className="col-md-6">
         <div className="col-md-6">

+ 27 - 0
apps/app/src/components/Admin/UserGroup/ExternalGroup/LdapGroupManagement.tsx

@@ -0,0 +1,27 @@
+import { FC, useCallback } from 'react';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+
+import { LdapGroupSyncSettingsForm } from './LdapGroupSyncSettingsForm';
+
+export const LdapGroupManagement: FC = () => {
+  const onSyncBtnClick = useCallback(async() => {
+    try {
+      await apiv3Put('/external-user-groups/ldap/sync');
+      toastSuccess('同期に成功しました');
+    }
+    catch (e) {
+      toastError('同期に失敗しました。LDAP による認証設定や、グループ同期設定が正しいことを確認してください。');
+    }
+  }, []);
+
+  return <>
+    <LdapGroupSyncSettingsForm />
+    <h3 className="border-bottom mb-3">同期実行</h3>
+    <div className="row">
+      <div className="col-md-3"></div>
+      <div className="col-md-6"><button className="btn btn-primary" onClick={onSyncBtnClick}>同期</button></div>
+    </div>
+  </>;
+};

+ 4 - 4
apps/app/src/interfaces/external-user-group.ts

@@ -1,10 +1,10 @@
-export interface LDAPGroupSyncSettings {
-  ldapGroupsDN: string
+export interface LdapGroupSyncSettings {
+  ldapGroupSearchBase: string
   ldapGroupMembershipAttribute: string
   ldapGroupMembershipAttribute: string
   ldapGroupMembershipAttributeType: string
   ldapGroupMembershipAttributeType: string
   ldapGroupChildGroupAttribute: string
   ldapGroupChildGroupAttribute: string
-  autoGenerateUserOnLDAPGroupSync: boolean
-  preserveDeletedLDAPGroups: boolean
+  autoGenerateUserOnLdapGroupSync: boolean
+  preserveDeletedLdapGroups: boolean
   ldapGroupNameAttribute: string
   ldapGroupNameAttribute: string
   ldapGroupDescriptionAttribute?: string
   ldapGroupDescriptionAttribute?: string
 }
 }

+ 7 - 0
apps/app/src/server/crowi/index.js

@@ -28,6 +28,7 @@ import AttachmentService from '../service/attachment';
 import { configManager as configManagerSingletonInstance } from '../service/config-manager';
 import { configManager as configManagerSingletonInstance } from '../service/config-manager';
 import { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
 import { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
 import { InstallerService } from '../service/installer';
 import { InstallerService } from '../service/installer';
+import LdapService from '../service/ldap';
 import PageService from '../service/page';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
 import PageGrantService from '../service/page-grant';
 import PageOperationService from '../service/page-operation';
 import PageOperationService from '../service/page-operation';
@@ -86,6 +87,7 @@ function Crowi() {
   this.xss = new Xss();
   this.xss = new Xss();
   this.questionnaireService = null;
   this.questionnaireService = null;
   this.questionnaireCronService = null;
   this.questionnaireCronService = null;
+  this.ldapService = null;
 
 
   this.tokens = null;
   this.tokens = null;
 
 
@@ -149,6 +151,7 @@ Crowi.prototype.init = async function() {
     this.setupCommentService(),
     this.setupCommentService(),
     this.setupSyncPageStatusService(),
     this.setupSyncPageStatusService(),
     this.setupQuestionnaireService(),
     this.setupQuestionnaireService(),
+    this.setupLdapService(),
     this.setUpCustomize(), // depends on pluginService
     this.setUpCustomize(), // depends on pluginService
   ]);
   ]);
 
 
@@ -320,6 +323,10 @@ Crowi.prototype.setupQuestionnaireService = function() {
   this.questionnaireService = new QuestionnaireService(this);
   this.questionnaireService = new QuestionnaireService(this);
 };
 };
 
 
+Crowi.prototype.setupLdapService = function() {
+  this.ldapService = new LdapService(this);
+};
+
 Crowi.prototype.scanRuntimeVersions = async function() {
 Crowi.prototype.scanRuntimeVersions = async function() {
   const self = this;
   const self = this;
 
 

+ 28 - 15
apps/app/src/server/routes/apiv3/external-user-group.ts

@@ -19,27 +19,28 @@ module.exports = (crowi: Crowi): Router => {
 
 
   const validators = {
   const validators = {
     ldapSyncSettings: [
     ldapSyncSettings: [
-      body('ldapGroupsDN').exists({ checkFalsy: true }).isString(),
+      body('ldapGroupSearchBase').optional({ nullable: true }).isString(),
       body('ldapGroupMembershipAttribute').exists({ checkFalsy: true }).isString(),
       body('ldapGroupMembershipAttribute').exists({ checkFalsy: true }).isString(),
       body('ldapGroupMembershipAttributeType').exists({ checkFalsy: true }).isString(),
       body('ldapGroupMembershipAttributeType').exists({ checkFalsy: true }).isString(),
       body('ldapGroupChildGroupAttribute').exists({ checkFalsy: true }).isString(),
       body('ldapGroupChildGroupAttribute').exists({ checkFalsy: true }).isString(),
-      body('autoGenerateUserOnLDAPGroupSync').exists().isBoolean(),
-      body('preserveDeletedLDAPGroups').exists().isBoolean(),
+      body('autoGenerateUserOnLdapGroupSync').exists().isBoolean(),
+      body('preserveDeletedLdapGroups').exists().isBoolean(),
       body('ldapGroupNameAttribute').optional({ nullable: true }).isString(),
       body('ldapGroupNameAttribute').optional({ nullable: true }).isString(),
       body('ldapGroupDescriptionAttribute').optional({ nullable: true }).isString(),
       body('ldapGroupDescriptionAttribute').optional({ nullable: true }).isString(),
     ],
     ],
   };
   };
 
 
-  router.get('/ldap/sync-settings', loginRequiredStrictly, adminRequired, validators.ldapSyncSettings, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.get('/ldap/sync-settings', loginRequiredStrictly, adminRequired, validators.ldapSyncSettings, (req: AuthorizedRequest, res: ApiV3Response) => {
+    const { configManager } = crowi;
     const settings = {
     const settings = {
-      ldapGroupsDN: await crowi.configManager?.getConfig('crowi', 'external-user-group:ldap:groupsDN'),
-      ldapGroupMembershipAttribute: await crowi.configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttribute'),
-      ldapGroupMembershipAttributeType: await crowi.configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttributeType'),
-      ldapGroupChildGroupAttribute: await crowi.configManager?.getConfig('crowi', 'external-user-group:ldap:groupChildGroupAttribute'),
-      autoGenerateUserOnLDAPGroupSync: await crowi.configManager?.getConfig('crowi', 'external-user-group:ldap:autoGenerateUserOnGroupSync'),
-      preserveDeletedLDAPGroups: await crowi.configManager?.getConfig('crowi', 'external-user-group:ldap:preserveDeletedGroups'),
-      ldapGroupNameAttribute: await crowi.configManager?.getConfig('crowi', 'external-user-group:ldap:groupNameAttribute'),
-      ldapGroupDescriptionAttribute: await crowi.configManager?.getConfig('crowi', 'external-user-group:ldap:groupDescriptionAttribute'),
+      ldapGroupSearchBase: configManager?.getConfig('crowi', 'external-user-group:ldap:groupSearchBase'),
+      ldapGroupMembershipAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttribute'),
+      ldapGroupMembershipAttributeType: configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttributeType'),
+      ldapGroupChildGroupAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupChildGroupAttribute'),
+      autoGenerateUserOnLdapGroupSync: configManager?.getConfig('crowi', 'external-user-group:ldap:autoGenerateUserOnGroupSync'),
+      preserveDeletedLdapGroups: configManager?.getConfig('crowi', 'external-user-group:ldap:preserveDeletedGroups'),
+      ldapGroupNameAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupNameAttribute'),
+      ldapGroupDescriptionAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupDescriptionAttribute'),
     };
     };
 
 
     return res.apiv3(settings);
     return res.apiv3(settings);
@@ -52,12 +53,12 @@ module.exports = (crowi: Crowi): Router => {
     }
     }
 
 
     const params = {
     const params = {
-      'external-user-group:ldap:groupsDN': req.body.ldapGroupsDN,
+      'external-user-group:ldap:groupSearchBase': req.body.ldapGroupSearchBase,
       'external-user-group:ldap:groupMembershipAttribute': req.body.ldapGroupMembershipAttribute,
       'external-user-group:ldap:groupMembershipAttribute': req.body.ldapGroupMembershipAttribute,
       'external-user-group:ldap:groupMembershipAttributeType': req.body.ldapGroupMembershipAttributeType,
       'external-user-group:ldap:groupMembershipAttributeType': req.body.ldapGroupMembershipAttributeType,
       'external-user-group:ldap:groupChildGroupAttribute': req.body.ldapGroupChildGroupAttribute,
       'external-user-group:ldap:groupChildGroupAttribute': req.body.ldapGroupChildGroupAttribute,
-      'external-user-group:ldap:autoGenerateUserOnGroupSync': req.body.autoGenerateUserOnLDAPGroupSync,
-      'external-user-group:ldap:preserveDeletedGroups': req.body.preserveDeletedLDAPGroups,
+      'external-user-group:ldap:autoGenerateUserOnGroupSync': req.body.autoGenerateUserOnLdapGroupSync,
+      'external-user-group:ldap:preserveDeletedGroups': req.body.preserveDeletedLdapGroups,
       'external-user-group:ldap:groupNameAttribute': req.body.ldapGroupNameAttribute,
       'external-user-group:ldap:groupNameAttribute': req.body.ldapGroupNameAttribute,
       'external-user-group:ldap:groupDescriptionAttribute': req.body.ldapGroupDescriptionAttribute,
       'external-user-group:ldap:groupDescriptionAttribute': req.body.ldapGroupDescriptionAttribute,
     };
     };
@@ -77,6 +78,18 @@ module.exports = (crowi: Crowi): Router => {
     }
     }
   });
   });
 
 
+  router.put('/ldap/sync', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    try {
+      const groups = await crowi.ldapService?.searchGroup();
+      logger.debug(`ldap groups: ${groups}`);
+    }
+    catch (e) {
+      res.apiv3Err(e, 500);
+    }
+
+    return res.apiv3({}, 204);
+  });
+
   return router;
   return router;
 
 
 };
 };

+ 97 - 0
apps/app/src/server/service/ldap.ts

@@ -0,0 +1,97 @@
+import assert from 'assert';
+
+import ldap from 'ldapjs';
+
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:service:ldap-service');
+
+/**
+ * Service to connect to LDAP server.
+ * User auth using LDAP is done with PassportService, not here.
+*/
+class LdapService {
+
+  crowi: any;
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  search(filter?: string, base?: string): Promise<ldap.SearchEntry[]> {
+    const { configManager } = this.crowi;
+    const isLdapEnabled = configManager?.getConfig('crowi', 'security:passport-ldap:isEnabled');
+
+    if (!isLdapEnabled) {
+      const notEnabledMessage = 'LDAP is not enabled';
+      logger.error(notEnabledMessage);
+      throw new Error(notEnabledMessage);
+    }
+
+    // get configurations
+    const isUserBind = configManager?.getConfig('crowi', 'security:passport-ldap:isUserBind');
+    const serverUrl = configManager?.getConfig('crowi', 'security:passport-ldap:serverUrl');
+    const bindDN = configManager?.getConfig('crowi', 'security:passport-ldap:bindDN');
+    const bindCredentials = configManager?.getConfig('crowi', 'security:passport-ldap:bindDNPassword');
+    const searchFilter = configManager?.getConfig('crowi', 'security:passport-ldap:searchFilter') || '(uid={{username}})';
+
+    // parse serverUrl
+    // see: https://regex101.com/r/0tuYBB/1
+    const match = serverUrl.match(/(ldaps?:\/\/[^/]+)\/(.*)?/);
+    if (match == null || match.length < 1) {
+      const urlInvalidMessage = 'serverUrl is invalid';
+      logger.error(urlInvalidMessage);
+      throw new Error(urlInvalidMessage);
+    }
+    const url = match[1];
+    const searchBase = match[2] || '';
+
+    const client = ldap.createClient({
+      url,
+    });
+
+    client.bind(bindDN, bindCredentials, (err) => {
+      assert.ifError(err);
+    });
+
+    const searchResults: ldap.SearchEntry[] = [];
+
+    return new Promise((resolve, reject) => {
+      client.search(base || searchBase, { scope: 'sub', filter }, (err, res) => {
+        if (err != null) {
+          reject(err);
+        }
+
+        res.on('searchEntry', (entry: any) => {
+          const pojo = entry?.pojo as ldap.SearchEntry;
+          searchResults.push(pojo);
+        });
+        res.on('error', (err) => {
+          reject(err);
+        });
+        res.on('end', (result) => {
+          if (result?.status === 0) {
+            resolve(searchResults);
+          }
+          else {
+            reject(new Error(`LDAP search failed: status code ${result?.status}`));
+          }
+        });
+      });
+    });
+  }
+
+  searchGroup(): Promise<ldap.SearchEntry[]> {
+    const { configManager } = this.crowi;
+
+    const groupSearchBase = configManager?.getConfig('crowi', 'external-user-group:ldap:groupSearchBase')
+    || configManager?.getConfig('crowi', 'security:passport-ldap:groupSearchBase');
+
+    return this.search(undefined, groupSearchBase);
+  }
+
+}
+
+export default LdapService;

+ 2 - 2
apps/app/src/stores/external-user-group.ts

@@ -1,9 +1,9 @@
 import useSWR, { SWRResponse } from 'swr';
 import useSWR, { SWRResponse } from 'swr';
 
 
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
-import { LDAPGroupSyncSettings } from '~/interfaces/external-user-group';
+import { LdapGroupSyncSettings } from '~/interfaces/external-user-group';
 
 
-export const useSWRxLDAPGroupSyncSettings = (): SWRResponse<LDAPGroupSyncSettings, Error> => {
+export const useSWRxLdapGroupSyncSettings = (): SWRResponse<LdapGroupSyncSettings, Error> => {
   return useSWR(
   return useSWR(
     '/external-user-groups/ldap/sync-settings',
     '/external-user-groups/ldap/sync-settings',
     endpoint => apiv3Get(endpoint).then((response) => {
     endpoint => apiv3Get(endpoint).then((response) => {

+ 113 - 1
yarn.lock

@@ -2673,6 +2673,77 @@
   dependencies:
   dependencies:
     "@types/react" ">=16.0.0"
     "@types/react" ">=16.0.0"
 
 
+"@ldapjs/asn1@2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@ldapjs/asn1/-/asn1-2.0.0.tgz#e25fa38fcf0b4310275d6a5a05fe4603efef5eb4"
+  integrity sha512-G9+DkEOirNgdPmD0I8nu57ygQJKOOgFEMKknEuQvIHbGLwP3ny1mY+OTUYLCbCaGJP4sox5eYgBJRuSUpnAddA==
+
+"@ldapjs/asn1@^1.2.0":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@ldapjs/asn1/-/asn1-1.2.0.tgz#5e99338fb39ff518c205827bec0fd9a6bf6b42db"
+  integrity sha512-KX/qQJ2xxzvO2/WOvr1UdQ+8P5dVvuOLk/C9b1bIkXxZss8BaR28njXdPgFCpj5aHaf1t8PmuVnea+N9YG9YMw==
+
+"@ldapjs/attribute@1.0.0":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@ldapjs/attribute/-/attribute-1.0.0.tgz#d81d626080584c1c80ef300a214458f9f78a8abb"
+  integrity sha512-ptMl2d/5xJ0q+RgmnqOi3Zgwk/TMJYG7dYMC0Keko+yZU6n+oFM59MjQOUht5pxJeS4FWrImhu/LebX24vJNRQ==
+  dependencies:
+    "@ldapjs/asn1" "2.0.0"
+    "@ldapjs/protocol" "^1.2.1"
+    process-warning "^2.1.0"
+
+"@ldapjs/change@1.0.0":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@ldapjs/change/-/change-1.0.0.tgz#34818a3a31cb337d3b90ab853bb7fa90517c2c4f"
+  integrity sha512-EOQNFH1RIku3M1s0OAJOzGfAohuFYXFY4s73wOhRm4KFGhmQQ7MChOh2YtYu9Kwgvuq1B0xKciXVzHCGkB5V+Q==
+  dependencies:
+    "@ldapjs/asn1" "2.0.0"
+    "@ldapjs/attribute" "1.0.0"
+
+"@ldapjs/controls@2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@ldapjs/controls/-/controls-2.0.0.tgz#c1e2b90fc82e8b955ef2392c855bc730f7596484"
+  integrity sha512-NpFmdIc2q83tYRGR2a3NDulKgU1e4YOgqjQmmMezCoN4Xz0tju4yB4eibQNC+Zg8YRW06KPwFPKbebDaCqFF0w==
+  dependencies:
+    "@ldapjs/asn1" "^1.2.0"
+    "@ldapjs/protocol" "^1.2.1"
+
+"@ldapjs/dn@1.0.0":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@ldapjs/dn/-/dn-1.0.0.tgz#bc7ec14ba765f253ed498e7578777fa04a7984a1"
+  integrity sha512-qPsJDC5dQU2TSkA/IpswvPEg9MU6TIjjq0UOCHtuUeD3eWihTUjHuu/dith4NFRKjBvgFnqRQvo+t0YC+3z0Rw==
+  dependencies:
+    "@ldapjs/asn1" "2.0.0"
+    process-warning "^2.1.0"
+
+"@ldapjs/filter@2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@ldapjs/filter/-/filter-2.0.0.tgz#5350c727ec5c93dc21d1861378ab3a535bd53e33"
+  integrity sha512-7hMv5DNlHJk4qoGzCFGbbSV0vgvn2A7hZ4mt15557xDhw+BXjhryBvs8ANTHUpyaWvESbU+oNOsbBobNLZ45Nw==
+  dependencies:
+    "@ldapjs/asn1" "2.0.0"
+    "@ldapjs/protocol" "^1.2.1"
+    process-warning "^2.1.0"
+
+"@ldapjs/messages@1.0.2":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@ldapjs/messages/-/messages-1.0.2.tgz#693aaa6f7c2b89441a89147edb0273d3b43e5831"
+  integrity sha512-aVYyqTDsIfnUt2Qr2syJi99M39h4ll9soggOtUjsf4Sv1xVQ/M5VY11T0h69S2fQ4NnaYi9iXd440LVU4MCCKQ==
+  dependencies:
+    "@ldapjs/asn1" "2.0.0"
+    "@ldapjs/attribute" "1.0.0"
+    "@ldapjs/change" "1.0.0"
+    "@ldapjs/controls" "2.0.0"
+    "@ldapjs/dn" "1.0.0"
+    "@ldapjs/filter" "2.0.0"
+    "@ldapjs/protocol" "1.2.1"
+    process-warning "^2.1.0"
+
+"@ldapjs/protocol@1.2.1", "@ldapjs/protocol@^1.2.1":
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/@ldapjs/protocol/-/protocol-1.2.1.tgz#d58d371d6958f28095e8de23b35341bcaba55cf3"
+  integrity sha512-O89xFDLW2gBoZWNXuXpBSM32/KealKCTb3JGtJdtUQc7RjAk8XzrRgyz02cPAwGKwKPxy0ivuC7UP9bmN87egQ==
+
 "@lykmapipo/common@>=0.34.2", "@lykmapipo/common@>=0.34.3":
 "@lykmapipo/common@>=0.34.2", "@lykmapipo/common@>=0.34.3":
   version "0.34.3"
   version "0.34.3"
   resolved "https://registry.yarnpkg.com/@lykmapipo/common/-/common-0.34.3.tgz#eb74fa4af14f2f1e59ddd42491f05ab69f96bd71"
   resolved "https://registry.yarnpkg.com/@lykmapipo/common/-/common-0.34.3.tgz#eb74fa4af14f2f1e59ddd42491f05ab69f96bd71"
@@ -4397,7 +4468,7 @@ abort-controller@^3.0.0:
   dependencies:
   dependencies:
     event-target-shim "^5.0.0"
     event-target-shim "^5.0.0"
 
 
-abstract-logging@^2.0.0:
+abstract-logging@^2.0.0, abstract-logging@^2.0.1:
   version "2.0.1"
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/abstract-logging/-/abstract-logging-2.0.1.tgz#6b0c371df212db7129b57d2e7fcf282b8bf1c839"
   resolved "https://registry.yarnpkg.com/abstract-logging/-/abstract-logging-2.0.1.tgz#6b0c371df212db7129b57d2e7fcf282b8bf1c839"
   integrity sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==
   integrity sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==
@@ -11346,6 +11417,26 @@ ldapjs@^2.2.1:
     vasync "^2.2.0"
     vasync "^2.2.0"
     verror "^1.8.1"
     verror "^1.8.1"
 
 
+ldapjs@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/ldapjs/-/ldapjs-3.0.2.tgz#1f21fd4ae6e77b78ef3b56d0643a656553761f13"
+  integrity sha512-EBxQaBmgXk1DEaYYJWkp5i5PtSLRI2CWtm1gzxG5buOt40Q7j3zY6MbpRDkach/Cnxr3qSyLHiyXvvkLCOXw+Q==
+  dependencies:
+    "@ldapjs/asn1" "2.0.0"
+    "@ldapjs/attribute" "1.0.0"
+    "@ldapjs/change" "1.0.0"
+    "@ldapjs/controls" "2.0.0"
+    "@ldapjs/dn" "1.0.0"
+    "@ldapjs/filter" "2.0.0"
+    "@ldapjs/messages" "1.0.2"
+    "@ldapjs/protocol" "^1.2.1"
+    abstract-logging "^2.0.1"
+    assert-plus "^1.0.0"
+    backoff "^2.5.0"
+    once "^1.4.0"
+    vasync "^2.2.1"
+    verror "^1.10.1"
+
 less@^3.12.2:
 less@^3.12.2:
   version "3.13.1"
   version "3.13.1"
   resolved "https://registry.yarnpkg.com/less/-/less-3.13.1.tgz#0ebc91d2a0e9c0c6735b83d496b0ab0583077909"
   resolved "https://registry.yarnpkg.com/less/-/less-3.13.1.tgz#0ebc91d2a0e9c0c6735b83d496b0ab0583077909"
@@ -14265,6 +14356,11 @@ process-nextick-args@~2.0.0:
   version "2.0.0"
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz#a37d732f4271b4ab1ad070d35508e8290788ffaa"
   resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz#a37d732f4271b4ab1ad070d35508e8290788ffaa"
 
 
+process-warning@^2.1.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-2.2.0.tgz#008ec76b579820a8e5c35d81960525ca64feb626"
+  integrity sha512-/1WZ8+VQjR6avWOgHeEPd7SDQmFQ1B5mC1eRXsCm5TarlNmx/wCsa5GEaxGm05BORRtyG/Ex/3xq3TuRvq57qg==
+
 prom-client@^14.1.1:
 prom-client@^14.1.1:
   version "14.1.1"
   version "14.1.1"
   resolved "https://registry.yarnpkg.com/prom-client/-/prom-client-14.1.1.tgz#e9bebef0e2269bfde22a322f4ca803cb52b4a0c0"
   resolved "https://registry.yarnpkg.com/prom-client/-/prom-client-14.1.1.tgz#e9bebef0e2269bfde22a322f4ca803cb52b4a0c0"
@@ -17776,6 +17872,13 @@ vasync@^2.2.0:
   dependencies:
   dependencies:
     verror "1.10.0"
     verror "1.10.0"
 
 
+vasync@^2.2.1:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/vasync/-/vasync-2.2.1.tgz#d881379ff3685e4affa8e775cf0fd369262a201b"
+  integrity sha512-Hq72JaTpcTFdWiNA4Y22Amej2GH3BFmBaKPPlDZ4/oC8HNn2ISHLkFrJU4Ds8R3jcUi7oo5Y9jcMHKjES+N9wQ==
+  dependencies:
+    verror "1.10.0"
+
 verror@1.10.0, verror@^1.8.1:
 verror@1.10.0, verror@^1.8.1:
   version "1.10.0"
   version "1.10.0"
   resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"
   resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"
@@ -17784,6 +17887,15 @@ verror@1.10.0, verror@^1.8.1:
     core-util-is "1.0.2"
     core-util-is "1.0.2"
     extsprintf "^1.2.0"
     extsprintf "^1.2.0"
 
 
+verror@^1.10.1:
+  version "1.10.1"
+  resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.1.tgz#4bf09eeccf4563b109ed4b3d458380c972b0cdeb"
+  integrity sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==
+  dependencies:
+    assert-plus "^1.0.0"
+    core-util-is "1.0.2"
+    extsprintf "^1.2.0"
+
 vfile-location@^4.0.0:
 vfile-location@^4.0.0:
   version "4.0.1"
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/vfile-location/-/vfile-location-4.0.1.tgz#06f2b9244a3565bef91f099359486a08b10d3a95"
   resolved "https://registry.yarnpkg.com/vfile-location/-/vfile-location-4.0.1.tgz#06f2b9244a3565bef91f099359486a08b10d3a95"