diff --git a/config/routes.php b/config/routes.php
index 02fd3abd..e57d3079 100644
--- a/config/routes.php
+++ b/config/routes.php
@@ -13,6 +13,12 @@ $route->get('/login', 'AuthController@login');
$route->post('/login', 'AuthController@postLogin');
$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
$route->get('/metrics', 'Metrics\\Controller@metrics');
$route->get('/stats', 'Metrics\\Controller@stats');
diff --git a/includes/controller/users_controller.php b/includes/controller/users_controller.php
index 892089e7..3ad2ffd9 100644
--- a/includes/controller/users_controller.php
+++ b/includes/controller/users_controller.php
@@ -1,7 +1,6 @@
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.
*
diff --git a/includes/model/User_model.php b/includes/model/User_model.php
index 1994bc47..681e70aa 100644
--- a/includes/model/User_model.php
+++ b/includes/model/User_model.php
@@ -2,7 +2,6 @@
use Carbon\Carbon;
use Engelsystem\Database\DB;
-use Engelsystem\Models\User\PasswordReset;
use Engelsystem\Models\User\User;
use Engelsystem\ValidationResult;
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
* @return float
diff --git a/includes/view/User_view.php b/includes/view/User_view.php
index b38a5062..95ecb626 100644
--- a/includes/view/User_view.php
+++ b/includes/view/User_view.php
@@ -759,41 +759,6 @@ function User_view_state_admin($freeloader, $user_source)
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
* @return string
diff --git a/resources/lang/de_DE/default.mo b/resources/lang/de_DE/default.mo
index fb93d590..d4b7885b 100644
Binary files a/resources/lang/de_DE/default.mo and b/resources/lang/de_DE/default.mo differ
diff --git a/resources/lang/de_DE/default.po b/resources/lang/de_DE/default.po
index 091e1114..d6dc38bb 100644
--- a/resources/lang/de_DE/default.po
+++ b/resources/lang/de_DE/default.po
@@ -619,9 +619,9 @@ msgid "Please visit %s to recover your password."
msgstr "Bitte besuche %s, um Dein Passwort zurückzusetzen"
#: 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 ""
-"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
#, php-format
@@ -2769,3 +2769,21 @@ msgstr "Bitte gib ein Passwort an."
msgid "validation.login.required"
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."
diff --git a/resources/lang/en_US/default.mo b/resources/lang/en_US/default.mo
index 7ef9c3b2..30dd375d 100644
Binary files a/resources/lang/en_US/default.mo and b/resources/lang/en_US/default.mo differ
diff --git a/resources/lang/en_US/default.po b/resources/lang/en_US/default.po
index 1ff16c83..a2d56fd1 100644
--- a/resources/lang/en_US/default.po
+++ b/resources/lang/en_US/default.po
@@ -30,3 +30,21 @@ msgstr "The password is required."
msgid "validation.login.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."
diff --git a/resources/lang/pt_BR/default.mo b/resources/lang/pt_BR/default.mo
index 8b864156..d15826e6 100644
Binary files a/resources/lang/pt_BR/default.mo and b/resources/lang/pt_BR/default.mo differ
diff --git a/resources/lang/pt_BR/default.po b/resources/lang/pt_BR/default.po
index b9bf420d..e5e5371e 100644
--- a/resources/lang/pt_BR/default.po
+++ b/resources/lang/pt_BR/default.po
@@ -551,7 +551,7 @@ msgid "Please visit %s to recover your password."
msgstr "Por favor visite %s para recuperar sua senha"
#: 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."
#: includes/helper/email_helper.php:12
diff --git a/resources/views/emails/mail.twig b/resources/views/emails/mail.twig
index ec70f594..e0ad7b64 100644
--- a/resources/views/emails/mail.twig
+++ b/resources/views/emails/mail.twig
@@ -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')]) }}
-{{ message|raw }}
+{% block introduction %}{{ __('here is a message for you from the %s:', [config('app_name')]) }}{% endblock %}
+{% 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 %}
diff --git a/resources/views/emails/password-reset.twig b/resources/views/emails/password-reset.twig
new file mode 100644
index 00000000..30b613b4
--- /dev/null
+++ b/resources/views/emails/password-reset.twig
@@ -0,0 +1,3 @@
+{% extends "emails/mail.twig" %}
+
+{% block message %}{{ __('Please visit %s to recover your password.', [url('/password/reset/') ~ reset.token]) }}{% endblock %}
diff --git a/resources/views/macros/form.twig b/resources/views/macros/form.twig
new file mode 100644
index 00000000..5d41b085
--- /dev/null
+++ b/resources/views/macros/form.twig
@@ -0,0 +1,18 @@
+{% macro input(name, label, type, required) %}
+
+ {% if label %}
+
+ {% endif %}
+
+
+{% endmacro %}
+
+{% macro hidden(name, value) %}
+
+{% endmacro %}
+
+{% macro submit(label) %}
+
+{% endmacro %}
diff --git a/resources/views/pages/login.twig b/resources/views/pages/login.twig
index 6160508f..34dbd63f 100644
--- a/resources/views/pages/login.twig
+++ b/resources/views/pages/login.twig
@@ -62,7 +62,7 @@
diff --git a/resources/views/pages/password/reset-form.twig b/resources/views/pages/password/reset-form.twig
new file mode 100644
index 00000000..60eb2499
--- /dev/null
+++ b/resources/views/pages/password/reset-form.twig
@@ -0,0 +1,18 @@
+{% extends "pages/password/reset.twig" %}
+{% import 'macros/base.twig' as m %}
+{% import 'macros/form.twig' as f %}
+
+{% block row_content %}
+
+{% endblock %}
diff --git a/resources/views/pages/password/reset-success.twig b/resources/views/pages/password/reset-success.twig
new file mode 100644
index 00000000..19b8a93e
--- /dev/null
+++ b/resources/views/pages/password/reset-success.twig
@@ -0,0 +1,12 @@
+{% extends "pages/password/reset.twig" %}
+{% import 'macros/base.twig' as m %}
+
+{% block row_content %}
+
+ {% 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 %}
+
+{% endblock %}
diff --git a/resources/views/pages/password/reset.twig b/resources/views/pages/password/reset.twig
new file mode 100644
index 00000000..289152ea
--- /dev/null
+++ b/resources/views/pages/password/reset.twig
@@ -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 %}
+
+
{{ __('Password recovery') }}
+
+ {% for message in errors|default([]) %}
+ {{ m.alert(__(message), 'danger') }}
+ {% endfor %}
+
+
+ {% block row_content %}
+
+ {% endblock %}
+
+
+{% endblock %}
diff --git a/src/Controllers/PasswordResetController.php b/src/Controllers/PasswordResetController.php
new file mode 100644
index 00000000..505ed8eb
--- /dev/null
+++ b/src/Controllers/PasswordResetController.php
@@ -0,0 +1,167 @@
+ '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;
+ }
+}
diff --git a/src/Middleware/LegacyMiddleware.php b/src/Middleware/LegacyMiddleware.php
index 27a15faa..11508e1c 100644
--- a/src/Middleware/LegacyMiddleware.php
+++ b/src/Middleware/LegacyMiddleware.php
@@ -26,7 +26,6 @@ class LegacyMiddleware implements MiddlewareInterface
'shifts_json_export',
'users',
'user_driver_licenses',
- 'user_password_recovery',
'user_worklog',
];
@@ -112,11 +111,6 @@ class LegacyMiddleware implements MiddlewareInterface
case 'shifts_json_export':
require_once realpath(__DIR__ . '/../../includes/controller/shifts_controller.php');
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':
return public_dashboard_controller();
case 'angeltypes':
diff --git a/tests/Unit/Controllers/PasswordResetControllerTest.php b/tests/Unit/Controllers/PasswordResetControllerTest.php
new file mode 100644
index 00000000..54046cef
--- /dev/null
+++ b/tests/Unit/Controllers/PasswordResetControllerTest.php
@@ -0,0 +1,266 @@
+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;
+ }
+}