Skip to content

Commit 71f0d37

Browse files
committed
Log failed logins + translate messages
1 parent 68ffccc commit 71f0d37

File tree

7 files changed

+157
-6
lines changed

7 files changed

+157
-6
lines changed

config/services/services.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,10 @@ services:
107107
PhpList\Core\Domain\Messaging\Service\BounceActionResolver:
108108
arguments:
109109
- !tagged_iterator { tag: 'phplist.bounce_action_handler' }
110+
111+
# I18n
112+
PhpList\Core\Domain\Common\I18n\SimpleTranslator:
113+
autowire: true
114+
autoconfigure: true
115+
116+
PhpList\Core\Domain\Common\I18n\TranslatorInterface: '@PhpList\Core\Domain\Common\I18n\SimpleTranslator'
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
use PhpList\Core\Domain\Common\I18n\Messages;
4+
5+
return [
6+
// Authentication
7+
Messages::AUTH_NOT_AUTHORIZED => 'Not authorized',
8+
Messages::AUTH_LOGIN_FAILED => "Failed admin login attempt for '{login}'",
9+
Messages::AUTH_LOGIN_DISABLED => "Login attempt for disabled admin '{login}'",
10+
];
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\Core\Domain\Common\I18n;
6+
7+
/**
8+
* Centralized message keys to be used across the application.
9+
* These keys map to translation strings in resources/translations.
10+
*/
11+
final class Messages
12+
{
13+
// Authentication / Authorization
14+
public const AUTH_NOT_AUTHORIZED = 'auth.not_authorized';
15+
public const AUTH_LOGIN_FAILED = 'auth.login_failed';
16+
public const AUTH_LOGIN_DISABLED = 'auth.login_disabled';
17+
18+
private function __construct()
19+
{
20+
}
21+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\Core\Domain\Common\I18n;
6+
7+
/**
8+
* Minimal translator to support message keys and parameter interpolation.
9+
* Designed to be compatible with future integration with Symfony Translator and POEditor.
10+
*/
11+
class SimpleTranslator implements TranslatorInterface
12+
{
13+
private string $defaultLocale;
14+
15+
/** @var array<string,array<string,string>> */
16+
private array $catalogues = [];
17+
18+
public function __construct(string $defaultLocale = 'en')
19+
{
20+
$this->defaultLocale = $defaultLocale;
21+
}
22+
23+
public function translate(string $key, array $params = [], ?string $locale = null): string
24+
{
25+
$loc = $locale ?? $this->defaultLocale;
26+
$messages = $this->loadCatalogue($loc);
27+
$message = $messages[$key] ?? $key;
28+
29+
$replacements = [];
30+
foreach ($params as $name => $value) {
31+
$replacements['{' . $name . '}'] = (string)$value;
32+
}
33+
34+
return strtr($message, $replacements);
35+
}
36+
37+
/**
38+
* @return array<string,string>
39+
*/
40+
private function loadCatalogue(string $locale): array
41+
{
42+
if (!isset($this->catalogues[$locale])) {
43+
$pathPhp = __DIR__ . '/../../../../resources/translations/messages.' . $locale . '.php';
44+
if (is_file($pathPhp)) {
45+
/** @var array<string,string> $messages */
46+
$messages = include $pathPhp;
47+
} else {
48+
$fallback = __DIR__ . '/../../../../resources/translations/messages.en.php';
49+
if (is_file($fallback)) {
50+
/** @var array<string,string> $messages */
51+
$messages = include $fallback;
52+
} else {
53+
$messages = [];
54+
}
55+
}
56+
$this->catalogues[$locale] = $messages;
57+
}
58+
return $this->catalogues[$locale];
59+
}
60+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\Core\Domain\Common\I18n;
6+
7+
interface TranslatorInterface
8+
{
9+
/**
10+
* Translate a message key with optional parameters.
11+
* @param string $key Message key (e.g., Messages::AUTH_NOT_AUTHORIZED)
12+
* @param array<string,string|int|float> $params Placeholder values (e.g., ['login' => 'admin'])
13+
* @param string|null $locale Optional locale (defaults to environment/app locale)
14+
*/
15+
public function translate(string $key, array $params = [], ?string $locale = null): string;
16+
}

src/Domain/Identity/Service/SessionManager.php

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
namespace PhpList\Core\Domain\Identity\Service;
66

7+
use PhpList\Core\Domain\Common\I18n\Messages;
8+
use PhpList\Core\Domain\Common\I18n\TranslatorInterface;
9+
use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager;
710
use PhpList\Core\Domain\Identity\Model\AdministratorToken;
811
use PhpList\Core\Domain\Identity\Repository\AdministratorRepository;
912
use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository;
@@ -13,24 +16,36 @@ class SessionManager
1316
{
1417
private AdministratorTokenRepository $tokenRepository;
1518
private AdministratorRepository $administratorRepository;
19+
private EventLogManager $eventLogManager;
20+
private TranslatorInterface $translator;
1621

1722
public function __construct(
1823
AdministratorTokenRepository $tokenRepository,
19-
AdministratorRepository $administratorRepository
24+
AdministratorRepository $administratorRepository,
25+
EventLogManager $eventLogManager,
26+
TranslatorInterface $translator
2027
) {
2128
$this->tokenRepository = $tokenRepository;
2229
$this->administratorRepository = $administratorRepository;
30+
$this->eventLogManager = $eventLogManager;
31+
$this->translator = $translator;
2332
}
2433

2534
public function createSession(string $loginName, string $password): AdministratorToken
2635
{
2736
$administrator = $this->administratorRepository->findOneByLoginCredentials($loginName, $password);
2837
if ($administrator === null) {
29-
throw new UnauthorizedHttpException('', 'Not authorized', null, 1500567098);
38+
$entry = $this->translator->translate(Messages::AUTH_LOGIN_FAILED, ['login' => $loginName]);
39+
$this->eventLogManager->log('login', $entry);
40+
$message = $this->translator->translate(Messages::AUTH_NOT_AUTHORIZED);
41+
throw new UnauthorizedHttpException('', $message, null, 1500567098);
3042
}
3143

3244
if ($administrator->isDisabled()) {
33-
throw new UnauthorizedHttpException('', 'Not authorized', null, 1500567099);
45+
$entry = $this->translator->translate(Messages::AUTH_LOGIN_DISABLED, ['login' => $loginName]);
46+
$this->eventLogManager->log('login', $entry);
47+
$message = $this->translator->translate(Messages::AUTH_NOT_AUTHORIZED);
48+
throw new UnauthorizedHttpException('', $message, null, 1500567099);
3449
}
3550

3651
$token = new AdministratorToken();

tests/Unit/Domain/Identity/Service/SessionManagerTest.php

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
namespace PhpList\Core\Tests\Unit\Domain\Identity\Service;
66

7+
use PhpList\Core\Domain\Common\I18n\Messages;
8+
use PhpList\Core\Domain\Common\I18n\TranslatorInterface;
9+
use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager;
710
use PhpList\Core\Domain\Identity\Model\AdministratorToken;
811
use PhpList\Core\Domain\Identity\Repository\AdministratorRepository;
912
use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository;
@@ -13,7 +16,7 @@
1316

1417
class SessionManagerTest extends TestCase
1518
{
16-
public function testCreateSessionWithInvalidCredentialsThrowsException(): void
19+
public function testCreateSessionWithInvalidCredentialsThrowsExceptionAndLogs(): void
1720
{
1821
$adminRepo = $this->createMock(AdministratorRepository::class);
1922
$adminRepo->expects(self::once())
@@ -24,7 +27,24 @@ public function testCreateSessionWithInvalidCredentialsThrowsException(): void
2427
$tokenRepo = $this->createMock(AdministratorTokenRepository::class);
2528
$tokenRepo->expects(self::never())->method('save');
2629

27-
$manager = new SessionManager($tokenRepo, $adminRepo);
30+
$eventLogManager = $this->createMock(EventLogManager::class);
31+
$eventLogManager->expects(self::once())
32+
->method('log')
33+
->with('login', $this->stringContains('admin'));
34+
35+
$translator = $this->createMock(TranslatorInterface::class);
36+
$translator->expects(self::exactly(2))
37+
->method('translate')
38+
->withConsecutive(
39+
[Messages::AUTH_LOGIN_FAILED, ['login' => 'admin']],
40+
[Messages::AUTH_NOT_AUTHORIZED, []]
41+
)
42+
->willReturnOnConsecutiveCalls(
43+
"Failed admin login attempt for 'admin'",
44+
'Not authorized'
45+
);
46+
47+
$manager = new SessionManager($tokenRepo, $adminRepo, $eventLogManager, $translator);
2848

2949
$this->expectException(UnauthorizedHttpException::class);
3050
$this->expectExceptionMessage('Not authorized');
@@ -42,8 +62,10 @@ public function testDeleteSessionCallsRemove(): void
4262
->with($token);
4363

4464
$adminRepo = $this->createMock(AdministratorRepository::class);
65+
$eventLogManager = $this->createMock(EventLogManager::class);
66+
$translator = $this->createMock(TranslatorInterface::class);
4567

46-
$manager = new SessionManager($tokenRepo, $adminRepo);
68+
$manager = new SessionManager($tokenRepo, $adminRepo, $eventLogManager, $translator);
4769
$manager->deleteSession($token);
4870
}
4971
}

0 commit comments

Comments
 (0)