diff --git a/src/services/configuration.service.ts b/src/services/configuration.service.ts index 2a30cca6..991062c8 100644 --- a/src/services/configuration.service.ts +++ b/src/services/configuration.service.ts @@ -16,6 +16,8 @@ const Keys = { directoryType: 'directoryType', userDelta: 'userDeltaToken', groupDelta: 'groupDeltaToken', + lastUserSync: 'lastUserSync', + lastGroupSync: 'lastGroupSync', }; export class ConfigurationService { @@ -119,4 +121,28 @@ export class ConfigurationService { return this.storageService.save(Keys.groupDelta, token); } } + + getLastUserSyncDate(): Promise { + return this.storageService.get(Keys.lastUserSync); + } + + saveLastUserSyncDate(date: Date) { + if (date == null) { + return this.storageService.remove(Keys.lastUserSync); + } else { + return this.storageService.save(Keys.lastUserSync, date); + } + } + + getLastGroupSyncDate(): Promise { + return this.storageService.get(Keys.lastGroupSync); + } + + saveLastGroupSyncDate(date: Date) { + if (date == null) { + return this.storageService.remove(Keys.lastGroupSync); + } else { + return this.storageService.save(Keys.lastGroupSync, date); + } + } } diff --git a/src/services/ldap-directory.service.ts b/src/services/ldap-directory.service.ts index ba332f02..03fa2342 100644 --- a/src/services/ldap-directory.service.ts +++ b/src/services/ldap-directory.service.ts @@ -10,12 +10,14 @@ import { UserEntry } from '../models/userEntry'; import { ConfigurationService } from './configuration.service'; import { DirectoryService } from './directory.service'; +import { LogService } from 'jslib/abstractions/log.service'; + export class LdapDirectoryService implements DirectoryService { private client: ldap.Client; private dirConfig: LdapConfiguration; private syncConfig: SyncConfiguration; - constructor(private configurationService: ConfigurationService) { } + constructor(private configurationService: ConfigurationService, private logService: LogService) { } async getEntries(force = false): Promise<[GroupEntry[], UserEntry[]]> { const type = await this.configurationService.getDirectoryType(); @@ -33,54 +35,305 @@ export class LdapDirectoryService implements DirectoryService { return; } - await this.auth(); - await this.getUsers(); + await this.bind(); + + let users: UserEntry[]; + if (this.syncConfig.users) { + users = await this.getUsers(force); + } + + let groups: GroupEntry[]; + if (this.syncConfig.groups) { + let groupForce = force; + if (!groupForce && users != null) { + const activeUsers = users.filter((u) => !u.deleted && !u.disabled); + groupForce = activeUsers.length > 0; + } + groups = await this.getGroups(groupForce); + } + + await this.unbind(); + return [groups, users]; + } + + private async getUsers(force: boolean): Promise { + const lastSync = await this.configurationService.getLastUserSyncDate(); + + let filter = this.buildBaseFilter(this.syncConfig.userObjectClass, this.syncConfig.userFilter); + filter = this.buildRevisionFilter(filter, force, lastSync); + + const path = this.makeSearchPath(this.syncConfig.userPath); + this.logService.info('User search: ' + path + ' => ' + filter); + + const regularUsers = await this.search(path, filter, + (item: any) => this.buildUser(item, false)); + + if (!this.dirConfig.ad) { + return regularUsers; + } + + let deletedFilter = this.buildBaseFilter(this.syncConfig.userObjectClass, '(isDeleted=TRUE)'); + deletedFilter = this.buildRevisionFilter(deletedFilter, force, lastSync); + + const deletedPath = this.makeSearchPath('CN=Deleted Objects'); + this.logService.info('Deleted user search: ' + deletedPath + ' => ' + deletedFilter); + + const deletedUsers = await this.search(deletedPath, deletedFilter, + (item: any) => this.buildUser(item, true)); + return regularUsers.concat(deletedUsers); + } + + private buildUser(item: any, deleted: boolean): UserEntry { + const user = new UserEntry(); + user.referenceId = item.objectName; + user.deleted = deleted; + + if (user.referenceId == null) { + return null; + } + + user.externalId = this.getExternalId(item, user.referenceId); + user.disabled = this.entryDisabled(item); + user.email = this.getAttr(item, this.syncConfig.userEmailAttribute); + if (user.email == null && this.syncConfig.useEmailPrefixSuffix && + this.syncConfig.emailPrefixAttribute != null && this.syncConfig.emailSuffix != null) { + const prefixAttr = this.getAttr(item, this.syncConfig.emailPrefixAttribute); + if (prefixAttr != null) { + user.email = (prefixAttr + this.syncConfig.emailSuffix).toLowerCase(); + } + } + + if (!user.deleted && (user.email == null || user.email.trim() === '')) { + return null; + } + + // TODO: dates + user.revisonDate = new Date(); + user.creationDate = new Date(); + + return user; + } + + private async getGroups(force: boolean): Promise { + const entries: GroupEntry[] = []; + + const lastSync = await this.configurationService.getLastUserSyncDate(); + + const originalFilter = this.buildBaseFilter(this.syncConfig.groupObjectClass, this.syncConfig.groupFilter); + let filter = originalFilter; + const revisionFilter = this.buildRevisionFilter(filter, force, lastSync); + const searchSinceRevision = filter !== revisionFilter; + filter = revisionFilter; + + const path = this.makeSearchPath(this.syncConfig.groupPath); + this.logService.info('Group search: ' + path + ' => ' + filter); + + let items: any[] = []; + const initialSearchGroupIds = await this.search(path, filter, (item: any) => { + items.push(item); + return item.objectName; + }); + + if (searchSinceRevision && initialSearchGroupIds.length === 0) { + return []; + } else if (searchSinceRevision) { + items = await this.search(path, originalFilter, (item: any) => item); + } + + const userFilter = this.buildBaseFilter(this.syncConfig.userObjectClass, this.syncConfig.userFilter); + const userPath = this.makeSearchPath(this.syncConfig.userPath); + + const userIdMap = new Map(); + await this.search(path, filter, (item: any) => { + userIdMap.set(item.objectName, this.getExternalId(item, item.objectName)); + return null; + }); + + items.forEach((item) => { + const group = this.buildGroup(item, userIdMap); + if (group != null) { + entries.push(group); + } + }); + + return entries; + } + + private buildGroup(item: any, userMap: Map) { + const group = new GroupEntry(); + group.referenceId = item.objectName; + if (group.referenceId == null) { + return null; + } + + group.externalId = this.getExternalId(item, group.referenceId); + + group.name = this.getAttr(item, this.syncConfig.groupNameAttribute); + if (group.name == null) { + group.name = this.getAttr(item, 'cn'); + } + + if (group.name == null) { + return null; + } + + // TODO: dates + group.revisonDate = new Date(); + group.creationDate = new Date(); + + const members = this.getAttrVals(item, this.syncConfig.memberAttribute); + if (members != null) { + members.forEach((memDn) => { + if (userMap.has(memDn) && !group.userMemberExternalIds.has(userMap.get(memDn))) { + group.userMemberExternalIds.add(userMap.get(memDn)); + } else if (!group.groupMemberReferenceIds.has(memDn)) { + group.groupMemberReferenceIds.add(memDn); + } + }); + } + + return group; + } + + private getExternalId(item: any, referenceId: string) { + let externalId = this.getAttr(item, 'objectGUID'); // from guid to string? + if (externalId == null) { + externalId = referenceId; + } + return externalId; + } + + private buildBaseFilter(objectClass: string, subFilter: string): string { + let filter = this.buildObjectClassFilter(objectClass); + if (subFilter != null && subFilter.trim() !== '') { + filter = '(&' + filter + subFilter + ')'; + } + return filter; + } + + private buildObjectClassFilter(objectClass: string): string { + return '(&(objectClass=' + objectClass + '))'; + } + + private buildRevisionFilter(baseFilter: string, force: boolean, lastRevisionDate: Date) { + const revisionAttr = this.syncConfig.revisionDateAttribute; + if (!force && lastRevisionDate != null && revisionAttr != null && revisionAttr.trim() !== '') { + const dateString = lastRevisionDate.toISOString().replace(/[-:T]/g, '').substr(0, 16) + 'Z'; + baseFilter = '(&' + baseFilter + '(' + revisionAttr + '>=' + dateString + '))'; + } + + return baseFilter; + } + + private makeSearchPath(pathPrefix: string) { + if (this.dirConfig.rootPath != null && this.dirConfig.rootPath.trim() !== '') { + const trimmedRootPath = this.dirConfig.rootPath.trim().toLowerCase(); + let path = trimmedRootPath.substr(trimmedRootPath.indexOf('dc=')); + if (pathPrefix != null && pathPrefix.trim() !== '') { + path = pathPrefix.trim() + ',' + path; + } + return path; + } return null; } - private getUsers() { + private getAttrVals(searchEntry: any, attr: string): string[] { + if (searchEntry == null || searchEntry.attributes == null) { + return null; + } + + const attrs = searchEntry.attributes.filter((a: any) => a.type === attr); + if (attrs == null || attrs.length === 0 || attrs[0].vals == null || attrs[0].vals.length === 0) { + return null; + } + + return attrs[0].vals; + } + + private getAttr(searchEntry: any, attr: string): string { + const vals = this.getAttrVals(searchEntry, attr); + if (vals == null) { + return null; + } + return vals[0]; + } + + private entryDisabled(searchEntry: any): boolean { + const control = this.getAttr(searchEntry, 'userAccountControl'); + if (control == null) { + return false; + } + + // TODO + return false; + } + + private async search(path: string, filter: string, processEntry: (searchEntry: any) => T): Promise { const options: ldap.SearchOptions = { - filter: null, + filter: filter, scope: 'sub', - attributes: ['dn', 'sn', 'cn'], + paged: true, }; - return new Promise((resolve, reject) => { - this.client.search('dc=example,dc=com', options, (err, res) => { + const entries: T[] = []; + return new Promise((resolve, reject) => { + this.client.search(path, options, (err, res) => { if (err != null) { - console.error('search error: ' + err); reject(err); return; } - res.on('searchEntry', (entry) => { - console.log(entry); - }); - res.on('searchReference', (referral) => { - console.log('referral: ' + referral.uris.join()); - }); + res.on('error', (resErr) => { - console.error('error: ' + resErr.message); reject(resErr); }); - res.on('end', (result) => { - console.log('status: ' + result.status); + + res.on('searchEntry', (entry) => { + const e = processEntry(entry); + if (e != null) { + entries.push(e); + } }); - resolve(); + res.on('end', (result) => { + resolve(entries); + }); }); }); } - private async auth() { + private async bind(): Promise { return new Promise((resolve, reject) => { const url = 'ldap' + (this.dirConfig.ssl ? 's' : '') + '://' + this.dirConfig.hostname + ':' + this.dirConfig.port; + this.client = ldap.createClient({ - url: url, + url: url.toLowerCase(), }); - this.client.bind(this.dirConfig.username, this.dirConfig.password, (err) => { + const user = this.dirConfig.username == null || this.dirConfig.username.trim() === '' ? null : + this.dirConfig.username; + const pass = this.dirConfig.password == null || this.dirConfig.password.trim() === '' ? null : + this.dirConfig.password; + + if (user == null && pass == null) { + resolve(); + return; + } + + this.client.bind(user, pass, (err) => { + if (err != null) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + private async unbind(): Promise { + return new Promise((resolve, reject) => { + this.client.unbind((err) => { if (err != null) { reject(err); } else { diff --git a/src/services/sync.service.ts b/src/services/sync.service.ts index 6fa95849..6deafc55 100644 --- a/src/services/sync.service.ts +++ b/src/services/sync.service.ts @@ -39,7 +39,7 @@ export class SyncService { case DirectoryType.AzureActiveDirectory: return new AzureDirectoryService(this.configurationService); case DirectoryType.Ldap: - return new LdapDirectoryService(this.configurationService); + return new LdapDirectoryService(this.configurationService, this.logService); default: return null; }