Selfish, регистрация и восстановление пароля

avm

Опубликован:  2018-04-12T10:41:38.073018Z
Отредактирован:  2018-04-12T15:02:00.605857Z
500
Продолжение пошагового описания процесса разработки web-приложения Selfish. Продолжена разработка системы авторизации. В результате выполнения описанных действий Selfish предоставит своим пользователям возможность самостоятельно регистрировать аккаунты и восстанавливать забытый пароль. В приложении появится очередь задач и будет реализована отправка сообщений электронной почты.

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

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

Все топики посвященные Selfish можно отфильтровать по метке selfish.

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

  • Запрос пароля;
  • Регистрация псевдонима и пароля;
  • Восстановление забытого пароля.

2. Функциональные страницы для регистрации и восстановления пароля

Selfish уже сейчас имеет суперпользователя, который может регистрировать аккаунты новых пользователей непосредственно в 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>'

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

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

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

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

(venv) sadmin@debian:~/workspace/selfish$ pip install celery
Collecting celery
  Downloading celery-4.1.0-py2.py3-none-any.whl (400kB)
    100% |████████████████████████████████| 409kB 862kB/s 
Collecting kombu<5.0,>=4.0.2 (from celery)
  Downloading kombu-4.1.0-py2.py3-none-any.whl (181kB)
    100% |████████████████████████████████| 184kB 920kB/s 
Collecting pytz>dev (from celery)
  Downloading pytz-2018.3-py2.py3-none-any.whl (509kB)
    100% |████████████████████████████████| 512kB 526kB/s 
Collecting billiard<3.6.0,>=3.5.0.2 (from celery)
  Downloading billiard-3.5.0.3-py3-none-any.whl (89kB)
    100% |████████████████████████████████| 92kB 595kB/s 
Collecting amqp<3.0,>=2.1.4 (from kombu<5.0,>=4.0.2->celery)
  Downloading amqp-2.2.2-py2.py3-none-any.whl (48kB)
    100% |████████████████████████████████| 51kB 677kB/s 
Collecting vine>=1.1.3 (from amqp<3.0,>=2.1.4->kombu<5.0,>=4.0.2->celery)
  Downloading vine-1.1.4-py2.py3-none-any.whl
Installing collected packages: vine, amqp, kombu, pytz, billiard, celery
Successfully installed amqp-2.2.2 billiard-3.5.0.3 celery-4.1.0 kombu-4.1.0 pytz-2018.3 vine-1.1.4
(venv) sadmin@debian:~/workspace/selfish$ 

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

(venv) sadmin@debian:~/workspace/selfish$ pip install redis
Collecting redis
  Downloading redis-2.10.6-py2.py3-none-any.whl (64kB)
    100% |████████████████████████████████| 71kB 341kB/s 
Installing collected packages: redis
Successfully installed redis-2.10.6
(venv) sadmin@debian:~/workspace/selfish$ 

Здесь следует отметить, что Redis требует сервера. У меня Redis установлен на базовом сервере, подробности об организации рабочего места изложены в Debian stretch, подготовка рабочего места к web-разработке на Python3.

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

(venv) sadmin@debian:~/workspace/selfish$ pip install Flask-Mail
Collecting Flask-Mail
  Downloading Flask-Mail-0.9.1.tar.gz (45kB)
    100% |████████████████████████████████| 51kB 249kB/s 
Requirement already satisfied: Flask in ./venv/lib/python3.5/site-packages (from Flask-Mail)
Collecting blinker (from Flask-Mail)
  Downloading blinker-1.4.tar.gz (111kB)
    100% |████████████████████████████████| 112kB 442kB/s 
Requirement already satisfied: Jinja2>=2.4 in ./venv/lib/python3.5/site-packages (from Flask->Flask-Mail)
Requirement already satisfied: Werkzeug>=0.7 in ./venv/lib/python3.5/site-packages (from Flask->Flask-Mail)
Requirement already satisfied: click>=2.0 in ./venv/lib/python3.5/site-packages (from Flask->Flask-Mail)
Requirement already satisfied: itsdangerous>=0.21 in ./venv/lib/python3.5/site-packages (from Flask->Flask-Mail)
Requirement already satisfied: MarkupSafe>=0.23 in ./venv/lib/python3.5/site-packages (from Jinja2>=2.4->Flask->Flask-Mail)
Building wheels for collected packages: Flask-Mail, blinker
  Running setup.py bdist_wheel for Flask-Mail ... done
  Stored in directory: /home/sadmin/.cache/pip/wheels/b5/f8/fb/a2c4ba26c9e4a56d034b410deea2c3bfb9b1a21fed2e245f76
  Running setup.py bdist_wheel for blinker ... done
  Stored in directory: /home/sadmin/.cache/pip/wheels/7b/8a/eb/5a4f4444f366c515073db8a129c92d4727ad945e5e64b9e8bd
