Пишем web на Python3, регистрируемся и восстанавливаем забытый пароль

newbie

Опубликован:  2019-04-16T11:01:54.016809Z
Отредактирован:  2019-04-19T08:21:41.449083Z
Продолжаем разработку проекта selfish. Очередной этап разработки системы авторизации web-приложения посвящен проектированию инструментов, позволяющих пользователям самостоятельно регистрироваться в сервисе и восстанавливать забытый пароль. Selfish получит новые возможности, у приложения появится очередь задач и возможность автоматически отправлять сообщения электронной почты.

1. В предыдущих сериях

Selfish - web-приложение на базе Python3 и Flask, начальная конфигурация и разработка которого детально описаны в предыдущих выпусках этого блога. Все посвященные разработке этого приложения выпуски можно отфильтровать по метке selfish.

2. Создаём функциональные страницы для регистрации и восстановления пароля

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

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

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

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

Поскольку первый шаг процедуры регистрации и первый шаг процедуры восстановления забытого пароля - операции очень похожие и отличаются только содержанием отсылаемого системой авторизации сообщения, есть смысл объединить эти два действия на одной функциональной странице. Открываю файл selfish/auth/views.py и определяю в этом файле новую функцию представления.

@auth.route('/get-password')
def get_password():
    return '<p>Страница в стадии разработки.</p>'

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

Для двух других функциональных страниц - страницы регистрации аккаунта и страницы восстановления пароля - определяю свои функции представления в этом же файле.

@auth.route('/create-password/<token>')
def create_password(token):
    return '<p>Страница в стадии разработки.</p>'


@auth.route('/reset-password/<token>')
def reset_password(token):
    return '<p>Страница в стадии разработки.</p>'

SSeSaLLxao.png

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

3. Устанавливаем дополнительные пакеты Python3

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

fetr1UWVz9.png

Для реализации очереди задач мне понадобится Celery. Устанавливаю.

pip install celery

GepaW9Yp9a.png

Для Celery необходим брокер, в качестве брокера я буду использовать Redis, устанавливаю.

pip install redis

r6iLxahfjM.png

Следует иметь ввиду, что Redis требует сервера, у меня Redis установлен на базовом сервере, подробности об организации рабочего места изложены в одном из предыдущих выпусков блога.

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

pip install Flask-Mail

EXxEh19wkv.png

Чтобы не потерять список зависимостей, обновляю файл requirements.txt.

pip freeze > requirements.txt

klAzv1t8ab.png

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

4. Конфигурируем очередь задач в контексте selfish

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

Создаю новый файл.

touch selfish/cely.py

Открываю этот файл в редакторе и создаю в нём новую служебную функцию.

def init_celery(app, celery):
    celery.conf.update(app.config)
    base = celery.Task

    class ContextTask(base):
        abstract = True

        def __call__(self, *args, **kwargs):
            with app.app_context():
                return base.__call__(self, *args, **kwargs)

    celery.Task = ContextTask
    return celery

Открываю файл selfish/__init__.py, в этом файле создаю экземпляр класса Celery и инициирую его соответствующим образом в служебной функции create_app.

from celery import Celery
...
from config import Config, config
from .cely import init_celery

...
celery = Celery(
    __name__,
    broker=Config.CELERY_BROCKER_URL,
    backend=Config.CELERY_RESULT_BACKEND)
...

def create_app(config_name, celery_instance):
    ...
    celery_instance = init_celery(app, celery_instance)
    return app, celery_instance

oBCfgVh6ac.png

Эти изменения необходимо отразить в файле config.py, открываю его в редакторе и дописываю классу Config следующие новые свойства.

class Config:
    ...
    CELERY_RESULT_BACKEND = 'redis://192.168.56.101:6379/0'
    CELERY_BROCKER_URL = 'redis://192.168.56.101:6379/0'
    CELERY_IMPORTS = ('selfish.tasks',)
    CELERY_TASK_RESULT_EXPIRES = timedelta(hours=1)

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

