Spaces:
Runtime error
Runtime error
| import { ConflictException, Injectable, NotFoundException, UnauthorizedException, OnModuleInit } from '@nestjs/common'; | |
| import { ModuleRef } from '@nestjs/core'; | |
| import { InjectRepository } from '@nestjs/typeorm'; | |
| import { IsNull, MoreThan, Not, Repository } from 'typeorm'; | |
| import { randomBytes } from 'crypto'; | |
| import { existsSync, readFileSync } from 'fs'; | |
| import { join } from 'path'; | |
| import { writeSecretFile } from '../../common/utils/secret-file'; | |
| import { ipMatches } from '../../common/utils/ip'; | |
| import { hashApiKey } from './api-key-hash'; | |
| import { ApiKey, ApiKeyRole } from './entities/api-key.entity'; | |
| import { CreateApiKeyDto, UpdateApiKeyDto } from './dto'; | |
| import { createLogger } from '../../common/services/logger.service'; | |
| import { EventsGateway, type ApiKeyEvictionReason } from '../events/events.gateway'; | |
| import { getDataDirectory } from '../../config/data-directory'; | |
| const API_KEY_FILE = join(getDataDirectory(), '.api-key'); | |
| /** | |
| * Resolves the API key to seed on first boot (when no keys exist yet). | |
| * Precedence: an explicit `API_MASTER_KEY` always wins; otherwise a | |
| * cryptographically random `owa_k1_` key is generated — the secure default, | |
| * including in non-production. The legacy fixed `dev-admin-key` is used only when | |
| * a developer explicitly opts in with `ALLOW_DEV_API_KEY=true`, never by default. | |
| */ | |
| export function resolveSeedApiKey(): string { | |
| if (process.env.API_MASTER_KEY) { | |
| return process.env.API_MASTER_KEY; | |
| } | |
| if (process.env.ALLOW_DEV_API_KEY === 'true') { | |
| return 'dev-admin-key'; | |
| } | |
| return `owa_k1_${randomBytes(32).toString('hex')}`; | |
| } | |
| /** | |
| * The line to print for the API key in the startup banner. The full raw key is shown ONLY when it was | |
| * just created (first run, when the operator needs to capture it once). On every subsequent boot the | |
| * key is masked to a short non-secret fingerprint, so the live admin key is not re-written to the log | |
| * pipeline (Docker/Loki/CloudWatch) on each restart — it stays in `data/.api-key` (0600) and the | |
| * dashboard. A placeholder (e.g. "(check dashboard for keys)") is passed through unchanged. | |
| */ | |
| export function bannerKeyLine(displayKey: string, isNewKey: boolean): string { | |
| if (isNewKey) return displayKey; | |
| if (displayKey.startsWith('(')) return displayKey; | |
| return `${displayKey.slice(0, 8)}… (full key in data/.api-key or the dashboard)`; | |
| } | |
| () | |
| export class AuthService implements OnModuleInit { | |
| private readonly logger = createLogger('AuthService'); | |
| /** Coalesce per-request usage-stat writes to at most one DB write per key per window. */ | |
| private static readonly STAT_FLUSH_INTERVAL_MS = 60_000; | |
| /** keyId -> usage increments observed but not yet persisted (flushed on the next windowed write). */ | |
| private readonly pendingUsage = new Map<string, number>(); | |
| constructor( | |
| (ApiKey, 'main') | |
| private readonly apiKeyRepository: Repository<ApiKey>, | |
| private readonly moduleRef: ModuleRef, | |
| ) {} | |
| async onModuleInit(): Promise<void> { | |
| // Seed a default API key if none exist | |
| const count = await this.apiKeyRepository.count(); | |
| let displayKey: string; | |
| let isNewKey = false; | |
| if (count === 0) { | |
| displayKey = resolveSeedApiKey(); | |
| await this.seedApiKey(displayKey, 'Default Admin Key', ApiKeyRole.ADMIN); | |
| isNewKey = true; | |
| // Save raw key to file for startup script to read (owner-only — it's the raw admin key). | |
| try { | |
| writeSecretFile(API_KEY_FILE, displayKey); | |
| } catch (err) { | |
| this.logger.warn('Could not save API key file', { error: String(err) }); | |
| } | |
| } else { | |
| // Read saved API key from file if exists | |
| if (existsSync(API_KEY_FILE)) { | |
| try { | |
| displayKey = readFileSync(API_KEY_FILE, 'utf-8').trim(); | |
| } catch (error) { | |
| this.logger.warn(`Failed to read API key file: ${API_KEY_FILE}`, { error: String(error) }); | |
| displayKey = '(check dashboard for keys)'; | |
| } | |
| } else { | |
| displayKey = '(check dashboard for keys)'; | |
| } | |
| } | |
| // Always show the welcome banner on startup | |
| const apiBaseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 2785}`; | |
| // The dashboard is served by NestJS at the same origin as the API now, so default to it. | |
| const dashboardUrl = process.env.DASHBOARD_URL || apiBaseUrl; | |
| this.logger.log(''); | |
| this.logger.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); | |
| this.logger.log(''); | |
| this.logger.log(' 🟢 Welcome to OpenWA - WhatsApp API Gateway'); | |
| this.logger.log(''); | |
| this.logger.log(` 📊 Dashboard: ${dashboardUrl}`); | |
| this.logger.log(` 📚 API Docs: ${apiBaseUrl}/api/docs`); | |
| this.logger.log(''); | |
| if (isNewKey) { | |
| this.logger.log(' 🔑 API Key (newly created):'); | |
| } else { | |
| this.logger.log(' 🔑 API Key:'); | |
| } | |
| this.logger.log(` ${bannerKeyLine(displayKey, isNewKey)}`); | |
| this.logger.log(''); | |
| this.logger.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); | |
| this.logger.log(''); | |
| } | |
| private async seedApiKey(rawKey: string, name: string, role: ApiKeyRole): Promise<ApiKey> { | |
| const keyHash = this.hashKey(rawKey); | |
| const keyPrefix = rawKey.substring(0, 12); | |
| const apiKey = this.apiKeyRepository.create({ | |
| name, | |
| keyHash, | |
| keyPrefix, | |
| role, | |
| }); | |
| return this.apiKeyRepository.save(apiKey); | |
| } | |
| async createApiKey(dto: CreateApiKeyDto): Promise<{ apiKey: ApiKey; rawKey: string }> { | |
| // Generate secure random key: owa_k1_<32 bytes hex> | |
| const rawKey = `owa_k1_${randomBytes(32).toString('hex')}`; | |
| const keyHash = this.hashKey(rawKey); | |
| const keyPrefix = rawKey.substring(0, 12); | |
| const apiKey = this.apiKeyRepository.create({ | |
| name: dto.name, | |
| keyHash, | |
| keyPrefix, | |
| role: dto.role || ApiKeyRole.OPERATOR, | |
| allowedIps: dto.allowedIps || null, | |
| allowedSessions: dto.allowedSessions || null, | |
| expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null, | |
| }); | |
| const saved = await this.apiKeyRepository.save(apiKey); | |
| this.logger.log(`API key created: ${saved.name}`, { | |
| keyId: saved.id, | |
| role: saved.role, | |
| action: 'api_key_created', | |
| }); | |
| return { apiKey: saved, rawKey }; | |
| } | |
| async findAll(): Promise<ApiKey[]> { | |
| return this.apiKeyRepository.find({ | |
| order: { createdAt: 'DESC' }, | |
| }); | |
| } | |
| async findOne(id: string): Promise<ApiKey> { | |
| const apiKey = await this.apiKeyRepository.findOne({ where: { id } }); | |
| if (!apiKey) { | |
| throw new NotFoundException(`API key with id '${id}' not found`); | |
| } | |
| return apiKey; | |
| } | |
| async update(id: string, dto: UpdateApiKeyDto): Promise<ApiKey> { | |
| const apiKey = await this.findOne(id); | |
| const removesOrSchedulesLastAdmin = | |
| (dto.role !== undefined && dto.role !== ApiKeyRole.ADMIN) || | |
| (dto.expiresAt !== undefined && dto.expiresAt !== null); | |
| if (removesOrSchedulesLastAdmin) { | |
| await this.assertNotLastUsableAdmin(apiKey); | |
| } | |
| // Capture the authorization-relevant fields BEFORE applying the change. Only a change to role, | |
| // allowedIps, allowedSessions, or expiry can widen or restrict what an already-connected WebSocket | |
| // socket may see, so only those trigger eviction of live /events sockets — a benign rename must | |
| // NOT disconnect clients. REST enforces the new state immediately; without eviction a live socket | |
| // keeps streaming events for sessions/IPs the key just lost until it resubscribes or drops. | |
| const before = { | |
| role: apiKey.role, | |
| allowedIps: apiKey.allowedIps, | |
| allowedSessions: apiKey.allowedSessions, | |
| expiresAt: apiKey.expiresAt, | |
| }; | |
| if (dto.name) apiKey.name = dto.name; | |
| if (dto.role) apiKey.role = dto.role; | |
| if (dto.allowedIps !== undefined) apiKey.allowedIps = dto.allowedIps; | |
| if (dto.allowedSessions !== undefined) apiKey.allowedSessions = dto.allowedSessions; | |
| if (dto.expiresAt !== undefined) apiKey.expiresAt = dto.expiresAt ? new Date(dto.expiresAt) : null; | |
| const saved = await this.apiKeyRepository.save(apiKey); | |
| // Compare membership, not order: a pure reorder of allowedIps/allowedSessions is a no-op for the | |
| // .includes()-based enforcement, so sort before stringify to avoid a spurious eviction on a reorder. | |
| const ordered = (v: string[] | null) => (v ? [...v].sort() : v); | |
| const authzChanged = | |
| saved.role !== before.role || | |
| saved.expiresAt?.getTime() !== before.expiresAt?.getTime() || | |
| JSON.stringify(ordered(saved.allowedIps)) !== JSON.stringify(ordered(before.allowedIps)) || | |
| JSON.stringify(ordered(saved.allowedSessions)) !== JSON.stringify(ordered(before.allowedSessions)); | |
| if (authzChanged) { | |
| this.evictActiveSockets(id, 'authorization_changed'); | |
| } | |
| return saved; | |
| } | |
| async delete(id: string): Promise<void> { | |
| const apiKey = await this.findOne(id); | |
| await this.assertNotLastUsableAdmin(apiKey); | |
| // Drop any un-flushed usage accumulator so a deleted key leaves nothing behind in the Map. | |
| this.pendingUsage.delete(id); | |
| await this.apiKeyRepository.remove(apiKey); | |
| this.evictActiveSockets(id, 'deleted'); | |
| this.logger.log(`API key deleted: ${apiKey.name}`, { | |
| keyId: id, | |
| action: 'api_key_deleted', | |
| }); | |
| } | |
| async revoke(id: string): Promise<ApiKey> { | |
| const apiKey = await this.findOne(id); | |
| await this.assertNotLastUsableAdmin(apiKey); | |
| // A revoked key fails validation before its next flush, so its accumulator would orphan — | |
| // drop it here. | |
| this.pendingUsage.delete(id); | |
| apiKey.isActive = false; | |
| const saved = await this.apiKeyRepository.save(apiKey); | |
| // Kick any WebSocket connections already authenticated with this key: without this, a revoked | |
| // key keeps receiving events on already-subscribed sockets until they happen to disconnect. | |
| this.evictActiveSockets(id, 'revoked'); | |
| return saved; | |
| } | |
| private async assertNotLastUsableAdmin(target: ApiKey): Promise<void> { | |
| const now = new Date(); | |
| const targetIsUsableAdmin = | |
| target.role === ApiKeyRole.ADMIN && target.isActive && (!target.expiresAt || target.expiresAt > now); | |
| if (!targetIsUsableAdmin) return; | |
| const otherUsableAdmins = await this.apiKeyRepository.count({ | |
| where: [ | |
| { id: Not(target.id), role: ApiKeyRole.ADMIN, isActive: true, expiresAt: IsNull() }, | |
| { id: Not(target.id), role: ApiKeyRole.ADMIN, isActive: true, expiresAt: MoreThan(now) }, | |
| ], | |
| }); | |
| if (otherUsableAdmins === 0) { | |
| throw new ConflictException('Cannot remove the last active admin key'); | |
| } | |
| } | |
| /** | |
| * Disconnect every WebSocket socket authenticated with the given key id. Resolved lazily via | |
| * ModuleRef (not constructor injection) to avoid a static DI cycle between AuthModule and | |
| * EventsModule. Best-effort: if the WS gateway isn't loaded (or has no sockets for the key), | |
| * this is a silent no-op. | |
| */ | |
| private evictActiveSockets(keyId: string, reason: ApiKeyEvictionReason = 'revoked'): void { | |
| try { | |
| const gateway = this.moduleRef.get(EventsGateway, { strict: false }); | |
| if (gateway) { | |
| gateway.evictApiKey(keyId, reason); | |
| } | |
| } catch (error) { | |
| // Eviction is best-effort: the key's DB state is already authoritative (validateApiKey | |
| // rejects it), so a failure here must never roll back the revoke/delete. | |
| this.logger.warn(`Failed to evict WebSocket sockets for key ${keyId}`, { | |
| error: error instanceof Error ? error.message : String(error), | |
| }); | |
| } | |
| } | |
| async validateApiKey(rawKey: string, clientIp?: string, sessionId?: string): Promise<ApiKey> { | |
| const keyHash = this.hashKey(rawKey); | |
| const apiKey = await this.apiKeyRepository.findOne({ where: { keyHash } }); | |
| if (!apiKey) { | |
| throw new UnauthorizedException('Invalid API key'); | |
| } | |
| if (!apiKey.isActive) { | |
| throw new UnauthorizedException('API key is revoked'); | |
| } | |
| if (apiKey.expiresAt && apiKey.expiresAt < new Date()) { | |
| throw new UnauthorizedException('API key has expired'); | |
| } | |
| // Check IP whitelist (fail closed: if a whitelist is configured but the client | |
| // IP could not be determined, reject rather than silently skipping the check) | |
| if (apiKey.allowedIps && apiKey.allowedIps.length > 0) { | |
| if (!clientIp) { | |
| throw new UnauthorizedException('Client IP could not be determined'); | |
| } | |
| if (!this.isIpAllowed(clientIp, apiKey.allowedIps)) { | |
| this.logger.warn(`IP not allowed: ${clientIp}`, { | |
| keyId: apiKey.id, | |
| action: 'ip_rejected', | |
| }); | |
| throw new UnauthorizedException('IP address not allowed'); | |
| } | |
| } | |
| // Check session restriction | |
| if (apiKey.allowedSessions && apiKey.allowedSessions.length > 0 && sessionId) { | |
| if (!apiKey.allowedSessions.includes(sessionId)) { | |
| throw new UnauthorizedException('API key not authorized for this session'); | |
| } | |
| } | |
| // Update usage stats — coalesced. Validation above is unchanged/synchronous; only | |
| // the stat WRITE is throttled to at most once per key per window. usageCount stays | |
| // accurate via an in-memory accumulator; the returned object reflects the true count. | |
| const pending = (this.pendingUsage.get(apiKey.id) ?? 0) + 1; | |
| const previousLastUsedAt = apiKey.lastUsedAt; | |
| apiKey.lastUsedAt = new Date(); | |
| apiKey.usageCount += pending; // DB value + all not-yet-persisted increments (incl. this request) | |
| const due = | |
| !previousLastUsedAt || | |
| apiKey.lastUsedAt.getTime() - previousLastUsedAt.getTime() >= AuthService.STAT_FLUSH_INTERVAL_MS; | |
| if (due) { | |
| this.pendingUsage.delete(apiKey.id); | |
| await this.apiKeyRepository.save(apiKey); | |
| } else { | |
| this.pendingUsage.set(apiKey.id, pending); | |
| } | |
| return apiKey; | |
| } | |
| private hashKey(rawKey: string): string { | |
| return hashApiKey(rawKey, process.env.API_KEY_PEPPER); | |
| } | |
| private isIpAllowed(clientIp: string, allowedIps: string[]): boolean { | |
| // Delegate to the shared, hardened matcher (also used by the throttler and the API-key guard's IP | |
| // resolution): it handles both an exact IP entry and CIDR notation, and — unlike the previous local | |
| // parser — rejects a malformed octet instead of coercing it into range. | |
| return allowedIps.some(entry => ipMatches(clientIp, entry)); | |
| } | |
| hasPermission(apiKey: ApiKey, requiredRole: ApiKeyRole): boolean { | |
| const roleHierarchy: Record<ApiKeyRole, number> = { | |
| [ApiKeyRole.VIEWER]: 1, | |
| [ApiKeyRole.OPERATOR]: 2, | |
| [ApiKeyRole.ADMIN]: 3, | |
| }; | |
| return roleHierarchy[apiKey.role] >= roleHierarchy[requiredRole]; | |
| } | |
| } | |