Как создать Flask API с проверкой подлинности по токенам JSON Web Token: Часть 2 из 5: Модели базы данных, миграции и настройка JSON Web Token

Вторая часть руководства по разработке API на Flask с использованием JSON Web Token. Настраиваем классы для моделей базы данных, проверку подлинности веб-токена и тесты с использованием зафиксированных объектов (fixtures) из pytest.

Источник (Aaron Luna): How To: Create a Flask API with JWT-Based Authentication Part 2: Database Models, Migrations and JWT Setup

Содержание

  • Структура проекта
  • Модели баз данных и миграции с использованием Flask-SQLAlchemy
    • Модель User для базы данных
    • Создание первой миграции
  • Проверка подлинности веб-токена JSON
    • Функция encode_access_token
    • Глобальные зафиксированные объекты (fixtures) для тестирования: conftest.py
    • Модульный тест: test_encode_access_token
    • Функция decode_access_token
    • Модульные тесты (Unit Tests): Расшифровка токена доступа
  • Контрольная точка

Часть 1

Часть 2

Часть 3

Репозиторий на github для данного проекта содержит релизы с тегами для каждого раздела руководства; пользуйтесь нижеприведенными ссылками, чтобы просмотреть или загрузить код для Части 2.

View Code

Download .zip file

Download .tar.gz file

Diff Part 2 Part 1

Структура проекта

На нижеприведенной схеме показана структура папок, созданная в Части 1. В данном посте мы будем работать со всеми файлами, помеченными как # НОВЫЙ КОД. Файлы, которые содержат код из части 1 и не будут меняться в данном посте, помечены как # БЕЗ ИЗМЕНЕНИЙ.

. (корневая папка проекта)
|- src
|   |- flask_api_tutorial
|       |- api
|       |   |- auth
|       |   |   |- __init__.py
|       |   |
|       |   |- widgets
|       |   |   |- __init__.py
|       |   |
|       |   |- __init__.py
|       |
|       |- models
|       |   |- __init__.py
|       |   |- user.py    <! –– НОВЫЙ КОД ––>
|       |
|       |- util
|       |   |- __init__.py
|       |   |- datetime_util.py    <! –– БЕЗ ИЗМЕНЕНИЙ––>
|       |   |- result.py    <! –– БЕЗ ИЗМЕНЕНИЙ––>
|       |
|       |- __init__.py    <! –– БЕЗ ИЗМЕНЕНИЙ––>
|       |- config.py    <! –– БЕЗ ИЗМЕНЕНИЙ––>
|
|- tests
|   |- __init__.py
|   |- conftest.py    <! –– НОВЫЙ КОД ––>
|   |- test_config.py    <! –– БЕЗ ИЗМЕНЕНИЙ––>
|   |- test_user.py    <! –– НОВЫЙ КОД ––>
|   |- util.py    <! –– НОВЫЙ КОД ––>
|
|- .env    <! –– БЕЗ ИЗМЕНЕНИЙ––>
|- .gitignore    <! –– БЕЗ ИЗМЕНЕНИЙ––>
|- .pre-commit-config.yaml    <! –– БЕЗ ИЗМЕНЕНИЙ––>
|- pyproject.toml    <! –– БЕЗ ИЗМЕНЕНИЙ––>
|- pytest.ini    <! –– БЕЗ ИЗМЕНЕНИЙ––>
|- README.md    <! –– БЕЗ ИЗМЕНЕНИЙ––>
|- run.py    <! –– НОВЫЙ КОД ––>
|- setup.py    <! –– БЕЗ ИЗМЕНЕНИЙ––>
|- tox.ini    <! –– БЕЗ ИЗМЕНЕНИЙ––>

Модели баз данных и миграции с использованием Flask-SQLAlchemy

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

Распространенная задача, которая, как мне кажется, редко освещается в подобных руководствах, – это управление изменениями, внесенными в базу данных. Для внесения изменений в схему базы данных обычно требуется менять данные, которые уже хранятся в базе данных, или создать миграцию существующих данных. Мы будем использовать расширение Flask-Migrate для решения данной задачи и объясним, как настроить систему миграции, а также как создавать и применять миграции.

Модель User для базы данных

В части 1 мы создали экземпляр расширения Flask-SQLAlchemy с именем db в файле src/flask_api_tutorial/__init__.py и инициализировали его в методе create_app. Объект db содержит функции и классы из sqlalchemy и sqlalchemy.orm.

Каждый раз, когда нам нужно задекларировать новую модель для базы данных (т.е. создать новую таблицу базы данных), мы создаем класс, который является подклассом db.Model. Поскольку мы создаем API, который выполняет проверку подлинности пользователя, нашей первой моделью SQLAlchemy будет класс User, в котором хранятся учетные данные и метаданные зарегистрированных пользователей.

Создаем новый файл user.py в папке src/flask_api_tutorial/models и добавляем следующий контент:

"""Дефиниция класса для модели User."""
from datetime import datetime, timezone
from uuid import uuid4

from flask import current_app
from sqlalchemy.ext.hybrid import hybrid_property

from flask_api_tutorial import db, bcrypt
from flask_api_tutorial.util.datetime_util import (
    utc_now,
    get_local_utcoffset,
    make_tzaware,
    localized_dt_string,
)


class User(db.Model):
    """Модель User для хранения учетных данных и других сведений."""

    __tablename__ = "site_user"

    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    email = db.Column(db.String(255), unique=True, nullable=False)
    password_hash = db.Column(db.String(100), nullable=False)
    registered_on = db.Column(db.DateTime, default=utc_now)
    admin = db.Column(db.Boolean, default=False)
    public_id = db.Column(db.String(36), unique=True, default=lambda: str(uuid4()))

    def __repr__(self):
        return (
            f"<User email={self.email}, public_id={self.public_id}, admin={self.admin}>"
        )

    @hybrid_property
    def registered_on_str(self):
        registered_on_utc = make_tzaware(
            self.registered_on, use_tz=timezone.utc, localize=False
        )
        return localized_dt_string(registered_on_utc, use_tz=get_local_utcoffset())

    @property
    def password(self):
        raise AttributeError("password: write-only field")

    @password.setter
    def password(self, password):
        log_rounds = current_app.config.get("BCRYPT_LOG_ROUNDS")
        hash_bytes = bcrypt.generate_password_hash(password, log_rounds)
        self.password_hash = hash_bytes.decode("utf-8")

    def check_password(self, password):
        return bcrypt.check_password_hash(self.password_hash, password)

    @classmethod
    def find_by_email(cls, email):
        return cls.query.filter_by(email=email).first()

    @classmethod
    def find_by_public_id(cls, public_id):
        return cls.query.filter_by(public_id=public_id).first()

Класс User демонстрирует несколько концепций, которые имеют большое значение при создании моделей для базы данных в SQLAlchemy:

Строка 17: User определяется как подкласс для db.Model. Когда мы создаем подкласс, db.Model «регистрирует» модель с помощью SQLAlchemy, позволяя ORM создать таблицу базы данных на основе определений для столбцов, приведенных в строках 18–23.

