Введение


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


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


Условия


Для предотвращения скачки файлов ботами используется проверочная «капча».


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


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


Есть прокси, которая перенаправляет на бекенд все запросы.


Проблемы


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


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


Решение


Выделяем функциональность


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


<img src="data:image/png;base64,{{ IMAGE CODE BASE64 }}">
<input type="hidden" name="key" value="{{ KEY }}">

Либо JSON:


{
    ...
    "data": {
        "image": "data:image/png;base64,{{ IMAGE CODE BASE64 }}",
        "key": "{{ KEY }}"
    }
}

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


ssi on;

А в коде страницы формы вставляем:



...
    <form action="download" method="get" ...>
        ...
        <!--#include virtual="/x/captcha/generate"-->
        ...
    </form>
...

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


Да, механизм Server Side Include (SSI) уже практически забыт, но модуль nginx для него живее всех живых и работает очень быстро. И кстати, если закешировать proxy_pass_cache всю страницу, то результат include virtual кешироваться не будет, а будет отрабатывать каждый раз при запросе. Это позволяет сделать вставку — динамической.

Кеширование капчи


Для реализации кеширования нам потребуется что-нибудь достаточно рандомное и контролируемое по количеству вариантов, на эту роль подходит переменная $request_id — она достаточно рандомная и шестнадцатеричная, то есть, выбрав определенную часть данной переменной, можно ограничить количество элементов кеша до 16^n, где n — количество символов которые нам потребуются взять из переменной. Итак:


Определяем зону кеширования:


proxy_cache_path /cache/nginx/captcha levels=1:1 keys_zone=captcha:10m max_size=128m;

Тут важно определить, какое значение n мы выберем, соответственно от этого зависят параметры:


  • levels=1:2
  • max_size=128m
  • keys_zone=captcha:10m

Дабы хватило на все, но не было лишнего. Далее определяем ключ кеширования:


