diff --git a/src/identity/identity.module.ts b/src/identity/identity.module.ts index 2c5f9a9fa..dfbf9bb68 100644 --- a/src/identity/identity.module.ts +++ b/src/identity/identity.module.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -12,6 +12,7 @@ import { User } from '../users/user.entity'; import { UsersModule } from '../users/users.module'; import { Identity } from './identity.entity'; import { IdentityService } from './identity.service'; +import { LdapStrategy } from './ldap/ldap.strategy'; import { LocalStrategy } from './local/local.strategy'; @Module({ @@ -22,7 +23,7 @@ import { LocalStrategy } from './local/local.strategy'; LoggerModule, ], controllers: [], - providers: [IdentityService, LocalStrategy], - exports: [IdentityService, LocalStrategy], + providers: [IdentityService, LocalStrategy, LdapStrategy], + exports: [IdentityService, LocalStrategy, LdapStrategy], }) export class IdentityModule {} diff --git a/src/identity/ldap/ldap-login.dto.ts b/src/identity/ldap/ldap-login.dto.ts new file mode 100644 index 000000000..ebccf6d7a --- /dev/null +++ b/src/identity/ldap/ldap-login.dto.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { IsString } from 'class-validator'; + +export class LdapLoginDto { + @IsString() + username: string; + @IsString() + password: string; +} diff --git a/src/identity/ldap/ldap.strategy.ts b/src/identity/ldap/ldap.strategy.ts new file mode 100644 index 000000000..0c0c12495 --- /dev/null +++ b/src/identity/ldap/ldap.strategy.ts @@ -0,0 +1,285 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { + BadRequestException, + Inject, + Injectable, + InternalServerErrorException, + UnauthorizedException, +} from '@nestjs/common'; +import { AuthGuard, PassportStrategy } from '@nestjs/passport'; +import { Request } from 'express'; +import LdapAuth from 'ldapauth-fork'; +import { Strategy, VerifiedCallback } from 'passport-custom'; + +import authConfiguration, { + AuthConfig, + LDAPConfig, +} from '../../config/auth.config'; +import { NotInDBError } from '../../errors/errors'; +import { ConsoleLoggerService } from '../../logger/console-logger.service'; +import { UsersService } from '../../users/users.service'; +import { Identity } from '../identity.entity'; +import { IdentityService } from '../identity.service'; +import { ProviderType } from '../provider-type.enum'; +import { LdapLoginDto } from './ldap-login.dto'; + +const LDAP_ERROR_MAP: Record = { + /* eslint-disable @typescript-eslint/naming-convention */ + '530': 'Not Permitted to login at this time', + '531': 'Not permitted to logon at this workstation', + '532': 'Password expired', + '533': 'Account disabled', + '534': 'Account disabled', + '701': 'Account expired', + '773': 'User must reset password', + '775': 'User account locked', + default: 'Invalid username/password', + /* eslint-enable @typescript-eslint/naming-convention */ +}; + +interface LdapPathParameters { + ldapIdentifier: string; +} + +@Injectable() +export class LdapAuthGuard extends AuthGuard('ldap') {} + +@Injectable() +export class LdapStrategy extends PassportStrategy(Strategy, 'ldap') { + constructor( + private readonly logger: ConsoleLoggerService, + @Inject(authConfiguration.KEY) + private authConfig: AuthConfig, + private usersService: UsersService, + private identityService: IdentityService, + ) { + super( + ( + request: Request, + doneCallBack: VerifiedCallback, + ) => { + logger.setContext(LdapStrategy.name); + const ldapIdentifier = request.params.ldapIdentifier.toUpperCase(); + const ldapConfig = this.getLDAPConfig(ldapIdentifier); + const username = request.body.username; + const password = request.body.password; + this.loginWithLDAP(ldapConfig, username, password, doneCallBack); + }, + ); + } + + /** + * Try to log in the user with the given credentials. + * @param ldapConfig {LDAPConfig} - the ldap config to use + * @param username {string} - the username to login with + * @param password {string} - the password to login with + * @param doneCallBack {VerifiedCallback} - the callback to call if the login worked + * @returns {void} + * @throws {UnauthorizedException} - the user has given us incorrect credentials + * @throws {InternalServerErrorException} - if there are errors that we can't assign to wrong credentials + * @private + */ + private loginWithLDAP( + ldapConfig: LDAPConfig, + username: string, + password: string, + doneCallBack: VerifiedCallback, + ): void { + // initialize LdapAuth lib + const auth = new LdapAuth({ + url: ldapConfig.url, + searchBase: ldapConfig.searchBase, + searchFilter: ldapConfig.searchFilter, + searchAttributes: ldapConfig.searchAttributes, + bindDN: ldapConfig.bindDn, + bindCredentials: ldapConfig.bindCredentials, + tlsOptions: { + ca: ldapConfig.tlsCaCerts, + }, + }); + + auth.once('error', (error) => { + throw new InternalServerErrorException(error); + }); + // eslint-disable-next-line @typescript-eslint/no-empty-function + auth.on('error', () => {}); // Ignore further errors + auth.authenticate( + username, + password, + (error, user: Record) => { + auth.close(() => { + // We don't care about the closing + }); + if (error) { + try { + this.handleLDAPError(username, error); + } catch (error) { + doneCallBack(error, null); + return; + } + } + + if (!user) { + doneCallBack( + new UnauthorizedException(LDAP_ERROR_MAP['default']), + null, + ); + return; + } + + const userId = user[ldapConfig.userIdField]; + try { + this.createOrUpdateIdentity(userId, ldapConfig, user, username); + doneCallBack(null, username); + } catch (error) { + doneCallBack(error, null); + } + }, + ); + } + + private createOrUpdateIdentity( + userId: string, + ldapConfig: LDAPConfig, + user: Record, + username: string, + ): void { + this.identityService + .getIdentityFromUserIdAndProviderType(userId, ProviderType.LDAP) + .then(async (identity) => { + await this.updateIdentity( + identity, + ldapConfig.displayNameField, + ldapConfig.profilePictureField, + user, + ); + return; + }) + .catch(async (error) => { + if (error instanceof NotInDBError) { + // The user/identity does not yet exist + const newUser = await this.usersService.createUser( + username, + // if there is no displayName we use the username + user[ldapConfig.displayNameField] ?? username, + ); + const identity = await this.identityService.createIdentity( + newUser, + ProviderType.LDAP, + userId, + ); + await this.updateIdentity( + identity, + ldapConfig.displayNameField, + ldapConfig.profilePictureField, + user, + ); + return; + } else { + throw error; + } + }); + } + + /** + * Get and return the correct ldap config from the list of available configs. + * @param {string} ldapIdentifier- the identifier for the ldap config to be used + * @returns {LDAPConfig} - the ldap config with the given identifier + * @throws {BadRequestException} - there is no ldap config with the given identifier + * @private + */ + private getLDAPConfig(ldapIdentifier: string): LDAPConfig { + const ldapConfig: LDAPConfig | undefined = this.authConfig.ldap.find( + (config) => config.identifier === ldapIdentifier, + ); + if (!ldapConfig) { + this.logger.warn( + `The LDAP Config '${ldapIdentifier}' was requested, but doesn't exist`, + ); + throw new BadRequestException( + `There is no ldapConfig '${ldapIdentifier}'`, + ); + } + return ldapConfig; + } + + /** + * @async + * Update identity with data from the ldap user. + * @param {Identity} identity - the identity to sync + * @param {string} displayNameField - the field to be used as a display name + * @param {string} profilePictureField - the field to be used as a profile picture + * @param {Record} user - the user object from ldap + * @private + */ + private async updateIdentity( + identity: Identity, + displayNameField: string, + profilePictureField: string, + user: Record, + ): Promise { + let email: string | undefined = undefined; + if (user['mail']) { + if (Array.isArray(user['mail'])) { + email = user['mail'][0] as string; + } else { + email = user['mail']; + } + } + return await this.identityService.updateIdentity( + identity, + user[displayNameField], + email, + user[profilePictureField], + ); + } + + /** + * This method transforms the ldap error codes we receive into correct errors. + * It's very much inspired by https://github.com/vesse/passport-ldapauth/blob/b58c60000a7cc62165b112274b80c654adf59fff/lib/passport-ldapauth/strategy.js#L261 + * @throws {UnauthorizedException} if error indicates that the user is not allowed to log in + * @throws {InternalServerErrorException} in every other cases + * @private + */ + private handleLDAPError(username: string, error: Error | string): void { + // Invalid credentials / user not found are not errors but login failures + let message = ''; + if (typeof error === 'object') { + switch (error.name) { + case 'InvalidCredentialsError': { + message = 'Invalid username/password'; + const ldapComment = error.message.match( + /data ([\da-fA-F]*), v[\da-fA-F]*/, + ); + if (ldapComment && ldapComment[1]) { + message = + LDAP_ERROR_MAP[ldapComment[1]] || LDAP_ERROR_MAP['default']; + } + break; + } + case 'NoSuchObjectError': + message = 'Bad search base'; + break; + case 'ConstraintViolationError': + message = 'Bad search base'; + break; + default: + message = 'Invalid username/password'; + break; + } + } + if (message !== '') { + this.logger.log( + `User with username '${username}' could not log in. Reason: ${message}`, + ); + throw new UnauthorizedException(message); + } + + // Other errors are (most likely) real errors + throw new InternalServerErrorException(error); + } +}