Этот пост был перепостирован из моего блога.
ОБНОВЛЕНИЕ (10.03.2022): .NET 6 теперь должен успешно выполнять дайджест-аутентификацию, как любезно указал кто-то в примере репозитория. Это должно избавить вас от необходимости делать это вручную, как в этом сообщении в блоге!
Класс HttpClient должен справиться с этим, — говорите вы. Что ж, к сожалению, настройка сетевых учетных данных для экземпляра HttpClient мне не помогла. Множество людей, кажется, чтобы сообщали о том, что они тоже столкнулись с проблемами. Я не смог найти никакой информации о том, должно ли это быть исправлено в .NET 6, к тому же мне нужно было перенести его на .NET Framework 4.8 (💀💀💀). В любом случае, я расскажу, как я реализовал дайджест-аутентификацию в качестве метода расширения для класса HttpClient.
TL;DR: Вы можете прочитать код в моем репозитории GitHub здесь, в README приведен пример использования. Для одного запроса мы генерируем два запроса, используя мой метод, так как ничего (нонсы, счетчики одноразовых номеров) не кэшируется. Вероятно, вы можете кэшировать заголовок и правильно реализовать подсчет одноразовых номеров, но это выходит за рамки того, что мне нужно.
Это не будет реализовывать полную спецификацию RFC для дайджест-аутентификации. Вместо этого это должно быть довольно хорошей отправной точкой для людей, которые ищут что-то, что «просто работает».
Я буду использовать Httpbin для имитации рабочего процесса дайджест-аутентификации.
Предпосылки
- Знакомство с .NET-разработкой
- Знакомство с HTTP
- Нечеткое представление о том, что такое дайджест-аутентификация, и/или знание базовой аутентификации.
- Разочаровывайтесь, что .NET не делает этого за вас (❗ВАЖНО❗)
Дайджест-аутентификация — обзор
Википедия уже дает отличный обзор того, как работает дайджест-аутентификация. Если вам нужно более подробное объяснение, вам, вероятно, следует прочитать это. Но все, что вам действительно нужно знать, это:
- Отправляется запрос, сервер отвечает кодом 401, а ответ имеет такой заголовок:
WWW-Authenticate: Digest realm=»[email protected]», qop=»auth», nonce=»dcd98b7102dd2f0e8b11d0f600bfb0c093', opaque=»5ccc069c403ebaf9f0171e9517f40e41'
- Вы анализируете заголовок и получаете область, qop (качество защиты), одноразовый номер и непрозрачные вызовы.
- Используя комбинацию имени пользователя, пароля и одноразовых номеров, вы вычисляете новые хэши и создаете заголовок авторизации, например:
Авторизация: Digest username="Mufasa", realm="[email protected]", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093', uri="/dir/index.html", qop=auth, nc=00000001, cnonce="0a4f113b ", ответ = "6629fae49393a05397450978507c4ef1', непрозрачный =" 5ccc069c403ebaf9f0171e9517f40e41'
- Затем вы повторно отправляете запрос с заданным заголовком авторизации, который должен разрешить вам доступ к ресурсу.
Здесь следует отметить несколько вещей. Во-первых, в моем случае использования мне нужно было использовать только MD5 в качестве алгоритма хеширования. Дайджест-аутентификация также может использовать SHA-256, и это обычно указывается в вызове algorithm=SHA-256,
в заголовке WWW-Authenticate. Хотя веб-документы mdn предполагают, что это на самом деле не поддерживается и редко используется, поэтому для простоты я не учитывал свой вариант использования.
И последнее, проблема качества защиты (qop) может иметь больше значений, чем просто «auth». В некоторых примерах вы можете увидеть это как qop=auth-int
, где auth-int
— это аутентификация с защитой целостности. Опять же, мне не нужно было учитывать это для моего варианта использования.
Интерпретация ошибки 401
Ниже вы можете увидеть код для подключения экземпляра HttpClient, чтобы попытаться получить ресурс в httpbin:
var client = new HttpClient();
client.BaseAddress = new Uri("https://httpbin.org");
var response = await client.GetAsync("/digest-auth/auth/username/password");
Вы можете увидеть код ответа 401, если вы проверите его с помощью отладчика.
Копнув дальше, вы увидите, что один из заголовков ответа — WWW-Authenticate: Digest realm="[email protected]", nonce="dd355b0ef0f19b473c6a6609aa288adb", qop="auth", opaque="5a6aa7c0f368ef5c3ae070778fcd54f2", algorithm=MD5, stale=FALSE
. Это наш первый признак того, что этот ответ является ответом на запрос аутентификации, он даже дает нам тип аутентификации: Digest
.
Разбор заголовка WWW-Authenticate
Используя приведенный ниже код, мы можем проанализировать заголовок WWW-Authenticate, чтобы извлечь значения, необходимые для создания заголовка авторизации. Который будет добавлен к нашему второму аутентифицированному запросу.
var wwwAuthenticateHeaderValue = response.Headers.GetValues("WWW-Authenticate").FirstOrDefault();
var realm = GrabHeaderVar("realm", wwwAuthenticateHeaderValue);
var nonce = GrabHeaderVar("nonce", wwwAuthenticateHeaderValue);
var qop = GrabHeaderVar("qop", wwwAuthenticateHeaderValue);
var clientNonce = new Random().Next(123400, 9999999).ToString();
var opaque = GrabHeaderVar("opaque", wwwAuthenticateHeaderValue);
private static string GetChallengeValueFromHeader(string challengeName, string fullHeaderValue)
{
// if variableName = qop, the below regex would look like qop="([^""]*)"
// So it matches anything with the challenge name and then gets the challenge value
var regHeader = new Regex($@"{challengeName}=""([^""]*)""");
var matchHeader = regHeader.Match(fullHeaderValue);
if (matchHeader.Success) return matchHeader.Groups[1].Value;
throw new ApplicationException($"Header {challengeName} not found");
}
Затем я решил добавить проанализированные значения задач в класс, чтобы упростить управление. Вы также заметите, что я не делаю ничего необычного в поддержании состояния подсчета одноразовых номеров. Это связано с тем, что если сервер увидит такое же количество одноразовых номеров для ранее отправленного одноразового номера, ответ будет повторным. Это было нормально для моего случая использования, когда я просто обращался к статическому контенту, но об этом нужно помнить. Это может привести к тому, что серверы отклонят запрос, если они интерпретируют его как повторную атаку. Для меня это звучит как проблема на другой день 👉😎👉.
var digestHeader = new DigestAuthHeader(realm, username, password, nonce, qop, nonceCount: 1, clientNonce, opaque);
internal class DigestAuthHeader
{
public DigestAuthHeader(string realm, string username, string password, string nonce, string qualityOfProtection,
int nonceCount, string clientNonce, string opaque)
{
Realm = realm;
Username = username;
Password = password;
Nonce = nonce;
QualityOfProtection = qualityOfProtection;
NonceCount = nonceCount;
ClientNonce = clientNonce;
Opaque = opaque;
}
public string Realm { get; }
public string Username { get; }
public string Password { get; }
public string Nonce { get; }
public string QualityOfProtection { get; }
public int NonceCount { get; }
public string ClientNonce { get; }
public string Opaque { get; }
}
Генерация заголовка ответа на вызов
Вытащив значения запроса, мы можем сгенерировать значение заголовка ответа на вызов Authorization
, используя код ниже:
private static string GenerateMD5Hash(string input)
{
// x2 formatter is for hexadecimal in the ToString function
using MD5 hash = MD5.Create();
return string.Concat(hash.ComputeHash(Encoding.UTF8.GetBytes(input))
.Select( x => x.ToString("x2"))
);
}
private static string GetDigestHeader(DigestAuthHeader digest, string digestUri, HttpMethod method)
{
var ha1 = GenerateMD5Hash($"{digest.Username}:{digest.Realm}:{digest.Password}");
var ha2 = GenerateMD5Hash($"{method}:{digestUri}");
var digestResponse =
GenerateMD5Hash($"{ha1}:{digest.Nonce}:{digest.NonceCount:00000000}:{digest.ClientNonce}:{digest.QualityOfProtection}:{ha2}");
var headerString =
$"Digest username=\"{digest.Username}\", realm=\"{digest.Realm}\", nonce=\"{digest.Nonce}\", uri=\"{digestUri}\", " +
$"algorithm=MD5, qop={digest.QualityOfProtection}, nc={digest.NonceCount:00000000}, cnonce=\"{digest.ClientNonce}\", " +
$"response=\"{digestResponse}\", opaque=\"{digest.Opaque}\"";
return headerString;
}
Поскольку я очень ленив, я скопирую и вставлю объяснение из Википедии, которое ссылается на приведенный выше раздел кода:
Значение «отклика» рассчитывается в три этапа следующим образом. Если значения объединяются, они разделяются двоеточиями.
- Вычисляется хэш MD5 объединенного имени пользователя, области аутентификации и пароля. Результат обозначается как Ha1.
- Вычисляется хэш MD5 комбинированного URI метода и дайджеста, например. из «GET» и «/dir/index.html». Результат обозначается как Ha2.
- Вычисляется хэш MD5 комбинированного результата HA1, одноразового номера сервера (nonce), счетчика запросов (nc), одноразового номера клиента (cnonce), кода качества защиты (qop) и результата HA2. Результатом является значение «ответ», предоставленное клиентом.
Расширение класса HttpClient
Я решил обернуть всю эту аутентификацию и повторную отправку запросов в метод расширения, который вы можете увидеть ниже:
public static async Task<HttpResponseMessage> SendWithDigestAuthAsync(this HttpClient client,
HttpRequestMessage request, HttpCompletionOption httpCompletionOption,
string username, string password)
{
var newRequest = CloneBeforeContentSet(request);
var response = await client.SendAsync(request, httpCompletionOption);
if (response.StatusCode != System.Net.HttpStatusCode.Unauthorized) return response;
var wwwAuthenticateHeaderValue = response.Headers.GetValues("WWW-Authenticate").FirstOrDefault();
var realm = GetChallengeValueFromHeader("realm", wwwAuthenticateHeaderValue);
var nonce = GetChallengeValueFromHeader("nonce", wwwAuthenticateHeaderValue);
var qop = GetChallengeValueFromHeader("qop", wwwAuthenticateHeaderValue);
// Must be fresh on every request, so low chance of same client nonce here by just using a random number.
var clientNonce = new Random().Next(123400, 9999999).ToString();
var opaque = GetChallengeValueFromHeader("opaque", wwwAuthenticateHeaderValue);
var digestHeader = new DigestAuthHeader(realm, username, password, nonce, qop, nonceCount: 1, clientNonce, opaque);
var digestRequestHeader = GetDigestHeader(digestHeader, newRequest.RequestUri.ToString(), request.Method);
newRequest.Headers.Add("Authorization", digestRequestHeader);
var authRes = await client.SendAsync(newRequest, httpCompletionOption);
return authRes;
}
private static HttpRequestMessage CloneBeforeContentSet(this HttpRequestMessage req)
{
// Deep clone of a given request, outlined here:
// https://stackoverflow.com/questions/18000583/re-send-httprequestmessage-exception/18014515#18014515
HttpRequestMessage clone = new HttpRequestMessage(req.Method, req.RequestUri);
clone.Content = req.Content;
clone.Version = req.Version;
foreach (KeyValuePair<string, object> prop in req.Properties)
{
clone.Properties.Add(prop);
}
foreach (KeyValuePair<string, IEnumerable<string>> header in req.Headers)
{
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
return clone;
}
Последний звонок 🤞
После абстрагирования всех неприятных вещей, связанных с аутентификацией, окончательные вызовы функций должны выглядеть довольно просто, и вы на самом деле просто заменяете один вызов метода:
var client = new HttpClient();
client.BaseAddress = new Uri("https://httpbin.org");
var request = new HttpRequestMessage(HttpMethod.Get, "/digest-auth/auth/username/password");
request.Headers.Add("Accept", "*/*");
request.Headers.Add("User-Agent", "HttpClientDigestAuthTester");
request.Headers.Add("Accept-Encoding", "gzip, deflate, br");
request.Headers.Add("Connection", "keep-alive");
// This will no respond with a 200 status code
var response = await client.SendWithDigestAuthAsync(request, HttpCompletionOption.ResponseContentRead, "username", "password");
// this is what originally gave a 401 status code
//var response = await client.GetAsync("/digest-auth/auth/username/password");
Проверка объекта ответа должна показать, что теперь он отвечает с кодом состояния 200, поэтому мы успешно выполнили танец аутентификации с сервером!
Заключительные слова
Надеюсь, кто-нибудь сообщит мне, что есть более простой, более «.NET» способ сделать это. Где .NET делает за вас большую часть тяжелой работы. Если кто-то это сделает, обязательно свяжитесь с нами, и я с радостью перечеркну весь этот пост и укажу лучший способ сделать это.
Как я упоминал в предыдущем разделе, эта реализация далека от реализации полной спецификации RFC для дайджест-аутентификации, но она должна стать достойной отправной точкой.
Код для этого находится на моем GitHub, так что не стесняйтесь просматривать или вносить любые предложения.