Completed
Branch master (9ffc40)
by
unknown
24:04
created

Session::getEncryptionAlgorithm()   D

Complexity

Conditions 10
Paths 15

Size

Total Lines 46
Code Lines 29

Duplication

Lines 16
Ratio 34.78 %

Importance

Changes 0
Metric Value
cc 10
eloc 29
nc 15
nop 0
dl 16
loc 46
rs 4.983
c 0
b 0
f 0

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
/**
3
 * MediaWiki session
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 User;
28
use WebRequest;
29
30
/**
31
 * Manages data for an an authenticated session
32
 *
33
 * A Session represents the fact that the current HTTP request is part of a
34
 * session. There are two broad types of Sessions, based on whether they
35
 * return true or false from self::canSetUser():
36
 * * When true (mutable), the Session identifies multiple requests as part of
37
 *   a session generically, with no tie to a particular user.
38
 * * When false (immutable), the Session identifies multiple requests as part
39
 *   of a session by identifying and authenticating the request itself as
40
 *   belonging to a particular user.
41
 *
42
 * The Session object also serves as a replacement for PHP's $_SESSION,
43
 * managing access to per-session data.
44
 *
45
 * @ingroup Session
46
 * @since 1.27
47
 */
48
final class Session implements \Countable, \Iterator, \ArrayAccess {
49
	/** @var null|string[] Encryption algorithm to use */
50
	private static $encryptionAlgorithm = null;
51
52
	/** @var SessionBackend Session backend */
53
	private $backend;
54
55
	/** @var int Session index */
56
	private $index;
57
58
	/** @var LoggerInterface */
59
	private $logger;
60
61
	/**
62
	 * @param SessionBackend $backend
63
	 * @param int $index
64
	 * @param LoggerInterface $logger
65
	 */
66
	public function __construct( SessionBackend $backend, $index, LoggerInterface $logger ) {
67
		$this->backend = $backend;
68
		$this->index = $index;
69
		$this->logger = $logger;
70
	}
71
72
	public function __destruct() {
73
		$this->backend->deregisterSession( $this->index );
74
	}
75
76
	/**
77
	 * Returns the session ID
78
	 * @return string
79
	 */
80
	public function getId() {
81
		return $this->backend->getId();
82
	}
83
84
	/**
85
	 * Returns the SessionId object
86
	 * @private For internal use by WebRequest
87
	 * @return SessionId
88
	 */
89
	public function getSessionId() {
90
		return $this->backend->getSessionId();
91
	}
92
93
	/**
94
	 * Changes the session ID
95
	 * @return string New ID (might be the same as the old)
96
	 */
97
	public function resetId() {
98
		return $this->backend->resetId();
99
	}
100
101
	/**
102
	 * Fetch the SessionProvider for this session
103
	 * @return SessionProviderInterface
104
	 */
105
	public function getProvider() {
106
		return $this->backend->getProvider();
107
	}
108
109
	/**
110
	 * Indicate whether this session is persisted across requests
111
	 *
112
	 * For example, if cookies are set.
113
	 *
114
	 * @return bool
115
	 */
116
	public function isPersistent() {
117
		return $this->backend->isPersistent();
118
	}
119
120
	/**
121
	 * Make this session persisted across requests
122
	 *
123
	 * If the session is already persistent, equivalent to calling
124
	 * $this->renew().
125
	 */
126
	public function persist() {
127
		$this->backend->persist();
128
	}
129
130
	/**
131
	 * Make this session not be persisted across requests
132
	 */
133
	public function unpersist() {
134
		$this->backend->unpersist();
135
	}
136
137
	/**
138
	 * Indicate whether the user should be remembered independently of the
139
	 * session ID.
140
	 * @return bool
141
	 */
142
	public function shouldRememberUser() {
143
		return $this->backend->shouldRememberUser();
144
	}
145
146
	/**
147
	 * Set whether the user should be remembered independently of the session
148
	 * ID.
149
	 * @param bool $remember
150
	 */
151
	public function setRememberUser( $remember ) {
152
		$this->backend->setRememberUser( $remember );
153
	}
154
155
	/**
156
	 * Returns the request associated with this session
157
	 * @return WebRequest
158
	 */
159
	public function getRequest() {
160
		return $this->backend->getRequest( $this->index );
161
	}
162
163
	/**
164
	 * Returns the authenticated user for this session
165
	 * @return User
166
	 */
167
	public function getUser() {
168
		return $this->backend->getUser();
169
	}
170
171
	/**
172
	 * Fetch the rights allowed the user when this session is active.
173
	 * @return null|string[] Allowed user rights, or null to allow all.
174
	 */
175
	public function getAllowedUserRights() {
176
		return $this->backend->getAllowedUserRights();
177
	}
178
179
	/**
180
	 * Indicate whether the session user info can be changed
181
	 * @return bool
182
	 */
183
	public function canSetUser() {
184
		return $this->backend->canSetUser();
185
	}
186
187
	/**
188
	 * Set a new user for this session
189
	 * @note This should only be called when the user has been authenticated
190
	 * @param User $user User to set on the session.
191
	 *   This may become a "UserValue" in the future, or User may be refactored
192
	 *   into such.
193
	 */
194
	public function setUser( $user ) {
195
		$this->backend->setUser( $user );
196
	}
197
198
	/**
199
	 * Get a suggested username for the login form
200
	 * @return string|null
201
	 */
202
	public function suggestLoginUsername() {
203
		return $this->backend->suggestLoginUsername( $this->index );
204
	}
205
206
	/**
207
	 * Whether HTTPS should be forced
208
	 * @return bool
209
	 */
210
	public function shouldForceHTTPS() {
211
		return $this->backend->shouldForceHTTPS();
212
	}
213
214
	/**
215
	 * Set whether HTTPS should be forced
216
	 * @param bool $force
217
	 */
218
	public function setForceHTTPS( $force ) {
219
		$this->backend->setForceHTTPS( $force );
220
	}
221
222
	/**
223
	 * Fetch the "logged out" timestamp
224
	 * @return int
225
	 */
226
	public function getLoggedOutTimestamp() {
227
		return $this->backend->getLoggedOutTimestamp();
228
	}
229
230
	/**
231
	 * Set the "logged out" timestamp
232
	 * @param int $ts
233
	 */
234
	public function setLoggedOutTimestamp( $ts ) {
235
		$this->backend->setLoggedOutTimestamp( $ts );
236
	}
237
238
	/**
239
	 * Fetch provider metadata
240
	 * @protected For use by SessionProvider subclasses only
241
	 * @return mixed
242
	 */
243
	public function getProviderMetadata() {
244
		return $this->backend->getProviderMetadata();
245
	}
246
247
	/**
248
	 * Delete all session data and clear the user (if possible)
249
	 */
250
	public function clear() {
251
		$data = &$this->backend->getData();
252
		if ( $data ) {
253
			$data = [];
254
			$this->backend->dirty();
255
		}
256
		if ( $this->backend->canSetUser() ) {
257
			$this->backend->setUser( new User );
258
		}
259
		$this->backend->save();
260
	}
261
262
	/**
263
	 * Renew the session
264
	 *
265
	 * Resets the TTL in the backend store if the session is near expiring, and
266
	 * re-persists the session to any active WebRequests if persistent.
267
	 */
268
	public function renew() {
269
		$this->backend->renew();
270
	}
271
272
	/**
273
	 * Fetch a copy of this session attached to an alternative WebRequest
274
	 *
275
	 * Actions on the copy will affect this session too, and vice versa.
276
	 *
277
	 * @param WebRequest $request Any existing session associated with this
278
	 *  WebRequest object will be overwritten.
279
	 * @return Session
280
	 */
281
	public function sessionWithRequest( WebRequest $request ) {
282
		$request->setSessionId( $this->backend->getSessionId() );
283
		return $this->backend->getSession( $request );
284
	}
285
286
	/**
287
	 * Fetch a value from the session
288
	 * @param string|int $key
289
	 * @param mixed $default Returned if $this->exists( $key ) would be false
290
	 * @return mixed
291
	 */
292
	public function get( $key, $default = null ) {
293
		$data = &$this->backend->getData();
294
		return array_key_exists( $key, $data ) ? $data[$key] : $default;
295
	}
296
297
	/**
298
	 * Test if a value exists in the session
299
	 * @note Unlike isset(), null values are considered to exist.
300
	 * @param string|int $key
301
	 * @return bool
302
	 */
303
	public function exists( $key ) {
304
		$data = &$this->backend->getData();
305
		return array_key_exists( $key, $data );
306
	}
307
308
	/**
309
	 * Set a value in the session
310
	 * @param string|int $key
311
	 * @param mixed $value
312
	 */
313
	public function set( $key, $value ) {
314
		$data = &$this->backend->getData();
315
		if ( !array_key_exists( $key, $data ) || $data[$key] !== $value ) {
316
			$data[$key] = $value;
317
			$this->backend->dirty();
318
		}
319
	}
320
321
	/**
322
	 * Remove a value from the session
323
	 * @param string|int $key
324
	 */
325
	public function remove( $key ) {
326
		$data = &$this->backend->getData();
327
		if ( array_key_exists( $key, $data ) ) {
328
			unset( $data[$key] );
329
			$this->backend->dirty();
330
		}
331
	}
332
333
	/**
334
	 * Fetch a CSRF token from the session
335
	 *
336
	 * Note that this does not persist the session, which you'll probably want
337
	 * to do if you want the token to actually be useful.
338
	 *
339
	 * @param string|string[] $salt Token salt
340
	 * @param string $key Token key
341
	 * @return Token
342
	 */
343
	public function getToken( $salt = '', $key = 'default' ) {
344
		$new = false;
345
		$secrets = $this->get( 'wsTokenSecrets' );
346
		if ( !is_array( $secrets ) ) {
347
			$secrets = [];
348
		}
349
		if ( isset( $secrets[$key] ) && is_string( $secrets[$key] ) ) {
350
			$secret = $secrets[$key];
351
		} else {
352
			$secret = \MWCryptRand::generateHex( 32 );
353
			$secrets[$key] = $secret;
354
			$this->set( 'wsTokenSecrets', $secrets );
355
			$new = true;
356
		}
357
		if ( is_array( $salt ) ) {
358
			$salt = implode( '|', $salt );
359
		}
360
		return new Token( $secret, (string)$salt, $new );
361
	}
362
363
	/**
364
	 * Remove a CSRF token from the session
365
	 *
366
	 * The next call to self::getToken() with $key will generate a new secret.
367
	 *
368
	 * @param string $key Token key
369
	 */
370
	public function resetToken( $key = 'default' ) {
371
		$secrets = $this->get( 'wsTokenSecrets' );
372
		if ( is_array( $secrets ) && isset( $secrets[$key] ) ) {
373
			unset( $secrets[$key] );
374
			$this->set( 'wsTokenSecrets', $secrets );
375
		}
376
	}
377
378
	/**
379
	 * Remove all CSRF tokens from the session
380
	 */
381
	public function resetAllTokens() {
382
		$this->remove( 'wsTokenSecrets' );
383
	}
384
385
	/**
386
	 * Fetch the secret keys for self::setSecret() and self::getSecret().
387
	 * @return string[] Encryption key, HMAC key
388
	 */
389
	private function getSecretKeys() {
390
		global $wgSessionSecret, $wgSecretKey, $wgSessionPbkdf2Iterations;
391
392
		$wikiSecret = $wgSessionSecret ?: $wgSecretKey;
393
		$userSecret = $this->get( 'wsSessionSecret', null );
394
		if ( $userSecret === null ) {
395
			$userSecret = \MWCryptRand::generateHex( 32 );
396
			$this->set( 'wsSessionSecret', $userSecret );
397
		}
398
		$iterations = $this->get( 'wsSessionPbkdf2Iterations', null );
399
		if ( $iterations === null ) {
400
			$iterations = $wgSessionPbkdf2Iterations;
401
			$this->set( 'wsSessionPbkdf2Iterations', $iterations );
402
		}
403
404
		$keymats = hash_pbkdf2( 'sha256', $wikiSecret, $userSecret, $iterations, 64, true );
405
		return [
406
			substr( $keymats, 0, 32 ),
407
			substr( $keymats, 32, 32 ),
408
		];
409
	}
410
411
	/**
412
	 * Decide what type of encryption to use, based on system capabilities.
413
	 * @return array
414
	 */
415
	private static function getEncryptionAlgorithm() {
416
		global $wgSessionInsecureSecrets;
417
418
		if ( self::$encryptionAlgorithm === null ) {
419
			if ( function_exists( 'openssl_encrypt' ) ) {
420
				$methods = openssl_get_cipher_methods();
421 View Code Duplication
				if ( in_array( 'aes-256-ctr', $methods, true ) ) {
422
					self::$encryptionAlgorithm = [ 'openssl', 'aes-256-ctr' ];
423
					return self::$encryptionAlgorithm;
424
				}
425 View Code Duplication
				if ( in_array( 'aes-256-cbc', $methods, true ) ) {
426
					self::$encryptionAlgorithm = [ 'openssl', 'aes-256-cbc' ];
427
					return self::$encryptionAlgorithm;
428
				}
429
			}
430
431
			if ( function_exists( 'mcrypt_encrypt' )
432
				&& in_array( 'rijndael-128', mcrypt_list_algorithms(), true )
433
			) {
434
				$modes = mcrypt_list_modes();
435 View Code Duplication
				if ( in_array( 'ctr', $modes, true ) ) {
436
					self::$encryptionAlgorithm = [ 'mcrypt', 'rijndael-128', 'ctr' ];
437
					return self::$encryptionAlgorithm;
438
				}
439 View Code Duplication
				if ( in_array( 'cbc', $modes, true ) ) {
440
					self::$encryptionAlgorithm = [ 'mcrypt', 'rijndael-128', 'cbc' ];
441
					return self::$encryptionAlgorithm;
442
				}
443
			}
444
445
			if ( $wgSessionInsecureSecrets ) {
446
				// @todo: import a pure-PHP library for AES instead of this
447
				self::$encryptionAlgorithm = [ 'insecure' ];
448
				return self::$encryptionAlgorithm;
449
			}
450
451
			throw new \BadMethodCallException(
452
				'Encryption is not available. You really should install the PHP OpenSSL extension, ' .
453
				'or failing that the mcrypt extension. But if you really can\'t and you\'re willing ' .
454
				'to accept insecure storage of sensitive session data, set ' .
455
				'$wgSessionInsecureSecrets = true in LocalSettings.php to make this exception go away.'
456
			);
457
		}
458
459
		return self::$encryptionAlgorithm;
460
	}
461
462
	/**
463
	 * Set a value in the session, encrypted
464
	 *
465
	 * This relies on the secrecy of $wgSecretKey (by default), or $wgSessionSecret.
466
	 *
467
	 * @param string|int $key
468
	 * @param mixed $value
469
	 */
470
	public function setSecret( $key, $value ) {
471
		list( $encKey, $hmacKey ) = $this->getSecretKeys();
472
		$serialized = serialize( $value );
473
474
		// The code for encryption (with OpenSSL) and sealing is taken from
475
		// Chris Steipp's OATHAuthUtils class in Extension::OATHAuth.
476
477
		// Encrypt
478
		// @todo: import a pure-PHP library for AES instead of doing $wgSessionInsecureSecrets
479
		$iv = \MWCryptRand::generate( 16, true );
480
		$algorithm = self::getEncryptionAlgorithm();
481
		switch ( $algorithm[0] ) {
482
			case 'openssl':
483
				$ciphertext = openssl_encrypt( $serialized, $algorithm[1], $encKey, OPENSSL_RAW_DATA, $iv );
484
				if ( $ciphertext === false ) {
485
					throw new \UnexpectedValueException( 'Encryption failed: ' . openssl_error_string() );
486
				}
487
				break;
488
			case 'mcrypt':
489
				// PKCS7 padding
490
				$blocksize = mcrypt_get_block_size( $algorithm[1], $algorithm[2] );
491
				$pad = $blocksize - ( strlen( $serialized ) % $blocksize );
492
				$serialized .= str_repeat( chr( $pad ), $pad );
493
494
				$ciphertext = mcrypt_encrypt( $algorithm[1], $encKey, $serialized, $algorithm[2], $iv );
495
				if ( $ciphertext === false ) {
496
					throw new \UnexpectedValueException( 'Encryption failed' );
497
				}
498
				break;
499
			case 'insecure':
500
				$ex = new \Exception( 'No encryption is available, storing data as plain text' );
501
				$this->logger->warning( $ex->getMessage(), [ 'exception' => $ex ] );
502
				$ciphertext = $serialized;
503
				break;
504
			default:
505
				throw new \LogicException( 'invalid algorithm' );
506
		}
507
508
		// Seal
509
		$sealed = base64_encode( $iv ) . '.' . base64_encode( $ciphertext );
510
		$hmac = hash_hmac( 'sha256', $sealed, $hmacKey, true );
511
		$encrypted = base64_encode( $hmac ) . '.' . $sealed;
512
513
		// Store
514
		$this->set( $key, $encrypted );
515
	}
516
517
	/**
518
	 * Fetch a value from the session that was set with self::setSecret()
519
	 * @param string|int $key
520
	 * @param mixed $default Returned if $this->exists( $key ) would be false or decryption fails
521
	 * @return mixed
522
	 */
523
	public function getSecret( $key, $default = null ) {
524
		// Fetch
525
		$encrypted = $this->get( $key, null );
526
		if ( $encrypted === null ) {
527
			return $default;
528
		}
529
530
		// The code for unsealing, checking, and decrypting (with OpenSSL) is
531
		// taken from Chris Steipp's OATHAuthUtils class in
532
		// Extension::OATHAuth.
533
534
		// Unseal and check
535
		$pieces = explode( '.', $encrypted );
536 View Code Duplication
		if ( count( $pieces ) !== 3 ) {
537
			$ex = new \Exception( 'Invalid sealed-secret format' );
538
			$this->logger->warning( $ex->getMessage(), [ 'exception' => $ex ] );
539
			return $default;
540
		}
541
		list( $hmac, $iv, $ciphertext ) = $pieces;
542
		list( $encKey, $hmacKey ) = $this->getSecretKeys();
543
		$integCalc = hash_hmac( 'sha256', $iv . '.' . $ciphertext, $hmacKey, true );
544
		if ( !hash_equals( $integCalc, base64_decode( $hmac ) ) ) {
545
			$ex = new \Exception( 'Sealed secret has been tampered with, aborting.' );
546
			$this->logger->warning( $ex->getMessage(), [ 'exception' => $ex ] );
547
			return $default;
548
		}
549
550
		// Decrypt
551
		$algorithm = self::getEncryptionAlgorithm();
552
		switch ( $algorithm[0] ) {
553
			case 'openssl':
554
				$serialized = openssl_decrypt( base64_decode( $ciphertext ), $algorithm[1], $encKey,
555
					OPENSSL_RAW_DATA, base64_decode( $iv ) );
556 View Code Duplication
				if ( $serialized === false ) {
557
					$ex = new \Exception( 'Decyption failed: ' . openssl_error_string() );
558
					$this->logger->debug( $ex->getMessage(), [ 'exception' => $ex ] );
559
					return $default;
560
				}
561
				break;
562
			case 'mcrypt':
563
				$serialized = mcrypt_decrypt( $algorithm[1], $encKey, base64_decode( $ciphertext ),
564
					$algorithm[2], base64_decode( $iv ) );
565 View Code Duplication
				if ( $serialized === false ) {
566
					$ex = new \Exception( 'Decyption failed' );
567
					$this->logger->debug( $ex->getMessage(), [ 'exception' => $ex ] );
568
					return $default;
569
				}
570
571
				// Remove PKCS7 padding
572
				$pad = ord( substr( $serialized, -1 ) );
573
				$serialized = substr( $serialized, 0, -$pad );
574
				break;
575
			case 'insecure':
576
				$ex = new \Exception(
577
					'No encryption is available, retrieving data that was stored as plain text'
578
				);
579
				$this->logger->warning( $ex->getMessage(), [ 'exception' => $ex ] );
580
				$serialized = base64_decode( $ciphertext );
581
				break;
582
			default:
583
				throw new \LogicException( 'invalid algorithm' );
584
		}
585
586
		$value = unserialize( $serialized );
587
		if ( $value === false && $serialized !== serialize( false ) ) {
588
			$value = $default;
589
		}
590
		return $value;
591
	}
592
593
	/**
594
	 * Delay automatic saving while multiple updates are being made
595
	 *
596
	 * Calls to save() or clear() will not be delayed.
597
	 *
598
	 * @return \ScopedCallback When this goes out of scope, a save will be triggered
599
	 */
600
	public function delaySave() {
601
		return $this->backend->delaySave();
602
	}
603
604
	/**
605
	 * Save the session
606
	 */
607
	public function save() {
608
		$this->backend->save();
609
	}
610
611
	/**
612
	 * @name Interface methods
613
	 * @{
614
	 */
615
616
	public function count() {
617
		$data = &$this->backend->getData();
618
		return count( $data );
619
	}
620
621
	public function current() {
622
		$data = &$this->backend->getData();
623
		return current( $data );
624
	}
625
626
	public function key() {
627
		$data = &$this->backend->getData();
628
		return key( $data );
629
	}
630
631
	public function next() {
632
		$data = &$this->backend->getData();
633
		next( $data );
634
	}
635
636
	public function rewind() {
637
		$data = &$this->backend->getData();
638
		reset( $data );
639
	}
640
641
	public function valid() {
642
		$data = &$this->backend->getData();
643
		return key( $data ) !== null;
644
	}
645
646
	/**
647
	 * @note Despite the name, this seems to be intended to implement isset()
648
	 *  rather than array_key_exists(). So do that.
649
	 */
650
	public function offsetExists( $offset ) {
651
		$data = &$this->backend->getData();
652
		return isset( $data[$offset] );
653
	}
654
655
	/**
656
	 * @note This supports indirect modifications but can't mark the session
657
	 *  dirty when those happen. SessionBackend::save() checks the hash of the
658
	 *  data to detect such changes.
659
	 * @note Accessing a nonexistent key via this mechanism causes that key to
660
	 *  be created with a null value, and does not raise a PHP warning.
661
	 */
662
	public function &offsetGet( $offset ) {
663
		$data = &$this->backend->getData();
664
		if ( !array_key_exists( $offset, $data ) ) {
665
			$ex = new \Exception( "Undefined index (auto-adds to session with a null value): $offset" );
666
			$this->logger->debug( $ex->getMessage(), [ 'exception' => $ex ] );
667
		}
668
		return $data[$offset];
669
	}
670
671
	public function offsetSet( $offset, $value ) {
672
		$this->set( $offset, $value );
673
	}
674
675
	public function offsetUnset( $offset ) {
676
		$this->remove( $offset );
677
	}
678
679
	/**@}*/
680
681
}
682