From 6cb5d37d25e11a701c8e7ec42f0ce2df5bbd51d7 Mon Sep 17 00:00:00 2001 From: Rich Lott / Artful Robot Date: Sat, 30 Sep 2023 12:47:52 +0100 Subject: [PATCH] standalone: password reset working except for missing MessageTpl --- CRM/Core/BAO/UFMatch.php | 3 +- .../Standaloneusers/Page/ChangePassword.php | 5 - .../Standaloneusers/Page/ResetPassword.php | 32 +++++ .../Civi/Api4/Action/User/PasswordReset.php | 2 +- .../Api4/Action/User/SendPasswordReset.php | 9 +- ext/standaloneusers/Civi/Api4/User.php | 5 +- ext/standaloneusers/ang/crmChangePassword.js | 5 +- .../ang/crmResetPassword.ang.php | 17 +++ ext/standaloneusers/ang/crmResetPassword.js | 132 ++++++++++++++++++ .../crmResetPassword/crmResetPassword.html | 61 ++++++++ ext/standaloneusers/standaloneusers.php | 5 + .../CRM/Standaloneusers/Page/Login.tpl | 4 +- .../Standaloneusers/Page/ResetPassword.tpl | 5 + .../xml/Menu/standaloneusers.xml | 6 + .../init/StandaloneUsers.civi-setup.php | 5 +- 15 files changed, 277 insertions(+), 19 deletions(-) create mode 100644 ext/standaloneusers/CRM/Standaloneusers/Page/ResetPassword.php create mode 100644 ext/standaloneusers/ang/crmResetPassword.ang.php create mode 100644 ext/standaloneusers/ang/crmResetPassword.js create mode 100644 ext/standaloneusers/ang/crmResetPassword/crmResetPassword.html create mode 100644 ext/standaloneusers/templates/CRM/Standaloneusers/Page/ResetPassword.tpl diff --git a/CRM/Core/BAO/UFMatch.php b/CRM/Core/BAO/UFMatch.php index 5a2406b5c1..6290430fd0 100644 --- a/CRM/Core/BAO/UFMatch.php +++ b/CRM/Core/BAO/UFMatch.php @@ -539,8 +539,7 @@ AND domain_id = %4 } /** - * Get the next unused uf_id value, since the standalone UF doesn't - * have id's (it uses OpenIDs, which go in a different field). + * Get the next unused uf_id value * * @deprecated * @return int diff --git a/ext/standaloneusers/CRM/Standaloneusers/Page/ChangePassword.php b/ext/standaloneusers/CRM/Standaloneusers/Page/ChangePassword.php index 1f897dc482..04c9c3bdc1 100644 --- a/ext/standaloneusers/CRM/Standaloneusers/Page/ChangePassword.php +++ b/ext/standaloneusers/CRM/Standaloneusers/Page/ChangePassword.php @@ -4,11 +4,6 @@ use CRM_Standaloneusers_ExtensionUtil as E; class CRM_Standaloneusers_Page_ChangePassword extends CRM_Core_Page { public function run() { - - // Example: Assign a variable for use in a template - if (!defined('CIVICRM_HIBP_URL')) { - define('CIVICRM_HIBP_URL', 'https://api.pwnedpasswords.com/range/'); - } $this->assign('hibp', CIVICRM_HIBP_URL); $this->assign('loggedInUserID', CRM_Utils_System::getLoggedInUfID()); Civi::service('angularjs.loader')->addModules('crmChangePassword'); diff --git a/ext/standaloneusers/CRM/Standaloneusers/Page/ResetPassword.php b/ext/standaloneusers/CRM/Standaloneusers/Page/ResetPassword.php new file mode 100644 index 0000000000..36c32d9e6f --- /dev/null +++ b/ext/standaloneusers/CRM/Standaloneusers/Page/ResetPassword.php @@ -0,0 +1,32 @@ +assign('hibp', CIVICRM_HIBP_URL); + // $this->assign('loggedInUserID', CRM_Utils_System::getLoggedInUfID()); + Civi::service('angularjs.loader')->addModules('crmResetPassword'); + + // If we have a password reset token, validate it without 'spending' it. + $token = CRM_Utils_Request::retrieveValue('token', 'String',NULL, FALSE, $method = 'GET'); + if ($token) { + if (!Security::singleton()->checkPasswordResetToken($token, FALSE)) { + $token = 'invalid'; + } + $this->assign('token', $token); + } + + parent::run(); + } + +} diff --git a/ext/standaloneusers/Civi/Api4/Action/User/PasswordReset.php b/ext/standaloneusers/Civi/Api4/Action/User/PasswordReset.php index e5307360d2..e02928d0e1 100644 --- a/ext/standaloneusers/Civi/Api4/Action/User/PasswordReset.php +++ b/ext/standaloneusers/Civi/Api4/Action/User/PasswordReset.php @@ -51,7 +51,7 @@ class PasswordReset extends AbstractAction { ->execute(); $result['success'] = 1; - $result[] = ['success' => 1]; + \Civi::log()->info("Changed password for user {userID} via User.PasswordReset", compact('userID')); } } diff --git a/ext/standaloneusers/Civi/Api4/Action/User/SendPasswordReset.php b/ext/standaloneusers/Civi/Api4/Action/User/SendPasswordReset.php index ce0d2c3555..db1db3584d 100644 --- a/ext/standaloneusers/Civi/Api4/Action/User/SendPasswordReset.php +++ b/ext/standaloneusers/Civi/Api4/Action/User/SendPasswordReset.php @@ -1,6 +1,12 @@ $token], TRUE, NULL, FALSE, TRUE); + // xxx this is not generating https://blah - just the path. Why? + $resetUrlPlaintext = \CRM_Utils_System::url('civicrm/login/password', ['token' => $token], TRUE, NULL, FALSE); $resetUrlHtml = htmlspecialchars($resetUrlPlaintext); // The template_params are used in the template like {$resetUrlHtml} and {$resetUrlHtml} $params = [ diff --git a/ext/standaloneusers/Civi/Api4/User.php b/ext/standaloneusers/Civi/Api4/User.php index 87b002ff25..5b6f7c37c1 100644 --- a/ext/standaloneusers/Civi/Api4/User.php +++ b/ext/standaloneusers/Civi/Api4/User.php @@ -55,10 +55,11 @@ class User extends Generic\DAOEntity { * Permissions are wide on this but are checked in validateValues. */ public static function permissions() { + $x=1; return [ 'default' => ['access CiviCRM'], - 'PasswordReset' => ['access password resets'], - 'SendPasswordreset' => ['access password resets'], + 'passwordReset' => ['access password resets'], + 'sendPasswordReset' => ['access password resets'], ]; } diff --git a/ext/standaloneusers/ang/crmChangePassword.js b/ext/standaloneusers/ang/crmChangePassword.js index 0c6bc4efa8..80d087661b 100644 --- a/ext/standaloneusers/ang/crmChangePassword.js +++ b/ext/standaloneusers/ang/crmChangePassword.js @@ -3,9 +3,6 @@ angular.module('crmChangePassword', CRM.angRequires('crmChangePassword')); - // "crmChangePassword" displays the status of a queue - // Example usage:
- // If "queue" is omitted, then inherit `CRM.vars.crmChangePassword.default`. angular.module('crmChangePassword').component('crmChangePassword', { templateUrl: '~/crmChangePassword/crmChangePassword.html', bindings: { @@ -57,7 +54,7 @@ } else { hash = hash.toUpperCase(); - hashPrefix = hash.substring(0, 5); + let hashPrefix = hash.substring(0, 5); return fetch(ctrl.hibp + hashPrefix) .then(r => r.text()) .then(hibpResult => { diff --git a/ext/standaloneusers/ang/crmResetPassword.ang.php b/ext/standaloneusers/ang/crmResetPassword.ang.php new file mode 100644 index 0000000000..53d27a7b75 --- /dev/null +++ b/ext/standaloneusers/ang/crmResetPassword.ang.php @@ -0,0 +1,17 @@ + 'standaloneusers', + 'js' => [ + 'ang/crmResetPassword.js', + // 'ang/crmQueueMonitor/*.js', + // 'ang/crmQueueMonitor/*/*.js', + ], + // 'css' => ['ang/crmQueueMonitor.css'], + 'partials' => ['ang/crmResetPassword'], + 'requires' => ['crmUi', 'crmUtil', 'api4'], + 'basePages' => ['civicrm/login/password'], + 'exports' => [ + // Export the module as an [E]lement + 'crm-reset-password' => 'E', + ], +]; diff --git a/ext/standaloneusers/ang/crmResetPassword.js b/ext/standaloneusers/ang/crmResetPassword.js new file mode 100644 index 0000000000..5a4108c19f --- /dev/null +++ b/ext/standaloneusers/ang/crmResetPassword.js @@ -0,0 +1,132 @@ +(function (angular) { + "use strict"; + + angular.module('crmResetPassword', CRM.angRequires('crmResetPassword')); + + angular.module('crmResetPassword').component('crmResetPassword', { + templateUrl: '~/crmResetPassword/crmResetPassword.html', + bindings: { + // things listed here become properties on the controller using values from attributes. + hibp: '@', + token: '@' + }, + controller: function($scope, $timeout, crmApi4) { + var ts = $scope.ts = CRM.ts(null), + ctrl = this; + + console.log('init crmResetPassword component starting'); + // $onInit gets run after the this controller is called, and after the bindings have been applied. + // this.$onInit = function() { console.log('user', ctrl.userId); }; + ctrl.formSubmitted = false; + ctrl.newPassword = ''; + ctrl.newPasswordAgain = ''; + ctrl.identifier = ''; + ctrl.busy = ''; + ctrl.pwnd = false; + ctrl.resetSuccessfullySubmitted = false; + + let updateAngular = (prop, newVal) => { + $timeout(() => { + console.log("Setting", prop, "to", newVal); + ctrl[prop] = newVal; + }, 0); + }; + ctrl.sendPasswordReset = () => { + updateAngular('busy', ts('Just a moment...')); + updateAngular('formSubmitted', true); + if (!ctrl.identifier) { + alert(ts('Please provide your username/email.')); + return; + } + crmApi4('User', 'SendPasswordReset', { identifier: ctrl.identifier }) + .then(r => { + updateAngular('busy', ''); + updateAngular('resetSuccessfullySubmitted', true); + }) + .catch(e => { + updateAngular('busy', ts('Sorry, something went wrong. Please contact your site administrators.')); + }); + }; + + ctrl.attemptChange = () => { + updateAngular('busy', ''); + updateAngular('formSubmitted', true); + updateAngular('pwnd', false); + if (ctrl.newPassword.length < 8) { + alert(ts("Passwords under 8 characters are simply not secure. Ideally you should use a secure password generator.")); + return; + } + if (ctrl.newPassword != ctrl.newPasswordAgain) { + alert(ts("Passwords do not match")); + return; + } + + let promises = Promise.resolve(null); + if (ctrl.hibp) { + promises = promises.then(() => { + updateAngular('busy', ts('Checking password is not known to have been involved in data breach...')); + return sha1(ctrl.newPassword) + .then(hash => { + if (!hash.match(/^[a-f0-9]+$/)) { + updateAngular('busy', ts('Could not check. Is your browser up-to-date?')); + } + else { + hash = hash.toUpperCase(); + let hashPrefix = hash.substring(0, 5); + return fetch(ctrl.hibp + hashPrefix) + .then(r => r.text()) + .then(hibpResult => { + if (hibpResult && + hibpResult.split(/\r\n/).find(line => hashPrefix + line.replace(/:\d+$/, '') === hash)) { + // e.g. Password123 + updateAngular('pwn', true); + return; + } + updateAngular('busy', ''); + }) + .catch( () => { + updateAngular('busy', ts('Could not perform check; service error.')); + }); + } + }); + }); + } + + promises = promises.then(() => { + updateAngular('busy', ctrl.busy + ts('Changing...')); + updateAngular('formSubmitted', true); + // Now submit api request. + return crmApi4('User', 'PasswordReset', { + token: ctrl.token, + password: ctrl.newPassword, + }) + .then(r => { + updateAngular('busy', ts('Password successfully updated. Redirecting to login...')); + $timeout(() => { + window.location = '/civicrm/login' + ctrl[prop] = newVal; + }, 1300); + }) + .catch(e => { + updateAngular('token', 'invalid'); + }); + }); + }; + + // Generate SHA-1 digest for given text. Returns Promise + function sha1(message) { + const encoder = new TextEncoder(); + const data = encoder.encode(message); + // const hashBuffer = + return crypto.subtle.digest('SHA-1', data) + .then(hashBuffer => { + const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array + const hashHex = hashArray + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); // convert bytes to hex string + return hashHex; + }); + } + } + }); +})(angular); diff --git a/ext/standaloneusers/ang/crmResetPassword/crmResetPassword.html b/ext/standaloneusers/ang/crmResetPassword/crmResetPassword.html new file mode 100644 index 0000000000..f81f3c3949 --- /dev/null +++ b/ext/standaloneusers/ang/crmResetPassword/crmResetPassword.html @@ -0,0 +1,61 @@ +
+ +
+ +
+ +
+ + +
+
+

