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_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/<filename>", methods=['DELETE'])
|
||||
@require_auth
|
||||
def delete_dataset_image(filename):
|
||||
"""Elimina una imagen del dataset"""
|
||||
try:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user