Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
"""Password hashing and JWT helpers.
|
||||
|
||||
Password hashing uses Argon2 (via `argon2-cffi`) directly - it's the
|
||||
currently recommended default and needs no extra abstraction layer.
|
||||
JWTs are signed with `JWT_SECRET` (HS256); access tokens are short-lived,
|
||||
refresh tokens are long-lived but stored server-side only as a hash so a
|
||||
leaked DB row can't be replayed as a valid token by itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from enum import StrEnum
|
||||
|
||||
import jwt
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
from fastapi import Request
|
||||
|
||||
from app.core.config import Settings
|
||||
|
||||
_hasher = PasswordHasher()
|
||||
|
||||
|
||||
def get_client_ip(request: Request, settings: Settings) -> str:
|
||||
"""The single source of truth for "what IP is this request from" - used
|
||||
by `is_localhost`, the IP throttle/ban engine, and anywhere else that
|
||||
needs to identify a caller. Today there's no reverse proxy in front of
|
||||
uvicorn in this stack, so the direct TCP peer (`request.client.host`) is
|
||||
the real originating address.
|
||||
|
||||
Once deployed behind Cloudflare (or Nginx), the direct peer becomes the
|
||||
proxy itself, not the visitor - `settings.trusted_proxy_ip_header` (e.g.
|
||||
"CF-Connecting-IP") switches this to read the real address from that
|
||||
header instead. Only ever set this when it's actually known the proxy is
|
||||
in front and stripping/overwriting that header from untrusted clients -
|
||||
otherwise a client could simply forge it to spoof any IP. Left empty by
|
||||
default (trust the direct connection only) - see KNOWN_LIMITATIONS.md."""
|
||||
header_name = settings.trusted_proxy_ip_header
|
||||
if header_name:
|
||||
forwarded = request.headers.get(header_name)
|
||||
if forwarded:
|
||||
return forwarded.strip()
|
||||
return request.client.host if request.client is not None else "unknown"
|
||||
|
||||
|
||||
def is_localhost(request: Request, settings: Settings) -> bool:
|
||||
"""True when the request's resolved client IP (see `get_client_ip`) is
|
||||
this machine's loopback address - not merely "someone on the LAN" - or
|
||||
is explicitly listed in `settings.additional_trusted_local_ips` (empty
|
||||
by default; a narrow, opt-in escape hatch for Docker Desktop's bridge
|
||||
networking, where even host-originated traffic doesn't arrive as
|
||||
literal loopback - see KNOWN_LIMITATIONS.md)."""
|
||||
client_ip = get_client_ip(request, settings)
|
||||
if client_ip in ("127.0.0.1", "::1"):
|
||||
return True
|
||||
extra = {ip.strip() for ip in settings.additional_trusted_local_ips.split(",") if ip.strip()}
|
||||
return client_ip in extra
|
||||
|
||||
|
||||
def hash_password(raw_password: str) -> str:
|
||||
return _hasher.hash(raw_password)
|
||||
|
||||
|
||||
def verify_password(raw_password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return _hasher.verify(password_hash, raw_password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
|
||||
|
||||
class TokenType(StrEnum):
|
||||
ACCESS = "access"
|
||||
REFRESH = "refresh"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DecodedToken:
|
||||
user_id: uuid.UUID
|
||||
token_type: TokenType
|
||||
jti: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
def create_access_token(user_id: uuid.UUID, settings: Settings) -> str:
|
||||
return _encode_token(
|
||||
user_id, TokenType.ACCESS, timedelta(minutes=settings.jwt_access_token_minutes), settings
|
||||
)
|
||||
|
||||
|
||||
def create_refresh_token(user_id: uuid.UUID, settings: Settings) -> tuple[str, str, datetime]:
|
||||
"""Returns (raw_jwt, jti, expires_at). Caller stores a hash of `jti`, not the JWT itself."""
|
||||
expires_at = datetime.now(UTC) + timedelta(days=settings.jwt_refresh_token_days)
|
||||
jti = secrets.token_urlsafe(32)
|
||||
token = _encode_token(
|
||||
user_id,
|
||||
TokenType.REFRESH,
|
||||
timedelta(days=settings.jwt_refresh_token_days),
|
||||
settings,
|
||||
jti=jti,
|
||||
)
|
||||
return token, jti, expires_at
|
||||
|
||||
|
||||
def _encode_token(
|
||||
user_id: uuid.UUID,
|
||||
token_type: TokenType,
|
||||
expires_in: timedelta,
|
||||
settings: Settings,
|
||||
jti: str | None = None,
|
||||
) -> str:
|
||||
now = datetime.now(UTC)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"type": token_type.value,
|
||||
"iat": now,
|
||||
"exp": now + expires_in,
|
||||
"jti": jti or secrets.token_urlsafe(16),
|
||||
}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
|
||||
|
||||
|
||||
class InvalidTokenError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def decode_token(token: str, settings: Settings, expected_type: TokenType) -> DecodedToken:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret, algorithms=["HS256"])
|
||||
except jwt.PyJWTError as exc:
|
||||
raise InvalidTokenError(str(exc)) from exc
|
||||
|
||||
if payload.get("type") != expected_type.value:
|
||||
raise InvalidTokenError(f"Expected a {expected_type.value} token")
|
||||
|
||||
try:
|
||||
user_id = uuid.UUID(payload["sub"])
|
||||
except (KeyError, ValueError) as exc:
|
||||
raise InvalidTokenError("Malformed token subject") from exc
|
||||
|
||||
return DecodedToken(
|
||||
user_id=user_id,
|
||||
token_type=TokenType(payload["type"]),
|
||||
jti=payload["jti"],
|
||||
expires_at=datetime.fromtimestamp(payload["exp"], tz=UTC),
|
||||
)
|
||||
|
||||
|
||||
def hash_token_identifier(jti: str) -> str:
|
||||
"""One-way hash of a refresh token's `jti` for storage/comparison (not the JWT itself)."""
|
||||
import hashlib
|
||||
|
||||
return hashlib.sha256(jti.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def generate_email_code() -> str:
|
||||
"""A 6-digit numeric code for email verification / password reset -
|
||||
short-lived and IP-throttled (app/services/ip_throttle_service.py), so
|
||||
it doesn't need Argon2's cost the way a password does."""
|
||||
return f"{secrets.randbelow(1_000_000):06d}"
|
||||
|
||||
|
||||
def hash_email_code(code: str) -> str:
|
||||
"""One-way hash of an email code for storage/comparison (never the raw
|
||||
code) - same precedent as hash_token_identifier."""
|
||||
import hashlib
|
||||
|
||||
return hashlib.sha256(code.encode("utf-8")).hexdigest()
|
||||
Reference in New Issue
Block a user