# teeu — Авторизация (Этап 2)

Phone-centric аккаунты (ТЗ §5): основной идентификатор — подтверждённый телефон `+7XXXXXXXXXX`.
Обычной email+password регистрации нет. Способы входа: **дозвон**, **Яндекс ID**, **VK ID**, **Сбер ID**.

## Компоненты

| Слой | Файлы |
|---|---|
| Нормализация телефона | `App\Support\PhoneNumber` (`8→+7`, `+7(...)`, 10-значный, E.164) |
| Драйвер дозвона | `Services\Auth\Contracts\PhoneVerifier` + `Verifiers\{ManualPhoneVerifier, VoicePasswordPhoneVerifier}` (порт из Placeo), выбор — `services.phone_verify.driver` |
| Challenge (БД) | `phone_verifications` + модель `PhoneVerification` (TTL, attempts, one-time, cooldown) |
| Оркестрация | `Services\Auth\PhoneAuthService` (start/verify), `AccountService` (resolveOrRegister, merge, linkIdentity) |
| OAuth | `Contracts\OAuthAdapter` + `Adapters\{YandexAdapter, VkIdAdapter, SberIdAdapter}` + `OAuthAdapterManager` |
| Капча перед звонком | `Services\Auth\SmartCaptcha` (Яндекс SmartCaptcha, проверка токена) |
| Вход через Apple | `Adapters\AppleAdapter` (OIDC, form_post, только в мобильном приложении) |
| HTTP | `Auth\PhoneAuthController` (start/verify), `Auth\OAuthController` (redirect/callback), logout |
| UI | `partials/auth-form` (Alpine `authForm`), `partials/auth-modal`, `auth/login` |

## Вход по дозвону (основной, ТЗ §5.6)

1. `POST /phone/start` (`throttle:phone-send`) → нормализация → проверка resend-cooldown → драйвер
   инициирует обратный звонок и возвращает код (последние N цифр звонящего). Код хэшируется и пишется
   в `phone_verifications` (TTL `services.phone_verify.ttl`, старые challenge для номера гасятся).
2. Пользователь видит входящий номер, вводит **последние 4 цифры**.
3. `POST /phone/verify` (`throttle:phone-check`) → берётся активный (не consumed, не expired) challenge,
   анти-брутфорс (`max_attempts`, по умолчанию 5), сверка хэша. При успехе challenge **consumed**
   (одноразовость), затем `AccountService::resolveOrRegisterByPhone`: найден подтверждённый номер →
   вход; иначе создаётся аккаунт (роль buyer, номер сразу verified+primary).

**Глобальная уникальность:** `user_phones.phone` — unique; `attachVerifiedPhone` не присвоит номер,
подтверждённый другим аккаунтом. Дубли не создаются.

Драйверы: `manual` (dev, код `services.phone_verify.dev_code`=`0000`), `voicepassword` (боевой,
код приходит в ответе `/send`). Параметры — `config/services.php → phone_verify` / `voicepassword`.

## Капча перед звонком (Яндекс SmartCaptcha)

Звонок — единственное действие гостя, которое стоит живых денег. Боты этим и воспользовались:
перебрали номера пачками и вычерпали баланс сервиса дозвонов. Поэтому перед `/phone/start` стоит
SmartCaptcha.

Порядок именно такой: гость вводит номер → жмёт **«Получить звонок»** → всплывает ползунок →
и только после него уходит запрос к сервису дозвона. Капча невидимая (`invisible: true`), поле она
не занимает; скрипт `captcha.js` подгружается **по первому нажатию**, а не на каждой странице — форма
входа лежит в layout, то есть присутствует везде. OAuth-кнопки (Яндекс ID, VK ID) капчу не трогают:
там денег не тратится.

| Настройка | Смысл |
|---|---|
| `captcha.enabled` | выключатель; выключено — вход работает как до капчи |
| `captcha.client_key` | `ysc1_…`, публичный: уходит в разметку формы |
| `captcha.server_key` | `ysc2_…`, секрет: им проверяется токен |

**Проверка закрытая.** Обычно советуют наоборот — если сервис валидации недоступен, пропускать
пользователя, чтобы его не терять. Здесь цена ошибки не «неудобно войти», а «списаны деньги», и
рядом есть вход через Яндекс ID, поэтому молчание сервиса означает «звонка нет»
(`Services\Auth\SmartCaptcha::verify`). Если капча ляжет надолго — её выключают настройкой, и это
осознанное решение человека, а не тихий обход.

Проверка стоит **только на `/phone/start`**. Шаг с кодом не защищён намеренно: там уже есть счётчик
попыток и одноразовость challenge, а звонок к этому моменту оплачен.

## Вход через Apple (ради App Store)

Заведён не для удобства, а по требованию Apple: раз приложение предлагает вход через сторонние
сервисы, оно обязано предлагать и Sign in with Apple (App Store Review Guideline 4.8). Кнопка
показывается **только внутри приложения** — признак `MobileAppContext::isApp()`; на сайте её нет.
Выключается настройкой `apple.enabled`, без выкатки кода.

