# Покупатели и вход

Покупатель сайта — не администратор. Разные таблицы, разные способы входа,
разные сессии. Общая таблица заставила бы каждый запрос покупателя тащить
признак «а не админ ли он», а любую ошибку в правах — превращать посетителя
в администратора. Про учётные записи админки — [admin.md](admin.md#вход).

## Что персайтовое

| Таблица | Уникальность |
|---|---|
| `users` | `(site_id, email)` |
| `user_phones` | `(site_id, phone)` |
| `auth_identities` | `(site_id, provider, provider_user_id)` |
| `phone_verifications` | — (индекс `(site_id, phone, consumed_at)`) |
| `password_reset_tokens` | первичный ключ `(site_id, email)` |

⚠️ **Все ключи — по паре с сайтом, и это не косметика.** Телефон,
зарегистрированный на одном сайте, не должен блокировать регистрацию
на другом: сайты принадлежат разным владельцам и друг о друге не знают.
Глобальный ключ вдобавок сломал бы восстановление персайтового дампа на общей
установке — два сайта принесли бы один и тот же номер.

Внутри одного сайта телефон, наоборот, уникален: подтверждённый номер
принадлежит ровно одной учётной записи, иначе вход по звонку открывал бы
чужие заказы.

Почты и пароля у покупателя может не быть вовсе — вход по звонку не спрашивает
ни того, ни другого, а требовать их «на всякий случай» означало бы хранить
выдуманные адреса, которыми никто не пользуется.

## Сессия покупателя привязана к сайту

`users.id` сквозной по всей установке, а сессия хранит только идентификатор.
Поэтому поставщик учётных записей — свой: `SiteUserProvider` довешивает
`site_id = :site` и `is_active = 1` к каждому запросу Laravel'овского гварда.

```php
'providers' => ['users' => ['driver' => 'site_users', 'model' => User::class]],
```

Без этого фильтра сессия, полученная на одном сайте, подняла бы покупателя
другого — достаточно совпадения id. Куки у сайтов разные, но полагаться на это
нельзя: у dev-адресов общий домен второго уровня.

Вне запроса (консоль, воркер до установки контекста) поставщик не отдаёт
никого: одна забытая установка контекста иначе означала бы вход в чужой сайт.

## Вход по звонку

Обратный дозвон: звонит «неизвестный номер», сбрасывает, и кодом служат
последние N цифр этого номера. Ничего никуда не отправляется, SMS-шлюз не нужен,
код известен сразу из ответа провайдера.

```
POST /auth/phone/start   { phone }        → заказ звонка
POST /auth/phone/verify  { phone, code }  → вход
POST /auth/logout
```

Оба шага отвечают и JSON'ом, и редиректом: форма в теме работает без JS —
вход не то место, где можно требовать включённый скрипт. Шаг ввода кода
без JS едет во флеш-сессии вместе с уже нормализованным номером.

Состояние попытки живёт в `phone_verifications`, а не в сессии: звонок оплачен,
и перезагрузка страницы или переход на другое устройство не должны сжигать его
впустую.

### Ограничения — часть механизма, а не украшение

| Что | Зачем |
|---|---|
| Пауза между заказами звонка (по умолчанию 60 с) | без неё баланс сайта выносится циклом на один номер |
| Предел попыток ввода (5) | четырёхзначный код подбирается за минуты |
| Одноразовость кода | подсмотренный код не должен работать завтра |
| Новый заказ гасит предыдущий | иначе два кода подряд оставляли бы рабочими оба |
| Срок жизни кода (600 с) | попытка не должна висеть открытой сутками |
| 10 заказов в час с одного IP | перебор чужих номеров тоже тратит деньги владельца |

Код хранится **только хешем**: дамп базы не должен давать возможности войти.

### Провайдер выбирается настройкой сайта

`Настройки → Вход покупателей`. Драйверов два:

- `log` — код пишется в лог, звонка нет. Для разработки и тестов; на боевой
  установке отказывается работать: код в логе — это вход в любой аккаунт
  для всякого, кто до лога дотянулся;
- `voicepassword` — обратный дозвон через voicepassword.ru.

Ключ API лежит в настройках **сайта**, а не в `.env`: у каждого арендатора свой
договор и свой баланс, а установка одна на сотни сайтов. Значение шифруется
и в браузер не уезжает — как любой секрет ([settings.md](settings.md)).

Свой провайдер подключается реализацией `PhoneVerifier` — интерфейс из двух
строк, точка расширения ровно там, где отличаются договоры арендаторов.

## Вход через Яндекс ID и VK ID

```
GET /auth/{provider}/redirect   → уходим к провайдеру
GET /auth/{provider}/callback   → возвращаемся, входим
```

Authorization Code + PKCE, `state` и `verifier` в сессии. Проверка `state`
обязательна: без неё чужой подсунет свой код авторизации и войдёт в аккаунт
жертвы. У VK ID три отличия, из-за которых у него свой класс: секрет
не используется вовсе, `device_id` из ответа обязателен при обмене кода,
а профиль отдаётся POST'ом с `client_id` в теле.

**Ключи приложения — настройки сайта**, а не платформы: у каждого арендатора
своё приложение в кабинете провайдера, потому что адрес возврата содержит его
домен. Сам адрес возврата не настраивается — он строится от текущего хоста,
иначе переезд сайта на новый домен молча ломал бы вход.

Ненастроенный провайдер не показывается и отдаёт 404: кнопка «Войти через
Яндекс», ведущая в ошибку, хуже её отсутствия.

В `auth_identities.metadata` едет только публичный профиль. Токенов там нет:
после входа они не нужны, а утечка непросроченного означала бы доступ
к аккаунту у самого провайдера.

## Один человек — одна учётная запись

`UserWriter` — единственная точка записи покупателей. Инвариант, ради которого
он существует: подтверждённый телефон принадлежит ровно одной учётной записи
сайта. Способов войти будет несколько (звонок, Yandex, VK), и каждый обязан
находить того же покупателя, а не заводить дубликат на канал — в легаси заказы
одного человека расползались по трём учёткам.

Отсюда же правила:

- номер нормализуется до `+7XXXXXXXXXX` **до** любого сравнения. «8 900…»,
  «+7 900…» и «9001234567» — один и тот же человек;
- подтверждённый чужой номер не перевешивается молча: `attachVerifiedPhone`
  вернёт `null`. Иначе вход по звонку превращается в способ забрать чужой
  аккаунт;
- **порядок поиска при внешнем входе**: уже привязанное удостоверение →
  подтверждённый телефон → почта → заводим нового. Провайдеры отдают только
  подтверждённые номера, поэтому человек, входивший звонком, а теперь через
  Яндекс, попадает в ту же учётку;
- **своё важнее провайдерского**: из внешнего профиля дописываются только
  пустые поля. Имя, введённое руками, не затирается тем, что записано
  в аккаунте у Яндекса.

## Блок «Вход покупателя»

Отдельной страницы входа нет намеренно. Тип содержимого блока `auth` ставится
в шапку, и посетитель входит, не теряя того, что смотрел; легаси уводил
на `/user/login` и возвращал на главную, из-за чего корзина собиралась заново.

Гость видит форму, вошедший — приветствие и «Выйти». Имени у покупателя,
вошедшего по звонку, ещё нет, поэтому здоровается с ним его же номер
в человеческом формате.

### Два вида вывода, в каждом — два состояния

| Настройка блока | Что делает |
|---|---|
| **Как выводить** | «форма прямо в блоке» или «кнопка, вход открывается поверх страницы» |
| **Подпись кнопки для гостя** | по умолчанию «Войти» |
| **Подпись кнопки для вошедшего** | пусто — на кнопке имя покупателя или его телефон |

Подписи две, потому что и состояний два: в шапке одна и та же кнопка
не может называться «Войти» и для гостя, и для того, кто уже вошёл —
второму она сообщала бы, что вход не сработал.

Окно — общий шаблон `platform::blocks.modal`, тот же, что у формы обратной
связи: расходиться поведению двух кнопок в шапке незачем. Устройство описано
в [forms.md](forms.md#кнопка-вместо-формы); здесь важны две особенности входа:

- **окно открывается само на шаге кода.** Звонок уже оплачен, и поле для кода
  не может остаться за закрытой дверью — иначе покупатель начнёт вход заново
  и оплатит второй звонок;
- **в окне приветствие не печатается дважды**: оно уже стоит заголовком окна.

## Чего ещё нет

| Что | Когда |
|---|---|
| Вход по паролю и восстановление почтой | по мере надобности |
| Кода страны кроме России | отдельное решение по схеме |
