@mntu/nestjs-ldap
Version:
NestJS library to access LDAP
594 lines (553 loc) • 19.9 kB
text/typescript
//#region Imports NPM
import { Inject, Injectable, LoggerService, Logger } from '@nestjs/common';
import CacheManager from 'cache-manager';
import RedisStore from 'cache-manager-ioredis';
import { parse as urlLibParse } from 'url';
import bcrypt from 'bcrypt';
//#endregion
//#region Imports Local
import type {
LdapModuleOptions,
LDAPCache,
LdapResponseUser,
LdapResponseGroup,
LdapAddEntry,
LoggerContext,
} from './ldap.interface';
import { LDAP_OPTIONS } from './ldap.interface';
import { Change } from './ldap/change';
import { LdapDomain } from './ldap.class';
import * as Ldap from "ldapjs";
//#endregion
const LDAP_PASSWORD_NULL =
'2058e76c5f3d68e12d7eec7e334fece75b0552edc5348f85c7889404d9211a36';
()
export class LdapService {
public ldapDomains: LdapDomain[];
private logger: LoggerService;
private cache?: CacheManager.Cache;
private cacheSalt: string;
private cacheTtl: number;
/**
* Create an LDAP class.
*
* @param {LdapModuleOptions} opts Config options
* @param {LogService} logger Logger service
* @param {ConfigService} configService Config service
* @constructor
*/
constructor(
(LDAP_OPTIONS) private readonly options: LdapModuleOptions,
) {
this.logger = options.logger;
if (options.cacheUrl || options.cache) {
this.cacheTtl = options.cacheTtl || 600;
this.cacheSalt = bcrypt.genSaltSync(6);
if (options.cache) {
this.cache = CacheManager.caching({
store: RedisStore,
redisInstance: options.cache,
keyPrefix: 'LDAP:',
ttl: this.cacheTtl,
});
} else if (options.cacheUrl) {
const redisArray = urlLibParse(options.cacheUrl);
if (
redisArray &&
(redisArray.protocol === 'redis:' ||
redisArray.protocol === 'rediss:')
) {
let username: string | undefined;
let password: string | undefined;
const db = parseInt(
redisArray.pathname?.slice(1) || '0',
10,
);
if (redisArray.auth) {
[username, password] = redisArray.auth.split(':');
}
this.cache = CacheManager.caching({
store: RedisStore,
host: redisArray.hostname,
port: parseInt(redisArray.port || '6379', 10),
username,
password,
db,
keyPrefix: 'LDAP:',
ttl: this.cacheTtl,
});
}
}
if (this.cache?.store) {
this.logger.debug!({
message: 'Redis connection: success',
context: LdapService.name,
function: 'constructor',
});
} else {
this.logger.error({
message: 'Redis connection: some error',
context: LdapService.name,
function: 'constructor',
});
}
} else {
this.cacheSalt = '';
this.cacheTtl = 0;
}
this.ldapDomains = this.options.domains.map(
(opts) => new LdapDomain(opts, this.logger),
);
}
/**
* Search user by Username
*
* @async
* @param {string} userByUsername user name
* @returns {Promise<LdapResponseUser>} User in LDAP
*/
public async searchByUsername({
username,
domain,
cache = true,
loggerContext,
}: {
username: string;
domain: string;
cache?: boolean;
loggerContext?: LoggerContext;
}): Promise<LdapResponseUser | undefined> {
const cachedID = `user:${domain}:${username}`;
if (cache && this.cache) {
// Check cache. 'cached' is `{password: <hashed-password>, user: <user>}`.
const cached = await this.cache.get<LDAPCache>(cachedID);
if (cached && cached.user && cached.user.sAMAccountName) {
this.logger.debug!({
message: `From cache: ${cached.user.sAMAccountName}`,
context: LdapService.name,
function: 'searchByUsername',
...loggerContext,
});
return cached.user as LdapResponseUser;
}
}
const domainLdap = this.ldapDomains.find(
(value) => value.domainName === domain,
);
if (!domainLdap) {
this.logger.debug!({
message: `Domain does not exist: ${domain}`,
context: LdapService.name,
function: 'searchByUsername',
...loggerContext,
});
throw new Error(`Domain does not exist: ${domain}`);
}
return domainLdap
.searchByUsername({ username, loggerContext })
.then((user) => {
if (user && this.cache) {
this.logger.debug!({
message: `To cache from domain ${domain}: ${user.dn}`,
context: LdapService.name,
function: 'searchByUsername',
...loggerContext,
});
this.cache.set<LDAPCache>(
`dn:${domain}:${user.dn}`,
{ user, password: LDAP_PASSWORD_NULL },
{ ttl: this.cacheTtl },
);
if (user.sAMAccountName) {
this.logger.debug!({
message: `To cache from domain ${domain}: ${user.sAMAccountName}`,
context: LdapService.name,
function: 'searchByUsername',
...loggerContext,
});
this.cache.set<LDAPCache>(
`user:${domain}:${user.sAMAccountName}`,
{ user, password: LDAP_PASSWORD_NULL },
{ ttl: this.cacheTtl },
);
}
}
return user;
});
}
/**
* Search user by DN
*
* @async
* @param {string} userByDN user distinguished name
* @returns {Promise<LdapResponseUser>} User in LDAP
*/
public async searchByDN({
dn,
domain,
cache = true,
loggerContext,
}: {
dn: string;
domain: string;
cache?: boolean;
loggerContext?: LoggerContext;
}): Promise<LdapResponseUser | null | undefined> {
if (!domain || !dn) {
throw new Error(`Arguments domain=${domain}, userByDN=${dn}`);
}
const cachedID = `dn:${domain}:${dn}`;
if (cache && this.cache) {
// Check cache. 'cached' is `{password: <hashed-password>, user: <user>}`.
const cached = await this.cache.get<LDAPCache>(cachedID);
if (cached?.user.dn) {
this.logger.debug!({
message: `From cache: ${cached.user.dn}`,
context: LdapService.name,
function: 'searchByDN',
...loggerContext,
});
return cached.user as LdapResponseUser;
}
}
const domainLdap = this.ldapDomains.find(
(value) => value.domainName === domain,
);
if (!domainLdap) {
this.logger.debug!({
message: `Domain does not exist: ${domain}`,
context: LdapService.name,
function: 'searchByDN',
...loggerContext,
});
throw new Error(`Domain does not exist: ${domain}`);
}
return domainLdap.searchByDN({ dn, loggerContext }).then((user) => {
if (user && this.cache) {
this.logger.debug!({
message: `To cache, domain "${domain}": ${user.dn}`,
context: LdapService.name,
function: 'searchByDN',
...loggerContext,
});
this.cache.set<LDAPCache>(
cachedID,
{ user, password: LDAP_PASSWORD_NULL },
{ ttl: this.cacheTtl },
);
if (user.sAMAccountName) {
this.logger.debug!({
message: `To cache, domain "${domain}": ${user.sAMAccountName}`,
context: LdapService.name,
function: 'searchByDN',
...loggerContext,
});
this.cache.set<LDAPCache>(
`user:${domain}:${user.sAMAccountName}`,
{ user, password: LDAP_PASSWORD_NULL },
{ ttl: this.cacheTtl },
);
}
}
return user;
}).catch((error) => {
if (error instanceof Ldap.NoSuchObjectError) {
return null;
} else {
throw error;
}
});
}
/**
* Synchronize users
*
* @async
* @returns {Record<string, LdapResponseUser[]>} User in LDAP
* @throws {Error}
*/
public async synchronization({
loggerContext,
}: {
loggerContext?: LoggerContext;
}): Promise<Record<string, Error | LdapResponseUser[]>> {
return Promise.all(
this.ldapDomains
.filter((domain) => !domain.hideSynchronization)
.map(async (domain) =>
domain.synchronization({ loggerContext }),
),
).then((promise) =>
promise.reduce(
(accumulator, domain) => ({ ...accumulator, ...domain }),
{},
),
);
}
/**
* Synchronize users
*
* @async
* @returns {Record<string, LdapResponseGroup[]>} Group in LDAP
* @throws {Error}
*/
public async synchronizationGroups({
loggerContext,
}: {
loggerContext?: LoggerContext;
}): Promise<Record<string, Error | LdapResponseGroup[]>> {
return Promise.all(
this.ldapDomains
.filter((domain) => !domain.hideSynchronization)
.map(async (domain) =>
domain.synchronizationGroups({ loggerContext }),
),
).then((promise) =>
promise.reduce(
(accumulator, domain) => ({ ...accumulator, ...domain }),
{},
),
);
}
/**
* Modify using the admin client.
*
* @public
* @async
* @param {string} dn LDAP Distiguished Name
* @param {Change[]} data LDAP modify data
* @param {string} username The optional parameter
* @param {string} password The optional parameter
* @returns {boolean} The result
* @throws {Ldap.Error}
*/
public async modify({
dn,
data,
domain,
username,
password,
loggerContext,
}: {
dn: string;
data: Change[];
domain: string;
username?: string;
password?: string;
loggerContext?: LoggerContext;
}): Promise<boolean> {
const domainLdap = this.ldapDomains.find(
(value) => value.domainName === domain,
);
if (!domainLdap) {
this.logger.debug!({
message: `Domain does not exist: ${domain}`,
context: LdapService.name,
function: 'modify',
...loggerContext,
});
throw new Error(`Domain does not exist: ${domain}`);
}
return domainLdap.modify({
dn,
data,
username,
password,
loggerContext,
});
}
/**
* Authenticate given credentials against LDAP server
*
* @async
* @param {string} username The username to authenticate
* @param {string} password The password to verify
* @param {string} domain The domain to check
* @returns {LdapResponseUser} User in LDAP
* @throws {Error}
*/
public async authenticate({
username,
password,
domain,
cache = true,
loggerContext,
}: {
username: string;
password: string;
domain: string;
cache?: boolean;
loggerContext?: LoggerContext;
}): Promise<LdapResponseUser> {
if (!password) {
this.logger.error({
message: `${domain}: No password given`,
error: `${domain}: No password given`,
context: LdapService.name,
function: 'authenticate',
...loggerContext,
});
throw new Error('No password given');
}
const domainLdap = this.ldapDomains.find(
(value) => value.domainName === domain,
);
if (!domainLdap) {
this.logger.debug!({
message: `Domain does not exist: ${domain}`,
context: LdapService.name,
function: 'authenticate',
...loggerContext,
});
throw new Error(`Domain does not exist: ${domain}`);
}
const cachedID = `user:${domain}:${username}`;
if (cache && this.cache) {
// Check cache. 'cached' is `{password: <hashed-password>, user: <user>}`.
const cached = await this.cache.get<LDAPCache>(cachedID);
if (
cached?.user?.sAMAccountName &&
(cached?.password === LDAP_PASSWORD_NULL ||
bcrypt.compareSync(password, cached.password))
) {
this.logger.debug!({
message: `From cache ${domain}: ${cached.user.sAMAccountName}`,
context: LdapService.name,
function: 'authenticate',
...loggerContext,
});
(async (): Promise<void> => {
try {
const user = await domainLdap.authenticate({
username,
password,
loggerContext,
});
if (
JSON.stringify(user) !==
JSON.stringify(cached.user) &&
this.cache
) {
this.logger.debug!({
message: `To cache from domain ${domain}: ${user.sAMAccountName}`,
context: LdapService.name,
function: 'authenticate',
...loggerContext,
});
this.cache.set<LDAPCache>(
`user:${domain}:${user.sAMAccountName}`,
{
user,
password: bcrypt.hashSync(
password,
this.cacheSalt,
),
},
{ ttl: this.cacheTtl },
);
}
} catch (error) {
const errorMessage =
error instanceof Error
? error.toString()
: JSON.stringify(error);
this.logger.error({
message: `LDAP auth error [${domain}]: ${errorMessage}`,
error,
context: LdapService.name,
function: 'authenticate',
...loggerContext,
});
}
})();
return cached.user;
}
}
return domainLdap
.authenticate({
username,
password,
loggerContext,
})
.then((user) => {
if (this.cache) {
this.logger.debug!({
message: `To cache from domain ${domain}: ${user.sAMAccountName}`,
context: LdapService.name,
function: 'authenticate',
...loggerContext,
});
this.cache.set<LDAPCache>(
`user:${domain}:${user.sAMAccountName}`,
{
user,
password: bcrypt.hashSync(password, this.cacheSalt),
},
{ ttl: this.cacheTtl },
);
}
return user;
});
}
/**
* Trusted domain
*
* @async
* @returns {LdapTrustedDomain} ?
* @throws {Error}
*/
public async trustedDomain({
searchBase,
domain,
loggerContext,
}: {
searchBase: string;
domain: string;
loggerContext?: LoggerContext;
}): Promise<any> {
const trustedDomain = '';
return trustedDomain;
}
/**
* This is add a LDAP object
*
* @async
* @param {Record<string, string>} value
* @returns {LdapResponseUser} User | Profile in LDAP
* @throws {Error}
*/
public async add({
entry,
domain,
loggerContext,
}: {
entry: LdapAddEntry;
domain: string;
loggerContext?: LoggerContext;
}): Promise<LdapResponseUser> {
const domainLdap = this.ldapDomains.find(
(value) => value.domainName === domain,
);
if (!domainLdap) {
this.logger.debug!({
message: `Domain does not exist: ${domain}`,
context: LdapService.name,
function: 'add',
...loggerContext,
});
throw new Error(`Domain does not exist: ${domain}`);
}
return domainLdap.add({ entry, loggerContext });
}
/**
* Unbind connections
*
* @async
* @returns {Promise<boolean[]>}
*/
public async close(): Promise<boolean[]> {
const promiseDomain = this.ldapDomains.map(async (domain) =>
domain.close(),
);
return Promise.all(promiseDomain).then((values) => values || []);
}
}