Строка 20: Flask-SQLAlchemy автоматически определит имя для таблицы базы данных, преобразовав имя класса (User) в нижний регистр. Однако слово user зарезервировано в нескольких реализациях SQL (например, PostgreSQL, MySQL, MSSQL), а использовать зарезервированное слово в качестве имени таблицы – плохая идея. Можно обойти данное значение по умолчанию, установив атрибут класса __tablename__.

Если создавать в Python имена классов в горбатом регистре, то имена таблиц тоже будут преобразовываться в нижний регистр, а между словами будут вставляться подчеркивания (например, класс Python CamelCase => таблица базы данных camel_case).

Строки 22-27: Для определения столбца используем db.Column. По умолчанию имя столбца будет совпадать с назначенным нами именем атрибута. Первым аргументом для db.Column является тип данных (т.е., db.Integer, db.String(size), db.Boolean). Доступно много разных типов данных общего характера, а также специальных типов данных от вендоров. В нашей таблице site_user будут следующие столбцы:

id: Первичный ключ нашей таблицы, обозначенный через primary_key=True. Мы будем использовать тип данных db.Integer, но можно использовать и другой тип данных или даже указать несколько столбцов в качестве первичного ключа. Также обратите внимание на то, что мы настроили действие «автоинкрементного режима», указав autoincrement = True. Это можно делать только с целочисленными типами данных.

email: Как и большинство сайтов, мы будем использовать адрес электронной почты для идентификации своих пользователей. Обратите внимание на то, что для данного столбца мы установили максимальную длину на 255 символов с помощью db.String(255). Очевидно, что это обязательное значение для всех пользователей, и мы его указываем с помощью nullable=False. Чтобы каждый адрес электронной почты мог зарегистрировать только один пользователь, мы указываем unique=True.

password_hash: Хранить пароли в базе данных – плохая практика. Вместо этого мы будем считать хеш пароля с помощью Flask-Bcrypt и хранить в данном столбце хешированное значение. Когда пользователь попытается войти, мы снова будем использовать Flask-Bcrypt для сравнения введенного пользователем пароля с хешированным значением. Этот момент объясним подробно в данном посте немного позднее.

registered_on: Данный столбец будет содержать дату и время создания учетной записи пользователя. SQLAlchemy предоставляет множество способов для хранения значений даты и времени, но проще всего использовать db.DateTime. Обратите внимание на то, что для данного столбца мы указали значение по умолчанию default=utc_now. Это функция в модуле app.util.datetime_util, которая возвращает текущую дату и время в формате UTC в виде объекта datetime, учитывающего часовой пояс. Когда в базу данных добавляется новый User (Пользователь), текущее время в формате UTC будет оцениваться и сохраняться как значение для registered_on.

В данном проекте предполагается, что все значения datetime имеют формат UTC при записи в базу данных.

admin: Флаг для указания на наличие у пользователя прав администратора. Мы будем использовать тип данных db.Boolean для создания столбца, содержащего только значения TRUE/FALSE. По умолчанию у пользователей не должно быть прав администратора. Мы указываем default = False, чтобы гарантировать такое поведение.

public_id: Данный столбец будет содержать значения UUID (Универсальный уникальный идентификатор). Данный столбец аналогичен столбцу email, так как мы храним значение в виде строки, которое должно быть уникальным для каждого пользователя. Однако, поскольку это случайное значение (т.е. оно не предоставляется пользователем), аналогично столбцу registered_on мы заполняем данный столбец результатом из лямбда-функции, которая вызывается при добавлении нового User в базу данных.

Возможно, вам будет интересно, почему мы используем default=lambda:str(uuid4()), а не default=uuid4. Если вызвать uuid.uuid4(), то возвращается объект UUID, который необходимо преобразовать в строку (string) перед записью в базу данных.

Строки 34-39: Декоратор @hybrid_property - это еще одна функция SQLAlchemy, способная дать гораздо больше, чем то, что я здесь демонстрирую. Чаще всего данный декоратор используется для создания «вычисляемых» или «виртуальных» столбцов, значение которых вычисляется из значений одного или нескольких столбцов. В данном примере столбец registered_on_str преобразует значение datetime, которое хранится в registered_on, в отформатированную строку.

Здесь я использую несколько функций из модуля app.util.datetime_util. Когда значение записывается в базу данных, значение registered_on (и все значения datetime) всегда конвертируется в часовой пояс UTC. Значение registered_on_str преобразует данное значение в часовой пояс компьютера, выполняющего данный код, и форматирует его в строковое значение.

Строки 41-43: Здесь частично реализовано хеширование паролей. Декоратор @property открывает доступ к атрибуту password в нашем классе User. Однако данное значение предназначено только для записи, поэтому, если клиент попытается вызвать user.password и извлечь значение, выдается ошибка AttributeError.

Строки 45-49: Это функция установки (setter function) для password @property, которая вычисляет значение, хранящееся в столбце password_hash. Данная структура сохраняет только хешированное значение и отбрасывает сам пароль. Кроме того, если несколько раз хешировать один и тот же пароль, всегда получается другое значение, поэтому невозможно сравнить значения password_hash, чтобы определить, имеют ли несколько пользователей один и тот же пароль.

Мы используем значение BCRYPT_LOG_ROUNDS из класса Config. Поскольку мы создали свой объект app с помощью шаблона проектирования factory, мы должны получить доступ к экземпляру приложения Flask через прокси-объект current_app (Подробнее можно почитать здесь).

Строки 51-52: Данная функция check_password используется, когда пользователь пытается войти. Переданный в функцию аргумент password - это предоставленное пользователем значение, и оно передается в функцию bcrypt.check_password_hash вместе со значением password_hash, которое создается после регистрации учетной записи пользователем. Функция возвращает True, если предоставленный пользователем пароль совпадает с хешем, а в противном случае - False.

Строки 54-60: Два удобных метода для создания чистого, легкого для чтения способа извлекать учетные записи User на основе значений, хранящихся в столбцах email или public_id. Поскольку данные значения должны быть уникальными для всех User, мы знаем, что из любого метода можно вернуть только одного пользователя или никого.

Вы заметили, что мы не определили метод __init__? Это потому, что в SQLAlchemy ко всем классам модели добавляется неявный конструктор, который принимает аргументы с ключевым словом для всех своих столбцов и связей. Если вы по какой-нибудь причине решили обойти конструктор, обязательно продолжайте принимать **kwargs и вызывайте конструктор super с этими **kwargs, чтобы данное поведение сохранилось:

class User(db.Model):
    # ...
    def __init__(self, **kwargs):
        super(User, self).__init__(**kwargs)
        # специальные действия

Создание первой миграции

Каждый раз, когда мы вносим изменения в схему своей базы данных (например, добавляем новую таблицу, меняем имя столбца, меняем зависимости для внешнего ключа и т.д.), нам необходимо создавать миграцию с помощью расширения Flask-Migrate. Каждая миграция сохраняется в базе данных миграции (на самом деле, это просто папка с именем migrations), которая также отслеживает порядок создания миграций. Это позволяет нам обновить или откатить свою базу данных до исходного состояния.