mkdir selfish/tasks
touch selfish/tasks/__init__.py

Обращаю внимание, что этот каталог определён в свойстве CELERY_IMPORTS класса Config, в дальнейшем Celery будет импортировать отложенные задачи из файлов этого каталога.

Теперь необходимо при помощи полученных вспомогательных функций получить экземпляр класса Flask, который и будет web-приложением selfish, а также экземпляр класса Celery, с помощью которого можно будет запустить рабочий процесс очереди задач. Создаю новый файл.

touch celery_worker.py

Открываю его в редакторе и импортирую в нём новые служебные объекты и с их помощью создаю новые экземпляры Flask и Celery.

from selfish import celery, create_app

app, celery = create_app('default', celery)

Чтобы зафиксировать все изменения, необходимо в файле manage.py заменить прежний объект app на новый, полученный только что в файле celery_worker.py. Открываю этот файл в редакторе, удаляю из него прежний объект app и дописываю новый импорт.

kX6Iq7nFda.png

Теперь, дабы убедиться, что все изменения в проекте пришлись в пору, пробую запустить в терминале рабочий процесс очереди задач, который будет принимать от selfish и исполнять отложенные задачи.

celery worker -A celery_worker.celery -l info

HJYq8ellv4.png

На снимке экрана выше видно, что рабочий процесс очереди задач запустился и готов к работе, но отложенных задач на текущий момент в этом процессе не имеется. Для справки, прервать процесс Celery можно сочетанием ctrl+c. С этого момента отладочный сервер я буду запускать только после запуска рабочего процесса очереди задач, но об этом мы поговорим чуть позже...

5. Готовимся к отправке сообщений электронной почтой

Flask-Mail - это пакет, который даст мне возможность в контексте selfish отправлять сообщения электронной почтой в автоматизированном режиме. Чтобы приступить к работе с этим пакетом, его тоже необходимо правильно сконфигурировать, для этого мне понадобится адрес электронной почты, с которого будут уходить сообщения, пароль к этому ящику и некоторые атрибуты самих сообщений. В рамках этого описания я буду использовать Яндекс-почту для доменов и один из почтовых ящиков своего домена. Открываю в редакторе файл config.py и дописываю классу Config дополнительный набор атрибутов.

class Config:
    ...
    MAIL_SERVER = 'smpt.yandex.ru'
    MAIL_PORT = 465
    MAIL_USE_SSL = True
    MAIL_USERNAME = os.getenv('SMUN', 'your_address@yandex.ru')
    MAIL_PASSWORD = os.getenv('SMPW', '###########')
    SELFISH_SUBJECT_PREFIX = '[Selfish] '
    SELFISH_SENDER = 'robot <{0}>'.format(
        os.getenv('SMUN', 'your_address@yandex.ru'))

Здесь следует обратить внимание, что адрес электронной почты, с которого будут уходить сообщения, я задаю при помощи переменной окружения SMUN, а пароль к этому ящику - при помощи переменной окружения SMPW, эти переменные окружения должны существовать в текущей сессии и содержать правильные данные. Необходимую информацию о работе с переменными окружения Linux в Python3 можно почерпнуть в статье по ссылке.

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

class Development(Config):
    ...
    MAIL_DEBUG = True
    MAIL_SUPPRESS_SEND = True
    ...

Открываю в редакторе файл selfish/__init__.py и инициирую инструмент для отправки почты в контексте приложения.

...
from flask_mail import Mail
...


mail = Mail()


def create_app(config_name, celery_instance):
    ...
    mail.init_app(app)
    ...

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

touch selfish/mail_tools.py

Открываю этот файл в редакторе и даю ему такой код.

from flask import render_template
from flask_mail import Message


def create_message(app, recipients, subject, template, **kwargs):
    message = Message(
        app.config.get('SELFISH_SUBJECT_PREFIX') + subject,
        sender=app.config.get('SELFISH_SENDER'),
        recipients=recipients)
    message.body = render_template(template + '.txt', **kwargs)
    message.html = render_template(template + '.html', **kwargs)
    return message

