`[tool.ruff]` set a line length and a target version and nothing else, so which rules ran depended on the installed ruff's defaults — fine while nobody ran it, not fine for a CI job that can turn a push red because a tool changed its mind. The set is named explicitly (E, F, I, UP, SIM, B, RUF) with three ignores that are each a real decision rather than a shrug: B008, because FastAPI's whole dependency idiom is a call in a default argument; E501, because wrapping prose is a formatter's business; and RUF001, because the reports and the bot speak Russian and "ambiguous character" is the wrong complaint about a string the user is meant to read. The 73 findings that remained are fixed — mostly `datetime.timezone.utc` → `datetime.UTC`, `typing.Callable` → `collections.abc.Callable`, import order, and unused `noqa` markers — plus seven by hand: `contextlib.suppress` for two try/except/pass pairs, unpacking instead of list concatenation, one negated return. `ruff format` is deliberately *not* adopted: it would reformat 51 files, and some of the layout here is hand-set (the comment tables, the DNS wire-format structs) in ways a formatter flattens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
144 lines
4.6 KiB
Python
144 lines
4.6 KiB
Python
"""Who is allowed into the browser UI.
|
|
|
|
Two ways in, both ending in the same thing — a row in `auth_sessions` and a
|
|
cookie that names it:
|
|
|
|
* a password, hashed with stdlib PBKDF2 (no new dependency for one check);
|
|
* the Telegram Login Widget, verified with the documented HMAC scheme against
|
|
the bot token already configured for notifications.
|
|
|
|
The desktop app and scripts are untouched — they keep using `X-API-Token`
|
|
(see `app.api.deps`). This module only ever runs for the browser.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import secrets
|
|
import time
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
|
|
from sqlalchemy import delete
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models import AuthSession
|
|
|
|
PBKDF2_ALGO = "pbkdf2_sha256"
|
|
PBKDF2_ITERATIONS = 260_000
|
|
SESSION_COOKIE = "kadence_session"
|
|
TELEGRAM_AUTH_MAX_AGE = 86400 # a login link older than a day is refused
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Passwords
|
|
# --------------------------------------------------------------------------
|
|
def hash_password(password: str) -> str:
|
|
salt = secrets.token_hex(16)
|
|
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), PBKDF2_ITERATIONS)
|
|
return f"{PBKDF2_ALGO}${PBKDF2_ITERATIONS}${salt}${digest.hex()}"
|
|
|
|
|
|
def verify_password(password: str, stored_hash: str) -> bool:
|
|
try:
|
|
algo, iterations, salt, expected = stored_hash.split("$")
|
|
if algo != PBKDF2_ALGO:
|
|
return False
|
|
digest = hashlib.pbkdf2_hmac(
|
|
"sha256", password.encode(), bytes.fromhex(salt), int(iterations)
|
|
)
|
|
return hmac.compare_digest(digest.hex(), expected)
|
|
except (ValueError, AttributeError):
|
|
return False
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Telegram Login Widget
|
|
# --------------------------------------------------------------------------
|
|
def verify_telegram_payload(data: dict[str, Any], bot_token: str) -> bool:
|
|
"""https://core.telegram.org/widgets/login#checking-authorization"""
|
|
received_hash = data.get("hash")
|
|
if not received_hash or not bot_token:
|
|
return False
|
|
auth_date = data.get("auth_date")
|
|
try:
|
|
if time.time() - int(auth_date) > TELEGRAM_AUTH_MAX_AGE:
|
|
return False
|
|
except (TypeError, ValueError):
|
|
return False
|
|
|
|
check_string = "\n".join(
|
|
f"{key}={data[key]}" for key in sorted(data) if key not in ("hash",)
|
|
)
|
|
secret_key = hashlib.sha256(bot_token.encode()).digest()
|
|
computed = hmac.new(secret_key, check_string.encode(), hashlib.sha256).hexdigest()
|
|
return hmac.compare_digest(computed, received_hash)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Sessions
|
|
# --------------------------------------------------------------------------
|
|
def create_session(session: Session, method: str, session_days: int) -> AuthSession:
|
|
_prune_expired(session)
|
|
row = AuthSession(
|
|
token=secrets.token_urlsafe(32),
|
|
method=method,
|
|
expires_at=datetime.now(UTC) + timedelta(days=session_days),
|
|
)
|
|
session.add(row)
|
|
session.commit()
|
|
return row
|
|
|
|
|
|
def _aware(dt: datetime) -> datetime:
|
|
"""SQLite drops the tzinfo it was stored with; Postgres does not."""
|
|
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
|
|
|
|
|
|
def valid_session(session: Session, token: str | None) -> AuthSession | None:
|
|
if not token:
|
|
return None
|
|
row = session.get(AuthSession, token)
|
|
if row is None:
|
|
return None
|
|
if _aware(row.expires_at) < datetime.now(UTC):
|
|
session.delete(row)
|
|
session.commit()
|
|
return None
|
|
return row
|
|
|
|
|
|
def delete_session(session: Session, token: str | None) -> None:
|
|
if not token:
|
|
return
|
|
session.execute(delete(AuthSession).where(AuthSession.token == token))
|
|
session.commit()
|
|
|
|
|
|
def _prune_expired(session: Session) -> None:
|
|
session.execute(delete(AuthSession).where(AuthSession.expires_at < datetime.now(UTC)))
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Login rate limiting (in-process; one instance, one user)
|
|
# --------------------------------------------------------------------------
|
|
_MAX_ATTEMPTS = 8
|
|
_WINDOW_SECONDS = 300
|
|
_failures: dict[str, list[float]] = {}
|
|
|
|
|
|
def rate_limited(key: str) -> bool:
|
|
now = time.monotonic()
|
|
recent = [t for t in _failures.get(key, []) if now - t < _WINDOW_SECONDS]
|
|
_failures[key] = recent
|
|
return len(recent) >= _MAX_ATTEMPTS
|
|
|
|
|
|
def record_failure(key: str) -> None:
|
|
_failures.setdefault(key, []).append(time.monotonic())
|
|
|
|
|
|
def record_success(key: str) -> None:
|
|
_failures.pop(key, None)
|