commit 04c84ca83b29b219dbb77e63b10a688fe3ff6d77 Author: Abel Hoogeveen Date: Mon Feb 6 15:44:28 2023 +0100 Initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d6966d7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +.gitattributes export-ignore +.gitignore export-ignore +.drone.yml export-ignore +test/ export-ignore \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1517f7e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +composer.lock +composer.phar +.idea/ +build/ +test/temp/ +vendor/ +test/.phpunit.result.cache \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..aaa8f37 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (C) 2013 - 2023 i15 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100755 index 0000000..8cbd620 --- /dev/null +++ b/composer.json @@ -0,0 +1,33 @@ +{ + "name": "fuzeworks/authentication", + "license": ["MIT"], + "authors": [ + { + "name": "Abel Hoogeveen", + "email": "abel@i15.nl" + } + ], + "require": { + "php": ">=8.1.0", + "fuzeworks/core": "~1.3.0", + "fuzeworks/mvcr": "~1.3.0", + "fuzeworks/database": "~1.3.0", + "fuzeworks/objectstorage": "~1.3.0", + "fuzeworks/layout": "~1.3.0", + "fuzeworks/forms": "~1.3.0", + "fuzeworks/mailer-wrapper": "~1.3.0" + }, + "suggest": { + "fuzeworks/webcomponent": "Provides a web-based frontend for authentication" + }, + "require-dev": { + "phpunit/phpunit": "^9", + "fuzeworks/webcomponent": "~1.3.0", + "fuzeworks/tracycomponent": "~1.3.0" + }, + "autoload": { + "psr-4": { + "FuzeWorks\\Authentication\\": "src/FuzeWorks/Authentication/" + } + } +} \ No newline at end of file diff --git a/config.authentication.php b/config.authentication.php new file mode 100755 index 0000000..5aba245 --- /dev/null +++ b/config.authentication.php @@ -0,0 +1,58 @@ + true, + 'auth_url' => "auth", + + // Whether registration is enabled on this website + 'register_enabled' => true, + + // Whether the user should be able to manually change their passwords + 'forgot_password_enabled' => true, + + // The database group to use for authentication + 'database_group' => 'default', + + // Password settings + 'password_min_length' => 8, + 'password_min_score' => 3, + 'password_algorithm' => PASSWORD_DEFAULT, + 'password_options' => [], + + // Email settings + 'verifyEmailWithin' => 3600*24*3 +]; \ No newline at end of file diff --git a/controller/controller.authentication.php b/controller/controller.authentication.php new file mode 100755 index 0000000..c9fdcb1 --- /dev/null +++ b/controller/controller.authentication.php @@ -0,0 +1,485 @@ +plugin = $this->plugins->get('auth'); + $this->session = $this->plugin->sessions->start(); + $this->authConfig = $this->plugin->config; + } + + /** + * Attempt a login using a designated identifier and password. + * + * Sessions are automatically extended as they approach their end. Sessions with remember = false will last an hour. + * Sessions with remember = true will last 3 months. + * + * Context data will be saved into the database for security purposes. + * + * Login may be prevented by the following causes: + * - Username and password mismatch, or password is not set. + * - The user is already currently logged in the current session. + * - The user doesn't exist. + * - User is set to inactive. + * - Email verification threshold has expired and the user must verify before being allowed to log in. + * - The event system cancels login for other reasons. + * + * @param string $identifier + * @param string $password + * @param bool $remember + * @param array $context + * @return Session + * @throws LoginErrorException + * @throws LoginWarningException + */ + public function login(string $identifier, string $password, bool $remember = false, array $context = []): Session + { + /** @var LoginEvent $event */ + $event = Events::fireEvent(new LoginEvent($identifier, $password, $remember, $context)); + if ($event->isCancelled()) + throw new LoginErrorException("Login cancelled by system."); + + // Fetch the user + $user = $this->plugin->users->getUserByEmail($event->identifier); + foreach ($event->getIdentifierFields() as $field) + { + if (empty($user)) + $user = $this->plugin->users->getUsersByProperty($field, $event->identifier); + } + + // If user not found, notify + if (empty($user)) + throw new LoginWarningException("The provided username and/or password is invalid."); + + // Verify that the current session is not the same as the supposed login session + if ($this->session->user->id === $user->id) + throw new LoginWarningException("User is already logged in."); + + // Check if the password is correct + if (!password_verify($event->password, $user->password)) + throw new LoginWarningException("The provided username and/or password is invalid."); + + // Check if the password needs to be updated + if (password_needs_rehash($user->password, + $this->plugin->config->get("password_algorithm"), + $this->plugin->config->get("password_options"))) + { + $this->plugin->users->changePassword($user, $event->password); + } + + // Check if the user is still active + if (!$user->active) + throw new LoginErrorException("User is inactive. Login blocked."); + + // Check if email has been verified on time + if (!is_null($user->emailVerifyToken) && date('U') > $user->emailVerifyExpiry) + { + $url = $this->plugin->getAuthenticationURL() . "/resend_verify_email?t=" . base64_encode($user->primaryEmail); + throw new LoginWarningException("User must verify email before logging in. Click here to resend the verification email."); + } + + // Create a session, log and return it + $this->session = $this->plugin->sessions->createSession($user, $event->context, $event->remember); + Logger::log("Logged in user '" . $user->primaryEmail . "'"); + return $this->session; + } + + /** + * Log a user out of the current session. + * + * Will set the session active to false and remove the session cookie. + * + * @return bool + */ + public function logout(): bool + { + if ($this->session->user->id == "0") + return false; + + return $this->plugin->sessions->endSession($this->session); + } + + /** + * Performs a registration of a new user using the provided data. + * + * The only absolutely required parameter is email. No user can exist without a valid email address. + * + * If password is null (for instance by creating the user from a panel instead of registration), the user shall be asked to set a password after verifying their email. + * + * Parameters should be an empty array, unless the developer wants to explicitly set internal values like emailExpiryTime. + * + * Properties can be any extra key => value data the developer wants to store about their users, such as usernames or social data. + * + * Use $bypassRestrictions in case registration is disabled by config, but a new user must be added anyway. + * + * Registration can be denied for the following reasons: + * - Registration is disabled by config and $bypassRestrictions is not set to true. + * - User already exists. + * - The event system cancels registration of another reason. + * + * @param string $email + * @param string|null $password + * @param array $parameters + * @param array $properties + * @param bool $bypassRestrictions + * @return bool + * @throws RegisterErrorException + * @throws RegisterWarningException + */ + public function register(string $email, ?string $password, array $parameters, array $properties, bool $bypassRestrictions = false): bool + { + // First check if registration is enabled + if (!$this->authConfig->get("register_enabled") && !$bypassRestrictions) + throw new RegisterErrorException("Registration is disabled."); + + /** @var RegisterEvent $event */ + $event = Events::fireEvent(new RegisterEvent($email, $password, $parameters, $properties)); + if ($event->isCancelled()) + { + $error = is_null($event->getError()) ? "Register cancelled by system." : $event->getError(); + throw new RegisterErrorException($error); + } + + // Create the user + try { + if (!$this->plugin->users->addUser($event->email, $event->password, true, $event->properties)) + throw new RegisterErrorException("Could not create user. System error."); + + // Fetch the user + $user = $this->plugin->users->getUserByEmail($event->email); + $this->sendVerifyEmail($user); + } catch (InputException|AuthenticationException $e) { + throw new RegisterErrorException($e->getMessage()); + } catch (Exception $e) { + throw new RegisterErrorException("Could not add user. Failed to send mail to user: " . $e->getMessage()); + } catch (FactoryException|LayoutException $e) { + throw new RegisterErrorException("Could not add user. System failure."); + } + + return true; + } + + /** + * Sends a new email verification message to the user, in case the existing message is lost or expired. + * + * @param string $email + * @return bool + * @throws LoginErrorException + * @throws RegisterErrorException + */ + public function resendVerifyEmail(string $email): bool + { + $user = $this->plugin->users->getUserByEmail($email); + if (is_null($user)) + throw new LoginErrorException("User not found."); + + try { + $this->sendVerifyEmail($user); + } catch (FactoryException|LayoutException $e) { + throw new LoginErrorException("Could not resend verify email. System failure."); + } catch (Exception $e) { + throw new RegisterErrorException("Could not resend verify email. Failed to send mail to user: " . $e->getMessage()); + } + + return true; + } + + /** + * Sends a email verification message to the user. + * + * @param User $user + * @return void + */ + private function sendVerifyEmail(User $user): void + { + // Load template engine + /** @var Layout $layout */ + $layout = Factory::getInstance("layouts"); + $layout->assign("user", $user); + $layout->assign("verifyURL", $this->plugin->getAuthenticationURL() . "/verify"); + + $html = $layout->get("mail/register"); + $alt = $layout->get("mail/register_alt"); + + // Prepare mailer + /** @var PHPMailerWrapper $mailer */ + $mailer = $this->libraries->get("mailer"); + $mailer->addAddress($user->primaryEmail); + $serverName = $this->config->getConfig("web")->get("serverName"); + $mailer->Subject = "Welcome to $serverName !"; + $mailer->isHTML(); + $mailer->Body = $html; + $mailer->AltBody = $alt; + + // And send mail + $mailer->send(); + } + + /** + * Verifies a user by their email token. + * + * @param string $token + * @return User|null + */ + public function verifyEmail(string $token): ?User + { + // Fetch user + $users = $this->plugin->users->getUsersByVariable("emailVerifyToken", $token); + if (count($users) === 1) + { + // Select user + /** @var User $user */ + $user = $users[0]; + $user->emailVerifyToken = null; + $user->emailVerifyExpiry = null; + + // Update the user + $this->plugin->users->updateUser($user); + + // And return true + return $user; + } + + return null; + } + + /** + * Sends a forgot password email to the user, based on the email address of the user. + * + * Will be denied if: + * - Password resets are disabled by config. + * - The user doesn't exist. + * - The user is marked as inactive. + * + * @param string $email + * @return bool + * @throws FactoryException + * @throws LoginErrorException + * @throws LoginWarningException + */ + public function forgotPassword(string $email): bool + { + // Check if forgot password is enabled + if (!$this->authConfig->get("forgot_password_enabled")) + throw new LoginErrorException("Forgot password is disabled."); + + // Fetch the user + $user = $this->plugin->users->getUserByEmail($email); + + // If user not found, notify + if (empty($user)) + throw new LoginWarningException("The provided email is unknown."); + + // Check if the user is still active + if (!$user->active) + throw new LoginErrorException("The provided email is unknown"); + + // Generate reset token + $token = bin2hex(random_bytes(32)); + + // Save the token and user ID to ObjectStorage + /** @var CacheInterface $cache */ + $cache = Factory::getInstance("storage")->getCache(); + $cache->set("forgot_password_" . $token, $user->id, 3600); + + // And send mail + try { + // Data is verified. Now send the email. + /** @var Layout $layout */ + $layout = Factory::getInstance("layouts"); + $layout->assign("resetURL", $this->plugin->getAuthenticationURL() . "/reset_password"); + $layout->assign("token", $token); + $html = $layout->get("mail/forgot_password"); + $alt = $layout->get("mail/forgot_password_alt"); + + // Prepare mailer + /** @var PHPMailerWrapper $mailer */ + $mailer = $this->libraries->get("mailer"); + $mailer->addAddress($email); + $mailer->Subject = "Password reset"; + $mailer->isHTML(); + $mailer->Body = $html; + $mailer->AltBody = $alt; + + $mailer->send(); + } catch (Exception $e) { + throw new LoginErrorException("Could not send mail to user: " . $e->getMessage()); + } catch (FactoryException|LayoutException $e) { + throw new LoginErrorException("Could not send mail to user. System failure."); + } + + return true; + } + + /** + * Method used by AdminView to generate a token to reset password with, after verifying email. + * + * @internal + * @param User $user + * @return string + */ + public function createResetToken(User $user): string + { + // Generate reset token + $token = bin2hex(random_bytes(32)); + + // Save the token and user ID to ObjectStorage + /** @var CacheInterface $cache */ + $cache = Factory::getInstance("storage")->getCache(); + $cache->set("forgot_password_" . $token, $user->id, 3600); + + return $token; + } + + /** + * Reset the password of the user with the selected token + * + * @param string $token + * @param string $password + * @return bool + * @throws LoginErrorException + */ + public function resetPassword(string $token, string $password): bool + { + // Check token in cache + /** @var CacheInterface $cache */ + $cache = Factory::getInstance("storage")->getCache(); + if (!$cache->has("forgot_password_" . $token)) + throw new LoginErrorException("User was not found."); + + // Fetch user + $user = $this->plugin->users->getUserByUid($cache->get("forgot_password_" . $token)); + + // Modify the user + try { + if ($this->plugin->users->changePassword($user, $password)) + $cache->delete("forgot_password_" . $token); + + return true; + } catch (InputException $e) { + throw new LoginErrorException($e->getMessage()); + } + } + + /** + * Returns an array of detailed user information. + * + * Returns ['user' => User, 'sessions' => Session[]] , or null if not found. + * + * @param string $userId + * @return array|null + */ + public function getDetailedUserInformation(string $userId): ?array + { + $user = $this->plugin->users->getUserByUid($userId); + if (is_null($user)) + return null; + + $sessions = $this->plugin->sessions->getSessions(['user' => $userId]); + return ['user' => $user, 'sessions' => $sessions]; + } + + /** + * Gives a list of users that match the filter. + * + * @param array $filter + * @param int $index + * @param int $limit + * @return array + */ + public function listUsers(array $filter = [], int $index = 0, int $limit = 25): array + { + return $this->plugin->users->getUsers($index, $limit); + } + + /** + * Condition callable for register form, to check if password 1 and 2 are equivalent. + * + * @internal + * @param Form $form + * @return bool + */ + public static function registerFormCondition(Form $form): bool + { + // Verify password1 and password2 equivalency + $pass1 = $form->getField("password1"); + $pass2 = $form->getField("password2"); + if ($pass1->getValue() !== $pass2->getValue()) + { + $pass1->invalidate(); + $pass2->invalidate(); + $pass1->addError("Passwords do not match"); + $pass2->addError("Passwords do not match"); + return false; + } + + return true; + } + +} \ No newline at end of file diff --git a/layout/admin/layout.users_create.latte b/layout/admin/layout.users_create.latte new file mode 100644 index 0000000..4086ca6 --- /dev/null +++ b/layout/admin/layout.users_create.latte @@ -0,0 +1,5 @@ +
+
+ {$form|noescape} +
+
\ No newline at end of file diff --git a/layout/admin/layout.users_list.latte b/layout/admin/layout.users_list.latte new file mode 100644 index 0000000..77d0921 --- /dev/null +++ b/layout/admin/layout.users_list.latte @@ -0,0 +1,65 @@ +{varType FuzeWorks\Authentication\Model\User[] $users} +
+
+
+
+
+