Здесь необходимо обратить внимание на параметр recipients функции create_message, по условиям задачи этот параметр должен быть экземпляром класса list, то есть списком, содержащим e-mail адреса всех получателей сообщения, это необходимо знать, чтобы правильно оформить вызов функции впоследствии.

6. Создаём секретный брелок регистрационной ссылки

Функциональные страницы регистрации аккаунта и восстановления пароля должны быть доступны только одному определённому пользователю, который имеет секретную ссылку на соответствующую страницу. Ссылка будет содержать в себе секретный ключ, при помощи которого доступ к логике функции представления предоставляется только владельцу ключа - ссылки. Эта ссылка и будет высылаться сообщением электронной почты на ящик регистрирующегося пользователя. Чтобы иметь возможность создавать такой ключ для любого аккаунта, дописываю классу Account в файле selfish/models/auth.py ещё один новый метод.

...
from flask import current_app, request
...
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer

...

class Account(db.Model):
    ...

    def generate_token(self, expiration=3600):
        s = Serializer(current_app.config.get('SECRET_KEY'), expiration)
        return s.dumps({'confirm': self.id}).decode('utf-8')

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

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

touch selfish/auth/tools.py

Открываю этот файл в редакторе и пишу код новой функции.

from flask import current_app
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer, BadData

from ..models.auth import Account


def check_token(token):
    s = Serializer(current_app.config.get('SECRET_KEY'))
    try:
        account = Account.query.get(s.loads(token).get('confirm'))
    except BadData:
        account = None
    return account

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

7. Верстаем шаблоны регистрационных сообщений

Для формирования регистрационных сообщений мне понадобится четыре новых шаблона: два шаблона для регистрационного сообщения, два шаблона для сообщения на восстановление забытого пароля. В каждой паре html-шаблон и txt-шаблон, который будет отличаться от первого только отсутствием html-разметки. Создаю четыре новых файла.

mkdir -p selfish/templates/emails/auth
touch selfish/templates/emails/auth/invitation.html
touch selfish/templates/emails/auth/invitation.txt
touch selfish/templates/emails/auth/reset_password.html
touch selfish/templates/emails/auth/reset_password.txt

В файл invitation.html пишу следующий текст.

<p>
  Здравствуйте, уважаемый {{ username }}.
</p>
<p>
  Вы получили это сообщение, потому что запросили пароль в нашем
  <a href="{{ index }}">сервисе</a>.
  Если ничего подобного Вы не делали, возможно, кто-то ошибочно ввёл Ваш адрес
  в форму запроса, просто проигнорируйте это сообщение.
</p>
<p>
  Для получения пароля и завершения регистрации учётной записи в нашем сервисе
  проследуйте по <a href="{{ target }}">ссылке</a>, которая приведёт Вас к
  регистрационной форме. Заполните эту форму, задайте свой псевдоним в сервисе
  и пароль. В дальнейшем эти данные позволят Вам войти в сервис и получить
  доступ к его возможностям.
</p>
<p>
  Пожалуйста, имейте ввиду, что ссылка будет действительна в течение
  {{ length }} часов, а повторный запрос ссылки на регистрационную форму
  возможен не ранее чем через {{ interval }} часа. Зарегистрированная по ссылке
  учётная запись будет привязана к Вашему адресу электронной почты, пожалуйста
  не передавайте ссылку на регистрационную форму третьим лицам.
</p>
<p>
  Благодарим Вас за явно выраженный интерес и желаем приятного веб-серфинга.
</p>

Повторяю тот же текст, но только без разметки в файле invitation.txt.

Здравствуйте, уважаемый {{ username }}.

Вы получили это сообщение, потому что запросили пароль в нашем сервисе:
{{ index }}

Если ничего подобного Вы не делали, возможно, кто-то ошибочно ввёл Ваш адрес
в форму запроса, просто проигнорируйте это сообщение.

