tenant arreglado
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Auth Schemas - ServiceManagerWeb
|
Auth Schemas - ServiceManagerWeb
|
||||||
|
|
||||||
Pydantic schemas para autenticación y autorización.
|
Pydantic schemas para autenticación y autorización.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr
|
||||||
@@ -12,7 +12,7 @@ class LoginRequest(BaseModel):
|
|||||||
"""Schema para solicitud de login."""
|
"""Schema para solicitud de login."""
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str
|
password: str
|
||||||
tenant_slug: str
|
tenant_slug: Optional[str] = None
|
||||||
totp_code: Optional[str] = None
|
totp_code: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -53,28 +53,28 @@ class TwoFactorSetupResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class TwoFactorEnableRequest(BaseModel):
|
class TwoFactorEnableRequest(BaseModel):
|
||||||
"""Código TOTP para confirmar y activar 2FA."""
|
"""Código TOTP para confirmar y activar 2FA."""
|
||||||
totp_code: str
|
totp_code: str
|
||||||
|
|
||||||
|
|
||||||
class TwoFactorEnableResponse(BaseModel):
|
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
|
enabled: bool
|
||||||
backup_codes: List[str]
|
backup_codes: List[str]
|
||||||
|
|
||||||
|
|
||||||
class TwoFactorDisableRequest(BaseModel):
|
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
|
totp_code: Optional[str] = None
|
||||||
backup_code: Optional[str] = None
|
backup_code: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Cambio de contraseña
|
# Cambio de contraseña
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
class ChangePasswordRequest(BaseModel):
|
class ChangePasswordRequest(BaseModel):
|
||||||
"""Schema para cambio de contraseña del usuario autenticado."""
|
"""Schema para cambio de contraseña del usuario autenticado."""
|
||||||
current_password: str
|
current_password: str
|
||||||
new_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):
|
class ForgotPasswordRequest(BaseModel):
|
||||||
"""Solicitar enlace de reseteo de contraseña por email."""
|
"""Solicitar enlace de reseteo de contraseña por email."""
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
class ResetPasswordRequest(BaseModel):
|
class ResetPasswordRequest(BaseModel):
|
||||||
"""Aplicar nueva contraseña usando token de reseteo."""
|
"""Aplicar nueva contraseña usando token de reseteo."""
|
||||||
token: str
|
token: str
|
||||||
new_password: str
|
new_password: str
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Authentication Endpoints - ServiceManagerWeb
|
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
|
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:
|
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"
|
return "client_access_token" if role in CLIENT_ROLES else "internal_access_token"
|
||||||
from app.api.schemas.auth import (
|
from app.api.schemas.auth import (
|
||||||
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
|
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
|
||||||
@@ -88,25 +88,23 @@ async def login(
|
|||||||
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
|
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 1. Validar tenant
|
# 1. Validar tenant - por slug si viene, sino buscar por email
|
||||||
|
if login_data.tenant_slug:
|
||||||
tenant_result = await db.execute(
|
tenant_result = await db.execute(
|
||||||
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
|
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
|
||||||
)
|
)
|
||||||
tenant = tenant_result.scalar_one_or_none()
|
tenant = tenant_result.scalar_one_or_none()
|
||||||
if tenant is None:
|
if tenant is None:
|
||||||
logger.warning(
|
|
||||||
"Login failed - tenant not found",
|
|
||||||
email=login_data.email,
|
|
||||||
tenant_slug=login_data.tenant_slug,
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="Tenant not found",
|
detail="Tenant not found",
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
tenant = None
|
||||||
|
|
||||||
# Rate limiting (best-effort): by (tenant,email) to slow brute force.
|
# Rate limiting (best-effort): by (tenant,email) to slow brute force.
|
||||||
ident_key = None
|
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()
|
email_norm = login_data.email.strip().lower()
|
||||||
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)
|
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)
|
||||||
ident_count = await cache.incr(ident_key, 1)
|
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)},
|
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Buscar usuario en base de datos (aislado por tenant)
|
# 2. Buscar usuario - filtrar por tenant si se detecto, sino buscar por email
|
||||||
|
if tenant:
|
||||||
query = select(User).where(
|
query = select(User).where(
|
||||||
User.email == login_data.email,
|
User.email == login_data.email,
|
||||||
User.tenant_id == tenant.id,
|
User.tenant_id == tenant.id,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
query = select(User).where(User.email == login_data.email)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
user = result.scalar_one_or_none()
|
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):
|
if not user or not security.verify_password(login_data.password, user.password_hash):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Login failed - invalid credentials",
|
"Login failed - invalid credentials",
|
||||||
email=login_data.email
|
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:
|
if user:
|
||||||
try:
|
try:
|
||||||
await AuditService.log(
|
await AuditService.log(
|
||||||
@@ -178,7 +179,7 @@ async def login(
|
|||||||
detail="Invalid credentials",
|
detail="Invalid credentials",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 4. Verificar si está activo
|
# 4. Verificar si está activo
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Login failed - user inactive",
|
"Login failed - user inactive",
|
||||||
@@ -189,19 +190,19 @@ async def login(
|
|||||||
detail="User inactive",
|
detail="User inactive",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 5. Verificar 2FA si está habilitado
|
# 5. Verificar 2FA si está habilitado
|
||||||
if user.totp_enabled:
|
if user.totp_enabled:
|
||||||
if not login_data.totp_code:
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
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):
|
if not security.verify_totp(user.totp_secret, login_data.totp_code):
|
||||||
logger.warning("Login failed - invalid 2FA code", email=login_data.email)
|
logger.warning("Login failed - invalid 2FA code", email=login_data.email)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Código 2FA inválido o expirado"
|
detail="Código 2FA inválido o expirado"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create tokens
|
# Create tokens
|
||||||
@@ -233,7 +234,7 @@ async def login(
|
|||||||
detail="Service temporarily unavailable",
|
detail="Service temporarily unavailable",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Registrar login exitoso en auditoría
|
# Registrar login exitoso en auditorÃa
|
||||||
try:
|
try:
|
||||||
await AuditService.log(
|
await AuditService.log(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -392,7 +393,7 @@ async def logout(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to revoke refresh tokens on logout", error=str(e))
|
logger.warning("Failed to revoke refresh tokens on logout", error=str(e))
|
||||||
|
|
||||||
# Registrar logout en auditoría
|
# Registrar logout en auditorÃa
|
||||||
try:
|
try:
|
||||||
import uuid
|
import uuid
|
||||||
user_id = uuid.UUID(payload["sub"])
|
user_id = uuid.UUID(payload["sub"])
|
||||||
@@ -413,7 +414,7 @@ async def logout(
|
|||||||
|
|
||||||
logger.info("Logout successful", user_id=payload["sub"])
|
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", ""))
|
cookie_name = _cookie_name_for_role(payload.get("role", ""))
|
||||||
response.delete_cookie(key=cookie_name)
|
response.delete_cookie(key=cookie_name)
|
||||||
return {"message": "Successfully logged out"}
|
return {"message": "Successfully logged out"}
|
||||||
@@ -502,7 +503,7 @@ async def get_2fa_status(
|
|||||||
current_user: User = Depends(get_current_user),
|
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:
|
Returns:
|
||||||
Estado de 2FA del usuario autenticado.
|
Estado de 2FA del usuario autenticado.
|
||||||
@@ -516,10 +517,10 @@ async def setup_2fa(
|
|||||||
db: AsyncSession = Depends(get_db),
|
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.
|
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.
|
Se necesita llamar a /2fa/enable con un código válido para activarlo.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Secret y QR URI para escanear con la app autenticadora.
|
Secret y QR URI para escanear con la app autenticadora.
|
||||||
@@ -527,7 +528,7 @@ async def setup_2fa(
|
|||||||
new_secret = security.generate_totp_secret()
|
new_secret = security.generate_totp_secret()
|
||||||
qr_uri = security.generate_totp_uri(new_secret, current_user.email)
|
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
|
current_user.totp_secret = new_secret
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
@@ -543,29 +544,29 @@ async def enable_2fa(
|
|||||||
db: AsyncSession = Depends(get_db),
|
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.
|
Requiere que /2fa/setup haya sido llamado previamente.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: Código TOTP generado por la app autenticadora.
|
data: Código TOTP generado por la app autenticadora.
|
||||||
|
|
||||||
Returns:
|
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:
|
if not current_user.totp_secret:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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):
|
if not security.verify_totp(current_user.totp_secret, data.totp_code):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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()
|
backup_codes = security.generate_backup_codes()
|
||||||
current_user.totp_enabled = True
|
current_user.totp_enabled = True
|
||||||
current_user.backup_codes = backup_codes
|
current_user.backup_codes = backup_codes
|
||||||
@@ -593,21 +594,21 @@ async def disable_2fa(
|
|||||||
db: AsyncSession = Depends(get_db),
|
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:
|
Args:
|
||||||
data: totp_code o backup_code para verificar identidad.
|
data: totp_code o backup_code para verificar identidad.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Mensaje de confirmación.
|
Mensaje de confirmación.
|
||||||
"""
|
"""
|
||||||
if not current_user.totp_enabled:
|
if not current_user.totp_enabled:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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
|
verified = False
|
||||||
|
|
||||||
if data.totp_code:
|
if data.totp_code:
|
||||||
@@ -615,7 +616,7 @@ async def disable_2fa(
|
|||||||
elif data.backup_code and current_user.backup_codes:
|
elif data.backup_code and current_user.backup_codes:
|
||||||
if data.backup_code in current_user.backup_codes:
|
if data.backup_code in current_user.backup_codes:
|
||||||
verified = True
|
verified = True
|
||||||
# Invalidar el código de respaldo usado
|
# Invalidar el código de respaldo usado
|
||||||
current_user.backup_codes = [
|
current_user.backup_codes = [
|
||||||
c for c in current_user.backup_codes if c != data.backup_code
|
c for c in current_user.backup_codes if c != data.backup_code
|
||||||
]
|
]
|
||||||
@@ -623,7 +624,7 @@ async def disable_2fa(
|
|||||||
if not verified:
|
if not verified:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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
|
# Deshabilitar 2FA
|
||||||
@@ -644,7 +645,7 @@ async def disable_2fa(
|
|||||||
|
|
||||||
logger.info("2FA disabled", user_id=str(current_user.id))
|
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)
|
@router.post("/change-password", status_code=status.HTTP_200_OK)
|
||||||
@@ -654,32 +655,32 @@ async def change_password(
|
|||||||
db: AsyncSession = Depends(get_db),
|
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.
|
Verifica la contraseña actual antes de actualizar.
|
||||||
Requiere autenticación activa.
|
Requiere autenticación activa.
|
||||||
"""
|
"""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
# Validar longitud mínima
|
# Validar longitud mÃnima
|
||||||
if len(data.new_password) < 8:
|
if len(data.new_password) < 8:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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):
|
if not security.verify_password(data.current_password, current_user.password_hash):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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
|
# No permitir que la nueva sea igual a la actual
|
||||||
if security.verify_password(data.new_password, current_user.password_hash):
|
if security.verify_password(data.new_password, current_user.password_hash):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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)
|
current_user.password_hash = security.hash_password(data.new_password)
|
||||||
@@ -697,11 +698,11 @@ async def change_password(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
logger.info("Password changed", user_id=str(current_user.id))
|
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
|
_RESET_TOKEN_TTL = 1800 # 30 minutos en segundos
|
||||||
@@ -716,10 +717,10 @@ async def forgot_password(
|
|||||||
db: AsyncSession = Depends(get_db),
|
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
|
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
|
import secrets
|
||||||
from redis.asyncio import from_url as redis_from_url
|
from redis.asyncio import from_url as redis_from_url
|
||||||
@@ -735,9 +736,9 @@ async def forgot_password(
|
|||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not user:
|
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)
|
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
|
# Generar token seguro
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
@@ -757,7 +758,7 @@ async def forgot_password(
|
|||||||
|
|
||||||
await send_email(
|
await send_email(
|
||||||
to_email=user.email,
|
to_email=user.email,
|
||||||
subject="Restablece tu contraseña — ServiceManager",
|
subject="Restablece tu contraseña — ServiceManager",
|
||||||
html_content=html,
|
html_content=html,
|
||||||
text_content=text,
|
text_content=text,
|
||||||
)
|
)
|
||||||
@@ -774,7 +775,7 @@ async def forgot_password(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
logger.info("Password reset email sent", user_id=str(user.id))
|
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)
|
@router.post("/reset-password", status_code=status.HTTP_200_OK)
|
||||||
@@ -785,7 +786,7 @@ async def reset_password(
|
|||||||
db: AsyncSession = Depends(get_db),
|
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.
|
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:
|
if len(data.new_password) < 8:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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}"
|
redis_key = f"{_RESET_KEY_PREFIX}{data.token}"
|
||||||
@@ -807,7 +808,7 @@ async def reset_password(
|
|||||||
if not user_id_str:
|
if not user_id_str:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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)
|
# Eliminar token inmediatamente (un solo uso)
|
||||||
@@ -838,4 +839,4 @@ async def reset_password(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
logger.info("Password reset completed", user_id=str(user.id))
|
logger.info("Password reset completed", user_id=str(user.id))
|
||||||
return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."}
|
return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."}
|
||||||
67
backend/fix_login.py
Normal file
67
backend/fix_login.py
Normal file
@@ -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")
|
||||||
23
backend/fix_ratelimit.py
Normal file
23
backend/fix_ratelimit.py
Normal file
@@ -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)
|
||||||
67
fix_login.py
Normal file
67
fix_login.py
Normal file
@@ -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")
|
||||||
23
fix_ratelimit.py
Normal file
23
fix_ratelimit.py
Normal file
@@ -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)
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
let email = '';
|
let email = '';
|
||||||
let password = '';
|
let password = '';
|
||||||
let tenantSlug = 'aduanasoft-demo';
|
let tenantSlug = 'ventas';
|
||||||
let totpCode = '';
|
let totpCode = '';
|
||||||
let isLoading = false;
|
let isLoading = false;
|
||||||
let showTwoFactor = false;
|
let showTwoFactor = false;
|
||||||
@@ -34,11 +34,11 @@
|
|||||||
await auth.login({
|
await auth.login({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
tenant_slug: tenantSlug.trim() || 'aduanasoft-demo',
|
tenant_slug: tenantSlug.trim() || 'ventas',
|
||||||
totp_code: totpCode || undefined
|
totp_code: totpCode || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success('¡Bienvenido! Has iniciado sesión correctamente');
|
toast.success('¡Bienvenido! Has iniciado sesión correctamente');
|
||||||
goto('/');
|
goto('/');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Login error:', error);
|
console.error('Login error:', error);
|
||||||
@@ -46,9 +46,9 @@
|
|||||||
// Check if 2FA is required
|
// Check if 2FA is required
|
||||||
if (error.message.includes('two-factor') || error.message.includes('2FA')) {
|
if (error.message.includes('two-factor') || error.message.includes('2FA')) {
|
||||||
showTwoFactor = true;
|
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 {
|
} else {
|
||||||
errorMessage = error.message || 'Error al iniciar sesión';
|
errorMessage = error.message || 'Error al iniciar sesión';
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -96,8 +96,8 @@
|
|||||||
de Servicios de TI
|
de Servicios de TI
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-lg text-blue-100/90 font-light max-w-lg leading-relaxed drop-shadow-md">
|
<p class="text-lg text-blue-100/90 font-light max-w-lg leading-relaxed drop-shadow-md">
|
||||||
Portal de atención a clientes. Genere tickets de soporte técnico para nuestros sistemas y
|
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.
|
reciba asistencia especializada para garantizar la continuidad de su operación.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
<div
|
<div
|
||||||
class="p-3 rounded-md bg-red-50 border border-red-100 flex items-center gap-3 animate-fade-in text-sm text-red-600"
|
class="p-3 rounded-md bg-red-50 border border-red-100 flex items-center gap-3 animate-fade-in text-sm text-red-600"
|
||||||
>
|
>
|
||||||
<Icon name="alert-circle" class="w-4 h-4 flex-shrink-0" />
|
<Icon name="alert-circle" className="w-4 h-4 flex-shrink-0" />
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -135,13 +135,13 @@
|
|||||||
<!-- Email Input -->
|
<!-- Email Input -->
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="email" class="block text-sm font-semibold text-gray-700"
|
<label for="email" class="block text-sm font-semibold text-gray-700"
|
||||||
>Correo Electrónico</label
|
>Correo Electrónico</label
|
||||||
>
|
>
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
<Icon
|
<Icon
|
||||||
name="mail"
|
name="mail"
|
||||||
class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
|
className="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -160,13 +160,13 @@
|
|||||||
<!-- Password Input -->
|
<!-- Password Input -->
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="password" class="block text-sm font-semibold text-gray-700"
|
<label for="password" class="block text-sm font-semibold text-gray-700"
|
||||||
>Contraseña</label
|
>Contraseña</label
|
||||||
>
|
>
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
<Icon
|
<Icon
|
||||||
name="lock"
|
name="lock"
|
||||||
class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
|
className="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{#if showPassword}
|
{#if showPassword}
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
bind:value={password}
|
bind:value={password}
|
||||||
on:keydown={handleKeyDown}
|
on:keydown={handleKeyDown}
|
||||||
class="block w-full pl-10 pr-10 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
|
class="block w-full pl-10 pr-10 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
required
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
@@ -187,7 +187,7 @@
|
|||||||
bind:value={password}
|
bind:value={password}
|
||||||
on:keydown={handleKeyDown}
|
on:keydown={handleKeyDown}
|
||||||
class="block w-full pl-10 pr-10 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
|
class="block w-full pl-10 pr-10 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
required
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
@@ -197,7 +197,7 @@
|
|||||||
class="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer text-gray-400 hover:text-gray-600 focus:outline-none"
|
class="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer text-gray-400 hover:text-gray-600 focus:outline-none"
|
||||||
on:click={() => (showPassword = !showPassword)}
|
on:click={() => (showPassword = !showPassword)}
|
||||||
>
|
>
|
||||||
<Icon name={showPassword ? 'eye-off' : 'eye'} class="w-5 h-5" />
|
<Icon name={showPassword ? 'eye-off' : 'eye'} className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
class="text-sm font-medium text-blue-600 hover:text-blue-500 bg-transparent border-none p-0 cursor-pointer"
|
class="text-sm font-medium text-blue-600 hover:text-blue-500 bg-transparent border-none p-0 cursor-pointer"
|
||||||
on:click={() => goto('/forgot-password')}
|
on:click={() => goto('/forgot-password')}
|
||||||
>
|
>
|
||||||
Olvide mi clave
|
Olvidé mi clave
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,13 +229,13 @@
|
|||||||
<!-- 2FA Input -->
|
<!-- 2FA Input -->
|
||||||
<div class="space-y-4 animate-slide-up">
|
<div class="space-y-4 animate-slide-up">
|
||||||
<label for="code" class="block text-sm font-medium text-gray-700 text-center"
|
<label for="code" class="block text-sm font-medium text-gray-700 text-center"
|
||||||
>Código de Verificación (2FA)</label
|
>Código de Verificación (2FA)</label
|
||||||
>
|
>
|
||||||
<p class="text-xs text-center text-gray-500 mb-4">Ingrese el código de 6 dígitos</p>
|
<p class="text-xs text-center text-gray-500 mb-4">Ingrese el código de 6 dÃgitos</p>
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
<Icon name="shield-check" class="w-5 h-5 text-blue-500" />
|
<Icon name="shield-check" className="w-5 h-5 text-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
id="code"
|
id="code"
|
||||||
@@ -259,7 +259,7 @@
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<Icon name="loader-2" class="w-5 h-5 animate-spin mr-2" />
|
<Icon name="loader-2" className="w-5 h-5 animate-spin mr-2" />
|
||||||
Procesando...
|
Procesando...
|
||||||
{:else}
|
{:else}
|
||||||
{showTwoFactor ? 'Verificar Acceso' : 'Acceder al Portal'}
|
{showTwoFactor ? 'Verificar Acceso' : 'Acceder al Portal'}
|
||||||
@@ -268,7 +268,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 text-center text-xs text-gray-400">
|
<div class="mt-8 text-center text-xs text-gray-400">
|
||||||
© 2026 Aduanasoft. Acceso exclusivo autorizado.
|
© 2026 Aduanasoft. Acceso exclusivo autorizado.
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,11 +32,11 @@
|
|||||||
await auth.login({
|
await auth.login({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
tenant_slug: 'aduanasoft-demo',
|
tenant_slug: 'aduanasoft',
|
||||||
totp_code: totpCode || undefined
|
totp_code: totpCode || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success('¡Bienvenido! Has iniciado sesión correctamente');
|
toast.success('¡Bienvenido! Has iniciado sesión correctamente');
|
||||||
goto('/');
|
goto('/');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Login error:', error);
|
console.error('Login error:', error);
|
||||||
@@ -44,9 +44,9 @@
|
|||||||
// Check if 2FA is required
|
// Check if 2FA is required
|
||||||
if (error.message.includes('two-factor') || error.message.includes('2FA')) {
|
if (error.message.includes('two-factor') || error.message.includes('2FA')) {
|
||||||
showTwoFactor = true;
|
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 {
|
} else {
|
||||||
errorMessage = error.message || 'Error al iniciar sesión';
|
errorMessage = error.message || 'Error al iniciar sesión';
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -77,27 +77,27 @@
|
|||||||
<div class="relative z-10">
|
<div class="relative z-10">
|
||||||
<div class="flex items-center space-x-3 mb-6">
|
<div class="flex items-center space-x-3 mb-6">
|
||||||
<div class="p-2 bg-blue-500/20 rounded border border-blue-500/30">
|
<div class="p-2 bg-blue-500/20 rounded border border-blue-500/30">
|
||||||
<Icon name="server" class="w-6 h-6 text-blue-400" />
|
<Icon name="server" className="w-6 h-6 text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm font-mono tracking-wider text-blue-400">INTERNAL_ACCESS_V2</span>
|
<span class="text-sm font-mono tracking-wider text-blue-400">INTERNAL_ACCESS_V2</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="text-3xl font-bold tracking-tight mb-4">
|
<h1 class="text-3xl font-bold tracking-tight mb-4">
|
||||||
Panel de Administración
|
Panel de Administración
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-gray-400 text-sm leading-relaxed max-w-sm">
|
<p class="text-gray-400 text-sm leading-relaxed max-w-sm">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative z-10 mt-12">
|
<div class="relative z-10 mt-12">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-center space-x-3 text-xs text-gray-400 font-mono">
|
<div class="flex items-center space-x-3 text-xs text-gray-400 font-mono">
|
||||||
<Icon name="check-circle" class="w-4 h-4 text-green-500" />
|
<Icon name="check-circle" className="w-4 h-4 text-green-500" />
|
||||||
<span>System Status: Operational</span>
|
<span>System Status: Operational</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-3 text-xs text-gray-400 font-mono">
|
<div class="flex items-center space-x-3 text-xs text-gray-400 font-mono">
|
||||||
<Icon name="shield" class="w-4 h-4 text-blue-500" />
|
<Icon name="shield" className="w-4 h-4 text-blue-500" />
|
||||||
<span>256-bit Encryption Enabled</span>
|
<span>256-bit Encryption Enabled</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,14 +109,14 @@
|
|||||||
|
|
||||||
<div class="max-w-sm mx-auto w-full">
|
<div class="max-w-sm mx-auto w-full">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">Iniciar Sesión</h2>
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">Iniciar Sesión</h2>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Acceso al sistema central</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">Acceso al sistema central</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form on:submit|preventDefault={handleLogin} class="space-y-5">
|
<form on:submit|preventDefault={handleLogin} class="space-y-5">
|
||||||
{#if errorMessage}
|
{#if errorMessage}
|
||||||
<div class="p-3 rounded-md bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-900 flex items-start gap-3">
|
<div class="p-3 rounded-md bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-900 flex items-start gap-3">
|
||||||
<Icon name="alert-triangle" class="w-5 h-5 text-red-600 dark:text-red-500 flex-shrink-0 mt-0.5" />
|
<Icon name="alert-triangle" className="w-5 h-5 text-red-600 dark:text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
<p class="text-sm text-red-600 dark:text-red-500">{errorMessage}</p>
|
<p class="text-sm text-red-600 dark:text-red-500">{errorMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
<label for="email" class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">Usuario / Correo</label>
|
<label for="email" class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">Usuario / Correo</label>
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors">
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors">
|
||||||
<Icon name="user" class="w-5 h-5" />
|
<Icon name="user" className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
<label for="password" class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">Clave de Acceso</label>
|
<label for="password" class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">Clave de Acceso</label>
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors">
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors">
|
||||||
<Icon name="lock" class="w-5 h-5" />
|
<Icon name="lock" className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
bind:value={password}
|
bind:value={password}
|
||||||
on:keydown={handleKeyDown}
|
on:keydown={handleKeyDown}
|
||||||
class="form-input w-full pl-10 py-2.5 bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all font-mono text-sm"
|
class="form-input w-full pl-10 py-2.5 bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all font-mono text-sm"
|
||||||
placeholder="••••••••••••"
|
placeholder="••••••••••••"
|
||||||
required
|
required
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
@@ -165,7 +165,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<!-- 2FA Input -->
|
<!-- 2FA Input -->
|
||||||
<div class="bg-blue-50 dark:bg-blue-900/10 p-4 rounded-lg border border-blue-100 dark:border-blue-800/30">
|
<div class="bg-blue-50 dark:bg-blue-900/10 p-4 rounded-lg border border-blue-100 dark:border-blue-800/30">
|
||||||
<label for="code" class="block text-xs font-semibold uppercase tracking-wider text-blue-800 dark:text-blue-300 mb-2 text-center">Verificación de Seguridad</label>
|
<label for="code" class="block text-xs font-semibold uppercase tracking-wider text-blue-800 dark:text-blue-300 mb-2 text-center">Verificación de Seguridad</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
id="code"
|
id="code"
|
||||||
@@ -193,7 +193,7 @@
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<Icon name="loader" class="w-4 h-4 animate-spin mr-2" />
|
<Icon name="loader" className="w-4 h-4 animate-spin mr-2" />
|
||||||
Autenticando...
|
Autenticando...
|
||||||
{:else}
|
{:else}
|
||||||
{showTwoFactor ? 'Verificar Token' : 'Entrar al Panel'}
|
{showTwoFactor ? 'Verificar Token' : 'Entrar al Panel'}
|
||||||
@@ -204,7 +204,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800">
|
<div class="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800">
|
||||||
<p class="text-[10px] text-gray-400 text-center uppercase tracking-widest">Aduanasoft Internal Systems © 2024</p>
|
<p class="text-[10px] text-gray-400 text-center uppercase tracking-widest">Aduanasoft Internal Systems © 2024</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user