Fix security vulnerabilities. Add logging

This commit is contained in:
Ettore
2026-05-09 17:52:59 +02:00
parent d803e2d7f6
commit 69e4f594de
14 changed files with 226 additions and 72 deletions

View File

@@ -8,5 +8,15 @@ SECRET_KEY=replace-with-a-random-64-char-hex-string
# Port configuration # Port configuration
APP_PORT=8000 APP_PORT=8000
# CORS allowed origins (comma-separated)
CORS_ORIGINS=http://localhost:8000
# Trusted proxy IPs for correct client IP extraction (comma-separated)
TRUSTED_PROXY_IPS=127.0.0.1
# Database path (default: ./data/gates.db relative to project root) # Database path (default: ./data/gates.db relative to project root)
# DATABASE_URL=sqlite:///./data/gates.db # DATABASE_URL=sqlite:///./data/gates.db
# Logging configuration
LOG_LEVEL=INFO
#LOG_FILE=logs/app.log

View File

@@ -1,6 +1,11 @@
# Use Python 3.11 slim image # Use Python 3.11 slim image
FROM python:3.11-slim FROM python:3.11-slim
# Ensure Python output is sent straight to stdout/stderr (no buffering),
# which is required for logs to appear in `docker logs`.
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
@@ -17,8 +22,8 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/ COPY src/ ./src/
COPY data/ ./data/ COPY data/ ./data/
# Create data directory if it doesn't exist # Create data and log directories if they don't exist
RUN mkdir -p data RUN mkdir -p data logs
# Expose port # Expose port
EXPOSE $APP_PORT EXPOSE $APP_PORT

View File

@@ -109,15 +109,46 @@ data/
## Configuration ## Configuration
All settings are read from environment variables. All settings are read from environment variables (centralised in `src/core/config.py`).
### Security
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `SECRET_KEY` | Random 32 bytes | JWT signing key and Fernet encryption key. **Set this explicitly in production.** | | `SECRET_KEY` | *(required)* | JWT signing key and Fernet encryption key. The application will refuse to start if this is not set. Use a long random string (`openssl rand -hex 32`). |
| `ADMIN_USERNAME` | `admin` | Username for the initial admin account |
| `ADMIN_PASSWORD` | *(none)* | Password for the initial admin account. If unset, no seed account is created. | ### Admin seed account
| `APP_PORT` | `8000` | HTTP port the server listens on |
| `DATABASE_URL` | `sqlite:///data/gates.db` | SQLAlchemy database URL | | Variable | Default | Description |
|---|---|---|
| `ADMIN_USERNAME` | `admin` | Username for the initial admin account created on first run. |
| `ADMIN_PASSWORD` | *(none)* | Password for the initial admin account. If unset, no seed account is created. Minimum 12 characters. |
### Server
| Variable | Default | Description |
|---|---|---|
| `APP_PORT` | `8000` | HTTP port the server listens on. |
### Database
| Variable | Default | Description |
|---|---|---|
| `DATABASE_URL` | `sqlite:///data/gates.db` | SQLAlchemy database URL. |
### Network / reverse proxy
| Variable | Default | Description |
|---|---|---|
| `CORS_ORIGINS` | *(empty — no cross-origin requests)* | Comma-separated list of allowed CORS origins, e.g. `https://gates.example.com`. |
| `TRUSTED_PROXY_IPS` | `127.0.0.1` | Comma-separated list of reverse-proxy IPs whose `X-Forwarded-For` header is trusted for client IP resolution. |
### Logging
| Variable | Default | Description |
|---|---|---|
| `LOG_LEVEL` | `INFO` | Logging verbosity. One of `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`. |
| `LOG_FILE` | `/var/log/lagomaregates.log` | Path to the rotating log file (10 MB, 5 backups). Set to an empty string to disable file logging. |
## Running with Docker Compose ## Running with Docker Compose
@@ -126,7 +157,7 @@ All settings are read from environment variables.
docker compose up -d docker compose up -d
``` ```
The default `docker-compose.yml` starts the service on port `8000` with the initial admin credentials `admin` / `changeme`. Change `ADMIN_PASSWORD` and set a strong `SECRET_KEY` before deploying. The default `docker-compose.yml` starts the service on port `8000`. Set a strong `SECRET_KEY` and, optionally, `ADMIN_USERNAME` / `ADMIN_PASSWORD` before deploying.
The `./data` directory is mounted into the container so the SQLite database persists across restarts. The `./data` directory is mounted into the container so the SQLite database persists across restarts.
@@ -137,11 +168,11 @@ python -m venv venv
source venv/bin/activate source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
export SECRET_KEY="change-me" export SECRET_KEY="$(openssl rand -hex 32)"
export ADMIN_USERNAME="admin" export ADMIN_USERNAME="admin"
export ADMIN_PASSWORD="changeme" export ADMIN_PASSWORD="changeme-at-least-12"
uvicorn src.main:app --reload --port 8000 uvicorn src.main:app --port 8000
``` ```
The application is then available at: The application is then available at:

