Это пятая часть серии мега-учебника Flask, в которой я собираюсь рассказать вам, как создать подсистему входа пользователей.

Оглавление
  • Глава 1: Привет, мир!

  • Глава 2: Шаблоны

  • Глава 3: Веб-формы

  • Глава 4: База данных

  • Глава 5: Логины пользователей (Эта статья)

  • Глава 6: Страница профиля и аватары

  • Глава 7: Обработка ошибок

  • Глава 8: Подписчики

  • Глава 9: Разбивка на страницы

  • Глава 10: Поддержка по электронной почте

  • Глава 11: Подтяжка лица

  • Глава 12: Даты и время

  • Глава 13: I18n и L10n

  • Глава 14: Ajax

  • Глава 15: Улучшенная структура приложения

  • Глава 16: Полнотекстовый поиск

  • Глава 17: Развертывание в Linux

  • Глава 18: Развертывание на Heroku

  • Глава 19: Развертывание в контейнерах Docker

  • Глава 20: Немного магии JavaScript

  • Глава 21: Уведомления пользователей

  • Глава 22: Фоновые задания

  • Глава 23: Интерфейсы прикладного программирования (API)

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

Ссылки на GitHub для этой главы: BrowseZipDiff.

Хэширование паролей

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

Одним из пакетов, реализующих хэширование паролей, является Werkzeug , на который вы, возможно, видели ссылку в выходных данных pip при установке Flask, поскольку это одна из его основных зависимостей. Поскольку это зависимость, Werkzeug уже установлен в вашей виртуальной среде. Следующий сеанс оболочки Python демонстрирует, как хэшировать пароль с помощью этого пакета:

>>> from werkzeug.security import generate_password_hash
>>> hash = generate_password_hash('foobar')
>>> hash
'scrypt:32768:8:1$DdbIPADqKg2nniws$4ab051ebb6767a...'

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

Процесс проверки выполняется с помощью второй функции от Werkzeug следующим образом:

>>> from werkzeug.security import check_password_hash
>>> check_password_hash(hash, 'foobar')
True
>>> check_password_hash(hash, 'barfoo')
False

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

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

app/models.py: Хеширование и проверка пароля

from werkzeug.security import generate_password_hash, check_password_hash

# ...

class User(db.Model):
    # ...

    def set_password(self, password):
        self.password_hash = generate_password_hash(password)

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

Благодаря этим двум методам объект user теперь может выполнять безопасную проверку пароля без необходимости хранить исходные пароли. Вот пример использования этих новых методов.:

>>> u = User(username='susan', email='susan@example.com')
>>> u.set_password('mypassword')
>>> u.check_password('anotherpassword')
False
>>> u.check_password('mypassword')
True

Введение в Flask-Login

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

(venv) $ pip install flask-login

Как и в случае с другими расширениями, Flask-Login необходимо создавать и инициализировать сразу после создания экземпляра приложения в app/__init__.py. Вот как инициализируется это расширение:

app/__init__.py: Инициализация входа в Flask

# ...
from flask_login import LoginManager
app = Flask(name)
# ...
login = LoginManager(app)
# ...

Подготовка модели пользователя для Flask-Login

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

Ниже перечислены четыре обязательных элемента:

  • is_authenticated: свойство, которое возвращает значение True если у пользователя действительные учетные данные или False в ином случае.

  • is_active: свойство, которое возвращает значение, True если учетная запись пользователя активна или False в ином случае.

  • is_anonymous: свойство, которое возвращает False для обычных пользователей и True только для специального анонимного пользователя.

  • get_id(): метод, который возвращает уникальный идентификатор пользователя в виде строки.

Я могу легко реализовать эти четыре варианта, но поскольку реализации довольно универсальны, Flask-Login предоставляет миксин-класс с именем UserMixin, который включает безопасные реализации, подходящие для большинства классов пользовательских моделей. Вот как миксин-класс добавляется в модель:

app/models.py: Класс смешивания Flask-Login user

# ...
from flask_login import UserMixin

class User(UserMixin, db.Model):
# ...

Функция пользовательского загрузчика

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

Поскольку Flask-Login ничего не знает о базах данных, ему нужна помощь приложения при загрузке пользователя. По этой причине расширение ожидает, что приложение настроит функцию загрузки пользователя, которая может быть вызвана для загрузки пользователя с заданным идентификатором. Эту функцию можно добавить в app/models.py модуль:

app/models.py: Функция загрузки Flask-Login user

from app import login
# ...
@login.user_loader
def load_user(id):
  return db.session.get(User, int(id))

Загрузчик пользователя зарегистрирован в системе Flask-Login с помощью декоратора @login.user_loader. Аргумент id, который Flask-Login передает функции, будет строкой, поэтому для баз данных, использующих числовые идентификаторы, необходимо преобразовать строку в целое число, как вы видите выше.

