Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
99.11% |
335 / 338 |
|
94.29% |
33 / 35 |
CRAP | |
0.00% |
0 / 1 |
SessionBackend | |
99.11% |
335 / 338 |
|
94.29% |
33 / 35 |
122 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
50 / 50 |
|
100.00% |
1 / 1 |
13 | |||
getSession | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
deregisterSession | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
shutdown | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getId | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getSessionId | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
resetId | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
6 | |||
getProvider | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isPersistent | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
persist | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
unpersist | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
5 | |||
shouldRememberUser | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setRememberUser | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
getRequest | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getUser | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getAllowedUserRights | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getRestrictions | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
canSetUser | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setUser | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
2 | |||
suggestLoginUsername | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
shouldForceHTTPS | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setForceHTTPS | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
getLoggedOutTimestamp | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setLoggedOutTimestamp | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
2 | |||
getProviderMetadata | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setProviderMetadata | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
4 | |||
getData | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
addData | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
4 | |||
dirty | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
renew | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
3 | |||
delaySave | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
autosave | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
save | |
100.00% |
78 / 78 |
|
100.00% |
1 / 1 |
31 | |||
checkPHPSession | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
6 | |||
logPersistenceChange | |
100.00% |
26 / 26 |
|
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 | |
24 | namespace MediaWiki\Session; |
25 | |
26 | use InvalidArgumentException; |
27 | use MediaWiki\Deferred\DeferredUpdates; |
28 | use MediaWiki\HookContainer\HookContainer; |
29 | use MediaWiki\HookContainer\HookRunner; |
30 | use MediaWiki\MainConfigNames; |
31 | use MediaWiki\MediaWikiServices; |
32 | use MediaWiki\Request\WebRequest; |
33 | use MediaWiki\User\User; |
34 | use MWRestrictions; |
35 | use Psr\Log\LoggerInterface; |
36 | use Wikimedia\AtEase\AtEase; |
37 | use 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 | */ |
57 | final 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 | } |