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