Completed
Branch master (bbf110)
by
unknown
25:51
created

SessionManager::getSessionForRequest()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 7
nc 2
nop 1
dl 0
loc 10
rs 9.4285
c 0
b 0
f 0
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 MWException;
27
use Psr\Log\LoggerInterface;
28
use BagOStuff;
29
use CachedBagOStuff;
30
use Config;
31
use FauxRequest;
32
use User;
33
use WebRequest;
34
35
/**
36
 * This serves as the entry point to the MediaWiki session handling system.
37
 *
38
 * @ingroup Session
39
 * @since 1.27
40
 */
41
final class SessionManager implements SessionManagerInterface {
42
	/** @var SessionManager|null */
43
	private static $instance = null;
44
45
	/** @var Session|null */
46
	private static $globalSession = null;
47
48
	/** @var WebRequest|null */
49
	private static $globalSessionRequest = null;
50
51
	/** @var LoggerInterface */
52
	private $logger;
53
54
	/** @var Config */
55
	private $config;
56
57
	/** @var CachedBagOStuff|null */
58
	private $store;
59
60
	/** @var SessionProvider[] */
61
	private $sessionProviders = null;
62
63
	/** @var string[] */
64
	private $varyCookies = null;
65
66
	/** @var array */
67
	private $varyHeaders = null;
68
69
	/** @var SessionBackend[] */
70
	private $allSessionBackends = [];
71
72
	/** @var SessionId[] */
73
	private $allSessionIds = [];
74
75
	/** @var string[] */
76
	private $preventUsers = [];
77
78
	/**
79
	 * Get the global SessionManager
80
	 * @return SessionManagerInterface
81
	 *  (really a SessionManager, but this is to make IDEs less confused)
82
	 */
83
	public static function singleton() {
84
		if ( self::$instance === null ) {
85
			self::$instance = new self();
86
		}
87
		return self::$instance;
88
	}
89
90
	/**
91
	 * Get the "global" session
92
	 *
93
	 * If PHP's session_id() has been set, returns that session. Otherwise
94
	 * returns the session for RequestContext::getMain()->getRequest().
95
	 *
96
	 * @return Session
97
	 */
98
	public static function getGlobalSession() {
99
		if ( !PHPSessionHandler::isEnabled() ) {
100
			$id = '';
101
		} else {
102
			$id = session_id();
103
		}
104
105
		$request = \RequestContext::getMain()->getRequest();
106
		if (
107
			!self::$globalSession // No global session is set up yet
108
			|| self::$globalSessionRequest !== $request // The global WebRequest changed
109
			|| $id !== '' && self::$globalSession->getId() !== $id // Someone messed with session_id()
110
		) {
111
			self::$globalSessionRequest = $request;
112
			if ( $id === '' ) {
113
				// session_id() wasn't used, so fetch the Session from the WebRequest.
114
				// We use $request->getSession() instead of $singleton->getSessionForRequest()
115
				// because doing the latter would require a public
116
				// "$request->getSessionId()" method that would confuse end
117
				// users by returning SessionId|null where they'd expect it to
118
				// be short for $request->getSession()->getId(), and would
119
				// wind up being a duplicate of the code in
120
				// $request->getSession() anyway.
121
				self::$globalSession = $request->getSession();
122
			} else {
123
				// Someone used session_id(), so we need to follow suit.
124
				// Note this overwrites whatever session might already be
125
				// associated with $request with the one for $id.
126
				self::$globalSession = self::singleton()->getSessionById( $id, true, $request )
127
					?: $request->getSession();
128
			}
129
		}
130
		return self::$globalSession;
131
	}
132
133
	/**
134
	 * @param array $options
135
	 *  - config: Config to fetch configuration from. Defaults to the default 'main' config.
136
	 *  - logger: LoggerInterface to use for logging. Defaults to the 'session' channel.
137
	 *  - store: BagOStuff to store session data in.
138
	 */
139
	public function __construct( $options = [] ) {
140
		if ( isset( $options['config'] ) ) {
141
			$this->config = $options['config'];
142
			if ( !$this->config instanceof Config ) {
143
				throw new \InvalidArgumentException(
144
					'$options[\'config\'] must be an instance of Config'
145
				);
146
			}
147
		} else {
148
			$this->config = \ConfigFactory::getDefaultInstance()->makeConfig( 'main' );
149
		}
150
151
		if ( isset( $options['logger'] ) ) {
152
			if ( !$options['logger'] instanceof LoggerInterface ) {
153
				throw new \InvalidArgumentException(
154
					'$options[\'logger\'] must be an instance of LoggerInterface'
155
				);
156
			}
157
			$this->setLogger( $options['logger'] );
158
		} else {
159
			$this->setLogger( \MediaWiki\Logger\LoggerFactory::getInstance( 'session' ) );
160
		}
161
162
		if ( isset( $options['store'] ) ) {
163
			if ( !$options['store'] instanceof BagOStuff ) {
164
				throw new \InvalidArgumentException(
165
					'$options[\'store\'] must be an instance of BagOStuff'
166
				);
167
			}
168
			$store = $options['store'];
169
		} else {
170
			$store = \ObjectCache::getInstance( $this->config->get( 'SessionCacheType' ) );
171
		}
172
		$this->store = $store instanceof CachedBagOStuff ? $store : new CachedBagOStuff( $store );
173
174
		register_shutdown_function( [ $this, 'shutdown' ] );
175
	}
176
177
	public function setLogger( LoggerInterface $logger ) {
178
		$this->logger = $logger;
179
	}
180
181
	public function getSessionForRequest( WebRequest $request ) {
182
		$info = $this->getSessionInfoForRequest( $request );
183
184
		if ( !$info ) {
185
			$session = $this->getEmptySession( $request );
186
		} else {
187
			$session = $this->getSessionFromInfo( $info, $request );
188
		}
189
		return $session;
190
	}
191
192
	public function getSessionById( $id, $create = false, WebRequest $request = null ) {
193
		if ( !self::validateSessionId( $id ) ) {
194
			throw new \InvalidArgumentException( 'Invalid session ID' );
195
		}
196
		if ( !$request ) {
197
			$request = new FauxRequest;
198
		}
199
200
		$session = null;
201
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [ 'id' => $id, 'idIsSafe' => true ] );
202
203
		// If we already have the backend loaded, use it directly
204
		if ( isset( $this->allSessionBackends[$id] ) ) {
205
			return $this->getSessionFromInfo( $info, $request );
206
		}
207
208
		// Test if the session is in storage, and if so try to load it.
209
		$key = wfMemcKey( 'MWSession', $id );
210
		if ( is_array( $this->store->get( $key ) ) ) {
211
			$create = false; // If loading fails, don't bother creating because it probably will fail too.
212
			if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
213
				$session = $this->getSessionFromInfo( $info, $request );
214
			}
215
		}
