mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 11:28:47 +02:00
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:
@@ -1,22 +1,20 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
|
||||||
from app.auth import create_access_token, get_current_user, get_current_user_cookie_or_bearer
|
from app.auth import (
|
||||||
from app.config import settings
|
check_login_rate_limit,
|
||||||
|
create_access_token,
|
||||||
|
get_current_user_cookie_or_bearer,
|
||||||
|
verify_admin_credentials,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
def login(form_data: OAuth2PasswordRequestForm = Depends()):
|
def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
|
||||||
if (
|
check_login_rate_limit(request)
|
||||||
form_data.username != settings.ADMIN_USERNAME
|
verify_admin_credentials(form_data.username, form_data.password)
|
||||||
or form_data.password != settings.ADMIN_PASSWORD
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Invalid credentials",
|
|
||||||
)
|
|
||||||
token = create_access_token(form_data.username)
|
token = create_access_token(form_data.username)
|
||||||
return {"access_token": token, "token_type": "bearer"}
|
return {"access_token": token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
import hmac
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
|
from collections import defaultdict, deque
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional
|
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 fastapi.security import OAuth2PasswordBearer
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
@@ -14,6 +17,57 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
|||||||
|
|
||||||
ALGORITHM = "HS256"
|
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:
|
def create_access_token(subject: str) -> str:
|
||||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import uuid
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
|
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 fastapi.middleware.cors import CORSMiddleware
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -13,7 +13,12 @@ from pydantic import BaseModel
|
|||||||
from app.agent.agent import build_agent
|
from app.agent.agent import build_agent
|
||||||
from app.agent.tools import reset_session, set_session
|
from app.agent.tools import reset_session, set_session
|
||||||
from app.api.v1.router import api_router
|
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.config import settings
|
||||||
from app.db import get_session, init_db, run_migrations
|
from app.db import get_session, init_db, run_migrations
|
||||||
from app.seed import seed_db
|
from app.seed import seed_db
|
||||||
@@ -78,10 +83,10 @@ app = FastAPI(title="WealthySmart API", version="0.1.0", lifespan=lifespan)
|
|||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=settings.cors_origins_list,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
|
||||||
allow_headers=["*"],
|
allow_headers=["content-type", "authorization"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -161,12 +166,9 @@ class LoginRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/auth/login")
|
@app.post("/api/auth/login")
|
||||||
def cookie_login(body: LoginRequest, response: Response):
|
def cookie_login(body: LoginRequest, request: Request, response: Response):
|
||||||
if (
|
check_login_rate_limit(request)
|
||||||
body.username != settings.ADMIN_USERNAME
|
verify_admin_credentials(body.username, body.password)
|
||||||
or body.password != settings.ADMIN_PASSWORD
|
|
||||||
):
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
|
||||||
token = create_access_token(body.username)
|
token = create_access_token(body.username)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key="ws_token",
|
key="ws_token",
|
||||||
@@ -174,7 +176,7 @@ def cookie_login(body: LoginRequest, response: Response):
|
|||||||
httponly=True,
|
httponly=True,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user