View File

@@ -7,9 +7,14 @@ services:
- "8000:8000" - "8000:8000"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./logs:/app/logs
environment: environment:
- ADMIN_USERNAME=admin - ADMIN_USERNAME=admin
- ADMIN_PASSWORD=changeme - ADMIN_PASSWORD=changeme
- SECRET_KEY=supersecretkey - SECRET_KEY=supersecretkey
- APP_PORT=8000 - APP_PORT=8000
- CORS_ORIGINS=http://localhost:8000
- TRUSTED_PROXY_IPS=127.0.0.1
- LOG_LEVEL=INFO
- LOG_FILE=/app/logs/app.log
restart: unless-stopped restart: unless-stopped

View File

@@ -1,19 +1,16 @@
import base64 import base64
import hashlib import hashlib
import os
import secrets
from typing import Optional from typing import Optional
import bcrypt import bcrypt
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from jose import JWTError, jwt from jose import JWTError, jwt
from core.config import SECRET_KEY
# ── Password hashing ────────────────────────────────────────────────────────── # ── Password hashing ──────────────────────────────────────────────────────────
ALGORITHM = "HS256" ALGORITHM = "HS256"
# Loaded once at import time; changing SECRET_KEY invalidates all existing tokens.
SECRET_KEY: str = os.environ.get("SECRET_KEY") or secrets.token_hex(32)
def hash_password(plain: str) -> str: def hash_password(plain: str) -> str:
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode() return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
@@ -40,7 +37,7 @@ def decode_token(token: str) -> Optional[dict]:
# ── Symmetric encryption for AVConnect passwords ───────────────────────────── # ── Symmetric encryption for AVConnect passwords ─────────────────────────────
# Derive a stable 32-byte Fernet key from SECRET_KEY so only one env var is needed. # Derive a stable 32-byte Fernet key from SECRET_KEY so only one env var is needed.
_raw = os.environ.get("SECRET_KEY") or SECRET_KEY _raw = SECRET_KEY
_fernet_key = base64.urlsafe_b64encode(hashlib.sha256(_raw.encode()).digest()) _fernet_key = base64.urlsafe_b64encode(hashlib.sha256(_raw.encode()).digest())
_fernet = Fernet(_fernet_key) _fernet = Fernet(_fernet_key)

View File

