From c68360c81f1614f14bb77f078003183fdb61938a Mon Sep 17 00:00:00 2001 From: rubikscraft Date: Sat, 3 Sep 2022 13:50:53 +0200 Subject: [PATCH] add api key management endpoints --- .../collections/apikey-db/apikey-db.module.ts | 8 +-- .../apikey-db/apikey-db.service.ts | 67 +++++++++++------ .../src/database/entities/apikey.entity.ts | 13 +++- backend/src/routes/api/api.module.ts | 2 + .../routes/api/apikeys/apikeys.controller.ts | 71 +++++++++++++++++++ .../src/routes/api/apikeys/apikeys.module.ts | 9 +++ .../api/experiment/experiment.controller.ts | 4 +- .../api/experiment/experiment.module.ts | 4 +- frontend/src/app/i18n/permissions.i18n.ts | 3 + shared/src/dto/api/apikeys.dto.ts | 53 ++++++++++++++ shared/src/dto/permissions.enum.ts | 3 + shared/src/entities/apikey.entity.ts | 6 +- shared/src/entities/image.entity.ts | 2 +- shared/src/validators/api-key.validator.ts | 4 ++ 14 files changed, 215 insertions(+), 34 deletions(-) create mode 100644 backend/src/routes/api/apikeys/apikeys.controller.ts create mode 100644 backend/src/routes/api/apikeys/apikeys.module.ts create mode 100644 shared/src/dto/api/apikeys.dto.ts create mode 100644 shared/src/validators/api-key.validator.ts diff --git a/backend/src/collections/apikey-db/apikey-db.module.ts b/backend/src/collections/apikey-db/apikey-db.module.ts index df3c878..2efdf19 100644 --- a/backend/src/collections/apikey-db/apikey-db.module.ts +++ b/backend/src/collections/apikey-db/apikey-db.module.ts @@ -1,11 +1,11 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { EApiKeyBackend } from '../../database/entities/apikey.entity'; -import { ApikeyDbService } from './apikey-db.service'; +import { ApiKeyDbService } from './apikey-db.service'; @Module({ imports: [TypeOrmModule.forFeature([EApiKeyBackend])], - providers: [ApikeyDbService], - exports: [ApikeyDbService], + providers: [ApiKeyDbService], + exports: [ApiKeyDbService], }) -export class ApikeyDbModule {} +export class ApiKeyDbModule {} diff --git a/backend/src/collections/apikey-db/apikey-db.service.ts b/backend/src/collections/apikey-db/apikey-db.service.ts index 61194c3..8d664dc 100644 --- a/backend/src/collections/apikey-db/apikey-db.service.ts +++ b/backend/src/collections/apikey-db/apikey-db.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { AsyncFailable, Fail, FT, HasFailed } from 'picsur-shared/dist/types'; import { FindResult } from 'picsur-shared/dist/types/find-result'; @@ -8,7 +8,9 @@ import { EApiKeyBackend } from '../../database/entities/apikey.entity'; import { EUserBackend } from '../../database/entities/user.entity'; @Injectable() -export class ApikeyDbService { +export class ApiKeyDbService { + private readonly logger = new Logger(ApiKeyDbService.name); + constructor( @InjectRepository(EApiKeyBackend) private readonly apikeyRepo: Repository, @@ -32,28 +34,20 @@ export class ApikeyDbService { } } - async resolve( - key: string - ): AsyncFailable> { - try { - const apikey = await this.apikeyRepo.findOne({ - where: { key }, - relations: ['user'], - }); - if (!apikey) return Fail(FT.NotFound, 'API key not found'); - return apikey as EApiKeyBackend; - } catch (e) { - return Fail(FT.Database, e); - } - } - async findOne( key: string, userid: string | undefined, ): AsyncFailable> { try { const apikey = await this.apikeyRepo.findOne({ - where: { user: userid, key }, + where: { + user: + userid !== undefined + ? // This is stupid, but typeorm do typeorm + ({ id: userid } as any) + : undefined, + key, + }, loadRelationIds: true, }); if (!apikey) return Fail(FT.NotFound, 'API key not found'); @@ -73,7 +67,13 @@ export class ApikeyDbService { try { const [apikeys, amount] = await this.apikeyRepo.findAndCount({ - where: { user: userid }, + where: { + user: + userid !== undefined + ? // This is stupid, but typeorm do typeorm + ({ id: userid } as any) + : undefined, + }, skip: count * page, take: count, loadRelationIds: true, @@ -97,12 +97,35 @@ export class ApikeyDbService { const apikeyToDelete = await this.findOne(key, userid); if (HasFailed(apikeyToDelete)) return apikeyToDelete; + const apiKeyCopy = { ...apikeyToDelete }; try { - return (await this.apikeyRepo.remove( - apikeyToDelete, - )) as EApiKeyBackend; + await this.apikeyRepo.remove(apikeyToDelete); + return apiKeyCopy as EApiKeyBackend; } catch (e) { return Fail(FT.Database, e); } } + + async resolve(key: string): AsyncFailable> { + try { + const apikey = await this.apikeyRepo.findOne({ + where: { key }, + relations: ['user'], + }); + if (!apikey) return Fail(FT.NotFound, 'API key not found'); + + this.updateLastUsed(apikey); + + return apikey as EApiKeyBackend; + } catch (e) { + return Fail(FT.Database, e); + } + } + + private updateLastUsed(apikey: EApiKeyBackend) { + (async () => { + apikey.last_used = new Date(); + this.apikeyRepo.save(apikey); + })().catch(this.logger.error.bind(this.logger)); + } } diff --git a/backend/src/database/entities/apikey.entity.ts b/backend/src/database/entities/apikey.entity.ts index 5e8170b..c289eb3 100644 --- a/backend/src/database/entities/apikey.entity.ts +++ b/backend/src/database/entities/apikey.entity.ts @@ -1,5 +1,11 @@ import { EApiKeySchema } from 'picsur-shared/dist/entities/apikey.entity'; -import { CreateDateColumn, Entity, ManyToOne, PrimaryColumn } from 'typeorm'; +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + PrimaryColumn +} from 'typeorm'; import { z } from 'zod'; import { EUserBackend } from './user.entity'; @@ -31,4 +37,9 @@ export class EApiKeyBackend< nullable: false, }) created: Date; + + @Column({ + nullable: true, + }) + last_used: Date; } diff --git a/backend/src/routes/api/api.module.ts b/backend/src/routes/api/api.module.ts index 9e5aa8c..9ad8ccf 100644 --- a/backend/src/routes/api/api.module.ts +++ b/backend/src/routes/api/api.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { ApiKeysModule } from './apikeys/apikeys.module'; import { ExperimentModule } from './experiment/experiment.module'; import { InfoModule } from './info/info.module'; import { PrefModule } from './pref/pref.module'; @@ -12,6 +13,7 @@ import { UserApiModule } from './user/user.module'; ExperimentModule, InfoModule, RolesApiModule, + ApiKeysModule, ], }) export class PicsurApiModule {} diff --git a/backend/src/routes/api/apikeys/apikeys.controller.ts b/backend/src/routes/api/apikeys/apikeys.controller.ts new file mode 100644 index 0000000..67caea5 --- /dev/null +++ b/backend/src/routes/api/apikeys/apikeys.controller.ts @@ -0,0 +1,71 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { + ApiKeyCreateResponse, + ApiKeyDeleteRequest, + ApiKeyDeleteResponse, + ApiKeyInfoRequest, + ApiKeyInfoResponse, + ApiKeyListRequest, + ApiKeyListResponse +} from 'picsur-shared/dist/dto/api/apikeys.dto'; +import { Permission } from 'picsur-shared/dist/dto/permissions.enum'; +import { ThrowIfFailed } from 'picsur-shared/dist/types'; +import { ApiKeyDbService } from '../../../collections/apikey-db/apikey-db.service'; +import { + HasPermission, + RequiredPermissions +} from '../../../decorators/permissions.decorator'; +import { ReqUserID } from '../../../decorators/request-user.decorator'; +import { Returns } from '../../../decorators/returns.decorator'; + +@Controller('api/apikeys') +@RequiredPermissions(Permission.ApiKey) +export class ApiKeysController { + constructor(private readonly apikeyDB: ApiKeyDbService) {} + + @Post('info') + @Returns(ApiKeyInfoResponse) + async getApiKeyInfo( + @ReqUserID() userid: string, + @Body() body: ApiKeyInfoRequest, + @HasPermission(Permission.ApiKeyAdmin) isAdmin: boolean, + ): Promise { + return ThrowIfFailed( + await this.apikeyDB.findOne(body.key, isAdmin ? undefined : userid), + ); + } + + @Post('list') + @Returns(ApiKeyListResponse) + async listApiKeys( + @ReqUserID() userid: string, + @Body() body: ApiKeyListRequest, + @HasPermission(Permission.ApiKeyAdmin) isAdmin: boolean, + ): Promise { + if (!isAdmin) body.user_id = userid; + + return ThrowIfFailed( + await this.apikeyDB.findMany(body.count, body.page, body.user_id), + ); + } + + @Post('create') + @Returns(ApiKeyCreateResponse) + async createApiKey( + @ReqUserID() userID: string, + ): Promise { + return ThrowIfFailed(await this.apikeyDB.createApiKey(userID)); + } + + @Post('delete') + @Returns(ApiKeyDeleteResponse) + async deleteApiKey( + @ReqUserID() userID: string, + @Body() body: ApiKeyDeleteRequest, + @HasPermission(Permission.ApiKeyAdmin) isAdmin: boolean, + ): Promise { + return ThrowIfFailed( + await this.apikeyDB.deleteApiKey(body.key, isAdmin ? undefined : userID), + ); + } +} diff --git a/backend/src/routes/api/apikeys/apikeys.module.ts b/backend/src/routes/api/apikeys/apikeys.module.ts new file mode 100644 index 0000000..ac7f426 --- /dev/null +++ b/backend/src/routes/api/apikeys/apikeys.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ApiKeyDbModule } from '../../../collections/apikey-db/apikey-db.module'; +import { ApiKeysController } from './apikeys.controller'; + +@Module({ + imports: [ApiKeyDbModule], + controllers: [ApiKeysController], +}) +export class ApiKeysModule {} diff --git a/backend/src/routes/api/experiment/experiment.controller.ts b/backend/src/routes/api/experiment/experiment.controller.ts index 5fb3f96..a960ff9 100644 --- a/backend/src/routes/api/experiment/experiment.controller.ts +++ b/backend/src/routes/api/experiment/experiment.controller.ts @@ -1,7 +1,7 @@ import { Controller, Get, Request } from '@nestjs/common'; import { UserInfoResponse } from 'picsur-shared/dist/dto/api/user-manage.dto'; import { Permission } from 'picsur-shared/dist/dto/permissions.enum'; -import { ApikeyDbService } from '../../../collections/apikey-db/apikey-db.service'; +import { ApiKeyDbService } from '../../../collections/apikey-db/apikey-db.service'; import { RequiredPermissions } from '../../../decorators/permissions.decorator'; import { ReqUserID } from '../../../decorators/request-user.decorator'; import { Returns } from '../../../decorators/returns.decorator'; @@ -11,7 +11,7 @@ import type AuthFasityRequest from '../../../models/interfaces/authrequest.dto'; @RequiredPermissions(Permission.SysPrefAdmin) export class ExperimentController { constructor( - private readonly apikeyDB: ApikeyDbService, + private readonly apikeyDB: ApiKeyDbService, ){} @Get() diff --git a/backend/src/routes/api/experiment/experiment.module.ts b/backend/src/routes/api/experiment/experiment.module.ts index 53f9e91..1fa1fc8 100644 --- a/backend/src/routes/api/experiment/experiment.module.ts +++ b/backend/src/routes/api/experiment/experiment.module.ts @@ -1,12 +1,12 @@ import { Module } from '@nestjs/common'; -import { ApikeyDbModule } from '../../../collections/apikey-db/apikey-db.module'; +import { ApiKeyDbModule } from '../../../collections/apikey-db/apikey-db.module'; import { ExperimentController } from './experiment.controller'; // This is comletely useless module, but is used for testing // TODO: remove when out of beta @Module({ - imports: [ApikeyDbModule], + imports: [ApiKeyDbModule], controllers: [ExperimentController], }) export class ExperimentModule {} diff --git a/frontend/src/app/i18n/permissions.i18n.ts b/frontend/src/app/i18n/permissions.i18n.ts index f70303c..f76fdea 100644 --- a/frontend/src/app/i18n/permissions.i18n.ts +++ b/frontend/src/app/i18n/permissions.i18n.ts @@ -12,8 +12,11 @@ export const UIFriendlyPermissions: { [Permission.Settings]: 'View settings', + [Permission.ApiKey]: 'Use API keys', + [Permission.ImageAdmin]: 'Image Admin', [Permission.UserAdmin]: 'User Admin', [Permission.RoleAdmin]: 'Role Admin', + [Permission.ApiKeyAdmin]: 'API Key Admin', [Permission.SysPrefAdmin]: 'System Admin', }; diff --git a/shared/src/dto/api/apikeys.dto.ts b/shared/src/dto/api/apikeys.dto.ts new file mode 100644 index 0000000..c47761c --- /dev/null +++ b/shared/src/dto/api/apikeys.dto.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; +import { EApiKeySchema } from '../../entities/apikey.entity'; +import { createZodDto } from '../../util/create-zod-dto'; +import { IsPosInt } from '../../validators/positive-int.validator'; + +// ApiKeyInfo +export const ApiKeyInfoRequestSchema = EApiKeySchema.pick({ + key: true, +}); +export class ApiKeyInfoRequest extends createZodDto(ApiKeyInfoRequestSchema) {} + +export const ApiKeyInfoResponseSchema = EApiKeySchema; +export class ApiKeyInfoResponse extends createZodDto( + ApiKeyInfoResponseSchema, +) {} + +// ApiKeyList + +export const ApiKeyListRequestSchema = z.object({ + count: IsPosInt(), + page: IsPosInt(), + user_id: z.string().uuid().optional(), +}); +export class ApiKeyListRequest extends createZodDto(ApiKeyListRequestSchema) {} + +export const ApiKeyListResponseSchema = z.object({ + results: z.array(EApiKeySchema), + total: IsPosInt(), + page: IsPosInt(), + pages: IsPosInt(), +}); +export class ApiKeyListResponse extends createZodDto( + ApiKeyListResponseSchema, +) {} + +// ApiKeyCreate +export const ApiKeyCreateResponseSchema = EApiKeySchema; +export class ApiKeyCreateResponse extends createZodDto( + ApiKeyCreateResponseSchema, +) {} + +// ApiKeyDelete +export const ApiKeyDeleteRequestSchema = EApiKeySchema.pick({ + key: true, +}); +export class ApiKeyDeleteRequest extends createZodDto( + ApiKeyDeleteRequestSchema, +) {} + +export const ApiKeyDeleteResponseSchema = EApiKeySchema; +export class ApiKeyDeleteResponse extends createZodDto( + ApiKeyDeleteResponseSchema, +) {} diff --git a/shared/src/dto/permissions.enum.ts b/shared/src/dto/permissions.enum.ts index 518d5ab..a166ddf 100644 --- a/shared/src/dto/permissions.enum.ts +++ b/shared/src/dto/permissions.enum.ts @@ -12,8 +12,11 @@ export enum Permission { Settings = 'settings', // Ability to view (personal) settings + ApiKey = 'apikey', // Ability to create and remove your own api keys + ImageAdmin = 'image-admin', // Ability to manage everyones manage images UserAdmin = 'user-admin', // Allow modification of users RoleAdmin = 'role-admin', // Allow modification of roles + ApiKeyAdmin = 'apikey-admin', // Allow modification of all api keys SysPrefAdmin = 'syspref-admin', } diff --git a/shared/src/entities/apikey.entity.ts b/shared/src/entities/apikey.entity.ts index 64e3edd..3739a2b 100644 --- a/shared/src/entities/apikey.entity.ts +++ b/shared/src/entities/apikey.entity.ts @@ -1,9 +1,11 @@ import { z } from 'zod'; +import { IsApiKey } from '../validators/api-key.validator'; import { IsEntityID } from '../validators/entity-id.validator'; export const EApiKeySchema = z.object({ - key: z.string(), + key: IsApiKey(), user: IsEntityID(), - created: z.preprocess((data: any) => new Date(data), z.date()), + created: z.date(), + last_used: z.date().nullable(), }); export type EApiKey = z.infer; diff --git a/shared/src/entities/image.entity.ts b/shared/src/entities/image.entity.ts index ec4fb82..3813de3 100644 --- a/shared/src/entities/image.entity.ts +++ b/shared/src/entities/image.entity.ts @@ -4,7 +4,7 @@ import { IsEntityID } from '../validators/entity-id.validator'; export const EImageSchema = z.object({ id: IsEntityID(), user_id: IsEntityID(), - created: z.preprocess((data: any) => new Date(data), z.date()), + created: z.date(), file_name: z.string(), }); export type EImage = z.infer; diff --git a/shared/src/validators/api-key.validator.ts b/shared/src/validators/api-key.validator.ts new file mode 100644 index 0000000..3d0e7c9 --- /dev/null +++ b/shared/src/validators/api-key.validator.ts @@ -0,0 +1,4 @@ +import { z } from 'zod'; + +export const IsApiKey = () => + z.string().regex(/^[a-zA-Z0-9]{32}$/, 'Invalid API key');