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