Files
portal/app/app/Providers/AppServiceProvider.php
T
Дмитрий 7aa3083399 feat(смс-клиент): страница отказа по ссылке из СМС — без входа, номер маской, с лимитом
Человек, получивший СМС, теперь может отказаться сам: короткая ссылка
liderra.ru/s/<токен> открывает простую страницу с одной кнопкой. Нажал — номер
в стоп-листе именно той компании, от которой пришло сообщение.

- таблица client_sms_unsubscribe_links (RLS + tenant_isolation), одна ссылка
  на пару «клиент + номер» навсегда — при каждой рассылке новая не плодится
- сервис токенов: 12 символов, без 0/O/o и 1/l/I (ссылку диктуют вслух)
- публичная страница: blade без Vue-сборки, номер только маской +7 *** *** ** 67
- ограничение частоты 20/мин с IP — перебор ссылок иначе = утечка номеров;
  защита проверена вырезанием, тест на 429 краснеет
- неизвестная ссылка отвечает 404 без подсказок
- служебное соединение только в этом контроллере (тенант-контекста у страницы
  нет) + GRANT для crm_supplier_worker на client_sms_optouts
- CHANGELOG схемы v9.02, с напоминанием ПЕРЕзапустить 03_service_bypass_policies.sql

Строки приёмочного листа 1.6, 1.7, 1.8, 1.9 закрыты тестами; живой прогон —
после выката, страницу надо открыть телефоном.

Ветка feat/client-sms-broadcast, песочница, в main не влито.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 17:58:56 +03:00

306 lines
15 KiB
PHP

