Регистрация и авторизация пользователей на PHP. Часть седьмая

Вы можете скачать все файлы для работы описанного одним архивом: скачать
Вы можете просмотреть пример работы описанного выше: перейти

В прошлой части статьи мы остановились на том, что ссылку на регистрацию авторизованному пользователю показывать нет необходимости. Мы просто забыли указать условие для её вывода на экран. Исправляем эту ошибку.

<!-- Файл /blocks/header.php -->
	<header class="bg-dark text-white">
	<div class="container-fluid container-lg">
		<nav class="navbar navbar-dark bg-dark">
			<a class="navbar-brand link-decor" href="/lk.php">Личный кабинет</a>
			
			<?php
			// Форму авторизации нужно показывать только неавторизованным пользователям
			// Если пользователь прошел авторизацию, ему должны быть достуные другие возможности
			// Для авторизованных пользователей
			if (!is_null(Core_Page::instance()->user))
			{
			?>
				<a class="nav-item" href="/logout.php">Выйти</a>
			<?php
			}
			// Для неавторизованных пользователей показываем форму авторизации
			else
			{
			?>
				<form id="user-authorization" name="user-authorization" class="form-inline" autocomplete="off" onsubmit="return false;" method="post" action="/service.php">
					<label class="sr-only" for="user_login">Логин</label>
					<div class="input-group mb-2 mb-lg-0 mr-sm-2">
						<div class="input-group-prepend">
							<div class="input-group-text">@</div>
						</div>
						<input class="form-control mr-sm-2" type="text" id="user_auth_login" name="user_auth_login" placeholder="Логин" aria-label="Логин" />
					</div>
					<input class="form-control mb-2 mb-lg-0  mr-sm-2" type="password" id="user_auth_password" name="user_auth_password" placeholder="Пароль" aria-label="Пароль" />
					<div class="form-check mb-2 mb-lg-0  mr-sm-2">
						<input class="form-check-input" type="checkbox" id="user_auth_remember" name="user_auth_remember" />
						<label class="form-check-label" for="user_auth_remember">
							Запомнить меня
						</label>
					</div>
					<!-- Скрытое поле формы, в котором хранится строка запроса к сценарию-обработчику -->
					<input type="hidden" id="user_auth_request" name="request" value="user_auth_login" />
					<button class="btn btn-success my-2 my-sm-0" type="submit">Войти</button>
				</form>
			<?php
			}
			?>
		</nav>
		<?php
		if (is_null(Core_Page::instance()->user))
		{
		?>
			<div class="row text-xl-right">
				<div class="col-12">
					<div class="mr-xl-3 ml-xl-0 ml-3 mb-3">
						<a href="/registration.php" class="link-decor">Зарегистрироваться</a>
					</div>
				</div>
			</div>
		<?php } ?>
	</div>
</header>

Теперь всё как надо.

Алгоритм работы деавторизации пользователя

За сам процесс деавторизации пользователя будет отвечать сценарий в файле /logout.php. Здесь уже обойдется без JavaScript.

  1. Пользователь нажимает кнопку ссылку "Выйти", перемещается по адресу /logout.php.
  2. Для запущенной сессии пользователя устанавливается отрицательное время жизни.
  3. Удаляется имеющееся значение $_SESSION["user_id"], сессия запускается с новым идентификатором. Именно с новым, а не с обновленным.
  4. Пользователь перенаправляется на главную страницу сайта.

Сценарий, деавторизующий пользователя

<?php
// Файл /logout.php

require_once('bootstrap.php');

print "<pre>";
print_r($_SESSION);
print "</pre><hr />";

// Устанавливаем для сессии отрицательное время жизни
Core_Session::setMaxLifeTime(-1);

// Запускаем сессию
Core_Session::start();
print "<pre>";
print_r($_SESSION);
print "</pre><hr />";
// Удаляем из сессии идентификатор сессии
if (!empty(Core_Array::getSession("user_id")))
{
	unset($_SESSION["user_id"]);
}
print "<pre>";
print_r($_SESSION);
print "</pre><hr />";
// Запускаем сессию с новым ID
Core_Session::sessionCreateId();
print "<pre>";
print_r($_SESSION);
print "</pre><hr />";

// Удаляем куку с токеном пользователя
Core_Array::setCookie(["name" => "user_token"]);

// Перенаправляем пользователя на главную страницу
// header("Location: /");
?>

Посмотрим, как оно работает.

Итак, работает как и задумывалось. Убираем всё лишнее из сценария деавторизации.

<?php
// Файл /logout.php

require_once('bootstrap.php');

// Устанавливаем для сессии отрицательное время жизни
Core_Session::setMaxLifeTime(-1);

// Запускаем сессию
Core_Session::start();

// Удаляем из сессии идентификатор сессии
if (!empty(Core_Array::getSession("user_id")))
{
	unset($_SESSION["user_id"]);
}

// Запускаем сессию с новым ID
Core_Session::sessionCreateId();

// Удаляем куку с токеном пользователя
Core_Array::setCookie(["name" => "user_token"]);

// Перенаправляем пользователя на главную страницу
header("Location: /");
?>

Авторизация пользователя по сохраненному токену

Настало время решить последнюю задачу: авторизовать пользователя, пожелавшего запомнить свою авторизацию на сайте. Мы установили время актуальности токена — месяц.

Представим, что время жизни сессии пользователя истекло.

Токен есть, а авторизации нет. Что будем делать? Для начала, мы тут так подумали, а давайте слегка усложним условия для создания токена? Ведь, в теории, пользователь может авторизоваться с десятка разных браузеров. Каждый из них может создать токен. Тогда нужно добавить нечто, что еще и поможет идентифицировать браузер. А потом к этому всему добавляем строку даты и времени момента, в который создается хэш строки. И, таким образом, токен для такой реализации, на наш взгляд, получается максимально уникальным и защищенным от компрометации.

Перепесываем код файла /modules/core/config/usertoken.php.

<?php
// Файл modules/core/config/usertoken.php

defined("LEZH") || exit("Доступ к файлу запрещен");

return array(
	"id" => "",
	"login" => "",
	"registration_date" => "",
	"http_user_agent" => $_SERVER["HTTP_USER_AGENT"],
	"http_x_real_ip" => $_SERVER["HTTP_X_REAL_IP"],
	"current_time" => date("c")
);
?>

Теперь нужно изменить немного и код метода User_Model::authUser().

<?php
// Файл modules/core/user/model.php

defined("LEZH") || exit("Доступ к файлу запрещен");

class User_Model extends Core_ORM
{
	// Имя модели
	protected $_modelName = "user";
	
	// Запрещенные к загрузке значения полей
	protected $_forbiddenFields = array(
		"password"	// Пароль пользователя не получаем из БД
	);
	
	/**
	 * Связь один-к-одному
	 * 
	 * Запись "model_name" => array() говорит о том, что связь происходит через 
	 * стандартные поля id, являющиеся первичными ключами в своих таблицах
	 * В этом случае связанные строки таблицы model_name будут отбираться по значению
	 * поля user_id
	 */ 
	protected $_refersTo = array(
		
	);
	
	// Связь один-ко-многим
	protected $_hasSeveral = array(
		"user_token" => array()
	);
	
	/**
	 * Конструктор класса
	 * Сразу же предусмотрим возможность создания экземпляра класса для конкретного 
	 * пользователя, для которого будет передан его идентификатор
	 */
	public function __construct($iPrimaryKey = NULL)
	{
		// Основные действия будут происходить в конструкторе родительского класса
		parent::__construct($iPrimaryKey);
	}
	
	/**
	 * Проверяем существование пользователя по указанным логину и паролю
	 */
	public function getByLoginAndPassword(string $login, string $password)
	{
		// Создаем объект запроса
		$this->query()
			// Указываем, что нужен пользователь с определенным логином
			->where("login", "=", $login);
		
		// Если пользователь с таким логином есть
		if ($this->getCount())
		{
			// Получаем данные о пользователе
			$oUser = $this->query()->result()[0];
			
			// Сверяем переданный пароль с тем, что хранится в БД
			if (password_verify($password, $oUser->password))
			{
				// Записываем в текущий объект идентификатор пользователя
				$this->setPrimaryKey($oUser->id);
				
				unset($oUser);
				
				return TRUE;
			}
			// Если пароль не совпал
			else
			{
				return FALSE;
			}
		}
		// Если такой пользователь не найден
		else
		{
			return FALSE;
		}
		
		return FALSE;
	}
	
