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