@@ -1 +1,45 @@
import logging
import os import os
from typing import Optional
# ── Paths ─────────────────────────────────────────────────────────────────────
_HERE = os.path.dirname(os.path.abspath(__file__)) # src/core/
_SRC_DIR = os.path.dirname(_HERE) # src/
_PROJECT_ROOT = os.path.dirname(_SRC_DIR) # project root
DATA_DIR: str = os.path.join(_PROJECT_ROOT, "data")
# ── Logging ───────────────────────────────────────────────────────────────────
# LOG_LEVEL: one of DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO)
LOG_LEVEL: int = getattr(logging, os.environ.get("LOG_LEVEL", "INFO").upper(), logging.INFO)
# LOG_FILE: set to empty string to disable file logging
LOG_FILE: str = os.environ.get("LOG_FILE", "")
# ── Security ──────────────────────────────────────────────────────────────────
SECRET_KEY: str = os.environ.get("SECRET_KEY") or ""
if not SECRET_KEY:
raise RuntimeError("SECRET_KEY environment variable must be set")
# ── Database ──────────────────────────────────────────────────────────────────
DATABASE_URL: str = os.environ.get(
"DATABASE_URL",
f"sqlite:///{os.path.join(DATA_DIR, 'gates.db')}",
)
# ── CORS ──────────────────────────────────────────────────────────────────────
# Comma-separated list of allowed origins, e.g. "https://example.com,https://app.example.com"
# Default to empty list (no cross-origin requests allowed) when not set.
_cors_env = os.environ.get("CORS_ORIGINS", "")
CORS_ORIGINS: list[str] = [o.strip() for o in _cors_env.split(",") if o.strip()]
# ── Proxy ─────────────────────────────────────────────────────────────────────
# Comma-separated list of trusted reverse-proxy IPs for X-Forwarded-For propagation.
# e.g. "127.0.0.1,10.0.0.1"
_proxy_env = os.environ.get("TRUSTED_PROXY_IPS", "127.0.0.1")
TRUSTED_PROXY_IPS: list[str] = [ip.strip() for ip in _proxy_env.split(",") if ip.strip()]
# ── Admin seed ────────────────────────────────────────────────────────────────
ADMIN_USERNAME: str = os.environ.get("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD: Optional[str] = os.environ.get("ADMIN_PASSWORD") or None
# ── Server ────────────────────────────────────────────────────────────────────
APP_PORT: int = int(os.environ.get("APP_PORT", 8000))

View File

@@ -5,15 +5,7 @@ from typing import Optional
from sqlalchemy import Boolean, String, Text, create_engine from sqlalchemy import Boolean, String, Text, create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
_HERE = os.path.dirname(os.path.abspath(__file__)) from core.config import DATA_DIR, DATABASE_URL
_SRC_DIR = os.path.dirname(_HERE)
_PROJECT_ROOT = os.path.dirname(_SRC_DIR)
_DATA_DIR = os.path.join(_PROJECT_ROOT, "data")
DATABASE_URL = os.environ.get(
"DATABASE_URL",
f"sqlite:///{os.path.join(_DATA_DIR, 'gates.db')}",
)
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@@ -90,5 +82,5 @@ def get_db():
def init_db() -> None: def init_db() -> None:
os.makedirs(_DATA_DIR, exist_ok=True) os.makedirs(DATA_DIR, exist_ok=True)
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)

View File

@@ -1,4 +1,4 @@
from datetime import datetime from datetime import datetime, timezone
from typing import Optional from typing import Optional
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
@@ -45,6 +45,6 @@ def require_keypass(
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass not found") raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass not found")
if kp.revoked: if kp.revoked:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has been revoked") raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has been revoked")
if kp.expires_at is not None and kp.expires_at < datetime.utcnow(): if kp.expires_at is not None and kp.expires_at < datetime.now(timezone.utc):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has expired") raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has expired")
return kp return kp

View File

