Skip to content

Commit 920f794

Browse files
committed
Refactor MailCrypt key creation on login
1 parent dbf909c commit 920f794

File tree

6 files changed

+192
-55
lines changed

6 files changed

+192
-55
lines changed

config/services.yaml

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,8 @@ services:
6767
$mailLocation: '%env(DOVECOT_MAIL_LOCATION)%'
6868

6969
App\Command\VoucherCreateCommand:
70-
arguments:
71-
$appUrl: "%env(APP_URL)%"
70+
arguments:
71+
$appUrl: '%env(APP_URL)%'
7272

7373
App\Controller\DovecotController:
7474
arguments:
@@ -82,6 +82,10 @@ services:
8282
tags:
8383
- { name: kernel.event_subscriber }
8484

85+
App\EventListener\LoginListener:
86+
arguments:
87+
$mailCryptEnv: '%env(MAIL_CRYPT)%'
88+
8589
App\EventListener\LocaleListener:
8690
arguments:
8791
$defaultLocale: '%locale%'
@@ -122,14 +126,14 @@ services:
122126
$to: '%env(NOTIFICATION_ADDRESS)%'
123127

124128
App\Handler\UserRestoreHandler:
125-
arguments:
126-
$mailCryptEnv: "%env(MAIL_CRYPT)%"
129+
arguments:
130+
$mailCryptEnv: '%env(MAIL_CRYPT)%'
127131

128132
App\Handler\RegistrationHandler:
129-
public: true
130-
arguments:
131-
$registrationOpen: "%env(REGISTRATION_OPEN)%"
132-
$mailCrypt: '%env(MAIL_CRYPT)%'
133+
public: true
134+
arguments:
135+
$registrationOpen: '%env(REGISTRATION_OPEN)%'
136+
$mailCrypt: '%env(MAIL_CRYPT)%'
133137

134138
App\Handler\SuspiciousChildrenHandler:
135139
arguments:

src/Controller/DovecotController.php

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -95,19 +95,6 @@ public function authenticate(
9595
return $this->json(['message' => self::MESSAGE_AUTHENTICATION_FAILED], Response::HTTP_UNAUTHORIZED);
9696
}
9797

98-
// If mailCrypt is enforced for all users, optionally create mailCrypt keypair for user
99-
if (
100-
$this->mailCrypt === MailCrypt::ENABLED_ENFORCE_ALL_USERS &&
101-
false === $user->getMailCryptEnabled() &&
102-
null === $user->getMailCryptPublicKey()
103-
) {
104-
try {
105-
$this->mailCryptKeyHandler->create($user, $request->getPassword(), true);
106-
} catch (Exception $exception) {
107-
return $this->json(['error' => $exception->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
108-
}
109-
}
110-
11198
// If mailCrypt is enabled and enabled for user, derive mailCryptPrivateKey
11299
if ($this->mailCrypt->isAtLeast(MailCrypt::ENABLED_OPTIONAL) && $user->getMailCryptEnabled()) {
113100
try {

src/Event/LoginEvent.php

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,27 @@
66
use App\Traits\UserAwareTrait;
77
use Symfony\Contracts\EventDispatcher\Event;
88

9-
/**
10-
* Class LoginEvent.
11-
*/
129
class LoginEvent extends Event
1310
{
1411
use UserAwareTrait;
1512

1613
public const NAME = 'user.login';
1714

18-
/**
19-
* Constructor.
20-
*/
21-
public function __construct(User $user)
15+
private string $plainPassword;
16+
17+
public function getPlainPassword(): string
18+
{
19+
return $this->plainPassword;
20+
}
21+
22+
public function setPlainPassword(string $plainPassword): void
23+
{
24+
$this->plainPassword = $plainPassword;
25+
}
26+
27+
public function __construct(User $user, string $plainPassword)
2228
{
2329
$this->user = $user;
30+
$this->plainPassword = $plainPassword;
2431
}
2532
}

src/EventListener/LoginListener.php

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,33 +3,59 @@
33
namespace App\EventListener;
44

55
use App\Entity\User;
6+
use App\Enum\MailCrypt;
67
use App\Event\LoginEvent;
78
use Doctrine\ORM\EntityManagerInterface;
89
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
910
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
1011
use Symfony\Component\Security\Http\SecurityEvents;
12+
use App\Handler\MailCryptKeyHandler;
1113

1214
readonly class LoginListener implements EventSubscriberInterface
1315
{
14-
public function __construct(private EntityManagerInterface $manager)
15-
{
16+
private readonly MailCrypt $mailCrypt;
17+
18+
public function __construct(
19+
private EntityManagerInterface $manager,
20+
private readonly MailCryptKeyHandler $mailCryptKeyHandler,
21+
private readonly int $mailCryptEnv,
22+
) {
23+
$this->mailCrypt = MailCrypt::from($this->mailCryptEnv);
1624
}
1725

1826
public function onSecurityInteractiveLogin(InteractiveLoginEvent $event): void
1927
{
2028
$user = $event->getAuthenticationToken()->getUser();
2129

2230
if ($user instanceof User) {
23-
$this->handleLogin($user);
31+
$password = $event->getRequest()->get('_password');
32+
$this->handleLogin($user, $password);
2433
}
2534
}
2635

27-
public function onLogin(LoginEvent $event): void
36+
public function onAuthenticationHandlerSuccess(LoginEvent $event): void
37+
{
38+
$this->handleLogin($event->getUser(), $event->getPlainPassword());
39+
}
40+
41+
private function handleLogin(User $user, ?string $password): void
2842
{
29-
$this->handleLogin($event->getUser());
43+
if ($this->mailCrypt === MailCrypt::ENABLED_ENFORCE_ALL_USERS && null !== $password) {
44+
$this->enableMailCrypt($user, $password);
45+
}
46+
47+
$this->updateLastLogin($user);
48+
}
49+
50+
private function enableMailCrypt(User $user, string $password): void
51+
{
52+
if ($user->getMailCryptEnabled() || null !== $user->getMailCryptPublicKey()) {
53+
return;
54+
}
55+
$this->mailCryptKeyHandler->create($user, $password, true);
3056
}
3157

32-
private function handleLogin(User $user): void
58+
private function updateLastLogin(User $user): void
3359
{
3460
$user->updateLastLoginTime();
3561
$this->manager->persist($user);
@@ -40,7 +66,7 @@ public static function getSubscribedEvents(): array
4066
{
4167
return [
4268
SecurityEvents::INTERACTIVE_LOGIN => 'onSecurityInteractiveLogin',
43-
LoginEvent::NAME => 'onLogin',
69+
LoginEvent::NAME => 'onAuthenticationHandlerSuccess',
4470
];
4571
}
4672
}

src/Handler/UserAuthenticationHandler.php

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,25 @@
77
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
88
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
99

10-
/**
11-
* Class UserAuthenticationHandler.
12-
*/
1310
class UserAuthenticationHandler
1411
{
15-
/**
16-
* UserAuthenticationHandler constructor.
17-
*/
18-
public function __construct(private readonly PasswordHasherFactoryInterface $passwordHasherFactory, protected EventDispatcherInterface $eventDispatcher)
19-
{
20-
}
12+
public function __construct(
13+
private readonly PasswordHasherFactoryInterface
14+
$passwordHasherFactory,
15+
protected EventDispatcherInterface $eventDispatcher
16+
) {}
2117

2218
public function authenticate(User $user, string $password): ?User
2319
{
2420
$hasher = $this->passwordHasherFactory->getPasswordHasher($user);
2521
if (!$hasher->verify($user->getPassword(), $password)) {
2622
return null;
2723
}
28-
$this->eventDispatcher->dispatch(new LoginEvent($user), LoginEvent::NAME);
2924

25+
$this->eventDispatcher->dispatch(
26+
new LoginEvent($user, $password),
27+
LoginEvent::NAME
28+
);
3029
return $user;
3130
}
3231
}

tests/EventListener/LoginListenerTest.php

Lines changed: 124 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,40 +5,84 @@
55
use App\Entity\User;
66
use App\Event\LoginEvent;
77
use App\EventListener\LoginListener;
8+
use App\Handler\MailCryptKeyHandler;
89
use App\Helper\PasswordUpdater;
910
use Doctrine\ORM\EntityManagerInterface;
11+
use PHPUnit\Framework\MockObject\MockBuilder;
1012
use PHPUnit\Framework\TestCase;
1113
use Symfony\Component\HttpFoundation\Request;
1214
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
1315
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
1416
use Symfony\Component\Security\Http\SecurityEvents;
17+
use function PHPUnit\Framework\assertCount;
18+
use function PHPUnit\Framework\assertEquals;
1519

1620
class LoginListenerTest extends TestCase
1721
{
1822
private EntityManagerInterface $manager;
1923
private LoginListener $listener;
24+
private LoginListener $listenerMailCrypt;
25+
private MailCryptKeyHandler $mailCryptKeyHandler;
2026

2127
public function setUp(): void
2228
{
2329
$this->manager = $this->getMockBuilder(EntityManagerInterface::class)
2430
->disableOriginalConstructor()
2531
->getMock();
26-
$this->listener = new LoginListener($this->manager);
32+
$this->passwordUpdater = $this->getMockBuilder(PasswordUpdater::class)
33+
->disableOriginalConstructor()
34+
->getMock();
35+
$this->mailCryptKeyHandler = $this->getMockBuilder(MailCryptKeyHandler::class)
36+
->disableOriginalConstructor()
37+
->getMock();
38+
$this->listener = new LoginListener(
39+
$this->manager,
40+
$this->mailCryptKeyHandler,
41+
2
42+
);
43+
// Enforces creation of mailCrypt on Login
44+
$this->listenerMailCrypt = new LoginListener(
45+
$this->manager,
46+
$this->mailCryptKeyHandler,
47+
3
48+
);
2749
}
2850

29-
public function testOnSecurityInteractiveLogin(): void
51+
/**
52+
* @dataProvider provider
53+
*/
54+
public function testOnSecurityInteractiveLogin(User $user, bool $createMailCrypt): void
3055
{
31-
$user = new User();
3256
$this->manager->expects($this->once())->method('flush');
33-
$event = $this->getEvent($user);
57+
$this->mailCryptKeyHandler->expects($this->never())->method('create');
58+
59+
$event = $this->getInteractiveEvent($user);
3460

3561
$this->listener->onSecurityInteractiveLogin($event);
3662
}
3763

64+
/**
65+
* @dataProvider provider
66+
*/
67+
public function testOnSecurityInteractiveLoginMailCrypt(User $user, bool $createMailCrypt): void
68+
{
69+
$this->manager->expects($this->once())->method('flush');
70+
71+
if ($createMailCrypt) {
72+
$this->mailCryptKeyHandler->expects($this->once())->method('create');
73+
} else {
74+
$this->mailCryptKeyHandler->expects($this->never())->method('create');
75+
}
76+
77+
$event = $this->getInteractiveEvent($user);
78+
79+
$this->listenerMailCrypt->onSecurityInteractiveLogin($event);
80+
}
81+
3882
/**
3983
* @return \PHPUnit_Framework_MockObject_MockObject|InteractiveLoginEvent
4084
*/
41-
private function getEvent(User $user): InteractiveLoginEvent
85+
private function getInteractiveEvent(User $user): InteractiveLoginEvent
4286
{
4387
$request = $this->getMockBuilder(Request::class)
4488
->disableOriginalConstructor()
@@ -60,12 +104,82 @@ private function getEvent(User $user): InteractiveLoginEvent
60104
return $event;
61105
}
62106

107+
108+
/**
109+
* @dataProvider provider
110+
*/
111+
public function testOnAuthenticationHandlerSuccess(User $user, bool $createMailCrypt): void
112+
{
113+
$this->manager->expects($this->once())->method('flush');
114+
$this->mailCryptKeyHandler->expects($this->never())->method('create');
115+
116+
$event = $this->getLoginEvent($user);
117+
118+
$this->listener->onAuthenticationHandlerSuccess($event);
119+
}
120+
121+
/**
122+
* @dataProvider provider
123+
*/
124+
public function testOnAuthenticationHandlerSuccessMailCrypt(User $user, bool $createMailCrypt): void
125+
{
126+
$this->manager->expects($this->once())->method('flush');
127+
128+
if ($createMailCrypt) {
129+
$this->mailCryptKeyHandler->expects($this->once())->method('create');
130+
} else {
131+
$this->mailCryptKeyHandler->expects($this->never())->method('create');
132+
}
133+
134+
$event = $this->getLoginEvent($user);
135+
136+
$this->listenerMailCrypt->onAuthenticationHandlerSuccess($event);
137+
}
138+
139+
/**
140+
* @return \PHPUnit_Framework_MockObject_MockObject|LoginEvent
141+
*/
142+
private function getLoginEvent(User $user): LoginEvent
143+
{
144+
$event = $this->getMockBuilder(LoginEvent::class)
145+
->disableOriginalConstructor()
146+
->getMock();
147+
148+
$event->method('getUser')->willReturn($user);
149+
$event->method('getPlainPassword')->willReturn('password');
150+
151+
return $event;
152+
}
153+
154+
public function provider(): array
155+
{
156+
return [
157+
[$this->getUser(null), true],
158+
[$this->getUser(false), true],
159+
[$this->getUser(true), false],
160+
];
161+
}
162+
163+
public function getUser(?bool $mailCryptEnabled): User
164+
{
165+
$user = new User();
166+
if ($mailCryptEnabled === false || $mailCryptEnabled === true) {
167+
$user->setMailCryptEnabled($mailCryptEnabled);
168+
}
169+
170+
return $user;
171+
}
172+
173+
174+
63175
public function testGetSubscribedEvents(): void
64176
{
65-
$this->assertEquals([
66-
SecurityEvents::INTERACTIVE_LOGIN => 'onSecurityInteractiveLogin',
67-
LoginEvent::NAME => 'onLogin',
68-
],
69-
$this->listener::getSubscribedEvents());
177+
$this->assertEquals(
178+
[
179+
SecurityEvents::INTERACTIVE_LOGIN => 'onSecurityInteractiveLogin',
180+
LoginEvent::NAME => 'onAuthenticationHandlerSuccess',
181+
],
182+
$this->listener::getSubscribedEvents()
183+
);
70184
}
71185
}

0 commit comments

Comments
 (0)