Successfully built Flask-Mail blinker
Installing collected packages: blinker, Flask-Mail
Successfully installed Flask-Mail-0.9.1 blinker-1.4
(venv) sadmin@debian:~/workspace/selfish$ 

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

(venv) sadmin@debian:~/workspace/selfish$ pip freeze > requirements.txt 
(venv) sadmin@debian:~/workspace/selfish$ 

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

4. Конфигурация Celery в контексте web-приложения Selfish

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

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/cely.py
(venv) sadmin@debian:~/workspace/selfish$ 

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

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 и переписываю функцию create_app следующим образом.

from celery import Celery
from flask import Flask, session
from flask_assets import Environment
from flask_login import LoginManager
from flask_sqlalchemy import SQLAlchemy
from flask_wtf.csrf import CSRFProtect

from config import Config, config
from .cely import init_celery

assets = Environment()
celery = Celery(
    __name__,
    broker=Config.CELERY_BROCKER_URL,
    backend=Config.CELERY_RESULT_BACKEND)
db = SQLAlchemy()
csrf = CSRFProtect()
lm = LoginManager()
lm.login_view = 'auth.login'
lm.login_message = 'Только для зарегистрированных пользователей.'


def create_app(config_name, celery_instance):
    app = Flask(__name__)
    app.before_request(lambda: setattr(session, 'permanent', True))
    conf_obj = config[config_name]
    app.config.from_object(conf_obj)
    conf_obj.init_app(app)
    assets.init_app(app)
    db.init_app(app)
    csrf.init_app(app)
    lm.init_app(app)
    from .main import main as main_blueprint
    app.register_blueprint(main_blueprint)
    from .auth import auth as auth_blueprint
    app.register_blueprint(auth_blueprint, url_prefix='/auth')
    from .admin import admin as admin_blueprint
    app.register_blueprint(admin_blueprint, url_prefix='/admin')
    from .ajax import ajax as ajax_blueprint
    app.register_blueprint(ajax_blueprint, url_prefix='/ajax')
    celery_instance = init_celery(app, celery_instance)
    return app, celery_instance

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

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

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

(venv) sadmin@debian:~/workspace/selfish$ mkdir selfish/tasks
(venv) sadmin@debian:~/workspace/selfish$ touch selfish/tasks/__init__.py
(venv) sadmin@debian:~/workspace/selfish$ 

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

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

(venv) sadmin@debian:~/workspace/selfish$ touch celery_worker.py
(venv) sadmin@debian:~/workspace/selfish$ 

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

from selfish import celery, create_app

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

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

...

from celery_worker import app
from selfish import db
...

После чего удаляю из этого файла одну строчку.

app = create_app('default')    # эту строчку нужно удалить

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

(venv) sadmin@debian:~/workspace/selfish$ celery worker -A celery_worker.celery -l info

 -------------- celery@debian v4.1.0 (latentcall)
---- **** ----- 
--- * ***  * -- Linux-4.9.0-6-amd64-x86_64-with-debian-9.4 2018-04-09 11:35:56
-- * - **** --- 
- ** ---------- [config]
- ** ---------- .> app:         selfish:0x7f15ad4d6780
- ** ---------- .> transport:   redis://192.168.56.102:6379/0
- ** ---------- .> results:     redis://192.168.56.102:6379/0
- *** --- * --- .> concurrency: 2 (prefork)
-- ******* ---- .> task events: OFF (enable -E to monitor tasks in this worker)
--- ***** ----- 
 -------------- [queues]
                .> celery           exchange=celery(direct) key=celery


[tasks]


[2018-04-09 11:35:56,405: INFO/MainProcess] Connected to redis://192.168.56.102:6379/0
[2018-04-09 11:35:56,425: INFO/MainProcess] mingle: searching for neighbors
[2018-04-09 11:35:57,464: INFO/MainProcess] mingle: all alone
[2018-04-09 11:35:57,485: INFO/MainProcess] celery@debian ready.

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

