Подводные камни использования сессий в PHP

в 8:58, , рубрики: cookie, php, авторизация, аутентификация, Веб-разработка, идентификация, Программирование, сессии, метки: , , , , ,

image
Приветствую, уважаемое сообщество.

Прежде всего, хочу поблагодарить за очень полезный ресурс. Не раз находил здесь множество интересных идей и практических советов.

Цель этой статьи — осветить подводные камни использования сессий в PHP. Конечно, есть документация по PHP и масса примеров, и данная статья не претендует на полное руководство. Она призвана раскрыть некоторые ньюансы работы с сессиями и оградить разработчиков от ненужной траты времени.

Самым распространенным примером использования сессий является, конечно, авторизация пользователей. Начнем с самой базовой реализации, чтобы последовательно развивать ее по мере появления новых задач.

(В целях экономии места и времени ограничимся в примерах только самими функциями работы с сессиями, вместо того, чтобы строить здесь полноценное тестовое приложение с красивой иерархией классов, исчерпывающей обработкой ошибок и прочими правильными штуками).

function startSession() {
	// Если сессия уже была запущена, прекращаем выполнение и возвращаем TRUE
	if ( session_id() ) return true;
	else return session_start();
	// Примечание: До версии 5.3.0 функция session_start()возвращала TRUE даже в случае ошибки.
	// Если вы используете версию ниже 5.3.0, выполняйте дополнительную проверку session_id()
	// после вызова session_start()
}

function destroySession() {
	if ( session_id() ) {
		// Если есть активная сессия, удаляем куки сессии,
		setcookie(session_name(), session_id(), time()-60*60*24);
		// и уничтожаем сессию
		session_unset();
		session_destroy();
	}
}

Примечание: Подразумевается, что базовые знания о сессиях PHP у читателя имеются, поэтому принцип работы функций session_start() и session_destroy() освещать здесь не будем. Задачи верстки формы входа и аутентификации пользователя не относятся к теме статьи, поэтому их мы также опустим. Напомню только, что для идентификации пользователя в каждом последующем запросе, нам необходимо в момент успешного входа сохранить в сессионной переменной (с именем userid, например) идентификатор пользователя, который будет доступен во всех последующих запросах в пределах жизни сессии. Также необходимо реализовать обработку результата нашей функции startSession(). Если функция вернула FALSE — отобразить в браузере форму входа. Если функция вернула TRUE, и сессионная переменная, содержащая идентификатор авторизованного пользователя (в нашем случае — userid), существует — отобразить страницу авторизованного пользователя.

Пока все понятно. Вопросы начинаются, когда требуется реализовать контроль отсутствия активности пользователя (session timeout), дать возможность одновременной работы в одном браузере нескольких пользователей, а также защитить сессии от несанкционированного использования. Об этом и пойдет речь ниже.

Контроль отсутствия активности пользователя встроенными средствами PHP

Первый вопрос, который часто возникает у разработчиков всевозможных консолей для пользователей — автоматическое завершение сеанса в случае отсутствия активности со стороны пользователя. Нет ничего проще, чем сделать это с помощью встроенных возможностей PHP.

function startSession() {
	// Таймаут отсутствия активности пользователя (в секундах)
	$sessionLifetime = 300;

	if ( session_id() ) return true;
	// Если таймаут отсутствия активности пользователя задан, устанавливаем время жизни сессии на сервере
	// Примечание: Для production-сервера рекомендуется предустановить этот параметр в файле php.ini
	if ( $sessionLifetime ) ini_set('session.gc_maxlifetime', $sessionLifetime);
	return session_start();
}

Немного пояснений. Как известно, PHP определяет, какую именно сессию нужно запустить, по имени куки, передаваемом браузером в заголовке запроса. Браузер же, в свою очередь, получает этот куки в заголовке предыдущего ответа сервера, куда помещает его функция session_start(). Если время жизни куки в браузере истекло, он не будет передан в запросе, а значит PHP не сможет определить, какую сессию нужно запустить, и расценит это как создание новой сессии. Но контролировать сессии на основании времени жизни куки браузера — не слишком надежное решение. Существует возможность заполучить куки и отправить поддельный HTTP-запрос, подставив нужный куки, даже если в браузере время его жизни уже истекло. Поэтому в нашем примере мы реализовали контроль времени жизни сессии на стороне сервера.

Параметр настроек PHP session.gc_maxlifetime, который устанавливается равным нашему таймауту отсутствия активности пользователя, задает время жизни PHP-сессии и контролируется сервером, а значит является более надежным, чем контроль времени жизни куки браузером. Работает контроль времени жизни сессии следующим образом (здесь рассматривается пример хранилища сессий во временных файлах как самый распространенный и установленный по умолчанию в PHP вариант).

