Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
27.78% covered (danger)
27.78%
50 / 180
10.00% covered (danger)
10.00%
1 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
OATHUserRepository
27.78% covered (danger)
27.78%
50 / 180
10.00% covered (danger)
10.00%
1 / 10
345.82
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 setLogger
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 findByUser
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 persist
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
72
 createKey
90.00% covered (success)
90.00%
27 / 30
0.00% covered (danger)
0.00%
0 / 1
4.02
 updateKey
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
2.00
 removeKey
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
12
 remove
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 removeAll
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
 loadKeysFromDatabase
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
42
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
19namespace MediaWiki\Extension\OATHAuth;
20
21use InvalidArgumentException;
22use MediaWiki\Config\ConfigException;
23use MediaWiki\Context\RequestContext;
24use MediaWiki\Extension\OATHAuth\Notifications\Manager;
25use MediaWiki\Json\FormatJson;
26use MediaWiki\User\CentralId\CentralIdLookupFactory;
27use MediaWiki\User\UserIdentity;
28use MWException;
29use Psr\Log\LoggerAwareInterface;
30use Psr\Log\LoggerInterface;
31use RuntimeException;
32use Wikimedia\ObjectCache\BagOStuff;
33use Wikimedia\Rdbms\IConnectionProvider;
34
35class OATHUserRepository implements LoggerAwareInterface {
36    private IConnectionProvider $dbProvider;
37
38    private BagOStuff $cache;
39
40    private OATHAuthModuleRegistry $moduleRegistry;
41
42    private CentralIdLookupFactory $centralIdLookupFactory;
43
44    private LoggerInterface $logger;
45
46    public function __construct(
47        IConnectionProvider $dbProvider,
48        BagOStuff $cache,
49        OATHAuthModuleRegistry $moduleRegistry,
50        CentralIdLookupFactory $centralIdLookupFactory,
51        LoggerInterface $logger
52    ) {
53        $this->dbProvider = $dbProvider;
54        $this->cache = $cache;
55        $this->moduleRegistry = $moduleRegistry;
56        $this->centralIdLookupFactory = $centralIdLookupFactory;
57        $this->setLogger( $logger );
58    }
59
60    /**
61     * @param LoggerInterface $logger
62     */
63    public function setLogger( LoggerInterface $logger ) {
64        $this->logger = $logger;
65    }
66
67    /**
68     * @param UserIdentity $user
69     * @return OATHUser
70     * @throws ConfigException
71     * @throws MWException
72     */
73    public function findByUser( UserIdentity $user ) {
74        $oathUser = $this->cache->get( $user->getName() );
75        if ( !$oathUser ) {
76            $uid = $this->centralIdLookupFactory->getLookup()
77                ->centralIdFromLocalUser( $user );
78            $oathUser = new OATHUser( $user, $uid );
79            $this->loadKeysFromDatabase( $oathUser );
80
81            $this->cache->set( $user->getName(), $oathUser );
82        }
83        return $oathUser;
84    }
85
86    /**
87     * @param OATHUser $user
88     * @param string|null $clientInfo
89     * @throws ConfigException
90     * @throws MWException
91     */
92    public function persist( OATHUser $user, $clientInfo = null ) {
93        if ( !$clientInfo ) {
94            $clientInfo = RequestContext::getMain()->getRequest()->getIP();
95        }
96        $prevUser = $this->findByUser( $user->getUser() );
97        $userId = $this->centralIdLookupFactory->getLookup()->centralIdFromLocalUser( $user->getUser() );
98        $moduleId = null;
99
100        $dbw = $this->dbProvider->getPrimaryDatabase( 'virtual-oathauth' );
101        $dbw->startAtomic( __METHOD__ );
102
103        // TODO: only update changed rows
104        $dbw->newDeleteQueryBuilder()
105            ->deleteFrom( 'oathauth_devices' )
106            ->where( [ 'oad_user' => $userId ] )
107            ->caller( __METHOD__ )
108            ->execute();
109
110        if ( $user->getKeys() ) {
111            // if we have keys, then it means we also have a module, lets fetch it
112            // TODO: get the moduleId from the key instead of user once we support multiple keys
113            $moduleId = $this->moduleRegistry->getModuleId( $user->getModule()->getName() );
114        }
115        foreach ( $user->getKeys() as $key ) {
116            $dbw->newInsertQueryBuilder()
117                ->insertInto( 'oathauth_devices' )
118                ->row( [
119                    'oad_user' => $userId,
120                    'oad_type' => $moduleId,
121                    'oad_data' => FormatJson::encode( $key->jsonSerialize() )
122                ] )
123                ->caller( __METHOD__ )
124                ->execute();
125        }
126
127        $dbw->endAtomic( __METHOD__ );
128
129        $this->loadKeysFromDatabase( $user );
130
131        $userName = $user->getUser()->getName();
132        $this->cache->set( $userName, $user );
133
134        if ( $prevUser !== false ) {
135            $this->logger->info( 'OATHAuth updated for {user} from {clientip}', [
136                'user' => $userName,
137                'clientip' => $clientInfo,
138                'oldoathtype' => $prevUser->getModule() ? $prevUser->getModule()->getName() : 'disabled',
139                'newoathtype' => $user->getModule() ? $user->getModule()->getName() : 'disabled'
140            ] );
141        } else {
142            // If findByUser() has returned false, there was no user row or cache entry
143            $this->logger->info( 'OATHAuth enabled for {user} from {clientip}', [
144                'user' => $userName,
145                'clientip' => $clientInfo,
146                'oathtype' => $user->getModule() ? $user->getModule()->getName() : 'disabled',
147            ] );
148            Manager::notifyEnabled( $user );
149        }
150    }
151
152    /**
153     * Persists the given OAuth key in the database.
154     *
155     * @param OATHUser $user
156     * @param IModule $module
157     * @param array $keyData
158     * @param string $clientInfo
159     * @return IAuthKey
160     */
161    public function createKey( OATHUser $user, IModule $module, array $keyData, string $clientInfo ): IAuthKey {
162        if ( $user->getModule() && $user->getModule()->getName() !== $module->getName() ) {
163            throw new InvalidArgumentException(
164                "User already has a key from a different module enabled ({$user->getModule()->getName()})"
165            );
166        }
167
168        $moduleId = $this->moduleRegistry->getModuleId( $module->getName() );
169
170        $dbw = $this->dbProvider->getPrimaryDatabase( 'virtual-oathauth' );
171        $dbw->newInsertQueryBuilder()
172            ->insertInto( 'oathauth_devices' )
173            ->row( [
174                'oad_user' => $user->getCentralId(),
175                'oad_type' => $moduleId,
176                'oad_data' => FormatJson::encode( $keyData ),
177                'oad_created' => $dbw->timestamp(),
178            ] )
179            ->caller( __METHOD__ )
180            ->execute();
181        $id = $dbw->insertId();
182
183        $hasExistingKey = $user->isTwoFactorAuthEnabled();
184
185        $key = $module->newKey( $keyData + [ 'id' => $id ] );
186        $user->addKey( $key );
187
188        $this->logger->info( 'OATHAuth {oathtype} key {key} added for {user} from {clientip}', [
189            'key' => $id,
190            'user' => $user->getUser()->getName(),
191            'clientip' => $clientInfo,
192            'oathtype' => $module->getName(),
193        ] );
194
195        if ( !$hasExistingKey ) {
196            $user->setModule( $module );
197            Manager::notifyEnabled( $user );
198        }
199
200        return $key;
201    }
202
203    /**
204     * Saves an existing key in the database.
205     *
206     * @param OATHUser $user
207     * @param IAuthKey $key
208     * @return void
209     */
210    public function updateKey( OATHUser $user, IAuthKey $key ): void {
211        $keyId = $key->getId();
212        if ( !$keyId ) {
213            throw new InvalidArgumentException( 'updateKey() can only be used with already existing keys' );
214        }
215
216        $userId = $this->centralIdLookupFactory->getLookup()
217            ->centralIdFromLocalUser( $user->getUser() );
218
219        $dbw = $this->dbProvider->getPrimaryDatabase( 'virtual-oathauth' );
220        $dbw->newUpdateQueryBuilder()
221            ->table( 'oathauth_devices' )
222            ->set( [ 'oad_data' => FormatJson::encode( $key->jsonSerialize() ) ] )
223            ->where( [ 'oad_user' => $userId, 'oad_id' => $keyId ] )
224            ->caller( __METHOD__ )
225            ->execute();
226
227        $this->logger->info( 'OATHAuth key {keyId} updated for {user}', [
228            'keyId' => $keyId,
229            'user' => $user->getUser()->getName(),
230        ] );
231    }
232
233    /**
234     * @param OATHUser $user
235     * @param IAuthKey $key
236     * @param string $clientInfo
237     * @param bool $self Whether they disabled it themselves
238     */
239    public function removeKey( OATHUser $user, IAuthKey $key, string $clientInfo, bool $self ) {
240        $keyId = $key->getId();
241        if ( !$keyId ) {
242            throw new InvalidArgumentException( 'A non-persisted key cannot be removed' );
243        }
244
245        $userId = $this->centralIdLookupFactory->getLookup()
246            ->centralIdFromLocalUser( $user->getUser() );
247        $this->dbProvider->getPrimaryDatabase( 'virtual-oathauth' )
248            ->newDeleteQueryBuilder()
249            ->deleteFrom( 'oathauth_devices' )
250            ->where( [ 'oad_user' => $userId, 'oad_id' => $keyId ] )
251            ->caller( __METHOD__ )
252            ->execute();
253
254        // TODO: figure this out from the key itself
255        // After calling ->disable(), getModule() will return null so this
256        // has to be done before.
257        $keyType = $user->getModule()->getName();
258
259        // Remove the key from the user object
260        $user->setKeys(
261            array_values(
262                array_filter(
263                    $user->getKeys(),
264                    static function ( IAuthKey $key ) use ( $keyId ) {
265                        return $key->getId() !== $keyId;
266                    }
267                )
268            )
269        );
270
271        if ( !$user->getKeys() ) {
272            $user->setModule( null );
273        }
274
275        $userName = $user->getUser()->getName();
276        $this->cache->delete( $userName );
277
278        $this->logger->info( 'OATHAuth removed {oathtype} key {key} for {user} from {clientip}', [
279            'key' => $keyId,
280            'user' => $userName,
281            'clientip' => $clientInfo,
282            'oathtype' => $keyType,
283        ] );
284
285        Manager::notifyDisabled( $user, $self );
286    }
287
288    /**
289     * @param OATHUser $user
290     * @param string $clientInfo
291     * @param bool $self Whether the user disabled the 2FA themselves
292     *
293     * @deprecated since 1.41, use removeAll() instead
294     */
295    public function remove( OATHUser $user, $clientInfo, bool $self ) {
296        $this->removeAll( $user, $clientInfo, $self );
297    }
298
299    /**
300     * @param OATHUser $user
301     * @param string $clientInfo
302     * @param bool $self Whether they disabled it themselves
303     */
304    public function removeAll( OATHUser $user, $clientInfo, bool $self ) {
305        $userId = $this->centralIdLookupFactory->getLookup()
306            ->centralIdFromLocalUser( $user->getUser() );
307        $this->dbProvider->getPrimaryDatabase( 'virtual-oathauth' )
308            ->newDeleteQueryBuilder()
309            ->deleteFrom( 'oathauth_devices' )
310            ->where( [ 'oad_user' => $userId ] )
311            ->caller( __METHOD__ )
312            ->execute();
313
314        // TODO: figure this out from the key itself
315        // After calling ->disable(), getModule() will return null so this
316        // has to be done before.
317        $keyType = $user->getModule()->getName();
318
319        $user->disable();
320
321        $userName = $user->getUser()->getName();
322        $this->cache->delete( $userName );
323
324        $this->logger->info( 'OATHAuth disabled for {user} from {clientip}', [
325            'user' => $userName,
326            'clientip' => $clientInfo,
327            'oathtype' => $keyType,
328        ] );
329
330        Manager::notifyDisabled( $user, $self );
331    }
332
333    private function loadKeysFromDatabase( OATHUser $user ): void {
334        $uid = $this->centralIdLookupFactory->getLookup()
335            ->centralIdFromLocalUser( $user->getUser() );
336
337        $res = $this->dbProvider
338            ->getReplicaDatabase( 'virtual-oathauth' )
339            ->newSelectQueryBuilder()
340            ->select( [
341                'oad_id',
342                'oad_data',
343                'oat_name',
344            ] )
345            ->from( 'oathauth_devices' )
346            ->join( 'oathauth_types', null, [ 'oat_id = oad_type' ] )
347            ->where( [ 'oad_user' => $uid ] )
348            ->caller( __METHOD__ )
349            ->fetchResultSet();
350
351        $module = null;
352
353        // Clear stored key list before loading keys
354        $user->disable();
355
356        foreach ( $res as $row ) {
357            if ( $module && $row->oat_name !== $module->getName() ) {
358                // Not supported by current application-layer code.
359                throw new RuntimeException( "User {$uid} has multiple different two-factor modules defined" );
360            }
361
362            if ( !$module ) {
363                $module = $this->moduleRegistry->getModuleByKey( $row->oat_name );
364                $user->setModule( $module );
365
366                if ( !$module ) {
367                    throw new MWException( 'oathauth-module-invalid' );
368                }
369            }
370
371            $keyData = FormatJson::decode( $row->oad_data, true );
372            $user->addKey( $module->newKey( $keyData + [ 'id' => (int)$row->oad_id ] ) );
373        }
374    }
375}