a26f5af2da
Code-quality review of Task 3: index() filtered by is_active only — an expired-but-active key would be listed as valid. Adds an expires_at > now() filter plus a test. Cannot occur today (regenerate is the only write path, always +1 year) but is the correct semantic contract for an «active key» listing. phpstan-baseline.neon: count bumps only for ApiKeyControllerTest.php ($tenant 5→7, $user 3→5, getJson 3→4). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
74 lines
2.5 KiB
PHP
74 lines
2.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\ApiKey;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
/**
|
|
* API-ключи тенанта (audit D2/D3/J5). Endpoints под auth:sanctum + tenant.
|
|
*
|
|
* Полный ключ показывается ОДИН раз — в ответе regenerate(). В БД хранится
|
|
* только bcrypt key_hash + key_prefix (первые 10 символов для UI). У тенанта
|
|
* поддерживается один активный ключ: regenerate деактивирует прежние.
|
|
*/
|
|
class ApiKeyController extends Controller
|
|
{
|
|
private const KEY_PREFIX = 'lpkapi_';
|
|
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
$tenantId = (int) $request->user()->tenant_id;
|
|
|
|
// Defense-in-depth: явный where даже при RLS — в тестах PG superuser BYPASSRLS.
|
|
$keys = ApiKey::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('is_active', true)
|
|
->where('expires_at', '>', now())
|
|
->orderByDesc('created_at')
|
|
->get(['id', 'name', 'key_prefix', 'last_used_at', 'expires_at', 'created_at']);
|
|
|
|
return response()->json(['data' => $keys]);
|
|
}
|
|
|
|
public function regenerate(Request $request): JsonResponse
|
|
{
|
|
$tenantId = (int) $request->user()->tenant_id;
|
|
$userId = (int) $request->user()->id;
|
|
|
|
// Один активный ключ на тенанта — прежние деактивируются.
|
|
ApiKey::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('is_active', true)
|
|
->update(['is_active' => false]);
|
|
|
|
$plainKey = self::KEY_PREFIX.Str::random(48);
|
|
|
|
$key = ApiKey::query()->create([
|
|
'tenant_id' => $tenantId,
|
|
'user_id' => $userId,
|
|
'name' => 'API-ключ',
|
|
'key_hash' => Hash::make($plainKey),
|
|
'key_prefix' => substr($plainKey, 0, 10),
|
|
'scopes' => ['read'],
|
|
'expires_at' => now()->addYear(),
|
|
'is_active' => true,
|
|
'created_at' => now(),
|
|
]);
|
|
|
|
return response()->json([
|
|
'id' => $key->id,
|
|
'name' => $key->name,
|
|
'key' => $plainKey,
|
|
'key_prefix' => $key->key_prefix,
|
|
], Response::HTTP_CREATED);
|
|
}
|
|
}
|