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