Completed
Branch master (54277f)
by
unknown
24:54
created

SessionManager::getSessionById()   D

Complexity

Conditions 9
Paths 21

Size

Total Lines 41
Code Lines 24

Duplication

Lines 0
Ratio 0 %
Metric Value
dl 0
loc 41
rs 4.909
cc 9
eloc 24
nc 21
nop 3
1
<?php
2
/**
3
 * MediaWiki\Session entry point
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
use CachedBagOStuff;
29
use Config;
30
use FauxRequest;
31
use User;
32
use WebRequest;
33
34
/**
35
 * This serves as the entry point to the MediaWiki session handling system.
36
 *
37
 * @ingroup Session
38
 * @since 1.27
39
 */
40
final class SessionManager implements SessionManagerInterface {
41
	/** @var SessionManager|null */
42
	private static $instance = null;
43
44
	/** @var Session|null */
45
	private static $globalSession = null;
46
47
	/** @var WebRequest|null */
48
	private static $globalSessionRequest = null;
49
50
	/** @var LoggerInterface */
51
	private $logger;
52
53
	/** @var Config */
54
	private $config;
55
56
	/** @var CachedBagOStuff|null */
57
	private $store;
58
59
	/** @var SessionProvider[] */
60
	private $sessionProviders = null;
61
62
	/** @var string[] */
63
	private $varyCookies = null;
64
65
	/** @var array */
66
	private $varyHeaders = null;
67
68
	/** @var SessionBackend[] */
69
	private $allSessionBackends = [];
70
71
	/** @var SessionId[] */
72
	private $allSessionIds = [];
73
74
	/** @var string[] */
75
	private $preventUsers = [];
76
77
	/**
78
	 * Get the global SessionManager
79
	 * @return SessionManagerInterface
80
	 *  (really a SessionManager, but this is to make IDEs less confused)
81
	 */
82
	public static function singleton() {
83
		if ( self::$instance === null ) {
84
			self::$instance = new self();
85
		}
86
		return self::$instance;
87
	}
88
89
	/**
90
	 * Get the "global" session
91
	 *
92
	 * If PHP's session_id() has been set, returns that session. Otherwise
93
	 * returns the session for RequestContext::getMain()->getRequest().
94
	 *
95
	 * @return Session
96
	 */
97
	public static function getGlobalSession() {
98
		if ( !PHPSessionHandler::isEnabled() ) {
99
			$id = '';
100
		} else {
101
			$id = session_id();
102
		}
103
104
		$request = \RequestContext::getMain()->getRequest();
105
		if (
106
			!self::$globalSession // No global session is set up yet
107
			|| self::$globalSessionRequest !== $request // The global WebRequest changed
108
			|| $id !== '' && self::$globalSession->getId() !== $id // Someone messed with session_id()
109
		) {
110
			self::$globalSessionRequest = $request;
111
			if ( $id === '' ) {
112
				// session_id() wasn't used, so fetch the Session from the WebRequest.
113
				// We use $request->getSession() instead of $singleton->getSessionForRequest()
114
				// because doing the latter would require a public
115
				// "$request->getSessionId()" method that would confuse end
116
				// users by returning SessionId|null where they'd expect it to
117
				// be short for $request->getSession()->getId(), and would
118
				// wind up being a duplicate of the code in
119
				// $request->getSession() anyway.
120
				self::$globalSession = $request->getSession();
121
			} else {
122
				// Someone used session_id(), so we need to follow suit.
123
				// Note this overwrites whatever session might already be
124
				// associated with $request with the one for $id.
125
				self::$globalSession = self::singleton()->getSessionById( $id, true, $request )
126
					?: $request->getSession();
127
			}
128
		}
129
		return self::$globalSession;
130
	}
131
132
	/**
133
	 * @param array $options
134
	 *  - config: Config to fetch configuration from. Defaults to the default 'main' config.
135
	 *  - logger: LoggerInterface to use for logging. Defaults to the 'session' channel.
136
	 *  - store: BagOStuff to store session data in.
137
	 */
138
	public function __construct( $options = [] ) {
139
		if ( isset( $options['config'] ) ) {
140
			$this->config = $options['config'];
141
			if ( !$this->config instanceof Config ) {
142
				throw new \InvalidArgumentException(
143
					'$options[\'config\'] must be an instance of Config'
144
				);
145
			}
146
		} else {
147
			$this->config = \ConfigFactory::getDefaultInstance()->makeConfig( 'main' );
148
		}
149
150
		if ( isset( $options['logger'] ) ) {
151
			if ( !$options['logger'] instanceof LoggerInterface ) {
152
				throw new \InvalidArgumentException(
153
					'$options[\'logger\'] must be an instance of LoggerInterface'
154
				);
155
			}
156
			$this->setLogger( $options['logger'] );
157
		} else {
158
			$this->setLogger( \MediaWiki\Logger\LoggerFactory::getInstance( 'session' ) );
159
		}
160
161
		if ( isset( $options['store'] ) ) {
162
			if ( !$options['store'] instanceof BagOStuff ) {
163
				throw new \InvalidArgumentException(
164
					'$options[\'store\'] must be an instance of BagOStuff'
165
				);
166
			}
167
			$store = $options['store'];
168
		} else {
169
			$store = \ObjectCache::getInstance( $this->config->get( 'SessionCacheType' ) );
170
		}
171
		$this->store = $store instanceof CachedBagOStuff ? $store : new CachedBagOStuff( $store );
172
173
		register_shutdown_function( [ $this, 'shutdown' ] );
174
	}
175
176
	public function setLogger( LoggerInterface $logger ) {
177
		$this->logger = $logger;
178
	}
179
180
	public function getSessionForRequest( WebRequest $request ) {
181
		$info = $this->getSessionInfoForRequest( $request );
182
183
		if ( !$info ) {
184
			$session = $this->getEmptySession( $request );
185
		} else {
186
			$session = $this->getSessionFromInfo( $info, $request );
187
		}
188
		return $session;
189
	}
190
191
	public function getSessionById( $id, $create = false, WebRequest $request = null ) {
192
		if ( !self::validateSessionId( $id ) ) {
193
			throw new \InvalidArgumentException( 'Invalid session ID' );
194
		}
195
		if ( !$request ) {
196
			$request = new FauxRequest;
197
		}
198
199
		$session = null;
200
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [ 'id' => $id, 'idIsSafe' => true ] );
201
202
		// If we already have the backend loaded, use it directly
203
		if ( isset( $this->allSessionBackends[$id] ) ) {
204
			return $this->getSessionFromInfo( $info, $request );
205
		}
206
207
		// Test if the session is in storage, and if so try to load it.
208
		$key = wfMemcKey( 'MWSession', $id );
209
		if ( is_array( $this->store->get( $key ) ) ) {
210
			$create = false; // If loading fails, don't bother creating because it probably will fail too.
211
			if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
212
				$session = $this->getSessionFromInfo( $info, $request );
213
			}
214
		}
