Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
71 / 71
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
ThrottlePreAuthenticationProvider
100.00% covered (success)
100.00%
71 / 71
100.00% covered (success)
100.00%
5 / 5
24
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 postInitSetup
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
4
 testForAccountCreation
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 testForAuthentication
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
9
 postAuthentication
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Auth;
8
9use MediaWiki\MainConfigNames;
10use MediaWiki\MediaWikiServices;
11use MediaWiki\User\User;
12use Wikimedia\ObjectCache\BagOStuff;
13
14/**
15 * A pre-authentication provider to throttle authentication actions.
16 *
17 * Adding this provider will throttle account creations and primary authentication attempts
18 * (more specifically, any authentication that returns FAIL on failure). Secondary authentication
19 * cannot be easily throttled on a framework level (since it would typically return UI on failure);
20 * secondary providers are expected to do their own throttling.
21 *
22 * @ingroup Auth
23 * @since 1.27
24 */
25class ThrottlePreAuthenticationProvider extends AbstractPreAuthenticationProvider {
26    /** @var array */
27    protected $throttleSettings;
28
29    protected ?Throttler $accountCreationThrottle = null;
30    protected ?Throttler $passwordAttemptThrottle = null;
31    protected BagOStuff $cache;
32
33    /**
34     * @param array $params
35     *  - accountCreationThrottle: (array) Condition array for the account creation throttle; an array
36     *    of arrays in a format like $wgPasswordAttemptThrottle, passed to the Throttler constructor.
37     *  - passwordAttemptThrottle: (array) Condition array for the password attempt throttle, in the
38     *    same format as accountCreationThrottle.
39     *  - cache: (BagOStuff) Where to store the throttle, defaults to the local cluster instance.
40     */
41    public function __construct( $params = [] ) {
42        $this->throttleSettings = array_intersect_key( $params,
43            [ 'accountCreationThrottle' => true, 'passwordAttemptThrottle' => true ] );
44        $services = MediaWikiServices::getInstance();
45        $this->cache = $params['cache'] ?? $services->getObjectCacheFactory()
46            ->getLocalClusterInstance();
47    }
48
49    protected function postInitSetup() {
50        $accountCreationThrottle = $this->config->get( MainConfigNames::AccountCreationThrottle );
51        // Handle old $wgAccountCreationThrottle format (number of attempts per 24 hours)
52        if ( !is_array( $accountCreationThrottle ) ) {
53            $accountCreationThrottle = [ [
54                'count' => $accountCreationThrottle,
55                'seconds' => 86400,
56            ] ];
57        }
58
59        // @codeCoverageIgnoreStart
60        $this->throttleSettings += [
61        // @codeCoverageIgnoreEnd
62            'accountCreationThrottle' => $accountCreationThrottle,
63            'passwordAttemptThrottle' =>
64                $this->config->get( MainConfigNames::PasswordAttemptThrottle ),
65        ];
66
67        if ( !empty( $this->throttleSettings['accountCreationThrottle'] ) ) {
68            $this->accountCreationThrottle = new Throttler(
69                $this->throttleSettings['accountCreationThrottle'], [
70                    'type' => 'acctcreate',
71                    'cache' => $this->cache,
72                ]
73            );
74        }
75        if ( !empty( $this->throttleSettings['passwordAttemptThrottle'] ) ) {
76            $this->passwordAttemptThrottle = new Throttler(
77                $this->throttleSettings['passwordAttemptThrottle'], [
78                    'type' => 'password',
79                    'cache' => $this->cache,
80                ]
81            );
82        }
83    }
84
85    /** @inheritDoc */
86    public function testForAccountCreation( $user, $creator, array $reqs ) {
87        if ( !$this->accountCreationThrottle || !$creator->isPingLimitable() ) {
88            return \StatusValue::newGood();
89        }
90
91        $ip = $this->manager->getRequest()->getIP();
92
93        if ( !$this->getHookRunner()->onExemptFromAccountCreationThrottle( $ip ) ) {
94            $this->logger->debug( __METHOD__ . ": a hook allowed account creation w/o throttle" );
95            return \StatusValue::newGood();
96        }
97
98        $result = $this->accountCreationThrottle->increase( null, $ip, __METHOD__ );
99        if ( $result ) {
100            $message = wfMessage( 'acct_creation_throttle_hit' )->params( $result['count'] )
101                ->durationParams( $result['wait'] );
102            return \StatusValue::newFatal( $message );
103        }
104
105        return \StatusValue::newGood();
106    }
107
108    /** @inheritDoc */
109    public function testForAuthentication( array $reqs ) {
110        if ( !$this->passwordAttemptThrottle ) {
111            return \StatusValue::newGood();
112        }
113
114        $ip = $this->manager->getRequest()->getIP();
115        try {
116            $username = AuthenticationRequest::getUsernameFromRequests( $reqs );
117        } catch ( \UnexpectedValueException ) {
118            $username = null;
119        }
120
121        // Get everything this username could normalize to, and throttle each one individually.
122        // If nothing uses usernames, just throttle by IP.
123        if ( $username !== null ) {
124            $usernames = $this->manager->normalizeUsername( $username );
125        } else {
126            $usernames = [ null ];
127        }
128        $result = false;
129        foreach ( $usernames as $name ) {
130            $r = $this->passwordAttemptThrottle->increase( $name, $ip, __METHOD__ );
131            if ( $r && ( !$result || $result['wait'] < $r['wait'] ) ) {
132                $result = $r;
133            }
134        }
135
136        if ( $result ) {
137            $message = wfMessage( 'login-throttled' )->durationParams( $result['wait'] );
138            return \StatusValue::newFatal( $message );
139        } else {
140            $this->manager->setAuthenticationSessionData( 'LoginThrottle',
141                [ 'users' => $usernames, 'ip' => $ip ] );
142            return \StatusValue::newGood();
143        }
144    }
145
146    /**
147     * @param null|User $user
148     * @param AuthenticationResponse $response
149     */
150    public function postAuthentication( $user, AuthenticationResponse $response ) {
151        if ( $response->status !== AuthenticationResponse::PASS ) {
152            return;
153        } elseif ( !$this->passwordAttemptThrottle ) {
154            return;
155        }
156
157        $data = $this->manager->getAuthenticationSessionData( 'LoginThrottle' );
158        if ( !$data ) {
159            // this can occur when login is happening via AuthenticationRequest::$loginRequest
160            // so testForAuthentication is skipped
161            $this->logger->info( 'throttler data not found for {user}', [ 'user' => $user->getName() ] );
162            return;
163        }
164
165        foreach ( $data['users'] as $name ) {
166            $this->passwordAttemptThrottle->clear( $name, $data['ip'] );
167        }
168    }
169}