This commit is contained in:
timka123 committed 2026-10-19 09:41:32 +03:00
1 parent 4bdbc8bfa8
commit 2be074f191
22 files changed
+1034 -25

No files matched your search

+32 -11
View File
@@ -1,33 +1,54 @@
"""Shared dependencies: the database session and the optional shared secret."""
"""Shared dependencies: the database session and the two ways into the API."""
from __future__ import annotations
from typing import Annotated
from fastapi import Depends, Header, HTTPException, status
from fastapi import Cookie, Depends, Header, HTTPException, status
from sqlalchemy.orm import Session
from app.config import env_settings
from app.db import get_session
from app.services import auth_service
from app.services.settings_service import load_settings
from app.settings_schema import AppSettings
DbSession = Annotated[Session, Depends(get_session)]
def require_token(x_api_token: Annotated[str | None, Header()] = None) -> None:
"""Guard every API call when `API_TOKEN` is set.
def require_auth(
session: DbSession,
x_api_token: Annotated[str | None, Header()] = None,
kadence_session: Annotated[str | None, Cookie()] = None,
) -> None:
"""Guard every API call.
Off by default: on a laptop the backend is bound to localhost and a token is
friction. Set it the moment the container is reachable from anywhere else.
Two independent gates, either of which lets a request through:
* `X-API-Token` against `API_TOKEN` — the desktop app and scripts, off by
default (friction-free on a laptop; set the moment the backend leaves it).
* a `kadence_session` cookie from `/api/auth/login*` — the browser, closed
the moment a password or Telegram login is configured in Settings → Access.
With neither `API_TOKEN` nor a login configured, the API stays open, exactly
as before this existed.
"""
expected = env_settings().api_token
if not expected:
if expected and x_api_token == expected:
return
if x_api_token != expected:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Bad or missing X-API-Token."
)
if auth_service.valid_session(session, kadence_session):
return
settings = load_settings(session)
login_required = bool(settings.auth.password_hash) or (
settings.auth.telegram_login_enabled and bool(settings.auth.telegram_owner_id)
)
if not expected and not login_required:
return
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Sign in, or send a valid X-API-Token.",
)
def get_settings(session: DbSession) -> AppSettings:
+160
View File
@@ -0,0 +1,160 @@
"""Signing in and out of the browser UI.
Mounted directly on the app, not through `api_router` — these endpoints have
to be reachable *before* a session exists, so they never carry the
`require_auth` dependency. `change_password` checks for a live session itself.
"""
from __future__ import annotations
from typing import Annotated, Any
from fastapi import APIRouter, Body, Cookie, HTTPException, Request, Response, status
from app.api.deps import DbSession
from app.services import auth_service, settings_service
router = APIRouter(prefix="/api/auth", tags=["auth"])
_COOKIE = auth_service.SESSION_COOKIE
def _client_key(request: Request) -> str:
return request.client.host if request.client else "unknown"
def _is_https(request: Request) -> bool:
# TLS usually terminates at a reverse proxy in front of this container, so
# `request.url.scheme` alone would read "http" even on an HTTPS deployment.
return request.url.scheme == "https" or request.headers.get("x-forwarded-proto") == "https"
def _set_cookie(response: Response, request: Request, token: str, max_age: int) -> None:
response.set_cookie(
_COOKIE,
token,
max_age=max_age,
httponly=True,
secure=_is_https(request),
samesite="lax",
path="/",
)
@router.get("/me")
def me(
session: DbSession,
kadence_session: Annotated[str | None, Cookie()] = None,
) -> dict:
settings = settings_service.load_settings(session)
row = auth_service.valid_session(session, kadence_session)
login_required = bool(settings.auth.password_hash) or (
settings.auth.telegram_login_enabled and bool(settings.auth.telegram_owner_id)
)
return {
"authenticated": row is not None or not login_required,
"login_required": login_required,
"username": settings.auth.username,
"password_login_enabled": bool(settings.auth.password_hash),
"telegram_login_enabled": settings.auth.telegram_login_enabled
and bool(settings.auth.telegram_owner_id),
"telegram_bot_username": settings.auth.telegram_bot_username,
}
@router.post("/login")
def login(
session: DbSession,
request: Request,
response: Response,
body: dict[str, Any] = Body(...),
) -> dict:
key = f"password:{_client_key(request)}"
if auth_service.rate_limited(key):
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Too many attempts. Wait a few minutes.")
settings = settings_service.load_settings(session)
username = str(body.get("username", ""))
password = str(body.get("password", ""))
ok = (
bool(settings.auth.password_hash)
and username == settings.auth.username
and auth_service.verify_password(password, settings.auth.password_hash)
)
if not ok:
auth_service.record_failure(key)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wrong username or password.")
auth_service.record_success(key)
row = auth_service.create_session(session, "password", settings.auth.session_days)
_set_cookie(response, request, row.token, settings.auth.session_days * 86400)
return {"ok": True}
@router.post("/login/telegram")
def login_telegram(
session: DbSession,
request: Request,
response: Response,
body: dict[str, Any] = Body(...),
) -> dict:
key = f"telegram:{_client_key(request)}"
if auth_service.rate_limited(key):
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Too many attempts. Wait a few minutes.")
settings = settings_service.load_settings(session)
telegram = settings.notifications.telegram
auth = settings.auth
ok = (
auth.telegram_login_enabled
and bool(auth.telegram_owner_id)
and bool(telegram.bot_token)
and auth_service.verify_telegram_payload(body, telegram.bot_token)
and str(body.get("id")) == auth.telegram_owner_id
)
if not ok:
auth_service.record_failure(key)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Telegram sign-in did not check out.")
auth_service.record_success(key)
row = auth_service.create_session(session, "telegram", auth.session_days)
_set_cookie(response, request, row.token, auth.session_days * 86400)
return {"ok": True}
@router.post("/logout")
def logout(
session: DbSession,
response: Response,
kadence_session: Annotated[str | None, Cookie()] = None,
) -> dict:
auth_service.delete_session(session, kadence_session)
response.delete_cookie(_COOKIE, path="/")
return {"ok": True}
@router.post("/password")
def change_password(
session: DbSession,
body: dict[str, Any] = Body(...),
kadence_session: Annotated[str | None, Cookie()] = None,
) -> dict:
"""Requires a live session — set the first password via `AUTH_PASSWORD` or
`make set-password` instead, since there is nothing to authenticate with
yet on a fresh install."""
if auth_service.valid_session(session, kadence_session) is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Sign in first.")
settings = settings_service.load_settings(session)
current = str(body.get("current_password", ""))
new = str(body.get("new_password", ""))
if settings.auth.password_hash and not auth_service.verify_password(
current, settings.auth.password_hash
):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Current password is wrong.")
if len(new) < 8:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Use at least 8 characters.")
settings.auth.password_hash = auth_service.hash_password(new)
settings_service.save_settings(session, settings)
return {"ok": True}
+19
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import argparse
import getpass
import json
from datetime import date
@@ -27,6 +28,9 @@ def main() -> int:
report_cmd.add_argument("--print", action="store_true", help="print, do not send")
sub.add_parser("stats", help="print a summary as JSON")
setpw = sub.add_parser("set-password", help="set or replace the web login password")
setpw.add_argument("--username", default=None, help="defaults to the current one")
args = parser.parse_args()
if args.command == "serve":
@@ -69,6 +73,21 @@ def main() -> int:
ensure_ascii=False,
)
)
elif args.command == "set-password":
from app.services import auth_service
password = getpass.getpass("New web login password: ")
if password != getpass.getpass("Confirm: "):
print("Passwords did not match.")
return 1
if len(password) < 8:
print("Use at least 8 characters.")
return 1
if args.username:
settings.auth.username = args.username
settings.auth.password_hash = auth_service.hash_password(password)
settings_service.save_settings(session, settings)
print(f"Password set for {settings.auth.username!r}.")
return 0
+11
View File
@@ -85,6 +85,17 @@ class EnvSettings(BaseSettings):
telegram_chat_id: str = ""
slack_webhook_url: str = ""
# ---- web login (closes the browser UI; the desktop app keeps API_TOKEN) ---
auth_username: str = "timka"
auth_password: str = ""
"""Set once; the hash is seeded into settings on first boot, never stored here."""
telegram_owner_id: str = ""
"""Your numeric Telegram user id — https://t.me/userinfobot says it in a DM."""
telegram_bot_username: str = ""
"""Without the `@`. Needed to render the Telegram Login Widget."""
@property
def cors_origin_list(self) -> list[str]:
if self.cors_origins.strip() == "*":
+10 -3
View File
@@ -20,7 +20,8 @@ from fastapi.staticfiles import StaticFiles
from app import __version__
from app.api import api_router
from app.api.deps import require_token
from app.api.deps import require_auth
from app.api.routes_auth import router as auth_router
from app.config import env_settings
from app.db import init_db, session_scope
from app.services import scheduler, settings_service, telegram_bot
@@ -70,7 +71,8 @@ app.add_middleware(
allow_headers=["*"],
)
app.include_router(api_router, dependencies=[Depends(require_token)])
app.include_router(auth_router)
app.include_router(api_router, dependencies=[Depends(require_auth)])
@app.get("/api/health", tags=["meta"])
@@ -82,9 +84,14 @@ def health() -> dict:
def info() -> dict:
"""What a client needs to know before it has a token: who we are, what is on."""
env = env_settings()
with session_scope() as session:
settings = settings_service.load_settings(session)
login_required = bool(settings.auth.password_hash) or (
settings.auth.telegram_login_enabled and bool(settings.auth.telegram_owner_id)
)
return {
"version": __version__,
"auth_required": bool(env.api_token),
"auth_required": bool(env.api_token) or login_required,
"scheduler": env.scheduler_enabled,
"jobs": scheduler.describe(),
}
+16
View File
@@ -173,6 +173,22 @@ class DailyReport(Base):
score: Mapped[float] = mapped_column(Float, default=0.0)
class AuthSession(Base):
"""A browser login, created by a password or Telegram sign-in.
The token is the cookie value itself (a random opaque string) — no server
side id/secret split needed for a single-user app. Expired rows are pruned
lazily whenever a session is looked up.
"""
__tablename__ = "auth_sessions"
token: Mapped[str] = mapped_column(String(64), primary_key=True)
method: Mapped[str] = mapped_column(String(16)) # password | telegram
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
class EventLog(Base):
"""Small append-only log used for "did we already send this?" questions.
+143
View File
@@ -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)
+12
View File
@@ -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
+28
View File
@@ -276,6 +276,32 @@ class NotificationsSettings(BaseModel):
report: ReportSettings = Field(default_factory=ReportSettings)
# --------------------------------------------------------------------------
# Web login
# --------------------------------------------------------------------------
class AuthSettings(BaseModel):
"""Gates the browser UI. The desktop app and scripts keep using `API_TOKEN`.
The web UI is closed the moment either credential is configured: a password
(`password_hash` set) or Telegram sign-in (`telegram_login_enabled` with an
`telegram_owner_id`). With neither set, the browser is open — the same
"friction-free on a laptop" default `API_TOKEN` already has.
"""
username: str = "timka"
password_hash: str = ""
"""`pbkdf2_sha256$<iterations>$<salt>$<hash>`, never the plaintext password."""
telegram_login_enabled: bool = False
telegram_owner_id: str = ""
"""The numeric Telegram user id allowed to sign in with the Login Widget."""
telegram_bot_username: str = ""
"""Without the `@`, e.g. `kadence_bot` — the widget needs it to render."""
session_days: int = Field(default=30, ge=1, le=365)
# --------------------------------------------------------------------------
# Root
# --------------------------------------------------------------------------
@@ -298,6 +324,7 @@ class AppSettings(BaseModel):
blocker: BlockerSettings = Field(default_factory=BlockerSettings)
notifications: NotificationsSettings = Field(default_factory=NotificationsSettings)
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
auth: AuthSettings = Field(default_factory=AuthSettings)
@field_validator("timezone")
@classmethod
@@ -321,6 +348,7 @@ SECRET_FIELDS = frozenset(
"session_cookie",
"bot_token",
"webhook_url",
"password_hash",
}
)
+4
View File
@@ -31,6 +31,10 @@ for name in (
"TELEGRAM_BOT_TOKEN",
"TELEGRAM_CHAT_ID",
"SLACK_WEBHOOK_URL",
"AUTH_PASSWORD",
"TELEGRAM_OWNER_ID",
"TELEGRAM_BOT_USERNAME",
"API_TOKEN",
):
os.environ.pop(name, None)
+67
View File
@@ -0,0 +1,67 @@
"""Login gate: closed only once a password or Telegram login is configured."""
from __future__ import annotations
from app.services import auth_service
def test_open_by_default(client):
assert client.get("/api/settings").status_code == 200
def test_password_hash_round_trips():
hashed = auth_service.hash_password("correct horse battery staple")
assert auth_service.verify_password("correct horse battery staple", hashed)
assert not auth_service.verify_password("wrong", hashed)
def test_login_flow(client):
from app.db import SessionLocal
from app.services import settings_service
db = SessionLocal()
try:
settings = settings_service.load_settings(db)
settings.auth.password_hash = auth_service.hash_password("s3cret!!")
settings_service.save_settings(db, settings)
finally:
db.close()
# Locked out without a session.
assert client.get("/api/settings").status_code == 401
assert client.get("/api/auth/me").json()["login_required"] is True
bad = client.post("/api/auth/login", json={"username": "timka", "password": "nope"})
assert bad.status_code == 401
ok = client.post("/api/auth/login", json={"username": "timka", "password": "s3cret!!"})
assert ok.status_code == 200
assert "kadence_session" in client.cookies
assert client.get("/api/settings").status_code == 200
assert client.get("/api/auth/me").json()["authenticated"] is True
client.post("/api/auth/logout")
assert client.get("/api/settings").status_code == 401
def test_api_token_still_works_once_login_is_configured(client, monkeypatch):
from app.config import env_settings
from app.db import SessionLocal
from app.services import settings_service
env_settings.cache_clear()
monkeypatch.setenv("API_TOKEN", "desktop-secret")
env_settings.cache_clear()
db = SessionLocal()
try:
settings = settings_service.load_settings(db)
settings.auth.password_hash = auth_service.hash_password("s3cret!!")
settings_service.save_settings(db, settings)
finally:
db.close()
assert client.get("/api/settings").status_code == 401
assert client.get("/api/settings", headers={"X-API-Token": "desktop-secret"}).status_code == 200
env_settings.cache_clear()