Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 142
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 / 142
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 / 14
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 LogicException;
7use MediaWiki\Context\IContextSource;
8use MediaWiki\Logger\LoggerFactory;
9use MediaWiki\Message\Message;
10use MWException;
11use Psr\Log\LoggerInterface;
12use stdClass;
13use Wikimedia\Rdbms\DBError;
14use Wikimedia\Rdbms\DBReadOnlyError;
15use Wikimedia\Rdbms\IDatabase;
16use Wikimedia\Rdbms\IDBAccessObject;
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        $queryBuilder = $db->newSelectQueryBuilder()
105            ->select( array_values( static::getFieldColumnMap() ) )
106            ->from( static::getTable() )
107            ->where( [ static::getIdColumn() => (int)$id ] )
108            ->caller( __METHOD__ );
109        if ( $flags & IDBAccessObject::READ_LOCKING ) {
110            $queryBuilder->forUpdate();
111        }
112        $row = $queryBuilder->fetchRow();
113
114        if ( $row ) {
115            $class = static::getConsumerClass( (array)$row );
116            $consumer = new $class();
117            $consumer->loadFromRow( $db, $row );
118            return $consumer;
119        } else {
120            return false;
121        }
122    }
123
124    /**
125     * Get the value of a field
126     *
127     * @param string $name
128     * @return mixed
129     * @throws LogicException
130     */
131    final public function get( $name ) {
132        if ( !static::hasField( $name ) ) {
133            throw new LogicException( "Object has no '$name' field." );
134        }
135        return $this->$name;
136    }
137
138    /**
139     * Set the value of a field
140     *
141     * @param string $name
142     * @param mixed $value
143     * @return mixed The old value
144     * @throws Exception
145     */
146    final public function setField( $name, $value ) {
147        $old = $this->setFields( [ $name => $value ] );
148        return $old[$name];
149    }
150
151    /**
152     * Set the values for a set of fields
153     *
154     * @param array $values (field => value) map
155     * @throws LogicException
156     * @return array Map of old values
157     */
158    final public function setFields( array $values ) {
159        $old = [];
160        foreach ( $values as $name => $value ) {
161            if ( !static::hasField( $name ) ) {
162                throw new LogicException( "Object has no '$name' field." );
163            }
164            $old[$name] = $this->$name;
165            $this->$name = $value;
166            if ( $old[$name] !== $value ) {
167                $this->daoPending = true;
168            }
169        }
170        $this->normalizeValues();
171        return $old;
172    }
173
174    /**
175     * @return string[]
176     */
177    final public function getFieldNames() {
178        return array_keys( static::getFieldColumnMap() );
179    }
180
181    /**
182     * @param IDatabase $dbw
183     * @return bool
184     * @throws DBReadOnlyError
185     */
186    public function save( IDatabase $dbw ) {
187        global $wgMWOAuthReadOnly;
188
189        $uniqueId = $this->getIdValue();
190        $idColumn = static::getIdColumn();
191        if ( $wgMWOAuthReadOnly ) {
192            throw new DBReadOnlyError( $dbw, __CLASS__ . ": tried to save while db is read-only" );
193        }
194        if ( $this->daoOrigin === 'db' ) {
195            if ( $this->daoPending ) {
196                $this->logger->debug( get_class( $this ) . ': performing DB update; object changed.' );
197                $dbw->newUpdateQueryBuilder()
198                    ->update( static::getTable() )
199                    ->set( $this->getRowArray( $dbw ) )
200                    ->where( [ $idColumn => $uniqueId ] )
201                    ->caller( __METHOD__ )
202                    ->execute();
203                $this->daoPending = false;
204                return $dbw->affectedRows() > 0;
205            } else {
206                $this->logger->debug( get_class( $this ) . ': skipping DB update; object unchanged.' );
207                return false;
208            }
209        } else {
210            $this->logger->debug( get_class( $this ) . ': performing DB update; new object.' );
211            $afield = static::getAutoIncrField();
212            $acolumn = $afield !== null ? static::getColumn( $afield ) : null;
213            $row = $this->getRowArray( $dbw );
214            if ( $acolumn !== null && $row[$acolumn] === null ) {
215                // auto-increment field should be omitted, not set null, for
216                // auto-incrementing behavior
217                unset( $row[$acolumn] );
218            }
219            $dbw->newInsertQueryBuilder()
220                ->insertInto( static::getTable() )
221                ->row( $row )
222                ->caller( __METHOD__ )
223                ->execute();
224            if ( $afield !== null ) {
225                // update field for auto-increment field
226                $this->$afield = $dbw->insertId();
227            }
228            $this->daoPending = false;
229            return true;
230        }
231    }
232
233    /**
234     * @param IDatabase $dbw
235     * @return bool
236     * @throws DBReadOnlyError
237     */
238    public function delete( IDatabase $dbw ) {
239        global $wgMWOAuthReadOnly;
240
241        $uniqueId = $this->getIdValue();
242        $idColumn = static::getIdColumn();
243        if ( $wgMWOAuthReadOnly ) {
244            throw new DBReadOnlyError( $dbw, __CLASS__ . ": tried to delete while db is read-only" );
245        }
246        if ( $this->daoOrigin === 'db' ) {
247            $dbw->newDeleteQueryBuilder()
248                ->deleteFrom( static::getTable() )
249                ->where( [ $idColumn => $uniqueId ] )
250                ->caller( __METHOD__ )
251                ->execute();
252            $this->daoPending = true;
253            return $dbw->affectedRows() > 0;
254        } else {
255            return false;
256        }
257    }
258
259    /**
260     * Get the schema information for this object type
261     *
262     * This should return an associative array with:
263     *   - idField        : a field with an int/hex UNIQUE identifier
264     *   - autoIncrField  : a field that auto-increments in the DB (or NULL if none)
265     *   - table          : a table name
266     *   - fieldColumnMap : a map of field names to column names
267     *
268     * @return array
269     */
270    protected static function getSchema() {
271        // Note: declaring this abstract raises E_STRICT
272        throw new MWException( "getSchema() not defined in " . self::class );
273    }
274
275    /**
276     * Get the access control check methods for this object type
277     *
278     * This returns a map of field names to method names.
279     * The methods check if a context user has access to the field,
280     * returning true if they do and a Message object otherwise.
281     * The methods take (field name, IContextSource) as arguments.
282     *
283     * @see MWOAuthDAO::userCanAccess()
284     * @see MWOAuthDAOAccessControl
285     *
286     * @throws LogicException Subclasses must override
287     * @return array<string,string> Map of (field name => name of method that checks access)
288     */
289    protected static function getFieldPermissionChecks() {
290        // Note: declaring this abstract raises E_STRICT
291        throw new LogicException( "getFieldPermissionChecks() not defined in " . self::class );
292    }
293
294    /**
295     * @return string
296     */
297    final protected static function getTable() {
298        $schema = static::getSchema();
299        return $schema['table'];
300    }
301
302    /**
303     * @return array<string,string>
304     */
305    final protected static function getFieldColumnMap() {
306        $schema = static::getSchema();
307        return $schema['fieldColumnMap'];
308    }
309
310    /**
311     * @param string $field
312     * @return string
313     */
314    final protected static function getColumn( $field ) {
315        $schema = static::getSchema();
316        return $schema['fieldColumnMap'][$field];
317    }
318
319    /**
320     * @param string $field
321     * @return bool
322     */
323    final protected static function hasField( $field ) {
324        $schema = static::getSchema();
325        return isset( $schema['fieldColumnMap'][$field] );
326    }
327
328    /**
329     * @return string|null
330     */
331    final protected static function getAutoIncrField() {
332        $schema = static::getSchema();
333        return $schema['autoIncrField'] ?? null;
334    }
335
336    /**
337     * @return string
338     */
339    final protected static function getIdColumn() {
340        $schema = static::getSchema();
341        return $schema['fieldColumnMap'][$schema['idField']];
342    }
343
344    /**
345     * @return int|string
346     */
347    final protected function getIdValue() {
348        $schema = static::getSchema();
349        $field = $schema['idField'];
350        return $this->$field;
351    }
352
353    /**
354     * @param array $values
355     */
356    final protected function loadFromValues( array $values ) {
357        foreach ( static::getFieldColumnMap() as $field => $column ) {
358            if ( !array_key_exists( $field, $values ) ) {
359                throw new MWException( get_class( $this ) . " requires '$field' field." );
360            }
361            $this->$field = $values[$field];
362        }
363        $this->normalizeValues();
364        $this->daoOrigin = 'new';
365        $this->daoPending = true;
366    }
367
368    /**
369     * Subclasses should make this normalize fields (e.g. timestamps)
370     *
371     * @return void
372     */
373    abstract protected function normalizeValues();
374
375    /**
376     * @param IDatabase $db
377     * @param stdClass|array $row
378     * @return void
379     */
380    final protected function loadFromRow( IDatabase $db, $row ) {
381        $row = $this->decodeRow( $db, (array)$row );
382        $values = [];
383        foreach ( static::getFieldColumnMap() as $field => $column ) {
384            $values[$field] = $row[$column];
385        }
386        $this->loadFromValues( $values );
387        $this->daoOrigin = 'db';
388        $this->daoPending = false;
389    }
390
391    /**
392     * Subclasses should make this to encode DB fields (e.g. timestamps).
393     * This must also flatten any PHP data structures into flat values.
394     *
395     * @param IDatabase $db
396     * @param array $row
397     * @return array
398     */
399    abstract protected function encodeRow( IDatabase $db, $row );
400
401    /**
402     * Subclasses should make this to decode DB fields (e.g. timestamps).
403     * This can also expand some flat values (e.g. JSON) into PHP data structures.
404     * Note: this does not need to handle what normalizeValues() already does.
405     *
406     * @param IDatabase $db
407     * @param array $row
408     * @return array
409     */
410    abstract protected function decodeRow( IDatabase $db, $row );
411
412    /**
413     * @param IDatabase $db
414     * @return array
415     */
416    final protected function getRowArray( IDatabase $db ) {
417        $row = [];
418        foreach ( static::getFieldColumnMap() as $field => $column ) {
419            $row[$column] = $this->$field;
420        }
421        return $this->encodeRow( $db, $row );
422    }
423
424    /**
425     * Check if a user (from the context) can view a field
426     *
427     * @see MWOAuthDAO::userCanAccess()
428     * @see MWOAuthDAOAccessControl
429     *
430     * @param string $name
431     * @param IContextSource $context
432     * @return Message|true Returns on success or a Message if the user lacks access
433     */
434    final public function userCanAccess( $name, IContextSource $context ) {
435        $map = static::getFieldPermissionChecks();
436        if ( isset( $map[$name] ) ) {
437            $method = $map[$name];
438            return $this->$method( $name, $context );
439        } else {
440            return true;
441        }
442    }
443
444    /**
445     * Get the current conflict token value for a user
446     *
447     * @param IContextSource $context
448     * @return string Hex token
449     */
450    final public function getChangeToken( IContextSource $context ) {
451        $map = [];
452        foreach ( $this->getFieldNames() as $field ) {
453            if ( $this->userCanAccess( $field, $context ) ) {
454                $map[$field] = $this->$field;
455            } else {
456                // don't convey this information
457                $map[$field] = null;
458            }
459        }
460        return hash_hmac(
461            'sha1',
462            serialize( $map ),
463            $context->getUser()->getId() . '#' . $this->getIdValue()
464        );
465    }
466
467    /**
468     * Compare an old change token to the current one
469     *
470     * @param IContextSource $context
471     * @param string $oldToken
472     * @return bool Whether the current is unchanged
473     */
474    final public function checkChangeToken( IContextSource $context, $oldToken ) {
475        return ( $this->getChangeToken( $context ) === $oldToken );
476    }
477
478    /**
479     * Update whether this object should be written to the data store
480     * @param bool $pending set to true to mark this object as needing to write its data
481     */
482    public function setPending( $pending ) {
483        $this->daoPending = $pending;
484    }
485
486    /**
487     * Update the origin of this object
488     * @param string $source source of the object
489     *     'new': Treat this as a new object to the datastore (insert on save)
490     *     'db': Treat this as already in the datastore (update on save)
491     */
492    public function updateOrigin( $source ) {
493        $this->daoOrigin = $source;
494    }
495}