1<?php
2/**
3 * Zend Framework (http://framework.zend.com/)
4 *
5 * @link      http://github.com/zendframework/zf2 for the canonical source repository
6 * @copyright Copyright (c) 2005-2015 Zend Technologies USA Inc. (http://www.zend.com)
7 * @license   http://framework.zend.com/license/new-bsd New BSD License
8 */
9
10namespace Zend\Session;
11
12use Zend\EventManager\EventManagerInterface;
13use Zend\Stdlib\ArrayUtils;
14
15/**
16 * Session ManagerInterface implementation utilizing ext/session
17 */
18class SessionManager extends AbstractManager
19{
20    /**
21     * Default options when a call to {@link destroy()} is made
22     * - send_expire_cookie: whether or not to send a cookie expiring the current session cookie
23     * - clear_storage: whether or not to empty the storage object of any stored values
24     * @var array
25     */
26    protected $defaultDestroyOptions = array(
27        'send_expire_cookie' => true,
28        'clear_storage'      => false,
29    );
30
31    /**
32     * @var string value returned by session_name()
33     */
34    protected $name;
35
36    /**
37     * @var EventManagerInterface Validation chain to determine if session is valid
38     */
39    protected $validatorChain;
40
41    /**
42     * Constructor
43     *
44     * @param  Config\ConfigInterface|null           $config
45     * @param  Storage\StorageInterface|null         $storage
46     * @param  SaveHandler\SaveHandlerInterface|null $saveHandler
47     * @param  array                                 $validators
48     * @throws Exception\RuntimeException
49     */
50    public function __construct(
51        Config\ConfigInterface $config = null,
52        Storage\StorageInterface $storage = null,
53        SaveHandler\SaveHandlerInterface $saveHandler = null,
54        array $validators = array()
55    ) {
56        parent::__construct($config, $storage, $saveHandler, $validators);
57        register_shutdown_function(array($this, 'writeClose'));
58    }
59
60    /**
61     * Does a session exist and is it currently active?
62     *
63     * @return bool
64     */
65    public function sessionExists()
66    {
67        $sid = defined('SID') ? constant('SID') : false;
68        if ($sid !== false && $this->getId()) {
69            return true;
70        }
71        if (headers_sent()) {
72            return true;
73        }
74        return false;
75    }
76
77    /**
78     * Start session
79     *
80     * if No session currently exists, attempt to start it. Calls
81     * {@link isValid()} once session_start() is called, and raises an
82     * exception if validation fails.
83     *
84     * @param bool $preserveStorage        If set to true, current session storage will not be overwritten by the
85     *                                     contents of $_SESSION.
86     * @return void
87     * @throws Exception\RuntimeException
88     */
89    public function start($preserveStorage = false)
90    {
91        if ($this->sessionExists()) {
92            return;
93        }
94
95        $saveHandler = $this->getSaveHandler();
96        if ($saveHandler instanceof SaveHandler\SaveHandlerInterface) {
97            // register the session handler with ext/session
98            $this->registerSaveHandler($saveHandler);
99        }
100
101        $oldSessionData = array();
102        if (isset($_SESSION)) {
103            $oldSessionData = $_SESSION;
104        }
105
106        session_start();
107
108        if ($oldSessionData instanceof \Traversable
109            || (! empty($oldSessionData) && is_array($oldSessionData))
110        ) {
111            $_SESSION = ArrayUtils::merge($oldSessionData, $_SESSION, true);
112        }
113
114        $storage = $this->getStorage();
115
116        // Since session is starting, we need to potentially repopulate our
117        // session storage
118        if ($storage instanceof Storage\SessionStorage && $_SESSION !== $storage) {
119            if (!$preserveStorage) {
120                $storage->fromArray($_SESSION);
121            }
122            $_SESSION = $storage;
123        } elseif ($storage instanceof Storage\StorageInitializationInterface) {
124            $storage->init($_SESSION);
125        }
126
127        $this->initializeValidatorChain();
128
129        if (!$this->isValid()) {
130            throw new Exception\RuntimeException('Session validation failed');
131        }
132    }
133
134    /**
135     * Create validators, insert reference value and add them to the validator chain
136     */
137    protected function initializeValidatorChain()
138    {
139        $validatorChain  = $this->getValidatorChain();
140        $validatorValues = $this->getStorage()->getMetadata('_VALID');
141
142        foreach ($this->validators as $validator) {
143            // Ignore validators which are already present in Storage
144            if (is_array($validatorValues) && array_key_exists($validator, $validatorValues)) {
145                continue;
146            }
147
148            $validator = new $validator(null);
149            $validatorChain->attach('session.validate', array($validator, 'isValid'));
150        }
151    }
152
153    /**
154     * Destroy/end a session
155     *
156     * @param  array $options See {@link $defaultDestroyOptions}
157     * @return void
158     */
159    public function destroy(array $options = null)
160    {
161        if (!$this->sessionExists()) {
162            return;
163        }
164
165        if (null === $options) {
166            $options = $this->defaultDestroyOptions;
167        } else {
168            $options = array_merge($this->defaultDestroyOptions, $options);
169        }
170
171        session_destroy();
172        if ($options['send_expire_cookie']) {
173            $this->expireSessionCookie();
174        }
175
176        if ($options['clear_storage']) {
177            $this->getStorage()->clear();
178        }
179    }
180
181    /**
182     * Write session to save handler and close
183     *
184     * Once done, the Storage object will be marked as isImmutable.
185     *
186     * @return void
187     */
188    public function writeClose()
189    {
190        // The assumption is that we're using PHP's ext/session.
191        // session_write_close() will actually overwrite $_SESSION with an
192        // empty array on completion -- which leads to a mismatch between what
193        // is in the storage object and $_SESSION. To get around this, we
194        // temporarily reset $_SESSION to an array, and then re-link it to
195        // the storage object.
196        //
197        // Additionally, while you _can_ write to $_SESSION following a
198        // session_write_close() operation, no changes made to it will be
199        // flushed to the session handler. As such, we now mark the storage
200        // object isImmutable.
201        $storage  = $this->getStorage();
202        if (!$storage->isImmutable()) {
203            $_SESSION = $storage->toArray(true);
204            session_write_close();
205            $storage->fromArray($_SESSION);
206            $storage->markImmutable();
207        }
208    }
209
210    /**
211     * Attempt to set the session name
212     *
213     * If the session has already been started, or if the name provided fails
214     * validation, an exception will be raised.
215     *
216     * @param  string $name
217     * @return SessionManager
218     * @throws Exception\InvalidArgumentException
219     */
220    public function setName($name)
221    {
222        if ($this->sessionExists()) {
223            throw new Exception\InvalidArgumentException(
224                'Cannot set session name after a session has already started'
225            );
226        }
227
228        if (!preg_match('/^[a-zA-Z0-9]+$/', $name)) {
229            throw new Exception\InvalidArgumentException(
230                'Name provided contains invalid characters; must be alphanumeric only'
231            );
232        }
233
234        $this->name = $name;
235        session_name($name);
236        return $this;
237    }
238
239    /**
240     * Get session name
241     *
242     * Proxies to {@link session_name()}.
243     *
244     * @return string
245     */
246    public function getName()
247    {
248        if (null === $this->name) {
249            // If we're grabbing via session_name(), we don't need our
250            // validation routine; additionally, calling setName() after
251            // session_start() can lead to issues, and often we just need the name
252            // in order to do things such as setting cookies.
253            $this->name = session_name();
254        }
255        return $this->name;
256    }
257
258    /**
259     * Set session ID
260     *
261     * Can safely be called in the middle of a session.
262     *
263     * @param  string $id
264     * @return SessionManager
265     */
266    public function setId($id)
267    {
268        if ($this->sessionExists()) {
269            throw new Exception\RuntimeException('Session has already been started, to change the session ID call regenerateId()');
270        }
271        session_id($id);
272        return $this;
273    }
274
275    /**
276     * Get session ID
277     *
278     * Proxies to {@link session_id()}
279     *
280     * @return string
281     */
282    public function getId()
283    {
284        return session_id();
285    }
286
287    /**
288     * Regenerate id
289     *
290     * Regenerate the session ID, using session save handler's
291     * native ID generation Can safely be called in the middle of a session.
292     *
293     * @param  bool $deleteOldSession
294     * @return SessionManager
295     */
296    public function regenerateId($deleteOldSession = true)
297    {
298        session_regenerate_id((bool) $deleteOldSession);
299        return $this;
300    }
301
302    /**
303     * Set the TTL (in seconds) for the session cookie expiry
304     *
305     * Can safely be called in the middle of a session.
306     *
307     * @param  null|int $ttl
308     * @return SessionManager
309     */
310    public function rememberMe($ttl = null)
311    {
312        if (null === $ttl) {
313            $ttl = $this->getConfig()->getRememberMeSeconds();
314        }
315        $this->setSessionCookieLifetime($ttl);
316        return $this;
317    }
318
319    /**
320     * Set a 0s TTL for the session cookie
321     *
322     * Can safely be called in the middle of a session.
323     *
324     * @return SessionManager
325     */
326    public function forgetMe()
327    {
328        $this->setSessionCookieLifetime(0);
329        return $this;
330    }
331
332    /**
333     * Set the validator chain to use when validating a session
334     *
335     * In most cases, you should use an instance of {@link ValidatorChain}.
336     *
337     * @param  EventManagerInterface $chain
338     * @return SessionManager
339     */
340    public function setValidatorChain(EventManagerInterface $chain)
341    {
342        $this->validatorChain = $chain;
343        return $this;
344    }
345
346    /**
347     * Get the validator chain to use when validating a session
348     *
349     * By default, uses an instance of {@link ValidatorChain}.
350     *
351     * @return EventManagerInterface
352     */
353    public function getValidatorChain()
354    {
355        if (null === $this->validatorChain) {
356            $this->setValidatorChain(new ValidatorChain($this->getStorage()));
357        }
358        return $this->validatorChain;
359    }
360
361    /**
362     * Is this session valid?
363     *
364     * Notifies the Validator Chain until either all validators have returned
365     * true or one has failed.
366     *
367     * @return bool
368     */
369    public function isValid()
370    {
371        $validator = $this->getValidatorChain();
372        $responses = $validator->trigger('session.validate', $this, array($this), function ($test) {
373            return false === $test;
374        });
375        if ($responses->stopped()) {
376            // If execution was halted, validation failed
377            return false;
378        }
379        // Otherwise, we're good to go
380        return true;
381    }
382
383    /**
384     * Expire the session cookie
385     *
386     * Sends a session cookie with no value, and with an expiry in the past.
387     *
388     * @return void
389     */
390    public function expireSessionCookie()
391    {
392        $config = $this->getConfig();
393        if (!$config->getUseCookies()) {
394            return;
395        }
396        setcookie(
397            $this->getName(), // session name
398            '', // value
399            $_SERVER['REQUEST_TIME'] - 42000, // TTL for cookie
400            $config->getCookiePath(),
401            $config->getCookieDomain(),
402            $config->getCookieSecure(),
403            $config->getCookieHttpOnly()
404        );
405    }
406
407    /**
408     * Set the session cookie lifetime
409     *
410     * If a session already exists, destroys it (without sending an expiration
411     * cookie), regenerates the session ID, and restarts the session.
412     *
413     * @param  int $ttl
414     * @return void
415     */
416    protected function setSessionCookieLifetime($ttl)
417    {
418        $config = $this->getConfig();
419        if (!$config->getUseCookies()) {
420            return;
421        }
422
423        // Set new cookie TTL
424        $config->setCookieLifetime($ttl);
425
426        if ($this->sessionExists()) {
427            // There is a running session so we'll regenerate id to send a new cookie
428            $this->regenerateId();
429        }
430    }
431
432    /**
433     * Register Save Handler with ext/session
434     *
435     * Since ext/session is coupled to this particular session manager
436     * register the save handler with ext/session.
437     *
438     * @param SaveHandler\SaveHandlerInterface $saveHandler
439     * @return bool
440     */
441    protected function registerSaveHandler(SaveHandler\SaveHandlerInterface $saveHandler)
442    {
443        return session_set_save_handler(
444            array($saveHandler, 'open'),
445            array($saveHandler, 'close'),
446            array($saveHandler, 'read'),
447            array($saveHandler, 'write'),
448            array($saveHandler, 'destroy'),
449            array($saveHandler, 'gc')
450        );
451    }
452}
453