В момент создания новой сессии в каталоге, установленном как каталог для хранения сессий в параметре настроек PHP session.save_path, создается файл с именем sess_<sessionid>, где <sessionid> — идентификатор сессии. Далее, в каждом запросе, в момент запуска уже существующей сессии, PHP обновляет время модификации этого файла. Таким образом, в каждом следующем запросе PHP, путем разницы между текущим временем и временем последней модификации файла сессии, может определить, является ли сессия активной, или ее время жизни уже истекло. Если время жизни сессии истекло, после вызова функции session_start() глобальная переменная $_SESSION окажется пустой. В нашем случае это означает, что мы не получим идентификатор авторизованного ранее пользователя, сохраненный в сессионной переменной, и расценим этот запрос как неавторизованный, что нам и требовалось. Если же запрос пришел до истечения времени жизни сессии, PHP обновит дату модификации файла при вызове функции session_start(), тем самым продлив время жизни сессии на установленный нами таймаут отсутствия активности пользователя.

Примечание: Здесь следует отметить, что параметр session.gc_maxlifetime действует на все сессии в пределах одного сервера. Это значит, что если на сервере работает несколько сайтов, и каждый из них имеет собственный таймаут отсутствия активности пользователей, то установка этого параметра на одном из сайтов приведет к его установке и для других сайтов. То же касается и shared-хостинга. Для избежания подобной ситуации используются отдельные каталоги сессий для каждого сайта в пределах одного сервера. Установка пути к каталогу сессий производится с помощью параметра session.save_path в файле настроек php.ini, или путем вызова функции ini_set(). После этого сессии каждого сайта будут храниться в отдельных каталогах, и параметр session.gc_maxlifetime, установленный на одном из сайтов, будет действовать только на его сессии. Мы не станем рассматривать этот случай подробно, тем более, что у нас в запасе есть более гибкий вариант контроля отсутствия активности пользователя.

Контроль отсутствия активности пользователя с помощью сессионных переменных

Казалось бы, предыдущий вариант при всей своей простоте (всего одна дополнительная строка кода, а в случае установки параметра session.gc_maxlifetime в файле php.ini, и вообще без изменений) дает все, что нам нужно. Но что, если не каждый запрос можно расценивать как результат активности пользователя? Например, на странице установлен таймер, который периодически выполняет AJAX-запрос на получение обновлений от сервера. Такой запрос нельзя расценивать как активность пользователя, а значит автоматическое продление времени жизни сессии является не корректным в данном случае. Но мы знаем, что PHP обновляет время модификации файла сессии автоматически при каждом вызове функции session_start(), а значит любой запрос приведет к продлению времени жизни сессии, и таймаут отсутствия активности пользователя не наступит никогда. К тому же, последнее примечание из предыдущего раздела о тонкостях работы параметра session.gc_maxlifetime может показаться кому-то слишком запутанным и сложным в реализации.

Для решения этой проблемы откажемся от использования встроенных механизмов PHP и введем несколько новых сессионных переменных, которые позволят нам контролировать время отсутствия активности пользователей самостоятельно.

function startSession($isUserActivity=true) {
	$sessionLifetime = 300;

	if ( session_id() ) return true;
	if ( ! session_start() ) return false;

	$t = time();

	if ( $sessionLifetime ) {
		// Если таймаут отсутствия активности пользователя задан,
		// проверяем время, прошедшее с момента последней активности пользователя
		// (время последнего запроса, когда была обновлена сессионная переменная lastactivity)
		if ( isset($_SESSION['lastactivity']) && $t-$_SESSION['lastactivity'] >= $sessionLifetime ) {
			// Если время, прошедшее с момента последней активности пользователя,
			// больше таймаута отсутствия активности, значит сессия истекла, и нужно завершить сеанс
			destroySession();
			return false;
		}
		else {
			// Если таймаут еще не наступил,
			// и если запрос пришел как результат активности пользователя,
			// обновляем переменную lastactivity значением текущего времени,
			// продлевая тем самым время сеанса еще на sessionLifetime секунд
			if ( $isUserActivity ) $_SESSION['lastactivity'] = $t;
		}
	}

	return true;
}

Подытожим. В каждом запросе мы проверяем, не достигнут ли таймаут с момента последней активности пользователя до текущего момента, и если он достигнут — уничтожаем сессию и прерываем выполнение функции, возвращая FALSE. Если же таймаут не достигнут, и в функцию передан параметр $isUserActivity со значением TRUE — обновляем время последней активности пользователя. Все, что нам остается сделать — это определять в вызывающем скрипте, является ли запрос результатом активности пользователя, и если нет — вызывать функцию startSession со значением параметра $isUserActivity, равным FALSE.

