11

Dzień 11 z 18

Użytkownik i bezpieczeństwo

Opublikowany

Dwa tematy: sesja (historia oglądanych ofert przez RequestStack) oraz natywne bezpieczeństwo — encja User, logowanie e-mailem, hashowanie haseł, login throttling i ochrona panelu admina rolą ROLE_ADMIN. Bez FOSUserBundle.

Czego się nauczysz

  • Historia oglądanych ofert w sesji (RequestStack)
  • Encja User — natywne interfejsy, logowanie e-mailem
  • security.yaml — password_hashers, provider, firewall, access_control
  • Formularz logowania (SecurityController) i login throttling
  • Użytkownicy testowi z hashowaniem haseł
  • Ochrona /admin przez #[IsGranted(ROLE_ADMIN)]
  • Linki logowania, wylogowania i panelu w menu

Co zmieniło się w Symfony 8 vs 4.2

  • Natywny Security Bundle zamiast FOSUserBundle
  • RequestStack zamiast usuniętego SessionInterface
  • UserPasswordHasherInterface (password_hashers: auto) zamiast PasswordEncoder
  • Wbudowany login throttling (od 5.2) zamiast zewnętrznego bundla

Dzień 11: Użytkownik i bezpieczeństwo

Dziś dwa tematy: sesja (dane trwałe między żądaniami HTTP) oraz bezpieczeństwo (logowanie, role, ochrona panelu admina). Najpierw dodamy historię ostatnio oglądanych ofert przechowywaną w sesji, a potem zbudujemy natywny system logowania i zabezpieczymy /admin.

Zmiana vs Symfony 4.2: oryginał opierał logowanie na FOSUserBundle. W Symfony 8 robimy to natywnie — encja User, komponent Security, hashowanie haseł i formularz logowania bez żadnego zewnętrznego bundla. Sesję pobieramy z RequestStack, a nie z usuniętego SessionInterface.


Historia oglądanych ofert (sesja)

Dodajmy wymaganie: ostatnie 3 oglądane oferty mają być widoczne na stronie głównej i kategorii. Logikę zamkniemy w serwisie. HTTP jest bezstanowy, więc dane trzymamy w sesji — ale nie zapisujemy w niej obiektów encji (mogą się „zestarzeć” po serializacji), tylko identyfikatory.

Utwórz src/Service/JobHistoryService.php:

<?php

namespace App\Service;

use App\Entity\Job;
use App\Repository\JobRepository;
use Symfony\Component\HttpFoundation\RequestStack;

class JobHistoryService
{
    private const int MAX = 3;
    private const string KEY = 'job_history';

    public function __construct(
        private readonly RequestStack $requestStack,
        private readonly JobRepository $jobs,
    ) {}

    public function add(Job $job): void
    {
        $session = $this->requestStack->getSession();

        $ids = $session->get(self::KEY, []);
        array_unshift($ids, $job->getId());          // najnowsza na początku
        $ids = array_slice(array_unique($ids), 0, self::MAX);

        $session->set(self::KEY, $ids);
    }

    /** @return Job[] */
    public function getJobs(): array
    {
        $session = $this->requestStack->getSession();

        $jobs = array_map(
            fn (int $id): ?Job => $this->jobs->findActiveJob($id),
            $session->get(self::KEY, []),
        );

        return array_filter($jobs); // pomija oferty, które przestały być aktywne
    }
}

Zmiana vs Symfony 4.2: wstrzykujemy RequestStack i pobieramy sesję przez $requestStack->getSession(). Bezpośrednie wstrzykiwanie SessionInterface zostało usunięte, a $_SESSION nigdy nie używamy wprost. Przechowujemy ID ofert, nie obiekty — sesja jest serializowana między żądaniami.

Zapisuj oglądaną ofertę w akcji show() (JobController) i przekazuj historię do widoków listy oraz kategorii:

// JobController::show() — dodaj serwis i wywołanie
public function show(
    #[MapEntity(expr: 'repository.findActiveJob(id)')] Job $job,
    JobHistoryService $history,
): Response {
    $history->add($job);

    return $this->render('job/show.html.twig', ['job' => $job]);
}

// JobController::list() — przekaż historię
public function list(CategoryRepository $categories, JobHistoryService $history): Response
{
    return $this->render('job/list.html.twig', [
        'categories'  => $categories->findWithActiveJobs(),
        'historyJobs' => $history->getJobs(),
    ]);
}

To samo w CategoryController::show() — dodaj JobHistoryService $history i 'historyJobs' => $history->getJobs() do renderowania.

Wydziel fragment templates/job/_history.html.twig:

{% if historyJobs %}
    <div class="alert alert-light border">
        <strong>Ostatnio oglądane:</strong>
        {% for job in historyJobs %}
            <a href="{{ path('job_show', { id: job.id }) }}">{{ job.position }} — {{ job.company }}</a>{{ loop.last ? '' : ', ' }}
        {% endfor %}
    </div>
{% endif %}

Dołącz go na górze templates/job/list.html.twig i templates/category/show.html.twig:

{% include 'job/_history.html.twig' with { historyJobs: historyJobs } only %}

Otwórz kilka ofert i wróć na stronę główną — zobaczysz listę ostatnio oglądanych. Zawartość sesji podejrzysz w Profilerze (/_profiler, zakładka Request / Session).


Encja User

Bezpieczeństwo zaczynamy od encji użytkownika. Wygeneruj ją komendą:

docker compose exec app php bin/console make:user

Kreator zapyta o nazwę (User), pole identyfikujące (email) i czy hasła mają być hashowane (tak). Powstanie encja implementująca UserInterface i PasswordAuthenticatedUserInterface:

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'users')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 180, unique: true)]
    private ?string $email = null;

    /** @var list<string> */
    #[ORM\Column]
    private array $roles = [];

    #[ORM\Column]
    private ?string $password = null;

    public function getUserIdentifier(): string
    {
        return (string) $this->email;
    }

    public function getRoles(): array
    {
        $roles = $this->roles;
        $roles[] = 'ROLE_USER'; // każdy użytkownik ma co najmniej ROLE_USER

        return array_unique($roles);
    }

    // ... gettery/settery wygenerowane przez make:user ...
}

Wygeneruj i uruchom migrację tworzącą tabelę users:

docker compose exec app php bin/console make:migration
docker compose exec app php bin/console doctrine:migrations:migrate --no-interaction

Zmiana vs Symfony 4.2: żadnego FOS\UserBundle\Model\User ani dziedziczenia po klasie bundla — User to zwykła encja implementująca natywne interfejsy. Identyfikatorem jest e-mail (getUserIdentifier()), zgodnie z konwencją projektu.


Konfiguracja bezpieczeństwa

W Symfony 8 całość opisuje config/packages/security.yaml. Ustaw hashowanie haseł, provider z encji, firewall z formularzem logowania i ochronę tras /admin:

security:
    password_hashers:
        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'

    providers:
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email

    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|assets)/
            security: false
        main:
            lazy: true
            provider: app_user_provider
            form_login:
                login_path: app_login
                check_path: app_login
                enable_csrf: true
            logout:
                path: app_logout
                target: job_list
            login_throttling:
                max_attempts: 5

    access_control:
        - { path: ^/admin, roles: ROLE_ADMIN }

Zmiana vs Symfony 4.2: password_hashers: 'auto' (dawniej encoders z jawnym argon2i) — Symfony samo wybiera najlepszy dostępny algorytm. login_throttling (od Symfony 5.2) chroni przed atakami brute-force bez żadnego bundla. Firewall nie ma już anonymous: ~ — lazy: true obsługuje dostęp anonimowy.


Formularz logowania

Wygeneruj kontroler i szablon logowania:

docker compose exec app php bin/console make:security:form-login

Powstanie src/Controller/SecurityController.php z trasami app_login i app_logout:

#[Route('/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
    return $this->render('security/login.html.twig', [
        'last_username' => $authenticationUtils->getLastUsername(),
        'error'         => $authenticationUtils->getLastAuthenticationError(),
    ]);
}

#[Route('/logout', name: 'app_logout')]
public function logout(): void
{
    throw new \LogicException('Ta metoda jest przechwytywana przez firewall (klucz logout).');
}

Szablon templates/security/login.html.twig (Bootstrap 5, logowanie e-mailem):

{% extends 'base.html.twig' %}

{% block title %}Logowanie — Jobeet{% endblock %}

{% block body %}
    <form method="post" class="col-md-5 mx-auto mt-4">
        {% if error %}
            <div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
        {% endif %}

        <h1 class="h3 mb-3">Logowanie</h1>

        <div class="mb-3">
            <label for="username" class="form-label">E-mail</label>
            <input type="email" name="_username" id="username" value="{{ last_username }}"
                   class="form-control" autofocus required>
        </div>

        <div class="mb-3">
            <label for="password" class="form-label">Hasło</label>
            <input type="password" name="_password" id="password" class="form-control" required>
        </div>

        <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">

        <button class="btn btn-primary">Zaloguj</button>
    </form>
{% endblock %}

