ldap.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  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('crowi', 'security:passport-ldap:serverUrl');
  28. // parse serverUrl
  29. // see: https://regex101.com/r/0tuYBB/1
  30. const match = serverUrl?.match(/(ldaps?:\/\/[^/]+)\/(.*)?/);
  31. if (match == null || match.length < 1) {
  32. const urlInvalidMessage = 'serverUrl is invalid';
  33. logger.error(urlInvalidMessage);
  34. throw new Error(urlInvalidMessage);
  35. }
  36. const url = match[1];
  37. this.searchBase = match[2] || '';
  38. this.client = ldap.createClient({
  39. url,
  40. });
  41. this.bind(userBindUsername, userBindPassword);
  42. }
  43. /**
  44. * Bind to LDAP server.
  45. * This method is declared independently, so multiple operations can be requested to the LDAP server with a single bind.
  46. * @param {string} userBindUsername Necessary when bind type is user bind
  47. * @param {string} userBindPassword Necessary when bind type is user bind
  48. */
  49. bind(userBindUsername?: string, userBindPassword?: string): Promise<void> {
  50. const client = this.client;
  51. if (client == null) throw new Error('LDAP client is not initialized');
  52. const isLdapEnabled = configManager?.getConfig('crowi', 'security:passport-ldap:isEnabled');
  53. if (!isLdapEnabled) {
  54. const notEnabledMessage = 'LDAP is not enabled';
  55. logger.error(notEnabledMessage);
  56. throw new Error(notEnabledMessage);
  57. }
  58. // get configurations
  59. const isUserBind = configManager?.getConfig('crowi', 'security:passport-ldap:isUserBind');
  60. const bindDN = configManager?.getConfig('crowi', 'security:passport-ldap:bindDN');
  61. const bindCredentials = configManager?.getConfig('crowi', 'security:passport-ldap:bindDNPassword');
  62. // user bind
  63. const fixedBindDN = (isUserBind)
  64. ? bindDN.replace(/{{username}}/, userBindUsername)
  65. : bindDN;
  66. const fixedBindCredentials = (isUserBind) ? userBindPassword : bindCredentials;
  67. return new Promise<void>((resolve, reject) => {
  68. client.bind(fixedBindDN, fixedBindCredentials, (err) => {
  69. if (err != null) {
  70. reject(err);
  71. }
  72. resolve();
  73. });
  74. });
  75. }
  76. /**
  77. * Execute search on LDAP server and return result
  78. * Execution of bind() is necessary before search
  79. * @param {string} filter Search filter
  80. * @param {string} base Base DN to execute search on
  81. * @returns {SearchEntry[]} Search result. Default scope is set to 'sub'.
  82. */
  83. search(filter?: string, base?: string, scope: 'sub' | 'base' | 'one' = 'sub'): Promise<SearchResultEntry[]> {
  84. const client = this.client;
  85. if (client == null) throw new Error('LDAP client is not initialized');
  86. const searchResults: SearchResultEntry[] = [];
  87. return new Promise((resolve, reject) => {
  88. // reject on client connection error (occures when not binded or host is not found)
  89. client.on('error', (err) => {
  90. reject(err);
  91. });
  92. client.search(base || this.searchBase, {
  93. scope, filter, paged: true, sizeLimit: 200,
  94. }, (err, res) => {
  95. if (err != null) {
  96. reject(err);
  97. }
  98. // @types/ldapjs is outdated, and pojo property (type SearchResultEntry) does not exist.
  99. // Typecast to manually declared SearchResultEntry in the meantime.
  100. res.on('searchEntry', (entry: any) => {
  101. const pojo = entry?.pojo as SearchResultEntry;
  102. searchResults.push(pojo);
  103. });
  104. res.on('error', (err) => {
  105. if (err instanceof NoSuchObjectError) {
  106. resolve([]);
  107. }
  108. else {
  109. reject(err);
  110. }
  111. });
  112. res.on('end', (result) => {
  113. if (result?.status === 0) {
  114. resolve(searchResults);
  115. }
  116. else {
  117. reject(new Error(`LDAP search failed: status code ${result?.status}`));
  118. }
  119. });
  120. });
  121. });
  122. }
  123. searchGroupDir(): Promise<SearchResultEntry[]> {
  124. return this.search(undefined, this.getGroupSearchBase());
  125. }
  126. getArrayValFromSearchResultEntry(entry: SearchResultEntry, attributeType: string): string[] {
  127. const values: string | string[] = entry.attributes.find(attribute => attribute.type === attributeType)?.values || [];
  128. return typeof values === 'string' ? [values] : values;
  129. }
  130. getStringValFromSearchResultEntry(entry: SearchResultEntry, attributeType: string): string | undefined {
  131. const values: string | string[] | undefined = entry.attributes.find(attribute => attribute.type === attributeType)?.values;
  132. if (typeof values === 'string' || values == null) {
  133. return values;
  134. }
  135. if (values.length > 0) {
  136. return values[0];
  137. }
  138. return undefined;
  139. }
  140. getGroupSearchBase(): string {
  141. return configManager?.getConfig('crowi', 'external-user-group:ldap:groupSearchBase')
  142. || configManager?.getConfig('crowi', 'security:passport-ldap:groupSearchBase');
  143. }
  144. }
  145. // export the singleton instance
  146. export const ldapService = new LdapService();