[Перевод] Мега-Учебник Flask Глава 5: Логины пользователей (издание 2024)

Это пятая часть серии мега-учебника 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 для этой главы:  Browse,  Zip,  Diff.

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

В главе 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: Ссылки для условного входа и выхода из системы

Microblog: Home {% if current_user.is_anonymous %} Login {% else %} Logout {% endif %}

Свойство 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 %}
    

Hi, {{ current_user.username }}!

{% for post in posts %}

{{ post.author.username }} says: {{ post.body }}

{% 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_, 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 %}
    

Register

{{ form.hidden_tag() }}

{{ form.username.label }}
{{ form.username(size=32) }}
{% for error in form.username.errors %} [{{ error }}] {% endfor %}

{{ form.email.label }}
{{ form.email(size=64) }}
{% for error in form.email.errors %} [{{ error }}] {% endfor %}

{{ form.password.label }}
{{ form.password(size=32) }}
{% for error in form.password.errors %} [{{ error }}] {% endfor %}

{{ form.password2.label }}
{{ form.password2(size=32) }}
{% for error in form.password2.errors %} [{{ error }}] {% endfor %}

{{ form.submit() }}

{% endblock %}

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

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

New User? Click to Register!

И, наконец, мне нужно написать функцию просмотра, которая будет обрабатывать регистрацию пользователей в 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(), создает нового пользователя с указанным именем пользователя, электронной почтой и паролем, записывает его в базу данных, а затем перенаправляет на приглашение для входа, чтобы пользователь мог войти в систему.

24066fac901c65a567b1397849995089.png

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

Habrahabr.ru прочитано 10448 раз