MediaWiki  master
CookieSessionProvider.php
Go to the documentation of this file.
1 <?php
24 namespace MediaWiki\Session;
25 
28 use User;
29 use WebRequest;
30 
38 
40  protected $params = [];
41 
43  protected $cookieOptions = [];
44 
47 
61  public function __construct( $params = [] ) {
62  parent::__construct();
63 
64  $params += [
65  'cookieOptions' => [],
66  // @codeCoverageIgnoreStart
67  ];
68  // @codeCoverageIgnoreEnd
69 
70  if ( !isset( $params['priority'] ) ) {
71  throw new \InvalidArgumentException( __METHOD__ . ': priority must be specified' );
72  }
73  if ( $params['priority'] < SessionInfo::MIN_PRIORITY ||
75  ) {
76  throw new \InvalidArgumentException( __METHOD__ . ': Invalid priority' );
77  }
78 
79  if ( !is_array( $params['cookieOptions'] ) ) {
80  throw new \InvalidArgumentException( __METHOD__ . ': cookieOptions must be an array' );
81  }
82 
83  $this->priority = $params['priority'];
84  $this->cookieOptions = $params['cookieOptions'];
85  $this->params = $params;
86  unset( $this->params['priority'] );
87  unset( $this->params['cookieOptions'] );
88  }
89 
90  protected function postInitSetup() {
91  $this->params += [
92  'sessionName' =>
94  ?: $this->getConfig()->get( MainConfigNames::CookiePrefix ) . '_session',
95  ];
96 
97  $sameSite = $this->getConfig()->get( MainConfigNames::CookieSameSite );
98  $this->useCrossSiteCookies = $sameSite !== null && strcasecmp( $sameSite, 'none' ) === 0;
99 
100  // @codeCoverageIgnoreStart
101  $this->cookieOptions += [
102  // @codeCoverageIgnoreEnd
103  'prefix' => $this->getConfig()->get( MainConfigNames::CookiePrefix ),
104  'path' => $this->getConfig()->get( MainConfigNames::CookiePath ),
105  'domain' => $this->getConfig()->get( MainConfigNames::CookieDomain ),
106  'secure' => $this->getConfig()->get( MainConfigNames::CookieSecure )
107  || $this->getConfig()->get( MainConfigNames::ForceHTTPS ),
108  'httpOnly' => $this->getConfig()->get( MainConfigNames::CookieHttpOnly ),
109  'sameSite' => $sameSite,
110  ];
111  }
112 
113  public function provideSessionInfo( WebRequest $request ) {
114  $sessionId = $this->getCookie( $request, $this->params['sessionName'], '' );
115  $info = [
116  'provider' => $this,
117  'forceHTTPS' => $this->getCookie( $request, 'forceHTTPS', '', false )
118  ];
119  if ( SessionManager::validateSessionId( $sessionId ) ) {
120  $info['id'] = $sessionId;
121  $info['persisted'] = true;
122  }
123 
124  [ $userId, $userName, $token ] = $this->getUserInfoFromCookies( $request );
125  if ( $userId !== null ) {
126  try {
127  $userInfo = UserInfo::newFromId( $userId );
128  } catch ( \InvalidArgumentException $ex ) {
129  return null;
130  }
131 
132  if ( $userName !== null && $userInfo->getName() !== $userName ) {
133  $this->logger->warning(
134  'Session "{session}" requested with mismatched UserID and UserName cookies.',
135  [
136  'session' => $sessionId,
137  'mismatch' => [
138  'userid' => $userId,
139  'cookie_username' => $userName,
140  'username' => $userInfo->getName(),
141  ],
142  ] );
143  return null;
144  }
145 
146  if ( $token !== null ) {
147  if ( !hash_equals( $userInfo->getToken(), $token ) ) {
148  $this->logger->warning(
149  'Session "{session}" requested with invalid Token cookie.',
150  [
151  'session' => $sessionId,
152  'userid' => $userId,
153  'username' => $userInfo->getName(),
154  ] );
155  return null;
156  }
157  $info['userInfo'] = $userInfo->verified();
158  $info['persisted'] = true; // If we have user+token, it should be
159  } elseif ( isset( $info['id'] ) ) {
160  $info['userInfo'] = $userInfo;
161  } else {
162  // No point in returning, loadSessionInfoFromStore() will
163  // reject it anyway.
164  return null;
165  }
166  } elseif ( isset( $info['id'] ) ) {
167  // No UserID cookie, so insist that the session is anonymous.
168  // Note: this event occurs for several normal activities:
169  // * anon visits Special:UserLogin
170  // * anon browsing after seeing Special:UserLogin
171  // * anon browsing after edit or preview
172  $this->logger->debug(
173  'Session "{session}" requested without UserID cookie',
174  [
175  'session' => $info['id'],
176  ] );
177  $info['userInfo'] = UserInfo::newAnonymous();
178  } else {
179  // No session ID and no user is the same as an empty session, so
180  // there's no point.
181  return null;
182  }
183 
184  return new SessionInfo( $this->priority, $info );
185  }
186 
187  public function persistsSessionId() {
188  return true;
189  }
190 
191  public function canChangeUser() {
192  return true;
193  }
194 
195  public function persistSession( SessionBackend $session, WebRequest $request ) {
196  $response = $request->response();
197  if ( $response->headersSent() ) {
198  // Can't do anything now
199  $this->logger->debug( __METHOD__ . ': Headers already sent' );
200  return;
201  }
202 
203  $user = $session->getUser();
204 
205  $cookies = $this->cookieDataToExport( $user, $session->shouldRememberUser() );
206  $sessionData = $this->sessionDataToExport( $user );
207 
208  $options = $this->cookieOptions;
209 
210  $forceHTTPS = $session->shouldForceHTTPS() || $user->requiresHTTPS();
211  if ( $forceHTTPS ) {
212  $options['secure'] = $this->getConfig()->get( MainConfigNames::CookieSecure )
213  || $this->getConfig()->get( MainConfigNames::ForceHTTPS );
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, $session->shouldRememberUser() );
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 
269  protected function setForceHTTPSCookie( $set, ?SessionBackend $backend, WebRequest $request ) {
270  if ( $this->getConfig()->get( MainConfigNames::ForceHTTPS ) ) {
271  // No need to send a cookie if the wiki is always HTTPS (T256095)
272  return;
273  }
274  $response = $request->response();
275  if ( $set ) {
276  if ( $backend->shouldRememberUser() ) {
277  $expirationDuration = $this->getLoginCookieExpiration(
278  'forceHTTPS',
279  true
280  );
281  $expiration = $expirationDuration ? $expirationDuration + time() : null;
282  } else {
283  $expiration = null;
284  }
285  $response->setCookie( 'forceHTTPS', 'true', $expiration,
286  [ 'prefix' => '', 'secure' => false ] + $this->cookieOptions );
287  } else {
288  $response->clearCookie( 'forceHTTPS',
289  [ 'prefix' => '', 'secure' => false ] + $this->cookieOptions );
290  }
291  }
292 
297  protected function setLoggedOutCookie( $loggedOut, WebRequest $request ) {
298  if ( $loggedOut + 86400 > time() &&
299  $loggedOut !== (int)$this->getCookie( $request, 'LoggedOut', $this->cookieOptions['prefix'] )
300  ) {
301  $request->response()->setCookie( 'LoggedOut', (string)$loggedOut, $loggedOut + 86400,
302  $this->cookieOptions );
303  }
304  }
305 
306  public function getVaryCookies() {
307  return [
308  // Vary on token and session because those are the real authn
309  // determiners. UserID and UserName don't matter without those.
310  $this->cookieOptions['prefix'] . 'Token',
311  $this->cookieOptions['prefix'] . 'LoggedOut',
312  $this->params['sessionName'],
313  'forceHTTPS',
314  ];
315  }
316 
317  public function suggestLoginUsername( WebRequest $request ) {
318  $name = $this->getCookie( $request, 'UserName', $this->cookieOptions['prefix'] );
319  if ( $name !== null ) {
320  if ( $this->userNameUtils->isTemp( $name ) ) {
321  $name = false;
322  } else {
323  $name = $this->userNameUtils->getCanonical( $name, UserRigorOptions::RIGOR_USABLE );
324  }
325  }
326  return $name === false ? null : $name;
327  }
328 
334  protected function getUserInfoFromCookies( $request ) {
335  $prefix = $this->cookieOptions['prefix'];
336  return [
337  $this->getCookie( $request, 'UserID', $prefix ),
338  $this->getCookie( $request, 'UserName', $prefix ),
339  $this->getCookie( $request, 'Token', $prefix ),
340  ];
341  }
342 
351  protected function getCookie( $request, $key, $prefix, $default = null ) {
352  if ( $this->useCrossSiteCookies ) {
353  $value = $request->getCrossSiteCookie( $key, $prefix, $default );
354  } else {
355  $value = $request->getCookie( $key, $prefix, $default );
356  }
357  if ( $value === 'deleted' ) {
358  // PHP uses this value when deleting cookies. A legitimate cookie will never have
359  // this value (usernames start with uppercase, token is longer, other auth cookies
360  // are booleans or integers). Seeing this means that in a previous request we told the
361  // client to delete the cookie, but it has poor cookie handling. Pretend the cookie is
362  // not there to avoid invalidating the session.
363  return null;
364  }
365  return $value;
366  }
367 
374  protected function cookieDataToExport( $user, $remember ) {
375  if ( $user->isAnon() ) {
376  return [
377  'UserID' => false,
378  'Token' => false,
379  ];
380  } else {
381  return [
382  'UserID' => $user->getId(),
383  'UserName' => $user->getName(),
384  'Token' => $remember ? (string)$user->getToken() : false,
385  ];
386  }
387  }
388 
394  protected function sessionDataToExport( $user ) {
395  return [];
396  }
397 
398  public function whyNoSession() {
399  return wfMessage( 'sessionprovider-nocookies' );
400  }
401 
402  public function getRememberUserDuration() {
403  return min( $this->getLoginCookieExpiration( 'UserID', true ),
404  $this->getLoginCookieExpiration( 'Token', true ) ) ?: null;
405  }
406 
413  protected function getExtendedLoginCookies() {
414  return [ 'UserID', 'UserName', 'Token' ];
415  }
416 
427  protected function getLoginCookieExpiration( $cookieName, $shouldRememberUser ) {
428  $extendedCookies = $this->getExtendedLoginCookies();
429  $normalExpiration = $this->getConfig()->get( MainConfigNames::CookieExpiration );
430 
431  if ( $shouldRememberUser && in_array( $cookieName, $extendedCookies, true ) ) {
432  $extendedExpiration = $this->getConfig()->get( MainConfigNames::ExtendedLoginCookieExpiration );
433 
434  return ( $extendedExpiration !== null ) ? (int)$extendedExpiration : (int)$normalExpiration;
435  } else {
436  return (int)$normalExpiration;
437  }
438  }
439 }
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.
Definition: SessionInfo.php:37
const MIN_PRIORITY
Minimum allowed priority.
Definition: SessionInfo.php:39
const MAX_PRIORITY
Maximum allowed priority.
Definition: SessionInfo.php:42
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
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition: User.php:71
The WebRequest class encapsulates getting at data passed in the URL or via a POSTed form stripping il...
Definition: WebRequest.php:49
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.