UNPKG

@directus/api

Version:

Directus is a real-time API and App dashboard for managing SQL database content

349 lines (348 loc) 14.5 kB
import { useEnv } from '@directus/env'; import { ErrorCode, InvalidCredentialsError, InvalidPayloadError, InvalidProviderConfigError, InvalidProviderError, ServiceUnavailableError, UnexpectedResponseError, isDirectusError, } from '@directus/errors'; import { Router } from 'express'; import Joi from 'joi'; import ldap from 'ldapjs'; 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 { AuthDriver } from '../auth.js'; import { getSchema } from '../../utils/get-schema.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 = ldap.createClient({ url: clientUrl, reconnect: true, ...clientConfig }); this.bindClient.on('error', (err) => { logger.warn(err); }); this.config = config; } async validateBindClient() { const logger = useLogger(); const { bindDn, bindPassword, provider } = this.config; return new Promise((resolve, reject) => { // Healthcheck bind user this.bindClient.search(bindDn, {}, (err, res) => { if (err) { reject(handleError(err)); return; } res.on('searchEntry', () => { resolve(); }); res.on('error', () => { // Attempt to rebind on search error this.bindClient.bind(bindDn, bindPassword, (err) => { if (err) { const error = handleError(err); if (isDirectusError(error, ErrorCode.InvalidCredentials)) { logger.warn('Invalid bind user'); reject(new InvalidProviderConfigError({ provider })); } else { reject(error); } } else { resolve(); } }); }); res.on('end', (result) => { // Handle edge case where authenticated bind user cannot read their own DN // Status `0` is success if (result?.status !== 0) { logger.warn('[LDAP] Failed to find bind user record'); reject(new UnexpectedResponseError()); } }); }); }); } async fetchUserInfo(baseDn, filter, scope) { let { firstNameAttribute, lastNameAttribute, mailAttribute } = this.config; firstNameAttribute ??= 'givenName'; lastNameAttribute ??= 'sn'; mailAttribute ??= 'mail'; return new Promise((resolve, reject) => { // Search for the user in LDAP by filter this.bindClient.search(baseDn, { filter, scope, attributes: ['uid', firstNameAttribute, lastNameAttribute, mailAttribute, 'userAccountControl'], }, (err, res) => { if (err) { reject(handleError(err)); return; } res.on('searchEntry', ({ object }) => { const user = { dn: object['dn'], userAccountControl: Number(getEntryValue(object['userAccountControl']) ?? 0), }; const firstName = getEntryValue(object[firstNameAttribute]); if (firstName) user.firstName = firstName; const lastName = getEntryValue(object[lastNameAttribute]); if (lastName) user.lastName = lastName; const email = getEntryValue(object[mailAttribute]); if (email) user.email = email; const uid = getEntryValue(object['uid']); if (uid) user.uid = uid; resolve(user); }); res.on('error', (err) => { reject(handleError(err)); }); res.on('end', () => { resolve(undefined); }); }); }); } async fetchUserGroups(baseDn, filter, scope) { return new Promise((resolve, reject) => { let userGroups = []; // Search for the user info in LDAP by group attribute this.bindClient.search(baseDn, { filter, scope, attributes: ['cn'], }, (err, res) => { if (err) { reject(handleError(err)); return; } res.on('searchEntry', ({ object }) => { if (typeof object['cn'] === 'object') { userGroups = [...userGroups, ...object['cn']]; } else if (object['cn']) { userGroups.push(object['cn']); } }); res.on('error', (err) => { reject(handleError(err)); }); res.on('end', () => { resolve(userGroups); }); }); }); } 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, new ldap.EqualityFilter({ attribute: userAttribute ?? 'cn', value: payload['identifier'], }), userScope ?? 'one'); if (!userInfo?.dn) { throw new InvalidCredentialsError(); } let userRole; if (groupDn) { const userGroups = await this.fetchUserGroups(groupDn, new ldap.EqualityFilter({ attribute: groupAttribute ?? 'member', value: groupAttribute?.toLowerCase() === 'memberuid' && userInfo.uid ? userInfo.uid : userInfo.dn, }), 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(); } return new Promise((resolve, reject) => { const clientConfig = typeof this.config['client'] === 'object' ? this.config['client'] : {}; const client = ldap.createClient({ url: this.config['clientUrl'], ...clientConfig, reconnect: false, }); client.on('error', (err) => { reject(handleError(err)); }); client.bind(user.external_identifier, password, (err) => { if (err) { reject(handleError(err)); } else { resolve(); } client.destroy(); }); }); } async login(user, payload) { await this.verify(user, payload['password']); } async refresh(user) { await this.validateBindClient(); const userInfo = await this.fetchUserInfo(user.external_identifier); if (userInfo?.userAccountControl && userInfo.userAccountControl & INVALID_ACCOUNT_FLAGS) { throw new InvalidCredentialsError(); } } } const handleError = (e) => { if (e instanceof ldap.InappropriateAuthenticationError || e instanceof ldap.InvalidCredentialsError || e instanceof ldap.InsufficientAccessRightsError) { return new InvalidCredentialsError(); } return new ServiceUnavailableError({ service: 'ldap', reason: `Service returned unexpected error: ${e.message}`, }); }; const getEntryValue = (value) => { return typeof value === 'object' ? value[0] : value; }; 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; }