diff --git a/backend/src/authentication/providers/ldap/auth-ldap.config.ts b/backend/src/authentication/providers/ldap/auth-ldap.config.ts index 9428ba8f..113d00bd 100644 --- a/backend/src/authentication/providers/ldap/auth-ldap.config.ts +++ b/backend/src/authentication/providers/ldap/auth-ldap.config.ts @@ -53,6 +53,10 @@ export class AuthProviderLDAPConfig { @IsString({ each: true }) servers: string[] + @IsOptional() + @IsObject() + tlsOptions?: Record & { ca: string | string[] | Buffer } + @IsString() @IsNotEmpty() baseDN: string diff --git a/backend/src/authentication/providers/ldap/auth-ldap.interface.ts b/backend/src/authentication/providers/ldap/auth-ldap.interface.ts new file mode 100644 index 00000000..fa7e384c --- /dev/null +++ b/backend/src/authentication/providers/ldap/auth-ldap.interface.ts @@ -0,0 +1,9 @@ +import type { Entry } from 'ldapts' +import type { ConnectionOptions } from 'node:tls' +import { LDAP_COMMON_ATTR, LDAP_LOGIN_ATTR } from './auth-ldap.constants' + +export type LdapUserEntry = Entry & + Record, string> & { + [LDAP_COMMON_ATTR.MEMBER_OF]?: string[] + } +export type LdapCa = ConnectionOptions['ca'] diff --git a/backend/src/authentication/providers/ldap/auth-provider-ldap.service.spec.ts b/backend/src/authentication/providers/ldap/auth-provider-ldap.service.spec.ts index 453dd4a3..291babcb 100644 --- a/backend/src/authentication/providers/ldap/auth-provider-ldap.service.spec.ts +++ b/backend/src/authentication/providers/ldap/auth-provider-ldap.service.spec.ts @@ -1,6 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing' import { Mocked } from 'jest-mock' import { Client, InvalidCredentialsError } from 'ldapts' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' import { CONNECT_ERROR_CODE } from '../../../app.constants' import { USER_PERMISSION, USER_ROLE } from '../../../applications/users/constants/user' import { UserModel } from '../../../applications/users/models/user.model' @@ -77,6 +80,7 @@ describe(AuthProviderLDAP.name, () => { ;(authProviderLDAP as any).ldapConfig = next ;(authProviderLDAP as any).isAD = [LDAP_LOGIN_ATTR.SAM, LDAP_LOGIN_ATTR.UPN].includes(next.attributes.login) ;(authProviderLDAP as any).hasServiceBind = Boolean(next.serviceBindDN && next.serviceBindPassword) + ;(authProviderLDAP as any).clientOptionsPromise = (authProviderLDAP as any).buildClientOptions() } const mockBindResolve = () => { @@ -511,4 +515,37 @@ describe(AuthProviderLDAP.name, () => { const samLogin = (authProviderLDAP as any).buildLdapLogin('john') expect(samLogin).toBe('SYNC\\john') }) + + it('should load CA from file and keep inline CA values', async () => { + const tmpPath = await mkdtemp(path.join(tmpdir(), 'ldap-tls-')) + const caPath = path.join(tmpPath, 'ca.pem') + await writeFile(caPath, 'CA_PEM') + + setLdapConfig({ tlsOptions: { ca: [caPath, 'INLINE_CA'] } }) + + const initialClientOptions = await (authProviderLDAP as any).clientOptionsPromise + expect(initialClientOptions.tlsOptions).toEqual({ + ca: ['CA_PEM', 'INLINE_CA'] + }) + + const initialCa = initialClientOptions.tlsOptions.ca + ;(authProviderLDAP as any).ldapConfig.tlsOptions.ca = 'CHANGED_INLINE' + expect((await (authProviderLDAP as any).clientOptionsPromise).tlsOptions.ca).toBe(initialCa) + + await rm(tmpPath, { recursive: true, force: true }) + }) + + it('should warn and fallback when ca path is not readable', async () => { + const warnSpy = jest.spyOn(authProviderLDAP['logger'], 'warn').mockImplementation(() => undefined) + const unreadableCaPath = '/definitely/missing/ca.pem' + + const ca = await (authProviderLDAP as any).readTlsCa(unreadableCaPath) + + expect(ca).toBe(unreadableCaPath) + expect(warnSpy).toHaveBeenCalledWith( + expect.objectContaining({ + msg: expect.stringContaining('unable to read ca path, assume inline PEM content') + }) + ) + }) }) diff --git a/backend/src/authentication/providers/ldap/auth-provider-ldap.service.ts b/backend/src/authentication/providers/ldap/auth-provider-ldap.service.ts index f8bf22e6..29ff99cd 100644 --- a/backend/src/authentication/providers/ldap/auth-provider-ldap.service.ts +++ b/backend/src/authentication/providers/ldap/auth-provider-ldap.service.ts @@ -1,6 +1,8 @@ import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common' import { AndFilter, Client, ClientOptions, Entry, EqualityFilter, InvalidCredentialsError, OrFilter } from 'ldapts' +import { readFile } from 'node:fs/promises' import { CONNECT_ERROR_CODE } from '../../../app.constants' +import { isPathIsReadable } from '../../../applications/files/utils/files' import { USER_ROLE } from '../../../applications/users/constants/user' import type { CreateUserDto, UpdateUserDto } from '../../../applications/users/dto/create-or-update-user.dto' import { UserModel } from '../../../applications/users/models/user.model' @@ -12,11 +14,7 @@ import type { AUTH_SCOPE } from '../../constants/scope' import { AuthProvider } from '../auth-providers.models' import type { AuthProviderLDAPConfig } from './auth-ldap.config' import { ALL_LDAP_ATTRIBUTES, LDAP_COMMON_ATTR, LDAP_LOGIN_ATTR, LDAP_SEARCH_ATTR } from './auth-ldap.constants' - -type LdapUserEntry = Entry & - Record, string> & { - [LDAP_COMMON_ATTR.MEMBER_OF]?: string[] - } +import type { LdapCa, LdapUserEntry } from './auth-ldap.interface' @Injectable() export class AuthProviderLDAP implements AuthProvider { @@ -24,7 +22,7 @@ export class AuthProviderLDAP implements AuthProvider { private readonly ldapConfig: AuthProviderLDAPConfig = configuration.auth.ldap private readonly hasServiceBind = Boolean(this.ldapConfig.serviceBindDN && this.ldapConfig.serviceBindPassword) private readonly isAD = this.ldapConfig.attributes.login === LDAP_LOGIN_ATTR.SAM || this.ldapConfig.attributes.login === LDAP_LOGIN_ATTR.UPN - private clientOptions: ClientOptions = { timeout: 6000, connectTimeout: 6000, url: '' } + private readonly clientOptionsPromise: Promise = this.buildClientOptions() constructor( private readonly usersManager: UsersManager, @@ -100,8 +98,9 @@ export class AuthProviderLDAP implements AuthProvider { // Generic LDAP: build DN from login attribute + baseDN const bindUserDN = this.buildBindUserDN(ldapLogin) let error: InvalidCredentialsError | any + const clientOptions = await this.clientOptionsPromise for (const s of this.ldapConfig.servers) { - const client = new Client({ ...this.clientOptions, url: s }) + const client = new Client({ ...clientOptions, url: s }) let attemptedBindDN = bindUserDN try { if (this.hasServiceBind) { @@ -138,6 +137,48 @@ export class AuthProviderLDAP implements AuthProvider { return false } + private async buildClientOptions(): Promise { + const ca = await this.readTlsCa(this.ldapConfig.tlsOptions?.ca) + const tlsOptions = + this.ldapConfig.tlsOptions && typeof this.ldapConfig.tlsOptions === 'object' + ? { + ...this.ldapConfig.tlsOptions, + ...(ca !== undefined ? { ca } : {}) + } + : undefined + + return { + timeout: 6000, + connectTimeout: 6000, + url: '', + ...(tlsOptions ? { tlsOptions } : {}) + } + } + + private async readTlsCa(ca: LdapCa): Promise { + if (Buffer.isBuffer(ca)) { + return ca + } + if (Array.isArray(ca)) { + const values = await Promise.all(ca.map((v) => this.readTlsCa(v))) + return values.flat().filter((v): v is string | Buffer => typeof v === 'string' || Buffer.isBuffer(v)) + } + if (typeof ca !== 'string') { + this.logger.debug({ tag: this.readTlsCa.name, msg: 'ca file is not string or buffer' }) + return undefined + } + if (!(await isPathIsReadable(ca))) { + this.logger.warn({ tag: this.readTlsCa.name, msg: 'unable to read ca path, assume inline PEM content' }) + return ca + } + try { + return await readFile(ca, 'utf8') + } catch (e) { + this.logger.error({ tag: this.readTlsCa.name, msg: `unable to read ca path: ${e}` }) + return ca + } + } + private async checkAccess(login: string, client: Client, bindUserDN?: string): Promise { // Search for the LDAP entry and normalize attributes. const result = await this.findUserEntry(login, client, bindUserDN) diff --git a/environment/environment.dist.yaml b/environment/environment.dist.yaml index 7e2ec3ad..ebdf81bd 100755 --- a/environment/environment.dist.yaml +++ b/environment/environment.dist.yaml @@ -121,9 +121,16 @@ auth: # Multiple servers are tried in order until a bind/search succeeds. # required servers: [] - # baseDN: Distinguished name (e.g.: ou=people,dc=ldap,dc=sync-in,dc=com) - # Used as the search base for users, and for groups when adminGroup is a CN. - # required + # tlsOptions: Node.js TLS options used for the LDAP secure connection. + # Supports standard TLS options such as `ca`, `rejectUnauthorized`, etc. + # See: https://nodejs.org/api/tls.html + # https://nodejs.org/api/tls.html#tlscreatesecurecontextoptions + # Example: + # tlsOptions: + # rejectUnauthorized: true + # ca: [/app/certs/ca.pem] + # optional + tlsOptions: baseDN: ou=people,dc=ldap,dc=sync-in,dc=com # filter, e.g: (acl=admin) # Appended as-is to the LDAP search filter (trusted config).