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

+17
View File
@@ -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
+4 -1
View File
@@ -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
+50 -8
View File
@@ -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).
+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()
+6
View File
@@ -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:-}
+36 -1
View File
@@ -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 (
<ThemeProvider theme={theme}>
<CssBaseline />
<LinearProgress />
</ThemeProvider>
);
}
if (!me.authenticated) {
const theme = buildTheme(prefersDark ? "dark" : "light", false);
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<LoginPage />
</ThemeProvider>
);
}
return (
<SettingsProvider>
<Shell />
</SettingsProvider>
);
}
export default function App() {
return (
<AuthProvider>
<Gate />
</AuthProvider>
);
}
+28
View File
@@ -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<TimerState>("/api/timer/state", 5000);
const policy = useResource<BlockerPolicy>("/api/blocker/policy", 10000);
@@ -187,6 +190,23 @@ export default function AppShell({
<Divider sx={{ mb: 1.5 }} />
{status}
{me?.login_required && (
<Stack
direction="row"
alignItems="center"
spacing={1}
onClick={() => void logout()}
sx={{
mt: 1.5,
cursor: "pointer",
color: "text.secondary",
"&:hover": { color: "text.primary" },
}}
>
<LogoutOutlinedIcon fontSize="small" />
<Typography variant="caption">Sign out</Typography>
</Stack>
)}
</Box>
)}
@@ -208,6 +228,14 @@ export default function AppShell({
Kadence
</Typography>
<Box sx={{ width: 120 }}>{status}</Box>
{me?.login_required && (
<Box
onClick={() => void logout()}
sx={{ display: "flex", alignItems: "center", color: "text.secondary", ml: 1 }}
>
<LogoutOutlinedIcon fontSize="small" />
</Box>
)}
</Toolbar>
</AppBar>
)}
+13 -1
View File
@@ -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<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
headers.set("Accept", "application/json");
@@ -33,7 +42,10 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
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 {
+81
View File
@@ -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<void>;
loginWithTelegram: (payload: Record<string, unknown>) => Promise<void>;
logout: () => Promise<void>;
refresh: () => Promise<void>;
}
const Context = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [me, setMe] = useState<AuthMe | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const body = await api.get<AuthMe>("/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<string, unknown>) => {
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 <Context.Provider value={value}>{children}</Context.Provider>;
}
export function useAuth(): AuthContextValue {
const value = useContext(Context);
if (!value) throw new Error("useAuth outside AuthProvider");
return value;
}
+17
View File
@@ -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;
}
+148
View File
@@ -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<string, unknown>) => 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<string | null>(null);
const [busy, setBusy] = useState(false);
const widgetHost = useRef<HTMLDivElement>(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 (
<Box
sx={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "background.default",
px: 2,
}}
>
<Box sx={{ width: "100%", maxWidth: 360 }}>
<Stack spacing={0.5} sx={{ mb: 4, textAlign: "center" }}>
<Typography
sx={{
fontFamily: theme.typography.h1.fontFamily,
fontSize: 32,
fontWeight: 500,
letterSpacing: "-0.02em",
lineHeight: 1,
}}
>
Kadence
</Typography>
<Typography variant="caption" color="text.secondary">
find your rhythm
</Typography>
</Stack>
<Typography variant="overline" color="text.secondary" sx={{ display: "block", mb: 1 }}>
Sign in
</Typography>
<Divider sx={{ mb: 3 }} />
{me?.password_login_enabled && (
<Box component="form" onSubmit={submit}>
<Stack spacing={2}>
<TextField
label="Username"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoFocus
fullWidth
/>
<TextField
label="Password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
fullWidth
/>
{error && <Alert severity="error">{error}</Alert>}
<Button type="submit" variant="contained" disabled={busy} size="large">
{busy ? "Signing in…" : "Sign in"}
</Button>
</Stack>
</Box>
)}
{me?.password_login_enabled && me?.telegram_login_enabled && (
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ my: 3 }}>
<Divider sx={{ flexGrow: 1 }} />
<Typography variant="caption" color="text.secondary">
or
</Typography>
<Divider sx={{ flexGrow: 1 }} />
</Stack>
)}
{me?.telegram_login_enabled && (
<Stack alignItems="center" spacing={2}>
<Box ref={widgetHost} />
{!me?.password_login_enabled && error && <Alert severity="error">{error}</Alert>}
</Stack>
)}
{!me?.password_login_enabled && !me?.telegram_login_enabled && (
<Alert severity="warning">
No login method is configured yet. Set <code>AUTH_PASSWORD</code> (or a Telegram owner id) in{" "}
<code>.env</code>, or run <code>make set-password</code>.
</Alert>
)}
</Box>
</Box>
);
}
+132
View File
@@ -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) =
<code>desktop/</code>; its lists live on the <strong>Blocker</strong> page.
</Alert>
</Section>
<AccessSection patch={patch} onToast={onToast} />
</Stack>
</>
);
}
function AccessSection({
patch,
onToast,
}: {
patch: (change: Patch, message?: string) => Promise<void>;
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 (
<Section
title="Access"
subtitle="Closes the browser UI behind a login. The desktop app keeps using its own API token."
>
<Panel>
<Typography sx={{ fontWeight: 600, mb: 1.5 }}>Password</Typography>
{auth.password_hash ? (
<>
{me?.login_required ? (
<Stack direction="row" spacing={1.5} sx={{ flexWrap: "wrap", rowGap: 1.5 }}>
<TextField
label="Current password"
type="password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
sx={{ minWidth: 220 }}
/>
<TextField
label="New password"
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
sx={{ minWidth: 220 }}
/>
<Button
variant="outlined"
disabled={saving || newPassword.length < 8}
onClick={changePassword}
>
Change password
</Button>
</Stack>
) : (
<Typography variant="body2" color="text.secondary">
A password is set, but this browser isn't signed in. Sign out and back in to rotate it
here, or run <code>make set-password</code> on the server.
</Typography>
)}
<Stack direction="row" spacing={2} sx={{ mt: 1.5 }} alignItems="center">
<Chip size="small" color="success" variant="outlined" label={`on · ${auth.username}`} />
<Button size="small" onClick={() => void logout()}>
Sign out
</Button>
</Stack>
</>
) : (
<Typography variant="body2" color="text.secondary">
Not set — the browser UI is open to anyone who can reach this address. Set{" "}
<code>AUTH_PASSWORD</code> in <code>.env</code> (and restart), or run{" "}
<code>make set-password</code> on the server, then reload this page.
</Typography>
)}
<Field
label="Username"
value={auth.username}
onCommit={(value) => void patch({ auth: { username: value } })}
/>
</Panel>
<Panel sx={{ mt: 2 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: auth.telegram_login_enabled ? 1.5 : 0 }}>
<Typography sx={{ fontWeight: 600, flexGrow: 1 }}>Telegram login</Typography>
{auth.telegram_login_enabled && auth.telegram_owner_id && (
<Chip size="small" color="success" variant="outlined" label="on" />
)}
<Switch
checked={auth.telegram_login_enabled}
onChange={(event) => void patch({ auth: { telegram_login_enabled: event.target.checked } })}
/>
</Stack>
{auth.telegram_login_enabled && (
<Stack spacing={1.5} direction="row" sx={{ flexWrap: "wrap", rowGap: 1.5 }}>
<Field
label="Your Telegram user id"
value={auth.telegram_owner_id}
placeholder="e.g. from @userinfobot"
onCommit={(value) => void patch({ auth: { telegram_owner_id: value } })}
/>
<Field
label="Bot username (no @)"
value={auth.telegram_bot_username}
placeholder={settings.notifications.telegram.bot_token ? "" : "needs a bot token above"}
onCommit={(value) => void patch({ auth: { telegram_bot_username: value } })}
/>
</Stack>
)}
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1.5 }}>
Needs the bot token from the Telegram section above, plus `/setdomain` registered with
@BotFather for this site's HTTPS domain.
</Typography>
</Panel>
</Section>
);
}
function Section({
title,
subtitle,