List of registered users on this system

+ Create User +
+ +
+ + + + + + + + + + + + {foreach $users as $user} + + + + + + + + {/foreach} + + + + + + + + + + + +
EmailPermissionsEmail verified2FAActive
{$user->primaryEmail}{$user->permissions|implode:", "}{is_null($user->emailVerifyToken) ? "Yes" : "No"}{is_null($user->mfaSecret) ? "No" : "Yes"}{$user->active ? "Yes" : "No"}
EmailPermissionsEmail verified2FAActive
+
+ +
+ +
+ +
+ +
+ \ No newline at end of file diff --git a/layout/bridge/layout.authpanel.php b/layout/bridge/layout.authpanel.php new file mode 100644 index 0000000..94c4d9c --- /dev/null +++ b/layout/bridge/layout.authpanel.php @@ -0,0 +1,26 @@ + +

Session info:

+
+ + user->id)) { ?> + + + + + + + + + + + + + +
KeyValue
UserIDuser->id ?>
Emailuser->primaryEmail, ENT_QUOTES, 'UTF-8') ?>
SessionKeysessionKey, ENT_QUOTES, 'UTF-8') ?>
Persistentpersistent ? 'true' : 'false') ?>
ExpireexpiryDate) ?>
UpdateexpiryThreshold) ?>
Permissionsuser->permissions) ?>
+ +
diff --git a/layout/bridge/layout.authtab.php b/layout/bridge/layout.authtab.php new file mode 100644 index 0000000..e03f383 --- /dev/null +++ b/layout/bridge/layout.authtab.php @@ -0,0 +1,13 @@ +user->id !== "0" ? '#3ba9e6' : '#666666'; +$color = $session->user->hasPermission("SUPER") ? "#aa0000" : $color; +$user = $session->user->id !== "0" ? $session->user->primaryEmail : 'Guest'; +?> + + + + + + + diff --git a/layout/mail/layout.forgot_password.latte b/layout/mail/layout.forgot_password.latte new file mode 100644 index 0000000..346bb62 --- /dev/null +++ b/layout/mail/layout.forgot_password.latte @@ -0,0 +1,215 @@ +{varType string $serverName} +{varType string $resetURL} +{varType string $token} + + + + + + + + + + + + + + + + + + + + diff --git a/layout/mail/layout.forgot_password_alt.latte b/layout/mail/layout.forgot_password_alt.latte new file mode 100644 index 0000000..ed02dc9 --- /dev/null +++ b/layout/mail/layout.forgot_password_alt.latte @@ -0,0 +1,13 @@ +{varType string $serverName} +{varType string $resetURL} +{varType string $token} +Password reset request for {$serverName}! + +If you've reset your password or wish to reset it, use the link below to get started. + +{$resetURL}?t={$token} Click here to reset your password. + +If you did not request a password reset, please ignore this email. Only a person with access to your email account can reset your password. + +With kind regards, +The {$serverName} team. \ No newline at end of file diff --git a/layout/mail/layout.register.latte b/layout/mail/layout.register.latte new file mode 100644 index 0000000..07c8766 --- /dev/null +++ b/layout/mail/layout.register.latte @@ -0,0 +1,214 @@ +{varType FuzeWorks\Authentication\Model\User $user} + + + + + + + + + + + + + + + + + + + + diff --git a/layout/mail/layout.register_alt.latte b/layout/mail/layout.register_alt.latte new file mode 100644 index 0000000..c679aa4 --- /dev/null +++ b/layout/mail/layout.register_alt.latte @@ -0,0 +1,11 @@ +{varType FuzeWorks\Authentication\Model\User $user} +Welcome to {$serverName}! + +You registered an account on {$serverName}. Before being able to use your account, you need to verify that this is your email address by clicking the link below. + +If you have any questions, feel free to contact us using the contact form on the website. + +With kind regards, +The {$serverName} team. + +{$verifyURL}?t={$user->emailVerifyToken} Click here to verify email! \ No newline at end of file diff --git a/layout/pages/layout.auth_form_page.latte b/layout/pages/layout.auth_form_page.latte new file mode 100644 index 0000000..fc661a3 --- /dev/null +++ b/layout/pages/layout.auth_form_page.latte @@ -0,0 +1,34 @@ +{varType bool $register_enabled} +{varType bool $forgot_password_enabled} +{varType string $auth_url} +{varType FuzeWorks\Forms\Form $form} + + + + + + {$serverName} > {$form->getLabel()} + + + + + + + + +

{$form->getLabel()}

