Files
kadence/backend/app/services/auth_service.py
T
timka123andClaude Opus 5 ec7eedbc66 style: say which lint rules this repo keeps, then keep them
`[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>
2026-10-22 17:28:57 +03:00

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)