216
217
		if ( $create && $session === null ) {
218
			$ex = null;
219
			try {
220
				$session = $this->getEmptySessionInternal( $request, $id );
221
			} catch ( \Exception $ex ) {
222
				$this->logger->error( 'Failed to create empty session: {exception}',
223
					[
224
						'method' => __METHOD__,
225
						'exception' => $ex,
226
				] );
227
				$session = null;
228
			}
229
		}
230
231
		return $session;
232
	}
233
234
	public function getEmptySession( WebRequest $request = null ) {
235
		return $this->getEmptySessionInternal( $request );
236
	}
237
238
	/**
239
	 * @see SessionManagerInterface::getEmptySession
240
	 * @param WebRequest|null $request
241
	 * @param string|null $id ID to force on the new session
242
	 * @return Session
243
	 */
244
	private function getEmptySessionInternal( WebRequest $request = null, $id = null ) {
245
		if ( $id !== null ) {
246
			if ( !self::validateSessionId( $id ) ) {
247
				throw new \InvalidArgumentException( 'Invalid session ID' );
248
			}
249
250
			$key = wfMemcKey( 'MWSession', $id );
251
			if ( is_array( $this->store->get( $key ) ) ) {
252
				throw new \InvalidArgumentException( 'Session ID already exists' );
253
			}
254
		}
255
		if ( !$request ) {
256
			$request = new FauxRequest;
257
		}
258
259
		$infos = [];
260
		foreach ( $this->getProviders() as $provider ) {
261
			$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...
262
			if ( !$info ) {
263
				continue;
264
			}
265
			if ( $info->getProvider() !== $provider ) {
266
				throw new \UnexpectedValueException(
267
					"$provider returned an empty session info for a different provider: $info"
268
				);
269
			}
270
			if ( $id !== null && $info->getId() !== $id ) {
271
				throw new \UnexpectedValueException(
272
					"$provider returned empty session info with a wrong id: " .
273
						$info->getId() . ' != ' . $id
274
				);
275
			}
276
			if ( !$info->isIdSafe() ) {
277
				throw new \UnexpectedValueException(
278
					"$provider returned empty session info with id flagged unsafe"
279
				);
280
			}
281
			$compare = $infos ? SessionInfo::compare( $infos[0], $info ) : -1;
282
			if ( $compare > 0 ) {
283
				continue;
284
			}
285
			if ( $compare === 0 ) {
286
				$infos[] = $info;
287
			} else {
288
				$infos = [ $info ];
289
			}
290
		}
291
292
		// Make sure there's exactly one
293
		if ( count( $infos ) > 1 ) {
294
			throw new \UnexpectedValueException(
295
				'Multiple empty sessions tied for top priority: ' . implode( ', ', $infos )
296
			);
297
		} elseif ( count( $infos ) < 1 ) {
298
			throw new \UnexpectedValueException( 'No provider could provide an empty session!' );
299
		}
300
301
		return $this->getSessionFromInfo( $infos[0], $request );
302
	}