	/**
	 * Устанавливает авторизованного пользователя
	 */
	public function authUser($remember = FALSE)
	{
		// На момент авторизации пользователя уже должен быть известен его идентификатор
		// Этот идентификатор должен уже быть загружен в данные модели
		if (!empty($this->id))
		{
			throw new Exception("User::authUser() не установлен идентификатор пользователя");
		}
		
		// Стартуем сессию, генерируем её новый идентификатор
		Core_Session::sessionRegenerateId();
		
		// Записываем в сессию идентификатор пользователя
		$_SESSION["user_id"] = $this->id;
		
		// Пользователь авторизован
		
		// Если авторизацию пользователя нужно запомнить
		if ($remember === TRUE)
		{
			// Получаем параметры конфигурации для хэширования строк
			$aHashConfig = Core_Config::instance()->get("hash");
			
			// Получаем параметры конфигурации для выбора полей, значения которых будем хэшировать
			$aUserTokenConfig = Core_Config::instance()->get("usertoken");
			
			// Продолжаем только если определена указанная функция
			if (function_exists($aHashConfig["algo"]))
			{
				// Сохраняем имя функции хэширования
				$function = $aHashConfig["algo"];
				
				// Создаем переменную, в которую потом запишем строку для хэширования
				$sValue = "";
				
				// Продолжаем только если есть имена полей, значения которых должны создать строку для хэширования
				if (!empty($aUserTokenConfig))
				{
					foreach ($aUserTokenConfig as $component => $value)
					{
						$sValue .= (empty($value)) ? $this->$component : $value;
					}
					
					// Хэшируем строку
					$sHashedString = $function($sValue);
					
					// Сохраняем токен пользователя
					$oUser_Token = $this->User_Tokens;
					$oUser_Token->token($sHashedString)
						->save();
					
					// Сохраняем COOKIE с вновь созданным токеном
					Core_Array::setCookie([
						"name" => "user_token", 
						"value" => $sHashedString,
						"expires" => $oUser_Token->getTokenExpiresTimestamp()
					]);
				}
				else
				{
					throw new Exception("Не заданы поля для создания хэшированной строки. Невозможно авторизовать пользователя на длительный срок");
				}
			}
			else
			{
				throw new Exception("Не существует указанной вами функции " . $aHashConfig["algo"] . "(). Невозможно авторизовать пользователя на длительный срок");
			}
		}
		
		return $this;
	}
	
	/**
	 * Получает авторизованного пользователя
	 */
	public function getAuthUser()
	{
		// Запускаем сессию
		Core_Session::start();
		
		// Получаем идентификатор авторизованного пользователя
		$iUserId = intval(Core_Array::getSession("user_id"));
		
		// Если такой пользователь есть 
		if (!empty($iUserId))
		{
			// Загружаем данные о пользователе в модель
			$this->find($iUserId);
			
			return $this;
		}
		
		return NULL;
	}
}
?>

Мы сделали все, чтобы обеспечить уникальность токена и степень его устойчивости к компрометации. Поэтому, если пользователь передает сайту свой токен, мы можем ему доверять, ну... достаточно можем доверять. Значит, нам просто нужно будет получить из БД идентификатор пользователя по токену, потом — связанный с токеном объект модели пользователя, и уже потом авторизовать его. В процессе деавторизации помимо удаления куки, в которой хранится токен, будем удалять и значение токена из БД.

Кстати, теперь для хэширования строки токена можно использовать функцию несколько понадежнее и получше, чем, например, md5() или sha1(). Нужный алгоритм шифрования, который есть в вашей сборке PHP, достаточно указать в файле /modules/core/config/hash.php.

Мы поменяли алгоритм шифрования.

<?php
// Файл modules/core/config/hash.php

defined("LEZH") || exit("Доступ к файлу запрещен");

return array(
	"algo" => PASSWORD_BCRYPT,
	"method" => "password_hash",
	"secret_key" => "",
	"sault" => ""
);
?>

И теперь снова вносим изменения в код метода User_Mode::authUser().

<?php
// Файл modules/core/user/model.php

defined("LEZH") || exit("Доступ к файлу запрещен");

class User_Model extends Core_ORM
{
	// Имя модели
	protected $_modelName = "user";
	
	// Запрещенные к загрузке значения полей
	protected $_forbiddenFields = array(
		"password"	// Пароль пользователя не получаем из БД
	);
	
	/**
	 * Связь один-к-одному
	 * 
	 * Запись "model_name" => array() говорит о том, что связь происходит через 
	 * стандартные поля id, являющиеся первичными ключами в своих таблицах
	 * В этом случае связанные строки таблицы model_name будут отбираться по значению
	 * поля user_id
	 */ 
	protected $_refersTo = array(
		
	);
	
	// Связь один-ко-многим
	protected $_hasSeveral = array(
		"user_token" => array()
	);
	
