"""Users helpers — password hashing, JWT tokens, and payload cleaning. Uses the `bcrypt` package directly rather than passlib: passlib 1.7.4 reads `bcrypt.__about__.__version__`, which bcrypt dropped in 4.1, and the failed version probe makes it reject every password as longer than 72 bytes. """ from __future__ import annotations import os import uuid from datetime import datetime, timedelta, timezone from typing import Any import bcrypt import jwt from dotenv import load_dotenv load_dotenv() # bcrypt hashes at most 72 bytes and raises on anything longer. BCRYPT_MAX_BYTES = 72 # Columns the server owns; a client must never be able to set them. SERVER_OWNED_FIELDS = ("id", "created_at", "updated_at", "is_deleted") JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY") JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "30")) REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("JWT_REFRESH_TOKEN_EXPIRE_DAYS", "7")) RESET_TOKEN_EXPIRE_MINUTES = int(os.getenv("JWT_RESET_TOKEN_EXPIRE_MINUTES", "10")) ACCESS_TOKEN_EXPIRE_SECONDS = ACCESS_TOKEN_EXPIRE_MINUTES * 60 RESET_TOKEN_EXPIRE_SECONDS = RESET_TOKEN_EXPIRE_MINUTES * 60 def _encode(raw: str) -> bytes: """UTF-8 bytes truncated to what bcrypt accepts, without splitting a character.""" return raw.encode("utf-8")[:BCRYPT_MAX_BYTES].decode("utf-8", "ignore").encode("utf-8") def hash_password(raw: str) -> str: return bcrypt.hashpw(_encode(raw), bcrypt.gensalt()).decode("ascii") def verify_password(raw: str, hashed: str) -> bool: """False rather than raising on rows written before hashing existed.""" if not raw or not hashed: return False try: return bcrypt.checkpw(_encode(raw), hashed.encode("utf-8")) except (ValueError, TypeError): return False def clean_user_payload(payload: dict, *, partial: bool = False) -> dict: """Strip server-owned keys and hash the password; on partial, drop unset fields.""" fields = { key: value for key, value in payload.items() if key not in SERVER_OWNED_FIELDS } if partial: fields = {key: value for key, value in fields.items() if value is not None} if fields.get("password"): fields["password"] = hash_password(fields["password"]) else: fields.pop("password", None) return fields def _secret() -> str: if not JWT_SECRET_KEY: raise RuntimeError("JWT_SECRET_KEY is not set") return JWT_SECRET_KEY def _create_token( subject: str, *, token_type: str, expires_delta: timedelta, claims: dict[str, Any] | None = None, ) -> str: now = datetime.now(timezone.utc) payload: dict[str, Any] = { "sub": subject, "type": token_type, "iat": now, "exp": now + expires_delta, "jti": str(uuid.uuid4()), } if claims: payload.update(claims) return jwt.encode(payload, _secret(), algorithm=JWT_ALGORITHM) def create_access_token(user) -> str: return _create_token( str(user.id), token_type="access", expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), claims={ "email": user.email, "role_id": user.role_id, }, ) def create_refresh_token(user) -> str: return _create_token( str(user.id), token_type="refresh", expires_delta=timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS), ) def create_reset_token(email: str, *, code_id: str) -> str: return _create_token( email, token_type="reset", expires_delta=timedelta(minutes=RESET_TOKEN_EXPIRE_MINUTES), claims={"crid": code_id}, ) def decode_token(token: str, *, expected_type: str) -> dict: payload = jwt.decode(token, _secret(), algorithms=[JWT_ALGORITHM]) if payload.get("type") != expected_type: raise jwt.InvalidTokenError("Unexpected token type") return payload