Documentation menu
On this page
Packages · v1.x
Security
The Security package (src/packages/Security) authenticates users (login form, HTTP Basic, access tokens, custom authenticators, remember-me) and checks their permissions (roles, role hierarchy, voters, #[IsGranted], access_control).
It is enabled when config/packages/security.yaml defines at least one firewall or one access_control rule; otherwise it does nothing.
Quick start
php bin/neo make:user
php bin/neo make:migration
php bin/neo migration:migrate
php bin/neo make:auth
php bin/neo security:hash-password secret
make:user creates src/Entity/User.php and src/Repository/UserRepository.php; make:auth creates src/Controller/SecurityController.php and templates/security/login.php (--twig for Twig), plus the base layout when it is missing.
providers:
users:
entity:
class: App\Entity\User
property: email
password_hashers:
default: auto
firewalls:
assets:
pattern: ^/builds/
security: false
main:
pattern: ^/
provider: users
form_login:
login_path: app_login
enable_csrf: true
default_target_path: /
logout:
path: app_logout
target: /
remember_me:
lifetime: 604800
login_throttling:
max_attempts: 5
interval: 60
role_hierarchy:
ROLE_ADMIN: [ROLE_USER]
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/profile, roles: IS_AUTHENTICATED }
access_decision_manager:
strategy: affirmative
allow_if_all_abstain: false
allow_if_equal_granted_denied: true
voters: []
neo install creates a default config/packages/security.yaml (memory provider without users, login form on /login).
| Key | Description |
|---|---|
providers |
user providers, see User providers |
password_hashers |
hashers per user class, see Passwords |
firewalls |
see Firewalls |
role_hierarchy |
role => list of inherited roles |
access_control |
list of rules, see Authorization |
access_decision_manager |
voting strategy, see Voters |
voters |
extra voter classes |
Users
A user implements NeoPHP\Package\Security\Contract\UserInterface, and PasswordAuthenticatedUserInterface when it logs in with a password.
| Interface | Methods |
|---|---|
UserInterface |
getUserIdentifier(): string, getRoles(): array |
PasswordAuthenticatedUserInterface |
getPassword(): ?string |
make:user [User] [--property=email] [--force] generates an ORM entity with id, the identifier property (unique), roles (JSON, ROLE_USER always added) and password.
NeoPHP\Package\Security\User\InMemoryUser is a ready-made user: new InMemoryUser(string $identifier, ?string $password = null, array $roles = []), with setPassword().
NeoPHP\Package\Security\User\UserClass::of(UserInterface $user): string returns the real class of a user, ORM lazy proxies included (use it to compare classes or pick a hasher).
User providers
| Provider | Configuration | Class |
|---|---|---|
| ORM entity | entity: { class: App\Entity\User, property: email }; without property, the repository must define loadUserByIdentifier() |
EntityUserProvider |
| Memory | memory: { users: { admin: { password: '$2y$...', roles: [ROLE_ADMIN] } } } |
InMemoryUserProvider (createUser(InMemoryUser $user)) |
| Chain | chain: { providers: [users, admins] } |
ChainUserProvider (getProviders()) |
| Custom | id: App\Security\MyProvider |
implements UserProviderInterface |
A firewall uses its provider option, or the single provider when only one is defined. The user is stored in the session by identifier and reloaded on the first access to the user of the request; when its password changed, the session is closed.
<?php
declare(strict_types=1);
namespace App\Security;
use App\Entity\User;
use NeoPHP\Package\Security\Contract\UserInterface;
use NeoPHP\Package\Security\Contract\UserProviderInterface;
use NeoPHP\Package\Security\Exception\UserNotFoundException;
class LdapUserProvider implements UserProviderInterface
{
public function __construct(private LdapClient $ldap)
{
}
public function loadUserByIdentifier(string $identifier): UserInterface
{
return $this->ldap->findUser($identifier) ?? throw new UserNotFoundException();
}
public function refreshUser(UserInterface $user): UserInterface
{
return $this->loadUserByIdentifier($user->getUserIdentifier());
}
public function supportsClass(string $class): bool
{
return is_a($class, User::class, true);
}
}
A provider that also implements PasswordUpgraderInterface (upgradePassword(PasswordAuthenticatedUserInterface $user, string $hashedPassword): void) receives the rehashed passwords; the entity and chain providers implement it.
Passwords
password_hashers maps a class (or an interface, or default) to a hasher:
password_hashers:
App\Entity\User: auto
App\Entity\Admin: { algorithm: bcrypt, cost: 12 }
App\Entity\Legacy: { id: App\Security\LegacyHasher }
default: auto
| Value | Hasher |
|---|---|
auto, bcrypt, argon2i, argon2id |
NativePasswordHasher(string $algorithm = 'auto', ?int $cost = null, ?int $memoryCost = null, ?int $timeCost = null) |
plaintext |
PlaintextPasswordHasher (tests only) |
{ id: ... } |
class implementing PasswordHasherInterface |
PasswordHasherInterface: hash(string $plainPassword): string, verify(string $hashedPassword, string $plainPassword): bool, needsRehash(string $hashedPassword): bool; MAX_PASSWORD_LENGTH = 4096.
Inject NeoPHP\Package\Security\Hasher\UserPasswordHasher:
$user->setPassword($hasher->hashPassword($user, $plainPassword));
$valid = $hasher->isPasswordValid($user, $plainPassword);
| Method | Description |
|---|---|
hashPassword(PasswordAuthenticatedUserInterface|string $user, string $plainPassword): string |
hashes with the hasher of the user (or class name) |
isPasswordValid(PasswordAuthenticatedUserInterface $user, string $plainPassword): bool |
checks a password |
needsRehash(PasswordAuthenticatedUserInterface $user): bool |
the hash uses older options |
getPasswordHasher(string|object $user = 'default'): PasswordHasherInterface |
the hasher of a user or class (UserPasswordHasher::DEFAULT_KEY) |
When a password hashed with older options is valid, it is rehashed and saved through the provider (PasswordUpgraderInterface).
Firewalls
The first firewall whose pattern (regular expression, plus optional host, methods, ips) matches the request is used.
| Option | Description |
|---|---|
pattern, host, methods, ips |
request matcher |
security: false |
no authentication at all (assets...) |
stateless: true |
nothing stored in the session (APIs) |
provider |
name of the user provider |
context |
firewalls with the same context share the logged user |
form_login |
login form, see Login form |
http_basic |
{ realm: 'Secured Area' } |
access_token |
see Access tokens |
custom_authenticators |
list of classes implementing AuthenticatorInterface |
remember_me |
{ lifetime: 604800, name: REMEMBERME, parameter: _remember_me, always: false, path: /, domain: ~, secure: auto, samesite: Lax }: parameter is the checkbox of the login form, always sets the cookie on every login |
login_throttling |
{ max_attempts: 5, interval: 60 }: attempts per IP + identifier (and 5 × more per IP) during interval seconds, stored in var/cache/security/throttling/; the IP is REMOTE_ADDR |
logout |
see Logout |
user_checker |
class implementing UserCheckerInterface |
entry_point |
form_login, http_basic, access_token or a class implementing EntryPointInterface |
Paths accept a path (/login) or a route name (app_login).
When an anonymous user is denied, the entry point answers: the login form redirects to login_path (the requested URL is restored after the login) or returns a 401 JSON response for AJAX/JSON requests, HTTP Basic returns a 401 with WWW-Authenticate, the access token returns a 401 JSON response. A logged user who is denied gets a 403, except a remembered user on IS_AUTHENTICATED_FULLY, who is sent to the login form.
EntryPointInterface: start(Request $request, ?AuthenticationException $exception = null): Response.
UserCheckerInterface rejects banned or disabled accounts by throwing an AuthenticationException (usually CustomUserMessageAuthenticationException):
<?php
declare(strict_types=1);
namespace App\Security;
use App\Entity\User;
use NeoPHP\Package\Security\Contract\UserCheckerInterface;
use NeoPHP\Package\Security\Contract\UserInterface;
use NeoPHP\Package\Security\Exception\CustomUserMessageAuthenticationException;
class UserChecker implements UserCheckerInterface
{
public function checkPreAuth(UserInterface $user): void
{
if ($user instanceof User && $user->isBanned()) {
throw new CustomUserMessageAuthenticationException('Your account is banned.');
}
}
public function checkPostAuth(UserInterface $user): void
{
}
}
Login form
form_login option |
Default |
|---|---|
login_path |
/login |
check_path |
login_path (the POST on this path is the login) |
username_parameter / password_parameter |
_username / _password |
enable_csrf / csrf_parameter / csrf_token_id |
false / _csrf_token / authenticate |
default_target_path |
/ |
always_use_default_target_path |
false |
target_path_parameter |
_target_path (relative paths only) |
use_referer |
false |
failure_path |
login_path |
#[Route('/login', name: 'app_login', methods: ['GET', 'POST'])]
public function login(): Response
{
return $this->render('security/login.php', [
'last_username' => $this->getLastUsername(),
'error' => $this->getLastAuthenticationError(),
]);
}
The error is a safe message (Invalid credentials., Invalid CSRF token., Too many failed login attempts, please try again in 1 minute(s).): an unknown user and a wrong password give the same message.
Logout
Disabled unless declared:
logout:
path: /logout
target: /
invalidate_session: true
enable_csrf: false
csrf_parameter: _csrf_token
csrf_token_id: logout
clear_cookies: []
The request on path is handled by the firewall (the route needs no code). logout_path() in views returns the URL, with the CSRF token when enabled.
Access tokens
access_token:
token_handler: App\Security\ApiTokenHandler
header: Authorization
token_type: Bearer
query_parameter: ~
realm: ~
<?php
declare(strict_types=1);
namespace App\Security;
use App\Repository\ApiTokenRepository;
use NeoPHP\Package\Security\Contract\AccessTokenHandlerInterface;
use NeoPHP\Package\Security\Contract\UserInterface;
use NeoPHP\Package\Security\Exception\BadCredentialsException;
class ApiTokenHandler implements AccessTokenHandlerInterface
{
public function __construct(private ApiTokenRepository $tokens)
{
}
public function getUserFrom(string $accessToken): string|UserInterface
{
return $this->tokens->findOneBy(['value' => $accessToken])?->getOwner()?->getEmail()
?? throw new BadCredentialsException('Invalid access token.');
}
}
The handler returns a user identifier (loaded by the provider) or a user.
Custom authenticators
A custom authenticator extends AbstractAuthenticator and is listed in custom_authenticators:
<?php
declare(strict_types=1);
namespace App\Security;
use App\Repository\UserRepository;
use NeoPHP\Component\Http\Request\Request;
use NeoPHP\Package\Security\Authentication\Passport;
use NeoPHP\Package\Security\Contract\AbstractAuthenticator;
use NeoPHP\Package\Security\Contract\UserInterface;
class ApiKeyAuthenticator extends AbstractAuthenticator
{
public function __construct(private UserRepository $users)
{
}
public function supports(Request $request): bool
{
return $request->headers->has('X-API-KEY');
}
public function authenticate(Request $request): Passport
{
$key = (string) $request->headers->get('X-API-KEY');
return Passport::selfValidating($key, fn (string $key): ?UserInterface => $this->users->findOneBy(['apiKey' => $key]));
}
}
AuthenticatorInterface:
| Method | Description |
|---|---|
supports(Request $request): bool |
whether the request carries credentials |
authenticate(Request $request): Passport |
builds the passport, throws an AuthenticationException on error |
createToken(Passport $passport, string $firewall): TokenInterface |
AbstractAuthenticator creates a SecurityToken |
onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewall): ?Response |
response, or null to continue the request |
onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response |
response, or null to continue the request |
Passport
NeoPHP\Package\Security\Authentication\Passport carries the credentials:
| Method | Description |
|---|---|
new Passport(string $userIdentifier, ?string $password = null, ?Closure $userLoader = null, array $badges = [], array $attributes = []) |
the password is checked with the hasher |
Passport::selfValidating(string $userIdentifier, ?Closure $userLoader = null, array $badges = [], array $attributes = []) |
no password check |
csrf(string $id, ?string $token) |
adds a CSRF check (BADGE_CSRF) |
rememberMe(bool $enabled = true), isRememberMe() |
sets the remember-me cookie (BADGE_REMEMBER_ME) |
addCheck(Closure $check), getChecks() |
extra checks fn (UserInterface $user) => ... (throw to reject) |
setBadge(), hasBadge(), getBadge() |
badges |
getAttributes(), setAttribute() |
attributes copied to the token |
getUserIdentifier(), getUserLoader(), getUser(), setUser(), hasPassword(), getPassword(), erasePassword() |
accessors |
Without a user loader, the user is loaded by the provider of the firewall. NeoPHP\Package\Security\Authentication\AuthenticationManager runs the authenticators (authenticate(), loadUser()); it is used by the firewall and rarely needed directly.
Tokens
TokenInterface represents the authentication of the current request:
| Method | Description |
|---|---|
getUser(), setUser(), getUserIdentifier() |
the user |
getRoleNames() |
the roles of the user |
getFirewall(), getAuthenticator() |
where and how the user logged in |
isAuthenticated(), isRemembered() |
state |
getAttributes(), getAttribute($name, $default), setAttribute() |
attributes |
Implementations: SecurityToken, RememberMeToken (isRemembered() is true), NullToken (anonymous). TokenStorage holds the token of the request (getToken(), setToken(), isInitialized(), reset()).
Authorization
| Attribute | Granted when |
|---|---|
ROLE_* |
the user has the role, directly or through role_hierarchy |
IS_AUTHENTICATED |
a user is logged in (also IS_AUTHENTICATED_REMEMBERED) |
IS_AUTHENTICATED_FULLY |
logged in during this session (not by the remember-me cookie) |
IS_REMEMBERED |
logged in by the remember-me cookie |
PUBLIC_ACCESS |
always |
| anything else | decided by the voters |
An array of attributes is granted when one of them is granted.
#[IsGranted]
#[IsGranted(string|array $attribute, string|array|null $subject = null, ?string $message = null, ?int $statusCode = null)] on a class or a method (repeatable) runs before the controller, as a route middleware (IsGrantedMiddleware):
#[IsGranted('ROLE_ADMIN')]
class AdminController extends AbstractController
{
#[Route('/admin/posts/{id}')]
#[IsGranted('ROLE_EDITOR')]
#[IsGranted('ROLE_SUPER_ADMIN', statusCode: 404, message: 'Not found')]
public function show(int $id): Response
{
$post = $this->posts->find($id) ?? throw $this->createNotFoundException();
$this->denyAccessUnlessGranted('POST_EDIT', $post);
return $this->render('admin/post.php', ['post' => $post]);
}
#[Route('/admin/sections/{section}')]
#[IsGranted('SECTION_ACCESS', subject: 'section')]
public function section(string $section): Response
{
return $this->render('admin/section.php', ['section' => $section]);
}
}
subject is the name of a route parameter (or a list of names): the voter receives its raw value ('news', '42'), so load entities in the controller and use denyAccessUnlessGranted() to vote on them. statusCode replaces the 403 by another status (404 hides the page) and never redirects to the login form.
access_control
Rules are checked on every request; the first matching rule applies (path, host, methods, ips, roles) and the user needs one of the roles.
access_control:
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/api, methods: [POST, DELETE], roles: ROLE_API }
- { path: ^/admin, ips: [127.0.0.1], roles: [ROLE_ADMIN, ROLE_SUPER_ADMIN] }
Voters
A voter is a class of src/ with #[AsVoter(priority: 0)] extending AbstractVoter. make:voter Post generates src/Security/Voter/PostVoter.php with POST_VIEW, POST_EDIT and POST_DELETE.
<?php
declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Post;
use NeoPHP\Package\Security\Attribute\AsVoter;
use NeoPHP\Package\Security\Contract\AbstractVoter;
use NeoPHP\Package\Security\Contract\TokenInterface;
#[AsVoter]
class PostVoter extends AbstractVoter
{
protected function supports(string $attribute, mixed $subject): bool
{
return $attribute === 'POST_EDIT' && $subject instanceof Post;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
return $subject->getAuthor() === $token->getUser();
}
}
A voter can also implement VoterInterface directly: vote(TokenInterface $token, mixed $subject, array $attributes): int returning ACCESS_GRANTED (1), ACCESS_ABSTAIN (0) or ACCESS_DENIED (-1). Voters can also be listed in voters: [App\Security\MyVoter]. Built-in voters: RoleVoter (roles, with RoleHierarchy) and AuthenticatedVoter (IS_*, PUBLIC_ACCESS).
access_decision_manager strategies (AccessDecisionManager::STRATEGIES):
| Strategy | Granted when |
|---|---|
affirmative (default) |
one voter grants |
consensus |
more grants than denials (allow_if_equal_granted_denied on equality) |
unanimous |
no voter denies |
priority |
the first voter that does not abstain grants |
allow_if_all_abstain decides when every voter abstains. AccessDecisionManager exposes decide(TokenInterface $token, array $attributes, mixed $subject = null): bool, getVoters(), addVoter(), getStrategy(); RoleHierarchy exposes getReachableRoleNames(array $roles) and getMap().
Helpers
| Controller | View | Result |
|---|---|---|
getUser() |
app_user() |
the logged user or null |
isGranted($attribute, $subject = null) |
is_granted($attribute, $subject = null) |
bool |
denyAccessUnlessGranted($attribute, $subject = null, $message = 'Access Denied.') |
throws an AccessDeniedException (403 or entry point) |
|
getLastUsername() |
last_username() |
last submitted username |
getLastAuthenticationError($clear = true) |
last_authentication_error($clear = true) |
last login error message |
loginUser($user, $firewall = null, $rememberMe = false) |
logs a user in (after a registration...) | |
logoutUser() |
logout_path($firewall = null) |
logs out and returns the redirection / URL of the logout (with the CSRF token when enabled, null without logout) |
{% if is_granted('ROLE_ADMIN') %}
<a href="/admin">Admin</a>
{% endif %}
{% if app_user() %}
{{ app_user().userIdentifier }} <a href="{{ logout_path() }}">Logout</a>
{% endif %}
Outside controllers, inject NeoPHP\Package\Security\Contract\SecurityInterface (implemented by SecurityManager):
| Method | Description |
|---|---|
isEnabled() |
a firewall or rule is configured |
getToken(), setToken(), getUser() |
current authentication |
isGranted(), denyAccessUnlessGranted() |
authorization |
getFirewall() |
the Firewall of the request |
login(UserInterface $user, ?string $firewall = null, bool $rememberMe = false), logout(): Response |
log in / out |
getLogoutPath(?string $firewall = null) |
logout URL |
getLastAuthenticationError(bool $clear = true), getLastUsername() |
login form state (session keys LAST_ERROR, LAST_USERNAME) |
handleRequest(Request $request), handleException(Request $request, Throwable $exception) |
called by the kernel |
Events
In NeoPHP\Package\Security\Event (see the Events documentation):
| Event | When | Methods |
|---|---|---|
LoginSuccessEvent |
after a successful authentication | getToken(), getUser(), getFirewall(), getRequest(), getAuthenticator(), getResponse(), setResponse() |
LoginFailureEvent |
after a failed authentication | getException(), getFirewall(), getRequest(), getAuthenticator(), getResponse(), setResponse() |
LogoutEvent |
on logout | getToken(), getFirewall(), getRequest(), getResponse(), setResponse() |
#[AsListener(LoginSuccessEvent::class)]
public function onLogin(LoginSuccessEvent $event): void
{
$this->logger->info('Login of ' . $event->getToken()->getUserIdentifier());
}
Exceptions
In NeoPHP\Package\Security\Exception, all extending SecurityException:
| Exception | Description |
|---|---|
AccessDeniedException |
access denied (getAttributes(), getSubject()) |
AuthenticationException |
authentication failure; getSafeMessage() is the message shown to the user, translated through the security domain of the Translation package (translations/security.{locale}.yaml or .xlf, the key is the English message: Invalid credentials., Invalid CSRF token., Too many failed login attempts, please try again in {minutes} minute(s)., An authentication exception occurred.) |
BadCredentialsException |
invalid credentials |
UserNotFoundException |
unknown user (shown as Invalid credentials.) |
InvalidCsrfTokenException |
invalid CSRF token |
TooManyLoginAttemptsException |
login throttled (getRetryAfter(), Retry-After header) |
CustomUserMessageAuthenticationException |
its message is shown as is: new CustomUserMessageAuthenticationException(string $message, array $context = []) |
Commands
| Command | Description |
|---|---|
make:user [User] [-p email] |
generates a user entity and its repository |
make:auth [SecurityController] [--twig] |
generates a login controller and its template (and the base layout when missing) |
make:voter Post |
generates src/Security/Voter/PostVoter.php (the subject is the entity of the same name when it exists) |
security:hash-password [password] [user-class] |
hashes a password with the configured hasher (asked without echo when omitted) |
The make:* commands never overwrite a file without --force. See the Console documentation.
Profiler
When the WebProfiler package is enabled (web_profiler.config → enabled: true), SecurityProvider attaches a SecurityTrace to the AccessDecisionManager; otherwise nothing is recorded and decide() costs a single null check. The Helper/Profiler/SecurityProfiler.php element (auto-discovered, priority 70) then shows:
- Toolbar (
usericon): user identifier orn/a; green when authenticated, orange when an access was denied during the request. Details: user, roles, firewall, token class, decisions count, logout path. - Panel: metrics (authenticated, reachable roles, decisions, denied), user & token, and tabs Roles (direct / inherited through
role_hierarchy), Firewall (config summary: pattern, provider, stateless, authenticators, entry point, login / logout paths), Access decisions (everyisGranted()/denyAccessUnlessGranted()/#[IsGranted]/access_controlcheck with attributes, subject, result, strategy and each voter vote), Access control (rules and the one matching the request), Events (login success / failure, logout) and Voters.
Passwords, secrets and tokens are masked (******). Subjects are stored as a short string (Post #12), never as objects.
$trace = $security->getAccessDecisionManager()->getTrace();
$trace?->getDecisions();
Changelog
- v1.25.2 — profiler integration:
SecurityProfilertoolbar item and panel, opt-inSecurityTraceon the access decision manager (decisions, voter votes, access_control, login / logout events),AbstractSecurity::getAccessMap(). - v1.20.0 — Messages translated through the Translation package (domain security).
- bugfix —
UserClass::of()resolves the real class of ORM proxies;AuthenticationManagerandPassportmoved toNeoPHP\Package\Security\Authentication. - bugfix (after v1.17.0) —
make:authcreates the missing base layout. - v1.17.0 —
make:user,make:authandmake:voterask for their values. - v1.15.0 — commands rewritten on the new Console API.
- v1.13.0 — Security package: firewalls with login form, HTTP Basic, access tokens, custom authenticators and remember-me, entity / memory / chain / custom providers, password hashers with automatic rehash, login throttling, user checkers, logout, role hierarchy, voters,
#[IsGranted],access_control, decision strategies, controller and view helpers, login events,make:user,make:auth,make:voter,security:hash-password.