303
304
	public function invalidateSessionsForUser( User $user ) {
305
		$user->setToken();
306
		$user->saveSettings();
307
308
		$authUser = \MediaWiki\Auth\AuthManager::callLegacyAuthPlugin( 'getUserInstance', [ &$user ] );
309
		if ( $authUser ) {
310
			$authUser->resetAuthToken();
311
		}
312
313
		foreach ( $this->getProviders() as $provider ) {
314
			$provider->invalidateSessionsForUser( $user );
315
		}
316
	}
317
318
	public function getVaryHeaders() {
319
		// @codeCoverageIgnoreStart
320
		if ( defined( 'MW_NO_SESSION' ) && MW_NO_SESSION !== 'warn' ) {
0 ignored issues
show
Unused Code Bug introduced by
The strict comparison !== seems to always evaluate to true as the types of MW_NO_SESSION (integer) and 'warn' (string) can never be identical. Maybe you want to use a loose comparison != instead?
Loading history...
321
			return [];
322
		}
323
		// @codeCoverageIgnoreEnd
324
		if ( $this->varyHeaders === null ) {
325
			$headers = [];
326
			foreach ( $this->getProviders() as $provider ) {
327
				foreach ( $provider->getVaryHeaders() as $header => $options ) {
328
					if ( !isset( $headers[$header] ) ) {
329
						$headers[$header] = [];
330
					}
331
					if ( is_array( $options ) ) {
332
						$headers[$header] = array_unique( array_merge( $headers[$header], $options ) );
333
					}
334
				}
335
			}
336
			$this->varyHeaders = $headers;
337
		}
338
		return $this->varyHeaders;
339
	}
340
341
	public function getVaryCookies() {
342
		// @codeCoverageIgnoreStart
343
		if ( defined( 'MW_NO_SESSION' ) && MW_NO_SESSION !== 'warn' ) {
0 ignored issues
show
Unused Code Bug introduced by
The strict comparison !== seems to always evaluate to true as the types of MW_NO_SESSION (integer) and 'warn' (string) can never be identical. Maybe you want to use a loose comparison != instead?
Loading history...
344
			return [];
345
		}
346
		// @codeCoverageIgnoreEnd
347
		if ( $this->varyCookies === null ) {
348
			$cookies = [];
349
			foreach ( $this->getProviders() as $provider ) {
350
				$cookies = array_merge( $cookies, $provider->getVaryCookies() );
351
			}
352
			$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...
353
		}
354
		return $this->varyCookies;
355
	}
356
357
	/**
358
	 * Validate a session ID
359
	 * @param string $id
360
	 * @return bool
361
	 */
362
	public static function validateSessionId( $id ) {
363
		return is_string( $id ) && preg_match( '/^[a-zA-Z0-9_-]{32,}$/', $id );
364
	}
365
366
	/**
367
	 * @name Internal methods
368
	 * @{
369
	 */
370
371
	/**
372
	 * Auto-create the given user, if necessary
373
	 * @private Don't call this yourself. Let Setup.php do it for you at the right time.
374
	 * @deprecated since 1.27, use MediaWiki\Auth\AuthManager::autoCreateUser instead
375
	 * @param User $user User to auto-create
376
	 * @return bool Success
377
	 * @codeCoverageIgnore
378
	 */
379
	public static function autoCreateUser( User $user ) {
380
		wfDeprecated( __METHOD__, '1.27' );
381
		return \MediaWiki\Auth\AuthManager::singleton()->autoCreateUser(
382
			$user,
383
			\MediaWiki\Auth\AuthManager::AUTOCREATE_SOURCE_SESSION,
384
			false
385
		)->isGood();
386
	}
387
388
	/**
389
	 * Prevent future sessions for the user
390
	 *
391
	 * The intention is that the named account will never again be usable for
392
	 * normal login (i.e. there is no way to undo the prevention of access).
393
	 *
394
	 * @private For use from \User::newSystemUser only
395
	 * @param string $username
396
	 */
397
	public function preventSessionsForUser( $username ) {
398
		$this->preventUsers[$username] = true;
399
400
		// Instruct the session providers to kill any other sessions too.
401
		foreach ( $this->getProviders() as $provider ) {
402
			$provider->preventSessionsForUser( $username );
403
		}
404
	}
405
406
	/**
407
	 * Test if a user is prevented
408
	 * @private For use from SessionBackend only
409
	 * @param string $username
410
	 * @return bool
411
	 */
412
	public function isUserSessionPrevented( $username ) {
413
		return !empty( $this->preventUsers[$username] );
414
	}
415
416
	/**
417
	 * Get the available SessionProviders
418
	 * @return SessionProvider[]
419
	 */
420
	protected function getProviders() {
421
		if ( $this->sessionProviders === null ) {
422
			$this->sessionProviders = [];
423
			foreach ( $this->config->get( 'SessionProviders' ) as $spec ) {
424
				$provider = \ObjectFactory::getObjectFromSpec( $spec );
425
				$provider->setLogger( $this->logger );
426
				$provider->setConfig( $this->config );
427
				$provider->setManager( $this );
428
				if ( isset( $this->sessionProviders[(string)$provider] ) ) {
429
					throw new \UnexpectedValueException( "Duplicate provider name \"$provider\"" );
430
				}
431
				$this->sessionProviders[(string)$provider] = $provider;
432
			}
433
		}
434
		return $this->sessionProviders;
435
	}
436
437
	/**
438
	 * Get a session provider by name
439
	 *
440
	 * Generally, this will only be used by internal implementation of some
441
	 * special session-providing mechanism. General purpose code, if it needs
442
	 * to access a SessionProvider at all, will use Session::getProvider().
443
	 *
444
	 * @param string $name
445
	 * @return SessionProvider|null
446
	 */
447
	public function getProvider( $name ) {
448
		$providers = $this->getProviders();
449
		return isset( $providers[$name] ) ? $providers[$name] : null;
450
	}
451
452
	/**
453
	 * Save all active sessions on shutdown
454
	 * @private For internal use with register_shutdown_function()
455
	 */
456
	public function shutdown() {
457
		if ( $this->allSessionBackends ) {
458
			$this->logger->debug( 'Saving all sessions on shutdown' );
459
			if ( session_id() !== '' ) {
460
				// @codeCoverageIgnoreStart
461
				session_write_close();
462
			}
463
			// @codeCoverageIgnoreEnd
464
			foreach ( $this->allSessionBackends as $backend ) {
465
				$backend->shutdown();
466
			}
467
		}
468
	}
469
470
	/**
471
	 * Fetch the SessionInfo(s) for a request
472
	 * @param WebRequest $request
473
	 * @return SessionInfo|null
474
	 */
475
	private function getSessionInfoForRequest( WebRequest $request ) {
476
		// Call all providers to fetch "the" session
477
		$infos = [];
478
		foreach ( $this->getProviders() as $provider ) {
479
			$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...
480
			if ( !$info ) {
481
				continue;
482
			}
483
			if ( $info->getProvider() !== $provider ) {
484
				throw new \UnexpectedValueException(
485
					"$provider returned session info for a different provider: $info"
486
				);
487
			}
488
			$infos[] = $info;
489
		}
490
491
		// Sort the SessionInfos. Then find the first one that can be
492
		// successfully loaded, and then all the ones after it with the same
493
		// priority.
494
		usort( $infos, 'MediaWiki\\Session\\SessionInfo::compare' );
495
		$retInfos = [];
496
		while ( $infos ) {
497
			$info = array_pop( $infos );
498
			if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
499
				$retInfos[] = $info;
500
				while ( $infos ) {
501
					$info = array_pop( $infos );
502
					if ( SessionInfo::compare( $retInfos[0], $info ) ) {
503
						// We hit a lower priority, stop checking.
504
						break;
505
					}
506
					if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
507
						// This is going to error out below, but we want to
508
						// provide a complete list.
509
						$retInfos[] = $info;
510
					} else {
511
						// Session load failed, so unpersist it from this request
512
						$info->getProvider()->unpersistSession( $request );
513
					}
514
				}
515
			} else {
516
				// Session load failed, so unpersist it from this request
517
				$info->getProvider()->unpersistSession( $request );
518
			}
