crowdata / app /auth /manager.py
YOSOYYONOSOYOTRO's picture
Upload folder using huggingface_hub
83bdb4a verified
Raw
History Blame Contribute Delete
12.2 kB
import uuid
import asyncio
import logging
import json
import secrets
import re
from typing import Optional
from datetime import datetime, timedelta
from fastapi import Depends, Request, HTTPException
from fastapi_users import BaseUserManager, UUIDIDMixin
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.auth.models import User, RefreshToken
from app.auth.db import get_user_db
from app.database import get_db, AsyncSessionLocal
from app.config import get_settings
from app.utils.security import mask_email
from app.utils.encryption import encrypt_backup_codes, decrypt_backup_codes
logger = logging.getLogger(__name__)
settings = get_settings()
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
reset_password_token_secret = settings.reset_password_token_secret or settings.secret_key
verification_token_secret = settings.verification_token_secret or settings.secret_key
async def on_after_register(self, user: User, request: Optional[Request] = None):
logger.info(f"User {user.id} ({mask_email(user.email)}) has registered.")
try:
from app.utils.email_service import send_welcome_email
asyncio.create_task(send_welcome_email(
to_email=user.email,
full_name=user.full_name,
))
except Exception as e:
logger.warning(f"Failed to send welcome email to {mask_email(user.email)}: {e}")
async def on_after_forgot_password(
self, user: User, token: str, request: Optional[Request] = None
):
logger.info(f"User {user.id} has forgot their password. Reset token generated.")
async def on_after_request_verify(
self, user: User, token: str, request: Optional[Request] = None
):
logger.info(f"Verification requested for user {user.id}.")
# ─── Password Validation ───
async def validate_password(self, password: str, user: Optional[User] = None) -> None:
"""Validate password strength using zxcvbn and check against HIBP."""
# Minimum length check
if len(password) < 12:
raise ValueError("La contraseña debe tener al menos 12 caracteres")
# Check for common patterns - only flag long sequences (5+ chars)
if re.search(r'(.)\1{2,}', password): # 3+ repeated characters
raise ValueError("La contraseña no debe contener caracteres repetidos (3 o más)")
# Check for sequential patterns (5+ chars) - e.g., abcde, 12345, etc.
sequential_patterns = [
'abcde', 'bcdef', 'cdefg', 'defgh', 'efghi', 'fghij', 'ghijk', 'hijkl', 'ijklm', 'jklmn',
'klmno', 'lmnop', 'mnopq', 'nopqr', 'opqrs', 'pqrst', 'qrstu', 'rstuv', 'stuvw', 'tuvwx',
'uvwxy', 'vwxy', 'wxyz',
'01234', '12345', '23456', '34567', '45678', '56789'
]
password_lower = password.lower()
for seq in sequential_patterns:
if seq in password_lower:
raise ValueError("La contraseña no debe contener secuencias comunes (5+ caracteres)")
# zxcvbn score check (minimum score 3 = good)
try:
from zxcvbn import zxcvbn
result = zxcvbn(password)
if result['score'] < 3:
raise ValueError(f"Contraseña muy débil. Mejora: {'; '.join(result['feedback']['suggestions'])}")
# Check against HIBP (k-anonymity)
import httpx
import hashlib
sha1 = hashlib.sha1(password.encode()).hexdigest().upper()
prefix, suffix = sha1[:5], sha1[5:]
try:
with httpx.Client(timeout=5.0) as client:
resp = client.get(f"https://api.pwnedpasswords.com/range/{prefix}")
if resp.status_code == 200:
if suffix in resp.text:
raise ValueError("Esta contraseña ha sido filtrada en brechas de seguridad conocidas. Usa otra.")
except httpx.TimeoutException:
logger.warning("HIBP check timeout, skipping")
except Exception as e:
logger.warning(f"HIBP check failed: {e}")
except ImportError:
# zxcvbn not installed, skip advanced checks
logger.warning("zxcvbn not installed, skipping advanced password checks")
pass
async def authenticate(self, credentials):
"""Override authenticate to add account lockout and password validation."""
from fastapi_users.exceptions import InvalidPasswordException, UserNotExists, UserInactive
# Get user by email
try:
user = await self.user_db.get_by_email(credentials.username)
except Exception:
user = None
if not user:
raise UserNotExists()
if not user.is_active:
raise UserInactive()
# Check account lockout
if user.locked_until and user.locked_until > datetime.utcnow():
remaining = int((user.locked_until - datetime.utcnow()).total_seconds() / 60)
raise HTTPException(
status_code=403,
detail=f"Cuenta bloqueada temporalmente. Intente en {remaining} minutos."
)
# Verify password
is_valid, new_hash = self.password_helper.verify_and_update(credentials.password, user.hashed_password)
if not is_valid:
# Increment failed attempts
user.failed_login_attempts += 1
if user.failed_login_attempts >= 5:
user.locked_until = datetime.utcnow() + timedelta(minutes=15)
logger.warning(f"Account locked for user {user.id} ({mask_email(user.email)}) after 5 failed attempts")
await self.user_db.update(user)
raise InvalidPasswordException()
# Update hash if it was upgraded (e.g., bcrypt rounds increased)
if new_hash:
user.hashed_password = new_hash
await self.user_db.update(user)
# Successful login - reset failed attempts and lock
if user.failed_login_attempts > 0 or user.locked_until:
user.failed_login_attempts = 0
user.locked_until = None
await self.user_db.update(user)
# Verify email is verified
if not user.is_verified:
raise HTTPException(
status_code=403,
detail="Tu cuenta no ha sido verificada. Revisa tu email para verificar tu cuenta."
)
return user
# ─── Refresh Token Methods ───
async def create_refresh_token(
self,
user: User,
request: Optional[Request] = None,
db: Optional[AsyncSession] = None,
) -> str:
"""Crear nuevo refresh token y almacenar hash en BD."""
if db is None:
async for session in get_db():
return await self._create_refresh_token_internal(user, request, session)
return await self._create_refresh_token_internal(user, request, db)
async def _create_refresh_token_internal(
self,
user: User,
request: Optional[Request],
db: AsyncSession,
) -> str:
# Revocar tokens anteriores del usuario (rotación)
await self.revoke_user_refresh_tokens(user.id, db)
# Generar nuevo token
raw_token = RefreshToken.generate_token()
token_hash = RefreshToken.hash_token(raw_token)
expires_at = datetime.utcnow() + timedelta(days=settings.refresh_token_expire_days)
# Extraer info de request
user_agent = request.headers.get("user-agent") if request else None
ip_address = request.client.host if request and request.client else None
refresh_token = RefreshToken(
user_id=user.id,
token_hash=token_hash,
expires_at=expires_at,
user_agent=user_agent,
ip_address=ip_address,
)
db.add(refresh_token)
await db.commit()
logger.info(f"Refresh token created for user {user.id}")
return raw_token
async def verify_refresh_token(
self,
token: str,
db: Optional[AsyncSession] = None,
) -> Optional[User]:
"""Verificar refresh token y retornar usuario si válido."""
if db is None:
async for session in get_db():
return await self._verify_refresh_token_internal(token, session)
return await self._verify_refresh_token_internal(token, db)
async def _verify_refresh_token_internal(
self,
token: str,
db: AsyncSession,
) -> Optional[User]:
token_hash = RefreshToken.hash_token(token)
result = await db.execute(
select(RefreshToken).where(
RefreshToken.token_hash == token_hash,
RefreshToken.revoked == False,
RefreshToken.expires_at > datetime.utcnow(),
)
)
refresh_token = result.scalar_one_or_none()
if not refresh_token:
return None
# Obtener usuario
user = await self.user_db.get(refresh_token.user_id)
if not user or not user.is_active:
return None
return user
async def revoke_refresh_token(
self,
token: str,
db: Optional[AsyncSession] = None,
) -> bool:
"""Revocar un refresh token específico."""
if db is None:
async for session in get_db():
return await self._revoke_refresh_token_internal(token, session)
return await self._revoke_refresh_token_internal(token, db)
async def _revoke_refresh_token_internal(
self,
token: str,
db: AsyncSession,
) -> bool:
token_hash = RefreshToken.hash_token(token)
result = await db.execute(
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
)
refresh_token = result.scalar_one_or_none()
if not refresh_token:
return False
refresh_token.revoked = True
await db.commit()
return True
async def revoke_user_refresh_tokens(
self,
user_id: uuid.UUID,
db: Optional[AsyncSession] = None,
) -> int:
"""Revocar TODOS los refresh tokens de un usuario (logout everywhere)."""
if db is None:
async for session in get_db():
return await self._revoke_user_refresh_tokens_internal(user_id, session)
return await self._revoke_user_refresh_tokens_internal(user_id, db)
async def _revoke_user_refresh_tokens_internal(
self,
user_id: uuid.UUID,
db: AsyncSession,
) -> int:
result = await db.execute(
select(RefreshToken).where(
RefreshToken.user_id == user_id,
RefreshToken.revoked == False,
)
)
tokens = result.scalars().all()
for token in tokens:
token.revoked = True
await db.commit()
return len(tokens)
async def create_user(self, user_create, safe: bool = False, request: Request | None = None):
"""Override to validate password strength on registration."""
# Validate password strength
self.validate_password(user_create.password)
# Call parent create_user
return await super().create_user(user_create, safe, request)
async def update_user(self, user_update, user, safe: bool = False, request: Request | None = None):
"""Override to validate password on update."""
if hasattr(user_update, 'password') and user_update.password:
self.validate_password(user_update.password)
return await super().update_user(user_update, user, safe, request)
async def get_user_manager(user_db=Depends(get_user_db)):
yield UserManager(user_db)