mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-06-06 01:21:39 -04:00
feat(auth): refactor auth, add oidc
Some checks are pending
Docker / build-and-push (frontend) (push) Waiting to run
Docker / build-and-push (backend) (push) Waiting to run
Deploy HD2 docs to Netlify / Deploys to netlify (push) Waiting to run
E2E Tests / backend-sqlite (push) Waiting to run
E2E Tests / backend-mariadb (push) Waiting to run
E2E Tests / backend-postgres (push) Waiting to run
E2E Tests / Build test build of frontend (push) Waiting to run
E2E Tests / frontend-cypress (1) (push) Blocked by required conditions
E2E Tests / frontend-cypress (2) (push) Blocked by required conditions
E2E Tests / frontend-cypress (3) (push) Blocked by required conditions
Lint and check format / Lint files and check formatting (push) Waiting to run
REUSE Compliance Check / reuse (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Static Analysis / Njsscan code scanning (push) Waiting to run
Static Analysis / CodeQL analysis (push) Waiting to run
Run tests & build / Test and build with NodeJS 20 (push) Waiting to run
Some checks are pending
Docker / build-and-push (frontend) (push) Waiting to run
Docker / build-and-push (backend) (push) Waiting to run
Deploy HD2 docs to Netlify / Deploys to netlify (push) Waiting to run
E2E Tests / backend-sqlite (push) Waiting to run
E2E Tests / backend-mariadb (push) Waiting to run
E2E Tests / backend-postgres (push) Waiting to run
E2E Tests / Build test build of frontend (push) Waiting to run
E2E Tests / frontend-cypress (1) (push) Blocked by required conditions
E2E Tests / frontend-cypress (2) (push) Blocked by required conditions
E2E Tests / frontend-cypress (3) (push) Blocked by required conditions
Lint and check format / Lint files and check formatting (push) Waiting to run
REUSE Compliance Check / reuse (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Static Analysis / Njsscan code scanning (push) Waiting to run
Static Analysis / CodeQL analysis (push) Waiting to run
Run tests & build / Test and build with NodeJS 20 (push) Waiting to run
Thanks to all HedgeDoc team members for the time discussing, helping with weird Nest issues, providing feedback and suggestions! Co-authored-by: Philip Molares <philip.molares@udo.edu> Signed-off-by: Philip Molares <philip.molares@udo.edu> Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
parent
1609f3e01f
commit
7f665fae4b
109 changed files with 2927 additions and 1700 deletions
191
backend/src/identity/ldap/ldap.service.ts
Normal file
191
backend/src/identity/ldap/ldap.service.ts
Normal file
|
@ -0,0 +1,191 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { HttpException } from '@nestjs/common/exceptions/http.exception';
|
||||
import LdapAuth from 'ldapauth-fork';
|
||||
|
||||
import authConfiguration, {
|
||||
AuthConfig,
|
||||
LDAPConfig,
|
||||
} from '../../config/auth.config';
|
||||
import { ConsoleLoggerService } from '../../logger/console-logger.service';
|
||||
import { FullUserInfoWithIdDto } from '../../users/user-info.dto';
|
||||
import { Username } from '../../utils/username';
|
||||
|
||||
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 */
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class LdapService {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
@Inject(authConfiguration.KEY)
|
||||
private authConfig: AuthConfig,
|
||||
) {
|
||||
logger.setContext(LdapService.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to log in the user with the given credentials.
|
||||
*
|
||||
* @param ldapConfig {LDAPConfig} - the ldap config to use
|
||||
* @param username {string} - the username to log in with
|
||||
* @param password {string} - the password to log in with
|
||||
* @returns {FullUserInfoWithIdDto} - the user info of the user that logged in
|
||||
* @throws {UnauthorizedException} - the user has given us incorrect credentials
|
||||
* @throws {InternalServerErrorException} - if there are errors that we can't assign to wrong credentials
|
||||
* @private
|
||||
*/
|
||||
getUserInfoFromLdap(
|
||||
ldapConfig: LDAPConfig,
|
||||
username: string, // This is not of type Username, because LDAP server may use mixed case usernames
|
||||
password: string,
|
||||
): Promise<FullUserInfoWithIdDto> {
|
||||
return new Promise<FullUserInfoWithIdDto>((resolve, reject) => {
|
||||
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: string | Error) => {
|
||||
const exception = this.getLdapException(username, error);
|
||||
return reject(exception);
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
auth.on('error', () => {}); // Ignore further errors
|
||||
auth.authenticate(
|
||||
username,
|
||||
password,
|
||||
(error, userInfo: Record<string, string>) => {
|
||||
auth.close(() => {
|
||||
// We don't care about the closing
|
||||
});
|
||||
if (error) {
|
||||
const exception = this.getLdapException(username, error);
|
||||
return reject(exception);
|
||||
}
|
||||
|
||||
if (!userInfo) {
|
||||
return reject(new UnauthorizedException(LDAP_ERROR_MAP['default']));
|
||||
}
|
||||
|
||||
let email: string | undefined = undefined;
|
||||
if (userInfo['mail']) {
|
||||
if (Array.isArray(userInfo['mail'])) {
|
||||
email = userInfo['mail'][0] as string;
|
||||
} else {
|
||||
email = userInfo['mail'];
|
||||
}
|
||||
}
|
||||
|
||||
return resolve({
|
||||
email,
|
||||
username: username as Username,
|
||||
id: userInfo[ldapConfig.userIdField],
|
||||
displayName: userInfo[ldapConfig.displayNameField] ?? username,
|
||||
photoUrl: undefined, // TODO LDAP stores images as binaries,
|
||||
// we need to convert them into a data-URL or alike
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {NotFoundException} - there is no ldap config with the given identifier
|
||||
* @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 NotFoundException(`There is no ldapConfig '${ldapIdentifier}'`);
|
||||
}
|
||||
return ldapConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @returns {HttpException} - the matching HTTP exception to throw to the client
|
||||
* @throws {UnauthorizedException} if error indicates that the user is not allowed to log in
|
||||
* @throws {InternalServerErrorException} in every other case
|
||||
*/
|
||||
private getLdapException(
|
||||
username: string,
|
||||
error: Error | string,
|
||||
): HttpException {
|
||||
// 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 !== '' ||
|
||||
(typeof error === 'string' && error.startsWith('no such user:'))
|
||||
) {
|
||||
this.logger.log(
|
||||
`User with username '${username}' could not log in. Reason: ${message}`,
|
||||
);
|
||||
return new UnauthorizedException(message);
|
||||
}
|
||||
|
||||
// Other errors are (most likely) real errors
|
||||
return new InternalServerErrorException(error);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue