Автор:
Anatoly Lubarsky (
anatolylubarsky@hotmail.com)
Источник:
www.aspnetmania.com
В статье "Система аутентификации на базе протокола HTTP Basic был рассмотрен алгоритм Basic аутентификации и с помощью него была построена система Basic аутентификации на основе ролей, работающая без специальной настройки IIS сервера и использующая базу данных для хранения учетных записей пользователей.
У Basic есть один недостаток, а именно username и password передаются по сети открыто (clear text), base64 кодировка не может считаться защитой.
Аутентификация Digest
В этой статье будет рассмотрен алгоритм Digest аутентификации, решающей некоторые проблемы, имеющиеся у HTTP Basic Authentication. Например эта схема не передаёт password по сети открытым текстом. Официальное название схемы - "Digest Access Authentication".
Расширим нашу систему из предыдущей статьи.
К преимуществам Digest можно отнести следуещее:
- passwords не передаются открыто по сети
- способность защиты от повторяющихся атак (monitoring http nc value)
- способность создать защиту (monitoring nonce)
- в определённый промежуток времени
- от определённого client
- от определённого request
Один сайт может одновременно использовать несколько систем защиты, например Basic и Digest
Пришло время рассмотреть работу алгоритма Digest аутентификации:
1. Первый запрос от User Agent к Http Server заголовок Authorization пустой - значит server должен возвратить запрос на аутентификацию. Например такой:
2. ответ сервера:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Digest
realm="testrealm@host.com",
qop="auth",
nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",
opaque="5ccc069c403ebaf9f0171e9517f40e41",
algorithm=MD5,
stale=false
Разберём заголовок WWW-Authenticate (как вы заметили, он усложнился по сравнению с заголовком Basic):
realm |
Строка, указывающая юзеру где он и какой пароль вводить например "registered_users@gotham.news.com". |
nonce |
Уникальная строка, которая генерируется на сервере в момент ответа 401. запрещено использовать кавычку, так как внутри заголовка строка в кавычках рекомендуется также закодировать её base64, например time-stamp H(time-stamp ":" ETag ":" private-key) |
opaque |
Строка, которую юзер должен будет вернуть на сервер в неизменённом виде Рекомендуется закодировать base64 |
stale |
true/false Индикатор, который показывает, что если true - запрос был правильный, username-password тоже, nonce неправильный false или любое другое значение или отсутствие stale - неправильные username, password |
algorithm |
optional, MD5 = default |
qop |
указывает "quality of protection". "auth" указывает authentication, "auth-int" указывает authentication + integrity protection. могут быть оба через запятую |
3. У юзера всплывает модальное окно, предлагающее ввести username и password (обратите внимание, окно отличается от Basic окна). Происходит запрос юзера для аутентификации:
GET ... ... HTTP/1.1
Authorization: Digest
username="Mufasa",
realm="testrealm@host.com",
nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",
uri="/dir/index.html",
qop=auth,
nc=00000001,
cnonce="0a4f113b",
response="6629fae49393a05397450978507c4ef1",
opaque="5ccc069c403ebaf9f0171e9517f40e41"
Теперь разберём заголовок Authorization:
username |
имя юзера |
realm |
см. WWW-Authenticate |
qop |
см. WWW-Authenticate (должно совпадать с одним из списка qop WWW-Authenticate) |
algorithm |
см. WWW-Authenticate (должно совпадать) |
opaque |
см. WWW-Authenticate (должно совпадать) |
uri |
запрос (например страница) |
response |
32-character строка - именно c её помощью проверяется пароль. |
nonce |
|
nc |
once count - сколько раз был использован текущий nonce |
cnonce |
уникальная строка, посылаемая браузером на сервер |
4. Последовательность обработки запроса пользователя
- Проверяем, существует ли заголовок Authorization
- Проверяем, является ли он Digest
- Отсекаем слово Digest
- Берём username (обратите внимание - в заголовке отсутствует password - по крайней мере его не видно)
- проверяем базу данных с этим юзером, существует ли он - запоминаем его password из базы
- проверяем запрос по ролям против страниц с ролями, как мы уже делали в Basic.
Если что-то не так - переходим к пункту 6. Если всё ok - идём дальше (это ещё далеко не всё :))
5. Проверка пароля пользователя
- создаём строку A1 вида
A1 = unq(username) : unq(realm) : passwd
- хешируем A1
HA1 = MD5(A1)
- создаём строку A2 вида
A2 = Http Method ":" digest-uri
- хешируем A2
HA2 = MD5(A2)
- создаём строку GENRESPONSE вида
GENRESPONSE = HA1 ":" nonce ":" nc ":" cnonce ":" qop ":" HA2
- хешируем GENRESPONSE
HGENRESPONSE = MD5(GENRESPONSE)
если HGENRESPONSE равен response в заголовке Authorization запроса юзера и nonce в порядке - всё ok, нет - переходим к пункту 6
6. выдаём состояние ответа сервера 401, поднимающее модальное окно как в пункте 2 иначе говоря формируем WWW-Authenticate по типу Digest
HTTP Модуль AuthDigest
Ну что ж, вооружившись рассмотренными выше теоретическими выкладками напишем наш HttpModule, который воплотит Digest аутентификацию.
// наследует HttpModule
public class AuthDigest : IHttpModule
{
public AuthDigest() { }
public void Dispose() { }
public void Init(HttpApplication application)
{
application.AuthenticateRequest += new EventHandler(this.OnAuthenticateRequest);
application.EndRequest += new EventHandler(this.OnEndRequest);
}
// Как обычно, нам нужно 2 метода:
// OnAuthenticateRequest
// OnEndRequest
/*
##########################################################################
# OnAuthenticateRequest
# <summary>
#
# </summary>
##########################################################################
*/
public void OnAuthenticateRequest(object source, EventArgs eventArgs)
{
HttpApplication app = (HttpApplication) source;
// достаём заголовок, проверяем его
// get Authorization header; check if not empty
string authorization = app.Request.Headers["Authorization"];
if ((authorization == null) || (authorization.Length == 0))
{
AccessDenied(app);
return;
}
// Digest не Digest?
// is it digest scheme?
authorization = authorization.Trim();
if (authorization.IndexOf("Digest", 0) != 0)
{
AccessDenied(app);
return;
}
// записываем части заголовка в dictionary
// get Header parts
ListDictionary dictAuthHeaderContents = new ListDictionary();
dictAuthHeaderContents = getHeaderParts(authorization);
// проверяем юзера против базы на основе ролей
// если всё нормально - запоминаем password
// в этом подходе - отличие от Basic
// получаем группы юзера - для экземпляра GenericPrincipal
//
// check against DATABASE (by roles)
string username = (string)dictAuthHeaderContents["username"];
string password = "";
string[] groups;
if (!AuthenticateAgentDigest(app, username, out password, out groups))
{
AccessDenied(app);
return;
}
// см. пункт 5 алгоритма Digest
//
// check against DIGEST SCHEME
string realm = ConfigurationSettings.AppSettings["HTTPDigest.Components.AuthDigest_Realm"];
// a)
// A1 = unq(username-value) ":" unq(realm-value) ":" passwd
string A1 = String.Format("{0}:{1}:{2}",
(string)dictAuthHeaderContents["username"],
realm,
password);
// b)
// HA1 = MD5(A1)
string HA1 = CvtHex(A1);
// c)
// A2 = HTTP Method ":" digest-uri-value
string A2 = String.Format("{0}:{1}",
app.Request.HttpMethod,
(string)dictAuthHeaderContents["uri"]);
// d)
// HA2 = MD5(A2)
string HA2 = CvtHex(A2);
// e)
// GENRESPONSE = HA1 ":" nonce ":" nc ":" cnonce ":" qop ":" HA2
string GENRESPONSE;
if (dictAuthHeaderContents["qop"] != null)
{
GENRESPONSE = String.Format("{0}:{1}:{2}:{3}:{4}:{5}",
HA1,
(string)dictAuthHeaderContents["nonce"],
(string)dictAuthHeaderContents["nc"],
(string)dictAuthHeaderContents["cnonce"],
(string)dictAuthHeaderContents["qop"],
HA2);
}
else
{
GENRESPONSE = String.Format("{0}:{1}:{2}",
HA1,
(string)dictAuthHeaderContents["nonce"],
HA2);
}
string HGENRESPONSE = CvtHex(GENRESPONSE);
// Проверяем nonce
bool isNonceStale = !IsValidNonce((string)dictAuthHeaderContents["nonce"]);
app.Context.Items["staleNonce"] = isNonceStale;
// Сверяем HGENRESPONSE с response заголовка
// Проверяем nonce
// если всё ok - создаём GenericPrincipal с группами юзера
if (((string)dictAuthHeaderContents["response"] == HGENRESPONSE) && (!isNonceStale))
{
app.Context.User = new GenericPrincipal(new GenericIdentity(username, "HTTPDigest.Components.AuthDigest"), groups);
}
else
{
AccessDenied(app);
return;
}
}
/*
##########################################################################
# OnEndRequest
# <summary>
# set server response WWW-Authenticate header (digest scheme)
# build header string according to scheme
# lift up modal window
# </summary>
##########################################################################
*/
public void OnEndRequest(object source, EventArgs eventArgs)
{
HttpApplication app = (HttpApplication) source;
if (app.Response.StatusCode == 401)
{
// from config.
string lRealm = ConfigurationSettings.AppSettings["HTTPDigest.Components.AuthDigest_Realm"];
string lOpaque= ConfigurationSettings.AppSettings["HTTPDigest.Components.AuthDigest_Opaque"];
string lAlgorithm = ConfigurationSettings.AppSettings["HTTPDigest.Components.AuthDigest_Algorithm"];
string lQop = ConfigurationSettings.AppSettings["HTTPDigest.Components.AuthDigest_Qop"];
// generate
string lNonce = GenerateNonce();
bool isNonceStale = false;
object staleObj = app.Context.Items["staleNonce"];
if (staleObj != null)
isNonceStale = (bool)staleObj;
// Поднимаем модальное окно типа Digest
// Для этого собираем заголовок WWW-Authenticate
// build authHeader string
StringBuilder authHeader = new StringBuilder("Digest");
authHeader.Append(" realm=\"");
authHeader.Append(lRealm);
authHeader.Append("\"");
authHeader.Append(", nonce=\"");
authHeader.Append(lNonce);
authHeader.Append("\"");
authHeader.Append(", opaque=\"");
authHeader.Append(lOpaque);
authHeader.Append("\"");
authHeader.Append(", stale=");
authHeader.Append(isNonceStale ? "true" : "false");
authHeader.Append(", algorithm=\"");
authHeader.Append(lAlgorithm);
authHeader.Append("\"");
authHeader.Append(", qop=\"");
authHeader.Append(lQop);
authHeader.Append("\"");
app.Response.AppendHeader("WWW-Authenticate", authHeader.ToString());
// Задаём код состояния 401
app.Response.StatusCode = 401;
}
}
/*
##########################################################################
# GenerateNonce
# <summary>
# generate unique server nonce
# </summary>
##########################################################################
*/
protected virtual string GenerateNonce()
{
// Создаём unique nonce - самый облегчённый вариант
// настоящее время + 3 минуты, закодированное base64
// проверка на качество nonce тоже будет происходить по времени
// Пример усиленного варианта - использование ETag и ещё одного ключа, который
// знает server
DateTime nonceTime = DateTime.Now + TimeSpan.FromMinutes(3);
string expireStr = nonceTime.ToString("G");
Encoding enc = new ASCIIEncoding();
byte[] expireBytes = enc.GetBytes(expireStr);
string nonce = Convert.ToBase64String(expireBytes);
// base64 приписывает знак равенства, запрещённый в заголовке
// отсекаем его
nonce = nonce.TrimEnd(new Char[] {'='});
return nonce;
}
/*
##########################################################################
# IsValidNonce
# <summary>
# string nonce : in
# </summary>
##########################################################################
*/
protected virtual bool IsValidNonce(string nonce)
{
// Проверяем nonce
// раскодируем из base64 и сверяем
// в этом примере nonce простой - поэтому и проверка простая -
// сверяем с временем
DateTime expireTime;
int numPadChars = nonce.Length % 4;
if (numPadChars > 0)
numPadChars = 4 - numPadChars;
string newNonce = nonce.PadRight(nonce.Length + numPadChars, '=');
try
{
byte[] decodedBytes = Convert.FromBase64String(newNonce);
string preNonce = new ASCIIEncoding().GetString(decodedBytes);
expireTime = DateTime.Parse(preNonce);
}
catch (FormatException)
{
return false;
}
return (expireTime >= DateTime.Now);
}
/*
##########################################################################
# CvtHex - hashes strings
# <summary>
# string sToConvert : in
# string SConverted : out
# </summary>
##########################################################################
*/
private string CvtHex(string sToConvert)
{
// Хэширование
Encoding enc = new ASCIIEncoding();
MD5 md5 = new MD5CryptoServiceProvider();
byte[] bToConvert = md5.ComputeHash(enc.GetBytes(sToConvert));
string sConverted = "";
for (int i = 0 ; i < 16 ; i++)
sConverted += String.Format("{0:x02}", bToConvert[i]);
return sConverted;
}
/*
##########################################################################
# getHeaderParts(string authorization)
# <summary>
# convert Authorization header
# from string to Dictionary
# string authorization : in
# ListDictionary : out
# </summary>
##########################################################################
*/
private ListDictionary getHeaderParts(string authorization)
{
// Функция, которая переводит заголовок HTTP со всем его содержимым
// в объект ListDictionary
ListDictionary dict = new ListDictionary();
string[] parts = authorization.Substring(7).Split(new char[] {','});
foreach (string part in parts)
{
string[] subParts = part.Split(new char[] {'='}, 2);
string key = subParts[0].Trim(new char[] {' ', '\"'});
string val = subParts[1].Trim(new char[] {' ', '\"'});
dict.Add(key, val);
}
return dict;
}
/*
##########################################################################
# AccessDenied
# 401 - Access Denied
# <summary>
# app in ; HttpApplication
# </summary>
##########################################################################
*/
private void AccessDenied(HttpApplication app)
{
// Вход воспрещён
// пишем в браузер
app.Response.StatusCode = 401;
app.Response.StatusDescription = "Access Denied";
app.Response.Write("401 Access Denied");
app.CompleteRequest();
}
// следующий метод реализует проверку против базы данных
// на основе ролей
// если всё нормально, возвращает true и список групп пользовател
// который нужен для создания экземпляра GenericPrincipal
// + его password - в этом отличие от Basic
/*
##########################################################################
# AuthenticateAgentDigest
#
# <summary>
# Authenticates Agent, returns true/false
# app in ; HttpApplication
# User in ; username
# Password out; password to hash then and check
# groups out; agent groups to create GenericPrincipal
# </summary>
##########################################################################
*/
protected virtual bool AuthenticateAgentDigest(HttpApplication app, string username, out string password, out string[] groups)
{
password = "";
groups = null;
int lagentID = 0;
string lpageURL = "";
// экземпляр класса, который осуществляет работу с базой
// код прилагаетс
SqlDataProvider dataProvider = new SqlDataProvider();
// проверим есть ли вообще такой юзер
// если есть - достанем его password
// get agent if exists
lagentID = dataProvider.getAgentByUsername(username, out password);
if (lagentID == 0)
return false;
// проверим есть ли вообще у него группы
// get agent groups
ArrayList arrAgentsGroups = new ArrayList();
arrAgentsGroups = dataProvider.getGroupsByAgentID(lagentID);
if (arrAgentsGroups.Count == 0)
return false;
// проверим есть ли группы у запрашиваемой страницы
// get pages groups
lpageURL = app.Request.Path;
ArrayList arrPagesGroups = new ArrayList();
arrPagesGroups = dataProvider.getGroupsByPageURL(lpageURL);
if (arrPagesGroups.Count == 0)
return false;
// проверим если хотя бы одна группа юзера
// находится в списке групп запрашиваемой страницы
// если да - возвращаем true
// check if at least one agent group is in Page Groups List
string[] pagegroups = (String[]) arrPagesGroups.ToArray(typeof(String));
groups = (string[]) arrAgentsGroups.ToArray(typeof(string));
foreach (String groupagentID in groups)
{
foreach (String grouppageID in pagegroups)
{
if (groupagentID == grouppageID)
return true;
}
}
return false;
}
}
---------------------------------------------------------
Всё. HttpModule готов. Подключим его к веб приложению: Для этого в файле web.config в разделе sytem.web пишем
<httpModules>
<add name="DigestAuthenticationModule" type="HttpDigest.Components.AuthDigest, HttpDigest" />
</httpModules>
отменяем встроенную аутентификацию
<authentication mode="None" />
<authorization>
<deny users="?" />
</authorization>
и сохраняем данные для Digest аутентификации (realm string, алгоритм, qop string, server opaque)
<appSettings>
<add key="HttpDigest.Components.AuthDigest_Realm" value="testrealm@host.com" />
<add key="HttpDigest.Components.AuthDigest_Opaque"value="5ccc069c403ebaf9f0171e9517f40e41" />
<add key="HttpDigest.Components.AuthDigest_Algorithm" value="MD5" />
<add key="HttpDigest.Components.AuthDigest_Qop" value="auth" />
</appSettings>
Вот и всё.
Алгоритм Digest аутентификации не претендует на решение всех задач, связанных с безопасностью в интернете. Эта схема не кодирует содержимое запроса-ответа. Цель Digest заключается в том, чтобы обеспечить систему аутентификации, такую же простую и удобную, как и Basic, но в которой бы отсутствовали недостатки, присущие Basic аутентификации. Тем не менее эта система гораздо сильнее, чем например CRAM-MD5, которая была предложена для LDAP, POP и IMAP(rfc 2195).
К коду прилагаются database scripts и упрощённый класс для работы с базой.
При написании этой статьи использовались rfc 2617 - HTTP Authentication: Basic and Digest Access Authentication и rfc 1321 - MD5 алгоритм.