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