5. Конфигурация Flask-Mail

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

class Config:
    ...
    MAIL_SERVER = 'smtp.yandex.ru'
    MAIL_PORT = 465
    MAIL_USE_SSL = True
    MAIL_USERNAME = os.getenv('SMUN', 'your_address@yandex.ru')
    MAIL_PASSWORD = os.getenv('SMPW', 'T4asMD12u')
    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
...

...
lm.login_message = 'Только для зарегистрированных пользователей.'
mail = Mail()


def create_app(config_name, celery_instance):
    ...
    lm.init_app(app)
    mail.init_app(app)    # новая строчка
    from .main import main as main_blueprint
    app.register_blueprint(main_blueprint)
    ...
    return app, celery_instance

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

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/mail_tools.py
(venv) sadmin@debian:~/workspace/selfish$ 

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

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, который задаётся в секундах.

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

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/auth/tools.py
(venv) sadmin@debian:~/workspace/selfish$ 

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

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-тегов. Создаю четыре файла.

(venv) sadmin@debian:~/workspace/selfish$ mkdir -p selfish/templates/emails/auth
(venv) sadmin@debian:~/workspace/selfish$ touch selfish/templates/emails/auth/invitation.html
(venv) sadmin@debian:~/workspace/selfish$ touch selfish/templates/emails/auth/invitation.txt
(venv) sadmin@debian:~/workspace/selfish$ touch selfish/templates/emails/auth/reset_password.html
(venv) sadmin@debian:~/workspace/selfish$ touch selfish/templates/emails/auth/reset_password.txt
(venv) sadmin@debian:~/workspace/selfish$ 

В файл selfish/templates/emails/auth/invitation.html пишу следующее.

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

Повторяю этот же текст в файле selfish/templates/email/auth/invitation.txt

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

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

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

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

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

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

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

Шаблон selfish/templates/emails/auth/reset_password.html получает следующий текст.

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

Повторяю тот же текст в selfish/templates/emails/auth/reset_password.txt.

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

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

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

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

{{ target }}

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

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

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

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

8. Отложенное задание

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

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/tasks/auth_tools.py
(venv) sadmin@debian:~/workspace/selfish$ 

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

from datetime import datetime

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


class Resolver:
    def __init__(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)

Для отложенного задания мне понадобится новый файл. Создаю его.

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/tasks/auth.py
(venv) sadmin@debian:~/workspace/selfish$ 

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

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

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

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/templates/auth/get_password.html
(venv) sadmin@debian:~/workspace/selfish$ 

Открываю его в редакторе и пишу такой код.

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

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

                <li><a href="">Зарегистрироваться</a></li>

И переписываю его следующим образом.

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

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

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(default=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))

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

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/templates/auth/create_password.html
(venv) sadmin@debian:~/workspace/selfish$ 

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

{% 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 в файле selfish/auth/views.py.

...
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))

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

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/templates/auth/reset_password.html
(venv) sadmin@debian:~/workspace/selfish$ 

Открываю его в редакторе и пишу такой код.

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

Работа этой страницы будет продемонстрирована далее.

12. Тестирование в браузере

Для тестирования новых возможностей в браузере необходимо запустить отладочный сервер, но поскольку у Selfish появилось отложенное задание, сначала необходимо запустить дежурный процесс Celery. Это можно сделать при помощи команды:

(venv) sadmin@debian:~/workspace/selfish$ celery worker -A celery_worker.celery -l info &

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

console

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

browser

Следую по ней и попадаю на страницу запроса пароля. Ввожу адрес электронной почты зарегистрированного пользователя support.

request

Жму кнопку и получаю такой результат.

request

Возвращаюсь на страницу запроса пароля и повторяю действие с тем же адресом. Получаю другой результат.

repeated request

Нахожу в консоли регистрационную ссылку и копирую её.

link

Вставляю ссылку в браузер и перехожу по ней.

reset

Получаю возможность восстановить пароль для входа в сервис.

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

new-address

Жму кнопку, нахожу в консоли ссылку, копирую.

new-link

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

new-username

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

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

Текущая версия кода Selfish доступна на github.com.

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

 
Осталось: 4
Комментарии: