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>
This commit is contained in:
Дмитрий
2026-07-27 17:58:56 +03:00
parent 3531b3a0cc
commit 7aa3083399
11 changed files with 519 additions and 0 deletions
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\ClientSmsOptout;
use App\Models\ClientSmsUnsubscribeLink;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Страница отказа от рассылки по короткой ссылке из СМС (строки листа 1.61.8).
*
* Без авторизации: человек, получивший СМС, не наш пользователь и войти не
* может. Отсюда три правила, каждое обязательное:
* · номер показывается только маской страницу открывает кто угодно;
* · ограничение частоты иначе перебор ссылок превращается в утечку номеров;
* · неизвестная ссылка отвечает 404 без подсказок, существует она или нет.
*
* Соединение служебное (`pgsql_supplier`): тенант-контекста здесь нет и быть не
* может мы узнаём клиента ИЗ ссылки, а не наоборот, поэтому изоляция по
* тенанту сработала бы как «строк нет». Обход изоляции сознательно заперт в этом
* одном классе, а сам tenant_id всё равно проставляется явно.
*/
final class PublicUnsubscribeController extends Controller
{
private const CONNECTION = 'pgsql_supplier';
public function show(string $token, Request $request): View
{
$link = $this->link($token);
return view('unsubscribe', [
'token' => $token,
'masked' => $this->mask($link->phone),
'done' => $request->boolean('done'),
]);
}
public function store(string $token): RedirectResponse
{
$link = $this->link($token);
ClientSmsOptout::on(self::CONNECTION)->firstOrCreate(
['tenant_id' => $link->tenant_id, 'phone' => $link->phone],
['source' => ClientSmsOptout::SOURCE_RECIPIENT],
);
return redirect("/s/{$token}?done=1");
}
private function link(string $token): ClientSmsUnsubscribeLink
{
$link = ClientSmsUnsubscribeLink::on(self::CONNECTION)->whereKey($token)->first();
if ($link === null) {
throw new NotFoundHttpException;
}
return $link;
}
/** 79990000067 → «+7 *** *** ** 67» */
private function mask(string $phone): string
{
return '+7 *** *** ** '.substr($phone, -2);
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Короткая ссылка отказа из СМС: токен пара «клиент + номер».
*
* Соединение обычное. Изоляция по тенанту живёт на таблице (RLS), и всё, что
* создаёт ссылки, работает внутри тенант-контекста. Публичная страница
* `/s/{token}` контекста не имеет она одна читает через служебное соединение
* явным `->on(...)`, чтобы обход изоляции был виден в одном месте, а не был
* молча включён для всего модуля.
*
* Источник: db/schema.sql, table `client_sms_unsubscribe_links`.
*/
class ClientSmsUnsubscribeLink extends Model
{
protected $table = 'client_sms_unsubscribe_links';
protected $primaryKey = 'token';
public $incrementing = false;
protected $keyType = 'string';
public $timestamps = false;
protected $fillable = [
'token',
'tenant_id',
'phone',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
'created_at' => 'immutable_datetime',
];
}
}
+5
View File
@@ -207,6 +207,11 @@ class AppServiceProvider extends ServiceProvider
// защита от накрутки счётчика.
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 ')
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Services\ClientSms;
use App\Models\ClientSmsUnsubscribeLink;
/**
* Токен страницы отказа. Один и тот же на пару «клиент + номер» иначе при
* каждой рассылке одному человеку уезжала бы новая ссылка (строка листа 1.9).
*
* В алфавите нет 0/O/o и 1/l/I: ссылку диктуют вслух и переписывают руками,
* а перепутанная буква чужая страница отказа.
*/
final class ClientSmsUnsubscribeLinkService
{
private const ALPHABET = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
private const LENGTH = 12;
public function tokenFor(int $tenantId, string $phone): string
{
$existing = ClientSmsUnsubscribeLink::query()
->where('tenant_id', $tenantId)
->where('phone', $phone)
->value('token');
if ($existing !== null) {
return (string) $existing;
}
do {
$token = $this->randomToken();
} while (ClientSmsUnsubscribeLink::whereKey($token)->exists());
ClientSmsUnsubscribeLink::create([
'token' => $token,
'tenant_id' => $tenantId,
'phone' => $phone,
]);
return $token;
}
private function randomToken(): string
{
$out = '';
$last = strlen(self::ALPHABET) - 1;
for ($i = 0; $i < self::LENGTH; $i++) {
$out .= self::ALPHABET[random_int(0, $last)];
}
return $out;
}
}
@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Строка листа 1.9: короткая ссылка отказа из СМС. Одна и та же на пару
* «клиент + номер» (unique), иначе одному человеку при каждой рассылке уезжала
* бы новая ссылка, а старые копились бы вечно.
*
* RLS включён как у тенантского стоп-листа: в таблице лежит телефон живого
* человека. Публичная страница `/s/{token}` читает строку БЕЗ тенант-контекста,
* поэтому она (и только она) ходит служебной ролью отсюда GRANT для
* crm_supplier_worker. Без него страница получит отказ доступа.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('client_sms_unsubscribe_links', function (Blueprint $table) {
$table->string('token', 12)->primary();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->string('phone', 11);
$table->timestampTz('created_at')->useCurrent();
$table->unique(['tenant_id', 'phone']);
});
DB::statement('ALTER TABLE client_sms_unsubscribe_links ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE client_sms_unsubscribe_links FORCE ROW LEVEL SECURITY');
DB::statement('DROP POLICY IF EXISTS tenant_isolation ON client_sms_unsubscribe_links');
DB::statement("CREATE POLICY tenant_isolation ON client_sms_unsubscribe_links USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
DB::statement('GRANT SELECT, INSERT ON client_sms_unsubscribe_links TO crm_app_user');
DB::statement('GRANT SELECT ON client_sms_unsubscribe_links TO crm_supplier_worker');
}
public function down(): void
{
Schema::dropIfExists('client_sms_unsubscribe_links');
}
};
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Строка листа 1.6: публичная страница отказа `/s/{token}` пишет номер в стоп-лист
* клиента, не имея тенант-контекста она узнаёт клиента ИЗ ссылки. Поэтому ходит
* служебной ролью (`pgsql_supplier` = crm_supplier_worker, политика srv_bypass).
*
* Прав на `client_sms_optouts` у этой роли не было: таблицу заводили под клиентский
* кабинет, где роль всегда crm_app_user. Без этого GRANT'а страница отказа падала бы
* отказом доступа то есть человек, попросивший больше не писать, не был бы услышан.
*
* ⚠️ При выкате на управляемый кластер после накатки миграций ПЕРЕзапустить
* `db/03_service_bypass_policies.sql`: политика srv_bypass выдаётся скриптом по
* списку таблиц с RLS, и новая `client_sms_unsubscribe_links` без него останется
* без cross-tenant доступа служебных ролей (тихий ноль, а не ошибка).
*/
return new class extends Migration
{
public function up(): void
{
DB::statement('GRANT SELECT, INSERT ON client_sms_optouts TO crm_supplier_worker');
DB::statement('GRANT USAGE, SELECT ON SEQUENCE client_sms_optouts_id_seq TO crm_supplier_worker');
}
public function down(): void
{
DB::statement('REVOKE SELECT, INSERT ON client_sms_optouts FROM crm_supplier_worker');
DB::statement('REVOKE USAGE, SELECT ON SEQUENCE client_sms_optouts_id_seq FROM crm_supplier_worker');
}
};
+84
View File
@@ -0,0 +1,84 @@
{{-- Страница отказа от СМС-рассылки (строки листа 1.6, 1.7). Открывает человек
с телефона, по ссылке из СМС: без входа, без Vue-сборки, одна кнопка.
Номер только маской: страницу может открыть кто угодно. --}}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Отказ от СМС-рассылки</title>
<style>
body {
margin: 0;
padding: 32px 20px;
background: #F6F3EC;
color: #012019;
font: 16px/1.5 -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
}
main {
max-width: 420px;
margin: 0 auto;
background: #fff;
border-radius: 16px;
padding: 28px 24px;
box-shadow: 0 2px 16px rgba(1, 32, 25, .08);
}
h1 {
margin: 0 0 16px;
font-size: 22px;
line-height: 1.3;
}
.phone {
font-family: ui-monospace, "JetBrains Mono", Consolas, monospace;
font-size: 18px;
margin: 0 0 24px;
}
button {
width: 100%;
padding: 14px 20px;
font-size: 17px;
color: #fff;
background: #0F6E56;
border: 0;
border-radius: 10px;
cursor: pointer;
}
button:hover {
background: #0c5844;
}
.done {
margin: 0;
font-size: 18px;
}
.note {
margin: 20px 0 0;
font-size: 14px;
color: #4a5c57;
}
</style>
</head>
<body>
<main>
@if ($done)
<h1>Готово</h1>
<p class="done">Больше не напишем на номер {{ $masked }}.</p>
@else
<h1>Отказ от СМС-рассылки</h1>
<p class="phone">Номер {{ $masked }}</p>
<form method="POST" action="/s/{{ $token }}">
@csrf
<button type="submit">Больше не писать</button>
</form>
<p class="note">Отказ действует только на рассылки той компании, от которой пришло сообщение.</p>
@endif
</main>
</body>
</html>
+10
View File
@@ -665,6 +665,16 @@ Route::get('/kak-eto-rabotaet', function () {
return response()->file($page, ['Content-Type' => 'text/html; charset=utf-8']);
});
// Публичная страница отказа от СМС-рассылки (строки листа 1.6–1.8). БЕЗ авторизации:
// получатель СМС — не наш пользователь. Ограничение частоты обязательно — перебор
// ссылок иначе превращается в утечку номеров. Регистрируется до fallback'а.
Route::middleware('throttle:sms-unsubscribe')->group(function () {
Route::get('/s/{token}', 'App\Http\Controllers\PublicUnsubscribeController@show')
->where('token', '[A-Za-z0-9]{12}');
Route::post('/s/{token}', 'App\Http\Controllers\PublicUnsubscribeController@store')
->where('token', '[A-Za-z0-9]{12}');
});
// Fallback для всех неизвестных путей — Vue Router catch-all отрисует 404.
// Срабатывает ПОСЛЕ всех явных route'ов выше и runtime-route'ов от Pest
// beforeEach (они регистрируются в момент теста, до запроса).
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
use App\Models\ClientSmsOptout;
use App\Models\Tenant;
use App\Services\ClientSms\ClientSmsUnsubscribeLinkService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\RateLimiter;
use Tests\Concerns\SharesSupplierPdo;
/**
* Строки листа 1.6, 1.7, 1.8: страница отказа по короткой ссылке из СМС.
*
* Первый маршрут модуля БЕЗ авторизации: получатель СМС не наш пользователь.
* Отсюда три требования, каждое проверяется отдельно: номер только маской,
* перебор токенов упирается в ограничение частоты, неизвестный токен молчит.
*
* Синтетические номера 7999 реальные НИКОГДА.
*/
// Страница ходит служебным соединением (тенант-контекста у неё нет) — в тестах
// оба соединения обязаны видеть одну транзакцию, иначе засеянная ссылка «не найдена».
uses(RefreshDatabase::class, SharesSupplierPdo::class);
beforeEach(function () {
RateLimiter::clear('sms-unsubscribe');
$this->tenant = Tenant::factory()->create();
$this->token = app(ClientSmsUnsubscribeLinkService::class)
->tokenFor($this->tenant->id, '79990000067');
});
it('человек по ссылке отказывается — и попадает в стоп-лист того клиента', function () {
$this->post("/s/{$this->token}")->assertRedirect();
$row = ClientSmsOptout::where('tenant_id', $this->tenant->id)
->where('phone', '79990000067')
->first();
expect($row)->not->toBeNull()
->and($row->source)->toBe(ClientSmsOptout::SOURCE_RECIPIENT);
});
it('повторный отказ по той же ссылке не ломается', function () {
$this->post("/s/{$this->token}")->assertRedirect();
$this->post("/s/{$this->token}")->assertRedirect();
expect(ClientSmsOptout::where('tenant_id', $this->tenant->id)->count())->toBe(1);
});
it('страница не показывает номер целиком — только маской', function () {
$this->get("/s/{$this->token}")
->assertOk()
->assertDontSee('79990000067')
->assertSee('** 67', false);
});
it('неизвестный токен не выдаёт, существует он или нет', function () {
$this->get('/s/zzzzzzzzzzzz')->assertNotFound();
});
it('перебор ссылок упирается в ограничение частоты', function () {
$status = 200;
foreach (range(1, 30) as $i) {
$status = $this->get('/s/aaaaaaaaaaaa')->status();
if ($status === 429) {
break;
}
}
expect($status)->toBe(429);
});
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
use App\Models\ClientSmsUnsubscribeLink;
use App\Models\Tenant;
use App\Services\ClientSms\ClientSmsUnsubscribeLinkService;
use Illuminate\Foundation\Testing\RefreshDatabase;
/**
* Строка листа 1.9: ссылка на один и тот же номер у одного клиента всегда одна
* и та же. Иначе при каждой рассылке одному человеку уезжала бы новая ссылка,
* а старые копились бы вечно.
*
* Синтетические номера 7999 реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->otherTenant = Tenant::factory()->create();
$this->service = app(ClientSmsUnsubscribeLinkService::class);
});
it('токен на пару «клиент + номер» постоянный', function () {
$first = $this->service->tokenFor($this->tenant->id, '79990000001');
$second = $this->service->tokenFor($this->tenant->id, '79990000001');
expect($second)->toBe($first)
->and(strlen($first))->toBe(12)
->and(ClientSmsUnsubscribeLink::count())->toBe(1);
});
it('у разных клиентов на один номер разные ссылки', function () {
$mine = $this->service->tokenFor($this->tenant->id, '79990000001');
$theirs = $this->service->tokenFor($this->otherTenant->id, '79990000001');
expect($theirs)->not->toBe($mine)
->and(ClientSmsUnsubscribeLink::count())->toBe(2);
});
it('по токену находится тот самый клиент и тот самый номер', function () {
$token = $this->service->tokenFor($this->tenant->id, '79990000002');
$link = ClientSmsUnsubscribeLink::findOrFail($token);
expect($link->tenant_id)->toBe($this->tenant->id)
->and($link->phone)->toBe('79990000002');
});
it('в токене нет букв и цифр, которые путаются при чтении вслух', function () {
$tokens = [];
foreach (range(1, 20) as $i) {
$tokens[] = $this->service->tokenFor($this->tenant->id, '7999000'.str_pad((string) $i, 4, '0', STR_PAD_LEFT));
}
// 0/O/o, 1/l/I — человек диктует ссылку по телефону, путаница = чужая страница
expect(implode('', $tokens))->not->toMatch('/[0OolI1]/');
});
+38
View File
@@ -8,6 +8,44 @@
> параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор),
> поэтому при сведении они перенумерованы. Содержание не менялось.
## v9.02 (2026-07-30) — Клиентская СМС, Этап 1: короткие ссылки отказа `client_sms_unsubscribe_links` + право служебной роли на стоп-лист
Две миграции:
**1. `app/database/migrations/2026_07_30_100200_create_client_sms_unsubscribe_links.php`**
— новая таблица `client_sms_unsubscribe_links`:
- `token` varchar(12) **PRIMARY KEY** — то, что видно в СМС после `liderra.ru/s/`;
- `tenant_id` bigint FK `tenants` ON DELETE CASCADE;
- `phone` varchar(11);
- `created_at` timestamptz DEFAULT now();
- `UNIQUE (tenant_id, phone)` — одна ссылка на пару «клиент + номер» навсегда.
RLS **включён** (`ENABLE` + `FORCE`) с политикой `tenant_isolation` — в таблице
лежит телефон живого человека. GRANT'ы: `crm_app_user``SELECT, INSERT`
(создание ссылок идёт из тенант-контекста), `crm_supplier_worker``SELECT`.
**2. `app/database/migrations/2026_07_30_100250_grant_optouts_to_supplier_worker.php`**
`crm_supplier_worker` получает `SELECT, INSERT` на `client_sms_optouts`
и `USAGE, SELECT` на её последовательность.
**Зачем служебная роль.** Публичная страница `/s/{token}` открывается без входа
в систему: получатель СМС — не наш пользователь. Клиента она узнаёт ИЗ ссылки,
то есть тенант-контекста в момент запроса нет и быть не может — под обычной
ролью политика изоляции вернула бы «строк нет», а запись отказа получила бы
отказ доступа. Обход изоляции заперт в одном классе
(`PublicUnsubscribeController`, явный `->on('pgsql_supplier')`), а не включён
для всего модуля; `tenant_id` при этом проставляется явно.
⚠️ **При выкате на управляемый кластер** после накатки миграций ПЕРЕзапустить
`db/03_service_bypass_policies.sql` — политика `srv_bypass` выдаётся скриптом по
списку таблиц с RLS, и новая `client_sms_unsubscribe_links` без этого останется
без cross-tenant доступа служебных ролей. Симптом — тихий ноль (страница отказа
скажет «ссылка не найдена» на живую ссылку), а не ошибка.
Ветка `feat/client-sms-broadcast`, в main не влита. Номер записи при сведении
может быть перенумерован.
## v9.01 (2026-07-30) — Клиентская СМС, Этап 1: у отказа появился источник и комментарий (`client_sms_optouts`)
Миграция `app/database/migrations/2026_07_30_100100_add_source_to_client_sms_optouts.php`