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

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

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

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



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

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

function startSession() {
	// Если сессия уже была запущена, прекращаем выполнение и возвращаем TRUE
	// (параметр session.auto_start в файле настроек php.ini должен быть выключен - значение по умолчанию)
	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), существует — отобразить страницу авторизованного пользователя (подробнее об обработке ошибок см. дополнение от 2013-06-07 в разделе о сессионных переменных).

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

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


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

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

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


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

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

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

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


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

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

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

	if ( session_id() ) return true;
	// Устанавливаем время жизни куки до закрытия браузера (контролировать все будем на стороне сервера)
	ini_set('session.cookie_lifetime', 0);
	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.

Дополнение от 2013-06-07

Обработка результата функции sessionStart()


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

Как видно, функция sessionStart может вернуть FALSE в двух случаях. Либо сессию не удалось запустить из-за каких-то внутренних ошибок сервера (например, неправильные настройки сессий в php.ini), либо время жизни сессии истекло. В первом случае мы должны перебросить пользователя на страницу с ошибкой о том, что есть проблемы на сервере, и формой обращения в службу поддержки. Во втором случае мы должны перевести пользователя на форму входа и вывести в ней соответствующее сообщение о том, что время сессии истекло. Для этого нам необходимо ввести коды ошибок и возвращать вместо 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, то сможете заметить, что в описанном выше случае, когда браузер закрывается и очищает все свои куки, в каталоге сессий какое-то время все еще остаются старые сессии. Но это не должно вас волновать, т.к. как уже было сказано, это ни коим образом не влияет на безопасность нашего механизма.

Дополнение от 2013-06-07

Предотвращение зависания скриптов из-за блокировки файла сессии



В комментариях подняли вопрос о зависании одновременно выполняющихся скриптов из-за блокировки файла сессии (как самый яркий вариант — long poll).

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

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

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


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


