BryanDavis has uploaded a new change for review.

  https://gerrit.wikimedia.org/r/247858

Change subject: Add support for self-service password resets
......................................................................

Add support for self-service password resets

Add a screen to request a password reset token to be delivered by email
and an associated screen to validate the reset token and allow
a password reset.

Bug: T116110
Change-Id: I0eb3aa722954c8cc9bc395f427aee1beaa80c2e1
---
A data/db/migrations/20151020-add-user-reset-token.sql
M data/db/schema.mysql
M data/i18n/en.json
M data/i18n/qqq.json
A data/templates/account/recover.html
A data/templates/account/reset.html
M data/templates/login.html
M data/templates/nav_login.html
M src/App.php
A src/Controllers/User/RecoverAccount.php
A src/Controllers/User/ResetPassword.php
M src/Dao/Users.php
12 files changed, 401 insertions(+), 2 deletions(-)


  git pull ssh://gerrit.wikimedia.org:29418/wikimedia/iegreview 
refs/changes/58/247858/1

diff --git a/data/db/migrations/20151020-add-user-reset-token.sql 
b/data/db/migrations/20151020-add-user-reset-token.sql
new file mode 100644
index 0000000..1080e94
--- /dev/null
+++ b/data/db/migrations/20151020-add-user-reset-token.sql
@@ -0,0 +1,4 @@
+ALTER TABLE users
+  ADD COLUMN reset_hash VARCHAR(64) DEFAULT NULL
+  AFTER blocked;
+
diff --git a/data/db/schema.mysql b/data/db/schema.mysql
index 8d34383..abf085c 100644
--- a/data/db/schema.mysql
+++ b/data/db/schema.mysql
@@ -10,6 +10,7 @@
   , isadmin     TINYINT(1) DEFAULT NULL
   , viewreports TINYINT(1) DEFAULT NULL
   , blocked     TINYINT(1) DEFAULT 0
+  , reset_hash VARCHAR(64) DEFAULT NULL
   , PRIMARY KEY (id)
   , UNIQUE KEY username (username)
 ) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8 AUTO_INCREMENT=1000 ;
diff --git a/data/i18n/en.json b/data/i18n/en.json
index da16580..0eb3b40 100644
--- a/data/i18n/en.json
+++ b/data/i18n/en.json
@@ -46,6 +46,20 @@
        "login-failed": "Login failed.",
        "login-error": "Username and password required.",
 
+       "forgot-password": "Forgot your password?",
+       "forgot-password-email": "Email",
+       "forgot-password-submit": "Send recovery email",
+       "recover-account-subject": "Grants review password recovery",
+       "recover-account-email": "A request was made at $3 to reset the 
password for your user account.\n\nRecovery information:\n\nusername: 
$1\nrecovery URL: $2\n\nIf you did not make this request you can ignore this 
email. Your account has not been compromised.\n\nSincerely,\nGrants review 
administrators",
+       "recover-account-success": "Recovery instructions sent. Check your 
email and spam folder.",
+       "recover-account-bad-input": "Invalid input.",
+       "reset-password-bad-token": "Invalid recovery token supplied.",
+       "reset-password-no-match": "Passwords do not match.",
+       "reset-password-empty": "Password can not be empty.",
+       "reset-password-success": "Password reset successful.",
+       "reset-password-fail": "Password reset failed.",
+       "reset-password-invalid": "Invalid input.",
+
        "error-heading": "System error",
        "error-message": "An unexpected error has occurred and we are working 
to fix the problem. Please try again in a moment.",
        "error-id": "Error ID: $1",
diff --git a/data/i18n/qqq.json b/data/i18n/qqq.json
index 272ce4c..35b20c5 100644
--- a/data/i18n/qqq.json
+++ b/data/i18n/qqq.json
@@ -84,6 +84,9 @@
        "error-message": "Fatal error page content",
        "footer-credits": "Page footer navigation label, links to credits 
page.\n{{Identical|Credit}}",
        "footer-privacy": "Page footer navigation label, links to privacy 
page.\n{{Identical|Privacy}}",
+       "forgot-password": "Link text, links to account recovery page.",
+       "forgot-password-email": "Label for form field",
+       "forgot-password-submit": "Label for form submit button",
        "form-conditional": "Radio button label.",
        "form-neutral": "Radio button label.",
        "form-no": "Radio button label.",
