Одним из способов защиты от «роботов» и «ботов» является установка «капчи» на ключевые действия пользователей. Но красивую и сложную «капчу» генерить довольно ресурсоемко и не всегда оправдано: пользователь может выйти на страницу с «капчей» и не вводить её, а ресурсы на её отрисовку уже потрачены. Что бы не тратить ресурсы на генерацию «капчи» во время отрисовки формы, её можно создавать заранее и выводить как статичную картинку, но при этом мы сталкиваемся с определенными техническими трудностями, а именно:
- требуется прегенерять достаточно большое количество «капч» и следить что бы они не заканчивались;
- требуется выводить «капчу» рандомно при каждой отрисовки страницы (либо реализовать функционал обновления «капчи», если пользователь не смог разобрать на ней текст);
- требуется где-то хранить знания о том, что написано на каждой из «капч» (объяснять почему эти знания не стоит запихивать в имя файла или cookies я не буду);
- «капча» одноразовая, то есть, после того как она была показана и пользователь ввел правильный код, использовать повторно мы её не должны;
Задача
Есть некий файл, который доступен для скачивания, но при этом требуется вводить капчу.
Решение 1 — простое
Структура папок
- /spool/projects/capcha/ — root директория проекта;
- files/ — директория с файлами;
- capches/ — директория с «капчами»;
- index.html — индексный файл;
index.html
Все просто, форма в которую мы через SSI вставляем индексный файл из папки /capches/. Индексный файл у нас будет рандомный с использованием модуля ngx_http_random_index_module.
<html>
<body>
<form action="/download" method="GET">
<!--#include virtual="/capches/"-->
<input type="text" name="code" value="">
<input type="submit">
</form>
</body>
</html>
Файлы в папке capches
В файле такая часть формы:
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHgAAAA8AgMAAADQw5Y7AAAACVBMVEX///8AAAAAyMjozb6ZAAAB
50lEQVQ4jYWVS27DIBCGx0hUhLWVNa1aifYUTU5AJFuqukp3PQbNKgfIAVhGPmXnAW5SWR4jJ5hv
GP4ZXjCtPnACBxFfA+Az0HMFCNjg8X2CM9apRG6nB608WlPxjDMULEY6A1fxIxMuQBV8qT8AmXjg
DmxQ6IcK1sBPE46AxjIgEoaOtGUwaEFv4QY0YOyqeHGPrkW2m7FIr9pKFS7YsfRZW1XGulzDhroW
wVwLDUeKi0Ik39OUyXtEjCF5xKGpQ6dvGSZgmeJTsGfz2ALznM4ouEovrBiAIzC18TTj3LTx/x0O
om6OO4iB4BoZa6MpiTC3nHg5BJM9J5YDk2oxvBwE18R6VtYS2nBTR9pkHfCn4ACzuszKSmto2MtX
jZvrUfBZ3LCNI+yEOLE/V2Eynr/+6Qo3uHSD6x/HwSaT3IiR3uNsbRyyufSXLm12R/iPt90X4j49
22QhLeBjn31KEVK30NtaxJv3wwckM4YbXDjIzeG4zRaGnnp/cypoJlpvQ86RWVgc+4HwJ6SXZWxt
6H/20PfL0rrhddztM4Y1+jtpkjVJ6tXLbP7P2krO12ZMmW91tShrTV2pK+tc2SXqHlN36Nr+Vk4H
5WxRTiblXFNOReVMVU/ktfNcuQ2Uu0S5iZR7bP2S/AWn1wwm+CZwIQAAAABJRU5ErkJggg==
">
<input type="hidden" name="md5" value="IODn35yg2gLtnSRhyKyK6g">
<input type="hidden" name="key" value="5e12c2002a0370826a9dee5f6a55f5e3">
Так как после успешной проверки капчи нам потребуется удалять файл, поэтому изображение мы вставляем в него как data:image/png;base64. key — собственно имя файла, md5 — это md5 (base64) сумма от: key + код на капче + соль.
Конфиг nginx
server {
listen 80;
server_name capcha.local;
root /spool/projects/capcha/;
index index.shtml;
location / {
ssi on;
}
location /capches/ {
random_index on;
}
location /download {
proxy_pass %backend_uri%;
}
location /file {
deny all;
}
}
Здесь все просто:
- для корневого location разрешаем SSI директивы;
- для location /capches/ — устанавливаем рандомный индекс;
- location /download — проксируем на бекенд;
- для location /file — закрываем доступ, что бы по прямой ссылке невозможно было скачать файл.
Прегенерация частей форм
Как именно будет генерится капча и по какому алгоритму в рамках текущей статьи совершенно совершенно не важно. Я взял первый попавшийся модуль GD::SecurityImage и получился вот такой скрипт. Скрипт просто генерит картинку:
#!/usr/bin/perl
use uni::perl;
use GD::SecurityImage;
use Digest::MD5 qw|md5_base64 md5_hex|;
use MIME::Base64;
# Основные параметры:
my $root_dir = '/spool/projects/capcha';
my $salt = 'salt';
my $img_limit = 10;
# Считаем сколько файлов форм
my $counter = 0; $counter++ foreach <$root_dir/capches/*>;
while ($counter < $img_limit) {
my ($image_data, $mime_type, $random_number) = GD::SecurityImage
->new(width => 120, height => 60, gd_font => 'giant')
->random
->create('normal', 'circle', '#000000', '#00c8c8')
->out;
my $filename = md5_hex(rand);
my $encoded_image = encode_base64($image_data);
my $md5 = md5_base64($filename.' '.$random_number.' '.$salt);
next if $md5 =~ /[\+\/]/; # нам надо base64url но к сожалению в Digest::MD5 такого метода нет, а доработать - лень
open (my $form_fh, '>', $root_dir.'/forms/'.$filename.'.html') or die $!;
print $form_fh '<img src="data:image/'.$mime_type.';base64,'.$encoded_image.'">
<input type="hidden" name="md5" value="'.$md5.'">
<input type="hidden" name="key" value="'.$filename.'">';
close $form_fh;
$counter++;
}
1;
Как видно, имя файла соответствует параметру key, что бы было достаточно просто определить какой файл удалять в последствии.
Отдача файла
Часть бекенда я не буду рассматривать не буду, там все просто получаем параметры формы, проверяем сумму md5, в случае правильного набора удаляем файл с частью формы, отдаем файл пользователю.
Рассмотрим более необычное решение…
Решение 2 — pure nginx
Сразу хочу отметить, данное решение предлагается исключительно в ознакомительных целях. Если вы частично или совсем НЕ ПОНИМАЕТЕ механизма работы даже НЕ пытайтесь использовать это в продакшене. Те, кто полностью понимают механизм и сами поймут, надо ли им это.
Вообще-то основной целью этого решения является то, какие возможности предоставляют, те или иные казалось бы стандартные модули nginx. Итак…
Отдача файла осуществляется только при достижении определенных условий: введен правильный код и файл с частью формы в наличии и мы можем его удалить, и мы его удалили. Отдавать файлы — достаточно просто, это nginx умеет очень хорошо и нативно. Проверить md5 сумму — нет ничего проще, для этого есть модуль ngx_http_secure_link_module. Манипуляции с файлами можно осуществлять с помощью модуля ngx_http_dav_module, но тут немного сложнее, потому как нужно будет изменить метод GET на DELETE, а после удаления файла с частью формы еще требуется отдать запрашиваемый файл. Для этого дополнительно воспользуемся модулем ngx_http_proxy_module в связке с заголовком X-Accel-Redirect. Еще добавлю возможность скачивания только определенного списка файлов с помощью модуля ngx_http_map_module.
Добавим в форму запроса файла hidden поле file в котором укажем алиас файла для скачки:
<html>
<body>
<form action="/download" method="GET">
<!--#include virtual="/capches/"-->
<input type="hidden" name="file" value="arch1">
<input type="text" name="code" value="">
<input type="submit">
</form>
</body>
</html>
Для папки capches добавим символьную ссылку dav: ln -s /spool/projects/capcha/capches /spool/projects/capcha/dav
Конфигурация nginx будет выглядеть так:
# Проставляем соответствия алиасов и реальных имен файлов
map $file_alias $filename {
default 'fail';
'arch1' 'archive1.zip';
'arch2' 'archive2.zip';
'arch3' 'archive3.zip';
}
server {
listen 80;
server_name captcha.local;
root /spool/projects/capcha/;
index index.shtml;
location / {
ssi on;
}
location /capches/ {
random_index on;
}
location /download {
# Увы переданные аргументы мы можем получить только из строки запроса, поэтому обязательно GET
if ($request_method != 'GET') {
return 301 /fail;
}
# Проверяем что аргумент у нас передан без спец символов, так как относительно него мы будем удалять файл
if ($arg_key !~ '^\w+$') {
return 301 /fail;
}
# Проверяем md5 сумму
secure_link $arg_md5;
secure_link_md5 "$arg_key $arg_code salt";
if ($secure_link = "") {
return 301 /fail;
}
if ($secure_link = "0") {
return 301 /fail;
}
# После определения $file_alias автоматически переопределяется $filename
set $file_alias $arg_file;
proxy_intercept_errors on;
# Производим проксирование на location /dav с подменой метода на DELETE и URI на имя удаляемого файла части формы
proxy_pass http://127.0.0.1/dav/$arg_key.html;
proxy_method DELETE;
proxy_set_header Host $host;
# Так же определяем при проксировании дополнительный заголовок в котором укажем файл, которые потом потребуется отдать пользователю
proxy_set_header X-File $filename;
}
location /dav/ {
# Собственно символьная ссылка dav -> capches сделана именно для этого location, впрочем можно сделать и rewrite, на любителя
# Разрешаем доступ только с IP сервера, что бы данный location не был доступен извне, но доступен для локального проксирования
allow 127.0.0.1; # я тестировал локально поэтому такой IP
deny all;
# Можно только удалять
dav_methods DELETE;
# В случае правильного выполнения запроса обратно отдаем внутренний редирект на location /file/ с именем файла
add_header X-Accel-Redirect "/file/$http_x_file";
# так же это имя указываем дополнительно в заголовке, что бы файл скачивался с правильным именем
add_header Content-Disposition "attachment; filename=\"$http_x_file\"";
# иначе редиректим на страницу ошибки
error_page 403 404 =301 /fail;
}
location /file {
# location внутренний и доступен только по внутреннему редиректу
internal;
# Пытаемся прочитать файл или отдаем пустоту
try_files $uri =204;
}
location /fail {
return 200 'FAIL CODE';
}
}
И да, более простое правильное и понятное решение — использовать модуль ngx_http_perl_module, и всю логику осуществлять на уровне Perl, но, как я сказал выше данное решение я предлагаю исключительно в ознакомительных целях, что бы развить гибкость мышления при использовании стандартных инструментов.
Заключение
В заключении можно сказать следующее: Да, можно сделать статичную капчу и производить её валидацию прямо на уровне nginx не затрагивая при этом backend, но есть определенные сложности при работе с подобным решением, а именно:
- конкурентность — когда одновременно рандомно выберется один и тот же файл с частью формы двум разным пользователям, использовать её сможет только один, кто первый введет правильный код;
- требуется постоянно прегенерять достаточное количество «капч»;
- определенные трудности с масштабированием, впрочем, они решаемы;
P.S.
Как валидировать таким образом формы (например: регистрации), я, если честно, не думал, но постараюсь подумать об этом.