Users: Add two-factor authentication (#321)

This commit adds full TOTP 2FA. And even backup codes modeled on the original VK
This commit is contained in:
Maxim Leshchenko 2021-12-02 17:31:32 +02:00 committed by GitHub
parent 34f4f8fc5d
commit e433e83c5d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 425 additions and 2 deletions

View file

@ -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
{

View file

@ -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", "Не удалось войти", "Неверное имя пользователя или пароль. <a href='/restore.pl'>Забыли пароль?</a>");
if(!$this->authenticator->login($user->id, $this->postParam("password")))
if(!$this->authenticator->verifyCredentials($user->id, $this->postParam("password")))
$this->flashFail("err", "Не удалось войти", "Неверное имя пользователя или пароль. <a href='/restore.pl'>Забыли пароль?</a>");
$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

View file

@ -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"));
}
}

View file

@ -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);

View file

@ -17,6 +17,11 @@
<label for="password">Новый пароль: </label>
<input id="password" type="password" name="password" required />
<br/><br/>
{if $is2faEnabled}
<label for="code">Код двухфакторной аутентификации: </label>
<input id="code" type="text" name="code" required />
<br/><br/>
{/if}
<input type="hidden" name="hash" value="{$csrfToken}" />
<input type="submit" value="Сбросить пароль" class="button" style="float: right;" />

View file

@ -0,0 +1,38 @@
{extends "../@layout.xml"}
{block title}{_"log_in"}{/block}
{block header}
{_"log_in"}
{/block}
{block content}
<p>
{_"two_factor_authentication_login"}
</p>
<form method="POST" enctype="multipart/form-data">
<table cellspacing="7" cellpadding="0" width="40%" border="0" align="center">
<tbody>
<tr>
<td>
<span>{_code}: </span>
</td>
<td>
<input type="text" name="code" required />
</td>
</tr>
<tr>
<td>
</td>
<td>
<input type="hidden" name="login" value="{$login}" />
<input type="hidden" name="password" value="{$password}" />
<input type="hidden" name="hash" value="{$csrfToken}" />
<input type="submit" value="{_'log_in'}" class="button" />
</td>
</tr>
</tbody>
</table>
</form>
{/block}

View file

@ -59,6 +59,14 @@
<input type="password" name="repeat_pass" style="width: 100%;" />
</td>
</tr>
<tr n:if="$user->is2faEnabled()">
<td width="120" valign="top">
<span class="nobold">{_"2fa_code"}</span>
</td>
<td>
<input type="text" name="code" style="width: 100%;" />
</td>
</tr>
<tr>
<td>
@ -71,6 +79,70 @@
</tbody>
</table>
<br/>
<h4>{_two_factor_authentication}</h4>
<table cellspacing="7" cellpadding="0" width="60%" border="0" align="center">
<tbody>
{if $user->is2faEnabled()}
<tr>
<td>
<div class="accent-box">
{_two_factor_authentication_enabled}
</div>
</td>
</tr>
<tr>
<td style="text-align: center;">
<a class="button" href="javascript:viewBackupCodes()">{_view_backup_codes}</a>
<a class="button" href="javascript:disableTwoFactorAuth()">{_disable}</a>
</td>
</tr>
<script>
function viewBackupCodes() {
MessageBox("Просмотр резервных кодов", `
<form id="back-codes-view-form" method="post" action="/settings/2fa">
<label for="password">Пароль</label>
<input type="password" id="password" name="password" required />
<input type="hidden" name="hash" value={$csrfToken} />
</form>
`, ["Просмотреть", "Отменить"], [
() => {
document.querySelector("#back-codes-view-form").submit();
}, Function.noop
]);
}
function disableTwoFactorAuth() {
MessageBox("Отключить 2FA", `
<form id="two-factor-auth-disable-form" method="post" action="/settings/2fa/disable">
<label for="password">Пароль</label>
<input type="password" id="password" name="password" required />
<input type="hidden" name="hash" value={$csrfToken} />
</form>
`, ["Отключить", "Отменить"], [
() => {
document.querySelector("#two-factor-auth-disable-form").submit();
}, Function.noop
]);
}
</script>
{else}
<tr>
<td>
<div class="accent-box">
{_two_factor_authentication_disabled}
</div>
</td>
</tr>
<tr>
<td style="text-align: center;">
<a class="button" href="/settings/2fa">{_connect}</a>
</td>
</tr>
{/if}
</tbody>
</table>
<br/>
<h4>{_your_email_address}</h4>
<table cellspacing="7" cellpadding="0" width="60%" border="0" align="center">
<tbody>
@ -392,6 +464,17 @@
<span class="nobold">{_my_videos}</span>
</td>
</tr>
<tr>
<td width="120" valign="top" align="right">
<input
n:attr="checked => $user->getLeftMenuItemStatus('audios')"
type="checkbox"
name="menu_audioj" />
</td>
<td>
<span class="nobold">{_my_audios}</span>
</td>
</tr>
<tr>
<td width="120" valign="top" align="right">
<input

