Перейти к основному содержанию

Безопасная аутентификация по «небезопасному» HTTP-протоколу

Опубликовано mishutka -

Практически любая современная WEB-ориентированная система (различные CMS, галереи, каталоги и т.п.) аутентифицирует пользователей через ввод логина и пароля. Эти логин и пароль вводятся в поля HTML-формы, которая затем отправляется через GET или POST запрос на сервер для проверки. Причём и логин и пароль в этом запросе передаются в открытом виде. Перехватив трафик между браузером и сервером, злоумышленник легко получает эти логин и пароль.

Один из вариантов преодоления этой проблемы – использование протокола HTTPS с шифрованием трафика между браузером и сервером. Но, во-первых, использование протокола HTTPS не всегда доступно. Например, он может не поддерживаться хостингом, или у Вас нет возможности приобрести подписанные SSL-сертификаты. Во-вторых, Ваш сайт может отдавать данные или документы большого размера (например, фотографии в случае с фотогалереей). В таком случае шифрование всего трафика может серьёзно нагрузить сервер.

Попробуем изобрести способ устойчивой к перехвату пароля аутентификации без использования протокола HTTPS.

Постановка задачи

На ум сразу приходит вариант зашифровать пароль на стороне браузера при помощи JavaScript перед отправкой формы на сервер. Но JavaScript-код на стороне браузера доступен для просмотра пользователям Вашего сайта. Таким образом, они смогут увидеть, какой способ шифрования используется. Если алгоритм шифрования будет двухсторонним (например, простейшее XOR-шифрование, или более сложное DES-шифрование), то злоумышленник сможет найти в JavaScript-коде используемый для шифрования ключ и расшифровать передаваемые пароли. Если шифрование будет односторонним (например, RSA-алгоритм), то возникает задача генерации уникальной пары ключей для каждого хостинга, чтобы эти ключи не повторялись, а также задача хранения «закрытого» ключа, который используется на сервере для расшифровки данных.

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

Таким образом, идеальным способом представляется такой способ шифрования, который, будучи абсолютно прозрачным (ничего скрывать не придётся), обеспечит сохранность передаваемых паролей. При этом зашифрованный пароль должен выглядеть по-разному при каждой попытке аутентификации, чтобы исключить возможность воспользоваться перехваченной информацией.

Общее описание концепции

В качестве такого способа был разработан и реализован алгоритм SHA1-хеширования паролей. Возможно, этот или похожий алгоритм уже был кем-либо описан. В таком случаем просим сообщить нам об этом с указанием источников.

В общем виде метод сводится к следующей схеме:

  • При генерации HTML-формы сервер генерирует случайную строку и добавляет её в форму в виде поля HIDDEN. Назовём её секретной строкой.
  • К паролю, введённому пользователем, при помощи JavaScript дописывается секретная строка и от полученной строки вычисляется SHA1-хеш (SHA1-hash) – ответный хеш. Этот ответный хеш передаётся серверу вместо введённого пароля.
  • Сервер сравнивает полученный ответный хеш с хешем, вычисленным с паролем пользователя, хранящимся в БД. Если оба хеша совпадают – пользователь ввёл верный пароль.

Описание реализации метода подразумевает знание основ PHP+MySQL разработки. Все примеры кода максимально упрощены и вряд ли применимы для полноценных реализаций. Цель этих примеров – продемонстрировать концепцию.

Структура таблиц MySQL

Для реализации нам понадобятся 2 таблицы: первая – для хранения логинов и паролей, вторая – для хранения выданных пользователям секретных строк.

Приблизительная структура таблицы для хранения логинов и паролей:

CREATE TABLE `users` (
  `ID` int(11) NOT NULL AUTO_INCREMENT, //первичный ключ
  `Login` varchar(50) DEFAULT NULL, //логин пользователя
  `Passwd` char(40) DEFAULT NULL, //SHA1-хеш пароля пользователя
  PRIMARY KEY (`ID`), 
  UNIQUE KEY `idx_Login` (`Login`) //Логины не должны повторяться 
);

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

