Doc How Auth - PlutoGr/TravelAgency GitHub Wiki
Как работает вход и сессия
Токены никогда не попадают в JavaScript. Identity выпускает JWT, Gateway прячет его в httpOnly cookie, а на каждом запросе сам достаёт из cookie и передаёт сервисам как обычный заголовок Authorization: Bearer. Сервисы ничего не знают о cookie.
Простой пример
- Пользователь вводит email и пароль. Фронтенд отправляет
POST /api/v1/auth/loginс телом{ email, password }. - Gateway пропускает запрос в Identity (
POST /identity/login). - Identity проверяет пароль и отвечает
{ accessToken, refreshToken, expiresAt, refreshExpiresAt }. - Gateway перехватывает ответ: ставит cookie
access_token(Path=/, срок доexpiresAt) иrefresh_token(Path=/api/v1/auth, срок доrefreshExpiresAt), обеHttpOnly,Secure,SameSite=Lax. Стараяrefresh_tokenнаPath=/удаляется. Вместо токенов клиент получает{ "success": true }. - Фронтенд запрашивает
GET /api/v1/auth/meи получает профиль. - Следующий запрос, например
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 минут.
- Запрос получает 401.
- Перехватчик axios вызывает
POST /api/v1/auth/refreshс телом{}. Параллельные запросы ждут один и тот же вызовrefresh, а не запускают свои. - Gateway подставляет
refresh_tokenиз cookie в тело. Identity отзывает старый refresh-токен и выдаёт новую пару, Gateway обновляет обе cookie. Повторное использование отозванного refresh-токена даёт 401. - Исходный запрос повторяется один раз. Если повтор снова получил 401, фронтенд переходит на
/?auth=login(кромеGET /auth/me). - Если сам
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).