519
		}
520
521
		if ( count( $retInfos ) > 1 ) {
522
			$ex = new \OverflowException(
523
				'Multiple sessions for this request tied for top priority: ' . implode( ', ', $retInfos )
524
			);
525
			$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...
526
			throw $ex;
527
		}
528
529
		return $retInfos ? $retInfos[0] : null;
530
	}
531
532
	/**
533
	 * Load and verify the session info against the store
534
	 *
535
	 * @param SessionInfo &$info Will likely be replaced with an updated SessionInfo instance
536
	 * @param WebRequest $request
537
	 * @return bool Whether the session info matches the stored data (if any)
538
	 */
539
	private function loadSessionInfoFromStore( SessionInfo &$info, WebRequest $request ) {
540
		$key = wfMemcKey( 'MWSession', $info->getId() );
541
		$blob = $this->store->get( $key );
542
543
		// If we got data from the store and the SessionInfo says to force use,
544
		// "fail" means to delete the data from the store and retry. Otherwise,
545
		// "fail" is just return false.
546
		if ( $info->forceUse() && $blob !== false ) {
547
			$failHandler = function () use ( $key, &$info, $request ) {
548
				$this->store->delete( $key );
549
				return $this->loadSessionInfoFromStore( $info, $request );
550
			};
551
		} else {
552
			$failHandler = function () {
553
				return false;
554
			};
555
		}
556
557
		$newParams = [];
558
559
		if ( $blob !== false ) {
560
			// Sanity check: blob must be an array, if it's saved at all
561
			if ( !is_array( $blob ) ) {
562
				$this->logger->warning( 'Session "{session}": Bad data', [
563
					'session' => $info,
564
				] );
565
				$this->store->delete( $key );
566
				return $failHandler();
567
			}
568
569
			// Sanity check: blob has data and metadata arrays
570
			if ( !isset( $blob['data'] ) || !is_array( $blob['data'] ) ||
571
				!isset( $blob['metadata'] ) || !is_array( $blob['metadata'] )
572
			) {
573
				$this->logger->warning( 'Session "{session}": Bad data structure', [
574
					'session' => $info,
575
				] );
576
				$this->store->delete( $key );
577
				return $failHandler();
578
			}
579
580
			$data = $blob['data'];
581
			$metadata = $blob['metadata'];
582
583
			// Sanity check: metadata must be an array and must contain certain
584
			// keys, if it's saved at all
585
			if ( !array_key_exists( 'userId', $metadata ) ||
586
				!array_key_exists( 'userName', $metadata ) ||
587
				!array_key_exists( 'userToken', $metadata ) ||
588
				!array_key_exists( 'provider', $metadata )
589
			) {
590
				$this->logger->warning( 'Session "{session}": Bad metadata', [
591
					'session' => $info,
592
				] );
593
				$this->store->delete( $key );
594
				return $failHandler();
595
			}
596
597
			// First, load the provider from metadata, or validate it against the metadata.
598
			$provider = $info->getProvider();
599
			if ( $provider === null ) {
600
				$newParams['provider'] = $provider = $this->getProvider( $metadata['provider'] );
601
				if ( !$provider ) {
602
					$this->logger->warning(
603
						'Session "{session}": Unknown provider ' . $metadata['provider'],
604
						[
605
							'session' => $info,
606
						]
607
					);
608
					$this->store->delete( $key );
609
					return $failHandler();
610
				}
611
			} elseif ( $metadata['provider'] !== (string)$provider ) {
612
				$this->logger->warning( 'Session "{session}": Wrong provider ' .
613
					$metadata['provider'] . ' !== ' . $provider,
614
					[
615
						'session' => $info,
616
				] );
617
				return $failHandler();
618
			}
619
620
			// Load provider metadata from metadata, or validate it against the metadata
621
			$providerMetadata = $info->getProviderMetadata();
622
			if ( isset( $metadata['providerMetadata'] ) ) {
623
				if ( $providerMetadata === null ) {
624
					$newParams['metadata'] = $metadata['providerMetadata'];
625
				} else {
626
					try {
627
						$newProviderMetadata = $provider->mergeMetadata(
628
							$metadata['providerMetadata'], $providerMetadata
629
						);
630
						if ( $newProviderMetadata !== $providerMetadata ) {
631
							$newParams['metadata'] = $newProviderMetadata;
632
						}
633
					} catch ( MetadataMergeException $ex ) {
634
						$this->logger->warning(
635
							'Session "{session}": Metadata merge failed: {exception}',
636
							[
637
								'session' => $info,
638
								'exception' => $ex,
639
							] + $ex->getContext()
640
						);
641
						return $failHandler();
642
					}
643
				}
644
			}
645
646
			// Next, load the user from metadata, or validate it against the metadata.
647
			$userInfo = $info->getUserInfo();
648
			if ( !$userInfo ) {
649
				// For loading, id is preferred to name.
650
				try {
651
					if ( $metadata['userId'] ) {
652
						$userInfo = UserInfo::newFromId( $metadata['userId'] );
653
					} elseif ( $metadata['userName'] !== null ) { // Shouldn't happen, but just in case
654
						$userInfo = UserInfo::newFromName( $metadata['userName'] );
655
					} else {
656
						$userInfo = UserInfo::newAnonymous();
657
					}
658
				} catch ( \InvalidArgumentException $ex ) {
659
					$this->logger->error( 'Session "{session}": {exception}', [
660
						'session' => $info,
661
						'exception' => $ex,
662
					] );
663
					return $failHandler();
664
				}
665
				$newParams['userInfo'] = $userInfo;
666
			} else {
667
				// User validation passes if user ID matches, or if there
668
				// is no saved ID and the names match.
669
				if ( $metadata['userId'] ) {
670
					if ( $metadata['userId'] !== $userInfo->getId() ) {
671
						$this->logger->warning(
672
							'Session "{session}": User ID mismatch, {uid_a} !== {uid_b}',
673
							[
674
								'session' => $info,
675
								'uid_a' => $metadata['userId'],
676
								'uid_b' => $userInfo->getId(),
677
						] );
678
						return $failHandler();
679
					}
680
681
					// If the user was renamed, probably best to fail here.
682 View Code Duplication
					if ( $metadata['userName'] !== null &&
683
						$userInfo->getName() !== $metadata['userName']
684
					) {
685
						$this->logger->warning(
686
							'Session "{session}": User ID matched but name didn\'t (rename?), {uname_a} !== {uname_b}',
687
							[
688
								'session' => $info,
689
								'uname_a' => $metadata['userName'],
690
								'uname_b' => $userInfo->getName(),
691
						] );
692
						return $failHandler();
693
					}
694
695 View Code Duplication
				} elseif ( $metadata['userName'] !== null ) { // Shouldn't happen, but just in case
696
					if ( $metadata['userName'] !== $userInfo->getName() ) {
697
						$this->logger->warning(
698
							'Session "{session}": User name mismatch, {uname_a} !== {uname_b}',
699
							[
700
								'session' => $info,
701
								'uname_a' => $metadata['userName'],
702
								'uname_b' => $userInfo->getName(),
703
						] );
704
						return $failHandler();
705
					}
706
				} elseif ( !$userInfo->isAnon() ) {
707
					// Metadata specifies an anonymous user, but the passed-in
708
					// user isn't anonymous.
709
					$this->logger->warning(
710
						'Session "{session}": Metadata has an anonymous user, but a non-anon user was provided',
711
						[
712
							'session' => $info,
713
					] );
714
					return $failHandler();
715
				}
716
			}
717
718
			// And if we have a token in the metadata, it must match the loaded/provided user.
719
			if ( $metadata['userToken'] !== null &&
720
				$userInfo->getToken() !== $metadata['userToken']
721
			) {
722
				$this->logger->warning( 'Session "{session}": User token mismatch', [
723
					'session' => $info,
724
				] );
725
				return $failHandler();
726
			}
727
			if ( !$userInfo->isVerified() ) {
728
				$newParams['userInfo'] = $userInfo->verified();
729
			}
730
731
			if ( !empty( $metadata['remember'] ) && !$info->wasRemembered() ) {
732
				$newParams['remembered'] = true;
733
			}
734
			if ( !empty( $metadata['forceHTTPS'] ) && !$info->forceHTTPS() ) {
735
				$newParams['forceHTTPS'] = true;
736
			}
737
			if ( !empty( $metadata['persisted'] ) && !$info->wasPersisted() ) {
738
				$newParams['persisted'] = true;
739
			}
740
741
			if ( !$info->isIdSafe() ) {
742
				$newParams['idIsSafe'] = true;
743
			}
744
		} else {
745
			// No metadata, so we can't load the provider if one wasn't given.
746
			if ( $info->getProvider() === null ) {
747
				$this->logger->warning(
748
					'Session "{session}": Null provider and no metadata',
749
					[
750
						'session' => $info,
751
				] );
752
				return $failHandler();
753
			}
754
755
			// If no user was provided and no metadata, it must be anon.
756
			if ( !$info->getUserInfo() ) {
757
				if ( $info->getProvider()->canChangeUser() ) {
758
					$newParams['userInfo'] = UserInfo::newAnonymous();
759
				} else {
760
					$this->logger->info(
761
						'Session "{session}": No user provided and provider cannot set user',
762
						[
763
							'session' => $info,
764
					] );
765
					return $failHandler();
766
				}
767
			} elseif ( !$info->getUserInfo()->isVerified() ) {
768
				$this->logger->warning(
769
					'Session "{session}": Unverified user provided and no metadata to auth it',
770
					[
771
						'session' => $info,
772
				] );
773
				return $failHandler();
774
			}
775
776
			$data = false;
777
			$metadata = false;
778
779
			if ( !$info->getProvider()->persistsSessionId() && !$info->isIdSafe() ) {
780
				// The ID doesn't come from the user, so it should be safe
781
				// (and if not, nothing we can do about it anyway)
782
				$newParams['idIsSafe'] = true;
783
			}
784
		}
785
786
		// Construct the replacement SessionInfo, if necessary
787
		if ( $newParams ) {
788
			$newParams['copyFrom'] = $info;
789
			$info = new SessionInfo( $info->getPriority(), $newParams );
790
		}
791
792
		// Allow the provider to check the loaded SessionInfo
793
		$providerMetadata = $info->getProviderMetadata();
794
		if ( !$info->getProvider()->refreshSessionInfo( $info, $request, $providerMetadata ) ) {
795
			return $failHandler();
796
		}
797
		if ( $providerMetadata !== $info->getProviderMetadata() ) {
798
			$info = new SessionInfo( $info->getPriority(), [
799
				'metadata' => $providerMetadata,
800
				'copyFrom' => $info,
801
			] );
802
		}
803
804
		// Give hooks a chance to abort. Combined with the SessionMetadata
805
		// hook, this can allow for tying a session to an IP address or the
806
		// like.
807
		$reason = 'Hook aborted';
808
		if ( !\Hooks::run(
809
			'SessionCheckInfo',
810
			[ &$reason, $info, $request, $metadata, $data ]
811
		) ) {
812
			$this->logger->warning( 'Session "{session}": ' . $reason, [
813
				'session' => $info,
814
			] );
815
			return $failHandler();
816
		}
817
818
		return true;
819
	}
