Настройка аутентификации JWT в новом проекте Django

Данная статья является сборкой-компиляцией нескольких (основано на первой) статей, как результат моих изучений по теме jwt аутентификации в джанге со всем вытекающим. Так и не удалось (по крайней мере в рунете) найти нормальную статью, в которой рассказывается от этапа создания проекта, startproject, прикручивание jwt аутентификации.

Добротно исследовав, отдаю на людской суд.

Ссылки на пользуемые статьи прилагаются:

  1. https://thinkster.io/tutorials/django-json-api/authentication

  2. https://simpleisbetterthancomplex.com/tutorial/2018/12/19/how-to-use-jwt-authentication-with-django-rest-framework.html

  3. https://www.django-rest-framework.org/api-guide/authentication/

  4. https://medium.com/django-rest/django-rest-framework-jwt-authentication-94bee36f2af8


Настройка аутентификации JWT

Django поставляется с системой аутентификации, основанной на сеансах, и это работает из коробки. Это включает в себя все модели (models), представления (views) и шаблоны (templates), которые могут быть нужны вам для создания и дальнейшего логина пользователей. Но вот в чем загвоздка: стандартная система аутентификации Django работает только с традиционным 'запрос-ответ' циклом HTML.

Что мы имеем ввиду под «традиционным 'запрос-ответ' циклом HTML»? Исторически, когда пользователь хотел выполнить какое-то действие (например, создать новый аккаунт), он заполнял определенную форму в браузере. Далее, когда он кликал на кнопку «Отправить», браузер формировал запрос - который включал в себя данные, введенные пользователем - и отправлял на сервер, сервер обрабатывал запрос, и отвечал либо HTML страницей, либо редиректом на новую страницу. Это то, что мы имеем ввиду, когда говорим о «полном обновлении страницы».

Почему важно знать, что встроенная система аутентификации Django работает только с традиционным 'запрос-ответ' циклом HTML? Потому что клиент, для которого мы создадим данный API, не придерживается этого цикла. Вместо этого, клиент будет ожидать, что сервер вернет JSON, вместе обычного HTML. Возвращая JSON, мы можем позволить решать клиенту, а не серверу, что делать дальше. В цикле 'запрос-ответ' JSON, сервер получает данные, обрабатывает их и возвращает ответ (пока что как и в цикле 'запрос-ответ' HTML), но ответ не управляет поведением браузера. Ответ просто сообщает браузеру результат запроса.

К счастью, команда разработки Django поняла, что тренды веб разработки движутся именно в этом направлении. Они также знали, что некоторые проекты могут не захотеть использовать встроенные модели, представления и шаблоны. Вместо этого, они могут использовать собственные. Чтобы убедиться, что все усилия, затраченные на создание встроенной системы аутентификации Django, не потрачены зря, они решили сделать возможным использование наиболее важных частей, сохраняя при этом возможность настройки конечного результата.

Мы поговорим об этом позже в этом руководстве, а пока что вот список того, что вам нужно знать:

  1. Мы создадим собственную модель User, взамен модели Django

  2. Нам нужно будет написать наши собственные представления для поддержки возврата JSON вместо HTML

  3. Поскольку мы не будем использовать HTML, нам не нужны встроенные шаблоны входа и регистрации Django

Аутентификация, основанная на сессии

По умолчанию, Django использует сессии для аутентификации. Прежде чем идти дальше, нужно проговорить, что это значит, почему это важно, что такое аутентификация на основе токенов и что такое JSON Web Token Authentication (JWT для краткости), и что из всего этого мы будем использовать далее в статье.

В Django сессии хранятся в файлах куки (cookie). Эти сессии, наряду со встроенным промежуточным ПО (middlewares) и объектами запросов, гарантируют, что пользователь будет доступен в каждом запросе. Доступ к пользователю можно получить как request.user. Когда пользователь вошел в систему, request.user является экземпляром класса User. Когда же он разлогинивается, request.user является экземпляром класса AnonymousUser. Независимо от того, аутентифицирован пользователь или нет, request.user всегда будет существовать.

В чем же разница? Говоря просто, в любое время, когда вы хотите узнать, является ли текущий пользователь аутентифицированным, вы можете использовать request.user.isauthenticated(), который вернет True в случае аутентификации пользователя и False в обратно случае. Если request.user является AnonymousUser, request.user.isauthenticated() вернет False. Это позволяет разработчику (вам :) ) преобразовать

if request.user is not None and request.user.isauthenticated(): в if request.user.isauthenticated():

В этом случае, требуется меньше набора текста - и это хорошо!

В нашем случае, клиент и сервер будут работать в разных местах. Например, сервер будет напущен по адресу http://localhost:3000, а клиент по адресу http://localhost:5000. Браузер будет считать, что эти две локации будут находиться в разных местах, аналогично запуску сервера на http://www.server.com и клиента на http://www.clent.com. Мы не будем разрешать внешним доменам получать доступ к нашим файлам cookie, поэтому нам нужно найти другое, альтернативное решение, для использования сессий.

Если вам интересно, почему мы не разрешаем доступ к нашим файлам cookie, ознакомьтесь со статьями о совместном использовании ресурсов между источниками (Cross-Origin Resource Sharing, CORS) и подделке межсайтовых запросов (Cross-Site Request Forgery, CSRF), по ссылкам ниже:

CORS

CSRF

Аутентификация, основанная на токенах

Наиболее распространенной альтернативой аутентификации на основе сессий/сеансов является т.н. аутентификация на основе токенов. Мы будем использовать особую форму такой аутентификации для защиты нашего приложения. При аутентификации на основе токенов сервер предоставляет клиенту токен после успешного запроса на вход. Этот токен уникален для пользователя и хранится в базе данных вместе идентификатором пользователя (если точнее, возможны раные вариации генерации токена, основная же идея в том, чтобы он аутентифицировал пользователя, позволяя знать кто это и давая доступ к апи, и имел время жизни, по истечении которого "протухал"). Ожидается, что клиент отправит токен вместе с будущими запросами, чтобы сервер мог идентифицировать пользователя. Сервер делает это путем поиска в таблице базы данных, содержащей все созданные токены. Если соответствующий токен найден, то сервер продолжает проверять, действителен ли токен. Если не найден, мы говорим, что пользователь не аутентифицирован. Поскольку токены хранятся в базе данных, а не в файлах куки, аутентификация на основе токенов соответствует нашим потребностям.

