2d3078981ad67aea0a35aa80c8f3dcf0c2e99eae
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / session / SessionHandler.class.php
1 <?php
2
3 namespace wcf\system\session;
4
5 use ParagonIE\ConstantTime\Hex;
6 use wcf\data\session\Session as LegacySession;
7 use wcf\data\session\SessionEditor;
8 use wcf\data\user\User;
9 use wcf\data\user\UserEditor;
10 use wcf\system\cache\builder\UserGroupOptionCacheBuilder;
11 use wcf\system\cache\builder\UserGroupPermissionCacheBuilder;
12 use wcf\system\database\exception\DatabaseQueryExecutionException;
13 use wcf\system\database\util\PreparedStatementConditionBuilder;
14 use wcf\system\event\EventHandler;
15 use wcf\system\exception\PermissionDeniedException;
16 use wcf\system\page\PageLocationManager;
17 use wcf\system\request\RouteHandler;
18 use wcf\system\session\event\PreserveVariablesCollecting;
19 use wcf\system\SingletonFactory;
20 use wcf\system\spider\SpiderHandler;
21 use wcf\system\user\storage\UserStorageHandler;
22 use wcf\system\WCF;
23 use wcf\system\WCFACP;
24 use wcf\util\CryptoUtil;
25 use wcf\util\HeaderUtil;
26 use wcf\util\UserUtil;
27
28 /**
29 * Handles sessions.
30 *
31 * @author Tim Duesterhus, Alexander Ebert
32 * @copyright 2001-2020 WoltLab GmbH
33 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
34 *
35 * @property-read string $sessionID unique textual identifier of the session
36 * @property-read int|null $userID id of the user the session belongs to or `null` if the session belongs to a guest
37 * @property-read int|null $pageID id of the latest page visited
38 * @property-read int|null $pageObjectID id of the object the latest page visited belongs to
39 * @property-read int|null $parentPageID id of the parent page of latest page visited
40 * @property-read int|null $parentPageObjectID id of the object the parent page of latest page visited belongs to
41 * @property-read int $spiderIdentifier identifier of the spider
42 */
43 final class SessionHandler extends SingletonFactory
44 {
45 /**
46 * prevents update on shutdown
47 */
48 private bool $doNotUpdate = false;
49
50 /**
51 * disables page tracking
52 */
53 private bool $disableTracking = false;
54
55 /**
56 * group data and permissions
57 * @var mixed[][]
58 */
59 private $groupData;
60
61 /**
62 * true if within ACP or WCFSetup
63 */
64 private bool $isACP = false;
65
66 /**
67 * language id for active user
68 * @var int
69 */
70 private $languageID = 0;
71
72 /**
73 * @var string
74 */
75 private string $sessionID;
76
77 private ?LegacySession $legacySession = null;
78
79 /**
80 * user object
81 * @var User
82 */
83 private User $user;
84
85 /**
86 * session variables
87 * @var array
88 */
89 private $variables = [];
90
91 /**
92 * indicates if session variables changed and must be saved upon shutdown
93 */
94 private bool $variablesChanged = false;
95
96 private bool $firstVisit = false;
97
98 /**
99 * list of names of permissions only available for users
100 * @var string[]
101 */
102 private $usersOnlyPermissions = [];
103
104 private string $xsrfToken;
105
106 private const GUEST_SESSION_LIFETIME = 2 * 3600;
107
108 private const USER_SESSION_LIFETIME = 60 * 86400;
109
110 private const USER_SESSION_LIMIT = 30;
111
112 private const CHANGE_USER_AFTER_MULTIFACTOR_KEY = self::class . "\0__changeUserAfterMultifactor__";
113
114 private const PENDING_USER_LIFETIME = 15 * 60;
115
116 private const REAUTHENTICATION_KEY = self::class . "\0__reauthentication__";
117
118 private const REAUTHENTICATION_HARD_LIMIT = 12 * 3600;
119
120 private const REAUTHENTICATION_SOFT_LIMIT = 2 * 3600;
121
122 private const REAUTHENTICATION_SOFT_LIMIT_ACP = 2 * 3600;
123
124 private const REAUTHENTICATION_GRACE_PERIOD = 15 * 60;
125
126 /**
127 * Provides access to session data.
128 *
129 * @return mixed
130 */
131 public function __get(string $key)
132 {
133 switch ($key) {
134 case 'sessionID':
135 return $this->sessionID;
136 case 'userID':
137 return $this->user->userID;
138 case 'spiderIdentifier':
139 if ($this->userID) {
140 return null;
141 }
142
143 if ($this->isACP) {
144 return null;
145 }
146
147 return $this->legacySession->spiderIdentifier;
148 case 'pageID':
149 case 'pageObjectID':
150 case 'parentPageID':
151 case 'parentPageObjectID':
152 return $this->legacySession->{$key} ?? null;
153
154 /** @deprecated 5.4 - The below values are deprecated. */
155 case 'ipAddress':
156 return UserUtil::getIpAddress();
157 case 'userAgent':
158 return UserUtil::getUserAgent();
159 case 'requestURI':
160 return UserUtil::getRequestURI();
161 case 'requestMethod':
162 return !empty($_SERVER['REQUEST_METHOD']) ? \substr($_SERVER['REQUEST_METHOD'], 0, 7) : '';
163 case 'lastActivityTime':
164 return TIME_NOW;
165
166 default:
167 return null;
168 }
169 }
170
171 /**
172 * @inheritDoc
173 */
174 protected function init()
175 {
176 $this->isACP = (\class_exists(WCFACP::class, false) || !PACKAGE_ID);
177 $this->usersOnlyPermissions = UserGroupOptionCacheBuilder::getInstance()->getData([], 'usersOnlyOptions');
178 }
179
180 /**
181 * Parses the session cookie value, returning an array with the stored fields.
182 *
183 * The return array is guaranteed to have a `sessionId` key.
184 */
185 private function parseCookie(string $value): array
186 {
187 $length = \mb_strlen($value, '8bit');
188 if ($length < 1) {
189 throw new \InvalidArgumentException(\sprintf(
190 'Expected at least 1 Byte, %d given.',
191 $length
192 ));
193 }
194
195 $version = \unpack('Cversion', $value)['version'];
196 if (!\in_array($version, [1], true)) {
197 throw new \InvalidArgumentException(\sprintf(
198 'Unknown version %d',
199 $version
200 ));
201 }
202
203 if ($version === 1) {
204 if ($length !== 22) {
205 throw new \InvalidArgumentException(\sprintf(
206 'Expected exactly 22 Bytes, %d given.',
207 $length
208 ));
209 }
210 $data = \unpack('Cversion/a20sessionId/Ctimestep', $value);
211 \assert($data['version'] === 1);
212 \assert(\strlen($data['sessionId']) === 20);
213 $data['sessionId'] = Hex::encode($data['sessionId']);
214
215 return $data;
216 }
217
218 throw new \LogicException('Unreachable');
219 }
220
221 /**
222 * Extracts the data from the session cookie.
223 *
224 * @see SessionHandler::parseCookie()
225 * @since 5.4
226 */
227 private function getParsedCookieData(): ?array
228 {
229 $cookieName = COOKIE_PREFIX . "user_session";
230
231 if (!empty($_COOKIE[$cookieName])) {
232 if (!PACKAGE_ID) {
233 return [
234 'sessionId' => $_COOKIE[$cookieName],
235 ];
236 }
237
238 $cookieData = CryptoUtil::getValueFromSignedString($_COOKIE[$cookieName]);
239
240 // Check whether the sessionId was correctly signed.
241 if ($cookieData === null) {
242 return null;
243 }
244
245 try {
246 return $this->parseCookie($cookieData);
247 } catch (\InvalidArgumentException $e) {
248 return null;
249 }
250 }
251
252 return null;
253 }
254
255 /**
256 * Returns the session ID stored in the session cookie or `null`.
257 */
258 private function getSessionIdFromCookie(?array $cookieData): ?string
259 {
260 if ($cookieData !== null) {
261 return $cookieData['sessionId'];
262 }
263
264 return null;
265 }
266
267 /**
268 * Returns the current time step. The time step changes
269 * every 24 hours.
270 */
271 private function getCookieTimestep(): int
272 {
273 $window = (24 * 3600);
274
275 \assert((self::USER_SESSION_LIFETIME / $window) < 0xFF);
276
277 return \intdiv(TIME_NOW, $window) & 0xFF;
278 }
279
280 /**
281 * Returns the signed session data for use in a cookie.
282 */
283 private function getCookieValue(): string
284 {
285 if (!PACKAGE_ID) {
286 return $this->sessionID;
287 }
288
289 return CryptoUtil::createSignedString(\pack(
290 'CA20C',
291 1,
292 Hex::decode($this->sessionID),
293 $this->getCookieTimestep()
294 ));
295 }
296
297 /**
298 * Returns true if client provided a valid session cookie.
299 *
300 * @since 3.0
301 */
302 public function hasValidCookie(): bool
303 {
304 return $this->getSessionIdFromCookie($this->getParsedCookieData()) === $this->sessionID;
305 }
306
307 /**
308 * @deprecated 5.4 - Sessions are managed automatically. Use loadFromCookie().
309 */
310 public function load($sessionEditorClassName, $sessionID)
311 {
312 $hasSession = false;
313 if (!empty($sessionID)) {
314 $hasSession = $this->getExistingSession($sessionID);
315 }
316
317 if (!$hasSession) {
318 $this->create();
319 }
320 }
321
322 /**
323 * Loads the session matching the session cookie.
324 */
325 public function loadFromCookie()
326 {
327 $cookieData = $this->getParsedCookieData();
328 $sessionID = $this->getSessionIdFromCookie($cookieData);
329
330 $hasSession = false;
331 if ($sessionID) {
332 $hasSession = $this->getExistingSession($sessionID);
333 }
334
335 if ($hasSession) {
336 $this->maybeRefreshCookie($cookieData);
337 } else {
338 $this->create();
339 }
340 }
341
342 /**
343 * Refreshes the session cookie, extending the expiry.
344 */
345 private function maybeRefreshCookie(array $cookieData): void
346 {
347 // Guests use short-lived sessions with an actual session cookie.
348 if (!$this->user->userID) {
349 return;
350 }
351
352 // No refresh is needed if the timestep matches up.
353 if (isset($cookieData['timestep']) && $cookieData['timestep'] === $this->getCookieTimestep()) {
354 return;
355 }
356
357 // Refresh the cookie.
358 HeaderUtil::setCookie(
359 'user_session',
360 $this->getCookieValue(),
361 TIME_NOW + (self::USER_SESSION_LIFETIME + (7 * 86400))
362 );
363 }
364
365 /**
366 * Initializes session system.
367 */
368 public function initSession()
369 {
370 // assign language
371 $this->languageID = $this->getVar('languageID') ?: $this->user->languageID;
372
373 // https://github.com/WoltLab/WCF/issues/2568
374 if ($this->getVar('__wcfIsFirstVisit') === true) {
375 $this->firstVisit = true;
376 $this->unregister('__wcfIsFirstVisit');
377 }
378 }
379
380 /**
381 * Disables update on shutdown.
382 */
383 public function disableUpdate()
384 {
385 $this->doNotUpdate = true;
386 }
387
388 /**
389 * Disables page tracking.
390 */
391 public function disableTracking(): void
392 {
393 $this->disableTracking = true;
394 }
395
396 /**
397 * Initializes security token.
398 */
399 private function initSecurityToken(): void
400 {
401 $xsrfToken = '';
402 if (!empty($_COOKIE['XSRF-TOKEN'])) {
403 // We intentionally do not extract the signed value and instead just verify the correctness.
404 //
405 // The reason is that common JavaScript frameworks can use the contents of the `XSRF-TOKEN` cookie as-is,
406 // without performing any processing on it, improving interoperability. Leveraging this JavaScript framework
407 // feature requires the author of the controller to check the value within the `X-XSRF-TOKEN` request header
408 // instead of the WoltLab Suite specific `t` parameter, though.
409 //
410 // The only reason we sign the cookie is that an XSS vulnerability or a rogue application on a subdomain
411 // is not able to create a valid `XSRF-TOKEN`, e.g. by setting the `XSRF-TOKEN` cookie to the static
412 // value `1234`, possibly allowing later exploitation.
413 if (
414 !PACKAGE_ID
415 || CryptoUtil::getValueFromSignedString($_COOKIE['XSRF-TOKEN']) !== null
416 ) {
417 $xsrfToken = $_COOKIE['XSRF-TOKEN'];
418 }
419 }
420
421 if (!$xsrfToken) {
422 if (PACKAGE_ID) {
423 $xsrfToken = CryptoUtil::createSignedString(\random_bytes(16));
424 } else {
425 $xsrfToken = Hex::encode(\random_bytes(16));
426 }
427
428 // We construct the cookie manually instead of using HeaderUtil::setCookie(), because:
429 // 1) We don't want the prefix. The `XSRF-TOKEN` cookie name is a standard name across applications
430 // and it is supported by default in common JavaScript frameworks.
431 // 2) We want to set the SameSite=lax parameter.
432 // 3) We don't want the HttpOnly parameter.
433
434 $sameSite = '; SameSite=lax';
435
436 // Workaround for WebKit Bug #255524.
437 // https://bugs.webkit.org/show_bug.cgi?id=255524
438 $sameSite = '';
439
440 \header(
441 'set-cookie: XSRF-TOKEN=' . \rawurlencode($xsrfToken) . '; path=/' . (RouteHandler::secureConnection() ? '; secure' : '') . $sameSite,
442 false
443 );
444 }
445
446 $this->xsrfToken = $xsrfToken;
447 }
448
449 /**
450 * Returns security token.
451 */
452 public function getSecurityToken(): string
453 {
454 if (!isset($this->xsrfToken)) {
455 $this->initSecurityToken();
456 }
457
458 return $this->xsrfToken;
459 }
460
461 /**
462 * Validates the given security token, returns false if
463 * given token is invalid.
464 */
465 public function checkSecurityToken(string $token): bool
466 {
467 // The output of CryptoUtil::createSignedString() is not url-safe. For compatibility
468 // reasons the SECURITY_TOKEN in URLs might not be encoded, turning the '+' into a space.
469 // Convert it back before comparing.
470 $token = \str_replace(' ', '+', $token);
471
472 return \hash_equals($this->getSecurityToken(), $token);
473 }
474
475 /**
476 * Registers a session variable.
477 */
478 public function register(string $key, mixed $value): void
479 {
480 $scope = $this->isACP ? 'acp' : 'frontend';
481
482 $this->variables[$scope][$key] = $value;
483 $this->variablesChanged = true;
484 }
485
486 /**
487 * Unsets a session variable.
488 */
489 public function unregister(string $key): void
490 {
491 $scope = $this->isACP ? 'acp' : 'frontend';
492
493 unset($this->variables[$scope][$key]);
494 $this->variablesChanged = true;
495 }
496
497 /**
498 * Returns the value of a session variable or `null` if the session
499 * variable does not exist.
500 */
501 public function getVar(string $key): mixed
502 {
503 $scope = $this->isACP ? 'acp' : 'frontend';
504
505 return $this->variables[$scope][$key] ?? null;
506 }
507
508 /**
509 * Returns the user object of this session.
510 */
511 public function getUser(): User
512 {
513 return $this->user;
514 }
515
516 /**
517 * Tries to read existing session identified by the given session id. Returns whether
518 * a session could be found.
519 */
520 private function getExistingSession(string $sessionID): bool
521 {
522 $sql = "SELECT *
523 FROM wcf1_user_session
524 WHERE sessionID = ?";
525 $statement = WCF::getDB()->prepare($sql);
526 $statement->execute([
527 $sessionID,
528 ]);
529 $row = $statement->fetchSingleRow();
530
531 if (!$row) {
532 return false;
533 }
534
535 // Check whether the session technically already expired.
536 $lifetime = ($row['userID'] ? self::USER_SESSION_LIFETIME : self::GUEST_SESSION_LIFETIME);
537 if ($row['lastActivityTime'] < (TIME_NOW - $lifetime)) {
538 return false;
539 }
540
541 try {
542 $variables = \unserialize($row['sessionVariables']);
543
544 // Check whether the session variables became corrupted.
545 if (!\is_array($variables)) {
546 return false;
547 }
548 } catch (\Throwable $e) {
549 return false;
550 }
551
552 $this->sessionID = $sessionID;
553 $this->user = new User($row['userID']);
554 $this->variables = $variables;
555
556 // Update ipAddress, userAgent and lastActivityTime only once per minute to
557 // reduce write traffic to the hot 'user_session' table.
558 //
559 // The former two fields are not going to rapidly change and the latter is just
560 // used for session expiry, where accuracy to the second is not required.
561 if ($row['lastActivityTime'] < (TIME_NOW - 60)) {
562 $sql = "UPDATE wcf1_user_session
563 SET ipAddress = ?,
564 userAgent = ?,
565 lastActivityTime = ?
566 WHERE sessionID = ?";
567 $statement = WCF::getDB()->prepare($sql);
568 $statement->execute([
569 UserUtil::getIpAddress(),
570 UserUtil::getUserAgent(),
571 TIME_NOW,
572 $this->sessionID,
573 ]);
574 }
575
576 if (!$this->isACP) {
577 // Fetch legacy session.
578 $condition = new PreparedStatementConditionBuilder();
579
580 if ($row['userID']) {
581 // The `userID IS NOT NULL` condition technically is redundant, but is added for
582 // clarity and consistency with the guest case below.
583 $condition->add('userID IS NOT NULL');
584 $condition->add('userID = ?', [$row['userID']]);
585 } else {
586 $condition->add('userID IS NULL');
587 $condition->add('(sessionID = ? OR spiderIdentifier = ?)', [
588 $row['sessionID'],
589 SpiderHandler::getInstance()->getIdentifier(UserUtil::getUserAgent()),
590 ]);
591 }
592
593 $sql = "SELECT *
594 FROM wcf1_session
595 {$condition}";
596 $legacySessionStatement = WCF::getDB()->prepare($sql);
597 $legacySessionStatement->execute($condition->getParameters());
598 $this->legacySession = $legacySessionStatement->fetchSingleObject(LegacySession::class);
599
600 if ($this->legacySession === null) {
601 try {
602 $this->legacySession = $this->createLegacySession();
603 } catch (DatabaseQueryExecutionException $e) {
604 // Creation of the legacy session might fail due to duplicate key errors for
605 // concurrent requests.
606 if ($e->getCode() == '23000' && $e->getDriverCode() == '1062') {
607 // Attempt to load the legacy session once again. If the legacy session for some
608 // reason *still* is null then we simply continue without a legacy session. It is
609 // not required for proper request processing and consumers of the values stored
610 // within the legacy session (`page*`) cannot rely on any (valid) values being stored
611 // anyway.
612 $legacySessionStatement->execute($condition->getParameters());
613 $this->legacySession = $legacySessionStatement->fetchSingleObject(LegacySession::class);
614 } else {
615 throw $e;
616 }
617 }
618 }
619 }
620
621 return true;
622 }
623
624 /**
625 * Creates a new session.
626 */
627 private function create(): void
628 {
629 $this->sessionID = Hex::encode(\random_bytes(20));
630
631 $variables = [
632 'frontend' => [],
633 'acp' => [],
634 ];
635
636 // Create new session.
637 $sql = "INSERT INTO wcf1_user_session
638 (sessionID, ipAddress, userAgent, creationTime, lastActivityTime, sessionVariables)
639 VALUES (?, ?, ?, ?, ?, ?)";
640 $statement = WCF::getDB()->prepare($sql);
641 $statement->execute([
642 $this->sessionID,
643 UserUtil::getIpAddress(),
644 UserUtil::getUserAgent(),
645 TIME_NOW,
646 TIME_NOW,
647 \serialize($variables),
648 ]);
649
650 $this->variables = $variables;
651 $this->user = new User(null);
652 $this->firstVisit = true;
653
654 HeaderUtil::setCookie(
655 "user_session",
656 $this->getCookieValue()
657 );
658
659 // Maintain legacy session table for users online list.
660 $this->legacySession = null;
661
662 if (!$this->isACP) {
663 // Try to find an existing spider session. Order by lastActivityTime to maintain a
664 // stable selection in case duplicates exist for some reason.
665 $spiderIdentifier = SpiderHandler::getInstance()->getIdentifier(UserUtil::getUserAgent());
666 if ($spiderIdentifier) {
667 $sql = "SELECT *
668 FROM wcf1_session
669 WHERE spiderIdentifier = ?
670 AND userID IS NULL
671 ORDER BY lastActivityTime DESC";
672 $statement = WCF::getDB()->prepare($sql);
673 $statement->execute([$spiderIdentifier]);
674 $this->legacySession = $statement->fetchSingleObject(LegacySession::class);
675 }
676
677 if ($this->legacySession === null) {
678 $this->legacySession = $this->createLegacySession();
679 }
680 }
681 }
682
683 private function createLegacySession(): LegacySession
684 {
685 $spiderIdentifier = null;
686 if (!$this->user->userID) {
687 $spiderIdentifier = SpiderHandler::getInstance()->getIdentifier(UserUtil::getUserAgent());
688 }
689
690 // save session
691 $sessionData = [
692 'sessionID' => $this->sessionID,
693 'userID' => $this->user->userID,
694 'ipAddress' => UserUtil::getIpAddress(),
695 'userAgent' => UserUtil::getUserAgent(),
696 'lastActivityTime' => TIME_NOW,
697 'requestURI' => UserUtil::getRequestURI(),
698 'requestMethod' => !empty($_SERVER['REQUEST_METHOD']) ? \substr($_SERVER['REQUEST_METHOD'], 0, 7) : '',
699 'spiderIdentifier' => $spiderIdentifier,
700 ];
701
702 return SessionEditor::create($sessionData);
703 }
704
705 /**
706 * Returns the value of the permission with the given name.
707 *
708 * @return mixed permission value
709 */
710 public function getPermission(string $permission)
711 {
712 // check if a users only permission is checked for a guest and return
713 // false if that is the case
714 if (!$this->user->userID && \in_array($permission, $this->usersOnlyPermissions)) {
715 return false;
716 }
717
718 $this->loadGroupData();
719
720 if (!isset($this->groupData[$permission])) {
721 return false;
722 }
723
724 return $this->groupData[$permission];
725 }
726
727 /**
728 * Returns true if a permission was set to 'Never'. This is required to preserve
729 * compatibility, while preventing ACLs from overruling a 'Never' setting.
730 *
731 * @return bool
732 */
733 public function getNeverPermission(string $permission)
734 {
735 $this->loadGroupData();
736
737 return isset($this->groupData['__never'][$permission]);
738 }
739
740 /**
741 * Checks if the active user has the given permissions and throws a
742 * PermissionDeniedException if that isn't the case.
743 *
744 * @param string[] $permissions list of permissions where each one must pass
745 * @throws PermissionDeniedException
746 */
747 public function checkPermissions(array $permissions)
748 {
749 foreach ($permissions as $permission) {
750 if (!$this->getPermission($permission)) {
751 throw new PermissionDeniedException();
752 }
753 }
754 }
755
756 /**
757 * Loads group data from cache.
758 */
759 private function loadGroupData()
760 {
761 if ($this->groupData !== null) {
762 return;
763 }
764
765 // work-around for setup process (package wcf does not exist yet)
766 if (!PACKAGE_ID) {
767 $sql = "SELECT groupID
768 FROM wcf1_user_to_group
769 WHERE userID = ?";
770 $statement = WCF::getDB()->prepare($sql);
771 $statement->execute([$this->user->userID]);
772 $groupIDs = $statement->fetchAll(\PDO::FETCH_COLUMN);
773 } else {
774 $groupIDs = $this->user->getGroupIDs();
775 }
776
777 // get group data from cache
778 $this->groupData = UserGroupPermissionCacheBuilder::getInstance()->getData($groupIDs);
779 }
780
781 /**
782 * @deprecated 6.0 Use User::getLanguageIDs() instead.
783 */
784 public function getLanguageIDs()
785 {
786 if (!$this->user->userID) {
787 return [];
788 }
789
790 return $this->user->getLanguageIDs();
791 }
792
793 /**
794 * If multi-factor authentication is enabled for the given user then
795 * - the userID will be stored in the session variables, and
796 * - `true` is returned.
797 * Otherwise,
798 * - `changeUser()` will be called, and
799 * - `false` is returned.
800 *
801 * If `true` is returned you should perform a redirect to `MultifactorAuthenticationForm`.
802 *
803 * @since 5.4
804 */
805 public function changeUserAfterMultifactorAuthentication(User $user): bool
806 {
807 if ($user->multifactorActive) {
808 $this->register(self::CHANGE_USER_AFTER_MULTIFACTOR_KEY, [
809 'userId' => $user->userID,
810 'expires' => TIME_NOW + self::PENDING_USER_LIFETIME,
811 ]);
812 $this->setLanguageID($user->languageID);
813
814 return true;
815 } else {
816 $this->changeUser($user);
817
818 return false;
819 }
820 }
821
822 /**
823 * Applies the pending user change, calling `changeUser()` for the user returned
824 * by `getPendingUserChange()`.
825 *
826 * As a safety check you must provide the `$expectedUser` as a parameter, it must match the
827 * data stored within the session.
828 *
829 * @throws \RuntimeException If the `$expectedUser` does not match.
830 * @throws \BadMethodCallException If `getPendingUserChange()` returns `null`.
831 * @see SessionHandler::getPendingUserChange()
832 * @since 5.4
833 */
834 public function applyPendingUserChange(User $expectedUser): void
835 {
836 $user = $this->getPendingUserChange();
837 $this->clearPendingUserChange();
838
839 if (!$user) {
840 throw new \BadMethodCallException('No pending user change.');
841 }
842
843 if ($user->userID !== $expectedUser->userID) {
844 throw new \RuntimeException('Mismatching expectedUser.');
845 }
846
847 $this->changeUser($user);
848 }
849
850 /**
851 * Returns the pending user change initiated by `changeUserAfterMultifactorAuthentication()`.
852 *
853 * @see SessionHandler::changeUserAfterMultifactorAuthentication()
854 * @since 5.4
855 */
856 public function getPendingUserChange(): ?User
857 {
858 $data = $this->getVar(self::CHANGE_USER_AFTER_MULTIFACTOR_KEY);
859 if (!$data) {
860 return null;
861 }
862
863 $userId = $data['userId'];
864 $expires = $data['expires'];
865
866 if ($expires < TIME_NOW) {
867 return null;
868 }
869
870 $user = new User($userId);
871
872 if (!$user->userID) {
873 return null;
874 }
875
876 return $user;
877 }
878
879 /**
880 * Clears a pending user change, reverses the effects of `changeUserAfterMultifactorAuthentication()`.
881 *
882 * @see SessionHandler::changeUserAfterMultifactorAuthentication()
883 * @since 5.4
884 */
885 public function clearPendingUserChange(): void
886 {
887 $this->unregister(self::CHANGE_USER_AFTER_MULTIFACTOR_KEY);
888 }
889
890 /**
891 * Stores a new user object in this session, e.g. a user was guest because not
892 * logged in, after the login his old session is used to store his full data.
893 *
894 * @param $hideSession if true, database won't be updated
895 */
896 public function changeUser(User $user, bool $hideSession = false)
897 {
898 $eventParameters = ['user' => $user, 'hideSession' => $hideSession];
899
900 EventHandler::getInstance()->fireAction($this, 'beforeChangeUser', $eventParameters);
901
902 $user = $eventParameters['user'];
903 $hideSession = $eventParameters['hideSession'];
904
905 // skip changeUserVirtual, if session will not be persistent anyway
906 if (!$hideSession) {
907 $this->changeUserVirtual($user);
908 }
909
910 // update user reference
911 $this->user = $user;
912
913 // reset caches
914 $this->groupData = null;
915 $this->languageID = $this->user->languageID;
916
917 // change language
918 WCF::setLanguage($this->languageID ?: 0);
919
920 // in some cases the language id can be stuck in the session variables
921 $this->unregister('languageID');
922
923 EventHandler::getInstance()->fireAction($this, 'afterChangeUser');
924 }
925
926 /**
927 * Changes the user stored in the session.
928 *
929 * @param User $user
930 * @throws DatabaseException
931 */
932 private function changeUserVirtual(User $user): void
933 {
934 $event = new PreserveVariablesCollecting();
935 EventHandler::getInstance()->fire($event);
936
937 $saveVars = [];
938 foreach ($event->keys as $key) {
939 if (!$this->getVar($key)) {
940 continue;
941 }
942
943 $saveVars[$key] = $this->getVar($key);
944 }
945
946 // We must delete the old session to not carry over any state across different users.
947 $this->delete();
948
949 // If the target user is a registered user ...
950 if ($user->userID) {
951 // ... we create a new session with a new session ID ...
952 $this->create();
953
954 // ... delete the newly created legacy session ...
955 $sql = "DELETE FROM wcf1_session
956 WHERE sessionID = ?";
957 $statement = WCF::getDB()->prepare($sql);
958 $statement->execute([$this->sessionID]);
959
960 // ... perform the login ...
961 $sql = "UPDATE wcf1_user_session
962 SET userID = ?
963 WHERE sessionID = ?";
964 $statement = WCF::getDB()->prepare($sql);
965 $statement->execute([
966 $user->userID,
967 $this->sessionID,
968 ]);
969
970 // ... delete any user sessions exceeding the limit ...
971 $sql = "SELECT all_sessions.sessionID
972 FROM wcf1_user_session all_sessions
973 LEFT JOIN (
974 SELECT sessionID
975 FROM wcf1_user_session
976 WHERE userID = ?
977 ORDER BY lastActivityTime DESC
978 LIMIT ?
979 ) newest_sessions
980 ON newest_sessions.sessionID = all_sessions.sessionID
981 WHERE all_sessions.userID = ?
982 AND newest_sessions.sessionID IS NULL";
983 $statement = WCF::getDB()->prepare($sql);
984 $statement->execute([
985 $user->userID,
986 self::USER_SESSION_LIMIT,
987 $user->userID,
988 ]);
989 foreach ($statement->fetchAll(\PDO::FETCH_COLUMN) as $sessionID) {
990 $this->deleteUserSession($sessionID);
991 }
992
993 // ... and reload the session with the updated information.
994 $hasSession = $this->getExistingSession($this->sessionID);
995
996 if (!$hasSession) {
997 throw new \LogicException('Unreachable');
998 }
999
1000 // Replace the session-lived cookie by a long-lived cookie.
1001 HeaderUtil::setCookie(
1002 'user_session',
1003 $this->getCookieValue(),
1004 TIME_NOW + (self::USER_SESSION_LIFETIME + (7 * 86400))
1005 );
1006
1007 foreach ($saveVars as $key => $value) {
1008 $this->register($key, $value);
1009 }
1010 }
1011 }
1012
1013 /**
1014 * Checks whether the user needs to authenticate themselves once again
1015 * to access a security critical area.
1016 *
1017 * If `true` is returned you should perform a redirect to `ReAuthenticationForm`,
1018 * otherwise the user is sufficiently authenticated and may proceed.
1019 *
1020 * @throws \BadMethodCallException If the current user is a guest.
1021 * @since 5.4
1022 */
1023 public function needsReauthentication(): bool
1024 {
1025 if (!$this->getUser()->userID) {
1026 throw new \BadMethodCallException('The current user is a guest.');
1027 }
1028
1029 // Reauthentication for third party authentication is not supported.
1030 if ($this->getUser()->authData) {
1031 return false;
1032 }
1033
1034 $data = $this->getVar(self::REAUTHENTICATION_KEY);
1035
1036 // Request a new authentication if no stored information is available.
1037 if (!$data) {
1038 return true;
1039 }
1040
1041 $lastAuthentication = $data['lastAuthentication'];
1042 $lastCheck = $data['lastCheck'];
1043
1044 // Request a new authentication if the hard limit since the last authentication
1045 // is exceeded.
1046 if ($lastAuthentication < (TIME_NOW - self::REAUTHENTICATION_HARD_LIMIT)) {
1047 return true;
1048 }
1049
1050 $softLimit = self::REAUTHENTICATION_SOFT_LIMIT;
1051 if ($this->isACP) {
1052 $softLimit = self::REAUTHENTICATION_SOFT_LIMIT_ACP;
1053
1054 // If both the debug mode and the developer tools are enabled the
1055 // reauthentication soft limit within the ACP matches the hard limit.
1056 //
1057 // This allows for a continous access to the ACP and specifically the
1058 // developer tools within a single workday without needing to re-login
1059 // just because one spent 15 minutes within the IDE.
1060 if (ENABLE_DEBUG_MODE && ENABLE_DEVELOPER_TOOLS) {
1061 $softLimit = self::REAUTHENTICATION_HARD_LIMIT;
1062 }
1063 }
1064
1065 // Request a new authentication if the soft limit since the last authentication
1066 // is exceeded ...
1067 if ($lastAuthentication < (TIME_NOW - $softLimit)) {
1068 // ... and the grace period since the last check is also exceeded.
1069 if ($lastCheck < (TIME_NOW - self::REAUTHENTICATION_GRACE_PERIOD)) {
1070 return true;
1071 }
1072 }
1073
1074 // If we reach this point we determined that a new authentication is not necessary.
1075 \assert(
1076 ($lastAuthentication >= TIME_NOW - $softLimit)
1077 || ($lastAuthentication >= TIME_NOW - self::REAUTHENTICATION_HARD_LIMIT
1078 && $lastCheck >= TIME_NOW - self::REAUTHENTICATION_GRACE_PERIOD)
1079 );
1080
1081 // Update the lastCheck timestamp to make sure that the grace period works properly.
1082 //
1083 // The grace period allows the user to complete their action if the soft limit
1084 // expires between loading a form and actually submitting that form, provided that
1085 // the user does not take longer than the grace period to fill in the form.
1086 $data['lastCheck'] = TIME_NOW;
1087 $this->register(self::REAUTHENTICATION_KEY, $data);
1088
1089 return false;
1090 }
1091
1092 /**
1093 * Registers that the user performed reauthentication successfully.
1094 *
1095 * This method should be considered to be semi-public and is intended to be used
1096 * by `ReAuthenticationForm` only.
1097 *
1098 * @throws \BadMethodCallException If the current user is a guest.
1099 * @see SessionHandler::needsReauthentication()
1100 * @since 5.4
1101 */
1102 public function registerReauthentication(): void
1103 {
1104 if (!$this->getUser()->userID) {
1105 throw new \BadMethodCallException('The current user is a guest.');
1106 }
1107
1108 $this->register(self::REAUTHENTICATION_KEY, [
1109 'lastAuthentication' => TIME_NOW,
1110 'lastCheck' => TIME_NOW,
1111 ]);
1112 }
1113
1114 /**
1115 * Clears that the user performed reauthentication successfully.
1116 *
1117 * After this method is called `needsReauthentication()` will return true until
1118 * `registerReauthentication()` is called again.
1119 *
1120 * @throws \BadMethodCallException If the current user is a guest.
1121 * @see SessionHandler::needsReauthentication()
1122 * @see SessionHandler::registerReauthentication()
1123 * @since 5.4
1124 */
1125 public function clearReauthentication(): void
1126 {
1127 if (!$this->getUser()->userID) {
1128 throw new \BadMethodCallException('The current user is a guest.');
1129 }
1130
1131 $this->unregister(self::REAUTHENTICATION_KEY);
1132 }
1133
1134 /**
1135 * Updates user session on shutdown.
1136 */
1137 public function update(): void
1138 {
1139 if ($this->doNotUpdate) {
1140 return;
1141 }
1142
1143 if ($this->variablesChanged) {
1144 $sql = "UPDATE wcf1_user_session
1145 SET sessionVariables = ?
1146 WHERE sessionID = ?";
1147 $statement = WCF::getDB()->prepare($sql);
1148 $statement->execute([
1149 \serialize($this->variables),
1150 $this->sessionID,
1151 ]);
1152
1153 // Reset the flag, because the variables are no longer dirty.
1154 $this->variablesChanged = false;
1155 }
1156
1157 $data = [
1158 'ipAddress' => UserUtil::getIpAddress(),
1159 'userAgent' => $this->userAgent,
1160 'requestURI' => $this->requestURI,
1161 'requestMethod' => $this->requestMethod,
1162 'lastActivityTime' => TIME_NOW,
1163 'sessionID' => $this->sessionID,
1164 ];
1165 if (!\class_exists('wcf\system\CLIWCF', false) && !$this->disableTracking) {
1166 $pageLocations = PageLocationManager::getInstance()->getLocations();
1167 if (isset($pageLocations[0])) {
1168 $data['pageID'] = $pageLocations[0]['pageID'];
1169 $data['pageObjectID'] = ($pageLocations[0]['pageObjectID'] ?: null);
1170 $data['parentPageID'] = null;
1171 $data['parentPageObjectID'] = null;
1172
1173 for ($i = 1, $length = \count($pageLocations); $i < $length; $i++) {
1174 if (!empty($pageLocations[$i]['useAsParentLocation'])) {
1175 $data['parentPageID'] = $pageLocations[$i]['pageID'];
1176 $data['parentPageObjectID'] = ($pageLocations[$i]['pageObjectID'] ?: null);
1177 break;
1178 }
1179 }
1180 }
1181 }
1182
1183 if ($this->legacySession !== null) {
1184 $sessionEditor = new SessionEditor($this->legacySession);
1185 $sessionEditor->update($data);
1186 }
1187 }
1188
1189 /**
1190 * @deprecated 5.4 - This method is a noop. The lastActivityTime is always updated immediately after loading.
1191 */
1192 public function keepAlive()
1193 {
1194 }
1195
1196 /**
1197 * Deletes this session and its related data.
1198 */
1199 public function delete(): void
1200 {
1201 // clear storage
1202 if ($this->user->userID) {
1203 self::resetSessions([$this->user->userID]);
1204
1205 // update last activity time
1206 $editor = new UserEditor($this->user);
1207 $editor->update(['lastActivityTime' => TIME_NOW]);
1208 }
1209
1210 $this->deleteUserSession($this->sessionID);
1211 }
1212
1213 /**
1214 * Prunes expired sessions.
1215 */
1216 public function prune(): void
1217 {
1218 $sql = "DELETE FROM wcf1_user_session
1219 WHERE (lastActivityTime < ? AND userID IS NULL)
1220 OR (lastActivityTime < ? AND userID IS NOT NULL)";
1221 $statement = WCF::getDB()->prepare($sql);
1222 $statement->execute([
1223 TIME_NOW - self::GUEST_SESSION_LIFETIME,
1224 TIME_NOW - self::USER_SESSION_LIFETIME,
1225 ]);
1226
1227 // Legacy sessions live 120 minutes, they will be re-created on demand.
1228 $sql = "DELETE FROM wcf1_session
1229 WHERE lastActivityTime < ?";
1230 $statement = WCF::getDB()->prepare($sql);
1231 $statement->execute([
1232 TIME_NOW - (3600 * 2),
1233 ]);
1234 }
1235
1236 /**
1237 * Deletes this session if:
1238 * - it is newly created in this request, and
1239 * - it belongs to a guest.
1240 *
1241 * This method is useful if you have controllers that are likely to be
1242 * accessed by a user agent that is not going to re-use sessions (e.g.
1243 * curl in a cronjob). It immediately remove the session that was created
1244 * just for that request and that is not going to be used ever again.
1245 *
1246 * @since 5.2
1247 */
1248 public function deleteIfNew(): void
1249 {
1250 if ($this->isFirstVisit() && !$this->getUser()->userID) {
1251 $this->delete();
1252 }
1253 }
1254
1255 /**
1256 * Returns currently active language id.
1257 *
1258 * @return int
1259 */
1260 public function getLanguageID()
1261 {
1262 return $this->languageID;
1263 }
1264
1265 /**
1266 * Sets the currently active language id.
1267 *
1268 * @param int $languageID
1269 */
1270 public function setLanguageID($languageID)
1271 {
1272 $this->languageID = $languageID;
1273 $this->register('languageID', $this->languageID);
1274 }
1275
1276 /**
1277 * Resets session-specific storage data.
1278 *
1279 * @param int[] $userIDs
1280 * @deprecated 6.1 see https://github.com/WoltLab/WCF/pull/3767
1281 */
1282 public static function resetSessions(array $userIDs = [])
1283 {
1284 if (!empty($userIDs)) {
1285 UserStorageHandler::getInstance()->reset($userIDs, 'groupIDs');
1286 UserStorageHandler::getInstance()->reset($userIDs, 'languageIDs');
1287 } else {
1288 UserStorageHandler::getInstance()->resetAll('groupIDs');
1289 UserStorageHandler::getInstance()->resetAll('languageIDs');
1290 }
1291 }
1292
1293 /**
1294 * Returns true if this is a new session.
1295 */
1296 public function isFirstVisit(): bool
1297 {
1298 return $this->firstVisit;
1299 }
1300
1301 /**
1302 * Returns all sessions for a specific user.
1303 *
1304 * @return Session[]
1305 * @throws \InvalidArgumentException if the given user is a guest.
1306 * @since 5.4
1307 */
1308 public function getUserSessions(User $user): array
1309 {
1310 if (!$user->userID) {
1311 throw new \InvalidArgumentException("The given user is a guest.");
1312 }
1313
1314 $sql = "SELECT *
1315 FROM wcf1_user_session
1316 WHERE userID = ?";
1317 $statement = WCF::getDB()->prepare($sql);
1318 $statement->execute([$user->userID]);
1319
1320 $sessions = [];
1321 while ($row = $statement->fetchArray()) {
1322 $sessions[] = new Session($row);
1323 }
1324
1325 return $sessions;
1326 }
1327
1328 /**
1329 * Deletes the sessions for a specific user, except the session with the given session id.
1330 *
1331 * If the given session id is `null` or unknown, all sessions of the user will be deleted.
1332 *
1333 * @throws \InvalidArgumentException if the given user is a guest.
1334 * @since 5.4
1335 */
1336 public function deleteUserSessionsExcept(User $user, ?string $sessionID = null): void
1337 {
1338 if (!$user->userID) {
1339 throw new \InvalidArgumentException("The given user is a guest.");
1340 }
1341
1342 $conditionBuilder = new PreparedStatementConditionBuilder();
1343 $conditionBuilder->add('userID = ?', [$user->userID]);
1344
1345 if ($sessionID !== null) {
1346 $conditionBuilder->add('sessionID <> ?', [$sessionID]);
1347 }
1348
1349 $sql = "DELETE FROM wcf1_user_session
1350 {$conditionBuilder}";
1351 $statement = WCF::getDB()->prepare($sql);
1352 $statement->execute($conditionBuilder->getParameters());
1353
1354 // Delete legacy session.
1355 $sql = "DELETE FROM wcf1_session
1356 {$conditionBuilder}";
1357 $statement = WCF::getDB()->prepare($sql);
1358 $statement->execute($conditionBuilder->getParameters());
1359 }
1360
1361 /**
1362 * Deletes a session with the given session ID.
1363 *
1364 * @since 5.4
1365 */
1366 public function deleteUserSession(string $sessionID): void
1367 {
1368 $sql = "DELETE FROM wcf1_user_session
1369 WHERE sessionID = ?";
1370 $statement = WCF::getDB()->prepare($sql);
1371 $statement->execute([$sessionID]);
1372
1373 // Delete legacy session.
1374 $sql = "DELETE FROM wcf1_session
1375 WHERE sessionID = ?";
1376 $statement = WCF::getDB()->prepare($sql);
1377 $statement->execute([$sessionID]);
1378 }
1379
1380 /**
1381 * Returns the session variables.
1382 *
1383 * @since 6.1
1384 */
1385 public function getVariables(): array
1386 {
1387 $scope = $this->isACP ? 'acp' : 'frontend';
1388
1389 return $this->variables[$scope] ?? [];
1390 }
1391 }