2225a8487e
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>
173 lines
6.5 KiB
PHP
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;
|
|
}
|
|
}
|