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