@@ -1,15 +1,51 @@
import logging
import logging.handlers
import os import os
import sys import sys
from contextlib import asynccontextmanager
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
# Ensure src/ root is importable for models/services/routers # Ensure src/ root is importable for models/services/routers
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from core.config import ADMIN_PASSWORD, ADMIN_USERNAME, APP_PORT, CORS_ORIGINS, LOG_FILE, LOG_LEVEL, TRUSTED_PROXY_IPS
# ── Logging ───────────────────────────────────────────────────────────────────
_log_fmt = logging.Formatter(
fmt="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
)
logging.basicConfig(
level=LOG_LEVEL,
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
)
if LOG_FILE:
try:
_file_handler = logging.handlers.RotatingFileHandler(
LOG_FILE,
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=5,
encoding="utf-8",
)
_file_handler.setFormatter(_log_fmt)
logging.getLogger().addHandler(_file_handler)
except OSError as _e:
logging.getLogger(__name__).warning(
"Cannot open log file %r — file logging disabled: %s", LOG_FILE, _e
)
# Quieten noisy third-party loggers
logging.getLogger("uvicorn.access").setLevel(max(LOG_LEVEL, logging.INFO))
logging.getLogger("sqlalchemy.engine").setLevel(max(LOG_LEVEL, logging.WARNING))
logger = logging.getLogger(__name__)
from core.auth import hash_password from core.auth import hash_password
from core.database import AdminUser, SessionLocal, init_db from core.database import AdminUser, SessionLocal, init_db
from routers.auth import router as auth_router from routers.auth import router as auth_router
@@ -20,18 +56,41 @@ from routers.admins import router as admins_router
from routers.stats import router as stats_router from routers.stats import router as stats_router
# ── App ─────────────────────────────────────────────────────────────────────── # ── App ───────────────────────────────────────────────────────────────────────
app = FastAPI(title="Lagomare Gates", docs_url=None, redoc_url=None) @asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
_seed_admin()
yield
app = FastAPI(title="Lagomare Gates", docs_url=None, redoc_url=None, lifespan=lifespan)
_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static") _STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static") app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
# Trust X-Forwarded-For only from configured proxy IPs so request.client.host
# is already the real client address everywhere else in the code.
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=TRUSTED_PROXY_IPS)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=CORS_ORIGINS,
allow_methods=["*"], allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["*"], allow_headers=["Authorization", "Content-Type"],
) )
# Inject security headers on every response
@app.middleware("http")
async def _security_headers(request: Request, call_next) -> Response:
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
)
return response
# ── Routers (Controllers) ───────────────────────────────────────────────────── # ── Routers (Controllers) ─────────────────────────────────────────────────────
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(keypasses_router) app.include_router(keypasses_router)
@@ -72,28 +131,23 @@ async def _serve_manifest() -> FileResponse:
) )
# ── Startup ───────────────────────────────────────────────────────────────────
@app.on_event("startup")
async def _startup() -> None:
init_db()
_seed_admin()
def _seed_admin() -> None: def _seed_admin() -> None:
"""Create the initial admin user from env vars if it doesn't exist yet.""" if not ADMIN_PASSWORD:
username = os.environ.get("ADMIN_USERNAME", "admin")
password = os.environ.get("ADMIN_PASSWORD")
if not password:
return return
db = SessionLocal() db = SessionLocal()
try: try:
if not db.query(AdminUser).filter_by(username=username).first(): if not db.query(AdminUser).filter_by(username=ADMIN_USERNAME).first():
db.add(AdminUser(username=username, password_hash=hash_password(password))) db.add(AdminUser(username=ADMIN_USERNAME, password_hash=hash_password(ADMIN_PASSWORD)))
db.commit() db.commit()
finally: finally:
db.close() db.close()
if __name__ == "__main__": if __name__ == "__main__":
port = int(os.environ.get("APP_PORT", 8000)) uvicorn.run(
uvicorn.run("main:app", host="0.0.0.0", port=port, reload=True) "main:app",
host="0.0.0.0",
port=APP_PORT,
reload=False,
log_level=logging.getLevelName(LOG_LEVEL).lower(),
)

View File

@@ -29,6 +29,8 @@ async def create_admin(
raise HTTPException(422, "Username cannot be empty") raise HTTPException(422, "Username cannot be empty")
if req.role not in ("admin", "manager"): if req.role not in ("admin", "manager"):
raise HTTPException(422, "role must be 'admin' or 'manager'") raise HTTPException(422, "role must be 'admin' or 'manager'")
if len(req.password) < 12:
raise HTTPException(422, "Password must be at least 12 characters")
if db.query(AdminUser).filter_by(username=username).first(): if db.query(AdminUser).filter_by(username=username).first():
raise HTTPException(409, "Username already exists") raise HTTPException(409, "Username already exists")
user = AdminUser(username=username, password_hash=hash_password(req.password), role=req.role) user = AdminUser(username=username, password_hash=hash_password(req.password), role=req.role)
@@ -64,6 +66,8 @@ async def change_password(
): ):
if not req.new_password: if not req.new_password:
raise HTTPException(422, "Password cannot be empty") raise HTTPException(422, "Password cannot be empty")
if len(req.new_password) < 12:
raise HTTPException(422, "Password must be at least 12 characters")
user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=username).first() user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=username).first()
if not user: if not user:
raise HTTPException(404, "Admin not found") raise HTTPException(404, "Admin not found")

View File

