From e433e83c5d47150953b80f728bec25b494426960 Mon Sep 17 00:00:00 2001 From: Maxim Leshchenko <50026114+maksalees@users.noreply.github.com> Date: Thu, 2 Dec 2021 17:31:32 +0200 Subject: [PATCH] Users: Add two-factor authentication (#321) This commit adds full TOTP 2FA. And even backup codes modeled on the original VK --- Web/Models/Entities/User.php | 42 ++++++++++ Web/Presenters/AuthPresenter.php | 33 +++++++- Web/Presenters/UserPresenter.php | 69 +++++++++++++++ Web/Presenters/VKAPIPresenter.php | 5 ++ .../Auth/FinishRestoringPassword.xml | 5 ++ .../templates/Auth/LoginSecondFactor.xml | 38 +++++++++ Web/Presenters/templates/User/Settings.xml | 83 +++++++++++++++++++ .../templates/User/TwoFactorAuthCodes.xml | 18 ++++ .../templates/User/TwoFactorAuthSettings.xml | 48 +++++++++++ Web/routes.yml | 4 + Web/static/css/style.css | 7 ++ composer.json | 4 +- install/sqls/00013-2fa.sql | 11 +++ locales/en.strings | 31 +++++++ locales/ru.strings | 29 +++++++ 15 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 Web/Presenters/templates/Auth/LoginSecondFactor.xml create mode 100644 Web/Presenters/templates/User/TwoFactorAuthCodes.xml create mode 100644 Web/Presenters/templates/User/TwoFactorAuthSettings.xml create mode 100644 install/sqls/00013-2fa.sql diff --git a/Web/Models/Entities/User.php b/Web/Models/Entities/User.php index 3790ae64..403e8930 100644 --- a/Web/Models/Entities/User.php +++ b/Web/Models/Entities/User.php @@ -327,6 +327,16 @@ class User extends RowModel return (int)floor((time() - $this->getBirthday()) / mktime(0, 0, 0, 1, 1, 1971)); } + function get2faSecret(): ?string + { + return $this->getRecord()["2fa_secret"]; + } + + function is2faEnabled(): bool + { + return !is_null($this->get2faSecret()); + } + function updateNotificationOffset(): void { $this->stateChanges("notification_offset", time()); @@ -552,6 +562,38 @@ class User extends RowModel { return sizeof($this->getRecord()->related("gift_user_relations.receiver")); } + + function get2faBackupCodes(): \Traversable + { + $sel = $this->getRecord()->related("2fa_backup_codes.owner"); + foreach($sel as $target) + yield $target->code; + } + + function get2faBackupCodeCount(): int + { + return sizeof($this->getRecord()->related("2fa_backup_codes.owner")); + } + + function generate2faBackupCodes(): void + { + $codes = []; + + for($i = 0; $i < 10 - $this->get2faBackupCodeCount(); $i++) { + $codes[] = [ + owner => $this->getId(), + code => random_int(10000000, 99999999) + ]; + } + + if(sizeof($codes) > 0) + DatabaseConnection::i()->getContext()->table("2fa_backup_codes")->insert($codes); + } + + function use2faBackupCode(int $code): bool + { + return (bool) $this->getRecord()->related("2fa_backup_codes.owner")->where("code", $code)->delete(); + } function getSubscriptionStatus(User $user): int { diff --git a/Web/Presenters/AuthPresenter.php b/Web/Presenters/AuthPresenter.php index 135b4c86..cc34f8d3 100644 --- a/Web/Presenters/AuthPresenter.php +++ b/Web/Presenters/AuthPresenter.php @@ -10,6 +10,7 @@ use Chandler\Session\Session; use Chandler\Security\User as ChandlerUser; use Chandler\Security\Authenticator; use Chandler\Database\DatabaseConnection; +use lfkeitel\phptotp\{Base32, Totp}; final class AuthPresenter extends OpenVKPresenter { @@ -132,9 +133,27 @@ final class AuthPresenter extends OpenVKPresenter if(!$user) $this->flashFail("err", "Не удалось войти", "Неверное имя пользователя или пароль. Забыли пароль?"); - if(!$this->authenticator->login($user->id, $this->postParam("password"))) + if(!$this->authenticator->verifyCredentials($user->id, $this->postParam("password"))) $this->flashFail("err", "Не удалось войти", "Неверное имя пользователя или пароль. Забыли пароль?"); + + $secret = $user->related("profiles.user")->fetch()["2fa_secret"]; + $code = $this->postParam("code"); + if(!is_null($secret)) { + $this->template->_template = "Auth/LoginSecondFactor.xml"; + $this->template->login = $this->postParam("login"); + $this->template->password = $this->postParam("password"); + + if(is_null($code)) + return; + + $ovkUser = new User($user->related("profiles.user")->fetch()); + if(!($code === (new Totp)->GenerateToken(Base32::decode($secret)) || $ovkUser->use2faBackupCode((int) $code))) { + $this->flash("err", "Не удалось войти", tr("incorrect_2fa_code")); + return; + } + } + $this->authenticator->authenticate($user->id); $this->redirect($redirUrl ?? "/id" . $user->related("profiles.user")->fetch()->id, static::REDIRECT_TEMPORARY); exit; } @@ -176,8 +195,20 @@ final class AuthPresenter extends OpenVKPresenter $this->flash("err", "Ошибка манипулирования токеном", "Токен недействителен или истёк"); $this->redirect("/"); } + + $this->template->is2faEnabled = $request->getUser()->is2faEnabled(); if($_SERVER["REQUEST_METHOD"] === "POST") { + if($request->getUser()->is2faEnabled()) { + $user = $request->getUser(); + $code = $this->postParam("code"); + $secret = $user->get2faSecret(); + if(!($code === (new Totp)->GenerateToken(Base32::decode($secret)) || $user->use2faBackupCode((int) $code))) { + $this->flash("err", tr("error"), tr("incorrect_2fa_code")); + return; + } + } + $user = $request->getUser()->getChandlerUser(); $this->db->table("ChandlerTokens")->where("user", $user->getId())->delete(); #Logout from everywhere diff --git a/Web/Presenters/UserPresenter.php b/Web/Presenters/UserPresenter.php index 4115d178..4eae74e5 100644 --- a/Web/Presenters/UserPresenter.php +++ b/Web/Presenters/UserPresenter.php @@ -9,6 +9,9 @@ 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 Chandler\Security\Authenticator; +use lfkeitel\phptotp\{Base32, Totp}; +use chillerlan\QRCode\QRCode; final class UserPresenter extends OpenVKPresenter { @@ -283,6 +286,12 @@ final class UserPresenter extends OpenVKPresenter if($_GET['act'] === "main" || $_GET['act'] == NULL) { 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"); + 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")); + } + if(!$this->user->identity->getChandlerUser()->updatePassword($this->postParam("new_pass"), $this->postParam("old_pass"))) $this->flashFail("err", tr("error"), tr("error_old_password")); } else { @@ -376,4 +385,64 @@ final class UserPresenter extends OpenVKPresenter $this->template->user = $user; $this->template->themes = Themepacks::i()->getThemeList(); } + + function renderTwoFactorAuthSettings(): void + { + $this->assertUserLoggedIn(); + + if($this->user->identity->is2faEnabled()) { + if($_SERVER["REQUEST_METHOD"] === "POST") { + if(!Authenticator::verifyHash($this->postParam("password"), $this->user->identity->getChandlerUser()->getRaw()->passwordHash)) + $this->flashFail("err", tr("error"), tr("incorrect_password")); + + $this->user->identity->generate2faBackupCodes(); + $this->template->_template = "User/TwoFactorAuthCodes.xml"; + $this->template->codes = $this->user->identity->get2faBackupCodes(); + return; + } + + $this->redirect("/settings"); + } + + $secret = Base32::encode(Totp::GenerateSecret(16)); + if($_SERVER["REQUEST_METHOD"] === "POST") { + $this->willExecuteWriteAction(); + + if(!Authenticator::verifyHash($this->postParam("password"), $this->user->identity->getChandlerUser()->getRaw()->passwordHash)) + $this->flashFail("err", tr("error"), tr("incorrect_password")); + + $secret = $this->postParam("secret"); + $code = $this->postParam("code"); + + if($code === (new Totp)->GenerateToken(Base32::decode($secret))) { + $this->user->identity->set2fa_secret($secret); + $this->user->identity->save(); + + $this->flash("succ", tr("two_factor_authentication_enabled"), tr("two_factor_authentication_enabled_description")); + $this->redirect("/settings"); + } + + $this->template->secret = $secret; + $this->flash("err", tr("error"), tr("incorrect_code")); + } else { + $this->template->secret = $secret; + } + + $issuer = OPENVK_ROOT_CONF["openvk"]["appearance"]["name"]; + $email = $this->user->identity->getEmail(); + $this->template->qrCode = substr((new QRCode)->render("otpauth://totp/$issuer:$email?secret=$secret&issuer=$issuer"), 22); + } + + function renderDisableTwoFactorAuth(): void + { + $this->assertUserLoggedIn(); + $this->willExecuteWriteAction(); + + if(!Authenticator::verifyHash($this->postParam("password"), $this->user->identity->getChandlerUser()->getRaw()->passwordHash)) + $this->flashFail("err", tr("error"), tr("incorrect_password")); + + $this->user->identity->set2fa_secret(NULL); + $this->user->identity->save(); + $this->flashFail("succ", tr("information_-1"), tr("two_factor_authentication_disabled_message")); + } } diff --git a/Web/Presenters/VKAPIPresenter.php b/Web/Presenters/VKAPIPresenter.php index 571549f9..2add61ea 100644 --- a/Web/Presenters/VKAPIPresenter.php +++ b/Web/Presenters/VKAPIPresenter.php @@ -5,6 +5,7 @@ use Chandler\Database\DatabaseConnection as DB; use openvk\VKAPI\Exceptions\APIErrorException; use openvk\Web\Models\Entities\{User, APIToken}; use openvk\Web\Models\Repositories\{Users, APITokens}; +use lfkeitel\phptotp\{Base32, Totp}; final class VKAPIPresenter extends OpenVKPresenter { @@ -160,6 +161,10 @@ final class VKAPIPresenter extends OpenVKPresenter $uId = $chUser->related("profiles.user")->fetch()->id; $user = (new Users)->get($uId); + + $code = $this->requestParam("code"); + if($user->is2faEnabled() && !($code === (new Totp)->GenerateToken(Base32::decode($user->get2faSecret())) || $user->use2faBackupCode((int) $code))) + $this->fail(28, "Invalid 2FA code", "internal", "acquireToken"); $token = new APIToken; $token->setUser($user); diff --git a/Web/Presenters/templates/Auth/FinishRestoringPassword.xml b/Web/Presenters/templates/Auth/FinishRestoringPassword.xml index b89ea9eb..90f14f72 100644 --- a/Web/Presenters/templates/Auth/FinishRestoringPassword.xml +++ b/Web/Presenters/templates/Auth/FinishRestoringPassword.xml @@ -17,6 +17,11 @@

+ {if $is2faEnabled} + + +

+ {/if} diff --git a/Web/Presenters/templates/Auth/LoginSecondFactor.xml b/Web/Presenters/templates/Auth/LoginSecondFactor.xml new file mode 100644 index 00000000..48462595 --- /dev/null +++ b/Web/Presenters/templates/Auth/LoginSecondFactor.xml @@ -0,0 +1,38 @@ +{extends "../@layout.xml"} +{block title}{_"log_in"}{/block} + +{block header} + {_"log_in"} +{/block} + +{block content} +

+ {_"two_factor_authentication_login"} +

+ +
+ + + + + + + + + + + +
+ {_code}: + + +
+ + + + + + +
+
+{/block} diff --git a/Web/Presenters/templates/User/Settings.xml b/Web/Presenters/templates/User/Settings.xml index 410cafc1..20698eed 100644 --- a/Web/Presenters/templates/User/Settings.xml +++ b/Web/Presenters/templates/User/Settings.xml @@ -59,6 +59,14 @@ + + + {_"2fa_code"} + + + + + @@ -71,6 +79,70 @@
+

{_two_factor_authentication}

+ + + {if $user->is2faEnabled()} + + + + + + + + + {else} + + + + + + + {/if} + +
+
+ {_two_factor_authentication_enabled} +
+
+ {_view_backup_codes} + {_disable} +
+
+ {_two_factor_authentication_disabled} +
+
+ {_connect} +
+

{_your_email_address}

@@ -392,6 +464,17 @@ {_my_videos} + + + +
+ + + {_my_audios} +
{_"my_settings"} » {_"two_factor_authentication"} +{/block} + +{block content} +

{_"backup_codes"}

+

{_"two_factor_authentication_backup_codes_1"}

+

{_"two_factor_authentication_backup_codes_2"|noescape}

+ +
    +
  1. {$code}
  2. +
+ +

{_"two_factor_authentication_backup_codes_3"}

+{/block} diff --git a/Web/Presenters/templates/User/TwoFactorAuthSettings.xml b/Web/Presenters/templates/User/TwoFactorAuthSettings.xml new file mode 100644 index 00000000..309c4ebe --- /dev/null +++ b/Web/Presenters/templates/User/TwoFactorAuthSettings.xml @@ -0,0 +1,48 @@ +{extends "../@layout.xml"} +{block title}{_"my_settings"} - {_"two_factor_authentication"}{/block} + +{block header} + {_"my_settings"} » {_"two_factor_authentication"} +{/block} + +{block content} + {_"two_factor_authentication_settings_1"|noescape} +

{_"two_factor_authentication_settings_2"}

+
+ +
+

{tr("two_factor_authentication_settings_3", $secret)|noescape}

+

{_"two_factor_authentication_settings_4"}

+
+ + + + + + + + + + + + + + + +
+ {_code}: + + +
+ {_password}: + + +
+ + + + + +
+
+{/block} diff --git a/Web/routes.yml b/Web/routes.yml index d86a96fb..b66dd873 100644 --- a/Web/routes.yml +++ b/Web/routes.yml @@ -51,6 +51,10 @@ routes: handler: "Auth->su" - url: "/settings" handler: "User->settings" + - url: "/settings/2fa" + handler: "User->twoFactorAuthSettings" + - url: "/settings/2fa/disable" + handler: "User->disableTwoFactorAuth" - url: "/id{num}" handler: "User->view" - url: "/friends{num}" diff --git a/Web/static/css/style.css b/Web/static/css/style.css index c9607787..c80861de 100644 --- a/Web/static/css/style.css +++ b/Web/static/css/style.css @@ -1575,3 +1575,10 @@ body.scrolled .toTop:hover { border-top: 3px solid rgb(130, 130, 130); font-weight: bold; } + +.accent-box { + background-color: white; + padding: 10px; + margin: 5px; + border: 1px solid #C0CAD5; +} diff --git a/composer.json b/composer.json index 345550c2..0e7c889d 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,9 @@ "netcarver/textile": "^3.7@dev", "al/emoji-detector": "dev-master", "ezyang/htmlpurifier": "dev-master", - "scssphp/scssphp": "dev-master" + "scssphp/scssphp": "dev-master", + "lfkeitel/phptotp": "dev-master", + "chillerlan/php-qrcode": "dev-main" }, "minimum-stability": "dev" } diff --git a/install/sqls/00013-2fa.sql b/install/sqls/00013-2fa.sql new file mode 100644 index 00000000..2a9409ec --- /dev/null +++ b/install/sqls/00013-2fa.sql @@ -0,0 +1,11 @@ +ALTER TABLE `profiles` ADD COLUMN `2fa_secret` VARCHAR(26); +ALTER TABLE `groups` ADD COLUMN `2fa_required` BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE TABLE IF NOT EXISTS `2fa_backup_codes` ( + `owner` bigint(20) unsigned NOT NULL, + `code` int unsigned NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +ALTER TABLE `2fa_backup_codes` + ADD KEY `code` (`code`,`owner`), + ADD KEY `FK_ownerToCode` (`owner`); diff --git a/locales/en.strings b/locales/en.strings index 0c729686..41a19d0a 100644 --- a/locales/en.strings +++ b/locales/en.strings @@ -357,6 +357,37 @@ "ui_settings_rating_show" = "Show"; "ui_settings_rating_hide" = "Hide"; +/* Two-factor authentication */ + +"two_factor_authentication" = "Two-factor authentication"; +"two_factor_authentication_disabled" = "Provides reliable protection against hacking: to enter the page, you must enter the code obtained in the 2FA application."; +"two_factor_authentication_enabled" = "Two-factor authentication is enabled. Your page is protected."; +"two_factor_authentication_login" = "You have two-factor authentication enabled. To login, enter the code received in the application."; + +"two_factor_authentication_settings_1" = "Two-factor authentication via TOTP can be used even without internet. To do this, you need a code generation app. For example, Google Authenticator for Android and iOS or FOSS Aegis and andOTP for Android."; +"two_factor_authentication_settings_2" = "Using the app for two-factor authentication, scan the QR code below:"; +"two_factor_authentication_settings_3" = "or manually enter the given secret key: $1."; +"two_factor_authentication_settings_4" = "Now enter the code that the application gave you and the password for your page so that we can confirm that you really are."; + +"connect" = "Connect"; +"enable" = "Enable"; +"disable" = "Disable"; +"code" = "Code"; +"2fa_code" = "2FA code"; + +"incorrect_password" = "Incorrect password"; +"incorrect_code" = "Incorrect code"; +"incorrect_2fa_code" = "Incorrect two-factor authentication code"; +"two_factor_authentication_enabled_message" = "Two-factor authentication enabled"; +"two_factor_authentication_enabled_message_description" = "Your page has become more difficult to hack. We recommend that you download backup codes"; +"two_factor_authentication_disabled_message" = "Two-factor authentication disabled"; + +"view_backup_codes" = "View backup codes"; +"backup_codes" = "Backup codes for login confirmation"; +"two_factor_authentication_backup_codes_1" = "Backup codes allow you to validate your login when you don't have access to your phone, for example, while traveling."; +"two_factor_authentication_backup_codes_2" = "You have 10 more codes, each code can only be used once. Print them out, put them away in a safe place and use them when you need codes to validate your login."; +"two_factor_authentication_backup_codes_3" = "You can get new codes if they run out. Only the last created backup codes are valid."; + /* Sorting */ "sort_randomly" = "Sort randomly"; diff --git a/locales/ru.strings b/locales/ru.strings index c593944f..16ddeefe 100644 --- a/locales/ru.strings +++ b/locales/ru.strings @@ -387,6 +387,35 @@ "ui_settings_rating_show" = "Показывать"; "ui_settings_rating_hide" = "Скрывать"; +"two_factor_authentication" = "Двухфакторная аутентификация"; +"two_factor_authentication_disabled" = "Обеспечивает надежную защиту от взлома: для входа на страницу необходимо ввести код, полученный в приложении 2FA."; +"two_factor_authentication_enabled" = "Двухфакторная аутентификация включена. Ваша страница защищена."; +"two_factor_authentication_login" = "У вас включена двухфакторная аутентификация. Для входа введите код полученный в приложении."; + +"two_factor_authentication_settings_1" = "Двухфакторную аутентификацию через TOTP можно использовать даже без интернета. Для этого вам понадобится приложение для генерации кодов. Например, Google Authenticator для Android и iOS или свободные Aegis и andOTP для Android."; +"two_factor_authentication_settings_2" = "Используя приложение для двухфакторной аутентификации, отсканируйте приведенный ниже QR-код:"; +"two_factor_authentication_settings_3" = "или вручную введите секретный ключ: $1."; +"two_factor_authentication_settings_4" = "Теперь введите код, который вам предоставило приложение, и пароль от вашей страницы, чтобы мы могли подтвердить, что вы действительно вы."; + +"connect" = "Подключить"; +"enable" = "Включить"; +"disable" = "Отключить"; +"code" = "Код"; +"2fa_code" = "Код 2FA"; + +"incorrect_password" = "Неверный пароль"; +"incorrect_code" = "Неверный код"; +"incorrect_2fa_code" = "Неверный код двухфакторной аутентификации"; +"two_factor_authentication_enabled_message" = "Двухфакторная аутентификация включена"; +"two_factor_authentication_enabled_message_description" = "Вашу страницу стало труднее взломать. Рекомендуем вам скачать резервные коды"; +"two_factor_authentication_disabled_message" = "Двухфакторная аутентификация отключена"; + +"view_backup_codes" = "Посмотреть резервные коды"; +"backup_codes" = "Резервные коды для подтверждения входа"; +"two_factor_authentication_backup_codes_1" = "Резервные коды позволяют подтверждать вход, когда у вас нет доступа к телефону, например, в путешествии."; +"two_factor_authentication_backup_codes_2" = "У вас есть ещё 10 кодов, каждым кодом можно воспользоваться только один раз. Распечатайте их, уберите в надежное место и используйте, когда потребуются коды для подтверждения входа."; +"two_factor_authentication_backup_codes_3" = "Вы можете получить новые коды, если они заканчиваются. Действительны только последние созданные резервные коды."; + /* Sorting */ "sort_randomly" = "Сортировать рандомно";