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