diff --git a/1 b/1 new file mode 100644 index 00000000000..954385f3fed --- /dev/null +++ b/1 @@ -0,0 +1,308 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees; + +use Closure; +use Fisharebest\Webtrees\Contracts\UserInterface; +use PragmaRX\Google2FA\Google2FA; +use chillerlan\QRCode\QRCode; + +use function is_string; + +/** + * Provide an interface to the wt_user table. + */ +class User implements UserInterface +{ + private int $user_id; + + private string $user_name; + + private string $real_name; + + private string $email; + + /** @var array */ + private array $preferences; + + /** + * @param int $user_id + * @param string $user_name + * @param string $real_name + * @param string $email + */ + public function __construct(int $user_id, string $user_name, string $real_name, string $email) + { + $this->user_id = $user_id; + $this->user_name = $user_name; + $this->real_name = $real_name; + $this->email = $email; + + $this->preferences = DB::table('user_setting') + ->where('user_id', '=', $this->user_id) + ->pluck('setting_value', 'setting_name') + ->all(); + } + + /** + * The user‘s internal identifier. + * + * @return int + */ + public function id(): int + { + return $this->user_id; + } + + /** + * The users email address. + * + * @return string + */ + public function email(): string + { + return $this->email; + } + + /** + * Set the email address of this user. + * + * @param string $email + * + * @return User + */ + public function setEmail(string $email): User + { + if ($this->email !== $email) { + $this->email = $email; + + DB::table('user') + ->where('user_id', '=', $this->user_id) + ->update([ + 'email' => $email, + ]); + } + + return $this; + } + + /** + * The user‘s real name. + * + * @return string + */ + public function realName(): string + { + return $this->real_name; + } + /** + * Generate a QR code image based on 2FA secret and return both. + * + * @return array + */ + + public function genQRcode(): array + { + $qrinfo = array(); + $google2fa = new Google2FA(); + $qrinfo['secret'] = $google2fa->generateSecretKey(); + $data = 'otpauth://totp/' . (string)$this->user_id . '?secret=' . (string)$qrinfo['secret'] . '&issuer=' . (string)$_SERVER['SERVER_NAME']; + $qrinfo['qrcode'] = (new QRCode())->render($data); + return $qrinfo; + } + + /** + * Set the real name of this user. + * + * @param string $real_name + * + * @return User + */ + public function setRealName(string $real_name): User + { + if ($this->real_name !== $real_name) { + $this->real_name = $real_name; + + DB::table('user') + ->where('user_id', '=', $this->user_id) + ->update([ + 'real_name' => $real_name, + ]); + } + + return $this; + } + + /** + * The user‘s login name. + * + * @return string + */ + public function userName(): string + { + return $this->user_name; + } + + /** + * Set the login name for this user. + * + * @param string $user_name + * + * @return self + */ + public function setUserName(string $user_name): self + { + if ($this->user_name !== $user_name) { + $this->user_name = $user_name; + + DB::table('user') + ->where('user_id', '=', $this->user_id) + ->update([ + 'user_name' => $user_name, + ]); + } + + return $this; + } + + /** + * Fetch a user option/setting from the wt_user_setting table. + * Since we'll fetch several settings for each user, and since there aren't + * that many of them, fetch them all in one database query + * + * @param string $setting_name + * @param string $default + * + * @return string + */ + public function getPreference(string $setting_name, string $default = ''): string + { + return $this->preferences[$setting_name] ?? $default; + } + + /** + * Update a setting for the user. + * + * @param string $setting_name + * @param string $setting_value + * + * @return void + */ + public function setPreference(string $setting_name, string $setting_value): void + { + if ($this->getPreference($setting_name) !== $setting_value) { + DB::table('user_setting')->updateOrInsert([ + 'user_id' => $this->user_id, + 'setting_name' => $setting_name, + ], [ + 'setting_value' => $setting_value, + ]); + + $this->preferences[$setting_name] = $setting_value; + } + } + + /** + * Set the password of this user. + * + * @param string $password + * + * @return User + */ + public function setPassword(#[\SensitiveParameter] string $password): User + { + DB::table('user') + ->where('user_id', '=', $this->user_id) + ->update([ + 'password' => password_hash($password, PASSWORD_DEFAULT), + ]); + + return $this; + } + + /** + * Validate a supplied password + * + * @param string $password + * + * @return bool + */ + public function checkPassword(#[\SensitiveParameter] string $password): bool + { + $password_hash = DB::table('user') + ->where('user_id', '=', $this->id()) + ->value('password'); + + if (is_string($password_hash) && password_verify($password, $password_hash)) { + if (password_needs_rehash($password_hash, PASSWORD_DEFAULT)) { + $this->setPassword($password); + } + + return true; + } + + 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. + * + * @return Closure(object):User + */ + public static function rowMapper(): Closure + { + return static fn (object $row): User => new self((int) $row->user_id, $row->user_name, $row->real_name, $row->email); + } +} diff --git a/app/Contracts/UserInterface.php b/app/Contracts/UserInterface.php index 73aef7239fd..7ef8b9f29c7 100644 --- a/app/Contracts/UserInterface.php +++ b/app/Contracts/UserInterface.php @@ -32,6 +32,7 @@ interface UserInterface public const string PREF_IS_EMAIL_VERIFIED = 'verified'; public const string PREF_IS_VISIBLE_ONLINE = 'visibleonline'; public const string PREF_LANGUAGE = 'language'; + public const string PREF_IS_STATUS_MFA = 'statusmfa'; public const string PREF_NEW_ACCOUNT_COMMENT = 'comment'; public const string PREF_TIMESTAMP_REGISTERED = 'reg_timestamp'; public const string PREF_TIMESTAMP_ACTIVE = 'sessiontime'; @@ -72,7 +73,6 @@ public function email(): string; * @return string */ public function realName(): string; - /** * The user‘s login name. * diff --git a/app/Http/RequestHandlers/AccountEdit.php b/app/Http/RequestHandlers/AccountEdit.php index bb927841d43..3a067f5501d 100644 --- a/app/Http/RequestHandlers/AccountEdit.php +++ b/app/Http/RequestHandlers/AccountEdit.php @@ -28,6 +28,8 @@ use Fisharebest\Webtrees\Registry; use Fisharebest\Webtrees\Services\MessageService; use Fisharebest\Webtrees\Services\ModuleService; +use Fisharebest\Webtrees\Services\QrcodeService; +use Fisharebest\Webtrees\Site; use Fisharebest\Webtrees\Tree; use Fisharebest\Webtrees\Validator; use Psr\Http\Message\ResponseInterface; @@ -47,14 +49,18 @@ class AccountEdit implements RequestHandlerInterface private ModuleService $module_service; + private QrcodeService $qrcode_service; + /** * @param MessageService $message_service * @param ModuleService $module_service + * @param QrcodeService $qrcode_service */ - public function __construct(MessageService $message_service, ModuleService $module_service) + public function __construct(MessageService $message_service, ModuleService $module_service, QrcodeService $qrcode_service) { $this->message_service = $message_service; $this->module_service = $module_service; + $this->qrcode_service = $qrcode_service; } /** @@ -83,6 +89,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'); @@ -93,6 +100,8 @@ 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, + 'qrcode' => $this->qrcode_service->genQRcode($user), 'timezones' => $timezones, 'title' => $title, 'tree' => $tree, diff --git a/app/Http/RequestHandlers/AccountUpdate.php b/app/Http/RequestHandlers/AccountUpdate.php index ae73aaff771..aa835390bb2 100644 --- a/app/Http/RequestHandlers/AccountUpdate.php +++ b/app/Http/RequestHandlers/AccountUpdate.php @@ -67,15 +67,20 @@ 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) { @@ -99,6 +104,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'); diff --git a/app/Http/RequestHandlers/LoginAction.php b/app/Http/RequestHandlers/LoginAction.php index a320524c100..5bff3998526 100644 --- a/app/Http/RequestHandlers/LoginAction.php +++ b/app/Http/RequestHandlers/LoginAction.php @@ -32,6 +32,8 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +use Fisharebest\Webtrees\Site; +use Fisharebest\Webtrees\User as CoreUser; use function route; use function time; @@ -67,23 +69,52 @@ public function handle(ServerRequestInterface $request): ResponseInterface $tree = Validator::attributes($request)->treeOptional(); $default_url = route(HomePage::class); $username = Validator::parsedBody($request)->string('username'); - $password = Validator::parsedBody($request)->string('password'); + $password = Validator::parsedBody($request)->string('password', ''); + $loginstage = Validator::parsedBody($request)->string('loginstage', '1'); + $codemfa = Validator::parsedBody($request)->string('codemfa', ''); $url = Validator::parsedBody($request)->isLocalUrl()->string('url', $default_url); + $mfastatus = Validator::parsedBody($request)->string('mfastatus', '0'); + $mfasuccess = Validator::parsedBody($request)->string('mfasuccess', '0'); try { - $this->doLogin($username, $password); + $user = $this->user_service->findByIdentifier($username); + if ($user === null) { + Log::addAuthenticationLog('Login failed (no such user/email): ' . $username); + throw new Exception(I18N::translate('The username or password is incorrect.')); + } + if ($loginstage === "1") { + $mfastatus = $this->doLogin($username, $password, $user); + } else { + if ($mfastatus === "1") { + $mfasuccess = $this->doLoginMfa($username, $codemfa, $user); + } + } if (Auth::isAdmin() && $this->upgrade_service->isUpgradeAvailable()) { FlashMessages::addMessage(I18N::translate('A new version of webtrees is available.') . ' ' . I18N::translate('Upgrade to webtrees %s.', '' . $this->upgrade_service->latestVersion() . '') . ''); } - // Redirect to the target URL - return redirect($url); + # Show the mfa page + if ($mfastatus === "1" && $mfasuccess === "0") { + return redirect(route(LoginPageMfa::class, [ + 'tree' => $tree?->name(), + 'username' => $username, + 'url' => $url, + ])); + } else { + $this->completeLogin($username, $user); + // Redirect to the target URL + return redirect($url); + } } catch (Exception $ex) { // Failed to log in. FlashMessages::addMessage($ex->getMessage(), 'danger'); - - return redirect(route(LoginPage::class, [ + if ($loginstage === "2") { + $loginclass = LoginPageMfa::class; + } else { + $loginclass = LoginPage::class; + } + return redirect(route($loginclass, [ 'tree' => $tree?->name(), 'username' => $username, 'url' => $url, @@ -92,28 +123,22 @@ public function handle(ServerRequestInterface $request): ResponseInterface } /** - * Log in, if we can. Throw an exception, if we can't. + * Check basic log in details, if we can. Throw an exception, if we can't. * * @param string $username * @param string $password + * @param CoreUser $user * - * @return void + * @return string * @throws Exception */ - private function doLogin(string $username, #[\SensitiveParameter] string $password): void + private function doLogin(string $username, #[\SensitiveParameter] string $password, CoreUser $user): string { if ($_COOKIE === []) { Log::addAuthenticationLog('Login failed (no session cookies): ' . $username); throw new Exception(I18N::translate('You cannot sign in because your browser does not accept cookies.')); } - $user = $this->user_service->findByIdentifier($username); - - if ($user === null) { - Log::addAuthenticationLog('Login failed (no such user/email): ' . $username); - throw new Exception(I18N::translate('The username or password is incorrect.')); - } - if (!$user->checkPassword($password)) { Log::addAuthenticationLog('Login failed (incorrect password): ' . $username); throw new Exception(I18N::translate('The username or password is incorrect.')); @@ -128,7 +153,48 @@ 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) === "1" && (bool)Site::getPreference('SHOW_2FA_OPTION')) { + # MFA switched on for site and has been enabled by user + return "1"; + } else { + return "0"; + } + } + + /** + * Verify login with 2FA if user has enable this. Throw an exception, if we can't. + * + * @param string $username + * @param string $codemfa + * @param CoreUser $user + * + * @return string + * @throws Exception + */ + + private function doLoginMfa(string $username, string $codemfa, CoreUser $user): string + { + if ($codemfa !== '') { + if (!$user->checkMfaCode($codemfa)) { + throw new Exception(I18N::translate('Authentication code does not match. Please try again.')); + } + } else { + throw new Exception(I18N::translate('Authentication code must be entered as you have multi-factor authentication enabled. Please try again.')); + } + return "1"; + } + /** + * Complete login + * + * @param string $username + * @param CoreUser $user + * + * @return void + * @throws Exception + */ + private function completeLogin(string $username, CoreUser $user): void + { Auth::login($user); Log::addAuthenticationLog('Login: ' . Auth::user()->userName() . '/' . Auth::user()->realName()); Auth::user()->setPreference(UserInterface::PREF_TIMESTAMP_ACTIVE, (string) time()); diff --git a/app/Http/RequestHandlers/LoginPageMfa.php b/app/Http/RequestHandlers/LoginPageMfa.php new file mode 100644 index 00000000000..5a9414123b3 --- /dev/null +++ b/app/Http/RequestHandlers/LoginPageMfa.php @@ -0,0 +1,89 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\Http\RequestHandlers; + +use Fisharebest\Webtrees\Http\ViewResponseTrait; +use Fisharebest\Webtrees\I18N; +use Fisharebest\Webtrees\Services\TreeService; +use Fisharebest\Webtrees\Site; +use Fisharebest\Webtrees\Tree; +use Fisharebest\Webtrees\User; +use Fisharebest\Webtrees\Validator; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; + +/** + * Show a login form. + */ +class LoginPageMfa implements RequestHandlerInterface +{ + use ViewResponseTrait; + + private TreeService $tree_service; + + /** + * @param TreeService $tree_service + */ + public function __construct(TreeService $tree_service) + { + $this->tree_service = $tree_service; + } + + /** + * @param ServerRequestInterface $request + * + * @return ResponseInterface + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + $tree = Validator::attributes($request)->treeOptional(); + $user = Validator::attributes($request)->user(); + + // Already logged in? + if ($user instanceof User) { + return redirect(route(UserPage::class, ['tree' => $tree instanceof Tree ? $tree->name() : ''])); + } + + $url = Validator::queryParams($request)->isLocalUrl()->string('url', route(HomePage::class)); + $username = Validator::queryParams($request)->string('username', ''); + + // No tree? perhaps we came here from a page without one. + if ($tree === null) { + $default = Site::getPreference('DEFAULT_GEDCOM'); + $tree = $this->tree_service->all()->get($default) ?? $this->tree_service->all()->first(); + + if ($tree instanceof Tree) { + return redirect(route(self::class, ['tree' => $tree->name(), 'url' => $url])); + } + } + + $title = I18N::translate('Continue with MFA'); + $welcome = I18N::translate('Please enter your Google Authenticator code'); + + return $this->viewResponse('login-page-mfa', [ + 'title' => $title, + 'url' => $url, + 'tree' => $tree, + 'username' => $username, + 'welcome' => $welcome, + ]); + } +} diff --git a/app/Http/RequestHandlers/RegisterAction.php b/app/Http/RequestHandlers/RegisterAction.php index 1b409a377ee..a7ecc268a92 100644 --- a/app/Http/RequestHandlers/RegisterAction.php +++ b/app/Http/RequestHandlers/RegisterAction.php @@ -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'); @@ -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'); @@ -246,6 +248,7 @@ private function doValidateRegistration( string $username, string $email, string $realname, + string $secret, string $comments, #[\SensitiveParameter] string $password ): void { diff --git a/app/Http/RequestHandlers/SetupWizard.php b/app/Http/RequestHandlers/SetupWizard.php index cb5eabfdd64..9e5d621af6a 100644 --- a/app/Http/RequestHandlers/SetupWizard.php +++ b/app/Http/RequestHandlers/SetupWizard.php @@ -82,6 +82,7 @@ class SetupWizard implements RequestHandlerInterface 'wtuser' => '', 'wtpass' => '', 'wtemail' => '', + 'wtsecret' => '', ]; private const array DEFAULT_PORTS = [ @@ -326,6 +327,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']); } diff --git a/app/Http/RequestHandlers/SiteRegistrationAction.php b/app/Http/RequestHandlers/SiteRegistrationAction.php index 9543fefafe3..06f6fc72fd4 100644 --- a/app/Http/RequestHandlers/SiteRegistrationAction.php +++ b/app/Http/RequestHandlers/SiteRegistrationAction.php @@ -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'); diff --git a/app/Http/RequestHandlers/UserAddAction.php b/app/Http/RequestHandlers/UserAddAction.php index df4cf7dd597..f9f9c74a2db 100644 --- a/app/Http/RequestHandlers/UserAddAction.php +++ b/app/Http/RequestHandlers/UserAddAction.php @@ -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; diff --git a/app/Http/RequestHandlers/UserEditAction.php b/app/Http/RequestHandlers/UserEditAction.php index f36548097a0..87626b633c4 100644 --- a/app/Http/RequestHandlers/UserEditAction.php +++ b/app/Http/RequestHandlers/UserEditAction.php @@ -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'); @@ -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); diff --git a/app/Http/Routes/WebRoutes.php b/app/Http/Routes/WebRoutes.php index 480dd0e9b24..8f278984b4e 100644 --- a/app/Http/Routes/WebRoutes.php +++ b/app/Http/Routes/WebRoutes.php @@ -141,6 +141,7 @@ use Fisharebest\Webtrees\Http\RequestHandlers\LocationPage; use Fisharebest\Webtrees\Http\RequestHandlers\LoginAction; use Fisharebest\Webtrees\Http\RequestHandlers\LoginPage; +use Fisharebest\Webtrees\Http\RequestHandlers\LoginPageMfa; use Fisharebest\Webtrees\Http\RequestHandlers\Logout; use Fisharebest\Webtrees\Http\RequestHandlers\ManageMediaAction; use Fisharebest\Webtrees\Http\RequestHandlers\ManageMediaData; @@ -645,6 +646,7 @@ public function load(Map $router): void // Visitor routes - with an optional tree (for sites with no public trees). $router->attach('', '', static function (Map $router) { $router->get(LoginPage::class, '/login{/tree}'); + $router->get(LoginPageMfa::class, '/loginmfa{/tree}'); $router->post(LoginAction::class, '/login{/tree}'); $router->get(PasswordRequestPage::class, '/password-request{/tree}'); $router->post(PasswordRequestAction::class, '/password-request{/tree}'); diff --git a/app/Schema/Migration0.php b/app/Schema/Migration0.php index fc81373dc73..a7ae811cdfe 100644 --- a/app/Schema/Migration0.php +++ b/app/Schema/Migration0.php @@ -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'); diff --git a/app/Schema/SeedUserTable.php b/app/Schema/SeedUserTable.php index 4468be7b41c..d4dd5fe6f78 100644 --- a/app/Schema/SeedUserTable.php +++ b/app/Schema/SeedUserTable.php @@ -34,6 +34,7 @@ public function run(): void 'real_name' => 'DEFAULT_USER', 'email' => 'DEFAULT_USER', 'password' => 'DEFAULT_USER', + 'secret' => 'DEFAULT_USER', ]); }); } diff --git a/app/Services/QrcodeService.php b/app/Services/QrcodeService.php new file mode 100644 index 00000000000..a56e88fee39 --- /dev/null +++ b/app/Services/QrcodeService.php @@ -0,0 +1,50 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\Services; + +use PragmaRX\Google2FA\Google2FA; +use chillerlan\QRCode\QRCode; +use Fisharebest\Webtrees\Contracts\UserInterface; + +/** + * Generate a QR code and secret for user setting up multi-factor authentication. + */ +class QrcodeService +{ + /** + * Generate a QR code image based on 2FA secret and return both. + * + * @param UserInterface $user + * @return array + */ + + public function genQRcode(UserInterface $user): array + { + $qrinfo = array(); + $google2fa = new Google2FA(); + /** @var array{secret: string} $qrinfo */ + $qrinfo['secret'] = $google2fa->generateSecretKey(); + /** @var string $servername */ + $servername = $_SERVER['SERVER_NAME']; + $data = 'otpauth://totp/' . $user->id() . '?secret=' . $qrinfo['secret'] . '&issuer=' . $servername; + $qrinfo['qrcode'] = (new QRCode())->render($data); + return $qrinfo; + } +} diff --git a/app/Services/UserService.php b/app/Services/UserService.php index 466b6ab7d21..651a0de9dd6 100644 --- a/app/Services/UserService.php +++ b/app/Services/UserService.php @@ -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(); diff --git a/app/User.php b/app/User.php index 313533cc1e0..684b3fa0bec 100644 --- a/app/User.php +++ b/app/User.php @@ -21,6 +21,8 @@ use Closure; use Fisharebest\Webtrees\Contracts\UserInterface; +use PragmaRX\Google2FA\Google2FA; +use chillerlan\QRCode\QRCode; use function is_string; @@ -243,6 +245,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 $codemfa + * + * @return bool + */ + public function checkMfaCode(string $codemfa): bool + { + /** @var string $secret */ + $secret = DB::table('user') + ->where('user_id', '=', $this->id()) + ->value('secret'); + $google2fa = new Google2FA(); + if ((bool)$google2fa->verifyKey($secret, $codemfa)) { + return true; + } + return false; + } /** * A closure which will create an object from a database row. diff --git a/composer.json b/composer.json index 1c948e5f4b4..e65f82c0595 100644 --- a/composer.json +++ b/composer.json @@ -45,6 +45,7 @@ "ext-session": "*", "ext-xml": "*", "aura/router": "3.4.2", + "chillerlan/php-qrcode": "5.0.1", "ezyang/htmlpurifier": "4.18.0", "fig/http-message-util": "1.1.5", "fisharebest/algorithm": "1.6.0", @@ -63,6 +64,7 @@ "nesbot/carbon": "3.10.0", "nyholm/psr7": "1.8.2", "nyholm/psr7-server": "1.1.0", + "pragmarx/google2fa": "^8.0", "psr/cache": "3.0.0", "psr/http-message": "2.0", "psr/http-server-handler": "1.0.2", diff --git a/composer.lock b/composer.lock index 884d5aa7606..4d32ed7174d 100644 --- a/composer.lock +++ b/composer.lock @@ -188,6 +188,166 @@ ], "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "chillerlan/php-qrcode", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/chillerlan/php-qrcode.git", + "reference": "e81ed39ca3c94357aa021c7525fa9ffb451018d1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/chillerlan/php-qrcode/zipball/e81ed39ca3c94357aa021c7525fa9ffb451018d1", + "reference": "e81ed39ca3c94357aa021c7525fa9ffb451018d1", + "shasum": "" + }, + "require": { + "chillerlan/php-settings-container": "^2.1.4 || ^3.1", + "ext-mbstring": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "chillerlan/php-authenticator": "^4.1 || ^5.1", + "phan/phan": "^5.4", + "phpmd/phpmd": "^2.15", + "phpunit/phpunit": "^9.6", + "setasign/fpdf": "^1.8.2", + "squizlabs/php_codesniffer": "^3.8" + }, + "suggest": { + "chillerlan/php-authenticator": "Yet another Google authenticator! Also creates URIs for mobile apps.", + "setasign/fpdf": "Required to use the QR FPDF output.", + "simple-icons/simple-icons": "SVG icons that you can use to embed as logos in the QR Code" + }, + "type": "library", + "autoload": { + "psr-4": { + "chillerlan\\QRCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT", + "Apache-2.0" + ], + "authors": [ + { + "name": "Kazuhiko Arase", + "homepage": "https://github.com/kazuhikoarase/qrcode-generator" + }, + { + "name": "ZXing Authors", + "homepage": "https://github.com/zxing/zxing" + }, + { + "name": "Ashot Khanamiryan", + "homepage": "https://github.com/khanamiryan/php-qrcode-detector-decoder" + }, + { + "name": "Smiley", + "email": "smiley@chillerlan.net", + "homepage": "https://github.com/codemasher" + }, + { + "name": "Contributors", + "homepage": "https://github.com/chillerlan/php-qrcode/graphs/contributors" + } + ], + "description": "A QR code generator and reader with a user friendly API. PHP 7.4+", + "homepage": "https://github.com/chillerlan/php-qrcode", + "keywords": [ + "phpqrcode", + "qr", + "qr code", + "qr-reader", + "qrcode", + "qrcode-generator", + "qrcode-reader" + ], + "support": { + "docs": "https://php-qrcode.readthedocs.io", + "issues": "https://github.com/chillerlan/php-qrcode/issues", + "source": "https://github.com/chillerlan/php-qrcode" + }, + "funding": [ + { + "url": "https://www.paypal.com/donate?hosted_button_id=WLYUNAT9ZTJZ4", + "type": "custom" + }, + { + "url": "https://ko-fi.com/codemasher", + "type": "ko_fi" + } + ], + "time": "2024-01-07T19:37:03+00:00" + }, + { + "name": "chillerlan/php-settings-container", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/chillerlan/php-settings-container.git", + "reference": "95ed3e9676a1d47cab2e3174d19b43f5dbf52681" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/chillerlan/php-settings-container/zipball/95ed3e9676a1d47cab2e3174d19b43f5dbf52681", + "reference": "95ed3e9676a1d47cab2e3174d19b43f5dbf52681", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^8.1" + }, + "require-dev": { + "phpmd/phpmd": "^2.15", + "phpstan/phpstan": "^1.11", + "phpstan/phpstan-deprecation-rules": "^1.2", + "phpunit/phpunit": "^10.5", + "squizlabs/php_codesniffer": "^3.10" + }, + "type": "library", + "autoload": { + "psr-4": { + "chillerlan\\Settings\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Smiley", + "email": "smiley@chillerlan.net", + "homepage": "https://github.com/codemasher" + } + ], + "description": "A container class for immutable settings objects. Not a DI container.", + "homepage": "https://github.com/chillerlan/php-settings-container", + "keywords": [ + "Settings", + "configuration", + "container", + "helper" + ], + "support": { + "issues": "https://github.com/chillerlan/php-settings-container/issues", + "source": "https://github.com/chillerlan/php-settings-container" + }, + "funding": [ + { + "url": "https://www.paypal.com/donate?hosted_button_id=WLYUNAT9ZTJZ4", + "type": "custom" + }, + { + "url": "https://ko-fi.com/codemasher", + "type": "ko_fi" + } + ], + "time": "2024-07-16T11:13:48+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -2714,6 +2874,125 @@ ], "time": "2023-11-08T09:30:43+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "df1e7fde177501eee2037dd159cf04f5f301a512" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/df1e7fde177501eee2037dd159cf04f5f301a512", + "reference": "df1e7fde177501eee2037dd159cf04f5f301a512", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "phpunit/phpunit": "^9", + "vimeo/psalm": "^4|^5" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2024-05-08T12:36:18+00:00" + }, + { + "name": "pragmarx/google2fa", + "version": "v8.0.3", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa.git", + "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad", + "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^7.5.15|^8.5|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PragmaRX\\Google2FA\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "A One Time Password Authentication package, compatible with Google Authenticator.", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa/issues", + "source": "https://github.com/antonioribeiro/google2fa/tree/v8.0.3" + }, + "time": "2024-09-05T11:56:40+00:00" + }, { "name": "psr/cache", "version": "3.0.0", diff --git a/public/js/totp.js b/public/js/totp.js new file mode 100644 index 00000000000..37dbb869e2f --- /dev/null +++ b/public/js/totp.js @@ -0,0 +1,32 @@ +$( 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("  - (Click to generate new QR code) ") + } + +// 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("  - (Click to generate new QR code) ") + $('div#qrcode').show(); + $('input#secret').val($('input#newsecret').val()) + } + else { + $('div#qrcode').hide(); + $("a#getnewqr").hide(); + $('input#secret').val(''); + } + }); +}); + + diff --git a/resources/css/clouds.css b/resources/css/clouds.css old mode 100755 new mode 100644 diff --git a/resources/css/colors/ash.css b/resources/css/colors/ash.css old mode 100755 new mode 100644 diff --git a/resources/css/fab.css b/resources/css/fab.css old mode 100755 new mode 100644 diff --git a/resources/css/minimal.css b/resources/css/minimal.css old mode 100755 new mode 100644 diff --git a/resources/css/webtrees.css b/resources/css/webtrees.css old mode 100755 new mode 100644 diff --git a/resources/css/xenea.css b/resources/css/xenea.css old mode 100755 new mode 100644 diff --git a/resources/views/admin/site-registration.phtml b/resources/views/admin/site-registration.phtml index 930121f0ef7..b7f6e5c67ca 100644 --- a/resources/views/admin/site-registration.phtml +++ b/resources/views/admin/site-registration.phtml @@ -72,6 +72,19 @@ use Fisharebest\Webtrees\Site; + +
+ + + +
+ 'SHOW_2FA_OPTION', 'options' => [I18N::translate('no'), I18N::translate('yes')], 'selected' => (int) Site::getPreference('SHOW_2FA_OPTION')]) ?> +
+
+
+
+
- + +
+ + + +
+ I18N::translate('Enable or disable 2FA status'), 'name' => 'status-mfa', 'checked' => (bool) $user->getPreference(UserInterface::PREF_IS_STATUS_MFA)]) ?> +
+ +
+
+
+ +
+ + + + QR Code +
diff --git a/resources/views/layouts/default.phtml b/resources/views/layouts/default.phtml index c01114ee6ea..d87ba81da7d 100644 --- a/resources/views/layouts/default.phtml +++ b/resources/views/layouts/default.phtml @@ -142,6 +142,7 @@ use Psr\Http\Message\ServerRequestInterface; +