server {
    ...
    set $captcha_salt 'salt';
    if ( $request_id ~* "(\w{4})$" ) {
        set $cache_key $1;
    }
    ...

Переменная $captcha_salt нам еще пригодится, а сейчас она предохраняет от возможного пересечения ключей. Значение n я выбрал как 4, что значит 16^4 слотов кеширования, при этом на каждый слот в среднем выделяется по 2kb из общего объема кеша (max_size=128m) что должно хватить, иначе потребуется поднять максимальный размер.


Делаем соответствующий location


location /x/captcha/generate {
    proxy_cache         captcha;
    proxy_cache_key     "$captcha_salt:$cache_key";
    proxy_cache_valid   200 365d;
    proxy_cache_valid   any 0s;
    proxy_set_header    Host "captcha.service.domain.my";
    proxy_pass          http://captcha_upstream/?cache_key=$cache_key;
}

«Хорошие» ответы бекенда будут кешироваться почти навсегда, остальные кешироваться не будут. И да, можно сразу выделить функциональность работы с капчей в отдельный сервис.


Кстати, подобный механизм можно использовать для формирования псевдодинамики, когда пользователь давит F5 и ему каждый раз показывается новая случайная «картинка». При этом бекенд практически не нагружен.

Так же нам надо сбрасывать соответствующий кеш при проверке формы, поэтому бекенд, кроме всего прочего, должен отдавать значение cache_key для передачи его обратно в форму как hidden поле. К сожалению директива proxy_cache_purge доступна только в коммерческой версии. Не беда, есть сторонний модуль cache_purge, который может и слегка попроще, но нам хватит. Итак, location для сброса кеша:


location /x/captcha/cache/purge {
    internal;
    proxy_cache_purge   captcha "$captcha_salt:$arg_cache_key";
}

В нем стоит директива internal, так как мы не собираемся его использовать публично. А для вызова этого location будем использовать директиву mirror модуля http_mirror_module:


То есть мы делаем параллельный запрос на сброс кеша по ключу переменной $arg_cache_key, который передан в форме. Далее, просто проксируем запрос на наш бекенд где осуществляется остальная обработка.


Тернистый путь оптимизации


Тут на самом деле я хотел развить тему: как разделить проверку кода капчи и отдачу файла. Как предотвратить «вымывание» кеша неправильными запросами. Потом оптимизировать еще и еще, но все сводится к тому, что бекенд нам в общем-то уже не нужен… совсем… потому что у нас уже все есть и так.


Задача которая осталась за сервером в части проверки капчи — это собственно проверка ключ + код и удаление этой пары из хранилища. Проверка ключ + код может быть простым сравнением md5 суммы с ключем. Для этого нам достаточно модуля: http_secure_link_module. То есть ключ можно представить в виде формулы:


key = md5_baseurl( salt + code )

При этом нам не помешает привязка и к слоту кеша (ключу кеша), добавим и его:


key = md5_baseurl( salt + code + cache_key )

Salt у нас есть — это переменная $captcha_salt (вот она и пригодилась), но соль держать в двух местах backend и proxy — плохо, поэтому сделаем так:


location /x/captcha/salt {
    allow {{ captcha backend IPs }};
    deny all;
    return 200 "$captcha_salt";
}

И пусть бекенд ходит к прокси за солью.


Остается вопрос с хранилищем, где у нас хранится пара ключ + код который надо подчистить. Для этого нам подойдет механизм кеширования который мы уже реализовали. Единственно, что мы никак не обрабатываем результат cache_purge, а просто зеркалируем на него запрос, но это исправимо. И да, это оправдывает использование ключа кеша при создании ключа капчи.


Проверка кода


Переписываем location скачки файла:


location /download {
    proxy_set_header        Host $host;
    proxy_set_header        X-Context download;
    proxy_set_header        X-File-Name $arg_filename;
    proxy_set_header        X-Key $arg_key;
    proxy_set_header        X-Code $arg_code;
    proxy_set_header        X-Cache-Key $arg_cache_key;
    proxy_pass              http://127.0.0.1/x/captcha/check;
 
    proxy_intercept_errors  on;
    error_page              403 404  = /download/fail;
}

Требуемые параметры я передаю заголовками. Это необязательно, но мне так удобней. Обработку проксируем на локальный location проверки капчи. Дополнительно передается context=download, что бы в обработчике мы могли в зависимости от него выдавать тот или иной результат. При этом, обработчик может нам вернуть либо:


  • 403 — ошибка проверки кода. Собственно поэтому включен proxy_intercept_errors и объявлен location для перенаправления в случае ошибки;
  • 404 — ошибка чистки кеша. Модуль cache_purge в случае если в кеше с таким ключем нет ничего возвращает 404;
  • 200 + Accel-Redirect — на location отдачи файла, в случае, если проверка капчи прошла нормально. В нашем случае это будет X-Accel-Redirect: /store/file

Если бы error_page мог бы обрабатывать и коды 2XX то можно было бы обойтись одним им. Иначе требуется использовать механизм Accel-Redirect. Если очень хочется, то можно разделить обработчики ошибки 403 и 404;

Делаем простой location ошибки:


location /download/fail {
    internal;
    return 200 "FAIL DOWNLOAD";
}

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


Делаем location отдачи файлов:


location /store/file {
    internal;
    add_header  Content-Disposition "attachment; filename=\"$arg_filename\"";
    alias       /spool/tmp/;
    try_files   $arg_filename =404;
}

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


Следующий location у нас относится к проверке капчи:


location /x/captcha/check {
    allow               127.0.0.1;
    deny                all;
    secure_link_md5     "$captcha_salt$http_x_code$http_x_cache_key";
    secure_link         $http_x_key;
    if ($secure_link = "") {
        return          403 "FAIL CHECK CODE";
    }
    proxy_set_header    Host $host;
    proxy_pass          http://127.0.0.1/x/captcha/purge;
}

В нем 2 блока: проверка кода и проксирование дальше на очистку кеша. При этом, если проверка кода не прошла, то сразу возвращаем 403 (текст неважен, так как он далее не используется).


Проксирование на /x/captcha/purge вернет 2 варианта ответа:


  • 200 + Accel-Redirect — при успешной очистке кеша. Перенаправление будет на X-Accel-Redirect: /x/captcha/check/ok;
  • 404 — если очищать было нечего. Этот результат будет передан выше на /download и будет обработан в нем error_page;

Отдельный обработчик положительного ответа от /x/captcha/purge сделан из-за того, что во-первых нам требуется выйти на более высокий уровень проксирования, а не между /download и /x/captcha/check. Во-вторых, неплохо было бы отдавать свой положительный ответ относительно контекста.


Начнем с обработчика положительного ответа:


location /x/captcha/check/ok {
    internal;
    if ( $http_x_context = 'download' ) {
        add_header X-Accel-Redirect "/store/file?filename=$http_x_file_name";
    }
    ...
    return 200 "OK";
}

Собственно, в зависимости от значения переменной $http_x_context (заголовок X-Context), мы можем определить какой именно Accel-Redirect будет отдавать ответ с /x/captcha/check. Это значит, что можно этот механизм использовать в других местах кроме скачивания файла.


Очистка кеша — совсем простая:


location /x/captcha/purge {
    allow               127.0.0.1;
    deny                all;
    proxy_cache_purge   captcha "$http_x_cache_key";
    add_header          X-Accel-Redirect "/x/captcha/check/ok";
}

В общем и все, в итоге получилась такая конфигурация nginx:


proxy_cache_path /cache/nginx/captcha levels=1:1 keys_zone=captcha:10m max_size=128m;
 
server {
 
    ...
 
    location /download {
        proxy_set_header        Host $host;
        proxy_set_header        X-Context download;
        proxy_set_header        X-File-Name $arg_filename;
        proxy_set_header        X-Key $arg_key;
        proxy_set_header        X-Code $arg_code;
        proxy_set_header        X-Cache-Key $arg_cache_key;
        proxy_pass              http://127.0.0.1/x/captcha/check;
     
        proxy_intercept_errors  on;
        error_page              403 404  = /download/fail;
    }
 
    location /download/fail {
        internal;
        return 200 "FAIL DOWNLOAD";
    }
 
    location /store/file {
        internal;
        add_header  Content-Disposition "attachment; filename=\"$arg_filename\"";
        alias       /spool/tmp/;
        try_files   $arg_filename =404;
    }
 
    ...
 
    set $captcha_salt 'salt';
    if ( $request_id ~* "(\w{4})$" ) {
        set $cache_key $1;
    }
 
    location /x/captcha/generate {
        proxy_cache         captcha;
        proxy_cache_key     "$captcha_salt:$cache_key";
        proxy_cache_valid   200 365d;
        proxy_cache_valid   any 0s;
        proxy_set_header    Host "captcha.service.domain.my";
        proxy_pass          http://captcha_upstream/?cache_key=$cache_key;
    }
 
    location /x/captcha/salt {
        allow {{ captcha backend IPs }};
        deny all;
        return 200 "$captcha_salt";
    }
 
    location /x/captcha/check {
        allow               127.0.0.1;
        deny                all;
        secure_link_md5     "$captcha_salt$http_x_code$http_x_cache_key";
        secure_link         $http_x_key;
        if ($secure_link = "") { return 403 "FAIL CHECK CODE"; }
 
        proxy_set_header    Host $host;
        proxy_pass          http://127.0.0.1/x/captcha/purge;
    }
 
    location /x/captcha/check/ok {
        internal;
        if ( $http_x_context = 'download' ) {
            add_header X-Accel-Redirect "/store/file?filename=$http_x_file_name";
        }
        ...
        return 200 "OK";
    }
 
    location /x/captcha/purge {
        allow               127.0.0.1;
        deny                all;
        proxy_cache_purge   captcha "$http_x_cache_key";
        add_header          X-Accel-Redirect "/x/captcha/check/ok";
    }
}

На что следует обратить внимание:

  • Accel-Redirect срабатывает только при статусе ответа 2XX. Правда увы, про это нигде ничего не написано, а адепты nginx — расходятся во мнениях;
  • Приватные location закрываются либо allow 127.0.0.1; deny all; либо internal;, в зависимости от того попадаем ли бы на этот location через proxy_pass, либо через Accel-Redirect;
  • Все location связанные с капчей выделены в /x/capcha/... для того что бы можно было сформировать микросервис;

Для наглядности также нарисовал схему работы:


image

Резюме


В итоге от бекенда нам требуется только генерация непосредственно изображения и кода для него. Со всем остальным может спокойно справится и nginx. Конечно, это относительно простые логические операции, но тем не менее, это значительно ускорит работу и снизит нагрузку на бекенд. И по сути, мы не использовали никаких необычных механизмов, а только:


  • proxy_cache;
  • Accel-Redirect;
  • error_page;
  • secure_link;
  • cache_purge;

Остальное — правильное построение логических цепочек.


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