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