Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding 2FA for webtrees attempt #2 #5039

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/Contracts/UserInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ interface UserInterface
public const PREF_IS_ADMINISTRATOR = 'canadmin';
public const PREF_IS_EMAIL_VERIFIED = 'verified';
public const PREF_IS_VISIBLE_ONLINE = 'visibleonline';
public const PREF_IS_STATUS_MFA = 'statusmfa';
public const PREF_LANGUAGE = 'language';
public const PREF_NEW_ACCOUNT_COMMENT = 'comment';
public const PREF_TIMESTAMP_REGISTERED = 'reg_timestamp';
Expand Down
3 changes: 3 additions & 0 deletions app/Http/RequestHandlers/AccountEdit.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use Fisharebest\Webtrees\Registry;
use Fisharebest\Webtrees\Services\MessageService;
use Fisharebest\Webtrees\Services\ModuleService;
use Fisharebest\Webtrees\Site;
use Fisharebest\Webtrees\Tree;
use Fisharebest\Webtrees\Validator;
use Psr\Http\Message\ResponseInterface;
Expand Down Expand Up @@ -83,6 +84,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface
});

$show_delete_option = $user->getPreference(UserInterface::PREF_IS_ADMINISTRATOR) !== '1';
$show_2fa = Site::getPreference('SHOW_2FA_OPTION') === '1';
$timezone_ids = DateTimeZone::listIdentifiers();
$timezones = array_combine($timezone_ids, $timezone_ids);
$title = I18N::translate('My account');
Expand All @@ -93,6 +95,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface
'languages' => $languages->all(),
'my_individual_record' => $my_individual_record,
'show_delete_option' => $show_delete_option,
'show_2fa' => $show_2fa,
'timezones' => $timezones,
'title' => $title,
'tree' => $tree,
Expand Down
8 changes: 8 additions & 0 deletions app/Http/RequestHandlers/AccountUpdate.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,22 @@ public function handle(ServerRequestInterface $request): ResponseInterface
$language = Validator::parsedBody($request)->string('language');
$real_name = Validator::parsedBody($request)->string('real_name');
$password = Validator::parsedBody($request)->string('password');
$secret = Validator::parsedBody($request)->string('secret');
$time_zone = Validator::parsedBody($request)->string('timezone');
$user_name = Validator::parsedBody($request)->string('user_name');
$visible_online = Validator::parsedBody($request)->boolean('visible-online', false);
$status_mfa = Validator::parsedBody($request)->boolean('status-mfa', false);


// Change the password
if ($password !== '') {
$user->setPassword($password);
}

