Doc How Auth - PlutoGr/TravelAgency GitHub Wiki

Как работает вход и сессия

Токены никогда не попадают в JavaScript. Identity выпускает JWT, Gateway прячет его в httpOnly cookie, а на каждом запросе сам достаёт из cookie и передаёт сервисам как обычный заголовок Authorization: Bearer. Сервисы ничего не знают о cookie.

Простой пример

  1. Пользователь вводит email и пароль. Фронтенд отправляет POST /api/v1/auth/login с телом { email, password }.
  2. Gateway пропускает запрос в Identity (POST /identity/login).
  3. Identity проверяет пароль и отвечает { accessToken, refreshToken, expiresAt, refreshExpiresAt }.
  4. Gateway перехватывает ответ: ставит cookie access_token (Path=/, срок до expiresAt) и refresh_token (Path=/api/v1/auth, срок до refreshExpiresAt), обе HttpOnly, Secure, SameSite=Lax. Старая refresh_token на Path=/ удаляется. Вместо токенов клиент получает { "success": true }.
  5. Фронтенд запрашивает GET /api/v1/auth/me и получает профиль.
  6. Следующий запрос, например GET /api/v1/bookings/my, браузер отправляет с cookie сам. Gateway видит cookie, добавляет Authorization: Bearer <access_token>, проверяет JWT и роль, проксирует в Booking.

Участники

Где Класс Роль
Identity AuthController, LoginCommandHandler, RefreshTokenCommandHandler, JwtTokenService проверка пароля, выпуск и ротация токенов; AuthTokensDto несёт ExpiresAt (access) и RefreshExpiresAt (refresh)
Gateway AuthResponseTransformProvider токены из ответа Identity → cookie со своими сроками и путями; проверка всех 2xx ответов маршрута auth на токены; при logout cookie удаляются (Max-Age=0)
Gateway AuthRouteSettings префикс BasePath, пути RefreshPath и LogoutPath, список TokenResponsePaths (login, register, refresh)
Gateway CookieToAuthHeaderMiddleware cookie access_token → Authorization: Bearer, если заголовка ещё нет
Gateway AuthRequestMiddleware, AuthRequestTransformProvider POST /auth/refresh и /auth/logout: refreshToken из cookie попадает в тело запроса к Identity
Gateway CookieHeaderReader читает cookie из сырого заголовка Cookie и берёт первое значение с нужным именем
Shared JwtAuthenticationExtensions одинаковая проверка JWT в Gateway и сервисах
Фронтенд src/api/client.ts axios с withCredentials: true; на 401 один раз вызывает refresh и повторяет запрос
Фронтенд src/store/authStore.ts состояние входа; проверка через GET /auth/me
Фронтенд src/components/layout/AuthModal.tsx формы входа и регистрации, вывод ошибок

Cookie и их сроки

Cookie Path Срок (Expires) Откуда срок
access_token Cookie:Path (/) момент истечения JWT поле expiresAt ответа Identity, это exp access-токена (15 минут)
refresh_token AuthRoute:BasePath (/api/v1/auth) момент истечения refresh-токена поле refreshExpiresAt ответа Identity (7 дней)
  • Обе cookie HttpOnly. Secure и SameSite берутся из настроек Cookie (в продакшене Secure=true, SameSite=Lax); при SameSite=None флаг Secure ставится всегда.
  • refreshExpiresAt обязателен. Запасного срока нет: если поле отсутствует, пустое или уже в прошлом, Gateway отвечает 502 и cookie не ставит. То же для expiresAt.
  • Refresh-cookie лежит на префиксе /api/v1/auth, поэтому браузер отправляет её только на маршруты auth (в том числе refresh и logout) и не отправляет на запросы к каталогу, бронированиям и другим сервисам.

Переход со старой refresh-cookie

Раньше refresh_token ставилась на Path=/. Cookie с тем же именем и другим Path для браузера отдельная, новая её не заменяет. Поэтому на каждом ответе с токенами (login, register, refresh) и на logout Gateway дополнительно удаляет refresh_token на Path=/ (Max-Age=0).

Пока старая cookie не удалена, браузер присылает обе. Заголовок Cookie не содержит Path, но по RFC 6265 (раздел 5.4) cookie с более длинным путём идёт раньше. CookieHeaderReader берёт первое значение, то есть cookie с пути /api/v1/auth.

Чтение refresh-cookie в шлюзе

При записи значение cookie кодируется: Response.Cookies.Append вызывает Uri.EscapeDataString, и символы +, /, = становятся %2B, %2F, %3D. Например, токен ab+cd/ef== уходит в Set-Cookie как ab%2Bcd%2Fef%3D%3D.

