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, Request, status from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt from sqlmodel import Session, select from app.config import settings from app.timeutil import utcnow 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 = utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) return jwt.encode({"sub": subject, "exp": expire}, settings.SECRET_KEY, algorithm=ALGORITHM) def hash_token(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() def _validate_token(token: str) -> str: """Validate JWT and return subject, or raise 401.""" try: payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) username: str = payload.get("sub") if username is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) return username except JWTError: pass # Fallback: check API token from app.db import get_session from app.models.models import APIToken token_hash = hash_token(token) with next(get_session()) as session: api_token = session.exec( select(APIToken).where( APIToken.token_hash == token_hash, APIToken.is_active == True, ) ).first() if api_token: if api_token.expires_at and api_token.expires_at < utcnow(): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") return f"api:{api_token.name}" raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) def get_current_user_cookie_or_bearer( authorization: Optional[str] = Header(default=None), ws_token: Optional[str] = Cookie(default=None), ) -> str: """Accepts httpOnly cookie (SPA) or Bearer token (API clients / n8n).""" token: Optional[str] = None if authorization and authorization.lower().startswith("bearer "): token = authorization.split(" ", 1)[1].strip() elif ws_token: token = ws_token if not token: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) return _validate_token(token) def get_current_user( authorization: Optional[str] = Header(default=None), ws_token: Optional[str] = Cookie(default=None), ) -> str: """SPA cookie or Bearer token. Single dependency for all v1 endpoints.""" return get_current_user_cookie_or_bearer(authorization, ws_token)