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)