Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
107 / 107 |
|
100.00% |
11 / 11 |
CRAP | |
100.00% |
1 / 1 |
UserNameUtils | |
100.00% |
107 / 107 |
|
100.00% |
11 / 11 |
50 | |
100.00% |
1 / 1 |
__construct | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
isValid | |
100.00% |
26 / 26 |
|
100.00% |
1 / 1 |
13 | |||
isUsable | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
9 | |||
isCreatable | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
5 | |||
getCanonical | |
100.00% |
27 / 27 |
|
100.00% |
1 / 1 |
15 | |||
isIP | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
isValidIPRange | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isLikeIPv4DashRange | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
isTemp | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isTempReserved | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getTempPlaceholder | |
100.00% |
1 / 1 |
|
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 | |
23 | namespace MediaWiki\User; |
24 | |
25 | use InvalidArgumentException; |
26 | use MediaWiki\Config\ServiceOptions; |
27 | use MediaWiki\HookContainer\HookContainer; |
28 | use MediaWiki\HookContainer\HookRunner; |
29 | use MediaWiki\Language\Language; |
30 | use MediaWiki\MainConfigNames; |
31 | use MediaWiki\Title\MalformedTitleException; |
32 | use MediaWiki\Title\TitleParser; |
33 | use MediaWiki\User\TempUser\TempUserConfig; |
34 | use Psr\Log\LoggerInterface; |
35 | use Wikimedia\IPUtils; |
36 | use Wikimedia\Message\ITextFormatter; |
37 | use Wikimedia\Message\MessageValue; |
38 | |
39 | /** |
40 | * UserNameUtils service |
41 | * |
42 | * @since 1.35 |
43 | */ |
44 | class 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 | } |