# Модуль: Авторизация

[← К оглавлению](../README.md)

**Основной способ — вход/регистрация по номеру телефона** (подтверждение обратным
flash call). Вторичные — по почте (с кодом из письма) и через **Яндекс OAuth**.
Доступны в **модальном окне** в шапке и на отдельных страницах `/login`, `/register`.
Подача объявлений и кабинет — только авторизованным.

## Файлы

| Файл | Назначение |
|---|---|
| `app/Http/Controllers/Auth/PhoneAuthController.php` | **вход/регистрация по телефону** (start/verify, flash call) |
| `app/Services/Phone/PhoneService.php` | логика номеров: старт звонка, проверка кода, поиск/создание аккаунта |
| `app/Services/Phone/PhoneVerifier.php` (+ `Voicepassword`/`Plusofon`/`Manual`) | драйвер обратного flash call |
| `app/Http/Controllers/Auth/AuthenticatedSessionController.php` | вход по почте / выход |
| `app/Http/Controllers/Auth/RegisteredUserController.php` | регистрация по почте (шлёт код, юзер создаётся позже) |
| `app/Http/Controllers/Auth/EmailVerificationController.php` | подтверждение почты кодом → создание пользователя |
| `app/Http/Controllers/Auth/YandexController.php` | Яндекс OAuth (redirect/callback) |
| `resources/views/partials/auth-form.blade.php` + Alpine `authModal` | **общая форма** (телефон + почта + Яндекс) |
| `resources/views/partials/auth-modal.blade.php` | модалка в шапке (обёртка над `auth-form`) |
| `resources/views/auth/{login,register}.blade.php` | отдельные страницы (та же `auth-form`) |

## Маршруты (`routes/web.php`, группа `guest`)

```
POST /phone/start    (name: phone.start,  throttle: phone-send)   — инициировать звонок
POST /phone/verify   (name: phone.verify, throttle: phone-check)  — проверить код → вход/регистрация
GET/POST /login      (вход по почте; POST с Accept: json → {ok, redirect})
GET/POST /register   (регистрация по почте → код из письма)
POST     /email/verify, /email/resend
POST     /password/email-code, /password/reset, /password/resend   (сброс пароля)
GET      /auth/yandex/redirect (name: auth.yandex), /auth/yandex/callback
POST     /logout     (middleware auth)
```

## Вход по телефону (основной)

Поток для гостя (`PhoneAuthController`):

1. **`POST /phone/start`** — нормализует номер (`UserPhone::normalize` → `+7XXXXXXXXXX`),
   инициирует обратный звонок через `PhoneService::startGuest()` и кладёт состояние
   сессии звонка в server-side `session` (ключ `phone_auth`: phone, session, expires_at,
   attempts). Пользователь ещё не создаётся.
2. **`POST /phone/verify`** — сверяет 4-значный код (последние цифры позвонившего
   номера) с анти-брутфорсом (5 попыток, TTL). При успехе — `PhoneService::resolveOrRegister()`:
   - если номер **уже подтверждён** у какого-то аккаунта → входим в него;
   - если **ни у кого** не подтверждён → создаётся новый аккаунт (служебный email
     `7XXXXXXXXXX@phone.local`, `consent_accepted_at`), а номер сразу заводится
     **подтверждённым и основным** (`PhoneService::addVerified`).

**Глобальная уникальность:** один и тот же номер не может быть подтверждён более чем
в одном аккаунте (`PhoneService` проверяет это при добавлении).

### Драйвер подтверждения (flash call)

`PhoneVerifier` — обратный звонок: на номер поступает звонок с «незнакомого» номера,
код = его **последние 4 цифры**. Драйвер выбирается `config('services.phone_verify.driver')`:

| Драйвер | Когда |
|---|---|
| `manual` | dev-заглушка, код `services.phone_verify.dev_code` (по умолчанию `0000`) |
| `voicepassword` | боевой (voicepassword.ru) — используется на проде |
| `plusofon` | устаревший |

