From 3022bc328b622d84ad33b953eb32e67a38b0568e Mon Sep 17 00:00:00 2001 From: derekc Date: Sun, 22 Mar 2026 00:00:14 -0700 Subject: [PATCH] Security hardening: go-live review fixes - TV tokens upgraded from 4 to 6 digits; Regen Token button in Admin - Nginx rate limiting on TV dashboard and WebSocket endpoints - Login lockout after 5 failed attempts (15 min); clears on admin password reset - HSTS header added; CSP unsafe-inline removed from script-src; CORS restricted to explicit methods/headers - Dependency CVE fixes: PyJWT 2.12.0, aiomysql 0.3.0, cryptography 46.0.5, python-multipart 0.0.22 - datetime.utcnow() replaced with datetime.now(timezone.utc) throughout - SQL identifier whitelist for startup migration queries - README updated: security notes section, lockout docs, token regen, NPM proxy guidance Co-Authored-By: Claude Sonnet 4.6 --- README.md | 64 +++++++++++++++++++++++++++----- backend/app/main.py | 56 +++++++++++++++++++++++++--- backend/app/models/user.py | 4 +- backend/app/routers/admin.py | 6 +++ backend/app/routers/auth.py | 38 ++++++++++++++++--- backend/app/routers/children.py | 20 +++++++++- backend/app/utils/timer.py | 6 +-- backend/requirements.txt | 7 ++-- frontend/nginx.conf | 40 ++++++++++++++++++++ frontend/src/stores/children.js | 9 ++++- frontend/src/views/AdminView.vue | 8 ++++ 11 files changed, 228 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 867b676..5382319 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,10 @@ A self-hosted web app for managing homeschool schedules, tracking daily learning - **Timezone Support** — Set your local timezone in Admin → Settings. All activity log timestamps display in your timezone, including the TV dashboard clock. Midnight strike resets use this timezone. - **Password Change** — Users can change their own account password from Admin → Settings → Reset Password. The form requires the current password before accepting a new one. - **Multi-Child Support** — Manage multiple students under one parent account, each with their own color, schedule, and history. -- **JWT Authentication** — Secure parent login with access tokens and httpOnly refresh cookies. TV dashboard is public (no login required). Disabled accounts receive a clear error message explaining the account is disabled rather than a generic "invalid credentials" response. +- **JWT Authentication** — Secure parent login with access tokens and httpOnly refresh cookies. TV dashboard is public (no login required). Disabled accounts receive a clear error message explaining the account is disabled rather than a generic "invalid credentials" response. After **5 consecutive failed login attempts**, an account is locked for **15 minutes** — the error message includes the remaining wait time. Locks clear automatically after the cooldown, or immediately when a super admin resets the account's password. - **Super Admin Panel** — A separate admin interface (at `/super-admin`) for site-wide management. Log in with a dedicated admin username and password (set in `.env`). Provides full control over all registered parent accounts: - **Impersonate** — Enter any user's session to view and manage their data. An impersonation banner is shown at the top of every page with a one-click "Exit to Admin Panel" button. - - **Reset Password** — Set a new password for any user without needing the current password. + - **Reset Password** — Set a new password for any user without needing the current password. Also clears any active login lockout on the account. - **Disable / Enable** — Disable a user's login access. Disabled users cannot log in and see a specific error message. Re-enable at any time. - **Delete** — Permanently delete a user and all associated data (children, sessions, schedules, subjects, activity logs, etc.) with a confirmation dialog. This action cannot be undone. - **Last Active** — Shows the date each user last logged in or refreshed their session, displayed in that user's configured timezone. Shows "Never logged in" for accounts that have never authenticated. @@ -50,7 +50,7 @@ A self-hosted web app for managing homeschool schedules, tracking daily learning | Real-time | WebSockets via FastAPI | | Database | MySQL 8 | | ORM | SQLAlchemy 2.0 (async) | -| Auth | JWT — python-jose + passlib/bcrypt | +| Auth | JWT — PyJWT + passlib/bcrypt | | Orchestration | Docker Compose | --- @@ -82,11 +82,11 @@ homeschool/ │ │ ├── rule.py # RuleItem (rules & expectations) │ │ ├── session_block_agenda.py # SessionBlockAgenda (per-session block overrides) │ │ ├── strike.py # StrikeEvent (strike history) -│ │ └── user.py # User (incl. timezone, last_active_at) +│ │ └── user.py # User (incl. timezone, last_active_at, failed_login_attempts, locked_until) │ ├── schemas/ # Pydantic request/response schemas │ ├── routers/ # API route handlers │ │ ├── auth.py # Login, register, refresh, logout, change-password -│ │ ├── children.py # Children CRUD + strikes + midnight reset +│ │ ├── children.py # Children CRUD + strikes + midnight reset + TV token regeneration │ │ ├── subjects.py │ │ ├── schedules.py │ │ ├── sessions.py # Timer actions + break timer events + block agenda upsert @@ -105,7 +105,7 @@ homeschool/ │ └── frontend/ ├── Dockerfile # Multi-stage: Node build → nginx serve - ├── nginx.conf # Proxy /api/ and /ws/ to backend + ├── nginx.conf # Proxy /api/ and /ws/ to backend; rate limiting, security headers ├── index.html # Favicon set to house emoji via inline SVG └── src/ ├── composables/ @@ -192,7 +192,7 @@ Open **http://localhost:8054/login** and register. This creates your admin accou 6. **Admin** → Scroll to **Settings** (below Schedules) and select your local timezone. You can also change your account password here. 7. **Admin** → Scroll to **Schedules** → Create a schedule template, add time blocks assigned to subjects. For any block that should include a break, check **Break** and enter the break duration in minutes. 8. **Dashboard** (`/dashboard`) → Click "Start Day", choose a template -9. **TV** → From the Dashboard, click **Open TV View** to get the TV URL for that child. Each child is assigned a permanent random 4-digit token (e.g. `http://your-lan-ip:8054/tv/4823`). Open that URL on the living room TV. +9. **TV** → From the Dashboard, click **Open TV View** to get the TV URL for that child. Each child is assigned a permanent random 6-digit token (e.g. `http://your-lan-ip:8054/tv/482391`). Open that URL on the living room TV. --- @@ -204,7 +204,7 @@ Open **http://localhost:8054/login** and register. This creates your admin accou |-----|-------------| | `/dashboard` | Overview, start/stop sessions, select and time blocks, set per-block agendas, issue behavior strikes, trigger TV overlays | | `/logs` | Browse timer and strike event history and manual notes; filter by child and date | -| `/admin` | Manage children, subjects (with activity options), morning routine, break activities, rules & expectations, schedule templates, and account settings (timezone, password). Includes a Buy Me a Coffee support link at the top of the page. | +| `/admin` | Manage children (incl. TV token display and regeneration), subjects (with activity options), morning routine, break activities, rules & expectations, schedule templates, and account settings (timezone, password). Includes a Buy Me a Coffee support link at the top of the page. | ### Super Admin Views @@ -249,7 +249,7 @@ Pressing **Reset** after **Done** fully un-marks the block — removes the check |-----|-------------| | `/tv/:tvToken` | Full-screen display — greeting + morning routine, current block timer with subject activities, break timer with break activities, day progress bar, schedule sidebar, meeting warning toasts, meeting start overlay, rules/expectations overlay | -Each child is assigned a permanent random 4-digit token when created (e.g. `/tv/4823`). The token never changes and does not expose the internal database ID. Find the TV URL for a child by clicking **Open TV View** on the Dashboard. The page connects via WebSocket and updates automatically when a parent starts/stops/advances the timer from the Dashboard. +Each child is assigned a permanent random 6-digit token when created (e.g. `/tv/482391`). The token does not expose the internal database ID. Find the TV URL by clicking **Open TV View** on the Dashboard, or view the token directly in **Admin → Children** next to each child's name. To generate a new token (e.g. if the old URL needs to be invalidated), click **Regen Token** in the child's row — the old URL stops working immediately. The page connects via WebSocket and updates automatically when a parent starts/stops/advances the timer from the Dashboard. ### API Documentation @@ -271,11 +271,15 @@ docker compose up -d No separate migration tool or manual steps are required. +### Upgrading from an older version (4-digit TV tokens) + +TV tokens were changed from 4 digits to 6 digits to improve security. The startup migration does **not** automatically regenerate tokens for existing children — they will keep their old 4-digit token until you manually regenerate it. To do so, go to **Admin → Children**, find the child, and click **Regen Token**. The new 6-digit token is shown immediately in the row. Update the TV URL on your TV — the old URL stops working as soon as the token is regenerated. + --- ## WebSocket Events -The TV dashboard connects to `ws://host/ws/{tv_token}` (using the child's 4-digit TV token, not the internal database ID) and receives JSON events: +The TV dashboard connects to `ws://host/ws/{tv_token}` (using the child's 6-digit TV token, not the internal database ID) and receives JSON events: | Event | Triggered by | Key payload fields | |-------|-------------|---------| @@ -347,6 +351,46 @@ The TV dashboard connects to `ws://host/ws/{tv_token}` (using the child's 4-digi --- +## Security Notes + +### Rate Limiting + +Nginx enforces the following rate limits (per IP): + +| Endpoint | Limit | Burst | +|----------|-------|-------| +| `/api/auth/login`, `/api/auth/register` | 5 req/min | 3 | +| `/api/admin/login` | 5 req/min | 3 | +| `/api/dashboard/*` (TV token lookup) | 10 req/min | 5 | +| `/ws/*` (WebSocket handshake) | 10 req/min | 5 | + +Requests over the limit receive a `429 Too Many Requests` response. + +### Login Lockout + +After 5 consecutive failed login attempts, a parent account is locked for 15 minutes. The lock clears automatically after the cooldown, or immediately when a super admin resets the user's password. The lockout threshold and duration are configured in `backend/app/routers/auth.py` (`_LOGIN_MAX_ATTEMPTS`, `_LOGIN_LOCKOUT_MINUTES`). + +### Exposing via Reverse Proxy (Nginx Proxy Manager) + +If exposing the app externally via NPM: + +- Add your public domain to `CORS_ORIGINS` in `.env` and rebuild +- Restrict `/super-admin` and `/api/admin` paths to trusted IPs via NPM's Advanced tab: + +```nginx +location ~* ^/(super-admin|api/admin) { + allow 192.168.1.0/24; + deny all; + proxy_pass http://:8054; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +--- + ## Stopping and Restarting ```bash diff --git a/backend/app/main.py b/backend/app/main.py index 20ff199..769bb83 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,3 +1,4 @@ +import logging import random from contextlib import asynccontextmanager @@ -19,9 +20,36 @@ from app.websocket.manager import manager settings = get_settings() +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) +logger = logging.getLogger("homeschool") + + +_ALLOWED_IDENTIFIERS: set[str] = { + # tables + "schedule_blocks", "children", "users", "subjects", "daily_sessions", "timer_events", + # columns + "duration_minutes", "break_time_enabled", "break_time_minutes", "strikes", + "timezone", "is_system", "last_active_at", "strikes_last_reset", "tv_token", + "failed_login_attempts", "locked_until", + # index names + "ix_daily_sessions_session_date", "ix_daily_sessions_is_active", "ix_timer_events_event_type", + # index columns + "session_date", "is_active", "event_type", +} + + +def _check_identifier(*names: str) -> None: + for name in names: + if name not in _ALLOWED_IDENTIFIERS: + raise ValueError(f"Disallowed SQL identifier: {name!r}") + async def _add_column_if_missing(conn, table: str, column: str, definition: str): """Add a column to a table, silently ignoring if it already exists (MySQL 1060).""" + _check_identifier(table, column) try: await conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")) except OperationalError as e: @@ -29,6 +57,16 @@ async def _add_column_if_missing(conn, table: str, column: str, definition: str) raise +async def _add_index_if_missing(conn, index_name: str, table: str, column: str): + """Create an index, silently ignoring if it already exists (MySQL 1061).""" + _check_identifier(index_name, table, column) + try: + await conn.execute(text(f"CREATE INDEX {index_name} ON {table} ({column})")) + except OperationalError as e: + if e.orig.args[0] != 1061: # 1061 = Duplicate key name + raise + + @asynccontextmanager async def lifespan(app: FastAPI): # Create tables on startup (Alembic handles migrations in prod, this is a safety net) @@ -44,6 +82,12 @@ async def lifespan(app: FastAPI): await _add_column_if_missing(conn, "users", "last_active_at", "DATETIME NULL") await _add_column_if_missing(conn, "children", "strikes_last_reset", "DATE NULL") await _add_column_if_missing(conn, "children", "tv_token", "INT NULL") + await _add_column_if_missing(conn, "users", "failed_login_attempts", "INT NOT NULL DEFAULT 0") + await _add_column_if_missing(conn, "users", "locked_until", "DATETIME NULL") + # Idempotent index additions + await _add_index_if_missing(conn, "ix_daily_sessions_session_date", "daily_sessions", "session_date") + await _add_index_if_missing(conn, "ix_daily_sessions_is_active", "daily_sessions", "is_active") + await _add_index_if_missing(conn, "ix_timer_events_event_type", "timer_events", "event_type") # Backfill tv_token for existing children that don't have one from app.database import AsyncSessionLocal @@ -53,7 +97,7 @@ async def lifespan(app: FastAPI): used_tokens = set() for child in children_without_token: while True: - token = random.randint(1000, 9999) + token = random.randint(100000, 999999) if token not in used_tokens: existing = await db.execute(select(Child).where(Child.tv_token == token)) if not existing.scalar_one_or_none(): @@ -87,9 +131,9 @@ async def lifespan(app: FastAPI): app = FastAPI( title="Homeschool API", version="1.0.0", - docs_url="/api/docs", - redoc_url="/api/redoc", - openapi_url="/api/openapi.json", + docs_url="/api/docs" if settings.docs_enabled else None, + redoc_url="/api/redoc" if settings.docs_enabled else None, + openapi_url="/api/openapi.json" if settings.docs_enabled else None, lifespan=lifespan, ) @@ -97,8 +141,8 @@ app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins_list, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"], + allow_headers=["Content-Type", "Authorization"], ) # Routers diff --git a/backend/app/models/user.py b/backend/app/models/user.py index a788afc..f65fa85 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,5 +1,5 @@ from datetime import datetime -from sqlalchemy import String, Boolean, DateTime +from sqlalchemy import String, Boolean, DateTime, Integer from sqlalchemy.orm import Mapped, mapped_column, relationship from app.models.base import Base, TimestampMixin @@ -15,6 +15,8 @@ class User(TimestampMixin, Base): is_admin: Mapped[bool] = mapped_column(Boolean, default=False) timezone: Mapped[str] = mapped_column(String(64), nullable=False, default="UTC") last_active_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None) + failed_login_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + locked_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None) children: Mapped[list["Child"]] = relationship("Child", back_populates="user") # noqa: F821 subjects: Mapped[list["Subject"]] = relationship("Subject", back_populates="user") # noqa: F821 diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 812961c..33ff2a3 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -1,7 +1,10 @@ +import logging from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, delete +logger = logging.getLogger("homeschool.admin") + from app.auth.jwt import create_admin_token, create_access_token, hash_password from app.config import get_settings from app.dependencies import get_db, get_admin_user @@ -16,6 +19,7 @@ async def admin_login(body: dict): username = body.get("username", "") password = body.get("password", "") if username != settings.admin_username or password != settings.admin_password: + logger.warning("Failed super-admin login attempt for username=%s", username) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin credentials") token = create_admin_token({"sub": "admin"}) return {"access_token": token, "token_type": "bearer"} @@ -87,6 +91,8 @@ async def reset_user_password( if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") user.hashed_password = hash_password(new_password) + user.failed_login_attempts = 0 + user.locked_until = None await db.commit() return {"ok": True} diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index c73bc37..b649f3f 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,8 +1,11 @@ -from datetime import datetime +import logging +from datetime import datetime, timezone, timedelta from fastapi import APIRouter, Depends, HTTPException, Response, Request, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select +logger = logging.getLogger("homeschool.auth") + from app.auth.jwt import ( create_access_token, create_refresh_token, @@ -22,7 +25,7 @@ REFRESH_COOKIE = "refresh_token" COOKIE_OPTS = { "httponly": True, "samesite": "lax", - "secure": False, # set True in production with HTTPS + "secure": True, } @@ -57,16 +60,41 @@ async def register(body: RegisterRequest, response: Response, db: AsyncSession = return TokenResponse(access_token=access) +_LOGIN_MAX_ATTEMPTS = 5 +_LOGIN_LOCKOUT_MINUTES = 15 + + @router.post("/login", response_model=TokenResponse) async def login(body: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)): result = await db.execute(select(User).where(User.email == body.email)) user = result.scalar_one_or_none() - if not user or not verify_password(body.password, user.hashed_password): + if not user: raise HTTPException(status_code=401, detail="Invalid credentials") + + now = datetime.now(timezone.utc).replace(tzinfo=None) + + if user.locked_until and user.locked_until > now: + remaining = int((user.locked_until - now).total_seconds() / 60) + 1 + logger.warning("Locked account login attempt for email=%s", body.email) + raise HTTPException(status_code=429, detail=f"Account locked. Try again in {remaining} minute(s).") + if not user.is_active: + logger.warning("Login attempt on disabled account email=%s", body.email) raise HTTPException(status_code=403, detail="This account has been disabled. Please contact your administrator.") - user.last_active_at = datetime.utcnow() + if not verify_password(body.password, user.hashed_password): + user.failed_login_attempts += 1 + if user.failed_login_attempts >= _LOGIN_MAX_ATTEMPTS: + user.locked_until = now + timedelta(minutes=_LOGIN_LOCKOUT_MINUTES) + logger.warning("Account locked for email=%s after %d failed attempts", body.email, user.failed_login_attempts) + else: + logger.warning("Failed login attempt %d/%d for email=%s", user.failed_login_attempts, _LOGIN_MAX_ATTEMPTS, body.email) + await db.commit() + raise HTTPException(status_code=401, detail="Invalid credentials") + + user.failed_login_attempts = 0 + user.locked_until = None + user.last_active_at = datetime.now(timezone.utc) await db.commit() access = create_access_token({"sub": str(user.id)}) @@ -95,7 +123,7 @@ async def refresh_token(request: Request, response: Response, db: AsyncSession = if not user: raise HTTPException(status_code=401, detail="User not found") - user.last_active_at = datetime.utcnow() + user.last_active_at = datetime.now(timezone.utc) await db.commit() access = create_access_token({"sub": str(user.id)}) diff --git a/backend/app/routers/children.py b/backend/app/routers/children.py index 515799e..7b2dfb6 100644 --- a/backend/app/routers/children.py +++ b/backend/app/routers/children.py @@ -52,7 +52,7 @@ async def list_children( async def _generate_tv_token(db: AsyncSession) -> int: while True: - token = random.randint(1000, 9999) + token = random.randint(100000, 999999) result = await db.execute(select(Child).where(Child.tv_token == token)) if not result.scalar_one_or_none(): return token @@ -134,6 +134,24 @@ async def update_strikes( return child +@router.post("/{child_id}/regenerate-token", response_model=ChildOut) +async def regenerate_tv_token( + child_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(Child).where(Child.id == child_id, Child.user_id == current_user.id) + ) + child = result.scalar_one_or_none() + if not child: + raise HTTPException(status_code=404, detail="Child not found") + child.tv_token = await _generate_tv_token(db) + await db.commit() + await db.refresh(child) + return child + + @router.delete("/{child_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_child( child_id: int, diff --git a/backend/app/utils/timer.py b/backend/app/utils/timer.py index 48a2592..34e743a 100644 --- a/backend/app/utils/timer.py +++ b/backend/app/utils/timer.py @@ -1,5 +1,5 @@ """Shared timer-elapsed computation used by sessions and dashboard routers.""" -from datetime import datetime +from datetime import datetime, timezone from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select @@ -39,7 +39,7 @@ async def compute_block_elapsed( last_start = None running = last_start is not None if running: - elapsed += (datetime.utcnow() - last_start).total_seconds() + elapsed += (datetime.now(timezone.utc) - last_start).total_seconds() # is_paused is True whenever the timer is not actively running — # covers: explicitly paused, never started, or only selected. @@ -75,7 +75,7 @@ async def compute_break_elapsed( last_start = None running = last_start is not None if running: - elapsed += (datetime.utcnow() - last_start).total_seconds() + elapsed += (datetime.now(timezone.utc) - last_start).total_seconds() is_paused = not running return int(elapsed), is_paused diff --git a/backend/requirements.txt b/backend/requirements.txt index d5ceaf6..864272f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,11 +1,12 @@ fastapi==0.115.0 uvicorn[standard]==0.30.6 sqlalchemy[asyncio]==2.0.35 -aiomysql==0.2.0 -python-jose[cryptography]==3.3.0 +aiomysql==0.3.0 +PyJWT==2.12.0 +cryptography==46.0.5 passlib[bcrypt]==1.7.4 bcrypt==3.2.2 pydantic-settings==2.5.2 alembic==1.13.3 -python-multipart==0.0.12 +python-multipart==0.0.22 email-validator==2.2.0 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index f4822c6..e7f16c2 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,8 +1,46 @@ +# Rate limiting zones — included inside http{} block +limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=5r/m; +limit_req_zone $binary_remote_addr zone=tv_limit:10m rate=10r/m; + server { listen 80; + server_tokens off; root /usr/share/nginx/html; index index.html; + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self' data:;" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + # Gzip compression + gzip on; + gzip_types text/plain text/css application/javascript application/json application/x-javascript text/xml application/xml; + gzip_min_length 1024; + + # Rate-limited auth endpoints (checked before the generic /api/ block) + location ~ ^/api/(auth/(login|register)|admin/login)$ { + limit_req zone=auth_limit burst=3 nodelay; + limit_req_status 429; + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # Rate-limited TV dashboard endpoint (public, token-based) + location ~ ^/api/dashboard/ { + limit_req zone=tv_limit burst=5 nodelay; + limit_req_status 429; + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + # API proxy → FastAPI backend location /api/ { proxy_pass http://backend:8000; @@ -13,6 +51,8 @@ server { # WebSocket proxy → FastAPI backend location /ws/ { + limit_req zone=tv_limit burst=5 nodelay; + limit_req_status 429; proxy_pass http://backend:8000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; diff --git a/frontend/src/stores/children.js b/frontend/src/stores/children.js index 8dcddc7..41e6831 100644 --- a/frontend/src/stores/children.js +++ b/frontend/src/stores/children.js @@ -32,9 +32,16 @@ export const useChildrenStore = defineStore('children', () => { children.value = children.value.filter((c) => c.id !== id) } + async function regenerateToken(id) { + const res = await api.post(`/api/children/${id}/regenerate-token`) + const idx = children.value.findIndex((c) => c.id === id) + if (idx !== -1) children.value[idx] = res.data + return res.data + } + function setActiveChild(child) { activeChild.value = child } - return { children, activeChild, fetchChildren, createChild, updateChild, deleteChild, setActiveChild } + return { children, activeChild, fetchChildren, createChild, updateChild, deleteChild, regenerateToken, setActiveChild } }) diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 85a221d..66a3599 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -48,11 +48,13 @@
{{ child.name }} {{ child.is_active ? 'Active' : 'Inactive' }} + 📺 {{ child.tv_token }}
+
@@ -536,6 +538,12 @@ async function deleteChild(id) { } } +async function regenToken(child) { + if (confirm(`Regenerate TV token for ${child.name}? The old TV URL will stop working immediately.`)) { + await childrenStore.regenerateToken(child.id) + } +} + // Subjects const showSubjectForm = ref(false) const newSubject = ref({ name: '', icon: '📚', color: '#10B981' })