820
821
	/**
822
	 * Create a session corresponding to the passed SessionInfo
823
	 * @private For use by a SessionProvider that needs to specially create its
824
	 *  own session.
825
	 * @param SessionInfo $info
826
	 * @param WebRequest $request
827
	 * @return Session
828
	 */
829
	public function getSessionFromInfo( SessionInfo $info, WebRequest $request ) {
830
		// @codeCoverageIgnoreStart
831
		if ( defined( 'MW_NO_SESSION' ) ) {
832
			if ( MW_NO_SESSION === 'warn' ) {
0 ignored issues
show
Unused Code Bug introduced by
The strict comparison === seems to always evaluate to false as the types of MW_NO_SESSION (integer) and 'warn' (string) can never be identical. Maybe you want to use a loose comparison == instead?
Loading history...
833
				// Undocumented safety case for converting existing entry points
834
				$this->logger->error( 'Sessions are supposed to be disabled for this entry point', [
835
					'exception' => new \BadMethodCallException( 'Sessions are disabled for this entry point' ),
836
				] );
837
			} else {
838
				throw new \BadMethodCallException( 'Sessions are disabled for this entry point' );
839
			}
840
		}
841
		// @codeCoverageIgnoreEnd
842
843
		$id = $info->getId();
844
845
		if ( !isset( $this->allSessionBackends[$id] ) ) {
846
			if ( !isset( $this->allSessionIds[$id] ) ) {
847
				$this->allSessionIds[$id] = new SessionId( $id );
848
			}
849
			$backend = new SessionBackend(
850
				$this->allSessionIds[$id],
851
				$info,
852
				$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...
853
				$this->logger,
854
				$this->config->get( 'ObjectCacheSessionExpiry' )
855
			);
856
			$this->allSessionBackends[$id] = $backend;
857
			$delay = $backend->delaySave();
858
		} else {
859
			$backend = $this->allSessionBackends[$id];
860
			$delay = $backend->delaySave();
861
			if ( $info->wasPersisted() ) {
862
				$backend->persist();
863
			}
864
			if ( $info->wasRemembered() ) {
865
				$backend->setRememberUser( true );
866
			}
867
		}
868
869
		$request->setSessionId( $backend->getSessionId() );
870
		$session = $backend->getSession( $request );
871
872
		if ( !$info->isIdSafe() ) {
873
			$session->resetId();
874
		}
875
876
		\ScopedCallback::consume( $delay );
877
		return $session;
878
	}