Теперь, даже если сессия на сервере по-прежнему существует, она будет уничтожена при первом же обращении к ней, если таймаут отсутствия активности пользователя истек. И это произойдет независимо от того, какое время жизни сессий установлено в глобальных настройках PHP.

Примечание: А что произойдет, если браузер был закрыт, и куки с именем сессии был автоматически уничтожен? Запрос к серверу при следующем открытии браузера не будет содержать куки сессии, и сервер не сможет открыть сессию и проверить таймаут отсутствия активности пользователя. Для нас это равносильно созданию новой сессии и никак не влияет на функционал и безопасность. Но возникает справедливый вопрос — а кто же тогда уничтожит старую сессию, если до сих пор ее уничтожали мы по истечении таймаута? Или она теперь будет висеть в каталоге сессий вечно? Для очистки старых сессий в PHP существует механизм под названием garbage collection. Он запускается в момент очередного запроса к серверу и чистит все старые сессии на основании даты последнего изменения файлов сессий. Но запуск механизма garbage collection происходит не при каждом запросе к серверу. Частота (а точнее, вероятность) запуска определяется двумя параметрами настроек session.gc_probability и session.gc_divisor. Результат от деления первого параметра на второй и есть вероятностью запуска механизма garbage collection. Таким образом, для того, чтобы механизм очистки сессий запускался при каждом запросе к севреру, эти параметры нужно установить в равные значения, например «1». Такой подход гарантирует чистоту каталога сессий, но, очевидно, является слишком накладным для сервера. Поэтому в production-системах по умолчанию устанавливается значение session.gc_divisor, равное 1000, что означает, что механизм garbage collection будет запускаться с вероятностью 1/1000. Если вы поэкспериментируете с этими настройками в своем файле php.ini, то сможете заметить, что в описанном выше случае, когда браузер закрывается и очищает все свои куки, в каталоге сессий какое-то время все еще остаются старые сессии. Но это не должно вас волновать, т.к. как уже было сказано, это ни коим образом не влияет на безопасность нашего механизма.

Защита сессий от несанкционированного использования

Представим себе ситуацию. Один из ваших пользователей цепляет троян, который грабит куки браузера (в котором хранится наша сессия) и отправляет его на указанный email. Злоумышленник получает куки и использует его для подделки запроса от имени нашего авторизованного пользователя. Сервер успешно принимает и обрабатывает этот запрос, как если бы он пришел от авторизованного пользователя. Если не реализована дополнительная проверка IP-адреса, такая атака приведет к успешному взлому аккаунта пользователя со всеми вытекающими последствиями.

Почему это стало возможным? Очевидно, потому что имя и идентификатор сессии всегда одни и те же на все время жизни сессии, и если получить эти данные, то можно беспрепятственно слать запросы от имени другого пользователя (естественно, в пределах времени жизни этой сессии). Возможно, это не самый распространенный вид атак, но теоретически все выглядит вполне реализуемым, особенно учитывая, что подобному трояну даже не нужны права администратора, чтобы грабить куки браузера пользователя.

Как же можно защититься от атак подобного рода? Опять-таки, очевидно, ограничив время жизни идентификатора сессии и периодически изменяя идентификатор в пределах одной сессии. Мы можем также изменять и имя сессии, полностью удаляя старую и создавая новую сессию, копируя в нее все сессионные переменные из старой. Но на суть подхода это не влияет, поэтому для простоты ограничимся только идентификатором сессии.

Понятно, что чем меньше время жизни идентификатора сессии, тем меньше будет времени у злоумышленника, чтобы получить и применить куки для подделки запроса пользователя. В идеальном случае для каждого запроса должен использоваться новый идентификатор, что позволит свести к минимуму возможность использования чужой сессии. Но мы рассмотрим общий случай, когда время регенерации идентификатора сессии устанавливается произвольно.

(Опустим ту часть кода, которая уже рассмотрена).

function startSession($isUserActivity=true) {
	// Время жизни идентификатора сессии
	$idLifetime = 60;
	...
	if ( $idLifetime ) {
		// Если время жизни идентификатора сессии задано,
		// проверяем время, прошедшее с момента создания сессии или последней регенерации
		// (время последнего запроса, когда была обновлена сессионная переменная starttime)
		if ( isset($_SESSION['starttime']) ) {
			if ( $t-$_SESSION['starttime'] >= $idLifetime ) {
				// Время жизни идентификатора сессии истекло
				// Генерируем новый идентификатор
				session_regenerate_id(true);
				$_SESSION['starttime'] = $t;
			}
		}
		else {
			// Сюда мы попадаем, если сессия только что создана
			// Устанавливаем время генерации идентификатора сессии в текущее время
			$_SESSION['starttime'] = $t;
		}
	}

	return true;
}

