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())