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