File size: 3,977 Bytes
67aa1ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Seguridad: hashing de contraseñas, emisión/validación de JWT y
dependencias de autenticación para FastAPI.
"""

from __future__ import annotations

from datetime import timedelta
from typing import Annotated

import bcrypt
import jwt
from fastapi import Depends
from fastapi.security import OAuth2PasswordBearer

from app.core.config import settings
from app.database import collections
from app.database.mongodb import get_collection
from app.database.objectid import to_object_id
from app.exceptions import AuthenticationError, AuthorizationError
from app.models.common import utcnow
from app.models.user import User

# bcrypt admite como máximo 72 bytes de contraseña.
_BCRYPT_MAX_BYTES = 72

oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_PREFIX}/auth/login")


# ---------------------------------------------------------------------------
# Contraseñas
# ---------------------------------------------------------------------------


def _encode_password(password: str) -> bytes:
    """Codifica la contraseña a bytes, truncando al límite de bcrypt."""
    return password.encode("utf-8")[:_BCRYPT_MAX_BYTES]


def hash_password(password: str) -> str:
    """Devuelve el hash bcrypt de una contraseña en texto plano."""
    return bcrypt.hashpw(_encode_password(password), bcrypt.gensalt()).decode("utf-8")


def verify_password(password: str, hashed_password: str) -> bool:
    """Comprueba si una contraseña coincide con su hash."""
    try:
        return bcrypt.checkpw(
            _encode_password(password), hashed_password.encode("utf-8")
        )
    except (ValueError, TypeError):
        return False


# ---------------------------------------------------------------------------
# JWT
# ---------------------------------------------------------------------------


def create_access_token(subject: str, expires_minutes: int | None = None) -> str:
    """Crea un token de acceso firmado para el ``subject`` indicado."""
    minutes = expires_minutes or settings.ACCESS_TOKEN_EXPIRE_MINUTES
    now = utcnow()
    payload = {
        "sub": subject,
        "iat": now,
        "exp": now + timedelta(minutes=minutes),
    }
    return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)


def decode_access_token(token: str) -> str:
    """Decodifica un token y devuelve su ``subject`` (id de usuario)."""
    try:
        payload = jwt.decode(
            token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
        )
    except jwt.PyJWTError as exc:
        raise AuthenticationError("Token inválido o expirado.") from exc

    subject = payload.get("sub")
    if not subject:
        raise AuthenticationError("Token inválido: falta el sujeto.")
    return subject


# ---------------------------------------------------------------------------
# Dependencias
# ---------------------------------------------------------------------------


async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> User:
    """Resuelve el usuario autenticado a partir del token Bearer."""
    user_id = decode_access_token(token)
    collection = get_collection(collections.USERS)
    document = await collection.find_one({"_id": to_object_id(user_id)})
    if document is None:
        raise AuthenticationError("El usuario del token ya no existe.")
    return User(**document)


async def get_current_active_user(
    current_user: Annotated[User, Depends(get_current_user)],
) -> User:
    """Garantiza que el usuario autenticado está activo."""
    if not current_user.is_active:
        raise AuthorizationError("La cuenta está desactivada.")
    return current_user


async def get_current_superuser(
    current_user: Annotated[User, Depends(get_current_active_user)],
) -> User:
    """Garantiza que el usuario autenticado es superusuario."""
    if not current_user.is_superuser:
        raise AuthorizationError("Se requieren privilegios de administrador.")
    return current_user