14

Мне всегда было интересно, как заставить HttpClient работать с куками подобно браузеру, затем сохранять их при выходе из приложения, и продолжить использовать после повторного запуска. И вот, наконец, приспичило, и я сделал.

Реализовал работу HttpClient следующим образом:

Код для инспекции

public static class HttpManager
{
    private static readonly HttpClientHandler handler = new HttpClientHandler()
    {
        AutomaticDecompression = DecompressionMethods.All
    };
private static readonly HttpClient client = new HttpClient(handler)
{
    DefaultRequestVersion = HttpVersion.Version20
};

static HttpManager()
{
    client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:80.0) Gecko/20100101 Firefox/80.0");
}

// GET
public static async Task<string> GetPageAsync(string url)
{
    using HttpResponseMessage response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
    return await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync();
}

// POST
public static async Task<string> PostFormAsync(string url, Dictionary<string, string> data)
{
    using HttpContent content = new FormUrlEncodedContent(data);
    using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, url) { Content = content };
    using HttpResponseMessage response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
    return await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync();
}

// Загрузить и расшифровать Cookie из файла
public static async Task LoadCookiesAsync(string filename)
{
    if (File.Exists(filename))
    {
        using FileStream fs = File.OpenRead(filename);
        byte[] IV = new byte[16];
        fs.Read(IV);
        byte[] protectedKey = new byte[178];
        fs.Read(protectedKey);
        using AesManaged aes = new AesManaged
        {
            Key = ProtectedData.Unprotect(protectedKey, IV, DataProtectionScope.CurrentUser),
            IV = IV
        };
        using CryptoStream cs = new CryptoStream(fs, aes.CreateDecryptor(), CryptoStreamMode.Read, true);
        CookieCollection cookies = await JsonSerializer.DeserializeAsync<CookieCollection>(cs);
        foreach (Cookie cookie in cookies)
        {
            // не загружать, если кука заэкспайрилась
            if (!cookie.Expired && (cookie.Expires == DateTime.MinValue || cookie.Expires > DateTime.Now))
                handler.CookieContainer.Add(cookie);
        }
    }
}

// Зашифровать и сохранить Cookie в файл
public static async Task SaveCookiesAsync(string filename)
{
    using AesManaged aes = new AesManaged();
    using FileStream fs = File.Create(filename);
    fs.Write(aes.IV);
    fs.Write(ProtectedData.Protect(aes.Key, aes.IV, DataProtectionScope.CurrentUser));
    using CryptoStream cs = new CryptoStream(fs, aes.CreateEncryptor(), CryptoStreamMode.Write, true);
    await JsonSerializer.SerializeAsync(cs, handler.CookieContainer.GetAllCookies());
}

}

public static class CookieContainerExtensions { // Забирает все куки из контейнера public static CookieCollection GetAllCookies(this CookieContainer container) { CookieCollection allCookies = new CookieCollection(); IDictionary domains = (IDictionary)container.GetType() .GetRuntimeFields() .FirstOrDefault(x => x.Name == "m_domainTable") .GetValue(container);

    foreach (object field in domains.Values)
    {
        IDictionary values = (IDictionary)field.GetType()
            .GetRuntimeFields()
            .FirstOrDefault(x => x.Name == "m_list")
            .GetValue(field);

        foreach (CookieCollection cookies in values.Values)
        {
            allCookies.Add(cookies);
        }
    }
    return allCookies;
}

}

Куки сериализуются в Json, шифруются с защитой ключа через DPAPI и сохраняются на диск.


Воспроизводимый пример, работающий с инспектируемым кодом

Авторизация на StackOverflow с логином и паролем

using HtmlAgilityPack;
using Fizzler.Systems.HtmlAgilityPack;
private const string filename = "cookies.bin";

