diff --git a/src/app.module.ts b/src/app.module.ts index bbfc776..56b53c3 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; import { TypeOrmModule } from '@nestjs/typeorm'; import { buildTypeOrmConfig } from './database.config'; import { HealthController } from './common/health.controller'; @@ -7,15 +8,24 @@ import { MetricsService } from './common/metrics.service'; import { AuthModule } from './auth/auth.module'; import { NodesModule } from './nodes/nodes.module'; import { AuditModule } from './audit/audit.module'; +import { CenterApiKeyGuard } from './common/center-api-key.guard'; +import { GuildNode } from './entities/guild-node.entity'; @Module({ imports: [ TypeOrmModule.forRoot(buildTypeOrmConfig()), + TypeOrmModule.forFeature([GuildNode]), AuditModule, AuthModule, NodesModule, ], controllers: [HealthController, MetricsController], - providers: [MetricsService], + providers: [ + MetricsService, + { + provide: APP_GUARD, + useClass: CenterApiKeyGuard, + }, + ], }) export class AppModule {} diff --git a/src/common/center-api-key.guard.ts b/src/common/center-api-key.guard.ts new file mode 100644 index 0000000..b53ad25 --- /dev/null +++ b/src/common/center-api-key.guard.ts @@ -0,0 +1,43 @@ +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as bcrypt from 'bcryptjs'; +import { GuildNode } from '../entities/guild-node.entity'; + +@Injectable() +export class CenterApiKeyGuard implements CanActivate { + constructor( + @InjectRepository(GuildNode) + private readonly nodeRepo: Repository, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest<{ + path?: string; + method?: string; + headers: Record; + }>(); + + const path = req.path ?? ''; + const method = (req.method ?? 'GET').toUpperCase(); + + // only guild registration is exempt from API key; it is protected by HMAC secret + if (method === 'POST' && (path === '/nodes/register' || path.endsWith('/nodes/register'))) { + return true; + } + + const received = req.headers['x-api-key']; + const apiKey = Array.isArray(received) ? received[0] : received; + if (!apiKey) throw new UnauthorizedException('missing api key'); + + const nodes = await this.nodeRepo.find({ where: { status: 'active' } }); + for (const node of nodes) { + if (!node.apiKeyHash) continue; + const ok = await bcrypt.compare(apiKey, node.apiKeyHash); + if (ok) return true; + } + + throw new UnauthorizedException('invalid api key'); + } +} + diff --git a/src/entities/guild-node.entity.ts b/src/entities/guild-node.entity.ts index a79c2ba..92ca27e 100644 --- a/src/entities/guild-node.entity.ts +++ b/src/entities/guild-node.entity.ts @@ -20,6 +20,9 @@ export class GuildNode { @Column() endpoint!: string; + @Column({ type: 'varchar', length: 255, nullable: true }) + apiKeyHash!: string | null; + @Column({ type: 'enum', enum: ['active', 'offline', 'revoked'], diff --git a/src/nodes/nodes.controller.ts b/src/nodes/nodes.controller.ts index b150778..3f73188 100644 --- a/src/nodes/nodes.controller.ts +++ b/src/nodes/nodes.controller.ts @@ -16,6 +16,8 @@ import { } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import * as bcrypt from 'bcryptjs'; +import { randomBytes } from 'crypto'; import { GuildNode } from '../entities/guild-node.entity'; import { AuditService } from '../audit/audit.service'; import { RegisterNodeDto } from './dto.register-node.dto'; @@ -95,7 +97,10 @@ export class NodesController { name: body.name, endpoint: body.endpoint, status: 'active', + apiKeyHash: null, }); + const rawApiKey = `gk_${randomBytes(24).toString('hex')}`; + node.apiKeyHash = await bcrypt.hash(rawApiKey, 10); const saved = await this.nodeRepo.save(node); await this.audit.write({ action: 'node.register', @@ -114,6 +119,7 @@ export class NodesController { endpoint: saved.endpoint, status: saved.status, }, + apiKey: rawApiKey, }; }