From 66449eedb0a7257ec35ecc37ad41e258ed0d962e Mon Sep 17 00:00:00 2001 From: Clement Michaud Date: Sat, 7 Oct 2017 13:46:19 +0200 Subject: [PATCH] Use username matcher instead of user dn in group filter Previously, string "{0}" was replaced by the user dn in the groups_filter attributes of the LDAP configuration. However, if the groups children only have a memberUid attribute, one would like to use the username instead of the user dn. Since the user dn can be built from the username, "{0}" is now replaced by the username instead of the user dn so that an LDAP relying on attribute 'memberUid' can be used. --- config.template.yml | 12 +- config.test.yml | 8 +- server/src/lib/ServerVariablesHandler.ts | 13 +- .../lib/configuration/ConfigurationAdapter.ts | 6 +- server/src/lib/ldap/Authenticator.ts | 15 +- server/src/lib/ldap/Client.ts | 133 ++++-------------- server/src/lib/ldap/ClientFactory.ts | 10 +- .../src/lib/ldap/EmailsAndGroupsRetriever.ts | 46 ++++++ server/src/lib/ldap/EmailsRetriever.ts | 4 +- server/src/lib/ldap/GroupsRetriever.ts | 39 +++++ server/src/lib/ldap/IClient.ts | 2 +- server/src/lib/ldap/IEmailsRetriever.ts | 3 +- server/src/lib/ldap/IGroupsRetriever.ts | 6 + server/src/lib/ldap/ILdapClient.ts | 10 ++ server/src/lib/ldap/ILdapClientFactory.ts | 6 + server/src/lib/ldap/LdapClient.ts | 71 ++++++++++ server/src/lib/ldap/LdapClientFactory.ts | 20 +++ .../LdapConfigurationAdaptation.test.ts | 2 +- server/test/ldap/Authenticator.test.ts | 9 +- server/test/ldap/Client.test.ts | 45 ++++++ server/test/ldap/GroupsRetriever.test.ts | 75 ++++++++++ server/test/mocks/ldap/ClientStub.ts | 8 +- .../test/mocks/ldap/LdapClientFactoryStub.ts | 16 +++ server/test/mocks/ldap/LdapClientStub.ts | 33 +++++ test/features/authentication.feature | 6 +- 25 files changed, 444 insertions(+), 154 deletions(-) create mode 100644 server/src/lib/ldap/EmailsAndGroupsRetriever.ts create mode 100644 server/src/lib/ldap/GroupsRetriever.ts create mode 100644 server/src/lib/ldap/IGroupsRetriever.ts create mode 100644 server/src/lib/ldap/ILdapClient.ts create mode 100644 server/src/lib/ldap/ILdapClientFactory.ts create mode 100644 server/src/lib/ldap/LdapClient.ts create mode 100644 server/src/lib/ldap/LdapClientFactory.ts create mode 100644 server/test/ldap/Client.test.ts create mode 100644 server/test/ldap/GroupsRetriever.test.ts create mode 100644 server/test/mocks/ldap/LdapClientFactoryStub.ts create mode 100644 server/test/mocks/ldap/LdapClientStub.ts diff --git a/config.template.yml b/config.template.yml index 85920e8fa..dfc185f03 100644 --- a/config.template.yml +++ b/config.template.yml @@ -23,18 +23,18 @@ ldap: # An additional dn to define the scope to all users additional_users_dn: ou=users - # The users filter. - # {0} is the matcher replaced by username. + # The users filter used to find the user DN + # {0} is a matcher replaced by username. # 'cn={0}' by default. users_filter: cn={0} # An additional dn to define the scope of groups additional_groups_dn: ou=groups - # The groups filter. - # {0} is the matcher replaced by user dn. - # 'member={0}' by default. - groups_filter: (&(member={0})(objectclass=groupOfNames)) + # The groups filter used for retrieving groups of a given user. + # {0} is a matcher replaced by username. + # 'member=cn={0},,' by default. + groups_filter: (&(member=cn={0},ou=users,dc=example,dc=com)(objectclass=groupOfNames)) # The attribute holding the name of the group group_name_attribute: cn diff --git a/config.test.yml b/config.test.yml index 351a1262f..7b9d1e1ff 100644 --- a/config.test.yml +++ b/config.test.yml @@ -31,10 +31,10 @@ ldap: # An additional dn to define the scope of groups additional_groups_dn: ou=groups - # The groups filter. - # {0} is the matcher replaced by user dn. - # 'member={0}' by default. - groups_filter: (&(member={0})(objectclass=groupOfNames)) + # The groups filter used for retrieving groups of a given user. + # {0} is a matcher replaced by username. + # 'member=cn={0},,' by default. + groups_filter: (&(member=cn={0},ou=users,dc=example,dc=com)(objectclass=groupOfNames)) # The attribute holding the name of the group group_name_attribute: cn diff --git a/server/src/lib/ServerVariablesHandler.ts b/server/src/lib/ServerVariablesHandler.ts index ce40b4a49..e014cb2d2 100644 --- a/server/src/lib/ServerVariablesHandler.ts +++ b/server/src/lib/ServerVariablesHandler.ts @@ -1,6 +1,8 @@ import winston = require("winston"); import BluebirdPromise = require("bluebird"); +import U2F = require("u2f"); + import { IAuthenticator } from "./ldap/IAuthenticator"; import { IPasswordUpdater } from "./ldap/IPasswordUpdater"; import { IEmailsRetriever } from "./ldap/IEmailsRetriever"; @@ -8,10 +10,10 @@ import { Authenticator } from "./ldap/Authenticator"; import { PasswordUpdater } from "./ldap/PasswordUpdater"; import { EmailsRetriever } from "./ldap/EmailsRetriever"; import { ClientFactory } from "./ldap/ClientFactory"; +import { LdapClientFactory } from "./ldap/LdapClientFactory"; import { TOTPValidator } from "./TOTPValidator"; import { TOTPGenerator } from "./TOTPGenerator"; -import U2F = require("u2f"); import { IUserDataStore } from "./storage/IUserDataStore"; import { UserDataStore } from "./storage/UserDataStore"; import { INotifier } from "./notifiers/INotifier"; @@ -73,11 +75,12 @@ class UserDataStoreFactory { export class ServerVariablesHandler { static initialize(app: express.Application, config: Configuration.AppConfiguration, deps: GlobalDependencies): BluebirdPromise { const notifier = NotifierFactory.build(config.notifier, deps.nodemailer); - const ldapClientFactory = new ClientFactory(config.ldap, deps.ldapjs, deps.dovehash, deps.winston); + const ldapClientFactory = new LdapClientFactory(config.ldap, deps.ldapjs); + const clientFactory = new ClientFactory(config.ldap, ldapClientFactory, deps.dovehash, deps.winston); - const ldapAuthenticator = new Authenticator(config.ldap, ldapClientFactory); - const ldapPasswordUpdater = new PasswordUpdater(config.ldap, ldapClientFactory); - const ldapEmailsRetriever = new EmailsRetriever(config.ldap, ldapClientFactory); + const ldapAuthenticator = new Authenticator(config.ldap, clientFactory); + const ldapPasswordUpdater = new PasswordUpdater(config.ldap, clientFactory); + const ldapEmailsRetriever = new EmailsRetriever(config.ldap, clientFactory); const accessController = new AccessController(config.access_control, deps.winston); const totpValidator = new TOTPValidator(deps.speakeasy); const totpGenerator = new TOTPGenerator(deps.speakeasy); diff --git a/server/src/lib/configuration/ConfigurationAdapter.ts b/server/src/lib/configuration/ConfigurationAdapter.ts index 8307ef076..8931401ee 100644 --- a/server/src/lib/configuration/ConfigurationAdapter.ts +++ b/server/src/lib/configuration/ConfigurationAdapter.ts @@ -6,6 +6,7 @@ import { MongoStorageConfiguration, LocalStorageConfiguration, UserLdapConfiguration } from "./Configuration"; +import Util = require("util"); const LDAP_URL_ENV_VARIABLE = "LDAP_URL"; @@ -26,7 +27,10 @@ function ensure_key_existence(config: object, path: string): void { function adaptLdapConfiguration(userConfig: UserLdapConfiguration): LdapConfiguration { const DEFAULT_USERS_FILTER = "cn={0}"; - const DEFAULT_GROUPS_FILTER = "member={0}"; + const DEFAULT_GROUPS_FILTER = + userConfig.additional_users_dn + ? Util.format("member=cn={0},%s,%s", userConfig.additional_groups_dn, userConfig.base_dn) + : Util.format("member=cn={0},%s", userConfig.base_dn); const DEFAULT_GROUP_NAME_ATTRIBUTE = "cn"; const DEFAULT_MAIL_ATTRIBUTE = "mail"; diff --git a/server/src/lib/ldap/Authenticator.ts b/server/src/lib/ldap/Authenticator.ts index 22c59520c..a13c68a2f 100644 --- a/server/src/lib/ldap/Authenticator.ts +++ b/server/src/lib/ldap/Authenticator.ts @@ -7,7 +7,7 @@ import { GroupsAndEmails } from "./IClient"; import { IAuthenticator } from "./IAuthenticator"; import { LdapConfiguration } from "../configuration/Configuration"; -import { Winston, Ldapjs, Dovehash } from "../../../types/Dependencies"; +import { EmailsAndGroupsRetriever } from "./EmailsAndGroupsRetriever"; export class Authenticator implements IAuthenticator { @@ -23,7 +23,7 @@ export class Authenticator implements IAuthenticator { const that = this; let userClient: IClient; const adminClient = this.clientFactory.create(this.options.user, this.options.password); - let groupsAndEmails: GroupsAndEmails; + const emailsAndGroupsRetriever = new EmailsAndGroupsRetriever(this.options, this.clientFactory); return adminClient.open() .then(function () { @@ -37,16 +37,9 @@ export class Authenticator implements IAuthenticator { return userClient.close(); }) .then(function () { - return adminClient.open(); + return emailsAndGroupsRetriever.retrieve(username); }) - .then(function () { - return adminClient.searchEmailsAndGroups(username); - }) - .then(function (gae: GroupsAndEmails) { - groupsAndEmails = gae; - return adminClient.close(); - }) - .then(function () { + .then(function (groupsAndEmails: GroupsAndEmails) { return BluebirdPromise.resolve(groupsAndEmails); }) .error(function (err: Error) { diff --git a/server/src/lib/ldap/Client.ts b/server/src/lib/ldap/Client.ts index 834f8b1ef..823d40970 100644 --- a/server/src/lib/ldap/Client.ts +++ b/server/src/lib/ldap/Client.ts @@ -2,63 +2,37 @@ import util = require("util"); import BluebirdPromise = require("bluebird"); import exceptions = require("../Exceptions"); -import Ldapjs = require("ldapjs"); import Dovehash = require("dovehash"); import { EventEmitter } from "events"; import { IClient, GroupsAndEmails } from "./IClient"; +import { ILdapClient } from "./ILdapClient"; +import { ILdapClientFactory } from "./ILdapClientFactory"; import { LdapConfiguration } from "../configuration/Configuration"; import { Winston } from "../../../types/Dependencies"; -interface SearchEntry { - object: any; -} - -declare module "ldapjs" { - export interface ClientAsync { - on(event: string, callback: (data?: any) => void): void; - bindAsync(username: string, password: string): BluebirdPromise; - unbindAsync(): BluebirdPromise; - searchAsync(base: string, query: Ldapjs.SearchOptions): BluebirdPromise; - modifyAsync(userdn: string, change: Ldapjs.Change): BluebirdPromise; - } -} export class Client implements IClient { private userDN: string; private password: string; - private client: Ldapjs.ClientAsync; - - private ldapjs: typeof Ldapjs; + private ldapClient: ILdapClient; private logger: Winston; private dovehash: typeof Dovehash; private options: LdapConfiguration; constructor(userDN: string, password: string, options: LdapConfiguration, - ldapjs: typeof Ldapjs, dovehash: typeof Dovehash, logger: Winston) { + ldapClientFactory: ILdapClientFactory, dovehash: typeof Dovehash, logger: Winston) { this.options = options; - this.ldapjs = ldapjs; this.dovehash = dovehash; this.logger = logger; this.userDN = userDN; this.password = password; - - const ldapClient = ldapjs.createClient({ - url: this.options.url, - reconnect: true - }); - - /*const clientLogger = (ldapClient as any).log; - if (clientLogger) { - clientLogger.level("trace"); - }*/ - - this.client = BluebirdPromise.promisifyAll(ldapClient) as Ldapjs.ClientAsync; + this.ldapClient = ldapClientFactory.create(); } open(): BluebirdPromise { this.logger.debug("LDAP: Bind user '%s'", this.userDN); - return this.client.bindAsync(this.userDN, this.password) + return this.ldapClient.bindAsync(this.userDN, this.password) .error(function (err: Error) { return BluebirdPromise.reject(new exceptions.LdapBindError(err.message)); }); @@ -66,61 +40,24 @@ export class Client implements IClient { close(): BluebirdPromise { this.logger.debug("LDAP: Unbind user '%s'", this.userDN); - return this.client.unbindAsync() + return this.ldapClient.unbindAsync() .error(function (err: Error) { return BluebirdPromise.reject(new exceptions.LdapBindError(err.message)); }); } - private search(base: string, query: Ldapjs.SearchOptions): BluebirdPromise { + searchGroups(username: string): BluebirdPromise { const that = this; - - that.logger.debug("LDAP: Search for '%s' in '%s'", JSON.stringify(query), base); - return that.client.searchAsync(base, query) - .then(function (res: EventEmitter) { - const doc: SearchEntry[] = []; - - return new BluebirdPromise((resolve, reject) => { - res.on("searchEntry", function (entry: SearchEntry) { - that.logger.debug("Entry retrieved from LDAP is '%s'", JSON.stringify(entry.object)); - doc.push(entry.object); - }); - res.on("error", function (err: Error) { - that.logger.error("LDAP: Error received during search '%s'.", JSON.stringify(err)); - reject(new exceptions.LdapSearchError(err.message)); - }); - res.on("end", function () { - that.logger.debug("LDAP: Search ended and results are '%s'.", JSON.stringify(doc)); - resolve(doc); - }); - }); - }) - .catch(function (err: Error) { - return BluebirdPromise.reject(new exceptions.LdapSearchError(err.message)); - }); - } - - private searchGroups(username: string): BluebirdPromise { - const that = this; - - const groups: string[] = []; - return that.searchUserDn(username) - .then(function (userDN: string) { - const filter = that.options.groups_filter.replace("{0}", userDN); - const query = { - scope: "sub", - attributes: [that.options.group_name_attribute], - filter: filter - }; - return that.search(that.options.groups_dn, query); - }) - .then(function (docs) { - for (let i = 0; i < docs.length; ++i) { - groups.push(docs[i].cn); - } + const filter = that.options.groups_filter.replace("{0}", username); + const query = { + scope: "sub", + attributes: [that.options.group_name_attribute], + filter: filter + }; + return this.ldapClient.searchAsync(that.options.groups_dn, query) + .then(function (docs: { cn: string }[]) { + const groups = docs.map((doc: any) => { return doc.cn; }); that.logger.debug("LDAP: groups of user %s are %s", username, groups); - }) - .then(function () { return BluebirdPromise.resolve(groups); }); } @@ -136,7 +73,7 @@ export class Client implements IClient { }; that.logger.debug("LDAP: searching for user dn of %s", username); - return that.search(this.options.users_dn, query) + return that.ldapClient.searchAsync(this.options.users_dn, query) .then(function (users: { dn: string }[]) { that.logger.debug("LDAP: retrieved user dn is %s", users[0].dn); return BluebirdPromise.resolve(users[0].dn); @@ -153,35 +90,17 @@ export class Client implements IClient { return this.searchUserDn(username) .then(function (userDN) { - return that.search(userDN, query); + return that.ldapClient.searchAsync(userDN, query); }) .then(function (docs: { mail: string }[]) { - const emails: string[] = []; - if (typeof docs[0].mail === "string") - emails.push(docs[0].mail); - else { - emails.concat(docs[0].mail); - } + const emails: string[] = docs + .filter((d) => { return typeof d.mail === "string"; }) + .map((d) => { return d.mail; }); that.logger.debug("LDAP: emails of user '%s' are %s", username, emails); return BluebirdPromise.resolve(emails); - }); - } - - searchEmailsAndGroups(username: string): BluebirdPromise { - const that = this; - let retrievedEmails: string[], retrievedGroups: string[]; - - return this.searchEmails(username) - .then(function (emails: string[]) { - retrievedEmails = emails; - return that.searchGroups(username); }) - .then(function (groups: string[]) { - retrievedGroups = groups; - return BluebirdPromise.resolve({ - emails: retrievedEmails, - groups: retrievedGroups - }); + .catch(function (err: Error) { + return BluebirdPromise.reject(new exceptions.LdapError("Error while searching emails. " + err.stack)); }); } @@ -198,10 +117,10 @@ export class Client implements IClient { this.logger.debug("LDAP: update password of user '%s'", username); return this.searchUserDn(username) .then(function (userDN: string) { - that.client.modifyAsync(userDN, change); + that.ldapClient.modifyAsync(userDN, change); }) .then(function () { - return that.client.unbindAsync(); + return that.ldapClient.unbindAsync(); }); } } diff --git a/server/src/lib/ldap/ClientFactory.ts b/server/src/lib/ldap/ClientFactory.ts index 850227e25..48c4aebac 100644 --- a/server/src/lib/ldap/ClientFactory.ts +++ b/server/src/lib/ldap/ClientFactory.ts @@ -1,6 +1,7 @@ import { IClientFactory } from "./IClientFactory"; import { IClient } from "./IClient"; import { Client } from "./Client"; +import { ILdapClientFactory } from "./ILdapClientFactory"; import { LdapConfiguration } from "../configuration/Configuration"; import Ldapjs = require("ldapjs"); @@ -9,19 +10,20 @@ import Winston = require("winston"); export class ClientFactory implements IClientFactory { private config: LdapConfiguration; - private ldapjs: typeof Ldapjs; + private ldapClientFactory: ILdapClientFactory; private dovehash: typeof Dovehash; private logger: typeof Winston; - constructor(ldapConfiguration: LdapConfiguration, ldapjs: typeof Ldapjs, + constructor(ldapConfiguration: LdapConfiguration, ldapClientFactory: ILdapClientFactory, dovehash: typeof Dovehash, logger: typeof Winston) { this.config = ldapConfiguration; - this.ldapjs = ldapjs; + this.ldapClientFactory = ldapClientFactory; this.dovehash = dovehash; this.logger = logger; } create(userDN: string, password: string): IClient { - return new Client(userDN, password, this.config, this.ldapjs, this.dovehash, this.logger); + return new Client(userDN, password, this.config, this.ldapClientFactory, + this.dovehash, this.logger); } } \ No newline at end of file diff --git a/server/src/lib/ldap/EmailsAndGroupsRetriever.ts b/server/src/lib/ldap/EmailsAndGroupsRetriever.ts new file mode 100644 index 000000000..f17f0368f --- /dev/null +++ b/server/src/lib/ldap/EmailsAndGroupsRetriever.ts @@ -0,0 +1,46 @@ +import BluebirdPromise = require("bluebird"); +import exceptions = require("../Exceptions"); +import ldapjs = require("ldapjs"); +import { Client } from "./Client"; +import { IClientFactory } from "./IClientFactory"; +import { LdapConfiguration } from "../configuration/Configuration"; +import { GroupsAndEmails } from "./IClient"; + + +export class EmailsAndGroupsRetriever { + private options: LdapConfiguration; + private clientFactory: IClientFactory; + + constructor(options: LdapConfiguration, clientFactory: IClientFactory) { + this.options = options; + this.clientFactory = clientFactory; + } + + retrieve(username: string): BluebirdPromise { + const adminClient = this.clientFactory.create(this.options.user, this.options.password); + let emails: string[]; + let groups: string[]; + + return adminClient.open() + .then(function () { + return adminClient.searchEmails(username); + }) + .then(function (emails_: string[]) { + emails = emails_; + return adminClient.searchGroups(username); + }) + .then(function (groups_: string[]) { + groups = groups_; + return adminClient.close(); + }) + .then(function () { + return BluebirdPromise.resolve({ + emails: emails, + groups: groups + }); + }) + .error(function (err: Error) { + return BluebirdPromise.reject(new exceptions.LdapError("Failed during emails and groups retrieval: " + err.message)); + }); + } +} diff --git a/server/src/lib/ldap/EmailsRetriever.ts b/server/src/lib/ldap/EmailsRetriever.ts index 9cc5ff33d..34a93a750 100644 --- a/server/src/lib/ldap/EmailsRetriever.ts +++ b/server/src/lib/ldap/EmailsRetriever.ts @@ -32,8 +32,8 @@ export class EmailsRetriever implements IEmailsRetriever { .then(function () { return BluebirdPromise.resolve(emails); }) - .error(function (err: Error) { - return BluebirdPromise.reject(new exceptions.LdapError("Failed during password update: " + err.message)); + .catch(function (err: Error) { + return BluebirdPromise.reject(new exceptions.LdapError("Failed during email retrieval: " + err.message)); }); } } diff --git a/server/src/lib/ldap/GroupsRetriever.ts b/server/src/lib/ldap/GroupsRetriever.ts new file mode 100644 index 000000000..f5b02df97 --- /dev/null +++ b/server/src/lib/ldap/GroupsRetriever.ts @@ -0,0 +1,39 @@ +import BluebirdPromise = require("bluebird"); +import exceptions = require("../Exceptions"); +import ldapjs = require("ldapjs"); +import { IClient } from "./IClient"; + +import { IClientFactory } from "./IClientFactory"; +import { IGroupsRetriever } from "./IGroupsRetriever"; +import { LdapConfiguration } from "../configuration/Configuration"; + + +export class GroupsRetriever implements IGroupsRetriever { + private options: LdapConfiguration; + private clientFactory: IClientFactory; + + constructor(options: LdapConfiguration, clientFactory: IClientFactory) { + this.options = options; + this.clientFactory = clientFactory; + } + + retrieve(username: string, client?: IClient): BluebirdPromise { + client = this.clientFactory.create(this.options.user, this.options.password); + let groups: string[]; + + return client.open() + .then(function () { + return client.searchGroups(username); + }) + .then(function (groups_: string[]) { + groups = groups_; + return client.close(); + }) + .then(function () { + return BluebirdPromise.resolve(groups); + }) + .catch(function (err: Error) { + return BluebirdPromise.reject(new exceptions.LdapError("Failed during groups retrieval: " + err.message)); + }); + } +} diff --git a/server/src/lib/ldap/IClient.ts b/server/src/lib/ldap/IClient.ts index 741ebaf61..55856d675 100644 --- a/server/src/lib/ldap/IClient.ts +++ b/server/src/lib/ldap/IClient.ts @@ -11,6 +11,6 @@ export interface IClient { close(): BluebirdPromise; searchUserDn(username: string): BluebirdPromise; searchEmails(username: string): BluebirdPromise; - searchEmailsAndGroups(username: string): BluebirdPromise; + searchGroups(username: string): BluebirdPromise; modifyPassword(username: string, newPassword: string): BluebirdPromise; } \ No newline at end of file diff --git a/server/src/lib/ldap/IEmailsRetriever.ts b/server/src/lib/ldap/IEmailsRetriever.ts index 608a2883f..65ae21918 100644 --- a/server/src/lib/ldap/IEmailsRetriever.ts +++ b/server/src/lib/ldap/IEmailsRetriever.ts @@ -1,5 +1,6 @@ import BluebirdPromise = require("bluebird"); +import { IClient } from "./IClient"; export interface IEmailsRetriever { - retrieve(username: string): BluebirdPromise; + retrieve(username: string, client?: IClient): BluebirdPromise; } \ No newline at end of file diff --git a/server/src/lib/ldap/IGroupsRetriever.ts b/server/src/lib/ldap/IGroupsRetriever.ts new file mode 100644 index 000000000..ce43ac948 --- /dev/null +++ b/server/src/lib/ldap/IGroupsRetriever.ts @@ -0,0 +1,6 @@ +import BluebirdPromise = require("bluebird"); +import { IClient } from "./IClient"; + +export interface IGroupsRetriever { + retrieve(username: string): BluebirdPromise; +} \ No newline at end of file diff --git a/server/src/lib/ldap/ILdapClient.ts b/server/src/lib/ldap/ILdapClient.ts new file mode 100644 index 000000000..388240806 --- /dev/null +++ b/server/src/lib/ldap/ILdapClient.ts @@ -0,0 +1,10 @@ + +import BluebirdPromise = require("bluebird"); +import EventEmitter = require("events"); + +export interface ILdapClient { + bindAsync(username: string, password: string): BluebirdPromise; + unbindAsync(): BluebirdPromise; + searchAsync(base: string, query: any): BluebirdPromise; + modifyAsync(dn: string, changeRequest: any): BluebirdPromise; +} \ No newline at end of file diff --git a/server/src/lib/ldap/ILdapClientFactory.ts b/server/src/lib/ldap/ILdapClientFactory.ts new file mode 100644 index 000000000..be2ce8e4b --- /dev/null +++ b/server/src/lib/ldap/ILdapClientFactory.ts @@ -0,0 +1,6 @@ + +import { ILdapClient } from "./ILdapClient"; + +export interface ILdapClientFactory { + create(): ILdapClient; +} \ No newline at end of file diff --git a/server/src/lib/ldap/LdapClient.ts b/server/src/lib/ldap/LdapClient.ts new file mode 100644 index 000000000..4b43cb3ce --- /dev/null +++ b/server/src/lib/ldap/LdapClient.ts @@ -0,0 +1,71 @@ +import LdapJs = require("ldapjs"); +import EventEmitter = require("events"); +import BluebirdPromise = require("bluebird"); +import { ILdapClient } from "./ILdapClient"; +import Exceptions = require("../Exceptions"); + +declare module "ldapjs" { + export interface ClientAsync { + on(event: string, callback: (data?: any) => void): void; + bindAsync(username: string, password: string): BluebirdPromise; + unbindAsync(): BluebirdPromise; + searchAsync(base: string, query: LdapJs.SearchOptions): BluebirdPromise; + modifyAsync(userdn: string, change: LdapJs.Change): BluebirdPromise; + } +} + +interface SearchEntry { + object: any; +} + +export class LdapClient implements ILdapClient { + private client: LdapJs.ClientAsync; + + constructor(url: string, ldapjs: typeof LdapJs) { + const ldapClient = ldapjs.createClient({ + url: url, + reconnect: true + }); + + /*const clientLogger = (ldapClient as any).log; + if (clientLogger) { + clientLogger.level("trace"); + }*/ + + this.client = BluebirdPromise.promisifyAll(ldapClient) as LdapJs.ClientAsync; + } + + bindAsync(username: string, password: string): BluebirdPromise { + return this.client.bindAsync(username, password); + } + + unbindAsync(): BluebirdPromise { + return this.client.unbindAsync(); + } + + searchAsync(base: string, query: any): BluebirdPromise { + const that = this; + return this.client.searchAsync(base, query) + .then(function (res: EventEmitter) { + const doc: SearchEntry[] = []; + return new BluebirdPromise((resolve, reject) => { + res.on("searchEntry", function (entry: SearchEntry) { + doc.push(entry.object); + }); + res.on("error", function (err: Error) { + reject(new Exceptions.LdapSearchError(err.message)); + }); + res.on("end", function () { + resolve(doc); + }); + }); + }) + .catch(function (err: Error) { + return BluebirdPromise.reject(new Exceptions.LdapSearchError(err.message)); + }); + } + + modifyAsync(dn: string, changeRequest: any): BluebirdPromise { + return this.client.modifyAsync(dn, changeRequest); + } +} \ No newline at end of file diff --git a/server/src/lib/ldap/LdapClientFactory.ts b/server/src/lib/ldap/LdapClientFactory.ts new file mode 100644 index 000000000..39977808f --- /dev/null +++ b/server/src/lib/ldap/LdapClientFactory.ts @@ -0,0 +1,20 @@ +import { ILdapClientFactory } from "./ILdapClientFactory"; +import { ILdapClient } from "./ILdapClient"; +import { LdapClient } from "./LdapClient"; +import { LdapConfiguration } from "../configuration/Configuration"; + +import Ldapjs = require("ldapjs"); + +export class LdapClientFactory implements ILdapClientFactory { + private config: LdapConfiguration; + private ldapjs: typeof Ldapjs; + + constructor(ldapConfiguration: LdapConfiguration, ldapjs: typeof Ldapjs) { + this.config = ldapConfiguration; + this.ldapjs = ldapjs; + } + + create(): ILdapClient { + return new LdapClient(this.config.url, this.ldapjs); + } +} \ No newline at end of file diff --git a/server/test/configuration/LdapConfigurationAdaptation.test.ts b/server/test/configuration/LdapConfigurationAdaptation.test.ts index 42c84d851..63c0a8bdd 100644 --- a/server/test/configuration/LdapConfigurationAdaptation.test.ts +++ b/server/test/configuration/LdapConfigurationAdaptation.test.ts @@ -55,7 +55,7 @@ describe("test ldap configuration adaptation", function () { users_dn: "dc=example,dc=com", users_filter: "cn={0}", groups_dn: "dc=example,dc=com", - groups_filter: "member={0}", + groups_filter: "member=cn={0},dc=example,dc=com", group_name_attribute: "cn", mail_attribute: "mail", user: "admin", diff --git a/server/test/ldap/Authenticator.test.ts b/server/test/ldap/Authenticator.test.ts index 82373389e..59682d268 100644 --- a/server/test/ldap/Authenticator.test.ts +++ b/server/test/ldap/Authenticator.test.ts @@ -64,10 +64,8 @@ describe("test ldap authentication", function () { userClientStub.closeStub.returns(BluebirdPromise.resolve()); // admin retrieves emails and groups of user - adminClientStub.searchEmailsAndGroupsStub.returns(BluebirdPromise.resolve({ - groups: ["group1"], - emails: ["user@example.com"] - })); + adminClientStub.searchEmailsStub.returns(BluebirdPromise.resolve(["group1"])); + adminClientStub.searchGroupsStub.returns(BluebirdPromise.resolve(["user@example.com"])); return authenticator.authenticate(USERNAME, PASSWORD); }); @@ -117,8 +115,9 @@ describe("test ldap authentication", function () { userClientStub.openStub.returns(BluebirdPromise.resolve()); userClientStub.closeStub.returns(BluebirdPromise.resolve()); + adminClientStub.searchEmailsStub.returns(BluebirdPromise.resolve(["group1"])); // admin retrieves emails and groups of user - adminClientStub.searchEmailsAndGroupsStub + adminClientStub.searchGroupsStub .returns(BluebirdPromise.reject(new Error("Error while retrieving emails and groups"))); return authenticator.authenticate(USERNAME, PASSWORD) diff --git a/server/test/ldap/Client.test.ts b/server/test/ldap/Client.test.ts new file mode 100644 index 000000000..d69cec019 --- /dev/null +++ b/server/test/ldap/Client.test.ts @@ -0,0 +1,45 @@ + +import { LdapConfiguration } from "../../src/lib/configuration/Configuration"; +import { Client } from "../../src/lib/ldap/Client"; +import { LdapClientFactoryStub } from "../mocks/ldap/LdapClientFactoryStub"; +import { LdapClientStub } from "../mocks/ldap/LdapClientStub"; + +import Sinon = require("sinon"); +import BluebirdPromise = require("bluebird"); +import Assert = require("assert"); +import Dovehash = require("dovehash"); +import Winston = require("winston"); + +describe("test authelia ldap client", function () { + const USERNAME = "username"; + const ADMIN_USER_DN = "cn=admin,dc=example,dc=com"; + const ADMIN_PASSWORD = "password"; + + it("should replace {0} by username when searching for groups in LDAP", function () { + const options: LdapConfiguration = { + url: "ldap://ldap", + users_dn: "ou=users,dc=example,dc=com", + users_filter: "cn={0}", + groups_dn: "ou=groups,dc=example,dc=com", + groups_filter: "member=cn={0},ou=users,dc=example,dc=com", + group_name_attribute: "cn", + mail_attribute: "mail", + user: "cn=admin,dc=example,dc=com", + password: "password" + }; + const factory = new LdapClientFactoryStub(); + const ldapClient = new LdapClientStub(); + + factory.createStub.returns(ldapClient); + ldapClient.searchAsyncStub.returns(BluebirdPromise.resolve([ + "group1" + ])); + const client = new Client(ADMIN_USER_DN, ADMIN_PASSWORD, options, factory, Dovehash, Winston); + + return client.searchGroups("user1") + .then(function () { + Assert.equal(ldapClient.searchAsyncStub.getCall(0).args[1].filter, + "member=cn=user1,ou=users,dc=example,dc=com"); + }); + }); +}); \ No newline at end of file diff --git a/server/test/ldap/GroupsRetriever.test.ts b/server/test/ldap/GroupsRetriever.test.ts new file mode 100644 index 000000000..922383aea --- /dev/null +++ b/server/test/ldap/GroupsRetriever.test.ts @@ -0,0 +1,75 @@ + +import { GroupsRetriever } from "../../src/lib/ldap/GroupsRetriever"; +import { LdapConfiguration } from "../../src/lib/configuration/Configuration"; + +import Sinon = require("sinon"); +import BluebirdPromise = require("bluebird"); +import Assert = require("assert"); + +import { ClientFactoryStub } from "../mocks/ldap/ClientFactoryStub"; +import { ClientStub } from "../mocks/ldap/ClientStub"; + +describe("test groups retriever", function () { + const USERNAME = "username"; + const ADMIN_USER_DN = "cn=admin,dc=example,dc=com"; + const ADMIN_PASSWORD = "password"; + + let clientFactoryStub: ClientFactoryStub; + let adminClientStub: ClientStub; + + let groupsRetriever: GroupsRetriever; + let ldapConfig: LdapConfiguration; + + beforeEach(function () { + clientFactoryStub = new ClientFactoryStub(); + adminClientStub = new ClientStub(); + + ldapConfig = { + url: "http://ldap", + user: ADMIN_USER_DN, + password: ADMIN_PASSWORD, + users_dn: "ou=users,dc=example,dc=com", + groups_dn: "ou=groups,dc=example,dc=com", + group_name_attribute: "cn", + groups_filter: "member=cn={0},ou=users,dc=example,dc=com", + mail_attribute: "mail", + users_filter: "cn={0}" + }; + + groupsRetriever = new GroupsRetriever(ldapConfig, clientFactoryStub); + }); + + describe("success", function () { + it("should retrieve groups successfully", function () { + clientFactoryStub.createStub.withArgs(ADMIN_USER_DN, ADMIN_PASSWORD) + .returns(adminClientStub); + + // admin connects successfully + adminClientStub.openStub.returns(BluebirdPromise.resolve()); + adminClientStub.closeStub.returns(BluebirdPromise.resolve()); + + adminClientStub.searchGroupsStub.withArgs(USERNAME) + .returns(BluebirdPromise.resolve(["user@example.com"])); + + return groupsRetriever.retrieve(USERNAME); + }); + }); + + describe("failure", function () { + it("should fail retrieving groups when search operation fails", function () { + clientFactoryStub.createStub.withArgs(ADMIN_USER_DN, ADMIN_PASSWORD) + .returns(adminClientStub); + + // admin connects successfully + adminClientStub.openStub.returns(BluebirdPromise.resolve()); + adminClientStub.closeStub.returns(BluebirdPromise.resolve()); + + adminClientStub.searchGroupsStub.withArgs(USERNAME) + .returns(BluebirdPromise.reject(new Error("Error while searching groups"))); + + return groupsRetriever.retrieve(USERNAME) + .then(function () { return BluebirdPromise.reject(new Error("Should not be here")); }) + .catch(function () { return BluebirdPromise.resolve(); }); + }); + }); +}); \ No newline at end of file diff --git a/server/test/mocks/ldap/ClientStub.ts b/server/test/mocks/ldap/ClientStub.ts index bcfdc2aed..79e50dafc 100644 --- a/server/test/mocks/ldap/ClientStub.ts +++ b/server/test/mocks/ldap/ClientStub.ts @@ -8,7 +8,7 @@ export class ClientStub implements IClient { closeStub: Sinon.SinonStub; searchUserDnStub: Sinon.SinonStub; searchEmailsStub: Sinon.SinonStub; - searchEmailsAndGroupsStub: Sinon.SinonStub; + searchGroupsStub: Sinon.SinonStub; modifyPasswordStub: Sinon.SinonStub; constructor() { @@ -16,7 +16,7 @@ export class ClientStub implements IClient { this.closeStub = Sinon.stub(); this.searchUserDnStub = Sinon.stub(); this.searchEmailsStub = Sinon.stub(); - this.searchEmailsAndGroupsStub = Sinon.stub(); + this.searchGroupsStub = Sinon.stub(); this.modifyPasswordStub = Sinon.stub(); } @@ -36,8 +36,8 @@ export class ClientStub implements IClient { return this.searchEmailsStub(username); } - searchEmailsAndGroups(username: string): BluebirdPromise { - return this.searchEmailsAndGroupsStub(username); + searchGroups(username: string): BluebirdPromise { + return this.searchGroupsStub(username); } modifyPassword(username: string, newPassword: string): BluebirdPromise { diff --git a/server/test/mocks/ldap/LdapClientFactoryStub.ts b/server/test/mocks/ldap/LdapClientFactoryStub.ts new file mode 100644 index 000000000..01c3573e6 --- /dev/null +++ b/server/test/mocks/ldap/LdapClientFactoryStub.ts @@ -0,0 +1,16 @@ +import Sinon = require("sinon"); +import BluebirdPromise = require("bluebird"); +import { ILdapClientFactory } from "../../../src/lib/ldap/ILdapClientFactory"; +import { ILdapClient } from "../../../src/lib/ldap/ILdapClient"; + +export class LdapClientFactoryStub implements ILdapClientFactory { + createStub: Sinon.SinonStub; + + constructor() { + this.createStub = Sinon.stub(); + } + + create(): ILdapClient { + return this.createStub(); + } +} \ No newline at end of file diff --git a/server/test/mocks/ldap/LdapClientStub.ts b/server/test/mocks/ldap/LdapClientStub.ts new file mode 100644 index 000000000..9f99392da --- /dev/null +++ b/server/test/mocks/ldap/LdapClientStub.ts @@ -0,0 +1,33 @@ +import Sinon = require("sinon"); +import BluebirdPromise = require("bluebird"); +import { ILdapClient } from "../../../src/lib/ldap/ILdapClient"; + +export class LdapClientStub implements ILdapClient { + bindAsyncStub: Sinon.SinonStub; + unbindAsyncStub: Sinon.SinonStub; + searchAsyncStub: Sinon.SinonStub; + modifyAsyncStub: Sinon.SinonStub; + + constructor() { + this.bindAsyncStub = Sinon.stub(); + this.unbindAsyncStub = Sinon.stub(); + this.searchAsyncStub = Sinon.stub(); + this.modifyAsyncStub = Sinon.stub(); + } + + bindAsync(username: string, password: string): BluebirdPromise { + return this.bindAsyncStub(username, password); + } + + unbindAsync(): BluebirdPromise { + return this.unbindAsyncStub(); + } + + searchAsync(base: string, query: any): BluebirdPromise { + return this.searchAsyncStub(base, query); + } + + modifyAsync(dn: string, changeRequest: any): BluebirdPromise { + return this.modifyAsyncStub(dn, changeRequest); + } +} \ No newline at end of file diff --git a/test/features/authentication.feature b/test/features/authentication.feature index f48bd8ad6..3beed8045 100644 --- a/test/features/authentication.feature +++ b/test/features/authentication.feature @@ -18,14 +18,16 @@ Feature: User validate first factor Given I visit "https://auth.test.local:8080/" And I login with user "john" and password "password" And I register a TOTP secret called "Sec0" - When I visit "https://admin.test.local:8080/secret.html" and get redirected "https://auth.test.local:8080/?redirect=https%3A%2F%2Fadmin.test.local%3A8080%2Fsecret.html" + When I visit "https://admin.test.local:8080/secret.html" + And I'm redirected to "https://auth.test.local:8080/?redirect=https%3A%2F%2Fadmin.test.local%3A8080%2Fsecret.html" And I login with user "john" and password "password" And I use "Sec0" as TOTP token handle And I click on "TOTP" Then I'm redirected to "https://admin.test.local:8080/secret.html" Scenario: User fails TOTP second factor - When I visit "https://admin.test.local:8080/secret.html" and get redirected "https://auth.test.local:8080/?redirect=https%3A%2F%2Fadmin.test.local%3A8080%2Fsecret.html" + When I visit "https://admin.test.local:8080/secret.html" + And I'm redirected to "https://auth.test.local:8080/?redirect=https%3A%2F%2Fadmin.test.local%3A8080%2Fsecret.html" And I login with user "john" and password "password" And I use "BADTOKEN" as TOTP token And I click on "TOTP"