Перейти к содержанию

Вход по сессии и выход из портала

Оглавление серии «Laravel 13 и PHP: портал «Заявка»»

До этого шага портал читает вымышленные заявки локальной базы без входа. Теперь установим личность пользователя через сессию и ограничим список владельцем. Результат урока — вход по паролю, регенерация идентификатора сессии, защищённый маршрут и корректный выход. Это ограниченный учебный вход подготовленного аккаунта lab@example.test, а не готовая регистрация компании с восстановлением доступа.

Authentication отвечает на вопрос «кто выполняет запрос». Она ещё не отвечает, какую конкретную заявку этот человек вправе читать. Для списка уже добавим условие user_id текущего пользователя; подробная политика появится следующим шагом. Такой порядок позволяет не смешивать личность и право объекта, хотя обе проверки необходимы рабочему кабинету.

Вход и новая сессия

Создадим полный app/Http/Controllers/SessionController.php. Пароль не сохраняется в old input, а ошибка входа не раскрывает, существует ли данный адрес. Успешный Auth attempt сопровождается regenerate, чтобы сменить идентификатор сессии.

<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class SessionController extends Controller
{
    public function store(Request $request)
    {
        $credentials = $request->validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]);
        if (!Auth::attempt($credentials)) {
            return back()->withErrors(['email' => 'Не удалось войти с этими данными.'])->onlyInput('email');
        }
        $request->session()->regenerate();
        return redirect()->intended(route('tickets.index'));
    }
    public function destroy(Request $request)
    {
        Auth::logout();
        $request->session()->invalidate();
        $request->session()->regenerateToken();
        return redirect()->route('login');
    }
}

Документация сессионного входа Laravel описывает attempt, regenerate и завершение сессии. В нашем portal пароль создан локальным seeder из самостоятельно заданного окружения. User casts password как hashed, поэтому пароль хранится в подходящем представлении. Нельзя заменить это поле на прямое хранение строки ради удобства сравнения в контроллере.

Полный новый resources/views/auth/login.blade.php использует POST и @csrf. Даже вход является изменяющей операцией, поэтому его форма не должна превращаться в ссылку GET с паролем в URL.

@extends('layouts.app')
@section('content')
<h1>Вход в портал</h1>
<form method="post" action="{{ route('login.store') }}">
  @csrf
  <label>Почта <input name="email" type="email" value="{{ old('email') }}" required autocomplete="username"></label>
  @error('email')<p role="alert">{{ $message }}</p>@enderror
  <label>Пароль <input name="password" type="password" required autocomplete="current-password"></label>
  <button type="submit">Войти</button>
</form>
@endsection

Полная замена routes/web.php добавляет middleware guest и auth, а попытки входа ограничивает throttle. Это базовое ограничение частоты, не полноценная система защиты от всех вариантов перебора.

<?php
use App\Http\Controllers\SessionController;
use App\Http\Controllers\TicketController;
use Illuminate\Support\Facades\Route;
Route::get('/', fn () => redirect()->route('tickets.index'));
Route::view('/login', 'auth.login')->middleware('guest')->name('login');
Route::post('/login', [SessionController::class, 'store'])->middleware(['guest', 'throttle:5,1'])->name('login.store');
Route::post('/logout', [SessionController::class, 'destroy'])->middleware('auth')->name('logout');
Route::get('/tickets', [TicketController::class, 'index'])->middleware('auth')->name('tickets.index');

Личный список

Полностью заменим TicketController.php. Владелец определяется request user после auth, а не аргументом адреса или формы. Даже если клиент передаст user_id другого сотрудника, этот контроллер не станет его читать.

<?php
namespace App\Http\Controllers;
use App\Models\Ticket;
use Illuminate\Http\Request;
use Illuminate\View\View;
class TicketController extends Controller
{
    public function index(Request $request): View
    {
        $tickets = Ticket::query()->where('user_id', $request->user()->id)->orderByDesc('id')->limit(50)->get();
        return view('tickets.index', compact('tickets'));
    }
}

В общий layouts/app.blade.php добавьте рядом со ссылкой навигации следующую полную форму выхода. Листинг хранится как фрагмент, а cumulative-состояние архива включает полный итоговый каркас.

@auth
<form method="post" action="{{ route('logout') }}">@csrf
  <button type="submit">Выйти</button>
