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:
64
README.md
64
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.
|
- **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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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)})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
Reference in New Issue
Block a user