Верификация токенов

Мы всегда имеем возможность сохранить не только идентификатор пользователя (ID) с его токеном. Мы также можем хранить такие вещи, как дата истечения срока действия токена. В данном примере нам необходимо убедиться, что срок действия токена не прошел. Если прошел - считать, что токен недействителен. В таком случае, мы удаляем его из базы данных и просим пользователя снова войти в систему.

JSON Web Tokens

JSON Web Token (сокр. JWT) - это открытый стандарт (RFC 7519) , который определяет компактный и автономный способ безопасной передачи информации между двумя сторонами. Можно думать о JWT как о токенах аутентификации на стероидах.

Помните, что мы сказали, что будем использовать особую форму аутентификации на основе токенов? JWT это как раз то, что имелось ввиду.

Почему JSON Web Tokes лучше обычных токенов?

При переходе с обычных токенов на JWT мы получаем несколько преимуществ:

  1. JWT - открытый стандарт. Это означает, что что все реализации JWT должны быть довольно похожими, что является преимуществом при работе с разными языками и технологиями. Обычные токены имеют более свободную форму, что позволяет разработчику решать, как лучше всего реализовывать токены.

  2. JWT содержат информацию о пользователе, что удобно для клиентской стороны.

  3. Библиотеки здесь берут на себя основную тяжелую работу. Развертывание собственной системы аутентификации опасно, поэтому мы оставляем важные вещи проверенным «в боях» библиотекам, которым можем доверять.

Создание приложения, создание пользовательской модели

Для начала, создадим проект. Перейдите в терминале в вашу рабочую директорию, и выполните командуdjango-admin startproject json_auth_project (если вылезает ошибка, установите глобально джангу командой pip3 install django).

Теперь необходимо создать виртуальное окружение. Перейдите в директорию проекта командой cd jsonauthproject, далее выполните команду python3 -m venv venv. Виртуальное окружение может создаваться некоторое время на слабых компьютерах, но итогом станет появление директории venv, содержащей виртуальное окружение. Его необходимо активировать, для этого выполните команду . ./venv/bin/activate. Далее советую создать файл requirements.txt, который по мере наполнения проекта внешними пакетами актуализировать (так же обязательно установите в окружении пакет django командой pip3 install django). Выполните команду pip3 freeze > requirements.txt (выполняйте данную команду каждый раз, когда добавляете новый пакет/ы в проект). Советую сразу применить стандартные системные миграции командой ./manage.py migrate. Теперь можно попробовать запустить проект, чтобы убедиться, что все работает. Для запуска девелоп-сервера выполните команду ./manage.py runserver. Результатом станут запуск сервера на localhost и портом по умолчанию 8000. Перейдите в браузере по ссылке http://localhost:8000. Видите ракету с надписью "The install worked successfully! Congratulations!" - она готова к запуску :)

Для начала, создадим апп (app) authentication: ./manage.py startapp authentication. В файле apps/authentication/models.py будут храниться модели, которые мы будем использовать для аутентификации. Создайте этот файл, если его нет.

Нам понадобится следующий набор импортов для создания классов User и UserManager, поэтому добавьте в начало файла код:

import jwt

from datetime import datetime, timedelta

from django.conf import settings from django.contrib.auth.models import (
	AbstractBaseUser, BaseUserManager, PermissionsMixin
)

from django.db import models

При настройке аутентификации в Django одним из требований является указание настраиваемого класса Manager с двумя методами: createuser() и createsuperuser(). Чтобы узнать больше о пользовательской аутентификации в Django, прочтите https://docs.djangoproject.com/en/3.1/topics/auth/customizing/#substituting-a-custom-user-model

