- 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
184 lines
6.3 KiB
Python
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
|