Saltar a contenido

🛠 Unidad ISS-17 · Login OPEN — capa 🛠 CONSTRUIR

✅ GATE de la unidad

Capa Página Para qué
🧠 Aprender — no está en la fuente comprender, explicar y relacionar
🛠 Construir esta página ejecutar, programar y verificar
✅ GATE Condiciones de cierre condición para pasar al bloque siguiente

Esta es la guía ejecutable. El cuerpo de abajo es el ISS técnico verbatim: comandos, rutas, versiones, verificaciones y criterios, sin simplificar.

Guía de estudio. Esta unidad no tiene cuaderno en material/django/iss/ISS-17/aprendizaje/. La página publica solo el ISS técnico.


ISS-17 — Login OPEN

Objetivo

Demostrar identidad por username o email y entregar access más refresh.

Este ISS abre el primer acceso de la Fase II: OPEN. No pide JWT. El segundo acceso, JWT con token, es el perfil (ISS-20). El tercero, JWT con token + RBAC, cierra el negocio (ISS-22) y la matriz (ISS-23).

Requisitos

  • ISS-16 superado.
  • Login no puede exigir un access token: todavía no existe.

Construcción

apps/security/serializers.py y apps/security/auth_views.py no existen. Se crean ahora. En este ISS el archivo de vistas solo tiene el login y los helpers que refresh y logout van a reutilizar. Esas dos clases se agregan al final en el ISS-18 y el ISS-19.

cat > apps/security/serializers.py <<'EOF'
from rest_framework import serializers


class LoginSerializer(serializers.Serializer):
    username = serializers.CharField(required=False, allow_blank=False)
    email = serializers.EmailField(required=False)
    password = serializers.CharField(write_only=True)

    def validate(self, attrs):
        if not attrs.get("username") and not attrs.get("email"):
            raise serializers.ValidationError(
                "Informe username o email junto con password."
            )
        return attrs


class RefreshSerializer(serializers.Serializer):
    refresh = serializers.CharField()


class LogoutSerializer(serializers.Serializer):
    refresh = serializers.CharField()


class TokenPairSerializer(serializers.Serializer):
    access = serializers.CharField()
    refresh = serializers.CharField()
    token_type = serializers.CharField()
    access_expires_in = serializers.IntegerField()
EOF
cat > apps/security/auth_views.py <<'EOF'
import uuid

from django.conf import settings
from django.contrib.auth import get_user_model
from django.utils import timezone
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.tokens import AccessToken

from apps.common.status import RecordStatus
from apps.security.models import RefreshToken
from apps.security.serializers import LoginSerializer, TokenPairSerializer
from apps.security.tokens import generate_refresh_value, hash_token

User = get_user_model()


