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 @router.post("/forgot-password") 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.", } @router.post("/reset-password") 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) @router.post("/jwt/login") 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 @router.post("/jwt/refresh") 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 @router.post("/jwt/logout") 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) ─── @router.get("/.well-known/jwks.json", tags=["auth"]) 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 @router.post("/mfa/setup", dependencies=[Depends(current_active_user)]) 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." } @router.post("/mfa/verify", dependencies=[Depends(current_active_user)]) 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." } @router.post("/mfa/disable", dependencies=[Depends(current_active_user)]) 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"} @router.post("/mfa/backup-codes", dependencies=[Depends(current_active_user)]) 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." } @router.get("/mfa/status", dependencies=[Depends(current_active_user)]) 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 @router.post("/mfa/challenge") 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" }