import logging from datetime import timedelta, datetime from typing import Optional from fastapi import APIRouter, Depends, HTTPException, status, Form, BackgroundTasks from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from sqlalchemy.orm import Session from app.database import get_db from app.models.user import User, Device, CreditTransaction from app.schemas.user import UserCreate, UserOut, Token, DeviceRegister, ForgotPasswordRequest, ResetPasswordRequest, ChangePasswordRequest, AvatarUpdate, UsernameUpdate from passlib.context import CryptContext from jose import JWTError, jwt import os from dotenv import load_dotenv from app.utils.notifications import create_admin_notification # ========================================== # CONFIGURACIÓN Y LOGGING # ========================================== load_dotenv() logger = logging.getLogger(__name__) router = APIRouter(prefix="/auth", tags=["auth"]) # ========================================== # HELPER: Encolado proactivo de stems en la nube para nuevo dispositivo # ========================================== def _enqueue_cloud_stems_for_new_device(db: Session, user, new_device_id: str): """ No-op: La sincronización P2P por cola de dispositivos ha sido eliminada. """ pass # ========================================== # CONFIGURACIÓN DE SEGURIDAD # ========================================== SECRET_KEY = os.getenv("SECRET_KEY", "supersecretkey_change_me_in_production") ALGORITHM = os.getenv("ALGORITHM", "HS256") ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "43200")) pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token") # ========================================== # UTILIDADES # ========================================== def verify_password(plain_password, hashed_password): """Verifica si una contraseña coincide con el hash""" try: return pwd_context.verify(plain_password, hashed_password) except Exception as e: logger.warning(f"Error verificando password: {e}") return False def get_password_hash(password): """Genera hash de una contraseña""" return pwd_context.hash(password) def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): """Crea un token JWT con los datos proporcionados""" to_encode = data.copy() if expires_delta: expire = datetime.utcnow() + expires_delta else: expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) to_encode.update({"exp": expire}) encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt SUPABASE_JWT_SECRET = os.getenv("SUPABASE_JWT_SECRET", SECRET_KEY) async def get_current_user( token: str = Depends(oauth2_scheme), db: Session = Depends(get_db) ): """Obtiene el usuario actual validando el token JWT de Supabase o propio""" credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) payload = None try: # Intentar decodificar con SUPABASE_JWT_SECRET primero (para Google/Supabase login) payload = jwt.decode( token, SUPABASE_JWT_SECRET, algorithms=[ALGORITHM], options={"verify_aud": False} ) except JWTError: # Si falla, intentar decodificar con SECRET_KEY (para login local email/password) try: payload = jwt.decode( token, SECRET_KEY, algorithms=[ALGORITHM], options={"verify_aud": False} ) except JWTError as e: logger.warning(f"Token inválido (ambas firmas fallaron): {e}") raise credentials_exception try: # En Supabase Auth, el correo del usuario viene en el campo 'email' email: str = payload.get("email") device_id: Optional[str] = payload.get("device_id") if email is None: # Fallback para tokens antiguos que usaban username en 'sub' username: str = payload.get("sub") if username: user = db.query(User).filter(User.username == username).first() if user: if device_id: device_exists = db.query(Device).filter(Device.user_id == user.id, Device.device_id == device_id).first() if not device_exists: logger.warning(f"Sesión invalidada: Dispositivo {device_id} ya no está registrado para el usuario {user.username}") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Session invalidated. Logged in from another device.", headers={"WWW-Authenticate": "Bearer"}, ) return user logger.warning("Token inválido: no contiene email") raise credentials_exception except JWTError as e: logger.warning(f"Token inválido: {e}") raise credentials_exception # Buscar el usuario en nuestra tabla pública usando el email user = db.query(User).filter(User.email == email).first() if user is None: logger.warning(f"Usuario no encontrado: {email}") raise credentials_exception if getattr(user, "is_suspended", False): logger.warning(f"Intento de acceso de usuario suspendido: {user.username}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="user_suspended" ) # Validar el dispositivo si el token contiene device_id if device_id: device_exists = db.query(Device).filter(Device.user_id == user.id, Device.device_id == device_id).first() if not device_exists: logger.warning(f"Sesión invalidada: Dispositivo {device_id} ya no está registrado para el usuario {user.username}") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Session invalidated. Logged in from another device.", headers={"WWW-Authenticate": "Bearer"}, ) return user # ========================================== # ENDPOINTS # ========================================== @router.post("/register", response_model=UserOut) def register_user(user: UserCreate, db: Session = Depends(get_db)): """ Registra un nuevo usuario. - **username**: Nombre de usuario (único) - **email**: Correo electrónico (único) - **password**: Contraseña (se guarda encriptada) """ logger.info(f"Intento de registro para usuario: {user.username}") # Validar formato de username reg_username = user.username.strip() if not reg_username: raise HTTPException(status_code=400, detail="El nombre de usuario no puede estar vacío") if len(reg_username) < 3 or len(reg_username) > 20: raise HTTPException(status_code=400, detail="El nombre de usuario debe tener entre 3 y 20 caracteres") import re if not re.match(r"^[a-zA-Z0-9.\-\s]+$", reg_username): raise HTTPException(status_code=400, detail="El nombre de usuario solo puede contener letras, números, espacios, puntos y guiones") # Verificar username db_user = db.query(User).filter(User.username == reg_username).first() if db_user: logger.warning(f"Registro fallido: username '{user.username}' ya existe") raise HTTPException(status_code=400, detail="Username already registered") # Verificar email db_email = db.query(User).filter(User.email == user.email).first() if db_email: logger.warning(f"Registro fallido: email '{user.email}' ya existe") raise HTTPException(status_code=400, detail="Email already registered") # Crear usuario hashed_password = get_password_hash(user.password) new_user = User( username=reg_username, email=user.email, hashed_password=hashed_password, credits=10 # Créditos iniciales de prueba ) db.add(new_user) db.commit() db.refresh(new_user) # Registrar transacción inicial de créditos en el historial initial_tx = CreditTransaction( user_id=new_user.id, amount=10, concept="inicial", reference_id=None, created_at=datetime.utcnow() ) db.add(initial_tx) db.commit() logger.info(f"Usuario registrado exitosamente: {user.username} (ID: {new_user.id})") try: create_admin_notification( db, "Nueva Cuenta Creada", f"El usuario '{new_user.username}' ({new_user.email}) se ha registrado." ) except Exception as e: logger.warning(f"Error al registrar notificación para el nuevo usuario: {e}") return new_user @router.post("/token", response_model=Token) async def login_for_access_token( form_data: OAuth2PasswordRequestForm = Depends(), device_id: Optional[str] = Form(None), device_name: Optional[str] = Form(None), confirm_override: bool = Form(False), db: Session = Depends(get_db) ): """ Inicia sesión y obtiene un token de acceso. - **username**: Nombre de usuario - **password**: Contraseña - **device_id**: Identificador único del dispositivo (opcional) - **device_name**: Nombre amigable del dispositivo (opcional) """ logger.info(f"Intento de login para usuario: {form_data.username} desde dispositivo: {device_id}") # Verificar usuario usando el email (aunque el campo del formulario OAuth2 se llame 'username') user = db.query(User).filter(User.email == form_data.username).first() if not user: logger.warning(f"Login fallido: usuario con email '{form_data.username}' no existe") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password", headers={"WWW-Authenticate": "Bearer"}, ) if getattr(user, "is_suspended", False): logger.warning(f"Login fallido: usuario suspendido email '{form_data.username}'") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Cuenta suspendida. Para más información contacta a soporte." ) if not verify_password(form_data.password, user.hashed_password): logger.warning(f"Login fallido: contraseña incorrecta para email '{form_data.username}'") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password", headers={"WWW-Authenticate": "Bearer"}, ) # ========================================== # GESTIÓN DE DISPOSITIVOS # ========================================== if device_id and device_id != "admin_dashboard": device = next((d for d in user.devices if d.device_id == device_id), None) if device: # Actualizar última actividad device.last_active = datetime.utcnow() logger.debug(f"Dispositivo conocido: {device.name}") db.commit() else: # Verificar límite de dispositivos max_devices = 3 if user.is_premium else 1 if len(user.devices) >= max_devices: if not confirm_override: logger.warning(f"Login fallido: límite de dispositivos alcanzado para {form_data.username}") raise HTTPException( status_code=403, detail="device_override_required" ) else: # El usuario confirma anulación: if user.is_premium: # Para premium, eliminamos el dispositivo más antiguo para liberar un espacio oldest_device = db.query(Device).filter(Device.user_id == user.id).order_by(Device.last_active.asc()).first() if oldest_device: logger.info(f"Eliminando dispositivo antiguo {oldest_device.name} para usuario premium {user.username}") db.delete(oldest_device) else: # Para no premium, eliminamos todos db.query(Device).filter(Device.user_id == user.id).delete() db.commit() # Recargar relaciones db.refresh(user) logger.info(f"Anulando sesión anterior para {user.username} (confirm_override=True)") # Registrar nuevo dispositivo new_device = Device( user_id=user.id, device_id=device_id, name=device_name or "Desconocido" ) db.add(new_device) logger.info(f"Nuevo dispositivo registrado: {new_device.name} ({device_id})") db.commit() # Encolar proactivamente stems en la nube para descarga automática en el nuevo dispositivo _enqueue_cloud_stems_for_new_device(db, user, device_id) # ========================================== # GENERAR TOKEN # ========================================== access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) token_payload = {"sub": user.username, "email": user.email} if device_id and device_id != "admin_dashboard": token_payload["device_id"] = device_id access_token = create_access_token( data=token_payload, expires_delta=access_token_expires ) logger.info(f"Login exitoso: {user.email}") return { "access_token": access_token, "token_type": "bearer", "expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60, "user": { "id": user.id, "username": user.username, "credits": user.credits, "is_premium": user.is_premium, "avatar_url": user.avatar_url, "username_changes_this_month": user.username_changes_this_month, "last_username_change_at": user.last_username_change_at, "is_forum_banned": user.is_forum_banned } } @router.get("/users/me", response_model=UserOut) async def read_users_me( db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Obtiene la información del usuario autenticado""" logger.debug(f"Usuario {current_user.username} consultando perfil") now = datetime.utcnow() if current_user.last_username_change_at: last_change = current_user.last_username_change_at if (now.year > last_change.year) or (now.year == last_change.year and now.month > last_change.month): current_user.username_changes_this_month = 0 db.commit() return current_user @router.post("/logout") async def logout( device_id: str = Form(...), db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """ Cierra sesión eliminando el dispositivo registrado. Útil cuando el usuario quiere revocar acceso a un dispositivo. """ logger.info(f"Logout solicitado por {current_user.username} para dispositivo: {device_id}") device = next((d for d in current_user.devices if d.device_id == device_id), None) if device: db.delete(device) db.commit() logger.info(f"Dispositivo {device_id} eliminado para usuario {current_user.username}") return {"message": "Logout exitoso. Dispositivo eliminado."} else: logger.warning(f"Dispositivo {device_id} no encontrado para {current_user.username}") raise HTTPException(status_code=404, detail="Device not found") @router.get("/users/devices") async def list_devices( db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Lista todos los dispositivos registrados del usuario""" logger.info(f"Usuario {current_user.username} consultando dispositivos") devices = [ { "id": d.id, "device_id": d.device_id, "name": d.name, "registered_at": d.registered_at.isoformat() if d.registered_at else None, "last_active": d.last_active.isoformat() if d.last_active else None } for d in current_user.devices ] return { "total": len(devices), "devices": devices } @router.post("/register-device") async def register_device( device_data: DeviceRegister, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """ Registra un dispositivo en segundo plano para un usuario ya autenticado (por ejemplo, con Google). """ logger.info(f"Registro de dispositivo solicitado por {current_user.username} (ID: {current_user.id})") device = next((d for d in current_user.devices if d.device_id == device_data.device_id), None) if device: device.last_active = datetime.utcnow() logger.debug(f"Dispositivo conocido: {device.name}") db.commit() else: # Verificar límite de dispositivos max_devices = 3 if current_user.is_premium else 1 if len(current_user.devices) >= max_devices: if not device_data.confirm_override: logger.warning(f"Límite de dispositivos alcanzado para {current_user.username}") raise HTTPException( status_code=403, detail="device_override_required" ) else: if current_user.is_premium: # Para premium, eliminamos el dispositivo más antiguo para liberar un espacio oldest_device = db.query(Device).filter(Device.user_id == current_user.id).order_by(Device.last_active.asc()).first() if oldest_device: logger.info(f"Eliminando dispositivo antiguo {oldest_device.name} para usuario premium {current_user.username}") db.delete(oldest_device) else: # Para no premium, eliminamos todos db.query(Device).filter(Device.user_id == current_user.id).delete() db.commit() # Recargar relaciones db.refresh(current_user) logger.info(f"Anulando sesión anterior para {current_user.username} en registro de dispositivo") new_device = Device( user_id=current_user.id, device_id=device_data.device_id, name=device_data.device_name ) db.add(new_device) logger.info(f"Nuevo dispositivo registrado: {device_data.device_name} ({device_data.device_id})") db.commit() # Encolar proactivamente stems en la nube para descarga automática en el nuevo dispositivo _enqueue_cloud_stems_for_new_device(db, current_user, device_data.device_id) return {"message": "Dispositivo registrado exitosamente"} @router.post("/forgot-password") def forgot_password( data: ForgotPasswordRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db) ): import random user = db.query(User).filter(User.email == data.email).first() if not user: raise HTTPException( status_code=404, detail="No se encontró ninguna cuenta registrada con ese correo electrónico" ) code = f"{random.randint(100000, 999999)}" user.reset_token = code user.reset_token_expires = datetime.utcnow() + timedelta(minutes=15) db.commit() from app.utils.email import send_recovery_email background_tasks.add_task(send_recovery_email, user.email, code) return {"message": "Código de verificación enviado exitosamente a tu correo electrónico"} @router.post("/reset-password") def reset_password( data: ResetPasswordRequest, db: Session = Depends(get_db) ): user = db.query(User).filter(User.email == data.email).first() if not user: raise HTTPException(status_code=404, detail="Usuario no encontrado") if not user.reset_token or user.reset_token != data.code: raise HTTPException(status_code=400, detail="Código de verificación incorrecto") if not user.reset_token_expires or user.reset_token_expires < datetime.utcnow(): raise HTTPException(status_code=400, detail="El código de verificación ha expirado") user.hashed_password = get_password_hash(data.password) user.reset_token = None user.reset_token_expires = None db.commit() return {"message": "Contraseña restablecida exitosamente"} @router.post("/change-password") def change_user_password( data: ChangePasswordRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): if not current_user.hashed_password: raise HTTPException( status_code=400, detail="Esta cuenta está vinculada a un proveedor externo (Google/Supabase) y no tiene una contraseña local" ) if not verify_password(data.old_password, current_user.hashed_password): raise HTTPException(status_code=400, detail="La contraseña actual es incorrecta") current_user.hashed_password = get_password_hash(data.new_password) db.commit() return {"message": "Contraseña actualizada exitosamente"} @router.put("/users/me/avatar") def update_user_avatar( data: AvatarUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): current_user.avatar_url = data.avatar_url db.commit() return {"avatar_url": current_user.avatar_url} @router.put("/users/me/username") def update_username( data: UsernameUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): new_username = data.username.strip() if not new_username: raise HTTPException(status_code=400, detail="El nombre de usuario no puede estar vacío") if len(new_username) < 3 or len(new_username) > 20: raise HTTPException(status_code=400, detail="El nombre de usuario debe tener entre 3 y 20 caracteres") import re if not re.match(r"^[a-zA-Z0-9.\-\s]+$", new_username): raise HTTPException(status_code=400, detail="El nombre de usuario solo puede contener letras, números, espacios, puntos y guiones") # Excluir caracteres especiales raros si es necesario, pero validemos similitud básica if new_username == current_user.username: return { "username": current_user.username, "username_changes_this_month": current_user.username_changes_this_month, "message": "El nombre de usuario es idéntico" } # Reset del contador si es un nuevo mes now = datetime.utcnow() if current_user.last_username_change_at: last_change = current_user.last_username_change_at if (now.year > last_change.year) or (now.year == last_change.year and now.month > last_change.month): current_user.username_changes_this_month = 0 db.commit() # Verificar límite if current_user.username_changes_this_month >= 3: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Has alcanzado el límite de 3 cambios de nombre de usuario este mes. Podrás cambiarlo nuevamente el 1 del próximo mes." ) # Verificar si ya existe exists = db.query(User).filter(User.username == new_username).first() if exists: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="El nombre de usuario ya está registrado por otra cuenta" ) # Proceder a actualizar current_user.username = new_username current_user.username_changes_this_month += 1 current_user.last_username_change_at = now db.commit() # Notificar al usuario por WebSocket en tiempo real en todos sus dispositivos try: from app.websocket_manager import send_sync_message send_sync_message({ "type": "username_updated", "username": new_username }, current_user.id) except Exception: pass logger.info(f"Usuario {current_user.id} cambió su username a: {new_username}. Cambios este mes: {current_user.username_changes_this_month}/3") return { "username": current_user.username, "username_changes_this_month": current_user.username_changes_this_month, "last_username_change_at": current_user.last_username_change_at } @router.get("/test-smtp") def test_smtp(to: str): """ Endpoint de diagnóstico para probar el envío de correos SMTP y ver el error de configuración o conexión. """ from app.utils.email import send_recovery_email import os # Obtener valores para mostrarlos (con password censurado) smtp_host = os.getenv("SMTP_HOST") smtp_port = os.getenv("SMTP_PORT") smtp_user = os.getenv("SMTP_USER") smtp_password = os.getenv("SMTP_PASSWORD") smtp_from = os.getenv("SMTP_FROM") # Limpiar comillas iniciales/finales y espacios if smtp_host: smtp_host = smtp_host.strip("'\" ") if smtp_port: smtp_port = smtp_port.strip("'\" ") if smtp_user: smtp_user = smtp_user.strip("'\" ") if smtp_password: smtp_password = smtp_password.strip("'\" ") if smtp_from: smtp_from = smtp_from.strip("'\" ") config_info = { "SMTP_HOST": smtp_host, "SMTP_PORT": smtp_port, "SMTP_USER": smtp_user, "SMTP_FROM": smtp_from or f"Melodix <{smtp_user}>", "SMTP_PASSWORD_SET": smtp_password is not None and len(smtp_password) > 0 } # Intentar enviar try: success = send_recovery_email(to, "999999", raise_on_error=True) if success: return { "status": "success", "message": f"Correo de prueba enviado con éxito a {to}", "config": config_info } else: return { "status": "failed", "message": "El método send_recovery_email retornó False. Revisa los logs de tu servidor en Render para ver el error exacto.", "config": config_info } except Exception as e: import traceback error_trace = traceback.format_exc() return { "status": "error", "message": str(e), "traceback": error_trace, "config": config_info }