Расширение Flask-Migrate добавляет новый набор команд в интерфейс командной строки Flask, сгруппированный во flask db. Чтобы создать базу данных миграции, нужно запустить flask db init:

(flask-api-tutorial) flask-api-tutorial $ flask db init
  Creating directory /Users/aaronluna/Projects/flask-api-tutorial/migrations ...  done
  Creating directory /Users/aaronluna/Projects/flask-api-tutorial/migrations/versions ...  done
  Generating /Users/aaronluna/Projects/flask-api-tutorial/migrations/script.py.mako ...  done
  Generating /Users/aaronluna/Projects/flask-api-tutorial/migrations/env.py ...  done
  Generating /Users/aaronluna/Projects/flask-api-tutorial/migrations/README ...  done
  Generating /Users/aaronluna/Projects/flask-api-tutorial/migrations/alembic.ini ...  done
  Please edit configuration/connection/logging settings in '/Users/aaronluna/Projects/flask-api-tutorial/migrations/alembic.ini' before proceeding.

Чтобы Flask-Migrate обнаружил модель User, нужно импортировать ее в модуль run.py. Открываем run.py в корневой папке проекта и вносим выделенные ниже изменения (строки 5 и 12):

"""Точка входа в приложение/интерфейс командной строки Flask."""
import os

from flask_api_tutorial import create_app, db
from flask_api_tutorial.models.user import User

app = create_app(os.getenv("FLASK_ENV", "development"))


@app.shell_context_processor
def shell():
    return {"db": db, "User": User}

Строка 5: Класс User будет обнаружен расширением Flask-Migrate как новая таблица базы данных, только если есть данный оператор импорта.

Строка 12: Мы добавили объект User в словарь, который импортируется командой flask shell. Благодаря этому данный класс будет доступен в контексте оболочки без необходимости в явном импорте.

