add auth
This commit is contained in:
22 files changed
+1034
-25
No files matched your search
@@ -0,0 +1,143 @@
|
||||
"""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)
|
||||
@@ -83,6 +83,18 @@ def settings_from_env() -> AppSettings:
|
||||
settings.notifications.slack.enabled = True
|
||||
settings.notifications.slack.webhook_url = env.slack_webhook_url
|
||||
|
||||
auth = settings.auth
|
||||
if env.auth_password:
|
||||
from app.services import auth_service
|
||||
|
||||
auth.username = env.auth_username or auth.username
|
||||
auth.password_hash = auth_service.hash_password(env.auth_password)
|
||||
if env.telegram_owner_id:
|
||||
auth.telegram_owner_id = env.telegram_owner_id
|
||||
auth.telegram_login_enabled = True
|
||||
if env.telegram_bot_username:
|
||||
auth.telegram_bot_username = env.telegram_bot_username
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user