Просмотр исходного кода

refs 124384: implement getMemberUser ffor LdapUserGroupSyncService

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

+ 7 - 3
apps/app/src/components/Admin/UserGroup/ExternalGroup/LdapGroupSyncSettingsForm.tsx

@@ -17,7 +17,7 @@ export const LdapGroupSyncSettingsForm: FC = () => {
   const [formValues, setFormValues] = useState<LdapGroupSyncSettings>({
   const [formValues, setFormValues] = useState<LdapGroupSyncSettings>({
     ldapGroupSearchBase: '',
     ldapGroupSearchBase: '',
     ldapGroupMembershipAttribute: '',
     ldapGroupMembershipAttribute: '',
-    ldapGroupMembershipAttributeType: '',
+    ldapGroupMembershipAttributeType: 'DN',
     ldapGroupChildGroupAttribute: '',
     ldapGroupChildGroupAttribute: '',
     autoGenerateUserOnLdapGroupSync: false,
     autoGenerateUserOnLdapGroupSync: false,
     preserveDeletedLdapGroups: false,
     preserveDeletedLdapGroups: false,
@@ -94,7 +94,11 @@ export const LdapGroupSyncSettingsForm: FC = () => {
             name="ldapGroupMembershipAttributeType"
             name="ldapGroupMembershipAttributeType"
             id="ldapGroupMembershipAttributeType"
             id="ldapGroupMembershipAttributeType"
             value={formValues.ldapGroupMembershipAttributeType}
             value={formValues.ldapGroupMembershipAttributeType}
-            onChange={e => setFormValues({ ...formValues, ldapGroupMembershipAttributeType: e.target.value })}>
+            onChange={(e) => {
+              if (e.target.value === 'DN' || e.target.value === 'UID') {
+                setFormValues({ ...formValues, ldapGroupMembershipAttributeType: e.target.value });
+              }
+            }}>
             <option value="DN">DN</option>
             <option value="DN">DN</option>
             <option value="UID">UID</option>
             <option value="UID">UID</option>
           </select>
           </select>
@@ -208,7 +212,7 @@ export const LdapGroupSyncSettingsForm: FC = () => {
             type="text"
             type="text"
             name="ldapGroupDescriptionAttribute"
             name="ldapGroupDescriptionAttribute"
             id="ldapGroupDescriptionAttribute"
             id="ldapGroupDescriptionAttribute"
-            value={formValues.ldapGroupDescriptionAttribute}
+            value={formValues.ldapGroupDescriptionAttribute || ''}
             onChange={e => setFormValues({ ...formValues, ldapGroupDescriptionAttribute: e.target.value })}
             onChange={e => setFormValues({ ...formValues, ldapGroupDescriptionAttribute: e.target.value })}
           />
           />
           <p className="form-text text-muted">
           <p className="form-text text-muted">

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

@@ -14,10 +14,19 @@ export interface IExternalUserGroupRelation extends Omit<IUserGroupRelation, 're
 export interface LdapGroupSyncSettings {
 export interface LdapGroupSyncSettings {
   ldapGroupSearchBase: string
   ldapGroupSearchBase: string
   ldapGroupMembershipAttribute: string
   ldapGroupMembershipAttribute: string
-  ldapGroupMembershipAttributeType: string
+  ldapGroupMembershipAttributeType: 'DN' | 'UID'
   ldapGroupChildGroupAttribute: string
   ldapGroupChildGroupAttribute: string
   autoGenerateUserOnLdapGroupSync: boolean
   autoGenerateUserOnLdapGroupSync: boolean
   preserveDeletedLdapGroups: boolean
   preserveDeletedLdapGroups: boolean
   ldapGroupNameAttribute: string
   ldapGroupNameAttribute: string
   ldapGroupDescriptionAttribute?: string
   ldapGroupDescriptionAttribute?: string
 }
 }
+
+// interface for objects before they are converted into ExternalUserGroup
+export interface LdapGroup {
+  dn: string
+  users: string[] // DN or UID
+  childGroups: string[] // DN
+  name: string
+  description?: string
+}

+ 4 - 20
apps/app/src/server/routes/apiv3/external-user-group.ts

@@ -3,15 +3,14 @@ import { body, validationResult } from 'express-validator';
 
 
 import Crowi from '~/server/crowi';
 import Crowi from '~/server/crowi';
 import { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
-import LdapService from '~/server/service/ldap';
+import { configManager } from '~/server/service/config-manager';
+import LdapUserGroupSyncService from '~/server/service/external-group/ldap-user-group-sync-service';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:routes:apiv3:external-user-group');
 const logger = loggerFactory('growi:routes:apiv3:external-user-group');
 
 
 const router = Router();
 const router = Router();
 
 
-const ldapService = new LdapService();
-
 interface AuthorizedRequest extends Request {
 interface AuthorizedRequest extends Request {
   user?: any
   user?: any
 }
 }
@@ -34,7 +33,6 @@ module.exports = (crowi: Crowi): Router => {
   };
   };
 
 
   router.get('/ldap/sync-settings', loginRequiredStrictly, adminRequired, validators.ldapSyncSettings, (req: AuthorizedRequest, res: ApiV3Response) => {
   router.get('/ldap/sync-settings', loginRequiredStrictly, adminRequired, validators.ldapSyncSettings, (req: AuthorizedRequest, res: ApiV3Response) => {
-    const { configManager } = crowi;
     const settings = {
     const settings = {
       ldapGroupSearchBase: configManager?.getConfig('crowi', 'external-user-group:ldap:groupSearchBase'),
       ldapGroupSearchBase: configManager?.getConfig('crowi', 'external-user-group:ldap:groupSearchBase'),
       ldapGroupMembershipAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttribute'),
       ldapGroupMembershipAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttribute'),
@@ -72,7 +70,7 @@ module.exports = (crowi: Crowi): Router => {
     }
     }
 
 
     try {
     try {
-      await crowi.configManager?.updateConfigsInTheSameNamespace('crowi', params, true);
+      await configManager.updateConfigsInTheSameNamespace('crowi', params, true);
       return res.apiv3({}, 204);
       return res.apiv3({}, 204);
     }
     }
     catch (err) {
     catch (err) {
@@ -83,21 +81,7 @@ module.exports = (crowi: Crowi): Router => {
 
 
   router.put('/ldap/sync', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
   router.put('/ldap/sync', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
     try {
     try {
-      const isUserBind = crowi.configManager?.getConfig('crowi', 'security:passport-ldap:isUserBind');
-      const groups = async() => {
-        if (isUserBind) {
-          const username = req.user.name;
-          const password = req.body.password;
-          return ldapService.searchGroup(username, password);
-        }
-        return ldapService.searchGroup();
-      };
-
-      // Print searched groups for now
-      // TODO: implement LDAP group sync
-      // see: https://redmine.weseek.co.jp/issues/120030
-      console.log('ldap groups');
-      console.log(await groups());
+      const ldapUserGroupSyncService = new LdapUserGroupSyncService(req.user.name, req.body.password);
     }
     }
     catch (e) {
     catch (e) {
       res.apiv3Err(e, 500);
       res.apiv3Err(e, 500);

+ 8 - 48
apps/app/src/server/routes/login-passport.js

@@ -9,6 +9,8 @@ import { NullUsernameToBeRegisteredError } from '~/server/models/errors';
 import { createRedirectToForUnauthenticated } from '~/server/util/createRedirectToForUnauthenticated';
 import { createRedirectToForUnauthenticated } from '~/server/util/createRedirectToForUnauthenticated';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import ExternalAccountService from '../service/external-account';
+
 
 
 /* eslint-disable no-use-before-define */
 /* eslint-disable no-use-before-define */
 
 
@@ -18,6 +20,7 @@ module.exports = function(crowi, app) {
   const passport = require('passport');
   const passport = require('passport');
   const ExternalAccount = crowi.model('ExternalAccount');
   const ExternalAccount = crowi.model('ExternalAccount');
   const passportService = crowi.passportService;
   const passportService = crowi.passportService;
+  const externalAccountService = new ExternalAccountService(crowi);
 
 
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
 
 
@@ -50,49 +53,6 @@ module.exports = function(crowi, app) {
     });
     });
   };
   };
 
 
-  const getOrCreateUser = async(req, res, userInfo, providerId) => {
-    // get option
-    const isSameUsernameTreatedAsIdenticalUser = crowi.passportService.isSameUsernameTreatedAsIdenticalUser(providerId);
-    const isSameEmailTreatedAsIdenticalUser = crowi.passportService.isSameEmailTreatedAsIdenticalUser(providerId);
-
-    try {
-      // find or register(create) user
-      const externalAccount = await ExternalAccount.findOrRegister(
-        providerId,
-        userInfo.id,
-        userInfo.username,
-        userInfo.name,
-        userInfo.email,
-        isSameUsernameTreatedAsIdenticalUser,
-        isSameEmailTreatedAsIdenticalUser,
-      );
-      return externalAccount;
-    }
-    catch (err) {
-      /* eslint-disable no-else-return */
-      if (err instanceof NullUsernameToBeRegisteredError) {
-        logger.error(err.message);
-        throw new ErrorV3(err.message);
-      }
-      else if (err.name === 'DuplicatedUsernameException') {
-        if (isSameEmailTreatedAsIdenticalUser || isSameUsernameTreatedAsIdenticalUser) {
-          // associate to existing user
-          debug(`ExternalAccount '${userInfo.username}' will be created and bound to the exisiting User account`);
-          return ExternalAccount.associate(providerId, userInfo.id, err.user);
-        }
-        logger.error('provider-DuplicatedUsernameException', providerId);
-
-        throw new ErrorV3('message.provider_duplicated_username_exception', LoginErrorCode.PROVIDER_DUPLICATED_USERNAME_EXCEPTION,
-          undefined, { failedProviderForDuplicatedUsernameException: providerId });
-      }
-      else if (err.name === 'UserUpperLimitException') {
-        logger.error(err.message);
-        throw new ErrorV3(err.message);
-      }
-      /* eslint-enable no-else-return */
-    }
-  };
-
   /**
   /**
    * success handler
    * success handler
    * @param {*} req
    * @param {*} req
@@ -258,7 +218,7 @@ module.exports = function(crowi, app) {
 
 
     let externalAccount;
     let externalAccount;
     try {
     try {
-      externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
+      externalAccount = await externalAccountService.getOrCreateUser(userInfo, providerId);
     }
     }
     catch (error) {
     catch (error) {
       return next(error);
       return next(error);
@@ -432,7 +392,7 @@ module.exports = function(crowi, app) {
       userInfo.username = userInfo.email.slice(0, userInfo.email.indexOf('@'));
       userInfo.username = userInfo.email.slice(0, userInfo.email.indexOf('@'));
     }
     }
 
 
-    const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
+    const externalAccount = await externalAccountService.getOrCreateUser(userInfo, providerId);
     if (!externalAccount) {
     if (!externalAccount) {
       return next(new ExternalAccountLoginError('message.sign_in_failure'));
       return next(new ExternalAccountLoginError('message.sign_in_failure'));
     }
     }
@@ -475,7 +435,7 @@ module.exports = function(crowi, app) {
       name: response.displayName,
       name: response.displayName,
     };
     };
 
 
-    const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
+    const externalAccount = await externalAccountService.getOrCreateUser(userInfo, providerId);
     if (!externalAccount) {
     if (!externalAccount) {
       return next(new ExternalAccountLoginError('message.sign_in_failure'));
       return next(new ExternalAccountLoginError('message.sign_in_failure'));
     }
     }
@@ -525,7 +485,7 @@ module.exports = function(crowi, app) {
     };
     };
     debug('mapping response to userInfo', userInfo, response, attrMapId, attrMapUserName, attrMapMail);
     debug('mapping response to userInfo', userInfo, response, attrMapId, attrMapUserName, attrMapMail);
 
 
-    const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
+    const externalAccount = await externalAccountService.getOrCreateUser(userInfo, providerId);
     if (!externalAccount) {
     if (!externalAccount) {
       return new ExternalAccountLoginError('message.sign_in_failure');
       return new ExternalAccountLoginError('message.sign_in_failure');
     }
     }
@@ -584,7 +544,7 @@ module.exports = function(crowi, app) {
       return next(new ExternalAccountLoginError('Sign in failure due to insufficient privileges.'));
       return next(new ExternalAccountLoginError('Sign in failure due to insufficient privileges.'));
     }
     }
 
 
-    const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
+    const externalAccount = await externalAccountService.getOrCreateUser(userInfo, providerId);
     if (!externalAccount) {
     if (!externalAccount) {
       return next(new ExternalAccountLoginError('message.sign_in_failure'));
       return next(new ExternalAccountLoginError('message.sign_in_failure'));
     }
     }

+ 63 - 0
apps/app/src/server/service/external-account.js

@@ -0,0 +1,63 @@
+import { ErrorV3 } from '^/../../packages/core/dist';
+
+import { LoginErrorCode } from '~/interfaces/errors/login-error';
+import loggerFactory from '~/utils/logger';
+
+import { NullUsernameToBeRegisteredError } from '../models/errors';
+
+const logger = loggerFactory('growi:service:external-account-service');
+
+class ExternalAccountService {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  async getOrCreateUser(userInfo, providerId) {
+    // get option
+    const isSameUsernameTreatedAsIdenticalUser = this.crowi.passportService.isSameUsernameTreatedAsIdenticalUser(providerId);
+    const isSameEmailTreatedAsIdenticalUser = this.crowi.passportService.isSameEmailTreatedAsIdenticalUser(providerId);
+
+    const ExternalAccount = this.crowi.model('ExternalAccount');
+
+    try {
+      // find or register(create) user
+      const externalAccount = await ExternalAccount.findOrRegister(
+        providerId,
+        userInfo.id,
+        userInfo.username,
+        userInfo.name,
+        userInfo.email,
+        isSameUsernameTreatedAsIdenticalUser,
+        isSameEmailTreatedAsIdenticalUser,
+      );
+      return externalAccount;
+    }
+    catch (err) {
+      /* eslint-disable no-else-return */
+      if (err instanceof NullUsernameToBeRegisteredError) {
+        logger.error(err.message);
+        throw new ErrorV3(err.message);
+      }
+      else if (err.name === 'DuplicatedUsernameException') {
+        if (isSameEmailTreatedAsIdenticalUser || isSameUsernameTreatedAsIdenticalUser) {
+          // associate to existing user
+          logger.debug(`ExternalAccount '${userInfo.username}' will be created and bound to the exisiting User account`);
+          return ExternalAccount.associate(providerId, userInfo.id, err.user);
+        }
+        logger.error('provider-DuplicatedUsernameException', providerId);
+
+        throw new ErrorV3('message.provider_duplicated_username_exception', LoginErrorCode.PROVIDER_DUPLICATED_USERNAME_EXCEPTION,
+          undefined, { failedProviderForDuplicatedUsernameException: providerId });
+      }
+      else if (err.name === 'UserUpperLimitException') {
+        logger.error(err.message);
+        throw new ErrorV3(err.message);
+      }
+      /* eslint-enable no-else-return */
+    }
+  }
+
+}
+
+export default ExternalAccountService;

+ 36 - 0
apps/app/src/server/service/external-group/external-user-group-sync-service.ts

@@ -0,0 +1,36 @@
+import { IExternalUserGroup } from '~/interfaces/external-user-group';
+import { IUser } from '~/interfaces/user';
+
+abstract class ExternalUserGroupSyncService {
+
+  // 全グループ同期メソッド
+  /* 継承先の実メソッドイメージ
+     1. 読み込まれたパラメータを元に外部グループを全て取得する
+     2. 各親グループについて、createUpdateExternalUserGroup を呼び出す
+     3. 子についても同様に呼び出し、返却された子グループを親グループと紐付ける
+     4. 2, 3 を再起的に行う
+         - 木探索アルゴリズムはなんでも良いが、実クラスで実装が容易になるように上手く抽象化したい
+     5. 「外部サービスから削除されたグループを GROWI に残すか」が false の場合、木探索の過程で見つからなかった ExternalUserGroup は削除する
+    */
+  abstract syncExternalUserGroups(): void
+
+  // グループ生成/更新メソッド
+  /* 継承先の実メソッドイメージ
+     1. 読み込まれたパラメータを元に外部グループ情報をリクエストする
+     2. 読み込まれたパラメータと 1 で返却された外部グループ情報を元に ExternalUserGroup を生成/更新する
+     3. 外部グループ情報にある各ユーザ情報を元に、ExternalUserGroup に所属していないメンバーについて getMemberUser を呼び出し、返却されたユーザを ExternalUserGroup に所属させる (ExternalUserGroupRelation を生成する)
+     4. ExternalUserGroup を返却する
+    */
+  // abstract createUpdateExternalUserGroup(): IExternalUserGroup
+
+  // ユーザ検索メソッド
+  /* 継承先の実メソッドイメージ
+     1. 読み込まれたパラメータパラメータを元に外部ユーザ情報をリクエストする
+     2. 読み込まれたパラメータと 1 で返却された外部ユーザ情報を元に GROWI User を検索し、返却する
+       - 「作成されていない GROWI アカウントを自動生成するか」が true の場合、検索して見つからなければ生成して返却する
+    */
+  // abstract getMemberUser(): IUser
+
+}
+
+export default ExternalUserGroupSyncService;

+ 162 - 0
apps/app/src/server/service/external-group/ldap-user-group-sync-service.ts

@@ -0,0 +1,162 @@
+import { IExternalUserGroup, LdapGroup } from '~/interfaces/external-user-group';
+import { IUser } from '~/interfaces/user';
+
+import { configManager } from '../config-manager';
+import ExternalAccountService from '../external-account';
+import LdapService, { SearchResultEntry } from '../ldap';
+
+import ExternalUserGroupSyncService from './external-user-group-sync-service';
+
+class LdapUserGroupSyncService {
+
+  ldapGroups: LdapGroup[];
+
+  ldapService: LdapService;
+
+  externalAccountService: ExternalAccountService;
+
+  crowi: any;
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(crowi: any, userBindUsername?: string, userBindPassword?: string) {
+    this.crowi = crowi;
+    this.ldapService = new LdapService(userBindUsername, userBindPassword);
+    this.externalAccountService = new ExternalAccountService(crowi);
+  }
+
+  async fetchLdapGroups(): Promise<void> {
+    const isUserBind = configManager.getConfig('crowi', 'security:passport-ldap:isUserBind');
+    const getGroupDirData = async() => {
+      if (isUserBind) {
+        return this.ldapService.searchGroupDir();
+      }
+      return this.ldapService.searchGroupDir();
+    };
+
+    const groupDirData = await getGroupDirData();
+
+    this.ldapGroups = groupDirData.map(data => this.convertSearchResultEntryToLdapGroup(data))
+      .filter((group): group is NonNullable<LdapGroup> => group != null);
+  }
+
+  // 全グループ同期メソッド
+  syncExternalUserGroups(): void {
+    /*
+     1. ldapGroupSearchBase を使って LDAP から全てのグループを取得する
+         - 設定値を元に LDAPGroup に変換する
+     2. 各親グループについて、createUpdateExternalUserGroup を呼び出す
+     3. 子についても同様に呼び出し、返却された子グループを親グループと紐付ける
+     4. 2, 3 を再起的に行う
+     5. preserveDeletedLDAPGroups が false の場合、木探索の過程で見つからなかった ExternalUserGroup は削除する
+    */
+  }
+
+  // // グループ生成/更新メソッド
+  // createUpdateExternalUserGroup(ldapGroup: LdapGroup): IExternalUserGroup {
+  //   /*
+  //    1. ldapGroup を元に ExternalUserGroup を生成/更新する
+  //    2. ldapGroup.users を元に ExternalUserGroup に所属していないメンバーについて getMemberUser を呼び出し、返却された各ユーザを ExternalUserGroup に所属させる (ExternalUserGroupRelation を生成する)
+  //    4. ExternalUserGroup を返却する
+  //   */
+  // }
+
+  /**
+   * 1. Execute search on LDAP server for user using useridentifier
+   * 2. Search for GROWI user based on LDAP user info, and return
+   *   - if autoGenerateUserOnLDAPGroupSync is true and GROWI user is not found, create new GROWI user
+   * @param {string} userIdentifier Search LDAP server using this identifier (DN or UID)
+   * @returns {Promise<IUser | null>} IUser when found or created, null when neither
+   */
+  async getMemberUser(userIdentifier: string): Promise<IUser | null> {
+    const groupMembershipAttributeType = configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttributeType');
+
+    const getUser = async() => {
+      if (groupMembershipAttributeType === 'DN') {
+        return this.ldapService.search(undefined, userIdentifier, 'one');
+      }
+      if (groupMembershipAttributeType === 'UID') {
+        return this.ldapService.search(`(uid=${userIdentifier})`, undefined, 'one');
+      }
+    };
+
+    const userEntryArr = await getUser();
+
+    if (userEntryArr != null && userEntryArr.length > 0) {
+      const userEntry = userEntryArr[0];
+      const uid = this.getStringValFromSearchResultEntry(userEntry, 'uid');
+      if (uid != null) {
+        const externalAccount = await this.getExternalAccount(uid, userEntry);
+        if (externalAccount != null) {
+          return externalAccount.getPopulatedUser();
+        }
+      }
+    }
+
+    return null;
+  }
+
+  private convertSearchResultEntryToLdapGroup(groupEntry: SearchResultEntry): LdapGroup | null {
+    const groupChildGroupAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupChildGroupAttribute');
+    const groupMembershipAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttribute');
+    const groupNameAttribute = configManager.getConfig('crowi', 'external-user-group:ldap:groupNameAttribute');
+    const groupDescriptionAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupDescriptionAttribute');
+
+    const childGroups = this.getArrayValFromSearchResultEntry(groupEntry, groupChildGroupAttribute);
+    const users = this.getArrayValFromSearchResultEntry(groupEntry, groupMembershipAttribute);
+
+    const name = this.getStringValFromSearchResultEntry(groupEntry, groupNameAttribute);
+    const description = this.getStringValFromSearchResultEntry(groupEntry, groupDescriptionAttribute);
+
+    return name != null ? {
+      dn: groupEntry.objectName || '',
+      childGroups,
+      users,
+      name,
+      description,
+    } : null;
+  }
+
+  private async getExternalAccount(uid: string, userEntry: SearchResultEntry) {
+    const autoGenerateUserOnLDAPGroupSync = configManager?.getConfig('crowi', 'external-user-group:ldap:autoGenerateUserOnGroupSync');
+
+    if (autoGenerateUserOnLDAPGroupSync) {
+      const attrMapUsername = this.crowi.passportService.getLdapAttrNameMappedToUsername();
+      const attrMapName = this.crowi.passportService.getLdapAttrNameMappedToName();
+      const attrMapMail = this.crowi.passportService.getLdapAttrNameMappedToMail();
+      const usernameToBeRegistered = attrMapUsername === 'uid' ? uid : this.getStringValFromSearchResultEntry(userEntry, attrMapUsername);
+      const nameToBeRegistered = this.getStringValFromSearchResultEntry(userEntry, attrMapName);
+      const mailToBeRegistered = this.getStringValFromSearchResultEntry(userEntry, attrMapMail);
+
+      const userInfo = {
+        id: uid,
+        username: usernameToBeRegistered,
+        name: nameToBeRegistered,
+        email: mailToBeRegistered,
+      };
+
+      return this.externalAccountService.getOrCreateUser(userInfo, 'ldap');
+    }
+
+    return this.crowi.models.ExternalAccount
+      .findOne({ providerType: 'ldap', accountId: uid });
+  }
+
+  private getArrayValFromSearchResultEntry(entry: SearchResultEntry, attributeType: string) {
+    const values: string | string[] = entry.attributes.find(attribute => attribute.type === attributeType)?.values || [];
+    return typeof values === 'string' ? [values] : values;
+  }
+
+  private getStringValFromSearchResultEntry(entry: SearchResultEntry, attributeType: string): string | undefined {
+    const values: string | string[] | undefined = entry.attributes.find(attribute => attribute.type === attributeType)?.values;
+    if (typeof values === 'string' || values == null) {
+      return values;
+    }
+    if (values.length > 0) {
+      return values[0];
+    }
+    return undefined;
+  }
+
+}
+
+export default LdapUserGroupSyncService;

+ 28 - 11
apps/app/src/server/service/ldap.ts

@@ -9,20 +9,37 @@ import { configManager } from './config-manager';
 
 
 const logger = loggerFactory('growi:service:ldap-service');
 const logger = loggerFactory('growi:service:ldap-service');
 
 
+// @types/ldapjs is outdated, and SearchResultEntry does not exist.
+// Declare it manually in the meantime.
+export interface SearchResultEntry extends Omit<ldap.SearchEntry, 'attributes'> {
+  attributes: {
+    type: string,
+    values: string | string[]
+  }[]
+}
+
 /**
 /**
  * Service to connect to LDAP server.
  * Service to connect to LDAP server.
  * User auth using LDAP is done with PassportService, not here.
  * User auth using LDAP is done with PassportService, not here.
 */
 */
 class LdapService {
 class LdapService {
 
 
+  username?: string; // Necessary when bind type is user bind
+
+  password?: string; // Necessary when bind type is user bind
+
+  constructor(username?: string, password?: string) {
+    this.username = username;
+    this.password = password;
+  }
+
   /**
   /**
    * Execute search on LDAP server and return result
    * Execute search on LDAP server and return result
-   * @param {string} username Necessary when bind type is user bind
-   * @param {string} password Necessary when bind type is user bind
    * @param {string} filter Search filter
    * @param {string} filter Search filter
    * @param {string} base Base DN to execute search on
    * @param {string} base Base DN to execute search on
+   * @returns {SearchEntry[]} Search result. Default scope is set to 'sub'.
    */
    */
-  search(username?: string, password?: string, filter?: string, base?: string): Promise<ldap.SearchEntry[]> {
+  search(filter?: string, base?: string, scope: 'sub' | 'base' | 'one' = 'sub'): Promise<SearchResultEntry[]> {
     const isLdapEnabled = configManager?.getConfig('crowi', 'security:passport-ldap:isEnabled');
     const isLdapEnabled = configManager?.getConfig('crowi', 'security:passport-ldap:isEnabled');
 
 
     if (!isLdapEnabled) {
     if (!isLdapEnabled) {
@@ -50,9 +67,9 @@ class LdapService {
 
 
     // user bind
     // user bind
     const fixedBindDN = (isUserBind)
     const fixedBindDN = (isUserBind)
-      ? bindDN.replace(/{{username}}/, username)
+      ? bindDN.replace(/{{username}}/, this.username)
       : bindDN;
       : bindDN;
-    const fixedBindCredentials = (isUserBind) ? password : bindCredentials;
+    const fixedBindCredentials = (isUserBind) ? this.password : bindCredentials;
 
 
     const client = ldap.createClient({
     const client = ldap.createClient({
       url,
       url,
@@ -62,18 +79,18 @@ class LdapService {
       assert.ifError(err);
       assert.ifError(err);
     });
     });
 
 
-    const searchResults: ldap.SearchEntry[] = [];
+    const searchResults: SearchResultEntry[] = [];
 
 
     return new Promise((resolve, reject) => {
     return new Promise((resolve, reject) => {
-      client.search(base || searchBase, { scope: 'sub', filter }, (err, res) => {
+      client.search(base || searchBase, { scope, filter }, (err, res) => {
         if (err != null) {
         if (err != null) {
           reject(err);
           reject(err);
         }
         }
 
 
         // @types/ldapjs is outdated, and pojo property (type SearchResultEntry) does not exist.
         // @types/ldapjs is outdated, and pojo property (type SearchResultEntry) does not exist.
-        // Typecast and use SearchEntry in the meantime.
+        // Typecast to manually declared SearchResultEntry in the meantime.
         res.on('searchEntry', (entry: any) => {
         res.on('searchEntry', (entry: any) => {
-          const pojo = entry?.pojo as ldap.SearchEntry;
+          const pojo = entry?.pojo as SearchResultEntry;
           searchResults.push(pojo);
           searchResults.push(pojo);
         });
         });
         res.on('error', (err) => {
         res.on('error', (err) => {
@@ -91,11 +108,11 @@ class LdapService {
     });
     });
   }
   }
 
 
-  searchGroup(username?: string, password?: string): Promise<ldap.SearchEntry[]> {
+  searchGroupDir(): Promise<SearchResultEntry[]> {
     const groupSearchBase = configManager?.getConfig('crowi', 'external-user-group:ldap:groupSearchBase')
     const groupSearchBase = configManager?.getConfig('crowi', 'external-user-group:ldap:groupSearchBase')
     || configManager?.getConfig('crowi', 'security:passport-ldap:groupSearchBase');
     || configManager?.getConfig('crowi', 'security:passport-ldap:groupSearchBase');
 
 
-    return this.search(username, password, undefined, groupSearchBase);
+    return this.search(undefined, groupSearchBase);
   }
   }
 
 
 }
 }