Изменения, которые мы только что внесли в run.py, будут повторяться при каждом добавлении новой модели. В целом, каждый раз, когда мы добавляем новый класс модели базы данных в свой проект, необходимо обновить точку входа в приложение (в нашем случае файл run.py, чтобы импортировать новый класс с моделью перед запуском команды flask db migrate.

Хорошо, после внесения изменений в run.py мы готовы создать свою первую миграцию. Для этого мы используем команду flask db migrate. Также я рекомендую добавить сообщение с описанием изменений, которые будут внесены в схему, см. далее:

(flask-api-tutorial) flask-api-tutorial $ flask db migrate --message "add User model"
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'site_user'
  Generating /Users/aaronluna/Projects/flask-api-tutorial/migrations/versions/eb6c2faa0708_add_user_model.py ...  done

Команда flask db migrate создает сценарий миграции, но не применяет изменения к базе данных. Чтобы обновить базу данных и выполнить скрипт миграции, необходимо выполнить команду flask db upgrade:

(flask-api-tutorial) flask-api-tutorial $ flask db upgrade
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade  -> eb6c2faa0708, add User model

При каждом изменении схемы базы данных повторяйте вышеуказанные шаги: flask db migrate и flask db upgrade. Не забывайте добавлять сообщение с описанием изменений в схеме при создании новой миграции с помощью flask db migrate.

Мы можем убедиться в том, что таблица site_user создана с помощью flask shell и модуля sqlite3:

(flask-api-tutorial) flask-api-tutorial $ flask shell
Python 3.7.6 (default, Jan 19 2020, 06:08:58)
[Clang 11.0.0 (clang-1100.0.33.8)] on darwin
App: app [development]
Instance: /Users/aaronluna/Projects/flask_api_tutorial/instance
>>> import sqlite3
>>> from pathlib import Path
>>> DATABASE_URL = app.config.get("SQLALCHEMY_DATABASE_URI")
>>> db = sqlite3.connect(Path(DATABASE_URL).name)
>>> cursor = db.cursor()
>>> results = cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
>>> for row in results:
...     print(row)
...
('alembic_version',)
('site_user',)
>>> exit()

Выполненный нами запрос извлекает все строки из таблицы sqlite_master, в которой столбец type содержит table. Поэтому возвращено две строки: alembic_version и site_user. Последнее подтверждает, что при выполнении обновления успешно создана таблица, которую мы указали в user.py.

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

Проверка подлинности веб-токена JSON

В реализуемом нами процессе проверки подлинности пользователя используются веб-токены JSON (JSON Web Token), подробное описание которых приведено в Части 1. Далее приводится общий рабочий процесс:

Регистрация нового пользователя
1. Клиент направляет запрос с адресом электронной почты и паролем.
2. Сервер проверяет, чтобы адрес электронной почты еще не был зарегистрирован. Если это так, сервер создает новый объект User и направляет ответ, содержащий JSON Web Token в поле access_token.
3. Клиент сохраняет access_token.

Вход существующего пользователя
1. Клиент направляет запрос с адресом электронной почты и паролем.
2. Сервер извлекает объект User с предоставленным клиентом адресом электронной почты.
3. Сервер проверяет, что пароль для объекта User правильный и направляет ответ, содержащий JSON Web Token в поле access_token, если пароль правильный.
4. Клиент сохраняет access_token.

После успешного выполнения входа/регистрации
1. Клиент направляет запрос с JSON Web Token в поле Authorization заголовка запроса.
2. Если запрошенный ресурс требует авторизации, сервер декодирует токен доступа и позволяет клиенту получить доступ к запрошенному ресурсу, если токен правильный.
3. Декодированный токен содержит флаг admin. Если запрашиваемый ресурс требует прав доступа администратора, сервер предоставляет клиенту доступ к ресурсу, только если admin = True.

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

Функция encode_access_token

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

Сначала обновим операторы импорта в user.py, чтобы включить в них datetime.timedelta и пакет jwt (строки 2 и 5):

"""Дефиниция класса для модели User."""
from datetime import datetime, timedelta, timezone
from uuid import uuid4

import jwt
from flask import current_app
from sqlalchemy.ext.hybrid import hybrid_property

from flask_api_tutorial import db, bcrypt
from flask_api_tutorial.util.datetime_util import (
    utc_now,
    get_local_utcoffset,
    make_tzaware,
    localized_dt_string,
)

Затем добавим метод encode_access_token в user.py:

def encode_access_token(self):
    now = datetime.now(timezone.utc)
    token_age_h = current_app.config.get("TOKEN_EXPIRE_HOURS")
    token_age_m = current_app.config.get("TOKEN_EXPIRE_MINUTES")
    expire = now + timedelta(hours=token_age_h, minutes=token_age_m)
    if current_app.config["TESTING"]:
        expire = now + timedelta(seconds=5)
    payload = dict(exp=expire, iat=now, sub=self.public_id, admin=self.admin)
    key = current_app.config.get("SECRET_KEY")
    return jwt.encode(payload, key, algorithm="HS256")

Давайте разберемся, как данный метод генерирует токен доступа:

Строки 57-58: Используя прокси-объект curent_app, мы извлекаем параметры конфигурации TOKEN_EXPIRE_HOURS и TOKEN_EXPIRE_MINUTES. Не забывайте, что мы определили разные значения для данных параметров для каждой среды (development, testing, production).

Строка 59: Вычисляем для токена время истечения срока действия на основе параметров конфигурации и текущего времени.

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

Строка 62: В объекте с полезной нагрузкой (payload object) хранятся данные о токене и пользователе. Полезная нагрузка содержит набор пар ключ/значение, известных как «сущности» (дополнительную информацию о сущностях см. в Части 1). Наш токен будет содержать следующие зарегистрированные сущности:

  • exp: Дата/время истечения срока действия токена
  • iat: Дата/время генерации токена
  • sub: Субъект токена (т.е. пользователь, для которого сгенерирован токен)

Наш токен также содержит одну приватную сущность:

  • admin: Значение True/False, указывающее на наличие у User прав доступа администратора.

Строка 63: Чтобы вычислить подпись токена, нужно извлечь параметр конфигурации SECRET_KEY. Мы будем использовать это же значение для декодирования токена и гарантии отсутствия изменений в его содержании.

Строка 64: Функция jwt.encode() принимает три аргумента. Первые два из них мы только что описали: полезная нагрузка и секретный ключ. Третий аргумент – это алгоритм подписи. В большинстве приложений используется алгоритм HS256, который представляет собой сокращенное название HMAC-SHA256. Алгоритм подписи – это то, что защищает полезную нагрузку в JSON Web Token от взлома.

Глобальные зафиксированные объекты для тестов: conftest.py

Чтобы протестировать метод encode_access_token, нам понадобится объект User (для которого необходимо подключение к базе данных, для которого необходим экземпляр приложения Flask и т.д.). Для фреймворка unittest правильный способ выполнения данной задачи будет включать в себя создание базового тестового класса с методом для настройки, который создает экземпляр приложения и инициализирует базу данных. Затем мы создаем классы, наследующие базовый класс теста; благодаря этому метод для настройки будет доступен без дублирования одного и того же кода в каждом классе теста.

В данном подходе нет ничего плохого, но это (на мой взгляд) не лучшее и не самое простое решение. С помощью pytest можно полностью избавиться от шаблонного кода, необходимого для наследования классов; мы даже можем полностью избавиться от классов. Каким образом? Разумеется, с помощью зафиксированных объектов!

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

Сначала создадим файл с именем util.py в папке tests и добавим следующее содержимое:

"""Общие функции и константы для модульных тестов."""

EMAIL = "This email address is being protected from spambots. You need JavaScript enabled to view it."
PASSWORD = "test1234"

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

Затем создаем новый файл conftest.py в папке tests и добавим следующее содержимое:

"""Глобальные зафиксированные объекты pytest."""
import pytest

from flask_api_tutorial import create_app
from flask_api_tutorial import db as database
from flask_api_tutorial.models.user import User
from tests.util import EMAIL, PASSWORD


@pytest.fixture
def app():
    app = create_app("testing")
    return app


@pytest.fixture
def db(app, client, request):
    database.drop_all()
    database.create_all()
    database.session.commit()

    def fin():
        database.session.remove()

    request.addfinalizer(fin)
    return database


@pytest.fixture
def user(db):
    user = User(email=EMAIL, password=PASSWORD)
    db.session.add(user)
    db.session.commit()
    return user

conftest.py – это специальное имя файла, которое pytest ищет автоматически и из которого он загружает тестовые зафиксированные объекты, предоставляя к ним доступ для всех функций, которые находятся в одной папке с conftest.py (и в ее подпапках). Не нужно добавлять оператора импорта ни в какие тестовые функции, чтобы использовать определенные нами зафиксированные app или db.

Кроме того, особый случай представляет собой зафиксированный объект app. Расширение pytest-flask ищет зафиксированный объект app и автоматически создает зафиксированный объект client, возвращающий экземпляр тестового клиента Flask, который мы будем использовать для тестирования API-вызовов. Дополнительную информацию про pytest-flask можно прочитать в официальной документации.

Вот еще несколько моментов, которые следует отметить в отношении зафиксированных объектов, которые мы определили в conftest.py:

Строка 5: Объект db, импортированный из модуля app (который является объектом расширения Flask-SQLAlchemy), переименовывается в database, так как мы будем использовать имя db для тестового зафиксированного объекта, внедряющего объект database в наши тестовые функции.

Строка 17: Зафиксированный объект db использует зафиксированный объект client из pytest-flask. Параметр request – это еще одна специальная фича из pytest, которую можно использовать в качестве параметра в любой функции с зафиксированным объектом. Объект request предоставляет доступ к контексту теста, в котором запрашивается зафиксированный объект.

Не стоит путать объект request из pytest и глобальный объект request из Flask. Первый используется зафиксированным объектом для выполнения любого процесса прерывания (teardown)/уничтожения (destruction) тестового объекта, созданного зафиксированным объектом. Последний представляет собой HTTP-запрос, полученный приложением Flask, и содержит тело, заголовки HTML и т.д., отправленные клиентом.

Строки 18-20: Именно так я предпочитаю прерывать/создавать базу данных, используемую для тестирования. Возможно, вы заметили, что я не удаляю базу данных после каждого запуска теста, что является общераспространенной практикой, а просто удаляю все таблицы перед началом нового тестового сценария. Благодаря этому я могу проверять базу данных после неудачного запуска теста, так как данные все еще доступны.

Строки 22-25: Функция fin регистрируется с помощью метода addFinalizer в объекте request из pytest. Функция fin будет выполняться после завершения тестовой функции и в данном случае удаляет сеанс базы данных. Вы увидите, что данный паттерн повторяется во всех зафиксированных объектах, для которых требуется какой-то процесс прерывания, чтобы освободить ресурсы, выделенные функцией с зафиксированным объектом.

Строка 30: Здесь показано, что зафиксированные объекты могут включать в себя другие зафиксированные объекты. Зафиксированный объект user опирается на зафиксированный объект db, который опирается на зафиксированный объект client (т.е. app). Там сплошь одни зафиксированные объекты!

Данный зафиксированный объект создает новый, обычный (не администраторский) экземпляр User и передает данный экземпляр в базу данных. Объект User возвращается в тестовую функцию.

Модульные тесты (Unit Tests): test_encode_access_token

Наконец-то мы готовы написать тестовый код, проверяющий метод encode_access_token. Создаем новый файл в папке tests с именем test_user.py и добавим следующее содержимое (проверьте, чтобы test_user.py и conftest.py были в одной папке):

"""Модульные тесты класса для модели User."""


def test_encode_access_token(user):
    access_token = user.encode_access_token()
    assert isinstance(access_token, bytes)

Запускаем команду tox и проверяем, чтобы все тесты были успешными:

(flask-api-tutorial) flask-api-tutorial $ tox
GLOB sdist-make: /Users/aaronluna/Projects/flask-api-tutorial/setup.py
py37 create: /Users/aaronluna/Projects/flask-api-tutorial/.tox/py37
py37 installdeps: black, flake8, pydocstyle, pytest, pytest-black, pytest-clarity, pytest-dotenv, pytest-flake8, pytest-flask
py37 inst: /Users/aaronluna/Projects/flask-api-tutorial/.tox/.tmp/package/1/flask-api-tutorial-0.1.zip
py37 installed: alembic==1.4.0,aniso8601==8.0.0,appdirs==1.4.3,attrs==19.3.0,bcrypt==3.1.7,black==19.10b0,certifi==2019.11.28,cffi==1.14.0,chardet==3.0.4,Click==7.0,entrypoints==0.3,flake8==3.7.9,Flask==1.1.1,flask-api-tutorial==0.1,Flask-Bcrypt==0.7.1,Flask-Cors==3.0.8,Flask-Migrate==2.5.2,flask-restx==0.1.1,Flask-SQLAlchemy==2.4.1,idna==2.9,importlib-metadata==1.5.0,itsdangerous==1.1.0,Jinja2==2.11.1,jsonschema==3.2.0,Mako==1.1.1,MarkupSafe==1.1.1,mccabe==0.6.1,more-itertools==8.2.0,packaging==20.1,pathspec==0.7.0,pluggy==0.13.1,py==1.8.1,pycodestyle==2.5.0,pycparser==2.19,pydocstyle==5.0.2,pyflakes==2.1.1,PyJWT==1.7.1,pyparsing==2.4.6,pyrsistent==0.15.7,pytest==5.3.5,pytest-black==0.3.8,pytest-clarity==0.3.0a0,pytest-dotenv==0.4.0,pytest-flake8==1.0.4,pytest-flask==0.15.1,python-dateutil==2.8.1,python-dotenv==0.11.0,python-editor==1.0.4,pytz==2019.3,regex==2020.2.20,requests==2.23.0,six==1.14.0,snowballstemmer==2.0.0,SQLAlchemy==1.3.13,termcolor==1.1.0,toml==0.10.0,typed-ast==1.4.1,urllib3==1.25.8,wcwidth==0.1.8,Werkzeug==0.16.1,zipp==3.0.0
py37 run-test-pre: PYTHONHASHSEED='1533942126'
py37 run-test: commands[0] | pytest
================================================= test session starts ==================================================
platform darwin -- Python 3.7.6, pytest-5.3.5, py-1.8.1, pluggy-0.13.1 -- /Users/aaronluna/Projects/flask-api-tutorial/.tox/py37/bin/python
cachedir: .tox/py37/.pytest_cache
rootdir: /Users/aaronluna/Projects/flask-api-tutorial, inifile: pytest.ini
plugins: clarity-0.3.0a0, black-0.3.8, dotenv-0.4.0, flask-0.15.1, flake8-1.0.4
collected 38 items

run.py::FLAKE8 PASSED                                                                                            [  2%]
run.py::BLACK PASSED                                                                                             [  5%]
setup.py::FLAKE8 PASSED                                                                                          [  7%]
setup.py::BLACK PASSED                                                                                           [ 10%]
src/flask_api_tutorial/__init__.py::FLAKE8 PASSED                                                                [ 13%]
src/flask_api_tutorial/__init__.py::BLACK PASSED                                                                 [ 15%]
src/flask_api_tutorial/config.py::FLAKE8 PASSED                                                                  [ 18%]
src/flask_api_tutorial/config.py::BLACK PASSED                                                                   [ 21%]
src/flask_api_tutorial/api/__init__.py::FLAKE8 PASSED                                                            [ 23%]
src/flask_api_tutorial/api/__init__.py::BLACK PASSED                                                             [ 26%]
src/flask_api_tutorial/api/auth/__init__.py::FLAKE8 PASSED                                                       [ 28%]
src/flask_api_tutorial/api/auth/__init__.py::BLACK PASSED                                                        [ 31%]
src/flask_api_tutorial/api/widgets/__init__.py::FLAKE8 PASSED                                                    [ 34%]
src/flask_api_tutorial/api/widgets/__init__.py::BLACK PASSED                                                     [ 36%]
src/flask_api_tutorial/models/__init__.py::FLAKE8 PASSED                                                         [ 39%]
src/flask_api_tutorial/models/__init__.py::BLACK PASSED                                                          [ 42%]
src/flask_api_tutorial/models/user.py::FLAKE8 PASSED                                                             [ 44%]
src/flask_api_tutorial/models/user.py::BLACK PASSED                                                              [ 47%]
src/flask_api_tutorial/util/__init__.py::FLAKE8 PASSED                                                           [ 50%]
src/flask_api_tutorial/util/__init__.py::BLACK PASSED                                                            [ 52%]
src/flask_api_tutorial/util/datetime_util.py::FLAKE8 PASSED                                                      [ 55%]
src/flask_api_tutorial/util/datetime_util.py::BLACK PASSED                                                       [ 57%]
src/flask_api_tutorial/util/result.py::FLAKE8 PASSED                                                             [ 60%]
src/flask_api_tutorial/util/result.py::BLACK PASSED                                                              [ 63%]
tests/__init__.py::FLAKE8 PASSED                                                                                 [ 65%]
tests/__init__.py::BLACK PASSED                                                                                  [ 68%]
tests/conftest.py::FLAKE8 PASSED                                                                                 [ 71%]
tests/conftest.py::BLACK PASSED                                                                                  [ 73%]
tests/test_config.py::FLAKE8 PASSED                                                                              [ 76%]
tests/test_config.py::BLACK PASSED                                                                               [ 78%]
tests/test_config.py::test_config_development PASSED                                                             [ 81%]
tests/test_config.py::test_config_testing PASSED                                                                 [ 84%]
tests/test_config.py::test_config_production PASSED                                                              [ 86%]
tests/test_user.py::FLAKE8 PASSED                                                                                [ 89%]
tests/test_user.py::BLACK PASSED                                                                                 [ 92%]
tests/test_user.py::test_encode_access_token PASSED                                                              [ 94%]
tests/util.py::FLAKE8 PASSED                                                                                     [ 97%]
tests/util.py::BLACK PASSED                                                                                      [100%]

================================================== 38 passed in 6.33s ==================================================
_______________________________________________________ summary ________________________________________________________
  py37: commands succeeded
  congratulations :)

