Passed
Push — master ( 7702d6...24264f )
by
unknown
18:55
created

UserSessionManager::isSessionPersisted()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 1
dl 0
loc 3
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 1
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of the TYPO3 CMS project.
7
 *
8
 * It is free software; you can redistribute it and/or modify it under
9
 * the terms of the GNU General Public License, either version 2
10
 * of the License, or any later version.
11
 *
12
 * For the full copyright and license information, please read the
13
 * LICENSE.txt file that was distributed with this source code.
14
 *
15
 * The TYPO3 project - inspiring people to share!
16
 */
17
18
namespace TYPO3\CMS\Core\Session;
19
20
use Psr\Http\Message\ServerRequestInterface;
21
use Psr\Log\LoggerAwareInterface;
22
use Psr\Log\LoggerAwareTrait;
23
use TYPO3\CMS\Core\Authentication\IpLocker;
24
use TYPO3\CMS\Core\Crypto\Random;
25
use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotFoundException;
26
use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface;
27
use TYPO3\CMS\Core\Utility\GeneralUtility;
28
29
/**
30
 * The purpose of the UserSessionManager is to create new user session objects (acting as a factory),
31
 * depending on the need / request, and to fetch sessions from the Session Backend, effectively
32
 * encapsulating all calls to the SessionManager
33
 */
