add api key management endpoints

This commit is contained in:
rubikscraft 2022-09-03 13:50:53 +02:00
parent a7981ce8ad
commit c68360c81f
No known key found for this signature in database
GPG key ID: 1463EBE9200A5CD4
14 changed files with 215 additions and 34 deletions

View file

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

View file

@ -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<EApiKeyBackend>,
@ -32,28 +34,20 @@ export class ApikeyDbService {
}
}
async resolve(
key: string
): AsyncFailable<EApiKeyBackend<EUserBackend>> {
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<EUserBackend>;
} catch (e) {
return Fail(FT.Database, e);
}
}
async findOne(
key: string,
userid: string | undefined,
): AsyncFailable<EApiKeyBackend<string>> {
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<string>;
await this.apikeyRepo.remove(apikeyToDelete);
return apiKeyCopy as EApiKeyBackend<string>;
} catch (e) {
return Fail(FT.Database, e);
}
}
async resolve(key: string): AsyncFailable<EApiKeyBackend<EUserBackend>> {
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<EUserBackend>;
} 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));
}
}

View file

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

View file

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

View file

@ -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<ApiKeyInfoResponse> {
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<ApiKeyListResponse> {
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<ApiKeyCreateResponse> {
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<ApiKeyDeleteResponse> {
return ThrowIfFailed(
await this.apikeyDB.deleteApiKey(body.key, isAdmin ? undefined : userID),
);
}
}

View file

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

View file

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

View file

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

View file

@ -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',
};

View file

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

View file

@ -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',
}

View file

@ -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<typeof EApiKeySchema>;

View file

@ -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<typeof EImageSchema>;

View file

@ -0,0 +1,4 @@
import { z } from 'zod';
export const IsApiKey = () =>
z.string().regex(/^[a-zA-Z0-9]{32}$/, 'Invalid API key');