Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.76% covered (success)
95.76%
113 / 118
77.27% covered (warning)
77.27%
17 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
TempUserCreator
95.76% covered (success)
95.76%
113 / 118
77.27% covered (warning)
77.27%
17 / 22
41
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 create
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
 isEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isKnown
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isAutoCreateAction
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 shouldAutoCreate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isTempName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isReservedName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPlaceholderName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMatchPattern
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMatchPatterns
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMatchCondition
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getExpireAfterDays
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNotifyBeforeExpirationDays
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 attemptAutoCreate
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 acquireName
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 getSerialProvider
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 createSerialProvider
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
3.00
 getSerialMapping
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 createSerialMapping
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
3.00
 acquireAndStashName
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getStashedName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\User\TempUser;
4
5use MediaWiki\Auth\AuthManager;
6use MediaWiki\Auth\Throttler;
7use MediaWiki\Permissions\Authority;
8use MediaWiki\Registration\ExtensionRegistry;
9use MediaWiki\Request\WebRequest;
10use MediaWiki\Session\Session;
11use MediaWiki\User\CentralId\CentralIdLookup;
12use MediaWiki\User\UserFactory;
13use MediaWiki\User\UserRigorOptions;
14use MediaWiki\Utils\MWTimestamp;
15use UnexpectedValueException;
16use Wikimedia\IPUtils;
17use Wikimedia\ObjectFactory\ObjectFactory;
18use Wikimedia\Rdbms\IExpression;
19use Wikimedia\Rdbms\IReadableDatabase;
20
21/**
22 * Service for temporary user creation. For convenience this also proxies the
23 * TempUserConfig methods.
24 *
25 * This is separate from TempUserConfig to avoid dependency loops. Special pages
26 * and actions are free to use this class, but services should take it as a
27 * constructor parameter only if necessary.
28 *
29 * @since 1.39
30 */
31class TempUserCreator implements TempUserConfig {
32    private RealTempUserConfig $config;
33    private UserFactory $userFactory;
34    private AuthManager $authManager;
35    private CentralIdLookup $centralIdLookup;
36    private Throttler $tempAccountCreationThrottler;
37    private Throttler $tempAccountNameAcquisitionThrottler;
38    private array $serialProviderConfig;
39    private array $serialMappingConfig;
40    private ObjectFactory $objectFactory;
41    private ?SerialProvider $serialProvider;
42    private ?SerialMapping $serialMapping;
43
44    /** ObjectFactory specs for the core serial providers */
45    private const SERIAL_PROVIDERS = [
46        'local' => [
47            'class' => LocalSerialProvider::class,
48            'services' => [ 'DBLoadBalancerFactory' ],
49        ]
50    ];
51
52    /** ObjectFactory specs for the core serial maps */
53    private const SERIAL_MAPPINGS = [
54        'readable-numeric' => [
55            'class' => ReadableNumericSerialMapping::class,
56        ],
57        'plain-numeric' => [
58            'class' => PlainNumericSerialMapping::class,
59        ],
60        'localized-numeric' => [
61            'class' => LocalizedNumericSerialMapping::class,
62            'services' => [ 'LanguageFactory' ],
63        ],
64        'filtered-radix' => [
65            'class' => FilteredRadixSerialMapping::class,
66        ],
67        'scramble' => [
68            'class' => ScrambleMapping::class,
69        ]
70    ];
71
72    public function __construct(
73        RealTempUserConfig $config,
74        ObjectFactory $objectFactory,
75        UserFactory $userFactory,
76        AuthManager $authManager,
77        CentralIdLookup $centralIdLookup,
78        Throttler $tempAccountCreationThrottler,
79        Throttler $tempAccountNameAcquisitionThrottler
80    ) {
81        $this->config = $config;
82        $this->objectFactory = $objectFactory;
83        $this->userFactory = $userFactory;
84        $this->authManager = $authManager;
85        $this->centralIdLookup = $centralIdLookup;
86        $this->tempAccountCreationThrottler = $tempAccountCreationThrottler;
87        $this->tempAccountNameAcquisitionThrottler = $tempAccountNameAcquisitionThrottler;
88        $this->serialProviderConfig = $config->getSerialProviderConfig();
89        $this->serialMappingConfig = $config->getSerialMappingConfig();
90    }
91
92    /**
93     * Acquire a serial number, create the corresponding user and log in.
94     *
95     * @param string|null $name Previously acquired name
96     * @param WebRequest $request Request details, used for throttling
97     * @param string[] $tags Change tags to apply to the autocreation log.
98     * @return CreateStatus
99     */
100    public function create( ?string $name, WebRequest $request, array $tags = [] ): CreateStatus {
101        $status = new CreateStatus;
102
103        // Check name acquisition rate limits first.
104        if ( $name === null ) {
105            $name = $this->acquireName( $request->getIP() );
106            if ( $name === null ) {
107                // If the $name remains null after calling ::acquireName, then
108                // we cannot generate a username and therefore cannot create a user.
109                // This could also happen if acquiring the name was rate limited
110                // In this case return a CreateStatus indicating no user was created.
111                // TODO: Create a custom message to support workflows related to T357802
112                return CreateStatus::newFatal( 'temp-user-unable-to-acquire' );
113            }
114        }
115
116        // Check temp account creation rate limits.
117        // For IPv6 IPs, the throttle should apply to the /64 range
118        $ipToThrottle = $request->getIP();
119        if ( IPUtils::isIPv6( $ipToThrottle ) ) {
120            // Normalize to the beginning of the range
121            [ $ipHex ] = IPUtils::parseRange( $ipToThrottle . '/64' );
122            $ipToThrottle = IPUtils::formatHex( $ipHex ) . '/64';
123        }
124        $result = $this->tempAccountCreationThrottler->increase(
125            null, $ipToThrottle, 'TempUserCreator' );
126        if ( $result ) {
127            $message = wfMessage( 'acct_creation_throttle_hit-temp' )->params( $result['count'] )
128                ->durationParams( $result['wait'] );
129            $status->fatal( $message );
130            return $status;
131        }
132
133        $createStatus = $this->attemptAutoCreate( $name, false, $tags );
134
135        if ( $createStatus->isOK() ) {
136            // The temporary account name didn't already exist, so now attempt to login
137            // using ::attemptAutoCreate as there isn't a public method to just login.
138            $this->attemptAutoCreate( $name, true );
139        }
140        return $createStatus;
141    }
142
143    /** @inheritDoc */
144    public function isEnabled() {
145        return $this->config->isEnabled();
146    }
147
148    /** @inheritDoc */
149    public function isKnown() {
150        return $this->config->isKnown();
151    }
152
153    /** @inheritDoc */
154    public function isAutoCreateAction( string $action ) {
155        return $this->config->isAutoCreateAction( $action );
156    }
157
158    /** @inheritDoc */
159    public function shouldAutoCreate( Authority $authority, string $action ) {
160        return $this->config->shouldAutoCreate( $authority, $action );
161    }
162
163    /** @inheritDoc */
164    public function isTempName( string $name ) {
165        return $this->config->isTempName( $name );
166    }
167
168    /** @inheritDoc */
169    public function isReservedName( string $name ) {
170        return $this->config->isReservedName( $name );
171    }
172
173    public function getPlaceholderName(): string {
174        return $this->config->getPlaceholderName();
175    }
176
177    public function getMatchPattern(): Pattern {
178        return $this->config->getMatchPattern();
179    }
180
181    public function getMatchPatterns(): array {
182        return $this->config->getMatchPatterns();
183    }
184
185    public function getMatchCondition( IReadableDatabase $db, string $field, string $op ): IExpression {
186        return $this->config->getMatchCondition( $db, $field, $op );
187    }
188
189    public function getExpireAfterDays(): ?int {
190        return $this->config->getExpireAfterDays();
191    }
192
193    public function getNotifyBeforeExpirationDays(): ?int {
194        return $this->config->getNotifyBeforeExpirationDays();
195    }
196
197    /**
198     * Attempts to auto create a temporary user using
199     * AuthManager::autoCreateUser, and optionally log them
200     * in if $login is true.
201     *
202     * @param string $name
203     * @param bool $login Whether to also log the user in to this temporary account.
204     * @param string[] $tags Change tags to apply to the autocreation log.
205     * @return CreateStatus
206     */
207    private function attemptAutoCreate( string $name, bool $login = false, array $tags = [] ): CreateStatus {
208        $createStatus = new CreateStatus;
209        // Verify the $name is usable.
210        $user = $this->userFactory->newFromName( $name, UserRigorOptions::RIGOR_USABLE );
211        if ( !$user ) {
212            $createStatus->fatal( 'internalerror_info',
213                'Unable to create user with automatically generated name' );
214            return $createStatus;
215        }
216        $status = $this->authManager->autoCreateUser(
217            $user, AuthManager::AUTOCREATE_SOURCE_TEMP, $login, true, null, $tags
218        );
219        $createStatus->merge( $status );
220        // If a userexists warning is a part of the status, then
221        // add the fatal error temp-user-unable-to-acquire.
222        if ( $createStatus->hasMessage( 'userexists' ) ) {
223            $createStatus->fatal( 'temp-user-unable-to-acquire' );
224        }
225        if ( $createStatus->isOK() ) {
226            $createStatus->value = $user;
227        }
228        return $createStatus;
229    }
230
231    /**
232     * Acquire a new username and return it. Permanently reserve the ID in
233     * the database.
234     *
235     * @param string $ip The IP address associated with this name acquisition request.
236     * @return string|null The username, or null if the auto-generated username is
237     *    already in use, or if the attempt trips the TempAccountNameAcquisitionThrottle limits.
238     */
239    private function acquireName( string $ip ): ?string {
240        if ( $this->tempAccountNameAcquisitionThrottler->increase(
241            null, $ip, 'TempUserCreator'
242        ) ) {
243            return null;
244        }
245        $year = null;
246        if ( $this->serialProviderConfig['useYear'] ?? false ) {
247            $year = MWTimestamp::getInstance()->format( 'Y' );
248        }
249        // Check if the temporary account name is already in use as the ID provided
250        // may not be properly collision safe (T353390)
251        $index = $this->getSerialProvider()->acquireIndex( (int)$year );
252        $serialId = $this->getSerialMapping()->getSerialIdForIndex( $index );
253        $username = $this->config->getGeneratorPattern()->generate( $serialId, $year );
254
255        // Because the ::acquireIndex method may not always return a unique index,
256        // make sure that the temporary account name does not already exist. This
257        // is needed because of the problems discussed in T353390.
258        // The problems discussed at that task should not require the use of a primary lookup.
259        $centralId = $this->centralIdLookup->centralIdFromName(
260            $username,
261            CentralIdLookup::AUDIENCE_RAW
262        );
263        if ( !$centralId ) {
264            // If no user exists with this name centrally, then return the $username.
265            return $username;
266        }
267        return null;
268    }
269
270    private function getSerialProvider(): SerialProvider {
271        if ( !isset( $this->serialProvider ) ) {
272            $this->serialProvider = $this->createSerialProvider();
273        }
274        return $this->serialProvider;
275    }
276
277    private function createSerialProvider(): SerialProvider {
278        $type = $this->serialProviderConfig['type'];
279        if ( isset( self::SERIAL_PROVIDERS[$type] ) ) {
280            $spec = self::SERIAL_PROVIDERS[$type];
281        } else {
282            $extensionProviders = ExtensionRegistry::getInstance()
283                ->getAttribute( 'TempUserSerialProviders' );
284            if ( isset( $extensionProviders[$type] ) ) {
285                $spec = $extensionProviders[$type];
286            } else {
287                throw new UnexpectedValueException( __CLASS__ . ": unknown serial provider \"$type\"" );
288            }
289        }
290
291        /** @noinspection PhpIncompatibleReturnTypeInspection */
292        return $this->objectFactory->createObject(
293            $spec,
294            [
295                'assertClass' => SerialProvider::class,
296                'extraArgs' => [ $this->serialProviderConfig ]
297            ]
298        );
299    }
300
301    private function getSerialMapping(): SerialMapping {
302        if ( !isset( $this->serialMapping ) ) {
303            $this->serialMapping = $this->createSerialMapping();
304        }
305        return $this->serialMapping;
306    }
307
308    private function createSerialMapping(): SerialMapping {
309        $type = $this->serialMappingConfig['type'];
310        if ( isset( self::SERIAL_MAPPINGS[$type] ) ) {
311            $spec = self::SERIAL_MAPPINGS[$type];
312        } else {
313            $extensionMappings = ExtensionRegistry::getInstance()
314                ->getAttribute( 'TempUserSerialMappings' );
315            if ( isset( $extensionMappings[$type] ) ) {
316                $spec = $extensionMappings[$type];
317            } else {
318                throw new UnexpectedValueException( __CLASS__ . ": unknown serial mapping \"$type\"" );
319            }
320        }
321        /** @noinspection PhpIncompatibleReturnTypeInspection */
322        return $this->objectFactory->createObject(
323            $spec,
324            [
325                'assertClass' => SerialMapping::class,
326                'extraArgs' => [ $this->serialMappingConfig ]
327            ]
328        );
329    }
330
331    /**
332     * Permanently acquire a username, stash it in a session, and return it.
333     * Do not create the user.
334     *
335     * If this method was called before with the same session ID, return the
336     * previously stashed username instead of acquiring a new one.
337     *
338     * @param Session $session
339     * @return string|null The username, or null if no username could be acquired
340     */
341    public function acquireAndStashName( Session $session ) {
342        $name = $session->get( 'TempUser:name' );
343        if ( $name !== null ) {
344            return $name;
345        }
346        $name = $this->acquireName( $session->getRequest()->getIP() );
347        if ( $name !== null ) {
348            $session->set( 'TempUser:name', $name );
349            $session->save();
350        }
351        return $name;
352    }
353
354    /**
355     * Return a possible acquired and stashed username in a session.
356     * Do not acquire or create the user.
357     *
358     * If this method is called with the same session ID as function acquireAndStashName(),
359     * it returns the previously stashed username.
360     *
361     * @since 1.41
362     * @param Session $session
363     * @return ?string The username, if it was already acquired
364     */
365    public function getStashedName( Session $session ): ?string {
366        return $session->get( 'TempUser:name' );
367    }
368}