Наберите следующий код класса UserManager в файл apps/authentication/models.py и обязательно примите к сведению комментарии (больше про менеджеров: https://docs.djangoproject.com/en/3.1/topics/db/managers/):

class UserManager(BaseUserManager):
    """
    Django требует, чтобы кастомные пользователи определяли свой собственный
    класс Manager. Унаследовавшись от BaseUserManager, мы получаем много того
    же самого кода, который Django использовал для создания User (для демонстрации).
    """

    def create_user(self, username, email, password=None):
        """ Создает и возвращает пользователя с имэйлом, паролем и именем. """
        if username is None:
            raise TypeError('Users must have a username.')

        if email is None:
            raise TypeError('Users must have an email address.')

        user = self.model(username=username, email=self.normalize_email(email))
        user.set_password(password)
        user.save()

        return user

    def create_superuser(self, username, email, password):
        """ Создает и возввращет пользователя с привилегиями суперадмина. """
        if password is None:
            raise TypeError('Superusers must have a password.')

        user = self.create_user(username, email, password)
        user.is_superuser = True
        user.is_staff = True
        user.save()

        return user

Теперь, когда мы имеем класс менеджера, мы можем создать модель пользователя, наберите далее:

class User(AbstractBaseUser, PermissionsMixin):
    # Каждому пользователю нужен понятный человеку уникальный идентификатор,
    # который мы можем использовать для предоставления User в пользовательском
    # интерфейсе. Мы так же проиндексируем этот столбец в базе данных для
    # повышения скорости поиска в дальнейшем.
    username = models.CharField(db_index=True, max_length=255, unique=True)

    # Так же мы нуждаемся в поле, с помощью которого будем иметь возможность
    # связаться с пользователем и идентифицировать его при входе в систему.
    # Поскольку адрес почты нам нужен в любом случае, мы также будем
    # использовать его для входы в систему, так как это наиболее
    # распространенная форма учетных данных на данный момент (ну еще телефон).
    email = models.EmailField(db_index=True, unique=True)

    # Когда пользователь более не желает пользоваться нашей системой, он может
    # захотеть удалить свой аккаунт. Для нас это проблема, так как собираемые
    # нами данные очень ценны, и мы не хотим их удалять :) Мы просто предложим
    # пользователям способ деактивировать учетку вместо ее полного удаления.
    # Таким образом, они не будут отображаться на сайте, но мы все еще сможем
    # далее анализировать информацию.
    is_active = models.BooleanField(default=True)

    # Этот флаг определяет, кто может войти в административную часть нашего
    # сайта. Для большинства пользователей это флаг будет ложным.
    is_staff = models.BooleanField(default=False)

    # Временная метка создания объекта.
    created_at = models.DateTimeField(auto_now_add=True)

    # Временная метка показывающая время последнего обновления объекта.
    updated_at = models.DateTimeField(auto_now=True)

    # Дополнительный поля, необходимые Django
    # при указании кастомной модели пользователя.

    # Свойство USERNAME_FIELD сообщает нам, какое поле мы будем использовать
    # для входа в систему. В данном случае мы хотим использовать почту.
    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = ['username']

    # Сообщает Django, что определенный выше класс UserManager
    # должен управлять объектами этого типа.
    objects = UserManager()

    def __str__(self):
        """ Строковое представление модели (отображается в консоли) """
        return self.email

    @property
    def token(self):
        """
        Позволяет получить токен пользователя путем вызова user.token, вместо
        user._generate_jwt_token(). Декоратор @property выше делает это
        возможным. token называется "динамическим свойством".
        """
        return self._generate_jwt_token()

    def get_full_name(self):
        """
        Этот метод требуется Django для таких вещей, как обработка электронной
        почты. Обычно это имя фамилия пользователя, но поскольку мы не
        используем их, будем возвращать username.
        """
        return self.username

    def get_short_name(self):
        """ Аналогично методу get_full_name(). """
        return self.username

    def _generate_jwt_token(self):
        """
        Генерирует веб-токен JSON, в котором хранится идентификатор этого
        пользователя, срок действия токена составляет 1 день от создания
        """
        dt = datetime.now() + timedelta(days=1)

        token = jwt.encode({
            'id': self.pk,
            'exp': int(dt.strftime('%s'))
        }, settings.SECRET_KEY, algorithm='HS256')

        return token.decode('utf-8')

Если хотите узнать немного больше о кастомной аутентификации пользователя, несколько ссылок для глубокого изучения:

  1. models.CustomUser - охватывает все, что Django ожидает от кастомной модели User https://docs.djangoproject.com/en/3.1/topics/auth/customizing/#django.contrib.auth.models.CustomUser

  2. models.AbstractBaseUser и models.PermissionsMixin - предоставляют несколько требований выше сразу https://docs.djangoproject.com/en/3.1/topics/auth/customizing/#django.contrib.auth.models.AbstractBaseUser https://docs.djangoproject.com/en/3.1/topics/auth/customizing/#django.contrib.auth.models.PermissionsMixin

  3. models.BaseUserManager - дает нам несколько полезных инструментов для запуска нашего класса UserManager https://docs.djangoproject.com/en/3.1/topics/auth/customizing/#django.contrib.auth.models.BaseUserManager

  4. В справочнике по полям модели перечислены различные типы полей, поддерживаемые Django, и параметры, которые принимает каждое поле (например, db_index и unique) https://docs.djangoproject.com/en/3.1/ref/models/fields/

Определение AUTH_USER_MODEL в настройках проекта

По-умолчанию, Django предполагает, что модель пользователя стандартная - django.contrib.auth.models.User. Однако, мы хотим в качестве модели пользователя использовать нашу созданную модель. Поскольку мы создали класс User, следующее что нам нужно сделать, это указать Django использовать нашу модель User, а не стандартную.

Потратьте немного времени и почитайте о замене стандартной модели пользователя в Django: https://docs.djangoproject.com/en/3.1/topics/auth/customizing/#substituting-a-custom-user-model

Если вы уже перенесли свою модель базы данных до того, как указали кастомную модель User, вам может потребоваться удалить свою базу данных и повторно запустить миграции.

Укажите Django на использование нашей модели User, указав параметр AUTH_USER_MODEL в файле project/settings.py. Чтобы установить кастомную модель, введите в нижней части файла project/settings.py:

# Рассказать Django о созданной нами кастомной модели пользователя. Строка
# authentication.User сообщает Django, что мы ссылаемся на модель User в модуле
# authentication. Этот модуль зарегистрирован выше в настройке INSTALLED_APPS.
AUTH_USER_MODEL = 'authentication.User'

Создание и запуск миграций

По мере добавления новых моделей и изменения существующих, нам потребуется обновлять базу данных, чтобы отобразить эти изменения. Миграции - это то, что Django использует, чтобы сообщить базе данных, что что-то изменилось. Наша же миграция сообщит, что нам нужно добавить новую таблицу для нашей кастомной модели User.

  • Примечание: Если вы уже использовали ./manage.py makemigrations или ./manage.py migrate, вам необходимо удалить базу данных прежде, чем продолжать. Для SQLite достаточно просто удалить файл, лежащий в корне директории проекта. Django будет недоволен, если вы измените AUTH_USER_MODEL после создания базы данных, и лучше всего просто удалить базу данных и начать заново.

Теперь мы готовы создавать и применять миграции. После этого мы сможем создать нашего первого пользователя. Чтобы создать миграцию, необходимо запустить в консоли следующую команду:

./manage.py makemigrations

Это создаст стандартные миграции для нашего нового проекта Django. Однако, это не создат миграции для новых приложений внутри нашего проекта. В первый раз, когда мы хотим создать миграции для нового приложения, мы должны быть более конкретны.

Чтобы создать миграции для приложения authenticate, выполните

./manage.py makemigrations authentication

Это создаст инициализирующую миграцию для приложения authentication. В будущем. когда вы захотите сгенерировать новый миграции для приложения аутентификации, вам нужно будет запустить только

./manage.py makemigrations

Теперь мы можем применить миграции с помощью команды:

./manage.py migrate

В отличие от makemigrations, вам не нужно указывать название приложения при выполнении migrate.

Наш первый пользователь

Итак, мы создали нашу модель User, более того, наша база данных поднята и работает. Следующим шагом будет создание первого объекта пользователя, User. Мы сделаем этого пользователя суперадмином, так как будем использовать его для дальнейшего тестирования нашего приложения.

Создайте пользователя с помощью следующей команды терминала:

./manage.py createsuperuser

Django спросит вас о параметрах нового пользователя - почте, никнейме и пароле. После ввода данных, пользователь будет создан. Мои поздравления! :)

