You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
191 lines
13 KiB
191 lines
13 KiB
8 years ago
|
---
|
||
|
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) на базе этого же модуля.
|