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 10 commits into
base: main
Choose a base branch
from
Open
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 string PREF_IS_ADMINISTRATOR = 'canadmin';
public const string PREF_IS_EMAIL_VERIFIED = 'verified';
public const string PREF_IS_VISIBLE_ONLINE = 'visibleonline';
public const string PREF_IS_STATUS_MFA = 'statusmfa';
public const string PREF_LANGUAGE = 'language';
public const string PREF_NEW_ACCOUNT_COMMENT = 'comment';
public const string 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
1 change: 1 addition & 0 deletions app/Http/RequestHandlers/RegisterAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,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
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 array 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/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
59 changes: 59 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,24 @@ 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();
$servername = $_SERVER['SERVER_NAME'];
settype($servername, "string");
$data = 'otpauth://totp/' . $this->user_id . '?secret=' . $qrinfo['secret'] . '&issuer=' . $servername;
$qrcode = new QRCode();
$qrinfo['qrcode'] = $qrcode->render($data);
return $qrinfo;
}

/**
* Set the real name of this user.
Expand Down Expand Up @@ -243,6 +263,45 @@ 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');
settype($secret, "string");
$google2fa = new Google2FA();
$googleverifystatus = $google2fa->verifyKey($secret, $code2fa);
settype($googleverifystatus, "bool");
if ($googleverifystatus) {
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.18.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
Loading