Для проверки успешного создания пользователя, перейдите в шелл Django, посредством выполнения следующей команды:

./manage.py shell_plus (или стандартную версию шелла ./manage.py shell)

Оболочка shell_plus предоставляется библиотекой django-extensions, которую необходимо установить (pip3 install django-extensions), если вы хотите пользоваться shell_plus. Это удобно, так как он автоматически импортирует модели всех приложения, указанных в INSTALLED_APPS. При желании, его также можно настроить для автоматического импорта других утилит.

После открытия оболочки, выполните следующие команды:

user = User.objects.first()
user.username
user.token

Если все сделано нормально, вы должны увидеть в выводах username и token.

Регистрация новых пользователей

На текущий момент, пользователь не может делать чего бы то ни было интересного. Нашей следующей задачей будет создание эндпоинта для регистрации новых пользователей.

RegistrationSerializer

Создайте файл apps/authentication/serializers.py и наберите туда следующий код:

from rest_framework import serializers

from .models import User


class RegistrationSerializer(serializers.ModelSerializer):
    """ Сериализация регистрации пользователя и создания нового. """

    # Убедитесь, что пароль содержит не менее 8 символов, не более 128,
    # и так же что он не может быть прочитан клиентской стороной
    password = serializers.CharField(
        max_length=128,
        min_length=8,
        write_only=True
    )

    # Клиентская сторона не должна иметь возможность отправлять токен вместе с
    # запросом на регистрацию. Сделаем его доступным только на чтение.
    token = serializers.CharField(max_length=255, read_only=True)

    class Meta:
        model = User
        # Перечислить все поля, которые могут быть включены в запрос
        # или ответ, включая поля, явно указанные выше.
        fields = ['email', 'username', 'password', 'token']

    def create(self, validated_data):
        # Использовать метод create_user, который мы
        # написали ранее, для создания нового пользователя.
        return User.objects.create_user(**validated_data)

Прочитайте внимательно код, обращая особое внимание на комментарии, а затем продолжим.

Немного о ModelSerializer

В приведенном выше коде мы создали класс RegistrationSerializer, который наследуется от сериализатора serializers.ModelSerializer. serializers.ModelSerializer - это просто абстракция поверх serializers.Serializer, про которую подробней можно почитать в документации Django REST Framework (DFR). ModelSerializer просто напросто упрощает для нас выполнение некоторых стандартных вещей, относящихся к сериализации моделей Django. Следует также отметить, что он позволяет указать два метода: создание и обновление. В приведенном выше примере мы написали наш собственный метод create() с использованием User.objects.create_user(), но не указали метод обновления. В этом случае DRF будет использовать собственный метод обновления по умолчанию для обновления пользователя.

RegistrationAPIView

Теперь мы можем сериализовывать запросы и ответы для регистрации пользователя. Далее, мы должны создать вью (views) для реализации эндпоинта (endpoint), что даст клиентской стороне URL для запроса на создание нового пользователя.

Создайте файл apps/authentication/views.py если его нет и наберите следующий код:

from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView

from .serializers import RegistrationSerializer


class RegistrationAPIView(APIView):
    """
    Разрешить всем пользователям (аутентифицированным и нет) доступ к данному эндпоинту.
    """
    permission_classes = (AllowAny,)
    serializer_class = RegistrationSerializer

    def post(self, request):
        user = request.data.get('user', {})

        # Паттерн создания сериализатора, валидации и сохранения - довольно
        # стандартный, и его можно часто увидеть в реальных проектах.
        serializer = self.serializer_class(data=user)
        serializer.is_valid(raise_exception=True)
        serializer.save()

        return Response(serializer.data, status=status.HTTP_201_CREATED)

Проговорим о паре новых вещей в коде выше:

  1. Свойство permission_classes - это то, что решает, кто может использовать этот эндпоинт. Мы можем ограничить это авторизованными пользователями или администраторами и т.д. и т.п.

  2. Паттерн создания сериализатора, валидации и сохранения, который можно увидеть в методе post - довольно стандартный, и его можно часто увидеть в реальных проектах. Ознакомьтесь с ним подробнее.

Почитать про Django REST Framework (DRF) Permissions можно по ссылке https://www.django-rest-framework.org/api-guide/permissions/

Теперь мы должны настроить маршрутизацию для нашего проекта. В Django 1.x => 2.x были внесены некоторые изменения при переключении с URL на путь (path). Есть довольно много вопросов по адаптации старого URL-адреса к новому способу определения URL's, но это выходит далеко за рамки данной статьи. Стоит сказать, что в последних версиях Django работа с роутами значительно упростилась, в чем можно убедиться далее.

Создайте файл apps/authentication/urls.py и поместите в него следующий код для обработки маршрутов нашего приложения:

from django.urls import path

from .views import RegistrationAPIView

app_name = 'authentication'
urlpatterns = [
    path('users/', RegistrationAPIView.as_view()),
]

В Django настоятельно рекомендуется создавать пути для конкретных модульных приложений. Это по факту заставляет задумываться о дизайне приложения и сохранении его автономности и возможности повторного использования. Что в данном случае мы и сделали. Мы также указали app_name = 'authentication', чтобы мы могли использовать включение (including) и придерживаться модульности приложения. Теперь нужно включить указанный выше файл в наш файл глобальных URL-адресов.

Откройте project/urls.py и вы увидите следующую строку в верхней части файла:

from django.urls import path

Первое, что нужно сделать, это импортировать метод include() из django.urls

from django.urls import path, include

Метод include() позволяет включить еще один файл без необходимости выполнения кучи другой работы, например, импортирования а затем повторной регистрации маршрута в этом файле.

Ниже видим следующее:

urlpatterns = [
    path('admin/', admin.site.urls),
]

Обновим это, чтобы включить наш новый файл urls.py:

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('apps.authentication.urls', namespace='authentication')),
]

Регистрация пользователя с помощью Postman

