Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.69% covered (warning)
88.69%
149 / 168
90.00% covered (success)
90.00%
45 / 50
CRAP
0.00% covered (danger)
0.00%
0 / 1
Session
88.69% covered (warning)
88.69%
149 / 168
90.00% covered (success)
90.00%
45 / 50
92.97
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 __destruct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 unpersist
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRequest
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUser
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 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%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 suggestLoginUsername
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getProviderMetadata
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 clear
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 renew
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sessionWithRequest
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 exists
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 set
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 remove
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 hasToken
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getToken
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 resetToken
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 resetAllTokens
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSecretKeys
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 getEncryptionAlgorithm
15.38% covered (danger)
15.38%
2 / 13
0.00% covered (danger)
0.00%
0 / 1
20.15
 setSecret
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
4.04
 getSecret
86.67% covered (warning)
86.67%
26 / 30
0.00% covered (danger)
0.00%
0 / 1
9.19
 delaySave
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 save
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 count
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 current
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 key
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 next
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 rewind
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 valid
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 offsetExists
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 offsetGet
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 offsetSet
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 offsetUnset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * MediaWiki session
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 BadMethodCallException;
27use LogicException;
28use MediaWiki\MainConfigNames;
29use MediaWiki\MediaWikiServices;
30use MediaWiki\Request\WebRequest;
31use MediaWiki\User\User;
32use MWRestrictions;
33use Psr\Log\LoggerInterface;
34use RuntimeException;
35
36/**
37 * Manages data for an authenticated session
38 *
39 * A Session represents the fact that the current HTTP request is part of a
40 * session. There are two broad types of Sessions, based on whether they
41 * return true or false from self::canSetUser():
42 * * When true (mutable), the Session identifies multiple requests as part of
43 *   a session generically, with no tie to a particular user.
44 * * When false (immutable), the Session identifies multiple requests as part
45 *   of a session by identifying and authenticating the request itself as
46 *   belonging to a particular user.
47 *
48 * The Session object also serves as a replacement for PHP's $_SESSION,
49 * managing access to per-session data.
50 *
51 * @ingroup Session
52 * @since 1.27
53 */
54class Session implements \Countable, \Iterator, \ArrayAccess {
55    /** @var null|string[] Encryption algorithm to use */
56    private static $encryptionAlgorithm = null;
57
58    /** @var SessionBackend Session backend (can't be type-hinted, see DummySessionBackend in tests) */
59    private $backend;
60
61    /** @var int Session index */
62    private $index;
63
64    private LoggerInterface $logger;
65
66    /**
67     * @param SessionBackend $backend
68     * @param int $index
69     * @param LoggerInterface $logger
70     */
71    public function __construct( SessionBackend $backend, $index, LoggerInterface $logger ) {
72        $this->backend = $backend;
73        $this->index = $index;
74        $this->logger = $logger;
75    }
76
77    public function __destruct() {
78        $this->backend->deregisterSession( $this->index );
79    }
80
81    /**
82     * Returns the session ID
83     * @return string
84     */
85    public function getId() {
86        return $this->backend->getId();
87    }
88
89    /**
90     * Returns the SessionId object
91     * @internal For internal use by WebRequest
92     * @return SessionId
93     */
94    public function getSessionId() {
95        return $this->backend->getSessionId();
96    }
97
98    /**
99     * Changes the session ID
100     * @return string New ID (might be the same as the old)
101     */
102    public function resetId() {
103        return $this->backend->resetId();
104    }
105
106    /**
107     * Fetch the SessionProvider for this session
108     * @return SessionProviderInterface
109     */
110    public function getProvider() {
111        return $this->backend->getProvider();
112    }
113
114    /**
115     * Indicate whether this session is persisted across requests
116     *
117     * For example, if cookies are set.
118     *
119     * @return bool
120     */
121    public function isPersistent() {
122        return $this->backend->isPersistent();
123    }
124
125    /**
126     * Make this session persisted across requests
127     *
128     * If the session is already persistent, equivalent to calling
129     * $this->renew().
130     */
131    public function persist() {
132        $this->backend->persist();
133    }
134
135    /**
136     * Make this session not be persisted across requests
137     *
138     * This will remove persistence information (e.g. delete cookies)
139     * from the associated WebRequest(s), and delete session data in the
140     * backend. The session data will still be available via get() until
141     * the end of the request.
142     */
143    public function unpersist() {
144        $this->backend->unpersist();
145    }
146
147    /**
148     * Indicate whether the user should be remembered independently of the
149     * session ID.
150     * @return bool
151     */
152    public function shouldRememberUser() {
153        return $this->backend->shouldRememberUser();
154    }
155
156    /**
157     * Set whether the user should be remembered independently of the session
158     * ID.
159     * @param bool $remember
160     */
161    public function setRememberUser( $remember ) {
162        $this->backend->setRememberUser( $remember );
163    }
164
165    /**
166     * Returns the request associated with this session
167     * @return WebRequest
168     */
169    public function getRequest() {
170        return $this->backend->getRequest( $this->index );
171    }
172
173    /**
174     * Returns the authenticated user for this session
175     * @return User
176     */
177    public function getUser(): User {
178        return $this->backend->getUser();
179    }
180
181    /**
182     * Fetch the rights allowed the user when this session is active.
183     * @return null|string[] Allowed user rights, or null to allow all.
184     */
185    public function getAllowedUserRights() {
186        return $this->backend->getAllowedUserRights();
187    }
188
189    /**
190     * Fetch any restrictions imposed on logins or actions when this
191     * session is active.
192     * @return MWRestrictions|null
193     */
194    public function getRestrictions(): ?MWRestrictions {
195        return $this->backend->getRestrictions();
196    }
197
198    /**
199     * Indicate whether the session user info can be changed
200     * @return bool
201     */
202    public function canSetUser() {
203        return $this->backend->canSetUser();
204    }
205
206    /**
207     * Set a new user for this session
208     * @note This should only be called when the user has been authenticated
209     * @param User $user User to set on the session.
210     *   This may become a "UserValue" in the future, or User may be refactored
211     *   into such.
212     */
213    public function setUser( $user ) {
214        $this->backend->setUser( $user );
215    }
216
217    /**
218     * Get a suggested username for the login form
219     * @return string|null
220     */
221    public function suggestLoginUsername() {
222        return $this->backend->suggestLoginUsername( $this->index );
223    }
224
225    /**
226     * Get the expected value of the forceHTTPS cookie. This reflects whether
227     * session cookies were sent with the Secure attribute. If $wgForceHTTPS
228     * is true, the forceHTTPS cookie is not sent and this value is ignored.
229     *
230     * @return bool
231     */
232    public function shouldForceHTTPS() {
233        return $this->backend->shouldForceHTTPS();
234    }
235
236    /**
237     * Set the value of the forceHTTPS cookie. This reflects whether session
238     * cookies were sent with the Secure attribute. If $wgForceHTTPS is true,
239     * the forceHTTPS cookie is not sent, and this value is ignored.
240     *
241     * @param bool $force
242     */
243    public function setForceHTTPS( $force ) {
244        $this->backend->setForceHTTPS( $force );
245    }
246
247    /**
248     * Fetch the "logged out" timestamp
249     * @return int
250     */
251    public function getLoggedOutTimestamp() {
252        return $this->backend->getLoggedOutTimestamp();
253    }
254
255    /**
256     * @param int $ts
257     */
258    public function setLoggedOutTimestamp( $ts ) {
259        $this->backend->setLoggedOutTimestamp( $ts );
260    }
261
262    /**
263     * Fetch provider metadata
264     * @note For use by SessionProvider subclasses only
265     * @return mixed
266     */
267    public function getProviderMetadata() {
268        return $this->backend->getProviderMetadata();
269    }
270
271    /**
272     * Delete all session data and clear the user (if possible)
273     */
274    public function clear() {
275        $data = &$this->backend->getData();
276        if ( $data ) {
277            $data = [];
278            $this->backend->dirty();
279        }
280        if ( $this->backend->canSetUser() ) {
281            $this->backend->setUser( MediaWikiServices::getInstance()->getUserFactory()->newAnonymous() );
282        }
283        $this->backend->save();
284    }
285
286    /**
287     * Resets the TTL in the backend store if the session is near expiring, and
288     * re-persists the session to any active WebRequests if persistent.
289     */
290    public function renew() {
291        $this->backend->renew();
292    }
293
294    /**
295     * Fetch a copy of this session attached to an alternative WebRequest
296     *
297     * Actions on the copy will affect this session too, and vice versa.
298     *
299     * @param WebRequest $request Any existing session associated with this
300     *  WebRequest object will be overwritten.
301     * @return Session
302     */
303    public function sessionWithRequest( WebRequest $request ) {
304        $request->setSessionId( $this->backend->getSessionId() );
305        return $this->backend->getSession( $request );
306    }
307
308    /**
309     * Fetch a value from the session
310     * @param string|int $key
311     * @param mixed|null $default Returned if $this->exists( $key ) would be false
312     * @return mixed
313     */
314    public function get( $key, $default = null ) {
315        $data = &$this->backend->getData();
316        return array_key_exists( $key, $data ) ? $data[$key] : $default;
317    }
318
319    /**
320     * Test if a value exists in the session
321     * @note Unlike isset(), null values are considered to exist.
322     * @param string|int $key
323     * @return bool
324     */
325    public function exists( $key ) {
326        $data = &$this->backend->getData();
327        return array_key_exists( $key, $data );
328    }
329
330    /**
331     * Set a value in the session
332     * @param string|int $key
333     * @param mixed $value
334     */
335    public function set( $key, $value ) {
336        $data = &$this->backend->getData();
337        if ( !array_key_exists( $key, $data ) || $data[$key] !== $value ) {
338            $data[$key] = $value;
339            $this->backend->dirty();
340        }
341    }
342
343    /**
344     * Remove a value from the session
345     * @param string|int $key
346     */
347    public function remove( $key ) {
348        $data = &$this->backend->getData();
349        if ( array_key_exists( $key, $data ) ) {
350            unset( $data[$key] );
351            $this->backend->dirty();
352        }
353    }
354
355    /**
356     * Check if a CSRF token is set for the session
357     *
358     * @since 1.37
359     * @param string $key Token key
360     * @return bool
361     */
362    public function hasToken( string $key = 'default' ): bool {
363        $secrets = $this->get( 'wsTokenSecrets' );
364        if ( !is_array( $secrets ) ) {
365            return false;
366        }
367        return isset( $secrets[$key] ) && is_string( $secrets[$key] );
368    }
369
370    /**
371     * Fetch a CSRF token from the session
372     *
373     * Note that this does not persist the session, which you'll probably want
374     * to do if you want the token to actually be useful.
375     *
376     * @param string|string[] $salt Token salt
377     * @param string $key Token key
378     * @return Token
379     */
380    public function getToken( $salt = '', $key = 'default' ) {
381        $new = false;
382        $secrets = $this->get( 'wsTokenSecrets' );
383        if ( !is_array( $secrets ) ) {
384            $secrets = [];
385        }
386        if ( isset( $secrets[$key] ) && is_string( $secrets[$key] ) ) {
387            $secret = $secrets[$key];
388        } else {
389            $secret = \MWCryptRand::generateHex( 32 );
390            $secrets[$key] = $secret;
391            $this->set( 'wsTokenSecrets', $secrets );
392            $new = true;
393        }
394        if ( is_array( $salt ) ) {
395            $salt = implode( '|', $salt );
396        }
397        return new Token( $secret, (string)$salt, $new );
398    }
399
400    /**
401     * Remove a CSRF token from the session
402     *
403     * The next call to self::getToken() with $key will generate a new secret.
404     *
405     * @param string $key Token key
406     */
407    public function resetToken( $key = 'default' ) {
408        $secrets = $this->get( 'wsTokenSecrets' );
409        if ( is_array( $secrets ) && isset( $secrets[$key] ) ) {
410            unset( $secrets[$key] );
411            $this->set( 'wsTokenSecrets', $secrets );
412        }
413    }
414
415    /**
416     * Remove all CSRF tokens from the session
417     */
418    public function resetAllTokens() {
419        $this->remove( 'wsTokenSecrets' );
420    }
421
422    /**
423     * Fetch the secret keys for self::setSecret() and self::getSecret().
424     * @return string[] Encryption key, HMAC key
425     */
426    private function getSecretKeys() {
427        $mainConfig = MediaWikiServices::getInstance()->getMainConfig();
428        $sessionSecret = $mainConfig->get( MainConfigNames::SessionSecret );
429        $secretKey = $mainConfig->get( MainConfigNames::SecretKey );
430        $sessionPbkdf2Iterations = $mainConfig->get( MainConfigNames::SessionPbkdf2Iterations );
431        $wikiSecret = $sessionSecret ?: $secretKey;
432        $userSecret = $this->get( 'wsSessionSecret', null );
433        if ( $userSecret === null ) {
434            $userSecret = \MWCryptRand::generateHex( 32 );
435            $this->set( 'wsSessionSecret', $userSecret );
436        }
437        $iterations = $this->get( 'wsSessionPbkdf2Iterations', null );
438        if ( $iterations === null ) {
439            $iterations = $sessionPbkdf2Iterations;
440            $this->set( 'wsSessionPbkdf2Iterations', $iterations );
441        }
442
443        $keymats = openssl_pbkdf2( $wikiSecret, $userSecret, 64, $iterations, 'sha256' );
444        return [
445            substr( $keymats, 0, 32 ),
446            substr( $keymats, 32, 32 ),
447        ];
448    }
449
450    /**
451     * Decide what type of encryption to use, based on system capabilities.
452     * @return array
453     */
454    private static function getEncryptionAlgorithm() {
455        if ( self::$encryptionAlgorithm === null ) {
456            if ( function_exists( 'openssl_encrypt' ) ) {
457                $methods = openssl_get_cipher_methods();
458                if ( in_array( 'aes-256-ctr', $methods, true ) ) {
459                    self::$encryptionAlgorithm = [ 'openssl', 'aes-256-ctr' ];
460                    return self::$encryptionAlgorithm;
461                }
462                if ( in_array( 'aes-256-cbc', $methods, true ) ) {
463                    self::$encryptionAlgorithm = [ 'openssl', 'aes-256-cbc' ];
464                    return self::$encryptionAlgorithm;
465                }
466            }
467
468            throw new BadMethodCallException(
469                'Encryption is not available. You need to install the PHP OpenSSL extension.'
470            );
471        }
472
473        return self::$encryptionAlgorithm;
474    }
475
476    /**
477     * Set a value in the session, encrypted
478     *
479     * This relies on the secrecy of $wgSecretKey (by default), or $wgSessionSecret.
480     *
481     * @param string|int $key
482     * @param mixed $value
483     */
484    public function setSecret( $key, $value ) {
485        [ $encKey, $hmacKey ] = $this->getSecretKeys();
486        $serialized = serialize( $value );
487
488        // The code for encryption (with OpenSSL) and sealing is taken from
489        // Chris Steipp's OATHAuthUtils class in Extension::OATHAuth.
490
491        // Encrypt
492        $iv = random_bytes( 16 );
493        $algorithm = self::getEncryptionAlgorithm();
494        switch ( $algorithm[0] ) {
495            case 'openssl':
496                $ciphertext = openssl_encrypt( $serialized, $algorithm[1], $encKey, OPENSSL_RAW_DATA, $iv );
497                if ( $ciphertext === false ) {
498                    throw new \UnexpectedValueException( 'Encryption failed: ' . openssl_error_string() );
499                }
500                break;
501            default:
502                throw new LogicException( 'invalid algorithm' );
503        }
504
505        // Seal
506        $sealed = base64_encode( $iv ) . '.' . base64_encode( $ciphertext );
507        $hmac = hash_hmac( 'sha256', $sealed, $hmacKey, true );
508        $encrypted = base64_encode( $hmac ) . '.' . $sealed;
509
510        // Store
511        $this->set( $key, $encrypted );
512    }
513
514    /**
515     * Fetch a value from the session that was set with self::setSecret()
516     * @param string|int $key
517     * @param mixed|null $default Returned if $this->exists( $key ) would be false or decryption fails
518     * @return mixed
519     */
520    public function getSecret( $key, $default = null ) {
521        // Fetch
522        $encrypted = $this->get( $key, null );
523        if ( $encrypted === null ) {
524            return $default;
525        }
526
527        // The code for unsealing, checking, and decrypting (with OpenSSL) is
528        // taken from Chris Steipp's OATHAuthUtils class in
529        // Extension::OATHAuth.
530
531        // Unseal and check
532        $pieces = explode( '.', $encrypted, 4 );
533        if ( count( $pieces ) !== 3 ) {
534            $ex = new RuntimeException( 'Invalid sealed-secret format' );
535            $this->logger->warning( $ex->getMessage(), [ 'exception' => $ex ] );
536            return $default;
537        }
538        [ $hmac, $iv, $ciphertext ] = $pieces;
539        [ $encKey, $hmacKey ] = $this->getSecretKeys();
540        $integCalc = hash_hmac( 'sha256', $iv . '.' . $ciphertext, $hmacKey, true );
541        if ( !hash_equals( $integCalc, base64_decode( $hmac ) ) ) {
542            $ex = new RuntimeException( 'Sealed secret has been tampered with, aborting.' );
543            $this->logger->warning( $ex->getMessage(), [ 'exception' => $ex ] );
544            return $default;
545        }
546
547        // Decrypt
548        $algorithm = self::getEncryptionAlgorithm();
549        switch ( $algorithm[0] ) {
550            case 'openssl':
551                $serialized = openssl_decrypt( base64_decode( $ciphertext ), $algorithm[1], $encKey,
552                    OPENSSL_RAW_DATA, base64_decode( $iv ) );
553                if ( $serialized === false ) {
554                    $ex = new RuntimeException( 'Decyption failed: ' . openssl_error_string() );
555                    $this->logger->debug( $ex->getMessage(), [ 'exception' => $ex ] );
556                    return $default;
557                }
558                break;
559            default:
560                throw new \LogicException( 'invalid algorithm' );
561        }
562
563        $value = unserialize( $serialized );
564        if ( $value === false && $serialized !== serialize( false ) ) {
565            $value = $default;
566        }
567        return $value;
568    }
569
570    /**
571     * Delay automatic saving while multiple updates are being made
572     *
573     * Calls to save() or clear() will not be delayed.
574     *
575     * @return \Wikimedia\ScopedCallback When this goes out of scope, a save will be triggered
576     */
577    public function delaySave() {
578        return $this->backend->delaySave();
579    }
580
581    /**
582     * This will update the backend data and might re-persist the session
583     * if needed.
584     */
585    public function save() {
586        $this->backend->save();
587    }
588
589    // region   Interface methods
590    /** @name   Interface methods
591     * @{
592     */
593
594    /** @inheritDoc */
595    public function count(): int {
596        $data = &$this->backend->getData();
597        return count( $data );
598    }
599
600    /** @inheritDoc */
601    #[\ReturnTypeWillChange]
602    public function current() {
603        $data = &$this->backend->getData();
604        return current( $data );
605    }
606
607    /** @inheritDoc */
608    #[\ReturnTypeWillChange]
609    public function key() {
610        $data = &$this->backend->getData();
611        return key( $data );
612    }
613
614    /** @inheritDoc */
615    public function next(): void {
616        $data = &$this->backend->getData();
617        next( $data );
618    }
619
620    /** @inheritDoc */
621    public function rewind(): void {
622        $data = &$this->backend->getData();
623        reset( $data );
624    }
625
626    /** @inheritDoc */
627    public function valid(): bool {
628        $data = &$this->backend->getData();
629        return key( $data ) !== null;
630    }
631
632    /**
633     * @note Despite the name, this seems to be intended to implement isset()
634     *  rather than array_key_exists(). So do that.
635     * @inheritDoc
636     */
637    public function offsetExists( $offset ): bool {
638        $data = &$this->backend->getData();
639        return isset( $data[$offset] );
640    }
641
642    /**
643     * @note This supports indirect modifications but can't mark the session
644     *  dirty when those happen. SessionBackend::save() checks the hash of the
645     *  data to detect such changes.
646     * @note Accessing a nonexistent key via this mechanism causes that key to
647     *  be created with a null value, and does not raise a PHP warning.
648     * @inheritDoc
649     */
650    #[\ReturnTypeWillChange]
651    public function &offsetGet( $offset ) {
652        $data = &$this->backend->getData();
653        if ( !array_key_exists( $offset, $data ) ) {
654            $ex = new LogicException( "Undefined index (auto-adds to session with a null value): $offset" );
655            $this->logger->debug( $ex->getMessage(), [ 'exception' => $ex ] );
656        }
657        return $data[$offset];
658    }
659
660    /** @inheritDoc */
661    public function offsetSet( $offset, $value ): void {
662        $this->set( $offset, $value );
663    }
664
665    /** @inheritDoc */
666    public function offsetUnset( $offset ): void {
667        $this->remove( $offset );
668    }
669
670    /** @} */
671    // endregion  -- end of Interface methods
672}