Авторизация пользователей

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

app/routes.py: Логика функции просмотра входа в систему

# ...
from flask_login import current_user, login_user
import sqlalchemy as sa
from app import db
from app.models import User
# ...
@app.route('/login', methods=['GET', 'POST'])
def login():
  if current_user.is_authenticated:
    return redirect(url_for('index'))
  form = LoginForm()
  if form.validate_on_submit():
    user = db.session.scalar(
      sa.select(User).where(User.username == form.username.data))
    if user is None or not user.check_password(form.password.data):
      flash('Invalid username or password')
      return redirect(url_for('login'))
    login_user(user, remember=form.remember_me.data)
    return redirect(url_for('index'))
  return render_template('login.html', title='Sign In', form=form)

Две верхние строки в функции login() касаются странной ситуации. Представьте, что у вас есть пользователь, который вошел в систему, и пользователь переходит по URL-адресу /login вашего приложения. Очевидно, что это ошибка, поэтому я хочу этого не допустить. Переменная current_user берется из Flask-Login и может быть использована в любое время во время обработки запроса для получения объекта user, представляющего клиента этого запроса. Значением этой переменной может быть объект user из базы данных (который Flask-Login считывает через обратный вызов пользовательского загрузчика, который я предоставил выше), или специальный анонимный объект user, если пользователь еще не входил в систему. Помните, какие свойства требовались для входа в Flask в объекте user? Одним из таких свойств было is_authenticated, которое удобно для проверки, вошел пользователь в систему или нет. Когда пользователь уже вошел в систему, я просто перенаправляю на страницу индекса.

Вместо вызова flash(), который я использовал ранее, теперь я могу загрузить пользователя в систему по-настоящему. Первый шаг - загрузить пользователя из базы данных. Имя пользователя указано при отправке формы, поэтому я могу сделать запрос в базу данных с её помощью, чтобы найти пользователя. Для этой цели я использую метод where(), чтобы найти пользователей с данным именем пользователя. Поскольку я знаю, что будет только один или ноль результатов, я выполняю запрос, вызывая db.session.scalar(), который вернет объект user, если он существует, или None если его нет. В главе 4 вы видели, что при вызове метода all() выполняется запрос, и вы получаете список всех результатов, соответствующих этому запросу. Метод first() - это еще один часто используемый способ выполнения запроса, когда вам нужно получить только один результат.

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

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

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

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

Я знаю, что мне также нужно будет предложить пользователям возможность выйти из приложения. Это можно сделать с помощью функции logout_user() Flask-Login. Вот функция просмотра выхода из системы.:

app/routes.py: Функция просмотра выхода из системы

# ...
from flask_login import logout_user
# ...
@app.route('/logout')
def logout():
    logout_user()
    return redirect(url_for('index'))

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

app/templates/base.html: Ссылки для условного входа и выхода из системы

<div>
  Microblog:
  <a href="{{ url_for('index') }}">Home</a>
  {% if current_user.is_anonymous %}
  <a href="{{ url_for('login') }}">Login</a>
  {% else %}
  <a href="{{ url_for('logout') }}">Logout</a>
  {% endif %}
</div>

Свойство is_anonymous - это один из атрибутов, которые Flask-Login добавляет к объектам пользователя через UserMixin класс. Выражение current_user.is_anonymous будет True только тогда, когда пользователь не вошел в систему.

Требование авторизации пользователя в системе

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

Для реализации этой функции Flask-Login должен знать, что такое функция просмотра, которая обрабатывает логины. Это можно добавить в app/__init__.py:

# ...
login = LoginManager(app)
login.login_view = 'login'

Значение 'login' - это имя функции (или конечной точки) для представления входа. Другими словами, имя, которое вы использовали бы в вызове url_for() для получения URL-адреса.

Способ, которым Flask-Login защищает функцию просмотра от анонимных пользователей, заключается в использовании декоратора с именем @login_required. Когда вы добавляете этот декоратор в функцию просмотра под декоратором @app.route из Flask, функция становится защищенной и не разрешает доступ пользователям, которые не прошли проверку подлинности. Вот как декоратор может быть применен к функции индексного просмотра приложения:

app/routes.py: декоратор @login_required

from flask_login import login_required

@app.route('/')
@app.route('/index')
@login_required
def index():
    # ...