Ключи драйвера живут в `.env` (`VOICEPASSWORD_API_KEY` и т.д.). **На проде конфиг
закэширован** (`bootstrap/cache/config.php`) — после правки `.env` обязателен
`php artisan config:cache`, иначе приложение продолжит работать со старыми ключами.

### Защита от ботов: Яндекс SmartCaptcha

Каждый дозвон списывается с баланса сервиса, поэтому звонок — единственное действие
гостя, которое стоит живых денег. Боты этим пользовались и вычерпывали баланс, отсюда
капча ровно перед дозвоном.

- `App\Services\Auth\SmartCaptcha` — `isEnabled()`, `clientKey()`, `verify($token, $ip)`.
- **Ключи в админке** (`/admin → Настройки → Защита от ботов`), таблица `settings`:
  `captcha_enabled`, `captcha_client_key` (`ysc1_…`, публичный), `captcha_server_key`
  (`ysc2_…`, секретный). В `.env` их нет — меняются владельцем без деплоя.
- **Проверка закрытая**: недоступность сервиса валидации = «не пущен». Цена ошибки —
  не «неудобно войти» (рядом почта и Яндекс ID), а списанные деньги. Выключается
  тумблером, осознанно.
- Гейт стоит в `PhoneAuthController::start()` и `WidgetAuthController::phoneStart()` —
  **до** `PhoneService`, то есть до обращения к провайдеру.
- Фронтенд: невидимый виджет, скрипт грузится по первому нажатию «Получить код по
  звонку». Разметка — `resources/views/partials/auth-form.blade.php` (`x-ref="captcha"`
  с `data-sitekey`) и `resources/views/widget/auth.blade.php` (`#wa-captcha`); логика —
  `authModal` в `resources/js/app.js`. Пустой ключ = проверка выключена, форма работает
  как раньше.