Для получения пароля и завершения регистрации учётной записи в нашем сервисе
проследуйте по ссылке, которая приведёт Вас к регистрационной форме:
{{ target }}

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

Пожалуйста, имейте ввиду, что ссылка будет действительна в течение {{ length }}
часов, а повторный запрос ссылки на регистрационную форму возможен не ранее
чем через {{ interval }} часа. Зарегистрированная по ссылке учётная запись
будет привязана к Вашему адресу электронной почты, пожалуйста не передавайте
ссылку на регистрационную форму третьим лицам.

Благодарим Вас за явно выраженный интерес и желаем приятно веб-серфинга.

В шаблон reset_password.html пишу следующий текст.

<p>
  Здравствуйте, уважаемый {{ username }}.
</p>
<p>
  Вы получили это сообщение, потому что запросили сброс пароля в нашем
  <a href="{{ index }}">сервисе</a>.
  Если ничего подобного Вы не делали, возможно, кто-то ошибочно ввёл Ваш адрес
  в форму запроса, просто проигнорируйте это сообщение.
</p>
<p>
  Для сброса пароля Вашей учётной записи в нашем сервисе проследуйте по
  <a href="{{ target }}">ссылке</a>, которая приведёт Вас к форме сброса пароля.
  Заполните эту форму и задайте свой новый пароль. С ним Вы сможете войти
  в сервис.
</p>
<p>
  Пожалуйста, имейте ввиду, что ссылка будет действительна в течение {{ length}}
  часов, а повторный запрос ссылки на форму сброса пароля возможен не ранее
  чем через {{ interval }} часа. Пожалуйста, не передавайте ссылку на форму
  сброса пароля третьим лицам. Это может привести к полной утере контроля над
  Вашей учётной записью.
</p>
<p>
  Благодарим Вас за использование нашего сервиса и желаем приятного
  веб-сёрфинга.
</p>

Повторяю тот же текст без разметки в шаблоне reset_password.txt.

Здравствуйте, уважаемый {{ username }}.

Вы получили это сообщение, потому что запросили сброс пароля в нашем сервисе:
{{ index }}

Если ничего подобого Вы не делали, возможно, кто-то ошибочно ввёл Ваш адрес
в форму запроса, просто проигнорируйте это сообщение.

Для сброса пароля Вашей учётной записи в нашем сервисе проследуйте по ссылке,
которая приведёт Вас к форме сброса пароля:

{{ target }}

Заполните эту форму и задайте свой новый пароль, с которым Вы сможете войти в
сервис.

Пожалуйста, имейте ввиду, что ссылка будет действительна в течение
{{ length }} часов, а повторый запрос ссылки на форму сброса пароля возможен
не ранее чем через {{ interval }} часа. Пожалуйста, не передавайте ссылку на
форму сброса пароля третьим лицам, это может привести к полной утере контроля
над учётной записью.

Благодарим Вас за использование нашего сервиса и желаем приятного веб-сёрфинга.

На основе этих шаблонов система будет формировать e-mail сообщения при регистрации пользователей и при восстановлении забытого пароля.

8. Формируем первое задание в очередь задач

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

touch selfish/tasks/auth_tools.py

Открываю этот файл в редакторе и пишу следующий код.

from datetime import datetime

from .. import db
from ..models.auth import Account


class Resolver:
    def __init__(self):
        self.username = None
        self.subj = None
        self.template = None
        self.account = None

    def resolve(self, address):
        account = Account.query.filter_by(address=address).first()
        self.username, self.subj, self.template = self._define(account)
        self.account = self._get_account(account, address)
        db.session.add(self.account)
        db.session.commit()

    def _define(self, account):
        if account and account.user:
            return (account.user.username,
                    'Password Recovery',
                    'emails/auth/reset_password')
        return 'Гость', 'Registration', 'emails/auth/invitation'

    def _get_account(self, account, address):
        if account:
            account.swap = None
            account.requested = datetime.utcnow()
            return account
        return Account(address=address)

Теперь я имею возможность определить первое отложенное задание, создаю новый файл.

