feat: Implement service-to-service authentication, centralize environment configuration, and harden Docker security.

This commit is contained in:
2026-01-29 19:41:59 -03:00
parent 5d85dc0714
commit 5075a2440d
6 changed files with 169 additions and 12 deletions

24
.env Normal file
View File

@@ -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

43
.env.example Normal file
View File

@@ -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

View File

@@ -20,6 +20,7 @@ MODEL_PATH = os.environ.get('MODEL_PATH', 'best.pt')
DATASET_DIR = os.environ.get('DATASET_DIR', '/app/dataset') DATASET_DIR = os.environ.get('DATASET_DIR', '/app/dataset')
DATASET_COOLDOWN = int(os.environ.get('DATASET_COOLDOWN', 60)) DATASET_COOLDOWN = int(os.environ.get('DATASET_COOLDOWN', 60))
OCR_WORKERS = int(os.environ.get('OCR_WORKERS', 2)) # Número de workers OCR 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__) app = Flask(__name__)
CORS(app) CORS(app)
@@ -129,7 +130,10 @@ def send_plate(plate_number):
"""Envía la patente detectada al backend""" """Envía la patente detectada al backend"""
try: try:
url = f"{BACKEND_URL}/api/detect" 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}") print(f"✓ Plate sent: {plate_number}")
with metrics_lock: with metrics_lock:
@@ -348,7 +352,24 @@ def dataset_list():
def dataset_image(filename): def dataset_image(filename):
return send_from_directory(DATASET_DIR, 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/<filename>", methods=['DELETE']) @app.route("/dataset/images/<filename>", methods=['DELETE'])
@require_auth
def delete_dataset_image(filename): def delete_dataset_image(filename):
"""Elimina una imagen del dataset""" """Elimina una imagen del dataset"""
try: try:

View File

@@ -8,7 +8,17 @@ const { Server } = require('socket.io');
const app = express(); const app = express();
const prisma = new PrismaClient(); 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()); app.use(express.json());
// Rate limiting simple para /api/detect (G) // Rate limiting simple para /api/detect (G)
@@ -44,8 +54,9 @@ setInterval(() => {
const server = http.createServer(app); const server = http.createServer(app);
const io = new Server(server, { const io = new Server(server, {
cors: { cors: {
origin: "*", origin: ALLOWED_ORIGINS,
methods: ["GET", "POST"] 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) // Detection Endpoint (from Python) with Rate Limiting and Service Auth
app.post('/api/detect', async (req, res) => { // 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; const clientIp = req.ip || req.connection.remoteAddress;
// Check rate limit // Check rate limit
@@ -368,7 +397,18 @@ server.listen(PORT, async () => {
const userCount = await prisma.user.count(); const userCount = await prisma.user.count();
if (userCount === 0) { if (userCount === 0) {
console.log('No users found. Creating default admin user...'); 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({ await prisma.user.create({
data: { data: {
username: 'admin', username: 'admin',
@@ -376,7 +416,18 @@ server.listen(PORT, async () => {
role: 'ADMIN' 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) { } catch (err) {
console.error('Error seeding admin user:', err); console.error('Error seeding admin user:', err);

View File

@@ -1,6 +1,12 @@
const jwt = require('jsonwebtoken'); 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 authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization']; const authHeader = req.headers['authorization'];

View File

@@ -11,8 +11,10 @@ services:
POSTGRES_DB: ${DB_NAME:-controlpatente} POSTGRES_DB: ${DB_NAME:-controlpatente}
volumes: volumes:
- db_data:/var/lib/postgresql/data - db_data:/var/lib/postgresql/data
ports: # SECURITY: Port not exposed externally - only accessible within Docker network
- "5432:5432" # Uncomment for local development debugging only
# ports:
# - "5432:5432"
networks: networks:
- backend-net - backend-net
restart: unless-stopped restart: unless-stopped
@@ -34,6 +36,10 @@ services:
environment: environment:
- DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@db:5432/${DB_NAME:-controlpatente} - DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@db:5432/${DB_NAME:-controlpatente}
- PORT=3000 - 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: ports:
- "3000:3000" - "3000:3000"
depends_on: depends_on:
@@ -68,6 +74,7 @@ services:
- PROCESS_INTERVAL=1.5 - PROCESS_INTERVAL=1.5
- DATASET_COOLDOWN=60 - DATASET_COOLDOWN=60
- OCR_WORKERS=2 - OCR_WORKERS=2
- SERVICE_API_KEY=${SERVICE_API_KEY:-}
devices: devices:
- "/dev/video0:/dev/video0" - "/dev/video0:/dev/video0"
networks: networks:
@@ -76,7 +83,12 @@ services:
backend: backend:
condition: service_healthy condition: service_healthy
restart: unless-stopped 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: volumes:
- ./alpr-service/dataset:/app/dataset - ./alpr-service/dataset:/app/dataset
healthcheck: healthcheck: