Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 59
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
CentralAuthSessionManager
0.00% covered (danger)
0.00%
0 / 59
0.00% covered (danger)
0.00%
0 / 7
420
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getCentralAuthDBForSessionKey
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 makeSessionKey
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getSessionStore
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getCentralSession
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getCentralSessionById
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 setCentralSession
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
90
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21namespace MediaWiki\Extension\CentralAuth;
22
23use MediaWiki\Config\ServiceOptions;
24use MediaWiki\Extension\CentralAuth\Config\CAMainConfigNames;
25use MediaWiki\MainConfigNames;
26use MediaWiki\MediaWikiServices;
27use MediaWiki\Session\Session;
28use MediaWiki\Session\SessionManager;
29use MWCryptRand;
30use Wikimedia\ObjectCache\BagOStuff;
31use Wikimedia\ObjectCache\CachedBagOStuff;
32use Wikimedia\Stats\IBufferingStatsdDataFactory;
33use Wikimedia\Stats\StatsFactory;
34
35class CentralAuthSessionManager {
36
37    /**
38     * @internal Only public for service wiring use
39     */
40    public const CONSTRUCTOR_OPTIONS = [
41        'CentralAuthSessionCacheType',
42        'SessionCacheType',
43    ];
44
45    /** @var BagOStuff|null Session cache */
46    private $sessionStore = null;
47
48    private ServiceOptions $options;
49    private IBufferingStatsdDataFactory $statsdDataFactory;
50    private StatsFactory $statsFactory;
51
52    public function __construct(
53        ServiceOptions $options,
54        IBufferingStatsdDataFactory $statsdDataFactory,
55        StatsFactory $statsFactory
56    ) {
57        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
58        $this->options = $options;
59        $this->statsdDataFactory = $statsdDataFactory;
60        $this->statsFactory = $statsFactory;
61    }
62
63    /**
64     * @return string db name, for session key creation
65     * Note that if there is more than one CentralAuth database
66     * in use for the same session key store, the database names
67     * MUST be unique.
68     */
69    private function getCentralAuthDBForSessionKey() {
70        return MediaWikiServices::getInstance()
71            ->getDBLoadBalancerFactory()->getPrimaryDatabase( 'virtual-centralauth' )->getDomainID();
72    }
73
74    /**
75     * @param string $keygroup
76     * @param string ...$components
77     * @return string The global session key (with proper escaping)
78     */
79    public function makeSessionKey( string $keygroup, ...$components ): string {
80        return $this->getSessionStore()->makeGlobalKey(
81            $keygroup, $this->getCentralAuthDBForSessionKey(), ...$components
82        );
83    }
84
85    /**
86     * Get a cache for storage of central sessions
87     * @return BagOStuff
88     */
89    public function getSessionStore(): BagOStuff {
90        if ( !$this->sessionStore ) {
91            $sessionCacheType = $this->options->get( CAMainConfigNames::CentralAuthSessionCacheType )
92                ?? $this->options->get( MainConfigNames::SessionCacheType );
93            $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()->getInstance( $sessionCacheType );
94            $this->sessionStore = $cache instanceof CachedBagOStuff
95                ? $cache : new CachedBagOStuff( $cache );
96        }
97
98        return $this->sessionStore;
99    }
100
101    /**
102     * Get the central session data associated with the given local session.
103     *
104     * When the session is not centrally logged in, an empty array is returned.
105     *
106     * @param Session|null $session The local session. If omitted, uses the global session.
107     * @return array
108     */
109    public function getCentralSession( $session = null ) {
110        if ( !$session ) {
111            $session = SessionManager::getGlobalSession();
112        }
113        $id = $session->get( 'CentralAuth::centralSessionId' );
114
115        if ( $id !== null ) {
116            return $this->getCentralSessionById( $id );
117        } else {
118            return [];
119        }
120    }
121
122    /**
123     * Get the central session data.
124     *
125     * The shape of the data is not enforced by this class, but in practice it will contain these keys:
126     * - sessionId: string, the central session ID
127     * - expiry: int, timestamp when the session expires
128     * - user: string, the username
129     * - token: string, the central token (gu_auth_token)
130     * - remember: bool, the "keep me logged in" flag.
131     *
132     * When $id is not found in the central session store, an empty array is returned.
133     *
134     * During central login, the session is a provisional "stub session" (which will be seen
135     * by the session provider as an anonymous session) with the following keys:
136     * - pending_name: string, the username
137     * - pending_guid: string, the central user ID
138     * - sessionId, expiry: as above
139     * @param string $id
140     * @return array
141     */
142    public function getCentralSessionById( $id ) {
143        $key = $this->makeSessionKey( 'session', $id );
144
145        $stime = microtime( true );
146        $data = $this->getSessionStore()->get( $key ) ?: [];
147        $real = microtime( true ) - $stime;
148
149        // Stay backward compatible with the dashboard feeding on
150        // this data. NOTE: $real is in second with microsecond-level
151        // precision. This is reconciled on the grafana dashboard.
152        $this->statsdDataFactory->timing( 'centralauth.session.read', $real );
153
154        $this->statsFactory->withComponent( 'CentralAuth' )
155            ->getTiming( 'session_read_seconds' )
156            ->observe( $real * 1000 );
157
158        return $data;
159    }
160
161    /**
162     * Set data in the central session. Uses the central session ID stored in the local session
163     * to find the data; if not present (or $reset is used), creates a new object under a new ID
164     * and stores the ID in the local session.
165     *
166     * When not overridden in $data, the following keys in the central session data are preserved:
167     * expiry, user, token. (Expiry will be extended if the session is beyond half its lifetime.)
168     * sessionId will be updated as needed. Other data (ie. the remember flag or the stub session
169     * fields) will be lost if not explicitly included in $data. This is true regardless of whether
170     * $reset is used.
171     *
172     * @param array $data New session data.
173     * @param bool|string $reset Reset the session ID. If a string, this is the new ID.
174     * @param Session|null $session Local session. When omitted, uses the global session.
175     * @return string|null Session ID
176     */
177    public function setCentralSession( array $data, $reset = false, $session = null ) {
178        $keepKeys = [ 'user' => true, 'token' => true, 'expiry' => true ];
179
180        $session ??= SessionManager::getGlobalSession();
181        $id = $session->get( 'CentralAuth::centralSessionId' );
182
183        if ( $reset || $id === null ) {
184            $id = is_string( $reset ) ? $reset : MWCryptRand::generateHex( 32 );
185        }
186        $data['sessionId'] = $id;
187
188        $sessionStore = $this->getSessionStore();
189        $key = $this->makeSessionKey( 'session', $id );
190
191        // Copy certain keys from the existing session, if any (T124821)
192        $existing = $sessionStore->get( $key );
193
194        if ( is_array( $existing ) ) {
195            $data += array_intersect_key( $existing, $keepKeys );
196        }
197
198        $isDirty = ( $data !== $existing );
199        if ( $isDirty || !isset( $data['expiry'] ) || $data['expiry'] < time() + 32100 ) {
200            $data['expiry'] = time() + $sessionStore::TTL_DAY;
201            $stime = microtime( true );
202            $sessionStore->set(
203                $key,
204                $data,
205                $sessionStore::TTL_DAY
206            );
207            $real = microtime( true ) - $stime;
208            // Stay backward compatible with the dashboard feeding on
209            // this data. NOTE: $real is in second with microsecond-level
210            // precision. This is reconciled on the grafana dashboard.
211            $this->statsdDataFactory->timing( 'centralauth.session.write', $real );
212
213            $this->statsFactory->withComponent( 'CentralAuth' )
214                ->getTiming( 'session_write_seconds' )
215                ->observe( $real * 1000 );
216        }
217
218        if ( $session ) {
219            $session->set( 'CentralAuth::centralSessionId', $id );
220        }
221
222        return $id;
223    }
224}