809 lines
26 KiB
Python
809 lines
26 KiB
Python
"""
|
|
Authentication Endpoints - ServiceManagerWeb
|
|
|
|
Endpoints para autenticación y autorización
|
|
"""
|
|
|
|
from fastapi import APIRouter, HTTPException, status, Depends, Request
|
|
from fastapi.security import OAuth2PasswordRequestForm
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import selectinload
|
|
from typing import Optional
|
|
import structlog
|
|
|
|
from app.core.database import get_db
|
|
from app.core.security import security
|
|
from app.core.config import get_settings
|
|
from app.models.user import User
|
|
from app.models.tenant import Tenant
|
|
from app.services.audit_service import AuditService
|
|
from app.services.token_service import TokenService
|
|
from app.api.deps import oauth2_scheme, get_current_user
|
|
from app.core.cache import cache, cache_key
|
|
from app.api.schemas.auth import (
|
|
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
|
|
TwoFactorStatusResponse, TwoFactorSetupResponse,
|
|
TwoFactorEnableRequest, TwoFactorEnableResponse, TwoFactorDisableRequest,
|
|
ChangePasswordRequest, ForgotPasswordRequest, ResetPasswordRequest,
|
|
)
|
|
|
|
router = APIRouter()
|
|
logger = structlog.get_logger(__name__)
|
|
settings = get_settings()
|
|
|
|
|
|
# ===================================
|
|
# ENDPOINTS
|
|
# ===================================
|
|
|
|
@router.post("/login", response_model=LoginResponse)
|
|
async def login(
|
|
login_data: LoginRequest,
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Authenticate user and return access/refresh tokens.
|
|
|
|
Args:
|
|
login_data: Login credentials
|
|
db: Database session
|
|
|
|
Returns:
|
|
LoginResponse with tokens and user info
|
|
|
|
Raises:
|
|
HTTPException: If authentication fails
|
|
"""
|
|
logger.info(
|
|
"Login attempt",
|
|
email=login_data.email,
|
|
tenant_slug=login_data.tenant_slug
|
|
)
|
|
|
|
# Rate limiting (best-effort): by IP before any tenant/user lookup.
|
|
if settings.RATE_LIMIT_ENABLED and not settings.TESTING:
|
|
client_ip = request.client.host if request.client else "unknown"
|
|
ip_key = cache_key("rl", "login", "ip", client_ip)
|
|
ip_count = await cache.incr(ip_key, 1)
|
|
if ip_count == 1:
|
|
await cache.expire(ip_key, settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)
|
|
|
|
if ip_count is not None and ip_count > settings.LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
detail="Too many login attempts. Try again later.",
|
|
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",
|
|
)
|
|
|
|
# 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)
|
|
ident_count = await cache.incr(ident_key, 1)
|
|
if ident_count == 1:
|
|
await cache.expire(ident_key, settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)
|
|
|
|
if ident_count is not None and ident_count > settings.LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS:
|
|
try:
|
|
await AuditService.log(
|
|
db=db,
|
|
tenant_id=tenant.id,
|
|
user_id=None,
|
|
action="user.login_rate_limited",
|
|
resource_type="user",
|
|
resource_id=None,
|
|
metadata={
|
|
"email": email_norm,
|
|
"tenant_slug": login_data.tenant_slug,
|
|
"ip": request.client.host if request.client else None,
|
|
"scope": "tenant_email",
|
|
"window_seconds": settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS,
|
|
"max_attempts": settings.LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS,
|
|
},
|
|
request=request,
|
|
)
|
|
await db.commit()
|
|
except Exception as e:
|
|
logger.warning("Failed to log rate limit audit entry", error=str(e))
|
|
|
|
raise HTTPException(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
detail="Too many login attempts. Try again later.",
|
|
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,
|
|
)
|
|
result = await db.execute(query)
|
|
user = result.scalar_one_or_none()
|
|
|
|
# 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)
|
|
if user:
|
|
try:
|
|
await AuditService.log(
|
|
db=db,
|
|
tenant_id=user.tenant_id,
|
|
user_id=None, # Login fallido = sin user_id
|
|
action="user.login_failed",
|
|
resource_type="user",
|
|
resource_id=user.id,
|
|
metadata={"email": login_data.email, "reason": "invalid_password"}
|
|
)
|
|
await db.commit()
|
|
except Exception as e:
|
|
logger.warning("Failed to log audit entry", error=str(e))
|
|
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid credentials",
|
|
)
|
|
|
|
# 4. Verificar si está activo
|
|
if not user.is_active:
|
|
logger.warning(
|
|
"Login failed - user inactive",
|
|
email=login_data.email
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="User inactive",
|
|
)
|
|
|
|
# 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
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
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"
|
|
)
|
|
|
|
# Create tokens
|
|
token_data = {
|
|
"sub": str(user.id),
|
|
"email": user.email,
|
|
"role": user.role.value if hasattr(user.role, "value") else user.role,
|
|
"tenant_id": str(user.tenant_id)
|
|
}
|
|
|
|
access_token = security.create_access_token(token_data)
|
|
refresh_token = security.create_refresh_token(token_data)
|
|
|
|
# Persist refresh token so it can be revoked/validated later
|
|
try:
|
|
await TokenService.create_refresh_token(
|
|
db=db,
|
|
user=user,
|
|
refresh_token=refresh_token,
|
|
user_agent=request.headers.get("user-agent"),
|
|
ip_address=request.client.host if request.client else None,
|
|
)
|
|
await db.commit()
|
|
except Exception as e:
|
|
# If persistence fails, do not leak tokens
|
|
logger.error("Failed to persist refresh token", error=str(e), user_id=str(user.id))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="Service temporarily unavailable",
|
|
)
|
|
|
|
# Registrar login exitoso en auditoría
|
|
try:
|
|
await AuditService.log(
|
|
db=db,
|
|
tenant_id=user.tenant_id,
|
|
user_id=user.id,
|
|
action="user.login",
|
|
resource_type="user",
|
|
resource_id=user.id,
|
|
metadata={"email": user.email, "success": True}
|
|
)
|
|
await db.commit()
|
|
except Exception as e:
|
|
logger.warning("Failed to log audit entry", error=str(e))
|
|
|
|
logger.info(
|
|
"Login successful",
|
|
email=login_data.email,
|
|
tenant_slug=login_data.tenant_slug,
|
|
user_id=str(user.id)
|
|
)
|
|
|
|
# Best-effort: clear per-identity limiter on success.
|
|
if ident_key:
|
|
await cache.delete(ident_key)
|
|
|
|
return LoginResponse(
|
|
access_token=access_token,
|
|
refresh_token=refresh_token,
|
|
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
|
user={
|
|
"id": str(user.id),
|
|
"email": user.email,
|
|
"first_name": user.first_name,
|
|
"last_name": user.last_name,
|
|
"role": user.role,
|
|
"tenant_id": str(user.tenant_id),
|
|
"is_active": user.is_active,
|
|
"is_two_factor_enabled": user.totp_enabled or False,
|
|
"created_at": user.created_at.isoformat() if user.created_at else None
|
|
}
|
|
)
|
|
|
|
|
|
@router.post("/refresh", response_model=TokenResponse)
|
|
async def refresh_token(
|
|
refresh_data: RefreshTokenRequest,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Refresh access token using refresh token.
|
|
|
|
Args:
|
|
refresh_data: Refresh token data
|
|
db: Database session
|
|
|
|
Returns:
|
|
New access token
|
|
|
|
Raises:
|
|
HTTPException: If refresh token is invalid
|
|
"""
|
|
logger.info("Token refresh attempt")
|
|
|
|
# Verify refresh token
|
|
payload = security.verify_token(refresh_data.refresh_token)
|
|
if not payload or payload.get("type") != "refresh":
|
|
logger.warning("Token refresh failed - invalid token")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid refresh token"
|
|
)
|
|
|
|
# Check token exists in database and is not revoked/expired
|
|
db_token = await TokenService.verify_refresh_token(db=db, refresh_token=refresh_data.refresh_token)
|
|
if db_token is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid refresh token",
|
|
)
|
|
|
|
# Defensive: ensure DB token belongs to same subject
|
|
if str(db_token.user_id) != str(payload.get("sub")):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid refresh token",
|
|
)
|
|
|
|
# Create new access token
|
|
token_data = {
|
|
"sub": payload["sub"],
|
|
"email": payload["email"],
|
|
"role": payload["role"],
|
|
"tenant_id": payload["tenant_id"]
|
|
}
|
|
|
|
access_token = security.create_access_token(token_data)
|
|
|
|
logger.info("Token refresh successful", user_id=payload["sub"])
|
|
|
|
return TokenResponse(
|
|
access_token=access_token,
|
|
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
|
)
|
|
|
|
|
|
@router.post("/logout")
|
|
async def logout(
|
|
token: str = Depends(oauth2_scheme),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Logout user and revoke refresh token.
|
|
|
|
Args:
|
|
token: Access token
|
|
db: Database session
|
|
|
|
Returns:
|
|
Success message
|
|
"""
|
|
logger.info("Logout attempt")
|
|
|
|
# Verify token
|
|
payload = security.verify_token(token)
|
|
if not payload:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid token"
|
|
)
|
|
|
|
# Revoke all active refresh tokens for this user (logout invalidates refresh)
|
|
try:
|
|
import uuid
|
|
|
|
user_id = uuid.UUID(payload["sub"])
|
|
await TokenService.revoke_all_user_tokens(
|
|
db=db,
|
|
user_id=user_id,
|
|
revoked_by_user_id=user_id,
|
|
)
|
|
await db.commit()
|
|
except Exception as e:
|
|
logger.warning("Failed to revoke refresh tokens on logout", error=str(e))
|
|
|
|
# Registrar logout en auditoría
|
|
try:
|
|
import uuid
|
|
user_id = uuid.UUID(payload["sub"])
|
|
tenant_id = uuid.UUID(payload["tenant_id"])
|
|
|
|
await AuditService.log(
|
|
db=db,
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
action="user.logout",
|
|
resource_type="user",
|
|
resource_id=user_id,
|
|
metadata={"email": payload.get("email")}
|
|
)
|
|
await db.commit()
|
|
except Exception as e:
|
|
logger.warning("Failed to log audit entry", error=str(e))
|
|
|
|
logger.info("Logout successful", user_id=payload["sub"])
|
|
|
|
return {"message": "Successfully logged out"}
|
|
|
|
|
|
@router.get("/me")
|
|
async def get_current_user(
|
|
token: str = Depends(oauth2_scheme),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Get current user information.
|
|
|
|
Args:
|
|
token: Access token
|
|
db: Database session
|
|
|
|
Returns:
|
|
Current user data
|
|
|
|
Raises:
|
|
HTTPException: If token is invalid
|
|
"""
|
|
# Verify token
|
|
payload = security.verify_token(token)
|
|
if not payload:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid token"
|
|
)
|
|
|
|
user_id = payload.get("sub")
|
|
if not user_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid token payload"
|
|
)
|
|
|
|
# Fetch actual user from database
|
|
query = select(User).where(User.id == user_id).options(
|
|
selectinload(User.tenant)
|
|
)
|
|
result = await db.execute(query)
|
|
user = result.scalar_one_or_none()
|
|
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="User not found"
|
|
)
|
|
|
|
if not user.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="User account is disabled"
|
|
)
|
|
|
|
return {
|
|
"id": str(user.id),
|
|
"email": user.email,
|
|
"first_name": user.first_name,
|
|
"last_name": user.last_name,
|
|
"role": user.role.value if hasattr(user.role, 'value') else user.role,
|
|
"tenant_id": str(user.tenant_id),
|
|
"tenant_name": user.tenant.name if user.tenant else None,
|
|
"is_active": user.is_active,
|
|
"is_two_factor_enabled": user.totp_secret is not None,
|
|
"last_login": user.last_login.isoformat() if user.last_login else None,
|
|
"created_at": user.created_at.isoformat()
|
|
}
|
|
|
|
|
|
# ===================================
|
|
# DEPENDENCIES
|
|
# ===================================
|
|
# Dependencies are imported from app.api.deps to avoid duplication
|
|
# Use get_current_user and get_current_active_superuser from deps.py
|
|
|
|
|
|
# ===================================
|
|
# 2FA / TOTP ENDPOINTS
|
|
# ===================================
|
|
|
|
@router.get("/2fa/status", response_model=TwoFactorStatusResponse)
|
|
async def get_2fa_status(
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""
|
|
Consultar si el 2FA está habilitado para el usuario actual.
|
|
|
|
Returns:
|
|
Estado de 2FA del usuario autenticado.
|
|
"""
|
|
return TwoFactorStatusResponse(enabled=bool(current_user.totp_enabled))
|
|
|
|
|
|
@router.post("/2fa/setup", response_model=TwoFactorSetupResponse)
|
|
async def setup_2fa(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
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.
|
|
|
|
Returns:
|
|
Secret y QR URI para escanear con la app autenticadora.
|
|
"""
|
|
new_secret = security.generate_totp_secret()
|
|
qr_uri = security.generate_totp_uri(new_secret, current_user.email)
|
|
|
|
# Guardar el secret (sin habilitar aún)
|
|
current_user.totp_secret = new_secret
|
|
await db.commit()
|
|
|
|
logger.info("2FA setup initiated", user_id=str(current_user.id))
|
|
|
|
return TwoFactorSetupResponse(secret=new_secret, qr_uri=qr_uri)
|
|
|
|
|
|
@router.post("/2fa/enable", response_model=TwoFactorEnableResponse)
|
|
async def enable_2fa(
|
|
data: TwoFactorEnableRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
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.
|
|
|
|
Returns:
|
|
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"
|
|
)
|
|
|
|
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."
|
|
)
|
|
|
|
# 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
|
|
await db.commit()
|
|
|
|
await AuditService.log(
|
|
db=db,
|
|
tenant_id=current_user.tenant_id,
|
|
user_id=current_user.id,
|
|
action="user.2fa_enabled",
|
|
resource_type="user",
|
|
resource_id=current_user.id,
|
|
)
|
|
await db.commit()
|
|
|
|
logger.info("2FA enabled", user_id=str(current_user.id))
|
|
|
|
return TwoFactorEnableResponse(enabled=True, backup_codes=backup_codes)
|
|
|
|
|
|
@router.post("/2fa/disable")
|
|
async def disable_2fa(
|
|
data: TwoFactorDisableRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
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.
|
|
"""
|
|
if not current_user.totp_enabled:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="El 2FA no está habilitado en esta cuenta"
|
|
)
|
|
|
|
# Verificar con TOTP o código de respaldo
|
|
verified = False
|
|
|
|
if data.totp_code:
|
|
verified = security.verify_totp(current_user.totp_secret, data.totp_code)
|
|
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
|
|
current_user.backup_codes = [
|
|
c for c in current_user.backup_codes if c != data.backup_code
|
|
]
|
|
|
|
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."
|
|
)
|
|
|
|
# Deshabilitar 2FA
|
|
current_user.totp_enabled = False
|
|
current_user.totp_secret = None
|
|
current_user.backup_codes = None
|
|
await db.commit()
|
|
|
|
await AuditService.log(
|
|
db=db,
|
|
tenant_id=current_user.tenant_id,
|
|
user_id=current_user.id,
|
|
action="user.2fa_disabled",
|
|
resource_type="user",
|
|
resource_id=current_user.id,
|
|
)
|
|
await db.commit()
|
|
|
|
logger.info("2FA disabled", user_id=str(current_user.id))
|
|
|
|
return {"message": "Autenticación de dos factores deshabilitada correctamente"}
|
|
|
|
|
|
@router.post("/change-password", status_code=status.HTTP_200_OK)
|
|
async def change_password(
|
|
data: ChangePasswordRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
Cambiar la contraseña del usuario autenticado.
|
|
|
|
Verifica la contraseña actual antes de actualizar.
|
|
Requiere autenticación activa.
|
|
"""
|
|
from datetime import datetime
|
|
|
|
# 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"
|
|
)
|
|
|
|
# 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"
|
|
)
|
|
|
|
# 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"
|
|
)
|
|
|
|
current_user.password_hash = security.hash_password(data.new_password)
|
|
current_user.updated_at = datetime.utcnow()
|
|
await db.commit()
|
|
|
|
await AuditService.log(
|
|
db=db,
|
|
tenant_id=current_user.tenant_id,
|
|
user_id=current_user.id,
|
|
action="user.password_changed",
|
|
resource_type="user",
|
|
resource_id=current_user.id,
|
|
)
|
|
await db.commit()
|
|
|
|
logger.info("Password changed", user_id=str(current_user.id))
|
|
return {"message": "Contraseña actualizada correctamente"}
|
|
|
|
|
|
# ============================================================
|
|
# Recuperación de contraseña (forgot / reset)
|
|
# ============================================================
|
|
|
|
_RESET_TOKEN_TTL = 1800 # 30 minutos en segundos
|
|
_RESET_KEY_PREFIX = "pwd_reset:"
|
|
|
|
|
|
@router.post("/forgot-password", status_code=status.HTTP_200_OK)
|
|
async def forgot_password(
|
|
data: ForgotPasswordRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
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.
|
|
"""
|
|
import secrets
|
|
from redis.asyncio import from_url as redis_from_url
|
|
from app.core.email import send_email, build_password_reset_email
|
|
|
|
# Buscar usuario activo con ese email
|
|
result = await db.execute(
|
|
select(User).where(
|
|
User.email == data.email,
|
|
User.is_active == True, # noqa: E712
|
|
).limit(1)
|
|
)
|
|
user = result.scalar_one_or_none()
|
|
|
|
if not user:
|
|
# 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."}
|
|
|
|
# Generar token seguro
|
|
token = secrets.token_urlsafe(32)
|
|
redis_key = f"{_RESET_KEY_PREFIX}{token}"
|
|
|
|
# Guardar en Redis con TTL de 30 min
|
|
redis = redis_from_url(settings.REDIS_URL, decode_responses=True)
|
|
try:
|
|
await redis.setex(redis_key, _RESET_TOKEN_TTL, str(user.id))
|
|
finally:
|
|
await redis.aclose()
|
|
|
|
# Construir URL y enviar email
|
|
reset_url = f"{settings.CLIENT_FRONTEND_URL}/reset-password?token={token}"
|
|
user_name = f"{user.first_name} {user.last_name}".strip() or user.email
|
|
html, text = build_password_reset_email(reset_url, user_name)
|
|
|
|
await send_email(
|
|
to_email=user.email,
|
|
subject="Restablece tu contraseña — ServiceManager",
|
|
html_content=html,
|
|
text_content=text,
|
|
)
|
|
|
|
await AuditService.log(
|
|
db=db,
|
|
tenant_id=user.tenant_id,
|
|
user_id=user.id,
|
|
action="user.password_reset_requested",
|
|
resource_type="user",
|
|
resource_id=user.id,
|
|
new_values={"email": user.email},
|
|
)
|
|
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."}
|
|
|
|
|
|
@router.post("/reset-password", status_code=status.HTTP_200_OK)
|
|
async def reset_password(
|
|
data: ResetPasswordRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
Aplicar nueva contraseña usando el token recibido por email.
|
|
|
|
El token es de un solo uso: se elimina de Redis al usarse.
|
|
"""
|
|
from datetime import datetime
|
|
from redis.asyncio import from_url as redis_from_url
|
|
import uuid
|
|
|
|
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"
|
|
)
|
|
|
|
redis_key = f"{_RESET_KEY_PREFIX}{data.token}"
|
|
redis = redis_from_url(settings.REDIS_URL, decode_responses=True)
|
|
|
|
try:
|
|
user_id_str = await redis.get(redis_key)
|
|
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."
|
|
)
|
|
|
|
# Eliminar token inmediatamente (un solo uso)
|
|
await redis.delete(redis_key)
|
|
finally:
|
|
await redis.aclose()
|
|
|
|
# Buscar y actualizar usuario
|
|
user = await db.get(User, uuid.UUID(user_id_str))
|
|
if not user or not user.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Usuario no encontrado o inactivo"
|
|
)
|
|
|
|
user.password_hash = security.hash_password(data.new_password)
|
|
user.updated_at = datetime.utcnow()
|
|
await db.commit()
|
|
|
|
await AuditService.log(
|
|
db=db,
|
|
tenant_id=user.tenant_id,
|
|
user_id=user.id,
|
|
action="user.password_reset_completed",
|
|
resource_type="user",
|
|
resource_id=user.id,
|
|
)
|
|
await db.commit()
|
|
|
|
logger.info("Password reset completed", user_id=str(user.id))
|
|
return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."} |