Регистрация и авторизация пользователей на ООП в 2024 году
Пример алгоритма авторизации и регистрации
В предыдущей статье мы создали очень простой (с точки зрения исполнения) сценарий авторизации и регистрации. Теперь этот сценарий мы переделаем, поставив следующие задачи:
- Процедурный стиль меняем на объектно-ориентированный.
- Подключение к СУБД будем осуществлять через созданный для этих целей класс.
- Доступ к информации о пользователе будем получать через объекты.
Если кому-то лень читать весь текст статьи, и просто нужно знать, что делает весь этот, для вас есть короткий путь:
1. Классы и объекты для работы сценария
Все наши пользовательские функции мы подключали из файла bootstrap.php
через выражение require_once('bootstrap.php');
. Теперь таким же образом будем подключать и наши классы. С одним нюансом. Мы будем подключать не каждый класс, который далее создадим, а лишь один — класс ядра Core
. А уже с его помощью автоматически будут подключаться классы, которые мы будем вызывать.
Реализуем этот алгоритм мы с помощью функции spl_autoload_register()
. И смотрим на код нового файла bootstrap.php
и код файла classes/core/core.php
.
Файл bootstrap.php
<?php
// Создаем константу, в которой будет храниться путь к директории сайта на сервере
define('SITE_FOLDER', dirname(__FILE__) . DIRECTORY_SEPARATOR);
/**
* Создаем константу, которая предотвратит прямой доступ к файлам наших классов через веб-сервер
* Выражением defined('MYSITE') || exit('Прямой доступ к файлу запрещен') мы этот доступ и ограничим
*/
define('MYSITE', TRUE);
// Для запрета выполнения ini_set в сценариях нужно установить в TRUE
define('DENY_INI_SET', FALSE);
if (!defined('DENY_INI_SET') || !DENY_INI_SET)
{
ini_set('display_errors', 1);
}
// Подключаем класс ядра
require_once(SITE_FOLDER . "classes" . DIRECTORY_SEPARATOR . "core" . DIRECTORY_SEPARATOR . "core.php");
// Инициализируем ядро системы
Core::init();
?>
Из комментариев внутри файла всё должно быть понятно и очевидно.
Файл classes/core/core.php
<?php
defined('MYSITE') || exit('Прямой доступ к файлу запрещен');
class Core
{
// Статус инициализации ядра системы
static private $_init = FALSE;
// Путь к месту хранения файлов с определением пользовательских классов
static public $classesPath = NULL;
/**
* Возвращает статус инициализации ядра системы
* @return boolean TRUE | FALSE
*/
static public function isInit()
{
return static::$_init;
}
/**
* Инициализирует ядро системы
*/
static public function init()
{
// Если ядро уже было инициализировано, ничего не делаем
if (static::isInit())
{
return TRUE;
}
// Устанавливаем путь к месту хранения файлов с определениями пользовательских классов на сервере
static::setClassesPath();
// Установим кодировку UTF-8
mb_internal_encoding('UTF-8');
}
/**
* Устанавливает пути к месту хранения файлов с определениями пользовательских классов на сервере
*/
static public function setClassesPath()
{
static::$classesPath = SITE_FOLDER . "classes" . DIRECTORY_SEPARATOR;
}
}
?>
Здесь также из комментариев внутри файла всё должно быть понятно и очевидно. Теперь, почему бы сразу же не подключиться к СУБД при инициализации ядра? В конец статического метода Core::init()
допишем код Core_Database::instance()->connect();
.
Почему именно так? Потому что класс подключения к СУБД сделаем статическим, чтобы не плодить множество подключений. Выражение Core_Database
даёт понять интерпретатору PHP, что объявление класса нужно искать в файле classes/core/database.php
. Но если всё сделать так, и попробовать просмотреть страницу, мы получим ошибку: Fatal error: Uncaught Error: Class "Core_Database" not found in...
.
И это логично. Интерпретатор, пока что, не в курсе, где искать этот класс. Для этого нам и понадобится функция spl_autoload_register()
. Дополняем код статического метода Core::init()
, и дописываем в него частный статический метод Core::_autoload()
и ещё кое-чего. Подключение к СУБД заключаем в блок try catch
.
<?php
defined('MYSITE') || exit('Прямой доступ к файлу запрещен');
class Core
{
/**** .......Дополняем код класса Core ....... */
// Путь к месту хранения файлов с определением пользовательских классов
static public $classesPath = NULL;
// Свойство, в котором будет храниться информация об автозагруженных классах
static private $_autoloadCache = [];
/**
* Автозагрузка определений классов
*/
private static function _autoload($class)
{
// Если ранее класс уже был загружен, ничего не делаем
if (isset(static::$_autoloadCache[$class]))
{
return static::$_autoloadCache[$class];
}
$return = FALSE;
// Получаем путь к файлу на сервере с определением класса
$sPath = static::$classesPath . static::getClassPath($class);
// Если такой файл на диске есть, подключаем его
if (is_file($sPath))
{
include($sPath);
$return = TRUE;
}
static::$_autoloadCache[$class] = $return;
return $return;
}
/*** ................ */
static public function init()
{
/*** .................. */
// Инициализируем наши пользовательские классы
static::registerCallbackFunction();
/** .................... */
try {
// Устанавливаем соединение с СУБД
Core_Database::instance()->connect();
}
// В случае ошибки останавливаем работу сценария
catch (Exception $e)
{
echo "<p>{$e->getMessage()}</p>";
die();
}
}
/** ..................... */
/**
* Регистрирует реализации пользовательских классов
*/
static public function registerCallbackFunction()
{
spl_autoload_register(array('Core', '_autoload'));
}
/**
* Получает путь к файлу на сервере с определением класса
*/
static public function getClassPath($class) : string
{
// Разделяем имя искомого класса на составляющие
$aClassName = explode("_", strtolower($class));
// Последний элемент полученного массива будет являться именем файла
$sFileName = array_pop($aClassName);
// Собираем путь к файлу
// Если имя класса было передано без символа разделителя _
$sPath = empty($aClassName)
? $sFileName . DIRECTORY_SEPARATOR
: implode(DIRECTORY_SEPARATOR, $aClassName) . DIRECTORY_SEPARATOR;
// Добавляем имя файла
$sPath .= $sFileName . ".php";
// Возвращаем путь к файлу
return $sPath;
}
}
?>
Теперь код класса Core
выглядит так. Но мы всё равно имеем ошибку: Fatal error: Uncaught Error: Class "Core_Database" not found in...
. Конечно же, ведь нам нужно создать файл classes/database.php
, и объявить в нем класс Core_Database
.
2. Класс Core_Database
для работы с СУБД
Но сначала давайте посмотрим на немного измененный файл database.php
с параметрами подключения к СУБД, который теперь перенесен в каталог classes/core/config/
.
<?php
return [
'pdo' => [
'host' => 'localhost',
'user' => 'demo',
'password' => 'fy)s@6!9cJ*g!xvZ',
'dbname' => 'demo_auth_reg_1'
]
];
?>
Здесь в верхнем уровне массива имеется только ключ pdo
. Для подключения к СУБД MySQL через PDO и указаны параметры. Если бы мы хотели работать через mysqli
или иной драйвер, нужно было бы аналогично указать для него конфигурацию. Теперь смотрим на код класса Core_Database
.
<?php
// Запрещаем прямой доступ к файлу
defined('MYSITE') || exit('Прямой доступ к файлу запрещен');
/**
* Абстраактый класс
* Обеспечивает подключение к СУБД
* Класс является абстрактным, так как оставляет пользователю право определять, через какой
* модуль будет реализовано взаимодействие с СУБД
*
* Реализация такого взаимодействия должна быть написана в дочерних классах
* Например, вызов Core_Database::instance('mysql') вернет экземпляр класса Core_Database_Mysql
*/
abstract class Core_DataBase
{
// Экземпляр класса
static protected $_instance = [];
// Параметры подключение к СУБД
protected $_config = [];
// Здесь будет храниться последний выполненный запрос к БД
protected $_lastQuery = NULL;
/** Абстрактные методы не имеют реализации. Они должны быть реализованы в дочерних классах */
// Подключение к СУБД
abstract public function connect();
// Отключение от СУБД
abstract public function disconnect();
// Установка кодировки соединения
abstract public function setCharset($charset);
// Экранирование данных
abstract public function escape($unescapedString);
/**
* Защищенный конструктор класса, который невозможно вызвать откуда-либо, кроме как из самого класса
* Получает параметры подключения к СУБД
*/
protected function __construct(array $config)
{
$this->setConfig($config);
}
/**
* Возвращает и при необходимости создает экзепляр класса
* @return object Core_Database
*/
static public function instance(string $name = 'pdo')
{
// Если экземпляр класса не был создан
if (empty(static::$_instance[$name]))
{
// Получаем параметры подключения к СУБД
$aConfig = Core::$config->get('core_database', array());
if (!isset($aConfig[$name]))
{
throw new Exception('Для запрошенного типа подключения к СУБД нет конфигурации');
}
// Определяем, какой именно класс будем использовать
// Он будет именоваться Core_Database_{$name}, например Core_Database_Pdo или Core_Database_Mysql
$driver = __CLASS__ . "_" . ucfirst($name);
static::$_instance[$name] = new $driver($aConfig[$name]);
}
// Возвращем вызову экземпляр класса для работы с СУБД
return static::$_instance[$name];
}
public function setConfig(array $config)
{
$this->_config = $config + [
'host' => 'localhost',
'user' => '',
'password' => '',
'dbname' => NULL,
'charset' => 'utf8'
];
return $this;
}
}
?>
Класс является абстрактным. Основные методы взаимодействия с СУБД будут реализованы в дочерних классах. В этой статье речь пойдет о работе через PDO, а значит — Core_Database_Pdo
.
Конструктор класса Core_Database
нельзя вызывать напрямую. Вызывается статический метод Core_Database::instance()
, которому можно передать наименование драйвера для взаимодействия с СУБД. В случае отсутствия конфигурации для такого драйвера будет создано исключение, которое остановит работу сценария. По умолчанию используется драйвер PDO.
Здесь же в методе Core_Database::inctance()
мы видим вызов статического метода Core_Config::instance()
. А этот класс ранее не упоминался, так как не был нужен. Именно он получает параметры конфигурации, хранящиеся на сервере в отдельных файлах. В этом случае запрашиваются параметры конфигурации для подключения к СУБД.
Посмотрим, как выглядит класс Core_Config
.
<?php
// Запрещаем прямой доступ к файлу
defined('MYSITE') || exit('Прямой доступ к файлу запрещен');
/**
* Возвращает параметры конфигурации чего-либо
*/
class Core_Config
{
// Экземпляр класса
static protected $_instance = NULL;
// Загруженные параметры
protected $_values = [];
/**
* Возвращает последний компонент имени из указанного пути
* https://www.php.net/manual/ru/function.basename
* @param string $name
* @return string
*/
protected function _correctName($name) : string
{
return basename(strtolower($name));
}
/**
* Возвращает и при необходимости создает экзепляр класса
* @return object Core_Config
*/
static public function instance()
{
// Если экземпляр класса ранее уже был создан, его и возвращаем
if (!is_null(static::$_instance))
{
return static::$_instance;
}
// Создаем экземпляр класса, и сохраняем его здесь же
static::$_instance = new static();
// Возвращем вызову экземпляр класса
return static::$_instance;
}
/**
* Получает параметры чего-либо из файла на сервере по запрошенному имени файла, к примеру Core_Database подключит classes/core/config/database.php
* @param string $name
* @return mixed Config | NULL
*/
public function get($name)
{
$name = $this->_correctName($name);
// Если ранее не запрашивались параметры с таким именем
if (!isset($this->_values[$name]))
{
// Получаем путь к нужному файлу
$sPath = $this->getPath($name);
// Если такой путь существует
$this->_values[$name] = is_file($sPath)
? require_once($sPath)
: NULL;
}
return $this->_values[$name];
}
/**
* Получает путь к файлу с параметрами
* @param string $name
* @return string
*/
public function getPath($name)
{
// Разбираем строку с переданным именем на составляющие
$aConfig = explode('_', $name);
// Последним элементом будет имя файла
$sFileName = array_pop($aConfig);
// Собираем путь к файлу
$path = Core::$classesPath;
$path .= implode(DIRECTORY_SEPARATOR, $aConfig) . DIRECTORY_SEPARATOR;
$path .= 'config' . DIRECTORY_SEPARATOR . $sFileName . '.php';
return $path;
}
}
?>
Файл снабжен достаточными комментариями, должно быть понятно, что именно он делает. В ответ на вызов он возвращает массив, если запрошенные параметры имелись.
В итоге Core_Database
вернет экземпляр класса для работы с СУБД через запрошенный драйвер (в нашем случае это драйвер PDO по умолчанию). Пришла пора посмотреть на код класса Core_Database_Pdo
.
3. Работа с СУБД через PDO
<?php
// Запрещаем прямой доступ к файлу
defined('MYSITE') || exit('Прямой доступ к файлу запрещен');
class Core_Database_Pdo extends Core_DataBase
{
public function connect()
{
}
public function disconnect()
{
}
public function setCharset()
{
}
public function escape()
{
}
}
?>
Пока что здесь просто объявлены методы, которые в родительском классе Core_Database
указаны абстрактными. Для начала, обеспечим подключение к СУБД.
<?php
// Запрещаем прямой доступ к файлу
defined('MYSITE') || exit('Прямой доступ к файлу запрещен');
class Core_Database_Pdo extends Core_DataBase
{
/** Результат выполнения запроса
* @var resource | NULL
*/
protected $_result = NULL;
/**
* Возвращает активное подключение к СУБД
* @return resource
*/
public function getConnection()
{
$this->connect();
return $this->_connection;
}
/**
* Подключается к СУБД
* @return boolean TRUE | FALSE
*/
public function connect()
{
// Если подключение уже выполнено, ничего не делаем
if ($this->_connection)
{
return TRUE;
}
$this->_config += array(
'driverName' => 'mysql',
'attr' => array(
PDO::ATTR_PERSISTENT => FALSE
)
);
// Подключаемся к СУБД
try {
// Адрес сервера может быть задан со значением порта
$aHost = explode(":", $this->_config['host']);
// Формируем строку источника подключения к СУБД
$dsn = "{$this->_config['driverName']}:host={$aHost[0]}";
// Если был указан порт
!empty($aHost[1])
&& $dsn .= ";port={$aHost[1]}";
// Указываем имя БД
!is_null($this->_config['dbname'])
&& $dsn .= ";dbname={$this->_config['dbname']}";
// Кодировка
$dsn .= ";charset={$this->_config['charset']}";
// Подключаемся, и сохраняем подключение в экземпляре класса
$this->_connection = new PDO(
$dsn,
$this->_config['user'],
$this->_config['password'],
$this->_config['attr']
);
// В случае ошибок будет брошено исключение
$this->_connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
}
catch (PDOException $e)
{
throw new Exception("<p><strong>Ошибка при подключении к СУБД:</strong> {$e->getMessage()}</p>");
}
// Если ничего плохого не произошло
return TRUE;
}
/**
* Закрывает соединение с СУБД
* @return self
*/
public function disconnect()
{
$this->_connection = NULL;
return $this;
}
/**
* Устанавливает кодировку соединения клиента и сервера
* @param string $charset указанное наименование кодировки, которое примет СУБД
*/
public function setCharset($charset)
{
$this->connect();
$this->_connection->exec('SET NAMES ' . $this->quote($charset));
return $this;
}
/**
* Экранирование строки для использования в SQL-запросах
* @param string $unescapedString неэкранированная строка
* @return string Экранированная строка
*/
public function escape($unescapedString) : string
{
$this->connect();
$unescapedString = addcslashes(strval($unescapedString), "\000\032");
return $this->_connection->quote($unescapedString);
}
}
?>
Пока что, этого достаточно. Теперь, если обновить вашу страницу сайта, ошибок быть не должно. А если в вашей таблице `users`
уже имеются записи, и вы напишете следующее:
print "<pre>";
var_dump(Core_Database::instance()->getConnection()->query("SELECT * FROM `users`")->rowCount());
print "</pre>'";
...то вы увидите количество записией в таблице: int(2)
, к примеру.
Сколько бы раз вы не вызывали Core_Database::instance()
, вам всегда в ответ вернется один и тот же экземпляр соединения с СУБД для того драйвера, что вы указали.
Давайте же теперь перейдем к вопросу о том, как нам работать с пользователями.
4. Работа с пользователями сайта
Начнем с того, что нам нужно как-то определять статус авторизации посетителя. Для любых взаимодействий с пользователями мы будем использовать, прежде всего, класс User
.
<?php
// Запрещаем прямой доступ к файлу
defined('MYSITE') || exit('Прямой доступ к файлу запрещен');
/**
* Класс пользователя клиентского раздела сайта
* Объект класса может создаваться как для существующих пользователей, так и для несуществующих с целью их регистрации
*/
class User
{
/**
* Информация о пользователе (логин, пароль, и т.п.)
* @var array
*/
protected $_data = [];
/**
* Разрешенные для чтения поля таблицы пользователей
* @var array
*/
protected $_allowedProperties = [
'login',
'email',
'registration_date',
'active'
];
/**
* Запрещенные для чтения поля таблицы пользователей
* @var array
*/
protected $_forbiddenProperties = [
'password',
'deleted'
];
/**
* Конструктор класса
* @param int $id = 0
*/
public function __construct(int $id = 0)
{
}
/**
* Получает информацию об авторизованном пользователе
* @return mixed self | NULL если пользователь не авторизован
*/
public function getCurrent()
{
$return = NULL;
/**
* Информация о пользователе, если он авторизован, хранится в сессии
* Поэтому нужно просто проверить, имеется ли там нужная информация
* Если в сессии её нет, значит пользователь не авторизован
*/
(!empty($_SESSION['login'])
&& !empty($_SESSION['email'])
&& !empty($_SESSION['password']))
&& $this->_getUserData();
// Возвращаем результат вызову
return $return;
}
}
?>
Этого кода достаточно для того, чтобы примерно понять, как необходимо взаимодействовать с экземплярами класса User
, и как это будет работать. Если сейчас выполнить следующий код на страницах сайта:
print "<pre>";
var_dump((new User())->getCurrent());
print "</pre>'";
...то ответ будет получен такой: NULL
.
То есть, еслли мы напишем что-то вроде:
print "<p>Вы " . ((is_null((new User())->getCurrent())) ? " не" : "") . " авторизованы на сайте</p>";
...то получим на странице текст: Вы не авторизованы на сайте
. И уже основываясь на этом можем управлять доступом пользователей к ресурсам сайта. На данном этапе код класса User
далеко не оптимален. И предназначение его не совсем такое, каким должно бы быть. Совершенствовать этот класс будем в будущем. А сейчас, пока что, обеспечим авторизацию и регистрацию через объектно-ориентированный стиль.
Итак, пользователь не авторизован. Отправляем его на страницу авторизации, там он заполняет форму, обработку которой мы писали в файле /users.php
, и теперь мы эту обработку изменим. Начнем с регистрации пользователя. Для начала, посмотрим, как теперь выглядит страница /users.php
.
<?php
// Подключаем файл с основными функциями
require_once('bootstrap.php');
// Здесь будет храниться результат обработки форм
$aFormHandlerResult = [];
// Если была заполнена форма
// Здесь вместо прежних $_POST['sign-in'] и $_POST['sign-up'] мы используем константы класса USER
// Это позволяет избежать ошибок в коде разных сценариев и страниц, когда дело касается одних и тех же сущностей
if (!empty($_POST[User::ACTION_SIGNIN]) || !empty($_POST[User::ACTION_SIGNUP]))
{
// Создаем объект класса User
$oUser = new User();
// Обрабатываем пользовательские данные
// Теперь здесь мы не определяем авторизуется или регистрируется пользователь.
// Просто вызываем метод User::processUserData(), который это и определит
$aFormHandlerResult = $oUser->processUserData($_POST);
}
// Создаем объект класса User авторизованного пользователя
// Если пользователь не авторизован, новый объект будет иметь значение NULL
$oCurrentUser = (new User())->getCurrent();
// Если пользователь желает разлогиниться
if (!empty($_GET['action']) && $_GET['action'] == USER::ACTION_LOGOUT && !is_null($oCurrentUser))
{
// Завершаем сеанс пользователя
$oCurrentUser->unsetCurrent();
}
// Если пользователь вводил данные, покажем их ему
$sLogin = (!empty($aFormHandlerResult['data']['login'])) ? htmlspecialchars_decode($aFormHandlerResult['data']['login']) : "";
$sEmail = (!empty($aFormHandlerResult['data']['email'])) ? htmlspecialchars_decode($aFormHandlerResult['data']['email']) : "";
// Остальной код страницы остался практически тем же
?>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Личный кабинет пользователя. Авторизация/регистрация. PHP+MySQL+JavaScript,jQuery</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js" integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.min.js" integrity="sha384-BBtl+eGJRgqQAUMxJ7pMwbEyER4l1g+O15P+16Ep7Q9Q+zqX6gSbd85u4mG4QzX+" crossorigin="anonymous"></script>
<script>
(() => {
'use strict'
document.addEventListener('DOMContentLoaded', (event) => {
// Fetch all the forms we want to apply custom Bootstrap validation styles to
const forms = document.querySelectorAll('.needs-validation');
if (forms)
{
// Loop over them and prevent submission
Array.from(forms).forEach(form => {
form.addEventListener('submit', event => {
if (!form.checkValidity()) {
event.preventDefault();
event.stopPropagation();
}
form.classList.add('was-validated')
}, false);
});
}
});
})();
</script>
</head>
<body>
<div class="container-md container-fluid">
<h1 class="my-3">Личный кабинет пользователя</h1>
<p>
Сейчас вы <?php echo (!is_null($oCurrentUser)) ? "авторизованы" : "не авторизованы";?> на сайте. <br />
<?php
if (!is_null($oCurrentUser))
{
echo "<p>Ваш логин: <strong>" . $oCurrentUser()->login . "</strong>.</p>";
echo "<p>Вы можете <a href='/users.php?action=exit'>выйти</a> из системы.</p>";
}
// Если пользователь вышел из системы
elseif (!empty($_GET['action']))
{
if ($_GET['action'] == USER::ACTION_LOGOUT)
{
echo "Вы успешно вышли из системы";
}
}
// Перенаправим пользователя на главную страницу при успешной авторизации/регистрации
if (!empty($aFormHandlerResult['success']) && $aFormHandlerResult['success'] === TRUE)
{
?>
<script>
setTimeout(() => {
window.location.href="/";
}, 3000);
</script>
<?php
}
?>
</p>
<?php
// Блок с формами авторизации/регистрации показываем только неавторизованным пользователям
if (!!is_null($oCurrentUser))
{
?>
<ul class="nav nav-tabs my-3" id="user-action-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link <?php print (empty($aFormHandlerResult) || $aFormHandlerResult['type'] == User::ACTION_SIGNIN) ? "active" : "";?>" id="user-auth-tab" data-bs-toggle="tab" data-bs-target="#user-auth-tab-pane" type="button" role="tab" aria-controls="user-auth-tab-pane" aria-selected="true">Авторизация</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link <?php print (!empty($aFormHandlerResult) && $aFormHandlerResult['type'] == User::ACTION_SIGNUP) ? "active" : "";?>" id="user-reg-tab" data-bs-toggle="tab" data-bs-target="#user-reg-tab-pane" type="button" role="tab" aria-controls="user-reg-tab-pane" aria-selected="false">Регистрация</button>
</li>
</ul>
<div class="tab-content bg-light" id="user-action-tabs-content">
<div class="tab-pane fade px-3 <?php print (empty($aFormHandlerResult) || $aFormHandlerResult['type'] == User::ACTION_SIGNIN) ? "show active" : "";?>" id="user-auth-tab-pane" role="tabpanel" aria-labelledby="user-auth-tab-pane" tabindex="0">
<div class="row">
<div class="col-xxl-8 col-md-10 rounded text-dark p-3">
<!-- Блок для сообщений о результате обработки формы -->
<?php
// Если была обработана форма
if (!empty($aFormHandlerResult) && $aFormHandlerResult['type'] == User::ACTION_SIGNIN)
{
$sClass = match($aFormHandlerResult['success']) {
TRUE => "my-3 alert alert-success",
FALSE => "my-3 alert alert-danger"
};
?>
<div class="<?=$sClass?>">
<?=$aFormHandlerResult['message'];?>
</div>
<?php
}
?>
<h3 class="my-3">Авторизация пользователя</h3>
<form id="form-auth" class="needs-validation" name="form-auth" action="/users.php" method="post" autocomplete="off" novalidate>
<div class="my-3">
<label for="auth-login">Логин или электропочта:</label>
<input type="text" id="auth-login" name="login" class="form-control" placeholder="Ваши логин или электропочта" required value="<?php print (empty($aFormHandlerResult) || $aFormHandlerResult['type'] == User::ACTION_SIGNIN) ? $sLogin : "";?>" />
<div class="error invalid-feedback" id="auth-login_error"></div>
<div class="help form-text" id="auth-login_help">Напишите логин или адрес электропочты, указанные вами при регистрации на сайте</div>
</div>
<div class="my-3">
<label for="auth-password">Пароль:</label>
<input type="password" id="auth-password" name="password" class="form-control" placeholder="Напишите ваш пароль" required />
<div class="error invalid-feedback" id="auth-password_error"></div>
<div class="help form-text" id="auth-password_help">Напишите пароль, указанный вами при регистрации на сайте</div>
</div>
<div class="my-3">
<input type="submit" class="btn btn-primary" id="auth-submit" name="<?=USER::ACTION_SIGNIN;?>" value="Войти" />
</div>
</form>
</div>
</div>
</div>
<div class="tab-pane fade px-3 <?php print (!empty($aFormHandlerResult) && $aFormHandlerResult['type'] == User::ACTION_SIGNUP) ? "show active" : "";?>" id="user-reg-tab-pane" role="tabpanel" aria-labelledby="user-reg-tab-pane" tabindex="0">
<div class="row">
<div class="col-xxl-8 col-md-10 rounded text-dark p-3">
<!-- Блок для сообщений о результате обработки формы -->
<?php
// Если была обработана форма
if (!empty($aFormHandlerResult) && $aFormHandlerResult['type'] == User::ACTION_SIGNUP)
{
$sClass = match($aFormHandlerResult['success']) {
TRUE => "my-3 alert alert-success",
FALSE => "my-3 alert alert-danger"
};
?>
<div class="<?=$sClass?>">
<?=$aFormHandlerResult['message'];?>
</div>
<?php
}
?>
<h3 class="my-3">Регистрация пользователя</h3>
<form id="form-reg" class="needs-validation" name="form-reg" action="/users.php" method="post" autocomplete="off" novalidate>
<div class="row gy-2 mb-3">
<div class="col-md">
<label for="reg-login">Логин:</label>
<input type="text" id="reg-login" name="login" class="form-control" placeholder="Ваш логин для регистрации" required value="<?php print (!empty($aFormHandlerResult) && $aFormHandlerResult['type'] == User::ACTION_SIGNUP) ? $sLogin : "";?>" />
<div class="error invalid-feedback" id="reg-login_error">Логин введен неверно</div>
<div class="help form-text" id="reg-login_help">Напишите логин для регистрации на сайте</div>
</div>
<div class="col-md">
<label for="reg-email">Электропочта:</label>
<input type="email" id="reg-email" name="email" class="form-control" placeholder="Ваш адрес электропочты" required value="<?php print (!empty($aFormHandlerResult) && $aFormHandlerResult['type'] == User::ACTION_SIGNUP) ? $sEmail : "";?>" />
<div class="error invalid-feedback" id="reg-email_error"></div>
<div class="help form-text" id="reg-email_help">Напишите ваш действующий адрес электропочты для регистрации на сайте</div>
</div>
</div>
<div class="row gy-2 mb-3">
<div class="col-md">
<label for="reg-password">Пароль:</label>
<input type="password" id="reg-password" name="password" class="form-control" placeholder="Напишите ваш пароль" required />
<div class="error invalid-feedback" id="reg-password_error"></div>
<div class="help form-text" id="reg-password_help">Напишите пароль, для регистрации на сайте</div>
</div>
<div class="col-md">
<label for="reg-password2">Подтверждение пароля:</label>
<input type="password" id="reg-password2" name="password2" class="form-control" placeholder="Повторите ваш пароль" required />
<div class="error invalid-feedback" id="reg-password2_error"></div>
<div class="help form-text" id="reg-password2_help">Повторите пароль для его подтверждения и исключения ошибки</div>
</div>
</div>
<div class="my-3 d-flex">
<input type="submit" class="btn btn-success me-3" id="reg-submit" name="<?=USER::ACTION_SIGNUP;?>" value="Зарегистрироваться" />
<input type="reset" class="btn btn-danger" id="reg-reset" name="reset" value="Очистить" />
</div>
</form>
</div>
</div>
</div>
</div>
<?php
}
?>
</div>
</body>
</html>
Заметно изменилась лишь часть сценария, которая выполнялась до кода HTML-страницы. В комментариях всё описано. Теперь давайте посмотрим, что делает метод User::processUserData()
, да и на сам класс User
.
5. Класс User
<?php
// Запрещаем прямой доступ к файлу
defined('MYSITE') || exit('Прямой доступ к файлу запрещен');
/**
* Класс пользователя клиентского раздела сайта
* Объект класса может создаваться как для существующих пользователей, так и для несуществующих с целью их регистрации
*/
class User
{
/**
* Информация о пользователе (логин, пароль, и т.п.)
* @var array
*/
protected $_data = [];
/**
* Разрешенные для чтения и записи поля таблицы пользователей
* @var array
*/
protected $_allowedProperties = [
'id',
'login',
'email',
'registration_date',
'active'
];
/**
* Запрещенные для чтения и записи поля таблицы пользователей
* @var array
*/
protected $_forbiddenProperties = [
'password',
'deleted'
];
/**
* Имена полей таблицы в БД
* @var array
*/
protected $_columnNames = [];
/**
* Строка для действия регистрации
* @param const
*/
public const ACTION_SIGNUP = 'sign-up';
/**
* Строка для действия регистрации
* @param const
*/
public const ACTION_SIGNIN = 'sign-in';
/**
* Строка для действия выхода из системы
* @param const
*/
public const ACTION_LOGOUT = 'exit';
/**
* Получает данные о пользователе сайте
*/
protected function _getUserData()
{
}
/**
* Конструктор класса
* @param int $id = 0
*/
public function __construct(int $id = 0)
{
// Сразу же из базы данных получаем перечень имен полей таблицы
$this->getColumnNames();
}
/**
* Получает перечень имен полей таблицы из БД
* @return self
*/
public function getColumnNames()
{
$oCore_Database = Core_Database::instance();
$this->_columnNames = $oCore_Database->getColumnNames('users');
return $this;
}
/**
* Получает информацию об авторизованном пользователе
* @return mixed self | NULL если пользователь не авторизован
*/
public function getCurrent()
{
$return = NULL;
/**
* Информация о пользователе, если он авторизован, хранится в сессии
* Поэтому нужно просто проверить, имеется ли там нужная информация
* Если в сессии её нет, значит пользователь не авторизован
*/
(!empty($_SESSION['login'])
&& !empty($_SESSION['email'])
&& !empty($_SESSION['password']))
&& $this->_getUserData();
// Возвращаем результат вызову
return $return;
}
/**
* Обрабатывает данные, которыми пользователь заполнил форму
* @param array $post
*/
public function processUserData(array $post)
{
$aReturn = [
'success' => FALSE,
'message' => "При обработке формы произошла ошибка",
'data' => [],
'type' => static::ACTION_SIGNIN
];
// Если не передан массив на обработку, останавливаем работу сценария
if (empty($post))
{
die("<p>Для обработки пользовательских данных формы должен быть передан массив</p>");
}
// Если в массиве отсутствуют данные о типе заполненной формы, останавливаем работу сценария
if (empty($post[static::ACTION_SIGNIN]) && empty($post[static::ACTION_SIGNUP]))
{
die("<p>Метод <code>User::processUserData()</code> должен вызываться только для обработки данных из форм авторизации или регистрации</p>");
}
// Флаг регистрации нового пользователя
$bRegistrationUser = !empty($post[static::ACTION_SIGNUP]);
// Логин и пароль у нас должны иметься в обоих случаях
$sLogin = strval(htmlspecialchars(trim($_POST['login'])));
$sPassword = strval(htmlspecialchars(trim($_POST['password'])));
// А вот электропочта и повтор пароля будут только в случае регистрации
if ($bRegistrationUser)
{
$aReturn['type'] = static::ACTION_SIGNUP;
$sEmail = strval(htmlspecialchars(trim($_POST['email'])));
$sPassword2 = strval(htmlspecialchars(trim($_POST['password2'])));
// Проверяем данные на ошибки
if ($this->validateEmail($sEmail))
{
// Логин и пароли не могут быть пустыми
if (empty($sLogin))
{
$aReturn['message'] = "Поле логина не было заполнено";
$aReturn['data'] = $post;
}
elseif (empty($sPassword))
{
$aReturn['message'] = "Поле пароля не было заполнено";
$aReturn['data'] = $post;
}
// Пароли должны быть идентичны
elseif ($sPassword !== $sPassword2)
{
$aReturn['message'] = "Введенные пароли не совпадают";
$aReturn['data'] = $post;
}
// Если логин не уникален
elseif ($this->isValueExist($sLogin, 'login'))
{
$aReturn['message'] = "Указанный вами логин ранее уже был зарегистрирован";
$aReturn['data'] = $post;
}
// Если email не уникален
elseif ($this->isValueExist($sEmail, 'email'))
{
$aReturn['message'] = "Указанный вами email ранее уже был зарегистрирован";
$aReturn['data'] = $post;
}
// Если все проверки прошли успешно, можно регистрировать пользователя
else
{
/**
* Согласно документации к PHP, мы для подготовки пароля пользователя к сохранению в БД
* будем использовать функцию password_hash() https://www.php.net/manual/ru/function.password-hash
* Причем, согласно рекомендации, начиная с версии PHP 8.0.0 не нужно указывать соль для пароля. Значит, и не будем
*/
// Хэшируем пароль
$sPassword = password_hash($sPassword, PASSWORD_BCRYPT);
$this->login = $sLogin;
$this->password = $sPassword;
$this->email = $sEmail;
$this->save();
if (Core_Database::instance()->lastInsertId())
{
$aReturn['success'] = TRUE;
$aReturn['message'] = "Пользователь с логином <strong>{$sLogin}</strong> и email <strong>{$sEmail}</strong> успешно зарегистрирован.";
$aReturn['data']['user_id'] = Core_Database::instance()->lastInsertId();
}
}
}
else
{
$aReturn['message'] = "Указанное значение адреса электропочты не соответствует формату";
$aReturn['data'] = $post;
}
}
return $aReturn;
}
/**
* Проверяет правильность адреса электронной почты
* @param string $email
* @return TRUE | FALSE
*/
public function validateEmail(string $email) : bool
{
return filter_var($email, FILTER_VALIDATE_EMAIL);
}
/**
* Проверяет уникальность логина в системе
* @param string $value
* @param string $field
* @return TRUE | FALSE
*/
public function isValueExist($value, $field) : bool
{
// Подключаемся к СУБД
$oCore_Database = Core_Database::instance();
$oCore_Database->clearSelect()
->clearWhere()
->select()
->from('users')
->where($field, '=', $value)
->where('deleted', '=', 0);
// Выполняем запрос
try {
$stmt = $oCore_Database->execute();
}
catch (PDOException $e)
{
die("<p><strong>При выполнении запроса произошла ошибка:</strong> {$e->getMessage()}</p>");
}
// Если логин уникален, в результате запроса не должно быть строк
return $oCore_Database->getRowCount() !== 0;
}
/**
* Магический метод для установки значений необъявленных свойств класса
* @param string $property
* @param mixed $value
*/
public function __set(string $property, $value)
{
$this->_data[$property] = $value;
}
/**
* Сохраняет информацию о пользователе
* @return object self
*/
public function save()
{
$oCore_Database = Core_Database::instance();
$oCore_Database->insert('users')
->fields(['login', 'password', 'email'])
->values([
$this->_data['login'],
$this->_data['password'],
$this->_data['email']
]);
$stmt = $oCore_Database->execute();
return $this;
}
}
?>
Подробнее о реализации класса User
.
- В массиве
$_data
будет (может) храниться информация о пользователе: логин, пароль, email и любая иная, которая имеется. $_allowedProperties
перечисляет перечень полей таблицы`users`
, доступ к которым будет разрешен из кода php-сценария.$_forbiddenProperties
, соответственно, перечисляет перечень полей таблицы`users`
, доступ к которым будет, наоборот, запрещен из кода php-сценария.- Далее идут константы, которые нам повстречались в коде страницы
/users.php
. - Метод
_getUserData()
пока пустой, его мы заполним при разговоре об авторизаии пользователя. - Конструктор класса сейчас лишь получает данные об именах полей таблицы
`users`
. - Метод
getCurrent()
получает информацию об авторизованном пользователе, либо возвращает NULL, если такового нет. - Метод
processUserData()
обрабатывает данные форм авторизации и регистрации. - Метод
validateEmail()
проверяет, является ли переданное ему значение корректным адресом электропочты, а методisValueExist()
ищет в БД заданное значение, в целях определения его уникальности. Ранее у нас было две функции:isLoginExist()
иisEmailExist()
. Теперь у нас один общий метод. - Магический метод
__set()
для установки значений необъявленных свойств. - Метод
save()
сохраняет информацию о пользователе в БД. При регистрации нового, к примеру. - Если выражение
Core_Database::instance()->lastInsertId()
вернет значение идентификатора для новой записи в таблице`users`
, пользователь зарегистрирован успешно.
Отдельно рассмотрим метод User::processUserData()
.
<?php
public function processUserData(array $post)
{
$aReturn = [
'success' => FALSE,
'message' => "При обработке формы произошла ошибка",
'data' => [],
'type' => static::ACTION_SIGNIN
];
// Если не передан массив на обработку, останавливаем работу сценария
if (empty($post))
{
die("<p>Для обработки пользовательских данных формы должен быть передан массив</p>");
}
// Если в массиве отсутствуют данные о типе заполненной формы, останавливаем работу сценария
if (empty($post[static::ACTION_SIGNIN]) && empty($post[static::ACTION_SIGNUP]))
{
die("<p>Метод <code>User::processUserData()</code> должен вызываться только для обработки данных из форм авторизации или регистрации</p>");
}
// Флаг регистрации нового пользователя
$bRegistrationUser = !empty($post[static::ACTION_SIGNUP]);
// Логин и пароль у нас должны иметься в обоих случаях
$sLogin = strval(htmlspecialchars(trim($_POST['login'])));
$sPassword = strval(htmlspecialchars(trim($_POST['password'])));
// А вот электропочта и повтор пароля будут только в случае регистрации
if ($bRegistrationUser)
{
$aReturn['type'] = static::ACTION_SIGNUP;
$sEmail = strval(htmlspecialchars(trim($_POST['email'])));
$sPassword2 = strval(htmlspecialchars(trim($_POST['password2'])));
// Проверяем данные на ошибки
if ($this->validateEmail($sEmail))
{
// Логин и пароли не могут быть пустыми
if (empty($sLogin))
{
$aReturn['message'] = "Поле логина не было заполнено";
$aReturn['data'] = $post;
}
elseif (empty($sPassword))
{
$aReturn['message'] = "Поле пароля не было заполнено";
$aReturn['data'] = $post;
}
// Пароли должны быть идентичны
elseif ($sPassword !== $sPassword2)
{
$aReturn['message'] = "Введенные пароли не совпадают";
$aReturn['data'] = $post;
}
// Если логин не уникален
elseif ($this->isValueExist($sLogin, 'login'))
{
$aReturn['message'] = "Указанный вами логин ранее уже был зарегистрирован";
$aReturn['data'] = $post;
}
// Если email не уникален
elseif ($this->isValueExist($sEmail, 'email'))
{
$aReturn['message'] = "Указанный вами email ранее уже был зарегистрирован";
$aReturn['data'] = $post;
}
// Если все проверки прошли успешно, можно регистрировать пользователя
else
{
/**
* Согласно документации к PHP, мы для подготовки пароля пользователя к сохранению в БД
* будем использовать функцию password_hash() https://www.php.net/manual/ru/function.password-hash
* Причем, согласно рекомендации, начиная с версии PHP 8.0.0 не нужно указывать соль для пароля. Значит, и не будем
*/
// Хэшируем пароль
$sPassword = password_hash($sPassword, PASSWORD_BCRYPT);
$this->login = $sLogin;
$this->password = $sPassword;
$this->email = $sEmail;
$this->save();
if (Core_Database::instance()->lastInsertId())
{
$aReturn['success'] = TRUE;
$aReturn['message'] = "Пользователь с логином <strong>{$sLogin}</strong> и email <strong>{$sEmail}</strong> успешно зарегистрирован.";
$aReturn['data']['user_id'] = Core_Database::instance()->lastInsertId();
}
}
}
else
{
$aReturn['message'] = "Указанное значение адреса электропочты не соответствует формату";
$aReturn['data'] = $post;
}
}
return $aReturn;
}
/** ............ */
public function save()
{
$oCore_Database = Core_Database::instance();
$oCore_Database->insert('users')
->fields(['login', 'password', 'email'])
->values([
$this->_data['login'],
$this->_data['password'],
$this->_data['email']
]);
$stmt = $oCore_Database->execute();
return $this;
}
?>
$bRegistrationUser = !empty($post[static::ACTION_SIGNUP])
— мы определяем, авторизуется пользователь или регистрируется. Сейчас рассматриваем только процедуру регистрации.- Далее проходим все проверки, и если они пройдены, если логин и email уникальны, переходим к сохранению информации о пользователе в методе
User::save()
. - В этом методе мы через выражение
$oCore_Database = Core_Database::instance()
получаем доступ к объекту подключения к СУБД. Это не сам PDO, а наш классCore_Database_Pdo
, который реализует взаимодействие с MySQL посредством PDO. Ему мы и сообщаем через последовательный вызов методовinsert()
,fields()
иvalues()
перечень полей и значений, которые мы хотим в полях сохранить. - Выражение
$oCore_Database->execute()
выполняется отдельно, так как оно возвращет уже иной объект — неCore_Database_Pdo
, аPDOStatement
. И всё. Пользователь должен быть сохранен в БД таблице`users`
.
Всем этим внутри метода User::save()
мы заменили часть кода, которая раньше у нас была внутри тела функции userRegistration()
.
function userRegistration(array $data) : array
{
// ................
$dbh = dbConnect();
// Создаем тело SQL-запроса
$stmt = $dbh->prepare("INSERT INTO `users` (`login`, `email`, `password`) VALUES (:login, :email, :password)");
/**
* Согласно документации к PHP, мы для подготовки пароля пользователя к сохранению в БД
* будем использовать функцию password_hash() https://www.php.net/manual/ru/function.password-hash
* Причем, согласно рекомендации, начиная с версии PHP 8.0.0 не нужно указывать соль для пароля. Значит, и не будем
*/
// Хэшируем пароль
$sPassword = password_hash($sPassword, PASSWORD_BCRYPT);
// Подготавливаем запрос
$stmt->bindParam(':login', $sLogin);
$stmt->bindParam(':email', $sEmail);
$stmt->bindParam(':password', $sPassword);
// Выполняем запрос
$stmt->execute();
// ....................
}
Да, сейчас не очевидно, что же такого мы получили в виде выгоды, написав такую реализацию. Но это именно пока. В тот момент, пока мы говорим лишь о регистрации и авторизации. К тому же, сам класс PDO в PHP значительно упрощает взаимодействие с СУБД.
Очевидно, что изменения произошли и в классах Core_Database
и Core_Database_Pdo
. Посмотрим на них сейчас.
6. Классы Core_Database
и Core_Database_Pdo
<?php
// Запрещаем прямой доступ к файлу
defined('MYSITE') || exit('Прямой доступ к файлу запрещен');
/**
* Абстраактый класс
* Обеспечивает подключение к СУБД
* Класс является абстрактным, так как оставляет пользователю право определять, через какой
* модуль будет реализовано взаимодействие с СУБД
*
* Реализация такого взаимодействия должна быть написана в дочерних классах
* Например, вызов Core_Database::instance('mysql') вернет экземпляр класса Core_Database_Mysql
*/
abstract class Core_DataBase
{
// Экземпляр класса
static protected $_instance = [];
// Параметры подключение к СУБД
protected $_config = [];
// Здесь будет храниться последний выполненный запрос к БД
protected $_lastQuery = NULL;
/**
* Подключение к СУБД
* @var resource
*/
protected $_connection = NULL;
/**
* Число строк в результате запросе
* @var int
*/
protected $_lastQueryRows = NULL;
/**
* Перечень полей для запроса SELECT
* @var array
*/
protected $_select = [];
/**
* Имя таблицы для запроса SELECT
* @var string
*/
protected $_from = NULL;
/**
* Перечень полей для запроса INSERT
* @var array
*/
protected $_fields = [];
/**
* Перечень значений для запроса INSERT
* @var array
*/
protected $_values = [];
/**
* Имя таблицы для запроса INSERT
* @var string
*/
protected $_tableName = NULL;
/**
* Перечень условий для оператора WHERE
* @var array
*/
protected $_where = [];
/**
* Тип SQL-запроса:
* 0 - SELECT
* 1 - INSERT
* 2 - UPDATE
* 3 - DELETE
*/
protected $_queryType = NULL;
/** Абстрактные методы не имеют реализации. Они должны быть реализованы в дочерних классах */
// Подключение к СУБД
abstract public function connect();
// Отключение от СУБД
abstract public function disconnect();
// Установка кодировки соединения
abstract public function setCharset($charset);
// Экранирование данных
abstract public function escape($unescapedString);
// Представление результата в виде объекта
abstract public function asObject();
// Представление результата в виде ассоциативного массива
abstract public function asAssoc();
// Установка SQL-запроса
abstract public function query($query);
// Выполнение запроса
abstract public function result();
// Имена методов говорят сами за себя
abstract function select();
abstract function insert(string $tableName);
abstract function from(string $from);
abstract function fields();
abstract function values();
abstract function where(string $field, string $condition, $value);
abstract function execute();
abstract function lastInsertId();
/**
* Защищенный конструктор класса, который невозможно вызвать откуда-либо, кроме как из самого класса
* Получает параметры подключения к СУБД
*/
protected function __construct(array $config)
{
$this->setConfig($config);
}
/**
* Возвращает и при необходимости создает экзепляр класса
* @return object Core_Database
*/
static public function instance(string $name = 'pdo')
{
// Если экземпляр класса не был создан
if (empty(static::$_instance[$name]))
{
// Получаем параметры подключения к СУБД
$aConfig = Core::$config->get('core_database', array());
if (!isset($aConfig[$name]))
{
throw new Exception('Для запрошенного типа подключения к СУБД нет конфигурации');
}
// Определяем, какой именно класс будем использовать
// Он будет именоваться Core_Database_{$name}, например Core_Database_Pdo или Core_Database_Mysql
$driver = __CLASS__ . "_" . ucfirst($name);
self::$_instance[$name] = new $driver($aConfig[$name]);
}
// Возвращем вызову экземпляр класса для работы с СУБД
return static::$_instance[$name];
}
public function setConfig(array $config)
{
$this->_config = $config + [
'host' => 'localhost',
'user' => '',
'password' => '',
'dbname' => NULL,
'charset' => 'utf8'
];
return $this;
}
/**
* Получает перечень полей таблицы из БД
* @param string $tableName имя таблицы
* @param string $likeCondition значение для применения оператора LIKE
* @return array
*/
public function getColumnNames(string $tableName, string $likeCondition = NULL) : array
{
// Подключаемся к СУБД
$this->connect();
// Составляем строку запроса
$sQuery = "SHOW COLUMNS FROM " . $this->quoteColumnNames($tableName);
// Если есть значения для условия оператора LIKE
if (!is_null($likeCondition))
{
$sQuery .= ' LIKE ' . $this->quote($likeCondition);
}
// Выполняем запрос, результат получаем в виде ассоциативного массива
$result = $this->query($sQuery)->asAssoc()->result();
$return = [];
// Собираем информацию о столбцах
foreach ($result as $row)
{
$column['name'] = $row['Field'];
$column['columntype'] = $row['Type'];
$column['null'] = ($row['Null'] == 'YES');
$column['key'] = $row['Key'];
$column['default'] = $row['Default'];
$column['extra'] = $row['Extra'];
$return[$column['name']] = $column;
}
// Возвращаем вызову результат
return $return;
}
/**
* Экранирует имена полей или таблиц для применения в строке SQL-запроса
* @param string $value
* @return string
*/
public function quoteColumnNames(string $value) : string
{
return preg_replace('/(?<=^|\.)(\w+)(?=$|\.)/ui', '`$1`', $value);
}
/**
* Возвращает строку последнего выполненного запроса
* @return string | NULL
*/
public function getLastQuery()
{
return $this->_lastQuery;
}
/**
* Возвращает число строк из последнего результата запроса
*/
public function getRowCount()
{
return $this->_lastQueryRows;
}
/**
* Устанавливает тип запроса SELECT, INSERT и т.п.
* @param integer $queryType
* @return object self
*/
public function setQueryType(int $queryType)
{
$this->_queryType = $queryType;
return $this;
}
/**
* Возвращает тип запроса
* @return integer
*/
public function getQueryType()
{
return $this->_queryType;
}
}
?>
Файл снабжен обширными комментариями, которые должны донести суть описанного. Перейдем к классу Core_Database_Pdo
.
<?php
// Запрещаем прямой доступ к файлу
defined('MYSITE') || exit('Прямой доступ к файлу запрещен');
class Core_Database_Pdo extends Core_DataBase
{
/** Результат выполнения запроса
* @var resource | NULL
*/
protected $_result = NULL;
/**
* Представление результата запроса в виде ассоциативного массива либо объекта
*/
protected $_fetchType = PDO::FETCH_OBJ;
/**
* Возвращает активное подключение к СУБД
* @return resource
*/
public function getConnection()
{
$this->connect();
return $this->_connection;
}
/**
* Подключается к СУБД
* @return boolean TRUE | FALSE
*/
public function connect()
{
// Если подключение уже выполнено, ничего не делаем
if ($this->_connection)
{
return TRUE;
}
$this->_config += array(
'driverName' => 'mysql',
'attr' => array(
PDO::ATTR_PERSISTENT => FALSE
)
);
// Подключаемся к СУБД
try {
// Адрес сервера может быть задан со значением порта
$aHost = explode(":", $this->_config['host']);
// Формируем строку источника подключения к СУБД
$dsn = "{$this->_config['driverName']}:host={$aHost[0]}";
// Если был указан порт
!empty($aHost[1])
&& $dsn .= ";port={$aHost[1]}";
// Указываем имя БД
!is_null($this->_config['dbname'])
&& $dsn .= ";dbname={$this->_config['dbname']}";
// Кодировка
$dsn .= ";charset={$this->_config['charset']}";
// Подключаемся, и сохраняем подключение в экземпляре класса
$this->_connection = new PDO(
$dsn,
$this->_config['user'],
$this->_config['password'],
$this->_config['attr']
);
// В случае ошибок будет брошено исключение
$this->_connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
}
catch (PDOException $e)
{
throw new Exception("<p><strong>Ошибка при подключении к СУБД:</strong> {$e->getMessage()}</p>");
}
// Если ничего плохого не произошло
return TRUE;
}
/**
* Закрывает соединение с СУБД
* @return self
*/
public function disconnect()
{
$this->_connection = NULL;
return $this;
}
/**
* Устанавливает кодировку соединения клиента и сервера
* @param string $charset указанное наименование кодировки, которое примет СУБД
*/
public function setCharset($charset)
{
$this->connect();
$this->_connection->exec('SET NAMES ' . $this->quote($charset));
return $this;
}
/**
* Экранирование строки для использования в SQL-запросах
* @param string $unescapedString неэкранированная строка
* @return string Экранированная строка
*/
public function escape($unescapedString) : string
{
$this->connect();
$unescapedString = addcslashes(strval($unescapedString), "\000\032");
return $this->_connection->quote($unescapedString);
}
/**
* Возвращает результат работы метода PDO::quote()
* @return string
*/
public function quote(string $value) : string
{
return $this->_connection->quote($value);
}
/**
* Возвращает идентификатор последней вставленной записи в БД, если такой имеется
* @return integer|string|NULL
*/
public function lastInsertId()
{
return $this->getConnection()->lastInsertId();
}
/**
* Устанавливает строку запроса, который будет выполнен позднее
* @param string $query
* @return object self
*/
public function query($query)
{
// Переданную строку запроса сохраняем, чтобы её потом можно было просмотреть
$this->_lastQuery = $query;
// По умолчанию устанавливаем, что результат запроса хотим получать в виде объекта
$this->_fetchType = PDO::FETCH_OBJ;
return $this;
}
/**
* Устанавливает тип представления данных в результате запроса в виде объекта
* @return object self
*/
public function asObject()
{
$this->_fetchType = PDO::FETCH_OBJECT;
return $this;
}
/**
* Устанавливает тип представления данных в результате запроса в виде ассоциативного массива
* @return object self
*/
public function asAssoc()
{
$this->_fetchType = PDO::FETCH_ASSOC;
return $this;
}
/**
* Выполняет запрос SELECT, возвращает результат выполнения
* @return object PDOStatement
*/
public function result() : PDOStatement
{
// Результат выполнения запроса сохраняем внутри объекта
$this->_result = $this->_connection->query($this->_lastQuery, $this->_fetchType);
// Определяем количество строк в результате запроса, сохраняем внутри объекта
$this->_lastQueryRows = $this->_result->rowCount();
return $this->_result;
}
/**
* Устанавливает перечень полей для запроса SELECT
* @param string|array $data = "*"
* @return object self
*/
public function select($data = "*")
{
// Устанавливаем в объекте тип выполняемого запроса как SELECT
$this->getQueryType() != 0 && $this->setQueryType(0);
// Если методу не был передан перечень полей, очищаем все возможно установленные ранее поля
if ($data == "*")
{
$this->clearSelect();
}
// Сохраняем поля
try {
// Если перечень полей был передан в виде строки
if (is_string($data))
{
// Добавляем их к массиву в объекте
$this->_select[] = $data;
}
// Если был передан массив, его нужно интерпретировать как указание имени поля и его псевдонима в запросе
elseif (is_array($data))
{
// Если в переданном массиве не два элемента, это ошибка
if (count($data) != 2)
{
throw new Exception("<p>При передаче массива в качестве аргумента методу " . __METHOD__ . "() число элементов этого массива должно быть равным двум</p>");
}
// Если элементы переданного массива не являются строками, это ошибка
elseif (!is_string($data[0]) || !is_string($data[1]))
{
throw new Exception("<p>При передаче массива в качестве аргумента методу " . __METHOD__ . "() его элементы должны быть строками</p>");
}
// Если ошибок нет, сохраняем поля в массиве внутри объекта
else
{
// Имена полей экранируем
$this->_select[] = $this->quoteColumnNames($data[0]) . " AS " . $this->quoteColumnNames($data[1]);
}
}
}
catch (Exception $e)
{
print $e->getMessage();
die();
}
return $this;
}
/**
* Очищает перечень полей для оператора SELECT
* @return object self
*/
public function clearSelect()
{
$this->_select = [];
return $this;
}
/**
* Устанавливает имя таблицы для оператора SELECT
* @param string $from
* @return object self
*/
public function from(string $from)
{
try {
if (!is_string($from))
{
throw new Exception("<p>Методу " . __METHOD__ . "() нужно передать имя таблицы для запроса</p>");
}
// Экранируем данные
$this->_from = $this->quoteColumnNames($from);
}
catch (Exception $e)
{
print $e->getMessage();
die();
}
return $this;
}
/**
* Сохраняет перечень условий для оператора WHERE в SQL-запросе
* @param string $field
* @param string $condition
* @param string $value
* @return object self
*/
public function where(string $field, string $condition, $value)
{
try {
if (empty($field) || empty($condition))
{
throw new Exception("<p>Методу " . __METHOD__ . "() обязательно нужно передать значения имени поля и оператора сравнения</p>");
}
// Экранируем имена полей и значения, которые будут переданы оператору WHERE
$this->_where[] = $this->quoteColumnNames($field) . " " . $condition . " " . $this->_connection->quote($value);
}
catch (Exception $e)
{
print $e->getMessage();
die();
}
return $this;
}
/**
* Очищает массив условий отбора для оператора WHERE
* @return object self
*/
public function clearWhere()
{
$this->_where = [];
return $this;
}
/**
* Устанавливает имя таблицы для оператора INSERT
* @param string $tableName
* @return object self
*/
public function insert(string $tableName)
{
// Экранируем имя таблицы
$this->_tableName = $this->quoteColumnNames($tableName);
// Устанавливаем тип запроса INSERT
$this->_queryType = 1;
return $this;
}
/**
* Устанавливает перечень полей для оператора INSERT
* @return object self
*/
public function fields()
{
try {
// Если не было передано перечня полей
if (empty(func_get_args()))
{
throw new Exception("Метод " . __METHOD__ . "() нельзя вызывать без параметров. Нужно передать перечень полей либо в виде строки, либо в виде массива");
}
// Сохраняем перечень полей в переменную
$mFields = func_get_arg(0);
// Если передан массив
if (is_array($mFields))
{
// Просто сохраняем его
$this->_fields = $mFields;
}
// Если передана строка
elseif (is_string($mFields))
{
// Разбираем её, полученный массив сохраняем
$this->_fields = explode(',', $mFields);
}
// В ином случае будет ошибка
else
{
throw new Exception("Метод " . __METHOD__ . "() ожидает перечень полей либо в виде строки, либо в виде массива");
}
}
catch (Exception $e)
{
print $e->getMessage();
die();
}
return $this;
}
/**
* Устанавливает перечень значений, которые будут переданы оператору INSERT
* @return object self
*/
public function values()
{
try {
// Если значения не переданы, это ошибка
if (empty(func_get_args()))
{
throw new Exception("Метод " . __METHOD__ . "() нельзя вызывать без параметров. Нужно передать перечень значений либо в виде строки, либо в виде массива");
}
// Сохраняем переденные значения в переменную
$mValues = func_get_arg(0);
// Если был передан массив
if (is_array($mValues))
{
// Просто сохраняем его
$this->_values[] = $mValues;
}
// Если была передана строка
elseif (is_string($mValues))
{
// Разбираем её, полученный массив сохраняем в объекте
$this->_values[] = explode(',', $mValues);
}
// В ином случае будет ошибка
else
{
throw new Exception("Метод " . __METHOD__ . "() ожидает перечень значений либо в виде строки, либо в виде массива");
}
}
catch (Exception $e)
{
print $e->getMessage();
die();
}
return $this;
}
/**
* Выполняет SQL-запрос к СУБД
*/
public function execute() : PDOStatement | NULL
{
// Результат запроса будет представлен в виде объекта
$this->_fetchType = PDO::FETCH_OBJ;
// Пустая строка для SQL-запроса
$sQuery = "";
// Строка оператора WHERE
$sWhere = " WHERE ";
// Сначала собираем строку для оператора WHERE
foreach ($this->_where as $index => $sWhereRow)
{
// Для каждого из сохраненного массива для оператора WHERE формируем строку
$sWhere .= (($index) ? " AND" : "") . " " . $sWhereRow;
}
// Создаем данные, которые вернем в ответ на вызов
$return = NULL;
// Пробуем выполнить запрос
try {
// В зависимости от типа запроса
switch ($this->getQueryType())
{
// SELECT
case 0:
$sQuery .= "SELECT " . implode(", ", $this->_select) . " FROM {$this->_from}" . $sWhere;
$return = $this->query($sQuery)->result();
break;
// INSERT
case 1:
/**
* Здесь мы воспользуемся механизмом подготовки запроса от PDO
* https://www.php.net/manual/ru/pdo.prepared-statements.php
*/
$sPseudoValues = "(";
$sFields = "(";
foreach ($this->_fields as $index => $sField)
{
$sPseudoValues .= (($index) ? "," : "") . "?";
$sFields .= (($index) ? "," : "") . $this->quoteColumnNames($sField);
}
$sPseudoValues .= ")";
$sFields .= ")";
$sQuery .= "INSERT INTO " . $this->_tableName . " " . $sFields . " VALUES " . $sPseudoValues;
$stmt = $this->getConnection()->prepare($sQuery);
foreach ($this->_values as $aValues)
{
for ($i = 1; $i <= count($aValues); ++$i)
{
$stmt->bindParam($i, $aValues[$i - 1]);
}
$stmt->execute();
}
$return = $stmt;
break;
}
// Сохраняем строку запроса в объекте
$this->_lastQuery = $sQuery;
}
catch (PDOException $e)
{
throw new Exception($e->getMessage());
}
return $return;
}
}
?>
Комментарии к методам и свойствам класса должны дать полное понимание того, что именно происходит при их вызове. Повторяться не будем. Пользователь может регистрироваться. Теперь его нужно авторизовать через класс User
.
7. Авторизация пользователя через класс User
Класс User
был немного дополнен. Отметим здесь лишь внесенные изменения
<?php
// Запрещаем прямой доступ к файлу
defined('MYSITE') || exit('Прямой доступ к файлу запрещен');
/**
* Класс пользователя клиентского раздела сайта
* Объект класса может создаваться как для существующих пользователей, так и для несуществующих с целью их регистрации
*/
class User
{
// ..................
/**
* Результируйщий объект запроса к БД
* @var stdClass|NULL
*/
protected $_user = NULL;
// ..................
/**
* Получает данные о пользователе сайте
* @return mixed self|NULL
*/
protected function _getUserData()
{
$return = NULL;
// Если внутри объекта нет информации о пользователе, пробуем получить её из сессии
if (is_null($this->_user))
{
if (!empty($_SESSION['password']))
{
$sPassword = strval($_SESSION['password']);
$sValue = strval((!empty($_SESSION['email'])) ? $_SESSION['email'] : $_SESSION['login'] );
$stmt = $this->getByLoginOrEmail($sValue);
if (!is_null($stmt))
{
$this->_user = $stmt->fetch();
$return = $this;
}
}
}
else
{
$return = $this;
}
return $return;
}
// ..................
/**
* Получает информацию об авторизованном пользователе
* @return mixed self | NULL если пользователь не авторизован
*/
public function getCurrent()
{
$return = NULL;
/**
* Информация о пользователе, если он авторизован, хранится в сессии
* Поэтому нужно просто проверить, имеется ли там нужная информация
* Если в сессии её нет, значит пользователь не авторизован
*/
(!empty($_SESSION['login'])
&& !empty($_SESSION['email'])
&& !empty($_SESSION['password']))
&& $return = $this->_getUserData();
// Возвращаем результат вызову
return $return;
}
/**
* Устанавливает в сесии параметры пользователя, прошедшего авторизацию
* @return object self
*/
public function setCurrent()
{
$_SESSION['login'] = $this->_user->login;
$_SESSION['email'] = $this->_user->email;
$_SESSION['password'] = $this->_user->password;
return $this;
}
// ..................
/**
* Обрабатывает данные, которыми пользователь заполнил форму
* @param array $post
*/
public function processUserData(array $post)
{
// ..................
// А вот электропочта и повтор пароля будут только в случае регистрации
if ($bRegistrationUser)
{
// ..................
}
// Если пользователь авторизуется
else
{
// Проверяем, не был ли пользователь ранее авторизован
if ($this->_checkCurrent($sLogin))
{
$aReturn['success'] = TRUE;
$aReturn['message'] = "Вы ранее уже авторизовались на сайте";
}
// Если авторизации не было
else
{
// Если не передан пароль
if (empty($sPassword))
{
$aReturn['message'] = "Поле пароля не было заполнено";
$aReturn['data'] = $post;
}
else
{
// Ищем соответствие переданной информации в БД
$stmt = $this->getByLoginOrEmail($sLogin);
// Если были найдены записи
if (!is_null($stmt))
{
// Получаем объект с данными о пользователе
$oUser = $this->_user = Core_Database::instance()->result()->fetch();
// Проверяем пароль пользователя
// Если хэш пароля совпадает
if ($this->checkPassword($sPassword, $oUser->password))
{
// Авторизуем пользователя
$this->setCurrent();
$aReturn['success'] = TRUE;
$aReturn['message'] = "Вы успешно авторизовались на сайте";
$aReturn['data'] = $post;
$aReturn['data']['user_id'] = $oUser->id;
}
else
{
$aReturn['message'] = "Для учетной записи <strong>{$sLogin}</strong> указан неверный пароль";
$aReturn['data'] = $post;
}
}
}
}
}
return $aReturn;
}
/**
* Ищет в БД запись по переданному значению полей login или email
* @param string $value
* @return object PDOStatement|NULL
*/
public function getByLoginOrEmail(string $value) : PDOStatement | NULL
{
// Определяем тип авторизации: по логину или адресу электропочты
$sType = NULL;
$sType = match($this->validateEmail($value)) {
TRUE => 'email',
FALSE => 'login'
};
// Выполняем запрос SELECT
$oCore_Database = Core_Database::instance();
$oCore_Database->select()
->from('users')
->where($sType, '=', $value)
->where('deleted', '=', 0)
->where('active', '=', 1);
$stmt = $oCore_Database->execute();
// Если такой пользователь есть в БД, вернем объект с результатом запроса
return ($oCore_Database->getRowCount() > 0) ? $stmt : NULL;
}
/**
* Проверяет пароль пользователя, совпадает ли он с хранимым в БД
* @param string $password пароль пользователя
* @param string $hash хэш пароля пользователя из БД
* @return boolean TRUE|FALSE
*/
public function checkPassword(string $password, string $hash) : bool
{
/**
* Согласно документации к PHP, мы для подготовки пароля пользователя к сохранению в БД
* мы использовали функцию password_hash() https://www.php.net/manual/ru/function.password-hash
* Теперь для проверки пароля для авторизации нам нужно использовать функцию password_verify()
* https://www.php.net/manual/ru/function.password-verify.php
*/
return password_verify($password, $hash);
}
// ..................
}
?>
Сессию теперь стартуем при инициализации ядра сайта. Для этого в конец метода Core::init() небольшой кусок кода
<?php
defined('MYSITE') || exit('Прямой доступ к файлу запрещен');
class Core
{
// ....................
static public function init()
{
// ...............
// Запускаем сессию
if (session_status() !== PHP_SESSION_ACTIVE)
{
session_start();
}
}
// ....................
}
?>
Теперь пользователь может и авторизоваться. У нас после авторизаии пользователь перенаправляется на стартовую страницу сайта /index.php
. Давайте внесем в неё небольшие изменения относительно предыдущей версии.
<?php
// Подключаем файл с основными функциями
require_once('bootstrap.php');
// Создаем объект авторизованного пользователя
$oCurrentUser = (new User())->getCurrent();
?>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Авторизация/регистрация. PHP+MySQL+JavaScript,jQuery</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js" integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.min.js" integrity="sha384-BBtl+eGJRgqQAUMxJ7pMwbEyER4l1g+O15P+16Ep7Q9Q+zqX6gSbd85u4mG4QzX+" crossorigin="anonymous"></script>
</head>
<body>
<div class="container-md container-fluid">
<h1 class="my-3">Добро пожаловать на сайт</h1>
<p class="h6">
Вы зашли на сайт примера авторизации и регистрации пользователя.
</p>
<p>
Сейчас вы <?php echo (!is_null($oCurrentUser)) ? "авторизованы" : "не авторизованы";?> на сайте. <br />
<?php
if (!is_null($oCurrentUser))
{
// Для вывода даты регистрации пользователя значение из формата TIMESTAMP,
// в котором оно хранится в MySQL, нужно преобразовать
$oDateTime = new DateTime($oCurrentUser->registration_date);
echo "<p>Ваш логин: <strong>" . $oCurrentUser->login . "</strong>.</p>";
echo "<p>Ваша электропочта: <strong>" . $oCurrentUser->email . "</strong>.</p>";
echo "<p>Вы зарегистрировались: <strong>" . $oDateTime->format("d.m.Y") . "</strong>.</p>";
echo "<p>Вы можете <a href='/users.php?action=exit'>выйти</a> из системы.</p>";
}
else
{
?>
<p>На этом сайте вам доступно:</p>
<ul class="list-unstyled">
<li>
<a href="/users.php">Авторизация и регистрация</a>
</li>
</ul>
<?php
}
?>
</p>
</div>
</body>
</html>
Для авторизованных пользователей мы добавили вывод информации о email и дате регистрации. Для того, чтобы выражение вроде $oCurrentUser->email
возвращало что-либо кроме NULL
, нужно добавить реализацию магического метода User::__get()
, заодно реализуем метод User::unsetCurrent()
, ведь нам ещё нужно обеспечить возможность выхода из системы для пользователя.
<?php
// Запрещаем прямой доступ к файлу
defined('MYSITE') || exit('Прямой доступ к файлу запрещен');
/**
* Класс пользователя клиентского раздела сайта
* Объект класса может создаваться как для существующих пользователей, так и для несуществующих с целью их регистрации
*/
class User
{
// .....................
/**
* Завершает сеанс пользователя в системе
* @return object self
*/
public function unsetCurrent()
{
// Уничтожение данных о пользователе в сессии
unset($_SESSION['login']);
unset($_SESSION['email']);
unset($_SESSION['password']);
// Обновляем страницу
header("Refresh:0;"); die();
return NULL;
}
// .....................
/**
* Магический метод для получения значения необъявленного свойства класса
* Вернет значение из запрошенного поля таблицы, если оно разрешено в массиве $_allowedProperties
* @return mixed string|NULL
*/
public function __get(string $property) : string | NULL
{
return (in_array($property, $this->_allowedProperties) ? $this->_user->$property : NULL);
}
}
?>
После выхода из системы и очистки сессии, страница обновляется. Сеанс пользователя завершен.
Заключение
На этом статью мы завершаем. Работа с авторизацией и регистрацией переведена с процедурного типа на объектно-ориентированный. Начинающему читателю выгода от такого перехода сейчас может быть не очень понятна, но понимание придёт.
«Программируйте на уровне интерфейса, а не на уровне реализации.»
Опытный читатель же обратит внимание, и, возможно, даже, наберётся наглости написать в комментариях о том, что стиль конечно объектно-ориентированный, но с ним что-то не так. Реализация какая-то то ли незаконченная, то ли беcцелевая какая-то, что ли. Это так. Но это тема для следующих статей.
Таблица `users`
с прошлой статьи у нас не менялась.
--
-- Структура таблицы `users`
--
CREATE TABLE `users` (
`id` int(10) UNSIGNED NOT NULL,
`login` char(16) NOT NULL,
`email` char(32) NOT NULL,
`password` char(255) NOT NULL,
`registration_date` timestamp NOT NULL DEFAULT current_timestamp(),
`active` tinyint(1) UNSIGNED NOT NULL DEFAULT 1,
`deleted` tinyint(1) UNSIGNED NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
--
-- Индексы сохранённых таблиц
--
--
-- Индексы таблицы `users`
--
ALTER TABLE `users`
ADD PRIMARY KEY (`id`),
ADD KEY `active` (`active`),
ADD KEY `deleted` (`deleted`);
--
-- AUTO_INCREMENT для сохранённых таблиц
--
--
-- AUTO_INCREMENT для таблицы `users`
--
ALTER TABLE `users`
MODIFY `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
Просмотреть пример работы сценария можно здесь.
Исходный код описанных файлов доступен по ссылке.