From 45fe2707005b19013acb75df90d23b791c88d8d8 Mon Sep 17 00:00:00 2001 From: Maxim Leshchenko Date: Fri, 6 May 2022 16:17:08 +0200 Subject: [PATCH] Users: Add ability to change email address in settings Closes #63 --- Email/change-email.eml.latte | 204 ++++++++++++++++++ .../Entities/EmailChangeVerification.php | 15 ++ Web/Models/Entities/User.php | 11 + .../Repositories/EmailChangeVerifications.php | 33 +++ Web/Presenters/UserPresenter.php | 80 ++++++- Web/Presenters/templates/User/Settings.xml | 34 ++- Web/routes.yml | 2 + install/sqls/00023-email-change.sql | 8 + locales/en.strings | 4 + locales/ru.strings | 4 + 10 files changed, 385 insertions(+), 10 deletions(-) create mode 100644 Email/change-email.eml.latte create mode 100644 Web/Models/Entities/EmailChangeVerification.php create mode 100644 Web/Models/Repositories/EmailChangeVerifications.php create mode 100644 install/sqls/00023-email-change.sql diff --git a/Email/change-email.eml.latte b/Email/change-email.eml.latte new file mode 100644 index 00000000..6cff8c11 --- /dev/null +++ b/Email/change-email.eml.latte @@ -0,0 +1,204 @@ + + + + + + Подтверждение изменения Email + + + + + + + + +
+
+ + + + +
+   +
+ + + + +
+ + + + +
+ + + + +
+ + + + +
+   +
+

Подтверждение изменения Email

+
+
+ + + + + +
+ + + + +
+
+ +
+ + + + + +
+   +
+ +
+ + + + + +
+   +
+ +

+ Здравствуйте, {$name}! Вы вероятно изменили свой адрес электронной почты в OpenVK. Чтобы изменение вступило в силу, необходимо подтвердить ваш новый Email. +

+ + + + + +
+   +
+ + + + + +
+ + + + +
+
+ Подтвердить Email! +
+
+
+ + + + + +
+   +
+ +

+ Если кнопка не работает, вы можете попробовать скопировать и вставить эту ссылку в адресную строку вашего веб-обозревателя: +

+ + + + + +
+ + http://{$_SERVER['HTTP_HOST']}/settings/change_email?key={$key} + +
+ +

+ Обратите внимание на то, что эту ссылку нельзя: +

+ +
    +
  • Передавать другим людям (даже друзьям, питомцам, соседам, любимым девушкам)
  • +
  • Использовать, если прошло более двух дней с её генерации
  • +
+ + + + + +
+

+ Ещё раз обратите внимание на то, что данную ссылку или письмо ни в коем случае нельзя передавать другим людям! Даже если они представляются службой поддержки.
+ Это письмо предназначено исключительно для одноразового, непосредственного использования владельцем аккаунта. +

+
+ + + + + +
+   +
+ +

+ С уважением, овк-тян. +

+ + + + + +
+   +
+ +
+ + + + + +
+   +
+ +

+ + Вы получили это письмо так как кто-то или вы изменили адрес электронной почты. Это не рассылка и от неё нельзя отписаться. Если вы всё равно хотите перестать получать подобные письма, деактивируйте ваш аккаунт. + +