View file

@ -0,0 +1,18 @@
{extends "../@layout.xml"}
{block title}{_"my_settings"} - {_"two_factor_authentication"}{/block}
{block header}
<a href="/settings">{_"my_settings"}</a> » {_"two_factor_authentication"}
{/block}
{block content}
<h4>{_"backup_codes"}</h4>
<p>{_"two_factor_authentication_backup_codes_1"}</p>
<p>{_"two_factor_authentication_backup_codes_2"|noescape}</p>
<ol style="columns: 2; text-align: center;">
<li n:foreach="$codes as $code">{$code}</li>
</ol>
<p>{_"two_factor_authentication_backup_codes_3"}</p>
{/block}

View file

@ -0,0 +1,48 @@
{extends "../@layout.xml"}
{block title}{_"my_settings"} - {_"two_factor_authentication"}{/block}
{block header}
<a href="/settings">{_"my_settings"}</a> » {_"two_factor_authentication"}
{/block}
{block content}
{_"two_factor_authentication_settings_1"|noescape}
<p>{_"two_factor_authentication_settings_2"}</p>
<div style="text-align: center;">
<img src="data:image/png;base64,{$qrCode}">
</div>
<p>{tr("two_factor_authentication_settings_3", $secret)|noescape}</p>
<p>{_"two_factor_authentication_settings_4"}</p>
<form method="POST">
<table cellspacing="7" cellpadding="0" width="40%" border="0" align="center">
<tbody>
<tr>
<td>
<span>{_code}: </span>
</td>
<td>
<input type="text" name="code" required />
</td>
</tr>
<tr>
<td>
<span>{_password}: </span>
</td>
<td>
<input type="password" name="password" required />
</td>
</tr>
<tr>
<td>
</td>
<td>
<input type="hidden" name="secret" value="{$secret}" />
<input type="hidden" name="hash" value="{$csrfToken}" />
<input type="submit" value="{_enable}" class="button" />
</td>
</tr>
</tbody>
</table>
</form>
{/block}

View file

@ -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}"

View file

@ -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;
}

View file

@ -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"
}

View file

@ -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`);

View file

@ -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, <b>Google Authenticator</b> for Android and iOS or <b>FOSS Aegis and andOTP</b> 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: <b>$1</b>.";
"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 <a href='javascript:viewBackupCodes()'>backup codes</a>";
"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 <b>10 more codes</b>, 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";

View file

@ -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 можно использовать даже без интернета. Для этого вам понадобится приложение для генерации кодов. Например, <b>Google Authenticator</b> для Android и iOS или свободные <b>Aegis и andOTP</b> для Android.";
"two_factor_authentication_settings_2" = "Используя приложение для двухфакторной аутентификации, отсканируйте приведенный ниже QR-код:";
"two_factor_authentication_settings_3" = "или вручную введите секретный ключ: <b>$1</b>.";
"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" = "Вашу страницу стало труднее взломать. Рекомендуем вам скачать <a href='javascript:viewBackupCodes()'>резервные коды</a>";
"two_factor_authentication_disabled_message" = "Двухфакторная аутентификация отключена";
"view_backup_codes" = "Посмотреть резервные коды";
"backup_codes" = "Резервные коды для подтверждения входа";
"two_factor_authentication_backup_codes_1" = "Резервные коды позволяют подтверждать вход, когда у вас нет доступа к телефону, например, в путешествии.";
"two_factor_authentication_backup_codes_2" = "У вас есть ещё <b>10 кодов</b>, каждым кодом можно воспользоваться только один раз. Распечатайте их, уберите в надежное место и используйте, когда потребуются коды для подтверждения входа.";
"two_factor_authentication_backup_codes_3" = "Вы можете получить новые коды, если они заканчиваются. Действительны только последние созданные резервные коды.";
/* Sorting */
"sort_randomly" = "Сортировать рандомно";