Итак, при создании новой сессии (которое происходит в момент успешного входа пользователя), мы устанавливаем сессионную переменную starttime, хранящую для нас время последней генерации идентификатора сессии, в значение, равное текущему времени сервера. Далее в каждом запросе мы проверяем, не прошло ли достаточно времени (idLifetime) с момента последней генерации идентификатора, и если прошло — генерируем новый. Таким образом, если в течение установленного времени жизни идентификатора злоумышленник, получивший куки авторизованного пользователя, не успеет им воспользоваться, поддельный запрос будет расценен сервером как неавторизованный, и злоумышленник попадет на страницу входа.

Примечание: Новый идентификатор сессии попадает в куки браузера при вызове функции session_regenerate_id(), которая отправляет новый куки, аналогично функции session_start(), поэтому нам нет необходимости обновлять куки самостоятельно.

Если мы хотим максимально обезопасить наши сессии, достаточно установить время жизни идентификатора в единицу или же вообще вынести функцию session_regenerate_id() за скобки и убрать все проверки, что приведет к регенерации идентификатора в каждом запросе. (Я не проверял влияние такого подхода на быстродействие, и могу только сказать, что функция session_regenerate_id(true) выполняет по сути всего 4 действия: генерация нового идентификатора, создание заголовка с куки сессии, удаление старого и создание нового файла сессии).

Лирическое отступление: Если троян окажется настолько умным, что не будет отправлять куки злоумышленнику, а сам организует отправку заранее подготовленного поддельного запроса сразу при получении куки, описанный выше метод, скорее всего, не сможет защитить от подобной атаки, потому что между временем получения трояном куки и отправкой поддельного запроса практически не будет разницы, и велика вероятность, что в этот момент не произойдет регенерации идентификатора сессии.

Возможность одновременной работы в одном браузере от имени нескольких пользователей

Последняя задача, которую хотелось бы рассмотреть — возможность одновременной работы в одном браузере нескольких пользователей. Эта возможность особенно полезна на этапе тестирования, когда нужно эмулировать одновременную работу пользователей, и желательно делать это в своем любимом браузере, а не использовать весь доступный арсенал или открывать несколько экземпляров браузера в режиме «инкогнито».

В наших предыдущих примерах мы не задавали явно имя сессии, поэтому использовалось имя, установленное в PHP по умолчанию (PHPSESSID). Это значит, что все сессии, которые создавались нами до сих пор, отправляли браузеру куки под именем PHPSESSID. Очевидно, что если имя куки всегда одинаковое, то нет возможности в пределах одного браузера организовать две сессии с одинаковым именем. Но если бы мы для каждого пользователя использовали собственное имя сессии, то проблема была бы решена. Так и сделаем.

function startSession($isUserActivity=true, $prefix=null) {
	...
	if ( session_id() ) return true;
	// Если в параметрах передан префикс пользователя,
	// устанавливаем уникальное имя сессии, включающее этот префикс,
	// иначе устанавливаем общее для всех пользователей имя (например, MYPROJECT)
	session_name('MYPROJECT'.($prefix ? '_'.$prefix : ''));
	if ( ! session_start() ) return false;
	...
}

Теперь осталось позаботиться о том, чтобы вызывающий скрипт передавал в функцию startSession() уникальный префикс для каждого пользователя. Это можно сделать, например, через передачу префикса в GET/POST параметрах каждого запроса или через дополнительный куки.

Заключение

В заключение приведу полный конечный код наших функций для работы с сессиями PHP, включающий все рассмотренные выше задачи.

function startSession($isUserActivity=true, $prefix=null) {
	$sessionLifetime = 300;
	$idLifetime = 60;

	if ( session_id() ) return true;
	session_name('MYPROJECT'.($prefix ? '_'.$prefix : ''));
	if ( ! session_start() ) return false;

	$t = time();

	if ( $sessionLifetime ) {
		if ( isset($_SESSION['lastactivity']) && $t-$_SESSION['lastactivity'] >= $sessionLifetime ) {
			destroySession();
			return false;
		}
		else {
			if ( $isUserActivity ) $_SESSION['lastactivity'] = $t;
		}
	}

	if ( $idLifetime ) {
		if ( isset($_SESSION['starttime']) ) {
			if ( $t-$_SESSION['starttime'] >= $idLifetime ) {
				session_regenerate_id(true);
				$_SESSION['starttime'] = $t;
			}
		}
		else {
			$_SESSION['starttime'] = $t;
		}
	}

	return true;
}

function destroySession() {
	if ( session_id() ) {
		session_unset();
		setcookie(session_name(), session_id(), time()-60*60*24);
		session_destroy();
	}
}

Надеюсь, эта статья сэкономит немного времени тем, кто никогда особо не углублялся в механизм сессий, и даст достаточно понимания этого механизма тем, кто только начинает знакомиться с PHP.

Автор: socioniq

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js