fix: harden login/logout — secure cookie, server-side session revocation, timing-safe login

Addresses three Important-severity review findings inherited from Task 4's
plan reference code:

- login() now sets secure=True on the session cookie (safe behind the
  Cloudflare Tunnel, which terminates TLS at the edge).
- logout() looks up and deletes the matching AuthSession row before
  clearing the cookie, so a leaked raw token can no longer be replayed
  after logout.
- login() always performs exactly one verify_password call regardless of
  whether the username exists (against a module-level dummy hash for
  nonexistent users), removing the timing oracle that let unauthenticated
  requests distinguish registered from unregistered usernames.

Adds two tests: nonexistent-username login rejection, and logout revoking
the session server-side. Also adjusts two cookie-propagation touch points
in test_auth.py to manually re-inject the qm_session cookie, since
httpx's cookie jar won't auto-attach a Secure cookie to the test
transport's plain http://test base_url (a real browser talking to the
HTTPS tunnel edge wouldn't have this problem).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013PphXq1s43DNRj1uWKGXof
This commit is contained in:
Indiana
2026-07-20 15:34:12 +00:00
parent a0723b5237
commit 3758594896
2 changed files with 49 additions and 5 deletions

View File

@@ -1,18 +1,20 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_db
from app.deps import SESSION_COOKIE_NAME, get_current_user
from app.models.auth_session import AuthSession, SESSION_TTL, generate_session_token
from app.models.auth_session import AuthSession, SESSION_TTL, generate_session_token, hash_token
from app.models.user import User
from app.schemas import LoginRequest, RegisterRequest, UserOut
from app.security import hash_password, verify_password
router = APIRouter(prefix="/auth", tags=["auth"])
_DUMMY_PASSWORD_HASH = hash_password("dummy-password-for-timing-safety")
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)):
@@ -34,7 +36,10 @@ async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db))
@router.post("/login", response_model=UserOut)
async def login(payload: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
user = await db.scalar(select(User).where(User.username == payload.username))
if user is None or not verify_password(payload.password, user.password_hash):
if user is None:
verify_password(payload.password, _DUMMY_PASSWORD_HASH)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials")
if not verify_password(payload.password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials")
raw_token, token_hash = generate_session_token()
@@ -51,14 +56,25 @@ async def login(payload: LoginRequest, response: Response, db: AsyncSession = De
raw_token,
httponly=True,
samesite="lax",
secure=True,
max_age=int(SESSION_TTL.total_seconds()),
)
return user
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(response: Response):
response.delete_cookie(SESSION_COOKIE_NAME)
async def logout(
response: Response,
qm_session: str | None = Cookie(default=None),
db: AsyncSession = Depends(get_db),
):
if qm_session is not None:
token_hash = hash_token(qm_session)
session = await db.scalar(select(AuthSession).where(AuthSession.token_hash == token_hash))
if session is not None:
await db.delete(session)
await db.commit()
response.delete_cookie(SESSION_COOKIE_NAME, httponly=True, samesite="lax", secure=True)
@router.get("/me", response_model=UserOut)