Rebuild password reset

This commit is contained in:
Igor Scheller 2019-10-08 16:17:06 +02:00
parent 8f8130634e
commit dd03662968
20 changed files with 566 additions and 183 deletions

View File

@ -13,6 +13,12 @@ $route->get('/login', 'AuthController@login');
$route->post('/login', 'AuthController@postLogin'); $route->post('/login', 'AuthController@postLogin');
$route->get('/logout', 'AuthController@logout'); $route->get('/logout', 'AuthController@logout');
// Password recovery
$route->get('/password/reset', 'PasswordResetController@reset');
$route->post('/password/reset', 'PasswordResetController@postReset');
$route->get('/password/reset/{token:.+}', 'PasswordResetController@resetPassword');
$route->post('/password/reset/{token:.+}', 'PasswordResetController@postResetPassword');
// Stats // Stats
$route->get('/metrics', 'Metrics\\Controller@metrics'); $route->get('/metrics', 'Metrics\\Controller@metrics');
$route->get('/stats', 'Metrics\\Controller@stats'); $route->get('/stats', 'Metrics\\Controller@stats');

View File

@ -1,7 +1,6 @@
<?php <?php
use Engelsystem\Database\DB; use Engelsystem\Database\DB;
use Engelsystem\Models\User\PasswordReset;
use Engelsystem\Models\User\State; use Engelsystem\Models\User\State;
use Engelsystem\Models\User\User; use Engelsystem\Models\User\User;
use Engelsystem\ShiftCalendarRenderer; use Engelsystem\ShiftCalendarRenderer;
@ -311,120 +310,6 @@ function users_list_controller()
]; ];
} }
/**
* Second step of password recovery: set a new password using the token link from email
*
* @return string
*/
function user_password_recovery_set_new_controller()
{
$request = request();
$passwordReset = PasswordReset::whereToken($request->input('token'))->first();
if (!$passwordReset) {
error(__('Token is not correct.'));
redirect(page_link_to('login'));
}
if ($request->hasPostData('submit')) {
$valid = true;
if (
$request->has('password')
&& strlen($request->postData('password')) >= config('min_password_length')
) {
if ($request->postData('password') != $request->postData('password2')) {
$valid = false;
error(__('Your passwords don\'t match.'));
}
} else {
$valid = false;
error(__('Your password is to short (please use at least 6 characters).'));
}
if ($valid) {
auth()->setPassword($passwordReset->user, $request->postData('password'));
success(__('Password saved.'));
$passwordReset->delete();
redirect(page_link_to('login'));
}
}
return User_password_set_view();
}
/**
* First step of password recovery: display a form that asks for your email and send email with recovery link
*
* @return string
*/
function user_password_recovery_start_controller()
{
$request = request();
if ($request->hasPostData('submit')) {
$valid = true;
$user_source = null;
if ($request->has('email') && strlen(strip_request_item('email')) > 0) {
$email = strip_request_item('email');
if (check_email($email)) {
/** @var User $user_source */
$user_source = User::whereEmail($email)->first();
if (!$user_source) {
$valid = false;
error(__('E-mail address is not correct.'));
}
} else {
$valid = false;
error(__('E-mail address is not correct.'));
}
} else {
$valid = false;
error(__('Please enter your e-mail.'));
}
if ($valid) {
$token = User_generate_password_recovery_token($user_source);
engelsystem_email_to_user(
$user_source,
__('Password recovery'),
sprintf(
__('Please visit %s to recover your password.'),
page_link_to('user_password_recovery', ['token' => $token])
)
);
success(__('We sent an email containing your password recovery link.'));
redirect(page_link_to('login'));
}
}
return User_password_recovery_view();
}
/**
* User password recovery in 2 steps.
* (By email)
*
* @return string
*/
function user_password_recovery_controller()
{
if (request()->has('token')) {
return user_password_recovery_set_new_controller();
}
return user_password_recovery_start_controller();
}
/**
* Menu title for password recovery.
*
* @return string
*/
function user_password_recovery_title()
{
return __('Password recovery');
}
/** /**
* Loads a user from param user_id. * Loads a user from param user_id.
* *

View File

@ -2,7 +2,6 @@
use Carbon\Carbon; use Carbon\Carbon;
use Engelsystem\Database\DB; use Engelsystem\Database\DB;
use Engelsystem\Models\User\PasswordReset;
use Engelsystem\Models\User\User; use Engelsystem\Models\User\User;
use Engelsystem\ValidationResult; use Engelsystem\ValidationResult;
use Illuminate\Database\Query\JoinClause; use Illuminate\Database\Query\JoinClause;
@ -227,24 +226,6 @@ function User_reset_api_key($user, $log = true)
} }
} }
/**
* Generates a new password recovery token for given user.
*
* @param User $user
* @return string
*/
function User_generate_password_recovery_token($user)
{
$reset = PasswordReset::findOrNew($user->id);
$reset->user_id = $user->id;
$reset->token = md5($user->name . time() . rand());
$reset->save();
engelsystem_log('Password recovery for ' . User_Nick_render($user, true) . ' started.');
return $reset->token;
}
/** /**
* @param User $user * @param User $user
* @return float * @return float

View File

@ -759,41 +759,6 @@ function User_view_state_admin($freeloader, $user_source)
return $state; return $state;
} }
/**
* View for password recovery step 1: E-Mail
*
* @return string
*/
function User_password_recovery_view()
{
return page_with_title(user_password_recovery_title(), [
msg(),
__('We will send you an e-mail with a password recovery link. Please use the email address you used for registration.'),
form([
form_text('email', __('E-Mail'), ''),
form_submit('submit', __('Recover'))
])
]);
}
/**
* View for password recovery step 2: New password
*
* @return string
*/
function User_password_set_view()
{
return page_with_title(user_password_recovery_title(), [
msg(),
__('Please enter a new password.'),
form([
form_password('password', __('Password')),
form_password('password2', __('Confirm password')),
form_submit('submit', __('Save'))
])
]);
}
/** /**
* @param array[] $user_angeltypes * @param array[] $user_angeltypes
* @return string * @return string

Binary file not shown.

View File

@ -619,9 +619,9 @@ msgid "Please visit %s to recover your password."
msgstr "Bitte besuche %s, um Dein Passwort zurückzusetzen" msgstr "Bitte besuche %s, um Dein Passwort zurückzusetzen"
#: includes/controller/users_controller.php:394 #: includes/controller/users_controller.php:394
msgid "We sent an email containing your password recovery link." msgid "We sent you an email containing your password recovery link."
msgstr "" msgstr ""
"Wir haben eine eMail mit einem Link zum Passwort-zurücksetzen geschickt." "Wir haben dir eine eMail mit einem Link zum Passwort-zurücksetzen geschickt."
#: includes/helper/email_helper.php:41 #: includes/helper/email_helper.php:41
#, php-format #, php-format
@ -2769,3 +2769,21 @@ msgstr "Bitte gib ein Passwort an."
msgid "validation.login.required" msgid "validation.login.required"
msgstr "Bitte gib einen Loginnamen an." msgstr "Bitte gib einen Loginnamen an."
msgid "form.submit"
msgstr "Absenden"
msgid "validation.email.required"
msgstr "Bitte gib eine E-Mail-Adresse an."
msgid "validation.email.email"
msgstr "Die E-Mail-Adresse ist nicht gültig."
msgid "validation.password.min"
msgstr "Dein angegebenes Passwort ist zu kurz."
msgid "validation.password.confirmed"
msgstr "Deine Passwörter stimmen nicht überein."
msgid "validation.password_confirmation.required"
msgstr "Du musst dein Passwort bestätigen."

Binary file not shown.

View File

@ -30,3 +30,21 @@ msgstr "The password is required."
msgid "validation.login.required" msgid "validation.login.required"
msgstr "The login name is required." msgstr "The login name is required."
msgid "form.submit"
msgstr "Submit"
msgid "validation.email.required"
msgstr "The email address is required."
msgid "validation.email.email"
msgstr "This email address is not valid."
msgid "validation.password.min"
msgstr "Your password is too short."
msgid "validation.password.confirmed"
msgstr "Your passwords are not equal."
msgid "validation.password_confirmation.required"
msgstr "You have to confirm your password."

Binary file not shown.

View File

@ -551,7 +551,7 @@ msgid "Please visit %s to recover your password."
msgstr "Por favor visite %s para recuperar sua senha" msgstr "Por favor visite %s para recuperar sua senha"
#: includes/controller/users_controller.php:271 #: includes/controller/users_controller.php:271
msgid "We sent an email containing your password recovery link." msgid "We sent you an email containing your password recovery link."
msgstr "Nós enviamos um email com o link para recuperação da sua senha." msgstr "Nós enviamos um email com o link para recuperação da sua senha."
#: includes/helper/email_helper.php:12 #: includes/helper/email_helper.php:12

View File

@ -1,6 +1,6 @@
{{ __('Hi %s,', [username]) }} {% block title %}{{ __('Hi %s,', [username]) }}{% endblock %}
{{ __('here is a message for you from the %s:', [config('app_name')]) }} {% block introduction %}{{ __('here is a message for you from the %s:', [config('app_name')]) }}{% endblock %}
{{ message|raw }} {% block message %}{{ message|raw }}{% endblock %}
{{ __('This email is autogenerated and has not been signed. You got this email because you are registered in the %s.', [config('app_name')]) }} {% block footer %}{{ __('This email is autogenerated and has not been signed. You got this email because you are registered in the %s.', [config('app_name')]) }}{% endblock %}

View File

@ -0,0 +1,3 @@
{% extends "emails/mail.twig" %}
{% block message %}{{ __('Please visit %s to recover your password.', [url('/password/reset/') ~ reset.token]) }}{% endblock %}

View File

@ -0,0 +1,18 @@
{% macro input(name, label, type, required) %}
<div class="form-group">
{% if label %}
<label for="{{ name }}">{{ label }}</label>
{% endif %}
<input type="{{ type|default('text') }}" class="form-control" id="{{ name }}" name="{{ name }}"
{%- if required|default(false) %} required="required"{% endif -%}
>
</div>
{% endmacro %}
{% macro hidden(name, value) %}
<input type="hidden" id="{{ name }}" name="{{ name }}" value="{{ value }}">
{% endmacro %}
{% macro submit(label) %}
<button type="submit" class="btn btn-default">{{ label|default(__('form.submit')) }}</button>
{% endmacro %}

View File

@ -62,7 +62,7 @@
</div> </div>
<div class="text-center"> <div class="text-center">
<a href="{{ url('user-password-recovery') }}" class=""> <a href="{{ url('/password/reset') }}" class="">
{{ __('I forgot my password') }} {{ __('I forgot my password') }}
</a> </a>
</div> </div>

View File

@ -0,0 +1,18 @@
{% extends "pages/password/reset.twig" %}
{% import 'macros/base.twig' as m %}
{% import 'macros/form.twig' as f %}
{% block row_content %}
<div class="col-md-8">
<form action="" enctype="multipart/form-data" method="post">
{{ csrf() }}
{{ f.input('password', __('Password'), 'password', true) }}
{{ f.input('password_confirmation', __('Confirm password'), 'password', true) }}
<div class="form-group">
{{ f.submit(__('Save')) }}
</div>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends "pages/password/reset.twig" %}
{% import 'macros/base.twig' as m %}
{% block row_content %}
<div class="col-md-12">
{% if type == 'email' %}
{{ m.alert(__('We sent you an email containing your password recovery link.'), 'info') }}
{% elseif type == 'reset' %}
{{ m.alert(__('Password saved.'), 'success') }}
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,32 @@
{% extends 'layouts/app.twig' %}
{% import 'macros/base.twig' as m %}
{% import 'macros/form.twig' as f %}
{% block title %}{{ __('Password recovery') }}{% endblock %}
{% block content %}
<div class="container">
<h1>{{ __('Password recovery') }}</h1>
{% for message in errors|default([]) %}
{{ m.alert(__(message), 'danger') }}
{% endfor %}
<div class="row">
{% block row_content %}
<div class="col-md-8">
<form action="" enctype="multipart/form-data" method="post">
{{ csrf() }}
{{ __('We will send you an e-mail with a password recovery link. Please use the email address you used for registration.') }}
{{ f.input('email', __('E-Mail'), 'email', true) }}
<div class="form-group">
{{ f.submit(__('Recover')) }}
</div>
</form>
</div>
{% endblock %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,167 @@
<?php
namespace Engelsystem\Controllers;
use Engelsystem\Http\Exceptions\HttpNotFound;
use Engelsystem\Http\Request;
use Engelsystem\Http\Response;
use Engelsystem\Mail\EngelsystemMailer;
use Engelsystem\Models\User\PasswordReset;
use Engelsystem\Models\User\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
class PasswordResetController extends BaseController
{
/** @var LoggerInterface */
protected $log;
/** @var EngelsystemMailer */
protected $mail;
/** @var Response */
protected $response;
/** @var SessionInterface */
protected $session;
/** @var array */
protected $permissions = [
'reset' => 'login',
'postReset' => 'login',
'resetPassword' => 'login',
'postResetPassword' => 'login',
];
/**
* @param Response $response
* @param SessionInterface $session
* @param EngelsystemMailer $mail
* @param LoggerInterface $log
*/
public function __construct(
Response $response,
SessionInterface $session,
EngelsystemMailer $mail,
LoggerInterface $log
) {
$this->log = $log;
$this->mail = $mail;
$this->response = $response;
$this->session = $session;
}
/**
* @return Response
*/
public function reset(): Response
{
return $this->showView('pages/password/reset');
}
/**
* @param Request $request
* @return Response
*/
public function postReset(Request $request): Response
{
$data = $this->validate($request, [
'email' => 'required|email',
]);
/** @var User $user */
$user = User::whereEmail($data['email'])->first();
if ($user) {
$reset = PasswordReset::findOrNew($user->id);
$reset->user_id = $user->id;
$reset->token = md5(random_bytes(64));
$reset->save();
$this->log->info(
sprintf('Password recovery for %s (%u)', $user->name, $user->id),
['user' => $user->toJson()]
);
$this->mail->sendViewTranslated(
$user,
'Password recovery',
'emails/password-reset',
['username' => $user->name, 'reset' => $reset]
);
}
return $this->showView('pages/password/reset-success', ['type' => 'email']);
}
/**
* @param Request $request
* @return Response
*/
public function resetPassword(Request $request): Response
{
$this->requireToken($request);
return $this->showView('pages/password/reset-form');
}
/**
* @param Request $request
* @return Response
*/
public function postResetPassword(Request $request): Response
{
$reset = $this->requireToken($request);
$data = $this->validate($request, [
'password' => 'required|min:' . config('min_password_length'),
'password_confirmation' => 'required',
]);
if ($data['password'] !== $data['password_confirmation']) {
$this->session->set('errors',
array_merge($this->session->get('errors', []), ['validation.password.confirmed']));
return $this->showView('pages/password/reset-form');
}
auth()->setPassword($reset->user, $data['password']);
$reset->delete();
return $this->showView('pages/password/reset-success', ['type' => 'reset']);
}
/**
* @param string $view
* @param array $data
* @return Response
*/
protected function showView($view = 'pages/password/reset', $data = []): Response
{
$errors = Collection::make(Arr::flatten($this->session->get('errors', [])));
$this->session->remove('errors');
return $this->response->withView(
$view,
array_merge_recursive(['errors' => $errors], $data)
);
}
/**
* @param Request $request
* @return PasswordReset
*/
protected function requireToken(Request $request): PasswordReset
{
$token = $request->getAttribute('token');
/** @var PasswordReset|null $reset */
$reset = PasswordReset::whereToken($token)->first();
if (!$reset) {
throw new HttpNotFound();
}
return $reset;
}
}