</form>
@endauth

Ожидаемый пользователь после входа увидит только свои заявки. Неавторизованное чтение списка направляется к именованному login. После выхода повторный защищённый запрос больше не получает личный список. Указанная последовательность описывает договор стандартного session guard, а не фактически выполненный браузерный сценарий. Сессии и запросы в этой работе не запускались.

Личность не является полем запроса

Опасный вариант — прочитать owner из query string и использовать его вместо request user. Существование auth middleware в таком случае не спасает: вошедший человек мог бы выбрать чужой идентификатор. Поэтому фильтр берёт серверно установленную личность. Полезно проследить источник каждого значения: email и password являются входом попытки, user id после успешного входа является результатом проверки, а не свободным пожеланием клиента.

regenerate защищает смену идентификатора сессии при входе. invalidate на выходе завершает её данные, а regenerateToken создаёт новый CSRF-токен. Эти действия не одинаковы и не заменяют друг друга. Если ограничиться Auth logout, прежнее состояние сессии может продолжать содержать другие значения. Для учебного кабинета используем полное завершение, как описывает выбранная документация.

Кнопка выхода не ведёт на GET /logout. Ссылка чтения может быть вызвана браузерным предзапросом или внешней страницей, поэтому завершение сессии относится к POST с защитой формы. Токен CSRF проверяет связь изменяющего запроса с текущей сессией, однако не подтверждает пароль заново и не назначает права на заявки. Следующий урок добавит политику конкретного объекта.

Ограничения учебного входа

throttle в маршруте ограничивает поток попыток по стандартному механизму limiter выбранного Framework. Рабочая система может требовать отдельный ключ по пользователю и адресу, журнал отказов, мониторинг и согласованное сообщение о блокировке. В нашем проекте это повод различить доступность формы и полноценную безопасность аккаунта. Не рекламируйте ограниченный пример как готовый корпоративный провайдер идентификации.

На localhost cookies могут работать без HTTPS, поэтому .env.example имеет SESSION_SECURE_COOKIE false. Для внешнего окружения потребуется HTTPS и соответствующее значение, иначе транспорт сессии будет неправильно настроен. Закреплять локальное значение в production опасно. Курс не содержит выпущенного сервера и действующих данных, поэтому эти настройки относятся к договору самостоятельной подготовки, а не к выполненному размещению.

Имя lab@example.test намеренно относится к вымышленному адресу. Пароль пользователь задаёт сам вне архива, а seeder допускает только local. Не помещайте реальную почту или пароль в статьи, исходники и ZIP. Регистрация, reset password и проверка почты остаются отдельными возможностями. Для результата этого урока достаточно показать путь подготовленного аккаунта через сессию к личной выборке.

Перед продолжением разберите три запроса: чтение без сессии, чтение со своей сессией и чтение со своей сессией плюс чужой user_id в query. Первый требует входа, два последних используют одного и того же текущего пользователя. Такая проверка источника личности подготовит политику карточки: ограничение списка не должно быть единственным местом, где портал защищает чужие заявки.

Повторное чтение и окончание сеанса

Пользователь может оставить страницу открытой, затем выйти в другой вкладке. Уже показанный HTML не исчезнет сам, потому что его содержимое было отправлено раньше. Следующий защищённый запрос должен проверить текущую серверную сессию снова. Это объясняет, почему вход не является бессрочным разрешением страницы и почему клиентское скрытие блоков после logout не заменяет проверку backend.

redirect intended возвращает пользователя к первоначально запрошенному защищённому адресу, если такое намерение было сохранено middleware. Если его нет, выбирается маршрут списка. Такой переход не должен принимать произвольный внешний адрес из тела формы. Наш код использует серверный механизм и собственный запасной маршрут, не расширяя вход до открытого redirect по неизвестной строке.

При отказе attempt old input сохраняет email, чтобы человек исправил пароль без повторного набора адреса. Password специально не возвращается. Это удобство интерфейса одновременно ограничивает сохранение чувствительного ввода в сессии и шаблоне. Если форма позже получает дополнительные поля, для каждого нужно определить допустимость восстановления, а не автоматически применять withInput ко всему запросу.

Для самостоятельного воспроизведения результата «Создать и завершать серверную сессию» выберите lesson-05 в архиве исходников. Статус выполнения этого состояния: not_run.