working with login and building inventory

This commit is contained in:
2026-11-12 11:40:31 -07:00
parent d11e5e70c7
commit 0a93925e11
45 changed files with 2389 additions and 77 deletions

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

@@ -1,25 +1,50 @@
"""FastAPI routes for the Star Citizen inventory (scunpacked data)."""
"""FastAPI routes for the Star Citizen inventory (scunpacked data).
The catalog endpoints below are read-only. Accounts and personal inventories
are served by the auth and inventory routers and persisted in PostgreSQL.
"""
import math
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from app import loader
from app import config
from app.auth import router as auth_router
from app.catalog import store
from app.db import db_healthy, init_db
from app.inventory import router as inventory_router
@asynccontextmanager
async def lifespan(_app: FastAPI):
if config.AUTO_INIT_DB:
init_db(wait_seconds=config.DB_WAIT_SECONDS)
yield
app = FastAPI(
title="Star Citizen Inventory API",
description="Read-only item inventory built from scunpacked game data.",
version="1.0.0",
description="Read-only item catalog built from scunpacked game data, plus "
"per-user accounts and personal inventories.",
version="1.1.0",
lifespan=lifespan,
)
# Read-only public GET API; permissive CORS keeps local development easy.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET"],
)
# Same-origin deployment by default. Cross-origin access must be opted into
# explicitly, because credentialed cookies and a wildcard origin are
# incompatible (and unsafe).
if config.CORS_ORIGINS:
app.add_middleware(
CORSMiddleware,
allow_origins=config.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["Content-Type"],
)
store = loader.load_store()
app.include_router(auth_router)
app.include_router(inventory_router)
VALID_CATEGORIES = {"ships", "ship-items", "fps-items"}
@@ -53,7 +78,7 @@ def _filter_items(category, search, manufacturer, item_type, size):
@app.get("/healthz")
def healthz():
return {"status": "ok", "counts": store.counts}
return {"status": "ok", "counts": store.counts, "db": db_healthy()}
@app.get("/api/v1/stats")

View File

@@ -0,0 +1,160 @@
"""Authentication routes: register, login, logout and current-user lookup.
Identity is a self-hosted username/password pair. Passwords are hashed with
Argon2; sessions are opaque random tokens whose SHA-256 is persisted, delivered
in an httpOnly cookie.
"""
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session as DbSession
from app import config, security
from app.db import get_db
from app.deps import get_current_user, require_same_origin
from app.models import User, UserSession, utcnow
from app.schemas import AuthResponse, LoginRequest, RegisterRequest, UserOut
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
throttle = security.LoginThrottle(
config.LOGIN_MAX_ATTEMPTS, config.LOGIN_WINDOW_SECONDS
)
_COOKIE_MAX_AGE = config.SESSION_TTL_DAYS * 24 * 60 * 60
def _set_session_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=config.COOKIE_NAME,
value=token,
max_age=_COOKIE_MAX_AGE,
httponly=True,
samesite="lax",
secure=config.COOKIE_SECURE,
path="/",
)
def _issue_session(db: DbSession, user: User) -> str:
token = security.new_session_token()
now = utcnow()
# Drop this user's expired sessions while we are here.
db.execute(
delete(UserSession).where(
UserSession.user_id == user.id, UserSession.expires_at <= now
)
)
db.add(
UserSession(
token_hash=security.hash_token(token),
user_id=user.id,
created_at=now,
expires_at=now + timedelta(days=config.SESSION_TTL_DAYS),
)
)
db.commit()
return token
@router.post(
"/register",
status_code=201,
response_model=AuthResponse,
dependencies=[Depends(require_same_origin)],
)
def register(payload: RegisterRequest, response: Response, db: DbSession = Depends(get_db)):
username = payload.username.strip()
if not security.valid_username(username):
raise HTTPException(
status_code=422,
detail="Username must be 3-32 characters: letters, digits, '.', '_' or '-'",
)
username_key = security.normalize_username(username)
existing = db.execute(
select(User).where(User.username_key == username_key)
).scalar_one_or_none()
if existing is not None:
raise HTTPException(status_code=409, detail="Username is already taken")
user = User(
username=username,
username_key=username_key,
password_hash=security.hash_password(payload.password),
)
db.add(user)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail="Username is already taken")
db.refresh(user)
_set_session_cookie(response, _issue_session(db, user))
return {"user": UserOut.model_validate(user)}
@router.post(
"/login",
response_model=AuthResponse,
dependencies=[Depends(require_same_origin)],
)
def login(payload: LoginRequest, request: Request, response: Response, db: DbSession = Depends(get_db)):
username_key = security.normalize_username(payload.username)
ip = security.client_ip(request)
wait = throttle.retry_after(username_key, ip)
if wait:
raise HTTPException(
status_code=429,
detail="Too many failed login attempts. Try again later.",
headers={"Retry-After": str(wait)},
)
user = db.execute(
select(User).where(User.username_key == username_key)
).scalar_one_or_none()
if user is None:
security.burn_password_check(payload.password)
throttle.record_failure(username_key, ip)
raise HTTPException(status_code=401, detail="Invalid username or password")
if not security.verify_password(payload.password, user.password_hash):
throttle.record_failure(username_key, ip)
raise HTTPException(status_code=401, detail="Invalid username or password")
throttle.reset(username_key, ip)
if security.needs_rehash(user.password_hash):
user.password_hash = security.hash_password(payload.password)
db.commit()
_set_session_cookie(response, _issue_session(db, user))
return {"user": UserOut.model_validate(user)}
@router.post(
"/logout",
status_code=204,
dependencies=[Depends(require_same_origin)],
)
def logout(request: Request, db: DbSession = Depends(get_db)):
token = request.cookies.get(config.COOKIE_NAME)
if token:
db.execute(
delete(UserSession).where(
UserSession.token_hash == security.hash_token(token)
)
)
db.commit()
response = Response(status_code=204)
response.delete_cookie(config.COOKIE_NAME, path="/")
return response
@router.get("/me", response_model=AuthResponse)
def me(user: User = Depends(get_current_user)):
return {"user": UserOut.model_validate(user)}

