diff --git a/src/identity/identity.service.spec.ts b/src/identity/identity.service.spec.ts new file mode 100644 index 000000000..687b3d122 --- /dev/null +++ b/src/identity/identity.service.spec.ts @@ -0,0 +1,120 @@ +/* + * SPDX-FileCopyrightText: 2021 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 { NotInDBError } 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; + const password = 'test123'; + + 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); + user = User.create('test', 'Testy') as User; + identityRepo = module.get>( + getRepositoryToken(Identity), + ); + }); + + describe('createLocalIdentity', () => { + it('works', async () => { + jest + .spyOn(identityRepo, 'save') + .mockImplementationOnce( + async (identity: Identity): Promise => identity, + ); + const identity = await service.createLocalIdentity(user, password); + await checkPassword(password, identity.passwordHash ?? '').then( + (result) => expect(result).toBeTruthy(), + ); + expect(identity.user).toEqual(user); + }); + }); + + describe('updateLocalPassword', () => { + beforeEach(async () => { + jest + .spyOn(identityRepo, 'save') + .mockImplementationOnce( + async (identity: Identity): Promise => identity, + ) + .mockImplementationOnce( + async (identity: Identity): Promise => identity, + ); + const identity = await service.createLocalIdentity(user, password); + user.identities = Promise.resolve([identity]); + }); + it('works', async () => { + const newPassword = 'newPassword'; + const identity = await service.updateLocalPassword(user, newPassword); + await checkPassword(newPassword, identity.passwordHash ?? '').then( + (result) => expect(result).toBeTruthy(), + ); + expect(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( + NotInDBError, + ); + }); + }); + + describe('loginWithLocalIdentity', () => { + it('works', async () => { + const identity = Identity.create(user, ProviderType.LOCAL); + identity.passwordHash = await hashPassword(password); + user.identities = Promise.resolve([identity]); + await expect( + service.loginWithLocalIdentity(user, password), + ).resolves.toEqual(undefined); + }); + describe('fails', () => { + it('when user has no local identity', async () => { + user.identities = Promise.resolve([]); + await expect( + service.updateLocalPassword(user, password), + ).rejects.toThrow(NotInDBError); + }); + it('when the password is wrong', async () => { + user.identities = Promise.resolve([]); + await expect( + service.updateLocalPassword(user, 'wrong_password'), + ).rejects.toThrow(NotInDBError); + }); + }); + }); +}); diff --git a/src/identity/identity.service.ts b/src/identity/identity.service.ts new file mode 100644 index 000000000..10c763d33 --- /dev/null +++ b/src/identity/identity.service.ts @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: 2021 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 { Repository } from 'typeorm'; + +import authConfiguration, { AuthConfig } from '../config/auth.config'; +import { NotInDBError } 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, + @Inject(authConfiguration.KEY) + private authConfig: AuthConfig, + ) { + this.logger.setContext(IdentityService.name); + } + + /** + * @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 { + const identity = Identity.create(user, ProviderType.LOCAL); + 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 {NotInDBError} the specified user has no internal identity + * @return {Identity} the changed identity + */ + async updateLocalPassword( + user: User, + newPassword: string, + ): Promise { + 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 NotInDBError('This user has no internal identity.'); + } + internalIdentity.passwordHash = await hashPassword(newPassword); + return await this.identityRepository.save(internalIdentity); + } + + /** + * @async + * Login the user with their username and password + * @param {User} user - the user to use + * @param {string} password - the password to use + * @throws {NotInDBError} the specified user can't be logged in + */ + async loginWithLocalIdentity(user: User, password: string): Promise { + 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.`, + 'loginWithLocalIdentity', + ); + throw new NotInDBError(); + } + if (!(await checkPassword(password, internalIdentity.passwordHash ?? ''))) { + this.logger.debug( + `Password check for ${user.userName} did not succeed.`, + 'loginWithLocalIdentity', + ); + throw new NotInDBError(); + } + } +}