Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
27.78% |
50 / 180 |
|
10.00% |
1 / 10 |
CRAP | |
0.00% |
0 / 1 |
OATHUserRepository | |
27.78% |
50 / 180 |
|
10.00% |
1 / 10 |
345.82 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
setLogger | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
findByUser | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
persist | |
0.00% |
0 / 41 |
|
0.00% |
0 / 1 |
72 | |||
createKey | |
90.00% |
27 / 30 |
|
0.00% |
0 / 1 |
4.02 | |||
updateKey | |
93.75% |
15 / 16 |
|
0.00% |
0 / 1 |
2.00 | |||
removeKey | |
0.00% |
0 / 33 |
|
0.00% |
0 / 1 |
12 | |||
remove | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
removeAll | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
2 | |||
loadKeysFromDatabase | |
0.00% |
0 / 27 |
|
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 | |
19 | namespace MediaWiki\Extension\OATHAuth; |
20 | |
21 | use InvalidArgumentException; |
22 | use MediaWiki\Config\ConfigException; |
23 | use MediaWiki\Context\RequestContext; |
24 | use MediaWiki\Extension\OATHAuth\Notifications\Manager; |
25 | use MediaWiki\Json\FormatJson; |
26 | use MediaWiki\User\CentralId\CentralIdLookupFactory; |
27 | use MediaWiki\User\UserIdentity; |
28 | use MWException; |
29 | use Psr\Log\LoggerAwareInterface; |
30 | use Psr\Log\LoggerInterface; |
31 | use RuntimeException; |
32 | use Wikimedia\ObjectCache\BagOStuff; |
33 | use Wikimedia\Rdbms\IConnectionProvider; |
34 | |
35 | class 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 | } |