844 lines
27 KiB
Python
844 lines
27 KiB
Python
"""
|
||
Authentication Endpoints - ServiceManagerWeb
|
||
|
||
Endpoints para autenticación y autorización
|
||
"""
|
||
|
||
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
|
||
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.core.limiter import limiter
|
||
|
||
# Nombres de cookie por tipo de usuario
|
||
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."""
|
||
return "client_access_token" if role in CLIENT_ROLES else "internal_access_token"
|
||
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)
|
||
@limiter.limit("10/minute")
|
||
async def login(
|
||
login_data: LoginRequest,
|
||
request: Request,
|
||
response: Response,
|
||
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 - 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 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)
|
||
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 - 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
|
||
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)
|
||
|
||
# Cookie diferenciada por rol para aislar sesiones entre frontends
|
||
cookie_name = _cookie_name_for_role(
|
||
user.role.value if hasattr(user.role, "value") else user.role
|
||
)
|
||
response.set_cookie(
|
||
key=cookie_name,
|
||
value=access_token,
|
||
httponly=True,
|
||
secure=settings.is_production(),
|
||
samesite="strict" if settings.is_production() else "lax",
|
||
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||
)
|
||
|
||
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),
|
||
"tenant_slug": tenant.slug if tenant else 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(
|
||
response: Response,
|
||
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"])
|
||
|
||
# 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"}
|
||
|
||
|
||
@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,
|
||
"tenant_slug": user.tenant.slug 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)
|
||
@limiter.limit("5/minute")
|
||
async def forgot_password(
|
||
request: Request,
|
||
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)
|
||
@limiter.limit("5/minute")
|
||
async def reset_password(
|
||
request: Request,
|
||
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."} |