""" Password hashing, access-token issuance/verification, and the Principal that represents an authenticated caller. Two kinds of credential reach this module: * Interactive users. ``POST /api/auth/login`` exchanges a username/password for a short-lived signed JWT. No password is ever stored - only a PBKDF2 digest, read from the environment (``AUTH_ADMIN_PASSWORD_HASH`` / ``AUTH_USER_PASSWORD_HASH``). Generate those with ``python scripts/make_auth_secrets.py``. * Machine consumers. A static key sent as ``X-API-Key``, mapped to a role by ``API_KEYS``. These do not expire, so treat one as a long-lived secret and give each consumer its own so it can be revoked individually. PBKDF2-HMAC-SHA256 is used rather than bcrypt or argon2 deliberately: it is in the standard library, so the slim Python image needs no compiled dependency, and at the iteration count below it meets OWASP's current guidance. The encoded form carries its own iteration count, so raising the constant later does not invalidate hashes already issued. """ from __future__ import annotations import base64 import hashlib import hmac import logging import secrets import time from dataclasses import dataclass, field from typing import Dict, List, Optional, Tuple import jwt from app.infrastructure.settings import ( API_KEYS, AUTH_ADMIN_PASSWORD_HASH, AUTH_ADMIN_USERNAME, AUTH_ALLOW_ANY_LOGIN, AUTH_ENABLED, AUTH_SECRET_KEY, AUTH_TOKEN_TTL_MINUTES, config_source, ) logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Roles and permissions # --------------------------------------------------------------------------- # These mirror the permission strings the React UI already keys its navigation # off, so the server now enforces the same vocabulary the client was only # displaying. `admin` is a superuser: has_permission() grants it everything # rather than requiring every new permission to be added to this list. ROLE_PERMISSIONS: Dict[str, List[str]] = { "admin": [ "view_catalog", "view_project_details", "upload_train_test", "allocate_discounts", "manage_analytics", "manage_nutrition", ], "user": [ "add_product", "upload_batch_products", "update_db_and_json", "fetch_images", "upload_store_inventory", "view_store_analytics", "view_nutrition_insights", "optimize_profits", ], } VALID_ROLES = frozenset(ROLE_PERMISSIONS) JWT_ALGORITHM = "HS256" JWT_ISSUER = "brand-catalog-rag" # OWASP's floor for PBKDF2-HMAC-SHA256 at time of writing. _PBKDF2_ITERATIONS = 600_000 _PBKDF2_PREFIX = "pbkdf2_sha256" @dataclass(frozen=True) class Principal: """Whoever is making the current request, once their credential checks out.""" username: str role: str permissions: List[str] = field(default_factory=list) # "user" - logged in via /api/auth/login, carrying a JWT # "api_key" - a machine consumer from API_KEYS # "anonymous" - AUTH_ENABLED=false; no credential was checked at all kind: str = "user" def has_permission(self, permission: str) -> bool: return self.role == "admin" or permission in self.permissions class AuthError(Exception): """A credential was absent, malformed, expired, or simply wrong.""" # --------------------------------------------------------------------------- # Password hashing # --------------------------------------------------------------------------- def hash_password(password: str, *, iterations: int = _PBKDF2_ITERATIONS) -> str: """Return an encoded digest: ``pbkdf2_sha256$$$``.""" salt = secrets.token_bytes(16) digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) return "$".join( ( _PBKDF2_PREFIX, str(iterations), base64.b64encode(salt).decode("ascii"), base64.b64encode(digest).decode("ascii"), ) ) def _parse_encoded_hash(encoded: str) -> Optional[Tuple[bytes, bytes, int]]: """ Split an encoded digest into ``(salt, digest, iterations)``, or None if it is not one. One parser, three callers. `verify_password` needs the parts, while `hash_is_wellformed` and `describe_password_hash` need only the verdict - and a login failing because the *configured* hash is corrupt is a different incident from a wrong password, so the two must agree on what "corrupt" means. Two copies of this parse would eventually disagree. Values arrive here straight from the environment, so a hash pasted into a deployment platform's form field as "pbkdf2_sha256$..." is unwrapped rather than rejected: the surrounding quotes are almost never intended as part of the secret, and the failure they cause otherwise is a silent 401. """ if not encoded: return None encoded = encoded.strip().strip("'\"") try: prefix, raw_iterations, raw_salt, raw_digest = encoded.split("$") if prefix != _PBKDF2_PREFIX: return None # validate=True so junk is rejected rather than silently discarded: # b64decode's default drops non-alphabet characters, which would let a # subtly corrupted hash decode to the wrong bytes and fail as a "wrong # password" instead of as the configuration error it is. # binascii.Error subclasses ValueError, so it is caught below. salt = base64.b64decode(raw_salt, validate=True) digest = base64.b64decode(raw_digest, validate=True) iterations = int(raw_iterations) except (ValueError, TypeError): return None # A structurally valid string that decodes to nothing is still unusable, # and PBKDF2 rejects a non-positive iteration count by raising. if not salt or not digest or iterations < 1: return None return salt, digest, iterations def hash_is_wellformed(encoded: str) -> bool: """Whether a configured digest can be checked against at all. Distinct from "does the password match": this asks whether the credential *store* is usable, which is a deployment fault rather than a sign-in one. """ return _parse_encoded_hash(encoded) is not None def password_hash_fingerprint(encoded: str) -> str: """ A short, non-reversible identifier for a configured digest. Safe to log and to publish: it is a truncated SHA-256 of the *encoded digest*, and that digest already embeds a 16-byte random salt, so this says which credential is loaded without saying anything about the password behind it. It exists so a running deployment can be compared against the config it was supposed to have been built from - the failure this project actually hit - without moving a secret in order to do the comparison. """ if not encoded: return "" return hashlib.sha256(encoded.strip().strip("'\"").encode("utf-8")).hexdigest()[:12] def api_key_fingerprint(name: str, secret: str) -> str: """ A short, non-reversible identifier for a configured API key. Same purpose as password_hash_fingerprint - say *which* credential is loaded without moving the credential - but the safety argument is different and worth stating. That function digests an encoded hash which already embeds a 16-byte random salt. An API key has no salt, so the name is mixed in here to keep two consumers that were mistakenly issued the same secret from fingerprinting identically, and settings._parse_api_keys enforces a minimum secret length so the digest cannot be walked back with a wordlist. """ if not secret: return "" cleaned = secret.strip().strip("'\"") material = f"{name}:{cleaned}" return hashlib.sha256(material.encode("utf-8")).hexdigest()[:12] def describe_api_keys() -> List[Dict[str, object]]: """Every configured key as {name, role, fingerprint}, sorted by name. Sorted so two deployments' /api/health output can be diffed line for line; API_KEYS is keyed by secret, whose iteration order says nothing useful. """ return sorted( ( {"name": name, "role": role, "fingerprint": api_key_fingerprint(name, secret)} for secret, (name, role) in API_KEYS.items() ), key=lambda entry: entry["name"], ) def describe_password_hash(encoded: str) -> Dict[str, object]: """A loggable/publishable summary of a configured digest. Never its bytes.""" parsed = _parse_encoded_hash(encoded) return { "valid": parsed is not None, "algorithm": _PBKDF2_PREFIX if parsed is not None else None, "iterations": parsed[2] if parsed is not None else None, "fingerprint": password_hash_fingerprint(encoded), } def auth_config_summary() -> Dict[str, object]: """ The effective authentication configuration, in a form safe to both log and publish. Contains no password and no hash - only the fingerprint. This is deliberately one function with two callers (the startup log in app/main.py and GET /api/health), because its entire purpose is letting two *deployments* be compared, and that only works if both report the same fields computed the same way. `*_source` is the field that earns this its keep. A value of "process-env" means the container's own environment supplied it and the .env file baked into the image was ignored - which is invisible from anywhere else, and is precisely how a corrected credential can keep failing after a redeploy. The same argument is why the API keys are summarised here. backend/Dockerfile copies .env.production in at BUILD time, so a key added to that file and then merely restarted is not present in the running process - and from outside, an undeployed key is indistinguishable from a wrong one, because both are just a 401. Publishing the names and fingerprints answers "is my key on this deployment?" without anyone having to send the secret to find out. """ described = describe_password_hash(AUTH_ADMIN_PASSWORD_HASH) return { "enabled": AUTH_ENABLED, "allow_any_login": AUTH_ALLOW_ANY_LOGIN, "admin_username": AUTH_ADMIN_USERNAME, "password_hash_valid": bool(described["valid"]), "password_hash_iterations": described["iterations"], "password_hash_fingerprint": described["fingerprint"], "admin_username_source": config_source("AUTH_ADMIN_USERNAME"), "password_hash_source": config_source("AUTH_ADMIN_PASSWORD_HASH"), "api_keys_count": len(API_KEYS), "api_keys": describe_api_keys(), "api_keys_source": config_source("API_KEYS"), } def verify_password(password: str, encoded: str) -> bool: """ Check a password against an encoded digest. Returns False rather than raising on a malformed digest: a typo in AUTH_ADMIN_PASSWORD_HASH must fail the login, not 500 the endpoint and hand the caller a stack trace describing the credential store. """ if not encoded: return False parsed = _parse_encoded_hash(encoded) if parsed is None: logger.error( "A configured password hash is malformed and cannot be used. Regenerate " "it with: python scripts/make_auth_secrets.py" ) return False salt, digest, iterations = parsed candidate = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) return hmac.compare_digest(candidate, digest) # --------------------------------------------------------------------------- # Access tokens # --------------------------------------------------------------------------- def create_access_token( username: str, role: str, permissions: List[str], *, ttl_minutes: Optional[int] = None, ) -> tuple[str, int]: """Issue a signed JWT. Returns ``(token, expires_in_seconds)``.""" ttl = (ttl_minutes if ttl_minutes is not None else AUTH_TOKEN_TTL_MINUTES) * 60 now = int(time.time()) payload = { "sub": username, "role": role, "perms": permissions, "iss": JWT_ISSUER, "iat": now, "exp": now + ttl, } return jwt.encode(payload, AUTH_SECRET_KEY, algorithm=JWT_ALGORITHM), ttl def decode_access_token(token: str) -> Principal: """ Verify a JWT and return the Principal it names. The algorithm is pinned to a single-item allow-list rather than read from the token header. That is what closes the two classic JWT bypasses: a token presenting ``alg: none``, and one presenting ``alg: HS256`` against a key the server intended to use asymmetrically. """ try: payload = jwt.decode( token, AUTH_SECRET_KEY, algorithms=[JWT_ALGORITHM], issuer=JWT_ISSUER, options={"require": ["exp", "iat", "sub"]}, ) except jwt.ExpiredSignatureError as exc: raise AuthError("Token has expired. Sign in again.") from exc except jwt.InvalidTokenError as exc: raise AuthError("Invalid authentication token.") from exc role = payload.get("role") if role not in VALID_ROLES: raise AuthError("Token names an unknown role.") perms = payload.get("perms") return Principal( username=str(payload["sub"]), role=role, # Fall back to the role's current grants if the token predates a # permission change, rather than trusting an arbitrary claim shape. permissions=list(perms) if isinstance(perms, list) else ROLE_PERMISSIONS.get(role, []), kind="user", ) # --------------------------------------------------------------------------- # API keys (machine consumers) # --------------------------------------------------------------------------- def principal_for_api_key(presented: str) -> Principal: """ Resolve an ``X-API-Key`` value to a Principal. Every configured key is compared even after a match, using compare_digest, so the time taken does not reveal how far down the list a near-miss got. """ matched: Optional[tuple[str, str]] = None for secret, (name, role) in API_KEYS.items(): if hmac.compare_digest(presented, secret): matched = (name, role) if matched is None: raise AuthError("Invalid API key.") name, role = matched return Principal( username=name, role=role, permissions=ROLE_PERMISSIONS.get(role, []), kind="api_key", ) def anonymous_principal() -> Principal: """ The stand-in used when ``AUTH_ENABLED=false``. It is deliberately an admin: disabling auth is meant to make local development frictionless, and a half-privileged anonymous caller would produce confusing 403s instead. Nothing calls this when auth is on. """ return Principal( username="anonymous", role="admin", permissions=ROLE_PERMISSIONS["admin"], kind="anonymous", ) if not AUTH_ENABLED: logger.warning( "AUTH_ENABLED=false: every endpoint is unauthenticated, including catalog " "generation, ML training, and the upload endpoints. This is for local " "development only - never run it on a host reachable from the internet." )