diff --git a/README.md b/README.md index 684098ef..2870678e 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ scope | String | One of base, one, or sub. Defaults to base filter | String | A string version of an LDAP filter. Defaults to (objectclass=*) attributes | Array of String | attributes to select and return. Defaults to the empty set, which means all attributes sizeLimit | Number | the maximum number of entries to return. Defaults to 0 (unlimited) +pageSize | Number | Page size for paged search. If this attribute is set, search results are retrieved in pages. ActiveDirectory has a default limit of 1000 returned entries. If a larger number of results is expected, this attribute should be set timeLimit | Number | the maximum amount of time the server should take in responding, in seconds. Defaults to 10. Lots of servers will ignore this typesOnly | Boolean | on whether you want the server to only return the names of the attributes, and not their values. Borderline useless. Defaults to false diff --git a/__test__/client.spec.js b/__test__/client.spec.js index 3db8e5db..306c3562 100644 --- a/__test__/client.spec.js +++ b/__test__/client.spec.js @@ -151,6 +151,22 @@ describe('Client', () => { await client.destroy(); }); + it('paged search', async () => { + expect.assertions(4); + + const client = new Client({ url }); + + await client.bind(user, password); + const response = await client.search('ou=scientists,dc=example,dc=com', { scope: 'sub', pageSize: 1 }); + + expect(response.length).toBeGreaterThan(0); + expect(response[0].dn).toBeDefined(); + expect(response[0].ou).toBe('scientists'); + expect(response[0].objectClass.length).toBeGreaterThan(0); + + await client.destroy(); + }); + xit('unbind', async () => { expect.assertions(4); diff --git a/package-lock.json b/package-lock.json index c4a1b13e..379e90db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "ldapjs-client", - "version": "0.1.2", + "version": "0.1.5", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "ldapjs-client", - "version": "0.1.2", + "version": "0.1.5", "license": "MIT", "dependencies": { "asn1": "0.2.6", - "assert-plus": "^1.0.0", + "assert-plus": "1.0.0", "ldap-filter": "0.3.3" }, "devDependencies": { diff --git a/src/attribute.js b/src/attribute.js index 565b43e8..a47959e8 100644 --- a/src/attribute.js +++ b/src/attribute.js @@ -2,7 +2,7 @@ const assert = require('assert-plus'); const asn1 = require('asn1'); const Protocol = require('./utils/protocol'); -const _bufferEncoding = type => /;binary$/.test(type) ? 'base64' : 'utf8'; +const _bufferEncoding = type => type.endsWith(';binary') ? 'base64' : 'utf8'; class Attribute { constructor(options) { @@ -43,7 +43,7 @@ class Attribute { if (Buffer.isBuffer(val)) { this._vals.push(val); } else { - this._vals.push(new Buffer(String(val), _bufferEncoding(this.type))); + this._vals.push(Buffer.from(String(val), _bufferEncoding(this.type))); } } diff --git a/src/filters/presence_filter.js b/src/filters/presence_filter.js index b3e6f983..9ed57e4b 100644 --- a/src/filters/presence_filter.js +++ b/src/filters/presence_filter.js @@ -17,7 +17,7 @@ module.exports = class PresenceFilter extends parents.PresenceFilter { assert.ok(ber instanceof BerWriter, 'ber (BerWriter) required'); ber.startSequence(FILTER_PRESENT); - new Buffer(this.attribute).forEach(i => ber.writeByte(i)); + Buffer.from(this.attribute).forEach(i => ber.writeByte(i)); ber.endSequence(); return ber; diff --git a/src/index.js b/src/index.js index c5318181..dc3fae4b 100644 --- a/src/index.js +++ b/src/index.js @@ -8,6 +8,7 @@ const { getError, ConnectionError, TimeoutError, ProtocolError, LDAP_SUCCESS } = const { Add, Bind, Del, Modify, ModifyDN, Search, Unbind } = require('./requests'); const { Response, SearchEntry, SearchReference, Parser } = require('./responses'); const parseUrl = require('./utils/parse-url'); +const OID = require('./utils/OID'); class Client { constructor(options) { @@ -29,13 +30,16 @@ class Client { } else { const qItem = this._queue.get(msg.id); if (qItem) { - const { resolve, reject, result, request } = qItem; + const { resolve, reject, result, request, controls } = qItem; if (msg instanceof Response) { if (msg.status !== LDAP_SUCCESS) { reject(getError(msg)); } + controls.length = 0; + msg.controls.forEach((control) => controls.push(control)); + resolve(request instanceof Search ? result : msg.object); } else if (msg instanceof Error) { reject(msg); @@ -49,37 +53,37 @@ class Client { }); } - async add(entry, attributes) { + async add(entry, attributes, controls = []) { assert.string(entry, 'entry'); assert.object(attributes, 'attributes'); - return this._send(new Add({ entry, attributes: Attribute.fromObject(attributes) })); + return this._send(new Add({ entry, attributes: Attribute.fromObject(attributes), controls })); } - async bind(name, credentials) { + async bind(name, credentials, controls = []) { assert.string(name, 'name'); assert.optionalString(credentials, 'credentials'); - return this._send(new Bind({ name, credentials })); + return this._send(new Bind({ name, credentials, controls })); } - async del(entry) { + async del(entry, controls = []) { assert.string(entry, 'entry'); - return this._send(new Del({ entry })); + return this._send(new Del({ entry, controls })); } - async modify(entry, change) { + async modify(entry, change, controls = []) { assert.string(entry, 'entry'); assert.object(change, 'change'); const changes = []; (Array.isArray(change) ? change : [change]).forEach(c => changes.push(...Change.fromObject(c))); - return this._send(new Modify({ entry, changes })); + return this._send(new Modify({ entry, changes, controls })); } - async modifyDN(entry, newName) { + async modifyDN(entry, newName, controls = []) { assert.string(entry, 'entry'); assert.string(newName, 'newName'); @@ -88,24 +92,66 @@ class Client { if (newRdn.rdns.length !== 1) { return this._send(new ModifyDN({ entry, newRdn: parse(newRdn.rdns.shift().toString()), newSuperior: newRdn })); } else { - return this._send(new ModifyDN({ entry, newRdn })); + return this._send(new ModifyDN({ entry, newRdn, controls })); } } - async search(baseObject, options) { + async search(baseObject, options, controls = []) { assert.string(baseObject, 'baseObject'); assert.object(options, 'options'); assert.optionalString(options.scope, 'options.scope'); assert.optionalString(options.filter, 'options.filter'); assert.optionalNumber(options.sizeLimit, 'options.sizeLimit'); + assert.optionalNumber(options.pageSize, 'options.pageSize'); assert.optionalNumber(options.timeLimit, 'options.timeLimit'); assert.optionalArrayOfString(options.attributes, 'options.attributes'); - return this._send(new Search(Object.assign({ baseObject }, options))); + if (options.pageSize) { + let pageSize = options.pageSize; + if (pageSize > options.sizeLimit) pageSize = options.sizeLimit; + + const pagedResults = { + OID: OID.PagedResults, + criticality: true, + value: { + size: pageSize, + cookie: '' + } + }; + + controls = controls.filter((control) => { + return control.OID !== OID.PagedResults; + }); + controls.push(pagedResults); + + let cookie = ''; + const results = []; + let hasNext = true; + while (hasNext) { + pagedResults.value.cookie = cookie; + + results.push(await this._send(new Search(Object.assign({ baseObject, controls }, options)))); + + const responsePagedResults = controls.find((control) => { + return control.OID === OID.PagedResults; + }); + + if (responsePagedResults !== undefined) { + cookie = responsePagedResults.value.cookie; + } else { + hasNext = false; + } + } + + return results.flat(); + + } else { + return this._send(new Search(Object.assign({ baseObject, controls }, options))); + } } - async unbind() { - return this._send(new Unbind()); + async unbind(controls = []) { + return this._send(new Unbind({controls})); } async destroy() { @@ -169,7 +215,7 @@ class Client { return new Promise((resolve, reject) => { try { - this._queue.set(message.id, { resolve, reject, request: message, result: [] }); + this._queue.set(message.id, { resolve, reject, request: message, result: [], controls: message.controls }); this._socket.write(message.toBer()); if (message instanceof Unbind) { diff --git a/src/requests/del_request.js b/src/requests/del_request.js index d7e2d9dc..6a5ae315 100644 --- a/src/requests/del_request.js +++ b/src/requests/del_request.js @@ -10,7 +10,7 @@ module.exports = class extends Request { } _toBer(ber) { - new Buffer(this.entry).forEach(i => ber.writeByte(i)); + Buffer.from(this.entry).forEach(i => ber.writeByte(i)); return ber; } diff --git a/src/requests/request.js b/src/requests/request.js index 66977969..cb341e19 100644 --- a/src/requests/request.js +++ b/src/requests/request.js @@ -1,4 +1,7 @@ -const { BerWriter } = require('asn1'); +const asn1 = require('asn1'); +const BerWriter = asn1.BerWriter; +const { LDAP_CONTROLS } = require('../utils/protocol'); +const OID = require('../utils/OID'); let id = 0; const nextID = () => { @@ -6,6 +9,32 @@ const nextID = () => { return id; }; +const controlToBer = (control, writer) => { + writer.startSequence(); + writer.writeString(control.OID); + writer.writeBoolean(control.criticality); + + const ber = new BerWriter(); + ber.startSequence(); + switch (control.OID) { + case OID.PagedResults: + ber.writeInt(control.value.size); + if (control.value.cookie === '') { + ber.writeString(''); + } else { + ber.writeBuffer(control.value.cookie, asn1.Ber.OctetString); + } + break; + // Add New OID controls here + default: + } + + ber.endSequence(); + writer.writeBuffer(ber.buffer, 0x04); + + writer.endSequence(); +}; + module.exports = class { constructor(options) { Object.assign(this, options, { id: nextID() }); @@ -18,6 +47,15 @@ module.exports = class { writer.startSequence(this.protocolOp); writer = this._toBer(writer); writer.endSequence(); + + if (this.controls.length > 0) { + writer.startSequence(LDAP_CONTROLS); + this.controls.forEach((control) => { + controlToBer(control, writer); + }); + writer.endSequence(); + } + writer.endSequence(); return writer.buffer; } diff --git a/src/responses/response.js b/src/responses/response.js index cbdb4e3e..94bb5e24 100644 --- a/src/responses/response.js +++ b/src/responses/response.js @@ -1,5 +1,45 @@ const assert = require('assert-plus'); -const { LDAP_REP_REFERRAL } = require('../utils/protocol'); +const asn1 = require('asn1'); +const Ber = asn1.Ber; +const BerReader = asn1.BerReader; +const { LDAP_REP_REFERRAL, LDAP_CONTROLS } = require('../utils/protocol'); +const OID = require('../utils/OID'); + +const getControl = (ber) => { + if (ber.readSequence() === null) { return null; } + + const control = { + OID: '', + criticality: false, + value: null + }; + + if (ber.length) { + const end = ber.offset + ber.length; + + control.OID = ber.readString(); + if (ber.offset < end && ber.peek() === Ber.Boolean) control.criticality = ber.readBoolean(); + + if (ber.offset < end) control.value = ber.readString(Ber.OctetString, true); + + const controlBer = new BerReader(control.value); + switch (control.OID) { + case OID.PagedResults: + controlBer.readSequence(); + control.value = {}; + control.value.size = controlBer.readInt(); + control.value.cookie = controlBer.readString(asn1.Ber.OctetString, true); + if (control.value.cookie.length === 0) { + control.value.cookie = ''; + } + break; + // Add New OID controls here + default: + } + } + + return control; +}; module.exports = class { constructor(options) { @@ -7,8 +47,9 @@ module.exports = class { assert.optionalString(options.matchedDN); assert.optionalString(options.errorMessage); assert.optionalArrayOfString(options.referrals); + assert.optionalArrayOfObject(options.controls); - Object.assign(this, { status: 0, matchedDN: '', errorMessage: '', referrals: [], type: 'Response' }, options); + Object.assign(this, { status: 0, matchedDN: '', errorMessage: '', referrals: [], type: 'Response', controls: [] }, options); } get object() { @@ -27,6 +68,15 @@ module.exports = class { } } + if (ber.peek() === LDAP_CONTROLS) { + ber.readSequence(); + const end = ber.offset + ber.length; + while (ber.offset < end) { + const c = getControl(ber); + if (c) { this.controls.push(c); } + } + } + return true; } }; diff --git a/src/utils/OID.js b/src/utils/OID.js new file mode 100644 index 00000000..4bad9808 --- /dev/null +++ b/src/utils/OID.js @@ -0,0 +1,8 @@ +/** + * @see {@link https://ldap.com/ldap-oid-reference-guide/} + */ +const OID = { + PagedResults: '1.2.840.113556.1.4.319' +}; + +module.exports = OID;