Files
mqtt-ingester/webui/auth.py
T
2026-08-30 11:30:09 +02:00

174 lines
6.0 KiB
Python

"""Authentication for the web UI: password hashing, sessions, throttling.
- Passwords are hashed with argon2id (passlib).
- Session cookies carry a random token; only its SHA-256 is stored.
- Sessions are persisted in Postgres, so a login in the web UI is also
valid against the API (shared/access.py resolves the same table).
- Login is throttled per source IP (5 attempts per 15 minutes, then a
5-minute lockout) to blunt credential stuffing against the exposed
login form.
- On startup, an admin account is created only if the users table is
empty: username/password from ADMIN_USERNAME/ADMIN_PASSWORD if set,
otherwise the documented default admin/admin. The default account is
flagged must_change_password so the UI forces a change on first login.
"""
import hmac
import os
import secrets
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
import psycopg
from passlib.context import CryptContext
from shared.access import COOKIE_NAME, hash_token
# Fixed session lifetime; a sliding window would need a write per request,
# so sessions simply expire after this span (users log in again).
SESSION_TTL = timedelta(hours=12)
LOCKOUT_ATTEMPTS = 5
LOCKOUT_WINDOW = timedelta(minutes=15)
LOCKOUT_DURATION = timedelta(minutes=5)
_pwd: Optional[CryptContext] = None
def _crypt() -> CryptContext:
"""Lazy argon2 init: passlib's runtime self-check is slow (~1s)."""
global _pwd
if _pwd is None:
_pwd = CryptContext(schemes=["argon2"])
return _pwd
def hash_password(password: str) -> str:
return _crypt().hash(password)
def verify_password(password: str, password_hash: str) -> bool:
try:
return _crypt().verify(password, password_hash)
except Exception: # noqa: BLE001 - malformed hash must not 500 the login
return False
def _interval(td: timedelta) -> str:
return f"{int(td.total_seconds())} seconds"
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def create_session(conn: psycopg.Connection[Any], user_id: int) -> tuple[str, str]:
"""Create a session; returns (cookie_token, csrf_token)."""
cookie_token = secrets.token_urlsafe(32)
csrf_token = secrets.token_urlsafe(32)
with conn.cursor() as cur:
cur.execute(
"INSERT INTO sessions (token_hash, user_id, csrf_token, expires_at)"
" VALUES (%s, %s, %s, now() + %s::interval)",
(hash_token(cookie_token), user_id, csrf_token, _interval(SESSION_TTL)),
)
return cookie_token, csrf_token
def delete_session(conn: psycopg.Connection[Any], token: str) -> None:
with conn.cursor() as cur:
cur.execute("DELETE FROM sessions WHERE token_hash = %s", (hash_token(token),))
def bootstrap_admin(conn: psycopg.Connection[Any]) -> None:
"""Create the initial admin from ENV or default admin/admin.
Runs only when the users table is completely empty — restarting the
stack must never reset or resurrect an administrator.
"""
with conn.cursor() as cur:
count = cur.execute("SELECT count(*) FROM users").fetchone()[0]
if count and count > 0:
return
username = os.getenv("ADMIN_USERNAME", "admin")
password = os.getenv("ADMIN_PASSWORD", "admin")
from_env = bool(os.getenv("ADMIN_PASSWORD"))
# A default (non-ENV) password must always be changed at first
# login; an ENV-provided one is treated as provisioned.
must_change = not from_env or username == "admin"
cur.execute(
"INSERT INTO users (username, password_hash, role, must_change_password)"
" VALUES (%s, %s, 'admin', %s)",
(username, hash_password(password), must_change),
)
print(
f"Bootstrapped initial admin '{username}'"
+ (" (default credentials; change on first login)" if must_change else "")
)
def _attempt_key(ip: str) -> str:
# login_attempts.ip is TEXT PRIMARY KEY; keep long IPv6 strings sane.
return ip[:64] if ip else "unknown"
def check_throttle(conn: psycopg.Connection[Any], ip: str) -> tuple[bool, int]:
"""Return (allowed, attempts). Locks out after LOCKOUT_ATTEMPTS tries."""
key = _attempt_key(ip)
with conn.cursor() as cur:
row = cur.execute(
"SELECT attempts, last_seen FROM login_attempts WHERE ip = %s", (key,)
).fetchone()
if row is None:
return True, 0
attempts, last_seen = row
if attempts >= LOCKOUT_ATTEMPTS:
if last_seen < _utcnow() - LOCKOUT_DURATION:
# Lockout expired: start a fresh window.
cur.execute("DELETE FROM login_attempts WHERE ip = %s", (key,))
return True, 0
return False, attempts
return True, attempts
def record_failed_login(conn: psycopg.Connection[Any], ip: str) -> None:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO login_attempts (ip, attempts, last_seen)
VALUES (%s, 1, now())
ON CONFLICT (ip) DO UPDATE
SET attempts = CASE
WHEN login_attempts.last_seen < now() - %s::interval
THEN 1
ELSE login_attempts.attempts + 1
END,
last_seen = now()
""",
(_attempt_key(ip), _interval(LOCKOUT_WINDOW)),
)
def clear_failed_logins(conn: psycopg.Connection[Any], ip: str) -> None:
with conn.cursor() as cur:
cur.execute("DELETE FROM login_attempts WHERE ip = %s", (_attempt_key(ip),))
def set_cookie(response: Any, token: str) -> None:
"""Attach the session cookie (HttpOnly; the webui is same-origin)."""
response.set_cookie(
COOKIE_NAME,
token,
httponly=True,
samesite="lax",
path="/",
max_age=int(SESSION_TTL.total_seconds()),
)
def clear_cookie(response: Any) -> None:
response.delete_cookie(COOKIE_NAME, path="/")
def constant_time_eq(a: str, b: str) -> bool:
return hmac.compare_digest(a.encode(), b.encode())