Allow resetting password through email.

This also adopts the use of an Alert template for error and success messages.
This commit is contained in:
Aaron van Geffen 2016-09-02 11:17:10 +02:00
parent 3587447cc0
commit 7487068171
13 changed files with 456 additions and 20 deletions

View File

@ -1,10 +1,9 @@
TODO:
* Draaiing van foto's bij importeren goedzetten
* Import asset ownership
* Import users
* Import asset ownership
* Pagina om één foto te bekijken
* Taggen door gebruikers
* Uploaden door gebruikers
* Album management
* Password reset via e-mail

View File

@ -29,9 +29,12 @@ class Login extends HTMLController
if (isset($_POST['redirect_url']))
header('Location: ' . base64_decode($_POST['redirect_url']));
elseif (isset($_SESSION['login_url']))
{
unset($_SESSION['redirect_url']);
header('Location: ' . $_SESSION['redirect_url']);
}
else
header('Location: ' . BASEURL . '/admin/');
header('Location: ' . BASEURL . '/');
exit;
}
else
@ -39,15 +42,28 @@ class Login extends HTMLController
}
parent::__construct('Log in - ' . SITE_TITLE);
$this->page->appendStylesheet(BASEURL . '/css/admin.css');
$form = new LogInForm('Log in');
if ($login_error)
$form->setErrorMessage('Invalid email address or password.');
$form->adopt(new Alert('', 'Invalid email address or password.', 'error'));
// Tried anything? Be helpful, at least.
if (isset($_POST['emailaddress']))
$form->setEmail($_POST['emailaddress']);
// A message from the past/present/future?
if (isset($_SESSION['login_msg']))
{
$form->adopt(new Alert($_SESSION['login_msg'][0], $_SESSION['login_msg'][1], $_SESSION['login_msg'][2]));
unset($_SESSION['login_msg']);
}
// Going somewhere?
if (!empty($_GET['redirect']) && ($url = base64_decode($_GET['redirect'])))
{
$_SESSION['login_url'] = $url;
$form->setRedirectUrl($url);
}
$this->page->adopt($form);
}
}

View File

@ -0,0 +1,81 @@
<?php
/*****************************************************************************
* ResetPassword.php
* Contains the controller for the reset password procedure.
*
* Kabuki CMS (C) 2013-2016, Aaron van Geffen
*****************************************************************************/
class ResetPassword extends HTMLController
{
public function __construct()
{
// Already logged in? Then you don't need this.
if (Registry::get('user')->isLoggedIn())
throw new UserFacingException('You are already logged in.');
// Verifying an existing reset key?
if (isset($_GET['step'], $_GET['email'], $_GET['key']) && $_GET['step'] == 2)
{
$email = rawurldecode($_GET['email']);
$id_user = Authentication::getUserid($email);
if ($id_user === false)
throw new UserFacingException('Invalid email address. Please make sure you copied the full link in the email you received.');
$key = $_GET['key'];
if (!Authentication::checkResetKey($id_user, $key))
throw new UserFacingException('Invalid reset token. Please make sure you copied the full link in the email you received. Note: you cannot use the same token twice.');
parent::__construct('Reset password - ' . SITE_TITLE);
$form = new PasswordResetForm($email, $key);
$this->page->adopt($form);
// Are they trying to set something already?
if (isset($_POST['password1'], $_POST['password2']))
{
$missing = [];
if (strlen($_POST['password1']) < 6 || !preg_match('~[^A-z]~', $_POST['password1']))
$missing[] = 'Please fill in a password that is at least six characters long and contains at least one non-alphabetic character (e.g. a number or symbol).';
if ($_POST['password1'] != $_POST['password2'])
$missing[] = 'The passwords you entered do not match.';
// So, are we good to go?
if (empty($missing))
{
Authentication::updatePassword($id_user, Authentication::computeHash($_POST['password1']));
$_SESSION['login_msg'] = ['Your password has been reset', 'You can now use the form below to log in to your account.', 'success'];
header('Location: ' . BASEURL . '/login/');
exit;
}
else
$form->adopt(new Alert('Some fields require your attention', '<ul><li>' . implode('</li><li>', $missing) . '</li></ul>', 'error'));
}
}
else
{
parent::__construct('Reset password - ' . SITE_TITLE);
$form = new ForgotPasswordForm();
$this->page->adopt($form);
// Have they submitted an email address yet?
if (isset($_POST['emailaddress']) && preg_match('~^.+@.+\.[a-z]+$~', trim($_POST['emailaddress'])))
{
$id_user = Authentication::getUserid(trim($_POST['emailaddress']));
if ($id_user === false)
{
$form->adopt(new Alert('Invalid email address', 'The email address you provided could not be found in our system. Please try again.', 'error'));
return;
}
Authentication::setResetKey($id_user);
Email::resetMail($id_user);
// Show the success message
$this->page->clear();
$box = new DummyBox('An email has been sent');
$box->adopt(new Alert('', 'We have sent an email to ' . $_POST['emailaddress'] . ' containing details on how to reset your password.', 'success'));
$this->page->adopt($box);
}
}
}
}

