From d245bd25aef1cc7f17f2323ca4d557cd820cc469 Mon Sep 17 00:00:00 2001 From: Brad Jorsch Date: Sun, 22 Nov 2015 20:17:00 +0000 Subject: [PATCH] Add AuthManager MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit This implements the AuthManager class and its needed interfaces and subclasses, and integrates them into the backend portion of MediaWiki. Integration with frontend portions of MediaWiki (e.g. ApiLogin, Special:Login) is left for a followup. Bug: T91699 Bug: T71589 Bug: T111299 Co-Authored-By: Gergő Tisza Change-Id: If89d24838e326fe25fe867d02181eebcfbb0e196 --- autoload.php | 34 + docs/extension.schema.json | 19 + docs/hooks.txt | 47 +- includes/AuthPlugin.php | 5 + includes/DefaultSettings.php | 108 + includes/Preferences.php | 4 +- includes/Setup.php | 25 +- .../auth/AbstractAuthenticationProvider.php | 59 + ...tPasswordPrimaryAuthenticationProvider.php | 171 + .../AbstractPreAuthenticationProvider.php | 62 + .../AbstractPrimaryAuthenticationProvider.php | 118 + ...bstractSecondaryAuthenticationProvider.php | 86 + includes/auth/AuthManager.php | 2386 +++++++++++ includes/auth/AuthManagerAuthPlugin.php | 251 ++ ...uthPluginPrimaryAuthenticationProvider.php | 429 ++ includes/auth/AuthenticationProvider.php | 93 + includes/auth/AuthenticationRequest.php | 338 ++ includes/auth/AuthenticationResponse.php | 190 + includes/auth/ButtonAuthenticationRequest.php | 106 + ...kBlocksSecondaryAuthenticationProvider.php | 102 + .../auth/ConfirmLinkAuthenticationRequest.php | 80 + ...irmLinkSecondaryAuthenticationProvider.php | 150 + .../CreateFromLoginAuthenticationRequest.php | 62 + .../CreatedAccountAuthenticationRequest.php | 48 + .../CreationReasonAuthenticationRequest.php | 22 + .../LegacyHookPreAuthenticationProvider.php | 202 + ...lPasswordPrimaryAuthenticationProvider.php | 314 ++ .../auth/PasswordAuthenticationRequest.php | 83 + .../PasswordDomainAuthenticationRequest.php | 83 + includes/auth/PreAuthenticationProvider.php | 120 + .../auth/PrimaryAuthenticationProvider.php | 334 ++ .../auth/RememberMeAuthenticationRequest.php | 64 + ...asswordSecondaryAuthenticationProvider.php | 132 + .../auth/SecondaryAuthenticationProvider.php | 217 + ...TemporaryPasswordAuthenticationRequest.php | 105 + ...yPasswordPrimaryAuthenticationProvider.php | 454 ++ .../ThrottlePreAuthenticationProvider.php | 170 + includes/auth/Throttler.php | 210 + .../auth/UserDataAuthenticationRequest.php | 88 + .../auth/UsernameAuthenticationRequest.php | 39 + includes/registration/ExtensionProcessor.php | 2 + includes/session/SessionManager.php | 24 +- includes/specials/SpecialChangeEmail.php | 5 +- includes/specials/SpecialUserlogin.php | 30 +- includes/specials/SpecialUserrights.php | 6 +- includes/user/User.php | 465 ++- languages/i18n/en.json | 45 +- languages/i18n/qqq.json | 45 +- tests/TestsAutoLoader.php | 4 + tests/phpunit/MediaWikiTestCase.php | 1 + tests/phpunit/includes/api/ApiTestCase.php | 5 +- .../AbstractAuthenticationProviderTest.php | 37 + ...swordPrimaryAuthenticationProviderTest.php | 233 ++ .../AbstractPreAuthenticationProviderTest.php | 54 + ...tractPrimaryAuthenticationProviderTest.php | 183 + ...actSecondaryAuthenticationProviderTest.php | 93 + .../phpunit/includes/auth/AuthManagerTest.php | 3654 +++++++++++++++++ ...luginPrimaryAuthenticationProviderTest.php | 706 ++++ .../auth/AuthenticationRequestTest.php | 514 +++ .../auth/AuthenticationRequestTestCase.php | 96 + .../auth/AuthenticationResponseTest.php | 104 + .../auth/ButtonAuthenticationRequestTest.php | 64 + ...cksSecondaryAuthenticationProviderTest.php | 195 + .../ConfirmLinkAuthenticationRequestTest.php | 68 + ...inkSecondaryAuthenticationProviderTest.php | 276 ++ ...eateFromLoginAuthenticationRequestTest.php | 26 + ...reatedAccountAuthenticationRequestTest.php | 30 + ...reationReasonAuthenticationRequestTest.php | 34 + ...egacyHookPreAuthenticationProviderTest.php | 420 ++ ...swordPrimaryAuthenticationProviderTest.php | 666 +++ .../PasswordAuthenticationRequestTest.php | 138 + ...asswordDomainAuthenticationRequestTest.php | 159 + .../RememberMeAuthenticationRequestTest.php | 55 + ...ordSecondaryAuthenticationProviderTest.php | 313 ++ ...oraryPasswordAuthenticationRequestTest.php | 79 + ...swordPrimaryAuthenticationProviderTest.php | 749 ++++ .../ThrottlePreAuthenticationProviderTest.php | 235 ++ tests/phpunit/includes/auth/ThrottlerTest.php | 246 ++ .../UserDataAuthenticationRequestTest.php | 177 + .../UsernameAuthenticationRequestTest.php | 34 + .../includes/session/SessionManagerTest.php | 6 +- tests/phpunit/phpunit.php | 22 + 82 files changed, 17681 insertions(+), 227 deletions(-) create mode 100644 includes/auth/AbstractAuthenticationProvider.php create mode 100644 includes/auth/AbstractPasswordPrimaryAuthenticationProvider.php create mode 100644 includes/auth/AbstractPreAuthenticationProvider.php create mode 100644 includes/auth/AbstractPrimaryAuthenticationProvider.php create mode 100644 includes/auth/AbstractSecondaryAuthenticationProvider.php create mode 100644 includes/auth/AuthManager.php create mode 100644 includes/auth/AuthManagerAuthPlugin.php create mode 100644 includes/auth/AuthPluginPrimaryAuthenticationProvider.php create mode 100644 includes/auth/AuthenticationProvider.php create mode 100644 includes/auth/AuthenticationRequest.php create mode 100644 includes/auth/AuthenticationResponse.php create mode 100644 includes/auth/ButtonAuthenticationRequest.php create mode 100644 includes/auth/CheckBlocksSecondaryAuthenticationProvider.php create mode 100644 includes/auth/ConfirmLinkAuthenticationRequest.php create mode 100644 includes/auth/ConfirmLinkSecondaryAuthenticationProvider.php create mode 100644 includes/auth/CreateFromLoginAuthenticationRequest.php create mode 100644 includes/auth/CreatedAccountAuthenticationRequest.php create mode 100644 includes/auth/CreationReasonAuthenticationRequest.php create mode 100644 includes/auth/LegacyHookPreAuthenticationProvider.php create mode 100644 includes/auth/LocalPasswordPrimaryAuthenticationProvider.php create mode 100644 includes/auth/PasswordAuthenticationRequest.php create mode 100644 includes/auth/PasswordDomainAuthenticationRequest.php create mode 100644 includes/auth/PreAuthenticationProvider.php create mode 100644 includes/auth/PrimaryAuthenticationProvider.php create mode 100644 includes/auth/RememberMeAuthenticationRequest.php create mode 100644 includes/auth/ResetPasswordSecondaryAuthenticationProvider.php create mode 100644 includes/auth/SecondaryAuthenticationProvider.php create mode 100644 includes/auth/TemporaryPasswordAuthenticationRequest.php create mode 100644 includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php create mode 100644 includes/auth/ThrottlePreAuthenticationProvider.php create mode 100644 includes/auth/Throttler.php create mode 100644 includes/auth/UserDataAuthenticationRequest.php create mode 100644 includes/auth/UsernameAuthenticationRequest.php create mode 100644 tests/phpunit/includes/auth/AbstractAuthenticationProviderTest.php create mode 100644 tests/phpunit/includes/auth/AbstractPasswordPrimaryAuthenticationProviderTest.php create mode 100644 tests/phpunit/includes/auth/AbstractPreAuthenticationProviderTest.php create mode 100644 tests/phpunit/includes/auth/AbstractPrimaryAuthenticationProviderTest.php create mode 100644 tests/phpunit/includes/auth/AbstractSecondaryAuthenticationProviderTest.php create mode 100644 tests/phpunit/includes/auth/AuthManagerTest.php create mode 100644 tests/phpunit/includes/auth/AuthPluginPrimaryAuthenticationProviderTest.php create mode 100644 tests/phpunit/includes/auth/AuthenticationRequestTest.php create mode 100644 tests/phpunit/includes/auth/AuthenticationRequestTestCase.php create mode 100644 tests/phpunit/includes/auth/AuthenticationResponseTest.php create mode 100644 tests/phpunit/includes/auth/ButtonAuthenticationRequestTest.php create mode 100644 tests/phpunit/includes/auth/CheckBlocksSecondaryAuthenticationProviderTest.php create mode 100644 tests/phpunit/includes/auth/ConfirmLinkAuthenticationRequestTest.php create mode 100644 tests/phpunit/includes/auth/ConfirmLinkSecondaryAuthenticationProviderTest.php create mode 100644 tests/phpunit/includes/auth/CreateFromLoginAuthenticationRequestTest.php create mode 100644 tests/phpunit/includes/auth/CreatedAccountAuthenticationRequestTest.php create mode 100644 tests/phpunit/includes/auth/CreationReasonAuthenticationRequestTest.php create mode 100644 tests/phpunit/includes/auth/LegacyHookPreAuthenticationProviderTest.php create mode 100644 tests/phpunit/includes/auth/LocalPasswordPrimaryAuthenticationProviderTest.php create mode 100644 tests/phpunit/includes/auth/PasswordAuthenticationRequestTest.php create mode 100644 tests/phpunit/includes/auth/PasswordDomainAuthenticationRequestTest.php create mode 100644 tests/phpunit/includes/auth/RememberMeAuthenticationRequestTest.php create mode 100644 tests/phpunit/includes/auth/ResetPasswordSecondaryAuthenticationProviderTest.php create mode 100644 tests/phpunit/includes/auth/TemporaryPasswordAuthenticationRequestTest.php create mode 100644 tests/phpunit/includes/auth/TemporaryPasswordPrimaryAuthenticationProviderTest.php create mode 100644 tests/phpunit/includes/auth/ThrottlePreAuthenticationProviderTest.php create mode 100644 tests/phpunit/includes/auth/ThrottlerTest.php create mode 100644 tests/phpunit/includes/auth/UserDataAuthenticationRequestTest.php create mode 100644 tests/phpunit/includes/auth/UsernameAuthenticationRequestTest.php diff --git a/autoload.php b/autoload.php index eb47300125..170a86611f 100644 --- a/autoload.php +++ b/autoload.php @@ -779,6 +779,40 @@ $wgAutoloadLocalClasses = [ 'MediaWikiSite' => __DIR__ . '/includes/site/MediaWikiSite.php', 'MediaWikiTitleCodec' => __DIR__ . '/includes/title/MediaWikiTitleCodec.php', 'MediaWikiVersionFetcher' => __DIR__ . '/includes/MediaWikiVersionFetcher.php', + 'MediaWiki\\Auth\\AbstractAuthenticationProvider' => __DIR__ . '/includes/auth/AbstractAuthenticationProvider.php', + 'MediaWiki\\Auth\\AbstractPasswordPrimaryAuthenticationProvider' => __DIR__ . '/includes/auth/AbstractPasswordPrimaryAuthenticationProvider.php', + 'MediaWiki\\Auth\\AbstractPreAuthenticationProvider' => __DIR__ . '/includes/auth/AbstractPreAuthenticationProvider.php', + 'MediaWiki\\Auth\\AbstractPrimaryAuthenticationProvider' => __DIR__ . '/includes/auth/AbstractPrimaryAuthenticationProvider.php', + 'MediaWiki\\Auth\\AbstractSecondaryAuthenticationProvider' => __DIR__ . '/includes/auth/AbstractSecondaryAuthenticationProvider.php', + 'MediaWiki\\Auth\\AuthManager' => __DIR__ . '/includes/auth/AuthManager.php', + 'MediaWiki\\Auth\\AuthManagerAuthPlugin' => __DIR__ . '/includes/auth/AuthManagerAuthPlugin.php', + 'MediaWiki\\Auth\\AuthManagerAuthPluginUser' => __DIR__ . '/includes/auth/AuthManagerAuthPlugin.php', + 'MediaWiki\\Auth\\AuthPluginPrimaryAuthenticationProvider' => __DIR__ . '/includes/auth/AuthPluginPrimaryAuthenticationProvider.php', + 'MediaWiki\\Auth\\AuthenticationProvider' => __DIR__ . '/includes/auth/AuthenticationProvider.php', + 'MediaWiki\\Auth\\AuthenticationRequest' => __DIR__ . '/includes/auth/AuthenticationRequest.php', + 'MediaWiki\\Auth\\AuthenticationResponse' => __DIR__ . '/includes/auth/AuthenticationResponse.php', + 'MediaWiki\\Auth\\ButtonAuthenticationRequest' => __DIR__ . '/includes/auth/ButtonAuthenticationRequest.php', + 'MediaWiki\\Auth\\CheckBlocksSecondaryAuthenticationProvider' => __DIR__ . '/includes/auth/CheckBlocksSecondaryAuthenticationProvider.php', + 'MediaWiki\\Auth\\ConfirmLinkAuthenticationRequest' => __DIR__ . '/includes/auth/ConfirmLinkAuthenticationRequest.php', + 'MediaWiki\\Auth\\ConfirmLinkSecondaryAuthenticationProvider' => __DIR__ . '/includes/auth/ConfirmLinkSecondaryAuthenticationProvider.php', + 'MediaWiki\\Auth\\CreateFromLoginAuthenticationRequest' => __DIR__ . '/includes/auth/CreateFromLoginAuthenticationRequest.php', + 'MediaWiki\\Auth\\CreatedAccountAuthenticationRequest' => __DIR__ . '/includes/auth/CreatedAccountAuthenticationRequest.php', + 'MediaWiki\\Auth\\CreationReasonAuthenticationRequest' => __DIR__ . '/includes/auth/CreationReasonAuthenticationRequest.php', + 'MediaWiki\\Auth\\LegacyHookPreAuthenticationProvider' => __DIR__ . '/includes/auth/LegacyHookPreAuthenticationProvider.php', + 'MediaWiki\\Auth\\LocalPasswordPrimaryAuthenticationProvider' => __DIR__ . '/includes/auth/LocalPasswordPrimaryAuthenticationProvider.php', + 'MediaWiki\\Auth\\PasswordAuthenticationRequest' => __DIR__ . '/includes/auth/PasswordAuthenticationRequest.php', + 'MediaWiki\\Auth\\PasswordDomainAuthenticationRequest' => __DIR__ . '/includes/auth/PasswordDomainAuthenticationRequest.php', + 'MediaWiki\\Auth\\PreAuthenticationProvider' => __DIR__ . '/includes/auth/PreAuthenticationProvider.php', + 'MediaWiki\\Auth\\PrimaryAuthenticationProvider' => __DIR__ . '/includes/auth/PrimaryAuthenticationProvider.php', + 'MediaWiki\\Auth\\RememberMeAuthenticationRequest' => __DIR__ . '/includes/auth/RememberMeAuthenticationRequest.php', + 'MediaWiki\\Auth\\ResetPasswordSecondaryAuthenticationProvider' => __DIR__ . '/includes/auth/ResetPasswordSecondaryAuthenticationProvider.php', + 'MediaWiki\\Auth\\SecondaryAuthenticationProvider' => __DIR__ . '/includes/auth/SecondaryAuthenticationProvider.php', + 'MediaWiki\\Auth\\TemporaryPasswordAuthenticationRequest' => __DIR__ . '/includes/auth/TemporaryPasswordAuthenticationRequest.php', + 'MediaWiki\\Auth\\TemporaryPasswordPrimaryAuthenticationProvider' => __DIR__ . '/includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php', + 'MediaWiki\\Auth\\ThrottlePreAuthenticationProvider' => __DIR__ . '/includes/auth/ThrottlePreAuthenticationProvider.php', + 'MediaWiki\\Auth\\Throttler' => __DIR__ . '/includes/auth/Throttler.php', + 'MediaWiki\\Auth\\UserDataAuthenticationRequest' => __DIR__ . '/includes/auth/UserDataAuthenticationRequest.php', + 'MediaWiki\\Auth\\UsernameAuthenticationRequest' => __DIR__ . '/includes/auth/UsernameAuthenticationRequest.php', 'MediaWiki\\Languages\\Data\\Names' => __DIR__ . '/languages/data/Names.php', 'MediaWiki\\Languages\\Data\\ZhConversion' => __DIR__ . '/languages/data/ZhConversion.php', 'MediaWiki\\Linker\\LinkTarget' => __DIR__ . '/includes/linker/LinkTarget.php', diff --git a/docs/extension.schema.json b/docs/extension.schema.json index 158cb6e840..1d2b2f0568 100644 --- a/docs/extension.schema.json +++ b/docs/extension.schema.json @@ -512,6 +512,25 @@ "type": "object", "description": "Session providers" }, + "AuthManagerAutoConfig": { + "type": "object", + "description": "AuthManager auto-configuration", + "additionalProperties": false, + "properties": { + "preauth": { + "type": "object", + "description": "Pre-authentication providers" + }, + "primaryauth": { + "type": "object", + "description": "Primary authentication providers" + }, + "secondaryauth": { + "type": "object", + "description": "Secondary authentication providers" + } + } + }, "CentralIdLookupProviders": { "type": "object", "description": "Central ID lookup providers" diff --git a/docs/hooks.txt b/docs/hooks.txt index 2d5f6bc367..6786c6b703 100644 --- a/docs/hooks.txt +++ b/docs/hooks.txt @@ -238,9 +238,10 @@ MediaWiki 1.4rc1. This is a list of known events and parameters; please add to it if you're going to add events to the MediaWiki code. -'AbortAutoAccount': Return false to cancel automated local account creation, -where normally authentication against an external auth plugin would be creating -a local account. +'AbortAutoAccount': DEPRECATED! Create a PreAuthenticationProvider instead. +Return false to cancel automated local account creation, where normally +authentication against an external auth plugin would be creating a local +account. $user: the User object about to be created (read-only, incomplete) &$abortMsg: out parameter: name of error message to be displayed to user @@ -262,7 +263,8 @@ $editor: The User who made the change. $title: The Title of the page that was edited. $rc: The current RecentChange object. -'AbortLogin': Return false to cancel account login. +'AbortLogin': DEPRECATED! Create a PreAuthenticationProvider instead. +Return false to cancel account login. $user: the User object being authenticated against $password: the password being submitted, not yet checked for validity &$retval: a LoginForm class constant to return from authenticateUserData(); @@ -271,7 +273,8 @@ $password: the password being submitted, not yet checked for validity &$msg: the message identifier for abort reason (new in 1.18, not available before 1.18) -'AbortNewAccount': Return false to cancel explicit account creation. +'AbortNewAccount': DEPRECATED! Create a PreAuthenticationProvider instead. +Return false to cancel explicit account creation. $user: the User object about to be created (read-only, incomplete) &$msg: out parameter: HTML to display on abort &$status: out parameter: Status object to return, replaces the older $msg param @@ -744,13 +747,23 @@ viewing. redirect was followed. &$article: target article (object) +'AuthManagerLoginAuthenticateAudit': A login attempt either succeeded or failed +for a reason other than misconfiguration or session loss. No return data is +accepted; this hook is for auditing only. +$response: The MediaWiki\Auth\AuthenticationResponse in either a PASS or FAIL state. +$user: The User object being authenticated against, or null if authentication + failed before getting that far. +$username: A guess at the user name being authenticated, or null if we can't + even determine that. + 'AuthPluginAutoCreate': DEPRECATED! Use the 'LocalUserCreated' hook instead. Called when creating a local account for an user logged in from an external authentication method. $user: User object created locally -'AuthPluginSetup': Update or replace authentication plugin object ($wgAuth). -Gives a chance for an extension to set it programmatically to a variable class. +'AuthPluginSetup': DEPRECATED! Extensions should be updated to use AuthManager. +Update or replace authentication plugin object ($wgAuth). Gives a chance for an +extension to set it programmatically to a variable class. &$auth: the $wgAuth object, probably a stub 'AutopromoteCondition': Check autopromote condition for user. @@ -1930,10 +1943,11 @@ Special:ChangePassword. &$msg: Message object that will be shown to the user $username: Username of the user who's password was expired. -'LoginUserMigrated': Called during login to allow extensions the opportunity to -inform a user that their username doesn't exist for a specific reason, instead -of letting the login form give the generic error message that the account does -not exist. For example, when the account has been renamed or deleted. +'LoginUserMigrated': DEPRECATED! Create a PreAuthenticationProvider instead. +Called during login to allow extensions the opportunity to inform a user that +their username doesn't exist for a specific reason, instead of letting the +login form give the generic error message that the account does not exist. For +example, when the account has been renamed or deleted. $user: the User object being authenticated against. &$msg: the message identifier for abort reason, or an array to pass a message key and parameters. @@ -2571,6 +2585,17 @@ $parserOutput: ParserOutput representing the rendered version of the page &$updates: a list of DataUpdate objects, to be modified or replaced by the hook handler. +'SecuritySensitiveOperationStatus': Affect the return value from +MediaWiki\Auth\AuthManager::securitySensitiveOperationStatus(). +&$status: (string) The status to be returned. One of the AuthManager::SEC_* + constants. SEC_REAUTH will be automatically changed to SEC_FAIL if + authentication isn't possible for the current session type. +$operation: (string) The operation being checked. +$session: (MediaWiki\Session\Session) The current session. The + currently-authenticated user may be retrieved as $session->getUser(). +$timeSinceAuth: (int) The time since last authentication. PHP_INT_MAX if + the time of last auth is unknown, or -1 if authentication is not possible. + 'SelfLinkBegin': Called before a link to the current article is displayed to allow the display of the link to be customized. $nt: the Title object diff --git a/includes/AuthPlugin.php b/includes/AuthPlugin.php index add587674f..0b65593faf 100644 --- a/includes/AuthPlugin.php +++ b/includes/AuthPlugin.php @@ -32,6 +32,8 @@ * accounts authenticate externally, or use it only as a fallback; also * you can transparently create internal wiki accounts the first time * someone logs in who can be authenticated externally. + * + * @deprecated since 1.27 */ class AuthPlugin { /** @@ -322,6 +324,9 @@ class AuthPlugin { } } +/** + * @deprecated since 1.27 + */ class AuthPluginUser { function __construct( $user ) { # Override this! diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index bb1037258b..5c7eef528a 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -4388,6 +4388,114 @@ $wgPasswordPolicy = [ ], ]; +/** + * Disable AuthManager + * @since 1.27 + * @deprecated since 1.27, for use during development only + */ +$wgDisableAuthManager = true; + +/** + * Configure AuthManager + * + * All providers are constructed using ObjectFactory, see that for the general + * structure. The array may also contain a key "sort" used to order providers: + * providers are stably sorted by this value, which should be an integer + * (default is 0). + * + * Elements are: + * - preauth: Array (keys ignored) of specifications for PreAuthenticationProviders + * - primaryauth: Array (keys ignored) of specifications for PrimaryAuthenticationProviders + * - secondaryauth: Array (keys ignored) of specifications for SecondaryAuthenticationProviders + * + * @since 1.27 + * @note If this is null or empty, the value from $wgAuthManagerAutoConfig is + * used instead. Local customization should generally set this variable from + * scratch to the desired configuration. Extensions that want to + * auto-configure themselves should use $wgAuthManagerAutoConfig instead. + */ +$wgAuthManagerConfig = null; + +/** + * @see $wgAuthManagerConfig + * @since 1.27 + */ +$wgAuthManagerAutoConfig = [ + 'preauth' => [ + MediaWiki\Auth\LegacyHookPreAuthenticationProvider::class => [ + 'class' => MediaWiki\Auth\LegacyHookPreAuthenticationProvider::class, + 'sort' => 0, + ], + MediaWiki\Auth\ThrottlePreAuthenticationProvider::class => [ + 'class' => MediaWiki\Auth\ThrottlePreAuthenticationProvider::class, + 'sort' => 0, + ], + ], + 'primaryauth' => [ + // TemporaryPasswordPrimaryAuthenticationProvider should come before + // any other PasswordAuthenticationRequest-based + // PrimaryAuthenticationProvider (or at least any that might return + // FAIL rather than ABSTAIN for a wrong password), or password reset + // won't work right. Do not remove this (or change the key) or + // auto-configuration of other such providers in extensions will + // probably auto-insert themselves in the wrong place. + MediaWiki\Auth\TemporaryPasswordPrimaryAuthenticationProvider::class => [ + 'class' => MediaWiki\Auth\TemporaryPasswordPrimaryAuthenticationProvider::class, + 'args' => [ [ + // Fall through to LocalPasswordPrimaryAuthenticationProvider + 'authoritative' => false, + ] ], + 'sort' => 0, + ], + MediaWiki\Auth\LocalPasswordPrimaryAuthenticationProvider::class => [ + 'class' => MediaWiki\Auth\LocalPasswordPrimaryAuthenticationProvider::class, + 'args' => [ [ + // Last one should be authoritative, or else the user will get + // a less-than-helpful error message (something like "supplied + // authentication info not supported" rather than "wrong + // password") if it too fails. + 'authoritative' => true, + ] ], + 'sort' => 100, + ], + ], + 'secondaryauth' => [ + MediaWiki\Auth\CheckBlocksSecondaryAuthenticationProvider::class => [ + 'class' => MediaWiki\Auth\CheckBlocksSecondaryAuthenticationProvider::class, + 'sort' => 0, + ], + MediaWiki\Auth\ResetPasswordSecondaryAuthenticationProvider::class => [ + 'class' => MediaWiki\Auth\ResetPasswordSecondaryAuthenticationProvider::class, + 'sort' => 100, + ], + // Linking during login is experimental, enable at your own risk - T134952 + // MediaWiki\Auth\ConfirmLinkSecondaryAuthenticationProvider::class => [ + // 'class' => MediaWiki\Auth\ConfirmLinkSecondaryAuthenticationProvider::class, + // 'sort' => 100, + // ], + ], +]; + +/** + * If it has been this long since the last authentication, recommend + * re-authentication before security-sensitive operations (e.g. password or + * email changes). Set negative to disable. + * @since 1.27 + * @var int[] operation => time in seconds. A 'default' key must always be provided. + */ +$wgReauthenticateTime = [ + 'default' => 300, +]; + +/** + * Whether to allow security-sensitive operations when authentication is not possible. + * @since 1.27 + * @var bool[] operation => boolean. A 'default' key must always be provided. + */ +$wgAllowSecuritySensitiveOperationIfCannotReauthenticate = [ + 'default' => true, +]; + /** * For compatibility with old installations set to false * @deprecated since 1.24 will be removed in future diff --git a/includes/Preferences.php b/includes/Preferences.php index 3f56240f16..fd886f58ef 100644 --- a/includes/Preferences.php +++ b/includes/Preferences.php @@ -1410,8 +1410,6 @@ class Preferences { * @return bool|Status|string */ static function tryFormSubmit( $formData, $form ) { - global $wgAuth; - $user = $form->getModifiedUser(); $hiddenPrefs = $form->getConfig()->get( 'HiddenPrefs' ); $result = true; @@ -1462,7 +1460,7 @@ class Preferences { Hooks::run( 'PreferencesFormPreSave', [ $formData, $form, $user, &$result ] ); } - $wgAuth->updateExternalDB( $user ); + MediaWiki\Auth\AuthManager::callLegacyAuthPlugin( 'updateExternalDB', [ $user ] ); $user->saveSettings(); return $result; diff --git a/includes/Setup.php b/includes/Setup.php index 9db997adc5..e76ec2cb78 100644 --- a/includes/Setup.php +++ b/includes/Setup.php @@ -692,9 +692,22 @@ $wgContLang->initContLang(); $wgRequest->interpolateTitle(); if ( !is_object( $wgAuth ) ) { - $wgAuth = new AuthPlugin; + $wgAuth = $wgDisableAuthManager ? new AuthPlugin : new MediaWiki\Auth\AuthManagerAuthPlugin; Hooks::run( 'AuthPluginSetup', [ &$wgAuth ] ); } +if ( !$wgDisableAuthManager && + $wgAuth && !$wgAuth instanceof MediaWiki\Auth\AuthManagerAuthPlugin +) { + MediaWiki\Auth\AuthManager::singleton()->forcePrimaryAuthenticationProviders( [ + new MediaWiki\Auth\TemporaryPasswordPrimaryAuthenticationProvider( [ + 'authoritative' => false, + ] ), + new MediaWiki\Auth\AuthPluginPrimaryAuthenticationProvider( $wgAuth ), + new MediaWiki\Auth\LocalPasswordPrimaryAuthenticationProvider( [ + 'authoritative' => true, + ] ), + ], '$wgAuth is ' . get_class( $wgAuth ) ); +} // Set up the session $ps_session = Profiler::instance()->scopedProfileIn( $fname . '-session' ); @@ -820,7 +833,15 @@ if ( !defined( 'MW_NO_SESSION' ) && !$wgCommandLineMode ) { $sessionUser = MediaWiki\Session\SessionManager::getGlobalSession()->getUser(); if ( $sessionUser->getId() === 0 && User::isValidUserName( $sessionUser->getName() ) ) { $ps_autocreate = Profiler::instance()->scopedProfileIn( $fname . '-autocreate' ); - MediaWiki\Session\SessionManager::autoCreateUser( $sessionUser ); + if ( $wgDisableAuthManager ) { + MediaWiki\Session\SessionManager::autoCreateUser( $sessionUser ); + } else { + MediaWiki\Auth\AuthManager::singleton()->autoCreateUser( + $sessionUser, + MediaWiki\Auth\AuthManager::AUTOCREATE_SOURCE_SESSSION, + true + ); + } Profiler::instance()->scopedProfileOut( $ps_autocreate ); } unset( $sessionUser ); diff --git a/includes/auth/AbstractAuthenticationProvider.php b/includes/auth/AbstractAuthenticationProvider.php new file mode 100644 index 0000000000..9e38eccb3f --- /dev/null +++ b/includes/auth/AbstractAuthenticationProvider.php @@ -0,0 +1,59 @@ +logger = $logger; + } + + public function setManager( AuthManager $manager ) { + $this->manager = $manager; + } + + public function setConfig( Config $config ) { + $this->config = $config; + } + + /** + * @inheritdoc + * @note Override this if it makes sense to support more than one instance + */ + public function getUniqueId() { + return static::class; + } +} diff --git a/includes/auth/AbstractPasswordPrimaryAuthenticationProvider.php b/includes/auth/AbstractPasswordPrimaryAuthenticationProvider.php new file mode 100644 index 0000000000..900d2e5c8e --- /dev/null +++ b/includes/auth/AbstractPasswordPrimaryAuthenticationProvider.php @@ -0,0 +1,171 @@ +authoritative = !isset( $params['authoritative'] ) || (bool)$params['authoritative']; + } + + /** + * Get the PasswordFactory + * @return PasswordFactory + */ + protected function getPasswordFactory() { + if ( $this->passwordFactory === null ) { + $this->passwordFactory = new PasswordFactory(); + $this->passwordFactory->init( $this->config ); + } + return $this->passwordFactory; + } + + /** + * Get a Password object from the hash + * @param string $hash + * @return Password + */ + protected function getPassword( $hash ) { + $passwordFactory = $this->getPasswordFactory(); + try { + return $passwordFactory->newFromCiphertext( $hash ); + } catch ( \PasswordError $e ) { + $class = static::class; + $this->logger->debug( "Invalid password hash in {$class}::getPassword()" ); + return $passwordFactory->newFromCiphertext( null ); + } + } + + /** + * Return the appropriate response for failure + * @param PasswordAuthenticationRequest $req + * @return AuthenticationResponse + */ + protected function failResponse( PasswordAuthenticationRequest $req ) { + if ( $this->authoritative ) { + return AuthenticationResponse::newFail( + wfMessage( $req->password === '' ? 'wrongpasswordempty' : 'wrongpassword' ) + ); + } else { + return AuthenticationResponse::newAbstain(); + } + } + + /** + * Check that the password is valid + * + * This should be called *before* validating the password. If the result is + * not ok, login should fail immediately. + * + * @param string $username + * @param string $password + * @return Status + */ + protected function checkPasswordValidity( $username, $password ) { + return \User::newFromName( $username )->checkPasswordValidity( $password ); + } + + /** + * Check if the password should be reset + * + * This should be called after a successful login. It sets 'reset-pass' + * authentication data if necessary, see + * ResetPassSecondaryAuthenticationProvider. + * + * @param string $username + * @param Status $status From $this->checkPasswordValidity() + * @param mixed $data Passed through to $this->getPasswordResetData() + */ + protected function setPasswordResetFlag( $username, Status $status, $data = null ) { + $reset = $this->getPasswordResetData( $username, $data ); + + if ( !$reset && $this->config->get( 'InvalidPasswordReset' ) && !$status->isGood() ) { + $reset = (object)[ + 'msg' => $status->getMessage( 'resetpass-validity-soft' ), + 'hard' => false, + ]; + } + + if ( $reset ) { + $this->manager->setAuthenticationSessionData( 'reset-pass', $reset ); + } + } + + /** + * Get password reset data, if any + * + * @param string $username + * @param mixed $data + * @return object|null { 'hard' => bool, 'msg' => Message } + */ + protected function getPasswordResetData( $username, $data ) { + return null; + } + + /** + * Get expiration date for a new password, if any + * + * @param string $username + * @return string|null + */ + protected function getNewPasswordExpiry( $username ) { + $days = $this->config->get( 'PasswordExpirationDays' ); + $expires = $days ? wfTimestamp( TS_MW, time() + $days * 86400 ) : null; + + // Give extensions a chance to force an expiration + \Hooks::run( 'ResetPasswordExpiration', [ \User::newFromName( $username ), &$expires ] ); + + return $expires; + } + + public function getAuthenticationRequests( $action, array $options ) { + switch ( $action ) { + case AuthManager::ACTION_LOGIN: + case AuthManager::ACTION_REMOVE: + case AuthManager::ACTION_CREATE: + case AuthManager::ACTION_CHANGE: + return [ new PasswordAuthenticationRequest() ]; + default: + return []; + } + } +} diff --git a/includes/auth/AbstractPreAuthenticationProvider.php b/includes/auth/AbstractPreAuthenticationProvider.php new file mode 100644 index 0000000000..48a9c88c9d --- /dev/null +++ b/includes/auth/AbstractPreAuthenticationProvider.php @@ -0,0 +1,62 @@ +testUserExists( $username ); + } + + /** + * @inheritdoc + * @note Reimplement this if you do anything other than + * User::getCanonicalName( $req->username ) to determine the user being + * authenticated. + */ + public function providerNormalizeUsername( $username ) { + $name = User::getCanonicalName( $username ); + return $name === false ? null : $name; + } + + /** + * @inheritdoc + * @note Reimplement this if self::getAuthenticationRequests( AuthManager::ACTION_REMOVE ) + * doesn't return requests that will revoke all access for the user. + */ + public function providerRevokeAccessForUser( $username ) { + $reqs = $this->getAuthenticationRequests( + AuthManager::ACTION_REMOVE, [ 'username' => $username ] + ); + foreach ( $reqs as $req ) { + $req->username = $username; + $req->action = AuthManager::ACTION_REMOVE; + $this->providerChangeAuthenticationData( $req ); + } + } + + public function providerAllowsPropertyChange( $property ) { + return true; + } + + public function testForAccountCreation( $user, $creator, array $reqs ) { + return \StatusValue::newGood(); + } + + public function continuePrimaryAccountCreation( $user, $creator, array $reqs ) { + throw new \BadMethodCallException( __METHOD__ . ' is not implemented.' ); + } + + public function finishAccountCreation( $user, $creator, AuthenticationResponse $response ) { + return null; + } + + public function postAccountCreation( $user, $creator, AuthenticationResponse $response ) { + } + + public function testUserForCreation( $user, $autocreate ) { + return \StatusValue::newGood(); + } + + public function autoCreatedAccount( $user, $source ) { + } + + public function beginPrimaryAccountLink( $user, array $reqs ) { + if ( $this->accountCreationType() === self::TYPE_LINK ) { + throw new \BadMethodCallException( __METHOD__ . ' is not implemented.' ); + } else { + throw new \BadMethodCallException( + __METHOD__ . ' should not be called on a non-link provider.' + ); + } + } + + public function continuePrimaryAccountLink( $user, array $reqs ) { + throw new \BadMethodCallException( __METHOD__ . ' is not implemented.' ); + } + + public function postAccountLink( $user, AuthenticationResponse $response ) { + } + +} diff --git a/includes/auth/AbstractSecondaryAuthenticationProvider.php b/includes/auth/AbstractSecondaryAuthenticationProvider.php new file mode 100644 index 0000000000..89fd6f92a5 --- /dev/null +++ b/includes/auth/AbstractSecondaryAuthenticationProvider.php @@ -0,0 +1,86 @@ +getAuthenticationRequests( + AuthManager::ACTION_REMOVE, [ 'username' => $username ] + ); + foreach ( $reqs as $req ) { + $req->username = $username; + $this->providerChangeAuthenticationData( $req ); + } + } + + public function providerAllowsAuthenticationDataChange( + AuthenticationRequest $req, $checkData = true + ) { + return \StatusValue::newGood( 'ignored' ); + } + + public function providerChangeAuthenticationData( AuthenticationRequest $req ) { + } + + public function testForAccountCreation( $user, $creator, array $reqs ) { + return \StatusValue::newGood(); + } + + public function continueSecondaryAccountCreation( $user, $creator, array $reqs ) { + throw new \BadMethodCallException( __METHOD__ . ' is not implemented.' ); + } + + public function postAccountCreation( $user, $creator, AuthenticationResponse $response ) { + } + + public function testUserForCreation( $user, $autocreate ) { + return \StatusValue::newGood(); + } + + public function autoCreatedAccount( $user, $source ) { + } +} diff --git a/includes/auth/AuthManager.php b/includes/auth/AuthManager.php new file mode 100644 index 0000000000..efee53c6dc --- /dev/null +++ b/includes/auth/AuthManager.php @@ -0,0 +1,2386 @@ +getRequest(), + \ConfigFactory::getDefaultInstance()->makeConfig( 'main' ) + ); + } + return self::$instance; + } + + /** + * @param WebRequest $request + * @param Config $config + */ + public function __construct( WebRequest $request, Config $config ) { + $this->request = $request; + $this->config = $config; + $this->setLogger( \MediaWiki\Logger\LoggerFactory::getInstance( 'authentication' ) ); + } + + /** + * @param LoggerInterface $logger + */ + public function setLogger( LoggerInterface $logger ) { + $this->logger = $logger; + } + + /** + * @return WebRequest + */ + public function getRequest() { + return $this->request; + } + + /** + * Force certain PrimaryAuthenticationProviders + * @deprecated For backwards compatibility only + * @param PrimaryAuthenticationProvider[] $providers + * @param string $why + */ + public function forcePrimaryAuthenticationProviders( array $providers, $why ) { + $this->logger->warning( "Overriding AuthManager primary authn because $why" ); + + if ( $this->primaryAuthenticationProviders !== null ) { + $this->logger->warning( + 'PrimaryAuthenticationProviders have already been accessed! I hope nothing breaks.' + ); + + $this->allAuthenticationProviders = array_diff_key( + $this->allAuthenticationProviders, + $this->primaryAuthenticationProviders + ); + $session = $this->request->getSession(); + $session->remove( 'AuthManager::authnState' ); + $session->remove( 'AuthManager::accountCreationState' ); + $session->remove( 'AuthManager::accountLinkState' ); + $this->createdAccountAuthenticationRequests = []; + } + + $this->primaryAuthenticationProviders = []; + foreach ( $providers as $provider ) { + if ( !$provider instanceof PrimaryAuthenticationProvider ) { + throw new \RuntimeException( + 'Expected instance of MediaWiki\\Auth\\PrimaryAuthenticationProvider, got ' . + get_class( $provider ) + ); + } + $provider->setLogger( $this->logger ); + $provider->setManager( $this ); + $provider->setConfig( $this->config ); + $id = $provider->getUniqueId(); + if ( isset( $this->allAuthenticationProviders[$id] ) ) { + throw new \RuntimeException( + "Duplicate specifications for id $id (classes " . + get_class( $provider ) . ' and ' . + get_class( $this->allAuthenticationProviders[$id] ) . ')' + ); + } + $this->allAuthenticationProviders[$id] = $provider; + $this->primaryAuthenticationProviders[$id] = $provider; + } + } + + /** + * Call a legacy AuthPlugin method, if necessary + * @codeCoverageIgnore + * @deprecated For backwards compatibility only, should be avoided in new code + * @param string $method AuthPlugin method to call + * @param array $params Parameters to pass + * @param mixed $return Return value if AuthPlugin wasn't called + * @return mixed Return value from the AuthPlugin method, or $return + */ + public static function callLegacyAuthPlugin( $method, array $params, $return = null ) { + global $wgAuth; + + if ( $wgAuth && !$wgAuth instanceof AuthManagerAuthPlugin ) { + return call_user_func_array( [ $wgAuth, $method ], $params ); + } else { + return $return; + } + } + + /** + * @name Authentication + * @{ + */ + + /** + * Indicate whether user authentication is possible + * + * It may not be if the session is provided by something like OAuth + * for which each individual request includes authentication data. + * + * @return bool + */ + public function canAuthenticateNow() { + return $this->request->getSession()->canSetUser(); + } + + /** + * Start an authentication flow + * @param AuthenticationRequest[] $reqs + * @param string $returnToUrl Url that REDIRECT responses should eventually + * return to. + * @return AuthenticationResponse See self::continueAuthentication() + */ + public function beginAuthentication( array $reqs, $returnToUrl ) { + $session = $this->request->getSession(); + if ( !$session->canSetUser() ) { + // Caller should have called canAuthenticateNow() + $session->remove( 'AuthManager::authnState' ); + throw new \LogicException( 'Authentication is not possible now' ); + } + + $guessUserName = null; + foreach ( $reqs as $req ) { + $req->returnToUrl = $returnToUrl; + // @codeCoverageIgnoreStart + if ( $req->username !== null && $req->username !== '' ) { + if ( $guessUserName === null ) { + $guessUserName = $req->username; + } elseif ( $guessUserName !== $req->username ) { + $guessUserName = null; + break; + } + } + // @codeCoverageIgnoreEnd + } + + // Check for special-case login of a just-created account + $req = AuthenticationRequest::getRequestByClass( + $reqs, CreatedAccountAuthenticationRequest::class + ); + if ( $req ) { + if ( !in_array( $req, $this->createdAccountAuthenticationRequests, true ) ) { + throw new \LogicException( + 'CreatedAccountAuthenticationRequests are only valid on ' . + 'the same AuthManager that created the account' + ); + } + + $user = User::newFromName( $req->username ); + // @codeCoverageIgnoreStart + if ( !$user ) { + throw new \UnexpectedValueException( + "CreatedAccountAuthenticationRequest had invalid username \"{$req->username}\"" + ); + } elseif ( $user->getId() != $req->id ) { + throw new \UnexpectedValueException( + "ID for \"{$req->username}\" was {$user->getId()}, expected {$req->id}" + ); + } + // @codeCoverageIgnoreEnd + + $this->logger->info( 'Logging in {user} after account creation', [ + 'user' => $user->getName(), + ] ); + $ret = AuthenticationResponse::newPass( $user->getName() ); + $this->setSessionDataForUser( $user ); + $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $ret ] ); + $session->remove( 'AuthManager::authnState' ); + \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $ret, $user, $user->getName() ] ); + return $ret; + } + + $this->removeAuthenticationSessionData( null ); + + foreach ( $this->getPreAuthenticationProviders() as $provider ) { + $status = $provider->testForAuthentication( $reqs ); + if ( !$status->isGood() ) { + $this->logger->debug( 'Login failed in pre-authentication by ' . $provider->getUniqueId() ); + $ret = AuthenticationResponse::newFail( + Status::wrap( $status )->getMessage() + ); + $this->callMethodOnProviders( 7, 'postAuthentication', + [ User::newFromName( $guessUserName ) ?: null, $ret ] + ); + \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $ret, null, $guessUserName ] ); + return $ret; + } + } + + $state = [ + 'reqs' => $reqs, + 'returnToUrl' => $returnToUrl, + 'guessUserName' => $guessUserName, + 'primary' => null, + 'primaryResponse' => null, + 'secondary' => [], + 'maybeLink' => [], + 'continueRequests' => [], + ]; + + // Preserve state from a previous failed login + $req = AuthenticationRequest::getRequestByClass( + $reqs, CreateFromLoginAuthenticationRequest::class + ); + if ( $req ) { + $state['maybeLink'] = $req->maybeLink; + } + + $session = $this->request->getSession(); + $session->setSecret( 'AuthManager::authnState', $state ); + $session->persist(); + + return $this->continueAuthentication( $reqs ); + } + + /** + * Continue an authentication flow + * + * Return values are interpreted as follows: + * - status FAIL: Authentication failed. If $response->createRequest is + * set, that may be passed to self::beginAuthentication() or to + * self::beginAccountCreation() (after adding a username, if necessary) + * to preserve state. + * - status REDIRECT: The client should be redirected to the contained URL, + * new AuthenticationRequests should be made (if any), then + * AuthManager::continueAuthentication() should be called. + * - status UI: The client should be presented with a user interface for + * the fields in the specified AuthenticationRequests, then new + * AuthenticationRequests should be made, then + * AuthManager::continueAuthentication() should be called. + * - status RESTART: The user logged in successfully with a third-party + * service, but the third-party credentials aren't attached to any local + * account. This could be treated as a UI or a FAIL. + * - status PASS: Authentication was successful. + * + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse + */ + public function continueAuthentication( array $reqs ) { + $session = $this->request->getSession(); + try { + if ( !$session->canSetUser() ) { + // Caller should have called canAuthenticateNow() + // @codeCoverageIgnoreStart + throw new \LogicException( 'Authentication is not possible now' ); + // @codeCoverageIgnoreEnd + } + + $state = $session->getSecret( 'AuthManager::authnState' ); + if ( !is_array( $state ) ) { + return AuthenticationResponse::newFail( + wfMessage( 'authmanager-authn-not-in-progress' ) + ); + } + $state['continueRequests'] = []; + + $guessUserName = $state['guessUserName']; + + foreach ( $reqs as $req ) { + $req->returnToUrl = $state['returnToUrl']; + } + + // Step 1: Choose an primary authentication provider, and call it until it succeeds. + + if ( $state['primary'] === null ) { + // We haven't picked a PrimaryAuthenticationProvider yet + // @codeCoverageIgnoreStart + $guessUserName = null; + foreach ( $reqs as $req ) { + if ( $req->username !== null && $req->username !== '' ) { + if ( $guessUserName === null ) { + $guessUserName = $req->username; + } elseif ( $guessUserName !== $req->username ) { + $guessUserName = null; + break; + } + } + } + $state['guessUserName'] = $guessUserName; + // @codeCoverageIgnoreEnd + $state['reqs'] = $reqs; + + foreach ( $this->getPrimaryAuthenticationProviders() as $id => $provider ) { + $res = $provider->beginPrimaryAuthentication( $reqs ); + switch ( $res->status ) { + case AuthenticationResponse::PASS; + $state['primary'] = $id; + $state['primaryResponse'] = $res; + $this->logger->debug( "Primary login with $id succeeded" ); + break 2; + case AuthenticationResponse::FAIL; + $this->logger->debug( "Login failed in primary authentication by $id" ); + if ( $res->createRequest || $state['maybeLink'] ) { + $res->createRequest = new CreateFromLoginAuthenticationRequest( + $res->createRequest, $state['maybeLink'] + ); + } + $this->callMethodOnProviders( 7, 'postAuthentication', + [ User::newFromName( $guessUserName ) ?: null, $res ] + ); + $session->remove( 'AuthManager::authnState' ); + \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $res, null, $guessUserName ] ); + return $res; + case AuthenticationResponse::ABSTAIN; + // Continue loop + break; + case AuthenticationResponse::REDIRECT; + case AuthenticationResponse::UI; + $this->logger->debug( "Primary login with $id returned $res->status" ); + $state['primary'] = $id; + $state['continueRequests'] = $res->neededRequests; + $session->setSecret( 'AuthManager::authnState', $state ); + return $res; + + // @codeCoverageIgnoreStart + default: + throw new \DomainException( + get_class( $provider ) . "::beginPrimaryAuthentication() returned $res->status" + ); + // @codeCoverageIgnoreEnd + } + } + if ( $state['primary'] === null ) { + $this->logger->debug( 'Login failed in primary authentication because no provider accepted' ); + $ret = AuthenticationResponse::newFail( + wfMessage( 'authmanager-authn-no-primary' ) + ); + $this->callMethodOnProviders( 7, 'postAuthentication', + [ User::newFromName( $guessUserName ) ?: null, $ret ] + ); + $session->remove( 'AuthManager::authnState' ); + return $ret; + } + } elseif ( $state['primaryResponse'] === null ) { + $provider = $this->getAuthenticationProvider( $state['primary'] ); + if ( !$provider instanceof PrimaryAuthenticationProvider ) { + // Configuration changed? Force them to start over. + // @codeCoverageIgnoreStart + $ret = AuthenticationResponse::newFail( + wfMessage( 'authmanager-authn-not-in-progress' ) + ); + $this->callMethodOnProviders( 7, 'postAuthentication', + [ User::newFromName( $guessUserName ) ?: null, $ret ] + ); + $session->remove( 'AuthManager::authnState' ); + return $ret; + // @codeCoverageIgnoreEnd + } + $id = $provider->getUniqueId(); + $res = $provider->continuePrimaryAuthentication( $reqs ); + switch ( $res->status ) { + case AuthenticationResponse::PASS; + $state['primaryResponse'] = $res; + $this->logger->debug( "Primary login with $id succeeded" ); + break; + case AuthenticationResponse::FAIL; + $this->logger->debug( "Login failed in primary authentication by $id" ); + if ( $res->createRequest || $state['maybeLink'] ) { + $res->createRequest = new CreateFromLoginAuthenticationRequest( + $res->createRequest, $state['maybeLink'] + ); + } + $this->callMethodOnProviders( 7, 'postAuthentication', + [ User::newFromName( $guessUserName ) ?: null, $res ] + ); + $session->remove( 'AuthManager::authnState' ); + \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $res, null, $guessUserName ] ); + return $res; + case AuthenticationResponse::REDIRECT; + case AuthenticationResponse::UI; + $this->logger->debug( "Primary login with $id returned $res->status" ); + $state['continueRequests'] = $res->neededRequests; + $session->setSecret( 'AuthManager::authnState', $state ); + return $res; + default: + throw new \DomainException( + get_class( $provider ) . "::continuePrimaryAuthentication() returned $res->status" + ); + } + } + + $res = $state['primaryResponse']; + if ( $res->username === null ) { + $provider = $this->getAuthenticationProvider( $state['primary'] ); + if ( !$provider instanceof PrimaryAuthenticationProvider ) { + // Configuration changed? Force them to start over. + // @codeCoverageIgnoreStart + $ret = AuthenticationResponse::newFail( + wfMessage( 'authmanager-authn-not-in-progress' ) + ); + $this->callMethodOnProviders( 7, 'postAuthentication', + [ User::newFromName( $guessUserName ) ?: null, $ret ] + ); + $session->remove( 'AuthManager::authnState' ); + return $ret; + // @codeCoverageIgnoreEnd + } + + if ( $provider->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK && + $res->linkRequest && + // don't confuse the user with an incorrect message if linking is disabled + $this->getAuthenticationProvider( ConfirmLinkSecondaryAuthenticationProvider::class ) + ) { + $state['maybeLink'][$res->linkRequest->getUniqueId()] = $res->linkRequest; + $msg = 'authmanager-authn-no-local-user-link'; + } else { + $msg = 'authmanager-authn-no-local-user'; + } + $this->logger->debug( + "Primary login with {$provider->getUniqueId()} succeeded, but returned no user" + ); + $ret = AuthenticationResponse::newRestart( wfMessage( $msg ) ); + $ret->neededRequests = $this->getAuthenticationRequestsInternal( + self::ACTION_LOGIN, + [], + $this->getPrimaryAuthenticationProviders() + $this->getSecondaryAuthenticationProviders() + ); + if ( $res->createRequest || $state['maybeLink'] ) { + $ret->createRequest = new CreateFromLoginAuthenticationRequest( + $res->createRequest, $state['maybeLink'] + ); + $ret->neededRequests[] = $ret->createRequest; + } + $session->setSecret( 'AuthManager::authnState', [ + 'reqs' => [], // Will be filled in later + 'primary' => null, + 'primaryResponse' => null, + 'secondary' => [], + 'continueRequests' => $ret->neededRequests, + ] + $state ); + return $ret; + } + + // Step 2: Primary authentication succeeded, create the User object + // (and add the user locally if necessary) + + $user = User::newFromName( $res->username, 'usable' ); + if ( !$user ) { + throw new \DomainException( + get_class( $provider ) . " returned an invalid username: {$res->username}" + ); + } + if ( $user->getId() === 0 ) { + // User doesn't exist locally. Create it. + $this->logger->info( 'Auto-creating {user} on login', [ + 'user' => $user->getName(), + ] ); + $status = $this->autoCreateUser( $user, $state['primary'], false ); + if ( !$status->isGood() ) { + $ret = AuthenticationResponse::newFail( + Status::wrap( $status )->getMessage( 'authmanager-authn-autocreate-failed' ) + ); + $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $ret ] ); + $session->remove( 'AuthManager::authnState' ); + \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $ret, $user, $user->getName() ] ); + return $ret; + } + } + + // Step 3: Iterate over all the secondary authentication providers. + + $beginReqs = $state['reqs']; + + foreach ( $this->getSecondaryAuthenticationProviders() as $id => $provider ) { + if ( !isset( $state['secondary'][$id] ) ) { + // This provider isn't started yet, so we pass it the set + // of reqs from beginAuthentication instead of whatever + // might have been used by a previous provider in line. + $func = 'beginSecondaryAuthentication'; + $res = $provider->beginSecondaryAuthentication( $user, $beginReqs ); + } elseif ( !$state['secondary'][$id] ) { + $func = 'continueSecondaryAuthentication'; + $res = $provider->continueSecondaryAuthentication( $user, $reqs ); + } else { + continue; + } + switch ( $res->status ) { + case AuthenticationResponse::PASS; + $this->logger->debug( "Secondary login with $id succeeded" ); + // fall through + case AuthenticationResponse::ABSTAIN; + $state['secondary'][$id] = true; + break; + case AuthenticationResponse::FAIL; + $this->logger->debug( "Login failed in secondary authentication by $id" ); + $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $res ] ); + $session->remove( 'AuthManager::authnState' ); + \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $res, $user, $user->getName() ] ); + return $res; + case AuthenticationResponse::REDIRECT; + case AuthenticationResponse::UI; + $this->logger->debug( "Secondary login with $id returned " . $res->status ); + $state['secondary'][$id] = false; + $state['continueRequests'] = $res->neededRequests; + $session->setSecret( 'AuthManager::authnState', $state ); + return $res; + + // @codeCoverageIgnoreStart + default: + throw new \DomainException( + get_class( $provider ) . "::{$func}() returned $res->status" + ); + // @codeCoverageIgnoreEnd + } + } + + // Step 4: Authentication complete! Set the user in the session and + // clean up. + + $this->logger->info( 'Login for {user} succeeded', [ + 'user' => $user->getName(), + ] ); + $req = AuthenticationRequest::getRequestByClass( + $beginReqs, RememberMeAuthenticationRequest::class + ); + $this->setSessionDataForUser( $user, $req && $req->rememberMe ); + $ret = AuthenticationResponse::newPass( $user->getName() ); + $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $ret ] ); + $session->remove( 'AuthManager::authnState' ); + $this->removeAuthenticationSessionData( null ); + \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $ret, $user, $user->getName() ] ); + return $ret; + } catch ( \Exception $ex ) { + $session->remove( 'AuthManager::authnState' ); + throw $ex; + } + } + + /** + * Whether security-sensitive operations should proceed. + * + * A "security-sensitive operation" is something like a password or email + * change, that would normally have a "reenter your password to confirm" + * box if we only supported password-based authentication. + * + * @param string $operation Operation being checked. This should be a + * message-key-like string such as 'change-password' or 'change-email'. + * @return string One of the SEC_* constants. + */ + public function securitySensitiveOperationStatus( $operation ) { + $status = self::SEC_OK; + + $this->logger->debug( __METHOD__ . ": Checking $operation" ); + + $session = $this->request->getSession(); + $aId = $session->getUser()->getId(); + if ( $aId === 0 ) { + // User isn't authenticated. DWIM? + $status = $this->canAuthenticateNow() ? self::SEC_REAUTH : self::SEC_FAIL; + $this->logger->info( __METHOD__ . ": Not logged in! $operation is $status" ); + return $status; + } + + if ( $session->canSetUser() ) { + $id = $session->get( 'AuthManager:lastAuthId' ); + $last = $session->get( 'AuthManager:lastAuthTimestamp' ); + if ( $id !== $aId || $last === null ) { + $timeSinceLogin = PHP_INT_MAX; // Forever ago + } else { + $timeSinceLogin = max( 0, time() - $last ); + } + + $thresholds = $this->config->get( 'ReauthenticateTime' ); + if ( isset( $thresholds[$operation] ) ) { + $threshold = $thresholds[$operation]; + } elseif ( isset( $thresholds['default'] ) ) { + $threshold = $thresholds['default']; + } else { + throw new \UnexpectedValueException( '$wgReauthenticateTime lacks a default' ); + } + + if ( $threshold >= 0 && $timeSinceLogin > $threshold ) { + $status = self::SEC_REAUTH; + } + } else { + $timeSinceLogin = -1; + + $pass = $this->config->get( 'AllowSecuritySensitiveOperationIfCannotReauthenticate' ); + if ( isset( $pass[$operation] ) ) { + $status = $pass[$operation] ? self::SEC_OK : self::SEC_FAIL; + } elseif ( isset( $pass['default'] ) ) { + $status = $pass['default'] ? self::SEC_OK : self::SEC_FAIL; + } else { + throw new \UnexpectedValueException( + '$wgAllowSecuritySensitiveOperationIfCannotReauthenticate lacks a default' + ); + } + } + + \Hooks::run( 'SecuritySensitiveOperationStatus', [ + &$status, $operation, $session, $timeSinceLogin + ] ); + + // If authentication is not possible, downgrade from "REAUTH" to "FAIL". + if ( !$this->canAuthenticateNow() && $status === self::SEC_REAUTH ) { + $status = self::SEC_FAIL; + } + + $this->logger->info( __METHOD__ . ": $operation is $status" ); + + return $status; + } + + /** + * Determine whether a username can authenticate + * + * @param string $username + * @return bool + */ + public function userCanAuthenticate( $username ) { + foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) { + if ( $provider->testUserCanAuthenticate( $username ) ) { + return true; + } + } + return false; + } + + /** + * Provide normalized versions of the username for security checks + * + * Since different providers can normalize the input in different ways, + * this returns an array of all the different ways the name might be + * normalized for authentication. + * + * The returned strings should not be revealed to the user, as that might + * leak private information (e.g. an email address might be normalized to a + * username). + * + * @param string $username + * @return string[] + */ + public function normalizeUsername( $username ) { + $ret = []; + foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) { + $normalized = $provider->providerNormalizeUsername( $username ); + if ( $normalized !== null ) { + $ret[$normalized] = true; + } + } + return array_keys( $ret ); + } + + /**@}*/ + + /** + * @name Authentication data changing + * @{ + */ + + /** + * Revoke any authentication credentials for a user + * + * After this, the user should no longer be able to log in. + * + * @param string $username + */ + public function revokeAccessForUser( $username ) { + $this->logger->info( 'Revoking access for {user}', [ + 'user' => $username, + ] ); + $this->callMethodOnProviders( 6, 'providerRevokeAccessForUser', [ $username ] ); + } + + /** + * Validate a change of authentication data (e.g. passwords) + * @param AuthenticationRequest $req + * @param bool $checkData If false, $req hasn't been loaded from the + * submission so checks on user-submitted fields should be skipped. $req->username is + * considered user-submitted for this purpose, even if it cannot be changed via + * $req->loadFromSubmission. + * @return Status + */ + public function allowsAuthenticationDataChange( AuthenticationRequest $req, $checkData = true ) { + $any = false; + $providers = $this->getPrimaryAuthenticationProviders() + + $this->getSecondaryAuthenticationProviders(); + foreach ( $providers as $provider ) { + $status = $provider->providerAllowsAuthenticationDataChange( $req, $checkData ); + if ( !$status->isGood() ) { + return Status::wrap( $status ); + } + $any = $any || $status->value !== 'ignored'; + } + if ( !$any ) { + $status = Status::newGood( 'ignored' ); + $status->warning( 'authmanager-change-not-supported' ); + return $status; + } + return Status::newGood(); + } + + /** + * Change authentication data (e.g. passwords) + * + * If $req was returned for AuthManager::ACTION_CHANGE, using $req should + * result in a successful login in the future. + * + * If $req was returned for AuthManager::ACTION_REMOVE, using $req should + * no longer result in a successful login. + * + * @param AuthenticationRequest $req + */ + public function changeAuthenticationData( AuthenticationRequest $req ) { + $this->logger->info( 'Changing authentication data for {user} class {what}', [ + 'user' => is_string( $req->username ) ? $req->username : '', + 'what' => get_class( $req ), + ] ); + + $this->callMethodOnProviders( 6, 'providerChangeAuthenticationData', [ $req ] ); + + // When the main account's authentication data is changed, invalidate + // all BotPasswords too. + \BotPassword::invalidateAllPasswordsForUser( $req->username ); + } + + /**@}*/ + + /** + * @name Account creation + * @{ + */ + + /** + * Determine whether accounts can be created + * @return bool + */ + public function canCreateAccounts() { + foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) { + switch ( $provider->accountCreationType() ) { + case PrimaryAuthenticationProvider::TYPE_CREATE: + case PrimaryAuthenticationProvider::TYPE_LINK: + return true; + } + } + return false; + } + + /** + * Determine whether a particular account can be created + * @param string $username + * @param int $flags Bitfield of User:READ_* constants + * @return Status + */ + public function canCreateAccount( $username, $flags = User::READ_NORMAL ) { + if ( !$this->canCreateAccounts() ) { + return Status::newFatal( 'authmanager-create-disabled' ); + } + + if ( $this->userExists( $username, $flags ) ) { + return Status::newFatal( 'userexists' ); + } + + $user = User::newFromName( $username, 'creatable' ); + if ( !is_object( $user ) ) { + return Status::newFatal( 'noname' ); + } else { + $user->load( $flags ); // Explicitly load with $flags, auto-loading always uses READ_NORMAL + if ( $user->getId() !== 0 ) { + return Status::newFatal( 'userexists' ); + } + } + + // Denied by providers? + $providers = $this->getPreAuthenticationProviders() + + $this->getPrimaryAuthenticationProviders() + + $this->getSecondaryAuthenticationProviders(); + foreach ( $providers as $provider ) { + $status = $provider->testUserForCreation( $user, false ); + if ( !$status->isGood() ) { + return Status::wrap( $status ); + } + } + + return Status::newGood(); + } + + /** + * Basic permissions checks on whether a user can create accounts + * @param User $creator User doing the account creation + * @return Status + */ + public function checkAccountCreatePermissions( User $creator ) { + // Wiki is read-only? + if ( wfReadOnly() ) { + return Status::newFatal( 'readonlytext', wfReadOnlyReason() ); + } + + // This is awful, this permission check really shouldn't go through Title. + $permErrors = \SpecialPage::getTitleFor( 'CreateAccount' ) + ->getUserPermissionsErrors( 'createaccount', $creator, 'secure' ); + if ( $permErrors ) { + $status = Status::newGood(); + foreach ( $permErrors as $args ) { + call_user_func_array( [ $status, 'fatal' ], $args ); + } + return $status; + } + + $block = $creator->isBlockedFromCreateAccount(); + if ( $block ) { + $errorParams = [ + $block->getTarget(), + $block->mReason ?: wfMessage( 'blockednoreason' )->text(), + $block->getByName() + ]; + + if ( $block->getType() === \Block::TYPE_RANGE ) { + $errorMessage = 'cantcreateaccount-range-text'; + $errorParams[] = $this->getRequest()->getIP(); + } else { + $errorMessage = 'cantcreateaccount-text'; + } + + return Status::newFatal( wfMessage( $errorMessage, $errorParams ) ); + } + + $ip = $this->getRequest()->getIP(); + if ( $creator->isDnsBlacklisted( $ip, true /* check $wgProxyWhitelist */ ) ) { + return Status::newFatal( 'sorbs_create_account_reason' ); + } + + return Status::newGood(); + } + + /** + * Start an account creation flow + * @param User $creator User doing the account creation + * @param AuthenticationRequest[] $reqs + * @param string $returnToUrl Url that REDIRECT responses should eventually + * return to. + * @return AuthenticationResponse + */ + public function beginAccountCreation( User $creator, array $reqs, $returnToUrl ) { + $session = $this->request->getSession(); + if ( !$this->canCreateAccounts() ) { + // Caller should have called canCreateAccounts() + $session->remove( 'AuthManager::accountCreationState' ); + throw new \LogicException( 'Account creation is not possible' ); + } + + try { + $username = AuthenticationRequest::getUsernameFromRequests( $reqs ); + } catch ( \UnexpectedValueException $ex ) { + $username = null; + } + if ( $username === null ) { + $this->logger->debug( __METHOD__ . ': No username provided' ); + return AuthenticationResponse::newFail( wfMessage( 'noname' ) ); + } + + // Permissions check + $status = $this->checkAccountCreatePermissions( $creator ); + if ( !$status->isGood() ) { + $this->logger->debug( __METHOD__ . ': {creator} cannot create users: {reason}', [ + 'user' => $username, + 'creator' => $creator->getName(), + 'reason' => $status->getWikiText( null, null, 'en' ) + ] ); + return AuthenticationResponse::newFail( $status->getMessage() ); + } + + $status = $this->canCreateAccount( $username, User::READ_LOCKING ); + if ( !$status->isGood() ) { + $this->logger->debug( __METHOD__ . ': {user} cannot be created: {reason}', [ + 'user' => $username, + 'creator' => $creator->getName(), + 'reason' => $status->getWikiText( null, null, 'en' ) + ] ); + return AuthenticationResponse::newFail( $status->getMessage() ); + } + + $user = User::newFromName( $username, 'creatable' ); + foreach ( $reqs as $req ) { + $req->username = $username; + $req->returnToUrl = $returnToUrl; + if ( $req instanceof UserDataAuthenticationRequest ) { + $status = $req->populateUser( $user ); + if ( !$status->isGood() ) { + $status = Status::wrap( $status ); + $session->remove( 'AuthManager::accountCreationState' ); + $this->logger->debug( __METHOD__ . ': UserData is invalid: {reason}', [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + 'reason' => $status->getWikiText( null, null, 'en' ), + ] ); + return AuthenticationResponse::newFail( $status->getMessage() ); + } + } + } + + $this->removeAuthenticationSessionData( null ); + + $state = [ + 'username' => $username, + 'userid' => 0, + 'creatorid' => $creator->getId(), + 'creatorname' => $creator->getName(), + 'reqs' => $reqs, + 'returnToUrl' => $returnToUrl, + 'primary' => null, + 'primaryResponse' => null, + 'secondary' => [], + 'continueRequests' => [], + 'maybeLink' => [], + 'ranPreTests' => false, + ]; + + // Special case: converting a login to an account creation + $req = AuthenticationRequest::getRequestByClass( + $reqs, CreateFromLoginAuthenticationRequest::class + ); + if ( $req ) { + $state['maybeLink'] = $req->maybeLink; + + // If we get here, the user didn't submit a form with any of the + // usual AuthenticationRequests that are needed for an account + // creation. So we need to determine if there are any and return a + // UI response if so. + if ( $req->createRequest ) { + // We have a createRequest from a + // PrimaryAuthenticationProvider, so don't ask. + $providers = $this->getPreAuthenticationProviders() + + $this->getSecondaryAuthenticationProviders(); + } else { + // We're only preserving maybeLink, so ask for primary fields + // too. + $providers = $this->getPreAuthenticationProviders() + + $this->getPrimaryAuthenticationProviders() + + $this->getSecondaryAuthenticationProviders(); + } + $reqs = $this->getAuthenticationRequestsInternal( + self::ACTION_CREATE, + [], + $providers + ); + // See if we need any requests to begin + foreach ( (array)$reqs as $r ) { + if ( !$r instanceof UsernameAuthenticationRequest && + !$r instanceof UserDataAuthenticationRequest && + !$r instanceof CreationReasonAuthenticationRequest + ) { + // Needs some reqs, so request them + $reqs[] = new CreateFromLoginAuthenticationRequest( $req->createRequest, [] ); + $state['continueRequests'] = $reqs; + $session->setSecret( 'AuthManager::accountCreationState', $state ); + $session->persist(); + return AuthenticationResponse::newUI( $reqs, wfMessage( 'authmanager-create-from-login' ) ); + } + } + // No reqs needed, so we can just continue. + $req->createRequest->returnToUrl = $returnToUrl; + $reqs = [ $req->createRequest ]; + } + + $session->setSecret( 'AuthManager::accountCreationState', $state ); + $session->persist(); + + return $this->continueAccountCreation( $reqs ); + } + + /** + * Continue an account creation flow + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse + */ + public function continueAccountCreation( array $reqs ) { + $session = $this->request->getSession(); + try { + if ( !$this->canCreateAccounts() ) { + // Caller should have called canCreateAccounts() + $session->remove( 'AuthManager::accountCreationState' ); + throw new \LogicException( 'Account creation is not possible' ); + } + + $state = $session->getSecret( 'AuthManager::accountCreationState' ); + if ( !is_array( $state ) ) { + return AuthenticationResponse::newFail( + wfMessage( 'authmanager-create-not-in-progress' ) + ); + } + $state['continueRequests'] = []; + + // Step 0: Prepare and validate the input + + $user = User::newFromName( $state['username'], 'creatable' ); + if ( !is_object( $user ) ) { + $session->remove( 'AuthManager::accountCreationState' ); + $this->logger->debug( __METHOD__ . ': Invalid username', [ + 'user' => $state['username'], + ] ); + return AuthenticationResponse::newFail( wfMessage( 'noname' ) ); + } + + if ( $state['creatorid'] ) { + $creator = User::newFromId( $state['creatorid'] ); + } else { + $creator = new User; + $creator->setName( $state['creatorname'] ); + } + + // Avoid account creation races on double submissions + $cache = \ObjectCache::getLocalClusterInstance(); + $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $user->getName() ) ) ); + if ( !$lock ) { + // Don't clear AuthManager::accountCreationState for this code + // path because the process that won the race owns it. + $this->logger->debug( __METHOD__ . ': Could not acquire account creation lock', [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + return AuthenticationResponse::newFail( wfMessage( 'usernameinprogress' ) ); + } + + // Permissions check + $status = $this->checkAccountCreatePermissions( $creator ); + if ( !$status->isGood() ) { + $this->logger->debug( __METHOD__ . ': {creator} cannot create users: {reason}', [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + 'reason' => $status->getWikiText( null, null, 'en' ) + ] ); + $ret = AuthenticationResponse::newFail( $status->getMessage() ); + $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] ); + $session->remove( 'AuthManager::accountCreationState' ); + return $ret; + } + + // Load from master for existence check + $user->load( User::READ_LOCKING ); + + if ( $state['userid'] === 0 ) { + if ( $user->getId() != 0 ) { + $this->logger->debug( __METHOD__ . ': User exists locally', [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + $ret = AuthenticationResponse::newFail( wfMessage( 'userexists' ) ); + $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] ); + $session->remove( 'AuthManager::accountCreationState' ); + return $ret; + } + } else { + if ( $user->getId() == 0 ) { + $this->logger->debug( __METHOD__ . ': User does not exist locally when it should', [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + 'expected_id' => $state['userid'], + ] ); + throw new \UnexpectedValueException( + "User \"{$state['username']}\" should exist now, but doesn't!" + ); + } + if ( $user->getId() != $state['userid'] ) { + $this->logger->debug( __METHOD__ . ': User ID/name mismatch', [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + 'expected_id' => $state['userid'], + 'actual_id' => $user->getId(), + ] ); + throw new \UnexpectedValueException( + "User \"{$state['username']}\" exists, but " . + "ID {$user->getId()} != {$state['userid']}!" + ); + } + } + foreach ( $state['reqs'] as $req ) { + if ( $req instanceof UserDataAuthenticationRequest ) { + $status = $req->populateUser( $user ); + if ( !$status->isGood() ) { + // This should never happen... + $status = Status::wrap( $status ); + $this->logger->debug( __METHOD__ . ': UserData is invalid: {reason}', [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + 'reason' => $status->getWikiText( null, null, 'en' ), + ] ); + $ret = AuthenticationResponse::newFail( $status->getMessage() ); + $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] ); + $session->remove( 'AuthManager::accountCreationState' ); + return $ret; + } + } + } + + foreach ( $reqs as $req ) { + $req->returnToUrl = $state['returnToUrl']; + $req->username = $state['username']; + } + + // If we're coming in from a create-from-login UI response, we need + // to extract the createRequest (if any). + $req = AuthenticationRequest::getRequestByClass( + $reqs, CreateFromLoginAuthenticationRequest::class + ); + if ( $req && $req->createRequest ) { + $reqs[] = $req->createRequest; + } + + // Run pre-creation tests, if we haven't already + if ( !$state['ranPreTests'] ) { + $providers = $this->getPreAuthenticationProviders() + + $this->getPrimaryAuthenticationProviders() + + $this->getSecondaryAuthenticationProviders(); + foreach ( $providers as $id => $provider ) { + $status = $provider->testForAccountCreation( $user, $creator, $reqs ); + if ( !$status->isGood() ) { + $this->logger->debug( __METHOD__ . ": Fail in pre-authentication by $id", [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + $ret = AuthenticationResponse::newFail( + Status::wrap( $status )->getMessage() + ); + $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] ); + $session->remove( 'AuthManager::accountCreationState' ); + return $ret; + } + } + + $state['ranPreTests'] = true; + } + + // Step 1: Choose a primary authentication provider and call it until it succeeds. + + if ( $state['primary'] === null ) { + // We haven't picked a PrimaryAuthenticationProvider yet + foreach ( $this->getPrimaryAuthenticationProviders() as $id => $provider ) { + if ( $provider->accountCreationType() === PrimaryAuthenticationProvider::TYPE_NONE ) { + continue; + } + $res = $provider->beginPrimaryAccountCreation( $user, $creator, $reqs ); + switch ( $res->status ) { + case AuthenticationResponse::PASS; + $this->logger->debug( __METHOD__ . ": Primary creation passed by $id", [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + $state['primary'] = $id; + $state['primaryResponse'] = $res; + break 2; + case AuthenticationResponse::FAIL; + $this->logger->debug( __METHOD__ . ": Primary creation failed by $id", [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $res ] ); + $session->remove( 'AuthManager::accountCreationState' ); + return $res; + case AuthenticationResponse::ABSTAIN; + // Continue loop + break; + case AuthenticationResponse::REDIRECT; + case AuthenticationResponse::UI; + $this->logger->debug( __METHOD__ . ": Primary creation $res->status by $id", [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + $state['primary'] = $id; + $state['continueRequests'] = $res->neededRequests; + $session->setSecret( 'AuthManager::accountCreationState', $state ); + return $res; + + // @codeCoverageIgnoreStart + default: + throw new \DomainException( + get_class( $provider ) . "::beginPrimaryAccountCreation() returned $res->status" + ); + // @codeCoverageIgnoreEnd + } + } + if ( $state['primary'] === null ) { + $this->logger->debug( __METHOD__ . ': Primary creation failed because no provider accepted', [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + $ret = AuthenticationResponse::newFail( + wfMessage( 'authmanager-create-no-primary' ) + ); + $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] ); + $session->remove( 'AuthManager::accountCreationState' ); + return $ret; + } + } elseif ( $state['primaryResponse'] === null ) { + $provider = $this->getAuthenticationProvider( $state['primary'] ); + if ( !$provider instanceof PrimaryAuthenticationProvider ) { + // Configuration changed? Force them to start over. + // @codeCoverageIgnoreStart + $ret = AuthenticationResponse::newFail( + wfMessage( 'authmanager-create-not-in-progress' ) + ); + $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] ); + $session->remove( 'AuthManager::accountCreationState' ); + return $ret; + // @codeCoverageIgnoreEnd + } + $id = $provider->getUniqueId(); + $res = $provider->continuePrimaryAccountCreation( $user, $creator, $reqs ); + switch ( $res->status ) { + case AuthenticationResponse::PASS; + $this->logger->debug( __METHOD__ . ": Primary creation passed by $id", [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + $state['primaryResponse'] = $res; + break; + case AuthenticationResponse::FAIL; + $this->logger->debug( __METHOD__ . ": Primary creation failed by $id", [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $res ] ); + $session->remove( 'AuthManager::accountCreationState' ); + return $res; + case AuthenticationResponse::REDIRECT; + case AuthenticationResponse::UI; + $this->logger->debug( __METHOD__ . ": Primary creation $res->status by $id", [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + $state['continueRequests'] = $res->neededRequests; + $session->setSecret( 'AuthManager::accountCreationState', $state ); + return $res; + default: + throw new \DomainException( + get_class( $provider ) . "::continuePrimaryAccountCreation() returned $res->status" + ); + } + } + + // Step 2: Primary authentication succeeded, create the User object + // and add the user locally. + + if ( $state['userid'] === 0 ) { + $this->logger->info( 'Creating user {user} during account creation', [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + $status = $user->addToDatabase(); + if ( !$status->isOk() ) { + // @codeCoverageIgnoreStart + $ret = AuthenticationResponse::newFail( $status->getMessage() ); + $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] ); + $session->remove( 'AuthManager::accountCreationState' ); + return $ret; + // @codeCoverageIgnoreEnd + } + $this->setDefaultUserOptions( $user, $creator->isAnon() ); + \Hooks::run( 'LocalUserCreated', [ $user, false ] ); + $user->saveSettings(); + $state['userid'] = $user->getId(); + + // Update user count + \DeferredUpdates::addUpdate( new \SiteStatsUpdate( 0, 0, 0, 0, 1 ) ); + + // Watch user's userpage and talk page + $user->addWatch( $user->getUserPage(), User::IGNORE_USER_RIGHTS ); + + // Inform the provider + $logSubtype = $provider->finishAccountCreation( $user, $creator, $state['primaryResponse'] ); + + // Log the creation + if ( $this->config->get( 'NewUserLog' ) ) { + $isAnon = $creator->isAnon(); + $logEntry = new \ManualLogEntry( + 'newusers', + $logSubtype ?: ( $isAnon ? 'create' : 'create2' ) + ); + $logEntry->setPerformer( $isAnon ? $user : $creator ); + $logEntry->setTarget( $user->getUserPage() ); + $req = AuthenticationRequest::getRequestByClass( + $state['reqs'], CreationReasonAuthenticationRequest::class + ); + $logEntry->setComment( $req ? $req->reason : '' ); + $logEntry->setParameters( [ + '4::userid' => $user->getId(), + ] ); + $logid = $logEntry->insert(); + $logEntry->publish( $logid ); + } + } + + // Step 3: Iterate over all the secondary authentication providers. + + $beginReqs = $state['reqs']; + + foreach ( $this->getSecondaryAuthenticationProviders() as $id => $provider ) { + if ( !isset( $state['secondary'][$id] ) ) { + // This provider isn't started yet, so we pass it the set + // of reqs from beginAuthentication instead of whatever + // might have been used by a previous provider in line. + $func = 'beginSecondaryAccountCreation'; + $res = $provider->beginSecondaryAccountCreation( $user, $creator, $beginReqs ); + } elseif ( !$state['secondary'][$id] ) { + $func = 'continueSecondaryAccountCreation'; + $res = $provider->continueSecondaryAccountCreation( $user, $creator, $reqs ); + } else { + continue; + } + switch ( $res->status ) { + case AuthenticationResponse::PASS; + $this->logger->debug( __METHOD__ . ": Secondary creation passed by $id", [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + // fall through + case AuthenticationResponse::ABSTAIN; + $state['secondary'][$id] = true; + break; + case AuthenticationResponse::REDIRECT; + case AuthenticationResponse::UI; + $this->logger->debug( __METHOD__ . ": Secondary creation $res->status by $id", [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + $state['secondary'][$id] = false; + $state['continueRequests'] = $res->neededRequests; + $session->setSecret( 'AuthManager::accountCreationState', $state ); + return $res; + case AuthenticationResponse::FAIL; + throw new \DomainException( + get_class( $provider ) . "::{$func}() returned $res->status." . + ' Secondary providers are not allowed to fail account creation, that' . + ' should have been done via testForAccountCreation().' + ); + // @codeCoverageIgnoreStart + default: + throw new \DomainException( + get_class( $provider ) . "::{$func}() returned $res->status" + ); + // @codeCoverageIgnoreEnd + } + } + + $id = $user->getId(); + $name = $user->getName(); + $req = new CreatedAccountAuthenticationRequest( $id, $name ); + $ret = AuthenticationResponse::newPass( $name ); + $ret->loginRequest = $req; + $this->createdAccountAuthenticationRequests[] = $req; + + $this->logger->info( __METHOD__ . ': Account creation succeeded for {user}', [ + 'user' => $user->getName(), + 'creator' => $creator->getName(), + ] ); + + $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] ); + $session->remove( 'AuthManager::accountCreationState' ); + $this->removeAuthenticationSessionData( null ); + return $ret; + } catch ( \Exception $ex ) { + $session->remove( 'AuthManager::accountCreationState' ); + throw $ex; + } + } + + /** + * Auto-create an account, and log into that account + * @param User $user User to auto-create + * @param string $source What caused the auto-creation? This must be the ID + * of a PrimaryAuthenticationProvider or the constant self::AUTOCREATE_SOURCE_SESSION. + * @param bool $login Whether to also log the user in + * @return Status Good if user was created, Ok if user already existed, otherwise Fatal + */ + public function autoCreateUser( User $user, $source, $login = true ) { + if ( $source !== self::AUTOCREATE_SOURCE_SESSION && + !$this->getAuthenticationProvider( $source ) instanceof PrimaryAuthenticationProvider + ) { + throw new \InvalidArgumentException( "Unknown auto-creation source: $source" ); + } + + $username = $user->getName(); + + // Try the local user from the slave DB + $localId = User::idFromName( $username ); + $flags = User::READ_NORMAL; + + // Fetch the user ID from the master, so that we don't try to create the user + // when they already exist, due to replication lag + // @codeCoverageIgnoreStart + if ( !$localId && wfGetLB()->getReaderIndex() != 0 ) { + $localId = User::idFromName( $username, User::READ_LATEST ); + $flags = User::READ_LATEST; + } + // @codeCoverageIgnoreEnd + + if ( $localId ) { + $this->logger->debug( __METHOD__ . ': {username} already exists locally', [ + 'username' => $username, + ] ); + $user->setId( $localId ); + $user->loadFromId( $flags ); + if ( $login ) { + $this->setSessionDataForUser( $user ); + } + $status = Status::newGood(); + $status->warning( 'userexists' ); + return $status; + } + + // Wiki is read-only? + if ( wfReadOnly() ) { + $this->logger->debug( __METHOD__ . ': denied by wfReadOnly(): {reason}', [ + 'username' => $username, + 'reason' => wfReadOnlyReason(), + ] ); + $user->setId( 0 ); + $user->loadFromId(); + return Status::newFatal( 'readonlytext', wfReadOnlyReason() ); + } + + // Check the session, if we tried to create this user already there's + // no point in retrying. + $session = $this->request->getSession(); + if ( $session->get( 'AuthManager::AutoCreateBlacklist' ) ) { + $this->logger->debug( __METHOD__ . ': blacklisted in session {sessionid}', [ + 'username' => $username, + 'sessionid' => $session->getId(), + ] ); + $user->setId( 0 ); + $user->loadFromId(); + $reason = $session->get( 'AuthManager::AutoCreateBlacklist' ); + if ( $reason instanceof StatusValue ) { + return Status::wrap( $reason ); + } else { + return Status::newFatal( $reason ); + } + } + + // Is the username creatable? + if ( !User::isCreatableName( $username ) ) { + $this->logger->debug( __METHOD__ . ': name "{username}" is not creatable', [ + 'username' => $username, + ] ); + $session->set( 'AuthManager::AutoCreateBlacklist', 'noname', 600 ); + $user->setId( 0 ); + $user->loadFromId(); + return Status::newFatal( 'noname' ); + } + + // Is the IP user able to create accounts? + $anon = new User; + if ( !$anon->isAllowedAny( 'createaccount', 'autocreateaccount' ) ) { + $this->logger->debug( __METHOD__ . ': IP lacks the ability to create or autocreate accounts', [ + 'username' => $username, + 'ip' => $anon->getName(), + ] ); + $session->set( 'AuthManager::AutoCreateBlacklist', 'authmanager-autocreate-noperm', 600 ); + $session->persist(); + $user->setId( 0 ); + $user->loadFromId(); + return Status::newFatal( 'authmanager-autocreate-noperm' ); + } + + // Avoid account creation races on double submissions + $cache = \ObjectCache::getLocalClusterInstance(); + $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $username ) ) ); + if ( !$lock ) { + $this->logger->debug( __METHOD__ . ': Could not acquire account creation lock', [ + 'user' => $username, + ] ); + $user->setId( 0 ); + $user->loadFromId(); + return Status::newFatal( 'usernameinprogress' ); + } + + // Denied by providers? + $providers = $this->getPreAuthenticationProviders() + + $this->getPrimaryAuthenticationProviders() + + $this->getSecondaryAuthenticationProviders(); + foreach ( $providers as $provider ) { + $status = $provider->testUserForCreation( $user, $source ); + if ( !$status->isGood() ) { + $ret = Status::wrap( $status ); + $this->logger->debug( __METHOD__ . ': Provider denied creation of {username}: {reason}', [ + 'username' => $username, + 'reason' => $ret->getWikiText( null, null, 'en' ), + ] ); + $session->set( 'AuthManager::AutoCreateBlacklist', $status, 600 ); + $user->setId( 0 ); + $user->loadFromId(); + return $ret; + } + } + + // Ignore warnings about master connections/writes...hard to avoid here + \Profiler::instance()->getTransactionProfiler()->resetExpectations(); + + $backoffKey = wfMemcKey( 'AuthManager', 'autocreate-failed', md5( $username ) ); + if ( $cache->get( $backoffKey ) ) { + $this->logger->debug( __METHOD__ . ': {username} denied by prior creation attempt failures', [ + 'username' => $username, + ] ); + $user->setId( 0 ); + $user->loadFromId(); + return Status::newFatal( 'authmanager-autocreate-exception' ); + } + + // Checks passed, create the user... + $from = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : 'CLI'; + $this->logger->info( __METHOD__ . ': creating new user ({username}) - from: {from}', [ + 'username' => $username, + 'from' => $from, + ] ); + + try { + $status = $user->addToDatabase(); + if ( !$status->isOk() ) { + // double-check for a race condition (T70012) + $localId = User::idFromName( $username, User::READ_LATEST ); + if ( $localId ) { + $this->logger->info( __METHOD__ . ': {username} already exists locally (race)', [ + 'username' => $username, + ] ); + $user->setId( $localId ); + $user->loadFromId( User::READ_LATEST ); + if ( $login ) { + $this->setSessionDataForUser( $user ); + } + $status = Status::newGood(); + $status->warning( 'userexists' ); + } else { + $this->logger->error( __METHOD__ . ': {username} failed with message {message}', [ + 'username' => $username, + 'message' => $status->getWikiText( null, null, 'en' ) + ] ); + $user->setId( 0 ); + $user->loadFromId(); + } + return $status; + } + } catch ( \Exception $ex ) { + $this->logger->error( __METHOD__ . ': {username} failed with exception {exception}', [ + 'username' => $username, + 'exception' => $ex, + ] ); + // Do not keep throwing errors for a while + $cache->set( $backoffKey, 1, 600 ); + // Bubble up error; which should normally trigger DB rollbacks + throw $ex; + } + + $this->setDefaultUserOptions( $user, true ); + + // Inform the providers + $this->callMethodOnProviders( 6, 'autoCreatedAccount', [ $user, $source ] ); + + \Hooks::run( 'AuthPluginAutoCreate', [ $user ], '1.27' ); + \Hooks::run( 'LocalUserCreated', [ $user, true ] ); + $user->saveSettings(); + + // Update user count + \DeferredUpdates::addUpdate( new \SiteStatsUpdate( 0, 0, 0, 0, 1 ) ); + + // Watch user's userpage and talk page + $user->addWatch( $user->getUserPage(), User::IGNORE_USER_RIGHTS ); + + // Log the creation + if ( $this->config->get( 'NewUserLog' ) ) { + $logEntry = new \ManualLogEntry( 'newusers', 'autocreate' ); + $logEntry->setPerformer( $user ); + $logEntry->setTarget( $user->getUserPage() ); + $logEntry->setComment( '' ); + $logEntry->setParameters( [ + '4::userid' => $user->getId(), + ] ); + $logid = $logEntry->insert(); + } + + if ( $login ) { + $this->setSessionDataForUser( $user ); + } + + return Status::newGood(); + } + + /**@}*/ + + /** + * @name Account linking + * @{ + */ + + /** + * Determine whether accounts can be linked + * @return bool + */ + public function canLinkAccounts() { + foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) { + if ( $provider->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK ) { + return true; + } + } + return false; + } + + /** + * Start an account linking flow + * + * @param User $user User being linked + * @param AuthenticationRequest[] $reqs + * @param string $returnToUrl Url that REDIRECT responses should eventually + * return to. + * @return AuthenticationResponse + */ + public function beginAccountLink( User $user, array $reqs, $returnToUrl ) { + $session = $this->request->getSession(); + $session->remove( 'AuthManager::accountLinkState' ); + + if ( !$this->canLinkAccounts() ) { + // Caller should have called canLinkAccounts() + throw new \LogicException( 'Account linking is not possible' ); + } + + if ( $user->getId() === 0 ) { + if ( !User::isUsableName( $user->getName() ) ) { + $msg = wfMessage( 'noname' ); + } else { + $msg = wfMessage( 'authmanager-userdoesnotexist', $user->getName() ); + } + return AuthenticationResponse::newFail( $msg ); + } + foreach ( $reqs as $req ) { + $req->username = $user->getName(); + $req->returnToUrl = $returnToUrl; + } + + $this->removeAuthenticationSessionData( null ); + + $providers = $this->getPreAuthenticationProviders(); + foreach ( $providers as $id => $provider ) { + $status = $provider->testForAccountLink( $user ); + if ( !$status->isGood() ) { + $this->logger->debug( __METHOD__ . ": Account linking pre-check failed by $id", [ + 'user' => $user->getName(), + ] ); + $ret = AuthenticationResponse::newFail( + Status::wrap( $status )->getMessage() + ); + $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $ret ] ); + return $ret; + } + } + + $state = [ + 'username' => $user->getName(), + 'userid' => $user->getId(), + 'returnToUrl' => $returnToUrl, + 'primary' => null, + 'continueRequests' => [], + ]; + + $providers = $this->getPrimaryAuthenticationProviders(); + foreach ( $providers as $id => $provider ) { + if ( $provider->accountCreationType() !== PrimaryAuthenticationProvider::TYPE_LINK ) { + continue; + } + + $res = $provider->beginPrimaryAccountLink( $user, $reqs ); + switch ( $res->status ) { + case AuthenticationResponse::PASS; + $this->logger->info( "Account linked to {user} by $id", [ + 'user' => $user->getName(), + ] ); + $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] ); + return $res; + + case AuthenticationResponse::FAIL; + $this->logger->debug( __METHOD__ . ": Account linking failed by $id", [ + 'user' => $user->getName(), + ] ); + $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] ); + return $res; + + case AuthenticationResponse::ABSTAIN; + // Continue loop + break; + + case AuthenticationResponse::REDIRECT; + case AuthenticationResponse::UI; + $this->logger->debug( __METHOD__ . ": Account linking $res->status by $id", [ + 'user' => $user->getName(), + ] ); + $state['primary'] = $id; + $state['continueRequests'] = $res->neededRequests; + $session->setSecret( 'AuthManager::accountLinkState', $state ); + $session->persist(); + return $res; + + // @codeCoverageIgnoreStart + default: + throw new \DomainException( + get_class( $provider ) . "::beginPrimaryAccountLink() returned $res->status" + ); + // @codeCoverageIgnoreEnd + } + } + + $this->logger->debug( __METHOD__ . ': Account linking failed because no provider accepted', [ + 'user' => $user->getName(), + ] ); + $ret = AuthenticationResponse::newFail( + wfMessage( 'authmanager-link-no-primary' ) + ); + $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $ret ] ); + return $ret; + } + + /** + * Continue an account linking flow + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse + */ + public function continueAccountLink( array $reqs ) { + $session = $this->request->getSession(); + try { + if ( !$this->canLinkAccounts() ) { + // Caller should have called canLinkAccounts() + $session->remove( 'AuthManager::accountLinkState' ); + throw new \LogicException( 'Account linking is not possible' ); + } + + $state = $session->getSecret( 'AuthManager::accountLinkState' ); + if ( !is_array( $state ) ) { + return AuthenticationResponse::newFail( + wfMessage( 'authmanager-link-not-in-progress' ) + ); + } + $state['continueRequests'] = []; + + // Step 0: Prepare and validate the input + + $user = User::newFromName( $state['username'], 'usable' ); + if ( !is_object( $user ) ) { + $session->remove( 'AuthManager::accountLinkState' ); + return AuthenticationResponse::newFail( wfMessage( 'noname' ) ); + } + if ( $user->getId() != $state['userid'] ) { + throw new \UnexpectedValueException( + "User \"{$state['username']}\" is valid, but " . + "ID {$user->getId()} != {$state['userid']}!" + ); + } + + foreach ( $reqs as $req ) { + $req->username = $state['username']; + $req->returnToUrl = $state['returnToUrl']; + } + + // Step 1: Call the primary again until it succeeds + + $provider = $this->getAuthenticationProvider( $state['primary'] ); + if ( !$provider instanceof PrimaryAuthenticationProvider ) { + // Configuration changed? Force them to start over. + // @codeCoverageIgnoreStart + $ret = AuthenticationResponse::newFail( + wfMessage( 'authmanager-link-not-in-progress' ) + ); + $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $ret ] ); + $session->remove( 'AuthManager::accountLinkState' ); + return $ret; + // @codeCoverageIgnoreEnd + } + $id = $provider->getUniqueId(); + $res = $provider->continuePrimaryAccountLink( $user, $reqs ); + switch ( $res->status ) { + case AuthenticationResponse::PASS; + $this->logger->info( "Account linked to {user} by $id", [ + 'user' => $user->getName(), + ] ); + $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] ); + $session->remove( 'AuthManager::accountLinkState' ); + return $res; + case AuthenticationResponse::FAIL; + $this->logger->debug( __METHOD__ . ": Account linking failed by $id", [ + 'user' => $user->getName(), + ] ); + $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] ); + $session->remove( 'AuthManager::accountLinkState' ); + return $res; + case AuthenticationResponse::REDIRECT; + case AuthenticationResponse::UI; + $this->logger->debug( __METHOD__ . ": Account linking $res->status by $id", [ + 'user' => $user->getName(), + ] ); + $state['continueRequests'] = $res->neededRequests; + $session->setSecret( 'AuthManager::accountLinkState', $state ); + return $res; + default: + throw new \DomainException( + get_class( $provider ) . "::continuePrimaryAccountLink() returned $res->status" + ); + } + } catch ( \Exception $ex ) { + $session->remove( 'AuthManager::accountLinkState' ); + throw $ex; + } + } + + /**@}*/ + + /** + * @name Information methods + * @{ + */ + + /** + * Return the applicable list of AuthenticationRequests + * + * Possible values for $action: + * - ACTION_LOGIN: Valid for passing to beginAuthentication + * - ACTION_LOGIN_CONTINUE: Valid for passing to continueAuthentication in the current state + * - ACTION_CREATE: Valid for passing to beginAccountCreation + * - ACTION_CREATE_CONTINUE: Valid for passing to continueAccountCreation in the current state + * - ACTION_LINK: Valid for passing to beginAccountLink + * - ACTION_LINK_CONTINUE: Valid for passing to continueAccountLink in the current state + * - ACTION_CHANGE: Valid for passing to changeAuthenticationData to change credentials + * - ACTION_REMOVE: Valid for passing to changeAuthenticationData to remove credentials. + * - ACTION_UNLINK: Same as ACTION_REMOVE, but limited to linked accounts. + * + * @param string $action One of the AuthManager::ACTION_* constants + * @param User|null $user User being acted on, instead of the current user. + * @return AuthenticationRequest[] + */ + public function getAuthenticationRequests( $action, User $user = null ) { + $options = []; + $providerAction = $action; + + // Figure out which providers to query + switch ( $action ) { + case self::ACTION_LOGIN: + case self::ACTION_CREATE: + $providers = $this->getPreAuthenticationProviders() + + $this->getPrimaryAuthenticationProviders() + + $this->getSecondaryAuthenticationProviders(); + break; + + case self::ACTION_LOGIN_CONTINUE: + $state = $this->request->getSession()->getSecret( 'AuthManager::authnState' ); + return is_array( $state ) ? $state['continueRequests'] : []; + + case self::ACTION_CREATE_CONTINUE: + $state = $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ); + return is_array( $state ) ? $state['continueRequests'] : []; + + case self::ACTION_LINK: + $providers = array_filter( $this->getPrimaryAuthenticationProviders(), function ( $p ) { + return $p->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK; + } ); + break; + + case self::ACTION_UNLINK: + $providers = array_filter( $this->getPrimaryAuthenticationProviders(), function ( $p ) { + return $p->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK; + } ); + + // To providers, unlink and remove are identical. + $providerAction = self::ACTION_REMOVE; + break; + + case self::ACTION_LINK_CONTINUE: + $state = $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ); + return is_array( $state ) ? $state['continueRequests'] : []; + + case self::ACTION_CHANGE: + case self::ACTION_REMOVE: + $providers = $this->getPrimaryAuthenticationProviders() + + $this->getSecondaryAuthenticationProviders(); + break; + + // @codeCoverageIgnoreStart + default: + throw new \DomainException( __METHOD__ . ": Invalid action \"$action\"" ); + } + // @codeCoverageIgnoreEnd + + return $this->getAuthenticationRequestsInternal( $providerAction, $options, $providers, $user ); + } + + /** + * Internal request lookup for self::getAuthenticationRequests + * + * @param string $providerAction Action to pass to providers + * @param array $options Options to pass to providers + * @param AuthenticationProvider[] $providers + * @param User|null $user + * @return AuthenticationRequest[] + */ + private function getAuthenticationRequestsInternal( + $providerAction, array $options, array $providers, User $user = null + ) { + $user = $user ?: \RequestContext::getMain()->getUser(); + $options['username'] = $user->isAnon() ? null : $user->getName(); + + // Query them and merge results + $reqs = []; + $allPrimaryRequired = null; + foreach ( $providers as $provider ) { + $isPrimary = $provider instanceof PrimaryAuthenticationProvider; + $thisRequired = []; + foreach ( $provider->getAuthenticationRequests( $providerAction, $options ) as $req ) { + $id = $req->getUniqueId(); + + // If it's from a Primary, mark it as "primary-required" but + // track it for later. + if ( $isPrimary ) { + if ( $req->required ) { + $thisRequired[$id] = true; + $req->required = AuthenticationRequest::PRIMARY_REQUIRED; + } + } + + if ( !isset( $reqs[$id] ) || $req->required === AuthenticationRequest::REQUIRED ) { + $reqs[$id] = $req; + } + } + + // Track which requests are required by all primaries + if ( $isPrimary ) { + $allPrimaryRequired = $allPrimaryRequired === null + ? $thisRequired + : array_intersect_key( $allPrimaryRequired, $thisRequired ); + } + } + // Any requests that were required by all primaries are required. + foreach ( (array)$allPrimaryRequired as $id => $dummy ) { + $reqs[$id]->required = AuthenticationRequest::REQUIRED; + } + + // AuthManager has its own req for some actions + switch ( $providerAction ) { + case self::ACTION_LOGIN: + $reqs[] = new RememberMeAuthenticationRequest; + break; + + case self::ACTION_CREATE: + $reqs[] = new UsernameAuthenticationRequest; + $reqs[] = new UserDataAuthenticationRequest; + if ( $options['username'] !== null ) { + $reqs[] = new CreationReasonAuthenticationRequest; + $options['username'] = null; // Don't fill in the username below + } + break; + } + + // Fill in reqs data + foreach ( $reqs as $req ) { + $req->action = $providerAction; + if ( $req->username === null ) { + $req->username = $options['username']; + } + } + + // For self::ACTION_CHANGE, filter out any that something else *doesn't* allow changing + if ( $providerAction === self::ACTION_CHANGE || $providerAction === self::ACTION_REMOVE ) { + $reqs = array_filter( $reqs, function ( $req ) { + return $this->allowsAuthenticationDataChange( $req, false )->isGood(); + } ); + } + + return array_values( $reqs ); + } + + /** + * Determine whether a username exists + * @param string $username + * @param int $flags Bitfield of User:READ_* constants + * @return bool + */ + public function userExists( $username, $flags = User::READ_NORMAL ) { + foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) { + if ( $provider->testUserExists( $username, $flags ) ) { + return true; + } + } + + return false; + } + + /** + * Determine whether a user property should be allowed to be changed. + * + * Supported properties are: + * - emailaddress + * - realname + * - nickname + * + * @param string $property + * @return bool + */ + public function allowsPropertyChange( $property ) { + $providers = $this->getPrimaryAuthenticationProviders() + + $this->getSecondaryAuthenticationProviders(); + foreach ( $providers as $provider ) { + if ( !$provider->providerAllowsPropertyChange( $property ) ) { + return false; + } + } + return true; + } + + /**@}*/ + + /** + * @name Internal methods + * @{ + */ + + /** + * Store authentication in the current session + * @protected For use by AuthenticationProviders + * @param string $key + * @param mixed $data Must be serializable + */ + public function setAuthenticationSessionData( $key, $data ) { + $session = $this->request->getSession(); + $arr = $session->getSecret( 'authData' ); + if ( !is_array( $arr ) ) { + $arr = []; + } + $arr[$key] = $data; + $session->setSecret( 'authData', $arr ); + } + + /** + * Fetch authentication data from the current session + * @protected For use by AuthenticationProviders + * @param string $key + * @param mixed $default + * @return mixed + */ + public function getAuthenticationSessionData( $key, $default = null ) { + $arr = $this->request->getSession()->getSecret( 'authData' ); + if ( is_array( $arr ) && array_key_exists( $key, $arr ) ) { + return $arr[$key]; + } else { + return $default; + } + } + + /** + * Remove authentication data + * @protected For use by AuthenticationProviders + * @param string|null $key If null, all data is removed + */ + public function removeAuthenticationSessionData( $key ) { + $session = $this->request->getSession(); + if ( $key === null ) { + $session->remove( 'authData' ); + } else { + $arr = $session->getSecret( 'authData' ); + if ( is_array( $arr ) && array_key_exists( $key, $arr ) ) { + unset( $arr[$key] ); + $session->setSecret( 'authData', $arr ); + } + } + } + + /** + * Create an array of AuthenticationProviders from an array of ObjectFactory specs + * @param string $class + * @param array[] $specs + * @return AuthenticationProvider[] + */ + protected function providerArrayFromSpecs( $class, array $specs ) { + $i = 0; + foreach ( $specs as &$spec ) { + $spec = [ 'sort2' => $i++ ] + $spec + [ 'sort' => 0 ]; + } + unset( $spec ); + usort( $specs, function ( $a, $b ) { + return ( (int)$a['sort'] ) - ( (int)$b['sort'] ) + ?: $a['sort2'] - $b['sort2']; + } ); + + $ret = []; + foreach ( $specs as $spec ) { + $provider = \ObjectFactory::getObjectFromSpec( $spec ); + if ( !$provider instanceof $class ) { + throw new \RuntimeException( + "Expected instance of $class, got " . get_class( $provider ) + ); + } + $provider->setLogger( $this->logger ); + $provider->setManager( $this ); + $provider->setConfig( $this->config ); + $id = $provider->getUniqueId(); + if ( isset( $this->allAuthenticationProviders[$id] ) ) { + throw new \RuntimeException( + "Duplicate specifications for id $id (classes " . + get_class( $provider ) . ' and ' . + get_class( $this->allAuthenticationProviders[$id] ) . ')' + ); + } + $this->allAuthenticationProviders[$id] = $provider; + $ret[$id] = $provider; + } + return $ret; + } + + /** + * Get the configuration + * @return array + */ + private function getConfiguration() { + return $this->config->get( 'AuthManagerConfig' ) ?: $this->config->get( 'AuthManagerAutoConfig' ); + } + + /** + * Get the list of PreAuthenticationProviders + * @return PreAuthenticationProvider[] + */ + protected function getPreAuthenticationProviders() { + if ( $this->preAuthenticationProviders === null ) { + $conf = $this->getConfiguration(); + $this->preAuthenticationProviders = $this->providerArrayFromSpecs( + PreAuthenticationProvider::class, $conf['preauth'] + ); + } + return $this->preAuthenticationProviders; + } + + /** + * Get the list of PrimaryAuthenticationProviders + * @return PrimaryAuthenticationProvider[] + */ + protected function getPrimaryAuthenticationProviders() { + if ( $this->primaryAuthenticationProviders === null ) { + $conf = $this->getConfiguration(); + $this->primaryAuthenticationProviders = $this->providerArrayFromSpecs( + PrimaryAuthenticationProvider::class, $conf['primaryauth'] + ); + } + return $this->primaryAuthenticationProviders; + } + + /** + * Get the list of SecondaryAuthenticationProviders + * @return SecondaryAuthenticationProvider[] + */ + protected function getSecondaryAuthenticationProviders() { + if ( $this->secondaryAuthenticationProviders === null ) { + $conf = $this->getConfiguration(); + $this->secondaryAuthenticationProviders = $this->providerArrayFromSpecs( + SecondaryAuthenticationProvider::class, $conf['secondaryauth'] + ); + } + return $this->secondaryAuthenticationProviders; + } + + /** + * Get a provider by ID + * @param string $id + * @return AuthenticationProvider|null + */ + protected function getAuthenticationProvider( $id ) { + // Fast version + if ( isset( $this->allAuthenticationProviders[$id] ) ) { + return $this->allAuthenticationProviders[$id]; + } + + // Slow version: instantiate each kind and check + $providers = $this->getPrimaryAuthenticationProviders(); + if ( isset( $providers[$id] ) ) { + return $providers[$id]; + } + $providers = $this->getSecondaryAuthenticationProviders(); + if ( isset( $providers[$id] ) ) { + return $providers[$id]; + } + $providers = $this->getPreAuthenticationProviders(); + if ( isset( $providers[$id] ) ) { + return $providers[$id]; + } + + return null; + } + + /** + * @param User $user + * @param bool|null $remember + */ + private function setSessionDataForUser( $user, $remember = null ) { + $session = $this->request->getSession(); + $delay = $session->delaySave(); + + $session->resetId(); + if ( $session->canSetUser() ) { + $session->setUser( $user ); + } + if ( $remember !== null ) { + $session->setRememberUser( $remember ); + } + $session->set( 'AuthManager:lastAuthId', $user->getId() ); + $session->set( 'AuthManager:lastAuthTimestamp', time() ); + $session->persist(); + + \ScopedCallback::consume( $delay ); + + \Hooks::run( 'UserLoggedIn', [ $user ] ); + } + + /** + * @param User $user + * @param bool $useContextLang Use 'uselang' to set the user's language + */ + private function setDefaultUserOptions( User $user, $useContextLang ) { + global $wgContLang; + + \MediaWiki\Session\SessionManager::singleton()->invalidateSessionsForUser( $user ); + + $lang = $useContextLang ? \RequestContext::getMain()->getLanguage() : $wgContLang; + $user->setOption( 'language', $lang->getPreferredVariant() ); + + if ( $wgContLang->hasVariants() ) { + $user->setOption( 'variant', $wgContLang->getPreferredVariant() ); + } + } + + /** + * @param int $which Bitmask: 1 = pre, 2 = primary, 4 = secondary + * @param string $method + * @param array $args + */ + private function callMethodOnProviders( $which, $method, array $args ) { + $providers = []; + if ( $which & 1 ) { + $providers += $this->getPreAuthenticationProviders(); + } + if ( $which & 2 ) { + $providers += $this->getPrimaryAuthenticationProviders(); + } + if ( $which & 4 ) { + $providers += $this->getSecondaryAuthenticationProviders(); + } + foreach ( $providers as $provider ) { + call_user_func_array( [ $provider, $method ], $args ); + } + } + + /** + * Reset the internal caching for unit testing + */ + public static function resetCache() { + if ( !defined( 'MW_PHPUNIT_TEST' ) ) { + // @codeCoverageIgnoreStart + throw new \MWException( __METHOD__ . ' may only be called from unit tests!' ); + // @codeCoverageIgnoreEnd + } + + self::$instance = null; + } + + /**@}*/ + +} + +/** + * For really cool vim folding this needs to be at the end: + * vim: foldmarker=@{,@} foldmethod=marker + */ diff --git a/includes/auth/AuthManagerAuthPlugin.php b/includes/auth/AuthManagerAuthPlugin.php new file mode 100644 index 0000000000..bf1e0215bc --- /dev/null +++ b/includes/auth/AuthManagerAuthPlugin.php @@ -0,0 +1,251 @@ +logger = \MediaWiki\Logger\LoggerFactory::getInstance( 'authentication' ); + } + + public function userExists( $name ) { + return AuthManager::singleton()->userExists( $name ); + } + + public function authenticate( $username, $password ) { + $data = [ + 'username' => $username, + 'password' => $password, + ]; + if ( $this->domain !== null && $this->domain !== '' ) { + $data['domain'] = $this->domain; + } + $reqs = AuthManager::singleton()->getAuthenticationRequests( AuthManager::ACTION_LOGIN ); + $reqs = AuthenticationRequest::loadRequestsFromSubmission( $reqs, $data ); + + $res = AuthManager::singleton()->beginAuthentication( $reqs, 'null:' ); + switch ( $res->status ) { + case AuthenticationResponse::PASS: + return true; + case AuthenticationResponse::FAIL: + // Hope it's not a PreAuthenticationProvider that failed... + $msg = $res->message instanceof \Message ? $res->message : new \Message( $res->message ); + $this->logger->info( __METHOD__ . ': Authentication failed: ' . $msg->plain() ); + return false; + default: + throw new \BadMethodCallException( + 'AuthManager does not support such simplified authentication' + ); + } + } + + public function modifyUITemplate( &$template, &$type ) { + // AuthManager does not support direct UI screwing-around-with + } + + public function setDomain( $domain ) { + $this->domain = $domain; + } + + public function getDomain() { + if ( isset( $this->domain ) ) { + return $this->domain; + } else { + return 'invaliddomain'; + } + } + + public function validDomain( $domain ) { + $domainList = $this->domainList(); + return $domainList ? in_array( $domain, $domainList, true ) : $domain === ''; + } + + public function updateUser( &$user ) { + \Hooks::run( 'UserLoggedIn', [ $user ] ); + return true; + } + + public function autoCreate() { + return true; + } + + public function allowPropChange( $prop = '' ) { + return AuthManager::singleton()->allowsPropertyChange( $prop ); + } + + public function allowPasswordChange() { + $reqs = AuthManager::singleton()->getAuthenticationRequests( AuthManager::ACTION_CHANGE ); + foreach ( $reqs as $req ) { + if ( $req instanceof PasswordAuthenticationRequest ) { + return true; + } + } + + return false; + } + + public function allowSetLocalPassword() { + // There should be a PrimaryAuthenticationProvider that does this, if necessary + return false; + } + + public function setPassword( $user, $password ) { + $data = [ + 'username' => $user->getName(), + 'password' => $password, + ]; + if ( $this->domain !== null && $this->domain !== '' ) { + $data['domain'] = $this->domain; + } + $reqs = AuthManager::singleton()->getAuthenticationRequests( AuthManager::ACTION_CHANGE ); + $reqs = AuthenticationRequest::loadRequestsFromSubmission( $reqs, $data ); + foreach ( $reqs as $req ) { + $status = AuthManager::singleton()->allowsAuthenticationDataChange( $req ); + if ( !$status->isOk() ) { + $this->logger->info( __METHOD__ . ': Password change rejected: {reason}', [ + 'username' => $data['username'], + 'reason' => $status->getWikiText( null, null, 'en' ), + ] ); + return false; + } + } + foreach ( $reqs as $req ) { + AuthManager::singleton()->changeAuthenticationData( $req ); + } + return true; + } + + public function updateExternalDB( $user ) { + // This fires the necessary hook + $user->saveSettings(); + return true; + } + + public function updateExternalDBGroups( $user, $addgroups, $delgroups = [] ) { + \Hooks::run( 'UserGroupsChanged', [ $user, $addgroups, $delgroups ] ); + return true; + } + + public function canCreateAccounts() { + return AuthManager::singleton()->canCreateAccounts(); + } + + public function addUser( $user, $password, $email = '', $realname = '' ) { + global $wgUser; + + $data = [ + 'username' => $user->getName(), + 'password' => $password, + 'retype' => $password, + 'email' => $email, + 'realname' => $realname, + ]; + if ( $this->domain !== null && $this->domain !== '' ) { + $data['domain'] = $this->domain; + } + $reqs = AuthManager::singleton()->getAuthenticationRequests( AuthManager::ACTION_CREATE ); + $reqs = AuthenticationRequest::loadRequestsFromSubmission( $reqs, $data ); + + $res = AuthManager::singleton()->beginAccountCreation( $wgUser, $reqs, 'null:' ); + switch ( $res->status ) { + case AuthenticationResponse::PASS: + return true; + case AuthenticationResponse::FAIL: + // Hope it's not a PreAuthenticationProvider that failed... + $msg = $res->message instanceof \Message ? $res->message : new \Message( $res->message ); + $this->logger->info( __METHOD__ . ': Authentication failed: ' . $msg->plain() ); + return false; + default: + throw new \BadMethodCallException( + 'AuthManager does not support such simplified account creation' + ); + } + } + + public function strict() { + // There should be a PrimaryAuthenticationProvider that does this, if necessary + return true; + } + + public function strictUserAuth( $username ) { + // There should be a PrimaryAuthenticationProvider that does this, if necessary + return true; + } + + public function initUser( &$user, $autocreate = false ) { + \Hooks::run( 'LocalUserCreated', [ $user, $autocreate ] ); + } + + public function getCanonicalName( $username ) { + // AuthManager doesn't support restrictions beyond MediaWiki's + return $username; + } + + public function getUserInstance( User &$user ) { + return new AuthManagerAuthPluginUser( $user ); + } + + public function domainList() { + return []; + } +} + +/** + * @since 1.27 + * @deprecated since 1.27 + */ +class AuthManagerAuthPluginUser extends \AuthPluginUser { + /** @var User */ + private $user; + + function __construct( $user ) { + $this->user = $user; + } + + public function getId() { + return $this->user->getId(); + } + + public function isLocked() { + return $this->user->isLocked(); + } + + public function isHidden() { + return $this->user->isHidden(); + } + + public function resetAuthToken() { + \MediaWiki\Session\SessionManager::singleton()->invalidateSessionsForUser( $this->user ); + return true; + } +} diff --git a/includes/auth/AuthPluginPrimaryAuthenticationProvider.php b/includes/auth/AuthPluginPrimaryAuthenticationProvider.php new file mode 100644 index 0000000000..9746637b00 --- /dev/null +++ b/includes/auth/AuthPluginPrimaryAuthenticationProvider.php @@ -0,0 +1,429 @@ +domainList() returns + * more than one domain, this must be a PasswordDomainAuthenticationRequest. + */ + public function __construct( AuthPlugin $auth, $requestType = null ) { + parent::__construct(); + + if ( $auth instanceof AuthManagerAuthPlugin ) { + throw new \InvalidArgumentException( + 'Trying to wrap AuthManagerAuthPlugin in AuthPluginPrimaryAuthenticationProvider ' . + 'makes no sense.' + ); + } + + $need = count( $auth->domainList() ) > 1 + ? PasswordDomainAuthenticationRequest::class + : PasswordAuthenticationRequest::class; + if ( $requestType === null ) { + $requestType = $need; + } elseif ( $requestType !== $need && !is_subclass_of( $requestType, $need ) ) { + throw new \InvalidArgumentException( "$requestType is not a $need" ); + } + + $this->auth = $auth; + $this->requestType = $requestType; + $this->hasDomain = ( + $requestType === PasswordDomainAuthenticationRequest::class || + is_subclass_of( $requestType, PasswordDomainAuthenticationRequest::class ) + ); + $this->authoritative = $auth->strict(); + + // Registering hooks from core is unusual, but is needed here to be + // able to call the AuthPlugin methods those hooks replace. + \Hooks::register( 'UserSaveSettings', [ $this, 'onUserSaveSettings' ] ); + \Hooks::register( 'UserGroupsChanged', [ $this, 'onUserGroupsChanged' ] ); + \Hooks::register( 'UserLoggedIn', [ $this, 'onUserLoggedIn' ] ); + \Hooks::register( 'LocalUserCreated', [ $this, 'onLocalUserCreated' ] ); + } + + /** + * Create an appropriate AuthenticationRequest + * @return PasswordAuthenticationRequest + */ + protected function makeAuthReq() { + $class = $this->requestType; + if ( $this->hasDomain ) { + return new $class( $this->auth->domainList() ); + } else { + return new $class(); + } + } + + /** + * Call $this->auth->setDomain() + * @param PasswordAuthenticationRequest $req + */ + protected function setDomain( $req ) { + if ( $this->hasDomain ) { + $domain = $req->domain; + } else { + // Just grab the first one. + $domainList = $this->auth->domainList(); + $domain = reset( $domainList ); + } + + // Special:UserLogin does this. Strange. + if ( !$this->auth->validDomain( $domain ) ) { + $domain = $this->auth->getDomain(); + } + $this->auth->setDomain( $domain ); + } + + /** + * Hook function to call AuthPlugin::updateExternalDB() + * @param User $user + * @codeCoverageIgnore + */ + public function onUserSaveSettings( $user ) { + // No way to know the domain, just hope the provider handles that. + $this->auth->updateExternalDB( $user ); + } + + /** + * Hook function to call AuthPlugin::updateExternalDBGroups() + * @param User $user + * @param array $added + * @param array $removed + */ + public function onUserGroupsChanged( $user, $added, $removed ) { + // No way to know the domain, just hope the provider handles that. + $this->auth->updateExternalDBGroups( $user, $added, $removed ); + } + + /** + * Hook function to call AuthPlugin::updateUser() + * @param User $user + */ + public function onUserLoggedIn( $user ) { + $hookUser = $user; + // No way to know the domain, just hope the provider handles that. + $this->auth->updateUser( $hookUser ); + if ( $hookUser !== $user ) { + throw new \UnexpectedValueException( + get_class( $this->auth ) . '::updateUser() tried to replace $user!' + ); + } + } + + /** + * Hook function to call AuthPlugin::initUser() + * @param User $user + * @param bool $autocreated + */ + public function onLocalUserCreated( $user, $autocreated ) { + // For $autocreated, see self::autoCreatedAccount() + if ( !$autocreated ) { + $hookUser = $user; + // No way to know the domain, just hope the provider handles that. + $this->auth->initUser( $hookUser, $autocreated ); + if ( $hookUser !== $user ) { + throw new \UnexpectedValueException( + get_class( $this->auth ) . '::initUser() tried to replace $user!' + ); + } + } + } + + public function getUniqueId() { + return parent::getUniqueId() . ':' . get_class( $this->auth ); + } + + public function getAuthenticationRequests( $action, array $options ) { + switch ( $action ) { + case AuthManager::ACTION_LOGIN: + case AuthManager::ACTION_CREATE: + return [ $this->makeAuthReq() ]; + + case AuthManager::ACTION_CHANGE: + case AuthManager::ACTION_REMOVE: + // No way to know the domain, just hope the provider handles that. + return $this->auth->allowPasswordChange() ? [ $this->makeAuthReq() ] : []; + + default: + return []; + } + } + + public function beginPrimaryAuthentication( array $reqs ) { + $req = AuthenticationRequest::getRequestByClass( $reqs, $this->requestType ); + if ( !$req || $req->username === null || $req->password === null || + ( $this->hasDomain && $req->domain === null ) + ) { + return AuthenticationResponse::newAbstain(); + } + + $username = User::getCanonicalName( $req->username, 'usable' ); + if ( $username === false ) { + return AuthenticationResponse::newAbstain(); + } + + $this->setDomain( $req ); + if ( $this->testUserCanAuthenticateInternal( User::newFromName( $username ) ) && + $this->auth->authenticate( $username, $req->password ) + ) { + return AuthenticationResponse::newPass( $username ); + } else { + $this->authoritative = $this->auth->strict() || $this->auth->strictUserAuth( $username ); + return $this->failResponse( $req ); + } + } + + public function testUserCanAuthenticate( $username ) { + $username = User::getCanonicalName( $username, 'usable' ); + if ( $username === false ) { + return false; + } + + // We have to check every domain, because at least LdapAuthentication + // interprets AuthPlugin::userExists() as applying only to the current + // domain. + $curDomain = $this->auth->getDomain(); + $domains = $this->auth->domainList() ?: [ '' ]; + foreach ( $domains as $domain ) { + $this->auth->setDomain( $domain ); + if ( $this->testUserCanAuthenticateInternal( User::newFromName( $username ) ) ) { + $this->auth->setDomain( $curDomain ); + return true; + } + } + $this->auth->setDomain( $curDomain ); + return false; + } + + /** + * @see self::testUserCanAuthenticate + * @note The caller is responsible for calling $this->auth->setDomain() + * @param User $user + * @return bool + */ + private function testUserCanAuthenticateInternal( $user ) { + if ( $this->auth->userExists( $user->getName() ) ) { + return !$this->auth->getUserInstance( $user )->isLocked(); + } else { + return false; + } + } + + public function providerRevokeAccessForUser( $username ) { + $username = User::getCanonicalName( $username, 'usable' ); + if ( $username === false ) { + return; + } + $user = User::newFromName( $username ); + if ( $user ) { + // Reset the password on every domain. + $curDomain = $this->auth->getDomain(); + $domains = $this->auth->domainList() ?: [ '' ]; + $failed = []; + foreach ( $domains as $domain ) { + $this->auth->setDomain( $domain ); + if ( $this->testUserCanAuthenticateInternal( $user ) && + !$this->auth->setPassword( $user, null ) + ) { + $failed[] = $domain === '' ? '(default)' : $domain; + } + } + $this->auth->setDomain( $curDomain ); + if ( $failed ) { + throw new \UnexpectedValueException( + "AuthPlugin failed to reset password for $username in the following domains: " + . join( ' ', $failed ) + ); + } + } + } + + public function testUserExists( $username, $flags = User::READ_NORMAL ) { + $username = User::getCanonicalName( $username, 'usable' ); + if ( $username === false ) { + return false; + } + + // We have to check every domain, because at least LdapAuthentication + // interprets AuthPlugin::userExists() as applying only to the current + // domain. + $curDomain = $this->auth->getDomain(); + $domains = $this->auth->domainList() ?: [ '' ]; + foreach ( $domains as $domain ) { + $this->auth->setDomain( $domain ); + if ( $this->auth->userExists( $username ) ) { + $this->auth->setDomain( $curDomain ); + return true; + } + } + $this->auth->setDomain( $curDomain ); + return false; + } + + public function providerAllowsPropertyChange( $property ) { + // No way to know the domain, just hope the provider handles that. + return $this->auth->allowPropChange( $property ); + } + + public function providerAllowsAuthenticationDataChange( + AuthenticationRequest $req, $checkData = true + ) { + if ( get_class( $req ) !== $this->requestType ) { + return \StatusValue::newGood( 'ignored' ); + } + + // Hope it works, AuthPlugin gives us no way to do this. + $curDomain = $this->auth->getDomain(); + $this->setDomain( $req ); + try { + // If !$checkData the domain might be wrong. Nothing we can do about that. + if ( !$this->auth->allowPasswordChange() ) { + return \StatusValue::newFatal( 'authmanager-authplugin-setpass-denied' ); + } + + if ( !$checkData ) { + return \StatusValue::newGood(); + } + + if ( $this->hasDomain ) { + if ( $req->domain === null ) { + return \StatusValue::newGood( 'ignored' ); + } + if ( !$this->auth->validDomain( $domain ) ) { + return \StatusValue::newFatal( 'authmanager-authplugin-setpass-bad-domain' ); + } + } + + $username = User::getCanonicalName( $req->username, 'usable' ); + if ( $username !== false ) { + $sv = \StatusValue::newGood(); + if ( $req->password !== null ) { + if ( $req->password !== $req->retype ) { + $sv->fatal( 'badretype' ); + } else { + $sv->merge( $this->checkPasswordValidity( $username, $req->password ) ); + } + } + return $sv; + } else { + return \StatusValue::newGood( 'ignored' ); + } + } finally { + $this->auth->setDomain( $curDomain ); + } + } + + public function providerChangeAuthenticationData( AuthenticationRequest $req ) { + if ( get_class( $req ) === $this->requestType ) { + $username = $req->username !== null ? User::getCanonicalName( $req->username, 'usable' ) : false; + if ( $username === false ) { + return; + } + + if ( $this->hasDomain && $req->domain === null ) { + return; + } + + $this->setDomain( $req ); + $user = User::newFromName( $username ); + if ( !$this->auth->setPassword( $user, $req->password ) ) { + // This is totally unfriendly and leaves other + // AuthenticationProviders in an uncertain state, but what else + // can we do? + throw new \ErrorPageError( + 'authmanager-authplugin-setpass-failed-title', + 'authmanager-authplugin-setpass-failed-message' + ); + } + } + } + + public function accountCreationType() { + // No way to know the domain, just hope the provider handles that. + return $this->auth->canCreateAccounts() ? self::TYPE_CREATE : self::TYPE_NONE; + } + + public function testForAccountCreation( $user, $creator, array $reqs ) { + return \StatusValue::newGood(); + } + + public function beginPrimaryAccountCreation( $user, $creator, array $reqs ) { + if ( $this->accountCreationType() === self::TYPE_NONE ) { + throw new \BadMethodCallException( 'Shouldn\'t call this when accountCreationType() is NONE' ); + } + + $req = AuthenticationRequest::getRequestByClass( $reqs, $this->requestType ); + if ( !$req || $req->username === null || $req->password === null || + ( $this->hasDomain && $req->domain === null ) + ) { + return AuthenticationResponse::newAbstain(); + } + + $username = User::getCanonicalName( $req->username, 'usable' ); + if ( $username === false ) { + return AuthenticationResponse::newAbstain(); + } + + $this->setDomain( $req ); + if ( $this->auth->addUser( + $user, $req->password, $user->getEmail(), $user->getRealName() + ) ) { + return AuthenticationResponse::newPass(); + } else { + return AuthenticationResponse::newFail( + new \Message( 'authmanager-authplugin-create-fail' ) + ); + } + } + + public function autoCreatedAccount( $user, $source ) { + $hookUser = $user; + // No way to know the domain, just hope the provider handles that. + $this->auth->initUser( $hookUser, true ); + if ( $hookUser !== $user ) { + throw new \UnexpectedValueException( + get_class( $this->auth ) . '::initUser() tried to replace $user!' + ); + } + } +} diff --git a/includes/auth/AuthenticationProvider.php b/includes/auth/AuthenticationProvider.php new file mode 100644 index 0000000000..4db0a84be9 --- /dev/null +++ b/includes/auth/AuthenticationProvider.php @@ -0,0 +1,93 @@ + + * - password: + * - select: + * - multiselect: More a grid of checkboxes than if 'image' is set, otherwise + * (uses 'label' as button text) + * - hidden: Not visible to the user, but needs to be preserved for the next request + * - null: No widget, just display the 'label' message. + * - options: (array) Maps option values to Messages for the + * 'select' and 'multiselect' types. + * - value: (string) Value (for 'null' and 'hidden') or default value (for other types). + * - image: (string) URL of an image to use in connection with the input + * - label: (Message) Text suitable for a label in an HTML form + * - help: (Message) Text suitable as a description of what the field is + * - optional: (bool) If set and truthy, the field may be left empty + * + * @return array As above + */ + abstract public function getFieldInfo(); + + /** + * Returns metadata about this request. + * + * This is mainly for the benefit of API clients which need more detailed render hints + * than what's available through getFieldInfo(). Semantics are unspecified and left to the + * individual subclasses, but the contents of the array should be primitive types so that they + * can be transformed into JSON or similar formats. + * + * @return array A (possibly nested) array with primitive types + */ + public function getMetadata() { + return []; + } + + /** + * Initialize form submitted form data. + * + * Should always return false if self::getFieldInfo() returns an empty + * array. + * + * @param array $data Submitted data as an associative array + * @return bool Whether the request data was successfully loaded + */ + public function loadFromSubmission( array $data ) { + $fields = array_filter( $this->getFieldInfo(), function ( $info ) { + return $info['type'] !== 'null'; + } ); + if ( !$fields ) { + return false; + } + + foreach ( $fields as $field => $info ) { + // Checkboxes and buttons are special. Depending on the method used + // to populate $data, they might be unset meaning false or they + // might be boolean. Further, image buttons might submit the + // coordinates of the click rather than the expected value. + if ( $info['type'] === 'checkbox' || $info['type'] === 'button' ) { + $this->$field = isset( $data[$field] ) && $data[$field] !== false + || isset( $data["{$field}_x"] ) && $data["{$field}_x"] !== false; + if ( !$this->$field && empty( $info['optional'] ) ) { + return false; + } + continue; + } + + // Multiselect are too, slightly + if ( !isset( $data[$field] ) && $info['type'] === 'multiselect' ) { + $data[$field] = []; + } + + if ( !isset( $data[$field] ) ) { + return false; + } + if ( $data[$field] === '' || $data[$field] === [] ) { + if ( empty( $info['optional'] ) ) { + return false; + } + } else { + switch ( $info['type'] ) { + case 'select': + if ( !isset( $info['options'][$data[$field]] ) ) { + return false; + } + break; + + case 'multiselect': + $data[$field] = (array)$data[$field]; + $allowed = array_keys( $info['options'] ); + if ( array_diff( $data[$field], $allowed ) !== [] ) { + return false; + } + break; + } + } + + $this->$field = $data[$field]; + } + + return true; + } + + /** + * Describe the credentials represented by this request + * + * This is used on requests returned by + * AuthenticationProvider::getAuthenticationRequests() for ACTION_LINK + * and ACTION_REMOVE and for requests returned in + * AuthenticationResponse::$linkRequest to create useful user interfaces. + * + * @return Message[] with the following keys: + * - provider: A Message identifying the service that provides + * the credentials, e.g. the name of the third party authentication + * service. + * - account: A Message identifying the credentials themselves, + * e.g. the email address used with the third party authentication + * service. + */ + public function describeCredentials() { + return [ + 'provider' => new \RawMessage( '$1', [ get_called_class() ] ), + 'account' => new \RawMessage( '$1', [ $this->getUniqueId() ] ), + ]; + } + + /** + * Update a set of requests with form submit data, discarding ones that fail + * @param AuthenticationRequest[] $reqs + * @param array $data + * @return AuthenticationRequest[] + */ + public static function loadRequestsFromSubmission( array $reqs, array $data ) { + return array_values( array_filter( $reqs, function ( $req ) use ( $data ) { + return $req->loadFromSubmission( $data ); + } ) ); + } + + /** + * Select a request by class name. + * @param AuthenticationRequest[] $reqs + * @param string $class Class name + * @param bool $allowSubclasses If true, also returns any request that's a subclass of the given + * class. + * @return AuthenticationRequest|null Returns null if there is not exactly + * one matching request. + */ + public static function getRequestByClass( array $reqs, $class, $allowSubclasses = false ) { + $requests = array_filter( $reqs, function ( $req ) use ( $class, $allowSubclasses ) { + if ( $allowSubclasses ) { + return is_a( $req, $class, false ); + } else { + return get_class( $req ) === $class; + } + } ); + return count( $requests ) === 1 ? reset( $requests ) : null; + } + + /** + * Get the username from the set of requests + * + * Only considers requests that have a "username" field. + * + * @param AuthenticationRequest[] $requests + * @return string|null + * @throws \UnexpectedValueException If multiple different usernames are present. + */ + public static function getUsernameFromRequests( array $reqs ) { + $username = null; + $otherClass = null; + foreach ( $reqs as $req ) { + $info = $req->getFieldInfo(); + if ( $info && array_key_exists( 'username', $info ) && $req->username !== null ) { + if ( $username === null ) { + $username = $req->username; + $otherClass = get_class( $req ); + } elseif ( $username !== $req->username ) { + $requestClass = get_class( $req ); + throw new \UnexpectedValueException( "Conflicting username fields: \"{$req->username}\" from " + . "$requestClass::\$username vs. \"$username\" from $otherClass::\$username" ); + } + } + } + return $username; + } + + /** + * Merge the output of multiple AuthenticationRequest::getFieldInfo() calls. + * @param AuthenticationRequest[] $reqs + * @return array + * @throws \UnexpectedValueException If fields cannot be merged + */ + public static function mergeFieldInfo( array $reqs ) { + $merged = []; + + foreach ( $reqs as $req ) { + $info = $req->getFieldInfo(); + if ( !$info ) { + continue; + } + + foreach ( $info as $name => $options ) { + if ( $req->required !== self::REQUIRED ) { + // If the request isn't required, its fields aren't required either. + $options['optional'] = true; + } else { + $options['optional'] = !empty( $options['optional'] ); + } + + if ( !array_key_exists( $name, $merged ) ) { + $merged[$name] = $options; + } elseif ( $merged[$name]['type'] !== $options['type'] ) { + throw new \UnexpectedValueException( "Field type conflict for \"$name\", " . + "\"{$merged[$name]['type']}\" vs \"{$options['type']}\"" + ); + } else { + if ( isset( $options['options'] ) ) { + if ( isset( $merged[$name]['options'] ) ) { + $merged[$name]['options'] += $options['options']; + } else { + // @codeCoverageIgnoreStart + $merged[$name]['options'] = $options['options']; + // @codeCoverageIgnoreEnd + } + } + + $merged[$name]['optional'] = $merged[$name]['optional'] && $options['optional']; + + // No way to merge 'value', 'image', 'help', or 'label', so just use + // the value from the first request. + } + } + } + + return $merged; + } + + /** + * Implementing this mainly for use from the unit tests. + * @param array $data + * @return AuthenticationRequest + */ + public static function __set_state( $data ) { + $ret = new static(); + foreach ( $data as $k => $v ) { + $ret->$k = $v; + } + return $ret; + } +} diff --git a/includes/auth/AuthenticationResponse.php b/includes/auth/AuthenticationResponse.php new file mode 100644 index 0000000000..db0182552d --- /dev/null +++ b/includes/auth/AuthenticationResponse.php @@ -0,0 +1,190 @@ +createRequest to + * AuthManager::beginCreateAccount(). + */ + const RESTART = 'RESTART'; + + /** Indicates that the authentication provider does not handle this request. */ + const ABSTAIN = 'ABSTAIN'; + + /** Indicates that the authentication needs further user input of some sort. */ + const UI = 'UI'; + + /** Indicates that the authentication needs to be redirected to a third party to proceed. */ + const REDIRECT = 'REDIRECT'; + + /** @var string One of the constants above */ + public $status; + + /** @var string|null URL to redirect to for a REDIRECT response */ + public $redirectTarget = null; + + /** + * @var mixed Data for a REDIRECT response that a client might use to + * query the remote site via its API rather than by following $redirectTarget. + * Value must be something acceptable to ApiResult::addValue(). + */ + public $redirectApiData = null; + + /** + * @var AuthenticationRequest[] Needed AuthenticationRequests to continue + * after a UI or REDIRECT response + */ + public $neededRequests = []; + + /** @var Message|null I18n message to display in case of UI or FAIL */ + public $message = null; + + /** + * @var string|null Local user name from authentication. + * May be null if the authentication passed but no local user is known. + */ + public $username = null; + + /** + * @var AuthenticationRequest|null + * + * Returned with a PrimaryAuthenticationProvider login FAIL, this holds a + * request that should result in a PASS when passed to that provider's + * PrimaryAuthenticationProvider::beginPrimaryAccountCreation(). + * + * Returned with an AuthManager login FAIL or RESTART, this holds a request + * that may be passed to AuthManager::beginCreateAccount() after setting + * its ->returnToUrl property. It may also be passed to + * AuthManager::beginAuthentication() to preserve state. + */ + public $createRequest = null; + + /** + * @var AuthenticationRequest|null Returned with a PrimaryAuthenticationProvider + * login PASS with no username, this holds a request to pass to + * AuthManager::changeAuthenticationData() to link the account once the + * local user has been determined. + */ + public $linkRequest = null; + + /** + * @var AuthenticationRequest|null Returned with an AuthManager account + * creation PASS, this holds a request to pass to AuthManager::beginAuthentication() + * to immediately log into the created account. + */ + public $loginRequest = null; + + /** + * @param string|null $username Local username + * @return AuthenticationResponse + */ + public static function newPass( $username = null ) { + $ret = new AuthenticationResponse; + $ret->status = AuthenticationResponse::PASS; + $ret->username = $username; + return $ret; + } + + /** + * @param Message $msg + * @return AuthenticationResponse + */ + public static function newFail( Message $msg ) { + $ret = new AuthenticationResponse; + $ret->status = AuthenticationResponse::FAIL; + $ret->message = $msg; + return $ret; + } + + /** + * @param Message $msg + * @return AuthenticationResponse + */ + public static function newRestart( Message $msg ) { + $ret = new AuthenticationResponse; + $ret->status = AuthenticationResponse::RESTART; + $ret->message = $msg; + return $ret; + } + + /** + * @return AuthenticationResponse + */ + public static function newAbstain() { + $ret = new AuthenticationResponse; + $ret->status = AuthenticationResponse::ABSTAIN; + return $ret; + } + + /** + * @param AuthenticationRequest[] $reqs AuthenticationRequests needed to continue + * @param Message $msg + * @return AuthenticationResponse + */ + public static function newUI( array $reqs, Message $msg ) { + if ( !$reqs ) { + throw new \InvalidArgumentException( '$reqs may not be empty' ); + } + + $ret = new AuthenticationResponse; + $ret->status = AuthenticationResponse::UI; + $ret->neededRequests = $reqs; + $ret->message = $msg; + return $ret; + } + + /** + * @param AuthenticationRequest[] $reqs AuthenticationRequests needed to continue + * @param string $redirectTarget URL + * @param mixed $redirectApiData Data suitable for adding to an ApiResult + * @return AuthenticationResponse + */ + public static function newRedirect( array $reqs, $redirectTarget, $redirectApiData = null ) { + if ( !$reqs ) { + throw new \InvalidArgumentException( '$reqs may not be empty' ); + } + + $ret = new AuthenticationResponse; + $ret->status = AuthenticationResponse::REDIRECT; + $ret->neededRequests = $reqs; + $ret->redirectTarget = $redirectTarget; + $ret->redirectApiData = $redirectApiData; + return $ret; + } + +} diff --git a/includes/auth/ButtonAuthenticationRequest.php b/includes/auth/ButtonAuthenticationRequest.php new file mode 100644 index 0000000000..055d7ea49f --- /dev/null +++ b/includes/auth/ButtonAuthenticationRequest.php @@ -0,0 +1,106 @@ +name = $name; + $this->label = $label; + $this->help = $help; + $this->required = $required ? self::REQUIRED : self::OPTIONAL; + } + + public function getUniqueId() { + return parent::getUniqueId() . ':' . $this->name; + } + + public function getFieldInfo() { + return [ + $this->name => [ + 'type' => 'button', + 'label' => $this->label, + 'help' => $this->help, + ] + ]; + } + + /** + * Fetch a ButtonAuthenticationRequest or subclass by name + * @param AuthenticationRequest[] $reqs Requests to search + * @param string $name Name to look for + * @return ButtonAuthenticationRequest|null Returns null if there is not + * exactly one matching request. + */ + public static function getRequestByName( array $reqs, $name ) { + $requests = array_filter( $reqs, function ( $req ) use ( $name ) { + return $req instanceof ButtonAuthenticationRequest && $req->name === $name; + } ); + return count( $requests ) === 1 ? reset( $requests ) : null; + } + + /** + * @codeCoverageIgnore + */ + public static function __set_state( $data ) { + if ( !isset( $data['label'] ) ) { + $data['label'] = new \RawMessage( '$1', $data['name'] ); + } elseif ( is_string( $data['label'] ) ) { + $data['label'] = new \Message( $data['label'] ); + } elseif ( is_array( $data['label'] ) ) { + $data['label'] = call_user_func_array( 'Message::newFromKey', $data['label'] ); + } + if ( !isset( $data['help'] ) ) { + $data['help'] = new \RawMessage( '$1', $data['name'] ); + } elseif ( is_string( $data['help'] ) ) { + $data['help'] = new \Message( $data['help'] ); + } elseif ( is_array( $data['help'] ) ) { + $data['help'] = call_user_func_array( 'Message::newFromKey', $data['help'] ); + } + $ret = new static( $data['name'], $data['label'], $data['help'] ); + foreach ( $data as $k => $v ) { + $ret->$k = $v; + } + return $ret; + } +} diff --git a/includes/auth/CheckBlocksSecondaryAuthenticationProvider.php b/includes/auth/CheckBlocksSecondaryAuthenticationProvider.php new file mode 100644 index 0000000000..070da9f542 --- /dev/null +++ b/includes/auth/CheckBlocksSecondaryAuthenticationProvider.php @@ -0,0 +1,102 @@ +blockDisablesLogin = (bool)$params['blockDisablesLogin']; + } + } + + public function setConfig( Config $config ) { + parent::setConfig( $config ); + + if ( $this->blockDisablesLogin === null ) { + $this->blockDisablesLogin = $this->config->get( 'BlockDisablesLogin' ); + } + } + + public function getAuthenticationRequests( $action, array $options ) { + return []; + } + + public function beginSecondaryAuthentication( $user, array $reqs ) { + if ( !$this->blockDisablesLogin ) { + return AuthenticationResponse::newAbstain(); + } elseif ( $user->isBlocked() ) { + return AuthenticationResponse::newFail( + new \Message( 'login-userblocked', [ $user->getName() ] ) + ); + } else { + return AuthenticationResponse::newPass(); + } + } + + public function beginSecondaryAccountCreation( $user, $creator, array $reqs ) { + return AuthenticationResponse::newAbstain(); + } + + public function testUserForCreation( $user, $autocreate ) { + $block = $user->isBlockedFromCreateAccount(); + if ( $block ) { + $errorParams = [ + $block->getTarget(), + $block->mReason ?: \Message::newFromKey( 'blockednoreason' )->text(), + $block->getByName() + ]; + + if ( $block->getType() === \Block::TYPE_RANGE ) { + $errorMessage = 'cantcreateaccount-range-text'; + $errorParams[] = $this->manager->getRequest()->getIP(); + } else { + $errorMessage = 'cantcreateaccount-text'; + } + + return StatusValue::newFatal( + new \Message( $errorMessage, $errorParams ) + ); + } else { + return StatusValue::newGood(); + } + } + +} diff --git a/includes/auth/ConfirmLinkAuthenticationRequest.php b/includes/auth/ConfirmLinkAuthenticationRequest.php new file mode 100644 index 0000000000..b82914f53a --- /dev/null +++ b/includes/auth/ConfirmLinkAuthenticationRequest.php @@ -0,0 +1,80 @@ +linkRequests = $linkRequests; + } + + public function getFieldInfo() { + $options = []; + foreach ( $this->linkRequests as $req ) { + $description = $req->describeCredentials(); + $options[$req->getUniqueId()] = wfMessage( + 'authprovider-confirmlink-option', + $description['provider']->text(), $description['account']->text() + ); + } + return [ + 'confirmedLinkIDs' => [ + 'type' => 'multiselect', + 'options' => $options, + 'label' => wfMessage( 'authprovider-confirmlink-request-label' ), + 'help' => wfMessage( 'authprovider-confirmlink-request-help' ), + 'optional' => true, + ] + ]; + } + + public function getUniqueId() { + return parent::getUniqueId() . ':' . implode( '|', array_map( function ( $req ) { + return $req->getUniqueId(); + }, $this->linkRequests ) ); + } + + /** + * Implementing this mainly for use from the unit tests. + * @param array $data + * @return AuthenticationRequest + */ + public static function __set_state( $data ) { + $ret = new static( $data['linkRequests'] ); + foreach ( $data as $k => $v ) { + $ret->$k = $v; + } + return $ret; + } +} diff --git a/includes/auth/ConfirmLinkSecondaryAuthenticationProvider.php b/includes/auth/ConfirmLinkSecondaryAuthenticationProvider.php new file mode 100644 index 0000000000..180aaae34e --- /dev/null +++ b/includes/auth/ConfirmLinkSecondaryAuthenticationProvider.php @@ -0,0 +1,150 @@ +beginLinkAttempt( $user, 'AuthManager::authnState' ); + } + + public function continueSecondaryAuthentication( $user, array $reqs ) { + return $this->continueLinkAttempt( $user, 'AuthManager::authnState', $reqs ); + } + + public function beginSecondaryAccountCreation( $user, $creator, array $reqs ) { + return $this->beginLinkAttempt( $user, 'AuthManager::accountCreationState' ); + } + + public function continueSecondaryAccountCreation( $user, $creator, array $reqs ) { + return $this->continueLinkAttempt( $user, 'AuthManager::accountCreationState', $reqs ); + } + + /** + * Begin the link attempt + * @param User $user + * @param string $key Session key to look in + * @return AuthenticationResponse + */ + protected function beginLinkAttempt( $user, $key ) { + $session = $this->manager->getRequest()->getSession(); + $state = $session->getSecret( $key ); + if ( !is_array( $state ) ) { + return AuthenticationResponse::newAbstain(); + } + $maybeLink = $state['maybeLink']; + if ( !$maybeLink ) { + return AuthenticationResponse::newAbstain(); + } + + $req = new ConfirmLinkAuthenticationRequest( $maybeLink ); + return AuthenticationResponse::newUI( + [ $req ], + wfMessage( 'authprovider-confirmlink-message' ) + ); + } + + /** + * Continue the link attempt + * @param User $user + * @param string $key Session key to look in + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse + */ + protected function continueLinkAttempt( $user, $key, array $reqs ) { + $req = ButtonAuthenticationRequest::getRequestByName( $reqs, 'linkOk' ); + if ( $req ) { + return AuthenticationResponse::newPass(); + } + + $req = AuthenticationRequest::getRequestByClass( $reqs, ConfirmLinkAuthenticationRequest::class ); + if ( !$req ) { + // WTF? Retry. + return $this->beginLinkAttempt( $user, $key ); + } + + $session = $this->manager->getRequest()->getSession(); + $state = $session->getSecret( $key ); + if ( !is_array( $state ) ) { + return AuthenticationResponse::newAbstain(); + } + + $maybeLink = []; + foreach ( $state['maybeLink'] as $linkReq ) { + $maybeLink[$linkReq->getUniqueId()] = $linkReq; + } + if ( !$maybeLink ) { + return AuthenticationResponse::newAbstain(); + } + + $state['maybeLink'] = []; + $session->setSecret( $key, $state ); + + $statuses = []; + $anyFailed = false; + foreach ( $req->confirmedLinkIDs as $id ) { + if ( isset( $maybeLink[$id] ) ) { + $req = $maybeLink[$id]; + $req->username = $user->getName(); + if ( !$req->action ) { + // Make sure the action is set, but don't override it if + // the provider filled it in. + $req->action = AuthManager::ACTION_CHANGE; + } + $status = $this->manager->allowsAuthenticationDataChange( $req ); + $statuses[] = [ $req, $status ]; + if ( $status->isGood() ) { + $this->manager->changeAuthenticationData( $req ); + } else { + $anyFailed = true; + } + } + } + if ( !$anyFailed ) { + return AuthenticationResponse::newPass(); + } + + $combinedStatus = \Status::newGood(); + foreach ( $statuses as $data ) { + list( $req, $status ) = $data; + $descriptionInfo = $req->describeCredentials(); + $description = wfMessage( + 'authprovider-confirmlink-option', + $descriptionInfo['provider']->text(), $descriptionInfo['account']->text() + )->text(); + if ( $status->isGood() ) { + $combinedStatus->error( wfMessage( 'authprovider-confirmlink-success-line', $description ) ); + } else { + $combinedStatus->error( wfMessage( + 'authprovider-confirmlink-failure-line', $description, $status->getMessage()->text() + ) ); + } + } + return AuthenticationResponse::newUI( + [ + new ButtonAuthenticationRequest( + 'linkOk', wfMessage( 'ok' ), wfMessage( 'authprovider-confirmlink-ok-help' ) + ) + ], + $combinedStatus->getMessage( 'authprovider-confirmlink-failed' ) + ); + } +} diff --git a/includes/auth/CreateFromLoginAuthenticationRequest.php b/includes/auth/CreateFromLoginAuthenticationRequest.php new file mode 100644 index 0000000000..949302d8bc --- /dev/null +++ b/includes/auth/CreateFromLoginAuthenticationRequest.php @@ -0,0 +1,62 @@ +createRequest = $createRequest; + $this->maybeLink = $maybeLink; + } + + public function getFieldInfo() { + return []; + } + + public function loadFromSubmission( array $data ) { + return true; + } +} diff --git a/includes/auth/CreatedAccountAuthenticationRequest.php b/includes/auth/CreatedAccountAuthenticationRequest.php new file mode 100644 index 0000000000..48a6e1d36c --- /dev/null +++ b/includes/auth/CreatedAccountAuthenticationRequest.php @@ -0,0 +1,48 @@ +id = $id; + $this->username = $name; + } +} diff --git a/includes/auth/CreationReasonAuthenticationRequest.php b/includes/auth/CreationReasonAuthenticationRequest.php new file mode 100644 index 0000000000..1711aec974 --- /dev/null +++ b/includes/auth/CreationReasonAuthenticationRequest.php @@ -0,0 +1,22 @@ + [ + 'type' => 'string', + 'label' => wfMessage( 'createacct-reason' ), + 'help' => wfMessage( 'createacct-reason-help' ), + ], + ]; + } +} diff --git a/includes/auth/LegacyHookPreAuthenticationProvider.php b/includes/auth/LegacyHookPreAuthenticationProvider.php new file mode 100644 index 0000000000..1a8a75892d --- /dev/null +++ b/includes/auth/LegacyHookPreAuthenticationProvider.php @@ -0,0 +1,202 @@ +username ); + $password = $req->password; + } else { + $user = null; + foreach ( $reqs as $req ) { + if ( $req->username !== null ) { + $user = User::newFromName( $req->username ); + break; + } + } + if ( !$user ) { + $this->logger->debug( __METHOD__ . ': No username in $reqs, skipping hooks' ); + return StatusValue::newGood(); + } + + // Something random for the 'AbortLogin' hook. + $password = wfRandomString( 32 ); + } + + $msg = null; + if ( !\Hooks::run( 'LoginUserMigrated', [ $user, &$msg ] ) ) { + return $this->makeFailResponse( + $user, null, LoginForm::USER_MIGRATED, $msg, 'LoginUserMigrated' + ); + } + + $abort = LoginForm::ABORTED; + $msg = null; + if ( !\Hooks::run( 'AbortLogin', [ $user, $password, &$abort, &$msg ] ) ) { + return $this->makeFailResponse( $user, null, $abort, $msg, 'AbortLogin' ); + } + + return StatusValue::newGood(); + } + + public function testForAccountCreation( $user, $creator, array $reqs ) { + $abortError = ''; + $abortStatus = null; + if ( !\Hooks::run( 'AbortNewAccount', [ $user, &$abortError, &$abortStatus ] ) ) { + // Hook point to add extra creation throttles and blocks + $this->logger->debug( __METHOD__ . ': a hook blocked creation' ); + if ( $abortStatus === null ) { + // Report back the old string as a raw message status. + // This will report the error back as 'createaccount-hook-aborted' + // with the given string as the message. + // To return a different error code, return a StatusValue object. + $msg = wfMessage( 'createaccount-hook-aborted' )->rawParams( $abortError ); + return StatusValue::newFatal( $msg ); + } else { + // For MediaWiki 1.23+ and updated hooks, return the Status object + // returned from the hook. + $ret = StatusValue::newGood(); + $ret->merge( $abortStatus ); + return $ret; + } + } + + return StatusValue::newGood(); + } + + public function testUserForCreation( $user, $autocreate ) { + if ( $autocreate !== false ) { + $abortError = ''; + if ( !\Hooks::run( 'AbortAutoAccount', [ $user, &$abortError ] ) ) { + // Hook point to add extra creation throttles and blocks + $this->logger->debug( __METHOD__ . ": a hook blocked auto-creation: $abortError\n" ); + return $this->makeFailResponse( + $user, $user, LoginForm::ABORTED, $abortError, 'AbortAutoAccount' + ); + } + } else { + $abortError = ''; + $abortStatus = null; + if ( !\Hooks::run( 'AbortNewAccount', [ $user, &$abortError, &$abortStatus ] ) ) { + // Hook point to add extra creation throttles and blocks + $this->logger->debug( __METHOD__ . ': a hook blocked creation' ); + if ( $abortStatus === null ) { + // Report back the old string as a raw message status. + // This will report the error back as 'createaccount-hook-aborted' + // with the given string as the message. + // To return a different error code, return a StatusValue object. + $msg = wfMessage( 'createaccount-hook-aborted' )->rawParams( $abortError ); + return StatusValue::newFatal( $msg ); + } else { + // For MediaWiki 1.23+ and updated hooks, return the Status object + // returned from the hook. + $ret = StatusValue::newGood(); + $ret->merge( $abortStatus ); + return $ret; + } + } + } + + return StatusValue::newGood(); + } + + /** + * Construct an appropriate failure response + * @param User $user + * @param User|null $creator + * @param int $constant LoginForm constant + * @param string|null $msg Message + * @param string $hook Hook + * @return StatusValue + */ + protected function makeFailResponse( $user, $creator, $constant, $msg, $hook ) { + switch ( $constant ) { + case LoginForm::SUCCESS: + // WTF? + $this->logger->debug( "$hook is SUCCESS?!" ); + return StatusValue::newGood(); + + case LoginForm::NEED_TOKEN: + return StatusValue::newFatal( $msg ?: 'nocookiesforlogin' ); + + case LoginForm::WRONG_TOKEN: + return StatusValue::newFatal( $msg ?: 'sessionfailure' ); + + case LoginForm::NO_NAME: + case LoginForm::ILLEGAL: + return StatusValue::newFatal( $msg ?: 'noname' ); + + case LoginForm::WRONG_PLUGIN_PASS: + case LoginForm::WRONG_PASS: + return StatusValue::newFatal( $msg ?: 'wrongpassword' ); + + case LoginForm::NOT_EXISTS: + return StatusValue::newFatal( $msg ?: 'nosuchusershort', wfEscapeWikiText( $user->getName() ) ); + + case LoginForm::EMPTY_PASS: + return StatusValue::newFatal( $msg ?: 'wrongpasswordempty' ); + + case LoginForm::RESET_PASS: + return StatusValue::newFatal( $msg ?: 'resetpass_announce' ); + + case LoginForm::THROTTLED: + $throttle = $this->config->get( 'PasswordAttemptThrottle' ); + return StatusValue::newFatal( + $msg ?: 'login-throttled', + \Message::durationParam( $throttle['seconds'] ) + ); + + case LoginForm::USER_BLOCKED: + return StatusValue::newFatal( + $msg ?: 'login-userblocked', wfEscapeWikiText( $user->getName() ) + ); + + case LoginForm::ABORTED: + return StatusValue::newFatal( + $msg ?: 'login-abort-generic', wfEscapeWikiText( $user->getName() ) + ); + + case LoginForm::USER_MIGRATED: + $error = $msg ?: 'login-migrated-generic'; + return call_user_func_array( 'StatusValue::newFatal', (array)$error ); + + // @codeCoverageIgnoreStart + case LoginForm::CREATE_BLOCKED: // Can never happen + default: + throw new \DomainException( __METHOD__ . ": Unhandled case value from $hook" ); + } + // @codeCoverageIgnoreEnd + } +} diff --git a/includes/auth/LocalPasswordPrimaryAuthenticationProvider.php b/includes/auth/LocalPasswordPrimaryAuthenticationProvider.php new file mode 100644 index 0000000000..5f5ef79c25 --- /dev/null +++ b/includes/auth/LocalPasswordPrimaryAuthenticationProvider.php @@ -0,0 +1,314 @@ +loginOnly = !empty( $params['loginOnly'] ); + } + + protected function getPasswordResetData( $username, $row ) { + $now = wfTimestamp(); + $expiration = wfTimestampOrNull( TS_UNIX, $row->user_password_expires ); + if ( $expiration === null || $expiration >= $now ) { + return null; + } + + $grace = $this->config->get( 'PasswordExpireGrace' ); + if ( $expiration + $grace < $now ) { + $data = [ + 'hard' => true, + 'msg' => \Status::newFatal( 'resetpass-expired' )->getMessage(), + ]; + } else { + $data = [ + 'hard' => false, + 'msg' => \Status::newFatal( 'resetpass-expired-soft' )->getMessage(), + ]; + } + + return (object)$data; + } + + public function beginPrimaryAuthentication( array $reqs ) { + $req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class ); + if ( !$req ) { + return AuthenticationResponse::newAbstain(); + } + + if ( $req->username === null || $req->password === null ) { + return AuthenticationResponse::newAbstain(); + } + + $username = User::getCanonicalName( $req->username, 'usable' ); + if ( $username === false ) { + return AuthenticationResponse::newAbstain(); + } + + $fields = [ + 'user_id', 'user_password', 'user_password_expires', + ]; + + $dbw = wfGetDB( DB_MASTER ); + $row = $dbw->selectRow( + 'user', + $fields, + [ 'user_name' => $username ], + __METHOD__ + ); + if ( !$row ) { + return AuthenticationResponse::newAbstain(); + } + + // Check for *really* old password hashes that don't even have a type + // The old hash format was just an md5 hex hash, with no type information + if ( preg_match( '/^[0-9a-f]{32}$/', $row->user_password ) ) { + if ( $this->config->get( 'PasswordSalt' ) ) { + $row->user_password = ":A:{$row->user_id}:{$row->user_password}"; + } else { + $row->user_password = ":A:{$row->user_password}"; + } + } + + $status = $this->checkPasswordValidity( $username, $req->password ); + if ( !$status->isOk() ) { + // Fatal, can't log in + return AuthenticationResponse::newFail( $status->getMessage() ); + } + + $pwhash = $this->getPassword( $row->user_password ); + if ( !$pwhash->equals( $req->password ) ) { + if ( $this->config->get( 'LegacyEncoding' ) ) { + // Some wikis were converted from ISO 8859-1 to UTF-8, the passwords can't be converted + // Check for this with iconv + $cp1252Password = iconv( 'UTF-8', 'WINDOWS-1252//TRANSLIT', $req->password ); + if ( $cp1252Password === $req->password || !$pwhash->equals( $cp1252Password ) ) { + return $this->failResponse( $req ); + } + } else { + return $this->failResponse( $req ); + } + } + + // @codeCoverageIgnoreStart + if ( $this->getPasswordFactory()->needsUpdate( $pwhash ) ) { + $pwhash = $this->getPasswordFactory()->newFromPlaintext( $req->password ); + $dbw->update( + 'user', + [ 'user_password' => $pwhash->toString() ], + [ 'user_id' => $row->user_id ], + __METHOD__ + ); + } + // @codeCoverageIgnoreEnd + + $this->setPasswordResetFlag( $username, $status, $row ); + + return AuthenticationResponse::newPass( $username ); + } + + public function testUserCanAuthenticate( $username ) { + $username = User::getCanonicalName( $username, 'usable' ); + if ( $username === false ) { + return false; + } + + $dbw = wfGetDB( DB_MASTER ); + $row = $dbw->selectRow( + 'user', + [ 'user_password' ], + [ 'user_name' => $username ], + __METHOD__ + ); + if ( !$row ) { + return false; + } + + // Check for *really* old password hashes that don't even have a type + // The old hash format was just an md5 hex hash, with no type information + if ( preg_match( '/^[0-9a-f]{32}$/', $row->user_password ) ) { + return true; + } + + return !$this->getPassword( $row->user_password ) instanceof \InvalidPassword; + } + + public function testUserExists( $username, $flags = User::READ_NORMAL ) { + $username = User::getCanonicalName( $username, 'usable' ); + if ( $username === false ) { + return false; + } + + list( $db, $options ) = \DBAccessObjectUtils::getDBOptions( $flags ); + return (bool)wfGetDB( $db )->selectField( + [ 'user' ], + [ 'user_id' ], + [ 'user_name' => $username ], + __METHOD__, + $options + ); + } + + public function providerAllowsAuthenticationDataChange( + AuthenticationRequest $req, $checkData = true + ) { + // We only want to blank the password if something else will accept the + // new authentication data, so return 'ignore' here. + if ( $this->loginOnly ) { + return \StatusValue::newGood( 'ignored' ); + } + + if ( get_class( $req ) === PasswordAuthenticationRequest::class ) { + if ( !$checkData ) { + return \StatusValue::newGood(); + } + + $username = User::getCanonicalName( $req->username, 'usable' ); + if ( $username !== false ) { + $row = wfGetDB( DB_MASTER )->selectRow( + 'user', + [ 'user_id' ], + [ 'user_name' => $username ], + __METHOD__ + ); + if ( $row ) { + $sv = \StatusValue::newGood(); + if ( $req->password !== null ) { + if ( $req->password !== $req->retype ) { + $sv->fatal( 'badretype' ); + } else { + $sv->merge( $this->checkPasswordValidity( $username, $req->password ) ); + } + } + return $sv; + } + } + } + + return \StatusValue::newGood( 'ignored' ); + } + + public function providerChangeAuthenticationData( AuthenticationRequest $req ) { + $username = $req->username !== null ? User::getCanonicalName( $req->username, 'usable' ) : false; + if ( $username === false ) { + return; + } + + $pwhash = null; + + if ( $this->loginOnly ) { + $pwhash = $this->getPasswordFactory()->newFromCiphertext( null ); + $expiry = null; + // @codeCoverageIgnoreStart + } elseif ( get_class( $req ) === PasswordAuthenticationRequest::class ) { + // @codeCoverageIgnoreEnd + $pwhash = $this->getPasswordFactory()->newFromPlaintext( $req->password ); + $expiry = $this->getNewPasswordExpiry( $username ); + } + + if ( $pwhash ) { + $dbw = wfGetDB( DB_MASTER ); + $dbw->update( + 'user', + [ + 'user_password' => $pwhash->toString(), + 'user_password_expires' => $dbw->timestampOrNull( $expiry ), + ], + [ 'user_name' => $username ], + __METHOD__ + ); + } + } + + public function accountCreationType() { + return $this->loginOnly ? self::TYPE_NONE : self::TYPE_CREATE; + } + + public function testForAccountCreation( $user, $creator, array $reqs ) { + $req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class ); + + $ret = \StatusValue::newGood(); + if ( !$this->loginOnly && $req && $req->username !== null && $req->password !== null ) { + if ( $req->password !== $req->retype ) { + $ret->fatal( 'badretype' ); + } else { + $ret->merge( + $this->checkPasswordValidity( $user->getName(), $req->password ) + ); + } + } + return $ret; + } + + public function beginPrimaryAccountCreation( $user, $creator, array $reqs ) { + if ( $this->accountCreationType() === self::TYPE_NONE ) { + throw new \BadMethodCallException( 'Shouldn\'t call this when accountCreationType() is NONE' ); + } + + $req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class ); + if ( $req ) { + if ( $req->username !== null && $req->password !== null ) { + // Nothing we can do besides claim it, because the user isn't in + // the DB yet + if ( $req->username !== $user->getName() ) { + $req = clone( $req ); + $req->username = $user->getName(); + } + $ret = AuthenticationResponse::newPass( $req->username ); + $ret->createRequest = $req; + return $ret; + } + } + return AuthenticationResponse::newAbstain(); + } + + public function finishAccountCreation( $user, $creator, AuthenticationResponse $res ) { + if ( $this->accountCreationType() === self::TYPE_NONE ) { + throw new \BadMethodCallException( 'Shouldn\'t call this when accountCreationType() is NONE' ); + } + + // Now that the user is in the DB, set the password on it. + $this->providerChangeAuthenticationData( $res->createRequest ); + + return null; + } +} diff --git a/includes/auth/PasswordAuthenticationRequest.php b/includes/auth/PasswordAuthenticationRequest.php new file mode 100644 index 0000000000..187c29ae9f --- /dev/null +++ b/includes/auth/PasswordAuthenticationRequest.php @@ -0,0 +1,83 @@ +action === AuthManager::ACTION_REMOVE ) { + return []; + } + + // for password change it's nice to make extra clear that we are asking for the new password + $forNewPassword = $this->action === AuthManager::ACTION_CHANGE; + $passwordLabel = $forNewPassword ? 'newpassword' : 'userlogin-yourpassword'; + $retypeLabel = $forNewPassword ? 'retypenew' : 'yourpasswordagain'; + + $ret = [ + 'username' => [ + 'type' => 'string', + 'label' => wfMessage( 'userlogin-yourname' ), + 'help' => wfMessage( 'authmanager-username-help' ), + ], + 'password' => [ + 'type' => 'password', + 'label' => wfMessage( $passwordLabel ), + 'help' => wfMessage( 'authmanager-password-help' ), + ], + ]; + + switch ( $this->action ) { + case AuthManager::ACTION_CHANGE: + case AuthManager::ACTION_REMOVE: + unset( $ret['username'] ); + break; + } + + if ( $this->action !== AuthManager::ACTION_LOGIN ) { + $ret['retype'] = [ + 'type' => 'password', + 'label' => wfMessage( $retypeLabel ), + 'help' => wfMessage( 'authmanager-retype-help' ), + ]; + } + + return $ret; + } + + public function describeCredentials() { + return [ + 'provider' => wfMessage( 'authmanager-provider-password' ), + 'account' => new \RawMessage( '$1', [ $this->username ] ), + ]; + } +} diff --git a/includes/auth/PasswordDomainAuthenticationRequest.php b/includes/auth/PasswordDomainAuthenticationRequest.php new file mode 100644 index 0000000000..ddad54b252 --- /dev/null +++ b/includes/auth/PasswordDomainAuthenticationRequest.php @@ -0,0 +1,83 @@ +domainList = $domainList; + } + + public function getFieldInfo() { + $ret = parent::getFieldInfo(); + + // Only add a domain field if we have the username field included + if ( isset( $ret['username'] ) ) { + $ret['domain'] = [ + 'type' => 'select', + 'options' => [], + 'label' => wfMessage( 'yourdomainname' ), + 'help' => wfMessage( 'authmanager-domain-help' ), + ]; + foreach ( $this->domainList as $domain ) { + $ret['domain']['options'][$domain] = new \RawMessage( '$1', [ $domain ] ); + } + } + + return $ret; + } + + public function describeCredentials() { + return [ + 'provider' => wfMessage( 'authmanager-provider-password-domain' ), + 'account' => wfMessage( + 'authmanager-account-password-domain', [ $this->username, $this->domain ] + ), + ]; + } + + /** + * @codeCoverageIgnore + */ + public static function __set_state( $data ) { + $ret = new static( $data['domainList'] ); + foreach ( $data as $k => $v ) { + if ( $k !== 'domainList' ) { + $ret->$k = $v; + } + } + return $ret; + } +} diff --git a/includes/auth/PreAuthenticationProvider.php b/includes/auth/PreAuthenticationProvider.php new file mode 100644 index 0000000000..846d16e265 --- /dev/null +++ b/includes/auth/PreAuthenticationProvider.php @@ -0,0 +1,120 @@ +", or append "#servicename" to the username passed to a + * third-party service). + * + * If the provider doesn't use a username at all in its + * AuthenticationRequests, return null. If the name is syntactically + * invalid, it's probably best to return null. + * + * @param string $username + * @return string|null + */ + public function providerNormalizeUsername( $username ); + + /** + * Revoke the user's credentials + * + * This may cause the user to no longer exist for the provider, or the user + * may continue to exist in a "disabled" state. + * + * The intention is that the named account will never again be usable for + * normal login (i.e. there is no way to undo the revocation of access). + * + * @param string $username + */ + public function providerRevokeAccessForUser( $username ); + + /** + * Determine whether a property can change + * @see AuthManager::allowsPropertyChange() + * @param string $property + * @return bool + */ + public function providerAllowsPropertyChange( $property ); + + /** + * Validate a change of authentication data (e.g. passwords) + * + * Return StatusValue::newGood( 'ignored' ) if you don't support this + * AuthenticationRequest type. + * + * @param AuthenticationRequest $req + * @param bool $checkData If false, $req hasn't been loaded from the + * submission so checks on user-submitted fields should be skipped. + * $req->username is considered user-submitted for this purpose, even + * if it cannot be changed via $req->loadFromSubmission. + * @return StatusValue + */ + public function providerAllowsAuthenticationDataChange( + AuthenticationRequest $req, $checkData = true + ); + + /** + * Change or remove authentication data (e.g. passwords) + * + * If $req was returned for AuthManager::ACTION_CHANGE, the corresponding + * credentials should result in a successful login in the future. + * + * If $req was returned for AuthManager::ACTION_REMOVE, the corresponding + * credentials should no longer result in a successful login. + * + * @param AuthenticationRequest $req + */ + public function providerChangeAuthenticationData( AuthenticationRequest $req ); + + /** + * Fetch the account-creation type + * @return string One of the TYPE_* constants + */ + public function accountCreationType(); + + /** + * Determine whether an account creation may begin + * + * Called from AuthManager::beginAccountCreation() + * + * @note No need to test if the account exists, AuthManager checks that + * @param User $user User being created (not added to the database yet). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param User $creator User doing the creation. This may become a + * "UserValue" in the future, or User may be refactored into such. + * @param AuthenticationRequest[] $reqs + * @return StatusValue + */ + public function testForAccountCreation( $user, $creator, array $reqs ); + + /** + * Start an account creation flow + * @param User $user User being created (not added to the database yet). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param User $creator User doing the creation. This may become a + * "UserValue" in the future, or User may be refactored into such. + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse Expected responses: + * - PASS: The user may be created. Secondary providers will now run. + * - FAIL: The user may not be created. Fail the creation process. + * - ABSTAIN: These $reqs are not handled. Some other primary provider may handle it. + * - UI: The $reqs are accepted, no other primary provider will run. + * Additional AuthenticationRequests are needed to complete the process. + * - REDIRECT: The $reqs are accepted, no other primary provider will run. + * Redirection to a third party is needed to complete the process. + */ + public function beginPrimaryAccountCreation( $user, $creator, array $reqs ); + + /** + * Continue an account creation flow + * @param User $user User being created (not added to the database yet). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param User $creator User doing the creation. This may become a + * "UserValue" in the future, or User may be refactored into such. + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse Expected responses: + * - PASS: The user may be created. Secondary providers will now run. + * - FAIL: The user may not be created. Fail the creation process. + * - UI: Additional AuthenticationRequests are needed to complete the process. + * - REDIRECT: Redirection to a third party is needed to complete the process. + */ + public function continuePrimaryAccountCreation( $user, $creator, array $reqs ); + + /** + * Post-creation callback + * + * Called after the user is added to the database, before secondary + * authentication providers are run. + * + * @param User $user User being created (has been added to the database now). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param User $creator User doing the creation. This may become a + * "UserValue" in the future, or User may be refactored into such. + * @param AuthenticationResponse $response PASS response returned earlier + * @return string|null 'newusers' log subtype to use for logging the + * account creation. If null, either 'create' or 'create2' will be used + * depending on $creator. + */ + public function finishAccountCreation( $user, $creator, AuthenticationResponse $response ); + + /** + * Post-creation callback + * + * Called when the account creation process ends. + * + * @param User $user User that was attempted to be created. + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param User $creator User doing the creation. This may become a + * "UserValue" in the future, or User may be refactored into such. + * @param AuthenticationResponse $response Authentication response that will be returned + */ + public function postAccountCreation( $user, $creator, AuthenticationResponse $response ); + + /** + * Determine whether an account may be created + * + * @param User $user User being created (not added to the database yet). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param bool|string $autocreate False if this is not an auto-creation, or + * the source of the auto-creation passed to AuthManager::autoCreateUser(). + * @return StatusValue + */ + public function testUserForCreation( $user, $autocreate ); + + /** + * Post-auto-creation callback + * @param User $user User being created (has been added to the database now). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param string $source The source of the auto-creation passed to + * AuthManager::autoCreateUser(). + */ + public function autoCreatedAccount( $user, $source ); + + /** + * Start linking an account to an existing user + * @param User $user User being linked. + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse Expected responses: + * - PASS: The user is linked. + * - FAIL: The user is not linked. Fail the linking process. + * - ABSTAIN: These $reqs are not handled. Some other primary provider may handle it. + * - UI: The $reqs are accepted, no other primary provider will run. + * Additional AuthenticationRequests are needed to complete the process. + * - REDIRECT: The $reqs are accepted, no other primary provider will run. + * Redirection to a third party is needed to complete the process. + */ + public function beginPrimaryAccountLink( $user, array $reqs ); + + /** + * Continue linking an account to an existing user + * @param User $user User being linked. + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse Expected responses: + * - PASS: The user is linked. + * - FAIL: The user is not linked. Fail the linking process. + * - UI: Additional AuthenticationRequests are needed to complete the process. + * - REDIRECT: Redirection to a third party is needed to complete the process. + */ + public function continuePrimaryAccountLink( $user, array $reqs ); + + /** + * Post-link callback + * @param User $user User that was attempted to be linked. + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param AuthenticationResponse $response Authentication response that will be returned + */ + public function postAccountLink( $user, AuthenticationResponse $response ); + +} diff --git a/includes/auth/RememberMeAuthenticationRequest.php b/includes/auth/RememberMeAuthenticationRequest.php new file mode 100644 index 0000000000..d487e31092 --- /dev/null +++ b/includes/auth/RememberMeAuthenticationRequest.php @@ -0,0 +1,64 @@ +getProvider(); + $this->expiration = $provider->getRememberUserDuration(); + } + + public function getFieldInfo() { + if ( !$this->expiration ) { + return []; + } + + $expirationDays = ceil( $this->expiration / ( 3600 * 24 ) ); + return [ + 'rememberMe' => [ + 'type' => 'checkbox', + 'label' => wfMessage( 'userlogin-remembermypassword' )->numParams( $expirationDays ), + 'help' => wfMessage( 'authmanager-userlogin-remembermypassword-help' ), + 'optional' => true, + ] + ]; + } +} diff --git a/includes/auth/ResetPasswordSecondaryAuthenticationProvider.php b/includes/auth/ResetPasswordSecondaryAuthenticationProvider.php new file mode 100644 index 0000000000..2e51cf22c1 --- /dev/null +++ b/includes/auth/ResetPasswordSecondaryAuthenticationProvider.php @@ -0,0 +1,132 @@ +manager->setAuthenticationSessionData() + * + * The authentication data key is 'reset-pass'; the data is an object with the + * following properties: + * - msg: Message object to display to the user + * - hard: Boolean, if true the reset cannot be skipped. + * - req: Optional PasswordAuthenticationRequest to use to actually reset the + * password. Won't be displayed to the user. + * + * @ingroup Auth + * @since 1.27 + */ +class ResetPasswordSecondaryAuthenticationProvider extends AbstractSecondaryAuthenticationProvider { + + public function getAuthenticationRequests( $action, array $options ) { + return []; + } + + public function beginSecondaryAuthentication( $user, array $reqs ) { + return $this->tryReset( $user, $reqs ); + } + + public function continueSecondaryAuthentication( $user, array $reqs ) { + return $this->tryReset( $user, $reqs ); + } + + public function beginSecondaryAccountCreation( $user, $creator, array $reqs ) { + return $this->tryReset( $user, $reqs ); + } + + public function continueSecondaryAccountCreation( $user, $creator, array $reqs ) { + return $this->tryReset( $user, $reqs ); + } + + /** + * Try to reset the password + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse + */ + protected function tryReset( \User $user, array $reqs ) { + $data = $this->manager->getAuthenticationSessionData( 'reset-pass' ); + if ( !$data ) { + return AuthenticationResponse::newAbstain(); + } + + if ( is_array( $data ) ) { + $data = (object)$data; + } + if ( !is_object( $data ) ) { + throw new \UnexpectedValueException( 'reset-pass is not valid' ); + } + + if ( !isset( $data->msg ) ) { + throw new \UnexpectedValueException( 'reset-pass msg is missing' ); + } elseif ( !$data->msg instanceof \Message ) { + throw new \UnexpectedValueException( 'reset-pass msg is not valid' ); + } elseif ( !isset( $data->hard ) ) { + throw new \UnexpectedValueException( 'reset-pass hard is missing' ); + } elseif ( isset( $data->req ) && ( + !$data->req instanceof PasswordAuthenticationRequest || + !array_key_exists( 'retype', $data->req->getFieldInfo() ) + ) ) { + throw new \UnexpectedValueException( 'reset-pass req is not valid' ); + } + + if ( !$data->hard ) { + $req = ButtonAuthenticationRequest::getRequestByName( $reqs, 'skipReset' ); + if ( $req ) { + $this->manager->removeAuthenticationSessionData( 'reset-pass' ); + return AuthenticationResponse::newPass(); + } + } + + if ( isset( $data->req ) ) { + $needReq = $data->req; + } else { + $needReq = new PasswordAuthenticationRequest(); + $needReq->action = AuthManager::ACTION_CHANGE; + } + $needReqs = [ $needReq ]; + if ( !$data->hard ) { + $needReqs[] = new ButtonAuthenticationRequest( + 'skipReset', + wfMessage( 'authprovider-resetpass-skip-label' ), + wfMessage( 'authprovider-resetpass-skip-help' ) + ); + } + + $req = AuthenticationRequest::getRequestByClass( $reqs, get_class( $needReq ) ); + if ( !$req || !array_key_exists( 'retype', $req->getFieldInfo() ) ) { + return AuthenticationResponse::newUI( $needReqs, $data->msg ); + } + + if ( $req->password !== $req->retype ) { + return AuthenticationResponse::newUI( $needReqs, new \Message( 'badretype' ) ); + } + + $req->username = $user->getName(); + $status = $this->manager->allowsAuthenticationDataChange( $req ); + if ( !$status->isGood() ) { + return AuthenticationResponse::newUI( $needReqs, $status->getMessage() ); + } + $this->manager->changeAuthenticationData( $req ); + + $this->manager->removeAuthenticationSessionData( 'reset-pass' ); + return AuthenticationResponse::newPass(); + } +} diff --git a/includes/auth/SecondaryAuthenticationProvider.php b/includes/auth/SecondaryAuthenticationProvider.php new file mode 100644 index 0000000000..0d52d2500b --- /dev/null +++ b/includes/auth/SecondaryAuthenticationProvider.php @@ -0,0 +1,217 @@ +username is considered user-submitted for this purpose, even + * if it cannot be changed via $req->loadFromSubmission. + * @return StatusValue + */ + public function providerAllowsAuthenticationDataChange( + AuthenticationRequest $req, $checkData = true + ); + + /** + * Change or remove authentication data (e.g. passwords) + * + * If $req was returned for AuthManager::ACTION_CHANGE, the corresponding + * credentials should result in a successful login in the future. + * + * If $req was returned for AuthManager::ACTION_REMOVE, the corresponding + * credentials should no longer result in a successful login. + * + * @param AuthenticationRequest $req + */ + public function providerChangeAuthenticationData( AuthenticationRequest $req ); + + /** + * Determine whether an account creation may begin + * + * Called from AuthManager::beginAccountCreation() + * + * @note No need to test if the account exists, AuthManager checks that + * @param User $user User being created (not added to the database yet). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param User $creator User doing the creation. This may become a + * "UserValue" in the future, or User may be refactored into such. + * @param AuthenticationRequest[] $reqs + * @return StatusValue + */ + public function testForAccountCreation( $user, $creator, array $reqs ); + + /** + * Start an account creation flow + * @param User $user User being created (has been added to the database). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param User $creator User doing the creation. This may become a + * "UserValue" in the future, or User may be refactored into such. + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse Expected responses: + * - PASS: The user creation is ok. Additional secondary providers may run. + * - ABSTAIN: Additional secondary providers may run. + * - UI: Additional AuthenticationRequests are needed to complete the process. + * - REDIRECT: Redirection to a third party is needed to complete the process. + */ + public function beginSecondaryAccountCreation( $user, $creator, array $reqs ); + + /** + * Continue an authentication flow + * @param User $user User being created (has been added to the database). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param User $creator User doing the creation. This may become a + * "UserValue" in the future, or User may be refactored into such. + * @param AuthenticationRequest[] $reqs + * @return AuthenticationResponse Expected responses: + * - PASS: The user creation is ok. Additional secondary providers may run. + * - ABSTAIN: Additional secondary providers may run. + * - UI: Additional AuthenticationRequests are needed to complete the process. + * - REDIRECT: Redirection to a third party is needed to complete the process. + */ + public function continueSecondaryAccountCreation( $user, $creator, array $reqs ); + + /** + * Post-creation callback + * @param User $user User that was attempted to be created. + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param User $creator User doing the creation. This may become a + * "UserValue" in the future, or User may be refactored into such. + * @param AuthenticationResponse $response Authentication response that will be returned + */ + public function postAccountCreation( $user, $creator, AuthenticationResponse $response ); + + /** + * Determine whether an account may be created + * + * @param User $user User being created (not added to the database yet). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param bool|string $autocreate False if this is not an auto-creation, or + * the source of the auto-creation passed to AuthManager::autoCreateUser(). + * @return StatusValue + */ + public function testUserForCreation( $user, $autocreate ); + + /** + * Post-auto-creation callback + * @param User $user User being created (has been added to the database now). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @param string $source The source of the auto-creation passed to + * AuthManager::autoCreateUser(). + */ + public function autoCreatedAccount( $user, $source ); + +} diff --git a/includes/auth/TemporaryPasswordAuthenticationRequest.php b/includes/auth/TemporaryPasswordAuthenticationRequest.php new file mode 100644 index 0000000000..42f0e70223 --- /dev/null +++ b/includes/auth/TemporaryPasswordAuthenticationRequest.php @@ -0,0 +1,105 @@ + [ + 'type' => 'checkbox', + 'label' => wfMessage( 'createaccountmail' ), + 'help' => wfMessage( 'createaccountmail-help' ), + ], + ]; + } + + /** + * @param string|null $password + */ + public function __construct( $password = null ) { + $this->password = $password; + if ( $password ) { + $this->mailpassword = true; + } + } + + /** + * Return an instance with a new, random password + * @return TemporaryPasswordAuthenticationRequest + */ + public static function newRandom() { + $config = \ConfigFactory::getDefaultInstance()->makeConfig( 'main' ); + + // get the min password length + $minLength = $config->get( 'MinimalPasswordLength' ); + $policy = $config->get( 'PasswordPolicy' ); + foreach ( $policy['policies'] as $p ) { + if ( isset( $p['MinimalPasswordLength'] ) ) { + $minLength = max( $minLength, $p['MinimalPasswordLength'] ); + } + if ( isset( $p['MinimalPasswordLengthToLogin'] ) ) { + $minLength = max( $minLength, $p['MinimalPasswordLengthToLogin'] ); + } + } + + $password = \PasswordFactory::generateRandomPasswordString( $minLength ); + + return new self( $password ); + } + + /** + * Return an instance with an invalid password + * @return TemporaryPasswordAuthenticationRequest + */ + public static function newInvalid() { + $request = new self( null ); + return $request; + } + + public function describeCredentials() { + return [ + 'provider' => wfMessage( 'authmanager-provider-temporarypassword' ), + 'account' => new \RawMessage( '$1', [ $this->username ] ), + ] + parent::describeCredentials(); + } + +} diff --git a/includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php b/includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php new file mode 100644 index 0000000000..46cbab5a3a --- /dev/null +++ b/includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php @@ -0,0 +1,454 @@ +emailEnabled = (bool)$params['emailEnabled']; + } + if ( isset( $params['newPasswordExpiry'] ) ) { + $this->newPasswordExpiry = (int)$params['newPasswordExpiry']; + } + if ( isset( $params['passwordReminderResendTime'] ) ) { + $this->passwordReminderResendTime = $params['passwordReminderResendTime']; + } + } + + public function setConfig( \Config $config ) { + parent::setConfig( $config ); + + if ( $this->emailEnabled === null ) { + $this->emailEnabled = $this->config->get( 'EnableEmail' ); + } + if ( $this->newPasswordExpiry === null ) { + $this->newPasswordExpiry = $this->config->get( 'NewPasswordExpiry' ); + } + if ( $this->passwordReminderResendTime === null ) { + $this->passwordReminderResendTime = $this->config->get( 'PasswordReminderResendTime' ); + } + } + + protected function getPasswordResetData( $username, $data ) { + // Always reset + return (object)[ + 'msg' => wfMessage( 'resetpass-temp-emailed' ), + 'hard' => true, + ]; + } + + public function getAuthenticationRequests( $action, array $options ) { + switch ( $action ) { + case AuthManager::ACTION_LOGIN: + return [ new PasswordAuthenticationRequest() ]; + + case AuthManager::ACTION_CHANGE: + return [ TemporaryPasswordAuthenticationRequest::newRandom() ]; + + case AuthManager::ACTION_CREATE: + if ( isset( $options['username'] ) && $this->emailEnabled ) { + // Creating an account for someone else + return [ TemporaryPasswordAuthenticationRequest::newRandom() ]; + } else { + // It's not terribly likely that an anonymous user will + // be creating an account for someone else. + return []; + } + + case AuthManager::ACTION_REMOVE: + return [ new TemporaryPasswordAuthenticationRequest ]; + + default: + return []; + } + } + + public function beginPrimaryAuthentication( array $reqs ) { + $req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class ); + if ( !$req || $req->username === null || $req->password === null ) { + return AuthenticationResponse::newAbstain(); + } + + $username = User::getCanonicalName( $req->username, 'usable' ); + if ( $username === false ) { + return AuthenticationResponse::newAbstain(); + } + + $dbw = wfGetDB( DB_MASTER ); + $row = $dbw->selectRow( + 'user', + [ + 'user_id', 'user_newpassword', 'user_newpass_time', + ], + [ 'user_name' => $username ], + __METHOD__ + ); + if ( !$row ) { + return AuthenticationResponse::newAbstain(); + } + + $status = $this->checkPasswordValidity( $username, $req->password ); + if ( !$status->isOk() ) { + // Fatal, can't log in + return AuthenticationResponse::newFail( $status->getMessage() ); + } + + $pwhash = $this->getPassword( $row->user_newpassword ); + if ( !$pwhash->equals( $req->password ) ) { + return $this->failResponse( $req ); + } + + if ( !$this->isTimestampValid( $row->user_newpass_time ) ) { + return $this->failResponse( $req ); + } + + $this->setPasswordResetFlag( $username, $status ); + + return AuthenticationResponse::newPass( $username ); + } + + public function testUserCanAuthenticate( $username ) { + $username = User::getCanonicalName( $username, 'usable' ); + if ( $username === false ) { + return false; + } + + $dbw = wfGetDB( DB_MASTER ); + $row = $dbw->selectRow( + 'user', + [ 'user_newpassword', 'user_newpass_time' ], + [ 'user_name' => $username ], + __METHOD__ + ); + if ( !$row ) { + return false; + } + + if ( $this->getPassword( $row->user_newpassword ) instanceof \InvalidPassword ) { + return false; + } + + if ( !$this->isTimestampValid( $row->user_newpass_time ) ) { + return false; + } + + return true; + } + + public function testUserExists( $username, $flags = User::READ_NORMAL ) { + $username = User::getCanonicalName( $username, 'usable' ); + if ( $username === false ) { + return false; + } + + list( $db, $options ) = \DBAccessObjectUtils::getDBOptions( $flags ); + return (bool)wfGetDB( $db )->selectField( + [ 'user' ], + [ 'user_id' ], + [ 'user_name' => $username ], + __METHOD__, + $options + ); + } + + public function providerAllowsAuthenticationDataChange( + AuthenticationRequest $req, $checkData = true + ) { + if ( get_class( $req ) !== TemporaryPasswordAuthenticationRequest::class ) { + // We don't really ignore it, but this is what the caller expects. + return \StatusValue::newGood( 'ignored' ); + } + + if ( !$checkData ) { + return \StatusValue::newGood(); + } + + $username = User::getCanonicalName( $req->username, 'usable' ); + if ( $username === false ) { + return \StatusValue::newGood( 'ignored' ); + } + + $row = wfGetDB( DB_MASTER )->selectRow( + 'user', + [ 'user_id', 'user_newpass_time' ], + [ 'user_name' => $username ], + __METHOD__ + ); + + if ( !$row ) { + return \StatusValue::newGood( 'ignored' ); + } + + $sv = \StatusValue::newGood(); + if ( $req->password !== null ) { + $sv->merge( $this->checkPasswordValidity( $username, $req->password ) ); + + if ( $req->mailpassword ) { + if ( !$this->emailEnabled && !$req->hasBackchannel ) { + return \StatusValue::newFatal( 'passwordreset-emaildisabled' ); + } + + // We don't check whether the user has an email address; + // that information should not be exposed to the caller. + + // do not allow temporary password creation within + // $wgPasswordReminderResendTime from the last attempt + if ( + $this->passwordReminderResendTime + && $row->user_newpass_time + && time() < wfTimestamp( TS_UNIX, $row->user_newpass_time ) + + $this->passwordReminderResendTime * 3600 + ) { + // Round the time in hours to 3 d.p., in case someone is specifying + // minutes or seconds. + return \StatusValue::newFatal( 'throttled-mailpassword', + round( $this->passwordReminderResendTime, 3 ) ); + } + + if ( !$req->caller ) { + return \StatusValue::newFatal( 'passwordreset-nocaller' ); + } + if ( !\IP::isValid( $req->caller ) ) { + $caller = User::newFromName( $req->caller ); + if ( !$caller ) { + return \StatusValue::newFatal( 'passwordreset-nosuchcaller', $req->caller ); + } + } + } + } + return $sv; + } + + public function providerChangeAuthenticationData( AuthenticationRequest $req ) { + $username = $req->username !== null ? User::getCanonicalName( $req->username, 'usable' ) : false; + if ( $username === false ) { + return; + } + + $dbw = wfGetDB( DB_MASTER ); + + $sendMail = false; + if ( $req->action !== AuthManager::ACTION_REMOVE && + get_class( $req ) === TemporaryPasswordAuthenticationRequest::class + ) { + $pwhash = $this->getPasswordFactory()->newFromPlaintext( $req->password ); + $newpassTime = $dbw->timestamp(); + $sendMail = $req->mailpassword; + } else { + // Invalidate the temporary password when any other auth is reset, or when removing + $pwhash = $this->getPasswordFactory()->newFromCiphertext( null ); + $newpassTime = null; + } + + $dbw->update( + 'user', + [ + 'user_newpassword' => $pwhash->toString(), + 'user_newpass_time' => $newpassTime, + ], + [ 'user_name' => $username ], + __METHOD__ + ); + + if ( $sendMail ) { + $this->sendPasswordResetEmail( $req ); + } + } + + public function accountCreationType() { + return self::TYPE_CREATE; + } + + public function testForAccountCreation( $user, $creator, array $reqs ) { + /** @var TemporaryPasswordAuthenticationRequest $req */ + $req = AuthenticationRequest::getRequestByClass( + $reqs, TemporaryPasswordAuthenticationRequest::class + ); + + $ret = \StatusValue::newGood(); + if ( $req ) { + if ( $req->mailpassword && !$req->hasBackchannel ) { + if ( !$this->emailEnabled ) { + $ret->merge( \StatusValue::newFatal( 'emaildisabled' ) ); + } elseif ( !$user->getEmail() ) { + $ret->merge( \StatusValue::newFatal( 'noemailcreate' ) ); + } + } + + $ret->merge( + $this->checkPasswordValidity( $user->getName(), $req->password ) + ); + } + return $ret; + } + + public function beginPrimaryAccountCreation( $user, $creator, array $reqs ) { + /** @var TemporaryPasswordAuthenticationRequest $req */ + $req = AuthenticationRequest::getRequestByClass( + $reqs, TemporaryPasswordAuthenticationRequest::class + ); + if ( $req ) { + if ( $req->username !== null && $req->password !== null ) { + // Nothing we can do yet, because the user isn't in the DB yet + if ( $req->username !== $user->getName() ) { + $req = clone( $req ); + $req->username = $user->getName(); + } + + if ( $req->mailpassword ) { + // prevent EmailNotificationSecondaryAuthenticationProvider from sending another mail + $this->manager->setAuthenticationSessionData( 'no-email', true ); + } + + $ret = AuthenticationResponse::newPass( $req->username ); + $ret->createRequest = $req; + return $ret; + } + } + return AuthenticationResponse::newAbstain(); + } + + public function finishAccountCreation( $user, $creator, AuthenticationResponse $res ) { + /** @var TemporaryPasswordAuthenticationRequest $req */ + $req = $res->createRequest; + $mailpassword = $req->mailpassword; + $req->mailpassword = false; // providerChangeAuthenticationData would send the wrong email + + // Now that the user is in the DB, set the password on it. + $this->providerChangeAuthenticationData( $req ); + + if ( $mailpassword ) { + $this->sendNewAccountEmail( $user, $creator, $req->password ); + } + + return $mailpassword ? 'byemail' : null; + } + + /** + * Check that a temporary password is still valid (hasn't expired). + * @param string $timestamp A timestamp in MediaWiki (TS_MW) format + * @return bool + */ + protected function isTimestampValid( $timestamp ) { + $time = wfTimestampOrNull( TS_MW, $timestamp ); + if ( $time !== null ) { + $expiry = wfTimestamp( TS_UNIX, $time ) + $this->newPasswordExpiry; + if ( time() >= $expiry ) { + return false; + } + } + return true; + } + + /** + * Send an email about the new account creation and the temporary password. + * @param User $user The new user account + * @param User $creatingUser The user who created the account (can be anonymous) + * @param string $password The temporary password + * @return \Status + */ + protected function sendNewAccountEmail( User $user, User $creatingUser, $password ) { + $ip = $creatingUser->getRequest()->getIP(); + // @codeCoverageIgnoreStart + if ( !$ip ) { + return \Status::newFatal( 'badipaddress' ); + } + // @codeCoverageIgnoreEnd + + \Hooks::run( 'User::mailPasswordInternal', [ &$creatingUser, &$ip, &$user ] ); + + $mainPageUrl = \Title::newMainPage()->getCanonicalURL(); + $userLanguage = $user->getOption( 'language' ); + $subjectMessage = wfMessage( 'createaccount-title' )->inLanguage( $userLanguage ); + $bodyMessage = wfMessage( 'createaccount-text', $ip, $user->getName(), $password, + '<' . $mainPageUrl . '>', round( $this->newPasswordExpiry / 86400 ) ) + ->inLanguage( $userLanguage ); + + $status = $user->sendMail( $subjectMessage->text(), $bodyMessage->text() ); + + // TODO show 'mailerror' message on error, 'accmailtext' success message otherwise? + // @codeCoverageIgnoreStart + if ( !$status->isGood() ) { + $this->logger->warning( 'Could not send account creation email: ' . + $status->getWikiText( false, false, 'en' ) ); + } + // @codeCoverageIgnoreEnd + + return $status; + } + + /** + * @param TemporaryPasswordAuthenticationRequest $req + * @return \Status + */ + protected function sendPasswordResetEmail( TemporaryPasswordAuthenticationRequest $req ) { + $user = User::newFromName( $req->username ); + if ( !$user ) { + return \Status::newFatal( 'noname' ); + } + $userLanguage = $user->getOption( 'language' ); + $callerIsAnon = \IP::isValid( $req->caller ); + $callerName = $callerIsAnon ? $req->caller : User::newFromName( $req->caller )->getName(); + $passwordMessage = wfMessage( 'passwordreset-emailelement', $user->getName(), + $req->password )->inLanguage( $userLanguage ); + $emailMessage = wfMessage( $callerIsAnon ? 'passwordreset-emailtext-ip' + : 'passwordreset-emailtext-user' )->inLanguage( $userLanguage ); + $emailMessage->params( $callerName, $passwordMessage->text(), 1, + '<' . \Title::newMainPage()->getCanonicalURL() . '>', + round( $this->newPasswordExpiry / 86400 ) ); + $emailTitle = wfMessage( 'passwordreset-emailtitle' )->inLanguage( $userLanguage ); + return $user->sendMail( $emailTitle->text(), $emailMessage->text() ); + } +} diff --git a/includes/auth/ThrottlePreAuthenticationProvider.php b/includes/auth/ThrottlePreAuthenticationProvider.php new file mode 100644 index 0000000000..e2123efa61 --- /dev/null +++ b/includes/auth/ThrottlePreAuthenticationProvider.php @@ -0,0 +1,170 @@ +throttleSettings = array_intersect_key( $params, + [ 'accountCreationThrottle' => true, 'passwordAttemptThrottle' => true ] ); + $this->cache = isset( $params['cache'] ) ? $params['cache'] : + \ObjectCache::getLocalClusterInstance(); + } + + public function setConfig( Config $config ) { + parent::setConfig( $config ); + + // @codeCoverageIgnoreStart + $this->throttleSettings += [ + // @codeCoverageIgnoreEnd + 'accountCreationThrottle' => [ [ + 'count' => $this->config->get( 'AccountCreationThrottle' ), + 'seconds' => 86400, + ] ], + 'passwordAttemptThrottle' => $this->config->get( 'PasswordAttemptThrottle' ), + ]; + + if ( !empty( $this->throttleSettings['accountCreationThrottle'] ) ) { + $this->accountCreationThrottle = new Throttler( + $this->throttleSettings['accountCreationThrottle'], [ + 'type' => 'acctcreate', + 'cache' => $this->cache, + ] + ); + } + if ( !empty( $this->throttleSettings['passwordAttemptThrottle'] ) ) { + $this->passwordAttemptThrottle = new Throttler( + $this->throttleSettings['passwordAttemptThrottle'], [ + 'type' => 'password', + 'cache' => $this->cache, + ] + ); + } + } + + public function testForAccountCreation( $user, $creator, array $reqs ) { + if ( !$this->accountCreationThrottle || !$creator->isPingLimitable() ) { + return \StatusValue::newGood(); + } + + $ip = $this->manager->getRequest()->getIP(); + + if ( !\Hooks::run( 'ExemptFromAccountCreationThrottle', [ $ip ] ) ) { + $this->logger->debug( __METHOD__ . ": a hook allowed account creation w/o throttle\n" ); + return \StatusValue::newGood(); + } + + $result = $this->accountCreationThrottle->increase( null, $ip, __METHOD__ ); + if ( $result ) { + return \StatusValue::newFatal( 'acct_creation_throttle_hit', $result['count'] ); + } + + return \StatusValue::newGood(); + } + + public function testForAuthentication( array $reqs ) { + if ( !$this->passwordAttemptThrottle ) { + return \StatusValue::newGood(); + } + + $ip = $this->manager->getRequest()->getIP(); + try { + $username = AuthenticationRequest::getUsernameFromRequests( $reqs ); + } catch ( \UnexpectedValueException $e ) { + $username = ''; + } + + // Get everything this username could normalize to, and throttle each one individually. + // If nothing uses usernames, just throttle by IP. + $usernames = $this->manager->normalizeUsername( $username ); + $result = false; + foreach ( $usernames as $name ) { + $r = $this->passwordAttemptThrottle->increase( $name, $ip, __METHOD__ ); + if ( $r && ( !$result || $result['wait'] < $r['wait'] ) ) { + $result = $r; + } + } + + if ( $result ) { + $message = wfMessage( 'login-throttled' )->durationParams( $result['wait'] ); + return \StatusValue::newFatal( $message ); + } else { + $this->manager->setAuthenticationSessionData( 'LoginThrottle', + [ 'users' => $usernames, 'ip' => $ip ] ); + return \StatusValue::newGood(); + } + } + + /** + * @param null|\User $user + * @param AuthenticationResponse $response + */ + public function postAuthentication( $user, AuthenticationResponse $response ) { + if ( $response->status !== AuthenticationResponse::PASS ) { + return; + } elseif ( !$this->passwordAttemptThrottle ) { + return; + } + + $data = $this->manager->getAuthenticationSessionData( 'LoginThrottle' ); + if ( !$data ) { + $this->logger->error( 'throttler data not found for {user}', [ 'user' => $user->getName() ] ); + return; + } + + foreach ( $data['users'] as $name ) { + $this->passwordAttemptThrottle->clear( $name, $data['ip'] ); + } + } +} diff --git a/includes/auth/Throttler.php b/includes/auth/Throttler.php new file mode 100644 index 0000000000..5b14a3baff --- /dev/null +++ b/includes/auth/Throttler.php @@ -0,0 +1,210 @@ +makeConfig( 'main' ); + $conditions = $config->get( 'PasswordAttemptThrottle' ); + $params += [ + 'type' => 'password', + 'cache' => \ObjectCache::getLocalClusterInstance(), + 'warningLimit' => 50, + ]; + } else { + $params += [ + 'type' => 'custom', + 'cache' => \ObjectCache::getLocalClusterInstance(), + 'warningLimit' => INF, + ]; + } + + $this->type = $params['type']; + $this->conditions = static::normalizeThrottleConditions( $conditions ); + $this->cache = $params['cache']; + $this->warningLimit = $params['warningLimit']; + + $this->setLogger( LoggerFactory::getInstance( 'throttler' ) ); + } + + public function setLogger( LoggerInterface $logger ) { + $this->logger = $logger; + } + + /** + * Increase the throttle counter and return whether the attempt should be throttled. + * + * Should be called before an authentication attempt. + * + * @param string|null $username + * @param string|null $ip + * @param string|null $caller The authentication method from which we were called. + * @return array|false False if the attempt should not be throttled, an associative array + * with three keys otherwise: + * - throttleIndex: which throttle condition was met (a key of the conditions array) + * - count: throttle count (ie. number of failed attempts) + * - wait: time in seconds until authentication can be attempted + */ + public function increase( $username = null, $ip = null, $caller = null ) { + if ( $username === null && $ip === null ) { + throw new \InvalidArgumentException( 'Either username or IP must be set for throttling' ); + } + + $userKey = $username ? md5( $username ) : null; + foreach ( $this->conditions as $index => $throttleCondition ) { + $ipKey = isset( $throttleCondition['allIPs'] ) ? null : $ip; + $count = $throttleCondition['count']; + $expiry = $throttleCondition['seconds']; + + // a limit of 0 is used as a disable flag in some throttling configuration settings + // throttling the whole world is probably a bad idea + if ( !$count || $userKey === null && $ipKey === null ) { + continue; + } + + $throttleKey = wfGlobalCacheKey( 'throttler', $this->type, $index, $ipKey, $userKey ); + $throttleCount = $this->cache->get( $throttleKey ); + + if ( !$throttleCount ) { // counter not started yet + $this->cache->add( $throttleKey, 1, $expiry ); + } elseif ( $throttleCount < $count ) { // throttle limited not yet reached + $this->cache->incr( $throttleKey ); + } else { // throttled + $this->logRejection( [ + 'type' => $this->type, + 'index' => $index, + 'ip' => $ipKey, + 'username' => $username, + 'count' => $count, + 'expiry' => $expiry, + // @codeCoverageIgnoreStart + 'method' => $caller ?: __METHOD__, + // @codeCoverageIgnoreEnd + ] ); + + return [ + 'throttleIndex' => $index, + 'count' => $count, + 'wait' => $expiry, + ]; + } + } + return false; + } + + /** + * Clear the throttle counter. + * + * Should be called after a successful authentication attempt. + * + * @param string|null $username + * @param string|null $ip + * @throws \MWException + */ + public function clear( $username = null, $ip = null ) { + $userKey = $username ? md5( $username ) : null; + foreach ( $this->conditions as $index => $specificThrottle ) { + $ipKey = isset( $specificThrottle['allIPs'] ) ? null : $ip; + $throttleKey = wfGlobalCacheKey( 'throttler', $this->type, $index, $ipKey, $userKey ); + $this->cache->delete( $throttleKey ); + } + } + + /** + * Handles B/C for $wgPasswordAttemptThrottle. + * @param array $throttleConditions + * @return array + * @see $wgPasswordAttemptThrottle for structure + */ + protected static function normalizeThrottleConditions( $throttleConditions ) { + if ( !is_array( $throttleConditions ) ) { + return []; + } + if ( isset( $throttleConditions['count'] ) ) { // old style + $throttleConditions = [ $throttleConditions ]; + } + return $throttleConditions; + } + + protected function logRejection( array $context ) { + $logMsg = 'Throttle {type} hit, throttled for {expiry} seconds due to {count} attempts ' + . 'from username {username} and IP {ip}'; + + // If we are hitting a throttle for >= warningLimit attempts, it is much more likely to be + // an attack than someone simply forgetting their password, so log it at a higher level. + $level = $context['count'] >= $this->warningLimit ? LogLevel::WARNING : LogLevel::INFO; + + // It should be noted that once the throttle is hit, every attempt to login will + // generate the log message until the throttle expires, not just the attempt that + // puts the throttle over the top. + $this->logger->log( $level, $logMsg, $context ); + } + +} diff --git a/includes/auth/UserDataAuthenticationRequest.php b/includes/auth/UserDataAuthenticationRequest.php new file mode 100644 index 0000000000..ee77d7bc0d --- /dev/null +++ b/includes/auth/UserDataAuthenticationRequest.php @@ -0,0 +1,88 @@ +makeConfig( 'main' ); + $ret = [ + 'email' => [ + 'type' => 'string', + 'label' => wfMessage( 'authmanager-email-label' ), + 'help' => wfMessage( 'authmanager-email-help' ), + 'optional' => true, + ], + 'realname' => [ + 'type' => 'string', + 'label' => wfMessage( 'authmanager-realname-label' ), + 'help' => wfMessage( 'authmanager-realname-help' ), + 'optional' => true, + ], + ]; + + if ( !$config->get( 'EnableEmail' ) ) { + unset( $ret['email'] ); + } + + if ( in_array( 'realname', $config->get( 'HiddenPrefs' ), true ) ) { + unset( $ret['realname'] ); + } + + return $ret; + } + + /** + * Add data to the User object + * @param User $user User being created (not added to the database yet). + * This may become a "UserValue" in the future, or User may be refactored + * into such. + * @return StatusValue + */ + public function populateUser( $user ) { + if ( $this->email !== null && $this->email !== '' ) { + if ( !\Sanitizer::validateEmail( $this->email ) ) { + return StatusValue::newFatal( 'invalidemailaddress' ); + } + $user->setEmail( $this->email ); + } + if ( $this->realname !== null && $this->realname !== '' ) { + $user->setRealName( $this->realname ); + } + return StatusValue::newGood(); + } + +} diff --git a/includes/auth/UsernameAuthenticationRequest.php b/includes/auth/UsernameAuthenticationRequest.php new file mode 100644 index 0000000000..7bf8f1308e --- /dev/null +++ b/includes/auth/UsernameAuthenticationRequest.php @@ -0,0 +1,39 @@ + [ + 'type' => 'string', + 'label' => wfMessage( 'userlogin-yourname' ), + 'help' => wfMessage( 'authmanager-username-help' ), + ], + ]; + } +} diff --git a/includes/registration/ExtensionProcessor.php b/includes/registration/ExtensionProcessor.php index 26058c9b68..78f9370845 100644 --- a/includes/registration/ExtensionProcessor.php +++ b/includes/registration/ExtensionProcessor.php @@ -24,6 +24,7 @@ class ExtensionProcessor implements Processor { 'ContentHandlers', 'ConfigRegistry', 'SessionProviders', + 'AuthManagerAutoConfig', 'CentralIdLookupProviders', 'RateLimits', 'RecentChangesFlags', @@ -68,6 +69,7 @@ class ExtensionProcessor implements Processor { 'wgNamespaceProtection' => 'array_plus', 'wgCapitalLinkOverrides' => 'array_plus', 'wgRateLimits' => 'array_plus_2d', + 'wgAuthManagerAutoConfig' => 'array_plus_2d', ]; /** diff --git a/includes/session/SessionManager.php b/includes/session/SessionManager.php index 777d3d6e44..c3481e80ed 100644 --- a/includes/session/SessionManager.php +++ b/includes/session/SessionManager.php @@ -302,12 +302,13 @@ final class SessionManager implements SessionManagerInterface { } public function invalidateSessionsForUser( User $user ) { - global $wgAuth; - $user->setToken(); $user->saveSettings(); - $wgAuth->getUserInstance( $user )->resetAuthToken(); + $authUser = \MediaWiki\Auth\AuthManager::callLegacyAuthPlugin( 'getUserInstance', [ &$user ] ); + if ( $authUser ) { + $authUser->resetAuthToken(); + } foreach ( $this->getProviders() as $provider ) { $provider->invalidateSessionsForUser( $user ); @@ -370,14 +371,23 @@ final class SessionManager implements SessionManagerInterface { /** * Auto-create the given user, if necessary * @private Don't call this yourself. Let Setup.php do it for you at the right time. - * @note This more properly belongs in AuthManager, but we need it now. - * When AuthManager comes, this will be deprecated and will pass-through - * to the corresponding AuthManager method. + * @deprecated since 1.27, use MediaWiki\Auth\AuthManager::autoCreateUser instead * @param User $user User to auto-create * @return bool Success */ public static function autoCreateUser( User $user ) { - global $wgAuth; + global $wgAuth, $wgDisableAuthManager; + + // @codeCoverageIgnoreStart + if ( !$wgDisableAuthManager ) { + wfDeprecated( __METHOD__, '1.27' ); + return \MediaWiki\Auth\AuthManager::singleton()->autoCreateUser( + $user, + \MediaWiki\Auth\AuthManager::AUTOCREATE_SOURCE_SESSSION, + false + )->isGood(); + } + // @codeCoverageIgnoreEnd $logger = self::singleton()->logger; diff --git a/includes/specials/SpecialChangeEmail.php b/includes/specials/SpecialChangeEmail.php index b35446de65..376e51d33f 100644 --- a/includes/specials/SpecialChangeEmail.php +++ b/includes/specials/SpecialChangeEmail.php @@ -163,8 +163,6 @@ class SpecialChangeEmail extends FormSpecialPage { * @return Status */ private function attemptChange( User $user, $pass, $newaddr ) { - global $wgAuth; - if ( $newaddr != '' && !Sanitizer::validateEmail( $newaddr ) ) { return Status::newFatal( 'invalidemailaddress' ); } @@ -200,8 +198,7 @@ class SpecialChangeEmail extends FormSpecialPage { Hooks::run( 'PrefsEmailAudit', [ $user, $oldaddr, $newaddr ] ); $user->saveSettings(); - - $wgAuth->updateExternalDB( $user ); + MediaWiki\Auth\AuthManager::callLegacyAuthPlugin( 'updateExternalDB', [ $user ] ); return $status; } diff --git a/includes/specials/SpecialUserlogin.php b/includes/specials/SpecialUserlogin.php index 45315a79af..11182d4a1b 100644 --- a/includes/specials/SpecialUserlogin.php +++ b/includes/specials/SpecialUserlogin.php @@ -690,7 +690,12 @@ class LoginForm extends SpecialPage { $status = $u->addToDatabase(); if ( !$status->isOK() ) { - return $status; + if ( $status->hasMessage( 'userexists' ) ) { + // AuthManager probably just added the user. + $u->saveSettings(); + } else { + return $status; + } } if ( $wgAuth->allowPasswordChange() ) { @@ -702,10 +707,12 @@ class LoginForm extends SpecialPage { SessionManager::singleton()->invalidateSessionsForUser( $u ); Hooks::run( 'LocalUserCreated', [ $u, $autocreate ] ); - $oldUser = $u; - $wgAuth->initUser( $u, $autocreate ); - if ( $oldUser !== $u ) { - wfWarn( get_class( $wgAuth ) . '::initUser() replaced the user object' ); + if ( $wgAuth && !$wgAuth instanceof MediaWiki\Auth\AuthManagerAuthPlugin ) { + $oldUser = $u; + $wgAuth->initUser( $u, $autocreate ); + if ( $oldUser !== $u ) { + wfWarn( get_class( $wgAuth ) . '::initUser() replaced the user object' ); + } } $u->saveSettings(); @@ -857,10 +864,12 @@ class LoginForm extends SpecialPage { $this->mAbortLoginErrorMsg = 'resetpass-expired'; } else { Hooks::run( 'UserLoggedIn', [ $u ] ); - $oldUser = $u; - $wgAuth->updateUser( $u ); - if ( $oldUser !== $u ) { - wfWarn( get_class( $wgAuth ) . '::updateUser() replaced the user object' ); + if ( $wgAuth && !$wgAuth instanceof MediaWiki\Auth\AuthManagerAuthPlugin ) { + $oldUser = $u; + $wgAuth->updateUser( $u ); + if ( $oldUser !== $u ) { + wfWarn( get_class( $wgAuth ) . '::updateUser() replaced the user object' ); + } } $wgUser = $u; // This should set it for OutputPage and the Skin @@ -1817,8 +1826,7 @@ class LoginForm extends SpecialPage { } /** - * Private function to check password expiration, until AuthManager comes - * along to handle that. + * Private function to check password expiration, until this is rewritten for AuthManager. * @param User $user * @return string|bool */ diff --git a/includes/specials/SpecialUserrights.php b/includes/specials/SpecialUserrights.php index be110aa9cc..d5affc7873 100644 --- a/includes/specials/SpecialUserrights.php +++ b/includes/specials/SpecialUserrights.php @@ -250,8 +250,6 @@ class UserrightsPage extends SpecialPage { * @return array Tuple of added, then removed groups */ function doSaveUserGroups( $user, $add, $remove, $reason = '' ) { - global $wgAuth; - // Validate input set... $isself = $user->getName() == $this->getUser()->getName(); $groups = $user->getGroups(); @@ -293,7 +291,9 @@ class UserrightsPage extends SpecialPage { // update groups in external authentication database Hooks::run( 'UserGroupsChanged', [ $user, $add, $remove, $this->getUser(), $reason ] ); - $wgAuth->updateExternalDBGroups( $user, $add, $remove ); + MediaWiki\Auth\AuthManager::callLegacyAuthPlugin( + 'updateExternalDBGroups', [ $user, $add, $remove ] + ); wfDebug( 'oldGroups: ' . print_r( $oldGroups, true ) . "\n" ); wfDebug( 'newGroups: ' . print_r( $newGroups, true ) . "\n" ); diff --git a/includes/user/User.php b/includes/user/User.php index 1b0bcc0fc0..f2facdfeba 100644 --- a/includes/user/User.php +++ b/includes/user/User.php @@ -23,6 +23,9 @@ use MediaWiki\MediaWikiServices; use MediaWiki\Session\SessionManager; use MediaWiki\Session\Token; +use MediaWiki\Auth\AuthManager; +use MediaWiki\Auth\AuthenticationResponse; +use MediaWiki\Auth\AuthenticationRequest; /** * String Some punctuation to prevent editing from broken text-mangling proxies. @@ -678,6 +681,8 @@ class User implements IDBAccessObject { * @since 1.27 */ public static function newSystemUser( $name, $options = [] ) { + global $wgDisableAuthManager; + $options += [ 'validate' => 'valid', 'create' => true, @@ -689,13 +694,15 @@ class User implements IDBAccessObject { return null; } + $fields = self::selectFields(); + if ( $wgDisableAuthManager ) { + $fields = array_merge( $fields, [ 'user_password', 'user_newpassword' ] ); + } + $dbw = wfGetDB( DB_MASTER ); $row = $dbw->selectRow( 'user', - array_merge( - self::selectFields(), - [ 'user_password', 'user_newpassword' ] - ), + $fields, [ 'user_name' => $name ], __METHOD__ ); @@ -708,40 +715,50 @@ class User implements IDBAccessObject { // A user is considered to exist as a non-system user if it has a // password set, or a temporary password set, or an email set, or a // non-invalid token. - $passwordFactory = new PasswordFactory(); - $passwordFactory->init( RequestContext::getMain()->getConfig() ); - try { - $password = $passwordFactory->newFromCiphertext( $row->user_password ); - } catch ( PasswordError $e ) { - wfDebug( 'Invalid password hash found in database.' ); - $password = PasswordFactory::newInvalidPassword(); - } - try { - $newpassword = $passwordFactory->newFromCiphertext( $row->user_newpassword ); - } catch ( PasswordError $e ) { - wfDebug( 'Invalid password hash found in database.' ); - $newpassword = PasswordFactory::newInvalidPassword(); - } - if ( !$password instanceof InvalidPassword || !$newpassword instanceof InvalidPassword - || $user->mEmail || $user->mToken !== self::INVALID_TOKEN - ) { + if ( !$user->mEmail && $user->mToken === self::INVALID_TOKEN ) { + if ( $wgDisableAuthManager ) { + $passwordFactory = new PasswordFactory(); + $passwordFactory->init( RequestContext::getMain()->getConfig() ); + try { + $password = $passwordFactory->newFromCiphertext( $row->user_password ); + } catch ( PasswordError $e ) { + wfDebug( 'Invalid password hash found in database.' ); + $password = PasswordFactory::newInvalidPassword(); + } + try { + $newpassword = $passwordFactory->newFromCiphertext( $row->user_newpassword ); + } catch ( PasswordError $e ) { + wfDebug( 'Invalid password hash found in database.' ); + $newpassword = PasswordFactory::newInvalidPassword(); + } + $canAuthenticate = !$password instanceof InvalidPassword || + !$newpassword instanceof InvalidPassword; + } else { + $canAuthenticate = AuthManager::singleton()->userCanAuthenticate( $name ); + } + } + if ( $user->mEmail || $user->mToken !== self::INVALID_TOKEN || $canAuthenticate ) { // User exists. Steal it? if ( !$options['steal'] ) { return null; } - $nopass = PasswordFactory::newInvalidPassword()->toString(); + if ( $wgDisableAuthManager ) { + $nopass = PasswordFactory::newInvalidPassword()->toString(); + $dbw->update( + 'user', + [ + 'user_password' => $nopass, + 'user_newpassword' => $nopass, + 'user_newpass_time' => null, + ], + [ 'user_id' => $user->getId() ], + __METHOD__ + ); + } else { + AuthManager::singleton()->revokeAccessForUser( $name ); + } - $dbw->update( - 'user', - [ - 'user_password' => $nopass, - 'user_newpassword' => $nopass, - 'user_newpass_time' => null, - ], - [ 'user_id' => $user->getId() ], - __METHOD__ - ); $user->invalidateEmail(); $user->mToken = self::INVALID_TOKEN; $user->saveSettings(); @@ -1080,8 +1097,9 @@ class User implements IDBAccessObject { } // Reject various classes of invalid names - global $wgAuth; - $name = $wgAuth->getCanonicalName( $t->getText() ); + $name = AuthManager::callLegacyAuthPlugin( + 'getCanonicalName', [ $t->getText() ], $t->getText() + ); switch ( $validate ) { case false: @@ -1406,7 +1424,7 @@ class User implements IDBAccessObject { * @see $wgAutopromoteOnce */ public function addAutopromoteOnceGroups( $event ) { - global $wgAutopromoteOnceLogInRC, $wgAuth; + global $wgAutopromoteOnceLogInRC; if ( wfReadOnly() || !$this->getId() ) { return []; @@ -1427,7 +1445,7 @@ class User implements IDBAccessObject { } // update groups in external authentication database Hooks::run( 'UserGroupsChanged', [ $this, $toPromote, [], false, false ] ); - $wgAuth->updateExternalDBGroups( $this, $toPromote ); + AuthManager::callLegacyAuthPlugin( 'updateExternalDBGroups', [ $this, $toPromote ] ); $newGroups = array_merge( $oldGroups, $toPromote ); // all groups @@ -2042,9 +2060,8 @@ class User implements IDBAccessObject { if ( $this->mLocked !== null ) { return $this->mLocked; } - global $wgAuth; - $authUser = $wgAuth->getUserInstance( $this ); - $this->mLocked = (bool)$authUser->isLocked(); + $authUser = AuthManager::callLegacyAuthPlugin( 'getUserInstance', [ &$this ], null ); + $this->mLocked = $authUser && $authUser->isLocked(); Hooks::run( 'UserIsLocked', [ $this, &$this->mLocked ] ); return $this->mLocked; } @@ -2060,9 +2077,8 @@ class User implements IDBAccessObject { } $this->getBlockedStatus(); if ( !$this->mHideName ) { - global $wgAuth; - $authUser = $wgAuth->getUserInstance( $this ); - $this->mHideName = (bool)$authUser->isHidden(); + $authUser = AuthManager::callLegacyAuthPlugin( 'getUserInstance', [ &$this ], null ); + $this->mHideName = $authUser && $authUser->isHidden(); Hooks::run( 'UserIsHidden', [ $this, &$this->mHideName ] ); } return $this->mHideName; @@ -2468,13 +2484,17 @@ class User implements IDBAccessObject { * wipes it, so the account cannot be logged in until * a new password is set, for instance via e-mail. * - * @deprecated since 1.27. AuthManager is coming. + * @deprecated since 1.27, use AuthManager instead * @param string $str New password to set * @throws PasswordError On failure * @return bool */ public function setPassword( $str ) { - global $wgAuth; + global $wgAuth, $wgDisableAuthManager; + + if ( !$wgDisableAuthManager ) { + return $this->setPasswordInternal( $str ); + } if ( $str !== null ) { if ( !$wgAuth->allowPasswordChange() ) { @@ -2493,7 +2513,6 @@ class User implements IDBAccessObject { $this->setOption( 'watchlisttoken', false ); $this->setPasswordInternal( $str ); - SessionManager::singleton()->invalidateSessionsForUser( $this ); return true; } @@ -2501,18 +2520,21 @@ class User implements IDBAccessObject { /** * Set the password and reset the random token unconditionally. * - * @deprecated since 1.27. AuthManager is coming. + * @deprecated since 1.27, use AuthManager instead * @param string|null $str New password to set or null to set an invalid * password hash meaning that the user will not be able to log in * through the web interface. */ public function setInternalPassword( $str ) { - global $wgAuth; + global $wgAuth, $wgDisableAuthManager; + + if ( !$wgDisableAuthManager ) { + $this->setPasswordInternal( $str ); + } if ( $wgAuth->allowSetLocalPassword() ) { $this->setOption( 'watchlisttoken', false ); $this->setPasswordInternal( $str ); - SessionManager::singleton()->invalidateSessionsForUser( $this ); } } @@ -2522,31 +2544,68 @@ class User implements IDBAccessObject { * @param string|null $str New password to set or null to set an invalid * password hash meaning that the user will not be able to log in * through the web interface. + * @return bool Success */ private function setPasswordInternal( $str ) { - $id = self::idFromName( $this->getName(), self::READ_LATEST ); - if ( $id == 0 ) { - throw new LogicException( 'Cannot set a password for a user that is not in the database.' ); + global $wgDisableAuthManager; + + if ( $wgDisableAuthManager ) { + $id = self::idFromName( $this->getName(), self::READ_LATEST ); + if ( $id == 0 ) { + throw new LogicException( 'Cannot set a password for a user that is not in the database.' ); + } + + $passwordFactory = new PasswordFactory(); + $passwordFactory->init( RequestContext::getMain()->getConfig() ); + $dbw = wfGetDB( DB_MASTER ); + $dbw->update( + 'user', + [ + 'user_password' => $passwordFactory->newFromPlaintext( $str )->toString(), + 'user_newpassword' => PasswordFactory::newInvalidPassword()->toString(), + 'user_newpass_time' => $dbw->timestampOrNull( null ), + ], + [ + 'user_id' => $id, + ], + __METHOD__ + ); + + // When the main password is changed, invalidate all bot passwords too + BotPassword::invalidateAllPasswordsForUser( $this->getName() ); + } else { + $manager = AuthManager::singleton(); + + // If the user doesn't exist yet, fail + if ( !$manager->userExists( $this->getName() ) ) { + throw new LogicException( 'Cannot set a password for a user that is not in the database.' ); + } + + $data = [ + 'username' => $this->getName(), + 'password' => $str, + 'retype' => $str, + ]; + $reqs = $manager->getAuthenticationRequests( AuthManager::ACTION_CHANGE, $this ); + $reqs = AuthenticationRequest::loadRequestsFromSubmission( $reqs, $data ); + foreach ( $reqs as $req ) { + $status = $manager->allowsAuthenticationDataChange( $req ); + if ( !$status->isOk() ) { + \MediaWiki\Logger\LoggerFactory::getInstance( 'authentication' ) + ->info( __METHOD__ . ': Password change rejected: ' . $status->getWikiText() ); + return false; + } + } + foreach ( $reqs as $req ) { + $manager->changeAuthenticationData( $req ); + } + + $this->setOption( 'watchlisttoken', false ); } - $passwordFactory = new PasswordFactory(); - $passwordFactory->init( RequestContext::getMain()->getConfig() ); - $dbw = wfGetDB( DB_MASTER ); - $dbw->update( - 'user', - [ - 'user_password' => $passwordFactory->newFromPlaintext( $str )->toString(), - 'user_newpassword' => PasswordFactory::newInvalidPassword()->toString(), - 'user_newpass_time' => $dbw->timestampOrNull( null ), - ], - [ - 'user_id' => $id, - ], - __METHOD__ - ); + SessionManager::singleton()->invalidateSessionsForUser( $this ); - // When the main password is changed, invalidate all bot passwords too - BotPassword::invalidateAllPasswordsForUser( $this->getName() ); + return true; } /** @@ -2608,63 +2667,76 @@ class User implements IDBAccessObject { /** * Set the password for a password reminder or new account email * - * @deprecated since 1.27, AuthManager is coming + * @deprecated since 1.27. Some way to do this via AuthManager (probably + * involving TemporaryPasswordAuthenticationRequest) has yet to be + * designed. * @param string $str New password to set or null to set an invalid * password hash meaning that the user will not be able to use it * @param bool $throttle If true, reset the throttle timestamp to the present */ public function setNewpassword( $str, $throttle = true ) { - $id = $this->getId(); - if ( $id == 0 ) { - throw new LogicException( 'Cannot set new password for a user that is not in the database.' ); - } + global $wgDisableAuthManager; - $dbw = wfGetDB( DB_MASTER ); + if ( $wgDisableAuthManager ) { + $id = $this->getId(); + if ( $id == 0 ) { + throw new LogicException( 'Cannot set new password for a user that is not in the database.' ); + } - $passwordFactory = new PasswordFactory(); - $passwordFactory->init( RequestContext::getMain()->getConfig() ); - $update = [ - 'user_newpassword' => $passwordFactory->newFromPlaintext( $str )->toString(), - ]; + $dbw = wfGetDB( DB_MASTER ); - if ( $str === null ) { - $update['user_newpass_time'] = null; - } elseif ( $throttle ) { - $update['user_newpass_time'] = $dbw->timestamp(); - } + $passwordFactory = new PasswordFactory(); + $passwordFactory->init( RequestContext::getMain()->getConfig() ); + $update = [ + 'user_newpassword' => $passwordFactory->newFromPlaintext( $str )->toString(), + ]; + + if ( $str === null ) { + $update['user_newpass_time'] = null; + } elseif ( $throttle ) { + $update['user_newpass_time'] = $dbw->timestamp(); + } - $dbw->update( 'user', $update, [ 'user_id' => $id ], __METHOD__ ); + $dbw->update( 'user', $update, [ 'user_id' => $id ], __METHOD__ ); + } else { + throw new BadMethodCallException( __METHOD__ . ' has been removed in 1.27' ); + } } /** * Has password reminder email been sent within the last * $wgPasswordReminderResendTime hours? + * @deprecated Removed in 1.27. See above. * @return bool */ public function isPasswordReminderThrottled() { - global $wgPasswordReminderResendTime; + global $wgPasswordReminderResendTime, $wgDisableAuthManager; - if ( !$wgPasswordReminderResendTime ) { - return false; - } + if ( $wgDisableAuthManager ) { + if ( !$wgPasswordReminderResendTime ) { + return false; + } - $this->load(); + $this->load(); - $db = ( $this->queryFlagsUsed & self::READ_LATEST ) - ? wfGetDB( DB_MASTER ) - : wfGetDB( DB_SLAVE ); - $newpassTime = $db->selectField( - 'user', - 'user_newpass_time', - [ 'user_id' => $this->getId() ], - __METHOD__ - ); + $db = ( $this->queryFlagsUsed & self::READ_LATEST ) + ? wfGetDB( DB_MASTER ) + : wfGetDB( DB_SLAVE ); + $newpassTime = $db->selectField( + 'user', + 'user_newpass_time', + [ 'user_id' => $this->getId() ], + __METHOD__ + ); - if ( $newpassTime === null ) { - return false; + if ( $newpassTime === null ) { + return false; + } + $expiry = wfTimestamp( TS_UNIX, $newpassTime ) + $wgPasswordReminderResendTime * 3600; + return time() < $expiry; + } else { + throw new BadMethodCallException( __METHOD__ . ' has been removed in 1.27' ); } - $expiry = wfTimestamp( TS_UNIX, $newpassTime ) + $wgPasswordReminderResendTime * 3600; - return time() < $expiry; } /** @@ -4148,110 +4220,140 @@ class User implements IDBAccessObject { /** * Check to see if the given clear-text password is one of the accepted passwords - * @deprecated since 1.27. AuthManager is coming. + * @deprecated since 1.27, use AuthManager instead * @param string $password User password * @return bool True if the given password is correct, otherwise False */ public function checkPassword( $password ) { - global $wgAuth, $wgLegacyEncoding; + global $wgAuth, $wgLegacyEncoding, $wgDisableAuthManager; - $this->load(); + if ( $wgDisableAuthManager ) { + $this->load(); - // Some passwords will give a fatal Status, which means there is - // some sort of technical or security reason for this password to - // be completely invalid and should never be checked (e.g., T64685) - if ( !$this->checkPasswordValidity( $password )->isOK() ) { - return false; - } + // Some passwords will give a fatal Status, which means there is + // some sort of technical or security reason for this password to + // be completely invalid and should never be checked (e.g., T64685) + if ( !$this->checkPasswordValidity( $password )->isOK() ) { + return false; + } - // Certain authentication plugins do NOT want to save - // domain passwords in a mysql database, so we should - // check this (in case $wgAuth->strict() is false). - if ( $wgAuth->authenticate( $this->getName(), $password ) ) { - return true; - } elseif ( $wgAuth->strict() ) { - // Auth plugin doesn't allow local authentication - return false; - } elseif ( $wgAuth->strictUserAuth( $this->getName() ) ) { - // Auth plugin doesn't allow local authentication for this user name - return false; - } + // Certain authentication plugins do NOT want to save + // domain passwords in a mysql database, so we should + // check this (in case $wgAuth->strict() is false). + if ( $wgAuth->authenticate( $this->getName(), $password ) ) { + return true; + } elseif ( $wgAuth->strict() ) { + // Auth plugin doesn't allow local authentication + return false; + } elseif ( $wgAuth->strictUserAuth( $this->getName() ) ) { + // Auth plugin doesn't allow local authentication for this user name + return false; + } - $passwordFactory = new PasswordFactory(); - $passwordFactory->init( RequestContext::getMain()->getConfig() ); - $db = ( $this->queryFlagsUsed & self::READ_LATEST ) - ? wfGetDB( DB_MASTER ) - : wfGetDB( DB_SLAVE ); + $passwordFactory = new PasswordFactory(); + $passwordFactory->init( RequestContext::getMain()->getConfig() ); + $db = ( $this->queryFlagsUsed & self::READ_LATEST ) + ? wfGetDB( DB_MASTER ) + : wfGetDB( DB_SLAVE ); - try { - $mPassword = $passwordFactory->newFromCiphertext( $db->selectField( - 'user', 'user_password', [ 'user_id' => $this->getId() ], __METHOD__ - ) ); - } catch ( PasswordError $e ) { - wfDebug( 'Invalid password hash found in database.' ); - $mPassword = PasswordFactory::newInvalidPassword(); - } + try { + $mPassword = $passwordFactory->newFromCiphertext( $db->selectField( + 'user', 'user_password', [ 'user_id' => $this->getId() ], __METHOD__ + ) ); + } catch ( PasswordError $e ) { + wfDebug( 'Invalid password hash found in database.' ); + $mPassword = PasswordFactory::newInvalidPassword(); + } - if ( !$mPassword->equals( $password ) ) { - if ( $wgLegacyEncoding ) { - // Some wikis were converted from ISO 8859-1 to UTF-8, the passwords can't be converted - // Check for this with iconv - $cp1252Password = iconv( 'UTF-8', 'WINDOWS-1252//TRANSLIT', $password ); - if ( $cp1252Password === $password || !$mPassword->equals( $cp1252Password ) ) { + if ( !$mPassword->equals( $password ) ) { + if ( $wgLegacyEncoding ) { + // Some wikis were converted from ISO 8859-1 to UTF-8, the passwords can't be converted + // Check for this with iconv + $cp1252Password = iconv( 'UTF-8', 'WINDOWS-1252//TRANSLIT', $password ); + if ( $cp1252Password === $password || !$mPassword->equals( $cp1252Password ) ) { + return false; + } + } else { return false; } - } else { - return false; } - } - if ( $passwordFactory->needsUpdate( $mPassword ) && !wfReadOnly() ) { - $this->setPasswordInternal( $password ); - } + if ( $passwordFactory->needsUpdate( $mPassword ) && !wfReadOnly() ) { + $this->setPasswordInternal( $password ); + } - return true; + return true; + } else { + $manager = AuthManager::singleton(); + $reqs = AuthenticationRequest::loadRequestsFromSubmission( + $manager->getAuthenticationRequests( AuthManager::ACTION_LOGIN ), + [ + 'username' => $this->getName(), + 'password' => $password, + ] + ); + $res = AuthManager::singleton()->beginAuthentication( $reqs, 'null:' ); + switch ( $res->status ) { + case AuthenticationResponse::PASS: + return true; + case AuthenticationResponse::FAIL: + // Hope it's not a PreAuthenticationProvider that failed... + \MediaWiki\Logger\LoggerFactory::getInstance( 'authentication' ) + ->info( __METHOD__ . ': Authentication failed: ' . $res->message->plain() ); + return false; + default: + throw new BadMethodCallException( + 'AuthManager returned a response unsupported by ' . __METHOD__ + ); + } + } } /** * Check if the given clear-text password matches the temporary password * sent by e-mail for password reset operations. * - * @deprecated since 1.27. AuthManager is coming. + * @deprecated since 1.27, use AuthManager instead * @param string $plaintext * @return bool True if matches, false otherwise */ public function checkTemporaryPassword( $plaintext ) { - global $wgNewPasswordExpiry; + global $wgNewPasswordExpiry, $wgDisableAuthManager; - $this->load(); + if ( $wgDisableAuthManager ) { + $this->load(); - $passwordFactory = new PasswordFactory(); - $passwordFactory->init( RequestContext::getMain()->getConfig() ); - $db = ( $this->queryFlagsUsed & self::READ_LATEST ) - ? wfGetDB( DB_MASTER ) - : wfGetDB( DB_SLAVE ); + $passwordFactory = new PasswordFactory(); + $passwordFactory->init( RequestContext::getMain()->getConfig() ); + $db = ( $this->queryFlagsUsed & self::READ_LATEST ) + ? wfGetDB( DB_MASTER ) + : wfGetDB( DB_SLAVE ); - $row = $db->selectRow( - 'user', - [ 'user_newpassword', 'user_newpass_time' ], - [ 'user_id' => $this->getId() ], - __METHOD__ - ); - try { - $newPassword = $passwordFactory->newFromCiphertext( $row->user_newpassword ); - } catch ( PasswordError $e ) { - wfDebug( 'Invalid password hash found in database.' ); - $newPassword = PasswordFactory::newInvalidPassword(); - } + $row = $db->selectRow( + 'user', + [ 'user_newpassword', 'user_newpass_time' ], + [ 'user_id' => $this->getId() ], + __METHOD__ + ); + try { + $newPassword = $passwordFactory->newFromCiphertext( $row->user_newpassword ); + } catch ( PasswordError $e ) { + wfDebug( 'Invalid password hash found in database.' ); + $newPassword = PasswordFactory::newInvalidPassword(); + } - if ( $newPassword->equals( $plaintext ) ) { - if ( is_null( $row->user_newpass_time ) ) { - return true; + if ( $newPassword->equals( $plaintext ) ) { + if ( is_null( $row->user_newpass_time ) ) { + return true; + } + $expiry = wfTimestamp( TS_UNIX, $row->user_newpass_time ) + $wgNewPasswordExpiry; + return ( time() < $expiry ); + } else { + return false; } - $expiry = wfTimestamp( TS_UNIX, $row->user_newpass_time ) + $wgNewPasswordExpiry; - return ( time() < $expiry ); } else { - return false; + // Can't check the temporary password individually. + return $this->checkPassword( $plaintext ); } } @@ -5107,6 +5209,7 @@ class User implements IDBAccessObject { * Add a newuser log entry for this user. * Before 1.19 the return value was always true. * + * @deprecated since 1.27, AuthManager handles logging * @param string|bool $action Account creation type. * - String, one of the following values: * - 'create' for an anonymous user creating an account for himself. @@ -5119,14 +5222,13 @@ class User implements IDBAccessObject { * - true will be converted to 'byemail' * - false will be converted to 'create' if this object is the same as * $wgUser and to 'create2' otherwise - * * @param string $reason User supplied reason - * - * @return int|bool True if not $wgNewUserLog; otherwise ID of log item or 0 on failure + * @return int|bool True if not $wgNewUserLog or not $wgDisableAuthManager; + * otherwise ID of log item or 0 on failure */ public function addNewUserLogEntry( $action = false, $reason = '' ) { - global $wgUser, $wgNewUserLog; - if ( empty( $wgNewUserLog ) ) { + global $wgUser, $wgNewUserLog, $wgDisableAuthManager; + if ( !$wgDisableAuthManager || empty( $wgNewUserLog ) ) { return true; // disabled } @@ -5167,6 +5269,7 @@ class User implements IDBAccessObject { * Used by things like CentralAuth and perhaps other authplugins. * Consider calling addNewUserLogEntry() directly instead. * + * @deprecated since 1.27, AuthManager handles logging * @return bool */ public function addNewUserLogEntryAutoCreate() { diff --git a/languages/i18n/en.json b/languages/i18n/en.json index 08d95b9e16..6ee785b8a7 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -4089,5 +4089,48 @@ "log-action-filter-suppress-block": "User supppression by block", "log-action-filter-suppress-reblock": "User suppression by reblock", "log-action-filter-upload-upload": "New upload", - "log-action-filter-upload-overwrite": "Reupload" + "log-action-filter-upload-overwrite": "Reupload", + "authmanager-authn-not-in-progress": "Authentication is not in progress or session data has been lost. Please start again from the beginning.", + "authmanager-authn-no-primary": "The supplied credentials could not be authenticated.", + "authmanager-authn-no-local-user": "The supplied credentials are not associated with any user on this wiki.", + "authmanager-authn-no-local-user-link": "The supplied credentials are valid but are not associated with any user on this wiki. Login in a different way, or create a new user, and you will have an option to link your previous credentials to that account.", + "authmanager-authn-autocreate-failed": "Auto-creation of a local account failed: $1", + "authmanager-change-not-supported": "The supplied credentials cannot be changed, as nothing would use them.", + "authmanager-create-disabled": "Account creation is disabled.", + "authmanager-create-from-login": "To create your account, please fill in the fields below.", + "authmanager-create-not-in-progress": "Account creation is not in progress or session data has been lost. Please start again from the beginning.", + "authmanager-create-no-primary": "The supplied credentials could not be used for account creation.", + "authmanager-link-no-primary": "The supplied credentials could not be used for account linking.", + "authmanager-link-not-in-progress": "Account linking is not in progress or session data has been lost. Please start again from the beginning.", + "authmanager-authplugin-setpass-failed-title": "Password change failed", + "authmanager-authplugin-setpass-failed-message": "The authentication plugin denied the password change.", + "authmanager-authplugin-create-fail": "The authentication plugin denied the account creation.", + "authmanager-authplugin-setpass-denied": "The authentication plugin does not allow changing passwords.", + "authmanager-authplugin-setpass-bad-domain": "Invalid domain.", + "authmanager-autocreate-noperm": "Automatic account creation is not allowed.", + "authmanager-autocreate-exception": "Automatic account creation temporarily disabled due to prior errors.", + "authmanager-userdoesnotexist": "User account \"$1\" is not registered.", + "authmanager-userlogin-remembermypassword-help": "Whether the password should be remembered for longer than the length of the session.", + "authmanager-username-help": "Username for authentication.", + "authmanager-password-help": "Password for authentication.", + "authmanager-domain-help": "Domain for external authentication.", + "authmanager-retype-help": "Password again to confirm.", + "authmanager-email-label": "Email", + "authmanager-email-help": "Email address", + "authmanager-realname-label": "Real name", + "authmanager-realname-help": "Real name of the user", + "authmanager-provider-password": "Password-based authentication", + "authmanager-provider-password-domain": "Password- and domain-based authentication", + "authmanager-account-password-domain": "$1@$2", + "authmanager-provider-temporarypassword": "Temporary password", + "authprovider-confirmlink-message": "Based on your recent login attempts, the following accounts can be linked to your wiki account. Linking them enables logging in via those accounts. Please select which ones should be linked.", + "authprovider-confirmlink-option": "$1 ($2)", + "authprovider-confirmlink-request-label": "Accounts which should be linked", + "authprovider-confirmlink-request-help": "", + "authprovider-confirmlink-success-line": "$1: Linked successfully.", + "authprovider-confirmlink-failed-line": "$1: $2", + "authprovider-confirmlink-failed": "Account linking did not fully succeed: $1", + "authprovider-confirmlink-ok-help": "Continue after displaying linking failure messages.", + "authprovider-resetpass-skip-label": "Skip", + "authprovider-resetpass-skip-help": "Skip resetting the password." } diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index 005b659263..f0e15f5ac2 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -4268,5 +4268,48 @@ "log-action-filter-suppress-block": "{{doc-log-action-filter-action|suppress|block}}", "log-action-filter-suppress-reblock": "{{doc-log-action-filter-action|suppress|reblock}}", "log-action-filter-upload-upload": "{{doc-log-action-filter-action|upload|upload}}", - "log-action-filter-upload-overwrite": "{{doc-log-action-filter-action|upload|overwrite}}" + "log-action-filter-upload-overwrite": "{{doc-log-action-filter-action|upload|overwrite}}", + "authmanager-authn-not-in-progress": "Error message when AuthManager session data is lost during authentication, or the user hits the \"continue\" endpoint without an active authentication attempt.", + "authmanager-authn-no-primary": "Error message when no AuthenticationProvider handles the AuthenticationRequests for login. This might mean the user needs to fill out all the form fields.", + "authmanager-authn-no-local-user": "Error message when authentication somehow succeeds without a username being known. This probably should never happen.", + "authmanager-authn-no-local-user-link": "Error message when federated authentication (e.g. \"login with Google\") succeeds, but no account is associated.", + "authmanager-authn-autocreate-failed": "Error message when auto-creation fails during login. Parameters:\n* $1 - Error message from the account creation attempt, as wikitext.", + "authmanager-change-not-supported": "Error message when all PrimaryAuthenticationProviders ignore the change request.", + "authmanager-create-disabled": "Message displayed when account creation is disabled.", + "authmanager-create-from-login": "Message displayed when moving from login to account creation and additional data must be collected from the user.", + "authmanager-create-not-in-progress": "Error message when AuthManager session data is lost during account creation, or the user hits the \"continue\" endpoint without an active account creation attempt.", + "authmanager-create-no-primary": "Error message when no AuthenticationProvider handles the AuthenticationRequests for account creation. This might mean the user needs to fill out all the form fields.", + "authmanager-link-no-primary": "Error message when no AuthenticationProvider handles the AuthenticationRequests for account linking. This might mean the user needs to fill out all the form fields.", + "authmanager-link-not-in-progress": "Error message when AuthManager session data is lost during account linking, or the user hits the \"continue\" endpoint without an active account link attempt.", + "authmanager-authplugin-setpass-failed-title": "Title of error page from AuthManager if AuthPlugin returns false from its setPassword() method.", + "authmanager-authplugin-setpass-failed-message": "Text of error page from AuthManager if AuthPlugin returns false from its setPassword() method.", + "authmanager-authplugin-create-fail": "Error message from AuthManager if the AuthPlugin returns false from its addUser() method.", + "authmanager-authplugin-setpass-denied": "Error message from AuthManager if the AuthPlugin returns false from its allowPasswordChange() method.", + "authmanager-authplugin-setpass-bad-domain": "Error message from AuthManager if the AuthPlugin rejects the passed domain.", + "authmanager-autocreate-noperm": "Error message when auto-creation fails due to lack of permission.", + "authmanager-autocreate-exception": "Error message when auto-creation fails because we tried recently and an exception was thrown, so we're not going to try again yet.", + "authmanager-userdoesnotexist": "Error message when a user account does not exist. Parameters:\n* $1 - User name.", + "authmanager-userlogin-remembermypassword-help": "Description of the field with label {{msg-mw|userlogin-remembermypassword}}.", + "authmanager-username-help": "Description of the field with label {{msg-mw|userlogin-yourname}}.", + "authmanager-password-help": "Description of the field with label {{msg-mw|userlogin-yourpassword}}.", + "authmanager-domain-help": "Description of the field with label {{msg-mw|yourdomainname}}.", + "authmanager-retype-help": "Description of the field with label {{msg-mw|createacct-yourpasswordagain}}.", + "authmanager-email-label": "Label for the email field.", + "authmanager-email-help": "Description of the field with label {{msg-mw|authmanager-email-label}}.", + "authmanager-realname-label": "Label for the realname field.", + "authmanager-realname-help": "Description of the field with label {{msg-mw|authmanager-realname-label}}.", + "authmanager-provider-password": "Description for PasswordAuthenticationRequest. Will be used as $1 in messages such as {{msg-mw|authprovider-confirmlink-option}}.", + "authmanager-provider-password-domain": "Description for PasswordDomainAuthenticationRequest. Will be used as $1 in messages such as {{msg-mw|authprovider-confirmlink-option}}.", + "authmanager-account-password-domain": "Format to display username and domain for PasswordDomainAuthenticationRequest. Will be used as $2 in messages such as {{msg-mw|authprovider-confirmlink-option}}. Parameters:\n* $1 - Username\n* $2 - Domain", + "authmanager-provider-temporarypassword": "Description for TemporaryPasswordAuthenticationRequest. Will be used as $1 in messages such as {{msg-mw|authprovider-confirmlink-option}}.", + "authprovider-confirmlink-message": "Message from ConfirmLinkSecondaryAuthenticationProvider to indicate that credentials may be linked.", + "authprovider-confirmlink-option": "Used to format linkable credentials in ConfirmLinkSecondaryAuthenticationProvider. Parameters:\n* $1 - Credential provider (e.g. the name of the third-party authentication service).\n* $2 - Credential account (e.g. the email address).", + "authprovider-confirmlink-request-label": "Form field label for the list of linkable credentials", + "authprovider-confirmlink-request-help": "Form field help text", + "authprovider-confirmlink-success-line": "Line to display that credentials were linked successfully. Parameters:\n* $1 - Linked credentials, formatted with {{msg-mw|authprovider-confirmlink-option}}\n\nSee also:\n* {{msg-mw|authprovider-confirmlink-failed}}\n* {{msg-mw|authprovider-confirmlink-failed-line}}", + "authprovider-confirmlink-failed-line": "Line to display that credentials were not linked successfully. Parameters:\n* $1 - Credentials that failed, formatted with {{msg-mw|authprovider-confirmlink-option}}\n* $2 - Failure message text.\n\nSee also:\n* {{msg-mw|authprovider-confirmlink-failed}}\n* {{msg-mw|authprovider-confirmlink-success-line}}", + "authprovider-confirmlink-failed": "Used to prefix the list of individual link statuses when some did not succeed. Parameters:\n* $1 - Failure message, or a wikitext bulleted list of failure messages.\n\nSee also:\n* {{msg-mw|authprovider-confirmlink-success-line}}\n* {{msg-mw|authprovider-confirmlink-failed-line}}", + "authprovider-confirmlink-ok-help": "Description of the \"ok\" field when ConfirmLinkSecondaryAuthenticationProvider needs to display link failure messages to the user.", + "authprovider-resetpass-skip-label": "Label for the \"Skip\" button when it's possible to skip resetting a password in ResetPasswordSecondaryAuthenticationProvider.", + "authprovider-resetpass-skip-help": "Description of the option to skip resetting a password in ResetPasswordSecondaryAuthenticationProvider." } diff --git a/tests/TestsAutoLoader.php b/tests/TestsAutoLoader.php index 26085b8239..ebb6d901c3 100644 --- a/tests/TestsAutoLoader.php +++ b/tests/TestsAutoLoader.php @@ -65,6 +65,10 @@ $wgAutoloadClasses += [ 'UserWrapper' => "$testDir/phpunit/includes/api/UserWrapper.php", 'RandomImageGenerator' => "$testDir/phpunit/includes/api/RandomImageGenerator.php", + # tests/phpunit/includes/auth + 'MediaWiki\\Auth\\AuthenticationRequestTestCase' => + "$testDir/phpunit/includes/auth/AuthenticationRequestTestCase.php", + # tests/phpunit/includes/changes 'TestRecentChangesHelper' => "$testDir/phpunit/includes/changes/TestRecentChangesHelper.php", diff --git a/tests/phpunit/MediaWikiTestCase.php b/tests/phpunit/MediaWikiTestCase.php index 25e0e31760..9f3aa11a7c 100644 --- a/tests/phpunit/MediaWikiTestCase.php +++ b/tests/phpunit/MediaWikiTestCase.php @@ -498,6 +498,7 @@ abstract class MediaWikiTestCase extends PHPUnit_Framework_TestCase { } $wgRequest = new FauxRequest(); MediaWiki\Session\SessionManager::resetCache(); + MediaWiki\Auth\AuthManager::resetCache(); $phpErrorLevel = intval( ini_get( 'error_reporting' ) ); diff --git a/tests/phpunit/includes/api/ApiTestCase.php b/tests/phpunit/includes/api/ApiTestCase.php index 246ea3d235..31e98d0b23 100644 --- a/tests/phpunit/includes/api/ApiTestCase.php +++ b/tests/phpunit/includes/api/ApiTestCase.php @@ -14,7 +14,7 @@ abstract class ApiTestCase extends MediaWikiLangTestCase { protected $tablesUsed = [ 'user', 'user_groups', 'user_properties' ]; protected function setUp() { - global $wgServer; + global $wgServer, $wgDisableAuthManager; parent::setUp(); self::$apiUrl = $wgServer . wfScript( 'api' ); @@ -37,7 +37,7 @@ abstract class ApiTestCase extends MediaWikiLangTestCase { ]; $this->setMwGlobals( [ - 'wgAuth' => new AuthPlugin, + 'wgAuth' => $wgDisableAuthManager ? new AuthPlugin : new MediaWiki\Auth\AuthManagerAuthPlugin, 'wgRequest' => new FauxRequest( [] ), 'wgUser' => self::$users['sysop']->user, ] ); @@ -101,6 +101,7 @@ abstract class ApiTestCase extends MediaWikiLangTestCase { $wgRequest = new FauxRequest( $params, true, $session ); RequestContext::getMain()->setRequest( $wgRequest ); RequestContext::getMain()->setUser( $wgUser ); + MediaWiki\Auth\AuthManager::resetCache(); // set up local environment $context = $this->apiContext->newTestContext( $wgRequest, $wgUser ); diff --git a/tests/phpunit/includes/auth/AbstractAuthenticationProviderTest.php b/tests/phpunit/includes/auth/AbstractAuthenticationProviderTest.php new file mode 100644 index 0000000000..1ded0df568 --- /dev/null +++ b/tests/phpunit/includes/auth/AbstractAuthenticationProviderTest.php @@ -0,0 +1,37 @@ +markTestSkipped( '$wgDisableAuthManager is set' ); + } + } + + public function testAbstractAuthenticationProvider() { + $provider = $this->getMockForAbstractClass( AbstractAuthenticationProvider::class ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + + $obj = $this->getMockForAbstractClass( 'Psr\Log\LoggerInterface' ); + $provider->setLogger( $obj ); + $this->assertSame( $obj, $providerPriv->logger, 'setLogger' ); + + $obj = AuthManager::singleton(); + $provider->setManager( $obj ); + $this->assertSame( $obj, $providerPriv->manager, 'setManager' ); + + $obj = $this->getMockForAbstractClass( 'Config' ); + $provider->setConfig( $obj ); + $this->assertSame( $obj, $providerPriv->config, 'setConfig' ); + + $this->assertType( 'string', $provider->getUniqueId(), 'getUniqueId' ); + } +} diff --git a/tests/phpunit/includes/auth/AbstractPasswordPrimaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/AbstractPasswordPrimaryAuthenticationProviderTest.php new file mode 100644 index 0000000000..ecce932485 --- /dev/null +++ b/tests/phpunit/includes/auth/AbstractPasswordPrimaryAuthenticationProviderTest.php @@ -0,0 +1,233 @@ +markTestSkipped( '$wgDisableAuthManager is set' ); + } + } + + public function testConstructor() { + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class + ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + $this->assertTrue( $providerPriv->authoritative ); + + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class, + [ [ 'authoritative' => false ] ] + ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + $this->assertFalse( $providerPriv->authoritative ); + } + + public function testGetPasswordFactory() { + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class + ); + $provider->setConfig( \ConfigFactory::getDefaultInstance()->makeConfig( 'main' ) ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + + $obj = $providerPriv->getPasswordFactory(); + $this->assertInstanceOf( 'PasswordFactory', $obj ); + $this->assertSame( $obj, $providerPriv->getPasswordFactory() ); + } + + public function testGetPassword() { + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class + ); + $provider->setConfig( \ConfigFactory::getDefaultInstance()->makeConfig( 'main' ) ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + + $obj = $providerPriv->getPassword( null ); + $this->assertInstanceOf( 'Password', $obj ); + + $obj = $providerPriv->getPassword( 'invalid' ); + $this->assertInstanceOf( 'Password', $obj ); + } + + public function testGetNewPasswordExpiry() { + $config = new \HashConfig; + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class + ); + $provider->setConfig( new \MultiConfig( [ + $config, + \ConfigFactory::getDefaultInstance()->makeConfig( 'main' ) + ] ) ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + + $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'ResetPasswordExpiration' => [] ] ); + + $config->set( 'PasswordExpirationDays', 0 ); + $this->assertNull( $providerPriv->getNewPasswordExpiry( 'UTSysop' ) ); + + $config->set( 'PasswordExpirationDays', 5 ); + $this->assertEquals( + time() + 5 * 86400, + wfTimestamp( TS_UNIX, $providerPriv->getNewPasswordExpiry( 'UTSysop' ) ), + '', + 2 /* Fuzz */ + ); + + $this->mergeMwGlobalArrayValue( 'wgHooks', [ + 'ResetPasswordExpiration' => [ function ( $user, &$expires ) { + $this->assertSame( 'UTSysop', $user->getName() ); + $expires = '30001231235959'; + } ] + ] ); + $this->assertEquals( '30001231235959', $providerPriv->getNewPasswordExpiry( 'UTSysop' ) ); + } + + public function testCheckPasswordValidity() { + $uppCalled = 0; + $uppStatus = \Status::newGood(); + $this->setMwGlobals( [ + 'wgPasswordPolicy' => [ + 'policies' => [ + 'default' => [ + 'Check' => true, + ], + ], + 'checks' => [ + 'Check' => function () use ( &$uppCalled, &$uppStatus ) { + $uppCalled++; + return $uppStatus; + }, + ], + ] + ] ); + + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class + ); + $provider->setConfig( \ConfigFactory::getDefaultInstance()->makeConfig( 'main' ) ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + + $this->assertEquals( $uppStatus, $providerPriv->checkPasswordValidity( 'foo', 'bar' ) ); + + $uppStatus->fatal( 'arbitrary-warning' ); + $this->assertEquals( $uppStatus, $providerPriv->checkPasswordValidity( 'foo', 'bar' ) ); + } + + public function testSetPasswordResetFlag() { + $config = new \HashConfig( [ + 'InvalidPasswordReset' => true, + ] ); + + $manager = new AuthManager( + new \FauxRequest(), \ConfigFactory::getDefaultInstance()->makeConfig( 'main' ) + ); + + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class + ); + $provider->setConfig( $config ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setManager( $manager ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + + $manager->removeAuthenticationSessionData( null ); + $status = \Status::newGood(); + $providerPriv->setPasswordResetFlag( 'Foo', $status ); + $this->assertNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + + $manager->removeAuthenticationSessionData( null ); + $status = \Status::newGood(); + $status->error( 'testing' ); + $providerPriv->setPasswordResetFlag( 'Foo', $status ); + $ret = $manager->getAuthenticationSessionData( 'reset-pass' ); + $this->assertNotNull( $ret ); + $this->assertSame( 'resetpass-validity-soft', $ret->msg->getKey() ); + $this->assertFalse( $ret->hard ); + + $config->set( 'InvalidPasswordReset', false ); + $manager->removeAuthenticationSessionData( null ); + $providerPriv->setPasswordResetFlag( 'Foo', $status ); + $ret = $manager->getAuthenticationSessionData( 'reset-pass' ); + $this->assertNull( $ret ); + } + + public function testFailResponse() { + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class, + [ [ 'authoritative' => false ] ] + ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + + $req = new PasswordAuthenticationRequest; + + $ret = $providerPriv->failResponse( $req ); + $this->assertSame( AuthenticationResponse::ABSTAIN, $ret->status ); + + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class, + [ [ 'authoritative' => true ] ] + ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + + $req->password = ''; + $ret = $providerPriv->failResponse( $req ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'wrongpasswordempty', $ret->message->getKey() ); + + $req->password = 'X'; + $ret = $providerPriv->failResponse( $req ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'wrongpassword', $ret->message->getKey() ); + } + + /** + * @dataProvider provideGetAuthenticationRequests + * @param string $action + * @param array $response + */ + public function testGetAuthenticationRequests( $action, $response ) { + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class + ); + + $this->assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) ); + } + + public static function provideGetAuthenticationRequests() { + return [ + [ AuthManager::ACTION_LOGIN, [ new PasswordAuthenticationRequest() ] ], + [ AuthManager::ACTION_CREATE, [ new PasswordAuthenticationRequest() ] ], + [ AuthManager::ACTION_LINK, [] ], + [ AuthManager::ACTION_CHANGE, [ new PasswordAuthenticationRequest() ] ], + [ AuthManager::ACTION_REMOVE, [ new PasswordAuthenticationRequest() ] ], + ]; + } + + public function testProviderRevokeAccessForUser() { + $req = new PasswordAuthenticationRequest; + $req->action = AuthManager::ACTION_REMOVE; + $req->username = 'foo'; + $req->password = null; + + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class + ); + $provider->expects( $this->once() ) + ->method( 'providerChangeAuthenticationData' ) + ->with( $this->equalTo( $req ) ); + + $provider->providerRevokeAccessForUser( 'foo' ); + } + +} diff --git a/tests/phpunit/includes/auth/AbstractPreAuthenticationProviderTest.php b/tests/phpunit/includes/auth/AbstractPreAuthenticationProviderTest.php new file mode 100644 index 0000000000..c35430e56d --- /dev/null +++ b/tests/phpunit/includes/auth/AbstractPreAuthenticationProviderTest.php @@ -0,0 +1,54 @@ +markTestSkipped( '$wgDisableAuthManager is set' ); + } + } + + public function testAbstractPreAuthenticationProvider() { + $user = \User::newFromName( 'UTSysop' ); + + $provider = $this->getMockForAbstractClass( AbstractPreAuthenticationProvider::class ); + + $this->assertEquals( + [], + $provider->getAuthenticationRequests( AuthManager::ACTION_LOGIN, [] ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAuthentication( [] ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, [] ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $user, AuthManager::AUTOCREATE_SOURCE_SESSION ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $user, false ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountLink( $user ) + ); + + $res = AuthenticationResponse::newPass(); + $provider->postAuthentication( $user, $res ); + $provider->postAccountCreation( $user, $user, $res ); + $provider->postAccountLink( $user, $res ); + } +} diff --git a/tests/phpunit/includes/auth/AbstractPrimaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/AbstractPrimaryAuthenticationProviderTest.php new file mode 100644 index 0000000000..420a330c47 --- /dev/null +++ b/tests/phpunit/includes/auth/AbstractPrimaryAuthenticationProviderTest.php @@ -0,0 +1,183 @@ +markTestSkipped( '$wgDisableAuthManager is set' ); + } + } + + public function testAbstractPrimaryAuthenticationProvider() { + $user = \User::newFromName( 'UTSysop' ); + + $provider = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class ); + + try { + $provider->continuePrimaryAuthentication( [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + } + + try { + $provider->continuePrimaryAccountCreation( $user, $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + } + + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + + $this->assertTrue( $provider->providerAllowsPropertyChange( 'foo' ) ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, [] ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $user, AuthManager::AUTOCREATE_SOURCE_SESSION ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $user, false ) + ); + + $this->assertNull( + $provider->finishAccountCreation( $user, $user, AuthenticationResponse::newPass() ) + ); + $provider->autoCreatedAccount( $user, AuthManager::AUTOCREATE_SOURCE_SESSION ); + + $res = AuthenticationResponse::newPass(); + $provider->postAuthentication( $user, $res ); + $provider->postAccountCreation( $user, $user, $res ); + $provider->postAccountLink( $user, $res ); + + $provider->expects( $this->once() ) + ->method( 'testUserExists' ) + ->with( $this->equalTo( 'foo' ) ) + ->will( $this->returnValue( true ) ); + $this->assertTrue( $provider->testUserCanAuthenticate( 'foo' ) ); + } + + public function testProviderRevokeAccessForUser() { + $reqs = []; + for ( $i = 0; $i < 3; $i++ ) { + $reqs[$i] = $this->getMock( AuthenticationRequest::class ); + $reqs[$i]->done = false; + } + + $provider = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class ); + $provider->expects( $this->once() )->method( 'getAuthenticationRequests' ) + ->with( + $this->identicalTo( AuthManager::ACTION_REMOVE ), + $this->identicalTo( [ 'username' => 'UTSysop' ] ) + ) + ->will( $this->returnValue( $reqs ) ); + $provider->expects( $this->exactly( 3 ) )->method( 'providerChangeAuthenticationData' ) + ->will( $this->returnCallback( function ( $req ) { + $this->assertSame( 'UTSysop', $req->username ); + $this->assertFalse( $req->done ); + $req->done = true; + } ) ); + + $provider->providerRevokeAccessForUser( 'UTSysop' ); + + foreach ( $reqs as $i => $req ) { + $this->assertTrue( $req->done, "#$i" ); + } + } + + /** + * @dataProvider providePrimaryAccountLink + * @param string $type PrimaryAuthenticationProvider::TYPE_* constant + * @param string $msg Error message from beginPrimaryAccountLink + */ + public function testPrimaryAccountLink( $type, $msg ) { + $provider = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class ); + $provider->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( $type ) ); + + $class = AbstractPrimaryAuthenticationProvider::class; + $msg1 = "{$class}::beginPrimaryAccountLink $msg"; + $msg2 = "{$class}::continuePrimaryAccountLink is not implemented."; + + $user = \User::newFromName( 'Whatever' ); + + try { + $provider->beginPrimaryAccountLink( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + $this->assertSame( $msg1, $ex->getMessage() ); + } + try { + $provider->continuePrimaryAccountLink( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + $this->assertSame( $msg2, $ex->getMessage() ); + } + } + + public static function providePrimaryAccountLink() { + return [ + [ + PrimaryAuthenticationProvider::TYPE_NONE, + 'should not be called on a non-link provider.', + ], + [ + PrimaryAuthenticationProvider::TYPE_CREATE, + 'should not be called on a non-link provider.', + ], + [ + PrimaryAuthenticationProvider::TYPE_LINK, + 'is not implemented.', + ], + ]; + } + + /** + * @dataProvider provideProviderNormalizeUsername + */ + public function testProviderNormalizeUsername( $name, $expect ) { + // fake interwiki map for the 'Interwiki prefix' testcase + $this->mergeMwGlobalArrayValue( 'wgHooks', [ + 'InterwikiLoadPrefix' => [ + function ( $prefix, &$iwdata ) { + if ( $prefix === 'interwiki' ) { + $iwdata = [ + 'iw_url' => 'http://example.com/', + 'iw_local' => 0, + 'iw_trans' => 0, + ]; + return false; + } + }, + ], + ] ); + + $provider = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class ); + $this->assertSame( $expect, $provider->providerNormalizeUsername( $name ) ); + } + + public static function provideProviderNormalizeUsername() { + return [ + 'Leading space' => [ ' Leading space', 'Leading space' ], + 'Trailing space ' => [ 'Trailing space ', 'Trailing space' ], + 'Namespace prefix' => [ 'Talk:Username', null ], + 'Interwiki prefix' => [ 'interwiki:Username', null ], + 'With hash' => [ 'name with # hash', null ], + 'Multi spaces' => [ 'Multi spaces', 'Multi spaces' ], + 'Lowercase' => [ 'lowercase', 'Lowercase' ], + 'Invalid character' => [ 'in[]valid', null ], + 'With slash' => [ 'with / slash', null ], + 'Underscores' => [ '___under__scores___', 'Under scores' ], + ]; + } + +} diff --git a/tests/phpunit/includes/auth/AbstractSecondaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/AbstractSecondaryAuthenticationProviderTest.php new file mode 100644 index 0000000000..9cdc05184c --- /dev/null +++ b/tests/phpunit/includes/auth/AbstractSecondaryAuthenticationProviderTest.php @@ -0,0 +1,93 @@ +markTestSkipped( '$wgDisableAuthManager is set' ); + } + } + + public function testAbstractSecondaryAuthenticationProvider() { + $user = \User::newFromName( 'UTSysop' ); + + $provider = $this->getMockForAbstractClass( AbstractSecondaryAuthenticationProvider::class ); + + try { + $provider->continueSecondaryAuthentication( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + } + + try { + $provider->continueSecondaryAccountCreation( $user, $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + } + + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + + $this->assertTrue( $provider->providerAllowsPropertyChange( 'foo' ) ); + $this->assertEquals( + \StatusValue::newGood( 'ignored' ), + $provider->providerAllowsAuthenticationDataChange( $req ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, [] ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $user, AuthManager::AUTOCREATE_SOURCE_SESSION ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $user, false ) + ); + + $provider->providerChangeAuthenticationData( $req ); + $provider->autoCreatedAccount( $user, AuthManager::AUTOCREATE_SOURCE_SESSION ); + + $res = AuthenticationResponse::newPass(); + $provider->postAuthentication( $user, $res ); + $provider->postAccountCreation( $user, $user, $res ); + } + + public function testProviderRevokeAccessForUser() { + $reqs = []; + for ( $i = 0; $i < 3; $i++ ) { + $reqs[$i] = $this->getMock( AuthenticationRequest::class ); + $reqs[$i]->done = false; + } + + $provider = $this->getMockBuilder( AbstractSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'providerChangeAuthenticationData' ] ) + ->getMockForAbstractClass(); + $provider->expects( $this->once() )->method( 'getAuthenticationRequests' ) + ->with( + $this->identicalTo( AuthManager::ACTION_REMOVE ), + $this->identicalTo( [ 'username' => 'UTSysop' ] ) + ) + ->will( $this->returnValue( $reqs ) ); + $provider->expects( $this->exactly( 3 ) )->method( 'providerChangeAuthenticationData' ) + ->will( $this->returnCallback( function ( $req ) { + $this->assertSame( 'UTSysop', $req->username ); + $this->assertFalse( $req->done ); + $req->done = true; + } ) ); + + $provider->providerRevokeAccessForUser( 'UTSysop' ); + + foreach ( $reqs as $i => $req ) { + $this->assertTrue( $req->done, "#$i" ); + } + } +} diff --git a/tests/phpunit/includes/auth/AuthManagerTest.php b/tests/phpunit/includes/auth/AuthManagerTest.php new file mode 100644 index 0000000000..377abe2b55 --- /dev/null +++ b/tests/phpunit/includes/auth/AuthManagerTest.php @@ -0,0 +1,3654 @@ +markTestSkipped( '$wgDisableAuthManager is set' ); + } + + $this->setMwGlobals( [ 'wgAuth' => null ] ); + $this->stashMwGlobals( [ 'wgHooks' ] ); + } + + /** + * Sets a mock on a hook + * @param string $hook + * @param object $expect From $this->once(), $this->never(), etc. + * @return object $mock->expects( $expect )->method( ... ). + */ + protected function hook( $hook, $expect ) { + global $wgHooks; + $mock = $this->getMock( __CLASS__, [ "on$hook" ] ); + $wgHooks[$hook] = [ $mock ]; + return $mock->expects( $expect )->method( "on$hook" ); + } + + /** + * Unsets a hook + * @param string $hook + */ + protected function unhook( $hook ) { + global $wgHooks; + $wgHooks[$hook] = []; + } + + /** + * Ensure a value is a clean Message object + * @param string|Message $key + * @param array $params + * @return Message + */ + protected function message( $key, $params = [] ) { + if ( $key === null ) { + return null; + } + if ( $key instanceof \MessageSpecifier ) { + $params = $key->getParams(); + $key = $key->getKey(); + } + return new \Message( $key, $params, \Language::factory( 'en' ) ); + } + + /** + * Initialize the AuthManagerConfig variable in $this->config + * + * Uses data from the various 'mocks' fields. + */ + protected function initializeConfig() { + $config = [ + 'preauth' => [ + ], + 'primaryauth' => [ + ], + 'secondaryauth' => [ + ], + ]; + + foreach ( [ 'preauth', 'primaryauth', 'secondaryauth' ] as $type ) { + $key = $type . 'Mocks'; + foreach ( $this->$key as $mock ) { + $config[$type][$mock->getUniqueId()] = [ 'factory' => function () use ( $mock ) { + return $mock; + } ]; + } + } + + $this->config->set( 'AuthManagerConfig', $config ); + $this->config->set( 'LanguageCode', 'en' ); + $this->config->set( 'NewUserLog', false ); + } + + /** + * Initialize $this->manager + * @param bool $regen Force a call to $this->initializeConfig() + */ + protected function initializeManager( $regen = false ) { + if ( $regen || !$this->config ) { + $this->config = new \HashConfig(); + } + if ( $regen || !$this->request ) { + $this->request = new \FauxRequest(); + } + if ( !$this->logger ) { + $this->logger = new \TestLogger(); + } + + if ( $regen || !$this->config->has( 'AuthManagerConfig' ) ) { + $this->initializeConfig(); + } + $this->manager = new AuthManager( $this->request, $this->config ); + $this->manager->setLogger( $this->logger ); + $this->managerPriv = \TestingAccessWrapper::newFromObject( $this->manager ); + } + + /** + * Setup SessionManager with a mock session provider + * @param bool|null $canChangeUser If non-null, canChangeUser will be mocked to return this + * @param array $methods Additional methods to mock + * @return array (MediaWiki\Session\SessionProvider, ScopedCallback) + */ + protected function getMockSessionProvider( $canChangeUser = null, array $methods = [] ) { + if ( !$this->config ) { + $this->config = new \HashConfig(); + $this->initializeConfig(); + } + $this->config->set( 'ObjectCacheSessionExpiry', 100 ); + + $methods[] = '__toString'; + $methods[] = 'describe'; + if ( $canChangeUser !== null ) { + $methods[] = 'canChangeUser'; + } + $provider = $this->getMockBuilder( 'DummySessionProvider' ) + ->setMethods( $methods ) + ->getMock(); + $provider->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'MockSessionProvider' ) ); + $provider->expects( $this->any() )->method( 'describe' ) + ->will( $this->returnValue( 'MockSessionProvider sessions' ) ); + if ( $canChangeUser !== null ) { + $provider->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( $canChangeUser ) ); + } + $this->config->set( 'SessionProviders', [ + [ 'factory' => function () use ( $provider ) { + return $provider; + } ], + ] ); + + $manager = new \MediaWiki\Session\SessionManager( [ + 'config' => $this->config, + 'logger' => new \Psr\Log\NullLogger(), + 'store' => new \HashBagOStuff(), + ] ); + \TestingAccessWrapper::newFromObject( $manager )->getProvider( (string)$provider ); + + $reset = \MediaWiki\Session\TestUtils::setSessionManagerSingleton( $manager ); + + if ( $this->request ) { + $manager->getSessionForRequest( $this->request ); + } + + return [ $provider, $reset ]; + } + + public function testSingleton() { + // Temporarily clear out the global singleton, if any, to test creating + // one. + $rProp = new \ReflectionProperty( AuthManager::class, 'instance' ); + $rProp->setAccessible( true ); + $old = $rProp->getValue(); + $cb = new \ScopedCallback( [ $rProp, 'setValue' ], [ $old ] ); + $rProp->setValue( null ); + + $singleton = AuthManager::singleton(); + $this->assertInstanceOf( AuthManager::class, AuthManager::singleton() ); + $this->assertSame( $singleton, AuthManager::singleton() ); + $this->assertSame( \RequestContext::getMain()->getRequest(), $singleton->getRequest() ); + $this->assertSame( + \RequestContext::getMain()->getConfig(), + \TestingAccessWrapper::newFromObject( $singleton )->config + ); + + $this->setMwGlobals( [ 'wgDisableAuthManager' => true ] ); + try { + AuthManager::singleton(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + $this->assertSame( '$wgDisableAuthManager is set', $ex->getMessage() ); + } + } + + public function testCanAuthenticateNow() { + $this->initializeManager(); + + list( $provider, $reset ) = $this->getMockSessionProvider( false ); + $this->assertFalse( $this->manager->canAuthenticateNow() ); + \ScopedCallback::consume( $reset ); + + list( $provider, $reset ) = $this->getMockSessionProvider( true ); + $this->assertTrue( $this->manager->canAuthenticateNow() ); + \ScopedCallback::consume( $reset ); + } + + public function testNormalizeUsername() { + $mocks = [ + $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ), + $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ), + $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ), + $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ), + ]; + foreach ( $mocks as $key => $mock ) { + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( $key ) ); + } + $mocks[0]->expects( $this->once() )->method( 'providerNormalizeUsername' ) + ->with( $this->identicalTo( 'XYZ' ) ) + ->willReturn( 'Foo' ); + $mocks[1]->expects( $this->once() )->method( 'providerNormalizeUsername' ) + ->with( $this->identicalTo( 'XYZ' ) ) + ->willReturn( 'Foo' ); + $mocks[2]->expects( $this->once() )->method( 'providerNormalizeUsername' ) + ->with( $this->identicalTo( 'XYZ' ) ) + ->willReturn( null ); + $mocks[3]->expects( $this->once() )->method( 'providerNormalizeUsername' ) + ->with( $this->identicalTo( 'XYZ' ) ) + ->willReturn( 'Bar!' ); + + $this->primaryauthMocks = $mocks; + + $this->initializeManager(); + + $this->assertSame( [ 'Foo', 'Bar!' ], $this->manager->normalizeUsername( 'XYZ' ) ); + } + + /** + * @dataProvider provideSecuritySensitiveOperationStatus + * @param bool $mutableSession + */ + public function testSecuritySensitiveOperationStatus( $mutableSession ) { + $this->logger = new \Psr\Log\NullLogger(); + $user = \User::newFromName( 'UTSysop' ); + $provideUser = null; + $reauth = $mutableSession ? AuthManager::SEC_REAUTH : AuthManager::SEC_FAIL; + + list( $provider, $reset ) = $this->getMockSessionProvider( + $mutableSession, [ 'provideSessionInfo' ] + ); + $provider->expects( $this->any() )->method( 'provideSessionInfo' ) + ->will( $this->returnCallback( function () use ( $provider, &$provideUser ) { + return new SessionInfo( SessionInfo::MIN_PRIORITY, [ + 'provider' => $provider, + 'id' => \DummySessionProvider::ID, + 'persisted' => true, + 'userInfo' => UserInfo::newFromUser( $provideUser, true ) + ] ); + } ) ); + $this->initializeManager(); + + $this->config->set( 'ReauthenticateTime', [] ); + $this->config->set( 'AllowSecuritySensitiveOperationIfCannotReauthenticate', [] ); + $provideUser = new \User; + $session = $provider->getManager()->getSessionForRequest( $this->request ); + $this->assertSame( 0, $session->getUser()->getId(), 'sanity check' ); + + // Anonymous user => reauth + $session->set( 'AuthManager:lastAuthId', 0 ); + $session->set( 'AuthManager:lastAuthTimestamp', time() - 5 ); + $this->assertSame( $reauth, $this->manager->securitySensitiveOperationStatus( 'foo' ) ); + + $provideUser = $user; + $session = $provider->getManager()->getSessionForRequest( $this->request ); + $this->assertSame( $user->getId(), $session->getUser()->getId(), 'sanity check' ); + + // Error for no default (only gets thrown for non-anonymous user) + $session->set( 'AuthManager:lastAuthId', $user->getId() + 1 ); + $session->set( 'AuthManager:lastAuthTimestamp', time() - 5 ); + try { + $this->manager->securitySensitiveOperationStatus( 'foo' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + $mutableSession + ? '$wgReauthenticateTime lacks a default' + : '$wgAllowSecuritySensitiveOperationIfCannotReauthenticate lacks a default', + $ex->getMessage() + ); + } + + if ( $mutableSession ) { + $this->config->set( 'ReauthenticateTime', [ + 'test' => 100, + 'test2' => -1, + 'default' => 10, + ] ); + + // Mismatched user ID + $session->set( 'AuthManager:lastAuthId', $user->getId() + 1 ); + $session->set( 'AuthManager:lastAuthTimestamp', time() - 5 ); + $this->assertSame( + AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'foo' ) + ); + $this->assertSame( + AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'test' ) + ); + $this->assertSame( + AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'test2' ) + ); + + // Missing time + $session->set( 'AuthManager:lastAuthId', $user->getId() ); + $session->set( 'AuthManager:lastAuthTimestamp', null ); + $this->assertSame( + AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'foo' ) + ); + $this->assertSame( + AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'test' ) + ); + $this->assertSame( + AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'test2' ) + ); + + // Recent enough to pass + $session->set( 'AuthManager:lastAuthTimestamp', time() - 5 ); + $this->assertSame( + AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'foo' ) + ); + + // Not recent enough to pass + $session->set( 'AuthManager:lastAuthTimestamp', time() - 20 ); + $this->assertSame( + AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'foo' ) + ); + // But recent enough for the 'test' operation + $this->assertSame( + AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'test' ) + ); + } else { + $this->config->set( 'AllowSecuritySensitiveOperationIfCannotReauthenticate', [ + 'test' => false, + 'default' => true, + ] ); + + $this->assertEquals( + AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'foo' ) + ); + + $this->assertEquals( + AuthManager::SEC_FAIL, $this->manager->securitySensitiveOperationStatus( 'test' ) + ); + } + + // Test hook, all three possible values + foreach ( [ + AuthManager::SEC_OK => AuthManager::SEC_OK, + AuthManager::SEC_REAUTH => $reauth, + AuthManager::SEC_FAIL => AuthManager::SEC_FAIL, + ] as $hook => $expect ) { + $this->hook( 'SecuritySensitiveOperationStatus', $this->exactly( 2 ) ) + ->with( + $this->anything(), + $this->anything(), + $this->callback( function ( $s ) use ( $session ) { + return $s->getId() === $session->getId(); + } ), + $mutableSession ? $this->equalTo( 500, 1 ) : $this->equalTo( -1 ) + ) + ->will( $this->returnCallback( function ( &$v ) use ( $hook ) { + $v = $hook; + return true; + } ) ); + $session->set( 'AuthManager:lastAuthTimestamp', time() - 500 ); + $this->assertEquals( + $expect, $this->manager->securitySensitiveOperationStatus( 'test' ), "hook $hook" + ); + $this->assertEquals( + $expect, $this->manager->securitySensitiveOperationStatus( 'test2' ), "hook $hook" + ); + $this->unhook( 'SecuritySensitiveOperationStatus' ); + } + + \ScopedCallback::consume( $reset ); + } + + public function onSecuritySensitiveOperationStatus( &$status, $operation, $session, $time ) { + } + + public static function provideSecuritySensitiveOperationStatus() { + return [ + [ true ], + [ false ], + ]; + } + + /** + * @dataProvider provideUserCanAuthenticate + * @param bool $primary1Can + * @param bool $primary2Can + * @param bool $expect + */ + public function testUserCanAuthenticate( $primary1Can, $primary2Can, $expect ) { + $mock1 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock1->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary1' ) ); + $mock1->expects( $this->any() )->method( 'testUserCanAuthenticate' ) + ->with( $this->equalTo( 'UTSysop' ) ) + ->will( $this->returnValue( $primary1Can ) ); + $mock2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock2->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary2' ) ); + $mock2->expects( $this->any() )->method( 'testUserCanAuthenticate' ) + ->with( $this->equalTo( 'UTSysop' ) ) + ->will( $this->returnValue( $primary2Can ) ); + $this->primaryauthMocks = [ $mock1, $mock2 ]; + + $this->initializeManager( true ); + $this->assertSame( $expect, $this->manager->userCanAuthenticate( 'UTSysop' ) ); + } + + public static function provideUserCanAuthenticate() { + return [ + [ false, false, false ], + [ true, false, true ], + [ false, true, true ], + [ true, true, true ], + ]; + } + + public function testRevokeAccessForUser() { + $this->initializeManager(); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary' ) ); + $mock->expects( $this->once() )->method( 'providerRevokeAccessForUser' ) + ->with( $this->equalTo( 'UTSysop' ) ); + $this->primaryauthMocks = [ $mock ]; + + $this->initializeManager( true ); + $this->logger->setCollect( true ); + + $this->manager->revokeAccessForUser( 'UTSysop' ); + + $this->assertSame( [ + [ LogLevel::INFO, 'Revoking access for {user}' ], + ], $this->logger->getBuffer() ); + } + + public function testProviderCreation() { + $mocks = [ + 'pre' => $this->getMockForAbstractClass( PreAuthenticationProvider::class ), + 'primary' => $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ), + 'secondary' => $this->getMockForAbstractClass( SecondaryAuthenticationProvider::class ), + ]; + foreach ( $mocks as $key => $mock ) { + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( $key ) ); + $mock->expects( $this->once() )->method( 'setLogger' ); + $mock->expects( $this->once() )->method( 'setManager' ); + $mock->expects( $this->once() )->method( 'setConfig' ); + } + $this->preauthMocks = [ $mocks['pre'] ]; + $this->primaryauthMocks = [ $mocks['primary'] ]; + $this->secondaryauthMocks = [ $mocks['secondary'] ]; + + // Normal operation + $this->initializeManager(); + $this->assertSame( + $mocks['primary'], + $this->managerPriv->getAuthenticationProvider( 'primary' ) + ); + $this->assertSame( + $mocks['secondary'], + $this->managerPriv->getAuthenticationProvider( 'secondary' ) + ); + $this->assertSame( + $mocks['pre'], + $this->managerPriv->getAuthenticationProvider( 'pre' ) + ); + $this->assertSame( + [ 'pre' => $mocks['pre'] ], + $this->managerPriv->getPreAuthenticationProviders() + ); + $this->assertSame( + [ 'primary' => $mocks['primary'] ], + $this->managerPriv->getPrimaryAuthenticationProviders() + ); + $this->assertSame( + [ 'secondary' => $mocks['secondary'] ], + $this->managerPriv->getSecondaryAuthenticationProviders() + ); + + // Duplicate IDs + $mock1 = $this->getMockForAbstractClass( PreAuthenticationProvider::class ); + $mock2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock1->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock2->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $this->preauthMocks = [ $mock1 ]; + $this->primaryauthMocks = [ $mock2 ]; + $this->secondaryauthMocks = []; + $this->initializeManager( true ); + try { + $this->managerPriv->getAuthenticationProvider( 'Y' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \RuntimeException $ex ) { + $class1 = get_class( $mock1 ); + $class2 = get_class( $mock2 ); + $this->assertSame( + "Duplicate specifications for id X (classes $class1 and $class2)", $ex->getMessage() + ); + } + + // Wrong classes + $mock = $this->getMockForAbstractClass( AuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $class = get_class( $mock ); + $this->preauthMocks = [ $mock ]; + $this->primaryauthMocks = [ $mock ]; + $this->secondaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + try { + $this->managerPriv->getPreAuthenticationProviders(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \RuntimeException $ex ) { + $this->assertSame( + "Expected instance of MediaWiki\\Auth\\PreAuthenticationProvider, got $class", + $ex->getMessage() + ); + } + try { + $this->managerPriv->getPrimaryAuthenticationProviders(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \RuntimeException $ex ) { + $this->assertSame( + "Expected instance of MediaWiki\\Auth\\PrimaryAuthenticationProvider, got $class", + $ex->getMessage() + ); + } + try { + $this->managerPriv->getSecondaryAuthenticationProviders(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \RuntimeException $ex ) { + $this->assertSame( + "Expected instance of MediaWiki\\Auth\\SecondaryAuthenticationProvider, got $class", + $ex->getMessage() + ); + } + + // Sorting + $mock1 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock3 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock1->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'A' ) ); + $mock2->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'B' ) ); + $mock3->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'C' ) ); + $this->preauthMocks = []; + $this->primaryauthMocks = [ $mock1, $mock2, $mock3 ]; + $this->secondaryauthMocks = []; + $this->initializeConfig(); + $config = $this->config->get( 'AuthManagerConfig' ); + + $this->initializeManager( false ); + $this->assertSame( + [ 'A' => $mock1, 'B' => $mock2, 'C' => $mock3 ], + $this->managerPriv->getPrimaryAuthenticationProviders(), + 'sanity check' + ); + + $config['primaryauth']['A']['sort'] = 100; + $config['primaryauth']['C']['sort'] = -1; + $this->config->set( 'AuthManagerConfig', $config ); + $this->initializeManager( false ); + $this->assertSame( + [ 'C' => $mock3, 'B' => $mock2, 'A' => $mock1 ], + $this->managerPriv->getPrimaryAuthenticationProviders() + ); + } + + public function testSetDefaultUserOptions() { + $this->initializeManager(); + + $context = \RequestContext::getMain(); + $reset = new \ScopedCallback( [ $context, 'setLanguage' ], [ $context->getLanguage() ] ); + $context->setLanguage( 'de' ); + $this->setMwGlobals( 'wgContLang', \Language::factory( 'zh' ) ); + + $user = \User::newFromName( self::usernameForCreation() ); + $user->addToDatabase(); + $oldToken = $user->getToken(); + $this->managerPriv->setDefaultUserOptions( $user, false ); + $user->saveSettings(); + $this->assertNotEquals( $oldToken, $user->getToken() ); + $this->assertSame( 'zh', $user->getOption( 'language' ) ); + $this->assertSame( 'zh', $user->getOption( 'variant' ) ); + + $user = \User::newFromName( self::usernameForCreation() ); + $user->addToDatabase(); + $oldToken = $user->getToken(); + $this->managerPriv->setDefaultUserOptions( $user, true ); + $user->saveSettings(); + $this->assertNotEquals( $oldToken, $user->getToken() ); + $this->assertSame( 'de', $user->getOption( 'language' ) ); + $this->assertSame( 'zh', $user->getOption( 'variant' ) ); + + $this->setMwGlobals( 'wgContLang', \Language::factory( 'en' ) ); + + $user = \User::newFromName( self::usernameForCreation() ); + $user->addToDatabase(); + $oldToken = $user->getToken(); + $this->managerPriv->setDefaultUserOptions( $user, true ); + $user->saveSettings(); + $this->assertNotEquals( $oldToken, $user->getToken() ); + $this->assertSame( 'de', $user->getOption( 'language' ) ); + $this->assertSame( null, $user->getOption( 'variant' ) ); + } + + public function testForcePrimaryAuthenticationProviders() { + $mockA = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mockB = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mockB2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mockA->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'A' ) ); + $mockB->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'B' ) ); + $mockB2->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'B' ) ); + $this->primaryauthMocks = [ $mockA ]; + + $this->logger = new \TestLogger( true ); + + // Test without first initializing the configured providers + $this->initializeManager(); + $this->manager->forcePrimaryAuthenticationProviders( [ $mockB ], 'testing' ); + $this->assertSame( + [ 'B' => $mockB ], $this->managerPriv->getPrimaryAuthenticationProviders() + ); + $this->assertSame( null, $this->managerPriv->getAuthenticationProvider( 'A' ) ); + $this->assertSame( $mockB, $this->managerPriv->getAuthenticationProvider( 'B' ) ); + $this->assertSame( [ + [ LogLevel::WARNING, 'Overriding AuthManager primary authn because testing' ], + ], $this->logger->getBuffer() ); + $this->logger->clearBuffer(); + + // Test with first initializing the configured providers + $this->initializeManager(); + $this->assertSame( $mockA, $this->managerPriv->getAuthenticationProvider( 'A' ) ); + $this->assertSame( null, $this->managerPriv->getAuthenticationProvider( 'B' ) ); + $this->request->getSession()->setSecret( 'AuthManager::authnState', 'test' ); + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', 'test' ); + $this->manager->forcePrimaryAuthenticationProviders( [ $mockB ], 'testing' ); + $this->assertSame( + [ 'B' => $mockB ], $this->managerPriv->getPrimaryAuthenticationProviders() + ); + $this->assertSame( null, $this->managerPriv->getAuthenticationProvider( 'A' ) ); + $this->assertSame( $mockB, $this->managerPriv->getAuthenticationProvider( 'B' ) ); + $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::authnState' ) ); + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ) + ); + $this->assertSame( [ + [ LogLevel::WARNING, 'Overriding AuthManager primary authn because testing' ], + [ + LogLevel::WARNING, + 'PrimaryAuthenticationProviders have already been accessed! I hope nothing breaks.' + ], + ], $this->logger->getBuffer() ); + $this->logger->clearBuffer(); + + // Test duplicate IDs + $this->initializeManager(); + try { + $this->manager->forcePrimaryAuthenticationProviders( [ $mockB, $mockB2 ], 'testing' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \RuntimeException $ex ) { + $class1 = get_class( $mockB ); + $class2 = get_class( $mockB2 ); + $this->assertSame( + "Duplicate specifications for id B (classes $class2 and $class1)", $ex->getMessage() + ); + } + + // Wrong classes + $mock = $this->getMockForAbstractClass( AuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $class = get_class( $mock ); + try { + $this->manager->forcePrimaryAuthenticationProviders( [ $mock ], 'testing' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \RuntimeException $ex ) { + $this->assertSame( + "Expected instance of MediaWiki\\Auth\\PrimaryAuthenticationProvider, got $class", + $ex->getMessage() + ); + } + + } + + public function testBeginAuthentication() { + $this->initializeManager(); + + // Immutable session + list( $provider, $reset ) = $this->getMockSessionProvider( false ); + $this->hook( 'UserLoggedIn', $this->never() ); + $this->request->getSession()->setSecret( 'AuthManager::authnState', 'test' ); + try { + $this->manager->beginAuthentication( [], 'http://localhost/' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \LogicException $ex ) { + $this->assertSame( 'Authentication is not possible now', $ex->getMessage() ); + } + $this->unhook( 'UserLoggedIn' ); + $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::authnState' ) ); + \ScopedCallback::consume( $reset ); + $this->initializeManager( true ); + + // CreatedAccountAuthenticationRequest + $user = \User::newFromName( 'UTSysop' ); + $reqs = [ + new CreatedAccountAuthenticationRequest( $user->getId(), $user->getName() ) + ]; + $this->hook( 'UserLoggedIn', $this->never() ); + try { + $this->manager->beginAuthentication( $reqs, 'http://localhost/' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \LogicException $ex ) { + $this->assertSame( + 'CreatedAccountAuthenticationRequests are only valid on the same AuthManager ' . + 'that created the account', + $ex->getMessage() + ); + } + $this->unhook( 'UserLoggedIn' ); + + $this->request->getSession()->clear(); + $this->request->getSession()->setSecret( 'AuthManager::authnState', 'test' ); + $this->managerPriv->createdAccountAuthenticationRequests = [ $reqs[0] ]; + $this->hook( 'UserLoggedIn', $this->once() ) + ->with( $this->callback( function ( $u ) use ( $user ) { + return $user->getId() === $u->getId() && $user->getName() === $u->getName(); + } ) ); + $this->hook( 'AuthManagerLoginAuthenticateAudit', $this->once() ); + $this->logger->setCollect( true ); + $ret = $this->manager->beginAuthentication( $reqs, 'http://localhost/' ); + $this->logger->setCollect( false ); + $this->unhook( 'UserLoggedIn' ); + $this->unhook( 'AuthManagerLoginAuthenticateAudit' ); + $this->assertSame( AuthenticationResponse::PASS, $ret->status ); + $this->assertSame( $user->getName(), $ret->username ); + $this->assertSame( $user->getId(), $this->request->getSessionData( 'AuthManager:lastAuthId' ) ); + $this->assertEquals( + time(), $this->request->getSessionData( 'AuthManager:lastAuthTimestamp' ), + 'timestamp ±1', 1 + ); + $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::authnState' ) ); + $this->assertSame( $user->getId(), $this->request->getSession()->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::INFO, 'Logging in {user} after account creation' ], + ], $this->logger->getBuffer() ); + } + + public function testCreateFromLogin() { + $user = \User::newFromName( 'UTSysop' ); + $req1 = $this->getMock( AuthenticationRequest::class ); + $req2 = $this->getMock( AuthenticationRequest::class ); + $req3 = $this->getMock( AuthenticationRequest::class ); + $userReq = new UsernameAuthenticationRequest; + $userReq->username = 'UTDummy'; + + // Passing one into beginAuthentication(), and an immediate FAIL + $primary = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class ); + $this->primaryauthMocks = [ $primary ]; + $this->initializeManager( true ); + $res = AuthenticationResponse::newFail( wfMessage( 'foo' ) ); + $res->createRequest = $req1; + $primary->expects( $this->any() )->method( 'beginPrimaryAuthentication' ) + ->will( $this->returnValue( $res ) ); + $createReq = new CreateFromLoginAuthenticationRequest( + null, [ $req2->getUniqueId() => $req2 ] + ); + $this->logger->setCollect( true ); + $ret = $this->manager->beginAuthentication( [ $createReq ], 'http://localhost/' ); + $this->logger->setCollect( false ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertInstanceOf( CreateFromLoginAuthenticationRequest::class, $ret->createRequest ); + $this->assertSame( $req1, $ret->createRequest->createRequest ); + $this->assertEquals( [ $req2->getUniqueId() => $req2 ], $ret->createRequest->maybeLink ); + + // UI, then FAIL in beginAuthentication() + $primary = $this->getMockBuilder( AbstractPrimaryAuthenticationProvider::class ) + ->setMethods( [ 'continuePrimaryAuthentication' ] ) + ->getMockForAbstractClass(); + $this->primaryauthMocks = [ $primary ]; + $this->initializeManager( true ); + $primary->expects( $this->any() )->method( 'beginPrimaryAuthentication' ) + ->will( $this->returnValue( + AuthenticationResponse::newUI( [ $req1 ], wfMessage( 'foo' ) ) + ) ); + $res = AuthenticationResponse::newFail( wfMessage( 'foo' ) ); + $res->createRequest = $req2; + $primary->expects( $this->any() )->method( 'continuePrimaryAuthentication' ) + ->will( $this->returnValue( $res ) ); + $this->logger->setCollect( true ); + $ret = $this->manager->beginAuthentication( [], 'http://localhost/' ); + $this->assertSame( AuthenticationResponse::UI, $ret->status, 'sanity check' ); + $ret = $this->manager->continueAuthentication( [] ); + $this->logger->setCollect( false ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertInstanceOf( CreateFromLoginAuthenticationRequest::class, $ret->createRequest ); + $this->assertSame( $req2, $ret->createRequest->createRequest ); + $this->assertEquals( [], $ret->createRequest->maybeLink ); + + // Pass into beginAccountCreation(), no createRequest, primary needs reqs + $primary = $this->getMockBuilder( AbstractPrimaryAuthenticationProvider::class ) + ->setMethods( [ 'testForAccountCreation' ] ) + ->getMockForAbstractClass(); + $this->primaryauthMocks = [ $primary ]; + $this->initializeManager( true ); + $primary->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $primary->expects( $this->any() )->method( 'getAuthenticationRequests' ) + ->will( $this->returnValue( [ $req1 ] ) ); + $primary->expects( $this->any() )->method( 'testForAccountCreation' ) + ->will( $this->returnValue( StatusValue::newFatal( 'fail' ) ) ); + $createReq = new CreateFromLoginAuthenticationRequest( + null, [ $req2->getUniqueId() => $req2 ] + ); + $this->logger->setCollect( true ); + $ret = $this->manager->beginAccountCreation( + $user, [ $userReq, $createReq ], 'http://localhost/' + ); + $this->logger->setCollect( false ); + $this->assertSame( AuthenticationResponse::UI, $ret->status ); + $this->assertCount( 4, $ret->neededRequests ); + $this->assertSame( $req1, $ret->neededRequests[0] ); + $this->assertInstanceOf( UsernameAuthenticationRequest::class, $ret->neededRequests[1] ); + $this->assertInstanceOf( UserDataAuthenticationRequest::class, $ret->neededRequests[2] ); + $this->assertInstanceOf( CreateFromLoginAuthenticationRequest::class, $ret->neededRequests[3] ); + $this->assertSame( null, $ret->neededRequests[3]->createRequest ); + $this->assertEquals( [], $ret->neededRequests[3]->maybeLink ); + + // Pass into beginAccountCreation(), with createRequest, primary needs reqs + $createReq = new CreateFromLoginAuthenticationRequest( $req2, [] ); + $this->logger->setCollect( true ); + $ret = $this->manager->beginAccountCreation( + $user, [ $userReq, $createReq ], 'http://localhost/' + ); + $this->logger->setCollect( false ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'fail', $ret->message->getKey() ); + + // Again, with a secondary needing reqs too + $secondary = $this->getMockBuilder( AbstractSecondaryAuthenticationProvider::class ) + ->getMockForAbstractClass(); + $this->secondaryauthMocks = [ $secondary ]; + $this->initializeManager( true ); + $secondary->expects( $this->any() )->method( 'getAuthenticationRequests' ) + ->will( $this->returnValue( [ $req3 ] ) ); + $createReq = new CreateFromLoginAuthenticationRequest( $req2, [] ); + $this->logger->setCollect( true ); + $ret = $this->manager->beginAccountCreation( + $user, [ $userReq, $createReq ], 'http://localhost/' + ); + $this->logger->setCollect( false ); + $this->assertSame( AuthenticationResponse::UI, $ret->status ); + $this->assertCount( 4, $ret->neededRequests ); + $this->assertSame( $req3, $ret->neededRequests[0] ); + $this->assertInstanceOf( UsernameAuthenticationRequest::class, $ret->neededRequests[1] ); + $this->assertInstanceOf( UserDataAuthenticationRequest::class, $ret->neededRequests[2] ); + $this->assertInstanceOf( CreateFromLoginAuthenticationRequest::class, $ret->neededRequests[3] ); + $this->assertSame( $req2, $ret->neededRequests[3]->createRequest ); + $this->assertEquals( [], $ret->neededRequests[3]->maybeLink ); + $this->logger->setCollect( true ); + $ret = $this->manager->continueAccountCreation( $ret->neededRequests ); + $this->logger->setCollect( false ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'fail', $ret->message->getKey() ); + } + + /** + * @dataProvider provideAuthentication + * @param StatusValue $preResponse + * @param array $primaryResponses + * @param array $secondaryResponses + * @param array $managerResponses + * @param bool $link Whether the primary authentication provider is a "link" provider + */ + public function testAuthentication( + StatusValue $preResponse, array $primaryResponses, array $secondaryResponses, + array $managerResponses, $link = false + ) { + $this->initializeManager(); + $user = \User::newFromName( 'UTSysop' ); + $id = $user->getId(); + $name = $user->getName(); + + // Set up lots of mocks... + $req = new RememberMeAuthenticationRequest; + $req->rememberMe = (bool)rand( 0, 1 ); + $req->pre = $preResponse; + $req->primary = $primaryResponses; + $req->secondary = $secondaryResponses; + $mocks = []; + foreach ( [ 'pre', 'primary', 'secondary' ] as $key ) { + $class = ucfirst( $key ) . 'AuthenticationProvider'; + $mocks[$key] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key ) ); + $mocks[$key . '2'] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks[$key . '2']->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key . '2' ) ); + $mocks[$key . '3'] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks[$key . '3']->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key . '3' ) ); + } + foreach ( $mocks as $mock ) { + $mock->expects( $this->any() )->method( 'getAuthenticationRequests' ) + ->will( $this->returnValue( [] ) ); + } + + $mocks['pre']->expects( $this->once() )->method( 'testForAuthentication' ) + ->will( $this->returnCallback( function ( $reqs ) use ( $req ) { + $this->assertContains( $req, $reqs ); + return $req->pre; + } ) ); + + $ct = count( $req->primary ); + $callback = $this->returnCallback( function ( $reqs ) use ( $req ) { + $this->assertContains( $req, $reqs ); + return array_shift( $req->primary ); + } ); + $mocks['primary']->expects( $this->exactly( min( 1, $ct ) ) ) + ->method( 'beginPrimaryAuthentication' ) + ->will( $callback ); + $mocks['primary']->expects( $this->exactly( max( 0, $ct - 1 ) ) ) + ->method( 'continuePrimaryAuthentication' ) + ->will( $callback ); + if ( $link ) { + $mocks['primary']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) ); + } + + $ct = count( $req->secondary ); + $callback = $this->returnCallback( function ( $user, $reqs ) use ( $id, $name, $req ) { + $this->assertSame( $id, $user->getId() ); + $this->assertSame( $name, $user->getName() ); + $this->assertContains( $req, $reqs ); + return array_shift( $req->secondary ); + } ); + $mocks['secondary']->expects( $this->exactly( min( 1, $ct ) ) ) + ->method( 'beginSecondaryAuthentication' ) + ->will( $callback ); + $mocks['secondary']->expects( $this->exactly( max( 0, $ct - 1 ) ) ) + ->method( 'continueSecondaryAuthentication' ) + ->will( $callback ); + + $abstain = AuthenticationResponse::newAbstain(); + $mocks['pre2']->expects( $this->atMost( 1 ) )->method( 'testForAuthentication' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $mocks['primary2']->expects( $this->atMost( 1 ) )->method( 'beginPrimaryAuthentication' ) + ->will( $this->returnValue( $abstain ) ); + $mocks['primary2']->expects( $this->never() )->method( 'continuePrimaryAuthentication' ); + $mocks['secondary2']->expects( $this->atMost( 1 ) )->method( 'beginSecondaryAuthentication' ) + ->will( $this->returnValue( $abstain ) ); + $mocks['secondary2']->expects( $this->never() )->method( 'continueSecondaryAuthentication' ); + $mocks['secondary3']->expects( $this->atMost( 1 ) )->method( 'beginSecondaryAuthentication' ) + ->will( $this->returnValue( $abstain ) ); + $mocks['secondary3']->expects( $this->never() )->method( 'continueSecondaryAuthentication' ); + + $this->preauthMocks = [ $mocks['pre'], $mocks['pre2'] ]; + $this->primaryauthMocks = [ $mocks['primary'], $mocks['primary2'] ]; + $this->secondaryauthMocks = [ + $mocks['secondary3'], $mocks['secondary'], $mocks['secondary2'], + // So linking happens + new ConfirmLinkSecondaryAuthenticationProvider, + ]; + $this->initializeManager( true ); + $this->logger->setCollect( true ); + + $constraint = \PHPUnit_Framework_Assert::logicalOr( + $this->equalTo( AuthenticationResponse::PASS ), + $this->equalTo( AuthenticationResponse::FAIL ) + ); + $providers = array_filter( + array_merge( + $this->preauthMocks, $this->primaryauthMocks, $this->secondaryauthMocks + ), + function ( $p ) { + return is_callable( [ $p, 'expects' ] ); + } + ); + foreach ( $providers as $p ) { + $p->postCalled = false; + $p->expects( $this->atMost( 1 ) )->method( 'postAuthentication' ) + ->willReturnCallback( function ( $user, $response ) use ( $constraint, $p ) { + if ( $user !== null ) { + $this->assertInstanceOf( 'User', $user ); + $this->assertSame( 'UTSysop', $user->getName() ); + } + $this->assertInstanceOf( AuthenticationResponse::class, $response ); + $this->assertThat( $response->status, $constraint ); + $p->postCalled = $response->status; + } ); + } + + $session = $this->request->getSession(); + $session->setRememberUser( !$req->rememberMe ); + + foreach ( $managerResponses as $i => $response ) { + $success = $response instanceof AuthenticationResponse && + $response->status === AuthenticationResponse::PASS; + if ( $success ) { + $this->hook( 'UserLoggedIn', $this->once() ) + ->with( $this->callback( function ( $user ) use ( $id, $name ) { + return $user->getId() === $id && $user->getName() === $name; + } ) ); + } else { + $this->hook( 'UserLoggedIn', $this->never() ); + } + if ( $success || ( + $response instanceof AuthenticationResponse && + $response->status === AuthenticationResponse::FAIL && + $response->message->getKey() !== 'authmanager-authn-not-in-progress' && + $response->message->getKey() !== 'authmanager-authn-no-primary' + ) + ) { + $this->hook( 'AuthManagerLoginAuthenticateAudit', $this->once() ); + } else { + $this->hook( 'AuthManagerLoginAuthenticateAudit', $this->never() ); + } + + $ex = null; + try { + if ( !$i ) { + $ret = $this->manager->beginAuthentication( [ $req ], 'http://localhost/' ); + } else { + $ret = $this->manager->continueAuthentication( [ $req ] ); + } + if ( $response instanceof \Exception ) { + $this->fail( 'Expected exception not thrown', "Response $i" ); + } + } catch ( \Exception $ex ) { + if ( !$response instanceof \Exception ) { + throw $ex; + } + $this->assertEquals( $response->getMessage(), $ex->getMessage(), "Response $i, exception" ); + $this->assertNull( $session->getSecret( 'AuthManager::authnState' ), + "Response $i, exception, session state" ); + $this->unhook( 'UserLoggedIn' ); + $this->unhook( 'AuthManagerLoginAuthenticateAudit' ); + return; + } + + $this->unhook( 'UserLoggedIn' ); + $this->unhook( 'AuthManagerLoginAuthenticateAudit' ); + + $this->assertSame( 'http://localhost/', $req->returnToUrl ); + + $ret->message = $this->message( $ret->message ); + $this->assertEquals( $response, $ret, "Response $i, response" ); + if ( $success ) { + $this->assertSame( $id, $session->getUser()->getId(), + "Response $i, authn" ); + } else { + $this->assertSame( 0, $session->getUser()->getId(), + "Response $i, authn" ); + } + if ( $success || $response->status === AuthenticationResponse::FAIL ) { + $this->assertNull( $session->getSecret( 'AuthManager::authnState' ), + "Response $i, session state" ); + foreach ( $providers as $p ) { + $this->assertSame( $response->status, $p->postCalled, + "Response $i, post-auth callback called" ); + } + } else { + $this->assertNotNull( $session->getSecret( 'AuthManager::authnState' ), + "Response $i, session state" ); + $this->assertEquals( + $ret->neededRequests, + $this->manager->getAuthenticationRequests( AuthManager::ACTION_LOGIN_CONTINUE ), + "Response $i, continuation check" + ); + foreach ( $providers as $p ) { + $this->assertFalse( $p->postCalled, "Response $i, post-auth callback not called" ); + } + } + + $state = $session->getSecret( 'AuthManager::authnState' ); + $maybeLink = isset( $state['maybeLink'] ) ? $state['maybeLink'] : []; + if ( $link && $response->status === AuthenticationResponse::RESTART ) { + $this->assertEquals( + $response->createRequest->maybeLink, + $maybeLink, + "Response $i, maybeLink" + ); + } else { + $this->assertEquals( [], $maybeLink, "Response $i, maybeLink" ); + } + } + + if ( $success ) { + $this->assertSame( $req->rememberMe, $session->shouldRememberUser(), + 'rememberMe checkbox had effect' ); + } else { + $this->assertNotSame( $req->rememberMe, $session->shouldRememberUser(), + 'rememberMe checkbox wasn\'t applied' ); + } + } + + public function provideAuthentication() { + $user = \User::newFromName( 'UTSysop' ); + $id = $user->getId(); + $name = $user->getName(); + + $rememberReq = new RememberMeAuthenticationRequest; + $rememberReq->action = AuthManager::ACTION_LOGIN; + + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $req->foobar = 'baz'; + $restartResponse = AuthenticationResponse::newRestart( + $this->message( 'authmanager-authn-no-local-user' ) + ); + $restartResponse->neededRequests = [ $rememberReq ]; + + $restartResponse2Pass = AuthenticationResponse::newPass( null ); + $restartResponse2Pass->linkRequest = $req; + $restartResponse2 = AuthenticationResponse::newRestart( + $this->message( 'authmanager-authn-no-local-user-link' ) + ); + $restartResponse2->createRequest = new CreateFromLoginAuthenticationRequest( + null, [ $req->getUniqueId() => $req ] + ); + $restartResponse2->neededRequests = [ $rememberReq, $restartResponse2->createRequest ]; + + return [ + 'Failure in pre-auth' => [ + StatusValue::newFatal( 'fail-from-pre' ), + [], + [], + [ + AuthenticationResponse::newFail( $this->message( 'fail-from-pre' ) ), + AuthenticationResponse::newFail( + $this->message( 'authmanager-authn-not-in-progress' ) + ), + ] + ], + 'Failure in primary' => [ + StatusValue::newGood(), + $tmp = [ + AuthenticationResponse::newFail( $this->message( 'fail-from-primary' ) ), + ], + [], + $tmp + ], + 'All primary abstain' => [ + StatusValue::newGood(), + [ + AuthenticationResponse::newAbstain(), + ], + [], + [ + AuthenticationResponse::newFail( $this->message( 'authmanager-authn-no-primary' ) ) + ] + ], + 'Primary UI, then redirect, then fail' => [ + StatusValue::newGood(), + $tmp = [ + AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + AuthenticationResponse::newRedirect( [ $req ], '/foo.html', [ 'foo' => 'bar' ] ), + AuthenticationResponse::newFail( $this->message( 'fail-in-primary-continue' ) ), + ], + [], + $tmp + ], + 'Primary redirect, then abstain' => [ + StatusValue::newGood(), + [ + $tmp = AuthenticationResponse::newRedirect( + [ $req ], '/foo.html', [ 'foo' => 'bar' ] + ), + AuthenticationResponse::newAbstain(), + ], + [], + [ + $tmp, + new \DomainException( + 'MockPrimaryAuthenticationProvider::continuePrimaryAuthentication() returned ABSTAIN' + ) + ] + ], + 'Primary UI, then pass with no local user' => [ + StatusValue::newGood(), + [ + $tmp = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + AuthenticationResponse::newPass( null ), + ], + [], + [ + $tmp, + $restartResponse, + ] + ], + 'Primary UI, then pass with no local user (link type)' => [ + StatusValue::newGood(), + [ + $tmp = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + $restartResponse2Pass, + ], + [], + [ + $tmp, + $restartResponse2, + ], + true + ], + 'Primary pass with invalid username' => [ + StatusValue::newGood(), + [ + AuthenticationResponse::newPass( '<>' ), + ], + [], + [ + new \DomainException( 'MockPrimaryAuthenticationProvider returned an invalid username: <>' ), + ] + ], + 'Secondary fail' => [ + StatusValue::newGood(), + [ + AuthenticationResponse::newPass( $name ), + ], + $tmp = [ + AuthenticationResponse::newFail( $this->message( 'fail-in-secondary' ) ), + ], + $tmp + ], + 'Secondary UI, then abstain' => [ + StatusValue::newGood(), + [ + AuthenticationResponse::newPass( $name ), + ], + [ + $tmp = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + AuthenticationResponse::newAbstain() + ], + [ + $tmp, + AuthenticationResponse::newPass( $name ), + ] + ], + 'Secondary pass' => [ + StatusValue::newGood(), + [ + AuthenticationResponse::newPass( $name ), + ], + [ + AuthenticationResponse::newPass() + ], + [ + AuthenticationResponse::newPass( $name ), + ] + ], + ]; + } + + /** + * @dataProvider provideUserExists + * @param bool $primary1Exists + * @param bool $primary2Exists + * @param bool $expect + */ + public function testUserExists( $primary1Exists, $primary2Exists, $expect ) { + $mock1 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock1->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary1' ) ); + $mock1->expects( $this->any() )->method( 'testUserExists' ) + ->with( $this->equalTo( 'UTSysop' ) ) + ->will( $this->returnValue( $primary1Exists ) ); + $mock2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock2->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary2' ) ); + $mock2->expects( $this->any() )->method( 'testUserExists' ) + ->with( $this->equalTo( 'UTSysop' ) ) + ->will( $this->returnValue( $primary2Exists ) ); + $this->primaryauthMocks = [ $mock1, $mock2 ]; + + $this->initializeManager( true ); + $this->assertSame( $expect, $this->manager->userExists( 'UTSysop' ) ); + } + + public static function provideUserExists() { + return [ + [ false, false, false ], + [ true, false, true ], + [ false, true, true ], + [ true, true, true ], + ]; + } + + /** + * @dataProvider provideAllowsAuthenticationDataChange + * @param StatusValue $primaryReturn + * @param StatusValue $secondaryReturn + * @param Status $expect + */ + public function testAllowsAuthenticationDataChange( $primaryReturn, $secondaryReturn, $expect ) { + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + + $mock1 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock1->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( '1' ) ); + $mock1->expects( $this->any() )->method( 'providerAllowsAuthenticationDataChange' ) + ->with( $this->equalTo( $req ) ) + ->will( $this->returnValue( $primaryReturn ) ); + $mock2 = $this->getMockForAbstractClass( SecondaryAuthenticationProvider::class ); + $mock2->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( '2' ) ); + $mock2->expects( $this->any() )->method( 'providerAllowsAuthenticationDataChange' ) + ->with( $this->equalTo( $req ) ) + ->will( $this->returnValue( $secondaryReturn ) ); + + $this->primaryauthMocks = [ $mock1 ]; + $this->secondaryauthMocks = [ $mock2 ]; + $this->initializeManager( true ); + $this->assertEquals( $expect, $this->manager->allowsAuthenticationDataChange( $req ) ); + } + + public static function provideAllowsAuthenticationDataChange() { + $ignored = \Status::newGood( 'ignored' ); + $ignored->warning( 'authmanager-change-not-supported' ); + + $okFromPrimary = StatusValue::newGood(); + $okFromPrimary->warning( 'warning-from-primary' ); + $okFromSecondary = StatusValue::newGood(); + $okFromSecondary->warning( 'warning-from-secondary' ); + + return [ + [ + StatusValue::newGood(), + StatusValue::newGood(), + \Status::newGood(), + ], + [ + StatusValue::newGood(), + StatusValue::newGood( 'ignore' ), + \Status::newGood(), + ], + [ + StatusValue::newGood( 'ignored' ), + StatusValue::newGood(), + \Status::newGood(), + ], + [ + StatusValue::newGood( 'ignored' ), + StatusValue::newGood( 'ignored' ), + $ignored, + ], + [ + StatusValue::newFatal( 'fail from primary' ), + StatusValue::newGood(), + \Status::newFatal( 'fail from primary' ), + ], + [ + $okFromPrimary, + StatusValue::newGood(), + \Status::wrap( $okFromPrimary ), + ], + [ + StatusValue::newGood(), + StatusValue::newFatal( 'fail from secondary' ), + \Status::newFatal( 'fail from secondary' ), + ], + [ + StatusValue::newGood(), + $okFromSecondary, + \Status::wrap( $okFromSecondary ), + ], + ]; + } + + public function testChangeAuthenticationData() { + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $req->username = 'UTSysop'; + + $mock1 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock1->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( '1' ) ); + $mock1->expects( $this->once() )->method( 'providerChangeAuthenticationData' ) + ->with( $this->equalTo( $req ) ); + $mock2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock2->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( '2' ) ); + $mock2->expects( $this->once() )->method( 'providerChangeAuthenticationData' ) + ->with( $this->equalTo( $req ) ); + + $this->primaryauthMocks = [ $mock1, $mock2 ]; + $this->initializeManager( true ); + $this->logger->setCollect( true ); + $this->manager->changeAuthenticationData( $req ); + $this->assertSame( [ + [ LogLevel::INFO, 'Changing authentication data for {user} class {what}' ], + ], $this->logger->getBuffer() ); + } + + public function testCanCreateAccounts() { + $types = [ + PrimaryAuthenticationProvider::TYPE_CREATE => true, + PrimaryAuthenticationProvider::TYPE_LINK => true, + PrimaryAuthenticationProvider::TYPE_NONE => false, + ]; + + foreach ( $types as $type => $can ) { + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( $type ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( $type ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + $this->assertSame( $can, $this->manager->canCreateAccounts(), $type ); + } + } + + public function testCheckAccountCreatePermissions() { + global $wgGroupPermissions; + + $this->stashMwGlobals( [ 'wgGroupPermissions' ] ); + + $this->initializeManager( true ); + + $wgGroupPermissions['*']['createaccount'] = true; + $this->assertEquals( + \Status::newGood(), + $this->manager->checkAccountCreatePermissions( new \User ) + ); + + $this->setMwGlobals( [ 'wgReadOnly' => 'Because' ] ); + $this->assertEquals( + \Status::newFatal( 'readonlytext', 'Because' ), + $this->manager->checkAccountCreatePermissions( new \User ) + ); + $this->setMwGlobals( [ 'wgReadOnly' => false ] ); + + $wgGroupPermissions['*']['createaccount'] = false; + $status = $this->manager->checkAccountCreatePermissions( new \User ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( 'badaccess-groups' ) ); + $wgGroupPermissions['*']['createaccount'] = true; + + $user = \User::newFromName( 'UTBlockee' ); + if ( $user->getID() == 0 ) { + $user->addToDatabase(); + \TestUser::setPasswordForUser( $user, 'UTBlockeePassword' ); + $user->saveSettings(); + } + $oldBlock = \Block::newFromTarget( 'UTBlockee' ); + if ( $oldBlock ) { + // An old block will prevent our new one from saving. + $oldBlock->delete(); + } + $blockOptions = [ + 'address' => 'UTBlockee', + 'user' => $user->getID(), + 'reason' => __METHOD__, + 'expiry' => time() + 100500, + 'createAccount' => true, + ]; + $block = new \Block( $blockOptions ); + $block->insert(); + $status = $this->manager->checkAccountCreatePermissions( $user ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( 'cantcreateaccount-text' ) ); + + $blockOptions = [ + 'address' => '127.0.0.0/24', + 'reason' => __METHOD__, + 'expiry' => time() + 100500, + 'createAccount' => true, + ]; + $block = new \Block( $blockOptions ); + $block->insert(); + $scopeVariable = new \ScopedCallback( [ $block, 'delete' ] ); + $status = $this->manager->checkAccountCreatePermissions( new \User ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( 'cantcreateaccount-range-text' ) ); + \ScopedCallback::consume( $scopeVariable ); + + $this->setMwGlobals( [ + 'wgEnableDnsBlacklist' => true, + 'wgDnsBlacklistUrls' => [ + 'local.wmftest.net', // This will resolve for every subdomain, which works to test "listed?" + ], + 'wgProxyWhitelist' => [], + ] ); + $status = $this->manager->checkAccountCreatePermissions( new \User ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( 'sorbs_create_account_reason' ) ); + $this->setMwGlobals( 'wgProxyWhitelist', [ '127.0.0.1' ] ); + $status = $this->manager->checkAccountCreatePermissions( new \User ); + $this->assertTrue( $status->isGood() ); + } + + /** + * @param string $uniq + * @return string + */ + private static function usernameForCreation( $uniq = '' ) { + $i = 0; + do { + $username = "UTAuthManagerTestAccountCreation" . $uniq . ++$i; + } while ( \User::newFromName( $username )->getId() !== 0 ); + return $username; + } + + public function testCanCreateAccount() { + $username = self::usernameForCreation(); + $this->initializeManager(); + + $this->assertEquals( + \Status::newFatal( 'authmanager-create-disabled' ), + $this->manager->canCreateAccount( $username ) + ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( true ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $this->assertEquals( + \Status::newFatal( 'userexists' ), + $this->manager->canCreateAccount( $username ) + ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( false ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $this->assertEquals( + \Status::newFatal( 'noname' ), + $this->manager->canCreateAccount( $username . '<>' ) + ); + + $this->assertEquals( + \Status::newFatal( 'userexists' ), + $this->manager->canCreateAccount( 'UTSysop' ) + ); + + $this->assertEquals( + \Status::newGood(), + $this->manager->canCreateAccount( $username ) + ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( false ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newFatal( 'fail' ) ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $this->assertEquals( + \Status::newFatal( 'fail' ), + $this->manager->canCreateAccount( $username ) + ); + } + + public function testBeginAccountCreation() { + $creator = \User::newFromName( 'UTSysop' ); + $userReq = new UsernameAuthenticationRequest; + $this->logger = new \TestLogger( false, function ( $message, $level ) { + return $level === LogLevel::DEBUG ? null : $message; + } ); + $this->initializeManager(); + + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', 'test' ); + $this->hook( 'LocalUserCreated', $this->never() ); + try { + $this->manager->beginAccountCreation( + $creator, [], 'http://localhost/' + ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \LogicException $ex ) { + $this->assertEquals( 'Account creation is not possible', $ex->getMessage() ); + } + $this->unhook( 'LocalUserCreated' ); + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ) + ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( true ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->beginAccountCreation( $creator, [], 'http://localhost/' ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'noname', $ret->message->getKey() ); + + $this->hook( 'LocalUserCreated', $this->never() ); + $userReq->username = self::usernameForCreation(); + $userReq2 = new UsernameAuthenticationRequest; + $userReq2->username = $userReq->username . 'X'; + $ret = $this->manager->beginAccountCreation( + $creator, [ $userReq, $userReq2 ], 'http://localhost/' + ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'noname', $ret->message->getKey() ); + + $this->setMwGlobals( [ 'wgReadOnly' => 'Because' ] ); + $this->hook( 'LocalUserCreated', $this->never() ); + $userReq->username = self::usernameForCreation(); + $ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'readonlytext', $ret->message->getKey() ); + $this->assertSame( [ 'Because' ], $ret->message->getParams() ); + $this->setMwGlobals( [ 'wgReadOnly' => false ] ); + + $this->hook( 'LocalUserCreated', $this->never() ); + $userReq->username = self::usernameForCreation(); + $ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'userexists', $ret->message->getKey() ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( false ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newFatal( 'fail' ) ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $this->hook( 'LocalUserCreated', $this->never() ); + $userReq->username = self::usernameForCreation(); + $ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'fail', $ret->message->getKey() ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( false ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $this->hook( 'LocalUserCreated', $this->never() ); + $userReq->username = self::usernameForCreation() . '<>'; + $ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'noname', $ret->message->getKey() ); + + $this->hook( 'LocalUserCreated', $this->never() ); + $userReq->username = $creator->getName(); + $ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'userexists', $ret->message->getKey() ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( false ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $mock->expects( $this->any() )->method( 'testForAccountCreation' ) + ->will( $this->returnValue( StatusValue::newFatal( 'fail' ) ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $req = $this->getMockBuilder( UserDataAuthenticationRequest::class ) + ->setMethods( [ 'populateUser' ] ) + ->getMock(); + $req->expects( $this->any() )->method( 'populateUser' ) + ->willReturn( \StatusValue::newFatal( 'populatefail' ) ); + $userReq->username = self::usernameForCreation(); + $ret = $this->manager->beginAccountCreation( + $creator, [ $userReq, $req ], 'http://localhost/' + ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'populatefail', $ret->message->getKey() ); + + $req = new UserDataAuthenticationRequest; + $userReq->username = self::usernameForCreation(); + + $ret = $this->manager->beginAccountCreation( + $creator, [ $userReq, $req ], 'http://localhost/' + ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'fail', $ret->message->getKey() ); + + $this->manager->beginAccountCreation( + \User::newFromName( $userReq->username ), [ $userReq, $req ], 'http://localhost/' + ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'fail', $ret->message->getKey() ); + } + + public function testContinueAccountCreation() { + $creator = \User::newFromName( 'UTSysop' ); + $username = self::usernameForCreation(); + $this->logger = new \TestLogger( false, function ( $message, $level ) { + return $level === LogLevel::DEBUG ? null : $message; + } ); + $this->initializeManager(); + + $session = [ + 'userid' => 0, + 'username' => $username, + 'creatorid' => 0, + 'creatorname' => $username, + 'reqs' => [], + 'primary' => null, + 'primaryResponse' => null, + 'secondary' => [], + 'ranPreTests' => true, + ]; + + $this->hook( 'LocalUserCreated', $this->never() ); + try { + $this->manager->continueAccountCreation( [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \LogicException $ex ) { + $this->assertEquals( 'Account creation is not possible', $ex->getMessage() ); + } + $this->unhook( 'LocalUserCreated' ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( false ) ); + $mock->expects( $this->any() )->method( 'beginPrimaryAccountCreation' )->will( + $this->returnValue( AuthenticationResponse::newFail( $this->message( 'fail' ) ) ) + ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', null ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->continueAccountCreation( [] ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'authmanager-create-not-in-progress', $ret->message->getKey() ); + + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', + [ 'username' => "$username<>" ] + $session ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->continueAccountCreation( [] ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'noname', $ret->message->getKey() ); + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ) + ); + + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', $session ); + $this->hook( 'LocalUserCreated', $this->never() ); + $cache = \ObjectCache::getLocalClusterInstance(); + $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $username ) ) ); + $ret = $this->manager->continueAccountCreation( [] ); + unset( $lock ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'usernameinprogress', $ret->message->getKey() ); + // This error shouldn't remove the existing session, because the + // raced-with process "owns" it. + $this->assertSame( + $session, $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ) + ); + + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', + [ 'username' => $creator->getName() ] + $session ); + $this->setMwGlobals( [ 'wgReadOnly' => 'Because' ] ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->continueAccountCreation( [] ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'readonlytext', $ret->message->getKey() ); + $this->assertSame( [ 'Because' ], $ret->message->getParams() ); + $this->setMwGlobals( [ 'wgReadOnly' => false ] ); + + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', + [ 'username' => $creator->getName() ] + $session ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->continueAccountCreation( [] ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'userexists', $ret->message->getKey() ); + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ) + ); + + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', + [ 'userid' => $creator->getId() ] + $session ); + $this->hook( 'LocalUserCreated', $this->never() ); + try { + $ret = $this->manager->continueAccountCreation( [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertEquals( "User \"{$username}\" should exist now, but doesn't!", $ex->getMessage() ); + } + $this->unhook( 'LocalUserCreated' ); + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ) + ); + + $id = $creator->getId(); + $name = $creator->getName(); + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', + [ 'username' => $name, 'userid' => $id + 1 ] + $session ); + $this->hook( 'LocalUserCreated', $this->never() ); + try { + $ret = $this->manager->continueAccountCreation( [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertEquals( + "User \"{$name}\" exists, but ID $id != " . ( $id + 1 ) . '!', $ex->getMessage() + ); + } + $this->unhook( 'LocalUserCreated' ); + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ) + ); + + $req = $this->getMockBuilder( UserDataAuthenticationRequest::class ) + ->setMethods( [ 'populateUser' ] ) + ->getMock(); + $req->expects( $this->any() )->method( 'populateUser' ) + ->willReturn( \StatusValue::newFatal( 'populatefail' ) ); + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', + [ 'reqs' => [ $req ] ] + $session ); + $ret = $this->manager->continueAccountCreation( [] ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'populatefail', $ret->message->getKey() ); + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ) + ); + } + + /** + * @dataProvider provideAccountCreation + * @param StatusValue $preTest + * @param StatusValue $primaryTest + * @param StatusValue $secondaryTest + * @param array $primaryResponses + * @param array $secondaryResponses + * @param array $managerResponses + */ + public function testAccountCreation( + StatusValue $preTest, $primaryTest, $secondaryTest, + array $primaryResponses, array $secondaryResponses, array $managerResponses + ) { + $creator = \User::newFromName( 'UTSysop' ); + $username = self::usernameForCreation(); + + $this->initializeManager(); + + // Set up lots of mocks... + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $req->preTest = $preTest; + $req->primaryTest = $primaryTest; + $req->secondaryTest = $secondaryTest; + $req->primary = $primaryResponses; + $req->secondary = $secondaryResponses; + $mocks = []; + foreach ( [ 'pre', 'primary', 'secondary' ] as $key ) { + $class = ucfirst( $key ) . 'AuthenticationProvider'; + $mocks[$key] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key ) ); + $mocks[$key]->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $mocks[$key]->expects( $this->any() )->method( 'testForAccountCreation' ) + ->will( $this->returnCallback( + function ( $user, $creatorIn, $reqs ) + use ( $username, $creator, $req, $key ) + { + $this->assertSame( $username, $user->getName() ); + $this->assertSame( $creator->getId(), $creatorIn->getId() ); + $this->assertSame( $creator->getName(), $creatorIn->getName() ); + $foundReq = false; + foreach ( $reqs as $r ) { + $this->assertSame( $username, $r->username ); + $foundReq = $foundReq || get_class( $r ) === get_class( $req ); + } + $this->assertTrue( $foundReq, '$reqs contains $req' ); + $k = $key . 'Test'; + return $req->$k; + } + ) ); + + for ( $i = 2; $i <= 3; $i++ ) { + $mocks[$key . $i] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks[$key . $i]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key . $i ) ); + $mocks[$key . $i]->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $mocks[$key . $i]->expects( $this->atMost( 1 ) )->method( 'testForAccountCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + } + } + + $mocks['primary']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mocks['primary']->expects( $this->any() )->method( 'testUserExists' ) + ->will( $this->returnValue( false ) ); + $ct = count( $req->primary ); + $callback = $this->returnCallback( function ( $user, $creator, $reqs ) use ( $username, $req ) { + $this->assertSame( $username, $user->getName() ); + $this->assertSame( 'UTSysop', $creator->getName() ); + $foundReq = false; + foreach ( $reqs as $r ) { + $this->assertSame( $username, $r->username ); + $foundReq = $foundReq || get_class( $r ) === get_class( $req ); + } + $this->assertTrue( $foundReq, '$reqs contains $req' ); + return array_shift( $req->primary ); + } ); + $mocks['primary']->expects( $this->exactly( min( 1, $ct ) ) ) + ->method( 'beginPrimaryAccountCreation' ) + ->will( $callback ); + $mocks['primary']->expects( $this->exactly( max( 0, $ct - 1 ) ) ) + ->method( 'continuePrimaryAccountCreation' ) + ->will( $callback ); + + $ct = count( $req->secondary ); + $callback = $this->returnCallback( function ( $user, $creator, $reqs ) use ( $username, $req ) { + $this->assertSame( $username, $user->getName() ); + $this->assertSame( 'UTSysop', $creator->getName() ); + $foundReq = false; + foreach ( $reqs as $r ) { + $this->assertSame( $username, $r->username ); + $foundReq = $foundReq || get_class( $r ) === get_class( $req ); + } + $this->assertTrue( $foundReq, '$reqs contains $req' ); + return array_shift( $req->secondary ); + } ); + $mocks['secondary']->expects( $this->exactly( min( 1, $ct ) ) ) + ->method( 'beginSecondaryAccountCreation' ) + ->will( $callback ); + $mocks['secondary']->expects( $this->exactly( max( 0, $ct - 1 ) ) ) + ->method( 'continueSecondaryAccountCreation' ) + ->will( $callback ); + + $abstain = AuthenticationResponse::newAbstain(); + $mocks['primary2']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) ); + $mocks['primary2']->expects( $this->any() )->method( 'testUserExists' ) + ->will( $this->returnValue( false ) ); + $mocks['primary2']->expects( $this->atMost( 1 ) )->method( 'beginPrimaryAccountCreation' ) + ->will( $this->returnValue( $abstain ) ); + $mocks['primary2']->expects( $this->never() )->method( 'continuePrimaryAccountCreation' ); + $mocks['primary3']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_NONE ) ); + $mocks['primary3']->expects( $this->any() )->method( 'testUserExists' ) + ->will( $this->returnValue( false ) ); + $mocks['primary3']->expects( $this->never() )->method( 'beginPrimaryAccountCreation' ); + $mocks['primary3']->expects( $this->never() )->method( 'continuePrimaryAccountCreation' ); + $mocks['secondary2']->expects( $this->atMost( 1 ) ) + ->method( 'beginSecondaryAccountCreation' ) + ->will( $this->returnValue( $abstain ) ); + $mocks['secondary2']->expects( $this->never() )->method( 'continueSecondaryAccountCreation' ); + $mocks['secondary3']->expects( $this->atMost( 1 ) ) + ->method( 'beginSecondaryAccountCreation' ) + ->will( $this->returnValue( $abstain ) ); + $mocks['secondary3']->expects( $this->never() )->method( 'continueSecondaryAccountCreation' ); + + $this->preauthMocks = [ $mocks['pre'], $mocks['pre2'] ]; + $this->primaryauthMocks = [ $mocks['primary3'], $mocks['primary'], $mocks['primary2'] ]; + $this->secondaryauthMocks = [ + $mocks['secondary3'], $mocks['secondary'], $mocks['secondary2'] + ]; + + $this->logger = new \TestLogger( true, function ( $message, $level ) { + return $level === LogLevel::DEBUG ? null : $message; + } ); + $expectLog = []; + $this->initializeManager( true ); + + $constraint = \PHPUnit_Framework_Assert::logicalOr( + $this->equalTo( AuthenticationResponse::PASS ), + $this->equalTo( AuthenticationResponse::FAIL ) + ); + $providers = array_merge( + $this->preauthMocks, $this->primaryauthMocks, $this->secondaryauthMocks + ); + foreach ( $providers as $p ) { + $p->postCalled = false; + $p->expects( $this->atMost( 1 ) )->method( 'postAccountCreation' ) + ->willReturnCallback( function ( $user, $creator, $response ) + use ( $constraint, $p, $username ) + { + $this->assertInstanceOf( 'User', $user ); + $this->assertSame( $username, $user->getName() ); + $this->assertSame( 'UTSysop', $creator->getName() ); + $this->assertInstanceOf( AuthenticationResponse::class, $response ); + $this->assertThat( $response->status, $constraint ); + $p->postCalled = $response->status; + } ); + } + + // We're testing with $wgNewUserLog = false, so assert that it worked + $dbw = wfGetDB( DB_MASTER ); + $maxLogId = $dbw->selectField( 'logging', 'MAX(log_id)', [ 'log_type' => 'newusers' ] ); + + $first = true; + $created = false; + foreach ( $managerResponses as $i => $response ) { + $success = $response instanceof AuthenticationResponse && + $response->status === AuthenticationResponse::PASS; + if ( $i === 'created' ) { + $created = true; + $this->hook( 'LocalUserCreated', $this->once() ) + ->with( + $this->callback( function ( $user ) use ( $username ) { + return $user->getName() === $username; + } ), + $this->equalTo( false ) + ); + $expectLog[] = [ LogLevel::INFO, "Creating user {user} during account creation" ]; + } else { + $this->hook( 'LocalUserCreated', $this->never() ); + } + + $ex = null; + try { + if ( $first ) { + $userReq = new UsernameAuthenticationRequest; + $userReq->username = $username; + $ret = $this->manager->beginAccountCreation( + $creator, [ $userReq, $req ], 'http://localhost/' + ); + } else { + $ret = $this->manager->continueAccountCreation( [ $req ] ); + } + if ( $response instanceof \Exception ) { + $this->fail( 'Expected exception not thrown', "Response $i" ); + } + } catch ( \Exception $ex ) { + if ( !$response instanceof \Exception ) { + throw $ex; + } + $this->assertEquals( $response->getMessage(), $ex->getMessage(), "Response $i, exception" ); + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ), + "Response $i, exception, session state" + ); + $this->unhook( 'LocalUserCreated' ); + return; + } + + $this->unhook( 'LocalUserCreated' ); + + $this->assertSame( 'http://localhost/', $req->returnToUrl ); + + if ( $success ) { + $this->assertNotNull( $ret->loginRequest, "Response $i, login marker" ); + $this->assertContains( + $ret->loginRequest, $this->managerPriv->createdAccountAuthenticationRequests, + "Response $i, login marker" + ); + + $expectLog[] = [ + LogLevel::INFO, + "MediaWiki\Auth\AuthManager::continueAccountCreation: Account creation succeeded for {user}" + ]; + + // Set some fields in the expected $response that we couldn't + // know in provideAccountCreation(). + $response->username = $username; + $response->loginRequest = $ret->loginRequest; + } else { + $this->assertNull( $ret->loginRequest, "Response $i, login marker" ); + $this->assertSame( [], $this->managerPriv->createdAccountAuthenticationRequests, + "Response $i, login marker" ); + } + $ret->message = $this->message( $ret->message ); + $this->assertEquals( $response, $ret, "Response $i, response" ); + if ( $success || $response->status === AuthenticationResponse::FAIL ) { + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ), + "Response $i, session state" + ); + foreach ( $providers as $p ) { + $this->assertSame( $response->status, $p->postCalled, + "Response $i, post-auth callback called" ); + } + } else { + $this->assertNotNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ), + "Response $i, session state" + ); + $this->assertEquals( + $ret->neededRequests, + $this->manager->getAuthenticationRequests( AuthManager::ACTION_CREATE_CONTINUE ), + "Response $i, continuation check" + ); + foreach ( $providers as $p ) { + $this->assertFalse( $p->postCalled, "Response $i, post-auth callback not called" ); + } + } + + if ( $created ) { + $this->assertNotEquals( 0, \User::idFromName( $username ) ); + } else { + $this->assertEquals( 0, \User::idFromName( $username ) ); + } + + $first = false; + } + + $this->assertSame( $expectLog, $this->logger->getBuffer() ); + + $this->assertSame( + $maxLogId, + $dbw->selectField( 'logging', 'MAX(log_id)', [ 'log_type' => 'newusers' ] ) + ); + } + + public function provideAccountCreation() { + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $good = StatusValue::newGood(); + + return [ + 'Pre-creation test fail in pre' => [ + StatusValue::newFatal( 'fail-from-pre' ), $good, $good, + [], + [], + [ + AuthenticationResponse::newFail( $this->message( 'fail-from-pre' ) ), + ] + ], + 'Pre-creation test fail in primary' => [ + $good, StatusValue::newFatal( 'fail-from-primary' ), $good, + [], + [], + [ + AuthenticationResponse::newFail( $this->message( 'fail-from-primary' ) ), + ] + ], + 'Pre-creation test fail in secondary' => [ + $good, $good, StatusValue::newFatal( 'fail-from-secondary' ), + [], + [], + [ + AuthenticationResponse::newFail( $this->message( 'fail-from-secondary' ) ), + ] + ], + 'Failure in primary' => [ + $good, $good, $good, + $tmp = [ + AuthenticationResponse::newFail( $this->message( 'fail-from-primary' ) ), + ], + [], + $tmp + ], + 'All primary abstain' => [ + $good, $good, $good, + [ + AuthenticationResponse::newAbstain(), + ], + [], + [ + AuthenticationResponse::newFail( $this->message( 'authmanager-create-no-primary' ) ) + ] + ], + 'Primary UI, then redirect, then fail' => [ + $good, $good, $good, + $tmp = [ + AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + AuthenticationResponse::newRedirect( [ $req ], '/foo.html', [ 'foo' => 'bar' ] ), + AuthenticationResponse::newFail( $this->message( 'fail-in-primary-continue' ) ), + ], + [], + $tmp + ], + 'Primary redirect, then abstain' => [ + $good, $good, $good, + [ + $tmp = AuthenticationResponse::newRedirect( + [ $req ], '/foo.html', [ 'foo' => 'bar' ] + ), + AuthenticationResponse::newAbstain(), + ], + [], + [ + $tmp, + new \DomainException( + 'MockPrimaryAuthenticationProvider::continuePrimaryAccountCreation() returned ABSTAIN' + ) + ] + ], + 'Primary UI, then pass; secondary abstain' => [ + $good, $good, $good, + [ + $tmp1 = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + AuthenticationResponse::newPass(), + ], + [ + AuthenticationResponse::newAbstain(), + ], + [ + $tmp1, + 'created' => AuthenticationResponse::newPass( '' ), + ] + ], + 'Primary pass; secondary UI then pass' => [ + $good, $good, $good, + [ + AuthenticationResponse::newPass( '' ), + ], + [ + $tmp1 = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + AuthenticationResponse::newPass( '' ), + ], + [ + 'created' => $tmp1, + AuthenticationResponse::newPass( '' ), + ] + ], + 'Primary pass; secondary fail' => [ + $good, $good, $good, + [ + AuthenticationResponse::newPass(), + ], + [ + AuthenticationResponse::newFail( $this->message( '...' ) ), + ], + [ + 'created' => new \DomainException( + 'MockSecondaryAuthenticationProvider::beginSecondaryAccountCreation() returned FAIL. ' . + 'Secondary providers are not allowed to fail account creation, ' . + 'that should have been done via testForAccountCreation().' + ) + ] + ], + ]; + } + + /** + * @dataProvider provideAccountCreationLogging + * @param bool $isAnon + * @param string|null $logSubtype + */ + public function testAccountCreationLogging( $isAnon, $logSubtype ) { + $creator = $isAnon ? new \User : \User::newFromName( 'UTSysop' ); + $username = self::usernameForCreation(); + + $this->initializeManager(); + + // Set up lots of mocks... + $mock = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\PrimaryAuthenticationProvider", [] + ); + $mock->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary' ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $mock->expects( $this->any() )->method( 'testForAccountCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' ) + ->will( $this->returnValue( false ) ); + $mock->expects( $this->any() )->method( 'beginPrimaryAccountCreation' ) + ->will( $this->returnValue( AuthenticationResponse::newPass( $username ) ) ); + $mock->expects( $this->any() )->method( 'finishAccountCreation' ) + ->will( $this->returnValue( $logSubtype ) ); + + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + $this->logger->setCollect( true ); + + $this->config->set( 'NewUserLog', true ); + + $dbw = wfGetDB( DB_MASTER ); + $maxLogId = $dbw->selectField( 'logging', 'MAX(log_id)', [ 'log_type' => 'newusers' ] ); + + $userReq = new UsernameAuthenticationRequest; + $userReq->username = $username; + $reasonReq = new CreationReasonAuthenticationRequest; + $reasonReq->reason = $this->toString(); + $ret = $this->manager->beginAccountCreation( + $creator, [ $userReq, $reasonReq ], 'http://localhost/' + ); + + $this->assertSame( AuthenticationResponse::PASS, $ret->status ); + + $user = \User::newFromName( $username ); + $this->assertNotEquals( 0, $user->getId(), 'sanity check' ); + $this->assertNotEquals( $creator->getId(), $user->getId(), 'sanity check' ); + + $data = \DatabaseLogEntry::getSelectQueryData(); + $rows = iterator_to_array( $dbw->select( + $data['tables'], + $data['fields'], + [ + 'log_id > ' . (int)$maxLogId, + 'log_type' => 'newusers' + ] + $data['conds'], + __METHOD__, + $data['options'], + $data['join_conds'] + ) ); + $this->assertCount( 1, $rows ); + $entry = \DatabaseLogEntry::newFromRow( reset( $rows ) ); + + $this->assertSame( $logSubtype ?: ( $isAnon ? 'create' : 'create2' ), $entry->getSubtype() ); + $this->assertSame( + $isAnon ? $user->getId() : $creator->getId(), + $entry->getPerformer()->getId() + ); + $this->assertSame( + $isAnon ? $user->getName() : $creator->getName(), + $entry->getPerformer()->getName() + ); + $this->assertSame( $user->getUserPage()->getFullText(), $entry->getTarget()->getFullText() ); + $this->assertSame( [ '4::userid' => $user->getId() ], $entry->getParameters() ); + $this->assertSame( $this->toString(), $entry->getComment() ); + } + + public static function provideAccountCreationLogging() { + return [ + [ true, null ], + [ true, 'foobar' ], + [ false, null ], + [ false, 'byemail' ], + ]; + } + + public function testAutoAccountCreation() { + global $wgGroupPermissions, $wgHooks; + + // PHPUnit seems to have a bug where it will call the ->with() + // callbacks for our hooks again after the test is run (WTF?), which + // breaks here because $username no longer matches $user by the end of + // the testing. + $workaroundPHPUnitBug = false; + + $username = self::usernameForCreation(); + $this->initializeManager(); + + $this->stashMwGlobals( [ 'wgGroupPermissions' ] ); + $wgGroupPermissions['*']['createaccount'] = true; + $wgGroupPermissions['*']['autocreateaccount'] = false; + + \ObjectCache::$instances[__METHOD__] = new \HashBagOStuff(); + $this->setMwGlobals( [ 'wgMainCacheType' => __METHOD__ ] ); + + // Set up lots of mocks... + $mocks = []; + foreach ( [ 'pre', 'primary', 'secondary' ] as $key ) { + $class = ucfirst( $key ) . 'AuthenticationProvider'; + $mocks[$key] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key ) ); + } + + $good = StatusValue::newGood(); + $callback = $this->callback( function ( $user ) use ( &$username, &$workaroundPHPUnitBug ) { + return $workaroundPHPUnitBug || $user->getName() === $username; + } ); + + $mocks['pre']->expects( $this->exactly( 12 ) )->method( 'testUserForCreation' ) + ->with( $callback, $this->identicalTo( AuthManager::AUTOCREATE_SOURCE_SESSION ) ) + ->will( $this->onConsecutiveCalls( + StatusValue::newFatal( 'ok' ), StatusValue::newFatal( 'ok' ), // For testing permissions + StatusValue::newFatal( 'fail-in-pre' ), $good, $good, + $good, // backoff test + $good, // addToDatabase fails test + $good, // addToDatabase throws test + $good, // addToDatabase exists test + $good, $good, $good // success + ) ); + + $mocks['primary']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mocks['primary']->expects( $this->any() )->method( 'testUserExists' ) + ->will( $this->returnValue( true ) ); + $mocks['primary']->expects( $this->exactly( 9 ) )->method( 'testUserForCreation' ) + ->with( $callback, $this->identicalTo( AuthManager::AUTOCREATE_SOURCE_SESSION ) ) + ->will( $this->onConsecutiveCalls( + StatusValue::newFatal( 'fail-in-primary' ), $good, + $good, // backoff test + $good, // addToDatabase fails test + $good, // addToDatabase throws test + $good, // addToDatabase exists test + $good, $good, $good + ) ); + $mocks['primary']->expects( $this->exactly( 3 ) )->method( 'autoCreatedAccount' ) + ->with( $callback, $this->identicalTo( AuthManager::AUTOCREATE_SOURCE_SESSION ) ); + + $mocks['secondary']->expects( $this->exactly( 8 ) )->method( 'testUserForCreation' ) + ->with( $callback, $this->identicalTo( AuthManager::AUTOCREATE_SOURCE_SESSION ) ) + ->will( $this->onConsecutiveCalls( + StatusValue::newFatal( 'fail-in-secondary' ), + $good, // backoff test + $good, // addToDatabase fails test + $good, // addToDatabase throws test + $good, // addToDatabase exists test + $good, $good, $good + ) ); + $mocks['secondary']->expects( $this->exactly( 3 ) )->method( 'autoCreatedAccount' ) + ->with( $callback, $this->identicalTo( AuthManager::AUTOCREATE_SOURCE_SESSION ) ); + + $this->preauthMocks = [ $mocks['pre'] ]; + $this->primaryauthMocks = [ $mocks['primary'] ]; + $this->secondaryauthMocks = [ $mocks['secondary'] ]; + $this->initializeManager( true ); + $session = $this->request->getSession(); + + $logger = new \TestLogger( true, function ( $m ) { + $m = str_replace( 'MediaWiki\\Auth\\AuthManager::autoCreateUser: ', '', $m ); + return $m; + } ); + $this->manager->setLogger( $logger ); + + try { + $user = \User::newFromName( 'UTSysop' ); + $this->manager->autoCreateUser( $user, 'InvalidSource', true ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Unknown auto-creation source: InvalidSource', $ex->getMessage() ); + } + + // First, check an existing user + $session->clear(); + $user = \User::newFromName( 'UTSysop' ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $expect = \Status::newGood(); + $expect->warning( 'userexists' ); + $this->assertEquals( $expect, $ret ); + $this->assertNotEquals( 0, $user->getId() ); + $this->assertSame( 'UTSysop', $user->getName() ); + $this->assertEquals( $user->getId(), $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, '{username} already exists locally' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + + $session->clear(); + $user = \User::newFromName( 'UTSysop' ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, false ); + $this->unhook( 'LocalUserCreated' ); + $expect = \Status::newGood(); + $expect->warning( 'userexists' ); + $this->assertEquals( $expect, $ret ); + $this->assertNotEquals( 0, $user->getId() ); + $this->assertSame( 'UTSysop', $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, '{username} already exists locally' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + + // Wiki is read-only + $session->clear(); + $this->setMwGlobals( [ 'wgReadOnly' => 'Because' ] ); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'readonlytext', 'Because' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'denied by wfReadOnly(): {reason}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->setMwGlobals( [ 'wgReadOnly' => false ] ); + + // Session blacklisted + $session->clear(); + $session->set( 'AuthManager::AutoCreateBlacklist', 'test' ); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'test' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'blacklisted in session {sessionid}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + + $session->clear(); + $session->set( 'AuthManager::AutoCreateBlacklist', StatusValue::newFatal( 'test2' ) ); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'test2' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'blacklisted in session {sessionid}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + + // Uncreatable name + $session->clear(); + $user = \User::newFromName( $username . '@' ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'noname' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username . '@', $user->getId() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'name "{username}" is not creatable' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertSame( 'noname', $session->get( 'AuthManager::AutoCreateBlacklist' ) ); + + // IP unable to create accounts + $wgGroupPermissions['*']['createaccount'] = false; + $wgGroupPermissions['*']['autocreateaccount'] = false; + $session->clear(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'authmanager-autocreate-noperm' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'IP lacks the ability to create or autocreate accounts' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertSame( + 'authmanager-autocreate-noperm', $session->get( 'AuthManager::AutoCreateBlacklist' ) + ); + + // Test that both permutations of permissions are allowed + // (this hits the two "ok" entries in $mocks['pre']) + $wgGroupPermissions['*']['createaccount'] = false; + $wgGroupPermissions['*']['autocreateaccount'] = true; + $session->clear(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'ok' ), $ret ); + + $wgGroupPermissions['*']['createaccount'] = true; + $wgGroupPermissions['*']['autocreateaccount'] = false; + $session->clear(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'ok' ), $ret ); + $logger->clearBuffer(); + + // Test lock fail + $session->clear(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $cache = \ObjectCache::getLocalClusterInstance(); + $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $username ) ) ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + unset( $lock ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'usernameinprogress' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'Could not acquire account creation lock' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + + // Test pre-authentication provider fail + $session->clear(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'fail-in-pre' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'Provider denied creation of {username}: {reason}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertEquals( + StatusValue::newFatal( 'fail-in-pre' ), $session->get( 'AuthManager::AutoCreateBlacklist' ) + ); + + $session->clear(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'fail-in-primary' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'Provider denied creation of {username}: {reason}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertEquals( + StatusValue::newFatal( 'fail-in-primary' ), $session->get( 'AuthManager::AutoCreateBlacklist' ) + ); + + $session->clear(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'fail-in-secondary' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'Provider denied creation of {username}: {reason}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertEquals( + StatusValue::newFatal( 'fail-in-secondary' ), $session->get( 'AuthManager::AutoCreateBlacklist' ) + ); + + // Test backoff + $cache = \ObjectCache::getLocalClusterInstance(); + $backoffKey = wfMemcKey( 'AuthManager', 'autocreate-failed', md5( $username ) ); + $cache->set( $backoffKey, true ); + $session->clear(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'authmanager-autocreate-exception' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, '{username} denied by prior creation attempt failures' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertSame( null, $session->get( 'AuthManager::AutoCreateBlacklist' ) ); + $cache->delete( $backoffKey ); + + // Test addToDatabase fails + $session->clear(); + $user = $this->getMock( 'User', [ 'addToDatabase' ] ); + $user->expects( $this->once() )->method( 'addToDatabase' ) + ->will( $this->returnValue( \Status::newFatal( 'because' ) ) ); + $user->setName( $username ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->assertEquals( \Status::newFatal( 'because' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ], + [ LogLevel::ERROR, '{username} failed with message {message}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertSame( null, $session->get( 'AuthManager::AutoCreateBlacklist' ) ); + + // Test addToDatabase throws an exception + $cache = \ObjectCache::getLocalClusterInstance(); + $backoffKey = wfMemcKey( 'AuthManager', 'autocreate-failed', md5( $username ) ); + $this->assertFalse( $cache->get( $backoffKey ), 'sanity check' ); + $session->clear(); + $user = $this->getMock( 'User', [ 'addToDatabase' ] ); + $user->expects( $this->once() )->method( 'addToDatabase' ) + ->will( $this->throwException( new \Exception( 'Excepted' ) ) ); + $user->setName( $username ); + try { + $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \Exception $ex ) { + $this->assertSame( 'Excepted', $ex->getMessage() ); + } + $this->assertEquals( 0, $user->getId() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ], + [ LogLevel::ERROR, '{username} failed with exception {exception}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertSame( null, $session->get( 'AuthManager::AutoCreateBlacklist' ) ); + $this->assertNotEquals( false, $cache->get( $backoffKey ) ); + $cache->delete( $backoffKey ); + + // Test addToDatabase fails because the user already exists. + $session->clear(); + $user = $this->getMock( 'User', [ 'addToDatabase' ] ); + $user->expects( $this->once() )->method( 'addToDatabase' ) + ->will( $this->returnCallback( function () use ( $username ) { + $status = \User::newFromName( $username )->addToDatabase(); + $this->assertTrue( $status->isOK(), 'sanity check' ); + return \Status::newFatal( 'userexists' ); + } ) ); + $user->setName( $username ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $expect = \Status::newGood(); + $expect->warning( 'userexists' ); + $this->assertEquals( $expect, $ret ); + $this->assertNotEquals( 0, $user->getId() ); + $this->assertEquals( $username, $user->getName() ); + $this->assertEquals( $user->getId(), $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ], + [ LogLevel::INFO, '{username} already exists locally (race)' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertSame( null, $session->get( 'AuthManager::AutoCreateBlacklist' ) ); + + // Success! + $session->clear(); + $username = self::usernameForCreation(); + $user = \User::newFromName( $username ); + $this->hook( 'AuthPluginAutoCreate', $this->once() ) + ->with( $callback ); + $this->hideDeprecated( 'AuthPluginAutoCreate hook (used in ' . + get_class( $wgHooks['AuthPluginAutoCreate'][0] ) . '::onAuthPluginAutoCreate)' ); + $this->hook( 'LocalUserCreated', $this->once() ) + ->with( $callback, $this->equalTo( true ) ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->unhook( 'AuthPluginAutoCreate' ); + $this->assertEquals( \Status::newGood(), $ret ); + $this->assertNotEquals( 0, $user->getId() ); + $this->assertEquals( $username, $user->getName() ); + $this->assertEquals( $user->getId(), $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + + $dbw = wfGetDB( DB_MASTER ); + $maxLogId = $dbw->selectField( 'logging', 'MAX(log_id)', [ 'log_type' => 'newusers' ] ); + $session->clear(); + $username = self::usernameForCreation(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->once() ) + ->with( $callback, $this->equalTo( true ) ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, false ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newGood(), $ret ); + $this->assertNotEquals( 0, $user->getId() ); + $this->assertEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertSame( + $maxLogId, + $dbw->selectField( 'logging', 'MAX(log_id)', [ 'log_type' => 'newusers' ] ) + ); + + $this->config->set( 'NewUserLog', true ); + $session->clear(); + $username = self::usernameForCreation(); + $user = \User::newFromName( $username ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, false ); + $this->assertEquals( \Status::newGood(), $ret ); + $logger->clearBuffer(); + + $data = \DatabaseLogEntry::getSelectQueryData(); + $rows = iterator_to_array( $dbw->select( + $data['tables'], + $data['fields'], + [ + 'log_id > ' . (int)$maxLogId, + 'log_type' => 'newusers' + ] + $data['conds'], + __METHOD__, + $data['options'], + $data['join_conds'] + ) ); + $this->assertCount( 1, $rows ); + $entry = \DatabaseLogEntry::newFromRow( reset( $rows ) ); + + $this->assertSame( 'autocreate', $entry->getSubtype() ); + $this->assertSame( $user->getId(), $entry->getPerformer()->getId() ); + $this->assertSame( $user->getName(), $entry->getPerformer()->getName() ); + $this->assertSame( $user->getUserPage()->getFullText(), $entry->getTarget()->getFullText() ); + $this->assertSame( [ '4::userid' => $user->getId() ], $entry->getParameters() ); + + $workaroundPHPUnitBug = true; + } + + /** + * @dataProvider provideGetAuthenticationRequests + * @param string $action + * @param array $expect + * @param array $state + */ + public function testGetAuthenticationRequests( $action, $expect, $state = [] ) { + $makeReq = function ( $key ) use ( $action ) { + $req = $this->getMock( AuthenticationRequest::class ); + $req->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key ) ); + $req->action = $action === AuthManager::ACTION_UNLINK ? AuthManager::ACTION_REMOVE : $action; + $req->key = $key; + return $req; + }; + $cmpReqs = function ( $a, $b ) { + $ret = strcmp( get_class( $a ), get_class( $b ) ); + if ( !$ret ) { + $ret = strcmp( $a->key, $b->key ); + } + return $ret; + }; + + $good = StatusValue::newGood(); + + $mocks = []; + foreach ( [ 'pre', 'primary', 'secondary' ] as $key ) { + $class = ucfirst( $key ) . 'AuthenticationProvider'; + $mocks[$key] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key ) ); + $mocks[$key]->expects( $this->any() )->method( 'getAuthenticationRequests' ) + ->will( $this->returnCallback( function ( $action ) use ( $key, $makeReq ) { + return [ $makeReq( "$key-$action" ), $makeReq( 'generic' ) ]; + } ) ); + $mocks[$key]->expects( $this->any() )->method( 'providerAllowsAuthenticationDataChange' ) + ->will( $this->returnValue( $good ) ); + } + + $primaries = []; + foreach ( [ + PrimaryAuthenticationProvider::TYPE_NONE, + PrimaryAuthenticationProvider::TYPE_CREATE, + PrimaryAuthenticationProvider::TYPE_LINK + ] as $type ) { + $class = 'PrimaryAuthenticationProvider'; + $mocks["primary-$type"] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks["primary-$type"]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( "primary-$type" ) ); + $mocks["primary-$type"]->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( $type ) ); + $mocks["primary-$type"]->expects( $this->any() )->method( 'getAuthenticationRequests' ) + ->will( $this->returnCallback( function ( $action ) use ( $type, $makeReq ) { + return [ $makeReq( "primary-$type-$action" ), $makeReq( 'generic' ) ]; + } ) ); + $mocks["primary-$type"]->expects( $this->any() ) + ->method( 'providerAllowsAuthenticationDataChange' ) + ->will( $this->returnValue( $good ) ); + $this->primaryauthMocks[] = $mocks["primary-$type"]; + } + + $mocks['primary2'] = $this->getMockForAbstractClass( + PrimaryAuthenticationProvider::class, [], "MockPrimaryAuthenticationProvider" + ); + $mocks['primary2']->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary2' ) ); + $mocks['primary2']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) ); + $mocks['primary2']->expects( $this->any() )->method( 'getAuthenticationRequests' ) + ->will( $this->returnValue( [] ) ); + $mocks['primary2']->expects( $this->any() ) + ->method( 'providerAllowsAuthenticationDataChange' ) + ->will( $this->returnCallback( function ( $req ) use ( $good ) { + return $req->key === 'generic' ? StatusValue::newFatal( 'no' ) : $good; + } ) ); + $this->primaryauthMocks[] = $mocks['primary2']; + + $this->preauthMocks = [ $mocks['pre'] ]; + $this->secondaryauthMocks = [ $mocks['secondary'] ]; + $this->initializeManager( true ); + + if ( $state ) { + if ( isset( $state['continueRequests'] ) ) { + $state['continueRequests'] = array_map( $makeReq, $state['continueRequests'] ); + } + if ( $action === AuthManager::ACTION_LOGIN_CONTINUE ) { + $this->request->getSession()->setSecret( 'AuthManager::authnState', $state ); + } elseif ( $action === AuthManager::ACTION_CREATE_CONTINUE ) { + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', $state ); + } elseif ( $action === AuthManager::ACTION_LINK_CONTINUE ) { + $this->request->getSession()->setSecret( 'AuthManager::accountLinkState', $state ); + } + } + + $expectReqs = array_map( $makeReq, $expect ); + if ( $action === AuthManager::ACTION_LOGIN ) { + $req = new RememberMeAuthenticationRequest; + $req->action = $action; + $req->required = AuthenticationRequest::REQUIRED; + $expectReqs[] = $req; + } elseif ( $action === AuthManager::ACTION_CREATE ) { + $req = new UsernameAuthenticationRequest; + $req->action = $action; + $expectReqs[] = $req; + $req = new UserDataAuthenticationRequest; + $req->action = $action; + $req->required = AuthenticationRequest::REQUIRED; + $expectReqs[] = $req; + } + usort( $expectReqs, $cmpReqs ); + + $actual = $this->manager->getAuthenticationRequests( $action ); + foreach ( $actual as $req ) { + // Don't test this here. + $req->required = AuthenticationRequest::REQUIRED; + } + usort( $actual, $cmpReqs ); + + $this->assertEquals( $expectReqs, $actual ); + + // Test CreationReasonAuthenticationRequest gets returned + if ( $action === AuthManager::ACTION_CREATE ) { + $req = new CreationReasonAuthenticationRequest; + $req->action = $action; + $req->required = AuthenticationRequest::REQUIRED; + $expectReqs[] = $req; + usort( $expectReqs, $cmpReqs ); + + $actual = $this->manager->getAuthenticationRequests( $action, \User::newFromName( 'UTSysop' ) ); + foreach ( $actual as $req ) { + // Don't test this here. + $req->required = AuthenticationRequest::REQUIRED; + } + usort( $actual, $cmpReqs ); + + $this->assertEquals( $expectReqs, $actual ); + } + } + + public static function provideGetAuthenticationRequests() { + return [ + [ + AuthManager::ACTION_LOGIN, + [ 'pre-login', 'primary-none-login', 'primary-create-login', + 'primary-link-login', 'secondary-login', 'generic' ], + ], + [ + AuthManager::ACTION_CREATE, + [ 'pre-create', 'primary-none-create', 'primary-create-create', + 'primary-link-create', 'secondary-create', 'generic' ], + ], + [ + AuthManager::ACTION_LINK, + [ 'primary-link-link', 'generic' ], + ], + [ + AuthManager::ACTION_CHANGE, + [ 'primary-none-change', 'primary-create-change', 'primary-link-change', + 'secondary-change' ], + ], + [ + AuthManager::ACTION_REMOVE, + [ 'primary-none-remove', 'primary-create-remove', 'primary-link-remove', + 'secondary-remove' ], + ], + [ + AuthManager::ACTION_UNLINK, + [ 'primary-link-remove' ], + ], + [ + AuthManager::ACTION_LOGIN_CONTINUE, + [], + ], + [ + AuthManager::ACTION_LOGIN_CONTINUE, + $reqs = [ 'continue-login', 'foo', 'bar' ], + [ + 'continueRequests' => $reqs, + ], + ], + [ + AuthManager::ACTION_CREATE_CONTINUE, + [], + ], + [ + AuthManager::ACTION_CREATE_CONTINUE, + $reqs = [ 'continue-create', 'foo', 'bar' ], + [ + 'continueRequests' => $reqs, + ], + ], + [ + AuthManager::ACTION_LINK_CONTINUE, + [], + ], + [ + AuthManager::ACTION_LINK_CONTINUE, + $reqs = [ 'continue-link', 'foo', 'bar' ], + [ + 'continueRequests' => $reqs, + ], + ], + ]; + } + + public function testGetAuthenticationRequestsRequired() { + $makeReq = function ( $key, $required ) { + $req = $this->getMock( AuthenticationRequest::class ); + $req->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key ) ); + $req->action = AuthManager::ACTION_LOGIN; + $req->key = $key; + $req->required = $required; + return $req; + }; + $cmpReqs = function ( $a, $b ) { + $ret = strcmp( get_class( $a ), get_class( $b ) ); + if ( !$ret ) { + $ret = strcmp( $a->key, $b->key ); + } + return $ret; + }; + + $good = StatusValue::newGood(); + + $primary1 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $primary1->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary1' ) ); + $primary1->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $primary1->expects( $this->any() )->method( 'getAuthenticationRequests' ) + ->will( $this->returnCallback( function ( $action ) use ( $makeReq ) { + return [ + $makeReq( "primary-shared", AuthenticationRequest::REQUIRED ), + $makeReq( "required", AuthenticationRequest::REQUIRED ), + $makeReq( "optional", AuthenticationRequest::OPTIONAL ), + $makeReq( "foo", AuthenticationRequest::REQUIRED ), + $makeReq( "bar", AuthenticationRequest::REQUIRED ), + $makeReq( "baz", AuthenticationRequest::OPTIONAL ), + ]; + } ) ); + + $primary2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $primary2->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary2' ) ); + $primary2->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $primary2->expects( $this->any() )->method( 'getAuthenticationRequests' ) + ->will( $this->returnCallback( function ( $action ) use ( $makeReq ) { + return [ + $makeReq( "primary-shared", AuthenticationRequest::REQUIRED ), + $makeReq( "required2", AuthenticationRequest::REQUIRED ), + $makeReq( "optional2", AuthenticationRequest::OPTIONAL ), + ]; + } ) ); + + $secondary = $this->getMockForAbstractClass( SecondaryAuthenticationProvider::class ); + $secondary->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'secondary' ) ); + $secondary->expects( $this->any() )->method( 'getAuthenticationRequests' ) + ->will( $this->returnCallback( function ( $action ) use ( $makeReq ) { + return [ + $makeReq( "foo", AuthenticationRequest::OPTIONAL ), + $makeReq( "bar", AuthenticationRequest::REQUIRED ), + $makeReq( "baz", AuthenticationRequest::REQUIRED ), + ]; + } ) ); + + $rememberReq = new RememberMeAuthenticationRequest; + $rememberReq->action = AuthManager::ACTION_LOGIN; + + $this->primaryauthMocks = [ $primary1, $primary2 ]; + $this->secondaryauthMocks = [ $secondary ]; + $this->initializeManager( true ); + + $actual = $this->manager->getAuthenticationRequests( AuthManager::ACTION_LOGIN ); + $expected = [ + $rememberReq, + $makeReq( "primary-shared", AuthenticationRequest::REQUIRED ), + $makeReq( "required", AuthenticationRequest::PRIMARY_REQUIRED ), + $makeReq( "required2", AuthenticationRequest::PRIMARY_REQUIRED ), + $makeReq( "optional", AuthenticationRequest::OPTIONAL ), + $makeReq( "optional2", AuthenticationRequest::OPTIONAL ), + $makeReq( "foo", AuthenticationRequest::PRIMARY_REQUIRED ), + $makeReq( "bar", AuthenticationRequest::REQUIRED ), + $makeReq( "baz", AuthenticationRequest::REQUIRED ), + ]; + usort( $actual, $cmpReqs ); + usort( $expected, $cmpReqs ); + $this->assertEquals( $expected, $actual ); + + $this->primaryauthMocks = [ $primary1 ]; + $this->secondaryauthMocks = [ $secondary ]; + $this->initializeManager( true ); + + $actual = $this->manager->getAuthenticationRequests( AuthManager::ACTION_LOGIN ); + $expected = [ + $rememberReq, + $makeReq( "primary-shared", AuthenticationRequest::REQUIRED ), + $makeReq( "required", AuthenticationRequest::REQUIRED ), + $makeReq( "optional", AuthenticationRequest::OPTIONAL ), + $makeReq( "foo", AuthenticationRequest::REQUIRED ), + $makeReq( "bar", AuthenticationRequest::REQUIRED ), + $makeReq( "baz", AuthenticationRequest::REQUIRED ), + ]; + usort( $actual, $cmpReqs ); + usort( $expected, $cmpReqs ); + $this->assertEquals( $expected, $actual ); + } + + public function testAllowsPropertyChange() { + $mocks = []; + foreach ( [ 'primary', 'secondary' ] as $key ) { + $class = ucfirst( $key ) . 'AuthenticationProvider'; + $mocks[$key] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key ) ); + $mocks[$key]->expects( $this->any() )->method( 'providerAllowsPropertyChange' ) + ->will( $this->returnCallback( function ( $prop ) use ( $key ) { + return $prop !== $key; + } ) ); + } + + $this->primaryauthMocks = [ $mocks['primary'] ]; + $this->secondaryauthMocks = [ $mocks['secondary'] ]; + $this->initializeManager( true ); + + $this->assertTrue( $this->manager->allowsPropertyChange( 'foo' ) ); + $this->assertFalse( $this->manager->allowsPropertyChange( 'primary' ) ); + $this->assertFalse( $this->manager->allowsPropertyChange( 'secondary' ) ); + } + + public function testAutoCreateOnLogin() { + $username = self::usernameForCreation(); + + $req = $this->getMock( AuthenticationRequest::class ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'primary' ) ); + $mock->expects( $this->any() )->method( 'beginPrimaryAuthentication' ) + ->will( $this->returnValue( AuthenticationResponse::newPass( $username ) ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( true ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + + $mock2 = $this->getMockForAbstractClass( SecondaryAuthenticationProvider::class ); + $mock2->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'secondary' ) ); + $mock2->expects( $this->any() )->method( 'beginSecondaryAuthentication' )->will( + $this->returnValue( + AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ) + ) + ); + $mock2->expects( $this->any() )->method( 'continueSecondaryAuthentication' ) + ->will( $this->returnValue( AuthenticationResponse::newAbstain() ) ); + $mock2->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + + $this->primaryauthMocks = [ $mock ]; + $this->secondaryauthMocks = [ $mock2 ]; + $this->initializeManager( true ); + $this->manager->setLogger( new \Psr\Log\NullLogger() ); + $session = $this->request->getSession(); + $session->clear(); + + $this->assertSame( 0, \User::newFromName( $username )->getId(), + 'sanity check' ); + + $callback = $this->callback( function ( $user ) use ( $username ) { + return $user->getName() === $username; + } ); + + $this->hook( 'UserLoggedIn', $this->never() ); + $this->hook( 'LocalUserCreated', $this->once() )->with( $callback, $this->equalTo( true ) ); + $ret = $this->manager->beginAuthentication( [], 'http://localhost/' ); + $this->unhook( 'LocalUserCreated' ); + $this->unhook( 'UserLoggedIn' ); + $this->assertSame( AuthenticationResponse::UI, $ret->status ); + + $id = (int)\User::newFromName( $username )->getId(); + $this->assertNotSame( 0, \User::newFromName( $username )->getId() ); + $this->assertSame( 0, $session->getUser()->getId() ); + + $this->hook( 'UserLoggedIn', $this->once() )->with( $callback ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->continueAuthentication( [] ); + $this->unhook( 'LocalUserCreated' ); + $this->unhook( 'UserLoggedIn' ); + $this->assertSame( AuthenticationResponse::PASS, $ret->status ); + $this->assertSame( $username, $ret->username ); + $this->assertSame( $id, $session->getUser()->getId() ); + } + + public function testAutoCreateFailOnLogin() { + $username = self::usernameForCreation(); + + $mock = $this->getMockForAbstractClass( + PrimaryAuthenticationProvider::class, [], "MockPrimaryAuthenticationProvider" ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'primary' ) ); + $mock->expects( $this->any() )->method( 'beginPrimaryAuthentication' ) + ->will( $this->returnValue( AuthenticationResponse::newPass( $username ) ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( true ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newFatal( 'fail-from-primary' ) ) ); + + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + $this->manager->setLogger( new \Psr\Log\NullLogger() ); + $session = $this->request->getSession(); + $session->clear(); + + $this->assertSame( 0, $session->getUser()->getId(), + 'sanity check' ); + $this->assertSame( 0, \User::newFromName( $username )->getId(), + 'sanity check' ); + + $this->hook( 'UserLoggedIn', $this->never() ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->beginAuthentication( [], 'http://localhost/' ); + $this->unhook( 'LocalUserCreated' ); + $this->unhook( 'UserLoggedIn' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'authmanager-authn-autocreate-failed', $ret->message->getKey() ); + + $this->assertSame( 0, \User::newFromName( $username )->getId() ); + $this->assertSame( 0, $session->getUser()->getId() ); + } + + public function testAuthenticationSessionData() { + $this->initializeManager( true ); + + $this->assertNull( $this->manager->getAuthenticationSessionData( 'foo' ) ); + $this->manager->setAuthenticationSessionData( 'foo', 'foo!' ); + $this->manager->setAuthenticationSessionData( 'bar', 'bar!' ); + $this->assertSame( 'foo!', $this->manager->getAuthenticationSessionData( 'foo' ) ); + $this->assertSame( 'bar!', $this->manager->getAuthenticationSessionData( 'bar' ) ); + $this->manager->removeAuthenticationSessionData( 'foo' ); + $this->assertNull( $this->manager->getAuthenticationSessionData( 'foo' ) ); + $this->assertSame( 'bar!', $this->manager->getAuthenticationSessionData( 'bar' ) ); + $this->manager->removeAuthenticationSessionData( 'bar' ); + $this->assertNull( $this->manager->getAuthenticationSessionData( 'bar' ) ); + + $this->manager->setAuthenticationSessionData( 'foo', 'foo!' ); + $this->manager->setAuthenticationSessionData( 'bar', 'bar!' ); + $this->manager->removeAuthenticationSessionData( null ); + $this->assertNull( $this->manager->getAuthenticationSessionData( 'foo' ) ); + $this->assertNull( $this->manager->getAuthenticationSessionData( 'bar' ) ); + + } + + public function testCanLinkAccounts() { + $types = [ + PrimaryAuthenticationProvider::TYPE_CREATE => true, + PrimaryAuthenticationProvider::TYPE_LINK => true, + PrimaryAuthenticationProvider::TYPE_NONE => false, + ]; + + foreach ( $types as $type => $can ) { + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( $type ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( $type ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + $this->assertSame( $can, $this->manager->canCreateAccounts(), $type ); + } + } + + public function testBeginAccountLink() { + $user = \User::newFromName( 'UTSysop' ); + $this->initializeManager(); + + $this->request->getSession()->setSecret( 'AuthManager::accountLinkState', 'test' ); + try { + $this->manager->beginAccountLink( $user, [], 'http://localhost/' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \LogicException $ex ) { + $this->assertEquals( 'Account linking is not possible', $ex->getMessage() ); + } + $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ) ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $ret = $this->manager->beginAccountLink( new \User, [], 'http://localhost/' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'noname', $ret->message->getKey() ); + + $ret = $this->manager->beginAccountLink( + \User::newFromName( 'UTDoesNotExist' ), [], 'http://localhost/' + ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'authmanager-userdoesnotexist', $ret->message->getKey() ); + } + + public function testContinueAccountLink() { + $user = \User::newFromName( 'UTSysop' ); + $this->initializeManager(); + + $session = [ + 'userid' => $user->getId(), + 'username' => $user->getName(), + 'primary' => 'X', + ]; + + try { + $this->manager->continueAccountLink( [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \LogicException $ex ) { + $this->assertEquals( 'Account linking is not possible', $ex->getMessage() ); + } + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) ); + $mock->expects( $this->any() )->method( 'beginPrimaryAccountLink' )->will( + $this->returnValue( AuthenticationResponse::newFail( $this->message( 'fail' ) ) ) + ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $this->request->getSession()->setSecret( 'AuthManager::accountLinkState', null ); + $ret = $this->manager->continueAccountLink( [] ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'authmanager-link-not-in-progress', $ret->message->getKey() ); + + $this->request->getSession()->setSecret( 'AuthManager::accountLinkState', + [ 'username' => $user->getName() . '<>' ] + $session ); + $ret = $this->manager->continueAccountLink( [] ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'noname', $ret->message->getKey() ); + $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ) ); + + $id = $user->getId(); + $this->request->getSession()->setSecret( 'AuthManager::accountLinkState', + [ 'userid' => $id + 1 ] + $session ); + try { + $ret = $this->manager->continueAccountLink( [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertEquals( + "User \"{$user->getName()}\" is valid, but ID $id != " . ( $id + 1 ) . '!', + $ex->getMessage() + ); + } + $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ) ); + } + + /** + * @dataProvider provideAccountLink + * @param StatusValue $preTest + * @param array $primaryResponses + * @param array $managerResponses + */ + public function testAccountLink( + StatusValue $preTest, array $primaryResponses, array $managerResponses + ) { + $user = \User::newFromName( 'UTSysop' ); + + $this->initializeManager(); + + // Set up lots of mocks... + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $req->primary = $primaryResponses; + $mocks = []; + + foreach ( [ 'pre', 'primary' ] as $key ) { + $class = ucfirst( $key ) . 'AuthenticationProvider'; + $mocks[$key] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key ) ); + + for ( $i = 2; $i <= 3; $i++ ) { + $mocks[$key . $i] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks[$key . $i]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key . $i ) ); + } + } + + $mocks['pre']->expects( $this->any() )->method( 'testForAccountLink' ) + ->will( $this->returnCallback( + function ( $u ) + use ( $user, $preTest ) + { + $this->assertSame( $user->getId(), $u->getId() ); + $this->assertSame( $user->getName(), $u->getName() ); + return $preTest; + } + ) ); + + $mocks['pre2']->expects( $this->atMost( 1 ) )->method( 'testForAccountLink' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + + $mocks['primary']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) ); + $ct = count( $req->primary ); + $callback = $this->returnCallback( function ( $u, $reqs ) use ( $user, $req ) { + $this->assertSame( $user->getId(), $u->getId() ); + $this->assertSame( $user->getName(), $u->getName() ); + $foundReq = false; + foreach ( $reqs as $r ) { + $this->assertSame( $user->getName(), $r->username ); + $foundReq = $foundReq || get_class( $r ) === get_class( $req ); + } + $this->assertTrue( $foundReq, '$reqs contains $req' ); + return array_shift( $req->primary ); + } ); + $mocks['primary']->expects( $this->exactly( min( 1, $ct ) ) ) + ->method( 'beginPrimaryAccountLink' ) + ->will( $callback ); + $mocks['primary']->expects( $this->exactly( max( 0, $ct - 1 ) ) ) + ->method( 'continuePrimaryAccountLink' ) + ->will( $callback ); + + $abstain = AuthenticationResponse::newAbstain(); + $mocks['primary2']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) ); + $mocks['primary2']->expects( $this->atMost( 1 ) )->method( 'beginPrimaryAccountLink' ) + ->will( $this->returnValue( $abstain ) ); + $mocks['primary2']->expects( $this->never() )->method( 'continuePrimaryAccountLink' ); + $mocks['primary3']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mocks['primary3']->expects( $this->never() )->method( 'beginPrimaryAccountLink' ); + $mocks['primary3']->expects( $this->never() )->method( 'continuePrimaryAccountLink' ); + + $this->preauthMocks = [ $mocks['pre'], $mocks['pre2'] ]; + $this->primaryauthMocks = [ $mocks['primary3'], $mocks['primary2'], $mocks['primary'] ]; + $this->logger = new \TestLogger( true, function ( $message, $level ) { + return $level === LogLevel::DEBUG ? null : $message; + } ); + $this->initializeManager( true ); + + $constraint = \PHPUnit_Framework_Assert::logicalOr( + $this->equalTo( AuthenticationResponse::PASS ), + $this->equalTo( AuthenticationResponse::FAIL ) + ); + $providers = array_merge( $this->preauthMocks, $this->primaryauthMocks ); + foreach ( $providers as $p ) { + $p->postCalled = false; + $p->expects( $this->atMost( 1 ) )->method( 'postAccountLink' ) + ->willReturnCallback( function ( $user, $response ) use ( $constraint, $p ) { + $this->assertInstanceOf( 'User', $user ); + $this->assertSame( 'UTSysop', $user->getName() ); + $this->assertInstanceOf( AuthenticationResponse::class, $response ); + $this->assertThat( $response->status, $constraint ); + $p->postCalled = $response->status; + } ); + } + + $first = true; + $created = false; + $expectLog = []; + foreach ( $managerResponses as $i => $response ) { + if ( $response instanceof AuthenticationResponse && + $response->status === AuthenticationResponse::PASS + ) { + $expectLog[] = [ LogLevel::INFO, 'Account linked to {user} by primary' ]; + } + + $ex = null; + try { + if ( $first ) { + $ret = $this->manager->beginAccountLink( $user, [ $req ], 'http://localhost/' ); + } else { + $ret = $this->manager->continueAccountLink( [ $req ] ); + } + if ( $response instanceof \Exception ) { + $this->fail( 'Expected exception not thrown', "Response $i" ); + } + } catch ( \Exception $ex ) { + if ( !$response instanceof \Exception ) { + throw $ex; + } + $this->assertEquals( $response->getMessage(), $ex->getMessage(), "Response $i, exception" ); + $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ), + "Response $i, exception, session state" ); + return; + } + + $this->assertSame( 'http://localhost/', $req->returnToUrl ); + + $ret->message = $this->message( $ret->message ); + $this->assertEquals( $response, $ret, "Response $i, response" ); + if ( $response->status === AuthenticationResponse::PASS || + $response->status === AuthenticationResponse::FAIL + ) { + $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ), + "Response $i, session state" ); + foreach ( $providers as $p ) { + $this->assertSame( $response->status, $p->postCalled, + "Response $i, post-auth callback called" ); + } + } else { + $this->assertNotNull( + $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ), + "Response $i, session state" + ); + $this->assertEquals( + $ret->neededRequests, + $this->manager->getAuthenticationRequests( AuthManager::ACTION_LINK_CONTINUE ), + "Response $i, continuation check" + ); + foreach ( $providers as $p ) { + $this->assertFalse( $p->postCalled, "Response $i, post-auth callback not called" ); + } + } + + $first = false; + } + + $this->assertSame( $expectLog, $this->logger->getBuffer() ); + } + + public function provideAccountLink() { + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $good = StatusValue::newGood(); + + return [ + 'Pre-link test fail in pre' => [ + StatusValue::newFatal( 'fail-from-pre' ), + [], + [ + AuthenticationResponse::newFail( $this->message( 'fail-from-pre' ) ), + ] + ], + 'Failure in primary' => [ + $good, + $tmp = [ + AuthenticationResponse::newFail( $this->message( 'fail-from-primary' ) ), + ], + $tmp + ], + 'All primary abstain' => [ + $good, + [ + AuthenticationResponse::newAbstain(), + ], + [ + AuthenticationResponse::newFail( $this->message( 'authmanager-link-no-primary' ) ) + ] + ], + 'Primary UI, then redirect, then fail' => [ + $good, + $tmp = [ + AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + AuthenticationResponse::newRedirect( [ $req ], '/foo.html', [ 'foo' => 'bar' ] ), + AuthenticationResponse::newFail( $this->message( 'fail-in-primary-continue' ) ), + ], + $tmp + ], + 'Primary redirect, then abstain' => [ + $good, + [ + $tmp = AuthenticationResponse::newRedirect( + [ $req ], '/foo.html', [ 'foo' => 'bar' ] + ), + AuthenticationResponse::newAbstain(), + ], + [ + $tmp, + new \DomainException( + 'MockPrimaryAuthenticationProvider::continuePrimaryAccountLink() returned ABSTAIN' + ) + ] + ], + 'Primary UI, then pass' => [ + $good, + [ + $tmp1 = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + AuthenticationResponse::newPass(), + ], + [ + $tmp1, + AuthenticationResponse::newPass( '' ), + ] + ], + 'Primary pass' => [ + $good, + [ + AuthenticationResponse::newPass( '' ), + ], + [ + AuthenticationResponse::newPass( '' ), + ] + ], + ]; + } +} diff --git a/tests/phpunit/includes/auth/AuthPluginPrimaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/AuthPluginPrimaryAuthenticationProviderTest.php new file mode 100644 index 0000000000..b676d69a48 --- /dev/null +++ b/tests/phpunit/includes/auth/AuthPluginPrimaryAuthenticationProviderTest.php @@ -0,0 +1,706 @@ +markTestSkipped( '$wgDisableAuthManager is set' ); + } + } + + public function testConstruction() { + $plugin = new AuthManagerAuthPlugin(); + try { + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'Trying to wrap AuthManagerAuthPlugin in AuthPluginPrimaryAuthenticationProvider ' . + 'makes no sense.', + $ex->getMessage() + ); + } + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertEquals( + [ new PasswordAuthenticationRequest ], + $provider->getAuthenticationRequests( AuthManager::ACTION_LOGIN, [] ) + ); + + $req = $this->getMock( PasswordAuthenticationRequest::class ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin, get_class( $req ) ); + $this->assertEquals( + [ $req ], + $provider->getAuthenticationRequests( AuthManager::ACTION_LOGIN, [] ) + ); + + $reqType = get_class( $this->getMock( AuthenticationRequest::class ) ); + try { + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin, $reqType ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + "$reqType is not a MediaWiki\\Auth\\PasswordAuthenticationRequest", + $ex->getMessage() + ); + } + } + + public function testOnUserSaveSettings() { + $user = \User::newFromName( 'UTSysop' ); + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'updateExternalDB' ) + ->with( $this->identicalTo( $user ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + \Hooks::run( 'UserSaveSettings', [ $user ] ); + } + + public function testOnUserGroupsChanged() { + $user = \User::newFromName( 'UTSysop' ); + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'updateExternalDBGroups' ) + ->with( + $this->identicalTo( $user ), + $this->identicalTo( [ 'added' ] ), + $this->identicalTo( [ 'removed' ] ) + ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + \Hooks::run( 'UserGroupsChanged', [ $user, [ 'added' ], [ 'removed' ] ] ); + } + + public function testOnUserLoggedIn() { + $user = \User::newFromName( 'UTSysop' ); + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->exactly( 2 ) )->method( 'updateUser' ) + ->with( $this->identicalTo( $user ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + \Hooks::run( 'UserLoggedIn', [ $user ] ); + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'updateUser' ) + ->will( $this->returnCallback( function ( &$user ) { + $user = \User::newFromName( 'UTSysop' ); + } ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + try { + \Hooks::run( 'UserLoggedIn', [ $user ] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + get_class( $plugin ) . '::updateUser() tried to replace $user!', + $ex->getMessage() + ); + } + } + + public function testOnLocalUserCreated() { + $user = \User::newFromName( 'UTSysop' ); + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->exactly( 2 ) )->method( 'initUser' ) + ->with( $this->identicalTo( $user ), $this->identicalTo( false ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + \Hooks::run( 'LocalUserCreated', [ $user, false ] ); + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'initUser' ) + ->will( $this->returnCallback( function ( &$user ) { + $user = \User::newFromName( 'UTSysop' ); + } ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + try { + \Hooks::run( 'LocalUserCreated', [ $user, false ] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + get_class( $plugin ) . '::initUser() tried to replace $user!', + $ex->getMessage() + ); + } + } + + public function testGetUniqueId() { + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertSame( + 'MediaWiki\\Auth\\AuthPluginPrimaryAuthenticationProvider:' . get_class( $plugin ), + $provider->getUniqueId() + ); + } + + /** + * @dataProvider provideGetAuthenticationRequests + * @param string $action + * @param array $response + * @param bool $allowPasswordChange + */ + public function testGetAuthenticationRequests( $action, $response, $allowPasswordChange ) { + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->any() )->method( 'allowPasswordChange' ) + ->will( $this->returnValue( $allowPasswordChange ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) ); + } + + public static function provideGetAuthenticationRequests() { + $arr = [ new PasswordAuthenticationRequest() ]; + return [ + [ AuthManager::ACTION_LOGIN, $arr, true ], + [ AuthManager::ACTION_LOGIN, $arr, false ], + [ AuthManager::ACTION_CREATE, $arr, true ], + [ AuthManager::ACTION_CREATE, $arr, false ], + [ AuthManager::ACTION_LINK, [], true ], + [ AuthManager::ACTION_LINK, [], false ], + [ AuthManager::ACTION_CHANGE, $arr, true ], + [ AuthManager::ACTION_CHANGE, [], false ], + [ AuthManager::ACTION_REMOVE, $arr, true ], + [ AuthManager::ACTION_REMOVE, [], false ], + ]; + } + + public function testAuthentication() { + $req = new PasswordAuthenticationRequest(); + $req->action = AuthManager::ACTION_LOGIN; + $reqs = [ PasswordAuthenticationRequest::class => $req ]; + + $plugin = $this->getMockBuilder( 'AuthPlugin' ) + ->setMethods( [ 'authenticate' ] ) + ->getMock(); + $plugin->expects( $this->never() )->method( 'authenticate' ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( [] ) + ); + + $req->username = 'foo'; + $req->password = null; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->username = null; + $req->password = 'bar'; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->username = 'foo'; + $req->password = 'bar'; + + $plugin = $this->getMockBuilder( 'AuthPlugin' ) + ->setMethods( [ 'userExists', 'authenticate' ] ) + ->getMock(); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'authenticate' ) + ->with( $this->equalTo( 'Foo' ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertEquals( + AuthenticationResponse::newPass( 'Foo', $req ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $plugin = $this->getMockBuilder( 'AuthPlugin' ) + ->setMethods( [ 'userExists', 'authenticate' ] ) + ->getMock(); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->will( $this->returnValue( false ) ); + $plugin->expects( $this->never() )->method( 'authenticate' ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $pluginUser = $this->getMockBuilder( 'AuthPluginUser' ) + ->setMethods( [ 'isLocked' ] ) + ->disableOriginalConstructor() + ->getMock(); + $pluginUser->expects( $this->once() )->method( 'isLocked' ) + ->will( $this->returnValue( true ) ); + $plugin = $this->getMockBuilder( 'AuthPlugin' ) + ->setMethods( [ 'userExists', 'getUserInstance', 'authenticate' ] ) + ->getMock(); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'getUserInstance' ) + ->will( $this->returnValue( $pluginUser ) ); + $plugin->expects( $this->never() )->method( 'authenticate' ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $plugin = $this->getMockBuilder( 'AuthPlugin' ) + ->setMethods( [ 'userExists', 'authenticate' ] ) + ->getMock(); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'authenticate' ) + ->with( $this->equalTo( 'Foo' ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( false ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $plugin = $this->getMockBuilder( 'AuthPlugin' ) + ->setMethods( [ 'userExists', 'authenticate', 'strict' ] ) + ->getMock(); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'authenticate' ) + ->with( $this->equalTo( 'Foo' ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( false ) ); + $plugin->expects( $this->any() )->method( 'strict' )->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'wrongpassword', $ret->message->getKey() ); + + $plugin = $this->getMockBuilder( 'AuthPlugin' ) + ->setMethods( [ 'userExists', 'authenticate', 'strictUserAuth' ] ) + ->getMock(); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'authenticate' ) + ->with( $this->equalTo( 'Foo' ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( false ) ); + $plugin->expects( $this->any() )->method( 'strictUserAuth' ) + ->with( $this->equalTo( 'Foo' ) ) + ->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'wrongpassword', $ret->message->getKey() ); + + $plugin = $this->getMockBuilder( 'AuthPlugin' ) + ->setMethods( [ 'domainList', 'validDomain', 'setDomain', 'userExists', 'authenticate' ] ) + ->getMock(); + $plugin->expects( $this->any() )->method( 'domainList' ) + ->will( $this->returnValue( [ 'Domain1', 'Domain2' ] ) ); + $plugin->expects( $this->any() )->method( 'validDomain' ) + ->will( $this->returnCallback( function ( $domain ) { + return in_array( $domain, [ 'Domain1', 'Domain2' ] ); + } ) ); + $plugin->expects( $this->once() )->method( 'setDomain' ) + ->with( $this->equalTo( 'Domain2' ) ); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'authenticate' ) + ->with( $this->equalTo( 'Foo' ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + list( $req ) = $provider->getAuthenticationRequests( AuthManager::ACTION_LOGIN, [] ); + $req->username = 'foo'; + $req->password = 'bar'; + $req->domain = 'Domain2'; + $provider->beginPrimaryAuthentication( [ $req ] ); + } + + public function testTestUserExists() { + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->with( $this->equalTo( 'Foo' ) ) + ->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + $this->assertTrue( $provider->testUserExists( 'foo' ) ); + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->with( $this->equalTo( 'Foo' ) ) + ->will( $this->returnValue( false ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + $this->assertFalse( $provider->testUserExists( 'foo' ) ); + } + + public function testTestUserCanAuthenticate() { + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->with( $this->equalTo( 'Foo' ) ) + ->will( $this->returnValue( false ) ); + $plugin->expects( $this->never() )->method( 'getUserInstance' ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertFalse( $provider->testUserCanAuthenticate( 'foo' ) ); + + $pluginUser = $this->getMockBuilder( 'AuthPluginUser' ) + ->disableOriginalConstructor() + ->getMock(); + $pluginUser->expects( $this->once() )->method( 'isLocked' ) + ->will( $this->returnValue( true ) ); + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->with( $this->equalTo( 'Foo' ) ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'getUserInstance' ) + ->with( $this->callback( function ( $user ) { + $this->assertInstanceOf( 'User', $user ); + $this->assertEquals( 'Foo', $user->getName() ); + return true; + } ) ) + ->will( $this->returnValue( $pluginUser ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertFalse( $provider->testUserCanAuthenticate( 'foo' ) ); + + $pluginUser = $this->getMockBuilder( 'AuthPluginUser' ) + ->disableOriginalConstructor() + ->getMock(); + $pluginUser->expects( $this->once() )->method( 'isLocked' ) + ->will( $this->returnValue( false ) ); + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->with( $this->equalTo( 'Foo' ) ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'getUserInstance' ) + ->with( $this->callback( function ( $user ) { + $this->assertInstanceOf( 'User', $user ); + $this->assertEquals( 'Foo', $user->getName() ); + return true; + } ) ) + ->will( $this->returnValue( $pluginUser ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertTrue( $provider->testUserCanAuthenticate( 'foo' ) ); + } + + public function testProviderRevokeAccessForUser() { + $plugin = $this->getMockBuilder( 'AuthPlugin' ) + ->setMethods( [ 'userExists', 'setPassword' ] ) + ->getMock(); + $plugin->expects( $this->once() )->method( 'userExists' )->willReturn( true ); + $plugin->expects( $this->once() )->method( 'setPassword' ) + ->with( $this->callback( function ( $u ) { + return $u instanceof \User && $u->getName() === 'Foo'; + } ), $this->identicalTo( null ) ) + ->willReturn( true ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $provider->providerRevokeAccessForUser( 'foo' ); + + $plugin = $this->getMockBuilder( 'AuthPlugin' ) + ->setMethods( [ 'domainList', 'userExists', 'setPassword' ] ) + ->getMock(); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [ 'D1', 'D2', 'D3' ] ); + $plugin->expects( $this->exactly( 3 ) )->method( 'userExists' ) + ->willReturnCallback( function () use ( $plugin ) { + return $plugin->getDomain() !== 'D2'; + } ); + $plugin->expects( $this->exactly( 2 ) )->method( 'setPassword' ) + ->with( $this->callback( function ( $u ) { + return $u instanceof \User && $u->getName() === 'Foo'; + } ), $this->identicalTo( null ) ) + ->willReturnCallback( function () use ( $plugin ) { + $this->assertNotEquals( 'D2', $plugin->getDomain() ); + return $plugin->getDomain() !== 'D1'; + } ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + try { + $provider->providerRevokeAccessForUser( 'foo' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'AuthPlugin failed to reset password for Foo in the following domains: D1', + $ex->getMessage() + ); + } + } + + public function testProviderAllowsPropertyChange() { + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->any() )->method( 'allowPropChange' ) + ->will( $this->returnCallback( function ( $prop ) { + return $prop === 'allow'; + } ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + $this->assertTrue( $provider->providerAllowsPropertyChange( 'allow' ) ); + $this->assertFalse( $provider->providerAllowsPropertyChange( 'deny' ) ); + } + + /** + * @dataProvider provideProviderAllowsAuthenticationDataChange + * @param string $type + * @param bool|null $allow + * @param StatusValue $expect + */ + public function testProviderAllowsAuthenticationDataChange( $type, $allow, $expect ) { + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $allow === null ? $this->never() : $this->once() ) + ->method( 'allowPasswordChange' )->will( $this->returnValue( $allow ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + if ( $type === PasswordAuthenticationRequest::class ) { + $req = new $type(); + } else { + $req = $this->getMock( $type ); + } + $req->action = AuthManager::ACTION_CHANGE; + $req->username = 'UTSysop'; + $req->password = 'Pa$$w0Rd!!!'; + $req->retype = 'Pa$$w0Rd!!!'; + $this->assertEquals( $expect, $provider->providerAllowsAuthenticationDataChange( $req ) ); + } + + public static function provideProviderAllowsAuthenticationDataChange() { + return [ + [ AuthenticationRequest::class, null, \StatusValue::newGood( 'ignored' ) ], + [ PasswordAuthenticationRequest::class, true, \StatusValue::newGood() ], + [ + PasswordAuthenticationRequest::class, + false, + \StatusValue::newFatal( 'authmanager-authplugin-setpass-denied' ) + ], + ]; + } + + public function testProviderChangeAuthenticationData() { + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->never() )->method( 'setPassword' ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $provider->providerChangeAuthenticationData( + $this->getMock( AuthenticationRequest::class ) + ); + + $req = new PasswordAuthenticationRequest(); + $req->action = AuthManager::ACTION_CHANGE; + $req->username = 'foo'; + $req->password = 'bar'; + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'setPassword' ) + ->with( $this->callback( function ( $u ) { + return $u instanceof \User && $u->getName() === 'Foo'; + } ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $provider->providerChangeAuthenticationData( $req ); + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'setPassword' ) + ->with( $this->callback( function ( $u ) { + return $u instanceof \User && $u->getName() === 'Foo'; + } ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( false ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + try { + $provider->providerChangeAuthenticationData( $req ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \ErrorPageError $e ) { + $this->assertSame( 'authmanager-authplugin-setpass-failed-title', $e->title ); + $this->assertSame( 'authmanager-authplugin-setpass-failed-message', $e->msg ); + } + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' ) + ->will( $this->returnValue( [ 'Domain1', 'Domain2' ] ) ); + $plugin->expects( $this->any() )->method( 'validDomain' ) + ->will( $this->returnCallback( function ( $domain ) { + return in_array( $domain, [ 'Domain1', 'Domain2' ] ); + } ) ); + $plugin->expects( $this->once() )->method( 'setDomain' ) + ->with( $this->equalTo( 'Domain2' ) ); + $plugin->expects( $this->once() )->method( 'setPassword' ) + ->with( $this->callback( function ( $u ) { + return $u instanceof \User && $u->getName() === 'Foo'; + } ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + list( $req ) = $provider->getAuthenticationRequests( AuthManager::ACTION_CREATE, [] ); + $req->username = 'foo'; + $req->password = 'bar'; + $req->domain = 'Domain2'; + $provider->providerChangeAuthenticationData( $req ); + } + + /** + * @dataProvider provideAccountCreationType + * @param bool $can + * @param string $expect + */ + public function testAccountCreationType( $can, $expect ) { + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() ) + ->method( 'canCreateAccounts' )->will( $this->returnValue( $can ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + $this->assertSame( $expect, $provider->accountCreationType() ); + } + + public static function provideAccountCreationType() { + return [ + [ true, PrimaryAuthenticationProvider::TYPE_CREATE ], + [ false, PrimaryAuthenticationProvider::TYPE_NONE ], + ]; + } + + public function testTestForAccountCreation() { + $user = \User::newFromName( 'foo' ); + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, [] ) + ); + } + + public function testAccountCreation() { + $user = \User::newFromName( 'foo' ); + $user->setEmail( 'email' ); + $user->setRealName( 'realname' ); + + $req = new PasswordAuthenticationRequest(); + $req->action = AuthManager::ACTION_CREATE; + $reqs = [ PasswordAuthenticationRequest::class => $req ]; + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->any() )->method( 'canCreateAccounts' ) + ->will( $this->returnValue( false ) ); + $plugin->expects( $this->never() )->method( 'addUser' ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + try { + $provider->beginPrimaryAccountCreation( $user, $user, [] ); + $this->fail( 'Expected exception was not thrown' ); + } catch ( \BadMethodCallException $ex ) { + $this->assertSame( + 'Shouldn\'t call this when accountCreationType() is NONE', $ex->getMessage() + ); + } + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->any() )->method( 'canCreateAccounts' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->never() )->method( 'addUser' ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, [] ) + ); + + $req->username = 'foo'; + $req->password = null; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) + ); + + $req->username = null; + $req->password = 'bar'; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) + ); + + $req->username = 'foo'; + $req->password = 'bar'; + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->any() )->method( 'canCreateAccounts' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'addUser' ) + ->with( + $this->callback( function ( $u ) { + return $u instanceof \User && $u->getName() === 'Foo'; + } ), + $this->equalTo( 'bar' ), + $this->equalTo( 'email' ), + $this->equalTo( 'realname' ) + ) + ->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertEquals( + AuthenticationResponse::newPass(), + $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) + ); + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->any() )->method( 'canCreateAccounts' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'addUser' ) + ->with( + $this->callback( function ( $u ) { + return $u instanceof \User && $u->getName() === 'Foo'; + } ), + $this->equalTo( 'bar' ), + $this->equalTo( 'email' ), + $this->equalTo( 'realname' ) + ) + ->will( $this->returnValue( false ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $ret = $provider->beginPrimaryAccountCreation( $user, $user, $reqs ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'authmanager-authplugin-create-fail', $ret->message->getKey() ); + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'canCreateAccounts' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->any() )->method( 'domainList' ) + ->will( $this->returnValue( [ 'Domain1', 'Domain2' ] ) ); + $plugin->expects( $this->any() )->method( 'validDomain' ) + ->will( $this->returnCallback( function ( $domain ) { + return in_array( $domain, [ 'Domain1', 'Domain2' ] ); + } ) ); + $plugin->expects( $this->once() )->method( 'setDomain' ) + ->with( $this->equalTo( 'Domain2' ) ); + $plugin->expects( $this->once() )->method( 'addUser' ) + ->with( $this->callback( function ( $u ) { + return $u instanceof \User && $u->getName() === 'Foo'; + } ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + list( $req ) = $provider->getAuthenticationRequests( AuthManager::ACTION_CREATE, [] ); + $req->username = 'foo'; + $req->password = 'bar'; + $req->domain = 'Domain2'; + $provider->beginPrimaryAccountCreation( $user, $user, [ $req ] ); + } + +} diff --git a/tests/phpunit/includes/auth/AuthenticationRequestTest.php b/tests/phpunit/includes/auth/AuthenticationRequestTest.php new file mode 100644 index 0000000000..84a0ea6dbe --- /dev/null +++ b/tests/phpunit/includes/auth/AuthenticationRequestTest.php @@ -0,0 +1,514 @@ +markTestSkipped( '$wgDisableAuthManager is set' ); + } + } + + public function testBasics() { + $mock = $this->getMockForAbstractClass( AuthenticationRequest::class ); + + $this->assertSame( get_class( $mock ), $mock->getUniqueId() ); + + $this->assertType( 'array', $mock->getMetadata() ); + + $ret = $mock->describeCredentials(); + $this->assertInternalType( 'array', $ret ); + $this->assertArrayHasKey( 'provider', $ret ); + $this->assertInstanceOf( 'Message', $ret['provider'] ); + $this->assertArrayHasKey( 'account', $ret ); + $this->assertInstanceOf( 'Message', $ret['account'] ); + } + + public function testLoadRequestsFromSubmission() { + $mb = $this->getMockBuilder( AuthenticationRequest::class ) + ->setMethods( [ 'loadFromSubmission' ] ); + + $data = [ 'foo', 'bar' ]; + + $req1 = $mb->getMockForAbstractClass(); + $req1->expects( $this->once() )->method( 'loadFromSubmission' ) + ->with( $this->identicalTo( $data ) ) + ->will( $this->returnValue( false ) ); + + $req2 = $mb->getMockForAbstractClass(); + $req2->expects( $this->once() )->method( 'loadFromSubmission' ) + ->with( $this->identicalTo( $data ) ) + ->will( $this->returnValue( true ) ); + + $this->assertSame( + [ $req2 ], + AuthenticationRequest::loadRequestsFromSubmission( [ $req1, $req2 ], $data ) + ); + } + + public function testGetRequestByClass() { + $mb = $this->getMockBuilder( + AuthenticationRequest::class, 'AuthenticationRequestTest_AuthenticationRequest2' + ); + + $reqs = [ + $this->getMockForAbstractClass( + AuthenticationRequest::class, [], 'AuthenticationRequestTest_AuthenticationRequest1' + ), + $mb->getMockForAbstractClass(), + $mb->getMockForAbstractClass(), + $this->getMockForAbstractClass( + PasswordAuthenticationRequest::class, [], + 'AuthenticationRequestTest_PasswordAuthenticationRequest' + ), + ]; + + $this->assertNull( AuthenticationRequest::getRequestByClass( + $reqs, 'AuthenticationRequestTest_AuthenticationRequest0' + ) ); + $this->assertSame( $reqs[0], AuthenticationRequest::getRequestByClass( + $reqs, 'AuthenticationRequestTest_AuthenticationRequest1' + ) ); + $this->assertNull( AuthenticationRequest::getRequestByClass( + $reqs, 'AuthenticationRequestTest_AuthenticationRequest2' + ) ); + $this->assertNull( AuthenticationRequest::getRequestByClass( + $reqs, PasswordAuthenticationRequest::class + ) ); + $this->assertNull( AuthenticationRequest::getRequestByClass( + $reqs, 'ClassThatDoesNotExist' + ) ); + + $this->assertNull( AuthenticationRequest::getRequestByClass( + $reqs, 'AuthenticationRequestTest_AuthenticationRequest0', true + ) ); + $this->assertSame( $reqs[0], AuthenticationRequest::getRequestByClass( + $reqs, 'AuthenticationRequestTest_AuthenticationRequest1', true + ) ); + $this->assertNull( AuthenticationRequest::getRequestByClass( + $reqs, 'AuthenticationRequestTest_AuthenticationRequest2', true + ) ); + $this->assertSame( $reqs[3], AuthenticationRequest::getRequestByClass( + $reqs, PasswordAuthenticationRequest::class, true + ) ); + $this->assertNull( AuthenticationRequest::getRequestByClass( + $reqs, 'ClassThatDoesNotExist', true + ) ); + } + + public function testGetUsernameFromRequests() { + $mb = $this->getMockBuilder( AuthenticationRequest::class ); + + for ( $i = 0; $i < 3; $i++ ) { + $req = $mb->getMockForAbstractClass(); + $req->expects( $this->any() )->method( 'getFieldInfo' )->will( $this->returnValue( [ + 'username' => [ + 'type' => 'string', + ], + ] ) ); + $reqs[] = $req; + } + + $req = $mb->getMockForAbstractClass(); + $req->expects( $this->any() )->method( 'getFieldInfo' )->will( $this->returnValue( [] ) ); + $req->username = 'baz'; + $reqs[] = $req; + + $this->assertNull( AuthenticationRequest::getUsernameFromRequests( $reqs ) ); + + $reqs[1]->username = 'foo'; + $this->assertSame( 'foo', AuthenticationRequest::getUsernameFromRequests( $reqs ) ); + + $reqs[0]->username = 'foo'; + $reqs[2]->username = 'foo'; + $this->assertSame( 'foo', AuthenticationRequest::getUsernameFromRequests( $reqs ) ); + + $reqs[1]->username = 'bar'; + try { + AuthenticationRequest::getUsernameFromRequests( $reqs ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'Conflicting username fields: "bar" from ' . + get_class( $reqs[1] ) . '::$username vs. "foo" from ' . + get_class( $reqs[0] ) . '::$username', + $ex->getMessage() + ); + } + } + + public function testMergeFieldInfo() { + $msg = wfMessage( 'foo' ); + + $req1 = $this->getMock( AuthenticationRequest::class ); + $req1->required = AuthenticationRequest::REQUIRED; + $req1->expects( $this->any() )->method( 'getFieldInfo' )->will( $this->returnValue( [ + 'string1' => [ + 'type' => 'string', + 'label' => $msg, + 'help' => $msg, + ], + 'string2' => [ + 'type' => 'string', + 'label' => $msg, + 'help' => $msg, + ], + 'optional' => [ + 'type' => 'string', + 'label' => $msg, + 'help' => $msg, + 'optional' => true, + ], + 'select' => [ + 'type' => 'select', + 'options' => [ 'foo' => $msg, 'baz' => $msg ], + 'label' => $msg, + 'help' => $msg, + ], + ] ) ); + + $req2 = $this->getMock( AuthenticationRequest::class ); + $req2->required = AuthenticationRequest::REQUIRED; + $req2->expects( $this->any() )->method( 'getFieldInfo' )->will( $this->returnValue( [ + 'string1' => [ + 'type' => 'string', + 'label' => $msg, + 'help' => $msg, + ], + 'string3' => [ + 'type' => 'string', + 'label' => $msg, + 'help' => $msg, + ], + 'select' => [ + 'type' => 'select', + 'options' => [ 'bar' => $msg, 'baz' => $msg ], + 'label' => $msg, + 'help' => $msg, + ], + ] ) ); + + $req3 = $this->getMock( AuthenticationRequest::class ); + $req3->required = AuthenticationRequest::REQUIRED; + $req3->expects( $this->any() )->method( 'getFieldInfo' )->will( $this->returnValue( [ + 'string1' => [ + 'type' => 'checkbox', + 'label' => $msg, + 'help' => $msg, + ], + ] ) ); + + $req4 = $this->getMock( AuthenticationRequest::class ); + $req4->required = AuthenticationRequest::REQUIRED; + $req4->expects( $this->any() )->method( 'getFieldInfo' )->will( $this->returnValue( [] ) ); + + // Basic combining + + $fields = AuthenticationRequest::mergeFieldInfo( [ $req1 ] ); + $expect = $req1->getFieldInfo(); + foreach ( $expect as $name => &$options ) { + $options['optional'] = !empty( $options['optional'] ); + } + unset( $options ); + $this->assertEquals( $expect, $fields ); + + $fields = AuthenticationRequest::mergeFieldInfo( [ $req1, $req4 ] ); + $this->assertEquals( $expect, $fields ); + + try { + AuthenticationRequest::mergeFieldInfo( [ $req1, $req3 ] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'Field type conflict for "string1", "string" vs "checkbox"', + $ex->getMessage() + ); + } + + $fields = AuthenticationRequest::mergeFieldInfo( [ $req1, $req2 ] ); + $expect += $req2->getFieldInfo(); + $expect['string2']['optional'] = false; + $expect['string3']['optional'] = false; + $expect['select']['options']['bar'] = $msg; + $this->assertEquals( $expect, $fields ); + + // Combining with something not required + + $req1->required = AuthenticationRequest::PRIMARY_REQUIRED; + + $fields = AuthenticationRequest::mergeFieldInfo( [ $req1 ] ); + $expect = $req1->getFieldInfo(); + foreach ( $expect as $name => &$options ) { + $options['optional'] = true; + } + unset( $options ); + $this->assertEquals( $expect, $fields ); + + $fields = AuthenticationRequest::mergeFieldInfo( [ $req1, $req2 ] ); + $expect += $req2->getFieldInfo(); + $expect['string1']['optional'] = false; + $expect['string3']['optional'] = false; + $expect['select']['optional'] = false; + $expect['select']['options']['bar'] = $msg; + $this->assertEquals( $expect, $fields ); + } + + /** + * @dataProvider provideLoadFromSubmission + * @param array $fieldInfo + * @param array $data + * @param array|bool $expectState + */ + public function testLoadFromSubmission( $fieldInfo, $data, $expectState ) { + $mock = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $mock->expects( $this->any() )->method( 'getFieldInfo' ) + ->will( $this->returnValue( $fieldInfo ) ); + + $ret = $mock->loadFromSubmission( $data ); + if ( is_array( $expectState ) ) { + $this->assertTrue( $ret ); + $expect = call_user_func( [ get_class( $mock ), '__set_state' ], $expectState ); + $this->assertEquals( $expect, $mock ); + } else { + $this->assertFalse( $ret ); + } + } + + public static function provideLoadFromSubmission() { + return [ + 'No fields' => [ + [], + $data = [ 'foo' => 'bar' ], + false + ], + + 'Simple field' => [ + [ + 'field' => [ + 'type' => 'string', + ], + ], + $data = [ 'field' => 'string!' ], + $data + ], + 'Simple field, not supplied' => [ + [ + 'field' => [ + 'type' => 'string', + ], + ], + [], + false + ], + 'Simple field, empty' => [ + [ + 'field' => [ + 'type' => 'string', + ], + ], + [ 'field' => '' ], + false + ], + 'Simple field, optional, not supplied' => [ + [ + 'field' => [ + 'type' => 'string', + 'optional' => true, + ], + ], + [], + false + ], + 'Simple field, optional, empty' => [ + [ + 'field' => [ + 'type' => 'string', + 'optional' => true, + ], + ], + $data = [ 'field' => '' ], + $data + ], + + 'Checkbox, checked' => [ + [ + 'check' => [ + 'type' => 'checkbox', + ], + ], + [ 'check' => '' ], + [ 'check' => true ] + ], + 'Checkbox, unchecked' => [ + [ + 'check' => [ + 'type' => 'checkbox', + ], + ], + [], + false + ], + 'Checkbox, optional, unchecked' => [ + [ + 'check' => [ + 'type' => 'checkbox', + 'optional' => true, + ], + ], + [], + [ 'check' => false ] + ], + + 'Button, used' => [ + [ + 'push' => [ + 'type' => 'button', + ], + ], + [ 'push' => '' ], + [ 'push' => true ] + ], + 'Button, unused' => [ + [ + 'push' => [ + 'type' => 'button', + ], + ], + [], + false + ], + 'Button, optional, unused' => [ + [ + 'push' => [ + 'type' => 'button', + 'optional' => true, + ], + ], + [], + [ 'push' => false ] + ], + 'Button, image-style' => [ + [ + 'push' => [ + 'type' => 'button', + ], + ], + [ 'push_x' => 0, 'push_y' => 0 ], + [ 'push' => true ] + ], + + 'Select' => [ + [ + 'choose' => [ + 'type' => 'select', + 'options' => [ + 'foo' => wfMessage( 'mainpage' ), + 'bar' => wfMessage( 'mainpage' ), + ], + ], + ], + $data = [ 'choose' => 'foo' ], + $data + ], + 'Select, invalid choice' => [ + [ + 'choose' => [ + 'type' => 'select', + 'options' => [ + 'foo' => wfMessage( 'mainpage' ), + 'bar' => wfMessage( 'mainpage' ), + ], + ], + ], + $data = [ 'choose' => 'baz' ], + false + ], + 'Multiselect (2)' => [ + [ + 'choose' => [ + 'type' => 'multiselect', + 'options' => [ + 'foo' => wfMessage( 'mainpage' ), + 'bar' => wfMessage( 'mainpage' ), + ], + ], + ], + $data = [ 'choose' => [ 'foo', 'bar' ] ], + $data + ], + 'Multiselect (1)' => [ + [ + 'choose' => [ + 'type' => 'multiselect', + 'options' => [ + 'foo' => wfMessage( 'mainpage' ), + 'bar' => wfMessage( 'mainpage' ), + ], + ], + ], + $data = [ 'choose' => [ 'bar' ] ], + $data + ], + 'Multiselect, string for some reason' => [ + [ + 'choose' => [ + 'type' => 'multiselect', + 'options' => [ + 'foo' => wfMessage( 'mainpage' ), + 'bar' => wfMessage( 'mainpage' ), + ], + ], + ], + [ 'choose' => 'foo' ], + [ 'choose' => [ 'foo' ] ] + ], + 'Multiselect, invalid choice' => [ + [ + 'choose' => [ + 'type' => 'multiselect', + 'options' => [ + 'foo' => wfMessage( 'mainpage' ), + 'bar' => wfMessage( 'mainpage' ), + ], + ], + ], + [ 'choose' => [ 'foo', 'baz' ] ], + false + ], + 'Multiselect, empty' => [ + [ + 'choose' => [ + 'type' => 'multiselect', + 'options' => [ + 'foo' => wfMessage( 'mainpage' ), + 'bar' => wfMessage( 'mainpage' ), + ], + ], + ], + [ 'choose' => [] ], + false + ], + 'Multiselect, optional, nothing submitted' => [ + [ + 'choose' => [ + 'type' => 'multiselect', + 'options' => [ + 'foo' => wfMessage( 'mainpage' ), + 'bar' => wfMessage( 'mainpage' ), + ], + 'optional' => true, + ], + ], + [], + [ 'choose' => [] ] + ], + ]; + } +} diff --git a/tests/phpunit/includes/auth/AuthenticationRequestTestCase.php b/tests/phpunit/includes/auth/AuthenticationRequestTestCase.php new file mode 100644 index 0000000000..aafcd09d6f --- /dev/null +++ b/tests/phpunit/includes/auth/AuthenticationRequestTestCase.php @@ -0,0 +1,96 @@ +markTestSkipped( '$wgDisableAuthManager is set' ); + } + } + + abstract protected function getInstance( array $args = [] ); + + /** + * @dataProvider provideGetFieldInfo + */ + public function testGetFieldInfo( array $args ) { + $info = $this->getInstance( $args )->getFieldInfo(); + $this->assertType( 'array', $info ); + + foreach ( $info as $field => $data ) { + $this->assertType( 'array', $data, "Field $field" ); + $this->assertArrayHasKey( 'type', $data, "Field $field" ); + $this->assertArrayHasKey( 'label', $data, "Field $field" ); + $this->assertInstanceOf( 'Message', $data['label'], "Field $field, label" ); + + if ( $data['type'] !== 'null' ) { + $this->assertArrayHasKey( 'help', $data, "Field $field" ); + $this->assertInstanceOf( 'Message', $data['help'], "Field $field, help" ); + } + + if ( isset( $data['optional'] ) ) { + $this->assertType( 'bool', $data['optional'], "Field $field, optional" ); + } + if ( isset( $data['image'] ) ) { + $this->assertType( 'string', $data['image'], "Field $field, image" ); + } + + switch ( $data['type'] ) { + case 'string': + case 'password': + case 'hidden': + break; + case 'select': + case 'multiselect': + $this->assertArrayHasKey( 'options', $data, "Field $field" ); + $this->assertType( 'array', $data['options'], "Field $field, options" ); + foreach ( $data['options'] as $val => $msg ) { + $this->assertInstanceOf( 'Message', $msg, "Field $field, option $val" ); + } + break; + case 'checkbox': + break; + case 'button': + break; + case 'null': + break; + default: + $this->fail( "Field $field, unknown type " . $data['type'] ); + break; + } + } + } + + public static function provideGetFieldInfo() { + return [ + [ [] ] + ]; + } + + /** + * @dataProvider provideLoadFromSubmission + * @param array $args + * @param array $data + * @param array|bool $expectState + */ + public function testLoadFromSubmission( array $args, array $data, $expectState ) { + $instance = $this->getInstance( $args ); + $ret = $instance->loadFromSubmission( $data ); + if ( is_array( $expectState ) ) { + $this->assertTrue( $ret ); + $expect = call_user_func( [ get_class( $instance ), '__set_state' ], $expectState ); + $this->assertEquals( $expect, $instance ); + } else { + $this->assertFalse( $ret ); + } + } + + abstract public function provideLoadFromSubmission(); +} diff --git a/tests/phpunit/includes/auth/AuthenticationResponseTest.php b/tests/phpunit/includes/auth/AuthenticationResponseTest.php new file mode 100644 index 0000000000..58ff8b6e3b --- /dev/null +++ b/tests/phpunit/includes/auth/AuthenticationResponseTest.php @@ -0,0 +1,104 @@ +markTestSkipped( '$wgDisableAuthManager is set' ); + } + } + + /** + * @dataProvider provideConstructors + * @param string $constructor + * @param array $args + * @param array|Exception $expect + */ + public function testConstructors( $constructor, $args, $expect ) { + if ( is_array( $expect ) ) { + $res = new AuthenticationResponse(); + foreach ( $expect as $field => $value ) { + $res->$field = $value; + } + $ret = call_user_func_array( "MediaWiki\\Auth\\AuthenticationResponse::$constructor", $args ); + $this->assertEquals( $res, $ret ); + } else { + try { + call_user_func_array( "MediaWiki\\Auth\\AuthenticationResponse::$constructor", $args ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \Exception $ex ) { + $this->assertEquals( $expect, $ex ); + } + } + } + + public function provideConstructors() { + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $msg = new \Message( 'mainpage' ); + + return [ + [ 'newPass', [], [ + 'status' => AuthenticationResponse::PASS, + ] ], + [ 'newPass', [ 'name' ], [ + 'status' => AuthenticationResponse::PASS, + 'username' => 'name', + ] ], + [ 'newPass', [ 'name', null ], [ + 'status' => AuthenticationResponse::PASS, + 'username' => 'name', + ] ], + + [ 'newFail', [ $msg ], [ + 'status' => AuthenticationResponse::FAIL, + 'message' => $msg, + ] ], + + [ 'newRestart', [ $msg ], [ + 'status' => AuthenticationResponse::RESTART, + 'message' => $msg, + ] ], + + [ 'newAbstain', [], [ + 'status' => AuthenticationResponse::ABSTAIN, + ] ], + + [ 'newUI', [ [ $req ], $msg ], [ + 'status' => AuthenticationResponse::UI, + 'neededRequests' => [ $req ], + 'message' => $msg, + ] ], + [ 'newUI', [ [], $msg ], + new \InvalidArgumentException( '$reqs may not be empty' ) + ], + + [ 'newRedirect', [ [ $req ], 'http://example.org/redir' ], [ + 'status' => AuthenticationResponse::REDIRECT, + 'neededRequests' => [ $req ], + 'redirectTarget' => 'http://example.org/redir', + ] ], + [ + 'newRedirect', + [ [ $req ], 'http://example.org/redir', [ 'foo' => 'bar' ] ], + [ + 'status' => AuthenticationResponse::REDIRECT, + 'neededRequests' => [ $req ], + 'redirectTarget' => 'http://example.org/redir', + 'redirectApiData' => [ 'foo' => 'bar' ], + ] + ], + [ 'newRedirect', [ [], 'http://example.org/redir' ], + new \InvalidArgumentException( '$reqs may not be empty' ) + ], + ]; + } + +} diff --git a/tests/phpunit/includes/auth/ButtonAuthenticationRequestTest.php b/tests/phpunit/includes/auth/ButtonAuthenticationRequestTest.php new file mode 100644 index 0000000000..3bc077cb76 --- /dev/null +++ b/tests/phpunit/includes/auth/ButtonAuthenticationRequestTest.php @@ -0,0 +1,64 @@ + 1, 'label' => 1, 'help' => 1 ] ); + return ButtonAuthenticationRequest::__set_state( $data ); + } + + public static function provideGetFieldInfo() { + return [ + [ [ 'name' => 'foo', 'label' => 'bar', 'help' => 'baz' ] ] + ]; + } + + public function provideLoadFromSubmission() { + return [ + 'Empty request' => [ + [ 'name' => 'foo', 'label' => 'bar', 'help' => 'baz' ], + [], + false + ], + 'Button present' => [ + [ 'name' => 'foo', 'label' => 'bar', 'help' => 'baz' ], + [ 'foo' => 'Foobar' ], + [ 'name' => 'foo', 'label' => 'bar', 'help' => 'baz', 'foo' => true ] + ], + ]; + } + + public function testGetUniqueId() { + $req = new ButtonAuthenticationRequest( 'foo', wfMessage( 'bar' ), wfMessage( 'baz' ) ); + $this->assertSame( + 'MediaWiki\\Auth\\ButtonAuthenticationRequest:foo', $req->getUniqueId() + ); + } + + public function testGetRequestByName() { + $reqs = []; + $reqs['testOne'] = new ButtonAuthenticationRequest( + 'foo', wfMessage( 'msg' ), wfMessage( 'help' ) + ); + $reqs[] = new ButtonAuthenticationRequest( 'bar', wfMessage( 'msg1' ), wfMessage( 'help1' ) ); + $reqs[] = new ButtonAuthenticationRequest( 'bar', wfMessage( 'msg2' ), wfMessage( 'help2' ) ); + $reqs['testSub'] = $this->getMockBuilder( ButtonAuthenticationRequest::class ) + ->setConstructorArgs( [ 'subclass', wfMessage( 'msg3' ), wfMessage( 'help3' ) ] ) + ->getMock(); + + $this->assertNull( ButtonAuthenticationRequest::getRequestByName( $reqs, 'missing' ) ); + $this->assertSame( + $reqs['testOne'], ButtonAuthenticationRequest::getRequestByName( $reqs, 'foo' ) + ); + $this->assertNull( ButtonAuthenticationRequest::getRequestByName( $reqs, 'bar' ) ); + $this->assertSame( + $reqs['testSub'], ButtonAuthenticationRequest::getRequestByName( $reqs, 'subclass' ) + ); + } +} diff --git a/tests/phpunit/includes/auth/CheckBlocksSecondaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/CheckBlocksSecondaryAuthenticationProviderTest.php new file mode 100644 index 0000000000..f2341bcd3f --- /dev/null +++ b/tests/phpunit/includes/auth/CheckBlocksSecondaryAuthenticationProviderTest.php @@ -0,0 +1,195 @@ +markTestSkipped( '$wgDisableAuthManager is set' ); + } + } + + public function testConstructor() { + $provider = new CheckBlocksSecondaryAuthenticationProvider(); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + $config = new \HashConfig( [ + 'BlockDisablesLogin' => false + ] ); + $provider->setConfig( $config ); + $this->assertSame( false, $providerPriv->blockDisablesLogin ); + + $provider = new CheckBlocksSecondaryAuthenticationProvider( + [ 'blockDisablesLogin' => true ] + ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + $config = new \HashConfig( [ + 'BlockDisablesLogin' => false + ] ); + $provider->setConfig( $config ); + $this->assertSame( true, $providerPriv->blockDisablesLogin ); + } + + public function testBasics() { + $provider = new CheckBlocksSecondaryAuthenticationProvider(); + $user = \User::newFromName( 'UTSysop' ); + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginSecondaryAccountCreation( $user, $user, [] ) + ); + } + + /** + * @dataProvider provideGetAuthenticationRequests + * @param string $action + * @param array $response + */ + public function testGetAuthenticationRequests( $action, $response ) { + $provider = new CheckBlocksSecondaryAuthenticationProvider(); + + $this->assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) ); + } + + public static function provideGetAuthenticationRequests() { + return [ + [ AuthManager::ACTION_LOGIN, [] ], + [ AuthManager::ACTION_CREATE, [] ], + [ AuthManager::ACTION_LINK, [] ], + [ AuthManager::ACTION_CHANGE, [] ], + [ AuthManager::ACTION_REMOVE, [] ], + ]; + } + + private function getBlockedUser() { + $user = \User::newFromName( 'UTBlockee' ); + if ( $user->getID() == 0 ) { + $user->addToDatabase(); + \TestUser::setPasswordForUser( $user, 'UTBlockeePassword' ); + $user->saveSettings(); + } + $oldBlock = \Block::newFromTarget( 'UTBlockee' ); + if ( $oldBlock ) { + // An old block will prevent our new one from saving. + $oldBlock->delete(); + } + $blockOptions = [ + 'address' => 'UTBlockee', + 'user' => $user->getID(), + 'reason' => __METHOD__, + 'expiry' => time() + 100500, + 'createAccount' => true, + ]; + $block = new \Block( $blockOptions ); + $block->insert(); + return $user; + } + + public function testBeginSecondaryAuthentication() { + $unblockedUser = \User::newFromName( 'UTSysop' ); + $blockedUser = $this->getBlockedUser(); + + $provider = new CheckBlocksSecondaryAuthenticationProvider( + [ 'blockDisablesLogin' => false ] + ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginSecondaryAuthentication( $unblockedUser, [] ) + ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginSecondaryAuthentication( $blockedUser, [] ) + ); + + $provider = new CheckBlocksSecondaryAuthenticationProvider( + [ 'blockDisablesLogin' => true ] + ); + $this->assertEquals( + AuthenticationResponse::newPass(), + $provider->beginSecondaryAuthentication( $unblockedUser, [] ) + ); + $ret = $provider->beginSecondaryAuthentication( $blockedUser, [] ); + $this->assertEquals( AuthenticationResponse::FAIL, $ret->status ); + } + + public function testTestUserForCreation() { + $provider = new CheckBlocksSecondaryAuthenticationProvider( + [ 'blockDisablesLogin' => false ] + ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( new \HashConfig() ); + $provider->setManager( AuthManager::singleton() ); + + $unblockedUser = \User::newFromName( 'UTSysop' ); + $blockedUser = $this->getBlockedUser(); + + $user = \User::newFromName( 'RandomUser' ); + + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $unblockedUser, AuthManager::AUTOCREATE_SOURCE_SESSION ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $unblockedUser, false ) + ); + + $status = $provider->testUserForCreation( $blockedUser, AuthManager::AUTOCREATE_SOURCE_SESSION ); + $this->assertInstanceOf( 'StatusValue', $status ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( 'cantcreateaccount-text' ) ); + + $status = $provider->testUserForCreation( $blockedUser, false ); + $this->assertInstanceOf( 'StatusValue', $status ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( 'cantcreateaccount-text' ) ); + } + + public function testRangeBlock() { + $blockOptions = [ + 'address' => '127.0.0.0/24', + 'reason' => __METHOD__, + 'expiry' => time() + 100500, + 'createAccount' => true, + ]; + $block = new \Block( $blockOptions ); + $block->insert(); + $scopeVariable = new \ScopedCallback( [ $block, 'delete' ] ); + + $user = \User::newFromName( 'UTNormalUser' ); + if ( $user->getID() == 0 ) { + $user->addToDatabase(); + \TestUser::setPasswordForUser( $user, 'UTNormalUserPassword' ); + $user->saveSettings(); + } + $this->setMwGlobals( [ 'wgUser' => $user ] ); + $newuser = \User::newFromName( 'RandomUser' ); + + $provider = new CheckBlocksSecondaryAuthenticationProvider( + [ 'blockDisablesLogin' => true ] + ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( new \HashConfig() ); + $provider->setManager( AuthManager::singleton() ); + + $ret = $provider->beginSecondaryAuthentication( $user, [] ); + $this->assertEquals( AuthenticationResponse::FAIL, $ret->status ); + + $status = $provider->testUserForCreation( $newuser, AuthManager::AUTOCREATE_SOURCE_SESSION ); + $this->assertInstanceOf( 'StatusValue', $status ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( 'cantcreateaccount-range-text' ) ); + + $status = $provider->testUserForCreation( $newuser, false ); + $this->assertInstanceOf( 'StatusValue', $status ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( 'cantcreateaccount-range-text' ) ); + } +} diff --git a/tests/phpunit/includes/auth/ConfirmLinkAuthenticationRequestTest.php b/tests/phpunit/includes/auth/ConfirmLinkAuthenticationRequestTest.php new file mode 100644 index 0000000000..f208cc4be7 --- /dev/null +++ b/tests/phpunit/includes/auth/ConfirmLinkAuthenticationRequestTest.php @@ -0,0 +1,68 @@ +getLinkRequests() ); + } + + /** + * @expectedException InvalidArgumentException + * @expectedExceptionMessage $linkRequests must not be empty + */ + public function testConstructorException() { + new ConfirmLinkAuthenticationRequest( [] ); + } + + /** + * Get requests for testing + * @return AuthenticationRequest[] + */ + private function getLinkRequests() { + $reqs = []; + + $mb = $this->getMockBuilder( AuthenticationRequest::class ) + ->setMethods( [ 'getUniqueId' ] ); + for ( $i = 1; $i <= 3; $i++ ) { + $req = $mb->getMockForAbstractClass(); + $req->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( "Request$i" ) ); + $reqs[$req->getUniqueId()] = $req; + } + + return $reqs; + } + + public function provideLoadFromSubmission() { + $reqs = $this->getLinkRequests(); + + return [ + 'Empty request' => [ + [], + [], + [ 'linkRequests' => $reqs ], + ], + 'Some confirmed' => [ + [], + [ 'confirmedLinkIDs' => [ 'Request1', 'Request3' ] ], + [ 'confirmedLinkIDs' => [ 'Request1', 'Request3' ], 'linkRequests' => $reqs ], + ], + ]; + } + + public function testGetUniqueId() { + $req = new ConfirmLinkAuthenticationRequest( $this->getLinkRequests() ); + $this->assertSame( + get_class( $req ) . ':Request1|Request2|Request3', + $req->getUniqueId() + ); + } +} diff --git a/tests/phpunit/includes/auth/ConfirmLinkSecondaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/ConfirmLinkSecondaryAuthenticationProviderTest.php new file mode 100644 index 0000000000..09d046c8b9 --- /dev/null +++ b/tests/phpunit/includes/auth/ConfirmLinkSecondaryAuthenticationProviderTest.php @@ -0,0 +1,276 @@ +markTestSkipped( '$wgDisableAuthManager is set' ); + } + } + + /** + * @dataProvider provideGetAuthenticationRequests + * @param string $action + * @param array $response + */ + public function testGetAuthenticationRequests( $action, $response ) { + $provider = new ConfirmLinkSecondaryAuthenticationProvider(); + + $this->assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) ); + } + + public static function provideGetAuthenticationRequests() { + return [ + [ AuthManager::ACTION_LOGIN, [] ], + [ AuthManager::ACTION_CREATE, [] ], + [ AuthManager::ACTION_LINK, [] ], + [ AuthManager::ACTION_CHANGE, [] ], + [ AuthManager::ACTION_REMOVE, [] ], + ]; + } + + public function testBeginSecondaryAuthentication() { + $user = \User::newFromName( 'UTSysop' ); + $obj = new \stdClass; + + $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'beginLinkAttempt', 'continueLinkAttempt' ] ) + ->getMock(); + $mock->expects( $this->once() )->method( 'beginLinkAttempt' ) + ->with( $this->identicalTo( $user ), $this->identicalTo( 'AuthManager::authnState' ) ) + ->will( $this->returnValue( $obj ) ); + $mock->expects( $this->never() )->method( 'continueLinkAttempt' ); + + $this->assertSame( $obj, $mock->beginSecondaryAuthentication( $user, [] ) ); + } + + public function testContinueSecondaryAuthentication() { + $user = \User::newFromName( 'UTSysop' ); + $obj = new \stdClass; + $reqs = [ new \stdClass ]; + + $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'beginLinkAttempt', 'continueLinkAttempt' ] ) + ->getMock(); + $mock->expects( $this->never() )->method( 'beginLinkAttempt' ); + $mock->expects( $this->once() )->method( 'continueLinkAttempt' ) + ->with( + $this->identicalTo( $user ), + $this->identicalTo( 'AuthManager::authnState' ), + $this->identicalTo( $reqs ) + ) + ->will( $this->returnValue( $obj ) ); + + $this->assertSame( $obj, $mock->continueSecondaryAuthentication( $user, $reqs ) ); + } + + public function testBeginSecondaryAccountCreation() { + $user = \User::newFromName( 'UTSysop' ); + $obj = new \stdClass; + + $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'beginLinkAttempt', 'continueLinkAttempt' ] ) + ->getMock(); + $mock->expects( $this->once() )->method( 'beginLinkAttempt' ) + ->with( $this->identicalTo( $user ), $this->identicalTo( 'AuthManager::accountCreationState' ) ) + ->will( $this->returnValue( $obj ) ); + $mock->expects( $this->never() )->method( 'continueLinkAttempt' ); + + $this->assertSame( $obj, $mock->beginSecondaryAccountCreation( $user, $user, [] ) ); + } + + public function testContinueSecondaryAccountCreation() { + $user = \User::newFromName( 'UTSysop' ); + $obj = new \stdClass; + $reqs = [ new \stdClass ]; + + $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'beginLinkAttempt', 'continueLinkAttempt' ] ) + ->getMock(); + $mock->expects( $this->never() )->method( 'beginLinkAttempt' ); + $mock->expects( $this->once() )->method( 'continueLinkAttempt' ) + ->with( + $this->identicalTo( $user ), + $this->identicalTo( 'AuthManager::accountCreationState' ), + $this->identicalTo( $reqs ) + ) + ->will( $this->returnValue( $obj ) ); + + $this->assertSame( $obj, $mock->continueSecondaryAccountCreation( $user, $user, $reqs ) ); + } + + /** + * Get requests for testing + * @return AuthenticationRequest[] + */ + private function getLinkRequests() { + $reqs = []; + + $mb = $this->getMockBuilder( AuthenticationRequest::class ) + ->setMethods( [ 'getUniqueId' ] ); + for ( $i = 1; $i <= 3; $i++ ) { + $req = $mb->getMockForAbstractClass(); + $req->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( "Request$i" ) ); + $req->id = $i - 1; + $reqs[$req->getUniqueId()] = $req; + } + + return $reqs; + } + + public function testBeginLinkAttempt() { + $user = \User::newFromName( 'UTSysop' ); + $provider = \TestingAccessWrapper::newFromObject( + new ConfirmLinkSecondaryAuthenticationProvider + ); + $request = new \FauxRequest(); + $manager = new AuthManager( $request, \RequestContext::getMain()->getConfig() ); + $provider->setManager( $manager ); + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginLinkAttempt( $user, 'state' ) + ); + + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => [], + ] ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginLinkAttempt( $user, 'state' ) + ); + + $reqs = $this->getLinkRequests(); + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => $reqs + ] ); + $res = $provider->beginLinkAttempt( $user, 'state' ); + $this->assertInstanceOf( AuthenticationResponse::class, $res ); + $this->assertSame( AuthenticationResponse::UI, $res->status ); + $this->assertSame( 'authprovider-confirmlink-message', $res->message->getKey() ); + $this->assertCount( 1, $res->neededRequests ); + $req = $res->neededRequests[0]; + $this->assertInstanceOf( ConfirmLinkAuthenticationRequest::class, $req ); + $this->assertEquals( $reqs, \TestingAccessWrapper::newFromObject( $req )->linkRequests ); + } + + public function testContinueLinkAttempt() { + $user = \User::newFromName( 'UTSysop' ); + $obj = new \stdClass; + $reqs = $this->getLinkRequests(); + + $done = [ false, false, false ]; + + // First, test the pass-through for not containing the ConfirmLinkAuthenticationRequest + $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'beginLinkAttempt' ] ) + ->getMock(); + $mock->expects( $this->once() )->method( 'beginLinkAttempt' ) + ->with( $this->identicalTo( $user ), $this->identicalTo( 'state' ) ) + ->will( $this->returnValue( $obj ) ); + $this->assertSame( + $obj, + \TestingAccessWrapper::newFromObject( $mock )->continueLinkAttempt( $user, 'state', $reqs ) + ); + + // Now test the actual functioning + $provider = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class ) + ->setMethods( [ + 'beginLinkAttempt', 'providerAllowsAuthenticationDataChange', + 'providerChangeAuthenticationData' + ] ) + ->getMock(); + $provider->expects( $this->never() )->method( 'beginLinkAttempt' ); + $provider->expects( $this->any() )->method( 'providerAllowsAuthenticationDataChange' ) + ->will( $this->returnCallback( function ( $req ) use ( $reqs ) { + return $req->getUniqueId() === 'Request3' + ? \StatusValue::newFatal( 'foo' ) : \StatusValue::newGood(); + } ) ); + $provider->expects( $this->any() )->method( 'providerChangeAuthenticationData' ) + ->will( $this->returnCallback( function ( $req ) use ( &$done ) { + $done[$req->id] = true; + } ) ); + $config = new \HashConfig( [ + 'AuthManagerConfig' => [ + 'preauth' => [], + 'primaryauth' => [], + 'secondaryauth' => [ + [ 'factory' => function () use ( $provider ) { + return $provider; + } ], + ], + ], + ] ); + $request = new \FauxRequest(); + $manager = new AuthManager( $request, $config ); + $provider->setManager( $manager ); + $provider = \TestingAccessWrapper::newFromObject( $provider ); + + $req = new ConfirmLinkAuthenticationRequest( $reqs ); + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->continueLinkAttempt( $user, 'state', [ $req ] ) + ); + + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => [], + ] ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->continueLinkAttempt( $user, 'state', [ $req ] ) + ); + + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => $reqs + ] ); + $this->assertEquals( + AuthenticationResponse::newPass(), + $res = $provider->continueLinkAttempt( $user, 'state', [ $req ] ) + ); + $this->assertSame( [ false, false, false ], $done ); + + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => [ $reqs['Request2'] ], + ] ); + $req->confirmedLinkIDs = [ 'Request1', 'Request2' ]; + $res = $provider->continueLinkAttempt( $user, 'state', [ $req ] ); + $this->assertEquals( AuthenticationResponse::newPass(), $res ); + $this->assertSame( [ false, true, false ], $done ); + $done = [ false, false, false ]; + + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => $reqs, + ] ); + $req->confirmedLinkIDs = [ 'Request1', 'Request2' ]; + $res = $provider->continueLinkAttempt( $user, 'state', [ $req ] ); + $this->assertEquals( AuthenticationResponse::newPass(), $res ); + $this->assertSame( [ true, true, false ], $done ); + $done = [ false, false, false ]; + + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => $reqs, + ] ); + $req->confirmedLinkIDs = [ 'Request1', 'Request3' ]; + $res = $provider->continueLinkAttempt( $user, 'state', [ $req ] ); + $this->assertEquals( AuthenticationResponse::UI, $res->status ); + $this->assertCount( 1, $res->neededRequests ); + $this->assertInstanceOf( ButtonAuthenticationRequest::class, $res->neededRequests[0] ); + $this->assertSame( [ true, false, false ], $done ); + $done = [ false, false, false ]; + + $res = $provider->continueLinkAttempt( $user, 'state', [ $res->neededRequests[0] ] ); + $this->assertEquals( AuthenticationResponse::newPass(), $res ); + $this->assertSame( [ false, false, false ], $done ); + } + +} diff --git a/tests/phpunit/includes/auth/CreateFromLoginAuthenticationRequestTest.php b/tests/phpunit/includes/auth/CreateFromLoginAuthenticationRequestTest.php new file mode 100644 index 0000000000..fb0613d185 --- /dev/null +++ b/tests/phpunit/includes/auth/CreateFromLoginAuthenticationRequestTest.php @@ -0,0 +1,26 @@ + [ + [], + [], + [], + ], + ]; + } +} diff --git a/tests/phpunit/includes/auth/CreatedAccountAuthenticationRequestTest.php b/tests/phpunit/includes/auth/CreatedAccountAuthenticationRequestTest.php new file mode 100644 index 0000000000..fc1e6f15bb --- /dev/null +++ b/tests/phpunit/includes/auth/CreatedAccountAuthenticationRequestTest.php @@ -0,0 +1,30 @@ +assertSame( 42, $ret->id ); + $this->assertSame( 'Test', $ret->username ); + } + + public function provideLoadFromSubmission() { + return [ + 'Empty request' => [ + [], + [], + false + ], + ]; + } +} diff --git a/tests/phpunit/includes/auth/CreationReasonAuthenticationRequestTest.php b/tests/phpunit/includes/auth/CreationReasonAuthenticationRequestTest.php new file mode 100644 index 0000000000..cce1e8cdfb --- /dev/null +++ b/tests/phpunit/includes/auth/CreationReasonAuthenticationRequestTest.php @@ -0,0 +1,34 @@ + [ + [], + [], + false + ], + 'Reason given' => [ + [], + $data = [ 'reason' => 'Because' ], + $data, + ], + 'Reason empty' => [ + [], + [ 'reason' => '' ], + false + ], + ]; + } +} diff --git a/tests/phpunit/includes/auth/LegacyHookPreAuthenticationProviderTest.php b/tests/phpunit/includes/auth/LegacyHookPreAuthenticationProviderTest.php new file mode 100644 index 0000000000..edee6fc1e9 --- /dev/null +++ b/tests/phpunit/includes/auth/LegacyHookPreAuthenticationProviderTest.php @@ -0,0 +1,420 @@ +markTestSkipped( '$wgDisableAuthManager is set' ); + } + } + + /** + * Get an instance of the provider + * @return LegacyHookPreAuthenticationProvider + */ + protected function getProvider() { + $request = $this->getMock( 'FauxRequest', [ 'getIP' ] ); + $request->expects( $this->any() )->method( 'getIP' )->will( $this->returnValue( '127.0.0.42' ) ); + + $manager = new AuthManager( + $request, \ConfigFactory::getDefaultInstance()->makeConfig( 'main' ) + ); + + $provider = new LegacyHookPreAuthenticationProvider(); + $provider->setManager( $manager ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( new \HashConfig( [ + 'PasswordAttemptThrottle' => [ 'count' => 23, 'seconds' => 42 ], + ] ) ); + return $provider; + } + + /** + * Sets a mock on a hook + * @param string $hook + * @param object $expect From $this->once(), $this->never(), etc. + * @return object $mock->expects( $expect )->method( ... ). + */ + protected function hook( $hook, $expect ) { + $mock = $this->getMock( __CLASS__, [ "on$hook" ] ); + $this->mergeMwGlobalArrayValue( 'wgHooks', [ + $hook => [ $mock ], + ] ); + return $mock->expects( $expect )->method( "on$hook" ); + } + + /** + * Unsets a hook + * @param string $hook + */ + protected function unhook( $hook ) { + $this->mergeMwGlobalArrayValue( 'wgHooks', [ + $hook => [], + ] ); + } + + // Stubs for hooks taking reference parameters + public function onLoginUserMigrated( $user, &$msg ) { + } + public function onAbortLogin( $user, $password, &$abort, &$msg ) { + } + public function onAbortNewAccount( $user, &$abortError, &$abortStatus ) { + } + public function onAbortAutoAccount( $user, &$abortError ) { + } + + /** + * @dataProvider provideTestForAuthentication + * @param string|null $username + * @param string|null $password + * @param string|null $msgForLoginUserMigrated + * @param int|null $abortForAbortLogin + * @param string|null $msgForAbortLogin + * @param string|null $failMsg + * @param array $failParams + */ + public function testTestForAuthentication( + $username, $password, + $msgForLoginUserMigrated, $abortForAbortLogin, $msgForAbortLogin, + $failMsg, $failParams = [] + ) { + $reqs = []; + if ( $username === null ) { + $this->hook( 'LoginUserMigrated', $this->never() ); + $this->hook( 'AbortLogin', $this->never() ); + } else { + if ( $password === null ) { + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + } else { + $req = new PasswordAuthenticationRequest(); + $req->action = AuthManager::ACTION_LOGIN; + $req->password = $password; + } + $req->username = $username; + $reqs[get_class( $req )] = $req; + + $h = $this->hook( 'LoginUserMigrated', $this->once() ); + if ( $msgForLoginUserMigrated !== null ) { + $h->will( $this->returnCallback( + function ( $user, &$msg ) use ( $username, $msgForLoginUserMigrated ) { + $this->assertInstanceOf( 'User', $user ); + $this->assertSame( $username, $user->getName() ); + $msg = $msgForLoginUserMigrated; + return false; + } + ) ); + $this->hook( 'AbortLogin', $this->never() ); + } else { + $h->will( $this->returnCallback( + function ( $user, &$msg ) use ( $username ) { + $this->assertInstanceOf( 'User', $user ); + $this->assertSame( $username, $user->getName() ); + return true; + } + ) ); + $h2 = $this->hook( 'AbortLogin', $this->once() ); + if ( $abortForAbortLogin !== null ) { + $h2->will( $this->returnCallback( + function ( $user, $pass, &$abort, &$msg ) + use ( $username, $password, $abortForAbortLogin, $msgForAbortLogin ) + { + $this->assertInstanceOf( 'User', $user ); + $this->assertSame( $username, $user->getName() ); + if ( $password !== null ) { + $this->assertSame( $password, $pass ); + } else { + $this->assertInternalType( 'string', $pass ); + } + $abort = $abortForAbortLogin; + $msg = $msgForAbortLogin; + return false; + } + ) ); + } else { + $h2->will( $this->returnCallback( + function ( $user, $pass, &$abort, &$msg ) use ( $username, $password ) { + $this->assertInstanceOf( 'User', $user ); + $this->assertSame( $username, $user->getName() ); + if ( $password !== null ) { + $this->assertSame( $password, $pass ); + } else { + $this->assertInternalType( 'string', $pass ); + } + return true; + } + ) ); + } + } + } + unset( $h, $h2 ); + + $status = $this->getProvider()->testForAuthentication( $reqs ); + + $this->unhook( 'LoginUserMigrated' ); + $this->unhook( 'AbortLogin' ); + + if ( $failMsg === null ) { + $this->assertEquals( \StatusValue::newGood(), $status, 'should succeed' ); + } else { + $this->assertInstanceOf( 'StatusValue', $status, 'should fail (type)' ); + $this->assertFalse( $status->isOk(), 'should fail (ok)' ); + $errors = $status->getErrors(); + $this->assertEquals( $failMsg, $errors[0]['message'], 'should fail (message)' ); + $this->assertEquals( $failParams, $errors[0]['params'], 'should fail (params)' ); + } + } + + public static function provideTestForAuthentication() { + return [ + 'No valid requests' => [ + null, null, null, null, null, null + ], + 'No hook errors' => [ + 'User', 'PaSsWoRd', null, null, null, null + ], + 'No hook errors, no password' => [ + 'User', null, null, null, null, null + ], + 'LoginUserMigrated no message' => [ + 'User', 'PaSsWoRd', false, null, null, 'login-migrated-generic' + ], + 'LoginUserMigrated with message' => [ + 'User', 'PaSsWoRd', 'LUM-abort', null, null, 'LUM-abort' + ], + 'LoginUserMigrated with message and params' => [ + 'User', 'PaSsWoRd', [ 'LUM-abort', 'foo' ], null, null, 'LUM-abort', [ 'foo' ] + ], + 'AbortLogin, SUCCESS' => [ + 'User', 'PaSsWoRd', null, \LoginForm::SUCCESS, null, null + ], + 'AbortLogin, NEED_TOKEN, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::NEED_TOKEN, null, 'nocookiesforlogin' + ], + 'AbortLogin, NEED_TOKEN, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::NEED_TOKEN, 'needtoken', 'needtoken' + ], + 'AbortLogin, WRONG_TOKEN, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::WRONG_TOKEN, null, 'sessionfailure' + ], + 'AbortLogin, WRONG_TOKEN, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::WRONG_TOKEN, 'wrongtoken', 'wrongtoken' + ], + 'AbortLogin, ILLEGAL, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::ILLEGAL, null, 'noname' + ], + 'AbortLogin, ILLEGAL, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::ILLEGAL, 'badname', 'badname' + ], + 'AbortLogin, NO_NAME, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::NO_NAME, null, 'noname' + ], + 'AbortLogin, NO_NAME, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::NO_NAME, 'badname', 'badname' + ], + 'AbortLogin, WRONG_PASS, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::WRONG_PASS, null, 'wrongpassword' + ], + 'AbortLogin, WRONG_PASS, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::WRONG_PASS, 'badpass', 'badpass' + ], + 'AbortLogin, WRONG_PLUGIN_PASS, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::WRONG_PLUGIN_PASS, null, 'wrongpassword' + ], + 'AbortLogin, WRONG_PLUGIN_PASS, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::WRONG_PLUGIN_PASS, 'badpass', 'badpass' + ], + 'AbortLogin, NOT_EXISTS, no message' => [ + "User'", 'A', null, \LoginForm::NOT_EXISTS, null, 'nosuchusershort', [ 'User'' ] + ], + 'AbortLogin, NOT_EXISTS, with message' => [ + "User'", 'A', null, \LoginForm::NOT_EXISTS, 'badname', 'badname', [ 'User'' ] + ], + 'AbortLogin, EMPTY_PASS, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::EMPTY_PASS, null, 'wrongpasswordempty' + ], + 'AbortLogin, EMPTY_PASS, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::EMPTY_PASS, 'badpass', 'badpass' + ], + 'AbortLogin, RESET_PASS, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::RESET_PASS, null, 'resetpass_announce' + ], + 'AbortLogin, RESET_PASS, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::RESET_PASS, 'resetpass', 'resetpass' + ], + 'AbortLogin, THROTTLED, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::THROTTLED, null, 'login-throttled', + [ \Message::durationParam( 42 ) ] + ], + 'AbortLogin, THROTTLED, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::THROTTLED, 't', 't', + [ \Message::durationParam( 42 ) ] + ], + 'AbortLogin, USER_BLOCKED, no message' => [ + "User'", 'P', null, \LoginForm::USER_BLOCKED, null, 'login-userblocked', [ 'User'' ] + ], + 'AbortLogin, USER_BLOCKED, with message' => [ + "User'", 'P', null, \LoginForm::USER_BLOCKED, 'blocked', 'blocked', [ 'User'' ] + ], + 'AbortLogin, ABORTED, no message' => [ + "User'", 'P', null, \LoginForm::ABORTED, null, 'login-abort-generic', [ 'User'' ] + ], + 'AbortLogin, ABORTED, with message' => [ + "User'", 'P', null, \LoginForm::ABORTED, 'aborted', 'aborted', [ 'User'' ] + ], + 'AbortLogin, USER_MIGRATED, no message' => [ + 'User', 'P', null, \LoginForm::USER_MIGRATED, null, 'login-migrated-generic' + ], + 'AbortLogin, USER_MIGRATED, with message' => [ + 'User', 'P', null, \LoginForm::USER_MIGRATED, 'migrated', 'migrated' + ], + 'AbortLogin, USER_MIGRATED, with message and params' => [ + 'User', 'P', null, \LoginForm::USER_MIGRATED, [ 'migrated', 'foo' ], + 'migrated', [ 'foo' ] + ], + ]; + } + + /** + * @dataProvider provideTestForAccountCreation + * @param string $msg + * @param Status|null $status + * @param StatusValue Result + */ + public function testTestForAccountCreation( $msg, $status, $result ) { + $this->hook( 'AbortNewAccount', $this->once() ) + ->will( $this->returnCallback( function ( $user, &$error, &$abortStatus ) + use ( $msg, $status ) + { + $this->assertInstanceOf( 'User', $user ); + $this->assertSame( 'User', $user->getName() ); + $error = $msg; + $abortStatus = $status; + return $error === null && $status === null; + } ) ); + + $user = \User::newFromName( 'User' ); + $creator = \User::newFromName( 'UTSysop' ); + $ret = $this->getProvider()->testForAccountCreation( $user, $creator, [] ); + + $this->unhook( 'AbortNewAccount' ); + + $this->assertEquals( $result, $ret ); + } + + public static function provideTestForAccountCreation() { + return [ + 'No hook errors' => [ + null, null, \StatusValue::newGood() + ], + 'AbortNewAccount, old style' => [ + 'foobar', null, \StatusValue::newFatal( + \Message::newFromKey( 'createaccount-hook-aborted' )->rawParams( 'foobar' ) + ) + ], + 'AbortNewAccount, new style' => [ + 'foobar', + \Status::newFatal( 'aborted!', 'param' ), + \StatusValue::newFatal( 'aborted!', 'param' ) + ], + ]; + } + + /** + * @dataProvider provideTestUserForCreation + * @param string|null $error + * @param string|null $failMsg + */ + public function testTestUserForCreation( $error, $failMsg ) { + $this->hook( 'AbortNewAccount', $this->never() ); + $this->hook( 'AbortAutoAccount', $this->once() ) + ->will( $this->returnCallback( function ( $user, &$abortError ) use ( $error ) { + $this->assertInstanceOf( 'User', $user ); + $this->assertSame( 'UTSysop', $user->getName() ); + $abortError = $error; + return $error === null; + } ) ); + + $status = $this->getProvider()->testUserForCreation( + \User::newFromName( 'UTSysop' ), AuthManager::AUTOCREATE_SOURCE_SESSION + ); + + $this->unhook( 'AbortNewAccount' ); + $this->unhook( 'AbortAutoAccount' ); + + if ( $failMsg === null ) { + $this->assertEquals( \StatusValue::newGood(), $status, 'should succeed' ); + } else { + $this->assertInstanceOf( 'StatusValue', $status, 'should fail (type)' ); + $this->assertFalse( $status->isOk(), 'should fail (ok)' ); + $errors = $status->getErrors(); + $this->assertEquals( $failMsg, $errors[0]['message'], 'should fail (message)' ); + } + + $this->hook( 'AbortAutoAccount', $this->never() ); + $this->hook( 'AbortNewAccount', $this->once() ) + ->will( $this->returnCallback( + function ( $user, &$abortError, &$abortStatus ) use ( $error ) { + $this->assertInstanceOf( 'User', $user ); + $this->assertSame( 'UTSysop', $user->getName() ); + $abortError = $error; + return $error === null; + } + ) ); + $status = $this->getProvider()->testUserForCreation( \User::newFromName( 'UTSysop' ), false ); + $this->unhook( 'AbortNewAccount' ); + $this->unhook( 'AbortAutoAccount' ); + if ( $failMsg === null ) { + $this->assertEquals( \StatusValue::newGood(), $status, 'should succeed' ); + } else { + $this->assertInstanceOf( 'StatusValue', $status, 'should fail (type)' ); + $this->assertFalse( $status->isOk(), 'should fail (ok)' ); + $errors = $status->getErrors(); + $msg = $errors[0]['message']; + $this->assertInstanceOf( \Message::class, $msg ); + $this->assertEquals( + 'createaccount-hook-aborted', $msg->getKey(), 'should fail (message)' + ); + } + + if ( $error !== false ) { + $this->hook( 'AbortAutoAccount', $this->never() ); + $this->hook( 'AbortNewAccount', $this->once() ) + ->will( $this->returnCallback( + function ( $user, &$abortError, &$abortStatus ) use ( $error ) { + $this->assertInstanceOf( 'User', $user ); + $this->assertSame( 'UTSysop', $user->getName() ); + $abortStatus = $error ? \Status::newFatal( $error ) : \Status::newGood(); + return $error === null; + } + ) ); + $status = $this->getProvider()->testUserForCreation( \User::newFromName( 'UTSysop' ), false ); + $this->unhook( 'AbortNewAccount' ); + $this->unhook( 'AbortAutoAccount' ); + if ( $failMsg === null ) { + $this->assertEquals( \StatusValue::newGood(), $status, 'should succeed' ); + } else { + $this->assertInstanceOf( 'StatusValue', $status, 'should fail (type)' ); + $this->assertFalse( $status->isOk(), 'should fail (ok)' ); + $errors = $status->getErrors(); + $this->assertEquals( $failMsg, $errors[0]['message'], 'should fail (message)' ); + } + } + } + + public static function provideTestUserForCreation() { + return [ + 'Success' => [ null, null ], + 'Fail, no message' => [ false, 'login-abort-generic' ], + 'Fail, with message' => [ 'fail', 'fail' ], + ]; + } +} diff --git a/tests/phpunit/includes/auth/LocalPasswordPrimaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/LocalPasswordPrimaryAuthenticationProviderTest.php new file mode 100644 index 0000000000..fa68deed43 --- /dev/null +++ b/tests/phpunit/includes/auth/LocalPasswordPrimaryAuthenticationProviderTest.php @@ -0,0 +1,666 @@ +markTestSkipped( '$wgDisableAuthManager is set' ); + } + } + + /** + * Get an instance of the provider + * + * $provider->checkPasswordValidity is mocked to return $this->validity, + * because we don't need to test that here. + * + * @param bool $loginOnly + * @return LocalPasswordPrimaryAuthenticationProvider + */ + protected function getProvider( $loginOnly = false ) { + if ( !$this->config ) { + $this->config = new \HashConfig(); + } + $config = new \MultiConfig( [ + $this->config, + \ConfigFactory::getDefaultInstance()->makeConfig( 'main' ) + ] ); + + if ( !$this->manager ) { + $this->manager = new AuthManager( new \FauxRequest(), $config ); + } + $this->validity = \Status::newGood(); + + $provider = $this->getMock( + LocalPasswordPrimaryAuthenticationProvider::class, + [ 'checkPasswordValidity' ], + [ [ 'loginOnly' => $loginOnly ] ] + ); + $provider->expects( $this->any() )->method( 'checkPasswordValidity' ) + ->will( $this->returnCallback( function () { + return $this->validity; + } ) ); + $provider->setConfig( $config ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setManager( $this->manager ); + + return $provider; + } + + public function testBasics() { + $provider = new LocalPasswordPrimaryAuthenticationProvider(); + + $this->assertSame( + PrimaryAuthenticationProvider::TYPE_CREATE, + $provider->accountCreationType() + ); + + $this->assertTrue( $provider->testUserExists( 'UTSysop' ) ); + $this->assertTrue( $provider->testUserExists( 'uTSysop' ) ); + $this->assertFalse( $provider->testUserExists( 'DoesNotExist' ) ); + $this->assertFalse( $provider->testUserExists( '' ) ); + + $provider = new LocalPasswordPrimaryAuthenticationProvider( [ 'loginOnly' => true ] ); + + $this->assertSame( + PrimaryAuthenticationProvider::TYPE_NONE, + $provider->accountCreationType() + ); + + $this->assertTrue( $provider->testUserExists( 'UTSysop' ) ); + $this->assertFalse( $provider->testUserExists( 'DoesNotExist' ) ); + + $req = new PasswordAuthenticationRequest; + $req->action = AuthManager::ACTION_CHANGE; + $req->username = ''; + $provider->providerChangeAuthenticationData( $req ); + } + + public function testTestUserCanAuthenticate() { + $dbw = wfGetDB( DB_MASTER ); + $oldHash = $dbw->selectField( 'user', 'user_password', [ 'user_name' => 'UTSysop' ] ); + $cb = new \ScopedCallback( function () use ( $dbw, $oldHash ) { + $dbw->update( 'user', [ 'user_password' => $oldHash ], [ 'user_name' => 'UTSysop' ] ); + } ); + $id = \User::idFromName( 'UTSysop' ); + + $provider = $this->getProvider(); + + $this->assertFalse( $provider->testUserCanAuthenticate( '' ) ); + + $this->assertFalse( $provider->testUserCanAuthenticate( 'DoesNotExist' ) ); + + $this->assertTrue( $provider->testUserCanAuthenticate( 'UTSysop' ) ); + $this->assertTrue( $provider->testUserCanAuthenticate( 'uTSysop' ) ); + + $dbw->update( + 'user', + [ 'user_password' => \PasswordFactory::newInvalidPassword()->toString() ], + [ 'user_name' => 'UTSysop' ] + ); + $this->assertFalse( $provider->testUserCanAuthenticate( 'UTSysop' ) ); + + // Really old format + $dbw->update( + 'user', + [ 'user_password' => '0123456789abcdef0123456789abcdef' ], + [ 'user_name' => 'UTSysop' ] + ); + $this->assertTrue( $provider->testUserCanAuthenticate( 'UTSysop' ) ); + } + + public function testSetPasswordResetFlag() { + // Set instance vars + $this->getProvider(); + + /// @todo: Because we're currently using User, which uses the global config... + $this->setMwGlobals( [ 'wgPasswordExpireGrace' => 100 ] ); + + $this->config->set( 'PasswordExpireGrace', 100 ); + $this->config->set( 'InvalidPasswordReset', true ); + + $provider = new LocalPasswordPrimaryAuthenticationProvider(); + $provider->setConfig( $this->config ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setManager( $this->manager ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + + $dbw = wfGetDB( DB_MASTER ); + $row = $dbw->selectRow( + 'user', + '*', + [ 'user_name' => 'UTSysop' ], + __METHOD__ + ); + + $this->manager->removeAuthenticationSessionData( null ); + $row->user_password_expires = wfTimestamp( TS_MW, time() + 200 ); + $providerPriv->setPasswordResetFlag( 'UTSysop', \Status::newGood(), $row ); + $this->assertNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) ); + + $this->manager->removeAuthenticationSessionData( null ); + $row->user_password_expires = wfTimestamp( TS_MW, time() - 200 ); + $providerPriv->setPasswordResetFlag( 'UTSysop', \Status::newGood(), $row ); + $ret = $this->manager->getAuthenticationSessionData( 'reset-pass' ); + $this->assertNotNull( $ret ); + $this->assertSame( 'resetpass-expired', $ret->msg->getKey() ); + $this->assertTrue( $ret->hard ); + + $this->manager->removeAuthenticationSessionData( null ); + $row->user_password_expires = wfTimestamp( TS_MW, time() - 1 ); + $providerPriv->setPasswordResetFlag( 'UTSysop', \Status::newGood(), $row ); + $ret = $this->manager->getAuthenticationSessionData( 'reset-pass' ); + $this->assertNotNull( $ret ); + $this->assertSame( 'resetpass-expired-soft', $ret->msg->getKey() ); + $this->assertFalse( $ret->hard ); + + $this->manager->removeAuthenticationSessionData( null ); + $row->user_password_expires = null; + $status = \Status::newGood(); + $status->error( 'testing' ); + $providerPriv->setPasswordResetFlag( 'UTSysop', $status, $row ); + $ret = $this->manager->getAuthenticationSessionData( 'reset-pass' ); + $this->assertNotNull( $ret ); + $this->assertSame( 'resetpass-validity-soft', $ret->msg->getKey() ); + $this->assertFalse( $ret->hard ); + } + + public function testAuthentication() { + $dbw = wfGetDB( DB_MASTER ); + $oldHash = $dbw->selectField( 'user', 'user_password', [ 'user_name' => 'UTSysop' ] ); + $cb = new \ScopedCallback( function () use ( $dbw, $oldHash ) { + $dbw->update( 'user', [ 'user_password' => $oldHash ], [ 'user_name' => 'UTSysop' ] ); + } ); + $id = \User::idFromName( 'UTSysop' ); + + $req = new PasswordAuthenticationRequest(); + $req->action = AuthManager::ACTION_LOGIN; + $reqs = [ PasswordAuthenticationRequest::class => $req ]; + + $provider = $this->getProvider(); + + // General failures + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( [] ) + ); + + $req->username = 'foo'; + $req->password = null; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->username = null; + $req->password = 'bar'; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->username = ''; + $req->password = 'WhoCares'; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->username = 'DoesNotExist'; + $req->password = 'DoesNotExist'; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + // Validation failure + $req->username = 'UTSysop'; + $req->password = 'UTSysopPassword'; + $this->validity = \Status::newFatal( 'arbitrary-failure' ); + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status + ); + $this->assertEquals( + 'arbitrary-failure', + $ret->message->getKey() + ); + + // Successful auth + $this->manager->removeAuthenticationSessionData( null ); + $this->validity = \Status::newGood(); + $this->assertEquals( + AuthenticationResponse::newPass( 'UTSysop' ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + $this->assertNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) ); + + // Successful auth after normalizing name + $this->manager->removeAuthenticationSessionData( null ); + $this->validity = \Status::newGood(); + $req->username = 'uTSysop'; + $this->assertEquals( + AuthenticationResponse::newPass( 'UTSysop' ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + $this->assertNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) ); + $req->username = 'UTSysop'; + + // Successful auth with reset + $this->manager->removeAuthenticationSessionData( null ); + $this->validity->error( 'arbitrary-warning' ); + $this->assertEquals( + AuthenticationResponse::newPass( 'UTSysop' ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + $this->assertNotNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) ); + + // Wrong password + $this->validity = \Status::newGood(); + $req->password = 'Wrong'; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey() + ); + + // Correct handling of legacy encodings + $password = ':B:salt:' . md5( 'salt-' . md5( "\xe1\xe9\xed\xf3\xfa" ) ); + $dbw->update( 'user', [ 'user_password' => $password ], [ 'user_name' => 'UTSysop' ] ); + $req->password = 'áéíóú'; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey() + ); + + $this->config->set( 'LegacyEncoding', true ); + $this->assertEquals( + AuthenticationResponse::newPass( 'UTSysop' ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->password = 'áéíóú Wrong'; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey() + ); + + // Correct handling of really old password hashes + $this->config->set( 'PasswordSalt', false ); + $password = md5( 'FooBar' ); + $dbw->update( 'user', [ 'user_password' => $password ], [ 'user_name' => 'UTSysop' ] ); + $req->password = 'FooBar'; + $this->assertEquals( + AuthenticationResponse::newPass( 'UTSysop' ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $this->config->set( 'PasswordSalt', true ); + $password = md5( "$id-" . md5( 'FooBar' ) ); + $dbw->update( 'user', [ 'user_password' => $password ], [ 'user_name' => 'UTSysop' ] ); + $req->password = 'FooBar'; + $this->assertEquals( + AuthenticationResponse::newPass( 'UTSysop' ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + } + + /** + * @dataProvider provideProviderAllowsAuthenticationDataChange + * @param string $type + * @param string $user + * @param \Status $validity Result of the password validity check + * @param \StatusValue $expect1 Expected result with $checkData = false + * @param \StatusValue $expect2 Expected result with $checkData = true + */ + public function testProviderAllowsAuthenticationDataChange( $type, $user, \Status $validity, + \StatusValue $expect1, \StatusValue $expect2 + ) { + if ( $type === PasswordAuthenticationRequest::class ) { + $req = new $type(); + } elseif ( $type === PasswordDomainAuthenticationRequest::class ) { + $req = new $type( [] ); + } else { + $req = $this->getMock( $type ); + } + $req->action = AuthManager::ACTION_CHANGE; + $req->username = $user; + $req->password = 'NewPassword'; + $req->retype = 'NewPassword'; + + $provider = $this->getProvider(); + $this->validity = $validity; + $this->assertEquals( $expect1, $provider->providerAllowsAuthenticationDataChange( $req, false ) ); + $this->assertEquals( $expect2, $provider->providerAllowsAuthenticationDataChange( $req, true ) ); + + $req->retype = 'BadRetype'; + $this->assertEquals( + $expect1, + $provider->providerAllowsAuthenticationDataChange( $req, false ) + ); + $this->assertEquals( + $expect2->getValue() === 'ignored' ? $expect2 : \StatusValue::newFatal( 'badretype' ), + $provider->providerAllowsAuthenticationDataChange( $req, true ) + ); + + $provider = $this->getProvider( true ); + $this->assertEquals( + \StatusValue::newGood( 'ignored' ), + $provider->providerAllowsAuthenticationDataChange( $req, true ), + 'loginOnly mode should claim to ignore all changes' + ); + } + + public static function provideProviderAllowsAuthenticationDataChange() { + $err = \StatusValue::newGood(); + $err->error( 'arbitrary-warning' ); + + return [ + [ AuthenticationRequest::class, 'UTSysop', \Status::newGood(), + \StatusValue::newGood( 'ignored' ), \StatusValue::newGood( 'ignored' ) ], + [ PasswordAuthenticationRequest::class, 'UTSysop', \Status::newGood(), + \StatusValue::newGood(), \StatusValue::newGood() ], + [ PasswordAuthenticationRequest::class, 'uTSysop', \Status::newGood(), + \StatusValue::newGood(), \StatusValue::newGood() ], + [ PasswordAuthenticationRequest::class, 'UTSysop', \Status::wrap( $err ), + \StatusValue::newGood(), $err ], + [ PasswordAuthenticationRequest::class, 'UTSysop', \Status::newFatal( 'arbitrary-error' ), + \StatusValue::newGood(), \StatusValue::newFatal( 'arbitrary-error' ) ], + [ PasswordAuthenticationRequest::class, 'DoesNotExist', \Status::newGood(), + \StatusValue::newGood(), \StatusValue::newGood( 'ignored' ) ], + [ PasswordDomainAuthenticationRequest::class, 'UTSysop', \Status::newGood(), + \StatusValue::newGood( 'ignored' ), \StatusValue::newGood( 'ignored' ) ], + ]; + } + + /** + * @dataProvider provideProviderChangeAuthenticationData + * @param string $user + * @param string $type + * @param bool $loginOnly + * @param bool $changed + */ + public function testProviderChangeAuthenticationData( $user, $type, $loginOnly, $changed ) { + $cuser = ucfirst( $user ); + $oldpass = 'UTSysopPassword'; + $newpass = 'NewPassword'; + + $dbw = wfGetDB( DB_MASTER ); + $oldHash = $dbw->selectField( 'user', 'user_password', [ 'user_name' => $cuser ] ); + $oldExpiry = $dbw->selectField( 'user', 'user_password_expires', [ 'user_name' => $cuser ] ); + $cb = new \ScopedCallback( function () use ( $dbw, $cuser, $oldHash, $oldExpiry ) { + $dbw->update( + 'user', + [ + 'user_password' => $oldHash, + 'user_password_expires' => $oldExpiry, + ], + [ 'user_name' => $cuser ] + ); + } ); + + $this->mergeMwGlobalArrayValue( 'wgHooks', [ + 'ResetPasswordExpiration' => [ function ( $user, &$expires ) { + $expires = '30001231235959'; + } ] + ] ); + + $provider = $this->getProvider( $loginOnly ); + + // Sanity check + $loginReq = new PasswordAuthenticationRequest(); + $loginReq->action = AuthManager::ACTION_LOGIN; + $loginReq->username = $user; + $loginReq->password = $oldpass; + $loginReqs = [ PasswordAuthenticationRequest::class => $loginReq ]; + $this->assertEquals( + AuthenticationResponse::newPass( $cuser ), + $provider->beginPrimaryAuthentication( $loginReqs ), + 'Sanity check' + ); + + if ( $type === PasswordAuthenticationRequest::class ) { + $changeReq = new $type(); + } else { + $changeReq = $this->getMock( $type ); + } + $changeReq->action = AuthManager::ACTION_CHANGE; + $changeReq->username = $user; + $changeReq->password = $newpass; + $provider->providerChangeAuthenticationData( $changeReq ); + + if ( $loginOnly ) { + $old = 'fail'; + $new = 'fail'; + $expectExpiry = null; + } elseif ( $changed ) { + $old = 'fail'; + $new = 'pass'; + $expectExpiry = '30001231235959'; + } else { + $old = 'pass'; + $new = 'fail'; + $expectExpiry = $oldExpiry; + } + + $loginReq->password = $oldpass; + $ret = $provider->beginPrimaryAuthentication( $loginReqs ); + if ( $old === 'pass' ) { + $this->assertEquals( + AuthenticationResponse::newPass( $cuser ), + $ret, + 'old password should pass' + ); + } else { + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status, + 'old password should fail' + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey(), + 'old password should fail' + ); + } + + $loginReq->password = $newpass; + $ret = $provider->beginPrimaryAuthentication( $loginReqs ); + if ( $new === 'pass' ) { + $this->assertEquals( + AuthenticationResponse::newPass( $cuser ), + $ret, + 'new password should pass' + ); + } else { + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status, + 'new password should fail' + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey(), + 'new password should fail' + ); + } + + $this->assertSame( + $expectExpiry, + $dbw->selectField( 'user', 'user_password_expires', [ 'user_name' => $cuser ] ) + ); + } + + public static function provideProviderChangeAuthenticationData() { + return [ + [ 'UTSysop', AuthenticationRequest::class, false, false ], + [ 'UTSysop', PasswordAuthenticationRequest::class, false, true ], + [ 'UTSysop', AuthenticationRequest::class, true, false ], + [ 'UTSysop', PasswordAuthenticationRequest::class, true, true ], + [ 'uTSysop', PasswordAuthenticationRequest::class, false, true ], + [ 'uTSysop', PasswordAuthenticationRequest::class, true, true ], + ]; + } + + public function testTestForAccountCreation() { + $user = \User::newFromName( 'foo' ); + $req = new PasswordAuthenticationRequest(); + $req->action = AuthManager::ACTION_CREATE; + $req->username = 'Foo'; + $req->password = 'Bar'; + $req->retype = 'Bar'; + $reqs = [ PasswordAuthenticationRequest::class => $req ]; + + $provider = $this->getProvider(); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, [] ), + 'No password request' + ); + + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, $reqs ), + 'Password request, validated' + ); + + $req->retype = 'Baz'; + $this->assertEquals( + \StatusValue::newFatal( 'badretype' ), + $provider->testForAccountCreation( $user, $user, $reqs ), + 'Password request, bad retype' + ); + $req->retype = 'Bar'; + + $this->validity->error( 'arbitrary warning' ); + $expect = \StatusValue::newGood(); + $expect->error( 'arbitrary warning' ); + $this->assertEquals( + $expect, + $provider->testForAccountCreation( $user, $user, $reqs ), + 'Password request, not validated' + ); + + $provider = $this->getProvider( true ); + $this->validity->error( 'arbitrary warning' ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, $reqs ), + 'Password request, not validated, loginOnly' + ); + } + + public function testAccountCreation() { + $user = \User::newFromName( 'Foo' ); + + $req = new PasswordAuthenticationRequest(); + $req->action = AuthManager::ACTION_CREATE; + $reqs = [ PasswordAuthenticationRequest::class => $req ]; + + $provider = $this->getProvider( true ); + try { + $provider->beginPrimaryAccountCreation( $user, $user, [] ); + $this->fail( 'Expected exception was not thrown' ); + } catch ( \BadMethodCallException $ex ) { + $this->assertSame( + 'Shouldn\'t call this when accountCreationType() is NONE', $ex->getMessage() + ); + } + + try { + $provider->finishAccountCreation( $user, $user, AuthenticationResponse::newPass() ); + $this->fail( 'Expected exception was not thrown' ); + } catch ( \BadMethodCallException $ex ) { + $this->assertSame( + 'Shouldn\'t call this when accountCreationType() is NONE', $ex->getMessage() + ); + } + + $provider = $this->getProvider( false ); + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, [] ) + ); + + $req->username = 'foo'; + $req->password = null; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) + ); + + $req->username = null; + $req->password = 'bar'; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) + ); + + $req->username = 'foo'; + $req->password = 'bar'; + + $expect = AuthenticationResponse::newPass( 'Foo' ); + $expect->createRequest = clone( $req ); + $expect->createRequest->username = 'Foo'; + $this->assertEquals( $expect, $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) ); + + // We have to cheat a bit to avoid having to add a new user to + // the database to test the actual setting of the password works right + $dbw = wfGetDB( DB_MASTER ); + $oldHash = $dbw->selectField( 'user', 'user_password', [ 'user_name' => $user ] ); + $cb = new \ScopedCallback( function () use ( $dbw, $user, $oldHash ) { + $dbw->update( 'user', [ 'user_password' => $oldHash ], [ 'user_name' => $user ] ); + } ); + + $user = \User::newFromName( 'UTSysop' ); + $req->username = $user->getName(); + $req->password = 'NewPassword'; + $expect = AuthenticationResponse::newPass( 'UTSysop' ); + $expect->createRequest = $req; + + $res2 = $provider->beginPrimaryAccountCreation( $user, $user, $reqs ); + $this->assertEquals( $expect, $res2, 'Sanity check' ); + + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( AuthenticationResponse::FAIL, $ret->status, 'sanity check' ); + + $this->assertNull( $provider->finishAccountCreation( $user, $user, $res2 ) ); + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( AuthenticationResponse::PASS, $ret->status, 'new password is set' ); + + } + +} diff --git a/tests/phpunit/includes/auth/PasswordAuthenticationRequestTest.php b/tests/phpunit/includes/auth/PasswordAuthenticationRequestTest.php new file mode 100644 index 0000000000..3387e7c9ac --- /dev/null +++ b/tests/phpunit/includes/auth/PasswordAuthenticationRequestTest.php @@ -0,0 +1,138 @@ +action = $args[0]; + return $ret; + } + + public static function provideGetFieldInfo() { + return [ + [ [ AuthManager::ACTION_LOGIN ] ], + [ [ AuthManager::ACTION_CREATE ] ], + [ [ AuthManager::ACTION_CHANGE ] ], + [ [ AuthManager::ACTION_REMOVE ] ], + ]; + } + + public function testGetFieldInfo2() { + $info = []; + foreach ( [ + AuthManager::ACTION_LOGIN, + AuthManager::ACTION_CREATE, + AuthManager::ACTION_CHANGE, + AuthManager::ACTION_REMOVE, + ] as $action ) { + $req = new PasswordAuthenticationRequest(); + $req->action = $action; + $info[$action] = $req->getFieldInfo(); + } + + $this->assertSame( [], $info[AuthManager::ACTION_REMOVE], 'No data needed to remove' ); + + $this->assertArrayNotHasKey( 'retype', $info[AuthManager::ACTION_LOGIN], + 'No need to retype password on login' ); + $this->assertArrayHasKey( 'retype', $info[AuthManager::ACTION_CREATE], + 'Need to retype when creating new password' ); + $this->assertArrayHasKey( 'retype', $info[AuthManager::ACTION_CHANGE], + 'Need to retype when changing password' ); + + $this->assertNotEquals( + $info[AuthManager::ACTION_LOGIN]['password']['label'], + $info[AuthManager::ACTION_CHANGE]['password']['label'], + 'Password field for change is differentiated from login' + ); + $this->assertNotEquals( + $info[AuthManager::ACTION_CREATE]['password']['label'], + $info[AuthManager::ACTION_CHANGE]['password']['label'], + 'Password field for change is differentiated from create' + ); + $this->assertNotEquals( + $info[AuthManager::ACTION_CREATE]['retype']['label'], + $info[AuthManager::ACTION_CHANGE]['retype']['label'], + 'Retype field for change is differentiated from create' + ); + } + + public function provideLoadFromSubmission() { + return [ + 'Empty request, login' => [ + [ AuthManager::ACTION_LOGIN ], + [], + false, + ], + 'Empty request, change' => [ + [ AuthManager::ACTION_CHANGE ], + [], + false, + ], + 'Empty request, remove' => [ + [ AuthManager::ACTION_REMOVE ], + [], + false, + ], + 'Username + password, login' => [ + [ AuthManager::ACTION_LOGIN ], + $data = [ 'username' => 'User', 'password' => 'Bar' ], + $data + [ 'action' => AuthManager::ACTION_LOGIN ], + ], + 'Username + password, change' => [ + [ AuthManager::ACTION_CHANGE ], + [ 'username' => 'User', 'password' => 'Bar' ], + false, + ], + 'Username + password + retype' => [ + [ AuthManager::ACTION_CHANGE ], + [ 'username' => 'User', 'password' => 'Bar', 'retype' => 'baz' ], + [ 'password' => 'Bar', 'retype' => 'baz', 'action' => AuthManager::ACTION_CHANGE ], + ], + 'Username empty, login' => [ + [ AuthManager::ACTION_LOGIN ], + [ 'username' => '', 'password' => 'Bar' ], + false, + ], + 'Username empty, change' => [ + [ AuthManager::ACTION_CHANGE ], + [ 'username' => '', 'password' => 'Bar', 'retype' => 'baz' ], + [ 'password' => 'Bar', 'retype' => 'baz', 'action' => AuthManager::ACTION_CHANGE ], + ], + 'Password empty, login' => [ + [ AuthManager::ACTION_LOGIN ], + [ 'username' => 'User', 'password' => '' ], + false, + ], + 'Password empty, login, with retype' => [ + [ AuthManager::ACTION_LOGIN ], + [ 'username' => 'User', 'password' => '', 'retype' => 'baz' ], + false, + ], + 'Retype empty' => [ + [ AuthManager::ACTION_CHANGE ], + [ 'username' => 'User', 'password' => 'Bar', 'retype' => '' ], + false, + ], + ]; + } + + public function testDescribeCredentials() { + $req = new PasswordAuthenticationRequest; + $req->action = AuthManager::ACTION_LOGIN; + $req->username = 'UTSysop'; + $ret = $req->describeCredentials(); + $this->assertInternalType( 'array', $ret ); + $this->assertArrayHasKey( 'provider', $ret ); + $this->assertInstanceOf( 'Message', $ret['provider'] ); + $this->assertSame( 'authmanager-provider-password', $ret['provider']->getKey() ); + $this->assertArrayHasKey( 'account', $ret ); + $this->assertInstanceOf( 'Message', $ret['account'] ); + $this->assertSame( [ 'UTSysop' ], $ret['account']->getParams() ); + } +} diff --git a/tests/phpunit/includes/auth/PasswordDomainAuthenticationRequestTest.php b/tests/phpunit/includes/auth/PasswordDomainAuthenticationRequestTest.php new file mode 100644 index 0000000000..f746515b09 --- /dev/null +++ b/tests/phpunit/includes/auth/PasswordDomainAuthenticationRequestTest.php @@ -0,0 +1,159 @@ +action = $args[0]; + return $ret; + } + + public static function provideGetFieldInfo() { + return [ + [ [ AuthManager::ACTION_LOGIN ] ], + [ [ AuthManager::ACTION_CREATE ] ], + [ [ AuthManager::ACTION_CHANGE ] ], + [ [ AuthManager::ACTION_REMOVE ] ], + ]; + } + + public function testGetFieldInfo2() { + $info = []; + foreach ( [ + AuthManager::ACTION_LOGIN, + AuthManager::ACTION_CREATE, + AuthManager::ACTION_CHANGE, + AuthManager::ACTION_REMOVE, + ] as $action ) { + $req = new PasswordDomainAuthenticationRequest( [ 'd1', 'd2' ] ); + $req->action = $action; + $info[$action] = $req->getFieldInfo(); + } + + $this->assertSame( [], $info[AuthManager::ACTION_REMOVE], 'No data needed to remove' ); + + $this->assertArrayNotHasKey( 'retype', $info[AuthManager::ACTION_LOGIN], + 'No need to retype password on login' ); + $this->assertArrayHasKey( 'domain', $info[AuthManager::ACTION_LOGIN], + 'Domain needed on login' ); + $this->assertArrayHasKey( 'retype', $info[AuthManager::ACTION_CREATE], + 'Need to retype when creating new password' ); + $this->assertArrayHasKey( 'domain', $info[AuthManager::ACTION_CREATE], + 'Domain needed on account creation' ); + $this->assertArrayHasKey( 'retype', $info[AuthManager::ACTION_CHANGE], + 'Need to retype when changing password' ); + $this->assertArrayNotHasKey( 'domain', $info[AuthManager::ACTION_CHANGE], + 'Domain not needed on account creation' ); + + $this->assertNotEquals( + $info[AuthManager::ACTION_LOGIN]['password']['label'], + $info[AuthManager::ACTION_CHANGE]['password']['label'], + 'Password field for change is differentiated from login' + ); + $this->assertNotEquals( + $info[AuthManager::ACTION_CREATE]['password']['label'], + $info[AuthManager::ACTION_CHANGE]['password']['label'], + 'Password field for change is differentiated from create' + ); + $this->assertNotEquals( + $info[AuthManager::ACTION_CREATE]['retype']['label'], + $info[AuthManager::ACTION_CHANGE]['retype']['label'], + 'Retype field for change is differentiated from create' + ); + } + + public function provideLoadFromSubmission() { + $domainList = [ 'domainList' => [ 'd1', 'd2' ] ]; + return [ + 'Empty request, login' => [ + [ AuthManager::ACTION_LOGIN ], + [], + false, + ], + 'Empty request, change' => [ + [ AuthManager::ACTION_CHANGE ], + [], + false, + ], + 'Empty request, remove' => [ + [ AuthManager::ACTION_REMOVE ], + [], + false, + ], + 'Username + password, login' => [ + [ AuthManager::ACTION_LOGIN ], + $data = [ 'username' => 'User', 'password' => 'Bar' ], + false, + ], + 'Username + password + domain, login' => [ + [ AuthManager::ACTION_LOGIN ], + $data = [ 'username' => 'User', 'password' => 'Bar', 'domain' => 'd1' ], + $data + [ 'action' => AuthManager::ACTION_LOGIN ] + $domainList, + ], + 'Username + password + bad domain, login' => [ + [ AuthManager::ACTION_LOGIN ], + $data = [ 'username' => 'User', 'password' => 'Bar', 'domain' => 'd5' ], + false, + ], + 'Username + password + domain, change' => [ + [ AuthManager::ACTION_CHANGE ], + [ 'username' => 'User', 'password' => 'Bar', 'domain' => 'd1' ], + false, + ], + 'Username + password + domain + retype' => [ + [ AuthManager::ACTION_CHANGE ], + [ 'username' => 'User', 'password' => 'Bar', 'retype' => 'baz', 'domain' => 'd1' ], + [ 'password' => 'Bar', 'retype' => 'baz', 'action' => AuthManager::ACTION_CHANGE ] + + $domainList, + ], + 'Username empty, login' => [ + [ AuthManager::ACTION_LOGIN ], + [ 'username' => '', 'password' => 'Bar', 'domain' => 'd1' ], + false, + ], + 'Username empty, change' => [ + [ AuthManager::ACTION_CHANGE ], + [ 'username' => '', 'password' => 'Bar', 'retype' => 'baz', 'domain' => 'd1' ], + [ 'password' => 'Bar', 'retype' => 'baz', 'action' => AuthManager::ACTION_CHANGE ] + + $domainList, + ], + 'Password empty, login' => [ + [ AuthManager::ACTION_LOGIN ], + [ 'username' => 'User', 'password' => '', 'domain' => 'd1' ], + false, + ], + 'Password empty, login, with retype' => [ + [ AuthManager::ACTION_LOGIN ], + [ 'username' => 'User', 'password' => '', 'retype' => 'baz', 'domain' => 'd1' ], + false, + ], + 'Retype empty' => [ + [ AuthManager::ACTION_CHANGE ], + [ 'username' => 'User', 'password' => 'Bar', 'retype' => '', 'domain' => 'd1' ], + false, + ], + ]; + } + + public function testDescribeCredentials() { + $req = new PasswordDomainAuthenticationRequest( [ 'd1', 'd2' ] ); + $req->action = AuthManager::ACTION_LOGIN; + $req->username = 'UTSysop'; + $req->domain = 'd2'; + $ret = $req->describeCredentials(); + $this->assertInternalType( 'array', $ret ); + $this->assertArrayHasKey( 'provider', $ret ); + $this->assertInstanceOf( 'Message', $ret['provider'] ); + $this->assertSame( 'authmanager-provider-password-domain', $ret['provider']->getKey() ); + $this->assertArrayHasKey( 'account', $ret ); + $this->assertInstanceOf( 'Message', $ret['account'] ); + $this->assertSame( 'authmanager-account-password-domain', $ret['account']->getKey() ); + $this->assertSame( [ 'UTSysop', 'd2' ], $ret['account']->getParams() ); + } +} diff --git a/tests/phpunit/includes/auth/RememberMeAuthenticationRequestTest.php b/tests/phpunit/includes/auth/RememberMeAuthenticationRequestTest.php new file mode 100644 index 0000000000..3f90169cac --- /dev/null +++ b/tests/phpunit/includes/auth/RememberMeAuthenticationRequestTest.php @@ -0,0 +1,55 @@ +expiration = 30 * 24 * 3600; + $this->assertNotEmpty( $req->getFieldInfo() ); + + $reqWrapper->expiration = null; + $this->assertEmpty( $req->getFieldInfo() ); + } + + protected function getInstance( array $args = [] ) { + $req = new RememberMeAuthenticationRequest(); + $reqWrapper = \TestingAccessWrapper::newFromObject( $req ); + $reqWrapper->expiration = $args[0]; + return $req; + } + + public function provideLoadFromSubmission() { + return [ + 'Empty request' => [ + [ 30 * 24 * 3600 ], + [], + [ 'expiration' => 30 * 24 * 3600, 'rememberMe' => false ] + ], + 'RememberMe present' => [ + [ 30 * 24 * 3600 ], + [ 'rememberMe' => '' ], + [ 'expiration' => 30 * 24 * 3600, 'rememberMe' => true ] + ], + 'RememberMe present but session provider cannot remember' => [ + [ null ], + [ 'rememberMe' => '' ], + false + ], + ]; + } +} diff --git a/tests/phpunit/includes/auth/ResetPasswordSecondaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/ResetPasswordSecondaryAuthenticationProviderTest.php new file mode 100644 index 0000000000..59ededed72 --- /dev/null +++ b/tests/phpunit/includes/auth/ResetPasswordSecondaryAuthenticationProviderTest.php @@ -0,0 +1,313 @@ +markTestSkipped( '$wgDisableAuthManager is set' ); + } + } + + /** + * @dataProvider provideGetAuthenticationRequests + * @param string $action + * @param array $response + */ + public function testGetAuthenticationRequests( $action, $response ) { + $provider = new ResetPasswordSecondaryAuthenticationProvider(); + + $this->assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) ); + } + + public static function provideGetAuthenticationRequests() { + return [ + [ AuthManager::ACTION_LOGIN, [] ], + [ AuthManager::ACTION_CREATE, [] ], + [ AuthManager::ACTION_LINK, [] ], + [ AuthManager::ACTION_CHANGE, [] ], + [ AuthManager::ACTION_REMOVE, [] ], + ]; + } + + public function testBasics() { + $user = \User::newFromName( 'UTSysop' ); + $user2 = new \User; + $obj = new \stdClass; + $reqs = [ new \stdClass ]; + + $mb = $this->getMockBuilder( ResetPasswordSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'tryReset' ] ); + + $methods = [ + 'beginSecondaryAuthentication' => [ $user, $reqs ], + 'continueSecondaryAuthentication' => [ $user, $reqs ], + 'beginSecondaryAccountCreation' => [ $user, $user2, $reqs ], + 'continueSecondaryAccountCreation' => [ $user, $user2, $reqs ], + ]; + foreach ( $methods as $method => $args ) { + $mock = $mb->getMock(); + $mock->expects( $this->once() )->method( 'tryReset' ) + ->with( $this->identicalTo( $user ), $this->identicalTo( $reqs ) ) + ->will( $this->returnValue( $obj ) ); + $this->assertSame( $obj, call_user_func_array( [ $mock, $method ], $args ) ); + } + } + + public function testTryReset() { + $user = \User::newFromName( 'UTSysop' ); + + $provider = $this->getMockBuilder( + ResetPasswordSecondaryAuthenticationProvider::class + ) + ->setMethods( [ + 'providerAllowsAuthenticationDataChange', 'providerChangeAuthenticationData' + ] ) + ->getMock(); + $provider->expects( $this->any() )->method( 'providerAllowsAuthenticationDataChange' ) + ->will( $this->returnCallback( function ( $req ) { + $this->assertSame( 'UTSysop', $req->username ); + return $req->allow; + } ) ); + $provider->expects( $this->any() )->method( 'providerChangeAuthenticationData' ) + ->will( $this->returnCallback( function ( $req ) { + $this->assertSame( 'UTSysop', $req->username ); + $req->done = true; + } ) ); + $config = new \HashConfig( [ + 'AuthManagerConfig' => [ + 'preauth' => [], + 'primaryauth' => [], + 'secondaryauth' => [ + [ 'factory' => function () use ( $provider ) { + return $provider; + } ], + ], + ], + ] ); + $manager = new AuthManager( new \FauxRequest, $config ); + $provider->setManager( $manager ); + $provider = \TestingAccessWrapper::newFromObject( $provider ); + + $msg = wfMessage( 'foo' ); + $skipReq = new ButtonAuthenticationRequest( + 'skipReset', + wfMessage( 'authprovider-resetpass-skip-label' ), + wfMessage( 'authprovider-resetpass-skip-help' ) + ); + $passReq = new PasswordAuthenticationRequest(); + $passReq->action = AuthManager::ACTION_CHANGE; + $passReq->password = 'Foo'; + $passReq->retype = 'Bar'; + $passReq->allow = \StatusValue::newGood(); + $passReq->done = false; + + $passReq2 = $this->getMockBuilder( PasswordAuthenticationRequest::class ) + ->enableProxyingToOriginalMethods() + ->getMock(); + $passReq2->action = AuthManager::ACTION_CHANGE; + $passReq2->password = 'Foo'; + $passReq2->retype = 'Foo'; + $passReq2->allow = \StatusValue::newGood(); + $passReq2->done = false; + + $passReq3 = new PasswordAuthenticationRequest(); + $passReq3->action = AuthManager::ACTION_LOGIN; + $passReq3->password = 'Foo'; + $passReq3->retype = 'Foo'; + $passReq3->allow = \StatusValue::newGood(); + $passReq3->done = false; + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->tryReset( $user, [] ) + ); + + $manager->setAuthenticationSessionData( 'reset-pass', 'foo' ); + try { + $provider->tryReset( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( 'reset-pass is not valid', $ex->getMessage() ); + } + + $manager->setAuthenticationSessionData( 'reset-pass', (object)[] ); + try { + $provider->tryReset( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( 'reset-pass msg is missing', $ex->getMessage() ); + } + + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => 'foo', + ] ); + try { + $provider->tryReset( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( 'reset-pass msg is not valid', $ex->getMessage() ); + } + + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + ] ); + try { + $provider->tryReset( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( 'reset-pass hard is missing', $ex->getMessage() ); + } + + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => true, + 'req' => 'foo', + ] ); + try { + $provider->tryReset( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( 'reset-pass req is not valid', $ex->getMessage() ); + } + + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => false, + 'req' => $passReq3, + ] ); + try { + $provider->tryReset( $user, [ $passReq ] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( 'reset-pass req is not valid', $ex->getMessage() ); + } + + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => true, + ] ); + $res = $provider->tryReset( $user, [] ); + $this->assertInstanceOf( AuthenticationResponse::class, $res ); + $this->assertSame( AuthenticationResponse::UI, $res->status ); + $this->assertEquals( $msg, $res->message ); + $this->assertCount( 1, $res->neededRequests ); + $this->assertInstanceOf( + PasswordAuthenticationRequest::class, + $res->neededRequests[0] + ); + $this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertFalse( $passReq->done ); + + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => false, + 'req' => $passReq, + ] ); + $res = $provider->tryReset( $user, [] ); + $this->assertInstanceOf( AuthenticationResponse::class, $res ); + $this->assertSame( AuthenticationResponse::UI, $res->status ); + $this->assertEquals( $msg, $res->message ); + $this->assertCount( 2, $res->neededRequests ); + $this->assertEquals( $passReq, $res->neededRequests[0] ); + $this->assertEquals( $skipReq, $res->neededRequests[1] ); + $this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertFalse( $passReq->done ); + + $passReq->retype = 'Bad'; + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => false, + 'req' => $passReq, + ] ); + $res = $provider->tryReset( $user, [ $skipReq, $passReq ] ); + $this->assertEquals( AuthenticationResponse::newPass(), $res ); + $this->assertNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertFalse( $passReq->done ); + + $passReq->retype = 'Bad'; + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => true, + ] ); + $res = $provider->tryReset( $user, [ $skipReq, $passReq ] ); + $this->assertSame( AuthenticationResponse::UI, $res->status ); + $this->assertSame( 'badretype', $res->message->getKey() ); + $this->assertCount( 1, $res->neededRequests ); + $this->assertInstanceOf( + PasswordAuthenticationRequest::class, + $res->neededRequests[0] + ); + $this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertFalse( $passReq->done ); + + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => true, + ] ); + $res = $provider->tryReset( $user, [ $skipReq, $passReq3 ] ); + $this->assertSame( AuthenticationResponse::UI, $res->status ); + $this->assertEquals( $msg, $res->message ); + $this->assertCount( 1, $res->neededRequests ); + $this->assertInstanceOf( + PasswordAuthenticationRequest::class, + $res->neededRequests[0] + ); + $this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertFalse( $passReq->done ); + + $passReq->retype = $passReq->password; + $passReq->allow = \StatusValue::newFatal( 'arbitrary-fail' ); + $res = $provider->tryReset( $user, [ $skipReq, $passReq ] ); + $this->assertSame( AuthenticationResponse::UI, $res->status ); + $this->assertSame( 'arbitrary-fail', $res->message->getKey() ); + $this->assertCount( 1, $res->neededRequests ); + $this->assertInstanceOf( + PasswordAuthenticationRequest::class, + $res->neededRequests[0] + ); + $this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertFalse( $passReq->done ); + + $passReq->allow = \StatusValue::newGood(); + $res = $provider->tryReset( $user, [ $skipReq, $passReq ] ); + $this->assertEquals( AuthenticationResponse::newPass(), $res ); + $this->assertNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertTrue( $passReq->done ); + + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => false, + 'req' => $passReq2, + ] ); + $res = $provider->tryReset( $user, [ $passReq2 ] ); + $this->assertEquals( AuthenticationResponse::newPass(), $res ); + $this->assertNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertTrue( $passReq2->done ); + + $passReq->done = false; + $passReq2->done = false; + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => false, + 'req' => $passReq2, + ] ); + $res = $provider->tryReset( $user, [ $passReq ] ); + $this->assertInstanceOf( AuthenticationResponse::class, $res ); + $this->assertSame( AuthenticationResponse::UI, $res->status ); + $this->assertEquals( $msg, $res->message ); + $this->assertCount( 2, $res->neededRequests ); + $this->assertEquals( $passReq2, $res->neededRequests[0] ); + $this->assertEquals( $skipReq, $res->neededRequests[1] ); + $this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertFalse( $passReq->done ); + $this->assertFalse( $passReq2->done ); + } +} diff --git a/tests/phpunit/includes/auth/TemporaryPasswordAuthenticationRequestTest.php b/tests/phpunit/includes/auth/TemporaryPasswordAuthenticationRequestTest.php new file mode 100644 index 0000000000..05c5165b44 --- /dev/null +++ b/tests/phpunit/includes/auth/TemporaryPasswordAuthenticationRequestTest.php @@ -0,0 +1,79 @@ +action = $args[0]; + return $ret; + } + + public static function provideGetFieldInfo() { + return [ + [ [ AuthManager::ACTION_CREATE ] ], + [ [ AuthManager::ACTION_CHANGE ] ], + [ [ AuthManager::ACTION_REMOVE ] ], + ]; + } + + public function testNewRandom() { + global $wgPasswordPolicy; + + $this->stashMwGlobals( 'wgPasswordPolicy' ); + $wgPasswordPolicy['policies']['default'] += [ + 'MinimalPasswordLength' => 1, + 'MinimalPasswordLengthToLogin' => 1, + ]; + + $ret1 = TemporaryPasswordAuthenticationRequest::newRandom(); + $ret2 = TemporaryPasswordAuthenticationRequest::newRandom(); + $this->assertNotSame( '', $ret1->password ); + $this->assertNotSame( '', $ret2->password ); + $this->assertNotSame( $ret1->password, $ret2->password ); + } + + public function testNewInvalid() { + $ret = TemporaryPasswordAuthenticationRequest::newInvalid(); + $this->assertNull( $ret->password ); + } + + public function provideLoadFromSubmission() { + return [ + 'Empty request' => [ + [ AuthManager::ACTION_REMOVE ], + [], + false, + ], + 'Create, empty request' => [ + [ AuthManager::ACTION_CREATE ], + [], + false, + ], + 'Create, mailpassword set' => [ + [ AuthManager::ACTION_CREATE ], + [ 'mailpassword' => 1 ], + [ 'mailpassword' => true, 'action' => AuthManager::ACTION_CREATE ], + ], + ]; + } + + public function testDescribeCredentials() { + $req = new TemporaryPasswordAuthenticationRequest; + $req->action = AuthManager::ACTION_LOGIN; + $req->username = 'UTSysop'; + $ret = $req->describeCredentials(); + $this->assertInternalType( 'array', $ret ); + $this->assertArrayHasKey( 'provider', $ret ); + $this->assertInstanceOf( 'Message', $ret['provider'] ); + $this->assertSame( 'authmanager-provider-temporarypassword', $ret['provider']->getKey() ); + $this->assertArrayHasKey( 'account', $ret ); + $this->assertInstanceOf( 'Message', $ret['account'] ); + $this->assertSame( [ 'UTSysop' ], $ret['account']->getParams() ); + } +} diff --git a/tests/phpunit/includes/auth/TemporaryPasswordPrimaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/TemporaryPasswordPrimaryAuthenticationProviderTest.php new file mode 100644 index 0000000000..8d0bf96a77 --- /dev/null +++ b/tests/phpunit/includes/auth/TemporaryPasswordPrimaryAuthenticationProviderTest.php @@ -0,0 +1,749 @@ +markTestSkipped( '$wgDisableAuthManager is set' ); + } + } + + /** + * Get an instance of the provider + * + * $provider->checkPasswordValidity is mocked to return $this->validity, + * because we don't need to test that here. + * + * @param array $params + * @return TemporaryPasswordPrimaryAuthenticationProvider + */ + protected function getProvider( $params = [] ) { + if ( !$this->config ) { + $this->config = new \HashConfig( [ + 'EmailEnabled' => true, + ] ); + } + $config = new \MultiConfig( [ + $this->config, + \ConfigFactory::getDefaultInstance()->makeConfig( 'main' ) + ] ); + + if ( !$this->manager ) { + $this->manager = new AuthManager( new \FauxRequest(), $config ); + } + $this->validity = \Status::newGood(); + + $mockedMethods[] = 'checkPasswordValidity'; + $provider = $this->getMock( + TemporaryPasswordPrimaryAuthenticationProvider::class, + $mockedMethods, + [ $params ] + ); + $provider->expects( $this->any() )->method( 'checkPasswordValidity' ) + ->will( $this->returnCallback( function () { + return $this->validity; + } ) ); + $provider->setConfig( $config ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setManager( $this->manager ); + + return $provider; + } + + protected function hookMailer( $func = null ) { + \Hooks::clear( 'AlternateUserMailer' ); + if ( $func ) { + \Hooks::register( 'AlternateUserMailer', $func ); + // Safety + \Hooks::register( 'AlternateUserMailer', function () { + return false; + } ); + } else { + \Hooks::register( 'AlternateUserMailer', function () { + $this->fail( 'AlternateUserMailer hook called unexpectedly' ); + return false; + } ); + } + + return new \ScopedCallback( function () { + \Hooks::clear( 'AlternateUserMailer' ); + \Hooks::register( 'AlternateUserMailer', function () { + return false; + } ); + } ); + } + + public function testBasics() { + $provider = new TemporaryPasswordPrimaryAuthenticationProvider(); + + $this->assertSame( + PrimaryAuthenticationProvider::TYPE_CREATE, + $provider->accountCreationType() + ); + + $this->assertTrue( $provider->testUserExists( 'UTSysop' ) ); + $this->assertTrue( $provider->testUserExists( 'uTSysop' ) ); + $this->assertFalse( $provider->testUserExists( 'DoesNotExist' ) ); + $this->assertFalse( $provider->testUserExists( '' ) ); + + $req = new PasswordAuthenticationRequest; + $req->action = AuthManager::ACTION_CHANGE; + $req->username = ''; + $provider->providerChangeAuthenticationData( $req ); + } + + public function testConfig() { + $config = new \HashConfig( [ + 'EnableEmail' => false, + 'NewPasswordExpiry' => 100, + 'PasswordReminderResendTime' => 101, + ] ); + + $p = \TestingAccessWrapper::newFromObject( new TemporaryPasswordPrimaryAuthenticationProvider() ); + $p->setConfig( $config ); + $this->assertSame( false, $p->emailEnabled ); + $this->assertSame( 100, $p->newPasswordExpiry ); + $this->assertSame( 101, $p->passwordReminderResendTime ); + + $p = \TestingAccessWrapper::newFromObject( new TemporaryPasswordPrimaryAuthenticationProvider( [ + 'emailEnabled' => true, + 'newPasswordExpiry' => 42, + 'passwordReminderResendTime' => 43, + ] ) ); + $p->setConfig( $config ); + $this->assertSame( true, $p->emailEnabled ); + $this->assertSame( 42, $p->newPasswordExpiry ); + $this->assertSame( 43, $p->passwordReminderResendTime ); + } + + public function testTestUserCanAuthenticate() { + $dbw = wfGetDB( DB_MASTER ); + + $passwordFactory = new \PasswordFactory(); + $passwordFactory->init( \RequestContext::getMain()->getConfig() ); + // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only + $passwordFactory->setDefaultType( 'A' ); + $pwhash = $passwordFactory->newFromPlaintext( 'password' )->toString(); + + $provider = $this->getProvider(); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + + $this->assertFalse( $provider->testUserCanAuthenticate( '' ) ); + $this->assertFalse( $provider->testUserCanAuthenticate( 'DoesNotExist' ) ); + + $dbw->update( + 'user', + [ + 'user_newpassword' => \PasswordFactory::newInvalidPassword()->toString(), + 'user_newpass_time' => null, + ], + [ 'user_name' => 'UTSysop' ] + ); + $this->assertFalse( $provider->testUserCanAuthenticate( 'UTSysop' ) ); + + $dbw->update( + 'user', + [ + 'user_newpassword' => $pwhash, + 'user_newpass_time' => null, + ], + [ 'user_name' => 'UTSysop' ] + ); + $this->assertTrue( $provider->testUserCanAuthenticate( 'UTSysop' ) ); + $this->assertTrue( $provider->testUserCanAuthenticate( 'uTSysop' ) ); + + $dbw->update( + 'user', + [ + 'user_newpassword' => $pwhash, + 'user_newpass_time' => $dbw->timestamp( time() - 10 ), + ], + [ 'user_name' => 'UTSysop' ] + ); + $providerPriv->newPasswordExpiry = 100; + $this->assertTrue( $provider->testUserCanAuthenticate( 'UTSysop' ) ); + $providerPriv->newPasswordExpiry = 1; + $this->assertFalse( $provider->testUserCanAuthenticate( 'UTSysop' ) ); + + $dbw->update( + 'user', + [ + 'user_newpassword' => \PasswordFactory::newInvalidPassword()->toString(), + 'user_newpass_time' => null, + ], + [ 'user_name' => 'UTSysop' ] + ); + } + + /** + * @dataProvider provideGetAuthenticationRequests + * @param string $action + * @param array $options + * @param array $expected + */ + public function testGetAuthenticationRequests( $action, $options, $expected ) { + $actual = $this->getProvider()->getAuthenticationRequests( $action, $options ); + foreach ( $actual as $req ) { + if ( $req instanceof TemporaryPasswordAuthenticationRequest && $req->password !== null ) { + $req->password = 'random'; + } + } + $this->assertEquals( $expected, $actual ); + } + + public static function provideGetAuthenticationRequests() { + $anon = [ 'username' => null ]; + $loggedIn = [ 'username' => 'UTSysop' ]; + + return [ + [ AuthManager::ACTION_LOGIN, $anon, [ + new PasswordAuthenticationRequest + ] ], + [ AuthManager::ACTION_LOGIN, $loggedIn, [ + new PasswordAuthenticationRequest + ] ], + [ AuthManager::ACTION_CREATE, $anon, [] ], + [ AuthManager::ACTION_CREATE, $loggedIn, [ + new TemporaryPasswordAuthenticationRequest( 'random' ) + ] ], + [ AuthManager::ACTION_LINK, $anon, [] ], + [ AuthManager::ACTION_LINK, $loggedIn, [] ], + [ AuthManager::ACTION_CHANGE, $anon, [ + new TemporaryPasswordAuthenticationRequest( 'random' ) + ] ], + [ AuthManager::ACTION_CHANGE, $loggedIn, [ + new TemporaryPasswordAuthenticationRequest( 'random' ) + ] ], + [ AuthManager::ACTION_REMOVE, $anon, [ + new TemporaryPasswordAuthenticationRequest + ] ], + [ AuthManager::ACTION_REMOVE, $loggedIn, [ + new TemporaryPasswordAuthenticationRequest + ] ], + ]; + } + + public function testAuthentication() { + $password = 'TemporaryPassword'; + $hash = ':A:' . md5( $password ); + $dbw = wfGetDB( DB_MASTER ); + $dbw->update( + 'user', + [ 'user_newpassword' => $hash, 'user_newpass_time' => $dbw->timestamp( time() - 10 ) ], + [ 'user_name' => 'UTSysop' ] + ); + + $req = new PasswordAuthenticationRequest(); + $req->action = AuthManager::ACTION_LOGIN; + $reqs = [ PasswordAuthenticationRequest::class => $req ]; + + $provider = $this->getProvider(); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + + $providerPriv->newPasswordExpiry = 100; + + // General failures + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( [] ) + ); + + $req->username = 'foo'; + $req->password = null; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->username = null; + $req->password = 'bar'; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->username = ''; + $req->password = 'WhoCares'; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->username = 'DoesNotExist'; + $req->password = 'DoesNotExist'; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + // Validation failure + $req->username = 'UTSysop'; + $req->password = $password; + $this->validity = \Status::newFatal( 'arbitrary-failure' ); + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status + ); + $this->assertEquals( + 'arbitrary-failure', + $ret->message->getKey() + ); + + // Successful auth + $this->manager->removeAuthenticationSessionData( null ); + $this->validity = \Status::newGood(); + $this->assertEquals( + AuthenticationResponse::newPass( 'UTSysop' ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + $this->assertNotNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) ); + + $this->manager->removeAuthenticationSessionData( null ); + $this->validity = \Status::newGood(); + $req->username = 'uTSysop'; + $this->assertEquals( + AuthenticationResponse::newPass( 'UTSysop' ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + $this->assertNotNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) ); + $req->username = 'UTSysop'; + + // Expired password + $providerPriv->newPasswordExpiry = 1; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey() + ); + + // Bad password + $providerPriv->newPasswordExpiry = 100; + $this->validity = \Status::newGood(); + $req->password = 'Wrong'; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey() + ); + + } + + /** + * @dataProvider provideProviderAllowsAuthenticationDataChange + * @param string $type + * @param string $user + * @param \Status $validity Result of the password validity check + * @param \StatusValue $expect1 Expected result with $checkData = false + * @param \StatusValue $expect2 Expected result with $checkData = true + */ + public function testProviderAllowsAuthenticationDataChange( $type, $user, \Status $validity, + \StatusValue $expect1, \StatusValue $expect2 + ) { + if ( $type === PasswordAuthenticationRequest::class || + $type === TemporaryPasswordAuthenticationRequest::class + ) { + $req = new $type(); + } else { + $req = $this->getMock( $type ); + } + $req->action = AuthManager::ACTION_CHANGE; + $req->username = $user; + $req->password = 'NewPassword'; + + $provider = $this->getProvider(); + $this->validity = $validity; + $this->assertEquals( $expect1, $provider->providerAllowsAuthenticationDataChange( $req, false ) ); + $this->assertEquals( $expect2, $provider->providerAllowsAuthenticationDataChange( $req, true ) ); + } + + public static function provideProviderAllowsAuthenticationDataChange() { + $err = \StatusValue::newGood(); + $err->error( 'arbitrary-warning' ); + + return [ + [ AuthenticationRequest::class, 'UTSysop', \Status::newGood(), + \StatusValue::newGood( 'ignored' ), \StatusValue::newGood( 'ignored' ) ], + [ PasswordAuthenticationRequest::class, 'UTSysop', \Status::newGood(), + \StatusValue::newGood( 'ignored' ), \StatusValue::newGood( 'ignored' ) ], + [ TemporaryPasswordAuthenticationRequest::class, 'UTSysop', \Status::newGood(), + \StatusValue::newGood(), \StatusValue::newGood() ], + [ TemporaryPasswordAuthenticationRequest::class, 'uTSysop', \Status::newGood(), + \StatusValue::newGood(), \StatusValue::newGood() ], + [ TemporaryPasswordAuthenticationRequest::class, 'UTSysop', \Status::wrap( $err ), + \StatusValue::newGood(), $err ], + [ TemporaryPasswordAuthenticationRequest::class, 'UTSysop', + \Status::newFatal( 'arbitrary-error' ), \StatusValue::newGood(), + \StatusValue::newFatal( 'arbitrary-error' ) ], + [ TemporaryPasswordAuthenticationRequest::class, 'DoesNotExist', \Status::newGood(), + \StatusValue::newGood(), \StatusValue::newGood( 'ignored' ) ], + [ TemporaryPasswordAuthenticationRequest::class, '', \Status::newGood(), + \StatusValue::newGood(), \StatusValue::newGood( 'ignored' ) ], + ]; + } + + /** + * @dataProvider provideProviderChangeAuthenticationData + * @param string $user + * @param string $type + * @param bool $changed + */ + public function testProviderChangeAuthenticationData( $user, $type, $changed ) { + $cuser = ucfirst( $user ); + $oldpass = 'OldTempPassword'; + $newpass = 'NewTempPassword'; + + $hash = ':A:' . md5( $oldpass ); + $dbw = wfGetDB( DB_MASTER ); + $dbw->update( + 'user', + [ 'user_newpassword' => $hash, 'user_newpass_time' => $dbw->timestamp( time() + 10 ) ], + [ 'user_name' => 'UTSysop' ] + ); + + $dbw = wfGetDB( DB_MASTER ); + $oldHash = $dbw->selectField( 'user', 'user_newpassword', [ 'user_name' => $cuser ] ); + $cb = new \ScopedCallback( function () use ( $dbw, $cuser, $oldHash ) { + $dbw->update( 'user', [ 'user_newpassword' => $oldHash ], [ 'user_name' => $cuser ] ); + } ); + + $provider = $this->getProvider(); + + // Sanity check + $loginReq = new PasswordAuthenticationRequest(); + $loginReq->action = AuthManager::ACTION_CHANGE; + $loginReq->username = $user; + $loginReq->password = $oldpass; + $loginReqs = [ PasswordAuthenticationRequest::class => $loginReq ]; + $this->assertEquals( + AuthenticationResponse::newPass( $cuser ), + $provider->beginPrimaryAuthentication( $loginReqs ), + 'Sanity check' + ); + + if ( $type === PasswordAuthenticationRequest::class || + $type === TemporaryPasswordAuthenticationRequest::class + ) { + $changeReq = new $type(); + } else { + $changeReq = $this->getMock( $type ); + } + $changeReq->action = AuthManager::ACTION_CHANGE; + $changeReq->username = $user; + $changeReq->password = $newpass; + $resetMailer = $this->hookMailer(); + $provider->providerChangeAuthenticationData( $changeReq ); + \ScopedCallback::consume( $resetMailer ); + + $loginReq->password = $oldpass; + $ret = $provider->beginPrimaryAuthentication( $loginReqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status, + 'old password should fail' + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey(), + 'old password should fail' + ); + + $loginReq->password = $newpass; + $ret = $provider->beginPrimaryAuthentication( $loginReqs ); + if ( $changed ) { + $this->assertEquals( + AuthenticationResponse::newPass( $cuser ), + $ret, + 'new password should pass' + ); + $this->assertNotNull( + $dbw->selectField( 'user', 'user_newpass_time', [ 'user_name' => $cuser ] ) + ); + } else { + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status, + 'new password should fail' + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey(), + 'new password should fail' + ); + $this->assertNull( + $dbw->selectField( 'user', 'user_newpass_time', [ 'user_name' => $cuser ] ) + ); + } + } + + public static function provideProviderChangeAuthenticationData() { + return [ + [ 'UTSysop', AuthenticationRequest::class, false ], + [ 'UTSysop', PasswordAuthenticationRequest::class, false ], + [ 'UTSysop', TemporaryPasswordAuthenticationRequest::class, true ], + ]; + } + + public function testProviderChangeAuthenticationDataEmail() { + $dbw = wfGetDB( DB_MASTER ); + $dbw->update( + 'user', + [ 'user_newpass_time' => $dbw->timestamp( time() - 5 * 3600 ) ], + [ 'user_name' => 'UTSysop' ] + ); + + $user = \User::newFromName( 'UTSysop' ); + $reset = new \ScopedCallback( function ( $email ) use ( $user ) { + $user->setEmail( $email ); + $user->saveSettings(); + }, [ $user->getEmail() ] ); + + $user->setEmail( 'test@localhost.localdomain' ); + $user->saveSettings(); + + $req = TemporaryPasswordAuthenticationRequest::newRandom(); + $req->username = $user->getName(); + $req->mailpassword = true; + + $provider = $this->getProvider( [ 'emailEnabled' => false ] ); + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertEquals( \StatusValue::newFatal( 'passwordreset-emaildisabled' ), $status ); + $req->hasBackchannel = true; + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertFalse( $status->hasMessage( 'passwordreset-emaildisabled' ) ); + $req->hasBackchannel = false; + + $provider = $this->getProvider( [ 'passwordReminderResendTime' => 10 ] ); + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertEquals( \StatusValue::newFatal( 'throttled-mailpassword', 10 ), $status ); + + $provider = $this->getProvider( [ 'passwordReminderResendTime' => 3 ] ); + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertFalse( $status->hasMessage( 'throttled-mailpassword' ) ); + + $dbw->update( + 'user', + [ 'user_newpass_time' => $dbw->timestamp( time() + 5 * 3600 ) ], + [ 'user_name' => 'UTSysop' ] + ); + $provider = $this->getProvider( [ 'passwordReminderResendTime' => 0 ] ); + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertFalse( $status->hasMessage( 'throttled-mailpassword' ) ); + + $req->caller = null; + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertEquals( \StatusValue::newFatal( 'passwordreset-nocaller' ), $status ); + + $req->caller = '127.0.0.256'; + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertEquals( \StatusValue::newFatal( 'passwordreset-nosuchcaller', '127.0.0.256' ), + $status ); + + $req->caller = ''; + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertEquals( \StatusValue::newFatal( 'passwordreset-nosuchcaller', '' ), + $status ); + + $req->caller = '127.0.0.1'; + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertEquals( \StatusValue::newGood(), $status ); + + $req->caller = 'UTSysop'; + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertEquals( \StatusValue::newGood(), $status ); + + $mailed = false; + $resetMailer = $this->hookMailer( function ( $headers, $to, $from, $subject, $body ) + use ( &$mailed, $req ) + { + $mailed = true; + $this->assertSame( 'test@localhost.localdomain', $to[0]->address ); + $this->assertContains( $req->password, $body ); + return false; + } ); + $provider->providerChangeAuthenticationData( $req ); + \ScopedCallback::consume( $resetMailer ); + $this->assertTrue( $mailed ); + + $priv = \TestingAccessWrapper::newFromObject( $provider ); + $req->username = ''; + $status = $priv->sendPasswordResetEmail( $req ); + $this->assertEquals( \Status::newFatal( 'noname' ), $status ); + } + + public function testTestForAccountCreation() { + $user = \User::newFromName( 'foo' ); + $req = new TemporaryPasswordAuthenticationRequest(); + $req->username = 'Foo'; + $req->password = 'Bar'; + $reqs = [ TemporaryPasswordAuthenticationRequest::class => $req ]; + + $provider = $this->getProvider(); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, [] ), + 'No password request' + ); + + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, $reqs ), + 'Password request, validated' + ); + + $this->validity->error( 'arbitrary warning' ); + $expect = \StatusValue::newGood(); + $expect->error( 'arbitrary warning' ); + $this->assertEquals( + $expect, + $provider->testForAccountCreation( $user, $user, $reqs ), + 'Password request, not validated' + ); + } + + public function testAccountCreation() { + $resetMailer = $this->hookMailer(); + + $user = \User::newFromName( 'Foo' ); + + $req = new TemporaryPasswordAuthenticationRequest(); + $reqs = [ TemporaryPasswordAuthenticationRequest::class => $req ]; + + $authreq = new PasswordAuthenticationRequest(); + $authreq->action = AuthManager::ACTION_CREATE; + $authreqs = [ PasswordAuthenticationRequest::class => $authreq ]; + + $provider = $this->getProvider(); + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, [] ) + ); + + $req->username = 'foo'; + $req->password = null; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) + ); + + $req->username = null; + $req->password = 'bar'; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) + ); + + $req->username = 'foo'; + $req->password = 'bar'; + + $expect = AuthenticationResponse::newPass( 'Foo' ); + $expect->createRequest = clone( $req ); + $expect->createRequest->username = 'Foo'; + $this->assertEquals( $expect, $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) ); + $this->assertNull( $this->manager->getAuthenticationSessionData( 'no-email' ) ); + + // We have to cheat a bit to avoid having to add a new user to + // the database to test the actual setting of the password works right + $user = \User::newFromName( 'UTSysop' ); + $req->username = $authreq->username = $user->getName(); + $req->password = $authreq->password = 'NewPassword'; + $expect = AuthenticationResponse::newPass( 'UTSysop' ); + $expect->createRequest = $req; + + $res2 = $provider->beginPrimaryAccountCreation( $user, $user, $reqs ); + $this->assertEquals( $expect, $res2, 'Sanity check' ); + + $ret = $provider->beginPrimaryAuthentication( $authreqs ); + $this->assertEquals( AuthenticationResponse::FAIL, $ret->status, 'sanity check' ); + + $this->assertSame( null, $provider->finishAccountCreation( $user, $user, $res2 ) ); + + $ret = $provider->beginPrimaryAuthentication( $authreqs ); + $this->assertEquals( AuthenticationResponse::PASS, $ret->status, 'new password is set' ); + } + + public function testAccountCreationEmail() { + $creator = \User::newFromName( 'Foo' ); + $user = \User::newFromName( 'UTSysop' ); + $reset = new \ScopedCallback( function ( $email ) use ( $user ) { + $user->setEmail( $email ); + $user->saveSettings(); + }, [ $user->getEmail() ] ); + + $user->setEmail( null ); + + $req = TemporaryPasswordAuthenticationRequest::newRandom(); + $req->username = $user->getName(); + $req->mailpassword = true; + + $provider = $this->getProvider( [ 'emailEnabled' => false ] ); + $status = $provider->testForAccountCreation( $user, $creator, [ $req ] ); + $this->assertEquals( \StatusValue::newFatal( 'emaildisabled' ), $status ); + $req->hasBackchannel = true; + $status = $provider->testForAccountCreation( $user, $creator, [ $req ] ); + $this->assertFalse( $status->hasMessage( 'emaildisabled' ) ); + $req->hasBackchannel = false; + + $provider = $this->getProvider( [ 'emailEnabled' => true ] ); + $status = $provider->testForAccountCreation( $user, $creator, [ $req ] ); + $this->assertEquals( \StatusValue::newFatal( 'noemailcreate' ), $status ); + $req->hasBackchannel = true; + $status = $provider->testForAccountCreation( $user, $creator, [ $req ] ); + $this->assertFalse( $status->hasMessage( 'noemailcreate' ) ); + $req->hasBackchannel = false; + + $user->setEmail( 'test@localhost.localdomain' ); + $status = $provider->testForAccountCreation( $user, $creator, [ $req ] ); + $this->assertEquals( \StatusValue::newGood(), $status ); + + $mailed = false; + $resetMailer = $this->hookMailer( function ( $headers, $to, $from, $subject, $body ) + use ( &$mailed, $req ) + { + $mailed = true; + $this->assertSame( 'test@localhost.localdomain', $to[0]->address ); + $this->assertContains( $req->password, $body ); + return false; + } ); + + $expect = AuthenticationResponse::newPass( 'UTSysop' ); + $expect->createRequest = clone( $req ); + $expect->createRequest->username = 'UTSysop'; + $res = $provider->beginPrimaryAccountCreation( $user, $creator, [ $req ] ); + $this->assertEquals( $expect, $res ); + $this->assertTrue( $this->manager->getAuthenticationSessionData( 'no-email' ) ); + $this->assertFalse( $mailed ); + + $this->assertSame( 'byemail', $provider->finishAccountCreation( $user, $creator, $res ) ); + $this->assertTrue( $mailed ); + + \ScopedCallback::consume( $resetMailer ); + $this->assertTrue( $mailed ); + } + +} diff --git a/tests/phpunit/includes/auth/ThrottlePreAuthenticationProviderTest.php b/tests/phpunit/includes/auth/ThrottlePreAuthenticationProviderTest.php new file mode 100644 index 0000000000..8b273b5791 --- /dev/null +++ b/tests/phpunit/includes/auth/ThrottlePreAuthenticationProviderTest.php @@ -0,0 +1,235 @@ +markTestSkipped( '$wgDisableAuthManager is set' ); + } + } + + public function testConstructor() { + $provider = new ThrottlePreAuthenticationProvider(); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + $config = new \HashConfig( [ + 'AccountCreationThrottle' => 123, + 'PasswordAttemptThrottle' => [ [ + 'count' => 5, + 'seconds' => 300, + ] ], + ] ); + $provider->setConfig( $config ); + $this->assertSame( [ + 'accountCreationThrottle' => [ [ 'count' => 123, 'seconds' => 86400 ] ], + 'passwordAttemptThrottle' => [ [ 'count' => 5, 'seconds' => 300 ] ] + ], $providerPriv->throttleSettings ); + $accountCreationThrottle = \TestingAccessWrapper::newFromObject( + $providerPriv->accountCreationThrottle ); + $this->assertSame( [ [ 'count' => 123, 'seconds' => 86400 ] ], + $accountCreationThrottle->conditions ); + $passwordAttemptThrottle = \TestingAccessWrapper::newFromObject( + $providerPriv->passwordAttemptThrottle ); + $this->assertSame( [ [ 'count' => 5, 'seconds' => 300 ] ], + $passwordAttemptThrottle->conditions ); + + $provider = new ThrottlePreAuthenticationProvider( [ + 'accountCreationThrottle' => [ [ 'count' => 43, 'seconds' => 10000 ] ], + 'passwordAttemptThrottle' => [ [ 'count' => 11, 'seconds' => 100 ] ], + ] ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + $config = new \HashConfig( [ + 'AccountCreationThrottle' => 123, + 'PasswordAttemptThrottle' => [ [ + 'count' => 5, + 'seconds' => 300, + ] ], + ] ); + $provider->setConfig( $config ); + $this->assertSame( [ + 'accountCreationThrottle' => [ [ 'count' => 43, 'seconds' => 10000 ] ], + 'passwordAttemptThrottle' => [ [ 'count' => 11, 'seconds' => 100 ] ], + ], $providerPriv->throttleSettings ); + + $cache = new \HashBagOStuff(); + $provider = new ThrottlePreAuthenticationProvider( [ 'cache' => $cache ] ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + $provider->setConfig( new \HashConfig( [ + 'AccountCreationThrottle' => [ [ 'count' => 1, 'seconds' => 1 ] ], + 'PasswordAttemptThrottle' => [ [ 'count' => 1, 'seconds' => 1 ] ], + ] ) ); + $accountCreationThrottle = \TestingAccessWrapper::newFromObject( + $providerPriv->accountCreationThrottle ); + $this->assertSame( $cache, $accountCreationThrottle->cache ); + $passwordAttemptThrottle = \TestingAccessWrapper::newFromObject( + $providerPriv->passwordAttemptThrottle ); + $this->assertSame( $cache, $passwordAttemptThrottle->cache ); + } + + public function testDisabled() { + $provider = new ThrottlePreAuthenticationProvider( [ + 'accountCreationThrottle' => [], + 'passwordAttemptThrottle' => [], + 'cache' => new \HashBagOStuff(), + ] ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( new \HashConfig( [ + 'AccountCreationThrottle' => null, + 'PasswordAttemptThrottle' => null, + ] ) ); + $provider->setManager( AuthManager::singleton() ); + + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( + \User::newFromName( 'Created' ), + \User::newFromName( 'Creator' ), + [] + ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAuthentication( [] ) + ); + } + + /** + * @dataProvider provideTestForAccountCreation + * @param string $creatorname + * @param bool $succeed + * @param bool $hook + */ + public function testTestForAccountCreation( $creatorname, $succeed, $hook ) { + $provider = new ThrottlePreAuthenticationProvider( [ + 'accountCreationThrottle' => [ [ 'count' => 2, 'seconds' => 86400 ] ], + 'cache' => new \HashBagOStuff(), + ] ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( new \HashConfig( [ + 'AccountCreationThrottle' => null, + 'PasswordAttemptThrottle' => null, + ] ) ); + $provider->setManager( AuthManager::singleton() ); + + $user = \User::newFromName( 'RandomUser' ); + $creator = \User::newFromName( $creatorname ); + if ( $hook ) { + $mock = $this->getMock( 'stdClass', [ 'onExemptFromAccountCreationThrottle' ] ); + $mock->expects( $this->any() )->method( 'onExemptFromAccountCreationThrottle' ) + ->will( $this->returnValue( false ) ); + $this->mergeMwGlobalArrayValue( 'wgHooks', [ + 'ExemptFromAccountCreationThrottle' => [ $mock ], + ] ); + } + + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $creator, [] ), + 'attempt #1' + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $creator, [] ), + 'attempt #2' + ); + $this->assertEquals( + $succeed ? \StatusValue::newGood() : \StatusValue::newFatal( 'acct_creation_throttle_hit', 2 ), + $provider->testForAccountCreation( $user, $creator, [] ), + 'attempt #3' + ); + } + + public static function provideTestForAccountCreation() { + return [ + 'Normal user' => [ 'NormalUser', false, false ], + 'Sysop' => [ 'UTSysop', true, false ], + 'Normal user with hook' => [ 'NormalUser', true, true ], + ]; + } + + public function testTestForAuthentication() { + $provider = new ThrottlePreAuthenticationProvider( [ + 'passwordAttemptThrottle' => [ [ 'count' => 2, 'seconds' => 86400 ] ], + 'cache' => new \HashBagOStuff(), + ] ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( new \HashConfig( [ + 'AccountCreationThrottle' => null, + 'PasswordAttemptThrottle' => null, + ] ) ); + $provider->setManager( AuthManager::singleton() ); + + $req = new UsernameAuthenticationRequest; + $req->username = 'SomeUser'; + for ( $i = 1; $i <= 3; $i++ ) { + $status = $provider->testForAuthentication( [ $req ] ); + $this->assertEquals( $i < 3, $status->isGood(), "attempt #$i" ); + } + $this->assertCount( 1, $status->getErrors() ); + $msg = new \Message( $status->getErrors()[0]['message'], $status->getErrors()[0]['params'] ); + $this->assertEquals( 'login-throttled', $msg->getKey() ); + + $provider->postAuthentication( \User::newFromName( 'SomeUser' ), + AuthenticationResponse::newFail( wfMessage( 'foo' ) ) ); + $this->assertFalse( $provider->testForAuthentication( [ $req ] )->isGood(), 'after FAIL' ); + + $provider->postAuthentication( \User::newFromName( 'SomeUser' ), + AuthenticationResponse::newPass() ); + $this->assertTrue( $provider->testForAuthentication( [ $req ] )->isGood(), 'after PASS' ); + + $req1 = new UsernameAuthenticationRequest; + $req1->username = 'foo'; + $req2 = new UsernameAuthenticationRequest; + $req2->username = 'bar'; + $this->assertTrue( $provider->testForAuthentication( [ $req1, $req2 ] )->isGood() ); + + $req = new UsernameAuthenticationRequest; + $req->username = 'Some user'; + $provider->testForAuthentication( [ $req ] ); + $req->username = 'Some_user'; + $provider->testForAuthentication( [ $req ] ); + $req->username = 'some user'; + $status = $provider->testForAuthentication( [ $req ] ); + $this->assertFalse( $status->isGood(), 'denormalized usernames are normalized' ); + } + + public function testPostAuthentication() { + $provider = new ThrottlePreAuthenticationProvider( [ + 'passwordAttemptThrottle' => [], + 'cache' => new \HashBagOStuff(), + ] ); + $provider->setLogger( new \TestLogger ); + $provider->setConfig( new \HashConfig( [ + 'AccountCreationThrottle' => null, + 'PasswordAttemptThrottle' => null, + ] ) ); + $provider->setManager( AuthManager::singleton() ); + $provider->postAuthentication( \User::newFromName( 'SomeUser' ), + AuthenticationResponse::newPass() ); + + $provider = new ThrottlePreAuthenticationProvider( [ + 'passwordAttemptThrottle' => [ [ 'count' => 2, 'seconds' => 86400 ] ], + 'cache' => new \HashBagOStuff(), + ] ); + $logger = new \TestLogger( true ); + $provider->setLogger( $logger ); + $provider->setConfig( new \HashConfig( [ + 'AccountCreationThrottle' => null, + 'PasswordAttemptThrottle' => null, + ] ) ); + $provider->setManager( AuthManager::singleton() ); + $provider->postAuthentication( \User::newFromName( 'SomeUser' ), + AuthenticationResponse::newPass() ); + $this->assertSame( [ + [ \Psr\Log\LogLevel::ERROR, 'throttler data not found for {user}' ], + ], $logger->getBuffer() ); + } +} diff --git a/tests/phpunit/includes/auth/ThrottlerTest.php b/tests/phpunit/includes/auth/ThrottlerTest.php new file mode 100644 index 0000000000..dba748b578 --- /dev/null +++ b/tests/phpunit/includes/auth/ThrottlerTest.php @@ -0,0 +1,246 @@ +markTestSkipped( '$wgDisableAuthManager is set' ); + } + } + + public function testConstructor() { + $cache = new \HashBagOStuff(); + $logger = $this->getMockBuilder( AbstractLogger::class ) + ->setMethods( [ 'log' ] ) + ->getMockForAbstractClass(); + + $throttler = new Throttler( + [ [ 'count' => 123, 'seconds' => 456 ] ], + [ 'type' => 'foo', 'cache' => $cache ] + ); + $throttler->setLogger( $logger ); + $throttlerPriv = \TestingAccessWrapper::newFromObject( $throttler ); + $this->assertSame( [ [ 'count' => 123, 'seconds' => 456 ] ], $throttlerPriv->conditions ); + $this->assertSame( 'foo', $throttlerPriv->type ); + $this->assertSame( $cache, $throttlerPriv->cache ); + $this->assertSame( $logger, $throttlerPriv->logger ); + + $throttler = new Throttler( [ [ 'count' => 123, 'seconds' => 456 ] ] ); + $throttler->setLogger( new NullLogger() ); + $throttlerPriv = \TestingAccessWrapper::newFromObject( $throttler ); + $this->assertSame( [ [ 'count' => 123, 'seconds' => 456 ] ], $throttlerPriv->conditions ); + $this->assertSame( 'custom', $throttlerPriv->type ); + $this->assertInstanceOf( BagOStuff::class, $throttlerPriv->cache ); + $this->assertInstanceOf( LoggerInterface::class, $throttlerPriv->logger ); + + $this->setMwGlobals( [ 'wgPasswordAttemptThrottle' => [ [ 'count' => 321, + 'seconds' => 654 ] ] ] ); + $throttler = new Throttler(); + $throttler->setLogger( new NullLogger() ); + $throttlerPriv = \TestingAccessWrapper::newFromObject( $throttler ); + $this->assertSame( [ [ 'count' => 321, 'seconds' => 654 ] ], $throttlerPriv->conditions ); + $this->assertSame( 'password', $throttlerPriv->type ); + $this->assertInstanceOf( BagOStuff::class, $throttlerPriv->cache ); + $this->assertInstanceOf( LoggerInterface::class, $throttlerPriv->logger ); + + try { + new Throttler( [], [ 'foo' => 1, 'bar' => 2, 'baz' => 3 ] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'unrecognized parameters: foo, bar, baz', $ex->getMessage() ); + } + } + + /** + * @dataProvider provideNormalizeThrottleConditions + */ + public function testNormalizeThrottleConditions( $condition, $normalized ) { + $throttler = new Throttler( $condition ); + $throttler->setLogger( new NullLogger() ); + $throttlerPriv = \TestingAccessWrapper::newFromObject( $throttler ); + $this->assertSame( $normalized, $throttlerPriv->conditions ); + } + + public function provideNormalizeThrottleConditions() { + return [ + [ + [], + [], + ], + [ + [ 'count' => 1, 'seconds' => 2 ], + [ [ 'count' => 1, 'seconds' => 2 ] ], + ], + [ + [ [ 'count' => 1, 'seconds' => 2 ], [ 'count' => 2, 'seconds' => 3 ] ], + [ [ 'count' => 1, 'seconds' => 2 ], [ 'count' => 2, 'seconds' => 3 ] ], + ], + ]; + } + + public function testNormalizeThrottleConditions2() { + $priv = \TestingAccessWrapper::newFromClass( Throttler::class ); + $this->assertSame( [], $priv->normalizeThrottleConditions( null ) ); + $this->assertSame( [], $priv->normalizeThrottleConditions( 'bad' ) ); + } + + public function testIncrease() { + $cache = new \HashBagOStuff(); + $throttler = new Throttler( [ + [ 'count' => 2, 'seconds' => 10, ], + [ 'count' => 4, 'seconds' => 15, 'allIPs' => true ], + ], [ 'cache' => $cache ] ); + $throttler->setLogger( new NullLogger() ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertSame( [ 'throttleIndex' => 0, 'count' => 2, 'wait' => 10 ], $result ); + + $result = $throttler->increase( 'OtherUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler->increase( 'SomeUser', '2.3.4.5' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler->increase( 'SomeUser', '3.4.5.6' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler->increase( 'SomeUser', '3.4.5.6' ); + $this->assertSame( [ 'throttleIndex' => 1, 'count' => 4, 'wait' => 15 ], $result ); + } + + public function testZeroCount() { + $cache = new \HashBagOStuff(); + $throttler = new Throttler( [ [ 'count' => 0, 'seconds' => 10 ] ], [ 'cache' => $cache ] ); + $throttler->setLogger( new NullLogger() ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle, count=0 is ignored' ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle, count=0 is ignored' ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle, count=0 is ignored' ); + } + + public function testNamespacing() { + $cache = new \HashBagOStuff(); + $throttler1 = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ], + [ 'cache' => $cache, 'type' => 'foo' ] ); + $throttler2 = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ], + [ 'cache' => $cache, 'type' => 'foo' ] ); + $throttler3 = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ], + [ 'cache' => $cache, 'type' => 'bar' ] ); + $throttler1->setLogger( new NullLogger() ); + $throttler2->setLogger( new NullLogger() ); + $throttler3->setLogger( new NullLogger() ); + + $throttled = [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ]; + + $result = $throttler1->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler1->increase( 'SomeUser', '1.2.3.4' ); + $this->assertEquals( $throttled, $result, 'should throttle' ); + + $result = $throttler2->increase( 'SomeUser', '1.2.3.4' ); + $this->assertEquals( $throttled, $result, 'should throttle, same namespace' ); + + $result = $throttler3->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle, different namespace' ); + } + + public function testExpiration() { + $cache = $this->getMock( HashBagOStuff::class, [ 'add' ] ); + $throttler = new Throttler( [ [ 'count' => 3, 'seconds' => 10 ] ], [ 'cache' => $cache ] ); + $throttler->setLogger( new NullLogger() ); + + $cache->expects( $this->once() )->method( 'add' )->with( $this->anything(), 1, 10 ); + $throttler->increase( 'SomeUser' ); + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testException() { + $throttler = new Throttler( [ [ 'count' => 3, 'seconds' => 10 ] ] ); + $throttler->setLogger( new NullLogger() ); + $throttler->increase(); + } + + public function testLog() { + $cache = new \HashBagOStuff(); + $throttler = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ], [ 'cache' => $cache ] ); + + $logger = $this->getMockBuilder( AbstractLogger::class ) + ->setMethods( [ 'log' ] ) + ->getMockForAbstractClass(); + $logger->expects( $this->never() )->method( 'log' ); + $throttler->setLogger( $logger ); + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle' ); + + $logger = $this->getMockBuilder( AbstractLogger::class ) + ->setMethods( [ 'log' ] ) + ->getMockForAbstractClass(); + $logger->expects( $this->once() )->method( 'log' )->with( $this->anything(), $this->anything(), [ + 'type' => 'custom', + 'index' => 0, + 'ip' => '1.2.3.4', + 'username' => 'SomeUser', + 'count' => 1, + 'expiry' => 10, + 'method' => 'foo', + ] ); + $throttler->setLogger( $logger ); + $result = $throttler->increase( 'SomeUser', '1.2.3.4', 'foo' ); + $this->assertSame( [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ], $result ); + } + + public function testClear() { + $cache = new \HashBagOStuff(); + $throttler = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ], [ 'cache' => $cache ] ); + $throttler->setLogger( new NullLogger() ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertSame( [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ], $result ); + + $result = $throttler->increase( 'OtherUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler->increase( 'OtherUser', '1.2.3.4' ); + $this->assertSame( [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ], $result ); + + $throttler->clear( 'SomeUser', '1.2.3.4' ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler->increase( 'OtherUser', '1.2.3.4' ); + $this->assertSame( [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ], $result ); + } +} diff --git a/tests/phpunit/includes/auth/UserDataAuthenticationRequestTest.php b/tests/phpunit/includes/auth/UserDataAuthenticationRequestTest.php new file mode 100644 index 0000000000..7fe335117c --- /dev/null +++ b/tests/phpunit/includes/auth/UserDataAuthenticationRequestTest.php @@ -0,0 +1,177 @@ +setMwGlobals( 'wgHiddenPrefs', [] ); + } + + /** + * @dataProvider providePopulateUser + * @param string $email Email to set + * @param string $realname Realname to set + * @param StatusValue $expect Expected return + */ + public function testPopulateUser( $email, $realname, $expect ) { + $user = new \User(); + $user->setEmail( 'default@example.com' ); + $user->setRealName( 'Fake Name' ); + + $req = new UserDataAuthenticationRequest; + $req->email = $email; + $req->realname = $realname; + $this->assertEquals( $expect, $req->populateUser( $user ) ); + if ( $expect->isOk() ) { + $this->assertSame( $email ?: 'default@example.com', $user->getEmail() ); + $this->assertSame( $realname ?: 'Fake Name', $user->getRealName() ); + } + + } + + public static function providePopulateUser() { + $good = \StatusValue::newGood(); + return [ + [ 'email@example.com', 'Real Name', $good ], + [ 'email@example.com', '', $good ], + [ '', 'Real Name', $good ], + [ '', '', $good ], + [ 'invalid-email', 'Real Name', \StatusValue::newFatal( 'invalidemailaddress' ) ], + ]; + } + + /** + * @dataProvider provideLoadFromSubmission + */ + public function testLoadFromSubmission( + array $args, array $data, $expectState /* $hiddenPref, $enableEmail */ + ) { + list( $args, $data, $expectState, $hiddenPref, $enableEmail ) = func_get_args(); + $this->setMwGlobals( 'wgHiddenPrefs', $hiddenPref ); + $this->setMwGlobals( 'wgEnableEmail', $enableEmail ); + parent::testLoadFromSubmission( $args, $data, $expectState ); + } + + public function provideLoadFromSubmission() { + $unhidden = []; + $hidden = [ 'realname' ]; + + return [ + 'Empty request, unhidden, email enabled' => [ + [], + [], + false, + $unhidden, + true + ], + 'email + realname, unhidden, email enabled' => [ + [], + $data = [ 'email' => 'Email', 'realname' => 'Name' ], + $data, + $unhidden, + true + ], + 'email empty, unhidden, email enabled' => [ + [], + $data = [ 'email' => '', 'realname' => 'Name' ], + $data, + $unhidden, + true + ], + 'email omitted, unhidden, email enabled' => [ + [], + [ 'realname' => 'Name' ], + false, + $unhidden, + true + ], + 'realname empty, unhidden, email enabled' => [ + [], + $data = [ 'email' => 'Email', 'realname' => '' ], + $data, + $unhidden, + true + ], + 'realname omitted, unhidden, email enabled' => [ + [], + [ 'email' => 'Email' ], + false, + $unhidden, + true + ], + 'Empty request, hidden, email enabled' => [ + [], + [], + false, + $hidden, + true + ], + 'email + realname, hidden, email enabled' => [ + [], + [ 'email' => 'Email', 'realname' => 'Name' ], + [ 'email' => 'Email' ], + $hidden, + true + ], + 'email empty, hidden, email enabled' => [ + [], + $data = [ 'email' => '', 'realname' => 'Name' ], + [ 'email' => '' ], + $hidden, + true + ], + 'email omitted, hidden, email enabled' => [ + [], + [ 'realname' => 'Name' ], + false, + $hidden, + true + ], + 'realname empty, hidden, email enabled' => [ + [], + $data = [ 'email' => 'Email', 'realname' => '' ], + [ 'email' => 'Email' ], + $hidden, + true + ], + 'realname omitted, hidden, email enabled' => [ + [], + [ 'email' => 'Email' ], + [ 'email' => 'Email' ], + $hidden, + true + ], + 'email + realname, unhidden, email disabled' => [ + [], + [ 'email' => 'Email', 'realname' => 'Name' ], + [ 'realname' => 'Name' ], + $unhidden, + false + ], + 'email omitted, unhidden, email disabled' => [ + [], + [ 'realname' => 'Name' ], + [ 'realname' => 'Name' ], + $unhidden, + false + ], + 'email empty, unhidden, email disabled' => [ + [], + [ 'email' => '', 'realname' => 'Name' ], + [ 'realname' => 'Name' ], + $unhidden, + false + ], + ]; + } +} diff --git a/tests/phpunit/includes/auth/UsernameAuthenticationRequestTest.php b/tests/phpunit/includes/auth/UsernameAuthenticationRequestTest.php new file mode 100644 index 0000000000..63628dd89e --- /dev/null +++ b/tests/phpunit/includes/auth/UsernameAuthenticationRequestTest.php @@ -0,0 +1,34 @@ + [ + [], + [], + false + ], + 'Username' => [ + [], + $data = [ 'username' => 'User' ], + $data, + ], + 'Username empty' => [ + [], + [ 'username' => '' ], + false + ], + ]; + } +} diff --git a/tests/phpunit/includes/session/SessionManagerTest.php b/tests/phpunit/includes/session/SessionManagerTest.php index 5f387ea29d..e725feef6f 100644 --- a/tests/phpunit/includes/session/SessionManagerTest.php +++ b/tests/phpunit/includes/session/SessionManagerTest.php @@ -868,7 +868,11 @@ class SessionManagerTest extends MediaWikiTestCase { } public function testAutoCreateUser() { - global $wgGroupPermissions; + global $wgGroupPermissions, $wgDisableAuthManager; + + if ( !$wgDisableAuthManager ) { + $this->markTestSkipped( 'AuthManager is not disabled' ); + } \ObjectCache::$instances[__METHOD__] = new TestBagOStuff(); $this->setMwGlobals( [ 'wgMainCacheType' => __METHOD__ ] ); diff --git a/tests/phpunit/phpunit.php b/tests/phpunit/phpunit.php index d876c4578a..d13da60473 100755 --- a/tests/phpunit/phpunit.php +++ b/tests/phpunit/phpunit.php @@ -77,6 +77,7 @@ class PHPUnitMaintClass extends Maintenance { global $wgDevelopmentWarnings; global $wgSessionProviders; global $wgJobTypeConf; + global $wgAuthManagerConfig, $wgAuth, $wgDisableAuthManager; // Inject test autoloader require_once __DIR__ . '/../TestsAutoLoader.php'; @@ -124,6 +125,27 @@ class PHPUnitMaintClass extends Maintenance { ], ]; + // Generic AuthManager configuration for testing + $wgAuthManagerConfig = [ + 'preauth' => [], + 'primaryauth' => [ + [ + 'class' => MediaWiki\Auth\TemporaryPasswordPrimaryAuthenticationProvider::class, + 'args' => [ [ + 'authoritative' => false, + ] ], + ], + [ + 'class' => MediaWiki\Auth\LocalPasswordPrimaryAuthenticationProvider::class, + 'args' => [ [ + 'authoritative' => true, + ] ], + ], + ], + 'secondaryauth' => [], + ]; + $wgAuth = $wgDisableAuthManager ? new AuthPlugin : new MediaWiki\Auth\AuthManagerAuthPlugin(); + // Bug 44192 Do not attempt to send a real e-mail Hooks::clear( 'AlternateUserMailer' ); Hooks::register( -- 2.20.1