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

@@ -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 {}

View File

@@ -1,59 +0,0 @@
import { APIKeyEntity } from '@app/infra';
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[]>;
}
@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,74 +0,0 @@
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 { 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';
@Injectable()
export class APIKeyService {
constructor(@Inject(IKeyRepository) private repository: IKeyRepository) {}
async create(authUser: AuthUserDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
const key = randomBytes(24).toString('base64').replace(/\W/g, '');
const entity = await this.repository.create({
key: await 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<UserEntity> {
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;
}
}
throw new UnauthorizedException('Invalid API Key');
}
}

View File

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

View File

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

View File

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

View File

@@ -1,19 +0,0 @@
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

@@ -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: [],

View File

@@ -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')

View File

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

View File

@@ -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],
})

View File

@@ -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);
}
}

View File

@@ -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"]
}