485 lines
18 KiB
PHP
485 lines
18 KiB
PHP
|
<?php
|
||
|
/**
|
||
|
* FuzeWorks Authentication Plugin.
|
||
|
*
|
||
|
* The FuzeWorks PHP FrameWork
|
||
|
*
|
||
|
* 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.
|
||
|
*
|
||
|
* @author i15
|
||
|
* @copyright Copyright (C) 2013 - 2023, i15. (https://i15.nl)
|
||
|
* @license https://opensource.org/licenses/MIT MIT License
|
||
|
*
|
||
|
* @since Version 1.3.0
|
||
|
*
|
||
|
* @version Version 1.3.0
|
||
|
*/
|
||
|
|
||
|
namespace Application\Controller;
|
||
|
use FuzeWorks\Authentication\Events\LoginEvent;
|
||
|
use FuzeWorks\Authentication\Events\RegisterEvent;
|
||
|
use FuzeWorks\Authentication\Exceptions\AuthenticationException;
|
||
|
use FuzeWorks\Authentication\Exceptions\InputException;
|
||
|
use FuzeWorks\Authentication\Exceptions\LoginErrorException;
|
||
|
use FuzeWorks\Authentication\Exceptions\LoginWarningException;
|
||
|
use FuzeWorks\Authentication\Exceptions\RegisterErrorException;
|
||
|
use FuzeWorks\Authentication\Exceptions\RegisterWarningException;
|
||
|
use FuzeWorks\Authentication\Model\Session;
|
||
|
use FuzeWorks\Authentication\Model\User;
|
||
|
use FuzeWorks\ConfigORM\ConfigORM;
|
||
|
use FuzeWorks\Controller;
|
||
|
use FuzeWorks\Authentication\AuthenticationPlugin;
|
||
|
use FuzeWorks\Events;
|
||
|
use FuzeWorks\Exception\FactoryException;
|
||
|
use FuzeWorks\Exception\LayoutException;
|
||
|
use FuzeWorks\Factory;
|
||
|
use FuzeWorks\Forms\Form;
|
||
|
use FuzeWorks\Layout;
|
||
|
use FuzeWorks\Logger;
|
||
|
use FuzeWorks\Mailer\PHPMailerWrapper;
|
||
|
use FuzeWorks\ObjectStorage\ObjectStorageComponent;
|
||
|
use PHPMailer\PHPMailer\Exception;
|
||
|
use Psr\SimpleCache\CacheInterface;
|
||
|
|
||
|
class AuthenticationController extends Controller
|
||
|
{
|
||
|
|
||
|
protected AuthenticationPlugin $plugin;
|
||
|
public Session $session;
|
||
|
public ConfigORM $authConfig;
|
||
|
|
||
|
public function __construct()
|
||
|
{
|
||
|
parent::__construct();
|
||
|
$this->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 <a href='$url'>here</a> 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;
|
||
|
}
|
||
|
|
||
|
}
|