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