Функция decode_access_token

Перейдем к очевидному следующему шагу в нашем рабочем процессе авторизации: расшифровка токенов. Нам нужно обновить операторы импорта в user.py, добавив класс Result (строка 16 на нижеприведенном примере):

"""Дефиниция класса для модели User."""
from datetime import datetime, timedelta, timezone
from uuid import uuid4

import jwt
from flask import current_app
from sqlalchemy.ext.hybrid import hybrid_property

from flask_api_tutorial import db, bcrypt
from flask_api_tutorial.util.datetime_util import (
    utc_now,
    get_local_utcoffset,
    make_tzaware,
    localized_dt_string,
)
from flask_api_tutorial.util.result import Result

Затем добавим метод decode_access_token в файл user.py:

@staticmethod
def decode_access_token(access_token):
    if isinstance(access_token, bytes):
        access_token = access_token.decode("ascii")
    if access_token.startswith("Bearer "):
        split = access_token.split("Bearer")
        access_token = split[1].strip()
    try:
        key = current_app.config.get("SECRET_KEY")
        payload = jwt.decode(access_token, key, algorithms=["HS256"])
    except jwt.ExpiredSignatureError:
        error = "Access token expired. Please log in again."
        return Result.Fail(error)
    except jwt.InvalidTokenError:
        error = "Invalid token. Please log in again."
        return Result.Fail(error)

    user_dict = dict(
        public_id=payload["sub"],
        admin=payload["admin"],
        token=access_token,
        expires_at=payload["exp"],
    )
    return Result.Ok(user_dict)

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