215
216
		if ( $create && $session === null ) {
217
			$ex = null;
218
			try {
219
				$session = $this->getEmptySessionInternal( $request, $id );
220
			} catch ( \Exception $ex ) {
221
				$this->logger->error( 'Failed to create empty session: {exception}',
222
					[
223
						'method' => __METHOD__,
224
						'exception' => $ex,
225
				] );
226
				$session = null;
227
			}
228
		}
229
230
		return $session;
231
	}
232
233
	public function getEmptySession( WebRequest $request = null ) {
234
		return $this->getEmptySessionInternal( $request );
235
	}
236
237
	/**
238
	 * @see SessionManagerInterface::getEmptySession
239
	 * @param WebRequest|null $request
240
	 * @param string|null $id ID to force on the new session
241
	 * @return Session
242
	 */
243
	private function getEmptySessionInternal( WebRequest $request = null, $id = null ) {
244
		if ( $id !== null ) {
245
			if ( !self::validateSessionId( $id ) ) {
246
				throw new \InvalidArgumentException( 'Invalid session ID' );
247
			}
248
249
			$key = wfMemcKey( 'MWSession', $id );
250
			if ( is_array( $this->store->get( $key ) ) ) {
251
				throw new \InvalidArgumentException( 'Session ID already exists' );
252
			}
253
		}
254
		if ( !$request ) {
255
			$request = new FauxRequest;
256
		}
257
258
		$infos = [];
259
		foreach ( $this->getProviders() as $provider ) {
260
			$info = $provider->newSessionInfo( $id );
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $info is correct as $provider->newSessionInfo($id) (which targets MediaWiki\Session\Sessio...vider::newSessionInfo()) seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
261
			if ( !$info ) {
262
				continue;
263
			}
264
			if ( $info->getProvider() !== $provider ) {
265
				throw new \UnexpectedValueException(
266
					"$provider returned an empty session info for a different provider: $info"
267
				);
268
			}
269
			if ( $id !== null && $info->getId() !== $id ) {
270
				throw new \UnexpectedValueException(
271
					"$provider returned empty session info with a wrong id: " .
272
						$info->getId() . ' != ' . $id
273
				);
274
			}
275
			if ( !$info->isIdSafe() ) {
276
				throw new \UnexpectedValueException(
277
					"$provider returned empty session info with id flagged unsafe"
278
				);
279
			}
280
			$compare = $infos ? SessionInfo::compare( $infos[0], $info ) : -1;
281
			if ( $compare > 0 ) {
282
				continue;
283
			}
284
			if ( $compare === 0 ) {
285
				$infos[] = $info;
286
			} else {
287
				$infos = [ $info ];
288
			}
289
		}
290
291
		// Make sure there's exactly one
292
		if ( count( $infos ) > 1 ) {
293
			throw new \UnexpectedValueException(
294
				'Multiple empty sessions tied for top priority: ' . implode( ', ', $infos )
295
			);
296
		} elseif ( count( $infos ) < 1 ) {
297
			throw new \UnexpectedValueException( 'No provider could provide an empty session!' );
298
		}
299
300
		return $this->getSessionFromInfo( $infos[0], $request );
301
	}
302
303
	public function getVaryHeaders() {
304
		// @codeCoverageIgnoreStart
305
		if ( defined( 'MW_NO_SESSION' ) && MW_NO_SESSION !== 'warn' ) {
306
			return [];
307
		}
308
		// @codeCoverageIgnoreEnd
309
		if ( $this->varyHeaders === null ) {
310
			$headers = [];
311
			foreach ( $this->getProviders() as $provider ) {
312
				foreach ( $provider->getVaryHeaders() as $header => $options ) {
313
					if ( !isset( $headers[$header] ) ) {
314
						$headers[$header] = [];
315
					}
316
					if ( is_array( $options ) ) {
317
						$headers[$header] = array_unique( array_merge( $headers[$header], $options ) );
318
					}
319
				}
320
			}
321
			$this->varyHeaders = $headers;
322
		}
323
		return $this->varyHeaders;
324
	}
