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"} +
+ + +{/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 @@ +
+
+ {_two_factor_authentication_enabled}
+
+ |
+
+ {_view_backup_codes} + {_disable} + | +
+
+ {_two_factor_authentication_disabled}
+
+ |
+
+ {_connect} + | +
+ + | ++ {_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} + +
{_"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"} + +{/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" = "Сортировать рандомно"; |