- A26 — champ `standalone` mort supprimé (0 lecteur) ; `sqlite:////data/…` ne produit plus de chemin UNC sous Windows ; `.env.example` ne promet plus PostgreSQL ; raise au boot si `APP_SECRET_KEY` vaut la valeur par défaut - A33 — rate limit : préfixes manquants (`/scim/v2/`, `/workspace/`, `/db/` + non-GET sur `/s/` et `/f/`), limite lue dans `settings.rate_limit_requests` (60 annoncés / 100 codés en dur), clé `X-Forwarded-For` seulement derrière un proxy local (anti-spoof), `_store` épuré (mémoire bornée) + test dédié - A34 — `_spawn()` : les 10 schedulers loggent leur exception et redémarrent après 10 s au lieu de mourir en silence ; 2 `logger.debug` de scheduler → warning - A35 — OpenAPI régénéré 439 → 511 chemins (`info.version 7.3.9`), README (était v6.7.0), compteur API_GUIDE, titre dupliqué retiré du ROADMAP ; le drift Python 3.12/3.13 reste noté (rebuild d'image à valider) - A36 — 4 dépendances mortes purgées de requirements.txt (aiosqlite, slowapi, loguru, packaging = 0 import) ; pyproject reste sans [project] : Docker et la CI installent requirements.txt, dupliquer les 22 deps créerait 2 sources - A43 — 15 `datetime.utcnow()` → `now(UTC).replace(tzinfo=None)` (format ISO naïf identique, warnings de dépréciation divisés : 2374 → 926) suite **1029/1029** · `ruff check app tests` OK · docs/ROADMAP/CHANGELOG/WORKLOAD à jour
184 lines
6.4 KiB
Python
184 lines
6.4 KiB
Python
"""FlowDeck — Session management with signed cookies."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import UTC, datetime
|
|
from uuid import uuid4
|
|
|
|
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
|
|
|
from app.config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_serializer = URLSafeTimedSerializer(settings.app_secret_key)
|
|
|
|
|
|
class SessionManager:
|
|
"""Manages user sessions via signed cookies (v5.2.0: revocable).
|
|
|
|
Each cookie embeds a ``sid`` referencing a row in ``user_sessions``.
|
|
Revoking that row instantly invalidates the cookie (checked in
|
|
``decode_session``). Legacy cookies without a ``sid`` stay valid.
|
|
"""
|
|
|
|
@staticmethod
|
|
def create_session(user_data: dict, request=None) -> str:
|
|
"""Create a signed session cookie value.
|
|
|
|
``request`` is optional — when provided the session is recorded in the
|
|
``user_sessions`` table (ip + user agent) and becomes revocable.
|
|
"""
|
|
payload = {
|
|
"user": user_data,
|
|
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
|
}
|
|
user_id = user_data.get("id")
|
|
if user_id:
|
|
sid = str(uuid4())
|
|
payload["sid"] = sid
|
|
_record_session(sid, user_id, request)
|
|
return _serializer.dumps(payload)
|
|
|
|
@staticmethod
|
|
def decode_session(cookie: str) -> dict | None:
|
|
"""Decode and validate a session cookie. Returns user data or None."""
|
|
try:
|
|
payload = _serializer.loads(cookie, max_age=86400 * 7) # 7 days
|
|
except (BadSignature, SignatureExpired):
|
|
return None
|
|
|
|
sid = payload.get("sid") or ""
|
|
if sid and not _session_active(sid):
|
|
# Revoked or deleted session → treat as logged out.
|
|
return None
|
|
if sid:
|
|
_touch_session(sid)
|
|
return payload.get("user")
|
|
|
|
@staticmethod
|
|
def session_id(cookie: str) -> str | None:
|
|
"""Return the session id embedded in a cookie (or None)."""
|
|
try:
|
|
payload = _serializer.loads(cookie, max_age=86400 * 7)
|
|
return payload.get("sid")
|
|
except (BadSignature, SignatureExpired):
|
|
return None
|
|
|
|
@staticmethod
|
|
def list_sessions(user_id: int) -> list[dict]:
|
|
"""All recorded sessions for a user (for the Settings UI)."""
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT id, ip_address, user_agent, created_at, last_seen_at, revoked "
|
|
"FROM user_sessions WHERE user_id=? ORDER BY last_seen_at DESC",
|
|
(user_id,),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
@staticmethod
|
|
def revoke_session(sid: str) -> bool:
|
|
"""Revoke a session row. Returns True if a row was updated."""
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
cur = conn.execute(
|
|
"UPDATE user_sessions SET revoked=1 WHERE id=? AND revoked=0", (sid,)
|
|
)
|
|
conn.commit()
|
|
return cur.rowcount > 0
|
|
|
|
@staticmethod
|
|
def refresh_session(cookie: str, user_data: dict, request=None) -> str:
|
|
"""Re-sign a cookie keeping its session id (used after profile edits)."""
|
|
sid = SessionManager.session_id(cookie) if cookie else None
|
|
payload = {
|
|
"user": user_data,
|
|
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
|
}
|
|
user_id = user_data.get("id")
|
|
if user_id:
|
|
if sid is None:
|
|
sid = str(uuid4())
|
|
_record_session(sid, user_id, request)
|
|
payload["sid"] = sid
|
|
return _serializer.dumps(payload)
|
|
|
|
@staticmethod
|
|
def store_token(user_id: int, gitea_token: str) -> None:
|
|
"""Store a user's Gitea OAuth token in SQLite."""
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"""INSERT INTO user_tokens (gitea_user_id, gitea_token, updated_at)
|
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
ON CONFLICT(gitea_user_id)
|
|
DO UPDATE SET gitea_token=excluded.gitea_token, updated_at=CURRENT_TIMESTAMP""",
|
|
(user_id, gitea_token),
|
|
)
|
|
conn.commit()
|
|
|
|
@staticmethod
|
|
def get_token(user_id: int) -> str | None:
|
|
"""Get a user's stored Gitea token."""
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT gitea_token FROM user_tokens WHERE gitea_user_id=?",
|
|
(user_id,),
|
|
).fetchone()
|
|
return row["gitea_token"] if row else None
|
|
|
|
|
|
def _record_session(sid: str, user_id: int, request) -> None:
|
|
ip = ""
|
|
ua = ""
|
|
if request is not None:
|
|
ip = request.client.host if getattr(request, "client", None) else ""
|
|
ua = (request.headers.get("user-agent", "") or "")[:500]
|
|
try:
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"INSERT INTO user_sessions (id, user_id, ip_address, user_agent) VALUES (?, ?, ?, ?)",
|
|
(sid, user_id, ip, ua),
|
|
)
|
|
conn.commit()
|
|
except Exception as exc: # table may not exist in very old installs
|
|
logger.debug("session record skipped: %s", exc)
|
|
|
|
|
|
def _session_active(sid: str) -> bool:
|
|
try:
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT revoked FROM user_sessions WHERE id=?", (sid,)
|
|
).fetchone()
|
|
return bool(row and not row["revoked"])
|
|
except Exception:
|
|
# No table / DB unavailable → keep the cookie valid (fail-open-safe).
|
|
return True
|
|
|
|
|
|
def _touch_session(sid: str) -> None:
|
|
try:
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE user_sessions SET last_seen_at=CURRENT_TIMESTAMP WHERE id=? AND revoked=0",
|
|
(sid,),
|
|
)
|
|
conn.commit()
|
|
except Exception:
|
|
logger.exception("_touch_session")
|
|
|
|
|
|
# FastAPI dependency
|
|
async def get_current_user(request) -> dict | None:
|
|
"""FastAPI dependency: extract current user from session cookie."""
|
|
session = request.cookies.get("flowdeck_session")
|
|
if session:
|
|
return SessionManager.decode_session(session)
|
|
return None
|