Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
62.50% covered (warning)
62.50%
65 / 104
41.67% covered (danger)
41.67%
5 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
SubmitControl
62.50% covered (warning)
62.50%
65 / 104
41.67% covered (danger)
41.67%
5 / 12
151.79
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setInputParameters
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 submit
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
4.10
 registerValidators
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 validateFieldInternal
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
8.14
 getDefaultValidationError
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
42
 getValidationResult
75.00% covered (warning)
75.00%
15 / 20
0.00% covered (danger)
0.00%
0 / 1
11.56
 getRequiredFields
n/a
0 / 0
n/a
0 / 0
0
 checkBasePermissions
n/a
0 / 0
n/a
0 / 0
0
 validateFields
73.33% covered (warning)
73.33%
11 / 15
0.00% covered (danger)
0.00%
0 / 1
9.21
 processAction
n/a
0 / 0
n/a
0 / 0
0
 failure
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 success
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getIrrevocableGrants
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getAcceptedConsumerGrants
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\OAuth\Control;
4
5use LogicException;
6use MediaWiki\Api\ApiMessage;
7use MediaWiki\Context\ContextSource;
8use MediaWiki\Context\IContextSource;
9use MediaWiki\Exception\MWException;
10use MediaWiki\Extension\OAuth\Backend\Consumer;
11use MediaWiki\HTMLForm\HTMLForm;
12use MediaWiki\MediaWikiServices;
13use MediaWiki\Message\Message;
14use MediaWiki\Status\Status;
15use StatusValue;
16use Wikimedia\Message\MessageParam;
17use Wikimedia\Message\MessageSpecifier;
18
19/**
20 * (c) Aaron Schulz 2013, GPL
21 *
22 * @license GPL-2.0-or-later
23 */
24
25/**
26 * Handle the logic of submitting a client request
27 */
28abstract class SubmitControl extends ContextSource {
29    /** @var string[]|null */
30    private static $irrevocableGrants = null;
31
32    /** @var array (field name => value) */
33    protected $vals;
34
35    /**
36     * @param IContextSource $context
37     * @param array $params
38     */
39    public function __construct( IContextSource $context, array $params ) {
40        $this->setContext( $context );
41        $this->vals = $params;
42    }
43
44    /**
45     * @param array $params
46     */
47    public function setInputParameters( array $params ) {
48        $this->vals = $params;
49    }
50
51    /**
52     * Attempt to validate and submit this data
53     *
54     * This will check basic permissions, validate the action and parameters
55     * and route the submission handling to the internal subclass function.
56     *
57     * @throws MWException
58     * @return Status
59     */
60    public function submit() {
61        $status = $this->checkBasePermissions();
62        if ( !$status->isOK() ) {
63            return $status;
64        }
65
66        $action = $this->vals['action'];
67        $required = $this->getRequiredFields();
68        if ( !isset( $required[$action] ) ) {
69            // @TODO: check for field-specific message first
70            return $this->failure( 'invalid_field_action', 'mwoauth-invalid-field', 'action' );
71        }
72
73        $status = $this->validateFields( $required[$action] );
74        if ( !$status->isOK() ) {
75            return $status;
76        }
77
78        return $this->processAction( $action );
79    }
80
81    /**
82     * Add the validators from getRequiredFields() to the given HTMLForm descriptor.
83     * Existing validators are not overridden.
84     *
85     * It also adds a checkbox to override warnings when necessary.
86     *
87     * @param array[] $descriptors
88     * @return array[]
89     */
90    public function registerValidators( array $descriptors ) {
91        foreach ( $descriptors as $field => &$description ) {
92            if ( array_key_exists( 'validation-callback', $description ) ) {
93                // already set to something
94                continue;
95            }
96            $description['validation-callback'] =
97                function ( $value, $allValues, $form ) use ( $field ) {
98                    return $this->validateFieldInternal( $field, $value, $allValues, $form );
99                };
100        }
101        $descriptors['ignorewarnings'] = [
102            'type' => 'check',
103            'label-message' => 'mwoauth-ignorewarnings',
104            'cssclass' => 'mw-oauth-form-ignorewarnings-hidden',
105        ];
106        return $descriptors;
107    }
108
109    /**
110     * Do some basic checks and call the validator provided by getRequiredFields().
111     * This method should not be called outside SubmitControl.
112     *
113     * @param string $field
114     * @param mixed $value
115     * @param array $allValues
116     * @param HTMLForm $form
117     * @throws MWException
118     * @return true|string
119     */
120    public function validateFieldInternal( string $field, $value, array $allValues, HTMLForm $form ) {
121        if ( !isset( $allValues['action'] ) && isset( $this->vals['action'] ) ) {
122            // The action may be derived, especially for multi-button forms.
123            // Such an HTMLForm will not have an action key set in $allValues.
124            $allValues['action'] = $this->vals['action'];
125        }
126        if ( !isset( $allValues['action'] ) ) {
127            throw new LogicException( "No form action defined; cannot validate fields." );
128        }
129        $validators = $this->getRequiredFields();
130        if ( !isset( $validators[$allValues['action']][$field] ) ) {
131            // nothing to check
132            return true;
133        }
134        $validator = $validators[$allValues['action']][$field];
135        $validationResult = $this->getValidationResult( $validator, $value, $allValues, $form );
136        if ( $validationResult === false ) {
137            return $this->getDefaultValidationError( $field, $value, $form )->text();
138        } elseif ( $validationResult instanceof ApiMessage ) {
139            return $validationResult->parse();
140        }
141        return true;
142    }
143
144    /**
145     * Generate an error message for a field. Used when the validator returns false.
146     *
147     * @param string $field
148     * @param mixed $value
149     * @param HTMLForm|null $form
150     * @return Message Error message (to be rendered via text()).
151     */
152    private function getDefaultValidationError( string $field, $value, ?HTMLForm $form = null ): Message {
153        $errorMessage = $this->msg( 'mwoauth-invalid-field-' . $field );
154        if ( !$errorMessage->isDisabled() ) {
155            return $errorMessage;
156        }
157
158        $generic = '';
159        if ( $form && $form->getField( $field )->canDisplayErrors() ) {
160            // error can be shown right next to the field so no need to mention the field name
161            $generic = '-generic';
162        }
163
164        $problem = 'invalid';
165        if ( $value === '' && !$generic ) {
166            $problem = 'missing';
167        }
168
169        // messages: mwoauth-missing-field, mwoauth-invalid-field, mwoauth-invalid-field-generic
170        return $this->msg( "mwoauth-$problem-field$generic", $field );
171    }
172
173    /**
174     * @param mixed $validator One of the callbacks registered via registerValidator.
175     * @param mixed $value The value of the field being validated.
176     * @param array $allValues All field values, keyed by field name.
177     * @param HTMLForm|null $form
178     * @return bool|ApiMessage
179     * @phan-param string|callable(mixed,array):(bool|StatusValue) $validator
180     */
181    private function getValidationResult( $validator, $value, array $allValues, ?HTMLForm $form = null ) {
182        if ( is_string( $validator ) ) {
183            return preg_match( $validator, $value ?? '' );
184        }
185        $result = $validator( $value, $allValues );
186        if ( $result instanceof StatusValue ) {
187            if ( $result->isGood() ) {
188                return true;
189            } elseif ( count( $result->getMessages() ) !== 1 ) {
190                throw new LogicException( 'Validator return status has too many errors: '
191                    . $result );
192            }
193            [ $errors, $warnings ] = $result->splitByErrorType();
194            if ( $errors->isOK() ) {
195                // $result is a warning -  if the user checked "ignore warnings", ignore;
196                // otherwise show the checkbox
197                if ( $form ) {
198                    // This is a horrible hack. There doesn't seem to be a way to modify a form's
199                    // CSS classes or other display properties between validation and rendering.
200                    $form->setId( 'oauth-form-with-warnings' );
201                }
202
203                if ( $allValues['ignorewarnings'] ?? false ) {
204                    return true;
205                }
206            }
207            $result = $result->getMessages()[0];
208        }
209        if ( is_bool( $result ) || $result instanceof ApiMessage ) {
210            return $result;
211        }
212
213        $type = get_debug_type( $result );
214        throw new LogicException( 'Invalid validator return type: ' . $type );
215    }
216
217    /**
218     * Get the field names and their validation methods. Fields can be omitted.
219     *
220     * A validation method is either a regex string or a callable.
221     * Callables take (field value, field/value map) as params and must return a boolean or a
222     * StatusValue with nothing or a single ApiMessage in it. If that single message is a warning,
223     * the user will be allowed to override it. A StatusValue with an error or boolean false will
224     * prevent submission.
225     *
226     * When false is returned, the error message will be 'mwoauth-invalid-field-<fieldname>'
227     * if it exists, or a generic message otherwise (see getDefaultValidationError()).
228     *
229     * @return array (action => (field name => validation regex or function))
230     * @phan-return array<string,array<string,string|callable(mixed):(bool|StatusValue)|callable(mixed,array):(bool|StatusValue)>>
231     */
232    abstract protected function getRequiredFields();
233
234    /**
235     * Check action-independent permissions against the user for this submission
236     *
237     * @return Status
238     */
239    abstract protected function checkBasePermissions();
240
241    /**
242     * Check that the action is valid and that the required fields are valid
243     *
244     * @param array $required (field => regex or callback)
245     * @phan-param array<string,string|callable(mixed,array):bool|StatusValue> $required
246     * @return Status
247     */
248    protected function validateFields( array $required ) {
249        foreach ( $required as $field => $validator ) {
250            if ( !isset( $this->vals[$field] ) ) {
251                return $this->failure( "missing_field_$field", 'mwoauth-missing-field', $field );
252            } elseif ( !is_scalar( $this->vals[$field] )
253                && !in_array( $field, [ 'restrictions', 'oauth2GrantTypes' ], true )
254            ) {
255                return $this->failure( "invalid_field_$field", 'mwoauth-invalid-field', $field );
256            }
257            if ( is_string( $this->vals[$field] ) ) {
258                $this->vals[$field] = trim( $this->vals[$field] );
259            }
260            $validationResult = $this->getValidationResult( $validator, $this->vals[$field], $this->vals );
261            if ( $validationResult === false ) {
262                $message = $this->getDefaultValidationError( $field, $this->vals[$field] );
263                return $this->failure( "invalid_field_$field", $message );
264            } elseif ( $validationResult instanceof ApiMessage ) {
265                return $this->failure( $validationResult->getApiCode(), $validationResult );
266            }
267        }
268        return $this->success();
269    }
270
271    /**
272     * Attempt to validate and submit this data for the given action
273     *
274     * @param string $action
275     * @return Status
276     */
277    abstract protected function processAction( $action ): Status;
278
279    /**
280     * @param string $error API error key
281     * @param string|MessageSpecifier $msg Message
282     * @param MessageParam|MessageSpecifier|string|int|float ...$params Additional arguments used as message parameters
283     * @return Status
284     */
285    protected function failure( $error, $msg, ...$params ) {
286        $status = Status::newFatal( $msg, ...$params );
287        $status->value = [ 'error' => $error, 'result' => null ];
288        return $status;
289    }
290
291    /**
292     * @param mixed|null $value
293     * @return Status
294     */
295    protected function success( $value = null ) {
296        return Status::newGood( [ 'error' => null, 'result' => $value ] );
297    }
298
299    public static function getIrrevocableGrants(): array {
300        if ( self::$irrevocableGrants === null ) {
301            self::$irrevocableGrants = array_merge(
302                MediaWikiServices::getInstance()->getGrantsInfo()->getHiddenGrants(),
303                Consumer::AUTH_ONLY_GRANTS
304            );
305        }
306        return self::$irrevocableGrants;
307    }
308
309    /**
310     * Given a list of accepted grants (in OAuth 1 terminology; scopes in OAuth 2 terminology),
311     * assumed to be from user input, filter them to those allowed by the consumer,
312     * and make sure that irrevocable grants needed by the consumer are included.
313     */
314    protected function getAcceptedConsumerGrants( array $grants, Consumer $cmr ): array {
315        return array_values(
316            array_unique(
317                array_intersect(
318                    array_merge( self::getIrrevocableGrants(), $grants ),
319                    // Only keep the applicable ones
320                    $cmr->getGrants()
321                )
322            )
323        );
324    }
325}