| Настройка | Что это |
|---|---|
| `apple.services_id` | **Services ID** из Apple Developer — это `client_id` веб-потока. Не bundle id приложения |
| `apple.team_id` | Team ID (10 символов) |
| `apple.key_id` | Key ID ключа Sign in with Apple |
| `apple.private_key` | содержимое `.p8` (секрет) |
| `apple.domain_association` | файл, которым Apple подтверждает домен; отдаётся по `/.well-known/apple-developer-domain-association.txt` |

`AppleAdapter` не похож на остальные провайдеры в трёх местах, и каждое из них ломается молча:

**client_secret — это JWT, а не строка.** Подписывается ES256 ключом `.p8`, живёт до полугода.
Собирается на лету и кэшируется; хранить его в настройках значило бы однажды получить
неработающий вход без единой ошибки в логах.

**Ответ приходит POST'ом.** Без `response_mode=form_post` Apple не отдаёт имя и почту, поэтому у
callback свой POST-маршрут (`auth.apple.callback`) и исключение в `validateCsrfTokens`: запрос
приходит с домена Apple, нашего токена в нём нет и быть не может. Подлинность держится на подписи
`id_token` и собственном `state`.

**`state` не в сессии.** При кросс-доменном POST браузер не пришлёт cookie (SameSite=Lax), сессия
окажется пустой — и проверка развалилась бы ровно там, где нужна. Поэтому `state` подписан
`app.key` и проверяется сам по себе.

Ещё две особенности самого Apple: **имя приходит только при первом входе** отдельным полем `user`
и больше никогда — не сохранить сразу значит не узнать вовсе; **почта часто ретранслятор**
`…@privaterelay.appleid.com` — письма через него доходят, но адрес перестанет работать, если
пользователь отзовёт доступ. Телефона Apple не даёт вовсе, поэтому `requiresPhone() = false`,
как у Яндекса.

Отдельно к ревью: Apple требует, чтобы приложение с регистрацией умело **удалять аккаунт**
(Guideline 5.1.1(v)) — это уже есть в профиле покупателя.

## Яндекс ID (ТЗ §5.3)

`YandexAdapter` через Socialite (`socialiteproviders/yandex`). Scope — из доступов OAuth-приложения,
не в коде. Из колбэка: `id`, `default_email`, `default_phone.number` (уже верифицирован Яндексом →
attach как verified), аватар (если `is_avatar_empty=false`). Dedup: identity → verified phone → email.
`requiresPhone()=false` (для Яндекса email допустим).

Redirect URI: `{APP_URL}/auth/yandex/callback`. Креды — `.env`: `YANDEX_CLIENT_ID/SECRET/REDIRECT_URI`.

## VK ID (ТЗ §5.4) — требуется spike

`VkIdAdapter` (OAuth 2.1 + PKCE, `requiresPhone()=true`). **Перед боевым использованием** —
technical spike: убедиться, что текущая конфигурация приложения VK ID реально возвращает **пригодный
подтверждённый телефон** в доступном scope. Правило (реализовано в `OAuthController`): если телефон не
получен — **аккаунт не создаётся**, показывается «Не удалось получить подтверждённый телефон.
Используйте другой способ входа». Endpoints: `id.vk.com` (authorize/oauth2/auth/user_info). Адаптер
подключён, но неактивен без кред (`VK_CLIENT_ID/SECRET/REDIRECT_URI`).

## Сбер ID (ТЗ §5.5)

`SberIdAdapter` (OIDC + PKCE, state, `requiresPhone()=true`). Endpoints конфигурируемы
(`services.sber.base_url`). Возможна необходимость сертификатов по договору — уточняется при
подключении. Неактивен без кред (`SBER_CLIENT_ID/SECRET/REDIRECT_URI`).

## Объединение идентичностей (ТЗ §5.7)

`auth_identities` (`provider + provider_user_id` unique, `provider_phone`, `metadata`). Access/refresh
токены по умолчанию **не хранятся**; при необходимости — шифрованно, без логирования.
`AccountService::resolveFromOAuth` линкует identity к существующему пользователю (по телефону/email)
или создаёт нового; профиль дозаполняется без перезаписи заполненных полей.

**Имя, e-mail и аватар от провайдера не могут сорвать вход.** VK ID отдаёт аватар адресом CDN с
перечнем всех размеров — сотни символов; в `varchar(255)` он не помещался, MySQL отказывал в INSERT,
и регистрация через VK падала целиком (прод, 14.09.2026). Теперь `users.avatar` — `text`, а
`AccountService` перед записью обрезает имя до 255 символов и не сохраняет e-mail длиннее колонки и
аватар, который не похож на http(s)-адрес или длиннее 2048 символов. На sqlite в тестах длина
varchar не проверяется, поэтому тест сверяет тип колонки. Если создать аккаунт всё же не удалось,
`OAuthController` возвращает на страницу входа с сообщением и пишет исключение в журнал, а не
показывает 500.