Осталось реализовать перенаправление с успешного входа обратно на страницу, к которой пользователь хотел получить доступ. Когда пользователь, который не вошел в систему, пытается получить доступ к функции просмотра, защищенной с помощью декоратора @login_required, декоратор перенаправляет на страницу входа в систему, но в это перенаправление будет включена некоторая дополнительная информация, чтобы приложение могло затем вернуться на исходную страницу. Например, если пользователь переходит к /index, декоратор @login_required перехватит запрос и ответит перенаправлением на /login, но он добавит аргумент строки запроса к этому URL, создав полный URL перенаправления /login?next= /index. В аргументе next строки запроса задается исходный URL, поэтому приложение может использовать его для перенаправления обратно после входа в систему.

Вот фрагмент кода, который показывает, как читать и обрабатывать аргумент next строки запроса. Изменения внесены в четыре строки под вызовом login_user().

app/routes.py: Перенаправление на страницу "next"

from flask import request
from urllib.parse import urlsplit

@app.route('/login', methods=['GET', 'POST'])
def login():
    # ...
    if form.validate_on_submit():
        user = db.session.scalar(
            sa.select(User).where(User.username == form.username.data))
        if user is None or not user.check_password(form.password.data):
            flash('Invalid username or password')
            return redirect(url_for('login'))
        login_user(user, remember=form.remember_me.data)
        next_page = request.args.get('next')
        if not next_page or urlsplit(next_page).netloc != '':
            next_page = url_for('index')
        return redirect(next_page)
    # ...

Сразу после входа пользователя в систему путем вызова функции login_user() Flask-Login получается значение аргумента next строки запроса. Flask предоставляет переменную request, содержащую всю информацию, отправленную клиентом с запросом. В частности, атрибут request.args предоставляет содержимое строки запроса в удобном формате словаря. На самом деле существует три возможных случая, которые необходимо рассмотреть, чтобы определить, куда перенаправлять после успешного входа в систему:

  • Если URL-адрес для входа в систему не имеет аргумента next, то пользователь перенаправляется на страницу индекса.

  • Если URL-адрес для входа содержит аргумент next, для которого задан относительный путь (или, другими словами, URL-адрес без указания домена), то пользователь перенаправляется на этот URL-адрес.

  • Если URL-адрес для входа содержит аргумент next, для которого задан полный URL-адрес, включающий доменное имя, то этот URL-адрес игнорируется, и пользователь перенаправляется на страницу индекса.

Первый и второй примеры понятны сами по себе. Третий пример используется для повышения безопасности приложения. Злоумышленник может вставить URL-адрес вредоносного сайта в аргумент next, поэтому приложение перенаправляет только в том случае, если URL-адрес является относительным, что гарантирует, что перенаправление произойдёт в пределах того же сайта, что и приложение. Чтобы определить, является ли URL абсолютным или относительным, я анализирую его с помощью urlsplit() функции Python, а затем проверяю, установлен ли компонент netloc или нет.

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

Помните, еще в главе 2 я создал поддельного пользователя, чтобы он помог мне разработать домашнюю страницу приложения до того, как была создана пользовательская подсистема? Что ж, теперь у приложения есть настоящие пользователи, так что теперь я могу удалить поддельного пользователя и начать работать с реальными пользователями. Вместо поддельного пользователя я могу использовать current_user Flask-Login в шаблоне index.html:

app/templates/index.html: Передача текущего пользователя в шаблон

{% extends "base.html" %}

{% block content %}
    <h1>Hi, {{ current_user.username }}!</h1>
    {% for post in posts %}
    <div><p>{{ post.author.username }} says: <b>{{ post.body }}</b></p></div>
    {% endfor %}
{% endblock %}

И я могу удалить аргумент user шаблона в функции просмотра:

app/routes.py: Пользователь больше не передаётся в шаблон

@app.route('/')
@app.route('/index')
@login_required
def index():
    # ...
    return render_template("index.html", title='Home Page', posts=posts)

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

>>> u = User(username='susan', email='susan@example.com')
>>> u.set_password('cat')
>>> db.session.add(u)
>>> db.session.commit()

Если вы сейчас запустите приложение и перейдете по URL-адресам приложения / или /index, вы будете немедленно перенаправлены на страницу входа, а после входа в систему с использованием учетных данных пользователя, которого вы добавили в свою базу данных, вы вернетесь на исходную страницу, на которой увидите персонализированное приветствие и макет записи в блоге. Если вы затем нажмете ссылку для выхода из системы в верхней панели навигации, вы вернетесь на страницу индекса как анонимный пользователь и сразу же снова будете перенаправлены на страницу входа с помощью Flask-Login.

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

Последняя часть функциональности, которую я собираюсь создать в этой главе, - это регистрационная форма, позволяющая пользователям регистрироваться через веб-форму. Давайте начнем с создания класса веб-формы в app/forms.py:

app/forms.py: Форма регистрации пользователя

from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo
import sqlalchemy as sa
from app import db
from app.models import User

