diff --git a/.env.example b/.env.example index 1081b52..2469115 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,23 @@ SCHEDULER_ENABLED=true TELEGRAM_BOT_ENABLED=true LOG_LEVEL=INFO +# ---- web login (closes the browser UI to everyone but you) ----------------- +# Unset: the browser UI is as open as the API ("off by default" above). Set +# either of these and the login screen appears — the desktop app is untouched, +# it keeps using API_TOKEN. +# +# Password: set once, seeds a hash into the DB on first boot; from then on +# change it in Settings -> Access or with `make set-password` on the server. +AUTH_USERNAME=timka +AUTH_PASSWORD= +# +# Telegram login (the "Login Widget", not the bot): needs TELEGRAM_BOT_TOKEN +# above, this site's HTTPS domain registered with @BotFather via /setdomain, +# and your numeric Telegram user id (DM @userinfobot to get it — it is not +# the same as TELEGRAM_CHAT_ID when that is a group). +TELEGRAM_OWNER_ID= +TELEGRAM_BOT_USERNAME= + # ---- Jira (required for the Jira source) ----------------------------------- JIRA_BASE_URL=https://your-site.atlassian.net JIRA_EMAIL=you@example.com diff --git a/Makefile b/Makefile index 7aa306b..17530d6 100644 --- a/Makefile +++ b/Makefile @@ -39,4 +39,7 @@ helper-install: ## Install the privileged blocking helper (asks for sudo) report: ## Build and send today's report right now cd backend && PYTHONPATH=$$PWD .venv/bin/python -m app.cli report -.PHONY: help up down logs dev test backend-venv web-build desktop desktop-install helper-install report +set-password: ## Set/rotate the web login password (asks for a confirm) + docker compose exec app python -m app.cli set-password + +.PHONY: help up down logs dev test backend-venv web-build desktop desktop-install helper-install report set-password diff --git a/README.md b/README.md index 87eab39..a759aef 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,8 @@ and you can fill everything in from **Settings**. | `JIRA_*`, `TODOIST_*`, `GOOGLE_*`, `LAUNDRY_*` | seeds for the sources | | `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` | the bot | | `SCHEDULER_ENABLED`, `TELEGRAM_BOT_ENABLED` | switch the background jobs / bot off | +| `AUTH_USERNAME`, `AUTH_PASSWORD` | closes the **browser UI** behind a login; see [Going public](#going-public) | +| `TELEGRAM_OWNER_ID`, `TELEGRAM_BOT_USERNAME` | lets that same browser login happen via the Telegram Login Widget | --- @@ -200,7 +202,7 @@ and you can fill everything in from **Settings**. * `backend/` — FastAPI app. `app/services/` holds the thinking (timer, tasks, stats, report, blocker policy, scheduler, bot); `app/sources/` holds one module - per integration; `app/api/` is a thin HTTP layer. 44 tests under `backend/tests`. + per integration; `app/api/` is a thin HTTP layer. 63 tests under `backend/tests`. * `frontend/` — React + TypeScript + MUI, built by Vite into the backend image and served from the same origin (so there is no CORS to configure). * **The look** — warm paper, ink type, hairline rules instead of boxes and @@ -228,14 +230,46 @@ make report # build and send today's report right now The backend also has a CLI, handy from cron or a systemd timer: ```bash -python -m app.cli refresh # pull every source once -python -m app.cli briefing # send the morning briefing -python -m app.cli report # build and send today's report -python -m app.cli stats # print the numbers as JSON +python -m app.cli refresh # pull every source once +python -m app.cli briefing # send the morning briefing +python -m app.cli report # build and send today's report +python -m app.cli stats # print the numbers as JSON +python -m app.cli set-password # set/rotate the web login password (prompts, never argv) ``` --- +## Going public + +Kadence is still single-user: `AUTH_USERNAME`/`AUTH_PASSWORD` and +`TELEGRAM_OWNER_ID` all name *you*, not a user table. What they do is close the +**browser UI** to everyone else once the backend is reachable beyond your own +machine — `API_TOKEN` already protected the raw API; this is the same idea with +a login screen instead of a header the desktop app fills in for you. + +1. **Password.** Set `AUTH_PASSWORD` (and optionally `AUTH_USERNAME`, default + `timka`) in `.env` and restart — the hash is seeded into the database on + first boot, the plaintext is never stored. A browser now has to sign in at + `/`; the desktop app is unaffected, it keeps sending `X-API-Token`. Rotate + the password later from **Settings → Access** (while signed in) or by + running `make set-password` on the server — the UI never accepts a new + password over an unauthenticated connection. +2. **Telegram login, in addition** (optional). You already have the bot token + (`TELEGRAM_BOT_TOKEN`) for notifications; login reuses it: + * DM [@userinfobot](https://t.me/userinfobot) for your numeric Telegram id + → `TELEGRAM_OWNER_ID`. + * Tell [@BotFather](https://t.me/BotFather) `/setdomain` and give it this + site's **HTTPS** domain — the Login Widget refuses to render anywhere else. + * Set `TELEGRAM_BOT_USERNAME` (no `@`) in `.env`, or fill it in under + **Settings → Access** once the UI is reachable. + * Restart. The login screen grows a "Log in with Telegram" button under the + password form. +3. Either credential alone is enough to close the UI; set both and either one + signs you in. With neither set, the browser stays as open as the bare API + always was — nothing changes until you opt in. + +--- + ## Security notes * The desktop app never runs as root. The only privileged code is @@ -245,6 +279,14 @@ python -m app.cli stats # print the numbers as JSON hostname. It is authorised by polkit (`auth_admin_keep` by default — one prompt per session; `install.sh --passwordless` drops that for one user). * Tokens live in the database and in `.env`, are masked in every API response, - and are never sent to the browser in full. -* Set `API_TOKEN` before exposing the backend beyond localhost; put it behind TLS - if it leaves your network. + and are never sent to the browser in full. The password is never stored at + all — only a salted PBKDF2 hash. +* Set `API_TOKEN` before exposing the backend beyond localhost (protects the + raw API — desktop app, scripts, cron); see [Going public](#going-public) to + close the **browser UI** the same way, with a login instead of a header. +* The login cookie is `HttpOnly`, `SameSite=Lax`, and `Secure` whenever the + request arrives as HTTPS (directly or via `X-Forwarded-Proto` from a reverse + proxy) — put the backend behind TLS before relying on it over the open + internet, same as `API_TOKEN` always needed. +* Login attempts are rate-limited per source IP (in-process; fine for one + instance, one user). diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 2fa0ff5..f7aa761 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -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: diff --git a/backend/app/api/routes_auth.py b/backend/app/api/routes_auth.py new file mode 100644 index 0000000..3fbff25 --- /dev/null +++ b/backend/app/api/routes_auth.py @@ -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} diff --git a/backend/app/cli.py b/backend/app/cli.py index e51c0c2..8c6003c 100644 --- a/backend/app/cli.py +++ b/backend/app/cli.py @@ -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 diff --git a/backend/app/config.py b/backend/app/config.py index 0f671fc..d996747 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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() == "*": diff --git a/backend/app/main.py b/backend/app/main.py index ca4c330..de55164 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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(), } diff --git a/backend/app/models.py b/backend/app/models.py index e2b43be..149165d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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. diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..1105db3 --- /dev/null +++ b/backend/app/services/auth_service.py @@ -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) diff --git a/backend/app/services/settings_service.py b/backend/app/services/settings_service.py index a832697..58793fd 100644 --- a/backend/app/services/settings_service.py +++ b/backend/app/services/settings_service.py @@ -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 diff --git a/backend/app/settings_schema.py b/backend/app/settings_schema.py index 5e59a7d..96bbc4d 100644 --- a/backend/app/settings_schema.py +++ b/backend/app/settings_schema.py @@ -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$$$`, 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", } ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a970e6c..42cbc36 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..eeaa5b9 --- /dev/null +++ b/backend/tests/test_auth.py @@ -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() diff --git a/docker-compose.yml b/docker-compose.yml index fc0eaaf..a1bcbd9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,12 @@ services: TELEGRAM_BOT_ENABLED: ${TELEGRAM_BOT_ENABLED:-true} LOG_LEVEL: ${LOG_LEVEL:-INFO} + # Web login — see .env.example for what each does. + AUTH_USERNAME: ${AUTH_USERNAME:-timka} + AUTH_PASSWORD: ${AUTH_PASSWORD:-} + TELEGRAM_OWNER_ID: ${TELEGRAM_OWNER_ID:-} + TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-} + # Seeds for the editable settings — the same variable names jira-helper # uses, so an existing .env can be copied across unchanged. JIRA_BASE_URL: ${JIRA_BASE_URL:-} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f2dd594..6979ae7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,9 +3,11 @@ import { Alert, Box, CssBaseline, LinearProgress, Snackbar, ThemeProvider, useMe import { Navigate, Route, Routes } from "react-router-dom"; import AppShell from "./components/AppShell"; +import { AuthProvider, useAuth } from "./lib/auth"; import { SettingsProvider, useSettings } from "./lib/settings"; import { buildTheme, type ThemeMode } from "./theme"; import BlockerPage from "./pages/BlockerPage"; +import LoginPage from "./pages/LoginPage"; import ReportPage from "./pages/ReportPage"; import SettingsPage from "./pages/SettingsPage"; import TasksPage from "./pages/TasksPage"; @@ -66,10 +68,43 @@ function Shell() { ); } -export default function App() { +function Gate() { + const { me, loading } = useAuth(); + const prefersDark = useMediaQuery("(prefers-color-scheme: dark)"); + + if (loading || !me) { + // The paper theme, so the blank moment before `/api/auth/me` answers + // doesn't flash a default MUI white. + const theme = buildTheme(prefersDark ? "dark" : "light", false); + return ( + + + + + ); + } + + if (!me.authenticated) { + const theme = buildTheme(prefersDark ? "dark" : "light", false); + return ( + + + + + ); + } + return ( ); } + +export default function App() { + return ( + + + + ); +} diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 4a2c37e..7eb2c6f 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -22,11 +22,13 @@ import { } from "@mui/material"; import AssignmentOutlinedIcon from "@mui/icons-material/AssignmentOutlined"; import BarChartOutlinedIcon from "@mui/icons-material/BarChartOutlined"; +import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined"; import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined"; import TimerOutlinedIcon from "@mui/icons-material/TimerOutlined"; import { useLocation, useNavigate } from "react-router-dom"; +import { useAuth } from "../lib/auth"; import { clock } from "../lib/format"; import { useResource } from "../lib/hooks"; import type { BlockerPolicy, TimerState } from "../lib/types"; @@ -55,6 +57,7 @@ export default function AppShell({ const navigate = useNavigate(); const location = useLocation(); const palette = vizPalette(dark); + const { me, logout } = useAuth(); const timer = useResource("/api/timer/state", 5000); const policy = useResource("/api/blocker/policy", 10000); @@ -187,6 +190,23 @@ export default function AppShell({ {status} + {me?.login_required && ( + void logout()} + sx={{ + mt: 1.5, + cursor: "pointer", + color: "text.secondary", + "&:hover": { color: "text.primary" }, + }} + > + + Sign out + + )} )} @@ -208,6 +228,14 @@ export default function AppShell({ Kadence {status} + {me?.login_required && ( + void logout()} + sx={{ display: "flex", alignItems: "center", color: "text.secondary", ml: 1 }} + > + + + )} )} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0e25879..1deffaa 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -26,6 +26,15 @@ export function setToken(token: string): void { else localStorage.removeItem(TOKEN_KEY); } +// The login screen listens here, so a session that expires mid-use drops the +// app back to it instead of showing a generic "backend unreachable" error. +const unauthorizedListeners = new Set<() => void>(); + +export function onUnauthorized(listener: () => void): () => void { + unauthorizedListeners.add(listener); + return () => unauthorizedListeners.delete(listener); +} + async function request(path: string, init: RequestInit = {}): Promise { const headers = new Headers(init.headers); headers.set("Accept", "application/json"); @@ -33,7 +42,10 @@ async function request(path: string, init: RequestInit = {}): Promise { const token = getToken(); if (token) headers.set("X-API-Token", token); - const response = await fetch(path, { ...init, headers }); + const response = await fetch(path, { ...init, headers, credentials: "same-origin" }); + if (response.status === 401 && !path.startsWith("/api/auth/")) { + unauthorizedListeners.forEach((listener) => listener()); + } if (!response.ok) { let detail = `${response.status} ${response.statusText}`; try { diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx new file mode 100644 index 0000000..9f37f98 --- /dev/null +++ b/frontend/src/lib/auth.tsx @@ -0,0 +1,81 @@ +/** + * The login gate. + * + * One fetch at start-up decides whether the app behind it is reachable yet. + * When no password or Telegram login is configured, `authenticated` comes + * back `true` immediately — the same friction-free default the API token has + * always had — so nothing changes until Settings → Access is actually set up. + */ +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; + +import { api, onUnauthorized } from "./api"; +import type { AuthMe } from "./types"; + +interface AuthContextValue { + me: AuthMe | null; + loading: boolean; + error: string | null; + loginWithPassword: (username: string, password: string) => Promise; + loginWithTelegram: (payload: Record) => Promise; + logout: () => Promise; + refresh: () => Promise; +} + +const Context = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [me, setMe] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + try { + const body = await api.get("/api/auth/me"); + setMe(body); + setError(null); + } catch (exc) { + setError(exc instanceof Error ? exc.message : String(exc)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useEffect(() => onUnauthorized(() => void refresh()), [refresh]); + + const loginWithPassword = useCallback( + async (username: string, password: string) => { + await api.post("/api/auth/login", { username, password }); + await refresh(); + }, + [refresh], + ); + + const loginWithTelegram = useCallback( + async (payload: Record) => { + await api.post("/api/auth/login/telegram", payload); + await refresh(); + }, + [refresh], + ); + + const logout = useCallback(async () => { + await api.post("/api/auth/logout"); + await refresh(); + }, [refresh]); + + const value = useMemo( + () => ({ me, loading, error, loginWithPassword, loginWithTelegram, logout, refresh }), + [me, loading, error, loginWithPassword, loginWithTelegram, logout, refresh], + ); + return {children}; +} + +export function useAuth(): AuthContextValue { + const value = useContext(Context); + if (!value) throw new Error("useAuth outside AuthProvider"); + return value; +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index b53a2cb..35647ea 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -236,9 +236,26 @@ export interface AppSettings { }; }; appearance: { theme: "light" | "dark" | "system"; accent: string; compact: boolean }; + auth: { + username: string; + password_hash: string; + telegram_login_enabled: boolean; + telegram_owner_id: string; + telegram_bot_username: string; + session_days: number; + }; } export interface SettingsResponse { settings: AppSettings; defaults: { blocked_domains: string[]; blocked_apps: string[] }; } + +export interface AuthMe { + authenticated: boolean; + login_required: boolean; + username: string; + password_login_enabled: boolean; + telegram_login_enabled: boolean; + telegram_bot_username: string; +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..53fc97c --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,148 @@ +/** + * The gate in front of everything else. + * + * Same editorial language as the rest of the app — warm paper, a serif + * wordmark, a hairline, one filled button — just with nowhere else to go + * until one of the two credentials checks out. + */ +import { useEffect, useRef, useState } from "react"; +import { Alert, Box, Button, Divider, Stack, TextField, Typography, useTheme } from "@mui/material"; + +import { useAuth } from "../lib/auth"; + +declare global { + interface Window { + onKadenceTelegramAuth?: (user: Record) => void; + } +} + +export default function LoginPage() { + const theme = useTheme(); + const { me, loginWithPassword, loginWithTelegram } = useAuth(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const widgetHost = useRef(null); + + const submit = async (event: React.FormEvent) => { + event.preventDefault(); + setBusy(true); + setError(null); + try { + await loginWithPassword(username, password); + } catch (exc) { + setError(exc instanceof Error ? exc.message : String(exc)); + } finally { + setBusy(false); + } + }; + + useEffect(() => { + if (!me?.telegram_login_enabled || !me.telegram_bot_username || !widgetHost.current) return; + + window.onKadenceTelegramAuth = (user) => { + setError(null); + void loginWithTelegram(user).catch((exc) => + setError(exc instanceof Error ? exc.message : String(exc)), + ); + }; + + const script = document.createElement("script"); + script.src = "https://telegram.org/js/telegram-widget.js?22"; + script.async = true; + script.dataset.telegramLogin = me.telegram_bot_username; + script.dataset.size = "large"; + script.dataset.radius = "10"; + script.dataset.onauth = "onKadenceTelegramAuth(user)"; + script.dataset.requestAccess = "write"; + widgetHost.current.innerHTML = ""; + widgetHost.current.appendChild(script); + }, [me?.telegram_login_enabled, me?.telegram_bot_username, loginWithTelegram]); + + return ( + + + + + Kadence + + + find your rhythm + + + + + Sign in + + + + {me?.password_login_enabled && ( + + + setUsername(event.target.value)} + autoFocus + fullWidth + /> + setPassword(event.target.value)} + fullWidth + /> + {error && {error}} + + + + )} + + {me?.password_login_enabled && me?.telegram_login_enabled && ( + + + + or + + + + )} + + {me?.telegram_login_enabled && ( + + + {!me?.password_login_enabled && error && {error}} + + )} + + {!me?.password_login_enabled && !me?.telegram_login_enabled && ( + + No login method is configured yet. Set AUTH_PASSWORD (or a Telegram owner id) in{" "} + .env, or run make set-password. + + )} + + + ); +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 1d29b0d..48a86e3 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -22,6 +22,7 @@ import { import { PageHeader, Panel, SectionTitle } from "../components/Layout"; import { api, getToken, setToken } from "../lib/api"; +import { useAuth } from "../lib/auth"; import { useSettings } from "../lib/settings"; import type { AppSettings } from "../lib/types"; @@ -453,11 +454,142 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) = desktop/; its lists live on the Blocker page. + + ); } +function AccessSection({ + patch, + onToast, +}: { + patch: (change: Patch, message?: string) => Promise; + onToast: (message: string) => void; +}) { + const { settings } = useSettings(); + const { me, logout } = useAuth(); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [saving, setSaving] = useState(false); + + if (!settings) return null; + const auth = settings.auth; + + const changePassword = async () => { + setSaving(true); + try { + await api.post("/api/auth/password", { + current_password: currentPassword, + new_password: newPassword, + }); + setCurrentPassword(""); + setNewPassword(""); + onToast("Password changed"); + } catch (exc) { + onToast(exc instanceof Error ? exc.message : String(exc)); + } finally { + setSaving(false); + } + }; + + return ( +
+ + Password + {auth.password_hash ? ( + <> + {me?.login_required ? ( + + setCurrentPassword(event.target.value)} + sx={{ minWidth: 220 }} + /> + setNewPassword(event.target.value)} + sx={{ minWidth: 220 }} + /> + + + ) : ( + + A password is set, but this browser isn't signed in. Sign out and back in to rotate it + here, or run make set-password on the server. + + )} + + + + + + ) : ( + + Not set — the browser UI is open to anyone who can reach this address. Set{" "} + AUTH_PASSWORD in .env (and restart), or run{" "} + make set-password on the server, then reload this page. + + )} + void patch({ auth: { username: value } })} + /> + + + + + Telegram login + {auth.telegram_login_enabled && auth.telegram_owner_id && ( + + )} + void patch({ auth: { telegram_login_enabled: event.target.checked } })} + /> + + {auth.telegram_login_enabled && ( + + void patch({ auth: { telegram_owner_id: value } })} + /> + void patch({ auth: { telegram_bot_username: value } })} + /> + + )} + + Needs the bot token from the Telegram section above, plus `/setdomain` registered with + @BotFather for this site's HTTPS domain. + + +
+ ); +} + function Section({ title, subtitle,