def token_payload(user, raw_refresh):
    access = AccessToken.for_user(user)
    lifetime = int(settings.SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"].total_seconds())
    return {
        "access": str(access),
        "refresh": raw_refresh,
        "token_type": "Bearer",
        "access_expires_in": lifetime,
    }


def issue_refresh(user, family_id=None):
    raw_refresh = generate_refresh_value()
    stored = RefreshToken.objects.create(
        user=user,
        token_hash=hash_token(raw_refresh),
        family_id=family_id or uuid.uuid4(),
        expires_at=RefreshToken.default_expiry(),
        status=RecordStatus.ACTIVE,
    )
    return stored, raw_refresh


class LoginAPIView(APIView):
    authentication_classes = []
    permission_classes = [AllowAny]

    def post(self, request):
        serializer = LoginSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        data = serializer.validated_data
        user = _find_user(data.get("username"), data.get("email"))
        invalid = Response(
            {"detail": "Credenciales inválidas."},
            status=status.HTTP_401_UNAUTHORIZED,
        )
        if user is None or user.status != RecordStatus.ACTIVE:
            return invalid
        if not user.check_password(data["password"]):
            return invalid
        _stored, raw_refresh = issue_refresh(user)
        return Response(token_payload(user, raw_refresh), status=status.HTTP_200_OK)


def _find_user(username, email):
    if username and email:
        return User.objects.filter(username=username, email__iexact=email).first()
    if username:
        return User.objects.filter(username=username).first()
    if email:
        return User.objects.filter(email__iexact=email).first()
    return None
EOF

extend_schema se agrega en el ISS-14 ya pasó; si spectacular está instalado, el ISS-24 exige el decorador. Puede añadirlo encima de post cuando valide el esquema. Login es APIView con authentication_classes = [] y permission_classes = [AllowAny].

cat > apps/security/urls.py <<'EOF'
from django.urls import path

from apps.security.auth_views import LoginAPIView

urlpatterns = [
    path("auth/login/", LoginAPIView.as_view(), name="auth-login"),
]
EOF
ARCHIVO: config/urls.py

DEBAJO DE:
    path("api/", include("apps.sale.urls")),

AGREGAR:
    path("api/", include("apps.security.urls")),

auth/login/ vive en la app security. El proyecto solo agrega otro include bajo api/. Refresh, logout, perfil y el CRUD de la matriz se agregan a este mismo urlpatterns en los ISS siguientes. No se abre un segundo urls.py.

POST /api/auth/login/
        │
        ▼
username o email
        │
        ▼
¿user active?
        │
        ▼
check_password()
        │
        ├── no → 401 "Credenciales inválidas."
        └── sí → AccessToken.for_user + hash del refresh

Usuario inexistente, inactivo o clave mala responden el mismo 401. No se dice cuál de los tres falló.

La prueba se agrega al final de apps/security/tests.py, que desde el ISS-03 solo tiene DatabaseSelectionTests. Login es OPEN: el test no llama force_authenticate ni grant. Refresh, logout y perfil agregan métodos a esta misma clase en sus ISS.

ARCHIVO: apps/security/tests.py

ENCIMA DE:
from config.database import database_from_environ

AGREGAR:
from rest_framework import status
from rest_framework.test import APITestCase

from apps.common.status import RecordStatus
from apps.common.testing import PASSWORD, create_active_user
from apps.security.models import RefreshToken
from apps.security.tokens import hash_token

AL FINAL DEL ARCHIVO, AGREGAR:

class AuthFlowTests(APITestCase):
    def setUp(self):
        self.user = create_active_user()

    def test_login_with_username_and_email(self):
        by_username = self.client.post(
            "/api/auth/login/",
            {"username": "ana", "password": PASSWORD},
            format="json",
        )
        self.assertEqual(by_username.status_code, status.HTTP_200_OK)
        self.assertIn("access", by_username.data)
        self.assertIn("refresh", by_username.data)
        self.assertNotIn("password", by_username.data)
        stored = RefreshToken.objects.get()
        self.assertEqual(stored.token_hash, hash_token(by_username.data["refresh"]))

        by_email = self.client.post(
            "/api/auth/login/",
            {"email": "ANA@storelab.test", "password": PASSWORD},
            format="json",
        )
        self.assertEqual(by_email.status_code, status.HTTP_200_OK)

    def test_inactive_user_and_bad_password_are_401(self):
        self.user.status = RecordStatus.INACTIVE
        self.user.save()
        inactive = self.client.post(
            "/api/auth/login/",
            {"username": "ana", "password": PASSWORD},
            format="json",
        )
        self.assertEqual(inactive.status_code, status.HTTP_401_UNAUTHORIZED)

apps.common.testing todavía no existe en el ISS-17 si se sigue el orden estricto: create_active_user se escribe en el ISS-22. Para poder ejecutar este test aquí, cree en este ISS solo create_active_user y PASSWORD dentro de apps/common/testing.py. El ISS-22 completa ese archivo con grant y grant_crud sin borrar lo anterior.

cat > apps/common/testing.py <<'EOF'
from apps.common.status import RecordStatus
from apps.security.models import User

PASSWORD = "StoreLab-Test-1"


def create_active_user(username="ana", email="ana@storelab.test", password=PASSWORD):
    return User.objects.create_user(
        username=username,
        email=email,
        password=password,
        status=RecordStatus.ACTIVE,
    )
EOF
python manage.py test apps.security.tests.AuthFlowTests.test_login_with_username_and_email apps.security.tests.AuthFlowTests.test_inactive_user_and_bad_password_are_401

extend_schema documenta LoginSerializer y TokenPairSerializer.

Explicación

Es APIView porque el flujo no es un alta de User. Crear el usuario es otro endpoint, y exige RBAC. Login consulta users y escribe refresh_tokens: es OPEN, y aun así toca tablas de seguridad.

Vaciar authentication_classes evita que un Bearer vencido, enviado por costumbre, rechace el login antes de leer el cuerpo.

El access sale de SimpleJWT. No se persiste. Dura 60 minutos (ACCESS_TOKEN_LIFETIME).

Cómo probarlo

Capa HTTP, acceso OPEN. El test crea el usuario con create_active_user y llama el login sin force_authenticate. La base de prueba aísla la fila de refresh_tokens: RefreshToken.objects.get() ve solo la de este test.

python manage.py test apps.security.tests.AuthFlowTests.test_login_with_username_and_email
python manage.py test apps.security.tests.AuthFlowTests.test_inactive_user_and_bad_password_are_401
        │
        ▼
POST /api/auth/login/          sin Authorize
        │
        ├── username + password          200   access y refresh; el hash queda en refresh_tokens
        ├── email en otro caso           200
        ├── usuario inactive             401
        └── clave distinta               401   el mismo mensaje

Swagger ya existe desde el ISS-14. Este POST se pulsa en esta capa, en OPEN, sin Authorize. El access se copia recién en el ISS-20, para el perfil, y el botón Authorize se registra en el ISS-24.

python manage.py runserver
        │
        ▼
swagger-ui  →  POST /api/auth/login/     sin Authorize
        │
        ├── usuario active + clave buena     200   access y refresh
        └── inactive o clave mala            401

Criterios de aceptación

  • AC-17-01: username y password correctos responden 200 con access y refresh.
  • AC-17-02: el email en otro caso también entra.
  • AC-17-03: usuario inactive responde 401.
  • AC-17-04: clave incorrecta responde 401.
  • AC-17-05: el JSON no trae password.

Verificación

AuthFlowTests.test_login_with_username_and_email y test_inactive_user_and_bad_password_are_401.

Evidencias

  • EVI-17-01: tests PASS.

GATE

AC Verificación Evidencia Resultado
AC-17-01 login username EVI-17-01 PASS
AC-17-02 login email EVI-17-01 PASS
AC-17-03 inactive EVI-17-01 PASS
AC-17-04 clave mala EVI-17-01 PASS
AC-17-05 sin password EVI-17-01 PASS

✅ GATE de la unidad ISS-17 — este bloque no añade ningún criterio nuevo.

Las condiciones de cierre son las de esta misma página:

Con el GATE en verde queda habilitado el bloque siguiente de la ruta.

Navegación de la ruta: ← ISS-16 · 🛠 Construir · ↑ Ruta Django · → ISS-18 · 🛠 Construir