feat: Implement service-to-service authentication, centralize environment configuration, and harden Docker security.
This commit is contained in:
24
.env
Normal file
24
.env
Normal 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
43
.env.example
Normal 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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user