SessionService.php 16.4 KB
Newer Older
1
<?php
2

3
/*
4
 * This file is part of the TYPO3 CMS project.
5
 *
6
7
8
 * It is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, either version 2
 * of the License, or any later version.
9
 *
10
11
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
12
 *
13
14
 * The TYPO3 project - inspiring people to share!
 */
15

16
17
namespace TYPO3\CMS\Install\Service;

18
use Symfony\Component\HttpFoundation\Cookie;
19
use TYPO3\CMS\Core\Core\Environment;
20
21
22
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\DefaultRestrictionContainer;
use TYPO3\CMS\Core\Database\Query\Restriction\RootLevelRestriction;
23
use TYPO3\CMS\Core\Messaging\FlashMessage;
24
use TYPO3\CMS\Core\Security\BlockSerializationTrait;
25
26
27
28
use TYPO3\CMS\Core\Session\Backend\HashableSessionBackendInterface;
use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface;
use TYPO3\CMS\Core\Session\SessionManager;
use TYPO3\CMS\Core\Session\UserSession;
29
use TYPO3\CMS\Core\SingletonInterface;
30
use TYPO3\CMS\Core\Utility\GeneralUtility;
31
use TYPO3\CMS\Install\Exception;
32
use TYPO3\CMS\Install\Service\Session\FileSessionHandler;
33

34
35
/**
 * Secure session handling for the install tool.
36
 *
37
 * @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API.
38
 */
