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