diff --git a/backend/src/api-key/api-key.controller.ts b/backend/src/api-key/api-key.controller.ts new file mode 100644 index 0000000..8a3163a --- /dev/null +++ b/backend/src/api-key/api-key.controller.ts @@ -0,0 +1,25 @@ +/* eslint-disable prettier/prettier */ +import { Controller, Post, Body, Param, Get, Delete } from '@nestjs/common'; +import { ApiKeysService } from './api-key.service'; +import { CreateApiKeyDto } from './dto/create-api-key.dto'; +import { RevokeApiKeyDto } from './dto/revoke-api-key.dto'; + +@Controller('api-keys') +export class ApiKeysController { + constructor(private readonly apiKeysService: ApiKeysService) {} + + @Post() + async create(@Body() dto: CreateApiKeyDto) { + return this.apiKeysService.createKey(dto); + } + + @Delete() + async revoke(@Body() dto: RevokeApiKeyDto) { + return this.apiKeysService.revokeKey(dto); + } + + @Get(':companyId') + async list(@Param('companyId') companyId: string) { + return this.apiKeysService.listKeys(companyId); + } +} diff --git a/backend/src/api-key/api-key.module.ts b/backend/src/api-key/api-key.module.ts new file mode 100644 index 0000000..9b0aa09 --- /dev/null +++ b/backend/src/api-key/api-key.module.ts @@ -0,0 +1,20 @@ +/* eslint-disable prettier/prettier */ +import { Module, MiddlewareConsumer } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ApiKeysController } from './api-key.controller'; +import { ApiKeysService } from './api-key.service'; +import { ApiKey } from './entities/api-key.entity'; +import { Company } from 'src/companies/entities/company.entity'; +import { ApiKeyMiddleware } from './middleware/api-key.middleware'; + +@Module({ + imports: [TypeOrmModule.forFeature([ApiKey, Company])], + controllers: [ApiKeysController], + providers: [ApiKeysService], + exports: [ApiKeysService], +}) +export class ApiKeysModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(ApiKeyMiddleware).forRoutes('*'); // apply globally or restrict to certain routes + } +} diff --git a/backend/src/api-key/api-key.service.ts b/backend/src/api-key/api-key.service.ts new file mode 100644 index 0000000..548c225 --- /dev/null +++ b/backend/src/api-key/api-key.service.ts @@ -0,0 +1,65 @@ +/* eslint-disable prettier/prettier */ +import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ApiKey } from './entities/api-key.entity'; +import { CreateApiKeyDto } from './dto/create-api-key.dto'; +import { RevokeApiKeyDto } from './dto/revoke-api-key.dto'; +import { randomBytes } from 'crypto'; +import { Company } from 'src/companies/entities/company.entity'; + +@Injectable() +export class ApiKeysService { + constructor( + @InjectRepository(ApiKey) + private apiKeyRepo: Repository, + @InjectRepository(Company) + private companyRepo: Repository, + ) {} + + private generateKey(): string { + return `ASSETSUP_${randomBytes(32).toString('hex')}`; + } + + async createKey(dto: CreateApiKeyDto): Promise { + const company = await this.companyRepo.findOne({ where: { id: Number(dto.companyId) } }); + if (!company) throw new NotFoundException('Company not found'); + + const apiKey = this.apiKeyRepo.create({ + key: this.generateKey(), + company, + expiryDate: dto.expiryDate ? new Date(dto.expiryDate) : null, + }); + + return await this.apiKeyRepo.save(apiKey); + } + + async revokeKey(dto: RevokeApiKeyDto): Promise { + const key = await this.apiKeyRepo.findOne({ where: { id: dto.apiKeyId } }); + if (!key) throw new NotFoundException('API key not found'); + + key.status = 'revoked'; + return await this.apiKeyRepo.save(key); + } + + async validateKey(key: string): Promise { + const apiKey = await this.apiKeyRepo.findOne({ + where: { key, status: 'active' }, + relations: ['company'], + }); + + if (!apiKey) throw new ForbiddenException('Invalid API key'); + if (apiKey.expiryDate && apiKey.expiryDate < new Date()) { + throw new ForbiddenException('API key expired'); + } + + return apiKey.company; + } + + async listKeys(companyId: string): Promise { + return this.apiKeyRepo.find({ + where: { company: { id: Number(companyId) } }, + order: { createdAt: 'DESC' }, + }); + } +} diff --git a/backend/src/api-key/dto/create-api-key.dto.ts b/backend/src/api-key/dto/create-api-key.dto.ts new file mode 100644 index 0000000..70aa04c --- /dev/null +++ b/backend/src/api-key/dto/create-api-key.dto.ts @@ -0,0 +1,12 @@ +/* eslint-disable prettier/prettier */ +import { IsUUID, IsOptional, IsDateString } from 'class-validator'; + +export class CreateApiKeyDto { + @IsUUID() + companyId: string; + + @IsOptional() + @IsDateString() + expiryDate?: string; +} + diff --git a/backend/src/api-key/dto/revoke-api-key.dto.ts b/backend/src/api-key/dto/revoke-api-key.dto.ts new file mode 100644 index 0000000..be95fce --- /dev/null +++ b/backend/src/api-key/dto/revoke-api-key.dto.ts @@ -0,0 +1,7 @@ +/* eslint-disable prettier/prettier */ +import { IsUUID } from 'class-validator'; + +export class RevokeApiKeyDto { + @IsUUID() + apiKeyId: string; +} diff --git a/backend/src/api-key/entities/api-key.entity.ts b/backend/src/api-key/entities/api-key.entity.ts new file mode 100644 index 0000000..24c945f --- /dev/null +++ b/backend/src/api-key/entities/api-key.entity.ts @@ -0,0 +1,34 @@ +/* eslint-disable prettier/prettier */ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Company } from 'src/companies/entities/company.entity'; +@Entity('api_keys') +export class ApiKey { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + key: string; + + @ManyToOne(() => Company, (company) => company.apiKeys, { onDelete: 'CASCADE' }) + company: Company; + + @Column({ default: 'active' }) + status: 'active' | 'revoked'; + + @Column({ type: 'timestamp', nullable: true }) + expiryDate: Date | null; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} + diff --git a/backend/src/api-key/middleware/api-key.middleware.ts b/backend/src/api-key/middleware/api-key.middleware.ts new file mode 100644 index 0000000..0f56ef2 --- /dev/null +++ b/backend/src/api-key/middleware/api-key.middleware.ts @@ -0,0 +1,18 @@ +/* eslint-disable prettier/prettier */ +import { Injectable, NestMiddleware, ForbiddenException } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { ApiKeysService } from '../api-key.service'; + +@Injectable() +export class ApiKeyMiddleware implements NestMiddleware { + constructor(private readonly apiKeysService: ApiKeysService) {} + + async use(req: Request, res: Response, next: NextFunction) { + const apiKey = req.headers['x-api-key'] as string; + if (!apiKey) throw new ForbiddenException('Missing API key'); + + const company = await this.apiKeysService.validateKey(apiKey); + (req as any).company = company; + next(); + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index ae1d9ef..6a97b89 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -33,6 +33,7 @@ import { AssetCategoriesModule } from './asset-categories/asset-categories.modul import { DepartmentsModule } from './departments/departments.module'; import { AssetTransfersModule } from './asset-transfers/asset-transfers.module'; import { SearchModule } from './search/search.module'; +import { ApiKeyModule } from './api-key/api-key.module'; @Module({ imports: [ @@ -82,6 +83,7 @@ import { SearchModule } from './search/search.module'; // VendorDirectoryModule, WebhooksModule, AuditLogsModule, + ApiKeyModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/companies/entities/company.entity.ts b/backend/src/companies/entities/company.entity.ts index 5ee6892..9fd75e5 100644 --- a/backend/src/companies/entities/company.entity.ts +++ b/backend/src/companies/entities/company.entity.ts @@ -1,3 +1,4 @@ +/* eslint-disable prettier/prettier */ import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; @Entity('companies') @@ -19,6 +20,7 @@ export class Company { @UpdateDateColumn() updatedAt: Date; + apiKeys: any; }