Completed
Branch master (537795)
by
unknown
33:10
created

PHPSessionHandler::returnFailure()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 2
nc 4
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * Session storage in object cache.
4
 *
5
 * This program is free software; you can redistribute it and/or modify
6
 * it under the terms of the GNU General Public License as published by
7
 * the Free Software Foundation; either version 2 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU General Public License along
16
 * with this program; if not, write to the Free Software Foundation, Inc.,
17
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18
 * http://www.gnu.org/copyleft/gpl.html
19
 *
20
 * @file
21
 * @ingroup Session
22
 */
23
24
namespace MediaWiki\Session;
25
26
use Psr\Log\LoggerInterface;
27
use BagOStuff;
28
29
/**
30
 * Adapter for PHP's session handling
31
 * @ingroup Session
32
 * @since 1.27
33
 */
34
class PHPSessionHandler implements \SessionHandlerInterface {
35
	/** @var PHPSessionHandler */
36
	protected static $instance = null;
37
38
	/** @var bool Whether PHP session handling is enabled */
39
	protected $enable = false;
40
	protected $warn = true;
41
42
	/** @var SessionManager|null */
43
	protected $manager;
44
45
	/** @var BagOStuff|null */
46
	protected $store;
47
48
	/** @var LoggerInterface */
49
	protected $logger;
50
51
	/** @var array Track original session fields for later modification check */
52
	protected $sessionFieldCache = [];
53
54
	protected function __construct( SessionManager $manager ) {
55
		$this->setEnableFlags(
56
			\RequestContext::getMain()->getConfig()->get( 'PHPSessionHandling' )
57
		);
58
		$manager->setupPHPSessionHandler( $this );
59
	}
60
61
	/**
62
	 * Set $this->enable and $this->warn
63
	 *
64
	 * Separate just because there doesn't seem to be a good way to test it
65
	 * otherwise.
66
	 *
67
	 * @param string $PHPSessionHandling See $wgPHPSessionHandling
68
	 */
69
	private function setEnableFlags( $PHPSessionHandling ) {
70
		switch ( $PHPSessionHandling ) {
71
			case 'enable':
72
				$this->enable = true;
73
				$this->warn = false;
74
				break;
75
76
			case 'warn':
77
				$this->enable = true;
78
				$this->warn = true;
79
				break;
80
81
			case 'disable':
82
				$this->enable = false;
83
				$this->warn = false;
84
				break;
85
		}
86
	}
87
88
	/**
89
	 * Test whether the handler is installed
90
	 * @return bool
91
	 */
92
	public static function isInstalled() {
93
		return (bool)self::$instance;
94
	}
95
96
	/**
97
	 * Test whether the handler is installed and enabled
98
	 * @return bool
99
	 */
100
	public static function isEnabled() {
101
		return self::$instance && self::$instance->enable;
102
	}
103
104
	/**
105
	 * Install a session handler for the current web request
106
	 * @param SessionManager $manager
107
	 */
108
	public static function install( SessionManager $manager ) {
109
		if ( self::$instance ) {
110
			$manager->setupPHPSessionHandler( self::$instance );
111
			return;
112
		}
113
114
		// @codeCoverageIgnoreStart
115
		if ( defined( 'MW_NO_SESSION_HANDLER' ) ) {
116
			throw new \BadMethodCallException( 'MW_NO_SESSION_HANDLER is defined' );
117
		}
118
		// @codeCoverageIgnoreEnd
119
120
		self::$instance = new self( $manager );
121
122
		// Close any auto-started session, before we replace it
123
		session_write_close();
124
125
		// Tell PHP not to mess with cookies itself
126
		ini_set( 'session.use_cookies', 0 );
127
		ini_set( 'session.use_trans_sid', 0 );
128
129
		// T124510: Disable automatic PHP session related cache headers.
130
		// MediaWiki adds it's own headers and the default PHP behavior may
131
		// set headers such as 'Pragma: no-cache' that cause problems with
132
		// some user agents.
133
		session_cache_limiter( '' );
134
135
		// Also set a sane serialization handler
136
		\Wikimedia\PhpSessionSerializer::setSerializeHandler();
137
138
		// Register this as the save handler, and register an appropriate
139
		// shutdown function.
140
		session_set_save_handler( self::$instance, true );
141
	}
142
143
	/**
144
	 * Set the manager, store, and logger
145
	 * @private Use self::install().
146
	 * @param SessionManager $manager
147
	 * @param BagOStuff $store
148
	 * @param LoggerInterface $store
149
	 */
150
	public function setManager(
151
		SessionManager $manager, BagOStuff $store, LoggerInterface $logger
152
	) {
153
		if ( $this->manager !== $manager ) {
154
			// Close any existing session before we change stores
155
			if ( $this->manager ) {
156
				session_write_close();
157
			}
158
			$this->manager = $manager;
159
			$this->store = $store;
160
			$this->logger = $logger;
161
			\Wikimedia\PhpSessionSerializer::setLogger( $this->logger );
162
		}
163
	}
164
165
	/**
166
	 * Workaround for PHP5 bug
167
	 *
168
	 * PHP5 has a bug in handling boolean return values for
169
	 * SessionHandlerInterface methods, it expects 0 or -1 instead of true or
170
	 * false. See <https://wiki.php.net/rfc/session.user.return-value>.
171
	 *
172
	 * PHP7 and HHVM are not affected.
173
	 *
174
	 * @todo When we drop support for Zend PHP 5, this can be removed.
175
	 * @return bool|int
176
	 * @codeCoverageIgnore
177
	 */
178
	protected static function returnSuccess() {
179
		return defined( 'HHVM_VERSION' ) || version_compare( PHP_VERSION, '7.0.0', '>=' ) ? true : 0;
180
	}
181
182
	/**
183
	 * Workaround for PHP5 bug
184
	 * @see self::returnSuccess()
185
	 * @return bool|int
186
	 * @codeCoverageIgnore
187
	 */
188
	protected static function returnFailure() {
189
		return defined( 'HHVM_VERSION' ) || version_compare( PHP_VERSION, '7.0.0', '>=' ) ? false : -1;
190
	}
191
192
	/**
193
	 * Initialize the session (handler)
194
	 * @private For internal use only
195
	 * @param string $save_path Path used to store session files (ignored)
196
	 * @param string $session_name Session name (ignored)
197
	 * @return bool|int Success (see self::returnSuccess())
198
	 */
199
	public function open( $save_path, $session_name ) {
200
		if ( self::$instance !== $this ) {
201
			throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
202
		}
203
		if ( !$this->enable ) {
204
			throw new \BadMethodCallException( 'Attempt to use PHP session management' );
205
		}
206
		return self::returnSuccess();
207
	}
208
209
	/**
210
	 * Close the session (handler)
211
	 * @private For internal use only
212
	 * @return bool|int Success (see self::returnSuccess())
213
	 */
214
	public function close() {
215
		if ( self::$instance !== $this ) {
216
			throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
217
		}
218
		$this->sessionFieldCache = [];
219
		return self::returnSuccess();
220
	}
221
222
	/**
223
	 * Read session data
224
	 * @private For internal use only
225
	 * @param string $id Session id
226
	 * @return string Session data
227
	 */
228
	public function read( $id ) {
229
		if ( self::$instance !== $this ) {
230
			throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
231
		}
232
		if ( !$this->enable ) {
233
			throw new \BadMethodCallException( 'Attempt to use PHP session management' );
234
		}
235
236
		$session = $this->manager->getSessionById( $id, false );
237
		if ( !$session ) {
238
			return '';
239
		}
240
		$session->persist();
241
242
		$data = iterator_to_array( $session );
243
		$this->sessionFieldCache[$id] = $data;
244
		return (string)\Wikimedia\PhpSessionSerializer::encode( $data );
245
	}
246
247
	/**
248
	 * Write session data
249
	 * @private For internal use only
250
	 * @param string $id Session id
251
	 * @param string $dataStr Session data. Not that you should ever call this
252
	 *   directly, but note that this has the same issues with code injection
253
	 *   via user-controlled data as does PHP's unserialize function.
254
	 * @return bool|int Success (see self::returnSuccess())
255
	 */
256
	public function write( $id, $dataStr ) {
257
		if ( self::$instance !== $this ) {
258
			throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
259
		}
260
		if ( !$this->enable ) {
261
			throw new \BadMethodCallException( 'Attempt to use PHP session management' );
262
		}
263
264
		$session = $this->manager->getSessionById( $id, true );
265
		if ( !$session ) {
266
			// This can happen under normal circumstances, if the session exists but is
267
			// invalid. Let's emit a log warning instead of a PHP warning.
268
			$this->logger->warning(
269
				__METHOD__ . ': Session "{session}" cannot be loaded, skipping write.',
270
				[
271
					'session' => $id,
272
			] );
273
			return self::returnSuccess();
274
		}
275
276
		// First, decode the string PHP handed us
277
		$data = \Wikimedia\PhpSessionSerializer::decode( $dataStr );
278
		if ( $data === null ) {
279
			// @codeCoverageIgnoreStart
280
			return self::returnFailure();
281
			// @codeCoverageIgnoreEnd
282
		}
283
284
		// Now merge the data into the Session object.
285
		$changed = false;
286
		$cache = isset( $this->sessionFieldCache[$id] ) ? $this->sessionFieldCache[$id] : [];
287
		foreach ( $data as $key => $value ) {
288
			if ( !array_key_exists( $key, $cache ) ) {
289 View Code Duplication
				if ( $session->exists( $key ) ) {
290
					// New in both, so ignore and log
291
					$this->logger->warning(
292
						__METHOD__ . ": Key \"$key\" added in both Session and \$_SESSION!"
293
					);
294
				} else {
295
					// New in $_SESSION, keep it
296
					$session->set( $key, $value );
297
					$changed = true;
298
				}
299
			} elseif ( $cache[$key] === $value ) {
0 ignored issues
show
Unused Code introduced by
This elseif statement is empty, and could be removed.

This check looks for the bodies of elseif statements that have no statements or where all statements have been commented out. This may be the result of changes for debugging or the code may simply be obsolete.

These elseif bodies can be removed. If you have an empty elseif but statements in the else branch, consider inverting the condition.

Loading history...
300
				// Unchanged in $_SESSION, so ignore it
301 View Code Duplication
			} elseif ( !$session->exists( $key ) ) {
302
				// Deleted in Session, keep but log
303
				$this->logger->warning(
304
					__METHOD__ . ": Key \"$key\" deleted in Session and changed in \$_SESSION!"
305
				);
306
				$session->set( $key, $value );
307
				$changed = true;
308
			} elseif ( $cache[$key] === $session->get( $key ) ) {
309
				// Unchanged in Session, so keep it
310
				$session->set( $key, $value );
311
				$changed = true;
312
			} else {
313
				// Changed in both, so ignore and log
314
				$this->logger->warning(
315
					__METHOD__ . ": Key \"$key\" changed in both Session and \$_SESSION!"
316
				);
317
			}
318
		}
319
		// Anything deleted in $_SESSION and unchanged in Session should be deleted too
320
		// (but not if $_SESSION can't represent it at all)
321
		\Wikimedia\PhpSessionSerializer::setLogger( new \Psr\Log\NullLogger() );
322
		foreach ( $cache as $key => $value ) {
323
			if ( !array_key_exists( $key, $data ) && $session->exists( $key ) &&
324
				\Wikimedia\PhpSessionSerializer::encode( [ $key => true ] )
0 ignored issues
show
Bug Best Practice introduced by
The expression \Wikimedia\PhpSessionSer...de(array($key => true)) of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
325
			) {
326
				if ( $cache[$key] === $session->get( $key ) ) {
327
					// Unchanged in Session, delete it
328
					$session->remove( $key );
329
					$changed = true;
330
				} else {
331
					// Changed in Session, ignore deletion and log
332
					$this->logger->warning(
333
						__METHOD__ . ": Key \"$key\" changed in Session and deleted in \$_SESSION!"
334
					);
335
				}
336
			}
337
		}
338
		\Wikimedia\PhpSessionSerializer::setLogger( $this->logger );
339
340
		// Save and update cache if anything changed
341
		if ( $changed ) {
342
			if ( $this->warn ) {
343
				wfDeprecated( '$_SESSION', '1.27' );
344
				$this->logger->warning( 'Something wrote to $_SESSION!' );
345
			}
346
347
			$session->save();
348
			$this->sessionFieldCache[$id] = iterator_to_array( $session );
349
		}
350
351
		$session->persist();
352
353
		return self::returnSuccess();
354
	}
355
356
	/**
357
	 * Destroy a session
358
	 * @private For internal use only
359
	 * @param string $id Session id
360
	 * @return bool|int Success (see self::returnSuccess())
361
	 */
362
	public function destroy( $id ) {
363
		if ( self::$instance !== $this ) {
364
			throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
365
		}
366
		if ( !$this->enable ) {
367
			throw new \BadMethodCallException( 'Attempt to use PHP session management' );
368
		}
369
		$session = $this->manager->getSessionById( $id, false );
370
		if ( $session ) {
371
			$session->clear();
372
		}
373
		return self::returnSuccess();
374
	}
375
376
	/**
377
	 * Execute garbage collection.
378
	 * @private For internal use only
379
	 * @param int $maxlifetime Maximum session life time (ignored)
380
	 * @return bool|int Success (see self::returnSuccess())
381
	 * @codeCoverageIgnore See T135576
382
	 */
383
	public function gc( $maxlifetime ) {
384
		if ( self::$instance !== $this ) {
385
			throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
386
		}
387
		$before = date( 'YmdHis', time() );
388
		$this->store->deleteObjectsExpiringBefore( $before );
389
		return self::returnSuccess();
390
	}
391
}
392