144 lines
4.7 KiB
Python
144 lines
4.7 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 datetime, timedelta, timezone
|
|
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(timezone.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=timezone.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(timezone.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(timezone.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)
|