# ...

class RegistrationForm(FlaskForm):
    username = StringField('Username', validators=[DataRequired()])
    email = StringField('Email', validators=[DataRequired(), Email()])
    password = PasswordField('Password', validators=[DataRequired()])
    password2 = PasswordField(
        'Repeat Password', validators=[DataRequired(), EqualTo('password')])
    submit = SubmitField('Register')

    def validate_username(self, username):
        user = db.session.scalar(sa.select(User).where(
            User.username == username.data))
        if user is not None:
            raise ValidationError('Please use a different username.')

    def validate_email(self, email):
        user = db.session.scalar(sa.select(User).where(
            User.email == email.data))
        if user is not None:
            raise ValidationError('Please use a different email address.')

В этой новой форме есть пара интересных моментов, связанных с проверкой. Во-первых, для email поля я добавил второй валидатор после DataRequired, называемый Email. Это еще один стандартный валидатор, который поставляется с WTForms, который гарантирует, что то, что пользователь вводит в это поле, соответствует структуре адреса электронной почты.

Для Email() валидатора из WTForms требуется установка внешней зависимости:

(venv) $ pip install email-validator

Поскольку это регистрационная форма, обычно пользователя просят ввести пароль два раза, чтобы снизить риск опечатки. По этой причине у меня есть поля password и password2. Во втором поле пароля используется еще один стандартный валидатор под названием EqualTo, который гарантирует, что его значение идентично значению для первого поля пароля.

Когда вы добавляете какие-либо методы, соответствующие шаблону validate_<field_name>, WTForms использует их в качестве пользовательских валидаторов и вызывает их в дополнение к стандартным валидаторам. Я добавил два из этих методов в этот класс для полей username и email. В этом случае я хочу убедиться, что имя пользователя и адрес электронной почты, введенные пользователем, еще не находятся в базе данных, поэтому эти два метода выдают запросы к базе данных, ожидая, что результатов не будет. В случае наличия результата возникает ошибка проверки при возникновении исключения типа ValidationError. Сообщение, включенное в качестве аргумента в исключение, будет сообщением, которое будет отображаться рядом с полем для просмотра пользователем.

Обратите внимание, как выполняются два запроса на проверку. Эти запросы никогда не найдут более одного результата, поэтому вместо того, чтобы запускать их с db.session.scalars() я использую db.session.scalar() в единственном числе, которое возвращает None, если результатов нет, или же первый результат.

Чтобы отобразить эту форму на веб-странице, мне нужен HTML-шаблон, который я собираюсь сохранить в файле app/templates/register.html. Этот шаблон построен аналогично шаблону для формы входа в систему:

app/templates/register.html: Шаблон регистрации

{% extends "base.html" %}

{% block content %}
    <h1>Register</h1>
    <form action="" method="post">
        {{ form.hidden_tag() }}
        <p>
            {{ form.username.label }}<br>
            {{ form.username(size=32) }}<br>
            {% for error in form.username.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>
            {{ form.email.label }}<br>
            {{ form.email(size=64) }}<br>
            {% for error in form.email.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>
            {{ form.password.label }}<br>
            {{ form.password(size=32) }}<br>
            {% for error in form.password.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>
            {{ form.password2.label }}<br>
            {{ form.password2(size=32) }}<br>
            {% for error in form.password2.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>{{ form.submit() }}</p>
    </form>
{% endblock %}

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

app/templates/login.html: Ссылка на страницу регистрации

<p>New User? <a href="{{ url_for('register') }}">Click to Register!</a></p>

И, наконец, мне нужно написать функцию просмотра, которая будет обрабатывать регистрацию пользователей в app/routes.py:

app/routes.py: Функция просмотра регистрации пользователя

from app import db
from app.forms import RegistrationForm

# ...

@app.route('/register', methods=['GET', 'POST'])
def register():
    if current_user.is_authenticated:
        return redirect(url_for('index'))
    form = RegistrationForm()
    if form.validate_on_submit():
        user = User(username=form.username.data, email=form.email.data)
        user.set_password(form.password.data)
        db.session.add(user)
        db.session.commit()
        flash('Congratulations, you are now a registered user!')
        return redirect(url_for('login'))
    return render_template('register.html', title='Register', form=form)

И эта функция просмотра также должна быть в основном понятной. Сначала я удостоверяюсь, что пользователь, вызывающий этот маршрут, не вошел в систему. Форма обрабатывается так же, как и форма для входа в систему. Логика, выполняемая внутри условия if validate_on_submit(), создает нового пользователя с указанным именем пользователя, электронной почтой и паролем, записывает его в базу данных, а затем перенаправляет на приглашение для входа, чтобы пользователь мог войти в систему.

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

Комментарии (0)