325
326
	public function getVaryCookies() {
327
		// @codeCoverageIgnoreStart
328
		if ( defined( 'MW_NO_SESSION' ) && MW_NO_SESSION !== 'warn' ) {
329
			return [];
330
		}
331
		// @codeCoverageIgnoreEnd
332
		if ( $this->varyCookies === null ) {
333
			$cookies = [];
334
			foreach ( $this->getProviders() as $provider ) {
335
				$cookies = array_merge( $cookies, $provider->getVaryCookies() );
336
			}
337
			$this->varyCookies = array_values( array_unique( $cookies ) );
0 ignored issues
show
Documentation Bug introduced by
It seems like array_values(array_unique($cookies)) of type array<integer,?> is incompatible with the declared type array<integer,string> of property $varyCookies.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
338
		}
339
		return $this->varyCookies;
340
	}
341
342
	/**
343
	 * Validate a session ID
344
	 * @param string $id
345
	 * @return bool
346
	 */
347
	public static function validateSessionId( $id ) {
348
		return is_string( $id ) && preg_match( '/^[a-zA-Z0-9_-]{32,}$/', $id );
349
	}
350
351
	/**
352
	 * @name Internal methods
353
	 * @{
354
	 */
355
356
	/**
357
	 * Auto-create the given user, if necessary
358
	 * @private Don't call this yourself. Let Setup.php do it for you at the right time.
359
	 * @note This more properly belongs in AuthManager, but we need it now.
360
	 *  When AuthManager comes, this will be deprecated and will pass-through
361
	 *  to the corresponding AuthManager method.
362
	 * @param User $user User to auto-create
363
	 * @return bool Success
364
	 */
