From 5075a2440d6050182e76e8778a0a8b1c1fa5a4a8 Mon Sep 17 00:00:00 2001 From: raven Date: Thu, 29 Jan 2026 19:41:59 -0300 Subject: [PATCH] feat: Implement service-to-service authentication, centralize environment configuration, and harden Docker security. --- .env | 24 +++++++++++++ .env.example | 43 ++++++++++++++++++++++ alpr-service/main.py | 23 +++++++++++- backend/src/index.js | 65 ++++++++++++++++++++++++++++++---- backend/src/middleware/auth.js | 8 ++++- docker-compose.yml | 18 ++++++++-- 6 files changed, 169 insertions(+), 12 deletions(-) create mode 100644 .env create mode 100644 .env.example diff --git a/.env b/.env new file mode 100644 index 0000000..c47e2e0 --- /dev/null +++ b/.env @@ -0,0 +1,24 @@ +# =================================================================== +# ControlPatente - Environment Configuration +# =================================================================== +# Generated on: 2026-01-29 +# =================================================================== + +# --- Database Configuration --- +DB_USER=postgres +DB_PASSWORD=e0p0kcnMmG8kg2YylgQ0Mw +DB_NAME=controlpatente + +# --- Security Configuration --- + +# JWT Secret (auto-generated) +JWT_SECRET=d95810e29f700cb99d3ed6a891ace603522875069ec655887a01629891a38ce8 + +# Admin password (optional - if not set, a random password will be generated on first run) +# ADMIN_PASSWORD= + +# Allowed origins for CORS (comma-separated) +ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,https://demo.v1ru5.cl + +# Service-to-service API key (auto-generated) +SERVICE_API_KEY=a6b73ab722d2980cafb89836393266f96bf798209b6a4ce2 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..74a22ee --- /dev/null +++ b/.env.example @@ -0,0 +1,43 @@ +# =================================================================== +# ControlPatente - Environment Configuration +# =================================================================== +# Copy this file to .env and configure the values before starting +# +# IMPORTANT: Never commit .env to version control! +# =================================================================== + +# --- Database Configuration --- +DB_USER=postgres +DB_PASSWORD=CHANGE_THIS_PASSWORD +DB_NAME=controlpatente + +# --- Security Configuration (REQUIRED) --- + +# JWT Secret - REQUIRED for authentication +# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +JWT_SECRET=GENERATE_A_SECURE_64_CHARACTER_HEX_STRING_HERE + +# Admin password (optional - if not set, a random password will be generated) +# ADMIN_PASSWORD=your_secure_admin_password + +# Allowed origins for CORS (comma-separated) +# Default: http://localhost:5173 +ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 + +# Service-to-service API key (for ALPR -> Backend communication) +# Generate with: node -e "console.log(require('crypto').randomBytes(24).toString('hex'))" +SERVICE_API_KEY=GENERATE_A_SECURE_48_CHARACTER_HEX_STRING_HERE + +# --- Optional Configuration --- + +# Backend port (default: 3000) +# PORT=3000 + +# ALPR processing interval in seconds (default: 1.5) +# PROCESS_INTERVAL=1.5 + +# Dataset capture cooldown in seconds (default: 60) +# DATASET_COOLDOWN=60 + +# Number of OCR worker threads (default: 2) +# OCR_WORKERS=2 diff --git a/alpr-service/main.py b/alpr-service/main.py index 26b9edc..6728744 100644 --- a/alpr-service/main.py +++ b/alpr-service/main.py @@ -20,6 +20,7 @@ MODEL_PATH = os.environ.get('MODEL_PATH', 'best.pt') DATASET_DIR = os.environ.get('DATASET_DIR', '/app/dataset') DATASET_COOLDOWN = int(os.environ.get('DATASET_COOLDOWN', 60)) OCR_WORKERS = int(os.environ.get('OCR_WORKERS', 2)) # Número de workers OCR +SERVICE_API_KEY = os.environ.get('SERVICE_API_KEY', '') # For backend auth app = Flask(__name__) CORS(app) @@ -129,7 +130,10 @@ def send_plate(plate_number): """Envía la patente detectada al backend""" try: url = f"{BACKEND_URL}/api/detect" - requests.post(url, json={'plate_number': plate_number}, timeout=3) + headers = {} + if SERVICE_API_KEY: + headers['X-Service-Key'] = SERVICE_API_KEY + requests.post(url, json={'plate_number': plate_number}, headers=headers, timeout=3) print(f"✓ Plate sent: {plate_number}") with metrics_lock: @@ -348,7 +352,24 @@ def dataset_list(): def dataset_image(filename): return send_from_directory(DATASET_DIR, filename) +# SECURITY: Auth decorator for destructive operations +from functools import wraps +def require_auth(f): + @wraps(f) + def decorated(*args, **kwargs): + if not SERVICE_API_KEY: + # No key configured = dev mode, allow but warn + print("⚠️ SERVICE_API_KEY not set - dataset DELETE unprotected!") + return f(*args, **kwargs) + + provided_key = request.headers.get('X-Service-Key', '') + if provided_key != SERVICE_API_KEY: + return {"error": "Unauthorized"}, 401 + return f(*args, **kwargs) + return decorated + @app.route("/dataset/images/", methods=['DELETE']) +@require_auth def delete_dataset_image(filename): """Elimina una imagen del dataset""" try: diff --git a/backend/src/index.js b/backend/src/index.js index fe11f4f..2cfc4c8 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -8,7 +8,17 @@ const { Server } = require('socket.io'); const app = express(); const prisma = new PrismaClient(); -app.use(cors()); +// SECURITY: Configure CORS with specific origins +const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS + ? process.env.ALLOWED_ORIGINS.split(',') + : ['http://localhost:5173', 'http://127.0.0.1:5173']; + +app.use(cors({ + origin: ALLOWED_ORIGINS, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Service-Key'] +})); app.use(express.json()); // Rate limiting simple para /api/detect (G) @@ -44,8 +54,9 @@ setInterval(() => { const server = http.createServer(app); const io = new Server(server, { cors: { - origin: "*", - methods: ["GET", "POST"] + origin: ALLOWED_ORIGINS, + methods: ["GET", "POST"], + credentials: true } }); @@ -270,8 +281,26 @@ app.post('/api/people/bulk-approve', authenticateToken, isAdmin, async (req, res } }); -// Detection Endpoint (from Python) with Rate Limiting (G) -app.post('/api/detect', async (req, res) => { +// Detection Endpoint (from Python) with Rate Limiting and Service Auth +// SECURITY: Requires X-Service-Key header for service-to-service auth +const validateServiceKey = (req, res, next) => { + const serviceKey = process.env.SERVICE_API_KEY; + + // If no key configured, allow (development mode) but warn + if (!serviceKey) { + console.warn('⚠️ SERVICE_API_KEY not configured - /api/detect is unprotected!'); + return next(); + } + + const providedKey = req.headers['x-service-key']; + if (providedKey !== serviceKey) { + console.warn(`🔒 Rejected /api/detect request - invalid service key from ${req.ip}`); + return res.status(401).json({ error: 'Invalid service key' }); + } + next(); +}; + +app.post('/api/detect', validateServiceKey, async (req, res) => { const clientIp = req.ip || req.connection.remoteAddress; // Check rate limit @@ -368,7 +397,18 @@ server.listen(PORT, async () => { const userCount = await prisma.user.count(); if (userCount === 0) { console.log('No users found. Creating default admin user...'); - const hashedPassword = await bcrypt.hash('admin123', 10); + + // SECURITY: Use env var or generate random password + let adminPassword = process.env.ADMIN_PASSWORD; + let isGenerated = false; + + if (!adminPassword) { + // Generate a secure random password + adminPassword = require('crypto').randomBytes(12).toString('base64url'); + isGenerated = true; + } + + const hashedPassword = await bcrypt.hash(adminPassword, 10); await prisma.user.create({ data: { username: 'admin', @@ -376,7 +416,18 @@ server.listen(PORT, async () => { role: 'ADMIN' } }); - console.log('Default admin created: admin / admin123'); + + console.log('═'.repeat(50)); + console.log('🔐 ADMIN USER CREATED'); + console.log(' Username: admin'); + if (isGenerated) { + console.log(` Password: ${adminPassword}`); + console.log(' ⚠️ SAVE THIS PASSWORD - it won\'t be shown again!'); + console.log(' 💡 Set ADMIN_PASSWORD env var to use a custom password'); + } else { + console.log(' Password: [from ADMIN_PASSWORD env var]'); + } + console.log('═'.repeat(50)); } } catch (err) { console.error('Error seeding admin user:', err); diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 546f437..f5e9b72 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -1,6 +1,12 @@ const jwt = require('jsonwebtoken'); -const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this'; +// SECURITY: JWT_SECRET must be configured via environment variable +const JWT_SECRET = process.env.JWT_SECRET; +if (!JWT_SECRET) { + console.error('❌ FATAL: JWT_SECRET environment variable is required'); + console.error(' Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"'); + process.exit(1); +} const authenticateToken = (req, res, next) => { const authHeader = req.headers['authorization']; diff --git a/docker-compose.yml b/docker-compose.yml index a2a0d03..e8d3ca1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,8 +11,10 @@ services: POSTGRES_DB: ${DB_NAME:-controlpatente} volumes: - db_data:/var/lib/postgresql/data - ports: - - "5432:5432" + # SECURITY: Port not exposed externally - only accessible within Docker network + # Uncomment for local development debugging only + # ports: + # - "5432:5432" networks: - backend-net restart: unless-stopped @@ -34,6 +36,10 @@ services: environment: - DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@db:5432/${DB_NAME:-controlpatente} - PORT=3000 + - JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required} + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} + - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost:5173} + - SERVICE_API_KEY=${SERVICE_API_KEY:-} ports: - "3000:3000" depends_on: @@ -68,6 +74,7 @@ services: - PROCESS_INTERVAL=1.5 - DATASET_COOLDOWN=60 - OCR_WORKERS=2 + - SERVICE_API_KEY=${SERVICE_API_KEY:-} devices: - "/dev/video0:/dev/video0" networks: @@ -76,7 +83,12 @@ services: backend: condition: service_healthy restart: unless-stopped - privileged: true + # SECURITY: Use specific capabilities instead of privileged mode + # privileged: true # REMOVED - security risk + cap_add: + - SYS_RAWIO + security_opt: + - no-new-privileges:true volumes: - ./alpr-service/dataset:/app/dataset healthcheck: