From 8b0aed63c30656d1629317c2ccd87b8edb36d534 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Tue, 20 Oct 2026 19:14:09 -0400 Subject: [PATCH] feat(security): dispatch an event when a password reset is requested Add PasswordResetRequestedEvent, dispatched for every password reset email request with the login name or email address that was entered, whether or not it matches an account. A listener can prevent the email, for example while one account is flooded with reset requests. The response stays the same either way, so it reveals nothing. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- core/Controller/LostController.php | 8 ++- lib/composer/composer/autoload_classmap.php | 1 + lib/composer/composer/autoload_static.php | 1 + .../Events/PasswordResetRequestedEvent.php | 60 +++++++++++++++++++ tests/Core/Controller/LostControllerTest.php | 57 ++++++++++++++++-- 5 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 lib/public/Security/Events/PasswordResetRequestedEvent.php diff --git a/core/Controller/LostController.php b/core/Controller/LostController.php index b25052306ce77..20dbd234d4235 100644 --- a/core/Controller/LostController.php +++ b/core/Controller/LostController.php @@ -40,6 +40,7 @@ use OCP\Log\Audit\CriticalActionPerformedEvent; use OCP\Mail\IMailer; use OCP\PreConditionNotMetException; +use OCP\Security\Events\PasswordResetRequestedEvent; use OCP\Security\VerificationToken\InvalidTokenException; use OCP\Security\VerificationToken\IVerificationToken; use OCP\Server; @@ -162,9 +163,14 @@ public function email(string $user): JSONResponse { $user = $this->userManager->getUserNameFromLoginName($user); + $event = new PasswordResetRequestedEvent($user); + $this->eventDispatcher->dispatchTyped($event); + // FIXME: use HTTP error codes try { - $this->sendEmail($user); + if (!$event->isEmailPrevented()) { + $this->sendEmail($user); + } } catch (ResetPasswordException $e) { // Ignore the error since we do not want to leak this info $this->logger->warning('Could not send password reset email: ' . $e->getMessage()); diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 4467e4e6179c2..7009b350fdec9 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -883,6 +883,7 @@ 'OCP\\Security\\Bruteforce\\MaxDelayReached' => $baseDir . '/lib/public/Security/Bruteforce/MaxDelayReached.php', 'OCP\\Security\\CSP\\AddContentSecurityPolicyEvent' => $baseDir . '/lib/public/Security/CSP/AddContentSecurityPolicyEvent.php', 'OCP\\Security\\Events\\GenerateSecurePasswordEvent' => $baseDir . '/lib/public/Security/Events/GenerateSecurePasswordEvent.php', + 'OCP\\Security\\Events\\PasswordResetRequestedEvent' => $baseDir . '/lib/public/Security/Events/PasswordResetRequestedEvent.php', 'OCP\\Security\\Events\\ValidatePasswordPolicyEvent' => $baseDir . '/lib/public/Security/Events/ValidatePasswordPolicyEvent.php', 'OCP\\Security\\FeaturePolicy\\AddFeaturePolicyEvent' => $baseDir . '/lib/public/Security/FeaturePolicy/AddFeaturePolicyEvent.php', 'OCP\\Security\\IContentSecurityPolicyManager' => $baseDir . '/lib/public/Security/IContentSecurityPolicyManager.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index dac59af04087a..64532fb8446c4 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -924,6 +924,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Security\\Bruteforce\\MaxDelayReached' => __DIR__ . '/../../..' . '/lib/public/Security/Bruteforce/MaxDelayReached.php', 'OCP\\Security\\CSP\\AddContentSecurityPolicyEvent' => __DIR__ . '/../../..' . '/lib/public/Security/CSP/AddContentSecurityPolicyEvent.php', 'OCP\\Security\\Events\\GenerateSecurePasswordEvent' => __DIR__ . '/../../..' . '/lib/public/Security/Events/GenerateSecurePasswordEvent.php', + 'OCP\\Security\\Events\\PasswordResetRequestedEvent' => __DIR__ . '/../../..' . '/lib/public/Security/Events/PasswordResetRequestedEvent.php', 'OCP\\Security\\Events\\ValidatePasswordPolicyEvent' => __DIR__ . '/../../..' . '/lib/public/Security/Events/ValidatePasswordPolicyEvent.php', 'OCP\\Security\\FeaturePolicy\\AddFeaturePolicyEvent' => __DIR__ . '/../../..' . '/lib/public/Security/FeaturePolicy/AddFeaturePolicyEvent.php', 'OCP\\Security\\IContentSecurityPolicyManager' => __DIR__ . '/../../..' . '/lib/public/Security/IContentSecurityPolicyManager.php', diff --git a/lib/public/Security/Events/PasswordResetRequestedEvent.php b/lib/public/Security/Events/PasswordResetRequestedEvent.php new file mode 100644 index 0000000000000..753fedab83c5f --- /dev/null +++ b/lib/public/Security/Events/PasswordResetRequestedEvent.php @@ -0,0 +1,60 @@ +input; + } + + /** + * Do not send the password reset email for this request + * + * @since 36.0.0 + */ + public function preventEmail(): void { + $this->emailPrevented = true; + } + + /** + * @since 36.0.0 + */ + public function isEmailPrevented(): bool { + return $this->emailPrevented; + } +} diff --git a/tests/Core/Controller/LostControllerTest.php b/tests/Core/Controller/LostControllerTest.php index 63a3ffdd2c5b3..10cca572c8665 100644 --- a/tests/Core/Controller/LostControllerTest.php +++ b/tests/Core/Controller/LostControllerTest.php @@ -29,6 +29,7 @@ use OCP\Log\Audit\CriticalActionPerformedEvent; use OCP\Mail\IEMailTemplate; use OCP\Mail\IMailer; +use OCP\Security\Events\PasswordResetRequestedEvent; use OCP\Security\VerificationToken\InvalidTokenException; use OCP\Security\VerificationToken\IVerificationToken; use PHPUnit\Framework\MockObject\MockObject; @@ -239,8 +240,10 @@ public function testEmailUnsuccessful(): void { $this->logger->expects($this->exactly(0)) ->method('error'); - $this->eventDispatcher->expects($this->never()) - ->method('dispatchTyped'); + $this->eventDispatcher->method('dispatchTyped') + ->willReturnCallback(function (object $event): void { + $this->assertNotInstanceOf(CriticalActionPerformedEvent::class, $event, 'no audit entry for a reset that sends no email'); + }); $this->logger->expects($this->exactly(2)) ->method('warning'); @@ -324,16 +327,24 @@ public function testEmailSuccessful(): void { ->method('send') ->with($message); - $this->eventDispatcher->expects($this->once()) + $events = []; + $this->eventDispatcher->expects($this->exactly(2)) ->method('dispatchTyped') - ->with($this->callback(fn (CriticalActionPerformedEvent $event): bool => $event->getOperation() === 'auth.password_reset.requested' - && $event->getParameters() === ['userId' => 'ExistingUser'] - && $event->getLogMessage() === 'Password reset was requested for user "%s"')); + ->willReturnCallback(static function (object $event) use (&$events): void { + $events[] = $event; + }); $response = $this->lostController->email('ExistingUser'); $expectedResponse = new JSONResponse(['status' => 'success']); $expectedResponse->throttle(); $this->assertEquals($expectedResponse, $response); + + $this->assertInstanceOf(PasswordResetRequestedEvent::class, $events[0]); + $this->assertSame('ExistingUser', $events[0]->getInput()); + $this->assertInstanceOf(CriticalActionPerformedEvent::class, $events[1]); + $this->assertSame('auth.password_reset.requested', $events[1]->getOperation()); + $this->assertSame(['userId' => 'ExistingUser'], $events[1]->getParameters()); + $this->assertSame('Password reset was requested for user "%s"', $events[1]->getLogMessage()); } public function testEmailWithMailSuccessful(): void { @@ -398,6 +409,40 @@ public function testEmailWithMailSuccessful(): void { $this->assertEquals($expectedResponse, $response); } + public function testEmailDispatchesRequestedEvent(): void { + $this->userManager->method('getByEmail') + ->willReturn([]); + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(static fn (PasswordResetRequestedEvent $event): bool => $event->getInput() === 'NonExistingUser')); + + $response = $this->lostController->email('NonExistingUser'); + + $expectedResponse = new JSONResponse(['status' => 'success']); + $expectedResponse->throttle(); + $this->assertEquals($expectedResponse, $response); + } + + public function testEmailPreventedByListener(): void { + $this->userManager->method('get') + ->with('ExistingUser') + ->willReturn($this->existingUser); + $this->eventDispatcher->method('dispatchTyped') + ->willReturnCallback(static function (PasswordResetRequestedEvent $event): void { + $event->preventEmail(); + }); + $this->verificationToken->expects($this->never()) + ->method('create'); + $this->mailer->expects($this->never()) + ->method('send'); + + $response = $this->lostController->email('ExistingUser'); + + $expectedResponse = new JSONResponse(['status' => 'success']); + $expectedResponse->throttle(); + $this->assertEquals($expectedResponse, $response); + } + public function testEmailCantSendException(): void { $this->userManager ->expects($this->any())