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