touch selfish/tasks/auth.py

Этот файл будет содержать определения всех отложенных заданий для системы авторизации selfish. Открываю файл в редакторе и пишу такое определение.

from flask import current_app

from .. import celery, mail
from ..mail_tools import create_message
from .auth_tools import Resolver


@celery.task
def request_password(address, index, target_url):
    app = current_app._get_current_object()
    aim = Resolver()
    aim.resolve(address)
    length = app.config.get('TOKEN_LENGTH', 1)
    target_url = target_url.replace(
        'token',
        aim.account.generate_token(expiration=round(3600*length)))
    message = create_message(
        app, [aim.account.address], aim.subj, aim.template,
        username=aim.username, index=index, target=target_url, length=length,
        interval=app.config.get('REQUEST_INTERVAL', 2))
    with app.app_context():
        mail.send(message)
    if app.config.get('MAIL_DEBUG'):
        return message.body
    return 'Done!'

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

9. Верстаем страницу запроса регистрации и восстановления пароля

Страница для запроса регистрации и восстановления пароля будет интерпретироваться функцией представления get_password файла selfish/auth/views.py. Именно эта функция представления будет инициировать отложенное задание, которое формирует и отправляет регистрационное письмо на адрес электронной почты, введённый пользователем в форму. Доступ к отправке сообщения необходимо ограничить по времени, для этого открываю в редакторе файл config.py и дописываю классу Config пару новых свойств.

class Config:
    ...
    TOKEN_LENGTH = 12
    REQUEST_INTERVAL = 24
    ...

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

class Development(Config):
    ...
    TOKEN_LENGTH = 0.1
    REQUEST_INTERVAL = 0.12
    ...

Мне потребуется новая форма, открываю в редакторе файл selfish/auth/forms.py и дописываю в этот файл ещё один класс.

...
from wtforms.validators import DataRequired, Email, Length

...

class GetPassword(FlaskForm):
    address = StringField(
        'Адрес эл.почты:',
        validators=[DataRequired(message='без адреса никак, увы'),
                    Email(message='нужен адрес электронной почты'),
                    Length(max=120,
                           message='максимальная длина адреса - 128 знаков')])
    submit = SubmitField('Получить пароль')

Кроме этого, мне потребуется ещё одна вспомогательная функция, при помощи которой я буду получать url-адрес для отправляемой ссылки. Открываю файл selfish/auth/tools.py и дописываю в нём новую функцию.

from flask import current_app, url_for
...

def define_target_url(account):
    if account and account.user:
        return url_for('auth.reset_password', token='token', _external=True)
    return url_for('auth.create_password', token='token', _external=True)

Открываю в редакторе файл selfish/auth/views.py и переопределяю функцию представления get_password следующим образом.

from datetime import datetime, timedelta
...
from ..tasks.auth import request_password
...
from .forms import GetPassword, LoginForm
from .tools import define_target_url
...

@auth.route('/get-password', methods=['GET', 'POST'])
def get_password():
    if current_user.is_authenticated:
        return redirect(url_for('main.show_index'))
    form = GetPassword()
    if form.validate_on_submit():
        delta = timedelta(
            seconds=round(3600*current_app.config.get('REQUEST_INTERVAL')))
        account = Account.query.filter_by(address=form.address.data).first()
        if account and datetime.utcnow() - account.requested < delta:
            flash('Сервис временно недоступен, попробуйте зайти позже.')
            return redirect(url_for('auth.get_password'))
        swapped = Account.query.filter_by(swap=form.address.data).first()
        if swapped:
            flash('Адрес в свопе, выберите другой или попробуйте позже.')
            return redirect(url_for('auth.get_password'))
        request_password.delay(
            form.address.data,
            url_for('main.show_index', _external=True),
            define_target_url(account))
        flash('На Ваш адрес выслано письмо с инструкциями.')
        return redirect(url_for('auth.login'))
    return render_template('auth/get_password.html', form=form)

Создаю для этой функции представления шаблон.

