├── .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 |
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 |
--------------------------------------------------------------------------------