8fc63d5782
Owner-decision: register не должен падать 500 при сбое SMTP — код уже создан, клиент может «отправить повторно». Mail::queue + try/catch + Log (без email — ПДн). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
265 lines
11 KiB
PHP
265 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Auth;
|
|
|
|
use App\Mail\EmailVerificationCodeMail;
|
|
use App\Models\EmailVerification;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\Captcha\CaptchaVerifier;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* Оркестрация самозаписи (G1/SP1): register / confirm / resend.
|
|
*
|
|
* Все обращения к tenants/users/email_verifications — через BYPASSRLS-подключение
|
|
* pgsql_supplier: публичные роуты не выставляют app.current_tenant_id, и под RLS
|
|
* (роль crm_app_user) SELECT/INSERT по этим таблицам не прошёл бы.
|
|
*
|
|
* Гонка дублей: в схеме нет глобального UNIQUE(users.email) (только
|
|
* UNIQUE(tenant_id,email)), поэтому «проверка-потом-вставка» сериализуется
|
|
* advisory-локом по email внутри транзакции — два параллельных register на один
|
|
* новый email не создадут два тенанта (лок снимается на commit/rollback).
|
|
*/
|
|
class RegistrationService
|
|
{
|
|
private const DB_CONNECTION = 'pgsql_supplier';
|
|
|
|
private const CODE_TTL_MINUTES = 15;
|
|
|
|
private const MAX_FAILED_ATTEMPTS = 5;
|
|
|
|
private const START_BALANCE_RUB = '300.00';
|
|
|
|
public function __construct(private readonly CaptchaVerifier $captcha) {}
|
|
|
|
/**
|
|
* @return array{status:string, user:User, verification:EmailVerification, dev_code:?string}
|
|
*/
|
|
public function register(string $email, string $password, ?string $captchaToken, ?string $ip): array
|
|
{
|
|
if (! $this->captcha->verify($captchaToken, $ip)) {
|
|
throw new RegistrationException('captcha_failed');
|
|
}
|
|
|
|
$email = mb_strtolower(trim($email));
|
|
$conn = DB::connection(self::DB_CONNECTION);
|
|
|
|
// Сериализация одновременных регистраций одного email (TOCTOU-защита, см. docblock).
|
|
// Письмо отправляем ПОСЛЕ commit — не держим SMTP внутри транзакции.
|
|
$issued = $this->atomic(function () use ($conn, $email, $password) {
|
|
$conn->statement('SELECT pg_advisory_xact_lock(hashtext(?))', ['liderra:self-register:'.$email]);
|
|
|
|
$existing = User::on(self::DB_CONNECTION)->where('email', $email)->first();
|
|
if ($existing && $existing->is_active) {
|
|
throw new RegistrationException('email_taken');
|
|
}
|
|
|
|
$user = $existing ?: $this->createPendingTenantOwner($email, $password);
|
|
|
|
return $this->createCodeRecord($user);
|
|
});
|
|
|
|
$this->sendCode($issued['user']->email, $issued['plain']);
|
|
|
|
return [
|
|
'status' => 'pending_email_confirm',
|
|
'user' => $issued['user'],
|
|
'verification' => $issued['record'],
|
|
'dev_code' => $issued['dev_code'],
|
|
];
|
|
}
|
|
|
|
public function confirm(string $email, string $code): User
|
|
{
|
|
$email = mb_strtolower(trim($email));
|
|
$user = User::on(self::DB_CONNECTION)->where('email', $email)->first();
|
|
if (! $user) {
|
|
throw new RegistrationException('not_found');
|
|
}
|
|
|
|
$record = EmailVerification::on(self::DB_CONNECTION)
|
|
->where('user_id', $user->id)
|
|
->whereNull('verified_at')
|
|
->orderByDesc('id')
|
|
->first();
|
|
|
|
if (! $record || ! $record->isUsable()) {
|
|
$reason = $record === null ? 'not_found'
|
|
: ($record->isExpired() ? 'expired' : 'too_many_attempts');
|
|
throw new RegistrationException($reason);
|
|
}
|
|
|
|
if (! Hash::check($code, (string) $record->code_hash)) {
|
|
// increment ВНЕ транзакции: счётчик должен пережить 422 (откат сбросил
|
|
// бы failed_attempts и сломал лимит 5 попыток).
|
|
$record->increment('failed_attempts');
|
|
throw new RegistrationException(
|
|
'invalid_code',
|
|
max(0, self::MAX_FAILED_ATTEMPTS - $record->failed_attempts),
|
|
);
|
|
}
|
|
|
|
// Успех — атомарно: пометка кода + активация владельца + статус/баланс тенанта.
|
|
$this->atomic(function () use ($record, $user): void {
|
|
$record->update(['verified_at' => now()]);
|
|
$user->update(['is_active' => true]);
|
|
Tenant::on(self::DB_CONNECTION)->where('id', $user->tenant_id)->update([
|
|
'status' => 'active',
|
|
'balance_rub' => self::START_BALANCE_RUB,
|
|
]);
|
|
});
|
|
|
|
return $user->fresh();
|
|
}
|
|
|
|
/** @return ?string dev-код (только local/testing), иначе null. Anti-enumeration: тихо для active/missing. */
|
|
public function resend(string $email): ?string
|
|
{
|
|
$email = mb_strtolower(trim($email));
|
|
$conn = DB::connection(self::DB_CONNECTION);
|
|
|
|
$issued = $this->atomic(function () use ($conn, $email) {
|
|
$conn->statement('SELECT pg_advisory_xact_lock(hashtext(?))', ['liderra:self-register:'.$email]);
|
|
|
|
$user = User::on(self::DB_CONNECTION)->where('email', $email)->first();
|
|
if ($user && ! $user->is_active) {
|
|
return $this->createCodeRecord($user);
|
|
}
|
|
|
|
return null;
|
|
});
|
|
|
|
if ($issued === null) {
|
|
return null;
|
|
}
|
|
|
|
$this->sendCode($issued['user']->email, $issued['plain']);
|
|
|
|
return $issued['dev_code'];
|
|
}
|
|
|
|
/**
|
|
* Выполнить $work атомарно на pgsql_supplier.
|
|
*
|
|
* Прод-путь: соединение НЕ в транзакции → открываем свою (`transaction()`),
|
|
* advisory xact-lock держится до commit/rollback — корректная сериализация.
|
|
*
|
|
* Если PDO УЖЕ в транзакции (внешний caller обернул нас ИЛИ тест-харнес
|
|
* SharesSupplierPdo делит уже-открытый PDO под DatabaseTransactions) —
|
|
* участвуем в существующей транзакции без вложенного beginTransaction:
|
|
* pgsql_supplier-connection не отслеживает уровень внешней транзакции, и
|
|
* `transaction()` попытался бы `PDO::beginTransaction()` поверх открытой →
|
|
* «There is already an active transaction». Это nested-transaction-safety,
|
|
* не тест-специфичная ветка: повторный вызов внутри открытой транзакции
|
|
* корректно переиспользует её.
|
|
*
|
|
* @template T
|
|
*
|
|
* @param callable():T $work
|
|
* @return T
|
|
*/
|
|
private function atomic(callable $work): mixed
|
|
{
|
|
$conn = DB::connection(self::DB_CONNECTION);
|
|
|
|
if ($conn->getPdo()->inTransaction()) {
|
|
return $work();
|
|
}
|
|
|
|
return $conn->transaction($work);
|
|
}
|
|
|
|
private function createPendingTenantOwner(string $email, string $password): User
|
|
{
|
|
$tenant = Tenant::on(self::DB_CONNECTION)->create([
|
|
'subdomain' => $this->generateSubdomain($email),
|
|
'organization_name' => $email, // плейсхолдер; уточняется в SP2 (реквизиты)
|
|
'contact_email' => $email,
|
|
'balance_rub' => 0,
|
|
'is_trial' => true,
|
|
]);
|
|
// tenants.status НЕ в $fillable модели Tenant (колонка DEFAULT 'active') —
|
|
// выставляем явно, минуя mass-assignment; иначе самозапись активировала бы
|
|
// тенанта до подтверждения почты (баг: tenant создавался 'active').
|
|
$tenant->status = 'pending_email_confirm';
|
|
$tenant->save();
|
|
|
|
return User::on(self::DB_CONNECTION)->create([
|
|
'tenant_id' => $tenant->id,
|
|
'email' => $email,
|
|
'password_hash' => Hash::make($password),
|
|
'first_name' => 'Новый',
|
|
'last_name' => 'клиент',
|
|
'is_active' => false,
|
|
'totp_enabled' => false,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array{user:User, record:EmailVerification, plain:string, dev_code:?string}
|
|
*/
|
|
private function createCodeRecord(User $user): array
|
|
{
|
|
// Гасим прежние непогашенные коды этого пользователя (делаем неюзабельными).
|
|
EmailVerification::on(self::DB_CONNECTION)
|
|
->where('user_id', $user->id)
|
|
->whereNull('verified_at')
|
|
->update(['failed_attempts' => self::MAX_FAILED_ATTEMPTS]);
|
|
|
|
$plain = (string) random_int(100_000, 999_999);
|
|
|
|
$record = EmailVerification::on(self::DB_CONNECTION)->create([
|
|
'user_id' => $user->id,
|
|
'email' => $user->email,
|
|
'token' => (string) Str::uuid(),
|
|
'code_hash' => Hash::make($plain),
|
|
'failed_attempts' => 0,
|
|
'expires_at' => now()->addMinutes(self::CODE_TTL_MINUTES),
|
|
]);
|
|
|
|
return [
|
|
'user' => $user,
|
|
'record' => $record,
|
|
'plain' => $plain,
|
|
'dev_code' => app()->environment('local', 'testing') ? $plain : null,
|
|
];
|
|
}
|
|
|
|
private function sendCode(string $email, string $plain): void
|
|
{
|
|
// Письмо ставим в очередь (не держим SMTP в HTTP-пути) и НЕ валим самозапись
|
|
// при сбое доставки: запись кода уже создана, клиент может «отправить повторно».
|
|
// Email в лог не пишем (ПДн §5.2) — только факт и текст ошибки.
|
|
try {
|
|
Mail::to($email)->queue(new EmailVerificationCodeMail($plain, $email));
|
|
} catch (\Throwable $e) {
|
|
Log::warning('register: не удалось поставить письмо с кодом в очередь: '.$e->getMessage());
|
|
}
|
|
}
|
|
|
|
private function generateSubdomain(string $email): string
|
|
{
|
|
$base = Str::of($email)->before('@')->lower()->replaceMatches('/[^a-z0-9]/', '')->value();
|
|
if ($base === '') {
|
|
$base = 'client';
|
|
}
|
|
$base = Str::limit($base, 50, '');
|
|
|
|
$candidate = $base;
|
|
$i = 0;
|
|
while (Tenant::on(self::DB_CONNECTION)->where('subdomain', $candidate)->exists()) {
|
|
$i++;
|
|
$candidate = $base.$i;
|
|
}
|
|
|
|
return Str::limit($candidate, 63, '');
|
|
}
|
|
}
|