Files
portal/app/app/Http/Controllers/Api/ImpersonationController.php
T
Дмитрий 1fd6f7f597 fix(security): harden impersonation/webhook/tenant — audit A2/A3/B3/C2
- 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>
2026-05-15 19:16:13 +03:00

227 lines
9.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 завершён.',
]);
}
}