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

@@ -4,8 +4,7 @@ from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from fastapi import Cookie, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session
from database import get_db
@@ -14,9 +13,10 @@ from models import User
SECRET_KEY = os.environ["JWT_SECRET"]
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_DAYS = 30
COOKIE_NAME = "token"
SECURE_COOKIES = os.environ.get("SECURE_COOKIES", "true").lower() == "true"
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def verify_password(plain_password: str, hashed_password: str) -> bool:
@@ -41,28 +41,44 @@ def create_access_token(user_id: int, username: str, is_admin: bool, user_timezo
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
async def get_token_payload(
token: str = Depends(oauth2_scheme),
) -> dict:
try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
def set_auth_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=COOKIE_NAME,
value=token,
httponly=True,
secure=SECURE_COOKIES,
samesite="strict",
max_age=ACCESS_TOKEN_EXPIRE_DAYS * 24 * 60 * 60,
path="/",
)
def clear_auth_cookie(response: Response) -> None:
response.delete_cookie(key=COOKIE_NAME, httponly=True, secure=SECURE_COOKIES, samesite="strict", path="/")
def token_to_user_payload(token: str) -> dict:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return {
"sub": payload["sub"],
"username": payload["username"],
"is_admin": payload["is_admin"],
"timezone": payload["timezone"],
"exp": payload["exp"],
"admin_id": payload.get("admin_id"),
}
async def get_current_user(
token: str = Depends(oauth2_scheme),
token: Optional[str] = Cookie(default=None, alias=COOKIE_NAME),
db: Session = Depends(get_db),
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
if not token:
raise credentials_exception
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id_str: Optional[str] = payload.get("sub")
@@ -85,3 +101,20 @@ async def get_current_admin(current_user: User = Depends(get_current_user)) -> U
detail="Admin access required",
)
return current_user
async def get_token_payload(
token: Optional[str] = Cookie(default=None, alias=COOKIE_NAME),
) -> dict:
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
)
try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
)