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