@@ -177,6 +180,16 @@
        "report-aggregated-theme": "Report column header",
        "report-format-recommend": "Report column value.\n\n* $1 - Number of 
reviewers recommending proposal\n* $2 - \"*\" if some recommendations were 
conditional\n* $3 - Total number of reviews\n* $4 - Percentage of reviews 
recomending approval.",
        "proposals-search-campaigns-empty": "Select list usage prompt.",
+       "recover-account-subject": "Email subject",
+       "recover-account-email": "Email body. Parameters:\n* $1 - Account 
username\n* $2 - URL to password recovery page of application\n* $3 - URL to 
main page of application",
+       "recover-account-success": "Success message for account recovery 
request",
+       "recover-account-bad-input": "Error message for account recovery 
request",
+       "reset-password-bad-token": "Error message for password reset request",
+       "reset-password-empty": "Error message for password reset request",
+       "reset-password-fail": "Error message for password reset request",
+       "reset-password-invalid": "Error message for password reset request",
+       "reset-password-no-match": "Error message for password reset request",
+       "reset-password-success": "Success message for password reset request",
        "reports-wikitext-go": "Form submit button label.",
        "review-instructions": "Wikitext formatted form instructions.",
        "review-rank-1": "Radio button label.",
diff --git a/data/templates/account/recover.html 
b/data/templates/account/recover.html
new file mode 100644
index 0000000..f93a2ca
--- /dev/null
+++ b/data/templates/account/recover.html
@@ -0,0 +1,25 @@
+{% extends "base.html" %}
+
+{% block subtitle %}{{ 'forgot-password'|message }}{% endblock %}
+{% block content %}
+{% spaceless %}
+<ol class="breadcrumb">
+  <li>{{ 'forgot-password'|message }}</li>
+</ol>
+
+<form class="form-horizontal" method="post" action="{{ urlFor( 
'account_recover_post' ) }}">
+  <input type="hidden" name="{{ csrf_param }}" value="{{ csrf_token }}" />
+
+  <div class="form-group">
+    <label class="col-sm-2 control-label" for="email">{{ 
'forgot-password-email'|message }}</label>
+    <div class="col-sm-10">
+      <input type="email" class="form-control" name="email" id="email" 
value="" required="required"/>
+    </div>
+  </div>
+
+  <div class="col-sm-10 col-sm-offset-2">
+    <input type="submit" class="btn btn-default" id="save" name="save" 
value="{{ 'forgot-password-submit'|message }}"/>
+  </div>
+</form>
+{% endspaceless %}
+{% endblock content %}
diff --git a/data/templates/account/reset.html 
b/data/templates/account/reset.html
new file mode 100644
index 0000000..2c9b5e8
--- /dev/null
+++ b/data/templates/account/reset.html
@@ -0,0 +1,32 @@
+{% extends "base.html" %}
+
+{% block subtitle %}{{ 'change-password'|message }}{% endblock %}
+{% block content %}
+{% spaceless %}
+<ol class="breadcrumb">
+  <li>{{ 'change-password'|message }}</li>
+</ol>
+
+<form class="form-horizontal" method="post" action="{{ urlFor( 
'reset_password_post', { 'uid': id, 'token': token } ) }}">
+  <input type="hidden" name="{{ csrf_param }}" value="{{ csrf_token }}" />
+
+  <div class="form-group">
+    <label class="col-sm-2 control-label" for="newpw1">{{ 
'new-password'|message }}</label>
+    <div class="col-sm-10">
+      <input type="password" class="form-control" name="newpw1" id="newpw1" 
value="" required="required"/>
+    </div>
+  </div>
+
+  <div class="form-group">
+    <label class="col-sm-2 control-label" for="newpw2">{{ 
'new-password-again'|message }}</label>
+    <div class="col-sm-10">
+      <input type="password" class="form-control" name="newpw2" id="newpw2" 
value="" required="required"/>
+    </div>
+  </div>
+
+  <div class="col-sm-10 col-sm-offset-2">
+    <input type="submit" class="btn btn-default" id="save" name="save" 
value="{{ 'save-password'|message }}"/>
+  </div>
+</form>
+{% endspaceless %}
+{% endblock content %}
diff --git a/data/templates/login.html b/data/templates/login.html
index 5b57270..37de577 100644
--- a/data/templates/login.html
+++ b/data/templates/login.html
@@ -31,6 +31,7 @@
         </div>
       </div>
     </form>