{{ts('Thanks. If your username/email matched an active account, we will email you with a special link to provide a new password.')}}

+

{{ts('The link must be used within an hour, and can only be used once.')}}

+
+ +
{{$ctrl.busy}}
+ + +
+
+
+ +
+ +
+ + + {{ts('Passwords do not match')}} + +
+ + +
+
+ +
+

{{ts("This password reset link has expired or is otherwise invalid.")}}

+

{{ts('Send new password reset link')}}

+
+ +
+ diff --git a/ext/standaloneusers/standaloneusers.php b/ext/standaloneusers/standaloneusers.php index 1af490bbd9..dff4299e3f 100644 --- a/ext/standaloneusers/standaloneusers.php +++ b/ext/standaloneusers/standaloneusers.php @@ -1,5 +1,10 @@ {ts}You may need to login to access that.{/ts}
- - + +
diff --git a/ext/standaloneusers/templates/CRM/Standaloneusers/Page/ResetPassword.tpl b/ext/standaloneusers/templates/CRM/Standaloneusers/Page/ResetPassword.tpl new file mode 100644 index 0000000000..5a594b3356 --- /dev/null +++ b/ext/standaloneusers/templates/CRM/Standaloneusers/Page/ResetPassword.tpl @@ -0,0 +1,5 @@ + + + diff --git a/ext/standaloneusers/xml/Menu/standaloneusers.xml b/ext/standaloneusers/xml/Menu/standaloneusers.xml index c4f7c94f3f..118d3d766e 100644 --- a/ext/standaloneusers/xml/Menu/standaloneusers.xml +++ b/ext/standaloneusers/xml/Menu/standaloneusers.xml @@ -17,4 +17,10 @@ Change Password access CiviCRM + + civicrm/login/password + CRM_Standaloneusers_Page_ResetPassword + Reset Password + access password resets + diff --git a/setup/plugins/init/StandaloneUsers.civi-setup.php b/setup/plugins/init/StandaloneUsers.civi-setup.php index 55eb46afbe..b9a71da9e3 100644 --- a/setup/plugins/init/StandaloneUsers.civi-setup.php +++ b/setup/plugins/init/StandaloneUsers.civi-setup.php @@ -55,6 +55,7 @@ if (!defined('CIVI_SETUP')) { 'view event info', 'register for events', 'access password resets', + 'authenticate with password', //xxx? ], ], [ @@ -77,9 +78,9 @@ if (!defined('CIVI_SETUP')) { $params = [ 'cms_name' => $e->getModel()->extras['adminUser'], 'cms_pass' => $e->getModel()->extras['adminPass'], - 'email' => $adminEmail, + 'email' => $adminEmail, 'notify' => FALSE, - 'contactID' => $contactID, + 'contact_id' => $contactID, ]; $userID = \CRM_Core_BAO_CMSUser::create($params, 'email'); -- 2.25.1