3brs/enterprise-security-bundle
Standalone Symfony bundle with reusable security primitives: 2FA, passkeys/WebAuthn, magic-link and OAuth login, account lockout, session tracking, IP allow/deny lists, password policy, GDPR account deletion, and runtime settings. Contracts only—wire into your app.
enabled = false stops the next sign-in, while revoking stamps revokedAt and closes nothing
until the session revocation listener
is in place. The clause was added while rewriting 2.2.0's corrections from audit notes into
instructions, and reintroduced exactly the defect that release set out to remove — a summary
asserting an outcome the code delivers only with the integrator's help.RateLimitGuard can pair a username-keyed action with a per-address companion counter, via the
new $ipCompanionActions map (e.g. ['login' => 'login_ip']). This closes password spraying,
which the username-keyed login limit and the lockout counter both miss by construction: an
attacker spreading a few common passwords over many accounts stays under every per-account
threshold. Both counters are consumed before either verdict is acted on, so a tripped username
cannot shield the address counter; the companion is skipped when no username was passed, since the
primary counter is keyed on the address already. The map is empty by default — behaviour and
settings reads are unchanged until a consumer fills it in, and the companion carries its own
rate_limit.{action}.* settings so an address limit is not forced to reuse a per-account number.symfony/clock. services.yaml binds the clock service in three places but the
package was never required — it only resolved because symfony/security-bundle happens to pull it
in. Now explicit, so a change upstream cannot break container compilation here.The signed OAuth state cookie now carries its own expiry. StateCookieSigner stamps an exp
into the signed body and refuses anything past it, or carrying none. Previously the only bound on
the value's lifetime was the cookie's Expires attribute — a hint to a cooperating browser, which
an attacker replaying a captured value simply ignores. Because a link cookie names the user the
callback signs in, any copy that outlived the browser's window (an exported HAR, a proxy or WAF
log, a captured request in an error report) stayed a working sign-in for that account forever,
unaffected by logout, a password change or "revoke all sessions".
Exploiting this needed the value in the first place, and whoever can read a victim's cookie jar
can usually read their session cookie too — so this is hardening, not a break anyone was standing
on. It applies only to providers marked FormPostOAuthProviderInterface (of the bundled ones,
Apple) and only to intent=link, since login carries no user.
StateCookieSigner::__construct() takes ClockInterface $clock and int $ttl = 600 after
$secret. StateCookieSignerInterface is unchanged, so consumers that only call encode() /
decode() need no edit — see UPGRADE.md if you register the service yourself.
State cookies issued before the upgrade are refused, costing anyone mid-sign-in one retry.
Documented how rate-limit counters are keyed, and that RateLimitGuard::buildKey() is the
intended override point. The account-lockout guide now states the trusted_proxies prerequisite
as a precondition for enabling any IP-keyed action rather than a closing footnote — unconfigured,
every visitor reads as one address and the limiter shuts the endpoint for all of them; configured
too widely, X-Forwarded-For becomes attacker-supplied. It also warns against substituting a CDN
header for getClientIp(), and no longer claims the login limit slows credential stuffing:
keyed on the username it bounds guesses per account, exactly as the lockout counter does, and
spraying is built to stay under both.
Swept the remaining documentation for the same defect — a claim of protection the code does not
implement — and corrected ten of them. The bundle is contract-first, so the recurring shape was a
feature summary written in bundle voice for something that is actually an abstract hook or an
uncalled service. Fixed: 2FA enforced mode (shouldEnforceFor*() has no caller in src/;
nothing holds an un-enrolled user at setup, and wiring the success handler does not change that),
magic-link timing padding (padTo() has no caller; the neutral response body is bundle
behaviour, the neutral response time is not), the admin "block account" / "sign out from all
devices" actions (revoking is a revokedAt stamp, not a sign-out), the passkey and OAuth
last-method guards (canRemoveCredential() / canUnlinkProvider() are abstract — the definition
of "last method" is the integrator's), the WebAuthn ceremony (verifyAndPersist() is abstract),
password_expiration (documented as readable in the global scope, which the checker never
consults), the account-state message key (three of five controllers flash it; the passkey endpoint
answers 403 and the recovery challenge throws), and the passkey verify request shape
({"credential": "<string>"}, not the credential JSON raw).
Documentation-only corrections, of statements that promised a guarantee the code does not give:
AutoRegistrationPolicy no longer documented as requiring a verified email. It refuses only on
isEmailVerified() === false and accepts null; only GoogleOAuthProvider sets the flag. Added
what each provider actually attests: Google and Apple do, Entra emits no email_verified and warns
against using email for authorization — its xms_edov claim covers this but is off by default.revokedAt; the sign-out listener is the integrator's. Correct further down the page, misleading
in the feature summary.findExistingLinkUser() contract stated: resolve by (provider, providerUserId), never by
email — that branch signs the user in with no ownership proof.tenant: 'common' with auto-registration, in the Entra setup section
and inline in the configuration.md example.AccountStateGuardTrait — holds the UserCheckerInterface and the isAccountAllowedToSignIn()
predicate that the sign-in controllers guard themselves with, plus the ACCOUNT_REFUSED_MESSAGE
key they flash when they turn a refused account away.AbstractAccountDeletionRequestController::isDeletionConfirmed(FormInterface $form, UserInterface $user): bool
— the re-authentication before a deletion request is now an overridable hook. The default is the
previous behaviour (the current password); a subclass whose accounts have no password (created by
a social sign-up, for instance) overrides it with the confirmation it does have. It returns false
instead of throwing when the form carries no password field, and the failure message moved to the
overridable NOT_CONFIRMED_MESSAGE constant (was the hard-coded
three_brs.account_deletion.invalid_password).AbstractMagicLinkVerifyController, AbstractPasskeyLoginVerifyController,
AbstractOAuthCallbackController, AbstractOAuthConfirmLinkController and
AbstractTwoFactorRecoveryChallengeController each run UserCheckerInterface::checkPreAuth()
before writing the security token. They write it directly (so the second factor, which guards
password sign-in, is not challenged), which means the firewall's user checker never ran for them:
an account disabled by an administrator — or one with a self-service deletion pending — could sign
straight back in through a magic link, a social account, a passkey or a recovery code.three_brs.account_state.sign_in_refused) plus
a redirect, a 403 from the passkey verify endpoint (it answers a fetch()), or an
AccessDeniedException from the two-factor recovery challenge — so no AccountStatusException
escapes into the firewall.Symfony\Component\Security\Core\User\UserCheckerInterface $userChecker. Bind a checker that
refuses on the account state alone, not the firewall's checker chain (see UPGRADE.md).PasswordPolicyFilteringValidator no longer carries the message key of any particular framework.
The host application names its own redundant password-length messages in the new parameter
three_brs.password_policy.redundant_message_templates (constructor argument
$redundantMessageTemplates, empty by default); Symfony's Length constraint is still recognised
without configuration.OAuthLinkCodeGenerator (OAuthLinkCodeGeneratorInterface) — optional confirm-link helper
that mints a zero-padded 6-digit one-time code and SHA-256-hashes it for storage/comparison.CodeChallengeValidator (CodeChallengeValidatorInterface) — optional confirm-link helper for
the verify half of a one-time-code challenge: enforces expiry + attempt limit + single-use +
constant-time compare over a transport-agnostic CodeChallengeState, returning a
CodeChallengeOutcome (verdict + next state to persist). Clock-driven; never touches the session.form_post OAuth provider support (e.g. Apple Sign In): the
FormPostOAuthProviderInterface opt-in marker plus a dedicated SameSite=None; Secure; HttpOnly single-use HMAC-signed state cookie carrying state / intent / link-initiating
user across the cross-site POST (the session cookie is not sent there). GET-redirect
providers (Google, Microsoft) are unaffected.StateCookieSigner (StateCookieSignerInterface) — signs and verifies that state cookie so
its payload (including the link-initiating user) cannot be forged or tampered with by the client.AbstractPasswordLoginCheckListener no longer takes a preference repository
in its constructor and no longer performs a per-user lookup; it blocks every password
login for the bound user type when the scope toggle is off. Its abstract hook
isFeatureEnabled() is replaced by isPasswordLoginEnabled() (return true when
password login is allowed for the scope).password_login_control → password_login.AbstractOAuthConfirmLinkController no longer performs a hard-coded password check on
confirm-link. Account ownership is now proven via two new abstract hooks subclasses must
implement: prepareChallenge(UserInterface $user, array $pending, Request $request): void
(issues the ownership-proof challenge on the initial GET; should be idempotent across
refreshes) and verifyChallenge(UserInterface $user, array $pending, Request $request): ?string
(verifies the submitted proof on POST; returns null on success or a translation key on
failure). __invoke() keeps its signature but delegates the check to these hooks instead of
reading the _password request field.AbstractOAuthInitiateController gained a StateCookieSignerInterface constructor argument
(and an optional Security to capture the logged-in user for a link); AbstractOAuthCallbackController
gained a StateCookieSignerInterface argument and a new abstract hook
findUserByIdentifier(string $identifier): ?UserInterface that resolves the link-initiating
user from the verified state cookie on a cross-site callback.PasswordLoginPreferenceInterface and PasswordLoginPreferenceRepositoryInterface — the
per-user password-login preference contracts (the listener now reads a scope toggle, not a
per-user record).UserPasswordHasherInterface $passwordHasher (and the promoted
property) from AbstractOAuthConfirmLinkController — the base class no longer verifies
passwords; its constructor is now ($tokenStorage, $router, $twig, $logger).three_brs.ui.social_login.invalid_password from
AbstractOAuthConfirmLinkController — failure keys now come from the subclass's
verifyChallenge().getCreatedAt(): ?\DateTimeInterface on PasswordExpiration{Shop,Admin}UserInterface.PasswordExpirationChecker no longer treats a missing passwordChangedAt as
expired — it falls back to getCreatedAt(). Enabling expiration no longer
forces a password reset on every existing user.AbstractMagicLinkVerifyController and
AbstractPasskeyLoginVerifyController lost their 2FA arguments.three_brs.passkey.skip_2fa_when_user_verified (and its setting key).PasskeyAssertionResultInterface::isUserVerified().README.md.How can I help you explore Laravel packages today?