// Change the secret
if ($secret !== '' || $status_mfa === false) {
$user->setSecret($secret);
}
// Change the username
if ($user_name !== $user->userName()) {
if ($this->user_service->findByUserName($user_name) === null) {
Expand All @@ -99,6 +106,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface
$user->setPreference(UserInterface::PREF_LANGUAGE, $language);
$user->setPreference(UserInterface::PREF_TIME_ZONE, $time_zone);
$user->setPreference(UserInterface::PREF_IS_VISIBLE_ONLINE, (string) $visible_online);
$user->setPreference(UserInterface::PREF_IS_STATUS_MFA, (string) $status_mfa);

if ($tree instanceof Tree) {
$default_xref = Validator::parsedBody($request)->string('default-xref');
Expand Down
17 changes: 14 additions & 3 deletions app/Http/RequestHandlers/LoginAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,11 @@ public function handle(ServerRequestInterface $request): ResponseInterface
$default_url = route(HomePage::class);
$username = Validator::parsedBody($request)->string('username');
$password = Validator::parsedBody($request)->string('password');
$code2fa = Validator::parsedBody($request)->string('code2fa');
$url = Validator::parsedBody($request)->isLocalUrl()->string('url', $default_url);

try {
$this->doLogin($username, $password);
$this->doLogin($username, $password, $code2fa);

if (Auth::isAdmin() && $this->upgrade_service->isUpgradeAvailable()) {
FlashMessages::addMessage(I18N::translate('A new version of webtrees is available.') . ' <a class="alert-link" href="' . e(route(UpgradeWizardPage::class)) . '">' . I18N::translate('Upgrade to webtrees %s.', '<span dir="ltr">' . $this->upgrade_service->latestVersion() . '</span>') . '</a>');
Expand All @@ -97,11 +98,12 @@ public function handle(ServerRequestInterface $request): ResponseInterface
*
* @param string $username
* @param string $password
* @param string $code2fa
*
* @return void
* @throws Exception
*/
private function doLogin(string $username, #[\SensitiveParameter] string $password): void
private function doLogin(string $username, #[\SensitiveParameter] string $password, string $code2fa): void
{
if ($_COOKIE === []) {
Log::addAuthenticationLog('Login failed (no session cookies): ' . $username);
Expand Down Expand Up @@ -129,7 +131,16 @@ private function doLogin(string $username, #[\SensitiveParameter] string $passwo
Log::addAuthenticationLog('Login failed (not approved by admin): ' . $username);
throw new Exception(I18N::translate('This account has not been approved. Please wait for an administrator to approve it.'));
}

if ($user->getPreference(UserInterface::PREF_IS_STATUS_MFA) !== '') {
# covers scenario where 2fa not enabled by user
if ($code2fa != '') {
if (!$user->check2FAcode($code2fa)) {
throw new Exception(I18N::translate('2FA code does not match. Please try again.'));
}
} else {
throw new Exception(I18N::translate('2FA code must be entered as you have 2FA authentication enabled. Please try again.'));
}
}
Auth::login($user);
Log::addAuthenticationLog('Login: ' . Auth::user()->userName() . '/' . Auth::user()->realName());
Auth::user()->setPreference(UserInterface::PREF_TIMESTAMP_ACTIVE, (string) time());
Expand Down
7 changes: 5 additions & 2 deletions app/Http/RequestHandlers/RegisterAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,14 @@ public function handle(ServerRequestInterface $request): ResponseInterface
$password = Validator::parsedBody($request)->string('password');
$realname = Validator::parsedBody($request)->string('realname');
$username = Validator::parsedBody($request)->string('username');
$secret = Validator::parsedBody($request)->string('secret');

try {
if ($this->captcha_service->isRobot($request)) {
throw new Exception(I18N::translate('Please try again.'));
}

$this->doValidateRegistration($request, $username, $email, $realname, $comments, $password);
$this->doValidateRegistration($request, $username, $email, $realname, $comments, $password, $secret);

Session::forget('register_comments');
Session::forget('register_email');
Expand Down Expand Up @@ -135,6 +136,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface
$user->setPreference(UserInterface::PREF_CONTACT_METHOD, MessageService::CONTACT_METHOD_INTERNAL_AND_EMAIL);
$user->setPreference(UserInterface::PREF_NEW_ACCOUNT_COMMENT, $comments);
$user->setPreference(UserInterface::PREF_IS_VISIBLE_ONLINE, '1');
$user->setPreference(UserInterface::PREF_IS_STATUS_MFA, '0');
$user->setPreference(UserInterface::PREF_AUTO_ACCEPT_EDITS, '');
$user->setPreference(UserInterface::PREF_IS_ADMINISTRATOR, '');
$user->setPreference(UserInterface::PREF_TIMESTAMP_ACTIVE, '0');
Expand Down Expand Up @@ -247,7 +249,8 @@ private function doValidateRegistration(
string $email,
string $realname,
string $comments,
#[\SensitiveParameter] string $password
#[\SensitiveParameter] string $password,
string $secret
): void {
// All fields are required
if ($username === '' || $email === '' || $realname === '' || $comments === '' || $password === '') {
Expand Down
2 changes: 2 additions & 0 deletions app/Http/RequestHandlers/SetupWizard.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ class SetupWizard implements RequestHandlerInterface
'wtuser' => '',
'wtpass' => '',
'wtemail' => '',
'wtsecret' => '',
];

private const DEFAULT_PORTS = [
Expand Down Expand Up @@ -408,6 +409,7 @@ private function createConfigFile(array $data): void
$admin = $this->user_service->create($data['wtuser'], $data['wtname'], $data['wtemail'], $data['wtpass']);
$admin->setPreference(UserInterface::PREF_LANGUAGE, $data['lang']);
$admin->setPreference(UserInterface::PREF_IS_VISIBLE_ONLINE, '1');
$admin->setPreference(UserInterface::PREF_IS_STATUS_MFA, '0');
} else {
$admin->setPassword($_POST['wtpass']);
}
Expand Down
2 changes: 2 additions & 0 deletions app/Http/RequestHandlers/SiteRegistrationAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,13 @@ public function handle(ServerRequestInterface $request): ResponseInterface
$text = Validator::parsedBody($request)->string('WELCOME_TEXT_AUTH_MODE_4');
$allow_registration = Validator::parsedBody($request)->boolean('USE_REGISTRATION_MODULE');
$show_caution = Validator::parsedBody($request)->boolean('SHOW_REGISTER_CAUTION');
$show_2fa = Validator::parsedBody($request)->boolean('SHOW_2FA_OPTION');

Site::setPreference('WELCOME_TEXT_AUTH_MODE', $mode);
Site::setPreference('WELCOME_TEXT_AUTH_MODE_' . I18N::languageTag(), $text);
Site::setPreference('USE_REGISTRATION_MODULE', (string) $allow_registration);
Site::setPreference('SHOW_REGISTER_CAUTION', (string) $show_caution);
Site::setPreference('SHOW_2FA_OPTION', (string) $show_2fa);

FlashMessages::addMessage(I18N::translate('The website preferences have been updated.'), 'success');

Expand Down
1 change: 1 addition & 0 deletions app/Http/RequestHandlers/UserAddAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface
$real_name = Validator::parsedBody($request)->string('real_name');
$email = Validator::parsedBody($request)->string('email');
$password = Validator::parsedBody($request)->string('password');
$secret = "";

$errors = false;

Expand Down
2 changes: 2 additions & 0 deletions app/Http/RequestHandlers/UserEditAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface
$real_name = Validator::parsedBody($request)->string('real_name');
$email = Validator::parsedBody($request)->string('email');
$password = Validator::parsedBody($request)->string('password');
$secret = Validator::parsedBody($request)->string('secret');
$theme = Validator::parsedBody($request)->string('theme');
$language = Validator::parsedBody($request)->string('language');
$timezone = Validator::parsedBody($request)->string('timezone');
Expand All @@ -84,6 +85,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface
$canadmin = Validator::parsedBody($request)->boolean('canadmin', false);
$visible_online = Validator::parsedBody($request)->boolean('visible-online', false);
$verified = Validator::parsedBody($request)->boolean('verified', false);
$status_mfa = Validator::parsedBody($request)->boolean('status-mfa', false);
$approved = Validator::parsedBody($request)->boolean('approved', false);

$edit_user = $this->user_service->find($user_id);
Expand Down
1 change: 1 addition & 0 deletions app/Schema/Migration0.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ public function upgrade(): void
$table->string('real_name', 64);
$table->string('email', 64);
$table->string('password', 128);
$table->string('secret', 128);

$table->unique('user_name');
$table->unique('email');
Expand Down
1 change: 1 addition & 0 deletions app/Schema/SeedUserTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public function run(): void
'real_name' => 'DEFAULT_USER',
'email' => 'DEFAULT_USER',
'password' => 'DEFAULT_USER',
'secret' => 'DEFAULT_USER',
]);

if (DB::driverName() === DB::SQL_SERVER) {
Expand Down
1 change: 1 addition & 0 deletions app/Services/UserService.php
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ public function create(string $user_name, string $real_name, string $email, #[\S
'real_name' => $real_name,
'email' => $email,
'password' => password_hash($password, PASSWORD_DEFAULT),
'secret' => '',
]);

$user_id = DB::lastInsertId();
Expand Down
54 changes: 54 additions & 0 deletions app/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

use Closure;
use Fisharebest\Webtrees\Contracts\UserInterface;
use PragmaRX\Google2FA\Google2FA;
use chillerlan\QRCode\QRCode;

use function is_string;

Expand Down Expand Up @@ -110,6 +112,22 @@ public function realName(): string
{
return $this->real_name;
}
/**
* Generate a QR code image based on 2FA secret and return both.
*
* @return array<string, mixed>
*/

public function genQRcode(): array
{
$qrinfo = array();
$google2fa = new Google2FA();
$qrinfo['secret'] = $google2fa->generateSecretKey();
$data = 'otpauth://totp/' . $this->user_id . '?secret=' . $qrinfo['secret'] . '&issuer=' . $_SERVER['SERVER_NAME'];
$qrcode = new QRCode();
$qrinfo['qrcode'] = $qrcode->render($data);
return $qrinfo;
}

/**
* Set the real name of this user.
Expand Down Expand Up @@ -243,6 +261,42 @@ public function checkPassword(#[\SensitiveParameter] string $password): bool

return false;
}
/**
* Set the Secret of this user.
*
* @param string $secret
*
* @return User
*/
public function setSecret(#[\SensitiveParameter] string $secret): User
{
DB::table('user')
->where('user_id', '=', $this->user_id)
->update([
'secret' => $secret,
]);

return $this;
}

/**
* Validate a supplied 2fa code
*
* @param string $code2fa
*
* @return bool
*/
public function check2facode(string $code2fa): bool
{
$secret = DB::table('user')
->where('user_id', '=', $this->id())
->value('secret');
$google2fa = new Google2FA();
if ($google2fa->verifyKey($secret, $code2fa)) {
return true;
}
return false;
}

/**
* A closure which will create an object from a database row.
Expand Down
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"ext-session": "*",
"ext-xml": "*",
"aura/router": "3.3.0",
"chillerlan/php-qrcode": "4.4.0",
"ezyang/htmlpurifier": "4.17.0",
"fig/http-message-util": "1.1.5",
"fisharebest/algorithm": "1.6.0",
Expand All @@ -62,6 +63,7 @@
"nyholm/psr7": "1.8.2",
"nyholm/psr7-server": "1.1.0",
"oscarotero/middleland": "1.0.1",
"pragmarx/google2fa": "^8.0",
"psr/cache": "3.0.0",
"psr/http-message": "1.1",
"psr/http-server-handler": "1.0.2",
Expand Down
31 changes: 31 additions & 0 deletions public/js/totp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
$( document ).ready(function() {
// resize the qr code and hide as default in edit user page
$('div#qrcode').css('maxWidth', '300px');
$('div#qrcode').hide();

// show a link to get another qr code if 2fa enabled
if($('input#status-mfa-1').is(':checked')) {
$('input#status-mfa-1').parent().append("<span> <a href='#' id='getnewqr'>&nbsp;-&nbsp;(Click to generate new QR code)</a> </span>")
}

// click to get new qr code and secret
$("a#getnewqr").click(function(){
$('div#qrcode').show();
$("a#getnewqr").hide();
$('input#secret').val($('input#newsecret').val())
});
// deal with toggling of 2fa setting to ensure no secret saved if no 2fa required but the secret associated with any generated qr code is saved.
$('input#status-mfa-1').change(function() {
if(this.checked) {
$(this).parent().append("<span> <a href='#'>&nbsp;-&nbsp;(Click to generate new QR code)</a> </span>")
$('div#qrcode').show();
$('input#secret').val($('input#newsecret').val())
}
else {
$('div#qrcode').hide();
$("a#getnewqr").hide();
$('input#secret').val('');
}
});
});

15 changes: 15 additions & 0 deletions resources/views/admin/site-registration.phtml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,21 @@ use Fisharebest\Webtrees\Site;
</div>
</div>
</fieldset>

<!-- ALLOW USERS TO SET UP 2FA -->
<fieldset class="row mb-3">
<legend class="col-form-label col-sm-3">
<?= /* I18N: A configuration setting */ I18N::translate('Allow user
to turn on or off two-factor authentication from My Account page') ?>
</legend>
<div class="col-sm-9">
<?= view('components/radios-inline', ['name' => 'SHOW_2FA_OPTION', 'options' => [I18N::translate('no'), I18N::translate('yes')], 'selected' => (int) Site::getPreference('SHOW_2FA_OPTION')]) ?>
<div class="form-text">
</div>
</div>
</fieldset>



<div class="row mb-3">
<div class="offset-sm-3 col-sm-9">
Expand Down
1 change: 1 addition & 0 deletions resources/views/admin/users-edit.phtml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ use Illuminate\Support\Collection;

<form method="post" action="<?= e(route(UserEditAction::class)) ?>" class="form-horizontal" autocomplete="off">
<input type="hidden" name="user_id" value="<?= $user->id() ?>">
<input type="hidden" name="secret" value=$secret>

<!-- REAL NAME -->
<div class="row mb-3">
Expand Down
Loading
Loading