Skip to content

Cookie Storage RU

Алексей . edited this page Jul 26, 2026 · 1 revision

Хранилище cookie

English Русский

LogsParser не владеет хранением cookie — этим занимается вызывающий код через контракт ICookieStorage. Эта страница описывает контракт и его точную семантику, встроенную реализацию MemoryCookieStorage, способ написать хранилище, переживающее перезапуск процесса, и то, как хранилище выбирается для запроса.

Зачем нужен контракт

Аутентификация реактивна: вызывать LoginAsync перед работой не нужно, такого метода нет. Транспорт реагирует на редиректы 302 от сервиса, поэтому самый первый запрос данных в процессе сам выполняет вход и подтверждение второго фактора, если отправленные им сессионные cookie отсутствуют или устарели. Всё, что идентифицирует эту сессию, живёт в cookie.

Хранилище, существующее только в памяти, означает полный вход при каждом старте процесса: новый POST с учётными данными, свежесгенерированный код TOTP и ещё один проход по страницам входа сайта до того, как вернутся данные. Хранилище, которое записывает своё содержимое в долговременное место и читает его обратно в конструкторе, отдаёт транспорту уже аутентифицированную сессию, и первый запрос сразу идёт за данными.

Библиотека никогда не выбирает это место за вас. Она читает cookie перед отправкой запроса и записывает их обратно после каждого ответа; где они находятся между этими моментами — целиком дело реализации.

Контракт ICookieStorage

Пространство имён: LogsParser.Abstractions.

public interface ICookieStorage
IReadOnlyCollection<ParserCookie> GetCookies();
void SetCookies(IReadOnlyCollection<ParserCookie> cookies);
Член Возвращает Описание
GetCookies() IReadOnlyCollection<ParserCookie> Полный текущий набор cookie. Вызывается перед каждым запросом для построения заголовка Cookie и в начале каждого обновления.
SetCookies(IReadOnlyCollection<ParserCookie> cookies) void Полностью заменяет набор cookie значением cookies. Вызывается после ответов с Set-Cookie и при сохранении токена анти-DDoS-защиты.

ParserCookie

Пространство имён: LogsParser (корневое).

public sealed record ParserCookie(string Name, string Value);
Свойство Тип Описание
Name string Имя cookie; во всей библиотеке сравнивается через StringComparer.Ordinal.
Value string Значение cookie; хранится и отправляется дословно — никакого кодирования или декодирования не выполняется.

Семантика, которую обязана соблюдать реализация

Правило Подробности
Только пары имя/значение Все атрибуты Set-Cookie — expires, max-age, path, domain, secure, httponly, samesite — отбрасываются до того, как cookie попадёт в хранилище. Сохраняется только первый сегмент name=value заголовка. От хранилища не ожидается моделирование срока жизни или области видимости, и библиотека никогда об этом не попросит.
SetCookies — полная замена Это не слияние. Переданная коллекция является полным новым состоянием; всё, что хранилось раньше и отсутствует в аргументе, исчезает. Слияние выполняет библиотека до вызова, а не хранилище.
Порядковое сравнение имён Имена cookie сравниваются через StringComparer.Ordinal. XSRF-TOKEN и xsrf-token — это две разные cookie.
GetCookies возвращает снимок Возвращайте копию, а не «живую» внутреннюю коллекцию. Вызывающий код не должен иметь возможности изменить состояние хранилища в обход него, а хранилище не должно видеть правки вызывающего кода.
Потокобезопасность Оба метода могут вызываться параллельно — из нескольких выполняющихся запросов, из процедуры аутентификации, из решателя challenge, — поэтому реализация обязана быть безопасной при вызове из нескольких потоков.
Пустые имена бессмысленны Библиотека никогда не создаёт cookie с пустым или пробельным именем; хранилище может отбрасывать такие записи, как это делает MemoryCookieStorage.
Одна запись на имя Заголовок Cookie собирается склейкой пар Name=Value через "; " в том порядке, в котором их вернул GetCookies. Дублирующиеся имена были бы отправлены дважды, поэтому убирайте дубликаты при записи.

MemoryCookieStorage

Реализация по умолчанию. Пространство имён: LogsParser.Abstractions (файл лежит в Infrastructure/Cookies/).

public sealed class MemoryCookieStorage : ICookieStorage
  • sealed, с неявным конструктором без параметров.
  • Оба метода берут приватную блокировку, поэтому тип безопасно разделять между параллельными запросами.
  • GetCookies возвращает новый массив при каждом вызове — снимок, а не внутренний список.
  • SetCookies отбрасывает записи, у которых Name равен null, пуст или состоит из пробелов, затем убирает дубликаты по Name через StringComparer.Ordinal, сохраняя первое вхождение каждого имени.
  • Состояние привязано к экземпляру и живёт только пока живёт процесс. Два экземпляра ничем не связаны; ничего никуда не записывается. Перезапуск процесса теряет сессию.