View File

@ -44,6 +44,31 @@ class Authentication
return empty($res) ? false : $res;
}
public static function setResetKey($id_user)
{
return Registry::get('db')->query('
UPDATE users
SET reset_key = {string:key}
WHERE id_user = {int:id}',
[
'id' => $id_user,
'key' => self::newActivationKey(),
]);
}
public static function checkResetKey($id_user, $reset_key)
{
$key = Registry::get('db')->queryValue('
SELECT reset_key
FROM users
WHERE id_user = {int:id}',
[
'id' => $id_user,
]);
return $key == $reset_key;
}
/**
* Verifies whether the user is currently logged in.
*/
@ -62,6 +87,18 @@ class Authentication
return isset($_SESSION['user_id']) && self::checkExists($_SESSION['user_id']);
}
/**
* Generates a new activation key.
*/
public static function newActivationKey()
{
$alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
$string = '';
for ($i = 0; $i < 16; $i++)
$string .= $alpha[mt_rand(0, strlen($alpha) - 1)];
return $string;
}
/**
* Checks a password for a given username against the database.
*/

View File

@ -21,6 +21,7 @@ class Dispatcher
'managetags' => 'ManageTags',
'manageusers' => 'ManageUsers',
'people' => 'ViewPeople',
'resetpassword' => 'ResetPassword',
'suggest' => 'ProvideAutoSuggest',
'timeline' => 'ViewTimeline',
'uploadmedia' => 'UploadMedia',
@ -77,6 +78,11 @@ class Dispatcher
else
self::trigger403();
}
catch (UserFacingException $e)
{
$debug_info = ErrorHandler::getDebugInfo($e->getTrace());
ErrorHandler::display($e->getMessage(), $debug_info, false);
}
catch (Exception $e)
{
ErrorHandler::handleError(E_USER_ERROR, 'Unspecified exception: ' . $e->getMessage(), $e->getFile(), $e->getLine());
@ -93,7 +99,7 @@ class Dispatcher
public static function kickGuest()
{
$form = new LogInForm('Log in');
$form->setErrorMessage('Admin access required. Please log in.');
$form->adopt(new Alert('', 'You need to be logged in to view this page.', 'error'));
$form->setRedirectUrl($_SERVER['REQUEST_URI']);
$page = new MainTemplate('Login required');

100
models/Email.php Normal file
View File

@ -0,0 +1,100 @@
<?php
/*****************************************************************************
* Email.php
* Contains key class Email.
*
* Kabuki CMS (C) 2013-2016, Aaron van Geffen
*****************************************************************************/
class Email
{
public static function send($address, $addressee, $subject, $body, $headers = '')
{
// Set a boundary.
$boundary = uniqid('sr');
if (empty($headers))
$headers .= "From: HashRU Pics <no-reply@pics.hashru.nl>\r\n";
// Set up headers.
$headers .= "MIME-Version: 1.0\r\n";
$headers .= "Content-Type: multipart/alternative;boundary=$boundary\r\n";
// Start the message with a plaintext version of the mail.
$message = "This is a MIME encoded message.";
$message .= "\r\n\r\n--$boundary\r\n";
$message .= "Content-type: text/plain;charset=utf-8\r\n\r\n";
$message .= self::wrapLines($body);
// Autolink URLs and wrap lines.
$html_body = preg_replace('~\b(?!")https?://[^"\s]+~', '<a href="$0">$0</a>', $body);
$html_body = preg_replace('~(\s+)(www\..+\.[^"\s]+?)(\.|\s+)~', '$1<a href="https://$2">$2</a>$3', $html_body);
$html_body = preg_replace('~={10,}~', '<hr>', $html_body);
$html_body = self::wrapLines(str_replace("\r", "<br>\r", $html_body), 80, "\r\n");
// Then, more excitingly, add an HTML version!
$message .= "\r\n\r\n--" . $boundary . "\r\n";
$message .= "Content-type: text/html;charset=utf-8\r\n\r\n";
$message .= "<html>\r\n<head><style type=\"text/css\">\r\nbody { font: 13px Helvetica, Arial, sans-serif; }\r\n</style></head>\r\n<body><p>" .
$html_body . "</p></body>\r\n</html>";
// End off with a final boundary.
$message .= "\r\n\r\n--" . $boundary . "--";
if (DEBUG)
return file_put_contents(BASEDIR . '/mail_dumps.txt', "To: \"$addressee\" <$address>\r\n$headers\r\nSubject: $subject\r\n" . self::wrapLines($message), FILE_APPEND);
else
return mail("\"$addressee\" <$address>", $subject, $message, $headers, '-fbounces@pics.hashru.nl');
}
public static function wrapLines($body, $maxlength = 80, $break = "\r\n")
{
$lines = explode("\n", $body);
$wrapped = "";
foreach ($lines as $line)
$wrapped .= wordwrap($line, $maxlength, $break) . $break;
return $wrapped;
}
private static function parseTemplate($template, $replacements)
{
$replacement_keys = array_map(function($el) { return "%$el%"; }, array_keys($replacements));
$subject = str_replace($replacement_keys, array_values($replacements), $template['subject']);
$body = str_replace($replacement_keys, array_values($replacements), $template['body']);
return [$subject, $body];
}
public static function resetMail($id_user)
{
$row = Registry::get('db')->queryAssoc('
SELECT first_name, surname, emailaddress, reset_key
FROM users
WHERE id_user = {int:id_user}',
[
'id_user' => $id_user,
]);
if (empty($row))
return false;
list($subject, $body) = self::parseTemplate([
'subject' => 'Information on how to reset your HashRU password',
'body' => str_replace("\n", "\r\n", 'Dear %FIRST_NAME%,
You are receiving this email because a password reset request was issued on our website.
If you did not request a password reset, please disregard this email. Otherwise, please follow the link below.
%RESET_LINK%
The HashRU Pics team'),
], [
'FIRST_NAME' => $row['first_name'],
'RESET_LINK' => BASEURL . '/resetpassword/?step=2&email=' . rawurlencode($row['emailaddress']) . '&key=' . $row['reset_key'],
]);
$addressee = trim($row['first_name'] . ' ' . $row['surname']);
self::send($row['emailaddress'], $addressee, $subject, $body);
}
}

View File

@ -3,7 +3,7 @@
* ErrorHandler.php
* Contains key class ErrorHandler.
*
* Kabuki CMS (C) 2013-2015, Aaron van Geffen
* Kabuki CMS (C) 2013-2016, Aaron van Geffen
*****************************************************************************/
class ErrorHandler
@ -11,6 +11,18 @@ class ErrorHandler
private static $error_count = 0;
private static $handling_error;
public static function enable()
{
set_error_handler('ErrorHandler::handleError');
ini_set("display_errors", DEBUG ? "On" : "Off");
}
public static function disable()
{
set_error_handler(NULL);
ini_set("display_errors", "Off");
}
// Handler for standard PHP error messages.
public static function handleError($error_level, $error_message, $file, $line, $context = null)
{
@ -121,14 +133,27 @@ class ErrorHandler
return $error_message;
}
public static function display($message, $debug_info)
public static function display($message, $debug_info, $is_sensitive = true)
{
$is_admin = Registry::has('user') && Registry::get('user')->isAdmin();
// Just show the message if we're running in a console.
if (empty($_SERVER['HTTP_HOST']))
{
echo $message;
exit;
}
// JSON request?
elseif (isset($_GET['json']) || isset($_GET['format']) && $_GET['format'] == 'json')
{
if (DEBUG || $is_admin)
echo json_encode(['error' => $message . "\n\n" . $debug_info]);
elseif (!$is_sensitive)
echo json_encode(['error' => $message]);
else
echo json_encode(['error' => 'Our apologies, an error occured while we were processing your request. Please try again later, or contact us if the problem persists.']);
exit;
}
// Initialise the main template to present a nice message to the user.
$page = new MainTemplate('An error occured!');
@ -146,6 +171,8 @@ class ErrorHandler
$page->adopt(new AdminBar());
}
}
elseif (!$is_sensitive)
$page->adopt(new DummyBox('An error occured!', '<p>' . $message . '</p>'));
else
$page->adopt(new DummyBox('An error occured!', '<p>Our apologies, an error occured while we were processing your request. Please try again later, or contact us if the problem persists.</p>'));

View File

@ -0,0 +1,12 @@
<?php
/*****************************************************************************
* UserFacingException.php
* Contains exception class UserFacingException.
*
* Kabuki CMS (C) 2013-2016, Aaron van Geffen
*****************************************************************************/
class UserFacingException extends Exception
{
}

View File

@ -395,6 +395,70 @@ textarea {
}
/* Alert boxes -- styling borrowed from Bootstrap 2
-----------------------------------------------------*/
.alert {
padding: 8px 35px 8px 14px;
margin-bottom: 20px;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
background-color: #fcf8e3;
border: 1px solid #fbeed5;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
border-radius: 4px;
}
.alert,
.alert h4 {
color: #c09853;
}
.alert h4 {
margin: 0;
}
.alert .close {
position: relative;
top: -2px;
right: -21px;
line-height: 20px;
}
.alert-success {
background-color: #dff0d8;
border-color: #d6e9c6;
color: #468847;
}
.alert-success h4 {
color: #468847;
}
.alert-danger,
.alert-error {
background-color: #f2dede;
border-color: #eed3d7;
color: #b94a48;
}
.alert-danger h4,
.alert-error h4 {
color: #b94a48;
}
.alert-info {
background-color: #d9edf7;
border-color: #bce8f1;
color: #3a87ad;
}
.alert-info h4 {
color: #3a87ad;
}
.alert-block {
padding-top: 14px;
padding-bottom: 14px;
}
.alert-block > p,
.alert-block > ul {
margin-bottom: 0;
}
.alert-block p + p {
margin-top: 5px;
}
/* Responsive: smartphone in portrait
---------------------------------------*/
@media only screen and (max-width: 895px) {

24
templates/Alert.php Normal file
View File

@ -0,0 +1,24 @@
<?php
/*****************************************************************************
* Alert.php
* Defines the key template Alert.
*
* Kabuki CMS (C) 2013-2015, Aaron van Geffen
*****************************************************************************/
class Alert extends SubTemplate
{
public function __construct($title = '', $message = '', $type = 'alert')
{
$this->_title = $title;
$this->_message = $message;
$this->_type = in_array($type, ['alert', 'error', 'success', 'info']) ? $type : 'alert';
}
protected function html_content()
{
echo '
<div class="alert', $this->_type != 'alert' ? ' alert-' . $this->_type : '', '">', (!empty($this->_title) ? '
<strong>' . $this->_title . '</strong><br>' : ''), $this->_message, '</div>';
}
}

View File

@ -0,0 +1,29 @@
<?php
/*****************************************************************************
* ForgotPasswordForm.php
* Contains the forget password form template.
*
* Kabuki CMS (C) 2013-2016, Aaron van Geffen
*****************************************************************************/
class ForgotPasswordForm extends SubTemplate
{
protected function html_content()
{
echo '
<div class="boxed_content">
<h2>Password reset procedure</h2>';
foreach ($this->_subtemplates as $template)
$template->html_main();
echo '
<p>Please fill in the email address you used to sign up in the form below. You will be sent a reset link to your email address.</p>
<form class="form-horizontal" action="', BASEURL, '/resetpassword/?step=1" method="post">
<label class="control-label" for="field_emailaddress">E-mail address:</label><br>
<input type="text" id="field_emailaddress" name="emailaddress">
<button type="submit" class="btn btn-primary">Send mail</button>
</form>
</div>';
}
}

View File

@ -8,15 +8,9 @@
class LogInForm extends SubTemplate
{
private $error_message = '';
private $redirect_url = '';
private $emailaddress = '';
public function setErrorMessage($message)
{
$this->error_message = $message;
}
public function setRedirectUrl($url)
{
$_SESSION['login_url'] = $url;
@ -32,12 +26,10 @@ class LogInForm extends SubTemplate
{
echo '
<form action="', BASEURL, '/login/" method="post" id="login">
<h3>Admin login</h3>';
<h3>Log in</h3>';
// Invalid login? Show a message.
if (!empty($this->error_message))
echo '
<p style="color: red">', $this->error_message, '</p>';
foreach ($this->_subtemplates as $template)
$template->html_main();
echo '
<dl>
@ -54,7 +46,10 @@ class LogInForm extends SubTemplate
<input type="hidden" name="redirect_url" value="', base64_encode($this->redirect_url), '">';
echo '
<div><button type="submit" class="btn btn-primary" id="field_login" name="login" tabindex="3">Log in</button></div>
<a href="', BASEURL, '/resetpassword/">Forgotten your password?</a>
<div class="buttonstrip">
<button type="submit" class="btn btn-primary" id="field_login" name="login" tabindex="3">Log in</button>
</div>
</form>';
}
}

View File

@ -0,0 +1,46 @@
<?php
/*****************************************************************************
* PasswordResetForm.php
* Contains the password reset form template.
*
* Kabuki CMS (C) 2013-2016, Aaron van Geffen
*****************************************************************************/
class PasswordResetForm extends SubTemplate
{
private $email;
private $key;
public function __construct($email, $key)
{
$this->email = $email;
$this->key = $key;
}
protected function html_content()
{
echo '
<div class="boxed_content">
<h2>Password reset procedure</h2>';
foreach ($this->_subtemplates as $template)
$template->html_main();
echo '
<p>You have successfully confirmed your identify. Please use the form below to set a new password.</p>
<form class="form-horizontal" action="', BASEURL, '/resetpassword/?step=2&amp;email=', rawurlencode($this->email), '&amp;key=', $this->key, '" method="post">
<p>
<label class="control-label" for="field_password1">New password:</label>
<input type="password" id="field_password1" name="password1">
</p>
<p>
<label class="control-label" for="field_password2">Repeat new password:</label>
<input type="password" id="field_password2" name="password2">
</p>
<button type="submit" class="btn btn-primary">Reset password</button>
</form>
</div>';
}
}