Merge "Remove unused functions from unroll of Article::__call"
[lhc/web/wiklou.git] / includes / session / CookieSessionProvider.php
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 = array();
39 protected $cookieOptions = array();
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 = array() ) {
55 parent::__construct();
56
57 $params += array(
58 'cookieOptions' => array(),
59 // @codeCoverageIgnoreStart
60 );
61 // @codeCoverageIgnoreEnd
62
63 if ( !isset( $params['priority'] ) ) {
64 throw new \InvalidArgumentException( __METHOD__ . ': priority must be specified' );
65 }
66 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 += array(
88 // @codeCoverageIgnoreEnd
89 'callUserSetCookiesHook' => false,
90 'sessionName' =>
91 $config->get( 'SessionName' ) ?: $config->get( 'CookiePrefix' ) . '_session',
92 );
93
94 // @codeCoverageIgnoreStart
95 $this->cookieOptions += array(
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 $info = array(
107 'id' => $this->getCookie( $request, $this->params['sessionName'], '' ),
108 'provider' => $this,
109 'forceHTTPS' => $this->getCookie( $request, 'forceHTTPS', '', false )
110 );
111 if ( !SessionManager::validateSessionId( $info['id'] ) ) {
112 unset( $info['id'] );
113 }
114 $info['persisted'] = isset( $info['id'] );
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 return null;
127 }
128
129 if ( $token !== null ) {
130 if ( !hash_equals( $userInfo->getToken(), $token ) ) {
131 return null;
132 }
133 $info['userInfo'] = $userInfo->verified();
134 } elseif ( isset( $info['id'] ) ) {
135 $info['userInfo'] = $userInfo;
136 } else {
137 // No point in returning, loadSessionInfoFromStore() will
138 // reject it anyway.
139 return null;
140 }
141 } elseif ( isset( $info['id'] ) ) {
142 // No UserID cookie, so insist that the session is anonymous.
143 $info['userInfo'] = UserInfo::newAnonymous();
144 } else {
145 // No session ID and no user is the same as an empty session, so
146 // there's no point.
147 return null;
148 }
149
150 return new SessionInfo( $this->priority, $info );
151 }
152
153 public function persistsSessionId() {
154 return true;
155 }
156
157 public function canChangeUser() {
158 return true;
159 }
160
161 public function persistSession( SessionBackend $session, WebRequest $request ) {
162 $response = $request->response();
163 if ( $response->headersSent() ) {
164 // Can't do anything now
165 $this->logger->debug( __METHOD__ . ': Headers already sent' );
166 return;
167 }
168
169 $user = $session->getUser();
170
171 $cookies = $this->cookieDataToExport( $user, $session->shouldRememberUser() );
172 $sessionData = $this->sessionDataToExport( $user );
173
174 // Legacy hook
175 if ( $this->params['callUserSetCookiesHook'] && !$user->isAnon() ) {
176 \Hooks::run( 'UserSetCookies', array( $user, &$sessionData, &$cookies ) );
177 }
178
179 $options = $this->cookieOptions;
180
181 $forceHTTPS = $session->shouldForceHTTPS() || $user->requiresHTTPS();
182 if ( $forceHTTPS ) {
183 // Don't set the secure flag if the request came in
184 // over "http", for backwards compat.
185 // @todo Break that backwards compat properly.
186 $options['secure'] = $this->config->get( 'CookieSecure' );
187 }
188
189 $response->setCookie( $this->params['sessionName'], $session->getId(), null,
190 array( 'prefix' => '' ) + $options
191 );
192
193 $extendedCookies = $this->config->get( 'ExtendedLoginCookies' );
194 $extendedExpiry = $this->config->get( 'ExtendedLoginCookieExpiration' );
195
196 foreach ( $cookies as $key => $value ) {
197 if ( $value === false ) {
198 $response->clearCookie( $key, $options );
199 } else {
200 if ( $extendedExpiry !== null && in_array( $key, $extendedCookies ) ) {
201 $expiry = time() + (int)$extendedExpiry;
202 } else {
203 $expiry = 0; // Default cookie expiration
204 }
205 $response->setCookie( $key, (string)$value, $expiry, $options );
206 }
207 }
208
209 $this->setForceHTTPSCookie( $forceHTTPS, $session, $request );
210 $this->setLoggedOutCookie( $session->getLoggedOutTimestamp(), $request );
211
212 if ( $sessionData ) {
213 $session->addData( $sessionData );
214 }
215 }
216
217 public function unpersistSession( WebRequest $request ) {
218 $response = $request->response();
219 if ( $response->headersSent() ) {
220 // Can't do anything now
221 $this->logger->debug( __METHOD__ . ': Headers already sent' );
222 return;
223 }
224
225 $cookies = array(
226 'UserID' => false,
227 'Token' => false,
228 );
229
230 $response->clearCookie(
231 $this->params['sessionName'], array( 'prefix' => '' ) + $this->cookieOptions
232 );
233
234 foreach ( $cookies as $key => $value ) {
235 $response->clearCookie( $key, $this->cookieOptions );
236 }
237
238 $this->setForceHTTPSCookie( false, null, $request );
239 }
240
241 /**
242 * Set the "forceHTTPS" cookie
243 * @param bool $set Whether the cookie should be set or not
244 * @param SessionBackend|null $backend
245 * @param WebRequest $request
246 */
247 protected function setForceHTTPSCookie(
248 $set, SessionBackend $backend = null, WebRequest $request
249 ) {
250 $response = $request->response();
251 if ( $set ) {
252 $response->setCookie( 'forceHTTPS', 'true', $backend->shouldRememberUser() ? 0 : null,
253 array( 'prefix' => '', 'secure' => false ) + $this->cookieOptions );
254 } else {
255 $response->clearCookie( 'forceHTTPS',
256 array( 'prefix' => '', 'secure' => false ) + $this->cookieOptions );
257 }
258 }
259
260 /**
261 * Set the "logged out" cookie
262 * @param int $loggedOut timestamp
263 * @param WebRequest $request
264 */
265 protected function setLoggedOutCookie( $loggedOut, WebRequest $request ) {
266 if ( $loggedOut + 86400 > time() &&
267 $loggedOut !== (int)$this->getCookie( $request, 'LoggedOut', $this->cookieOptions['prefix'] )
268 ) {
269 $request->response()->setCookie( 'LoggedOut', $loggedOut, $loggedOut + 86400,
270 $this->cookieOptions );
271 }
272 }
273
274 public function getVaryCookies() {
275 return array(
276 // Vary on token and session because those are the real authn
277 // determiners. UserID and UserName don't matter without those.
278 $this->cookieOptions['prefix'] . 'Token',
279 $this->cookieOptions['prefix'] . 'LoggedOut',
280 $this->params['sessionName'],
281 'forceHTTPS',
282 );
283 }
284
285 public function suggestLoginUsername( WebRequest $request ) {
286 $name = $this->getCookie( $request, 'UserName', $this->cookieOptions['prefix'] );
287 if ( $name !== null ) {
288 $name = User::getCanonicalName( $name, 'usable' );
289 }
290 return $name === false ? null : $name;
291 }
292
293 /**
294 * Fetch the user identity from cookies
295 * @param \WebRequest $request
296 * @return array (string|null $id, string|null $username, string|null $token)
297 */
298 protected function getUserInfoFromCookies( $request ) {
299 $prefix = $this->cookieOptions['prefix'];
300 return array(
301 $this->getCookie( $request, 'UserID', $prefix ),
302 $this->getCookie( $request, 'UserName', $prefix ),
303 $this->getCookie( $request, 'Token', $prefix ),
304 );
305 }
306
307 /**
308 * Get a cookie. Contains an auth-specific hack.
309 * @param \WebRequest $request
310 * @param string $key
311 * @param string $prefix
312 * @param mixed $default
313 * @return mixed
314 */
315 protected function getCookie( $request, $key, $prefix, $default = null ) {
316 $value = $request->getCookie( $key, $prefix, $default );
317 if ( $value === 'deleted' ) {
318 // PHP uses this value when deleting cookies. A legitimate cookie will never have
319 // this value (usernames start with uppercase, token is longer, other auth cookies
320 // are booleans or integers). Seeing this means that in a previous request we told the
321 // client to delete the cookie, but it has poor cookie handling. Pretend the cookie is
322 // not there to avoid invalidating the session.
323 return null;
324 }
325 return $value;
326 }
327
328 /**
329 * Return the data to store in cookies
330 * @param User $user
331 * @param bool $remember
332 * @return array $cookies Set value false to unset the cookie
333 */
334 protected function cookieDataToExport( $user, $remember ) {
335 if ( $user->isAnon() ) {
336 return array(
337 'UserID' => false,
338 'Token' => false,
339 );
340 } else {
341 return array(
342 'UserID' => $user->getId(),
343 'UserName' => $user->getName(),
344 'Token' => $remember ? (string)$user->getToken() : false,
345 );
346 }
347 }
348
349 /**
350 * Return extra data to store in the session
351 * @param User $user
352 * @return array $session
353 */
354 protected function sessionDataToExport( $user ) {
355 // If we're calling the legacy hook, we should populate $session
356 // like User::setCookies() did.
357 if ( !$user->isAnon() && $this->params['callUserSetCookiesHook'] ) {
358 return array(
359 'wsUserID' => $user->getId(),
360 'wsToken' => $user->getToken(),
361 'wsUserName' => $user->getName(),
362 );
363 }
364
365 return array();
366 }
367
368 public function whyNoSession() {
369 return wfMessage( 'sessionprovider-nocookies' );
370 }
371
372 }