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