Реализация генерации уникальных ссылок на скачивание
Уникальная ссылка — основной механизм контроля доступа к цифровым товарам. В отличие от прямой ссылки на файл, уникальный токен не позволяет угадать URL другой покупки и привязывает скачивание к конкретной транзакции. Правильная реализация требует криптографически стойкого генератора, защиты от перебора и механизма ротации компрометированных ссылок.
Требования к токену
- Непредсказуемость — нельзя угадать или вывести из порядкового номера заказа
- Достаточная энтропия — минимум 128 бит (32 байта случайных данных → 64 символа hex)
-
Уникальность — в таблице
UNIQUE-ограничение с обработкой коллизий - Короткий URL — токен не должен быть длиннее 64–80 символов
Генерация токена
class DownloadTokenGenerator
{
/**
* Генерируем 32 случайных байта через CSPRNG и конвертируем в hex
* random_bytes() использует /dev/urandom на Linux, CryptGenRandom на Windows
*/
public function generate(): string
{
return bin2hex(random_bytes(32)); // 64 символа
}
/**
* Генерация с гарантией уникальности
*/
public function generateUnique(int $maxAttempts = 5): string
{
for ($i = 0; $i < $maxAttempts; $i++) {
$token = $this->generate();
if (!DigitalOrderDownload::where('token', $token)->exists()) {
return $token;
}
}
// Коллизия 5 раз подряд при 64-символьном hex — статистически невозможна,
// но обрабатываем как hard error
throw new TokenGenerationFailedException('Не удалось сгенерировать уникальный токен');
}
}
Короткие токены (опционально)
Если нужны читаемые ссылки (для SMS, QR-кодов), используется Base62:
class ShortTokenGenerator
{
private const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
/**
* 16 символов Base62 = ~95 бит энтропии — достаточно для ссылок скачивания
*/
public function generate(int $length = 16): string
{
$token = '';
$bytes = random_bytes($length);
for ($i = 0; $i < $length; $i++) {
$token .= self::ALPHABET[ord($bytes[$i]) % 62];
}
return $token;
}
}
Структура URL
https://example.com/dl/a3f8c92b1d4e7f0a2b5c8d9e1f3g4h5j
Короткий путь /dl/ — не /download/ — чтобы не раскрывать логику системы. Токен в пути, не в query string — query string логируется в access_log и может попасть в рефереры.
// routes/web.php
Route::get('/dl/{token}', [DigitalDownloadController::class, 'show'])
->name('digital.download.show')
->middleware(['throttle:30,1']); // максимум 30 запросов в минуту
Route::get('/dl/{token}/get', [DigitalDownloadController::class, 'download'])
->name('digital.download.get')
->middleware(['throttle:10,1']); // реальное скачивание — жёстче
Rate limiting против перебора
// app/Http/Middleware/ThrottleDownloadTokens.php
class ThrottleDownloadAttempts
{
public function handle(Request $request, Closure $next): Response
{
$key = 'download-attempt:' . $request->ip();
if (RateLimiter::tooManyAttempts($key, 20)) {
// Слишком много попыток с одного IP
abort(429, 'Слишком много запросов. Попробуйте позже.');
}
RateLimiter::hit($key, 60); // окно 1 минута
$response = $next($request);
// Неверный токен (404) тоже считается попыткой
if ($response->getStatusCode() === 404) {
RateLimiter::hit('invalid-token:' . $request->ip(), 3600);
if (RateLimiter::tooManyAttempts('invalid-token:' . $request->ip(), 10)) {
// Более 10 несуществующих токенов с одного IP за час — блокируем
app(IpBlocklistService::class)->block($request->ip(), reason: 'token_bruteforce');
}
}
return $response;
}
}
Ротация скомпрометированной ссылки
Если покупатель сообщает, что ссылка была опубликована или украдена:
class RotateDownloadTokenAction
{
public function execute(DigitalOrderDownload $download, string $reason): DigitalOrderDownload
{
// Инвалидируем старый токен
$download->update(['is_revoked' => true]);
// Логируем причину
DownloadTokenRotationLog::create([
'original_token' => $download->token,
'reason' => $reason,
'rotated_at' => now(),
'rotated_by' => auth()->id(),
]);
// Создаём новый токен с теми же параметрами
$newDownload = DigitalOrderDownload::create([
'order_item_id' => $download->order_item_id,
'digital_product_id' => $download->digital_product_id,
'token' => app(DownloadTokenGenerator::class)->generateUnique(),
'downloads_count' => 0, // счётчик сбрасывается
'downloads_limit' => $download->downloads_limit,
'expires_at' => $download->expires_at,
]);
// Отправляем новую ссылку
Mail::to($download->orderItem->order->email)
->send(new DownloadTokenRotatedMail($newDownload));
return $newDownload;
}
}
Подписанные URL (альтернативный подход)
Вместо хранения токена в БД можно использовать HMAC-подписанные URL. Преимущество — не нужна запись в БД для каждой покупки. Недостаток — нельзя инвалидировать отдельный токен без ключа.
// Генерация подписанного URL с истечением
$url = URL::temporarySignedRoute(
'digital.download.get',
now()->addDays(30),
['order_item_id' => $item->id]
);
// Проверка в контроллере — автоматически через middleware SignedMiddleware
Route::get('/dl/signed/{order_item_id}', [DigitalDownloadController::class, 'signedDownload'])
->name('digital.download.get')
->middleware('signed');
Подписанные URL используются, когда нужно раздать доступ сразу многим пользователям (например, корпоративная лицензия на 100 сотрудников) без создания 100 записей в БД.
Мониторинг аномалий
// Алерт: один токен скачивается с множества IP
$suspiciousDownloads = DownloadEvent::selectRaw('
digital_order_download_id,
COUNT(DISTINCT ip_address) as unique_ips,
COUNT(*) as total_downloads
')
->where('downloaded_at', '>', now()->subHours(24))
->groupBy('digital_order_download_id')
->having('unique_ips', '>', 5)
->get();
Сроки
Базовая генерация токенов + защита от перебора — 1–2 рабочих дня. Ротация токенов, мониторинг аномалий, подписанные URL как альтернативный механизм — ещё 2 дня.