34
class UserSessionManager implements LoggerAwareInterface
35
{
36
    use LoggerAwareTrait;
37
38
    protected const SESSION_ID_LENGTH = 32;
39
    protected const GARBAGE_COLLECTION_LIFETIME = 86400;
40
    protected const LIFETIME_OF_ANONYMOUS_SESSION_DATA = 86400;
41
42
    /**
43
     * Session timeout (on the storage-side, used to know until a session (timestamp) is valid
44
     *
45
     * If >0: session-timeout in seconds.
46
     * If =0: Instant logout after login.
47
     *
48
     * @var int
49
     */
50
    protected int $sessionLifetime;
51
52
    protected int $garbageCollectionForAnonymousSessions = self::LIFETIME_OF_ANONYMOUS_SESSION_DATA;
53
    protected SessionBackendInterface $sessionBackend;
54
    protected IpLocker $ipLocker;
55
56
    /**
57
     * Constructor. Marked as internal, as it is recommended to use the factory method "create"
58
     *
59
     * @param SessionBackendInterface $sessionBackend
60
     * @param int $sessionLifetime
61
     * @param IpLocker $ipLocker
62
     * @internal
63
     */
64
    public function __construct(SessionBackendInterface $sessionBackend, int $sessionLifetime, IpLocker $ipLocker)
65
    {
66
        $this->sessionBackend = $sessionBackend;
67
        $this->sessionLifetime = $sessionLifetime;
68
        $this->ipLocker = $ipLocker;
69
    }
70
71
    protected function setGarbageCollectionTimeoutForAnonymousSessions(int $garbageCollectionForAnonymousSessions = 0): void
72
    {
73
        if ($garbageCollectionForAnonymousSessions > 0) {
74
            $this->garbageCollectionForAnonymousSessions = $garbageCollectionForAnonymousSessions;
75
        }
76
    }
77
78
    /**
79
     * sessionId is set to ses_id if a cookie is present in the request.
80
     * Otherwise a new anonymous session will start.
81
     *
82
     * @param ServerRequestInterface $request
83
     * @param string $cookieName
84
     * @return UserSession
85
     */
86
    public function createFromRequestOrAnonymous(ServerRequestInterface $request, string $cookieName): UserSession
87
    {
88
        $sessionId = (string)($request->getCookieParams()[$cookieName] ?? '');
89
        return $this->getSessionFromSessionId($sessionId) ?? $this->createAnonymousSession();
90
    }
91
92
    /**
93
     * sessionId is set to ses_id if a cookie is present in $_COOKIE.
94
     * Otherwise a new anonymous session will start.
95
     *
96
     * @param string $cookieName
97
     * @return UserSession
98
     */
99
    public function createFromGlobalCookieOrAnonymous(string $cookieName): UserSession
100
    {
101
        $sessionId = isset($_COOKIE[$cookieName]) ? stripslashes((string)$_COOKIE[$cookieName]) : '';
102
        return $this->getSessionFromSessionId($sessionId) ?? $this->createAnonymousSession();
103
    }
104
105
    /**
106
     * Creates a non-fixated session without a user logged in
107
     *
108
     * @return UserSession
109
     */
110
    public function createAnonymousSession(): UserSession
111
    {
112
        $randomSessionId = $this->createSessionId();
113
        return UserSession::createNonFixated($randomSessionId);
114
    }
115
116
    /**
117
     * Creates a new user session object from an existing session data.
118
     *
119
     * @param string $sessionId The session id to be looked up in the session backend
120
     * @return UserSession The created user session object
121
     * @internal this is only used as a bridge for existing methods, might be removed or renamed without further notice
122
     */
123
    public function createSessionFromStorage(string $sessionId): UserSession
124
    {
125
        $this->logger->debug('Fetch session with identifier ' . $sessionId);
126
        $sessionRecord = $this->sessionBackend->get($sessionId);
127
        return UserSession::createFromRecord($sessionId, $sessionRecord);
128
    }
129
130
    /**
131
     * Check if a session has expired. This is the case if sessionLifetime is 0,
132
     * or current time greater than sessionLifetime plus last update time of the session.
133
     *
134
     * @param UserSession $session
135
     * @return bool
136
     */
137
    public function hasExpired(UserSession $session): bool
138
    {
139
        return $this->sessionLifetime === 0 || $GLOBALS['EXEC_TIME'] > $session->getLastUpdated() + $this->sessionLifetime;
140
    }
141
142
    /**
143
     * Check if a session will expire within the given grace period.
144
     *
145
     * @param UserSession $session
146
     * @param int $gracePeriod
147
     * @return bool
148
     */
149
    public function willExpire(UserSession $session, int $gracePeriod): bool
150
    {
151
        return $GLOBALS['EXEC_TIME'] >= ($session->getLastUpdated() + $this->sessionLifetime) - $gracePeriod;
152
    }
153
154
    /**
155
     * Persists an anonymous session without a user logged in,
156
     * in order to store session data between requests.
157
     *
158
     * @param UserSession $session The user session to fixate
159
     * @param bool $isPermanent If TRUE, the session will get the is_permanent flag
160
     * @return UserSession a new session object with an updated ses_tstamp (allowing to keep the session alive)
161
     *
162
     * @throws Backend\Exception\SessionNotCreatedException
163
     */
164
    public function fixateAnonymousSession(UserSession $session, bool $isPermanent = false): UserSession
165
    {
166
        $sessionIpLock = $this->ipLocker->getSessionIpLock((string)GeneralUtility::getIndpEnv('REMOTE_ADDR'));
167
        $sessionRecord = $session->toArray();
168
        $sessionRecord['ses_iplock'] = $sessionIpLock;
169
        // Ensure the user is not set, as this is always an anonymous session (see elevateToFixatedUserSession)
170
        $sessionRecord['ses_userid'] = 0;
171
        if ($isPermanent) {
172
            $sessionRecord['ses_permanent'] = 1;
173
        }
174
        // The updated session record now also contains an updated timestamp (ses_tstamp)
175
        $updatedSessionRecord = $this->sessionBackend->set($session->getIdentifier(), $sessionRecord);
176
        return $this->recreateUserSession($session, $updatedSessionRecord);
177
    }
178
179
    /**
180
     * Removes existing entries, creates and returns a new user session record
181
     *
182
     * @param UserSession $session The user session to recreate
183
     * @param int $userId The user id the session belongs to
184
     * @param bool $isPermanent If TRUE, the session will get the is_permanent flag
185
     * @return UserSession The newly created user session object
186
     *
187
     * @throws Backend\Exception\SessionNotCreatedException
188
     */
189
    public function elevateToFixatedUserSession(UserSession $session, int $userId, bool $isPermanent = false): UserSession
190
    {
191
        $sessionId = $session->getIdentifier();
192
        $this->logger->debug('Create session ses_id = ' . $sessionId);
193
        // Delete any session entry first
194
        $this->sessionBackend->remove($sessionId);
195
        // Re-create session entry
196
        $sessionIpLock = $this->ipLocker->getSessionIpLock((string)GeneralUtility::getIndpEnv('REMOTE_ADDR'));
197
        $sessionRecord = [
198
            'ses_iplock' => $sessionIpLock,
199
            'ses_userid' => $userId,
200
            'ses_tstamp' => $GLOBALS['EXEC_TIME'],
201
            'ses_data' => '',
202
        ];
203
        if ($isPermanent) {
204
            $sessionRecord['ses_permanent'] = 1;
205
        }
206
        $sessionRecord = $this->sessionBackend->set($sessionId, $sessionRecord);
207
        return UserSession::createFromRecord($sessionId, $sessionRecord, true);
208
    }
209
210
    /**
211
     * Regenerate the session ID and transfer the session to new ID
212
     * Call this method whenever a user proceeds to a higher authorization level
213
     * e.g. when an anonymous session is now authenticated.
214
     *
215
     * @param string $sessionId The session id
216
     * @param array $existingSessionRecord If given, this session record will be used instead of fetching again
217
     * @param bool $anonymous If true session will be regenerated as anonymous session
218
     * @return UserSession
219
     *
220
     * @throws Backend\Exception\SessionNotCreatedException
221
     * @throws SessionNotFoundException
222
     */
223
    public function regenerateSession(
224
        string $sessionId,
225
        array $existingSessionRecord = [],
226
        bool $anonymous = false
227
    ): UserSession {
228
        if (empty($existingSessionRecord)) {
229
            $existingSessionRecord = $this->sessionBackend->get($sessionId);
230
        }
231
        if ($anonymous) {
232
            $existingSessionRecord['ses_userid'] = 0;
233
        }
234
        // Update session record with new ID
235
        $newSessionId = $this->createSessionId();
236
        $this->sessionBackend->set($newSessionId, $existingSessionRecord);
237
        $this->sessionBackend->remove($sessionId);
238
        return UserSession::createFromRecord($newSessionId, $existingSessionRecord, true);
239
    }
240
241
    /**
242
     * Updates the session timestamp for the given user session if
243
     * the session is marked as "needs update" (which means the current
244
     * timestamp is greater than "last updated + a specified gracetime-value".
245
     *
246
     * @param UserSession $session
247
     * @return UserSession a modified user session with a last updated value if needed
248
     * @throws Backend\Exception\SessionNotUpdatedException
249
     */
250
    public function updateSessionTimestamp(UserSession $session): UserSession
251
    {
252
        if ($session->needsUpdate()) {
253
            // Update the session timestamp by writing a dummy update. (Backend will update the timestamp)
254
            $this->sessionBackend->update($session->getIdentifier(), []);
255
            $session = $this->recreateUserSession($session);
256
        }
257
        return $session;
258
    }
259
260
    public function isSessionPersisted(UserSession $session): bool
261
    {
262
        return $this->getSessionFromSessionId($session->getIdentifier()) !== null;
263
    }
264
265
    public function removeSession(UserSession $session): void
266
    {
267
        $this->sessionBackend->remove($session->getIdentifier());
268
    }
269
270
    public function updateSession(UserSession $session): UserSession
271
    {
272
        $sessionRecord = $this->sessionBackend->update($session->getIdentifier(), $session->toArray());
273
        return $this->recreateUserSession($session, $sessionRecord);
274
    }
275
276
    public function collectGarbage(int $garbageCollectionProbability = 1): void
277
    {
278
        // If we're lucky we'll get to clean up old sessions
279
        if (random_int(0, mt_getrandmax()) % 100 <= $garbageCollectionProbability) {
280
            $this->sessionBackend->collectGarbage(
281
                $this->sessionLifetime > 0 ? $this->sessionLifetime : self::GARBAGE_COLLECTION_LIFETIME,
282
                $this->garbageCollectionForAnonymousSessions
283
            );
284
        }
285
    }
286
287
    /**
288
     * Creates a new session ID using a random with SESSION_ID_LENGTH as length
289
     *
290
     * @return string
291
     */
292
    protected function createSessionId(): string
293
    {
294
        return GeneralUtility::makeInstance(Random::class)->generateRandomHexString(self::SESSION_ID_LENGTH);
295
    }
296
297
    /**
298
     * Tries to fetch a user session form the session backend.
299
     * If non is given, an anonymous session will be created.
300
     *
301
     * @param string $id
302
     * @return UserSession|null The created user session object or null
303
     */
304
    protected function getSessionFromSessionId(string $id): ?UserSession
305
    {
306
        if ($id === '') {
307
            return null;
308
        }
309
        try {
310
            $sessionRecord = $this->sessionBackend->get($id);
311
            if ($sessionRecord === []) {
312
                return null;
313
            }
314
            // If the session does not match the current IP lock, it should be treated as invalid
315
            // and a new session should be created.
316
            if ($this->ipLocker->validateRemoteAddressAgainstSessionIpLock(
317
                (string)GeneralUtility::getIndpEnv('REMOTE_ADDR'),
318
                $sessionRecord['ses_iplock']
319
            )) {
320
                return UserSession::createFromRecord($id, $sessionRecord);
321
            }
322
        } catch (SessionNotFoundException $e) {
323
            return null;
324
        }
325
326
        return null;
327
    }
328
329
    /**
330
     * Create a UserSessionManager instance for the given login type. Has several optional arguments used for testing purposes
331
     * to inject dummy objects if needed.
332
     *
333
     * Ideally, this factory encapsulates all "TYPO3_CONF_VARS" options, so the actual object does not need to consider any
334
     * global state.
335
     *
336
     * @param string $loginType
337
     * @param int|null $sessionLifetime
338
     * @param SessionManager|null $sessionManager
339
     * @param IpLocker|null $ipLocker
340
     * @return static
341
     */
342
    public static function create(string $loginType, int $sessionLifetime = null, SessionManager $sessionManager = null, IpLocker $ipLocker = null): self
343
    {
344
        $sessionManager = $sessionManager ?? GeneralUtility::makeInstance(SessionManager::class);
345
        $ipLocker = $ipLocker ?? GeneralUtility::makeInstance(
346
            IpLocker::class,
347
            $GLOBALS['TYPO3_CONF_VARS'][$loginType]['lockIP'],
348
            $GLOBALS['TYPO3_CONF_VARS'][$loginType]['lockIPv6']
349
        );
350
        $lifetime = (int)($GLOBALS['TYPO3_CONF_VARS'][$loginType]['lifetime'] ?? 0);
351
        $sessionLifetime = $sessionLifetime ?? (int)$GLOBALS['TYPO3_CONF_VARS'][$loginType]['sessionTimeout'];
352
        if ($sessionLifetime > 0 && $sessionLifetime < $lifetime && $lifetime > 0) {
353
            // If server session timeout is non-zero but less than client session timeout: Copy this value instead.
354
            $sessionLifetime = $lifetime;
355
        }
356
        $object = GeneralUtility::makeInstance(
357
            self::class,
358
            $sessionManager->getSessionBackend($loginType),
359
            $sessionLifetime,
360
            $ipLocker
361
        );
362
        if ($loginType === 'FE') {
363
            $object->setGarbageCollectionTimeoutForAnonymousSessions((int)($GLOBALS['TYPO3_CONF_VARS']['FE']['sessionDataLifetime'] ?? 0));
364
        }
365
        return $object;
366
    }
367
368
    /**
369
     * Recreates `UserSession` object from existing session data - keeping `new` state.
370
     * This method shall be used to reflect updated low-level session data in corresponding `UserSession` object.
371
     *
372
     * @param UserSession $session
373
     * @param array|null $sessionRecord
374
     * @return UserSession
375
     * @throws SessionNotFoundException
376
     */
377
    protected function recreateUserSession(UserSession $session, array $sessionRecord = null): UserSession
378
    {
379
        return UserSession::createFromRecord(
380
            $session->getIdentifier(),
381
            $sessionRecord ?? $this->sessionBackend->get($session->getIdentifier()),
382
            $session->isNew() // keep state (required to emit e.g. cookies)
383
        );
384
    }
385
}
386