Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
108 / 108 |
|
100.00% |
11 / 11 |
CRAP | |
100.00% |
1 / 1 |
| UserNameUtils | |
100.00% |
108 / 108 |
|
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% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | */ |
| 6 | |
| 7 | namespace MediaWiki\User; |
| 8 | |
| 9 | use InvalidArgumentException; |
| 10 | use MediaWiki\Config\ServiceOptions; |
| 11 | use MediaWiki\HookContainer\HookContainer; |
| 12 | use MediaWiki\HookContainer\HookRunner; |
| 13 | use MediaWiki\Language\Language; |
| 14 | use MediaWiki\MainConfigNames; |
| 15 | use MediaWiki\Title\MalformedTitleException; |
| 16 | use MediaWiki\Title\TitleParser; |
| 17 | use MediaWiki\User\TempUser\TempUserConfig; |
| 18 | use Psr\Log\LoggerInterface; |
| 19 | use Wikimedia\IPUtils; |
| 20 | use Wikimedia\Message\ITextFormatter; |
| 21 | use Wikimedia\Message\MessageValue; |
| 22 | |
| 23 | /** |
| 24 | * UserNameUtils service |
| 25 | * |
| 26 | * @since 1.35 |
| 27 | * @ingroup User |
| 28 | * @author DannyS712 |
| 29 | */ |
| 30 | class 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 | } |