Теперь, когда мы создали модель User и добавили эндпоинт для регистрации новых пользователей, выполним быструю проверку работоспособности, чтобы убедиться, что мы на правильном пути. Для этого использует прекрасный инструмент тестирования (и далеко не только) апи под названием Postman (ознакомиться с его функциональность подробнее можно по ссылке https://learning.postman.com/docs/getting-started/introduction/).

Необходимо сформировать POST запрос по пути localhost:8000/api/users/ со следующей структурой данных в теле запроса:

{
    "user": {
        "username": "user1",
        "email": "user1@user.user",
        "password": "qweasdzxc"
    }
}

В ответ вернутся данных только что созданного пользователя. Мои поздравления! Все работает как надо, правда, с небольшим нюансом. В ответе вся информация пользователя находится на корневом уровне, а не располагается в поле "user". Чтобы это исправить (чтобы ответ был похож на тело запроса при регистрации, указанное выше), нужно создать настраиваемое средство визуализации DRF (renderer).

Рендеринг объектов User

Создайте файл под названием apps/authentication/renderers.py и наберите в него следующий код:

import json

from rest_framework.renderers import JSONRenderer


class UserJSONRenderer(JSONRenderer):
    charset = 'utf-8'

    def render(self, data, media_type=None, renderer_context=None):
        # Если мы получим ключ token как часть ответа, это будет байтовый
        # объект. Байтовые объекты плохо сериализуются, поэтому нам нужно
        # декодировать их перед рендерингом объекта User.
        token = data.get('token', None)

        if token is not None and isinstance(token, bytes):
            # Как говорится выше, декодирует token если он имеет тип bytes.
            data['token'] = token.decode('utf-8')

        # Наконец, мы можем отобразить наши данные в простанстве имен 'user'.
        return json.dumps({
            'user': data
        })

Здесь ничего особо нового или интересного не происходит, поэтому прочитайте комментарии в коде и двигаемся дальше.

Теперь, откройте файл apps/auhentication/views.py и импортируйте созданный нами UserJSONRenderer, добавив следующую строку:

from .renderers import UserJSONRenderer

Кроме того, необходимо установить свойство renderer_classes класса RegistrationAPIView:

renderer_classes = (UserJSONRenderer,)

Теперь, имея UserJSONRenderer на нужном месте, используйте запрос в Postman'e на создание нового пользователя. Обратите внимание, что теперь ответ находится внутри пространства имен "user".

Вход пользователей в систему

Поскольку теперь пользователи могут зарегистрироваться в приложении, нам нужно реализовать для них способ входа в свою учетную запись. Далее, мы добавим сериализатор и представление, необходимые пользователям для входа в систему. Мы также начнем смотреть, как наш API должен обрабатывать ошибки.

LoginSerializer

Откройте файл apps/authentication/serializers.py и добавьте следующий импорт:

from django.contrib.auth import authenticate

После, наберите следующий код сериализатора в конце файла:

class LoginSerializer(serializers.Serializer):
    email = serializers.CharField(max_length=255)
    username = serializers.CharField(max_length=255, read_only=True)
    password = serializers.CharField(max_length=128, write_only=True)
    token = serializers.CharField(max_length=255, read_only=True)

    def validate(self, data):
        # В методе validate мы убеждаемся, что текущий экземпляр
        # LoginSerializer значение valid. В случае входа пользователя в систему
        # это означает подтверждение того, что присутствуют адрес электронной
        # почты и то, что эта комбинация соответствует одному из пользователей.
        email = data.get('email', None)
        password = data.get('password', None)

        # Вызвать исключение, если не предоставлена почта.
        if email is None:
            raise serializers.ValidationError(
                'An email address is required to log in.'
            )

        # Вызвать исключение, если не предоставлен пароль.
        if password is None:
            raise serializers.ValidationError(
                'A password is required to log in.'
            )

        # Метод authenticate предоставляется Django и выполняет проверку, что
        # предоставленные почта и пароль соответствуют какому-то пользователю в
        # нашей базе данных. Мы передаем email как username, так как в модели
        # пользователя USERNAME_FIELD = email.
        user = authenticate(username=email, password=password)

        # Если пользователь с данными почтой/паролем не найден, то authenticate
        # вернет None. Возбудить исключение в таком случае.
        if user is None:
            raise serializers.ValidationError(
                'A user with this email and password was not found.'
            )

        # Django предоставляет флаг is_active для модели User. Его цель
        # сообщить, был ли пользователь деактивирован или заблокирован.
        # Проверить стоит, вызвать исключение в случае True.
        if not user.is_active:
            raise serializers.ValidationError(
                'This user has been deactivated.'
            )

        # Метод validate должен возвращать словать проверенных данных. Это
        # данные, которые передются в т.ч. в методы create и update.
        return {
            'email': user.email,
            'username': user.username,
            'token': user.token
        }

Когда сериализатор будет на своем месте, можно отправляться писать представление.

LoginAPIView

Откройте файл apps/authentication/views.py и обновите импорты:

from .serializers import LoginSerializer, RegistrationSerializer

А затем, добавьте само представление:

class LoginAPIView(APIView):
    permission_classes = (AllowAny,)
    renderer_classes = (UserJSONRenderer,)
    serializer_class = LoginSerializer

    def post(self, request):
        user = request.data.get('user', {})

        # Обратите внимание, что мы не вызываем метод save() сериализатора, как
        # делали это для регистрации. Дело в том, что в данном случае нам
        # нечего сохранять. Вместо этого, метод validate() делает все нужное.
        serializer = self.serializer_class(data=user)
        serializer.is_valid(raise_exception=True)

        return Response(serializer.data, status=status.HTTP_200_OK)

Далее, откройте файл apps/authentication/urls.py и обновите импорт:

from .views import LoginAPIView, RegistrationAPIView

И затем добавьте новое правило в urlpatterns:

urlpatterns = [
    path('users/', RegistrationAPIView.as_view()),
    path('users/login/', LoginAPIView.as_view()),
]

Вход пользователя с помощью Postman

На данном этапе, пользователь должен иметь возможность войти в систему, используя соответствующий эндпоинт нашей системы. Сделаем это :) Откроем использованный ранее Postman, и попробуем выполнить пост запрос на http://localhost:8000/api/users/login/, передав в теле почту и пароль ранее созданного пользователя. Если все было сделано верно, должен вернуться объект вида:

