diff --git a/backend/src/api-keys/README.md b/backend/src/api-keys/README.md deleted file mode 100644 index d06dffb..0000000 --- a/backend/src/api-keys/README.md +++ /dev/null @@ -1,195 +0,0 @@ -# API Key Authentication - -This document describes the API key authentication system for external integrations in MindBlock. - -## Overview - -The API key authentication system allows external services, webhooks, and third-party applications to authenticate with the MindBlock API using secure API keys. - -## Key Features - -- **Secure Generation**: API keys are cryptographically random and follow a specific format -- **Hashed Storage**: Keys are stored as bcrypt hashes, never in plain text -- **Scope-based Permissions**: Keys can have different permission levels (read, write, delete, admin) -- **Rate Limiting**: Per-key rate limiting to prevent abuse -- **Expiration**: Keys can have expiration dates -- **Revocation**: Keys can be instantly revoked -- **Usage Tracking**: All API key usage is logged and tracked -- **IP Whitelisting**: Optional IP address restrictions - -## API Key Format - -API keys follow this format: -``` -mbk_{environment}_{random_string} -``` - -- **Prefix**: `mbk_` (MindBlock Key) -- **Environment**: `live_` or `test_` -- **Random String**: 32 characters (base62) - -Example: `mbk_live_Ab3Cd5Ef7Gh9Ij1Kl3Mn5Op7Qr9St1U` - -## Authentication Methods - -API keys can be provided in two ways: - -1. **Header**: `X-API-Key: mbk_live_...` -2. **Query Parameter**: `?apiKey=mbk_live_...` - -## Scopes and Permissions - -- `read`: Can read data (GET requests) -- `write`: Can create/update data (POST, PUT, PATCH) -- `delete`: Can delete data (DELETE requests) -- `admin`: Full access to all operations -- `custom`: Define specific endpoint access - -## API Endpoints - -### Managing API Keys - -All API key management endpoints require JWT authentication. - -#### Generate API Key -``` -POST /api-keys -Authorization: Bearer -Content-Type: application/json - -{ - "name": "My Integration Key", - "scopes": ["read", "write"], - "expiresAt": "2024-12-31T23:59:59Z", - "ipWhitelist": ["192.168.1.1"] -} -``` - -Response: -```json -{ - "apiKey": "mbk_live_Ab3Cd5Ef7Gh9Ij1Kl3Mn5Op7Qr9St1U", - "apiKeyEntity": { - "id": "key-uuid", - "name": "My Integration Key", - "scopes": ["read", "write"], - "expiresAt": "2024-12-31T23:59:59Z", - "isActive": true, - "usageCount": 0, - "createdAt": "2024-01-01T00:00:00Z" - } -} -``` - -#### List API Keys -``` -GET /api-keys -Authorization: Bearer -``` - -#### Revoke API Key -``` -DELETE /api-keys/{key_id} -Authorization: Bearer -``` - -#### Rotate API Key -``` -POST /api-keys/{key_id}/rotate -Authorization: Bearer -``` - -### Using API Keys - -To authenticate with an API key, include it in requests: - -#### Header Authentication -``` -GET /users/api-keys/stats -X-API-Key: mbk_live_Ab3Cd5Ef7Gh9Ij1Kl3Mn5Op7Qr9St1U -``` - -#### Query Parameter Authentication -``` -GET /users/api-keys/stats?apiKey=mbk_live_Ab3Cd5Ef7Gh9Ij1Kl3Mn5Op7Qr9St1U -``` - -## Error Responses - -### Invalid API Key -```json -{ - "statusCode": 401, - "message": "Invalid API key", - "error": "Unauthorized" -} -``` - -### Insufficient Permissions -```json -{ - "statusCode": 401, - "message": "Insufficient API key permissions", - "error": "Unauthorized" -} -``` - -### Expired Key -```json -{ - "statusCode": 401, - "message": "API key has expired", - "error": "Unauthorized" -} -``` - -### Rate Limited -```json -{ - "statusCode": 429, - "message": "Too Many Requests", - "error": "Too Many Requests" -} -``` - -## Rate Limiting - -- API keys have a default limit of 100 requests per minute -- Rate limits are tracked per API key -- Exceeding limits returns HTTP 429 - -## Security Best Practices - -1. **Store Keys Securely**: Never expose API keys in client-side code or logs -2. **Use Appropriate Scopes**: Grant only necessary permissions -3. **Set Expiration**: Use expiration dates for temporary access -4. **IP Whitelisting**: Restrict access to known IP addresses when possible -5. **Monitor Usage**: Regularly review API key usage logs -6. **Rotate Keys**: Periodically rotate keys for security -7. **Revoke Compromised Keys**: Immediately revoke keys if compromised - -## Implementation Details - -### Middleware Order -1. `ApiKeyMiddleware` - Extracts and validates API key (optional) -2. `ApiKeyGuard` - Enforces authentication requirements -3. `ApiKeyThrottlerGuard` - Applies rate limiting -4. `ApiKeyLoggingInterceptor` - Logs usage - -### Database Schema -API keys are stored in the `api_keys` table with: -- `keyHash`: Bcrypt hash of the API key -- `userId`: Associated user ID -- `scopes`: Array of permission scopes -- `expiresAt`: Optional expiration timestamp -- `isActive`: Active status -- `usageCount`: Number of uses -- `lastUsedAt`: Last usage timestamp -- `ipWhitelist`: Optional IP restrictions - -## Testing - -API keys can be tested using the test environment: -- Use `mbk_test_` prefixed keys for testing -- Test keys don't affect production data -- All features work identically in test mode \ No newline at end of file diff --git a/backend/src/api-keys/api-key-logging.interceptor.ts b/backend/src/api-keys/api-key-logging.interceptor.ts deleted file mode 100644 index f949111..0000000 --- a/backend/src/api-keys/api-key-logging.interceptor.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - Injectable, - NestInterceptor, - ExecutionContext, - CallHandler, - Logger, -} from '@nestjs/common'; -import { Observable } from 'rxjs'; -import { RequestWithApiKey } from './api-key.middleware'; - -@Injectable() -export class ApiKeyLoggingInterceptor implements NestInterceptor { - private readonly logger = new Logger(ApiKeyLoggingInterceptor.name); - - intercept(context: ExecutionContext, next: CallHandler): Observable { - const request = context.switchToHttp().getRequest(); - const response = context.switchToHttp().getResponse(); - - if (request.apiKey) { - const startTime = Date.now(); - - return next.handle().pipe( - tap(() => { - const duration = Date.now() - startTime; - this.logger.log( - `API Key Usage: ${request.apiKey.id} - ${request.method} ${request.url} - ${response.statusCode} - ${duration}ms`, - ); - }), - ); - } - - return next.handle(); - } -} \ No newline at end of file diff --git a/backend/src/api-keys/api-key-throttler.guard.ts b/backend/src/api-keys/api-key-throttler.guard.ts deleted file mode 100644 index 4e3c3a5..0000000 --- a/backend/src/api-keys/api-key-throttler.guard.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Injectable, ExecutionContext, Inject } from '@nestjs/common'; -import { ThrottlerGuard } from '@nestjs/throttler'; -import { RequestWithApiKey } from './api-key.middleware'; - -@Injectable() -export class ApiKeyThrottlerGuard extends ThrottlerGuard { - protected async getTracker(req: RequestWithApiKey): Promise { - // Use API key ID as tracker if API key is present - if (req.apiKey) { - return `api-key:${req.apiKey.id}`; - } - - // Fall back to IP-based tracking if no API key - return req.ip || req.connection.remoteAddress || req.socket.remoteAddress || 'unknown'; - } - - protected async getLimit(context: ExecutionContext): Promise { - const req = context.switchToHttp().getRequest(); - - // Different limits for API keys vs regular requests - if (req.apiKey) { - // API keys get higher limits - return 100; // 100 requests per ttl - } - - // Regular requests use default limit - return 10; // Default from ThrottlerModule config - } - - protected async getTtl(context: ExecutionContext): Promise { - const req = context.switchToHttp().getRequest(); - - // Different TTL for API keys - if (req.apiKey) { - return 60000; // 1 minute - } - - return 60000; // Default from ThrottlerModule config - } -} \ No newline at end of file diff --git a/backend/src/api-keys/api-key.controller.ts b/backend/src/api-keys/api-key.controller.ts deleted file mode 100644 index f86ff58..0000000 --- a/backend/src/api-keys/api-key.controller.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { - Controller, - Post, - Get, - Delete, - Body, - Param, - UseGuards, - Request, - BadRequestException, -} from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; -import { ApiKeyService } from './api-key.service'; -import { ApiKeyScope } from './api-key.entity'; -import { AuthGuard } from '@nestjs/passport'; - -class CreateApiKeyDto { - name: string; - scopes: ApiKeyScope[]; - expiresAt?: Date; - ipWhitelist?: string[]; -} - -class ApiKeyResponseDto { - id: string; - name: string; - scopes: ApiKeyScope[]; - expiresAt?: Date; - isActive: boolean; - lastUsedAt?: Date; - usageCount: number; - createdAt: Date; -} - -@ApiTags('API Keys') -@Controller('api-keys') -@UseGuards(AuthGuard('jwt')) -@ApiBearerAuth() -export class ApiKeyController { - constructor(private readonly apiKeyService: ApiKeyService) {} - - @Post() - @ApiOperation({ summary: 'Generate a new API key' }) - @ApiResponse({ status: 201, description: 'API key generated successfully' }) - async createApiKey( - @Request() req, - @Body() dto: CreateApiKeyDto, - ): Promise<{ apiKey: string; apiKeyEntity: ApiKeyResponseDto }> { - const userId = req.user.id; - - const result = await this.apiKeyService.generateApiKey( - userId, - dto.name, - dto.scopes, - dto.expiresAt, - dto.ipWhitelist, - ); - - const { apiKey, apiKeyEntity } = result; - return { - apiKey, - apiKeyEntity: { - id: apiKeyEntity.id, - name: apiKeyEntity.name, - scopes: apiKeyEntity.scopes, - expiresAt: apiKeyEntity.expiresAt, - isActive: apiKeyEntity.isActive, - lastUsedAt: apiKeyEntity.lastUsedAt, - usageCount: apiKeyEntity.usageCount, - createdAt: apiKeyEntity.createdAt, - }, - }; - } - - @Get() - @ApiOperation({ summary: 'Get all API keys for the current user' }) - @ApiResponse({ status: 200, description: 'List of API keys' }) - async getApiKeys(@Request() req): Promise { - const userId = req.user.id; - const apiKeys = await this.apiKeyService.getUserApiKeys(userId); - - return apiKeys.map(key => ({ - id: key.id, - name: key.name, - scopes: key.scopes, - expiresAt: key.expiresAt, - isActive: key.isActive, - lastUsedAt: key.lastUsedAt, - usageCount: key.usageCount, - createdAt: key.createdAt, - })); - } - - @Delete(':id') - @ApiOperation({ summary: 'Revoke an API key' }) - @ApiResponse({ status: 200, description: 'API key revoked successfully' }) - async revokeApiKey(@Request() req, @Param('id') apiKeyId: string): Promise { - const userId = req.user.id; - await this.apiKeyService.revokeApiKey(apiKeyId, userId); - } - - @Post(':id/rotate') - @ApiOperation({ summary: 'Rotate an API key' }) - @ApiResponse({ status: 201, description: 'API key rotated successfully' }) - async rotateApiKey( - @Request() req, - @Param('id') apiKeyId: string, - ): Promise<{ apiKey: string; apiKeyEntity: ApiKeyResponseDto }> { - const userId = req.user.id; - - const result = await this.apiKeyService.rotateApiKey(apiKeyId, userId); - - const { apiKey, apiKeyEntity } = result; - return { - apiKey, - apiKeyEntity: { - id: apiKeyEntity.id, - name: apiKeyEntity.name, - scopes: apiKeyEntity.scopes, - expiresAt: apiKeyEntity.expiresAt, - isActive: apiKeyEntity.isActive, - lastUsedAt: apiKeyEntity.lastUsedAt, - usageCount: apiKeyEntity.usageCount, - createdAt: apiKeyEntity.createdAt, - }, - }; - } -} \ No newline at end of file diff --git a/backend/src/api-keys/api-key.decorators.ts b/backend/src/api-keys/api-key.decorators.ts deleted file mode 100644 index 233ec78..0000000 --- a/backend/src/api-keys/api-key.decorators.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { SetMetadata, UseGuards, applyDecorators } from '@nestjs/common'; -import { ApiKeyScope } from './api-key.entity'; -import { ApiKeyGuard } from './api-key.guard'; -import { ApiKeyThrottlerGuard } from './api-key-throttler.guard'; - -export const API_KEY_SCOPES = 'api_key_scopes'; -export const REQUIRE_API_KEY = 'require_api_key'; - -export function RequireApiKey() { - return applyDecorators( - SetMetadata(REQUIRE_API_KEY, true), - UseGuards(ApiKeyGuard, ApiKeyThrottlerGuard), - ); -} - -export function RequireApiKeyScopes(...scopes: ApiKeyScope[]) { - return applyDecorators( - SetMetadata(API_KEY_SCOPES, scopes), - SetMetadata(REQUIRE_API_KEY, true), - UseGuards(ApiKeyGuard, ApiKeyThrottlerGuard), - ); -} \ No newline at end of file diff --git a/backend/src/api-keys/api-key.entity.ts b/backend/src/api-keys/api-key.entity.ts deleted file mode 100644 index f1ddd44..0000000 --- a/backend/src/api-keys/api-key.entity.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { - Column, - Entity, - ManyToOne, - PrimaryGeneratedColumn, - CreateDateColumn, - UpdateDateColumn, - JoinColumn, -} from 'typeorm'; -import { User } from '../users/user.entity'; - -export enum ApiKeyScope { - READ = 'read', - WRITE = 'write', - DELETE = 'delete', - ADMIN = 'admin', - CUSTOM = 'custom', -} - -@Entity('api_keys') -export class ApiKey { - @PrimaryGeneratedColumn('uuid') - id: string; - - @ApiProperty({ description: 'Hashed API key' }) - @Column('varchar', { length: 255, unique: true }) - keyHash: string; - - @ApiProperty({ description: 'User-friendly name for the API key' }) - @Column('varchar', { length: 100 }) - name: string; - - @ApiProperty({ description: 'Associated user ID' }) - @Column('uuid') - userId: string; - - @ManyToOne(() => User) - @JoinColumn({ name: 'userId' }) - user: User; - - @ApiProperty({ - description: 'Scopes/permissions for this API key', - enum: ApiKeyScope, - isArray: true, - }) - @Column('simple-array', { default: [ApiKeyScope.READ] }) - scopes: ApiKeyScope[]; - - @ApiProperty({ description: 'Expiration date' }) - @Column({ type: 'timestamp', nullable: true }) - expiresAt?: Date; - - @ApiProperty({ description: 'Whether the key is active' }) - @Column({ type: 'boolean', default: true }) - isActive: boolean; - - @ApiProperty({ description: 'Last used timestamp' }) - @Column({ type: 'timestamp', nullable: true }) - lastUsedAt?: Date; - - @ApiProperty({ description: 'Usage count' }) - @Column({ type: 'int', default: 0 }) - usageCount: number; - - @ApiProperty({ description: 'IP whitelist (optional)' }) - @Column('simple-array', { nullable: true }) - ipWhitelist?: string[]; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} \ No newline at end of file diff --git a/backend/src/api-keys/api-key.guard.ts b/backend/src/api-keys/api-key.guard.ts deleted file mode 100644 index e077dd2..0000000 --- a/backend/src/api-keys/api-key.guard.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { ApiKeyService } from './api-key.service'; -import { ApiKeyScope } from './api-key.entity'; -import { RequestWithApiKey } from './api-key.middleware'; -import { API_KEY_SCOPES, REQUIRE_API_KEY } from './api-key.decorators'; - -@Injectable() -export class ApiKeyGuard implements CanActivate { - constructor( - private readonly reflector: Reflector, - private readonly apiKeyService: ApiKeyService, - ) {} - - canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest(); - const requireApiKey = this.reflector.get(REQUIRE_API_KEY, context.getHandler()); - - if (!requireApiKey) { - return true; // No API key required - } - - if (!request.apiKey) { - throw new UnauthorizedException('API key authentication required'); - } - - const requiredScopes = this.reflector.get(API_KEY_SCOPES, context.getHandler()); - - if (requiredScopes && requiredScopes.length > 0) { - const hasRequiredScope = requiredScopes.some(scope => - this.apiKeyService.hasScope(request.apiKey, scope) - ); - - if (!hasRequiredScope) { - throw new UnauthorizedException('Insufficient API key permissions'); - } - } - - return true; - } -} \ No newline at end of file diff --git a/backend/src/api-keys/api-key.middleware.ts b/backend/src/api-keys/api-key.middleware.ts deleted file mode 100644 index 573b980..0000000 --- a/backend/src/api-keys/api-key.middleware.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common'; -import { Request, Response, NextFunction } from 'express'; -import { ApiKeyService } from './api-key.service'; -import { ApiKeyScope } from './api-key.entity'; - -export interface RequestWithApiKey extends Request { - apiKey?: any; - user?: any; -} - -@Injectable() -export class ApiKeyMiddleware implements NestMiddleware { - constructor(private readonly apiKeyService: ApiKeyService) {} - - async use(req: RequestWithApiKey, res: Response, next: NextFunction) { - const apiKey = this.extractApiKey(req); - - if (!apiKey) { - return next(); - } - - try { - const clientIp = req.ip || req.connection.remoteAddress || req.socket.remoteAddress; - const apiKeyEntity = await this.apiKeyService.validateApiKey(apiKey, clientIp as string); - - req.apiKey = apiKeyEntity; - req.user = apiKeyEntity.user; - - // Store API key info in response locals for logging - res.locals.apiKeyId = apiKeyEntity.id; - res.locals.userId = apiKeyEntity.userId; - - } catch (error) { - throw new UnauthorizedException(error.message); - } - - next(); - } - - private extractApiKey(req: Request): string | null { - // Check header first - const headerKey = req.headers['x-api-key'] as string; - if (headerKey) { - return headerKey; - } - - // Check query parameter - const queryKey = req.query.apiKey as string; - if (queryKey) { - return queryKey; - } - - return null; - } -} - -@Injectable() -export class ApiKeyAuthMiddleware implements NestMiddleware { - constructor(private readonly apiKeyService: ApiKeyService) {} - - async use(req: RequestWithApiKey, res: Response, next: NextFunction) { - const apiKey = this.extractApiKey(req); - - if (!apiKey) { - throw new UnauthorizedException('API key required'); - } - - try { - const clientIp = req.ip || req.connection.remoteAddress || req.socket.remoteAddress; - const apiKeyEntity = await this.apiKeyService.validateApiKey(apiKey, clientIp as string); - - req.apiKey = apiKeyEntity; - req.user = apiKeyEntity.user; - - // Store API key info in response locals for logging - res.locals.apiKeyId = apiKeyEntity.id; - res.locals.userId = apiKeyEntity.userId; - - } catch (error) { - throw new UnauthorizedException(error.message); - } - - next(); - } - - private extractApiKey(req: Request): string | null { - // Check header first - const headerKey = req.headers['x-api-key'] as string; - if (headerKey) { - return headerKey; - } - - // Check query parameter - const queryKey = req.query.apiKey as string; - if (queryKey) { - return queryKey; - } - - return null; - } -} - -@Injectable() -export class ApiKeyScopeMiddleware implements NestMiddleware { - constructor( - private readonly apiKeyService: ApiKeyService, - private readonly requiredScopes: ApiKeyScope[], - ) {} - - async use(req: RequestWithApiKey, res: Response, next: NextFunction) { - if (!req.apiKey) { - throw new UnauthorizedException('API key authentication required'); - } - - const hasRequiredScope = this.requiredScopes.some(scope => - this.apiKeyService.hasScope(req.apiKey, scope) - ); - - if (!hasRequiredScope) { - throw new UnauthorizedException('Insufficient API key permissions'); - } - - next(); - } -} \ No newline at end of file diff --git a/backend/src/api-keys/api-key.module.ts b/backend/src/api-keys/api-key.module.ts deleted file mode 100644 index 91530d9..0000000 --- a/backend/src/api-keys/api-key.module.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Module, MiddlewareConsumer, RequestMethod } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { APP_INTERCEPTOR } from '@nestjs/core'; -import { ApiKey } from './api-key.entity'; -import { ApiKeyService } from './api-key.service'; -import { ApiKeyController } from './api-key.controller'; -import { User } from '../users/user.entity'; -import { ApiKeyMiddleware, ApiKeyAuthMiddleware } from './api-key.middleware'; -import { ApiKeyLoggingInterceptor } from './api-key-logging.interceptor'; -import { ApiKeyThrottlerGuard } from './api-key-throttler.guard'; -import { ApiKeyGuard } from './api-key.guard'; - -@Module({ - imports: [TypeOrmModule.forFeature([ApiKey, User])], - controllers: [ApiKeyController], - providers: [ - ApiKeyService, - ApiKeyThrottlerGuard, - ApiKeyGuard, - { - provide: APP_INTERCEPTOR, - useClass: ApiKeyLoggingInterceptor, - }, - ], - exports: [ApiKeyService, ApiKeyThrottlerGuard], -}) -export class ApiKeyModule { - configure(consumer: MiddlewareConsumer) { - // Apply API key middleware to all routes (optional authentication) - consumer - .apply(ApiKeyMiddleware) - .forRoutes({ path: '*', method: RequestMethod.ALL }); - } -} \ No newline at end of file diff --git a/backend/src/api-keys/api-key.service.spec.ts b/backend/src/api-keys/api-key.service.spec.ts deleted file mode 100644 index d8b0e6c..0000000 --- a/backend/src/api-keys/api-key.service.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { ApiKeyService } from './api-key.service'; -import { ApiKey, ApiKeyScope } from './api-key.entity'; -import { User } from '../users/user.entity'; - -describe('ApiKeyService', () => { - let service: ApiKeyService; - let apiKeyRepository: Repository; - let userRepository: Repository; - - const mockUser = { - id: 'user-123', - email: 'test@example.com', - }; - - const mockApiKey = { - id: 'key-123', - keyHash: 'hashed-key', - name: 'Test Key', - userId: 'user-123', - scopes: [ApiKeyScope.READ], - isActive: true, - usageCount: 0, - createdAt: new Date(), - updatedAt: new Date(), - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - ApiKeyService, - { - provide: getRepositoryToken(ApiKey), - useValue: { - create: jest.fn().mockReturnValue(mockApiKey), - save: jest.fn().mockResolvedValue(mockApiKey), - findOne: jest.fn().mockResolvedValue(mockApiKey), - find: jest.fn().mockResolvedValue([mockApiKey]), - }, - }, - { - provide: getRepositoryToken(User), - useValue: { - findOne: jest.fn().mockResolvedValue(mockUser), - }, - }, - ], - }).compile(); - - service = module.get(ApiKeyService); - apiKeyRepository = module.get>(getRepositoryToken(ApiKey)); - userRepository = module.get>(getRepositoryToken(User)); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('generateApiKey', () => { - it('should generate a new API key', async () => { - const result = await service.generateApiKey('user-123', 'Test Key', [ApiKeyScope.READ]); - - expect(result).toHaveProperty('apiKey'); - expect(result).toHaveProperty('apiKeyEntity'); - expect(result.apiKey).toMatch(/^mbk_(live|test)_[A-Za-z0-9_-]{32}$/); - expect(apiKeyRepository.create).toHaveBeenCalled(); - expect(apiKeyRepository.save).toHaveBeenCalled(); - }); - }); - - describe('validateApiKey', () => { - it('should validate a correct API key', async () => { - const rawKey = 'mbk_test_abc123def456ghi789jkl012mno345pqr'; - jest.spyOn(service as any, 'hashApiKey').mockResolvedValue('hashed-key'); - - const result = await service.validateApiKey(rawKey); - - expect(result).toEqual(mockApiKey); - }); - - it('should throw error for invalid key format', async () => { - await expect(service.validateApiKey('invalid-key')).rejects.toThrow('Invalid API key format'); - }); - }); -}); \ No newline at end of file diff --git a/backend/src/api-keys/api-key.service.ts b/backend/src/api-keys/api-key.service.ts deleted file mode 100644 index 2b7cae8..0000000 --- a/backend/src/api-keys/api-key.service.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { Injectable, BadRequestException, UnauthorizedException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import * as bcrypt from 'bcryptjs'; -import * as crypto from 'crypto'; -import { ApiKey, ApiKeyScope } from './api-key.entity'; -import { User } from '../users/user.entity'; - -@Injectable() -export class ApiKeyService { - constructor( - @InjectRepository(ApiKey) - private readonly apiKeyRepository: Repository, - @InjectRepository(User) - private readonly userRepository: Repository, - ) {} - - /** - * Generate a new API key for a user - */ - async generateApiKey( - userId: string, - name: string, - scopes: ApiKeyScope[] = [ApiKeyScope.READ], - expiresAt?: Date, - ipWhitelist?: string[], - ): Promise<{ apiKey: string; apiKeyEntity: ApiKey }> { - const user = await this.userRepository.findOne({ where: { id: userId } }); - if (!user) { - throw new BadRequestException('User not found'); - } - - const rawKey = this.generateRawApiKey(); - const keyHash = await bcrypt.hash(rawKey, 12); - - const apiKeyEntity = this.apiKeyRepository.create({ - keyHash, - name, - userId, - scopes, - expiresAt, - ipWhitelist, - }); - - await this.apiKeyRepository.save(apiKeyEntity); - - return { apiKey: rawKey, apiKeyEntity }; - } - - /** - * Validate an API key and return the associated ApiKey entity - */ - async validateApiKey(rawKey: string, clientIp?: string): Promise { - // Extract the key part (after mbk_live_ or mbk_test_) - const keyParts = rawKey.split('_'); - if (keyParts.length !== 3 || keyParts[0] !== 'mbk') { - throw new UnauthorizedException('Invalid API key format'); - } - - const keyHash = await this.hashApiKey(rawKey); - const apiKey = await this.apiKeyRepository.findOne({ - where: { keyHash }, - relations: ['user'], - }); - - if (!apiKey) { - throw new UnauthorizedException('Invalid API key'); - } - - if (!apiKey.isActive) { - throw new UnauthorizedException('API key is inactive'); - } - - if (apiKey.expiresAt && apiKey.expiresAt < new Date()) { - throw new UnauthorizedException('API key has expired'); - } - - if (apiKey.ipWhitelist && apiKey.ipWhitelist.length > 0 && clientIp) { - if (!apiKey.ipWhitelist.includes(clientIp)) { - throw new UnauthorizedException('IP address not whitelisted'); - } - } - - // Update usage stats - apiKey.lastUsedAt = new Date(); - apiKey.usageCount += 1; - await this.apiKeyRepository.save(apiKey); - - return apiKey; - } - - /** - * Check if an API key has a specific scope - */ - hasScope(apiKey: ApiKey, requiredScope: ApiKeyScope): boolean { - return apiKey.scopes.includes(requiredScope) || apiKey.scopes.includes(ApiKeyScope.ADMIN); - } - - /** - * Revoke an API key - */ - async revokeApiKey(apiKeyId: string, userId: string): Promise { - const apiKey = await this.apiKeyRepository.findOne({ - where: { id: apiKeyId, userId }, - }); - - if (!apiKey) { - throw new BadRequestException('API key not found'); - } - - apiKey.isActive = false; - await this.apiKeyRepository.save(apiKey); - } - - /** - * Get all API keys for a user - */ - async getUserApiKeys(userId: string): Promise { - return this.apiKeyRepository.find({ - where: { userId }, - order: { createdAt: 'DESC' }, - }); - } - - /** - * Rotate an API key (generate new key, revoke old) - */ - async rotateApiKey(apiKeyId: string, userId: string): Promise<{ apiKey: string; apiKeyEntity: ApiKey }> { - const oldApiKey = await this.apiKeyRepository.findOne({ - where: { id: apiKeyId, userId }, - }); - - if (!oldApiKey) { - throw new BadRequestException('API key not found'); - } - - // Revoke old key - oldApiKey.isActive = false; - await this.apiKeyRepository.save(oldApiKey); - - // Generate new key with same settings - return this.generateApiKey( - userId, - `${oldApiKey.name} (rotated)`, - oldApiKey.scopes, - oldApiKey.expiresAt, - oldApiKey.ipWhitelist, - ); - } - - private generateRawApiKey(): string { - const env = process.env.NODE_ENV === 'production' ? 'live' : 'test'; - const randomString = crypto.randomBytes(24).toString('base64url').slice(0, 32); - return `mbk_${env}_${randomString}`; - } - - private async hashApiKey(rawKey: string): Promise { - return bcrypt.hash(rawKey, 12); - } -} \ No newline at end of file diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 6c6210f..5da1b31 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -22,7 +22,6 @@ import jwtConfig from './auth/authConfig/jwt.config'; import { UsersService } from './users/providers/users.service'; import { GeolocationMiddleware } from './common/middleware/geolocation.middleware'; import { HealthModule } from './health/health.module'; -import { ApiKeyModule } from './api-keys/api-key.module'; // const ENV = process.env.NODE_ENV; // console.log('NODE_ENV:', process.env.NODE_ENV); @@ -103,7 +102,6 @@ import { ApiKeyModule } from './api-keys/api-key.module'; }), }), HealthModule, - ApiKeyModule, ], controllers: [AppController], providers: [AppService], diff --git a/backend/src/database/migrations/1774515572086-CreateApiKeysTable.ts b/backend/src/database/migrations/1774515572086-CreateApiKeysTable.ts deleted file mode 100644 index 68fffa5..0000000 --- a/backend/src/database/migrations/1774515572086-CreateApiKeysTable.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; - -export class CreateApiKeysTable1774515572086 implements MigrationInterface { - - public async up(queryRunner: QueryRunner): Promise { - } - - public async down(queryRunner: QueryRunner): Promise { - } - -} diff --git a/backend/src/database/migrations/20260326000000-CreateApiKeysTable.ts b/backend/src/database/migrations/20260326000000-CreateApiKeysTable.ts deleted file mode 100644 index ea3fa41..0000000 --- a/backend/src/database/migrations/20260326000000-CreateApiKeysTable.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class CreateApiKeysTable20260326000000 implements MigrationInterface { - name = 'CreateApiKeysTable20260326000000'; - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(` - CREATE TYPE "public"."api_key_scope_enum" AS ENUM('read', 'write', 'delete', 'admin', 'custom'); - - CREATE TABLE "api_keys" ( - "id" uuid NOT NULL DEFAULT uuid_generate_v4(), - "keyHash" character varying(255) NOT NULL, - "name" character varying(100) NOT NULL, - "userId" uuid NOT NULL, - "scopes" text NOT NULL DEFAULT 'read', - "expiresAt" TIMESTAMP, - "isActive" boolean NOT NULL DEFAULT true, - "lastUsedAt" TIMESTAMP, - "usageCount" integer NOT NULL DEFAULT 0, - "ipWhitelist" text, - "createdAt" TIMESTAMP NOT NULL DEFAULT now(), - "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), - CONSTRAINT "PK_api_keys_id" PRIMARY KEY ("id"), - CONSTRAINT "UQ_api_keys_keyHash" UNIQUE ("keyHash") - ); - - ALTER TABLE "api_keys" - ADD CONSTRAINT "FK_api_keys_user" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE; - `); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(` - ALTER TABLE "api_keys" DROP CONSTRAINT "FK_api_keys_user"; - DROP TABLE "api_keys"; - DROP TYPE "public"."api_key_scope_enum"; - `); - } -} \ No newline at end of file diff --git a/backend/src/users/controllers/users.controller.ts b/backend/src/users/controllers/users.controller.ts index 5343848..3972b52 100644 --- a/backend/src/users/controllers/users.controller.ts +++ b/backend/src/users/controllers/users.controller.ts @@ -15,8 +15,6 @@ import { paginationQueryDto } from '../../common/pagination/paginationQueryDto'; import { EditUserDto } from '../dtos/editUserDto.dto'; import { CreateUserDto } from '../dtos/createUserDto'; import { User } from '../user.entity'; -import { RequireApiKey, RequireApiKeyScopes } from '../../api-keys/api-key.decorators'; -import { ApiKeyScope } from '../../api-keys/api-key.entity'; @Controller('users') @ApiTags('users') @@ -81,22 +79,4 @@ export class UsersController { async update(@Param('id') id: string, @Body() editUserDto: EditUserDto) { return this.usersService.update(id, editUserDto); } - - @Get('api-keys/stats') - @RequireApiKey() - @ApiOperation({ summary: 'Get user statistics (requires API key)' }) - @ApiResponse({ status: 200, description: 'User stats retrieved' }) - async getUserStatsWithApiKey() { - // This endpoint requires API key authentication - return { message: 'This endpoint requires API key authentication' }; - } - - @Post('api-keys/admin-action') - @RequireApiKeyScopes(ApiKeyScope.ADMIN) - @ApiOperation({ summary: 'Admin action (requires admin API key scope)' }) - @ApiResponse({ status: 200, description: 'Admin action performed' }) - async adminActionWithApiKey() { - // This endpoint requires API key with admin scope - return { message: 'Admin action performed with API key' }; - } }