316bc2d852
Убраны письма manual_required и failover_to_form (по проекту, без троттла) —
источник потопа ~116 писем на ops@ 07.07. Строка в ручной очереди остаётся
(запись видна в админке). Зависимость Mailer удалена из канала и DI-привязки.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 72818f5a80)
174 lines
8.6 KiB
PHP
174 lines
8.6 KiB
PHP
<?php
|
|
|
|
namespace App\Providers;
|
|
|
|
use App\Models\ImpersonationToken;
|
|
use App\Models\PersonalAccessToken;
|
|
use App\Models\User;
|
|
use App\Services\Billing\Gateway\PaymentGatewayDriver;
|
|
use App\Services\Billing\Gateway\PaymentGatewayManager;
|
|
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\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,
|
|
);
|
|
|
|
// Резерв канала миграции проектов: 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);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Bootstrap any application services.
|
|
*/
|
|
public function boot(): void
|
|
{
|
|
// 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.
|
|
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();
|
|
});
|
|
}
|
|
}
|