standalone: password reset working except for missing MessageTpl
authorRich Lott / Artful Robot <code.commits@artfulrobot.uk>
Sat, 30 Sep 2023 11:47:52 +0000 (12:47 +0100)
committerRich Lott / Artful Robot <code.commits@artfulrobot.uk>
Sat, 30 Sep 2023 11:48:37 +0000 (12:48 +0100)
15 files changed:
CRM/Core/BAO/UFMatch.php
ext/standaloneusers/CRM/Standaloneusers/Page/ChangePassword.php
ext/standaloneusers/CRM/Standaloneusers/Page/ResetPassword.php [new file with mode: 0644]
ext/standaloneusers/Civi/Api4/Action/User/PasswordReset.php
ext/standaloneusers/Civi/Api4/Action/User/SendPasswordReset.php
ext/standaloneusers/Civi/Api4/User.php
ext/standaloneusers/ang/crmChangePassword.js
ext/standaloneusers/ang/crmResetPassword.ang.php [new file with mode: 0644]
ext/standaloneusers/ang/crmResetPassword.js [new file with mode: 0644]
ext/standaloneusers/ang/crmResetPassword/crmResetPassword.html [new file with mode: 0644]
ext/standaloneusers/standaloneusers.php
ext/standaloneusers/templates/CRM/Standaloneusers/Page/Login.tpl
ext/standaloneusers/templates/CRM/Standaloneusers/Page/ResetPassword.tpl [new file with mode: 0644]
ext/standaloneusers/xml/Menu/standaloneusers.xml
setup/plugins/init/StandaloneUsers.civi-setup.php

index 5a2406b5c17365a3c45e5ff24bc6176d8ab120a2..6290430fd0d7f89cc56d562cdc6249f1597eb618 100644 (file)
@@ -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
index 1f897dc48283b0bce186c949e059c07efed41408..04c9c3bdc18f973c55edfa15638246db00120142 100644 (file)
@@ -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 (file)
index 0000000..36c32d9
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+use CRM_Standaloneusers_ExtensionUtil as E;
+
+use Civi\Standalone\Security;
+
+/**
+ * Provide the send password reset / reset password page.
+ * URL: /civicrm/login/password[?token=xxxx]
+ *
+ * If called with ?token=xxxx then it's the latter.
+ */
+class CRM_Standaloneusers_Page_ResetPassword extends CRM_Core_Page {
+
+  public function run() {
+
+    $this->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();
+  }
+
+}
index e5307360d2133e15013059399ff183b3b6d6cce1..e02928d0e1d7b4fdfccf4ce5ba1e8a30a2c371b5 100644 (file)
@@ -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'));
   }
 
 }
index ce0d2c35553824a9fea16be25cecbad01a595aa0..db1db3584d0a6293b1470d54d2ac9f3b7309d0d3 100644 (file)
@@ -1,6 +1,12 @@
 <?php
 namespace Civi\Api4\Action\User;
 
+// @todo
+// URL is (a) just theh path in the emails.
+// clicking button on form with proper token does nothing.
+// should redirect to login on success
+//
+
 use Civi;
 use Civi\Api4\Generic\Result;
 use API_Exception;
