Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 104 |
|
0.00% |
0 / 12 |
CRAP | |
0.00% |
0 / 1 |
| SubmitControl | |
0.00% |
0 / 104 |
|
0.00% |
0 / 12 |
2070 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| setInputParameters | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| submit | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
20 | |||
| registerValidators | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
12 | |||
| validateFieldInternal | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
56 | |||
| getDefaultValidationError | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
42 | |||
| getValidationResult | |
0.00% |
0 / 20 |
|
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% |
0 / 15 |
|
0.00% |
0 / 1 |
72 | |||
| processAction | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
| failure | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| success | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getIrrevocableGrants | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
| getAcceptedConsumerGrants | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace MediaWiki\Extension\OAuth\Control; |
| 4 | |
| 5 | use LogicException; |
| 6 | use MediaWiki\Api\ApiMessage; |
| 7 | use MediaWiki\Context\ContextSource; |
| 8 | use MediaWiki\Context\IContextSource; |
| 9 | use MediaWiki\Exception\MWException; |
| 10 | use MediaWiki\Extension\OAuth\Backend\Consumer; |
| 11 | use MediaWiki\HTMLForm\HTMLForm; |
| 12 | use MediaWiki\MediaWikiServices; |
| 13 | use MediaWiki\Message\Message; |
| 14 | use MediaWiki\Status\Status; |
| 15 | use StatusValue; |
| 16 | use Wikimedia\Message\MessageParam; |
| 17 | use 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 | */ |
| 28 | abstract 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 | } |