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