Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 183
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 / 183
0.00% covered (danger)
0.00%
0 / 12
2756
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
6
 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 / 5
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 / 27
0.00% covered (danger)
0.00%
0 / 1
182
 logAuthenticationResult
0.00% covered (danger)
0.00%
0 / 9
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 * 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 * @since 1.27
22 */
23
24use MediaWiki\Auth\AuthenticationRequest;
25use MediaWiki\Auth\AuthenticationResponse;
26use MediaWiki\Auth\AuthManager;
27use MediaWiki\Auth\CreateFromLoginAuthenticationRequest;
28use MediaWiki\Logger\LoggerFactory;
29use MediaWiki\MediaWikiServices;
30use MediaWiki\Parser\Parser;
31use Wikimedia\ParamValidator\ParamValidator;
32
33/**
34 * Helper class for AuthManager-using API modules. Intended for use via
35 * composition.
36 *
37 * @ingroup API
38 */
39class ApiAuthManagerHelper {
40
41    /** @var ApiBase API module, for context and parameters */
42    private $module;
43
44    /** @var string Message output format */
45    private $messageFormat;
46
47    private AuthManager $authManager;
48
49    /**
50     * @param ApiBase $module API module, for context and parameters
51     * @param AuthManager|null $authManager
52     */
53    public function __construct( ApiBase $module, AuthManager $authManager = null ) {
54        $this->module = $module;
55
56        $params = $module->extractRequestParams();
57        $this->messageFormat = $params['messageformat'] ?? 'wikitext';
58        $this->authManager = $authManager ?: MediaWikiServices::getInstance()->getAuthManager();
59    }
60
61    /**
62     * Static version of the constructor, for chaining
63     * @param ApiBase $module API module, for context and parameters
64     * @param AuthManager|null $authManager
65     * @return ApiAuthManagerHelper
66     */
67    public static function newForModule( ApiBase $module, AuthManager $authManager = null ) {
68        return new self( $module, $authManager );
69    }
70
71    /**
72     * Format a message for output
73     * @param array &$res Result array
74     * @param string $key Result key
75     * @param Message $message
76     */
77    private function formatMessage( array &$res, $key, Message $message ) {
78        switch ( $this->messageFormat ) {
79            case 'none':
80                break;
81
82            case 'wikitext':
83                $res[$key] = $message->setContext( $this->module )->text();
84                break;
85
86            case 'html':
87                $res[$key] = $message->setContext( $this->module )->parseAsBlock();
88                $res[$key] = Parser::stripOuterParagraph( $res[$key] );
89                break;
90
91            case 'raw':
92                $params = $message->getParams();
93                $res[$key] = [
94                    'key' => $message->getKey(),
95                    'params' => $params,
96                ];
97                ApiResult::setIndexedTagName( $params, 'param' );
98                break;
99        }
100    }
101
102    /**
103     * Call $manager->securitySensitiveOperationStatus()
104     * @param string $operation Operation being checked.
105     * @throws ApiUsageException
106     */
107    public function securitySensitiveOperation( $operation ) {
108        $status = $this->authManager->securitySensitiveOperationStatus( $operation );
109        switch ( $status ) {
110            case AuthManager::SEC_OK:
111                return;
112
113            case AuthManager::SEC_REAUTH:
114                $this->module->dieWithError( 'apierror-reauthenticate' );
115                // dieWithError prevents continuation
116
117            case AuthManager::SEC_FAIL:
118                $this->module->dieWithError( 'apierror-cannotreauthenticate' );
119                // dieWithError prevents continuation
120
121            default:
122                throw new UnexpectedValueException( "Unknown status \"$status\"" );
123        }
124    }
125
126    /**
127     * Filter out authentication requests by class name
128     * @param AuthenticationRequest[] $reqs Requests to filter
129     * @param string[] $remove Class names to remove
130     * @return AuthenticationRequest[]
131     */
132    public static function blacklistAuthenticationRequests( array $reqs, array $remove ) {
133        if ( $remove ) {
134            $remove = array_fill_keys( $remove, true );
135            $reqs = array_filter( $reqs, static function ( $req ) use ( $remove ) {
136                return !isset( $remove[get_class( $req )] );
137            } );
138        }
139        return $reqs;
140    }
141
142    /**
143     * Fetch and load the AuthenticationRequests for an action
144     * @param string $action One of the AuthManager::ACTION_* constants
145     * @return AuthenticationRequest[]
146     */
147    public function loadAuthenticationRequests( $action ) {
148        $params = $this->module->extractRequestParams();
149
150        $reqs = $this->authManager->getAuthenticationRequests( $action, $this->module->getUser() );
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            $this->formatMessage( $ret, 'message', $res->message );
225            $ret['messagecode'] = ApiMessage::create( $res->message )->getApiCode();
226        }
227
228        if ( $res->status === AuthenticationResponse::FAIL ||
229            $res->status === AuthenticationResponse::RESTART
230        ) {
231            $this->module->getRequest()->getSession()->set(
232                'ApiAuthManagerHelper::createRequest',
233                $res->createRequest
234            );
235            $ret['canpreservestate'] = $res->createRequest !== null;
236        } else {
237            $this->module->getRequest()->getSession()->remove( 'ApiAuthManagerHelper::createRequest' );
238        }
239
240        return $ret;
241    }
242
243    /**
244     * Logs successful or failed authentication.
245     * @param string $event Event type (e.g. 'accountcreation')
246     * @param AuthenticationResponse $result Response or error message
247     */
248    public function logAuthenticationResult( $event, AuthenticationResponse $result ) {
249        if ( !in_array( $result->status, [ AuthenticationResponse::PASS, AuthenticationResponse::FAIL ] ) ) {
250            return;
251        }
252
253        $module = $this->module->getModuleName();
254        LoggerFactory::getInstance( 'authevents' )->info( "$module API attempt", [
255            'event' => $event,
256            'successful' => $result->status === AuthenticationResponse::PASS,
257            'status' => $result->message ? $result->message->getKey() : '-',
258            'module' => $module,
259        ] );
260    }
261
262    /**
263     * Fetch the preserved CreateFromLoginAuthenticationRequest, if any
264     * @return CreateFromLoginAuthenticationRequest|null
265     */
266    public function getPreservedRequest() {
267        $ret = $this->module->getRequest()->getSession()->get( 'ApiAuthManagerHelper::createRequest' );
268        return $ret instanceof CreateFromLoginAuthenticationRequest ? $ret : null;
269    }
270
271    /**
272     * Format an array of AuthenticationRequests for return
273     * @param AuthenticationRequest[] $reqs
274     * @return array Will have a 'requests' key, and also 'fields' if $module's
275     *  params include 'mergerequestfields'.
276     */
277    public function formatRequests( array $reqs ) {
278        $params = $this->module->extractRequestParams();
279        $mergeFields = !empty( $params['mergerequestfields'] );
280
281        $ret = [ 'requests' => [] ];
282        foreach ( $reqs as $req ) {
283            $describe = $req->describeCredentials();
284            $reqInfo = [
285                'id' => $req->getUniqueId(),
286                'metadata' => $req->getMetadata() + [ ApiResult::META_TYPE => 'assoc' ],
287            ];
288            switch ( $req->required ) {
289                case AuthenticationRequest::OPTIONAL:
290                    $reqInfo['required'] = 'optional';
291                    break;
292                case AuthenticationRequest::REQUIRED:
293                    $reqInfo['required'] = 'required';
294                    break;
295                case AuthenticationRequest::PRIMARY_REQUIRED:
296                    $reqInfo['required'] = 'primary-required';
297                    break;
298            }
299            $this->formatMessage( $reqInfo, 'provider', $describe['provider'] );
300            $this->formatMessage( $reqInfo, 'account', $describe['account'] );
301            if ( !$mergeFields ) {
302                $reqInfo['fields'] = $this->formatFields( (array)$req->getFieldInfo() );
303            }
304            $ret['requests'][] = $reqInfo;
305        }
306
307        if ( $mergeFields ) {
308            $fields = AuthenticationRequest::mergeFieldInfo( $reqs );
309            $ret['fields'] = $this->formatFields( $fields );
310        }
311
312        return $ret;
313    }
314
315    /**
316     * Clean up a field array for output
317     * @param array $fields
318     * @phpcs:ignore Generic.Files.LineLength
319     * @phan-param array{type:string,options:array,value:string,label:Message,help:Message,optional:bool,sensitive:bool,skippable:bool} $fields
320     * @return array
321     */
322    private function formatFields( array $fields ) {
323        static $copy = [
324            'type' => true,
325            'value' => true,
326        ];
327
328        $module = $this->module;
329        $retFields = [];
330
331        foreach ( $fields as $name => $field ) {
332            $ret = array_intersect_key( $field, $copy );
333
334            if ( isset( $field['options'] ) ) {
335                $ret['options'] = array_map( static function ( $msg ) use ( $module ) {
336                    return $msg->setContext( $module )->plain();
337                }, $field['options'] );
338                ApiResult::setArrayType( $ret['options'], 'assoc' );
339            }
340            $this->formatMessage( $ret, 'label', $field['label'] );
341            $this->formatMessage( $ret, 'help', $field['help'] );
342            $ret['optional'] = !empty( $field['optional'] );
343            $ret['sensitive'] = !empty( $field['sensitive'] );
344
345            $retFields[$name] = $ret;
346        }
347
348        ApiResult::setArrayType( $retFields, 'assoc' );
349
350        return $retFields;
351    }
352
353    /**
354     * Fetch the standard parameters this helper recognizes
355     * @param string $action AuthManager action
356     * @param string ...$wantedParams Parameters to use
357     * @return array
358     */
359    public static function getStandardParams( $action, ...$wantedParams ) {
360        $params = [
361            'requests' => [
362                ParamValidator::PARAM_TYPE => 'string',
363                ParamValidator::PARAM_ISMULTI => true,
364                ApiBase::PARAM_HELP_MSG => [ 'api-help-authmanagerhelper-requests', $action ],
365            ],
366            'request' => [
367                ParamValidator::PARAM_TYPE => 'string',
368                ParamValidator::PARAM_REQUIRED => true,
369                ApiBase::PARAM_HELP_MSG => [ 'api-help-authmanagerhelper-request', $action ],
370            ],
371            'messageformat' => [
372                ParamValidator::PARAM_DEFAULT => 'wikitext',
373                ParamValidator::PARAM_TYPE => [ 'html', 'wikitext', 'raw', 'none' ],
374                ApiBase::PARAM_HELP_MSG => 'api-help-authmanagerhelper-messageformat',
375            ],
376            'mergerequestfields' => [
377                ParamValidator::PARAM_DEFAULT => false,
378                ApiBase::PARAM_HELP_MSG => 'api-help-authmanagerhelper-mergerequestfields',
379            ],
380            'preservestate' => [
381                ParamValidator::PARAM_DEFAULT => false,
382                ApiBase::PARAM_HELP_MSG => 'api-help-authmanagerhelper-preservestate',
383            ],
384            'returnurl' => [
385                ParamValidator::PARAM_TYPE => 'string',
386                ApiBase::PARAM_HELP_MSG => 'api-help-authmanagerhelper-returnurl',
387            ],
388            'continue' => [
389                ParamValidator::PARAM_DEFAULT => false,
390                ApiBase::PARAM_HELP_MSG => 'api-help-authmanagerhelper-continue',
391            ],
392        ];
393
394        $ret = [];
395        foreach ( $wantedParams as $name ) {
396            if ( isset( $params[$name] ) ) {
397                $ret[$name] = $params[$name];
398            }
399        }
400        return $ret;
401    }
402}