Files
portal/app/app/Http/Controllers/Api/AccountController.php
T
Дмитрий 2225a8487e
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat(security): рабочая «Завершить сессию» — реальный отзыв активных сессий
UI-аудит: вкладка Безопасность показывала фейк-сессии с мёртвой кнопкой.
Теперь — реальные активные сессии + рабочий отзыв.

- UserSessionTracker (новый): запись сессии при входе (login + 2FA verify +
  recovery-use) в существующую таблицу user_sessions; отзыв = удаление строки
  + удаление сессии из Redis по session_id (реальный выход с устройства);
  logout снимает текущую сессию из списка. Best-effort (не ломает вход/выход).
- AccountController: GET /api/account/security отдаёт реальные сессии;
  DELETE /api/account/sessions/{id} — отзыв (только свои; чужая → 404).
- Фронт SessionsTable: список + кнопка «Завершить» (кроме текущей).
- phpstan-baseline обновлён (Pest-$this нового теста).

Pest: 10/10. Верификация: Playwright (2 сессии → «Завершить» → исчезла).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 17:15:26 +03:00

173 lines
6.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Concerns\WritesAuthLog;
use App\Http\Controllers\Controller;
use App\Http\Requests\Account\ChangePasswordRequest;
use App\Models\User;
use App\Services\UserSessionTracker;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
/**
* Аккаунт пользователя — вкладка «Безопасность» (UI-аудит 21.06.2026).
*
* Заменяет статичные mock-карточки (ChangePasswordCard/SessionsTable):
* - POST /api/account/change-password — реальная смена пароля.
* - GET /api/account/security — дата последней смены пароля + активные сессии.
* - DELETE /api/account/sessions/{id} — отозвать сессию (UI-аудит 21.06.2026).
*
* Активные сессии берутся из user_sessions (запись при входе); отзыв реально
* убивает сессию (удаление из Redis по session_id). Заменяет прежний mock.
*/
class AccountController extends Controller
{
use WritesAuthLog;
/**
* POST /api/account/change-password — смена пароля авторизованным пользователем.
*
* Проверяет текущий пароль (Hash::check против password_hash), пишет новый хэш,
* логирует password_changed в auth_log. На неверном текущем — 422 + лог
* password_change_failed.
*/
public function changePassword(ChangePasswordRequest $request): JsonResponse
{
/** @var User $user */
$user = $request->user();
if (! Hash::check($request->string('current_password')->toString(), (string) $user->password_hash)) {
$this->logAuthEvent(
'password_change_failed',
$user->id,
$user->tenant_id,
$user->email,
$request->ip(),
$request->userAgent(),
'wrong_current_password',
);
throw ValidationException::withMessages([
'current_password' => ['Неверный текущий пароль.'],
]);
}
$user->forceFill([
'password_hash' => Hash::make($request->string('password')->toString()),
])->save();
$this->logAuthEvent(
'password_changed',
$user->id,
$user->tenant_id,
$user->email,
$request->ip(),
$request->userAgent(),
null,
);
return response()->json([
'message' => 'Пароль изменён.',
'last_password_change_at' => now()->toIso8601String(),
]);
}
/**
* GET /api/account/security — данные вкладки «Безопасность».
*
* last_password_change_at — max(created_at) по password-событиям в auth_log
* (null, если пароль ни разу не менялся через портал).
* recent_logins — последние входы текущего пользователя (устройство/IP/время).
*/
public function security(Request $request): JsonResponse
{
/** @var User $user */
$user = $request->user();
$lastChange = DB::table('auth_log')
->where('user_id', $user->id)
->whereIn('event', ['password_changed', 'password_reset_completed'])
->max('created_at');
$currentSid = $request->session()->getId();
$rows = DB::table('user_sessions')
->where('user_id', $user->id)
->where('expires_at', '>', now())
->orderByDesc('created_at')
->limit(20)
->get(['id', 'token_hash', 'ip_address', 'user_agent', 'last_active_at', 'created_at']);
$sessions = $rows->map(fn ($row): array => [
'id' => $row->id,
'device' => $this->deviceLabel($row->user_agent),
'ip' => $row->ip_address,
'at' => Carbon::parse($row->last_active_at ?? $row->created_at)->toIso8601String(),
'current' => $row->token_hash === $currentSid,
])->all();
return response()->json([
'last_password_change_at' => $lastChange ? Carbon::parse($lastChange)->toIso8601String() : null,
'sessions' => $sessions,
]);
}
/** DELETE /api/account/sessions/{id} — отозвать конкретную сессию пользователя. */
public function revokeSession(Request $request, int $id): JsonResponse
{
/** @var User $user */
$user = $request->user();
$ok = app(UserSessionTracker::class)->revoke($user->id, $id);
if (! $ok) {
return response()->json(['message' => 'Сессия не найдена.'], 404);
}
$this->logAuthEvent(
'session_revoked',
$user->id,
$user->tenant_id,
$user->email,
$request->ip(),
$request->userAgent(),
null,
);
return response()->json(['message' => 'Сессия завершена.']);
}
/** Грубый человекочитаемый ярлык устройства из User-Agent (браузер + ОС). */
private function deviceLabel(?string $ua): string
{
if ($ua === null || $ua === '') {
return 'Неизвестное устройство';
}
$browser = match (true) {
str_contains($ua, 'Firefox/') => 'Firefox',
str_contains($ua, 'Edg/') => 'Edge',
str_contains($ua, 'OPR/') || str_contains($ua, 'Opera') => 'Opera',
str_contains($ua, 'Chrome/') => 'Chrome',
str_contains($ua, 'Safari/') => 'Safari',
default => 'Браузер',
};
$os = match (true) {
str_contains($ua, 'Windows') => 'Windows',
str_contains($ua, 'Android') => 'Android',
str_contains($ua, 'iPhone') || str_contains($ua, 'iPad') => 'iOS',
str_contains($ua, 'Mac OS') || str_contains($ua, 'Macintosh') => 'macOS',
str_contains($ua, 'Linux') => 'Linux',
default => '',
};
return $os !== '' ? "{$browser}, {$os}" : $browser;
}
}