static async Task Main(string[] args) {
await HttpManager.LoadCookiesAsync(filename);

string html = await HttpManager.GetPageAsync("https://ru.stackoverflow.com/");
HtmlDocument doc = new HtmlDocument();
doc.LoadHtml(html);
if (doc.DocumentNode.QuerySelector(".top-bar ol").HasClass("user-logged-out"))
{
    html = await HttpManager.GetPageAsync("https://ru.stackoverflow.com/users/login?ssrc=head&returnurl=https%3a%2f%2fru.stackoverflow.com%2f");
    doc = new HtmlDocument();
    doc.LoadHtml(html);
    string fkey = doc.DocumentNode.QuerySelector("form#login-form input[name=fkey]").Attributes["value"].Value;
    string ssrc = doc.DocumentNode.QuerySelector("form#login-form input[name=ssrc]").Attributes["value"].Value;
    Console.Write("Login: ");
    string login = Console.ReadLine();
    Console.Write("Password: ");
    string password = ReadPassword();
    Dictionary<string, string> formData = new Dictionary<string, string>
    {
        { "fkey", fkey },
        { "ssrc", ssrc },
        { "email", login },
        { "password", password },
        { "oauth_version", "" },
        { "oauth_server", "" }
    };
    html = await HttpManager.PostFormAsync("https://ru.stackoverflow.com/users/login?ssrc=head&returnurl=https%3a%2f%2fru.stackoverflow.com%2f", formData);
    doc = new HtmlDocument();
    doc.LoadHtml(html);
}

string user = doc.DocumentNode.QuerySelector(".top-bar ol .my-profile span.v-visible-sr").InnerText;
Console.WriteLine(user);
string rep = HtmlEntity.DeEntitize(doc.DocumentNode.QuerySelector(".top-bar ol .my-profile div.-rep").Attributes["title"].Value);
Console.WriteLine(rep);
string badges = string.Join(Environment.NewLine, doc.DocumentNode.QuerySelectorAll(".top-bar ol .my-profile div.-badges span.v-visible-sr").Select(x => x.InnerText));
Console.WriteLine(badges);

await HttpManager.SaveCookiesAsync(filename);
Console.ReadKey();

}

private static string ReadPassword() { string password = string.Empty; ConsoleKey key; do { ConsoleKeyInfo keyInfo = Console.ReadKey(true); key = keyInfo.Key;

    if (key == ConsoleKey.Backspace && password.Length > 0)
    {
        Console.Write("\b \b");
        password = password[0..^1];
    }
    else if (!char.IsControl(keyInfo.KeyChar))
    {
        Console.Write("*");
        password += keyInfo.KeyChar;
    }
} while (key != ConsoleKey.Enter);
Console.WriteLine();
return password;

}

Вывод в консоль при первом запуске с запросом учетных данных

Login: <my_email>@<censored>.ru
Password: ********************
aepot
ваша репутация: 4,904
2 золотых знака
5 серебряных знаков
25 бронзовых знаков

Вывод в консоль при повторном запуске приложения, автоматически используются куки для авторизации

aepot
ваша репутация: 4,904
2 золотых знака
5 серебряных знаков
25 бронзовых знаков

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

Кстати! Чтобы работало, нужно, чтобы в параметрах безопасности учетной записи StackExchange был активен способ входа по логину и паролю.

Посмотрите пожалуйста код класса HttpManager, есть ли что улучшить или сделать по-другому? Я раньше не работал с Cookie.


Обновление - с использованием BinaryFormatter

По совету от @ヒミコ попробовал реализацию с использованием BinaryFormatter. Тоже работает. Из плюсов - теперь не нужен дополнительный метод для извлечения кук, а CookieContainer сохраняется "как есть". Пока не понял, что лучше, но Microsoft ругает BinaryFormatter как небезопасный.

BinaryFormatter is insecure and can't be made secure. For more information, see the BinaryFormatter security guide.

public static class HttpManager
{
    private static readonly HttpClientHandler handler = new HttpClientHandler()
    {
        AutomaticDecompression = DecompressionMethods.All
    };
private static readonly HttpClient client = new HttpClient(handler)
{
    DefaultRequestVersion = HttpVersion.Version20
};

static HttpManager()
{
    client.DefaultRequestHeaders.UserAgent.ParseAdd(&quot;Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:80.0) Gecko/20100101 Firefox/80.0&quot;);
}

public static async Task&lt;string&gt; GetPageAsync(string url)
{
    using HttpResponseMessage response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
    return await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync();
}

public static async Task&lt;string&gt; PostFormAsync(string url, Dictionary&lt;string, string&gt; data)
{
    using HttpContent content = new FormUrlEncodedContent(data);
    using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, url) { Content = content };
    using HttpResponseMessage response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
    return await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync();
}

public static async Task LoadCookiesAsync(string filename)
{
    if (File.Exists(filename))
    {
        using FileStream fs = File.OpenRead(filename);
        byte[] IV = new byte[16];
        await fs.ReadAsync(IV);
        byte[] protectedKey = new byte[178];
        await fs.ReadAsync(protectedKey);
        using AesManaged aes = new AesManaged
        {
            Key = ProtectedData.Unprotect(protectedKey, IV, DataProtectionScope.CurrentUser),
            IV = IV
        };
        using CryptoStream cs = new CryptoStream(fs, aes.CreateDecryptor(), CryptoStreamMode.Read, true);
        handler.CookieContainer = new BinaryFormatter().Deserialize(cs) as CookieContainer ?? new CookieContainer();
    }
}