Pola muszą nazywać się _username i _password (form_login), a token CSRF ma identyfikator authenticate. Ponieważ provider używa pola email, w _username wpisujemy adres e-mail.


Użytkownicy testowi (fixtures)

Utwórz src/DataFixtures/UserFixtures.php. Hasła hashujemy jawnie przez UserPasswordHasherInterface:

<?php

namespace App\DataFixtures;

use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

class UserFixtures extends Fixture
{
    public function __construct(private readonly UserPasswordHasherInterface $hasher) {}

    public function load(ObjectManager $manager): void
    {
        $user = (new User())->setEmail('user@jobeet.local')->setRoles(['ROLE_USER']);
        $user->setPassword($this->hasher->hashPassword($user, 'user1234'));

        $admin = (new User())->setEmail('admin@jobeet.local')->setRoles(['ROLE_ADMIN']);
        $admin->setPassword($this->hasher->hashPassword($admin, 'admin1234'));

        $manager->persist($user);
        $manager->persist($admin);
        $manager->flush();
    }
}

Załaduj dane:

docker compose exec app php bin/console doctrine:fixtures:load --no-interaction

Zmiana vs Symfony 4.2: hasła hashujemy jawnie metodą hashPassword(), zamiast polegać na setPlainPassword() i nasłuchiwaczu z FOSUserBundle. Role zapisujemy w tablicy roles encji.


Zabezpieczenie panelu admina

Obiecane w Dniu 10 zabezpieczenie: dodaj atrybut #[IsGranted('ROLE_ADMIN')] na klasach kontrolerów admina (src/Controller/Admin/CategoryController.php i JobController.php):

use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route('/admin/categories', name: 'admin_category_')]
#[IsGranted('ROLE_ADMIN')]
final class CategoryController extends AbstractController
{
    // ...
}

Mamy teraz podwójną ochronę: regułę access_control w security.yaml oraz atrybut #[IsGranted] przy kontrolerze. Wejście na /admin/... bez roli ROLE_ADMIN przekieruje do formularza logowania.

Po zalogowaniu Profiler potwierdzi tożsamość i role (pasek na dole strony, sekcja Security):

Profiler — zalogowany administrator


Linki logowania w menu

Uzupełnij nawigację w templates/base.html.twig — link do panelu dla admina, wylogowanie dla zalogowanych, logowanie dla gości:

<div class="ms-auto d-flex gap-2">
    {% if is_granted('ROLE_ADMIN') %}
        <a href="{{ path('admin_job_index') }}" class="btn btn-outline-light">Panel admina</a>
    {% endif %}

    <a href="{{ path('job_create') }}" class="btn btn-outline-light">Dodaj ofertę</a>

    {% if app.user %}
        <a href="{{ path('app_logout') }}" class="btn btn-outline-light">
            Wyloguj ({{ app.user.userIdentifier }})
        </a>
    {% else %}
        <a href="{{ path('app_login') }}" class="btn btn-outline-light">Zaloguj</a>
    {% endif %}
</div>
  • is_granted('ROLE_ADMIN') — sprawdza rolę,
  • app.user — obiekt zalogowanego użytkownika (lub null dla gościa),
  • app.user.userIdentifier — e-mail zalogowanego.

Zaloguj się jako admin@jobeet.local / admin1234 — zobaczysz przycisk „Panel admina” i uzyskasz dostęp do /admin. Konto user@jobeet.local / user1234 (rola ROLE_USER) do panelu nie wejdzie.


Podsumowanie

Jobeet ma sesję i pełne, natywne bezpieczeństwo:

  • ✅ historia oglądanych ofert w sesji przez RequestStack (przechowujemy ID, nie encje),
  • ✅ encja User (natywne interfejsy, logowanie e-mailem) — bez FOSUserBundle,
  • ✅ security.yaml: password_hashers: auto, provider z encji, form_login, logout,
  • ✅ login throttling (ochrona brute-force) wbudowany,
  • ✅ formularz logowania (SecurityController) i hashowanie haseł w fixtures,
  • ✅ ochrona /admin przez access_control i atrybut #[IsGranted('ROLE_ADMIN')],
  • ✅ linki logowania/wylogowania i panelu admina w menu.

W Dniu 12 zbudujemy API dla partnerów — udostępnimy oferty w formacie JSON z uwierzytelnianiem tokenem, używając kontrolera REST z DTO i dokumentacją OpenAPI (NelmioApiDoc).

Narzędzia / paczki

symfony/security-bundle symfony/password-hasher

Spis treści