Spaces:
Paused
Paused
File size: 12,236 Bytes
83bdb4a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 | 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) |