Страница входа в сервис в web-приложении на Python и Starlette
programming
Вход пользователя в сервис web-приложения на Python и Starlette, процесс разработки которого мы внимательно и в пошаговом режиме исследуем в этом цикле статей, должен сопровождаться вводом логина и пароля. В очередном выпуске этого блога я покажу вёрстку страницы, на которой посетитель будущего web-сайта сможет ввести в специальную форму свои уникальные идентификационные данные (логин и пароль), нажать расположенную в этой форме "волшебную" кнопку и авторизоваться — действие элементарное, всех нас давно приучили к подобному действию многочисленные социальные сети и публичные сервисы современной сети Интернет. И сейчас я покажу вам, что прячется за старым холстом в каморке папы Карло. Будьте осторожны, в топике много кода, картинок и лирических отступлений автора.
Кэшируем капчу
Эта демонстрация является логическим продолжением предыдущего повествования, в ходе которого я показал процесс автоматизированного создания группы картинок с кодом для CAPTCHA-теста. На текущий момент эти картинки хранятся в SQL базе данных. Поскольку количество этих картинок выражается конечным целым числом, а доступ к каждой из них осуществляется по уникальному семизначному суффиксу, который в свою очередь является параметром url-адреса этой конкретной картинки, не исключена вероятность, что злоумышленник скачает все эти картинки, распознает их программно, а потом использует для преодоления поставленного барьера. Дабы затруднить реализацию такого коварного плана, замысел разработчика предусматривает механизм ротации картинок и изображенного на них кода при каждом использовании в тесте. В данном случае не важно, был тест успешным, или был провален. За каждым использованием конкретной картинки с кодом должен следовать принудительный процесс обновления в базе данных этой картинки и кода на ней.
Если посмотреть с другой стороны, не исключена ситуация, когда одна и та же картинка с кодом будет одновременно запрошена в двух различных сеансах двумя разными пользователями. Первый угадавший (или не угадавший) пользователь автоматически запустит механизм ротации, который изменит и картинку, и код на ней, при этом лишив второго пользователя возможности угадать код на показанной ему картинке. Отсюда следует необходимость сохранить данные перед показом картинки с кодом пользователю. Сохранить где-то ещё, вне основной SQL базы данных, чтобы механизм ротации не лишал пользователей возможности успешно пройти тест. И вот это самое где-то ещё я и должен сейчас описать адекватным и простым кодом.
Отметьте этот день в календаре — это день, когда в разрабатываемом в этом цикле статей web-приложении появится собственный, пусть и элементарный, API. Приступим...
Для размещения кода API мне необходим отдельный каталог. Создаю его в базовом каталоге приложения, даю ему соответствующее имя — api
.
$ mkdir webapp/api
В этом каталоге создаю собственный "инит", так я привык обозначать пакеты Python.
$ touch webapp/api/__init__.py
Кэшировать данные картинки для капчи я намерен в базу данных Redis, инструменты для подключения к которой разработал в доступной по этой короткой ссылке демонстрации. Для этого мне потребуется пара простых и удобных инструментов. Создаю с помощью текстового редактора Vim в каталоге api
новый вложенный модуль, даю ему интуитивно понятное имя — redi.py
.
$ vim webapp/api/redi.py
Пишу в этот файл следующий код...
Подключаю ранее разработанные вспомогательные функции.
from ..common.random import randomize
from ..common.redi import get_rc
Функция randomize
генерирует случайную строку вида abcd1234
, литеры в которой следуют в случайном порядке, а разрядность строки (количество символов) можно задать с помощью единственного аргумента функции. А при помощи функции get_rc
можно создать активное подключение к текущей базе данных Redis.
В этом же модуле создаю вспомогательную функцию с именем get_unique
, с помощью которой планирую создавать имя ключа Redis, в который помещу впоследствии данные кэшируемой картинки с кодом.
async def get_unique(conn, prefix, num):
## в бесконечном цикле
while True:
## создаю имя ключа
res = prefix + await randomize(num)
## заглядываю в текущую базу Redis
if await conn.exists(res):
## если условие выполняется, ключ
## с именем в переменной res существует
## в базе данных
## возвращаюсь в начало цикла
continue
## если ключ с запрошенным именем
## в текущей базе данных не существует,
## прерываю цикл и
## возвращаю только что созданное имя ключа
return res
С помощью второй вспомогательной функции этого модуля, ей даю имя assign_cache
, я планирую создавать новый ключ в текущей базе данных Redis, передавая этой функции необходимые по условию задачи аргументы.
async def assign_cache(request, prefix, suffix, val, expiration):
## подключаюсь к базе данных Redis
rc = await get_rc(request)
## создаю уникальное имя ключа
cache = await get_unique(rc, prefix, 6)
## записываю в ключ с только что созданным
## именем данные картинки с кодом,
## меня интересуют суффикс и код на картинке
await rc.hmset(cache, {'suffix': suffix, 'val': val})
## назначаю период выгорания для созданного ключа,
## по истечению которого ключ будет удалён
await rc.expire(cache, expiration)
## закрываю соединение с текущей базой данных
await rc.close()
## возвращаю имя только что созданного ключа
return cache
Здесь следует обратить внимание на аргумент prefix
в этой функции. Я планирую для данных капчи создавать ключи вида captcha:abc123
, в этом случае prefix
будет иметь вид captcha:
. Сохраняю изменения в файл, но покидать текстовый редактор не тороплюсь, для него ещё есть много работы.
Кэшировать данные картинки с кодом для капчи я планирую самым простым http-запросом по методу GET, настало время создать обработчик для такого запроса. В командном режиме Vim создаю в каталоге api
новый модуль, даю ему имя main.py
— в данном случае имя модуля говорит нам, что модуль описывает вспомогательные http-запросы для главной подпрограммы разрабатываемого web-приложения, так мне будет проще ориентировать в недрах исходного кода программы.
:edit webapp/api/main.py
В этот модуль пишу следующий код...
Создать обработчик для запроса кэширования данных капчи я планирую на базе библиотечного класса HTTPEndpoint из арсенала Starlette, подключаю его.
from starlette.endpoints import HTTPEndpoint
Разрабатываемый обработчик запроса должен будет вернуть Content-Type
JSON в своём ответе, подключаю соответствующий класс из арсенала Starlette.
from starlette.responses import JSONResponse
Для реализации задуманного мне потребуются разработанные ранее вспомогательные функции get_conn
и assign_cache
, подключаю.
from ..common.pg import get_conn
from .redi import assign_cache
И вот как в моих представлениях должен выглядеть обработчик запроса для кэширования данных картинки с кодом для капчи.
class Captcha(HTTPEndpoint):
async def get(self, request):
## обрабатываю запрос по методу GET
## создаю подключение к PostgreSQL базе данных
conn = await get_conn(request.app.config)
## запрашиваю данные случайной строки
## из этой базы данных, меня интересуют
## суффикс и код картинки из поля val
captcha = await conn.fetchrow(
'SELECT val, suffix FROM captchas ORDER BY random() LIMIT 1')
## создаю словарь с именем res, подключившись
## к текущей базе Redis с помощью функции assign_cache
## при этом задаю правильный prefix и время выгорания 3 минуты
res = await assign_cache(
request, 'captcha:',
captcha.get('suffix'), captcha.get('val'), 180)
## создаю url-адрес для только что выбранной картинки
url = request.url_for('captcha', suffix=captcha.get('suffix'))._url
## возвращаю полученные в итоге обработки данные,
## а именно, значения переменных res и url
return JSONResponse({'captcha': res, 'url': url})
Сохраняю изменения в файл.
Только что созданному обработчику необходимо сопоставить соответствующий url-адрес. Карта url-адресов приложения хранится в "ините" базового каталога. Открываю этот файл в командном режиме Vim.
:edit webapp/__init__.py
В недрах этого модуля мне нужно подключить только что созданный класс Captcha
.
from .api.main import Captcha
Поскольку создаваемый url-адрес будет относиться к API разрабатываемого web-приложения, создаю с помощью библиотечного класса Mount из арсенала Starlette новую группу адресов, вписываю в список параметра routes
объекта app
ещё один элемент.
Mount('/api', name='api', routes=[
Route('/captcha', Captcha, name='acaptcha'),
]),
И вот как этот код в конечном итоге выглядит в окне моего текстового редактора.
Сохраняю изменения в файл. Текстовый редактор не покидаю, для него ещё есть работа. А только что разработанный инструментарий следует протестировать...
Тестируем в браузере
В соседнем окне запускаю ещё один экземпляр терминала, вхожу в этом терминале в корневой каталог разрабатываемого web-приложения и активирую его виртуальное окружение. Штатный запуск отладочного сервера — это первое, что я ожидаю увидеть, внимание на следующий снимок экрана.
Как видно выше, отладочный сервер запустился без сбоев, ошибок и предупреждений в своём выхлопе. Отрадно..! Пойдём в браузер.
Запускаю браузер, вбиваю в его адресной строке только что разработанный url-адрес. Внимание на следующий снимок экрана.
Как видно на картинке выше, по заданному адресу сервер вернул JSON с именем ключа базы данных Redis, в котором хранятся кэшированные данные картинки с кодом, и url-адрес искомой картинки. Заглянем в Redis и проверим наличие указанного в ответе сервера ключа и данных искомой картинки.
По предложенному в ответе сервера url-адресу тоже стоит проследовать, дабы убедиться, что код на картинке совпадает с кодом в ключе Redis, делать ещё один снимок экрана я не стану, просто поверьте мне на слово.
Следуем далее...
Редактируем главное меню
Итак, эта демонстрация посвящена вёрстке страницы авторизации, или, другими словами, страницы для входа пользователя в предлагаемый разрабатываемым web-приложением сервис. Этой странице необходим собственный url-адрес. Но создавать для этой страницы отдельный обработчик в карте адресов приложения я не буду. Я буду использовать параметры поиска на url-адресе стартовой страницы. В командном режиме текстового редактора Vim открываю файл базового шаблона приложения.
:edit webapp/templates/base.html
В коде этого файла ищу тег <ul>
с классом navbar-right
, в рамках этого тега нахожу ссылку с идентификатором login
и дописываю этой ссылке атрибут href
.
<a id="login" href="/?realm=login">Войти</a>
Чтобы не потеряться в недрах этого файла, демонстрирую, как интересующий меня код выглядит в окне моего текстового редактора.
На стартовой странице я планирую обрабатывать параметр поиска с именем realm
и значением login
.
Ищу в этом же файле тег с идентификатором mc
и дописываю этому тегу класс.
<div id="mc"
class="{% if listed %}listed{% else %}nonlisted{% endif %}">
{% block page_body %}{% endblock page_body %}
</div>
Здесь я с помощью инструментов Jinja2 задал этому тегу один из двух возможных классов: listed
или nonlisted
, в зависимости от значения в переменной listed
, эта переменная чуть позже появится в соответствующих словарях обработчика стартовой страницы, об этом позже. Вот как внесённая правка выглядит в окне моего тестового редактора.
Сохраняю изменения в файл, и остаюсь в окне текстового редактора Vim, для него есть ещё работа.
Обрабатываем параметры поиска
На стартовой странице появился параметр поиска с именем realm
, его значение мне нужно обработать в соответствующей функции представления. Открываю в текстовом редакторе модуль views.py
из каталога главной подпрограммы main
.
:edit webapp/main/views.py
В этом файле нахожу функцию представления с именем show_idex
. Эту функцию переписываю следующим образом.
async def show_index(request):
conn = await get_conn(request.app.config)
## новый код
## создаю текущего пользователя и присваиваю
## этой переменной значение None, текущего пользователя
## в арсенале web-приложения пока нет
cu = None
## получаю из параметров поиска
## обрабатываемого url-адреса
## значение параметра realm
realm = request.query_params.get('realm')
amount = await conn.fetchval('SELECT count(*) FROM users')
## проверяю первое условие
if cu is None:
## если условие выполняется, и текущий пользователь
## не существует, проверяю второе условие
if realm == 'login':
## если условие выполняется, в параметре
## поиска realm хранится строка login?
## закрываю созданное ранее соединение
## с базой данных PostgreSQL
await conn.close()
## рендерю шаблон с именем login.html
## и возвращаю его содержимое в http-ответе сервера
return request.app.jinja.TemplateResponse(
'main/login.html',
{'request': request,
'listed': False})
## если первое условие не выполняется
## и текущий пользователь существует
## исполняю разработанный ранее код
await conn.close()
await set_flashed(request, f'Известно пользователей: {amount}.')
return request.app.jinja.TemplateResponse(
'main/index.html',
{'request': request,
'message': 'Читайте меня, читайте..!',
'listed': True,
'flashed': await get_flashed(request)})
Вот как выглядит эта функция представления в окне моего редактора после внесённых в этот файл только что правок.
Обращаю ваше внимание, что в шаблон index.html
я добавил переменную listed
, обозначающую соответствующий класс тега с идентификатором mc
в коде страницы, соответствующие правки в базовый шаблон я уже внёс чуть ранее.
Сохраняю изменения в файл. Остаюсь в окне текстового редактора.
Создаём шаблон страницы входа
Раз уж в обработчике стартовой страницы я использовал ещё один шаблон, этот шаблон нужно создать. В командном режиме в окне текстового редактора Vim исполняю следующую команду.
:edit webapp/templates/main/login.html
Шаблон login.html
будет наследником шаблона base.html
, и в этот шаблон пишу следующий код.
{% extends "base.html" %}
{% block title_part %}Login{% endblock title_part %}
{% block styles %}
{{ super() }}
{% assets filters='cssmin', output='generic/css/main/login.css',
'css/base.css' %}
<link rel="stylesheet" href="{{ ASSET_URL }}">
{% endassets %}
{% endblock styles %}
{% block scripts %}
{{ super() }}
{% assets filters='rjsmin', output='generic/js/main/login.js',
'js/main/login.js' %}
<script src="{{ ASSET_URL }}"></script>
{% endassets %}
{% endblock scripts %}
Между блоками styles
и scripts
дописываю ещё один блок, даю ему имя templates
. В рамках этого блока создаю тег <script>
с идентификатором logint
. Вот как выглядит код этого блока.
{%- block templates -%}
{{- super() -}}
<script id="logint" type="text/template">
<div id="loginf" class="content-block">
<h1>Вход в сервис</h1>
<div class="today-field">some</div>
<div class="form-help">
<p>
Для входа в сервис введите свой логин, пароль и код с картинки,
нажмите кнопку "Войти в сервис". Логином может быть
зарегестрированный псевдоним или адрес электронной почты. Форма
действительна в течение 3-х минут после загрузки страницы или
обновления картинки с кодом. Если код на картинке плохо
читается, можно обновить картинку кнопкой со стрелками и найти
картинку с хорошо читаемым кодом. <b>Для справки:</b> код
состоит из строчных латинских букв и цифр без повторов, будьте
внимательны, очень легко спутать букву "o" и цифру "0", "l" и "1",
"i" и "j". Если в коде допущена ошибка, тест считается проваленным,
но есть возможность исправить ошибку и отправить форму вновь.
</p>
</div>
<div class="form-form">
<form>
<div class="form-group">
<div class="form-label text-right">
<label for="logininput">Логин:</label>
</div>
<div class="form-input">
<input class="form-control"
id="logininput"
name="logininput"
autocomplete="username"
placeholder="введите свой логин"
required
type="text">
</div>
<div class="footer-bottom"></div>
</div>
<div class="form-group">
<div class="form-label text-right">
<label for="password">Пароль:</label>
</div>
<div class="form-input">
<input class="form-control"
id="password"
name="password"
autocomplete="current-password"
placeholder="введите свой пароль"
required
type="password">
</div>
</div>
<div class="form-group">
<div class="form-input checkbox">
<label>
<input id="remember_me"
name="remember_me"
type="checkbox">
<label for="remember_me">
Хранить сессию 30 дней
</label>
</label>
</div>
</div>
<div class="form-group">
<div class="form-label captcha-options text-right">
<button type="button"
title="обновить картинку"
id="lcaptcha-reload"
class="btn btn-default">
<span class="glyphicon glyphicon-refresh"
aria-hidden="true"></span>
</button>
</div>
<div id="lcaptcha-field" class="form-input captcha-field"
style="background:url(<% url %>)"></div>
<div class="footer-bottom"></div>
</div>
<div class="form-group">
<div class="form-label text-right">
<label for="lcaptcha">Код с картинки:</label>
</div>
<div class="form-input">
<input class="form-control"
id="lcaptcha"
placeholder="введите код с картинки"
required
type="text">
</div>
<div class="footer-bottom"></div>
</div>
<div class="form-group hidden">
<div class="form-input">
<input class="form-control"
id="lsuffix"
required
type="text"
value="<% captcha %>">
</div>
</div>
<div class="form-group">
<div class="form-input">
<button type="button"
class="btn btn-primary"
id="login-submit"
name="login-submit">Войти в сервис</button>
<button type="button"
class="btn btn-info"
id="login-reg">Получить пароль</button>
</div>
</div>
</form>
</div>
</div>
</script>
{%- endblock templates -%}
Этот код определяет шаблон для обработки инструментами библиотеки Mustache.js, эта библиотека была подключена в приложение в одной из предыдущих демонстраций этого цикла статей. В этом коде следует обратить внимание на теги с идентификаторами lcaptcha-field
и lsuffix
, в этих тегах я задал значения с помощью переменных Mustache.js, внимание на следующий снимок экрана.
Значения обозначенных переменных я намерен получить из кэширующего данные капчи GET-запроса, который уже оформлен кодом в начале этой демонстрации. Сохраняю изменения в файл.
Рендерим страницу инструментами JavaScript
Поскольку в html коде страницы авторизации появился шаблон, этот шаблон необходимо отрендерить, и делать это я буду с помощь сценария JavaScript. Открываю в командном режиме Vim следующий файл.
:edit webapp/static/js/main/login.js
В этот файл пишу следующий код.
$(function() {
"use strict";
let dt = luxon.DateTime.now();
$.ajax({
method: 'GET',
url: '/api/captcha',
success: function(data) {
let form = Mustache.render($('#logint').html(), data);
$('#mc').append(form);
if ($('.today-field').length) renderTF('.today-field', dt);
checkPC(860);
},
dataType: 'json'
});
});
Этот код использует инструменты jQuery, эта библиотека имеется в арсенале разрабатываемого web-приложения, она подключена ранее. В этом коде следует обратить внимание на следующую функцию.
$.ajax({
method: 'GET',
url: '/api/captcha',
success: function(data) {
let form = Mustache.render($('#logint').html(), data);
$('#mc').append(form);
if ($('.today-field').length) renderTF('.today-field', dt);
checkPC(860);
},
dataType: 'json'
});
Эта функция реализует Ajax-запрос по методу GET и по указанном в ней url-адресу, кэширующему данные картинки с кодом для капчи. В ключе success
я рендерю шаблон с идентификатором logint
— его код представлен выше, передаю этому шаблону полученные из ответа сервера, и итоговый html код вставляю в тег с идентификатором mc
. Поскольку в теле этой функции я использовал вызовы разработанных ранее функций renderTF
и checkPC
, эти файлы необходимо дописать в соответствующий asset шаблона login.html
— открываю его в окне текстового редактора.
:edit webapp/templates/main/login.html
И редактирую блок scripts
этого шаблона следующим образом, внимание на следующий снимок экрана.
В соседнем терминале уже запущен отладочный сервер, в окне браузера уже можно посмотреть, как будет выглядеть станица авторизации в данной редакции. Я сделаю это чуть позже...
Сохраняю изменения всех файлов.
Стилизуем страницу авторизации
На только что разработанной странице авторизации пользователей я использовал несколько новых элементов. Эти же элементы я буду использовать много раз на других страницах этого web-приложения, поэтому таблицы стилей для этих элементов я вынесу в отдельные файлы.
Создаю файл для стилей картинки CAPTCHA.
:edit webapp/static/css/captcha.css
Пишу в этот файл следующий код.
.captcha-field {
border: 1px solid gainsboro;
box-shadow: 0 0 4px tan;
border-radius: 3px;
width: 120px;
height: 60px;
}
.captcha-options {
padding-top: 12px;
}
Блок формы авторизации я обработаю в файле с именем content-block.css
, создаю его.
:edit webapp/static/css/content-block.css
В этом блоке стилизую только заголовок блока, пишу в этот файл следующий код.
.content-block h1 {
margin: 0;
font-size: 1.4em;
}
Справочный текст формы авторизации я обработаю стилями файла с именем form-help.css
, создаю его.
:edit webapp/static/css/form-help.css
В этот файл добавлю следующий код.
.form-help {
font-style: italic;
text-align: justify;
font-size: 0.9em;
}
.form-help p {
margin: 0;
}
Поля формы авторизации имеют соответствующие лэйблы, их я обработаю в файле form-labeled.css
, создаю его.
:edit webapp/static/css/form-labeled.css
Пишу в этот файл следующий код.
.form-form {
margin: 0;
font-size: 0.9em;
}
.form-group {
margin: 6px 0 0;
}
.form-label {
width: 25%;
float: left;
padding-top: 7px;
}
.checkbox {
margin: 0;
}
.checkbox label label {
padding-left: 3px;
}
.form-input {
margin-left: 26%;
}
В теге с идентификатором mc
появился новый класс, его я обработаю в файле mc.css
, создаю его.
:edit webapp/static/css/mc.css
Пишу в этот файл следующий код.
.nonlisted {
padding: 8px;
border-radius: 4px;
border: 1px solid gainsboro;
background-image: linear-gradient(to top, ivory, ivory);
box-shadow: 0 0 4px gainsboro;
}
И для полного счастья мне не хватает пары вариантов для обработки тега с идентификатором page-content
, файл с именем page-content.css
уже существует, открываю его в окне текстового редактора.
:edit webapp/static/css/page-content.css
Привожу код этого файла к следующему виду.
#page-content {
padding: 4px 0 6px;
margin-left: auto;
margin-right: auto;
width: 840px;
}
@media (max-width: 768px) {
#page-content {
width: 748px;
}
}
Всё это великолепие новых файлов с таблицами стилей необходимо добавить в шаблон страницы авторизации. Вновь открываю его в окне текстового редактора.
:edit webapp/templates/main/login.html
В блоке styles
привожу его assets к следующему виду, внимание на снимок экрана.
Сохраняю изменения всех файлов, напоминаю, что в текстовом редакторе Vim это можно сделать командой :wa
. Пришло время протестировать внешний вид и содержимое страницы авторизации в браузере.
Тестируем в браузере
Поскольку отладочный сервер уже запущен в терминале соседнего окна, запускаю браузер и в его окне, в его адресной строке вбиваю адрес стартовой страницы. На стартовой странице нахожу главное меню, в нём раздел Действия, в этом разделе меня интересует ссылка Войти.
Следую по этой ссылке, и, как говорят французы, вуаля...
Констатирую, что у меня получилась вполне симпатичная форма для авторизации пользователей. Как видно на снимке экрана, в поле CAPTCHA теста отображается картинка с кодом, как и ожидалось.
Следует посмотреть на исходный код страницы, как его отображает браузер, в исходном коде форма отсутствует. Следует также заглянуть в консоль JavaScript браузера и убедиться, что в ней отсутствуют сообщения об ошибках и предупреждения. Подтверждать эти тесты снимками экрана не стану, у меня дефицит дискового пространства на сервере avm4.ru
, просто поверьте мне на слово, или, если нет, проверьте сами.
Подводим промежуточный итог
Работе конец, наконец-то, всё чисто и гладко... Честно признаться, я немного запурхался, отлаживая представленный в этой демонстрации код. А что вы думали..? Web-разработка — путь тернистый, кормить в пути не обещали. Но зато, в итоге всех потраченных усилий и злоключений в арсенале разрабатываемого web-приложения появилась станица авторизации пользователей. Текущую версию кода приложения, как всегда, можно найти в моём профиле на github.com по этой короткой ссылке. А я получаю возможность продолжить разработку, и в следующем выпуске этого блога я планирую показать инструменты ротации картинок CAPTCHA-теста. Продолжения цикла не будет, не востребовано.