MediaWiki REL1_39
CookieSessionProvider.php
Go to the documentation of this file.
1<?php
24namespace MediaWiki\Session;
25
28use User;
29use WebRequest;
30
38
40 protected $params = [];
41
43 protected $cookieOptions = [];
44
47
62 public function __construct( $params = [] ) {
63 parent::__construct();
64
65 $params += [
66 'cookieOptions' => [],
67 // @codeCoverageIgnoreStart
68 ];
69 // @codeCoverageIgnoreEnd
70
71 if ( !isset( $params['priority'] ) ) {
72 throw new \InvalidArgumentException( __METHOD__ . ': priority must be specified' );
73 }
74 if ( $params['priority'] < SessionInfo::MIN_PRIORITY ||
76 ) {
77 throw new \InvalidArgumentException( __METHOD__ . ': Invalid priority' );
78 }
79
80 if ( !is_array( $params['cookieOptions'] ) ) {
81 throw new \InvalidArgumentException( __METHOD__ . ': cookieOptions must be an array' );
82 }
83
84 $this->priority = $params['priority'];
85 $this->cookieOptions = $params['cookieOptions'];
86 $this->params = $params;
87 unset( $this->params['priority'] );
88 unset( $this->params['cookieOptions'] );
89 }
90
91 protected function postInitSetup() {
92 // @codeCoverageIgnoreStart
93 $this->params += [
94 // @codeCoverageIgnoreEnd
95 'callUserSetCookiesHook' => false,
96 'sessionName' =>
98 ?: $this->getConfig()->get( MainConfigNames::CookiePrefix ) . '_session',
99 ];
100
101 $sameSite = $this->getConfig()->get( MainConfigNames::CookieSameSite );
102 $this->useCrossSiteCookies = $sameSite !== null && strcasecmp( $sameSite, 'none' ) === 0;
103
104 // @codeCoverageIgnoreStart
105 $this->cookieOptions += [
106 // @codeCoverageIgnoreEnd
107 'prefix' => $this->getConfig()->get( MainConfigNames::CookiePrefix ),
108 'path' => $this->getConfig()->get( MainConfigNames::CookiePath ),
109 'domain' => $this->getConfig()->get( MainConfigNames::CookieDomain ),
110 'secure' => $this->getConfig()->get( MainConfigNames::CookieSecure )
111 || $this->getConfig()->get( MainConfigNames::ForceHTTPS ),
112 'httpOnly' => $this->getConfig()->get( MainConfigNames::CookieHttpOnly ),
113 'sameSite' => $sameSite,
114 ];
115 }
116
117 public function provideSessionInfo( WebRequest $request ) {
118 $sessionId = $this->getCookie( $request, $this->params['sessionName'], '' );
119 $info = [
120 'provider' => $this,
121 'forceHTTPS' => $this->getCookie( $request, 'forceHTTPS', '', false )
122 ];
123 if ( SessionManager::validateSessionId( $sessionId ) ) {
124 $info['id'] = $sessionId;
125 $info['persisted'] = true;
126 }
127
128 list( $userId, $userName, $token ) = $this->getUserInfoFromCookies( $request );
129 if ( $userId !== null ) {
130 try {
131 $userInfo = UserInfo::newFromId( $userId );
132 } catch ( \InvalidArgumentException $ex ) {
133 return null;
134 }
135
136 if ( $userName !== null && $userInfo->getName() !== $userName ) {
137 $this->logger->warning(
138 'Session "{session}" requested with mismatched UserID and UserName cookies.',
139 [
140 'session' => $sessionId,
141 'mismatch' => [
142 'userid' => $userId,
143 'cookie_username' => $userName,
144 'username' => $userInfo->getName(),
145 ],
146 ] );
147 return null;
148 }
149
150 if ( $token !== null ) {
151 if ( !hash_equals( $userInfo->getToken(), $token ) ) {
152 $this->logger->warning(
153 'Session "{session}" requested with invalid Token cookie.',
154 [
155 'session' => $sessionId,
156 'userid' => $userId,
157 'username' => $userInfo->getName(),
158 ] );
159 return null;
160 }
161 $info['userInfo'] = $userInfo->verified();
162 $info['persisted'] = true; // If we have user+token, it should be
163 } elseif ( isset( $info['id'] ) ) {
164 $info['userInfo'] = $userInfo;
165 } else {
166 // No point in returning, loadSessionInfoFromStore() will
167 // reject it anyway.
168 return null;
169 }
170 } elseif ( isset( $info['id'] ) ) {
171 // No UserID cookie, so insist that the session is anonymous.
172 // Note: this event occurs for several normal activities:
173 // * anon visits Special:UserLogin
174 // * anon browsing after seeing Special:UserLogin
175 // * anon browsing after edit or preview
176 $this->logger->debug(
177 'Session "{session}" requested without UserID cookie',
178 [
179 'session' => $info['id'],
180 ] );
181 $info['userInfo'] = UserInfo::newAnonymous();
182 } else {
183 // No session ID and no user is the same as an empty session, so
184 // there's no point.
185 return null;
186 }
187
188 return new SessionInfo( $this->priority, $info );
189 }
190
191 public function persistsSessionId() {
192 return true;
193 }
194
195 public function canChangeUser() {
196 return true;
197 }
198
199 public function persistSession( SessionBackend $session, WebRequest $request ) {
200 $response = $request->response();
201 if ( $response->headersSent() ) {
202 // Can't do anything now
203 $this->logger->debug( __METHOD__ . ': Headers already sent' );
204 return;
205 }
206
207 $user = $session->getUser();
208
209 $cookies = $this->cookieDataToExport( $user, $session->shouldRememberUser() );
210 $sessionData = $this->sessionDataToExport( $user );
211
212 // Legacy hook
213 if ( $this->params['callUserSetCookiesHook'] && !$user->isAnon() ) {
214 $this->getHookRunner()->onUserSetCookies( $user, $sessionData, $cookies );
215 }
216
217 $options = $this->cookieOptions;
218
219 $forceHTTPS = $session->shouldForceHTTPS() || $user->requiresHTTPS();
220 if ( $forceHTTPS ) {
221 $options['secure'] = $this->getConfig()->get( MainConfigNames::CookieSecure )
222 || $this->getConfig()->get( MainConfigNames::ForceHTTPS );
223 }
224
225 $response->setCookie( $this->params['sessionName'], $session->getId(), null,
226 [ 'prefix' => '' ] + $options
227 );
228
229 foreach ( $cookies as $key => $value ) {
230 if ( $value === false ) {
231 $response->clearCookie( $key, $options );
232 } else {
233 $expirationDuration = $this->getLoginCookieExpiration( $key, $session->shouldRememberUser() );
234 $expiration = $expirationDuration ? $expirationDuration + time() : null;
235 $response->setCookie( $key, (string)$value, $expiration, $options );
236 }
237 }
238
239 $this->setForceHTTPSCookie( $forceHTTPS, $session, $request );
240 $this->setLoggedOutCookie( $session->getLoggedOutTimestamp(), $request );
241
242 if ( $sessionData ) {
243 $session->addData( $sessionData );
244 }
245 }
246
247 public function unpersistSession( WebRequest $request ) {
248 $response = $request->response();
249 if ( $response->headersSent() ) {
250 // Can't do anything now
251 $this->logger->debug( __METHOD__ . ': Headers already sent' );
252 return;
253 }
254
255 $cookies = [
256 'UserID' => false,
257 'Token' => false,
258 ];
259
260 $response->clearCookie(
261 $this->params['sessionName'], [ 'prefix' => '' ] + $this->cookieOptions
262 );
263
264 foreach ( $cookies as $key => $value ) {
265 $response->clearCookie( $key, $this->cookieOptions );
266 }
267
268 $this->setForceHTTPSCookie( false, null, $request );
269 }
270
278 protected function setForceHTTPSCookie( $set, ?SessionBackend $backend, WebRequest $request ) {
279 if ( $this->getConfig()->get( MainConfigNames::ForceHTTPS ) ) {
280 // No need to send a cookie if the wiki is always HTTPS (T256095)
281 return;
282 }
283 $response = $request->response();
284 if ( $set ) {
285 if ( $backend->shouldRememberUser() ) {
286 $expirationDuration = $this->getLoginCookieExpiration(
287 'forceHTTPS',
288 true
289 );
290 $expiration = $expirationDuration ? $expirationDuration + time() : null;
291 } else {
292 $expiration = null;
293 }
294 $response->setCookie( 'forceHTTPS', 'true', $expiration,
295 [ 'prefix' => '', 'secure' => false ] + $this->cookieOptions );
296 } else {
297 $response->clearCookie( 'forceHTTPS',
298 [ 'prefix' => '', 'secure' => false ] + $this->cookieOptions );
299 }
300 }
301
306 protected function setLoggedOutCookie( $loggedOut, WebRequest $request ) {
307 if ( $loggedOut + 86400 > time() &&
308 $loggedOut !== (int)$this->getCookie( $request, 'LoggedOut', $this->cookieOptions['prefix'] )
309 ) {
310 $request->response()->setCookie( 'LoggedOut', (string)$loggedOut, $loggedOut + 86400,
311 $this->cookieOptions );
312 }
313 }
314
315 public function getVaryCookies() {
316 return [
317 // Vary on token and session because those are the real authn
318 // determiners. UserID and UserName don't matter without those.
319 $this->cookieOptions['prefix'] . 'Token',
320 $this->cookieOptions['prefix'] . 'LoggedOut',
321 $this->params['sessionName'],
322 'forceHTTPS',
323 ];
324 }
325
326 public function suggestLoginUsername( WebRequest $request ) {
327 $name = $this->getCookie( $request, 'UserName', $this->cookieOptions['prefix'] );
328 if ( $name !== null ) {
329 if ( $this->userNameUtils->isTemp( $name ) ) {
330 $name = false;
331 } else {
332 $name = $this->userNameUtils->getCanonical( $name, UserRigorOptions::RIGOR_USABLE );
333 }
334 }
335 return $name === false ? null : $name;
336 }
337
343 protected function getUserInfoFromCookies( $request ) {
344 $prefix = $this->cookieOptions['prefix'];
345 return [
346 $this->getCookie( $request, 'UserID', $prefix ),
347 $this->getCookie( $request, 'UserName', $prefix ),
348 $this->getCookie( $request, 'Token', $prefix ),
349 ];
350 }
351
360 protected function getCookie( $request, $key, $prefix, $default = null ) {
361 if ( $this->useCrossSiteCookies ) {
362 $value = $request->getCrossSiteCookie( $key, $prefix, $default );
363 } else {
364 $value = $request->getCookie( $key, $prefix, $default );
365 }
366 if ( $value === 'deleted' ) {
367 // PHP uses this value when deleting cookies. A legitimate cookie will never have
368 // this value (usernames start with uppercase, token is longer, other auth cookies
369 // are booleans or integers). Seeing this means that in a previous request we told the
370 // client to delete the cookie, but it has poor cookie handling. Pretend the cookie is
371 // not there to avoid invalidating the session.
372 return null;
373 }
374 return $value;
375 }
376
383 protected function cookieDataToExport( $user, $remember ) {
384 if ( $user->isAnon() ) {
385 return [
386 'UserID' => false,
387 'Token' => false,
388 ];
389 } else {
390 return [
391 'UserID' => $user->getId(),
392 'UserName' => $user->getName(),
393 'Token' => $remember ? (string)$user->getToken() : false,
394 ];
395 }
396 }
397
403 protected function sessionDataToExport( $user ) {
404 // If we're calling the legacy hook, we should populate $session
405 // like User::setCookies() did.
406 if ( !$user->isAnon() && $this->params['callUserSetCookiesHook'] ) {
407 return [
408 'wsUserID' => $user->getId(),
409 'wsToken' => $user->getToken(),
410 'wsUserName' => $user->getName(),
411 ];
412 }
413
414 return [];
415 }
416
417 public function whyNoSession() {
418 return wfMessage( 'sessionprovider-nocookies' );
419 }
420
421 public function getRememberUserDuration() {
422 return min( $this->getLoginCookieExpiration( 'UserID', true ),
423 $this->getLoginCookieExpiration( 'Token', true ) ) ?: null;
424 }
425
432 protected function getExtendedLoginCookies() {
433 return [ 'UserID', 'UserName', 'Token' ];
434 }
435
446 protected function getLoginCookieExpiration( $cookieName, $shouldRememberUser ) {
447 $extendedCookies = $this->getExtendedLoginCookies();
448 $normalExpiration = $this->getConfig()->get( MainConfigNames::CookieExpiration );
449
450 if ( $shouldRememberUser && in_array( $cookieName, $extendedCookies, true ) ) {
451 $extendedExpiration = $this->getConfig()->get( MainConfigNames::ExtendedLoginCookieExpiration );
452
453 return ( $extendedExpiration !== null ) ? (int)$extendedExpiration : (int)$normalExpiration;
454 } else {
455 return (int)$normalExpiration;
456 }
457 }
458}
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
A class containing constants representing the names of configuration variables.
const ForceHTTPS
Name constant for the ForceHTTPS setting, for use with Config::get()
const CookieExpiration
Name constant for the CookieExpiration setting, for use with Config::get()
const CookieDomain
Name constant for the CookieDomain setting, for use with Config::get()
const CookiePath
Name constant for the CookiePath setting, for use with Config::get()
const CookieSameSite
Name constant for the CookieSameSite setting, for use with Config::get()
const CookieSecure
Name constant for the CookieSecure setting, for use with Config::get()
const SessionName
Name constant for the SessionName setting, for use with Config::get()
const ExtendedLoginCookieExpiration
Name constant for the ExtendedLoginCookieExpiration setting, for use with Config::get()
const CookiePrefix
Name constant for the CookiePrefix setting, for use with Config::get()
const CookieHttpOnly
Name constant for the CookieHttpOnly setting, for use with Config::get()
A CookieSessionProvider persists sessions using cookies.
suggestLoginUsername(WebRequest $request)
Get a suggested username for the login form.
canChangeUser()
Indicate whether the user associated with the request can be changed.
sessionDataToExport( $user)
Return extra data to store in the session.
persistSession(SessionBackend $session, WebRequest $request)
Persist a session into a request/response.
getUserInfoFromCookies( $request)
Fetch the user identity from cookies.
whyNoSession()
Return a Message for why sessions might not be being persisted.For example, "check whether you're blo...
setLoggedOutCookie( $loggedOut, WebRequest $request)
getExtendedLoginCookies()
Gets the list of cookies that must be set to the 'remember me' duration, if $wgExtendedLoginCookieExp...
setForceHTTPSCookie( $set, ?SessionBackend $backend, WebRequest $request)
Set the "forceHTTPS" cookie, unless $wgForceHTTPS prevents it.
getVaryCookies()
Return the list of cookies that need varying on.
getCookie( $request, $key, $prefix, $default=null)
Get a cookie.
persistsSessionId()
Indicate whether self::persistSession() can save arbitrary session IDs.
provideSessionInfo(WebRequest $request)
Provide session info for a request.
cookieDataToExport( $user, $remember)
Return the data to store in cookies.
getRememberUserDuration()
Returns the duration (in seconds) for which users will be remembered when Session::setRememberUser() ...
unpersistSession(WebRequest $request)
Remove any persisted session from a request/response.
getLoginCookieExpiration( $cookieName, $shouldRememberUser)
Returns the lifespan of the login cookies, in seconds.
postInitSetup()
A provider can override this to do any necessary setup after init() is called.
This is the actual workhorse for Session.
addData(array $newData)
Add data to the session.
shouldForceHTTPS()
Whether HTTPS should be forced.
getId()
Returns the session ID.
getLoggedOutTimestamp()
Fetch the "logged out" timestamp.
getUser()
Returns the authenticated user for this session.
shouldRememberUser()
Indicate whether the user should be remembered independently of the session ID.
Value object returned by SessionProvider.
const MIN_PRIORITY
Minimum allowed priority.
const MAX_PRIORITY
Maximum allowed priority.
static validateSessionId( $id)
Validate a session ID.
A SessionProvider provides SessionInfo and support for Session.
static newAnonymous()
Create an instance for an anonymous (i.e.
Definition UserInfo.php:78
static newFromId( $id, $verified=false)
Create an instance for a logged-in user by ID.
Definition UserInfo.php:88
internal since 1.36
Definition User.php:70
The WebRequest class encapsulates getting at data passed in the URL or via a POSTed form stripping il...
response()
Return a handle to WebResponse style object, for setting cookies, headers and other stuff,...
Shared interface for rigor levels when dealing with User methods.