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