Skip to content

Commit

Permalink
Many fixes and updates, see CHANGELOG.md version 3.1.0
Browse files Browse the repository at this point in the history
  • Loading branch information
milux committed Aug 15, 2023
1 parent 36175aa commit f1edc22
Show file tree
Hide file tree
Showing 11 changed files with 303 additions and 120 deletions.
5 changes: 3 additions & 2 deletions .env.dist
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Add debug infos to log, non-empty string means "true"!
#DEBUG=true
# Add debug infos to log, non-empty string != "false" means "true"!
DEBUG=false
TRACE=false
# This is required for clients using lowercase DNs, e.g. ownCloud/nextCloud
IS_DN_LOWER_CASE=true
# This is required for clients that need lowercase email addresses, e.g. Seafile
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

### 3.1.0
- Migrated to `ldapjs` 3.0.4
- Added case-insensitive EqualityFilter.matches() implementation
(i.e. now supports **case-insensitive user & email matching,** yay!)
- Aligned case-insensitive SubstringFilter.matches() implementation with `ldapjs` 3.x
- Fixed LDAP errors when logging in with wrong credentials
- Added workaround for ChurchTools API HTTP status 403 on session expiry
- Added back options object (for TLS encryption) in `ldapjs.createServer()`
- Introduced new logging level `TRACE` for very verbose log outputs

### 3.0.2
- Fixed error due to changed ChurchTools API pagination behavior
- Keep session cookies, which gains about 100 ms speedup
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ COPY --chown=node:node .yarnrc.yml .
COPY --chown=node:node .yarn ./.yarn
RUN yarn install

COPY --chown=node:node *.js .
COPY --chown=node:node ctldap.yml .
COPY --chown=node:node *.js .

EXPOSE 1389

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# ctldap 3.0.2 - LDAP Wrapper for ChurchTools
# ctldap 3.1.0 - LDAP Wrapper for ChurchTools

This software acts as an LDAP server for ChurchTools 3

Expand All @@ -9,7 +9,7 @@ This software acts as an LDAP server for ChurchTools 3

The old installation methods are discouraged and won't be supported any further.

## Migration from version 2.2.2
## Migration from version 2.x to 3.x
Version 3.0.0 includes some breaking changes in the configuration format and some parameters.
Assuming Docker setup, the necessary adaptations are not that difficult, though.

