refactor(server): api keys (#1339)

* refactor: api keys

* refactor: test module

* chore: tests

* chore: fix provider

* refactor: test mock repos
This commit is contained in:
Jason Rasmussen
2023-01-18 09:40:15 -05:00
committed by GitHub
parent 0c469cc712
commit 92972ac776
33 changed files with 538 additions and 312 deletions

View File

@@ -0,0 +1,16 @@
import { APIKeyEntity } from '@app/infra';
export const IKeyRepository = 'IKeyRepository';
export interface IKeyRepository {
create(dto: Partial<APIKeyEntity>): Promise<APIKeyEntity>;
update(userId: string, id: number, dto: Partial<APIKeyEntity>): Promise<APIKeyEntity>;
delete(userId: string, id: number): Promise<void>;
/**
* Includes the hashed `key` for verification
* @param id
*/
getKey(id: number): Promise<APIKeyEntity | null>;
getById(userId: string, id: number): Promise<APIKeyEntity | null>;
getByUserId(userId: string): Promise<APIKeyEntity[]>;
}

View File

@@ -0,0 +1,142 @@
import { APIKeyEntity } from '@app/infra';
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { authStub, entityStub, newCryptoRepositoryMock, newKeyRepositoryMock } from '../../test';
import { ICryptoRepository } from '../auth';
import { IKeyRepository } from './api-key.repository';
import { APIKeyService } from './api-key.service';
const adminKey = Object.freeze({
id: 1,
name: 'My Key',
key: 'my-api-key (hashed)',
userId: authStub.admin.id,
user: entityStub.admin,
} as APIKeyEntity);
const token = Buffer.from('1:my-api-key', 'utf8').toString('base64');
describe(APIKeyService.name, () => {
let sut: APIKeyService;
let keyMock: jest.Mocked<IKeyRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
beforeEach(async () => {
cryptoMock = newCryptoRepositoryMock();
keyMock = newKeyRepositoryMock();
sut = new APIKeyService(cryptoMock, keyMock);
});
describe('create', () => {
it('should create a new key', async () => {
keyMock.create.mockResolvedValue(adminKey);
await sut.create(authStub.admin, { name: 'Test Key' });
expect(keyMock.create).toHaveBeenCalledWith({
key: 'cmFuZG9tLWJ5dGVz (hashed)',
name: 'Test Key',
userId: authStub.admin.id,
});
expect(cryptoMock.randomBytes).toHaveBeenCalled();
expect(cryptoMock.hash).toHaveBeenCalled();
});
it('should not require a name', async () => {
keyMock.create.mockResolvedValue(adminKey);
await sut.create(authStub.admin, {});
expect(keyMock.create).toHaveBeenCalledWith({
key: 'cmFuZG9tLWJ5dGVz (hashed)',
name: 'API Key',
userId: authStub.admin.id,
});
expect(cryptoMock.randomBytes).toHaveBeenCalled();
expect(cryptoMock.hash).toHaveBeenCalled();
});
});
describe('update', () => {
it('should throw an error if the key is not found', async () => {
keyMock.getById.mockResolvedValue(null);
await expect(sut.update(authStub.admin, 1, { name: 'New Name' })).rejects.toBeInstanceOf(BadRequestException);
expect(keyMock.update).not.toHaveBeenCalledWith(1);
});
it('should update a key', async () => {
keyMock.getById.mockResolvedValue(adminKey);
await sut.update(authStub.admin, 1, { name: 'New Name' });
expect(keyMock.update).toHaveBeenCalledWith(authStub.admin.id, 1, { name: 'New Name' });
});
});
describe('delete', () => {
it('should throw an error if the key is not found', async () => {
keyMock.getById.mockResolvedValue(null);
await expect(sut.delete(authStub.admin, 1)).rejects.toBeInstanceOf(BadRequestException);
expect(keyMock.delete).not.toHaveBeenCalledWith(1);
});
it('should delete a key', async () => {
keyMock.getById.mockResolvedValue(adminKey);
await sut.delete(authStub.admin, 1);
expect(keyMock.delete).toHaveBeenCalledWith(authStub.admin.id, 1);
});
});
describe('getById', () => {
it('should throw an error if the key is not found', async () => {
keyMock.getById.mockResolvedValue(null);
await expect(sut.getById(authStub.admin, 1)).rejects.toBeInstanceOf(BadRequestException);
expect(keyMock.getById).toHaveBeenCalledWith(authStub.admin.id, 1);
});
it('should get a key by id', async () => {
keyMock.getById.mockResolvedValue(adminKey);
await sut.getById(authStub.admin, 1);
expect(keyMock.getById).toHaveBeenCalledWith(authStub.admin.id, 1);
});
});
describe('getAll', () => {
it('should return all the keys for a user', async () => {
keyMock.getByUserId.mockResolvedValue([adminKey]);
await expect(sut.getAll(authStub.admin)).resolves.toHaveLength(1);
expect(keyMock.getByUserId).toHaveBeenCalledWith(authStub.admin.id);
});
});
describe('validate', () => {
it('should throw an error for an invalid id', async () => {
keyMock.getKey.mockResolvedValue(null);
await expect(sut.validate(token)).rejects.toBeInstanceOf(UnauthorizedException);
expect(keyMock.getKey).toHaveBeenCalledWith(1);
expect(cryptoMock.compareSync).not.toHaveBeenCalled();
});
it('should validate the token', async () => {
keyMock.getKey.mockResolvedValue(adminKey);
await expect(sut.validate(token)).resolves.toEqual(authStub.admin);
expect(keyMock.getKey).toHaveBeenCalledWith(1);
expect(cryptoMock.compareSync).toHaveBeenCalledWith('my-api-key', 'my-api-key (hashed)');
});
});
});

View File

@@ -0,0 +1,83 @@
import { UserEntity } from '@app/infra';
import { BadRequestException, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthUserDto, ICryptoRepository } from '../auth';
import { IKeyRepository } from './api-key.repository';
import { APIKeyCreateDto } from './dto/api-key-create.dto';
import { APIKeyCreateResponseDto } from './response-dto/api-key-create-response.dto';
import { APIKeyResponseDto, mapKey } from './response-dto/api-key-response.dto';
@Injectable()
export class APIKeyService {
constructor(
@Inject(ICryptoRepository) private crypto: ICryptoRepository,
@Inject(IKeyRepository) private repository: IKeyRepository,
) {}
async create(authUser: AuthUserDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
const key = this.crypto.randomBytes(24).toString('base64').replace(/\W/g, '');
const entity = await this.repository.create({
key: await this.crypto.hash(key, 10),
name: dto.name || 'API Key',
userId: authUser.id,
});
const secret = Buffer.from(`${entity.id}:${key}`, 'utf8').toString('base64');
return { secret, apiKey: mapKey(entity) };
}
async update(authUser: AuthUserDto, id: number, dto: APIKeyCreateDto): Promise<APIKeyResponseDto> {
const exists = await this.repository.getById(authUser.id, id);
if (!exists) {
throw new BadRequestException('API Key not found');
}
return this.repository.update(authUser.id, id, {
name: dto.name,
});
}
async delete(authUser: AuthUserDto, id: number): Promise<void> {
const exists = await this.repository.getById(authUser.id, id);
if (!exists) {
throw new BadRequestException('API Key not found');
}
await this.repository.delete(authUser.id, id);
}
async getById(authUser: AuthUserDto, id: number): Promise<APIKeyResponseDto> {
const key = await this.repository.getById(authUser.id, id);
if (!key) {
throw new BadRequestException('API Key not found');
}
return mapKey(key);
}
async getAll(authUser: AuthUserDto): Promise<APIKeyResponseDto[]> {
const keys = await this.repository.getByUserId(authUser.id);
return keys.map(mapKey);
}
async validate(token: string): Promise<AuthUserDto> {
const [_id, key] = Buffer.from(token, 'base64').toString('utf8').split(':');
const id = Number(_id);
if (id && key) {
const entity = await this.repository.getKey(id);
if (entity?.user && entity?.key && this.crypto.compareSync(key, entity.key)) {
const user = entity.user as UserEntity;
return {
id: user.id,
email: user.email,
isAdmin: user.isAdmin,
isPublicUser: false,
isAllowUpload: true,
};
}
}
throw new UnauthorizedException('Invalid API Key');
}
}

View File

@@ -0,0 +1,8 @@
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class APIKeyCreateDto {
@IsString()
@IsNotEmpty()
@IsOptional()
name?: string;
}

View File

@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class APIKeyUpdateDto {
@IsString()
@IsNotEmpty()
name!: string;
}

View File

@@ -0,0 +1,2 @@
export * from './api-key-create.dto';
export * from './api-key-update.dto';

View File

@@ -0,0 +1,4 @@
export * from './api-key.repository';
export * from './api-key.service';
export * from './dto';
export * from './response-dto';

View File

@@ -0,0 +1,6 @@
import { APIKeyResponseDto } from './api-key-response.dto';
export class APIKeyCreateResponseDto {
secret!: string;
apiKey!: APIKeyResponseDto;
}

View File

@@ -0,0 +1,19 @@
import { APIKeyEntity } from '@app/infra';
import { ApiProperty } from '@nestjs/swagger';
export class APIKeyResponseDto {
@ApiProperty({ type: 'integer' })
id!: number;
name!: string;
createdAt!: string;
updatedAt!: string;
}
export function mapKey(entity: APIKeyEntity): APIKeyResponseDto {
return {
id: entity.id,
name: entity.name,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
};
}

View File

@@ -0,0 +1,2 @@
export * from './api-key-create-response.dto';
export * from './api-key-response.dto';

View File

@@ -0,0 +1,7 @@
export const ICryptoRepository = 'ICryptoRepository';
export interface ICryptoRepository {
randomBytes(size: number): Buffer;
hash(data: string | Buffer, saltOrRounds: string | number): Promise<string>;
compareSync(data: Buffer | string, encrypted: string): boolean;
}

View File

@@ -1 +1,2 @@
export * from './crypto.repository';
export * from './dto';

View File

@@ -1,8 +1,10 @@
import { DynamicModule, Global, Module, ModuleMetadata, Provider } from '@nestjs/common';
import { APIKeyService } from './api-key';
import { UserService } from './user';
const providers: Provider[] = [
//
APIKeyService,
UserService,
];

View File

@@ -1,3 +1,4 @@
export * from './api-key';
export * from './auth';
export * from './domain.module';
export * from './user';

View File

@@ -1,10 +1,11 @@
import { IUserRepository } from '@app/domain';
import { UserEntity } from '@app/infra';
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { AuthUserDto } from '../auth';
import { IUserRepository } from '@app/domain';
import { when } from 'jest-when';
import { UserService } from './user.service';
import { newUserRepositoryMock } from '../../test';
import { AuthUserDto } from '../auth';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserService } from './user.service';
const adminUserAuth: AuthUserDto = Object.freeze({
id: 'admin_id',
@@ -73,28 +74,18 @@ const adminUserResponse = Object.freeze({
createdAt: '2021-01-01',
});
describe('UserService', () => {
describe(UserService.name, () => {
let sut: UserService;
let userRepositoryMock: jest.Mocked<IUserRepository>;
beforeEach(() => {
userRepositoryMock = {
get: jest.fn(),
getAdmin: jest.fn(),
getByEmail: jest.fn(),
getByOAuthId: jest.fn(),
getList: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
restore: jest.fn(),
};
beforeEach(async () => {
userRepositoryMock = newUserRepositoryMock();
sut = new UserService(userRepositoryMock);
when(userRepositoryMock.get).calledWith(adminUser.id).mockResolvedValue(adminUser);
when(userRepositoryMock.get).calledWith(adminUser.id, undefined).mockResolvedValue(adminUser);
when(userRepositoryMock.get).calledWith(immichUser.id).mockResolvedValue(immichUser);
when(userRepositoryMock.get).calledWith(immichUser.id, undefined).mockResolvedValue(immichUser);
sut = new UserService(userRepositoryMock);
});
describe('getAllUsers', () => {
@@ -285,9 +276,7 @@ describe('UserService', () => {
describe('deleteUser', () => {
it('cannot delete admin user', async () => {
const result = sut.deleteUser(adminUserAuth, adminUserAuth.id);
await expect(result).rejects.toBeInstanceOf(ForbiddenException);
await expect(sut.deleteUser(adminUserAuth, adminUserAuth.id)).rejects.toBeInstanceOf(ForbiddenException);
});
it('should require the auth user be an admin', async () => {

View File

@@ -0,0 +1,12 @@
import { IKeyRepository } from '../src';
export const newKeyRepositoryMock = (): jest.Mocked<IKeyRepository> => {
return {
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
getKey: jest.fn(),
getById: jest.fn(),
getByUserId: jest.fn(),
};
};

View File

@@ -0,0 +1,9 @@
import { ICryptoRepository } from '../src';
export const newCryptoRepositoryMock = (): jest.Mocked<ICryptoRepository> => {
return {
randomBytes: jest.fn().mockReturnValue(Buffer.from('random-bytes', 'utf8')),
compareSync: jest.fn().mockReturnValue(true),
hash: jest.fn().mockImplementation((input) => Promise.resolve(`${input} (hashed)`)),
};
};

View File

@@ -0,0 +1,44 @@
import { UserEntity } from '@app/infra';
import { AuthUserDto } from '../src';
export const authStub = {
admin: Object.freeze<AuthUserDto>({
id: 'admin_id',
email: 'admin@test.com',
isAdmin: true,
isPublicUser: false,
isAllowUpload: true,
}),
user1: Object.freeze<AuthUserDto>({
id: 'immich_id',
email: 'immich@test.com',
isAdmin: false,
isPublicUser: false,
isAllowUpload: true,
}),
};
export const entityStub = {
admin: Object.freeze<UserEntity>({
...authStub.admin,
password: 'admin_password',
firstName: 'admin_first_name',
lastName: 'admin_last_name',
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
createdAt: '2021-01-01',
tags: [],
}),
user1: Object.freeze<UserEntity>({
...authStub.user1,
password: 'immich_password',
firstName: 'immich_first_name',
lastName: 'immich_last_name',
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
createdAt: '2021-01-01',
tags: [],
}),
};

View File

@@ -0,0 +1,4 @@
export * from './api-key.repository.mock';
export * from './crypto.repository.mock';
export * from './fixtures';
export * from './user.repository.mock';

View File

@@ -0,0 +1,15 @@
import { IUserRepository } from '../src';
export const newUserRepositoryMock = (): jest.Mocked<IUserRepository> => {
return {
get: jest.fn(),
getAdmin: jest.fn(),
getByEmail: jest.fn(),
getByOAuthId: jest.fn(),
getList: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
restore: jest.fn(),
};
};

View File

@@ -0,0 +1,9 @@
import { ICryptoRepository } from '@app/domain';
import { compareSync, hash } from 'bcrypt';
import { randomBytes } from 'crypto';
export const cryptoRepository: ICryptoRepository = {
randomBytes,
hash,
compareSync,
};

View File

@@ -0,0 +1,45 @@
import { IKeyRepository } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { APIKeyEntity } from '../entities';
@Injectable()
export class APIKeyRepository implements IKeyRepository {
constructor(@InjectRepository(APIKeyEntity) private repository: Repository<APIKeyEntity>) {}
async create(dto: Partial<APIKeyEntity>): Promise<APIKeyEntity> {
return this.repository.save(dto);
}
async update(userId: string, id: number, dto: Partial<APIKeyEntity>): Promise<APIKeyEntity> {
await this.repository.update({ userId, id }, dto);
return this.repository.findOneOrFail({ where: { id: dto.id } });
}
async delete(userId: string, id: number): Promise<void> {
await this.repository.delete({ userId, id });
}
getKey(id: number): Promise<APIKeyEntity | null> {
return this.repository.findOne({
select: {
id: true,
key: true,
userId: true,
},
where: { id },
relations: {
user: true,
},
});
}
getById(userId: string, id: number): Promise<APIKeyEntity | null> {
return this.repository.findOne({ where: { userId, id } });
}
getByUserId(userId: string): Promise<APIKeyEntity[]> {
return this.repository.find({ where: { userId }, order: { createdAt: 'DESC' } });
}
}

View File

@@ -1 +1,2 @@
export * from './api-key.repository';
export * from './user.repository';

View File

@@ -1,11 +1,15 @@
import { ICryptoRepository, IKeyRepository, IUserRepository } from '@app/domain';
import { databaseConfig, UserEntity } from '@app/infra';
import { IUserRepository } from '@app/domain';
import { Global, Module, Provider } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserRepository } from './db';
import { cryptoRepository } from './auth/crypto.repository';
import { APIKeyEntity, UserRepository } from './db';
import { APIKeyRepository } from './db/repository';
const providers: Provider[] = [
//
{ provide: ICryptoRepository, useValue: cryptoRepository },
{ provide: IKeyRepository, useClass: APIKeyRepository },
{ provide: IUserRepository, useClass: UserRepository },
];
@@ -14,7 +18,7 @@ const providers: Provider[] = [
imports: [
//
TypeOrmModule.forRoot(databaseConfig),
TypeOrmModule.forFeature([UserEntity]),
TypeOrmModule.forFeature([APIKeyEntity, UserEntity]),
],
providers: [...providers],
exports: [...providers],