Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 189
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiAuthManagerHelper
0.00% covered (danger)
0.00%
0 / 188
0.00% covered (danger)
0.00%
0 / 12
2652
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 newForModule
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 formatMessage
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 securitySensitiveOperation
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 blacklistAuthenticationRequests
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 loadAuthenticationRequests
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
42
 formatAuthenticationResponse
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
182
 logAuthenticationResult
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 getPreservedRequest
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 formatRequests
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
56
 formatFields
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
12
 getStandardParams
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * Copyright © 2016 Wikimedia Foundation and contributors
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 * @since 1.27
8 */
9
10namespace MediaWiki\Api;
11
12use MediaWiki\Auth\AuthenticationRequest;
13use MediaWiki\Auth\AuthenticationResponse;
14use MediaWiki\Auth\AuthManager;
15use MediaWiki\Auth\CreateFromLoginAuthenticationRequest;
16use MediaWiki\Language\RawMessage;
17use MediaWiki\Logger\LoggerFactory;
18use MediaWiki\MediaWikiServices;
19use MediaWiki\Message\Message;
20use MediaWiki\Parser\Parser;
21use MediaWiki\User\UserIdentity;
22use MediaWiki\User\UserIdentityUtils;
23use UnexpectedValueException;
24use Wikimedia\ParamValidator\ParamValidator;
25
26/**
27 * Helper class for AuthManager-using API modules. Intended for use via
28 * composition.
29 *
30 * @ingroup API
31 */
32class ApiAuthManagerHelper {
33
34    /** @var string Message output format */
35    private string $messageFormat;
36
37    private AuthManager $authManager;
38
39    private UserIdentityUtils $identityUtils;
40
41    /**
42     * @param ApiBase $module API module, for context and parameters
43     * @param AuthManager|null $authManager
44     * @param UserIdentityUtils|null $identityUtils
45     */
46    public function __construct(
47        private readonly ApiBase $module,
48        ?AuthManager $authManager = null,
49        ?UserIdentityUtils $identityUtils = null,
50    ) {
51        $params = $module->extractRequestParams();
52        $this->messageFormat = $params['messageformat'] ?? 'wikitext';
53        $this->authManager = $authManager ?? MediaWikiServices::getInstance()->getAuthManager();
54        // TODO: inject this as currently it's always taken from container
55        $this->identityUtils = $identityUtils ?? MediaWikiServices::getInstance()->getUserIdentityUtils();
56    }
57
58    /**
59     * Static version of the constructor, for chaining
60     * @param ApiBase $module API module, for context and parameters
61     * @param AuthManager|null $authManager
62     * @return self
63     */
64    public static function newForModule( ApiBase $module, ?AuthManager $authManager = null ) {
65        return new self( $module, $authManager );
66    }
67
68    /**
69     * Format a message for output
70     * @param array &$res Result array
71     * @param string $key Result key
72     * @param Message $message
73     */
74    private function formatMessage( array &$res, $key, Message $message ) {
75        switch ( $this->messageFormat ) {
76            case 'none':
77                break;
78
79            case 'wikitext':
80                $res[$key] = $message->setContext( $this->module )->text();
81                break;
82
83            case 'html':
84                $res[$key] = $message->setContext( $this->module )->parseAsBlock();
85                $res[$key] = Parser::stripOuterParagraph( $res[$key] );
86                break;
87
88            case 'raw':
89                $params = $message->getParams();
90                $res[$key] = [
91                    'key' => $message->getKey(),
92                    'params' => $params,
93                ];
94                ApiResult::setIndexedTagName( $params, 'param' );
95                break;
96        }
97    }
98
99    /**
100     * Call $manager->securitySensitiveOperationStatus()
101     * @param string $operation Operation being checked.
102     * @throws ApiUsageException
103     */
104    public function securitySensitiveOperation( $operation ) {
105        $status = $this->authManager->securitySensitiveOperationStatus( $operation );
106        switch ( $status ) {
107            case AuthManager::SEC_OK:
108                $this->authManager->securitySensitiveOperationDone( $operation );
109                return;
110
111            case AuthManager::SEC_REAUTH:
112                $this->module->dieWithError( [ 'apierror-reauthenticate', $operation ],
113                    null, [ 'operation' => $operation ] );
114                // dieWithError prevents continuation
115
116            case AuthManager::SEC_FAIL:
117                $this->module->dieWithError( 'apierror-cannotreauthenticate' );
118                // dieWithError prevents continuation
119
120            default:
121                throw new UnexpectedValueException( "Unknown status \"$status\"" );
122        }
123    }
124
125    /**
126     * Filter out authentication requests by class name
127     * @param AuthenticationRequest[] $reqs Requests to filter
128     * @param string[] $remove Class names to remove
129     * @return AuthenticationRequest[]
130     */
131    public static function blacklistAuthenticationRequests( array $reqs, array $remove ) {
132        if ( $remove ) {
133            $remove = array_fill_keys( $remove, true );
134            $reqs = array_filter( $reqs, static function ( $req ) use ( $remove ) {
135                return !isset( $remove[get_class( $req )] );
136            } );
137        }
138        return $reqs;
139    }
140
141    /**
142     * Fetch and load the AuthenticationRequests for an action
143     * @param string $action One of the AuthManager::ACTION_* constants
144     * @param array $options Options to pass to AuthManager::getAuthenticationRequests()
145     * @return AuthenticationRequest[]
146     */
147    public function loadAuthenticationRequests( $action, $options = [] ) {
148        $params = $this->module->extractRequestParams();
149
150        $reqs = $this->authManager->getAuthenticationRequests( $action, $this->module->getUser(), $options );
151
152        // Filter requests, if requested to do so
153        $wantedRequests = null;
154        if ( isset( $params['requests'] ) ) {
155            $wantedRequests = array_fill_keys( $params['requests'], true );
156        } elseif ( isset( $params['request'] ) ) {
157            $wantedRequests = [ $params['request'] => true ];
158        }
159        if ( $wantedRequests !== null ) {
160            $reqs = array_filter(
161                $reqs,
162                static function ( AuthenticationRequest $req ) use ( $wantedRequests ) {
163                    return isset( $wantedRequests[$req->getUniqueId()] );
164                }
165            );
166        }
167
168        // Collect the fields for all the requests
169        $fields = [];
170        $sensitive = [];
171        foreach ( $reqs as $req ) {
172            $info = (array)$req->getFieldInfo();
173            $fields += $info;
174            $sensitive += array_filter( $info, static function ( $opts ) {
175                return !empty( $opts['sensitive'] );
176            } );
177        }
178
179        // Extract the request data for the fields and mark those request
180        // parameters as used
181        $data = array_intersect_key( $this->module->getRequest()->getValues(), $fields );
182        $this->module->getMain()->markParamsUsed( array_keys( $data ) );
183
184        if ( $sensitive ) {
185            $this->module->getMain()->markParamsSensitive( array_keys( $sensitive ) );
186            $this->module->requirePostedParameters( array_keys( $sensitive ), 'noprefix' );
187        }
188
189        return AuthenticationRequest::loadRequestsFromSubmission( $reqs, $data );
190    }
191
192    /**
193     * Format an AuthenticationResponse for return
194     * @param AuthenticationResponse $res
195     * @return array
196     */
197    public function formatAuthenticationResponse( AuthenticationResponse $res ) {
198        $ret = [
199            'status' => $res->status,
200        ];
201
202        if ( $res->status === AuthenticationResponse::PASS && $res->username !== null ) {
203            $ret['username'] = $res->username;
204        }
205
206        if ( $res->status === AuthenticationResponse::REDIRECT ) {
207            $ret['redirecttarget'] = $res->redirectTarget;
208            if ( $res->redirectApiData !== null ) {
209                $ret['redirectdata'] = $res->redirectApiData;
210            }
211        }
212
213        if ( $res->status === AuthenticationResponse::REDIRECT ||
214            $res->status === AuthenticationResponse::UI ||
215            $res->status === AuthenticationResponse::RESTART
216        ) {
217            $ret += $this->formatRequests( $res->neededRequests );
218        }
219
220        if ( $res->status === AuthenticationResponse::FAIL ||
221            $res->status === AuthenticationResponse::UI ||
222            $res->status === AuthenticationResponse::RESTART
223        ) {
224            $res->message ??= new RawMessage( '' );
225            $this->formatMessage( $ret, 'message', $res->message );
226            $ret['messagecode'] = ApiMessage::create( $res->message )->getApiCode();
227        }
228
229        if ( $res->status === AuthenticationResponse::FAIL ||
230            $res->status === AuthenticationResponse::RESTART
231        ) {
232            $this->module->getRequest()->getSession()->set(
233                'ApiAuthManagerHelper::createRequest',
234                $res->createRequest
235            );
236            $ret['canpreservestate'] = $res->createRequest !== null;
237        } else {
238            $this->module->getRequest()->getSession()->remove( 'ApiAuthManagerHelper::createRequest' );
239        }
240
241        return $ret;
242    }
243
244    /**
245     * Logs successful or failed authentication.
246     * @param string $event Event type (e.g. 'accountcreation')
247     * @param UserIdentity $performer
248     * @param AuthenticationResponse $result Response or error message
249     */
250    public function logAuthenticationResult( $event, UserIdentity $performer, AuthenticationResponse $result ) {
251        if ( !in_array( $result->status, [ AuthenticationResponse::PASS, AuthenticationResponse::FAIL ] ) ) {
252            return;
253        }
254        $accountType = $this->identityUtils->getShortUserTypeInternal( $performer );
255
256        $module = $this->module->getModuleName();
257        LoggerFactory::getInstance( 'authevents' )->info( "$module API attempt", [
258            'event' => $event,
259            'successful' => $result->status === AuthenticationResponse::PASS,
260            'status' => $result->message ? $result->message->getKey() : '-',
261            'accountType' => $accountType,
262            'module' => $module,
263        ] );
264    }
265
266    /**
267     * Fetch the preserved CreateFromLoginAuthenticationRequest, if any
268     * @return CreateFromLoginAuthenticationRequest|null
269     */
270    public function getPreservedRequest() {
271        $ret = $this->module->getRequest()->getSession()->get( 'ApiAuthManagerHelper::createRequest' );
272        return $ret instanceof CreateFromLoginAuthenticationRequest ? $ret : null;
273    }
274
275    /**
276     * Format an array of AuthenticationRequests for return
277     * @param AuthenticationRequest[] $reqs
278     * @return array Will have a 'requests' key, and also 'fields' if $module's
279     *  params include 'mergerequestfields'.
280     */
281    public function formatRequests( array $reqs ) {
282        $params = $this->module->extractRequestParams();
283        $mergeFields = !empty( $params['mergerequestfields'] );
284
285        $ret = [ 'requests' => [] ];
286        foreach ( $reqs as $req ) {
287            $describe = $req->describeCredentials();
288            $reqInfo = [
289                'id' => $req->getUniqueId(),
290                'metadata' => $req->getMetadata() + [ ApiResult::META_TYPE => 'assoc' ],
291            ];
292            switch ( $req->required ) {
293                case AuthenticationRequest::OPTIONAL:
294                    $reqInfo['required'] = 'optional';
295                    break;
296                case AuthenticationRequest::REQUIRED:
297                    $reqInfo['required'] = 'required';
298                    break;
299                case AuthenticationRequest::PRIMARY_REQUIRED:
300                    $reqInfo['required'] = 'primary-required';
301                    break;
302            }
303            $this->formatMessage( $reqInfo, 'provider', $describe['provider'] );
304            $this->formatMessage( $reqInfo, 'account', $describe['account'] );
305            if ( !$mergeFields ) {
306                $reqInfo['fields'] = $this->formatFields( (array)$req->getFieldInfo() );
307            }
308            $ret['requests'][] = $reqInfo;
309        }
310
311        if ( $mergeFields ) {
312            $fields = AuthenticationRequest::mergeFieldInfo( $reqs );
313            $ret['fields'] = $this->formatFields( $fields );
314        }
315
316        return $ret;
317    }
318
319    /**
320     * Clean up a field array for output
321     * @param array $fields
322     * @phpcs:ignore Generic.Files.LineLength
323     * @phan-param array{type:string,options?:array,value:string,label?:Message,help:Message,optional:bool,sensitive:bool,skippable:bool} $fields
324     * @return array
325     */
326    private function formatFields( array $fields ) {
327        static $copy = [
328            'type' => true,
329            'value' => true,
330        ];
331
332        $module = $this->module;
333        $retFields = [];
334
335        foreach ( $fields as $name => $field ) {
336            $ret = array_intersect_key( $field, $copy );
337
338            if ( isset( $field['options'] ) ) {
339                $ret['options'] = array_map( static function ( $msg ) use ( $module ) {
340                    return $msg->setContext( $module )->plain();
341                }, $field['options'] );
342                ApiResult::setArrayType( $ret['options'], 'assoc' );
343            }
344            $this->formatMessage( $ret, 'label', $field['label'] ?? new RawMessage( '' ) );
345            $this->formatMessage( $ret, 'help', $field['help'] );
346            $ret['optional'] = !empty( $field['optional'] );
347            $ret['sensitive'] = !empty( $field['sensitive'] );
348
349            $retFields[$name] = $ret;
350        }
351
352        ApiResult::setArrayType( $retFields, 'assoc' );
353
354        return $retFields;
355    }
356
357    /**
358     * Fetch the standard parameters this helper recognizes
359     * @param string $action AuthManager action
360     * @param string ...$wantedParams Parameters to use
361     * @return array
362     */
363    public static function getStandardParams( $action, ...$wantedParams ) {
364        $params = [
365            'requests' => [
366                ParamValidator::PARAM_TYPE => 'string',
367                ParamValidator::PARAM_ISMULTI => true,
368                ApiBase::PARAM_HELP_MSG => [ 'api-help-authmanagerhelper-requests', $action ],
369            ],
370            'request' => [
371                ParamValidator::PARAM_TYPE => 'string',
372                ParamValidator::PARAM_REQUIRED => true,
373                ApiBase::PARAM_HELP_MSG => [ 'api-help-authmanagerhelper-request', $action ],
374            ],
375            'messageformat' => [
376                ParamValidator::PARAM_DEFAULT => 'wikitext',
377                ParamValidator::PARAM_TYPE => [ 'html', 'wikitext', 'raw', 'none' ],
378                ApiBase::PARAM_HELP_MSG => 'api-help-authmanagerhelper-messageformat',
379            ],
380            'mergerequestfields' => [
381                ParamValidator::PARAM_DEFAULT => false,
382                ApiBase::PARAM_HELP_MSG => 'api-help-authmanagerhelper-mergerequestfields',
383            ],
384            'preservestate' => [
385                ParamValidator::PARAM_DEFAULT => false,
386                ApiBase::PARAM_HELP_MSG => 'api-help-authmanagerhelper-preservestate',
387            ],
388            'returnurl' => [
389                ParamValidator::PARAM_TYPE => 'string',
390                ApiBase::PARAM_HELP_MSG => 'api-help-authmanagerhelper-returnurl',
391            ],
392            'continue' => [
393                ParamValidator::PARAM_DEFAULT => false,
394                ApiBase::PARAM_HELP_MSG => 'api-help-authmanagerhelper-continue',
395            ],
396        ];
397
398        $ret = [];
399        foreach ( $wantedParams as $name ) {
400            if ( isset( $params[$name] ) ) {
401                $ret[$name] = $params[$name];
402            }
403        }
404        return $ret;
405    }
406}
407
408/** @deprecated class alias since 1.43 */
409class_alias( ApiAuthManagerHelper::class, 'ApiAuthManagerHelper' );