mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-15 07:34:42 -04:00
fix(repository): Move backend code into subdirectory
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
86584e705f
commit
bf30cbcf48
272 changed files with 87 additions and 67 deletions
115
backend/src/identity/identity.entity.ts
Normal file
115
backend/src/identity/identity.entity.ts
Normal file
|
@ -0,0 +1,115 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { User } from '../users/user.entity';
|
||||
import { ProviderType } from './provider-type.enum';
|
||||
|
||||
/**
|
||||
* The identity represents a single way for a user to login.
|
||||
* A 'user' can have any number of these.
|
||||
* Each one holds a type (local, github, twitter, etc.), if this type can have multiple instances (e.g. gitlab),
|
||||
* it also saves the name of the instance. Also if this identity shall be the syncSource is saved.
|
||||
*/
|
||||
@Entity()
|
||||
export class Identity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
/**
|
||||
* User that this identity corresponds to
|
||||
*/
|
||||
@ManyToOne((_) => User, (user) => user.identities, {
|
||||
onDelete: 'CASCADE', // This deletes the Identity, when the associated User is deleted
|
||||
})
|
||||
user: Promise<User>;
|
||||
|
||||
/**
|
||||
* The ProviderType of the identity
|
||||
*/
|
||||
@Column()
|
||||
providerType: string;
|
||||
|
||||
/**
|
||||
* The name of the provider.
|
||||
* Only set if there are multiple provider of that type (e.g. gitlab)
|
||||
*/
|
||||
@Column({
|
||||
nullable: true,
|
||||
type: 'text',
|
||||
})
|
||||
providerName: string | null;
|
||||
|
||||
/**
|
||||
* If the identity should be used as the sync source.
|
||||
* See [authentication doc](../../docs/content/dev/authentication.md) for clarification
|
||||
*/
|
||||
@Column()
|
||||
syncSource: boolean;
|
||||
|
||||
/**
|
||||
* When the identity was created.
|
||||
*/
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* When the identity was last updated.
|
||||
*/
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
/**
|
||||
* The unique identifier of a user from the login provider
|
||||
*/
|
||||
@Column({
|
||||
nullable: true,
|
||||
type: 'text',
|
||||
})
|
||||
providerUserId: string | null;
|
||||
|
||||
/**
|
||||
* Token used to access the OAuth provider in the users name.
|
||||
*/
|
||||
@Column({
|
||||
nullable: true,
|
||||
type: 'text',
|
||||
})
|
||||
oAuthAccessToken: string | null;
|
||||
|
||||
/**
|
||||
* The hash of the password
|
||||
* Only set when the type of the identity is local
|
||||
*/
|
||||
@Column({
|
||||
nullable: true,
|
||||
type: 'text',
|
||||
})
|
||||
passwordHash: string | null;
|
||||
|
||||
public static create(
|
||||
user: User,
|
||||
providerType: ProviderType,
|
||||
syncSource: boolean,
|
||||
): Omit<Identity, 'id' | 'createdAt' | 'updatedAt'> {
|
||||
const newIdentity = new Identity();
|
||||
newIdentity.user = Promise.resolve(user);
|
||||
newIdentity.providerType = providerType;
|
||||
newIdentity.providerName = null;
|
||||
newIdentity.syncSource = syncSource;
|
||||
newIdentity.providerUserId = null;
|
||||
newIdentity.oAuthAccessToken = null;
|
||||
newIdentity.passwordHash = null;
|
||||
return newIdentity;
|
||||
}
|
||||
}
|
29
backend/src/identity/identity.module.ts
Normal file
29
backend/src/identity/identity.module.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { LoggerModule } from '../logger/logger.module';
|
||||
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({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Identity, User]),
|
||||
UsersModule,
|
||||
PassportModule,
|
||||
LoggerModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [IdentityService, LocalStrategy, LdapStrategy],
|
||||
exports: [IdentityService, LocalStrategy, LdapStrategy],
|
||||
})
|
||||
export class IdentityModule {}
|
139
backend/src/identity/identity.service.spec.ts
Normal file
139
backend/src/identity/identity.service.spec.ts
Normal file
|
@ -0,0 +1,139 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import appConfigMock from '../config/mock/app.config.mock';
|
||||
import authConfigMock from '../config/mock/auth.config.mock';
|
||||
import {
|
||||
InvalidCredentialsError,
|
||||
NoLocalIdentityError,
|
||||
PasswordTooWeakError,
|
||||
} from '../errors/errors';
|
||||
import { LoggerModule } from '../logger/logger.module';
|
||||
import { User } from '../users/user.entity';
|
||||
import { checkPassword, hashPassword } from '../utils/password';
|
||||
import { Identity } from './identity.entity';
|
||||
import { IdentityService } from './identity.service';
|
||||
import { ProviderType } from './provider-type.enum';
|
||||
|
||||
describe('IdentityService', () => {
|
||||
let service: IdentityService;
|
||||
let user: User;
|
||||
let identityRepo: Repository<Identity>;
|
||||
const password = 'AStrongPasswordToStartWith123';
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
IdentityService,
|
||||
{
|
||||
provide: getRepositoryToken(Identity),
|
||||
useClass: Repository,
|
||||
},
|
||||
],
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [appConfigMock, authConfigMock],
|
||||
}),
|
||||
LoggerModule,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<IdentityService>(IdentityService);
|
||||
user = User.create('test', 'Testy') as User;
|
||||
identityRepo = module.get<Repository<Identity>>(
|
||||
getRepositoryToken(Identity),
|
||||
);
|
||||
});
|
||||
|
||||
describe('createLocalIdentity', () => {
|
||||
it('works', async () => {
|
||||
jest
|
||||
.spyOn(identityRepo, 'save')
|
||||
.mockImplementationOnce(
|
||||
async (identity: Identity): Promise<Identity> => identity,
|
||||
);
|
||||
const identity = await service.createLocalIdentity(user, password);
|
||||
await checkPassword(password, identity.passwordHash ?? '').then(
|
||||
(result) => expect(result).toBeTruthy(),
|
||||
);
|
||||
expect(await identity.user).toEqual(user);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateLocalPassword', () => {
|
||||
beforeEach(async () => {
|
||||
jest
|
||||
.spyOn(identityRepo, 'save')
|
||||
.mockImplementationOnce(
|
||||
async (identity: Identity): Promise<Identity> => identity,
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
async (identity: Identity): Promise<Identity> => identity,
|
||||
);
|
||||
const identity = await service.createLocalIdentity(user, password);
|
||||
user.identities = Promise.resolve([identity]);
|
||||
});
|
||||
it('works', async () => {
|
||||
const newPassword = 'ThisIsAStrongNewP@ssw0rd';
|
||||
const identity = await service.updateLocalPassword(user, newPassword);
|
||||
await checkPassword(newPassword, identity.passwordHash ?? '').then(
|
||||
(result) => expect(result).toBeTruthy(),
|
||||
);
|
||||
expect(await identity.user).toEqual(user);
|
||||
});
|
||||
it('fails, when user has no local identity', async () => {
|
||||
user.identities = Promise.resolve([]);
|
||||
await expect(service.updateLocalPassword(user, password)).rejects.toThrow(
|
||||
NoLocalIdentityError,
|
||||
);
|
||||
});
|
||||
it('fails, when new password is too weak', async () => {
|
||||
await expect(
|
||||
service.updateLocalPassword(user, 'password1'),
|
||||
).rejects.toThrow(PasswordTooWeakError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loginWithLocalIdentity', () => {
|
||||
it('works', async () => {
|
||||
const identity = Identity.create(
|
||||
user,
|
||||
ProviderType.LOCAL,
|
||||
false,
|
||||
) as Identity;
|
||||
identity.passwordHash = await hashPassword(password);
|
||||
user.identities = Promise.resolve([identity]);
|
||||
await expect(service.checkLocalPassword(user, password)).resolves.toEqual(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
describe('fails', () => {
|
||||
it('when the password is wrong', async () => {
|
||||
const identity = Identity.create(
|
||||
user,
|
||||
ProviderType.LOCAL,
|
||||
false,
|
||||
) as Identity;
|
||||
identity.passwordHash = await hashPassword(password);
|
||||
user.identities = Promise.resolve([identity]);
|
||||
await expect(
|
||||
service.checkLocalPassword(user, 'wrong_password'),
|
||||
).rejects.toThrow(InvalidCredentialsError);
|
||||
});
|
||||
it('when user has no local identity', async () => {
|
||||
user.identities = Promise.resolve([]);
|
||||
await expect(
|
||||
service.checkLocalPassword(user, password),
|
||||
).rejects.toThrow(NoLocalIdentityError);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
206
backend/src/identity/identity.service.ts
Normal file
206
backend/src/identity/identity.service.ts
Normal file
|
@ -0,0 +1,206 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { zxcvbnAsync, zxcvbnOptions } from '@zxcvbn-ts/core';
|
||||
import zxcvbnCommonPackage from '@zxcvbn-ts/language-common';
|
||||
import zxcvbnEnPackage from '@zxcvbn-ts/language-en';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import authConfiguration, { AuthConfig } from '../config/auth.config';
|
||||
import {
|
||||
InvalidCredentialsError,
|
||||
NoLocalIdentityError,
|
||||
NotInDBError,
|
||||
PasswordTooWeakError,
|
||||
} from '../errors/errors';
|
||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||
import { User } from '../users/user.entity';
|
||||
import { checkPassword, hashPassword } from '../utils/password';
|
||||
import { Identity } from './identity.entity';
|
||||
import { ProviderType } from './provider-type.enum';
|
||||
import { getFirstIdentityFromUser } from './utils';
|
||||
|
||||
@Injectable()
|
||||
export class IdentityService {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
@InjectRepository(Identity)
|
||||
private identityRepository: Repository<Identity>,
|
||||
@Inject(authConfiguration.KEY)
|
||||
private authConfig: AuthConfig,
|
||||
) {
|
||||
this.logger.setContext(IdentityService.name);
|
||||
const options = {
|
||||
dictionary: {
|
||||
...zxcvbnCommonPackage.dictionary,
|
||||
...zxcvbnEnPackage.dictionary,
|
||||
},
|
||||
graphs: zxcvbnCommonPackage.adjacencyGraphs,
|
||||
translations: zxcvbnEnPackage.translations,
|
||||
};
|
||||
zxcvbnOptions.setOptions(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Retrieve an identity by userId and providerType.
|
||||
* @param {string} userId - the userId of the wanted identity
|
||||
* @param {ProviderType} providerType - the providerType of the wanted identity
|
||||
*/
|
||||
async getIdentityFromUserIdAndProviderType(
|
||||
userId: string,
|
||||
providerType: ProviderType,
|
||||
): Promise<Identity> {
|
||||
const identity = await this.identityRepository.findOne({
|
||||
where: {
|
||||
providerUserId: userId,
|
||||
providerType: providerType,
|
||||
},
|
||||
relations: ['user'],
|
||||
});
|
||||
if (identity === null) {
|
||||
throw new NotInDBError(`Identity for user id '${userId}' not found`);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Update the given Identity with the given information
|
||||
* @param {Identity} identity - the identity to update
|
||||
* @param {string | undefined} displayName - the displayName to update the user with
|
||||
* @param {string | undefined} email - the email to update the user with
|
||||
* @param {string | undefined} profilePicture - the profilePicture to update the user with
|
||||
*/
|
||||
async updateIdentity(
|
||||
identity: Identity,
|
||||
displayName?: string,
|
||||
email?: string,
|
||||
profilePicture?: string,
|
||||
): Promise<Identity> {
|
||||
if (identity.syncSource) {
|
||||
// The identity is the syncSource and the user should be changed accordingly
|
||||
const user = await identity.user;
|
||||
let shouldSave = false;
|
||||
if (displayName) {
|
||||
user.displayName = displayName;
|
||||
shouldSave = true;
|
||||
}
|
||||
if (email) {
|
||||
user.email = email;
|
||||
shouldSave = true;
|
||||
}
|
||||
if (profilePicture) {
|
||||
// ToDo: sync image
|
||||
}
|
||||
if (shouldSave) {
|
||||
identity.user = Promise.resolve(user);
|
||||
return await this.identityRepository.save(identity);
|
||||
}
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Create a new generic identity.
|
||||
* @param {User} user - the user the identity should be added to
|
||||
* @param {ProviderType} providerType - the providerType of the identity
|
||||
* @param {string} userId - the userId the identity should have
|
||||
* @return {Identity} the new local identity
|
||||
*/
|
||||
async createIdentity(
|
||||
user: User,
|
||||
providerType: ProviderType,
|
||||
userId: string,
|
||||
): Promise<Identity> {
|
||||
const identity = Identity.create(user, providerType, false);
|
||||
identity.providerUserId = userId;
|
||||
return await this.identityRepository.save(identity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Create a new identity for internal auth
|
||||
* @param {User} user - the user the identity should be added to
|
||||
* @param {string} password - the password the identity should have
|
||||
* @return {Identity} the new local identity
|
||||
*/
|
||||
async createLocalIdentity(user: User, password: string): Promise<Identity> {
|
||||
const identity = Identity.create(user, ProviderType.LOCAL, false);
|
||||
await this.checkPasswordStrength(password);
|
||||
identity.passwordHash = await hashPassword(password);
|
||||
return await this.identityRepository.save(identity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Update the internal password of the specified the user
|
||||
* @param {User} user - the user, which identity should be updated
|
||||
* @param {string} newPassword - the new password
|
||||
* @throws {NoLocalIdentityError} the specified user has no internal identity
|
||||
* @return {Identity} the changed identity
|
||||
*/
|
||||
async updateLocalPassword(
|
||||
user: User,
|
||||
newPassword: string,
|
||||
): Promise<Identity> {
|
||||
const internalIdentity: Identity | undefined =
|
||||
await getFirstIdentityFromUser(user, ProviderType.LOCAL);
|
||||
if (internalIdentity === undefined) {
|
||||
this.logger.debug(
|
||||
`The user with the username ${user.username} does not have a internal identity.`,
|
||||
'updateLocalPassword',
|
||||
);
|
||||
throw new NoLocalIdentityError('This user has no internal identity.');
|
||||
}
|
||||
await this.checkPasswordStrength(newPassword);
|
||||
internalIdentity.passwordHash = await hashPassword(newPassword);
|
||||
return await this.identityRepository.save(internalIdentity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Checks if the user and password combination matches
|
||||
* @param {User} user - the user to use
|
||||
* @param {string} password - the password to use
|
||||
* @throws {InvalidCredentialsError} the password and user do not match
|
||||
* @throws {NoLocalIdentityError} the specified user has no internal identity
|
||||
*/
|
||||
async checkLocalPassword(user: User, password: string): Promise<void> {
|
||||
const internalIdentity: Identity | undefined =
|
||||
await getFirstIdentityFromUser(user, ProviderType.LOCAL);
|
||||
if (internalIdentity === undefined) {
|
||||
this.logger.debug(
|
||||
`The user with the username ${user.username} does not have a internal identity.`,
|
||||
'checkLocalPassword',
|
||||
);
|
||||
throw new NoLocalIdentityError('This user has no internal identity.');
|
||||
}
|
||||
if (!(await checkPassword(password, internalIdentity.passwordHash ?? ''))) {
|
||||
this.logger.debug(
|
||||
`Password check for ${user.username} did not succeed.`,
|
||||
'checkLocalPassword',
|
||||
);
|
||||
throw new InvalidCredentialsError('Password is not correct');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Check if the password is strong enough.
|
||||
* This check is performed against the minimalPasswordStrength of the {@link AuthConfig}.
|
||||
* @param {string} password - the password to check
|
||||
* @throws {PasswordTooWeakError} the password is too weak
|
||||
*/
|
||||
private async checkPasswordStrength(password: string): Promise<void> {
|
||||
const result = await zxcvbnAsync(password);
|
||||
if (result.score < this.authConfig.local.minimalPasswordStrength) {
|
||||
throw new PasswordTooWeakError();
|
||||
}
|
||||
}
|
||||
}
|
13
backend/src/identity/ldap/ldap-login.dto.ts
Normal file
13
backend/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
backend/src/identity/ldap/ldap.strategy.ts
Normal file
285
backend/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);
|
||||
}
|
||||
}
|
56
backend/src/identity/local/local.strategy.ts
Normal file
56
backend/src/identity/local/local.strategy.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthGuard, PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-local';
|
||||
|
||||
import {
|
||||
InvalidCredentialsError,
|
||||
NoLocalIdentityError,
|
||||
} from '../../errors/errors';
|
||||
import { ConsoleLoggerService } from '../../logger/console-logger.service';
|
||||
import { UserRelationEnum } from '../../users/user-relation.enum';
|
||||
import { User } from '../../users/user.entity';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
import { IdentityService } from '../identity.service';
|
||||
|
||||
@Injectable()
|
||||
export class LocalAuthGuard extends AuthGuard('local') {}
|
||||
|
||||
@Injectable()
|
||||
export class LocalStrategy extends PassportStrategy(Strategy, 'local') {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private userService: UsersService,
|
||||
private identityService: IdentityService,
|
||||
) {
|
||||
super();
|
||||
logger.setContext(LocalStrategy.name);
|
||||
}
|
||||
|
||||
async validate(username: string, password: string): Promise<User> {
|
||||
try {
|
||||
const user = await this.userService.getUserByUsername(username, [
|
||||
UserRelationEnum.IDENTITIES,
|
||||
]);
|
||||
await this.identityService.checkLocalPassword(user, password);
|
||||
return user;
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof InvalidCredentialsError ||
|
||||
e instanceof NoLocalIdentityError
|
||||
) {
|
||||
this.logger.log(
|
||||
`User with username '${username}' could not log in. Reason: ${e.name}`,
|
||||
);
|
||||
throw new UnauthorizedException(
|
||||
'This username and password combination is not valid.',
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
13
backend/src/identity/local/login.dto.ts
Normal file
13
backend/src/identity/local/login.dto.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
username: string;
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
17
backend/src/identity/local/register.dto.ts
Normal file
17
backend/src/identity/local/register.dto.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class RegisterDto {
|
||||
@IsString()
|
||||
username: string;
|
||||
|
||||
@IsString()
|
||||
displayName: string;
|
||||
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
13
backend/src/identity/local/update-password.dto.ts
Normal file
13
backend/src/identity/local/update-password.dto.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class UpdatePasswordDto {
|
||||
@IsString()
|
||||
currentPassword: string;
|
||||
@IsString()
|
||||
newPassword: string;
|
||||
}
|
18
backend/src/identity/provider-type.enum.ts
Normal file
18
backend/src/identity/provider-type.enum.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export enum ProviderType {
|
||||
LOCAL = 'local',
|
||||
LDAP = 'ldap',
|
||||
SAML = 'saml',
|
||||
OAUTH2 = 'oauth2',
|
||||
GITLAB = 'gitlab',
|
||||
GITHUB = 'github',
|
||||
FACEBOOK = 'facebook',
|
||||
TWITTER = 'twitter',
|
||||
DROPBOX = 'dropbox',
|
||||
GOOGLE = 'google',
|
||||
}
|
70
backend/src/identity/session.guard.ts
Normal file
70
backend/src/identity/session.guard.ts
Normal file
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Inject,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { GuestAccess } from '../config/guest_access.enum';
|
||||
import noteConfiguration, { NoteConfig } from '../config/note.config';
|
||||
import { NotInDBError } from '../errors/errors';
|
||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||
import { User } from '../users/user.entity';
|
||||
import { UsersService } from '../users/users.service';
|
||||
|
||||
/**
|
||||
* This guard checks if a session is present.
|
||||
*
|
||||
* If there is a username in `request.session.user` it will try to get this user from the database and put it into `request.user`. See {@link RequestUser}.
|
||||
* If there is no `request.session.user`, but any GuestAccess is configured, `request.session.authProvider` is set to `guest` to indicate a guest user.
|
||||
*
|
||||
* @throws UnauthorizedException
|
||||
*/
|
||||
@Injectable()
|
||||
export class SessionGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private userService: UsersService,
|
||||
@Inject(noteConfiguration.KEY)
|
||||
private noteConfig: NoteConfig,
|
||||
) {
|
||||
this.logger.setContext(SessionGuard.name);
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request: Request & {
|
||||
session?: { user: string; authProvider: string };
|
||||
user?: User;
|
||||
} = context.switchToHttp().getRequest();
|
||||
if (!request.session?.user) {
|
||||
if (this.noteConfig.guestAccess !== GuestAccess.DENY) {
|
||||
if (request.session) {
|
||||
request.session.authProvider = 'guest';
|
||||
return true;
|
||||
}
|
||||
}
|
||||
this.logger.debug('The user has no session.');
|
||||
throw new UnauthorizedException("You're not logged in");
|
||||
}
|
||||
try {
|
||||
request.user = await this.userService.getUserByUsername(
|
||||
request.session.user,
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof NotInDBError) {
|
||||
this.logger.debug(
|
||||
`The user '${request.session.user}' does not exist, but has a session.`,
|
||||
);
|
||||
throw new UnauthorizedException("You're not logged in");
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
27
backend/src/identity/utils.ts
Normal file
27
backend/src/identity/utils.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { User } from '../users/user.entity';
|
||||
import { Identity } from './identity.entity';
|
||||
import { ProviderType } from './provider-type.enum';
|
||||
|
||||
/**
|
||||
* Get the first identity of a given type from the user
|
||||
* @param {User} user - the user to get the identity from
|
||||
* @param {ProviderType} providerType - the type of the identity
|
||||
* @return {Identity | undefined} the first identity of the user or undefined, if such an identity can not be found
|
||||
*/
|
||||
export async function getFirstIdentityFromUser(
|
||||
user: User,
|
||||
providerType: ProviderType,
|
||||
): Promise<Identity | undefined> {
|
||||
const identities = await user.identities;
|
||||
if (identities === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return identities.find(
|
||||
(aIdentity) => aIdentity.providerType === providerType,
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue