User lifecycle
The bundle provides four headless services for common authentication flows. Each service emits events so the app can send emails, log activity, or trigger side effects — the bundle stays transport-agnostic.
Token security model
All tokens (email verification, password reset, email change) share the same security model:
- 256 bits of entropy:
bin2hex(random_bytes(32))→ 64-char hex string. - SHA-256 hash stored: the database holds
hash('sha256', $plaintext), never the plaintext. A leaked database cannot be replayed. - Single-use: consumption sets
usedAt. - Time-boxed: each token type has a configurable TTL.
hash_equals(): timing-safe comparison on validation.
The plaintext is returned exactly once in a GeneratedToken wrapper and
is meant to be emailed immediately.
Registration
use Derafu\IdentityBundle\Contract\Service\RegistrarInterface;
class RegisterController
{
public function __invoke(RegistrarInterface $registrar, Request $request): Response
{
$registration = $registrar->register(
email: '[email protected]',
plainPassword: 'secret',
name: 'Alice'
);
// $registration->user — the persisted User
// $registration->verificationToken->plaintext — for the email
return new JsonResponse(['message' => 'Check your inbox.'], 201);
}
}
The UserRegistered event is dispatched with the plaintext token.
Subscribe to it to send the confirmation email:
#[AsEventListener]
class SendWelcomeEmail
{
public function __invoke(UserRegistered $event): void
{
// Build URL: /verify-email?token={$event->verificationTokenPlaintext}
// Send email to $event->user->getEmail()
}
}
Email verification
use Derafu\IdentityBundle\Contract\Service\EmailVerifierInterface;
// GET /verify-email?token=abc123...
$user = $emailVerifier->verify($request->query->get('token'));
// $user->isEmailVerified() is now true
Emits UserEmailVerified.
Password reset
Request phase (user submits email):
use Derafu\IdentityBundle\Contract\Service\PasswordResetterInterface;
$passwordResetter->requestReset('[email protected]');
// Silent no-op if email not registered (anti-enumeration).
Emits PasswordResetRequested (only when user exists). Subscribe to
send the reset email with the plaintext token.
Reset phase (user clicks link, submits new password):
$user = $passwordResetter->reset($token, $newPassword);
Emits UserPasswordChanged.
Email change
use Derafu\IdentityBundle\Contract\Service\EmailChangerInterface;
// Step 1: request (sends confirmation to the NEW email)
$generated = $emailChanger->requestChange($currentUser, '[email protected]');
// Step 2: confirm (user clicks link in the new email)
$user = $emailChanger->confirmChange($token);
// $user->getEmail() is now '[email protected]'
The new email is bound to the token’s payload — it cannot be swapped
after issuance. Emits EmailChangeRequested (step 1) and
UserEmailChanged (step 2, carries both old and new addresses).
Magic links (passwordless login)
Magic links let users log in by clicking a link sent to their email —
no password needed. The flow reuses the existing VerificationToken
infrastructure with a MAGIC_LINK type.
Request a link:
use Derafu\IdentityBundle\Contract\Service\MagicLinkManagerInterface;
// POST /magic-link (user submits their email)
$magicLinkManager->requestLink('[email protected]');
// Silent no-op if email not registered (anti-enumeration).
Emits MagicLinkRequested (only when user exists). Subscribe to send
the login email:
#[AsEventListener]
class SendMagicLinkEmail
{
public function __invoke(MagicLinkRequested $event): void
{
$url = 'https://app.example.com/magic-link?_magic_link_token='
. $event->plaintextToken;
// Send email to $event->user->getEmail() with $url
}
}
Authenticate via the link:
The bundle ships a Symfony Security authenticator. Enable it in
security.yaml:
security:
firewalls:
main:
custom_authenticators:
- Derafu\IdentityBundle\Security\Authenticator\MagicLinkAuthenticator
The authenticator fires on any request with a _magic_link_token query
parameter, consumes the token, and authenticates the user. Both success
and failure handling delegate to the firewall’s configured handlers
(default_target_path, login_path, etc.) — the bundle stays headless.
Typical route setup:
#[Route('/magic-link', name: 'magic_link')]
public function magicLink(): Response
{
// If we reach here, the authenticator already ran and the user
// is authenticated. Redirect to the dashboard.
return $this->redirectToRoute('dashboard');
}
Token cleanup
Expired tokens pile up over time. Run the purge command periodically:
bin/console identity:tokens:purge
Typically as a daily cron job.
Sudo mode (re-authentication for sensitive actions)
Sudo mode is a time-limited elevated state that proves the user
recently confirmed their password. Actions decorated with
#[RequiresSudo] will throw SudoRequiredException when the window
expires — the app catches it to show a password-confirmation form.
Protect an action:
use Derafu\IdentityBundle\Attribute\RequiresSudo;
#[RequiresSudo]
#[Route('/account/delete', methods: ['POST'])]
public function deleteAccount(): Response { ... }
Confirm password (in the confirmation controller):
use Derafu\IdentityBundle\Contract\Service\SudoManagerInterface;
#[Route('/confirm-password', methods: ['POST'])]
public function confirmPassword(SudoManagerInterface $sudoManager, Request $request): Response
{
$sudoManager->confirmPassword($this->getUser(), $request->get('password'));
// Sudo window refreshed — redirect back to the original action.
return $this->redirect($request->get('_target'));
}
Handle the exception (in a kernel.exception listener):
use Derafu\IdentityBundle\Exception\SudoRequiredException;
public function onKernelException(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
if ($exception instanceof SudoRequiredException) {
$event->setResponse(new RedirectResponse('/confirm-password?_target=' . $event->getRequest()->getUri()));
}
}
Auto-granted on login. The SudoOnLoginListener calls grant()
after every successful login — users are not asked to re-confirm
immediately after typing their password.
Session-based, no DB. The timestamp lives in the session
(_derafu_identity.sudo_granted_at). Works naturally in PHP-FPM.
Account lockout
After N consecutive failed login attempts (default 5), the account is automatically locked for a configurable duration (default 30 min).
Fully automatic. The AccountLockListener hooks into Symfony’s
authentication events — no app-side wiring needed:
CheckPassportEvent— blocks login when locked (even with correct password).LoginFailureEvent— increments the failure counter; locks when threshold is reached.LoginSuccessEvent— resets the counter to zero.
Lock expires automatically. When lockedUntil passes, the user can
login again. The counter resets on the next successful login.
Admin unlock:
use Derafu\IdentityBundle\Contract\Service\AccountLockManagerInterface;
$lockManager->unlock($user);
// Resets counter + clears lock. Emits AccountUnlocked.
Check programmatically:
$lockManager->isLocked($user); // true when lockedUntil > now
Login history
Every successful login is automatically recorded — IP, User-Agent, and which authenticator succeeded. Zero wiring needed.
Query recent logins (for a “security activity” page):
use Derafu\IdentityBundle\Contract\Service\LoginHistoryManagerInterface;
$records = $loginHistoryManager->getRecentForUser($user, limit: 10);
foreach ($records as $record) {
echo $record->getIpAddress(); // "192.168.1.1"
echo $record->getUserAgent(); // "Mozilla/5.0..."
echo $record->getAuthenticator(); // "FormLoginAuthenticator"
echo $record->getCreatedAt()->format('Y-m-d H:i');
}
Purge old records:
bin/console identity:login-history:purge --days=90
TTL & lockout configuration
derafu_identity:
sudo_ttl: 900 # 15 minutes (default)
lockout_max_attempts: 5 # lock after 5 failures (default)
lockout_duration: 1800 # lock for 30 minutes (default)
verification_ttl:
email_verify: 86400 # 1 day (default)
password_reset: 3600 # 1 hour (default)
email_change: 86400 # 1 day (default)
magic_link: 900 # 15 minutes (default)