Skip to content
Open
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
79 changes: 79 additions & 0 deletions docs/securitybook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions src/Account/Infrastructure/Security/FunnyGreetingProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace App\Account\Infrastructure\Security;

final class FunnyGreetingProvider
{
public const string FLASH_TYPE = 'auth_greeting';

/**
* @var non-empty-list<non-empty-string>
*/
private const array GREETING_KEYS = [
'auth.greeting.1',
'auth.greeting.2',
'auth.greeting.3',
'auth.greeting.4',
'auth.greeting.5',
];

/**
* @return non-empty-list<non-empty-string>
*/
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];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

declare(strict_types=1);

namespace App\Account\Infrastructure\Security;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;

#[AsEventListener(event: LoginSuccessEvent::class, method: 'handle')]
final readonly class LoginSuccessFunnyGreetingListener
{
private const string MAIN_FIREWALL_NAME = 'main';

public function __construct(
private FunnyGreetingProvider $funnyGreetingProvider,
) {
}

public function handle(LoginSuccessEvent $event): void
{
if (!$this->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';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
<div class="{{ alert_classes }}" role="alert">
{{ message }}
<div class="{{ alert_classes }}" role="alert" data-flash-type="{{ type }}">
{% if type is same as 'auth_greeting' %}
{{ message|trans }}
{% else %}
{{ message }}
{% endif %}
</div>
{% endfor %}
{% endfor %}
Expand Down
81 changes: 81 additions & 0 deletions tests/Application/Account/SignInTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
namespace App\Tests\Application\Account;

use App\Account\Domain\Service\AccountDomainService;
use App\Account\Infrastructure\Security\FunnyGreetingProvider;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Contracts\Translation\TranslatorInterface;

/**
* Tests the sign-in flow to prevent regressions in form field configuration.
Expand All @@ -18,6 +21,8 @@ final class SignInTest extends WebTestCase
{
private KernelBrowser $client;
private AccountDomainService $accountDomainService;
private FunnyGreetingProvider $funnyGreetingProvider;
private TranslatorInterface $translator;

protected function setUp(): void
{
Expand All @@ -27,6 +32,14 @@ protected function setUp(): void
/** @var AccountDomainService $accountDomainService */
$accountDomainService = $container->get(AccountDomainService::class);
$this->accountDomainService = $accountDomainService;

/** @var FunnyGreetingProvider $funnyGreetingProvider */
$funnyGreetingProvider = $container->get(FunnyGreetingProvider::class);
$this->funnyGreetingProvider = $funnyGreetingProvider;

/** @var TranslatorInterface $translator */
$translator = $container->get(TranslatorInterface::class);
$this->translator = $translator;
}

public function testSignInWithValidCredentialsRedirectsToProjects(): void
Expand Down Expand Up @@ -54,6 +67,12 @@ public function testSignInWithValidCredentialsRedirectsToProjects(): void
$this->client->followRedirect();
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('h1', 'Your projects');
$this->assertGreetingOccurrences('en', 1);

// Flash should only be shown on the first page after login.
$this->client->request('GET', '/en/projects');
self::assertResponseIsSuccessful();
$this->assertGreetingOccurrences('en', 0);
}

public function testSignInWithInvalidCredentialsShowsError(): void
Expand Down Expand Up @@ -95,6 +114,68 @@ public function testSignInFormHasCorrectFieldNames(): void
self::assertCount(0, $crawler->filter('input[name="_password"]'));
}

public function testSignInShowsLocalizedGreetingInGerman(): void
{
// Arrange: Create a test user
$email = 'test-signin-de-' . uniqid() . '@example.com';
$plainPassword = 'test-password-123';

$this->createTestUser($email, $plainPassword);

// Act: Submit the German login form
$crawler = $this->client->request('GET', '/de/account/sign-in');

$form = $crawler->selectButton('Weiter')->form([
'email' => $email,
'password' => $plainPassword,
]);

$this->client->submit($form);

// Assert: Redirect and localized greeting on the first rendered page.
self::assertResponseRedirects('/de/projects');
$this->client->followRedirect();
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('h1', 'Ihre Projekte');
$this->assertGreetingOccurrences('de', 1);
}

private function assertGreetingOccurrences(string $locale, int $expectedOccurrences): void
{
$responseContent = $this->client->getResponse()->getContent();
self::assertIsString($responseContent);

$crawler = new Crawler($responseContent);
$greetingFlashNodes = $crawler->filter(sprintf('div[role="alert"][data-flash-type="%s"]', FunnyGreetingProvider::FLASH_TYPE));
self::assertCount($expectedOccurrences, $greetingFlashNodes);

if ($expectedOccurrences === 0) {
return;
}

$expectedGreetings = $this->getLocalizedGreetings($locale);
$actualGreetings = $greetingFlashNodes->each(
static fn (Crawler $greetingFlashNode): string => trim($greetingFlashNode->text(''))
);
foreach ($actualGreetings as $actualGreeting) {
self::assertContains($actualGreeting, $expectedGreetings);
}
}

/**
* @return list<string>
*/
private function getLocalizedGreetings(string $locale): array
{
$greetingKeys = $this->funnyGreetingProvider->getAvailableGreetingKeys();
$localizedGreetings = [];
foreach ($greetingKeys as $greetingKey) {
$localizedGreetings[] = $this->translator->trans($greetingKey, [], null, $locale);
}

return $localizedGreetings;
}

private function createTestUser(string $email, string $plainPassword): void
{
// Use proper registration to trigger organization creation via event
Expand Down
Loading
Loading