7aa3083399
Человек, получивший СМС, теперь может отказаться сам: короткая ссылка 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>
306 lines
15 KiB
PHP
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);
|
|
});
|
|
}
|
|
}
|