Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.05% covered (success)
99.05%
104 / 105
90.00% covered (success)
90.00%
9 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
AuthenticationRequest
99.05% covered (success)
99.05%
104 / 105
90.00% covered (success)
90.00%
9 / 10
57
0.00% covered (danger)
0.00%
0 / 1
 getUniqueId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFieldInfo
n/a
0 / 0
n/a
0 / 0
0
 getMetadata
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadFromSubmission
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
20
 validate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 describeCredentials
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 loadRequestsFromSubmission
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getRequestByClass
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getUsernameFromRequests
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 mergeFieldInfo
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
18
 __set_state
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Auth;
8
9use MediaWiki\Language\RawMessage;
10use MediaWiki\Message\Message;
11use StatusValue;
12use 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 */
29abstract 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}