{
    "user": {
        "email": "email@email.email",
        "username": "admin",
        "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNjA1MTE3MjkwfQ.W8B6RY-jGO9PYDTzDWxhrkSHsTe1p3jlzq1BL7Tbwcs"
    }
}

Как видно, в ответ включен token, который можно использовать для выполнения всех будущих запросов, требующих аутентификации пользователя.

Есть еще кое-что, что нам необходимо сделать. Попробуйте выполнить вход, указав неверные почту или пароль, или оба. Обратите внимание на ответ с ошибкой - с ней есть две проблемы. Во-первых, non_field_errors выглядит странно. Обычно этот ключ должен обзываться именем поля, по которому сериализатор не прошел проверку. Поскольку мы переопределили весь метод проверки, вместо использования отдельных методов, зависящих от проверяемого поля, такого как например validate_email, Django REST Framework не знает, какое поле атрибутировать. По умолчанию, это поле обзывается nonfield_errors, и так как наш клиент будет использовать это поле для отображения ошибок, нам необходимо изменить такое поведение. Во-вторых, клиент будет ожидать, что любые ошибки будут помещены пространство имен под соответствующим ключом ошибки в JSON ответе (как мы сделали это в эндпоинтах регистрации и входа). Мы добьемся этого, переопределив стандартную обработку ошибок Django REST Framework.

Перегрузка EXCEPTION_HANDLER и NON_FIELD_ERRORS_KEY

Одна из настроек DRF под названием EXCEPTION_HANDLER возвращает словарь ошибок. Мы хотим, чтобы имена наших ошибок находились под общим единым ключом, потому нам нужно переопределить EXCEPTION_HANDLER. Так же переопределим и NON_FIELD_ERRORS_KEY, как упоминалось ранее.

Начнем с создания project/exceptions.py, и добавления в него следующего кода:

from rest_framework.views import exception_handler


def core_exception_handler(exc, context):
    # Если возникает исключение, которые мы не обрабатываем здесь явно, мы
    # хотим передать его обработчику исключений по-умолчанию, предлагаемому
    # DRF. И все же, если мы обрабатываем такой тип исключения, нам нужен
    # доступ к сгенерированному DRF - получим его заранее здесь.
    response = exception_handler(exc, context)
    handlers = {
        'ValidationError': _handle_generic_error
    }
    # Определить тип текущего исключения. Мы воспользуемся этим сразу далее,
    # чтобы решить, делать ли это самостоятельно или отдать эту работу DRF.
    exception_class = exc.__class__.__name__

    if exception_class in handlers:
        # Если это исключение можно обработать - обработать :) В противном
        # случае, вернуть ответ сгенерированный стандартными средствами заранее
        return handlers[exception_class](exc, context, response)

    return response


def _handle_generic_error(exc, context, response):
    # Это самый простой обработчик исключений, который мы можем создать. Мы
    # берем ответ сгенерированный DRF и заключаем его в ключ 'errors'.
    response.data = {
        'errors': response.data
    }

    return response

Позаботившись об этом, откройте файл project/settings.py и добавьте новый параметр под названием REST_FRAMEWORK в конец файла:

REST_FRAMEWORK = {
    'EXCEPTION_HANDLER': 'project.exceptions.core_exception_handler',
    'NON_FIELD_ERRORS_KEY': 'error',
}

Так переопределяются стандартные настройки DFR. Чуть позже, мы добавим еще одну настройку, когда будем писать представления, требующие аутентификации пользователя. Попробуйте отправить еще один некорректный (с неверными почтой и/или паролем) запрос на вход с помощью Postman - сообщение об ошибке должно измениться.

Обновить UserJSONRenderer

Нет, в ответе полученном с неверными почтой/паролем все совсем не так, как ожидалось. Да, мы получили ключ "error", но все пространство имен заключено в ключе "user", что совсем не хорошо. Давайте обновим UserJSONRenderer чтобы проверять ключ "error" и предпринять некоторые действия в таком случае. Откройте файл apps/authenticate/renderers.py и внесите следующие изменения:

import json

from rest_framework.renderers import JSONRenderer


class UserJSONRenderer(JSONRenderer):
    charset = 'utf-8'

    def render(self, data, media_type=None, renderer_context=None):
        # Если представление выдает ошибку (например, пользователь не может
        # быть аутентифицирован), data будет содержать ключ error. Мы хотим,
        # чтобы стандартный JSONRenderer обрабатывал такие ошибки, поэтому
        # такой случай необходимо проверить.
        errors = data.get('errors', None)

        # Если мы получим ключ token как часть ответа, это будет байтовый
        # объект. Байтовые объекты плохо сериализуются, поэтому нам нужно
        # декодировать их перед рендерингом объекта User.
        token = data.get('token', None)

        if errors is not None:
            # Позволим стандартному JSONRenderer обрабатывать ошибку.
            return super(UserJSONRenderer, self).render(data)

        if token is not None and isinstance(token, bytes):
            # Как говорится выше, декодирует token если он имеет тип bytes.
            data['token'] = token.decode('utf-8')

        # Наконец, мы можем отобразить наши данные в простанстве имен 'user'.
        return json.dumps({
            'user': data
        })

Теперь, пошлите снова некорректный (неверные почта/пароль) запрос с помощью Postman - все должно быть как ожидается.

Получение и обновление пользователей.

Пользователи могут регистрировать новые аккаунты и заходить, логиниться в эти аккаунты. Теперь пользователи нуждаются в возможности получить и обновить свою информацию. Давайте реализуем это прежде чем перейти к созданию профилей пользователей.

UserSerializer

Мы собираемся создать еще один сериализатор для профилей. У нас есть сериализаторы для запросов входа и регистрации, но нам так же нужна возможность сериализации самих пользовательских объектов.

Откройте файл apps/authentication/serializers.py и добавьте следующий код:

