Merge "Validate the output of the dump scripts."
[lhc/web/wiklou.git] / includes / session / SessionBackend.php
1 <?php
2 /**
3 * MediaWiki session backend
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 CachedBagOStuff;
27 use Psr\Log\LoggerInterface;
28 use User;
29 use WebRequest;
30
31 /**
32 * This is the actual workhorse for Session.
33 *
34 * Most code does not need to use this class, you want \MediaWiki\Session\Session.
35 * The exceptions are SessionProviders and SessionMetadata hook functions,
36 * which get an instance of this class rather than Session.
37 *
38 * The reasons for this split are:
39 * 1. A session can be attached to multiple requests, but we want the Session
40 * object to have some features that correspond to just one of those
41 * requests.
42 * 2. We want reasonable garbage collection behavior, but we also want the
43 * SessionManager to hold a reference to every active session so it can be
44 * saved when the request ends.
45 *
46 * @ingroup Session
47 * @since 1.27
48 */
49 final class SessionBackend {
50 /** @var SessionId */
51 private $id;
52
53 /** @var bool */
54 private $persist = false;
55
56 /** @var bool */
57 private $remember = false;
58
59 /** @var bool */
60 private $forceHTTPS = false;
61
62 /** @var array|null */
63 private $data = null;
64
65 /** @var bool */
66 private $forcePersist = false;
67
68 /** @var bool */
69 private $metaDirty = false;
70
71 /** @var bool */
72 private $dataDirty = false;
73
74 /** @var string Used to detect subarray modifications */
75 private $dataHash = null;
76
77 /** @var CachedBagOStuff */
78 private $store;
79
80 /** @var LoggerInterface */
81 private $logger;
82
83 /** @var int */
84 private $lifetime;
85
86 /** @var User */
87 private $user;
88
89 /** @var int */
90 private $curIndex = 0;
91
92 /** @var WebRequest[] Session requests */
93 private $requests = [];
94
95 /** @var SessionProvider provider */
96 private $provider;
97
98 /** @var array|null provider-specified metadata */
99 private $providerMetadata = null;
100
101 /** @var int */
102 private $expires = 0;
103
104 /** @var int */
105 private $loggedOut = 0;
106
107 /** @var int */
108 private $delaySave = 0;
109
110 /** @var bool */
111 private $usePhpSessionHandling = true;
112 /** @var bool */
113 private $checkPHPSessionRecursionGuard = false;
114
115 /** @var bool */
116 private $shutdown = false;
117
118 /**
119 * @param SessionId $id
120 * @param SessionInfo $info Session info to populate from
121 * @param CachedBagOStuff $store Backend data store
122 * @param LoggerInterface $logger
123 * @param int $lifetime Session data lifetime in seconds
124 */
125 public function __construct(
126 SessionId $id, SessionInfo $info, CachedBagOStuff $store, LoggerInterface $logger, $lifetime
127 ) {
128 $phpSessionHandling = \RequestContext::getMain()->getConfig()->get( 'PHPSessionHandling' );
129 $this->usePhpSessionHandling = $phpSessionHandling !== 'disable';
130
131 if ( $info->getUserInfo() && !$info->getUserInfo()->isVerified() ) {
132 throw new \InvalidArgumentException(
133 "Refusing to create session for unverified user {$info->getUserInfo()}"
134 );
135 }
136 if ( $info->getProvider() === null ) {
137 throw new \InvalidArgumentException( 'Cannot create session without a provider' );
138 }
139 if ( $info->getId() !== $id->getId() ) {
140 throw new \InvalidArgumentException( 'SessionId and SessionInfo don\'t match' );
141 }
142
143 $this->id = $id;
144 $this->user = $info->getUserInfo() ? $info->getUserInfo()->getUser() : new User;
145 $this->store = $store;
146 $this->logger = $logger;
147 $this->lifetime = $lifetime;
148 $this->provider = $info->getProvider();
149 $this->persist = $info->wasPersisted();
150 $this->remember = $info->wasRemembered();
151 $this->forceHTTPS = $info->forceHTTPS();
152 $this->providerMetadata = $info->getProviderMetadata();
153
154 $blob = $store->get( $store->makeKey( 'MWSession', (string)$this->id ) );
155 if ( !is_array( $blob ) ||
156 !isset( $blob['metadata'] ) || !is_array( $blob['metadata'] ) ||
157 !isset( $blob['data'] ) || !is_array( $blob['data'] )
158 ) {
159 $this->data = [];
160 $this->dataDirty = true;
161 $this->metaDirty = true;
162 $this->logger->debug(
163 'SessionBackend "{session}" is unsaved, marking dirty in constructor',
164 [
165 'session' => $this->id,
166 ] );
167 } else {
168 $this->data = $blob['data'];
169 if ( isset( $blob['metadata']['loggedOut'] ) ) {
170 $this->loggedOut = (int)$blob['metadata']['loggedOut'];
171 }
172 if ( isset( $blob['metadata']['expires'] ) ) {
173 $this->expires = (int)$blob['metadata']['expires'];
174 } else {
175 $this->metaDirty = true;
176 $this->logger->debug(
177 'SessionBackend "{session}" metadata dirty due to missing expiration timestamp',
178 [
179 'session' => $this->id,
180 ] );
181 }
182 }
183 $this->dataHash = md5( serialize( $this->data ) );
184 }
185
186 /**
187 * Return a new Session for this backend
188 * @param WebRequest $request
189 * @return Session
190 */
191 public function getSession( WebRequest $request ) {
192 $index = ++$this->curIndex;
193 $this->requests[$index] = $request;
194 $session = new Session( $this, $index, $this->logger );
195 return $session;
196 }
197
198 /**
199 * Deregister a Session
200 * @private For use by \MediaWiki\Session\Session::__destruct() only
201 * @param int $index
202 */
203 public function deregisterSession( $index ) {
204 unset( $this->requests[$index] );
205 if ( !$this->shutdown && !count( $this->requests ) ) {
206 $this->save( true );
207 $this->provider->getManager()->deregisterSessionBackend( $this );
208 }
209 }
210
211 /**
212 * Shut down a session
213 * @private For use by \MediaWiki\Session\SessionManager::shutdown() only
214 */
215 public function shutdown() {
216 $this->save( true );
217 $this->shutdown = true;
218 }
219
220 /**
221 * Returns the session ID.
222 * @return string
223 */
224 public function getId() {
225 return (string)$this->id;
226 }
227
228 /**
229 * Fetch the SessionId object
230 * @private For internal use by WebRequest
231 * @return SessionId
232 */
233 public function getSessionId() {
234 return $this->id;
235 }
236
237 /**
238 * Changes the session ID
239 * @return string New ID (might be the same as the old)
240 */
241 public function resetId() {
242 if ( $this->provider->persistsSessionId() ) {
243 $oldId = (string)$this->id;
244 $restart = $this->usePhpSessionHandling && $oldId === session_id() &&
245 PHPSessionHandler::isEnabled();
246
247 if ( $restart ) {
248 // If this session is the one behind PHP's $_SESSION, we need
249 // to close then reopen it.
250 session_write_close();
251 }
252
253 $this->provider->getManager()->changeBackendId( $this );
254 $this->provider->sessionIdWasReset( $this, $oldId );
255 $this->metaDirty = true;
256 $this->logger->debug(
257 'SessionBackend "{session}" metadata dirty due to ID reset (formerly "{oldId}")',
258 [
259 'session' => $this->id,
260 'oldId' => $oldId,
261 ] );
262
263 if ( $restart ) {
264 session_id( (string)$this->id );
265 \Wikimedia\quietCall( 'session_start' );
266 }
267
268 $this->autosave();
269
270 // Delete the data for the old session ID now
271 $this->store->delete( $this->store->makeKey( 'MWSession', $oldId ) );
272 }
273 }
274
275 /**
276 * Fetch the SessionProvider for this session
277 * @return SessionProviderInterface
278 */
279 public function getProvider() {
280 return $this->provider;
281 }
282
283 /**
284 * Indicate whether this session is persisted across requests
285 *
286 * For example, if cookies are set.
287 *
288 * @return bool
289 */
290 public function isPersistent() {
291 return $this->persist;
292 }
293
294 /**
295 * Make this session persisted across requests
296 *
297 * If the session is already persistent, equivalent to calling
298 * $this->renew().
299 */
300 public function persist() {
301 if ( !$this->persist ) {
302 $this->persist = true;
303 $this->forcePersist = true;
304 $this->metaDirty = true;
305 $this->logger->debug(
306 'SessionBackend "{session}" force-persist due to persist()',
307 [
308 'session' => $this->id,
309 ] );
310 $this->autosave();
311 } else {
312 $this->renew();
313 }
314 }
315
316 /**
317 * Make this session not persisted across requests
318 */
319 public function unpersist() {
320 if ( $this->persist ) {
321 // Close the PHP session, if we're the one that's open
322 if ( $this->usePhpSessionHandling && PHPSessionHandler::isEnabled() &&
323 session_id() === (string)$this->id
324 ) {
325 $this->logger->debug(
326 'SessionBackend "{session}" Closing PHP session for unpersist',
327 [ 'session' => $this->id ]
328 );
329 session_write_close();
330 session_id( '' );
331 }
332
333 $this->persist = false;
334 $this->forcePersist = true;
335 $this->metaDirty = true;
336
337 // Delete the session data, so the local cache-only write in
338 // self::save() doesn't get things out of sync with the backend.
339 $this->store->delete( $this->store->makeKey( 'MWSession', (string)$this->id ) );
340
341 $this->autosave();
342 }
343 }
344
345 /**
346 * Indicate whether the user should be remembered independently of the
347 * session ID.
348 * @return bool
349 */
350 public function shouldRememberUser() {
351 return $this->remember;
352 }
353
354 /**
355 * Set whether the user should be remembered independently of the session
356 * ID.
357 * @param bool $remember
358 */
359 public function setRememberUser( $remember ) {
360 if ( $this->remember !== (bool)$remember ) {
361 $this->remember = (bool)$remember;
362 $this->metaDirty = true;
363 $this->logger->debug(
364 'SessionBackend "{session}" metadata dirty due to remember-user change',
365 [
366 'session' => $this->id,
367 ] );
368 $this->autosave();
369 }
370 }
371
372 /**
373 * Returns the request associated with a Session
374 * @param int $index Session index
375 * @return WebRequest
376 */
377 public function getRequest( $index ) {
378 if ( !isset( $this->requests[$index] ) ) {
379 throw new \InvalidArgumentException( 'Invalid session index' );
380 }
381 return $this->requests[$index];
382 }
383
384 /**
385 * Returns the authenticated user for this session
386 * @return User
387 */
388 public function getUser() {
389 return $this->user;
390 }
391
392 /**
393 * Fetch the rights allowed the user when this session is active.
394 * @return null|string[] Allowed user rights, or null to allow all.
395 */
396 public function getAllowedUserRights() {
397 return $this->provider->getAllowedUserRights( $this );
398 }
399
400 /**
401 * Indicate whether the session user info can be changed
402 * @return bool
403 */
404 public function canSetUser() {
405 return $this->provider->canChangeUser();
406 }
407
408 /**
409 * Set a new user for this session
410 * @note This should only be called when the user has been authenticated via a login process
411 * @param User $user User to set on the session.
412 * This may become a "UserValue" in the future, or User may be refactored
413 * into such.
414 */
415 public function setUser( $user ) {
416 if ( !$this->canSetUser() ) {
417 throw new \BadMethodCallException(
418 'Cannot set user on this session; check $session->canSetUser() first'
419 );
420 }
421
422 $this->user = $user;
423 $this->metaDirty = true;
424 $this->logger->debug(
425 'SessionBackend "{session}" metadata dirty due to user change',
426 [
427 'session' => $this->id,
428 ] );
429 $this->autosave();
430 }
431
432 /**
433 * Get a suggested username for the login form
434 * @param int $index Session index
435 * @return string|null
436 */
437 public function suggestLoginUsername( $index ) {
438 if ( !isset( $this->requests[$index] ) ) {
439 throw new \InvalidArgumentException( 'Invalid session index' );
440 }
441 return $this->provider->suggestLoginUsername( $this->requests[$index] );
442 }
443
444 /**
445 * Whether HTTPS should be forced
446 * @return bool
447 */
448 public function shouldForceHTTPS() {
449 return $this->forceHTTPS;
450 }
451
452 /**
453 * Set whether HTTPS should be forced
454 * @param bool $force
455 */
456 public function setForceHTTPS( $force ) {
457 if ( $this->forceHTTPS !== (bool)$force ) {
458 $this->forceHTTPS = (bool)$force;
459 $this->metaDirty = true;
460 $this->logger->debug(
461 'SessionBackend "{session}" metadata dirty due to force-HTTPS change',
462 [
463 'session' => $this->id,
464 ] );
465 $this->autosave();
466 }
467 }
468
469 /**
470 * Fetch the "logged out" timestamp
471 * @return int
472 */
473 public function getLoggedOutTimestamp() {
474 return $this->loggedOut;
475 }
476
477 /**
478 * Set the "logged out" timestamp
479 * @param int|null $ts
480 */
481 public function setLoggedOutTimestamp( $ts = null ) {
482 $ts = (int)$ts;
483 if ( $this->loggedOut !== $ts ) {
484 $this->loggedOut = $ts;
485 $this->metaDirty = true;
486 $this->logger->debug(
487 'SessionBackend "{session}" metadata dirty due to logged-out-timestamp change',
488 [
489 'session' => $this->id,
490 ] );
491 $this->autosave();
492 }
493 }
494
495 /**
496 * Fetch provider metadata
497 * @protected For use by SessionProvider subclasses only
498 * @return array|null
499 */
500 public function getProviderMetadata() {
501 return $this->providerMetadata;
502 }
503
504 /**
505 * Set provider metadata
506 * @protected For use by SessionProvider subclasses only
507 * @param array|null $metadata
508 */
509 public function setProviderMetadata( $metadata ) {
510 if ( $metadata !== null && !is_array( $metadata ) ) {
511 throw new \InvalidArgumentException( '$metadata must be an array or null' );
512 }
513 if ( $this->providerMetadata !== $metadata ) {
514 $this->providerMetadata = $metadata;
515 $this->metaDirty = true;
516 $this->logger->debug(
517 'SessionBackend "{session}" metadata dirty due to provider metadata change',
518 [
519 'session' => $this->id,
520 ] );
521 $this->autosave();
522 }
523 }
524
525 /**
526 * Fetch the session data array
527 *
528 * Note the caller is responsible for calling $this->dirty() if anything in
529 * the array is changed.
530 *
531 * @private For use by \MediaWiki\Session\Session only.
532 * @return array
533 */
534 public function &getData() {
535 return $this->data;
536 }
537
538 /**
539 * Add data to the session.
540 *
541 * Overwrites any existing data under the same keys.
542 *
543 * @param array $newData Key-value pairs to add to the session
544 */
545 public function addData( array $newData ) {
546 $data = &$this->getData();
547 foreach ( $newData as $key => $value ) {
548 if ( !array_key_exists( $key, $data ) || $data[$key] !== $value ) {
549 $data[$key] = $value;
550 $this->dataDirty = true;
551 $this->logger->debug(
552 'SessionBackend "{session}" data dirty due to addData(): {callers}',
553 [
554 'session' => $this->id,
555 'callers' => wfGetAllCallers( 5 ),
556 ] );
557 }
558 }
559 }
560
561 /**
562 * Mark data as dirty
563 * @private For use by \MediaWiki\Session\Session only.
564 */
565 public function dirty() {
566 $this->dataDirty = true;
567 $this->logger->debug(
568 'SessionBackend "{session}" data dirty due to dirty(): {callers}',
569 [
570 'session' => $this->id,
571 'callers' => wfGetAllCallers( 5 ),
572 ] );
573 }
574
575 /**
576 * Renew the session by resaving everything
577 *
578 * Resets the TTL in the backend store if the session is near expiring, and
579 * re-persists the session to any active WebRequests if persistent.
580 */
581 public function renew() {
582 if ( time() + $this->lifetime / 2 > $this->expires ) {
583 $this->metaDirty = true;
584 $this->logger->debug(
585 'SessionBackend "{callers}" metadata dirty for renew(): {callers}',
586 [
587 'session' => $this->id,
588 'callers' => wfGetAllCallers( 5 ),
589 ] );
590 if ( $this->persist ) {
591 $this->forcePersist = true;
592 $this->logger->debug(
593 'SessionBackend "{session}" force-persist for renew(): {callers}',
594 [
595 'session' => $this->id,
596 'callers' => wfGetAllCallers( 5 ),
597 ] );
598 }
599 }
600 $this->autosave();
601 }
602
603 /**
604 * Delay automatic saving while multiple updates are being made
605 *
606 * Calls to save() will not be delayed.
607 *
608 * @return \Wikimedia\ScopedCallback When this goes out of scope, a save will be triggered
609 */
610 public function delaySave() {
611 $this->delaySave++;
612 return new \Wikimedia\ScopedCallback( function () {
613 if ( --$this->delaySave <= 0 ) {
614 $this->delaySave = 0;
615 $this->save();
616 }
617 } );
618 }
619
620 /**
621 * Save the session, unless delayed
622 * @see SessionBackend::save()
623 */
624 private function autosave() {
625 if ( $this->delaySave <= 0 ) {
626 $this->save();
627 }
628 }
629
630 /**
631 * Save the session
632 *
633 * Update both the backend data and the associated WebRequest(s) to
634 * reflect the state of the SessionBackend. This might include
635 * persisting or unpersisting the session.
636 *
637 * @param bool $closing Whether the session is being closed
638 */
639 public function save( $closing = false ) {
640 $anon = $this->user->isAnon();
641
642 if ( !$anon && $this->provider->getManager()->isUserSessionPrevented( $this->user->getName() ) ) {
643 $this->logger->debug(
644 'SessionBackend "{session}" not saving, user {user} was ' .
645 'passed to SessionManager::preventSessionsForUser',
646 [
647 'session' => $this->id,
648 'user' => $this->user,
649 ] );
650 return;
651 }
652
653 // Ensure the user has a token
654 // @codeCoverageIgnoreStart
655 if ( !$anon && !$this->user->getToken( false ) ) {
656 $this->logger->debug(
657 'SessionBackend "{session}" creating token for user {user} on save',
658 [
659 'session' => $this->id,
660 'user' => $this->user,
661 ] );
662 $this->user->setToken();
663 if ( !wfReadOnly() ) {
664 // Promise that the token set here will be valid; save it at end of request
665 $user = $this->user;
666 \DeferredUpdates::addCallableUpdate( function () use ( $user ) {
667 $user->saveSettings();
668 } );
669 }
670 $this->metaDirty = true;
671 }
672 // @codeCoverageIgnoreEnd
673
674 if ( !$this->metaDirty && !$this->dataDirty &&
675 $this->dataHash !== md5( serialize( $this->data ) )
676 ) {
677 $this->logger->debug(
678 'SessionBackend "{session}" data dirty due to hash mismatch, {expected} !== {got}',
679 [
680 'session' => $this->id,
681 'expected' => $this->dataHash,
682 'got' => md5( serialize( $this->data ) ),
683 ] );
684 $this->dataDirty = true;
685 }
686
687 if ( !$this->metaDirty && !$this->dataDirty && !$this->forcePersist ) {
688 return;
689 }
690
691 $this->logger->debug(
692 'SessionBackend "{session}" save: dataDirty={dataDirty} ' .
693 'metaDirty={metaDirty} forcePersist={forcePersist}',
694 [
695 'session' => $this->id,
696 'dataDirty' => (int)$this->dataDirty,
697 'metaDirty' => (int)$this->metaDirty,
698 'forcePersist' => (int)$this->forcePersist,
699 ] );
700
701 // Persist or unpersist to the provider, if necessary
702 if ( $this->metaDirty || $this->forcePersist ) {
703 if ( $this->persist ) {
704 foreach ( $this->requests as $request ) {
705 $request->setSessionId( $this->getSessionId() );
706 $this->provider->persistSession( $this, $request );
707 }
708 if ( !$closing ) {
709 $this->checkPHPSession();
710 }
711 } else {
712 foreach ( $this->requests as $request ) {
713 if ( $request->getSessionId() === $this->id ) {
714 $this->provider->unpersistSession( $request );
715 }
716 }
717 }
718 }
719
720 $this->forcePersist = false;
721
722 if ( !$this->metaDirty && !$this->dataDirty ) {
723 return;
724 }
725
726 // Save session data to store, if necessary
727 $metadata = $origMetadata = [
728 'provider' => (string)$this->provider,
729 'providerMetadata' => $this->providerMetadata,
730 'userId' => $anon ? 0 : $this->user->getId(),
731 'userName' => User::isValidUserName( $this->user->getName() ) ? $this->user->getName() : null,
732 'userToken' => $anon ? null : $this->user->getToken(),
733 'remember' => !$anon && $this->remember,
734 'forceHTTPS' => $this->forceHTTPS,
735 'expires' => time() + $this->lifetime,
736 'loggedOut' => $this->loggedOut,
737 'persisted' => $this->persist,
738 ];
739
740 \Hooks::run( 'SessionMetadata', [ $this, &$metadata, $this->requests ] );
741
742 foreach ( $origMetadata as $k => $v ) {
743 if ( $metadata[$k] !== $v ) {
744 throw new \UnexpectedValueException( "SessionMetadata hook changed metadata key \"$k\"" );
745 }
746 }
747
748 $flags = $this->persist ? 0 : CachedBagOStuff::WRITE_CACHE_ONLY;
749 $flags |= CachedBagOStuff::WRITE_SYNC; // write to all datacenters
750 $this->store->set(
751 $this->store->makeKey( 'MWSession', (string)$this->id ),
752 [
753 'data' => $this->data,
754 'metadata' => $metadata,
755 ],
756 $metadata['expires'],
757 $flags
758 );
759
760 $this->metaDirty = false;
761 $this->dataDirty = false;
762 $this->dataHash = md5( serialize( $this->data ) );
763 $this->expires = $metadata['expires'];
764 }
765
766 /**
767 * For backwards compatibility, open the PHP session when the global
768 * session is persisted
769 */
770 private function checkPHPSession() {
771 if ( !$this->checkPHPSessionRecursionGuard ) {
772 $this->checkPHPSessionRecursionGuard = true;
773 $reset = new \Wikimedia\ScopedCallback( function () {
774 $this->checkPHPSessionRecursionGuard = false;
775 } );
776
777 if ( $this->usePhpSessionHandling && session_id() === '' && PHPSessionHandler::isEnabled() &&
778 SessionManager::getGlobalSession()->getId() === (string)$this->id
779 ) {
780 $this->logger->debug(
781 'SessionBackend "{session}" Taking over PHP session',
782 [
783 'session' => $this->id,
784 ] );
785 session_id( (string)$this->id );
786 \Wikimedia\quietCall( 'session_start' );
787 }
788 }
789 }
790
791 }