- Ключи выпускаются в консоли Yandex Cloud → SmartCaptcha
  (<https://yandex.cloud/ru/docs/smartcaptcha/>).

### Лимиты на заказ звонка

Два независимых рубежа:

| Рубеж | Где | Ключ |
|---|---|---|
| `throttle:phone-send` — 5/час | маршрут (`AppServiceProvider`) | IP (или id пользователя) |
| `PhoneService::MAX_CALLS_PER_HOUR` — 5/час | `PhoneService::guardPerNumberLimit()` | **сам номер**, независимо от IP |

Лимит по IP от ботов не спасает: они ходят через ротацию прокси, и в журнале это видно
прямо — по одной попытке с каждого адреса. Лимит по номеру от ротации не зависит и заодно
защищает владельца номера от шквала звонков. Стоит в `startCall()`, то есть действует для
всех входов сразу (сайт, виджет, мини-апп, кабинет), а не только для размеченных маршрутов.

Счётчик увеличивается **перед** обращением к провайдеру, а не после удачи: иначе при
отказах провайдера лимита фактически нет. Исчерпание пишется в журнал как `rate_limited`.

### Журнал звонков (`phone_call_logs`)

Каждая попытка заказать звонок пишется в таблицу — иначе непонятно, кто вычерпал баланс.
Пишет `PhoneService::startCall()` (единственная точка обращения к провайдеру) плюс отказы
капчи из контроллеров. Смотреть — `/admin → Звонки-подтверждения` (только чтение, фильтры
по исходу/источнику/периоду, поиск по номеру и IP; бейдж = число ушедших звонков за сутки).

| Поле | Смысл |
|---|---|
| `result` | `ok` (звонок ушёл, деньги списались) · `captcha_failed` · `rate_limited` · `provider_error` · `invalid_phone` |
| `source` | `web` · `widget` · `miniapp` · `cabinet` (определяется по маршруту) |
| `ip_address`, `user_agent`, `user_id`, `error` | контекст для разбора атаки |

Отдельно: `LOG_LEVEL` на проде стоит `error`, поэтому диагностика провайдера пишется
через `Log::error()` — иначе отказы дозвона в `laravel.log` не попадают.

**Чистка.** Модель `MassPrunable`, задача `model:prune` в расписании раз в сутки в 03:30
(`routes/console.php`). Сроки хранения — константы в `PhoneCallLog`:

| Что | Срок | Почему |
|---|---|---|
| `captcha_failed`, `invalid_phone`, `rate_limited` (`BLOCKED_RESULTS`) | `KEEP_BLOCKED_DAYS` = 30 дней | шум атаки, тысячи строк в сутки; нужен, пока разбираешься с происходящим |
| всё остальное (`ok`, `provider_error`, будущие исходы) | `KEEP_REAL_DAYS` = 365 дней | история трат и сбоев провайдера: объём мал, ценность долгая |

Вторая ветка задана через `whereNotIn(BLOCKED_RESULTS)`, а не перечислением, — чтобы новый
вид исхода не остался без чистки навсегда.

## Вход/регистрация по почте (вторичный)

- **Регистрация** (`POST /register`) пользователя сразу не создаёт: кладёт заявку
  (имя, email, пароль, хэш 6-значного кода) в кэш на 15 мин (`register:{email}`) и шлёт
  код письмом. `POST /email/verify` сверяет код (до 5 попыток) → создаёт `User`
  (`email_verified_at`, `consent_accepted_at`), логинит. Инкапсулировано в
  `EmailVerificationController` (`sendCode()` переиспользуется).
- **Вход** (`POST /login`) — `Auth::attempt`; при `expectsJson()` возвращает `{ok, redirect}`.
- **Сброс пароля** — код на почту (`/password/email-code` → `/password/reset`).

## Яндекс OAuth

- Провайдер регистрируется в `AppServiceProvider::boot()` (событие `SocialiteWasCalled`).
- **Scope не передаётся вручную** — Яндекс использует доступы OAuth-приложения.
- Колбэк извлекает email (`default_email`), телефон (`default_phone.number`), аватар
  (только если `is_avatar_empty=false`). Привязка по `yandex_id`/`email`; пустые поля
  дозаполняются. Телефон от Яндекса уже верифицирован — заводится подтверждённым
  (`PhoneService::addVerified`).

## Единая форма (UI)

`partials/auth-form.blade.php` (Alpine-компонент `authModal`) — **один источник** для
всех точек входа: модалка в шапке и страницы `/login`, `/register`. Режимы:

- **`phone`** (по умолчанию): ввод номера → шаг ввода 4 цифр из звонка; ссылка «Войти по почте».
- **`email`**: вкладки Вход/Регистрация, сброс пароля, ссылка «Войти по телефону».
- Кнопка **Яндекс** — на стартовых шагах обоих режимов.

Компонент шлёт AJAX на `/phone/start|verify`, `/login`, `/register`, `/email/*`,
`/password/*` и по `{redirect}` делает переход.

## Модель User

`app/Models/User.php`:
- Телефоны: `phones()`, `verifiedPhones()`, `primaryPhone()`, `makePrimaryPhone()`,
  `publicPhone()`/`publicPhoneNumber()` (см. также номера в [cabinet.md](cabinet.md)).
- `telegram_id` — для авто-входа в Telegram-мини-приложении (см. [telegram-miniapp.md](telegram-miniapp.md)).
- `password` nullable (аккаунты только по телефону / OAuth).
- `displayName()`, `avatarUrl()`, `initial()`, `isAdmin()`, `canAccessPanel()` (Filament — `role=admin`).

## Проверка

1. Вход по телефону: номер → звонок → 4 цифры → вход (новый номер регистрирует аккаунт).
2. «Войти по почте» в той же форме → вход/регистрация по почте (код из письма).
3. Вход через Яндекс → профиль дозаполнен (email/телефон).
4. Гость на `/cabinet/*` → редирект на `/login`.

> Локально драйвер `manual`, код `0000` (см. [setup.md](setup.md)).

См. также: [cabinet.md](cabinet.md), [telegram-miniapp.md](telegram-miniapp.md), [integrations.md](integrations.md).