39
class SessionService implements SingletonInterface
40
{
41
    use BlockSerializationTrait;
42

43
44
45
46
47
48
49
50
51
52
53
54
    /**
     * the cookie to store the session ID of the install tool
     *
     * @var string
     */
    private $cookieName = 'Typo3InstallTool';

    /**
     * time (minutes) to expire an unused session
     *
     * @var int
     */
55
    private $expireTimeInMinutes = 15;
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71

    /**
     * time (minutes) to generate a new session id for our current session
     *
     * @var int
     */
    private $regenerateSessionIdTime = 5;

    /**
     * Constructor. Starts PHP session handling in our own private store
     *
     * Side-effect: might set a cookie, so must be called before any other output.
     */
    public function __construct()
    {
        // Register our "save" session handler
72
73
74
75
76
77
        $sessionHandler = GeneralUtility::makeInstance(
            FileSessionHandler::class,
            Environment::getVarPath() . '/session',
            $this->expireTimeInMinutes
        );
        session_set_save_handler($sessionHandler);
78
        session_name($this->cookieName);
79
        ini_set('session.cookie_secure', GeneralUtility::getIndpEnv('TYPO3_SSL') ? 'On' : 'Off');
80
        ini_set('session.cookie_httponly', 'On');
81
        ini_set('session.cookie_samesite', Cookie::SAMESITE_STRICT);
82
        ini_set('session.cookie_path', (string)GeneralUtility::getIndpEnv('TYPO3_SITE_PATH'));
83
        // Always call the garbage collector to clean up stale session files
84
85
        ini_set('session.gc_probability', (string)100);
        ini_set('session.gc_divisor', (string)100);
86
        ini_set('session.gc_maxlifetime', (string)($this->expireTimeInMinutes * 2 * 60));
87
        if ($this->isSessionAutoStartEnabled()) {
88
89
90
            $sessionCreationError = 'Error: session.auto-start is enabled.<br />';
            $sessionCreationError .= 'The PHP option session.auto-start is enabled. Disable this option in php.ini or .htaccess:<br />';
            $sessionCreationError .= '<pre>php_value session.auto_start Off</pre>';
91
            throw new Exception($sessionCreationError, 1294587485);
92
        }
93
        if (session_status() === PHP_SESSION_ACTIVE) {
94
95
            $sessionCreationError = 'Session already started by session_start().<br />';
            $sessionCreationError .= 'Make sure no installed extension is starting a session in its ext_localconf.php or ext_tables.php.';
96
            throw new Exception($sessionCreationError, 1294587486);
97
        }
98
99
100
101
102
103
104
    }

    public function initializeSession()
    {
        if (session_status() === PHP_SESSION_ACTIVE) {
            return;
        }
105
        session_start();
106
107
    }

108
109
110
    /**
     * Starts a new session
     *
111
     * @return string|false The session ID
112
113
114
     */
    public function startSession()
    {
115
116
117
118
119
        $this->initializeSession();
        // check if session is already active
        if ($_SESSION['active'] ?? false) {
            return session_id();
        }
120
121
122
123
124
125
126
127
128
129
        $_SESSION['active'] = true;
        // Be sure to use our own session id, so create a new one
        return $this->renewSession();
    }

    /**
     * Destroys a session
     */
    public function destroySession()
    {
130
131
132
133
        if ($this->hasSessionCookie()) {
            $this->initializeSession();
            $_SESSION = [];
            $params = session_get_cookie_params();
134
135
136
137
138
139
140
141
142
            $cookie = Cookie::create(($sessionName = session_name()) !== false ? $sessionName : $this->cookieName)
                ->withValue('0')
                ->withPath($params['path'])
                ->withDomain($params['domain'])
                ->withSecure($params['samesite'] === Cookie::SAMESITE_NONE || GeneralUtility::getIndpEnv('TYPO3_SSL'))
                ->withHttpOnly($params['httponly'])
                ->withSameSite($params['samesite']);

            header('Set-Cookie: ' . $cookie);
143
144
            session_destroy();
        }
145
146
147
148
149
150
151
    }

    /**
     * Reset session. Sets _SESSION to empty array.
     */
    public function resetSession()
    {
152
        $this->initializeSession();
153
        $_SESSION = [];
154
155
156
157
158
159
        $_SESSION['active'] = false;
    }

    /**
     * Generates a new session ID and sends it to the client.
     *
160
     * @return string|false the new session ID
161
162
163
     */
    private function renewSession()
    {
164
165
        // we do not have parallel ajax requests so we can safely remove the old session data
        session_regenerate_id(true);
166
167
168
169
        return session_id();
    }

    /**
170
     * Checks whether whether is session cookie is set
171
     *
172
     * @return bool
173
     */
174
    public function hasSessionCookie(): bool
175
    {
176
        return isset($_COOKIE[$this->cookieName]);
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
    }

    /**
     * Marks this session as an "authorized" one (login successful).
     * Should only be called if:
     * a) we have a valid session running
     * b) the "password" or some other authorization mechanism really matched
     */
    public function setAuthorized()
    {
        $_SESSION['authorized'] = true;
        $_SESSION['lastSessionId'] = time();
        $_SESSION['tstamp'] = time();
        $_SESSION['expires'] = time() + $this->expireTimeInMinutes * 60;
        // Renew the session id to avoid session fixation
        $this->renewSession();
    }

195
196
197
    /**
     * Marks this session as an "authorized by backend user" one.
     * This is called by BackendModuleController from backend context.
198
199
     *
     * @param UserSession $userSession session of the current backend user
200
     */
201
    public function setAuthorizedBackendSession(UserSession $userSession)
202
    {
203
204
205
206
207
208
209
        $nonce = bin2hex(random_bytes(20));
        $sessionBackend = $this->getBackendUserSessionBackend();
        // use hash mechanism of session backend, or pass plain value through generic hmac
        $sessionHmac = $sessionBackend instanceof HashableSessionBackendInterface
            ? $sessionBackend->hash($userSession->getIdentifier())
            : hash_hmac('sha256', $userSession->getIdentifier(), $nonce);

210
211
212
213
214
        $_SESSION['authorized'] = true;
        $_SESSION['lastSessionId'] = time();
        $_SESSION['tstamp'] = time();
        $_SESSION['expires'] = time() + $this->expireTimeInMinutes * 60;
        $_SESSION['isBackendSession'] = true;
215
216
217
218
219
        $_SESSION['backendUserSession'] = [
            'nonce' => $nonce,
            'userId' => $userSession->getUserId(),
            'hmac' => $sessionHmac,
        ];
220
221
222
223
        // Renew the session id to avoid session fixation
        $this->renewSession();
    }

224
225
226
227
228
229
230
    /**
     * Check if we have an already authorized session
     *
     * @return bool TRUE if this session has been authorized before (by a correct password)
     */
    public function isAuthorized()
    {
231
        if (!$this->hasSessionCookie()) {
232
233
            return false;
        }
234
235
        $this->initializeSession();
        if (empty($_SESSION['authorized'])) {
236
237
            return false;
        }
238
        return !$this->isExpired();
239
240
    }

241
242
243
244
245
    /**
     * Check if we have an authorized session from a system maintainer
     *
     * @return bool TRUE if this session has been authorized before and initialized by a backend system maintainer
     */
246
    public function isAuthorizedBackendUserSession(): bool
247
    {
248
        if (!$this->hasSessionCookie()) {
249
250
            return false;
        }
251
252
        $this->initializeSession();
        if (empty($_SESSION['authorized']) || empty($_SESSION['isBackendSession'])) {
253
254
            return false;
        }
255
        return !$this->isExpired();
256
257
    }

258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
    /**
     * Evaluates whether the backend user that initiated this admin tool session,
     * has an active role (is still admin & system maintainer) and has an active backend user interface session.
     *
     * @return bool whether the backend user has an active role and backend user interface session
     */
    public function hasActiveBackendUserRoleAndSession(): bool
    {
        // @see \TYPO3\CMS\Install\Controller\BackendModuleController::setAuthorizedAndRedirect()
        $backendUserSession = $this->getBackendUserSession();
        $backendUserRecord = $this->getBackendUserRecord($backendUserSession['userId']);
        if ($backendUserRecord === null || empty($backendUserRecord['uid'])) {
            return false;
        }
        $isAdmin = (($backendUserRecord['admin'] ?? 0) & 1) === 1;
        $systemMaintainers = array_map('intval', $GLOBALS['TYPO3_CONF_VARS']['SYS']['systemMaintainers'] ?? []);
274
275
276
277
        // in case no system maintainers are configured, all admin users are considered to be system maintainers
        $isSystemMaintainer = empty($systemMaintainers) || in_array((int)$backendUserRecord['uid'], $systemMaintainers, true);
        // in development context, all admin users are considered to be system maintainers
        $hasDevelopmentContext = Environment::getContext()->isDevelopment();
278
        // stop here, in case the current admin tool session does not belong to a backend user having admin & maintainer privileges
279
        if (!$isAdmin || !$hasDevelopmentContext && !$isSystemMaintainer) {
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
            return false;
        }

        $sessionBackend = $this->getBackendUserSessionBackend();
        foreach ($sessionBackend->getAll() as $sessionRecord) {
            $sessionUserId = (int)($sessionRecord['ses_userid'] ?? 0);
            // skip, in case backend user id does not match
            if ($backendUserSession['userId'] !== $sessionUserId) {
                continue;
            }
            $sessionId = (string)($sessionRecord['ses_id'] ?? '');
            // use persisted hashed `ses_id` directly, or pass through hmac for plain values
            $sessionHmac = $sessionBackend instanceof HashableSessionBackendInterface
                ? $sessionId
                : hash_hmac('sha256', $sessionId, $backendUserSession['nonce']);
            // skip, in case backend user session id does not match
            if ($backendUserSession['hmac'] !== $sessionHmac) {
                continue;
            }
            // backend user id and session id matched correctly
            return true;
        }
        return false;
    }

305
306
307
308
309
310
311
312
313
    /**
     * Check if our session is expired.
     * Useful only right after a FALSE "isAuthorized" to see if this is the
     * reason for not being authorized anymore.
     *
     * @return bool TRUE if an authorized session exists, but is expired
     */
    public function isExpired()
    {
314
        if (!$this->hasSessionCookie()) {
315
316
317
            // Session never existed, means it is not "expired"
            return false;
        }
318
319
320
321
        $this->initializeSession();
        if (empty($_SESSION['authorized'])) {
            // Session never authorized, means it is not "expired"
            return false;
322
        }
323
        return $_SESSION['expires'] <= time();
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
    }

    /**
     * Refreshes our session information, rising the expire time.
     * Also generates a new session ID every 5 minutes to minimize the risk of
     * session hijacking.
     */
    public function refreshSession()
    {
        $_SESSION['tstamp'] = time();
        $_SESSION['expires'] = time() + $this->expireTimeInMinutes * 60;
        if (time() > $_SESSION['lastSessionId'] + $this->regenerateSessionIdTime * 60) {
            // Renew our session ID
            $_SESSION['lastSessionId'] = time();
            $this->renewSession();
        }
    }

    /**
     * Add a message to "Flash" message storage.
     *
345
     * @param FlashMessage $message A message to add
346
     */
347
    public function addMessage(FlashMessage $message)
348
349
    {
        if (!is_array($_SESSION['messages'])) {
350
            $_SESSION['messages'] = [];
351
352
353
354
355
356
357
        }
        $_SESSION['messages'][] = $message;
    }

    /**
     * Return stored session messages and flush.
     *
358
     * @return FlashMessage[] Messages
359
360
361
     */
    public function getMessagesAndFlush()
    {
362
        $messages = [];
363
364
365
        if (is_array($_SESSION['messages'])) {
            $messages = $_SESSION['messages'];
        }
366
        $_SESSION['messages'] = [];
367
368
369
        return $messages;
    }

370
371
372
373
374
375
376
377
378
379
380
381
382
383
    /**
     * @return array{userId: int, nonce: string, hmac: string} backend user session references
     */
    public function getBackendUserSession(): array
    {
        if (empty($_SESSION['backendUserSession'])) {
            throw new Exception(
                'The backend user session is only available if invoked via the backend user interface.',
                1624879295
            );
        }
        return $_SESSION['backendUserSession'];
    }

384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
    /**
     * Check if php session.auto_start is enabled
     *
     * @return bool TRUE if session.auto_start is enabled, FALSE if disabled
     */
    protected function isSessionAutoStartEnabled()
    {
        return $this->getIniValueBoolean('session.auto_start');
    }

    /**
     * Cast an on/off php ini value to boolean
     *
     * @param string $configOption
     * @return bool TRUE if the given option is enabled, FALSE if disabled
     */
    protected function getIniValueBoolean($configOption)
    {
402
403
404
405
406
        return filter_var(
            ini_get($configOption),
            FILTER_VALIDATE_BOOLEAN,
            [FILTER_REQUIRE_SCALAR, FILTER_NULL_ON_FAILURE]
        );
407
    }
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455

    /**
     * Fetching a user record with uid=$uid.
     * Functionally similar to TYPO3\CMS\Core\Authentication\BackendUserAuthentication::setBeUserByUid().
     *
     * @param int $uid The UID of the backend user
     * @return array<string, int>|null The backend user record or NULL
     */
    protected function getBackendUserRecord(int $uid): ?array
    {
        $restrictionContainer = GeneralUtility::makeInstance(DefaultRestrictionContainer::class);
        $restrictionContainer->add(GeneralUtility::makeInstance(RootLevelRestriction::class, ['be_users']));

        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('be_users');
        $queryBuilder->setRestrictions($restrictionContainer);
        $queryBuilder->select('uid', 'admin')
            ->from('be_users')
            ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)));

        $resetBeUsersTca = false;
        if (!isset($GLOBALS['TCA']['be_users'])) {
            // The admin tool intentionally does not load any TCA information at this time.
            // The database restictions, needs the enablecolumns TCA information
            // for 'be_users' to load the user correctly.
            // That is why this part of the TCA ($GLOBALS['TCA']['be_users']['ctrl']['enablecolumns'])
            // is simulated.
            // The simulation state will be removed later to avoid unexpected side effects.
            $GLOBALS['TCA']['be_users']['ctrl']['enablecolumns'] = [
                'rootLevel' => 1,
                'deleted' => 'deleted',
                'disabled' => 'disable',
                'starttime' => 'starttime',
                'endtime' => 'endtime',
            ];
            $resetBeUsersTca = true;
        }
        $result = $queryBuilder->executeQuery()->fetchAssociative();
        if ($resetBeUsersTca) {
            unset($GLOBALS['TCA']['be_users']);
        }

        return is_array($result) ? $result : null;
    }

    protected function getBackendUserSessionBackend(): SessionBackendInterface
    {
        return GeneralUtility::makeInstance(SessionManager::class)->getSessionBackend('BE');
    }
456
}