Представим себе ситуацию. Один из ваших пользователей цепляет троян, который грабит куки браузера (в котором хранится наша сессия) и отправляет его на указанный 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 : ''));
	ini_set('session.cookie_lifetime', 0);
	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 : ''));
	ini_set('session.cookie_lifetime', 0);
	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.
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 74

    +1
    Скажите, вы на практике это пробовали? Или это вы только теорию сюда вынесли?
      0
      Конечно. Есть даже небольшое тестовое приложение, которое позволяет продемонстрировать все рассмотренные вопросы.
        0
        Как вы получаете новое имя сессии для нескольких пользователей. Нужно передать уникальный идентификатор ($prefix) — откуда он берется?
          0
          Префикс должен передаваться в каждом запросе (либо в параметрах запроса, либо в куках, например). Этот префикс может быть, например, ником пользователя (что-то типа site.com?user=nickname, ну или более красивый вариант с роутером site.com/nickname, как это делается в социальных сетях, например). Хотя в общем случае префикс может быть просто уникальной строкой, сгенерированной при открытии формы логона. Но такой вариант действительно подходит разве что только для тестирования.
            0
            Если вы будете передавать префикс в куках — то в пределах одного браузера тестировать все равно не получится. Только GET.
              0
              Да, согласен. Я использую GET, как в предыдущем комментарии.
      +11
      В статье описаны не подводные камни, а неадекватные подходы к использования сессий. Подводные камни — это зависания сессий, не учтожение кук и прочие.

      Ваши подходы ненадёжны благодаря одному очень интересному фактору — сессии могу самопроизвольно закрываться. Таким образом у Вас при больших нагрузках начнут проявляться проблемы (потеря авторизации).
        0
        Не уничтожение кук никак не повлияет, поскольку время жизни сессии контролируется на стороне сервера. С самопроизвольным закрытием сессий не сталкивался. Теоретически это не возможно, т.к. механизм garbage collection работает достаточно просто. Но практически все может быть, конечно. А что значит зависание сессий? Если имеется ввиду не удаление (или не своевременное удаление), то это не баг, а фича работы garbage collection, которая запускается с вероятностью, установленной в настройках PHP. Но даже если старая сессия не удалена, и куки в браузере по-прежнему существует, описанный в статье контроль сессионных переменных не позволит выполнить запрос в старой сессии.
          +3
          Я бы к подводным камням отнёс проблемы зависания скриптов из-за блокировки сессионных файлов. Например, подобное описано в этой статье konrness.com/php5/how-to-prevent-blocking-php-requests/ или можете погуглить по запросу «php session locks».
            +3
            Да, проблема блокировки файлов сессий существует. Я как-то не сопоставил в предыдущем комментарии, что зависание сессий == блокировка файлов сессий. Пожалуй, стоило включить этот вопрос в рассмотрение в статье. Но раз уж не включил, отвечу здесь.

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

            Для сведения блокировки файлов сессий к минимуму настоятельно рекомендуется закрывать сессию путем вызова функции session_write_close() сразу после того, как выполнены все действия с сессионными переменными. На практике это означает, что не следует хранить в сессионных переменных все подряд и обращаться к ним на всем протяжении выполнения скрипта. А если и надо хранить в сессионных переменных какие-то рабочие данные, то считывать их сразу при старте сессии, сохранять в локальные переменные для последующего использования и закрывать сессию.
              0
              То есть Вы на корню исключаете long polling из доступных возможностей PHP?
                0
                Почему? Открывается сессия, считываются/записываются все необходимые сессионные переменные, сессия закрывается (session_write_close), и скрипт уходит в ожидание событий. Если после наступления события нужно еще что-то дописать в сессию, значит открываем повторно, записываем и закрываем.
                  –2
                  Если сессия закрывается, то как Вы собираетесь отдавать ответ пользователю?
                    0
                    Функция session_write_close() закрывает файл сессии (не удаляет, а закрывает). Этот файл всего лишь хранит сессионные переменные. Он будет доступен при следующем запросе к серверу. Таким образом, сессия закрывается, а не уничтожается. Запись куки в заголовок ответа сервера производится функциями session_srart() и session_regenerate_id(). После этого файл сессии можно закрывать. Дальше можно дописать еще чего-то в заголовки, если нужно, ну а дальше выводить контент.
                      –2
                      Да, я ошибся с session_write_close() — ответ пользователь сможет получить. Но от зависаний это не поможет. Если от того же самого пользователя придёт новый запрос, то они выстроятся в очередь — сессия зависнет.
                        0
                        Второй запрос зависнет только в том случае, если первый ушел в ожидание события и не освободил перед этим файл сессии, путем вызова session_write_close(). Ну или если первый запрос еще чего-то там заблокировал, но это уже к теме не относится.
                          –1
                          К теме это как раз и относится, т.к. ваш механизм управления сессиями исключает возможность обхода проблемной ситуации.
                            0
                            Описанный механизм не будет блокировать файл сессии, если сразу после идентификации пользователя и обработки сессионных переменных он закроет сессию и продолжит свою работу (в случае long poll — уйдет в цикл ожидания событий). Больше ничего этот механизм не блокирует. Если следующие запросы все-таки зависают, то дело не в механизме контроля сессии, и ошибку надо искать в другом месте.
          0
          А можно узнать, что вы имели ввиду под «сессии могут самопроизвольно закрываться»? Хотелось бы учесть этот момент в будущем.
          +1
          if ( $idLifetime ) {
                  if ( isset($_SESSION['starttime']) ) {
                      if ( $t-$_SESSION['starttime'] >= $idLifetime ) {
                          session_regenerate_id(true);
                          $_SESSION['starttime'] = $t;
                      }
                  }
                  else {
                      $_SESSION['starttime'] = $t;
                  }
              }
          

          какой лапшекод.
            0
            каким образом по нескольким return false; вы поймёте где на самом деле была ошибка?
              +1
              Конечно, в рабочем приложении нужно возвращать код ошибки (например, стандартные 403 в случае невозможности стартовать сессию, и 401 в случае завершения времени сессии), а в вызывающем методе обрабатывать этот код. В первом варианте статьи так и было. Но объем поста и так слишком велик, поэтому я исключил эти тонкости, поскольку они не относятся к теме статьи. А вообще — да, если не ввести обработку ошибок, то форма входа зациклится.
              0
              Я пишу компактнее и на ООП. Но такая структура кода была необходима, чтобы комментарии объясняли каждое выполняемое действие. Если свести все в две строки, пришлось бы обойтись без комментариев. По-моему, в данном случае краткость навредила бы. Но учту на будущее.
              0
              Спасибо за статью. Узнал из неё про существование функции session_regenerate_id() =))
              Такой вопрос. Во время вызова этой функции полностью пересоздается файл, в котором хранятся данные сессии на сервере? Или же просто выполняется его переименование? То есть, будут ли все переменные «старой» сессии доступны для сессии с новым идентификатором без необходимости их «перекладывания руками»?
                0
                Конечно будут. Данные остаются, меняется только идентификатор.
                  0
                  Создается новый файл и удаляется старый. Но можете не волноваться по поводу переменных из старого файла. Они копируются в новый целиком и полностью.
                  0
                  От использования ворованных кукисов неплохо помогает привязка сессии к одному или нескольким IP.
                  В этом случае простое воровстро не поможет, и троян должен уметь слать запросы с компа пользователя.
                    0
                    Если троян украл куки браузера, то он уже на компе пользователя, и запросы от него будут идти с IP пользователя.
                      0
                      Не всегда у трояна может быть возможность слать произвольные запросы, файерволл может разрешать ему доступ только к одному домену/IP.
                        0
                        Конечно, но тогда и проблем нет — проверка IP в таком случае поможет. Но, к сожалению, это только частный случай и не дает полной гарантии.
                      0
                      Есть NAT, благодаря которому с одного IP могут делать запросы сотни разных компьютеров. В случае целевой атаки (например посмотреть приватную переписку родственника или коллеги) привязка к IP не спасет в таких случаях.
                        0
                        Да, я и не говорил что это панацея. Но попасть с жертвой атаки за один и тот же NAT — это еще надо постараться.
                      +1
                      Здесь следует отметить, что параметр session.gc_maxlifetime действует на все сессии в пределах одного сервера.

                      Не совсем корректно. Он действует на все сессии в рамках одного главного процесса php (мастер и все его форки). Чаще всего он один (даже если слушает несколько разных сокетов от разных пользователей), но это не абсолютное правило. «Глобальные» настройки PHP на самом деле per master process, а не per system.
                        0
                        Не всегда.
                        В debian-based дистрибутивах из коробки вероятность запуска сборщика старых сессий установлена в 0, а время жизни сессии выгрепывается-выседывается из глобальных конфигов и используется cronjob'ом, который при помощи find удаляет файлы сессий, к которым последний раз к ним обращались больше, чем session.gc_maxlifetime/60 минут назад.
                        0
                        Спасибо за уточнение, подправлю в посте.
                          0
                          Добавлю, что session.gc_maxlifetime создан не для валидации времени жизни сессии! Это значит, что по истечении данного времени, сессия не обязательно будет удалена.
                          session.gc_maxlifetime задает отсрочку времени в секундах, после которой данные будут рассматриваться как «мусор» и потенциально будут удалены. Сбор мусора может произойти в течение старта сессии (в зависимости от значений session.gc_probability и session.gc_divisor).
                            0
                            Абсолютно верно, и об этом было сказано в статье:

                            Для очистки старых сессий в PHP существует механизм под названием garbage collection. Он запускается в момент очередного запроса к серверу и чистит все старые сессии на основании даты последнего изменения файлов сессий. Но запуск механизма garbage collection происходит не при каждом запросе к серверу. Частота (а точнее, вероятность) запуска определяется двумя параметрами настроек session.gc_probability и session.gc_divisor. Результат от деления первого параметра на второй и есть вероятностью запуска механизма garbage collection.
                            +1
                            Но при этом вы пишете:
                            Первый вопрос, который часто возникает у разработчиков всевозможных консолей для пользователей — автоматическое завершение сеанса в случае отсутствия активности со стороны пользователя. Нет ничего проще, чем сделать это с помощью встроенных возможностей PHP.

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

                            Это может ввести в заблуждение, что контроль отсутствия активности пользователя можно возложить на garbage collector.
                              0
                              Вы правы, спасибо за замечание. Я упустил, что для этого варианта необходимо устанавливать также время жизни сессионной куки, что делает этот способ еще хуже. Так что лучше на него вообще не полагаться и использовать вариант с сессионными переменными. Исправлю в статье. Вот так будет правильно:

                              function startSession() {
                                  $sessionLifetime = 300;
                              
                                  if ( session_id() ) return true;
                                  ini_set('session.cookie_lifetime', $sessionLifetime);
                                  if ( $sessionLifetime ) ini_set('session.gc_maxlifetime', $sessionLifetime);
                                  if ( session_start() ) {
                              	setcookie(session_name(), session_id(), time()+$sessionLifetime);
                              	return true;
                                  }
                                  else return false;
                              }
                              
                              
                              –1
                              К подводным камням можно также отнести и отсутствие соответствующей записи в базе данных сразу после старта новой сессии и до завершения скрипта (если используется собственный механизм). Не видел ни одной статьи, где об этом нюансе хотя бы вскользь упоминается.
                                0
                                %Ваш% минус говорит только о том, что вы этого не только не знаете, но и считаете что происходит по другому. А стоило бы проверить собственноручно. %Вам% же на пользу.
                                0
                                Получается противоречие между

                                 setcookie(session_name(), session_id(), time()+$sessionLifetime);
                                

                                и
                                браузер закрывается и очищает все свои куки

                                Такая кука не удалиться во время закрытия браузера.
                                  +1
                                  аааа… хочу исправить свой позор…
                                    0
                                    Да, если время жизни установлено не в ноль, то не удалится. Но это и правильно. Если при следующем открытии браузера время жизни куки не истекло, значит сессия поднимется. Хотя, согласен, можно подправить в тексте, чтобы не было вопросов. Спасибо.
                                    0
                                    Кстати, всегда считал, что в единственном числе будет «Кука» (женский род).
                                      0
                                      Я тоже так считал. Но в результате решил что кука — это файл. А файл — это он. Нигде не нашел, как все-таки правильно, и написал «он».
                                        0
                                        Нашли за что минусовать. Такого слова в русском языке вообще не существует. А в английском это «оно», раз уж на то пошло.
                                      0
                                      И ещё. Если функция startSession вернёт false, то я не уверен, что показ формы авторизации поможет.
                                        0
                                        Тут было пару комментариев по поводу возврата. Конечно, надо возвращать не просто FALSE, а код ошибки, чтобы определить в вызывающем методе причину — сессия не смогла стартануть (внутренняя ошибка сервера) или сессия просрочена. Но обработка возвратов — это уже за рамками статьи, поэтому убрал.

                                        Так почему не поможет? Если сессия не стартанула по внутренней причине сервера, то она и не стартанет (например, сессии запрещены или параметры криво настроены в php.ini). А если с сессией все в порядке, но она истекла, то мы попадем на destroySession, уничтожим сессию и выведем форму входа. Без вариантов.
                                          0
                                          Что-то я не понял, как просроченная сессия связана с тем, что сессия вообще не стартовала?
                                            0
                                            Никак не связана. Если сессия не стартовала, то что показывать? Форму логона, конечно. Но только с сообщением о том, что с сервером что-то не в порядке. А если стартовала, но просрочена — тоже форму логона, но только уже с сообщением о том, что сессия истекла. Но это все очевидно и выходит за рамки статьи. Поэтому и спрашиваю — что не так с формой логона?
                                              –1
                                              Перед первым примечанием идёт код, который не связан с истечением сессии. Если сессия не стартанула, то нужно звонить, пищать и всячески махать флагами в сторону поддержки, а не просто «отобразить в браузере форму входа».

                                              Так же получается противоречие между названиями функции, тем что она делает.

                                              И зачем при истекшей сессии возвращать false, ведь можно просто начать новую сессию и тогда не нужно будет заниматься дополнительной обработкой вообще?
                                                +1
                                                Да, конечно, надо махать флагами, если с сервером что-то не то. Мне надо было отобразить это в статье, вы считаете?

                                                И в чем противоречие в названии функции? Функция называется startSession и занимается тем, что стартует сессию и проверяет, чтобы она была не просроченной.

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

                                                Что-то я не совсем вас понимаю…
                                                  0
                                                  … вместо того, чтобы строить здесь полноценное тестовое приложение с… исчерпывающей обработкой ошибок ...

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

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

                                                  "… при истекшей сессии выкидывать пользователя из приложения на форму входа." Простите, а Вы показываете форму входа только тем, у которого время сессии истекло или всем, у кого в сессии не отмечено, что он вошёл? Зачем отдельная обработка тех у кого сессия кончилась? Ведь после окончания времени сессии пользователь должен считаться как новый и для него сайт должен выглядеть так же как и для любого другого нового пользователя сайта. А Вы поделили пользователей не на две, а на три группы: пользователь без сессии(первый раз открыл сайт), пользователь с сессией(уже листает сайт), пользователь, у которого сессия истекла. Или вы именно такого поведения добивались?
                                                    0
                                                    Да, я добивался именно такого поведения. Вот пример из реального проекта.

                                                    Есть однооконное JS-приложение, которое взаимодействует с сервером через ajax. Если время отсутствия активности пользователя истекло (функция startSession уничтожила сессию и вернула код 401, и сервер, в свою очередь, вернул на ajax-запрос этот же код), я выкидываю окно с сообщением, что время сеанса истекло, а после закрытия окна перевожу пользователя на форму входа (как вариант, можно обойтись без окна, переводить сразу на форму входа, а в самой форме красными жирными буквами написать, что время бездействия истекло). Делается это для того, чтобы пользователь понимал, почему вместо приложения он видит форму логона. Если произошла ошибка старта сессии (startSession вернула код 500, и сервер вернул в браузер этот же код), я перекидываю пользователя на страницу ошибки (не форму логона) с предложением сообщить о случившемся в службу поддержки. Форма логона работает тоже через ajax, и если пользователь не проходит аутентификацию (это лежит за пределами sessionStart), то я выдаю в форме логона красными жирными буквами сообщение об этом.

                                                    В результате, любая ситуация отрабатывается так, как положено для максимального удобства пользователя. Но это все абсолютно никак не относится к теме статьи, поэтому я и не подумал расписывать все эти моменты и увеличивать еще вдвое и так достаточно объемную статью. Но хорошо, что все эти моменты всплыли в комментариях. Это дополнило статью достаточно полезными близкими к теме сведениями, за что вам и всем комментировавшим большое спасибо.
                                                      –1
                                                      Вот видите. Вы даже код возвращаете 401, что обозначает «неавторизован». Что обозначает, что пользователь просто неавторизован. И не важно истекла у него сессия или она только что началась. В любом случае он просто неавторизован. Нет дополнительного кода 488 «неавторизован, потому что время сессии истекло только что».

                                                      Коротко выскажу своё мнение: есть два статуса пользователя: либо он авторизован, либо нет. Не должно быть чего-то промежуточного.
                                                        0
                                                        488 — не знал, что этот код означает, что время сессии истекло. Нигде в стандартах такого не видел, использовал бы его, конечно. Спасибо за подсказку. Но на результат это не влияет, потому что, когда пользователь внутри приложения получает 401, это значит, что время сессии истекло (других вариантов этого кода быть не может внутри приложения), а когда он получает 401 на форме логона, это значит, что аутентификация не прошла (аналогично, других вариантов быть не может).

                                                        С точки зрения приложения действительно есть только авторизованный и не авторизованный пользователь. Но вы же не хотите сказать, что мое стремление сделать жизнь пользователя проще и понятнее заслуживает порицания? Что плохого в том, что пользователь получает немного больше информации, чем думает о нем приложение?
                                                          0
                                                          Шутка… такого когда нет. В том-то всё и дело.: )

                                                          Ну вот. Видите, Вам даже не нужно проверять сессию дополнительно: 401 на форме логона — неправильный вход, 401 на внутренних страницах — время сессии истекло.
                                                            0
                                                            От вы шутник… А я уже десяток сайтов обошел в поисках 488 ошибки :)

                                                            Так я ничего и не проверяю дополнительно. Этот код возвращают два метода. Первый описан в статье — это sessionStart. Второй — метод аутентификации пользователя. Для приложения это одно и то же — в обоих случаях оно выкидывает в ответ заголовок с кодом ошибки. Но дело в том, что оно возвращает этот код в разные места — один в консоль авторизованного пользователя, второй — в форму логона. Поэтому я могу реагировать в обоих случаях по-разному, потому что для пользователя, в отличие от приложения и от нас с вами, это две разные ситуации. И вот, честно, не могу понять — что здесь плохого?
                                                              0
                                                              Плохо в том, что вы это проверяете в функции с названием startSession. И то, что если сессия действительно(а не по Вашим хитрым алгоритмам) закончится Вы не узнаете истекла сессия или просто началась новая.

                                                              И ещё вспомнил. С Вашим подходом можно попасть на форму входа со страницы с гостевым доступом, если долго отсутствовал на сайте.
                                                                0
                                                                Не совсем понял, о каких хитрых алгоритмах речь, и почему я не узнаю, что сессия истекла.

                                                                Разве режим гостевого доступа не попадает под правила отсутствия активности?
                                                                  0
                                                                  Кончилось время куки — сессия истекла. Кончилось время сессии — сессия истекла. В обоих случаях стартанётся новая сессия и Вы не узнаете(если, конечно, не использовать свой хендлер) какая была предыдущая сессия и была ли она вообще.
                                                                    0
                                                                    Если одно из этих событий произошло, когда браузер был открыт, и пользователь висел без действия в консоли, JS получив от сервера на очередной AJAX-запрос (когда пользователь все-таки проснулся) ответ 401, перекинет пользователя на форму входа с сообщением «Your session expired».

                                                                    Если эти события произошли, когда браузер/вкладку закрыли, а потом открыли, будет выведена форма логона без всяких сообщений. Да, здесь тоже можно было бы написать, что сессия истекла. Но совсем не обязательно. Пользователь закрыл вкладку и ушел. Через пару часов (или дней) открыл ее опять и получил форму логона. По-моему, логично. А когда он в консоли попадает на таймаут — вот тогда надо сказать, что время вышло.

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

                                                            По хорошему нужно посылать 403 по-моему, если введен неправильный пароль. То есть 401 просит ввести логин и парол в принципе, указывая, что они должны быть, а 403 сообщает что логин и пароль есть, но пользователю с таким их сочетанием доступ запрещен.
                                                              0
                                                              Хотелось бы, но не совсем так.

                                                              403 Forbidden
                                                              The request was a valid request, but the server is refusing to respond to it. Unlike a 401 Unauthorized response, authenticating will make no difference. On servers where authentication is required, this commonly means that the provided credentials were successfully authenticated but that the credentials still do not grant the client permission to access the resource (e.g. a recognized user attempting to access restricted content).


                                                              Так что приходится довольствоваться одним 401-ым.
                                                    0
                                                    И ещё

                                                    if ( session_id() ) return true;
                                                    

                                                    Этот участок кода вообще не нужен. Это и есть подводный камень. Ведь если включен session.auto_start или что-нибудь другое стартует сессию, то мы можем очень долго отлаживать свой код, который может оказаться вообще ни при делах.

                                                    Короче, старт сессии, по моему мнению, должен выглядеть примерно так:

                                                    function startSession() {
                                                        // установка параметров сессии
                                                        if (!session_start())
                                                            throw new Exception('Сессия не может быть запущена (либо сервер гонит, либо что-то не так с кодом)');
                                                    }
                                                    
                                                      0
                                                      Соглашусь, что не учел session.auto_start (просто он по умолчанию выключен, да и не встречал я людей, которые его включают, но согласен, что надо было упомянуть об этом, внесу правку, спасибо).

                                                      Что касается if (! session_start() ) — тут согласиться не могу, потому что не у всех пока PHP 5.3, а до 5.3 функция session_start() всегда возвращала TRUE.
                                                        0
                                                        Ну, не суть. Тогда вот так.

                                                        function startSession() {
                                                            // установка параметров сессии
                                                            session_start();
                                                            if (!session_id())
                                                                throw new Exception('Сессия не может быть запущена (либо сервер гонит, либо что-то не так с кодом)');
                                                        }
                                                        
                                                          0
                                                          А зачем вызывать session_start() каждый раз при входе в функцию, если сессия уже может быть запущена? Вот для этого и стоит первой строкой проверка session_id(), а потом уже session_start(). В общем, это уже пошли мелочи, не стоящие нашего с вами времени.

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

                                                          Думаю, разобрались :)
                                                        0
                                                        А что вы имеете ввиду под «что-нибудь другое стартует сессию»? Разве может стартовать сессию что-то отличное от session.auto_start и session_start()? Если нет, то в коде все правильно. Если отключен session.auto_start, и функция session_id() вернула ненулевой результат, то значит мы уже заходили сюда раньше во время выполнения этого экземпляра скрипта, а значит мы уже сделали все проверки и повторно делать их не за чем.
                                                          0
                                                          Т.е. Вы считаете правильным если Ваша функция старта сессии была вызвана несколько раз?
                                                            0
                                                            Нет, я так не считаю, конечно. Но если говорить о библиотеке, которую могут использовать другие разработчики, работающие со мной в паре, то это вполне возможно, согласитесь. Кто-то может сделать ошибку в логике и вызвать sessionStart() повторно. Библиотека должна быть к этому готова, я считаю.

                                          Only users with full accounts can post comments. Log in, please.