+
+
+
+ + + + +
+   +
+
+
+ + diff --git a/Web/Models/Entities/EmailChangeVerification.php b/Web/Models/Entities/EmailChangeVerification.php new file mode 100644 index 00000000..e9b92db1 --- /dev/null +++ b/Web/Models/Entities/EmailChangeVerification.php @@ -0,0 +1,15 @@ +getRecord()->new_email; + } +} diff --git a/Web/Models/Entities/User.php b/Web/Models/Entities/User.php index 68ad6a77..ef22c700 100644 --- a/Web/Models/Entities/User.php +++ b/Web/Models/Entities/User.php @@ -877,6 +877,17 @@ class User extends RowModel return true; } + function setEmail(string $email): void + { + DatabaseConnection::i()->getContext()->table("ChandlerUsers") + ->where("id", $this->getChandlerUser()->getId())->update([ + "login" => $email + ]); + + $this->stateChanges("email", $email); + $this->save(); + } + function adminNotify(string $message): bool { $admId = OPENVK_ROOT_CONF["openvk"]["preferences"]["support"]["adminAccount"]; diff --git a/Web/Models/Repositories/EmailChangeVerifications.php b/Web/Models/Repositories/EmailChangeVerifications.php new file mode 100644 index 00000000..0e8c668b --- /dev/null +++ b/Web/Models/Repositories/EmailChangeVerifications.php @@ -0,0 +1,33 @@ +context = DatabaseConnection::i()->getContext(); + $this->verifications = $this->context->table("email_change_verifications"); + } + + function toEmailChangeVerification(?ActiveRow $ar): ?EmailChangeVerification + { + return is_null($ar) ? NULL : new EmailChangeVerification($ar); + } + + function getByToken(string $token): ?EmailChangeVerification + { + return $this->toEmailChangeVerification($this->verifications->where("key", $token)->fetch()); + } + + function getLatestByUser(User $user): ?EmailChangeVerification + { + return $this->toEmailChangeVerification($this->verifications->where("profile", $user->getId())->order("timestamp DESC")->fetch()); + } +} diff --git a/Web/Presenters/UserPresenter.php b/Web/Presenters/UserPresenter.php index b9e8b714..0f741654 100644 --- a/Web/Presenters/UserPresenter.php +++ b/Web/Presenters/UserPresenter.php @@ -9,12 +9,15 @@ use openvk\Web\Models\Repositories\Albums; use openvk\Web\Models\Repositories\Videos; use openvk\Web\Models\Repositories\Notes; use openvk\Web\Models\Repositories\Vouchers; +use openvk\Web\Models\Repositories\EmailChangeVerifications; use openvk\Web\Models\Exceptions\InvalidUserNameException; use openvk\Web\Util\Validator; use openvk\Web\Models\Entities\Notifications\{CoinsTransferNotification, RatingUpNotification}; +use openvk\Web\Models\Entities\EmailChangeVerification; use Chandler\Security\Authenticator; use lfkeitel\phptotp\{Base32, Totp}; use chillerlan\QRCode\{QRCode, QROptions}; +use Nette\Database\UniqueConstraintViolationException; final class UserPresenter extends OpenVKPresenter { @@ -132,7 +135,7 @@ final class UserPresenter extends OpenVKPresenter if(!$id) $this->notFound(); - + $user = $this->users->get($id); if($_SERVER["REQUEST_METHOD"] === "POST") { $this->willExecuteWriteAction($_GET['act'] === "status"); @@ -300,7 +303,7 @@ final class UserPresenter extends OpenVKPresenter if(!$id) $this->notFound(); - + if(in_array($this->queryParam("act"), ["finance", "finance.top-up"]) && !OPENVK_ROOT_CONF["openvk"]["preferences"]["commerce"]) $this->flashFail("err", tr("error"), tr("feature_disabled")); @@ -312,7 +315,7 @@ final class UserPresenter extends OpenVKPresenter if($this->postParam("old_pass") && $this->postParam("new_pass") && $this->postParam("repeat_pass")) { if($this->postParam("new_pass") === $this->postParam("repeat_pass")) { if($this->user->identity->is2faEnabled()) { - $code = $this->postParam("code"); + $code = $this->postParam("password_change_code"); if(!($code === (new Totp)->GenerateToken(Base32::decode($this->user->identity->get2faSecret())) || $this->user->identity->use2faBackupCode((int) $code))) $this->flashFail("err", tr("error"), tr("incorrect_2fa_code")); } @@ -323,6 +326,46 @@ final class UserPresenter extends OpenVKPresenter $this->flashFail("err", tr("error"), tr("error_new_password")); } } + + if($this->postParam("new_email")) { + if(!Validator::i()->emailValid($this->postParam("new_email"))) + $this->flashFail("err", tr("invalid_email_address"), tr("invalid_email_address_comment")); + + if(!Authenticator::verifyHash($this->postParam("email_change_pass"), $user->getChandlerUser()->getRaw()->passwordHash)) + $this->flashFail("err", tr("error"), tr("incorrect_password")); + + if($user->is2faEnabled()) { + $code = $this->postParam("email_change_code"); + if(!($code === (new Totp)->GenerateToken(Base32::decode($user->get2faSecret())) || $user->use2faBackupCode((int) $code))) + $this->flashFail("err", tr("error"), tr("incorrect_2fa_code")); + } + + if($this->postParam("new_email") !== $user->getEmail()) { + if (OPENVK_ROOT_CONF['openvk']['preferences']['security']['requireEmail']) { + $request = (new EmailChangeVerifications)->getLatestByUser($user); + if(!is_null($request) && $request->isNew()) + $this->flashFail("err", tr("forbidden"), tr("email_rate_limit_error")); + + $verification = new EmailChangeVerification; + $verification->setProfile($user->getId()); + $verification->setNew_Email($this->postParam("new_email")); + $verification->save(); + + $params = [ + "key" => $verification->getKey(), + "name" => $user->getCanonicalName(), + ]; + $this->sendmail($this->postParam("new_email"), "change-email", $params); #Vulnerability possible + $this->flashFail("succ", tr("information_-1"), tr("email_change_confirm_message")); + } + + try { + $user->setEmail($this->postParam("new_email")); + } catch(UniqueConstraintViolationException $ex) { + $this->flashFail("err", tr("error"), tr("user_already_exists")); + } + } + } if(!$user->setShortCode(empty($this->postParam("sc")) ? NULL : $this->postParam("sc"))) $this->flashFail("err", tr("error"), tr("error_shorturl_incorrect")); @@ -400,11 +443,7 @@ final class UserPresenter extends OpenVKPresenter throw $ex; } - $this->flash( - "succ", - "Изменения сохранены", - "Новые данные появятся на вашей странице." - ); + $this->flash("succ", tr("changes_saved"), tr("changes_saved_comment")); } $this->template->mode = in_array($this->queryParam("act"), [ "main", "privacy", "finance", "finance.top-up", "interface" @@ -502,6 +541,9 @@ final class UserPresenter extends OpenVKPresenter $this->assertUserLoggedIn(); $this->willExecuteWriteAction(); + if(!OPENVK_ROOT_CONF["openvk"]["preferences"]["commerce"]) + $this->flashFail("err", tr("error"), tr("feature_disabled")); + $receiverAddress = $this->postParam("receiver"); $value = (int) $this->postParam("value"); $message = $this->postParam("message"); @@ -517,7 +559,7 @@ final class UserPresenter extends OpenVKPresenter $receiver = $this->users->getByAddress($receiverAddress); if(!$receiver) - $this->flashFail("err", tr("failed_to_tranfer_points"), tr("receiver_not_found")); + $this->flashFail("err", tr("failed_to_tranfer_points"), tr("receiver_not_found")); if($this->user->identity->getCoins() < $value) $this->flashFail("err", tr("failed_to_tranfer_points"), tr("you_dont_have_enough_points")); @@ -574,4 +616,24 @@ final class UserPresenter extends OpenVKPresenter $this->flashFail("succ", tr("information_-1"), tr("rating_increase_successful", $receiver->getURL(), htmlentities($receiver->getCanonicalName()), $value)); } + + function renderEmailChangeFinish(): void + { + $request = (new EmailChangeVerifications)->getByToken(str_replace(" ", "+", $this->queryParam("key"))); + if(!$request || !$request->isStillValid()) { + $this->flash("err", tr("token_manipulation_error"), tr("token_manipulation_error_comment")); + $this->redirect("/settings"); + } else { + $request->delete(false); + + try { + $request->getUser()->setEmail($request->getNewEmail()); + } catch(UniqueConstraintViolationException $ex) { + $this->flashFail("err", tr("error"), tr("user_already_exists")); + } + + $this->flash("succ", tr("changes_saved"), tr("changes_saved_comment")); + $this->redirect("/settings"); + } + } } diff --git a/Web/Presenters/templates/User/Settings.xml b/Web/Presenters/templates/User/Settings.xml index e24c1535..03300a81 100644 --- a/Web/Presenters/templates/User/Settings.xml +++ b/Web/Presenters/templates/User/Settings.xml @@ -64,7 +64,7 @@ {_"2fa_code"} - + @@ -154,6 +154,38 @@ {$user->getEmail()} + + + {_new_email_address} + + + + + + + + {_password} + + + + + + + + {_"2fa_code"} + + + + + + + + + + + + +
diff --git a/Web/routes.yml b/Web/routes.yml index d89ce2d5..cf5aed85 100644 --- a/Web/routes.yml +++ b/Web/routes.yml @@ -73,6 +73,8 @@ routes: handler: "User->disableTwoFactorAuth" - url: "/settings/reset_theme" handler: "User->resetThemepack" + - url: "/settings/change_email" + handler: "User->emailChangeFinish" - url: "/coins_transfer" handler: "User->coinsTransfer" - url: "/increase_social_credits" diff --git a/install/sqls/00023-email-change.sql b/install/sqls/00023-email-change.sql new file mode 100644 index 00000000..70320fb2 --- /dev/null +++ b/install/sqls/00023-email-change.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS `email_change_verifications` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `profile` bigint(20) unsigned NOT NULL, + `key` char(64) COLLATE utf8mb4_unicode_520_ci NOT NULL, + `new_email` varchar(90) COLLATE utf8mb4_unicode_520_ci DEFAULT NULL, + `timestamp` bigint(20) unsigned NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; diff --git a/locales/en.strings b/locales/en.strings index 63fc7cce..398d14ef 100644 --- a/locales/en.strings +++ b/locales/en.strings @@ -444,6 +444,8 @@ "your_page_address" = "Your address page"; "page_address" = "Address page"; "current_email_address" = "Current email address"; +"new_email_address" = "New email address"; +"save_email_address" = "Save email address"; "page_id" = "Page ID"; "you_can_also" = "You can also"; "delete_your_page" = "delete your page"; @@ -458,6 +460,8 @@ "additional_links" = "Additional links"; "ad_poster" = "Ad poster"; +"email_change_confirm_message" = "Please confirm your new email address for the change to take effect. We have sent instructions to it."; + /* Two-factor authentication */ "two_factor_authentication" = "Two-factor authentication"; diff --git a/locales/ru.strings b/locales/ru.strings index f5fc8414..0e65c0eb 100644 --- a/locales/ru.strings +++ b/locales/ru.strings @@ -472,6 +472,8 @@ "your_page_address" = "Адрес Вашей страницы"; "page_address" = "Адрес страницы"; "current_email_address" = "Текущий адрес"; +"new_email_address" = "Новый адрес"; +"save_email_address" = "Сохранить адрес"; "page_id" = "ID страницы"; "you_can_also" = "Вы также можете"; "delete_your_page" = "удалить свою страницу"; @@ -486,6 +488,8 @@ "additional_links" = "Дополнительные ссылки"; "ad_poster" = "Рекламный плакат"; +"email_change_confirm_message" = "Чтобы изменение вступило в силу, подтвердите ваш новый адрес электронной почты. Мы отправили инструкции на него."; + /* Two-factor authentication */ "two_factor_authentication" = "Двухфакторная аутентификация";