365
	public static function autoCreateUser( User $user ) {
0 ignored issues
show
Coding Style introduced by
autoCreateUser uses the super-global variable $_SERVER which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
366
		global $wgAuth;
367
368
		$logger = self::singleton()->logger;
369
370
		// Much of this code is based on that in CentralAuth
371
372
		// Try the local user from the slave DB
373
		$localId = User::idFromName( $user->getName() );
374
		$flags = 0;
375
376
		// Fetch the user ID from the master, so that we don't try to create the user
377
		// when they already exist, due to replication lag
378
		// @codeCoverageIgnoreStart
379
		if ( !$localId && wfGetLB()->getReaderIndex() != 0 ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $localId of type integer|null is loosely compared to false; this is ambiguous if the integer can be zero. 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 integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
380
			$localId = User::idFromName( $user->getName(), User::READ_LATEST );
381
			$flags = User::READ_LATEST;
382
		}
383
		// @codeCoverageIgnoreEnd
384
385
		if ( $localId ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $localId of type integer|null is loosely compared to true; this is ambiguous if the integer can be zero. 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 integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
386
			// User exists after all.
387
			$user->setId( $localId );
388
			$user->loadFromId( $flags );
389
			return false;
390
		}
391
392
		// Denied by AuthPlugin? But ignore AuthPlugin itself.
393
		if ( get_class( $wgAuth ) !== 'AuthPlugin' && !$wgAuth->autoCreate() ) {
394
			$logger->debug( __METHOD__ . ': denied by AuthPlugin' );
395
			$user->setId( 0 );
396
			$user->loadFromId();
397
			return false;
398
		}
399
400
		// Wiki is read-only?
401 View Code Duplication
		if ( wfReadOnly() ) {
402
			$logger->debug( __METHOD__ . ': denied by wfReadOnly()' );
403
			$user->setId( 0 );
404
			$user->loadFromId();
405
			return false;
406
		}
407
408
		$userName = $user->getName();
409
410
		// Check the session, if we tried to create this user already there's
411
		// no point in retrying.
412
		$session = self::getGlobalSession();
413
		$reason = $session->get( 'MWSession::AutoCreateBlacklist' );
414 View Code Duplication
		if ( $reason ) {
415
			$logger->debug( __METHOD__ . ": blacklisted in session ($reason)" );
416
			$user->setId( 0 );
417
			$user->loadFromId();
418
			return false;
419
		}
420
421
		// Is the IP user able to create accounts?
422
		$anon = new User;
423
		if ( !$anon->isAllowedAny( 'createaccount', 'autocreateaccount' )
424
			|| $anon->isBlockedFromCreateAccount()
425
		) {
426
			// Blacklist the user to avoid repeated DB queries subsequently
427
			$logger->debug( __METHOD__ . ': user is blocked from this wiki, blacklisting' );
428
			$session->set( 'MWSession::AutoCreateBlacklist', 'blocked', 600 );
429
			$session->persist();
430
			$user->setId( 0 );
431
			$user->loadFromId();
432
			return false;
433
		}
434
435
		// Check for validity of username
436
		if ( !User::isCreatableName( $userName ) ) {
437
			$logger->debug( __METHOD__ . ': Invalid username, blacklisting' );
438
			$session->set( 'MWSession::AutoCreateBlacklist', 'invalid username', 600 );
439
			$session->persist();
440
			$user->setId( 0 );
441
			$user->loadFromId();
442
			return false;
443
		}
444
445
		// Give other extensions a chance to stop auto creation.
446
		$user->loadDefaults( $userName );
447
		$abortMessage = '';
448
		if ( !\Hooks::run( 'AbortAutoAccount', [ $user, &$abortMessage ] ) ) {
449
			// In this case we have no way to return the message to the user,
450
			// but we can log it.
451
			$logger->debug( __METHOD__ . ": denied by hook: $abortMessage" );
452
			$session->set( 'MWSession::AutoCreateBlacklist', "hook aborted: $abortMessage", 600 );
453
			$session->persist();
454
			$user->setId( 0 );
455
			$user->loadFromId();
456
			return false;
457
		}
458
459
		// Make sure the name has not been changed
460
		if ( $user->getName() !== $userName ) {
461
			$user->setId( 0 );
462
			$user->loadFromId();
463
			throw new \UnexpectedValueException(
464
				'AbortAutoAccount hook tried to change the user name'
465
			);
466
		}
467
468
		// Ignore warnings about master connections/writes...hard to avoid here
469
		\Profiler::instance()->getTransactionProfiler()->resetExpectations();
470
471
		$cache = \ObjectCache::getLocalClusterInstance();
472
		$backoffKey = wfMemcKey( 'MWSession', 'autocreate-failed', md5( $userName ) );
473
		if ( $cache->get( $backoffKey ) ) {
474
			$logger->debug( __METHOD__ . ': denied by prior creation attempt failures' );
475
			$user->setId( 0 );
476
			$user->loadFromId();
477
			return false;
478
		}
479
480
		// Checks passed, create the user...
481
		$from = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : 'CLI';
482
		$logger->info( __METHOD__ . ': creating new user ({username}) - from: {url}',
483
			[
484
				'username' => $userName,
485
				'url' => $from,
486
		] );
487
488
		try {
489
			// Insert the user into the local DB master
490
			$status = $user->addToDatabase();
491
			if ( !$status->isOK() ) {
492
				// @codeCoverageIgnoreStart
493
				// double-check for a race condition (T70012)
494
				$id = User::idFromName( $user->getName(), User::READ_LATEST );
495
				if ( $id ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $id of type integer|null is loosely compared to true; this is ambiguous if the integer can be zero. 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 integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
496
					$logger->info( __METHOD__ . ': tried to autocreate existing user',
497
						[
498
							'username' => $userName,
499
						] );
500
				} else {
501
					$logger->error( __METHOD__ . ': failed with message ' . $status->getWikiText(),
502
						[
503
							'username' => $userName,
504
						] );
505
				}
506
				$user->setId( $id );
507
				$user->loadFromId( User::READ_LATEST );
508
				return false;
509
				// @codeCoverageIgnoreEnd
510
			}
511
		} catch ( \Exception $ex ) {
512
			// @codeCoverageIgnoreStart
513
			$logger->error( __METHOD__ . ': failed with exception {exception}', [
514
				'exception' => $ex,
515
				'username' => $userName,
516
			] );
517
			// Do not keep throwing errors for a while
518
			$cache->set( $backoffKey, 1, 600 );
519
			// Bubble up error; which should normally trigger DB rollbacks
520
			throw $ex;
521
			// @codeCoverageIgnoreEnd
522
		}
523
524
		# Notify AuthPlugin
525
		// @codeCoverageIgnoreStart
526
		$tmpUser = $user;
527
		$wgAuth->initUser( $tmpUser, true );
528
		if ( $tmpUser !== $user ) {
529
			$logger->warning( __METHOD__ . ': ' .
530
				get_class( $wgAuth ) . '::initUser() replaced the user object' );
531
		}
532
		// @codeCoverageIgnoreEnd
533
534
		# Notify hooks (e.g. Newuserlog)
535
		\Hooks::run( 'AuthPluginAutoCreate', [ $user ] );
536
		\Hooks::run( 'LocalUserCreated', [ $user, true ] );
537
538
		$user->saveSettings();
539
540
		# Update user count
541
		\DeferredUpdates::addUpdate( new \SiteStatsUpdate( 0, 0, 0, 0, 1 ) );
542
543
		# Watch user's userpage and talk page
544
		$user->addWatch( $user->getUserPage(), User::IGNORE_USER_RIGHTS );
545
546
		return true;
547
	}
548
549
	/**
550
	 * Prevent future sessions for the user
551
	 *
552
	 * The intention is that the named account will never again be usable for
553
	 * normal login (i.e. there is no way to undo the prevention of access).
554
	 *
555
	 * @private For use from \\User::newSystemUser only
556
	 * @param string $username
557
	 */
558
	public function preventSessionsForUser( $username ) {
559
		$this->preventUsers[$username] = true;
560
561
		// Instruct the session providers to kill any other sessions too.
562
		foreach ( $this->getProviders() as $provider ) {
563
			$provider->preventSessionsForUser( $username );
564
		}
565
	}
566
567
	/**
568
	 * Test if a user is prevented
569
	 * @private For use from SessionBackend only
570
	 * @param string $username
571
	 * @return bool
572
	 */
573
	public function isUserSessionPrevented( $username ) {
574
		return !empty( $this->preventUsers[$username] );
575
	}
