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