mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 01:01:25 +02:00
✨ feat(auth): add API key management for STT/TTS services
- Add api_keys schema in mana-core-auth with SHA-256 hashing - Create NestJS module with CRUD endpoints and validation - Add external auth module to STT/TTS for sk_live_ key validation - Create web UI page at /api-keys for key management - Support rate limiting per key with configurable limits - Cache validation results for 5 minutes to reduce auth service load Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
552dc10f25
commit
8b6ff0c679
18 changed files with 1238 additions and 16 deletions
59
services/mana-core-auth/src/api-keys/api-keys.controller.ts
Normal file
59
services/mana-core-auth/src/api-keys/api-keys.controller.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
import { CreateApiKeyDto, ValidateApiKeyDto } from './dto';
|
||||
|
||||
@Controller('api-keys')
|
||||
export class ApiKeysController {
|
||||
constructor(private readonly apiKeysService: ApiKeysService) {}
|
||||
|
||||
/**
|
||||
* List all API keys for the authenticated user
|
||||
*/
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async listKeys(@CurrentUser() user: CurrentUserData) {
|
||||
return this.apiKeysService.listUserApiKeys(user.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new API key
|
||||
* Returns the full key only once - it cannot be retrieved later
|
||||
*/
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async createKey(@CurrentUser() user: CurrentUserData, @Body() dto: CreateApiKeyDto) {
|
||||
return this.apiKeysService.createApiKey(user.userId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an API key
|
||||
*/
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async revokeKey(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
await this.apiKeysService.revokeApiKey(user.userId, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an API key (for STT/TTS services)
|
||||
* This endpoint does NOT require JWT authentication
|
||||
*/
|
||||
@Post('validate')
|
||||
async validateKey(@Body() dto: ValidateApiKeyDto) {
|
||||
return this.apiKeysService.validateApiKey(dto.apiKey, dto.scope);
|
||||
}
|
||||
}
|
||||
10
services/mana-core-auth/src/api-keys/api-keys.module.ts
Normal file
10
services/mana-core-auth/src/api-keys/api-keys.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ApiKeysController } from './api-keys.controller';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ApiKeysController],
|
||||
providers: [ApiKeysService],
|
||||
exports: [ApiKeysService],
|
||||
})
|
||||
export class ApiKeysModule {}
|
||||
173
services/mana-core-auth/src/api-keys/api-keys.service.ts
Normal file
173
services/mana-core-auth/src/api-keys/api-keys.service.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq, and, isNull } from 'drizzle-orm';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { getDb } from '../db/connection';
|
||||
import { apiKeys } from '../db/schema';
|
||||
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
||||
import type { ValidateApiKeyResponseDto } from './dto/validate-api-key.dto';
|
||||
|
||||
const DEFAULT_SCOPES = ['stt', 'tts'];
|
||||
const KEY_PREFIX = 'sk_live_';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeysService {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
private getDb() {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
return getDb(databaseUrl!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new API key
|
||||
* Format: sk_live_<32 random hex chars>
|
||||
*/
|
||||
private generateKey(): string {
|
||||
const randomPart = randomBytes(16).toString('hex');
|
||||
return `${KEY_PREFIX}${randomPart}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash an API key using SHA-256
|
||||
*/
|
||||
private hashKey(key: string): string {
|
||||
return createHash('sha256').update(key).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract prefix for display (first 12 characters after sk_live_)
|
||||
*/
|
||||
private getKeyPrefix(key: string): string {
|
||||
return key.substring(0, KEY_PREFIX.length + 8) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* List all API keys for a user (without exposing the full key)
|
||||
*/
|
||||
async listUserApiKeys(userId: string) {
|
||||
const db = this.getDb();
|
||||
const keys = await db
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
name: apiKeys.name,
|
||||
keyPrefix: apiKeys.keyPrefix,
|
||||
scopes: apiKeys.scopes,
|
||||
rateLimitRequests: apiKeys.rateLimitRequests,
|
||||
rateLimitWindow: apiKeys.rateLimitWindow,
|
||||
createdAt: apiKeys.createdAt,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
revokedAt: apiKeys.revokedAt,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.userId, userId));
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new API key
|
||||
* Returns the full key only once - it cannot be retrieved later
|
||||
*/
|
||||
async createApiKey(userId: string, dto: CreateApiKeyDto) {
|
||||
const db = this.getDb();
|
||||
|
||||
const key = this.generateKey();
|
||||
const keyHash = this.hashKey(key);
|
||||
const keyPrefix = this.getKeyPrefix(key);
|
||||
|
||||
const [apiKey] = await db
|
||||
.insert(apiKeys)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
userId,
|
||||
name: dto.name,
|
||||
keyPrefix,
|
||||
keyHash,
|
||||
scopes: dto.scopes || DEFAULT_SCOPES,
|
||||
rateLimitRequests: dto.rateLimitRequests || 60,
|
||||
rateLimitWindow: dto.rateLimitWindow || 60,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Return the full key only on creation
|
||||
return {
|
||||
id: apiKey.id,
|
||||
name: apiKey.name,
|
||||
key, // Full key - shown only once
|
||||
keyPrefix: apiKey.keyPrefix,
|
||||
scopes: apiKey.scopes,
|
||||
rateLimitRequests: apiKey.rateLimitRequests,
|
||||
rateLimitWindow: apiKey.rateLimitWindow,
|
||||
createdAt: apiKey.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an API key (soft delete)
|
||||
*/
|
||||
async revokeApiKey(userId: string, keyId: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Verify key exists and belongs to user
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, userId), isNull(apiKeys.revokedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('API key not found');
|
||||
}
|
||||
|
||||
await db
|
||||
.update(apiKeys)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, userId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an API key (for STT/TTS services to call)
|
||||
* This endpoint does NOT require authentication
|
||||
*/
|
||||
async validateApiKey(apiKey: string, scope?: string): Promise<ValidateApiKeyResponseDto> {
|
||||
const db = this.getDb();
|
||||
|
||||
// Hash the incoming key to compare
|
||||
const keyHash = this.hashKey(apiKey);
|
||||
|
||||
// Find the key
|
||||
const [key] = await db
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.where(and(eq(apiKeys.keyHash, keyHash), isNull(apiKeys.revokedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!key) {
|
||||
return { valid: false, error: 'Invalid or revoked API key' };
|
||||
}
|
||||
|
||||
// Check scope if provided
|
||||
if (scope && key.scopes && !key.scopes.includes(scope)) {
|
||||
return { valid: false, error: `API key does not have scope: ${scope}` };
|
||||
}
|
||||
|
||||
// Update last used timestamp (fire-and-forget)
|
||||
db.update(apiKeys)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(apiKeys.id, key.id))
|
||||
.then(() => {})
|
||||
.catch(() => {});
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
userId: key.userId,
|
||||
scopes: key.scopes || [],
|
||||
rateLimit: {
|
||||
requests: key.rateLimitRequests,
|
||||
window: key.rateLimitWindow,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { IsString, IsOptional, MaxLength, IsArray, IsInt, Min, Max } from 'class-validator';
|
||||
|
||||
export class CreateApiKeyDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
scopes?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
rateLimitRequests?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(3600)
|
||||
rateLimitWindow?: number;
|
||||
}
|
||||
2
services/mana-core-auth/src/api-keys/dto/index.ts
Normal file
2
services/mana-core-auth/src/api-keys/dto/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './create-api-key.dto';
|
||||
export * from './validate-api-key.dto';
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class ValidateApiKeyDto {
|
||||
@IsString()
|
||||
apiKey: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export class ValidateApiKeyResponseDto {
|
||||
valid: boolean;
|
||||
userId?: string;
|
||||
scopes?: string[];
|
||||
rateLimit?: {
|
||||
requests: number;
|
||||
window: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
4
services/mana-core-auth/src/api-keys/index.ts
Normal file
4
services/mana-core-auth/src/api-keys/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './api-keys.module';
|
||||
export * from './api-keys.service';
|
||||
export * from './api-keys.controller';
|
||||
export * from './dto';
|
||||
Loading…
Add table
Add a link
Reference in a new issue