Обратите внимание на то, что мы не храним пароли пользователей в чистом виде. Вместо этого хранится SHA1-хеш пароля. Это позволяет избежать раскрытия паролей пользователей в случае получения несанкционированного доступа к СУБД. SQL-запрос, добавляющий нового пользователя, при этом будет выглядеть приблизительно так:

INSERT INTO `users`(`Login`, `Passwd`) VALUES("testuser", SHA1("SuperStrongPassword"));

Вторая таблица – для хранения секретных строк, отправленных в HTML-форму:

CREATE TABLE `loginhash` ( 
  `IP` varchar(15) NOT NULL, //IP-адрес клиента, для которого была 
                             //сгенерирована секретная строка
  `Hash` varchar(40) NOT NULL, //в качестве секретных строк мы будем 
                               //использовать всё те же SHA1-хеши 
  `Deadline` datetime NOT NULL //время, после которого секретная строка 
                               //будет не действительна
) ENGINE=MEMORY;

Обратите внимание на то, что в качестве типа таблицы выбран тип MEMORY, т.к. хранить секретные строки мы будем временно и смысла сохранять их в БД постоянно – нет.

Генерация HTML-формы

Сначала нам надо сгенерировать секретную строку и записать её в БД:

//IP-адрес пользователя
$ip = $_SERVER['REMOTE_ADDR'];
//секретная строка: для обеспечения уникальности берём время,
//случайное число и IP-адрес пользователя
$SecurityHash = sha1(time() . rand() . $ip);
 
//удаляем хеши, с истёкшим временем действия
//(подразумеваем, что к БД мы уже подсоединились)
mysql_query('DELETE FROM `loginhash` WHERE `Deadline` < NOW()');
 
//небольшая задержка для предотвращения перебора паролей
sleep(1); //одна секунда
 
