diff --git a/backend/app/api/schemas/auth.py b/backend/app/api/schemas/auth.py index 1e18baa..110ca81 100644 --- a/backend/app/api/schemas/auth.py +++ b/backend/app/api/schemas/auth.py @@ -1,7 +1,7 @@ """ Auth Schemas - ServiceManagerWeb -Pydantic schemas para autenticación y autorización. +Pydantic schemas para autenticación y autorización. """ from pydantic import BaseModel, EmailStr @@ -12,7 +12,7 @@ class LoginRequest(BaseModel): """Schema para solicitud de login.""" email: EmailStr password: str - tenant_slug: str + tenant_slug: Optional[str] = None totp_code: Optional[str] = None @@ -53,28 +53,28 @@ class TwoFactorSetupResponse(BaseModel): class TwoFactorEnableRequest(BaseModel): - """Código TOTP para confirmar y activar 2FA.""" + """Código TOTP para confirmar y activar 2FA.""" totp_code: str class TwoFactorEnableResponse(BaseModel): - """Resultado al habilitar 2FA: incluye los códigos de respaldo.""" + """Resultado al habilitar 2FA: incluye los códigos de respaldo.""" enabled: bool backup_codes: List[str] class TwoFactorDisableRequest(BaseModel): - """Deshabilitar 2FA verificando con TOTP o código de respaldo.""" + """Deshabilitar 2FA verificando con TOTP o código de respaldo.""" totp_code: Optional[str] = None backup_code: Optional[str] = None # ============================================================ -# Cambio de contraseña +# Cambio de contraseña # ============================================================ class ChangePasswordRequest(BaseModel): - """Schema para cambio de contraseña del usuario autenticado.""" + """Schema para cambio de contraseña del usuario autenticado.""" current_password: str new_password: str @@ -82,15 +82,15 @@ class ChangePasswordRequest(BaseModel): # ============================================================ -# Recuperación de contraseña +# Recuperación de contraseña # ============================================================ class ForgotPasswordRequest(BaseModel): - """Solicitar enlace de reseteo de contraseña por email.""" + """Solicitar enlace de reseteo de contraseña por email.""" email: EmailStr class ResetPasswordRequest(BaseModel): - """Aplicar nueva contraseña usando token de reseteo.""" + """Aplicar nueva contraseña usando token de reseteo.""" token: str new_password: str diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index fc6b33b..a35db67 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -1,7 +1,7 @@ """ Authentication Endpoints - ServiceManagerWeb -Endpoints para autenticación y autorización +Endpoints para autenticación y autorización """ from fastapi import APIRouter, HTTPException, status, Depends, Request, Response @@ -28,7 +28,7 @@ CLIENT_ROLES = {"CLIENT_ADMIN", "CLIENT_USER"} def _cookie_name_for_role(role: str) -> str: - """Devuelve el nombre de cookie según el rol del usuario.""" + """Devuelve el nombre de cookie según el rol del usuario.""" return "client_access_token" if role in CLIENT_ROLES else "internal_access_token" from app.api.schemas.auth import ( LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse, @@ -88,25 +88,23 @@ async def login( headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)}, ) - # 1. Validar tenant - tenant_result = await db.execute( - select(Tenant).where(Tenant.slug == login_data.tenant_slug) - ) - tenant = tenant_result.scalar_one_or_none() - if tenant is None: - logger.warning( - "Login failed - tenant not found", - email=login_data.email, - tenant_slug=login_data.tenant_slug, - ) - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Tenant not found", + # 1. Validar tenant - por slug si viene, sino buscar por email + if login_data.tenant_slug: + tenant_result = await db.execute( + select(Tenant).where(Tenant.slug == login_data.tenant_slug) ) + tenant = tenant_result.scalar_one_or_none() + if tenant is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tenant not found", + ) + else: + tenant = None # Rate limiting (best-effort): by (tenant,email) to slow brute force. ident_key = None - if settings.RATE_LIMIT_ENABLED and not settings.TESTING: + if settings.RATE_LIMIT_ENABLED and not settings.TESTING and tenant: email_norm = login_data.email.strip().lower() ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm) ident_count = await cache.incr(ident_key, 1) @@ -142,22 +140,25 @@ async def login( headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)}, ) - # 2. Buscar usuario en base de datos (aislado por tenant) - query = select(User).where( - User.email == login_data.email, - User.tenant_id == tenant.id, - ) + # 2. Buscar usuario - filtrar por tenant si se detecto, sino buscar por email + if tenant: + query = select(User).where( + User.email == login_data.email, + User.tenant_id == tenant.id, + ) + else: + query = select(User).where(User.email == login_data.email) result = await db.execute(query) user = result.scalar_one_or_none() - # 3. Verificar usuario y contraseña + # 3. Verificar usuario y contraseña if not user or not security.verify_password(login_data.password, user.password_hash): logger.warning( "Login failed - invalid credentials", email=login_data.email ) - # Registrar intento fallido en auditoría (si el usuario existe) + # Registrar intento fallido en auditorÃa (si el usuario existe) if user: try: await AuditService.log( @@ -178,7 +179,7 @@ async def login( detail="Invalid credentials", ) - # 4. Verificar si está activo + # 4. Verificar si está activo if not user.is_active: logger.warning( "Login failed - user inactive", @@ -189,19 +190,19 @@ async def login( detail="User inactive", ) - # 5. Verificar 2FA si está habilitado + # 5. Verificar 2FA si está habilitado if user.totp_enabled: if not login_data.totp_code: - # Indicar al frontend que debe pedir el código TOTP + # Indicar al frontend que debe pedir el código TOTP raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Se requiere autenticación de dos factores (2FA). Ingresa tu código." + detail="Se requiere autenticación de dos factores (2FA). Ingresa tu código." ) if not security.verify_totp(user.totp_secret, login_data.totp_code): logger.warning("Login failed - invalid 2FA code", email=login_data.email) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Código 2FA inválido o expirado" + detail="Código 2FA inválido o expirado" ) # Create tokens @@ -233,7 +234,7 @@ async def login( detail="Service temporarily unavailable", ) - # Registrar login exitoso en auditoría + # Registrar login exitoso en auditorÃa try: await AuditService.log( db=db, @@ -392,7 +393,7 @@ async def logout( except Exception as e: logger.warning("Failed to revoke refresh tokens on logout", error=str(e)) - # Registrar logout en auditoría + # Registrar logout en auditorÃa try: import uuid user_id = uuid.UUID(payload["sub"]) @@ -413,7 +414,7 @@ async def logout( logger.info("Logout successful", user_id=payload["sub"]) - # Borrar la cookie correcta según el rol del usuario + # Borrar la cookie correcta según el rol del usuario cookie_name = _cookie_name_for_role(payload.get("role", "")) response.delete_cookie(key=cookie_name) return {"message": "Successfully logged out"} @@ -502,7 +503,7 @@ async def get_2fa_status( current_user: User = Depends(get_current_user), ): """ - Consultar si el 2FA está habilitado para el usuario actual. + Consultar si el 2FA está habilitado para el usuario actual. Returns: Estado de 2FA del usuario autenticado. @@ -516,10 +517,10 @@ async def setup_2fa( db: AsyncSession = Depends(get_db), ): """ - Iniciar configuración de 2FA: genera un nuevo TOTP secret y QR URI. + Iniciar configuración de 2FA: genera un nuevo TOTP secret y QR URI. - El secret se guarda en BD pero 2FA NO se activa todavía. - Se necesita llamar a /2fa/enable con un código válido para activarlo. + El secret se guarda en BD pero 2FA NO se activa todavÃa. + Se necesita llamar a /2fa/enable con un código válido para activarlo. Returns: Secret y QR URI para escanear con la app autenticadora. @@ -527,7 +528,7 @@ async def setup_2fa( new_secret = security.generate_totp_secret() qr_uri = security.generate_totp_uri(new_secret, current_user.email) - # Guardar el secret (sin habilitar aún) + # Guardar el secret (sin habilitar aún) current_user.totp_secret = new_secret await db.commit() @@ -543,29 +544,29 @@ async def enable_2fa( db: AsyncSession = Depends(get_db), ): """ - Activar 2FA verificando que el usuario escaneó correctamente el QR. + Activar 2FA verificando que el usuario escaneó correctamente el QR. Requiere que /2fa/setup haya sido llamado previamente. Args: - data: Código TOTP generado por la app autenticadora. + data: Código TOTP generado por la app autenticadora. Returns: - Confirmación y lista de códigos de respaldo. + Confirmación y lista de códigos de respaldo. """ if not current_user.totp_secret: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Primero inicia el proceso de configuración con /2fa/setup" + detail="Primero inicia el proceso de configuración con /2fa/setup" ) if not security.verify_totp(current_user.totp_secret, data.totp_code): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Código TOTP inválido. Verifica la hora de tu dispositivo e intenta de nuevo." + detail="Código TOTP inválido. Verifica la hora de tu dispositivo e intenta de nuevo." ) - # Activar 2FA y generar códigos de respaldo + # Activar 2FA y generar códigos de respaldo backup_codes = security.generate_backup_codes() current_user.totp_enabled = True current_user.backup_codes = backup_codes @@ -593,21 +594,21 @@ async def disable_2fa( db: AsyncSession = Depends(get_db), ): """ - Deshabilitar 2FA verificando con código TOTP o código de respaldo. + Deshabilitar 2FA verificando con código TOTP o código de respaldo. Args: data: totp_code o backup_code para verificar identidad. Returns: - Mensaje de confirmación. + Mensaje de confirmación. """ if not current_user.totp_enabled: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="El 2FA no está habilitado en esta cuenta" + detail="El 2FA no está habilitado en esta cuenta" ) - # Verificar con TOTP o código de respaldo + # Verificar con TOTP o código de respaldo verified = False if data.totp_code: @@ -615,7 +616,7 @@ async def disable_2fa( elif data.backup_code and current_user.backup_codes: if data.backup_code in current_user.backup_codes: verified = True - # Invalidar el código de respaldo usado + # Invalidar el código de respaldo usado current_user.backup_codes = [ c for c in current_user.backup_codes if c != data.backup_code ] @@ -623,7 +624,7 @@ async def disable_2fa( if not verified: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Verificación fallida. Proporciona un código TOTP o un código de respaldo válido." + detail="Verificación fallida. Proporciona un código TOTP o un código de respaldo válido." ) # Deshabilitar 2FA @@ -644,7 +645,7 @@ async def disable_2fa( logger.info("2FA disabled", user_id=str(current_user.id)) - return {"message": "Autenticación de dos factores deshabilitada correctamente"} + return {"message": "Autenticación de dos factores deshabilitada correctamente"} @router.post("/change-password", status_code=status.HTTP_200_OK) @@ -654,32 +655,32 @@ async def change_password( db: AsyncSession = Depends(get_db), ): """ - Cambiar la contraseña del usuario autenticado. + Cambiar la contraseña del usuario autenticado. - Verifica la contraseña actual antes de actualizar. - Requiere autenticación activa. + Verifica la contraseña actual antes de actualizar. + Requiere autenticación activa. """ from datetime import datetime - # Validar longitud mínima + # Validar longitud mÃnima if len(data.new_password) < 8: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="La nueva contraseña debe tener al menos 8 caracteres" + detail="La nueva contraseña debe tener al menos 8 caracteres" ) - # Verificar que la contraseña actual sea correcta + # Verificar que la contraseña actual sea correcta if not security.verify_password(data.current_password, current_user.password_hash): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="La contraseña actual es incorrecta" + detail="La contraseña actual es incorrecta" ) # No permitir que la nueva sea igual a la actual if security.verify_password(data.new_password, current_user.password_hash): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="La nueva contraseña no puede ser igual a la actual" + detail="La nueva contraseña no puede ser igual a la actual" ) current_user.password_hash = security.hash_password(data.new_password) @@ -697,11 +698,11 @@ async def change_password( await db.commit() logger.info("Password changed", user_id=str(current_user.id)) - return {"message": "Contraseña actualizada correctamente"} + return {"message": "Contraseña actualizada correctamente"} # ============================================================ -# Recuperación de contraseña (forgot / reset) +# Recuperación de contraseña (forgot / reset) # ============================================================ _RESET_TOKEN_TTL = 1800 # 30 minutos en segundos @@ -716,10 +717,10 @@ async def forgot_password( db: AsyncSession = Depends(get_db), ): """ - Solicitar reseteo de contraseña. + Solicitar reseteo de contraseña. Siempre retorna 200 aunque el email no exista, para no revelar - si una dirección está registrada en el sistema. + si una dirección está registrada en el sistema. """ import secrets from redis.asyncio import from_url as redis_from_url @@ -735,9 +736,9 @@ async def forgot_password( user = result.scalar_one_or_none() if not user: - # Respuesta idéntica — no revelar existencia + # Respuesta idéntica — no revelar existencia logger.info("Forgot password: email not found", email=data.email) - return {"message": "Si el correo está registrado recibirás un enlace en breve."} + return {"message": "Si el correo está registrado recibirás un enlace en breve."} # Generar token seguro token = secrets.token_urlsafe(32) @@ -757,7 +758,7 @@ async def forgot_password( await send_email( to_email=user.email, - subject="Restablece tu contraseña — ServiceManager", + subject="Restablece tu contraseña — ServiceManager", html_content=html, text_content=text, ) @@ -774,7 +775,7 @@ async def forgot_password( await db.commit() logger.info("Password reset email sent", user_id=str(user.id)) - return {"message": "Si el correo está registrado recibirás un enlace en breve."} + return {"message": "Si el correo está registrado recibirás un enlace en breve."} @router.post("/reset-password", status_code=status.HTTP_200_OK) @@ -785,7 +786,7 @@ async def reset_password( db: AsyncSession = Depends(get_db), ): """ - Aplicar nueva contraseña usando el token recibido por email. + Aplicar nueva contraseña usando el token recibido por email. El token es de un solo uso: se elimina de Redis al usarse. """ @@ -796,7 +797,7 @@ async def reset_password( if len(data.new_password) < 8: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="La contraseña debe tener al menos 8 caracteres" + detail="La contraseña debe tener al menos 8 caracteres" ) redis_key = f"{_RESET_KEY_PREFIX}{data.token}" @@ -807,7 +808,7 @@ async def reset_password( if not user_id_str: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="El enlace de reseteo es inválido o ya expiró. Solicita uno nuevo." + detail="El enlace de reseteo es inválido o ya expiró. Solicita uno nuevo." ) # Eliminar token inmediatamente (un solo uso) @@ -838,4 +839,4 @@ async def reset_password( await db.commit() logger.info("Password reset completed", user_id=str(user.id)) - return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."} \ No newline at end of file + return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."} \ No newline at end of file diff --git a/backend/fix_login.py b/backend/fix_login.py new file mode 100644 index 0000000..ccfa675 --- /dev/null +++ b/backend/fix_login.py @@ -0,0 +1,67 @@ +import re + +with open("/app/app/api/v1/endpoints/auth.py", "r") as f: + content = f.read() + +old = ''' # 1. Validar tenant + tenant_result = await db.execute( + select(Tenant).where(Tenant.slug == login_data.tenant_slug) + ) + tenant = tenant_result.scalar_one_or_none() + if tenant is None: + logger.warning( + "Login failed - tenant not found", + email=login_data.email, + tenant_slug=login_data.tenant_slug, + ) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tenant not found", + )''' + +new = ''' # 1. Validar tenant - por slug si viene, sino detectar por email + if login_data.tenant_slug: + tenant_result = await db.execute( + select(Tenant).where(Tenant.slug == login_data.tenant_slug) + ) + tenant = tenant_result.scalar_one_or_none() + if tenant is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tenant not found", + ) + else: + tenant = None''' + +if old in content: + content = content.replace(old, new) + print("OK: bloque tenant reemplazado") +else: + print("ERROR: bloque no encontrado") + +# Tambien actualizar la query de usuario para usar tenant o no +old2 = ''' # 2. Buscar usuario en base de datos (aislado por tenant) + query = select(User).where( + User.email == login_data.email, + User.tenant_id == tenant.id, + )''' + +new2 = ''' # 2. Buscar usuario - filtrar por tenant si se detecto, sino buscar por email + if tenant: + query = select(User).where( + User.email == login_data.email, + User.tenant_id == tenant.id, + ) + else: + query = select(User).where(User.email == login_data.email)''' + +if old2 in content: + content = content.replace(old2, new2) + print("OK: bloque query reemplazado") +else: + print("ERROR: bloque query no encontrado") + +with open("/app/app/api/v1/endpoints/auth.py", "w") as f: + f.write(content) + +print("Listo") diff --git a/backend/fix_ratelimit.py b/backend/fix_ratelimit.py new file mode 100644 index 0000000..462ddbb --- /dev/null +++ b/backend/fix_ratelimit.py @@ -0,0 +1,23 @@ +with open("/app/app/api/v1/endpoints/auth.py", "r") as f: + content = f.read() + +old = ''' # Rate limiting (best-effort): by (tenant,email) to slow brute force. + ident_key = None + if settings.RATE_LIMIT_ENABLED and not settings.TESTING: + email_norm = login_data.email.strip().lower() + ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)''' + +new = ''' # Rate limiting (best-effort): by (tenant,email) to slow brute force. + ident_key = None + if settings.RATE_LIMIT_ENABLED and not settings.TESTING and tenant: + email_norm = login_data.email.strip().lower() + ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)''' + +if old in content: + content = content.replace(old, new) + print("OK: rate limiting fix aplicado") +else: + print("ERROR: bloque no encontrado") + +with open("/app/app/api/v1/endpoints/auth.py", "w") as f: + f.write(content) diff --git a/fix_login.py b/fix_login.py new file mode 100644 index 0000000..ccfa675 --- /dev/null +++ b/fix_login.py @@ -0,0 +1,67 @@ +import re + +with open("/app/app/api/v1/endpoints/auth.py", "r") as f: + content = f.read() + +old = ''' # 1. Validar tenant + tenant_result = await db.execute( + select(Tenant).where(Tenant.slug == login_data.tenant_slug) + ) + tenant = tenant_result.scalar_one_or_none() + if tenant is None: + logger.warning( + "Login failed - tenant not found", + email=login_data.email, + tenant_slug=login_data.tenant_slug, + ) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tenant not found", + )''' + +new = ''' # 1. Validar tenant - por slug si viene, sino detectar por email + if login_data.tenant_slug: + tenant_result = await db.execute( + select(Tenant).where(Tenant.slug == login_data.tenant_slug) + ) + tenant = tenant_result.scalar_one_or_none() + if tenant is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tenant not found", + ) + else: + tenant = None''' + +if old in content: + content = content.replace(old, new) + print("OK: bloque tenant reemplazado") +else: + print("ERROR: bloque no encontrado") + +# Tambien actualizar la query de usuario para usar tenant o no +old2 = ''' # 2. Buscar usuario en base de datos (aislado por tenant) + query = select(User).where( + User.email == login_data.email, + User.tenant_id == tenant.id, + )''' + +new2 = ''' # 2. Buscar usuario - filtrar por tenant si se detecto, sino buscar por email + if tenant: + query = select(User).where( + User.email == login_data.email, + User.tenant_id == tenant.id, + ) + else: + query = select(User).where(User.email == login_data.email)''' + +if old2 in content: + content = content.replace(old2, new2) + print("OK: bloque query reemplazado") +else: + print("ERROR: bloque query no encontrado") + +with open("/app/app/api/v1/endpoints/auth.py", "w") as f: + f.write(content) + +print("Listo") diff --git a/fix_ratelimit.py b/fix_ratelimit.py new file mode 100644 index 0000000..462ddbb --- /dev/null +++ b/fix_ratelimit.py @@ -0,0 +1,23 @@ +with open("/app/app/api/v1/endpoints/auth.py", "r") as f: + content = f.read() + +old = ''' # Rate limiting (best-effort): by (tenant,email) to slow brute force. + ident_key = None + if settings.RATE_LIMIT_ENABLED and not settings.TESTING: + email_norm = login_data.email.strip().lower() + ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)''' + +new = ''' # Rate limiting (best-effort): by (tenant,email) to slow brute force. + ident_key = None + if settings.RATE_LIMIT_ENABLED and not settings.TESTING and tenant: + email_norm = login_data.email.strip().lower() + ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)''' + +if old in content: + content = content.replace(old, new) + print("OK: rate limiting fix aplicado") +else: + print("ERROR: bloque no encontrado") + +with open("/app/app/api/v1/endpoints/auth.py", "w") as f: + f.write(content) diff --git a/frontend-client/src/routes/login/+page.svelte b/frontend-client/src/routes/login/+page.svelte index 4b8729c..504fd9f 100644 --- a/frontend-client/src/routes/login/+page.svelte +++ b/frontend-client/src/routes/login/+page.svelte @@ -7,7 +7,7 @@ let email = ''; let password = ''; - let tenantSlug = 'aduanasoft-demo'; + let tenantSlug = 'ventas'; let totpCode = ''; let isLoading = false; let showTwoFactor = false; @@ -34,11 +34,11 @@ await auth.login({ email, password, - tenant_slug: tenantSlug.trim() || 'aduanasoft-demo', + tenant_slug: tenantSlug.trim() || 'ventas', totp_code: totpCode || undefined }); - toast.success('¡Bienvenido! Has iniciado sesión correctamente'); + toast.success('¡Bienvenido! Has iniciado sesión correctamente'); goto('/'); } catch (error: any) { console.error('Login error:', error); @@ -46,9 +46,9 @@ // Check if 2FA is required if (error.message.includes('two-factor') || error.message.includes('2FA')) { showTwoFactor = true; - errorMessage = 'Introduce el código de tu aplicación de autenticación'; + errorMessage = 'Introduce el código de tu aplicación de autenticación'; } else { - errorMessage = error.message || 'Error al iniciar sesión'; + errorMessage = error.message || 'Error al iniciar sesión'; toast.error(errorMessage); } } finally { @@ -96,8 +96,8 @@ de Servicios de TI
- Portal de atención a clientes. Genere tickets de soporte técnico para nuestros sistemas y - reciba asistencia especializada para garantizar la continuidad de su operación. + Portal de atención a clientes. Genere tickets de soporte técnico para nuestros sistemas y + reciba asistencia especializada para garantizar la continuidad de su operación.
@@ -125,7 +125,7 @@- Plataforma de gestión de servicios, monitoreo de tickets y administración de usuarios. Acceso restringido únicamente a personal autorizado. + Plataforma de gestión de servicios, monitoreo de tickets y administración de usuarios. Acceso restringido únicamente a personal autorizado.
Acceso al sistema central