public static async Task SaveCookiesAsync(string filename)
{
    using AesManaged aes = new AesManaged();
    using FileStream fs = File.Create(filename);
    await fs.WriteAsync(aes.IV);
    await fs.WriteAsync(ProtectedData.Protect(aes.Key, aes.IV, DataProtectionScope.CurrentUser));
    using CryptoStream cs = new CryptoStream(fs, aes.CreateEncryptor(), CryptoStreamMode.Write, true);
    new BinaryFormatter().Serialize(cs, handler.CookieContainer);
}

}

aepot
  • 49,560
  • 1
    https://ru.stackoverflow.com/questions/958755/httpwebrequest-%d0%bd%d0%b5-%d0%bc%d0%be%d0%b3%d1%83-%d0%b0%d0%b2%d1%82%d0%be%d1%80%d0%b8%d0%b7%d0%be%d0%b2%d0%b0%d1%82%d1%8c%d1%81%d1%8f/958900?r=SearchResults#958900 давно для dle делал клиент. Может поможет :) –  Sep 15 '20 at 05:12
  • BinaryFormatter можно без проблем использовать, если данные остаются в пределах одной машины. То есть для приложения нет опасности использовать свои же данные. А вот получать с его помощью данные извне - опасно: там может оказаться что угодно. И это "что угодно" будет создано в процессе десериализации (конструкторы классов вызваны). – Alexander Petrov Sep 16 '20 at 17:32
  • я тоже решал подобную проблему и написал свой хелпер https://gist.github.com/mt89vein/9c9d7291d42dac0d1598f1a61da3117c. И сериализовал/десериализовал List в json. Чтобы все корректно работало, в HttpClient отключил cookie container, т.к. Handler может быть переиспользован в HttpClientFactory. – Vein Sep 17 '20 at 04:25
  • @SultanovShamil свой менеджмент кук хорошо, но зачем, если HttpClientHandler прекрасно справляется с этой задачей без постороннего вмешательства. Да и сам CookieContainer не так прост, как казалось бы. – aepot Sep 17 '20 at 06:12
  • 1
    Не надо BinaryFormatter: https://github.com/dotnet/designs/pull/141 – MSDN.WhiteKnight Sep 17 '20 at 06:33
  • @MSDN.WhiteKnight спасибо, я подозревал, что где-то оно должно быть. А что вместо него? Для .NET 5 подозреваю, что JsonSerializer справится "из коробки", там вроде сериализацию полей добавили. Или еще что-то есть (кроме XML)? – aepot Sep 17 '20 at 06:46
  • @aepot HttpClientHandler справляется, но IHttpClientFactory переиспользует их у себя под капотом и для разных пользователей мне нужен был свой набор кук, а создавать новый HttpClientHandler на каждый запрос - дорого (сокеты не бесконечны и закрываются не быстро), проще куки доставать и закидывать вручную) – Vein Sep 17 '20 at 10:14
  • @SultanovShamil странно, выглядит как использование одного браузера одновременно несколькими пользователями под одной учетной записью в системе. Весьма специфичный случай, не популярный. Ну ок, пусть будет так. – aepot Sep 17 '20 at 10:23
  • @aepot да, возможно специфичный. У меня кейс когда сервер эмулирует работу юзера в браузере, под аутентификацией на куках. Т.е. нужны изолированые сессии – Vein Sep 17 '20 at 12:46
  • @MikhailLazko Плюс не желание использовать готовое и протестированное решение из нугета Перечитал ваш комментарий, вопросов стало еще больше: о каком именно протестированном решении из нугета речь? CefSharp? Selenium? Зачем мне браузер? Почему встроенный в .NET HttpClient хуже? Либо я вообще не понял, о чем вы. – aepot Sep 18 '20 at 10:14
  • Просто интересно, кому удобно читать новые однострочные using - конструкции? Я до сих пор пишу с фигурными скобками, никак не привыкну – Andrei Khotko Sep 18 '20 at 17:21
  • @AndreiKhotko Мне удобно, избавляет от большой степени вложенности в то время, когда Dispose ожидается в одной и той же точке кода. Когда нужно задиспозить что-нибудь раньше, чем наступит конец кодового блока, я сам пишу по-старому, ну потому-что иначе никак. – aepot Sep 18 '20 at 17:33
  • @aepot прощу прощения, перечитал снова тред с коментариямя. похоже я был под кофеинов и потерял нить пока читал) я давно с этим не пересекался но выглядит странным что нет готового решения которое это бы решало из коробки в пару-тройку строк. – Mike Lazko Sep 18 '20 at 18:36
  • @MikhailLazko ничего, бывает. Но по сути HttpClient из коробки и решает, я добавил только чтение и запись кук и базовые настройки. – aepot Sep 18 '20 at 19:10

1 Answers1

6

В итоге решение получилось такое.

Ушел от статики, чтобы решение стало совместимым с IoC паттерном.

