From 420f256454250646b506fb43c8107a36b520a531 Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 25 Sep 2026 18:59:14 -0500 Subject: [PATCH] feat(provisioning_api): add endpoint to trigger password reset email for a user Adds an admin-only OCS endpoint POST /ocs/v2.php/cloud/users/{userId}/resetpassword that reuses the existing lost-password flow (IVerificationToken + core.ResetPassword mail template), respecting the same lost_password_link config and per-user rate limit as the self-service path. Mirrors resendWelcomeMessage's admin/subadmin scoping exactly. Frontend: new 'Send password reset email' action in the settings user-row menu (only shown when the account has an email), backed by a new sendPasswordResetMail Vuex store action. openapi.json + openapi-full.json hand-updated to include the new endpoint; CI regen may normalize formatting on next run. Closes nextcloud/server#14411 Signed-off-by: Tim --- apps/provisioning_api/appinfo/routes.php | 1 + .../lib/Controller/UsersController.php | 99 ++++++++++++++++++- apps/provisioning_api/openapi-full.json | 97 ++++++++++++++++++ apps/provisioning_api/openapi.json | 97 ++++++++++++++++++ .../settings/src/components/Users/UserRow.vue | 17 ++++ apps/settings/src/store/users.js | 14 +++ 6 files changed, 324 insertions(+), 1 deletion(-) diff --git a/apps/provisioning_api/appinfo/routes.php b/apps/provisioning_api/appinfo/routes.php index ec1aab5215376..58f198c8f3b15 100644 --- a/apps/provisioning_api/appinfo/routes.php +++ b/apps/provisioning_api/appinfo/routes.php @@ -54,6 +54,7 @@ ['root' => '/cloud', 'name' => 'Users#addSubAdmin', 'url' => '/users/{userId}/subadmins', 'verb' => 'POST'], ['root' => '/cloud', 'name' => 'Users#removeSubAdmin', 'url' => '/users/{userId}/subadmins', 'verb' => 'DELETE'], ['root' => '/cloud', 'name' => 'Users#resendWelcomeMessage', 'url' => '/users/{userId}/welcome', 'verb' => 'POST'], + ['root' => '/cloud', 'name' => 'Users#sendPasswordResetEmail', 'url' => '/users/{userId}/resetpassword', 'verb' => 'POST'], // Config ['name' => 'AppConfig#getApps', 'url' => '/api/v1/config/apps', 'verb' => 'GET'], diff --git a/apps/provisioning_api/lib/Controller/UsersController.php b/apps/provisioning_api/lib/Controller/UsersController.php index 56bc7245335ce..5d85b42e681e4 100644 --- a/apps/provisioning_api/lib/Controller/UsersController.php +++ b/apps/provisioning_api/lib/Controller/UsersController.php @@ -25,6 +25,7 @@ use OCP\Accounts\PropertyDoesNotExistException; use OCP\App\IAppManager; use OCP\AppFramework\Http; +use OCP\Defaults; use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoSubAdminRequired; @@ -40,6 +41,8 @@ use OCP\Group\ISubAdmin; use OCP\HintException; use OCP\IAppConfig; +use OC\Security\RateLimiting\Exception\RateLimitExceededException; +use OC\Security\RateLimiting\Limiter; use OCP\IConfig; use OCP\IGroup; use OCP\IGroupManager; @@ -47,14 +50,16 @@ use OCP\IPhoneNumberUtil; use OCP\IRequest; use OCP\IURLGenerator; +use OCP\Util; use OCP\IUser; use OCP\IUserManager; use OCP\IUserSession; use OCP\L10N\IFactory; use OCP\Security\Events\GenerateSecurePasswordEvent; +use OCP\Mail\IMailer; use OCP\Security\ISecureRandom; +use OCP\Security\VerificationToken\IVerificationToken; use OCP\User\Backend\ISetDisplayNameBackend; -use OCP\Util; use Psr\Log\LoggerInterface; /** @@ -88,6 +93,10 @@ public function __construct( private IPhoneNumberUtil $phoneNumberUtil, private IAppManager $appManager, private IAppConfig $appConfig, + private IVerificationToken $verificationToken, + private IMailer $mailer, + private Defaults $defaults, + private Limiter $limiter, GroupDisplayNameCache $groupDisplayNameCache, ) { parent::__construct( @@ -2067,4 +2076,92 @@ public function resendWelcomeMessage(string $userId): DataResponse { return new DataResponse(); } + + /** + * Trigger the existing lost-password email flow for a user, so an admin + * can force a password reset without knowing the current password. + * + * @param string $userId ID of the user + * @return DataResponse, array{}> + * @throws OCSException + * + * 200: Password reset email sent + */ + #[PasswordConfirmationRequired] + #[NoAdminRequired] + public function sendPasswordResetEmail(string $userId): DataResponse { + $currentLoggedInUser = $this->userSession->getUser(); + + $targetUser = $this->userManager->get($userId); + if ($targetUser === null) { + throw new OCSException('', OCSController::RESPOND_NOT_FOUND); + } + + // Check if admin / subadmin (same scoping as resendWelcomeMessage) + $subAdminManager = $this->groupManager->getSubAdmin(); + $isAdmin = $this->groupManager->isAdmin($currentLoggedInUser->getUID()); + $isDelegatedAdmin = $this->groupManager->isDelegatedAdmin($currentLoggedInUser->getUID()); + if ( + !$subAdminManager->isUserAccessible($currentLoggedInUser, $targetUser) + && !($isAdmin || $isDelegatedAdmin) + ) { + throw new OCSException('', OCSController::RESPOND_NOT_FOUND); + } + + if ($this->config->getSystemValue('lost_password_link', '') === 'disabled') { + throw new OCSException($this->l10n->t('Password reset is disabled'), Http::STATUS_BAD_REQUEST); + } + + $email = $targetUser->getEMailAddress(); + if ($email === '' || $email === null) { + throw new OCSException($this->l10n->t('Email address not available'), 101); + } + + // Same per-user rate limit as the self-service lost-password flow + try { + $this->limiter->registerUserRequest('lostpasswordemail', 5, 1800, $targetUser); + } catch (RateLimitExceededException $e) { + throw new OCSException($this->l10n->t('Could not send reset email, too many were sent recently'), Http::STATUS_TOO_MANY_REQUESTS, $e); + } + + $coreL10n = $this->l10nFactory->get('core'); + + // The token is stored encrypted with the user's email + the system + // secret, so it invalidates automatically when the email changes. + $token = $this->verificationToken->create($targetUser, 'lostpassword', $email); + $link = $this->urlGenerator->linkToRouteAbsolute('core.lost.resetform', [ + 'userId' => $targetUser->getUID(), + 'token' => $token, + ]); + + $emailTemplate = $this->mailer->createEMailTemplate('core.ResetPassword', [ + 'link' => $link, + ]); + $emailTemplate->setSubject($coreL10n->t('%s password reset', [$this->defaults->getName()])); + $emailTemplate->addHeader(); + $emailTemplate->addHeading($coreL10n->t('Password reset')); + $emailTemplate->addBodyText( + htmlspecialchars($coreL10n->t('Click the following button to reset your password. If you have not requested the password reset, then ignore this email.')), + $coreL10n->t('Click the following link to reset your password. If you have not requested the password reset, then ignore this email.') + ); + $emailTemplate->addBodyButton( + htmlspecialchars($coreL10n->t('Reset your password')), + $link, + false + ); + $emailTemplate->addFooter(); + + try { + $message = $this->mailer->createMessage(); + $message->setTo([$email => $targetUser->getDisplayName()]); + $message->setFrom([Util::getDefaultEmailAddress('no-reply') => $this->defaults->getName()]); + $message->useTemplate($emailTemplate); + $this->mailer->send($message); + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['app' => 'provisioning_api', 'exception' => $e]); + throw new OCSException($this->l10n->t('Sending email failed'), 102, $e); + } + + return new DataResponse(); + } } diff --git a/apps/provisioning_api/openapi-full.json b/apps/provisioning_api/openapi-full.json index 4303f86cd63cd..a6fa13d22ebc0 100644 --- a/apps/provisioning_api/openapi-full.json +++ b/apps/provisioning_api/openapi-full.json @@ -6392,6 +6392,103 @@ } } }, + "/ocs/v2.php/cloud/users/{userId}/resetpassword": { + "post": { + "operationId": "users-send-password-reset-email", + "summary": "Send a password reset email", + "description": "This endpoint requires password confirmation", + "tags": [ + "users" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "ID if the user", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Password reset email sent", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/provisioning_api/api/v1/config/users/{appId}/{configKey}": { "post": { "operationId": "preferences-set-preference", diff --git a/apps/provisioning_api/openapi.json b/apps/provisioning_api/openapi.json index b47957bcfdf10..55e6e7a520bad 100644 --- a/apps/provisioning_api/openapi.json +++ b/apps/provisioning_api/openapi.json @@ -3896,6 +3896,103 @@ } } }, + "/ocs/v2.php/cloud/users/{userId}/resetpassword": { + "post": { + "operationId": "users-send-password-reset-email", + "summary": "Send a password reset email", + "description": "This endpoint requires password confirmation", + "tags": [ + "users" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "ID if the user", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Password reset email sent", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/provisioning_api/api/v1/config/apps/{app}/{key}": { "post": { "operationId": "app_config-set-value", diff --git a/apps/settings/src/components/Users/UserRow.vue b/apps/settings/src/components/Users/UserRow.vue index d3a0f0ce009f1..14a4a10f0c274 100644 --- a/apps/settings/src/components/Users/UserRow.vue +++ b/apps/settings/src/components/Users/UserRow.vue @@ -307,6 +307,11 @@ const userActions = computed(() => { text: t('settings', 'Resend welcome email'), action: sendWelcomeMail, }) + actions.push({ + icon: 'icon-password', + text: t('settings', 'Send password reset email'), + action: sendPasswordResetMail, + }) } return actions.concat(props.externalActions) }) @@ -414,6 +419,18 @@ function sendWelcomeMail() { loading.all = false }) } + +/** + * Send a password reset email to the account. + */ +function sendPasswordResetMail() { + loading.all = true + store.dispatch('sendPasswordResetMail', props.user.id) + .then(() => showSuccess(t('settings', 'Password reset email sent!'), { timeout: 2000 })) + .finally(() => { + loading.all = false + }) +}