## Согласие и принятие соглашения (ТЗ §5.2)

- **При входе/регистрации** (`partials/auth-form`, Alpine `authForm`) — две обязательные галочки:
  «Политика конфиденциальности» и «Согласие на обработку ПДн» (ссылки на `/privacy`, `/consent`). Пока
  обе не отмечены — кнопка «Получить звонок» задизейблена, клики по OAuth-кнопкам заблокированы.
  Момент согласия — `users.consent_accepted_at` (ставит `AccountService`).
- **Terms-gate:** при первом входе и после каждой правки текста соглашения показывается **немодальное
  окно** с Пользовательским соглашением (`partials/terms-gate`, Alpine `termsGate`) — не закрывается,
  только кнопкой «Принимаю условия». Хранится в `users.terms_accepted_at`; показ определяет
  `User::needsTermsAcceptance()` (пусто ИЛИ старше `Page::termsUpdatedAt()` — даты правки страницы
  «terms»). Приём — `POST /terms/accept` (`TermsController`). Подключено в layouts app/account/merchant.
  Пересохранение «terms» в админке бампает `updated_at` → окно выходит у всех заново (см. [catalog.md]).

## Вход администратора под пользователем (эмуляция)

`App\Services\Admin\Impersonation` + действие «Войти как пользователя» в ресурсе Users.
Нужно, чтобы настроить кабинет за продавца и увидеть сайт его глазами.

Сессия **подменяется целиком** — пользователь становится настоящим, а не «админом с чужими
правами». Иначе `Gate::before`, который выдаёт админу любую способность, показывал бы то, чего
сам пользователь не видит, и смотреть его глазами было бы бессмысленно.

Возврат держится на единственной зацепке — `impersonator_id` в сессии. Кладётся **после**
`Auth::login`: вход пересоздаёт сессию и стёр бы ключ. Маршрут выхода `POST /stop-impersonating`
живёт вне админ-префикса — под чужой учёткой пользователь не администратор и до маршрута внутри
панели не добрался бы.

Ограничения: только админ, не под собой, **не под другим админом** (иначе следы в аудите
неразличимы — кто из двоих что сделал) и не поверх активной эмуляции. Оба конца пишутся в
`audit_logs` (`impersonation.start` / `impersonation.stop`). Пока режим активен, во всех трёх
layout висит оранжевая полоса с кнопкой возврата.

## Сколько живёт вход

`SESSION_LIFETIME = 129600` — **90 дней** бездействия вместо стандартных двух часов
(`config/session.php`, драйвер `database`). Причина в способе входа: пароля у нас нет, вход — код
из звонка, и повторять его каждый рабочий день значит платить заметным неудобством за защиту,
которой при желании обойти и так нет. Оба входа (`PhoneAuthController`, `OAuthController`) вдобавок
зовут `Auth::login(..., remember: true)`, так что запись в `sessions` — не единственная опора.

Обратная сторона: тот же срок достаётся заходам **без входа**, а их на порядок больше. На проде это
около 14 000 строк в сутки (боты и разовые посетители) против пары десятков пользовательских — за
квартал вышло бы больше миллиона строк и несколько гигабайт на сервере, где базу только что
подрезали. Поэтому гостевые сессии убираются отдельно:

| Чья сессия | Живёт | Кто убирает |
|---|---|---|
| Вошедшего пользователя | 90 дней | штатная уборка Laravel по `session.lifetime` |
| Посетителя без входа | 2 суток (`teeu.sessions.guest_lifetime`) | `teeu:sessions:prune-guests`, ежедневно в 04:50 |

Гостю удаление ничего не стоит: корзина (`teeu_cart`), город, посетительский id и подтверждение 18+
живут в собственных cookie и сессию не переживают только потому, что её не используют. Единственное,
что теряется, — CSRF-токен, а он выдаётся заново.

Удаление идёт частями по 2000 строк: таблица сессий нужна каждому запросу, держать её в блокировке
нельзя.
## Безопасность

- Rate limit: `phone-send` (5/мин на IP, 10/час на номер), `phone-check` (10/мин) — `AppServiceProvider`.
- Challenge: TTL, лимит попыток, одноразовость, cooldown между звонками; код — только в hash.
- OAuth: `state` + PKCE (`S256`) в сессии, проверка на колбэке (CSRF/replay).
- Ошибки внешних API логируются без секретов.

## Тесты (ТЗ §56.1)

`tests/Unit/PhoneNumberTest`, `tests/Feature/AuthPhoneTest` (регистрация, merge, attempts+lock, expired,
reused, cooldown, invalid), `tests/Feature/AuthOAuthTest` (identity attach, dedup по телефону, повторный
identity, 404 неизвестного/невключённого провайдера, provider error).
