diff --git a/README.md b/README.md index a1af264..01d27ac 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ # UMBRA -A self-hosted personal life administration app with a dark-themed UI. Manage your todos, calendar events, projects, reminders, contacts, and locations from a single dashboard. +A self-hosted, multi-user life administration app with a dark-themed UI and role-based access control. Manage your todos, calendar events, projects, reminders, contacts, and locations from a single dashboard. ## Features +- **Multi-user RBAC** - Admin and standard user roles, per-user data isolation, admin portal with IAM, system config, and audit logs - **Dashboard** - Contextual greeting, week timeline, stat cards, upcoming events, weather widget, day briefing - **Todos** - Task management with priorities, due dates, recurrence, and grouped sections (overdue/today/upcoming) - **Calendar** - Multi-calendar system with month/week/day views, recurring events, drag-and-drop, event templates @@ -12,8 +13,9 @@ A self-hosted personal life administration app with a dark-themed UI. Manage you - **People** - Contact directory with avatar initials, favourites, birthday tracking, category filtering - **Locations** - Location management with OSM search integration, category filtering, frequent locations - **Weather** - Dashboard weather widget with temperature, conditions, and contextual rain warnings -- **Settings** - Accent color picker (5 presets), first day of week, weather city, ntfy push notifications, TOTP two-factor auth, auto-lock, password management +- **Settings** - Accent color picker (8 presets), first day of week, weather city, ntfy push notifications, TOTP two-factor auth, auto-lock, password management - **Notifications** - ntfy push notifications for reminders (configurable per-user) +- **Admin Portal** - User management (create, delete, activate/deactivate, role assignment, password reset), system configuration (open registration, MFA enforcement), audit log viewer ## Tech Stack @@ -24,8 +26,8 @@ A self-hosted personal life administration app with a dark-themed UI. Manage you | Fonts | Sora (headings), DM Sans (body) via Google Fonts | | State | TanStack Query v5, React Router v6 | | Backend | FastAPI, Python 3.12, Pydantic v2 | -| Database | PostgreSQL 16, SQLAlchemy 2.0 (async), Alembic (25 migrations) | -| Auth | Username/password with Argon2id hashing, DB-backed sessions (signed cookies), optional TOTP MFA | +| Database | PostgreSQL 16, SQLAlchemy 2.0 (async), Alembic (37 migrations) | +| Auth | Argon2id hashing, DB-backed sessions (signed httpOnly cookies), TOTP MFA, CSRF middleware, role-based access control | | Scheduler | APScheduler (async) for ntfy notification dispatch | | Deployment | Docker Compose (3 services), Nginx reverse proxy | @@ -66,7 +68,7 @@ A self-hosted personal life administration app with a dark-themed UI. Manage you 4. **Open the app** - Navigate to `http://localhost` in your browser. On first launch you'll be prompted to create a username and password. + Navigate to `http://localhost` in your browser. On first launch you'll be prompted to create an admin account. ## Architecture @@ -108,18 +110,25 @@ A self-hosted personal life administration app with a dark-themed UI. Manage you ### Hardened by default +- **Multi-user data isolation** — all resources scoped by `user_id` with per-query filtering; pentest-verified (51+ test cases, 0 exploitable IDOR findings) +- **Role-based access control** — `admin` and `standard` roles with `require_admin` dependency on all admin endpoints +- **CSRF protection** — global `CSRFHeaderMiddleware` requires `X-Requested-With: XMLHttpRequest` on all mutating requests +- **Input validation** — `extra="forbid"` on all Pydantic schemas prevents mass-assignment; `max_length` on all string fields; `ge=1, le=2147483647` on path IDs - **Non-root containers** — both backend (`appuser:1000`) and frontend (`nginx-unprivileged`) run as non-root - **No external backend port** — port 8000 is internal-only; all traffic flows through nginx - **Server version suppression** — `server_tokens off` (nginx) and `--no-server-header` (uvicorn) - **Rate limiting** — nginx `limit_req_zone` (10 req/min) on `/api/auth/login` (burst=5), `/verify-password` (burst=5), `/change-password` (burst=5), `/totp-verify` (burst=5), `/setup` (burst=3) - **DB-backed account lockout** — 10 failed attempts triggers 30-minute lock per account +- **Inactive user blocking** — disabled accounts rejected at login (HTTP 403) without session creation, lockout reset, or last_login_at update +- **Timing-safe login** — dummy Argon2id hash for non-existent users prevents username enumeration +- **Password reuse prevention** — change-password endpoint rejects same password as old - **Dotfile blocking** — `/.env`, `/.git/config`, etc. return 404 (`.well-known` preserved for ACME) - **CSP headers** — Content-Security-Policy on all responses, scoped for Google Fonts - **CORS** — configurable origins with explicit method/header allowlists - **API docs disabled in production** — Swagger/ReDoc/OpenAPI only available when `ENVIRONMENT=development` - **Argon2id password hashing** with transparent bcrypt migration on first login -- **DB-backed sessions** — revocable, with signed itsdangerous cookies -- **Optional TOTP MFA** — authenticator app support with backup codes +- **DB-backed sessions** — revocable, with signed itsdangerous httpOnly cookies, 7-day sliding window with 30-day hard ceiling +- **Optional TOTP MFA** — authenticator app support with backup codes, admin-enforced MFA for new users ### Production Hardening @@ -150,12 +159,13 @@ Additionally for production: ## API Overview -All endpoints require authentication (signed session cookie) except auth routes and the health check. +All endpoints require authentication (signed session cookie) except auth routes and the health check. Admin endpoints require the `admin` role. | Endpoint | Description | |-----------------------|-------------| | `GET /health` | Health check | -| `/api/auth/*` | Login, logout, setup, status, password change, TOTP MFA | +| `/api/auth/*` | Login, logout, setup, register, status, password change, TOTP MFA | +| `/api/admin/*` | User management, system config, audit logs (admin only) | | `/api/todos/*` | Todos CRUD + toggle completion | | `/api/events/*` | Calendar events CRUD (incl. recurring) | | `/api/event-templates/*` | Event templates CRUD | @@ -164,7 +174,7 @@ All endpoints require authentication (signed session cookie) except auth routes | `/api/projects/*` | Projects + nested tasks + comments CRUD | | `/api/people/*` | People CRUD | | `/api/locations/*` | Locations CRUD + search | -| `/api/settings/*` | App settings + password change + ntfy config | +| `/api/settings/*` | App settings + ntfy config | | `/api/dashboard` | Dashboard aggregation | | `/api/upcoming` | Unified upcoming items feed | | `/api/weather/*` | Weather data proxy | @@ -209,15 +219,15 @@ umbra/ │ ├── Dockerfile │ ├── requirements.txt │ ├── alembic.ini -│ ├── alembic/versions/ # 25 migrations (001–025) +│ ├── alembic/versions/ # 37 migrations (001–037) │ └── app/ -│ ├── main.py # FastAPI app, router registration, health endpoint +│ ├── main.py # FastAPI app, CSRF middleware, router registration, health endpoint │ ├── config.py # Pydantic BaseSettings (DATABASE_URL, SECRET_KEY, CORS, etc.) │ ├── database.py # Async SQLAlchemy engine + session factory -│ ├── models/ # 17 SQLAlchemy ORM models -│ ├── schemas/ # Pydantic v2 request/response schemas -│ ├── routers/ # 14 API route handlers -│ ├── services/ # Auth (Argon2id), recurrence, TOTP, ntfy +│ ├── models/ # 18 SQLAlchemy ORM models (incl. User, UserSession, SystemConfig, AuditLog) +│ ├── schemas/ # 13 Pydantic v2 request/response schema modules (incl. admin) +│ ├── routers/ # 14 API route handlers (incl. auth, admin, totp) +│ ├── services/ # Auth (Argon2id), recurrence, TOTP, ntfy, audit │ └── jobs/ # APScheduler notification dispatch └── frontend/ ├── Dockerfile @@ -225,15 +235,16 @@ umbra/ ├── proxy-params.conf # Shared proxy settings (DRY include) ├── package.json └── src/ - ├── App.tsx # Routes and auth guard - ├── lib/ # api.ts, date-utils.ts, utils.ts - ├── hooks/ # useAuth, useSettings, useTheme, useCalendars, useConfirmAction, useCategoryOrder, useTableVisibility + ├── App.tsx # Routes, ProtectedRoute, AdminRoute auth guards + ├── lib/ # api.ts (axios + 401 interceptor), date-utils.ts, utils.ts + ├── hooks/ # useAuth, useAdmin, useSettings, useTheme, useCalendars, useConfirmAction, useCategoryOrder, useTableVisibility ├── types/ # TypeScript interfaces └── components/ - ├── ui/ # 18 base components (Button, Dialog, Sheet, Card, Input, Select, Switch, etc.) + ├── ui/ # 17 base components (Button, Dialog, Sheet, Card, Input, Select, Switch, etc.) ├── shared/ # EntityTable, EntityDetailPanel, CategoryFilterBar, CategoryAutocomplete, CopyableField ├── layout/ # AppLayout, Sidebar, LockOverlay ├── auth/ # LockScreen, AmbientBackground + ├── admin/ # AdminPortal, IAMPage, ConfigPage, AdminDashboardPage, CreateUserDialog, UserActionsMenu, UserDetailSection ├── dashboard/ # DashboardPage + 8 widgets ├── calendar/ # CalendarPage, CalendarSidebar, CalendarForm, EventForm, TemplateForm ├── todos/ # TodosPage, TodoList, TodoItem, TodoForm diff --git a/backend/alembic/versions/026_add_user_role_and_system_config.py b/backend/alembic/versions/026_add_user_role_and_system_config.py new file mode 100644 index 0000000..63b11f4 --- /dev/null +++ b/backend/alembic/versions/026_add_user_role_and_system_config.py @@ -0,0 +1,101 @@ +"""Add role, mfa_enforce_pending, must_change_password to users; create system_config table. + +Revision ID: 026 +Revises: 025 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "026" +down_revision = "025" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. Add role column with server_default for existing rows + op.add_column("users", sa.Column( + "role", sa.String(30), nullable=False, server_default="standard" + )) + + # 2. Add MFA enforcement pending flag + op.add_column("users", sa.Column( + "mfa_enforce_pending", sa.Boolean(), nullable=False, server_default="false" + )) + + # 3. Add forced password change flag (SEC-12) + op.add_column("users", sa.Column( + "must_change_password", sa.Boolean(), nullable=False, server_default="false" + )) + + # 4. Add last_password_change_at audit column + op.add_column("users", sa.Column( + "last_password_change_at", sa.DateTime(), nullable=True + )) + + # 5. Add CHECK constraint on role values (SEC-16) + op.create_check_constraint( + "ck_users_role", + "users", + "role IN ('admin', 'standard', 'public_event_manager')" + ) + + # 6. Promote the first (existing) user to admin + op.execute( + "UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users)" + ) + + # 7. Create system_config table (singleton pattern -- always id=1) + op.create_table( + "system_config", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("allow_registration", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("enforce_mfa_new_users", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("NOW()")), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("NOW()")), + sa.PrimaryKeyConstraint("id"), + # SEC-09: Enforce singleton row + sa.CheckConstraint("id = 1", name="ck_system_config_singleton"), + ) + + # 8. Seed the singleton row + op.execute( + "INSERT INTO system_config (id, allow_registration, enforce_mfa_new_users) " + "VALUES (1, false, false)" + ) + + # 9. Create audit_log table + op.create_table( + "audit_log", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("actor_user_id", sa.Integer(), nullable=True), + sa.Column("target_user_id", sa.Integer(), nullable=True), + sa.Column("action", sa.String(100), nullable=False), + sa.Column("detail", sa.Text(), nullable=True), + sa.Column("ip_address", sa.String(45), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("NOW()")), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint( + ["target_user_id"], ["users.id"], ondelete="SET NULL" + ), + ) + op.create_index("ix_audit_log_actor_user_id", "audit_log", ["actor_user_id"]) + op.create_index("ix_audit_log_target_user_id", "audit_log", ["target_user_id"]) + op.create_index("ix_audit_log_action", "audit_log", ["action"]) + op.create_index("ix_audit_log_created_at", "audit_log", ["created_at"]) + + +def downgrade() -> None: + op.drop_index("ix_audit_log_created_at", table_name="audit_log") + op.drop_index("ix_audit_log_action", table_name="audit_log") + op.drop_index("ix_audit_log_target_user_id", table_name="audit_log") + op.drop_index("ix_audit_log_actor_user_id", table_name="audit_log") + op.drop_table("audit_log") + op.drop_table("system_config") + op.drop_constraint("ck_users_role", "users", type_="check") + op.drop_column("users", "last_password_change_at") + op.drop_column("users", "must_change_password") + op.drop_column("users", "mfa_enforce_pending") + op.drop_column("users", "role") diff --git a/backend/alembic/versions/027_add_user_id_to_todos.py b/backend/alembic/versions/027_add_user_id_to_todos.py new file mode 100644 index 0000000..4a60855 --- /dev/null +++ b/backend/alembic/versions/027_add_user_id_to_todos.py @@ -0,0 +1,38 @@ +"""Add user_id FK to todos table. + +Revision ID: 027 +Revises: 026 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "027" +down_revision = "026" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("todos", sa.Column("user_id", sa.Integer(), nullable=True)) + op.execute( + "UPDATE todos SET user_id = (" + " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ")" + ) + op.create_foreign_key( + "fk_todos_user_id", "todos", "users", + ["user_id"], ["id"], ondelete="CASCADE" + ) + op.alter_column("todos", "user_id", nullable=False) + op.create_index("ix_todos_user_id", "todos", ["user_id"]) + op.create_index("ix_todos_user_completed", "todos", ["user_id", "completed"]) + op.create_index("ix_todos_user_due_date", "todos", ["user_id", "due_date"]) + + +def downgrade() -> None: + op.drop_index("ix_todos_user_due_date", table_name="todos") + op.drop_index("ix_todos_user_completed", table_name="todos") + op.drop_index("ix_todos_user_id", table_name="todos") + op.drop_constraint("fk_todos_user_id", "todos", type_="foreignkey") + op.drop_column("todos", "user_id") diff --git a/backend/alembic/versions/028_add_user_id_to_reminders.py b/backend/alembic/versions/028_add_user_id_to_reminders.py new file mode 100644 index 0000000..75a2dbb --- /dev/null +++ b/backend/alembic/versions/028_add_user_id_to_reminders.py @@ -0,0 +1,36 @@ +"""Add user_id FK to reminders table. + +Revision ID: 028 +Revises: 027 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "028" +down_revision = "027" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("reminders", sa.Column("user_id", sa.Integer(), nullable=True)) + op.execute( + "UPDATE reminders SET user_id = (" + " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ")" + ) + op.create_foreign_key( + "fk_reminders_user_id", "reminders", "users", + ["user_id"], ["id"], ondelete="CASCADE" + ) + op.alter_column("reminders", "user_id", nullable=False) + op.create_index("ix_reminders_user_id", "reminders", ["user_id"]) + op.create_index("ix_reminders_user_remind_at", "reminders", ["user_id", "remind_at"]) + + +def downgrade() -> None: + op.drop_index("ix_reminders_user_remind_at", table_name="reminders") + op.drop_index("ix_reminders_user_id", table_name="reminders") + op.drop_constraint("fk_reminders_user_id", "reminders", type_="foreignkey") + op.drop_column("reminders", "user_id") diff --git a/backend/alembic/versions/029_add_user_id_to_projects.py b/backend/alembic/versions/029_add_user_id_to_projects.py new file mode 100644 index 0000000..f5f9031 --- /dev/null +++ b/backend/alembic/versions/029_add_user_id_to_projects.py @@ -0,0 +1,36 @@ +"""Add user_id FK to projects table. + +Revision ID: 029 +Revises: 028 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "029" +down_revision = "028" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("projects", sa.Column("user_id", sa.Integer(), nullable=True)) + op.execute( + "UPDATE projects SET user_id = (" + " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ")" + ) + op.create_foreign_key( + "fk_projects_user_id", "projects", "users", + ["user_id"], ["id"], ondelete="CASCADE" + ) + op.alter_column("projects", "user_id", nullable=False) + op.create_index("ix_projects_user_id", "projects", ["user_id"]) + op.create_index("ix_projects_user_status", "projects", ["user_id", "status"]) + + +def downgrade() -> None: + op.drop_index("ix_projects_user_status", table_name="projects") + op.drop_index("ix_projects_user_id", table_name="projects") + op.drop_constraint("fk_projects_user_id", "projects", type_="foreignkey") + op.drop_column("projects", "user_id") diff --git a/backend/alembic/versions/030_add_user_id_to_calendars.py b/backend/alembic/versions/030_add_user_id_to_calendars.py new file mode 100644 index 0000000..23aafe1 --- /dev/null +++ b/backend/alembic/versions/030_add_user_id_to_calendars.py @@ -0,0 +1,40 @@ +"""Add user_id FK to calendars table. + +Revision ID: 030 +Revises: 029 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "030" +down_revision = "029" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("calendars", sa.Column("user_id", sa.Integer(), nullable=True)) + # Backfill existing calendars to first admin user + op.execute( + "UPDATE calendars SET user_id = (" + " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ")" + ) + # On fresh installs no users exist yet, so seeded calendars still have + # NULL user_id. Remove them — account setup will recreate defaults. + op.execute("DELETE FROM calendars WHERE user_id IS NULL") + op.create_foreign_key( + "fk_calendars_user_id", "calendars", "users", + ["user_id"], ["id"], ondelete="CASCADE" + ) + op.alter_column("calendars", "user_id", nullable=False) + op.create_index("ix_calendars_user_id", "calendars", ["user_id"]) + op.create_index("ix_calendars_user_default", "calendars", ["user_id", "is_default"]) + + +def downgrade() -> None: + op.drop_index("ix_calendars_user_default", table_name="calendars") + op.drop_index("ix_calendars_user_id", table_name="calendars") + op.drop_constraint("fk_calendars_user_id", "calendars", type_="foreignkey") + op.drop_column("calendars", "user_id") diff --git a/backend/alembic/versions/031_add_user_id_to_people.py b/backend/alembic/versions/031_add_user_id_to_people.py new file mode 100644 index 0000000..60ee2bc --- /dev/null +++ b/backend/alembic/versions/031_add_user_id_to_people.py @@ -0,0 +1,36 @@ +"""Add user_id FK to people table. + +Revision ID: 031 +Revises: 030 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "031" +down_revision = "030" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("people", sa.Column("user_id", sa.Integer(), nullable=True)) + op.execute( + "UPDATE people SET user_id = (" + " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ")" + ) + op.create_foreign_key( + "fk_people_user_id", "people", "users", + ["user_id"], ["id"], ondelete="CASCADE" + ) + op.alter_column("people", "user_id", nullable=False) + op.create_index("ix_people_user_id", "people", ["user_id"]) + op.create_index("ix_people_user_name", "people", ["user_id", "name"]) + + +def downgrade() -> None: + op.drop_index("ix_people_user_name", table_name="people") + op.drop_index("ix_people_user_id", table_name="people") + op.drop_constraint("fk_people_user_id", "people", type_="foreignkey") + op.drop_column("people", "user_id") diff --git a/backend/alembic/versions/032_add_user_id_to_locations.py b/backend/alembic/versions/032_add_user_id_to_locations.py new file mode 100644 index 0000000..996a207 --- /dev/null +++ b/backend/alembic/versions/032_add_user_id_to_locations.py @@ -0,0 +1,34 @@ +"""Add user_id FK to locations table. + +Revision ID: 032 +Revises: 031 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "032" +down_revision = "031" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("locations", sa.Column("user_id", sa.Integer(), nullable=True)) + op.execute( + "UPDATE locations SET user_id = (" + " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ")" + ) + op.create_foreign_key( + "fk_locations_user_id", "locations", "users", + ["user_id"], ["id"], ondelete="CASCADE" + ) + op.alter_column("locations", "user_id", nullable=False) + op.create_index("ix_locations_user_id", "locations", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_locations_user_id", table_name="locations") + op.drop_constraint("fk_locations_user_id", "locations", type_="foreignkey") + op.drop_column("locations", "user_id") diff --git a/backend/alembic/versions/033_add_user_id_to_event_templates.py b/backend/alembic/versions/033_add_user_id_to_event_templates.py new file mode 100644 index 0000000..5ca90a4 --- /dev/null +++ b/backend/alembic/versions/033_add_user_id_to_event_templates.py @@ -0,0 +1,34 @@ +"""Add user_id FK to event_templates table. + +Revision ID: 033 +Revises: 032 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "033" +down_revision = "032" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("event_templates", sa.Column("user_id", sa.Integer(), nullable=True)) + op.execute( + "UPDATE event_templates SET user_id = (" + " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ")" + ) + op.create_foreign_key( + "fk_event_templates_user_id", "event_templates", "users", + ["user_id"], ["id"], ondelete="CASCADE" + ) + op.alter_column("event_templates", "user_id", nullable=False) + op.create_index("ix_event_templates_user_id", "event_templates", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_event_templates_user_id", table_name="event_templates") + op.drop_constraint("fk_event_templates_user_id", "event_templates", type_="foreignkey") + op.drop_column("event_templates", "user_id") diff --git a/backend/alembic/versions/034_add_user_id_to_ntfy_sent.py b/backend/alembic/versions/034_add_user_id_to_ntfy_sent.py new file mode 100644 index 0000000..e40e66d --- /dev/null +++ b/backend/alembic/versions/034_add_user_id_to_ntfy_sent.py @@ -0,0 +1,50 @@ +"""Add user_id FK to ntfy_sent table, rebuild unique constraint as composite. + +Revision ID: 034 +Revises: 033 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "034" +down_revision = "033" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("ntfy_sent", sa.Column("user_id", sa.Integer(), nullable=True)) + op.execute( + "UPDATE ntfy_sent SET user_id = (" + " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ")" + ) + op.create_foreign_key( + "fk_ntfy_sent_user_id", "ntfy_sent", "users", + ["user_id"], ["id"], ondelete="CASCADE" + ) + # On fresh DB ntfy_sent may be empty — clean up NULLs just in case + op.execute("DELETE FROM ntfy_sent WHERE user_id IS NULL") + op.alter_column("ntfy_sent", "user_id", nullable=False) + + # Migration 022 created a unique INDEX (ix_ntfy_sent_notification_key), not a + # named unique CONSTRAINT. Drop the index; the new composite unique constraint + # below replaces it. + op.drop_index("ix_ntfy_sent_notification_key", table_name="ntfy_sent") + + # Create composite unique constraint (per-user dedup) + op.create_unique_constraint( + "uq_ntfy_sent_user_key", "ntfy_sent", ["user_id", "notification_key"] + ) + op.create_index("ix_ntfy_sent_user_id", "ntfy_sent", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_ntfy_sent_user_id", table_name="ntfy_sent") + op.drop_constraint("uq_ntfy_sent_user_key", "ntfy_sent", type_="unique") + op.create_index( + "ix_ntfy_sent_notification_key", "ntfy_sent", ["notification_key"], unique=True + ) + op.drop_constraint("fk_ntfy_sent_user_id", "ntfy_sent", type_="foreignkey") + op.drop_column("ntfy_sent", "user_id") diff --git a/backend/alembic/versions/035_add_performance_indexes.py b/backend/alembic/versions/035_add_performance_indexes.py new file mode 100644 index 0000000..e36baae --- /dev/null +++ b/backend/alembic/versions/035_add_performance_indexes.py @@ -0,0 +1,66 @@ +"""Add performance indexes for hot query paths. + +Covers: +- calendar_events range queries scoped by calendar (dashboard, notifications) +- calendar_events starred query (dashboard widget) +- calendar_events parent_event_id (recurring series DELETE) +- user_sessions lookup (auth middleware, every request) +- ntfy_sent purge query (background job, every 60s) + +Revision ID: 035 +Revises: 034 +Create Date: 2026-02-27 +""" +from alembic import op + +revision = "035" +down_revision = "034" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Composite index for event range queries scoped by calendar + op.create_index( + "ix_calendar_events_calendar_start_end", + "calendar_events", + ["calendar_id", "start_datetime", "end_datetime"], + ) + + # Partial index for starred events dashboard query — only rows where + # is_starred = true are ever queried, so a partial index is smaller and faster. + op.create_index( + "ix_calendar_events_calendar_starred", + "calendar_events", + ["calendar_id", "start_datetime"], + postgresql_where="is_starred = true", + ) + + # FK lookup index for recurring children DELETE + op.create_index( + "ix_calendar_events_parent_id", + "calendar_events", + ["parent_event_id"], + ) + + # Composite index for session validation (runs on every authenticated request) + op.create_index( + "ix_user_sessions_lookup", + "user_sessions", + ["user_id", "revoked", "expires_at"], + ) + + # Index for ntfy_sent purge query (DELETE WHERE sent_at < cutoff) + op.create_index( + "ix_ntfy_sent_sent_at", + "ntfy_sent", + ["sent_at"], + ) + + +def downgrade() -> None: + op.drop_index("ix_ntfy_sent_sent_at", table_name="ntfy_sent") + op.drop_index("ix_user_sessions_lookup", table_name="user_sessions") + op.drop_index("ix_calendar_events_parent_id", table_name="calendar_events") + op.drop_index("ix_calendar_events_calendar_starred", table_name="calendar_events") + op.drop_index("ix_calendar_events_calendar_start_end", table_name="calendar_events") diff --git a/backend/alembic/versions/036_add_cascade_to_transitive_fks.py b/backend/alembic/versions/036_add_cascade_to_transitive_fks.py new file mode 100644 index 0000000..a0e6b22 --- /dev/null +++ b/backend/alembic/versions/036_add_cascade_to_transitive_fks.py @@ -0,0 +1,104 @@ +"""Add ondelete to transitive FK constraints. + +Without these, deleting a user would fail because the DB-level CASCADE +only reaches first-level children (calendars, projects, people, locations). +Second-level children (calendar_events via calendar_id, project_tasks via +project_id, etc.) need their own ondelete rules to allow the full cascade. + +FK changes: + calendar_events.calendar_id → CASCADE (events die with calendar) + calendar_events.location_id → SET NULL (optional ref, just unlink) + project_tasks.project_id → CASCADE (tasks die with project) + project_tasks.person_id → SET NULL (optional assignee, just unlink) + todos.project_id → SET NULL (optional ref, just unlink) + +Revision ID: 036 +Revises: 035 +Create Date: 2026-02-27 +""" +from alembic import op + +revision = "036" +down_revision = "035" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # calendar_events.calendar_id → CASCADE + op.drop_constraint( + "fk_calendar_events_calendar_id", "calendar_events", type_="foreignkey" + ) + op.create_foreign_key( + "fk_calendar_events_calendar_id", + "calendar_events", + "calendars", + ["calendar_id"], + ["id"], + ondelete="CASCADE", + ) + + # calendar_events.location_id → SET NULL + op.drop_constraint( + "calendar_events_location_id_fkey", "calendar_events", type_="foreignkey" + ) + op.create_foreign_key( + "calendar_events_location_id_fkey", + "calendar_events", + "locations", + ["location_id"], + ["id"], + ondelete="SET NULL", + ) + + # project_tasks.project_id → CASCADE + op.drop_constraint( + "project_tasks_project_id_fkey", "project_tasks", type_="foreignkey" + ) + op.create_foreign_key( + "project_tasks_project_id_fkey", + "project_tasks", + "projects", + ["project_id"], + ["id"], + ondelete="CASCADE", + ) + + # project_tasks.person_id → SET NULL + op.drop_constraint( + "project_tasks_person_id_fkey", "project_tasks", type_="foreignkey" + ) + op.create_foreign_key( + "project_tasks_person_id_fkey", + "project_tasks", + "people", + ["person_id"], + ["id"], + ondelete="SET NULL", + ) + + # todos.project_id → SET NULL + op.drop_constraint( + "todos_project_id_fkey", "todos", type_="foreignkey" + ) + op.create_foreign_key( + "todos_project_id_fkey", + "todos", + "projects", + ["project_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + # Reverse: remove ondelete by re-creating without it + for table, col, ref_table, constraint in [ + ("todos", "project_id", "projects", "todos_project_id_fkey"), + ("project_tasks", "person_id", "people", "project_tasks_person_id_fkey"), + ("project_tasks", "project_id", "projects", "project_tasks_project_id_fkey"), + ("calendar_events", "location_id", "locations", "calendar_events_location_id_fkey"), + ("calendar_events", "calendar_id", "calendars", "fk_calendar_events_calendar_id"), + ]: + op.drop_constraint(constraint, table, type_="foreignkey") + op.create_foreign_key(constraint, table, ref_table, [col], ["id"]) diff --git a/backend/alembic/versions/037_add_user_profile_fields.py b/backend/alembic/versions/037_add_user_profile_fields.py new file mode 100644 index 0000000..1710a96 --- /dev/null +++ b/backend/alembic/versions/037_add_user_profile_fields.py @@ -0,0 +1,29 @@ +"""Add user profile fields (email, first_name, last_name). + +Revision ID: 037 +Revises: 036 +""" +from alembic import op +import sqlalchemy as sa + +revision = "037" +down_revision = "036" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("email", sa.String(255), nullable=True)) + op.add_column("users", sa.Column("first_name", sa.String(100), nullable=True)) + op.add_column("users", sa.Column("last_name", sa.String(100), nullable=True)) + + op.create_unique_constraint("uq_users_email", "users", ["email"]) + op.create_index("ix_users_email", "users", ["email"]) + + +def downgrade() -> None: + op.drop_index("ix_users_email", table_name="users") + op.drop_constraint("uq_users_email", "users", type_="unique") + op.drop_column("users", "last_name") + op.drop_column("users", "first_name") + op.drop_column("users", "email") diff --git a/backend/app/config.py b/backend/app/config.py index ee2da79..ca01e9c 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -9,8 +9,9 @@ class Settings(BaseSettings): COOKIE_SECURE: bool = False OPENWEATHERMAP_API_KEY: str = "" - # Session config - SESSION_MAX_AGE_DAYS: int = 30 + # Session config — sliding window + SESSION_MAX_AGE_DAYS: int = 7 # Sliding window: inactive sessions expire after 7 days + SESSION_TOKEN_HARD_CEILING_DAYS: int = 30 # Absolute token lifetime for itsdangerous max_age # MFA token config (short-lived token bridging password OK → TOTP verification) MFA_TOKEN_MAX_AGE_SECONDS: int = 300 # 5 minutes diff --git a/backend/app/jobs/notifications.py b/backend/app/jobs/notifications.py index cd3099e..cc97c35 100644 --- a/backend/app/jobs/notifications.py +++ b/backend/app/jobs/notifications.py @@ -19,6 +19,7 @@ from app.database import AsyncSessionLocal from app.models.settings import Settings from app.models.reminder import Reminder from app.models.calendar_event import CalendarEvent +from app.models.calendar import Calendar from app.models.todo import Todo from app.models.project import Project from app.models.ntfy_sent import NtfySent @@ -39,26 +40,32 @@ UMBRA_URL = "http://10.0.69.35" # ── Dedup helpers ───────────────────────────────────────────────────────────── -async def _already_sent(db: AsyncSession, key: str) -> bool: +async def _get_sent_keys(db: AsyncSession, user_id: int) -> set[str]: + """Batch-fetch recent notification keys for a user (within the 7-day purge window).""" + cutoff = datetime.now() - timedelta(days=7) result = await db.execute( - select(NtfySent).where(NtfySent.notification_key == key) + select(NtfySent.notification_key).where( + NtfySent.user_id == user_id, + NtfySent.sent_at >= cutoff, + ) ) - return result.scalar_one_or_none() is not None + return set(result.scalars().all()) -async def _mark_sent(db: AsyncSession, key: str) -> None: - db.add(NtfySent(notification_key=key)) +async def _mark_sent(db: AsyncSession, key: str, user_id: int) -> None: + db.add(NtfySent(notification_key=key, user_id=user_id)) await db.commit() # ── Dispatch functions ──────────────────────────────────────────────────────── -async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetime) -> None: +async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetime, sent_keys: set[str]) -> None: """Send notifications for reminders that are currently due and not dismissed/snoozed.""" - # Mirror the filter from /api/reminders/due + # Mirror the filter from /api/reminders/due, scoped to this user result = await db.execute( select(Reminder).where( and_( + Reminder.user_id == settings.user_id, Reminder.remind_at <= now, Reminder.is_dismissed == False, # noqa: E712 Reminder.is_active == True, # noqa: E712 @@ -72,9 +79,9 @@ async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetim if reminder.snoozed_until and reminder.snoozed_until > now: continue # respect snooze - # Key ties notification to the specific day to handle re-fires after midnight - key = f"reminder:{reminder.id}:{reminder.remind_at.date()}" - if await _already_sent(db, key): + # Key includes user_id to prevent cross-user dedup collisions + key = f"reminder:{settings.user_id}:{reminder.id}:{reminder.remind_at.date()}" + if key in sent_keys: continue payload = build_reminder_notification( @@ -89,18 +96,23 @@ async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetim **payload, ) if sent: - await _mark_sent(db, key) + await _mark_sent(db, key, settings.user_id) + sent_keys.add(key) -async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime) -> None: +async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime, sent_keys: set[str]) -> None: """Send notifications for calendar events within the configured lead time window.""" lead_minutes = settings.ntfy_event_lead_minutes # Window: events starting between now and (now + lead_minutes) window_end = now + timedelta(minutes=lead_minutes) + # Scope events through calendar ownership + user_calendar_ids = select(Calendar.id).where(Calendar.user_id == settings.user_id) + result = await db.execute( select(CalendarEvent).where( and_( + CalendarEvent.calendar_id.in_(user_calendar_ids), CalendarEvent.start_datetime >= now, CalendarEvent.start_datetime <= window_end, # Exclude recurring parent templates — they duplicate the child instance rows. @@ -116,9 +128,9 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime) today = now.date() for event in events: - # Key includes the minute-precision start to avoid re-firing during the window - key = f"event:{event.id}:{event.start_datetime.strftime('%Y-%m-%dT%H:%M')}" - if await _already_sent(db, key): + # Key includes user_id to prevent cross-user dedup collisions + key = f"event:{settings.user_id}:{event.id}:{event.start_datetime.strftime('%Y-%m-%dT%H:%M')}" + if key in sent_keys: continue payload = build_event_notification( @@ -136,18 +148,19 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime) **payload, ) if sent: - await _mark_sent(db, key) + await _mark_sent(db, key, settings.user_id) + sent_keys.add(key) -async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None: +async def _dispatch_todos(db: AsyncSession, settings: Settings, today, sent_keys: set[str]) -> None: """Send notifications for incomplete todos due within the configured lead days.""" - from datetime import date as date_type lead_days = settings.ntfy_todo_lead_days cutoff = today + timedelta(days=lead_days) result = await db.execute( select(Todo).where( and_( + Todo.user_id == settings.user_id, Todo.completed == False, # noqa: E712 Todo.due_date != None, # noqa: E711 Todo.due_date <= cutoff, @@ -157,8 +170,9 @@ async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None: todos = result.scalars().all() for todo in todos: - key = f"todo:{todo.id}:{today}" - if await _already_sent(db, key): + # Key includes user_id to prevent cross-user dedup collisions + key = f"todo:{settings.user_id}:{todo.id}:{today}" + if key in sent_keys: continue payload = build_todo_notification( @@ -174,10 +188,11 @@ async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None: **payload, ) if sent: - await _mark_sent(db, key) + await _mark_sent(db, key, settings.user_id) + sent_keys.add(key) -async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> None: +async def _dispatch_projects(db: AsyncSession, settings: Settings, today, sent_keys: set[str]) -> None: """Send notifications for projects with deadlines within the configured lead days.""" lead_days = settings.ntfy_project_lead_days cutoff = today + timedelta(days=lead_days) @@ -185,6 +200,7 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non result = await db.execute( select(Project).where( and_( + Project.user_id == settings.user_id, Project.due_date != None, # noqa: E711 Project.due_date <= cutoff, Project.status != "completed", @@ -194,8 +210,9 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non projects = result.scalars().all() for project in projects: - key = f"project:{project.id}:{today}" - if await _already_sent(db, key): + # Key includes user_id to prevent cross-user dedup collisions + key = f"project:{settings.user_id}:{project.id}:{today}" + if key in sent_keys: continue payload = build_project_notification( @@ -210,7 +227,23 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non **payload, ) if sent: - await _mark_sent(db, key) + await _mark_sent(db, key, settings.user_id) + sent_keys.add(key) + + +async def _dispatch_for_user(db: AsyncSession, settings: Settings, now: datetime) -> None: + """Run all notification dispatches for a single user's settings.""" + # Batch-fetch all sent keys once per user instead of one query per entity + sent_keys = await _get_sent_keys(db, settings.user_id) + + if settings.ntfy_reminders_enabled: + await _dispatch_reminders(db, settings, now, sent_keys) + if settings.ntfy_events_enabled: + await _dispatch_events(db, settings, now, sent_keys) + if settings.ntfy_todos_enabled: + await _dispatch_todos(db, settings, now.date(), sent_keys) + if settings.ntfy_projects_enabled: + await _dispatch_projects(db, settings, now.date(), sent_keys) async def _purge_old_sent_records(db: AsyncSession) -> None: @@ -240,29 +273,35 @@ async def run_notification_dispatch() -> None: """ Main dispatch function called by APScheduler every 60 seconds. Uses AsyncSessionLocal directly — not the get_db() request-scoped dependency. + + Iterates over ALL users with ntfy enabled. Per-user errors are caught and + logged individually so one user's failure does not prevent others from + receiving notifications. """ try: async with AsyncSessionLocal() as db: - result = await db.execute(select(Settings)) - settings = result.scalar_one_or_none() + # Fetch all Settings rows that have ntfy enabled + result = await db.execute( + select(Settings).where(Settings.ntfy_enabled == True) # noqa: E712 + ) + all_settings = result.scalars().all() - if not settings or not settings.ntfy_enabled: + if not all_settings: return # See DATETIME NOTE at top of file re: naive datetime usage now = datetime.now() - today = now.date() - if settings.ntfy_reminders_enabled: - await _dispatch_reminders(db, settings, now) - if settings.ntfy_events_enabled: - await _dispatch_events(db, settings, now) - if settings.ntfy_todos_enabled: - await _dispatch_todos(db, settings, today) - if settings.ntfy_projects_enabled: - await _dispatch_projects(db, settings, today) + for user_settings in all_settings: + try: + await _dispatch_for_user(db, user_settings, now) + except Exception: + # Isolate per-user failures — log and continue to next user + logger.exception( + "ntfy dispatch failed for user_id=%s", user_settings.user_id + ) - # Daily housekeeping: purge stale dedup records + # Daily housekeeping: purge stale dedup records (shared across all users) await _purge_old_sent_records(db) # Security housekeeping runs every cycle regardless of ntfy_enabled diff --git a/backend/app/main.py b/backend/app/main.py index c3a7e9a..4f1ddcd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from app.config import settings from app.database import engine from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates -from app.routers import totp +from app.routers import totp, admin from app.jobs.notifications import run_notification_dispatch # Import models so Alembic's autogenerate can discover them @@ -15,6 +15,61 @@ from app.models import user as _user_model # noqa: F401 from app.models import session as _session_model # noqa: F401 from app.models import totp_usage as _totp_usage_model # noqa: F401 from app.models import backup_code as _backup_code_model # noqa: F401 +from app.models import system_config as _system_config_model # noqa: F401 +from app.models import audit_log as _audit_log_model # noqa: F401 + + +# --------------------------------------------------------------------------- +# Pure ASGI CSRF middleware — SEC-08 (global) +# --------------------------------------------------------------------------- + +class CSRFHeaderMiddleware: + """ + Require X-Requested-With: XMLHttpRequest on all state-mutating requests. + + Browsers never send this header cross-origin without a CORS preflight, + which our CORS policy blocks. This prevents CSRF attacks from simple + form submissions and cross-origin fetches. + + Uses pure ASGI (not BaseHTTPMiddleware) to avoid streaming/memory overhead. + """ + + _EXEMPT_PATHS = frozenset({ + "/health", + "/", + "/api/auth/login", + "/api/auth/setup", + "/api/auth/register", + "/api/auth/totp-verify", + "/api/auth/totp/enforce-setup", + "/api/auth/totp/enforce-confirm", + }) + _MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] == "http": + method = scope.get("method", "") + path = scope.get("path", "") + + if method in self._MUTATING_METHODS and path not in self._EXEMPT_PATHS: + headers = dict(scope.get("headers", [])) + if headers.get(b"x-requested-with") != b"XMLHttpRequest": + body = b'{"detail":"Invalid request origin"}' + await send({ + "type": "http.response.start", + "status": 403, + "headers": [ + [b"content-type", b"application/json"], + [b"content-length", str(len(body)).encode()], + ], + }) + await send({"type": "http.response.body", "body": body}) + return + + await self.app(scope, receive, send) @asynccontextmanager @@ -45,13 +100,18 @@ app = FastAPI( openapi_url="/openapi.json" if _is_dev else None, ) -# CORS configuration +# Middleware stack — added in reverse order (last added = outermost). +# CSRF is added first (innermost), then CORS wraps it (outermost). +# This ensures CORS headers appear on CSRF 403 responses. +app.add_middleware(CSRFHeaderMiddleware) + +# CORS configuration — outermost layer app.add_middleware( CORSMiddleware, allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()], allow_credentials=True, allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - allow_headers=["Content-Type", "Authorization", "Cookie"], + allow_headers=["Content-Type", "Authorization", "Cookie", "X-Requested-With"], ) # Include routers with /api prefix @@ -68,6 +128,7 @@ app.include_router(dashboard.router, prefix="/api", tags=["Dashboard"]) app.include_router(weather.router, prefix="/api/weather", tags=["Weather"]) app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"]) app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"]) +app.include_router(admin.router, prefix="/api/admin", tags=["Admin"]) @app.get("/") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index cca74b0..d9d7a34 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -13,6 +13,8 @@ from app.models.session import UserSession from app.models.ntfy_sent import NtfySent from app.models.totp_usage import TOTPUsage from app.models.backup_code import BackupCode +from app.models.system_config import SystemConfig +from app.models.audit_log import AuditLog __all__ = [ "Settings", @@ -30,4 +32,6 @@ __all__ = [ "NtfySent", "TOTPUsage", "BackupCode", + "SystemConfig", + "AuditLog", ] diff --git a/backend/app/models/audit_log.py b/backend/app/models/audit_log.py new file mode 100644 index 0000000..1ebef1d --- /dev/null +++ b/backend/app/models/audit_log.py @@ -0,0 +1,27 @@ +from sqlalchemy import String, Text, Integer, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime +from typing import Optional +from app.database import Base + + +class AuditLog(Base): + """ + Append-only audit trail for admin actions and auth events. + No DELETE endpoint — this table is immutable once written. + """ + __tablename__ = "audit_log" + + id: Mapped[int] = mapped_column(primary_key=True) + actor_user_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True + ) + target_user_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True + ) + action: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + detail: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True) + created_at: Mapped[datetime] = mapped_column( + default=func.now(), server_default=func.now(), index=True + ) diff --git a/backend/app/models/calendar.py b/backend/app/models/calendar.py index 60de5f7..43ab782 100644 --- a/backend/app/models/calendar.py +++ b/backend/app/models/calendar.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Boolean, func +from sqlalchemy import String, Boolean, Integer, ForeignKey, func from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime from typing import List @@ -9,6 +9,9 @@ class Calendar(Base): __tablename__ = "calendars" id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) name: Mapped[str] = mapped_column(String(100), nullable=False) color: Mapped[str] = mapped_column(String(20), nullable=False, default="#3b82f6") is_default: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") diff --git a/backend/app/models/calendar_event.py b/backend/app/models/calendar_event.py index 725d3eb..c85dfc7 100644 --- a/backend/app/models/calendar_event.py +++ b/backend/app/models/calendar_event.py @@ -15,10 +15,10 @@ class CalendarEvent(Base): end_datetime: Mapped[datetime] = mapped_column(nullable=False) all_day: Mapped[bool] = mapped_column(Boolean, default=False) color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) - location_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("locations.id"), nullable=True) + location_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("locations.id", ondelete="SET NULL"), nullable=True) recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) is_starred: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") - calendar_id: Mapped[int] = mapped_column(Integer, ForeignKey("calendars.id"), nullable=False) + calendar_id: Mapped[int] = mapped_column(Integer, ForeignKey("calendars.id", ondelete="CASCADE"), nullable=False) # Recurrence fields # parent_event_id: set on child events; NULL on the parent template row diff --git a/backend/app/models/event_template.py b/backend/app/models/event_template.py index 47004de..d4cb662 100644 --- a/backend/app/models/event_template.py +++ b/backend/app/models/event_template.py @@ -9,6 +9,9 @@ class EventTemplate(Base): __tablename__ = "event_templates" id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) name: Mapped[str] = mapped_column(String(255), nullable=False) title: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) @@ -21,4 +24,4 @@ class EventTemplate(Base): Integer, ForeignKey("locations.id", ondelete="SET NULL"), nullable=True ) is_starred: Mapped[bool] = mapped_column(Boolean, default=False) - created_at: Mapped[datetime] = mapped_column(default=datetime.now, server_default=func.now()) + created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now()) diff --git a/backend/app/models/location.py b/backend/app/models/location.py index 7dbea4a..c78adf8 100644 --- a/backend/app/models/location.py +++ b/backend/app/models/location.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Text, Boolean, func, text +from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, func, text from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime from typing import Optional, List @@ -9,6 +9,9 @@ class Location(Base): __tablename__ = "locations" id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) name: Mapped[str] = mapped_column(String(255), nullable=False) address: Mapped[str] = mapped_column(Text, nullable=False) category: Mapped[str] = mapped_column(String(100), default="other") diff --git a/backend/app/models/ntfy_sent.py b/backend/app/models/ntfy_sent.py index ad3868a..a819741 100644 --- a/backend/app/models/ntfy_sent.py +++ b/backend/app/models/ntfy_sent.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, func +from sqlalchemy import String, Integer, ForeignKey, UniqueConstraint, func from sqlalchemy.orm import Mapped, mapped_column from datetime import datetime from app.database import Base @@ -8,7 +8,7 @@ class NtfySent(Base): """ Deduplication table for ntfy notifications. Prevents the background job from re-sending the same notification - within a given time window. + within a given time window. Scoped per-user. Key format: "{type}:{entity_id}:{date_window}" Examples: @@ -18,7 +18,13 @@ class NtfySent(Base): "project:3:2026-02-25" """ __tablename__ = "ntfy_sent" + __table_args__ = ( + UniqueConstraint("user_id", "notification_key", name="uq_ntfy_sent_user_key"), + ) id: Mapped[int] = mapped_column(primary_key=True) - notification_key: Mapped[str] = mapped_column(String(255), unique=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + notification_key: Mapped[str] = mapped_column(String(255), index=True) sent_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now()) diff --git a/backend/app/models/person.py b/backend/app/models/person.py index fbf7984..66c3acd 100644 --- a/backend/app/models/person.py +++ b/backend/app/models/person.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Text, Date, Boolean, func, text +from sqlalchemy import String, Text, Date, Boolean, Integer, ForeignKey, func, text from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime, date from typing import Optional, List @@ -9,6 +9,9 @@ class Person(Base): __tablename__ = "people" id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) name: Mapped[str] = mapped_column(String(255), nullable=False) email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) diff --git a/backend/app/models/project.py b/backend/app/models/project.py index 57b0969..faefbe5 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -1,5 +1,5 @@ import sqlalchemy as sa -from sqlalchemy import Boolean, String, Text, Date, func +from sqlalchemy import Boolean, String, Text, Date, Integer, ForeignKey, func from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime, date from typing import Optional, List @@ -10,6 +10,9 @@ class Project(Base): __tablename__ = "projects" id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) status: Mapped[str] = mapped_column(String(20), default="not_started") diff --git a/backend/app/models/project_task.py b/backend/app/models/project_task.py index 6d52be5..94a41d1 100644 --- a/backend/app/models/project_task.py +++ b/backend/app/models/project_task.py @@ -9,7 +9,7 @@ class ProjectTask(Base): __tablename__ = "project_tasks" id: Mapped[int] = mapped_column(primary_key=True, index=True) - project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id"), nullable=False) + project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False) parent_task_id: Mapped[Optional[int]] = mapped_column( Integer, ForeignKey("project_tasks.id", ondelete="CASCADE"), nullable=True ) @@ -18,7 +18,7 @@ class ProjectTask(Base): status: Mapped[str] = mapped_column(String(20), default="pending") priority: Mapped[str] = mapped_column(String(20), default="medium") due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) - person_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("people.id"), nullable=True) + person_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("people.id", ondelete="SET NULL"), nullable=True) sort_order: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[datetime] = mapped_column(default=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/reminder.py b/backend/app/models/reminder.py index 8259d21..261a89b 100644 --- a/backend/app/models/reminder.py +++ b/backend/app/models/reminder.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Text, Boolean, func +from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, func from sqlalchemy.orm import Mapped, mapped_column from datetime import datetime from typing import Optional @@ -9,6 +9,9 @@ class Reminder(Base): __tablename__ = "reminders" id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) title: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) remind_at: Mapped[Optional[datetime]] = mapped_column(nullable=True) diff --git a/backend/app/models/system_config.py b/backend/app/models/system_config.py new file mode 100644 index 0000000..3e801b2 --- /dev/null +++ b/backend/app/models/system_config.py @@ -0,0 +1,27 @@ +from sqlalchemy import Boolean, CheckConstraint, func +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime +from app.database import Base + + +class SystemConfig(Base): + """ + Singleton system configuration table (always id=1). + Stores global toggles for registration, MFA enforcement, etc. + """ + __tablename__ = "system_config" + __table_args__ = ( + CheckConstraint("id = 1", name="ck_system_config_singleton"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + allow_registration: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false" + ) + enforce_mfa_new_users: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false" + ) + created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + default=func.now(), onupdate=func.now(), server_default=func.now() + ) diff --git a/backend/app/models/todo.py b/backend/app/models/todo.py index cf38b3f..8934179 100644 --- a/backend/app/models/todo.py +++ b/backend/app/models/todo.py @@ -9,6 +9,9 @@ class Todo(Base): __tablename__ = "todos" id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) title: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) priority: Mapped[str] = mapped_column(String(20), default="medium") @@ -20,7 +23,7 @@ class Todo(Base): recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) reset_at: Mapped[Optional[datetime]] = mapped_column(nullable=True, index=True) next_due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) - project_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("projects.id"), nullable=True) + project_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("projects.id", ondelete="SET NULL"), nullable=True) created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now(), server_default=func.now()) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 8de4311..92623dd 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -9,6 +9,9 @@ class User(Base): id: Mapped[int] = mapped_column(primary_key=True, index=True) username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True) + email: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True, index=True) + first_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + last_name: Mapped[str | None] = mapped_column(String(100), nullable=True) password_hash: Mapped[str] = mapped_column(String(255), nullable=False) # MFA — populated in Track B @@ -23,7 +26,23 @@ class User(Base): # Account state is_active: Mapped[bool] = mapped_column(Boolean, default=True) + # RBAC + role: Mapped[str] = mapped_column( + String(30), nullable=False, default="standard", server_default="standard" + ) + + # MFA enforcement (admin can toggle; checked at login) + mfa_enforce_pending: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false" + ) + + # Forced password change (set after admin reset) + must_change_password: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false" + ) + # Audit created_at: Mapped[datetime] = mapped_column(default=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) last_login_at: Mapped[datetime | None] = mapped_column(nullable=True, default=None) + last_password_change_at: Mapped[datetime | None] = mapped_column(nullable=True, default=None) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..aac4d97 --- /dev/null +++ b/backend/app/routers/admin.py @@ -0,0 +1,802 @@ +""" +Admin router — full user management, system config, and audit log. + +Security measures implemented: + SEC-02: Session revocation on role change + SEC-05: Block admin self-actions (own role/password/MFA/active status) + SEC-08: X-Requested-With validation (now handled globally by CSRFHeaderMiddleware) + SEC-13: Session revocation + ntfy alert on MFA disable + +All routes require the `require_admin` dependency (which chains through +get_current_user, so the session cookie is always validated). +""" +import secrets +from datetime import datetime +from typing import Optional + +import sqlalchemy as sa +from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.audit_log import AuditLog +from app.models.backup_code import BackupCode +from app.models.session import UserSession +from app.models.settings import Settings +from app.models.system_config import SystemConfig +from app.models.user import User +from app.routers.auth import ( + _create_user_defaults, + get_current_user, + require_admin, +) +from app.schemas.admin import ( + AdminDashboardResponse, + AuditLogEntry, + AuditLogResponse, + CreateUserRequest, + DeleteUserResponse, + ResetPasswordResponse, + SystemConfigResponse, + SystemConfigUpdate, + ToggleActiveRequest, + ToggleMfaEnforceRequest, + UpdateUserRoleRequest, + UserDetailResponse, + UserListItem, + UserListResponse, +) +from app.services.audit import log_audit_event +from app.services.auth import hash_password + +# --------------------------------------------------------------------------- +# Router — all endpoints inherit require_admin +# (SEC-08 CSRF validation is now handled globally by CSRFHeaderMiddleware) +# --------------------------------------------------------------------------- + +router = APIRouter( + dependencies=[Depends(require_admin)], +) + + +# --------------------------------------------------------------------------- +# Audit log helper — resolve target username even for deleted users +# --------------------------------------------------------------------------- + +def _target_username_col(target_alias, audit_model): + """ + COALESCE: prefer the live username from the users table, + fall back to the username stored in the audit detail JSON + (survives user deletion since audit_log.target_user_id → SET NULL). + """ + return sa.func.coalesce( + target_alias.username, + sa.cast(audit_model.detail, JSONB)["username"].as_string(), + ).label("target_username") + + +# --------------------------------------------------------------------------- +# Session revocation helper (used in multiple endpoints) +# --------------------------------------------------------------------------- + +async def _revoke_all_sessions(db: AsyncSession, user_id: int) -> int: + """Mark every active session for user_id as revoked. Returns count revoked.""" + result = await db.execute( + sa.update(UserSession) + .where(UserSession.user_id == user_id, UserSession.revoked == False) + .values(revoked=True) + .returning(UserSession.id) + ) + return len(result.fetchall()) + + +# --------------------------------------------------------------------------- +# Self-action guard — SEC-05 +# --------------------------------------------------------------------------- + +def _guard_self_action(actor: User, target_id: int, action: str) -> None: + """Raise 403 if an admin attempts a privileged action against their own account.""" + if actor.id == target_id: + raise HTTPException( + status_code=403, + detail=f"Admins cannot {action} their own account", + ) + + +# --------------------------------------------------------------------------- +# GET /users +# --------------------------------------------------------------------------- + +@router.get("/users", response_model=UserListResponse) +async def list_users( + db: AsyncSession = Depends(get_db), + _actor: User = Depends(get_current_user), +): + """Return all users with basic stats including active session counts.""" + active_sub = ( + sa.select(sa.func.count()) + .select_from(UserSession) + .where( + UserSession.user_id == User.id, + UserSession.revoked == False, + UserSession.expires_at > datetime.now(), + ) + .correlate(User) + .scalar_subquery() + ) + + result = await db.execute( + sa.select(User, active_sub.label("active_sessions")) + .order_by(User.created_at) + ) + rows = result.all() + + return UserListResponse( + users=[ + UserListItem( + **UserListItem.model_validate(user).model_dump(exclude={"active_sessions"}), + active_sessions=count, + ) + for user, count in rows + ], + total=len(rows), + ) + + +# --------------------------------------------------------------------------- +# GET /users/{user_id} +# --------------------------------------------------------------------------- + +@router.get("/users/{user_id}", response_model=UserDetailResponse) +async def get_user( + user_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + _actor: User = Depends(get_current_user), +): + """Return a single user with their active session count and preferred_name.""" + result = await db.execute(sa.select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + session_result = await db.execute( + sa.select(sa.func.count()).select_from(UserSession).where( + UserSession.user_id == user_id, + UserSession.revoked == False, + UserSession.expires_at > datetime.now(), + ) + ) + active_sessions = session_result.scalar_one() + + # Fetch preferred_name from Settings + settings_result = await db.execute( + sa.select(Settings.preferred_name).where(Settings.user_id == user_id) + ) + preferred_name = settings_result.scalar_one_or_none() + + return UserDetailResponse( + **UserListItem.model_validate(user).model_dump(exclude={"active_sessions"}), + active_sessions=active_sessions, + preferred_name=preferred_name, + ) + + +# --------------------------------------------------------------------------- +# POST /users +# --------------------------------------------------------------------------- + +@router.post("/users", response_model=UserDetailResponse, status_code=201) +async def create_user( + data: CreateUserRequest, + request: Request, + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """Admin-create a user with Settings and default calendars.""" + existing = await db.execute(sa.select(User).where(User.username == data.username)) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="Username already taken") + + # Check email uniqueness if provided + email = data.email.strip().lower() if data.email else None + if email: + email_exists = await db.execute(sa.select(User).where(User.email == email)) + if email_exists.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="Email already in use") + + new_user = User( + username=data.username, + password_hash=hash_password(data.password), + role=data.role, + email=email, + first_name=data.first_name, + last_name=data.last_name, + last_password_change_at=datetime.now(), + # Force password change so the user sets their own credential + must_change_password=True, + ) + db.add(new_user) + await db.flush() # populate new_user.id + + await _create_user_defaults(db, new_user.id, preferred_name=data.preferred_name) + + await log_audit_event( + db, + action="admin.user_created", + actor_id=actor.id, + target_id=new_user.id, + detail={"username": new_user.username, "role": new_user.role}, + ip=request.client.host if request.client else None, + ) + + try: + await db.commit() + except IntegrityError: + await db.rollback() + raise HTTPException(status_code=409, detail="Username or email already in use") + + return UserDetailResponse( + **UserListItem.model_validate(new_user).model_dump(exclude={"active_sessions"}), + active_sessions=0, + ) + + +# --------------------------------------------------------------------------- +# PUT /users/{user_id}/role — SEC-02, SEC-05 +# --------------------------------------------------------------------------- + +@router.put("/users/{user_id}/role") +async def update_user_role( + user_id: int = Path(ge=1, le=2147483647), + data: UpdateUserRoleRequest = ..., + request: Request = ..., + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """ + Change a user's role. + Blocks demotion of the last admin (SEC-05 variant). + Revokes all sessions after role change (SEC-02). + """ + _guard_self_action(actor, user_id, "change role of") + + result = await db.execute(sa.select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Prevent demoting the last admin + if user.role == "admin" and data.role != "admin": + admin_count = await db.scalar( + sa.select(sa.func.count()).select_from(User).where(User.role == "admin") + ) + if admin_count <= 1: + raise HTTPException( + status_code=409, + detail="Cannot demote the last admin account", + ) + + old_role = user.role + user.role = data.role + + # SEC-02: revoke sessions so the new role takes effect immediately + revoked = await _revoke_all_sessions(db, user_id) + + await log_audit_event( + db, + action="admin.role_changed", + actor_id=actor.id, + target_id=user_id, + detail={"old_role": old_role, "new_role": data.role, "sessions_revoked": revoked}, + ip=request.client.host if request.client else None, + ) + await db.commit() + + return {"message": f"Role updated to '{data.role}'. {revoked} session(s) revoked."} + + +# --------------------------------------------------------------------------- +# POST /users/{user_id}/reset-password — SEC-05 +# --------------------------------------------------------------------------- + +@router.post("/users/{user_id}/reset-password", response_model=ResetPasswordResponse) +async def reset_user_password( + user_id: int = Path(ge=1, le=2147483647), + request: Request = ..., + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """ + Generate a temporary password, revoke all sessions, and mark must_change_password. + The admin is shown the plaintext temp password once — it is not stored. + """ + _guard_self_action(actor, user_id, "reset the password of") + + result = await db.execute(sa.select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + temp_password = secrets.token_urlsafe(16) + user.password_hash = hash_password(temp_password) + user.must_change_password = True + user.last_password_change_at = datetime.now() + + revoked = await _revoke_all_sessions(db, user_id) + + await log_audit_event( + db, + action="admin.password_reset", + actor_id=actor.id, + target_id=user_id, + detail={"sessions_revoked": revoked}, + ip=request.client.host if request.client else None, + ) + await db.commit() + + return ResetPasswordResponse( + message=f"Password reset. {revoked} session(s) revoked. User must change password on next login.", + temporary_password=temp_password, + ) + + +# --------------------------------------------------------------------------- +# POST /users/{user_id}/disable-mfa — SEC-05, SEC-13 +# --------------------------------------------------------------------------- + +@router.post("/users/{user_id}/disable-mfa") +async def disable_user_mfa( + user_id: int = Path(ge=1, le=2147483647), + request: Request = ..., + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """ + Clear TOTP secret + backup codes and revoke all sessions (SEC-13). + """ + _guard_self_action(actor, user_id, "disable MFA for") + + result = await db.execute(sa.select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if not user.totp_enabled: + raise HTTPException(status_code=409, detail="MFA is not enabled for this user") + + # Clear TOTP data + user.totp_secret = None + user.totp_enabled = False + user.mfa_enforce_pending = False + + # Remove all backup codes + await db.execute( + sa.delete(BackupCode).where(BackupCode.user_id == user_id) + ) + + # SEC-13: revoke sessions so the MFA downgrade takes effect immediately + revoked = await _revoke_all_sessions(db, user_id) + + await log_audit_event( + db, + action="admin.mfa_disabled", + actor_id=actor.id, + target_id=user_id, + detail={"sessions_revoked": revoked}, + ip=request.client.host if request.client else None, + ) + await db.commit() + + return {"message": f"MFA disabled. {revoked} session(s) revoked."} + + +# --------------------------------------------------------------------------- +# PUT /users/{user_id}/enforce-mfa — SEC-05 +# --------------------------------------------------------------------------- + +@router.put("/users/{user_id}/enforce-mfa") +async def toggle_mfa_enforce( + user_id: int = Path(ge=1, le=2147483647), + data: ToggleMfaEnforceRequest = ..., + request: Request = ..., + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """Toggle the mfa_enforce_pending flag. Next login will prompt MFA setup.""" + _guard_self_action(actor, user_id, "toggle MFA enforcement for") + + result = await db.execute(sa.select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + user.mfa_enforce_pending = data.enforce + + await log_audit_event( + db, + action="admin.mfa_enforce_toggled", + actor_id=actor.id, + target_id=user_id, + detail={"enforce": data.enforce}, + ip=request.client.host if request.client else None, + ) + await db.commit() + + return {"message": f"MFA enforcement {'enabled' if data.enforce else 'disabled'} for user."} + + +# --------------------------------------------------------------------------- +# PUT /users/{user_id}/active — SEC-05 +# --------------------------------------------------------------------------- + +@router.put("/users/{user_id}/active") +async def toggle_user_active( + user_id: int = Path(ge=1, le=2147483647), + data: ToggleActiveRequest = ..., + request: Request = ..., + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """ + Enable or disable a user account. + Revoking an account also revokes all active sessions immediately. + """ + _guard_self_action(actor, user_id, "change active status of") + + result = await db.execute(sa.select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + user.is_active = data.is_active + revoked = 0 + + if not data.is_active: + revoked = await _revoke_all_sessions(db, user_id) + + await log_audit_event( + db, + action="admin.user_deactivated" if not data.is_active else "admin.user_activated", + actor_id=actor.id, + target_id=user_id, + detail={"sessions_revoked": revoked}, + ip=request.client.host if request.client else None, + ) + await db.commit() + + state = "activated" if data.is_active else f"deactivated ({revoked} session(s) revoked)" + return {"message": f"User {state}."} + + +# --------------------------------------------------------------------------- +# DELETE /users/{user_id}/sessions +# --------------------------------------------------------------------------- + +@router.delete("/users/{user_id}/sessions") +async def revoke_user_sessions( + user_id: int = Path(ge=1, le=2147483647), + request: Request = ..., + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """Forcibly revoke all active sessions for a user.""" + result = await db.execute(sa.select(User).where(User.id == user_id)) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="User not found") + + revoked = await _revoke_all_sessions(db, user_id) + + await log_audit_event( + db, + action="admin.sessions_revoked", + actor_id=actor.id, + target_id=user_id, + detail={"sessions_revoked": revoked}, + ip=request.client.host if request.client else None, + ) + await db.commit() + + return {"message": f"{revoked} session(s) revoked."} + + +# --------------------------------------------------------------------------- +# DELETE /users/{user_id} — hard delete user + all data +# --------------------------------------------------------------------------- + +@router.delete("/users/{user_id}", response_model=DeleteUserResponse) +async def delete_user( + user_id: int = Path(ge=1, le=2147483647), + request: Request = ..., + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """ + Permanently delete a user and all their data. + DB CASCADE rules handle child row cleanup. + """ + _guard_self_action(actor, user_id, "delete") + + result = await db.execute(sa.select(User).where(User.id == user_id)) + target = result.scalar_one_or_none() + if not target: + raise HTTPException(status_code=404, detail="User not found") + + # Prevent deleting the last admin + if target.role == "admin": + admin_count = await db.scalar( + sa.select(sa.func.count()).select_from(User).where(User.role == "admin") + ) + if admin_count <= 1: + raise HTTPException( + status_code=409, + detail="Cannot delete the last admin account", + ) + + deleted_username = target.username + + # Belt-and-suspenders: explicitly revoke sessions before delete + await _revoke_all_sessions(db, user_id) + + await log_audit_event( + db, + action="admin.user_deleted", + actor_id=actor.id, + target_id=user_id, + detail={"user_id": user_id, "username": deleted_username}, + ip=request.client.host if request.client else None, + ) + # Flush audit + session revocation within the same transaction + await db.flush() + + # DB CASCADE handles all child rows; SET NULL fires on audit_log.target_user_id + await db.delete(target) + await db.commit() + + return DeleteUserResponse( + message=f"User '{deleted_username}' permanently deleted.", + deleted_username=deleted_username, + ) + + +# --------------------------------------------------------------------------- +# GET /users/{user_id}/sessions +# --------------------------------------------------------------------------- + +@router.get("/users/{user_id}/sessions") +async def list_user_sessions( + user_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + _actor: User = Depends(get_current_user), +): + """List all active (non-revoked, non-expired) sessions for a user.""" + result = await db.execute(sa.select(User).where(User.id == user_id)) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="User not found") + + sessions_result = await db.execute( + sa.select(UserSession).where( + UserSession.user_id == user_id, + UserSession.revoked == False, + UserSession.expires_at > datetime.now(), + ).order_by(UserSession.created_at.desc()) + ) + sessions = sessions_result.scalars().all() + + return { + "sessions": [ + { + "id": s.id, + "created_at": s.created_at, + "expires_at": s.expires_at, + "ip_address": s.ip_address, + "user_agent": s.user_agent, + } + for s in sessions + ], + "total": len(sessions), + } + + +# --------------------------------------------------------------------------- +# GET /config +# --------------------------------------------------------------------------- + +@router.get("/config", response_model=SystemConfigResponse) +async def get_system_config( + db: AsyncSession = Depends(get_db), + _actor: User = Depends(get_current_user), +): + """Fetch the singleton system configuration row.""" + result = await db.execute(sa.select(SystemConfig).where(SystemConfig.id == 1)) + config = result.scalar_one_or_none() + if not config: + # Bootstrap the singleton if it doesn't exist yet + config = SystemConfig(id=1) + db.add(config) + await db.commit() + return config + + +# --------------------------------------------------------------------------- +# PUT /config +# --------------------------------------------------------------------------- + +@router.put("/config", response_model=SystemConfigResponse) +async def update_system_config( + data: SystemConfigUpdate, + request: Request, + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """Update one or more system config fields (partial update).""" + result = await db.execute(sa.select(SystemConfig).where(SystemConfig.id == 1)) + config = result.scalar_one_or_none() + if not config: + config = SystemConfig(id=1) + db.add(config) + await db.flush() + + changes: dict = {} + if data.allow_registration is not None: + changes["allow_registration"] = data.allow_registration + config.allow_registration = data.allow_registration + if data.enforce_mfa_new_users is not None: + changes["enforce_mfa_new_users"] = data.enforce_mfa_new_users + config.enforce_mfa_new_users = data.enforce_mfa_new_users + + if changes: + await log_audit_event( + db, + action="admin.config_updated", + actor_id=actor.id, + detail=changes, + ip=request.client.host if request.client else None, + ) + + await db.commit() + return config + + +# --------------------------------------------------------------------------- +# GET /dashboard +# --------------------------------------------------------------------------- + +@router.get("/dashboard", response_model=AdminDashboardResponse) +async def admin_dashboard( + db: AsyncSession = Depends(get_db), + _actor: User = Depends(get_current_user), +): + """Aggregate stats for the admin portal dashboard.""" + total_users = await db.scalar( + sa.select(sa.func.count()).select_from(User) + ) + active_users = await db.scalar( + sa.select(sa.func.count()).select_from(User).where(User.is_active == True) + ) + admin_count = await db.scalar( + sa.select(sa.func.count()).select_from(User).where(User.role == "admin") + ) + totp_count = await db.scalar( + sa.select(sa.func.count()).select_from(User).where(User.totp_enabled == True) + ) + active_sessions = await db.scalar( + sa.select(sa.func.count()).select_from(UserSession).where( + UserSession.revoked == False, + UserSession.expires_at > datetime.now(), + ) + ) + + mfa_adoption = (totp_count / total_users) if total_users else 0.0 + + # 10 most recent logins + recent_logins_result = await db.execute( + sa.select(User.username, User.last_login_at) + .where(User.last_login_at != None) + .order_by(User.last_login_at.desc()) + .limit(10) + ) + recent_logins = [ + {"username": row.username, "last_login_at": row.last_login_at} + for row in recent_logins_result + ] + + # 10 most recent audit entries — resolve usernames via JOINs + actor_user = sa.orm.aliased(User, name="actor_user") + target_user = sa.orm.aliased(User, name="target_user") + recent_audit_result = await db.execute( + sa.select( + AuditLog, + actor_user.username.label("actor_username"), + _target_username_col(target_user, AuditLog), + ) + .outerjoin(actor_user, AuditLog.actor_user_id == actor_user.id) + .outerjoin(target_user, AuditLog.target_user_id == target_user.id) + .order_by(AuditLog.created_at.desc()) + .limit(10) + ) + recent_audit_entries = [ + { + "action": row.AuditLog.action, + "actor_username": row.actor_username, + "target_username": row.target_username, + "created_at": row.AuditLog.created_at, + } + for row in recent_audit_result + ] + + return AdminDashboardResponse( + total_users=total_users or 0, + active_users=active_users or 0, + admin_count=admin_count or 0, + active_sessions=active_sessions or 0, + mfa_adoption_rate=round(mfa_adoption, 4), + recent_logins=recent_logins, + recent_audit_entries=recent_audit_entries, + ) + + +# --------------------------------------------------------------------------- +# GET /audit-log +# --------------------------------------------------------------------------- + +@router.get("/audit-log", response_model=AuditLogResponse) +async def get_audit_log( + db: AsyncSession = Depends(get_db), + _actor: User = Depends(get_current_user), + action: Optional[str] = Query(None, description="Filter by action string (prefix match)"), + target_user_id: Optional[int] = Query(None, description="Filter by target user ID"), + page: int = Query(1, ge=1, description="Page number (1-indexed)"), + per_page: int = Query(50, ge=1, le=200, description="Results per page"), +): + """ + Paginated audit log with optional filters. + Resolves actor and target user IDs to usernames via a JOIN. + """ + # Aliases for the two user joins + actor_user = sa.orm.aliased(User, name="actor_user") + target_user = sa.orm.aliased(User, name="target_user") + + # Base query — left outer join so entries with NULL actor/target still appear + base_q = ( + sa.select( + AuditLog, + actor_user.username.label("actor_username"), + _target_username_col(target_user, AuditLog), + ) + .outerjoin(actor_user, AuditLog.actor_user_id == actor_user.id) + .outerjoin(target_user, AuditLog.target_user_id == target_user.id) + ) + + if action: + base_q = base_q.where(AuditLog.action.like(f"{action}%")) + if target_user_id is not None: + base_q = base_q.where(AuditLog.target_user_id == target_user_id) + + # Count before pagination + count_q = sa.select(sa.func.count()).select_from( + base_q.subquery() + ) + total = await db.scalar(count_q) or 0 + + # Paginate + offset = (page - 1) * per_page + rows_result = await db.execute( + base_q.order_by(AuditLog.created_at.desc()).offset(offset).limit(per_page) + ) + + entries = [ + AuditLogEntry( + id=row.AuditLog.id, + actor_username=row.actor_username, + target_username=row.target_username, + action=row.AuditLog.action, + detail=row.AuditLog.detail, + ip_address=row.AuditLog.ip_address, + created_at=row.AuditLog.created_at, + ) + for row in rows_result + ] + + return AuditLogResponse(entries=entries, total=total) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index e0fb04b..ba8d7bd 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,18 +1,20 @@ """ -Authentication router — username/password with DB-backed sessions and account lockout. +Authentication router — username/password with DB-backed sessions, account lockout, +role-based access control, and multi-user registration. Session flow: - POST /setup → create User + Settings row → issue session cookie - POST /login → verify credentials → check lockout → insert UserSession → issue cookie - → if TOTP enabled: return mfa_token instead of full session + POST /setup → create admin User + Settings + calendars → issue session cookie + POST /login → verify credentials → check lockout → MFA/enforce checks → issue session + POST /register → create standard user (when registration enabled) POST /logout → mark session revoked in DB → delete cookie - GET /status → verify user exists + session valid + GET /status → verify user exists + session valid + role + registration_open Security layers: - 1. Nginx limit_req_zone (real-IP, 10 req/min burst 5) — outer guard on all auth endpoints - 2. DB-backed account lockout (10 failures → 30-min lock, HTTP 423) — per-user guard + 1. Nginx limit_req_zone (real-IP, 10 req/min burst 5) — outer guard on auth endpoints + 2. DB-backed account lockout (10 failures → 30-min lock, HTTP 423) 3. Session revocation stored in DB (survives container restarts) - 4. bcrypt→Argon2id transparent upgrade on first login with migrated hash + 4. bcrypt→Argon2id transparent upgrade on first login + 5. Role-based authorization via require_role() dependency factory """ import uuid from datetime import datetime, timedelta @@ -20,24 +22,38 @@ from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request, Response, Cookie from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, func from app.database import get_db from app.models.user import User from app.models.session import UserSession from app.models.settings import Settings -from app.schemas.auth import SetupRequest, LoginRequest, ChangePasswordRequest, VerifyPasswordRequest +from app.models.system_config import SystemConfig +from app.models.calendar import Calendar +from app.schemas.auth import ( + SetupRequest, LoginRequest, RegisterRequest, + ChangePasswordRequest, VerifyPasswordRequest, +) from app.services.auth import ( hash_password, + verify_password, verify_password_with_upgrade, create_session_token, verify_session_token, create_mfa_token, + create_mfa_enforce_token, ) +from app.services.audit import log_audit_event from app.config import settings as app_settings router = APIRouter() +# Pre-computed dummy hash for timing equalization (M-02). +# When a login attempt targets a non-existent username, we still run +# Argon2id verification against this dummy hash so the response time +# is indistinguishable from a wrong-password attempt. +_DUMMY_HASH = hash_password("timing-equalization-dummy") + # --------------------------------------------------------------------------- # Cookie helper # --------------------------------------------------------------------------- @@ -59,13 +75,16 @@ def _set_session_cookie(response: Response, token: str) -> None: async def get_current_user( request: Request, + response: Response, session_cookie: Optional[str] = Cookie(None, alias="session"), db: AsyncSession = Depends(get_db), ) -> User: """ Dependency that verifies the session cookie and returns the authenticated User. - Replaces the old get_current_session (which returned Settings). - Any router that hasn't been updated will get a compile-time type error. + + L-03 sliding window: if the session has less than + (SESSION_MAX_AGE_DAYS - 1) days remaining, silently extend expires_at + and re-issue the cookie so active users never hit expiration. """ if not session_cookie: raise HTTPException(status_code=401, detail="Not authenticated") @@ -99,6 +118,17 @@ async def get_current_user( if not user: raise HTTPException(status_code=401, detail="User not found or inactive") + # L-03: Sliding window renewal — extend session if >1 day has elapsed since + # last renewal (i.e. remaining time < SESSION_MAX_AGE_DAYS - 1 day). + now = datetime.now() + renewal_threshold = timedelta(days=app_settings.SESSION_MAX_AGE_DAYS - 1) + if db_session.expires_at - now < renewal_threshold: + db_session.expires_at = now + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS) + await db.flush() + # Re-issue cookie with fresh signed token to reset browser max_age timer + fresh_token = create_session_token(user_id, session_id) + _set_session_cookie(response, fresh_token) + return user @@ -119,6 +149,24 @@ async def get_current_settings( return settings_obj +# --------------------------------------------------------------------------- +# Role-based authorization dependencies +# --------------------------------------------------------------------------- + +def require_role(*allowed_roles: str): + """Factory: returns a dependency that enforces role membership.""" + async def _check( + current_user: User = Depends(get_current_user), + ) -> User: + if current_user.role not in allowed_roles: + raise HTTPException(status_code=403, detail="Insufficient permissions") + return current_user + return _check + +# Convenience aliases +require_admin = require_role("admin") + + # --------------------------------------------------------------------------- # Account lockout helpers # --------------------------------------------------------------------------- @@ -166,15 +214,36 @@ async def _create_db_session( id=session_id, user_id=user.id, expires_at=expires_at, - ip_address=ip[:45] if ip else None, # clamp to column width + ip_address=ip[:45] if ip else None, user_agent=(user_agent or "")[:255] if user_agent else None, ) db.add(db_session) - await db.commit() + await db.flush() token = create_session_token(user.id, session_id) return session_id, token +# --------------------------------------------------------------------------- +# User bootstrapping helper (Settings + default calendars) +# --------------------------------------------------------------------------- + +async def _create_user_defaults( + db: AsyncSession, user_id: int, *, preferred_name: str | None = None, +) -> None: + """Create Settings row and default calendars for a new user.""" + db.add(Settings(user_id=user_id, preferred_name=preferred_name)) + db.add(Calendar( + name="Personal", color="#3b82f6", + is_default=True, is_system=False, is_visible=True, + user_id=user_id, + )) + db.add(Calendar( + name="Birthdays", color="#f59e0b", + is_default=False, is_system=True, is_visible=True, + user_id=user_id, + )) + + # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @@ -187,28 +256,35 @@ async def setup( db: AsyncSession = Depends(get_db), ): """ - First-time setup: create the User record and a linked Settings row. + First-time setup: create the admin User + Settings + default calendars. Only works when no users exist (i.e., fresh install). """ - existing = await db.execute(select(User)) - if existing.scalar_one_or_none(): + user_count = await db.execute(select(func.count()).select_from(User)) + if user_count.scalar_one() > 0: raise HTTPException(status_code=400, detail="Setup already completed") password_hash = hash_password(data.password) - new_user = User(username=data.username, password_hash=password_hash) + new_user = User( + username=data.username, + password_hash=password_hash, + role="admin", + last_password_change_at=datetime.now(), + ) db.add(new_user) - await db.flush() # assign new_user.id before creating Settings + await db.flush() - # Create Settings row linked to this user with all defaults - new_settings = Settings(user_id=new_user.id) - db.add(new_settings) - await db.commit() + await _create_user_defaults(db, new_user.id) ip = request.client.host if request.client else "unknown" user_agent = request.headers.get("user-agent") _, token = await _create_db_session(db, new_user, ip, user_agent) _set_session_cookie(response, token) + await log_audit_event( + db, action="auth.setup_complete", actor_id=new_user.id, ip=ip, + ) + await db.commit() + return {"message": "Setup completed successfully", "authenticated": True} @@ -223,37 +299,70 @@ async def login( Authenticate with username + password. Returns: - { authenticated: true } — on success (no TOTP) + { authenticated: true } — on success (no TOTP, no enforcement) { authenticated: false, totp_required: true, mfa_token: "..." } — TOTP pending - HTTP 401 — wrong credentials (generic; never reveals which field is wrong) + { authenticated: false, mfa_setup_required: true, mfa_token: "..." } — MFA enforcement + { authenticated: false, must_change_password: true } — forced password change after admin reset + HTTP 401 — wrong credentials + HTTP 403 — account disabled (is_active=False) HTTP 423 — account locked - HTTP 429 — IP rate limited """ client_ip = request.client.host if request.client else "unknown" - # Lookup user — do NOT differentiate "user not found" from "wrong password" result = await db.execute(select(User).where(User.username == data.username)) user = result.scalar_one_or_none() if not user: + # M-02: Run Argon2id against a dummy hash so the response time is + # indistinguishable from a wrong-password attempt (prevents username enumeration). + verify_password("x", _DUMMY_HASH) raise HTTPException(status_code=401, detail="Invalid username or password") + # M-02: Run password verification BEFORE lockout check so Argon2id always + # executes — prevents distinguishing "locked" from "wrong password" via timing. + valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash) + await _check_account_lockout(user) - # Transparent bcrypt→Argon2id upgrade - valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash) - if not valid: await _record_failed_login(db, user) + await log_audit_event( + db, action="auth.login_failed", actor_id=user.id, + detail={"reason": "invalid_password"}, ip=client_ip, + ) + await db.commit() raise HTTPException(status_code=401, detail="Invalid username or password") - # Persist upgraded hash if migration happened + # Block disabled accounts — checked AFTER password verification to avoid + # leaking account-state info, and BEFORE _record_successful_login so + # last_login_at and lockout counters are not reset for inactive users. + if not user.is_active: + await log_audit_event( + db, action="auth.login_blocked_inactive", actor_id=user.id, + detail={"reason": "account_disabled"}, ip=client_ip, + ) + await db.commit() + raise HTTPException(status_code=403, detail="Account is disabled. Contact an administrator.") + if new_hash: user.password_hash = new_hash await _record_successful_login(db, user) - # If TOTP is enabled, issue a short-lived MFA challenge token instead of a full session + # SEC-03: MFA enforcement — block login entirely until MFA setup completes + if user.mfa_enforce_pending and not user.totp_enabled: + enforce_token = create_mfa_enforce_token(user.id) + await log_audit_event( + db, action="auth.mfa_enforce_prompted", actor_id=user.id, ip=client_ip, + ) + await db.commit() + return { + "authenticated": False, + "mfa_setup_required": True, + "mfa_token": enforce_token, + } + + # If TOTP is enabled, issue a short-lived MFA challenge token if user.totp_enabled: mfa_token = create_mfa_token(user.id) return { @@ -262,13 +371,98 @@ async def login( "mfa_token": mfa_token, } + # SEC-12: Forced password change after admin reset + if user.must_change_password: + # Issue a session but flag the frontend to show password change + user_agent = request.headers.get("user-agent") + _, token = await _create_db_session(db, user, client_ip, user_agent) + _set_session_cookie(response, token) + await db.commit() + return { + "authenticated": True, + "must_change_password": True, + } + user_agent = request.headers.get("user-agent") _, token = await _create_db_session(db, user, client_ip, user_agent) _set_session_cookie(response, token) + await log_audit_event( + db, action="auth.login_success", actor_id=user.id, ip=client_ip, + ) + await db.commit() + return {"authenticated": True} +@router.post("/register") +async def register( + data: RegisterRequest, + response: Response, + request: Request, + db: AsyncSession = Depends(get_db), +): + """ + Create a new standard user account. + Only available when system_config.allow_registration is True. + """ + config_result = await db.execute( + select(SystemConfig).where(SystemConfig.id == 1) + ) + config = config_result.scalar_one_or_none() + if not config or not config.allow_registration: + raise HTTPException(status_code=403, detail="Registration is not available") + + # Check username availability (generic error to prevent enumeration) + existing = await db.execute( + select(User).where(User.username == data.username) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Registration could not be completed. Please try a different username.") + + password_hash = hash_password(data.password) + # SEC-01: Explicit field assignment — never **data.model_dump() + new_user = User( + username=data.username, + password_hash=password_hash, + role="standard", + last_password_change_at=datetime.now(), + ) + + # Check if MFA enforcement is enabled for new users + if config.enforce_mfa_new_users: + new_user.mfa_enforce_pending = True + + db.add(new_user) + await db.flush() + + await _create_user_defaults(db, new_user.id) + + ip = request.client.host if request.client else "unknown" + user_agent = request.headers.get("user-agent") + + await log_audit_event( + db, action="auth.registration", actor_id=new_user.id, ip=ip, + ) + await db.commit() + + # If MFA enforcement is pending, don't issue a session — require MFA setup first + if new_user.mfa_enforce_pending: + enforce_token = create_mfa_enforce_token(new_user.id) + return { + "message": "Registration successful", + "authenticated": False, + "mfa_setup_required": True, + "mfa_token": enforce_token, + } + + _, token = await _create_db_session(db, new_user, ip, user_agent) + _set_session_cookie(response, token) + await db.commit() + + return {"message": "Registration successful", "authenticated": True} + + @router.post("/logout") async def logout( response: Response, @@ -304,13 +498,14 @@ async def auth_status( db: AsyncSession = Depends(get_db), ): """ - Check authentication status and whether initial setup has been performed. - Used by the frontend to decide whether to show login vs setup screen. + Check authentication status, role, and whether initial setup/registration is available. """ - user_result = await db.execute(select(User)) - existing_user = user_result.scalar_one_or_none() - setup_required = existing_user is None + user_count_result = await db.execute( + select(func.count()).select_from(User) + ) + setup_required = user_count_result.scalar_one() == 0 authenticated = False + role = None if not setup_required and session_cookie: payload = verify_session_token(session_cookie) @@ -326,9 +521,33 @@ async def auth_status( UserSession.expires_at > datetime.now(), ) ) - authenticated = session_result.scalar_one_or_none() is not None + if session_result.scalar_one_or_none() is not None: + authenticated = True + user_obj_result = await db.execute( + select(User).where(User.id == user_id, User.is_active == True) + ) + u = user_obj_result.scalar_one_or_none() + if u: + role = u.role + else: + authenticated = False - return {"authenticated": authenticated, "setup_required": setup_required} + # Check registration availability + registration_open = False + if not setup_required: + config_result = await db.execute( + select(SystemConfig).where(SystemConfig.id == 1) + ) + config = config_result.scalar_one_or_none() + registration_open = config.allow_registration if config else False + + return { + "authenticated": authenticated, + "setup_required": setup_required, + "role": role, + "username": u.username if authenticated and u else None, + "registration_open": registration_open, + } @router.post("/verify-password") @@ -340,8 +559,6 @@ async def verify_password( """ Verify the current user's password without changing anything. Used by the frontend lock screen to re-authenticate without a full login. - Also handles transparent bcrypt→Argon2id upgrade. - Shares the same lockout guards as /login. Nginx limit_req_zone handles IP rate limiting. """ await _check_account_lockout(current_user) @@ -350,7 +567,6 @@ async def verify_password( await _record_failed_login(db, current_user) raise HTTPException(status_code=401, detail="Invalid password") - # Persist upgraded hash if migration happened if new_hash: current_user.password_hash = new_hash await db.commit() @@ -372,7 +588,16 @@ async def change_password( await _record_failed_login(db, current_user) raise HTTPException(status_code=401, detail="Invalid current password") + if data.new_password == data.old_password: + raise HTTPException(status_code=400, detail="New password must be different from your current password") + current_user.password_hash = hash_password(data.new_password) + current_user.last_password_change_at = datetime.now() + + # Clear forced password change flag if set (SEC-12) + if current_user.must_change_password: + current_user.must_change_password = False + await db.commit() return {"message": "Password changed successfully"} diff --git a/backend/app/routers/calendars.py b/backend/app/routers/calendars.py index 70322ed..2e324a7 100644 --- a/backend/app/routers/calendars.py +++ b/backend/app/routers/calendars.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Path from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, update from typing import List @@ -18,7 +18,11 @@ async def get_calendars( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): - result = await db.execute(select(Calendar).order_by(Calendar.is_default.desc(), Calendar.name.asc())) + result = await db.execute( + select(Calendar) + .where(Calendar.user_id == current_user.id) + .order_by(Calendar.is_default.desc(), Calendar.name.asc()) + ) return result.scalars().all() @@ -34,6 +38,7 @@ async def create_calendar( is_default=False, is_system=False, is_visible=True, + user_id=current_user.id, ) db.add(new_calendar) await db.commit() @@ -43,12 +48,14 @@ async def create_calendar( @router.put("/{calendar_id}", response_model=CalendarResponse) async def update_calendar( - calendar_id: int, - calendar_update: CalendarUpdate, + calendar_id: int = Path(ge=1, le=2147483647), + calendar_update: CalendarUpdate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): - result = await db.execute(select(Calendar).where(Calendar.id == calendar_id)) + result = await db.execute( + select(Calendar).where(Calendar.id == calendar_id, Calendar.user_id == current_user.id) + ) calendar = result.scalar_one_or_none() if not calendar: @@ -70,11 +77,13 @@ async def update_calendar( @router.delete("/{calendar_id}", status_code=204) async def delete_calendar( - calendar_id: int, + calendar_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): - result = await db.execute(select(Calendar).where(Calendar.id == calendar_id)) + result = await db.execute( + select(Calendar).where(Calendar.id == calendar_id, Calendar.user_id == current_user.id) + ) calendar = result.scalar_one_or_none() if not calendar: @@ -86,8 +95,13 @@ async def delete_calendar( if calendar.is_default: raise HTTPException(status_code=400, detail="Cannot delete the default calendar") - # Reassign all events on this calendar to the default calendar - default_result = await db.execute(select(Calendar).where(Calendar.is_default == True)) + # Reassign all events on this calendar to the user's default calendar + default_result = await db.execute( + select(Calendar).where( + Calendar.user_id == current_user.id, + Calendar.is_default == True, + ) + ) default_calendar = default_result.scalar_one_or_none() if default_calendar: diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index cd0b8b0..18190db 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -8,9 +8,11 @@ from app.database import get_db from app.models.settings import Settings from app.models.todo import Todo from app.models.calendar_event import CalendarEvent +from app.models.calendar import Calendar from app.models.reminder import Reminder from app.models.project import Project -from app.routers.auth import get_current_settings +from app.models.user import User +from app.routers.auth import get_current_user, get_current_settings router = APIRouter() @@ -24,18 +26,23 @@ _not_parent_template = or_( @router.get("/dashboard") async def get_dashboard( - client_date: Optional[date] = Query(None), + client_date: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)), db: AsyncSession = Depends(get_db), - current_settings: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), + current_settings: Settings = Depends(get_current_settings), ): """Get aggregated dashboard data.""" today = client_date or date.today() upcoming_cutoff = today + timedelta(days=current_settings.upcoming_days) + # Subquery: calendar IDs belonging to this user (for event scoping) + user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + # Today's events (exclude parent templates — they are hidden, children are shown) today_start = datetime.combine(today, datetime.min.time()) today_end = datetime.combine(today, datetime.max.time()) events_query = select(CalendarEvent).where( + CalendarEvent.calendar_id.in_(user_calendar_ids), CalendarEvent.start_datetime >= today_start, CalendarEvent.start_datetime <= today_end, _not_parent_template, @@ -45,6 +52,7 @@ async def get_dashboard( # Upcoming todos (not completed, with due date from today through upcoming_days) todos_query = select(Todo).where( + Todo.user_id == current_user.id, Todo.completed == False, Todo.due_date.isnot(None), Todo.due_date >= today, @@ -55,6 +63,7 @@ async def get_dashboard( # Active reminders (not dismissed, is_active = true, from today onward) reminders_query = select(Reminder).where( + Reminder.user_id == current_user.id, Reminder.is_active == True, Reminder.is_dismissed == False, Reminder.remind_at >= today_start @@ -62,28 +71,33 @@ async def get_dashboard( reminders_result = await db.execute(reminders_query) active_reminders = reminders_result.scalars().all() - # Project stats - total_projects_result = await db.execute(select(func.count(Project.id))) + # Project stats (scoped to user) + total_projects_result = await db.execute( + select(func.count(Project.id)).where(Project.user_id == current_user.id) + ) total_projects = total_projects_result.scalar() projects_by_status_query = select( Project.status, func.count(Project.id).label("count") - ).group_by(Project.status) + ).where(Project.user_id == current_user.id).group_by(Project.status) projects_by_status_result = await db.execute(projects_by_status_query) projects_by_status = {row[0]: row[1] for row in projects_by_status_result} - # Total incomplete todos count + # Total incomplete todos count (scoped to user) total_incomplete_result = await db.execute( - select(func.count(Todo.id)).where(Todo.completed == False) + select(func.count(Todo.id)).where( + Todo.user_id == current_user.id, + Todo.completed == False, + ) ) total_incomplete_todos = total_incomplete_result.scalar() - # Starred events (upcoming, ordered by date) - now = datetime.now() + # Starred events (upcoming, ordered by date, scoped to user's calendars) starred_query = select(CalendarEvent).where( + CalendarEvent.calendar_id.in_(user_calendar_ids), CalendarEvent.is_starred == True, - CalendarEvent.start_datetime > now, + CalendarEvent.start_datetime > today_start, _not_parent_template, ).order_by(CalendarEvent.start_datetime.asc()).limit(5) starred_result = await db.execute(starred_query) @@ -141,9 +155,10 @@ async def get_dashboard( @router.get("/upcoming") async def get_upcoming( days: int = Query(default=7, ge=1, le=90), - client_date: Optional[date] = Query(None), + client_date: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)), db: AsyncSession = Depends(get_db), - current_settings: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), + current_settings: Settings = Depends(get_current_settings), ): """Get unified list of upcoming items (todos, events, reminders) sorted by date.""" today = client_date or date.today() @@ -151,8 +166,12 @@ async def get_upcoming( cutoff_datetime = datetime.combine(cutoff_date, datetime.max.time()) today_start = datetime.combine(today, datetime.min.time()) - # Get upcoming todos with due dates (today onward only) + # Subquery: calendar IDs belonging to this user + user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + + # Get upcoming todos with due dates (today onward only, scoped to user) todos_query = select(Todo).where( + Todo.user_id == current_user.id, Todo.completed == False, Todo.due_date.isnot(None), Todo.due_date >= today, @@ -161,8 +180,9 @@ async def get_upcoming( todos_result = await db.execute(todos_query) todos = todos_result.scalars().all() - # Get upcoming events (from today onward, exclude parent templates) + # Get upcoming events (from today onward, exclude parent templates, scoped to user's calendars) events_query = select(CalendarEvent).where( + CalendarEvent.calendar_id.in_(user_calendar_ids), CalendarEvent.start_datetime >= today_start, CalendarEvent.start_datetime <= cutoff_datetime, _not_parent_template, @@ -170,8 +190,9 @@ async def get_upcoming( events_result = await db.execute(events_query) events = events_result.scalars().all() - # Get upcoming reminders (today onward only) + # Get upcoming reminders (today onward only, scoped to user) reminders_query = select(Reminder).where( + Reminder.user_id == current_user.id, Reminder.is_active == True, Reminder.is_dismissed == False, Reminder.remind_at >= today_start, diff --git a/backend/app/routers/event_templates.py b/backend/app/routers/event_templates.py index 95efe42..2d50354 100644 --- a/backend/app/routers/event_templates.py +++ b/backend/app/routers/event_templates.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Path, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select @@ -20,7 +20,11 @@ async def list_templates( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - result = await db.execute(select(EventTemplate).order_by(EventTemplate.name)) + result = await db.execute( + select(EventTemplate) + .where(EventTemplate.user_id == current_user.id) + .order_by(EventTemplate.name) + ) return result.scalars().all() @@ -30,7 +34,7 @@ async def create_template( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - template = EventTemplate(**payload.model_dump()) + template = EventTemplate(**payload.model_dump(), user_id=current_user.id) db.add(template) await db.commit() await db.refresh(template) @@ -39,13 +43,16 @@ async def create_template( @router.put("/{template_id}", response_model=EventTemplateResponse) async def update_template( - template_id: int, - payload: EventTemplateUpdate, + template_id: int = Path(ge=1, le=2147483647), + payload: EventTemplateUpdate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): result = await db.execute( - select(EventTemplate).where(EventTemplate.id == template_id) + select(EventTemplate).where( + EventTemplate.id == template_id, + EventTemplate.user_id == current_user.id, + ) ) template = result.scalar_one_or_none() if template is None: @@ -61,12 +68,15 @@ async def update_template( @router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_template( - template_id: int, + template_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): result = await db.execute( - select(EventTemplate).where(EventTemplate.id == template_id) + select(EventTemplate).where( + EventTemplate.id == template_id, + EventTemplate.user_id == current_user.id, + ) ) template = result.scalar_one_or_none() if template is None: diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index d91776f..c0a3c2e 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -1,5 +1,5 @@ import json -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Path, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, delete from sqlalchemy.orm import selectinload @@ -105,19 +105,33 @@ def _birthday_events_for_range( return virtual_events -async def _get_default_calendar_id(db: AsyncSession) -> int: - """Return the id of the default calendar, raising 500 if not found.""" - result = await db.execute(select(Calendar).where(Calendar.is_default == True)) +async def _get_default_calendar_id(db: AsyncSession, user_id: int) -> int: + """Return the id of the user's default calendar, raising 500 if not found.""" + result = await db.execute( + select(Calendar).where( + Calendar.user_id == user_id, + Calendar.is_default == True, + ) + ) default = result.scalar_one_or_none() if not default: raise HTTPException(status_code=500, detail="No default calendar configured") return default.id +async def _verify_calendar_ownership(db: AsyncSession, calendar_id: int, user_id: int) -> None: + """Raise 404 if calendar_id does not belong to user_id (SEC-04).""" + result = await db.execute( + select(Calendar).where(Calendar.id == calendar_id, Calendar.user_id == user_id) + ) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Calendar not found") + + @router.get("/", response_model=None) async def get_events( - start: Optional[date] = Query(None), - end: Optional[date] = Query(None), + start: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)), + end: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ) -> List[Any]: @@ -128,9 +142,13 @@ async def get_events( recurrence_rule IS NOT NULL) are excluded — their materialised children are what get displayed on the calendar. """ + # Scope events through calendar ownership + user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + query = ( select(CalendarEvent) .options(selectinload(CalendarEvent.calendar)) + .where(CalendarEvent.calendar_id.in_(user_calendar_ids)) ) # Exclude parent template rows — they are not directly rendered @@ -154,14 +172,24 @@ async def get_events( response: List[dict] = [_event_to_dict(e) for e in events] - # Fetch Birthdays calendar; only generate virtual events if visible + # Fetch the user's Birthdays system calendar; only generate virtual events if visible bday_result = await db.execute( - select(Calendar).where(Calendar.name == "Birthdays", Calendar.is_system == True) + select(Calendar).where( + Calendar.user_id == current_user.id, + Calendar.name == "Birthdays", + Calendar.is_system == True, + ) ) bday_calendar = bday_result.scalar_one_or_none() if bday_calendar and bday_calendar.is_visible: - people_result = await db.execute(select(Person).where(Person.birthday.isnot(None))) + # Scope birthday people to this user + people_result = await db.execute( + select(Person).where( + Person.user_id == current_user.id, + Person.birthday.isnot(None), + ) + ) people = people_result.scalars().all() virtual = _birthday_events_for_range( @@ -187,9 +215,12 @@ async def create_event( data = event.model_dump() - # Resolve calendar_id to default if not provided + # Resolve calendar_id to user's default if not provided if not data.get("calendar_id"): - data["calendar_id"] = await _get_default_calendar_id(db) + data["calendar_id"] = await _get_default_calendar_id(db, current_user.id) + else: + # SEC-04: verify the target calendar belongs to the requesting user + await _verify_calendar_ownership(db, data["calendar_id"], current_user.id) # Serialize RecurrenceRule object to JSON string for DB storage # Exclude None values so defaults in recurrence service work correctly @@ -241,14 +272,19 @@ async def create_event( @router.get("/{event_id}", response_model=CalendarEventResponse) async def get_event( - event_id: int, + event_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): + user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + result = await db.execute( select(CalendarEvent) .options(selectinload(CalendarEvent.calendar)) - .where(CalendarEvent.id == event_id) + .where( + CalendarEvent.id == event_id, + CalendarEvent.calendar_id.in_(user_calendar_ids), + ) ) event = result.scalar_one_or_none() @@ -260,15 +296,20 @@ async def get_event( @router.put("/{event_id}", response_model=CalendarEventResponse) async def update_event( - event_id: int, - event_update: CalendarEventUpdate, + event_id: int = Path(ge=1, le=2147483647), + event_update: CalendarEventUpdate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): + user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + result = await db.execute( select(CalendarEvent) .options(selectinload(CalendarEvent.calendar)) - .where(CalendarEvent.id == event_id) + .where( + CalendarEvent.id == event_id, + CalendarEvent.calendar_id.in_(user_calendar_ids), + ) ) event = result.scalar_one_or_none() @@ -285,6 +326,10 @@ async def update_event( if rule_obj is not None: update_data["recurrence_rule"] = json.dumps({k: v for k, v in rule_obj.items() if v is not None}) if rule_obj else None + # SEC-04: if calendar_id is being changed, verify the target belongs to the user + if "calendar_id" in update_data and update_data["calendar_id"] is not None: + await _verify_calendar_ownership(db, update_data["calendar_id"], current_user.id) + start = update_data.get("start_datetime", event.start_datetime) end_dt = update_data.get("end_datetime", event.end_datetime) if end_dt is not None and end_dt < start: @@ -376,12 +421,19 @@ async def update_event( @router.delete("/{event_id}", status_code=204) async def delete_event( - event_id: int, + event_id: int = Path(ge=1, le=2147483647), scope: Optional[Literal["this", "this_and_future"]] = Query(None), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id)) + user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + + result = await db.execute( + select(CalendarEvent).where( + CalendarEvent.id == event_id, + CalendarEvent.calendar_id.in_(user_calendar_ids), + ) + ) event = result.scalar_one_or_none() if not event: diff --git a/backend/app/routers/locations.py b/backend/app/routers/locations.py index ed64541..30ab340 100644 --- a/backend/app/routers/locations.py +++ b/backend/app/routers/locations.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Path, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, or_ from datetime import datetime, timezone @@ -29,14 +29,15 @@ async def search_locations( """Search locations from local DB and Nominatim OSM.""" results: List[LocationSearchResult] = [] - # Local DB search + # Local DB search — scoped to user's locations local_query = ( select(Location) .where( + Location.user_id == current_user.id, or_( Location.name.ilike(f"%{q}%"), Location.address.ilike(f"%{q}%"), - ) + ), ) .limit(5) ) @@ -89,7 +90,7 @@ async def get_locations( current_user: User = Depends(get_current_user) ): """Get all locations with optional category filter.""" - query = select(Location) + query = select(Location).where(Location.user_id == current_user.id) if category: query = query.where(Location.category == category) @@ -109,7 +110,7 @@ async def create_location( current_user: User = Depends(get_current_user) ): """Create a new location.""" - new_location = Location(**location.model_dump()) + new_location = Location(**location.model_dump(), user_id=current_user.id) db.add(new_location) await db.commit() await db.refresh(new_location) @@ -119,12 +120,14 @@ async def create_location( @router.get("/{location_id}", response_model=LocationResponse) async def get_location( - location_id: int, + location_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Get a specific location by ID.""" - result = await db.execute(select(Location).where(Location.id == location_id)) + result = await db.execute( + select(Location).where(Location.id == location_id, Location.user_id == current_user.id) + ) location = result.scalar_one_or_none() if not location: @@ -135,13 +138,15 @@ async def get_location( @router.put("/{location_id}", response_model=LocationResponse) async def update_location( - location_id: int, - location_update: LocationUpdate, + location_id: int = Path(ge=1, le=2147483647), + location_update: LocationUpdate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Update a location.""" - result = await db.execute(select(Location).where(Location.id == location_id)) + result = await db.execute( + select(Location).where(Location.id == location_id, Location.user_id == current_user.id) + ) location = result.scalar_one_or_none() if not location: @@ -163,12 +168,14 @@ async def update_location( @router.delete("/{location_id}", status_code=204) async def delete_location( - location_id: int, + location_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Delete a location.""" - result = await db.execute(select(Location).where(Location.id == location_id)) + result = await db.execute( + select(Location).where(Location.id == location_id, Location.user_id == current_user.id) + ) location = result.scalar_one_or_none() if not location: diff --git a/backend/app/routers/people.py b/backend/app/routers/people.py index 150f268..2c1b517 100644 --- a/backend/app/routers/people.py +++ b/backend/app/routers/people.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Path, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, or_ from datetime import datetime, timezone @@ -37,7 +37,7 @@ async def get_people( current_user: User = Depends(get_current_user) ): """Get all people with optional search and category filter.""" - query = select(Person) + query = select(Person).where(Person.user_id == current_user.id) if search: term = f"%{search}%" @@ -75,7 +75,7 @@ async def create_person( parts = data['name'].split(' ', 1) data['first_name'] = parts[0] data['last_name'] = parts[1] if len(parts) > 1 else None - new_person = Person(**data) + new_person = Person(**data, user_id=current_user.id) new_person.name = _compute_display_name( new_person.first_name, new_person.last_name, @@ -91,12 +91,14 @@ async def create_person( @router.get("/{person_id}", response_model=PersonResponse) async def get_person( - person_id: int, + person_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Get a specific person by ID.""" - result = await db.execute(select(Person).where(Person.id == person_id)) + result = await db.execute( + select(Person).where(Person.id == person_id, Person.user_id == current_user.id) + ) person = result.scalar_one_or_none() if not person: @@ -107,13 +109,15 @@ async def get_person( @router.put("/{person_id}", response_model=PersonResponse) async def update_person( - person_id: int, - person_update: PersonUpdate, + person_id: int = Path(ge=1, le=2147483647), + person_update: PersonUpdate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Update a person and refresh the denormalised display name.""" - result = await db.execute(select(Person).where(Person.id == person_id)) + result = await db.execute( + select(Person).where(Person.id == person_id, Person.user_id == current_user.id) + ) person = result.scalar_one_or_none() if not person: @@ -142,12 +146,14 @@ async def update_person( @router.delete("/{person_id}", status_code=204) async def delete_person( - person_id: int, + person_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Delete a person.""" - result = await db.execute(select(Person).where(Person.id == person_id)) + result = await db.execute( + select(Person).where(Person.id == person_id, Person.user_id == current_user.id) + ) person = result.scalar_one_or_none() if not person: diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index dd86618..7314287 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Path, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from sqlalchemy.orm import selectinload @@ -49,7 +49,12 @@ async def get_projects( current_user: User = Depends(get_current_user) ): """Get all projects with their tasks. Optionally filter by tracked status.""" - query = select(Project).options(*_project_load_options()).order_by(Project.created_at.desc()) + query = ( + select(Project) + .options(*_project_load_options()) + .where(Project.user_id == current_user.id) + .order_by(Project.created_at.desc()) + ) if tracked is not None: query = query.where(Project.is_tracked == tracked) result = await db.execute(query) @@ -77,6 +82,7 @@ async def get_tracked_tasks( selectinload(ProjectTask.parent_task), ) .where( + Project.user_id == current_user.id, Project.is_tracked == True, ProjectTask.due_date.isnot(None), ProjectTask.due_date >= today, @@ -110,7 +116,7 @@ async def create_project( current_user: User = Depends(get_current_user) ): """Create a new project.""" - new_project = Project(**project.model_dump()) + new_project = Project(**project.model_dump(), user_id=current_user.id) db.add(new_project) await db.commit() @@ -122,12 +128,16 @@ async def create_project( @router.get("/{project_id}", response_model=ProjectResponse) async def get_project( - project_id: int, + project_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Get a specific project by ID with its tasks.""" - query = select(Project).options(*_project_load_options()).where(Project.id == project_id) + query = ( + select(Project) + .options(*_project_load_options()) + .where(Project.id == project_id, Project.user_id == current_user.id) + ) result = await db.execute(query) project = result.scalar_one_or_none() @@ -139,13 +149,15 @@ async def get_project( @router.put("/{project_id}", response_model=ProjectResponse) async def update_project( - project_id: int, - project_update: ProjectUpdate, + project_id: int = Path(ge=1, le=2147483647), + project_update: ProjectUpdate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Update a project.""" - result = await db.execute(select(Project).where(Project.id == project_id)) + result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) project = result.scalar_one_or_none() if not project: @@ -166,12 +178,14 @@ async def update_project( @router.delete("/{project_id}", status_code=204) async def delete_project( - project_id: int, + project_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Delete a project and all its tasks.""" - result = await db.execute(select(Project).where(Project.id == project_id)) + result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) project = result.scalar_one_or_none() if not project: @@ -185,12 +199,15 @@ async def delete_project( @router.get("/{project_id}/tasks", response_model=List[ProjectTaskResponse]) async def get_project_tasks( - project_id: int, + project_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Get top-level tasks for a specific project (subtasks are nested).""" - result = await db.execute(select(Project).where(Project.id == project_id)) + # Verify project ownership first + result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) project = result.scalar_one_or_none() if not project: @@ -213,13 +230,16 @@ async def get_project_tasks( @router.post("/{project_id}/tasks", response_model=ProjectTaskResponse, status_code=201) async def create_project_task( - project_id: int, - task: ProjectTaskCreate, + project_id: int = Path(ge=1, le=2147483647), + task: ProjectTaskCreate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Create a new task or subtask for a project.""" - result = await db.execute(select(Project).where(Project.id == project_id)) + # Verify project ownership first + result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) project = result.scalar_one_or_none() if not project: @@ -259,13 +279,16 @@ async def create_project_task( @router.put("/{project_id}/tasks/reorder", status_code=200) async def reorder_tasks( - project_id: int, - items: List[ReorderItem], + project_id: int = Path(ge=1, le=2147483647), + items: List[ReorderItem] = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Bulk update sort_order for tasks.""" - result = await db.execute(select(Project).where(Project.id == project_id)) + # Verify project ownership first + result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) project = result.scalar_one_or_none() if not project: @@ -289,13 +312,20 @@ async def reorder_tasks( @router.put("/{project_id}/tasks/{task_id}", response_model=ProjectTaskResponse) async def update_project_task( - project_id: int, - task_id: int, - task_update: ProjectTaskUpdate, + project_id: int = Path(ge=1, le=2147483647), + task_id: int = Path(ge=1, le=2147483647), + task_update: ProjectTaskUpdate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Update a project task.""" + # Verify project ownership first, then fetch task scoped to that project + project_result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) + if not project_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Project not found") + result = await db.execute( select(ProjectTask).where( ProjectTask.id == task_id, @@ -326,12 +356,19 @@ async def update_project_task( @router.delete("/{project_id}/tasks/{task_id}", status_code=204) async def delete_project_task( - project_id: int, - task_id: int, + project_id: int = Path(ge=1, le=2147483647), + task_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Delete a project task (cascades to subtasks).""" + # Verify project ownership first, then fetch task scoped to that project + project_result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) + if not project_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Project not found") + result = await db.execute( select(ProjectTask).where( ProjectTask.id == task_id, @@ -351,13 +388,20 @@ async def delete_project_task( @router.post("/{project_id}/tasks/{task_id}/comments", response_model=TaskCommentResponse, status_code=201) async def create_task_comment( - project_id: int, - task_id: int, - comment: TaskCommentCreate, + project_id: int = Path(ge=1, le=2147483647), + task_id: int = Path(ge=1, le=2147483647), + comment: TaskCommentCreate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Add a comment to a task.""" + # Verify project ownership first, then fetch task scoped to that project + project_result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) + if not project_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Project not found") + result = await db.execute( select(ProjectTask).where( ProjectTask.id == task_id, @@ -379,13 +423,20 @@ async def create_task_comment( @router.delete("/{project_id}/tasks/{task_id}/comments/{comment_id}", status_code=204) async def delete_task_comment( - project_id: int, - task_id: int, - comment_id: int, + project_id: int = Path(ge=1, le=2147483647), + task_id: int = Path(ge=1, le=2147483647), + comment_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Delete a task comment.""" + # Verify project ownership first, then fetch comment scoped through task + project_result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) + if not project_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Project not found") + result = await db.execute( select(TaskComment).where( TaskComment.id == comment_id, diff --git a/backend/app/routers/reminders.py b/backend/app/routers/reminders.py index b33872a..ef4de54 100644 --- a/backend/app/routers/reminders.py +++ b/backend/app/routers/reminders.py @@ -1,6 +1,6 @@ from datetime import datetime, timedelta -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Path, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_, or_ from typing import Optional, List @@ -22,7 +22,7 @@ async def get_reminders( current_user: User = Depends(get_current_user) ): """Get all reminders with optional filters.""" - query = select(Reminder) + query = select(Reminder).where(Reminder.user_id == current_user.id) if active is not None: query = query.where(Reminder.is_active == active) @@ -48,6 +48,7 @@ async def get_due_reminders( now = client_now or datetime.now() query = select(Reminder).where( and_( + Reminder.user_id == current_user.id, Reminder.remind_at <= now, Reminder.is_dismissed == False, Reminder.is_active == True, @@ -68,13 +69,18 @@ async def get_due_reminders( @router.patch("/{reminder_id}/snooze", response_model=ReminderResponse) async def snooze_reminder( - reminder_id: int, - body: ReminderSnooze, + reminder_id: int = Path(ge=1, le=2147483647), + body: ReminderSnooze = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Snooze a reminder for N minutes from now.""" - result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) + result = await db.execute( + select(Reminder).where( + Reminder.id == reminder_id, + Reminder.user_id == current_user.id, + ) + ) reminder = result.scalar_one_or_none() if not reminder: @@ -99,7 +105,7 @@ async def create_reminder( current_user: User = Depends(get_current_user) ): """Create a new reminder.""" - new_reminder = Reminder(**reminder.model_dump()) + new_reminder = Reminder(**reminder.model_dump(), user_id=current_user.id) db.add(new_reminder) await db.commit() await db.refresh(new_reminder) @@ -109,12 +115,17 @@ async def create_reminder( @router.get("/{reminder_id}", response_model=ReminderResponse) async def get_reminder( - reminder_id: int, + reminder_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Get a specific reminder by ID.""" - result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) + result = await db.execute( + select(Reminder).where( + Reminder.id == reminder_id, + Reminder.user_id == current_user.id, + ) + ) reminder = result.scalar_one_or_none() if not reminder: @@ -125,13 +136,18 @@ async def get_reminder( @router.put("/{reminder_id}", response_model=ReminderResponse) async def update_reminder( - reminder_id: int, - reminder_update: ReminderUpdate, + reminder_id: int = Path(ge=1, le=2147483647), + reminder_update: ReminderUpdate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Update a reminder.""" - result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) + result = await db.execute( + select(Reminder).where( + Reminder.id == reminder_id, + Reminder.user_id == current_user.id, + ) + ) reminder = result.scalar_one_or_none() if not reminder: @@ -159,12 +175,17 @@ async def update_reminder( @router.delete("/{reminder_id}", status_code=204) async def delete_reminder( - reminder_id: int, + reminder_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Delete a reminder.""" - result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) + result = await db.execute( + select(Reminder).where( + Reminder.id == reminder_id, + Reminder.user_id == current_user.id, + ) + ) reminder = result.scalar_one_or_none() if not reminder: @@ -178,12 +199,17 @@ async def delete_reminder( @router.patch("/{reminder_id}/dismiss", response_model=ReminderResponse) async def dismiss_reminder( - reminder_id: int, + reminder_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Dismiss a reminder.""" - result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) + result = await db.execute( + select(Reminder).where( + Reminder.id == reminder_id, + Reminder.user_id == current_user.id, + ) + ) reminder = result.scalar_one_or_none() if not reminder: diff --git a/backend/app/routers/todos.py b/backend/app/routers/todos.py index 57841a7..f42fdb7 100644 --- a/backend/app/routers/todos.py +++ b/backend/app/routers/todos.py @@ -1,6 +1,6 @@ -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Path, Query from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, and_ +from sqlalchemy import select, and_, func from typing import Optional, List from datetime import datetime, date, timedelta import calendar @@ -73,15 +73,31 @@ def _calculate_recurrence( return reset_at, next_due -async def _reactivate_recurring_todos(db: AsyncSession) -> None: +async def _reactivate_recurring_todos(db: AsyncSession, user_id: int) -> None: """Auto-reactivate recurring todos whose reset_at has passed. Uses flush (not commit) so changes are visible to the subsequent query within the same transaction. The caller's commit handles persistence. + Scoped to a single user to avoid cross-user reactivation. """ now = datetime.now() + + # Fast-path: skip the FOR UPDATE lock when nothing needs reactivation (common case) + count = await db.scalar( + select(func.count()).select_from(Todo).where( + Todo.user_id == user_id, + Todo.completed == True, # noqa: E712 + Todo.recurrence_rule.isnot(None), + Todo.reset_at.isnot(None), + Todo.reset_at <= now, + ) + ) + if count == 0: + return + query = select(Todo).where( and_( + Todo.user_id == user_id, Todo.completed == True, Todo.recurrence_rule.isnot(None), Todo.reset_at.isnot(None), @@ -110,13 +126,14 @@ async def get_todos( category: Optional[str] = Query(None), search: Optional[str] = Query(None), db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), + current_settings: Settings = Depends(get_current_settings), ): """Get all todos with optional filters.""" # Reactivate any recurring todos whose reset time has passed - await _reactivate_recurring_todos(db) + await _reactivate_recurring_todos(db, current_user.id) - query = select(Todo) + query = select(Todo).where(Todo.user_id == current_user.id) if completed is not None: query = query.where(Todo.completed == completed) @@ -144,10 +161,10 @@ async def get_todos( async def create_todo( todo: TodoCreate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), ): """Create a new todo.""" - new_todo = Todo(**todo.model_dump()) + new_todo = Todo(**todo.model_dump(), user_id=current_user.id) db.add(new_todo) await db.commit() await db.refresh(new_todo) @@ -157,12 +174,14 @@ async def create_todo( @router.get("/{todo_id}", response_model=TodoResponse) async def get_todo( - todo_id: int, + todo_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), ): """Get a specific todo by ID.""" - result = await db.execute(select(Todo).where(Todo.id == todo_id)) + result = await db.execute( + select(Todo).where(Todo.id == todo_id, Todo.user_id == current_user.id) + ) todo = result.scalar_one_or_none() if not todo: @@ -173,13 +192,16 @@ async def get_todo( @router.put("/{todo_id}", response_model=TodoResponse) async def update_todo( - todo_id: int, - todo_update: TodoUpdate, + todo_id: int = Path(ge=1, le=2147483647), + todo_update: TodoUpdate = ..., db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), + current_settings: Settings = Depends(get_current_settings), ): """Update a todo.""" - result = await db.execute(select(Todo).where(Todo.id == todo_id)) + result = await db.execute( + select(Todo).where(Todo.id == todo_id, Todo.user_id == current_user.id) + ) todo = result.scalar_one_or_none() if not todo: @@ -210,7 +232,7 @@ async def update_todo( reset_at, next_due = _calculate_recurrence( todo.recurrence_rule, todo.due_date, - current_user.first_day_of_week, + current_settings.first_day_of_week, ) todo.reset_at = reset_at todo.next_due_date = next_due @@ -227,12 +249,14 @@ async def update_todo( @router.delete("/{todo_id}", status_code=204) async def delete_todo( - todo_id: int, + todo_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), ): """Delete a todo.""" - result = await db.execute(select(Todo).where(Todo.id == todo_id)) + result = await db.execute( + select(Todo).where(Todo.id == todo_id, Todo.user_id == current_user.id) + ) todo = result.scalar_one_or_none() if not todo: @@ -246,12 +270,15 @@ async def delete_todo( @router.patch("/{todo_id}/toggle", response_model=TodoResponse) async def toggle_todo( - todo_id: int, + todo_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), + current_settings: Settings = Depends(get_current_settings), ): """Toggle todo completion status. For recurring todos, calculates reset schedule.""" - result = await db.execute(select(Todo).where(Todo.id == todo_id)) + result = await db.execute( + select(Todo).where(Todo.id == todo_id, Todo.user_id == current_user.id) + ) todo = result.scalar_one_or_none() if not todo: @@ -267,7 +294,7 @@ async def toggle_todo( reset_at, next_due = _calculate_recurrence( todo.recurrence_rule, todo.due_date, - current_user.first_day_of_week, + current_settings.first_day_of_week, ) todo.reset_at = reset_at todo.next_due_date = next_due diff --git a/backend/app/routers/totp.py b/backend/app/routers/totp.py index e8b9be7..e0424da 100644 --- a/backend/app/routers/totp.py +++ b/backend/app/routers/totp.py @@ -24,7 +24,7 @@ from datetime import datetime, timedelta from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request, Response -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, delete from sqlalchemy.exc import IntegrityError @@ -39,6 +39,7 @@ from app.services.auth import ( verify_password_with_upgrade, hash_password, verify_mfa_token, + verify_mfa_enforce_token, create_session_token, ) from app.services.totp import ( @@ -75,23 +76,38 @@ _ph = PasswordHasher( # --------------------------------------------------------------------------- class TOTPConfirmRequest(BaseModel): - code: str + model_config = ConfigDict(extra="forbid") + code: str = Field(min_length=6, max_length=6) class TOTPVerifyRequest(BaseModel): - mfa_token: str - code: Optional[str] = None # 6-digit TOTP code - backup_code: Optional[str] = None # Alternative: XXXX-XXXX backup code + model_config = ConfigDict(extra="forbid") + mfa_token: str = Field(max_length=256) + code: Optional[str] = Field(None, min_length=6, max_length=6) # 6-digit TOTP code + backup_code: Optional[str] = Field(None, max_length=9) # XXXX-XXXX backup code class TOTPDisableRequest(BaseModel): - password: str - code: str # Current TOTP code required to disable + model_config = ConfigDict(extra="forbid") + password: str = Field(max_length=128) + code: str = Field(min_length=6, max_length=6) # Current TOTP code required to disable class BackupCodesRegenerateRequest(BaseModel): - password: str - code: str # Current TOTP code required to regenerate + model_config = ConfigDict(extra="forbid") + password: str = Field(max_length=128) + code: str = Field(min_length=6, max_length=6) # Current TOTP code required to regenerate + + +class EnforceSetupRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + mfa_token: str = Field(max_length=256) + + +class EnforceConfirmRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + mfa_token: str = Field(max_length=256) + code: str = Field(min_length=6, max_length=6) # 6-digit TOTP code from authenticator app # --------------------------------------------------------------------------- @@ -394,6 +410,108 @@ async def regenerate_backup_codes( return {"backup_codes": plaintext_codes} +@router.post("/totp/enforce-setup") +async def enforce_setup_totp( + data: EnforceSetupRequest, + db: AsyncSession = Depends(get_db), +): + """ + Generate TOTP secret + QR code + backup codes during MFA enforcement. + + Called after login returns mfa_setup_required=True. Uses the mfa_enforce_token + (not a session cookie) because the user is not yet fully authenticated. + + Idempotent: regenerates secret if called again before confirm. + Returns { secret, qr_code_base64, backup_codes }. + """ + user_id = verify_mfa_enforce_token(data.mfa_token) + if user_id is None: + raise HTTPException(status_code=401, detail="Invalid or expired enforcement token — please log in again") + + result = await db.execute(select(User).where(User.id == user_id, User.is_active == True)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=401, detail="User not found or inactive") + + if not user.mfa_enforce_pending: + raise HTTPException(status_code=400, detail="MFA enforcement is not pending for this account") + + if user.totp_enabled: + raise HTTPException(status_code=400, detail="TOTP is already enabled for this account") + + # Generate new secret (idempotent — overwrite any unconfirmed secret) + raw_secret = generate_totp_secret() + encrypted_secret = encrypt_totp_secret(raw_secret) + user.totp_secret = encrypted_secret + user.totp_enabled = False # Not enabled until enforce-confirm called + + # Generate backup codes — hash before storage, return plaintext once + plaintext_codes = generate_backup_codes(10) + await _delete_backup_codes(db, user.id) + await _store_backup_codes(db, user.id, plaintext_codes) + + await db.commit() + + uri = get_totp_uri(encrypted_secret, user.username) + qr_base64 = generate_qr_base64(uri) + + return { + "secret": raw_secret, + "qr_code_base64": qr_base64, + "backup_codes": plaintext_codes, + } + + +@router.post("/totp/enforce-confirm") +async def enforce_confirm_totp( + data: EnforceConfirmRequest, + request: Request, + response: Response, + db: AsyncSession = Depends(get_db), +): + """ + Confirm TOTP setup during enforcement, clear the pending flag, issue a full session. + + Must be called after /totp/enforce-setup while totp_enabled is still False. + On success: enables TOTP, clears mfa_enforce_pending, sets session cookie, + returns { authenticated: true }. + """ + user_id = verify_mfa_enforce_token(data.mfa_token) + if user_id is None: + raise HTTPException(status_code=401, detail="Invalid or expired enforcement token — please log in again") + + result = await db.execute(select(User).where(User.id == user_id, User.is_active == True)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=401, detail="User not found or inactive") + + if not user.mfa_enforce_pending: + raise HTTPException(status_code=400, detail="MFA enforcement is not pending for this account") + + if not user.totp_secret: + raise HTTPException(status_code=400, detail="TOTP setup not started — call /totp/enforce-setup first") + + if user.totp_enabled: + raise HTTPException(status_code=400, detail="TOTP is already enabled") + + # Verify the confirmation code + matched_window = verify_totp_code(user.totp_secret, data.code) + if matched_window is None: + raise HTTPException(status_code=400, detail="Invalid code — check your authenticator app time sync") + + # Enable TOTP and clear the enforcement flag + user.totp_enabled = True + user.mfa_enforce_pending = False + user.last_login_at = datetime.now() + await db.commit() + + # Issue a full session + token = await _create_full_session(db, user, request) + _set_session_cookie(response, token) + + return {"authenticated": True} + + @router.get("/totp/status") async def totp_status( db: AsyncSession = Depends(get_db), diff --git a/backend/app/routers/weather.py b/backend/app/routers/weather.py index 25f8b94..8568b37 100644 --- a/backend/app/routers/weather.py +++ b/backend/app/routers/weather.py @@ -3,6 +3,7 @@ from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from datetime import datetime, timedelta +from collections import OrderedDict import asyncio import urllib.request import urllib.parse @@ -11,13 +12,39 @@ import json from app.database import get_db from app.models.settings import Settings +from app.models.user import User from app.config import settings as app_settings from app.routers.auth import get_current_user, get_current_settings -from app.models.user import User router = APIRouter() -_cache: dict = {} +# SEC-15: Bounded LRU cache keyed by (user_id, location) — max 100 entries. +# OrderedDict preserves insertion order; move_to_end on hit, popitem(last=False) +# to evict the oldest when capacity is exceeded. +# NOTE: This cache is process-local. With multiple workers each process would +# maintain its own copy, wasting API quota. Currently safe — single Uvicorn worker. +_CACHE_MAX = 100 +_cache: OrderedDict = OrderedDict() + + +def _cache_get(key: tuple) -> dict | None: + """Return cached entry if it exists and hasn't expired.""" + entry = _cache.get(key) + if entry and datetime.now() < entry["expires_at"]: + _cache.move_to_end(key) # LRU: promote to most-recently-used + return entry["data"] + if entry: + del _cache[key] # expired — evict immediately + return None + + +def _cache_set(key: tuple, data: dict) -> None: + """Store an entry; evict the oldest if over capacity.""" + if key in _cache: + _cache.move_to_end(key) + _cache[key] = {"data": data, "expires_at": datetime.now() + timedelta(hours=1)} + while len(_cache) > _CACHE_MAX: + _cache.popitem(last=False) # evict LRU (oldest) class GeoSearchResult(BaseModel): @@ -66,23 +93,24 @@ async def search_locations( @router.get("/") async def get_weather( db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), + current_settings: Settings = Depends(get_current_settings), ): - city = current_user.weather_city - lat = current_user.weather_lat - lon = current_user.weather_lon + city = current_settings.weather_city + lat = current_settings.weather_lat + lon = current_settings.weather_lon if not city and (lat is None or lon is None): raise HTTPException(status_code=400, detail="No weather location configured") - # Build cache key from coordinates or city + # Cache key includes user_id so each user gets isolated cache entries use_coords = lat is not None and lon is not None - cache_key = f"{lat},{lon}" if use_coords else city + location_key = f"{lat},{lon}" if use_coords else city + cache_key = (current_user.id, location_key) - # Check cache - now = datetime.now() - if _cache.get("expires_at") and now < _cache["expires_at"] and _cache.get("cache_key") == cache_key: - return _cache["data"] + cached = _cache_get(cache_key) + if cached is not None: + return cached api_key = app_settings.OPENWEATHERMAP_API_KEY if not api_key: @@ -122,11 +150,7 @@ async def get_weather( "city": current_data["name"], } - # Cache for 1 hour - _cache["data"] = weather_result - _cache["expires_at"] = now + timedelta(hours=1) - _cache["cache_key"] = cache_key - + _cache_set(cache_key, weather_result) return weather_result except urllib.error.URLError: diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py new file mode 100644 index 0000000..c24160d --- /dev/null +++ b/backend/app/schemas/admin.py @@ -0,0 +1,194 @@ +""" +Admin API schemas — Pydantic v2. + +All admin-facing request/response shapes live here to keep the admin router +clean and testable in isolation. +""" +import re +from datetime import datetime +from typing import Optional, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.schemas.auth import _validate_username, _validate_password_strength + + +# --------------------------------------------------------------------------- +# User list / detail +# --------------------------------------------------------------------------- + +class UserListItem(BaseModel): + id: int + username: str + email: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + role: str + is_active: bool + last_login_at: Optional[datetime] = None + last_password_change_at: Optional[datetime] = None + totp_enabled: bool + mfa_enforce_pending: bool + created_at: datetime + active_sessions: int = 0 + + model_config = ConfigDict(from_attributes=True) + + +class UserListResponse(BaseModel): + users: list[UserListItem] + total: int + + +class UserDetailResponse(UserListItem): + preferred_name: Optional[str] = None + must_change_password: bool = False + locked_until: Optional[datetime] = None + + +# --------------------------------------------------------------------------- +# Mutating user requests +# --------------------------------------------------------------------------- + +class CreateUserRequest(BaseModel): + """Admin-created user — allows role selection (unlike public RegisterRequest).""" + model_config = ConfigDict(extra="forbid") + + username: str + password: str + role: Literal["admin", "standard", "public_event_manager"] = "standard" + email: Optional[str] = Field(None, max_length=254) + first_name: Optional[str] = Field(None, max_length=100) + last_name: Optional[str] = Field(None, max_length=100) + preferred_name: Optional[str] = Field(None, max_length=100) + + @field_validator("username") + @classmethod + def validate_username(cls, v: str) -> str: + return _validate_username(v) + + @field_validator("password") + @classmethod + def validate_password(cls, v: str) -> str: + return _validate_password_strength(v) + + @field_validator("email") + @classmethod + def validate_email(cls, v: str | None) -> str | None: + if v is None: + return None + v = v.strip().lower() + if not v: + return None + # Basic format check: must have exactly one @, with non-empty local and domain parts + if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", v): + raise ValueError("Invalid email format") + return v + + @field_validator("first_name", "last_name", "preferred_name") + @classmethod + def validate_name_fields(cls, v: str | None) -> str | None: + if v is None: + return None + v = v.strip() + if not v: + return None + # Reject ASCII control characters + if re.search(r"[\x00-\x1f]", v): + raise ValueError("Name must not contain control characters") + return v + + +class UpdateUserRoleRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + role: Literal["admin", "standard", "public_event_manager"] + + +class ToggleActiveRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + is_active: bool + + +class ToggleMfaEnforceRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + enforce: bool + + +# --------------------------------------------------------------------------- +# System config +# --------------------------------------------------------------------------- + +class SystemConfigResponse(BaseModel): + allow_registration: bool + enforce_mfa_new_users: bool + + model_config = ConfigDict(from_attributes=True) + + +class SystemConfigUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + allow_registration: Optional[bool] = None + enforce_mfa_new_users: Optional[bool] = None + + +# --------------------------------------------------------------------------- +# Admin dashboard +# --------------------------------------------------------------------------- + +class RecentLoginItem(BaseModel): + username: str + last_login_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + + +class RecentAuditItem(BaseModel): + action: str + actor_username: Optional[str] = None + target_username: Optional[str] = None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class AdminDashboardResponse(BaseModel): + total_users: int + active_users: int + admin_count: int + active_sessions: int + mfa_adoption_rate: float + recent_logins: list[RecentLoginItem] + recent_audit_entries: list[RecentAuditItem] + + +# --------------------------------------------------------------------------- +# Password reset +# --------------------------------------------------------------------------- + +class ResetPasswordResponse(BaseModel): + message: str + temporary_password: str + + +class DeleteUserResponse(BaseModel): + message: str + deleted_username: str + + +# --------------------------------------------------------------------------- +# Audit log +# --------------------------------------------------------------------------- + +class AuditLogEntry(BaseModel): + id: int + actor_username: Optional[str] = None + target_username: Optional[str] = None + action: str + detail: Optional[str] = None + ip_address: Optional[str] = None + created_at: datetime + + +class AuditLogResponse(BaseModel): + entries: list[AuditLogEntry] + total: int diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 806835d..ad86a15 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -1,5 +1,5 @@ import re -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, ConfigDict, field_validator def _validate_password_strength(v: str) -> str: @@ -21,19 +21,48 @@ def _validate_password_strength(v: str) -> str: return v +def _validate_username(v: str) -> str: + """Shared username validation.""" + v = v.strip().lower() + if not 3 <= len(v) <= 50: + raise ValueError("Username must be 3–50 characters") + if not re.fullmatch(r"[a-z0-9_.\-]+", v): + raise ValueError("Username may only contain letters, numbers, _ . and -") + return v + + class SetupRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + username: str password: str @field_validator("username") @classmethod def validate_username(cls, v: str) -> str: - v = v.strip().lower() - if not 3 <= len(v) <= 50: - raise ValueError("Username must be 3–50 characters") - if not re.fullmatch(r"[a-z0-9_\-]+", v): - raise ValueError("Username may only contain letters, numbers, _ and -") - return v + return _validate_username(v) + + @field_validator("password") + @classmethod + def validate_password(cls, v: str) -> str: + return _validate_password_strength(v) + + +class RegisterRequest(BaseModel): + """ + Public registration schema — SEC-01: extra="forbid" prevents role injection. + An attacker sending {"username": "...", "password": "...", "role": "admin"} + will get a 422 Validation Error instead of silent acceptance. + """ + model_config = ConfigDict(extra="forbid") + + username: str + password: str + + @field_validator("username") + @classmethod + def validate_username(cls, v: str) -> str: + return _validate_username(v) @field_validator("password") @classmethod @@ -42,6 +71,8 @@ class SetupRequest(BaseModel): class LoginRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + username: str password: str @@ -53,6 +84,8 @@ class LoginRequest(BaseModel): class ChangePasswordRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + old_password: str new_password: str @@ -63,6 +96,8 @@ class ChangePasswordRequest(BaseModel): class VerifyPasswordRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + password: str @field_validator("password") diff --git a/backend/app/schemas/calendar.py b/backend/app/schemas/calendar.py index d2eafd4..e9e2753 100644 --- a/backend/app/schemas/calendar.py +++ b/backend/app/schemas/calendar.py @@ -1,16 +1,20 @@ -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from datetime import datetime from typing import Optional class CalendarCreate(BaseModel): - name: str - color: str = "#3b82f6" + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=100) + color: str = Field("#3b82f6", max_length=20) class CalendarUpdate(BaseModel): - name: Optional[str] = None - color: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + name: Optional[str] = Field(None, min_length=1, max_length=100) + color: Optional[str] = Field(None, max_length=20) is_visible: Optional[bool] = None diff --git a/backend/app/schemas/calendar_event.py b/backend/app/schemas/calendar_event.py index 54e40ff..88ccf66 100644 --- a/backend/app/schemas/calendar_event.py +++ b/backend/app/schemas/calendar_event.py @@ -39,12 +39,14 @@ def _coerce_recurrence_rule(v): class CalendarEventCreate(BaseModel): - title: str - description: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + title: str = Field(min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=5000) start_datetime: datetime end_datetime: datetime all_day: bool = False - color: Optional[str] = None + color: Optional[str] = Field(None, max_length=20) location_id: Optional[int] = None recurrence_rule: Optional[RecurrenceRule] = None is_starred: bool = False @@ -57,12 +59,14 @@ class CalendarEventCreate(BaseModel): class CalendarEventUpdate(BaseModel): - title: Optional[str] = None - description: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + title: Optional[str] = Field(None, min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=5000) start_datetime: Optional[datetime] = None end_datetime: Optional[datetime] = None all_day: Optional[bool] = None - color: Optional[str] = None + color: Optional[str] = Field(None, max_length=20) location_id: Optional[int] = None recurrence_rule: Optional[RecurrenceRule] = None is_starred: Optional[bool] = None diff --git a/backend/app/schemas/event_template.py b/backend/app/schemas/event_template.py index 9868537..75fdf4e 100644 --- a/backend/app/schemas/event_template.py +++ b/backend/app/schemas/event_template.py @@ -1,25 +1,29 @@ -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from datetime import datetime from typing import Optional class EventTemplateCreate(BaseModel): - name: str - title: str - description: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + title: str = Field(min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=5000) calendar_id: Optional[int] = None - recurrence_rule: Optional[str] = None + recurrence_rule: Optional[str] = Field(None, max_length=5000) all_day: bool = False location_id: Optional[int] = None is_starred: bool = False class EventTemplateUpdate(BaseModel): - name: Optional[str] = None - title: Optional[str] = None - description: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + name: Optional[str] = Field(None, min_length=1, max_length=255) + title: Optional[str] = Field(None, min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=5000) calendar_id: Optional[int] = None - recurrence_rule: Optional[str] = None + recurrence_rule: Optional[str] = Field(None, max_length=5000) all_day: Optional[bool] = None location_id: Optional[int] = None is_starred: Optional[bool] = None diff --git a/backend/app/schemas/location.py b/backend/app/schemas/location.py index e436d57..d8334ee 100644 --- a/backend/app/schemas/location.py +++ b/backend/app/schemas/location.py @@ -1,5 +1,5 @@ import re -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from datetime import datetime from typing import Optional, Literal @@ -14,13 +14,15 @@ class LocationSearchResult(BaseModel): class LocationCreate(BaseModel): - name: str - address: str - category: str = "other" - notes: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + address: str = Field(min_length=1, max_length=2000) + category: str = Field("other", max_length=100) + notes: Optional[str] = Field(None, max_length=5000) is_frequent: bool = False - contact_number: Optional[str] = None - email: Optional[str] = None + contact_number: Optional[str] = Field(None, max_length=50) + email: Optional[str] = Field(None, max_length=255) @field_validator('email') @classmethod @@ -31,13 +33,15 @@ class LocationCreate(BaseModel): class LocationUpdate(BaseModel): - name: Optional[str] = None - address: Optional[str] = None - category: Optional[str] = None - notes: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + name: Optional[str] = Field(None, min_length=1, max_length=255) + address: Optional[str] = Field(None, min_length=1, max_length=2000) + category: Optional[str] = Field(None, max_length=100) + notes: Optional[str] = Field(None, max_length=5000) is_frequent: Optional[bool] = None - contact_number: Optional[str] = None - email: Optional[str] = None + contact_number: Optional[str] = Field(None, max_length=50) + email: Optional[str] = Field(None, max_length=255) @field_validator('email') @classmethod diff --git a/backend/app/schemas/person.py b/backend/app/schemas/person.py index ebe37c7..6e2a5e7 100644 --- a/backend/app/schemas/person.py +++ b/backend/app/schemas/person.py @@ -1,5 +1,5 @@ import re -from pydantic import BaseModel, ConfigDict, model_validator, field_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator, field_validator from datetime import datetime, date from typing import Optional @@ -7,20 +7,22 @@ _EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$') class PersonCreate(BaseModel): - name: Optional[str] = None # legacy fallback — auto-split into first/last if provided alone - first_name: Optional[str] = None - last_name: Optional[str] = None - nickname: Optional[str] = None - email: Optional[str] = None - phone: Optional[str] = None - mobile: Optional[str] = None - address: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + name: Optional[str] = Field(None, max_length=255) # legacy fallback — auto-split into first/last if provided alone + first_name: Optional[str] = Field(None, max_length=100) + last_name: Optional[str] = Field(None, max_length=100) + nickname: Optional[str] = Field(None, max_length=100) + email: Optional[str] = Field(None, max_length=255) + phone: Optional[str] = Field(None, max_length=50) + mobile: Optional[str] = Field(None, max_length=50) + address: Optional[str] = Field(None, max_length=2000) birthday: Optional[date] = None - category: Optional[str] = None + category: Optional[str] = Field(None, max_length=100) is_favourite: bool = False - company: Optional[str] = None - job_title: Optional[str] = None - notes: Optional[str] = None + company: Optional[str] = Field(None, max_length=255) + job_title: Optional[str] = Field(None, max_length=255) + notes: Optional[str] = Field(None, max_length=5000) @model_validator(mode='after') def require_some_name(self) -> 'PersonCreate': @@ -42,20 +44,22 @@ class PersonCreate(BaseModel): class PersonUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + # name is intentionally omitted — always computed from first/last/nickname - first_name: Optional[str] = None - last_name: Optional[str] = None - nickname: Optional[str] = None - email: Optional[str] = None - phone: Optional[str] = None - mobile: Optional[str] = None - address: Optional[str] = None + first_name: Optional[str] = Field(None, max_length=100) + last_name: Optional[str] = Field(None, max_length=100) + nickname: Optional[str] = Field(None, max_length=100) + email: Optional[str] = Field(None, max_length=255) + phone: Optional[str] = Field(None, max_length=50) + mobile: Optional[str] = Field(None, max_length=50) + address: Optional[str] = Field(None, max_length=2000) birthday: Optional[date] = None - category: Optional[str] = None + category: Optional[str] = Field(None, max_length=100) is_favourite: Optional[bool] = None - company: Optional[str] = None - job_title: Optional[str] = None - notes: Optional[str] = None + company: Optional[str] = Field(None, max_length=255) + job_title: Optional[str] = Field(None, max_length=255) + notes: Optional[str] = Field(None, max_length=5000) @field_validator('email') @classmethod diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index 2b2f39e..925156e 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from datetime import datetime, date from typing import Optional, List, Literal from app.schemas.project_task import ProjectTaskResponse @@ -7,19 +7,23 @@ ProjectStatus = Literal["not_started", "in_progress", "completed", "blocked", "r class ProjectCreate(BaseModel): - name: str - description: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=5000) status: ProjectStatus = "not_started" - color: Optional[str] = None + color: Optional[str] = Field(None, max_length=20) due_date: Optional[date] = None is_tracked: bool = False class ProjectUpdate(BaseModel): - name: Optional[str] = None - description: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + name: Optional[str] = Field(None, min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=5000) status: Optional[ProjectStatus] = None - color: Optional[str] = None + color: Optional[str] = Field(None, max_length=20) due_date: Optional[date] = None is_tracked: Optional[bool] = None diff --git a/backend/app/schemas/project_task.py b/backend/app/schemas/project_task.py index 760fdcb..531315e 100644 --- a/backend/app/schemas/project_task.py +++ b/backend/app/schemas/project_task.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from datetime import datetime, date from typing import Optional, List, Literal from app.schemas.task_comment import TaskCommentResponse @@ -8,8 +8,10 @@ TaskPriority = Literal["none", "low", "medium", "high"] class ProjectTaskCreate(BaseModel): - title: str - description: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + title: str = Field(min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=5000) status: TaskStatus = "pending" priority: TaskPriority = "medium" due_date: Optional[date] = None @@ -19,8 +21,10 @@ class ProjectTaskCreate(BaseModel): class ProjectTaskUpdate(BaseModel): - title: Optional[str] = None - description: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + title: Optional[str] = Field(None, min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=5000) status: Optional[TaskStatus] = None priority: Optional[TaskPriority] = None due_date: Optional[date] = None diff --git a/backend/app/schemas/reminder.py b/backend/app/schemas/reminder.py index a4a8d39..f639c75 100644 --- a/backend/app/schemas/reminder.py +++ b/backend/app/schemas/reminder.py @@ -1,19 +1,23 @@ -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from datetime import datetime from typing import Literal, Optional class ReminderCreate(BaseModel): - title: str - description: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + title: str = Field(min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=5000) remind_at: Optional[datetime] = None is_active: bool = True recurrence_rule: Optional[Literal['daily', 'weekly', 'monthly']] = None class ReminderUpdate(BaseModel): - title: Optional[str] = None - description: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + title: Optional[str] = Field(None, min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=5000) remind_at: Optional[datetime] = None is_active: Optional[bool] = None is_dismissed: Optional[bool] = None @@ -21,6 +25,8 @@ class ReminderUpdate(BaseModel): class ReminderSnooze(BaseModel): + model_config = ConfigDict(extra="forbid") + minutes: Literal[5, 10, 15] client_now: Optional[datetime] = None diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 8591e62..8f11b60 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -1,5 +1,5 @@ import re -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from datetime import datetime from typing import Literal, Optional @@ -9,17 +9,19 @@ _NTFY_TOPIC_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$') class SettingsUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + accent_color: Optional[AccentColor] = None upcoming_days: int | None = None - preferred_name: str | None = None - weather_city: str | None = None + preferred_name: str | None = Field(None, max_length=100) + weather_city: str | None = Field(None, max_length=100) weather_lat: float | None = None weather_lon: float | None = None first_day_of_week: int | None = None # ntfy configuration fields - ntfy_server_url: Optional[str] = None - ntfy_topic: Optional[str] = None + ntfy_server_url: Optional[str] = Field(None, max_length=500) + ntfy_topic: Optional[str] = Field(None, max_length=100) # Empty string means "clear the token"; None means "leave unchanged" ntfy_auth_token: Optional[str] = None ntfy_enabled: Optional[bool] = None diff --git a/backend/app/schemas/task_comment.py b/backend/app/schemas/task_comment.py index 5b3dfc0..6c28615 100644 --- a/backend/app/schemas/task_comment.py +++ b/backend/app/schemas/task_comment.py @@ -1,9 +1,11 @@ -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from datetime import datetime class TaskCommentCreate(BaseModel): - content: str + model_config = ConfigDict(extra="forbid") + + content: str = Field(min_length=1, max_length=10000) class TaskCommentResponse(BaseModel): diff --git a/backend/app/schemas/todo.py b/backend/app/schemas/todo.py index 80338eb..f7cf251 100644 --- a/backend/app/schemas/todo.py +++ b/backend/app/schemas/todo.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from datetime import datetime, date, time from typing import Optional, Literal @@ -7,24 +7,28 @@ RecurrenceRule = Literal["daily", "weekly", "monthly"] class TodoCreate(BaseModel): - title: str - description: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + title: str = Field(min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=5000) priority: TodoPriority = "medium" due_date: Optional[date] = None due_time: Optional[time] = None - category: Optional[str] = None + category: Optional[str] = Field(None, max_length=100) recurrence_rule: Optional[RecurrenceRule] = None project_id: Optional[int] = None class TodoUpdate(BaseModel): - title: Optional[str] = None - description: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + title: Optional[str] = Field(None, min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=5000) priority: Optional[TodoPriority] = None due_date: Optional[date] = None due_time: Optional[time] = None completed: Optional[bool] = None - category: Optional[str] = None + category: Optional[str] = Field(None, max_length=100) recurrence_rule: Optional[RecurrenceRule] = None project_id: Optional[int] = None diff --git a/backend/app/services/audit.py b/backend/app/services/audit.py new file mode 100644 index 0000000..a38548c --- /dev/null +++ b/backend/app/services/audit.py @@ -0,0 +1,22 @@ +import json +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.audit_log import AuditLog + + +async def log_audit_event( + db: AsyncSession, + action: str, + actor_id: int | None = None, + target_id: int | None = None, + detail: dict | None = None, + ip: str | None = None, +) -> None: + """Record an action in the audit log. Does NOT commit — caller handles transaction.""" + entry = AuditLog( + actor_user_id=actor_id, + target_user_id=target_id, + action=action, + detail=json.dumps(detail) if detail else None, + ip_address=ip[:45] if ip else None, + ) + db.add(entry) diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index 186524b..c6ef16a 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -88,10 +88,14 @@ def create_session_token(user_id: int, session_id: str) -> str: def verify_session_token(token: str, max_age: int | None = None) -> dict | None: """ Verify a session cookie and return its payload dict, or None if invalid/expired. - max_age defaults to SESSION_MAX_AGE_DAYS from config. + + max_age defaults to SESSION_TOKEN_HARD_CEILING_DAYS (absolute token lifetime). + The sliding window (SESSION_MAX_AGE_DAYS) is enforced via DB expires_at checks, + not by itsdangerous — this decoupling prevents the serializer from rejecting + renewed tokens that were created more than SESSION_MAX_AGE_DAYS ago. """ if max_age is None: - max_age = app_settings.SESSION_MAX_AGE_DAYS * 86400 + max_age = app_settings.SESSION_TOKEN_HARD_CEILING_DAYS * 86400 try: return _serializer.loads(token, max_age=max_age) except (BadSignature, SignatureExpired): @@ -126,3 +130,32 @@ def verify_mfa_token(token: str) -> int | None: return data["uid"] except Exception: return None + + +# --------------------------------------------------------------------------- +# MFA enforcement tokens (SEC-03: distinct salt from challenge tokens) +# --------------------------------------------------------------------------- + +_mfa_enforce_serializer = URLSafeTimedSerializer( + secret_key=app_settings.SECRET_KEY, + salt="mfa-enforce-setup-v1", +) + + +def create_mfa_enforce_token(user_id: int) -> str: + """Create a short-lived token for MFA enforcement setup (not a session).""" + return _mfa_enforce_serializer.dumps({"uid": user_id}) + + +def verify_mfa_enforce_token(token: str) -> int | None: + """ + Verify an MFA enforcement setup token. + Returns user_id on success, None if invalid or expired (5-minute TTL). + """ + try: + data = _mfa_enforce_serializer.loads( + token, max_age=app_settings.MFA_TOKEN_MAX_AGE_SECONDS + ) + return data["uid"] + except Exception: + return None diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 3519178..1e8bbd8 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,5 +1,9 @@ # Rate limiting zones (before server block) limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m; +# SEC-14: Registration endpoint — slightly more permissive than strict auth endpoints +limit_req_zone $binary_remote_addr zone=register_limit:10m rate=5r/m; +# Admin API — generous for legitimate use but still guards against scraping/brute-force +limit_req_zone $binary_remote_addr zone=admin_limit:10m rate=30r/m; # Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access map $http_x_forwarded_proto $forwarded_proto { @@ -60,6 +64,20 @@ server { include /etc/nginx/proxy-params.conf; } + # SEC-14: Rate-limit public registration endpoint + location /api/auth/register { + limit_req zone=register_limit burst=3 nodelay; + limit_req_status 429; + include /etc/nginx/proxy-params.conf; + } + + # Admin API — rate-limited separately from general /api traffic + location /api/admin/ { + limit_req zone=admin_limit burst=10 nodelay; + limit_req_status 429; + include /etc/nginx/proxy-params.conf; + } + # API proxy location /api { proxy_pass http://backend:8000; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6e1694d..a969411 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,4 @@ +import { lazy, Suspense } from 'react'; import { Routes, Route, Navigate } from 'react-router-dom'; import { useAuth } from '@/hooks/useAuth'; import LockScreen from '@/components/auth/LockScreen'; @@ -12,6 +13,8 @@ import PeoplePage from '@/components/people/PeoplePage'; import LocationsPage from '@/components/locations/LocationsPage'; import SettingsPage from '@/components/settings/SettingsPage'; +const AdminPortal = lazy(() => import('@/components/admin/AdminPortal')); + function ProtectedRoute({ children }: { children: React.ReactNode }) { const { authStatus, isLoading } = useAuth(); @@ -30,6 +33,24 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { return <>{children}>; } +function AdminRoute({ children }: { children: React.ReactNode }) { + const { authStatus, isLoading } = useAuth(); + + if (isLoading) { + return ( +
No recent logins.
+ ) : ( +| + Username + | ++ When + | +
|---|---|
| {entry.username} | ++ {getRelativeTime(entry.last_login_at)} + | +
No recent actions.
+ ) : ( +| + Action + | ++ Actor + | ++ Target + | ++ When + | +
|---|---|---|---|
| + + {entry.action} + + | ++ {entry.actor_username ?? ( + system + )} + | ++ {entry.target_username ?? '—'} + | ++ {getRelativeTime(entry.created_at)} + | +
No audit entries found.
+ ) : ( + <> +| + Time + | ++ Actor + | ++ Action + | ++ Target + | ++ IP + | ++ Detail + | +
|---|---|---|---|---|---|
| + {getRelativeTime(entry.created_at)} + | ++ {entry.actor_username ?? ( + system + )} + | ++ + {entry.action} + + | ++ {entry.target_username ?? '—'} + | ++ {entry.ip_address ?? '—'} + | ++ {entry.detail ?? '—'} + | +
+ {searchQuery ? 'No users match your search.' : 'No users found.'} +
+ ) : ( +| + Username + | ++ Email + | ++ Role + | ++ Status + | ++ Last Login + | ++ MFA + | ++ Sessions + | ++ Created + | ++ Actions + | +
|---|---|---|---|---|---|---|---|---|
| {user.username} | ++ {user.email || '—'} + | +
+ |
+ + + {user.is_active ? 'Active' : 'Disabled'} + + | ++ {user.last_login_at ? getRelativeTime(user.last_login_at) : '—'} + | ++ {user.totp_enabled ? ( + + On + + ) : user.mfa_enforce_pending ? ( + + Pending + + ) : ( + — + )} + | ++ {user.active_sessions} + | ++ {getRelativeTime(user.created_at)} + | + e.stopPropagation()}>
+ |
+
+ When enabled, the /register page accepts new sign-ups. +
++ Newly registered users will be required to set up TOTP before accessing the app. +
+Temporary password:
+ {
+ navigator.clipboard.writeText(tempPassword);
+ toast.success('Password copied to clipboard');
+ }}
+ >
+ {tempPassword}
+
+
+ {label}
+{value}
++ An administrator has required that your account be protected with an authenticator app. + You'll need an app like Google Authenticator, Authy, or 1Password to continue. +
+ ++ These {mfaEnforceBackupCodes.length} codes can each be used once if you lose access to + your authenticator app. MFA is now active on your account. +
+
+ {code}
+
+ ))}
+ + Can't scan? Enter this code manually in your app: +
+
+ {mfaEnforceSecret}
+
+
+ {loginError}
+{lockoutMessage}
-