576
577
	/**
578
	 * Get the available SessionProviders
579
	 * @return SessionProvider[]
580
	 */
581
	protected function getProviders() {
582
		if ( $this->sessionProviders === null ) {
583
			$this->sessionProviders = [];
584
			foreach ( $this->config->get( 'SessionProviders' ) as $spec ) {
585
				$provider = \ObjectFactory::getObjectFromSpec( $spec );
586
				$provider->setLogger( $this->logger );
587
				$provider->setConfig( $this->config );
588
				$provider->setManager( $this );
589
				if ( isset( $this->sessionProviders[(string)$provider] ) ) {
590
					throw new \UnexpectedValueException( "Duplicate provider name \"$provider\"" );
591
				}
592
				$this->sessionProviders[(string)$provider] = $provider;
593
			}
594
		}
595
		return $this->sessionProviders;
596
	}
597
598
	/**
599
	 * Get a session provider by name
600
	 *
601
	 * Generally, this will only be used by internal implementation of some
602
	 * special session-providing mechanism. General purpose code, if it needs
603
	 * to access a SessionProvider at all, will use Session::getProvider().
604
	 *
605
	 * @param string $name
606
	 * @return SessionProvider|null
607
	 */
608
	public function getProvider( $name ) {
609
		$providers = $this->getProviders();
610
		return isset( $providers[$name] ) ? $providers[$name] : null;
611
	}
612
613
	/**
614
	 * Save all active sessions on shutdown
615
	 * @private For internal use with register_shutdown_function()
616
	 */
617
	public function shutdown() {
618
		if ( $this->allSessionBackends ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->allSessionBackends of type MediaWiki\Session\SessionBackend[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
619
			$this->logger->debug( 'Saving all sessions on shutdown' );
620
			if ( session_id() !== '' ) {
621
				// @codeCoverageIgnoreStart
622
				session_write_close();
623
			}
624
			// @codeCoverageIgnoreEnd
625
			foreach ( $this->allSessionBackends as $backend ) {
626
				$backend->save( true );
627
			}
628
		}
629
	}
630
631
	/**
632
	 * Fetch the SessionInfo(s) for a request
633
	 * @param WebRequest $request
634
	 * @return SessionInfo|null
635
	 */
636
	private function getSessionInfoForRequest( WebRequest $request ) {
637
		// Call all providers to fetch "the" session
638
		$infos = [];
639
		foreach ( $this->getProviders() as $provider ) {
640
			$info = $provider->provideSessionInfo( $request );
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $info is correct as $provider->provideSessionInfo($request) (which targets MediaWiki\Session\Sessio...r::provideSessionInfo()) seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
641
			if ( !$info ) {
642
				continue;
643
			}
644
			if ( $info->getProvider() !== $provider ) {
645
				throw new \UnexpectedValueException(
646
					"$provider returned session info for a different provider: $info"
647
				);
648
			}
649
			$infos[] = $info;
650
		}
651
652
		// Sort the SessionInfos. Then find the first one that can be
653
		// successfully loaded, and then all the ones after it with the same
654
		// priority.
655
		usort( $infos, 'MediaWiki\\Session\\SessionInfo::compare' );
656
		$retInfos = [];
657
		while ( $infos ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $infos of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
658
			$info = array_pop( $infos );
659
			if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
660
				$retInfos[] = $info;
661
				while ( $infos ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $infos of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
662
					$info = array_pop( $infos );
663
					if ( SessionInfo::compare( $retInfos[0], $info ) ) {
664
						// We hit a lower priority, stop checking.
665
						break;
666
					}
667
					if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
668
						// This is going to error out below, but we want to
669
						// provide a complete list.
670
						$retInfos[] = $info;
671
					} else {
672
						// Session load failed, so unpersist it from this request
673
						$info->getProvider()->unpersistSession( $request );
674
					}
675
				}
676
			} else {
677
				// Session load failed, so unpersist it from this request
678
				$info->getProvider()->unpersistSession( $request );
679
			}
680
		}
681
682
		if ( count( $retInfos ) > 1 ) {
683
			$ex = new \OverflowException(
684
				'Multiple sessions for this request tied for top priority: ' . implode( ', ', $retInfos )
685
			);
686
			$ex->sessionInfos = $retInfos;
0 ignored issues
show
Bug introduced by
The property sessionInfos does not seem to exist in OverflowException.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
687
			throw $ex;
688
		}
689
690
		return $retInfos ? $retInfos[0] : null;
691
	}
692
693
	/**
694
	 * Load and verify the session info against the store
695
	 *
696
	 * @param SessionInfo &$info Will likely be replaced with an updated SessionInfo instance
697
	 * @param WebRequest $request
698
	 * @return bool Whether the session info matches the stored data (if any)
699
	 */
