Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 140
0.00% covered (danger)
0.00%
0 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
MWOAuthDAO
0.00% covered (danger)
0.00%
0 / 140
0.00% covered (danger)
0.00%
0 / 28
2652
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
6
 newFromArray
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getConsumerClass
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 newFromRow
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 newFromId
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 get
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 setField
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setFields
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 getFieldNames
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 save
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
72
 delete
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 getSchema
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFieldPermissionChecks
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTable
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getFieldColumnMap
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getColumn
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 hasField
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getAutoIncrField
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getIdColumn
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getIdValue
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 loadFromValues
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 normalizeValues
n/a
0 / 0
n/a
0 / 0
0
 loadFromRow
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 encodeRow
n/a
0 / 0
n/a
0 / 0
0
 decodeRow
n/a
0 / 0
n/a
0 / 0
0
 getRowArray
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 userCanAccess
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getChangeToken
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 checkChangeToken
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setPending
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 updateOrigin
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\OAuth\Backend;
4
5use Exception;
6use IContextSource;
7use IDBAccessObject;
8use LogicException;
9use MediaWiki\Logger\LoggerFactory;
10use Message;
11use MWException;
12use Psr\Log\LoggerInterface;
13use stdClass;
14use Wikimedia\Rdbms\DBError;
15use Wikimedia\Rdbms\DBReadOnlyError;
16use Wikimedia\Rdbms\IDatabase;
17
18/**
19 * (c) Aaron Schulz 2013, GPL
20 *
21 * This program is free software; you can redistribute it and/or modify
22 * it under the terms of the GNU General Public License as published by
23 * the Free Software Foundation; either version 2 of the License, or
24 * (at your option) any later version.
25 *
26 * This program is distributed in the hope that it will be useful,
27 * but WITHOUT ANY WARRANTY; without even the implied warranty of
28 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
29 * GNU General Public License for more details.
30 *
31 * You should have received a copy of the GNU General Public License along
32 * with this program; if not, write to the Free Software Foundation, Inc.,
33 * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
34 * http://www.gnu.org/copyleft/gpl.html
35 */
36
37/**
38 * Representation of a Data Access Object
39 */
40abstract class MWOAuthDAO {
41    /** @var string object construction origin */
42    private $daoOrigin = 'new';
43    /** @var bool whether fields changed or the field is new */
44    private $daoPending = true;
45
46    /** @var LoggerInterface */
47    protected $logger;
48
49    /**
50     * @throws LogicException
51     */
52    final protected function __construct() {
53        $fields = array_keys( static::getFieldPermissionChecks() );
54        if ( array_diff( $fields, $this->getFieldNames() ) ) {
55            throw new LogicException( "Invalid field(s) defined in access check methods." );
56        }
57        $this->logger = LoggerFactory::getInstance( 'OAuth' );
58    }
59
60    /**
61     * @param array $values (field => value) map
62     * @return static
63     */
64    final public static function newFromArray( array $values ) {
65        $class = static::getConsumerClass( $values );
66        $consumer = new $class();
67
68        // Make sure oauth_version is set - for backwards compat
69        $values['oauth_version'] ??= Consumer::OAUTH_VERSION_1;
70        $consumer->loadFromValues( $values );
71        return $consumer;
72    }
73
74    /**
75     * Determine and return the correct consumer class
76     *
77     * @param array $data
78     * @return string
79     */
80    protected static function getConsumerClass( array $data ) {
81        return static::class;
82    }
83
84    /**
85     * @param IDatabase $db
86     * @param array|stdClass $row
87     * @return static
88     */
89    final public static function newFromRow( IDatabase $db, $row ) {
90        $class = static::getConsumerClass( (array)$row );
91        $consumer = new $class();
92        $consumer->loadFromRow( $db, $row );
93        return $consumer;
94    }
95
96    /**
97     * @param IDatabase $db
98     * @param int $id
99     * @param int $flags IDBAccessObject::READ_* bitfield
100     * @return static|bool Returns false if not found
101     * @throws DBError
102     */
103    final public static function newFromId( IDatabase $db, $id, $flags = 0 ) {
104        $row = $db->selectRow( static::getTable(),
105            array_values( static::getFieldColumnMap() ),
106            [ static::getIdColumn() => (int)$id ],
107            __METHOD__,
108            ( $flags & IDBAccessObject::READ_LOCKING ) ? [ 'FOR UPDATE' ] : []
109        );
110
111        if ( $row ) {
112            $class = static::getConsumerClass( (array)$row );
113            $consumer = new $class();
114            $consumer->loadFromRow( $db, $row );
115            return $consumer;
116        } else {
117            return false;
118        }
119    }
120
121    /**
122     * Get the value of a field
123     *
124     * @param string $name
125     * @return mixed
126     * @throws LogicException
127     */
128    final public function get( $name ) {
129        if ( !static::hasField( $name ) ) {
130            throw new LogicException( "Object has no '$name' field." );
131        }
132        return $this->$name;
133    }
134
135    /**
136     * Set the value of a field
137     *
138     * @param string $name
139     * @param mixed $value
140     * @return mixed The old value
141     * @throws Exception
142     */
143    final public function setField( $name, $value ) {
144        $old = $this->setFields( [ $name => $value ] );
145        return $old[$name];
146    }
147
148    /**
149     * Set the values for a set of fields
150     *
151     * @param array $values (field => value) map
152     * @throws LogicException
153     * @return array Map of old values
154     */
155    final public function setFields( array $values ) {
156        $old = [];
157        foreach ( $values as $name => $value ) {
158            if ( !static::hasField( $name ) ) {
159                throw new LogicException( "Object has no '$name' field." );
160            }
161            $old[$name] = $this->$name;
162            $this->$name = $value;
163            if ( $old[$name] !== $value ) {
164                $this->daoPending = true;
165            }
166        }
167        $this->normalizeValues();
168        return $old;
169    }
170
171    /**
172     * @return string[]
173     */
174    final public function getFieldNames() {
175        return array_keys( static::getFieldColumnMap() );
176    }
177
178    /**
179     * @param IDatabase $dbw
180     * @return bool
181     * @throws DBReadOnlyError
182     */
183    public function save( IDatabase $dbw ) {
184        global $wgMWOAuthReadOnly;
185
186        $uniqueId = $this->getIdValue();
187        $idColumn = static::getIdColumn();
188        if ( $wgMWOAuthReadOnly ) {
189            throw new DBReadOnlyError( $dbw, __CLASS__ . ": tried to save while db is read-only" );
190        }
191        if ( $this->daoOrigin === 'db' ) {
192            if ( $this->daoPending ) {
193                $this->logger->debug( get_class( $this ) . ': performing DB update; object changed.' );
194                $dbw->update(
195                    static::getTable(),
196                    $this->getRowArray( $dbw ),
197                    [ $idColumn => $uniqueId ],
198                    __METHOD__
199                );
200                $this->daoPending = false;
201                return $dbw->affectedRows() > 0;
202            } else {
203                $this->logger->debug( get_class( $this ) . ': skipping DB update; object unchanged.' );
204                return false;
205            }
206        } else {
207            $this->logger->debug( get_class( $this ) . ': performing DB update; new object.' );
208            $afield = static::getAutoIncrField();
209            $acolumn = $afield !== null ? static::getColumn( $afield ) : null;
210            $row = $this->getRowArray( $dbw );
211            if ( $acolumn !== null && $row[$acolumn] === null ) {
212                // auto-increment field should be omitted, not set null, for
213                // auto-incrementing behavior
214                unset( $row[$acolumn] );
215            }
216            $dbw->insert(
217                static::getTable(),
218                $row,
219                __METHOD__
220            );
221            if ( $afield !== null ) {
222                // update field for auto-increment field
223                $this->$afield = $dbw->insertId();
224            }
225            $this->daoPending = false;
226            return true;
227        }
228    }
229
230    /**
231     * @param IDatabase $dbw
232     * @return bool
233     * @throws DBReadOnlyError
234     */
235    public function delete( IDatabase $dbw ) {
236        global $wgMWOAuthReadOnly;
237
238        $uniqueId = $this->getIdValue();
239        $idColumn = static::getIdColumn();
240        if ( $wgMWOAuthReadOnly ) {
241            throw new DBReadOnlyError( $dbw, __CLASS__ . ": tried to delete while db is read-only" );
242        }
243        if ( $this->daoOrigin === 'db' ) {
244            $dbw->delete(
245                static::getTable(),
246                [ $idColumn => $uniqueId ],
247                __METHOD__
248            );
249            $this->daoPending = true;
250            return $dbw->affectedRows() > 0;
251        } else {
252            return false;
253        }
254    }
255
256    /**
257     * Get the schema information for this object type
258     *
259     * This should return an associative array with:
260     *   - idField        : a field with an int/hex UNIQUE identifier
261     *   - autoIncrField  : a field that auto-increments in the DB (or NULL if none)
262     *   - table          : a table name
263     *   - fieldColumnMap : a map of field names to column names
264     *
265     * @return array
266     */
267    protected static function getSchema() {
268        // Note: declaring this abstract raises E_STRICT
269        throw new MWException( "getSchema() not defined in " . self::class );
270    }
271
272    /**
273     * Get the access control check methods for this object type
274     *
275     * This returns a map of field names to method names.
276     * The methods check if a context user has access to the field,
277     * returning true if they do and a Message object otherwise.
278     * The methods take (field name, IContextSource) as arguments.
279     *
280     * @see MWOAuthDAO::userCanAccess()
281     * @see MWOAuthDAOAccessControl
282     *
283     * @throws LogicException Subclasses must override
284     * @return array<string,string> Map of (field name => name of method that checks access)
285     */
286    protected static function getFieldPermissionChecks() {
287        // Note: declaring this abstract raises E_STRICT
288        throw new LogicException( "getFieldPermissionChecks() not defined in " . self::class );
289    }
290
291    /**
292     * @return string
293     */
294    final protected static function getTable() {
295        $schema = static::getSchema();
296        return $schema['table'];
297    }
298
299    /**
300     * @return array<string,string>
301     */
302    final protected static function getFieldColumnMap() {
303        $schema = static::getSchema();
304        return $schema['fieldColumnMap'];
305    }
306
307    /**
308     * @param string $field
309     * @return string
310     */
311    final protected static function getColumn( $field ) {
312        $schema = static::getSchema();
313        return $schema['fieldColumnMap'][$field];
314    }
315
316    /**
317     * @param string $field
318     * @return bool
319     */
320    final protected static function hasField( $field ) {
321        $schema = static::getSchema();
322        return isset( $schema['fieldColumnMap'][$field] );
323    }
324
325    /**
326     * @return string|null
327     */
328    final protected static function getAutoIncrField() {
329        $schema = static::getSchema();
330        return $schema['autoIncrField'] ?? null;
331    }
332
333    /**
334     * @return string
335     */
336    final protected static function getIdColumn() {
337        $schema = static::getSchema();
338        return $schema['fieldColumnMap'][$schema['idField']];
339    }
340
341    /**
342     * @return int|string
343     */
344    final protected function getIdValue() {
345        $schema = static::getSchema();
346        $field = $schema['idField'];
347        return $this->$field;
348    }
349
350    /**
351     * @param array $values
352     */
353    final protected function loadFromValues( array $values ) {
354        foreach ( static::getFieldColumnMap() as $field => $column ) {
355            if ( !array_key_exists( $field, $values ) ) {
356                throw new MWException( get_class( $this ) . " requires '$field' field." );
357            }
358            $this->$field = $values[$field];
359        }
360        $this->normalizeValues();
361        $this->daoOrigin = 'new';
362        $this->daoPending = true;
363    }
364
365    /**
366     * Subclasses should make this normalize fields (e.g. timestamps)
367     *
368     * @return void
369     */
370    abstract protected function normalizeValues();
371
372    /**
373     * @param IDatabase $db
374     * @param stdClass|array $row
375     * @return void
376     */
377    final protected function loadFromRow( IDatabase $db, $row ) {
378        $row = $this->decodeRow( $db, (array)$row );
379        $values = [];
380        foreach ( static::getFieldColumnMap() as $field => $column ) {
381            $values[$field] = $row[$column];
382        }
383        $this->loadFromValues( $values );
384        $this->daoOrigin = 'db';
385        $this->daoPending = false;
386    }
387
388    /**
389     * Subclasses should make this to encode DB fields (e.g. timestamps).
390     * This must also flatten any PHP data structures into flat values.
391     *
392     * @param IDatabase $db
393     * @param array $row
394     * @return array
395     */
396    abstract protected function encodeRow( IDatabase $db, $row );
397
398    /**
399     * Subclasses should make this to decode DB fields (e.g. timestamps).
400     * This can also expand some flat values (e.g. JSON) into PHP data structures.
401     * Note: this does not need to handle what normalizeValues() already does.
402     *
403     * @param IDatabase $db
404     * @param array $row
405     * @return array
406     */
407    abstract protected function decodeRow( IDatabase $db, $row );
408
409    /**
410     * @param IDatabase $db
411     * @return array
412     */
413    final protected function getRowArray( IDatabase $db ) {
414        $row = [];
415        foreach ( static::getFieldColumnMap() as $field => $column ) {
416            $row[$column] = $this->$field;
417        }
418        return $this->encodeRow( $db, $row );
419    }
420
421    /**
422     * Check if a user (from the context) can view a field
423     *
424     * @see MWOAuthDAO::userCanAccess()
425     * @see MWOAuthDAOAccessControl
426     *
427     * @param string $name
428     * @param IContextSource $context
429     * @return Message|true Returns on success or a Message if the user lacks access
430     */
431    final public function userCanAccess( $name, IContextSource $context ) {
432        $map = static::getFieldPermissionChecks();
433        if ( isset( $map[$name] ) ) {
434            $method = $map[$name];
435            return $this->$method( $name, $context );
436        } else {
437            return true;
438        }
439    }
440
441    /**
442     * Get the current conflict token value for a user
443     *
444     * @param IContextSource $context
445     * @return string Hex token
446     */
447    final public function getChangeToken( IContextSource $context ) {
448        $map = [];
449        foreach ( $this->getFieldNames() as $field ) {
450            if ( $this->userCanAccess( $field, $context ) ) {
451                $map[$field] = $this->$field;
452            } else {
453                // don't convey this information
454                $map[$field] = null;
455            }
456        }
457        return hash_hmac(
458            'sha1',
459            serialize( $map ),
460            $context->getUser()->getId() . '#' . $this->getIdValue()
461        );
462    }
463
464    /**
465     * Compare an old change token to the current one
466     *
467     * @param IContextSource $context
468     * @param string $oldToken
469     * @return bool Whether the current is unchanged
470     */
471    final public function checkChangeToken( IContextSource $context, $oldToken ) {
472        return ( $this->getChangeToken( $context ) === $oldToken );
473    }
474
475    /**
476     * Update whether this object should be written to the data store
477     * @param bool $pending set to true to mark this object as needing to write its data
478     */
479    public function setPending( $pending ) {
480        $this->daoPending = $pending;
481    }
482
483    /**
484     * Update the origin of this object
485     * @param string $source source of the object
486     *     'new': Treat this as a new object to the datastore (insert on save)
487     *     'db': Treat this as already in the datastore (update on save)
488     */
489    public function updateOrigin( $source ) {
490        $this->daoOrigin = $source;
491    }
492}