Files
portal/app/app/Http/Controllers/Api/ImpersonationController.php
T

319 lines
14 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\Mail\ImpersonationCodeMail;
use App\Mail\ImpersonationEndedMail;
use App\Models\ImpersonationToken;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Pd\ImpersonationAuditService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
/**
* 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;
private const SESSION_TTL_MINUTES = 60;
/**
* SaaS-admin — кросс-тенантная зона: запросы к impersonation_tokens / tenants
* идут через BYPASSRLS-подключение pgsql_supplier (роль crm_supplier_worker).
* Иначе на проде (роль crm_app_user, RLS on) без выставленного GUC
* app.current_tenant_id запрос падает SQLSTATE 42704 — у saas-admin нет
* tenant-контекста (middleware 'tenant' на /api/admin/* не висит). На dev
* pgsql_supplier = fallback на postgres-superuser, поведение идентично.
*/
private const DB_CONNECTION = 'pgsql_supplier';
/** GET /api/admin/impersonation/active — активные сессии (used_at != null AND session_ended_at == null) */
public function active(): JsonResponse
{
$rows = ImpersonationToken::on(self::DB_CONNECTION)
->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::on(self::DB_CONNECTION)
->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, ImpersonationAuditService $audit): 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::on(self::DB_CONNECTION)->find($tenantId);
if (! $tenant) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
// 6-значный код. Числа от 100000 до 999999.
$plainCode = (string) random_int(100_000, 999_999);
$token = ImpersonationToken::on(self::DB_CONNECTION)->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),
]);
$audit->recordInit($token, adminId: $requestedBy, ip: $request->ip());
try {
Mail::to((string) $tenant->contact_email)
->queue(new ImpersonationCodeMail($plainCode, (string) $tenant->contact_email));
} catch (\Throwable $e) {
Log::warning('impersonation init: не удалось поставить письмо с кодом: '.$e->getMessage());
}
$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, ImpersonationAuditService $audit): JsonResponse
{
$tokenId = (int) $request->input('token_id');
$code = $request->string('code')->toString();
$token = ImpersonationToken::on(self::DB_CONNECTION)->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)) {
// increment атомарен на уровне SQL, а isUsable() независимо гейтит
// failed_attempts >= 5 — поэтому отдельная транзакция не нужна
// (и ломала бы общий PDO в тестах под SharesSupplierPdo).
$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: целевой пользователь тенанта = самый ранний активный.
$targetUser = User::on(self::DB_CONNECTION)
->where('tenant_id', $token->tenant_id)
->where('is_active', true)
->orderBy('id')
->first();
if ($targetUser === null) {
return response()->json(['message' => 'У тенанта нет активного пользователя для входа.'], 422);
}
// Машинный ключ для ИИ: lpimp_<id>_<secret>. Храним только хеш секрета.
$secret = Str::random(48);
$machineToken = 'lpimp_'.$token->id.'_'.$secret;
$token->update([
'used_at' => now(),
'session_token_hash' => Hash::make($secret),
]);
// Путь человека: логиним браузер целевым пользователем + маркер impersonation в сессию.
Auth::login($targetUser);
$request->session()->put('impersonation', [
'token_id' => $token->id,
'tenant_id' => $token->tenant_id,
'target_user_id' => $targetUser->id,
'started_at' => now()->toIso8601String(),
]);
$audit->recordVerify($token, adminId: (int) $token->requested_by, ip: $request->ip());
return response()->json([
'token_id' => $token->id,
'tenant_id' => $token->tenant_id,
'used_at' => $token->used_at->toIso8601String(),
'expires_at' => $token->sessionExpiresAt(self::SESSION_TTL_MINUTES)->toIso8601String(),
'machine_token' => $machineToken,
'message' => 'Impersonation начат. Сессия активна 1 час.',
]);
}
/** POST /api/admin/impersonation/end */
public function end(Request $request, ImpersonationAuditService $audit): JsonResponse
{
$tokenId = (int) $request->input('token_id');
$token = ImpersonationToken::on(self::DB_CONNECTION)->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()]);
$audit->recordEnd($token, adminId: (int) $token->requested_by, ip: $request->ip());
try {
Mail::to((string) $token->sent_to_email)
->queue(new ImpersonationEndedMail((string) $token->sent_to_email));
} catch (\Throwable $e) {
Log::warning('impersonation end mail: '.$e->getMessage());
}
return response()->json([
'token_id' => $token->id,
'session_ended_at' => $token->session_ended_at->toIso8601String(),
'message' => 'Impersonation завершён.',
]);
}
/**
* POST /api/impersonation/leave — завершить свою impersonation-сессию из кабинета.
*
* Маркер `impersonation` из сессии НЕ удаляется здесь намеренно:
* ImpersonationContext (global web middleware) на следующем запросе
* обнаружит isSessionActive()=false и вернёт 401 явно, не доходя до auth:sanctum.
* Это обеспечивает корректный 401 как в реальном браузере, так и в тест-среде
* (где Auth::guard('web')->logout() может не повлиять на кэш sanctum-guard).
*/
public function leave(Request $request, ImpersonationAuditService $audit): JsonResponse
{
$marker = $request->session()->get('impersonation');
if ($marker === null) {
return response()->json(['message' => 'Сессия impersonation не активна.'], 422);
}
$token = ImpersonationToken::on(self::DB_CONNECTION)->find($marker['token_id']);
if ($token !== null && $token->session_ended_at === null) {
$token->update(['session_ended_at' => now()]);
$audit->recordEnd($token, adminId: (int) $token->requested_by, ip: $request->ip());
try {
Mail::to((string) $token->sent_to_email)
->queue(new ImpersonationEndedMail((string) $token->sent_to_email));
} catch (\Throwable $e) {
Log::warning('impersonation leave mail: '.$e->getMessage());
}
}
return response()->json(['message' => 'Вы вышли из режима поддержки.']);
}
}