Browse Source
* /articles/2017/05/17/nginx-authreq-2/ * /articles/2017/05/18/nginx-authreq-3/master
Alex 'AdUser' Z
8 years ago
13 changed files with 597 additions and 0 deletions
@ -0,0 +1,32 @@ |
|||||||
|
--- src/http/modules/ngx_http_auth_request_module.c 2017-03-03 12:55:31.236056000 +1000
|
||||||
|
+++ src/http/modules/ngx_http_auth_request_module.c 2017-03-03 13:09:15.908223000 +1000
|
||||||
|
@@ -161,6 +161,29 @@
|
||||||
|
return ctx->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ if (ctx->status == NGX_HTTP_MOVED_TEMPORARILY) {
|
||||||
|
+ sr = ctx->subrequest;
|
||||||
|
+
|
||||||
|
+ h = sr->headers_out.location;
|
||||||
|
+
|
||||||
|
+ if (!h && sr->upstream) {
|
||||||
|
+ h = sr->upstream->headers_in.location;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ if (h) {
|
||||||
|
+ ho = ngx_list_push(&r->headers_out.headers);
|
||||||
|
+ if (ho == NULL) {
|
||||||
|
+ return NGX_ERROR;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ *ho = *h;
|
||||||
|
+
|
||||||
|
+ r->headers_out.location = ho;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ return ctx->status;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
if (ctx->status >= NGX_HTTP_OK
|
||||||
|
&& ctx->status < NGX_HTTP_SPECIAL_RESPONSE)
|
||||||
|
{
|
@ -0,0 +1,91 @@ |
|||||||
|
--- |
||||||
|
title: nginx auth_request (1/3): Вводная |
||||||
|
tags: nginx, software, devel, репост |
||||||
|
--- |
||||||
|
У меня тут накопилось немного опыта работы с этим модулем, решил поделиться. |
||||||
|
|
||||||
|
Прежде всего - что это? Это модуль, который разрешает или запрещает прохождение запроса в nginx на основе **подзапроса**. |
||||||
|
Две основных схемы применения: |
||||||
|
|
||||||
|
* с его помощью можно соорудить WAF (web-application firewall) |
||||||
|
* ...и кастомный портал предварительной авторизации |
||||||
|
|
||||||
|
...всё перечисленное - без модификации исходного сайта. |
||||||
|
|
||||||
|
- [Вводная](/articles/2017/05/16/nginx-authreq-1/) |
||||||
|
- [Многофакторная авторизация, NFA](/articles/2017/05/17/nginx-authreq-2/) |
||||||
|
- [WAF, Web-Application firewall](/articles/2017/05/18/nginx-authreq-3/) |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
Выглядит это примерно так. Вот у нас есть типовой запрос, приходящий на некоторый вебсервер: |
||||||
|
|
||||||
|
GET /files/83084_s.jpg HTTP/1.1 |
||||||
|
Host: example.com |
||||||
|
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64; rv:52.0) Gecko/20100101 Firefox/52.0 |
||||||
|
Accept: */* |
||||||
|
Accept-Language: ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3 |
||||||
|
Accept-Encoding: gzip, deflate |
||||||
|
Connection: keep-alive |
||||||
|
|
||||||
|
nginx пересылает его сначала на указанный location, затем на основе ответа, |
||||||
|
решает что делать - пустить дальше или выдать ошибку. |
||||||
|
|
||||||
|
Общая схема запросов ([исходник](req-graph.msc)): |
||||||
|
|
||||||
|
![](req-graph.png) |
||||||
|
|
||||||
|
Authorizer - это отдельная internal локация nginx'а. |
||||||
|
Там может быть или `proxy_pass` на внешний сервер, или вызов скриптов с этого же сервера. |
||||||
|
|
||||||
|
Примерный конфиг nginx'а: |
||||||
|
|
||||||
|
location / { |
||||||
|
auth_request /auth; |
||||||
|
proxy_pass http://site.example.com; |
||||||
|
} |
||||||
|
location = /auth { |
||||||
|
internal; |
||||||
|
proxy_pass http://127.0.0.1/check.pl; |
||||||
|
proxy_pass_request_body off; # <- важно |
||||||
|
proxy_set_header Content-Length "0"; # <- важно |
||||||
|
proxy_set_header X-Original-URI $request_uri; |
||||||
|
} |
||||||
|
|
||||||
|
Обратите внимание на `Content-Length "0"`. |
||||||
|
Нужно это затем, чтобы в пересылаемом POST запросе получатель не ждал данных. |
||||||
|
|
||||||
|
Далее, допустим мы соорудили некий check.pl, который на основе пересланных запросов будет отвечать кодами 200/401/403/etc. |
||||||
|
С 200/OK - всё ясно, запрос проходит дальше. В случае остальных, например 403 - в дефолте nginx покажет простенькую, |
||||||
|
и совершенно неинформативную страничку "Access Denied". |
||||||
|
Чтобы этого не было, нам нужно добавить в блок `location / {}` перехват этих кодов: |
||||||
|
|
||||||
|
location / { |
||||||
|
<...> |
||||||
|
error_page 401 /auth.pl; |
||||||
|
error_page 403 /auth.pl; |
||||||
|
} |
||||||
|
|
||||||
|
... где `/auth.pl` -- страница авторизации или сообщения об ошибке. |
||||||
|
|
||||||
|
Здесь вырисовывается две проблемы: во-первых, нам нужно прописать локейшн и для `/auth.pl`, |
||||||
|
во-вторых -- 401/403 коды могут использоваться и в самом сайте. |
||||||
|
Первое бы хрен с ним, но второе -- реально проблема, если перехватывать **все** 403 с сайта, |
||||||
|
мы можем использовать для этой ошибки только одну *глобальную* страницу на сайт. |
||||||
|
|
||||||
|
Для обхода этого кейса, я написал [небольшой патч](authreq-302.patch), |
||||||
|
который позволяет также использовать код 302, временный редирект. |
||||||
|
Правда с включением в апстрим меня завернули, дескать это поломает |
||||||
|
использование этого модуля как **одного из** факторов авторизации: |
||||||
|
|
||||||
|
location / { |
||||||
|
proxy_pass http://site.example.com; |
||||||
|
auth_req /auth; # запрос должен быть авторизован через nginx authreq |
||||||
|
allow 192.168.0.0/16; # ...ИЛИ идти из локальной сети |
||||||
|
deny all; |
||||||
|
satisfy any; # <- "или" - берётся отсюда |
||||||
|
} |
||||||
|
|
||||||
|
В принципе, надо - берите. Если таки забодаете апстрим - вообще респект и уважуха :-). |
||||||
|
|
||||||
|
Далее покажу примеры реального использования для каждого случая. |
@ -0,0 +1,26 @@ |
|||||||
|
msc { |
||||||
|
hscale = 1, |
||||||
|
width = "500"; |
||||||
|
|
||||||
|
c [ label="Client" ], |
||||||
|
n [ label="Nginx" ], |
||||||
|
a [ label="Authorizer" ]; |
||||||
|
|
||||||
|
--- [ label="Good request" ]; |
||||||
|
c->n [ label="Original request" ]; |
||||||
|
n->a [ label="Mirrored request" ]; |
||||||
|
a->n [ label="Authorizer decision (200)" ]; |
||||||
|
n->c [ label="Client response (200)" ]; |
||||||
|
|
||||||
|
--- [ label="Bad request" ]; |
||||||
|
c->n [ label="Original request" ]; |
||||||
|
n->a [ label="Mirrored request" ]; |
||||||
|
a->n [ label="Authorizer decision (400)" ]; |
||||||
|
n->c [ label="Client response (403)" ]; |
||||||
|
|
||||||
|
--- [ label="Unsure request" ]; |
||||||
|
c->n [ label="Original request" ]; |
||||||
|
n->a [ label="Mirrored request" ]; |
||||||
|
a->n [ label="Authorizer decision (401)" ]; |
||||||
|
n->c [ label="Client response (401)" ]; |
||||||
|
} |
After Width: | Height: | Size: 21 KiB |
@ -0,0 +1,190 @@ |
|||||||
|
--- |
||||||
|
title: nginx auth_request (2/3): Многофакторная авторизация, NFA |
||||||
|
tags: nginx, software, devel, репост |
||||||
|
--- |
||||||
|
|
||||||
|
- [Вводная](/articles/2017/05/16/nginx-authreq-1/) |
||||||
|
- [Многофакторная авторизация, NFA](/articles/2017/05/17/nginx-authreq-2/) |
||||||
|
- [WAF, Web-Application firewall](/articles/2017/05/18/nginx-authreq-3/) |
||||||
|
|
||||||
|
В этой статье рассмотрим, как *в принципе* делается кастомный портал с авторизацией на базе `nginx_authreq`. |
||||||
|
|
||||||
|
Вся авторизация держится на предположении, что юзеру известен некий секрет, недоступный другим. |
||||||
|
Правда если его украсть или скопировать - система не может распознать обмана. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
N-факторная авторизация - это попытка убрать этот недостаток, путём использования нескольких "секретов" одновеременно. |
||||||
|
Украсть N разных секретов в N раз сложнее (если только юзер не ССЗБ, хранящий всё в одном месте). |
||||||
|
|
||||||
|
В быту, под "N-факторной авторизацией" чаще все понимают пару *логин/пароль*, *смс/email/токен* и возможно что-то ещё, например заход строго с определённоой сети. |
||||||
|
В качестве второго компонента, СМС встречается чаще, поскольку привязывает авторизацию к обладанию физической вещью (телефон с определённой симкой), скопировать которую проблематично (но не невозможно). |
||||||
|
|
||||||
|
Однако, давайте ближе к практике. Вот у нас есть некий сайт, с собственной системой авторизации, к которому нужно дополнительно ограничить доступ. |
||||||
|
Например, мы - онлайн банк, предоставляем доступ к операциям со счётом. |
||||||
|
|
||||||
|
Схема авторизации может выглядеть следующим образом: |
||||||
|
|
||||||
|
- запросить логин/пароль |
||||||
|
- при совпадении пары выше - выслать дополнительный код (действующий ограниченное время) по смс на предварительно указанный номер. |
||||||
|
- запросить высланный код |
||||||
|
- при совпадении кода - пустить на сайт, иначе - ошибка |
||||||
|
|
||||||
|
Эта схема может применяться только в том случае, если мы можем влиять на все этапы процесса авторизации. |
||||||
|
Если же такой возможности нет - "многофакторная" авторизация превращается в "многоступенчатую". |
||||||
|
Например, мы - хостер, предоставляем доступ к ipkvm. На саму железку мы влиять не можем, её прошивку писали не мы. |
||||||
|
В этом случае, схема авторизации будет такой: |
||||||
|
|
||||||
|
- запросить логин/пароль хостера |
||||||
|
- при совпадении пары выше - выслать дополнительный код (действующий ограниченное время) по смс на предварительно указанный номер. |
||||||
|
- запросить высланный код |
||||||
|
- при совпадении кода - пустить на вторую стадию авторизации (самого ipkvm'а) |
||||||
|
|
||||||
|
Сделать так, чтобы гипотетический ipkvm понимал данные с "первого этапа" не всегда представляется возможным, |
||||||
|
вследствие частой практики фундаментального огораживания своих девайсов среди производителей. |
||||||
|
|
||||||
|
Давайте ещё ближе к практике. Как это будет выглядеть на уровне конфигов и кода? |
||||||
|
Конфиг nginx из предыдущей статьи: |
||||||
|
|
||||||
|
location / { |
||||||
|
auth_request /check; |
||||||
|
proxy_pass http://site.example.com; |
||||||
|
} |
||||||
|
location = /check { |
||||||
|
internal; |
||||||
|
proxy_pass http://127.0.0.1:3000/check; |
||||||
|
proxy_pass_request_body off; # <- важно |
||||||
|
proxy_set_header Content-Length "0"; # <- важно |
||||||
|
proxy_set_header X-Original-URI $request_uri; |
||||||
|
} |
||||||
|
|
||||||
|
Обработчик `http://127.0.0.1:3000/check`: |
||||||
|
|
||||||
|
sub check { |
||||||
|
<...> |
||||||
|
|
||||||
|
if ($self->session('user')) { |
||||||
|
# авторизованный юзер |
||||||
|
$self->res->code(200); |
||||||
|
$self->render(text => 'OK'); |
||||||
|
} |
||||||
|
|
||||||
|
eval { |
||||||
|
# неизвестный юзер |
||||||
|
$self->res->code(403); # default deny |
||||||
|
my $user = $self->req->param('user'); |
||||||
|
my $pass = $self->req->param('pass'); |
||||||
|
my $code = $self->req->param('code'); |
||||||
|
if ($code and $user) { |
||||||
|
# формой отправлен предполагаемый логин и код из sms |
||||||
|
my $test = $self->app->redis->get("$user:code"); |
||||||
|
unless ($test) { |
||||||
|
# мы не посылали кода этому юзеру в последнее время, пнх |
||||||
|
$self->render('pages/stage1', msg => 'invalid session'); |
||||||
|
} elsif ($code eq $test) { |
||||||
|
# указан правильный код |
||||||
|
$self->app->redis->del("$user:code"); |
||||||
|
$self->session(user => $user); # см блок выше, до eval {} |
||||||
|
$self->render(text => 'OK'); |
||||||
|
} else { |
||||||
|
# указан неправильный код, откатываемся на 1ю стадию |
||||||
|
$self->app->redis->del("$user:code"); |
||||||
|
$self->render('pages/stage1', msg => 'wrong code'); |
||||||
|
} |
||||||
|
} elsif ($user and $pass) { |
||||||
|
# формой отправлен логин/пароль для проверки |
||||||
|
if (check_user_credentials($user, $pass)) { |
||||||
|
# юзер существует и пароль верен |
||||||
|
my $phone = get_user_phone($user); |
||||||
|
if ($phone) { |
||||||
|
# для указанного юзера задан телефон |
||||||
|
$code = generate_auth_code(); |
||||||
|
$self->app->redis->set("$user:code" => $code); |
||||||
|
$self->app->redis->expire("$user:code" => 60); # код будет верен в течении минуты |
||||||
|
send_sms($phone, "your auth code is: $code"); |
||||||
|
$self->render('pages/stage2', msg => 'code sent to your phone'); |
||||||
|
} else { |
||||||
|
$self->render('pages/stage1', msg => 'no configured phone number for this user'); |
||||||
|
} |
||||||
|
} else { |
||||||
|
$self->render('pages/stage1', msg => 'no such user / wrong password'); |
||||||
|
} |
||||||
|
} else { |
||||||
|
# данных нет, показываем стандартную форму логина |
||||||
|
$self->render('pages/stage1'); |
||||||
|
} |
||||||
|
} or do { |
||||||
|
$self->res->code(500); |
||||||
|
self->render('pages/error', msg => 'internal error'); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
В пример выше используется Mojolicious + redis, но с равным успехом может использоваться CGI, а в качестве хранилища - sqlite, bdb, dbm, memcached или просто обычные файлы. |
||||||
|
|
||||||
|
На самом деле, это - нерабочий пример. Почему? Потому что особенности™ работы `auth_req`. |
||||||
|
На самом деле nginx полностью игнорирует тело ответа от этого модуля и вместо него выдаёт свою стандартную страницу ошибки. |
||||||
|
Значения имеют только коды возврата, модуль обрабатытвает всего 3 случая: 200, 401 и *всё остальное. |
||||||
|
Мы даже не можем манипулировать состоянием через cookie, поскольку этот заголовок тоже не копируется в ответ. |
||||||
|
Только WWW-Authenticate и только при коде ответа 401. |
||||||
|
|
||||||
|
Следовательно, мы должны переписать пример выше так, чтобы использовались только коды статуса. |
||||||
|
Здесь нам поможет следующее шаманство: |
||||||
|
|
||||||
|
- выносим из `sub check {}` в новый обработчик всё, кроме первого блока. |
||||||
|
- в конце оставляем `$self->res->code(403); $self->render(text => 'auth');` |
||||||
|
- в nginx `location /auth` переопределяем коды 401,403 на путь к новому обрабобтчику |
||||||
|
- добавляем ещё один блок `location` уже для второго обработчика |
||||||
|
|
||||||
|
Второй блок нужен для того, чтобы `/auth`: а) не попала под действие `/check`, б) ему надо передавать данные через POST. |
||||||
|
|
||||||
|
Конфиг и код примут следующий вид: |
||||||
|
|
||||||
|
# Новый блок в nginx |
||||||
|
location = /auth { |
||||||
|
internal; |
||||||
|
proxy_pass http://127.0.0.1:3000/auth; |
||||||
|
# proxy_pass_request_body off; # <- важно |
||||||
|
# proxy_set_header Content-Length "0"; # <- важно |
||||||
|
proxy_set_header X-Original-URI $request_uri; |
||||||
|
} |
||||||
|
|
||||||
|
# то что осталось от первоначального обработчика |
||||||
|
sub check { |
||||||
|
<...> |
||||||
|
|
||||||
|
if ($self->session('user')) { |
||||||
|
# авторизованный юзер |
||||||
|
$self->res->code(200); |
||||||
|
$self->render(text => 'OK'); |
||||||
|
} |
||||||
|
$self->res->code(403); |
||||||
|
$self->render(text => 'auth'); |
||||||
|
} |
||||||
|
# всё остальное перехало сюда: |
||||||
|
sub auth { |
||||||
|
<...> |
||||||
|
} |
||||||
|
|
||||||
|
Граф вызовов в случае успешной авторизации с первого раза ([исходник](req-graph.msc)). |
||||||
|
|
||||||
|
![](req-graph.png) |
||||||
|
|
||||||
|
Примерное содержимое [pages/stage1](stage1.txt), [pages/stage2](stage2.txt). |
||||||
|
Любителям подключать тонну css и js на заметку: это всё придётся либо встраивать в страничку, либо мутить ТРЕТИЙ блок `location` в nginx, чтобы оно опять не попало под действие `/check`. |
||||||
|
Впрочем, можно сразу "провалить" всю машинерию на уровень ниже, например под `/auth`. Т.е. так: |
||||||
|
|
||||||
|
* /check -> /auth/check (описывается специальной локацией с точным соотвествием) |
||||||
|
* /auth -> /auth/login (описывается общей локацией для /auth, т.е. всё что не /auth/login -- пересылать туда-то через `proxy_pass`) |
||||||
|
|
||||||
|
Ну и на закуску - что даёт патч, приведённый в первой части, применительно к данной конфигурации. Удобство! |
||||||
|
Уходит необходимость в `error_page`, появляется возможность собрать логику в одном месте, и разруливать различные остояния не кодами статуса, а сразу редиректами. |
||||||
|
Плюс к тому же появляется возможность передать если не куку, то какой-то параметр через url (хак с `error_page` опять же такого не может). |
||||||
|
|
||||||
|
В данном примере использования, нас спасает то, что состояний по сути всего 2: известный пользователь (пропускаем) и неизвестный пользователь (перенаправляем на логин). |
||||||
|
3х доступных кодов ответа, из которых один (401й) использовате нежелательно из-за побочных эффектов (лезет браузерная форма с паролем) |
||||||
|
как раз хватает на 2 основных состояния (варианты "неизвестного пользователя" дополнительно разруливаются в обработчике /auth). |
||||||
|
|
||||||
|
Теперь представьте, что основных состояний хотя бы 5-6 (известный юзер, неизвестный с кукой, неизвестный без куки, подозрительный, заблокирован временно, заблокирован постоянно). |
||||||
|
Шаманство с `error_page` здесь уже не прокатит, тупо не хватит различаемых модулем кодов. |
||||||
|
|
||||||
|
В следующей части - построение кастомного WAF (Web Application Firewall) на базе этого же модуля. |
@ -0,0 +1,39 @@ |
|||||||
|
msc { |
||||||
|
hscale = 1, |
||||||
|
width = "700"; |
||||||
|
|
||||||
|
c [ label="client" ], |
||||||
|
n [ label="nginx" ], |
||||||
|
k [ label="/check" ], |
||||||
|
a [ label="/auth" ], |
||||||
|
d [ label="/" ]; |
||||||
|
|
||||||
|
--- [ label="unknown user, redirect to login page" ]; |
||||||
|
c -> n [ label="GET / HTTP/1.1" ]; |
||||||
|
n -> k [ label="GET / HTTP/1.1" ]; |
||||||
|
n <- k [ label="403 Forbidden" ]; |
||||||
|
n -> n [ label="error_page /auth" ]; |
||||||
|
n -> a [ label="GET /auth" ]; |
||||||
|
n <- a [ label="200 OK (pages/stage1)"]; |
||||||
|
c <- n [ label="200 OK (pages/stage1)"]; |
||||||
|
|
||||||
|
--- [ label="send auth data, stage 1 (user+pass)" ]; |
||||||
|
c -> n [ label="POST /auth (user+pass)" ]; |
||||||
|
n -> a [ label="POST /auth (user+pass)" ]; |
||||||
|
n <- a [ label="200 OK (pages/stage2)" ]; |
||||||
|
c <- n [ label="200 OK (pages/stage2)" ]; |
||||||
|
|
||||||
|
--- [ label="send auth data, stage 2 (user+code)" ]; |
||||||
|
c -> n [ label="POST /auth (user+code)" ]; |
||||||
|
n -> a [ label="POST /auth (user+code)" ]; |
||||||
|
n <- a [ label="302 / +Set-Cookie: hmac(base64({user=$user}))" ]; |
||||||
|
c <- n [ label="302 / +Set-Cookie: hmac(base64({user=$user}))" ]; |
||||||
|
|
||||||
|
--- [ label="authorized user" ]; |
||||||
|
c -> n [ label="GET / HTTP/1.1" ]; |
||||||
|
n -> k [ label="GET / HTTP/1.1" ]; |
||||||
|
n <- k [ label="200 OK" ]; |
||||||
|
n -> d [ label="GET / HTTP/1.1" ]; |
||||||
|
n <- d [ label="200 OK" ]; |
||||||
|
c <- n [ label="200 OK" ]; |
||||||
|
} |
After Width: | Height: | Size: 50 KiB |
@ -0,0 +1,14 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html> |
||||||
|
<!-- поскипано --> |
||||||
|
<body> |
||||||
|
<% if (my $msg = stash('msg')) { %> |
||||||
|
<h3><%= $msg %><h3> |
||||||
|
<% } %> |
||||||
|
<form method="POST" action="/auth"> |
||||||
|
<input name="user" type="text"> |
||||||
|
<input name="pass" type="password"> |
||||||
|
<input type="submit" value="Получить код"> |
||||||
|
</form> |
||||||
|
</body> |
||||||
|
</html> |
@ -0,0 +1,14 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html> |
||||||
|
<!-- поскипано --> |
||||||
|
<body> |
||||||
|
<% if (my $msg = stash('msg')) { %> |
||||||
|
<h3><%= $msg %><h3> |
||||||
|
<% } %> |
||||||
|
<form method="POST" action="/auth"> |
||||||
|
<input name="user" type="text"> |
||||||
|
<input name="code" type="text"> |
||||||
|
<input type="submit" value="Проверить код"> |
||||||
|
</form> |
||||||
|
</body> |
||||||
|
</html> |
@ -0,0 +1,161 @@ |
|||||||
|
--- |
||||||
|
title: nginx auth_request (3/3): Web-Application firewall, WAF |
||||||
|
tags: nginx, software, devel, репост |
||||||
|
--- |
||||||
|
|
||||||
|
- [Вводная](/articles/2017/05/16/nginx-authreq-1/) |
||||||
|
- [Многофакторная авторизация, NFA](/articles/2017/05/17/nginx-authreq-2/) |
||||||
|
- [WAF, Web-Application firewall](/articles/2017/05/18/nginx-authreq-3/) |
||||||
|
|
||||||
|
Сразу оговорюсь - я знаю про существование `nginx naxsi` и `mod_security`, речь про то, как вообще такое делается. |
||||||
|
|
||||||
|
Под WAF я понимаю некое дополнение к веб-серверу, выполняющее одну или несколько следющих задач: |
||||||
|
|
||||||
|
* блокировка вредоносных запросов |
||||||
|
* подтверждение юзером "сомнительных" запросов |
||||||
|
* ограничение частоты запросов по сложным критериям |
||||||
|
|
||||||
|
Дополнительно можно собирать статистику. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
Принцип работы всё тот же - nginx сначала пересылает копию запроса на какой-то внешний сервис |
||||||
|
и на основании ответа от этого сервиса - решает, пропускать ли исходный запрос или нет. |
||||||
|
|
||||||
|
Механизм работы опсан в предыдущей статье, поэтому интересны прежде всего алгоритмы: |
||||||
|
1) выделение запросов одного пользователя, |
||||||
|
2) ограничения частоты запросов. |
||||||
|
3) определения "вредоносности" или "подозрительности" запроса, |
||||||
|
Нетрудно заметить, третья задача зависит от первой, а вторая и третья тесно связаны. |
||||||
|
|
||||||
|
По порядку. |
||||||
|
|
||||||
|
Выделение запросов одного пользователя |
||||||
|
-------------------------------------- |
||||||
|
|
||||||
|
Первая и очевидная мысль, приходящая в голову - просто смотреть запросы с одного ip. |
||||||
|
Но тут есть множество тонкостей. Случаев, когда с одного адреса могут сидеть несколько |
||||||
|
пользователей - достаточно много: nat, vpn, tor, ipv6-to-4 брокер. |
||||||
|
И обратный случай - когда один юзер может сидеть с нескольких адресов: tor. |
||||||
|
|
||||||
|
Что делать? На заголовки полагаться нельзя, там можно подделать абсолютно всё. |
||||||
|
Комбинация ip+заголовок, например User-Agent? Это ещё хуже, подделкой заголовка можно добиться, |
||||||
|
чтобы система определяла тебя как разных юзеров, даже не меняя свой ip. |
||||||
|
|
||||||
|
Один из возможных вариантов - заставить каждого неопознанного клиента произвоить некую ресурсоёмкую операцию, |
||||||
|
после выполнения которой этому клиенту присваивается уникальный идентификатор, |
||||||
|
который тот будет предъявлять в дальнейшем как доказательство выполненной работы. |
||||||
|
Разумеется, его нужно защитить от подделки и ограничить срок годности. |
||||||
|
|
||||||
|
Блок схема ([исходник](schema-1.dot)): |
||||||
|
|
||||||
|
[![](schema-1_tn.png)](schema-1.png) |
||||||
|
|
||||||
|
Путь "известного" юзера показан жирной линией, "неизвестного" - прерывистой. |
||||||
|
Операция (3) может быть к/либо вычислением на стороне пользователя, с сообщением результата. |
||||||
|
Или же - проверкой на робота, например следование по редиректам. сделанных средствами самого html. |
||||||
|
|
||||||
|
Схема может изменяться в некоторых пределах, например (2) "N" может переходить не в (4), а в (3). |
||||||
|
Можно например, "неизвестных" клиентов сразу кидать на капчу, т.е. (1) "N" -> (4). |
||||||
|
|
||||||
|
Думаю не нужно напоминать, что все операции, кроме (3) следует оптимизировать на минимальные |
||||||
|
затраты ресурсов и максимальное быстродействие. Если используется капча, её лучше нагенерить заранее |
||||||
|
и продумать механизм "карантина" на какое-то время для показанных, но не решённых. |
||||||
|
|
||||||
|
Обратите внимание, путь по жирной линии выполняется в пределах одного обработчика. |
||||||
|
А вот переходы к другим состояниям - требуют перехода на другие странички. |
||||||
|
Помните, что я говорил про `error_page` и поддержку редиректов? |
||||||
|
|
||||||
|
Ограничение частоты запросов |
||||||
|
---------------------------- |
||||||
|
|
||||||
|
Здесь может быть куча вариантов реализации. |
||||||
|
|
||||||
|
Например самое простое и железобетонное решение: |
||||||
|
выделять временн*ы*е слоты определённой длительности |
||||||
|
и считать запросы юзера в пределах текущего слота. |
||||||
|
|
||||||
|
my $len = 5; # новый слот каждые 5 минут |
||||||
|
my $time = time(); |
||||||
|
# вычисляем имя слота |
||||||
|
my $slot = sprintf "req:%d:%d", $len, ($time - ($time % ($len * 60))); |
||||||
|
# увеличиваем число запросов для юзера с $uuid |
||||||
|
# и узнаём, сколько запросов он уже сделал в пределах данного слота |
||||||
|
my $reqs = $redis->hincr($slot, $uuid => 1); # O(1) |
||||||
|
$redis->expire($slot, 3600) if $reqs == 1; |
||||||
|
# если $reqs >= $limit - блокируем запрос |
||||||
|
|
||||||
|
Метод хорош тем, что крайне прост (1 запрос), хорошо масштабируется |
||||||
|
и может работать вообще без обслуживания. |
||||||
|
Основной недостаток - низкая "разрешающая способность", |
||||||
|
на границе временного слота можно превысить лимит *до* 2х раз. |
||||||
|
|
||||||
|
Если нам нужна гарантия, что в каждый момент времени лимит не будет превышен, подход несколько другой. |
||||||
|
На каждого юзера заводится по персональному списку, туда пишется время запросов. |
||||||
|
Недостатки: стоимость выше, больший расход памяти. |
||||||
|
|
||||||
|
Принцип действия такой: при частых запросах в начале очереди растёт число "недавних" запросов. |
||||||
|
Как только N-ый элемент оказывается "недавним", значит лимит превышен. |
||||||
|
|
||||||
|
Вариант 2/а: |
||||||
|
|
||||||
|
my ($time, $limit) = (5, 60); # время окна в минутах и количество запросов |
||||||
|
my $now = time(); |
||||||
|
my $key = sprintf "user:%s", $uuid; |
||||||
|
my $some = $redis->lindex($key, $limit - 1); # O(N) |
||||||
|
my $next = $some + ($time * 60); |
||||||
|
if ($now > $next) { |
||||||
|
$redis->lpush($key, $now); # O(1) |
||||||
|
# периодически подрезаем список, чтоб не разрастался сверх меры |
||||||
|
$redis->ltrim($key, 0, $limit - 1); # O(N), где N - количество удалённых элементов |
||||||
|
$redis->expire($key, 3600) if $some == 0; |
||||||
|
} else { |
||||||
|
# лимит превышен |
||||||
|
} |
||||||
|
|
||||||
|
Вариант 2/б, где "гарантированно дорогой" lindex() с O(N), |
||||||
|
заменяется на llen() + lindex(-1), т.е. 2 x O(1). |
||||||
|
Хотя в теории, lindex(N) для списка в котором меньше N элементов - тоже должен отрабатывать за O(1). |
||||||
|
|
||||||
|
my ($time, $limit) = (5, 60); # время окна в минутах и количество запросов |
||||||
|
my $now = time(); |
||||||
|
my $key = sprintf "user:%s", $uuid; |
||||||
|
my $len = $redis->llen($key); # O(1) |
||||||
|
if ($len < $limit) { |
||||||
|
# первичное заполнение |
||||||
|
$redis->lpush($key, $now); # O(1) |
||||||
|
$redis->expire($key, 3600) if $len == 0; |
||||||
|
return; |
||||||
|
} else { |
||||||
|
$redis->ltrim($key, 0, $limit - 1); # O(N), где N - количество удалённых элементов |
||||||
|
my $last = $redis->lindex($key, -1); # O(1) |
||||||
|
my $next = $last + ($time * 60); |
||||||
|
if ($now >= $next) { |
||||||
|
$redis->lpush($key, $now); |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
# лимит превышен |
||||||
|
|
||||||
|
Впринципе, всё это экономия на спичках. Значительно большего эффекта можно добиться, |
||||||
|
если считать не абстрактные "запросы", а прикинуть стоимость конкретного запроса |
||||||
|
в плане нагрузки и выделять пользователю "бюджет" на пользование. |
||||||
|
|
||||||
|
Например, показ странички с картинками, где половина содержимого - статика, |
||||||
|
а остальное закешировано - это одно. Полнотекстовый поиск по сайту - это уже другое. |
||||||
|
А попытка авторизации на сайте - совсем даже третье. |
||||||
|
|
||||||
|
"Вредоносность" и "подозрительность" запроса |
||||||
|
-------------------------------------------- |
||||||
|
|
||||||
|
Здесь сложно дать какие-то рекомендации, смотрите по ситуации. |
||||||
|
Можно анализировать заголовки (User-Agent/Referer/Accept/...), частоту запросов, их логическую взаимосвязь. |
||||||
|
Например, запрос к автодополнению с X-Requested-With - это с высокой вероятностью человек. |
||||||
|
Постоянная долбёжка поля поиска с интервалом в секунду - практически наверняка бот-дудосер. |
||||||
|
Монотонные запросы несвязанные друг с другом - поисковый бот. |
||||||
|
Пиковые всплески запросов с интервалом в несколько минут - юзер, который открывает несколько вкладок сразу, а потом сидит их читает. |
||||||
|
|
||||||
|
Вобщем, на практке быстро научитесь на что нужно смотреть. |
||||||
|
|
||||||
|
Ну и не стоит забывать про типовые запросы для поиска админок вордпресса, скуэля, гостевух и прочего говнокода на похапе. |
||||||
|
Клиенту, спалившемся на таком можно просто возвращать 404 на все запросы, чтоб больше не приходил. |
@ -0,0 +1,30 @@ |
|||||||
|
digraph { |
||||||
|
s [ shape="box", label="[0] Request" ]; |
||||||
|
p [ shape="box", label="[A] Pass" ]; |
||||||
|
b [ shape="box", label="[B] Block" ]; |
||||||
|
t [ shape="oval", label="[6] Set new token" ]; |
||||||
|
c [ shape="oval", label="[4] Show captcha" ]; |
||||||
|
w [ shape="oval", label="[3] Some heavy\noperation" ]; |
||||||
|
i [ shape="oval", label="[9] Accounting" ]; |
||||||
|
rl [ shape="diamond", label="[8] Is rate-limit\nexceeded?" ]; |
||||||
|
ht [ shape="diamond", label="[1] Has token?" ]; |
||||||
|
vt [ shape="diamond", label="[2] Is token valid?" ]; |
||||||
|
cv [ shape="diamond", label="[5] Is captcha valid?" ]; |
||||||
|
ts [ shape="diamond", label="[7] Is token set?" ]; |
||||||
|
|
||||||
|
s -> ht [ style="bold", weight=2 ]; |
||||||
|
ht -> vt [ label="Y", style="bold", weight=2 ]; |
||||||
|
ht -> w [ label="N", style="dashed" ]; |
||||||
|
w -> t [ style="dashed" ]; |
||||||
|
t -> ts [ style="solid" ]; |
||||||
|
ts -> b [ label="N" ]; |
||||||
|
ts -> p [ label="Y", style="solid" ]; |
||||||
|
vt -> rl [ label="Y", style="bold", weight=2 ]; |
||||||
|
rl -> i [ label="N", style="bold", weight=2 ]; |
||||||
|
i -> p [ style="bold", weight=2 ]; |
||||||
|
rl -> b [ label="Y", style="bold" ]; |
||||||
|
vt -> c [ label="N" ]; |
||||||
|
c -> cv; |
||||||
|
cv -> c [ label="N" ]; |
||||||
|
cv -> t [ label="Y" ]; |
||||||
|
} |
After Width: | Height: | Size: 70 KiB |
After Width: | Height: | Size: 53 KiB |
Loading…
Reference in new issue