Этот пост был перепостирован из моего блога.

ОБНОВЛЕНИЕ (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;
}

Поскольку я очень ленив, я скопирую и вставлю объяснение из Википедии, которое ссылается на приведенный выше раздел кода:

Значение «отклика» рассчитывается в три этапа следующим образом. Если значения объединяются, они разделяются двоеточиями.

  1. Вычисляется хэш MD5 объединенного имени пользователя, области аутентификации и пароля. Результат обозначается как Ha1.
  2. Вычисляется хэш MD5 комбинированного URI метода и дайджеста, например. из «GET» и «/dir/index.html». Результат обозначается как Ha2.
  3. Вычисляется хэш 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, так что не стесняйтесь просматривать или вносить любые предложения.