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.
- **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://<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
```bash