Authentication/controller/controller.authentication.php

485 lines
18 KiB
PHP
Executable File

<?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;
}
}