700
	private function loadSessionInfoFromStore( SessionInfo &$info, WebRequest $request ) {
701
		$key = wfMemcKey( 'MWSession', $info->getId() );
702
		$blob = $this->store->get( $key );
703
704
		$newParams = [];
705
706
		if ( $blob !== false ) {
707
			// Sanity check: blob must be an array, if it's saved at all
708
			if ( !is_array( $blob ) ) {
709
				$this->logger->warning( 'Session "{session}": Bad data', [
710
					'session' => $info,
711
				] );
712
				$this->store->delete( $key );
713
				return false;
714
			}
715
716
			// Sanity check: blob has data and metadata arrays
717
			if ( !isset( $blob['data'] ) || !is_array( $blob['data'] ) ||
718
				!isset( $blob['metadata'] ) || !is_array( $blob['metadata'] )
719
			) {
720
				$this->logger->warning( 'Session "{session}": Bad data structure', [
721
					'session' => $info,
722
				] );
723
				$this->store->delete( $key );
724
				return false;
725
			}
726
727
			$data = $blob['data'];
728
			$metadata = $blob['metadata'];
729
730
			// Sanity check: metadata must be an array and must contain certain
731
			// keys, if it's saved at all
732
			if ( !array_key_exists( 'userId', $metadata ) ||
733
				!array_key_exists( 'userName', $metadata ) ||
734
				!array_key_exists( 'userToken', $metadata ) ||
735
				!array_key_exists( 'provider', $metadata )
736
			) {
737
				$this->logger->warning( 'Session "{session}": Bad metadata', [
738
					'session' => $info,
739
				] );
740
				$this->store->delete( $key );
741
				return false;
742
			}
743
744
			// First, load the provider from metadata, or validate it against the metadata.
745
			$provider = $info->getProvider();
746
			if ( $provider === null ) {
747
				$newParams['provider'] = $provider = $this->getProvider( $metadata['provider'] );
748
				if ( !$provider ) {
749
					$this->logger->warning(
750
						'Session "{session}": Unknown provider ' . $metadata['provider'],
751
						[
752
							'session' => $info,
753
						]
754
					);
755
					$this->store->delete( $key );
756
					return false;
757
				}
758
			} elseif ( $metadata['provider'] !== (string)$provider ) {
759
				$this->logger->warning( 'Session "{session}": Wrong provider ' .
760
					$metadata['provider'] . ' !== ' . $provider,
761
					[
762
						'session' => $info,
763
				] );
764
				return false;
765
			}
766
767
			// Load provider metadata from metadata, or validate it against the metadata
768
			$providerMetadata = $info->getProviderMetadata();
769
			if ( isset( $metadata['providerMetadata'] ) ) {
770
				if ( $providerMetadata === null ) {
771
					$newParams['metadata'] = $metadata['providerMetadata'];
772
				} else {
773
					try {
774
						$newProviderMetadata = $provider->mergeMetadata(
775
							$metadata['providerMetadata'], $providerMetadata
776
						);
777
						if ( $newProviderMetadata !== $providerMetadata ) {
778
							$newParams['metadata'] = $newProviderMetadata;
779
						}
780
					} catch ( MetadataMergeException $ex ) {
781
						$this->logger->warning(
782
							'Session "{session}": Metadata merge failed: {exception}',
783
							[
784
								'session' => $info,
785
								'exception' => $ex,
786
							] + $ex->getContext()
787
						);
788
						return false;
789
					}
790
				}
791
			}
792
793
			// Next, load the user from metadata, or validate it against the metadata.
794
			$userInfo = $info->getUserInfo();
795
			if ( !$userInfo ) {
796
				// For loading, id is preferred to name.
797
				try {
798
					if ( $metadata['userId'] ) {
799
						$userInfo = UserInfo::newFromId( $metadata['userId'] );
800
					} elseif ( $metadata['userName'] !== null ) { // Shouldn't happen, but just in case
801
						$userInfo = UserInfo::newFromName( $metadata['userName'] );
802
					} else {
803
						$userInfo = UserInfo::newAnonymous();
804
					}
805
				} catch ( \InvalidArgumentException $ex ) {
806
					$this->logger->error( 'Session "{session}": {exception}', [
807
						'session' => $info,
808
						'exception' => $ex,
809
					] );
810
					return false;
811
				}
812
				$newParams['userInfo'] = $userInfo;
813
			} else {
814
				// User validation passes if user ID matches, or if there
815
				// is no saved ID and the names match.
816
				if ( $metadata['userId'] ) {
817
					if ( $metadata['userId'] !== $userInfo->getId() ) {
818
						$this->logger->warning(
819
							'Session "{session}": User ID mismatch, {uid_a} !== {uid_b}',
820
							[
821
								'session' => $info,
822
								'uid_a' => $metadata['userId'],
823
								'uid_b' => $userInfo->getId(),
824
						] );
825
						return false;
826
					}
827
828
					// If the user was renamed, probably best to fail here.
829 View Code Duplication
					if ( $metadata['userName'] !== null &&
830
						$userInfo->getName() !== $metadata['userName']
831
					) {
832
						$this->logger->warning(
833
							'Session "{session}": User ID matched but name didn\'t (rename?), {uname_a} !== {uname_b}',
834
							[
835
								'session' => $info,
836
								'uname_a' => $metadata['userName'],
837
								'uname_b' => $userInfo->getName(),
838
						] );
839
						return false;
840
					}
841
842 View Code Duplication
				} elseif ( $metadata['userName'] !== null ) { // Shouldn't happen, but just in case
843
					if ( $metadata['userName'] !== $userInfo->getName() ) {
844
						$this->logger->warning(
845
							'Session "{session}": User name mismatch, {uname_a} !== {uname_b}',
846
							[
847
								'session' => $info,
848
								'uname_a' => $metadata['userName'],
849
								'uname_b' => $userInfo->getName(),
850
						] );
851
						return false;
852
					}
853
				} elseif ( !$userInfo->isAnon() ) {
854
					// Metadata specifies an anonymous user, but the passed-in
855
					// user isn't anonymous.
856
					$this->logger->warning(
857
						'Session "{session}": Metadata has an anonymous user, but a non-anon user was provided',
858
						[
859
							'session' => $info,
860
					] );
861
					return false;
862
				}
863
			}
864
865
			// And if we have a token in the metadata, it must match the loaded/provided user.
866
			if ( $metadata['userToken'] !== null &&
867
				$userInfo->getToken() !== $metadata['userToken']
868
			) {
869
				$this->logger->warning( 'Session "{session}": User token mismatch', [
870
					'session' => $info,
871
				] );
872
				return false;
873
			}
874
			if ( !$userInfo->isVerified() ) {
875
				$newParams['userInfo'] = $userInfo->verified();
876
			}
877
878
			if ( !empty( $metadata['remember'] ) && !$info->wasRemembered() ) {
879
				$newParams['remembered'] = true;
880
			}
881
			if ( !empty( $metadata['forceHTTPS'] ) && !$info->forceHTTPS() ) {
882
				$newParams['forceHTTPS'] = true;
883
			}
884
			if ( !empty( $metadata['persisted'] ) && !$info->wasPersisted() ) {
885
				$newParams['persisted'] = true;
886
			}
887
888
			if ( !$info->isIdSafe() ) {
889
				$newParams['idIsSafe'] = true;
890
			}
891
		} else {
892
			// No metadata, so we can't load the provider if one wasn't given.
893 View Code Duplication
			if ( $info->getProvider() === null ) {
894
				$this->logger->warning(
895
					'Session "{session}": Null provider and no metadata',
896
					[
897
						'session' => $info,
898
				] );
899
				return false;
900
			}
901
902
			// If no user was provided and no metadata, it must be anon.
903
			if ( !$info->getUserInfo() ) {
904
				if ( $info->getProvider()->canChangeUser() ) {
905
					$newParams['userInfo'] = UserInfo::newAnonymous();
906
				} else {
907
					$this->logger->info(
908
						'Session "{session}": No user provided and provider cannot set user',
909
						[
910
							'session' => $info,
911
					] );
912
					return false;
913
				}
914
			} elseif ( !$info->getUserInfo()->isVerified() ) {
915
				$this->logger->warning(
916
					'Session "{session}": Unverified user provided and no metadata to auth it',
917
					[
918
						'session' => $info,
919
				] );
920
				return false;
921
			}
922
923
			$data = false;
924
			$metadata = false;
925
926
			if ( !$info->getProvider()->persistsSessionId() && !$info->isIdSafe() ) {
927
				// The ID doesn't come from the user, so it should be safe
928
				// (and if not, nothing we can do about it anyway)
929
				$newParams['idIsSafe'] = true;
930
			}
931
		}
932
933
		// Construct the replacement SessionInfo, if necessary
934
		if ( $newParams ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $newParams of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
935
			$newParams['copyFrom'] = $info;
936
			$info = new SessionInfo( $info->getPriority(), $newParams );
937
		}
938
939
		// Allow the provider to check the loaded SessionInfo
940
		$providerMetadata = $info->getProviderMetadata();
941
		if ( !$info->getProvider()->refreshSessionInfo( $info, $request, $providerMetadata ) ) {
942
			return false;
943
		}
944
		if ( $providerMetadata !== $info->getProviderMetadata() ) {
945
			$info = new SessionInfo( $info->getPriority(), [
946
				'metadata' => $providerMetadata,
947
				'copyFrom' => $info,
948
			] );
949
		}
950
951
		// Give hooks a chance to abort. Combined with the SessionMetadata
952
		// hook, this can allow for tying a session to an IP address or the
953
		// like.
954
		$reason = 'Hook aborted';
955
		if ( !\Hooks::run(
956
			'SessionCheckInfo',
957
			[ &$reason, $info, $request, $metadata, $data ]
958
		) ) {
959
			$this->logger->warning( 'Session "{session}": ' . $reason, [
960
				'session' => $info,
961
			] );
962
			return false;
963
		}
964
965
		return true;
966
	}