На refresh и logout Gateway берёт refresh-токен не из Request.Cookies, а из сырого заголовка Cookie через CookieHeaderReader (его вызывают AuthRequestMiddleware и AuthRequestTransformProvider). Во время перехода у браузера могут быть две cookie refresh_token (на пути /api/v1/auth и старая на /). Request.Cookies хранит одно значение на имя, и более позднее затирает раннее, поэтому выиграла бы старая cookie. Браузер первой присылает cookie с более длинным путём, и CookieHeaderReader берёт первое значение.

Это значение раскодируется тем же Uri.UnescapeDataString, что и в Request.Cookies: кавычки вокруг значения снимаются, %2B снова становится +, а литеральный + не превращается в пробел. Раскодированный токен уходит в Identity в теле { refreshToken }.

Битая percent-последовательность (например %ZZ) запрос не ломает: значение остаётся как есть, вторая cookie вместо него не подставляется. Identity не находит такой токен и отвечает 401, ответа 500 нет.

Защита ответов Identity

Ответ с токенами на login, register и refresh

Ответ 200 или 201 на эти пути становится cookie только если в нём есть оба токена и оба срока. Иначе Gateway отвечает 502, тело Identity в JS не уходит, Set-Cookie нет. В лог пишется только причина, без токенов.

Ответ Identity Ответ клиенту
оба токена, expiresAt и refreshExpiresAt в будущем 200 или 201, cookie, { "success": true }
нет ни одного токена (в том числе поля в snake_case: access_token, refresh_token) 502 с пустым телом
только один токен, нет или истёк срок, тело не JSON 502 с телом { "code": "auth_upstream_invalid", "message": "Authentication is temporarily unavailable." }

Поля читаются без учёта регистра (AccessToken тоже подходит), но snake_case при разборе не распознаётся, поэтому такой ответ считается ответом без токенов.

Проверка содержимого

Главная защита не зависит от пути. Любой 2xx с маршрута /api/v1/auth/... с непустым телом Gateway читает целиком и ищет поле accessToken или refreshToken в любом регистре, со знаками _ (snake_case), на любой глубине JSON, включая массивы.

  • На login, register и refresh (ответ 200 или 201) такой ответ обрабатывается как ответ с токенами (см. выше).
  • На любом другом пути маршрута auth или с другим 2xx статусом ответ с полем токена заменяется на 502 с телом auth_upstream_invalid, без Set-Cookie.
  • Ответ без полей токена передаётся клиенту как есть.

Нормализация пути

Путь сравнивается с TokenResponsePaths и LogoutPath по HttpContext.Request.Path. ASP.NET кладёт туда уже раскодированный путь: запрос на /api/v1/auth/%6Cogin приходит с Path /api/v1/auth/login. Сырая строка запроса не читается. Перед сравнением Gateway убирает сегменты . и закрывающий слэш, регистр не учитывается. Поэтому /api/v1/auth/./login, /api/v1/auth/Login/ и /api/v1/auth/%6Cogin считаются входом.

Подстановка refreshToken из cookie в запрос (AuthRequestMiddleware, AuthRequestTransformProvider) срабатывает на точном пути RefreshPath или LogoutPath без учёта регистра, без удаления . и слэша.

Обновление токена

Access-токен живёт 15 минут.

  1. Запрос получает 401.
  2. Перехватчик axios вызывает POST /api/v1/auth/refresh с телом {}. Параллельные запросы ждут один и тот же вызов refresh, а не запускают свои.
  3. Gateway подставляет refresh_token из cookie в тело. Identity отзывает старый refresh-токен и выдаёт новую пару, Gateway обновляет обе cookie. Повторное использование отозванного refresh-токена даёт 401.
  4. Исходный запрос повторяется один раз. Если повтор снова получил 401, фронтенд переходит на /?auth=login (кроме GET /auth/me).
  5. Если сам refresh вернул 401 или 502, фронтенд без тоста отмечает посетителя гостем (markSessionExpired) и больше не повторяет.

При старте приложения checkAuth запрашивает GET /auth/me. Если access-cookie уже истекла, это 401, и перехватчик делает один refresh, затем повторяет auth/me. Так после возвращения на сайт сессия восстанавливается одним вызовом refresh. 401 или 502 на GET /auth/me не вызывает переход на вход, это обычный способ узнать, что пользователь не вошёл.

Ошибки входа, регистрации и профиля

Пример: регистрация с паролем abc показывает под полем пароля «Пароль должен быть не короче 8 символов и содержать хотя бы одну заглавную букву и одну цифру.»

