From b4123703ef719f1fc9b6b0572ef120beeef9f98d Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Tue, 9 Jun 2026 19:13:19 -0600 Subject: [PATCH] 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 --- backend/app/api/v1/endpoints/auth.py | 22 +++++------ backend/app/auth.py | 56 +++++++++++++++++++++++++++- backend/app/main.py | 26 +++++++------ 3 files changed, 79 insertions(+), 25 deletions(-) diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index 107f670..682a251 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -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"} diff --git a/backend/app/auth.py b/backend/app/auth.py index 9daf2be..26951e7 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index 4671eba..c381903 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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}