Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions backend/src/api-key/api-key.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
20 changes: 20 additions & 0 deletions backend/src/api-key/api-key.module.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
65 changes: 65 additions & 0 deletions backend/src/api-key/api-key.service.ts
Original file line number Diff line number Diff line change
@@ -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<ApiKey>,
@InjectRepository(Company)
private companyRepo: Repository<Company>,
) {}

private generateKey(): string {
return `ASSETSUP_${randomBytes(32).toString('hex')}`;
}

async createKey(dto: CreateApiKeyDto): Promise<ApiKey> {
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<ApiKey> {
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<Company> {
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<ApiKey[]> {
return this.apiKeyRepo.find({
where: { company: { id: Number(companyId) } },
order: { createdAt: 'DESC' },
});
}
}
12 changes: 12 additions & 0 deletions backend/src/api-key/dto/create-api-key.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}

7 changes: 7 additions & 0 deletions backend/src/api-key/dto/revoke-api-key.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/* eslint-disable prettier/prettier */
import { IsUUID } from 'class-validator';

export class RevokeApiKeyDto {
@IsUUID()
apiKeyId: string;
}
34 changes: 34 additions & 0 deletions backend/src/api-key/entities/api-key.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}

18 changes: 18 additions & 0 deletions backend/src/api-key/middleware/api-key.middleware.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -82,6 +83,7 @@ import { SearchModule } from './search/search.module';
// VendorDirectoryModule,
WebhooksModule,
AuditLogsModule,
ApiKeyModule,
],
controllers: [AppController],
providers: [
Expand Down
2 changes: 2 additions & 0 deletions backend/src/companies/entities/company.entity.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable prettier/prettier */
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';

@Entity('companies')
Expand All @@ -19,6 +20,7 @@ export class Company {

@UpdateDateColumn()
updatedAt: Date;
apiKeys: any;
}