Исключения

Метод Исключение Когда
SetCookies ArgumentNullException cookies равен null.
using LogsParser;
using LogsParser.Abstractions;

var storage = new MemoryCookieStorage();

storage.SetCookies(
[
    new ParserCookie("arizonarp_session", "abc"),
    new ParserCookie("arizonarp_session", "ignored-duplicate"),
    new ParserCookie("", "dropped"),
    new ParserCookie("XSRF-TOKEN", "xyz")
]);

foreach (var cookie in storage.GetCookies())
{
    Console.WriteLine($"{cookie.Name}={cookie.Value}");
}

// arizonarp_session=abc
// XSRF-TOKEN=xyz

Что попадает в хранилище

Сессионные cookie сайта и токен анти-DDoS-защиты лежат в одном плоском хранилище — никакого разделения по назначению, домену или пути нет, второго хранилища в библиотеке тоже нет.

Cookie Кем записывается Значение
arizonarp_session сервисом, через Set-Cookie в любом ответе Аутентифицированная сессия. Её потеря означает полный вход при следующем запросе.
XSRF-TOKEN сервисом, через Set-Cookie в любом ответе CSRF-cookie, которую сайт выдаёт вместе с сессией.
R3ACTLB библиотекой, после решения React-challenge анти-DDoS-защиты Токен challenge, сохраняемый ровно под этим именем и переотправляемый в последующих запросах.

Поскольку они лежат вместе, сохранение хранилища сохраняет и сессию, и решённый токен challenge: один файл, одно восстановление, никакого отдельного учёта. Процессы, порождающие эти значения, описаны на странице Транспорт и аутентификация.

Хранятся только имена и значения. Отсюда два следствия, о которых стоит знать: библиотека сама никогда не удаляет cookie по сроку жизни, а удаление на стороне сервера (Set-Cookie: name=; expires=…) записывается как запись с пустым значением, а не как удаление.

Сохранение сессии на диск

Полная реализация. Она загружает содержимое в конструкторе, записывает его в SetCookies и защищает всё одной блокировкой уровня экземпляра.

using System.Text.Json;
using LogsParser;
using LogsParser.Abstractions;

public sealed class FileCookieStorage : ICookieStorage
{
    private readonly object _sync = new();
    private readonly string _path;
    private Dictionary<string, string> _cookies;

    public FileCookieStorage(string path)
    {
        _path = path ?? throw new ArgumentNullException(nameof(path));
        _cookies = Load(path);
    }

    public IReadOnlyCollection<ParserCookie> GetCookies()
    {
        lock (_sync)
        {
            return _cookies.Select(pair => new ParserCookie(pair.Key, pair.Value)).ToArray();
        }
    }

    public void SetCookies(IReadOnlyCollection<ParserCookie> cookies)
    {
        ArgumentNullException.ThrowIfNull(cookies);

        lock (_sync)
        {
            _cookies = cookies
                .Where(cookie => !string.IsNullOrWhiteSpace(cookie.Name))
                .GroupBy(cookie => cookie.Name, StringComparer.Ordinal)
                .ToDictionary(group => group.Key, group => group.First().Value, StringComparer.Ordinal);

            Save();
        }
    }

    private void Save()
    {
        var directory = Path.GetDirectoryName(_path);
        if (!string.IsNullOrEmpty(directory))
        {
            Directory.CreateDirectory(directory);
        }

        var temporary = _path + ".tmp";
        File.WriteAllText(temporary, JsonSerializer.Serialize(_cookies));
        File.Move(temporary, _path, overwrite: true);
    }

    private static Dictionary<string, string> Load(string path)
    {
        if (!File.Exists(path))
        {
            return new Dictionary<string, string>(StringComparer.Ordinal);
        }

        var restored = JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(path));

        return restored is null
            ? new Dictionary<string, string>(StringComparer.Ordinal)
            : new Dictionary<string, string>(restored, StringComparer.Ordinal);
    }
}

Подключение — единственное изменение на стороне вызова:

using LogsParser;
using LogsParser.Models;
using LogsParser.Net;

using var dataSource = new LogsParserHttpDataSource(
    credentials: new LogsParserCredentials("my_login", "my_password", "BASE32SECRET"),
    cookieStorage: new FileCookieStorage("state/logsparser-session.json"));

var client = new LogsParserClient(dataSource);

// Первый запуск: вход, подтверждение 2FA, запись сессии в state/logsparser-session.json.
// Последующие запуски: сессия восстанавливается в конструкторе, вход не выполняется вовсе.
var page = await client.GetLogsAsync(new LogsQuery(ServerId: 201));

