nginx auth_request (2/3): Многофакторная авторизация, NFA

В этой статье рассмотрим, как в принципе делается кастомный портал с авторизацией на базе 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 {
  <...>
} 

Граф вызовов в случае успешной авторизации с первого раза (исходник).

Примерное содержимое pages/stage1, pages/stage2. Любителям подключать тонну 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) на базе этого же модуля.