Move JWT from localStorage to HttpOnly cookie; fix CSRF

- JWT stored in HttpOnly, Secure, SameSite=Strict cookie — JS cannot
  read the token at all; SameSite=Strict prevents CSRF without tokens
- Non-sensitive user payload returned in response body and stored in
  localStorage for UI purposes only (not usable for auth)
- Add POST /api/auth/logout endpoint that clears the cookie server-side
- Add SECURE_COOKIES env var (default true) for local HTTP testing
- Extract login.html inline script to login.js (CSP compliance)
- Remove Authorization: Bearer header from API calls; add credentials:
  include so cookies are sent automatically
- CSP script-src includes unsafe-inline to support existing onclick
  handlers throughout the app

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 23:57:22 -07:00
parent 6d09e40f58
commit 59f9685e2b
10 changed files with 229 additions and 165 deletions

View File

@@ -1,23 +1,28 @@
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from database import get_db
from models import User
from schemas import LoginRequest, TokenResponse, UserOut, UserCreate, ChangePasswordRequest, TimezoneUpdate
from auth import verify_password, hash_password, create_access_token, get_current_user
from schemas import LoginRequest, UserOut, UserCreate, ChangePasswordRequest, TimezoneUpdate, AuthResponse
from auth import (
verify_password, hash_password, create_access_token, get_current_user,
set_auth_cookie, clear_auth_cookie, token_to_user_payload,
)
router = APIRouter(prefix="/api/auth", tags=["auth"])
def _make_token(user: User) -> str:
return create_access_token(user.id, user.username, user.is_admin, user.timezone)
def _issue(response: Response, user: User, admin_id=None) -> AuthResponse:
token = create_access_token(user.id, user.username, user.is_admin, user.timezone, admin_id=admin_id)
set_auth_cookie(response, token)
return AuthResponse(user=token_to_user_payload(token))
@router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest, db: Session = Depends(get_db)):
@router.post("/login", response_model=AuthResponse)
def login(body: LoginRequest, response: Response, db: Session = Depends(get_db)):
user = db.scalars(select(User).where(User.username == body.username)).first()
if not user or not verify_password(body.password, user.hashed_password):
raise HTTPException(
@@ -29,15 +34,14 @@ def login(body: LoginRequest, db: Session = Depends(get_db)):
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is disabled. Contact your administrator.",
)
return TokenResponse(access_token=_make_token(user))
return _issue(response, user)
@router.post("/register", response_model=TokenResponse, status_code=201)
def register(body: UserCreate, db: Session = Depends(get_db)):
@router.post("/register", response_model=AuthResponse, status_code=201)
def register(body: UserCreate, response: Response, db: Session = Depends(get_db)):
existing = db.scalars(select(User).where(User.username == body.username)).first()
if existing:
raise HTTPException(status_code=409, detail="Username already taken")
# Default timezone to UTC; user can change it in settings
user = User(
username=body.username,
hashed_password=hash_password(body.password),
@@ -47,7 +51,13 @@ def register(body: UserCreate, db: Session = Depends(get_db)):
db.add(user)
db.commit()
db.refresh(user)
return TokenResponse(access_token=_make_token(user))
return _issue(response, user)
@router.post("/logout")
def logout(response: Response):
clear_auth_cookie(response)
return {"detail": "Logged out"}
@router.get("/me", response_model=UserOut)
@@ -68,18 +78,18 @@ def change_password(
return {"detail": "Password updated"}
@router.put("/timezone", response_model=TokenResponse)
@router.put("/timezone", response_model=AuthResponse)
def update_timezone(
body: TimezoneUpdate,
response: Response,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
try:
ZoneInfo(body.timezone) # validate it's a real IANA timezone
ZoneInfo(body.timezone)
except ZoneInfoNotFoundError:
raise HTTPException(status_code=400, detail=f"Unknown timezone: {body.timezone}")
current_user.timezone = body.timezone
db.commit()
db.refresh(current_user)
# Return a fresh token with the updated timezone embedded
return TokenResponse(access_token=_make_token(current_user))
return _issue(response, current_user)