diff --git a/CHANGELOG.md b/CHANGELOG.md index 469f052..ec31cc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# v3.7.8 +## 04/16/2024 + +1. [](#improved) + * Use `random_bytes()` for password reset and activation, only fallback to `mt_rand()` if there's a generation error + * Added a new `site_host` field in the "Security" section to use in password reset and activation links sent in email. This allows you to avoid any "Password Reset Poisoning" attacks. + * Added a new warning in reset and activation emails that shows the "site host" clearly in order to avoid any nefariously sent emails. + # v3.7.7 ## 01/05/2024 diff --git a/README.md b/README.md index 291b49e..b5c63bc 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,8 @@ dynamic_page_visibility: false # Integrate access into page visibil parent_acl: false # Look to parent `access` rules for access requirements protect_protected_page_media: false # Take `access` rules into account when directly accessing a page's media +site_host: # Optionally used in password reset and activation emails, to avoid "password poisoning attacks", this should be the URL of your site including the protocol. e.g. https://foo.com + rememberme: enabled: true # Enable 'remember me' functionality timeout: 604800 # Timeout in seconds. Defaults to 1 week @@ -427,6 +429,10 @@ user_registration: send_welcome_email: false # Send a welcome email to the user (probably should not be used with `send_activation_email` ``` +## Email Security Considerations + +For increased security and to deter users from being tricked into resetting their passwords or activating their accounts on 'fake' sites utilizing a [Password Poisoning Attack](https://portswigger.net/web-security/host-header/exploiting/password-reset-poisoning), you can now set the `site_host` property in the "Security" tab of the login properties, (e.g. `https://foo.com`) to ensure the users are sent to the original site only. + ## Sending an activation email By default the registration process adds a new user, and sets it as enabled. diff --git a/blueprints.yaml b/blueprints.yaml index e0dae9f..d54921f 100644 --- a/blueprints.yaml +++ b/blueprints.yaml @@ -1,7 +1,7 @@ name: Login slug: login type: plugin -version: 3.7.7 +version: 3.7.8 testing: false description: Enables user authentication and login screen. icon: sign-in @@ -411,6 +411,12 @@ form: title: PLUGIN_LOGIN.SECURITY_TAB fields: + site_host: + type: text + size: medium + label: PLUGIN_LOGIN.SITE_HOST + help: PLUGIN_LOGIN.SITE_HOST_HELP + placeholder: "https://example.com" max_pw_resets_count: type: number size: x-small diff --git a/classes/Controller.php b/classes/Controller.php index ed006d7..76463e2 100644 --- a/classes/Controller.php +++ b/classes/Controller.php @@ -389,8 +389,14 @@ protected function taskForgot() return true; } - $token = md5(uniqid((string)mt_rand(), true)); - $expire = time() + 604800; // next week + try { + $random_bytes = random_bytes(16); + } catch (\Exception $e) { + $random_bytes = mt_rand(); + } + + $token = md5(uniqid($random_bytes, true)); + $expire = time() + 86400; // 24 hours $user->reset = $token . '::' . $expire; $user->save(); diff --git a/classes/Email.php b/classes/Email.php index 74d0a98..1822c21 100644 --- a/classes/Email.php +++ b/classes/Email.php @@ -5,7 +5,6 @@ use Grav\Common\Config\Config; use Grav\Common\Grav; use Grav\Common\Language\Language; -use Grav\Common\Page\Pages; use Grav\Common\User\Interfaces\UserInterface; use Grav\Common\Utils; use Grav\Plugin\Login\Invitations\Invitation; @@ -39,9 +38,12 @@ public static function sendActivationEmail(UserInterface $user, UserInterface $a throw new \RuntimeException('User activation route does not exist!'); } - /** @var Pages $pages */ - $pages = Grav::instance()['pages']; - $activationLink = $pages->url( + $site_host = $config->get('plugins.login.site_host'); + if (!empty($site_host)) { + $activationRoute = rtrim($site_host, '/') . '/' . ltrim($activationRoute, '/'); + } + + $activationLink = Utilis::url( $activationRoute . '/token' . $param_sep . $token . '/username' . $param_sep . $user->username, null, true @@ -89,11 +91,14 @@ public static function sendResetPasswordEmail(UserInterface $user, UserInterface throw new \RuntimeException('Password reset route does not exist!'); } - /** @var Pages $pages */ - $pages = Grav::instance()['pages']; - $resetLink = $pages->url( + $site_host = static::getConfig()->get('plugins.login.site_host'); + if (!empty($site_host)) { + $resetRoute = rtrim($site_host, '/') . '/' . ltrim($resetRoute, '/'); + } + + $resetLink = Utils::url( "{$resetRoute}/task{$param_sep}login.reset/token{$param_sep}{$token}/user{$param_sep}{$user->username}/nonce{$param_sep}" . Utils::getNonce('reset-form'), - null, + true, true ); @@ -190,9 +195,7 @@ public static function sendInvitationEmail(Invitation $invitation, string $messa throw new \RuntimeException('User registration route does not exist!'); } - /** @var Pages $pages */ - $pages = Grav::instance()['pages']; - $invitationLink = $pages->url("{$inviteRoute}/{$param_sep}{$invitation->token}", null, true); + $invitationLink = Utils::url("{$inviteRoute}/{$param_sep}{$invitation->token}", true, true); $context = [ 'invitation_link' => $invitationLink, @@ -218,11 +221,17 @@ protected static function sendEmail(string $template, array $context, array $par $config = static::getConfig(); + $site_host = $config->get('plugins.login.site_host'); + if (empty($site_host)) { + $site_host = Grav::instance()['uri']->host(); + } + // Twig context. $context += [ 'actor' => $actor, 'user' => $user, 'site_name' => $config->get('site.title', 'Website'), + 'site_host' => $site_host, 'author' => $config->get('site.author.name', ''), ]; diff --git a/classes/Login.php b/classes/Login.php index 36b982b..f31eea4 100755 --- a/classes/Login.php +++ b/classes/Login.php @@ -491,7 +491,13 @@ public function sendActivationEmail(UserInterface $user) throw new \RuntimeException($this->language->translate('PLUGIN_LOGIN.USER_NEEDS_EMAIL_FIELD')); } - $token = md5(uniqid(mt_rand(), true)); + try { + $random_bytes = random_bytes(16); + } catch (\Exception $e) { + $random_bytes = mt_rand(); + } + + $token = md5(uniqid($random_bytes, true)); $expire = time() + 604800; // next week $user->activation_token = $token . '::' . $expire; $user->save(); diff --git a/languages/en.yaml b/languages/en.yaml index c3eb1f1..187cef5 100644 --- a/languages/en.yaml +++ b/languages/en.yaml @@ -156,4 +156,7 @@ PLUGIN_LOGIN: INVITATION_EMAIL_MESSAGE: "We welcome you to register an account to on site." INVALID_INVITE_EMAILS: "Error: An invalid list of emails was provided" INVALID_FORM: "Error: Invalid form" - FAILED_TO_SEND_EMAILS: "Failed to send emails to: %s" \ No newline at end of file + FAILED_TO_SEND_EMAILS: "Failed to send emails to: %s" + HOST_WARNING: '