Spaces:
Paused
Paused
| import uuid | |
| import json | |
| import secrets | |
| from typing import Optional | |
| from datetime import datetime | |
| from fastapi import APIRouter, Depends, HTTPException, Response, Request, Cookie | |
| from fastapi_users import FastAPIUsers, exceptions as fu_exceptions | |
| from fastapi_users.jwt import generate_jwt | |
| from app.auth.models import User, LoginHistory | |
| from app.auth.manager import get_user_manager, UserManager | |
| from app.auth.config import auth_backend, cookie_auth_backend | |
| from app.auth.schemas import UserRead, UserCreate, UserUpdate | |
| from app.database import get_db | |
| from sqlalchemy.ext.asyncio import AsyncSession | |
| from pydantic import BaseModel | |
| import traceback | |
| import pyotp | |
| import qrcode | |
| import io | |
| import base64 | |
| from app.config import get_settings | |
| from app.utils.security import mask_email | |
| from app.auth.models import User as UserModel | |
| settings = get_settings() | |
| # Incluir AMBOS backends para dual auth (header + cookie) | |
| fastapi_users = FastAPIUsers[User, uuid.UUID]( | |
| get_user_manager, | |
| [auth_backend, cookie_auth_backend], | |
| ) | |
| current_active_user = fastapi_users.current_user(active=True) | |
| router = APIRouter(prefix="/auth", tags=["auth"]) | |
| # Auth router con AMBOS backends | |
| router.include_router( | |
| fastapi_users.get_auth_router(auth_backend, requires_verification=False), | |
| prefix="/jwt", | |
| ) | |
| router.include_router( | |
| fastapi_users.get_auth_router(cookie_auth_backend, requires_verification=False), | |
| prefix="/jwt", | |
| ) | |
| router.include_router( | |
| fastapi_users.get_register_router(UserRead, UserCreate), | |
| ) | |
| router.include_router( | |
| fastapi_users.get_users_router(UserRead, UserUpdate), | |
| prefix="/users", | |
| ) | |
| class ForgotPasswordRequest(BaseModel): | |
| email: str | |
| class ResetPasswordRequest(BaseModel): | |
| token: str | |
| password: str | |
| async def forgot_password( | |
| body: ForgotPasswordRequest, | |
| manager: UserManager = Depends(get_user_manager), | |
| ): | |
| try: | |
| user = await manager.get_by_email(body.email) | |
| except fu_exceptions.UserNotExists: | |
| return {"message": "Si el email está registrado, se envió un enlace de recuperación."} | |
| token_data = { | |
| "sub": str(user.id), | |
| "password_fgpt": manager.password_helper.hash(user.hashed_password), | |
| "aud": manager.reset_password_token_audience, | |
| } | |
| token = generate_jwt( | |
| token_data, | |
| manager.reset_password_token_secret, | |
| manager.reset_password_token_lifetime_seconds, | |
| ) | |
| await manager.on_after_forgot_password(user, token, None) | |
| return { | |
| "message": "Si el email está registrado, se envió un enlace de recuperación.", | |
| } | |
| async def reset_password( | |
| body: ResetPasswordRequest, | |
| manager: UserManager = Depends(get_user_manager), | |
| ): | |
| try: | |
| user = await manager.reset_password(body.token, body.password) | |
| except fu_exceptions.InvalidResetPasswordToken: | |
| raise HTTPException(status_code=400, detail="Token inválido o expirado.") | |
| except fu_exceptions.UserInactive: | |
| raise HTTPException(status_code=400, detail="Usuario inactivo.") | |
| await manager.on_after_reset_password(user, None) | |
| return {"message": "Contraseña actualizada correctamente."} | |
| # Custom login que setea cookie HttpOnly Y devuelve token en body (compatibilidad) | |
| async def jwt_login( | |
| request: Request, | |
| response: Response, | |
| manager: UserManager = Depends(get_user_manager), | |
| ): | |
| try: | |
| # Parse form data (OAuth2 password flow) | |
| form = await request.form() | |
| username = form.get("username") | |
| password = form.get("password") | |
| if not username or not password: | |
| raise HTTPException(status_code=400, detail="username y password requeridos") | |
| # Autenticar usuario usando OAuth2PasswordRequestForm | |
| from fastapi.security import OAuth2PasswordRequestForm | |
| credentials = OAuth2PasswordRequestForm(username=username, password=password) | |
| user = await manager.authenticate(credentials) | |
| if not user: | |
| raise HTTPException(status_code=400, detail="Credenciales inválidas") | |
| if not user.is_active: | |
| raise HTTPException(status_code=400, detail="Usuario inactivo") | |
| # Si MFA está habilitado, requerir desafío TOTP/backup code | |
| if user.mfa_enabled: | |
| # Generar token temporal de pre-autenticación (válido 5 min) | |
| from fastapi_users.jwt import generate_jwt | |
| from app.config import get_settings | |
| settings = get_settings() | |
| mfa_token_data = { | |
| "sub": str(user.id), | |
| "mfa_pending": True, | |
| "aud": "mfa-challenge", | |
| } | |
| mfa_token = generate_jwt( | |
| mfa_token_data, | |
| settings.secret_key, | |
| 300, # 5 minutos | |
| ) | |
| return { | |
| "mfa_required": True, | |
| "mfa_token": mfa_token, | |
| "message": "Introduce tu código TOTP o código de respaldo", | |
| } | |
| # Generar access token | |
| jwt_strategy = auth_backend.get_strategy() | |
| access_token = await jwt_strategy.write_token(user) | |
| # Crear refresh token (rotación) | |
| from app.database import get_db | |
| async for db in get_db(): | |
| refresh_token = await manager.create_refresh_token(user, request, db) | |
| # Setear cookie HttpOnly para access token | |
| from app.config import get_settings | |
| settings = get_settings() | |
| cookie_max_age = settings.access_token_expire_minutes * 60 | |
| response.set_cookie( | |
| key="cd_token", | |
| value=access_token, | |
| max_age=cookie_max_age, | |
| httponly=True, | |
| secure=not settings.debug, | |
| samesite="lax", | |
| path="/", | |
| ) | |
| # Setear cookie HttpOnly para refresh token | |
| refresh_max_age = settings.refresh_token_expire_days * 24 * 60 * 60 | |
| response.set_cookie( | |
| key="cd_refresh_token", | |
| value=refresh_token, | |
| max_age=refresh_max_age, | |
| httponly=True, | |
| secure=not settings.debug, | |
| samesite="lax", | |
| path="/", | |
| ) | |
| # También setear cookie de expiración para que el frontend pueda leerla | |
| import time | |
| response.set_cookie( | |
| key="cd_token_expiry", | |
| value=str(int(time.time() * 1000) + cookie_max_age * 1000), | |
| max_age=cookie_max_age, | |
| httponly=True, | |
| secure=not settings.debug, | |
| samesite="lax", | |
| path="/", | |
| ) | |
| # Devolver token en body para compatibilidad con clientes existentes | |
| return { | |
| "access_token": access_token, | |
| "token_type": "bearer", | |
| } | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| import logging | |
| logger = logging.getLogger("crowdata.auth") | |
| logger.error(f"Error en jwt_login: {e}", exc_info=True) | |
| raise HTTPException(status_code=500, detail=f"Error interno: {str(e)}") | |
| # Refresh token endpoint - rota access token + nuevo refresh token | |
| async def jwt_refresh( | |
| request: Request, | |
| response: Response, | |
| cd_refresh_token: Optional[str] = Cookie(None, alias="cd_refresh_token"), | |
| manager: UserManager = Depends(get_user_manager), | |
| ): | |
| if not cd_refresh_token: | |
| raise HTTPException(status_code=401, detail="Refresh token requerido") | |
| # Verificar refresh token | |
| from app.database import get_db | |
| async for db in get_db(): | |
| user = await manager.verify_refresh_token(cd_refresh_token, db) | |
| if not user: | |
| raise HTTPException(status_code=401, detail="Refresh token inválido o expirado") | |
| # Generar nuevo access token | |
| jwt_strategy = auth_backend.get_strategy() | |
| access_token = await jwt_strategy.write_token(user) | |
| # Rotar refresh token (revocar viejo, crear nuevo) | |
| from app.database import get_db | |
| async for db in get_db(): | |
| new_refresh_token = await manager.create_refresh_token(user, request, db) | |
| # Setear cookies | |
| from app.config import get_settings | |
| settings = get_settings() | |
| cookie_max_age = settings.access_token_expire_minutes * 60 | |
| refresh_max_age = settings.refresh_token_expire_days * 24 * 60 * 60 | |
| response.set_cookie( | |
| key="cd_token", | |
| value=access_token, | |
| max_age=cookie_max_age, | |
| httponly=True, | |
| secure=not settings.debug, | |
| samesite="lax", | |
| path="/", | |
| ) | |
| response.set_cookie( | |
| key="cd_refresh_token", | |
| value=new_refresh_token, | |
| max_age=refresh_max_age, | |
| httponly=True, | |
| secure=not settings.debug, | |
| samesite="lax", | |
| path="/", | |
| ) | |
| import time | |
| response.set_cookie( | |
| key="cd_token_expiry", | |
| value=str(int(time.time() * 1000) + cookie_max_age * 1000), | |
| max_age=cookie_max_age, | |
| httponly=True, | |
| secure=not settings.debug, | |
| samesite="lax", | |
| path="/", | |
| ) | |
| return { | |
| "access_token": access_token, | |
| "token_type": "bearer", | |
| } | |
| # Logout: limpiar cookies | |
| async def jwt_logout( | |
| response: Response, | |
| cd_refresh_token: Optional[str] = Cookie(None, alias="cd_refresh_token"), | |
| manager: UserManager = Depends(get_user_manager), | |
| ): | |
| # Revocar refresh token en BD | |
| if cd_refresh_token: | |
| from app.database import get_db | |
| async for db in get_db(): | |
| await manager.revoke_refresh_token(cd_refresh_token, db) | |
| response.delete_cookie("cd_token", path="/", httponly=True, secure=True, samesite="lax") | |
| response.delete_cookie("cd_refresh_token", path="/", httponly=True, secure=True, samesite="lax") | |
| response.delete_cookie("cd_token_expiry", path="/", httponly=True, secure=True, samesite="lax") | |
| return {"message": "Sesión cerrada"} | |
| # ─── JWKS Endpoint para claves públicas (RFC 7517) ─── | |
| async def jwks(): | |
| """JSON Web Key Set - expone claves públicas para verificación RS256.""" | |
| from app.config import get_settings | |
| settings = get_settings() | |
| if not 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: | |
| public_key_pem = f.read() | |
| else: | |
| raise HTTPException(status_code=503, detail="Clave pública no configurada") | |
| else: | |
| public_key_pem = settings.jwt_public_key | |
| # Convertir PEM a JWK | |
| from jwt.algorithms import RSAAlgorithm | |
| public_key = RSAAlgorithm.from_jwk(public_key_pem) | |
| numbers = public_key.public_numbers() | |
| # Base64url encode sin padding | |
| import base64 | |
| def b64url_encode(data: bytes) -> str: | |
| return base64.urlsafe_b64encode(data).decode().rstrip('=') | |
| n = b64url_encode(numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, 'big')) | |
| e = b64url_encode(numbers.e.to_bytes((numbers.e.bit_length() + 7) // 8, 'big')) | |
| return { | |
| "keys": [ | |
| { | |
| "kty": "RSA", | |
| "use": "sig", | |
| "alg": "RS256", | |
| "kid": settings.jwt_key_id, | |
| "n": n, | |
| "e": e, | |
| } | |
| ] | |
| } | |
| # ════════════════════════════════════════════════════════════════════════════ | |
| # MFA / 2FA (TOTP) Endpoints | |
| # ════════════════════════════════════════════════════════════════════════════ | |
| class MFASetupRequest(BaseModel): | |
| password: str # Confirmar contraseña actual | |
| class MFAVerifyRequest(BaseModel): | |
| code: str # Código TOTP de 6 dígitos | |
| class MFADisableRequest(BaseModel): | |
| password: str | |
| code: str | |
| async def mfa_setup( | |
| response: Response, | |
| request: Request, | |
| body: MFASetupRequest, | |
| user: User = Depends(current_active_user), | |
| manager: UserManager = Depends(get_user_manager), | |
| ): | |
| """ | |
| Iniciar configuración de MFA. | |
| Genera secreto TOTP y devuelve QR code (base64) + secret para app autenticadora. | |
| Requiere contraseña actual para confirmar identidad. | |
| """ | |
| # Verificar contraseña | |
| if not manager.password_helper.verify(body.password, user.hashed_password): | |
| raise HTTPException(status_code=400, detail="Contraseña incorrecta") | |
| if user.mfa_enabled: | |
| raise HTTPException(status_code=400, detail="MFA ya está habilitado") | |
| import pyotp | |
| import qrcode | |
| import io | |
| import base64 | |
| # Generar secreto único | |
| secret = pyotp.random_base32() | |
| # Generar URI para QR code (compatible con Google Authenticator, Authy, etc.) | |
| totp_uri = pyotp.totp.TOTP(secret).provisioning_uri( | |
| name=user.email, | |
| issuer_name="CrowData", | |
| ) | |
| # Generar QR code como base64 | |
| qr = qrcode.QRCode(version=1, box_size=10, border=5) | |
| qr.add_data(totp_uri) | |
| qr.make(fit=True) | |
| img = qr.make_image(fill_color="black", back_color="white") | |
| buf = io.BytesIO() | |
| img.save(buf, format='PNG') | |
| qr_base64 = base64.b64encode(buf.getvalue()).decode() | |
| # Guardar secreto temporal (no activar hasta verificar) - using encrypted property | |
| user.mfa_secret_decrypted = secret | |
| user.mfa_backup_codes_decrypted = [] | |
| from app.database import get_db | |
| async for db in get_db(): | |
| await db.commit() | |
| return { | |
| "secret": secret, | |
| "qr_code": f"data:image/png;base64,{qr_base64}", | |
| "uri": totp_uri, | |
| "message": "Escanea el QR con tu app autenticadora (Google Authenticator, Authy, 1Password, etc.) y luego usa /mfa/verify para activar." | |
| } | |
| async def mfa_verify( | |
| body: MFAVerifyRequest, | |
| user: User = Depends(current_active_user), | |
| manager: UserManager = Depends(get_user_manager), | |
| ): | |
| """ | |
| Verificar código TOTP y activar MFA. | |
| Genera códigos de respaldo (backup codes) al activar. | |
| """ | |
| if not user.mfa_secret_decrypted: | |
| raise HTTPException(status_code=400, detail="MFA no iniciado. Usa /mfa/setup primero.") | |
| if user.mfa_enabled: | |
| raise HTTPException(status_code=400, detail="MFA ya está habilitado") | |
| import pyotp | |
| totp = pyotp.TOTP(user.mfa_secret_decrypted) | |
| if not totp.verify(body.code, valid_window=1): | |
| raise HTTPException(status_code=400, detail="Código inválido o expirado") | |
| # Generar backup codes (8 códigos de 8 chars cada uno) | |
| import secrets | |
| backup_codes = [secrets.token_urlsafe(6) for _ in range(8)] | |
| user.mfa_enabled = True | |
| user.mfa_backup_codes_decrypted = backup_codes | |
| user.mfa_verified_at = datetime.utcnow() | |
| from app.database import get_db | |
| async for db in get_db(): | |
| await db.commit() | |
| return { | |
| "message": "MFA activado correctamente", | |
| "backup_codes": backup_codes, | |
| "warning": "Guarda estos códigos de respaldo en un lugar seguro. Cada uno se puede usar una sola vez." | |
| } | |
| async def mfa_disable( | |
| body: MFADisableRequest, | |
| user: User = Depends(current_active_user), | |
| manager: UserManager = Depends(get_user_manager), | |
| ): | |
| """ | |
| Desactivar MFA. | |
| Requiere contraseña + código TOTP actual (o backup code). | |
| """ | |
| if not user.mfa_enabled: | |
| raise HTTPException(status_code=400, detail="MFA no está habilitado") | |
| # Verificar contraseña | |
| if not manager.password_helper.verify(body.password, user.hashed_password): | |
| raise HTTPException(status_code=400, detail="Contraseña incorrecta") | |
| # Verificar código (TOTP o backup code) | |
| import pyotp | |
| valid = False | |
| totp = pyotp.TOTP(user.mfa_secret_decrypted) | |
| if totp.verify(body.code, valid_window=1): | |
| valid = True | |
| elif body.code in user.mfa_backup_codes_decrypted: | |
| # Es un backup code - consumirlo | |
| codes = user.mfa_backup_codes_decrypted | |
| codes.remove(body.code) | |
| user.mfa_backup_codes_decrypted = codes | |
| valid = True | |
| if not valid: | |
| raise HTTPException(status_code=400, detail="Código inválido") | |
| # Desactivar MFA | |
| user.mfa_enabled = False | |
| user.mfa_secret_decrypted = None | |
| user.mfa_backup_codes_decrypted = [] | |
| user.mfa_verified_at = None | |
| from app.database import get_db | |
| async for db in get_db(): | |
| await db.commit() | |
| return {"message": "MFA desactivado correctamente"} | |
| async def mfa_regenerate_backup_codes( | |
| user: User = Depends(current_active_user), | |
| manager: UserManager = Depends(get_user_manager), | |
| ): | |
| """ | |
| Regenerar códigos de respaldo (invalida los anteriores). | |
| """ | |
| if not user.mfa_enabled: | |
| raise HTTPException(status_code=400, detail="MFA no está habilitado") | |
| import secrets | |
| backup_codes = [secrets.token_urlsafe(6) for _ in range(8)] | |
| user.mfa_backup_codes_decrypted = backup_codes | |
| from app.database import get_db | |
| async for db in get_db(): | |
| await db.commit() | |
| return { | |
| "backup_codes": backup_codes, | |
| "warning": "Los códigos anteriores han sido invalidados. Guarda los nuevos en un lugar seguro." | |
| } | |
| async def mfa_status(user: User = Depends(current_active_user)): | |
| """Estado actual de MFA del usuario.""" | |
| return { | |
| "mfa_enabled": user.mfa_enabled, | |
| "mfa_verified_at": user.mfa_verified_at, | |
| "backup_codes_remaining": len(user.mfa_backup_codes_decrypted), | |
| } | |
| class MFAChallengeRequest(BaseModel): | |
| mfa_token: str # Token temporal del login inicial | |
| code: str # Código TOTP o backup code | |
| async def mfa_challenge( | |
| body: MFAChallengeRequest, | |
| response: Response, | |
| manager: UserManager = Depends(get_user_manager), | |
| ): | |
| """ | |
| Verificar código TOTP/backup code durante login con MFA habilitado. | |
| Recibe el token temporal (mfa_token) del login inicial + código. | |
| Si es válido, setea cookies y devuelve access_token. | |
| """ | |
| # Verificar token temporal MFA | |
| from app.config import get_settings | |
| settings = get_settings() | |
| try: | |
| import jwt as pyjwt | |
| payload = pyjwt.decode( | |
| body.mfa_token, | |
| settings.secret_key, | |
| algorithms=["HS256"], | |
| audience="mfa-challenge", | |
| ) | |
| except pyjwt.InvalidTokenError: | |
| raise HTTPException(status_code=401, detail="Token MFA inválido o expirado") | |
| if not payload.get("mfa_pending"): | |
| raise HTTPException(status_code=401, detail="Token MFA inválido") | |
| user_id = payload.get("sub") | |
| if not user_id: | |
| raise HTTPException(status_code=401, detail="Token MFA inválido") | |
| import uuid | |
| user = await manager.get(uuid.UUID(user_id)) | |
| if not user or not user.is_active: | |
| raise HTTPException(status_code=401, detail="Usuario no encontrado o inactivo") | |
| if not user.mfa_enabled: | |
| raise HTTPException(status_code=400, detail="MFA no está habilitado para este usuario") | |
| # Verificar código TOTP | |
| import pyotp | |
| totp = pyotp.TOTP(user.mfa_secret_decrypted) | |
| code_valid = False | |
| if totp.verify(body.code, valid_window=1): | |
| code_valid = True | |
| elif body.code in user.mfa_backup_codes_decrypted: | |
| # Es un backup code - consumirlo | |
| codes = user.mfa_backup_codes_decrypted | |
| codes.remove(body.code) | |
| user.mfa_backup_codes_decrypted = codes | |
| code_valid = True | |
| if not code_valid: | |
| raise HTTPException(status_code=400, detail="Código inválido o expirado") | |
| # Código válido - generar tokens normales | |
| jwt_strategy = auth_backend.get_strategy() | |
| access_token = await jwt_strategy.write_token(user) | |
| # Crear refresh token | |
| from app.database import get_db | |
| from starlette.requests import Request | |
| async for db in get_db(): | |
| refresh_token = await manager.create_refresh_token(user, Request({"type": "http"}), db) | |
| # Setear cookies HttpOnly | |
| cookie_max_age = settings.access_token_expire_minutes * 60 | |
| refresh_max_age = settings.refresh_token_expire_days * 24 * 60 * 60 | |
| response.set_cookie( | |
| key="cd_token", | |
| value=access_token, | |
| max_age=cookie_max_age, | |
| httponly=True, | |
| secure=not settings.debug, | |
| samesite="lax", | |
| path="/", | |
| ) | |
| response.set_cookie( | |
| key="cd_refresh_token", | |
| value=refresh_token, | |
| max_age=refresh_max_age, | |
| httponly=True, | |
| secure=not settings.debug, | |
| samesite="lax", | |
| path="/", | |
| ) | |
| import time | |
| response.set_cookie( | |
| key="cd_token_expiry", | |
| value=str(int(time.time() * 1000) + cookie_max_age * 1000), | |
| max_age=cookie_max_age, | |
| httponly=True, | |
| secure=not settings.debug, | |
| samesite="lax", | |
| path="/", | |
| ) | |
| return { | |
| "access_token": access_token, | |
| "token_type": "bearer", | |
| "message": "Autenticación MFA completada" | |
| } |