123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443 |
- <?php
- /**
- * MediaWiki cookie-based session provider interface
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- * @ingroup Session
- */
- namespace MediaWiki\Session;
- use Config;
- use User;
- use WebRequest;
- /**
- * A CookieSessionProvider persists sessions using cookies
- *
- * @ingroup Session
- * @since 1.27
- */
- class CookieSessionProvider extends SessionProvider {
- /** @var mixed[] */
- protected $params = [];
- /** @var mixed[] */
- protected $cookieOptions = [];
- /**
- * @param array $params Keys include:
- * - priority: (required) Priority of the returned sessions
- * - callUserSetCookiesHook: Whether to call the deprecated hook
- * - sessionName: Session cookie name. Doesn't honor 'prefix'. Defaults to
- * $wgSessionName, or $wgCookiePrefix . '_session' if that is unset.
- * - cookieOptions: Options to pass to WebRequest::setCookie():
- * - prefix: Cookie prefix, defaults to $wgCookiePrefix
- * - path: Cookie path, defaults to $wgCookiePath
- * - domain: Cookie domain, defaults to $wgCookieDomain
- * - secure: Cookie secure flag, defaults to $wgCookieSecure
- * - httpOnly: Cookie httpOnly flag, defaults to $wgCookieHttpOnly
- */
- public function __construct( $params = [] ) {
- parent::__construct();
- $params += [
- 'cookieOptions' => [],
- // @codeCoverageIgnoreStart
- ];
- // @codeCoverageIgnoreEnd
- if ( !isset( $params['priority'] ) ) {
- throw new \InvalidArgumentException( __METHOD__ . ': priority must be specified' );
- }
- if ( $params['priority'] < SessionInfo::MIN_PRIORITY ||
- $params['priority'] > SessionInfo::MAX_PRIORITY
- ) {
- throw new \InvalidArgumentException( __METHOD__ . ': Invalid priority' );
- }
- if ( !is_array( $params['cookieOptions'] ) ) {
- throw new \InvalidArgumentException( __METHOD__ . ': cookieOptions must be an array' );
- }
- $this->priority = $params['priority'];
- $this->cookieOptions = $params['cookieOptions'];
- $this->params = $params;
- unset( $this->params['priority'] );
- unset( $this->params['cookieOptions'] );
- }
- public function setConfig( Config $config ) {
- parent::setConfig( $config );
- // @codeCoverageIgnoreStart
- $this->params += [
- // @codeCoverageIgnoreEnd
- 'callUserSetCookiesHook' => false,
- 'sessionName' =>
- $config->get( 'SessionName' ) ?: $config->get( 'CookiePrefix' ) . '_session',
- ];
- // @codeCoverageIgnoreStart
- $this->cookieOptions += [
- // @codeCoverageIgnoreEnd
- 'prefix' => $config->get( 'CookiePrefix' ),
- 'path' => $config->get( 'CookiePath' ),
- 'domain' => $config->get( 'CookieDomain' ),
- 'secure' => $config->get( 'CookieSecure' ),
- 'httpOnly' => $config->get( 'CookieHttpOnly' ),
- ];
- }
- public function provideSessionInfo( WebRequest $request ) {
- $sessionId = $this->getCookie( $request, $this->params['sessionName'], '' );
- $info = [
- 'provider' => $this,
- 'forceHTTPS' => $this->getCookie( $request, 'forceHTTPS', '', false )
- ];
- if ( SessionManager::validateSessionId( $sessionId ) ) {
- $info['id'] = $sessionId;
- $info['persisted'] = true;
- }
- list( $userId, $userName, $token ) = $this->getUserInfoFromCookies( $request );
- if ( $userId !== null ) {
- try {
- $userInfo = UserInfo::newFromId( $userId );
- } catch ( \InvalidArgumentException $ex ) {
- return null;
- }
- // Sanity check
- if ( $userName !== null && $userInfo->getName() !== $userName ) {
- $this->logger->warning(
- 'Session "{session}" requested with mismatched UserID and UserName cookies.',
- [
- 'session' => $sessionId,
- 'mismatch' => [
- 'userid' => $userId,
- 'cookie_username' => $userName,
- 'username' => $userInfo->getName(),
- ],
- ] );
- return null;
- }
- if ( $token !== null ) {
- if ( !hash_equals( $userInfo->getToken(), $token ) ) {
- $this->logger->warning(
- 'Session "{session}" requested with invalid Token cookie.',
- [
- 'session' => $sessionId,
- 'userid' => $userId,
- 'username' => $userInfo->getName(),
- ] );
- return null;
- }
- $info['userInfo'] = $userInfo->verified();
- $info['persisted'] = true; // If we have user+token, it should be
- } elseif ( isset( $info['id'] ) ) {
- $info['userInfo'] = $userInfo;
- } else {
- // No point in returning, loadSessionInfoFromStore() will
- // reject it anyway.
- return null;
- }
- } elseif ( isset( $info['id'] ) ) {
- // No UserID cookie, so insist that the session is anonymous.
- // Note: this event occurs for several normal activities:
- // * anon visits Special:UserLogin
- // * anon browsing after seeing Special:UserLogin
- // * anon browsing after edit or preview
- $this->logger->debug(
- 'Session "{session}" requested without UserID cookie',
- [
- 'session' => $info['id'],
- ] );
- $info['userInfo'] = UserInfo::newAnonymous();
- } else {
- // No session ID and no user is the same as an empty session, so
- // there's no point.
- return null;
- }
- return new SessionInfo( $this->priority, $info );
- }
- public function persistsSessionId() {
- return true;
- }
- public function canChangeUser() {
- return true;
- }
- public function persistSession( SessionBackend $session, WebRequest $request ) {
- $response = $request->response();
- if ( $response->headersSent() ) {
- // Can't do anything now
- $this->logger->debug( __METHOD__ . ': Headers already sent' );
- return;
- }
- $user = $session->getUser();
- $cookies = $this->cookieDataToExport( $user, $session->shouldRememberUser() );
- $sessionData = $this->sessionDataToExport( $user );
- // Legacy hook
- if ( $this->params['callUserSetCookiesHook'] && !$user->isAnon() ) {
- \Hooks::run( 'UserSetCookies', [ $user, &$sessionData, &$cookies ] );
- }
- $options = $this->cookieOptions;
- $forceHTTPS = $session->shouldForceHTTPS() || $user->requiresHTTPS();
- if ( $forceHTTPS ) {
- // Don't set the secure flag if the request came in
- // over "http", for backwards compat.
- // @todo Break that backwards compat properly.
- $options['secure'] = $this->config->get( 'CookieSecure' );
- }
- $response->setCookie( $this->params['sessionName'], $session->getId(), null,
- [ 'prefix' => '' ] + $options
- );
- foreach ( $cookies as $key => $value ) {
- if ( $value === false ) {
- $response->clearCookie( $key, $options );
- } else {
- $expirationDuration = $this->getLoginCookieExpiration( $key, $session->shouldRememberUser() );
- $expiration = $expirationDuration ? $expirationDuration + time() : null;
- $response->setCookie( $key, (string)$value, $expiration, $options );
- }
- }
- $this->setForceHTTPSCookie( $forceHTTPS, $session, $request );
- $this->setLoggedOutCookie( $session->getLoggedOutTimestamp(), $request );
- if ( $sessionData ) {
- $session->addData( $sessionData );
- }
- }
- public function unpersistSession( WebRequest $request ) {
- $response = $request->response();
- if ( $response->headersSent() ) {
- // Can't do anything now
- $this->logger->debug( __METHOD__ . ': Headers already sent' );
- return;
- }
- $cookies = [
- 'UserID' => false,
- 'Token' => false,
- ];
- $response->clearCookie(
- $this->params['sessionName'], [ 'prefix' => '' ] + $this->cookieOptions
- );
- foreach ( $cookies as $key => $value ) {
- $response->clearCookie( $key, $this->cookieOptions );
- }
- $this->setForceHTTPSCookie( false, null, $request );
- }
- /**
- * Set the "forceHTTPS" cookie
- * @param bool $set Whether the cookie should be set or not
- * @param SessionBackend|null $backend
- * @param WebRequest $request
- */
- protected function setForceHTTPSCookie(
- $set, SessionBackend $backend = null, WebRequest $request
- ) {
- $response = $request->response();
- if ( $set ) {
- if ( $backend->shouldRememberUser() ) {
- $expirationDuration = $this->getLoginCookieExpiration(
- 'forceHTTPS',
- true
- );
- $expiration = $expirationDuration ? $expirationDuration + time() : null;
- } else {
- $expiration = null;
- }
- $response->setCookie( 'forceHTTPS', 'true', $expiration,
- [ 'prefix' => '', 'secure' => false ] + $this->cookieOptions );
- } else {
- $response->clearCookie( 'forceHTTPS',
- [ 'prefix' => '', 'secure' => false ] + $this->cookieOptions );
- }
- }
- /**
- * Set the "logged out" cookie
- * @param int $loggedOut timestamp
- * @param WebRequest $request
- */
- protected function setLoggedOutCookie( $loggedOut, WebRequest $request ) {
- if ( $loggedOut + 86400 > time() &&
- $loggedOut !== (int)$this->getCookie( $request, 'LoggedOut', $this->cookieOptions['prefix'] )
- ) {
- $request->response()->setCookie( 'LoggedOut', $loggedOut, $loggedOut + 86400,
- $this->cookieOptions );
- }
- }
- public function getVaryCookies() {
- return [
- // Vary on token and session because those are the real authn
- // determiners. UserID and UserName don't matter without those.
- $this->cookieOptions['prefix'] . 'Token',
- $this->cookieOptions['prefix'] . 'LoggedOut',
- $this->params['sessionName'],
- 'forceHTTPS',
- ];
- }
- public function suggestLoginUsername( WebRequest $request ) {
- $name = $this->getCookie( $request, 'UserName', $this->cookieOptions['prefix'] );
- if ( $name !== null ) {
- $name = User::getCanonicalName( $name, 'usable' );
- }
- return $name === false ? null : $name;
- }
- /**
- * Fetch the user identity from cookies
- * @param \WebRequest $request
- * @return array (string|null $id, string|null $username, string|null $token)
- */
- protected function getUserInfoFromCookies( $request ) {
- $prefix = $this->cookieOptions['prefix'];
- return [
- $this->getCookie( $request, 'UserID', $prefix ),
- $this->getCookie( $request, 'UserName', $prefix ),
- $this->getCookie( $request, 'Token', $prefix ),
- ];
- }
- /**
- * Get a cookie. Contains an auth-specific hack.
- * @param \WebRequest $request
- * @param string $key
- * @param string $prefix
- * @param mixed|null $default
- * @return mixed
- */
- protected function getCookie( $request, $key, $prefix, $default = null ) {
- $value = $request->getCookie( $key, $prefix, $default );
- if ( $value === 'deleted' ) {
- // PHP uses this value when deleting cookies. A legitimate cookie will never have
- // this value (usernames start with uppercase, token is longer, other auth cookies
- // are booleans or integers). Seeing this means that in a previous request we told the
- // client to delete the cookie, but it has poor cookie handling. Pretend the cookie is
- // not there to avoid invalidating the session.
- return null;
- }
- return $value;
- }
- /**
- * Return the data to store in cookies
- * @param User $user
- * @param bool $remember
- * @return array $cookies Set value false to unset the cookie
- */
- protected function cookieDataToExport( $user, $remember ) {
- if ( $user->isAnon() ) {
- return [
- 'UserID' => false,
- 'Token' => false,
- ];
- } else {
- return [
- 'UserID' => $user->getId(),
- 'UserName' => $user->getName(),
- 'Token' => $remember ? (string)$user->getToken() : false,
- ];
- }
- }
- /**
- * Return extra data to store in the session
- * @param User $user
- * @return array $session
- */
- protected function sessionDataToExport( $user ) {
- // If we're calling the legacy hook, we should populate $session
- // like User::setCookies() did.
- if ( !$user->isAnon() && $this->params['callUserSetCookiesHook'] ) {
- return [
- 'wsUserID' => $user->getId(),
- 'wsToken' => $user->getToken(),
- 'wsUserName' => $user->getName(),
- ];
- }
- return [];
- }
- public function whyNoSession() {
- return wfMessage( 'sessionprovider-nocookies' );
- }
- public function getRememberUserDuration() {
- return min( $this->getLoginCookieExpiration( 'UserID', true ),
- $this->getLoginCookieExpiration( 'Token', true ) ) ?: null;
- }
- /**
- * Gets the list of cookies that must be set to the 'remember me' duration,
- * if $wgExtendedLoginCookieExpiration is in use.
- *
- * @return string[] Array of unprefixed cookie keys
- */
- protected function getExtendedLoginCookies() {
- return [ 'UserID', 'UserName', 'Token' ];
- }
- /**
- * Returns the lifespan of the login cookies, in seconds. 0 means until the end of the session.
- *
- * Cookies that are session-length do not call this function.
- *
- * @param string $cookieName
- * @param bool $shouldRememberUser Whether the user should be remembered
- * long-term
- * @return int Cookie expiration time in seconds; 0 for session cookies
- */
- protected function getLoginCookieExpiration( $cookieName, $shouldRememberUser ) {
- $extendedCookies = $this->getExtendedLoginCookies();
- $normalExpiration = $this->config->get( 'CookieExpiration' );
- if ( $shouldRememberUser && in_array( $cookieName, $extendedCookies, true ) ) {
- $extendedExpiration = $this->config->get( 'ExtendedLoginCookieExpiration' );
- return ( $extendedExpiration !== null ) ? (int)$extendedExpiration : (int)$normalExpiration;
- } else {
- return (int)$normalExpiration;
- }
- }
- }
|