153 lines
6.0 KiB
Python
153 lines
6.0 KiB
Python
import asyncio
|
|
import time
|
|
import uuid
|
|
from collections import defaultdict, deque
|
|
from datetime import datetime
|
|
|
|
from fastapi import Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, status
|
|
from fastapi.security import OAuth2PasswordBearer
|
|
from slowapi import Limiter
|
|
from sqlalchemy.orm import Session
|
|
|
|
from core.auth import decode_access_token, decode_access_token_payload, token_issued_before
|
|
from core.database import get_db
|
|
from core.models import User as UserModel
|
|
|
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
|
|
|
|
|
|
def escape_like(value: str) -> str:
|
|
r"""Neutralise LIKE wildcards in user input used as a search term.
|
|
|
|
Without this, '%' matches every row and '_' matches any character — so a one-char
|
|
search returns the whole table. Use with .ilike(..., escape='\\').
|
|
"""
|
|
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
|
|
|
|
def parse_uuid(value: str, field: str = "id") -> uuid.UUID:
|
|
"""Path and body params reach us as arbitrary strings; a bare uuid.UUID() on one
|
|
raises ValueError, which surfaces as a 500 and a stack trace instead of a 400."""
|
|
try:
|
|
return uuid.UUID(value)
|
|
except (ValueError, AttributeError, TypeError):
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid {field}")
|
|
|
|
|
|
def get_client_ip(conn: Request | WebSocket) -> str:
|
|
"""The real external client, as seen through Nginx Proxy Manager.
|
|
|
|
NPM sets X-Forwarded-For to $proxy_add_x_forwarded_for, which *appends* the peer
|
|
it saw to whatever the client sent. So the last entry is always written by NPM and
|
|
anything a client forges lands to its left — take the rightmost, never the first.
|
|
|
|
This trusts the header because NPM is the only way in: port 8000 is unpublished and
|
|
the backend only shares the `proxy` network with NPM. If a port is ever published,
|
|
or another container is put on `proxy`, this becomes forgeable and needs revisiting.
|
|
"""
|
|
forwarded = conn.headers.get("x-forwarded-for")
|
|
if forwarded:
|
|
client = forwarded.rpartition(",")[2].strip()
|
|
if client:
|
|
return client
|
|
# No header: a direct connection, so the peer address is the client.
|
|
return conn.client.host if conn.client else "unknown"
|
|
|
|
|
|
# Per-user key where we can, real client IP otherwise. Without this every request
|
|
# keys on NPM's container address, i.e. one global bucket for the whole internet —
|
|
# eleven logins a minute would lock every user out.
|
|
def get_user_id_from_request(request: Request) -> str:
|
|
auth = request.headers.get("Authorization", "")
|
|
if auth.startswith("Bearer "):
|
|
user_id = decode_access_token(auth[7:])
|
|
if user_id:
|
|
return f"user:{user_id}"
|
|
return get_client_ip(request)
|
|
|
|
|
|
# Default key_func for every @limiter.limit that doesn't override it.
|
|
limiter = Limiter(key_func=get_user_id_from_request)
|
|
|
|
|
|
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> UserModel:
|
|
payload = decode_access_token_payload(token)
|
|
if not payload or not payload.get("sub"):
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
|
try:
|
|
user_uuid = uuid.UUID(payload["sub"])
|
|
except ValueError:
|
|
# Signature-verified, so not reachable without the key, but a 500 would be wrong.
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
|
user = db.query(UserModel).filter(UserModel.id == user_uuid).first()
|
|
if not user:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
|
# Single chokepoint for revocation: the user row is already loaded, so it's free.
|
|
if token_issued_before(payload, user.token_valid_after):
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired, please log in again")
|
|
# Throttle to one write per 5 minutes so every authenticated request doesn't hammer the DB
|
|
now = datetime.now()
|
|
if not user.last_active_at or (now - user.last_active_at).total_seconds() > 300:
|
|
user.last_active_at = now
|
|
db.commit()
|
|
return user
|
|
|
|
|
|
## WebSocket connection limiting
|
|
|
|
# slowapi decorates HTTP routes only, and WS handlers can't authenticate before
|
|
# accept() anyway, so connection attempts are limited by client IP instead.
|
|
WS_MAX_CONNECTIONS = 30
|
|
WS_WINDOW_SECONDS = 60
|
|
WS_AUTH_TIMEOUT_SECONDS = 10
|
|
|
|
_ws_attempts: dict[str, deque[float]] = defaultdict(deque)
|
|
|
|
|
|
async def check_ws_rate_limit(websocket: WebSocket) -> bool:
|
|
"""Reject and close if this client is opening sockets too fast.
|
|
|
|
Call before accept(). Closing an un-accepted WebSocket sends an HTTP rejection,
|
|
so a flooding client never gets a live connection or a DB session.
|
|
"""
|
|
key = get_client_ip(websocket)
|
|
now = time.monotonic()
|
|
attempts = _ws_attempts[key]
|
|
while attempts and now - attempts[0] > WS_WINDOW_SECONDS:
|
|
attempts.popleft()
|
|
if len(attempts) >= WS_MAX_CONNECTIONS:
|
|
await websocket.close(code=1008)
|
|
return False
|
|
attempts.append(now)
|
|
# Keys are unbounded otherwise: drop idle ones whenever the table gets large.
|
|
if len(_ws_attempts) > 10000:
|
|
for k in [k for k, v in _ws_attempts.items() if not v or now - v[-1] > WS_WINDOW_SECONDS]:
|
|
del _ws_attempts[k]
|
|
return True
|
|
|
|
|
|
async def accept_and_authenticate_ws(websocket: WebSocket) -> str | None:
|
|
"""Rate-limit, accept, then take the auth token from the first frame.
|
|
|
|
Returns the user id, or None having already closed the socket.
|
|
|
|
The token can't be read before accept() — ASGI delivers no frames until then — so
|
|
the flood limit above is what guards the pre-auth window, and the receive timeout
|
|
stops an accepted-but-never-authenticated socket from pinning a DB session.
|
|
"""
|
|
if not await check_ws_rate_limit(websocket):
|
|
return None
|
|
await websocket.accept()
|
|
try:
|
|
token = await asyncio.wait_for(websocket.receive_text(), timeout=WS_AUTH_TIMEOUT_SECONDS)
|
|
except asyncio.TimeoutError:
|
|
await websocket.close(code=1008)
|
|
return None
|
|
except WebSocketDisconnect:
|
|
return None
|
|
user_id = decode_access_token(token)
|
|
if not user_id:
|
|
await websocket.close(code=1008)
|
|
return None
|
|
return user_id
|