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

Модуль franchiseoffice

Статус

Создан Spring Modulith-модуль Личного кабинета ФЧ. Реализованы отдельная security chain, полный минимальный auth-контур, список юридических лиц своего ФЧ и управление их iiko-подключениями.

Назначение и границы

Модуль franchiseoffice является HTTP/application-границей для сотрудников франчайзи. Он принимает запросы /api/franchise/**, получает область доступа только из типизированного FranchisePrincipal и не владеет доменными таблицами.

Разрешённые зависимости:

franchiseoffice ──▶ iam::api
franchiseoffice ──▶ franchise::api
franchiseoffice ──▶ iiko::api

Прямой доступ к внутренним классам и таблицам доменных модулей запрещён. Зависимость на restaurant::api будет добавлена вместе с первым ресторанным сценарием. Модуль расположен в Gradle-модуле web; доменные модули не зависят от MVC.

Frontend-клиент

Frontend Личного кабинета ФЧ развивается независимо в репозитории git@gitlab.tapir.ws:panam/backend/v2/franchise-lk.git и публикуется на https://franchise.panampizza.ru. Это React SPA обращается к /api/franchise/v1/** через тот же origin. Модуль franchiseoffice остаётся серверной HTTP/application-границей и не содержит frontend-код.

Стек, сессия, генерация API-клиента и правила отображения интерфейса по permissions описаны в архитектуре административных frontend-приложений.

OpenAPI этого HTTP-контура публикуется отдельно на /v3/api-docs/franchise и содержит только маршруты /api/franchise/**. Локальный снимок для генерации frontend-клиента создаётся командой task openapi:export-franchise в web/build/openapi/franchise.json.

Реализованный endpoint

POST /api/franchise/v1/auth/login
Content-Type: application/json

{
  "email": "employee@example.com",
  "password": "permanent password"
}

Сценарий:

  1. FranchiseAuthentication проверяет credentials, активное членство и отсутствие operationalBlock.
  2. FranchiseSessions создаёт серверную refresh-сессию аудитории FRANCHISE_OFFICE.
  3. FranchiseAccessTokens выпускает JWT аудитории franchise-office.
  4. Открытый refresh token помещается только в cookie.
  5. Access token, userId, franchiseeId, отображаемое имя и permissions возвращаются в JSON.

Ответ:

{
  "accessToken": "...",
  "tokenType": "Bearer",
  "accessExpiresAt": "2026-01-01T00:15:00Z",
  "userId": "...",
  "franchiseeId": "...",
  "displayName": "Администратор ФЧ",
  "permissions": ["RESTAURANT_MANAGE"],
  "passwordChangeRequired": false
}

Refresh token отсутствует в JSON. Если выпуск access token завершается ошибкой после создания сессии, новая refresh-сессия отзывается. Неверные credentials и недоступное членство возвращают одинаковый 401 без cookie.

Refresh

POST /api/franchise/v1/auth/refresh
Cookie: panam_franchise_refresh=...

Endpoint читает refresh token только из cookie своего контура, выполняет идемпотентную ротацию через FranchiseSessions.rotate, выпускает новый access JWT и заменяет cookie. Успешный JSON-ответ совпадает с login.

Старый token в течение 30 секунд повторно возвращает тот же successor при стабильном sid. Отсутствующая, неизвестная, истёкшая, отозванная или повторно использованная после окна cookie возвращает одинаковый 401 с Session refresh failed. Ответ одновременно устанавливает пустую panam_franchise_refresh с Max-Age=0. Admin refresh-cookie не читается и также приводит к этому ответу.

Logout

POST /api/franchise/v1/auth/logout
Cookie: panam_franchise_refresh=...

Logout отзывает серверную сессию по franchise refresh-cookie и всегда удаляет cookie через Max-Age=0. Endpoint идемпотентен: отсутствие, неизвестность или повторный отзыв token не являются ошибкой, ответ всегда 204 No Content. Bearer token не требуется и намеренно игнорируется, чтобы устаревший Authorization header не мешал выходу. Admin-cookie не читается и не отзывает сессию ФЧ.

После logout связанный access JWT немедленно отклоняется, поскольку его проверка каждый раз проверяет состояние серверной сессии.

Юридические лица своего ФЧ

GET /api/franchise/v1/legal-entities
Authorization: Bearer <franchise-access-token>

Endpoint требует FranchisePermission.LEGAL_ENTITY_MANAGE и возвращает юридические лица только текущего ФЧ. franchiseeId не принимается ни в query, ни в body: application-сервис получает его исключительно из типизированного FranchisePrincipal и вызывает LegalEntities.findByFranchisee(principal.franchiseeId).

Ответ содержит id, реквизиты, статус, временные поля и version. franchiseeId в элемент ответа не включён, поскольку scope уже однозначно задан аутентификацией. Отсутствующий или admin JWT даёт 401, а аутентифицированный principal без permission — 403.

iiko-подключения юридического лица

POST   /api/franchise/v1/legal-entities/{legalEntityId}/iiko-connections
GET    /api/franchise/v1/legal-entities/{legalEntityId}/iiko-connections
DELETE /api/franchise/v1/legal-entities/{legalEntityId}/iiko-connections/{connectionId}
Authorization: Bearer <franchise-access-token>

Все операции требуют FranchisePermission.IIKO_CONNECTION_MANAGE. Application-сервис сначала проверяет, что legalEntityId принадлежит principal.franchiseeId, и только после этого вызывает iiko::api. В POST принимаются только name и apiLogin; владелец берётся из path. GET возвращает активные и отключённые подключения выбранного юридического лица. DELETE не удаляет запись, а переводит подключение в DISABLED и возвращает 204 No Content.

Ответ содержит id, legalEntityId, имя, статус, временные поля и version. apiLogin никогда не возвращается. Несуществующее или чужое юридическое лицо, чужой либо несуществующий connection ID дают одинаковый 404, чтобы не раскрывать наличие чужих данных. Некорректные поля дают 400; создание для неоперационного юридического лица — 409.

name      panam_franchise_refresh
path      /api/franchise/v1/auth
HttpOnly  true
Secure    true по умолчанию
SameSite  Strict
Max-Age   оставшийся срок серверной refresh-сессии

Отдельные имя и path не позволяют браузеру отправить cookie в API Админки ГК. Для локального HTTP Secure можно отключить через PANAM_FRANCHISE_OFFICE_AUTH_REFRESH_COOKIE_SECURE=false; в общих средах и production он должен оставаться включённым.

Security chain

Отдельная цепочка с приоритетом после admin применяется только к /api/franchise/**. Без аутентификации разрешены login, refresh и идемпотентный logout. Остальные маршруты требуют bearer-аутентификацию.

CSRF отключён (csrf.disable()) и явно задан sessionManagement(STATELESS) для этой stateless API chain: прикладные запросы используют Authorization header, а refresh/logout защищены отдельной HttpOnly; Secure; SameSite=Strict cookie.

FranchiseOfficeBearerAuthenticationFilter проверяет token через FranchiseAccessTokens и помещает в Security context отдельный FranchiseOfficeSecurityPrincipal: типизированный FranchisePrincipal, sessionId и признак временного пароля. Spring authorities строятся только из FranchisePermission с префиксом FRANCHISE_.

Фильтры обоих административных контуров дополнительно ограничены своими URI namespace в shouldNotFilter, поскольку Spring bean типа OncePerRequestFilter может регистрироваться как servlet filter вне конкретной security chain. Поэтому franchise-фильтр не обрабатывает /api/admin и наоборот. Admin JWT не создаёт аутентификацию в контуре ФЧ.

Третья, замыкающая цепочка (@Order(3), DefaultSecurityConfiguration в корневом пакете web) матчит /** и по умолчанию denyAll() всё, что не попало ни в эту, ни в admin-цепочку — кроме /v3/api-docs/** и /swagger-ui/**, которые остаются публичными. Это гарантирует, что новый эндпоинт вне /api/admin/** и /api/franchise/** по умолчанию закрыт, а не открыт.

LoginRateLimiter (корневой пакет web, общий для admin и franchise) ограничивает /login и /refresh по IP клиента: panam.security.login-rate-limit.login-limit попыток на login-window (по умолчанию 10 / минуту) и отдельно refresh-limit на refresh-window (по умолчанию 30 / минуту). Превышение отдаёт 429 application/problem+json с Retry-After. Счётчики хранятся в памяти процесса и не переживают перезапуск; при переходе на несколько инстансов их нужно вынести в общее хранилище.

Проверки

  • HTTP-тест проверяет успешный login, JSON без refresh token и атрибуты cookie.
  • HTTP-тест проверяет franchiseeId, permissions и JWT через FranchiseAccessTokens.
  • HTTP-тест проверяет одинаковый 401 для неизвестного email и неверного пароля.
  • HTTP-тесты проверяют ротацию token/cookie, идемпотентный повтор, отсутствующую cookie, admin-cookie и удаление franchise-cookie при отказе.
  • HTTP-тест проверяет закрытый маршрут без token и с реальным admin JWT.
  • Test-only защищённый endpoint проверяет типизированный principal, franchiseeId и authorities.
  • HTTP-тесты проверяют logout, удаление cookie, немедленную недействительность access JWT, идемпотентность и изоляцию от admin-cookie.
  • HTTP-тест списка юридических лиц создаёт данные двух ФЧ и проверяет, что чужие данные отсутствуют в ответе, а franchiseeId берётся только из principal.
  • HTTP-тесты проверяют 401 без franchise JWT и с admin JWT, а также 403 без LEGAL_ENTITY_MANAGE.
  • HTTP-тесты iiko-подключений проверяют создание и список без утечки apiLogin, отключение без удаления, валидацию, неоперационное юридическое лицо, 401, 403 и сокрытие чужих ресурсов.
  • WebModularityTests проверяет шесть модулей runtime-приложения и объявленные границы franchiseoffice → iam::api, franchise::api, iiko::api.

Следующий этап

После ручной проверки iiko-подключения зашифровать apiLogin отдельным принимаемым этапом.