Skip to content

Commit 15a67a6

Browse files
authored
Merge pull request #1 from Divineifed/keys
API keys
2 parents 54522d7 + ba62cdf commit 15a67a6

File tree

9 files changed

+185
-0
lines changed

9 files changed

+185
-0
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/* eslint-disable prettier/prettier */
2+
import { Controller, Post, Body, Param, Get, Delete } from '@nestjs/common';
3+
import { ApiKeysService } from './api-key.service';
4+
import { CreateApiKeyDto } from './dto/create-api-key.dto';
5+
import { RevokeApiKeyDto } from './dto/revoke-api-key.dto';
6+
7+
@Controller('api-keys')
8+
export class ApiKeysController {
9+
constructor(private readonly apiKeysService: ApiKeysService) {}
10+
11+
@Post()
12+
async create(@Body() dto: CreateApiKeyDto) {
13+
return this.apiKeysService.createKey(dto);
14+
}
15+
16+
@Delete()
17+
async revoke(@Body() dto: RevokeApiKeyDto) {
18+
return this.apiKeysService.revokeKey(dto);
19+
}
20+
21+
@Get(':companyId')
22+
async list(@Param('companyId') companyId: string) {
23+
return this.apiKeysService.listKeys(companyId);
24+
}
25+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/* eslint-disable prettier/prettier */
2+
import { Module, MiddlewareConsumer } from '@nestjs/common';
3+
import { TypeOrmModule } from '@nestjs/typeorm';
4+
import { ApiKeysController } from './api-key.controller';
5+
import { ApiKeysService } from './api-key.service';
6+
import { ApiKey } from './entities/api-key.entity';
7+
import { Company } from 'src/companies/entities/company.entity';
8+
import { ApiKeyMiddleware } from './middleware/api-key.middleware';
9+
10+
@Module({
11+
imports: [TypeOrmModule.forFeature([ApiKey, Company])],
12+
controllers: [ApiKeysController],
13+
providers: [ApiKeysService],
14+
exports: [ApiKeysService],
15+
})
16+
export class ApiKeysModule {
17+
configure(consumer: MiddlewareConsumer) {
18+
consumer.apply(ApiKeyMiddleware).forRoutes('*'); // apply globally or restrict to certain routes
19+
}
20+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/* eslint-disable prettier/prettier */
2+
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
3+
import { InjectRepository } from '@nestjs/typeorm';
4+
import { Repository } from 'typeorm';
5+
import { ApiKey } from './entities/api-key.entity';
6+
import { CreateApiKeyDto } from './dto/create-api-key.dto';
7+
import { RevokeApiKeyDto } from './dto/revoke-api-key.dto';
8+
import { randomBytes } from 'crypto';
9+
import { Company } from 'src/companies/entities/company.entity';
10+
11+
@Injectable()
12+
export class ApiKeysService {
13+
constructor(
14+
@InjectRepository(ApiKey)
15+
private apiKeyRepo: Repository<ApiKey>,
16+
@InjectRepository(Company)
17+
private companyRepo: Repository<Company>,
18+
) {}
19+
20+
private generateKey(): string {
21+
return `ASSETSUP_${randomBytes(32).toString('hex')}`;
22+
}
23+
24+
async createKey(dto: CreateApiKeyDto): Promise<ApiKey> {
25+
const company = await this.companyRepo.findOne({ where: { id: Number(dto.companyId) } });
26+
if (!company) throw new NotFoundException('Company not found');
27+
28+
const apiKey = this.apiKeyRepo.create({
29+
key: this.generateKey(),
30+
company,
31+
expiryDate: dto.expiryDate ? new Date(dto.expiryDate) : null,
32+
});
33+
34+
return await this.apiKeyRepo.save(apiKey);
35+
}
36+
37+
async revokeKey(dto: RevokeApiKeyDto): Promise<ApiKey> {
38+
const key = await this.apiKeyRepo.findOne({ where: { id: dto.apiKeyId } });
39+
if (!key) throw new NotFoundException('API key not found');
40+
41+
key.status = 'revoked';
42+
return await this.apiKeyRepo.save(key);
43+
}
44+
45+
async validateKey(key: string): Promise<Company> {
46+
const apiKey = await this.apiKeyRepo.findOne({
47+
where: { key, status: 'active' },
48+
relations: ['company'],
49+
});
50+
51+
if (!apiKey) throw new ForbiddenException('Invalid API key');
52+
if (apiKey.expiryDate && apiKey.expiryDate < new Date()) {
53+
throw new ForbiddenException('API key expired');
54+
}
55+
56+
return apiKey.company;
57+
}
58+
59+
async listKeys(companyId: string): Promise<ApiKey[]> {
60+
return this.apiKeyRepo.find({
61+
where: { company: { id: Number(companyId) } },
62+
order: { createdAt: 'DESC' },
63+
});
64+
}
65+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/* eslint-disable prettier/prettier */
2+
import { IsUUID, IsOptional, IsDateString } from 'class-validator';
3+
4+
export class CreateApiKeyDto {
5+
@IsUUID()
6+
companyId: string;
7+
8+
@IsOptional()
9+
@IsDateString()
10+
expiryDate?: string;
11+
}
12+
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/* eslint-disable prettier/prettier */
2+
import { IsUUID } from 'class-validator';
3+
4+
export class RevokeApiKeyDto {
5+
@IsUUID()
6+
apiKeyId: string;
7+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/* eslint-disable prettier/prettier */
2+
import {
3+
Entity,
4+
PrimaryGeneratedColumn,
5+
Column,
6+
ManyToOne,
7+
CreateDateColumn,
8+
UpdateDateColumn,
9+
} from 'typeorm';
10+
import { Company } from 'src/companies/entities/company.entity';
11+
@Entity('api_keys')
12+
export class ApiKey {
13+
@PrimaryGeneratedColumn('uuid')
14+
id: string;
15+
16+
@Column({ unique: true })
17+
key: string;
18+
19+
@ManyToOne(() => Company, (company) => company.apiKeys, { onDelete: 'CASCADE' })
20+
company: Company;
21+
22+
@Column({ default: 'active' })
23+
status: 'active' | 'revoked';
24+
25+
@Column({ type: 'timestamp', nullable: true })
26+
expiryDate: Date | null;
27+
28+
@CreateDateColumn()
29+
createdAt: Date;
30+
31+
@UpdateDateColumn()
32+
updatedAt: Date;
33+
}
34+
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/* eslint-disable prettier/prettier */
2+
import { Injectable, NestMiddleware, ForbiddenException } from '@nestjs/common';
3+
import { Request, Response, NextFunction } from 'express';
4+
import { ApiKeysService } from '../api-key.service';
5+
6+
@Injectable()
7+
export class ApiKeyMiddleware implements NestMiddleware {
8+
constructor(private readonly apiKeysService: ApiKeysService) {}
9+
10+
async use(req: Request, res: Response, next: NextFunction) {
11+
const apiKey = req.headers['x-api-key'] as string;
12+
if (!apiKey) throw new ForbiddenException('Missing API key');
13+
14+
const company = await this.apiKeysService.validateKey(apiKey);
15+
(req as any).company = company;
16+
next();
17+
}
18+
}

backend/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { AssetCategoriesModule } from './asset-categories/asset-categories.modul
3333
import { DepartmentsModule } from './departments/departments.module';
3434
import { AssetTransfersModule } from './asset-transfers/asset-transfers.module';
3535
import { SearchModule } from './search/search.module';
36+
import { ApiKeyModule } from './api-key/api-key.module';
3637

3738
@Module({
3839
imports: [
@@ -82,6 +83,7 @@ import { SearchModule } from './search/search.module';
8283
// VendorDirectoryModule,
8384
WebhooksModule,
8485
AuditLogsModule,
86+
ApiKeyModule,
8587
],
8688
controllers: [AppController],
8789
providers: [

backend/src/companies/entities/company.entity.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable prettier/prettier */
12
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
23

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

2021
@UpdateDateColumn()
2122
updatedAt: Date;
23+
apiKeys: any;
2224
}
2325

2426

0 commit comments

Comments
 (0)