@directus/api
Version:
Directus is a real-time API and App dashboard for managing SQL database content
354 lines (353 loc) • 14.2 kB
JavaScript
import { useEnv } from '@directus/env';
import { ErrorCode, InvalidCredentialsError, InvalidPayloadError, InvalidProviderConfigError, InvalidProviderError, isDirectusError, ServiceUnavailableError, UnexpectedResponseError, } from '@directus/errors';
import { Router } from 'express';
import Joi from 'joi';
import { Client, InappropriateAuthError, InsufficientAccessError, InvalidCredentialsError as LdapInvalidCredentialsError, } from 'ldapts';
import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../constants.js';
import getDatabase from '../../database/index.js';
import emitter from '../../emitter.js';
import { useLogger } from '../../logger/index.js';
import { respond } from '../../middleware/respond.js';
import { createDefaultAccountability } from '../../permissions/utils/create-default-accountability.js';
import { AuthenticationService } from '../../services/authentication.js';
import asyncHandler from '../../utils/async-handler.js';
import { getIPFromReq } from '../../utils/get-ip-from-req.js';
import { getSchema } from '../../utils/get-schema.js';
import { AuthDriver } from '../auth.js';
// 0x2: ACCOUNTDISABLE
// 0x10: LOCKOUT
// 0x800000: PASSWORD_EXPIRED
const INVALID_ACCOUNT_FLAGS = 0x800012;
export class LDAPAuthDriver extends AuthDriver {
bindClient;
config;
constructor(options, config) {
super(options, config);
const logger = useLogger();
const { bindDn, bindPassword, userDn, provider, clientUrl } = config;
if (bindDn === undefined ||
bindPassword === undefined ||
!userDn ||
!provider ||
(!clientUrl && !config['client']?.socketPath)) {
logger.error('Invalid provider config');
throw new InvalidProviderConfigError({ provider });
}
const clientConfig = typeof config['client'] === 'object' ? config['client'] : {};
this.bindClient = new Client({
url: clientUrl,
...clientConfig,
});
this.config = config;
}
async validateBindClient() {
const logger = useLogger();
const { bindDn, bindPassword, provider } = this.config;
try {
// Attempt to bind with the configured credentials
await this.bindClient.bind(bindDn, bindPassword);
// Healthcheck: verify bind user can read their own DN
const { searchEntries } = await this.bindClient.search(bindDn, {
scope: 'base',
});
if (searchEntries.length === 0) {
logger.warn('[LDAP] Failed to find bind user record');
throw new UnexpectedResponseError();
}
}
catch (err) {
const error = handleError(err);
if (isDirectusError(error, ErrorCode.InvalidCredentials)) {
logger.warn('Invalid bind user');
throw new InvalidProviderConfigError({ provider });
}
throw error;
}
}
async fetchUserInfo(baseDn, filter, scope) {
let { firstNameAttribute, lastNameAttribute, mailAttribute } = this.config;
firstNameAttribute ??= 'givenName';
lastNameAttribute ??= 'sn';
mailAttribute ??= 'mail';
try {
const searchOptions = {
attributes: ['uid', firstNameAttribute, lastNameAttribute, mailAttribute, 'userAccountControl'],
};
if (filter !== undefined)
searchOptions.filter = filter;
if (scope !== undefined)
searchOptions.scope = scope;
const { searchEntries } = await this.bindClient.search(baseDn, searchOptions);
if (searchEntries.length === 0) {
return undefined;
}
const entry = searchEntries[0];
const user = {
dn: entry['dn'],
userAccountControl: Number(getEntryValue(entry['userAccountControl']) ?? 0),
};
const firstName = getEntryValue(entry[firstNameAttribute]);
if (firstName)
user.firstName = firstName;
const lastName = getEntryValue(entry[lastNameAttribute]);
if (lastName)
user.lastName = lastName;
const email = getEntryValue(entry[mailAttribute]);
if (email)
user.email = email;
const uid = getEntryValue(entry['uid']);
if (uid)
user.uid = uid;
return user;
}
catch (err) {
throw handleError(err);
}
}
async fetchUserGroups(baseDn, filter, scope) {
try {
const searchOptions = {
attributes: ['cn'],
};
if (filter !== undefined)
searchOptions.filter = filter;
if (scope !== undefined)
searchOptions.scope = scope;
const { searchEntries } = await this.bindClient.search(baseDn, searchOptions);
const userGroups = [];
for (const entry of searchEntries) {
const cn = entry['cn'];
if (Array.isArray(cn)) {
userGroups.push(...cn.map((v) => String(v)));
}
else if (cn) {
userGroups.push(String(cn));
}
}
return userGroups;
}
catch (err) {
throw handleError(err);
}
}
async fetchUserId(userDn) {
const user = await this.knex
.select('id')
.from('directus_users')
.orWhereRaw('LOWER(??) = ?', ['external_identifier', userDn.toLowerCase()])
.first();
return user?.id;
}
async getUserID(payload) {
if (!payload['identifier']) {
throw new InvalidCredentialsError();
}
const logger = useLogger();
await this.validateBindClient();
const { userDn, userScope, userAttribute, groupDn, groupScope, groupAttribute, defaultRoleId, syncUserInfo } = this.config;
const userInfo = await this.fetchUserInfo(userDn, `(${validateLDAPAttribute(userAttribute ?? 'cn')}=${escapeFilterValue(payload['identifier'])})`, userScope ?? 'one');
if (!userInfo?.dn) {
throw new InvalidCredentialsError();
}
let userRole;
if (groupDn) {
const groupAttr = groupAttribute ?? 'member';
const memberValue = groupAttr.toLowerCase() === 'memberuid' && userInfo.uid ? userInfo.uid : userInfo.dn;
const userGroups = await this.fetchUserGroups(groupDn, `(${validateLDAPAttribute(groupAttr)}=${escapeFilterValue(memberValue)})`, groupScope ?? 'one');
if (userGroups.length) {
userRole = await this.knex
.select('id')
.from('directus_roles')
.whereRaw(`LOWER(??) IN (${userGroups.map(() => '?')})`, [
'name',
...userGroups.map((group) => group.toLowerCase()),
])
.first();
}
}
const userId = await this.fetchUserId(userInfo.dn);
if (userId) {
// Run hook so the end user has the chance to augment the
// user that is about to be updated
let emitPayload = {};
// Only sync roles if the AD groups are configured
if (groupDn) {
emitPayload = {
role: userRole?.id ?? defaultRoleId ?? null,
};
}
if (syncUserInfo) {
emitPayload = {
...emitPayload,
first_name: userInfo.firstName,
last_name: userInfo.lastName,
email: userInfo.email,
};
}
const schema = await getSchema();
const updatedUserPayload = await emitter.emitFilter(`auth.update`, emitPayload, { identifier: userInfo.dn, provider: this.config['provider'], providerPayload: { userInfo, userRole } }, { database: getDatabase(), schema, accountability: null });
// Update user to update properties that might have changed
const usersService = this.getUsersService(schema);
await usersService.updateOne(userId, updatedUserPayload);
return userId;
}
if (!userInfo) {
throw new InvalidCredentialsError();
}
const userPayload = {
provider: this.config['provider'],
first_name: userInfo.firstName,
last_name: userInfo.lastName,
email: userInfo.email,
external_identifier: userInfo.dn,
role: userRole?.id ?? defaultRoleId,
};
const schema = await getSchema();
// Run hook so the end user has the chance to augment the
// user that is about to be created
const updatedUserPayload = await emitter.emitFilter(`auth.create`, userPayload, { identifier: userInfo.dn, provider: this.config['provider'], providerPayload: { userInfo, userRole } }, { database: getDatabase(), schema, accountability: null });
try {
const usersService = this.getUsersService(schema);
await usersService.createOne(updatedUserPayload);
}
catch (e) {
if (isDirectusError(e, ErrorCode.RecordNotUnique)) {
logger.warn(e, '[LDAP] Failed to register user. User not unique');
throw new InvalidProviderError();
}
throw e;
}
return (await this.fetchUserId(userInfo.dn));
}
async verify(user, password) {
if (!user.external_identifier || !password) {
throw new InvalidCredentialsError();
}
const clientConfig = typeof this.config['client'] === 'object' ? this.config['client'] : {};
const client = new Client({
url: this.config['clientUrl'],
...clientConfig,
});
try {
await client.bind(user.external_identifier, password);
}
catch (err) {
throw handleError(err);
}
finally {
await client.unbind().catch(() => {
// Ignore unbind errors
});
}
}
async login(user, payload) {
await this.verify(user, payload['password']);
}
async refresh(user) {
await this.validateBindClient();
// Use scope 'base' to search the specific DN entry
const userInfo = await this.fetchUserInfo(user.external_identifier, undefined, 'base');
if (userInfo?.userAccountControl && userInfo.userAccountControl & INVALID_ACCOUNT_FLAGS) {
throw new InvalidCredentialsError();
}
}
}
const handleError = (e) => {
if (e instanceof InappropriateAuthError ||
e instanceof LdapInvalidCredentialsError ||
e instanceof InsufficientAccessError) {
return new InvalidCredentialsError();
}
if (e instanceof Error) {
return new ServiceUnavailableError({
service: 'ldap',
reason: `Service returned unexpected error: ${e.message}`,
});
}
return new ServiceUnavailableError({
service: 'ldap',
reason: 'Service returned unexpected error',
});
};
const getEntryValue = (value) => {
if (value === undefined)
return undefined;
if (Buffer.isBuffer(value)) {
return value.toString();
}
if (Array.isArray(value)) {
const first = value[0];
if (Buffer.isBuffer(first)) {
return first.toString();
}
return first;
}
return value;
};
/**
* Escape special characters in LDAP filter values according to RFC 4515
*/
const escapeFilterValue = (value) => {
return value
.replace(/\\/g, '\\5c')
.replace(/\*/g, '\\2a')
.replace(/\(/g, '\\28')
.replace(/\)/g, '\\29')
.replace(/\0/g, '\\00');
};
/**
* Validate LDAP attribute name according to RFC 4512
*/
const validateLDAPAttribute = (name) => {
if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(name) === false) {
throw new Error(`Invalid LDAP attribute name: "${name}"`);
}
return name;
};
export function createLDAPAuthRouter(provider) {
const router = Router();
const loginSchema = Joi.object({
identifier: Joi.string().required(),
password: Joi.string().required(),
mode: Joi.string().valid('cookie', 'json', 'session'),
otp: Joi.string(),
}).unknown();
router.post('/', asyncHandler(async (req, res, next) => {
const env = useEnv();
const accountability = createDefaultAccountability({
ip: getIPFromReq(req),
});
const userAgent = req.get('user-agent')?.substring(0, 1024);
if (userAgent)
accountability.userAgent = userAgent;
const origin = req.get('origin');
if (origin)
accountability.origin = origin;
const authenticationService = new AuthenticationService({
accountability: accountability,
schema: req.schema,
});
const { error } = loginSchema.validate(req.body);
if (error) {
throw new InvalidPayloadError({ reason: error.message });
}
const mode = req.body.mode ?? 'json';
const { accessToken, refreshToken, expires } = await authenticationService.login(provider, req.body, {
session: mode === 'session',
otp: req.body?.otp,
});
const payload = { access_token: accessToken, expires };
if (mode === 'json') {
payload.refresh_token = refreshToken;
}
if (mode === 'cookie') {
res.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, REFRESH_COOKIE_OPTIONS);
}
if (mode === 'session') {
res.cookie(env['SESSION_COOKIE_NAME'], accessToken, SESSION_COOKIE_OPTIONS);
}
res.locals['payload'] = { data: payload };
return next();
}), respond);
return router;
}