1<?php
2namespace TYPO3\CMS\Frontend\Authentication;
3
4/*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
18use TYPO3\CMS\Core\Configuration\Features;
19use TYPO3\CMS\Core\Database\ConnectionPool;
20use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotFoundException;
21use TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser;
22use TYPO3\CMS\Core\Utility\GeneralUtility;
23
24/**
25 * Extension class for Front End User Authentication.
26 */
27class FrontendUserAuthentication extends AbstractUserAuthentication
28{
29    /**
30     * form field with 0 or 1
31     * 1 = permanent login enabled
32     * 0 = session is valid for a browser session only
33     * @var string
34     */
35    public $formfield_permanent = 'permalogin';
36
37    /**
38     * Lifetime of anonymous session data in seconds.
39     * @var int
40     */
41    protected $sessionDataLifetime = 86400;
42
43    /**
44     * Session timeout (on the server)
45     *
46     * If >0: session-timeout in seconds.
47     * If <=0: Instant logout after login.
48     *
49     * @var int
50     */
51    public $sessionTimeout = 6000;
52
53    /**
54     * @var string
55     */
56    public $usergroup_column = 'usergroup';
57
58    /**
59     * @var string
60     */
61    public $usergroup_table = 'fe_groups';
62
63    /**
64     * @var array
65     */
66    public $groupData = [
67        'title' => [],
68        'uid' => [],
69        'pid' => []
70    ];
71
72    /**
73     * Used to accumulate the TSconfig data of the user
74     * @var array
75     */
76    public $TSdataArray = [];
77
78    /**
79     * @var array
80     */
81    public $userTS = [];
82
83    /**
84     * @var bool
85     */
86    public $userTSUpdated = false;
87
88    /**
89     * @var bool
90     */
91    public $sesData_change = false;
92
93    /**
94     * @var bool
95     */
96    public $userData_change = false;
97
98    /**
99     * @var bool
100     */
101    public $is_permanent = false;
102
103    /**
104     * @var bool
105     */
106    protected $loginHidden = false;
107
108    /**
109     * Default constructor.
110     */
111    public function __construct()
112    {
113        parent::__construct();
114
115        // Disable cookie by default, will be activated if saveSessionData() is called,
116        // a user is logging-in or an existing session is found
117        $this->dontSetCookie = true;
118
119        $this->name = self::getCookieName();
120        $this->get_name = 'ftu';
121        $this->loginType = 'FE';
122        $this->user_table = 'fe_users';
123        $this->username_column = 'username';
124        $this->userident_column = 'password';
125        $this->userid_column = 'uid';
126        $this->lastLogin_column = 'lastlogin';
127        $this->enablecolumns = [
128            'deleted' => 'deleted',
129            'disabled' => 'disable',
130            'starttime' => 'starttime',
131            'endtime' => 'endtime'
132        ];
133        $this->formfield_uname = 'user';
134        $this->formfield_uident = 'pass';
135        $this->formfield_status = 'logintype';
136        $this->sendNoCacheHeaders = false;
137        $this->getFallBack = true;
138        $this->getMethodEnabled = true;
139        $this->lockIP = $GLOBALS['TYPO3_CONF_VARS']['FE']['lockIP'];
140        $this->checkPid = $GLOBALS['TYPO3_CONF_VARS']['FE']['checkFeUserPid'];
141        $this->lifetime = (int)$GLOBALS['TYPO3_CONF_VARS']['FE']['lifetime'];
142        $this->sessionTimeout = (int)$GLOBALS['TYPO3_CONF_VARS']['FE']['sessionTimeout'];
143    }
144
145    /**
146     * Returns the configured cookie name
147     *
148     * @return string
149     */
150    public static function getCookieName()
151    {
152        $configuredCookieName = trim($GLOBALS['TYPO3_CONF_VARS']['FE']['cookieName']);
153        if (empty($configuredCookieName)) {
154            $configuredCookieName = 'fe_typo_user';
155        }
156        return $configuredCookieName;
157    }
158
159    /**
160     * Starts a user session
161     *
162     * @see AbstractUserAuthentication::start()
163     */
164    public function start()
165    {
166        if ($this->sessionTimeout > 0 && $this->sessionTimeout < $this->lifetime) {
167            // If server session timeout is non-zero but less than client session timeout: Copy this value instead.
168            $this->sessionTimeout = $this->lifetime;
169        }
170        $this->sessionDataLifetime = (int)$GLOBALS['TYPO3_CONF_VARS']['FE']['sessionDataLifetime'];
171        if ($this->sessionDataLifetime <= 0) {
172            $this->sessionDataLifetime = 86400;
173        }
174        parent::start();
175    }
176
177    /**
178     * Returns a new session record for the current user for insertion into the DB.
179     *
180     * @param array $tempuser
181     * @return array User session record
182     */
183    public function getNewSessionRecord($tempuser)
184    {
185        $insertFields = parent::getNewSessionRecord($tempuser);
186        $insertFields['ses_permanent'] = $this->is_permanent ? 1 : 0;
187        return $insertFields;
188    }
189
190    /**
191     * Determine whether a session cookie needs to be set (lifetime=0)
192     *
193     * @return bool
194     * @internal
195     */
196    public function isSetSessionCookie()
197    {
198        return ($this->newSessionID || $this->forceSetCookie)
199            && ((int)$this->lifetime === 0 || !isset($this->user['ses_permanent']) || !$this->user['ses_permanent']);
200    }
201
202    /**
203     * Determine whether a non-session cookie needs to be set (lifetime>0)
204     *
205     * @return bool
206     * @internal
207     */
208    public function isRefreshTimeBasedCookie()
209    {
210        return $this->lifetime > 0 && isset($this->user['ses_permanent']) && $this->user['ses_permanent'];
211    }
212
213    /**
214     * Returns an info array with Login/Logout data submitted by a form or params
215     *
216     * @return array
217     * @see AbstractUserAuthentication::getLoginFormData()
218     */
219    public function getLoginFormData()
220    {
221        $loginData = parent::getLoginFormData();
222        if ($GLOBALS['TYPO3_CONF_VARS']['FE']['permalogin'] == 0 || $GLOBALS['TYPO3_CONF_VARS']['FE']['permalogin'] == 1) {
223            if ($this->getMethodEnabled) {
224                $isPermanent = GeneralUtility::_GP($this->formfield_permanent);
225            } else {
226                $isPermanent = GeneralUtility::_POST($this->formfield_permanent);
227            }
228            if (strlen($isPermanent) != 1) {
229                $isPermanent = $GLOBALS['TYPO3_CONF_VARS']['FE']['permalogin'];
230            } elseif (!$isPermanent) {
231                // To make sure the user gets a session cookie and doesn't keep a possibly existing time based cookie,
232                // we need to force setting the session cookie here
233                $this->forceSetCookie = true;
234            }
235            $isPermanent = (bool)$isPermanent;
236        } elseif ($GLOBALS['TYPO3_CONF_VARS']['FE']['permalogin'] == 2) {
237            $isPermanent = true;
238        } else {
239            $isPermanent = false;
240        }
241        $loginData['permanent'] = $isPermanent;
242        $this->is_permanent = $isPermanent;
243        return $loginData;
244    }
245
246    /**
247     * Creates a user session record and returns its values.
248     * However, as the FE user cookie is normally not set, this has to be done
249     * before the parent class is doing the rest.
250     *
251     * @param array $tempuser User data array
252     * @return array The session data for the newly created session.
253     */
254    public function createUserSession($tempuser)
255    {
256        // At this point we do not know if we need to set a session or a permanent cookie
257        // So we force the cookie to be set after authentication took place, which will
258        // then call setSessionCookie(), which will set a cookie with correct settings.
259        $this->dontSetCookie = false;
260        return parent::createUserSession($tempuser);
261    }
262
263    /**
264     * Will select all fe_groups records that the current fe_user is member of
265     * and which groups are also allowed in the current domain.
266     * It also accumulates the TSconfig for the fe_user/fe_groups in ->TSdataArray
267     *
268     * @return int Returns the number of usergroups for the frontend users (if the internal user record exists and the usergroup field contains a value)
269     */
270    public function fetchGroupData()
271    {
272        $this->TSdataArray = [];
273        $this->userTS = [];
274        $this->userTSUpdated = false;
275        $this->groupData = [
276            'title' => [],
277            'uid' => [],
278            'pid' => []
279        ];
280        // Setting default configuration:
281        $this->TSdataArray[] = $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultUserTSconfig'];
282        // Get the info data for auth services
283        $authInfo = $this->getAuthInfoArray();
284        if (is_array($this->user)) {
285            $this->logger->debug('Get usergroups for user', [
286                $this->userid_column => $this->user[$this->userid_column],
287                $this->username_column => $this->user[$this->username_column]
288            ]);
289        } else {
290            $this->logger->debug('Get usergroups for "anonymous" user');
291        }
292        $groupDataArr = [];
293        // Use 'auth' service to find the groups for the user
294        $serviceChain = '';
295        $subType = 'getGroups' . $this->loginType;
296        while (is_object($serviceObj = GeneralUtility::makeInstanceService('auth', $subType, $serviceChain))) {
297            $serviceChain .= ',' . $serviceObj->getServiceKey();
298            $serviceObj->initAuth($subType, [], $authInfo, $this);
299            $groupData = $serviceObj->getGroups($this->user, $groupDataArr);
300            if (is_array($groupData) && !empty($groupData)) {
301                // Keys in $groupData should be unique ids of the groups (like "uid") so this function will override groups.
302                $groupDataArr = $groupData + $groupDataArr;
303            }
304            unset($serviceObj);
305        }
306        if ($serviceChain) {
307            $this->logger->debug($subType . ' auth services called: ' . $serviceChain);
308        }
309        if (empty($groupDataArr)) {
310            $this->logger->debug('No usergroups found by services');
311        }
312        if (!empty($groupDataArr)) {
313            $this->logger->debug(count($groupDataArr) . ' usergroup records found by services');
314        }
315        // Use 'auth' service to check the usergroups if they are really valid
316        foreach ($groupDataArr as $groupData) {
317            // By default a group is valid
318            $validGroup = true;
319            $serviceChain = '';
320            $subType = 'authGroups' . $this->loginType;
321            while (is_object($serviceObj = GeneralUtility::makeInstanceService('auth', $subType, $serviceChain))) {
322                $serviceChain .= ',' . $serviceObj->getServiceKey();
323                $serviceObj->initAuth($subType, [], $authInfo, $this);
324                if (!$serviceObj->authGroup($this->user, $groupData)) {
325                    $validGroup = false;
326                    $this->logger->debug($subType . ' auth service did not auth group', [
327                        'uid ' => $groupData['uid'],
328                        'title' => $groupData['title']
329                    ]);
330                    break;
331                }
332                unset($serviceObj);
333            }
334            unset($serviceObj);
335            if ($validGroup && (string)$groupData['uid'] !== '') {
336                $this->groupData['title'][$groupData['uid']] = $groupData['title'];
337                $this->groupData['uid'][$groupData['uid']] = $groupData['uid'];
338                $this->groupData['pid'][$groupData['uid']] = $groupData['pid'];
339                $this->groupData['TSconfig'][$groupData['uid']] = $groupData['TSconfig'];
340            }
341        }
342        if (!empty($this->groupData) && !empty($this->groupData['TSconfig'])) {
343            // TSconfig: collect it in the order it was collected
344            foreach ($this->groupData['TSconfig'] as $TSdata) {
345                $this->TSdataArray[] = $TSdata;
346            }
347            $this->TSdataArray[] = $this->user['TSconfig'];
348            // Sort information
349            ksort($this->groupData['title']);
350            ksort($this->groupData['uid']);
351            ksort($this->groupData['pid']);
352        }
353        return !empty($this->groupData['uid']) ? count($this->groupData['uid']) : 0;
354    }
355
356    /**
357     * Returns the parsed TSconfig for the fe_user
358     * The TSconfig will be cached in $this->userTS.
359     *
360     * @return array TSconfig array for the fe_user
361     */
362    public function getUserTSconf()
363    {
364        if (!$this->userTSUpdated) {
365            // Parsing the user TS (or getting from cache)
366            $this->TSdataArray = TypoScriptParser::checkIncludeLines_array($this->TSdataArray);
367            $userTS = implode(LF . '[GLOBAL]' . LF, $this->TSdataArray);
368            $parseObj = GeneralUtility::makeInstance(TypoScriptParser::class);
369            $parseObj->parse($userTS);
370            $this->userTS = $parseObj->setup;
371            $this->userTSUpdated = true;
372        }
373        return $this->userTS;
374    }
375
376    /*****************************************
377     *
378     * Session data management functions
379     *
380     ****************************************/
381    /**
382     * Will write UC and session data.
383     * If the flag $this->userData_change has been set, the function ->writeUC is called (which will save persistent user session data)
384     * If the flag $this->sesData_change has been set, the current session record is updated with the content of $this->sessionData
385     *
386     * @see getKey(), setKey()
387     */
388    public function storeSessionData()
389    {
390        // Saves UC and SesData if changed.
391        if ($this->userData_change) {
392            $this->writeUC();
393        }
394
395        if ($this->sesData_change && $this->id) {
396            if (empty($this->sessionData)) {
397                // Remove session-data
398                $this->removeSessionData();
399                // Remove cookie if not logged in as the session data is removed as well
400                if (empty($this->user['uid']) && !$this->loginHidden && $this->isCookieSet()) {
401                    $this->removeCookie($this->name);
402                }
403            } elseif (!$this->isExistingSessionRecord($this->id)) {
404                $sessionRecord = $this->getNewSessionRecord([]);
405                $sessionRecord['ses_anonymous'] = 1;
406                $sessionRecord['ses_data'] = serialize($this->sessionData);
407                $updatedSession = $this->getSessionBackend()->set($this->id, $sessionRecord);
408                $this->user = array_merge($this->user ?? [], $updatedSession);
409                // Now set the cookie (= fix the session)
410                $this->setSessionCookie();
411            } else {
412                // Update session data
413                $insertFields = [
414                    'ses_data' => serialize($this->sessionData)
415                ];
416                $updatedSession = $this->getSessionBackend()->update($this->id, $insertFields);
417                $this->user = array_merge($this->user ?? [], $updatedSession);
418            }
419        }
420    }
421
422    /**
423     * Removes data of the current session.
424     */
425    public function removeSessionData()
426    {
427        if (!empty($this->sessionData)) {
428            $this->sesData_change = true;
429        }
430        $this->sessionData = [];
431
432        if ($this->isExistingSessionRecord($this->id)) {
433            // Remove session record if $this->user is empty is if session is anonymous
434            if ((empty($this->user) && !$this->loginHidden) || $this->user['ses_anonymous']) {
435                $this->getSessionBackend()->remove($this->id);
436            } else {
437                $this->getSessionBackend()->update($this->id, [
438                    'ses_data' => ''
439                ]);
440            }
441        }
442    }
443
444    /**
445     * Removes the current session record, sets the internal ->user array to null,
446     * Thereby the current user (if any) is effectively logged out!
447     * Additionally the cookie is removed, but only if there is no session data.
448     * If session data exists, only the user information is removed and the session
449     * gets converted into an anonymous session if the feature toggle
450     * "security.frontend.keepSessionDataOnLogout" is set to true (default: false).
451     */
452    protected function performLogoff()
453    {
454        $sessionData = [];
455        try {
456            // Session might not be loaded at this point, so fetch it
457            $oldSession = $this->getSessionBackend()->get($this->id);
458            $sessionData = unserialize($oldSession['ses_data']);
459        } catch (SessionNotFoundException $e) {
460            // Leave uncaught, will unset cookie later in this method
461        }
462
463        $keepSessionDataOnLogout = GeneralUtility::makeInstance(Features::class)
464            ->isFeatureEnabled('security.frontend.keepSessionDataOnLogout');
465
466        if ($keepSessionDataOnLogout && !empty($sessionData)) {
467            // Regenerate session as anonymous
468            $this->regenerateSessionId($oldSession, true);
469            $this->user = null;
470        } else {
471            parent::performLogoff();
472            if ($this->isCookieSet()) {
473                $this->removeCookie($this->name);
474            }
475        }
476    }
477
478    /**
479     * Regenerate the session ID and transfer the session to new ID
480     * Call this method whenever a user proceeds to a higher authorization level
481     * e.g. when an anonymous session is now authenticated.
482     * Forces cookie to be set
483     *
484     * @param array $existingSessionRecord If given, this session record will be used instead of fetching again'
485     * @param bool $anonymous If true session will be regenerated as anonymous session
486     */
487    protected function regenerateSessionId(array $existingSessionRecord = [], bool $anonymous = false)
488    {
489        if (empty($existingSessionRecord)) {
490            $existingSessionRecord = $this->getSessionBackend()->get($this->id);
491        }
492        $existingSessionRecord['ses_anonymous'] = (int)$anonymous;
493        if ($anonymous) {
494            $existingSessionRecord['ses_userid'] = 0;
495        }
496        parent::regenerateSessionId($existingSessionRecord, $anonymous);
497        // We force the cookie to be set later in the authentication process
498        $this->dontSetCookie = false;
499    }
500
501    /**
502     * Returns session data for the fe_user; Either persistent data following the fe_users uid/profile (requires login)
503     * or current-session based (not available when browse is closed, but does not require login)
504     *
505     * @param string $type Session data type; Either "user" (persistent, bound to fe_users profile) or "ses" (temporary, bound to current session cookie)
506     * @param string $key Key from the data array to return; The session data (in either case) is an array ($this->uc / $this->sessionData) and this value determines which key to return the value for.
507     * @return mixed Returns whatever value there was in the array for the key, $key
508     * @see setKey()
509     */
510    public function getKey($type, $key)
511    {
512        if (!$key) {
513            return null;
514        }
515        $value = null;
516        switch ($type) {
517            case 'user':
518                $value = $this->uc[$key];
519                break;
520            case 'ses':
521                $value = $this->getSessionData($key);
522                break;
523        }
524        return $value;
525    }
526
527    /**
528     * Saves session data, either persistent or bound to current session cookie. Please see getKey() for more details.
529     * When a value is set the flags $this->userData_change or $this->sesData_change will be set so that the final call to ->storeSessionData() will know if a change has occurred and needs to be saved to the database.
530     * Notice: Simply calling this function will not save the data to the database! The actual saving is done in storeSessionData() which is called as some of the last things in \TYPO3\CMS\Frontend\Http\RequestHandler. So if you exit before this point, nothing gets saved of course! And the solution is to call $GLOBALS['TSFE']->storeSessionData(); before you exit.
531     *
532     * @param string $type Session data type; Either "user" (persistent, bound to fe_users profile) or "ses" (temporary, bound to current session cookie)
533     * @param string $key Key from the data array to store incoming data in; The session data (in either case) is an array ($this->uc / $this->sessionData) and this value determines in which key the $data value will be stored.
534     * @param mixed $data The data value to store in $key
535     * @see setKey(), storeSessionData()
536     */
537    public function setKey($type, $key, $data)
538    {
539        if (!$key) {
540            return;
541        }
542        switch ($type) {
543            case 'user':
544                if ($this->user['uid']) {
545                    if ($data === null) {
546                        unset($this->uc[$key]);
547                    } else {
548                        $this->uc[$key] = $data;
549                    }
550                    $this->userData_change = true;
551                }
552                break;
553            case 'ses':
554                $this->setSessionData($key, $data);
555                break;
556        }
557    }
558
559    /**
560     * Set session data by key.
561     * The data will last only for this login session since it is stored in the user session.
562     *
563     * @param string $key A non empty string to store the data under
564     * @param mixed $data Data store store in session
565     */
566    public function setSessionData($key, $data)
567    {
568        $this->sesData_change = true;
569        if ($data === null) {
570            unset($this->sessionData[$key]);
571            return;
572        }
573        parent::setSessionData($key, $data);
574    }
575
576    /**
577     * Saves the tokens so that they can be used by a later incarnation of this class.
578     *
579     * @param string $key
580     * @param mixed $data
581     */
582    public function setAndSaveSessionData($key, $data)
583    {
584        $this->setSessionData($key, $data);
585        $this->storeSessionData();
586    }
587
588    /**
589     * Garbage collector, removing old expired sessions.
590     *
591     * @internal
592     */
593    public function gc()
594    {
595        $this->getSessionBackend()->collectGarbage($this->gc_time, $this->sessionDataLifetime);
596    }
597
598    /**
599     * Hide the current login
600     *
601     * This is used by the fe_login_mode feature for pages.
602     * A current login is unset, but we remember that there has been one.
603     */
604    public function hideActiveLogin()
605    {
606        $this->user = null;
607        $this->loginHidden = true;
608    }
609
610    /**
611     * Update the field "is_online" every 60 seconds of a logged-in user
612     *
613     * @internal
614     */
615    public function updateOnlineTimestamp()
616    {
617        if (!is_array($this->user) || !$this->user['uid']
618            || $this->user['is_online'] >= $GLOBALS['EXEC_TIME'] - 60) {
619            return;
620        }
621        $dbConnection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('fe_users');
622        $dbConnection->update(
623            'fe_users',
624            ['is_online' => $GLOBALS['EXEC_TIME']],
625            ['uid' => (int)$this->user['uid']]
626        );
627        $this->user['is_online'] = $GLOBALS['EXEC_TIME'];
628    }
629}
630