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