fix(gateway): enforce API key auth on inventory and budget services (#5)
- Added X-API-Key middleware to inventory-service and budget-service - Services reject all requests without valid API key (401) - Gateway proxy injects service API keys for inventory and budget - Dashboard widget fetchers inject API keys - Generated unique API keys per service, stored in .env - Added SERVICE_API_KEY env var to docker-compose for both services Partial fix for #5 — internal services now require auth. Remaining: document trust model, validate service token semantics.
This commit is contained in:
@@ -34,6 +34,8 @@ services:
|
|||||||
- TRIPS_BACKEND_URL=http://trips-service:8087
|
- TRIPS_BACKEND_URL=http://trips-service:8087
|
||||||
- FITNESS_BACKEND_URL=http://fitness-service:8095
|
- FITNESS_BACKEND_URL=http://fitness-service:8095
|
||||||
- INVENTORY_BACKEND_URL=http://inventory-service:3000
|
- INVENTORY_BACKEND_URL=http://inventory-service:3000
|
||||||
|
- INVENTORY_SERVICE_API_KEY=${INVENTORY_SERVICE_API_KEY}
|
||||||
|
- BUDGET_SERVICE_API_KEY=${BUDGET_SERVICE_API_KEY}
|
||||||
- MINIFLUX_URL=${MINIFLUX_URL:-http://miniflux:8080}
|
- MINIFLUX_URL=${MINIFLUX_URL:-http://miniflux:8080}
|
||||||
- MINIFLUX_API_KEY=${MINIFLUX_API_KEY}
|
- MINIFLUX_API_KEY=${MINIFLUX_API_KEY}
|
||||||
- TRIPS_API_TOKEN=${TRIPS_API_TOKEN}
|
- TRIPS_API_TOKEN=${TRIPS_API_TOKEN}
|
||||||
@@ -114,6 +116,7 @@ services:
|
|||||||
- PUBLIC_APP_URL=${PLATFORM_ORIGIN}/inventory
|
- PUBLIC_APP_URL=${PLATFORM_ORIGIN}/inventory
|
||||||
- IMMICH_URL=${IMMICH_URL}
|
- IMMICH_URL=${IMMICH_URL}
|
||||||
- IMMICH_API_KEY=${IMMICH_API_KEY}
|
- IMMICH_API_KEY=${IMMICH_API_KEY}
|
||||||
|
- SERVICE_API_KEY=${INVENTORY_SERVICE_API_KEY}
|
||||||
- TZ=${TZ:-America/Chicago}
|
- TZ=${TZ:-America/Chicago}
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
@@ -130,6 +133,7 @@ services:
|
|||||||
- ACTUAL_SERVER_URL=http://actualbudget:5006
|
- ACTUAL_SERVER_URL=http://actualbudget:5006
|
||||||
- ACTUAL_PASSWORD=${ACTUAL_PASSWORD}
|
- ACTUAL_PASSWORD=${ACTUAL_PASSWORD}
|
||||||
- ACTUAL_SYNC_ID=${BUDGET_SYNC_ID}
|
- ACTUAL_SYNC_ID=${BUDGET_SYNC_ID}
|
||||||
|
- SERVICE_API_KEY=${BUDGET_SERVICE_API_KEY}
|
||||||
- TZ=${TZ:-America/Chicago}
|
- TZ=${TZ:-America/Chicago}
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ SHELFMARK_URL = os.environ.get("SHELFMARK_URL", "http://shelfmark:8084")
|
|||||||
SPOTIZERR_URL = os.environ.get("SPOTIZERR_URL", "http://spotizerr-app:7171")
|
SPOTIZERR_URL = os.environ.get("SPOTIZERR_URL", "http://spotizerr-app:7171")
|
||||||
BUDGET_URL = os.environ.get("BUDGET_BACKEND_URL", "http://localhost:3001")
|
BUDGET_URL = os.environ.get("BUDGET_BACKEND_URL", "http://localhost:3001")
|
||||||
|
|
||||||
|
# ── Service API keys (for internal service auth) ──
|
||||||
|
INVENTORY_SERVICE_API_KEY = os.environ.get("INVENTORY_SERVICE_API_KEY", "")
|
||||||
|
BUDGET_SERVICE_API_KEY = os.environ.get("BUDGET_SERVICE_API_KEY", "")
|
||||||
|
|
||||||
# ── Booklore (book library manager) ──
|
# ── Booklore (book library manager) ──
|
||||||
BOOKLORE_URL = os.environ.get("BOOKLORE_URL", "http://booklore:6060")
|
BOOKLORE_URL = os.environ.get("BOOKLORE_URL", "http://booklore:6060")
|
||||||
BOOKLORE_USER = os.environ.get("BOOKLORE_USER", "")
|
BOOKLORE_USER = os.environ.get("BOOKLORE_USER", "")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Platform Gateway — Dashboard, apps, user profile, connections, pinning handler
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from config import TRIPS_API_TOKEN, MINIFLUX_API_KEY
|
from config import TRIPS_API_TOKEN, MINIFLUX_API_KEY, INVENTORY_SERVICE_API_KEY, BUDGET_SERVICE_API_KEY
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from sessions import get_service_token, set_service_token, delete_service_token
|
from sessions import get_service_token, set_service_token, delete_service_token
|
||||||
import proxy as _proxy_module
|
import proxy as _proxy_module
|
||||||
@@ -212,12 +212,14 @@ def handle_dashboard(handler, user):
|
|||||||
|
|
||||||
def fetch_inventory(app):
|
def fetch_inventory(app):
|
||||||
target = app["proxy_target"]
|
target = app["proxy_target"]
|
||||||
s1, _, b1 = proxy_request(f"{target}/needs-review-count", "GET", {}, timeout=10)
|
hdrs = {"X-API-Key": INVENTORY_SERVICE_API_KEY} if INVENTORY_SERVICE_API_KEY else {}
|
||||||
|
s1, _, b1 = proxy_request(f"{target}/needs-review-count", "GET", hdrs, timeout=10)
|
||||||
return json.loads(b1) if s1 == 200 else None
|
return json.loads(b1) if s1 == 200 else None
|
||||||
|
|
||||||
def fetch_budget(app):
|
def fetch_budget(app):
|
||||||
try:
|
try:
|
||||||
s, _, b = proxy_request(f"{app['proxy_target']}/summary", "GET", {}, timeout=8)
|
hdrs = {"X-API-Key": BUDGET_SERVICE_API_KEY} if BUDGET_SERVICE_API_KEY else {}
|
||||||
|
s, _, b = proxy_request(f"{app['proxy_target']}/summary", "GET", hdrs, timeout=8)
|
||||||
if s == 200:
|
if s == 200:
|
||||||
data = json.loads(b)
|
data = json.loads(b)
|
||||||
return {"count": data.get("transactionCount", 0), "totalBalance": data.get("totalBalanceDollars", 0), "spending": data.get("spendingDollars", 0), "income": data.get("incomeDollars", 0), "topCategories": data.get("topCategories", [])[:5], "month": data.get("month", "")}
|
return {"count": data.get("transactionCount", 0), "totalBalance": data.get("totalBalanceDollars", 0), "spending": data.get("spendingDollars", 0), "income": data.get("incomeDollars", 0), "topCategories": data.get("topCategories", [])[:5], "month": data.get("month", "")}
|
||||||
|
|||||||
@@ -283,16 +283,21 @@ class GatewayHandler(ResponseMixin, BaseHTTPRequestHandler):
|
|||||||
self._send_json({"error": "Unknown service"}, 404)
|
self._send_json({"error": "Unknown service"}, 404)
|
||||||
return
|
return
|
||||||
|
|
||||||
from config import MINIFLUX_API_KEY
|
from config import MINIFLUX_API_KEY, INVENTORY_SERVICE_API_KEY, BUDGET_SERVICE_API_KEY
|
||||||
headers = {}
|
headers = {}
|
||||||
ct = self.headers.get("Content-Type")
|
ct = self.headers.get("Content-Type")
|
||||||
if ct:
|
if ct:
|
||||||
headers["Content-Type"] = ct
|
headers["Content-Type"] = ct
|
||||||
|
|
||||||
|
# Inject service-level auth
|
||||||
if service_id == "reader" and MINIFLUX_API_KEY:
|
if service_id == "reader" and MINIFLUX_API_KEY:
|
||||||
headers["X-Auth-Token"] = MINIFLUX_API_KEY
|
headers["X-Auth-Token"] = MINIFLUX_API_KEY
|
||||||
elif service_id == "trips" and TRIPS_API_TOKEN:
|
elif service_id == "trips" and TRIPS_API_TOKEN:
|
||||||
headers["Authorization"] = f"Bearer {TRIPS_API_TOKEN}"
|
headers["Authorization"] = f"Bearer {TRIPS_API_TOKEN}"
|
||||||
|
elif service_id == "inventory" and INVENTORY_SERVICE_API_KEY:
|
||||||
|
headers["X-API-Key"] = INVENTORY_SERVICE_API_KEY
|
||||||
|
elif service_id == "budget" and BUDGET_SERVICE_API_KEY:
|
||||||
|
headers["X-API-Key"] = BUDGET_SERVICE_API_KEY
|
||||||
elif user:
|
elif user:
|
||||||
svc_token = get_service_token(user["id"], service_id)
|
svc_token = get_service_token(user["id"], service_id)
|
||||||
if svc_token:
|
if svc_token:
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ const app = express();
|
|||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
// API key auth middleware — require X-API-Key header on all routes
|
||||||
|
const SERVICE_API_KEY = process.env.SERVICE_API_KEY || '';
|
||||||
|
if (SERVICE_API_KEY) {
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const key = req.headers['x-api-key'] || req.query.api_key;
|
||||||
|
if (key !== SERVICE_API_KEY) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized: invalid API key' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Configuration
|
// Configuration
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ app.use(express.json());
|
|||||||
// Allow form-encoded payloads from NocoDB webhook buttons
|
// Allow form-encoded payloads from NocoDB webhook buttons
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// API key auth middleware — require X-API-Key header on all routes
|
||||||
|
const SERVICE_API_KEY = process.env.SERVICE_API_KEY || '';
|
||||||
|
if (SERVICE_API_KEY) {
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const key = req.headers['x-api-key'] || req.query.api_key;
|
||||||
|
if (key !== SERVICE_API_KEY) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized: invalid API key' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage: multer.memoryStorage(),
|
||||||
limits: { fileSize: 10 * 1024 * 1024 }
|
limits: { fileSize: 10 * 1024 * 1024 }
|
||||||
|
|||||||
Reference in New Issue
Block a user