+    <p><a href="{{ urlFor( 'account_recover' ) }}">{{ 
'forgot-password'|message }}</a></p>
   </div>
 </div>
 {% endspaceless %}
diff --git a/data/templates/nav_login.html b/data/templates/nav_login.html
index d3a3579..6348ef9 100644
--- a/data/templates/nav_login.html
+++ b/data/templates/nav_login.html
@@ -10,5 +10,8 @@
     <input type="password" class="form-control" id="password" name="password" 
required="required" placeholder="{{ 'password'|message }}"/>
   </div>
   <input type="submit" class="btn btn-default" value="{{ 'login'|message }}" />
+  <div class="form-group">
+    <a href="{{ urlFor( 'account_recover' ) }}">{{ 'forgot-password'|message 
}}</a>
+  </div>
 </form>
 {% endblock login_form %}
diff --git a/src/App.php b/src/App.php
index bfe0b29..5bf7cd3 100644
--- a/src/App.php
+++ b/src/App.php
@@ -439,6 +439,45 @@
                        }
                );
 
+               // Account management helpers
+               $slim->group( '/account/',
+                       $middleware['inject-user'],
+                       function () use ( $slim, $middleware ) {
+                               $slim->get( 'recover', 
$middleware['must-revalidate'],
+                                       function () use ( $slim ) {
+                                               $page = new 
Controllers\User\RecoverAccount( $slim );
+                                               $page->setDao( $slim->usersDao 
);
+                                               $page();
+                                       }
+                               )->name( 'account_recover' );
+
+                               $slim->post( 'recover.post', 
$middleware['must-revalidate'],
+                                       function () use ( $slim ) {
+                                               $page = new 
Controllers\User\RecoverAccount( $slim );
+                                               $page->setDao( $slim->usersDao 
);
+                                               $page->setMailer( $slim->mailer 
);
+                                               $page();
+                                       }
+                               )->name( 'account_recover_post' );
+
+                               $slim->get( 'reset/:token/:uid', 
$middleware['must-revalidate'],
+                                       function ( $token, $uid ) use ( $slim ) 
{
+                                               $page = new 
Controllers\User\ResetPassword( $slim );
+                                               $page->setDao( $slim->usersDao 
);
+                                               $page( $uid, $token );
+                                       }
+                               )->name( 'reset_password' );
+
+                               $slim->post( 'reset.post/:token/:uid', 
$middleware['must-revalidate'],
+                                       function ( $token, $uid ) use ( $slim ) 
{
+                                               $page = new 
Controllers\User\ResetPassword( $slim );
+                                               $page->setDao( $slim->usersDao 
);
+                                               $page( $uid, $token );
+                                       }
+                               )->name( 'reset_password_post' );
+                       }
+               );
+
                // Routes for authenticated users
                $slim->group( '/user/',
                        $middleware['must-revalidate'],
diff --git a/src/Controllers/User/RecoverAccount.php 
b/src/Controllers/User/RecoverAccount.php
new file mode 100644
index 0000000..a43398c
--- /dev/null
+++ b/src/Controllers/User/RecoverAccount.php
@@ -0,0 +1,92 @@
+<?php
+/**
+ * @section LICENSE
+ * This file is part of Wikimedia Grants Review application.
+ *
+ * Wikimedia Grants Review application is free software: you can
+ * redistribute it and/or modify it under the terms of the GNU General Public
+ * License as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version.
+ *
+ * Wikimedia Grants Review application is distributed in the hope that it
+ * will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
+ * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with Wikimedia Grants Review application.  If not, see
+ * <http://www.gnu.org/licenses/>.
+ *
+ * @file
+ * @copyright © 2014 Bryan Davis, Wikimedia Foundation and contributors.
+ */
+
+namespace Wikimedia\IEGReview\Controllers\User;
+
+use Wikimedia\IEGReview\Controller;
+
+/**
+ * Request an account recovery by providing an email address
+ *
+ * @author Bryan Davis <[email protected]>
+ * @copyright © 2014 Bryan Davis, Wikimedia Foundation and contributors.
+ */
+class RecoverAccount extends Controller {
+
+       protected function handleGet() {
+               $this->render( 'account/recover.html' );
+       }
+
+
+       protected function handlePost() {
+               $this->form->requireEmail( 'email' );
+
+               $dest = $this->urlFor( 'login' );
+               if ( $this->form->validate() ) {
+                       $email = $this->form->get( 'email' );
+                       $data  = $this->dao->createPasswordResetToken( $email );
+                       foreach ( $data as $result ) {
+                               list( $token, $user ) = $result;
+                               if ( $token !== false ) {
+                                       $this->sendEmail( $user, $token );
+                               }
+                       }
+                       $this->flash( 'info',
+                               $this->i18nContext->message( 
'recover-account-success' )
+                       );
+               } else {
+                       $this->flash( 'error',
+                               $this->i18nContext->message( 
'recover-account-bad-input' )
+                       );
+                       $dest =  $this->urlFor( 'account_recover' );
+               }
+
+               $this->redirect( $dest );
+       }
+
+       protected function sendEmail( $user, $token ) {
+               $sent = $this->mailer->mail(
+                       $user['email'],
+                       $this->i18nContext->message( 'recover-account-subject' 
),
+                       $this->i18nContext->message( 'recover-account-email', 
array(
+                               $user['username'],
+                               $this->request->getUrl() .
+                               $this->urlFor( 'reset_password', array(
+                                       'uid' => $user['id'],
+                                       'token' => $token,
+                               ) ),
+                               $this->request->getUrl() . $this->urlFor( 
'home' ),
+                       ) )
+               );
+
+               if ( !$sent ) {
+                       $this->logger->error(
+                               'Failed to send reset email for user',
+                               array(
+                                       'method' => __METHOD__,
+                                       'user' => $user['id'],
+                       ) );
+               }
+       }
+
+}
diff --git a/src/Controllers/User/ResetPassword.php 
b/src/Controllers/User/ResetPassword.php
new file mode 100644
index 0000000..5691f5f
--- /dev/null
+++ b/src/Controllers/User/ResetPassword.php
@@ -0,0 +1,88 @@
+<?php
+/**
+ * @section LICENSE
+ * This file is part of Wikimedia Grants Review application.
+ *
+ * Wikimedia Grants Review application is free software: you can
+ * redistribute it and/or modify it under the terms of the GNU General Public
+ * License as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version.
+ *
+ * Wikimedia Grants Review application is distributed in the hope that it
+ * will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
+ * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with Wikimedia Grants Review application.  If not, see
+ * <http://www.gnu.org/licenses/>.
+ *
+ * @file
+ * @copyright © 2014 Bryan Davis, Wikimedia Foundation and contributors.
+ */
+
+namespace Wikimedia\IEGReview\Controllers\User;
+
+use Wikimedia\IEGReview\Controller;
+
+/**
+ * Reset password using recovery token
+ *
+ * @author Bryan Davis <[email protected]>
+ * @copyright © 2014 Bryan Davis, Wikimedia Foundation and contributors.
+ */
+class ResetPassword extends Controller {
+
+       protected function handleGet( $id, $token ) {
+               if ( $this->dao->validatePasswordResetToken( $id, $token ) ) {
+                       $this->view->set( 'id', $id );
+                       $this->view->set( 'token', $token );
+                       $this->render( 'account/reset.html' );
+
+               } else {
+                       $this->flash( 'error',
+                               $this->i18nContext->message( 
'reset-password-bad-token' ) );
+                       $this->redirect( $this->urlFor( 'account_recover' ) );
+               }
+       }
+
+
+       protected function handlePost( $id, $token ) {
+               $this->form->requireString( 'newpw1' );
+               $this->form->requireString( 'newpw2' );
+
+               $dest = $this->urlFor( 'reset_password', array(
+                       'uid' => $id,
+                       'token' => $token,
+               ) );
+
+               if ( $this->form->validate() ) {
+                       $newPass = $this->form->get( 'newpw1' );
+
+                       if ( $newPass !== $this->form->get( 'newpw2' ) ) {
+                               $this->flash( 'error',
+                                       $this->i18nContext->message( 
'reset-password-no-match' ) );
+
+                       } elseif ( empty( $newPass ) ) {
+                               $this->flash( 'error',
+                                       $this->i18nContext->message( 
'reset-password-empty' ) );
+
+                       } else {
+                               if ( $this->dao->resetPassword( $id, $token, 
$newPass ) ) {
+                                       $this->flash( 'info',
+                                               $this->i18nContext->message( 
'reset-password-success' ) );
+                                       $dest = $this->urlFor( 'login' );
+                               } else {
+                                       $this->flash( 'error',
+                                               $this->i18nContext->message( 
'reset-password-fail' ) );
+                               }
+                       }
+               } else {
+                       $this->flash( 'error',
+                               $this->i18nContext->message( 
'reset-password-invalid' ) );
+               }
+
+               $this->redirect( $dest );
+       }
+
+}
diff --git a/src/Dao/Users.php b/src/Dao/Users.php
index 960c50d..9620048 100644
--- a/src/Dao/Users.php
+++ b/src/Dao/Users.php
@@ -194,9 +194,11 @@
                        $stmt->execute( array( Password::encodePassword( $newpw 
), $id ) );
                        $this->dbh->commit();
                        $this->logger->notice( 'Changed password for user', 
array(
-                                       'method' => __METHOD__,
-                                       'user' => $id,
+                               'method' => __METHOD__,
+                               'user' => $id,
                        ) );
+                       // Invalidate any password reset token that may have 
been issued
+                       $this->updatePasswordResetHash( $id, null );
                        return true;
 
                } catch ( PDOException $e) {
@@ -243,4 +245,89 @@
                return $this->update( $sql, $params );
        }
 
