CookieSessionProvider.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  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. namespace MediaWiki\Session;
  24. use Config;
  25. use User;
  26. use WebRequest;
  27. /**
  28. * A CookieSessionProvider persists sessions using cookies
  29. *
  30. * @ingroup Session
  31. * @since 1.27
  32. */
  33. class CookieSessionProvider extends SessionProvider {
  34. /** @var mixed[] */
  35. protected $params = [];
  36. /** @var mixed[] */
  37. protected $cookieOptions = [];
  38. /**
  39. * @param array $params Keys include:
  40. * - priority: (required) Priority of the returned sessions
  41. * - callUserSetCookiesHook: Whether to call the deprecated hook
  42. * - sessionName: Session cookie name. Doesn't honor 'prefix'. Defaults to
  43. * $wgSessionName, or $wgCookiePrefix . '_session' if that is unset.
  44. * - cookieOptions: Options to pass to WebRequest::setCookie():
  45. * - prefix: Cookie prefix, defaults to $wgCookiePrefix
  46. * - path: Cookie path, defaults to $wgCookiePath
  47. * - domain: Cookie domain, defaults to $wgCookieDomain
  48. * - secure: Cookie secure flag, defaults to $wgCookieSecure
  49. * - httpOnly: Cookie httpOnly flag, defaults to $wgCookieHttpOnly
  50. */
  51. public function __construct( $params = [] ) {
  52. parent::__construct();
  53. $params += [
  54. 'cookieOptions' => [],
  55. // @codeCoverageIgnoreStart
  56. ];
  57. // @codeCoverageIgnoreEnd
  58. if ( !isset( $params['priority'] ) ) {
  59. throw new \InvalidArgumentException( __METHOD__ . ': priority must be specified' );
  60. }
  61. if ( $params['priority'] < SessionInfo::MIN_PRIORITY ||
  62. $params['priority'] > SessionInfo::MAX_PRIORITY
  63. ) {
  64. throw new \InvalidArgumentException( __METHOD__ . ': Invalid priority' );
  65. }
  66. if ( !is_array( $params['cookieOptions'] ) ) {
  67. throw new \InvalidArgumentException( __METHOD__ . ': cookieOptions must be an array' );
  68. }
  69. $this->priority = $params['priority'];
  70. $this->cookieOptions = $params['cookieOptions'];
  71. $this->params = $params;
  72. unset( $this->params['priority'] );
  73. unset( $this->params['cookieOptions'] );
  74. }
  75. public function setConfig( Config $config ) {
  76. parent::setConfig( $config );
  77. // @codeCoverageIgnoreStart
  78. $this->params += [
  79. // @codeCoverageIgnoreEnd
  80. 'callUserSetCookiesHook' => false,
  81. 'sessionName' =>
  82. $config->get( 'SessionName' ) ?: $config->get( 'CookiePrefix' ) . '_session',
  83. ];
  84. // @codeCoverageIgnoreStart
  85. $this->cookieOptions += [
  86. // @codeCoverageIgnoreEnd
  87. 'prefix' => $config->get( 'CookiePrefix' ),
  88. 'path' => $config->get( 'CookiePath' ),
  89. 'domain' => $config->get( 'CookieDomain' ),
  90. 'secure' => $config->get( 'CookieSecure' ),
  91. 'httpOnly' => $config->get( 'CookieHttpOnly' ),
  92. ];
  93. }
  94. public function provideSessionInfo( WebRequest $request ) {
  95. $sessionId = $this->getCookie( $request, $this->params['sessionName'], '' );
  96. $info = [
  97. 'provider' => $this,
  98. 'forceHTTPS' => $this->getCookie( $request, 'forceHTTPS', '', false )
  99. ];
  100. if ( SessionManager::validateSessionId( $sessionId ) ) {
  101. $info['id'] = $sessionId;
  102. $info['persisted'] = true;
  103. }
  104. list( $userId, $userName, $token ) = $this->getUserInfoFromCookies( $request );
  105. if ( $userId !== null ) {
  106. try {
  107. $userInfo = UserInfo::newFromId( $userId );
  108. } catch ( \InvalidArgumentException $ex ) {
  109. return null;
  110. }
  111. // Sanity check
  112. if ( $userName !== null && $userInfo->getName() !== $userName ) {
  113. $this->logger->warning(
  114. 'Session "{session}" requested with mismatched UserID and UserName cookies.',
  115. [
  116. 'session' => $sessionId,
  117. 'mismatch' => [
  118. 'userid' => $userId,
  119. 'cookie_username' => $userName,
  120. 'username' => $userInfo->getName(),
  121. ],
  122. ] );
  123. return null;
  124. }
  125. if ( $token !== null ) {
  126. if ( !hash_equals( $userInfo->getToken(), $token ) ) {
  127. $this->logger->warning(
  128. 'Session "{session}" requested with invalid Token cookie.',
  129. [
  130. 'session' => $sessionId,
  131. 'userid' => $userId,
  132. 'username' => $userInfo->getName(),
  133. ] );
  134. return null;
  135. }
  136. $info['userInfo'] = $userInfo->verified();
  137. $info['persisted'] = true; // If we have user+token, it should be
  138. } elseif ( isset( $info['id'] ) ) {
  139. $info['userInfo'] = $userInfo;
  140. } else {
  141. // No point in returning, loadSessionInfoFromStore() will
  142. // reject it anyway.
  143. return null;
  144. }
  145. } elseif ( isset( $info['id'] ) ) {
  146. // No UserID cookie, so insist that the session is anonymous.
  147. // Note: this event occurs for several normal activities:
  148. // * anon visits Special:UserLogin
  149. // * anon browsing after seeing Special:UserLogin
  150. // * anon browsing after edit or preview
  151. $this->logger->debug(
  152. 'Session "{session}" requested without UserID cookie',
  153. [
  154. 'session' => $info['id'],
  155. ] );
  156. $info['userInfo'] = UserInfo::newAnonymous();
  157. } else {
  158. // No session ID and no user is the same as an empty session, so
  159. // there's no point.
  160. return null;
  161. }
  162. return new SessionInfo( $this->priority, $info );
  163. }
  164. public function persistsSessionId() {
  165. return true;
  166. }
  167. public function canChangeUser() {
  168. return true;
  169. }
  170. public function persistSession( SessionBackend $session, WebRequest $request ) {
  171. $response = $request->response();
  172. if ( $response->headersSent() ) {
  173. // Can't do anything now
  174. $this->logger->debug( __METHOD__ . ': Headers already sent' );
  175. return;
  176. }
  177. $user = $session->getUser();
  178. $cookies = $this->cookieDataToExport( $user, $session->shouldRememberUser() );
  179. $sessionData = $this->sessionDataToExport( $user );
  180. // Legacy hook
  181. if ( $this->params['callUserSetCookiesHook'] && !$user->isAnon() ) {
  182. \Hooks::run( 'UserSetCookies', [ $user, &$sessionData, &$cookies ] );
  183. }
  184. $options = $this->cookieOptions;
  185. $forceHTTPS = $session->shouldForceHTTPS() || $user->requiresHTTPS();
  186. if ( $forceHTTPS ) {
  187. // Don't set the secure flag if the request came in
  188. // over "http", for backwards compat.
  189. // @todo Break that backwards compat properly.
  190. $options['secure'] = $this->config->get( 'CookieSecure' );
  191. }
  192. $response->setCookie( $this->params['sessionName'], $session->getId(), null,
  193. [ 'prefix' => '' ] + $options
  194. );
  195. foreach ( $cookies as $key => $value ) {
  196. if ( $value === false ) {
  197. $response->clearCookie( $key, $options );
  198. } else {
  199. $expirationDuration = $this->getLoginCookieExpiration( $key, $session->shouldRememberUser() );
  200. $expiration = $expirationDuration ? $expirationDuration + time() : null;
  201. $response->setCookie( $key, (string)$value, $expiration, $options );
  202. }
  203. }
  204. $this->setForceHTTPSCookie( $forceHTTPS, $session, $request );
  205. $this->setLoggedOutCookie( $session->getLoggedOutTimestamp(), $request );
  206. if ( $sessionData ) {
  207. $session->addData( $sessionData );
  208. }
  209. }
  210. public function unpersistSession( WebRequest $request ) {
  211. $response = $request->response();
  212. if ( $response->headersSent() ) {
  213. // Can't do anything now
  214. $this->logger->debug( __METHOD__ . ': Headers already sent' );
  215. return;
  216. }
  217. $cookies = [
  218. 'UserID' => false,
  219. 'Token' => false,
  220. ];
  221. $response->clearCookie(
  222. $this->params['sessionName'], [ 'prefix' => '' ] + $this->cookieOptions
  223. );
  224. foreach ( $cookies as $key => $value ) {
  225. $response->clearCookie( $key, $this->cookieOptions );
  226. }
  227. $this->setForceHTTPSCookie( false, null, $request );
  228. }
  229. /**
  230. * Set the "forceHTTPS" cookie
  231. * @param bool $set Whether the cookie should be set or not
  232. * @param SessionBackend|null $backend
  233. * @param WebRequest $request
  234. */
  235. protected function setForceHTTPSCookie(
  236. $set, SessionBackend $backend = null, WebRequest $request
  237. ) {
  238. $response = $request->response();
  239. if ( $set ) {
  240. if ( $backend->shouldRememberUser() ) {
  241. $expirationDuration = $this->getLoginCookieExpiration(
  242. 'forceHTTPS',
  243. true
  244. );
  245. $expiration = $expirationDuration ? $expirationDuration + time() : null;
  246. } else {
  247. $expiration = null;
  248. }
  249. $response->setCookie( 'forceHTTPS', 'true', $expiration,
  250. [ 'prefix' => '', 'secure' => false ] + $this->cookieOptions );
  251. } else {
  252. $response->clearCookie( 'forceHTTPS',
  253. [ 'prefix' => '', 'secure' => false ] + $this->cookieOptions );
  254. }
  255. }
  256. /**
  257. * Set the "logged out" cookie
  258. * @param int $loggedOut timestamp
  259. * @param WebRequest $request
  260. */
  261. protected function setLoggedOutCookie( $loggedOut, WebRequest $request ) {
  262. if ( $loggedOut + 86400 > time() &&
  263. $loggedOut !== (int)$this->getCookie( $request, 'LoggedOut', $this->cookieOptions['prefix'] )
  264. ) {
  265. $request->response()->setCookie( 'LoggedOut', $loggedOut, $loggedOut + 86400,
  266. $this->cookieOptions );
  267. }
  268. }
  269. public function getVaryCookies() {
  270. return [
  271. // Vary on token and session because those are the real authn
  272. // determiners. UserID and UserName don't matter without those.
  273. $this->cookieOptions['prefix'] . 'Token',
  274. $this->cookieOptions['prefix'] . 'LoggedOut',
  275. $this->params['sessionName'],
  276. 'forceHTTPS',
  277. ];
  278. }
  279. public function suggestLoginUsername( WebRequest $request ) {
  280. $name = $this->getCookie( $request, 'UserName', $this->cookieOptions['prefix'] );
  281. if ( $name !== null ) {
  282. $name = User::getCanonicalName( $name, 'usable' );
  283. }
  284. return $name === false ? null : $name;
  285. }
  286. /**
  287. * Fetch the user identity from cookies
  288. * @param \WebRequest $request
  289. * @return array (string|null $id, string|null $username, string|null $token)
  290. */
  291. protected function getUserInfoFromCookies( $request ) {
  292. $prefix = $this->cookieOptions['prefix'];
  293. return [
  294. $this->getCookie( $request, 'UserID', $prefix ),
  295. $this->getCookie( $request, 'UserName', $prefix ),
  296. $this->getCookie( $request, 'Token', $prefix ),
  297. ];
  298. }
  299. /**
  300. * Get a cookie. Contains an auth-specific hack.
  301. * @param \WebRequest $request
  302. * @param string $key
  303. * @param string $prefix
  304. * @param mixed|null $default
  305. * @return mixed
  306. */
  307. protected function getCookie( $request, $key, $prefix, $default = null ) {
  308. $value = $request->getCookie( $key, $prefix, $default );
  309. if ( $value === 'deleted' ) {
  310. // PHP uses this value when deleting cookies. A legitimate cookie will never have
  311. // this value (usernames start with uppercase, token is longer, other auth cookies
  312. // are booleans or integers). Seeing this means that in a previous request we told the
  313. // client to delete the cookie, but it has poor cookie handling. Pretend the cookie is
  314. // not there to avoid invalidating the session.
  315. return null;
  316. }
  317. return $value;
  318. }
  319. /**
  320. * Return the data to store in cookies
  321. * @param User $user
  322. * @param bool $remember
  323. * @return array $cookies Set value false to unset the cookie
  324. */
  325. protected function cookieDataToExport( $user, $remember ) {
  326. if ( $user->isAnon() ) {
  327. return [
  328. 'UserID' => false,
  329. 'Token' => false,
  330. ];
  331. } else {
  332. return [
  333. 'UserID' => $user->getId(),
  334. 'UserName' => $user->getName(),
  335. 'Token' => $remember ? (string)$user->getToken() : false,
  336. ];
  337. }
  338. }
  339. /**
  340. * Return extra data to store in the session
  341. * @param User $user
  342. * @return array $session
  343. */
  344. protected function sessionDataToExport( $user ) {
  345. // If we're calling the legacy hook, we should populate $session
  346. // like User::setCookies() did.
  347. if ( !$user->isAnon() && $this->params['callUserSetCookiesHook'] ) {
  348. return [
  349. 'wsUserID' => $user->getId(),
  350. 'wsToken' => $user->getToken(),
  351. 'wsUserName' => $user->getName(),
  352. ];
  353. }
  354. return [];
  355. }
  356. public function whyNoSession() {
  357. return wfMessage( 'sessionprovider-nocookies' );
  358. }
  359. public function getRememberUserDuration() {
  360. return min( $this->getLoginCookieExpiration( 'UserID', true ),
  361. $this->getLoginCookieExpiration( 'Token', true ) ) ?: null;
  362. }
  363. /**
  364. * Gets the list of cookies that must be set to the 'remember me' duration,
  365. * if $wgExtendedLoginCookieExpiration is in use.
  366. *
  367. * @return string[] Array of unprefixed cookie keys
  368. */
  369. protected function getExtendedLoginCookies() {
  370. return [ 'UserID', 'UserName', 'Token' ];
  371. }
  372. /**
  373. * Returns the lifespan of the login cookies, in seconds. 0 means until the end of the session.
  374. *
  375. * Cookies that are session-length do not call this function.
  376. *
  377. * @param string $cookieName
  378. * @param bool $shouldRememberUser Whether the user should be remembered
  379. * long-term
  380. * @return int Cookie expiration time in seconds; 0 for session cookies
  381. */
  382. protected function getLoginCookieExpiration( $cookieName, $shouldRememberUser ) {
  383. $extendedCookies = $this->getExtendedLoginCookies();
  384. $normalExpiration = $this->config->get( 'CookieExpiration' );
  385. if ( $shouldRememberUser && in_array( $cookieName, $extendedCookies, true ) ) {
  386. $extendedExpiration = $this->config->get( 'ExtendedLoginCookieExpiration' );
  387. return ( $extendedExpiration !== null ) ? (int)$extendedExpiration : (int)$normalExpiration;
  388. } else {
  389. return (int)$normalExpiration;
  390. }
  391. }
  392. }