Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.38% covered (warning)
76.38%
152 / 199
35.71% covered (danger)
35.71%
5 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
OATHUserRepository
76.38% covered (warning)
76.38%
152 / 199
35.71% covered (danger)
35.71%
5 / 14
53.07
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findByUser
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 findByUserHandle
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
3
 createKey
97.50% covered (success)
97.50%
39 / 40
0.00% covered (danger)
0.00%
0 / 1
6
 updateKey
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 removeSomeKeys
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 removeKey
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
5.01
 removeAllOfType
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 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 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 loadKeysFromDatabase
97.56% covered (success)
97.56%
40 / 41
0.00% covered (danger)
0.00%
0 / 1
6
 insertUserHandle
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
2.00
 deleteUserHandle
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * @license GPL-2.0-or-later
4 */
5
6namespace MediaWiki\Extension\OATHAuth;
7
8use InvalidArgumentException;
9use MediaWiki\CheckUser\Services\CheckUserInsert;
10use MediaWiki\Extension\OATHAuth\Key\AuthKey;
11use MediaWiki\Extension\OATHAuth\Key\WebAuthnKey;
12use MediaWiki\Extension\OATHAuth\Module\IModule;
13use MediaWiki\Extension\OATHAuth\Module\WebAuthn;
14use MediaWiki\Extension\OATHAuth\Notifications\Manager;
15use MediaWiki\Json\FormatJson;
16use MediaWiki\Logging\ManualLogEntry;
17use MediaWiki\MediaWikiServices;
18use MediaWiki\Page\PageReferenceValue;
19use MediaWiki\Registration\ExtensionRegistry;
20use MediaWiki\User\CentralId\CentralIdLookup;
21use MediaWiki\User\CentralId\CentralIdLookupFactory;
22use MediaWiki\User\UserIdentity;
23use Psr\Log\LoggerAwareInterface;
24use Psr\Log\LoggerInterface;
25use Wikimedia\ObjectCache\BagOStuff;
26use Wikimedia\Rdbms\IConnectionProvider;
27
28class OATHUserRepository implements LoggerAwareInterface {
29    private LoggerInterface $logger;
30
31    public function __construct(
32        private readonly IConnectionProvider $dbProvider,
33        private readonly BagOStuff $cache,
34        private readonly OATHAuthModuleRegistry $moduleRegistry,
35        private readonly CentralIdLookupFactory $centralIdLookupFactory,
36        LoggerInterface $logger,
37    ) {
38        $this->setLogger( $logger );
39    }
40
41    public function setLogger( LoggerInterface $logger ): void {
42        $this->logger = $logger;
43    }
44
45    public function findByUser( UserIdentity $user ): OATHUser {
46        $oathUser = $this->cache->get( $user->getName() );
47        if ( !$oathUser ) {
48            $uid = $this->centralIdLookupFactory->getLookup()
49                ->centralIdFromLocalUser( $user, CentralIdLookup::AUDIENCE_RAW );
50            $oathUser = new OATHUser( $user, $uid );
51            $this->loadKeysFromDatabase( $oathUser );
52
53            $this->cache->set( $user->getName(), $oathUser );
54        }
55        return $oathUser;
56    }
57
58    /**
59     * Find the user who owns a given User Handle, and load an OATHUser object for them.
60     * Use this to identify a user when you only have their WebAuthn authentication result.
61     * @param string $userHandle User Handle value from the user's WebAuthn key
62     * @return OATHUser|null OATHUser object for the user, or null if no user was found for the
63     *   given User Handle
64     */
65    public function findByUserHandle( string $userHandle ): ?OATHUser {
66        $userId = $this->dbProvider
67            ->getReplicaDatabase( 'virtual-oathauth' )
68            ->newSelectQueryBuilder()
69            ->select( 'oah_user' )
70            ->from( 'oathauth_user_handles' )
71            ->where( [ 'oah_handle' => base64_encode( $userHandle ) ] )
72            ->caller( __METHOD__ )
73            ->fetchField();
74        if ( $userId === false ) {
75            return null;
76        }
77
78        $user = $this->centralIdLookupFactory->getLookup()->localUserFromCentralId(
79            $userId, CentralIdLookup::AUDIENCE_RAW
80        );
81        if ( $user === null ) {
82            return null;
83        }
84
85        $oathUser = new OATHUser( $user, $userId );
86        $oathUser->setUserHandle( $userHandle );
87        $this->loadKeysFromDatabase( $oathUser );
88        $this->cache->set( $user->getName(), $oathUser );
89        return $oathUser;
90    }
91
92    /**
93     * Persists the given key in the database.
94     */
95    public function createKey( OATHUser $user, IModule $module, array $keyData, string $clientInfo ): AuthKey {
96        $uid = $user->getCentralId();
97        if ( !$uid ) {
98            throw new InvalidArgumentException( "Can't persist a key for user with no central ID available" );
99        }
100
101        $moduleId = $this->moduleRegistry->getModuleId( $module->getName() );
102        $dbw = $this->dbProvider->getPrimaryDatabase( 'virtual-oathauth' );
103        $createdTimestamp = $dbw->timestamp();
104        $dbw->newInsertQueryBuilder()
105            ->insertInto( 'oathauth_devices' )
106            ->row( [
107                'oad_user' => $uid,
108                'oad_type' => $moduleId,
109                'oad_data' => FormatJson::encode( $keyData ),
110                'oad_created' => $createdTimestamp,
111            ] )
112            ->caller( __METHOD__ )
113            ->execute();
114        $id = $dbw->insertId();
115
116        $hasExistingKey = $user->isTwoFactorAuthEnabled();
117
118        $key = $module->newKey( $keyData + [ 'id' => $id, 'created_timestamp' => $createdTimestamp ] );
119        $user->addKey( $key );
120
121        $this->logger->info( 'OATHAuth added {oathtype} key {key} for {user} from {clientip}', [
122            'key' => $id,
123            'user' => $user->getUser()->getName(),
124            'clientip' => $clientInfo,
125            'oathtype' => $module->getName(),
126        ] );
127
128        // If the user added a WebAuthn key, but doesn't have a User Handle yet, add this key's
129        // User Handle to the oathauth_user_handles table
130        if ( $key instanceof WebAuthnKey && $user->getUserHandle() === null ) {
131            $user->setUserHandle( $key->getUserHandle() );
132            $this->insertUserHandle( $user );
133        }
134
135        if ( !$hasExistingKey ) {
136            Manager::notifyEnabled( $user );
137
138            if ( ExtensionRegistry::getInstance()->isLoaded( 'CheckUser' ) ) {
139                $logEntry = new ManualLogEntry( 'oath', 'enable-self' );
140                $logEntry->setPerformer( $user->getUser() );
141                $logEntry->setTarget(
142                    PageReferenceValue::localReference( NS_USER, $user->getUser()->getName() )
143                );
144                /** @var CheckUserInsert $checkUserInsert */
145                $checkUserInsert = MediaWikiServices::getInstance()->get( 'CheckUserInsert' );
146                $checkUserInsert->updateCheckUserData( $logEntry->getRecentChange() );
147            }
148        }
149
150        return $key;
151    }
152
153    /**
154     * Saves an existing key in the database.
155     */
156    public function updateKey( OATHUser $user, AuthKey $key ): void {
157        $keyId = $key->getId();
158        if ( !$keyId ) {
159            throw new InvalidArgumentException( 'updateKey() can only be used with already existing keys' );
160        }
161
162        $dbw = $this->dbProvider->getPrimaryDatabase( 'virtual-oathauth' );
163        $dbw->newUpdateQueryBuilder()
164            ->table( 'oathauth_devices' )
165            ->set( [ 'oad_data' => FormatJson::encode( $key->jsonSerialize() ) ] )
166            ->where( [ 'oad_user' => $user->getCentralId(), 'oad_id' => $keyId ] )
167            ->caller( __METHOD__ )
168            ->execute();
169
170        $this->logger->info( 'OATHAuth key {keyId} updated for {user}', [
171            'keyId' => $keyId,
172            'user' => $user->getUser()->getName(),
173        ] );
174    }
175
176    private function removeSomeKeys( OATHUser $user, array $where ): void {
177        $this->dbProvider->getPrimaryDatabase( 'virtual-oathauth' )
178            ->newDeleteQueryBuilder()
179            ->deleteFrom( 'oathauth_devices' )
180            ->where( [ 'oad_user' => $user->getCentralId() ] )
181            ->where( $where )
182            ->caller( __METHOD__ )
183            ->execute();
184
185        $this->cache->delete( $user->getUser()->getName() );
186    }
187
188    public function removeKey( OATHUser $user, AuthKey $key, string $clientInfo, bool $self ) {
189        $keyId = $key->getId();
190        if ( !$keyId ) {
191            throw new InvalidArgumentException( 'A non-persisted key cannot be removed' );
192        }
193
194        $this->removeSomeKeys( $user, [ 'oad_id' => $keyId ] );
195        $user->removeKey( $key );
196
197        $moduleName = $key->getModule();
198        // If the user just deleted their last WebAuthn key, delete their User Handle
199        if ( $moduleName === WebAuthn::MODULE_ID && $user->getKeysForModule( $moduleName ) === [] ) {
200            $this->deleteUserHandle( $user );
201        }
202
203        $this->logger->info( 'OATHAuth removed {oathtype} key {key} for {user} from {clientip}', [
204            'key' => $keyId,
205            'user' => $user->getUser()->getName(),
206            'clientip' => $clientInfo,
207            'oathtype' => $key->getModule(),
208        ] );
209
210        if ( !$this->moduleRegistry->getModuleByKey( $key->getModule() )->isSpecial() ) {
211            Manager::notifyDisabled( $user, $self );
212        }
213    }
214
215    /**
216     * @param OATHUser $user
217     * @param string $keyType As in IModule::getName()
218     * @param string $clientInfo
219     * @param bool $self Whether they disabled it themselves
220     */
221    public function removeAllOfType( OATHUser $user, string $keyType, string $clientInfo, bool $self ) {
222        $moduleId = $this->moduleRegistry->getModuleId( $keyType );
223        if ( !$moduleId ) {
224            throw new InvalidArgumentException( 'Invalid key type: ' . $keyType );
225        }
226
227        $this->removeSomeKeys( $user, [ 'oad_type' => $moduleId ] );
228        $user->removeKeysForModule( $keyType );
229
230        // If the user just deleted all of their WebAuthn keys, delete their User Handle
231        if ( $keyType === WebAuthn::MODULE_ID ) {
232            $this->deleteUserHandle( $user );
233        }
234
235        $this->logger->info( 'OATHAuth removed {oathtype} keys for {user} from {clientip}', [
236            'user' => $user->getUser()->getName(),
237            'clientip' => $clientInfo,
238            'oathtype' => $keyType,
239        ] );
240
241        if ( !$this->moduleRegistry->getModuleByKey( $keyType )->isSpecial() ) {
242            Manager::notifyDisabled( $user, $self );
243        }
244    }
245
246    /**
247     * @param OATHUser $user
248     * @param string $clientInfo
249     * @param bool $self Whether the user disabled the 2FA themselves
250     *
251     * @deprecated since 1.41, use removeAll() instead
252     */
253    public function remove( OATHUser $user, $clientInfo, bool $self ) {
254        $this->removeAll( $user, $clientInfo, $self );
255    }
256
257    /**
258     * @param OATHUser $user
259     * @param string $clientInfo
260     * @param bool $self Whether they disabled it themselves
261     */
262    public function removeAll( OATHUser $user, $clientInfo, bool $self ) {
263        $this->removeSomeKeys( $user, [] );
264
265        $keyTypes = array_unique( array_map(
266            static fn ( AuthKey $key ) => $key->getModule(),
267            $user->getKeys()
268        ) );
269        $user->disable();
270
271        $this->deleteUserHandle( $user );
272
273        $this->logger->info( 'OATHAuth disabled for {user} from {clientip}', [
274            'user' => $user->getUser()->getName(),
275            'clientip' => $clientInfo,
276            'oathtype' => implode( ',', $keyTypes ),
277        ] );
278
279        Manager::notifyDisabled( $user, $self );
280    }
281
282    private function loadKeysFromDatabase( OATHUser $user ): void {
283        $uid = $user->getCentralId();
284        if ( !$uid ) {
285            // T379442
286            return;
287        }
288
289        $res = $this->dbProvider
290            ->getReplicaDatabase( 'virtual-oathauth' )
291            ->newSelectQueryBuilder()
292            ->select( [
293                'oad_id',
294                'oad_data',
295                'oat_name',
296                'oad_created',
297            ] )
298            ->from( 'oathauth_devices' )
299            ->join( 'oathauth_types', null, [ 'oat_id = oad_type' ] )
300            ->where( [ 'oad_user' => $uid ] )
301            ->caller( __METHOD__ )
302            ->fetchResultSet();
303
304        // Clear the stored key list before loading keys
305        $user->disable();
306
307        foreach ( $res as $row ) {
308            $module = $this->moduleRegistry->getModuleByKey( $row->oat_name );
309            $keyData = FormatJson::decode( $row->oad_data, true );
310
311            $user->addKey(
312                $module->newKey( $keyData + [
313                    'id' => (int)$row->oad_id,
314                    'created_timestamp' => $row->oad_created
315                ] )
316            );
317        }
318
319        if ( $user->getUserHandle() === null ) {
320            $userHandle = $this->dbProvider
321                ->getReplicaDatabase( 'virtual-oathauth' )
322                ->newSelectQueryBuilder()
323                ->select( 'oah_handle' )
324                ->from( 'oathauth_user_handles' )
325                ->where( [ 'oah_user' => $uid ] )
326                ->caller( __METHOD__ )
327                ->fetchField();
328            if ( $userHandle !== false ) {
329                $user->setUserHandle( base64_decode( $userHandle ) );
330            } else {
331                // If the user has any WebAuthn keys, derive their userHandle from that
332                /** @var WebAuthnKey[] $webauthnKeys */
333                $webauthnKeys = $user->getKeysForModule( WebAuthn::MODULE_ID );
334                '@phan-var WebAuthnKey[] $webauthnKeys';
335                if ( $webauthnKeys ) {
336                    $user->setUserHandle( $webauthnKeys[0]->getUserHandle() );
337                }
338            }
339        }
340    }
341
342    private function insertUserHandle( OATHUser $user ): void {
343        $userHandle = $user->getUserHandle();
344        if ( $userHandle === null ) {
345            return;
346        }
347        $this->dbProvider
348            ->getPrimaryDatabase( 'virtual-oathauth' )
349            ->newInsertQueryBuilder()
350            ->insertInto( 'oathauth_user_handles' )
351            ->ignore()
352            ->row( [
353                'oah_user' => $user->getCentralId(),
354                'oah_handle' => base64_encode( $userHandle )
355            ] )
356            ->caller( __METHOD__ )
357            ->execute();
358    }
359
360    private function deleteUserHandle( OATHUser $user ): void {
361        $user->setUserHandle( null );
362        $this->dbProvider
363            ->getPrimaryDatabase( 'virtual-oathauth' )
364            ->newDeleteQueryBuilder()
365            ->deleteFrom( 'oathauth_user_handles' )
366            ->where( [ 'oah_user' => $user->getCentralId() ] )
367            ->caller( __METHOD__ )
368            ->execute();
369    }
370}