public class HttpManager : IDisposable
{
    private readonly HttpClientHandler handler;
    private readonly HttpClient client;
public HttpManager(string baseAddress = null)
{
    handler = new HttpClientHandler()
    {
        AutomaticDecompression = DecompressionMethods.All
    };
    client = new HttpClient(handler)
    {
        DefaultRequestVersion = HttpVersion.Version20
    };
    client.DefaultRequestHeaders.UserAgent.ParseAdd(&quot;Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/111.0&quot;);
    client.DefaultRequestHeaders.Accept.ParseAdd(&quot;text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8&quot;);
    client.DefaultRequestHeaders.AcceptLanguage.ParseAdd(&quot;en-US;q=0.7,en;q=0.3&quot;);

    if (baseAddress != null)
    {
        client.BaseAddress = new Uri(baseAddress);
    }
}

public async Task&lt;string&gt; GetPageAsync(string url)
{
    CheckDisposed();
    using HttpResponseMessage response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
    return await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync();
}

public async Task&lt;string&gt; PostFormAsync(string url, Dictionary&lt;string, string&gt; data)
{
    CheckDisposed();
    using HttpContent content = new FormUrlEncodedContent(data);
    using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, url) { Content = content };
    using HttpResponseMessage response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
    return await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync();
}

public async Task LoadCookiesAsync(string fileName)
{
    CheckDisposed();
    if (File.Exists(filename))
    {
        using FileStream fs = File.OpenRead(fileName);
        byte[] IV = new byte[16];
        await fs.ReadAsync(IV);
        byte[] protectedKey = new byte[178];
        await fs.ReadAsync(protectedKey);
        using AesManaged aes = new AesManaged
        {
            Key = ProtectedData.Unprotect(protectedKey, IV, DataProtectionScope.CurrentUser),
            IV = IV
        };
        using CryptoStream cs = new CryptoStream(fs, aes.CreateDecryptor(), CryptoStreamMode.Read, true);
        CookieCollection cookies = await JsonSerializer.DeserializeAsync&lt;CookieCollection&gt;(cs);
        CookieContainer container = new CookieContainer();
        container.Add(cookies);
        handler.CookieContainer = container;
    }
}

public async Task SaveCookiesAsync(string fileName)
{
    CheckDisposed();
    using AesManaged aes = new AesManaged();
    using FileStream fs = File.Create(fileName);
    await fs.WriteAsync(aes.IV);
    await fs.WriteAsync(ProtectedData.Protect(aes.Key, aes.IV, DataProtectionScope.CurrentUser));
    using CryptoStream cs = new CryptoStream(fs, aes.CreateEncryptor(), CryptoStreamMode.Write, true);
    await JsonSerializer.SerializeAsync(cs, handler.CookieContainer.GetAllCookies());
}

private bool disposed;

private void CheckDisposed()
{
    if (disposed)
        throw new ObjectDisposedException(nameof(HttpManager));
}

public void Dispose()
{
    Dispose(true);
    GC.SuppressFinalize(this);
}

protected virtual void Dispose(bool disposing)
{
    CheckDisposed();
    if (disposing)
        client.Dispose();
    disposed = true;
}

~HttpManager() =&gt; Dispose(false);

}

Уйти от рефлексии не удалось, вот метод для извлечения кук:

public static class CookieContainerExtensions
{
    public static CookieCollection GetAllCookies(this CookieContainer container)
    {
        CookieCollection allCookies = new CookieCollection();
        Hashtable domainTable = (Hashtable)container.GetType()
            .GetRuntimeFields()
            .First(x => x.Name == "m_domainTable")
            .GetValue(container);
    FieldInfo pathListField = null;
    foreach (object domain in domainTable.Values)
    {
        SortedList pathList = (SortedList)(pathListField ??= domain.GetType()
            .GetRuntimeFields()
            .First(x =&gt; x.Name == &quot;m_list&quot;))
            .GetValue(domain);

        foreach (CookieCollection cookies in pathList.GetValueList())
            allCookies.Add(cookies);
    }
    return allCookies;
}

}

Использование

string fileName = "cookies.bin";
using HttpManager manager = new HttpManager("https://<адрес сайта>/");
await manager.LoadCookiesAsync(fileName);
string html = await manager.GetPageAsync("/admin");
//...

await manager.SaveCookiesAsync(filename); Console.WriteLine("Done.");


Новинка в .NET 6

В .NET 6 добавлен метод CookieContainer - GetAllCookies().

aepot
  • 49,560
  • подходят ли для Web Assembly данные куки ? как можно разобраться, чтоб интегрировать ? Благодарю – Dev18 Mar 28 '23 at 06:49
  • 1
    @Dev18 вообще это клиентский код, а Wasm это само по себе в браузере. Зачем эмуляция поведения браузера, когда вы и так в настоящем браузере находитесь? Напишите нужных JS функций и интегрируйтесь с ними. – aepot Mar 28 '23 at 06:53