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 zRequestStack, a nie z usuniętegoSessionInterface.
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
RequestStacki pobieramy sesję przez$requestStack->getSession(). Bezpośrednie wstrzykiwanieSessionInterfacezostało usunięte, a$_SESSIONnigdy 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\Userani dziedziczenia po klasie bundla —Userto 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'(dawniejencodersz jawnymargon2i) — 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: trueobsł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ć nasetPlainPassword()i nasłuchiwaczu z FOSUserBundle. Role zapisujemy w tablicyrolesencji.
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):

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 (lubnulldla 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
/adminprzezaccess_controli 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).