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