class UserSerializer(serializers.ModelSerializer):
    """ Ощуществляет сериализацию и десериализацию объектов User. """

    # Пароль должен содержать от 8 до 128 символов. Это стандартное правило. Мы
    # могли бы переопределить это по-своему, но это создаст лишнюю работу для
    # нас, не добавляя реальных преимуществ, потому оставим все как есть.
    password = serializers.CharField(
        max_length=128,
        min_length=8,
        write_only=True
    )

    class Meta:
        model = User
        fields = ('email', 'username', 'password', 'token',)

        # Параметр read_only_fields является альтернативой явному указанию поля
        # с помощью read_only = True, как мы это делали для пароля выше.
        # Причина, по которой мы хотим использовать здесь 'read_only_fields'
        # состоит в том, что нам не нужно ничего указывать о поле. В поле
        # пароля требуются свойства min_length и max_length,
        # но это не относится к полю токена.
        read_only_fields = ('token',)

    def update(self, instance, validated_data):
        """ Выполняет обновление User. """

        # В отличие от других полей, пароли не следует обрабатывать с помощью
        # setattr. Django предоставляет функцию, которая обрабатывает пароли
        # хешированием и 'солением'. Это означает, что нам нужно удалить поле
        # пароля из словаря 'validated_data' перед его использованием далее.
        password = validated_data.pop('password', None)

        for key, value in validated_data.items():
            # Для ключей, оставшихся в validated_data мы устанавливаем значения
            # в текущий экземпляр User по одному.
            setattr(instance, key, value)

        if password is not None:
            # 'set_password()' решает все вопросы, связанные с безопасностью
            # при обновлении пароля, потому нам не нужно беспокоиться об этом.
            instance.set_password(password)

        # После того, как все было обновлено, мы должны сохранить наш экземпляр
        # User. Стоит отметить, что set_password() не сохраняет модель.
        instance.save()

        return instance

Стоит отметить, что мы не определяем явно метод create в данном сериализаторе, поскольку DRF предоставляет метод создания по умолчанию для всех экземпляров serializers.ModelSerializer. С помощью этого сериализатора можно создать пользователя, но мы хотим, чтобы создание пользователя производилось с помощью RegistrationSerializer.

UserRetrieveUpdateAPIView

Откройте файл apps/authentication/views.py и обновите импорты следующим образом:

from rest_framework import status
from rest_framework.generics import RetrieveUpdateAPIView
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView

from .renderers import UserJSONRenderer
from .serializers import (
    LoginSerializer, RegistrationSerializer, UserSerializer,
)

Прописав импорты, создайте новое представление под названием UserRetrieveUpdateView:

class UserRetrieveUpdateAPIView(RetrieveUpdateAPIView):
    permission_classes = (IsAuthenticated,)
    renderer_classes = (UserJSONRenderer,)
    serializer_class = UserSerializer

    def retrieve(self, request, *args, **kwargs):
        # Здесь нечего валидировать или сохранять. Мы просто хотим, чтобы
        # сериализатор обрабатывал преобразования объекта User во что-то, что
        # можно привести к json и вернуть клиенту.
        serializer = self.serializer_class(request.user)

        return Response(serializer.data, status=status.HTTP_200_OK)

    def update(self, request, *args, **kwargs):
        serializer_data = request.data.get('user', {})

        # Паттерн сериализации, валидирования и сохранения - то, о чем говорили
        serializer = self.serializer_class(
            request.user, data=serializer_data, partial=True
        )
        serializer.is_valid(raise_exception=True)
        serializer.save()

        return Response(serializer.data, status=status.HTTP_200_OK)

Теперь перейдем к файлу apps/authentication/urls.py и обновим импорты в начале файла, чтобы включить UserRetrieveUpdateView:

from .views import (
    LoginAPIView, RegistrationAPIView, UserRetrieveUpdateAPIView
)

И добавим новый путь в urlpatterns:

urlpatterns = [
    path('user', UserRetrieveUpdateAPIView.as_view()),
    path('users/', RegistrationAPIView.as_view()),
    path('users/login/', LoginAPIView.as_view()),
]

Откройте Postman и отправьте запрос на текущего пользователя на получение информации о текущем пользователе (GET localhost:8000/api/user/). Если все сделано правильно, вы должны получить сообщение об ошибке как следующее:

{
    "user": {
        "detail": "Authentication credentials were not provided."
    }
}

Аутентификация пользователей

В Django существует идея бекендов аутентификации. Не вдаваясь в подробности, бекенд - это, по сути, план принятия решения о том, аутентифицирован ли пользователь. Нам нужно создать собственный бекенд для поддержки JWT, поскольку по умолчанию он не поддерживается ни Django, ни Django REST Framework (DRF).

Создайте и откройте файл apps/authentication/backends.py и добавьте в него следующий код:

import jwt

from django.conf import settings

from rest_framework import authentication, exceptions

from .models import User


class JWTAuthentication(authentication.BaseAuthentication):
    authentication_header_prefix = 'Token'

    def authenticate(self, request):
        """
        Метод authenticate вызывается каждый раз, независимо от того, требует
        ли того эндпоинт аутентификации. 'authenticate' имеет два возможных
        возвращаемых значения:
            1) None - мы возвращаем None если не хотим аутентифицироваться.
            Обычно это означает, что мы значем, что аутентификация не удастся.
            Примером этого является, например, случай, когда токен не включен в
            заголовок.
            2) (user, token) - мы возвращаем комбинацию пользователь/токен
            тогда, когда аутентификация пройдена успешно. Если ни один из
            случаев не соблюден, это означает, что произошла ошибка, и мы
            ничего не возвращаем. В таком случае мы просто вызовем исключение
            AuthenticationFailed и позволим DRF сделать все остальное.
        """
        request.user = None

        # 'auth_header' должен быть массивом с двумя элементами:
        # 1) именем заголовка аутентификации (Token в нашем случае)
        # 2) сам JWT, по которому мы должны пройти аутентифкацию
        auth_header = authentication.get_authorization_header(request).split()
        auth_header_prefix = self.authentication_header_prefix.lower()

        if not auth_header:
            return None

        if len(auth_header) == 1:
            # Некорректный заголовок токена, в заголовке передан один элемент
            return None

        elif len(auth_header) > 2:
            # Некорректный заголовок токена, какие-то лишние пробельные символы
            return None

        # JWT библиотека которую мы используем, обычно некорректно обрабатывает
        # тип bytes, который обычно используется стандартными библиотеками
        # Python3 (HINT: использовать PyJWT). Чтобы точно решить это, нам нужно
        # декодировать prefix и token. Это не самый чистый код, но это хорошее
        # решение, потому что возможна ошибка, не сделай мы этого.
        prefix = auth_header[0].decode('utf-8')
        token = auth_header[1].decode('utf-8')

        if prefix.lower() != auth_header_prefix:
            # Префикс заголовка не тот, который мы ожидали - отказ.
            return None

        # К настоящему моменту есть "шанс", что аутентификация пройдет успешно.
        # Мы делегируем фактическую аутентификацию учетных данных методу ниже.
        return self._authenticate_credentials(request, token)

    def _authenticate_credentials(self, request, token):
        """
        Попытка аутентификации с предоставленными данными. Если успешно -
        вернуть пользователя и токен, иначе - сгенерировать исключение.
        """
        try:
            payload = jwt.decode(token, settings.SECRET_KEY)
        except Exception:
            msg = 'Ошибка аутентификации. Невозможно декодировать токеню'
            raise exceptions.AuthenticationFailed(msg)

        try:
            user = User.objects.get(pk=payload['id'])
        except User.DoesNotExist:
            msg = 'Пользователь соответствующий данному токену не найден.'
            raise exceptions.AuthenticationFailed(msg)

        if not user.is_active:
            msg = 'Данный пользователь деактивирован.'
            raise exceptions.AuthenticationFailed(msg)

        return (user, token)

