From f77b029943f84fe03b9cd469ff646b6e771620a8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 12 Feb 2026 17:49:22 -0500 Subject: [PATCH] Add registration control, admin user management, and security hardening - Registration auto-closes after first user; admin can toggle from /admin/users - Admin user management view with user list and delete - Password confirmation on registration form - Password change in Settings (PUT /api/auth/password) - Session cookie hardening: HttpOnly, SameSite=Lax, optional Secure flag - Startup warning when SECRET_KEY is default - Production deployment docs: reverse proxy, rate limiting, CSP headers - Fix assist prompt to preserve markdown headings in target sections - Simplify prod compose networking Co-Authored-By: Claude Opus 4.6 --- README.md | 20 ++ docker-compose.prod.yml | 4 - frontend/src/components/AppHeader.vue | 1 + frontend/src/router/index.ts | 5 + frontend/src/stores/auth.ts | 4 + frontend/src/types/auth.ts | 1 + frontend/src/views/RegisterView.vue | 162 ++++++--- frontend/src/views/SettingsView.vue | 91 +++++ frontend/src/views/UserManagementView.vue | 386 ++++++++++++++++++++++ src/fabledassistant/app.py | 9 + src/fabledassistant/config.py | 1 + src/fabledassistant/routes/admin.py | 46 +++ src/fabledassistant/routes/auth.py | 28 +- src/fabledassistant/services/assist.py | 3 + src/fabledassistant/services/auth.py | 59 ++++ summary.md | 49 ++- 16 files changed, 809 insertions(+), 60 deletions(-) create mode 100644 frontend/src/views/UserManagementView.vue diff --git a/README.md b/README.md index 722a914..5126ae6 100644 --- a/README.md +++ b/README.md @@ -67,10 +67,30 @@ Configuration is done via environment variables. See `docker-compose.yml` for de | `SECRET_KEY` | (required) | Session signing key | | `OLLAMA_BASE_URL` | `http://ollama:11434` | Ollama API endpoint | | `DEFAULT_MODEL` | `llama3.1` | Default LLM model to auto-pull on startup | +| `SECURE_COOKIES` | `false` | Set to `true` when behind TLS to add `Secure` flag to session cookies | | `LOG_LEVEL` | `INFO` | Logging level | For production deployments, `docker-compose.prod.yml` supports Docker secrets (`SECRET_KEY_FILE`, `DATABASE_URL_FILE`) and includes network isolation, health checks, and resource limits. +## Production Deployment + +### Reverse Proxy (Required) + +Fabled Assistant does **not** handle SSL/TLS. You must run it behind a reverse proxy for production use: + +- **Nginx**, **Traefik**, or **Caddy** in front of the app container +- Terminate TLS at the proxy and forward traffic to port 5000 +- **Do not expose port 5000 directly to the internet** +- **Rate-limit auth endpoints** at the proxy layer — recommended: limit `/api/auth/login` and `/api/auth/register` to ~5 requests/minute per IP to prevent brute-force attacks +- **Set Content Security Policy headers** at the proxy — recommended: `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'` + +### Security Checklist + +- **Set a strong `SECRET_KEY`** — used to sign session cookies. Generate one with `python -c "import secrets; print(secrets.token_hex(32))"` or use Docker secrets via `SECRET_KEY_FILE`. +- **Registration auto-closes** — After the first user registers (who becomes admin), registration is closed by default. The admin can re-enable it from the user management page (`/admin/users`). +- **Use Docker secrets in production** — `docker-compose.prod.yml` reads `SECRET_KEY_FILE` and `DATABASE_URL_FILE` instead of plain environment variables. +- **Keep Ollama on an internal network** — The default compose files keep Ollama on a Docker-internal network, not exposed to the host. + ## Technical Overview ### Stack diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 50d451b..244308c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -10,7 +10,6 @@ services: OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1}" LOG_LEVEL: "${LOG_LEVEL:-INFO}" networks: - - fabledassistant_frontend - fabledassistant_backend healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"] @@ -80,8 +79,5 @@ volumes: ollama_models: networks: - fabledassistant_frontend: - driver: overlay fabledassistant_backend: driver: overlay - internal: true diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 19767ed..1b534be 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -61,6 +61,7 @@ router.afterEach(() => { Tasks Chat Settings + Users diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 6d4d073..d85b5c3 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -77,6 +77,11 @@ const router = createRouter({ name: "settings", component: () => import("@/views/SettingsView.vue"), }, + { + path: "/admin/users", + name: "admin-users", + component: () => import("@/views/UserManagementView.vue"), + }, ], }); diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 49113b4..aa31bf4 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -7,6 +7,7 @@ export const useAuthStore = defineStore("auth", () => { const user = ref(null); const loading = ref(true); const hasUsers = ref(true); + const registrationOpen = ref(false); const isAuthenticated = computed(() => user.value !== null); const isAdmin = computed(() => user.value?.role === "admin"); @@ -26,8 +27,10 @@ export const useAuthStore = defineStore("auth", () => { try { const data = await apiGet("/api/auth/status"); hasUsers.value = data.has_users; + registrationOpen.value = data.registration_open; } catch { hasUsers.value = true; + registrationOpen.value = false; } } @@ -52,6 +55,7 @@ export const useAuthStore = defineStore("auth", () => { user, loading, hasUsers, + registrationOpen, isAuthenticated, isAdmin, checkAuth, diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts index 371ed9a..9488300 100644 --- a/frontend/src/types/auth.ts +++ b/frontend/src/types/auth.ts @@ -8,4 +8,5 @@ export interface User { export interface AuthStatus { has_users: boolean; + registration_open: boolean; } diff --git a/frontend/src/views/RegisterView.vue b/frontend/src/views/RegisterView.vue index 10cb94e..3d71ebe 100644 --- a/frontend/src/views/RegisterView.vue +++ b/frontend/src/views/RegisterView.vue @@ -1,5 +1,5 @@ + + + + diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py index 61dbb53..8e9943b 100644 --- a/src/fabledassistant/app.py +++ b/src/fabledassistant/app.py @@ -26,6 +26,15 @@ def create_app() -> Quart: app = Quart(__name__, static_folder=None) app.secret_key = Config.SECRET_KEY + app.config["SESSION_COOKIE_HTTPONLY"] = True + app.config["SESSION_COOKIE_SAMESITE"] = "Lax" + app.config["SESSION_COOKIE_SECURE"] = Config.SECURE_COOKIES + + if Config.SECRET_KEY == "dev-secret-change-me": + logger.warning( + "SECRET_KEY is set to the default value — session cookies are insecure. " + "Set SECRET_KEY or SECRET_KEY_FILE for production use." + ) app.register_blueprint(admin_bp) app.register_blueprint(api) diff --git a/src/fabledassistant/config.py b/src/fabledassistant/config.py index b250864..d9c5e03 100644 --- a/src/fabledassistant/config.py +++ b/src/fabledassistant/config.py @@ -25,4 +25,5 @@ class Config: OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434") OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "llama3.1") SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me") + SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes") LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO") diff --git a/src/fabledassistant/routes/admin.py b/src/fabledassistant/routes/admin.py index 852b938..f4baec8 100644 --- a/src/fabledassistant/routes/admin.py +++ b/src/fabledassistant/routes/admin.py @@ -3,6 +3,12 @@ import json from quart import Blueprint, Response, jsonify, request from fabledassistant.auth import admin_required, login_required, get_current_user_id +from fabledassistant.services.auth import ( + delete_user, + is_registration_open, + list_users, + set_registration_open, +) from fabledassistant.services.backup import ( export_full_backup, export_user_backup, @@ -45,3 +51,43 @@ async def restore(): stats = await restore_full_backup(data) return jsonify({"status": "ok", "stats": stats}) + + +@admin_bp.route("/users", methods=["GET"]) +@admin_required +async def get_users(): + users = await list_users() + return jsonify({"users": [u.to_dict() for u in users]}) + + +@admin_bp.route("/users/", methods=["DELETE"]) +@admin_required +async def remove_user(user_id: int): + current_uid = get_current_user_id() + if user_id == current_uid: + return jsonify({"error": "Cannot delete your own account"}), 400 + + deleted = await delete_user(user_id) + if not deleted: + return jsonify({"error": "User not found"}), 404 + return jsonify({"status": "ok"}) + + +@admin_bp.route("/registration", methods=["GET"]) +@admin_required +async def get_registration(): + open_status = await is_registration_open() + return jsonify({"open": open_status}) + + +@admin_bp.route("/registration", methods=["PUT"]) +@admin_required +async def toggle_registration(): + data = await request.get_json() + open_val = data.get("open") + if open_val is None: + return jsonify({"error": "Missing 'open' field"}), 400 + + uid = get_current_user_id() + await set_registration_open(uid, bool(open_val)) + return jsonify({"status": "ok", "open": bool(open_val)}) diff --git a/src/fabledassistant/routes/auth.py b/src/fabledassistant/routes/auth.py index b297296..17e51cf 100644 --- a/src/fabledassistant/routes/auth.py +++ b/src/fabledassistant/routes/auth.py @@ -3,9 +3,11 @@ from quart import Blueprint, jsonify, request, session from fabledassistant.auth import login_required, get_current_user_id from fabledassistant.services.auth import ( authenticate, + change_password, create_user, get_user_by_id, get_user_count, + is_registration_open, ) auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth") @@ -13,6 +15,9 @@ auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth") @auth_bp.route("/register", methods=["POST"]) async def register(): + if not await is_registration_open(): + return jsonify({"error": "Registration is closed"}), 403 + data = await request.get_json() username = (data.get("username") or "").strip() password = data.get("password") or "" @@ -64,7 +69,28 @@ async def me(): return jsonify(user.to_dict()) +@auth_bp.route("/password", methods=["PUT"]) +@login_required +async def update_password(): + data = await request.get_json() + current = data.get("current_password") or "" + new_pw = data.get("new_password") or "" + + if not current: + return jsonify({"error": "Current password is required"}), 400 + if len(new_pw) < 8: + return jsonify({"error": "New password must be at least 8 characters"}), 400 + + uid = get_current_user_id() + success = await change_password(uid, current, new_pw) + if not success: + return jsonify({"error": "Current password is incorrect"}), 403 + + return jsonify({"status": "ok"}) + + @auth_bp.route("/status", methods=["GET"]) async def status(): count = await get_user_count() - return jsonify({"has_users": count > 0}) + reg_open = await is_registration_open() + return jsonify({"has_users": count > 0, "registration_open": reg_open}) diff --git a/src/fabledassistant/services/assist.py b/src/fabledassistant/services/assist.py index 853f7bd..20735b9 100644 --- a/src/fabledassistant/services/assist.py +++ b/src/fabledassistant/services/assist.py @@ -20,6 +20,9 @@ def build_assist_messages( f"--- Full Document ---\n{truncated_body}\n--- End Document ---\n\n" "The user will give you a specific section of the document and an instruction. " "Output ONLY the replacement text for that section. " + "If the target section starts with a markdown heading (e.g. ## Heading), " + "your output MUST also start with a heading at the same level. " + "You may revise the heading text but do not remove it. " "Do not include other sections, explanatory text, or markdown code fences around the output. " "Match the document's existing tone and style." ) diff --git a/src/fabledassistant/services/auth.py b/src/fabledassistant/services/auth.py index 58bd638..0616a98 100644 --- a/src/fabledassistant/services/auth.py +++ b/src/fabledassistant/services/auth.py @@ -63,6 +63,8 @@ async def create_user( .where(Setting.user_id == user.id) .values(user_id=user.id) ) + # Auto-close registration after first user setup + session.add(Setting(user_id=user.id, key="registration_open", value="false")) await session.commit() logger.info("First user '%s' created as admin, claimed orphaned data", username) @@ -83,3 +85,60 @@ async def authenticate(username: str, password: str) -> User | None: async def get_user_by_id(user_id: int) -> User | None: async with async_session() as session: return await session.get(User, user_id) + + +async def change_password(user_id: int, current_password: str, new_password: str) -> bool: + """Change a user's password. Returns True on success, False if current password is wrong.""" + async with async_session() as session: + user = await session.get(User, user_id) + if not user: + return False + if not verify_password(current_password, user.password_hash): + return False + user.password_hash = hash_password(new_password) + await session.commit() + logger.info("Password changed for user %d (%s)", user_id, user.username) + return True + + +async def is_registration_open() -> bool: + """Check if new user registration is allowed. + + Always open when no users exist (first-user setup). + Otherwise reads the admin's 'registration_open' setting (default closed). + """ + user_count = await get_user_count() + if user_count == 0: + return True + + async with async_session() as session: + # Find the admin user's registration_open setting + result = await session.execute( + select(Setting) + .join(User, Setting.user_id == User.id) + .where(User.role == "admin", Setting.key == "registration_open") + ) + setting = result.scalar_one_or_none() + return setting.value == "true" if setting else False + + +async def list_users() -> list[User]: + async with async_session() as session: + result = await session.execute(select(User).order_by(User.created_at)) + return list(result.scalars().all()) + + +async def delete_user(user_id: int) -> bool: + async with async_session() as session: + user = await session.get(User, user_id) + if not user: + return False + await session.delete(user) + await session.commit() + logger.info("Deleted user %d (%s)", user_id, user.username) + return True + + +async def set_registration_open(admin_user_id: int, open: bool) -> None: + from fabledassistant.services.settings import set_setting + await set_setting(admin_user_id, "registration_open", "true" if open else "false") diff --git a/summary.md b/summary.md index b6c9bfa..808716e 100644 --- a/summary.md +++ b/summary.md @@ -12,7 +12,7 @@ > Include file-level details in the commit body when the change is non-trivial. ## Last Updated -2026-02-11 — Phase 5.2: Tiptap Inline-Preview Editor + Layout Improvements +2026-02-12 — Phase 5.3: Registration Control, User Management, Security Hardening ## Project Overview Fabled Assistant is a self-hosted note-taking and task-tracking application with @@ -76,6 +76,12 @@ for AI-assisted features. `build_context()` returns `(messages, context_meta)` tuple; metadata includes auto-found note IDs/titles sent to frontend via SSE `context` event before streaming. Multi-word search splits terms into per-word ILIKE with AND logic (not adjacent match). +- **Reverse proxy required for production:** The app does not terminate TLS. A + reverse proxy (Nginx/Traefik/Caddy) must sit in front of port 5000. Do not + expose the app directly to the internet. +- **Registration auto-closes:** After the first user registers (admin), registration + is closed by default. The admin can toggle it from `/admin/users`. The setting is + stored as the admin user's `registration_open` setting key. - **No blocking long-running operations:** Any potentially slow operation (model pulls, LLM calls, URL fetching, etc.) must never block app startup or freeze the UI. Backend uses SSE streaming for incremental responses (chat messages and model @@ -91,6 +97,10 @@ for AI-assisted features. `CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and `DO $$ BEGIN CREATE TYPE ... EXCEPTION WHEN duplicate_object` to allow safe re-runs and fresh database creation. +- **Session cookie security:** `HttpOnly` and `SameSite=Lax` always set. `Secure` + flag controlled by `SECURE_COOKIES` env var (enable when behind TLS). +- **Default SECRET_KEY warning:** App logs a WARNING on startup if the default + `dev-secret-change-me` key is in use. ### High-Level Component Diagram ``` @@ -199,7 +209,7 @@ fabledassistant/ │ ├── __init__.py │ ├── app.py # Quart app factory: SPA via 404 handler, JSON 404/500 for API, request logging middleware │ ├── auth.py # Auth decorators: login_required, admin_required, get_current_user_id -│ ├── config.py # Config from env vars + Docker secrets file support (_read_secret) +│ ├── config.py # Config from env vars + Docker secrets file support (_read_secret) + SECURE_COOKIES flag │ ├── models/ │ │ ├── __init__.py # async_session factory, Base, imports all models │ │ ├── user.py # User model (id, username, email, password_hash, role, created_at) @@ -210,13 +220,13 @@ fabledassistant/ │ │ ├── __init__.py │ │ ├── api.py # /api blueprint with /health endpoint (public) │ │ ├── auth.py # /api/auth blueprint: register, login, logout, me, status -│ │ ├── admin.py # /api/admin blueprint: backup (user/full), restore (admin only) +│ │ ├── admin.py # /api/admin blueprint: backup, restore, user management, registration toggle (admin only) │ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming (all @login_required) │ │ ├── notes.py # /api/notes CRUD + wikilinks + backlinks (all @login_required) │ │ ├── tasks.py # /api/tasks CRUD + PATCH status (all @login_required) │ │ └── settings.py # /api/settings GET/PUT — per-user settings (@login_required) │ ├── services/ -│ │ ├── auth.py # Auth business logic: hash_password, verify_password, create_user, authenticate, get_user_by_id +│ │ ├── auth.py # Auth business logic: hash_password, verify_password, create_user, authenticate, get_user_by_id, is_registration_open, list_users, delete_user, set_registration_open │ │ ├── backup.py # Backup/restore: export_full_backup, export_user_backup, restore_full_backup │ │ ├── notes.py # CRUD with user_id isolation, is_task filter, convert, backlinks, search_notes_for_context │ │ ├── llm.py # Ollama interaction: build_context with user_id, streaming, URL fetching @@ -268,7 +278,8 @@ fabledassistant/ │ │ └── markdownSerializer.ts # Tiptap JSON → markdown serializer: handles all StarterKit nodes + marks │ ├── views/ │ │ ├── LoginView.vue # Login form with error display, link to register - │ │ ├── RegisterView.vue # Register form (username, password, email), first-user setup + │ │ ├── RegisterView.vue # Register form with password confirmation; shows "closed" message when registration disabled + │ │ ├── UserManagementView.vue # Admin user management: registration toggle, user list with delete │ │ ├── ChatView.vue # Dedicated /chat page: responsive sidebar (overlay on mobile), bubble messages, note picker, context pills │ │ ├── HomeView.vue # Landing page: recent notes + tasks + recent chats │ │ ├── SettingsView.vue # Settings page: assistant name, model catalog, data export/restore (admin) @@ -294,7 +305,7 @@ fabledassistant/ │ │ ├── PaginationBar.vue # Prev/next + page numbers │ │ └── ToastNotification.vue # Fixed-position toast container with close button, warning support │ └── router/ - │ └── index.ts # Routes: /, /login, /register, /notes/*, /tasks/*, /chat, /chat/:id, /settings; beforeEach auth guard + │ └── index.ts # Routes: /, /login, /register, /notes/*, /tasks/*, /chat, /chat/:id, /settings, /admin/users; beforeEach auth guard └── public/ ``` @@ -303,13 +314,18 @@ fabledassistant/ | Method | Path | Description | |--------|------|-------------| | GET | `/api/health` | Health check (public) | -| GET | `/api/auth/status` | Check if any users exist (public) | -| POST | `/api/auth/register` | Register new user (first user becomes admin) | +| GET | `/api/auth/status` | Check if any users exist + registration open status (public) | +| POST | `/api/auth/register` | Register new user (first user becomes admin; returns 403 if registration closed) | | POST | `/api/auth/login` | Login with username/password | | POST | `/api/auth/logout` | Logout (clear session) | | GET | `/api/auth/me` | Get current user info | +| PUT | `/api/auth/password` | Change password (body: `{current_password, new_password}`) | | GET | `/api/admin/backup` | Export backup (`?scope=user` for own data, full requires admin) | | POST | `/api/admin/restore` | Restore from JSON backup (admin only) | +| GET | `/api/admin/users` | List all users (admin only) | +| DELETE | `/api/admin/users/:id` | Delete a user (admin only, cannot delete self) | +| GET | `/api/admin/registration` | Get registration open/closed status (admin only) | +| PUT | `/api/admin/registration` | Toggle registration open/closed (admin only, body: `{open: bool}`) | | GET | `/api/notes` | List notes (params: `q`, `tag`, `sort`, `order`, `limit`, `offset`; defaults to `is_task=false` — plain notes only; `?is_task=true` for tasks, `?all=true` for everything) | | POST | `/api/notes` | Create note (body: `{title, body, status?, priority?, due_date?}` — tags auto-extracted) | | GET | `/api/notes/tags` | List all tags from notes table (param: `q` for filter) | @@ -583,6 +599,16 @@ When adding a new migration, follow these conventions: - [x] **Accept trailing newline:** Accepted AI suggestions always end with a newline for clean separation - [x] **Wider content area:** Max-width increased from 720px to 960px across all views +### Phase 5.3 — Registration Control, User Management, Security Hardening ✓ +- [x] **Registration auto-closes:** After the first user registers (admin), registration is closed by default +- [x] **Registration toggle:** Admin can open/close registration from `/admin/users` +- [x] **Password confirmation:** Register form requires password confirmation with inline validation +- [x] **Admin user management:** `/admin/users` view with user list and delete (admin only) +- [x] **Session cookie hardening:** `HttpOnly`, `SameSite=Lax` always on; `Secure` via `SECURE_COOKIES` env var +- [x] **Default SECRET_KEY warning:** Logs WARNING on startup if default key is in use +- [x] **Password change:** `PUT /api/auth/password` endpoint + Settings UI section +- [x] **Production deployment docs:** README documents reverse proxy, rate limiting, CSP headers, SECRET_KEY, registration behavior + ### Future / Stretch - Tagging/labeling system with LLM-suggested tags - Calendar/timeline view for tasks @@ -598,7 +624,7 @@ When adding a new migration, follow these conventions: - To reset database: `docker compose down -v && docker compose up --build` ## Current Status -**Phase:** Phase 5.2 complete. Tiptap Inline-Preview Editor + Layout Improvements. +**Phase:** Phase 5.3 complete. Registration Control, User Management, Security Hardening. - Full note-taking and task-tracking CRUD with markdown, wikilinks, backlinks, tags - **Tiptap WYSIWYG editor** with inline formatting preview, markdown round-trip, paste handling - **Tag/wikilink autocomplete** via `@tiptap/suggestion` with heading disambiguation @@ -611,7 +637,10 @@ When adding a new migration, follow these conventions: - Context pills with promote/exclude controls; note picker in chat input - Save assistant messages as notes (LLM-titled, chat-tagged); summarize conversations as notes - Dedicated `/chat` page with responsive sidebar (overlay on mobile) -- Settings page: assistant name, model catalog, data export/restore (admin) +- Settings page: assistant name, model catalog, password change, data export/restore (admin) +- **Registration control**: auto-closes after first user, admin toggle, password confirmation +- **Admin user management**: `/admin/users` with user list + delete +- **Session cookie hardening**: HttpOnly, SameSite=Lax, optional Secure flag - **App-wide layout fix**: navbar always visible, all views fit within viewport - **Responsive design**: hamburger menu, mobile touch targets, responsive breakpoints - **Docker Swarm production stack** with secrets, network isolation, health checks, resource limits