Completed
Branch master (1b8556)
by
unknown
26:56
created

CookieSessionProvider   C

Complexity

Total Complexity 58

Size/Duplication

Total Lines 384
Duplicated Lines 1.3 %

Coupling/Cohesion

Components 1
Dependencies 11

Importance

Changes 1
Bugs 1 Features 1
Metric Value
c 1
b 1
f 1
dl 5
loc 384
rs 6.3005
wmc 58
lcom 1
cbo 11

18 Methods

Rating   Name   Duplication   Size   Complexity  
B __construct() 5 28 5
A setConfig() 0 21 2
A persistsSessionId() 0 3 1
A canChangeUser() 0 3 1
A setLoggedOutCookie() 0 8 3
A suggestLoginUsername() 0 7 3
A getCookie() 0 12 2
A whyNoSession() 0 3 1
C provideSessionInfo() 0 74 10
A getVaryCookies() 0 10 1
A getUserInfoFromCookies() 0 8 1
A cookieDataToExport() 0 14 3
A sessionDataToExport() 0 13 3
C persistSession() 0 49 10
A unpersistSession() 0 23 3
A setForceHTTPSCookie() 0 18 4
A getRememberUserDuration() 0 4 2
A getLoginCookieExpiration() 0 10 3

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complex Class

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like CookieSessionProvider often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use CookieSessionProvider, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * MediaWiki cookie-based session provider interface
4
 *
5
 * This program is free software; you can redistribute it and/or modify
6
 * it under the terms of the GNU General Public License as published by
7
 * the Free Software Foundation; either version 2 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU General Public License along
16
 * with this program; if not, write to the Free Software Foundation, Inc.,
17
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18
 * http://www.gnu.org/copyleft/gpl.html
19
 *
20
 * @file
21
 * @ingroup Session
22
 */
23
24
namespace MediaWiki\Session;
25
26
use Config;
27
use User;
28
use WebRequest;
29
30
/**
31
 * A CookieSessionProvider persists sessions using cookies
32
 *
33
 * @ingroup Session
34
 * @since 1.27
35
 */
