feat(center): enforce API key on all APIs except node register
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { buildTypeOrmConfig } from './database.config';
|
import { buildTypeOrmConfig } from './database.config';
|
||||||
import { HealthController } from './common/health.controller';
|
import { HealthController } from './common/health.controller';
|
||||||
@@ -7,15 +8,24 @@ import { MetricsService } from './common/metrics.service';
|
|||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { NodesModule } from './nodes/nodes.module';
|
import { NodesModule } from './nodes/nodes.module';
|
||||||
import { AuditModule } from './audit/audit.module';
|
import { AuditModule } from './audit/audit.module';
|
||||||
|
import { CenterApiKeyGuard } from './common/center-api-key.guard';
|
||||||
|
import { GuildNode } from './entities/guild-node.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forRoot(buildTypeOrmConfig()),
|
TypeOrmModule.forRoot(buildTypeOrmConfig()),
|
||||||
|
TypeOrmModule.forFeature([GuildNode]),
|
||||||
AuditModule,
|
AuditModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
NodesModule,
|
NodesModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController, MetricsController],
|
controllers: [HealthController, MetricsController],
|
||||||
providers: [MetricsService],
|
providers: [
|
||||||
|
MetricsService,
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: CenterApiKeyGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
43
src/common/center-api-key.guard.ts
Normal file
43
src/common/center-api-key.guard.ts
Normal file
@@ -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<GuildNode>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const req = context.switchToHttp().getRequest<{
|
||||||
|
path?: string;
|
||||||
|
method?: string;
|
||||||
|
headers: Record<string, string | string[] | undefined>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -20,6 +20,9 @@ export class GuildNode {
|
|||||||
@Column()
|
@Column()
|
||||||
endpoint!: string;
|
endpoint!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
apiKeyHash!: string | null;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
enum: ['active', 'offline', 'revoked'],
|
enum: ['active', 'offline', 'revoked'],
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
import { GuildNode } from '../entities/guild-node.entity';
|
import { GuildNode } from '../entities/guild-node.entity';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
import { RegisterNodeDto } from './dto.register-node.dto';
|
import { RegisterNodeDto } from './dto.register-node.dto';
|
||||||
@@ -95,7 +97,10 @@ export class NodesController {
|
|||||||
name: body.name,
|
name: body.name,
|
||||||
endpoint: body.endpoint,
|
endpoint: body.endpoint,
|
||||||
status: 'active',
|
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);
|
const saved = await this.nodeRepo.save(node);
|
||||||
await this.audit.write({
|
await this.audit.write({
|
||||||
action: 'node.register',
|
action: 'node.register',
|
||||||
@@ -114,6 +119,7 @@ export class NodesController {
|
|||||||
endpoint: saved.endpoint,
|
endpoint: saved.endpoint,
|
||||||
status: saved.status,
|
status: saved.status,
|
||||||
},
|
},
|
||||||
|
apiKey: rawApiKey,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user