Строка 67: Декоратор @staticmethod указывает, что данный метод не привязан ни к экземпляру User, ни к классу User. Это означает, что нет никакой практической разницы между определением метода decode_access_token в классе User в качестве статического метода и его определением в качестве обычной функции внутри какого-нибудь модуля.

Есть только одна причина определять любой метод как статический: сгруппировать связанные между собой поведения в общем пространстве имен. Просто мне кажется, что, поскольку метод encode_access_token должен быть привязан к экземпляру User, определить decode_access_token в классе User будет логично и эстетично.

Строки 69-70: В зависимости от того, каким образом access_token передан в функцию decode_access_token, это может быть массив байтов (byte-array) или строка (string). Прежде чем продолжить, мы преобразуем access_token в строку, если это нужно.

Строки 71-73: Можно добавить данный шаг валидации на более позднем этапе, так как он понадобится после определения маршрутов API и добавления Flask-RESTx. Добавлю данный момент сейчас и говорю вам следующее: Иногда поле Authorization в заголовке запроса будет иметь префикс Bearer, а иногда – нет. При декодировании токенов доступа нам нужно охватывать обе ситуации.

Строка 75: Поскольку подпись токена рассчитывалась с помощью SECRET_KEY, нам нужно использовать то же значение для декодирования токена.

Строка 76: Функция jwt.decode принимает три аргумента: токен доступа, секретный ключ и список алгоритмов подписи, которые принимает приложение. Если токен доступа правильный, не менялся и срок его действия не истек, возвращаемое значение функции jwt.decode (payload) будет представлять собой словарь с временем создания и истечения срока действия токена, публичным ID пользователя и логическим значением, указывающим на наличие у пользователя прав администратора.

Строки 77-79: Данный код будет выполняться, только если срок действия токена истек. Обратите внимание на то, что нам не нужно выполнять никакую работу, чтобы проверить, истек токен или нет; это обрабатывается функцией jwt.decode. Если срок действия токена истек, возникнет ошибка jwt.ExpiredSignatureError. Поскольку это ожидаемый сбой, мы перехватываем его и создаем объект Result с описывающим сбой сообщением об ошибке и возвращаем Result.

Строки 80-82: Данный код будет выполняться только в случае сбоя процесса валидации подписи. Так могло бы случиться при любом вмешательстве в токен или его изменении, и мы создадим модульные тесты, чтобы убедиться в том, что данный момент работает как полагается. Опять же, нам не нужно выполнять никакую работу, чтобы определить, было ли вмешательство в токен; данный процесс обрабатывается функцией jwt.decode. Если подпись неправильная, возникнет ошибка jwt.InvalidTokenError. Поскольку это ожидаемый сбой, мы перехватываем его и создаем объект Result с описывающим сбой сообщением об ошибке и возвращаем Result.

Строки 84-90: Данный код выполняется, только если токен прошел все критерии валидации: формат токена правильный, подпись правильная (т.е. в токене не было изменений/вмешательства) и срок действия токена не истек. В данном случае мы создаем объект dict, содержащий проверенный access_token, отметку времени истечения срока действия токена, public_id пользователя и флаг о правах администратора из объекта payload. Мы возвращаем словарь в объекте Result, который указывает на успешность операции.

Модульные тесты (Unit Tests): Расшифровка токена доступа

Мы хотим проверить три возможных результата метода decode_access_token: токен правильный, токен просрочен и токен неправильный. Прежде чем мы начнем писать тесты, добавим следующие операторы импорта в верхней части test_user.py:

"""Модульные тесты для класса модели User."""
import json
import time
from base64 import urlsafe_b64encode, urlsafe_b64decode

from flask_api_tutorial.models.user import User

Первый тест проверит ожидаемое поведение для правильного токена доступа. Добавим метод test_decode_access_token_success в test_user.py:

def test_decode_access_token_success(user):
    access_token = user.encode_access_token()
    result = User.decode_access_token(access_token)
    assert result.success
    user_dict = result.value
    assert user.public_id == user_dict["public_id"]
    assert user.admin == user_dict["admin"]

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

Строка 17: Метод decode_access_token возвращает объект Result, поэтому мы сначала проверяем, что result.success имеет значение True. Если это так, мы знаем, что access_token правильный и успешно декодирован.

Строка 18: Поскольку access_token правильный, мы знаем, что result.value содержит объект user_dict.

Строки 19-20: Значения public_id и admin в user_dict должны соответствовать пользователю, создавшему access_token.

Затем создаем тест для проверки того, что происходит, когда мы пытаемся декодировать токен доступа с истекшим сроком действия. Добавим метод test_decode_access_token_expired в test_user.py:

def test_decode_access_token_expired(user):
    access_token = user.encode_access_token()
    time.sleep(6)
    result = User.decode_access_token(access_token)
    assert not result.success
    assert result.error == "Access token expired. Please log in again."

Пройдем по данному тесту и объясним, как мы достигли желаемого результата:

Строка 25: Как уже неоднократно указывалось, если используются параметры TestConfig, токены авторизации истекают через пять секунд. Сразу после генерации access_token мы вызываем time.sleep(6), который ждет шесть секунд перед попыткой декодировать токен доступа.

Строка 27: Поскольку мы ожидаем, что decode_access_token вернет объект Result, указывающий на то, что access_token не удалось декодировать, мы ожидаем, что значение result.success будет False.

Строка 28: Поскольку access_token не удалось декодировать, мы проверяем, что result.error указывает на истечение срока действия токена как на причину сбоя.

Последний тест, безусловно, самый интересный из этих трех. Очевидно, что легко внести частичное изменение в JSON Web Token и направить его вместо сгенерированного сервером токена. Например, что, если пользователь в своем токене поменяет сущность admin с False на True? Сможет ли он получить доступ к ресурсам, требующим прав доступа администратора, даже если ему не предоставлен необходимый доступ? Давайте попробуем!

Добавим метод test_decode_access_token_invalid в test_user.py:

def test_decode_access_token_invalid(user):
    access_token = user.encode_access_token()
    split = access_token.split(b".")
    payload_base64 = split[1]
    pad_len = 4 - (len(payload_base64) % 4)
    payload_base64 += pad_len * b"="
    payload_str = urlsafe_b64decode(payload_base64)
    payload = json.loads(payload_str)
    assert not payload["admin"]
    payload["admin"] = True
    payload_mod = json.dumps(payload)
    payload_mod_base64 = urlsafe_b64encode(payload_mod.encode())
    split[1] = payload_mod_base64.strip(b"=")
    access_token_mod = b".".join(split)
    assert not access_token == access_token_mod
    result = User.decode_access_token(access_token_mod)
    assert not result.success
    assert result.error == "Invalid token. Please log in again."