879
880
	/**
881
	 * Deregister a SessionBackend
882
	 * @private For use from \MediaWiki\Session\SessionBackend only
883
	 * @param SessionBackend $backend
884
	 */
885
	public function deregisterSessionBackend( SessionBackend $backend ) {
886
		$id = $backend->getId();
887 View Code Duplication
		if ( !isset( $this->allSessionBackends[$id] ) || !isset( $this->allSessionIds[$id] ) ||
888
			$this->allSessionBackends[$id] !== $backend ||
889
			$this->allSessionIds[$id] !== $backend->getSessionId()
890
		) {
891
			throw new \InvalidArgumentException( 'Backend was not registered with this SessionManager' );
892
		}
893
894
		unset( $this->allSessionBackends[$id] );
895
		// Explicitly do not unset $this->allSessionIds[$id]
896
	}
897
898
	/**
899
	 * Change a SessionBackend's ID
900
	 * @private For use from \MediaWiki\Session\SessionBackend only
901
	 * @param SessionBackend $backend
902
	 */
903
	public function changeBackendId( SessionBackend $backend ) {
904
		$sessionId = $backend->getSessionId();
905
		$oldId = (string)$sessionId;
906 View Code Duplication
		if ( !isset( $this->allSessionBackends[$oldId] ) || !isset( $this->allSessionIds[$oldId] ) ||
907
			$this->allSessionBackends[$oldId] !== $backend ||
908
			$this->allSessionIds[$oldId] !== $sessionId
909
		) {
910
			throw new \InvalidArgumentException( 'Backend was not registered with this SessionManager' );
911
		}
912
913
		$newId = $this->generateSessionId();
914
915
		unset( $this->allSessionBackends[$oldId], $this->allSessionIds[$oldId] );
916
		$sessionId->setId( $newId );
0 ignored issues
show
Security Bug introduced by
It seems like $newId defined by $this->generateSessionId() on line 913 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...
917
		$this->allSessionBackends[$newId] = $backend;
918
		$this->allSessionIds[$newId] = $sessionId;
919
	}
920
921
	/**
922
	 * Generate a new random session ID
923
	 * @return string
924
	 */
925
	public function generateSessionId() {
926
		do {
927
			$id = \Wikimedia\base_convert( \MWCryptRand::generateHex( 40 ), 16, 32, 32 );
928
			$key = wfMemcKey( 'MWSession', $id );
929
		} while ( isset( $this->allSessionIds[$id] ) || is_array( $this->store->get( $key ) ) );
930
		return $id;
931
	}
932
933
	/**
934
	 * Call setters on a PHPSessionHandler
935
	 * @private Use PhpSessionHandler::install()
936
	 * @param PHPSessionHandler $handler
937
	 */
938
	public function setupPHPSessionHandler( PHPSessionHandler $handler ) {
939
		$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...
940
	}
941
942
	/**
943
	 * Reset the internal caching for unit testing
944
	 */
945
	public static function resetCache() {
946
		if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
947
			// @codeCoverageIgnoreStart
948
			throw new MWException( __METHOD__ . ' may only be called from unit tests!' );
949
			// @codeCoverageIgnoreEnd
950
		}
951
952
		self::$globalSession = null;
953
		self::$globalSessionRequest = null;
954
	}
955
956
	/**@}*/
957
958
}
959