Текст выбирается в authFormErrors.ts по статусу HTTP, а для 400 по полю code ответа (#240). Поля detail и errors ответа на экран не выводятся: неизвестный код даёт запасную фразу формы.

  • 401 на POST /auth/login и POST /auth/register не запускает refresh и не повторяет запрос: пароль не отправляется второй раз.
  • Вход, 401: под формой «Неверный email или пароль.», email остаётся, поле пароля очищается.
  • Вход, 429: под формой «Слишком много попыток входа. Подождите немного и попробуйте снова.», email остаётся, пароль очищается, кнопка «Войти» снова доступна.
  • Вход, 502: поле пароля очищается, тост «Не удалось войти. Попробуйте ещё раз через минуту».
  • Вход, другие ошибки: под формой «Не удалось выполнить запрос. Попробуйте ещё раз.»
  • Регистрация, 409 или 400 с code: "DuplicateEmail": под формой «Пользователь с таким email уже зарегистрирован. Войдите в аккаунт.»
  • Регистрация, 400 с кодом правила пароля Identity (PasswordTooShort, PasswordRequiresDigit, PasswordRequiresUpper и другие PasswordRequires…): одна фраза про пароль под полем пароля, введённый пароль остаётся в поле.
  • Регистрация, 502: под формой «Не удалось зарегистрироваться. Попробуйте ещё раз через минуту» (#215).
  • Регистрация, другие ошибки: под формой «Ошибка регистрации. Попробуйте ещё раз.» Сейчас Identity отдаёт для ошибок проверки code: "validation", поэтому такие ответы получают эту фразу.
  • Ошибки регистрации показываются под формой, а не тостом. Email, имя, фамилия и телефон остаются, поле пароля после ответа сервера с ошибкой очищается, кроме ошибки правила пароля.
  • Профиль, любая ошибка сохранения: под формой «Не удалось выполнить запрос. Попробуйте ещё раз.»

Выход

POST /api/v1/auth/logout с телом {}: Gateway подставляет refresh-токен из cookie, Identity отзывает его и отвечает 204. На 204 и на 401 Gateway удаляет access_token, refresh_token на /api/v1/auth и старую refresh_token на /.

Чат и cookie

SignalR открывает WebSocket к /api/v1/chat/hubs/chat с withCredentials: true, cookie уходит вместе с рукопожатием, и CookieToAuthHeaderMiddleware делает из неё заголовок. Для пути хаба middleware также принимает токен в параметре access_token строки запроса (так SignalR передаёт токен, когда cookie недоступна).

Почему так

  • httpOnly cookie недоступна JavaScript, поэтому XSS-скрипт не может украсть токен.
  • SameSite=Lax не отправляет cookie в фоновых запросах с чужих сайтов; CORS разрешает только origin сайта.
  • Refresh-cookie на пути auth не уходит с каждым запросом к API, а только туда, где нужна.
  • Ротация refresh-токена: украденный и уже использованный токен не сработает второй раз.
  • Сервисы проверяют JWT сами, даже если Gateway уже проверил: запрос в обход Gateway внутри сети без токена не пройдёт.

Учебный кейс: срок cookie и срок токена разные вещи

Раньше cookie ставились без Expires и жили до закрытия браузера, а потом обе получили один и тот же срок. Срок cookie решает, пришлёт ли браузер токен; срок токена решает, примет ли его сервер. Если refresh-cookie живёт как access-токен (15 минут), браузер выбрасывает refresh-токен, хотя Identity принял бы его ещё 7 дней, и пользователю приходится входить заново. Если cookie переживает свой токен, браузер шлёт мёртвый токен. Поэтому каждая cookie получает срок своего токена из ответа Identity, а без срока ответ отклоняется (fail-closed), а не получает срок по умолчанию.

Учебный кейс: список путей со строгим сравнением защищает только записанное написание

Перехват токенов был привязан к списку путей и строгому сравнению строк. Один и тот же адрес можно записать по-разному: /api/v1/auth/login/, /api/v1/auth/Login, /api/v1/auth/./login, /api/v1/auth/%6Cogin. Маршрут Gateway пропускает в Identity любое написание под префиксом /api/v1/auth, а строгий список узнавал только одно, и ответ с токенами на другом написании мог дойти до JS. Вывод: решать по нормализованному пути (как его видит ASP.NET после раскодирования, без ., слэша и регистра) и по содержимому ответа. Любой ответ с полем токена, который не удалось превратить в cookie, отклоняется (fail-closed), каким бы путём он ни пришёл (задача #163, PR #203).

Учебный кейс: кодирование cookie при записи и чтении должно совпадать

Gateway записывал refresh-cookie через Response.Cookies.Append, то есть в URL-кодировке, а читал сырой заголовок Cookie без раскодирования. Токен с +, / или = уходил в Identity как ab%2Bcd%2Fef%3D%3D, Identity его не узнавал, и refresh и logout отвечали 401 сразу после входа. Вывод: если cookie читается в обход Request.Cookies, раскодирование должно быть тем же, что при записи (Uri.EscapeDataString и Uri.UnescapeDataString). Проверка нужна полным кругом: токен со спецсимволами записывается в cookie, читается обратно и сравнивается с исходным (задача #219, PR #223).

См. также: Identity · Gateway