mirror of
https://github.com/immich-app/immich.git
synced 2024-12-24 10:37:28 +02: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:
parent
0c469cc712
commit
92972ac776
@ -1,16 +0,0 @@
|
||||
import { APIKeyEntity } from '@app/infra';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { APIKeyController } from './api-key.controller';
|
||||
import { APIKeyRepository, IKeyRepository } from './api-key.repository';
|
||||
import { APIKeyService } from './api-key.service';
|
||||
|
||||
const KEY_REPOSITORY = { provide: IKeyRepository, useClass: APIKeyRepository };
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([APIKeyEntity])],
|
||||
controllers: [APIKeyController],
|
||||
providers: [APIKeyService, KEY_REPOSITORY],
|
||||
exports: [APIKeyService, KEY_REPOSITORY],
|
||||
})
|
||||
export class APIKeyModule {}
|
@ -2,7 +2,6 @@ import { immichAppConfig, immichBullAsyncConfig } from '@app/common/config';
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { AssetModule } from './api-v1/asset/asset.module';
|
||||
import { AuthModule } from './api-v1/auth/auth.module';
|
||||
import { APIKeyModule } from './api-v1/api-key/api-key.module';
|
||||
import { ImmichJwtModule } from './modules/immich-jwt/immich-jwt.module';
|
||||
import { DeviceInfoModule } from './api-v1/device-info/device-info.module';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
@ -22,7 +21,7 @@ import { ImmichConfigModule } from '@app/immich-config';
|
||||
import { ShareModule } from './api-v1/share/share.module';
|
||||
import { DomainModule } from '@app/domain';
|
||||
import { InfraModule } from '@app/infra';
|
||||
import { UserController } from './controllers';
|
||||
import { APIKeyController, UserController } from './controllers';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -32,8 +31,6 @@ import { UserController } from './controllers';
|
||||
imports: [InfraModule],
|
||||
}),
|
||||
|
||||
APIKeyModule,
|
||||
|
||||
AssetModule,
|
||||
|
||||
AuthModule,
|
||||
@ -69,6 +66,7 @@ import { UserController } from './controllers';
|
||||
controllers: [
|
||||
//
|
||||
AppController,
|
||||
APIKeyController,
|
||||
UserController,
|
||||
],
|
||||
providers: [],
|
||||
|
@ -1,12 +1,15 @@
|
||||
import {
|
||||
APIKeyCreateDto,
|
||||
APIKeyCreateResponseDto,
|
||||
APIKeyResponseDto,
|
||||
APIKeyService,
|
||||
APIKeyUpdateDto,
|
||||
AuthUserDto,
|
||||
} from '@app/domain';
|
||||
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||
import { Authenticated } from '../../decorators/authenticated.decorator';
|
||||
import { APIKeyService } from './api-key.service';
|
||||
import { APIKeyCreateDto } from './dto/api-key-create.dto';
|
||||
import { APIKeyUpdateDto } from './dto/api-key-update.dto';
|
||||
import { APIKeyCreateResponseDto } from './repsonse-dto/api-key-create-response.dto';
|
||||
import { APIKeyResponseDto } from './repsonse-dto/api-key-response.dto';
|
||||
import { GetAuthUser } from '../decorators/auth-user.decorator';
|
||||
import { Authenticated } from '../decorators/authenticated.decorator';
|
||||
|
||||
@ApiTags('API Key')
|
||||
@Controller('api-key')
|
@ -1 +1,2 @@
|
||||
export * from './api-key.controller';
|
||||
export * from './user.controller';
|
||||
|
@ -3,13 +3,12 @@ import { ImmichJwtService } from './immich-jwt.service';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { jwtConfig } from '../../config/jwt.config';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { APIKeyModule } from '../../api-v1/api-key/api-key.module';
|
||||
import { APIKeyStrategy } from './strategies/api-key.strategy';
|
||||
import { ShareModule } from '../../api-v1/share/share.module';
|
||||
import { PublicShareStrategy } from './strategies/public-share.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [JwtModule.register(jwtConfig), APIKeyModule, ShareModule],
|
||||
imports: [JwtModule.register(jwtConfig), ShareModule],
|
||||
providers: [ImmichJwtService, JwtStrategy, APIKeyStrategy, PublicShareStrategy],
|
||||
exports: [ImmichJwtService],
|
||||
})
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { APIKeyService, AuthUserDto } from '@app/domain';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { IStrategyOptions, Strategy } from 'passport-http-header-strategy';
|
||||
import { APIKeyService } from '../../../api-v1/api-key/api-key.service';
|
||||
import { AuthUserDto } from '../../../decorators/auth-user.decorator';
|
||||
|
||||
export const API_KEY_STRATEGY = 'api-key';
|
||||
|
||||
@ -16,16 +15,7 @@ export class APIKeyStrategy extends PassportStrategy(Strategy, API_KEY_STRATEGY)
|
||||
super(options);
|
||||
}
|
||||
|
||||
async validate(token: string): Promise<AuthUserDto> {
|
||||
const user = await this.apiKeyService.validate(token);
|
||||
|
||||
const authUser = new AuthUserDto();
|
||||
authUser.id = user.id;
|
||||
authUser.email = user.email;
|
||||
authUser.isAdmin = user.isAdmin;
|
||||
authUser.isPublicUser = false;
|
||||
authUser.isAllowUpload = true;
|
||||
|
||||
return authUser;
|
||||
validate(token: string): Promise<AuthUserDto> {
|
||||
return this.apiKeyService.validate(token);
|
||||
}
|
||||
}
|
||||
|
@ -4,14 +4,6 @@
|
||||
"declaration": false,
|
||||
"outDir": "../../dist/apps/immich"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"../../libs/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"test",
|
||||
"**/*spec.ts"
|
||||
]
|
||||
}
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||
}
|
||||
|
@ -1,6 +1,153 @@
|
||||
{
|
||||
"openapi": "3.0.0",
|
||||
"paths": {
|
||||
"/api-key": {
|
||||
"post": {
|
||||
"operationId": "createKey",
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIKeyCreateDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIKeyCreateResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"API Key"
|
||||
]
|
||||
},
|
||||
"get": {
|
||||
"operationId": "getKeys",
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/APIKeyResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"API Key"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api-key/{id}": {
|
||||
"get": {
|
||||
"operationId": "getKey",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIKeyResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"API Key"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "updateKey",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIKeyUpdateDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIKeyResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"API Key"
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "deleteKey",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"API Key"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/user": {
|
||||
"get": {
|
||||
"operationId": "getAllUsers",
|
||||
@ -341,153 +488,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api-key": {
|
||||
"post": {
|
||||
"operationId": "createKey",
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIKeyCreateDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIKeyCreateResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"API Key"
|
||||
]
|
||||
},
|
||||
"get": {
|
||||
"operationId": "getKeys",
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/APIKeyResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"API Key"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api-key/{id}": {
|
||||
"get": {
|
||||
"operationId": "getKey",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIKeyResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"API Key"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "updateKey",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIKeyUpdateDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIKeyResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"API Key"
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "deleteKey",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"API Key"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/asset/upload": {
|
||||
"post": {
|
||||
"operationId": "uploadFile",
|
||||
@ -2825,6 +2825,63 @@
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"APIKeyCreateDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"APIKeyResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
]
|
||||
},
|
||||
"APIKeyCreateResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
"apiKey": {
|
||||
"$ref": "#/components/schemas/APIKeyResponseDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"secret",
|
||||
"apiKey"
|
||||
]
|
||||
},
|
||||
"APIKeyUpdateDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"UserResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -2969,63 +3026,6 @@
|
||||
"profileImagePath"
|
||||
]
|
||||
},
|
||||
"APIKeyCreateDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"APIKeyResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
]
|
||||
},
|
||||
"APIKeyCreateResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
"apiKey": {
|
||||
"$ref": "#/components/schemas/APIKeyResponseDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"secret",
|
||||
"apiKey"
|
||||
]
|
||||
},
|
||||
"APIKeyUpdateDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"AssetFileUploadDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
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)');
|
||||
});
|
||||
});
|
||||
});
|
@ -1,21 +1,22 @@
|
||||
import { UserEntity } from '@app/infra';
|
||||
import { BadRequestException, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { compareSync, hash } from 'bcrypt';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||
import { AuthUserDto, ICryptoRepository } from '../auth';
|
||||
import { IKeyRepository } from './api-key.repository';
|
||||
import { APIKeyCreateDto } from './dto/api-key-create.dto';
|
||||
import { APIKeyCreateResponseDto } from './repsonse-dto/api-key-create-response.dto';
|
||||
import { APIKeyResponseDto, mapKey } from './repsonse-dto/api-key-response.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(IKeyRepository) private repository: IKeyRepository) {}
|
||||
constructor(
|
||||
@Inject(ICryptoRepository) private crypto: ICryptoRepository,
|
||||
@Inject(IKeyRepository) private repository: IKeyRepository,
|
||||
) {}
|
||||
|
||||
async create(authUser: AuthUserDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
|
||||
const key = randomBytes(24).toString('base64').replace(/\W/g, '');
|
||||
const key = this.crypto.randomBytes(24).toString('base64').replace(/\W/g, '');
|
||||
const entity = await this.repository.create({
|
||||
key: await hash(key, 10),
|
||||
key: await this.crypto.hash(key, 10),
|
||||
name: dto.name || 'API Key',
|
||||
userId: authUser.id,
|
||||
});
|
||||
@ -58,14 +59,22 @@ export class APIKeyService {
|
||||
return keys.map(mapKey);
|
||||
}
|
||||
|
||||
async validate(token: string): Promise<UserEntity> {
|
||||
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 && compareSync(key, entity.key)) {
|
||||
return entity.user as UserEntity;
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
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';
|
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';
|
7
server/libs/domain/src/auth/crypto.repository.ts
Normal file
7
server/libs/domain/src/auth/crypto.repository.ts
Normal 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;
|
||||
}
|
@ -1 +1,2 @@
|
||||
export * from './crypto.repository';
|
||||
export * from './dto';
|
||||
|
@ -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,
|
||||
];
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
export * from './api-key';
|
||||
export * from './auth';
|
||||
export * from './domain.module';
|
||||
export * from './user';
|
||||
|
@ -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 () => {
|
||||
|
12
server/libs/domain/test/api-key.repository.mock.ts
Normal file
12
server/libs/domain/test/api-key.repository.mock.ts
Normal 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(),
|
||||
};
|
||||
};
|
9
server/libs/domain/test/crypto.repository.mock.ts
Normal file
9
server/libs/domain/test/crypto.repository.mock.ts
Normal 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)`)),
|
||||
};
|
||||
};
|
44
server/libs/domain/test/fixtures.ts
Normal file
44
server/libs/domain/test/fixtures.ts
Normal 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: [],
|
||||
}),
|
||||
};
|
4
server/libs/domain/test/index.ts
Normal file
4
server/libs/domain/test/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './api-key.repository.mock';
|
||||
export * from './crypto.repository.mock';
|
||||
export * from './fixtures';
|
||||
export * from './user.repository.mock';
|
15
server/libs/domain/test/user.repository.mock.ts
Normal file
15
server/libs/domain/test/user.repository.mock.ts
Normal 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(),
|
||||
};
|
||||
};
|
9
server/libs/infra/src/auth/crypto.repository.ts
Normal file
9
server/libs/infra/src/auth/crypto.repository.ts
Normal 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,
|
||||
};
|
@ -1,22 +1,8 @@
|
||||
import { APIKeyEntity } from '@app/infra';
|
||||
import { IKeyRepository } from '@app/domain';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
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[]>;
|
||||
}
|
||||
import { APIKeyEntity } from '../entities';
|
||||
|
||||
@Injectable()
|
||||
export class APIKeyRepository implements IKeyRepository {
|
@ -1 +1,2 @@
|
||||
export * from './api-key.repository';
|
||||
export * from './user.repository';
|
||||
|
@ -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],
|
||||
|
@ -145,10 +145,10 @@
|
||||
"statements": 20
|
||||
},
|
||||
"./libs/domain/": {
|
||||
"branches": 60,
|
||||
"functions": 80,
|
||||
"lines": 80,
|
||||
"statements": 80
|
||||
"branches": 70,
|
||||
"functions": 85,
|
||||
"lines": 85,
|
||||
"statements": 85
|
||||
}
|
||||
},
|
||||
"testEnvironment": "node",
|
||||
|
Loading…
Reference in New Issue
Block a user