Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
108 / 108
100.00% covered (success)
100.00%
11 / 11
CRAP
100.00% covered (success)
100.00%
1 / 1
UserNameUtils
100.00% covered (success)
100.00%
108 / 108
100.00% covered (success)
100.00%
11 / 11
50
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 isValid
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
13
 isUsable
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
9
 isCreatable
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
 getCanonical
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
15
 isIP
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isValidIPRange
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isLikeIPv4DashRange
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 isTemp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isTempReserved
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTempPlaceholder
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\User;
8
9use InvalidArgumentException;
10use MediaWiki\Config\ServiceOptions;
11use MediaWiki\HookContainer\HookContainer;
12use MediaWiki\HookContainer\HookRunner;
13use MediaWiki\Language\Language;
14use MediaWiki\MainConfigNames;
15use MediaWiki\Title\MalformedTitleException;
16use MediaWiki\Title\TitleParser;
17use MediaWiki\User\TempUser\TempUserConfig;
18use Psr\Log\LoggerInterface;
19use Wikimedia\IPUtils;
20use Wikimedia\Message\ITextFormatter;
21use Wikimedia\Message\MessageValue;
22
23/**
24 * UserNameUtils service
25 *
26 * @since 1.35
27 * @ingroup User
28 * @author DannyS712
29 */
30class UserNameUtils implements UserRigorOptions {
31
32    /**
33     * @internal For use by ServiceWiring
34     */
35    public const CONSTRUCTOR_OPTIONS = [
36        MainConfigNames::MaxNameChars,
37        MainConfigNames::ReservedUsernames,
38        MainConfigNames::InvalidUsernameCharacters
39    ];
40
41    /**
42     * For use by isIP() and isLikeIPv4DashRange()
43     */
44    private const IPV4_ADDRESS = '\d{1,3}\.\d{1,3}\.\d{1,3}\.(?:xxx|\d{1,3})';
45
46    // RIGOR_* constants are inherited from UserRigorOptions
47
48    // phpcs:ignore MediaWiki.Commenting.PropertyDocumentation.WrongStyle
49    private ServiceOptions $options;
50    private Language $contentLang;
51    private LoggerInterface $logger;
52    private TitleParser $titleParser;
53    private ITextFormatter $textFormatter;
54
55    /**
56     * @var string[]|false Cache for isUsable()
57     */
58    private $reservedUsernames = false;
59
60    private HookRunner $hookRunner;
61    private TempUserConfig $tempUserConfig;
62
63    /**
64     * @param ServiceOptions $options
65     * @param Language $contentLang
66     * @param LoggerInterface $logger
67     * @param TitleParser $titleParser
68     * @param ITextFormatter $textFormatter the text formatter for the current content language
69     * @param HookContainer $hookContainer
70     * @param TempUserConfig $tempUserConfig
71     */
72    public function __construct(
73        ServiceOptions $options,
74        Language $contentLang,
75        LoggerInterface $logger,
76        TitleParser $titleParser,
77        ITextFormatter $textFormatter,
78        HookContainer $hookContainer,
79        TempUserConfig $tempUserConfig
80    ) {
81        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
82        $this->options = $options;
83        $this->contentLang = $contentLang;
84        $this->logger = $logger;
85        $this->titleParser = $titleParser;
86        $this->textFormatter = $textFormatter;
87        $this->hookRunner = new HookRunner( $hookContainer );
88        $this->tempUserConfig = $tempUserConfig;
89    }
90
91    /**
92     * Is the input a valid username?
93     *
94     * Checks if the input is a valid username, we don't want an empty string,
95     * an IP address, any type of IP range, anything that contains slashes
96     * (would mess up subpages), is longer than the maximum allowed username
97     * size or begins with a lowercase letter.
98     *
99     * @param string $name Name to match
100     * @return bool
101     */
102    public function isValid( string $name ): bool {
103        if ( $name === ''
104            || $this->isIP( $name )
105            || $this->isValidIPRange( $name )
106            || $this->isLikeIPv4DashRange( $name )
107            || str_contains( $name, '/' )
108            || strlen( $name ) > $this->options->get( MainConfigNames::MaxNameChars )
109            || $name !== $this->contentLang->ucfirst( $name )
110        ) {
111            return false;
112        }
113
114        // Ensure that the name can't be misresolved as a different title,
115        // such as with extra namespace keys at the start.
116        try {
117            $title = $this->titleParser->parseTitle( $name );
118        } catch ( MalformedTitleException ) {
119            $title = null;
120        }
121
122        if ( $title === null
123            || $title->getNamespace()
124            || $name !== $title->getText()
125        ) {
126            return false;
127        }
128
129        // Check an additional list of troublemaker characters.
130        // Should these be merged into the title char list?
131        $unicodeList = '/[' .
132            '\x{0080}-\x{009f}' . # iso-8859-1 control chars
133            '\x{00a0}' . # non-breaking space
134            '\x{2000}-\x{200f}' . # various whitespace
135            '\x{2028}-\x{202f}' . # breaks and control chars
136            '\x{3000}' . # ideographic space
137            '\x{e000}-\x{f8ff}' . # private use
138            ']/u';
139        if ( preg_match( $unicodeList, $name ) ) {
140            return false;
141        }
142
143        return true;
144    }
145
146    /**
147     * Usernames which fail to pass this function will be blocked
148     * from user login and new account registrations, but may be used
149     * internally by batch processes.
150     *
151     * If an account already exists in this form, login will be blocked
152     * by a failure to pass this function.
153     *
154     * @param string $name Name to match
155     * @return bool
156     */
157    public function isUsable( string $name ): bool {
158        // Must be a valid username, obviously ;)
159        if ( !$this->isValid( $name ) ) {
160            return false;
161        }
162
163        if ( !$this->reservedUsernames ) {
164            $reservedUsernames = $this->options->get( MainConfigNames::ReservedUsernames );
165            $this->hookRunner->onUserGetReservedNames( $reservedUsernames );
166            foreach ( $reservedUsernames as &$reserved ) {
167                if ( str_starts_with( $reserved, 'msg:' ) ) {
168                    $reserved = $this->textFormatter->format(
169                        MessageValue::new( substr( $reserved, 4 ) )
170                    );
171                }
172            }
173            $this->reservedUsernames = $reservedUsernames;
174        }
175
176        // Certain names may be reserved for batch processes.
177        if ( in_array( $name, $this->reservedUsernames, true ) ) {
178            return false;
179        }
180
181        // Treat this name as not usable if it is reserved by the temp user system and either:
182        // * Temporary account creation is disabled
183        // * The name is not a temporary account
184        // This is necessary to ensure that CentralAuth auto-creation will be denied (T342475).
185        if (
186            $this->isTempReserved( $name ) &&
187            ( !$this->tempUserConfig->isEnabled() || !$this->isTemp( $name ) )
188        ) {
189            return false;
190        }
191
192        return true;
193    }
194
195    /**
196     * Usernames which fail to pass this function will be blocked
197     * from new account registrations, but may be used internally
198     * either by batch processes or by user accounts which have
199     * already been created.
200     *
201     * Additional preventions may be added here rather than in
202     * isValid() to avoid disrupting existing accounts.
203     *
204     * @param string $name String to match
205     * @return bool
206     */
207    public function isCreatable( string $name ): bool {
208        // Ensure that the username isn't longer than 235 bytes, so that
209        // (at least for the builtin skins) user javascript and css files
210        // will work. (T25080)
211        if ( strlen( $name ) > 235 ) {
212            $this->logger->debug(
213                __METHOD__ . ": '$name' uncreatable due to length"
214            );
215            return false;
216        }
217
218        $invalid = $this->options->get( MainConfigNames::InvalidUsernameCharacters );
219        // Preg yells if you try to give it an empty string
220        if ( $invalid !== '' &&
221            preg_match( '/[' . preg_quote( $invalid, '/' ) . ']/', $name )
222        ) {
223            $this->logger->debug(
224                __METHOD__ . ": '$name' uncreatable due to wgInvalidUsernameCharacters"
225            );
226            return false;
227        }
228
229        if ( $this->isTempReserved( $name ) ) {
230            $this->logger->debug(
231                __METHOD__ . ": '$name' uncreatable due to TempUserConfig"
232            );
233            return false;
234        }
235
236        return $this->isUsable( $name );
237    }
238
239    /**
240     * Given unvalidated user input, return a canonical username, or false if
241     * the username is invalid.
242     * @param string $name User input
243     * @param string $validate Type of validation to use
244     *   Use of public constants RIGOR_* is preferred
245     *   - RIGOR_NONE        No validation
246     *   - RIGOR_VALID       Valid for batch processes
247     *   - RIGOR_USABLE      Valid for batch processes and login
248     *   - RIGOR_CREATABLE   Valid for batch processes, login and account creation
249     *
250     * @throws InvalidArgumentException
251     * @return string|false
252     */
253    public function getCanonical( string $name, string $validate = self::RIGOR_VALID ) {
254        // Force usernames to capital
255        $name = $this->contentLang->ucfirst( $name );
256
257        // Reject names containing '#'; these will be cleaned up
258        // with title normalisation, but then it's too late to
259        // check elsewhere
260        if ( str_contains( $name, '#' ) ) {
261            return false;
262        }
263
264        // No need to proceed if no validation is requested, just
265        // clean up underscores and user namespace prefix (see T283915).
266        if ( $validate === self::RIGOR_NONE ) {
267            // This is only needed here because if validation is
268            // not self::RIGOR_NONE, it would be done at title parsing stage.
269            $nsPrefix = $this->contentLang->getNsText( NS_USER ) . ':';
270            if ( str_starts_with( $name, $nsPrefix ) ) {
271                $name = str_replace( $nsPrefix, '', $name );
272            }
273            $name = strtr( $name, '_', ' ' );
274            return $name;
275        }
276
277        // Clean up name according to title rules,
278        // but only when validation is requested (T14654)
279        try {
280            $title = $this->titleParser->parseTitle( $name, NS_USER );
281        } catch ( MalformedTitleException ) {
282            $title = null;
283        }
284
285        // Check for invalid titles
286        if ( $title === null
287            || $title->getNamespace() !== NS_USER
288            || $title->isExternal()
289        ) {
290            return false;
291        }
292
293        $name = $title->getText();
294
295        // RIGOR_NONE handled above
296        switch ( $validate ) {
297            case self::RIGOR_VALID:
298                return $this->isValid( $name ) ? $name : false;
299            case self::RIGOR_USABLE:
300                return $this->isUsable( $name ) ? $name : false;
301            case self::RIGOR_CREATABLE:
302                return $this->isCreatable( $name ) ? $name : false;
303            default:
304                throw new InvalidArgumentException(
305                    "Invalid parameter value for validation ($validate) in " .
306                    __METHOD__
307                );
308        }
309    }
310
311    /**
312     * Does the string match an anonymous IP address?
313     *
314     * This function exists for username validation, in order to reject
315     * usernames which are similar in form to IP addresses. Strings such
316     * as 300.300.300.300 will return true because it looks like an IP
317     * address, despite not being strictly valid.
318     *
319     * We match "\d{1,3}\.\d{1,3}\.\d{1,3}\.xxx" as an anonymous IP
320     * address because the usemod software would "cloak" anonymous IP
321     * addresses like this, if we allowed accounts like this to be created
322     * new users could get the old edits of these anonymous users.
323     *
324     * This does //not// match IP ranges. See also T239527.
325     *
326     * @param string $name Name to check
327     * @return bool
328     */
329    public function isIP( string $name ): bool {
330        $anyIPv4 = '/^' . self::IPV4_ADDRESS . '$/';
331        $validIP = IPUtils::isValid( $name );
332        return $validIP || preg_match( $anyIPv4, $name );
333    }
334
335    /**
336     * Wrapper for IPUtils::isValidRange
337     *
338     * @param string $range Range to check
339     * @return bool
340     */
341    public function isValidIPRange( string $range ): bool {
342        return IPUtils::isValidRange( $range );
343    }
344
345    /**
346     * Validates IPv4 and IPv4-like ranges in the form of 1.2.3.4-5.6.7.8,
347     * (which we'd like to avoid as a username/title pattern).
348     *
349     * @since 1.42
350     * @param string $range IPv4 dash range to check
351     * @return bool
352     */
353    public function isLikeIPv4DashRange( string $range ): bool {
354        return preg_match(
355            '/^' . self::IPV4_ADDRESS . '-' . self::IPV4_ADDRESS . '$/',
356            $range
357        );
358    }
359
360    /**
361     * Does the username indicate a temporary user?
362     *
363     * @since 1.39
364     * @param string $name
365     * @return bool
366     */
367    public function isTemp( string $name ) {
368        return $this->tempUserConfig->isTempName( $name );
369    }
370
371    /**
372     * Is the username uncreatable due to it being reserved by the temp username
373     * system? Note that unlike isTemp(), this does not imply that a user having
374     * this name is an actual temp account. This should only be used to deny
375     * account creation.
376     *
377     * @since 1.41
378     * @param string $name
379     * @return bool
380     */
381    public function isTempReserved( string $name ) {
382        return $this->tempUserConfig->isReservedName( $name );
383    }
384
385    /**
386     * Get a placeholder name for a temporary user before serial acquisition
387     *
388     * This method throws if temporary users are not enabled, and you can't check whether they are or
389     * not from this class, so you have to check from the TempUserConfig class first, and then you
390     * might as well use TempUserConfig::getPlaceholderName() directly.
391     *
392     * @since 1.39
393     * @deprecated since 1.45 Use TempUserConfig::getPlaceholderName() instead
394     * @return string
395     */
396    public function getTempPlaceholder() {
397        wfDeprecated( __METHOD__, '1.45' );
398        return $this->tempUserConfig->getPlaceholderName();
399    }
400}