36
class CookieSessionProvider extends SessionProvider {
37
38
	protected $params = [];
39
	protected $cookieOptions = [];
40
41
	/**
42
	 * @param array $params Keys include:
43
	 *  - priority: (required) Priority of the returned sessions
44
	 *  - callUserSetCookiesHook: Whether to call the deprecated hook
45
	 *  - sessionName: Session cookie name. Doesn't honor 'prefix'. Defaults to
46
	 *    $wgSessionName, or $wgCookiePrefix . '_session' if that is unset.
47
	 *  - cookieOptions: Options to pass to WebRequest::setCookie():
48
	 *    - prefix: Cookie prefix, defaults to $wgCookiePrefix
49
	 *    - path: Cookie path, defaults to $wgCookiePath
50
	 *    - domain: Cookie domain, defaults to $wgCookieDomain
51
	 *    - secure: Cookie secure flag, defaults to $wgCookieSecure
52
	 *    - httpOnly: Cookie httpOnly flag, defaults to $wgCookieHttpOnly
53
	 */
54
	public function __construct( $params = [] ) {
55
		parent::__construct();
56
57
		$params += [
58
			'cookieOptions' => [],
59
			// @codeCoverageIgnoreStart
60
		];
61
		// @codeCoverageIgnoreEnd
62
63
		if ( !isset( $params['priority'] ) ) {
64
			throw new \InvalidArgumentException( __METHOD__ . ': priority must be specified' );
65
		}
66 View Code Duplication
		if ( $params['priority'] < SessionInfo::MIN_PRIORITY ||
67
			$params['priority'] > SessionInfo::MAX_PRIORITY
68
		) {
69
			throw new \InvalidArgumentException( __METHOD__ . ': Invalid priority' );
70
		}
71
72
		if ( !is_array( $params['cookieOptions'] ) ) {
73
			throw new \InvalidArgumentException( __METHOD__ . ': cookieOptions must be an array' );
74
		}
75
76
		$this->priority = $params['priority'];
77
		$this->cookieOptions = $params['cookieOptions'];
78
		$this->params = $params;
79
		unset( $this->params['priority'] );
80
		unset( $this->params['cookieOptions'] );
81
	}
82
83
	public function setConfig( Config $config ) {
84
		parent::setConfig( $config );
85
86
		// @codeCoverageIgnoreStart
87
		$this->params += [
88
			// @codeCoverageIgnoreEnd
89
			'callUserSetCookiesHook' => false,
90
			'sessionName' =>
91
				$config->get( 'SessionName' ) ?: $config->get( 'CookiePrefix' ) . '_session',
92
		];
93
94
		// @codeCoverageIgnoreStart
95
		$this->cookieOptions += [
96
			// @codeCoverageIgnoreEnd
97
			'prefix' => $config->get( 'CookiePrefix' ),
98
			'path' => $config->get( 'CookiePath' ),
99
			'domain' => $config->get( 'CookieDomain' ),
100
			'secure' => $config->get( 'CookieSecure' ),
101
			'httpOnly' => $config->get( 'CookieHttpOnly' ),
102
		];
103
	}
104
105
	public function provideSessionInfo( WebRequest $request ) {
106
		$sessionId = $this->getCookie( $request, $this->params['sessionName'], '' );
107
		$info = [
108
			'provider' => $this,
109
			'forceHTTPS' => $this->getCookie( $request, 'forceHTTPS', '', false )
110
		];
111
		if ( SessionManager::validateSessionId( $sessionId ) ) {
112
			$info['id'] = $sessionId;
113
			$info['persisted'] = true;
114
		}
115
116
		list( $userId, $userName, $token ) = $this->getUserInfoFromCookies( $request );
117
		if ( $userId !== null ) {
118
			try {
119
				$userInfo = UserInfo::newFromId( $userId );
120
			} catch ( \InvalidArgumentException $ex ) {
121
				return null;
122
			}
123
124
			// Sanity check
125
			if ( $userName !== null && $userInfo->getName() !== $userName ) {
126
				$this->logger->warning(
127
					'Session "{session}" requested with mismatched UserID and UserName cookies.',
128
					[
129
						'session' => $sessionId,
130
						'mismatch' => [
131
							'userid' => $userId,
132
							'cookie_username' => $userName,
133
							'username' => $userInfo->getName(),
134
						],
135
				] );
136
				return null;
137
			}
138
139
			if ( $token !== null ) {
140
				if ( !hash_equals( $userInfo->getToken(), $token ) ) {
141
					$this->logger->warning(
142
						'Session "{session}" requested with invalid Token cookie.',
143
						[
144
							'session' => $sessionId,
145
							'userid' => $userId,
146
							'username' => $userInfo->getName(),
147
					 ] );
148
					return null;
149
				}
150
				$info['userInfo'] = $userInfo->verified();
151
				$info['persisted'] = true; // If we have user+token, it should be
152
			} elseif ( isset( $info['id'] ) ) {
153
				$info['userInfo'] = $userInfo;
154
			} else {
155
				// No point in returning, loadSessionInfoFromStore() will
156
				// reject it anyway.
157
				return null;
158
			}
159
		} elseif ( isset( $info['id'] ) ) {
160
			// No UserID cookie, so insist that the session is anonymous.
161
			// Note: this event occurs for several normal activities:
162
			// * anon visits Special:UserLogin
163
			// * anon browsing after seeing Special:UserLogin
164
			// * anon browsing after edit or preview
165
			$this->logger->debug(
166
				'Session "{session}" requested without UserID cookie',
167
				[
168
					'session' => $info['id'],
169
			] );
170
			$info['userInfo'] = UserInfo::newAnonymous();
171
		} else {
172
			// No session ID and no user is the same as an empty session, so
173
			// there's no point.
174
			return null;
175
		}
176
177
		return new SessionInfo( $this->priority, $info );
178
	}
179
180
	public function persistsSessionId() {
181
		return true;
182
	}
183
184
	public function canChangeUser() {
185
		return true;
186
	}
187
188
	public function persistSession( SessionBackend $session, WebRequest $request ) {
189
		$response = $request->response();
190
		if ( $response->headersSent() ) {
191
			// Can't do anything now
192
			$this->logger->debug( __METHOD__ . ': Headers already sent' );
193
			return;
194
		}
195
196
		$user = $session->getUser();
197
198
		$cookies = $this->cookieDataToExport( $user, $session->shouldRememberUser() );
199
		$sessionData = $this->sessionDataToExport( $user );
200
201
		// Legacy hook
202
		if ( $this->params['callUserSetCookiesHook'] && !$user->isAnon() ) {
203
			\Hooks::run( 'UserSetCookies', [ $user, &$sessionData, &$cookies ] );
204
		}
205
206
		$options = $this->cookieOptions;
207
208
		$forceHTTPS = $session->shouldForceHTTPS() || $user->requiresHTTPS();
209
		if ( $forceHTTPS ) {
210
			// Don't set the secure flag if the request came in
211
			// over "http", for backwards compat.
212
			// @todo Break that backwards compat properly.
213
			$options['secure'] = $this->config->get( 'CookieSecure' );
214
		}
215
216
		$response->setCookie( $this->params['sessionName'], $session->getId(), null,
217
			[ 'prefix' => '' ] + $options
218
		);
219
220
		foreach ( $cookies as $key => $value ) {
221
			if ( $value === false ) {
222
				$response->clearCookie( $key, $options );
223
			} else {
224
				$expirationDuration = $this->getLoginCookieExpiration( $key );
225
				$expiration = $expirationDuration ? $expirationDuration + time() : null;
226
				$response->setCookie( $key, (string)$value, $expiration, $options );
227
			}
228
		}
229
230
		$this->setForceHTTPSCookie( $forceHTTPS, $session, $request );
231
		$this->setLoggedOutCookie( $session->getLoggedOutTimestamp(), $request );
232
233
		if ( $sessionData ) {
234
			$session->addData( $sessionData );
235
		}
236
	}
237
238
	public function unpersistSession( WebRequest $request ) {
239
		$response = $request->response();
240
		if ( $response->headersSent() ) {
241
			// Can't do anything now
242
			$this->logger->debug( __METHOD__ . ': Headers already sent' );
243
			return;
244
		}
245
246
		$cookies = [
247
			'UserID' => false,
248
			'Token' => false,
249
		];
250
251
		$response->clearCookie(
252
			$this->params['sessionName'], [ 'prefix' => '' ] + $this->cookieOptions
253
		);
254
255
		foreach ( $cookies as $key => $value ) {
256
			$response->clearCookie( $key, $this->cookieOptions );
257
		}
258
259
		$this->setForceHTTPSCookie( false, null, $request );
260
	}
261
262
	/**
263
	 * Set the "forceHTTPS" cookie
264
	 * @param bool $set Whether the cookie should be set or not
265
	 * @param SessionBackend|null $backend
266
	 * @param WebRequest $request
267
	 */
268
	protected function setForceHTTPSCookie(
269
		$set, SessionBackend $backend = null, WebRequest $request
270
	) {
271
		$response = $request->response();
272
		if ( $set ) {
273
			if ( $backend->shouldRememberUser() ) {
0 ignored issues
show
Bug introduced by
It seems like $backend is not always an object, but can also be of type null. Maybe add an additional type check?

If a variable is not always an object, we recommend to add an additional type check to ensure your method call is safe:

function someFunction(A $objectMaybe = null)
{
    if ($objectMaybe instanceof A) {
        $objectMaybe->doSomething();
    }
}
Loading history...
274
				$expirationDuration = $this->getLoginCookieExpiration( 'forceHTTPS' );
275
				$expiration = $expirationDuration ? $expirationDuration + time() : null;
276
			} else {
277
				$expiration = null;
278
			}
279
			$response->setCookie( 'forceHTTPS', 'true', $expiration,
280
				[ 'prefix' => '', 'secure' => false ] + $this->cookieOptions );
281
		} else {
282
			$response->clearCookie( 'forceHTTPS',
283
				[ 'prefix' => '', 'secure' => false ] + $this->cookieOptions );
284
		}
285
	}
286
287
	/**
288
	 * Set the "logged out" cookie
289
	 * @param int $loggedOut timestamp
290
	 * @param WebRequest $request
291
	 */
292
	protected function setLoggedOutCookie( $loggedOut, WebRequest $request ) {
293
		if ( $loggedOut + 86400 > time() &&
294
			$loggedOut !== (int)$this->getCookie( $request, 'LoggedOut', $this->cookieOptions['prefix'] )
295
		) {
296
			$request->response()->setCookie( 'LoggedOut', $loggedOut, $loggedOut + 86400,
297
				$this->cookieOptions );
298
		}
299
	}
300
301
	public function getVaryCookies() {
302
		return [
303
			// Vary on token and session because those are the real authn
304
			// determiners. UserID and UserName don't matter without those.
305
			$this->cookieOptions['prefix'] . 'Token',
306
			$this->cookieOptions['prefix'] . 'LoggedOut',
307
			$this->params['sessionName'],
308
			'forceHTTPS',
309
		];
310
	}
311
312
	public function suggestLoginUsername( WebRequest $request ) {
313
		 $name = $this->getCookie( $request, 'UserName', $this->cookieOptions['prefix'] );
314
		 if ( $name !== null ) {
315
			 $name = User::getCanonicalName( $name, 'usable' );
316
		 }
317
		 return $name === false ? null : $name;
318
	}
319
320
	/**
321
	 * Fetch the user identity from cookies
322
	 * @param \WebRequest $request
323
	 * @return array (string|null $id, string|null $username, string|null $token)
324
	 */
325
	protected function getUserInfoFromCookies( $request ) {
326
		$prefix = $this->cookieOptions['prefix'];
327
		return [
328
			$this->getCookie( $request, 'UserID', $prefix ),
329
			$this->getCookie( $request, 'UserName', $prefix ),
330
			$this->getCookie( $request, 'Token', $prefix ),
331
		];
332
	}
333
334
	/**
335
	 * Get a cookie. Contains an auth-specific hack.
336
	 * @param \WebRequest $request
337
	 * @param string $key
338
	 * @param string $prefix
339
	 * @param mixed $default
340
	 * @return mixed
341
	 */
342
	protected function getCookie( $request, $key, $prefix, $default = null ) {
343
		$value = $request->getCookie( $key, $prefix, $default );
344
		if ( $value === 'deleted' ) {
345
			// PHP uses this value when deleting cookies. A legitimate cookie will never have
346
			// this value (usernames start with uppercase, token is longer, other auth cookies
347
			// are booleans or integers). Seeing this means that in a previous request we told the
348
			// client to delete the cookie, but it has poor cookie handling. Pretend the cookie is
349
			// not there to avoid invalidating the session.
350
			return null;
351
		}
352
		return $value;
353
	}
354
355
	/**
356
	 * Return the data to store in cookies
357
	 * @param User $user
358
	 * @param bool $remember
359
	 * @return array $cookies Set value false to unset the cookie
360
	 */
361
	protected function cookieDataToExport( $user, $remember ) {
362
		if ( $user->isAnon() ) {
363
			return [
364
				'UserID' => false,
365
				'Token' => false,
366
			];
367
		} else {
368
			return [
369
				'UserID' => $user->getId(),
370
				'UserName' => $user->getName(),
371
				'Token' => $remember ? (string)$user->getToken() : false,
372
			];
373
		}
374
	}
375
376
	/**
377
	 * Return extra data to store in the session
378
	 * @param User $user
379
	 * @return array $session
380
	 */
381
	protected function sessionDataToExport( $user ) {
382
		// If we're calling the legacy hook, we should populate $session
383
		// like User::setCookies() did.
384
		if ( !$user->isAnon() && $this->params['callUserSetCookiesHook'] ) {
385
			return [
386
				'wsUserID' => $user->getId(),
387
				'wsToken' => $user->getToken(),
388
				'wsUserName' => $user->getName(),
389
			];
390
		}
391
392
		return [];
393
	}
394
395
	public function whyNoSession() {
396
		return wfMessage( 'sessionprovider-nocookies' );
397
	}
398
399
	public function getRememberUserDuration() {
400
		return min( $this->getLoginCookieExpiration( 'UserID' ),
401
			$this->getLoginCookieExpiration( 'Token' ) ) ?: null;
402
	}
403
404
	/**
405
	 * Returns the lifespan of the login cookies, in seconds. 0 means until the end of the session.
406
	 * @param string $cookieName
407
	 * @return int Cookie expiration time in seconds; 0 for session cookies
408
	 */
409
	protected function getLoginCookieExpiration( $cookieName ) {
410
		$normalExpiration = $this->config->get( 'CookieExpiration' );
411
		$extendedExpiration = $this->config->get( 'ExtendedLoginCookieExpiration' );
412
		$extendedCookies = $this->config->get( 'ExtendedLoginCookies' );
413
414
		if ( !in_array( $cookieName, $extendedCookies, true ) ) {
415
			return (int)$normalExpiration;
416
		}
417
		return ( $extendedExpiration !== null ) ? (int)$extendedExpiration : (int)$normalExpiration;
418
	}
419
}
420