Вместо того, чтобы, как раньше, объяснять данный тестовый сценарий построчно, думаю, проще будет выполнить тест в интерпретаторе flask shell и вывести значения нескольких важных переменных:

(flask-api-tutorial) flask-api-tutorial $ flask shell
Python 3.7.6 (default, Jan 19 2020, 06:08:58)
[Clang 11.0.0 (clang-1100.0.33.8)] on darwin
App: flask-api-tutorial [development]
Instance: /Users/aaronluna/Projects/flask_api_tutorial
>>> import json
>>> from base64 import urlsafe_b64encode, urlsafe_b64decode
>>> from flask_api_tutorial import db
>>> from flask_api_tutorial.models.user import User
>>> USER_EMAIL = "This email address is being protected from spambots. You need JavaScript enabled to view it."
>>> USER_PASSWORD = "test1234"
>>> user = User(email=USER_EMAIL, password=USER_PASSWORD)
>>> db.session.add(user)
>>> db.session.commit()
>>> access_token = user.encode_access_token()
>>> split = access_token.split(b'.')
>>> print(f'access_token (original):\n  header: {split[0]}\n  payload: {split[1]}\n  signature: {split[2]}')
access_token (original):
  header: b'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9'
  payload: b'eyJleHAiOjE1NTc5NDA3MDAsImlhdCI6MTU1NzkzOTc5NSwic3ViIjoiMzhjMDMwYTAtNTdhNC00NmRjLWFjOWYtZTcwZDA0OWUzMDE2IiwiYWRtaW4iOmZhbHNlfQ'
  signature: b'EvNgTtgbgxpEmJedAOwLuxf6YSq09N2GCRmQOxF2REs'

Значение payload представляет собой полезную нагрузку в кодировке base64 из исходного токена доступа, сгенерированного сервером. Затем мы декодируем полезную нагрузку, чтобы найти значения для сущностей пользователя.

>>> payload_base64 = split[1] >>> pad_len = 4 - (len(payload_base64) % 4) >>> payload_base64 += pad_len * b'=' >>> payload_str = urlsafe_b64decode(payload_base64)
>>> payload = json.loads(payload_str)
>>> print(f'payload (original):\n {json.dumps(payload, indent=2)}')
payload (original):
 {
  "exp": 1557940700,
  "iat": 1557939795,
  "sub": "38c030a0-57a4-46dc-ac9f-e70d049e3016",
  "admin": false
}

Обратите внимание на то, что любой может декодировать полезную нагрузку токена доступа с помощью функции urlsafe_b64decode, как показано выше. Именно поэтому никогда нельзя хранить важные данные пользователя (особенно пароли) в JSON Web Token.

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

Поскольку любой может редактировать значение полезной нагрузки, прежде чем отправить ее обратно на сервер, нам нужно гарантировать, что токен будет отклонен в случае наличия в нем изменений. Особенно важно убедиться в том, что, если для параметра «admin» значение изменилось на «True», пользователю не будет предоставлен доступ к защищенным ресурсам.

>>> payload['admin'] = True
>>> print(f'payload (modified):\n {json.dumps(payload, indent=2)}')
payload (modified):
 {
  "exp": 1557940700,
  "iat": 1557939795,
  "sub": "38c030a0-57a4-46dc-ac9f-e70d049e3016",
  "admin": true
}

Мы изменили значение для «admin» на True и вывели измененное значение полезной нагрузки, чтобы показать, что теперь значение изменено.

>>> payload_mod = json.dumps(payload)
>>> payload_mod_base64 = urlsafe_b64encode(payload_mod.encode())
>>> split[1] = payload_mod_base64.strip(b'=')
>>> print(f'access_token (modified):\n  header: {split[0]}\n  payload: {split[1]}\n  signature: {split[2]}')
access_token (modified):
  header: b'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9'
  payload: b'eyJleHAiOiAxNTU3OTQwNzAwLCAiaWF0IjogMTU1NzkzOTc5NSwgInN1YiI6ICIzOGMwMzBhMC01N2E0LTQ2ZGMtYWM5Zi1lNzBkMDQ5ZTMwMTYiLCAiYWRtaW4iOiB0cnVlfQ'
  signature: b'EvNgTtgbgxpEmJedAOwLuxf6YSq09N2GCRmQOxF2REs'

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

>>> access_token_mod = b'.'.join(split)
>>> result = User.decode_access_token(access_token_mod)
>>> result.success
False
>>> result.error
'Invalid token. Please log in again.'
>>> exit()

Как и ожидалось, измененный токен доступа не декодируется, и сообщение об ошибке указывает на то, что токен неправильный; это ожидаемо, если содержимое токена изменено. Поэтому наш гипотетический пользователь-злоумышленник не сможет предоставить себе доступ администратора, изменив JSON Web Token. Кризис предотвращен!

Запустим tox и убедимся в том, что все тестовые сценарии выполняются успешно:

(flask-api-tutorial) flask-api-tutorial $ tox
GLOB sdist-make: /Users/aaronluna/Projects/flask-api-tutorial/setup.py
py37 create: /Users/aaronluna/Projects/flask-api-tutorial/.tox/py37
py37 installdeps: black, flake8, pydocstyle, pytest, pytest-black, pytest-clarity, pytest-dotenv, pytest-flake8, pytest-flask
py37 inst: /Users/aaronluna/Projects/flask-api-tutorial/.tox/.tmp/package/1/flask-api-tutorial-0.1.zip
py37 installed: alembic==1.4.0,aniso8601==8.0.0,appdirs==1.4.3,attrs==19.3.0,bcrypt==3.1.7,black==19.10b0,certifi==2019.11.28,cffi==1.14.0,chardet==3.0.4,Click==7.0,entrypoints==0.3,flake8==3.7.9,Flask==1.1.1,flask-api-tutorial==0.1,Flask-Bcrypt==0.7.1,Flask-Cors==3.0.8,Flask-Migrate==2.5.2,flask-restx==0.1.1,Flask-SQLAlchemy==2.4.1,idna==2.9,importlib-metadata==1.5.0,itsdangerous==1.1.0,Jinja2==2.11.1,jsonschema==3.2.0,Mako==1.1.1,MarkupSafe==1.1.1,mccabe==0.6.1,more-itertools==8.2.0,packaging==20.1,pathspec==0.7.0,pluggy==0.13.1,py==1.8.1,pycodestyle==2.5.0,pycparser==2.19,pydocstyle==5.0.2,pyflakes==2.1.1,PyJWT==1.7.1,pyparsing==2.4.6,pyrsistent==0.15.7,pytest==5.3.5,pytest-black==0.3.8,pytest-clarity==0.3.0a0,pytest-dotenv==0.4.0,pytest-flake8==1.0.4,pytest-flask==0.15.1,python-dateutil==2.8.1,python-dotenv==0.11.0,python-editor==1.0.4,pytz==2019.3,regex==2020.2.20,requests==2.23.0,six==1.14.0,snowballstemmer==2.0.0,SQLAlchemy==1.3.13,termcolor==1.1.0,toml==0.10.0,typed-ast==1.4.1,urllib3==1.25.8,wcwidth==0.1.8,Werkzeug==0.16.1,zipp==3.0.0
py37 run-test-pre: PYTHONHASHSEED='2592492654'
py37 run-test: commands[0] | pytest
================================================= test session starts ==================================================
platform darwin -- Python 3.7.6, pytest-5.3.5, py-1.8.1, pluggy-0.13.1 -- /Users/aaronluna/Projects/flask-api-tutorial/.tox/py37/bin/python
cachedir: .tox/py37/.pytest_cache
rootdir: /Users/aaronluna/Projects/flask-api-tutorial, inifile: pytest.ini
plugins: clarity-0.3.0a0, black-0.3.8, dotenv-0.4.0, flask-0.15.1, flake8-1.0.4
collected 41 items

