Digest vs Basic
При использовании метода Digest, как уже было сказано, пароль
не передается, и его невозможно отснифить, однако есть и другая сторона
проблемы. Для того, чтобы проверить пароль, сервер должен вычислить
ответ и сравнить его с ответом клиента, следовательно, на сервере должен
храниться пароль или зависящие от него данные, необходимые для
прохождения аутентификации. Отсюда следует, что человек, получивший права
на чтение аккаунтов (например, с помощью SQL-injection), сможет получить
доступ к страницам, защищенным методом Digest. При использовании метода
Basic возможно хранение хешей вместо паролей, что не дает поднять права,
прочитав эти хеши (ниже мы увидим, что в Digest тоже могут храниться хеши,
но такие, что их знания достаточно для вычисления ответа). Таким образом, перед нами дилемма: либо наш пароль отснифят,
либо получат через web-уязвимость, которую кто-нибудь обязательно отыщет,
потому что кто ищет, тот всегда найдет. Есть метод аутентификации без
обоих этих недостатков - метод аутентификации на основе открытого ключа:
для проверки нужен открытый ключ, а для прохождения проверки - секретный,
однако в HTTP 1.1 такой метод не предусмотрен. RFC 2069
рекомендует использовать SSL, если защита так важна. Защищается только передача пароля, а контент не шифруется, так
что нет смысла защищать этим методом ресурсы, откуда пользователь
получает секретную информацию. Для них необходим SSL. А имеет смысл
защищать, например, форум или заливку контента на сайт. Итак, если хостинг не поддерживает SSL, а аутентификация должна
быть безопасной, то будем использовать Digest. В Apache предусмотрен модуль mod_digest. Для его использования
в конфиге (или в .htaccess) пишем:
AuthType Digest
AuthUserFile <файл>
AuthName <название защищаемой области>
Require valid_user
Файлы пользователей создаются утилитой
htdigest. Про mod_digest одно время появлялись сообщения, что он уязвим, так что,
возможно, там еще какие-нибудь проблемы обнаружатся. Кроме того, когда
я попытался его использовать у себя дома, получил ошибку
500 Server Internal Error. Кроме того, если добавление аккаунтов должно происходить
автоматически, и их должно быть много, они должны
храниться не в конфиге Апача, а в MySQL. Решение -
использовать PHP. В PHP нет встроенной поддержки этого
метода, поэтому его придется реализовать. Для этого необходимо изучить
этот метод подробно. Сразу замечу, что приведенная в этой статье
реализация работает только на Apache, так как полный доступ к заголовкам
запроса (функция apache_request_headers) работает только в Apache, а на
других серверах может отсутствовать. Нам же просто необходимо прочитать
заголовок Authorization.
Описание метода
Полностью описание метода можно прочитать в RFC 2069, а если
вкратце, то метод работает так. Когда сервер получает запрос, относящийся к защищенной области,
он выдает ошибку 401 Authorization Required и заголовок с запросом
аутентификации такого вида:
WWW-Authenticate: Digest realm="secure area", nonce="123456123456"
realm - это название защищенной области, а nonce - одноразовое
значение. Есть еще необязательные параметры, которые мы обсуждать
не будем. Клиент повторяет запрос, добавив к нему заголовок такого вида:
Authorization: Digest realm="secure area",
username="123", uri="/index.php", nonce="123456123456",
response="1234567890abcdef1234567890abcdef"
Параметр uri должен совпадать с URI в запросе, а response - это
ответ, который вычисляется так:
response = H(H(A1) + ":" + nonce + ":" + H(A2))
H - хеш-функция, по умолчанию MD5
A1 = логин + ":" + realm + ":" + пароль
A2 = метод запроса + ":" + URI
метод запроса - это GET, POST и тд.
Как видим, A1 не зависит ни от запроса, ни от одноразового
значения, поэтому на сервере может храниться не пароль, а
H(A1). Именно так это реализовано в mod_digest в Apache.
Однако этих же данных достаточно и клиенту. Злоумышленник, получив
этот хеш, может вычислить ответ по приведенным выше формулам и
сформировать HTTP-запрос, например, с помощью программы
AccessDriver
и ее инструмента HTTP
Debugger. Подробнее этот процесс будет показан ниже. Сервер должен
проверить, является ли одноразовое значение
тем, которое было ранее выдано клиенту и не устарело ли оно.
Если ответ соответствует параметру nonce, но значение этого параметра
не актуально, выдается описанный выше ответ с кодом 401 с той лишь
разницей, что в заголовок WWW-Authenticate добавляется параметр
stale=true, указывающий, что в доступе отказано лишь по этой причине,
и следует повторить попытку, не запрашивая у пользователя новый пароль.
Это, имхо, неудобно, поскольку если такая ситуация возникнет
при запросе POST или PUT с большим блоком данных, то клиенту придется
передать все данные дважды. Во избежание этого стандартом предусмотрен
заголовок Authentication-Info, в котором сервер может при ответе на
успешный запрос сообщить клиенту следующее одноразовое значение.
Синтаксис такой же, как у WWW-Authenticate, кроме того что nonce
заменяется на nextnonce. Однако, судя по результатам моих
экспериментов, Opera игнорирует этот заголовок. Другое решение: в
соответствии с
RFC 2068 (HTTP/1.1), сервер может ответить раньше, чем завершится
запрос,
чтобы клиент прервал ненужную передачу данных, но на Apache+PHP это
не реализуется, поскольку скрипт начинает выполняться только после
того,
как Apache полностью получит и пропарсит запрос.
Хранение данных между запросами
В реализации метода challenge/response на PHP есть тонкий момент.
Одноразовый параметр формируется и выдается клиенту в одном ответе, а
проверяется уже в другом сеансе работы скрипта.
То есть его необходимо сохранить от одного вызова скрипта до другого, и для этого придется
использовать файлы или БД. В моем примере используются файлы с именами,
соответствующими одноразовым значениям, а в самих файлах записаны
IP-адреса клиентов, которым они выданы. В примере не реализован сбор
мусора: надо периодически удалять старые файлы.
Разбор кода
Этот скрипт проверяет только пароль, и работает независимо от
логина. В зависимости от успешности проверки выдаются простые ответы.
<?php
$realm = 'secure area'; // Название защищаемой области
$pass = 'pass'; // Пароль
$fileprefix = './'; // Путь для файлов-меток, обозначающих валидность nonce
/* Сконструируем
одноразовый параметр так, как рекомендуется в RFC2069, хотя можно и
по-другому. Параметр, по рекомендации, должен зависеть от адреса
клиента, текущего времени и секретной строки. */
$nonce = md5($_SERVER['REMOTE_ADDR'] . ':' . time() . ':MyCooolPrivateKey');
// Получаем заголовки
$headers = apache_request_headers();
// Флаг, который мы установим в TRUE при успешной проверке
$auth_success = FALSE;
$stale = "";
// Если нет заголовка Authorization, то нечего и проверять
if (isset($headers['Authorization']))
{
$authorization = $headers['Authorization'];
/* Пропарсим заголовок с помощью
регулярного выражения. Заголовок содержит слово "Digest" и список
пареметров вида param="value" или param=value через запятую. Это
регулярное выражение соответствует одному такому параметру. */
preg_match_all('/(,|\s|^)(\w+)=("([^"]*)"|([\w\d]*))(,|$)/',
$authorization, $matches, PREG_SET_ORDER);
/* Теперь сформируем для удобства дальнейшей обработки массив, где ключи - названия параметров, а значения элементов массива -
значения параметров. */
$auth_params = Array();
for ($i = 0; $i < count($matches); $i++)
{
$match = $matches[$i];
/* Название всегда во второй группе скобок, в значениев зависимости от того, в кавычках оно или нет, может
быть в 4-й или 5-й группе. Для групп скобок, попавших
в нереализованную ветвь, в массиве пустая строка,
поэтому можно просто сложить значения. */
$auth_params[$match[2]] = $match[4] . $match[5];
}
/* Вычислим ответ, который соответствует
логину, введенному пользователем, нашему паролю и одноразовому параметру, переданному пользователем.
*/
$a1 = $auth_params['username'] . ':' . $auth_params['realm'] . ':' . $pass;
$a2 = $_SERVER['REQUEST_METHOD'] . ':' . $_SERVER['REQUEST_URI'];
$resp = md5(md5($a1) . ':' . $auth_params['nonce'] . ':' . md5($a2));
// Проверяем ответ.
if ($resp == $auth_params['response'])
{
// Проверяем актуальность одноразового параметра
$fn = $fileprefix . $auth_params['nonce'];
if (@file_get_contents($fn) == $_SERVER['REMOTE_ADDR'])
{
unlink($fn); // Больше этот параметр неактуален
$auth_success = TRUE; // Аутентификация пройдена
} else
{
// Одноразовый параметр неактуален
$stale = ", stale=true";
}
}
}
if ($auth_success)
{
print("<html><head><title>Digest auth test</title></head><body><pre>");
print("Successfully authenticated\n");
var_dump($auth_params);
print("</pre></body></html>");
} else
{
file_put_contents($fileprefix . $nonce, $_SERVER['REMOTE_ADDR']);
$proto = $_SERVER['SERVER_PROTOCOL'];
Header("$proto 401 Not Authorized");
Header("WWW-Authenticate: Digest realm=\"$realm\", nonce=\"$nonce\"$stale");
print("<html><head><title>Digest auth test</title></head><body><pre>");
print("You must authenticate with Digest method");
print("</pre></body></html>");
}
?>
Прохождение Digest Auth при известном H(A1)
Покажу на примере, как проходить проверку, если пароль неизвестен,
но известен H(A1). Для этого, как уже было сказано, понадобится
AccessDriver. Расчеты хешей я буду делать вызывая из командной строки
PHP CLI. Защищенная страница пусть находится по адресу
http://mrblack.local/auth1.php, а хеш H(A1) равен "a8fb5b2d780a7bf0782207a51a013f04".
Открываем AccessDriver->Tools->HTTP Debugger и вбиваем адрес
"http://mrblack.local/auth1.php". Жмем "Connect". Получаем:
HTTP Header[0] = HTTP/1.1 401 Authorization Required
HTTP Header[1] = Date: Mon, 04 Jul 2005 08:09:17 GMT
HTTP Header[2] = Server: Apache/1.3.31 (Win32) PHP/5.0.2
HTTP Header[3] = X-Powered-By: PHP/5.0.2
HTTP Header[4] = WWW-Authenticate: Digest realm="secure area", nonce="5925bea78552224abda11bfe318a8a03"
HTTP Header[5] = Connection: close
HTTP Header[6] = Content-Type: text/html
Открываем консоль, переходим в папку с PHP и вбиваем такую команду:
php -r "print md5('a8fb5b2d780a7bf0782207a51a013f04:
5925bea78552224abda11bfe318a8a03: '.md5('GET:http://mrblack.local/auth1.php'));"
Получаем искомый Digest-ответ: c6d0af0db239d75c
3f59640a4896d096
Теперь в AccessDriver ставим галочку "Header Data", копируем в появившееся
поле заголовки, которые были посланы в прошлом запросе, и дописываем к ним
Authorization. Вот что получается:
GET http://mrblack.local/auth1.php HTTP/1.1
Accept: image/gif, image/x-xbitmap, image/jpeg, image/pjpeg, application/x-shockwave-flash, */*
Accept-Language: en-us,en;q=0.5
User-Agent: Mozilla compatible
Host: mrblack.local
Pragma: no-cache
Authorization: Digest username="mrblack", realm="secure area", nonce="5925bea78552224ab
da11bfe318a8a03", uri="http://mrblack.local/auth1.php", response="c6d0af0db239d75c3f59
640a4896d096"
Жмем "Connect". Получаем результат:
HTTP Header[0] = HTTP/1.1 200 OK
HTTP Header[1] = Date: Mon, 04 Jul 2005 08:12:11 GMT
HTTP Header[2] = Server: Apache/1.3.31 (Win32) PHP/5.0.2
HTTP Header[3] = X-Powered-By: PHP/5.0.2
HTTP Header[4] = Connection: close
HTTP Header[5] = Content-Type: text/html
Авторизация пройдена, получен положительный ответ.
|