Parcourir la source

Merge pull request #7816 from weseek/feat/120030-124384-exec-ldap-group-sync

Feat/120030 124384 exec ldap group sync
Futa Arai il y a 2 ans
Parent
commit
55f53ba190
23 fichiers modifiés avec 637 ajouts et 155 suppressions
  1. 6 3
      apps/app/public/static/locales/en_US/admin.json
  2. 6 3
      apps/app/public/static/locales/ja_JP/admin.json
  3. 6 2
      apps/app/public/static/locales/zh_CN/admin.json
  4. 1 1
      apps/app/src/components/Admin/UserGroup/ExternalUserGroup/ExternalUserGroupManagement.tsx
  5. 7 7
      apps/app/src/components/Admin/UserGroup/ExternalUserGroup/LdapGroupManagement.tsx
  6. 32 24
      apps/app/src/components/Admin/UserGroup/ExternalUserGroup/LdapGroupSyncSettingsForm.tsx
  7. 1 1
      apps/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  8. 1 1
      apps/app/src/features/external-user-group/client/stores/external-user-group.ts
  9. 48 0
      apps/app/src/features/external-user-group/interfaces/external-user-group.ts
  10. 31 0
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts
  11. 71 0
      apps/app/src/features/external-user-group/server/models/external-user-group.ts
  12. 11 24
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  13. 123 0
      apps/app/src/features/external-user-group/server/service/external-user-group-sync-service.ts
  14. 136 0
      apps/app/src/features/external-user-group/server/service/ldap-user-group-sync-service.ts
  15. 0 10
      apps/app/src/interfaces/external-user-group.ts
  16. 8 1
      apps/app/src/server/crowi/index.js
  17. 4 0
      apps/app/src/server/models/config.ts
  18. 1 1
      apps/app/src/server/routes/apiv3/index.js
  19. 3 4
      apps/app/src/server/routes/apiv3/user-group.js
  20. 6 51
      apps/app/src/server/routes/login-passport.js
  21. 72 0
      apps/app/src/server/service/external-account.ts
  22. 62 21
      apps/app/src/server/service/ldap.ts
  23. 1 1
      apps/app/src/server/service/passport.ts

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

@@ -1041,10 +1041,11 @@
     "error_generate_growi_archive": "Failed to generate GROWI archive file",
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
   },
