Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
99.05% |
104 / 105 |
|
90.00% |
9 / 10 |
CRAP | |
0.00% |
0 / 1 |
| AuthenticationRequest | |
99.05% |
104 / 105 |
|
90.00% |
9 / 10 |
57 | |
0.00% |
0 / 1 |
| getUniqueId | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getFieldInfo | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
| getMetadata | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| loadFromSubmission | |
100.00% |
32 / 32 |
|
100.00% |
1 / 1 |
20 | |||
| validate | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| describeCredentials | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| loadRequestsFromSubmission | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| getRequestByClass | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| getUsernameFromRequests | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
7 | |||
| mergeFieldInfo | |
100.00% |
37 / 37 |
|
100.00% |
1 / 1 |
18 | |||
| __set_state | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | */ |
| 6 | |
| 7 | namespace MediaWiki\Auth; |
| 8 | |
| 9 | use MediaWiki\Language\RawMessage; |
| 10 | use MediaWiki\Message\Message; |
| 11 | use StatusValue; |
| 12 | use UnexpectedValueException; |
| 13 | |
| 14 | /** |
| 15 | * This is a value object for authentication requests. |
| 16 | * |
| 17 | * An AuthenticationRequest represents a set of form fields that are needed on |
| 18 | * and provided from a login, account creation, password change or similar form. Form fields can |
| 19 | * be shared by multiple AuthenticationRequests (see {@see ::mergeFieldInfo()}). |
| 20 | * |
| 21 | * Authentication providers that expect user input need to implement one or more subclasses |
| 22 | * of this class and return them from AuthenticationProvider::getAuthenticationRequests(). |
| 23 | * A typical subclass would override getFieldInfo() and set $required. |
| 24 | * |
| 25 | * @stable to extend |
| 26 | * @ingroup Auth |
| 27 | * @since 1.27 |
| 28 | */ |
| 29 | abstract class AuthenticationRequest { |
| 30 | |
| 31 | /** Indicates that the request is not required for authentication to proceed. */ |
| 32 | public const OPTIONAL = 0; |
| 33 | |
| 34 | /** |
| 35 | * Indicates that the request is required for authentication to proceed. |
| 36 | * This will only be used for UI purposes; it is the authentication providers' |
| 37 | * responsibility to verify that all required requests are present. |
| 38 | */ |
| 39 | public const REQUIRED = 1; |
| 40 | |
| 41 | /** |
| 42 | * Indicates that the request is required by a primary authentication |
| 43 | * provider. Since the user can choose which primary to authenticate with, |
| 44 | * the request might or might not end up being actually required. |
| 45 | */ |
| 46 | public const PRIMARY_REQUIRED = 2; |
| 47 | |
| 48 | /** |
| 49 | * The AuthManager::ACTION_* constant this request was created to be used for. |
| 50 | * Usually set by AuthManager. The *_CONTINUE constants are not used here, |
| 51 | * the corresponding "begin" constant is used instead. |
| 52 | * |
| 53 | * @var string|null |
| 54 | */ |
| 55 | public $action = null; |
| 56 | |
| 57 | /** |
| 58 | * Whether the authentication request is required (for login, continue, and link |
| 59 | * actions). Setting this to optional is roughly equivalent to setting the 'optional' flag for |
| 60 | * all fields in the field info. |
| 61 | * |
| 62 | * Set this to self::OPTIONAL or self::REQUIRED. When coming from a primary provider, |
| 63 | * self::REQUIRED will be automatically modified to self::PRIMARY_REQUIRED. |
| 64 | * |
| 65 | * @var int |
| 66 | */ |
| 67 | public $required = self::REQUIRED; |
| 68 | |
| 69 | /** |
| 70 | * Return-to URL, in case of a REDIRECT AuthenticationResponse. Set by AuthManager. |
| 71 | * @var string|null |
| 72 | */ |
| 73 | public $returnToUrl = null; |
| 74 | |
| 75 | /** |
| 76 | * Username. Usually set by AuthManager. See AuthenticationProvider::getAuthenticationRequests() |
| 77 | * for details of what this means and how it behaves. |
| 78 | * |
| 79 | * Often this doubles as a normal field (ie. getFieldInfo() has a 'username' key). |
| 80 | * |
| 81 | * @var string|null |
| 82 | */ |
| 83 | public $username = null; |
| 84 | |
| 85 | /** |
| 86 | * Supply a unique key for deduplication |
| 87 | * |
| 88 | * When the AuthenticationRequests instances returned by the providers are |
| 89 | * merged, the value returned here is used for keeping only one copy of |
| 90 | * duplicate requests. |
| 91 | * |
| 92 | * Subclasses should override this if multiple distinct instances would |
| 93 | * make sense, i.e. the request class has internal state of some sort. |
| 94 | * |
| 95 | * This value might be exposed to the user in web forms so it should not |
| 96 | * contain private information. |
| 97 | * |
| 98 | * @stable to override |
| 99 | * @return string |
| 100 | */ |
| 101 | public function getUniqueId() { |
| 102 | return get_called_class(); |
| 103 | } |
| 104 | |
| 105 | /** |
| 106 | * Fetch input field info. This will be used in the AuthManager APIs and web UIs to define |
| 107 | * API input parameters / form fields and to process the submitted data. |
| 108 | * |
| 109 | * The field info is an associative array mapping field names to info |
| 110 | * arrays. The info arrays have the following keys: |
| 111 | * - type: (string) Type of input. Types and equivalent HTML widgets are: |
| 112 | * - string: <input type="text"> |
| 113 | * - password: <input type="password"> |
| 114 | * - select: <select> |
| 115 | * - checkbox: <input type="checkbox"> |
| 116 | * - multiselect: More a grid of checkboxes than <select multi> |
| 117 | * - button: <input type="submit"> (uses 'label' as button text) |
| 118 | * - hidden: Not visible to the user, but needs to be preserved for the next request |
| 119 | * - null: No widget, just display the 'label' message. |
| 120 | * - options: (array) Maps option values to Messages for the |
| 121 | * 'select' and 'multiselect' types. |
| 122 | * - value: (string) Value (for 'null' and 'hidden') or default value (for other types). |
| 123 | * - label: (Message) Text suitable for a label in an HTML form |
| 124 | * - help: (Message) Text suitable as a description of what the field is. Used in API |
| 125 | * documentation. To add a help text to the web UI, use the AuthChangeFormFields hook. |
| 126 | * - optional: (bool) If set and truthy, the field may be left empty |
| 127 | * - sensitive: (bool) If set and truthy, the field is considered sensitive. Code using the |
| 128 | * request should avoid exposing the value of the field. |
| 129 | * - skippable: (bool) If set and truthy, the client is free to hide this |
| 130 | * field from the user to streamline the workflow. If all fields are |
| 131 | * skippable (except possibly a single button), no user interaction is |
| 132 | * required at all. |
| 133 | * |
| 134 | * All AuthenticationRequests are populated from the same data, so most of the time you'll |
| 135 | * want to prefix fields names with something unique to the extension/provider (although |
| 136 | * in some cases sharing the field with other requests is the right thing to do, e.g. for |
| 137 | * a 'password' field). When multiple fields have the same name, they will be merged (see |
| 138 | * AuthenticationRequests::mergeFieldInfo). |
| 139 | * Typically, AuthenticationRequest subclasses define public properties with names matching |
| 140 | * the field info keys, and those fields will be populated from the submitted data. More |
| 141 | * complex behavior can be implemented by overriding {@see ::loadFromSubmission()}. |
| 142 | * |
| 143 | * @return array As above |
| 144 | * @phan-return array<string,array{type:'null',value:Message,help:Message,label?:Message}|array{type:string,options?:array,value?:string,label:Message,help:Message,optional?:bool,sensitive?:bool,skippable?:bool}> |
| 145 | */ |
| 146 | abstract public function getFieldInfo(); |
| 147 | |
| 148 | /** |
| 149 | * Returns metadata about this request. |
| 150 | * |
| 151 | * This is mainly for the benefit of API clients which need more detailed render hints |
| 152 | * than what's available through getFieldInfo(). Semantics are unspecified and left to the |
| 153 | * individual subclasses, but the contents of the array should be primitive types so that they |
| 154 | * can be transformed into JSON or similar formats. |
| 155 | * |
| 156 | * @stable to override |
| 157 | * @return array A (possibly nested) array with primitive types |
| 158 | */ |
| 159 | public function getMetadata() { |
| 160 | return []; |
| 161 | } |
| 162 | |
| 163 | /** |
| 164 | * Initialize form submitted form data. |
| 165 | * |
| 166 | * The default behavior is to check for each key of self::getFieldInfo() |
| 167 | * in the submitted data, and copy the value - after type-appropriate transformations - |
| 168 | * to $this->$key. Most subclasses won't need to override this; if you do override it, |
| 169 | * make sure to always return false if self::getFieldInfo() returns an empty array. |
| 170 | * |
| 171 | * @stable to override |
| 172 | * @param array $data Submitted data as an associative array (keys will correspond |
| 173 | * to getFieldInfo()) |
| 174 | * @return bool Whether the request data was successfully loaded |
| 175 | */ |
| 176 | public function loadFromSubmission( array $data ) { |
| 177 | $fields = array_filter( $this->getFieldInfo(), static function ( $info ) { |
| 178 | return $info['type'] !== 'null'; |
| 179 | } ); |
| 180 | if ( !$fields ) { |
| 181 | return false; |
| 182 | } |
| 183 | |
| 184 | foreach ( $fields as $field => $info ) { |
| 185 | // Checkboxes and buttons are special. Depending on the method used |
| 186 | // to populate $data, they might be unset meaning false or they |
| 187 | // might be boolean. Further, image buttons might submit the |
| 188 | // coordinates of the click rather than the expected value. |
| 189 | if ( $info['type'] === 'checkbox' || $info['type'] === 'button' ) { |
| 190 | $this->$field = ( isset( $data[$field] ) && $data[$field] !== false ) |
| 191 | || ( isset( $data["{$field}_x"] ) && $data["{$field}_x"] !== false ); |
| 192 | if ( !$this->$field && empty( $info['optional'] ) ) { |
| 193 | return false; |
| 194 | } |
| 195 | continue; |
| 196 | } |
| 197 | |
| 198 | // Multiselect are too, slightly |
| 199 | if ( !isset( $data[$field] ) && $info['type'] === 'multiselect' ) { |
| 200 | $data[$field] = []; |
| 201 | } |
| 202 | |
| 203 | if ( !isset( $data[$field] ) ) { |
| 204 | return false; |
| 205 | } |
| 206 | if ( $data[$field] === '' || $data[$field] === [] ) { |
| 207 | if ( empty( $info['optional'] ) ) { |
| 208 | return false; |
| 209 | } |
| 210 | } else { |
| 211 | switch ( $info['type'] ) { |
| 212 | case 'select': |
| 213 | if ( !isset( $info['options'][$data[$field]] ) ) { |
| 214 | return false; |
| 215 | } |
| 216 | break; |
| 217 | |
| 218 | case 'multiselect': |
| 219 | $data[$field] = (array)$data[$field]; |
| 220 | // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset required for multiselect |
| 221 | $allowed = array_keys( $info['options'] ); |
| 222 | if ( array_diff( $data[$field], $allowed ) !== [] ) { |
| 223 | return false; |
| 224 | } |
| 225 | break; |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | $this->$field = $data[$field]; |
| 230 | } |
| 231 | |
| 232 | return true; |
| 233 | } |
| 234 | |
| 235 | /** |
| 236 | * Validate the submitted data. |
| 237 | * |
| 238 | * This is called by AuthManager sometime after request data was loaded |
| 239 | * with loadFromSubmission(). A fatal status will stop the authentication |
| 240 | * process and result in a FAIL response. (Non-fatal error statuses are |
| 241 | * ignored for now.) |
| 242 | * |
| 243 | * This is meant for authentication errors the user is unable to fix |
| 244 | * without restarting the process (such as token expiration) or which |
| 245 | * indicate tampering with the request. Recoverable errors are better |
| 246 | * handled by methods with allow the user to redo the request without |
| 247 | * restarting the process (e.g. the AuthChangeFormFields hook). |
| 248 | * |
| 249 | * Note that this check was added in MediaWiki 1.46 so don't use it as a |
| 250 | * security guarantee if you want to support older versions as well. |
| 251 | * |
| 252 | * @return StatusValue |
| 253 | * @since 1.46 |
| 254 | */ |
| 255 | public function validate() { |
| 256 | return StatusValue::newGood(); |
| 257 | } |
| 258 | |
| 259 | /** |
| 260 | * Describe the credentials represented by this request |
| 261 | * |
| 262 | * This is used on requests returned by |
| 263 | * AuthenticationProvider::getAuthenticationRequests() for ACTION_LINK |
| 264 | * and ACTION_REMOVE and for requests returned in |
| 265 | * AuthenticationResponse::$linkRequest to create useful user interfaces. |
| 266 | * |
| 267 | * @stable to override |
| 268 | * |
| 269 | * @return Message[] with the following keys: |
| 270 | * - provider: A Message identifying the service that provides |
| 271 | * the credentials, e.g. the name of the third party authentication |
| 272 | * service. |
| 273 | * - account: A Message identifying the credentials themselves, |
| 274 | * e.g. the email address used with the third party authentication |
| 275 | * service. |
| 276 | */ |
| 277 | public function describeCredentials() { |
| 278 | return [ |
| 279 | 'provider' => new RawMessage( '$1', [ get_called_class() ] ), |
| 280 | 'account' => new RawMessage( '$1', [ $this->getUniqueId() ] ), |
| 281 | ]; |
| 282 | } |
| 283 | |
| 284 | /** |
| 285 | * Update a set of requests with form submit data, discarding ones that fail |
| 286 | * |
| 287 | * @param AuthenticationRequest[] $reqs |
| 288 | * @param array $data |
| 289 | * @return AuthenticationRequest[] |
| 290 | */ |
| 291 | public static function loadRequestsFromSubmission( array $reqs, array $data ) { |
| 292 | $result = []; |
| 293 | foreach ( $reqs as $req ) { |
| 294 | if ( $req->loadFromSubmission( $data ) ) { |
| 295 | $result[] = $req; |
| 296 | } |
| 297 | } |
| 298 | return $result; |
| 299 | } |
| 300 | |
| 301 | /** |
| 302 | * Select a request by class name. |
| 303 | * |
| 304 | * @template T of AuthenticationRequest |
| 305 | * @param AuthenticationRequest[] $reqs |
| 306 | * @param class-string<T> $class Class name |
| 307 | * @param bool $allowSubclasses If true, also returns any request that's a subclass of the given |
| 308 | * class. |
| 309 | * @return T|null Returns null if there is not exactly |
| 310 | * one matching request. |
| 311 | */ |
| 312 | public static function getRequestByClass( array $reqs, $class, $allowSubclasses = false ) { |
| 313 | $requests = array_filter( $reqs, static function ( $req ) use ( $class, $allowSubclasses ) { |
| 314 | if ( $allowSubclasses ) { |
| 315 | return is_a( $req, $class, false ); |
| 316 | } else { |
| 317 | return get_class( $req ) === $class; |
| 318 | } |
| 319 | } ); |
| 320 | // @phan-suppress-next-line PhanTypeMismatchReturn False positive |
| 321 | return count( $requests ) === 1 ? reset( $requests ) : null; |
| 322 | } |
| 323 | |
| 324 | /** |
| 325 | * Get the username from the set of requests |
| 326 | * |
| 327 | * Only considers requests that have a "username" field. |
| 328 | * |
| 329 | * @param AuthenticationRequest[] $reqs |
| 330 | * @return string|null |
| 331 | * @throws UnexpectedValueException If multiple different usernames are present. |
| 332 | */ |
| 333 | public static function getUsernameFromRequests( array $reqs ) { |
| 334 | $username = null; |
| 335 | $otherClass = null; |
| 336 | foreach ( $reqs as $req ) { |
| 337 | $info = $req->getFieldInfo(); |
| 338 | if ( $info && array_key_exists( 'username', $info ) && $req->username !== null ) { |
| 339 | if ( $username === null ) { |
| 340 | $username = $req->username; |
| 341 | $otherClass = get_class( $req ); |
| 342 | } elseif ( $username !== $req->username ) { |
| 343 | $requestClass = get_class( $req ); |
| 344 | throw new UnexpectedValueException( "Conflicting username fields: \"{$req->username}\" from " |
| 345 | // @phan-suppress-next-line PhanTypeSuspiciousStringExpression $otherClass always set |
| 346 | . "$requestClass::\$username vs. \"$username\" from $otherClass::\$username" ); |
| 347 | } |
| 348 | } |
| 349 | } |
| 350 | return $username; |
| 351 | } |
| 352 | |
| 353 | /** |
| 354 | * Merge the output of multiple AuthenticationRequest::getFieldInfo() calls. |
| 355 | * @param AuthenticationRequest[] $reqs |
| 356 | * @return array Field info in the same format as getFieldInfo(). |
| 357 | * @throws UnexpectedValueException If the requests include fields with the same name but |
| 358 | * incompatible definitions (e.g. different field types). |
| 359 | */ |
| 360 | public static function mergeFieldInfo( array $reqs ) { |
| 361 | $merged = []; |
| 362 | |
| 363 | // fields that are required by some primary providers but not others are not actually required |
| 364 | $sharedRequiredPrimaryFields = null; |
| 365 | foreach ( $reqs as $req ) { |
| 366 | if ( $req->required !== self::PRIMARY_REQUIRED ) { |
| 367 | continue; |
| 368 | } |
| 369 | $required = []; |
| 370 | foreach ( $req->getFieldInfo() as $fieldName => $options ) { |
| 371 | if ( empty( $options['optional'] ) ) { |
| 372 | $required[] = $fieldName; |
| 373 | } |
| 374 | } |
| 375 | if ( $sharedRequiredPrimaryFields === null ) { |
| 376 | $sharedRequiredPrimaryFields = $required; |
| 377 | } else { |
| 378 | $sharedRequiredPrimaryFields = array_intersect( $sharedRequiredPrimaryFields, $required ); |
| 379 | } |
| 380 | } |
| 381 | |
| 382 | foreach ( $reqs as $req ) { |
| 383 | $info = $req->getFieldInfo(); |
| 384 | if ( !$info ) { |
| 385 | continue; |
| 386 | } |
| 387 | |
| 388 | foreach ( $info as $name => $options ) { |
| 389 | if ( |
| 390 | // If the request isn't required, its fields aren't required either. |
| 391 | $req->required === self::OPTIONAL |
| 392 | // If there is a primary not requiring this field, no matter how many others do, |
| 393 | // authentication can proceed without it. |
| 394 | || ( $req->required === self::PRIMARY_REQUIRED |
| 395 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal False positive |
| 396 | && !in_array( $name, $sharedRequiredPrimaryFields, true ) ) |
| 397 | ) { |
| 398 | $options['optional'] = true; |
| 399 | } else { |
| 400 | $options['optional'] = !empty( $options['optional'] ); |
| 401 | } |
| 402 | |
| 403 | $options['sensitive'] = !empty( $options['sensitive'] ); |
| 404 | $type = $options['type']; |
| 405 | |
| 406 | if ( !array_key_exists( $name, $merged ) ) { |
| 407 | $merged[$name] = $options; |
| 408 | } elseif ( $merged[$name]['type'] !== $type ) { |
| 409 | throw new UnexpectedValueException( "Field type conflict for \"$name\", " . |
| 410 | "\"{$merged[$name]['type']}\" vs \"$type\"" |
| 411 | ); |
| 412 | } else { |
| 413 | if ( isset( $options['options'] ) ) { |
| 414 | if ( isset( $merged[$name]['options'] ) ) { |
| 415 | $merged[$name]['options'] += $options['options']; |
| 416 | } else { |
| 417 | // @codeCoverageIgnoreStart |
| 418 | $merged[$name]['options'] = $options['options']; |
| 419 | // @codeCoverageIgnoreEnd |
| 420 | } |
| 421 | } |
| 422 | |
| 423 | $merged[$name]['optional'] = $merged[$name]['optional'] && $options['optional']; |
| 424 | $merged[$name]['sensitive'] = $merged[$name]['sensitive'] || $options['sensitive']; |
| 425 | |
| 426 | // No way to merge 'value', 'image', 'help', or 'label', so just use |
| 427 | // the value from the first request. |
| 428 | } |
| 429 | } |
| 430 | } |
| 431 | |
| 432 | return $merged; |
| 433 | } |
| 434 | |
| 435 | /** |
| 436 | * Implementing this mainly for use from the unit tests. |
| 437 | * @param array $data |
| 438 | * @return AuthenticationRequest |
| 439 | */ |
| 440 | public static function __set_state( $data ) { |
| 441 | // @phan-suppress-next-line PhanTypeInstantiateAbstractStatic |
| 442 | $ret = new static(); |
| 443 | foreach ( $data as $k => $v ) { |
| 444 | $ret->$k = $v; |
| 445 | } |
| 446 | return $ret; |
| 447 | } |
| 448 | } |