# Autenticación y Autorización — Implementaciones Completas

## Refresh Token — Rotación segura

```python
# Tabla de refresh tokens en BD (para invalidación)
class RefreshToken(Base):
    __tablename__ = "refresh_tokens"
    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    usuario_id = Column(UUID(as_uuid=True), ForeignKey("usuarios.id"), nullable=False)
    token_hash = Column(String(64), nullable=False, unique=True)  # SHA-256
    expires_at = Column(DateTime(timezone=True), nullable=False)
    revocado_en = Column(DateTime(timezone=True), nullable=True)
    ip_address = Column(String(45), nullable=True)

import hashlib

@router.post("/auth/refresh", response_model=TokenResponse)
async def refresh_token_endpoint(
    body: RefreshRequest,
    db: AsyncSession = Depends(get_db),
    request: Request = None,
) -> TokenResponse:
    try:
        payload = verificar_token(body.refresh_token, tipo_esperado="refresh")
    except CredencialesInvalidas:
        raise HTTPException(status_code=401, detail="Refresh token inválido")

    # Verificar contra BD (detección de reuso de tokens robados)
    token_hash = hashlib.sha256(body.refresh_token.encode()).hexdigest()
    rt = await db.execute(
        select(RefreshToken).where(
            RefreshToken.token_hash == token_hash,
            RefreshToken.revocado_en.is_(None),
            RefreshToken.expires_at > datetime.now(timezone.utc),
        )
    )
    rt = rt.scalar_one_or_none()
    if rt is None:
        # Posible reuso — revocar TODOS los tokens del usuario (token family)
        await revocar_todos_tokens_usuario(db, payload["sub"])
        raise HTTPException(status_code=401, detail="Refresh token inválido o reusado")

    # Rotar: revocar el anterior, emitir uno nuevo
    rt.revocado_en = datetime.now(timezone.utc)
    nuevo_refresh = crear_refresh_token(payload["sub"])
    db.add(RefreshToken(
        usuario_id=payload["sub"],
        token_hash=hashlib.sha256(nuevo_refresh.encode()).hexdigest(),
        expires_at=datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS),
        ip_address=request.client.host if request else None,
    ))
    await db.commit()

    usuario = await db.get(Usuario, payload["sub"])
    return TokenResponse(
        access_token=crear_access_token({"sub": str(usuario.id)}),
        refresh_token=nuevo_refresh,
        token_type="bearer",
    )
```

---

## OAuth2 / OIDC — Integración con proveedores externos

```python
# Con authlib — pip install authlib
from authlib.integrations.starlette_client import OAuth
from starlette.config import Config

config = Config(".env")
oauth = OAuth(config)

# Registro de proveedor (ej: Azure AD / Microsoft Entra)
oauth.register(
    name="microsoft",
    client_id=os.environ["AZURE_CLIENT_ID"],
    client_secret=os.environ["AZURE_CLIENT_SECRET"],
    server_metadata_url=(
        f"https://login.microsoftonline.com/{os.environ['AZURE_TENANT_ID']}"
        "/v2.0/.well-known/openid-configuration"
    ),
    client_kwargs={"scope": "openid email profile"},
)

@router.get("/auth/microsoft/login")
async def login_microsoft(request: Request):
    redirect_uri = str(request.url_for("auth_microsoft_callback"))
    return await oauth.microsoft.authorize_redirect(request, redirect_uri)

@router.get("/auth/microsoft/callback")
async def auth_microsoft_callback(request: Request, db: AsyncSession = Depends(get_db)):
    try:
        token = await oauth.microsoft.authorize_access_token(request)
    except Exception as e:
        raise HTTPException(status_code=400, detail="Autenticación fallida")

    userinfo = token.get("userinfo") or await oauth.microsoft.userinfo(token=token)
    email = userinfo["email"]

    usuario = await obtener_o_crear_usuario_oauth(db, email, userinfo)
    await db.commit()

    access_token = crear_access_token({"sub": str(usuario.id), "email": email})
    return RedirectResponse(
        f"{os.environ['FRONTEND_URL']}/auth/callback?token={access_token}"
    )
```

---

## Passwords — Hashing y validación de fortaleza

```python
from passlib.context import CryptContext

pwd_context = CryptContext(
    schemes=["bcrypt"],
    deprecated="auto",
    bcrypt__rounds=12,
)

def hash_password(password: str) -> str:
    return pwd_context.hash(password)

def verificar_password(plain: str, hashed: str) -> bool:
    return pwd_context.verify(plain, hashed)

# Validación de fortaleza de password
import re

def validar_fortaleza_password(password: str) -> list[str]:
    """Retorna lista de errores. Lista vacía = password válido."""
    errores = []
    if len(password) < 12:
        errores.append("Mínimo 12 caracteres")
    if not re.search(r"[A-Z]", password):
        errores.append("Al menos una mayúscula")
    if not re.search(r"[a-z]", password):
        errores.append("Al menos una minúscula")
    if not re.search(r"\d", password):
        errores.append("Al menos un número")
    if not re.search(r"[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]", password):
        errores.append("Al menos un carácter especial")
    return errores
```

---

## MFA — Autenticación Multifactor (TOTP)

```python
# pip install pyotp qrcode
import pyotp
import qrcode
from io import BytesIO
import base64

def generar_secreto_totp() -> str:
    return pyotp.random_base32()

def generar_qr_totp(email: str, secreto: str, emisor: str = "MiEmpresa") -> str:
    """Genera imagen QR en base64 para escanear con Google Authenticator."""
    totp = pyotp.TOTP(secreto)
    uri = totp.provisioning_uri(name=email, issuer_name=emisor)

    qr = qrcode.make(uri)
    buffer = BytesIO()
    qr.save(buffer, format="PNG")
    return base64.b64encode(buffer.getvalue()).decode()

def verificar_totp(secreto: str, codigo: str) -> bool:
    """Verifica un código TOTP. Permite ventana de +/-1 intervalo (30s)."""
    totp = pyotp.TOTP(secreto)
    return totp.verify(codigo, valid_window=1)

# Endpoint de activación MFA
@router.post("/auth/mfa/activar")
async def activar_mfa(
    usuario: Usuario = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    secreto = generar_secreto_totp()
    usuario.mfa_secreto_pendiente = secreto
    await db.commit()
    return {
        "secreto": secreto,
        "qr_code": generar_qr_totp(usuario.email, secreto),
    }

@router.post("/auth/mfa/confirmar")
async def confirmar_mfa(
    body: MFAConfirmRequest,
    usuario: Usuario = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    if not usuario.mfa_secreto_pendiente:
        raise HTTPException(400, "No hay MFA pendiente de activar")
    if not verificar_totp(usuario.mfa_secreto_pendiente, body.codigo):
        raise HTTPException(400, "Código TOTP inválido")
    usuario.mfa_secreto = usuario.mfa_secreto_pendiente
    usuario.mfa_secreto_pendiente = None
    usuario.mfa_activo = True
    await db.commit()
    return {"mensaje": "MFA activado correctamente"}
```

---

## Headers de seguridad

```python
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware

app.add_middleware(HTTPSRedirectMiddleware)
app.add_middleware(TrustedHostMiddleware, allowed_hosts=["miapp.com.mx", "*.miapp.com.mx"])

@app.middleware("http")
async def security_headers(request, call_next):
    response = await call_next(request)
    response.headers["X-Content-Type-Options"] = "nosniff"
    response.headers["X-Frame-Options"] = "DENY"
    response.headers["X-XSS-Protection"] = "1; mode=block"
    response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
    response.headers["Content-Security-Policy"] = "default-src 'self'"
    return response
```
