1fd6f7f597
- A2: impersonation _dev_plain_code в ответе init только в local/testing - A3: X-Tenant-Id принимается только в local/testing (anti-spoof тенанта) - B3: WebhookReceiveController isHmacRequired() default false→true (fail-secure) - C2: SupplierWebhookController per-IP rate-limit 600/min (DoS-guard) - WebhookReceiveTest обновлён под B3 (отсутствие настройки → 401) Tests: 70/70 passed (323 assertions) — Webhook/Impersonation/Tenant suites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
227 lines
9.1 KiB
PHP
227 lines
9.1 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Http\Controllers\Api;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
use App\Models\ImpersonationToken;
|
||
use App\Models\Tenant;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Hash;
|
||
|
||
/**
|
||
* SaaS-admin impersonation flow (ТЗ §22.7 / Ю-1).
|
||
*
|
||
* Flow:
|
||
* 1. Admin → POST /api/admin/impersonation/init {tenant_id, reason}.
|
||
* Backend генерирует 6-значный код, сохраняет hash в impersonation_tokens,
|
||
* отправляет plain код на tenant.contact_email. TTL 15 минут.
|
||
* 2. Admin вводит код в админке → POST /api/admin/impersonation/verify {token_id, code}.
|
||
* Backend проверяет hash, expiry, attempts. На success — token.used_at = NOW(),
|
||
* возвращает session_id (создаётся отдельно — отдельный коммит).
|
||
* 3. Admin завершает сессию → POST /api/admin/impersonation/end {token_id}.
|
||
* Backend ставит token.session_ended_at, отправляет email клиенту.
|
||
*
|
||
* NB: на MVP saas_admin_users auth не реализован, `requested_by` принимается
|
||
* параметром. Production: middleware('auth:saas-admin') + auth()->id().
|
||
*
|
||
* Two-person approval (CTO-15 / Ю-9): для тенантов с
|
||
* `pd_subject_request.processing_restricted=TRUE` ИЛИ
|
||
* `chargeback_unrecovered_rub > 0` требуется second_approver_id роли compliance.
|
||
* На MVP не реализовано — отдельный коммит после SaaS-admin auth.
|
||
*/
|
||
class ImpersonationController extends Controller
|
||
{
|
||
private const TOKEN_TTL_MINUTES = 15;
|
||
|
||
private const MAX_FAILED_ATTEMPTS = 5;
|
||
|
||
/** GET /api/admin/impersonation/active — активные сессии (used_at != null AND session_ended_at == null) */
|
||
public function active(): JsonResponse
|
||
{
|
||
$rows = ImpersonationToken::query()
|
||
->whereNotNull('used_at')
|
||
->whereNull('session_ended_at')
|
||
->with(['tenant'])
|
||
->orderByDesc('used_at')
|
||
->limit(100)
|
||
->get(['id', 'tenant_id', 'requested_by', 'reason', 'sent_to_email', 'used_at', 'expires_at']);
|
||
|
||
return response()->json([
|
||
'sessions' => $rows->map(fn (ImpersonationToken $t) => [
|
||
'token_id' => $t->id,
|
||
'tenant_id' => $t->tenant_id,
|
||
'tenant_name' => $t->tenant?->organization_name,
|
||
'requested_by' => $t->requested_by,
|
||
'reason' => $t->reason,
|
||
'sent_to_email' => $t->sent_to_email,
|
||
'used_at' => $t->used_at?->toIso8601String(),
|
||
'expires_at' => $t->expires_at->toIso8601String(),
|
||
]),
|
||
]);
|
||
}
|
||
|
||
/** GET /api/admin/impersonation/recent — последние 20 завершённых */
|
||
public function recent(): JsonResponse
|
||
{
|
||
$rows = ImpersonationToken::query()
|
||
->whereNotNull('used_at')
|
||
->whereNotNull('session_ended_at')
|
||
->with(['tenant'])
|
||
->orderByDesc('session_ended_at')
|
||
->limit(20)
|
||
->get(['id', 'tenant_id', 'requested_by', 'reason', 'used_at', 'session_ended_at']);
|
||
|
||
return response()->json([
|
||
'sessions' => $rows->map(fn (ImpersonationToken $t) => [
|
||
'token_id' => $t->id,
|
||
'tenant_id' => $t->tenant_id,
|
||
'tenant_name' => $t->tenant?->organization_name,
|
||
'requested_by' => $t->requested_by,
|
||
'reason' => $t->reason,
|
||
'used_at' => $t->used_at?->toIso8601String(),
|
||
'session_ended_at' => $t->session_ended_at?->toIso8601String(),
|
||
'duration_seconds' => $t->used_at && $t->session_ended_at
|
||
? abs($t->session_ended_at->diffInSeconds($t->used_at))
|
||
: null,
|
||
]),
|
||
]);
|
||
}
|
||
|
||
/** POST /api/admin/impersonation/init */
|
||
public function init(Request $request): JsonResponse
|
||
{
|
||
$tenantId = (int) $request->input('tenant_id');
|
||
$requestedBy = (int) $request->input('requested_by'); // TODO: $request->user()->id когда saas-admin auth готов
|
||
$reason = $request->string('reason')->toString();
|
||
|
||
if (mb_strlen($reason) < 30) {
|
||
return response()->json([
|
||
'message' => 'Основание (reason) должно быть не короче 30 символов.',
|
||
'errors' => ['reason' => ['Минимум 30 символов.']],
|
||
], 422);
|
||
}
|
||
|
||
$tenant = Tenant::find($tenantId);
|
||
if (! $tenant) {
|
||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||
}
|
||
|
||
// 6-значный код. Числа от 100000 до 999999.
|
||
$plainCode = (string) random_int(100_000, 999_999);
|
||
|
||
$token = ImpersonationToken::create([
|
||
'tenant_id' => $tenant->id,
|
||
'requested_by' => $requestedBy,
|
||
'code_hash' => Hash::make($plainCode),
|
||
'reason' => $reason,
|
||
'sent_to_email' => (string) $tenant->contact_email,
|
||
'expires_at' => now()->addMinutes(self::TOKEN_TTL_MINUTES),
|
||
]);
|
||
|
||
// TODO: отправить email на $tenant->contact_email с $plainCode.
|
||
$payload = [
|
||
'token_id' => $token->id,
|
||
'expires_at' => $token->expires_at->toIso8601String(),
|
||
'sent_to_email' => $token->sent_to_email,
|
||
];
|
||
|
||
// Audit-fix A2: plain-код возвращается в API-ответе ТОЛЬКО на dev/testing
|
||
// (для тестов и локальной разработки). На prod код уходит исключительно
|
||
// в email клиента — env-guard исключает захват impersonation-сессии
|
||
// через чтение ответа init.
|
||
if (app()->environment('local', 'testing')) {
|
||
$payload['_dev_plain_code'] = $plainCode;
|
||
}
|
||
|
||
return response()->json($payload);
|
||
}
|
||
|
||
/** POST /api/admin/impersonation/verify */
|
||
public function verify(Request $request): JsonResponse
|
||
{
|
||
$tokenId = (int) $request->input('token_id');
|
||
$code = $request->string('code')->toString();
|
||
|
||
$token = ImpersonationToken::find($tokenId);
|
||
if (! $token) {
|
||
return response()->json(['message' => 'Токен не найден.'], 404);
|
||
}
|
||
|
||
if (! $token->isUsable()) {
|
||
$reason = $token->isExpired() ? 'expired'
|
||
: ($token->used_at !== null ? 'used'
|
||
: ($token->invalidated_at !== null ? 'invalidated'
|
||
: 'too_many_attempts'));
|
||
|
||
return response()->json([
|
||
'message' => 'Токен недействителен.',
|
||
'reason' => $reason,
|
||
], 422);
|
||
}
|
||
|
||
if (! Hash::check($code, $token->code_hash)) {
|
||
DB::transaction(function () use ($token) {
|
||
$token->increment('failed_attempts');
|
||
if ($token->failed_attempts >= self::MAX_FAILED_ATTEMPTS) {
|
||
$token->update(['invalidated_at' => now()]);
|
||
}
|
||
});
|
||
|
||
return response()->json([
|
||
'message' => 'Неверный код.',
|
||
'attempts_remaining' => max(0, self::MAX_FAILED_ATTEMPTS - $token->failed_attempts),
|
||
], 422);
|
||
}
|
||
|
||
// Success: mark used. Создание saas_admin_session с
|
||
// impersonating_token_id — отдельный коммит после saas-admin auth.
|
||
$token->update([
|
||
'used_at' => now(),
|
||
]);
|
||
|
||
return response()->json([
|
||
'token_id' => $token->id,
|
||
'tenant_id' => $token->tenant_id,
|
||
'used_at' => $token->used_at->toIso8601String(),
|
||
'message' => 'Impersonation начат. Сессия активна 1 час.',
|
||
]);
|
||
}
|
||
|
||
/** POST /api/admin/impersonation/end */
|
||
public function end(Request $request): JsonResponse
|
||
{
|
||
$tokenId = (int) $request->input('token_id');
|
||
|
||
$token = ImpersonationToken::find($tokenId);
|
||
if (! $token) {
|
||
return response()->json(['message' => 'Токен не найден.'], 404);
|
||
}
|
||
|
||
if ($token->used_at === null) {
|
||
return response()->json([
|
||
'message' => 'Сессия impersonation не была активирована.',
|
||
], 422);
|
||
}
|
||
|
||
if ($token->session_ended_at !== null) {
|
||
return response()->json([
|
||
'message' => 'Сессия уже завершена.',
|
||
], 422);
|
||
}
|
||
|
||
$token->update(['session_ended_at' => now()]);
|
||
|
||
// TODO: уведомление клиенту по email о завершении (как и в init flow).
|
||
|
||
return response()->json([
|
||
'token_id' => $token->id,
|
||
'session_ended_at' => $token->session_ended_at->toIso8601String(),
|
||
'message' => 'Impersonation завершён.',
|
||
]);
|
||
}
|
||
}
|