@@ -1,4 +1,5 @@
from datetime import datetime, timedelta import logging
from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
@@ -9,18 +10,21 @@ from core.database import AdminUser, Keypass, get_db
from core.schemas import AdminLoginRequest, KeypassLoginRequest, TokenResponse from core.schemas import AdminLoginRequest, KeypassLoginRequest, TokenResponse
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
logger = logging.getLogger(__name__)
@router.post("/admin", response_model=TokenResponse) @router.post("/admin", response_model=TokenResponse)
async def admin_login(req: AdminLoginRequest, db: Session = Depends(get_db)): async def admin_login(req: AdminLoginRequest, db: Session = Depends(get_db)):
user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=req.username).first() user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=req.username).first()
if not user or not verify_password(req.password, user.password_hash): if not user or not verify_password(req.password, user.password_hash):
logger.warning("Failed admin login attempt for username=%r", req.username)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid credentials") raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid credentials")
logger.info("Admin login: username=%r role=%r", user.username, user.role)
token = create_token({ token = create_token({
"sub": user.username, "sub": user.username,
"role": "admin", "role": "admin",
"scope": user.role, # 'admin' | 'manager' "scope": user.role, # 'admin' | 'manager'
"exp": datetime.utcnow() + timedelta(hours=24), "exp": datetime.now(timezone.utc) + timedelta(hours=24),
}) })
return TokenResponse(token=token) return TokenResponse(token=token)
@@ -34,9 +38,9 @@ async def keypass_login(req: KeypassLoginRequest, db: Session = Depends(get_db))
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid keypass") raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid keypass")
if kp.revoked: if kp.revoked:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has been revoked") raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has been revoked")
if kp.expires_at is not None and kp.expires_at < datetime.utcnow(): if kp.expires_at is not None and kp.expires_at < datetime.now(timezone.utc):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has expired") raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has expired")
exp = kp.expires_at if kp.expires_at else datetime(2099, 12, 31, 23, 59, 59) exp = kp.expires_at if kp.expires_at else datetime(2099, 12, 31, 23, 59, 59, tzinfo=timezone.utc)
token = create_token({ token = create_token({
"sub": str(kp.id), "sub": str(kp.id),
"role": "keypass", "role": "keypass",

View File

@@ -1,5 +1,6 @@
import json import json
from datetime import datetime import logging
from datetime import datetime, timezone
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
@@ -12,6 +13,7 @@ from core.schemas import GateCreate, GatePublicResponse, GateResponse
from services.gates import call_open_gate from services.gates import call_open_gate
router = APIRouter(tags=["gates"]) router = APIRouter(tags=["gates"])
logger = logging.getLogger(__name__)
# ── Admin: gate CRUD ────────────────────────────────────────────────────────── # ── Admin: gate CRUD ──────────────────────────────────────────────────────────
@@ -85,7 +87,7 @@ async def admin_open_gate(
if not cred_db: if not cred_db:
raise HTTPException(503, "AVConnect credentials not configured") raise HTTPException(503, "AVConnect credentials not configured")
ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else None) ip = request.client.host if request.client else None
ua = request.headers.get("User-Agent") ua = request.headers.get("User-Agent")
mock = bool(cred_db.mock_avconnect) mock = bool(cred_db.mock_avconnect)
@@ -98,7 +100,7 @@ async def admin_open_gate(
) )
db.add(GateAccessLog( db.add(GateAccessLog(
timestamp=datetime.utcnow(), timestamp=datetime.now(timezone.utc),
keypass_id=0, keypass_id=0,
keypass_code=f"[{caller['sub']}]", keypass_code=f"[{caller['sub']}]",
gate_id=gate_db.id, gate_id=gate_db.id,
@@ -114,8 +116,10 @@ async def admin_open_gate(
db.commit() db.commit()
if not success: if not success:
logger.error("Gate open failed: gate_id=%d caller=%r error=%r", gate_db.id, caller["sub"], error_msg)
raise HTTPException(502, error_msg or "Gate operation failed") raise HTTPException(502, error_msg or "Gate operation failed")
logger.info("Gate opened by admin: gate_id=%d gate=%r caller=%r ip=%r", gate_db.id, gate_db.name, caller["sub"], ip)
return {"success": True, "gate": gate_db.name} return {"success": True, "gate": gate_db.name}
@@ -149,7 +153,7 @@ async def open_gate(
if not cred_db: if not cred_db:
raise HTTPException(503, "AVConnect credentials not configured") raise HTTPException(503, "AVConnect credentials not configured")
ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else None) ip = request.client.host if request.client else None
ua = request.headers.get("User-Agent") ua = request.headers.get("User-Agent")
allowed = json.loads(_kp.allowed_gates) if _kp.allowed_gates else None allowed = json.loads(_kp.allowed_gates) if _kp.allowed_gates else None
@@ -166,7 +170,7 @@ async def open_gate(
) )
db.add(GateAccessLog( db.add(GateAccessLog(
timestamp=datetime.utcnow(), timestamp=datetime.now(timezone.utc),
keypass_id=_kp.id, keypass_id=_kp.id,
keypass_code=_kp.code, keypass_code=_kp.code,
gate_id=gate_db.id, gate_id=gate_db.id,
@@ -177,11 +181,9 @@ async def open_gate(
error=error_msg, error=error_msg,
)) ))
if new_sid and new_sid != cred_db.session_id:
cred_db.session_id = new_sid
db.commit()
if not success: if not success:
logger.error("Gate open failed: gate_id=%d keypass_id=%d error=%r", gate_db.id, _kp.id, error_msg)
raise HTTPException(502, error_msg or "Gate operation failed") raise HTTPException(502, error_msg or "Gate operation failed")
logger.info("Gate opened by keypass: gate_id=%d gate=%r keypass_id=%d ip=%r", gate_db.id, gate_db.name, _kp.id, ip)
return {"success": True, "gate": gate_db.name} return {"success": True, "gate": gate_db.name}

View File

@@ -1,7 +1,7 @@
import json import json
import secrets import secrets
import string import string
from datetime import datetime from datetime import datetime, timezone
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
@@ -38,7 +38,7 @@ async def create_keypass(
kp = Keypass( kp = Keypass(
code=code, code=code,
description=req.description, description=req.description,
created_at=datetime.utcnow(), created_at=datetime.now(timezone.utc),
expires_at=req.expires_at, expires_at=req.expires_at,
revoked=False, revoked=False,
allowed_gates=json.dumps(req.gate_ids) if req.gate_ids else None, allowed_gates=json.dumps(req.gate_ids) if req.gate_ids else None,
@@ -80,10 +80,10 @@ async def revoke_keypass(
kp: Optional[Keypass] = db.query(Keypass).filter(Keypass.id == kp_id).first() kp: Optional[Keypass] = db.query(Keypass).filter(Keypass.id == kp_id).first()
if not kp: if not kp:
raise HTTPException(404, "Keypass not found") raise HTTPException(404, "Keypass not found")
if kp.expires_at is not None and kp.expires_at < datetime.utcnow(): if kp.expires_at is not None and kp.expires_at < datetime.now(timezone.utc):
raise HTTPException(409, "Expired keypasses cannot be revoked") raise HTTPException(409, "Expired keypasses cannot be revoked")
if kp.revoked: if kp.revoked:
raise HTTPException(409, "Keypass is already revoked") raise HTTPException(409, "Keypass is already revoked")
kp.revoked = True kp.revoked = True
kp.revoked_at = datetime.utcnow() kp.revoked_at = datetime.now(timezone.utc)
db.commit() db.commit()

View File

@@ -1,6 +1,12 @@
import logging
import urllib.parse
import requests import requests
from fake_useragent import UserAgent from fake_useragent import UserAgent
logger = logging.getLogger(__name__)
class AVConnectAPI: class AVConnectAPI:
_BASE_URL = "https://www.avconnect.it" _BASE_URL = "https://www.avconnect.it"
@@ -21,11 +27,11 @@ class AVConnectAPI:
"User-Agent": self._ua, "User-Agent": self._ua,
"Content-Type": "application/x-www-form-urlencoded" "Content-Type": "application/x-www-form-urlencoded"
} }
payload = f"userid={self._username}&password={self._password}&entra=Login" payload = urllib.parse.urlencode({"userid": self._username, "password": self._password, "entra": "Login"})
response = self._session.post(login_url, data=payload, headers=headers) response = self._session.post(login_url, data=payload, headers=headers)
if response.ok and "PHPSESSID" in self._session.cookies: if response.ok and "PHPSESSID" in self._session.cookies:
self._authenticated = True self._authenticated = True
print("Authenticated") logger.debug("AVConnect authentication successful")
return True return True
return False return False
@@ -37,7 +43,7 @@ class AVConnectAPI:
"User-Agent": self._ua, "User-Agent": self._ua,
} }
response = self._session.get(exec_url, headers=headers) response = self._session.get(exec_url, headers=headers)
print(response.ok) logger.debug("AVConnect session check: %s", response.ok)
return response.ok return response.ok
def exec_gate_macro(self, id_macro) -> bool: def exec_gate_macro(self, id_macro) -> bool:
@@ -48,6 +54,6 @@ class AVConnectAPI:
"User-Agent": self._ua, "User-Agent": self._ua,
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"
} }
payload = f"idmacrocom={id_macro}&nome=16" payload = urllib.parse.urlencode({"idmacrocom": id_macro, "nome": "16"})
response = self._session.post(exec_url, data=payload, headers=headers) response = self._session.post(exec_url, data=payload, headers=headers)
return response.ok return response.ok