	/**
	 * Конструктор класса
	 * Сразу же предусмотрим возможность создания экземпляра класса для конкретного 
	 * пользователя, для которого будет передан его идентификатор
	 */
	public function __construct($iPrimaryKey = NULL)
	{
		// Основные действия будут происходить в конструкторе родительского класса
		parent::__construct($iPrimaryKey);
	}
	
	/**
	 * Проверяем существование пользователя по указанным логину и паролю
	 */
	public function getByLoginAndPassword(string $login, string $password)
	{
		// Создаем объект запроса
		$this->query()
			// Указываем, что нужен пользователь с определенным логином
			->where("login", "=", $login);
		
		// Если пользователь с таким логином есть
		if ($this->getCount())
		{
			// Получаем данные о пользователе
			$oUser = $this->query()->result()[0];
			
			// Сверяем переданный пароль с тем, что хранится в БД
			if (password_verify($password, $oUser->password))
			{
				// Записываем в текущий объект идентификатор пользователя
				$this->setPrimaryKey($oUser->id);
				
				unset($oUser);
				
				return TRUE;
			}
			// Если пароль не совпал
			else
			{
				return FALSE;
			}
		}
		// Если такой пользователь не найден
		else
		{
			return FALSE;
		}
		
		return FALSE;
	}
	
	/**
	 * Устанавливает авторизованного пользователя
	 */
	public function authUser($remember = FALSE)
	{
		// На момент авторизации пользователя уже должен быть известен его идентификатор
		// Этот идентификатор должен уже быть загружен в данные модели
		if (!empty($this->id))
		{
			throw new Exception("User::authUser() не установлен идентификатор пользователя");
		}
		
		// Стартуем сессию, генерируем её новый идентификатор
		Core_Session::sessionRegenerateId();
		
		// Записываем в сессию идентификатор пользователя
		$_SESSION["user_id"] = $this->id;
		
		// Пользователь авторизован
		
		// Если авторизацию пользователя нужно запомнить
		if ($remember === TRUE)
		{
			// Получаем параметры конфигурации для хэширования строк
			$aHashConfig = Core_Config::instance()->get("hash");
			
			// Получаем параметры конфигурации для выбора полей, значения которых будем хэшировать
			$aUserTokenConfig = Core_Config::instance()->get("usertoken");
			
			// Сохраняем имя функции хэширования
			$function = (!empty($aHashConfig["method"])) ? $aHashConfig["method"] : $aHashConfig["algo"];
			
			// Продолжаем только если определена указанная функция
			if (function_exists($function))
			{
				// Соль к строке хэширования
				$sSault = $aHashConfig["sault"];
				
				// Создаем переменную, в которую потом запишем строку для хэширования
				$sValue = "";
				
				// Продолжаем только если есть имена полей, значения которых должны создать строку для хэширования
				if (!empty($aUserTokenConfig))
				{
					foreach ($aUserTokenConfig as $component => $value)
					{
						$sValue .= (empty($value)) ? $this->$component : $value;
					}
					
					// Хэшируем строку
					if ($function == "password_hash")
					{
						$sHashedString = $function($sValue, $aHashConfig["algo"]);
					}
					else
					{
						$sHashedString = $function($sValue, $sSault);
					}
					
					// Сохраняем токен пользователя
					$oUser_Token = $this->User_Tokens;
					$oUser_Token->token($sHashedString)
						->save();
					
					// Сохраняем COOKIE с вновь созданным токеном
					Core_Array::setCookie([
						"name" => "user_token", 
						"value" => $sHashedString,
						"expires" => $oUser_Token->getTokenExpiresTimestamp()
					]);
				}
				else
				{
					throw new Exception("Не заданы поля для создания хэшированной строки. Невозможно авторизовать пользователя на длительный срок");
				}
			}
			else
			{
				throw new Exception("Не существует указанной вами функции " . $aHashConfig["algo"] . "(). Невозможно авторизовать пользователя на длительный срок");
			}
		}
		
		return $this;
	}
	
	/**
	 * Получает авторизованного пользователя
	 */
	public function getAuthUser()
	{
		// Запускаем сессию
		Core_Session::start();
		
		// Получаем идентификатор авторизованного пользователя
		$iUserId = intval(Core_Array::getSession("user_id"));
		
		// Если такой пользователь есть 
		if (!empty($iUserId))
		{
			// Загружаем данные о пользователе в модель
			$this->find($iUserId);
			
			return $this;
		}
		
		return NULL;
	}
}
?>

Авторизуемся на нашем тестовом сайте, пожелав запомнить авторизацию. После этого либо дождитесь окончания времени жизни сессии, либо удалите все сессионные куки браузера. Именно сессионные. Ваша авторизация сразу же «слетит». Теперь нужно поработать над авторизацией по токену.