touch selfish/templates/auth/get_password.html

Открываю его в редакторе и верстаю.

{% extends "auth/auth_base.html" %}
{% from "macros/_auth.html" import get_message %}
{% from "macros/_auth.html" import render_form_group %}

{% block title %}Запрос пароля{% endblock %}

{% block form_content %}
  <div class="form-block content-block">
    <div class="block-header">
      <h3 class="panel-title">Запрос пароля</h3>
    </div>
    <div class="block-body">
      <div class="today-field"></div>
      <div class="form-help">
        <p>
          Уважаемый гость, для получения пароля заполните форму ниже.
          Введите в соответствующее поле Ваш адрес электронной почты, нажмите
          кнопку "Получить пароль". На Ваш адрес будет выслано письмо с
          дальнейшими инструкциями, следуйте им.
        </p>
      </div>
      {% set messages = get_flashed_messages() %}
      {% if messages %}
        {{ get_message(messages) }}
      {% endif %}
      <div class="form-form">
        <form class="form" method="POST" role="form">
          {{ form.hidden_tag() }}
          {{ render_form_group(
              form, form.address, 'address', 'введите адрес эл.почты') }}
          <div class="form-group">
            <div class="form-input">
              {{ form.submit(class="btn btn-primary") }}
            </div>
          </div>
        </form>
      </div>
    </div>
  </div>
{% endblock form_content %}

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

<li><a href="{{ url_for('auth.get_password') }}">
                  Получить пароль</a></li>

x7SigEkdi8.png

Чуть позже я продемонстрирую работу этой страницы в браузере...

10. Верстаем страницу регистрации псевдонима и пароля

Страница регистрации псевдонима и пароля будет интерпретироваться функцией представления create_password файла selfish/auth/views.py. Доступ к этой странице будет возможен только при наличии регистрационной ссылки. Для этой страницы мне понадобится ещё одна новая форма. Открываю в редакторе файл selfish/auth/forms.py и дописываю в нём два новых класса.

...
from wtforms.validators import (
    DataRequired, Email, EqualTo, Length, Regexp, ValidationError)

from ..models.auth import User

...

class Passwords(FlaskForm):
    password = PasswordField(
        'Новый пароль:',
        validators=[DataRequired(message='без пароля никак, увы'),
                    EqualTo('confirmation', message='пароли не совпадают')])
    confirmation = PasswordField(
        'Повторите:',
        validators=[DataRequired(message='без пароля никак, увы'),
                    EqualTo('password', message='пароли не совпадают')])


class CreatePassword(Passwords):
    username = StringField(
        'Псевдоним:',
        validators=[DataRequired(message='без псевдонима никак, увы'),
                    Length(min=3, max=16, message='от 3-х до 16-ти знаков'),
                    Regexp(r'^[A-Za-z][a-zA-Z0-9\-_.]*$',
                           message='латинские буквы, цифры, дефис, знак \
                                   подчеркивания, точка, первый символ - \
                                   латинская буква')])
    submit = SubmitField('Создать пароль')

    def validation_username(self, field):
        if User.query.filter_by(username=field.data).first():
            raise ValidationError('этот псевдоним уже используется')

Класс CreatePassword - целевая форма, которая и будет использована на проектируемой странице. Открываю в редакторе файл selfish/auth/views.py и переписываю функцию create_password следующим образом.

...
from flask import (
    abort, current_app, flash, redirect, render_template, request, url_for)
...
from .. import db
from ..models.auth import Account, Permission, User
...
from .forms import CreatePassword, GetPassword, LoginForm
from .tools import check_token, define_target_url

...

@auth.route('/create-password/<token>', methods=['GET', 'POST'])
def create_password(token):
    account = check_token(token)
    if account is None or current_user.is_authenticated or account.user:
        abort(404)
    form = CreatePassword()
    if form.validate_on_submit():
        user = User(
            username=form.username.data,
            password=form.password.data,
            permissions=[permission.permission for permission in
                         Permission.query.filter_by(initial=True)])
        user.account = account
        db.session.add(user)
        db.session.commit()
        flash('Для пользователя {0} пароль успешно создан.'
              .format(user.username))
        return redirect(url_for('auth.login'))
    return render_template(
        'auth/create_password.html', form=form,
        interval=current_app.config.get('REQUEST_INTERVAL', 2))