<?php
namespace App\Providers;
use App\Models\ImpersonationToken;
use App\Models\PersonalAccessToken;
use App\Models\SalesUser;
use App\Models\User;
use App\Services\Billing\Gateway\PaymentGatewayDriver;
use App\Services\Billing\Gateway\PaymentGatewayManager;
use App\Services\Bot\AitunnelChatClient;
use App\Services\Bot\LlmClient;
use App\Services\Bot\YandexGptClient;
use App\Services\Captcha\CaptchaVerifier;
use App\Services\Captcha\NullCaptchaVerifier;
use App\Services\Captcha\YandexSmartCaptchaVerifier;
use App\Services\DaData\DaDataPartyClient;
use App\Services\DaData\NullPartyLookup;
use App\Services\DaData\PartyLookup;
use App\Services\Sms\Providers\FakeSmsProvider;
use App\Services\Sms\Providers\MtsSmsProvider;
use App\Services\Sms\Providers\SmscSmsProvider;
use App\Services\Sms\SmsProvider;
use App\Services\Sms\SmsRouter;
use App\Services\Supplier\Channel\AjaxProjectChannel;
use App\Services\Supplier\Channel\FailoverProjectChannel;
use App\Services\Supplier\Channel\FormProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\ProcessFactory;
use App\Services\Supplier\SymfonyProcessFactory;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Laravel\Sanctum\Sanctum;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
$this->app->bind(
ProcessFactory::class,
SymfonyProcessFactory::class,
);
// Мозг бота: российская модель или зарубежная через шлюз AITunnel (services.bot.llm).
$this->app->bind(
LlmClient::class,
fn ($app) => config('services.bot.llm') === 'aitunnel'
? $app->make(AitunnelChatClient::class)
: $app->make(YandexGptClient::class),
);
// Резерв канала миграции проектов: SupplierProjectChannel резолвится в
// декоратор-оркестратор (ярус 1 AJAX → ярус 2 browser-form → ярус 3 queue).
// Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.4
$this->app->bind(
SupplierProjectChannel::class,
fn ($app) => new FailoverProjectChannel(
$app->make(AjaxProjectChannel::class),
$app->make(FormProjectChannel::class),
),
);
// Шов капчи самозаписи (G1/SP1 + M-2). Драйвер по CAPTCHA_DRIVER:
// 'yandex' → реальный Yandex SmartCaptcha; иначе (включая 'null') → Null
// (dev/test). Контроллер/RegistrationService зовут только интерфейс.
$this->app->bind(
CaptchaVerifier::class,
fn () => match (config('services.captcha.driver')) {
'yandex' => new YandexSmartCaptchaVerifier,
default => new NullCaptchaVerifier,
},
);
// Шов подтяжки организации по ИНН (G1/SP2). По флагу party_enabled —
// реальный DaData suggestions; иначе Null (dev/тесты не ходят в сеть).
$this->app->bind(
PartyLookup::class,
fn ($app) => config('services.dadata.party_enabled')
? $app->make(DaDataPartyClient::class)
: $app->make(NullPartyLookup::class),
);
// Шов платёжного драйвера (Task 6, billing-yookassa). Резолвит активный
// шлюз через PaymentGatewayManager и возвращает конкретный драйвер.
// Бросает RuntimeException если шлюз не настроен (fail-fast в тестах).
$this->app->bind(PaymentGatewayDriver::class, function ($app) {
$manager = $app->make(PaymentGatewayManager::class);
$gateway = $manager->activeGateway();
if ($gateway === null) {
throw new \RuntimeException('Нет активного платёжного шлюза');
}
return $manager->driverFor($gateway);
});
// Маршрутизатор СМС. Песочница включена — только заглушка (деньги оператору
// не уходят). В бою — включённые каналы из реестра services.sms.providers,
// у которых есть учётные данные (нет ключа → канал пропускается, поток цел).
$this->app->singleton(SmsRouter::class, function () {
if (config('services.sms.sandbox')) {
return new SmsRouter([new FakeSmsProvider]);
}
$providers = [];
foreach ((array) config('services.sms.providers', []) as $key => $entry) {
if (empty($entry['enabled'])) {
continue;
}
$provider = $this->makeSmsProvider((string) $key, (array) $entry);
if ($provider !== null) {
$providers[] = $provider;
}
}
return new SmsRouter($providers);
});
}
/**
* Создать канал по ключу реестра. Нет учётных данных → null (канал не встаёт).
* Новый оператор со СВОИМ API = ещё один арм здесь + запись в services.sms.providers.
*
* @param array<string, mixed> $entry
*/
private function makeSmsProvider(string $key, array $entry): ?SmsProvider
{
/** @var array<int, string> $serves */
$serves = $entry['serves'] ?? ['*'];
/** @var array<string, int> $price */
$price = $entry['price_kopecks'] ?? ['*' => 0];
return match ($key) {
'smsc' => ($login = (string) config('services.smsc.login')) !== ''
? new SmscSmsProvider(
$login,
(string) config('services.smsc.password'),
$serves,
$price,
)
: null,
'mts' => ($token = (string) config('services.sms.mts.token')) !== ''
? new MtsSmsProvider(
$token,
$serves,
$price,
(string) config('services.sms.mts.base_url'),
(string) config('services.sms.mts.naming'),
($ct = (string) config('services.sms.mts.connect_to')) !== '' ? $ct : null,
)
: null,
default => null,
};
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
// Task 15: авто-СМС на каждый новый лид — observer поверх боевого потока
// создания сделок (freshness-guard внутри; тело обёрнуто в try/catch, лид
// не роняет). См. App\Observers\DealSmsObserver.
\App\Models\Deal::observe(\App\Observers\DealSmsObserver::class);
// FN-RESET (приёмка 22.06.2026): дефолтное Laravel-уведомление ResetPassword
// строит ссылку через route('password.reset'), которого в SPA нет — роут
// сброса объявлен только на фронте (Vue Router /reset/:token, name
// 'reset-password'; backend знает лишь Route::view('/reset','welcome')).
// Без этого toMail() бросает «Route [password.reset] not defined» → письмо
// со ссылкой не уходит никому, сброс пароля сломан для всех.
// Формат ссылки — ровно тот, что разбирает ResetPasswordView.vue:
// {app.url}/reset/{token}?email={urlencoded}.
ResetPassword::createUrlUsing(
fn (User $user, string $token): string => rtrim((string) config('app.url'), '/')
.'/reset/'.$token
.'?email='.urlencode($user->getEmailForPasswordReset()),
);
// P1 go-live: per-IP route-throttle поверх прикладного per-credential
// rate-limit в auth-контроллерах. Именованные лимитеры изолируют счётчики
// login / 2fa / password. Применение — throttle:<name> в routes/web.php.
// 20/мин — стартовое значение (выше максимума тестовых циклов), снижать по
// боевому трафику. Runbook: docs/superpowers/runbooks/2026-06-17-auth-throttle-limits.md
foreach (['auth-login', 'auth-2fa', 'auth-password', 'auth-register'] as $limiterName) {
RateLimiter::for(
$limiterName,
fn (Request $request) => Limit::perMinute(20)->by($request->ip() ?: 'unknown'),
);
}
// apiv1-rate (приёмка 21.06): публичный read-API сделок (/api/v1/deals)
// прикрыт per-источник лимитом 120/мин ПЕРЕД ApiKeyAuth — режет brute/DoS
// по ключам и снимает нагрузку bcrypt/DB до аутентификации. Ключ лимитера —
// сам Bearer-ключ (sha256, «per ключ»); без заголовка — fallback на IP.
// track (spec 2026-07-13): публичный маячок учёта посетителей — 60/мин с IP,
// защита от накрутки счётчика.
RateLimiter::for('track', fn (Request $request) => Limit::perMinute(60)->by($request->ip() ?: 'unknown'));
// sms-unsubscribe (строка листа 1.8): публичная страница отказа `/s/{token}`.
// Ссылка короткая и без авторизации — без лимита перебор токенов становится
// способом собрать чужие номера. 20/мин с IP: живому человеку хватает с запасом.
RateLimiter::for('sms-unsubscribe', fn (Request $request) => Limit::perMinute(20)->by($request->ip() ?: 'unknown'));
RateLimiter::for('api-v1', function (Request $request) {
$header = (string) $request->header('Authorization', '');
$bearer = str_starts_with($header, 'Bearer ')
? trim(substr($header, 7))
: '';
$by = $bearer !== ''
? 'k:'.hash('sha256', $bearer)
: 'ip:'.($request->ip() ?: 'unknown');
return Limit::perMinute(120)->by($by);
});
// G7-B: заменяем Sanctum PersonalAccessToken на расширение, которое
// возвращает null для lpimp_-токенов без запроса к personal_access_tokens.
// Проект использует SPA cookie-auth — таблица personal_access_tokens отсутствует.
Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class);
// G7-B: кастомный auth-guard «impersonation».
// По Bearer-токену вида lpimp_<id>_<secret> резолвит первого активного
// пользователя тенанта из impersonation_tokens.
// Запросы к БД идут через pgsql_supplier (BYPASSRLS), чтобы не упереться
// в RLS до SetTenantContext (middleware «tenant» применяется позже).
Auth::viaRequest('impersonation', function (Request $request) {
$header = (string) $request->header('Authorization', '');
if (! str_starts_with($header, 'Bearer lpimp_')) {
return null;
}
$raw = trim(substr($header, 7)); // lpimp_<id>_<secret>
$parts = explode('_', $raw, 3);
if (count($parts) !== 3 || $parts[0] !== 'lpimp' || ! ctype_digit($parts[1])) {
return null;
}
$idStr = $parts[1];
$secret = $parts[2];
$token = ImpersonationToken::on('pgsql_supplier')->find((int) $idStr);
if ($token === null || $token->session_token_hash === null || ! $token->isSessionActive()) {
return null;
}
if (! Hash::check($secret, (string) $token->session_token_hash)) {
return null;
}
return User::on('pgsql_supplier')
->where('tenant_id', $token->tenant_id)
->where('is_active', true)
->orderBy('id')
->first();
});
// Инцидент 24.07.2026: гвард портала отдела продаж — ТОЛЬКО по Bearer-токену.
// Штатный Sanctum-гвард перед проверкой токена смотрит config('sanctum.guard')
// = ['web'] и, если на stateful-домене lk.liderra.ru в том же браузере открыт
// обычный кабинет (активная web-сессия), возвращает App\Models\User вместо
// SalesUser — даже не заглянув в токен. Это ломало ВЕСЬ портал (500 на всех
// маршрутах, роль слетала в «менеджера»). Свой резолвер в сессию не смотрит:
// сразу проверяет токен и пускает только SalesUser. Проверки повторяют
// Laravel\Sanctum\Guard (срок жизни, expires_at, отметка last_used_at).
// Чтение personal_access_tokens — на дефолтном соединении, которое middleware
// 'admin-db' (crm_admin_user) уже переключил ДО auth:sales — как и раньше.
Auth::viaRequest('sales-token', function (Request $request) {
$bearer = $request->bearerToken();
if ($bearer === null || $bearer === '') {
return null;
}
$accessToken = PersonalAccessToken::findToken($bearer);
if ($accessToken === null) {
return null;
}
$tokenable = $accessToken->tokenable;
if (! $tokenable instanceof SalesUser) {
return null; // токен не портала продаж — этому гварду не подходит
}
$expiration = config('sanctum.expiration');
if (is_int($expiration) && $accessToken->created_at !== null
&& $accessToken->created_at->lte(now()->subMinutes($expiration))) {
return null;
}
if ($accessToken->expires_at !== null && $accessToken->expires_at->isPast()) {
return null;
}
$accessToken->forceFill(['last_used_at' => now()])->save();
return $tokenable->withAccessToken($accessToken);
});
}
}