//запоминаем секретную строку в БД
//в качестве значения поля Deadline записываем текущее время
//с добавлением 10-ти минут, т.е. строка будет действительна в течение
//следующих 10-ти минут
mysql_query("INSERT INTO `loginhash`(IP, Hash, Deadline)
            VALUES('$ip', '$SecurityHash', ADDTIME(NOW(), '0:10:0'))");

После этого у нас есть всё, чтобы сгенерировать HTML-форму:

echo '
<SCRIPT SRC="sha1.js" TYPE="text/javascript"></SCRIPT>
<SCRIPT SRC="login_helpers.js" TYPE="text/javascript"></SCRIPT>
 
<FORM ACTION="check_auth.php" METHOD="post"
     ENCTYPE="multipart/form-data" NAME="login_form"
     ONSUBMIT="return OnPwdSubmit();">
 Login <INPUT TYPE="text" NAME="auth_login" SIZE="20"
              MAXLENGTH="50><BR>
 Password <INPUT TYPE="password" NAME="auth_passwd" SIZE="20"
                 MAXLENGTH ="200"><BR>
 <INPUT TYPE="hidden" NAME="SecurityHash"
        VALUE="' . $SecurityHash . '">
 <INPUT TYPE="submit" VALUE="OK">
</FORM>';

Первой строкой мы подключаем JavaScript-файл с реализацией вычисления хеша SHA1 от Chris Veness (http://www.movable-type.co.uk/scripts/s…). Файл содержит одну функцию – sha1Hash().  Во второй строке мы подключаем файл login_helpers.js (см. ниже) с реализацией функции OnPwdSubmit(), которая используется в качестве ONSUBMIT-обработчика формы. В HIDDEN-поле SecurityHash мы помещаем нашу секретную строку.

Обработка данных в браузере

Пользователь вводит логин и пароль и нажимает кнопку отправки данных на сервер. Перед отправкой данных формы вызывается обработчик ONSUBMIT, который обрабатывается нашей JavaScript-функцией OnPwdSubmit():

function OnPwdSubmit()
{
//получаем объекты для поля ввода пароля
//и скрытого поля с секретной строкой
  var p = document.getElementsByName('auth_passwd')[0];
  var sh = document.getElementsByName('SecurityHash')[0];
 
//вычисляем ответный хеш и помещаем его значение в поле ввода пароля
  p.value = sha1Hash(sha1Hash(p.value) + sh.value);
 
//разрешаем submit, возвращая значение true
  return true;
}

Т.к. пароли в БД у нас хранятся в виде SHA1-хешей, то мы должны сначала вычислить хеш введённого пользователем пароля: sha1Hash(p.value). Затем дописываем к нему секретную строку: sha1Hash(p.value) + sh.value, и, вычисляя общий SHA1-хеш, получаем ответный хеш: sha1Hash(sha1Hash(p.value) + sh.value).

Проверка на стороне сервера

Получив введённый логин и вычисленный на стороне браузера ответный хеш, мы должны проверить правильность ввода пароля. Для этого обрабатываем данные POST-запроса и сравниваем полученный ответный хеш с хешами, вычисленными на основании хранящегося в БД хеша пароля пользователя и переданной ему секретной строки.

Как видно из кода генерации HTML-формы, для обработки POST-запроса в нашем случае будет вызван файл check_auth.php.

Проверяем данные POST-запроса:

if(!isset($_POST['auth_login']))
  die('POST-запрос не содержит данных');

Для удобства записываем значения в переменные, проверяем обязательность ввода логина:

$auth_login = $_POST['auth_login'];
$auth_passwd = $_POST['auth_passwd'];
 
if(!strlen($auth_login))
  die('Необходимо ввести логин');

Удалим устаревшие хеши:

mysql_query('DELETE FROM `loginhash` WHERE `Deadline` < NOW()');

Формируем SQL-запрос проверки ответного хеша и выполняем его:

//получаем IP-адрес клиента
$ip = $_SERVER['REMOTE_ADDR'];
 
//SQL-запрос
$q = "SELECT u.`ID` AS ID, u.`Login`
     FROM `users` u, `loginhash` lh
     WHERE u.Login='$auth_login'
       AND lh.IP = '$ip'
       AND SHA1(CONCAT(u.Passwd, lh.Hash)) = '$auth_passwd'";
 
//выполняем запрос
$result = mysql_query($q);

Если это запрос вернёт хотя бы одну строку (а больше одной он вернуть не может в силу уникальности секретной строки), значит пользователь ввёл тот же пароль, что хранится в БД. Иначе – пароль или логин ошибочны.

if(mysql_num_rows($result))
{
  //получаем значения полей в массив $values
  $values = mysql_fetch_assoc($result);
 
  //выполняем действия, необходимые после успешно аутентификации
  //обычно это регистриция PHP-сессии
  session_start();
  $_SESSION['uid'] = $value['ID'];
  $_SESSION['username'] = $values['Login'];
 
  //Удаляем из БД "использованную" секретную строку
  mysql_query("DELETE FROM `loginhash`
              WHERE Hash='" . $values['Hash'] . "'");
}
else
{
  //Логин или пароль неверен
 
  sleep(5); //немного подпортим жизнь переборщикам паролей (5 секунд)
  die("Логин или пароль неверен. Вернитесь и попробуйте ещё раз.");
}

Заключение

Всё, пароль на правильность мы проверили. При этом в открытом виде он не передавался. А передаваемый в POST-запросе ответный хеш будет каждый раз новым (т.к. секретная строка каждый раз генерируется новая). Таким образом, даже перехватив весь трафик и расшифровав POST-запрос, злоумышленник не сможет получить с этого никакой выгоды.

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

Любые вопросы, замечания, уточнения, дополнения или пожелания Вы можете отправить на адрес info@mito-team.com.