mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
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:
16
server/libs/domain/src/api-key/api-key.repository.ts
Normal file
16
server/libs/domain/src/api-key/api-key.repository.ts
Normal 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[]>;
|
||||
}
|
||||
142
server/libs/domain/src/api-key/api-key.service.spec.ts
Normal file
142
server/libs/domain/src/api-key/api-key.service.spec.ts
Normal 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)');
|
||||
});
|
||||
});
|
||||
});
|
||||
83
server/libs/domain/src/api-key/api-key.service.ts
Normal file
83
server/libs/domain/src/api-key/api-key.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
8
server/libs/domain/src/api-key/dto/api-key-create.dto.ts
Normal file
8
server/libs/domain/src/api-key/dto/api-key-create.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class APIKeyCreateDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
}
|
||||
7
server/libs/domain/src/api-key/dto/api-key-update.dto.ts
Normal file
7
server/libs/domain/src/api-key/dto/api-key-update.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class APIKeyUpdateDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
}
|
||||
2
server/libs/domain/src/api-key/dto/index.ts
Normal file
2
server/libs/domain/src/api-key/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './api-key-create.dto';
|
||||
export * from './api-key-update.dto';
|
||||
4
server/libs/domain/src/api-key/index.ts
Normal file
4
server/libs/domain/src/api-key/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './api-key.repository';
|
||||
export * from './api-key.service';
|
||||
export * from './dto';
|
||||
export * from './response-dto';
|
||||
@@ -0,0 +1,6 @@
|
||||
import { APIKeyResponseDto } from './api-key-response.dto';
|
||||
|
||||
export class APIKeyCreateResponseDto {
|
||||
secret!: string;
|
||||
apiKey!: APIKeyResponseDto;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
2
server/libs/domain/src/api-key/response-dto/index.ts
Normal file
2
server/libs/domain/src/api-key/response-dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './api-key-create-response.dto';
|
||||
export * from './api-key-response.dto';
|
||||
Reference in New Issue
Block a user