🐐 Several security fixes
This commit is contained in:
+67
-16
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
@@ -15,34 +15,85 @@ REFRESH_TOKEN_EXPIRE_DAYS = 30
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"])
|
||||
|
||||
# Verified against when a login names a user that doesn't exist, so the response time
|
||||
# is the same either way. Without it, "no such user" returns before bcrypt runs and
|
||||
# the timing difference tells an attacker which usernames are real.
|
||||
DUMMY_PASSWORD_HASH = pwd_context.hash("not-a-real-password")
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
# All token timestamps are timezone-aware UTC. They used to be naive datetime.now(),
|
||||
# which jose serialises as if it were already UTC — with TZ=Europe/Paris that handed
|
||||
# out tokens living 1-2 hours longer than intended, drifting again at each DST change.
|
||||
#
|
||||
# 'iat' is carried so a password reset can invalidate everything issued before it;
|
||||
# see token_valid_after on the User model. It is written as a float rather than a
|
||||
# datetime: jose truncates datetimes to whole seconds, and at that resolution a token
|
||||
# minted just *after* a reset is indistinguishable from one minted just before, so
|
||||
# logging in immediately after a password change would be rejected. RFC 7519 permits
|
||||
# a non-integer NumericDate.
|
||||
|
||||
def create_access_token(user_id: str) -> str:
|
||||
expire = datetime.now() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
return jwt.encode({"sub": user_id, "exp": expire, "type": "access"}, SECRET_KEY, algorithm=ALGORITHM)
|
||||
now = datetime.now(timezone.utc)
|
||||
return jwt.encode(
|
||||
{
|
||||
"sub": user_id,
|
||||
"iat": now.timestamp(),
|
||||
"exp": now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
"type": "access",
|
||||
},
|
||||
SECRET_KEY, algorithm=ALGORITHM,
|
||||
)
|
||||
|
||||
def create_refresh_token(user_id: str) -> str:
|
||||
expire = datetime.now() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
return jwt.encode({"sub": user_id, "exp": expire, "type": "refresh"}, SECRET_KEY, algorithm=ALGORITHM)
|
||||
now = datetime.now(timezone.utc)
|
||||
return jwt.encode(
|
||||
{
|
||||
"sub": user_id,
|
||||
"iat": now.timestamp(),
|
||||
"exp": now + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS),
|
||||
"type": "refresh",
|
||||
},
|
||||
SECRET_KEY, algorithm=ALGORITHM,
|
||||
)
|
||||
|
||||
def decode_refresh_token(token: str) -> str | None:
|
||||
def _decode(token: str, expected_type: str) -> dict | None:
|
||||
"""Verified payload, or None if the signature, expiry or type is wrong."""
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
if payload.get("type") != "refresh":
|
||||
return None
|
||||
return payload.get("sub")
|
||||
except JWTError:
|
||||
return None
|
||||
if payload.get("type") != expected_type:
|
||||
return None
|
||||
return payload
|
||||
|
||||
def decode_refresh_token_payload(token: str) -> dict | None:
|
||||
return _decode(token, "refresh")
|
||||
|
||||
def decode_access_token_payload(token: str) -> dict | None:
|
||||
return _decode(token, "access")
|
||||
|
||||
def decode_refresh_token(token: str) -> str | None:
|
||||
payload = _decode(token, "refresh")
|
||||
return payload.get("sub") if payload else None
|
||||
|
||||
def decode_access_token(token: str) -> str | None:
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
if payload.get("type") != "access":
|
||||
return None
|
||||
return payload.get("sub")
|
||||
except JWTError:
|
||||
return None
|
||||
payload = _decode(token, "access")
|
||||
return payload.get("sub") if payload else None
|
||||
|
||||
def token_issued_before(payload: dict, cutoff: datetime | None) -> bool:
|
||||
"""True if this token predates a revocation cutoff (i.e. should be rejected)."""
|
||||
if cutoff is None:
|
||||
return False
|
||||
iat = payload.get("iat")
|
||||
# Tokens minted before 'iat' was introduced have no issue time, so they can't be
|
||||
# proven to postdate the cutoff — treat them as revoked rather than trusted.
|
||||
if iat is None:
|
||||
return True
|
||||
if cutoff.tzinfo is None:
|
||||
cutoff = cutoff.replace(tzinfo=timezone.utc)
|
||||
return datetime.fromtimestamp(iat, timezone.utc) < cutoff
|
||||
@@ -21,4 +21,9 @@ STRIPE_WEBHOOK_SECRET = require("STRIPE_WEBHOOK_SECRET")
|
||||
# Optional with sensible defaults for local dev
|
||||
FRONTEND_URL = optional("FRONTEND_URL", "http://localhost:5173")
|
||||
CORS_ORIGINS = optional("CORS_ORIGINS", "http://localhost:5173").split(",")
|
||||
WIKIRANK_USER_AGENT = optional("WIKIRANK_USER_AGENT", "WikiTCG/1.0")
|
||||
WIKIRANK_USER_AGENT = optional("WIKIRANK_USER_AGENT", "WikiTCG/1.0")
|
||||
|
||||
# Serves /docs, /redoc and /openapi.json, i.e. the whole API surface. Opt-in on an
|
||||
# explicit "true" rather than off on a truthy value, so a typo or an unset var in
|
||||
# a new environment fails closed.
|
||||
ENABLE_DOCS = optional("ENABLE_DOCS").lower() == "true"
|
||||
+126
-17
@@ -1,29 +1,90 @@
|
||||
import asyncio
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict, deque
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi import Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.auth import decode_access_token
|
||||
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")
|
||||
|
||||
# Shared rate limiter — registered on app.state in main.py
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
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:
|
||||
user_id = decode_access_token(token)
|
||||
if not user_id:
|
||||
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")
|
||||
user = db.query(UserModel).filter(UserModel.id == uuid.UUID(user_id)).first()
|
||||
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:
|
||||
@@ -32,12 +93,60 @@ def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(
|
||||
return user
|
||||
|
||||
|
||||
# Per-user key for rate limiting authenticated endpoints — prevents shared IPs (NAT/VPN)
|
||||
# from having their limits pooled. Falls back to remote IP for unauthenticated requests.
|
||||
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_remote_address(request)
|
||||
## 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
|
||||
|
||||
@@ -28,6 +28,11 @@ class User(Base):
|
||||
email_verification_token_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
trade_wishlist: Mapped[str | None] = mapped_column(Text, nullable=True, default="")
|
||||
last_active_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
# Tokens issued before this are rejected — the only way to revoke a session, since
|
||||
# tokens are pure JWTs with no server-side store. Set on both password-change paths.
|
||||
# timezone=True unlike its neighbours: it is compared against JWT 'iat', which is
|
||||
# always UTC, so leaving it naive would make it ambiguous against local-time columns.
|
||||
token_valid_after: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
cards: Mapped[list["Card"]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
decks: Mapped[list["Deck"]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
Reference in New Issue
Block a user