Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion core/Controller/LostController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down
1 change: 1 addition & 0 deletions lib/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions lib/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
60 changes: 60 additions & 0 deletions lib/public/Security/Events/PasswordResetRequestedEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCP\Security\Events;

use OCP\EventDispatcher\Event;

/**
* Dispatched when someone asks for a password reset email, before the email
* is sent and whether or not the input matches an account.
*
* A listener can prevent the email, for example while an account is flooded
* with reset requests. The response to the requester stays the same, so it
* does not reveal whether an email was sent.
*
* @since 36.0.0
*/
class PasswordResetRequestedEvent extends Event {
private bool $emailPrevented = false;

/**
* @since 36.0.0
*/
public function __construct(
private readonly string $input,
) {
parent::__construct();
}

/**
* The login name or email address that was entered
*
* @since 36.0.0
*/
public function getInput(): string {
return $this->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;
}
}
57 changes: 51 additions & 6 deletions tests/Core/Controller/LostControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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())
Expand Down
Loading