├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .nvmrc ├── .prettierrc ├── .vscode ├── nestjs-ldap.code-workspace └── settings.json ├── LICENSE ├── README.md ├── __mocks__ └── fileMock.js ├── examples └── ldap.module.ts ├── index.d.ts ├── index.js ├── index.ts ├── jest.config.js ├── jest.setup.ts ├── lib ├── index.ts ├── ldap.class.ts ├── ldap.interface.ts ├── ldap.module.ts ├── ldap.service.spec.ts ├── ldap.service.ts └── ldap │ ├── attribute.ts │ ├── change.ts │ └── protocol.ts ├── package.json ├── renovate.json ├── tsconfig.jest.json ├── tsconfig.json ├── typings └── global.d.ts └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | .git 2 | .local 3 | .vscode 4 | dist 5 | secure 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # Diagnostic reports (https://nodejs.org/api/report.html) 16 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | lib-cov 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage 29 | *.lcov 30 | 31 | # nyc test coverage 32 | .nyc_output 33 | 34 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 35 | .grunt 36 | 37 | # Bower dependency directory (https://bower.io/) 38 | bower_components/ 39 | 40 | # node-waf configuration 41 | .lock-wscript 42 | 43 | # Compiled binary addons (https://nodejs.org/api/addons.html) 44 | build/Release 45 | 46 | # Dependency directories 47 | node_modules/ 48 | **/node_modules/ 49 | jspm_packages 50 | 51 | # TypeScript cache 52 | *.tsbuildinfo 53 | 54 | # Optional npm cache directory 55 | .npm 56 | npmlist.json 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Optional REPL history 62 | .node_repl_history 63 | 64 | # Output of 'npm pack' 65 | *.tgz 66 | 67 | # Yarn Integrity file 68 | .yarn-integrity 69 | 70 | # dotenv environment variables file 71 | .env 72 | .env.example 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # next.js build output 79 | .next 80 | 81 | # nuxt.js build output 82 | .nuxt 83 | 84 | # vuepress build output 85 | .vuepress/dist 86 | 87 | # Serverless directories 88 | .serverless 89 | 90 | # FuseBox cache 91 | .fusebox 92 | 93 | # DynamoDB Local files 94 | .dynamodb 95 | 96 | # Lock files 97 | package-lock.json 98 | yarn.lock 99 | 100 | # Jest 101 | *.snap 102 | 103 | # Helm 104 | kubelet.conf 105 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | module.exports = { 4 | parser: '@typescript-eslint/parser', 5 | plugins: ['@typescript-eslint'], 6 | extends: [ 7 | 'plugin:@typescript-eslint/eslint-recommended', 8 | 'plugin:@typescript-eslint/recommended', 9 | 'plugin:jest/recommended', 10 | 'prettier', 11 | 'plugin:prettier/recommended', 12 | ], 13 | globals: { 14 | window: true, 15 | document: true, 16 | process: true, 17 | __DEV__: true, 18 | __SERVER__: true, 19 | }, 20 | parserOptions: { 21 | sourceType: 'module', 22 | ecmaVersion: 2020, 23 | ecmaFeatures: { 24 | jsx: true, 25 | }, 26 | // project: ['./tsconfig.json', 'apps/*/tsconfig.json', 'libs/*/tsconfig.json'], 27 | }, 28 | env: { 29 | node: true, 30 | }, 31 | rules: { 32 | 'no-underscore-dangle': 0, 33 | 'jest/valid-title': 0, 34 | 'no-confusing-arrow': 0, 35 | '@typescript-eslint/indent': 0, 36 | 'operator-linebreak': 0, 37 | 'function-paren-newline': 0, 38 | 'no-param-reassign': 0, 39 | 'comma-dangle': 0, 40 | 'object-curly-newline': 0, 41 | 'implicit-arrow-linebreak': 0, 42 | 'import/prefer-default-export': 0, 43 | 'class-methods-use-this': 0, 44 | 'lines-between-class-members': 0, 45 | 'quote-props': 0, 46 | 'indent': 0, 47 | 'no-nested-ternary': 0, 48 | 'spaced-comment': ['error', 'always', { markers: ['#region', '#endregion', '/'] }], 49 | 'max-len': ['error', { code: 140, ignoreUrls: true }], 50 | 'no-unused-vars': [ 51 | 'warn', 52 | { 53 | argsIgnorePattern: '^(_|[A-Z]+)', 54 | varsIgnorePattern: '^(_|[A-Z]+)', 55 | }, 56 | ], 57 | 'prettier/prettier': [ 58 | 'error', 59 | { 60 | parser: 'typescript', 61 | printWidth: 140, 62 | singleQuote: true, 63 | useTabs: false, 64 | tabWidth: 2, 65 | semi: true, 66 | bracketSpacing: true, 67 | trailingComma: 'all', 68 | arrowParens: 'always', 69 | insertPragma: true, 70 | quoteProps: 'consistent', 71 | jsxSingleQuote: false, 72 | jsxBracketSameLine: false, 73 | htmlWhitespaceSensivity: 'css', 74 | proseWrap: 'never', 75 | }, 76 | ], 77 | 'no-empty-function': 0, 78 | 'no-shadow': 'off', 79 | '@typescript-eslint/no-shadow': ['error'], 80 | '@typescript-eslint/no-explicit-any': 'warn', 81 | '@typescript-eslint/no-unused-vars': [ 82 | 'warn', 83 | { 84 | argsIgnorePattern: '^(_|[A-Z]+)', 85 | varsIgnorePattern: '^(_|[A-Z]+)', 86 | }, 87 | ], 88 | 'no-use-before-define': 0, 89 | 'no-useless-constructor': 0, 90 | // '@typescript-eslint/no-var-requires': 0, 91 | }, 92 | }; 93 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | yarn-error.log 4 | *.DS_Store 5 | *-lock.json 6 | .local 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | index.ts 2 | package-lock.json 3 | tslint.json 4 | tsconfig.json 5 | yarn-error.log 6 | yarn.lock 7 | docs 8 | example 9 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.14.2 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /.vscode/nestjs-ldap.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": ".." 5 | } 6 | ], 7 | "settings": { 8 | "editor.fontFamily": "Fira Code Retina", 9 | "editor.fontLigatures": true, 10 | "editor.fontWeight": "400", // Regular 11 | "jest.debugMode": false, 12 | "jest.pathToJest": "yarn test", 13 | "jest.restartJestOnSnapshotUpdate": true, 14 | "javascript.format.enable": false, 15 | "typescript.format.enable": false, 16 | "files.autoSave": "off", 17 | "editor.formatOnSave": true, 18 | "[html]": { 19 | "editor.formatOnSave": false 20 | }, 21 | "[md]": { 22 | "editor.formatOnSave": false 23 | }, 24 | "[yaml]": { 25 | "editor.formatOnSave": false 26 | }, 27 | "[graphql]": { 28 | "editor.formatOnSave": false 29 | }, 30 | "[javascript]": { 31 | "editor.formatOnSave": false, 32 | "editor.codeActionsOnSave": { 33 | "source.fixAll.eslint": true 34 | } 35 | }, 36 | "[javascriptreact]": { 37 | "editor.formatOnSave": false, 38 | "editor.codeActionsOnSave": { 39 | "source.fixAll.eslint": true 40 | } 41 | }, 42 | "[typescript]": { 43 | "editor.formatOnSave": false, 44 | "editor.codeActionsOnSave": { 45 | "source.fixAll.eslint": true 46 | } 47 | }, 48 | "[typescriptreact]": { 49 | "editor.formatOnSave": false, 50 | "editor.codeActionsOnSave": { 51 | "source.fixAll.eslint": true 52 | } 53 | }, 54 | "typescript.tsdk": "node_modules/typescript/lib", 55 | "less.lint.duplicateProperties": "warning", 56 | "less.lint.float": "warning", 57 | "eslint.enable": true, 58 | "eslint.lintTask.enable": true, 59 | "eslint.alwaysShowStatus": true, 60 | "eslint.packageManager": "yarn", 61 | "eslint.trace.server": "verbose", 62 | "eslint.validate": [ 63 | "css", 64 | "javascript", 65 | "javascriptreact", 66 | "less", 67 | "markdown", 68 | "scss", 69 | "typescript", 70 | "typescriptreact", 71 | "yaml" 72 | ], 73 | "prettier.disableLanguages": ["vue"], 74 | "editor.tabSize": 2, 75 | "scss.lint.boxModel": "warning", 76 | "scss.lint.compatibleVendorPrefixes": "warning", 77 | "scss.lint.duplicateProperties": "warning", 78 | "scss.lint.float": "warning", 79 | "scss.lint.idSelector": "warning", 80 | "scss.lint.ieHack": "warning", 81 | "scss.lint.important": "warning", 82 | "scss.lint.importStatement": "warning", 83 | "scss.lint.universalSelector": "warning", 84 | "scss.lint.unknownVendorSpecificProperties": "warning", 85 | "scss.lint.zeroUnits": "warning", 86 | "better-comments.tags": [ 87 | { 88 | "tag": "!", 89 | "color": "#FF2D00", 90 | "strikethrough": false, 91 | "backgroundColor": "transparent" 92 | }, 93 | { 94 | "tag": "?", 95 | "color": "#3498DB", 96 | "strikethrough": false, 97 | "backgroundColor": "transparent" 98 | }, 99 | { 100 | "tag": "//", 101 | "color": "#474747", 102 | "strikethrough": true, 103 | "backgroundColor": "transparent" 104 | }, 105 | { 106 | "tag": "todo", 107 | "color": "#FF8C00", 108 | "strikethrough": false, 109 | "backgroundColor": "transparent" 110 | }, 111 | { 112 | "tag": "fixme", 113 | "color": "#FFD666", 114 | "strikethrough": false, 115 | "backgroundColor": "transparent" 116 | }, 117 | { 118 | "tag": "debug", 119 | "color": "#FF0000", 120 | "strikethrough": false, 121 | "backgroundColor": "transparent" 122 | }, 123 | { 124 | "tag": "eslint", 125 | "color": "#FFD666", 126 | "strikethrough": false, 127 | "backgroundColor": "transparent" 128 | }, 129 | { 130 | "tag": "*", 131 | "color": "#98C379", 132 | "strikethrough": false, 133 | "backgroundColor": "transparent" 134 | } 135 | ], 136 | "todo-tree.highlights.customHighlight": { 137 | "TODO": { 138 | "icon": "issue-opened", 139 | "foreground": "#FF8C00", 140 | "iconColour": "#FF8C00" 141 | }, 142 | "FIXME": { 143 | "icon": "bug", 144 | "foreground": "#FFD666", 145 | "iconColour": "#FFD666" 146 | }, 147 | "DEBUG": { 148 | "icon": "bug", 149 | "foreground": "#FFD666", 150 | "iconColour": "#FFD666" 151 | } 152 | }, 153 | "todo-tree.highlights.defaultHighlight": { 154 | "icon": "check", 155 | "type": "line", 156 | "foreground": "red", 157 | "background": "white", 158 | "opacity": 10, 159 | "iconColour": "#FF8C00" 160 | }, 161 | "editor.codeActionsOnSave": { 162 | "source.fixAll.eslint": true 163 | }, 164 | "npm.packageManager": "yarn", 165 | "debug.node.autoAttach": "off", 166 | "cSpell.language": "en,ru,en-GB,en-US", 167 | "cSpell.allowCompoundWords": true, 168 | "cSpell.enableFiletypes": [ 169 | "javascript", 170 | "javascriptreact", 171 | "typescript", 172 | "typescriptreact", 173 | "json", 174 | "yaml", 175 | "yml", 176 | "markdown", 177 | "jsonc" 178 | ], 179 | "cSpell.ignorePaths": [ 180 | "**/package-lock.json", 181 | "**/node_modules/**", 182 | "**/vscode-extension/**", 183 | "**/.git/objects/**", 184 | ".vscode", 185 | "**/yarn.lock" 186 | ] 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "jest.jestCommandLine": "yarn test" 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Stanislav Vyaliy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NestJS LDAP 2 | 3 |

4 | Nest Logo 5 |

