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