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