View File

@ -26,7 +26,6 @@ class LegacyMiddleware implements MiddlewareInterface
'shifts_json_export', 'shifts_json_export',
'users', 'users',
'user_driver_licenses', 'user_driver_licenses',
'user_password_recovery',
'user_worklog', 'user_worklog',
]; ];
@ -112,11 +111,6 @@ class LegacyMiddleware implements MiddlewareInterface
case 'shifts_json_export': case 'shifts_json_export':
require_once realpath(__DIR__ . '/../../includes/controller/shifts_controller.php'); require_once realpath(__DIR__ . '/../../includes/controller/shifts_controller.php');
shifts_json_export_controller(); shifts_json_export_controller();
case 'user_password_recovery':
require_once realpath(__DIR__ . '/../../includes/controller/users_controller.php');
$title = user_password_recovery_title();
$content = user_password_recovery_controller();
return [$title, $content];
case 'public_dashboard': case 'public_dashboard':
return public_dashboard_controller(); return public_dashboard_controller();
case 'angeltypes': case 'angeltypes':

View File

@ -0,0 +1,266 @@
<?php
namespace Engelsystem\Test\Unit\Controllers;
use Engelsystem\Config\Config;
use Engelsystem\Controllers\PasswordResetController;
use Engelsystem\Helpers\Authenticator;
use Engelsystem\Http\Exceptions\HttpNotFound;
use Engelsystem\Http\Exceptions\ValidationException;
use Engelsystem\Http\Request;
use Engelsystem\Http\Response;
use Engelsystem\Http\Validation\Validator;
use Engelsystem\Mail\EngelsystemMailer;
use Engelsystem\Models\User\PasswordReset;
use Engelsystem\Models\User\User;
use Engelsystem\Renderer\Renderer;
use Engelsystem\Test\Unit\HasDatabase;
use Engelsystem\Test\Unit\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\Test\TestLogger;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
class PasswordResetControllerTest extends TestCase
{
use HasDatabase;
/** @var array */
protected $args = [];
/**
* @covers \Engelsystem\Controllers\PasswordResetController::reset
* @covers \Engelsystem\Controllers\PasswordResetController::__construct
*/
public function testReset(): void
{
$controller = $this->getController('pages/password/reset');
$response = $controller->reset();
$this->assertEquals(200, $response->getStatusCode());
}
/**
* @covers \Engelsystem\Controllers\PasswordResetController::postReset
*/
public function testPostReset(): void
{
$this->initDatabase();
$request = new Request([], ['email' => 'foo@bar.batz']);
$user = $this->createUser();
$controller = $this->getController(
'pages/password/reset-success',
['type' => 'email', 'errors' => collect()]
);
/** @var TestLogger $log */
$log = $this->args['log'];
/** @var EngelsystemMailer|MockObject $mailer */
$mailer = $this->args['mailer'];
$this->setExpects($mailer, 'sendViewTranslated');
$controller->postReset($request);
$this->assertNotEmpty(PasswordReset::find($user->id)->first());
$this->assertTrue($log->hasInfoThatContains($user->name));
}
/**
* @covers \Engelsystem\Controllers\PasswordResetController::postReset
*/
public function testPostResetInvalidRequest(): void
{
$request = new Request();
$controller = $this->getController();
$this->expectException(ValidationException::class);
$controller->postReset($request);
}
/**
* @covers \Engelsystem\Controllers\PasswordResetController::postReset
*/
public function testPostResetNoUser(): void
{
$this->initDatabase();
$request = new Request([], ['email' => 'foo@bar.batz']);
$controller = $this->getController(
'pages/password/reset-success',
['type' => 'email', 'errors' => collect()]
);
$controller->postReset($request);
}
/**
* @covers \Engelsystem\Controllers\PasswordResetController::resetPassword
* @covers \Engelsystem\Controllers\PasswordResetController::requireToken
*/
public function testResetPassword(): void
{
$this->initDatabase();
$user = $this->createUser();
$token = $this->createToken($user);
$request = new Request([], [], ['token' => $token->token]);
$controller = $this->getController('pages/password/reset-form');
$controller->resetPassword($request);
}
/**
* @covers \Engelsystem\Controllers\PasswordResetController::resetPassword
* @covers \Engelsystem\Controllers\PasswordResetController::requireToken
*/
public function testResetPasswordNoToken(): void
{
$this->initDatabase();
$controller = $this->getController();
$this->expectException(HttpNotFound::class);
$controller->resetPassword(new Request());
}
/**
* @covers \Engelsystem\Controllers\PasswordResetController::postResetPassword
*/
public function testPostResetPassword(): void
{
$this->initDatabase();
$this->app->instance('config', new Config(['min_password_length' => 3]));
$user = $this->createUser();
$token = $this->createToken($user);
$password = 'SomeRandomPasswordForAmazingSecurity';
$request = new Request(
[],
['password' => $password, 'password_confirmation' => $password],
['token' => $token->token]
);
$controller = $this->getController(
'pages/password/reset-success',
['type' => 'reset', 'errors' => collect()]
);
$auth = new Authenticator($request, $this->args['session'], $user);
$this->app->instance('authenticator', $auth);
$response = $controller->postResetPassword($request);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEmpty(PasswordReset::find($user->id));
$this->assertNotNull(auth()->authenticate($user->name, $password));
}
/**
* @covers \Engelsystem\Controllers\PasswordResetController::postResetPassword
* @covers \Engelsystem\Controllers\PasswordResetController::showView
*/
public function testPostResetPasswordNotMatching(): void
{
$this->initDatabase();
$this->app->instance('config', new Config(['min_password_length' => 3]));
$user = $this->createUser();
$token = $this->createToken($user);
$password = 'SomeRandomPasswordForAmazingSecurity';
$request = new Request(
[],
['password' => $password, 'password_confirmation' => $password . 'OrNot'],
['token' => $token->token]
);
$controller = $this->getController(
'pages/password/reset-form',
['errors' => collect(['some.other.error', 'validation.password.confirmed'])]
);
/** @var Session $session */
$session = $this->args['session'];
$session->set('errors', ['foo' => ['bar' => 'some.other.error']]);
$controller->postResetPassword($request);
$this->assertEmpty($session->get('errors'));
}
/**
* @return array
*/
protected function getControllerArgs(): array
{
$response = new Response();
$session = new Session(new MockArraySessionStorage());
/** @var EngelsystemMailer|MockObject $mailer */
$mailer = $this->createMock(EngelsystemMailer::class);
$log = new TestLogger();
$renderer = $this->createMock(Renderer::class);
$response->setRenderer($renderer);
return $this->args = [
'response' => $response,
'session' => $session,
'mailer' => $mailer,
'log' => $log,
'renderer' => $renderer
];
}
/**
* @param string $view
* @param array $data
* @return PasswordResetController
*/
protected function getController(?string $view = null, ?array $data = null): PasswordResetController
{
/** @var Response $response */
/** @var Session $session */
/** @var EngelsystemMailer|MockObject $mailer */
/** @var TestLogger $log */
/** @var Renderer|MockObject $renderer */
list($response, $session, $mailer, $log, $renderer) = array_values($this->getControllerArgs());
$controller = new PasswordResetController($response, $session, $mailer, $log);
$controller->setValidator(new Validator());
if ($view) {
$args = [$view];
if ($data) {
$args[] = $data;
}
$this->setExpects($renderer, 'render', $args, 'Foo');
}
return $controller;
}
/**
* @return User
*/
protected function createUser(): User
{
$user = new User([
'name' => 'foo',
'password' => '',
'email' => 'foo@bar.batz',
'api_key' => '',
]);
$user->save();
return $user;
}
/**
* @param User $user
* @return PasswordReset
*/
protected function createToken(User $user): PasswordReset
{
$reset = new PasswordReset(['user_id' => $user->id, 'token' => 'SomeTestToken123']);
$reset->save();
return $reset;
}
}