Создаю шаблон для этой страницы.

touch selfish/templates/auth/create_password.html

Открываю его в редакторе и верстаю.

{% extends "auth/auth_base.html" %}
{% from "macros/_auth.html" import get_message %}
{% from "macros/_auth.html" import render_form_group %}

{% block title %}Создайте свой пароль{% endblock %}

{% block form_content %}
  <div class="form-block content-block">
    <div class="block-header">
      <h3 class="panel-title">Создайте свой пароль</h3>
    </div>
    <div class="block-body">
      <div class="today-field"></div>
      <div class="form-help">
        <p>
          Уважаемый гость, для создания пароля заполните форму ниже.
          Придумайте и введите в соответствующие поля желаемый псевдоним в
          сервисе и пароль. Пароль нужно ввести второй раз в поле "Повторите".
          Нажмите кнопку "Создать пароль". Будьте внимательны, эти данные Вам
          потребуются позже для входа в сервис, а восстановить пароль можно
          будет не ранее чем через {{ interval }} часа.
        </p>
      </div>
      {% set messages = get_flashed_messages() %}
      {% if messages %}
        {{ get_message(messages) }}
      {% endif %}
      <div class="form-form">
        <form class="form" method="POST" role="form">
          {{ form.hidden_tag() }}
          {{ render_form_group(
              form, form.username, 'username', 'введите желаемый псевдоним') }}
          {{ render_form_group(
              form, form.password, 'password', 'введите желаемый пароль') }}
          {{ render_form_group(
              form, form.confirmation, 'confirmation', 'повторите пароль') }}
          <div class="form-group">
            <div class="form-input">
              {{ form.submit(class="btn btn-primary btn-block") }}
            </div>
          </div>
        </form>
      </div>
    </div>
  </div>
{% endblock form_content %}

Чуть позже работа этой страницы будет продемонстрирована в браузере...

11. Верстаем страницу восстановления забытого пароля

Осталась последняя функция представления - reset_password, которая будет интерпретировать страницу для восстановления пароля. Для этой страницы необходима своя форма. Открываю файл selfish/auth/forms.py и дописываю в него ещё один класс.

class ResetPassword(Passwords):
    address = StringField(
        'Адрес эл.почты:',
        validators=[DataRequired(message='без адреса никак, увы'),
                    Email(message='нужен адрес электронной почты')])
    submit = SubmitField('Обновить пароль')

Теперь у меня появляется возможность переопределить функцию представления reset_password.

...
from .forms import CreatePassword, GetPassword, LoginForm, ResetPassword
...

@auth.route('/reset-password/<token>', methods=['GET', 'POST'])
def reset_password(token):
    if current_user.is_authenticated:
        abort(404)
    source = check_token(token)
    if source is None or source.user is None \
            or source.user.last_visit > source.requested or source.swap:
        abort(404)
    form = ResetPassword()
    if form.validate_on_submit():
        account = Account.query.filter_by(address=form.address.data).first()
        if account != source:
            flash('Неверный запрос, действие отклонено.')
            return redirect(url_for('auth.login'))
        account.user.password = form.password.data
        account.user.last_visit = datetime.utcnow()
        db.session.add(account)
        db.session.commit()
        flash('У Вас новый пароль.')
        return redirect(url_for('auth.login'))
    return render_template(
        'auth/reset_password.html', form=form, username=source.user.username,
        interval=current_app.config.get('REQUEST_INTERVAL', 2))

Создаю для этой страницы шаблон.

touch selfish/templates/auth/reset_password.html

Открываю его в редакторе и верстаю.

{% extends "auth/auth_base.html" %}
{% from "macros/_auth.html" import get_message %}
{% from "macros/_auth.html" import render_form_group %}

