summaryrefslogtreecommitdiff
path: root/www/wiki/includes/auth/AuthenticationRequest.php
blob: 7fc362a2042722c1393e7cc4c163425a9145b4bf (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
<?php
/**
 * Authentication request value object
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
 * http://www.gnu.org/copyleft/gpl.html
 *
 * @file
 * @ingroup Auth
 */

namespace MediaWiki\Auth;

use Message;

/**
 * This is a value object for authentication requests.
 *
 * An AuthenticationRequest represents a set of form fields that are needed on
 * and provided from a login, account creation, password change or similar form.
 *
 * @ingroup Auth
 * @since 1.27
 */
abstract class AuthenticationRequest {

	/** Indicates that the request is not required for authentication to proceed. */
	const OPTIONAL = 0;

	/** Indicates that the request is required for authentication to proceed.
	 * This will only be used for UI purposes; it is the authentication providers'
	 * responsibility to verify that all required requests are present.
	 */
	const REQUIRED = 1;

	/** Indicates that the request is required by a primary authentication
	 * provider. Since the user can choose which primary to authenticate with,
	 * the request might or might not end up being actually required. */
	const PRIMARY_REQUIRED = 2;

	/** @var string|null The AuthManager::ACTION_* constant this request was
	 * created to be used for. The *_CONTINUE constants are not used here, the
	 * corresponding "begin" constant is used instead.
	 */
	public $action = null;

	/** @var int For login, continue, and link actions, one of self::OPTIONAL,
	 * self::REQUIRED, or self::PRIMARY_REQUIRED */
	public $required = self::REQUIRED;

	/** @var string|null Return-to URL, in case of redirect */
	public $returnToUrl = null;

	/** @var string|null Username. See AuthenticationProvider::getAuthenticationRequests()
	 * for details of what this means and how it behaves. */
	public $username = null;

	/**
	 * Supply a unique key for deduplication
	 *
	 * When the AuthenticationRequests instances returned by the providers are
	 * merged, the value returned here is used for keeping only one copy of
	 * duplicate requests.
	 *
	 * Subclasses should override this if multiple distinct instances would
	 * make sense, i.e. the request class has internal state of some sort.
	 *
	 * This value might be exposed to the user in web forms so it should not
	 * contain private information.
	 *
	 * @return string
	 */
	public function getUniqueId() {
		return get_called_class();
	}

	/**
	 * Fetch input field info
	 *
	 * The field info is an associative array mapping field names to info
	 * arrays. The info arrays have the following keys:
	 *  - type: (string) Type of input. Types and equivalent HTML widgets are:
	 *     - string: <input type="text">
	 *     - password: <input type="password">
	 *     - select: <select>
	 *     - checkbox: <input type="checkbox">
	 *     - multiselect: More a grid of checkboxes than <select multi>
	 *     - button: <input type="submit"> (uses 'label' as button text)
	 *     - hidden: Not visible to the user, but needs to be preserved for the next request
	 *     - null: No widget, just display the 'label' message.
	 *  - options: (array) Maps option values to Messages for the
	 *      'select' and 'multiselect' types.
	 *  - value: (string) Value (for 'null' and 'hidden') or default value (for other types).
	 *  - label: (Message) Text suitable for a label in an HTML form
	 *  - help: (Message) Text suitable as a description of what the field is
	 *  - optional: (bool) If set and truthy, the field may be left empty
	 *  - sensitive: (bool) If set and truthy, the field is considered sensitive. Code using the
	 *      request should avoid exposing the value of the field.
	 *  - skippable: (bool) If set and truthy, the client is free to hide this
	 *      field from the user to streamline the workflow. If all fields are
	 *      skippable (except possibly a single button), no user interaction is
	 *      required at all.
	 *
	 * All AuthenticationRequests are populated from the same data, so most of the time you'll
	 * want to prefix fields names with something unique to the extension/provider (although
	 * in some cases sharing the field with other requests is the right thing to do, e.g. for
	 * a 'password' field).
	 *
	 * @return array As above
	 */
	abstract public function getFieldInfo();

	/**
	 * Returns metadata about this request.
	 *
	 * This is mainly for the benefit of API clients which need more detailed render hints
	 * than what's available through getFieldInfo(). Semantics are unspecified and left to the
	 * individual subclasses, but the contents of the array should be primitive types so that they
	 * can be transformed into JSON or similar formats.
	 *
	 * @return array A (possibly nested) array with primitive types
	 */
	public function getMetadata() {
		return [];
	}

	/**
	 * Initialize form submitted form data.
	 *
	 * The default behavior is to to check for each key of self::getFieldInfo()
	 * in the submitted data, and copy the value - after type-appropriate transformations -
	 * to $this->$key. Most subclasses won't need to override this; if you do override it,
	 * make sure to always return false if self::getFieldInfo() returns an empty array.
	 *
	 * @param array $data Submitted data as an associative array (keys will correspond
	 *   to getFieldInfo())
	 * @return bool Whether the request data was successfully loaded
	 */
	public function loadFromSubmission( array $data ) {
		$fields = array_filter( $this->getFieldInfo(), function ( $info ) {
			return $info['type'] !== 'null';
		} );
		if ( !$fields ) {
			return false;
		}

		foreach ( $fields as $field => $info ) {
			// Checkboxes and buttons are special. Depending on the method used
			// to populate $data, they might be unset meaning false or they
			// might be boolean. Further, image buttons might submit the
			// coordinates of the click rather than the expected value.
			if ( $info['type'] === 'checkbox' || $info['type'] === 'button' ) {
				$this->$field = isset( $data[$field] ) && $data[$field] !== false
					|| isset( $data["{$field}_x"] ) && $data["{$field}_x"] !== false;
				if ( !$this->$field && empty( $info['optional'] ) ) {
					return false;
				}
				continue;
			}

			// Multiselect are too, slightly
			if ( !isset( $data[$field] ) && $info['type'] === 'multiselect' ) {
				$data[$field] = [];
			}

			if ( !isset( $data[$field] ) ) {
				return false;
			}
			if ( $data[$field] === '' || $data[$field] === [] ) {
				if ( empty( $info['optional'] ) ) {
					return false;
				}
			} else {
				switch ( $info['type'] ) {
					case 'select':
						if ( !isset( $info['options'][$data[$field]] ) ) {
							return false;
						}
						break;

					case 'multiselect':
						$data[$field] = (array)$data[$field];
						$allowed = array_keys( $info['options'] );
						if ( array_diff( $data[$field], $allowed ) !== [] ) {
							return false;
						}
						break;
				}
			}

			$this->$field = $data[$field];
		}

		return true;
	}

	/**
	 * Describe the credentials represented by this request
	 *
	 * This is used on requests returned by
	 * AuthenticationProvider::getAuthenticationRequests() for ACTION_LINK
	 * and ACTION_REMOVE and for requests returned in
	 * AuthenticationResponse::$linkRequest to create useful user interfaces.
	 *
	 * @return Message[] with the following keys:
	 *  - provider: A Message identifying the service that provides
	 *    the credentials, e.g. the name of the third party authentication
	 *    service.
	 *  - account: A Message identifying the credentials themselves,
	 *    e.g. the email address used with the third party authentication
	 *    service.
	 */
	public function describeCredentials() {
		return [
			'provider' => new \RawMessage( '$1', [ get_called_class() ] ),
			'account' => new \RawMessage( '$1', [ $this->getUniqueId() ] ),
		];
	}

	/**
	 * Update a set of requests with form submit data, discarding ones that fail
	 * @param AuthenticationRequest[] $reqs
	 * @param array $data
	 * @return AuthenticationRequest[]
	 */
	public static function loadRequestsFromSubmission( array $reqs, array $data ) {
		return array_values( array_filter( $reqs, function ( $req ) use ( $data ) {
			return $req->loadFromSubmission( $data );
		} ) );
	}

	/**
	 * Select a request by class name.
	 * @param AuthenticationRequest[] $reqs
	 * @param string $class Class name
	 * @param bool $allowSubclasses If true, also returns any request that's a subclass of the given
	 *   class.
	 * @return AuthenticationRequest|null Returns null if there is not exactly
	 *  one matching request.
	 */
	public static function getRequestByClass( array $reqs, $class, $allowSubclasses = false ) {
		$requests = array_filter( $reqs, function ( $req ) use ( $class, $allowSubclasses ) {
			if ( $allowSubclasses ) {
				return is_a( $req, $class, false );
			} else {
				return get_class( $req ) === $class;
			}
		} );
		return count( $requests ) === 1 ? reset( $requests ) : null;
	}

	/**
	 * Get the username from the set of requests
	 *
	 * Only considers requests that have a "username" field.
	 *
	 * @param AuthenticationRequest[] $reqs
	 * @return string|null
	 * @throws \UnexpectedValueException If multiple different usernames are present.
	 */
	public static function getUsernameFromRequests( array $reqs ) {
		$username = null;
		$otherClass = null;
		foreach ( $reqs as $req ) {
			$info = $req->getFieldInfo();
			if ( $info && array_key_exists( 'username', $info ) && $req->username !== null ) {
				if ( $username === null ) {
					$username = $req->username;
					$otherClass = get_class( $req );
				} elseif ( $username !== $req->username ) {
					$requestClass = get_class( $req );
					throw new \UnexpectedValueException( "Conflicting username fields: \"{$req->username}\" from "
						. "$requestClass::\$username vs. \"$username\" from $otherClass::\$username" );
				}
			}
		}
		return $username;
	}

	/**
	 * Merge the output of multiple AuthenticationRequest::getFieldInfo() calls.
	 * @param AuthenticationRequest[] $reqs
	 * @return array
	 * @throws \UnexpectedValueException If fields cannot be merged
	 */
	public static function mergeFieldInfo( array $reqs ) {
		$merged = [];

		// fields that are required by some primary providers but not others are not actually required
		$primaryRequests = array_filter( $reqs, function ( $req ) {
			return $req->required === AuthenticationRequest::PRIMARY_REQUIRED;
		} );
		$sharedRequiredPrimaryFields = array_reduce( $primaryRequests, function ( $shared, $req ) {
			$required = array_keys( array_filter( $req->getFieldInfo(), function ( $options ) {
				return empty( $options['optional'] );
			} ) );
			if ( $shared === null ) {
				return $required;
			} else {
				return array_intersect( $shared, $required );
			}
		}, null );

		foreach ( $reqs as $req ) {
			$info = $req->getFieldInfo();
			if ( !$info ) {
				continue;
			}

			foreach ( $info as $name => $options ) {
				if (
					// If the request isn't required, its fields aren't required either.
					$req->required === self::OPTIONAL
					// If there is a primary not requiring this field, no matter how many others do,
					// authentication can proceed without it.
					|| $req->required === self::PRIMARY_REQUIRED
						&& !in_array( $name, $sharedRequiredPrimaryFields, true )
				) {
					$options['optional'] = true;
				} else {
					$options['optional'] = !empty( $options['optional'] );
				}

				$options['sensitive'] = !empty( $options['sensitive'] );

				if ( !array_key_exists( $name, $merged ) ) {
					$merged[$name] = $options;
				} elseif ( $merged[$name]['type'] !== $options['type'] ) {
					throw new \UnexpectedValueException( "Field type conflict for \"$name\", " .
						"\"{$merged[$name]['type']}\" vs \"{$options['type']}\""
					);
				} else {
					if ( isset( $options['options'] ) ) {
						if ( isset( $merged[$name]['options'] ) ) {
							$merged[$name]['options'] += $options['options'];
						} else {
							// @codeCoverageIgnoreStart
							$merged[$name]['options'] = $options['options'];
							// @codeCoverageIgnoreEnd
						}
					}

					$merged[$name]['optional'] = $merged[$name]['optional'] && $options['optional'];
					$merged[$name]['sensitive'] = $merged[$name]['sensitive'] || $options['sensitive'];

					// No way to merge 'value', 'image', 'help', or 'label', so just use
					// the value from the first request.
				}
			}
		}

		return $merged;
	}

	/**
	 * Implementing this mainly for use from the unit tests.
	 * @param array $data
	 * @return AuthenticationRequest
	 */
	public static function __set_state( $data ) {
		$ret = new static();
		foreach ( $data as $k => $v ) {
			$ret->$k = $v;
		}
		return $ret;
	}
}