Spaces:
Paused
Paused
| from fastapi_users.authentication import ( | |
| AuthenticationBackend, | |
| BearerTransport, | |
| CookieTransport, | |
| JWTStrategy, | |
| ) | |
| from app.config import get_settings | |
| from typing import Optional, Any | |
| import jwt | |
| from jwt import PyJWK | |
| from datetime import datetime, timedelta | |
| settings = get_settings() | |
| # Bearer transport (para APIs programáticas / mobile) | |
| bearer_transport = BearerTransport(tokenUrl="api/auth/jwt/login") | |
| # Cookie transport (para navegadores - HttpOnly, Secure, SameSite=Lax) | |
| cookie_transport = CookieTransport( | |
| cookie_name="cd_token", | |
| cookie_max_age=settings.access_token_expire_minutes * 60, | |
| cookie_secure=not settings.debug, # Secure=True en prod, False en dev local | |
| cookie_httponly=True, | |
| cookie_samesite="lax", | |
| ) | |
| # ─── Custom Dual JWT Strategy ─── | |
| # Firma nuevos tokens con RS256 (private key) | |
| # Verifica: intenta RS256 (public key) → si falla, fallback a HS256 (legacy secret) | |
| class DualJWTStrategy(JWTStrategy): | |
| """JWT Strategy que soporta verificación dual: RS256 (nuevo) + HS256 (legacy).""" | |
| def __init__(self): | |
| # Cargar claves | |
| self._private_key = self._load_private_key() | |
| self._public_key = self._load_public_key() | |
| self._legacy_secret = settings.jwt_legacy_secret_key or settings.secret_key | |
| self._algorithm = settings.jwt_algorithm | |
| self._legacy_algorithm = settings.jwt_legacy_algorithm | |
| self._lifetime_seconds = settings.access_token_expire_minutes * 60 | |
| self._key_id = settings.jwt_key_id | |
| # Initialize base class with required params (using legacy secret for base compat) | |
| super().__init__( | |
| secret=self._legacy_secret, | |
| lifetime_seconds=self._lifetime_seconds, | |
| token_audience=["fastapi-users:auth"], | |
| algorithm=self._algorithm, | |
| public_key=self._public_key, | |
| ) | |
| def _load_private_key(self) -> Optional[str]: | |
| """Cargar clave privada RS256 desde config o archivo.""" | |
| if settings.jwt_private_key: | |
| return settings.jwt_private_key | |
| # Fallback: leer archivo | |
| import os | |
| key_path = os.path.join(os.path.dirname(__file__), '..', '..', 'private_key.pem') | |
| if os.path.exists(key_path): | |
| with open(key_path, 'r') as f: | |
| return f.read() | |
| return None | |
| def _load_public_key(self) -> Optional[str]: | |
| """Cargar clave pública RS256 desde config o archivo.""" | |
| if settings.jwt_public_key: | |
| return settings.jwt_public_key | |
| # Fallback: leer archivo | |
| import os | |
| key_path = os.path.join(os.path.dirname(__file__), '..', '..', 'public_key.pem') | |
| if os.path.exists(key_path): | |
| with open(key_path, 'r') as f: | |
| return f.read() | |
| return None | |
| async def write_token(self, user) -> str: | |
| """Firmar token con RS256 (nueva clave privada). Fallar si no hay clave.""" | |
| # Extraer ID del usuario (fastapi-users pasa el objeto User) | |
| user_id = str(user.id) | |
| data = {"sub": user_id, "aud": self.token_audience} | |
| if not self._private_key: | |
| raise RuntimeError( | |
| "JWT_PRIVATE_KEY no configurada. Configure RS256 keys para firmar tokens. " | |
| "No se permite fallback silencioso a HS256." | |
| ) | |
| # Agregar kid en header para rotación de claves | |
| headers = {"kid": self._key_id} | |
| return jwt.encode( | |
| {**data, "exp": datetime.utcnow() + timedelta(seconds=self._lifetime_seconds)}, | |
| self._private_key, | |
| algorithm=self._algorithm, | |
| headers=headers, | |
| ) | |
| async def read_token(self, token: Optional[str], user_manager) -> Optional[Any]: | |
| """Verificar token: solo RS256. Legacy HS256 solo si jwt_legacy_enabled=True.""" | |
| if token is None: | |
| return None | |
| # Intentar RS256 (nuevo) | |
| if self._public_key: | |
| try: | |
| data = jwt.decode( | |
| token, | |
| self._public_key, | |
| algorithms=[self._algorithm], | |
| audience=self.token_audience, | |
| ) | |
| user_id = data.get("sub") | |
| if user_id is None: | |
| return None | |
| parsed_id = user_manager.parse_id(user_id) | |
| return await user_manager.get(parsed_id) | |
| except jwt.PyJWTError: | |
| pass # Fallar a legacy si habilitado | |
| # Legacy HS256 SOLO si explícitamente habilitado | |
| if settings.jwt_legacy_enabled and self._legacy_secret: | |
| try: | |
| data = jwt.decode( | |
| token, | |
| self._legacy_secret, | |
| algorithms=[self._legacy_algorithm], | |
| audience=self.token_audience, | |
| ) | |
| user_id = data.get("sub") | |
| if user_id is None: | |
| return None | |
| parsed_id = user_manager.parse_id(user_id) | |
| return await user_manager.get(parsed_id) | |
| except jwt.PyJWTError: | |
| pass | |
| # Si llegamos aquí, token inválido | |
| return None | |
| async def destroy_token(self, token: str, user) -> None: | |
| """JWT es stateless, no hay nada que destruir server-side.""" | |
| pass | |
| def get_jwt_strategy() -> DualJWTStrategy: | |
| return DualJWTStrategy() | |
| # Dual auth backend: soporta AMBOS transports (header Authorization + cookie) | |
| auth_backend = AuthenticationBackend( | |
| name="jwt", | |
| transport=bearer_transport, # primary para compatibilidad | |
| get_strategy=get_jwt_strategy, | |
| ) | |
| # Segundo backend solo para cookies (se usa en login para setear cookie) | |
| cookie_auth_backend = AuthenticationBackend( | |
| name="jwt-cookie", | |
| transport=cookie_transport, | |
| get_strategy=get_jwt_strategy, | |
| ) |