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

@@ -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)})