-  "external_group": {
+  "external_user_group": {
     "management": "External Group Management",
     "execute_sync": "Execute Sync",
     "sync": "Sync",
+    "invalid_sync_settings": "Invalid sync settings",
     "ldap": {
       "group_sync_settings": "LDAP Group Sync Settings",
       "group_search_base_DN": "Group Search Base DN",
@@ -1061,9 +1062,11 @@
       "description_mapper_detail": "Attribute to map as group description. Description can be edited after sync. However, when a mapper is set, the edited value can possibly be overwritten by the next sync.",
       "updated_group_sync_settings": "Updated LDAP group sync settings",
       "sync_succeeded": "Sync succeeded",
-      "sync_failed": "Sync failed. Check LDAP security settings and group sync settings",
       "password": "Password",
-      "password_detail": "Login password is necessary because Bind type is set to User Bind"
+      "password_detail": "Login password is necessary because Bind type is set to User Bind",
+      "circular_reference": "Sync failed because there is a possible circular reference in your LDAP group tree structure",
+      "group_search_failed": "LDAP group search failed. Please check your LDAP security settings and group sync settings.",
+      "user_search_failed": "LDAP user search failed. Please check your LDAP security settings and group sync settings."
     }
   },
   "toaster": {

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

@@ -1049,10 +1049,11 @@
     "error_generate_growi_archive": "GROWI アーカイブファイルの作成に失敗しました",
     "error_send_growi_archive": "GROWI アーカイブファイルの送信に失敗しました"
   },
-  "external_group": {
+  "external_user_group": {
     "management": "外部グループ管理",
     "execute_sync": "同期実行",
     "sync": "同期",
+    "invalid_sync_settings": "同期設定に誤りがあります",
     "ldap": {
       "group_sync_settings": "LDAP グループ同期設定",
       "group_search_base_DN": "グループ検索ベース DN",
@@ -1069,9 +1070,11 @@
       "description_mapper_detail": "グループの「説明」として読み込む属性。「説明」は同期後に編集可能です。ただし、mapper が設定されている場合、編集内容は再同期によって上書きされます。",
       "updated_group_sync_settings": "LDAP グループ同期設定を更新しました",
       "sync_succeeded": "同期に成功しました",
-      "sync_failed": "同期に失敗しました。LDAP セキュリティ設定や、グループ同期設定が正しいことを確認してください。",
       "password": "パスワード",
-      "password_detail": "認証設定がユーザ Bind のため、ログイン時のパスワードの入力が必要となります"
+      "password_detail": "認証設定がユーザ Bind のため、ログイン時のパスワードの入力が必要となります",
+      "circular_reference": "LDAP グループの木構造に循環参照が行われている可能性があるため、同期に失敗しました",
+      "group_search_failed": "LDAP グループ検索に失敗しました。LDAP セキュリティ設定、グループ同期設定が正しいことを確認してください。",
+      "user_search_failed": "LDAP ユーザ検索に失敗しました。LDAP セキュリティ設定、グループ同期設定が正しいことを確認してください。"
     }
   },
   "toaster": {

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

@@ -1049,10 +1049,11 @@
     "error_generate_growi_archive": "Failed to generate GROWI archive file",
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
   },
-  "external_group": {
+  "external_user_group": {
     "management": "External Group Management",
     "execute_sync": "Execute Sync",
     "sync": "Sync",
+    "invalid_sync_settings": "Invalid sync settings",
     "ldap": {
       "group_sync_settings": "LDAP Group Sync Settings",
       "group_search_base_DN": "Group Search Base DN",
@@ -1071,7 +1072,10 @@
       "sync_succeeded": "Group sync succeeded",
       "sync_failed": "Sync failed. Check LDAP security settings and group sync settings",
       "password": "Password",
-      "password_detail": "Login password is necessary because Bind type is set to User Bind"
+      "password_detail": "Login password is necessary because Bind type is set to User Bind",
+      "circular_reference": "Sync failed because there is a possible circular reference in your LDAP group tree structure",
+      "group_search_failed": "LDAP group search failed. Please check your LDAP security settings and group sync settings.",
+      "user_search_failed": "LDAP user search failed. Please check your LDAP security settings and group sync settings."
     }
   },
   "toaster": {

+ 1 - 1
apps/app/src/components/Admin/UserGroup/ExternalGroup/ExternalGroupManagement.tsx → apps/app/src/components/Admin/UserGroup/ExternalUserGroup/ExternalUserGroupManagement.tsx

@@ -27,7 +27,7 @@ export const ExternalGroupManagement: FC = () => {
   }, []);
 
   return <>
-    <h2 className="border-bottom">{t('external_group.management')}</h2>
+    <h2 className="border-bottom">{t('external_user_group.management')}</h2>
     <CustomNav
       activeTab={activeTab}
       navTabMapping={navTabMapping}

+ 7 - 7
apps/app/src/components/Admin/UserGroup/ExternalGroup/LdapGroupManagement.tsx → apps/app/src/components/Admin/UserGroup/ExternalUserGroup/LdapGroupManagement.tsx

@@ -37,19 +37,19 @@ export const LdapGroupManagement: FC = () => {
       else {
         await apiv3Put('/external-user-groups/ldap/sync');
       }
-      toastSuccess(t('external_group.ldap.sync_succeeded'));
+      toastSuccess(t('external_user_group.ldap.sync_succeeded'));
     }
-    catch (e) {
-      toastError(t('external_group.ldap.sync_failed'));
+    catch (errs) {
+      toastError(t(errs[0]?.message));
     }
   }, [t, isUserBind]);
 
   return <>
     <LdapGroupSyncSettingsForm />
-    <h3 className="border-bottom mb-3">{t('external_group.execute_sync')}</h3>
+    <h3 className="border-bottom mb-3">{t('external_user_group.execute_sync')}</h3>
     <form onSubmit={onSyncBtnClick}>
       {isUserBind && <div className="row form-group">
-        <label htmlFor="ldapGroupSyncPassword" className="text-left text-md-right col-md-3 col-form-label">{t('external_group.ldap.password')}</label>
+        <label htmlFor="ldapGroupSyncPassword" className="text-left text-md-right col-md-3 col-form-label">{t('external_user_group.ldap.password')}</label>
         <div className="col-md-6">
           <input
             className="form-control"
@@ -58,13 +58,13 @@ export const LdapGroupManagement: FC = () => {
             id="ldapGroupSyncPassword"
           />
           <p className="form-text text-muted">
-            <small>{t('external_group.ldap.password_detail')}</small>
+            <small>{t('external_user_group.ldap.password_detail')}</small>
           </p>
         </div>
       </div>}
       <div className="row">
         <div className="col-md-3"></div>
-        <div className="col-md-6"><button className="btn btn-primary" type="submit">{t('external_group.sync')}</button></div>
+        <div className="col-md-6"><button className="btn btn-primary" type="submit">{t('external_user_group.sync')}</button></div>
       </div>
     </form>
   </>;

+ 32 - 24
apps/app/src/components/Admin/UserGroup/ExternalGroup/LdapGroupSyncSettingsForm.tsx → apps/app/src/components/Admin/UserGroup/ExternalUserGroup/LdapGroupSyncSettingsForm.tsx

@@ -6,8 +6,8 @@ import { useTranslation } from 'react-i18next';
 
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import { LdapGroupSyncSettings } from '~/interfaces/external-user-group';
-import { useSWRxLdapGroupSyncSettings } from '~/stores/external-user-group';
+import { useSWRxLdapGroupSyncSettings } from '~/features/external-user-group/client/stores/external-user-group';
+import { LdapGroupMembershipAttributeType, LdapGroupSyncSettings } from '~/features/external-user-group/interfaces/external-user-group';
 
 export const LdapGroupSyncSettingsForm: FC = () => {
   const { t } = useTranslation('admin');
@@ -17,7 +17,7 @@ export const LdapGroupSyncSettingsForm: FC = () => {
   const [formValues, setFormValues] = useState<LdapGroupSyncSettings>({
     ldapGroupSearchBase: '',
     ldapGroupMembershipAttribute: '',
-    ldapGroupMembershipAttributeType: '',
+    ldapGroupMembershipAttributeType: LdapGroupMembershipAttributeType.dn,
     ldapGroupChildGroupAttribute: '',
     autoGenerateUserOnLdapGroupSync: false,
     preserveDeletedLdapGroups: false,
@@ -35,18 +35,22 @@ export const LdapGroupSyncSettingsForm: FC = () => {
     e.preventDefault();
     try {
       await apiv3Put('/external-user-groups/ldap/sync-settings', formValues);
-      toastSuccess(t('external_group.ldap.updated_group_sync_settings'));
+      toastSuccess(t('external_user_group.ldap.updated_group_sync_settings'));
     }
-    catch (err) {
-      toastError(err);
+    catch (errs) {
+      toastError(t(errs[0]?.message));
     }
   }, [formValues, t]);
 
   return <>
-    <h3 className="border-bottom mb-3">{t('external_group.ldap.group_sync_settings')}</h3>
+    <h3 className="border-bottom mb-3">{t('external_user_group.ldap.group_sync_settings')}</h3>
     <form onSubmit={submitHandler}>
       <div className="row form-group">
-        <label htmlFor="ldapGroupSearchBase" 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_user_group.ldap.group_search_base_DN')}
+        </label>
         <div className="col-md-6">
           <input
             className="form-control"
@@ -57,13 +61,13 @@ export const LdapGroupSyncSettingsForm: FC = () => {
             onChange={e => setFormValues({ ...formValues, ldapGroupSearchBase: e.target.value })}
           />
           <p className="form-text text-muted">
-            <small>{t('external_group.ldap.group_search_base_dn_detail')}</small>
+            <small>{t('external_user_group.ldap.group_search_base_dn_detail')}</small>
           </p>
         </div>
       </div>
       <div className="row form-group">
         <label htmlFor="ldapGroupMembershipAttribute" className="text-left text-md-right col-md-3 col-form-label">
-          {t('external_group.ldap.membership_attribute')}
+          {t('external_user_group.ldap.membership_attribute')}
         </label>
         <div className="col-md-6">
           <input
@@ -77,15 +81,15 @@ export const LdapGroupSyncSettingsForm: FC = () => {
           />
           <p className="form-text text-muted">
             <small>
-              {t('external_group.ldap.membership_attribute_detail')} <br />
-            e.g.) <code>member</code>, <code>memberUid</code>
+              {t('external_user_group.ldap.membership_attribute_detail')} <br />
+              e.g.) <code>member</code>, <code>memberUid</code>
             </small>
           </p>
         </div>
       </div>
       <div className="row form-group">
         <label htmlFor="ldapGroupMembershipAttributeType" className="text-left text-md-right col-md-3 col-form-label">
-          {t('external_group.ldap.membership_attribute_type')}
+          {t('external_user_group.ldap.membership_attribute_type')}
         </label>
         <div className="col-md-6">
           <select
@@ -94,20 +98,24 @@ export const LdapGroupSyncSettingsForm: FC = () => {
             name="ldapGroupMembershipAttributeType"
             id="ldapGroupMembershipAttributeType"
             value={formValues.ldapGroupMembershipAttributeType}
-            onChange={e => setFormValues({ ...formValues, ldapGroupMembershipAttributeType: e.target.value })}>
+            onChange={(e) => {
+              if (e.target.value === LdapGroupMembershipAttributeType.dn || e.target.value === LdapGroupMembershipAttributeType.uid) {
+                setFormValues({ ...formValues, ldapGroupMembershipAttributeType: e.target.value });
+              }
+            }}>
             <option value="DN">DN</option>
             <option value="UID">UID</option>
           </select>
           <p className="form-text text-muted">
             <small>
-              {t('external_group.ldap.membership_attribute_type_detail')}
+              {t('external_user_group.ldap.membership_attribute_type_detail')}
             </small>
           </p>
         </div>
       </div>
       <div className="row form-group">
         <label htmlFor="ldapGroupChildGroupAttribute" className="text-left text-md-right col-md-3 col-form-label">
-          {t('external_group.ldap.child_group_attribute')}
+          {t('external_user_group.ldap.child_group_attribute')}
         </label>
         <div className="col-md-6">
           <input
@@ -120,7 +128,7 @@ export const LdapGroupSyncSettingsForm: FC = () => {
             onChange={e => setFormValues({ ...formValues, ldapGroupChildGroupAttribute: e.target.value })}/>
           <p className="form-text text-muted">
             <small>
-              {t('external_group.ldap.child_group_attribute_detail')}<br />
+              {t('external_user_group.ldap.child_group_attribute_detail')}<br />
             e.g.) <code>member</code>
             </small>
           </p>
@@ -130,7 +138,7 @@ export const LdapGroupSyncSettingsForm: FC = () => {
         <label
           className="text-left text-md-right col-md-3 col-form-label"
         >
-          {/* {t('external_group.ldap.auto_generate_user_on_sync')} */}
+          {/* {t('external_user_group.ldap.auto_generate_user_on_sync')} */}
         </label>
         <div className="col-md-6">
           <div className="custom-control custom-checkbox custom-checkbox-info">
@@ -146,7 +154,7 @@ export const LdapGroupSyncSettingsForm: FC = () => {
               className="custom-control-label"
               htmlFor="autoGenerateUserOnLdapGroupSync"
             >
-              {t('external_group.ldap.auto_generate_user_on_sync')}
+              {t('external_user_group.ldap.auto_generate_user_on_sync')}
             </label>
           </div>
         </div>
@@ -155,7 +163,7 @@ export const LdapGroupSyncSettingsForm: FC = () => {
         <label
           className="text-left text-md-right col-md-3 col-form-label"
         >
-          {/* {t('external_group.ldap.preserve_deleted_ldap_groups')} */}
+          {/* {t('external_user_group.ldap.preserve_deleted_ldap_groups')} */}
         </label>
         <div className="col-md-6">
           <div className="custom-control custom-checkbox custom-checkbox-info">
@@ -171,7 +179,7 @@ export const LdapGroupSyncSettingsForm: FC = () => {
               className="custom-control-label"
               htmlFor="preserveDeletedLdapGroups"
             >
-              {t('external_group.ldap.preserve_deleted_ldap_groups')}
+              {t('external_user_group.ldap.preserve_deleted_ldap_groups')}
             </label>
           </div>
         </div>
@@ -193,7 +201,7 @@ export const LdapGroupSyncSettingsForm: FC = () => {
           />
           <p className="form-text text-muted">
             <small>
-              {t('external_group.ldap.name_mapper_detail')}
+              {t('external_user_group.ldap.name_mapper_detail')}
             </small>
           </p>
         </div>
@@ -208,12 +216,12 @@ export const LdapGroupSyncSettingsForm: FC = () => {
             type="text"
             name="ldapGroupDescriptionAttribute"
             id="ldapGroupDescriptionAttribute"
-            value={formValues.ldapGroupDescriptionAttribute}
+            value={formValues.ldapGroupDescriptionAttribute || ''}
             onChange={e => setFormValues({ ...formValues, ldapGroupDescriptionAttribute: e.target.value })}
           />
           <p className="form-text text-muted">
             <small>
-              {t('external_group.ldap.description_mapper_detail')}
+              {t('external_user_group.ldap.description_mapper_detail')}
             </small>
           </p>
         </div>

+ 1 - 1
apps/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -9,7 +9,7 @@ import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 import { useIsAclEnabled } from '~/stores/context';
 import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
 
-import { ExternalGroupManagement } from './ExternalGroup/ExternalGroupManagement';
+import { ExternalGroupManagement } from './ExternalUserGroup/ExternalUserGroupManagement';
 
 const UserGroupDeleteModal = dynamic(() => import('./UserGroupDeleteModal').then(mod => mod.UserGroupDeleteModal), { ssr: false });
 const UserGroupModal = dynamic(() => import('./UserGroupModal').then(mod => mod.UserGroupModal), { ssr: false });

+ 1 - 1
apps/app/src/stores/external-user-group.ts → apps/app/src/features/external-user-group/client/stores/external-user-group.ts

@@ -1,7 +1,7 @@
 import useSWR, { SWRResponse } from 'swr';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
-import { LdapGroupSyncSettings } from '~/interfaces/external-user-group';
+import { LdapGroupSyncSettings } from '~/features/external-user-group/interfaces/external-user-group';
 
 export const useSWRxLdapGroupSyncSettings = (): SWRResponse<LdapGroupSyncSettings, Error> => {
   return useSWR(

+ 48 - 0
apps/app/src/features/external-user-group/interfaces/external-user-group.ts

@@ -0,0 +1,48 @@
+import { HasObjectId, IUserGroupRelation, Ref } from '@growi/core';
+
+import { IUserGroup } from '../../../interfaces/user';
+
+export const ExternalGroupProviderType = { ldap: 'ldap' } as const;
+export type ExternalGroupProviderType = typeof ExternalGroupProviderType[keyof typeof ExternalGroupProviderType];
+
+export interface IExternalUserGroup extends Omit<IUserGroup, 'parent'> {
+  parent: Ref<IExternalUserGroup> | null
+  externalId: string // identifier used in external app/server
+  provider: ExternalGroupProviderType
+}
+
+export type IExternalUserGroupHasId = IExternalUserGroup & HasObjectId;
+
+export interface IExternalUserGroupRelation extends Omit<IUserGroupRelation, 'relatedGroup'> {
+  relatedGroup: Ref<IExternalUserGroup>
+}
+
+export const LdapGroupMembershipAttributeType = { dn: 'DN', uid: 'UID' } as const;
+type LdapGroupMembershipAttributeType = typeof LdapGroupMembershipAttributeType[keyof typeof LdapGroupMembershipAttributeType];
+
+export interface LdapGroupSyncSettings {
+  ldapGroupSearchBase: string
+  ldapGroupMembershipAttribute: string
+  ldapGroupMembershipAttributeType: LdapGroupMembershipAttributeType
+  ldapGroupChildGroupAttribute: string
+  autoGenerateUserOnLdapGroupSync: boolean
+  preserveDeletedLdapGroups: boolean
+  ldapGroupNameAttribute: string
+  ldapGroupDescriptionAttribute?: string
+}
+
+export type ExternalUserInfo = {
+  id: string, // external user id
+  username: string,
+  name?: string,
+  email?: string,
+}
+
+// Data structure to express the tree structure of external groups, before converting to ExternalUserGroup model
+export interface ExternalUserGroupTreeNode {
+  id: string
+  userInfos: ExternalUserInfo[]
+  childGroupNodes: ExternalUserGroupTreeNode[]
+  name: string
+  description?: string
+}

+ 31 - 0
apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts

@@ -0,0 +1,31 @@
+import { Schema, Model, Document } from 'mongoose';
+
+import { getOrCreateModel } from '../../../../server/util/mongoose-utils';
+import { IExternalUserGroupRelation } from '../../interfaces/external-user-group';
+
+
+export interface ExternalUserGroupRelationDocument extends IExternalUserGroupRelation, Document {}
+
+export interface ExternalUserGroupRelationModel extends Model<ExternalUserGroupRelationDocument> {
+  [x:string]: any, // for old methods
+}
+
+const schema = new Schema<ExternalUserGroupRelationDocument, ExternalUserGroupRelationModel>({
+  relatedGroup: { type: Schema.Types.ObjectId, ref: 'ExternalUserGroup', required: true },
+  relatedUser: { type: Schema.Types.ObjectId, ref: 'User', required: true },
+}, {
+  timestamps: { createdAt: true, updatedAt: false },
+});
+
+schema.statics.createRelations = async function(userGroupIds, user) {
+  const documentsToInsert = userGroupIds.map((groupId) => {
+    return {
+      relatedGroup: groupId,
+      relatedUser: user._id,
+    };
+  });
+
+  return this.insertMany(documentsToInsert);
+};
+
+export default getOrCreateModel<ExternalUserGroupRelationDocument, ExternalUserGroupRelationModel>('ExternalUserGroupRelation', schema);

+ 71 - 0
apps/app/src/features/external-user-group/server/models/external-user-group.ts

@@ -0,0 +1,71 @@
+import { Schema, Model, Document } from 'mongoose';
+
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
+
+import { IExternalUserGroup } from '../../interfaces/external-user-group';
+
+
+export interface ExternalUserGroupDocument extends IExternalUserGroup, Document {}
+
+export interface ExternalUserGroupModel extends Model<ExternalUserGroupDocument> {
+  [x:string]: any, // for old methods
+}
+
+const schema = new Schema<ExternalUserGroupDocument, ExternalUserGroupModel>({
+  name: { type: String, required: true, unique: true },
+  parent: { type: Schema.Types.ObjectId, ref: 'ExternalUserGroup', index: true },
+  description: { type: String, default: '' },
+  externalId: { type: String, required: true, unique: true },
+  provider: { type: String, required: true },
+}, {
+  timestamps: true,
+});
+
+/**
+ * Find group that has specified externalId and update, or create one if it doesn't exist.
+ * @param name ExternalUserGroup name
+ * @param name ExternalUserGroup description
+ * @param name ExternalUserGroup externalId
+ * @param name ExternalUserGroup provider
+ * @param name ExternalUserGroup parentId
+ * @returns ExternalUserGroupDocument[]
+ */
+schema.statics.findAndUpdateOrCreateGroup = async function(name, description, externalId, provider, parentId) {
+  // create without parent
+  if (parentId == null) {
+    return this.findOneAndUpdate({ externalId }, { name, description, provider }, { upsert: true, new: true });
+  }
+
+  // create with parent
+  const parent = await this.findOne({ _id: parentId });
+  if (parent == null) {
+    throw Error('Parent does not exist.');
+  }
+  return this.findOneAndUpdate({ externalId }, {
+    name, description, provider, parent,
+  }, { upsert: true, new: true });
+};
+
+/**
+ * Find all ancestor groups starting from the UserGroup of the initial "group".
+ * Set "ancestors" as "[]" if the initial group is unnecessary as result.
+ * @param groups ExternalUserGroupDocument
+ * @param ancestors ExternalUserGroupDocument[]
+ * @returns ExternalUserGroupDocument[]
+ */
+schema.statics.findGroupsWithAncestorsRecursively = async function(group, ancestors = [group]) {
+  if (group == null) {
+    return ancestors;
+  }
+
+  const parent = await this.findOne({ _id: group.parent });
+  if (parent == null) {
+    return ancestors;
+  }
+
+  ancestors.unshift(parent);
+
+  return this.findGroupsWithAncestorsRecursively(parent, ancestors);
+};
+
+export default getOrCreateModel<ExternalUserGroupDocument, ExternalUserGroupModel>('ExternalUserGroup', schema);

+ 11 - 24
apps/app/src/server/routes/apiv3/external-user-group.ts → apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts

@@ -3,22 +3,22 @@ import { body, validationResult } from 'express-validator';
 
 import Crowi from '~/server/crowi';
 import { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
-import LdapService from '~/server/service/ldap';
+import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 
+import LdapUserGroupSyncService from '../../service/ldap-user-group-sync-service';
+
 const logger = loggerFactory('growi:routes:apiv3:external-user-group');
 
 const router = Router();
 
-const ldapService = new LdapService();
-
 interface AuthorizedRequest extends Request {
   user?: any
 }
 
 module.exports = (crowi: Crowi): Router => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
 
   const validators = {
     ldapSyncSettings: [
@@ -34,7 +34,6 @@ module.exports = (crowi: Crowi): Router => {
   };
 
   router.get('/ldap/sync-settings', loginRequiredStrictly, adminRequired, validators.ldapSyncSettings, (req: AuthorizedRequest, res: ApiV3Response) => {
-    const { configManager } = crowi;
     const settings = {
       ldapGroupSearchBase: configManager?.getConfig('crowi', 'external-user-group:ldap:groupSearchBase'),
       ldapGroupMembershipAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttribute'),
@@ -52,7 +51,7 @@ module.exports = (crowi: Crowi): Router => {
   router.put('/ldap/sync-settings', loginRequiredStrictly, adminRequired, validators.ldapSyncSettings, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const errors = validationResult(req);
     if (!errors.isEmpty()) {
-      return res.status(400).json({ error: 'invalid body params' });
+      return res.apiv3Err('external_user_group.invalid_sync_settings', 400);
     }
 
     const params = {
@@ -72,7 +71,7 @@ module.exports = (crowi: Crowi): Router => {
     }
 
     try {
-      await crowi.configManager?.updateConfigsInTheSameNamespace('crowi', params, true);
+      await configManager.updateConfigsInTheSameNamespace('crowi', params, true);
       return res.apiv3({}, 204);
     }
     catch (err) {
@@ -83,24 +82,12 @@ module.exports = (crowi: Crowi): Router => {
 
   router.put('/ldap/sync', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
     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(crowi.passportService, req.user.name, req.body.password);
+      await ldapUserGroupSyncService.syncExternalUserGroups();
     }
-    catch (e) {
-      res.apiv3Err(e, 500);
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(err.message, 500);
     }
 
     return res.apiv3({}, 204);

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

@@ -0,0 +1,123 @@
+import mongoose from 'mongoose';
+
+import { IUserHasId } from '~/interfaces/user';
+import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
+
+import { configManager } from '../../../../server/service/config-manager';
+import { externalAccountService } from '../../../../server/service/external-account';
+import {
+  ExternalGroupProviderType, ExternalUserGroupTreeNode, ExternalUserInfo, IExternalUserGroupHasId,
+} from '../../interfaces/external-user-group';
+import ExternalUserGroup from '../models/external-user-group';
+import ExternalUserGroupRelation from '../models/external-user-group-relation';
+
+abstract class ExternalUserGroupSyncService {
+
+  groupProviderType: ExternalGroupProviderType; // name of external service that contains user group info (e.g: ldap, keycloak)
+
+  authProviderType: string; // auth provider type (e.g: ldap, oidc)
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(groupProviderType: ExternalGroupProviderType, authProviderType: string) {
+    this.groupProviderType = groupProviderType;
+    this.authProviderType = authProviderType;
+  }
+
+  /** External user group tree sync method
+   * 1. Generate external user group tree
+   * 2. Use createUpdateExternalUserGroup on each node in the tree using DFS
+   * 3. If preserveDeletedLDAPGroups is false、delete all ExternalUserGroups that were not found during tree search
+  */
+  async syncExternalUserGroups(): Promise<void> {
+    const trees = await this.generateExternalUserGroupTrees();
+
+    const existingExternalUserGroupIds: string[] = [];
+
+    const syncNode = async(node: ExternalUserGroupTreeNode, parentId?: string) => {
+      const externalUserGroup = await this.createUpdateExternalUserGroup(node, parentId);
+      existingExternalUserGroupIds.push(externalUserGroup._id);
+      node.childGroupNodes.forEach((childNode) => {
+        syncNode(childNode, externalUserGroup._id);
+      });
+    };
+
+    await Promise.all(trees.map((root) => {
+      return syncNode(root);
+    }));
+
+    const preserveDeletedLdapGroups: boolean = configManager?.getConfig('crowi', `external-user-group:${this.groupProviderType}:preserveDeletedGroups`);
+    if (!preserveDeletedLdapGroups) {
+      await ExternalUserGroup.deleteMany({ _id: { $nin: existingExternalUserGroupIds }, groupProviderType: this.groupProviderType });
+    }
+  }
+
+  /** External user group node sync method
+   * 1. Create/Update ExternalUserGroup from using information of ExternalUserGroupTreeNode
+   * 2. For every element in node.userInfos, call getMemberUser and create an ExternalUserGroupRelation with ExternalUserGroup if it does not have one
+   * 3. Retrun ExternalUserGroup
+   * @param {string} node Node of external group tree
+   * @param {string} parentId Parent group id (id in GROWI) of the group we wan't to create/update
+   * @returns {Promise<IExternalUserGroupHasId>} ExternalUserGroup that was created/updated
+  */
+  async createUpdateExternalUserGroup(node: ExternalUserGroupTreeNode, parentId?: string): Promise<IExternalUserGroupHasId> {
+    const externalUserGroup = await ExternalUserGroup.findAndUpdateOrCreateGroup(
+      node.name, node.description, node.id, this.groupProviderType, parentId,
+    );
+    await Promise.all(node.userInfos.map((userInfo) => {
+      return (async() => {
+        const user = await this.getMemberUser(userInfo);
+
+        if (user != null) {
+          const userGroups = await ExternalUserGroup.findGroupsWithAncestorsRecursively(externalUserGroup);
+          const userGroupIds = userGroups.map(g => g._id);
+
+          // remove existing relations from list to create
+          const existingRelations = await ExternalUserGroupRelation.find({ relatedGroup: { $in: userGroupIds }, relatedUser: user._id });
+          const existingGroupIds = existingRelations.map(r => r.relatedGroup);
+          const groupIdsToCreateRelation = excludeTestIdsFromTargetIds(userGroupIds, existingGroupIds);
+
+          await ExternalUserGroupRelation.createRelations(groupIdsToCreateRelation, user);
+        }
+      })();
+    }));
+
+    return externalUserGroup;
+  }
+
+  /** Method to get group member GROWI user
+   * 1. Search for GROWI user based on user info of 1, and return user
+   * 2. If autoGenerateUserOnHogeGroupSync is true and GROWI user is not found, create new GROWI user
+   * @param {ExternalUserInfo} externalUserInfo Search external app/server using this identifier
+   * @returns {Promise<IUserHasId | null>} User when found or created, null when neither
+   */
+  async getMemberUser(userInfo: ExternalUserInfo): Promise<IUserHasId | null> {
+    const autoGenerateUserOnGroupSync = configManager?.getConfig('crowi', `external-user-group:${this.groupProviderType}:autoGenerateUserOnGroupSync`);
+    const ExternalAccount = mongoose.model('ExternalAccount');
+
+    const getExternalAccount = async() => {
+      if (autoGenerateUserOnGroupSync && externalAccountService != null) {
+        return externalAccountService.getOrCreateUser({
+          id: userInfo.id, username: userInfo.username, name: userInfo.name, email: userInfo.email,
+        }, this.authProviderType);
+      }
+      return ExternalAccount.findOne({ providerType: this.groupProviderType, accountId: userInfo.id });
+    };
+
+    const externalAccount = await getExternalAccount();
+
+    if (externalAccount != null) {
+      return externalAccount.getPopulatedUser();
+    }
+    return null;
+  }
+
+  /** Method to generate external group tree structure
+   * 1. Fetch user group info from external app/server
+   * 2. Convert each group tree structure to ExternalUserGroupTreeNode
+   * 3. Return the root node of each tree
+  */
+  abstract generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]>
+
+}
+
+export default ExternalUserGroupSyncService;

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

@@ -0,0 +1,136 @@
+import { configManager } from '~/server/service/config-manager';
+import LdapService, { SearchResultEntry } from '~/server/service/ldap';
+import PassportService from '~/server/service/passport';
+
+import {
+  ExternalGroupProviderType, ExternalUserGroupTreeNode, ExternalUserInfo, LdapGroupMembershipAttributeType,
+} from '../../interfaces/external-user-group';
+
+import ExternalUserGroupSyncService from './external-user-group-sync-service';
+
+class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
+
+  passportService: PassportService;
+
+  ldapService: LdapService;
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(passportService, userBindUsername?: string, userBindPassword?: string) {
+    super(ExternalGroupProviderType.ldap, 'ldap');
+    this.passportService = passportService;
+    this.ldapService = new LdapService(userBindUsername, userBindPassword);
+  }
+
+  async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
+    const groupChildGroupAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupChildGroupAttribute');
+    const groupMembershipAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttribute');
+    const groupNameAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupNameAttribute');
+    const groupDescriptionAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupDescriptionAttribute');
+    const groupBase: string = this.ldapService.getGroupSearchBase();
+
+    let groupEntries: SearchResultEntry[];
+    try {
+      groupEntries = await this.ldapService.searchGroupDir();
+    }
+    catch (e) {
+      throw Error('external_user_group.ldap.group_search_failed');
+    }
+
+    const getChildGroupDnsFromGroupEntry = (groupEntry: SearchResultEntry) => {
+      // groupChildGroupAttribute and groupMembershipAttribute may be the same,
+      // so filter values of groupChildGroupAttribute to ones that include groupBase
+      return this.ldapService.getArrayValFromSearchResultEntry(groupEntry, groupChildGroupAttribute).filter(attr => attr.includes(groupBase));
+    };
+    const getUserIdsFromGroupEntry = (groupEntry: SearchResultEntry) => {
+      // groupChildGroupAttribute and groupMembershipAttribute may be the same,
+      // so filter values of groupMembershipAttribute to ones that does not include groupBase
+      return this.ldapService.getArrayValFromSearchResultEntry(groupEntry, groupMembershipAttribute).filter(attr => !attr.includes(groupBase));
+    };
+
+    const convert = async(entry: SearchResultEntry, converted: string[]): Promise<ExternalUserGroupTreeNode | null> => {
+      if (converted.includes(entry.objectName)) {
+        throw Error('external_user_group.ldap.circular_reference');
+      }
+      converted.push(entry.objectName);
+
+      const userIds = getUserIdsFromGroupEntry(entry);
+      const userInfos = (await Promise.all(userIds.map((id) => {
+        return this.getUserInfo(id);
+      }))).filter((info): info is NonNullable<ExternalUserInfo> => info != null);
+      const name = this.ldapService.getStringValFromSearchResultEntry(entry, groupNameAttribute);
+      const description = this.ldapService.getStringValFromSearchResultEntry(entry, groupDescriptionAttribute);
+      const childGroupDNs = getChildGroupDnsFromGroupEntry(entry);
+
+      const childGroupNodes: ExternalUserGroupTreeNode[] = (await Promise.all(childGroupDNs.map((dn) => {
+        const childEntry = groupEntries.find(ge => ge.objectName === dn);
+        return childEntry != null ? convert(childEntry, converted) : null;
+      }))).filter((node): node is NonNullable<ExternalUserGroupTreeNode> => node != null);
+
+      return name != null ? {
+        id: entry.objectName,
+        userInfos,
+        childGroupNodes,
+        name,
+        description,
+      } : null;
+    };
+
+    // all the DNs of groups that are not a root of a tree
+    const allChildGroupDNs = new Set(groupEntries.flatMap((entry) => {
+      return getChildGroupDnsFromGroupEntry(entry);
+    }));
+
+    // root of every tree
+    const rootEntries = groupEntries.filter((entry) => {
+      return !allChildGroupDNs.has(entry.objectName);
+    });
+
+    return (await Promise.all(rootEntries.map(entry => convert(entry, [])))).filter((node): node is NonNullable<ExternalUserGroupTreeNode> => node != null);
+  }
+
+  private async getUserInfo(userId: string): Promise<ExternalUserInfo | null> {
+    const groupMembershipAttributeType = configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttributeType');
+    const attrMapUsername = this.passportService.getLdapAttrNameMappedToUsername();
+    const attrMapName = this.passportService.getLdapAttrNameMappedToName();
+    const attrMapMail = this.passportService.getLdapAttrNameMappedToMail();
+
+    // get full user info from LDAP server using externalUserInfo (DN or UID)
+    const getUserEntries = async() => {
+      if (groupMembershipAttributeType === LdapGroupMembershipAttributeType.dn) {
+        return this.ldapService.search(undefined, userId, 'base');
+      }
+      if (groupMembershipAttributeType === LdapGroupMembershipAttributeType.uid) {
+        return this.ldapService.search(`(uid=${userId})`, undefined);
+      }
+    };
+
+    let userEntries: SearchResultEntry[] | undefined;
+    try {
+      userEntries = await getUserEntries();
+    }
+    catch (e) {
+      throw Error('external_user_group.ldap.user_search_failed');
+    }
+
+    if (userEntries != null && userEntries.length > 0) {
+      const userEntry = userEntries[0];
+      const uid = this.ldapService.getStringValFromSearchResultEntry(userEntry, 'uid');
+      if (uid != null) {
+        const usernameToBeRegistered = attrMapUsername === 'uid' ? uid : this.ldapService.getStringValFromSearchResultEntry(userEntry, attrMapUsername);
+        const nameToBeRegistered = this.ldapService.getStringValFromSearchResultEntry(userEntry, attrMapName);
+        const mailToBeRegistered = this.ldapService.getStringValFromSearchResultEntry(userEntry, attrMapMail);
+
+        return usernameToBeRegistered != null ? {
+          id: uid,
+          username: usernameToBeRegistered,
+          name: nameToBeRegistered,
+          email: mailToBeRegistered,
+        } : null;
+      }
+    }
+    return null;
+  }
+
+}
+
+export default LdapUserGroupSyncService;

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

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

+ 8 - 1
apps/app/src/server/crowi/index.js

@@ -26,11 +26,13 @@ import { aclService as aclServiceSingletonInstance } from '../service/acl';
 import AppService from '../service/app';
 import AttachmentService from '../service/attachment';
 import { configManager as configManagerSingletonInstance } from '../service/config-manager';
+import { instanciate as instanciateExternalAccountService } from '../service/external-account';
 import { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
 import { InstallerService } from '../service/installer';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
 import PageOperationService from '../service/page-operation';
+import PassportService from '../service/passport';
 import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
@@ -150,6 +152,7 @@ Crowi.prototype.init = async function() {
     this.setupSyncPageStatusService(),
     this.setupQuestionnaireService(),
     this.setUpCustomize(), // depends on pluginService
+    this.setupExternalAccountService(),
   ]);
 
   // globalNotification depends on slack and mailer
@@ -359,7 +362,6 @@ Crowi.prototype.setupPassport = async function() {
   logger.debug('Passport is enabled');
 
   // initialize service
-  const PassportService = require('../service/passport');
   if (this.passportService == null) {
     this.passportService = new PassportService(this);
   }
@@ -780,4 +782,9 @@ Crowi.prototype.setupG2GTransferService = async function() {
   }
 };
 
+// execute after setupPassport
+Crowi.prototype.setupExternalAccountService = function() {
+  instanciateExternalAccountService(this.passportService);
+};
+
 export default Crowi;

+ 4 - 0
apps/app/src/server/models/config.ts

@@ -137,6 +137,10 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'importer:esa:access_token': undefined,
   'importer:qiita:team_name': undefined,
   'importer:qiita:access_token': undefined,
+
+  'external-user-group:ldap:groupMembershipAttributeType': 'DN',
+  'external-user-group:ldap:autoGenerateUserOnGroupSync': false,
+  'external-user-group:ldap:preserveDeletedGroups': false,
   /* eslint-enable key-spacing */
 };
 

+ 1 - 1
apps/app/src/server/routes/apiv3/index.js

@@ -35,7 +35,7 @@ module.exports = (crowi, app) => {
   routerForAdmin.use('/notification-setting', require('./notification-setting')(crowi));
   routerForAdmin.use('/users', require('./users')(crowi));
   routerForAdmin.use('/user-groups', require('./user-group')(crowi));
-  routerForAdmin.use('/external-user-groups', require('./external-user-group')(crowi));
+  routerForAdmin.use('/external-user-groups', require('~/features/external-user-group/server/routes/apiv3/external-user-group')(crowi));
   routerForAdmin.use('/export', require('./export')(crowi));
   routerForAdmin.use('/import', importRoute(crowi));
   routerForAdmin.use('/search', require('./search')(crowi));

+ 3 - 4
apps/app/src/server/routes/apiv3/user-group.js

@@ -657,13 +657,12 @@ module.exports = (crowi) => {
       const userGroups = await UserGroup.findGroupsWithAncestorsRecursively(userGroup);
       const userGroupIds = userGroups.map(g => g._id);
 
-      // check for duplicate users in groups
+      // remove existing relations from list to create
       const existingRelations = await UserGroupRelation.find({ relatedGroup: { $in: userGroupIds }, relatedUser: user._id });
       const existingGroupIds = existingRelations.map(r => r.relatedGroup);
+      const groupIdsToCreateRelation = excludeTestIdsFromTargetIds(userGroupIds, existingGroupIds);
 
-      const groupIdsOfRelationToCreate = excludeTestIdsFromTargetIds(userGroupIds, existingGroupIds);
-
-      const insertedRelations = await UserGroupRelation.createRelations(groupIdsOfRelationToCreate, user);
+      const insertedRelations = await UserGroupRelation.createRelations(groupIdsToCreateRelation, user);
       const serializedUser = serializeUserSecurely(user);
 
       const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_ADD_USER };

+ 6 - 51
apps/app/src/server/routes/login-passport.js

@@ -3,12 +3,11 @@ import { ErrorV3 } from '@growi/core';
 import next from 'next';
 
 import { SupportedAction } from '~/interfaces/activity';
-import { LoginErrorCode } from '~/interfaces/errors/login-error';
 import { ExternalAccountLoginError } from '~/models/vo/external-account-login-error';
-import { NullUsernameToBeRegisteredError } from '~/server/models/errors';
 import { createRedirectToForUnauthenticated } from '~/server/util/createRedirectToForUnauthenticated';
 import loggerFactory from '~/utils/logger';
 
+import { externalAccountService } from '../service/external-account';
 
 /* eslint-disable no-use-before-define */
 
@@ -16,7 +15,6 @@ module.exports = function(crowi, app) {
   const debug = require('debug')('growi:routes:login-passport');
   const logger = loggerFactory('growi:routes:login-passport');
   const passport = require('passport');
-  const ExternalAccount = crowi.model('ExternalAccount');
   const passportService = crowi.passportService;
 
   const activityEvent = crowi.event('activity');
@@ -50,49 +48,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
    * @param {*} req
@@ -258,7 +213,7 @@ module.exports = function(crowi, app) {
 
     let externalAccount;
     try {
-      externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
+      externalAccount = await externalAccountService.getOrCreateUser(userInfo, providerId);
     }
     catch (error) {
       return next(error);
@@ -432,7 +387,7 @@ module.exports = function(crowi, app) {
       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) {
       return next(new ExternalAccountLoginError('message.sign_in_failure'));
     }
@@ -475,7 +430,7 @@ module.exports = function(crowi, app) {
       name: response.displayName,
     };
 
-    const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
+    const externalAccount = await externalAccountService.getOrCreateUser(userInfo, providerId);
     if (!externalAccount) {
       return next(new ExternalAccountLoginError('message.sign_in_failure'));
     }
@@ -525,7 +480,7 @@ module.exports = function(crowi, app) {
     };
     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) {
       return new ExternalAccountLoginError('message.sign_in_failure');
     }
@@ -584,7 +539,7 @@ module.exports = function(crowi, app) {
       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) {
       return next(new ExternalAccountLoginError('message.sign_in_failure'));
     }

+ 72 - 0
apps/app/src/server/service/external-account.ts

@@ -0,0 +1,72 @@
+import { ErrorV3 } from '^/../../packages/core/dist';
+import mongoose from 'mongoose';
+
+import { LoginErrorCode } from '~/interfaces/errors/login-error';
+import { IExternalAccount } from '~/interfaces/external-account';
+import loggerFactory from '~/utils/logger';
+
+import { NullUsernameToBeRegisteredError } from '../models/errors';
+
+import PassportService from './passport';
+
+const logger = loggerFactory('growi:service:external-account-service');
+
+class ExternalAccountService {
+
+  passportService: PassportService;
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(passportService: PassportService) {
+    this.passportService = passportService;
+  }
+
+  async getOrCreateUser(userInfo: {id: string, username: string, name?: string, email?: string}, providerId: string): Promise<IExternalAccount | undefined> {
+    // get option
+    const isSameUsernameTreatedAsIdenticalUser = this.passportService.isSameUsernameTreatedAsIdenticalUser(providerId);
+    const isSameEmailTreatedAsIdenticalUser = this.passportService.isSameEmailTreatedAsIdenticalUser(providerId);
+
+    const ExternalAccount = mongoose.model('ExternalAccount') as any;
+
+    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) {
+      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-disable-next-line import/no-mutable-exports
+export let externalAccountService: ExternalAccountService | undefined; // singleton instance
+export function instanciate(passportService: PassportService): void {
+  externalAccountService = new ExternalAccountService(passportService);
+}

+ 62 - 21
apps/app/src/server/service/ldap.ts

@@ -1,6 +1,4 @@
-import assert from 'assert';
-
-import ldap from 'ldapjs';
+import ldap, { NoSuchObjectError } from 'ldapjs';
 
 import loggerFactory from '~/utils/logger';
 
@@ -9,20 +7,38 @@ import { configManager } from './config-manager';
 
 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 {
+  objectName: string // DN
+  attributes: {
+    type: string,
+    values: string | string[]
+  }[]
+}
+
 /**
  * Service to connect to LDAP server.
  * User auth using LDAP is done with PassportService, not here.
 */
 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
-   * @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} 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');
 
     if (!isLdapEnabled) {
@@ -50,34 +66,41 @@ class LdapService {
 
     // user bind
     const fixedBindDN = (isUserBind)
-      ? bindDN.replace(/{{username}}/, username)
+      ? bindDN.replace(/{{username}}/, this.username)
       : bindDN;
-    const fixedBindCredentials = (isUserBind) ? password : bindCredentials;
+    const fixedBindCredentials = (isUserBind) ? this.password : bindCredentials;
 
     const client = ldap.createClient({
       url,
     });
 
-    client.bind(fixedBindDN, fixedBindCredentials, (err) => {
-      assert.ifError(err);
-    });
-
-    const searchResults: ldap.SearchEntry[] = [];
+    const searchResults: SearchResultEntry[] = [];
 
     return new Promise((resolve, reject) => {
-      client.search(base || searchBase, { scope: 'sub', filter }, (err, res) => {
+      client.bind(fixedBindDN, fixedBindCredentials, (err) => {
+        if (err != null) {
+          reject(err);
+        }
+      });
+
+      client.search(base || searchBase, { scope, filter }, (err, res) => {
         if (err != null) {
           reject(err);
         }
 
         // @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) => {
-          const pojo = entry?.pojo as ldap.SearchEntry;
+          const pojo = entry?.pojo as SearchResultEntry;
           searchResults.push(pojo);
         });
         res.on('error', (err) => {
-          reject(err);
+          if (err instanceof NoSuchObjectError) {
+            resolve([]);
+          }
+          else {
+            reject(err);
+          }
         });
         res.on('end', (result) => {
           if (result?.status === 0) {
@@ -91,11 +114,29 @@ class LdapService {
     });
   }
 
-  searchGroup(username?: string, password?: string): Promise<ldap.SearchEntry[]> {
-    const groupSearchBase = configManager?.getConfig('crowi', 'external-user-group:ldap:groupSearchBase')
-    || configManager?.getConfig('crowi', 'security:passport-ldap:groupSearchBase');
+  searchGroupDir(): Promise<SearchResultEntry[]> {
+    return this.search(undefined, this.getGroupSearchBase());
+  }
 
-    return this.search(username, password, undefined, groupSearchBase);
+  getArrayValFromSearchResultEntry(entry: SearchResultEntry, attributeType: string): string[] {
+    const values: string | string[] = entry.attributes.find(attribute => attribute.type === attributeType)?.values || [];
+    return typeof values === 'string' ? [values] : values;
+  }
+
+  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;
+  }
+
+  getGroupSearchBase(): string {
+    return configManager?.getConfig('crowi', 'external-user-group:ldap:groupSearchBase')
+    || configManager?.getConfig('crowi', 'security:passport-ldap:groupSearchBase');
   }
 
 }

+ 1 - 1
apps/app/src/server/service/passport.ts

@@ -984,4 +984,4 @@ class PassportService implements S2sMessageHandlable {
 
 }
 
-module.exports = PassportService;
+export default PassportService;