Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/provisioning_api/appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
99 changes: 98 additions & 1 deletion apps/provisioning_api/lib/Controller/UsersController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -40,21 +41,25 @@
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;
use OCP\IL10N;
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;

/**
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<Http::STATUS_OK, list<empty>, 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();
}
}
97 changes: 97 additions & 0 deletions apps/provisioning_api/openapi-full.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
97 changes: 97 additions & 0 deletions apps/provisioning_api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions apps/settings/src/components/Users/UserRow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down Expand Up @@ -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
})
}
</script>

<style lang="scss" scoped>
Expand Down
Loading