Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@
Notable changes to `3brs/enterprise-security-bundle`. Follows
[Keep a Changelog](https://keepachangelog.com/) and [SemVer](https://semver.org/).

## [2.3.0] - 2026-08-24

### Added
- **`AbstractPasskeyAssertionVerifier`** — the verify half of a WebAuthn assertion, which every
consumer wrote out by hand (the guide even shipped the ~40-line skeleton to copy). The abstract
runs the ceremony; the subclass supplies the options session key, the credential's owner, its
result DTO and the flush. The `commit()` hook exists so the sign-count write stays inside the
verifier: it has to be atomic with the check, or two concurrent assertions replaying the same
authenticator response both pass before either one's counter lands. That invariant now lives in
one place instead of in each copy.
- **`AbstractNewDeviceDetector`** and **`KnownDeviceRecordInterface`** — the check-and-remember step
behind login notifications, together in one call because the two halves must not be separable: two
concurrent sign-ins from the same device would both read "unknown" and both email the user. The
insert race is settled by the unique key on the consumer's `(user, fingerprint)` columns, with the
subclass naming the exception that means conflict — the same shape `AbstractSessionTracker`
already uses, so no Doctrine dependency enters the bundle.

## [2.2.2] - 2026-08-24

### Added
Expand Down
22 changes: 22 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,25 @@ Nothing is required — every change is backwards compatible. Two of them are wo
— for a failed profile fetch it can quote the provider's raw response body — and the other
failures on that path (`Invalid OAuth state parameter.`, `Missing authorization code in Google
callback.`) told the person reading them nothing they could act on.

## 2.2.2 → 2.3.0

Nothing is required — the release is additive. Two new abstracts are available if you want to drop
your own copies of the same logic:

1. **`AbstractPasskeyAssertionVerifier`** replaces a hand-written `PasskeyAssertionVerifierInterface`
implementation. Extend it, pass its five collaborators to `parent::__construct()`, and implement
`getOptionsSessionKey()`, `resolveUser()`, `createResult()` and `commit()`. Your existing class
keeps working untouched — the interface is unchanged. The worked example is in
[Interface implementations](docs/interface-implementations.md#reference-impl-passkey-assertion-verifier).

One thing to check while switching: the flush belongs in `commit()`, i.e. inside the verifier. If
your controller flushed after calling `verify()`, move it — the sign-count write has to be atomic
with the WebAuthn check, or two concurrent assertions replaying the same response both pass.

2. **`AbstractNewDeviceDetector`** replaces the "is this device new?" service behind login
notifications, and `KnownDeviceRecordInterface` types the row it writes. Your known-device table
wants a **unique key over (user, fingerprint)** — the abstract settles a concurrent sign-in by
catching the insert conflict (`isConcurrentInsertConflict()`), which is what keeps a user from
getting two "new device" mails for one sign-in. Without the constraint it still works, it just
loses that protection.
1 change: 1 addition & 0 deletions docs/entities-and-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ The bundle defines **record contracts** for some entities; for others it just co
| `UserSocialAccountLink` | implements `SocialAccountLinkRecordInterface` | id, user FK, provider, providerUserId, email, linkedAt, lastUsedAt |
| `UserDeletionRequest` | implements `CustomerDeletionRequestRecordInterface` | id, user FK, requestedAt, scheduledFor, cancelledAt, requestedByAdmin |
| `UserPasskeyCredential` | implements `PasskeyCredentialRecordInterface` | id, user FK, credentialId, credentialSource (array), label, createdAt, lastUsedAt |
| `UserKnownDevice` | implements `KnownDeviceRecordInterface` | id, user FK, fingerprint, createdAt — **unique key over (user, fingerprint)**, which `AbstractNewDeviceDetector` relies on to settle concurrent sign-ins |
| `UserRecoveryCode` | *(your shape — hash via bundle's `RecoveryCodeGeneratorInterface`)* | id, user FK, codeHash, consumedAt |
| `UserPasswordHistory` | *(optional — only if you wire `PasswordHistory` constraint into your password-change form)* | id, user FK, passwordHash, createdAt |

Expand Down
3 changes: 2 additions & 1 deletion docs/features/passkey-login.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ Passwordless sign-in with passkeys — platform authenticators (Touch ID, Window

**Bundle primitives:**
- `PasskeyValidatorFactory`, `PasskeyCeremonyStepManagerFactory`, `PasskeyRelyingPartyEntityFactory`, `PasskeyWebauthnSerializer`, `SessionPasskeyOptionsStorage` (+ interfaces) — the WebAuthn engine: build relying-party identity, generate/serialize options, store the pending ceremony in the session, validate assertions.
- `PasskeyAssertionOptionsBuilderInterface`, `PasskeyAssertionVerifierInterface`, `PasskeyAssertionResultInterface` — the assertion contracts. You implement the verifier (credential lookup + `web-auth` validation); see [Interface implementations](../interface-implementations.md#reference-impl-passkey-assertion-verifier).
- `AbstractPasskeyAssertionVerifier` — extend it. It runs the whole assertion ceremony (consume the pending options, reject a non-assertion response, look the credential up, run the `web-auth` check, write the bumped sign-count back atomically); the subclass supplies only the options session key, the credential's owner, the result DTO and the flush. See [Interface implementations](../interface-implementations.md#reference-impl-passkey-assertion-verifier).
- `PasskeyAssertionOptionsBuilderInterface`, `PasskeyAssertionVerifierInterface`, `PasskeyAssertionResultInterface` — the assertion contracts (the abstract above implements the verifier one).
- `PasskeyCredentialRecordInterface` / `PasskeyCredentialRepositoryInterface` — the per-user credential storage you implement (credential id, credential source serialized as JSON, label, sign counter, timestamps). A user can register several, each labelled (e.g. "MacBook Touch ID", "YubiKey"). See [Entities & persistence](../entities-and-persistence.md).
- `PasskeyExtension` Twig helper — `three_brs_passkey_enabled(group)` so a template renders the "Sign in with a passkey" button only when enabled.
- Flow controllers: `PasskeyLoginOptionsController` (concrete JSON endpoint) + the abstract `AbstractPasskeyLoginVerifyController`, `AbstractPasskeyRegistrationOptionsController`, `AbstractPasskeyRegistrationVerifyController`, `AbstractPasskeyListController`, `AbstractPasskeyDeleteController` — abstract methods and the full extend/register/route walkthrough are in [Controllers](../controllers.md) (the passkey login-verify flow is the worked example there).
Expand Down
7 changes: 6 additions & 1 deletion docs/features/session-management-login-notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ Active-session listing with manual revocation, plus optional email notifications

On a successful sign-in, compute a device fingerprint with `SessionFingerprintGenerator::generate($userAgent, $ipAddress)` — `sha256(userAgent + '|' + ipAddress)`. If that fingerprint isn't already stored for the user, persist it and send a notification email (time, parsed browser/OS, IP, and country/city when a GeoIP provider is wired). Subsequent logins from the same UA + IP are a known device and send no email.

The bundle ships the fingerprint generator; the known-device store and the mailer are yours (the bundle has no mail-transport coupling).
**Bundle primitives:**
- `SessionFingerprintGenerator` (`SessionFingerprintGeneratorInterface`) — the fingerprint itself.
- `AbstractNewDeviceDetector` — extend it. `checkAndRemember($user, $fingerprint)` answers "is this device new?" and remembers it in one step, which is the point: two concurrent sign-ins from the same device must not both read "unknown" and both send the mail. The insert race is settled by the unique key on your `(user, fingerprint)` columns — the subclass says which exception means "conflict" (`isConcurrentInsertConflict`), the abstract turns that into "already known". Other hooks bind the lookup, the record factory and Doctrine persistence (`isKnownDevice`, `createRecord`, `save`, `discardUnflushed`).
- `KnownDeviceRecordInterface` — the persisted row you implement, plus your own user accessors. See [Entities & persistence](../entities-and-persistence.md).

The mailer stays yours — the bundle has no mail-transport coupling.

> **First-time enable.** The known-device store is empty when you first turn `login_notifications` on, so every active user gets a notification at their next sign-in (every device is "new" until stored). To suppress the initial wave, pre-populate trusted `(user, fingerprint)` pairs before enabling.

Expand Down
133 changes: 94 additions & 39 deletions docs/interface-implementations.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,60 +128,115 @@ Bundle's `MagicLinkTokenGenerator` + `MagicLinkTokenValidator` are concrete —

## Reference impl: Passkey assertion verifier

Heavier example because the WebAuthn ceremony involves multiple bundle services. A complete skeleton:
The ceremony itself lives in the bundle — extend `AbstractPasskeyAssertionVerifier` and bind the four
things it cannot know:

```php
use Webauthn\AuthenticatorAssertionResponse;
use Webauthn\PublicKeyCredential;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthn\PublicKeyCredentialSource;
use ThreeBRS\EnterpriseSecurityBundle\Passkey\AbstractPasskeyAssertionVerifier;

class PasskeyAssertionVerifier implements PasskeyAssertionVerifierInterface
class PasskeyAssertionVerifier extends AbstractPasskeyAssertionVerifier implements PasskeyAssertionVerifierInterface
{
public function __construct(
protected SessionPasskeyOptionsStorageInterface $sessionStorage, // bundle
protected PasskeyWebauthnSerializerInterface $serializer, // bundle
protected PasskeyValidatorFactoryInterface $validatorFactory, // bundle
protected UserPasskeyCredentialRepository $repo, // your repo
protected EntityManagerInterface $em, // your EM
protected ClockInterface $clock,
) {}
UserPasskeyCredentialRepository $repo, // your repo, typed as the bundle interface
SessionPasskeyOptionsStorageInterface $sessionStorage, // bundle
PasskeyWebauthnSerializerInterface $serializer, // bundle
PasskeyValidatorFactoryInterface $validatorFactory, // bundle
ClockInterface $clock,
protected EntityManagerInterface $em, // your EM
) {
parent::__construct($repo, $sessionStorage, $serializer, $validatorFactory, $clock);
}

public function verify(string $credentialResponseJson, string $host): PasskeyAssertionResultInterface
// The session key your options endpoint stored the ceremony under, via
// SessionPasskeyOptionsStorageInterface::store(). One per firewall, so a ceremony
// started on the shop cannot be completed on the admin.
protected function getOptionsSessionKey(): string
{
// 1. Read + consume the pending ceremony options the options endpoint stored
// (same session key the options builder wrote them under).
$serializedOptions = $this->sessionStorage->consume(self::ASSERTION_OPTIONS_KEY)
?? throw new \RuntimeException('No passkey assertion ceremony in progress.');
return PasskeyAssertionOptionsBuilder::SESSION_KEY;
}

// 2. Deserialize the stored options and the browser's credential response.
$options = $this->serializer->deserialize($serializedOptions, PublicKeyCredentialRequestOptions::class);
$credential = $this->serializer->deserialize($credentialResponseJson, PublicKeyCredential::class);
// Your credential entity's owner accessor — `PasskeyCredentialRecordInterface` covers
// credential data only (id, source, label, timestamps), not the user association.
protected function resolveUser(PasskeyCredentialRecordInterface $credential): UserInterface
{
return $credential->getUser();
}

$response = $credential->response;
if (!$response instanceof AuthenticatorAssertionResponse) {
throw new \RuntimeException('Expected an assertion response from the client.');
}
// A small DTO of yours implementing PasskeyAssertionResultInterface (`getUser()`).
protected function createResult(UserInterface $user): PasskeyAssertionResultInterface
{
return new PasskeyAssertionResult($user);
}

protected function commit(): void
{
$this->em->flush();
}
}
```

What the abstract runs, in order: consume the pending ceremony options (absent ⇒ refuse), deserialize
them and the browser's credential response, refuse anything that is not an
`AuthenticatorAssertionResponse`, look the stored credential up by its raw id (unknown ⇒ refuse),
rebuild its `PublicKeyCredentialSource`, run the WebAuthn check (signature, RP id, sign-count, …),
write the updated source and `lastUsedAt` back, and `commit()`.

**The commit belongs inside the verifier, not in your controller.** Persisting the bumped sign-count
has to be atomic with the check, or two concurrent assertions replaying the same authenticator
response both pass before either one's counter lands. That is why `commit()` is a hook rather than
something the caller does afterwards.

## Reference impl: New-device detector

// 3. Look up the stored credential by its raw id; rebuild its source object.
$stored = $this->repo->findOneByCredentialId($credential->rawId)
?? throw new \RuntimeException('Unknown credential.');
$source = $this->serializer->denormalize($stored->getCredentialSource(), PublicKeyCredentialSource::class);
Same shape, smaller surface — `AbstractNewDeviceDetector` owns the check-and-remember step behind
login notifications:

// 4. Run the WebAuthn assertion check (signature, RP id, sign-count, …); returns the updated source.
$updatedSource = $this->validatorFactory->createAssertionValidator()->check(
$source, $response, $options, $host, $source->userHandle,
);
```php
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use ThreeBRS\EnterpriseSecurityBundle\Session\AbstractNewDeviceDetector;
use ThreeBRS\EnterpriseSecurityBundle\Session\KnownDeviceRecordInterface;

class NewDeviceDetector extends AbstractNewDeviceDetector
{
public function __construct(
protected UserKnownDeviceRepository $repository,
protected EntityManagerInterface $em,
) {}

protected function isKnownDevice(UserInterface $user, string $fingerprint): bool
{
return $this->repository->existsForUser($user, $fingerprint);
}

protected function createRecord(UserInterface $user, string $fingerprint): KnownDeviceRecordInterface
{
$device = new UserKnownDevice();
$device->setUser($user);
$device->setFingerprint($fingerprint);

return $device;
}

// 5. Persist the bumped sign-count + lastUsedAt — flush here, atomic with the check,
// to close the replay window between concurrent assertions.
$stored->setCredentialSource($this->serializer->normalize($updatedSource));
$stored->setLastUsedAt($this->clock->now());
protected function save(KnownDeviceRecordInterface $record): void
{
$this->em->persist($record);
$this->em->flush();
}

return new PasskeyAssertionResult($stored->getUser());
protected function discardUnflushed(KnownDeviceRecordInterface $record): void
{
$this->em->detach($record);
}

protected function isConcurrentInsertConflict(\Throwable $exception): bool
{
return $exception instanceof UniqueConstraintViolationException;
}
}
```

`PasskeyAssertionResult` is a small DTO you write implementing `PasskeyAssertionResultInterface` (`getUser()`). `ASSERTION_OPTIONS_KEY` is the session key your options endpoint stored the ceremony under via `SessionPasskeyOptionsStorageInterface::store()`. `$stored->getUser()` is your credential entity's owner accessor — the bundle's `PasskeyCredentialRecordInterface` covers credential data only (id, source, label, timestamps), not the user association, so expose the owner on your own entity.
`checkAndRemember($user, $fingerprint)` returns true only for a device that was not on record — and
records it on the way out, so the second call for the same pair returns false. **Your table needs a
unique key over (user, fingerprint)**: that constraint is what makes a concurrent sign-in from the
same device fail its insert instead of both requests reporting a new device and emailing the user
twice. Without it the detector still works, it just loses the race protection.
Loading
Loading