Файл содержит действующий материал сессии. Любой, кто может прочитать arizonarp_session, способен действовать от имени вошедшей учётной записи, не зная ни пароля, ни секрета TOTP. Храните файл вне системы контроля версий, в приватном для пользователя месте, с ограничивающими правами доступа — и считайте его утечку компрометацией сессии.

Та же схема подходит для любого носителя: строки в базе данных, распределённого кэша, менеджера секретов. Меняются только тела Load/Save.

Выбор хранилища для запроса

Источник данных создаётся с хранилищем по умолчанию. Если оно не передано, он создаёт собственный MemoryCookieStorage:

public LogsParserHttpDataSource(
    LogsParserCredentials? credentials = null,
    ICookieStorage? cookieStorage = null,
    LogsParserHttpOptions? options = null,
    HttpClient? httpClient = null,
    ILoggerFactory? loggerFactory = null)

Каждый запрос несёт собственное необязательное хранилище, и оно побеждает для этого одного вызова:

public sealed record ParserRequest(string RelativeUri, ICookieStorage? CookieStorage = null);
Task<string> GetContentAsync(ParserRequest request, CancellationToken cancellationToken = default);

ParserRequest.CookieStorage переопределяет хранилище по умолчанию у источника данных; когда оно равно null, используется хранилище по умолчанию. Каждый метод LogsParserClient предоставляет то же переопределение в виде необязательного параметра и передаёт его в создаваемый ParserRequest:

public async Task<LogsPage> GetLogsAsync(
    LogsQuery query,
    ICookieStorage? cookieStorage = null,
    CancellationToken cancellationToken = default)

Благодаря этому одного источника данных достаточно для нескольких независимых сессий: учётные данные, HTTP-параметры и счётчики лимита запросов общие, а cookie — нет:

using LogsParser;
using LogsParser.Abstractions;
using LogsParser.Models;
using LogsParser.Net;

using var dataSource = new LogsParserHttpDataSource(
    credentials: new LogsParserCredentials("my_login", "my_password", "BASE32SECRET"),
    cookieStorage: new MemoryCookieStorage());

var client = new LogsParserClient(dataSource);

// Две отдельные серверные сессии, две отдельные «банки» cookie.
ICookieStorage persistent = new FileCookieStorage("state/logsparser-session.json");
ICookieStorage scratch = new MemoryCookieStorage();

var reused = await client.GetLogsAsync(new LogsQuery(ServerId: 201), persistent);
var isolated = await client.GetLogsAsync(new LogsQuery(ServerId: 201), scratch);

// Хранилище по умолчанию, переданное в конструктор, используется только при отсутствии переопределения.
var catalog = await client.GetLogsFilterCatalogAsync();

Оба вызова аутентифицируются единственным набором учётных данных, с которым создан источник данных, поэтому обе сессии принадлежат одной учётной записи — различается состояние сессии, которое никогда не перетекает из одного хранилища в другое. Переданное на запрос пустое хранилище вызовет собственный вход при первом использовании.

При использовании Microsoft.Extensions.DependencyInjection ICookieStorage регистрируется как singleton (по умолчанию MemoryCookieStorage) и может быть заменён через LogsParserRegistrationOptions.CookieStorageFactory — см. Внедрение зависимостей.

Параллелизм и потерянные обновления

Библиотека никогда не вызывает SetCookies напрямую. Каждое изменение проходит через внутренние read-modify-write-помощники (применение заголовков Set-Cookie из ответа и сохранение решённого токена R3ACTLB), которые удерживают блокировку, привязанную к самому экземпляру хранилища и хранящуюся в ConditionalWeakTable. Поэтому чтение и запись происходят в одной критической секции, и два параллельных запроса, использующих одно хранилище, не могут затереть cookie друг друга.

Эта блокировка внутренняя и недоступна из кода вызывающей стороны. Отсюда:

  • Собственные GetCookies/SetCookies хранилища всё равно обязаны быть потокобезопасными по отдельности — внутренняя блокировка защищает последовательности библиотеки, а не поля реализации.
  • Код, выполняющий собственный read-modify-write — GetCookies, правка снимка, SetCookies, — находится вне этой критической секции и может потерять обновление в гонке с выполняющимся запросом. Делайте такие правки под собственной блокировкой, отдельной для каждого экземпляра хранилища, и предпочтительно тогда, когда ни один запрос не выполняется.
  • «Слепые» полные замены (SetCookies с набором, собранным с нуля, например восстановление сохранённой сессии до первого запроса) безопасны сами по себе, поскольку ничего не читают.

См. также

Clone this wiki locally