967
968
	/**
969
	 * Create a session corresponding to the passed SessionInfo
970
	 * @private For use by a SessionProvider that needs to specially create its
971
	 *  own session.
972
	 * @param SessionInfo $info
973
	 * @param WebRequest $request
974
	 * @return Session
975
	 */
976
	public function getSessionFromInfo( SessionInfo $info, WebRequest $request ) {
977
		// @codeCoverageIgnoreStart
978
		if ( defined( 'MW_NO_SESSION' ) ) {
979
			if ( MW_NO_SESSION === 'warn' ) {
980
				// Undocumented safety case for converting existing entry points
981
				$this->logger->error( 'Sessions are supposed to be disabled for this entry point', [
982
					'exception' => new \BadMethodCallException( 'Sessions are disabled for this entry point' ),
983
				] );
984
			} else {
985
				throw new \BadMethodCallException( 'Sessions are disabled for this entry point' );
986
			}
987
		}
988
		// @codeCoverageIgnoreEnd
989
990
		$id = $info->getId();
991
992
		if ( !isset( $this->allSessionBackends[$id] ) ) {
993
			if ( !isset( $this->allSessionIds[$id] ) ) {
994
				$this->allSessionIds[$id] = new SessionId( $id );
995
			}
996
			$backend = new SessionBackend(
997
				$this->allSessionIds[$id],
998
				$info,
999
				$this->store,
0 ignored issues
show
Bug introduced by
It seems like $this->store can be null; however, __construct() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
1000
				$this->logger,
1001
				$this->config->get( 'ObjectCacheSessionExpiry' )
1002
			);
1003
			$this->allSessionBackends[$id] = $backend;
1004
			$delay = $backend->delaySave();
1005
		} else {
1006
			$backend = $this->allSessionBackends[$id];
1007
			$delay = $backend->delaySave();
1008
			if ( $info->wasPersisted() ) {
1009
				$backend->persist();
1010
			}
1011
			if ( $info->wasRemembered() ) {
1012
				$backend->setRememberUser( true );
1013
			}
1014
		}
1015
1016
		$request->setSessionId( $backend->getSessionId() );
1017
		$session = $backend->getSession( $request );
1018
1019
		if ( !$info->isIdSafe() ) {
1020
			$session->resetId();
1021
		}
1022
1023
		\ScopedCallback::consume( $delay );
1024
		return $session;
1025
	}
