mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-25 11:16:31 -05:00
feat: add ldap strategy
Signed-off-by: Philip Molares <philip.molares@udo.edu>
This commit is contained in:
parent
90d97689fd
commit
82dd9f8885
3 changed files with 302 additions and 3 deletions
|
@ -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 {}
|
||||
|
|
13
src/identity/ldap/ldap-login.dto.ts
Normal file
13
src/identity/ldap/ldap-login.dto.ts
Normal file
|
@ -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;
|
||||
}
|
285
src/identity/ldap/ldap.strategy.ts
Normal file
285
src/identity/ldap/ldap.strategy.ts
Normal file
|
@ -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<string, string> = {
|
||||
/* 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<LdapPathParameters, unknown, LdapLoginDto>,
|
||||
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<string, string>) => {
|
||||
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<string, string>,
|
||||
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<string, string>} user - the user object from ldap
|
||||
* @private
|
||||
*/
|
||||
private async updateIdentity(
|
||||
identity: Identity,
|
||||
displayNameField: string,
|
||||
profilePictureField: string,
|
||||
user: Record<string, string>,
|
||||
): Promise<Identity> {
|
||||
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);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue