Skip to content

Commit 552196d

Browse files
committed
Password reset logic
1 parent 621e93d commit 552196d

File tree

9 files changed

+530
-0
lines changed

9 files changed

+530
-0
lines changed

config/parameters.yml.dist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ parameters:
2929
env(MAILER_DSN): 'null://null'
3030
app.confirmation_url: '%%env(CONFIRMATION_URL)%%'
3131
env(CONFIRMATION_URL): 'https://example.com/confirm/'
32+
app.password_reset_url: '%%env(PASSWORD_RESET_URL)%%'
33+
env(PASSWORD_RESET_URL): 'https://example.com/reset/'
3234

3335
# Messenger configuration for asynchronous processing
3436
app.messenger_transport_dsn: '%%env(MESSENGER_TRANSPORT_DSN)%%'

config/services/managers.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,7 @@ services:
5959
PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager:
6060
autowire: true
6161
autoconfigure: true
62+
63+
PhpList\Core\Domain\Identity\Service\PasswordManager:
64+
autowire: true
65+
autoconfigure: true

config/services/messenger.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,10 @@ services:
1515
autowire: true
1616
autoconfigure: true
1717
tags: [ 'messenger.message_handler' ]
18+
19+
PhpList\Core\Domain\Messaging\MessageHandler\PasswordResetMessageHandler:
20+
autowire: true
21+
autoconfigure: true
22+
tags: [ 'messenger.message_handler' ]
23+
arguments:
24+
$passwordResetUrl: '%app.password_reset_url%'

src/Domain/Identity/Repository/AdminPasswordRequestRepository.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,20 @@
77
use PhpList\Core\Domain\Common\Repository\AbstractRepository;
88
use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait;
99
use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
10+
use PhpList\Core\Domain\Identity\Model\Administrator;
11+
use PhpList\Core\Domain\Identity\Model\AdminPasswordRequest;
1012

1113
class AdminPasswordRequestRepository extends AbstractRepository implements PaginatableRepositoryInterface
1214
{
1315
use CursorPaginationTrait;
16+
17+
public function findByAdmin(Administrator $administrator): array
18+
{
19+
return $this->findBy(['administrator' => $administrator]);
20+
}
21+
22+
public function findOneByToken(string $token): ?AdminPasswordRequest
23+
{
24+
return $this->findOneBy(['keyValue' => $token]);
25+
}
1426
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\Core\Domain\Identity\Service;
6+
7+
use DateTime;
8+
use PhpList\Core\Domain\Identity\Model\AdminPasswordRequest;
9+
use PhpList\Core\Domain\Identity\Model\Administrator;
10+
use PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository;
11+
use PhpList\Core\Domain\Identity\Repository\AdministratorRepository;
12+
use PhpList\Core\Domain\Messaging\Message\PasswordResetMessage;
13+
use PhpList\Core\Security\HashGenerator;
14+
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
15+
use Symfony\Component\Messenger\MessageBusInterface;
16+
17+
class PasswordManager
18+
{
19+
private const TOKEN_EXPIRY = '+24 hours';
20+
21+
private AdminPasswordRequestRepository $passwordRequestRepository;
22+
private AdministratorRepository $administratorRepository;
23+
private HashGenerator $hashGenerator;
24+
private MessageBusInterface $messageBus;
25+
26+
public function __construct(
27+
AdminPasswordRequestRepository $passwordRequestRepository,
28+
AdministratorRepository $administratorRepository,
29+
HashGenerator $hashGenerator,
30+
MessageBusInterface $messageBus
31+
) {
32+
$this->passwordRequestRepository = $passwordRequestRepository;
33+
$this->administratorRepository = $administratorRepository;
34+
$this->hashGenerator = $hashGenerator;
35+
$this->messageBus = $messageBus;
36+
}
37+
38+
/**
39+
* Generates a password reset token for the administrator with the given email.
40+
* Returns the token that should be sent to the user via email.
41+
*
42+
* @param string $email The email of the administrator
43+
* @return string The generated token
44+
* @throws NotFoundHttpException If no administrator with the given email exists
45+
*/
46+
public function generatePasswordResetToken(string $email): string
47+
{
48+
$administrator = $this->administratorRepository->findOneBy(['email' => $email]);
49+
if ($administrator === null) {
50+
throw new NotFoundHttpException('Administrator not found', null, 1500567100);
51+
}
52+
53+
$existingRequests = $this->passwordRequestRepository->findByAdmin($administrator);
54+
foreach ($existingRequests as $request) {
55+
$this->passwordRequestRepository->remove($request);
56+
}
57+
58+
$token = md5(random_bytes(256));
59+
60+
$expiryDate = new DateTime(self::TOKEN_EXPIRY);
61+
$passwordRequest = new AdminPasswordRequest(date: $expiryDate, admin: $administrator, keyValue: $token);
62+
63+
$this->passwordRequestRepository->save($passwordRequest);
64+
65+
$message = new PasswordResetMessage(email: $email, token: $token);
66+
$this->messageBus->dispatch($message);
67+
68+
return $token;
69+
}
70+
71+
/**
72+
* Validates a password reset token.
73+
* Returns the administrator if the token is valid, null otherwise.
74+
*
75+
* @param string $token The token to validate
76+
* @return Administrator|null The administrator if the token is valid, null otherwise
77+
*/
78+
public function validatePasswordResetToken(string $token): ?Administrator
79+
{
80+
$passwordRequest = $this->passwordRequestRepository->findOneByToken($token);
81+
if ($passwordRequest === null) {
82+
return null;
83+
}
84+
85+
$now = new DateTime();
86+
if ($now >= $passwordRequest->getDate()) {
87+
$this->passwordRequestRepository->remove($passwordRequest);
88+
return null;
89+
}
90+
91+
return $passwordRequest->getAdmin();
92+
}
93+
94+
/**
95+
* Updates the password for the administrator with the given token.
96+
* Returns true if the password was updated successfully, false otherwise.
97+
*
98+
* @param string $token The password reset token
99+
* @param string $newPassword The new password
100+
* @return bool True if the password was updated successfully, false otherwise
101+
*/
102+
public function updatePasswordWithToken(string $token, string $newPassword): bool
103+
{
104+
$administrator = $this->validatePasswordResetToken($token);
105+
if ($administrator === null) {
106+
return false;
107+
}
108+
109+
$passwordHash = $this->hashGenerator->createPasswordHash($newPassword);
110+
$administrator->setPasswordHash($passwordHash);
111+
$this->administratorRepository->save($administrator);
112+
113+
$passwordRequest = $this->passwordRequestRepository->findOneByToken($token);
114+
$this->passwordRequestRepository->remove($passwordRequest);
115+
116+
return true;
117+
}
118+
119+
/**
120+
* Cleans up expired password reset requests.
121+
*/
122+
public function cleanupExpiredTokens(): void
123+
{
124+
$now = new DateTime();
125+
$allRequests = $this->passwordRequestRepository->findAll();
126+
foreach ($allRequests as $request) {
127+
if ($now >= $request->getDate()) {
128+
$this->passwordRequestRepository->remove($request);
129+
}
130+
}
131+
}
132+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\Core\Domain\Messaging\Message;
6+
7+
class PasswordResetMessage
8+
{
9+
private string $email;
10+
private string $token;
11+
12+
public function __construct(string $email, string $token)
13+
{
14+
$this->email = $email;
15+
$this->token = $token;
16+
}
17+
18+
public function getEmail(): string
19+
{
20+
return $this->email;
21+
}
22+
23+
public function getToken(): string
24+
{
25+
return $this->token;
26+
}
27+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\Core\Domain\Messaging\MessageHandler;
6+
7+
use PhpList\Core\Domain\Messaging\Message\PasswordResetMessage;
8+
use PhpList\Core\Domain\Messaging\Service\EmailService;
9+
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
10+
use Symfony\Component\Mime\Email;
11+
12+
#[AsMessageHandler]
13+
class PasswordResetMessageHandler
14+
{
15+
private EmailService $emailService;
16+
private string $passwordResetUrl;
17+
18+
public function __construct(EmailService $emailService, string $passwordResetUrl)
19+
{
20+
$this->emailService = $emailService;
21+
$this->passwordResetUrl = $passwordResetUrl;
22+
}
23+
24+
/**
25+
* Process a subscriber confirmation message by sending the confirmation email
26+
*/
27+
public function __invoke(PasswordResetMessage $message): void
28+
{
29+
$confirmationLink = $this->generateLink($message->getToken());
30+
31+
$subject = 'Password Reset Request';
32+
$textContent = "Hello,\n\n"
33+
. "A password reset has been requested for your account.\n"
34+
. "Please use the following token to reset your password:\n\n"
35+
. $message->getToken()
36+
. "\n\nIf you did not request this password reset, please ignore this email.\n\nThank you.";
37+
38+
$htmlContent = '<p>Password Reset Request!</p>'
39+
. '<p>Hello! A password reset has been requested for your account.</p>'
40+
. '<p>Please use the following token to reset your password:</p>'
41+
. '<p><a href="' . $confirmationLink . '">Reset Password</a></p>'
42+
. '<p>If you did not request this password reset, please ignore this email.</p>'
43+
. '<p>Thank you.</p>';
44+
45+
$email = (new Email())
46+
->to($message->getEmail())
47+
->subject($subject)
48+
->text($textContent)
49+
->html($htmlContent);
50+
51+
$this->emailService->sendEmail($email);
52+
}
53+
54+
private function generateLink(string $uniqueId): string
55+
{
56+
return $this->passwordResetUrl . '?uniqueId=' . urlencode($uniqueId);
57+
}
58+
}

0 commit comments

Comments
 (0)