Аутентификация пользователя с помощью Django REST Framework и веб-токенов JSON

За последние пару месяцев я в свободное время создавал веб-приложение. Хотя я хотел бы держать детали моего приложения в секрете (пока), мне хотелось бы поделиться некоторыми трудностями, с которыми я столкнулся, работая над этим приложением; особенно когда дело доходит до управления пользователями и аутентификации с помощью веб-токенов JSON (JWT).

Стек технологий

Прежде чем углубиться в код, я хотел бы дать некоторую справочную информацию о технологии, которую я буду использовать. В качестве основы я выбрал Django и Django Rest Framework для разработки RESTful API, который был бы в основе моего приложения. Для внешнего интерфейсе я решил использовать Reactjs для создания своего SPA, чтобы делать запросы на сервер. Я буду использовать Docker для контейнеризации, CircleCI для построения конвейера CI/CD и AWS в качестве службы инфраструктуры. Наконец, я буду использовать Postman для запуска тестов и мониторинга API на ходу. Это предполагает предполагает некоторое знакомство с Python, Django и Docker.

Требования

Перед началом работы вы должны установить следующие зависимости Python:

Django==2.1 
django-rest-framework==0.1.0 
djangorestframework==3.8.2 
gunicorn==19.9.0 # Our WSGI server 
psycopg2==2.7.5 # Only if using Postgresql 
PyJWT==1.6.4 
pytz==2018.5

Поскольку я использую Docker, вы можете приступить к работе со следующим Dockerfile:

FROM python:3.6-alpine

MAINTAINER Sebastian Ojeda <sebastian@oddjobbox.com>

WORKDIR /app

COPY requirements.txt .

RUN apk add --no-cache --virtual .build-deps \
  build-base postgresql-dev jpeg-dev zlib-dev \
    && pip install -r requirements.txt \
    && find /usr/local \
        \( -type d -a -name test -o -name tests \) \
        -o \( -type f -a -name '*.pyc' -o -name '*.pyo' \) \
        -exec rm -rf '{}' + \
    && runDeps="$( \
        scanelf --needed --nobanner --recursive /usr/local \
                | awk '{ gsub(/,/, "\nso:", $2); print "so:" $2 }' \
                | sort -u \
                | xargs -r apk info --installed \
                | sort -u \
    )" \
    && apk add --virtual .rundeps $runDeps \
    && apk del .build-deps

COPY . .

CMD ["python3", "manage.py", "runserver", "0:8000"]

Я все еще работаю над уменьшением размера файла этого образа. А пока этого будет достаточно. Двигаемся дальше!

Создание модели User

Модель пользователя Django довольно проста. Мы будем наследовать от классов AbstractBaseUser и PermissionsMixin для создания нашей модели.

import jwt

from datetime import datetime
from datetime import timedelta

from django.conf import settings
from django.db import models
from django.core import validators
from django.contrib.auth.models import AbstractBaseUser
from django.contrib.auth.models import PermissionsMixin

class User(AbstractBaseUser, PermissionsMixin):
    """
    Определяет наш пользовательский класс User.
    Требуется имя пользователя, адрес электронной почты и пароль.
    """

    username = models.CharField(db_index=True, max_length=255, unique=True)

    email = models.EmailField(
        validators=[validators.validate_email],
        unique=True,
        blank=False
        )

    is_staff = models.BooleanField(default=False)

    is_active = models.BooleanField(default=True)

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

    REQUIRED_FIELDS = ('username',)

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

    def __str__(self):
        """
        Возвращает строковое представление этого `User`.
        Эта строка используется, когда в консоли выводится `User`.
        """
        return self.username

    @property
    def token(self):
        """
        Позволяет нам получить токен пользователя, вызвав `user.token` вместо
        `user.generate_jwt_token().

        Декоратор `@property` выше делает это возможным.
        `token` называется «динамическим свойством ».
        """
        return self._generate_jwt_token()

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

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

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

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

        return token.decode('utf-8')

Как видите, токен создается динамически с помощью декоратора @property с истечением 60 дней. UserManager, который будет вызываться при создании нового пользователя, выглядит следующим образом:

from django.contrib.auth.models import BaseUserManager

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

    Все, что нам нужно сделать, это переопределить функцию
    `create_user`, которую мы будем использовать
    для создания объектов `User`.
    """

    def _create_user(self, username, email, password=None, **extra_fields):
        if not username:
            raise ValueError('Указанное имя пользователя должно быть установлено')

        if not email:
            raise ValueError('Данный адрес электронной почты должен быть установлен')

        email = self.normalize_email(email)
        user = self.model(username=username, email=email, **extra_fields)
        user.set_password(password)
        user.save(using=self._db)

        return user

    def create_user(self, username, email, password=None, **extra_fields):
        """
        Создает и возвращает `User` с адресом электронной почты,
        именем пользователя и паролем.
        """
        extra_fields.setdefault('is_staff', False)
        extra_fields.setdefault('is_superuser', False)

        return self._create_user(username, email, password, **extra_fields)

    def create_superuser(self, username, email, password, **extra_fields):
        """
        Создает и возвращает пользователя с правами
        суперпользователя (администратора).
        """
        extra_fields.setdefault('is_staff', True)
        extra_fields.setdefault('is_superuser', True)

        if extra_fields.get('is_staff') is not True:
            raise ValueError('Суперпользователь должен иметь is_staff=True.')

        if extra_fields.get('is_superuser') is not True:
            raise ValueError('Суперпользователь должен иметь is_superuser=True.')

        return self._create_user(username, email, password, **extra_fields)

Я рекомендую читать код построчно, чтобы убедиться, что вы понимаете, что происходит (это, как правило, хорошая идея, когда вы копируете код из Интернета). Класс User и UserManager - все, что вам нужно для создания пользовательского пользователя в Django. Просто не забудьте сообщить Django, что эти модели существуют, объявив ваше приложение в файле settings.py:

INSTALLED_APPS = [
    ...
    'rest_framework',
    'authentication', # My'authentication` app
    ...
]

AUTH_USER_MODEL = 'authentication.User'

Бэкенд аутентификации

По умолчанию Django не знает, как аутентифицировать ваши JWT. Чтобы это исправить, мы должны создать следующий файл 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 = 'Bearer'

    def authenticate(self, request):
        """
        The `authenticate` method is called on every request regardless of
        whether the endpoint requires authentication.

        `authenticate` has two possible return values:

        1) `None` - We return `None` if we do not wish to authenticate. Usually
                    this means we know authentication will fail. An example of
                    this is when the request does not include a token in the
                    headers.

        2) `(user, token)` - We return a user/token combination when
                             authentication is successful.

                            If neither case is met, that means there's an error
                            and we do not return anything.
                            We simple raise the `AuthenticationFailed`
                            exception and let Django REST Framework
                            handle the rest.
        """
        request.user = None

        # `auth_header` should be an array with two elements: 1) the name of
        # the authentication header (in this case, "Token") and 2) the JWT
        # that we should authenticate against.
        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:
            # Invalid token header. No credentials provided. Do not attempt to
            # authenticate.
            return None

        elif len(auth_header) > 2:
            # Invalid token header. The Token string should not contain spaces.
            # Do not attempt to authenticate.
            return None

        # The JWT library we're using can't handle the `byte` type, which is
        # commonly used by standard libraries in Python 3. To get around this,
        # we simply have to decode `prefix` and `token`. This does not make for
        # clean code, but it is a good decision because we would get an error
        # if we didn't decode these values.
        prefix = auth_header[0].decode('utf-8')
        token = auth_header[1].decode('utf-8')

        if prefix.lower() != auth_header_prefix:
            # The auth header prefix is not what we expected. Do not attempt to
            # authenticate.
            return None

        # By now, we are sure there is a *chance* that authentication will
        # succeed. We delegate the actual credentials authentication to the
        # method below.
        return self._authenticate_credentials(request, token)

    def _authenticate_credentials(self, request, token):
        """
        Try to authenticate the given credentials. If authentication is
        successful, return the user and token. If not, throw an error.
        """
        try:
            payload = jwt.decode(token, settings.SECRET_KEY)
        except:
            msg = 'Invalid authentication. Could not decode token.'
            raise exceptions.AuthenticationFailed(msg)

        try:
            user = User.objects.get(pk=payload['id'])
        except User.DoesNotExist:
            msg = 'No user matching this token was found.'
            raise exceptions.AuthenticationFailed(msg)

        if not user.is_active:
            msg = 'This user has been deactivated.'
            raise exceptions.AuthenticationFailed(msg)

        return (user, token)

Опять же, много кода пропускается, но мне нравится думать, что это довольно просто, если у вас есть некоторый опыт работы с Python и Django.

Мы также должны не забыть обновить наш файл settings.py, чтобы сообщить Django, где найти наш пользовательский сервер аутентификации:

...
REST_FRAMEWORK = {
    'DEFAULT_PERMISSION_CLASSES': (
        'rest_framework.permissions.IsAuthenticated',
        ),
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'authentication.backends.JWTAuthentication',
        )
}
...

К настоящему времени вы создали пользовательскую модель User и модель UserManager и создали собственный класс JWTAuthentication для аутентификации ваших пользовательских токенов. Последним отсутствующим элементом является настройка пользовательских представлений для обработки DRF.

DRF сериализаторы

Есть пара видов, которые нужно сериализовать, чтобы наконец начать работу (узнать больше о сериализаторах Django Rest Framework). Первый - это RegistrationSerializer

from rest_framework import serializers
from .models import User

class RegistrationSerializer(serializers.ModelSerializer):
    """
    Creates a new user.
    Email, username, and password are required.
    Returns a JSON web token.
    """

    # The password must be validated and should not be read by the client
    password = serializers.CharField(
        max_length=128,
        min_length=8,
        write_only=True,
    )

    # The client should not be able to send a token along with a registration
    # request. Making `token` read-only handles that for us.
    token = serializers.CharField(max_length=255, read_only=True)

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

    def create(self, validated_data):
        return User.objects.create_user(**validated_data)

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

class LoginSerializer(serializers.Serializer):
    """
    Authenticates an existing user.
    Email and password are required.
    Returns a JSON web token.
    """
    email = serializers.EmailField(write_only=True)
    password = serializers.CharField(max_length=128, write_only=True)

    # Ignore these fields if they are included in the request.
    username = serializers.CharField(max_length=255, read_only=True)
    token = serializers.CharField(max_length=255, read_only=True)

    def validate(self, data):
        """
        Validates user data.
        """
        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.'
            )

        user = authenticate(username=email, password=password)

        if user is None:
            raise serializers.ValidationError(
                'A user with this email and password was not found.'
            )

        if not user.is_active:
            raise serializers.ValidationError(
                'This user has been deactivated.'
            )

        return {
            'token': user.token,
        }

DRF Views

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

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

from .models import User
from .serializers import LoginSerializer
from .serializers import RegistrationSerializer

class RegistrationAPIView(APIView):
    """
    Registers a new user.
    """
    permission_classes = [AllowAny]
    serializer_class = RegistrationSerializer

    def post(self, request):
        """
        Creates a new User object.
        Username, email, and password are required.
        Returns a JSON web token.
        """
        serializer = self.serializer_class(data=request.data)
        serializer.is_valid(raise_exception=True)
        serializer.save()

        return Response(
            {
                'token': serializer.data.get('token', None),
            },
            status=status.HTTP_201_CREATED,
        )


class LoginAPIView(APIView):
    """
    Logs in an existing user.
    """
    permission_classes = [AllowAny]
    serializer_class = LoginSerializer

    def post(self, request):
        """
        Checks is user exists.
        Email and password are required.
        Returns a JSON web token.
        """
        serializer = self.serializer_class(data=request.data)
        serializer.is_valid(raise_exception=True)

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

Последний шаг - настроить наш файл urls.py для сопоставления наших представлений с URL.

from django.urls import re_path, include

from .views import RegistrationAPIView
from .views import LoginAPIView

urlpatterns = [
    re_path(r'^registration/?$', RegistrationAPIView.as_view(), name='user_registration'),
    re_path(r'^login/?$', LoginAPIView.as_view(), name='user_login'),
]

Резюме

Создав все эти файлы, мы теперь можем регистрировать и авторизовать пользователей с помощью наших пользовательских моделей Django и успешно аутентифицировать наших пользователей с помощью JSON Web Tokens. Хотя большая часть этой информации была размещена на этой странице, я надеюсь, что она помогла тем, кто хочет сделать что-то подобное.

Перевод https://refactrd.com/user-authentication-with-django-rest-framework-and-json-web-tokens/

Вернуться на верх