1026
1027
	/**
1028
	 * Deregister a SessionBackend
1029
	 * @private For use from \\MediaWiki\\Session\\SessionBackend only
1030
	 * @param SessionBackend $backend
1031
	 */
1032
	public function deregisterSessionBackend( SessionBackend $backend ) {
1033
		$id = $backend->getId();
1034 View Code Duplication
		if ( !isset( $this->allSessionBackends[$id] ) || !isset( $this->allSessionIds[$id] ) ||
1035
			$this->allSessionBackends[$id] !== $backend ||
1036
			$this->allSessionIds[$id] !== $backend->getSessionId()
1037
		) {
1038
			throw new \InvalidArgumentException( 'Backend was not registered with this SessionManager' );
1039
		}
1040
1041
		unset( $this->allSessionBackends[$id] );
1042
		// Explicitly do not unset $this->allSessionIds[$id]
1043
	}
1044
1045
	/**
1046
	 * Change a SessionBackend's ID
1047
	 * @private For use from \\MediaWiki\\Session\\SessionBackend only
1048
	 * @param SessionBackend $backend
1049
	 */
1050
	public function changeBackendId( SessionBackend $backend ) {
1051
		$sessionId = $backend->getSessionId();
1052
		$oldId = (string)$sessionId;
1053 View Code Duplication
		if ( !isset( $this->allSessionBackends[$oldId] ) || !isset( $this->allSessionIds[$oldId] ) ||
1054
			$this->allSessionBackends[$oldId] !== $backend ||
1055
			$this->allSessionIds[$oldId] !== $sessionId
1056
		) {
1057
			throw new \InvalidArgumentException( 'Backend was not registered with this SessionManager' );
1058
		}
1059
1060
		$newId = $this->generateSessionId();
1061
1062
		unset( $this->allSessionBackends[$oldId], $this->allSessionIds[$oldId] );
1063
		$sessionId->setId( $newId );
0 ignored issues
show
Security Bug introduced by
It seems like $newId defined by $this->generateSessionId() on line 1060 can also be of type false; however, MediaWiki\Session\SessionId::setId() does only seem to accept string, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
1064
		$this->allSessionBackends[$newId] = $backend;
1065
		$this->allSessionIds[$newId] = $sessionId;
1066
	}
1067
1068
	/**
1069
	 * Generate a new random session ID
1070
	 * @return string
1071
	 */
1072
	public function generateSessionId() {
1073
		do {
1074
			$id = wfBaseConvert( \MWCryptRand::generateHex( 40 ), 16, 32, 32 );
0 ignored issues
show
Deprecated Code introduced by
The function wfBaseConvert() has been deprecated with message: 1.27 Use Wikimedia\base_convert() directly

This function has been deprecated. The supplier of the file has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed from the class and what other function to use instead.

Loading history...
1075
			$key = wfMemcKey( 'MWSession', $id );
1076
		} while ( isset( $this->allSessionIds[$id] ) || is_array( $this->store->get( $key ) ) );
1077
		return $id;
1078
	}
1079
1080
	/**
1081
	 * Call setters on a PHPSessionHandler
1082
	 * @private Use PhpSessionHandler::install()
1083
	 * @param PHPSessionHandler $handler
1084
	 */
1085
	public function setupPHPSessionHandler( PHPSessionHandler $handler ) {
1086
		$handler->setManager( $this, $this->store, $this->logger );
0 ignored issues
show
Bug introduced by
It seems like $this->store can be null; however, setManager() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
1087
	}
1088
1089
	/**
1090
	 * Reset the internal caching for unit testing
1091
	 */
1092
	public static function resetCache() {
1093
		if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
1094
			// @codeCoverageIgnoreStart
1095
			throw new MWException( __METHOD__ . ' may only be called from unit tests!' );
1096
			// @codeCoverageIgnoreEnd
1097
		}
1098
1099
		self::$globalSession = null;
1100
		self::$globalSessionRequest = null;
1101
	}
1102
1103
	/**@}*/
1104
1105
}
1106