ldap.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. import ldap, { NoSuchObjectError } from 'ldapjs';
  2. import loggerFactory from '~/utils/logger';
  3. import { configManager } from './config-manager';
  4. const logger = loggerFactory('growi:service:ldap-service');
  5. // @types/ldapjs is outdated, and SearchResultEntry does not exist.
  6. // Declare it manually in the meantime.
  7. export interface SearchResultEntry {
  8. objectName: string; // DN
  9. attributes: {
  10. type: string;
  11. values: string | string[];
  12. }[];
  13. }
  14. /**
  15. * Service to connect to LDAP server.
  16. * User auth using LDAP is done with PassportService, not here.
  17. */
  18. class LdapService {
  19. client: ldap.Client | null;
  20. searchBase: string;
  21. /**
  22. * Initialize LDAP client and bind.
  23. * @param {string} userBindUsername Necessary when bind type is user bind
  24. * @param {string} userBindPassword Necessary when bind type is user bind
  25. */
  26. initClient(userBindUsername?: string, userBindPassword?: string): void {
  27. const serverUrl = configManager.getConfig(
  28. 'security:passport-ldap:serverUrl',
  29. );
  30. // parse serverUrl
  31. // see: https://regex101.com/r/0tuYBB/1
  32. const match = serverUrl?.match(/(ldaps?:\/\/[^/]+)\/(.*)?/);
  33. if (match == null || match.length < 1) {
  34. const urlInvalidMessage = 'serverUrl is invalid';
  35. logger.error(urlInvalidMessage);
  36. throw new Error(urlInvalidMessage);
  37. }
  38. const url = match[1];
  39. this.searchBase = match[2] || '';
  40. this.client = ldap.createClient({
  41. url,
  42. });
  43. this.bind(userBindUsername, userBindPassword);
  44. }
  45. /**
  46. * Bind to LDAP server.
  47. * This method is declared independently, so multiple operations can be requested to the LDAP server with a single bind.
  48. * @param {string} userBindUsername Necessary when bind type is user bind
  49. * @param {string} userBindPassword Necessary when bind type is user bind
  50. */
  51. bind(userBindUsername = '', userBindPassword = ''): Promise<void> {
  52. const client = this.client;
  53. if (client == null) throw new Error('LDAP client is not initialized');
  54. const isLdapEnabled = configManager.getConfig(
  55. 'security:passport-ldap:isEnabled',
  56. );
  57. if (!isLdapEnabled) {
  58. const notEnabledMessage = 'LDAP is not enabled';
  59. logger.error(notEnabledMessage);
  60. throw new Error(notEnabledMessage);
  61. }
  62. // get configurations
  63. const isUserBind = configManager.getConfig(
  64. 'security:passport-ldap:isUserBind',
  65. );
  66. const bindDN =
  67. configManager.getConfig('security:passport-ldap:bindDN') ?? '';
  68. const bindCredentials =
  69. configManager.getConfig('security:passport-ldap:bindDNPassword') ?? '';
  70. // user bind
  71. const fixedBindDN = isUserBind
  72. ? bindDN.replace(/{{username}}/, userBindUsername)
  73. : bindDN;
  74. const fixedBindCredentials = isUserBind
  75. ? userBindPassword
  76. : bindCredentials;
  77. return new Promise<void>((resolve, reject) => {
  78. client.bind(fixedBindDN, fixedBindCredentials, (err) => {
  79. if (err != null) {
  80. reject(err);
  81. }
  82. resolve();
  83. });
  84. });
  85. }
  86. /**
  87. * Execute search on LDAP server and return result
  88. * Execution of bind() is necessary before search
  89. * @param {string} filter Search filter
  90. * @param {string} base Base DN to execute search on
  91. * @returns {SearchEntry[]} Search result. Default scope is set to 'sub'.
  92. */
  93. search(
  94. filter?: string,
  95. base?: string,
  96. scope: 'sub' | 'base' | 'one' = 'sub',
  97. ): Promise<SearchResultEntry[]> {
  98. const client = this.client;
  99. if (client == null) throw new Error('LDAP client is not initialized');
  100. const searchResults: SearchResultEntry[] = [];
  101. return new Promise((resolve, reject) => {
  102. // reject on client connection error (occures when not binded or host is not found)
  103. client.on('error', (err) => {
  104. reject(err);
  105. });
  106. client.search(
  107. base || this.searchBase,
  108. {
  109. scope,
  110. filter,
  111. paged: true,
  112. sizeLimit: 200,
  113. },
  114. (err, res) => {
  115. if (err != null) {
  116. reject(err);
  117. }
  118. // @types/ldapjs is outdated, and pojo property (type SearchResultEntry) does not exist.
  119. // Typecast to manually declared SearchResultEntry in the meantime.
  120. res.on('searchEntry', (entry: any) => {
  121. const pojo = entry?.pojo as SearchResultEntry;
  122. searchResults.push(pojo);
  123. });
  124. res.on('error', (err) => {
  125. if (err instanceof NoSuchObjectError) {
  126. resolve([]);
  127. } else {
  128. reject(err);
  129. }
  130. });
  131. res.on('end', (result) => {
  132. if (result?.status === 0) {
  133. resolve(searchResults);
  134. } else {
  135. reject(
  136. new Error(`LDAP search failed: status code ${result?.status}`),
  137. );
  138. }
  139. });
  140. },
  141. );
  142. });
  143. }
  144. searchGroupDir(): Promise<SearchResultEntry[]> {
  145. return this.search(undefined, this.getGroupSearchBase());
  146. }
  147. getArrayValFromSearchResultEntry(
  148. entry: SearchResultEntry,
  149. attributeType: string | undefined,
  150. ): string[] {
  151. const values: string | string[] =
  152. entry.attributes.find((attribute) => attribute.type === attributeType)
  153. ?.values || [];
  154. return typeof values === 'string' ? [values] : values;
  155. }
  156. getStringValFromSearchResultEntry(
  157. entry: SearchResultEntry,
  158. attributeType: string | undefined,
  159. ): string | undefined {
  160. const values: string | string[] | undefined = entry.attributes.find(
  161. (attribute) => attribute.type === attributeType,
  162. )?.values;
  163. if (typeof values === 'string' || values == null) {
  164. return values;
  165. }
  166. if (values.length > 0) {
  167. return values[0];
  168. }
  169. return undefined;
  170. }
  171. getGroupSearchBase(): string {
  172. return (
  173. configManager.getConfig('external-user-group:ldap:groupSearchBase') ??
  174. configManager.getConfig('security:passport-ldap:groupSearchBase') ??
  175. ''
  176. );
  177. }
  178. }
  179. // export the singleton instance
  180. export const ldapService = new LdapService();