diff --git a/docs/securitybook.md b/docs/securitybook.md index 914b891e..9eb7c553 100644 --- a/docs/securitybook.md +++ b/docs/securitybook.md @@ -30,6 +30,85 @@ providers: `SecurityUserProvider` also implements `PasswordUpgraderInterface`, so Symfony can transparently rehash passwords when the hashing algorithm changes -- without any controller involvement. +## Post-sign-in greeting flash messages + +After a successful authentication on the `main` firewall, the app shows one localized nerd-humor greeting as a one-time flash message on the first rendered page. + +### Why this exists + +- Provides lightweight positive feedback that sign-in succeeded. +- Keeps behavior centralized in one security hook instead of spreading it across controllers. + +### Implementation details + +1. `LoginSuccessFunnyGreetingListener` (`src/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListener.php`) listens to `LoginSuccessEvent`. +2. `FunnyGreetingProvider` (`src/Account/Infrastructure/Security/FunnyGreetingProvider.php`) owns the allowed translation keys and returns one random key per login. +3. The listener stores the **translation key** in the flash bag under type `auth_greeting`. +4. `base_appshell.html.twig` (`src/Common/Presentation/Resources/templates/base_appshell.html.twig`) renders that flash and translates it with Twig `trans`. + +### Runtime sequence (request lifecycle) + +1. A login authenticator succeeds (form login today; future authenticators can use the same firewall/event flow). +2. Symfony dispatches `LoginSuccessEvent`. +3. `LoginSuccessFunnyGreetingListener` evaluates guard conditions (firewall, token context, response type, request format, session capability). +4. If allowed, the listener adds exactly one flash entry: + - type: `auth_greeting` + - value: one random key from `FunnyGreetingProvider` (for example `auth.greeting.3`) +5. The authenticator redirects to the target page. +6. The first rendered page reads flashes from the session and translates `auth_greeting` values in Twig. +7. Symfony removes consumed flashes, so subsequent page loads do not show the greeting again. + +### Guardrails + +The listener intentionally skips adding a greeting when: + +- the login is not on the `main` firewall, +- there is a previous token (to avoid duplicate flashes from token refresh flows), +- a non-redirect custom response is already set, +- there is no session/flash bag available, +- the request is non-HTML (for example JSON/AJAX contexts). + +It also skips when an `auth_greeting` flash is already queued in the same request lifecycle. This protects against duplicate flashes if multiple success handlers/listeners run in one authentication path. + +### Guard matrix (technical) + +| Condition | Rationale | Result | +|---|---|---| +| Firewall is not `main` | Avoid side effects in non-app firewalls | Skip | +| `previousToken` is present | Treat as token refresh/reload flow, not fresh interactive sign-in | Skip | +| Event has non-redirect response | Respect custom success response semantics | Skip | +| Request is XHR/JSON/non-HTML | Avoid polluting API/AJAX channels with UI-only flash data | Skip | +| No session or no flash bag support | Cannot persist one-time page feedback safely | Skip | +| Existing `auth_greeting` flash already present | Prevent duplicate greeting rendering | Skip | +| None of the above | Fresh interactive web login | Add random greeting flash | + +### Translation keys + +The greeting texts live in: + +- `translations/messages.en.yaml` under `auth.greeting.1` ... `auth.greeting.5` +- `translations/messages.de.yaml` under `auth.greeting.1` ... `auth.greeting.5` + +Because the flash stores keys (not pre-translated strings), rendering uses the active locale of the page shown after sign-in. + +### Testing strategy + +- Unit tests: + - `tests/Unit/Account/Infrastructure/Security/FunnyGreetingProviderTest.php` + - `tests/Unit/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListenerTest.php` +- Application test: + - `tests/Application/Account/SignInTest.php` +- Covered behavior: + - random key is always selected from the configured key set + - greeting flash is added for successful main-firewall sign-in + - guard paths skip greeting in non-applicable contexts + - greeting renders localized and is consumed after first page view + +### Extending this feature safely + +If a new web authenticator (for example SSO) is introduced, keep it on the same firewall/event path to inherit greeting behavior automatically. If additional firewalls need this behavior, update the listener guard policy explicitly and add matching tests for each new authentication path. + + ## CSRF Protection: Stateless Tokens The application uses **stateless CSRF tokens** (Symfony 7.2+), not the traditional session-bound tokens. This is a fundamentally different protection model. diff --git a/src/Account/Infrastructure/Security/FunnyGreetingProvider.php b/src/Account/Infrastructure/Security/FunnyGreetingProvider.php new file mode 100644 index 00000000..1f51a267 --- /dev/null +++ b/src/Account/Infrastructure/Security/FunnyGreetingProvider.php @@ -0,0 +1,37 @@ + + */ + private const array GREETING_KEYS = [ + 'auth.greeting.1', + 'auth.greeting.2', + 'auth.greeting.3', + 'auth.greeting.4', + 'auth.greeting.5', + ]; + + /** + * @return non-empty-list + */ + public function getAvailableGreetingKeys(): array + { + return self::GREETING_KEYS; + } + + public function getRandomGreetingKey(): string + { + $greetingKeys = $this->getAvailableGreetingKeys(); + $keyIndex = random_int(0, count($greetingKeys) - 1); + + return $greetingKeys[$keyIndex]; + } +} diff --git a/src/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListener.php b/src/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListener.php new file mode 100644 index 00000000..025ed013 --- /dev/null +++ b/src/Account/Infrastructure/Security/LoginSuccessFunnyGreetingListener.php @@ -0,0 +1,78 @@ +isHandledFirewallLogin($event)) { + return; + } + + $response = $event->getResponse(); + if ($response !== null && !$response instanceof RedirectResponse) { + return; + } + + $request = $event->getRequest(); + if (!$this->isHtmlRequest($request) || !$request->hasSession()) { + return; + } + + $session = $request->getSession(); + if (!$session instanceof FlashBagAwareSessionInterface) { + return; + } + + $flashBag = $session->getFlashBag(); + if ($flashBag->peek(FunnyGreetingProvider::FLASH_TYPE) !== []) { + return; + } + + $flashBag->add(FunnyGreetingProvider::FLASH_TYPE, $this->funnyGreetingProvider->getRandomGreetingKey()); + } + + private function isHandledFirewallLogin(LoginSuccessEvent $event): bool + { + if ($event->getFirewallName() !== self::MAIN_FIREWALL_NAME) { + return false; + } + + // Ignore token refresh/reload paths that can emit login-success events + // but should not display a second greeting flash. + return $event->getPreviousToken() === null; + } + + private function isHtmlRequest(Request $request): bool + { + if ($request->isXmlHttpRequest()) { + return false; + } + + $requestFormat = $request->getRequestFormat(''); + if ($requestFormat !== '' && $requestFormat !== 'html') { + return false; + } + + $preferredFormat = $request->getPreferredFormat(''); + + return $preferredFormat === '' || $preferredFormat === 'html'; + } +} diff --git a/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php b/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php index 59a1a139..d6096949 100644 --- a/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php +++ b/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php @@ -147,24 +147,41 @@ public function __invoke(RunEditSessionMessage $message): void return; } - if ($chunk->chunkType === EditStreamChunkType::Text && $chunk->content !== null) { - EditSessionChunk::createTextChunk($session, $chunk->content); - } elseif ($chunk->chunkType === EditStreamChunkType::Event && $chunk->event !== null) { - $eventJson = $this->serializeEvent($chunk->event); - $contextBytes = ($chunk->event->inputBytes ?? 0) + ($chunk->event->resultBytes ?? 0); - EditSessionChunk::createEventChunk($session, $eventJson, $contextBytes > 0 ? $contextBytes : null); - } elseif ($chunk->chunkType === EditStreamChunkType::Progress && $chunk->content !== null) { - EditSessionChunk::createProgressChunk($session, $chunk->content); - } elseif ($chunk->chunkType === EditStreamChunkType::Message && $chunk->message !== null) { - // Persist new conversation messages - $this->persistConversationMessage($conversation, $chunk->message); - } elseif ($chunk->chunkType === EditStreamChunkType::Done) { - $streamEndedWithFailure = ($chunk->success ?? false) !== true; - EditSessionChunk::createDoneChunk( - $session, - $chunk->success ?? false, - $chunk->errorMessage - ); + switch ($chunk->chunkType) { + case EditStreamChunkType::Text: + if ($chunk->content !== null) { + EditSessionChunk::createTextChunk($session, $chunk->content); + } + + break; + case EditStreamChunkType::Event: + if ($chunk->event !== null) { + $eventJson = $this->serializeEvent($chunk->event); + $contextBytes = ($chunk->event->inputBytes ?? 0) + ($chunk->event->resultBytes ?? 0); + EditSessionChunk::createEventChunk($session, $eventJson, $contextBytes > 0 ? $contextBytes : null); + } + + break; + case EditStreamChunkType::Progress: + if ($chunk->content !== null) { + EditSessionChunk::createProgressChunk($session, $chunk->content); + } + + break; + case EditStreamChunkType::Message: + if ($chunk->message !== null) { + // Persist new conversation messages + $this->persistConversationMessage($conversation, $chunk->message); + } + + break; + case EditStreamChunkType::Done: + $streamEndedWithFailure = ($chunk->success ?? false) !== true; + EditSessionChunk::createDoneChunk( + $session, + $chunk->success ?? false, + $chunk->errorMessage + ); } $this->entityManager->flush(); diff --git a/src/Common/Presentation/Resources/templates/base_appshell.html.twig b/src/Common/Presentation/Resources/templates/base_appshell.html.twig index c04fc0b4..8ccad4ee 100644 --- a/src/Common/Presentation/Resources/templates/base_appshell.html.twig +++ b/src/Common/Presentation/Resources/templates/base_appshell.html.twig @@ -48,8 +48,12 @@ {% else %} {% set alert_classes = alert_classes ~ ' bg-blue-50 border-blue-300 dark:bg-blue-900/30 dark:border-blue-700/50 text-blue-800 dark:text-blue-200' %} {% endif %} -