Pull to refresh

Comments 74

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

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

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

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

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

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

Для очистки старых сессий в PHP существует механизм под названием garbage collection. Он запускается в момент очередного запроса к серверу и чистит все старые сессии на основании даты последнего изменения файлов сессий. Но запуск механизма garbage collection происходит не при каждом запросе к серверу. Частота (а точнее, вероятность) запуска определяется двумя параметрами настроек session.gc_probability и session.gc_divisor. Результат от деления первого параметра на второй и есть вероятностью запуска механизма garbage collection.
Но при этом вы пишете:
Первый вопрос, который часто возникает у разработчиков всевозможных консолей для пользователей — автоматическое завершение сеанса в случае отсутствия активности со стороны пользователя. Нет ничего проще, чем сделать это с помощью встроенных возможностей 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.
Вы правы, спасибо за замечание. Я упустил, что для этого варианта необходимо устанавливать также время жизни сессионной куки, что делает этот способ еще хуже. Так что лучше на него вообще не полагаться и использовать вариант с сессионными переменными. Исправлю в статье. Вот так будет правильно:

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;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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-ым.
И ещё

if ( session_id() ) return true;

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

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

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

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

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

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

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

Articles