Authentication/src/FuzeWorks/Authentication/Drivers/PdoUsersModelDriver.php

449 lines
17 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 FuzeWorks\Authentication\Drivers;
use FuzeWorks\Authentication\Exceptions\AuthenticationException;
use FuzeWorks\Authentication\Model\User;
use FuzeWorks\Authentication\UsersModelDriverInterface;
use FuzeWorks\DatabaseEngine\iDatabaseEngine;
use FuzeWorks\DatabaseEngine\PDOEngine;
use FuzeWorks\Exception\DatabaseException;
use FuzeWorks\Exception\TransactionException;
use FuzeWorks\Factory;
use FuzeWorks\Logger;
use FuzeWorks\ObjectStorage\ObjectStorageComponent;
use PDO;
class PdoUsersModelDriver implements UsersModelDriverInterface
{
/** @var PDOEngine $engine */
protected iDatabaseEngine $engine;
protected array $schema;
public function __construct(iDatabaseEngine $engine)
{
$this->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;
}
}