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