Merge branch '3.1' into 5.2
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / session / SessionHandler.class.php
1 <?php
2 namespace wcf\system\session;
3 use wcf\data\acp\session\virtual\ACPSessionVirtual;
4 use wcf\data\acp\session\virtual\ACPSessionVirtualAction;
5 use wcf\data\acp\session\virtual\ACPSessionVirtualEditor;
6 use wcf\data\session\virtual\SessionVirtual;
7 use wcf\data\session\virtual\SessionVirtualAction;
8 use wcf\data\session\virtual\SessionVirtualEditor;
9 use wcf\data\session\SessionEditor;
10 use wcf\data\user\User;
11 use wcf\data\user\UserEditor;
12 use wcf\system\cache\builder\SpiderCacheBuilder;
13 use wcf\system\cache\builder\UserGroupOptionCacheBuilder;
14 use wcf\system\cache\builder\UserGroupPermissionCacheBuilder;
15 use wcf\system\database\DatabaseException;
16 use wcf\system\event\EventHandler;
17 use wcf\system\exception\PermissionDeniedException;
18 use wcf\system\page\PageLocationManager;
19 use wcf\system\user\authentication\UserAuthenticationFactory;
20 use wcf\system\user\storage\UserStorageHandler;
21 use wcf\system\SingletonFactory;
22 use wcf\system\WCF;
23 use wcf\system\WCFACP;
24 use wcf\util\HeaderUtil;
25 use wcf\util\UserUtil;
26
27 /**
28 * Handles sessions.
29 *
30 * @author Alexander Ebert
31 * @copyright 2001-2019 WoltLab GmbH
32 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
33 * @package WoltLabSuite\Core\System\Session
34 *
35 * @property-read string $sessionID unique textual identifier of the session
36 * @property-read integer|null $userID id of the user the session belongs to or `null` if the acp session belongs to a guest
37 * @property-read string $ipAddress id of the user whom the session belongs to
38 * @property-read string $userAgent user agent of the user whom the session belongs to
39 * @property-read integer $lastActivityTime timestamp at which the latest activity occurred
40 * @property-read string $requestURI uri of the latest request
41 * @property-read string $requestMethod used request method of the latest request (`GET`, `POST`)
42 * @property-read integer|null $pageID id of the latest page visited
43 * @property-read integer|null $pageObjectID id of the object the latest page visited belongs to
44 * @property-read integer|null $parentPageID id of the parent page of latest page visited
45 * @property-read integer|null $parentPageObjectID id of the object the parent page of latest page visited belongs to
46 * @property-read integer $spiderID id of the spider the session belongs to
47 */
48 class SessionHandler extends SingletonFactory {
49 /**
50 * suffix used to tell ACP and frontend cookies apart
51 * @var string
52 */
53 protected $cookieSuffix = '';
54
55 /**
56 * prevents update on shutdown
57 * @var boolean
58 */
59 protected $doNotUpdate = false;
60
61 /**
62 * disables page tracking
63 * @var boolean
64 */
65 protected $disableTracking = false;
66
67 /**
68 * various environment variables
69 * @var array
70 */
71 protected $environment = [];
72
73 /**
74 * group data and permissions
75 * @var mixed[][]
76 */
77 protected $groupData = null;
78
79 /**
80 * true if client provided a valid session cookie
81 * @var boolean
82 */
83 protected $hasValidCookie = false;
84
85 /**
86 * true if within ACP or WCFSetup
87 * @var boolean
88 */
89 protected $isACP = false;
90
91 /**
92 * language id for active user
93 * @var integer
94 */
95 protected $languageID = 0;
96
97 /**
98 * language ids for active user
99 * @var integer[]
100 */
101 protected $languageIDs = null;
102
103 /**
104 * session object
105 * @var \wcf\data\acp\session\ACPSession
106 */
107 protected $session = null;
108
109 /**
110 * session class name
111 * @var string
112 */
113 protected $sessionClassName = '';
114
115 /**
116 * session editor class name
117 * @var string
118 */
119 protected $sessionEditorClassName = '';
120
121 /**
122 * virtual session support
123 * @var boolean
124 */
125 protected $supportsVirtualSessions = false;
126
127 /**
128 * style id
129 * @var integer
130 */
131 protected $styleID = null;
132
133 /**
134 * user object
135 * @var User
136 */
137 protected $user = null;
138
139 /**
140 * session variables
141 * @var array
142 */
143 protected $variables = null;
144
145 /**
146 * indicates if session variables changed and must be saved upon shutdown
147 * @var boolean
148 */
149 protected $variablesChanged = false;
150
151 /**
152 * virtual session object, null for guests
153 * @var \wcf\data\session\virtual\SessionVirtual
154 */
155 protected $virtualSession = false;
156
157 /**
158 * true if this is a new session
159 * @var boolean
160 */
161 protected $firstVisit = false;
162
163 /**
164 * list of names of permissions only available for users
165 * @var string[]
166 */
167 protected $usersOnlyPermissions = [];
168
169 /**
170 * Provides access to session data.
171 *
172 * @param string $key
173 * @return mixed
174 */
175 public function __get($key) {
176 if (isset($this->environment[$key])) {
177 return $this->environment[$key];
178 }
179
180 return $this->session->{$key};
181 }
182
183 /**
184 * @inheritDoc
185 */
186 protected function init() {
187 $this->isACP = (class_exists(WCFACP::class, false) || !PACKAGE_ID);
188 $this->usersOnlyPermissions = UserGroupOptionCacheBuilder::getInstance()->getData([], 'usersOnlyOptions');
189 }
190
191 /**
192 * Suffix used to tell ACP and frontend cookies apart
193 *
194 * @param string $cookieSuffix cookie suffix
195 */
196 public function setCookieSuffix($cookieSuffix) {
197 $this->cookieSuffix = $cookieSuffix;
198 }
199
200 /**
201 * Sets a boolean value to determine if the client provided a valid session cookie.
202 *
203 * @param boolean $hasValidCookie
204 * @since 3.0
205 */
206 public function setHasValidCookie($hasValidCookie) {
207 $this->hasValidCookie = $hasValidCookie;
208 }
209
210 /**
211 * Returns true if client provided a valid session cookie.
212 *
213 * @return boolean
214 * @since 3.0
215 */
216 public function hasValidCookie() {
217 return $this->hasValidCookie;
218 }
219
220 /**
221 * Loads an existing session or creates a new one.
222 *
223 * @param string $sessionEditorClassName
224 * @param string $sessionID
225 */
226 public function load($sessionEditorClassName, $sessionID) {
227 $this->sessionEditorClassName = $sessionEditorClassName;
228 $this->sessionClassName = call_user_func([$sessionEditorClassName, 'getBaseClass']);
229 $this->supportsVirtualSessions = call_user_func([$this->sessionClassName, 'supportsVirtualSessions']);
230
231 // try to get existing session
232 if (!empty($sessionID)) {
233 $this->getExistingSession($sessionID);
234 }
235
236 // create new session
237 if ($this->session === null) {
238 $this->create();
239 }
240 }
241
242 /**
243 * Initializes session system.
244 */
245 public function initSession() {
246 // init session environment
247 $this->loadVariables();
248 $this->initSecurityToken();
249
250 // session id change was delayed to the next request
251 // as the SID constants already were defined
252 if ($this->getVar('__changeSessionID')) {
253 $this->unregister('__changeSessionID');
254 $this->changeSessionID();
255 }
256 $this->defineConstants();
257
258 // assign language and style id
259 $this->languageID = ($this->getVar('languageID') === null) ? $this->user->languageID : $this->getVar('languageID');
260 $this->styleID = ($this->getVar('styleID') === null) ? $this->user->styleID : $this->getVar('styleID');
261
262 // init environment variables
263 $this->initEnvironment();
264
265 // https://github.com/WoltLab/WCF/issues/2568
266 if ($this->getVar('__wcfIsFirstVisit') === true) {
267 $this->firstVisit = true;
268 $this->unregister('__wcfIsFirstVisit');
269 }
270 }
271
272 /**
273 * Changes the session id to a new random one.
274 *
275 * Usually a change is requested after login to ensure
276 * that the user is not running a fixated session by an
277 * attacker.
278 */
279 protected function changeSessionID() {
280 $newSessionID = bin2hex(\random_bytes(20));
281
282 /** @var \wcf\data\DatabaseObjectEditor $sessionEditor */
283 $sessionEditor = new $this->sessionEditorClassName($this->session);
284 $sessionEditor->update([
285 'sessionID' => $newSessionID
286 ]);
287
288 // fetch new session data from database
289 $this->session = new $this->sessionClassName($newSessionID);
290
291 HeaderUtil::setCookie('cookieHash'.$this->cookieSuffix, $newSessionID);
292 }
293
294 /**
295 * Initializes environment variables.
296 */
297 protected function initEnvironment() {
298 $this->environment = [
299 'lastRequestURI' => $this->session->requestURI,
300 'lastRequestMethod' => $this->session->requestMethod,
301 'ipAddress' => UserUtil::getIpAddress(),
302 'userAgent' => UserUtil::getUserAgent(),
303 'requestURI' => UserUtil::getRequestURI(),
304 'requestMethod' => !empty($_SERVER['REQUEST_METHOD']) ? substr($_SERVER['REQUEST_METHOD'], 0, 7) : ''
305 ];
306 }
307
308 /**
309 * Disables update on shutdown.
310 */
311 public function disableUpdate() {
312 $this->doNotUpdate = true;
313 }
314
315 /**
316 * Disables page tracking.
317 */
318 public function disableTracking() {
319 $this->disableTracking = true;
320 }
321
322 /**
323 * Defines global wcf constants related to session.
324 */
325 protected function defineConstants() {
326 /* the SID*-constants below are deprecated since 3.0 */
327 if (!defined('SID_ARG_1ST')) define('SID_ARG_1ST', '');
328 if (!defined('SID_ARG_2ND')) define('SID_ARG_2ND', '');
329 if (!defined('SID_ARG_2ND_NOT_ENCODED')) define('SID_ARG_2ND_NOT_ENCODED', '');
330 if (!defined('SID')) define('SID', '');
331 if (!defined('SID_INPUT_TAG')) define('SID_INPUT_TAG', '');
332
333 // security token
334 if (!defined('SECURITY_TOKEN')) define('SECURITY_TOKEN', $this->getSecurityToken());
335 if (!defined('SECURITY_TOKEN_INPUT_TAG')) define('SECURITY_TOKEN_INPUT_TAG', '<input type="hidden" name="t" value="'.$this->getSecurityToken().'">');
336 }
337
338 /**
339 * Initializes security token.
340 */
341 protected function initSecurityToken() {
342 if ($this->getVar('__SECURITY_TOKEN') === null) {
343 $this->register('__SECURITY_TOKEN', bin2hex(\random_bytes(20)));
344 }
345 }
346
347 /**
348 * Returns security token.
349 *
350 * @return string
351 */
352 public function getSecurityToken() {
353 return $this->getVar('__SECURITY_TOKEN');
354 }
355
356 /**
357 * Validates the given security token, returns false if
358 * given token is invalid.
359 *
360 * @param string $token
361 * @return boolean
362 */
363 public function checkSecurityToken($token) {
364 return \hash_equals($this->getSecurityToken(), $token);
365 }
366
367 /**
368 * Registers a session variable.
369 *
370 * @param string $key
371 * @param mixed $value
372 */
373 public function register($key, $value) {
374 $this->variables[$key] = $value;
375 $this->variablesChanged = true;
376 }
377
378 /**
379 * Unsets a session variable.
380 *
381 * @param string $key
382 */
383 public function unregister($key) {
384 unset($this->variables[$key]);
385 $this->variablesChanged = true;
386 }
387
388 /**
389 * Returns the value of a session variable or `null` if the session
390 * variable does not exist.
391 *
392 * @param string $key
393 * @return mixed
394 */
395 public function getVar($key) {
396 if (isset($this->variables[$key])) {
397 return $this->variables[$key];
398 }
399
400 return null;
401 }
402
403 /**
404 * Initializes session variables.
405 */
406 protected function loadVariables() {
407 if ($this->session->sessionVariables !== null) {
408 $this->variables = @unserialize($this->session->sessionVariables);
409 if (!is_array($this->variables)) {
410 $this->variables = [];
411 }
412 }
413 else {
414 $this->variables = [];
415 }
416 }
417
418 /**
419 * Returns the user object of this session.
420 *
421 * @return User $user
422 */
423 public function getUser() {
424 return $this->user;
425 }
426
427 /**
428 * Tries to read existing session identified by the given session id.
429 *
430 * @param string $sessionID
431 */
432 protected function getExistingSession($sessionID) {
433 $this->session = new $this->sessionClassName($sessionID);
434 if (!$this->session->sessionID) {
435 $this->session = null;
436 return;
437 }
438
439 $this->user = new User($this->session->userID);
440 if ($this->isACP) {
441 $this->virtualSession = ACPSessionVirtual::getExistingSession($sessionID);
442 }
443 else {
444 $this->virtualSession = SessionVirtual::getExistingSession($sessionID);
445 }
446
447 if (!$this->validate()) {
448 $this->session = null;
449 $this->user = null;
450 $this->virtualSession = false;
451
452 return;
453 }
454
455 $this->loadVirtualSession();
456 }
457
458 /**
459 * Loads the virtual session object unless the user is not logged in or the session
460 * does not support virtual sessions. If there is no virtual session yet, it will be
461 * created on-the-fly.
462 *
463 * @param boolean $forceReload
464 */
465 protected function loadVirtualSession($forceReload = false) {
466 if ($this->virtualSession === null || $forceReload) {
467 $this->virtualSession = null;
468 if ($this->isACP) {
469 $virtualSessionAction = new ACPSessionVirtualAction([], 'create', ['data' => ['sessionID' => $this->session->sessionID]]);
470 }
471 else {
472 $virtualSessionAction = new SessionVirtualAction([], 'create', ['data' => ['sessionID' => $this->session->sessionID]]);
473 }
474
475 try {
476 $returnValues = $virtualSessionAction->executeAction();
477 $this->virtualSession = $returnValues['returnValues'];
478 }
479 catch (DatabaseException $e) {
480 // MySQL error 23000 = unique key
481 // do not check against the message itself, some weird systems localize them
482 if ($e->getCode() == 23000) {
483 if ($this->isACP) {
484 $this->virtualSession = ACPSessionVirtual::getExistingSession($this->session->sessionID);
485 }
486 else {
487 $this->virtualSession = SessionVirtual::getExistingSession($this->session->sessionID);
488 }
489 }
490 }
491 }
492 }
493
494 /**
495 * Validates the ip address and the user agent of this session.
496 *
497 * @return boolean
498 */
499 protected function validate() {
500 if (SESSION_VALIDATE_IP_ADDRESS) {
501 if ($this->virtualSession instanceof ACPSessionVirtual) {
502 if ($this->virtualSession->ipAddress != UserUtil::getIpAddress()) {
503 return false;
504 }
505 }
506 else if ($this->session->ipAddress != UserUtil::getIpAddress()) {
507 return false;
508 }
509 }
510
511 if (SESSION_VALIDATE_USER_AGENT) {
512 if ($this->virtualSession instanceof ACPSessionVirtual) {
513 if ($this->virtualSession->userAgent != UserUtil::getUserAgent()) {
514 return false;
515 }
516 }
517 else if ($this->session->userAgent != UserUtil::getUserAgent()) {
518 return false;
519 }
520 }
521
522 return true;
523 }
524
525 /**
526 * Creates a new session.
527 */
528 protected function create() {
529 $spiderID = null;
530 if ($this->sessionEditorClassName == SessionEditor::class) {
531 // get spider information
532 $spiderID = $this->getSpiderID(UserUtil::getUserAgent());
533 if ($spiderID !== null) {
534 // try to use existing session
535 if (($session = $this->getExistingSpiderSession($spiderID)) !== null) {
536 $this->user = new User(null);
537 $this->session = $session;
538 return;
539 }
540 }
541 }
542
543 // create new session hash
544 $sessionID = bin2hex(\random_bytes(20));
545
546 // get user automatically
547 $this->user = UserAuthenticationFactory::getInstance()->getUserAuthentication()->loginAutomatically(call_user_func([$this->sessionClassName, 'supportsPersistentLogins']));
548
549 // create user
550 if ($this->user === null) {
551 // no valid user found
552 // create guest user
553 $this->user = new User(null);
554 }
555 else if (!$this->supportsVirtualSessions) {
556 // delete all other sessions of this user
557 call_user_func([$this->sessionEditorClassName, 'deleteUserSessions'], [$this->user->userID]);
558 }
559
560 $createNewSession = true;
561 // find existing session
562 $session = call_user_func([$this->sessionClassName, 'getSessionByUserID'], $this->user->userID);
563
564 if ($session !== null) {
565 // inherit existing session
566 $this->session = $session;
567 $this->loadVirtualSession(true);
568
569 $createNewSession = false;
570 }
571
572 if ($createNewSession) {
573 // save session
574 $sessionData = [
575 'sessionID' => $sessionID,
576 'userID' => $this->user->userID,
577 'ipAddress' => UserUtil::getIpAddress(),
578 'userAgent' => UserUtil::getUserAgent(),
579 'lastActivityTime' => TIME_NOW,
580 'requestURI' => UserUtil::getRequestURI(),
581 'requestMethod' => !empty($_SERVER['REQUEST_METHOD']) ? substr($_SERVER['REQUEST_METHOD'], 0, 7) : ''
582 ];
583
584 if ($spiderID !== null) $sessionData['spiderID'] = $spiderID;
585
586 try {
587 $this->session = call_user_func([$this->sessionEditorClassName, 'create'], $sessionData);
588 }
589 catch (DatabaseException $e) {
590 // MySQL error 23000 = unique key
591 // do not check against the message itself, some weird systems localize them
592 if ($e->getCode() == 23000) {
593 // find existing session
594 $session = call_user_func([$this->sessionClassName, 'getSessionByUserID'], $this->user->userID);
595
596 if ($session === null) {
597 // MySQL reported a unique key error, but no corresponding session exists, rethrow exception
598 throw $e;
599 }
600 else {
601 // inherit existing session
602 $this->session = $session;
603 $this->loadVirtualSession(true);
604 }
605 }
606 else {
607 // unrelated to user id
608 throw $e;
609 }
610 }
611
612 $this->firstVisit = true;
613 $this->loadVirtualSession(true);
614 }
615 }
616
617 /**
618 * Returns the value of the permission with the given name.
619 *
620 * @param string $permission
621 * @return mixed permission value
622 */
623 public function getPermission($permission) {
624 // check if a users only permission is checked for a guest and return
625 // false if that is the case
626 if (!$this->user->userID && in_array($permission, $this->usersOnlyPermissions)) {
627 return false;
628 }
629
630 $this->loadGroupData();
631
632 if (!isset($this->groupData[$permission])) return false;
633 return $this->groupData[$permission];
634 }
635
636 /**
637 * Returns true if a permission was set to 'Never'. This is required to preserve
638 * compatibility, while preventing ACLs from overruling a 'Never' setting.
639 *
640 * @param string $permission
641 * @return boolean
642 */
643 public function getNeverPermission($permission) {
644 $this->loadGroupData();
645
646 return (isset($this->groupData['__never'][$permission]));
647 }
648
649 /**
650 * Checks if the active user has the given permissions and throws a
651 * PermissionDeniedException if that isn't the case.
652 *
653 * @param string[] $permissions list of permissions where each one must pass
654 * @throws PermissionDeniedException
655 */
656 public function checkPermissions(array $permissions) {
657 foreach ($permissions as $permission) {
658 if (!$this->getPermission($permission)) {
659 throw new PermissionDeniedException();
660 }
661 }
662 }
663
664 /**
665 * Loads group data from cache.
666 */
667 protected function loadGroupData() {
668 if ($this->groupData !== null) return;
669
670 // work-around for setup process (package wcf does not exist yet)
671 if (!PACKAGE_ID) {
672 $sql = "SELECT groupID
673 FROM wcf".WCF_N."_user_to_group
674 WHERE userID = ?";
675 $statement = WCF::getDB()->prepareStatement($sql);
676 $statement->execute([$this->user->userID]);
677 $groupIDs = $statement->fetchAll(\PDO::FETCH_COLUMN);
678 }
679 else {
680 $groupIDs = $this->user->getGroupIDs();
681 }
682
683 // get group data from cache
684 $this->groupData = UserGroupPermissionCacheBuilder::getInstance()->getData($groupIDs);
685 if (isset($this->groupData['groupIDs']) && $this->groupData['groupIDs'] != $groupIDs) {
686 $this->groupData = [];
687 }
688 }
689
690 /**
691 * Returns language ids for active user.
692 *
693 * @return integer[]
694 */
695 public function getLanguageIDs() {
696 $this->loadLanguageIDs();
697
698 return $this->languageIDs;
699 }
700
701 /**
702 * Loads language ids for active user.
703 */
704 protected function loadLanguageIDs() {
705 if ($this->languageIDs !== null) return;
706
707 $this->languageIDs = [];
708
709 if (!$this->user->userID) {
710 return;
711 }
712
713 // work-around for setup process (package wcf does not exist yet)
714 if (!PACKAGE_ID) {
715 $sql = "SELECT languageID
716 FROM wcf".WCF_N."_user_to_language
717 WHERE userID = ?";
718 $statement = WCF::getDB()->prepareStatement($sql);
719 $statement->execute([$this->user->userID]);
720 $this->languageIDs = $statement->fetchAll(\PDO::FETCH_COLUMN);
721 }
722 else {
723 $this->languageIDs = $this->user->getLanguageIDs();
724 }
725 }
726
727 /**
728 * Stores a new user object in this session, e.g. a user was guest because not
729 * logged in, after the login his old session is used to store his full data.
730 *
731 * @param User $user
732 * @param boolean $hideSession if true, database won't be updated
733 */
734 public function changeUser(User $user, $hideSession = false) {
735 $eventParameters = ['user' => $user, 'hideSession' => $hideSession];
736
737 EventHandler::getInstance()->fireAction($this, 'beforeChangeUser', $eventParameters);
738
739 $user = $eventParameters['user'];
740 $hideSession = $eventParameters['hideSession'];
741
742 // skip changeUserVirtual, if session will not be persistent anyway
743 if (!$hideSession) {
744 $this->changeUserVirtual($user);
745 }
746
747 // update user reference
748 $this->user = $user;
749 $this->userID = $this->user->userID ?: 0;
750
751 // reset caches
752 $this->groupData = null;
753 $this->languageIDs = null;
754 $this->languageID = $this->user->languageID;
755 $this->styleID = $this->user->styleID;
756
757 // change language
758 WCF::setLanguage($this->languageID ?: 0);
759
760 // in some cases the language id can be stuck in the session variables
761 $this->unregister('languageID');
762
763 EventHandler::getInstance()->fireAction($this, 'afterChangeUser');
764 }
765
766 /**
767 * Changes the user stored in the session.
768 *
769 * @param User $user
770 * @throws DatabaseException
771 */
772 protected function changeUserVirtual(User $user) {
773 /** @var \wcf\data\DatabaseObjectEditor $sessionEditor */
774
775 switch ($user->userID) {
776 //
777 // user -> guest (logout)
778 //
779 case 0:
780 // delete virtual session
781 if ($this->virtualSession) {
782 if ($this->isACP) {
783 $virtualSessionEditor = new ACPSessionVirtualEditor($this->virtualSession);
784 }
785 else {
786 $virtualSessionEditor = new SessionVirtualEditor($this->virtualSession);
787 }
788 $virtualSessionEditor->delete();
789 }
790
791 if ($this->isACP) {
792 $sessionCount = ACPSessionVirtual::countVirtualSessions($this->session->sessionID);
793 }
794 else {
795 $sessionCount = SessionVirtual::countVirtualSessions($this->session->sessionID);
796 }
797
798 // there are still other virtual sessions, create a new session
799 if ($sessionCount) {
800 // save session
801 $sessionData = [
802 'sessionID' => bin2hex(\random_bytes(20)),
803 'userID' => $user->userID,
804 'ipAddress' => UserUtil::getIpAddress(),
805 'userAgent' => UserUtil::getUserAgent(),
806 'lastActivityTime' => TIME_NOW,
807 'requestURI' => UserUtil::getRequestURI(),
808 'requestMethod' => !empty($_SERVER['REQUEST_METHOD']) ? substr($_SERVER['REQUEST_METHOD'], 0, 7) : ''
809 ];
810
811 $this->session = call_user_func([$this->sessionEditorClassName, 'create'], $sessionData);
812
813 HeaderUtil::setCookie('cookieHash'.$this->cookieSuffix, $this->session->sessionID);
814 }
815 else {
816 // this was the last virtual session, re-use current session
817 // update session
818 $sessionEditor = new $this->sessionEditorClassName($this->session);
819 $sessionEditor->update([
820 'userID' => $user->userID
821 ]);
822 }
823 break;
824
825 //
826 // guest -> user (login)
827 //
828 default:
829 if (!$this->supportsVirtualSessions) {
830 // delete all other sessions of this user
831 call_user_func([$this->sessionEditorClassName, 'deleteUserSessions'], [$user->userID]);
832 }
833
834 // find existing session for this user
835 $session = call_user_func([$this->sessionClassName, 'getSessionByUserID'], $user->userID);
836
837 // no session exists, re-use current session
838 if ($session === null) {
839 // update session
840 $sessionEditor = new $this->sessionEditorClassName($this->session);
841
842 try {
843 $this->register('__changeSessionID', true);
844
845 $sessionEditor->update([
846 'userID' => $user->userID
847 ]);
848 }
849 catch (DatabaseException $e) {
850 // MySQL error 23000 = unique key
851 // do not check against the message itself, some weird systems localize them
852 if ($e->getCode() == 23000) {
853 // delete guest session
854 $sessionEditor = new $this->sessionEditorClassName($this->session);
855 $sessionEditor->delete();
856
857 // inherit existing session
858 $this->session = $session;
859 }
860 else {
861 // not our business
862 throw $e;
863 }
864 }
865 }
866 else {
867 // delete guest session
868 $sessionEditor = new $this->sessionEditorClassName($this->session);
869 $sessionEditor->delete();
870
871 // inherit existing session
872 $this->session = $session;
873
874 // inherit security token
875 $variables = @unserialize($this->session->sessionVariables);
876 if (is_array($variables) && !empty($variables['__SECURITY_TOKEN'])) {
877 $this->register('__SECURITY_TOKEN', $variables['__SECURITY_TOKEN']);
878 }
879
880 HeaderUtil::setCookie('cookieHash'.$this->cookieSuffix, $this->session->sessionID);
881 }
882 break;
883 }
884
885 $this->loadVirtualSession(true);
886 }
887
888 /**
889 * Updates user session on shutdown.
890 */
891 public function update() {
892 if ($this->doNotUpdate) return;
893
894 // set up data
895 $data = [
896 'ipAddress' => UserUtil::getIpAddress(),
897 'userAgent' => $this->userAgent,
898 'requestURI' => $this->requestURI,
899 'requestMethod' => $this->requestMethod,
900 'lastActivityTime' => TIME_NOW
901 ];
902 if ($this->variablesChanged) {
903 $data['sessionVariables'] = serialize($this->variables);
904 }
905 if (!class_exists('wcf\system\CLIWCF', false) && !$this->isACP && !$this->disableTracking) {
906 $pageLocations = PageLocationManager::getInstance()->getLocations();
907 if (isset($pageLocations[0])) {
908 $data['pageID'] = $pageLocations[0]['pageID'];
909 $data['pageObjectID'] = ($pageLocations[0]['pageObjectID'] ?: null);
910 $data['parentPageID'] = null;
911 $data['parentPageObjectID'] = null;
912
913 for ($i = 1, $length = count($pageLocations); $i < $length; $i++) {
914 if (!empty($pageLocations[$i]['useAsParentLocation'])) {
915 $data['parentPageID'] = $pageLocations[$i]['pageID'];
916 $data['parentPageObjectID'] = ($pageLocations[$i]['pageObjectID'] ?: null);
917 break;
918 }
919 }
920 }
921 }
922
923 // update session
924 /** @var \wcf\data\DatabaseObjectEditor $sessionEditor */
925 $sessionEditor = new $this->sessionEditorClassName($this->session);
926 $sessionEditor->update($data);
927
928 if ($this->virtualSession instanceof ACPSessionVirtual) {
929 if ($this->isACP) {
930 $virtualSessionEditor = new ACPSessionVirtualEditor($this->virtualSession);
931 }
932 else {
933 $virtualSessionEditor = new SessionVirtualEditor($this->virtualSession);
934 }
935
936 $virtualSessionEditor->updateLastActivityTime();
937 }
938 }
939
940 /**
941 * Updates last activity time to protect session from expiring.
942 */
943 public function keepAlive() {
944 $this->disableUpdate();
945
946 // update last activity time
947 /** @var \wcf\data\DatabaseObjectEditor $sessionEditor */
948 $sessionEditor = new $this->sessionEditorClassName($this->session);
949 $sessionEditor->update([
950 'lastActivityTime' => TIME_NOW
951 ]);
952
953 if ($this->virtualSession instanceof ACPSessionVirtual) {
954 if ($this->isACP) {
955 $virtualSessionEditor = new ACPSessionVirtualEditor($this->virtualSession);
956 }
957 else {
958 $virtualSessionEditor = new SessionVirtualEditor($this->virtualSession);
959 }
960 $virtualSessionEditor->updateLastActivityTime();
961 }
962 }
963
964 /**
965 * Deletes this session and it's related data.
966 */
967 public function delete() {
968 // clear storage
969 if ($this->user->userID) {
970 self::resetSessions([$this->user->userID]);
971
972 // update last activity time
973 if (!$this->isACP) {
974 $editor = new UserEditor($this->user);
975 $editor->update(['lastActivityTime' => TIME_NOW]);
976 }
977 }
978
979 // 1st: Change user to guest, otherwise other the entire session, including
980 // all virtual sessions of the user will be deleted
981 $this->changeUser(new User(null));
982
983 // 2nd: Actually remove session
984 /** @var \wcf\data\DatabaseObjectEditor $sessionEditor */
985 $sessionEditor = new $this->sessionEditorClassName($this->session);
986 $sessionEditor->delete();
987
988 // disable update
989 $this->disableUpdate();
990 }
991
992 /**
993 * Deletes this session if:
994 * - it is newly created in this request, and
995 * - it belongs to a guest.
996 *
997 * This method is useful if you have controllers that are likely to be
998 * accessed by a user agent that is not going to re-use sessions (e.g.
999 * curl in a cronjob). It immediately remove the session that was created
1000 * just for that request and that is not going to be used ever again.
1001 *
1002 * @since 5.2
1003 */
1004 public function deleteIfNew() {
1005 if ($this->isFirstVisit() && !$this->getUser()->userID) {
1006 $this->delete();
1007 }
1008 }
1009
1010 /**
1011 * Returns currently active language id.
1012 *
1013 * @return integer
1014 */
1015 public function getLanguageID() {
1016 return $this->languageID;
1017 }
1018
1019 /**
1020 * Sets the currently active language id.
1021 *
1022 * @param integer $languageID
1023 */
1024 public function setLanguageID($languageID) {
1025 $this->languageID = $languageID;
1026 $this->register('languageID', $this->languageID);
1027 }
1028
1029 /**
1030 * Returns currently active style id.
1031 *
1032 * @return integer
1033 */
1034 public function getStyleID() {
1035 return $this->styleID;
1036 }
1037
1038 /**
1039 * Sets the currently active style id.
1040 *
1041 * @param integer $styleID
1042 */
1043 public function setStyleID($styleID) {
1044 $this->styleID = $styleID;
1045 $this->register('styleID', $this->styleID);
1046 }
1047
1048 /**
1049 * Resets session-specific storage data.
1050 *
1051 * @param integer[] $userIDs
1052 */
1053 public static function resetSessions(array $userIDs = []) {
1054 if (!empty($userIDs)) {
1055 UserStorageHandler::getInstance()->reset($userIDs, 'groupIDs');
1056 UserStorageHandler::getInstance()->reset($userIDs, 'languageIDs');
1057 }
1058 else {
1059 UserStorageHandler::getInstance()->resetAll('groupIDs');
1060 UserStorageHandler::getInstance()->resetAll('languageIDs');
1061 }
1062 }
1063
1064 /**
1065 * Returns the spider id for given user agent.
1066 *
1067 * @param string $userAgent
1068 * @return mixed
1069 */
1070 protected function getSpiderID($userAgent) {
1071 $spiderList = SpiderCacheBuilder::getInstance()->getData();
1072 $userAgent = strtolower($userAgent);
1073
1074 foreach ($spiderList as $spider) {
1075 if (strpos($userAgent, $spider->spiderIdentifier) !== false) {
1076 return $spider->spiderID;
1077 }
1078 }
1079
1080 return null;
1081 }
1082
1083 /**
1084 * Searches for existing session of a search spider.
1085 *
1086 * @param integer $spiderID
1087 * @return \wcf\data\session\Session
1088 */
1089 protected function getExistingSpiderSession($spiderID) {
1090 $sql = "SELECT *
1091 FROM wcf".WCF_N."_session
1092 WHERE spiderID = ?
1093 AND userID IS NULL";
1094 $statement = WCF::getDB()->prepareStatement($sql);
1095 $statement->execute([$spiderID]);
1096 $row = $statement->fetchArray();
1097 if ($row !== false) {
1098 // fix session validation
1099 $row['ipAddress'] = UserUtil::getIpAddress();
1100 $row['userAgent'] = UserUtil::getUserAgent();
1101
1102 // return session object
1103 return new $this->sessionClassName(null, $row);
1104 }
1105
1106 return null;
1107 }
1108
1109 /**
1110 * Returns true if this is a new session.
1111 *
1112 * @return boolean
1113 */
1114 public function isFirstVisit() {
1115 return $this->firstVisit;
1116 }
1117 }