Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.03% covered (success)
95.03%
153 / 161
91.67% covered (success)
91.67%
11 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiLogin
95.03% covered (success)
95.03%
153 / 161
91.67% covered (success)
91.67%
11 / 12
42
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getExtendedDescription
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 formatMessage
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 execute
92.73% covered (success)
92.73%
102 / 110
0.00% covered (danger)
0.00%
0 / 1
25.24
 isDeprecated
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 mustBePosted
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isReadMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isWriteMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllowedParams
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 getExamplesMessages
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getHelpUrls
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAuthenticationResponseLogData
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2/**
3 * Copyright © 2006-2007 Yuri Astrakhan "<Firstname><Lastname>@gmail.com",
4 * Daniel Cannon (cannon dot danielc at gmail dot com)
5 *
6 * This program is free software; you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation; either version 2 of the License, or
9 * (at your option) any later version.
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License along
17 * with this program; if not, write to the Free Software Foundation, Inc.,
18 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 * http://www.gnu.org/copyleft/gpl.html
20 *
21 * @file
22 */
23
24use MediaWiki\Auth\AuthenticationRequest;
25use MediaWiki\Auth\AuthenticationResponse;
26use MediaWiki\Auth\AuthManager;
27use MediaWiki\Logger\LoggerFactory;
28use MediaWiki\MainConfigNames;
29use MediaWiki\User\BotPassword;
30use Wikimedia\ParamValidator\ParamValidator;
31
32/**
33 * Unit to authenticate log-in attempts to the current wiki.
34 *
35 * @ingroup API
36 */
37class ApiLogin extends ApiBase {
38
39    private AuthManager $authManager;
40
41    /**
42     * @param ApiMain $main
43     * @param string $action
44     * @param AuthManager $authManager
45     */
46    public function __construct(
47        ApiMain $main,
48        $action,
49        AuthManager $authManager
50    ) {
51        parent::__construct( $main, $action, 'lg' );
52        $this->authManager = $authManager;
53    }
54
55    protected function getExtendedDescription() {
56        if ( $this->getConfig()->get( MainConfigNames::EnableBotPasswords ) ) {
57            return 'apihelp-login-extended-description';
58        } else {
59            return 'apihelp-login-extended-description-nobotpasswords';
60        }
61    }
62
63    /**
64     * Format a message for the response
65     * @param Message|string|array $message
66     * @return string|array
67     */
68    private function formatMessage( $message ) {
69        $message = Message::newFromSpecifier( $message );
70        $errorFormatter = $this->getErrorFormatter();
71        if ( $errorFormatter instanceof ApiErrorFormatter_BackCompat ) {
72            return ApiErrorFormatter::stripMarkup(
73                $message->useDatabase( false )->inLanguage( 'en' )->text()
74            );
75        } else {
76            return $errorFormatter->formatMessage( $message );
77        }
78    }
79
80    /**
81     * Executes the log-in attempt using the parameters passed. If
82     * the log-in succeeds, it attaches a cookie to the session
83     * and outputs the user id, username, and session token. If a
84     * log-in fails, as the result of a bad password, a nonexistent
85     * user, or any other reason, the host is cached with an expiry
86     * and no log-in attempts will be accepted until that expiry
87     * is reached. The expiry is $this->mLoginThrottle.
88     */
89    public function execute() {
90        // If we're in a mode that breaks the same-origin policy, no tokens can
91        // be obtained
92        if ( $this->lacksSameOriginSecurity() ) {
93            $this->getResult()->addValue( null, 'login', [
94                'result' => 'Aborted',
95                'reason' => $this->formatMessage( 'api-login-fail-sameorigin' ),
96            ] );
97
98            return;
99        }
100
101        $this->requirePostedParameters( [ 'password', 'token' ] );
102
103        $params = $this->extractRequestParams();
104
105        $result = [];
106
107        // Make sure session is persisted
108        $session = MediaWiki\Session\SessionManager::getGlobalSession();
109        $session->persist();
110
111        // Make sure it's possible to log in
112        if ( !$session->canSetUser() ) {
113            $this->getResult()->addValue( null, 'login', [
114                'result' => 'Aborted',
115                'reason' => $this->formatMessage( [
116                    'api-login-fail-badsessionprovider',
117                    $session->getProvider()->describe( $this->getErrorFormatter()->getLanguage() ),
118                ] )
119            ] );
120
121            return;
122        }
123
124        $authRes = false;
125        $loginType = 'N/A';
126
127        // Check login token
128        $token = $session->getToken( '', 'login' );
129        if ( !$params['token'] ) {
130            $authRes = 'NeedToken';
131        } elseif ( $token->wasNew() ) {
132            $authRes = 'Failed';
133            $message = ApiMessage::create( 'authpage-cannot-login-continue', 'sessionlost' );
134        } elseif ( !$token->match( $params['token'] ) ) {
135            $authRes = 'WrongToken';
136        }
137
138        // Try bot passwords
139        if (
140            $authRes === false && $this->getConfig()->get( MainConfigNames::EnableBotPasswords ) &&
141            ( $botLoginData = BotPassword::canonicalizeLoginData( $params['name'], $params['password'] ) )
142        ) {
143            $status = BotPassword::login(
144                $botLoginData[0], $botLoginData[1], $this->getRequest()
145            );
146            if ( $status->isOK() ) {
147                $session = $status->getValue();
148                $authRes = 'Success';
149                $loginType = 'BotPassword';
150            } elseif (
151                $status->hasMessage( 'login-throttled' ) ||
152                $status->hasMessage( 'botpasswords-needs-reset' ) ||
153                $status->hasMessage( 'botpasswords-locked' )
154            ) {
155                $authRes = 'Failed';
156                $message = $status->getMessage();
157                LoggerFactory::getInstance( 'authentication' )->info(
158                    'BotPassword login failed: ' . $status->getWikiText( false, false, 'en' )
159                );
160            }
161            // For other errors, let's see if it's a valid non-bot login
162        }
163
164        if ( $authRes === false ) {
165            // Simplified AuthManager login, for backwards compatibility
166            $reqs = AuthenticationRequest::loadRequestsFromSubmission(
167                $this->authManager->getAuthenticationRequests(
168                    AuthManager::ACTION_LOGIN,
169                    $this->getUser()
170                ),
171                [
172                    'username' => $params['name'],
173                    'password' => $params['password'],
174                    'domain' => $params['domain'],
175                    'rememberMe' => true,
176                ]
177            );
178            $res = $this->authManager->beginAuthentication( $reqs, 'null:' );
179            switch ( $res->status ) {
180                case AuthenticationResponse::PASS:
181                    if ( $this->getConfig()->get( MainConfigNames::EnableBotPasswords ) ) {
182                        $this->addDeprecation( 'apiwarn-deprecation-login-botpw', 'main-account-login' );
183                    } else {
184                        $this->addDeprecation( 'apiwarn-deprecation-login-nobotpw', 'main-account-login' );
185                    }
186                    $authRes = 'Success';
187                    $loginType = 'AuthManager';
188                    break;
189
190                case AuthenticationResponse::FAIL:
191                    // Hope it's not a PreAuthenticationProvider that failed...
192                    $authRes = 'Failed';
193                    $message = $res->message;
194                    LoggerFactory::getInstance( 'authentication' )
195                        ->info( __METHOD__ . ': Authentication failed: '
196                        . $message->inLanguage( 'en' )->plain() );
197                    break;
198
199                default:
200                    LoggerFactory::getInstance( 'authentication' )
201                        ->info( __METHOD__ . ': Authentication failed due to unsupported response type: '
202                        . $res->status, $this->getAuthenticationResponseLogData( $res ) );
203                    $authRes = 'Aborted';
204                    break;
205            }
206        }
207
208        $result['result'] = $authRes;
209        switch ( $authRes ) {
210            case 'Success':
211                $user = $session->getUser();
212
213                // Deprecated hook
214                $injected_html = '';
215                $this->getHookRunner()->onUserLoginComplete( $user, $injected_html, true );
216
217                $result['lguserid'] = $user->getId();
218                $result['lgusername'] = $user->getName();
219                break;
220
221            case 'NeedToken':
222                $result['token'] = $token->toString();
223                $this->addDeprecation( 'apiwarn-deprecation-login-token', 'action=login&!lgtoken' );
224                break;
225
226            case 'WrongToken':
227                break;
228
229            case 'Failed':
230                // @phan-suppress-next-next-line PhanTypeMismatchArgumentNullable,PhanPossiblyUndeclaredVariable
231                // message set on error
232                $result['reason'] = $this->formatMessage( $message );
233                break;
234
235            case 'Aborted':
236                $result['reason'] = $this->formatMessage(
237                    $this->getConfig()->get( MainConfigNames::EnableBotPasswords )
238                        ? 'api-login-fail-aborted'
239                        : 'api-login-fail-aborted-nobotpw'
240                );
241                break;
242
243            // @codeCoverageIgnoreStart
244            // Unreachable
245            default:
246                ApiBase::dieDebug( __METHOD__, "Unhandled case value: {$authRes}" );
247            // @codeCoverageIgnoreEnd
248        }
249
250        $this->getResult()->addValue( null, 'login', $result );
251
252        LoggerFactory::getInstance( 'authevents' )->info( 'Login attempt', [
253            'event' => 'login',
254            'successful' => $authRes === 'Success',
255            'loginType' => $loginType,
256            'status' => $authRes,
257        ] );
258    }
259
260    public function isDeprecated() {
261        return !$this->getConfig()->get( MainConfigNames::EnableBotPasswords );
262    }
263
264    public function mustBePosted() {
265        return true;
266    }
267
268    public function isReadMode() {
269        return false;
270    }
271
272    public function isWriteMode() {
273        // (T283394) Logging in triggers some database writes, so should be marked appropriately.
274        return true;
275    }
276
277    public function getAllowedParams() {
278        return [
279            'name' => null,
280            'password' => [
281                ParamValidator::PARAM_TYPE => 'password',
282            ],
283            'domain' => null,
284            'token' => [
285                ParamValidator::PARAM_TYPE => 'string',
286                ParamValidator::PARAM_REQUIRED => false, // for BC
287                ParamValidator::PARAM_SENSITIVE => true,
288                ApiBase::PARAM_HELP_MSG => [ 'api-help-param-token', 'login' ],
289            ],
290        ];
291    }
292
293    protected function getExamplesMessages() {
294        return [
295            'action=login&lgname=user&lgpassword=password&lgtoken=123ABC'
296                => 'apihelp-login-example-login',
297        ];
298    }
299
300    public function getHelpUrls() {
301        return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Login';
302    }
303
304    /**
305     * Turns an AuthenticationResponse into a hash suitable for passing to Logger
306     * @param AuthenticationResponse $response
307     * @return array
308     */
309    protected function getAuthenticationResponseLogData( AuthenticationResponse $response ) {
310        $ret = [
311            'status' => $response->status,
312        ];
313        if ( $response->message ) {
314            $ret['responseMessage'] = $response->message->inLanguage( 'en' )->plain();
315        }
316        $reqs = [
317            'neededRequests' => $response->neededRequests,
318            'createRequest' => $response->createRequest,
319            'linkRequest' => $response->linkRequest,
320        ];
321        foreach ( $reqs as $k => $v ) {
322            if ( $v ) {
323                $v = is_array( $v ) ? $v : [ $v ];
324                $reqClasses = array_unique( array_map( 'get_class', $v ) );
325                sort( $reqClasses );
326                $ret[$k] = implode( ', ', $reqClasses );
327            }
328        }
329        return $ret;
330    }
331}