wexample / symfony-user
User accounts and authentication for Symfony
Requires
- php: >=8.5
- doctrine/orm: ^3.0
- endroid/qr-code: ^6.0
- scheb/2fa-backup-code: ^8.6
- scheb/2fa-bundle: ^8.6
- scheb/2fa-totp: ^8.6
- scheb/2fa-trusted-device: ^8.6
- symfony/form: ^7.4
- symfony/mailer: ^7.4
- symfony/rate-limiter: ^7.4
- symfony/security-bundle: ^7.4
- symfony/twig-bridge: ^7.4
- symfony/validator: ^7.4
- wexample/symfony-activity: >=3.0.5
- wexample/symfony-forms: >=14.0.2
- wexample/symfony-helpers: >=18.0.2
- wexample/symfony-loader: >=25.0.1
- wexample/symfony-mail: >=3.0.4
- wexample/symfony-security: >=4.0.2
- wexample/symfony-translations: >=17.0.1
- wexample/symfony-tunnels: >=11.0.5
Requires (Dev)
- phpunit/phpunit: ^13
- symfony/browser-kit: ^7.4
- symfony/css-selector: ^7.4
- symfony/http-client: ^7.4
- symfony/monolog-bundle: ^3.10
- wexample/symfony-activity: *
- wexample/symfony-testing: >=2.0.0
- wexample/symfony-user-ds: *
Suggests
- symfony/monolog-bundle: Writes the security journal to its own channel, user_security
- wexample/symfony-activity: Keeps the security facts of each account as its history — the login history among them
Provides
None
Conflicts
None
Replaces
None
README
Version: 14.0.4
User accounts and authentication for Symfony
Table of Contents
Installation
Bundles
// config/bundles.php Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true], Wexample\SymfonySecurity\WexampleSymfonySecurityBundle::class => ['all' => true], Wexample\SymfonyUser\WexampleSymfonyUserBundle::class => ['all' => true],
WexampleSymfonySecurityBundle masks the package's secrets in the logs: without it, the container refuses to build.
The screens — login, second factor, password, terms, authenticator app — are symfony-user-ds's: install it with Wexample\SymfonyUserDs\WexampleSymfonyUserDsBundle. An application drawing its own serves pages under the names of Routing\UserRoute: the package redirects to them. Its own endpoints — /login/link, /logout, the reset and activation links, the code resend, new backup codes — stay here.
The scheb recipe also adds config/routes/scheb_2fa.yaml: delete it, the code form is served on /login/2fa.
The user entity
#[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\Table(name: '`user`')] class User extends AbstractUser { use UserWithNameTrait; // optional: first and last name, display name } class UserRepository extends AbstractUserRepository { public static function getEntityClassName(): string { return User::class; } }
AbstractUser is not a mapped superclass: its columns land in the application table. Generate a migration after extending it.
Security
# config/packages/security.yaml security: providers: users: entity: class: App\Entity\User # no `property`: email or username firewalls: main: lazy: true provider: users custom_authenticators: - Wexample\SymfonyUser\Security\Authenticator\LoginFormAuthenticator entry_point: Wexample\SymfonyUser\Security\Authenticator\LoginFormAuthenticator user_checker: Wexample\SymfonyUser\Security\UserChecker logout: path: user_security_logout login_throttling: ~ remember_me: secret: '%kernel.secret%' signature_properties: [password] login_link: check_route: user_security_login_link signature_properties: [password, dateLastLogin] lifetime: 600 success_handler: Wexample\SymfonyUser\Security\Handler\LoginLinkSuccessHandler two_factor: auth_form_path: user_security_two_factor prepare_on_login: true check_path: /_forms/submit/form-two_factor_code_form auth_code_parameter_name: two_factor_code_form[code] trusted_parameter_name: two_factor_code_form[trusted] success_handler: Wexample\SymfonyUser\Security\Handler\TwoFactorResponseHandler failure_handler: Wexample\SymfonyUser\Security\Handler\TwoFactorResponseHandler authentication_required_handler: Wexample\SymfonyUser\Security\Handler\TwoFactorResponseHandler enable_csrf: true csrf_parameter: two_factor_code_form[_token] csrf_token_id: submit # the form CSRF token id of the app access_control: - { path: ^/login/2fa, roles: IS_AUTHENTICATED_2FA_IN_PROGRESS } # What serves the pages rather than being one: symfony-loader's # components rendered on demand, symfony-design-system's ui state. - { path: ^/_(system|ui-state)/, roles: PUBLIC_ACCESS }
# config/packages/scheb_2fa.yaml scheb_two_factor: security_tokens: - Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken trusted_device: enabled: true lifetime: 5184000 # Authenticator apps, and their backup codes: /account/authenticator totp: enabled: true issuer: 'My application' leeway: 1 backup_codes: enabled: true
APP_SECRETmust be 32 bytes at least: the trusted device cookie is a JWT signed with it. The authenticator secrets are encrypted with a key derived from it too: changing it makes users set their app up again.- While a login waits for its code, scheb only lets through the paths
access_controlopens toPUBLIC_ACCESSorIS_AUTHENTICATED_2FA_IN_PROGRESS, and sends every other request to the code's page. That includes the/_system/and/_ui-state/calls the page's scripts make — the debug toolbar, a component loaded later —: left out, they receive the code's page instead of their JSON, and the browser reportsUnexpected token '<'. They are already open to anyone without a rule, so naming themPUBLIC_ACCESSopens nothing more. The rule has to be written by the application: Symfony refusesaccess_controlfrom more than one file, so no bundle can add it. - Without a second factor, drop the
two_factorkey; to ask it on every login, settrusted_device.enabled: false— the code form then drops its "trust this device" switch.
Mails
Links and codes are sent through symfony-mail's MailSenderService, which requires WexampleSymfonyMailBundle to be registered. They are framed by the application's mail layout (wexample_symfony_mail.layout), their text part keeps the link's address, and they are written in the account's language (AbstractUser::getLocale()), or the default locale. The sender comes from the mailer configuration:
framework: mailer: headers: From: 'no-reply@example.com'
To deliver them another way, alias Wexample\SymfonyUser\Interface\SecurityMessageSenderInterface to your own service. The templates are @WexampleSymfonyUserBundle/mails/<type>.html.twig — magic_link, password_reset, account_activation, two_factor_code —, overridden under templates/bundles/WexampleSymfonyUserBundle/mails/.
Link mails leave through Messenger, as Wexample\SymfonyUser\Message\SendSecurityMessage: the message names the account and the kind of link, and the handler builds the link when it sends, so neither the transport nor the failed queue ever holds one. Route it to an asynchronous transport, so a request never waits on the mail provider:
framework: messenger: failure_transport: failed transports: async: dsn: '%env(MESSENGER_TRANSPORT_DSN)%' retry_strategy: { max_retries: 3, multiplier: 2 } failed: 'doctrine://default?queue_name=failed' routing: Wexample\SymfonyUser\Message\SendSecurityMessage: async router: default_uri: '%env(APP_URL)%' # the worker has no request: the links take their host from here
Unrouted, the mails are sent within the request. Codes of the second factor are always sent at once. The mails are handed to the mailer transport directly, never to MailerInterface: with SendEmailMessage routed to a transport, as the Flex recipe does, the rendered mail would be queued with its link.
In development, keep the mails on the machine: MAILER_DSN=null://null drops them (the profiler still shows them), a Mailpit container (MAILER_DSN=smtp://mailpit:1025) shows them in its web interface.
Bundle options
# config/packages/wexample_symfony_user.yaml wexample_symfony_user: password_reset: token # or magic_link reveal_account_status: false # true: a disabled or locked account is told so, once its password is right terms: version: ~ # set: every signed-in user accepts this version before anything else; changing it asks everyone again text_route: ~ # the route of the page holding the text, owned by the application remember_me: true # false: no "stay signed in" on the login form — for a firewall without remember_me magic_link_login: true # false: no magic link form on the login page; MagicLinkService still sends the application's own links session: idle_lifetime: 0 # seconds a signed-in session survives unused; 0, never expires one password: refuse_leaked: false # true: refuse a password found in a breach — needs symfony/http-client, and an outbound call per password set activation: link_lifetime: 604800 # seconds the link of an activation mail works impersonation: targets: administered # or any: who may be impersonated; who may impersonate is the firewall's switch_user list_threshold: 50 # up to this many, the page lists them all; above, it searches require_intent: true # a switch must come from the impersonation form, not a bare ?_switch_user= link post_login: routes: {} # ROLE_X: route, in order — where a user lands when the page they came from is not theirs to open default_route: ~ # for a user holding none of them; unset, the home page request_limit: # reset and magic link mails, per hour; past it, the same answer and no mail per_identifier: 3 per_ip: 20 administration: # the rules of AccountAdministrationService and AccountRulesService page_role: ~ # the role opening the administration screens of symfony-user-ds; unset, they answer 404 for everyone protected_roles: [] # never left without an active holder manages: {} # ROLE_X: [ROLE_A] — the roles ROLE_X administers, with no right over their pages; unset, the roles reached role_email_domains: {} # ROLE_X: [example.com] — held only by an address of these domains exclusive_roles: {} # ROLE_X: [ROLE_A, ROLE_B] — never given together two_factor: pending_lifetime: 600 # seconds a correct password waits for its second factor; past it, back to the password required: false # true: every sign-in — magic link, login after a reset — asks a second factor, no account exempt app_required_roles: [] # roles that must use an authenticator app: held on its setup page until it is there, never allowed to turn it off
Off — the default —, unknown address, wrong password, disabled and locked account all get the same response; an unknown address also costs a password check, so the timing tells nothing either. That shield needs the password hasher configured on PasswordAuthenticatedUserInterface, as the Symfony recipe does.
symfony-user is a Symfony bundle for user accounts and their authentication. An application extends AbstractUser for its own User entity and gets: a password login that works as a page, in an ajax modal or embedded in any page (a tunnel step); login throttling; a second factor sent by email on devices not trusted yet, built on scheb/2fa-bundle; magic links; password change and reset, by signed link or by magic link; and role helpers (users by role, roles above a role, roles an editor may grant). Forms are ordinary symfony-forms ajax forms rendered with the design system; the security side is plain Symfony Security, so throttling, remember-me and the user checker keep working as documented by Symfony.
The showcase lives in symfony-user-demo, served on /user/ by the design-system app, with public test accounts and a mailbox standing in for real emails.