+ {$form|noescape} +
+
+ {if $form->getName() == 'AuthLoginForm'} + {if $register_enabled} +
Sign up + {/if} + {elseif $form->getName() == 'AuthRegisterForm' OR $form->getName() == "AuthForgotPasswordForm"} +
Sign in + {/if} +
+ +

© 2013 - {date('Y')}

+ + diff --git a/src/FuzeWorks/Authentication/AuthenticationPlugin.php b/src/FuzeWorks/Authentication/AuthenticationPlugin.php new file mode 100755 index 0000000..1fc1cf5 --- /dev/null +++ b/src/FuzeWorks/Authentication/AuthenticationPlugin.php @@ -0,0 +1,184 @@ +addComponentPath($pluginPath, Priority::LOWEST); + $views->addComponentPath($pluginPath . DS . "view", Priority::LOWEST); + $controllers->addComponentPath($pluginPath . DS . "controller", Priority::LOWEST); + $layout->addComponentPath($pluginPath . DS . "layout", Priority::LOWEST); + + // Open the configuration + $this->config = $config->getConfig('authentication'); + + // Settle auth URL + $webURL = $config->getConfig("web")->get("base_url"); + $auth_selector = $this->config->get("auth_url"); + $this->authURL = $webURL . "/" . $auth_selector; + + // If the plugin is disabled, stop here + if ($this->config->get('auth_enabled') !== true) + return; + + // Fetch Driver type and determine type + /** @var Database $databases */ + $databases = Factory::getInstance("databases"); + $databaseConnection = $this->config->get("database_group"); + $engine = $databases->get($databaseConnection); + if ($engine instanceof PDOEngine) + $driver = Driver::PDO; + elseif ($engine instanceof MongoEngine) + $driver = Driver::MONGO; + else + $driver = Driver::UNKNOWN; + + // Load submodules + $this->users = new Users($engine, $driver, $this); + $this->sessions = new Sessions($engine, $driver, $this->config, $this->users); + + // Load dependencies which are needed upon every request with the plugin enabled + /** @var Router $router */ + $router = Factory::getInstance('router'); + + // Register route and related views and controllers + $webString = $auth_selector . '(|\/(?P.*?)(|\/(?P.*?)))'; + $router->addRoute($webString, ['viewType' => 'html', 'viewName' => 'authentication'], Priority::HIGH); + + // And register the tracy bridge + if (class_exists('Tracy\Debugger', true)) + new AuthenticationTracyBridge($this); + } + + /** + * Method that returns on which URL authentication is set to listen to. + * + * By default, this is /auth , but this can be configured in the authentication configuration file. + * + * @return string + */ + public function getAuthenticationURL(): string + { + return $this->authURL; + } + + /** + * Returns the authentication configuration file. + * + * @return ConfigORM + */ + public function getConfig(): ConfigORM + { + return $this->config; + } + + /** + * @inheritDoc + */ + public function getName(): string + { + return 'auth'; + } + + /** + * @inheritDoc + */ + public function getClassesPrefix(): ?string + { + return null; + } + + /** + * @inheritDoc + */ + public function getSourceDirectory(): ?string + { + return null; + } + + /** + * @inheritDoc + */ + public function getPluginClass(): ?string + { + return null; + } + + /** + * Returns this plugin in accordance with FuzeWorks\Plugins + * + * @return $this + */ + public function getPlugin(): AuthenticationPlugin + { + return $this; + } +} \ No newline at end of file diff --git a/src/FuzeWorks/Authentication/AuthenticationTracyBridge.php b/src/FuzeWorks/Authentication/AuthenticationTracyBridge.php new file mode 100644 index 0000000..ce3605e --- /dev/null +++ b/src/FuzeWorks/Authentication/AuthenticationTracyBridge.php @@ -0,0 +1,84 @@ +plugin = $plugin; + $bar = Debugger::getBar(); + $bar->addPanel($this); + } + + /** + * @inheritDoc + */ + function getTab(): string + { + $session = $this->plugin->sessions->getCurrentSession(); + ob_start(); + require(dirname(__DIR__, 3) . DS . "layout" . DS . "bridge" . DS . "layout.authtab.php"); + return ob_get_clean(); + } + + /** + * @inheritDoc + */ + function getPanel(): string + { + $session = $this->plugin->sessions->getCurrentSession(); + ob_start(); + require(dirname(__DIR__, 3) . DS . "layout" . DS . "bridge" . DS . "layout.authpanel.php"); + return ob_get_clean(); + } +} \ No newline at end of file diff --git a/src/FuzeWorks/Authentication/Driver.php b/src/FuzeWorks/Authentication/Driver.php new file mode 100755 index 0000000..df5079d --- /dev/null +++ b/src/FuzeWorks/Authentication/Driver.php @@ -0,0 +1,46 @@ +engine = $engine; + $this->collection = $this->engine->selectDatabase("auth")->selectCollection("sessions"); + $this->usersModel = $usersModel; + } + + /** + * @param Session $session + * @return bool + */ + public function createSession(Session $session): bool + { + $insert = [ + "sessionKey" => $session->sessionKey, + "user" => $session->user->id, + "expiryDate" => $session->expiryDate, + "expiryThreshold" => $session->expiryThreshold, + "persistent" => $session->persistent, + "active" => $session->active, + "context" => $session->context + ]; + + $res = $this->collection->insertOne($insert); + if ($res->getInsertedCount() !== 1) + return false; + + return true; + } + + public function readSessions(array $filter): array + { + // Fetch from database + $results = $this->collection->find($filter); + $out = []; + foreach ($results as $result) + { + // Load variables + $sessionKey = $result->sessionKey; + $user = $this->usersModel->getUserByUid($result->user); + $expiryDate = $result->expiryDate; + $expiryThreshold = $result->expiryThreshold; + $persistent = $result->persistent; + $active = $result->active; + $context = (array) $result->context; + + $out[] = new Session($user, $sessionKey, $expiryDate, $expiryThreshold, $context, $persistent, $active); + } + + // And output cumulative results + return $out; + } + + public function updateSessions(array $sessions): bool + { + $modified = 0; + foreach ($sessions as $session) + { + $update = [ + "user" => $session->user->id, + "expiryDate" => $session->expiryDate, + "expiryThreshold" => $session->expiryThreshold, + "persistent" => $session->persistent, + "active" => $session->active, + "context" => $session->context + ]; + + $res = $this->collection->updateOne( + ["sessionKey" => $session->sessionKey], + ['$set' => $update] + ); + $modified += $res->getModifiedCount(); + } + + if ($modified === count($sessions)) + return true; + + return false; + } + + public function deleteSessions(array $sessions): bool + { + $deleted = 0; + foreach ($sessions as $session) + { + $res = $this->collection->deleteOne(["sessionKey" => $session->sessionKey]); + $deleted += $res->getDeletedCount(); + } + + if ($deleted === count($sessions)) + return true; + + return false; + } +} \ No newline at end of file diff --git a/src/FuzeWorks/Authentication/Drivers/MongoUsersModelDriver.php b/src/FuzeWorks/Authentication/Drivers/MongoUsersModelDriver.php new file mode 100755 index 0000000..b623b6c --- /dev/null +++ b/src/FuzeWorks/Authentication/Drivers/MongoUsersModelDriver.php @@ -0,0 +1,142 @@ +engine = $engine; + $this->collection = $this->engine->selectDatabase("auth")->selectCollection("users"); + } + + /** + * @param User[] $users + * @return bool + */ + public function createUsers(array $users): bool + { + $res = $this->collection->insertMany($users); + if ($res->getInsertedCount() == count($users)) + return true; + + return false; + } + + public function readUsers(array $filter, array $options = []): array + { + // Process options + $opt = []; + if (isset($options['limit'])) + $opt['limit'] = $options['limit']; + if (isset($options['index'])) + $opt['skip'] = $options['index']; + + $results = $this->collection->find($filter, $opt); + $out = []; + foreach ($results as $result) + { + $user = new User($result->id); + if (isset($result->primaryEmail)) + $user->primaryEmail = $result->primaryEmail; + if (isset($result->password)) + $user->password = $result->password; + if (isset($result->permissions)) + $user->permissions = (array) $result->permissions; + if (isset($result->active)) + $user->active = (bool) $result->active; + if (isset($result->emailVerifyToken)) + $user->emailVerifyToken = $result->emailVerifyToken; + if (isset($result->emailVerifyExpiry)) + $user->emailVerifyExpiry = $result->emailVerifyExpiry; + if (isset($result->properties)) + $user->properties = (array) $result->properties; + + $out[] = $user; + } + + return $out; + } + + /** + * @param User[] $users + * @return bool + */ + public function updateUsers(array $users): bool + { + $modified = 0; + foreach ($users as $user) + { + $res = $this->collection->updateOne(["id" => $user->id], ['$set' => $user]); + $modified += $res->getModifiedCount(); + } + + if ($modified === count($users)) + return true; + + return false; + } + + /** + * @param User[] $users + * @return bool + */ + public function deleteUsers(array $users): bool + { + $deleted = 0; + foreach ($users as $user) + { + $res = $this->collection->deleteOne(["id" => $user->id]); + $deleted += $res->getDeletedCount(); + } + + if ($deleted === count($users)) + return true; + + return false; + } +} \ No newline at end of file diff --git a/src/FuzeWorks/Authentication/Drivers/PdoSessionsModelDriver.php b/src/FuzeWorks/Authentication/Drivers/PdoSessionsModelDriver.php new file mode 100644 index 0000000..c1c9477 --- /dev/null +++ b/src/FuzeWorks/Authentication/Drivers/PdoSessionsModelDriver.php @@ -0,0 +1,205 @@ +engine = $engine; + $this->usersModel = $usersModel; + + // Load object storage + /** @var ObjectStorageComponent $storage */ + $storage = Factory::getInstance("storage"); + $cache = $storage->getCache(); + + // Lookup schema + $schema = $cache->get("authPdoSessionsSchema"); + if (is_null($schema)) + { + $this->loadSchema(); + $cache->set("authPdoSessionsSchema", true); + } + + $this->schema = true; + } + + private function loadSchema(): bool + { + // Check if the sessions table exists + $statement = $this->engine->prepare("SELECT * FROM information_schema.tables WHERE `table_name` = :table_name LIMIT 1"); + $statement->execute([':table_name' => 'sessions']); + if ($statement->rowCount() !== 1 && !$this->populateSchema()) + throw new AuthenticationException("Could not initiate Authentication. Database schema is missing."); + + return true; + } + + private function populateSchema(): bool + { + // @todo Implement schema populator + return false; + } + + public function createSession(Session $session): bool + { + $insert = [ + "sessionKey" => $session->sessionKey, + "user" => $session->user->id, + "expiryDate" => $session->expiryDate, + "expiryThreshold" => $session->expiryThreshold, + "persistent" => (int) $session->persistent, + "active" => (int) $session->active, + "context" => json_encode($session->context) + ]; + + // Perform the insert + $statement = $this->engine->prepare("INSERT INTO sessions (sessionKey, user, expiryDate, expiryThreshold, persistent, active, context) VALUES (:sessionKey, :user, :expiryDate, :expiryThreshold, :persistent, :active, :context)"); + $statement->execute($insert); + + return $statement->rowCount() === 1; + } + + public function readSessions(array $filter): array + { + // Prepare the where statement + if (empty($filter)) + $where = ''; + else + { + $whereKeys = []; + foreach ($filter as $filterKey => $filterVal) + $whereKeys[] = $filterKey . '=:' . $filterKey; + + $where = 'WHERE ' . implode(' AND ', $whereKeys); + } + + // Prepare the statement + $sql = "SELECT * FROM sessions $where"; + $statement = $this->engine->prepare($sql); + + // And execute the query + foreach ($filter as $key => $val) + { + if (is_null($val)) + $statement->bindValue(':' . $key, $val, PDO::PARAM_NULL); + else + $statement->bindValue(':' . $key, $val); + } + $statement->execute(); + + // And return the results + $sessions = []; + while ($result = $statement->fetch(PDO::FETCH_OBJ)) + { + // Load variables + $sessionKey = $result->sessionKey; + $user = $this->usersModel->getUserByUid($result->user); + $expiryDate = $result->expiryDate; + $expiryThreshold = $result->expiryThreshold; + $persistent = $result->persistent; + $active = $result->active; + $context = json_decode($result->context, true); + + $sessions[] = new Session($user, $sessionKey, $expiryDate, $expiryThreshold, $context, $persistent, $active); + } + + return $sessions; + } + + public function updateSessions(array $sessions): bool + { + // Prepare statement + $statement = $this->engine->prepare("UPDATE sessions SET user=:user, expiryDate=:expiryDate, expiryThreshold=:expiryThreshold, persistent=:persistent, active=:active, context=:context WHERE sessionKey=:sessionKey"); + + // Loop over sessions + foreach ($sessions as $session) + { + $update = [ + "user" => $session->user->id, + "expiryDate" => $session->expiryDate, + "expiryThreshold" => $session->expiryThreshold, + "persistent" => (int) $session->persistent, + "active" => (int) $session->active, + "context" => json_encode($session->context), + "sessionKey" => $session->sessionKey + ]; + + // Perform the update + $statement->execute($update); + + if ($statement->rowCount() !== 1) + return false; + } + + return true; + } + + public function deleteSessions(array $sessions): bool + { + // Prepare statement + $statement = $this->engine->prepare("DELETE FROM sessions WHERE sessionKey=:sessionKey"); + + // Loop over sessions + foreach ($sessions as $session) + { + // Perform the delete + $statement->execute([':sessionKey' => $session->sessionKey]); + + if ($statement->rowCount() !== 1) + return false; + } + + return true; + } +} \ No newline at end of file diff --git a/src/FuzeWorks/Authentication/Drivers/PdoUsersModelDriver.php b/src/FuzeWorks/Authentication/Drivers/PdoUsersModelDriver.php new file mode 100644 index 0000000..9dc5cab --- /dev/null +++ b/src/FuzeWorks/Authentication/Drivers/PdoUsersModelDriver.php @@ -0,0 +1,449 @@ +engine = $engine; + + // Load object storage + /** @var ObjectStorageComponent $storage */ + $storage = Factory::getInstance("storage"); + $cache = $storage->getCache(); + + // Lookup the current schema + $schema = $cache->get('authPdoUsersSchema'); + if (is_null($schema)) + { + $schema = $this->loadSchema(); + $cache->set('authPdoUsersSchema', $schema, 3600); + } + + $this->schema = $schema; + } + + /** + * Reads out the database for the current schema. Important to know which columns are available + * in the properties. + * + * @return array + * @throws AuthenticationException + */ + private function loadSchema(): array + { + // Check if the users table exist + $statement = $this->engine->prepare("SELECT * FROM information_schema.tables WHERE `table_name` = :table_name LIMIT 1"); + $statement->execute(['table_name' => "users"]); + if ($statement->rowCount() !== 1 && !$this->populateSchema()) + throw new AuthenticationException("Could not initiate Authentication. Database schema is missing."); + + // Read the properties table and find all existing columns + $statement = $this->engine->prepare("SELECT `column_name` FROM information_schema.columns WHERE `table_name` = :table_name"); + $statement->execute(['table_name' => "users_properties"]); + if ($statement->rowCount() < 1) + throw new AuthenticationException("Could not initiate Authentication. Database error."); + + // Fetch columns and remove standard columns + $columns = $statement->fetchAll(PDO::FETCH_COLUMN); + $columns = array_splice($columns, 1); + + return ['properties' => $columns]; + } + + private function populateSchema(): bool + { + // @todo Implement schema populator + return false; + } + + /** + * Adds the property to the schema. This will add the column to the properties table and save the + * new schema to the cache. + * + * @param string $propertyName + * @param mixed $propertyExample + * @return bool + * @throws DatabaseException + */ + private function addSchemaProperty(string $propertyName, mixed $propertyExample): bool + { + // First check if the property is not already in the schema + $illegal = ['id', 'primaryEmail', 'password', 'mfaSecret', 'permissions', 'active', 'emailVerifyToken', 'emailVerifyExpiry', 'properties', 'user_id']; + if (in_array($propertyName, $illegal)) + { + Logger::logError("Could not add property to schema. Illegal property name: " . $propertyName); + return false; + } + + // Check the type of the variable + $type = gettype($propertyExample); + switch ($type) + { + case "integer": + $type = "INT"; + break; + case "double": + $type = "DOUBLE"; + break; + case "string": + $type = "VARCHAR(255)"; + break; + case "boolean": + $type = "TINYINT(1)"; + break; + default: + Logger::logError("Could not add property to schema. Unknown type: " . $type); + return false; + } + + // Add the column to the properties table + $this->engine->query("ALTER TABLE `users_properties` ADD $propertyName $type NULL"); + + // Save the new schema + $this->schema['properties'][] = $propertyName; + + /** @var ObjectStorageComponent $storage */ + $storage = Factory::getInstance("storage"); + $cache = $storage->getCache(); + $cache->set('authPdoUsersSchema', $this->schema, 3600); + + return true; + } + + /** + * @inheritDoc + */ + public function createUsers(array $users): bool + { + try { + // Prepare statements + $users_sql = "INSERT INTO `users` (id, primaryEmail, password, mfaSecret, permissions, active, emailVerifyToken, emailVerifyExpiry) VALUES (:id, :primaryEmail, :password, :mfaSecret, :permissions, :active, :emailVerifyToken, :emailVerifyExpiry)"; + $properties_sql = "INSERT INTO `users_properties` (user_id) VALUES (:id)"; + $users_statement = $this->engine->prepare($users_sql); + $properties_statement = $this->engine->prepare($properties_sql); + + // Loop over each property + foreach ($users as $user) + foreach ($user->properties as $property => $value) + // Check if the property is already in the schema + if (!in_array($property, $this->schema['properties'])) + // Add the property to the schema + if (!$this->addSchemaProperty($property, $value)) + throw new DatabaseException("Could not add property to schema."); + + // Start transaction + $this->engine->transactionStart(); + + // Loop over each user + foreach ($users as $user) + { + // First insert the user into the general users table + $users_statement->execute([ + "id" => $user->id, + "primaryEmail" => $user->primaryEmail, + "password" => $user->password, + "mfaSecret" => $user->mfaSecret, + "permissions" => implode(";", $user->permissions), + "active" => (int) $user->active, + "emailVerifyToken" => $user->emailVerifyToken, + "emailVerifyExpiry" => $user->emailVerifyExpiry + ]); + if ($users_statement->rowCount() !== 1) + { + $this->engine->transactionRollback(); + return false; + } + + // Then append the user id to the properties table + $properties_statement->execute(['id' => $user->id]); + + // Insert all properties + foreach ($user->properties as $propertyKey => $propertyValue) + { + // Insert the property + $statement = $this->engine->prepare("UPDATE `users_properties` SET $propertyKey = :propertyValue WHERE `user_id` = :id"); + $statement->execute([ + "propertyValue" => $propertyValue, + "id" => $user->id + ]); + if ($statement->rowCount() !== 1) + { + $this->engine->transactionRollback(); + Logger::logError("Could not create users. Database error."); + return false; + } + } + } + + // Commit the transaction + return $this->engine->transactionCommit(); + } catch (TransactionException $e) { + Logger::logError("Could not create users. Transaction failure: " . $e->getMessage()); + } catch (DatabaseException $e) { + Logger::logError("Could not create users. Database error: " . $e->getMessage()); + } + + return false; + } + + /** + * @inheritDoc + */ + public function readUsers(array $filter, array $options = []): array + { + // Prepare the where statement + if (empty($filter)) + $where = ''; + else + { + $whereKeys = []; + foreach ($filter as $filterKey => $filterVal) + $whereKeys[] = $filterKey . '=:' . $filterKey; + + $where = 'WHERE ' . implode(' AND ', $whereKeys); + } + + // Prepare statement + $sql = "SELECT * FROM `users` LEFT JOIN `users_properties` ON users.`id`=users_properties.`user_id` $where"; + $statement = $this->engine->prepare($sql); + + // And execute the query + foreach ($filter as $key => $val) + { + if (is_null($val)) + $statement->bindValue(':' . $key, $val, PDO::PARAM_NULL); + else + $statement->bindValue(':' . $key, $val); + } + + // And at last, execute + $statement->execute(); + $results = $statement->fetchAll(PDO::FETCH_OBJ); + $out = []; + foreach ($results as $result) + { + $user = new User($result->id); + if (isset($result->primaryEmail)) + $user->primaryEmail = $result->primaryEmail; + if (isset($result->password)) + $user->password = $result->password; + if (isset($result->mfaSecret)) + $user->mfaSecret = $result->mfaSecret; + if (isset($result->permissions)) + $user->permissions = explode(";", $result->permissions); + if (isset($result->active)) + $user->active = (bool) $result->active; + if (isset($result->emailVerifyToken)) + $user->emailVerifyToken = $result->emailVerifyToken; + if (isset($result->emailVerifyExpiry)) + $user->emailVerifyExpiry = $result->emailVerifyExpiry; + + // And map properties + foreach ($this->schema['properties'] as $property) + if (isset($result->$property)) + $user->properties[$property] = $result->$property; + + $out[] = $user; + } + + return $out; + } + + /** + * @inheritDoc + */ + public function updateUsers(array $users): bool + { + try { + // Loop over each property + foreach ($users as $user) + foreach ($user->properties as $property => $value) + // Check if the property is already in the schema + if (!in_array($property, $this->schema['properties'])) + // Add the property to the schema + if (!$this->addSchemaProperty($property, $value)) + throw new DatabaseException("Could not add property to schema."); + + // Start transaction + $this->engine->transactionStart(); + + foreach ($users as $user) + { + // Get the unaltered user + $oldUser = $this->readUsers(['id' => $user->id])[0]; + + // Check if there is a difference + $vars = []; + if ($user->primaryEmail != $oldUser->primaryEmail) + $vars['primaryEmail'] = $user->primaryEmail; + if ($user->password != $oldUser->password) + $vars['password'] = $user->password; + if ($user->mfaSecret != $oldUser->mfaSecret) + $vars['mfaSecret'] = $user->mfaSecret; + if ($user->permissions != $oldUser->permissions) + $vars['permissions'] = implode(";", $user->permissions); + if ($user->active != $oldUser->active) + $vars['active'] = (int) $user->active; + if ($user->emailVerifyToken != $oldUser->emailVerifyToken) + $vars['emailVerifyToken'] = $user->emailVerifyToken; + if ($user->emailVerifyExpiry != $oldUser->emailVerifyExpiry) + $vars['emailVerifyExpiry'] = $user->emailVerifyExpiry; + + // Prepare statement + $sql = "UPDATE `users` SET " . implode(', ', array_map(function ($key) { + return $key . '=:' . $key; + }, array_keys($vars))) . " WHERE `id`=:id"; + $statement = $this->engine->prepare($sql); + + // Bind values + foreach ($vars as $key => $val) + $statement->bindValue(':' . $key, $val); + + // Bind id + $statement->bindValue(':id', $user->id); + + // And execute + if (!empty($vars)) + $statement->execute(); + + // Update all properties + $vars = []; + + // First check if any properties of oldUser are removed + foreach ($oldUser->properties as $property => $value) + if (!isset($user->properties[$property])) + $vars[$property] = null; + + // Then check if any properties of newUser are added + foreach ($user->properties as $property => $value) + if (!isset($oldUser->properties[$property]) || $oldUser->properties[$property] !== $value) + $vars[$property] = $value; + + // If there are no changes, continue + if (empty($vars)) + continue; + + // Prepare statement + $sql = "UPDATE `users_properties` SET " . implode(', ', array_map(function ($key) { + return $key . '=:' . $key; + }, array_keys($vars))) . " WHERE `user_id`=:id"; + $statement = $this->engine->prepare($sql); + + // Bind values + foreach ($vars as $key => $val) + $statement->bindValue(':' . $key, $val); + + // Bind id + $statement->bindValue(':id', $user->id); + + // And execute + $statement->execute(); + } + + // Commit the transaction + return $this->engine->transactionCommit(); + } catch (TransactionException $e) { + Logger::logError("Could not create users. Transaction failure: " . $e->getMessage()); + } catch (DatabaseException $e) { + Logger::logError("Could not create users. Database error: " . $e->getMessage()); + } + + return false; + } + + /** + * @inheritDoc + */ + public function deleteUsers(array $users): bool + { + try { + // Start transaction + $this->engine->transactionStart(); + + // Prepare statements + $statement = $this->engine->prepare("DELETE FROM `users` WHERE `id`=:id"); + $statement2 = $this->engine->prepare("DELETE FROM `users_properties` WHERE `user_id`=:id"); + + // Loop over each user + foreach ($users as $user) + { + // Bind id + $statement->bindValue(':id', $user->id); + $statement2->bindValue(':id', $user->id); + + // And execute + $statement2->execute(); + $statement->execute(); + + // Verify that the user was deleted with rowCount + if ($statement->rowCount() !== 1 || $statement2->rowCount() !== 1) + { + $this->engine->transactionRollback(); + Logger::logError("Could not delete users. Database error."); + return false; + } + } + + // Commit the transaction + return $this->engine->transactionCommit(); + } catch (TransactionException $e) { + Logger::logError("Could not delete users. Transaction failure: " . $e->getMessage()); + } catch (DatabaseException $e) { + Logger::logError("Could not delete users. Database error: " . $e->getMessage()); + } + + return false; + } +} \ No newline at end of file diff --git a/src/FuzeWorks/Authentication/Events/AddUserEvent.php b/src/FuzeWorks/Authentication/Events/AddUserEvent.php new file mode 100755 index 0000000..0d3424b --- /dev/null +++ b/src/FuzeWorks/Authentication/Events/AddUserEvent.php @@ -0,0 +1,91 @@ +email = $email; + $this->password = $password; + $this->active = $active; + $this->properties = $parameters; + } + + public function getProperties(): array + { + return $this->properties; + } + + public function setProperty(string $key, mixed $value): void + { + $this->properties[$key] = $value; + } + + public function hasProperty(string $key): bool + { + return isset($this->properties[$key]); + } + + public function setError(string $errorText) + { + $this->cancelError = $errorText; + } + + public function getError(): ?string + { + return $this->cancelError; + } + + public function __get(string $key) + { + return $this->properties[$key]; + } + + public function __set(string $key, mixed $value): void + { + $this->properties[$key] = $value; + } + +} \ No newline at end of file diff --git a/src/FuzeWorks/Authentication/Events/GetRegisterFieldsEvent.php b/src/FuzeWorks/Authentication/Events/GetRegisterFieldsEvent.php new file mode 100644 index 0000000..d5d7656 --- /dev/null +++ b/src/FuzeWorks/Authentication/Events/GetRegisterFieldsEvent.php @@ -0,0 +1,69 @@ +fields; + } + + /** + * Add a field to be used on the register page + * + * @param Field $field + * @return void + */ + public function addField(Field $field): void + { + $this->fields[] = $field; + } + + + +} \ No newline at end of file diff --git a/src/FuzeWorks/Authentication/Events/LoginEvent.php b/src/FuzeWorks/Authentication/Events/LoginEvent.php new file mode 100644 index 0000000..68df951 --- /dev/null +++ b/src/FuzeWorks/Authentication/Events/LoginEvent.php @@ -0,0 +1,66 @@ +identifier = $identifier; + $this->password = $password; + $this->remember = $remember; + $this->context = $context; + } + + public function addIdentifierField(string $identifierField) + { + $this->identifierFields[] = $identifierField; + } + + public function getIdentifierFields(): array + { + return $this->identifierFields; + } +} \ No newline at end of file diff --git a/src/FuzeWorks/Authentication/Events/RegisterEvent.php b/src/FuzeWorks/Authentication/Events/RegisterEvent.php new file mode 100644 index 0000000..c4eb805 --- /dev/null +++ b/src/FuzeWorks/Authentication/Events/RegisterEvent.php @@ -0,0 +1,80 @@ +email = $email; + $this->password = $password; + $this->parameters = $parameters; + $this->properties = $properties; + } + + public function getProperties(): array + { + return $this->properties; + } + + public function setProperty(string $key, mixed $value): void + { + $this->properties[$key] = $value; + } + + public function hasProperty(string $key): bool + { + return isset($this->properties[$key]); + } + + public function setError(string $errorText) + { + $this->cancelError = $errorText; + } + + public function getError(): ?string + { + return $this->cancelError; + } +} \ No newline at end of file diff --git a/src/FuzeWorks/Authentication/Exceptions/AuthenticationException.php b/src/FuzeWorks/Authentication/Exceptions/AuthenticationException.php new file mode 100755 index 0000000..ef3c7d0 --- /dev/null +++ b/src/FuzeWorks/Authentication/Exceptions/AuthenticationException.php @@ -0,0 +1,41 @@ +get('auth'); + + // @todo Log all exceptions of this kind to a log somewhere + } + +} \ No newline at end of file diff --git a/src/FuzeWorks/Authentication/Exceptions/LoginWarningException.php b/src/FuzeWorks/Authentication/Exceptions/LoginWarningException.php new file mode 100644 index 0000000..abbd59e --- /dev/null +++ b/src/FuzeWorks/Authentication/Exceptions/LoginWarningException.php @@ -0,0 +1,40 @@ +user = $user; + $this->sessionKey = $sessionKey; + $this->expiryDate = $expiryDate; + $this->expiryThreshold = $expiryThreshold; + $this->context = $context; + $this->persistent = $persistent; + $this->active = $active; + } + + /** + * Returns true if the user of this session has access to a specific permission node. + * + * @param string $permissionString + * @return bool + */ + public function hasPermission(string $permissionString): bool + { + return $this->user->hasPermission($permissionString); + } + +} \ No newline at end of file diff --git a/src/FuzeWorks/Authentication/Model/Sessions.php b/src/FuzeWorks/Authentication/Model/Sessions.php new file mode 100755 index 0000000..b3cf4bb --- /dev/null +++ b/src/FuzeWorks/Authentication/Model/Sessions.php @@ -0,0 +1,272 @@ +driver = new PdoSessionsModelDriver($engine, $usersModel); + elseif ($driver == Driver::MONGO) + $this->driver = new MongoSessionsModelDriver($engine, $usersModel); + + // Load config + $this->authCFG = $config; + $this->webCFG = Factory::getInstance("config")->getConfig("web"); + + // Save Users + $this->usersModel = $usersModel; + + // And load the guest session + $this->session = new GuestSession(); + } + + /** + * @param string|null $sessionKey + * @return Session + */ + public function start(string $sessionKey = null): Session + { + // Fetch the sessionKey + if (is_null($sessionKey)) + { + /** @var Input $input */ + $input = Factory::getInstance("input"); + $cookieName = $this->webCFG->get("cookie_prefix") . "fw_auth_token"; + $sessionKey = $input->cookie($cookieName); + } + + // If the sessionKey is still null, return the current GuestSession + if (is_null($sessionKey)) + return $this->session; + + // Otherwise, fetch the current session from the database + $session = $this->getSessionByHash($sessionKey); + if (is_null($session)) + { + // Remove the session cookie + $this->_setSession("", 0); + return $this->session; + } + + // Check if the date has expired + if (date('U') > $session->expiryDate) + { + Logger::log("Current session has expired. Ending session."); + $session->active = false; + if (!$this->updateSession($session)) + Logger::logError("Failed to update session. Setting guest session for now."); + + // And remove the session cookie + $this->_setSession("", 0); + return $this->session; + } + + // If session has been deactivated, remove cookie and return guest session + if (!$session->active) + { + $this->_setSession("", 0); + return $this->session; + } + + // If user has been deactivated, update session and remove cookie + if (!$session->user->active) + { + Logger::log("Current user has been deactivated. Ending session."); + $session->active = false; + if (!$this->updateSession($session)) + Logger::logError("Failed to update session. Setting guest session for now."); + + // And remove the session cookie + $this->_setSession("", 0); + return $this->session; + } + + // Verify that email has been verified on time + if (!is_null($session->user->emailVerifyToken) && date('U') > $session->user->emailVerifyExpiry) + { + // Deactivate the user and write to database + Logger::log("User must verify email before continuing."); + $this->_setSession("", 0); + return $this->session; + } + + // Extend the session if threshold has expired + if (date('U') > $session->expiryThreshold) + { + $session->expiryDate = ($session->persistent ? (int) date('U') + (3600*24*31*3) : (int) date('U') + 3600); + $session->expiryThreshold = ($session->persistent ? (int) date('U') + (3600*24) : (int) date('U') + 900); + Logger::log("Current session needs to be extended. Extending session."); + if (!$this->updateSession($session)) + Logger::logError("Failed to update session. Maintaining current session."); + + // Extend the session + $this->_setSession($session->sessionKey, $session->expiryDate); + } + + // Log the session + $id = !is_null($session->user->primaryEmail) ? $session->user->primaryEmail : $session->user->id; + Logger::log("Current session is: '" . $id . "'"); + + // Set the values and return the session + $this->session = $session; + return $this->session; + } + + /** + * @param string $sessionKey + * @return Session|null + */ + public function getSessionByHash(string $sessionKey): ?Session + { + $sessions = $this->driver->readSessions(["sessionKey" => $sessionKey]); + if (empty($sessions)) + return null; + + return $sessions[0]; + } + + /** + * @param string $contextKey + * @param string $contextValue + * @return Session[] + */ + public function getSessionsByContext(string $contextKey, string $contextValue): array + { + return $this->driver->readSessions(["context." . $contextKey => $contextValue]); + } + + /** + * @param array $filter + * @return Session[] + */ + public function getSessions(array $filter): array + { + return $this->driver->readSessions($filter); + } + + public function createSession(User $user, array $context, bool $persistent = false, bool $active = true): ?Session + { + // Generate variables + $hash = substr(base64_encode(sha1(mt_rand())), 0, 16); + $expire = ($persistent ? (int) date('U') + (3600*24*31*3) : (int) date('U') + 3600); + $threshold = ($persistent ? (int) date('U') + (3600*24) : (int) date('U') + 900); + + // Create Model + $session = new Session($user, $hash, $expire, $threshold, $context, $persistent, $active); + + // Write to driver and set cookie + if ($this->driver->createSession($session)) + { + $this->_setSession($hash, $expire); + return $session; + } + + return null; + } + + public function updateSession(Session $session): bool + { + return $this->driver->updateSessions([$session]); + } + + public function endSession(Session $session): bool + { + $session->active = false; + if ($this->updateSession($session)) + { + $this->_setSession("", 0); + return true; + } + + return false; + } + + public function deleteSession(Session $session): bool + { + return $this->driver->deleteSessions([$session]); + } + + public function getCurrentSession(): Session + { + return $this->session; + } + + private function _setSession(string $sessionKey, int $expire) + { + // @todo Update to use FuzeWorks\Output + setcookie( + $this->webCFG->get("cookie_prefix") . "fw_auth_token", + $sessionKey, + $expire, + $this->webCFG->get("cookie_path"), + $this->webCFG->get("cookie_domain"), + $this->webCFG->get("cookie_secure"), + $this->webCFG->get("cookie_httponly") + ); + } + + +} \ No newline at end of file diff --git a/src/FuzeWorks/Authentication/Model/User.php b/src/FuzeWorks/Authentication/Model/User.php new file mode 100755 index 0000000..5ab79e9 --- /dev/null +++ b/src/FuzeWorks/Authentication/Model/User.php @@ -0,0 +1,146 @@ + value list of other properties describing the user, such as username or social info. + * + * @var array + */ + public array $properties = []; + + public function __construct(string $id, string $primaryEmail = null, string $password = null, array $permissions = [], bool $active = true, string $emailToken = null, int $emailExpire = null, array $properties = []) + { + $this->id = $id; + $this->primaryEmail = $primaryEmail; + $this->password = $password; + $this->permissions = $permissions; + $this->active = $active; + $this->emailVerifyToken = $emailToken; + $this->emailVerifyExpiry = $emailExpire; + $this->properties = $properties; + } + + /** + * Returns true if the user has access to a specific permission node. + * + * @param string $permissionTag + * @return bool + */ + public function hasPermission(string $permissionTag): bool + { + return in_array(strtoupper($permissionTag), $this->permissions) || in_array("SUPER", $this->permissions); + } + + public function __isset(string $name) + { + return isset($this->properties[$name]); + } + + public function __get(string $name) + { + return $this->properties[$name]; + } + + public function __set(string $name, mixed $value) + { + $this->properties[$name] = $value; + } + +} \ No newline at end of file diff --git a/src/FuzeWorks/Authentication/Model/Users.php b/src/FuzeWorks/Authentication/Model/Users.php new file mode 100755 index 0000000..2d25862 --- /dev/null +++ b/src/FuzeWorks/Authentication/Model/Users.php @@ -0,0 +1,279 @@ +driver = new PdoUsersModelDriver($engine); + elseif ($driver == Driver::MONGO) + $this->driver = new MongoUsersModelDriver($engine); + + // Set plugin + $this->plugin = $plugin; + + // Load config + $this->authCFG = $plugin->config; + } + + /** + * @param string $email + * @return User|null + */ + public function getUserByEmail(string $email): ?User + { + $users = $this->driver->readUsers(["primaryEmail" => $email]); + if (empty($users)) + return null; + + return $users[0]; + } + + /** + * @param string $uid + * @return User|null + */ + public function getUserByUid(string $uid): ?User + { + $users = $this->driver->readUsers(["id" => $uid]); + if (empty($users)) + return null; + + return $users[0]; + } + + /** + * @param string[] $ids + * @return User[] + */ + public function getUsersByUids(array $ids): array + { + $out = []; + foreach ($ids as $id) + { + $user = $this->getUserByUid($id); + if (!is_null($user)) + $out[] = $user; + } + + return $out; + } + + /** + * Returns all the users which have a specific permission node. + * + * @note Currently non-functional on PDO backends + * @todo Fix^ + * + * @param string $permissionString + * @return User[] + */ + public function getUsersByPermissionString(string $permissionString): array + { + return $this->driver->readUsers(["permissions" => $permissionString]); + } + + /** + * @param string $variableKey + * @param string $variableValue + * @return User[] + */ + public function getUsersByProperty(string $variableKey, string $variableValue): array + { + return $this->driver->readUsers(["properties.".$variableKey => $variableValue]); + } + + /** + * @param string $variableKey + * @param string $variableValue + * @return User[] + */ + public function getUsersByVariable(string $variableKey, string $variableValue): array + { + return $this->driver->readUsers([$variableKey => $variableValue]); + } + + /** + * Retrieve all users + * + * @return Users[] + */ + public function getUsers(int $index = 0, int $limit = 999): array + { + return $this->driver->readUsers([], ['index' => $index, 'limit' => $limit]); + } + + /** + * @param string $email + * @param string|null $password + * @param bool $active + * @param array $properties + * @return bool + * @throws InputException + * @throws AuthenticationException + */ + public function addUser(string $email, ?string $password, bool $active = true, array $properties = []): bool + { + // First verify the variables + if (!is_null($password)) + { + $this->validatePassword($password); + + // Hash the password + $password = password_hash($password, $this->authCFG->get("password_algorithm"), $this->authCFG->get("password_options")); + } + + // Generate uid + $uid = substr(base64_encode(sha1(mt_rand())), 0, 16); + + // Verify if a user with this identifier already exists + $this->validateEmail($email); + if ($this->getUserByEmail($email) !== null) + throw new InputException("This user already exists."); + + // Fire AddUserEvent + /** @var AddUserEvent $event */ + $event = Events::fireEvent(new AddUserEvent($email, $password, $active, $properties)); + if ($event->isCancelled()) + { + $error = is_null($event->getError()) ? "Add user cancelled by system." : $event->getError(); + throw new InputException($error); + } + + // Prepare user verification code + $emailExpire = (int) date('U') + (int) $this->authCFG->get("verifyEmailWithin"); + $emailToken = substr(base64_encode(sha1(mt_rand())), 0, 16); + + // Prepare user object + $user = new User($uid, $event->email, $event->password, ["USER"], $event->active, $emailToken, $emailExpire, $event->getProperties()); + + // Send the data to the driver + return $this->driver->createUsers([$user]); + } + + /** + * @param User $user + * @param string $newPassword + * @return bool + * @throws InputException + */ + public function changePassword(User $user, string $newPassword): bool + { + // Validate the password + $this->validatePassword($newPassword); + + // Calculate password + $user->password = password_hash($newPassword, $this->authCFG->get("password_algorithm"), $this->authCFG->get("password_options")); + + // And commit + return $this->updateUser($user); + } + + public function updateUser(User $user): bool + { + return $this->driver->updateUsers([$user]); + } + + public function deleteUser(User $user): bool + { + return $this->driver->deleteUsers([$user]); + } + + /** + * Validate if the password is strong enough + * + * @param string $password + * @return bool + * @throws InputException + */ + protected function validatePassword(string $password): bool + { + // First check for length + $min_length = $this->authCFG->get('password_min_length'); + if (strlen($password) < $min_length) + throw new InputException("Password is too short. Must contain at least $min_length characters."); + + // @todo Implement more tests + + return true; + } + + /** + * Validate if the provided string is a valid email. + * + * @param string $email + * @return bool + * @throws InputException + */ + protected function validateEmail(string $email): bool + { + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) + throw new InputException("Provided email is not valid."); + + return true; + } + + +} \ No newline at end of file diff --git a/src/FuzeWorks/Authentication/SessionsModelDriverInterface.php b/src/FuzeWorks/Authentication/SessionsModelDriverInterface.php new file mode 100755 index 0000000..e985646 --- /dev/null +++ b/src/FuzeWorks/Authentication/SessionsModelDriverInterface.php @@ -0,0 +1,86 @@ +plugin = Factory::getInstance('plugins')->get('authentication'); + } + + public function testFoundation() + { + $this->assertInstanceOf(AuthenticationPlugin::class, $this->plugin); + } +} \ No newline at end of file diff --git a/test/bootstrap.php b/test/bootstrap.php new file mode 100644 index 0000000..1ae6786 --- /dev/null +++ b/test/bootstrap.php @@ -0,0 +1,65 @@ +setTempDirectory(__DIR__ . '/temp'); +$configurator->setLogDirectory(__DIR__ . '/temp'); + +// Other values +$configurator->setTimeZone('Europe/Amsterdam'); + +// Add config folder +$configurator->addDirectory(dirname(__FILE__), 'config'); + +// Add components +$configurator->addComponent(new \FuzeWorks\WebComponent()); +$configurator->addComponent(new \FuzeWorks\ObjectStorage\ObjectStorageComponent()); +$configurator->addComponent(new \FuzeWorks\DatabaseComponent()); +$configurator->addComponent(new \FuzeWorks\LayoutComponent()); +$configurator->addComponent(new \FuzeWorks\TracyComponent()); + +// Build the container +$container = $configurator->createContainer(); + +// And add the library +$container->libraries->addLibraryClass("forms", \FuzeWorks\Forms\Forms::class); +$container->plugins->addPlugin(new \FuzeWorks\Authentication\AuthenticationPlugin()); +return $container; \ No newline at end of file diff --git a/test/config.database.php b/test/config.database.php new file mode 100644 index 0000000..35c951e --- /dev/null +++ b/test/config.database.php @@ -0,0 +1,56 @@ + Core::getEnv("DATABASE_DRIVER", "pdo"), + 'connections' => [ + 'pdo' => [ + 'engineName' => 'pdo', + 'dsn' => Core::getEnv("PDO_DSN", Core::getEnv("PDO_DRIVER", "mysql") . ":host=" . Core::getEnv("PDO_HOST", "127.0.0.1") . ";dbname=" . Core::getEnv("PDO_DATABASE", "") . ";charset=utf8"), + 'username' => Core::getEnv("PDO_USERNAME", ""), + 'password' => Core::getEnv("PDO_PASSWORD", "") + ], + 'mongo' => [ + 'engineName' => 'mongo', + 'uri' => Core::getEnv("MONGO_URI", "mongodb://" . Core::getEnv("MONGO_HOST", "127.0.0.1") . ":" . Core::getEnv("MONGO_PORT", "27017")), + 'uriOptions' => [], + 'driverOptions' => [], + 'username' => Core::getEnv("MONGO_USERNAME"), + 'password' => Core::getEnv("MONGO_PASSWORD") + ] + ] +]; \ No newline at end of file diff --git a/test/config.objectstorage.php b/test/config.objectstorage.php new file mode 100755 index 0000000..756c3d6 --- /dev/null +++ b/test/config.objectstorage.php @@ -0,0 +1,60 @@ + 'DummyProvider', + 'DummyProvider' => [], + 'RedisProvider' => [ + // Type can be 'tcp' or 'unix' + 'socket_type' => Core::getEnv('OBJECTSTORAGE_REDIS_SOCKET_TYPE', 'tcp'), + // If socket_type == 'unix', set the socket here + 'socket' => Core::getEnv('OBJECTSTORAGE_REDIS_SOCKET', null), + // If socket_type == 'tcp', set the host here + 'host' => Core::getEnv('OBJECTSTORAGE_REDIS_HOST', '127.0.0.1'), + // And some standard settings + 'port' => Core::getEnv('OBJECTSTORAGE_REDIS_PORT', 6379), + 'password' => Core::getEnv('OBJECTSTORAGE_REDIS_PASSWORD', null), + 'timeout' => Core::getEnv('OBJECTSTORAGE_REDIS_TIMEOUT', 0), + 'db_index' => Core::getEnv('OBJECTSTORAGE_REDIS_DBINDEX', 0), + ], + 'FileProvider' => [ + // The directory where objects get stored by the FileProvider + 'storage_directory' => Core::getEnv('OBJECTSTORAGE_FILE_DIRECTORY', Core::$tempDir) + ] +]; \ No newline at end of file diff --git a/test/phpunit.xml b/test/phpunit.xml new file mode 100644 index 0000000..beea4a5 --- /dev/null +++ b/test/phpunit.xml @@ -0,0 +1,17 @@ + + + + + ../ + + + ../vendor/ + ../test/ + + + + + ./ + + + \ No newline at end of file diff --git a/test/temp/.gitignore b/test/temp/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/test/temp/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/view/view.admin.authentication.php b/view/view.admin.authentication.php new file mode 100644 index 0000000..bc35b85 --- /dev/null +++ b/view/view.admin.authentication.php @@ -0,0 +1,144 @@ +controller->listUsers(); + $this->layouts->assign("users", $users); + return $this->layouts->get("admin/users_list"); + } + + #[HiddenAttribute] + #[PermissionAttribute(["ADMIN"])] + public function view(string $userId) + { + $info = $this->controller->getDetailedUserInformation($userId); + dump($info); + exit; + + + return $userId; + } + + #[HiddenAttribute] + #[DisplayAttribute("Create User Account")] + public function create() + { + // Prepare form + /** @var Forms $forms */ + $forms = $this->libraries->get("forms"); + $form = $forms->getCachedForm(function (Form $form){ + // First prepare the identifier field + $field1 = new EmailField("identifier"); + $field1->setLabel("Email address") + ->placeholder("Email"); + $form->field($field1); + + // Add additional fields through GetRegisterFieldsEvent + /** @var GetRegisterFieldsEvent $event */ + $event = Events::fireEvent(new GetRegisterFieldsEvent()); + foreach ($event->getFields() as $field) + { + $field->setNote("eventField"); + $form->field($field); + } + + // Terms and conditions field + $field4 = new CheckboxField("sendemail"); + $field4->setLabel("Send User Registration Notification"); + $field4->checked(); + $form->field($field4); + + // Submit field + $field5 = new SubmitField("submit"); + $field5->setButtonText("Create User Account"); + $form->field($field5); + + return $form; + }, "AuthCreateUserForm", "Register"); + + // After validation, send data to the controller + if ($form->validate()) + { + // Prepare variables + $identifier = $form->getField("identifier")->getValue(); + + // Then forward the form to the controller + try { + $this->controller->register($identifier, null, [], [], true); + } catch (RegisterErrorException $e) { + $form->invalidate(true); + $form->addFormError($e->getMessage()); + } catch (RegisterWarningException $e) { + $form->invalidate(true); + $form->addFormWarning($e->getMessage()); + } + } + + $this->layouts->assign("form", $form); + return $this->layouts->get("admin/users_create"); + } + +} \ No newline at end of file diff --git a/view/view.html.authentication.php b/view/view.html.authentication.php new file mode 100755 index 0000000..966bbd3 --- /dev/null +++ b/view/view.html.authentication.php @@ -0,0 +1,414 @@ +layouts = Factory::getInstance("layouts"); + + // Get the authentication config + $this->plugin = $this->plugins->get('auth'); + $this->forms = $this->libraries->get("forms"); + + // And assign all related layout variables + $this->assignLayoutVariables(); + } + + public function login() + { + // If the current user is already logged in, redirect + $location = $this->input->get("location"); + $redirect = is_null($location) ? "/" : $location; + if ($this->controller->session->user->id !== "0") + $this->output->setHeader("Location: " . $redirect); + + // Prepare form + $form = $this->forms->getCachedForm(function (Form $form) { + // First prepare the identifier field + $field1 = new TextField("identifier"); + $field1->setLabel("Username")->placeholder("Username or email"); + $form->field($field1); + + // Then prepare the password field + $field2 = new PasswordField("password"); + $field2->setLabel("Password")->placeholder("Password"); + $form->field($field2); + + // Forgot password link + if ($this->plugin->config->forgot_password_enabled) + { + $url = $this->plugin->getAuthenticationURL(); + $forgotHtml = "Forgot password?"; + $forgot = new DecoratorField("forgot", $forgotHtml); + $form->field($forgot); + } + + // Then prepare remember me field + $field3 = new CheckboxField("remember"); + $field3->setLabel("Remember me"); + $form->field($field3); + + // Submit field + $field4 = new SubmitField("submit"); + $field4->setButtonText("Login")->addClass("btn-success")->addClass("btn-block"); + $form->field($field4); + + return $form; + }, "AuthLoginForm", "Login"); + + // Validate the form + if ($form->validate()) { + // Send data to the controller + try { + // Collect context + $context = [ + 'userAgent' => $this->input->userAgent(), + 'remoteAddr' => $this->input->server("REMOTE_ADDR") + ]; + + // Attempt to login. If it fails, will be handled by exception handlers. + $this->controller->login($form->identifier->getValue(), $form->password->getValue(), $form->remember->getValue(), $context); + + // And redirect the user + $this->output->setHeader("Location: " . $redirect); + } catch (LoginErrorException $e) { + $form->invalidate(true); + $form->addFormError($e->getMessage()); + } catch (LoginWarningException $e) { + $form->invalidate(true); + $form->addFormWarning($e->getMessage()); + } + } + + // Assign the form and display the page + $this->layouts->assign("form", $form); + $this->layouts->display("pages/auth_form_page"); + } + + public function logout() + { + $this->controller->logout(); + $location = $this->input->get("location"); + $redirect = is_null($location) ? "/" : $location; + $this->output->setHeader("Location: " . $redirect); + } + + public function register() + { + // First check if the registration is enabled + if (!$this->plugin->getConfig()->get("register_enabled")) + $this->output->setHeader("Location: /"); + + if ($this->controller->session->user->id !== "0") + $this->output->setHeader("Location: /"); + + // Prepare form + $form = $this->forms->getCachedForm(function (Form $form) { + // First prepare the identifier field + $field1 = new EmailField("identifier"); + $field1->setLabel("Email address") + ->placeholder("Email"); + $form->field($field1); + + // Add additional fields through GetRegisterFieldsEvent + /** @var GetRegisterFieldsEvent $event */ + $event = Events::fireEvent(new GetRegisterFieldsEvent()); + foreach ($event->getFields() as $field) { + $field->setNote("eventField"); + $form->field($field); + } + + // Add password fields + $field2 = new PasswordField("password1"); + $field3 = new PasswordField("password2"); + $field2->setLabel("Password") + ->placeholder("Password") + ->minLength($this->controller->authConfig->get("password_min_length")); + $field3->setLabel("Repeat password") + ->placeholder("Password") + ->minLength($this->controller->authConfig->get("password_min_length")); + $form->field($field2); + $form->field($field3); + + // Terms and conditions field + $field4 = new CheckboxField("terms"); + $field4->setLabel("Accept Terms and Conditions"); + $field4->required("You must accept the terms and conditions."); + $form->field($field4); + + // Submit field + $field5 = new SubmitField("submit"); + $field5->setButtonText("Register")->addClass("btn-success")->addClass("btn-block"); + $form->field($field5); + + // And add a password equivalency check + $form->condition([AuthenticationController::class, "registerFormCondition"]); + + return $form; + }, "AuthRegisterForm", "Register"); + + // After validation, send data to controller + if ($form->validate()) { + // Prepare variables + $identifier = $form->getField("identifier")->getValue(); + $password = $form->getField("password1")->getValue(); + $properties = []; + foreach ($form->getFields() as $field) + if ($field->getNote() === "eventField") + $properties[$field->getName()] = $field->getValue(); + + // Then forward the form to the controller + try { + $this->controller->register($identifier, $password, [], $properties); + } catch (RegisterErrorException $e) { + $form->invalidate(true); + $form->addFormError($e->getMessage()); + } catch (RegisterWarningException $e) { + $form->invalidate(true); + $form->addFormWarning($e->getMessage()); + } + } + + $this->layouts->assign("form", $form); + $this->layouts->display("pages/auth_form_page"); + } + + public function resend_verify_email() + { + // Get token + $token = $this->input->get("t"); + + // If the token is empty, report so + if (empty($token)) + $this->output->setHeader("Location: /"); + + // Generate basic form + $form = new Form("ResendVerifyEmailForm", "Resend verification email"); + $form->method(Method::GET); + $form->validate(); + + // Extract email + $email = base64_decode($token); + + // Resend verification email using the controller + try { + $this->controller->resendVerifyEmail($email); + + return "A new verification email has been sent to your email address."; + } catch (LoginErrorException $e) { + $form->invalidate(true); + $form->addFormError($e->getMessage()); + } catch (LoginWarningException $e) { + $form->invalidate(true); + $form->addFormWarning($e->getMessage()); + } + + $this->layouts->assign("form", $form); + $this->layouts->display("pages/auth_form_page"); + } + + public function verify(): string + { + // Fetch token + $token = $this->input->get("t"); + + // If the token is empty, report so + if (is_null($token)) + return "No token provided."; + + // Verify with controller + $user = $this->controller->verifyEmail($token); + if (is_null($user)) + return "Token invalid"; + + // If no password has been set, redirect the user to set a password + if (is_null($user->password)) + { + $token = $this->controller->createResetToken($user); + $this->output->setHeader("Location: " . $this->plugin->getAuthenticationURL() . "/reset_password?t=" . $token); + } + else + $this->output->setHeader("Location: " . $this->plugin->getAuthenticationURL()); + + return "Token verified"; + } + + public function forgot_password() + { + // If user is already logged in, redirect to homepage + if ($this->controller->session->user->id !== "0") + $this->output->setHeader("Location: /"); + + // Check if forgot password is disabled + if (!$this->plugin->getConfig()->get("forgot_password_enabled")) + $this->output->setHeader("Location: /"); + + // Prepare form + $form = $this->forms->getCachedForm(function (Form $form){ + // First prepare the identifier field + $field1 = new EmailField("identifier"); + $field1->setLabel("Email address") + ->placeholder("Email"); + $form->field($field1); + + // Submit field + $field4 = new SubmitField("submit"); + $field4->setButtonText("Send Account Recovery Email"); + $form->field($field4); + + return $form; + }, "AuthForgotPasswordForm", "Forgot password"); + + if ($form->validate()) + { + // Fetch identifier + $identifier = $form->getField("identifier")->getValue(); + + // Request a password reset + try { + $this->controller->forgotPassword($identifier); + } catch (LoginErrorException $e) { + $form->invalidate(true); + $form->addFormError($e->getMessage()); + } catch (LoginWarningException $e) { + $form->invalidate(true); + $form->addFormWarning($e->getMessage()); + } + } + + $this->layouts->assign("form", $form); + $this->layouts->display("pages/auth_form_page"); + } + + public function reset_password() + { + $form = $this->forms->getCachedForm(function (Form $form){ + // Add password fields + $field2 = new PasswordField("password1"); + $field3 = new PasswordField("password2"); + $field2->setLabel("Password") + ->placeholder("Password") + ->minLength($this->controller->authConfig->get("password_min_length")); + $field3->setLabel("Repeat password") + ->placeholder("Password") + ->minLength($this->controller->authConfig->get("password_min_length")); + $form->field($field2); + $form->field($field3); + + // Submit field + $field4 = new SubmitField("submit"); + $field4->setButtonText("Reset Password"); + $form->field($field4); + + // And add a password equivalency check + $form->condition([AuthenticationController::class, "registerFormCondition"]); + + return $form; + }, "AuthResetPasswordForm", "Reset Password"); + + if ($form->validate()) + { + // Fetch token + $token = $this->input->get("t"); + $password = $form->getField("password1")->getValue(); + + // Reset by controller + try { + $this->controller->resetPassword($token, $password); + $this->output->setHeader("Location: " . $this->plugin->getAuthenticationURL() . "/login"); + } catch (LoginErrorException $e) { + $form->invalidate(true); + $form->addFormError($e->getMessage()); + } catch (LoginWarningException $e) { + $form->invalidate(true); + $form->addFormWarning($e->getMessage()); + } + } + + $this->layouts->assign("form", $form); + $this->layouts->display("pages/auth_form_page"); + } + + /** + * Assign all view related variables to the layouts + * + * @return void + */ + private function assignLayoutVariables(): void + { + $config = $this->plugin->getConfig(); + $this->layouts->assign("register_enabled", $config->get("register_enabled")); + $this->layouts->assign("forgot_password_enabled", $config->get("forgot_password_enabled")); + $this->layouts->assign("auth_url", $this->plugin->getAuthenticationURL()); + } + +} \ No newline at end of file