Реалізація генерації унікальних посилань на завантаження
Унікальне посилання — основний механізм контролю доступу до цифрових товарів. На відміну від прямого посилання на файл, унікальний токен не дозволяє угадати 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]
);
// Перевірка в контролері — автоматично через 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 дні.