В этом файле много логики и исключений, но код довольно прост. Все, что мы сделали, это составили список условий, которые пользователю необходимо пройти для аутентификации, и описали исключения, которые выбросятся, если какое-то из условий окажется истинным.

Сообщить DRF про наш аутентификационный бекенд

Мы должны явно указать Django REST Framework, какой бекенд аутентификации мы хотим использовать, аналогично тому, как мы сказали Django использовать нашу пользовательскую модель.

Откройте файл project/settings.py и обновите словарь REST_FRAMEWORK следующим новым ключом:

REST_FRAMEWORK = {
    ...
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'apps.authentication.backends.JWTAuthentication',
    ),
}

Получение и обновление пользователей с помощью Postman

Теперь, когда наш новый бекенд аутентификаци установлен, ошибки аутентификации, которые мы наблюдали ранее, должна исчезнуть. Проверьте это, открыв Postman и отправив тот же самый запрос (GET localhost:8000/api/user/). Он должен быть успешным, и вы должны увидеть информацию о своем пользователе в ответе приложения. Помните, что мы создали эндпоинт обновления вместе с эндпоинтом получения информации? Давайте проверим и это. Отправьте запрос на обновление почты пользователя (PATCH localhost:8000/api/user/), передав в заголовках запроса токен. Если все было сделано верно, в ответе вы увидите, как адрес электронной почты изменился.

Итоги

Подведем краткие итоги того, что мы сделали в данной статье. Мы создали гибкую модель пользователя (в дальнейшем, можно ее расширять как душе угодно, добавляя разные поля, дополнительные модели и т.п.), три сериализатора, каждый из которых выполняют свою четко определенную функцию. Создали четыре эндпоинта, которые позволяют пользователям регистрироваться, входить в систему, получать и обновлять информацию о своем аккаунте. На мой взгляд, это очень приятный фундамент, основа, на которой можно строить какой-то новый ламповый проект по своему усмотрению:) (однако же, есть куча сфер, о которых можно говорить часами, например на языке крутится следующий этап о том, чтобы завернуть это все в контейнеры докера, прикрутив постгрес, редис и селери).

Комментарии 9

    +2
      –1

      спасибо, надо было срочно сделать авторизацию для SPA, как раз из за многобукв не осилил тогда jwt

        +1
        Возможно, я не очень внимательно прочитал статью, но не очень понял один момент. Вы выдаёте пользователю jwt-токен, имеющий время жизни. А что происходит в момент, когда оно истекает? Пользователя разлогинит прямо во время пользования вашей админкой/сайтом? Если да, то это не нормально. Кроме того, jwt можно валидировать без обращения к базе данных — в этом и заключается основное преимущество такого вида токенов (чтобы не лазать лишний раз в базу данных). У вас он валидируется, но после этого пользователь зачем-то ищется в базе. Какой в этом смысл, если id пользователя подделать невозможно, потому что jwt тогда станет невалидным. Так можно и обычный токен использовать вместо jwt.

        Тут, по идее, нужен не только access-токен, но и второй — refresh, чтобы была реализована ротация токенов.
          0
          Это как раз следующие шаги) обрати внимание, я написал, что эта статья про основу, фундамент так сказать jwt авторизации, с которым дальше можно танцевать как душе угодно. Да, обязательно нужен refresh/, на который фронт будет ходить периодически.

          Насчет генерации токена — ситуация похожа: для статьи, туториала, имхо, достаточно и этого. Быть может, когда-то созрею до продолжения, до усиления безопасности, ускорения и т.д.
          0
          Почему бы для маршрутизации не использовать DefaultRouter из самой же DRF?
            0
            Лично мне больше по душе urlpatterns, наверное дело вкуса, а может просто привык. Кроме синтаксических различий и, как следствие, удобства, есть ли какая-то большая разница между ними?
              0
              Если делать через router, а его уже подключить в urlpatterns как path('api/', include(router.urls)), то по адресу localhost:8000/api/ будет удобная «менюшка» со всеми эндпоинтами.
                0
                Спасибо!)
            0

            Простите, но я так и не понял зачем писать токен в базу


            Посмотрите на вашу последнюю (в статье) функцию
            она просто берет токен из http запроса, декодирует из него user_id и проверяет есть ли такой user_id (а не токен) в базе и если такой user есть и активен, то запрос авторизован


            И в самом начале посмотрите как токен генерится
            там простой объект (dict) с двумя полями: id и exp
            этот объект шифруется с помощью серверного SECRET_KEY и отдается клиенту для дальнейшего использования.


            А клиент просто в каждый http запрос вставляет заголовок с токеном (вместо сессионных куков)
            curl http://localhost:5000/api/whatever -H 'Authorization: Bearer $JWT'

            сохраненный в базу токен нигде и никак не используется

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое