From a185c91407c0f5ff6e4db9d96176710cfe03c6a4 Mon Sep 17 00:00:00 2001 From: Space-Banane Date: Wed, 20 May 2026 21:57:14 +0200 Subject: [PATCH] Add sliding session renewal and periodic token rotation --- README.md | 1 + TODO.md | 111 +++++++++++++++++++++++++----------------------- backend/main.py | 45 ++++++++++++++++---- 3 files changed, 96 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 36e59c6..b2c8564 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ The app expects a MariaDB instance configured through environment variables. ### Session and Cookie Env Vars - `SESSION_TTL_SECONDS` (default: `86400`) +- `SESSION_ROTATE_SECONDS` (default: `3600`, rotate active session token when exceeded) - `SESSION_COOKIE_SECURE` (default: `false`, set `true` in production HTTPS) - `REQUIRE_CSRF` (default: `false`, checks same-origin/same-referer for write routes when enabled) - `LOGIN_MAX_ATTEMPTS` (default: `5`) diff --git a/TODO.md b/TODO.md index edb454d..d166022 100644 --- a/TODO.md +++ b/TODO.md @@ -1,63 +1,68 @@ # TODO -Concrete follow-up work for Jellomator. +Concrete follow-up work for Jellomator, prioritized by implementation risk and user impact. -## P0 +## P0 - Security and Reliability -- Add a backup and restore flow for the database in the admin UI. - - Let an admin download the current database. - - Let an admin upload a replacement database after confirmation. - - Validate the uploaded file before swapping it in. -- Add a basic health endpoint for Docker and orchestration. - - Return `200` when the app can read and write the database. - - Return `503` if startup initialization or DB access fails. -- Add login rate limiting. - - Track failed attempts per session or IP. - - Temporarily block repeated failures. -- Add session expiry controls. - - Expire idle admin sessions after a configurable period. - - Renew active sessions on successful requests. +- [ ] Add session expiry and rotation. + - [x] Add `expires_at` and `last_seen_at` to `sessions`. + - [x] Reject expired tokens in `current_user`. + - [ ] Rotate session token on login and periodically on use. +- [x] Harden auth endpoints. + - [x] Add login rate limiting by IP + username pair. + - [x] Add brute-force lockout window with clear error message. + - [x] Add optional CSRF protection for cookie-authenticated write routes. +- [x] Fix cookie/security defaults for deployment. + - [x] Set cookie `secure` from environment (true in production). + - [x] Make cookie max-age configurable. + - [x] Keep `httponly` and `samesite=lax`. +- [x] Add input and payload validation. + - [x] Validate URL scheme for links (`http`/`https` only). + - [x] Enforce max lengths for `name`, `category`, `description`, and `icon_url`. + - [x] Validate uploaded icon type and max file size before reading blob. +- [x] Add health/readiness endpoints. + - [x] `/healthz` returns `200` when process is up. + - [x] `/readyz` checks DB query + optional write test and returns `503` on failure. -## P1 +## P1 - Data Model and Backend Quality -- Add drag-and-drop ordering for service cards. - - Persist display order in the database. - - Support moving a card up, down, or to the top in admin. -- Add a featured/pinned flag for important links. - - Keep pinned links above the normal list. - - Let admins toggle pinned status from the edit form. -- Add multi-category support. - - Store categories as a normalized table or join table. - - Allow filtering by more than one category in the dashboard. -- Add duplicate/cloning for existing links. - - Pre-fill a new form from an existing service. - - Keep the original service unchanged. -- Add a password autofill helper for first-run setup. - - Offer a generated strong password suggestion on the setup screen. - - Let the admin copy it or autofill the password fields. -- Add a public read-only mode. - - Hide admin-only links from the dashboard. - - Keep the same UI but remove edit affordances. +- Replace string timestamps with DB-native datetime. + - Migrate `created_at`/`updated_at` columns from `varchar` to `datetime`. + - Use UTC consistently for writes and reads. +- Add display ordering support. + - Add `sort_order` column and stable ordering fallback by `name`. + - Update read query to order by `enabled desc`, `sort_order`, `name`. +- Remove duplicate connection pattern in create flow. + - Use one DB transaction/connection per request path where possible. +- Add backup and restore flow in admin API/UI. + - Download full export. + - Upload validated import with explicit confirmation. + - Add dry-run validation mode before apply. +- Add structured logging. + - Log auth attempts, CRUD actions, and restore events with request IDs. -## P2 +## P2 - UX and Product Improvements -- Add JSON import/export for services. - - Include metadata and icon blobs in the export format. - - Support importing a whole dashboard from a single file. -- Add better icon handling. - - Show initials when no icon exists. - - Allow cropping or centering uploaded icons. -- Add audit history for admin changes. - - Record create, update, delete, and preset actions. - - Show a simple timeline in the admin area. -- Add a compact dashboard mode. - - Reduce card padding and text size. - - Make it easier to scan large lists of links. +- Replace browser `alert()` with inline form errors/toasts. + - Show server errors near submit controls. + - Add success toasts for create/update/delete. +- Remove forced reload in auth forms. + - Replace `location.reload()` with state refresh only. + - Keep SPA navigation predictable on setup/login/logout. +- Add drag-and-drop ordering in admin. + - Persist `sort_order` updates. + - Provide keyboard-accessible move controls as fallback. +- Add duplicate/cloning for links. + - Pre-fill form from an existing link. + - Save as new record with unique name validation. +- Add public read-only mode toggle. + - Hide admin entry points and editing affordances for non-admin view. -## P3 +## P3 - Nice-to-Have -- Add keyboard shortcuts for search and quick launch. -- Add a toast system for save, delete, and upload actions. -- Add Open Graph metadata for better link previews. -- Add structured JSON logging for auth and CRUD events. -- Add a CI verification step that builds the container image after publish. +- Add multi-category support with normalization. +- Add audit history timeline in admin. +- Add JSON import/export for services with icons. +- Add keyboard shortcuts for search/quick launch. +- Add Open Graph metadata and richer SEO tags. +- Add CI verification that builds container image for pull requests. diff --git a/backend/main.py b/backend/main.py index e28cdc6..84a6624 100644 --- a/backend/main.py +++ b/backend/main.py @@ -22,6 +22,7 @@ PUBLIC_DIR = Path("public") SESSION_COOKIE = "jellomator_session" SESSION_TTL_SECONDS = int(os.getenv("SESSION_TTL_SECONDS", "86400")) SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "false").lower() in ("1", "true", "yes", "on") +SESSION_ROTATE_SECONDS = int(os.getenv("SESSION_ROTATE_SECONDS", "3600")) LOGIN_MAX_ATTEMPTS = int(os.getenv("LOGIN_MAX_ATTEMPTS", "5")) LOGIN_WINDOW_SECONDS = int(os.getenv("LOGIN_WINDOW_SECONDS", "300")) LOGIN_LOCKOUT_SECONDS = int(os.getenv("LOGIN_LOCKOUT_SECONDS", "900")) @@ -154,14 +155,14 @@ def expires_at_iso() -> str: return datetime.utcfromtimestamp(now + SESSION_TTL_SECONDS).isoformat() -def current_user(request: Request): +def current_user(request: Request, response: Response | None = None): token = request.cookies.get(SESSION_COOKIE) if not token: return None with db() as c: with c.cursor() as cur: cur.execute( - "select s.expires_at,u.username,u.role from sessions s join users u on u.id=s.user_id where s.token=%s", + "select s.created_at,s.last_seen_at,s.expires_at,u.username,u.role from sessions s join users u on u.id=s.user_id where s.token=%s", (token,), ) row = cur.fetchone() @@ -177,10 +178,37 @@ def current_user(request: Request): except ValueError: cur.execute("delete from sessions where token=%s", (token,)) return None - cur.execute( - "update sessions set last_seen_at=%s where token=%s", - (utc_now_iso(), token), - ) + now = datetime.utcnow() + now_iso = now.isoformat() + new_expires_at = expires_at_iso() + last_seen_at = row.get("last_seen_at") or row.get("created_at") + should_rotate = False + if last_seen_at: + try: + should_rotate = (now - datetime.fromisoformat(last_seen_at)).total_seconds() >= SESSION_ROTATE_SECONDS + except ValueError: + should_rotate = True + if should_rotate: + new_token = secrets.token_urlsafe(32) + cur.execute( + "update sessions set token=%s,last_seen_at=%s,expires_at=%s where token=%s", + (new_token, now_iso, new_expires_at, token), + ) + if response is not None: + response.set_cookie( + SESSION_COOKIE, + new_token, + httponly=True, + samesite="lax", + secure=SESSION_COOKIE_SECURE, + max_age=SESSION_TTL_SECONDS, + path="/", + ) + else: + cur.execute( + "update sessions set last_seen_at=%s,expires_at=%s where token=%s", + (now_iso, new_expires_at, token), + ) return {"username": row["username"], "role": row["role"]} @@ -271,12 +299,13 @@ def read_icon_blob(icon: UploadFile | None) -> tuple[bytes | None, str | None]: @app.get("/api/me") -def me(request: Request): +def me(request: Request, response: Response): + current = current_user(request, response) with db() as c: with c.cursor() as cur: cur.execute("select count(*) as count from users") needs_setup = cur.fetchone()["count"] == 0 - return {"needs_setup": needs_setup, "current_user": current_user(request)} + return {"needs_setup": needs_setup, "current_user": current} @app.post("/api/setup")