run.py::FLAKE8 PASSED                                                                                            [  2%]
run.py::BLACK PASSED                                                                                             [  4%]
setup.py::FLAKE8 PASSED                                                                                          [  7%]
setup.py::BLACK PASSED                                                                                           [  9%]
src/flask_api_tutorial/__init__.py::FLAKE8 PASSED                                                                [ 12%]
src/flask_api_tutorial/__init__.py::BLACK PASSED                                                                 [ 14%]
src/flask_api_tutorial/config.py::FLAKE8 PASSED                                                                  [ 17%]
src/flask_api_tutorial/config.py::BLACK PASSED                                                                   [ 19%]
src/flask_api_tutorial/api/__init__.py::FLAKE8 PASSED                                                            [ 21%]
src/flask_api_tutorial/api/__init__.py::BLACK PASSED                                                             [ 24%]
src/flask_api_tutorial/api/auth/__init__.py::FLAKE8 PASSED                                                       [ 26%]
src/flask_api_tutorial/api/auth/__init__.py::BLACK PASSED                                                        [ 29%]
src/flask_api_tutorial/api/widgets/__init__.py::FLAKE8 PASSED                                                    [ 31%]
src/flask_api_tutorial/api/widgets/__init__.py::BLACK PASSED                                                     [ 34%]
src/flask_api_tutorial/models/__init__.py::FLAKE8 PASSED                                                         [ 36%]
src/flask_api_tutorial/models/__init__.py::BLACK PASSED                                                          [ 39%]
src/flask_api_tutorial/models/user.py::FLAKE8 PASSED                                                             [ 41%]
src/flask_api_tutorial/models/user.py::BLACK PASSED                                                              [ 43%]
src/flask_api_tutorial/util/__init__.py::FLAKE8 PASSED                                                           [ 46%]
src/flask_api_tutorial/util/__init__.py::BLACK PASSED                                                            [ 48%]
src/flask_api_tutorial/util/datetime_util.py::FLAKE8 PASSED                                                      [ 51%]
src/flask_api_tutorial/util/datetime_util.py::BLACK PASSED                                                       [ 53%]
src/flask_api_tutorial/util/result.py::FLAKE8 PASSED                                                             [ 56%]
src/flask_api_tutorial/util/result.py::BLACK PASSED                                                              [ 58%]
tests/__init__.py::FLAKE8 PASSED                                                                                 [ 60%]
tests/__init__.py::BLACK PASSED                                                                                  [ 63%]
tests/conftest.py::FLAKE8 PASSED                                                                                 [ 65%]
tests/conftest.py::BLACK PASSED                                                                                  [ 68%]
tests/test_config.py::FLAKE8 PASSED                                                                              [ 70%]
tests/test_config.py::BLACK PASSED                                                                               [ 73%]
tests/test_config.py::test_config_development PASSED                                                             [ 75%]
tests/test_config.py::test_config_testing PASSED                                                                 [ 78%]
tests/test_config.py::test_config_production PASSED                                                              [ 80%]
tests/test_user.py::FLAKE8 PASSED                                                                                [ 82%]
tests/test_user.py::BLACK PASSED                                                                                 [ 85%]
tests/test_user.py::test_encode_access_token PASSED                                                              [ 87%]
tests/test_user.py::test_decode_access_token_success PASSED                                                      [ 90%]
tests/test_user.py::test_decode_access_token_expired PASSED                                                      [ 92%]
tests/test_user.py::test_decode_access_token_invalid PASSED                                                      [ 95%]
tests/util.py::FLAKE8 PASSED                                                                                     [ 97%]
tests/util.py::BLACK PASSED                                                                                      [100%]

================================================= 41 passed in 12.27s ==================================================
_______________________________________________________ summary ________________________________________________________
  py37: commands succeeded
  congratulations :)

Контрольная точка

Обещаю, что темп будет ускоряться, так как у нас снова очень небольшой прогресс по требованиям к API. Считаю справедливым сказать, что полностью реализован один пункт: "JSON Web Token предусматривает следующие сущности: время выпуска токена, время истечения срока действия токена, значение для идентификации пользователя, и флаг для указания на наличие у пользователя прав администратора". Полагаю, мы также можем записать себе на счет частичное выполнение двух пунктов: "Запросы должны отклоняться, если JSON Web Token изменен", и "Запросы должны отклоняться, если истек срок действия JSON Web Token".

Управление пользователями/проверка подлинности по токенам JSON Web Token

 Новые пользователи могут зарегистрироваться, указывая адрес электронной почты и пароль.

 Существующие пользователи могут получить JSON Web Token, указав свой адрес электронной почты и пароль

 JSON Web Token предусматривает следующие сущности: время выпуска токена, время истечения срока действия токена, значение для идентификации пользователя, и флаг для указания на наличие у пользователя прав администратора

 JSON Web Token направляется в поле access_token HTTP-ответа после успешной проверки подлинности по электронной почте/паролю

 Срок действия токенов JSON Web Token должен истекать через 1 час (в продуктиве)

 Клиент направляет JSON Web Token в поле Authorization заголовка запроса

 Запросы следует отклонять, если JSON Web Token изменен

 Запросы следует отклонять, если истек срок действия JSON Web Token

 Если пользователь выходит из системы, его JSON Web Token сразу же утрачивает силу / истекает сроком действия

 Если истек срок действия JSON Web Token, пользователь должен повторно пройти проверку подлинности по электронной почте/паролю, чтобы получить новый JSON Web Token

Ресурс API: Список виджетов

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

 Все пользователи могут извлекать отдельные виджеты по имени

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

 Пользователи с правами администратора могут редактировать существующие виджеты

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

 Модель виджета содержит атрибут "name", который должен иметь значение строки (string), содержащей только строчные буквы, цифры и "-" (символ дефиса) или "_" (символ подчеркивания).

 Модель виджета содержит атрибут "deadline", который должен представлять собой значение datetime, в котором компонент date равен текущей дате или больше. В сравнении не учитывается значение компонента time при выполнении данного сравнения.

 Значения URL и datetime следует проверять перед добавлением нового виджета в базу данных (и при обновлении существующего виджета).

 Модель виджета содержит поле "name", которое должно иметь значение строки (string), содержащей только строчные буквы, цифры и "-" (символ дефиса) или "_" (символ подчеркивания).

 Имя виджета следует проверять перед добавлением нового виджета в базу данных (и при обновлении существующего виджета).

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