Как это сделать? Смотрите, что у нас сейчас в коде файла /bootstrap.php.

<?php
// Файл bootstrap.php

// Включаем сессионную опцию use_strict_mode
ini_set('session.use_strict_mode', 1);

// Инициализируем константу для защиты от стороннего доступа
define("LEZH", TRUE);

// Определяем каталог исходных файлов нашего сайта
define("SITE_DIR", __DIR__);

// Определяем путь к каталогу файла с определением ядра сайта
define("CORE_DIR", SITE_DIR . DIRECTORY_SEPARATOR . "modules" . DIRECTORY_SEPARATOR . "core" . DIRECTORY_SEPARATOR);

// Подключаем файл с определением класса ядра нашего сайта
require_once(CORE_DIR . "core.php");

// Определяем путь для подключения внешних файлов для страниц сайта
define("INCLUDE_BLOCKS_PATH", SITE_DIR . DIRECTORY_SEPARATOR . "blocks" . DIRECTORY_SEPARATOR);

// Инициализируем ядро сайта
Core::init();

// Получаем информацию об авторизации пользователя
$oCurrentUser = Core::factory("User")->getAuthUser();

// Сохраняем информацию об авторизованном пользователе в хранилище
Core_Page::instance()->user = $oCurrentUser;
?>

Метод User_Model::getAuthUser() пытается получить данные авторизованного пользователя. Но без данных, сохраненных в сессии, он ничего не получит. А так как срок сессии истёк, или в связи с тем, что сессионные куки были очищены, этот метод должен попытаться поискать данные для авторизации в куках в виде пользовательского токена.

/**
 * Получает авторизованного пользователя
 */
public function getAuthUser()
{
	// Запускаем сессию
	Core_Session::start();
	
	// Получаем идентификатор авторизованного пользователя
	$iUserId = intval(Core_Array::getSession("user_id"));
	
	// Если такой пользователь есть 
	if (!empty($iUserId))
	{
		// Загружаем данные о пользователе в модель
		$this->find($iUserId);
		
		return $this;
	}
	// Если такого пользователя нет, пытаемся его найти через куки
	elseif (!empty($_COOKIE) && !empty(Core_Array::getCookie("user_token")))
	{
		// Сохраняем токен пользователя в переменную
		$sToken = strval(Core_Array::getCookie("user_token"));
		
		// Создаем объект модели токена
		$oUser_Token = Core::factory("User_Token")->getByToken($sToken);
		
		// Если такой токен есть
		if (!is_null($oUser_Token))
		{
			// Переходим к связанному с токеном объекту модели пользователя
			$oUser = $oUser_Token->User;
			
			// Если такой пользователь есть, авторизуем его
			if (!empty($oUser->getPrimaryKey()))
			{
				return $oUser->authUser();
			}
		}
	}
	
	return NULL;
}

Теперь давайте дополним сценарий в файле /logout.php, чтобы при деавторизации еще и деактивация токена пользователя происходила.

<?php
// Файл /logout.php

require_once('bootstrap.php');

// Сохраняем значение токена, если оно есть
if (!empty($_COOKIE) && !empty(Core_Array::getCookie("user_token")))
{
	// Записываем токен в переменную
	$sToken = strval(Core_Array::getCookie("user_token"));
	
	// Создаем объект модели токена, уточняя значение токена
	$oUser_Token = Core::factory("User_Token")->getByToken($sToken);
	
	// Если такой токен в БД существует
	if (!is_null($oUser_Token))
	{
		// Помечаем его на удаление
		$oUser_Token->setDeleted();
	}
}

// Устанавливаем для сессии отрицательное время жизни
Core_Session::setMaxLifeTime(-1);

// Запускаем сессию
Core_Session::start();

// Удаляем из сессии идентификатор сессии
if (!empty(Core_Array::getSession("user_id")))
{
	unset($_SESSION["user_id"]);
}

// Запускаем сессию с новым ID
Core_Session::sessionCreateId();

// Удаляем куку с токеном пользователя
Core_Array::setCookie(["name" => "user_token"]);

// Перенаправляем пользователя на главную страницу
header("Location: /");
?>

Ну и, пожалуй, на этом, всё. Всех благодарим за участие!

Вы можете скачать все файлы для работы описанного одним архивом: скачать
Вы можете просмотреть пример работы описанного выше: перейти

Сайт принадлежит ООО Группа Ралтэк. 2014 — 2021 гг