Expand Down
3 changes: 2 additions & 1 deletion ctldap-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ export class CtldapConfig {
constructor() {
const yaml = readYamlEnvSync('./ctldap.yml');
const config = yaml.config;
this.debug = CtldapConfig.asOptionalBool(config.debug);
this.trace = CtldapConfig.asOptionalBool(config.trace);
this.debug = this.trace || CtldapConfig.asOptionalBool(config.debug);
this.ldapIp = config.ldapIp;
this.ldapPort = config.ldapPort;

Expand Down
16 changes: 15 additions & 1 deletion ctldap-site.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import bcrypt from "bcrypt";
import argon2 from "argon2";
import { CtldapConfig } from "./ctldap-config.js"
import { CookieJar } from "tough-cookie";
import { logWarn } from "./ctldap.js"

export class CtldapSite {

Expand All @@ -30,11 +31,24 @@ export class CtldapSite {
this.fnUserDn = (cn) => ldapEscape.dn`cn=${cn},ou=users,o=${name}`;
this.fnGroupDn = (cn) => ldapEscape.dn`cn=${cn},ou=groups,o=${name}`;
this.api = got.extend({
headers: { "Authorization": `Login ${site.apiToken}` },
headers: {"Authorization": `Login ${site.apiToken}`},
prefixUrl: `${site.ctUri.replace(/\/$/g, '')}/api`,
// Let us keep cookies, which may improve CT API performance.
// "undefined" is fine as "store" parameter here, it results in memory storage.
cookieJar: new CookieJar(undefined),
retry: {
statusCodes: [403, 408, 413, 429, 500, 502, 503, 504, 521, 522, 524]
},
hooks: {
beforeRetry: [
(error, _retryCount) => {
if (error.response.statusCode === 403) {
logWarn(this, "CT API responded with HTTP 403, clearing cookies before retry...");
error.options.cookieJar.removeAllCookiesSync();
}
}
]
},
responseType: 'json',
resolveBodyOnly: true,
http2: true
Expand Down
124 changes: 53 additions & 71 deletions ctldap.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@
* @copyright André Schild
* @licence GNU/GPL v3.0
*/
import helpers from "ldap-filter";
import fs from "fs";
import ldap from "ldapjs";
import ldapjs from "ldapjs";
const { InsufficientAccessRightsError, InvalidCredentialsError, OtherError, parseDN } = ldapjs;
import { CtldapConfig } from "./ctldap-config.js";
import { patchLdapjsFilters } from "./ldapjs-filter-overrides.js";

// Make some ldapjs filters case-insensitive
patchLdapjsFilters();

/**
* Simple integer range as array, inspired by https://developer.mozilla.org
Expand All @@ -19,24 +23,29 @@ import { CtldapConfig } from "./ctldap-config.js";
*/
const range = (start, end) => Array.from({ length: end - start }, (_, i) => start + i);

const parseDN = ldap.parseDN;
const config = new CtldapConfig();

function getIsoDate() {
return new Date().toISOString();
}

function logDebug(site, msg) {
export const logTrace = (site, msg) => {
if (config.trace) {
console.log(`${getIsoDate()} [TRACE] ${site.name} - ${msg}`);
}
}

export const logDebug = (site, msg) => {
if (config.debug) {
console.log(`${getIsoDate()} [DEBUG] ${site.name} - ${msg}`);
}
}

function logWarn(site, msg) {
export const logWarn = (site, msg) => {
console.warn(`${getIsoDate()} [WARN] ${site.name} - ${msg}`);
}

function logError(site, msg, error) {
export const logError = (site, msg, error) => {
console.error(`${getIsoDate()} [ERROR] ${site.name} - ${msg}`);
if (error !== undefined) {
console.error(error.stack);
Expand All @@ -51,7 +60,7 @@ if (config.ldapCertFilename && config.ldapKeyFilename) {
ldapKey = fs.readFileSync(new URL(`./${config.ldapKeyFilename}`, import.meta.url), { encoding: "utf8" });
options = { certificate: ldapCert, key: ldapKey };
}
const server = ldap.createServer();
const server = ldapjs.createServer(options);

const USERS_KEY = 'users', GROUPS_KEY = 'groups', RAW_DATA_KEY = 'rawData';

Expand Down Expand Up @@ -253,11 +262,11 @@ function requestUsers(req, _res, next) {
dn: p.dn,
attributes: {
cn,
displayname: `${p['firstName']} ${p['lastName']}`,
displayName: `${p['firstName']} ${p['lastName']}`,
id,
uid: cn,
nsuniqueid: `u${id}`,
givenname: p['firstName'],
nsUniqueId: `u${id}`,
givenName: p['firstName'],
street: p['street'],
telephoneMobile: p['mobile'],
telephoneHome: p['phonePrivate'],
Expand All @@ -266,15 +275,15 @@ function requestUsers(req, _res, next) {
sn: p['lastName'],
email,
mail: email,
objectclass: [
objectClass: [
'person',
'CTPerson',
// Map special CT field names of associated groups to the LDAP objectClass names defined in configuration.
...(p2g[id] || [])
.flatMap((gid) => groupMap[gid].specialClasses)
.map((key) => site.specialGroupMappings[key]['personClass'])
],
memberof: (p2g[id] || []).map((gid) => groupMap[gid].dn)
memberOf: (p2g[id] || []).map((gid) => groupMap[gid].dn)
}
};
});
Expand All @@ -289,9 +298,9 @@ function requestUsers(req, _res, next) {
displayname: "LDAP Administrator",
id: 0,
uid: cn,
nsuniqueid: "u0",
givenname: "LDAP Administrator",
objectclass: ['person'],
nsUniqueId: "u0",
givenName: "LDAP Administrator",
objectClass: ['person'],
}
});
}
Expand Down Expand Up @@ -325,9 +334,9 @@ function requestGroups(req, _res, next) {
cn,
displayname: g['name'],
id,
nsuniqueid: `g${id}`,
objectclass: objectClasses,
uniquemember: (g2p[id] || []).map((pid) => personMap[pid].dn)
nsUniqueId: `g${id}`,
objectClass: objectClasses,
uniqueMember: (g2p[id] || []).map((pid) => personMap[pid].dn)
}
};
});
Expand All @@ -347,7 +356,7 @@ function requestGroups(req, _res, next) {
function authorize(req, _res, next) {
if (!req.connection.ldap.bindDN.equals(req.site.adminDn)) {
logWarn(req.site, "Rejected search without proper binding!");
return next(new Error("Insufficient access rights, must bind to LDAP admin user first!"));
return next(new InsufficientAccessRightsError());
}
return next();
}
Expand All @@ -359,7 +368,7 @@ function authorize(req, _res, next) {
* @param {function} next - Next handler function of filter chain
*/
function searchLogging(req, _res, next) {
logDebug(req.site, "SEARCH base object: " + req.dn.toString() + " scope: " + req.scope);
logDebug(req.site, "SEARCH base object: " + req.dn.toString() + " scope: " + req.scopeName);
logDebug(req.site, "Filter: " + req.filter.toString());
return next();
}
Expand All @@ -371,17 +380,16 @@ function searchLogging(req, _res, next) {
* @param {function} next - Next handler function of filter chain
*/
function sendUsers(req, res, next) {
const strDn = req.dn.toString();
req.usersPromise.then((users) => {
users.forEach((u) => {
if ((req.checkAll || parseDN(strDn).equals(parseDN(u.dn))) && (req.filter.matches(u.attributes))) {
logDebug(req.site, "MatchUser: " + u.dn);
if ((req.checkAll || req.dn.equals(u.dn)) && req.filter.matches(u.attributes, false)) {
logTrace(req.site, "MatchUser: " + u.dn.toString());
res.send(u);
}
});
return next();
}, (error) => {
logError(req.site, "Error while retrieving users: ", error);
logError(req.site, "Error whilst retrieving users: ", error);
return next();
});
}
Expand All @@ -393,17 +401,16 @@ function sendUsers(req, res, next) {
* @param {function} next - Next handler function of filter chain
*/
function sendGroups(req, res, next) {
const strDn = req.dn.toString();
req.groupsPromise.then((groups) => {
groups.forEach((g) => {
if ((req.checkAll || parseDN(strDn).equals(parseDN(g.dn))) && (req.filter.matches(g.attributes))) {
logDebug(req.site, "MatchGroup: " + g.dn);
if ((req.checkAll || req.dn.equals(g.dn)) && req.filter.matches(g.attributes, false)) {
logTrace(req.site, "MatchGroup: " + g.dn);
res.send(g);
}
});
return next();
}, (error) => {
logError(req.site, "Error while retrieving groups: ", error);
logError(req.site, "Error whilst retrieving groups: ", error);
return next();
});
}
Expand All @@ -427,36 +434,42 @@ function endSuccess(_req, res, next) {
*/
async function authenticate(req, _res, next) {
const site = req.site;
if (req.dn.equals(site.adminDn)) {
logDebug(site, "Admin bind DN: " + req.dn.toString());
if (req.dn === site.adminDn) {
logDebug(site, "Admin bind DN: " + req.dn);
// If ldapPassword is undefined, try a default ChurchTools authentication with this user
if (site.ldapPassword !== undefined) {
try {
await site.authenticateAdmin(req.credentials);
logDebug(site, "Admin bind successful");
return next();
} catch (error) {
logError(site, `Invalid password for admin bind or auth error: ${error.message}`);
return next(error);
logError(site, `Invalid password for admin bind or auth error: `, error);
return next(new InvalidCredentialsError());
}
} else {
logDebug("ldapPassword is undefined, trying ChurchTools authentication...")
}
} else {
logDebug(site, "Bind user DN: %s", req.dn);
}
const username = parseDN(req.dn).rdnAt(0).getValue("cn");
try {
await site.api.post('login', {
json: {
"username": req.dn.rdns[0].attrs.cn.value,
"username": username,
"password": req.credentials
}
});
logDebug(site, "Authentication successful for " + req.dn.toString());
logDebug(site, "Authentication successful for " + req.dn);
return next();
} catch (error) {
logError(site, "Authentication error: ", error);
return next(new Error("Invalid LDAP password"));
if (error.response?.statusCode === 400) {
logWarn(site, `Authentication error (CT API HTTP 400) occurred for ${username} (probably wrong password): ${error}`);
return next(new InvalidCredentialsError());
} else {
logError(site, `Authentication error for ${username}: ${error}`);
return next(new OtherError());
}
}
}

Expand All @@ -473,7 +486,7 @@ config.sites.forEach((site) => {
next();
}, searchLogging, authorize, (req, _res, next) => {
logDebug(site, "Search for users");
req.checkAll = req.scope !== "base" && req.dn.rdns.length === 2;
req.checkAll = req.scopeName !== "base" && req.dn.length === 2;
return next();
}, requestUsers, sendUsers, endSuccess);

Expand All @@ -483,7 +496,7 @@ config.sites.forEach((site) => {
next();
}, searchLogging, authorize, (req, _res, next) => {
logDebug(site, "Search for groups");
req.checkAll = req.scope !== "base" && req.dn.rdns.length === 2;
req.checkAll = req.scopeName !== "base" && req.dn.length === 2;
return next();
}, requestGroups, sendGroups, endSuccess);

Expand All @@ -493,7 +506,7 @@ config.sites.forEach((site) => {
next();
}, searchLogging, authorize, (req, _res, next) => {
logDebug(site, "Search for users and groups combined");
req.checkAll = req.scope === "sub";
req.checkAll = req.scopeName === "subtree";
return next();
}, requestUsers, requestGroups, sendUsers, sendGroups, endSuccess);
});
Expand All @@ -512,44 +525,13 @@ server.search('', (req, res) => {
"dn": "",
};

if (req.filter.matches(obj.attributes)) {
if (req.filter.matches(obj.attributes, false)) {
res.send(obj);
}

res.end();
}, endSuccess);


function escapeRegExp(str) {
/* JSSTYLED */
return str.replace(/[\-\[\]\/{}()*+?.\\^$|]/g, '\\$&');
}

/**
* Case-insensitive search on substring filters
* Credits to @alansouzati, see https://github.com/ldapjs/node-ldapjs/issues/156
*/
ldap.filters.SubstringFilter.prototype.matches = function (target, strictAttrCase) {
const tv = helpers.getAttrValue(target, this.attribute, strictAttrCase);
if (tv !== undefined && tv !== null) {
let re = '';

if (this.initial) {
re += '^' + escapeRegExp(this.initial) + '.*';
}
this.any.forEach((s) => re += escapeRegExp(s) + '.*');
if (this.final) {
re += escapeRegExp(this.final) + '$';
}

const matcher = new RegExp(re, 'i');
return helpers.testValues((v) => matcher.test(v), tv, false);
}

return false;
};


// Start LDAP server
server.listen(parseInt(config.ldapPort), config.ldapIp, () => {
logDebug({ name: 'root logger' }, `ChurchTools-LDAP-Wrapper listening @ ${server.url}`);
Expand Down
Loading

0 comments on commit f1edc22

Please sign in to comment.