+       /**
+        * Generate password reset token(s) for the given email address.
+        *
+        * @param string $email Email address
+        * @return array (token, user) pairs; token === false on error
+        */
+       public function createPasswordResetToken( $email ) {
+               $ret = array();
+               $users = $this->search( array(
+                       'email' => $email,
+                       'items' => 'all',
+               ) );
+               foreach ( $users->rows as $user ) {
+                       $token = bin2hex( Password::getBytes( 16, true ) );
+                       $hash = hash( 'sha256', $token );
+                       if ( !$this->updatePasswordResetHash( $user['id'], 
$hash ) ) {
+                               $token = false;
+                       }
+                       $ret[] = array( $token, $user );
+               }
+               return $ret;
+       }
+
+       protected function updatePasswordResetHash( $id, $hash ) {
+               $ret = false;
+               $stmt = $this->dbh->prepare(
+                       'UPDATE users SET reset_hash = ? WHERE id = ?'
+               );
+               try {
+                       $this->dbh->beginTransaction();
+                       $stmt->execute( array( $hash, $id ) );
+                       $this->dbh->commit();
+                       $this->logger->notice( 'Created reset token for user', 
array(
+                               'method' => __METHOD__,
+                               'user' => $id,
+                       ) );
+                       $ret = true;
+
+               } catch ( PDOException $e) {
+                       $this->dbh->rollback();
+                       $this->logger->error(
+                               'Failed to update reset_hash for user',
+                               array(
+                                       'method' => __METHOD__,
+                                       'exception' => $e,
+                       ) );
+               }
+               return $ret;
+       }
+
+       /**
+        * Validate a user's password reset token.
+        *
+        * @param int $id User id
+        * @param string $token Reset token
+        * @return bool
+        */
+       public function validatePasswordResetToken( $id, $token ) {
+               $hash = hash( 'sha256', $token );
+               $row = $this->fetch(
+                       'SELECT id FROM users WHERE id = ? AND reset_hash = ?',
+                       array( $id, $hash )
+               );
+               return $row && $row['id'] == $id;
+       }
+
+       /**
+        * Reset a user's password after validating the reset token.
+        *
+        * @param int $id User id
+        * @param string $token Reset token
+        * @param string $pass New password
+        * @return bool
+        */
+       public function resetPassword( $id, $token, $pass ) {
+               $ret = false;
+               if ( $this->validatePasswordResetToken( $id, $token ) ) {
+                       $ret = $this->updatePassword( null, $pass, $id, true );
+                       if ( $ret ) {
+                               // Consume token if change was successful
+                               $this->updatePasswordResetHash( $id, null );
+                       }
+               }
+               return $ret;
+       }
 }

-- 
To view, visit https://gerrit.wikimedia.org/r/247858
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: newchange
Gerrit-Change-Id: I0eb3aa722954c8cc9bc395f427aee1beaa80c2e1
Gerrit-PatchSet: 1
Gerrit-Project: wikimedia/iegreview
Gerrit-Branch: master
Gerrit-Owner: BryanDavis <[email protected]>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to