6 | 7 | ## Description 8 | 9 | A NestJS library for LDAP (ActiveDirectory) 10 | 11 | ## Installation 12 | 13 | ```bash 14 | $ yarn add nestjs-ldap 15 | ``` 16 | 17 | ## Usage 18 | 19 | ```typescript 20 | import { LdapModule, ldapADattributes } from 'nestjs-ldap'; 21 | 22 | @Module({ 23 | imports: [ 24 | ... 25 | LdapModule.registerAsync({ 26 | inject: [ConfigService], 27 | useFactory: async (configService: ConfigService) => ( 28 | { 29 | cache: new Redis(), /* optional */ 30 | cacheUrl: 'redis://username:password@example.com:6379/0', /* optional */ 31 | cacheTtl: 600, /* optional */ 32 | domains: [ 33 | 'example.com' => { 34 | url: 'ldaps://pdc.example.local:389', 35 | bindDN: 'CN=Administrator,DC=example,DC=local', 36 | bindCredentials: 'PaSsWoRd123', 37 | searchBase: 'DC=example,DC=local', 38 | searchFilter: '(&(&(|(&(objectClass=user)(objectCategory=person))(&(objectClass=contact)(objectCategory=person)))))', 39 | searchScope: 'sub' as Scope, 40 | groupSearchBase: 'DC=example,DC=local', 41 | groupSearchFilter: '(&(objectClass=group)(member={{dn}}))', 42 | groupSearchScope: 'sub' as Scope, 43 | groupDnProperty: 'dn', 44 | hideSynchronization: false, 45 | searchBaseAllUsers: 'DC=example,DC=local', 46 | searchFilterAllUsers: '(&(&(|(&(objectClass=user)(objectCategory=person))(&(objectClass=contact)(objectCategory=person)))))', 47 | searchFilterAllGroups: 'objectClass=group', 48 | searchScopeAllUsers: 'sub' as Scope, 49 | newObject: 'OU=User,DC=example,DC=local', 50 | reconnect: true, 51 | groupSearchAttributes: ldapADattributes, 52 | searchAttributes: ldapADattributes, 53 | searchAttributesAllUsers: ldapADattributes, 54 | }, 55 | ] 56 | }), 57 | }), 58 | ... 59 | ] 60 | }) 61 | export class AppModule {} 62 | ``` 63 | 64 | ## License 65 | 66 | NestJS-ldap is [MIT licensed](LICENSE). 67 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | module.exports = ''; 4 | -------------------------------------------------------------------------------- /examples/ldap.module.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import { Module, Logger } from '@nestjs/common'; 4 | import { ConfigModule, ConfigService } from '@nestjs/config'; 5 | import { LdapModule } from '../lib/ldap.module'; 6 | import { ldapADattributes, Scope } from '../lib/ldap.interface'; 7 | 8 | @Module({ 9 | imports: [ 10 | ConfigModule.forRoot(), 11 | 12 | LdapModule.registerAsync({ 13 | inject: [ConfigService /* , RedisService */], 14 | useFactory: async (configService: ConfigService /* , redisService: RedisService */) => { 15 | const logger = new Logger('LDAP'); 16 | // let cache: /* Redis | */ undefined; 17 | // try { 18 | // cache = redisService.getClient('LDAP'); 19 | // } catch { 20 | // cache = undefined; 21 | // } 22 | 23 | const domainString = configService.get('LDAP'); 24 | let domainsConfig: Record; 25 | try { 26 | domainsConfig = JSON.parse(domainString); 27 | } catch { 28 | throw new Error('Not available authentication profiles.'); 29 | } 30 | 31 | const domains = Object.keys(domainsConfig).map((name) => ({ 32 | name, 33 | url: domainsConfig[name].url, 34 | bindDN: domainsConfig[name].bindDn, 35 | bindCredentials: domainsConfig[name].bindPw, 36 | searchBase: domainsConfig[name].searchBase, 37 | searchFilter: domainsConfig[name].searchUser, 38 | searchScope: 'sub' as Scope, 39 | groupSearchBase: domainsConfig[name].searchBase, 40 | groupSearchFilter: domainsConfig[name].searchGroup, 41 | groupSearchScope: 'sub' as Scope, 42 | groupDnProperty: 'dn', 43 | groupSearchAttributes: ldapADattributes, 44 | searchAttributes: ldapADattributes, 45 | hideSynchronization: domainsConfig[name].hideSynchronization === 'true' ?? false, 46 | searchBaseAllUsers: domainsConfig[name].searchBase, 47 | searchFilterAllUsers: domainsConfig[name].searchAllUsers, 48 | searchFilterAllGroups: domainsConfig[name].searchAllGroups, 49 | searchScopeAllUsers: 'sub' as Scope, 50 | searchAttributesAllUsers: ldapADattributes, 51 | reconnect: true, 52 | newObject: domainsConfig[name].newBase, 53 | })); 54 | 55 | return { 56 | // cache, 57 | // cacheTtl: configService.get('LDAP_REDIS_TTL'), 58 | domains, 59 | logger, 60 | }; 61 | }, 62 | }), 63 | ], 64 | }) 65 | export class AppModule {} 66 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist'; -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { 3 | if (k2 === undefined) k2 = k; 4 | Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); 5 | }) : (function(o, m, k, k2) { 6 | if (k2 === undefined) k2 = k; 7 | o[k2] = m[k]; 8 | })); 9 | var __exportStar = (this && this.__exportStar) || function(m, exports) { 10 | for (var p in m) if (p !== "default" && !exports.hasOwnProperty(p)) __createBinding(exports, m, p); 11 | }; 12 | exports.__esModule = true; 13 | __exportStar(require("./dist"), exports); 14 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export * from './dist'; -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | /* eslint @typescript-eslint/no-var-requires:0 */ 3 | 4 | // const { pathsToModuleNameMapper } = require('ts-jest/utils'); 5 | const { jsWithTs: tsjPreset } = require('ts-jest/presets'); 6 | // In the following statement, replace `./tsconfig` with the path to your `tsconfig` file 7 | // which contains the path mapping (ie the `compilerOptions.paths` option): 8 | // const { compilerOptions } = require('./tsconfig'); 9 | // const localPathMapper = pathsToModuleNameMapper(compilerOptions.paths, { prefix: '/' }); 10 | 11 | module.exports = { 12 | testTimeout: 180000, 13 | verbose: true, 14 | preset: 'ts-jest', 15 | testEnvironment: 'node', 16 | setupFiles: ['./jest.setup.ts'], 17 | globals: { 18 | 'ts-jest': { 19 | tsconfig: 'tsconfig.jest.json', 20 | // Disable type-checking 21 | isolatedModules: true, 22 | }, 23 | }, 24 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], 25 | moduleNameMapper: { 26 | // ...localPathMapper, 27 | // eslint-disable-next-line max-len 28 | '\\.(jpg|ico|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|css|scss|sass|less)(\\?.*)?$': 29 | '/__mocks__/fileMock.js', 30 | }, 31 | transform: { 32 | ...tsjPreset.transform, 33 | '.+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)(\\?.*)?$': 'jest-transform-stub', 34 | '^.+\\.svg$': 'jest-svg-transformer', 35 | }, 36 | // transformIgnorePatterns: ['node_modules/(?!(simple-git/src))/'], 37 | testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/(*.)+(spec|test).[jt]s?(x)'], 38 | testPathIgnorePatterns: ['/.git/', '/dist/', '/node_modules/', '/.local/'], 39 | }; 40 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | /* eslint spaced-comment:0, no-underscore-dangle:0 */ 3 | /// 4 | 5 | global.__SERVER__ = true; 6 | global.__DEV__ = true; 7 | global.__PRODUCTION__ = false; 8 | global.__TEST__ = true; 9 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | // Copyright 2020 Stanislav V Vyaliy. All rights reserved. 3 | 4 | export { 5 | InsufficientAccessRightsError, 6 | InvalidCredentialsError, 7 | EntryAlreadyExistsError, 8 | NoSuchObjectError, 9 | NoSuchAttributeError, 10 | ProtocolError, 11 | OperationsError, 12 | Error as LdapError, 13 | } from 'ldapjs'; 14 | 15 | export { Change } from './ldap/change'; 16 | export { Attribute } from './ldap/attribute'; 17 | export { Protocol } from './ldap/protocol'; 18 | export { LdapModule } from './ldap.module'; 19 | export { LdapService } from './ldap.service'; 20 | export * from './ldap.interface'; 21 | -------------------------------------------------------------------------------- /lib/ldap.class.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | import { LoggerService } from '@nestjs/common'; 3 | import { EventEmitter } from 'events'; 4 | import Ldap from 'ldapjs'; 5 | import type { LdapDomainsConfig, LoggerContext } from './ldap.interface'; 6 | import { ldapADattributes, LdapResponseObject, LdapResponseGroup, LdapResponseUser, LdapAddEntry } from './ldap.interface'; 7 | import { Change } from './ldap/change'; 8 | 9 | export class LdapDomain extends EventEmitter { 10 | public domainName: string; 11 | public hideSynchronization: boolean; 12 | 13 | private clientOpts: Ldap.ClientOptions; 14 | 15 | private bindDN: string; 16 | 17 | private bindCredentials: string; 18 | 19 | private adminClient: Ldap.Client; 20 | 21 | private adminBound: boolean; 22 | 23 | private userClient: Ldap.Client; 24 | 25 | private getGroups: ({ user, loggerContext }: { user: LdapResponseUser; loggerContext?: LoggerContext }) => Promise; 26 | 27 | /** 28 | * Create an LDAP class. 29 | * 30 | * @param {LdapModuleOptions} opts Config options 31 | * @param {LogService} logger Logger service 32 | * @param {ConfigService} configService Config service 33 | * @constructor 34 | */ 35 | constructor(private readonly options: LdapDomainsConfig, private readonly logger: LoggerService) { 36 | super(); 37 | 38 | this.domainName = options.name; 39 | this.hideSynchronization = options.hideSynchronization ?? false; 40 | 41 | this.clientOpts = { 42 | url: options.url, 43 | tlsOptions: options.tlsOptions, 44 | socketPath: options.socketPath, 45 | log: options.log, 46 | timeout: options.timeout || 5000, 47 | connectTimeout: options.connectTimeout || 5000, 48 | idleTimeout: options.idleTimeout || 5000, 49 | reconnect: options.reconnect || true, 50 | strictDN: options.strictDN, 51 | queueSize: options.queueSize || 200, 52 | queueTimeout: options.queueTimeout || 5000, 53 | queueDisable: options.queueDisable || false, 54 | }; 55 | 56 | this.bindDN = options.bindDN; 57 | this.bindCredentials = options.bindCredentials; 58 | 59 | this.adminClient = Ldap.createClient(this.clientOpts); 60 | this.adminBound = false; 61 | this.userClient = Ldap.createClient(this.clientOpts); 62 | 63 | this.adminClient.on('connectError', this.handleConnectError.bind(this)); 64 | this.userClient.on('connectError', this.handleConnectError.bind(this)); 65 | 66 | this.adminClient.on('error', this.handleErrorAdmin.bind(this)); 67 | this.userClient.on('error', this.handleErrorUser.bind(this)); 68 | 69 | if (options.reconnect) { 70 | this.once('installReconnectListener', () => { 71 | this.logger.debug!({ 72 | message: `${options.name}: install reconnect listener`, 73 | context: LdapDomain.name, 74 | function: 'constructor', 75 | }); 76 | this.adminClient.on('connect', () => this.onConnectAdmin({})); 77 | }); 78 | } 79 | 80 | this.adminClient.on('connectTimeout', this.handleErrorAdmin.bind(this)); 81 | this.userClient.on('connectTimeout', this.handleErrorUser.bind(this)); 82 | 83 | if (options.groupSearchBase && options.groupSearchFilter) { 84 | if (typeof options.groupSearchFilter === 'string') { 85 | const { groupSearchFilter } = options; 86 | // eslint-disable-next-line no-param-reassign 87 | options.groupSearchFilter = (user: LdapResponseUser): string => 88 | groupSearchFilter 89 | .replace( 90 | /{{dn}}/g, 91 | (options.groupDnProperty && (user[options.groupDnProperty] as string))?.replace(/\(/, '\\(')?.replace(/\)/, '\\)') || 92 | 'undefined', 93 | ) 94 | .replace(/{{username}}/g, user.sAMAccountName); 95 | } 96 | 97 | this.getGroups = this.findGroups; 98 | } else { 99 | // Assign an async identity function so there is no need to branch 100 | // the authenticate function to have cache set up. 101 | this.getGroups = async () => []; 102 | } 103 | } 104 | 105 | /** 106 | * Format a GUID 107 | * 108 | * @public 109 | * @param {string} objectGUID GUID in Active Directory notation 110 | * @returns {string} string GUID 111 | */ 112 | GUIDtoString = (objectGUID: string): string => 113 | (objectGUID && 114 | Buffer.from(objectGUID, 'base64') 115 | .toString('hex') 116 | .replace(/^(..)(..)(..)(..)(..)(..)(..)(..)(..)(..)(..)(..)(..)(..)(..)(..)$/, '$4$3$2$1-$6$5-$8$7-$10$9-$16$15$14$13$12$11') 117 | .toUpperCase()) || 118 | ''; 119 | 120 | /** 121 | * Ldap Date 122 | 123 | * @param {string} string 124 | */ 125 | dateFromString = (string: string): Date | null => { 126 | const b = string.match(/\d\d/g); 127 | 128 | return ( 129 | b && 130 | new Date( 131 | Date.UTC( 132 | Number.parseInt(b[0] + b[1], 10), 133 | Number.parseInt(b[2], 10) - 1, 134 | Number.parseInt(b[3], 10), 135 | Number.parseInt(b[4], 10), 136 | Number.parseInt(b[5], 10), 137 | Number.parseInt(b[6], 10), 138 | ), 139 | ) 140 | ); 141 | }; 142 | 143 | /** 144 | * Mark admin client unbound so reconnect works as expected and re-emit the error 145 | * 146 | * @private 147 | * @param {Ldap.Error} error The error to be logged and emitted 148 | * @returns {void} 149 | */ 150 | private handleErrorAdmin(error: Ldap.Error): void { 151 | if (`${error.code}` !== 'ECONNRESET') { 152 | this.logger.error({ 153 | message: `${this.domainName}: admin emitted error: [${error.code}]`, 154 | error, 155 | context: LdapDomain.name, 156 | function: 'handleErrorAdmin', 157 | }); 158 | } 159 | this.adminBound = false; 160 | } 161 | 162 | /** 163 | * Mark user client unbound so reconnect works as expected and re-emit the error 164 | * 165 | * @private 166 | * @param {Ldap.Error} error The error to be logged and emitted 167 | * @returns {void} 168 | */ 169 | private handleErrorUser(error: Ldap.Error): void { 170 | if (`${error.code}` !== 'ECONNRESET') { 171 | this.logger.error({ 172 | message: `${this.domainName}: user emitted error: [${error.code}]`, 173 | error, 174 | context: LdapDomain.name, 175 | function: 'handleErrorUser', 176 | }); 177 | } 178 | // this.adminBound = false; 179 | } 180 | 181 | /** 182 | * Connect error handler 183 | * 184 | * @private 185 | * @param {Ldap.Error} error The error to be logged and emitted 186 | * @returns {void} 187 | */ 188 | private handleConnectError(error: Ldap.Error): void { 189 | this.logger.error({ 190 | message: `${this.domainName}: emitted error: [${error.code}]`, 191 | error, 192 | context: LdapDomain.name, 193 | function: 'handleConnectError', 194 | }); 195 | } 196 | 197 | /** 198 | * Bind adminClient to the admin user on connect 199 | * 200 | * @private 201 | * @async 202 | * @returns {boolean | Error} 203 | */ 204 | private async onConnectAdmin({ loggerContext }: { loggerContext?: LoggerContext }): Promise { 205 | // Anonymous binding 206 | if (typeof this.bindDN === 'undefined' || this.bindDN === null) { 207 | this.adminBound = false; 208 | 209 | throw new Error(`${this.domainName}: bindDN is undefined`); 210 | } 211 | 212 | return new Promise((resolve, reject) => 213 | this.adminClient.bind(this.bindDN, this.bindCredentials, (error) => { 214 | if (error) { 215 | this.logger.error({ 216 | message: `${this.domainName}: bind error: ${error.toString()}`, 217 | error, 218 | context: LdapDomain.name, 219 | function: 'onConnectAdmin', 220 | ...loggerContext, 221 | }); 222 | this.adminBound = false; 223 | 224 | return reject(error); 225 | } 226 | 227 | this.adminBound = true; 228 | if (this.options.reconnect) { 229 | this.emit('installReconnectListener'); 230 | } 231 | 232 | return resolve(true); 233 | }), 234 | ); 235 | } 236 | 237 | /** 238 | * Ensure that `this.adminClient` is bound. 239 | * 240 | * @private 241 | * @async 242 | * @returns {boolean | Error} 243 | */ 244 | private adminBind = async ({ loggerContext }: { loggerContext?: LoggerContext }): Promise => 245 | this.adminBound ? true : this.onConnectAdmin({ loggerContext }); 246 | 247 | /** 248 | * Conduct a search using the admin client. Used for fetching both 249 | * user and group information. 250 | * 251 | * @private 252 | * @async 253 | * @param {string} searchBase LDAP search base 254 | * @param {Object} options LDAP search options 255 | * @param {string} options.filter LDAP search filter 256 | * @param {string} options.scope LDAP search scope 257 | * @param {(string[]|undefined)} options.attributes Attributes to fetch 258 | * @returns {undefined | Ldap.SearchEntryObject[]} 259 | * @throws {Error} 260 | */ 261 | private async search({ 262 | searchBase, 263 | options, 264 | loggerContext, 265 | }: { 266 | searchBase: string; 267 | options: Ldap.SearchOptions; 268 | loggerContext?: LoggerContext; 269 | }): Promise { 270 | return this.adminBind({ loggerContext }).then( 271 | () => 272 | new Promise((resolve, reject) => 273 | this.adminClient.search(searchBase, options, (searchError: Ldap.Error | null, searchResult: Ldap.SearchCallbackResponse) => { 274 | if (searchError !== null) { 275 | return reject(searchError); 276 | } 277 | if (typeof searchResult !== 'object') { 278 | return reject(new Error(`The LDAP server has empty search: ${searchBase}, options=${JSON.stringify(options)}`)); 279 | } 280 | 281 | const items: LdapResponseObject[] = []; 282 | searchResult.on('searchEntry', (entry: Ldap.SearchEntry) => { 283 | const object = Object.keys(entry.object).reduce((accumulator, key) => { 284 | let k = key; 285 | if (key.endsWith(';binary')) { 286 | k = key.replace(/;binary$/, ''); 287 | } 288 | switch (k) { 289 | case 'objectGUID': 290 | return { 291 | ...accumulator, 292 | objectGUID: this.GUIDtoString(entry.object[key] as string), 293 | } as LdapResponseObject; 294 | case 'dn': 295 | return { 296 | ...accumulator, 297 | dn: (entry.object[key] as string).toLowerCase(), 298 | } as LdapResponseObject; 299 | case 'sAMAccountName': 300 | return { 301 | ...accumulator, 302 | sAMAccountName: (entry.object[key] as string).toLowerCase(), 303 | } as LdapResponseObject; 304 | case 'whenCreated': 305 | case 'whenChanged': 306 | return { 307 | ...accumulator, 308 | [k]: this.dateFromString(entry.object[key] as string), 309 | } as LdapResponseObject; 310 | default: 311 | } 312 | 313 | // 'thumbnailPhoto' and 'jpegPhoto' is falling there 314 | return { ...accumulator, [k]: entry.object[key] } as LdapResponseObject; 315 | }, {} as LdapResponseObject); 316 | 317 | items.push({ ...object, loginDomain: this.domainName } as LdapResponseObject); 318 | 319 | // if (this.options.includeRaw === true) { 320 | // items[items.length - 1].raw = (entry.raw as unknown) as string; 321 | // } 322 | }); 323 | 324 | searchResult.on('error', (error: Ldap.Error) => { 325 | reject(error); 326 | }); 327 | 328 | searchResult.on('end', (result: Ldap.LDAPResult) => { 329 | if (result.status !== 0) { 330 | return reject(new Error(`non-zero status from LDAP search: ${result.status}`)); 331 | } 332 | 333 | return resolve(items); 334 | }); 335 | 336 | return undefined; 337 | }), 338 | ), 339 | ); 340 | } 341 | 342 | /** 343 | * Sanitize LDAP special characters from input 344 | * 345 | * {@link https://tools.ietf.org/search/rfc4515#section-3} 346 | * 347 | * @private 348 | * @param {string} input String to sanitize 349 | * @returns {string} Sanitized string 350 | */ 351 | private sanitizeInput(input: string): string { 352 | return input 353 | .replace(/\*/g, '\\2a') 354 | .replace(/\(/g, '\\28') 355 | .replace(/\)/g, '\\29') 356 | .replace(/\\/g, '\\5c') 357 | .replace(/\0/g, '\\00') 358 | .replace(/\//g, '\\2f'); 359 | } 360 | 361 | /** 362 | * Find the user record for the given username. 363 | * 364 | * @private 365 | * @async 366 | * @param {string} username Username to search for 367 | * @returns {undefined} If user is not found but no error happened, result is undefined. 368 | * @throws {Error} 369 | */ 370 | private async findUser({ username, loggerContext }: { username: string; loggerContext?: LoggerContext }): Promise { 371 | if (!username) { 372 | throw new Error('empty username'); 373 | } 374 | 375 | const searchFilter = this.options.searchFilter.replace(/{{username}}/g, this.sanitizeInput(username)); 376 | const options: Ldap.SearchOptions = { 377 | filter: searchFilter, 378 | scope: this.options.searchScope, 379 | attributes: ldapADattributes, 380 | timeLimit: this.options.timeLimit || 10, 381 | sizeLimit: this.options.sizeLimit || 0, 382 | paged: false, 383 | }; 384 | if (this.options.searchAttributes) { 385 | options.attributes = this.options.searchAttributes; 386 | } 387 | 388 | return this.search({ 389 | searchBase: this.options.searchBase, 390 | options, 391 | loggerContext, 392 | }) 393 | .then( 394 | (result) => 395 | new Promise((resolve, reject) => { 396 | if (!result) { 397 | return reject(new Ldap.NoSuchObjectError()); 398 | } 399 | 400 | switch (result.length) { 401 | case 0: 402 | return reject(new Ldap.NoSuchObjectError()); 403 | case 1: 404 | return resolve(result[0] as LdapResponseUser); 405 | default: 406 | return reject(new Error(`unexpected number of matches (${result.length}) for "${username}" username`)); 407 | } 408 | }), 409 | ) 410 | .catch((error: Error) => { 411 | this.logger.error({ 412 | message: `${this.domainName}: user search error: ${error.toString()}`, 413 | error, 414 | context: LdapDomain.name, 415 | function: 'findUser', 416 | ...loggerContext, 417 | }); 418 | 419 | throw error; 420 | }); 421 | } 422 | 423 | /** 424 | * Find groups for given user 425 | * 426 | * @private 427 | * @param {Ldap.SearchEntryObject} user The LDAP user object 428 | * @returns {Promise} Result handling callback 429 | */ 430 | private async findGroups({ 431 | user, 432 | loggerContext, 433 | }: { 434 | user: LdapResponseUser; 435 | loggerContext?: LoggerContext; 436 | }): Promise { 437 | if (!user) { 438 | throw new Error('no user'); 439 | } 440 | 441 | const searchFilter = typeof this.options.groupSearchFilter === 'function' ? this.options.groupSearchFilter(user) : undefined; 442 | 443 | const options: Ldap.SearchOptions = { 444 | filter: searchFilter, 445 | scope: this.options.groupSearchScope, 446 | timeLimit: this.options.timeLimit || 10, 447 | sizeLimit: this.options.sizeLimit || 0, 448 | paged: false, 449 | }; 450 | if (this.options.groupSearchAttributes) { 451 | options.attributes = this.options.groupSearchAttributes; 452 | } else { 453 | options.attributes = ldapADattributes; 454 | } 455 | 456 | return this.search({ 457 | searchBase: this.options.groupSearchBase || this.options.searchBase, 458 | options, 459 | loggerContext, 460 | }).catch((error: Error) => { 461 | this.logger.error({ 462 | message: `${this.domainName}: group search error: ${error.toString()}`, 463 | error, 464 | context: LdapDomain.name, 465 | function: 'findGroups', 466 | ...loggerContext, 467 | }); 468 | 469 | return []; 470 | }); 471 | } 472 | 473 | /** 474 | * Search user by Username 475 | * 476 | * @async 477 | * @param {string} userByUsername user name 478 | * @returns {Promise} User in LDAP 479 | */ 480 | public async searchByUsername({ 481 | username, 482 | loggerContext, 483 | }: { 484 | username: string; 485 | loggerContext?: LoggerContext; 486 | }): Promise { 487 | return this.findUser({ username, loggerContext }).catch((error: Error) => { 488 | this.logger.error({ 489 | message: `${this.domainName}: Search by Username error: ${error.toString()}`, 490 | error, 491 | context: LdapDomain.name, 492 | function: 'searchByUsername', 493 | ...loggerContext, 494 | }); 495 | 496 | throw error; 497 | }); 498 | } 499 | 500 | /** 501 | * Search user by DN 502 | * 503 | * @async 504 | * @param {string} userByDN user distinguished name 505 | * @returns {Promise} User in LDAP 506 | */ 507 | public async searchByDN({ dn, loggerContext }: { dn: string; loggerContext?: LoggerContext }): Promise { 508 | const options: Ldap.SearchOptions = { 509 | scope: this.options.searchScope, 510 | attributes: ['*'], 511 | timeLimit: this.options.timeLimit || 10, 512 | sizeLimit: this.options.sizeLimit || 0, 513 | paged: false, 514 | }; 515 | if (this.options.searchAttributes) { 516 | options.attributes = this.options.searchAttributes; 517 | } 518 | 519 | return this.search({ searchBase: dn, options, loggerContext }) 520 | .then( 521 | (result) => 522 | new Promise((resolve, reject) => { 523 | if (!result) { 524 | return reject(new Error('No result from search')); 525 | } 526 | 527 | switch (result.length) { 528 | case 0: 529 | return reject(new Ldap.NoSuchObjectError()); 530 | case 1: 531 | return resolve(result[0] as LdapResponseUser); 532 | default: 533 | return reject(new Error(`unexpected number of matches (${result.length}) for "${dn}" user DN`)); 534 | } 535 | }), 536 | ) 537 | .catch((error: Error | Ldap.NoSuchObjectError) => { 538 | if (error instanceof Ldap.NoSuchObjectError) { 539 | this.logger.error({ 540 | message: `${this.domainName}: Not found error: ${error.toString()}`, 541 | error, 542 | context: LdapDomain.name, 543 | function: 'searchByDN', 544 | ...loggerContext, 545 | }); 546 | } else { 547 | this.logger.error({ 548 | message: `${this.domainName}: Search by DN error: ${error.toString()}`, 549 | error, 550 | context: LdapDomain.name, 551 | function: 'searchByDN', 552 | ...loggerContext, 553 | }); 554 | } 555 | 556 | throw error; 557 | }); 558 | } 559 | 560 | /** 561 | * Synchronize users 562 | * 563 | * @async 564 | * @returns {Record} User in LDAP 565 | * @throws {Error} 566 | */ 567 | public async synchronization({ loggerContext }: { loggerContext?: LoggerContext }): Promise> { 568 | if (this.hideSynchronization) { 569 | return {}; 570 | } 571 | 572 | const options: Ldap.SearchOptions = { 573 | filter: this.options.searchFilterAllUsers, 574 | scope: this.options.searchScopeAllUsers, 575 | attributes: ldapADattributes, 576 | timeLimit: this.options.timeLimit || 10, 577 | sizeLimit: this.options.sizeLimit || 0, 578 | paged: true, 579 | }; 580 | if (this.options.searchAttributesAllUsers) { 581 | options.attributes = this.options.searchAttributesAllUsers; 582 | } 583 | 584 | return this.search({ 585 | searchBase: this.options.searchBase, 586 | options, 587 | loggerContext, 588 | }) 589 | .then(async (sync) => { 590 | if (sync) { 591 | const usersWithGroups = await Promise.all( 592 | sync.map(async (user) => ({ 593 | ...user, 594 | groups: await this.getGroups({ user: user as LdapResponseUser, loggerContext }), 595 | })), 596 | ); 597 | 598 | return { [this.domainName]: usersWithGroups as LdapResponseUser[] }; 599 | } 600 | 601 | this.logger.error({ 602 | message: `${this.domainName}: Synchronize unknown error`, 603 | error: 'Unknown', 604 | context: LdapDomain.name, 605 | function: 'synchronization', 606 | ...loggerContext, 607 | }); 608 | 609 | return { [this.domainName]: new Error(`${this.domainName}: Synchronize unknown error`) }; 610 | }) 611 | .catch((error: Error | Ldap.Error) => { 612 | this.logger.error({ 613 | message: `${this.domainName}: Synchronize error: ${error.toString()}`, 614 | error, 615 | context: LdapDomain.name, 616 | function: 'synchronization', 617 | ...loggerContext, 618 | }); 619 | 620 | return { [this.domainName]: error }; 621 | }); 622 | } 623 | 624 | /** 625 | * Synchronize groups 626 | * 627 | * @async 628 | * @returns {Record} Group in LDAP 629 | * @throws {Error} 630 | */ 631 | public async synchronizationGroups({ 632 | loggerContext, 633 | }: { 634 | loggerContext?: LoggerContext; 635 | }): Promise> { 636 | const options: Ldap.SearchOptions = { 637 | filter: this.options.searchFilterAllGroups, 638 | scope: this.options.groupSearchScope, 639 | attributes: ldapADattributes, 640 | timeLimit: this.options.timeLimit || 10, 641 | sizeLimit: this.options.sizeLimit || 0, 642 | paged: true, 643 | }; 644 | if (this.options.groupSearchAttributes) { 645 | options.attributes = this.options.groupSearchAttributes; 646 | } 647 | 648 | return this.search({ 649 | searchBase: this.options.searchBase, 650 | options, 651 | loggerContext, 652 | }) 653 | .then((sync) => { 654 | if (sync) { 655 | return { [this.domainName]: sync as LdapResponseGroup[] }; 656 | } 657 | 658 | this.logger.error({ 659 | message: `${this.domainName}: Synchronization groups: unknown error`, 660 | error: 'Unknown', 661 | context: LdapDomain.name, 662 | function: 'synchronizationGroups', 663 | ...loggerContext, 664 | }); 665 | 666 | return { [this.domainName]: new Error(`${this.domainName}: Synchronization groups: unknown error`) }; 667 | }) 668 | .catch((error: Error) => { 669 | this.logger.error({ 670 | message: `${this.domainName}: Synchronization groups: ${error.toString()}`, 671 | error, 672 | context: LdapDomain.name, 673 | function: 'synchronizationGroups', 674 | ...loggerContext, 675 | }); 676 | 677 | return { [this.domainName]: error }; 678 | }); 679 | } 680 | 681 | /** 682 | * Modify using the admin client. 683 | * 684 | * @public 685 | * @async 686 | * @param {string} dn LDAP Distiguished Name 687 | * @param {Change[]} data LDAP modify data 688 | * @param {string} username The optional parameter 689 | * @param {string} password The optional parameter 690 | * @returns {boolean} The result 691 | * @throws {Ldap.Error} 692 | */ 693 | public async modify({ 694 | dn, 695 | data, 696 | username, 697 | password, 698 | loggerContext, 699 | }: { 700 | dn: string; 701 | data: Change[]; 702 | username?: string; 703 | password?: string; 704 | loggerContext?: LoggerContext; 705 | }): Promise { 706 | return this.adminBind({ loggerContext }).then( 707 | () => 708 | new Promise((resolve, reject) => { 709 | if (password) { 710 | // If a password, then we try to connect with user's login and password, and try to modify 711 | this.userClient.bind(dn, password, (error): any => { 712 | data.forEach((d, i, a) => { 713 | if (d.modification.type === 'thumbnailPhoto' || d.modification.type === 'jpegPhoto') { 714 | // eslint-disable-next-line no-param-reassign 715 | a[i].modification.vals = '...skipped...'; 716 | } 717 | }); 718 | 719 | if (error) { 720 | this.logger.error({ 721 | message: `${this.domainName}: bind error: ${error.toString()}`, 722 | error, 723 | context: LdapDomain.name, 724 | function: 'modify', 725 | ...loggerContext, 726 | }); 727 | 728 | return reject(error); 729 | } 730 | 731 | return this.userClient.modify(dn, data, async (searchError: Ldap.Error | null): Promise => { 732 | if (searchError) { 733 | this.logger.error({ 734 | message: `${this.domainName}: Modify error "${dn}": ${searchError.toString()}`, 735 | error: searchError, 736 | context: LdapDomain.name, 737 | function: 'modify', 738 | ...loggerContext, 739 | }); 740 | 741 | reject(searchError); 742 | } 743 | 744 | this.logger.debug!({ 745 | message: `${this.domainName}: Modify success "${dn}"`, 746 | context: LdapDomain.name, 747 | function: 'modify', 748 | ...loggerContext, 749 | }); 750 | 751 | resolve(true); 752 | }); 753 | }); 754 | } else { 755 | this.adminClient.modify(dn, data, async (searchError: Ldap.Error | null): Promise => { 756 | data.forEach((d, i, a) => { 757 | if (d.modification.type === 'thumbnailPhoto' || d.modification.type === 'jpegPhoto') { 758 | // eslint-disable-next-line no-param-reassign 759 | a[i].modification.vals = '...skipped...'; 760 | } 761 | }); 762 | 763 | if (searchError) { 764 | this.logger.error({ 765 | message: `${this.domainName}: Modify error "${dn}": ${searchError.toString()}`, 766 | error: searchError, 767 | context: LdapDomain.name, 768 | function: 'modify', 769 | ...loggerContext, 770 | }); 771 | 772 | reject(searchError); 773 | return; 774 | } 775 | 776 | this.logger.debug!({ 777 | message: `${this.domainName}: Modify success "${dn}": ${JSON.stringify(data)}`, 778 | context: LdapDomain.name, 779 | function: 'modify', 780 | ...loggerContext, 781 | }); 782 | 783 | resolve(true); 784 | }); 785 | } 786 | }), 787 | ); 788 | } 789 | 790 | /** 791 | * Authenticate given credentials against LDAP server (Internal) 792 | * 793 | * @async 794 | * @param {string} username The username to authenticate 795 | * @param {string} password The password to verify 796 | * @returns {LdapResponseUser} User in LDAP 797 | * @throws {Error} 798 | */ 799 | public async authenticate({ 800 | username, 801 | password, 802 | loggerContext, 803 | }: { 804 | username: string; 805 | password: string; 806 | loggerContext?: LoggerContext; 807 | }): Promise { 808 | if (!password) { 809 | this.logger.error({ 810 | message: `${this.domainName}: No password given`, 811 | error: 'No password given', 812 | context: LdapDomain.name, 813 | function: 'authenticate', 814 | ...loggerContext, 815 | }); 816 | throw new Error(`${this.domainName}: No password given`); 817 | } 818 | 819 | try { 820 | // 1. Find the user DN in question. 821 | const foundUser = await this.findUser({ username, loggerContext }).catch((error: Error) => { 822 | this.logger.error({ 823 | message: `${this.domainName}: Not found user: "${username}"`, 824 | error, 825 | context: LdapDomain.name, 826 | function: 'authenticate', 827 | ...loggerContext, 828 | }); 829 | 830 | throw error; 831 | }); 832 | if (!foundUser) { 833 | this.logger.error({ 834 | message: `${this.domainName}: Not found user: "${username}"`, 835 | error: 'Not found user', 836 | context: LdapDomain.name, 837 | function: 'authenticate', 838 | ...loggerContext, 839 | }); 840 | 841 | throw new Error(`Not found user: "${username}"`); 842 | } 843 | 844 | // 2. Attempt to bind as that user to check password. 845 | return new Promise((resolve, reject) => { 846 | this.userClient.bind( 847 | foundUser[this.options.bindProperty || 'dn'], 848 | password, 849 | async (bindError): Promise => { 850 | if (bindError) { 851 | this.logger.error({ 852 | message: `${this.domainName}: bind error: ${bindError.toString()}`, 853 | error: bindError, 854 | context: LdapDomain.name, 855 | function: 'authenticate', 856 | ...loggerContext, 857 | }); 858 | 859 | return reject(bindError); 860 | } 861 | 862 | // 3. If requested, fetch user groups 863 | try { 864 | foundUser.groups = await this.getGroups({ user: foundUser, loggerContext }); 865 | 866 | return resolve(foundUser); 867 | } catch (error: unknown) { 868 | const errorMessage = error instanceof Error ? error.toString() : JSON.stringify(error); 869 | this.logger.error({ 870 | message: `${this.domainName}: Authenticate error: ${errorMessage}`, 871 | error, 872 | context: LdapDomain.name, 873 | function: 'authenticate', 874 | ...loggerContext, 875 | }); 876 | 877 | return reject(error); 878 | } 879 | }, 880 | ); 881 | }); 882 | } catch (error: unknown) { 883 | const errorMessage = error instanceof Error ? error.toString() : JSON.stringify(error); 884 | this.logger.error({ 885 | message: `${this.domainName}: LDAP auth error: ${errorMessage}`, 886 | error, 887 | context: LdapDomain.name, 888 | function: 'authenticate', 889 | ...loggerContext, 890 | }); 891 | 892 | throw error; 893 | } 894 | } 895 | 896 | /** 897 | * Trusted domain 898 | * 899 | * @async 900 | * @returns {LdapTrustedDomain} ? 901 | * @throws {Error} 902 | */ 903 | public async trustedDomain({ searchBase, loggerContext }: { searchBase: string; loggerContext?: LoggerContext }): Promise { 904 | const options: Ldap.SearchOptions = { 905 | filter: '(&(objectClass=trustedDomain))', 906 | scope: this.options.searchScope, 907 | attributes: ldapADattributes, 908 | timeLimit: this.options.timeLimit || 10, 909 | sizeLimit: this.options.sizeLimit || 0, 910 | paged: false, 911 | }; 912 | 913 | const trustedDomain = await this.search({ 914 | searchBase, 915 | options, 916 | loggerContext, 917 | }); 918 | 919 | return trustedDomain; 920 | } 921 | 922 | /** 923 | * This is add a LDAP object 924 | * 925 | * @async 926 | * @param {Record} value 927 | * @returns {LdapResponseUser} User | Profile in LDAP 928 | * @throws {Error} 929 | */ 930 | public async add({ entry, loggerContext }: { entry: LdapAddEntry; loggerContext?: LoggerContext }): Promise { 931 | return this.adminBind({ loggerContext }).then( 932 | () => 933 | new Promise((resolve, reject) => { 934 | if (!this.options.newObject) { 935 | throw new Error('ADD operation not available'); 936 | } 937 | 938 | const dn = `CN=${this.sanitizeInput(entry.cn as string)},${this.sanitizeInput(this.options.newObject)}`; 939 | this.adminClient.add(dn, entry, (error: Error) => { 940 | if (error) { 941 | return reject(error); 942 | } 943 | 944 | return resolve(this.searchByDN({ dn, loggerContext })); 945 | }); 946 | }), 947 | ); 948 | } 949 | 950 | /** 951 | * Unbind connections 952 | * 953 | * @async 954 | * @returns {Promise} 955 | */ 956 | public async close(): Promise { 957 | // It seems to be OK just to call unbind regardless of if the 958 | // client has been bound (e.g. how ldapjs pool destroy does) 959 | return new Promise((resolve) => { 960 | this.adminClient.unbind(() => { 961 | this.logger.debug!({ 962 | message: `${this.domainName}: adminClient: close`, 963 | context: LdapDomain.name, 964 | function: 'close', 965 | }); 966 | 967 | this.userClient.unbind(() => { 968 | this.logger.debug!({ 969 | message: `${this.domainName}: userClient: close`, 970 | context: LdapDomain.name, 971 | function: 'close', 972 | }); 973 | 974 | resolve(true); 975 | }); 976 | }); 977 | }); 978 | } 979 | } 980 | -------------------------------------------------------------------------------- /lib/ldap.interface.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | // Copyright 2020 Stanislav V Vyaliy. All rights reserved. 3 | 4 | //#region Imports NPM 5 | import type { LoggerService } from '@nestjs/common'; 6 | import type { ModuleMetadata, Type } from '@nestjs/common/interfaces'; 7 | import type { ClientOptions, SearchEntryObject } from 'ldapjs'; 8 | import type { Redis } from 'ioredis'; 9 | //#endregion 10 | 11 | export const LDAP_SYNC = 'LDAP_SYNC'; 12 | export const LDAP_OPTIONS = 'LDAP_OPTIONS'; 13 | 14 | export type Scope = 'base' | 'one' | 'sub'; 15 | 16 | export interface LoggerContext { 17 | [key: string]: string | unknown | null; 18 | } 19 | export interface LdapAddEntry { 20 | /** 21 | * Common name 22 | */ 23 | cn?: string; 24 | displayName?: string; 25 | name?: string; 26 | 27 | comment?: Record | string; 28 | 29 | thumbnailPhoto?: Buffer; 30 | 31 | [p: string]: undefined | string | string[] | Record | Buffer; 32 | } 33 | // deprecated 34 | export type LDAPAddEntry = LdapAddEntry; 35 | 36 | declare module 'ldapjs' { 37 | export interface SearchEntryObject { 38 | [p: string]: string | string[] | unknown; 39 | } 40 | } 41 | export interface LdapResponseObject extends Pick { 42 | /** 43 | * Domain of this user 44 | */ 45 | loginDomain: string; 46 | 47 | /** 48 | * Distinguished name 49 | */ 50 | distinguishedName: string; 51 | 52 | /** 53 | * Common name 54 | */ 55 | cn: string; 56 | 57 | /** 58 | * Description 59 | */ 60 | description: string; 61 | 62 | /** 63 | * Display name 64 | */ 65 | displayName: string; 66 | 67 | /** 68 | * Name 69 | */ 70 | name: string; 71 | 72 | // Object category 73 | objectCategory: string; 74 | objectClass: string[]; 75 | // Object GUID - ID in ldap 76 | objectGUID: string; 77 | 78 | /** 79 | * SAM account name 80 | */ 81 | sAMAccountName: string; 82 | sAMAccountType: string; 83 | 84 | whenChanged: Date; 85 | whenCreated: Date; 86 | } 87 | 88 | export type LdapResponseGroup = LdapResponseObject; 89 | 90 | export interface LdapResponseUser extends LdapResponseObject { 91 | /** 92 | * Ldap response groups 93 | */ 94 | 'groups': LdapResponseGroup[]; 95 | 96 | /** 97 | * Country 98 | */ 99 | 'c': string; 100 | 101 | /** 102 | * Country expanded 103 | */ 104 | 'co': string; 105 | 106 | /** 107 | * Comment 108 | */ 109 | 'comment': string; 110 | 111 | /** 112 | * Company 113 | */ 114 | 'company': string; 115 | 116 | /** 117 | * Country code 118 | */ 119 | 'countryCode': string; 120 | 121 | /** 122 | * Department name 123 | */ 124 | 'department': string; 125 | 126 | /** 127 | * Employee ID 128 | */ 129 | 'employeeID': string; 130 | 'employeeNumber': string; 131 | 'employeeType': string; 132 | 133 | /** 134 | * Given name 135 | */ 136 | 'givenName': string; 137 | 138 | /** 139 | * Additional flags 140 | */ 141 | 'flags': string; 142 | 143 | /** 144 | * Locality 145 | */ 146 | 'l': string; 147 | 148 | // Lockout time 149 | 'lockoutTime': string; 150 | 151 | // E-mail 152 | 'mail': string; 153 | 'otherMailbox': string[]; 154 | 155 | // Member of groups 156 | 'memberOf': string[]; 157 | 158 | // middle name 159 | 'middleName': string; 160 | 161 | // Mobile phone 162 | 'mobile': string; 163 | 164 | // Manager Profile ? 165 | 'manager': string; 166 | 167 | // Other telephones 168 | 'otherTelephone': string[]; 169 | 170 | // Postal code 171 | 'postalCode': string; 172 | 173 | /** 174 | * Office name 175 | */ 176 | 'physicalDeliveryOfficeName': string; 177 | 178 | /** 179 | * Family name 180 | */ 181 | 'sn': string; 182 | 183 | /** 184 | * Region 185 | */ 186 | 'st': string; 187 | 188 | /** 189 | * Street address 190 | */ 191 | 'streetAddress': string; 192 | 193 | /** 194 | * Telephone number 195 | */ 196 | 'telephoneNumber': string; 197 | 198 | /** 199 | * Fax number 200 | */ 201 | 'facsimileTelephoneNumber': string; 202 | 203 | /** 204 | * Thumbnail photo 205 | */ 206 | 'thumbnailPhoto': string; 207 | 208 | /** 209 | * Jpeg photo 210 | */ 211 | 'jpegPhoto': string[]; 212 | 213 | 'carLicense': string; 214 | 215 | /** 216 | * Work title 217 | */ 218 | 'title': string; 219 | 220 | 'userAccountControl': string; 221 | 222 | 'wWWHomePage': string; 223 | 224 | 'userPrincipalName': string; 225 | 226 | 'badPasswordTime': Date; 227 | 'badPwdCount': number; 228 | 229 | // Logon, logoff 230 | 'logonCount': number; 231 | 'lastLogoff': Date; 232 | 'lastLogon': Date; 233 | 'lastLogonTimestamp': Date; 234 | 235 | 'pwdLastSet': Date; 236 | 237 | /* Active Directory */ 238 | 'msDS-cloudExtensionAttribute1'?: string; 239 | 'msDS-cloudExtensionAttribute2'?: string; 240 | 241 | /* In our AD: Date of birth */ 242 | 'msDS-cloudExtensionAttribute3'?: string; 243 | 244 | 'msDS-cloudExtensionAttribute4'?: string; 245 | 'msDS-cloudExtensionAttribute5'?: string; 246 | 'msDS-cloudExtensionAttribute6'?: string; 247 | 'msDS-cloudExtensionAttribute7'?: string; 248 | 'msDS-cloudExtensionAttribute8'?: string; 249 | 'msDS-cloudExtensionAttribute9'?: string; 250 | 'msDS-cloudExtensionAttribute10'?: string; 251 | 'msDS-cloudExtensionAttribute11'?: string; 252 | 'msDS-cloudExtensionAttribute12'?: string; 253 | 254 | /* In our AD: access card (pass) */ 255 | 'msDS-cloudExtensionAttribute13'?: string; 256 | 257 | 'msDS-cloudExtensionAttribute14'?: string; 258 | 'msDS-cloudExtensionAttribute15'?: string; 259 | 'msDS-cloudExtensionAttribute16'?: string; 260 | 'msDS-cloudExtensionAttribute17'?: string; 261 | 'msDS-cloudExtensionAttribute18'?: string; 262 | 'msDS-cloudExtensionAttribute19'?: string; 263 | 'msDS-cloudExtensionAttribute20'?: string; 264 | } 265 | 266 | interface GroupSearchFilterFunction { 267 | /** 268 | * Construct a group search filter from user object 269 | * 270 | * @param user The user retrieved and authenticated from LDAP 271 | */ 272 | (user: LdapResponseUser): string; 273 | } 274 | 275 | export interface LdapDomainsConfig extends ClientOptions { 276 | /** 277 | * Name string: EXAMPLE.COM 278 | */ 279 | name: string; 280 | 281 | /** 282 | * Admin connection DN, e.g. uid=myapp,ou=users,dc=example,dc=org. 283 | * If not given at all, admin client is not bound. Giving empty 284 | * string may result in anonymous bind when allowed. 285 | * 286 | * Note: Not passed to ldapjs, it would bind automatically 287 | */ 288 | bindDN: string; 289 | /** 290 | * Password for bindDN 291 | */ 292 | bindCredentials: string; 293 | /** 294 | * Property of the LDAP user object to use when binding to verify 295 | * the password. E.g. name, email. Default: dn 296 | */ 297 | bindProperty?: 'dn'; 298 | 299 | /** 300 | * The base DN from which to search for users by username. 301 | * E.g. ou=users,dc=example,dc=org 302 | */ 303 | searchBase: string; 304 | /** 305 | * LDAP search filter with which to find a user by username, e.g. 306 | * (uid={{username}}). Use the literal {{username}} to have the 307 | * given username interpolated in for the LDAP search. 308 | */ 309 | searchFilter: string; 310 | /** 311 | * Scope of the search. Default: 'sub' 312 | */ 313 | searchScope?: Scope; 314 | /** 315 | * Array of attributes to fetch from LDAP server. Default: all 316 | */ 317 | searchAttributes?: string[]; 318 | 319 | /** 320 | * LDAP synchronization 321 | */ 322 | hideSynchronization?: boolean; 323 | 324 | /** 325 | * LDAP search filter with synchronization. 326 | */ 327 | searchFilterAllUsers?: string; 328 | /** 329 | * Scope of the search. Default: 'sub' 330 | */ 331 | searchScopeAllUsers?: Scope; 332 | /** 333 | * Array of attributes to fetch from LDAP server. Default: all 334 | */ 335 | searchAttributesAllUsers?: string[]; 336 | 337 | /** 338 | * The base DN from which to search for groups. If defined, 339 | * also groupSearchFilter must be defined for the search to work. 340 | */ 341 | groupSearchBase?: string; 342 | /** 343 | * LDAP search filter for groups. Place literal {{dn}} in the filter 344 | * to have it replaced by the property defined with `groupDnProperty` 345 | * of the found user object. Optionally you can also assign a 346 | * function instead. The found user is passed to the function and it 347 | * should return a valid search filter for the group search. 348 | */ 349 | groupSearchFilter?: string | GroupSearchFilterFunction; 350 | searchFilterAllGroups?: string; 351 | /** 352 | * Scope of the search. Default: sub 353 | */ 354 | groupSearchScope?: Scope; 355 | /** 356 | * Array of attributes to fetch from LDAP server. Default: all 357 | */ 358 | groupSearchAttributes?: string[]; 359 | 360 | /** 361 | * The property of user object to use in '{{dn}}' interpolation of 362 | * groupSearchFilter. Default: 'dn' 363 | */ 364 | groupDnProperty?: string; 365 | 366 | /** 367 | * Set to true to add property '_raw' containing the original buffers 368 | * to the returned user object. Useful when you need to handle binary 369 | * attributes 370 | */ 371 | includeRaw?: boolean; 372 | 373 | timeLimit?: number; 374 | sizeLimit?: number; 375 | 376 | /** 377 | * Where new objects (contacts, users) to place 378 | */ 379 | newObject?: string; 380 | } 381 | 382 | export interface LdapModuleOptions { 383 | /** 384 | * Domains config 385 | */ 386 | domains: LdapDomainsConfig[]; 387 | 388 | /** 389 | * Logging options 390 | */ 391 | logger: LoggerService; 392 | 393 | /** 394 | * If true, then up to 100 credentials at a time will be cached for 395 | * 5 minutes. 396 | */ 397 | cache?: Redis; 398 | cacheUrl?: string; 399 | cacheTtl?: number; 400 | } 401 | 402 | export interface LdapOptionsFactory { 403 | createLdapOptions(): Promise | LdapModuleOptions; 404 | } 405 | 406 | export interface LdapModuleAsyncOptions extends Pick { 407 | useExisting?: Type; 408 | useClass?: Type; 409 | useFactory?: (...args: any[]) => Promise | LdapModuleOptions; 410 | inject?: any[]; 411 | } 412 | 413 | export const ldapADattributes = [ 414 | 'thumbnailPhoto;binary', 415 | // 'jpegPhoto;binary', 416 | 'objectGUID;binary', 417 | // 'objectSid;binary', 418 | 'c', 419 | 'cn', 420 | 'co', 421 | 'codePage', 422 | 'comment', 423 | 'company', 424 | 'countryCode', 425 | 'department', 426 | 'description', 427 | 'displayName', 428 | 'distinguishedName', 429 | 'dn', 430 | 'employeeID', 431 | 'flags', 432 | 'givenName', 433 | 'l', 434 | 'mail', 435 | 'memberOf', 436 | 'middleName', 437 | 'manager', 438 | 'mobile', 439 | 'name', 440 | 'objectCategory', 441 | 'objectClass', 442 | 'otherMailbox', 443 | 'otherTelephone', 444 | 'postalCode', 445 | 'primaryGroupID', 446 | 'sAMAccountName', 447 | 'sAMAccountType', 448 | 'sn', 449 | 'st', 450 | 'streetAddress', 451 | 'telephoneNumber', 452 | 'title', 453 | 'wWWHomePage', 454 | 'userAccountControl', 455 | 'whenChanged', 456 | 'whenCreated', 457 | 'msDS-cloudExtensionAttribute1', 458 | 'msDS-cloudExtensionAttribute2', 459 | 'msDS-cloudExtensionAttribute3', 460 | 'msDS-cloudExtensionAttribute4', 461 | 'msDS-cloudExtensionAttribute5', 462 | 'msDS-cloudExtensionAttribute6', 463 | 'msDS-cloudExtensionAttribute7', 464 | 'msDS-cloudExtensionAttribute8', 465 | 'msDS-cloudExtensionAttribute9', 466 | 'msDS-cloudExtensionAttribute10', 467 | 'msDS-cloudExtensionAttribute11', 468 | 'msDS-cloudExtensionAttribute12', 469 | 'msDS-cloudExtensionAttribute13', 470 | 'msDS-cloudExtensionAttribute14', 471 | 'msDS-cloudExtensionAttribute15', 472 | 'msDS-cloudExtensionAttribute16', 473 | 'msDS-cloudExtensionAttribute17', 474 | 'msDS-cloudExtensionAttribute18', 475 | 'msDS-cloudExtensionAttribute19', 476 | 'msDS-cloudExtensionAttribute20', 477 | ]; 478 | 479 | export interface LDAPCache { 480 | user: LdapResponseUser; 481 | password: string; 482 | } 483 | -------------------------------------------------------------------------------- /lib/ldap.module.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | // Copyright 2020 Stanislav V Vyaliy. All rights reserved. 3 | 4 | //#region Imports NPM 5 | import { DynamicModule, Module, Provider, Type, Global } from '@nestjs/common'; 6 | //#endregion 7 | //#region Imports Local 8 | import { LdapService } from './ldap.service'; 9 | import { LDAP_OPTIONS, LdapModuleOptions, LdapModuleAsyncOptions, LdapOptionsFactory } from './ldap.interface'; 10 | //#endregion 11 | 12 | @Global() 13 | @Module({ 14 | imports: [], 15 | providers: [LdapService], 16 | exports: [LdapService], 17 | }) 18 | export class LdapModule { 19 | static register(options: LdapModuleOptions): DynamicModule { 20 | return { 21 | module: LdapModule, 22 | providers: [{ provide: LDAP_OPTIONS, useValue: options || {} }, LdapService], 23 | }; 24 | } 25 | 26 | static registerAsync(options: LdapModuleAsyncOptions): DynamicModule { 27 | return { 28 | module: LdapModule, 29 | imports: options.imports || [], 30 | providers: this.createAsyncProviders(options), 31 | }; 32 | } 33 | 34 | private static createAsyncProviders(options: LdapModuleAsyncOptions): Provider[] { 35 | if (options.useExisting || options.useFactory) { 36 | return [this.createAsyncOptionsProvider(options)]; 37 | } 38 | 39 | return [ 40 | this.createAsyncOptionsProvider(options), 41 | { 42 | provide: options.useClass as Type, 43 | useClass: options.useClass as Type, 44 | }, 45 | ]; 46 | } 47 | 48 | private static createAsyncOptionsProvider(options: LdapModuleAsyncOptions): Provider { 49 | if (options.useFactory) { 50 | return { 51 | provide: LDAP_OPTIONS, 52 | useFactory: options.useFactory, 53 | inject: options.inject || [], 54 | }; 55 | } 56 | 57 | return { 58 | provide: LDAP_OPTIONS, 59 | useFactory: async (optionsFactory: LdapOptionsFactory) => optionsFactory.createLdapOptions(), 60 | inject: [(options.useExisting as Type) || (options.useClass as Type)], 61 | }; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lib/ldap.service.spec.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import { Test, TestingModule } from '@nestjs/testing'; 4 | import { Logger } from '@nestjs/common'; 5 | import { LdapService } from './ldap.service'; 6 | import { LDAP_OPTIONS } from './ldap.interface'; 7 | 8 | const serviceMock = jest.fn(() => ({})); 9 | 10 | describe(LdapService.name, () => { 11 | let ldap: LdapService; 12 | 13 | beforeEach(async () => { 14 | const module: TestingModule = await Test.createTestingModule({ 15 | imports: [], 16 | providers: [ 17 | { provide: LDAP_OPTIONS, useValue: { options: {}, cache: false, domains: [] } }, 18 | { provide: Logger, useValue: serviceMock }, 19 | LdapService, 20 | ], 21 | }).compile(); 22 | 23 | ldap = module.get(LdapService); 24 | }); 25 | 26 | it('should be defined', () => { 27 | expect(ldap).toBeDefined(); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /lib/ldap.service.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | // Copyright 2020 Stanislav V Vyaliy. All rights reserved. 3 | 4 | //#region Imports NPM 5 | import { Inject, Injectable, LoggerService, Logger } from '@nestjs/common'; 6 | import CacheManager from 'cache-manager'; 7 | import RedisStore from 'cache-manager-ioredis'; 8 | import { parse as urlLibParse } from 'url'; 9 | import bcrypt from 'bcrypt'; 10 | //#endregion 11 | //#region Imports Local 12 | import type { LdapModuleOptions, LDAPCache, LdapResponseUser, LdapResponseGroup, LdapAddEntry, LoggerContext } from './ldap.interface'; 13 | import { LDAP_OPTIONS } from './ldap.interface'; 14 | import { Change } from './ldap/change'; 15 | import { LdapDomain } from './ldap.class'; 16 | //#endregion 17 | 18 | const LDAP_PASSWORD_NULL = '2058e76c5f3d68e12d7eec7e334fece75b0552edc5348f85c7889404d9211a36'; 19 | 20 | @Injectable() 21 | export class LdapService { 22 | public ldapDomains: LdapDomain[]; 23 | 24 | private logger: LoggerService; 25 | private cache?: CacheManager.Cache; 26 | private cacheSalt: string; 27 | private cacheTtl: number; 28 | 29 | /** 30 | * Create an LDAP class. 31 | * 32 | * @param {LdapModuleOptions} opts Config options 33 | * @param {LogService} logger Logger service 34 | * @param {ConfigService} configService Config service 35 | * @constructor 36 | */ 37 | constructor(@Inject(LDAP_OPTIONS) private readonly options: LdapModuleOptions) { 38 | this.logger = options.logger; 39 | 40 | if (options.cacheUrl || options.cache) { 41 | this.cacheTtl = options.cacheTtl || 600; 42 | this.cacheSalt = bcrypt.genSaltSync(6); 43 | 44 | if (options.cache) { 45 | this.cache = CacheManager.caching({ 46 | store: RedisStore, 47 | redisInstance: options.cache, 48 | keyPrefix: 'LDAP:', 49 | ttl: this.cacheTtl, 50 | }); 51 | } else if (options.cacheUrl) { 52 | const redisArray = urlLibParse(options.cacheUrl); 53 | if (redisArray && (redisArray.protocol === 'redis:' || redisArray.protocol === 'rediss:')) { 54 | let username: string | undefined; 55 | let password: string | undefined; 56 | const db = parseInt(redisArray.pathname?.slice(1) || '0', 10); 57 | if (redisArray.auth) { 58 | [username, password] = redisArray.auth.split(':'); 59 | } 60 | 61 | this.cache = CacheManager.caching({ 62 | store: RedisStore, 63 | host: redisArray.hostname, 64 | port: parseInt(redisArray.port || '6379', 10), 65 | username, 66 | password, 67 | db, 68 | keyPrefix: 'LDAP:', 69 | ttl: this.cacheTtl, 70 | }); 71 | } 72 | } 73 | if (this.cache?.store) { 74 | this.logger.debug!({ 75 | message: 'Redis connection: success', 76 | context: LdapService.name, 77 | function: 'constructor', 78 | }); 79 | } else { 80 | this.logger.error({ 81 | message: 'Redis connection: some error', 82 | context: LdapService.name, 83 | function: 'constructor', 84 | }); 85 | } 86 | } else { 87 | this.cacheSalt = ''; 88 | this.cacheTtl = 0; 89 | } 90 | 91 | this.ldapDomains = this.options.domains.map((opts) => new LdapDomain(opts, this.logger)); 92 | } 93 | 94 | /** 95 | * Search user by Username 96 | * 97 | * @async 98 | * @param {string} userByUsername user name 99 | * @returns {Promise} User in LDAP 100 | */ 101 | public async searchByUsername({ 102 | username, 103 | domain, 104 | cache = true, 105 | loggerContext, 106 | }: { 107 | username: string; 108 | domain: string; 109 | cache?: boolean; 110 | loggerContext?: LoggerContext; 111 | }): Promise { 112 | const cachedID = `user:${domain}:${username}`; 113 | 114 | if (cache && this.cache) { 115 | // Check cache. 'cached' is `{password: , user: }`. 116 | const cached = await this.cache.get(cachedID); 117 | if (cached && cached.user && cached.user.sAMAccountName) { 118 | this.logger.debug!({ 119 | message: `From cache: ${cached.user.sAMAccountName}`, 120 | context: LdapService.name, 121 | function: 'searchByUsername', 122 | ...loggerContext, 123 | }); 124 | 125 | return cached.user as LdapResponseUser; 126 | } 127 | } 128 | 129 | const domainLdap = this.ldapDomains.find((value) => value.domainName === domain); 130 | if (!domainLdap) { 131 | this.logger.debug!({ 132 | message: `Domain does not exist: ${domain}`, 133 | context: LdapService.name, 134 | function: 'searchByUsername', 135 | ...loggerContext, 136 | }); 137 | throw new Error(`Domain does not exist: ${domain}`); 138 | } 139 | 140 | return domainLdap.searchByUsername({ username, loggerContext }).then((user) => { 141 | if (user && this.cache) { 142 | this.logger.debug!({ 143 | message: `To cache from domain ${domain}: ${user.dn}`, 144 | context: LdapService.name, 145 | function: 'searchByUsername', 146 | ...loggerContext, 147 | }); 148 | this.cache.set(`dn:${domain}:${user.dn}`, { user, password: LDAP_PASSWORD_NULL }, { ttl: this.cacheTtl }); 149 | 150 | if (user.sAMAccountName) { 151 | this.logger.debug!({ 152 | message: `To cache from domain ${domain}: ${user.sAMAccountName}`, 153 | context: LdapService.name, 154 | function: 'searchByUsername', 155 | ...loggerContext, 156 | }); 157 | this.cache.set( 158 | `user:${domain}:${user.sAMAccountName}`, 159 | { user, password: LDAP_PASSWORD_NULL }, 160 | { ttl: this.cacheTtl }, 161 | ); 162 | } 163 | } 164 | 165 | return user; 166 | }); 167 | } 168 | 169 | /** 170 | * Search user by DN 171 | * 172 | * @async 173 | * @param {string} userByDN user distinguished name 174 | * @returns {Promise} User in LDAP 175 | */ 176 | public async searchByDN({ 177 | dn, 178 | domain, 179 | cache = true, 180 | loggerContext, 181 | }: { 182 | dn: string; 183 | domain: string; 184 | cache?: boolean; 185 | loggerContext?: LoggerContext; 186 | }): Promise { 187 | if (!domain || !dn) { 188 | throw new Error(`Arguments domain=${domain}, userByDN=${dn}`); 189 | } 190 | 191 | const cachedID = `dn:${domain}:${dn}`; 192 | if (cache && this.cache) { 193 | // Check cache. 'cached' is `{password: , user: }`. 194 | const cached = await this.cache.get(cachedID); 195 | if (cached?.user.dn) { 196 | this.logger.debug!({ 197 | message: `From cache: ${cached.user.dn}`, 198 | context: LdapService.name, 199 | function: 'searchByDN', 200 | ...loggerContext, 201 | }); 202 | 203 | return cached.user as LdapResponseUser; 204 | } 205 | } 206 | 207 | const domainLdap = this.ldapDomains.find((value) => value.domainName === domain); 208 | if (!domainLdap) { 209 | this.logger.debug!({ 210 | message: `Domain does not exist: ${domain}`, 211 | context: LdapService.name, 212 | function: 'searchByDN', 213 | ...loggerContext, 214 | }); 215 | throw new Error(`Domain does not exist: ${domain}`); 216 | } 217 | 218 | return domainLdap.searchByDN({ dn, loggerContext }).then((user) => { 219 | if (user && this.cache) { 220 | this.logger.debug!({ 221 | message: `To cache, domain "${domain}": ${user.dn}`, 222 | context: LdapService.name, 223 | function: 'searchByDN', 224 | ...loggerContext, 225 | }); 226 | this.cache.set(cachedID, { user, password: LDAP_PASSWORD_NULL }, { ttl: this.cacheTtl }); 227 | 228 | if (user.sAMAccountName) { 229 | this.logger.debug!({ 230 | message: `To cache, domain "${domain}": ${user.sAMAccountName}`, 231 | context: LdapService.name, 232 | function: 'searchByDN', 233 | ...loggerContext, 234 | }); 235 | this.cache.set( 236 | `user:${domain}:${user.sAMAccountName}`, 237 | { user, password: LDAP_PASSWORD_NULL }, 238 | { ttl: this.cacheTtl }, 239 | ); 240 | } 241 | } 242 | 243 | return user; 244 | }); 245 | } 246 | 247 | /** 248 | * Synchronize users 249 | * 250 | * @async 251 | * @returns {Record} User in LDAP 252 | * @throws {Error} 253 | */ 254 | public async synchronization({ loggerContext }: { loggerContext?: LoggerContext }): Promise> { 255 | return Promise.all( 256 | this.ldapDomains.filter((domain) => !domain.hideSynchronization).map(async (domain) => domain.synchronization({ loggerContext })), 257 | ).then((promise) => promise.reduce((accumulator, domain) => ({ ...accumulator, ...domain }), {})); 258 | } 259 | 260 | /** 261 | * Synchronize users 262 | * 263 | * @async 264 | * @returns {Record} Group in LDAP 265 | * @throws {Error} 266 | */ 267 | public async synchronizationGroups({ 268 | loggerContext, 269 | }: { 270 | loggerContext?: LoggerContext; 271 | }): Promise> { 272 | return Promise.all( 273 | this.ldapDomains 274 | .filter((domain) => !domain.hideSynchronization) 275 | .map(async (domain) => domain.synchronizationGroups({ loggerContext })), 276 | ).then((promise) => promise.reduce((accumulator, domain) => ({ ...accumulator, ...domain }), {})); 277 | } 278 | 279 | /** 280 | * Modify using the admin client. 281 | * 282 | * @public 283 | * @async 284 | * @param {string} dn LDAP Distiguished Name 285 | * @param {Change[]} data LDAP modify data 286 | * @param {string} username The optional parameter 287 | * @param {string} password The optional parameter 288 | * @returns {boolean} The result 289 | * @throws {Ldap.Error} 290 | */ 291 | public async modify({ 292 | dn, 293 | data, 294 | domain, 295 | username, 296 | password, 297 | loggerContext, 298 | }: { 299 | dn: string; 300 | data: Change[]; 301 | domain: string; 302 | username?: string; 303 | password?: string; 304 | loggerContext?: LoggerContext; 305 | }): Promise { 306 | const domainLdap = this.ldapDomains.find((value) => value.domainName === domain); 307 | if (!domainLdap) { 308 | this.logger.debug!({ 309 | message: `Domain does not exist: ${domain}`, 310 | context: LdapService.name, 311 | function: 'modify', 312 | ...loggerContext, 313 | }); 314 | throw new Error(`Domain does not exist: ${domain}`); 315 | } 316 | 317 | return domainLdap.modify({ dn, data, username, password, loggerContext }); 318 | } 319 | 320 | /** 321 | * Authenticate given credentials against LDAP server 322 | * 323 | * @async 324 | * @param {string} username The username to authenticate 325 | * @param {string} password The password to verify 326 | * @param {string} domain The domain to check 327 | * @returns {LdapResponseUser} User in LDAP 328 | * @throws {Error} 329 | */ 330 | public async authenticate({ 331 | username, 332 | password, 333 | domain, 334 | cache = true, 335 | loggerContext, 336 | }: { 337 | username: string; 338 | password: string; 339 | domain: string; 340 | cache?: boolean; 341 | loggerContext?: LoggerContext; 342 | }): Promise { 343 | if (!password) { 344 | this.logger.error({ 345 | message: `${domain}: No password given`, 346 | error: `${domain}: No password given`, 347 | context: LdapService.name, 348 | function: 'authenticate', 349 | ...loggerContext, 350 | }); 351 | throw new Error('No password given'); 352 | } 353 | 354 | const domainLdap = this.ldapDomains.find((value) => value.domainName === domain); 355 | if (!domainLdap) { 356 | this.logger.debug!({ 357 | message: `Domain does not exist: ${domain}`, 358 | context: LdapService.name, 359 | function: 'authenticate', 360 | ...loggerContext, 361 | }); 362 | throw new Error(`Domain does not exist: ${domain}`); 363 | } 364 | 365 | const cachedID = `user:${domain}:${username}`; 366 | if (cache && this.cache) { 367 | // Check cache. 'cached' is `{password: , user: }`. 368 | const cached = await this.cache.get(cachedID); 369 | if (cached?.user?.sAMAccountName && (cached?.password === LDAP_PASSWORD_NULL || bcrypt.compareSync(password, cached.password))) { 370 | this.logger.debug!({ 371 | message: `From cache ${domain}: ${cached.user.sAMAccountName}`, 372 | context: LdapService.name, 373 | function: 'authenticate', 374 | ...loggerContext, 375 | }); 376 | 377 | (async (): Promise => { 378 | try { 379 | const user = await domainLdap.authenticate({ 380 | username, 381 | password, 382 | loggerContext, 383 | }); 384 | if (JSON.stringify(user) !== JSON.stringify(cached.user) && this.cache) { 385 | this.logger.debug!({ 386 | message: `To cache from domain ${domain}: ${user.sAMAccountName}`, 387 | context: LdapService.name, 388 | function: 'authenticate', 389 | ...loggerContext, 390 | }); 391 | 392 | this.cache.set( 393 | `user:${domain}:${user.sAMAccountName}`, 394 | { 395 | user, 396 | password: bcrypt.hashSync(password, this.cacheSalt), 397 | }, 398 | { ttl: this.cacheTtl }, 399 | ); 400 | } 401 | } catch (error) { 402 | const errorMessage = error instanceof Error ? error.toString() : JSON.stringify(error); 403 | this.logger.error({ 404 | message: `LDAP auth error [${domain}]: ${errorMessage}`, 405 | error, 406 | context: LdapService.name, 407 | function: 'authenticate', 408 | ...loggerContext, 409 | }); 410 | } 411 | })(); 412 | 413 | return cached.user; 414 | } 415 | } 416 | 417 | return domainLdap 418 | .authenticate({ 419 | username, 420 | password, 421 | loggerContext, 422 | }) 423 | .then((user) => { 424 | if (this.cache) { 425 | this.logger.debug!({ 426 | message: `To cache from domain ${domain}: ${user.sAMAccountName}`, 427 | context: LdapService.name, 428 | function: 'authenticate', 429 | ...loggerContext, 430 | }); 431 | 432 | this.cache.set( 433 | `user:${domain}:${user.sAMAccountName}`, 434 | { 435 | user, 436 | password: bcrypt.hashSync(password, this.cacheSalt), 437 | }, 438 | { ttl: this.cacheTtl }, 439 | ); 440 | } 441 | 442 | return user; 443 | }); 444 | } 445 | 446 | /** 447 | * Trusted domain 448 | * 449 | * @async 450 | * @returns {LdapTrustedDomain} ? 451 | * @throws {Error} 452 | */ 453 | public async trustedDomain({ 454 | searchBase, 455 | domain, 456 | loggerContext, 457 | }: { 458 | searchBase: string; 459 | domain: string; 460 | loggerContext?: LoggerContext; 461 | }): Promise { 462 | const trustedDomain = ''; 463 | 464 | return trustedDomain; 465 | } 466 | 467 | /** 468 | * This is add a LDAP object 469 | * 470 | * @async 471 | * @param {Record} value 472 | * @returns {LdapResponseUser} User | Profile in LDAP 473 | * @throws {Error} 474 | */ 475 | public async add({ 476 | entry, 477 | domain, 478 | loggerContext, 479 | }: { 480 | entry: LdapAddEntry; 481 | domain: string; 482 | loggerContext?: LoggerContext; 483 | }): Promise { 484 | const domainLdap = this.ldapDomains.find((value) => value.domainName === domain); 485 | if (!domainLdap) { 486 | this.logger.debug!({ 487 | message: `Domain does not exist: ${domain}`, 488 | context: LdapService.name, 489 | function: 'add', 490 | ...loggerContext, 491 | }); 492 | throw new Error(`Domain does not exist: ${domain}`); 493 | } 494 | 495 | return domainLdap.add({ entry, loggerContext }); 496 | } 497 | 498 | /** 499 | * Unbind connections 500 | * 501 | * @async 502 | * @returns {Promise} 503 | */ 504 | public async close(): Promise { 505 | const promiseDomain = this.ldapDomains.map(async (domain) => domain.close()); 506 | return Promise.allSettled(promiseDomain) 507 | .then((values) => values.map((promise) => (promise.status === 'fulfilled' ? promise.value : false))) 508 | .then((values) => values.reduce((accumulator, value) => accumulator.concat(value), [] as boolean[])); 509 | } 510 | } 511 | -------------------------------------------------------------------------------- /lib/ldap/attribute.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | // Copyright 2020 Stanislav V Vyaliy. All rights reserved. 3 | 4 | import { Ber, BerWriter, BerReader } from 'asn1'; 5 | import { Protocol } from './protocol'; 6 | 7 | export class Attribute { 8 | public _vals?: Record; 9 | public type: string; 10 | 11 | /** 12 | * Get vals 13 | * 14 | * @returns {string | Buffer} 15 | */ 16 | get vals(): string | Buffer { 17 | return this._vals?.map((v: any) => v.toString(this.bufferEncoding(this.type))); 18 | } 19 | 20 | /** 21 | * Set vals 22 | * 23 | * @param {string | Buffer} vals 24 | */ 25 | set vals(vals: string | Buffer) { 26 | this._vals = []; 27 | if (Array.isArray(vals)) { 28 | vals.forEach((v) => { 29 | this.addValue(v); 30 | }); 31 | } else { 32 | this.addValue(vals); 33 | } 34 | } 35 | 36 | /** 37 | * Get buffers 38 | * 39 | * @returns {Record} buffers 40 | */ 41 | get buffers(): Record { 42 | return this._vals || {}; 43 | } 44 | 45 | /** 46 | * Get json 47 | * 48 | * @returns {Record} { type, vals } 49 | */ 50 | get json(): Record { 51 | return { 52 | type: this.type, 53 | vals: this.vals, 54 | }; 55 | } 56 | 57 | constructor(options: Record = { type: '' }) { 58 | if (options.type && typeof options.type !== 'string') { 59 | throw new TypeError('options.type must be a string'); 60 | } 61 | 62 | this.type = options.type || ''; 63 | 64 | if (options.vals !== undefined && options.vals !== null) { 65 | this.vals = options.vals; 66 | } 67 | } 68 | 69 | bufferEncoding = (type: string): 'base64' | 'utf8' => (/;binary$/.test(type) ? 'base64' : 'utf8'); 70 | 71 | addValue = (val: Buffer | string): void => { 72 | if (Buffer.isBuffer(val)) { 73 | this._vals?.push(val); 74 | } else { 75 | this._vals?.push(Buffer.from(val, this.bufferEncoding(this.type))); 76 | } 77 | }; 78 | 79 | static isAttribute = (attr: Attribute | Record): attr is Attribute => { 80 | if (attr instanceof Attribute) { 81 | return true; 82 | } 83 | 84 | if ( 85 | typeof attr.toBer === 'function' && 86 | typeof attr.type === 'string' && 87 | Array.isArray(attr.vals) && 88 | attr.vals.filter((item) => typeof item === 'string' || Buffer.isBuffer(item)).length === attr.vals.length 89 | ) { 90 | return true; 91 | } 92 | 93 | return false; 94 | }; 95 | 96 | static compare = (a: Attribute | Record, b: Attribute | Record): number => { 97 | if (!Attribute.isAttribute(a) || !Attribute.isAttribute(b)) { 98 | throw new TypeError('can only compare Attributes'); 99 | } 100 | 101 | if (a.type < b.type) return -1; 102 | if (a.type > b.type) return 1; 103 | if (a.vals.length < b.vals.length) return -1; 104 | if (a.vals.length > b.vals.length) return 1; 105 | 106 | for (let i = 0; i < a.vals.length; i += 1) { 107 | if (a.vals[i] < b.vals[i]) return -1; 108 | if (a.vals[i] > b.vals[i]) return 1; 109 | } 110 | 111 | return 0; 112 | }; 113 | 114 | parse = (ber?: BerReader): boolean => { 115 | if (!ber) { 116 | throw new TypeError('ldapjs Attribute parse: ber is undefined'); 117 | } 118 | 119 | ber.readSequence(); 120 | this.type = ber.readString(); 121 | 122 | if (ber.peek() === Protocol.LBER_SET) { 123 | if (ber.readSequence(Protocol.LBER_SET)) { 124 | const end = ber.offset + ber.length; 125 | while (ber.offset < end) this._vals?.push(ber.readString(Ber.OctetString, true)); 126 | } 127 | } 128 | 129 | return true; 130 | }; 131 | 132 | toString = (): string => JSON.stringify(this.json); 133 | 134 | toBer = (ber?: BerWriter): BerWriter => { 135 | if (!ber) { 136 | throw new TypeError('ldapjs Attribute toBer: ber is undefined'); 137 | } 138 | 139 | ber.startSequence(); 140 | ber.writeString(this.type); 141 | ber.startSequence(Protocol.LBER_SET); 142 | if (this._vals?.length) { 143 | this._vals.forEach((b: any) => { 144 | ber.writeByte(Ber.OctetString); 145 | ber.writeLength(b.length); 146 | for (let i = 0; i < b.length; i += 1) ber.writeByte(b[i]); 147 | }); 148 | } else { 149 | ber.writeStringArray([]); 150 | } 151 | ber.endSequence(); 152 | ber.endSequence(); 153 | 154 | return ber; 155 | }; 156 | } 157 | -------------------------------------------------------------------------------- /lib/ldap/change.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | // Copyright 2020 Stanislav V Vyaliy. All rights reserved. 3 | 4 | import { BerWriter, BerReader } from 'asn1'; 5 | import { Attribute } from './attribute'; 6 | 7 | export class Change { 8 | private _modification: Attribute; 9 | private _operation?: number; 10 | 11 | get operation(): 'add' | 'delete' | 'replace' { 12 | switch (this._operation) { 13 | case 0x00: 14 | return 'add'; 15 | case 0x01: 16 | return 'delete'; 17 | case 0x02: 18 | return 'replace'; 19 | default: 20 | throw new Error(`0x${this._operation?.toString(16)} is invalid`); 21 | } 22 | } 23 | 24 | set operation(value: 'add' | 'delete' | 'replace') { 25 | switch (value) { 26 | case 'add': 27 | this._operation = 0x00; 28 | break; 29 | case 'delete': 30 | this._operation = 0x01; 31 | break; 32 | case 'replace': 33 | this._operation = 0x02; 34 | break; 35 | default: 36 | throw new Error(`Invalid operation type: 0x${Number(value).toString(16)}`); 37 | } 38 | } 39 | 40 | get modification(): Attribute | Record { 41 | return this._modification; 42 | } 43 | 44 | set modification(value: Attribute | Record) { 45 | if (Attribute.isAttribute(value)) { 46 | this._modification = value; 47 | return; 48 | } 49 | 50 | // Does it have an attribute-like structure 51 | if (Object.keys(value).length === 2 && typeof value.type === 'string' && Array.isArray(value.vals)) { 52 | this._modification = new Attribute({ 53 | type: value.type, 54 | vals: value.vals, 55 | }); 56 | return; 57 | } 58 | 59 | const keys = Object.keys(value); 60 | if (keys.length > 1) { 61 | throw new Error('Only one attribute per Change allowed'); 62 | } else if (keys.length === 0) { 63 | return; 64 | } 65 | 66 | const k = keys[0]; 67 | const _attribute = new Attribute({ type: k }); 68 | if (Array.isArray(value[k])) { 69 | value[k].forEach((v: string | Buffer): void => { 70 | _attribute.addValue(v); 71 | }); 72 | } else if (Buffer.isBuffer(value[k])) { 73 | _attribute.addValue(value[k]); 74 | } else if (value[k] !== undefined && value[k] !== null) { 75 | _attribute.addValue(value[k]); 76 | } 77 | this._modification = _attribute; 78 | } 79 | 80 | constructor(options: Record = { operation: 'add' }) { 81 | this._modification = new Attribute(); 82 | this.operation = options.operation || options.type || 'add'; 83 | this.modification = options.modification || {}; 84 | } 85 | 86 | isChange = (change: Change | Record): boolean => { 87 | if (!change || typeof change !== 'object') { 88 | return false; 89 | } 90 | 91 | if ( 92 | change instanceof Change || 93 | (typeof change.toBer === 'function' && change.modification !== undefined && change.operation !== undefined) 94 | ) { 95 | return true; 96 | } 97 | 98 | return false; 99 | }; 100 | 101 | compare = (a: Change | Record, b: Change | Record): number => { 102 | if (!this.isChange(a) || !this.isChange(b)) { 103 | throw new TypeError('can only compare Changes'); 104 | } 105 | 106 | if (a.operation < b.operation) return -1; 107 | if (a.operation > b.operation) return 1; 108 | 109 | return Attribute.compare(a.modification, b.modification); 110 | }; 111 | 112 | /** 113 | * Apply a Change to properties of an object. 114 | * 115 | * @param {Object} change the change to apply. 116 | * @param {Object} obj the object to apply it to. 117 | * @param {Boolean} scalar convert single-item arrays to scalars. Default: false 118 | */ 119 | apply = ( 120 | change: Record = { operation: 'add', modification: { type: '' } }, 121 | object: Record, 122 | scalar: any, 123 | ): any => { 124 | const { type } = change.modification; 125 | const { vals } = change.modification; 126 | let data = object[type]; 127 | if (data !== undefined) { 128 | if (!Array.isArray(data)) { 129 | data = [data]; 130 | } 131 | } else { 132 | data = []; 133 | } 134 | switch (change.operation) { 135 | case 'replace': 136 | if (vals.length === 0) { 137 | // replace empty is a delete 138 | delete object[type]; 139 | return object; 140 | } 141 | data = vals; 142 | 143 | break; 144 | case 'add': { 145 | // add only new unique entries 146 | const newValues = vals.filter((entry: any) => !data.includes(entry)); 147 | data = data.concat(newValues); 148 | break; 149 | } 150 | case 'delete': 151 | data = data.filter((entry: any) => !vals.includes(entry)); 152 | if (data.length === 0) { 153 | // Erase the attribute if empty 154 | delete object[type]; 155 | return object; 156 | } 157 | break; 158 | default: 159 | break; 160 | } 161 | if (scalar && data.length === 1) { 162 | // store single-value outputs as scalars, if requested 163 | // eslint-disable-next-line prefer-destructuring 164 | object[type] = data[0]; 165 | } else { 166 | object[type] = data; 167 | } 168 | return object; 169 | }; 170 | 171 | parse = (ber?: BerReader): boolean => { 172 | if (!ber) { 173 | return false; 174 | } 175 | 176 | ber.readSequence(); 177 | this._operation = ber.readEnumeration(); 178 | this._modification = new Attribute(); 179 | this._modification.parse(ber); 180 | 181 | return true; 182 | }; 183 | 184 | toBer = (ber?: BerWriter): BerWriter => { 185 | if (!ber) { 186 | throw new TypeError('ldapjs Change toBer: ber is undefined'); 187 | } 188 | 189 | ber.startSequence(); 190 | ber.writeEnumeration(this._operation || 0x00); 191 | 192 | ber = this._modification?.toBer(ber); 193 | ber.endSequence(); 194 | 195 | return ber; 196 | }; 197 | 198 | json = (): Record => ({ 199 | operation: this.operation, 200 | modification: this._modification ? this._modification.json : {}, 201 | }); 202 | } 203 | -------------------------------------------------------------------------------- /lib/ldap/protocol.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | // Copyright 2020 Stanislav V Vyalyi. All rights reserved. 3 | 4 | export const Protocol = { 5 | // Misc 6 | LDAP_VERSION_3: 0x03, 7 | LBER_SET: 0x31, 8 | LDAP_CONTROLS: 0xa0, 9 | 10 | // Search 11 | SCOPE_BASE_OBJECT: 0, 12 | SCOPE_ONE_LEVEL: 1, 13 | SCOPE_SUBTREE: 2, 14 | 15 | NEVER_DEREF_ALIASES: 0, 16 | DEREF_IN_SEARCHING: 1, 17 | DEREF_BASE_OBJECT: 2, 18 | DEREF_ALWAYS: 3, 19 | 20 | FILTER_AND: 0xa0, 21 | FILTER_OR: 0xa1, 22 | FILTER_NOT: 0xa2, 23 | FILTER_EQUALITY: 0xa3, 24 | FILTER_SUBSTRINGS: 0xa4, 25 | FILTER_GE: 0xa5, 26 | FILTER_LE: 0xa6, 27 | FILTER_PRESENT: 0x87, 28 | FILTER_APPROX: 0xa8, 29 | FILTER_EXT: 0xa9, 30 | 31 | // Protocol Operations 32 | LDAP_REQ_BIND: 0x60, 33 | LDAP_REQ_UNBIND: 0x42, 34 | LDAP_REQ_SEARCH: 0x63, 35 | LDAP_REQ_MODIFY: 0x66, 36 | LDAP_REQ_ADD: 0x68, 37 | LDAP_REQ_DELETE: 0x4a, 38 | LDAP_REQ_MODRDN: 0x6c, 39 | LDAP_REQ_COMPARE: 0x6e, 40 | LDAP_REQ_ABANDON: 0x50, 41 | LDAP_REQ_EXTENSION: 0x77, 42 | 43 | LDAP_REP_BIND: 0x61, 44 | LDAP_REP_SEARCH_ENTRY: 0x64, 45 | LDAP_REP_SEARCH_REF: 0x73, 46 | LDAP_REP_SEARCH: 0x65, 47 | LDAP_REP_MODIFY: 0x67, 48 | LDAP_REP_ADD: 0x69, 49 | LDAP_REP_DELETE: 0x6b, 50 | LDAP_REP_MODRDN: 0x6d, 51 | LDAP_REP_COMPARE: 0x6f, 52 | LDAP_REP_EXTENSION: 0x78, 53 | }; 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-ldap", 3 | "version": "3.5.1", 4 | "description": "NestJS library to access LDAP", 5 | "author": "Stanislav V Vyaliy", 6 | "license": "MIT", 7 | "keywords": [ 8 | "nestjs", 9 | "nest", 10 | "ldap" 11 | ], 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/wisekaa03/nestjs-ldap.git" 15 | }, 16 | "scripts": { 17 | "build": "rimraf ./dist && tsc -p tsconfig.json", 18 | "prepublish": "yarn build", 19 | "publish-public": "yarn publish --access public", 20 | "test": "jest", 21 | "clean": "rm -rf node_modules yarn.lock", 22 | "lint": "node ./node_modules/eslint/bin/eslint.js lib/**" 23 | }, 24 | "resolutions": { 25 | "**/**/redis-commands": "^1", 26 | "**/**/rimraf": "^3" 27 | }, 28 | "dependencies": { 29 | "@nestjs/common": "^8.4.7", 30 | "bcrypt": "^5.0.1", 31 | "cache-manager": "^4.0.1", 32 | "cache-manager-ioredis": "^2.1.0", 33 | "ldapjs": "^2.3.3", 34 | "rxjs": "^7.5.5" 35 | }, 36 | "devDependencies": { 37 | "@nestjs/config": "^2.1.0", 38 | "@nestjs/core": "^8.4.7", 39 | "@nestjs/testing": "^8.4.7", 40 | "@types/asn1": "^0.2.0", 41 | "@types/bcrypt": "^5.0.0", 42 | "@types/cache-manager": "^4.0.0", 43 | "@types/cache-manager-ioredis": "^2.0.2", 44 | "@types/ldapjs": "^2.2.2", 45 | "@types/node": "^18.0.0", 46 | "@typescript-eslint/eslint-plugin": "^5.29.0", 47 | "@typescript-eslint/parser": "^5.29.0", 48 | "eslint": "^8.18.0", 49 | "eslint-config-prettier": "^8.5.0", 50 | "eslint-plugin-jest": "^26.5.3", 51 | "eslint-plugin-prettier": "^4.0.0", 52 | "jest": "^28.1.1", 53 | "jest-transform-stub": "^2.0.0", 54 | "prettier": "^2.7.1", 55 | "reflect-metadata": "^0.1.13", 56 | "rimraf": "^3.0.2", 57 | "ts-jest": "^28.0.5", 58 | "tsconfig-paths": "^4.0.0", 59 | "typescript": "^4.7.4" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "target": "es2020", 6 | "lib": ["es2018", "es2020", "esnext"], 7 | "jsx": "react", 8 | "allowJs": true, 9 | "allowSyntheticDefaultImports": true, 10 | "esModuleInterop": true, 11 | "experimentalDecorators": true, 12 | "noImplicitAny": true, 13 | "sourceMap": true 14 | }, 15 | "include": ["typings/global.d.ts", "lib"], 16 | "exclude": ["dist", "node_modules", ".local"] 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "strict": true, 5 | "pretty": true, 6 | "declaration": true, 7 | "alwaysStrict": true, 8 | "removeComments": true, 9 | "noImplicitAny": false, 10 | "noImplicitThis": false, 11 | 12 | "sourceMap": true, 13 | "inlineSources": true, 14 | 15 | "outDir": "./dist", 16 | "moduleResolution": "node", 17 | 18 | "emitDecoratorMetadata": true, 19 | "experimentalDecorators": true, 20 | 21 | "allowSyntheticDefaultImports": true, 22 | "esModuleInterop": true, 23 | 24 | "noLib": false, 25 | "target": "es2020", 26 | "skipLibCheck": true, 27 | 28 | "rootDir": "./lib" 29 | }, 30 | "include": ["typings/global.d.ts", "lib"], 31 | "exclude": [ 32 | "node_modules", 33 | "test", 34 | "dist", 35 | "__mocks__", 36 | "**/*spec.ts", 37 | "jest.setup.ts" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /typings/global.d.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | /* eslint max-len:0, no-underscore-dangle:0, @typescript-eslint/ban-types:0, @typescript-eslint/no-explicit-any:0, @typescript-eslint/no-empty-interface:0 */ 3 | 4 | declare const __DEV__: boolean; 5 | declare const __PRODUCTION__: boolean; 6 | declare const __TEST__: boolean; 7 | declare const __SERVER__: boolean; 8 | 9 | declare module 'cache-manager-redis-store'; 10 | 11 | declare namespace NodeJS { 12 | interface GlobalFetch {} 13 | interface Global extends NodeJS.Global, GlobalFetch { 14 | fetch: Function; 15 | __SERVER__?: boolean; 16 | __DEV__?: boolean; 17 | __PRODUCTION__?: boolean; 18 | __TEST__?: boolean; 19 | } 20 | } 21 | 22 | export declare global { 23 | interface globalThis { 24 | __SERVER__?: boolean; 25 | __DEV__?: boolean; 26 | __PRODUCTION__?: boolean; 27 | __TEST__?: boolean; 28 | } 29 | } 30 | 31 | declare module '*.woff2' { 32 | const content: string; 33 | const className: string; 34 | export = content; 35 | } 36 | 37 | declare module '*.svg' { 38 | const content: string; 39 | const className: string; 40 | export = content; 41 | } 42 | 43 | declare module '*.svg?inline' { 44 | const content: string; 45 | const className: string; 46 | export = content; 47 | } 48 | 49 | declare module '*.png' { 50 | const content: string; 51 | const className: string; 52 | export = content; 53 | } 54 | 55 | declare module '*.png?inline' { 56 | const content: string; 57 | const className: string; 58 | export = content; 59 | } 60 | 61 | declare module '*.webp' { 62 | const content: string; 63 | const className: string; 64 | export = content; 65 | } 66 | declare module '*.jpg' { 67 | const content: string; 68 | const className: string; 69 | export = content; 70 | } 71 | 72 | declare module '*.jpeg' { 73 | const content: string; 74 | const className: string; 75 | export = content; 76 | } 77 | 78 | declare module '*.gif' { 79 | const content: string; 80 | const className: string; 81 | export default content; 82 | } 83 | --------------------------------------------------------------------------------