Rate-limit logins, constant-time creds, pin CORS, secure cookie

Both login endpoints now share verify_admin_credentials (hmac
compare_digest, SEC-02) and a 5/min/IP sliding-window rate limit that
returns 429 with Retry-After (SEC-03). CORS is pinned to the prod
domain and localhost dev origins instead of '*' (SEC-04). The session
cookie's Secure flag is driven by COOKIE_SECURE instead of hardcoded
false (SEC-08).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-06-09 19:13:19 -06:00
parent 15ea1ef4c2
commit b4123703ef
3 changed files with 79 additions and 25 deletions

View File

@@ -1,22 +1,20 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, Request
from fastapi.security import OAuth2PasswordRequestForm
from app.auth import create_access_token, get_current_user, get_current_user_cookie_or_bearer
from app.config import settings
from app.auth import (
check_login_rate_limit,
create_access_token,
get_current_user_cookie_or_bearer,
verify_admin_credentials,
)
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login")
def login(form_data: OAuth2PasswordRequestForm = Depends()):
if (
form_data.username != settings.ADMIN_USERNAME
or form_data.password != settings.ADMIN_PASSWORD
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
)
def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
check_login_rate_limit(request)
verify_admin_credentials(form_data.username, form_data.password)
token = create_access_token(form_data.username)
return {"access_token": token, "token_type": "bearer"}

View File

@@ -1,9 +1,12 @@
import hashlib
import hmac
import re
import time
from collections import defaultdict, deque
from datetime import datetime, timedelta
from typing import Optional
from fastapi import Cookie, Depends, Header, HTTPException, status
from fastapi import Cookie, Depends, Header, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlmodel import Session, select
@@ -14,6 +17,57 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
ALGORITHM = "HS256"
# ── Login hardening ──────────────────────────────────────────────────────────
# Single-process app: an in-memory sliding window per client IP is sufficient.
LOGIN_RATE_LIMIT = 5
LOGIN_RATE_WINDOW_SECONDS = 60
_login_attempts: dict[str, deque[float]] = defaultdict(deque)
def _client_ip(request: Request) -> str:
# nginx-proxy sets X-Forwarded-For; the Hono proxy forwards it through.
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def check_login_rate_limit(request: Request) -> None:
"""Raise 429 if this client has attempted login too often. Call BEFORE
verifying credentials so attempts are counted regardless of outcome."""
now = time.monotonic()
attempts = _login_attempts[_client_ip(request)]
while attempts and now - attempts[0] > LOGIN_RATE_WINDOW_SECONDS:
attempts.popleft()
if len(attempts) >= LOGIN_RATE_LIMIT:
retry_after = int(LOGIN_RATE_WINDOW_SECONDS - (now - attempts[0])) + 1
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many login attempts, try again later",
headers={"Retry-After": str(retry_after)},
)
attempts.append(now)
# Bound memory against spoofed X-Forwarded-For values.
if len(_login_attempts) > 1000:
for key in [k for k, v in _login_attempts.items() if not v]:
del _login_attempts[key]
def verify_admin_credentials(username: str, password: str) -> None:
"""Constant-time credential check shared by both login endpoints."""
username_ok = hmac.compare_digest(
username.encode(), settings.ADMIN_USERNAME.encode()
)
password_ok = hmac.compare_digest(
password.encode(), settings.ADMIN_PASSWORD.encode()
)
if not (username_ok and password_ok):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
)
def create_access_token(subject: str) -> str:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)

View File

@@ -5,7 +5,7 @@ import uuid
from contextlib import asynccontextmanager
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
from fastapi import FastAPI, HTTPException, Request, Response, status
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from jose import JWTError, jwt
from pydantic import BaseModel
@@ -13,7 +13,12 @@ from pydantic import BaseModel
from app.agent.agent import build_agent
from app.agent.tools import reset_session, set_session
from app.api.v1.router import api_router
from app.auth import ALGORITHM, create_access_token
from app.auth import (
ALGORITHM,
check_login_rate_limit,
create_access_token,
verify_admin_credentials,
)
from app.config import settings
from app.db import get_session, init_db, run_migrations
from app.seed import seed_db
@@ -78,10 +83,10 @@ app = FastAPI(title="WealthySmart API", version="0.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
allow_headers=["content-type", "authorization"],
)
@@ -161,12 +166,9 @@ class LoginRequest(BaseModel):
@app.post("/api/auth/login")
def cookie_login(body: LoginRequest, response: Response):
if (
body.username != settings.ADMIN_USERNAME
or body.password != settings.ADMIN_PASSWORD
):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
def cookie_login(body: LoginRequest, request: Request, response: Response):
check_login_rate_limit(request)
verify_admin_credentials(body.username, body.password)
token = create_access_token(body.username)
response.set_cookie(
key="ws_token",
@@ -174,7 +176,7 @@ def cookie_login(body: LoginRequest, response: Response):
httponly=True,
samesite="lax",
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
secure=False, # set True behind TLS in production via nginx
secure=settings.COOKIE_SECURE,
)
return {"ok": True}