Перейти к содержанию

Аутентификация с cookies

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

Полная ветка lesson-21/after в архиве продолжения — самостоятельная HTTPS-песочница, не открытый CRUD ранних уроков. Она использует один учебный аккаунт editor-01, пароль из внешней переменной и стандартный PasswordHasher. Store Identity, регистрация, восстановление пароля и отзыв всех сессий не реализованы. При подготовке сертификат, сервер, вход и тесты не запускались. Ожидаемый результат ограничен устройством cookie, а не готовностью системы аккаунтов для интернета.

Билет выпускает сервер

В настройках регистрируется единственная схема CatalogCookie. Её имя используется одинаково при чтении, входе и выходе:

builder.Services.AddAuthentication("CatalogCookie")
    .AddCookie("CatalogCookie", options =>
    {
        options.Cookie.Name = "CatalogLab.Session";
        options.Cookie.HttpOnly = true;
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        options.Cookie.SameSite = SameSiteMode.Lax;
        options.ExpireTimeSpan = TimeSpan.FromMinutes(15);
        options.SlidingExpiration = false;
    });

HttpOnly запрещает обычному JavaScript читать cookie, но не защищает от всех последствий XSS: вредный скрипт на доверенном origin всё ещё может обращаться к API от лица браузера. Secure требует HTTPS. Профиль ветки слушает https://localhost:5081; для самостоятельного выполнения понадобится отдельно подготовленный доверенный сертификат разработчика. Переход на HTTP ради удобства противоречит настройкам примера.

SameSite=Lax ограничивает часть межсайтовых отправок, но не заменяет CSRF-защиту. Изменяющие запросы, включая вход и выход, уже требуют antiforgery token. В уроке 23 разберём его подробнее; здесь безопасность не откладывается до будущего шага. Cookies без атрибута Domain привязаны к host, а не к порту. Поэтому другое приложение на том же localhost нельзя считать полностью изолированным только из-за отличающегося порта.

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

Вход проверяет данные, а не имя роли

Клиент сначала получает пару antiforgery token через GET /api/csrf. Сервер устанавливает служебную cookie и возвращает request token в JSON с Cache-Control: no-store. Затем клиент отправляет login с заголовком X-CSRF-TOKEN. Этот endpoint проверяет token до выдачи сессии; отсутствие проверки сделало бы возможным навязанный вход в чужой аккаунт.

Входной JSON содержит имя и пароль:

{"userName":"editor","password":"<ваше внешнее учебное значение>"}

Сервер проверяет имя и результат VerifyHashedPassword. При неверном вводе он возвращает один общий 401, не уточняя, существует ли имя. После успешной проверки claims создаёт сервер:

var identity = new ClaimsIdentity(new[]
{
    new Claim(ClaimTypes.NameIdentifier, "editor-01"),
    new Claim(ClaimTypes.Name, "editor"),
    new Claim("permission", "course:edit")
}, "CatalogCookie");
await context.SignInAsync("CatalogCookie", new ClaimsPrincipal(identity));

ID и permission не принимаются из тела login. Если клиент добавит туда слово admin, это не изменит выданные утверждения. В защищённой cookie находятся данные билета; целостность и конфиденциальность обеспечивает механизм Data Protection. Не следует редактировать cookie как незашифрованный JSON или проектировать собственную подпись паролем аккаунта.

Конкретное значение cookie, ключи и время выпуска заранее неизвестны. Учебная статья не публикует чужие билеты и не обещает фиксированную строку. Для нескольких экземпляров реального приложения придётся согласовать защищённое хранение ключей и назначение приложения; случайно созданные разные key rings могут нарушить чтение сессии после переключения экземпляра.

Следующий запрос получает principal

В конвейере явно стоят UseAuthentication, затем UseAuthorization. GET /api/me требует аутентифицированного пользователя и возвращает только его стабильный ID. Без билета ожидается 401. После успешного входа и сохранения cookie — 200 с editor-01. Здесь нет вывода password hash и нет сериализации всего объекта аккаунта.

В .NET 10 известные API-endpoints с cookies обычно возвращают 401/403 вместо перенаправления на HTML-вход. Но определение API зависит от метаданных, а не только префикса /api. В песочнице события redirect дополнительно задают эти статусы явно для всех её endpoints. Это делает контракт определённым и для обработчиков, возвращающих общий IResult.

После изменения личности нужно получить новый CSRF request token. Пара связана с пользователем, поэтому токен, созданный до login, не следует использовать как бесконечный универсальный пропуск. Cookie аутентификации и antiforgery cookie выполняют разные обязанности: первая восстанавливает principal, вторая участвует в проверке изменяющего запроса.

Выход завершает текущую браузерную сессию

POST /api/logout требует входа и действительного token. В обработчике вызывается SignOutAsync для той же схемы. Ожидается 204 и инструкция браузеру удалить cookie. Следующий /api/me становится анонимным и получает 401. GET не выполняет выход, потому что безопасное чтение не должно изменять сессию.

Удаление cookie из одного браузера не означает отзыв всех ранее скопированных билетов. До истечения срока действующий билет может оставаться принимаемым, если система не проверяет серверное состояние отзыва. Наша маленькая реализация такого состояния не хранит. Для production нужны выбранная модель сессий, переоценка аккаунта и последствия блокировки.

Пятнадцать минут ограничивают срок действия билета, а SlidingExpiration=false не продлевает его автоматически от активности. Однако конкретные границы нужно проверять с учётом времени сервера. Нельзя считать, что закрытие вкладки уничтожает сессию или что окно другого браузера обязательно разделяет тот же cookie jar.

Последовательность для будущего самостоятельного просмотра: анонимный /api/me, token, неверный login, верный login, новый token, защищённое чтение, выход, повторное чтение. Сравнивайте статус и cookies, а не только наличие текста «успешно». Учебный общий rate limit процесса не заменяет аккаунтный lockout и распределённую защиту; отсутствие долговременного аккаунта остаётся ограничением лаборатории; её нельзя размещать как реальную форму входа. Механизм подтверждён источниками, а фактические HTTP-ответы ещё не подтверждены исполнением.

Настройка схемы описана в документации cookies ASP.NET Core 10, изменение API-статусов — в официальном описании поведения .NET 10.

Обратите внимание на повторный вход в уже открытом браузере. Старое состояние интерфейса не доказывает текущую личность: после изменения cookie нужно заново прочитать /api/me и получить token. Если интерфейс сохраняет прежний ID в localStorage, это только отображение, которому сервер не доверяет. Для будущего настоящего аккаунта отдельно задаются лимит попыток, блокировка, подтверждение и восстановление. Песочница не содержит этих процессов, поэтому общая ошибка login не должна создавать впечатление законченной системы защиты от перебора. Включённый локальный лимит запросов уменьшает частоту учебных попыток, но не заменяет распределённую защиту и не предназначен для оценки атаки. Стабильность principal проверяется по серверному ответу, а не по тексту кнопки входа.