@@ -90,7 +96,8 @@ class SendPasswordReset extends AbstractAction {
     $token = static::updateToken($user['id']);
 
     list($domainFromName, $domainFromEmail) = \CRM_Core_BAO_Domain::getNameAndEmail(TRUE);
-    $resetUrlPlaintext = \CRM_Utils_System::url('civicrm/password-reset', ['token' => $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 = [
index 87b002ff250ba9178a0d441dc01e87ecec091d01..5b6f7c37c1571c0cd67c6f5e88724702fa516a9c 100644 (file)
@@ -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'],
     ];
   }
 
index 0c6bc4efa82e25e8a558bf94725b641be41892b5..80d087661b70617e5c53ff16808c5322283ba537 100644 (file)
@@ -3,9 +3,6 @@
 
   angular.module('crmChangePassword', CRM.angRequires('crmChangePassword'));
 
-  // "crmChangePassword" displays the status of a queue
-  // Example usage: <div crm-change-password ></div>
-  // 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 (file)
index 0000000..53d27a7
--- /dev/null
@@ -0,0 +1,17 @@
+<?php
+return [
+  'ext' => '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 (file)
index 0000000..5a4108c
--- /dev/null
@@ -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 (file)
index 0000000..f81f3c3
--- /dev/null
@@ -0,0 +1,61 @@
+<div>
+  <!-- without a token, we offer for them to generate one. -->
+  <form name="requestLink" crm-ui-id-scope
+    ng-if="!$ctrl.formSubmitted && !$ctrl.token">
+
+    <div crm-ui-field="{name: 'identifier', title: ts('Enter the username or email on your account')}">
+      <input
+        crm-ui-id="identifier"
+        name="identifier"
+        ng-model="$ctrl.identifier"
+        class="crm-form-text"
+        type=text
+      />
+    </div>
+
+    <button ng-click="$ctrl.sendPasswordReset()" >{{ts('Send Password Reset')}}</button>
+  </form>
+  <div ng-if="$ctrl.resetSuccessfullySubmitted" >
+    <p>{{ts('Thanks. If your username/email matched an active account, we will email you with a special link to provide a new password.')}}</p>
+    <p>{{ts('The link must be used within an hour, and can only be used once.')}}</p>
+  </div>
+
+  <div ng-if="$ctrl.busy" >{{$ctrl.busy}}</div>
+
+  <!-- without a token, we offer for them to generate one. -->
+  <form ng-if="$ctrl.token && $ctrl.token !== 'invalid' && !$ctrl.formSubmitted" name="resetPassword" crm-ui-id-scope >
+    <div ng=if="$ctrl.token !== 'invalid'" >
+      <div crm-ui-field="{name: 'newPassword', title: ts('Enter a new password')}">
+        <input
+          crm-ui-id="newPassword"
+          name="newPassword"
+          ng-model="$ctrl.newPassword"
+          class="crm-form-text"
+          type=password
+          />
+      </div>
+
+      <div crm-ui-field="{name: 'newPasswordAgain', title: ts('Re-enter new password')}">
+        <input
+          crm-ui-id="newPasswordAgain"
+          name="newPasswordAgain"
+          ng-model="$ctrl.newPasswordAgain"
+          class="crm-form-text"
+          type=password
+          />
+        <span class="crm-error" ng-show="$ctrl.newPasswordAgain && $ctrl.newPassword && $ctrl.newPassword !== $ctrl.newPasswordAgain">
+          {{ts('Passwords do not match')}}
+        </span>
+      </div>
+
+      <button ng-click="$ctrl.attemptChange()" ng-disabled="$ctrl.formSubmitted">{{ts('Change Password')}}</button>
+    </div>
+  </form>
+
+  <div ng-if="$ctrl.token === 'invalid'" >
+    <p>{{ts("This password reset link has expired or is otherwise invalid.")}}</p>
+    <p><a href ng-click="$ctrl.token=''" >{{ts('Send new password reset link')}}</a></p>
+  </div>
+
+</div>
+
index 1af490bbd9d1ba7a8975bb5177e2c584ffea5e3c..dff4299e3f3e68e0ce66074b0460c55d12c55fff 100644 (file)
@@ -1,5 +1,10 @@
 <?php
 
+// Define default URL to haveibeenpwned service. Set this empty in settings to disable.
+if (!defined('CIVICRM_HIBP_URL')) {
+  define('CIVICRM_HIBP_URL', 'https://api.pwnedpasswords.com/range/');
+}
+
 require_once 'standaloneusers.civix.php';
 // phpcs:disable
 use CRM_Standaloneusers_ExtensionUtil as E;
index 176546ce673f3985b35be4d80e82825b05c4ef1d..4dc29936751e69f45f62045ef5673a8eaa37487f 100644 (file)
@@ -255,8 +255,8 @@ a:hover, a:focus {
     <div class="message warning" style="display:none;" id="anonAccessDenied">{ts}You may need to login to access that.{/ts}</div>
     <form>
       <div>
-        <label for="usernameInput" class="form-label">Username</label>
-        <input type="password" class="form-control" id="usernameInput" >
+        <label for="usernameInput" name=username class="form-label">Username</label>
+        <input type="text" class="form-control" id="usernameInput" >
       </div>
       <div>
         <label for="passwordInput" class="form-label">Password</label>
diff --git a/ext/standaloneusers/templates/CRM/Standaloneusers/Page/ResetPassword.tpl b/ext/standaloneusers/templates/CRM/Standaloneusers/Page/ResetPassword.tpl
new file mode 100644 (file)
index 0000000..5a594b3
--- /dev/null
@@ -0,0 +1,5 @@
+<crm-angular-js modules="crmResetPassword">
+  <crm-reset-password
+    hibp="{$hibp|escape}"
+    token="{$token|escape}" ></crm-reset-password>
+</crm-angular-js>
index c4f7c94f3f1a6ddcb9fa2bce449cf21f25d81212..118d3d766e54a1bedb4bd47a05a794ee0ae68973 100644 (file)
     <title>Change Password</title>
     <access_arguments>access CiviCRM</access_arguments>
   </item>
+  <item>
+    <path>civicrm/login/password</path>
+    <page_callback>CRM_Standaloneusers_Page_ResetPassword</page_callback>
+    <title>Reset Password</title>
+    <access_arguments>access password resets</access_arguments>
+  </item>
 </menu>
index 55eb46afbe74c974c8fd596263d817237e8f319a..b9a71da9e31409648faee5a09edc2d454b1b6d6b 100644 (file)
@@ -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');