diff --git a/app/Events/UserChangedEmail.php b/app/Events/UserChangedEmail.php new file mode 100644 index 0000000000..5da0caeb79 --- /dev/null +++ b/app/Events/UserChangedEmail.php @@ -0,0 +1,51 @@ +user = $user; + $this->ipAddress = $ipAddress; + $this->oldEmail = $oldEmail; + $this->newEmail = $newEmail; + } +} \ No newline at end of file diff --git a/app/Handlers/Events/UserEventHandler.php b/app/Handlers/Events/UserEventHandler.php index 8c7eb4a90c..1f17e6a39c 100644 --- a/app/Handlers/Events/UserEventHandler.php +++ b/app/Handlers/Events/UserEventHandler.php @@ -15,11 +15,15 @@ namespace FireflyIII\Handlers\Events; use FireflyIII\Events\RegisteredUser; use FireflyIII\Events\RequestedNewPassword; +use FireflyIII\Events\UserChangedEmail; +use FireflyIII\Mail\ConfirmEmailChangeMail; use FireflyIII\Mail\RegisteredUser as RegisteredUserMail; use FireflyIII\Mail\RequestedNewPassword as RequestedNewPasswordMail; +use FireflyIII\Mail\UndoEmailChangeMail; use FireflyIII\Repositories\User\UserRepositoryInterface; use Log; use Mail; +use Preferences; use Swift_TransportException; /** @@ -54,6 +58,54 @@ class UserEventHandler return true; } + /** + * @param UserChangedEmail $event + * + * @return bool + */ + public function sendEmailChangeConfirmMail(UserChangedEmail $event): bool + { + $newEmail = $event->newEmail; + $oldEmail = $event->oldEmail; + $user = $event->user; + $ipAddress = $event->ipAddress; + $token = Preferences::getForUser($user, 'email_change_confirm_token', 'invalid'); + $uri = route('profile.confirm-email-change', [$token->data]); + try { + Mail::to($newEmail)->send(new ConfirmEmailChangeMail($newEmail, $oldEmail, $uri, $ipAddress)); + // @codeCoverageIgnoreStart + } catch (Swift_TransportException $e) { + Log::error($e->getMessage()); + } + + // @codeCoverageIgnoreEnd + return true; + } + + /** + * @param UserChangedEmail $event + * + * @return bool + */ + public function sendEmailChangeUndoMail(UserChangedEmail $event): bool + { + $newEmail = $event->newEmail; + $oldEmail = $event->oldEmail; + $user = $event->user; + $ipAddress = $event->ipAddress; + $token = Preferences::getForUser($user, 'email_change_undo_token', 'invalid'); + $uri = route('profile.undo-email-change', [$token->data, hash('sha256', $oldEmail)]); + try { + Mail::to($oldEmail)->send(new UndoEmailChangeMail($newEmail, $oldEmail, $uri, $ipAddress)); + // @codeCoverageIgnoreStart + } catch (Swift_TransportException $e) { + Log::error($e->getMessage()); + } + + // @codeCoverageIgnoreEnd + return true; + } + /** * @param RequestedNewPassword $event * diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index d975ec5adc..8c5a860e13 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -14,6 +14,7 @@ declare(strict_types=1); namespace FireflyIII\Http\Controllers\Auth; use FireflyConfig; +use FireflyIII\Events\UserChangedEmail; use FireflyIII\Http\Controllers\Controller; use FireflyIII\User; use Illuminate\Cookie\CookieJar; diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index a85086a060..79ab6bb5a4 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -13,10 +13,15 @@ declare(strict_types=1); namespace FireflyIII\Http\Controllers; +use Auth; +use FireflyIII\Events\UserChangedEmail; +use FireflyIII\Exceptions\FireflyException; use FireflyIII\Exceptions\ValidationException; use FireflyIII\Http\Middleware\IsLimitedUser; use FireflyIII\Http\Requests\DeleteAccountFormRequest; +use FireflyIII\Http\Requests\EmailFormRequest; use FireflyIII\Http\Requests\ProfileFormRequest; +use FireflyIII\Models\Preference; use FireflyIII\Repositories\User\UserRepositoryInterface; use FireflyIII\User; use Hash; @@ -48,10 +53,23 @@ class ProfileController extends Controller return $next($request); } ); - $this->middleware(IsLimitedUser::class); + $this->middleware(IsLimitedUser::class)->except(['confirmEmailChange', 'undoEmailChange']); } + /** + * @return View + */ + public function changeEmail() + { + $title = auth()->user()->email; + $email = auth()->user()->email; + $subTitle = strval(trans('firefly.change_your_email')); + $subTitleIcon = 'fa-envelope'; + + return view('profile.change-email', compact('title', 'subTitle', 'subTitleIcon', 'email')); + } + /** * @return View */ @@ -64,6 +82,37 @@ class ProfileController extends Controller return view('profile.change-password', compact('title', 'subTitle', 'subTitleIcon')); } + /** + * @param string $token + * + * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector + * @throws FireflyException + */ + public function confirmEmailChange(string $token) + { + // find preference with this token value. + $set = Preferences::findByName('email_change_confirm_token'); + $user = null; + /** @var Preference $preference */ + foreach ($set as $preference) { + if ($preference->data === $token) { + $user = $preference->user; + } + } + // update user to clear blocked and blocked_code. + if (is_null($user)) { + throw new FireflyException('Invalid token.'); + } + $user->blocked = 0; + $user->blocked_code = ''; + $user->save(); + + // return to login. + Session::flash('success', strval(trans('firefly.login_with_new_email'))); + + return redirect(route('login')); + } + /** * @return View */ @@ -95,6 +144,49 @@ class ProfileController extends Controller return view('profile.index', compact('subTitle', 'userId', 'accessToken')); } + /** + * @param EmailFormRequest $request + * @param UserRepositoryInterface $repository + * + * @return $this|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector + */ + public function postChangeEmail(EmailFormRequest $request, UserRepositoryInterface $repository) + { + /** @var User $user */ + $user = auth()->user(); + $newEmail = $request->string('email'); + $oldEmail = $user->email; + if ($newEmail === $user->email) { + Session::flash('error', strval(trans('firefly.email_not_changed'))); + + return redirect(route('profile.change-email'))->withInput(); + } + $existing = $repository->findByEmail($newEmail); + if (!is_null($existing)) { + // force user logout. + $this->guard()->logout(); + $request->session()->invalidate(); + + Session::flash('success', strval(trans('firefly.email_changed'))); + + return redirect(route('index')); + } + + // now actually update user: + $repository->changeEmail($user, $newEmail); + + // call event. + $ipAddress = $request->ip(); + event(new UserChangedEmail($user, $newEmail, $oldEmail, $ipAddress)); + + // force user logout. + Auth::guard()->logout(); + $request->session()->invalidate(); + Session::flash('success', strval(trans('firefly.email_changed'))); + + return redirect(route('index')); + } + /** * @param ProfileFormRequest $request * @param UserRepositoryInterface $repository @@ -160,6 +252,53 @@ class ProfileController extends Controller return redirect(route('profile.index')); } + /** + * @param string $token + * @param string $hash + * + * @throws FireflyException + */ + public function undoEmailChange(string $token, string $hash) + { + // find preference with this token value. + $set = Preferences::findByName('email_change_undo_token'); + $user = null; + /** @var Preference $preference */ + foreach ($set as $preference) { + if ($preference->data === $token) { + $user = $preference->user; + } + } + if (is_null($user)) { + throw new FireflyException('Invalid token.'); + } + + // found user. + // which email address to return to? + $set = Preferences::beginsWith($user, 'previous_email_'); + $match = null; + foreach ($set as $entry) { + $hashed = hash('sha256', $entry->data); + if ($hashed === $hash) { + $match = $entry->data; + break; + } + } + if (is_null($match)) { + throw new FireflyException('Invalid token.'); + } + // change user back + $user->email = $match; + $user->blocked = 0; + $user->blocked_code = ''; + $user->save(); + + // return to login. + Session::flash('success', strval(trans('firefly.login_with_old_email'))); + + return redirect(route('login')); + } + /** * @param User $user * @param string $current diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php index 5733cb1c1a..5e4d6f9ed6 100644 --- a/app/Http/Middleware/Authenticate.php +++ b/app/Http/Middleware/Authenticate.php @@ -44,8 +44,13 @@ class Authenticate return redirect()->guest('login'); } if (intval(auth()->user()->blocked) === 1) { + $message = strval(trans('firefly.block_account_logout')); + if (auth()->user()->blocked_code === 'email_changed') { + $message = strval(trans('firefly.email_changed_logout')); + } + + Session::flash('logoutMessage', $message); Auth::guard($guard)->logout(); - Session::flash('logoutMessage', trans('firefly.block_account_logout')); return redirect()->guest('login'); } diff --git a/app/Http/Requests/EmailFormRequest.php b/app/Http/Requests/EmailFormRequest.php new file mode 100644 index 0000000000..5201d92202 --- /dev/null +++ b/app/Http/Requests/EmailFormRequest.php @@ -0,0 +1,42 @@ +check(); + } + + /** + * @return array + */ + public function rules() + { + // fixed + return [ + 'email' => 'required|email', + ]; + } +} diff --git a/app/Mail/ConfirmEmailChangeMail.php b/app/Mail/ConfirmEmailChangeMail.php new file mode 100644 index 0000000000..1e54293460 --- /dev/null +++ b/app/Mail/ConfirmEmailChangeMail.php @@ -0,0 +1,49 @@ +newEmail = $newEmail; + $this->oldEmail = $oldEmail; + $this->uri = $uri; + $this->ipAddress = $ipAddress; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + return $this->view('emails.confirm-email-change-html')->text('emails.confirm-email-change-text') + ->subject('Your Firefly III email address has changed.'); + } +} diff --git a/app/Mail/UndoEmailChangeMail.php b/app/Mail/UndoEmailChangeMail.php new file mode 100644 index 0000000000..5686795fb2 --- /dev/null +++ b/app/Mail/UndoEmailChangeMail.php @@ -0,0 +1,50 @@ +newEmail = $newEmail; + $this->oldEmail = $oldEmail; + $this->uri = $uri; + $this->ipAddress = $ipAddress; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + return $this->view('emails.undo-email-change-html')->text('emails.undo-email-change-text') + ->subject('Your Firefly III email address has changed.'); + } +} diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index e1e1e4de4e..4e7aa2068b 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -36,26 +36,35 @@ class EventServiceProvider extends ServiceProvider */ protected $listen = [ - // new event handlers: - 'FireflyIII\Events\RegisteredUser' => // is a User related event. + // is a User related event. + 'FireflyIII\Events\RegisteredUser' => [ 'FireflyIII\Handlers\Events\UserEventHandler@sendRegistrationMail', 'FireflyIII\Handlers\Events\UserEventHandler@attachUserRole', ], - 'FireflyIII\Events\RequestedNewPassword' => [ // is a User related event. - 'FireflyIII\Handlers\Events\UserEventHandler@sendNewPassword', + // is a User related event. + 'FireflyIII\Events\RequestedNewPassword' => [ + 'FireflyIII\Handlers\Events\UserEventHandler@sendNewPassword', ], - 'FireflyIII\Events\StoredTransactionJournal' => // is a Transaction Journal related event. + // is a User related event. + 'FireflyIII\Events\UserChangedEmail' => [ + 'FireflyIII\Handlers\Events\UserEventHandler@sendEmailChangeConfirmMail', + 'FireflyIII\Handlers\Events\UserEventHandler@sendEmailChangeUndoMail', + ], + // is a Transaction Journal related event. + 'FireflyIII\Events\StoredTransactionJournal' => [ 'FireflyIII\Handlers\Events\StoredJournalEventHandler@scanBills', 'FireflyIII\Handlers\Events\StoredJournalEventHandler@connectToPiggyBank', 'FireflyIII\Handlers\Events\StoredJournalEventHandler@processRules', ], - 'FireflyIII\Events\UpdatedTransactionJournal' => // is a Transaction Journal related event. + // is a Transaction Journal related event. + 'FireflyIII\Events\UpdatedTransactionJournal' => [ 'FireflyIII\Handlers\Events\UpdatedJournalEventHandler@scanBills', 'FireflyIII\Handlers\Events\UpdatedJournalEventHandler@processRules', ], + ]; /** diff --git a/app/Repositories/User/UserRepository.php b/app/Repositories/User/UserRepository.php index ae31df83c9..c66ab9b494 100644 --- a/app/Repositories/User/UserRepository.php +++ b/app/Repositories/User/UserRepository.php @@ -52,6 +52,33 @@ class UserRepository implements UserRepositoryInterface return true; } + /** + * @param User $user + * @param string $newEmail + * + * @return bool + */ + public function changeEmail(User $user, string $newEmail): bool + { + $oldEmail = $user->email; + + // save old email as pref + Preferences::setForUser($user, 'previous_email_latest', $oldEmail); + Preferences::setForUser($user, 'previous_email_' . date('Y-m-d-H-i-s'), $oldEmail); + + // set undo and confirm token: + Preferences::setForUser($user, 'email_change_undo_token', strval(bin2hex(random_bytes(16)))); + Preferences::setForUser($user, 'email_change_confirm_token', strval(bin2hex(random_bytes(16)))); + // update user + + $user->email = $newEmail; + $user->blocked = 1; + $user->blocked_code = 'email_changed'; + $user->save(); + + return true; + } + /** * @param User $user * @param string $password @@ -119,6 +146,16 @@ class UserRepository implements UserRepositoryInterface return new User; } + /** + * @param string $email + * + * @return User|null + */ + public function findByEmail(string $email): ?User + { + return User::where('email', $email)->first(); + } + /** * Return basic user information. * diff --git a/app/Repositories/User/UserRepositoryInterface.php b/app/Repositories/User/UserRepositoryInterface.php index 118ecf4a84..93af01ba07 100644 --- a/app/Repositories/User/UserRepositoryInterface.php +++ b/app/Repositories/User/UserRepositoryInterface.php @@ -42,6 +42,14 @@ interface UserRepositoryInterface */ public function attachRole(User $user, string $role): bool; + /** + * @param User $user + * @param string $newEmail + * + * @return bool + */ + public function changeEmail(User $user, string $newEmail): bool; + /** * @param User $user * @param string $password @@ -80,6 +88,13 @@ interface UserRepositoryInterface */ public function find(int $userId): User; + /** + * @param string $email + * + * @return User|null + */ + public function findByEmail(string $email): ?User; + /** * Return basic user information. * diff --git a/app/Support/Preferences.php b/app/Support/Preferences.php index 743a6444e1..0c52f496c9 100644 --- a/app/Support/Preferences.php +++ b/app/Support/Preferences.php @@ -16,6 +16,7 @@ namespace FireflyIII\Support; use Cache; use FireflyIII\Models\Preference; use FireflyIII\User; +use Illuminate\Support\Collection; use Session; /** @@ -25,13 +26,26 @@ use Session; */ class Preferences { + /** + * @param User $user + * @param string $search + * + * @return Collection + */ + public function beginsWith(User $user, string $search): Collection + { + $set = Preference::where('user_id', $user->id)->where('name', 'LIKE', $search . '%')->get(); + + return $set; + } + /** * @param $name * * @return bool * @throws \Exception */ - public function delete($name): bool + public function delete(string $name): bool { $fullName = sprintf('preference%s%s', auth()->user()->id, $name); if (Cache::has($fullName)) { @@ -42,6 +56,18 @@ class Preferences return true; } + /** + * @param string $name + * + * @return Collection + */ + public function findByName(string $name): Collection + { + $set = Preference::where('name', $name)->get(); + + return $set; + } + /** * @param $name * @param null $default diff --git a/resources/lang/en_US/firefly.php b/resources/lang/en_US/firefly.php index e43c0c1a8a..4a0a21e0af 100644 --- a/resources/lang/en_US/firefly.php +++ b/resources/lang/en_US/firefly.php @@ -424,6 +424,11 @@ return [ 'explain_access_token' => 'You need this token to perform command line options, such as importing or exporting data. Without it, such sensitive commands will not work. Do not share your access token. Nobody will ask you for this token, not even me. If you fear you lost this, or when you\'re paranoid, regenerate this token using the button.', 'regenerate_access_token' => 'Regenerate access token', 'token_regenerated' => 'A new token was generated', + 'change_your_email' => 'Change your email address', + 'email_verification' => 'An email message will be sent to your old AND new email address. For security purposes, you will not be able to login until you verify your new email address. If you are unsure if your Firefly III installation is capable of sending email, please do not use this feature. You can test this in the Administration.', + 'email_changed_logout' => 'Until you verify your email address, you cannot login.', + 'login_with_new_email' => 'You can now login with your new email address.', + 'login_with_old_email' => 'You can now login with your old email address again.', // attachments 'nr_of_attachments' => 'One attachment|:count attachments', diff --git a/resources/lang/en_US/form.php b/resources/lang/en_US/form.php index 8ca2fd2d9f..d12f7f7230 100644 --- a/resources/lang/en_US/form.php +++ b/resources/lang/en_US/form.php @@ -67,6 +67,8 @@ return [ 'source_amount' => 'Amount (source)', 'destination_amount' => 'Amount (destination)', 'native_amount' => 'Native amount', + 'new_email_address' => 'New email address', + 'verification' => 'Verification', 'source_account_asset' => 'Source account (asset account)', 'destination_account_expense' => 'Destination account (expense account)', diff --git a/resources/views/auth/login.twig b/resources/views/auth/login.twig index 8e925d16ba..2db9351349 100644 --- a/resources/views/auth/login.twig +++ b/resources/views/auth/login.twig @@ -13,6 +13,16 @@ {% endif %} + {# SUCCESS MESSAGE (ALWAYS SINGULAR) #} + {% if Session.has('success') %} +
+ You or somebody with access to your Firefly III account has changed your email address. If you did not expect this message, please ignore and delete it. +
++ The old email addres was: {{ oldEmail }} +
++ The new email address is: {{ newEmail }} +
++ You cannot use Firefly III until you confirm this change. Please follow the link below to do so. +
+ ++ {{ uri }} +
+{% include 'emails.footer-html' %} diff --git a/resources/views/emails/confirm-email-change-text.twig b/resources/views/emails/confirm-email-change-text.twig new file mode 100644 index 0000000000..cea4fd7c08 --- /dev/null +++ b/resources/views/emails/confirm-email-change-text.twig @@ -0,0 +1,9 @@ +{% include 'emails.header-text' %} +You or somebody with access to your Firefly III account has changed your email address. If you did not expect this message, please ignore and delete it. + +The old email addres was: {{ oldEmail }} + +The new email address is: {{ newEmail }} + +You cannot use Firefly III until you confirm this change. Please follow the link to do so: {{ uri }} +{% include 'emails.footer-text' %} diff --git a/resources/views/emails/undo-email-change-html.twig b/resources/views/emails/undo-email-change-html.twig new file mode 100644 index 0000000000..8d6021ee64 --- /dev/null +++ b/resources/views/emails/undo-email-change-html.twig @@ -0,0 +1,18 @@ +{% include 'emails.header-html' %} ++ You or somebody with access to your Firefly III account has changed your email address. + If you did not expect this to happen, you must follow the "undo"-link below to protect your account! +
++ If you initiated this change, you may safely ignore this message. +
++ The old email addres was: {{ oldEmail }} +
++ The new email address is: {{ newEmail }} +
++ To undo the change, follow this link: {{ uri }} +
+{% include 'emails.footer-html' %} diff --git a/resources/views/emails/undo-email-change-text.twig b/resources/views/emails/undo-email-change-text.twig new file mode 100644 index 0000000000..8bf398ca95 --- /dev/null +++ b/resources/views/emails/undo-email-change-text.twig @@ -0,0 +1,12 @@ +{% include 'emails.header-text' %} +You or somebody with access to your Firefly III account has changed your email address. If you did not expect this to happen, +you must follow the "undo"-link below to protect your account! + +If you initiated this change, you may safely ignore this message. + +The old email addres was: {{ oldEmail }} + +The new email address is: {{ newEmail }} + +To undo the change, follow this link: {{ uri }} +{% include 'emails.footer-text' %} diff --git a/resources/views/profile/change-email.twig b/resources/views/profile/change-email.twig new file mode 100644 index 0000000000..fe1d0aea3c --- /dev/null +++ b/resources/views/profile/change-email.twig @@ -0,0 +1,47 @@ +{% extends "./layout/default" %} + +{% block breadcrumbs %} + {{ Breadcrumbs.renderIfExists(Route.getCurrentRoute.getName) }} +{% endblock %} + +{% block content %} + +{% endblock %} diff --git a/resources/views/profile/index.twig b/resources/views/profile/index.twig index 75c08792fa..e64516b285 100644 --- a/resources/views/profile/index.twig +++ b/resources/views/profile/index.twig @@ -16,6 +16,7 @@ {{ trans('firefly.user_id_is',{user: userId})|raw }} diff --git a/routes/web.php b/routes/web.php index 121deb3a48..b1311e8f8a 100755 --- a/routes/web.php +++ b/routes/web.php @@ -32,6 +32,9 @@ Route::group( Route::post('password/reset', 'Auth\ResetPasswordController@reset'); Route::get('password/reset', 'Auth\ForgotPasswordController@showLinkRequestForm'); + // Change email routes: + Route::get('profile/confirm-email-change/{token}', ['uses' => 'ProfileController@confirmEmailChange', 'as' => 'profile.confirm-email-change']); + Route::get('profile/undo-email-change/{token}/{oldAddressHash}', ['uses' => 'ProfileController@undoEmailChange', 'as' => 'profile.undo-email-change']); } ); @@ -521,11 +524,13 @@ Route::group( ['middleware' => 'user-full-auth', 'prefix' => 'profile', 'as' => 'profile.'], function () { Route::get('', ['uses' => 'ProfileController@index', 'as' => 'index']); + Route::get('change-email', ['uses' => 'ProfileController@changeEmail', 'as' => 'change-email']); Route::get('change-password', ['uses' => 'ProfileController@changePassword', 'as' => 'change-password']); Route::get('delete-account', ['uses' => 'ProfileController@deleteAccount', 'as' => 'delete-account']); Route::post('delete-account', ['uses' => 'ProfileController@postDeleteAccount', 'as' => 'delete-account.post']); Route::post('change-password', ['uses' => 'ProfileController@postChangePassword', 'as' => 'change-password.post']); + Route::post('change-email', ['uses' => 'ProfileController@postChangeEmail', 'as' => 'change-email.post']); Route::post('regenerate', ['uses' => 'ProfileController@regenerate', 'as' => 'regenerate']); } );