Files
flowdeck/app/auth/session.py
T
bruno ffa1fa89ab
FlowDeck CI / lint (push) Successful in 1m51s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 10m12s
fix: A25 + A21 (partiel) — plus d'exception muque, transaction protégée (v7.3.8)
- A25 — 84 `except Exception: pass/…` → `logger.exception("<fonction>")`
  (19 fichiers : api_v2 30, dashboard 10, board 7, sites 5, workspace 5,
  api_v2_helpers 5, …) ; `logger` ajouté là où il manquait (api_v2_helpers,
  sites + `import logging`)
- A25 critique — les `try` autour de `materialize_properties` supprimés dans
  `create_collection_v2` ET `apply_db_template_v2` : un échec interrompt la
  transaction au lieu de commiter une collection sans schéma
- test `test_collection_rollback_when_materialize_fails` (Bearer v2, monkeypatch
  qui lève, assertions : RuntimeError + 0 ligne commitée)
- A21 partiel — `PRAGMA busy_timeout=5000` dans `get_conn()` (point d'entrée
  unique) ; commentaire `ponytail:` : le wrapper async + les 510 call sites
  restent à migrer module par module
- suite **1028/1028** · `ruff check app tests` OK
2026-10-01 08:16:42 -04:00

184 lines
6.3 KiB
Python

"""FlowDeck — Session management with signed cookies."""
from __future__ import annotations
import logging
from datetime import 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.utcnow().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.utcnow().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