{% block title %}Восстановите свой пароль{% endblock %}

{% block form_content %}
  <div class="form-block content-block">
    <div class="block-header">
      <h3 class="panel-title">Восстановите свой пароль</h3>
    </div>
    <div class="block-body">
      <div class="today-field"></div>
      <div class="form-help">
        <p>
          Уважаемый {{ username }}, здесь Вы сможете обновить свой забытый
          пароль, для этого заполните форму ниже. Введите в соответствующие
          поля Ваш адрес электронной почты, на который вы получили сообщение,
          содержащее ссылку на эту страницу, придумайте и введите новый пароль,
          повторите его в поле "Повторите" и нажмите кнопку "Обновить пароль".
          Будьте внимательны, после обновления пароля Вы сможете войти в сервис
          только с новым паролем. Повторно запросить восстановление пароля
          можно будет не раньше чем через {{ interval }} часа.
        </p>
      </div>
      {% set messages = get_flashed_messages() %}
      {% if messages %}
        {{ get_message(messages) }}
      {% endif %}
      <div class="form-form">
        <form class="form" method="POST" role="form">
          {{ form.hidden_tag() }}
          {{ render_form_group(
              form, form.address, 'address', 'введите свой адрес эл. почты') }}
          {{ render_form_group(
              form, form.password, 'password', 'введите желаемый пароль') }}
          {{ render_form_group(
              form, form.confirmation, 'confirmation', 'повторите пароль') }}
          <div class="form-group">
            <div class="form-input">
              {{ form.submit(class="btn btn-primary btn-block") }}
            </div>
          </div>
        </form>
      </div>
    </div>
  </div>
{% endblock form_content %}

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

12. Тестируем новый функционал в браузере

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

celery worker -A celery_worker.celery -l info &

JKVE0Y7UlV.png

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

python manage.py runserver

yMVMADbF3y.png

Иду в браузер и вбиваю в адресную строку url-адрес главной страницы приложения, в главном меню нахожу ссылку "Получить пароль" и следую по ней.

DjrMT05m43.png

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

sIsMilcB2w.png

Жму кнопку "Получить пароль" и получаю ожидаемый результат, система перенаправила меня на страницу входа в сервис и сообщила о высланном на введённый адрес письме с инструкциями.

GpCUkDzXb5.png

Возвращаюсь на страницу запроса пароля при помощи главного меню и пытаюсь повторно запросить письмо с инструкциями.

eEOs0qFAHx.png

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

Bf2nMofCvP.png

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

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

xWAkbDaFXg.png

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

o3w2sb9KUV.png

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

l0pD44mtfY.png

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

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

PhjKw8ADan.png

Нажимаю кнопку "Получить пароль" и оказываюсь на странице входа в сервис с сообщением о высланном письме с инструкциями.

aS7wKS6cQw.png

Опять лезу в терминал, прокручиваю выхлоп сервера и ищу текст регистрационного письма, нахожу в нём регистрационную ссылку и копирую её в буфер обмена.

X3QMUplNfR.png

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

VDM5MQFWTN.png

Придумываю и ввожу в форму псевдоним, новый пароль для регистрируемого пользователя, повторяю этот пароль и нажимаю кнопку "Создать пароль".

ohzZvzbPjx.png

Опять оказываюсь на странице входа в сервис с новым информационным сообщением.

W1ZPB5SVNe.png

Ввожу логин только что созданного пользователя и пароль.

V63jwi6eET.png

Нажимаю кнопку "Войти в сервис".

Kthei7Hzw3.png

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

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

Как всегда текущее состояние кода selfish можно увидеть в моём аккаунте на gitlab.com. Вопросы приветствуются, для этого у нас есть комментарии и приватные сообщения. В следующем выпуске блога я продемонстрирую первое тестовое развёртывание сервиса на виртуальный сервер и протестирую самостоятельную регистрацию аккаунта в боевых условиях, продолжение следует, будет интересно.

Комментарии: