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 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 00:00:14 -07:00
parent be86cae7fa
commit 3022bc328b
11 changed files with 228 additions and 30 deletions

View File

@@ -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. - **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. - **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. - **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: - **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. - **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. - **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. - **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. - **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 | | Real-time | WebSockets via FastAPI |
| Database | MySQL 8 | | Database | MySQL 8 |
| ORM | SQLAlchemy 2.0 (async) | | ORM | SQLAlchemy 2.0 (async) |
| Auth | JWT — python-jose + passlib/bcrypt | | Auth | JWT — PyJWT + passlib/bcrypt |
| Orchestration | Docker Compose | | Orchestration | Docker Compose |
--- ---
@@ -82,11 +82,11 @@ homeschool/
│ │ ├── rule.py # RuleItem (rules & expectations) │ │ ├── rule.py # RuleItem (rules & expectations)
│ │ ├── session_block_agenda.py # SessionBlockAgenda (per-session block overrides) │ │ ├── session_block_agenda.py # SessionBlockAgenda (per-session block overrides)
│ │ ├── strike.py # StrikeEvent (strike history) │ │ ├── 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 │ ├── schemas/ # Pydantic request/response schemas
│ ├── routers/ # API route handlers │ ├── routers/ # API route handlers
│ │ ├── auth.py # Login, register, refresh, logout, change-password │ │ ├── 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 │ │ ├── subjects.py
│ │ ├── schedules.py │ │ ├── schedules.py
│ │ ├── sessions.py # Timer actions + break timer events + block agenda upsert │ │ ├── sessions.py # Timer actions + break timer events + block agenda upsert
@@ -105,7 +105,7 @@ homeschool/
└── frontend/ └── frontend/
├── Dockerfile # Multi-stage: Node build → nginx serve ├── 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 ├── index.html # Favicon set to house emoji via inline SVG
└── src/ └── src/
├── composables/ ├── 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. 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. 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 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 | | `/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 | | `/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 ### 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 | | `/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 ### API Documentation
@@ -271,11 +271,15 @@ docker compose up -d
No separate migration tool or manual steps are required. 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 ## 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 | | 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://<upstream>: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 ## Stopping and Restarting
```bash ```bash

View File

@@ -1,3 +1,4 @@
import logging
import random import random
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -19,9 +20,36 @@ from app.websocket.manager import manager
settings = get_settings() 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): 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).""" """Add a column to a table, silently ignoring if it already exists (MySQL 1060)."""
_check_identifier(table, column)
try: try:
await conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")) await conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {definition}"))
except OperationalError as e: except OperationalError as e:
@@ -29,6 +57,16 @@ async def _add_column_if_missing(conn, table: str, column: str, definition: str)
raise 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Create tables on startup (Alembic handles migrations in prod, this is a safety net) # 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, "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", "strikes_last_reset", "DATE NULL")
await _add_column_if_missing(conn, "children", "tv_token", "INT 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 # Backfill tv_token for existing children that don't have one
from app.database import AsyncSessionLocal from app.database import AsyncSessionLocal
@@ -53,7 +97,7 @@ async def lifespan(app: FastAPI):
used_tokens = set() used_tokens = set()
for child in children_without_token: for child in children_without_token:
while True: while True:
token = random.randint(1000, 9999) token = random.randint(100000, 999999)
if token not in used_tokens: if token not in used_tokens:
existing = await db.execute(select(Child).where(Child.tv_token == token)) existing = await db.execute(select(Child).where(Child.tv_token == token))
if not existing.scalar_one_or_none(): if not existing.scalar_one_or_none():
@@ -87,9 +131,9 @@ async def lifespan(app: FastAPI):
app = FastAPI( app = FastAPI(
title="Homeschool API", title="Homeschool API",
version="1.0.0", version="1.0.0",
docs_url="/api/docs", docs_url="/api/docs" if settings.docs_enabled else None,
redoc_url="/api/redoc", redoc_url="/api/redoc" if settings.docs_enabled else None,
openapi_url="/api/openapi.json", openapi_url="/api/openapi.json" if settings.docs_enabled else None,
lifespan=lifespan, lifespan=lifespan,
) )
@@ -97,8 +141,8 @@ app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origins_list, allow_origins=settings.cors_origins_list,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["*"], allow_headers=["Content-Type", "Authorization"],
) )
# Routers # Routers

View File

@@ -1,5 +1,5 @@
from datetime import datetime 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin
@@ -15,6 +15,8 @@ class User(TimestampMixin, Base):
is_admin: Mapped[bool] = mapped_column(Boolean, default=False) is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
timezone: Mapped[str] = mapped_column(String(64), nullable=False, default="UTC") timezone: Mapped[str] = mapped_column(String(64), nullable=False, default="UTC")
last_active_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None) 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 children: Mapped[list["Child"]] = relationship("Child", back_populates="user") # noqa: F821
subjects: Mapped[list["Subject"]] = relationship("Subject", back_populates="user") # noqa: F821 subjects: Mapped[list["Subject"]] = relationship("Subject", back_populates="user") # noqa: F821

View File

@@ -1,7 +1,10 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete 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.auth.jwt import create_admin_token, create_access_token, hash_password
from app.config import get_settings from app.config import get_settings
from app.dependencies import get_db, get_admin_user from app.dependencies import get_db, get_admin_user
@@ -16,6 +19,7 @@ async def admin_login(body: dict):
username = body.get("username", "") username = body.get("username", "")
password = body.get("password", "") password = body.get("password", "")
if username != settings.admin_username or password != settings.admin_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") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin credentials")
token = create_admin_token({"sub": "admin"}) token = create_admin_token({"sub": "admin"})
return {"access_token": token, "token_type": "bearer"} return {"access_token": token, "token_type": "bearer"}
@@ -87,6 +91,8 @@ async def reset_user_password(
if not user: if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
user.hashed_password = hash_password(new_password) user.hashed_password = hash_password(new_password)
user.failed_login_attempts = 0
user.locked_until = None
await db.commit() await db.commit()
return {"ok": True} return {"ok": True}

View File

@@ -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 fastapi import APIRouter, Depends, HTTPException, Response, Request, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
logger = logging.getLogger("homeschool.auth")
from app.auth.jwt import ( from app.auth.jwt import (
create_access_token, create_access_token,
create_refresh_token, create_refresh_token,
@@ -22,7 +25,7 @@ REFRESH_COOKIE = "refresh_token"
COOKIE_OPTS = { COOKIE_OPTS = {
"httponly": True, "httponly": True,
"samesite": "lax", "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) return TokenResponse(access_token=access)
_LOGIN_MAX_ATTEMPTS = 5
_LOGIN_LOCKOUT_MINUTES = 15
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)): async def login(body: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == body.email)) result = await db.execute(select(User).where(User.email == body.email))
user = result.scalar_one_or_none() 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") 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: 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.") 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() await db.commit()
access = create_access_token({"sub": str(user.id)}) 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: if not user:
raise HTTPException(status_code=401, detail="User not found") 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() await db.commit()
access = create_access_token({"sub": str(user.id)}) access = create_access_token({"sub": str(user.id)})

View File

@@ -52,7 +52,7 @@ async def list_children(
async def _generate_tv_token(db: AsyncSession) -> int: async def _generate_tv_token(db: AsyncSession) -> int:
while True: while True:
token = random.randint(1000, 9999) token = random.randint(100000, 999999)
result = await db.execute(select(Child).where(Child.tv_token == token)) result = await db.execute(select(Child).where(Child.tv_token == token))
if not result.scalar_one_or_none(): if not result.scalar_one_or_none():
return token return token
@@ -134,6 +134,24 @@ async def update_strikes(
return child 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) @router.delete("/{child_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_child( async def delete_child(
child_id: int, child_id: int,

View File

@@ -1,5 +1,5 @@
"""Shared timer-elapsed computation used by sessions and dashboard routers.""" """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.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
@@ -39,7 +39,7 @@ async def compute_block_elapsed(
last_start = None last_start = None
running = last_start is not None running = last_start is not None
if running: 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 — # is_paused is True whenever the timer is not actively running —
# covers: explicitly paused, never started, or only selected. # covers: explicitly paused, never started, or only selected.
@@ -75,7 +75,7 @@ async def compute_break_elapsed(
last_start = None last_start = None
running = last_start is not None running = last_start is not None
if running: if running:
elapsed += (datetime.utcnow() - last_start).total_seconds() elapsed += (datetime.now(timezone.utc) - last_start).total_seconds()
is_paused = not running is_paused = not running
return int(elapsed), is_paused return int(elapsed), is_paused

View File

@@ -1,11 +1,12 @@
fastapi==0.115.0 fastapi==0.115.0
uvicorn[standard]==0.30.6 uvicorn[standard]==0.30.6
sqlalchemy[asyncio]==2.0.35 sqlalchemy[asyncio]==2.0.35
aiomysql==0.2.0 aiomysql==0.3.0
python-jose[cryptography]==3.3.0 PyJWT==2.12.0
cryptography==46.0.5
passlib[bcrypt]==1.7.4 passlib[bcrypt]==1.7.4
bcrypt==3.2.2 bcrypt==3.2.2
pydantic-settings==2.5.2 pydantic-settings==2.5.2
alembic==1.13.3 alembic==1.13.3
python-multipart==0.0.12 python-multipart==0.0.22
email-validator==2.2.0 email-validator==2.2.0

View File

@@ -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 { server {
listen 80; listen 80;
server_tokens off;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.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 # API proxy → FastAPI backend
location /api/ { location /api/ {
proxy_pass http://backend:8000; proxy_pass http://backend:8000;
@@ -13,6 +51,8 @@ server {
# WebSocket proxy → FastAPI backend # WebSocket proxy → FastAPI backend
location /ws/ { location /ws/ {
limit_req zone=tv_limit burst=5 nodelay;
limit_req_status 429;
proxy_pass http://backend:8000; proxy_pass http://backend:8000;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;

View File

@@ -32,9 +32,16 @@ export const useChildrenStore = defineStore('children', () => {
children.value = children.value.filter((c) => c.id !== id) 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) { function setActiveChild(child) {
activeChild.value = child activeChild.value = child
} }
return { children, activeChild, fetchChildren, createChild, updateChild, deleteChild, setActiveChild } return { children, activeChild, fetchChildren, createChild, updateChild, deleteChild, regenerateToken, setActiveChild }
}) })

View File

@@ -48,11 +48,13 @@
<div class="item-color" :style="{ background: child.color }"></div> <div class="item-color" :style="{ background: child.color }"></div>
<span class="item-name">{{ child.name }}</span> <span class="item-name">{{ child.name }}</span>
<span class="item-meta">{{ child.is_active ? 'Active' : 'Inactive' }}</span> <span class="item-meta">{{ child.is_active ? 'Active' : 'Inactive' }}</span>
<span class="item-meta token-meta" title="TV token">📺 {{ child.tv_token }}</span>
<div class="item-actions"> <div class="item-actions">
<button class="btn-sm" @click="startEditChild(child)">Edit</button> <button class="btn-sm" @click="startEditChild(child)">Edit</button>
<button class="btn-sm" @click="toggleChild(child)"> <button class="btn-sm" @click="toggleChild(child)">
{{ child.is_active ? 'Deactivate' : 'Activate' }} {{ child.is_active ? 'Deactivate' : 'Activate' }}
</button> </button>
<button class="btn-sm" @click="regenToken(child)" title="Generate a new TV token (old URL will stop working)">Regen Token</button>
<button class="btn-sm btn-danger" @click="deleteChild(child.id)">Delete</button> <button class="btn-sm btn-danger" @click="deleteChild(child.id)">Delete</button>
</div> </div>
</template> </template>
@@ -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 // Subjects
const showSubjectForm = ref(false) const showSubjectForm = ref(false)
const newSubject = ref({ name: '', icon: '📚', color: '#10B981' }) const newSubject = ref({ name: '', icon: '📚', color: '#10B981' })