View File

@@ -0,0 +1,8 @@
"""Single owner of the immutable, read-only scunpacked catalog.
Keeping the store here (instead of in app/api.py) lets the auth and inventory
routers import it without importing the FastAPI app itself.
"""
from app import loader
store = loader.load_store()

View File

@@ -3,6 +3,9 @@
DATA_DIR selects which dataset the API serves:
- local dev / tests: small real-schema samples (default) or a fixture dir
- container image: the full scunpacked dataset downloaded at build time
The catalog is read-only. Everything mutable (accounts, sessions, per-user
inventories) lives in the database configured by DATABASE_URL.
"""
import os
@@ -10,3 +13,45 @@ _APP_DIR = os.path.dirname(os.path.abspath(__file__))
_BACKEND_DIR = os.path.dirname(_APP_DIR)
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(_BACKEND_DIR, "data", "samples"))
def _env_bool(name: str, default: bool) -> bool:
raw = os.environ.get(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
# --- Persistence ----------------------------------------------------------
DATABASE_URL = os.environ.get(
"DATABASE_URL", "postgresql+psycopg://sc_inventory:sc_inventory_dev@db:5432/sc_inventory"
)
# Create missing tables on startup. Disabled by the test suite, which builds
# its own schema against an in-memory SQLite database.
AUTO_INIT_DB = _env_bool("AUTO_INIT_DB", True)
# How long startup waits for PostgreSQL to accept connections (container boot).
DB_WAIT_SECONDS = int(os.environ.get("DB_WAIT_SECONDS", "60"))
# --- Session cookies ------------------------------------------------------
# Enable COOKIE_SECURE once the site is served over TLS. Defaults to false so
# plain http://localhost:8080 keeps working out of the box.
COOKIE_SECURE = _env_bool("COOKIE_SECURE", False)
COOKIE_NAME = os.environ.get("COOKIE_NAME", "sc_session")
SESSION_TTL_DAYS = int(os.environ.get("SESSION_TTL_DAYS", "30"))
# --- CORS -----------------------------------------------------------------
# Empty by default: the frontend is served same-origin through nginx, so no
# cross-origin access is needed. Comma-separated origins opt in explicitly.
CORS_ORIGINS = [
origin.strip()
for origin in os.environ.get("CORS_ORIGINS", "").split(",")
if origin.strip()
]
# --- Login hardening ------------------------------------------------------
LOGIN_MAX_ATTEMPTS = int(os.environ.get("LOGIN_MAX_ATTEMPTS", "5"))
LOGIN_WINDOW_SECONDS = int(os.environ.get("LOGIN_WINDOW_SECONDS", "900"))
# --- Input limits ---------------------------------------------------------
MAX_NOTES_LENGTH = 1000
MAX_QUANTITY = 1_000_000

View File

@@ -0,0 +1,89 @@
"""Database engine, session factory and schema bootstrap.
The engine is created lazily so importing the app (and loading the read-only
catalog) never requires a reachable database.
"""
import logging
import time
from sqlalchemy import create_engine, text
from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app import config
from app.models import Base
log = logging.getLogger(__name__)
_engine = None
_session_factory = None
def get_engine():
"""Return the process-wide engine, creating it on first use."""
global _engine, _session_factory
if _engine is None:
url = config.DATABASE_URL
kwargs = {"pool_pre_ping": True, "future": True}
if url.startswith("sqlite"):
kwargs["connect_args"] = {"check_same_thread": False}
if ":memory:" in url or url in {"sqlite://", "sqlite+pysqlite://"}:
kwargs["poolclass"] = StaticPool
_engine = create_engine(url, **kwargs)
_session_factory = sessionmaker(
bind=_engine, autoflush=False, autocommit=False, expire_on_commit=False
)
return _engine
def get_session_factory():
get_engine()
return _session_factory
def get_db():
"""FastAPI dependency yielding a request-scoped SQLAlchemy session."""
db = get_session_factory()()
try:
yield db
finally:
db.close()
def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None:
"""Create missing tables, retrying while PostgreSQL finishes booting.
`create_all(checkfirst=True)` is idempotent and safe to run on every start.
It does not migrate changed columns; introduce Alembic when the schema
starts evolving in place.
"""
deadline = time.monotonic() + max(wait_seconds, 0)
while True:
try:
engine = get_engine()
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
Base.metadata.create_all(bind=engine, checkfirst=True)
return
except (OperationalError, IntegrityError, ProgrammingError) as exc:
# A concurrent startup may have created the tables first.
if "already exists" in str(exc).lower():
return
if time.monotonic() >= deadline:
raise
log.warning(
"database not ready (%s); retrying in %.1fs",
exc.__class__.__name__,
interval,
)
time.sleep(interval)
def db_healthy() -> bool:
try:
with get_engine().connect() as conn:
conn.execute(text("SELECT 1"))
return True
except Exception:
return False

View File

@@ -0,0 +1,39 @@
"""Shared FastAPI dependencies: origin check and current-user resolution."""
from datetime import timezone
from fastapi import Depends, HTTPException, Request
from sqlalchemy.orm import Session as DbSession
from app import config, security
from app.db import get_db
from app.models import User, UserSession, utcnow
def require_same_origin(request: Request) -> None:
if not security.same_origin(request):
raise HTTPException(status_code=403, detail="Cross-origin request rejected")
def _as_utc(value):
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def get_current_user(
request: Request, db: DbSession = Depends(get_db)
) -> User:
token = request.cookies.get(config.COOKIE_NAME)
if not token:
raise HTTPException(status_code=401, detail="Not authenticated")
session = db.get(UserSession, security.hash_token(token))
if session is None:
raise HTTPException(status_code=401, detail="Not authenticated")
if _as_utc(session.expires_at) <= utcnow():
db.delete(session)
db.commit()
raise HTTPException(status_code=401, detail="Session expired")
return session.user

View File

@@ -0,0 +1,227 @@
"""Per-user inventory routes.
Rows reference immutable catalog items by `class_name`; responses are enriched
live from the in-memory catalog. A missing catalog item is reported as
`available: false` instead of deleting the user's data.
"""
import math
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session as DbSession
from app import catalog
from app.db import get_db
from app.deps import get_current_user, require_same_origin
from app.models import InventoryEntry, User
from app.schemas import (
InventoryAddRequest,
InventoryAddResponse,
InventoryEntryOut,
InventoryPage,
InventoryPatchRequest,
InventorySummary,
)
router = APIRouter(
prefix="/api/v1/me/inventory",
tags=["inventory"],
dependencies=[Depends(require_same_origin)],
)
MAX_PAGE_SIZE = 100
def _enrich(entry: InventoryEntry) -> dict:
item = catalog.store.by_class_name.get(entry.class_name)
if item is None:
return {
"class_name": entry.class_name,
"name": entry.class_name.replace("_", " "),
"category": None,
"type": None,
"size": None,
"grade": None,
"manufacturer": None,
"quantity": entry.quantity,
"notes": entry.notes,
"available": False,
"created_at": entry.created_at,
"updated_at": entry.updated_at,
}
return {
"class_name": entry.class_name,
"name": item["name"],
"category": item["category"],
"type": item["type"],
"size": item["size"],
"grade": item["grade"],
"manufacturer": item["manufacturer"],
"quantity": entry.quantity,
"notes": entry.notes,
"available": True,
"created_at": entry.created_at,
"updated_at": entry.updated_at,
}
def _find(db: DbSession, user: User, class_name: str) -> InventoryEntry | None:
return db.execute(
select(InventoryEntry).where(
InventoryEntry.user_id == user.id,
InventoryEntry.class_name == class_name,
)
).scalar_one_or_none()
@router.get("", response_model=InventoryPage)
def list_inventory(
category: str | None = None,
search: str | None = None,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=25, ge=1),
user: User = Depends(get_current_user),
db: DbSession = Depends(get_db),
):
page_size = min(page_size, MAX_PAGE_SIZE)
rows = db.execute(
select(InventoryEntry).where(InventoryEntry.user_id == user.id)
).scalars().all()
items = [_enrich(row) for row in rows]
if category is not None and category.strip():
wanted = category.strip()
items = [i for i in items if i["category"] == wanted]
if search is not None and search.strip():
needle = search.strip().lower()
items = [
i
for i in items
if needle in i["name"].lower() or needle in i["class_name"].lower()
]
items.sort(key=lambda i: (i["name"].lower(), i["class_name"]))
total = len(items)
total_pages = max(1, math.ceil(total / page_size))
start = (page - 1) * page_size
return {
"total": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
"items": items[start : start + page_size],
}
@router.get("/summary", response_model=InventorySummary)
def inventory_summary(
user: User = Depends(get_current_user), db: DbSession = Depends(get_db)
):
rows = db.execute(
select(InventoryEntry).where(InventoryEntry.user_id == user.id)
).scalars().all()
categories: dict[str, int] = {}
unavailable = 0
total_quantity = 0
for row in rows:
item = catalog.store.by_class_name.get(row.class_name)
if item is None:
unavailable += 1
else:
key = item["category"]
categories[key] = categories.get(key, 0) + row.quantity
total_quantity += row.quantity
return {
"items": len(rows),
"quantity": total_quantity,
"categories": categories,
"unavailable": unavailable,
}
@router.post("", response_model=InventoryAddResponse)
def add_to_inventory(
payload: InventoryAddRequest,
response: Response,
user: User = Depends(get_current_user),
db: DbSession = Depends(get_db),
):
class_name = payload.class_name.strip()
if class_name not in catalog.store.by_class_name:
raise HTTPException(
status_code=404, detail=f"Catalog item '{class_name}' not found"
)
entry = _find(db, user, class_name)
created = entry is None
if created:
entry = InventoryEntry(
user_id=user.id,
class_name=class_name,
quantity=payload.quantity,
notes=payload.notes,
)
db.add(entry)
else:
entry.quantity += payload.quantity
if payload.notes:
entry.notes = payload.notes
try:
db.commit()
except IntegrityError:
# Lost a race against a concurrent add: re-read and increment instead.
db.rollback()
entry = _find(db, user, class_name)
if entry is None:
raise HTTPException(status_code=409, detail="Could not add item; retry")
entry.quantity += payload.quantity
if payload.notes:
entry.notes = payload.notes
db.commit()
created = False
db.refresh(entry)
response.status_code = 201 if created else 200
return {"entry": InventoryEntryOut.model_validate(_enrich(entry)), "created": created}
@router.patch("/{class_name}", response_model=InventoryEntryOut)
def update_inventory_entry(
class_name: str,
payload: InventoryPatchRequest,
user: User = Depends(get_current_user),
db: DbSession = Depends(get_db),
):
entry = _find(db, user, class_name)
if entry is None:
raise HTTPException(status_code=404, detail="Item is not in your inventory")
if payload.quantity is not None:
entry.quantity = payload.quantity
if payload.notes is not None:
entry.notes = payload.notes
db.commit()
db.refresh(entry)
return InventoryEntryOut.model_validate(_enrich(entry))
@router.delete("/{class_name}", status_code=204)
def remove_inventory_entry(
class_name: str,
user: User = Depends(get_current_user),
db: DbSession = Depends(get_db),
):
entry = _find(db, user, class_name)
if entry is None:
raise HTTPException(status_code=404, detail="Item is not in your inventory")
db.delete(entry)
db.commit()
return Response(status_code=204)

View File

@@ -0,0 +1,93 @@
"""SQLAlchemy models for mutable user data (accounts + personal inventories).
The scunpacked catalog is deliberately NOT modeled here. It stays read-only in
memory (see app/catalog.py); inventory rows reference catalog items only by
`class_name`, so the catalog can be replaced wholesale without touching user
data.
"""
from datetime import datetime, timezone
from sqlalchemy import (
CheckConstraint,
DateTime,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
def utcnow() -> datetime:
"""Timezone-aware current time (portable across SQLite and PostgreSQL)."""
return datetime.now(timezone.utc)
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Display name as typed at registration.
username: Mapped[str] = mapped_column(String(32), nullable=False)
# Lowercased form, unique: gives case-insensitive uniqueness without citext.
username_key: Mapped[str] = mapped_column(
String(32), nullable=False, unique=True, index=True
)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow
)
sessions: Mapped[list["UserSession"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
inventory: Mapped[list["InventoryEntry"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
class UserSession(Base):
__tablename__ = "sessions"
# Only the SHA-256 of the cookie token is persisted.
token_hash: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow
)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
user: Mapped[User] = relationship(back_populates="sessions")
class InventoryEntry(Base):
__tablename__ = "inventory_entries"
__table_args__ = (
UniqueConstraint("user_id", "class_name", name="uq_inventory_user_item"),
CheckConstraint("quantity >= 1", name="ck_inventory_quantity_positive"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
class_name: Mapped[str] = mapped_column(String(255), nullable=False)
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
notes: Mapped[str] = mapped_column(Text, nullable=False, default="")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow
)
user: Mapped[User] = relationship(back_populates="inventory")

View File

@@ -0,0 +1,77 @@
"""Pydantic request/response models for auth and personal inventory."""
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from app import config
class RegisterRequest(BaseModel):
username: str = Field(min_length=3, max_length=32)
password: str = Field(min_length=8, max_length=128)
class LoginRequest(BaseModel):
username: str = Field(min_length=1, max_length=32)
password: str = Field(min_length=1, max_length=128)
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
created_at: datetime
class AuthResponse(BaseModel):
user: UserOut
class InventoryAddRequest(BaseModel):
class_name: str = Field(min_length=1, max_length=255)
quantity: int = Field(default=1, ge=1, le=config.MAX_QUANTITY)
notes: str = Field(default="", max_length=config.MAX_NOTES_LENGTH)
class InventoryPatchRequest(BaseModel):
quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY)
notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH)
class InventoryEntryOut(BaseModel):
class_name: str
name: str
category: str | None
type: str | None
size: int | None
grade: Any | None = None
manufacturer: dict | None
quantity: int
notes: str
# False when the catalog no longer contains the referenced item (e.g. after
# a dataset refresh). The row is preserved rather than silently deleted.
available: bool
created_at: datetime
updated_at: datetime
class InventoryPage(BaseModel):
total: int
page: int
page_size: int
total_pages: int
items: list[InventoryEntryOut]
class InventorySummary(BaseModel):
items: int
quantity: int
categories: dict[str, int]
unavailable: int
class InventoryAddResponse(BaseModel):
entry: InventoryEntryOut
created: bool

View File

@@ -0,0 +1,140 @@
"""Password hashing, session tokens, origin checks and login throttling."""
import hashlib
import re
import secrets
import threading
import time
from collections import deque
from urllib.parse import urlsplit
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
_hasher = PasswordHasher()
# Hash of a throwaway secret, verified when a username does not exist so that
# "unknown user" and "wrong password" take comparable time.
_DUMMY_HASH = _hasher.hash(secrets.token_urlsafe(16))
USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{3,32}$")
MIN_PASSWORD_LENGTH = 8
MAX_PASSWORD_LENGTH = 128
def normalize_username(username: str) -> str:
return username.strip().lower()
def valid_username(username: str) -> bool:
return bool(USERNAME_RE.match(username.strip()))
def hash_password(password: str) -> str:
return _hasher.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
try:
return _hasher.verify(password_hash, password)
except (VerifyMismatchError, VerificationError, InvalidHashError):
return False
def burn_password_check(password: str) -> None:
"""Constant-ish work for unknown users, to blunt username enumeration."""
try:
_hasher.verify(_DUMMY_HASH, password)
except (VerifyMismatchError, VerificationError, InvalidHashError):
pass
def needs_rehash(password_hash: str) -> bool:
return _hasher.check_needs_rehash(password_hash)
def new_session_token() -> str:
return secrets.token_urlsafe(32)
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def client_ip(request) -> str:
"""Real client address as seen by the reverse proxy.
nginx overwrites X-Real-IP with the actual peer address, so it is safe to
trust here. X-Forwarded-For is client-appendable and is never used.
"""
real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip.strip()
return request.client.host if request.client else "unknown"
def same_origin(request) -> bool:
"""Reject cross-origin state-changing requests (CSRF defense in depth)."""
origin = request.headers.get("origin")
if not origin:
return True # non-browser client; the session cookie is still required
host = request.headers.get("x-forwarded-host") or request.headers.get("host") or ""
return urlsplit(origin).netloc.lower() == host.lower()
class LoginThrottle:
"""In-process sliding-window limiter for failed logins.
Tracks failures per (username, ip) and, with a higher ceiling, per ip so a
single address cannot spray passwords across many usernames.
"""
def __init__(self, max_attempts: int, window_seconds: int):
self.max_attempts = max_attempts
self.window = window_seconds
self._events: dict[tuple[str, str], deque[float]] = {}
self._lock = threading.Lock()
def _keys(self, username_key: str, ip: str) -> list[tuple[tuple[str, str], int]]:
return [
((username_key, ip), self.max_attempts),
(("*", ip), self.max_attempts * 4),
]
def _recent(self, key: tuple[str, str], now: float) -> deque[float] | None:
events = self._events.get(key)
if not events:
return None
cutoff = now - self.window
while events and events[0] < cutoff:
events.popleft()
if not events:
self._events.pop(key, None)
return None
return events
def retry_after(self, username_key: str, ip: str) -> int:
"""Seconds to wait before another attempt is allowed (0 if allowed)."""
now = time.monotonic()
with self._lock:
waits = []
for key, limit in self._keys(username_key, ip):
events = self._recent(key, now)
if events and len(events) >= limit:
waits.append(int(self.window - (now - events[0])) + 1)
return max(waits) if waits else 0
def record_failure(self, username_key: str, ip: str) -> None:
now = time.monotonic()
with self._lock:
if len(self._events) > 5000: # opportunistic cleanup
for key in list(self._events):
self._recent(key, now)
for key, _limit in self._keys(username_key, ip):
self._events.setdefault(key, deque()).append(now)
def reset(self, username_key: str, ip: str) -> None:
with self._lock:
for key, _limit in self._keys(username_key, ip):
self._events.pop(key, None)
def clear(self) -> None:
with self._lock:
self._events.clear()