271 lines
8.1 KiB
Python
271 lines
8.1 KiB
Python
"""
|
|
Token Service - ServiceManagerWeb
|
|
|
|
Servicio para gesti├│n de refresh tokens persistentes.
|
|
"""
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, delete
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Optional
|
|
import uuid
|
|
import structlog
|
|
|
|
from app.models.refresh_token import RefreshToken
|
|
from app.models.user import User
|
|
from app.core.config import get_settings
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
settings = get_settings()
|
|
|
|
|
|
class TokenService:
|
|
"""
|
|
Servicio para gesti├│n de refresh tokens.
|
|
|
|
Proporciona m├®todos para crear, validar, revocar y limpiar
|
|
refresh tokens persistentes.
|
|
|
|
NOTA: Los tokens se almacenan directamente en BD (no hash)
|
|
ya que los JWTs son firmados y verificables.
|
|
"""
|
|
|
|
@staticmethod
|
|
async def create_refresh_token(
|
|
db: AsyncSession,
|
|
user: User,
|
|
refresh_token: str,
|
|
device_id: Optional[str] = None,
|
|
device_name: Optional[str] = None,
|
|
user_agent: Optional[str] = None,
|
|
ip_address: Optional[str] = None
|
|
) -> RefreshToken:
|
|
"""
|
|
Crear y persistir un refresh token.
|
|
|
|
Args:
|
|
db: Sesi├│n de base de datos
|
|
user: Usuario propietario del token
|
|
refresh_token: Token JWT generado (se almacena directamente)
|
|
device_id: ID ├║nico del dispositivo (UUID generado por cliente)
|
|
device_name: Nombre del dispositivo (ej: "Chrome en Windows")
|
|
user_agent: User agent completo del navegador
|
|
ip_address: IP del cliente
|
|
|
|
Returns:
|
|
RefreshToken creado
|
|
"""
|
|
# Calcular expiraci├│n
|
|
expires_at = datetime.now(timezone.utc) + timedelta(
|
|
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
|
|
)
|
|
|
|
# Crear registro - almacena JWT directamente (columna UNIQUE)
|
|
db_token = RefreshToken(
|
|
user_id=user.id,
|
|
token=refresh_token, # JWT almacenado directamente
|
|
device_id=device_id,
|
|
device_name=device_name,
|
|
user_agent=user_agent,
|
|
ip_address=ip_address,
|
|
expires_at=expires_at,
|
|
revoked=False,
|
|
usage_count=0
|
|
)
|
|
|
|
db.add(db_token)
|
|
await db.flush()
|
|
|
|
logger.info(
|
|
"Refresh token created",
|
|
user_id=str(user.id),
|
|
token_id=str(db_token.id),
|
|
device_name=device_name,
|
|
expires_at=expires_at.isoformat()
|
|
)
|
|
|
|
return db_token
|
|
|
|
@staticmethod
|
|
async def verify_refresh_token(
|
|
db: AsyncSession,
|
|
refresh_token: str
|
|
) -> Optional[RefreshToken]:
|
|
"""
|
|
Verificar que el refresh token exista y sea válido.
|
|
|
|
Busca el JWT directamente en la BD y verifica su estado.
|
|
|
|
Args:
|
|
db: Sesi├│n de base de datos
|
|
refresh_token: Token JWT a verificar
|
|
|
|
Returns:
|
|
RefreshToken si es válido, None si no existe o está revocado/expirado
|
|
"""
|
|
# Buscar token directamente en BD (sin hash)
|
|
query = select(RefreshToken).where(
|
|
RefreshToken.token == refresh_token
|
|
)
|
|
result = await db.execute(query)
|
|
db_token = result.scalar_one_or_none()
|
|
|
|
if not db_token:
|
|
logger.warning("Refresh token not found in database")
|
|
return None
|
|
|
|
# Verificar si es válido (usa property is_valid del modelo)
|
|
if not db_token.is_valid:
|
|
logger.warning(
|
|
"Invalid refresh token",
|
|
token_id=str(db_token.id),
|
|
revoked=db_token.revoked,
|
|
expired=db_token.is_expired
|
|
)
|
|
return None
|
|
|
|
# Actualizar estadísticas de uso
|
|
db_token.track_usage()
|
|
await db.flush()
|
|
|
|
logger.info(
|
|
"Refresh token verified and usage tracked",
|
|
token_id=str(db_token.id),
|
|
usage_count=db_token.usage_count
|
|
)
|
|
return db_token
|
|
|
|
@staticmethod
|
|
async def revoke_token(
|
|
db: AsyncSession,
|
|
refresh_token: str,
|
|
revoked_by_user_id: Optional[uuid.UUID] = None
|
|
) -> bool:
|
|
"""
|
|
Revocar un refresh token específico.
|
|
|
|
Args:
|
|
db: Sesi├│n de base de datos
|
|
refresh_token: Token JWT a revocar
|
|
revoked_by_user_id: ID del usuario que revoca (para auditoría)
|
|
|
|
Returns:
|
|
True si se revoc├│, False si no se encontr├│
|
|
"""
|
|
# Buscar token directamente (sin hash)
|
|
query = select(RefreshToken).where(
|
|
RefreshToken.token == refresh_token
|
|
)
|
|
result = await db.execute(query)
|
|
db_token = result.scalar_one_or_none()
|
|
|
|
if not db_token:
|
|
logger.warning("Refresh token not found for revocation")
|
|
return False
|
|
|
|
# Revocar usando m├®todo del modelo
|
|
db_token.revoke(revoked_by=revoked_by_user_id)
|
|
await db.flush()
|
|
|
|
logger.info(
|
|
"Refresh token revoked",
|
|
token_id=str(db_token.id),
|
|
revoked_by=str(revoked_by_user_id) if revoked_by_user_id else None
|
|
)
|
|
return True
|
|
|
|
@staticmethod
|
|
async def revoke_all_user_tokens(
|
|
db: AsyncSession,
|
|
user_id: uuid.UUID,
|
|
revoked_by_user_id: Optional[uuid.UUID] = None
|
|
) -> int:
|
|
"""
|
|
Revocar todos los tokens activos de un usuario.
|
|
|
|
Útil para logout en todos los dispositivos.
|
|
|
|
Args:
|
|
db: Sesi├│n de base de datos
|
|
user_id: ID del usuario
|
|
revoked_by_user_id: ID del usuario que ejecuta la revocación (para auditoría)
|
|
|
|
Returns:
|
|
N├║mero de tokens revocados
|
|
"""
|
|
# Buscar todos los tokens activos del usuario
|
|
query = select(RefreshToken).where(
|
|
RefreshToken.user_id == user_id,
|
|
RefreshToken.revoked == False
|
|
)
|
|
result = await db.execute(query)
|
|
tokens = result.scalars().all()
|
|
|
|
count = 0
|
|
for token in tokens:
|
|
token.revoke(revoked_by=revoked_by_user_id)
|
|
count += 1
|
|
|
|
await db.flush()
|
|
|
|
logger.info(
|
|
"All user tokens revoked",
|
|
user_id=str(user_id),
|
|
count=count,
|
|
revoked_by=str(revoked_by_user_id) if revoked_by_user_id else None
|
|
)
|
|
return count
|
|
|
|
@staticmethod
|
|
async def cleanup_expired_tokens(
|
|
db: AsyncSession
|
|
) -> int:
|
|
"""
|
|
Eliminar tokens expirados de la base de datos.
|
|
|
|
Tarea de mantenimiento para limpiar tokens antiguos.
|
|
|
|
Args:
|
|
db: Sesi├│n de base de datos
|
|
|
|
Returns:
|
|
N├║mero de tokens eliminados
|
|
"""
|
|
# Eliminar tokens expirados hace más de 7 días
|
|
cutoff_date = datetime.now(timezone.utc) - timedelta(days=7)
|
|
|
|
query = delete(RefreshToken).where(
|
|
RefreshToken.expires_at < cutoff_date
|
|
)
|
|
result = await db.execute(query)
|
|
await db.flush()
|
|
|
|
deleted_count = result.rowcount
|
|
|
|
logger.info("Expired tokens cleaned up", count=deleted_count)
|
|
return deleted_count
|
|
|
|
@staticmethod
|
|
async def get_user_tokens(
|
|
db: AsyncSession,
|
|
user_id: uuid.UUID
|
|
) -> list[RefreshToken]:
|
|
"""
|
|
Obtener todos los tokens activos de un usuario.
|
|
|
|
Args:
|
|
db: Sesi├│n de base de datos
|
|
user_id: ID del usuario
|
|
|
|
Returns:
|
|
Lista de RefreshTokens activos
|
|
"""
|
|
query = select(RefreshToken).where(
|
|
RefreshToken.user_id == user_id,
|
|
RefreshToken.revoked == False,
|
|
RefreshToken.expires_at > datetime.utcnow()
|
|
).order_by(RefreshToken.created_at.desc())
|
|
|
|
result = await db.execute(query)
|
|
return list(result.scalars().all())
|