Merge "Add "mVersion" sanity check to User::loadFromCache()"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Mon, 16 May 2016 19:12:15 +0000 (19:12 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Mon, 16 May 2016 19:12:15 +0000 (19:12 +0000)
180 files changed:
RELEASE-NOTES-1.27
autoload.php
docs/extension.schema.json
docs/hooks.txt
includes/AuthPlugin.php
includes/DefaultSettings.php
includes/EditPage.php
includes/Preferences.php
includes/Setup.php
includes/Title.php
includes/api/ApiAMCreateAccount.php [new file with mode: 0644]
includes/api/ApiAuthManagerHelper.php [new file with mode: 0644]
includes/api/ApiChangeAuthenticationData.php [new file with mode: 0644]
includes/api/ApiClientLogin.php [new file with mode: 0644]
includes/api/ApiCreateAccount.php
includes/api/ApiFormatJson.php
includes/api/ApiLinkAccount.php [new file with mode: 0644]
includes/api/ApiLogin.php
includes/api/ApiMain.php
includes/api/ApiPageSet.php
includes/api/ApiQuery.php
includes/api/ApiQueryAuthManagerInfo.php [new file with mode: 0644]
includes/api/ApiQueryUsers.php
includes/api/ApiRemoveAuthenticationData.php [new file with mode: 0644]
includes/api/ApiResetPassword.php [new file with mode: 0644]
includes/api/i18n/en.json
includes/api/i18n/ko.json
includes/api/i18n/qqq.json
includes/api/i18n/sv.json
includes/auth/AbstractAuthenticationProvider.php [new file with mode: 0644]
includes/auth/AbstractPasswordPrimaryAuthenticationProvider.php [new file with mode: 0644]
includes/auth/AbstractPreAuthenticationProvider.php [new file with mode: 0644]
includes/auth/AbstractPrimaryAuthenticationProvider.php [new file with mode: 0644]
includes/auth/AbstractSecondaryAuthenticationProvider.php [new file with mode: 0644]
includes/auth/AuthManager.php [new file with mode: 0644]
includes/auth/AuthManagerAuthPlugin.php [new file with mode: 0644]
includes/auth/AuthPluginPrimaryAuthenticationProvider.php [new file with mode: 0644]
includes/auth/AuthenticationProvider.php [new file with mode: 0644]
includes/auth/AuthenticationRequest.php [new file with mode: 0644]
includes/auth/AuthenticationResponse.php [new file with mode: 0644]
includes/auth/ButtonAuthenticationRequest.php [new file with mode: 0644]
includes/auth/CheckBlocksSecondaryAuthenticationProvider.php [new file with mode: 0644]
includes/auth/ConfirmLinkAuthenticationRequest.php [new file with mode: 0644]
includes/auth/ConfirmLinkSecondaryAuthenticationProvider.php [new file with mode: 0644]
includes/auth/CreateFromLoginAuthenticationRequest.php [new file with mode: 0644]
includes/auth/CreatedAccountAuthenticationRequest.php [new file with mode: 0644]
includes/auth/CreationReasonAuthenticationRequest.php [new file with mode: 0644]
includes/auth/EmailNotificationSecondaryAuthenticationProvider.php [new file with mode: 0644]
includes/auth/LegacyHookPreAuthenticationProvider.php [new file with mode: 0644]
includes/auth/LocalPasswordPrimaryAuthenticationProvider.php [new file with mode: 0644]
includes/auth/PasswordAuthenticationRequest.php [new file with mode: 0644]
includes/auth/PasswordDomainAuthenticationRequest.php [new file with mode: 0644]
includes/auth/PreAuthenticationProvider.php [new file with mode: 0644]
includes/auth/PrimaryAuthenticationProvider.php [new file with mode: 0644]
includes/auth/RememberMeAuthenticationRequest.php [new file with mode: 0644]
includes/auth/ResetPasswordSecondaryAuthenticationProvider.php [new file with mode: 0644]
includes/auth/SecondaryAuthenticationProvider.php [new file with mode: 0644]
includes/auth/TemporaryPasswordAuthenticationRequest.php [new file with mode: 0644]
includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php [new file with mode: 0644]
includes/auth/ThrottlePreAuthenticationProvider.php [new file with mode: 0644]
includes/auth/Throttler.php [new file with mode: 0644]
includes/auth/UserDataAuthenticationRequest.php [new file with mode: 0644]
includes/auth/UsernameAuthenticationRequest.php [new file with mode: 0644]
includes/exception/UserNotLoggedIn.php
includes/installer/i18n/ko.json
includes/jobqueue/JobRunner.php
includes/jobqueue/jobs/AssembleUploadChunksJob.php
includes/jobqueue/jobs/PublishStashedFileJob.php
includes/password/PasswordPolicyChecks.php
includes/registration/ExtensionProcessor.php
includes/session/CookieSessionProvider.php
includes/session/ImmutableSessionProviderWithCookie.php
includes/session/SessionManager.php
includes/session/SessionProvider.php
includes/skins/SkinTemplate.php
includes/specialpage/AuthManagerSpecialPage.php [new file with mode: 0644]
includes/specialpage/LoginSignupSpecialPage.php [new file with mode: 0644]
includes/specialpage/SpecialPage.php
includes/specialpage/SpecialPageFactory.php
includes/specials/SpecialChangeCredentials.php [new file with mode: 0644]
includes/specials/SpecialChangeEmail.php
includes/specials/SpecialChangePassword.php
includes/specials/SpecialCreateAccount.php
includes/specials/SpecialLinkAccounts.php [new file with mode: 0644]
includes/specials/SpecialPasswordReset.php
includes/specials/SpecialRemoveCredentials.php [new file with mode: 0644]
includes/specials/SpecialTags.php
includes/specials/SpecialUnlinkAccounts.php [new file with mode: 0644]
includes/specials/SpecialUserLogin.php [new file with mode: 0644]
includes/specials/SpecialUserLogout.php [new file with mode: 0644]
includes/specials/SpecialUserlogin.php [deleted file]
includes/specials/SpecialUserlogout.php [deleted file]
includes/specials/SpecialUserrights.php
includes/specials/helpers/LoginHelper.php [new file with mode: 0644]
includes/specials/pre-authmanager/README [new file with mode: 0644]
includes/specials/pre-authmanager/SpecialChangeEmail.php [new file with mode: 0644]
includes/specials/pre-authmanager/SpecialChangePassword.php [new file with mode: 0644]
includes/specials/pre-authmanager/SpecialCreateAccount.php [new file with mode: 0644]
includes/specials/pre-authmanager/SpecialPasswordReset.php [new file with mode: 0644]
includes/specials/pre-authmanager/SpecialUserlogin.php [new file with mode: 0644]
includes/specials/pre-authmanager/SpecialUserlogout.php [new file with mode: 0644]
includes/templates/Usercreate.php
includes/templates/Userlogin.php
includes/user/PasswordReset.php [new file with mode: 0644]
includes/user/User.php
languages/i18n/ast.json
languages/i18n/bn.json
languages/i18n/de.json
languages/i18n/diq.json
languages/i18n/el.json
languages/i18n/en.json
languages/i18n/eo.json
languages/i18n/es.json
languages/i18n/fa.json
languages/i18n/gom-deva.json
languages/i18n/he.json
languages/i18n/hr.json
languages/i18n/ia.json
languages/i18n/inh.json
languages/i18n/jv.json
languages/i18n/ko.json
languages/i18n/ksh.json
languages/i18n/lb.json
languages/i18n/nds-nl.json
languages/i18n/nds.json
languages/i18n/nl.json
languages/i18n/pl.json
languages/i18n/qqq.json
languages/i18n/ru.json
languages/i18n/sl.json
languages/i18n/sr-ec.json
languages/i18n/sr-el.json
languages/i18n/sv.json
languages/i18n/tr.json
languages/i18n/tt-cyrl.json
languages/i18n/war.json
languages/i18n/zh-hans.json
languages/messages/MessagesEn.php
resources/src/mediawiki.legacy/shared.css
tests/TestsAutoLoader.php
tests/phpunit/MediaWikiTestCase.php
tests/phpunit/includes/api/ApiCreateAccountTest.php [deleted file]
tests/phpunit/includes/api/ApiLoginTest.php
tests/phpunit/includes/api/ApiTestCase.php
tests/phpunit/includes/auth/AbstractAuthenticationProviderTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/AbstractPasswordPrimaryAuthenticationProviderTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/AbstractPreAuthenticationProviderTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/AbstractPrimaryAuthenticationProviderTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/AbstractSecondaryAuthenticationProviderTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/AuthManagerTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/AuthPluginPrimaryAuthenticationProviderTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/AuthenticationRequestTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/AuthenticationRequestTestCase.php [new file with mode: 0644]
tests/phpunit/includes/auth/AuthenticationResponseTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/ButtonAuthenticationRequestTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/CheckBlocksSecondaryAuthenticationProviderTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/ConfirmLinkAuthenticationRequestTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/ConfirmLinkSecondaryAuthenticationProviderTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/CreateFromLoginAuthenticationRequestTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/CreatedAccountAuthenticationRequestTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/CreationReasonAuthenticationRequestTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/EmailNotificationSecondaryAuthenticationProviderTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/LegacyHookPreAuthenticationProviderTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/LocalPasswordPrimaryAuthenticationProviderTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/PasswordAuthenticationRequestTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/PasswordDomainAuthenticationRequestTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/RememberMeAuthenticationRequestTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/ResetPasswordSecondaryAuthenticationProviderTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/TemporaryPasswordAuthenticationRequestTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/TemporaryPasswordPrimaryAuthenticationProviderTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/ThrottlePreAuthenticationProviderTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/ThrottlerTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/UserDataAuthenticationRequestTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/UsernameAuthenticationRequestTest.php [new file with mode: 0644]
tests/phpunit/includes/session/CookieSessionProviderTest.php
tests/phpunit/includes/session/ImmutableSessionProviderWithCookieTest.php
tests/phpunit/includes/session/SessionManagerTest.php
tests/phpunit/includes/session/SessionProviderTest.php
tests/phpunit/includes/user/PasswordResetTest.php [new file with mode: 0644]
tests/phpunit/phpunit.php

index 3a0326e..f44e8c5 100644 (file)
@@ -117,8 +117,30 @@ The following PHP extensions are strongly recommended:
 * Removed configuration option $wgCopyrightIcon (deprecated since 1.18). Use
   $wgFooterIcons['copyright']['copyright'] instead.
 * If the openssl and mcrypt PHP extensions are both unavailable, secure
-  session storage (soon to be used for login) will raise an exception. This
-  exception may be bypassed by setting $wgSessionInsecureSecrets = true.
+  session storage (used for login) will raise an exception. This exception may
+  be bypassed by setting $wgSessionInsecureSecrets = true.
+* Massive overhaul to authentication:
+** AuthPlugin and AuthPluginUser are deprecated.
+** LoginForm and associated templates are deprecated. Extensions which called
+   static LoginForm methods should be converted into authentication providers.
+** The following hooks are deprecated:
+*** AbortAutoAccount (create a MediaWiki\Auth\PreAuthenticationProvider instead)
+*** AbortLogin (create a MediaWiki\Auth\PreAuthenticationProvider instead)
+*** AbortNewAccount (create a MediaWiki\Auth\PreAuthenticationProvider instead)
+*** AddNewAccount (use LocalUserCreated instead)
+*** AuthPluginSetup (create a MediaWiki\Auth\PrimaryAuthenticationProvider instead)
+*** ChangePasswordForm (use AuthChangeFormFields instead, or security levels)
+*** LoginUserMigrated (create a MediaWiki\Auth\PreAuthenticationProvider instead)
+*** UserCreateForm (create a MediaWiki\Auth\AuthenticationProvider of some type instead)
+*** UserLoginForm (create a MediaWiki\Auth\AuthenticationProvider of some type instead)
+** The following hooks are removed:
+*** AbortChangePassword
+*** LoginPasswordResetMessage
+*** PrefsPasswordAudit
+** The UserLoginComplete hook will no longer be called for all logins, only for
+   those via the web UI. Use UserLoggedIn if you need to do something on all
+   logins.
+** $wgRequirePasswordforEmailChange is removed.
 
 === New features in 1.27 ===
 * $wgDataCenterUpdateStickTTL was also added. This decides how long a user
@@ -198,6 +220,25 @@ The following PHP extensions are strongly recommended:
 * $wgJpegPixelFormat was added to override chroma subsampling for JPEG image
   thumbnails created via ImageMagick. Defaults to 'yuv420', providing bandwidth
   savings versus the previous behavior on many files.
+* MediaWiki\Auth infrastructure (called "AuthManager") allows for more flexible
+  configuration of multiple authentication pieces that was possible with
+  AuthPlugin. For example, it's now easy to plug in second-factor
+  authentication, or add additional checks to the login process, or to support
+  multiple login methods at once, or to support non-password-based login methods.
+** Providers are configured via the global setting $wgAuthManagerConfig.
+** A global, $wgDisableAuthManager, is temporarily available to disable
+   AuthManager until extensions are ready to support it.
+** New hook, AuthChangeFormFields, to adjust the form fields on
+   AuthManager-related special pages.
+** New hook, AuthManagerLoginAuthenticateAudit, for additional logging of
+   AuthManager-related authentication requests.
+** New hook, ChangeAuthenticationDataAudit, for additional logging of
+   AuthManager-related authentication data changes.
+** New hook, SecuritySensitiveOperationStatus, to work with the new mechanism
+   for requiring a recent login before taking security-sensitive operations
+   like changing a password.
+** Two new globals, $wgChangeCredentialsBlacklist and $wgRemoveCredentialsBlacklist
+   can be used to prevent the web UI and the API changing certain authentication data.
 
 === External library changes in 1.27 ===
 
@@ -239,6 +280,18 @@ The following PHP extensions are strongly recommended:
   merely need to change the username and password used after setting up a bot
   password.
 * action=upload no longer understands statuskey, asyncdownload or leavemessage.
+* Several changes when $wgDisableAuthManager is false:
+** action=login is deprecated for uses other than bot passwords.
+** list=users can now indicate if a missing username is creatable.
+** action=createaccount is changed in a non-backwards-compatible manner.
+** Added action=query&meta=authmanagerinfo.
+** Added action=clientlogin to be used to log into the main account instead of
+   action=login.
+** Added action=linkaccount.
+** Added action=unlinkaccount.
+** Added action=changeauthenticationdata.
+** Added action=removeauthenticationdata.
+** Added action=resetpassword.
 
 === Action API internal changes in 1.27 ===
 * ApiQueryORM removed.
@@ -271,6 +324,7 @@ The following PHP extensions are strongly recommended:
 * ApiMain::addFormat() was removed (deprecated in 1.21).
 * ApiMain::getFormats() was removed (deprecated in 1.21).
 * ApiPageSet::finishPageSetGeneration() was removed (deprecated in 1.21).
+* ApiCreateAccount is deprecated, and will be removed soon.
 
 === Languages updated in 1.27 ===
 
@@ -475,6 +529,11 @@ changes to languages because of Phabricator reports.
   performance on complex changes. $wgExternalDiffEngine = 'wikidiff3' therefore
   makes no difference now. Users are still recommended to use wikidiff2 if possible,
   though.
+* User::addNewUserLogEntry() was deprecated.
+* User::addNewUserLogEntryAutoCreate() was deprecated.
+* User::isPasswordReminderThrottled() was deprecated.
+* Bot-oriented parameters to Special:UserLogin (wpCookieCheck, wpSkipCookieCheck)
+  were removed.
 
 == Compatibility ==
 
index eb47300..6cfffad 100644 (file)
@@ -17,10 +17,14 @@ $wgAutoloadLocalClasses = [
        'AlterSharedConstraints' => __DIR__ . '/maintenance/oracle/alterSharedConstraints.php',
        'AncientPagesPage' => __DIR__ . '/includes/specials/SpecialAncientpages.php',
        'AnsiTermColorer' => __DIR__ . '/maintenance/term/MWTerm.php',
+       'ApiAMCreateAccount' => __DIR__ . '/includes/api/ApiAMCreateAccount.php',
+       'ApiAuthManagerHelper' => __DIR__ . '/includes/api/ApiAuthManagerHelper.php',
        'ApiBase' => __DIR__ . '/includes/api/ApiBase.php',
        'ApiBlock' => __DIR__ . '/includes/api/ApiBlock.php',
+       'ApiChangeAuthenticationData' => __DIR__ . '/includes/api/ApiChangeAuthenticationData.php',
        'ApiCheckToken' => __DIR__ . '/includes/api/ApiCheckToken.php',
        'ApiClearHasMsg' => __DIR__ . '/includes/api/ApiClearHasMsg.php',
+       'ApiClientLogin' => __DIR__ . '/includes/api/ApiClientLogin.php',
        'ApiComparePages' => __DIR__ . '/includes/api/ApiComparePages.php',
        'ApiContinuationManager' => __DIR__ . '/includes/api/ApiContinuationManager.php',
        'ApiCreateAccount' => __DIR__ . '/includes/api/ApiCreateAccount.php',
@@ -48,6 +52,7 @@ $wgAutoloadLocalClasses = [
        'ApiImageRotate' => __DIR__ . '/includes/api/ApiImageRotate.php',
        'ApiImport' => __DIR__ . '/includes/api/ApiImport.php',
        'ApiImportReporter' => __DIR__ . '/includes/api/ApiImport.php',
+       'ApiLinkAccount' => __DIR__ . '/includes/api/ApiLinkAccount.php',
        'ApiLogin' => __DIR__ . '/includes/api/ApiLogin.php',
        'ApiLogout' => __DIR__ . '/includes/api/ApiLogout.php',
        'ApiMain' => __DIR__ . '/includes/api/ApiMain.php',
@@ -75,6 +80,7 @@ $wgAutoloadLocalClasses = [
        'ApiQueryAllPages' => __DIR__ . '/includes/api/ApiQueryAllPages.php',
        'ApiQueryAllRevisions' => __DIR__ . '/includes/api/ApiQueryAllRevisions.php',
        'ApiQueryAllUsers' => __DIR__ . '/includes/api/ApiQueryAllUsers.php',
+       'ApiQueryAuthManagerInfo' => __DIR__ . '/includes/api/ApiQueryAuthManagerInfo.php',
        'ApiQueryBacklinks' => __DIR__ . '/includes/api/ApiQueryBacklinks.php',
        'ApiQueryBacklinksprop' => __DIR__ . '/includes/api/ApiQueryBacklinksprop.php',
        'ApiQueryBase' => __DIR__ . '/includes/api/ApiQueryBase.php',
@@ -123,6 +129,8 @@ $wgAutoloadLocalClasses = [
        'ApiQueryWatchlist' => __DIR__ . '/includes/api/ApiQueryWatchlist.php',
        'ApiQueryWatchlistRaw' => __DIR__ . '/includes/api/ApiQueryWatchlistRaw.php',
        'ApiRawMessage' => __DIR__ . '/includes/api/ApiMessage.php',
+       'ApiRemoveAuthenticationData' => __DIR__ . '/includes/api/ApiRemoveAuthenticationData.php',
+       'ApiResetPassword' => __DIR__ . '/includes/api/ApiResetPassword.php',
        'ApiResult' => __DIR__ . '/includes/api/ApiResult.php',
        'ApiRevisionDelete' => __DIR__ . '/includes/api/ApiRevisionDelete.php',
        'ApiRollback' => __DIR__ . '/includes/api/ApiRollback.php',
@@ -145,6 +153,7 @@ $wgAutoloadLocalClasses = [
        'AtomFeed' => __DIR__ . '/includes/Feed.php',
        'AtomicSectionUpdate' => __DIR__ . '/includes/deferred/AtomicSectionUpdate.php',
        'AttachLatest' => __DIR__ . '/maintenance/attachLatest.php',
+       'AuthManagerSpecialPage' => __DIR__ . '/includes/specialpage/AuthManagerSpecialPage.php',
        'AuthPlugin' => __DIR__ . '/includes/AuthPlugin.php',
        'AuthPluginUser' => __DIR__ . '/includes/AuthPlugin.php',
        'AutoLoader' => __DIR__ . '/includes/AutoLoader.php',
@@ -421,6 +430,7 @@ $wgAutoloadLocalClasses = [
        'FSFileOpHandle' => __DIR__ . '/includes/filebackend/FSFileBackend.php',
        'FSLockManager' => __DIR__ . '/includes/filebackend/lockmanager/FSLockManager.php',
        'FSRepo' => __DIR__ . '/includes/filerepo/FSRepo.php',
+       'FakeAuthTemplate' => __DIR__ . '/includes/specialpage/LoginSignupSpecialPage.php',
        'FakeConverter' => __DIR__ . '/languages/FakeConverter.php',
        'FakeMaintenance' => __DIR__ . '/maintenance/Maintenance.php',
        'FakeResultWrapper' => __DIR__ . '/includes/db/DatabaseUtility.php',
@@ -730,7 +740,11 @@ $wgAutoloadLocalClasses = [
        'LogPager' => __DIR__ . '/includes/logging/LogPager.php',
        'LoggedOutEditToken' => __DIR__ . '/includes/user/LoggedOutEditToken.php',
        'LoggedUpdateMaintenance' => __DIR__ . '/maintenance/Maintenance.php',
-       'LoginForm' => __DIR__ . '/includes/specials/SpecialUserlogin.php',
+       'LoginForm' => __DIR__ . '/includes/specialpage/LoginSignupSpecialPage.php',
+       'LoginFormAuthManager' => __DIR__ . '/includes/specialpage/LoginSignupSpecialPage.php',
+       'LoginFormPreAuthManager' => __DIR__ . '/includes/specials/pre-authmanager/SpecialUserlogin.php',
+       'LoginHelper' => __DIR__ . '/includes/specials/helpers/LoginHelper.php',
+       'LoginSignupSpecialPage' => __DIR__ . '/includes/specialpage/LoginSignupSpecialPage.php',
        'LonelyPagesPage' => __DIR__ . '/includes/specials/SpecialLonelypages.php',
        'LongPagesPage' => __DIR__ . '/includes/specials/SpecialLongpages.php',
        'MIMEsearchPage' => __DIR__ . '/includes/specials/SpecialMIMEsearch.php',
@@ -779,6 +793,41 @@ $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\\EmailNotificationSecondaryAuthenticationProvider' => __DIR__ . '/includes/auth/EmailNotificationSecondaryAuthenticationProvider.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',
@@ -962,6 +1011,7 @@ $wgAutoloadLocalClasses = [
        'PasswordError' => __DIR__ . '/includes/password/PasswordError.php',
        'PasswordFactory' => __DIR__ . '/includes/password/PasswordFactory.php',
        'PasswordPolicyChecks' => __DIR__ . '/includes/password/PasswordPolicyChecks.php',
+       'PasswordReset' => __DIR__ . '/includes/user/PasswordReset.php',
        'PatchSql' => __DIR__ . '/maintenance/patchSql.php',
        'PathRouter' => __DIR__ . '/includes/PathRouter.php',
        'PathRouterPatternReplacer' => __DIR__ . '/includes/PathRouter.php',
@@ -1201,11 +1251,15 @@ $wgAutoloadLocalClasses = [
        'SpecialCachedPage' => __DIR__ . '/includes/specials/SpecialCachedPage.php',
        'SpecialCategories' => __DIR__ . '/includes/specials/SpecialCategories.php',
        'SpecialChangeContentModel' => __DIR__ . '/includes/specials/SpecialChangeContentModel.php',
+       'SpecialChangeCredentials' => __DIR__ . '/includes/specials/SpecialChangeCredentials.php',
        'SpecialChangeEmail' => __DIR__ . '/includes/specials/SpecialChangeEmail.php',
+       'SpecialChangeEmailPreAuthManager' => __DIR__ . '/includes/specials/pre-authmanager/SpecialChangeEmail.php',
        'SpecialChangePassword' => __DIR__ . '/includes/specials/SpecialChangePassword.php',
+       'SpecialChangePasswordPreAuthManager' => __DIR__ . '/includes/specials/pre-authmanager/SpecialChangePassword.php',
        'SpecialComparePages' => __DIR__ . '/includes/specials/SpecialComparePages.php',
        'SpecialContributions' => __DIR__ . '/includes/specials/SpecialContributions.php',
        'SpecialCreateAccount' => __DIR__ . '/includes/specials/SpecialCreateAccount.php',
+       'SpecialCreateAccountPreAuthManager' => __DIR__ . '/includes/specials/pre-authmanager/SpecialCreateAccount.php',
        'SpecialDiff' => __DIR__ . '/includes/specials/SpecialDiff.php',
        'SpecialEditTags' => __DIR__ . '/includes/specials/SpecialEditTags.php',
        'SpecialEditWatchlist' => __DIR__ . '/includes/specials/SpecialEditWatchlist.php',
@@ -1215,6 +1269,7 @@ $wgAutoloadLocalClasses = [
        'SpecialFilepath' => __DIR__ . '/includes/specials/SpecialFilepath.php',
        'SpecialImport' => __DIR__ . '/includes/specials/SpecialImport.php',
        'SpecialJavaScriptTest' => __DIR__ . '/includes/specials/SpecialJavaScriptTest.php',
+       'SpecialLinkAccounts' => __DIR__ . '/includes/specials/SpecialLinkAccounts.php',
        'SpecialListAdmins' => __DIR__ . '/includes/specials/SpecialListusers.php',
        'SpecialListBots' => __DIR__ . '/includes/specials/SpecialListusers.php',
        'SpecialListFiles' => __DIR__ . '/includes/specials/SpecialListfiles.php',
@@ -1237,6 +1292,7 @@ $wgAutoloadLocalClasses = [
        'SpecialPageLanguage' => __DIR__ . '/includes/specials/SpecialPageLanguage.php',
        'SpecialPagesWithProp' => __DIR__ . '/includes/specials/SpecialPagesWithProp.php',
        'SpecialPasswordReset' => __DIR__ . '/includes/specials/SpecialPasswordReset.php',
+       'SpecialPasswordResetPreAuthManager' => __DIR__ . '/includes/specials/pre-authmanager/SpecialPasswordReset.php',
        'SpecialPermanentLink' => __DIR__ . '/includes/specials/SpecialPermanentLink.php',
        'SpecialPreferences' => __DIR__ . '/includes/specials/SpecialPreferences.php',
        'SpecialPrefixindex' => __DIR__ . '/includes/specials/SpecialPrefixindex.php',
@@ -1249,6 +1305,7 @@ $wgAutoloadLocalClasses = [
        'SpecialRecentChangesLinked' => __DIR__ . '/includes/specials/SpecialRecentchangeslinked.php',
        'SpecialRedirect' => __DIR__ . '/includes/specials/SpecialRedirect.php',
        'SpecialRedirectToSpecial' => __DIR__ . '/includes/specialpage/RedirectSpecialPage.php',
+       'SpecialRemoveCredentials' => __DIR__ . '/includes/specials/SpecialRemoveCredentials.php',
        'SpecialResetTokens' => __DIR__ . '/includes/specials/SpecialResetTokens.php',
        'SpecialRevisionDelete' => __DIR__ . '/includes/specials/SpecialRevisiondelete.php',
        'SpecialRunJobs' => __DIR__ . '/includes/specials/SpecialRunJobs.php',
@@ -1259,11 +1316,14 @@ $wgAutoloadLocalClasses = [
        'SpecialTrackingCategories' => __DIR__ . '/includes/specials/SpecialTrackingCategories.php',
        'SpecialUnblock' => __DIR__ . '/includes/specials/SpecialUnblock.php',
        'SpecialUndelete' => __DIR__ . '/includes/specials/SpecialUndelete.php',
+       'SpecialUnlinkAccounts' => __DIR__ . '/includes/specials/SpecialUnlinkAccounts.php',
        'SpecialUnlockdb' => __DIR__ . '/includes/specials/SpecialUnlockdb.php',
        'SpecialUpload' => __DIR__ . '/includes/specials/SpecialUpload.php',
        'SpecialUploadStash' => __DIR__ . '/includes/specials/SpecialUploadStash.php',
        'SpecialUploadStashTooLargeException' => __DIR__ . '/includes/specials/SpecialUploadStash.php',
-       'SpecialUserlogout' => __DIR__ . '/includes/specials/SpecialUserlogout.php',
+       'SpecialUserLogin' => __DIR__ . '/includes/specials/SpecialUserLogin.php',
+       'SpecialUserLogout' => __DIR__ . '/includes/specials/SpecialUserLogout.php',
+       'SpecialUserlogoutPreAuthManager' => __DIR__ . '/includes/specials/pre-authmanager/SpecialUserlogout.php',
        'SpecialVersion' => __DIR__ . '/includes/specials/SpecialVersion.php',
        'SpecialWatchlist' => __DIR__ . '/includes/specials/SpecialWatchlist.php',
        'SpecialWhatLinksHere' => __DIR__ . '/includes/specials/SpecialWhatlinkshere.php',
index 158cb6e..1d2b2f0 100644 (file)
                        "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"
index 2d5f6bc..f652786 100644 (file)
@@ -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
 
@@ -248,12 +249,6 @@ $user: the User object about to be created (read-only, incomplete)
 $autoblockip: The IP going to be autoblocked.
 &$block: The block from which the autoblock is coming.
 
-'AbortChangePassword': Return false to cancel password change.
-$user: the User object to which the password change is occuring
-$mOldpass: the old password provided by the user
-$newpass: the new password provided by the user
-&$abortMsg: the message identifier for abort reason
-
 'AbortDiffCache': Can be used to cancel the caching of a diff.
 &$diffEngine: DifferenceEngine object
 
@@ -262,7 +257,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 +267,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
@@ -295,7 +292,8 @@ $name: name of the action
 &$fields: HTMLForm descriptor array
 $article: Article object
 
-'AddNewAccount': After a user account is created.
+'AddNewAccount': DEPRECATED! Use LocalUserCreated.
+After a user account is created.
 $user: the User object that was created. (Parameter added in 1.7)
 $byEmail: true when account was created "by email" (added in 1.12)
 
@@ -744,13 +742,32 @@ viewing.
 redirect was followed.
 &$article: target article (object)
 
+'AuthChangeFormFields': After converting a field information array obtained
+from a set of AuthenticationRequest classes into a form descriptor; hooks
+can tweak the array to change how login etc. forms should look.
+$requests: array of AuthenticationRequests the fields are created from
+$fieldInfo: field information array (union of all AuthenticationRequest::getFieldInfo() responses).
+&$formDescriptor: HTMLForm descriptor. The special key 'weight' can be set
+  to change the order of the fields.
+$action: one of the AuthManager::ACTION_* constants.
+
+'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.
@@ -916,8 +933,14 @@ $html: Requested html content of anchor
 &$link: Returned value. When set to a non-null value by a hook subscriber
   this value will be used as the anchor instead of Linker::link
 
-'ChangePasswordForm': For extensions that need to add a field to the
-ChangePassword form via the Preferences form.
+'ChangeAuthenticationDataAudit': Called when user changes his password.
+No return data is accepted; this hook is for auditing only.
+$req: AuthenticationRequest object describing the change (and target user)
+$status: StatusValue with the result of the action
+
+'ChangePasswordForm': DEPRECATED! Use AuthChangeFormFields or security levels.
+For extensions that need to add a field to the ChangePassword form via the
+Preferences form.
 &$extraFields: An array of arrays that hold fields like would be passed to the
   pretty function.
 
@@ -1924,16 +1947,11 @@ in LoginForm::$validErrorMessages).
 &$messages: Already added messages (inclusive messages from
   LoginForm::$validErrorMessages)
 
-'LoginPasswordResetMessage': User is being requested to reset their password on
-login. Use this hook to change the Message that will be output on
-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.
@@ -2427,11 +2445,6 @@ $user: User (object) changing his email address
 $oldaddr: old email address (string)
 $newaddr: new email address (string)
 
-'PrefsPasswordAudit': Called when user changes his password.
-$user: User (object) changing his password
-$newPass: new password
-$error: error (string) 'badretype', 'wrongpassword', 'error' or 'success'
-
 'ProtectionForm::buildForm': Called after all protection type fieldsets are made
 in the form.
 $article: the title being (un)protected
@@ -2571,6 +2584,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
@@ -3254,7 +3278,8 @@ messages!" message, return false to not delete it.
 &$user: User (object) that will clear the message
 $oldid: ID of the talk page revision being viewed (0 means the most recent one)
 
-'UserCreateForm': change to manipulate the login form
+'UserCreateForm': DEPRECATED! Create an AuthenticationProvider instead.
+Manipulate the login form.
 &$template: SimpleTemplate instance for the form
 
 'UserEffectiveGroups': Called in User::getEffectiveGroups().
@@ -3357,12 +3382,14 @@ $user: User object
 'UserLoggedIn': Called after a user is logged in
 $user: User object for the logged-in user
 
-'UserLoginComplete': After a user has logged in.
+'UserLoginComplete': Show custom content after a user has logged in via the web interface.
+For functionality that needs to run after any login (API or web) use UserLoggedIn.
 &$user: the user object that was created on login
 &$inject_html: Any HTML to inject after the "logged in" message.
 
-'UserLoginForm': change to manipulate the login form
-&$template: SimpleTemplate instance for the form
+'UserLoginForm': DEPRECATED! Create an AuthenticationProvider instead.
+Manipulate the login form.
+&$template: QuickTemplate instance for the form
 
 'UserLogout': Before a user logs out.
 &$user: the user object that is about to be logged out
index add5876..0b65593 100644 (file)
@@ -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!
index bb10372..6088e8f 100644 (file)
@@ -4388,6 +4388,144 @@ $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,
+               // ],
+               MediaWiki\Auth\EmailNotificationSecondaryAuthenticationProvider::class => [
+                       'class' => MediaWiki\Auth\EmailNotificationSecondaryAuthenticationProvider::class,
+                       'sort' => 200,
+               ],
+       ],
+];
+
+/**
+ * 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,
+];
+
+/**
+ * List of AuthenticationRequest class names which are not changeable through
+ * Special:ChangeCredentials and the changeauthenticationdata API.
+ * This is only enforced on the client level; AuthManager itself (e.g.
+ * AuthManager::allowsAuthenticationDataChange calls) is not affected.
+ * Class names are checked for exact match (not for subclasses).
+ * @since 1.27
+ * @var string[]
+ */
+$wgChangeCredentialsBlacklist = [
+       \MediaWiki\Auth\TemporaryPasswordAuthenticationRequest::class
+];
+
+/**
+ * List of AuthenticationRequest class names which are not removable through
+ * Special:RemoveCredentials and the removeauthenticationdata API.
+ * This is only enforced on the client level; AuthManager itself (e.g.
+ * AuthManager::allowsAuthenticationDataChange calls) is not affected.
+ * Class names are checked for exact match (not for subclasses).
+ * @since 1.27
+ * @var string[]
+ */
+$wgRemoveCredentialsBlacklist = [
+       \MediaWiki\Auth\PasswordAuthenticationRequest::class,
+];
+
 /**
  * For compatibility with old installations set to false
  * @deprecated since 1.24 will be removed in future
index 870e2e0..0f52983 100644 (file)
@@ -2835,7 +2835,7 @@ class EditPage {
                                                // Log-in link
                                                '{{fullurl:Special:UserLogin|returnto={{FULLPAGENAMEE}}}}',
                                                // Sign-up link
-                                               '{{fullurl:Special:UserLogin/signup|returnto={{FULLPAGENAMEE}}}}' ]
+                                               '{{fullurl:Special:CreateAccount|returnto={{FULLPAGENAMEE}}}}' ]
                                );
                        } else {
                                $wgOut->wrapWikiMsg( "<div id=\"mw-anon-preview-warning\" class=\"warningbox\">\n$1</div>",
index 3f56240..9a55ae3 100644 (file)
@@ -19,6 +19,8 @@
  *
  * @file
  */
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Auth\PasswordAuthenticationRequest;
 
 /**
  * We're now using the HTMLForm object with some customisation to generate the
@@ -205,7 +207,7 @@ class Preferences {
         * @return void
         */
        static function profilePreferences( $user, IContextSource $context, &$defaultPreferences ) {
-               global $wgAuth, $wgContLang, $wgParser;
+               global $wgAuth, $wgContLang, $wgParser, $wgDisableAuthManager;
 
                $config = $context->getConfig();
                // retrieving user name for GENDER and misc.
@@ -281,16 +283,21 @@ class Preferences {
                $canEditPrivateInfo = $user->isAllowed( 'editmyprivateinfo' );
 
                // Actually changeable stuff
+               $realnameChangeAllowed = $wgDisableAuthManager ? $wgAuth->allowPropChange( 'realname' )
+                       : AuthManager::singleton()->allowsPropertyChange( 'realname' );
                $defaultPreferences['realname'] = [
                        // (not really "private", but still shouldn't be edited without permission)
-                       'type' => $canEditPrivateInfo && $wgAuth->allowPropChange( 'realname' ) ? 'text' : 'info',
+                       'type' => $canEditPrivateInfo && $realnameChangeAllowed ? 'text' : 'info',
                        'default' => $user->getRealName(),
                        'section' => 'personal/info',
                        'label-message' => 'yourrealname',
                        'help-message' => 'prefs-help-realname',
                ];
 
-               if ( $canEditPrivateInfo && $wgAuth->allowPasswordChange() ) {
+               $allowPasswordChange = $wgDisableAuthManager ? $wgAuth->allowPasswordChange()
+                       : AuthManager::singleton()->allowsAuthenticationDataChange(
+                               new PasswordAuthenticationRequest(), false );
+               if ( $canEditPrivateInfo && $allowPasswordChange ) {
                        $link = Linker::link( SpecialPage::getTitleFor( 'ChangePassword' ),
                                $context->msg( 'prefs-resetpass' )->escaped(), [],
                                [ 'returnto' => SpecialPage::getTitleFor( 'Preferences' )->getPrefixedText() ] );
@@ -411,8 +418,10 @@ class Preferences {
                        'default' => $oldsigHTML,
                        'section' => 'personal/signature',
                ];
+               $nicknameChangeAllowed = $wgDisableAuthManager ? $wgAuth->allowPropChange( 'nickname' )
+                       : AuthManager::singleton()->allowsPropertyChange( 'nickname' );
                $defaultPreferences['nickname'] = [
-                       'type' => $wgAuth->allowPropChange( 'nickname' ) ? 'text' : 'info',
+                       'type' => $nicknameChangeAllowed ? 'text' : 'info',
                        'maxlength' => $config->get( 'MaxSigChars' ),
                        'label-message' => 'yournick',
                        'validation-callback' => [ 'Preferences', 'validateSignature' ],
@@ -441,7 +450,9 @@ class Preferences {
                                }
 
                                $emailAddress = $user->getEmail() ? htmlspecialchars( $user->getEmail() ) : '';
-                               if ( $canEditPrivateInfo && $wgAuth->allowPropChange( 'emailaddress' ) ) {
+                               $emailChangeAllowed = $wgDisableAuthManager ? $wgAuth->allowPropChange( 'emailaddress' )
+                                       : AuthManager::singleton()->allowsPropertyChange( 'emailaddress' );
+                               if ( $canEditPrivateInfo && $emailChangeAllowed ) {
                                        $link = Linker::link(
                                                SpecialPage::getTitleFor( 'ChangeEmail' ),
                                                $context->msg( $user->getEmail() ? 'prefs-changeemail' : 'prefs-setemail' )->escaped(),
@@ -1410,8 +1421,6 @@ class Preferences {
         * @return bool|Status|string
         */
        static function tryFormSubmit( $formData, $form ) {
-               global $wgAuth;
-
                $user = $form->getModifiedUser();
                $hiddenPrefs = $form->getConfig()->get( 'HiddenPrefs' );
                $result = true;
@@ -1430,6 +1439,7 @@ class Preferences {
 
                // Fortunately, the realname field is MUCH simpler
                // (not really "private", but still shouldn't be edited without permission)
+
                if ( !in_array( 'realname', $hiddenPrefs )
                        && $user->isAllowed( 'editmyprivateinfo' )
                        && array_key_exists( 'realname', $formData )
@@ -1462,7 +1472,7 @@ class Preferences {
                        Hooks::run( 'PreferencesFormPreSave', [ $formData, $form, $user, &$result ] );
                }
 
-               $wgAuth->updateExternalDB( $user );
+               MediaWiki\Auth\AuthManager::callLegacyAuthPlugin( 'updateExternalDB', [ $user ] );
                $user->saveSettings();
 
                return $result;
index 9db997a..e57b96a 100644 (file)
@@ -450,6 +450,22 @@ if ( $wgProfileOnly ) {
        $wgDebugLogFile = '';
 }
 
+// Disable AuthManager API modules if $wgDisableAuthManager
+if ( $wgDisableAuthManager ) {
+       $wgAPIModules += [
+               'clientlogin' => 'ApiDisabled',
+               'createaccount' => 'ApiCreateAccount', // Use the non-AuthManager version
+               'linkaccount' => 'ApiDisabled',
+               'unlinkaccount' => 'ApiDisabled',
+               'changeauthenticationdata' => 'ApiDisabled',
+               'removeauthenticationdata' => 'ApiDisabled',
+               'resetpassword' => 'ApiDisabled',
+       ];
+       $wgAPIMetaModules += [
+               'authmanagerinfo' => 'ApiQueryDisabled',
+       ];
+}
+
 // Backwards compatibility with old password limits
 if ( $wgMinimalPasswordLength !== false ) {
        $wgPasswordPolicy['policies']['default']['MinimalPasswordLength'] = $wgMinimalPasswordLength;
@@ -692,9 +708,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 +849,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 );
index 876afe6..25fbce3 100644 (file)
@@ -1713,9 +1713,9 @@ class Title implements LinkTarget {
 
                                if ( $url === false
                                        && $wgVariantArticlePath
+                                       && preg_match( '/^variant=([^&]*)$/', $query, $matches )
                                        && $wgContLang->getCode() === $this->getPageLanguage()->getCode()
                                        && $this->getPageLanguage()->hasVariants()
-                                       && preg_match( '/^variant=([^&]*)$/', $query, $matches )
                                ) {
                                        $variant = urldecode( $matches[1] );
                                        if ( $this->getPageLanguage()->hasVariant( $variant ) ) {
diff --git a/includes/api/ApiAMCreateAccount.php b/includes/api/ApiAMCreateAccount.php
new file mode 100644 (file)
index 0000000..806b8d2
--- /dev/null
@@ -0,0 +1,132 @@
+<?php
+/**
+ * Copyright © 2016 Brad Jorsch <bjorsch@wikimedia.org>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Auth\AuthenticationRequest;
+use MediaWiki\Auth\AuthenticationResponse;
+
+/**
+ * Create an account with AuthManager
+ *
+ * @ingroup API
+ */
+class ApiAMCreateAccount extends ApiBase {
+
+       public function __construct( ApiMain $main, $action ) {
+               parent::__construct( $main, $action, 'create' );
+       }
+
+       public function getFinalDescription() {
+               // A bit of a hack to append 'api-help-authmanager-general-usage'
+               $msgs = parent::getFinalDescription();
+               $msgs[] = ApiBase::makeMessage( 'api-help-authmanager-general-usage', $this->getContext(), [
+                       $this->getModulePrefix(),
+                       $this->getModuleName(),
+                       $this->getModulePath(),
+                       AuthManager::ACTION_CREATE,
+                       self::needsToken(),
+               ] );
+               return $msgs;
+       }
+
+       public function execute() {
+               $params = $this->extractRequestParams();
+
+               $this->requireAtLeastOneParameter( $params, 'continue', 'returnurl' );
+
+               if ( $params['returnurl'] !== null ) {
+                       $bits = wfParseUrl( $params['returnurl'] );
+                       if ( !$bits || $bits['scheme'] === '' ) {
+                               $encParamName = $this->encodeParamName( 'returnurl' );
+                               $this->dieUsage(
+                                       "Invalid value '{$params['returnurl']}' for url parameter $encParamName",
+                                       "badurl_{$encParamName}"
+                               );
+                       }
+               }
+
+               $helper = new ApiAuthManagerHelper( $this );
+               $manager = AuthManager::singleton();
+
+               // Make sure it's possible to log in
+               if ( !$manager->canCreateAccounts() ) {
+                       $this->getResult()->addValue( null, 'createaccount', $helper->formatAuthenticationResponse(
+                               AuthenticationResponse::newFail(
+                                       $this->msg( 'userlogin-cannot-' . AuthManager::ACTION_CREATE )
+                               )
+                       ) );
+                       return;
+               }
+
+               // Perform the create step
+               if ( $params['continue'] ) {
+                       $reqs = $helper->loadAuthenticationRequests( AuthManager::ACTION_CREATE_CONTINUE );
+                       $res = $manager->continueAccountCreation( $reqs );
+               } else {
+                       $reqs = $helper->loadAuthenticationRequests( AuthManager::ACTION_CREATE );
+                       if ( $params['preservestate'] ) {
+                               $req = $helper->getPreservedRequest();
+                               if ( $req ) {
+                                       $reqs[] = $req;
+                               }
+                       }
+                       $res = $manager->beginAccountCreation( $this->getUser(), $reqs, $params['returnurl'] );
+               }
+
+               $this->getResult()->addValue( null, 'createaccount',
+                       $helper->formatAuthenticationResponse( $res ) );
+       }
+
+       public function isReadMode() {
+               return false;
+       }
+
+       public function isWriteMode() {
+               return true;
+       }
+
+       public function needsToken() {
+               return 'createaccount';
+       }
+
+       public function getAllowedParams() {
+               return ApiAuthManagerHelper::getStandardParams( AuthManager::ACTION_CREATE,
+                       'requests', 'messageformat', 'mergerequestfields', 'preservestate', 'returnurl', 'continue'
+               );
+       }
+
+       public function dynamicParameterDocumentation() {
+               return [ 'api-help-authmanagerhelper-additional-params', AuthManager::ACTION_CREATE ];
+       }
+
+       protected function getExamplesMessages() {
+               return [
+                       'action=createaccount&username=Example&password=ExamplePassword&retype=ExamplePassword'
+                               . '&createreturnurl=http://example.org/&createtoken=123ABC'
+                               => 'apihelp-createaccount-example-create',
+               ];
+       }
+
+       public function getHelpUrls() {
+               return 'https://www.mediawiki.org/wiki/API:Account_creation';
+       }
+}
diff --git a/includes/api/ApiAuthManagerHelper.php b/includes/api/ApiAuthManagerHelper.php
new file mode 100644 (file)
index 0000000..2997405
--- /dev/null
@@ -0,0 +1,365 @@
+<?php
+/**
+ * Copyright © 2016 Brad Jorsch <bjorsch@wikimedia.org>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @since 1.27
+ */
+
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Auth\AuthenticationRequest;
+use MediaWiki\Auth\AuthenticationResponse;
+use MediaWiki\Auth\CreateFromLoginAuthenticationRequest;
+
+/**
+ * Helper class for AuthManager-using API modules. Intended for use via
+ * composition.
+ *
+ * @ingroup API
+ */
+class ApiAuthManagerHelper {
+
+       /** @var ApiBase API module, for context and parameters */
+       private $module;
+
+       /** @var string Message output format */
+       private $messageFormat;
+
+       /**
+        * @param ApiBase $module API module, for context and parameters
+        */
+       public function __construct( ApiBase $module ) {
+               $this->module = $module;
+
+               $params = $module->extractRequestParams();
+               $this->messageFormat = isset( $params['messageformat'] ) ? $params['messageformat'] : 'wikitext';
+       }
+
+       /**
+        * Static version of the constructor, for chaining
+        * @param ApiBase $module API module, for context and parameters
+        * @return ApiAuthManagerHelper
+        */
+       public static function newForModule( ApiBase $module ) {
+               return new self( $module );
+       }
+
+       /**
+        * Format a message for output
+        * @param array &$res Result array
+        * @param string $key Result key
+        * @param Message $message
+        */
+       private function formatMessage( array &$res, $key, Message $message ) {
+               switch ( $this->messageFormat ) {
+                       case 'none':
+                               break;
+
+                       case 'wikitext':
+                               $res[$key] = $message->setContext( $this->module )->text();
+                               break;
+
+                       case 'html':
+                               $res[$key] = $message->setContext( $this->module )->parseAsBlock();
+                               $res[$key] = Parser::stripOuterParagraph( $res[$key] );
+                               break;
+
+                       case 'raw':
+                               $res[$key] = [
+                                       'key' => $message->getKey(),
+                                       'params' => $message->getParams(),
+                               ];
+                               break;
+               }
+       }
+
+       /**
+        * Call $manager->securitySensitiveOperationStatus()
+        * @param string $operation Operation being checked.
+        * @throws UsageException
+        */
+       public function securitySensitiveOperation( $operation ) {
+               $status = AuthManager::singleton()->securitySensitiveOperationStatus( $operation );
+               switch ( $status ) {
+                       case AuthManager::SEC_OK:
+                               return;
+
+                       case AuthManager::SEC_REAUTH:
+                               $this->module->dieUsage(
+                                       'You have not authenticated recently in this session, please reauthenticate.', 'reauthenticate'
+                               );
+
+                       case AuthManager::SEC_FAIL:
+                               $this->module->dieUsage(
+                                       'This action is not available as your identify cannot be verified.', 'cannotreauthenticate'
+                               );
+
+                       default:
+                               throw new UnexpectedValueException( "Unknown status \"$status\"" );
+               }
+       }
+
+       /**
+        * Filter out authentication requests by class name
+        * @param AuthenticationRequest[] $reqs Requests to filter
+        * @param string[] $blacklist Class names to remove
+        * @return AuthenticationRequest[]
+        */
+       public static function blacklistAuthenticationRequests( array $reqs, array $blacklist ) {
+               if ( $blacklist ) {
+                       $blacklist = array_flip( $blacklist );
+                       $reqs = array_filter( $reqs, function ( $req ) use ( $blacklist ) {
+                               return !isset( $blacklist[get_class( $req )] );
+                       } );
+               }
+               return $reqs;
+       }
+
+       /**
+        * Fetch and load the AuthenticationRequests for an action
+        * @param string $action One of the AuthManager::ACTION_* constants
+        * @return AuthenticationRequest[]
+        */
+       public function loadAuthenticationRequests( $action ) {
+               $params = $this->module->extractRequestParams();
+
+               $manager = AuthManager::singleton();
+               $reqs = $manager->getAuthenticationRequests( $action, $this->module->getUser() );
+
+               // Filter requests, if requested to do so
+               $wantedRequests = null;
+               if ( isset( $params['requests'] ) ) {
+                       $wantedRequests = array_flip( $params['requests'] );
+               } elseif ( isset( $params['request'] ) ) {
+                       $wantedRequests = [ $params['request'] => true ];
+               }
+               if ( $wantedRequests !== null ) {
+                       $reqs = array_filter( $reqs, function ( $req ) use ( $wantedRequests ) {
+                               return isset( $wantedRequests[$req->getUniqueId()] );
+                       } );
+               }
+
+               // Collect the fields for all the requests
+               $fields = [];
+               foreach ( $reqs as $req ) {
+                       $fields += (array)$req->getFieldInfo();
+               }
+
+               // Extract the request data for the fields and mark those request
+               // parameters as used
+               $data = array_intersect_key( $this->module->getRequest()->getValues(), $fields );
+               $this->module->getMain()->markParamsUsed( array_keys( $data ) );
+
+               return AuthenticationRequest::loadRequestsFromSubmission( $reqs, $data );
+       }
+
+       /**
+        * Format an AuthenticationResponse for return
+        * @param AuthenticationResponse $res
+        * @return array
+        */
+       public function formatAuthenticationResponse( AuthenticationResponse $res ) {
+               $params = $this->module->extractRequestParams();
+
+               $ret = [
+                       'status' => $res->status,
+               ];
+
+               if ( $res->status === AuthenticationResponse::PASS && $res->username !== null ) {
+                       $ret['username'] = $res->username;
+               }
+
+               if ( $res->status === AuthenticationResponse::REDIRECT ) {
+                       $ret['redirecttarget'] = $res->redirectTarget;
+                       if ( $res->redirectApiData !== null ) {
+                               $ret['redirectdata'] = $res->redirectApiData;
+                       }
+               }
+
+               if ( $res->status === AuthenticationResponse::REDIRECT ||
+                       $res->status === AuthenticationResponse::UI ||
+                       $res->status === AuthenticationResponse::RESTART
+               ) {
+                       $ret += $this->formatRequests( $res->neededRequests );
+               }
+
+               if ( $res->status === AuthenticationResponse::FAIL ||
+                       $res->status === AuthenticationResponse::UI ||
+                       $res->status === AuthenticationResponse::RESTART
+               ) {
+                       $this->formatMessage( $ret, 'message', $res->message );
+               }
+
+               if ( $res->status === AuthenticationResponse::FAIL ||
+                       $res->status === AuthenticationResponse::RESTART
+               ) {
+                       $this->module->getRequest()->getSession()->set(
+                               'ApiAuthManagerHelper::createRequest',
+                               $res->createRequest
+                       );
+                       $ret['canpreservestate'] = $res->createRequest !== null;
+               } else {
+                       $this->module->getRequest()->getSession()->remove( 'ApiAuthManagerHelper::createRequest' );
+               }
+
+               return $ret;
+       }
+
+       /**
+        * Fetch the preserved CreateFromLoginAuthenticationRequest, if any
+        * @return CreateFromLoginAuthenticationRequest|null
+        */
+       public function getPreservedRequest() {
+               $ret = $this->module->getRequest()->getSession()->get( 'ApiAuthManagerHelper::createRequest' );
+               return $ret instanceof CreateFromLoginAuthenticationRequest ? $ret : null;
+       }
+
+       /**
+        * Format an array of AuthenticationRequests for return
+        * @param AuthenticationRequest[] $reqs
+        * @return array Will have a 'requests' key, and also 'fields' if $module's
+        *  params include 'mergerequestfields'.
+        */
+       public function formatRequests( array $reqs ) {
+               $params = $this->module->extractRequestParams();
+               $mergeFields = !empty( $params['mergerequestfields'] );
+
+               $ret = [ 'requests' => [] ];
+               foreach ( $reqs as $req ) {
+                       $describe = $req->describeCredentials();
+                       $reqInfo = [
+                               'id' => $req->getUniqueId(),
+                               'metadata' => $req->getMetadata(),
+                       ];
+                       switch ( $req->required ) {
+                               case AuthenticationRequest::OPTIONAL:
+                                       $reqInfo['required'] = 'optional';
+                                       break;
+                               case AuthenticationRequest::REQUIRED:
+                                       $reqInfo['required'] = 'required';
+                                       break;
+                               case AuthenticationRequest::PRIMARY_REQUIRED:
+                                       $reqInfo['required'] = 'primary-required';
+                                       break;
+                       }
+                       $this->formatMessage( $reqInfo, 'provider', $describe['provider'] );
+                       $this->formatMessage( $reqInfo, 'account', $describe['account'] );
+                       if ( !$mergeFields ) {
+                               $reqInfo['fields'] = $this->formatFields( (array)$req->getFieldInfo() );
+                       }
+                       $ret['requests'][] = $reqInfo;
+               }
+
+               if ( $mergeFields ) {
+                       $fields = AuthenticationRequest::mergeFieldInfo( $reqs );
+                       $ret['fields'] = $this->formatFields( $fields );
+               }
+
+               return $ret;
+       }
+
+       /**
+        * Clean up a field array for output
+        * @param ApiBase $module For context and parameters 'mergerequestfields'
+        *  and 'messageformat'
+        * @param array $fields
+        * @return array
+        */
+       private function formatFields( array $fields ) {
+               static $copy = [
+                       'type' => true,
+                       'image' => true,
+                       'value' => true,
+               ];
+
+               $module = $this->module;
+               $retFields = [];
+
+               foreach ( $fields as $name => $field ) {
+                       $ret = array_intersect_key( $field, $copy );
+
+                       if ( isset( $field['options'] ) ) {
+                               $ret['options'] = array_map( function ( $msg ) use ( $module ) {
+                                       return $msg->setContext( $module )->plain();
+                               }, $field['options'] );
+                               ApiResult::setArrayType( $ret['options'], 'assoc' );
+                       }
+                       $this->formatMessage( $ret, 'label', $field['label'] );
+                       $this->formatMessage( $ret, 'help', $field['help'] );
+                       $ret['optional'] = !empty( $field['optional'] );
+
+                       $retFields[$name] = $ret;
+               }
+
+               ApiResult::setArrayType( $retFields, 'assoc' );
+
+               return $retFields;
+       }
+
+       /**
+        * Fetch the standard parameters this helper recognizes
+        * @param string $action AuthManager action
+        * @param string $param... Parameters to use
+        * @return array
+        */
+       public static function getStandardParams( $action, $param /* ... */ ) {
+               $params = [
+                       'requests' => [
+                               ApiBase::PARAM_TYPE => 'string',
+                               ApiBase::PARAM_ISMULTI => true,
+                               ApiBase::PARAM_HELP_MSG => [ 'api-help-authmanagerhelper-requests', $action ],
+                       ],
+                       'request' => [
+                               ApiBase::PARAM_TYPE => 'string',
+                               ApiBase::PARAM_REQUIRED => true,
+                               ApiBase::PARAM_HELP_MSG => [ 'api-help-authmanagerhelper-request', $action ],
+                       ],
+                       'messageformat' => [
+                               ApiBase::PARAM_DFLT => 'wikitext',
+                               ApiBase::PARAM_TYPE => [ 'html', 'wikitext', 'raw', 'none' ],
+                               ApiBase::PARAM_HELP_MSG => 'api-help-authmanagerhelper-messageformat',
+                       ],
+                       'mergerequestfields' => [
+                               ApiBase::PARAM_DFLT => false,
+                               ApiBase::PARAM_HELP_MSG => 'api-help-authmanagerhelper-mergerequestfields',
+                       ],
+                       'preservestate' => [
+                               ApiBase::PARAM_DFLT => false,
+                               ApiBase::PARAM_HELP_MSG => 'api-help-authmanagerhelper-preservestate',
+                       ],
+                       'returnurl' => [
+                               ApiBase::PARAM_TYPE => 'string',
+                               ApiBase::PARAM_HELP_MSG => 'api-help-authmanagerhelper-returnurl',
+                       ],
+                       'continue' => [
+                               ApiBase::PARAM_DFLT => false,
+                               ApiBase::PARAM_HELP_MSG => 'api-help-authmanagerhelper-continue',
+                       ],
+               ];
+
+               $ret = [];
+               $wantedParams = func_get_args();
+               array_shift( $wantedParams );
+               foreach ( $wantedParams as $name ) {
+                       if ( isset( $params[$name] ) ) {
+                               $ret[$name] = $params[$name];
+                       }
+               }
+               return $ret;
+       }
+}
diff --git a/includes/api/ApiChangeAuthenticationData.php b/includes/api/ApiChangeAuthenticationData.php
new file mode 100644 (file)
index 0000000..54547ef
--- /dev/null
@@ -0,0 +1,97 @@
+<?php
+/**
+ * Copyright © 2016 Brad Jorsch <bjorsch@wikimedia.org>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Auth\AuthManager;
+
+/**
+ * Change authentication data with AuthManager
+ *
+ * @ingroup API
+ */
+class ApiChangeAuthenticationData extends ApiBase {
+
+       public function __construct( ApiMain $main, $action ) {
+               parent::__construct( $main, $action, 'changeauth' );
+       }
+
+       public function execute() {
+               if ( !$this->getUser()->isLoggedIn() ) {
+                       $this->dieUsage( 'Must be logged in to change authentication data', 'notloggedin' );
+               }
+
+               $helper = new ApiAuthManagerHelper( $this );
+               $manager = AuthManager::singleton();
+
+               // Check security-sensitive operation status
+               $helper->securitySensitiveOperation( 'ChangeCredentials' );
+
+               // Fetch the request
+               $reqs = ApiAuthManagerHelper::blacklistAuthenticationRequests(
+                       $helper->loadAuthenticationRequests( AuthManager::ACTION_CHANGE ),
+                       $this->getConfig()->get( 'ChangeCredentialsBlacklist' )
+               );
+               if ( count( $reqs ) !== 1 ) {
+                       $this->dieUsage( 'Failed to create change request', 'badrequest' );
+               }
+               $req = reset( $reqs );
+
+               // Make the change
+               $status = $manager->allowsAuthenticationDataChange( $req, true );
+               if ( !$status->isGood() ) {
+                       $this->dieStatus( $status );
+               }
+               $manager->changeAuthenticationData( $req );
+
+               $this->getResult()->addValue( null, 'changeauthenticationdata', [ 'status' => 'success' ] );
+       }
+
+       public function isWriteMode() {
+               return true;
+       }
+
+       public function needsToken() {
+               return 'csrf';
+       }
+
+       public function getAllowedParams() {
+               return ApiAuthManagerHelper::getStandardParams( AuthManager::ACTION_CHANGE,
+                       'request'
+               );
+       }
+
+       public function dynamicParameterDocumentation() {
+               return [ 'api-help-authmanagerhelper-additional-params', AuthManager::ACTION_CHANGE ];
+       }
+
+       protected function getExamplesMessages() {
+               return [
+                       'action=changeauthenticationdata' .
+                               '&changeauthrequest=MediaWiki%5CAuth%5CPasswordAuthenticationRequest' .
+                               '&password=ExamplePassword&retype=ExamplePassword&changeauthtoken=123ABC'
+                               => 'apihelp-changeauthenticationdata-example-password',
+               ];
+       }
+
+       public function getHelpUrls() {
+               return 'https://www.mediawiki.org/wiki/API:Manage_authentication_data';
+       }
+}
diff --git a/includes/api/ApiClientLogin.php b/includes/api/ApiClientLogin.php
new file mode 100644 (file)
index 0000000..711234a
--- /dev/null
@@ -0,0 +1,128 @@
+<?php
+/**
+ * Copyright © 2016 Brad Jorsch <bjorsch@wikimedia.org>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Auth\AuthenticationRequest;
+use MediaWiki\Auth\AuthenticationResponse;
+
+/**
+ * Log in to the wiki with AuthManager
+ *
+ * @ingroup API
+ */
+class ApiClientLogin extends ApiBase {
+
+       public function __construct( ApiMain $main, $action ) {
+               parent::__construct( $main, $action, 'login' );
+       }
+
+       public function getFinalDescription() {
+               // A bit of a hack to append 'api-help-authmanager-general-usage'
+               $msgs = parent::getFinalDescription();
+               $msgs[] = ApiBase::makeMessage( 'api-help-authmanager-general-usage', $this->getContext(), [
+                       $this->getModulePrefix(),
+                       $this->getModuleName(),
+                       $this->getModulePath(),
+                       AuthManager::ACTION_LOGIN,
+                       self::needsToken(),
+               ] );
+               return $msgs;
+       }
+
+       public function execute() {
+               $params = $this->extractRequestParams();
+
+               $this->requireAtLeastOneParameter( $params, 'continue', 'returnurl' );
+
+               if ( $params['returnurl'] !== null ) {
+                       $bits = wfParseUrl( $params['returnurl'] );
+                       if ( !$bits || $bits['scheme'] === '' ) {
+                               $encParamName = $this->encodeParamName( 'returnurl' );
+                               $this->dieUsage(
+                                       "Invalid value '{$params['returnurl']}' for url parameter $encParamName",
+                                       "badurl_{$encParamName}"
+                               );
+                       }
+               }
+
+               $helper = new ApiAuthManagerHelper( $this );
+               $manager = AuthManager::singleton();
+
+               // Make sure it's possible to log in
+               if ( !$manager->canAuthenticateNow() ) {
+                       $this->getResult()->addValue( null, 'clientlogin', $helper->formatAuthenticationResponse(
+                               AuthenticationResponse::newFail( $this->msg( 'userlogin-cannot-' . AuthManager::ACTION_LOGIN ) )
+                       ) );
+                       return;
+               }
+
+               // Perform the login step
+               if ( $params['continue'] ) {
+                       $reqs = $helper->loadAuthenticationRequests( AuthManager::ACTION_LOGIN_CONTINUE );
+                       $res = $manager->continueAuthentication( $reqs );
+               } else {
+                       $reqs = $helper->loadAuthenticationRequests( AuthManager::ACTION_LOGIN );
+                       if ( $params['preservestate'] ) {
+                               $req = $helper->getPreservedRequest();
+                               if ( $req ) {
+                                       $reqs[] = $req;
+                               }
+                       }
+                       $res = $manager->beginAuthentication( $reqs, $params['returnurl'] );
+               }
+
+               $this->getResult()->addValue( null, 'clientlogin',
+                       $helper->formatAuthenticationResponse( $res ) );
+       }
+
+       public function isReadMode() {
+               return false;
+       }
+
+       public function needsToken() {
+               return 'login';
+       }
+
+       public function getAllowedParams() {
+               return ApiAuthManagerHelper::getStandardParams( AuthManager::ACTION_LOGIN,
+                       'requests', 'messageformat', 'mergerequestfields', 'preservestate', 'returnurl', 'continue'
+               );
+       }
+
+       public function dynamicParameterDocumentation() {
+               return [ 'api-help-authmanagerhelper-additional-params', AuthManager::ACTION_LOGIN ];
+       }
+
+       protected function getExamplesMessages() {
+               return [
+                       'action=clientlogin&username=Example&password=ExamplePassword&'
+                               . 'loginreturnurl=http://example.org/&logintoken=123ABC'
+                               => 'apihelp-clientlogin-example-login',
+                       'action=clientlogin&logincontinue=1&OATHToken=987654&logintoken=123ABC'
+                               => 'apihelp-clientlogin-example-login2',
+               ];
+       }
+
+       public function getHelpUrls() {
+               return 'https://www.mediawiki.org/wiki/API:Login';
+       }
+}
index 5552a85..6a48610 100644 (file)
@@ -27,6 +27,7 @@ use MediaWiki\Logger\LoggerFactory;
  * Unit to authenticate account registration attempts to the current wiki.
  *
  * @ingroup API
+ * @deprecated since 1.27, only used when $wgDisableAuthManager is true
  */
 class ApiCreateAccount extends ApiBase {
        public function execute() {
index 36cbbd9..41de925 100644 (file)
@@ -42,7 +42,7 @@ class ApiFormatJson extends ApiFormatBase {
                        # outside the control of the end user.
                        # (and do it here because ApiMain::reportUnusedParams() gets called
                        # before our ::execute())
-                       $this->getMain()->getCheck( '_' );
+                       $this->getMain()->markParamsUsed( '_' );
                }
        }
 
diff --git a/includes/api/ApiLinkAccount.php b/includes/api/ApiLinkAccount.php
new file mode 100644 (file)
index 0000000..14347d8
--- /dev/null
@@ -0,0 +1,130 @@
+<?php
+/**
+ * Copyright © 2016 Brad Jorsch <bjorsch@wikimedia.org>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Auth\AuthenticationRequest;
+use MediaWiki\Auth\AuthenticationResponse;
+
+/**
+ * Link an account with AuthManager
+ *
+ * @ingroup API
+ */
+class ApiLinkAccount extends ApiBase {
+
+       public function __construct( ApiMain $main, $action ) {
+               parent::__construct( $main, $action, 'link' );
+       }
+
+       public function getFinalDescription() {
+               // A bit of a hack to append 'api-help-authmanager-general-usage'
+               $msgs = parent::getFinalDescription();
+               $msgs[] = ApiBase::makeMessage( 'api-help-authmanager-general-usage', $this->getContext(), [
+                       $this->getModulePrefix(),
+                       $this->getModuleName(),
+                       $this->getModulePath(),
+                       AuthManager::ACTION_LINK,
+                       self::needsToken(),
+               ] );
+               return $msgs;
+       }
+
+       public function execute() {
+               if ( !$this->getUser()->isLoggedIn() ) {
+                       $this->dieUsage( 'Must be logged in to link accounts', 'notloggedin' );
+               }
+
+               $params = $this->extractRequestParams();
+
+               $this->requireAtLeastOneParameter( $params, 'continue', 'returnurl' );
+
+               if ( $params['returnurl'] !== null ) {
+                       $bits = wfParseUrl( $params['returnurl'] );
+                       if ( !$bits || $bits['scheme'] === '' ) {
+                               $encParamName = $this->encodeParamName( 'returnurl' );
+                               $this->dieUsage(
+                                       "Invalid value '{$params['returnurl']}' for url parameter $encParamName",
+                                       "badurl_{$encParamName}"
+                               );
+                       }
+               }
+
+               $helper = new ApiAuthManagerHelper( $this );
+               $manager = AuthManager::singleton();
+
+               // Check security-sensitive operation status
+               $helper->securitySensitiveOperation( 'LinkAccounts' );
+
+               // Make sure it's possible to link accounts
+               if ( !$manager->canLinkAccounts() ) {
+                       $this->getResult()->addValue( null, 'linkaccount', $helper->formatAuthenticationResponse(
+                               AuthenticationResponse::newFail( $this->msg( 'userlogin-cannot-' . AuthManager::ACTION_LINK ) )
+                       ) );
+                       return;
+               }
+
+               // Perform the link step
+               if ( $params['continue'] ) {
+                       $reqs = $helper->loadAuthenticationRequests( AuthManager::ACTION_LINK_CONTINUE );
+                       $res = $manager->continueAccountLink( $reqs );
+               } else {
+                       $reqs = $helper->loadAuthenticationRequests( AuthManager::ACTION_LINK );
+                       $res = $manager->beginAccountLink( $this->getUser(), $reqs, $params['returnurl'] );
+               }
+
+               $this->getResult()->addValue( null, 'linkaccount',
+                       $helper->formatAuthenticationResponse( $res ) );
+       }
+
+       public function isReadMode() {
+               return false;
+       }
+
+       public function isWriteMode() {
+               return true;
+       }
+
+       public function needsToken() {
+               return 'csrf';
+       }
+
+       public function getAllowedParams() {
+               return ApiAuthManagerHelper::getStandardParams( AuthManager::ACTION_LINK,
+                       'requests', 'messageformat', 'mergerequestfields', 'returnurl', 'continue'
+               );
+       }
+
+       public function dynamicParameterDocumentation() {
+               return [ 'api-help-authmanagerhelper-additional-params', AuthManager::ACTION_LINK ];
+       }
+
+       protected function getExamplesMessages() {
+               return [
+                       'action=linkaccount&provider=Example&linkreturnurl=http://example.org/&linktoken=123ABC'
+                               => 'apihelp-linkaccount-example-link',
+               ];
+       }
+
+       public function getHelpUrls() {
+               return 'https://www.mediawiki.org/wiki/API:Linkaccount';
+       }
+}
index 3891415..3572229 100644 (file)
@@ -25,6 +25,9 @@
  * @file
  */
 
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Auth\AuthenticationRequest;
+use MediaWiki\Auth\AuthenticationResponse;
 use MediaWiki\Logger\LoggerFactory;
 
 /**
@@ -38,6 +41,16 @@ class ApiLogin extends ApiBase {
                parent::__construct( $main, $action, 'lg' );
        }
 
+       protected function getDescriptionMessage() {
+               if ( $this->getConfig()->get( 'DisableAuthManager' ) ) {
+                       return 'apihelp-login-description-nonauthmanager';
+               } elseif ( $this->getConfig()->get( 'EnableBotPasswords' ) ) {
+                       return 'apihelp-login-description';
+               } else {
+                       return 'apihelp-login-description-nobotpasswords';
+               }
+       }
+
        /**
         * Executes the log-in attempt using the parameters passed. If
         * the log-in succeeds, it attaches a cookie to the session
@@ -83,11 +96,11 @@ class ApiLogin extends ApiBase {
                $loginType = 'N/A';
 
                // Check login token
-               $token = LoginForm::getLoginToken();
+               $token = $session->getToken( '', 'login' );
                if ( $token->wasNew() || !$params['token'] ) {
-                       $authRes = LoginForm::NEED_TOKEN;
+                       $authRes = 'NeedToken';
                } elseif ( !$token->match( $params['token'] ) ) {
-                       $authRes = LoginForm::WRONG_TOKEN;
+                       $authRes = 'WrongToken';
                }
 
                // Try bot passwords
@@ -99,48 +112,104 @@ class ApiLogin extends ApiBase {
                        );
                        if ( $status->isOK() ) {
                                $session = $status->getValue();
-                               $authRes = LoginForm::SUCCESS;
+                               $authRes = 'Success';
                                $loginType = 'BotPassword';
                        } else {
+                               $authRes = 'Failed';
+                               $message = $status->getMessage();
                                LoggerFactory::getInstance( 'authmanager' )->info(
                                        'BotPassword login failed: ' . $status->getWikiText( false, false, 'en' )
                                );
                        }
                }
 
-               // Normal login
                if ( $authRes === false ) {
-                       $context->setRequest( new DerivativeRequest(
-                               $this->getContext()->getRequest(),
-                               [
-                                       'wpName' => $params['name'],
-                                       'wpPassword' => $params['password'],
-                                       'wpDomain' => $params['domain'],
-                                       'wpLoginToken' => $params['token'],
-                                       'wpRemember' => ''
-                               ]
-                       ) );
-                       $loginForm = new LoginForm();
-                       $loginForm->setContext( $context );
-                       $authRes = $loginForm->authenticateUserData();
-                       $loginType = 'LoginForm';
+                       if ( $this->getConfig()->get( 'DisableAuthManager' ) ) {
+                               // Non-AuthManager login
+                               $context->setRequest( new DerivativeRequest(
+                                       $this->getContext()->getRequest(),
+                                       [
+                                               'wpName' => $params['name'],
+                                               'wpPassword' => $params['password'],
+                                               'wpDomain' => $params['domain'],
+                                               'wpLoginToken' => $params['token'],
+                                               'wpRemember' => ''
+                                       ]
+                               ) );
+                               $loginForm = new LoginForm();
+                               $loginForm->setContext( $context );
+                               $authRes = $loginForm->authenticateUserData();
+                               $loginType = 'LoginForm';
+
+                               switch ( $authRes ) {
+                                       case LoginForm::SUCCESS:
+                                               $authRes = 'Success';
+                                               break;
+                                       case LoginForm::NEED_TOKEN:
+                                               $authRes = 'NeedToken';
+                                               break;
+                               }
+                       } else {
+                               // Simplified AuthManager login, for backwards compatibility
+                               $manager = AuthManager::singleton();
+                               $reqs = AuthenticationRequest::loadRequestsFromSubmission(
+                                       $manager->getAuthenticationRequests( AuthManager::ACTION_LOGIN, $this->getUser() ),
+                                       [
+                                               'username' => $params['name'],
+                                               'password' => $params['password'],
+                                               'domain' => $params['domain'],
+                                               'rememberMe' => true,
+                                       ]
+                               );
+                               $res = AuthManager::singleton()->beginAuthentication( $reqs, 'null:' );
+                               switch ( $res->status ) {
+                                       case AuthenticationResponse::PASS:
+                                               if ( $this->getConfig()->get( 'EnableBotPasswords' ) ) {
+                                                       $warn = 'Main-account login via action=login is deprecated and may stop working ' .
+                                                               'without warning.';
+                                                       $warn .= ' To continue login with action=login, see [[Special:BotPasswords]].';
+                                                       $warn .= ' To safely continue using main-account login, see action=clientlogin.';
+                                               } else {
+                                                       $warn = 'Login via action=login is deprecated and may stop working without warning.';
+                                                       $warn .= ' To safely log in, see action=clientlogin.';
+                                               }
+                                               $this->setWarning( $warn );
+                                               $authRes = 'Success';
+                                               $loginType = 'AuthManager';
+                                               break;
+
+                                       case AuthenticationResponse::FAIL:
+                                               // Hope it's not a PreAuthenticationProvider that failed...
+                                               $authRes = 'Failed';
+                                               $message = $res->message;
+                                               \MediaWiki\Logger\LoggerFactory::getInstance( 'authentication' )
+                                                       ->info( __METHOD__ . ': Authentication failed: ' . $message->plain() );
+                                               break;
+
+                                       default:
+                                               $authRes = 'Aborted';
+                                               break;
+                               }
+                       }
                }
 
+               $result['result'] = $authRes;
                switch ( $authRes ) {
-                       case LoginForm::SUCCESS:
-                               $user = $context->getUser();
-                               $this->getContext()->setUser( $user );
-                               $user->setCookies( $this->getRequest(), null, true );
+                       case 'Success':
+                               if ( $this->getConfig()->get( 'DisableAuthManager' ) ) {
+                                       $user = $context->getUser();
+                                       $this->getContext()->setUser( $user );
+                                       $user->setCookies( $this->getRequest(), null, true );
+                               } else {
+                                       $user = $session->getUser();
+                               }
 
                                ApiQueryInfo::resetTokenCache();
 
-                               // Run hooks.
-                               // @todo FIXME: Split back and frontend from this hook.
-                               // @todo FIXME: This hook should be placed in the backend
+                               // Deprecated hook
                                $injected_html = '';
                                Hooks::run( 'UserLoginComplete', [ &$user, &$injected_html ] );
 
-                               $result['result'] = 'Success';
                                $result['lguserid'] = intval( $user->getId() );
                                $result['lgusername'] = $user->getName();
 
@@ -148,15 +217,14 @@ class ApiLogin extends ApiBase {
                                // point (1.28 at the earliest, and see T121527). They were ok
                                // when the core cookie-based login was the only thing, but
                                // CentralAuth broke that a while back and
-                               // SessionManager/AuthManager are *really* going to break it.
+                               // SessionManager/AuthManager *really* break it.
                                $result['lgtoken'] = $user->getToken();
                                $result['cookieprefix'] = $this->getConfig()->get( 'CookiePrefix' );
                                $result['sessionid'] = $session->getId();
                                break;
 
-                       case LoginForm::NEED_TOKEN:
-                               $result['result'] = 'NeedToken';
-                               $result['token'] = LoginForm::getLoginToken()->toString();
+                       case 'NeedToken':
+                               $result['token'] = $token->toString();
                                $this->setWarning( 'Fetching a token via action=login is deprecated. ' .
                                   'Use action=query&meta=tokens&type=login instead.' );
                                $this->logFeatureUsage( 'action=login&!lgtoken' );
@@ -166,6 +234,25 @@ class ApiLogin extends ApiBase {
                                $result['sessionid'] = $session->getId();
                                break;
 
+                       case 'WrongToken':
+                               break;
+
+                       case 'Failed':
+                               $result['reason'] = $message->useDatabase( 'false' )->inLanguage( 'en' )->text();
+                               break;
+
+                       case 'Aborted':
+                               $result['reason'] = 'Authentication requires user interaction, ' .
+                                  'which is not supported by action=login.';
+                               if ( $this->getConfig()->get( 'EnableBotPasswords' ) ) {
+                                       $result['reason'] .= ' To be able to login with action=login, see [[Special:BotPasswords]].';
+                                       $result['reason'] .= ' To continue using main-account login, see action=clientlogin.';
+                               } else {
+                                       $result['reason'] .= ' To log in, see action=clientlogin.';
+                               }
+                               break;
+
+                       // Results from LoginForm for when $wgDisableAuthManager is true
                        case LoginForm::WRONG_TOKEN:
                                $result['result'] = 'WrongToken';
                                break;
@@ -230,14 +317,22 @@ class ApiLogin extends ApiBase {
 
                $this->getResult()->addValue( null, 'login', $result );
 
+               if ( $loginType === 'LoginForm' && isset( LoginForm::$statusCodes[$authRes] ) ) {
+                       $authRes = LoginForm::$statusCodes[$authRes];
+               }
                LoggerFactory::getInstance( 'authmanager' )->info( 'Login attempt', [
                        'event' => 'login',
-                       'successful' => $authRes === LoginForm::SUCCESS,
+                       'successful' => $authRes === 'Success',
                        'loginType' => $loginType,
-                       'status' => LoginForm::$statusCodes[$authRes],
+                       'status' => $authRes,
                ] );
        }
 
+       public function isDeprecated() {
+               return !$this->getConfig()->get( 'DisableAuthManager' ) &&
+                       !$this->getConfig()->get( 'EnableBotPasswords' );
+       }
+
        public function mustBePosted() {
                return true;
        }
index 15cea44..b944385 100644 (file)
@@ -49,8 +49,14 @@ class ApiMain extends ApiBase {
         */
        private static $Modules = [
                'login' => 'ApiLogin',
+               'clientlogin' => 'ApiClientLogin',
                'logout' => 'ApiLogout',
-               'createaccount' => 'ApiCreateAccount',
+               'createaccount' => 'ApiAMCreateAccount',
+               'linkaccount' => 'ApiLinkAccount',
+               'unlinkaccount' => 'ApiRemoveAuthenticationData',
+               'changeauthenticationdata' => 'ApiChangeAuthenticationData',
+               'removeauthenticationdata' => 'ApiRemoveAuthenticationData',
+               'resetpassword' => 'ApiResetPassword',
                'query' => 'ApiQuery',
                'expandtemplates' => 'ApiExpandTemplates',
                'parse' => 'ApiParse',
@@ -1438,6 +1444,14 @@ class ApiMain extends ApiBase {
                return array_keys( $this->mParamsUsed );
        }
 
+       /**
+        * Mark parameters as used
+        * @param string|string[] $params
+        */
+       public function markParamsUsed( $params ) {
+               $this->mParamsUsed += array_fill_keys( (array)$params, true );
+       }
+
        /**
         * Get a request value, and register the fact that it was used, for logging.
         * @param string $name
index f278989..af4e536 100644 (file)
@@ -178,7 +178,7 @@ class ApiPageSet extends ApiBase {
                                // Prevent warnings from being reported on these parameters
                                $main = $this->getMain();
                                foreach ( $generator->extractRequestParams() as $paramName => $param ) {
-                                       $main->getVal( $generator->encodeParamName( $paramName ) );
+                                       $main->markParamsUsed( $generator->encodeParamName( $paramName ) );
                                }
                        }
 
index 733ea2c..3ca4c08 100644 (file)
@@ -112,6 +112,7 @@ class ApiQuery extends ApiBase {
         */
        private static $QueryMetaModules = [
                'allmessages' => 'ApiQueryAllMessages',
+               'authmanagerinfo' => 'ApiQueryAuthManagerInfo',
                'siteinfo' => 'ApiQuerySiteinfo',
                'userinfo' => 'ApiQueryUserInfo',
                'filerepoinfo' => 'ApiQueryFileRepoInfo',
diff --git a/includes/api/ApiQueryAuthManagerInfo.php b/includes/api/ApiQueryAuthManagerInfo.php
new file mode 100644 (file)
index 0000000..b591f9c
--- /dev/null
@@ -0,0 +1,116 @@
+<?php
+/**
+ * Copyright © 2016 Brad Jorsch <bjorsch@wikimedia.org>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @since 1.27
+ */
+
+use MediaWiki\Auth\AuthManager;
+
+/**
+ * A query action to return meta information about AuthManager state.
+ *
+ * @ingroup API
+ */
+class ApiQueryAuthManagerInfo extends ApiQueryBase {
+
+       public function __construct( ApiQuery $query, $moduleName ) {
+               parent::__construct( $query, $moduleName, 'ami' );
+       }
+
+       public function execute() {
+               $params = $this->extractRequestParams();
+               $helper = new ApiAuthManagerHelper( $this );
+
+               $manager = AuthManager::singleton();
+               $ret = [
+                       'canauthenticatenow' => $manager->canAuthenticateNow(),
+                       'cancreateaccounts' => $manager->canCreateAccounts(),
+                       'canlinkaccounts' => $manager->canLinkAccounts(),
+                       'haspreservedstate' => $helper->getPreservedRequest() !== null,
+               ];
+
+               if ( $params['securitysensitiveoperation'] !== null ) {
+                       $ret['securitysensitiveoperationstatus'] = $manager->securitySensitiveOperationStatus(
+                               $params['securitysensitiveoperation']
+                       );
+               }
+
+               if ( $params['requestsfor'] ) {
+                       $reqs = $manager->getAuthenticationRequests( $params['requestsfor'], $this->getUser() );
+
+                       // Filter out blacklisted requests, depending on the action
+                       switch ( $params['requestsfor'] ) {
+                               case AuthManager::ACTION_CHANGE:
+                                       $reqs = ApiAuthManagerHelper::blacklistAuthenticationRequests(
+                                               $reqs, $this->getConfig()->get( 'ChangeCredentialsBlacklist' )
+                                       );
+                                       break;
+                               case AuthManager::ACTION_REMOVE:
+                                       $reqs = ApiAuthManagerHelper::blacklistAuthenticationRequests(
+                                               $reqs, $this->getConfig()->get( 'RemoveCredentialsBlacklist' )
+                                       );
+                                       break;
+                       }
+
+                       $ret += $helper->formatRequests( $reqs );
+               }
+
+               $this->getResult()->addValue( [ 'query' ], $this->getModuleName(), $ret );
+       }
+
+       public function getCacheMode( $params ) {
+               return 'public';
+       }
+
+       public function getAllowedParams() {
+               return [
+                       'securitysensitiveoperation' => null,
+                       'requestsfor' => [
+                               ApiBase::PARAM_TYPE => [
+                                       AuthManager::ACTION_LOGIN,
+                                       AuthManager::ACTION_LOGIN_CONTINUE,
+                                       AuthManager::ACTION_CREATE,
+                                       AuthManager::ACTION_CREATE_CONTINUE,
+                                       AuthManager::ACTION_LINK,
+                                       AuthManager::ACTION_LINK_CONTINUE,
+                                       AuthManager::ACTION_CHANGE,
+                                       AuthManager::ACTION_REMOVE,
+                                       AuthManager::ACTION_UNLINK,
+                               ],
+                       ],
+               ] + ApiAuthManagerHelper::getStandardParams( '', 'mergerequestfields' );
+       }
+
+       protected function getExamplesMessages() {
+               return [
+                       'action=query&meta=authmanagerinfo&amirequestsfor=' . urlencode( AuthManager::ACTION_LOGIN )
+                               => 'apihelp-query+filerepoinfo-example-login',
+                       'action=query&meta=authmanagerinfo&amirequestsfor=' . urlencode( AuthManager::ACTION_LOGIN ) .
+                               '&amimergerequestfields=1'
+                               => 'apihelp-query+filerepoinfo-example-login-merged',
+                       'action=query&meta=authmanagerinfo&amisecuritysensitiveoperation=foo'
+                               => 'apihelp-query+filerepoinfo-example-securitysensitiveoperation',
+               ];
+       }
+
+       public function getHelpUrls() {
+               return 'https://www.mediawiki.org/wiki/API:Authmanagerinfo';
+       }
+}
index ee6ec76..68ec38d 100644 (file)
@@ -49,6 +49,7 @@ class ApiQueryUsers extends ApiQueryBase {
                'emailable',
                'gender',
                'centralids',
+               'cancreate',
        ];
 
        public function __construct( ApiQuery $query, $moduleName ) {
@@ -260,6 +261,10 @@ class ApiQueryUsers extends ApiQueryBase {
                                        }
                                } else {
                                        $data[$u]['missing'] = true;
+                                       if ( isset( $this->prop['cancreate'] ) && !$this->getConfig()->get( 'DisableAuthManager' ) ) {
+                                               $data[$u]['cancreate'] = MediaWiki\Auth\AuthManager::singleton()->canCreateAccount( $u )
+                                                       ->isGood();
+                                       }
                                }
                        } else {
                                if ( isset( $this->prop['groups'] ) && isset( $data[$u]['groups'] ) ) {
@@ -299,7 +304,7 @@ class ApiQueryUsers extends ApiQueryBase {
        }
 
        public function getAllowedParams() {
-               return [
+               $ret = [
                        'prop' => [
                                ApiBase::PARAM_ISMULTI => true,
                                ApiBase::PARAM_TYPE => [
@@ -312,6 +317,8 @@ class ApiQueryUsers extends ApiQueryBase {
                                        'emailable',
                                        'gender',
                                        'centralids',
+                                       // When adding a prop, consider whether it should be added
+                                       // to self::$publicProps
                                ],
                                ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
                        ],
@@ -326,6 +333,10 @@ class ApiQueryUsers extends ApiQueryBase {
                                ApiBase::PARAM_ISMULTI => true
                        ],
                ];
+               if ( !$this->getConfig()->get( 'DisableAuthManager' ) ) {
+                       $ret['prop'][ApiBase::PARAM_TYPE][] = 'cancreate';
+               }
+               return $ret;
        }
 
        protected function getExamplesMessages() {
diff --git a/includes/api/ApiRemoveAuthenticationData.php b/includes/api/ApiRemoveAuthenticationData.php
new file mode 100644 (file)
index 0000000..30e40fb
--- /dev/null
@@ -0,0 +1,110 @@
+<?php
+/**
+ * Copyright © 2016 Brad Jorsch <bjorsch@wikimedia.org>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Auth\AuthManager;
+
+/**
+ * Remove authentication data from AuthManager
+ *
+ * @ingroup API
+ */
+class ApiRemoveAuthenticationData extends ApiBase {
+
+       private $authAction;
+       private $operation;
+
+       public function __construct( ApiMain $main, $action ) {
+               parent::__construct( $main, $action );
+
+               $this->authAction = $action === 'unlinkaccount'
+                       ? AuthManager::ACTION_UNLINK
+                       : AuthManager::ACTION_REMOVE;
+               $this->operation = $action === 'unlinkaccount'
+                       ? 'UnlinkAccount'
+                       : 'RemoveCredentials';
+       }
+
+       public function execute() {
+               if ( !$this->getUser()->isLoggedIn() ) {
+                       $this->dieUsage( 'Must be logged in to remove authentication data', 'notloggedin' );
+               }
+
+               $params = $this->extractRequestParams();
+               $manager = AuthManager::singleton();
+
+               // Check security-sensitive operation status
+               ApiAuthManagerHelper::newForModule( $this )->securitySensitiveOperation( $this->operation );
+
+               // Fetch the request. No need to load from the request, so don't use
+               // ApiAuthManagerHelper's method.
+               $blacklist = $this->authAction === AuthManager::ACTION_REMOVE
+                       ? array_flip( $this->getConfig()->get( 'RemoveCredentialsBlacklist' ) )
+                       : [];
+               $reqs = array_filter(
+                       $manager->getAuthenticationRequests( $this->authAction, $this->getUser() ),
+                       function ( $req ) use ( $params, $blacklist ) {
+                               return $req->getUniqueId() === $params['request'] &&
+                                       !isset( $blacklist[get_class( $req )] );
+                       }
+               );
+               if ( count( $reqs ) !== 1 ) {
+                       $this->dieUsage( 'Failed to create change request', 'badrequest' );
+               }
+               $req = reset( $reqs );
+
+               // Perform the removal
+               $status = $manager->allowsAuthenticationDataChange( $req, true );
+               if ( !$status->isGood() ) {
+                       $this->dieStatus( $status );
+               }
+               $manager->changeAuthenticationData( $req );
+
+               $this->getResult()->addValue( null, $this->getModuleName(), [ 'status' => 'success' ] );
+       }
+
+       public function isWriteMode() {
+               return true;
+       }
+
+       public function needsToken() {
+               return 'csrf';
+       }
+
+       public function getAllowedParams() {
+               return ApiAuthManagerHelper::getStandardParams( $this->authAction,
+                       'request'
+               );
+       }
+
+       protected function getExamplesMessages() {
+               $path = $this->getModulePath();
+               $action = $this->getModuleName();
+               return [
+                       "action={$action}&request=FooAuthenticationRequest&token=123ABC"
+                               => "apihelp-{$path}-example-simple",
+               ];
+       }
+
+       public function getHelpUrls() {
+               return 'https://www.mediawiki.org/wiki/API:Manage_authentication_data';
+       }
+}
diff --git a/includes/api/ApiResetPassword.php b/includes/api/ApiResetPassword.php
new file mode 100644 (file)
index 0000000..042ad69
--- /dev/null
@@ -0,0 +1,146 @@
+<?php
+/**
+ * Copyright © 2016 Brad Jorsch <bjorsch@wikimedia.org>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Auth\AuthManager;
+
+/**
+ * Reset password, with AuthManager
+ *
+ * @ingroup API
+ */
+class ApiResetPassword extends ApiBase {
+
+       private $hasAnyRoutes = null;
+
+       /**
+        * Determine whether any reset routes are available.
+        * @return bool
+        */
+       private function hasAnyRoutes() {
+               if ( $this->hasAnyRoutes === null ) {
+                       $resetRoutes = $this->getConfig()->get( 'PasswordResetRoutes' );
+                       $this->hasAnyRoutes = !empty( $resetRoutes['username'] ) || !empty( $resetRoutes['email'] );
+               }
+               return $this->hasAnyRoutes;
+       }
+
+       protected function getDescriptionMessage() {
+               if ( !$this->hasAnyRoutes() ) {
+                       return 'apihelp-resetpassword-description-noroutes';
+               }
+               return parent::getDescriptionMessage();
+       }
+
+       public function execute() {
+               if ( !$this->hasAnyRoutes() ) {
+                       $this->dieUsage( 'No password reset routes are available.', 'moduledisabled' );
+               }
+
+               $params = $this->extractRequestParams() + [
+                       // Make sure the keys exist even if getAllowedParams didn't define them
+                       'user' => null,
+                       'email' => null,
+               ];
+
+               $this->requireOnlyOneParameter( $params, 'user', 'email' );
+
+               $passwordReset = new PasswordReset( $this->getConfig(), AuthManager::singleton() );
+
+               $status = $passwordReset->isAllowed( $this->getUser(), $params['capture'] );
+               if ( !$status->isOK() ) {
+                       $this->dieStatus( Status::wrap( $status ) );
+               }
+
+               $status = $passwordReset->execute(
+                       $this->getUser(), $params['user'], $params['email'], $params['capture']
+               );
+               if ( !$status->isOK() ) {
+                       $status->value = null;
+                       $this->dieStatus( Status::wrap( $status ) );
+               }
+
+               $result = $this->getResult();
+               $result->addValue( [ 'resetpassword' ], 'status', 'success' );
+               if ( $params['capture'] ) {
+                       $passwords = $status->getValue() ?: [];
+                       ApiResult::setArrayType( $passwords, 'kvp', 'user' );
+                       ApiResult::setIndexedTagName( $passwords, 'p' );
+                       $result->addValue( [ 'resetpassword' ], 'passwords', $passwords );
+               }
+       }
+
+       public function isWriteMode() {
+               return $this->hasAnyRoutes();
+       }
+
+       public function needsToken() {
+               if ( !$this->hasAnyRoutes() ) {
+                       return false;
+               }
+               return 'csrf';
+       }
+
+       public function getAllowedParams() {
+               if ( !$this->hasAnyRoutes() ) {
+                       return [];
+               }
+
+               $ret = [
+                       'user' => [
+                               ApiBase::PARAM_TYPE => 'user',
+                       ],
+                       'email' => [
+                               ApiBase::PARAM_TYPE => 'string',
+                       ],
+                       'capture' => false,
+               ];
+
+               $resetRoutes = $this->getConfig()->get( 'PasswordResetRoutes' );
+               if ( empty( $resetRoutes['username'] ) ) {
+                       unset( $ret['user'] );
+               }
+               if ( empty( $resetRoutes['email'] ) ) {
+                       unset( $ret['email'] );
+               }
+
+               return $ret;
+       }
+
+       protected function getExamplesMessages() {
+               $ret = [];
+               $resetRoutes = $this->getConfig()->get( 'PasswordResetRoutes' );
+
+               if ( !empty( $resetRoutes['username'] ) ) {
+                       $ret['action=resetpassword&user=Example&token=123ABC'] = 'apihelp-resetpassword-example-user';
+               }
+               if ( !empty( $resetRoutes['email'] ) ) {
+                       $ret['action=resetpassword&user=user@example.com&token=123ABC'] =
+                               'apihelp-resetpassword-example-email';
+               }
+
+               return $ret;
+       }
+
+       public function getHelpUrls() {
+               return 'https://www.mediawiki.org/wiki/API:Manage_authentication_data';
+       }
+}
index 2b23da0..43eed78 100644 (file)
@@ -34,6 +34,9 @@
        "apihelp-block-example-ip-simple": "Block IP address <kbd>192.0.2.5</kbd> for three days with reason <kbd>First strike</kbd>.",
        "apihelp-block-example-user-complex": "Block user <kbd>Vandal</kbd> indefinitely with reason <kbd>Vandalism</kbd>, and prevent new account creation and email sending.",
 
+       "apihelp-changeauthenticationdata-description": "Change authentication data for the current user.",
+       "apihelp-changeauthenticationdata-example-password": "Attempt to change the current user's password to <kbd>ExamplePassword</kbd>.",
+
        "apihelp-checktoken-description": "Check the validity of a token from <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
        "apihelp-checktoken-param-type": "Type of token being tested.",
        "apihelp-checktoken-param-token": "Token to test.",
        "apihelp-clearhasmsg-description": "Clears the <code>hasmsg</code> flag for the current user.",
        "apihelp-clearhasmsg-example-1": "Clear the <code>hasmsg</code> flag for the current user.",
 
+       "apihelp-clientlogin-description": "Log in to the wiki using the interactive flow.",
+       "apihelp-clientlogin-example-login": "Start the process of logging in to the wiki as user <kbd>Example</kbd> with password <kbd>ExamplePassword</kbd>.",
+       "apihelp-clientlogin-example-login2": "Continue logging in after a UI response for two-factor auth, supplying an <var>OATHToken</var> of <kbd>987654</kbd>.",
+
        "apihelp-compare-description": "Get the difference between 2 pages.\n\nA revision number, a page title, or a page ID for both \"from\" and \"to\" must be passed.",
        "apihelp-compare-param-fromtitle": "First title to compare.",
        "apihelp-compare-param-fromid": "First page ID to compare.",
@@ -53,6 +60,7 @@
        "apihelp-compare-example-1": "Create a diff between revision 1 and 2.",
 
        "apihelp-createaccount-description": "Create a new user account.",
+       "apihelp-createaccount-example-create": "Start the process of creating user <kbd>Example</kbd> with password <kbd>ExamplePassword</kbd>.",
        "apihelp-createaccount-param-name": "Username.",
        "apihelp-createaccount-param-password": "Password (ignored if <var>$1mailpassword</var> is set).",
        "apihelp-createaccount-param-domain": "Domain for external authentication (optional).",
        "apihelp-import-param-rootpage": "Import as subpage of this page. Cannot be used together with <var>$1namespace</var>.",
        "apihelp-import-example-import": "Import [[meta:Help:ParserFunctions]] to namespace 100 with full history.",
 
-       "apihelp-login-description": "Log in and get authentication cookies.\n\nIn the event of a successful log-in, the needed cookies will be included in the HTTP response headers. In the event of a failed log-in, further attempts may be throttled to limit automated password guessing attacks.",
+       "apihelp-linkaccount-description": "Link an account from a third-party provider to the current user.",
+       "apihelp-linkaccount-example-link": "Start the process of linking to an account from <kbd>Example</kbd>.",
+
+       "apihelp-login-description": "Log in and get authentication cookies.\n\nThis action should only be used in combination with [[Special:BotPasswords]]; use for main-account login is deprecated and may fail without warning. To safely log in to the main account, use <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+       "apihelp-login-description-nobotpasswords": "Log in and get authentication cookies.\n\nThis action is deprecated and may fail without warning. To safely log in, use <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+       "apihelp-login-description-nonauthmanager": "Log in and get authentication cookies.\n\nIn the event of a successful log-in, the needed cookies will be included in the HTTP response headers. In the event of a failed log-in, further attempts may be throttled to limit automated password guessing attacks.",
        "apihelp-login-param-name": "User name.",
        "apihelp-login-param-password": "Password.",
        "apihelp-login-param-domain": "Domain (optional).",
        "apihelp-query+allusers-param-attachedwiki": "With <kbd>$1prop=centralids</kbd>, also indicate whether the user is attached with the wiki identified by this ID.",
        "apihelp-query+allusers-example-Y": "List users starting at <kbd>Y</kbd>.",
 
+       "apihelp-query+authmanagerinfo-description": "Retrieve information about the current authentication status.",
+       "apihelp-query+authmanagerinfo-param-securitysensitiveoperation": "Test whether the user's current authentication status is sufficient for the specified security-sensitive operation.",
+       "apihelp-query+authmanagerinfo-param-requestsfor": "Fetch information about the authentication requests needed for the specified authentication action.",
+       "apihelp-query+filerepoinfo-example-login": "Fetch the requests that may be used when beginning a login.",
+       "apihelp-query+filerepoinfo-example-login-merged": "Fetch the requests that may be used when beginning a login, with form fields merged.",
+       "apihelp-query+filerepoinfo-example-securitysensitiveoperation": "Test whether authentication is sufficient for action <kbd>foo</kbd>.",
+
        "apihelp-query+backlinks-description": "Find all pages that link to the given page.",
        "apihelp-query+backlinks-param-title": "Title to search. Cannot be used together with <var>$1pageid</var>.",
        "apihelp-query+backlinks-param-pageid": "Page ID to search. Cannot be used together with <var>$1title</var>.",
        "apihelp-query+users-paramvalue-prop-emailable": "Tags if the user can and wants to receive email through [[Special:Emailuser]].",
        "apihelp-query+users-paramvalue-prop-gender": "Tags the gender of the user. Returns \"male\", \"female\", or \"unknown\".",
        "apihelp-query+users-paramvalue-prop-centralids": "Adds the central IDs and attachment status for the user.",
+       "apihelp-query+users-paramvalue-prop-cancreate": "Indicates whether an account for valid but unregistered usernames can be created.",
        "apihelp-query+users-param-attachedwiki": "With <kbd>$1prop=centralids</kbd>, indicate whether the user is attached with the wiki identified by this ID.",
        "apihelp-query+users-param-users": "A list of users to obtain information for.",
        "apihelp-query+users-param-token": "Use <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd> instead.",
        "apihelp-query+watchlistraw-example-simple": "List pages on the current user's watchlist.",
        "apihelp-query+watchlistraw-example-generator": "Fetch page info for pages on the current user's watchlist.",
 
+       "apihelp-removeauthenticationdata-description": "Remove authentication data for the current user.",
+       "apihelp-removeauthenticationdata-example-simple": "Attempt to remove the current user's data for <kbd>FooAuthenticationRequest</kbd>.",
+
+       "apihelp-resetpassword-description": "Send a password reset email to a user.",
+       "apihelp-resetpassword-description-noroutes": "No password reset routes are available.\n\nEnable routes in <var>[[mw:Manual:$wgPasswordResetRoutes|$wgPasswordResetRoutes]]</var> to use this module.",
+       "apihelp-resetpassword-param-user": "User being reset.",
+       "apihelp-resetpassword-param-email": "Email address of the user being reset.",
+       "apihelp-resetpassword-param-capture": "Return the temporary passwords that were sent. Requires the <code>passwordreset</code> user right.",
+       "apihelp-resetpassword-example-user": "Send a password reset email to user <kbd>Example</kbd>.",
+       "apihelp-resetpassword-example-email": "Send a password reset email for all users with email address <kbd>user@example.com</kbd>.",
+
        "apihelp-revisiondelete-description": "Delete and undelete revisions.",
        "apihelp-revisiondelete-param-type": "Type of revision deletion being performed.",
        "apihelp-revisiondelete-param-target": "Page title for the revision deletion, if required for the type.",
        "apihelp-undelete-example-page": "Undelete page <kbd>Main Page</kbd>.",
        "apihelp-undelete-example-revisions": "Undelete two revisions of page <kbd>Main Page</kbd>.",
 
+       "apihelp-unlinkaccount-description": "Remove a linked third-party account from the current user.",
+       "apihelp-unlinkaccount-example-simple": "Attempt to remove the current user's link for the provider associated with <kbd>FooAuthenticationRequest</kbd>.",
+
        "apihelp-upload-description": "Upload a file, or get the status of pending uploads.\n\nSeveral methods are available:\n* Upload file contents directly, using the <var>$1file</var> parameter.\n* Upload the file in pieces, using the <var>$1filesize</var>, <var>$1chunk</var>, and <var>$1offset</var> parameters.\n* Have the MediaWiki server fetch a file from a URL, using the <var>$1url</var> parameter.\n* Complete an earlier upload that failed due to warnings, using the <var>$1filekey</var> parameter.\nNote that the HTTP POST must be done as a file upload (i.e. using <code>multipart/form-data</code>) when sending the <var>$1file</var>.",
        "apihelp-upload-param-filename": "Target filename.",
        "apihelp-upload-param-comment": "Upload comment. Also used as the initial page text for new files if <var>$1text</var> is not specified.",
        "api-help-right-apihighlimits": "Use higher limits in API queries (slow queries: $1; fast queries: $2). The limits for slow queries also apply to multivalue parameters.",
        "api-help-open-in-apisandbox": "<small>[open in sandbox]</small>",
 
+       "api-help-authmanager-general-usage": "The general procedure to use this module is:\n# Fetch the fields available from <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> with <kbd>amirequestsfor=$4</kbd>, and a <kbd>$5</kbd> token from <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]].\n# Present the fields to the user, and obtain their submission.\n# Post to this module, supplying <var>$1returnurl</var> and any relevant fields.\n# Check the <samp>status</samp> in the response.\n#* If you received <samp>PASS</samp> or <samp>FAIL</samp>, you're done. The operation either succeeded or it didn't.\n#* If you received <samp>UI</samp>, present the new fields to the user and obtain their submission. Then post to this module with <var>$1continue</var> and the relevant fields set, and repeat step 4.\n#* If you received <samp>REDIRECT</samp>, direct the user to the <samp>redirecttarget</samp> and wait for the return to <var>$1returnurl</var>. Then post to this module with <var>$1continue</var> and any fields passed to the return URL, and repeat step 4.\n#* If you received <samp>RESTART</samp>, that means the authentication worked but we don't have an linked user account. You might treat this as UI or as FAIL.",
+       "api-help-authmanagerhelper-requests": "Only use these authentication requests, by the <samp>id</samp> returned from <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> with <kbd>amirequestsfor=$1</kbd> or from a previous response from this module.",
+       "api-help-authmanagerhelper-request": "Use this authentication request, by the <samp>id</samp> returned from <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> with <kbd>amirequestsfor=$1</kbd>.",
+       "api-help-authmanagerhelper-messageformat": "Format to use for returning messages.",
+       "api-help-authmanagerhelper-mergerequestfields": "Merge field information for all authentication requests into one array.",
+       "api-help-authmanagerhelper-preservestate": "Preserve state from a previous failed login attempt, if possible.",
+       "api-help-authmanagerhelper-returnurl": "Return URL for third-party authentication flows, must be absolute. Either this or <var>$1continue</var> is required.\n\nUpon receiving a <samp>REDIRECT</samp> response, you will typically open a browser or web view to the specified <samp>redirecttarget</samp> URL for a third-party authentication flow. When that completes, the third party will send the browser or web view to this URL. You should extract any query or POST parameters from the URL and pass them as a <var>$1continue</var> request to this API module.",
+       "api-help-authmanagerhelper-continue": "This request is a continuation after an earlier <samp>UI</samp> or <samp>REDIRECT</samp> response. Either this or <var>$1returnurl</var> is required.",
+       "api-help-authmanagerhelper-additional-params": "This module accepts additional parameters depending on the available authentication requests. Use <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> with <kbd>amirequestsfor=$1</kbd> (or a previous response from this module, if applicable) to determine the requests available and the fields that they use.",
+
        "api-credits-header": "Credits",
        "api-credits": "API developers:\n* Yuri Astrakhan (creator, lead developer Sep 2006–Sep 2007)\n* Roan Kattouw (lead developer Sep 2007–2009)\n* Victor Vasiliev\n* Bryan Tong Minh\n* Sam Reed\n* Brad Jorsch (lead developer 2013–present)\n\nPlease send your comments, suggestions and questions to mediawiki-api@lists.wikimedia.org\nor file a bug report at https://phabricator.wikimedia.org/."
 }
index 713dfc5..a33dedf 100644 (file)
@@ -45,6 +45,7 @@
        "apihelp-checktoken-example-simple": "<kbd>csrf</kbd> 토큰의 유효성을 테스트합니다.",
        "apihelp-clearhasmsg-description": "현재 사용자의 <code>hasmsg</code> 플래그를 비웁니다.",
        "apihelp-clearhasmsg-example-1": "현재 계정의 <code>hasmsg</code> 플래그를 삭제합니다.",
+       "apihelp-compare-description": "두 문서 간의 차이를 가져옵니다.\n\n대상이 되는 두 문서의 판 번호나 문서 제목 또는 문서 ID를 지정해야 합니다.",
        "apihelp-compare-param-fromtitle": "비교할 첫 이름.",
        "apihelp-compare-param-fromid": "비교할 첫 문서 ID.",
        "apihelp-compare-param-fromrev": "비교할 첫 판.",
@@ -66,6 +67,7 @@
        "apihelp-delete-description": "문서 삭제",
        "apihelp-delete-param-pageid": "삭제할 문서의 ID. <var>$1title</var>과 함께 사용할 수 없습니다.",
        "apihelp-delete-param-reason": "삭제의 이유. 설정하지 않으면 자동 생성되는 이유를 사용합니다.",
+       "apihelp-delete-param-watch": "문서를 현재 사용자의 주시문서 목록에 추가합니다.",
        "apihelp-delete-param-unwatch": "문서를 현재 사용자의 주시문서 목록에서 제거합니다.",
        "apihelp-delete-example-simple": "<kbd>Main Page</kbd>를 삭제합니다.",
        "apihelp-disabled-description": "이 모듈은 해제되었습니다.",
@@ -76,6 +78,8 @@
        "apihelp-edit-param-minor": "사소한 편집.",
        "apihelp-edit-param-notminor": "사소하지 않은 편집.",
        "apihelp-edit-param-bot": "이 편집을 봇으로 표시.",
+       "apihelp-edit-param-nocreate": "페이지가 존재하지 않으면 오류를 출력합니다.",
+       "apihelp-edit-param-watch": "문서를 현재 사용자의 주시문서 목록에 추가합니다.",
        "apihelp-edit-param-unwatch": "문서를 현재 사용자의 주시문서 목록에서 제거합니다.",
        "apihelp-edit-param-redirect": "자동으로 넘겨주기 처리하기.",
        "apihelp-edit-param-contentmodel": "새 콘텐츠의 콘텐츠 모델.",
index 4ded4aa..37bb4b0 100644 (file)
@@ -41,6 +41,8 @@
        "apihelp-block-param-watchuser": "{{doc-apihelp-param|block|watchuser}}",
        "apihelp-block-example-ip-simple": "{{doc-apihelp-example|block}}",
        "apihelp-block-example-user-complex": "{{doc-apihelp-example|block}}",
+       "apihelp-changeauthenticationdata-description": "{{doc-apihelp-description|changeauthenticationdata}}",
+       "apihelp-changeauthenticationdata-example-password": "{{doc-apihelp-example|changeauthenticationdata}}",
        "apihelp-checktoken-description": "{{doc-apihelp-description|checktoken}}",
        "apihelp-checktoken-param-type": "{{doc-apihelp-param|checktoken|type}}",
        "apihelp-checktoken-param-token": "{{doc-apihelp-param|checktoken|token}}",
@@ -48,6 +50,9 @@
        "apihelp-checktoken-example-simple": "{{doc-apihelp-example|checktoken}}",
        "apihelp-clearhasmsg-description": "{{doc-apihelp-description|clearhasmsg}}",
        "apihelp-clearhasmsg-example-1": "{{doc-apihelp-example|clearhasmsg}}",
+       "apihelp-clientlogin-description": "{{doc-apihelp-description|clientlogin}}",
+       "apihelp-clientlogin-example-login": "{{doc-apihelp-example|clientlogin}}",
+       "apihelp-clientlogin-example-login2": "{{doc-apihelp-example|clientlogin}}",
        "apihelp-compare-description": "{{doc-apihelp-description|compare}}",
        "apihelp-compare-param-fromtitle": "{{doc-apihelp-param|compare|fromtitle}}",
        "apihelp-compare-param-fromid": "{{doc-apihelp-param|compare|fromid}}",
@@ -57,6 +62,7 @@
        "apihelp-compare-param-torev": "{{doc-apihelp-param|compare|torev}}",
        "apihelp-compare-example-1": "{{doc-apihelp-example|compare}}",
        "apihelp-createaccount-description": "{{doc-apihelp-description|createaccount}}",
+       "apihelp-createaccount-example-create": "{{doc-apihelp-example|createaccount}}",
        "apihelp-createaccount-param-name": "{{doc-apihelp-param|createaccount|name}}\n{{Identical|Username}}",
        "apihelp-createaccount-param-password": "{{doc-apihelp-param|createaccount|password}}",
        "apihelp-createaccount-param-domain": "{{doc-apihelp-param|createaccount|domain}}",
        "apihelp-import-param-namespace": "{{doc-apihelp-param|import|namespace}}",
        "apihelp-import-param-rootpage": "{{doc-apihelp-param|import|rootpage}}",
        "apihelp-import-example-import": "{{doc-apihelp-example|import}}",
-       "apihelp-login-description": "{{doc-apihelp-description|login}}",
+       "apihelp-linkaccount-description": "{{doc-apihelp-description|linkaccount}}",
+       "apihelp-linkaccount-example-link": "{{doc-apihelp-example|linkaccount}}",
+       "apihelp-login-description": "{{doc-apihelp-description|login|info=This message is used when <code>$wgEnableBotPasswords</code> is true.|seealso=* {{msg-mw|apihelp-login-description-nobotpasswords}}}}",
+       "apihelp-login-description-nobotpasswords": "{{doc-apihelp-description|login|info=This message is used when <code>$wgEnableBotPasswords</code> is false.|seealso=* {{msg-mw|apihelp-login-description}}}}",
+       "apihelp-login-description-nonauthmanager": "{{doc-apihelp-description|login|info=This message is used when <code>$wgDisableAuthManager</code> is true.|seealso=* {{msg-mw|apihelp-login-description}}}}",
        "apihelp-login-param-name": "{{doc-apihelp-param|login|name}}\n{{Identical|Username}}",
        "apihelp-login-param-password": "{{doc-apihelp-param|login|password}}\n{{Identical|Password}}",
        "apihelp-login-param-domain": "{{doc-apihelp-param|login|domain}}",
        "apihelp-query+allusers-param-activeusers": "{{doc-apihelp-param|query+allusers|activeusers|params=* $1 - Value of [[mw:Manual:$wgActiveUserDays]]|paramstart=2}}",
        "apihelp-query+allusers-param-attachedwiki": "{{doc-apihelp-param|query+allusers|attachedwiki}}",
        "apihelp-query+allusers-example-Y": "{{doc-apihelp-example|query+allusers}}",
+       "apihelp-query+authmanagerinfo-description": "{{doc-apihelp-description|query+authmanagerinfo}}",
+       "apihelp-query+authmanagerinfo-param-requestsfor": "{{doc-apihelp-param|query+authmanagerinfo|requestsfor}}",
+       "apihelp-query+authmanagerinfo-param-securitysensitiveoperation": "{{doc-apihelp-param|query+authmanagerinfo|securitysensitiveoperation}}",
+       "apihelp-query+filerepoinfo-example-login": "{{doc-apihelp-example|query+filerepoinfo}}",
+       "apihelp-query+filerepoinfo-example-login-merged": "{{doc-apihelp-example|query+filerepoinfo}}",
+       "apihelp-query+filerepoinfo-example-securitysensitiveoperation": "{{doc-apihelp-example|query+filerepoinfo}}",
        "apihelp-query+backlinks-description": "{{doc-apihelp-description|query+backlinks}}",
        "apihelp-query+backlinks-param-title": "{{doc-apihelp-param|query+backlinks|title}}",
        "apihelp-query+backlinks-param-pageid": "{{doc-apihelp-param|query+backlinks|pageid}}",
        "apihelp-query+users-paramvalue-prop-emailable": "{{doc-apihelp-paramvalue|query+users|prop|emailable}}",
        "apihelp-query+users-paramvalue-prop-gender": "{{doc-apihelp-paramvalue|query+users|prop|gender}}",
        "apihelp-query+users-paramvalue-prop-centralids": "{{doc-apihelp-paramvalue|query+users|prop|centralids}}",
+       "apihelp-query+users-paramvalue-prop-cancreate": "{{doc-apihelp-paramvalue|query+users|prop|cancreate}}",
        "apihelp-query+users-param-attachedwiki": "{{doc-apihelp-param|query+users|attachedwiki}}",
        "apihelp-query+users-param-users": "{{doc-apihelp-param|query+users|users}}",
        "apihelp-query+users-param-token": "{{doc-apihelp-param|query+users|token}}",
        "apihelp-query+watchlistraw-param-totitle": "{{doc-apihelp-param|query+watchlistraw|totitle}}",
        "apihelp-query+watchlistraw-example-simple": "{{doc-apihelp-example|query+watchlistraw}}",
        "apihelp-query+watchlistraw-example-generator": "{{doc-apihelp-example|query+watchlistraw}}",
+       "apihelp-removeauthenticationdata-description": "{{doc-apihelp-description|removeauthenticationdata}}",
+       "apihelp-removeauthenticationdata-example-simple": "{{doc-apihelp-example|removeauthenticationdata}}",
+       "apihelp-resetpassword-description": "{{doc-apihelp-description|resetpassword|seealso=* {{msg-mw|apihelp-resetpassword-description-noroutes}}}}",
+       "apihelp-resetpassword-description-noroutes": "{{doc-apihelp-description|resetpassword|info=This message is used when no known routes are enabled in <var>[[mw:Manual:$wgPasswordResetRoutes|$wgPasswordResetRoutes]]</var>.|seealso={{msg-mw|apihelp-resetpassword-description}}}}",
+       "apihelp-resetpassword-param-user": "{{doc-apihelp-param|resetpassword|user}}",
+       "apihelp-resetpassword-param-email": "{{doc-apihelp-param|resetpassword|email}}",
+       "apihelp-resetpassword-param-capture": "{{doc-apihelp-param|resetpassword|capture}}",
+       "apihelp-resetpassword-example-email": "{{doc-apihelp-example|resetpassword}}",
+       "apihelp-resetpassword-example-user": "{{doc-apihelp-example|resetpassword}}",
        "apihelp-revisiondelete-description": "{{doc-apihelp-description|revisiondelete}}",
        "apihelp-revisiondelete-param-type": "{{doc-apihelp-param|revisiondelete|type}}",
        "apihelp-revisiondelete-param-target": "{{doc-apihelp-param|revisiondelete|target}}",
        "apihelp-undelete-param-watchlist": "{{doc-apihelp-param|undelete|watchlist}}",
        "apihelp-undelete-example-page": "{{doc-apihelp-example|undelete}}",
        "apihelp-undelete-example-revisions": "{{doc-apihelp-example|undelete}}",
+       "apihelp-unlinkaccount-description": "{{doc-apihelp-description|unlinkaccount}}",
+       "apihelp-unlinkaccount-example-simple": "{{doc-apihelp-example|unlinkaccount}}",
        "apihelp-upload-description": "{{doc-apihelp-description|upload}}",
        "apihelp-upload-param-filename": "{{doc-apihelp-param|upload|filename}}",
        "apihelp-upload-param-comment": "{{doc-apihelp-param|upload|comment}}",
        "api-help-permissions-granted-to": "Used to introduce the list of groups each permission is assigned to.\n\nParameters:\n* $1 - Number of groups\n* $2 - List of group names, comma-separated",
        "api-help-right-apihighlimits": "{{technical}}{{doc-right|apihighlimits|prefix=api-help}}\nThis message is used instead of {{msg-mw|right-apihighlimits}} in the API help to display the actual limits.\n\nParameters:\n* $1 - Limit for slow queries\n* $2 - Limit for fast queries",
        "api-help-open-in-apisandbox": "Text for the link to open an API example in [[Special:ApiSandbox]].",
+       "api-help-authmanager-general-usage": "Text giving a brief overview of how to use an AuthManager-using API module. Parameters:\n* $1 - Module parameter prefix, e.g. \"login\"\n* $2 - Module name, e.g. \"clientlogin\"\n* $3 - Module path, e.g. \"clientlogin\"\n* $4 - AuthManager action to use with this module.\n* $5 - Token type needed by the module.",
+       "api-help-authmanagerhelper-additional-params": "Message to display for AuthManager modules that take additional parameters to populate AuthenticationRequests. Parameters:\n* $1 - AuthManager action used by this module\n* $2 - Module parameter prefix, e.g. \"login\"\n* $3 - Module name, e.g. \"clientlogin\"\n* $4 - Module path, e.g. \"clientlogin\"",
+       "api-help-authmanagerhelper-requests": "{{doc-apihelp-param|description=the \"requests\" parameter for AuthManager-using API modules|params=* $1 - AuthManager action used by this module|paramstart=2|noseealso=1}}",
+       "api-help-authmanagerhelper-request": "{{doc-apihelp-param|description=the \"request\" parameter for AuthManager-using API modules|params=* $1 - AuthManager action used by this module|paramstart=2|noseealso=1}}",
+       "api-help-authmanagerhelper-messageformat": "{{doc-apihelp-param|description=the \"messageformat\" parameter for AuthManager-using API modules|noseealso=1}}",
+       "api-help-authmanagerhelper-mergerequestfields": "{{doc-apihelp-param|description=the \"mergerequestfields\" parameter for AuthManager-using API modules|noseealso=1}}",
+       "api-help-authmanagerhelper-preservestate": "{{doc-apihelp-param|description=the \"preservestate\" parameter for AuthManager-using API modules|noseealso=1}}",
+       "api-help-authmanagerhelper-returnurl": "{{doc-apihelp-param|description=the \"returnurl\" parameter for AuthManager-using API modules|noseealso=1}}",
+       "api-help-authmanagerhelper-continue": "{{doc-apihelp-param|description=the \"continue\" parameter for AuthManager-using API modules|noseealso=1}}",
        "api-credits-header": "Header for the API credits section in the API help output\n{{Identical|Credit}}",
        "api-credits": "API credits text, displayed in the API help output"
 }
index 246b05c..5de1dee 100644 (file)
        "apihelp-query+allcategories-param-min": "Returnera endast kategorier med minst så här många medlemmar.",
        "apihelp-query+allcategories-param-max": "Returnera endast kategorier med som mest så här många medlemmar.",
        "apihelp-query+allcategories-param-limit": "Hur många kategorier att returnera.",
+       "apihelp-query+allcategories-paramvalue-prop-size": "Lägger till antal sidor i kategorin.",
+       "apihelp-query+allcategories-paramvalue-prop-hidden": "Märker kategorier som är dolda med <code>_&#95;HIDDENCAT_&#95;</code>.",
        "apihelp-query+alldeletedrevisions-description": "Lista alla raderade revisioner av en användare or inom en namnrymd.",
        "apihelp-query+alldeletedrevisions-paraminfo-useronly": "Kan endast användas med <var>$3user</var>.",
        "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "Kan inte användas med <var>$3user</var>.",
diff --git a/includes/auth/AbstractAuthenticationProvider.php b/includes/auth/AbstractAuthenticationProvider.php
new file mode 100644 (file)
index 0000000..9e38ecc
--- /dev/null
@@ -0,0 +1,59 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use Config;
+use Psr\Log\LoggerInterface;
+
+/**
+ * A base class that implements some of the boilerplate for an AuthenticationProvider
+ * @ingroup Auth
+ * @since 1.27
+ */
+abstract class AbstractAuthenticationProvider implements AuthenticationProvider {
+       /** @var LoggerInterface */
+       protected $logger;
+       /** @var AuthManager */
+       protected $manager;
+       /** @var Config */
+       protected $config;
+
+       public function setLogger( LoggerInterface $logger ) {
+               $this->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 (file)
index 0000000..900d2e5
--- /dev/null
@@ -0,0 +1,171 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use Password;
+use PasswordFactory;
+use Status;
+
+/**
+ * Basic framework for a primary authentication provider that uses passwords
+ * @ingroup Auth
+ * @since 1.27
+ */
+abstract class AbstractPasswordPrimaryAuthenticationProvider
+       extends AbstractPrimaryAuthenticationProvider
+{
+       /** @var bool Whether this provider should ABSTAIN (false) or FAIL (true) on password failure */
+       protected $authoritative = true;
+
+       private $passwordFactory = null;
+
+       /**
+        * @param array $params Settings
+        *  - authoritative: Whether this provider should ABSTAIN (false) or FAIL
+        *    (true) on password failure
+        */
+       public function __construct( array $params = [] ) {
+               $this->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 (file)
index 0000000..48a9c88
--- /dev/null
@@ -0,0 +1,62 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+/**
+ * A base class that implements some of the boilerplate for a PreAuthenticationProvider
+ * @ingroup Auth
+ * @since 1.27
+ */
+abstract class AbstractPreAuthenticationProvider extends AbstractAuthenticationProvider
+       implements PreAuthenticationProvider
+{
+
+       public function getAuthenticationRequests( $action, array $options ) {
+               return [];
+       }
+
+       public function testForAuthentication( array $reqs ) {
+               return \StatusValue::newGood();
+       }
+
+       public function postAuthentication( $user, AuthenticationResponse $response ) {
+       }
+
+       public function testForAccountCreation( $user, $creator, array $reqs ) {
+               return \StatusValue::newGood();
+       }
+
+       public function testUserForCreation( $user, $autocreate ) {
+               return \StatusValue::newGood();
+       }
+
+       public function postAccountCreation( $user, $creator, AuthenticationResponse $response ) {
+       }
+
+       public function testForAccountLink( $user ) {
+               return \StatusValue::newGood();
+       }
+
+       public function postAccountLink( $user, AuthenticationResponse $response ) {
+       }
+
+}
diff --git a/includes/auth/AbstractPrimaryAuthenticationProvider.php b/includes/auth/AbstractPrimaryAuthenticationProvider.php
new file mode 100644 (file)
index 0000000..2e0d669
--- /dev/null
@@ -0,0 +1,118 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use User;
+
+/**
+ * A base class that implements some of the boilerplate for a PrimaryAuthenticationProvider
+ *
+ * @ingroup Auth
+ * @since 1.27
+ */
+abstract class AbstractPrimaryAuthenticationProvider extends AbstractAuthenticationProvider
+       implements PrimaryAuthenticationProvider
+{
+
+       public function continuePrimaryAuthentication( array $reqs ) {
+               throw new \BadMethodCallException( __METHOD__ . ' is not implemented.' );
+       }
+
+       public function postAuthentication( $user, AuthenticationResponse $response ) {
+       }
+
+       public function testUserCanAuthenticate( $username ) {
+               // Assume it can authenticate if it exists
+               return $this->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 (file)
index 0000000..89fd6f9
--- /dev/null
@@ -0,0 +1,86 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+/**
+ * A base class that implements some of the boilerplate for a SecondaryAuthenticationProvider
+ *
+ * @ingroup Auth
+ * @since 1.27
+ */
+abstract class AbstractSecondaryAuthenticationProvider extends AbstractAuthenticationProvider
+       implements SecondaryAuthenticationProvider
+{
+
+       public function continueSecondaryAuthentication( $user, array $reqs ) {
+               throw new \BadMethodCallException( __METHOD__ . ' is not implemented.' );
+       }
+
+       public function postAuthentication( $user, AuthenticationResponse $response ) {
+       }
+
+       public function providerAllowsPropertyChange( $property ) {
+               return true;
+       }
+
+       /**
+        * @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;
+                       $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 (file)
index 0000000..efee53c
--- /dev/null
@@ -0,0 +1,2386 @@
+<?php
+/**
+ * Authentication (and possibly Authorization in the future) system entry point
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use Config;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Status;
+use StatusValue;
+use User;
+use WebRequest;
+
+/**
+ * This serves as the entry point to the authentication system.
+ *
+ * In the future, it may also serve as the entry point to the authorization
+ * system.
+ *
+ * @ingroup Auth
+ * @since 1.27
+ */
+class AuthManager implements LoggerAwareInterface {
+       /** Log in with an existing (not necessarily local) user */
+       const ACTION_LOGIN = 'login';
+       /** Continue a login process that was interrupted by the need for user input or communication
+        * with an external provider */
+       const ACTION_LOGIN_CONTINUE = 'login-continue';
+       /** Create a new user */
+       const ACTION_CREATE = 'create';
+       /** Continue a user creation process that was interrupted by the need for user input or
+        * communication with an external provider */
+       const ACTION_CREATE_CONTINUE = 'create-continue';
+       /** Link an existing user to a third-party account */
+       const ACTION_LINK = 'link';
+       /** Continue a user linking process that was interrupted by the need for user input or
+        * communication with an external provider */
+       const ACTION_LINK_CONTINUE = 'link-continue';
+       /** Change a user's credentials */
+       const ACTION_CHANGE = 'change';
+       /** Remove a user's credentials */
+       const ACTION_REMOVE = 'remove';
+       /** Like ACTION_REMOVE but for linking providers only */
+       const ACTION_UNLINK = 'unlink';
+
+       /** Security-sensitive operations are ok. */
+       const SEC_OK = 'ok';
+       /** Security-sensitive operations should re-authenticate. */
+       const SEC_REAUTH = 'reauth';
+       /** Security-sensitive should not be performed. */
+       const SEC_FAIL = 'fail';
+
+       /** Auto-creation is due to SessionManager */
+       const AUTOCREATE_SOURCE_SESSION = \MediaWiki\Session\SessionManager::class;
+
+       /** @var AuthManager|null */
+       private static $instance = null;
+
+       /** @var WebRequest */
+       private $request;
+
+       /** @var Config */
+       private $config;
+
+       /** @var LoggerInterface */
+       private $logger;
+
+       /** @var AuthenticationProvider[] */
+       private $allAuthenticationProviders = [];
+
+       /** @var PreAuthenticationProvider[] */
+       private $preAuthenticationProviders = null;
+
+       /** @var PrimaryAuthenticationProvider[] */
+       private $primaryAuthenticationProviders = null;
+
+       /** @var SecondaryAuthenticationProvider[] */
+       private $secondaryAuthenticationProviders = null;
+
+       /** @var CreatedAccountAuthenticationRequest[] */
+       private $createdAccountAuthenticationRequests = [];
+
+       /**
+        * Get the global AuthManager
+        * @return AuthManager
+        */
+       public static function singleton() {
+               global $wgDisableAuthManager;
+
+               if ( $wgDisableAuthManager ) {
+                       throw new \BadMethodCallException( '$wgDisableAuthManager is set' );
+               }
+
+               if ( self::$instance === null ) {
+                       self::$instance = new self(
+                               \RequestContext::getMain()->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 : '<no name>',
+                       '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 (file)
index 0000000..bf1e021
--- /dev/null
@@ -0,0 +1,251 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Auth;
+
+use User;
+
+/**
+ * Backwards-compatibility wrapper for AuthManager via $wgAuth
+ * @since 1.27
+ * @deprecated since 1.27
+ */
+class AuthManagerAuthPlugin extends \AuthPlugin {
+       /** @var string|null */
+       protected $domain = null;
+
+       /** @var \\Psr\\Log\\LoggerInterface */
+       protected $logger = null;
+
+       public function __construct() {
+               $this->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 (file)
index 0000000..9746637
--- /dev/null
@@ -0,0 +1,429 @@
+<?php
+/**
+ * Primary authentication provider wrapper for AuthPlugin
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use AuthPlugin;
+use User;
+
+/**
+ * Primary authentication provider wrapper for AuthPlugin
+ * @warning If anything depends on the wrapped AuthPlugin being $wgAuth, it won't work with this!
+ * @ingroup Auth
+ * @since 1.27
+ * @deprecated since 1.27
+ */
+class AuthPluginPrimaryAuthenticationProvider
+       extends AbstractPasswordPrimaryAuthenticationProvider
+{
+       private $auth;
+       private $hasDomain;
+       private $requestType = null;
+
+       /**
+        * @param AuthPlugin $auth AuthPlugin to wrap
+        * @param string|null $requestType Class name of the
+        *  PasswordAuthenticationRequest to use. If $auth->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 (file)
index 0000000..4db0a84
--- /dev/null
@@ -0,0 +1,93 @@
+<?php
+/**
+ * Authentication provider interface
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use Config;
+use Psr\Log\LoggerAwareInterface;
+
+/**
+ * An AuthenticationProvider is used by AuthManager when authenticating users.
+ * @ingroup Auth
+ * @since 1.27
+ */
+interface AuthenticationProvider extends LoggerAwareInterface {
+
+       /**
+        * Set AuthManager
+        * @param AuthManager $manager
+        */
+       public function setManager( AuthManager $manager );
+
+       /**
+        * Set configuration
+        * @param Config $config
+        */
+       public function setConfig( Config $config );
+
+       /**
+        * Return a unique identifier for this instance
+        *
+        * This must be the same across requests. If multiple instances return the
+        * same ID, exceptions will be thrown from AuthManager.
+        *
+        * @return string
+        */
+       public function getUniqueId();
+
+       /**
+        * Return the applicable list of AuthenticationRequests
+        *
+        * Possible values for $action depend on whether the implementing class is
+        * also a PreAuthenticationProvider, PrimaryAuthenticationProvider, or
+        * SecondaryAuthenticationProvider.
+        *  - ACTION_LOGIN: Valid for passing to beginAuthentication. Called on all
+        *    providers.
+        *  - ACTION_CREATE: Valid for passing to beginAccountCreation. Called on
+        *    all providers.
+        *  - ACTION_LINK: Valid for passing to beginAccountLink. Called on linking
+        *    primary providers only.
+        *  - ACTION_CHANGE: Valid for passing to AuthManager::changeAuthenticationData
+        *    to change credentials. Called on primary and secondary providers.
+        *  - ACTION_REMOVE: Valid for passing to AuthManager::changeAuthenticationData
+        *    to remove credentials. Must work without additional user input (i.e.
+        *    without calling loadFromSubmission). Called on primary and secondary
+        *    providers.
+        *
+        * @see AuthManager::getAuthenticationRequests()
+        * @param string $action
+        * @param array $options Options are:
+        *  - username: User name related to the action, or null/unset if anon.
+        *    - ACTION_LOGIN: The currently logged-in user, if any.
+        *    - ACTION_CREATE: The account creator, if non-anonymous.
+        *    - ACTION_LINK: The local user being linked to.
+        *    - ACTION_CHANGE: The user having data changed.
+        *    - ACTION_REMOVE: The user having data removed.
+        *    This does not need to be copied into the returned requests, you only
+        *    need to pay attention to it if the set of requests differs based on
+        *    the user.
+        * @return AuthenticationRequest[]
+        */
+       public function getAuthenticationRequests( $action, array $options );
+
+}
diff --git a/includes/auth/AuthenticationRequest.php b/includes/auth/AuthenticationRequest.php
new file mode 100644 (file)
index 0000000..3c19b87
--- /dev/null
@@ -0,0 +1,338 @@
+<?php
+/**
+ * Authentication request value object
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use Message;
+
+/**
+ * This is a value object for authentication requests.
+ *
+ * An AuthenticationRequest represents a set of form fields that are needed on
+ * and provided from the login, account creation, or password change forms.
+ *
+ * @ingroup Auth
+ * @since 1.27
+ */
+abstract class AuthenticationRequest {
+
+       /** Indicates that the request is not required for authentication to proceed. */
+       const OPTIONAL = 0;
+
+       /** Indicates that the request is required for authentication to proceed. */
+       const REQUIRED = 1;
+
+       /** Indicates that the request is required by a primary authentication
+        * provdier, but other primary authentication providers do not require it. */
+       const PRIMARY_REQUIRED = 2;
+
+       /** @var string|null The AuthManager::ACTION_* constant this request was
+        * created to be used for. The *_CONTINUE constants are not used here, the
+        * corresponding "begin" constant is used instead.
+        */
+       public $action = null;
+
+       /** @var int For login, continue, and link actions, one of self::OPTIONAL,
+        * self::REQUIRED, or self::PRIMARY_REQUIRED */
+       public $required = self::REQUIRED;
+
+       /** @var string|null Return-to URL, in case of redirect */
+       public $returnToUrl = null;
+
+       /** @var string|null Username. May not be used by all subclasses. */
+       public $username = null;
+
+       /**
+        * Supply a unique key for deduplication
+        *
+        * When the AuthenticationRequests instances returned by the providers are
+        * merged, the value returned here is used for keeping only one copy of
+        * duplicate requests.
+        *
+        * Subclasses should override this if multiple distinct instances would
+        * make sense, i.e. the request class has internal state of some sort.
+        *
+        * This value might be exposed to the user in web forms so it should not
+        * contain private information.
+        *
+        * @return string
+        */
+       public function getUniqueId() {
+               return get_called_class();
+       }
+
+       /**
+        * Fetch input field info
+        *
+        * The field info is an associative array mapping field names to info
+        * arrays. The info arrays have the following keys:
+        *  - type: (string) Type of input. Types and equivalent HTML widgets are:
+        *     - string: <input type="text">
+        *     - password: <input type="password">
+        *     - select: <select>
+        *     - checkbox: <input type="checkbox">
+        *     - multiselect: More a grid of checkboxes than <select multi>
+        *     - button: <input type="image"> if 'image' is set, otherwise <input type="submit">
+        *       (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 (file)
index 0000000..db01825
--- /dev/null
@@ -0,0 +1,190 @@
+<?php
+/**
+ * Authentication response value object
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use Message;
+
+/**
+ * This is a value object to hold authentication response data
+ * @ingroup Auth
+ * @since 1.27
+ */
+class AuthenticationResponse {
+       /** Indicates that the authentication succeeded. */
+       const PASS = 'PASS';
+
+       /** Indicates that the authentication failed. */
+       const FAIL = 'FAIL';
+
+       /** Indicates that third-party authentication succeeded but no user exists.
+        * Either treat this like a UI response or pass $this->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 (file)
index 0000000..055d7ea
--- /dev/null
@@ -0,0 +1,106 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use Message;
+
+/**
+ * This is an authentication request that just implements a simple button.
+ * @ingroup Auth
+ * @since 1.27
+ */
+class ButtonAuthenticationRequest extends AuthenticationRequest {
+       /** @var string */
+       protected $name;
+
+       /** @var Message */
+       protected $label;
+
+       /** @var Message */
+       protected $help;
+
+       /**
+        * @param string $name Button name
+        * @param Message $label Button label
+        * @param Message $help Button help
+        * @param bool $required The button is required for authentication to proceed.
+        */
+       public function __construct( $name, Message $label, Message $help, $required = false ) {
+               $this->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 (file)
index 0000000..070da9f
--- /dev/null
@@ -0,0 +1,102 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use Config;
+use StatusValue;
+use User;
+
+/**
+ * Check if the user is blocked, and prevent authentication if so.
+ *
+ * @ingroup Auth
+ * @since 1.27
+ */
+class CheckBlocksSecondaryAuthenticationProvider extends AbstractSecondaryAuthenticationProvider {
+
+       /** @var bool */
+       protected $blockDisablesLogin = null;
+
+       /**
+        * @param array $params
+        *  - blockDisablesLogin: (bool) Whether blocked accounts can log in,
+        *    defaults to $wgBlockDisablesLogin
+        */
+       public function __construct( $params = [] ) {
+               if ( isset( $params['blockDisablesLogin'] ) ) {
+                       $this->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 (file)
index 0000000..b82914f
--- /dev/null
@@ -0,0 +1,80 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+class ConfirmLinkAuthenticationRequest extends AuthenticationRequest {
+       /** @var AuthenticationRequest[] */
+       protected $linkRequests;
+
+       /** @var string[] List of unique IDs of the confirmed accounts. */
+       public $confirmedLinkIDs = [];
+
+       /**
+        * @param AuthenticationRequest[] $linkRequests A list of autolink requests
+        *  which need to be confirmed.
+        */
+       public function __construct( array $linkRequests ) {
+               if ( !$linkRequests ) {
+                       throw new \InvalidArgumentException( '$linkRequests must not be empty' );
+               }
+               $this->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 (file)
index 0000000..180aaae
--- /dev/null
@@ -0,0 +1,150 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+use StatusValue;
+use User;
+
+/**
+ * Links third-party authentication to the user's account
+ *
+ * If the user logged into linking provider accounts that aren't linked to a
+ * local user, this provider will prompt the user to link them after a
+ * successful login or account creation.
+ *
+ * To avoid confusing behavior, this provider should be later in the
+ * configuration list than any provider that can abort the authentication
+ * process, so that it is only invoked for successful authentication.
+ */
+class ConfirmLinkSecondaryAuthenticationProvider extends AbstractSecondaryAuthenticationProvider {
+
+       public function getAuthenticationRequests( $action, array $options ) {
+               return [];
+       }
+
+       public function beginSecondaryAuthentication( $user, array $reqs ) {
+               return $this->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 (file)
index 0000000..949302d
--- /dev/null
@@ -0,0 +1,62 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+/**
+ * This transfers state between the login and account creation flows.
+ *
+ * AuthManager::getAuthenticationRequests() won't return this type, but it
+ * may be passed to AuthManager::beginAccountCreation() anyway.
+ *
+ * @ingroup Auth
+ * @since 1.27
+ */
+class CreateFromLoginAuthenticationRequest extends AuthenticationRequest {
+       public $required = self::OPTIONAL;
+
+       /** @var AuthenticationRequest|null */
+       public $createRequest;
+
+       /** @var AuthenticationRequest[] */
+       public $maybeLink = [];
+
+       /**
+        * @param AuthenticationRequest|null $createRequest A request to use to
+        *  begin creating the account
+        * @param AuthenticationRequest[] $maybeLink Additional accounts to link
+        *  after creation.
+        */
+       public function __construct(
+               AuthenticationRequest $createRequest = null, array $maybeLink = []
+       ) {
+               $this->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 (file)
index 0000000..48a6e1d
--- /dev/null
@@ -0,0 +1,48 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+/**
+ * Returned from account creation to allow for logging into the created account
+ * @ingroup Auth
+ * @since 1.27
+ */
+class CreatedAccountAuthenticationRequest extends AuthenticationRequest {
+
+       public $required = self::OPTIONAL;
+
+       /** @var int User id */
+       public $id;
+
+       public function getFieldInfo() {
+               return [];
+       }
+
+       /**
+        * @param int $id User id
+        * @param string $name Username
+        */
+       public function __construct( $id, $name ) {
+               $this->id = $id;
+               $this->username = $name;
+       }
+}
diff --git a/includes/auth/CreationReasonAuthenticationRequest.php b/includes/auth/CreationReasonAuthenticationRequest.php
new file mode 100644 (file)
index 0000000..1711aec
--- /dev/null
@@ -0,0 +1,22 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * Authentication request for the reason given for account creation.
+ * Used in logs and for notification.
+ */
+class CreationReasonAuthenticationRequest extends AuthenticationRequest {
+       /** @var string Account creation reason (only used when creating for someone else) */
+       public $reason;
+
+       public function getFieldInfo() {
+               return [
+                       'reason' => [
+                               'type' => 'string',
+                               'label' => wfMessage( 'createacct-reason' ),
+                               'help' => wfMessage( 'createacct-reason-help' ),
+                       ],
+               ];
+       }
+}
diff --git a/includes/auth/EmailNotificationSecondaryAuthenticationProvider.php b/includes/auth/EmailNotificationSecondaryAuthenticationProvider.php
new file mode 100644 (file)
index 0000000..c632e3c
--- /dev/null
@@ -0,0 +1,67 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+use Config;
+use StatusValue;
+
+/**
+ * Handles email notification / email address confirmation for account creation.
+ *
+ * Set 'no-email' to true (via AuthManager::setAuthenticationSessionData) to skip this provider.
+ * Primary providers doing so are expected to take care of email address confirmation.
+ */
+class EmailNotificationSecondaryAuthenticationProvider
+       extends AbstractSecondaryAuthenticationProvider
+{
+       /** @var bool */
+       protected $sendConfirmationEmail;
+
+       /**
+        * @param array $params
+        *  - sendConfirmationEmail: (bool) send an email asking the user to confirm their email
+        *    address after a successful registration
+        */
+       public function __construct( $params = [] ) {
+               if ( isset( $params['sendConfirmationEmail'] ) ) {
+                       $this->sendConfirmationEmail = (bool)$params['sendConfirmationEmail'];
+               }
+       }
+
+       public function setConfig( Config $config ) {
+               parent::setConfig( $config );
+
+               if ( $this->sendConfirmationEmail === null ) {
+                       $this->sendConfirmationEmail = $this->config->get( 'EnableEmail' )
+                               && $this->config->get( 'EmailAuthentication' );
+               }
+       }
+
+       public function getAuthenticationRequests( $action, array $options ) {
+               return [];
+       }
+
+       public function beginSecondaryAuthentication( $user, array $reqs ) {
+               return AuthenticationResponse::newAbstain();
+       }
+
+       public function beginSecondaryAccountCreation( $user, $creator, array $reqs ) {
+               if (
+                       $this->sendConfirmationEmail
+                       && $user->getEmail()
+                       && !$this->manager->getAuthenticationSessionData( 'no-email' )
+               ) {
+                       $status = $user->sendConfirmationMail();
+                       $user->saveSettings();
+                       if ( $status->isGood() ) {
+                               // TODO show 'confirmemail_oncreate' success message
+                       } else {
+                               // TODO show 'confirmemail_sendfailed' error message
+                               $this->logger->warning( 'Could not send confirmation email: ' .
+                                       $status->getWikiText( false, false, 'en' ) );
+                       }
+               }
+
+               return AuthenticationResponse::newPass();
+       }
+}
diff --git a/includes/auth/LegacyHookPreAuthenticationProvider.php b/includes/auth/LegacyHookPreAuthenticationProvider.php
new file mode 100644 (file)
index 0000000..1a8a758
--- /dev/null
@@ -0,0 +1,202 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use LoginForm;
+use StatusValue;
+use User;
+
+/**
+ * A pre-authentication provider to call some legacy hooks.
+ * @ingroup Auth
+ * @since 1.27
+ * @deprecated since 1.27
+ */
+class LegacyHookPreAuthenticationProvider extends AbstractPreAuthenticationProvider {
+
+       public function testForAuthentication( array $reqs ) {
+               $req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class );
+               if ( $req ) {
+                       $user = User::newFromName( $req->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 (file)
index 0000000..5f5ef79
--- /dev/null
@@ -0,0 +1,314 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use User;
+
+/**
+ * A primary authentication provider that uses the password field in the 'user' table.
+ * @ingroup Auth
+ * @since 1.27
+ */
+class LocalPasswordPrimaryAuthenticationProvider
+       extends AbstractPasswordPrimaryAuthenticationProvider
+{
+
+       /** @var bool If true, this instance is for legacy logins only. */
+       protected $loginOnly = false;
+
+       /**
+        * @param array $params Settings
+        *  - loginOnly: If true, the local passwords are for legacy logins only:
+        *    the local password will be invalidated when authentication is changed
+        *    and new users will not have a valid local password set.
+        */
+       public function __construct( $params = [] ) {
+               parent::__construct( $params );
+               $this->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 (file)
index 0000000..187c29a
--- /dev/null
@@ -0,0 +1,83 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+/**
+ * This is a value object for authentication requests with a username and password
+ * @ingroup Auth
+ * @since 1.27
+ */
+class PasswordAuthenticationRequest extends AuthenticationRequest {
+       /** @var string Password */
+       public $password = null;
+
+       /** @var string Password, again */
+       public $retype = null;
+
+       public function getFieldInfo() {
+               if ( $this->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 (file)
index 0000000..ddad54b
--- /dev/null
@@ -0,0 +1,83 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+/**
+ * This is a value object for authentication requests with a username, password, and domain
+ * @ingroup Auth
+ * @since 1.27
+ */
+class PasswordDomainAuthenticationRequest extends PasswordAuthenticationRequest {
+       /** @var string[] Domains available */
+       private $domainList;
+
+       /** @var string Domain */
+       public $domain = null;
+
+       /**
+        * @param string[] $domainList List of available domains
+        */
+       public function __construct( array $domainList ) {
+               $this->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 (file)
index 0000000..846d16e
--- /dev/null
@@ -0,0 +1,120 @@
+<?php
+/**
+ * Pre-authentication provider interface
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use StatusValue;
+use User;
+
+/**
+ * A pre-authentication provider is a check that must pass for authentication
+ * to proceed.
+ *
+ * A PreAuthenticationProvider is used to supply arbitrary checks to be
+ * performed before the PrimaryAuthenticationProviders are consulted during the
+ * login process. Possible uses include checking that a per-IP throttle has not
+ * been reached or that a captcha has been solved.
+ *
+ * @ingroup Auth
+ * @since 1.27
+ */
+interface PreAuthenticationProvider extends AuthenticationProvider {
+
+       /**
+        * Determine whether an authentication may begin
+        *
+        * Called from AuthManager::beginAuthentication()
+        *
+        * @param AuthenticationRequest[] $reqs
+        * @return StatusValue
+        */
+       public function testForAuthentication( array $reqs );
+
+       /**
+        * Post-login callback
+        * @param User|null $user User that was attempted to be logged in, if known.
+        *   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 postAuthentication( $user, AuthenticationResponse $response );
+
+       /**
+        * 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 );
+
+       /**
+        * 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-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 linked to another authentication method
+        *
+        * @param User $user User being linked.
+        *   This may become a "UserValue" in the future, or User may be refactored
+        *   into such.
+        * @return StatusValue
+        */
+       public function testForAccountLink( $user );
+
+       /**
+        * 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/PrimaryAuthenticationProvider.php b/includes/auth/PrimaryAuthenticationProvider.php
new file mode 100644 (file)
index 0000000..169e7f1
--- /dev/null
@@ -0,0 +1,334 @@
+<?php
+/**
+ * Primary authentication provider interface
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use StatusValue;
+use User;
+
+/**
+ * A primary authentication provider determines which user is trying to log in.
+ *
+ * A PrimaryAuthenticationProvider is used as part of presenting a login form
+ * to authenticate a user. In particular, the PrimaryAuthenticationProvider
+ * takes form data and determines the authenticated user (if any) corresponds
+ * to that form data. It might do this on the basis of a username and password
+ * in that data, or by interacting with an external authentication service
+ * (e.g. using OpenID), or by some other mechanism.
+ *
+ * A PrimaryAuthenticationProvider would not be appropriate for something like
+ * HTTP authentication, OAuth, or SSL client certificates where each HTTP
+ * request contains all the information needed to identify the user. In that
+ * case you'll want to be looking at a \\MediaWiki\\Session\\SessionProvider
+ * instead.
+ *
+ * This interface also provides methods for changing authentication data such
+ * as passwords and for creating new users who can later be authenticated with
+ * this provider.
+ *
+ * @ingroup Auth
+ * @since 1.27
+ */
+interface PrimaryAuthenticationProvider extends AuthenticationProvider {
+       /** Provider can create accounts */
+       const TYPE_CREATE = 'create';
+       /** Provider can link to existing accounts elsewhere */
+       const TYPE_LINK = 'link';
+       /** Provider cannot create or link to accounts */
+       const TYPE_NONE = 'none';
+
+       /**
+        * Start an authentication flow
+        *
+        * @param AuthenticationRequest[] $reqs
+        * @return AuthenticationResponse Expected responses:
+        *  - PASS: The user is authenticated. Secondary providers will now run.
+        *  - FAIL: The user is not authenticated. Fail the authentication 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 beginPrimaryAuthentication( array $reqs );
+
+       /**
+        * Continue an authentication flow
+        * @param AuthenticationRequest[] $reqs
+        * @return AuthenticationResponse Expected responses:
+        *  - PASS: The user is authenticated. Secondary providers will now run.
+        *  - FAIL: The user is not authenticated. Fail the authentication process.
+        *  - UI: Additional AuthenticationRequests are needed to complete the process.
+        *  - REDIRECT: Redirection to a third party is needed to complete the process.
+        */
+       public function continuePrimaryAuthentication( array $reqs );
+
+       /**
+        * Post-login callback
+        * @param User|null $user User that was attempted to be logged in, if known.
+        *   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 postAuthentication( $user, AuthenticationResponse $response );
+
+       /**
+        * Test whether the named user exists
+        * @param string $username
+        * @param int $flags Bitfield of User:READ_* constants
+        * @return bool
+        */
+       public function testUserExists( $username, $flags = User::READ_NORMAL );
+
+       /**
+        * Test whether the named user can authenticate with this provider
+        * @param string $username
+        * @return bool
+        */
+       public function testUserCanAuthenticate( $username );
+
+       /**
+        * Normalize the username for authentication
+        *
+        * Any two inputs that would result in the same user being authenticated
+        * should return the same string here, while inputs that would result in
+        * different users should return different strings.
+        *
+        * If possible, the best thing to do here is to return the canonicalized
+        * name of the local user account that would be used. If not, return
+        * something that would be invalid as a local username (e.g. wrap an email
+        * address in "<>", 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 (file)
index 0000000..d487e31
--- /dev/null
@@ -0,0 +1,64 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use MediaWiki\Session\SessionManager;
+use MediaWiki\Session\SessionProvider;
+
+/**
+ * This is an authentication request added by AuthManager to show a "remember
+ * me" checkbox. When checked, it will take more time for the authenticated session to expire.
+ * @ingroup Auth
+ * @since 1.27
+ */
+class RememberMeAuthenticationRequest extends AuthenticationRequest {
+
+       public $required = self::OPTIONAL;
+
+       /** @var int How long the user will be remembered, in seconds */
+       protected $expiration = null;
+
+       /** @var bool */
+       public $rememberMe = false;
+
+       public function __construct() {
+               /** @var SessionProvider $provider */
+               $provider = SessionManager::getGlobalSession()->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 (file)
index 0000000..2e51cf2
--- /dev/null
@@ -0,0 +1,132 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+/**
+ * Reset the local password, if signalled via $this->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 (file)
index 0000000..0d52d25
--- /dev/null
@@ -0,0 +1,217 @@
+<?php
+/**
+ * Secondary authentication provider interface
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use StatusValue;
+use User;
+
+/**
+ * A secondary authentication provider performs additional authentication steps
+ * after a PrimaryAuthenticationProvider has done its thing.
+ *
+ * A SecondaryAuthenticationProvider is used to perform arbitrary checks on an
+ * authentication request after the user itself has been authenticated. For
+ * example, it might implement a password reset, request the second factor for
+ * two-factor auth, or prevent the login if the account is blocked.
+ *
+ * @ingroup Auth
+ * @since 1.27
+ */
+interface SecondaryAuthenticationProvider extends AuthenticationProvider {
+
+       /**
+        * Start an authentication flow
+        *
+        * Note that this may be called for a user even if
+        * beginSecondaryAccountCreation() was never called. The module should take
+        * the opportunity to do any necessary setup in that case.
+        *
+        * @param User $user User being authenticated. 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 authenticated. Additional secondary providers may run.
+        *  - FAIL: The user is not authenticated. Fail the authentication process.
+        *  - 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 beginSecondaryAuthentication( $user, array $reqs );
+
+       /**
+        * Continue an authentication flow
+        * @param User $user User being authenticated. 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 authenticated. Additional secondary providers may run.
+        *  - FAIL: The user is not authenticated. Fail the authentication process.
+        *  - 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 continueSecondaryAuthentication( $user, array $reqs );
+
+       /**
+        * Post-login callback
+        * @param User|null $user User that was attempted to be logged in, if known.
+        *   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 postAuthentication( $user, AuthenticationResponse $response );
+
+       /**
+        * 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 );
+
+       /**
+        * 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 (file)
index 0000000..42f0e70
--- /dev/null
@@ -0,0 +1,105 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+/**
+ * This represents the intention to set a temporary password for the user.
+ * @ingroup Auth
+ * @since 1.27
+ */
+class TemporaryPasswordAuthenticationRequest extends AuthenticationRequest {
+       /** @var string|null Temporary password */
+       public $password;
+
+       /** @var bool Email password to the user. */
+       public $mailpassword = false;
+
+       /**
+        * @var bool Do not fail certain operations if the password cannot be mailed, there is a
+        *   backchannel present.
+        */
+       public $hasBackchannel = false;
+
+       /** @var string Username or IP address of the caller */
+       public $caller;
+
+       public function getFieldInfo() {
+               return [
+                       'mailpassword' => [
+                               '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 (file)
index 0000000..46cbab5
--- /dev/null
@@ -0,0 +1,454 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use User;
+
+/**
+ * A primary authentication provider that uses the temporary password field in
+ * the 'user' table.
+ *
+ * A successful login will force a password reset.
+ *
+ * @note For proper operation, this should generally come before any other
+ *  password-based authentication providers.
+ * @ingroup Auth
+ * @since 1.27
+ */
+class TemporaryPasswordPrimaryAuthenticationProvider
+       extends AbstractPasswordPrimaryAuthenticationProvider
+{
+       /** @var bool */
+       protected $emailEnabled = null;
+
+       /** @var int */
+       protected $newPasswordExpiry = null;
+
+       /** @var int */
+       protected $passwordReminderResendTime = null;
+
+       /**
+        * @param array $params
+        *  - emailEnabled: (bool) must be true for the option to email passwords to be present
+        *  - newPasswordExpiry: (int) expiraton time of temporary passwords, in seconds
+        *  - passwordReminderResendTime: (int) cooldown period in hours until a password reminder can
+        *    be sent to the same user again,
+        */
+       public function __construct( $params = [] ) {
+               parent::__construct( $params );
+
+               if ( isset( $params['emailEnabled'] ) ) {
+                       $this->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 (file)
index 0000000..e2123ef
--- /dev/null
@@ -0,0 +1,170 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use BagOStuff;
+use Config;
+
+/**
+ * A pre-authentication provider to throttle authentication actions.
+ *
+ * Adding this provider will throttle account creations and primary authentication attempts
+ * (more specifically, any authentication that returns FAIL on failure). Secondary authentication
+ * cannot be easily throttled on a framework level (since it would typically return UI on failure);
+ * secondary providers are expected to do their own throttling.
+ * @ingroup Auth
+ * @since 1.27
+ */
+class ThrottlePreAuthenticationProvider extends AbstractPreAuthenticationProvider {
+       /** @var array */
+       protected $throttleSettings;
+
+       /** @var Throttler */
+       protected $accountCreationThrottle;
+
+       /** @var Throttler */
+       protected $passwordAttemptThrottle;
+
+       /** @var BagOStuff */
+       protected $cache;
+
+       /**
+        * @param array $params
+        *  - accountCreationThrottle: (array) Condition array for the account creation throttle; an array
+        *    of arrays in a format like $wgPasswordAttemptThrottle, passed to the Throttler constructor.
+        *  - passwordAttemptThrottle: (array) Condition array for the password attempt throttle, in the
+        *    same format as accountCreationThrottle.
+        *  - cache: (BagOStuff) Where to store the throttle, defaults to the local cluster instance.
+        */
+       public function __construct( $params = [] ) {
+               $this->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 (file)
index 0000000..5b14a3b
--- /dev/null
@@ -0,0 +1,210 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use BagOStuff;
+use Config;
+use MediaWiki\Logger\LoggerFactory;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\LogLevel;
+use Psr\Log\NullLogger;
+use User;
+
+/**
+ * A helper class for throttling authentication attempts.
+ * @package MediaWiki\Auth
+ * @ingroup Auth
+ * @since 1.27
+ */
+class Throttler implements LoggerAwareInterface {
+       /** @var string */
+       protected $type;
+       /**
+        * See documentation of $wgPasswordAttemptThrottle for format. Old (pre-1.27) format is not
+        * allowed here.
+        * @var array
+        * @see https://www.mediawiki.org/wiki/Manual:$wgPasswordAttemptThrottle
+        */
+       protected $conditions;
+       /** @var BagOStuff */
+       protected $cache;
+       /** @var LoggerInterface */
+       protected $logger;
+       /** @var int|float */
+       protected $warningLimit;
+
+       /**
+        * @param array $conditions An array of arrays describing throttling conditions.
+        *     Defaults to $wgPasswordAttemptThrottle. See documentation of that variable for format.
+        * @param array $params Parameters (all optional):
+        *   - type: throttle type, used as a namespace for counters,
+        *   - cache: a BagOStuff object where throttle counters are stored.
+        *   - warningLimit: the log level will be raised to warning when rejecting an attempt after
+        *     no less than this many failures.
+        */
+       public function __construct( array $conditions = null, array $params = [] ) {
+               $invalidParams = array_diff_key( $params,
+                       array_fill_keys( [ 'type', 'cache', 'warningLimit' ], true ) );
+               if ( $invalidParams ) {
+                       throw new \InvalidArgumentException( 'unrecognized parameters: '
+                               . implode( ', ', array_keys( $invalidParams ) ) );
+               }
+
+               if ( $conditions === null ) {
+                       $config = \ConfigFactory::getDefaultInstance()->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 (file)
index 0000000..ee77d7b
--- /dev/null
@@ -0,0 +1,88 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use StatusValue;
+use User;
+
+/**
+ * This represents additional user data requested on the account creation form
+ *
+ * @ingroup Auth
+ * @since 1.27
+ */
+class UserDataAuthenticationRequest extends AuthenticationRequest {
+       /** @var string|null Email address */
+       public $email;
+
+       /** @var string|null Real name */
+       public $realname;
+
+       public function getFieldInfo() {
+               $config = \ConfigFactory::getDefaultInstance()->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 (file)
index 0000000..7bf8f13
--- /dev/null
@@ -0,0 +1,39 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+/**
+ * AuthenticationRequest to ensure something with a username is present
+ * @ingroup Auth
+ * @since 1.27
+ */
+class UsernameAuthenticationRequest extends AuthenticationRequest {
+       public function getFieldInfo() {
+               return [
+                       'username' => [
+                               'type' => 'string',
+                               'label' => wfMessage( 'userlogin-yourname' ),
+                               'help' => wfMessage( 'authmanager-username-help' ),
+                       ],
+               ];
+       }
+}
index 916be2d..b7c3489 100644 (file)
@@ -25,7 +25,7 @@
  * 'exception-nologin' as a title and 'exception-nologin-text' for the message.
  *
  * @note In order for this exception to redirect, the error message passed to the
- * constructor has to be explicitly added to LoginForm::validErrorMessages or with
+ * constructor has to be explicitly added to LoginHelper::validErrorMessages or with
  * the LoginFormValidErrorMessages hook. Otherwise, the user will just be shown the message
  * rather than redirected.
  *
@@ -79,7 +79,7 @@ class UserNotLoggedIn extends ErrorPageError {
        public function report() {
                // If an unsupported message is used, don't try redirecting to Special:Userlogin,
                // since the message may not be compatible.
-               if ( !in_array( $this->msg, LoginForm::getValidErrorMessages() ) ) {
+               if ( !in_array( $this->msg, LoginHelper::getValidErrorMessages() ) ) {
                        parent::report();
                }
 
index 14c0820..62afeee 100644 (file)
@@ -72,6 +72,7 @@
        "config-ctype": "<strong>치명</strong>: PHP는 [http://www.php.net/manual/en/ctype.installation.php Ctype 확장 기능]을 지원하도록 하여 컴파일해야 합니다.",
        "config-iconv": "<strong>치명</strong>: PHP는 [http://www.php.net/manual/en/iconv.installation.php iconv 확장 기능]을 지원하도록 하여 컴파일해야 합니다.",
        "config-json": "<strong>치명:</strong> PHP가 JSON 지원이 없이 컴파일되었습니다.\n미디어위키를 설치하기 전에 PHP JSON 확장 기능이나 [http://pecl.php.net/package/jsonc PECL jsonc] 확장 기능 중 하나를 설치해야 합니다.\n* PHP 확장 기능은 Red Hat Enterprise Linux (CentOS) 5와 6에 포함되어 있지만, <code>/etc/php.ini</code>나 <code>/etc/php.d/json.ini</code>에서 활성화해야 합니다.\n* 2013년 5월 이후에 출시된 일부 리눅스 배포판은 PHP 확장 기능이 생략된 대신, <code>php5-json</code>이나 <code>php-pecl-jsonc</code>로 PECL 확장 기능이 포장되어 있습니다.",
+       "config-mbstring-absent": "<strong>치명적 오류:</strong> PHP는 [http://www.php.net/manual/en/mbstring.setup.php mbstring 확장]을 지원하도록 컴파일되어야 합니다.",
        "config-xcache": "[http://xcache.lighttpd.net/ XCache]가 설치되었습니다",
        "config-apc": "[http://www.php.net/apc APC]가 설치되었습니다",
        "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache]가 설치되었습니다",
index a2f55b9..1350958 100644 (file)
@@ -268,13 +268,19 @@ class JobRunner implements LoggerAwareInterface {
 
                        DeferredUpdates::doUpdates();
                        $this->commitMasterChanges( $job );
-                       $job->teardown();
                } catch ( Exception $e ) {
                        MWExceptionHandler::rollbackMasterChangesAndLog( $e );
                        $status = false;
                        $error = get_class( $e ) . ': ' . $e->getMessage();
                        MWExceptionHandler::logException( $e );
                }
+               // Always attempt to call teardown() even if Job throws exception.
+               try {
+                       $job->teardown();
+               } catch ( Exception $e ) {
+                       MWExceptionHandler::logException( $e );
+               }
+
                // Commit all outstanding connections that are in a transaction
                // to get a fresh repeatable read snapshot on every connection.
                // Note that jobs are still responsible for handling slave lag.
index 16e35f1..1e804c4 100644 (file)
@@ -33,7 +33,6 @@ class AssembleUploadChunksJob extends Job {
        }
 
        public function run() {
-               /** @noinspection PhpUnusedLocalVariableInspection */
                $scope = RequestContext::importScopedSession( $this->params['session'] );
                $this->addTeardownCallback( function () use ( &$scope ) {
                        ScopedCallback::consume( $scope ); // T126450
index d2825a8..78531dc 100644 (file)
@@ -35,7 +35,6 @@ class PublishStashedFileJob extends Job {
        }
 
        public function run() {
-               /** @noinspection PhpUnusedLocalVariableInspection */
                $scope = RequestContext::importScopedSession( $this->params['session'] );
                $this->addTeardownCallback( function () use ( &$scope ) {
                        ScopedCallback::consume( $scope ); // T126450
index d7aee5b..b3776bd 100644 (file)
@@ -105,11 +105,15 @@ class PasswordPolicyChecks {
 
                $status = Status::newGood();
                $username = $user->getName();
-               if ( $policyVal
-                       && isset( $blockedLogins[$username] )
-                       && $password == $blockedLogins[$username]
-               ) {
-                       $status->error( 'password-login-forbidden' );
+               if ( $policyVal ) {
+                       if ( isset( $blockedLogins[$username] ) && $password == $blockedLogins[$username] ) {
+                               $status->error( 'password-login-forbidden' );
+                       }
+
+                       // Example from ApiChangeAuthenticationRequest
+                       if ( $password === 'ExamplePassword' ) {
+                               $status->error( 'password-login-forbidden' );
+                       }
                }
                return $status;
        }
index 26058c9..78f9370 100644 (file)
@@ -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',
        ];
 
        /**
index 8ce3174..3df0dae 100644 (file)
@@ -217,19 +217,13 @@ class CookieSessionProvider extends SessionProvider {
                        [ 'prefix' => '' ] + $options
                );
 
-               $extendedCookies = $this->config->get( 'ExtendedLoginCookies' );
-               $extendedExpiry = $this->config->get( 'ExtendedLoginCookieExpiration' );
-
                foreach ( $cookies as $key => $value ) {
                        if ( $value === false ) {
                                $response->clearCookie( $key, $options );
                        } else {
-                               if ( $extendedExpiry !== null && in_array( $key, $extendedCookies ) ) {
-                                       $expiry = time() + (int)$extendedExpiry;
-                               } else {
-                                       $expiry = 0; // Default cookie expiration
-                               }
-                               $response->setCookie( $key, (string)$value, $expiry, $options );
+                               $expirationDuration = $this->getLoginCookieExpiration( $key );
+                               $expiration = $expirationDuration ? $expirationDuration + time() : null;
+                               $response->setCookie( $key, (string)$value, $expiration, $options );
                        }
                }
 
@@ -276,7 +270,13 @@ class CookieSessionProvider extends SessionProvider {
        ) {
                $response = $request->response();
                if ( $set ) {
-                       $response->setCookie( 'forceHTTPS', 'true', $backend->shouldRememberUser() ? 0 : null,
+                       if ( $backend->shouldRememberUser() ) {
+                               $expirationDuration = $this->getLoginCookieExpiration( 'forceHTTPS' );
+                               $expiration = $expirationDuration ? $expirationDuration + time() : null;
+                       } else {
+                               $expiration = null;
+                       }
+                       $response->setCookie( 'forceHTTPS', 'true', $expiration,
                                [ 'prefix' => '', 'secure' => false ] + $this->cookieOptions );
                } else {
                        $response->clearCookie( 'forceHTTPS',
@@ -396,4 +396,24 @@ class CookieSessionProvider extends SessionProvider {
                return wfMessage( 'sessionprovider-nocookies' );
        }
 
+       public function getRememberUserDuration() {
+               return min( $this->getLoginCookieExpiration( 'UserID' ),
+                       $this->getLoginCookieExpiration( 'Token' ) ) ?: null;
+       }
+
+       /**
+        * Returns the lifespan of the login cookies, in seconds. 0 means until the end of the session.
+        * @param string $cookieName
+        * @return int Cookie expiration time in seconds; 0 for session cookies
+        */
+       protected function getLoginCookieExpiration( $cookieName ) {
+               $normalExpiration = $this->config->get( 'CookieExpiration' );
+               $extendedExpiration = $this->config->get( 'ExtendedLoginCookieExpiration' );
+               $extendedCookies = $this->config->get( 'ExtendedLoginCookies' );
+
+               if ( !in_array( $cookieName, $extendedCookies, true ) ) {
+                       return (int)$normalExpiration;
+               }
+               return ( $extendedExpiration !== null ) ? (int)$extendedExpiration : (int)$normalExpiration;
+       }
 }
index 2f6e133..1cab3d3 100644 (file)
@@ -113,7 +113,7 @@ abstract class ImmutableSessionProviderWithCookie extends SessionProvider {
 
                $options = $this->sessionCookieOptions;
                if ( $session->shouldForceHTTPS() || $session->getUser()->requiresHTTPS() ) {
-                       $response->setCookie( 'forceHTTPS', 'true', $session->shouldRememberUser() ? 0 : null,
+                       $response->setCookie( 'forceHTTPS', 'true', null,
                                [ 'prefix' => '', 'secure' => false ] + $options );
                        $options['secure'] = true;
                }
index 777d3d6..c3481e8 100644 (file)
@@ -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;
 
index ed113b7..50794d0 100644 (file)
@@ -275,6 +275,16 @@ abstract class SessionProvider implements SessionProviderInterface, LoggerAwareI
         */
        abstract public function canChangeUser();
 
+       /**
+        * Returns the duration (in seconds) for which users will be remembered when
+        * Session::setRememberUser() is set. Null means setting the remember flag will
+        * have no effect (and endpoints should not offer that option).
+        * @return int|null
+        */
+       public function getRememberUserDuration() {
+               return null;
+       }
+
        /**
         * Notification that the session ID was reset
         *
index e5dc59f..10d9cb9 100644 (file)
@@ -663,19 +663,35 @@ class SkinTemplate extends Skin {
                        $loginlink = $this->getUser()->isAllowed( 'createaccount' ) && $useCombinedLoginLink
                                ? 'nav-login-createaccount'
                                : 'pt-login';
-                       $is_signup = $request->getText( 'type' ) == 'signup';
 
-                       $login_url = [
-                               'text' => $this->msg( $loginlink )->text(),
-                               'href' => self::makeSpecialUrl( 'Userlogin', $returnto ),
-                               'active' => $title->isSpecial( 'Userlogin' )
-                                       && ( $loginlink == 'nav-login-createaccount' || !$is_signup ),
-                       ];
-                       $createaccount_url = [
-                               'text' => $this->msg( 'pt-createaccount' )->text(),
-                               'href' => self::makeSpecialUrl( 'Userlogin', "$returnto&type=signup" ),
-                               'active' => $title->isSpecial( 'Userlogin' ) && $is_signup,
-                       ];
+                       // TODO remove this after AuthManager is stable
+                       global $wgDisableAuthManager;
+                       if ( $wgDisableAuthManager ) {
+                               $is_signup = $request->getText( 'type' ) == 'signup';
+                               $login_url = [
+                                       'text' => $this->msg( $loginlink )->text(),
+                                       'href' => self::makeSpecialUrl( 'Userlogin', $returnto ),
+                                       'active' => $title->isSpecial( 'Userlogin' )
+                                               && ( $loginlink == 'nav-login-createaccount' || !$is_signup ),
+                               ];
+                               $createaccount_url = [
+                                       'text' => $this->msg( 'pt-createaccount' )->text(),
+                                       'href' => self::makeSpecialUrl( 'Userlogin', "$returnto&type=signup" ),
+                                       'active' => $title->isSpecial( 'Userlogin' ) && $is_signup,
+                               ];
+                       } else {
+                               $login_url = [
+                                       'text' => $this->msg( $loginlink )->text(),
+                                       'href' => self::makeSpecialUrl( 'Userlogin', $returnto ),
+                                       'active' => $title->isSpecial( 'Userlogin' ) ||
+                                               $title->isSpecial( 'CreateAccount' ) && $useCombinedLoginLink,
+                               ];
+                               $createaccount_url = [
+                                       'text' => $this->msg( 'pt-createaccount' )->text(),
+                                       'href' => self::makeSpecialUrl( 'CreateAccount', $returnto ),
+                                       'active' => $title->isSpecial( 'CreateAccount' ),
+                               ];
+                       }
 
                        // No need to show Talk and Contributions to anons if they can't contribute!
                        if ( User::groupHasPermission( '*', 'edit' ) ) {
diff --git a/includes/specialpage/AuthManagerSpecialPage.php b/includes/specialpage/AuthManagerSpecialPage.php
new file mode 100644 (file)
index 0000000..7866c12
--- /dev/null
@@ -0,0 +1,744 @@
+<?php
+
+use MediaWiki\Auth\AuthenticationRequest;
+use MediaWiki\Auth\AuthenticationResponse;
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\Session\SessionManager;
+use MediaWiki\Session\Token;
+
+/**
+ * A special page subclass for authentication-related special pages. It generates a form from
+ * a set of AuthenticationRequest objects, submits the result to AuthManager and
+ * partially handles the response.
+ */
+abstract class AuthManagerSpecialPage extends SpecialPage {
+       /** @var string[] The list of actions this special page deals with. Subclasses should override
+        * this. */
+       protected static $allowedActions = [
+               AuthManager::ACTION_LOGIN, AuthManager::ACTION_LOGIN_CONTINUE,
+               AuthManager::ACTION_CREATE, AuthManager::ACTION_CREATE_CONTINUE,
+               AuthManager::ACTION_LINK, AuthManager::ACTION_LINK_CONTINUE,
+               AuthManager::ACTION_CHANGE, AuthManager::ACTION_REMOVE, AuthManager::ACTION_UNLINK,
+       ];
+
+       /** @var array Customized messages */
+       protected static $messages = [];
+
+       /** @var string one of the AuthManager::ACTION_* constants. */
+       protected $authAction;
+
+       /** @var AuthenticationRequest[] */
+       protected $authRequests;
+
+       /** @var string Subpage of the special page. */
+       protected $subPage;
+
+       /** @var bool True if the current request is a result of returning from a redirect flow. */
+       protected $isReturn;
+
+       /** @var WebRequest|null If set, will be used instead of the real request. Used for redirection. */
+       protected $savedRequest;
+
+       /**
+        * Change the form descriptor that determines how a field will look in the authentication form.
+        * Called from fieldInfoToFormDescriptor().
+        * @param AuthenticationRequest[] $requests
+        * @param string $fieldInfo Field information array (union of all
+        *    AuthenticationRequest::getFieldInfo() responses).
+        * @param array $formDescriptor HTMLForm descriptor. The special key 'weight' can be set to
+        *    change the order of the fields.
+        * @param string $action Authentication type (one of the AuthManager::ACTION_* constants)
+        * @return bool
+        */
+       public function onAuthChangeFormFields(
+               array $requests, array $fieldInfo, array &$formDescriptor, $action
+       ) {
+               return true;
+       }
+
+       protected function getLoginSecurityLevel() {
+               return $this->getName();
+       }
+
+       public function getRequest() {
+               return $this->savedRequest ?: $this->getContext()->getRequest();
+       }
+
+       /**
+        * Override the POST data, GET data from the real request is preserved.
+        *
+        * Used to preserve POST data over a HTTP redirect.
+        *
+        * @param array $data
+        * @param bool $wasPosted
+        */
+       protected function setRequest( array $data, $wasPosted = null ) {
+               $request = $this->getContext()->getRequest();
+               if ( $wasPosted === null ) {
+                       $wasPosted = $request->wasPosted();
+               }
+               $this->savedRequest = new DerivativeRequest( $request, $data + $request->getQueryValues(),
+                       $wasPosted );
+       }
+
+       protected function beforeExecute( $subPage ) {
+               $this->getOutput()->disallowUserJs();
+
+               return $this->handleReturnBeforeExecute( $subPage )
+                       && $this->handleReauthBeforeExecute( $subPage );
+       }
+
+       /**
+        * Handle redirection from the /return subpage.
+        *
+        * This is used in the redirect flow where we need
+        * to be able to process data that was sent via a GET request. We set the /return subpage as
+        * the reentry point so we know we need to treat GET as POST, but we don't want to handle all
+        * future GETs as POSTs so we need to normalize the URL. (Also we don't want to show any
+        * received parameters around in the URL; they are ugly and might be sensitive.)
+        *
+        * Thus when on the /return subpage, we stash the request data in the session, redirect, then
+        * use the session to detect that we have been redirected, recover the data and replace the
+        * real WebRequest with a fake one that contains the saved data.
+        *
+        * @param string $subPage
+        * @return bool False if execution should be stopped.
+        */
+       protected function handleReturnBeforeExecute( $subPage ) {
+               $authManager = AuthManager::singleton();
+               $key = 'AuthManagerSpecialPage:return:' . $this->getName();
+
+               if ( $subPage === 'return' ) {
+                       $this->loadAuth( $subPage );
+                       $preservedParams = $this->getPreservedParams( false );
+
+                       // FIXME save POST values only from request
+                       $authData = array_diff_key( $this->getRequest()->getValues(),
+                               $preservedParams, [ 'title' => 1 ] );
+                       $authManager->setAuthenticationSessionData( $key, $authData );
+
+                       $url = $this->getPageTitle()->getFullURL( $preservedParams, false, PROTO_HTTPS );
+                       $this->getOutput()->redirect( $url );
+                       return false;
+               }
+
+               $authData = $authManager->getAuthenticationSessionData( $key );
+               if ( $authData ) {
+                       $authManager->removeAuthenticationSessionData( $key );
+                       $this->isReturn = true;
+                       $this->setRequest( $authData, true );
+               }
+
+               return true;
+       }
+
+       /**
+        * Handle redirection when the user needs to (re)authenticate.
+        *
+        * Send the user to the login form if needed; in case the request was a POST, stash in the
+        * session and simulate it once the user gets back.
+        *
+        * @param string $subPage
+        * @return bool False if execution should be stopped.
+        * @throws ErrorPageError When the user is not allowed to use this page.
+        */
+       protected function handleReauthBeforeExecute( $subPage ) {
+               $authManager = AuthManager::singleton();
+               $request = $this->getRequest();
+               $key = 'AuthManagerSpecialPage:reauth:' . $this->getName();
+
+               $securityLevel = $this->getLoginSecurityLevel();
+               if ( $securityLevel ) {
+                       $securityStatus = AuthManager::singleton()
+                               ->securitySensitiveOperationStatus( $securityLevel );
+                       if ( $securityStatus === AuthManager::SEC_REAUTH ) {
+                               $queryParams = array_diff_key( $request->getQueryValues(), [ 'title' => true ] );
+
+                               if ( $request->wasPosted() ) {
+                                       // unique ID in case the same special page is open in multiple browser tabs
+                                       $uniqueId = MWCryptRand::generateHex( 6 );
+                                       $key = $key . ':' . $uniqueId;
+
+                                       $queryParams = [ 'authUniqueId' => $uniqueId ] + $queryParams;
+                                       $authData = array_diff_key( $request->getValues(),
+                                                       $this->getPreservedParams( false ), [ 'title' => 1 ] );
+                                       $authManager->setAuthenticationSessionData( $key, $authData );
+                               }
+
+                               $title = SpecialPage::getTitleFor( 'Userlogin' );
+                               $url = $title->getFullURL( [
+                                       'returnto' => $this->getFullTitle()->getPrefixedDBkey(),
+                                       'returntoquery' => wfArrayToCgi( $queryParams ),
+                                       'force' => $securityLevel,
+                               ], false, PROTO_HTTPS );
+
+                               $this->getOutput()->redirect( $url );
+                               return false;
+                       } elseif ( $securityStatus !== AuthManager::SEC_OK ) {
+                               throw new ErrorPageError( 'cannotauth-not-allowed-title', 'cannotauth-not-allowed' );
+                       }
+               }
+
+               $uniqueId = $request->getVal( 'authUniqueId' );
+               if ( $uniqueId ) {
+                       $key = $key . ':' . $uniqueId;
+                       $authData = $authManager->getAuthenticationSessionData( $key );
+                       if ( $authData ) {
+                               $authManager->removeAuthenticationSessionData( $key );
+                               $this->setRequest( $authData, true );
+                       }
+               }
+
+               return true;
+       }
+
+       /**
+        * Get the default action for this special page, if none is given via URL/POST data.
+        * Subclasses should override this (or override loadAuth() so this is never called).
+        * @param string $subPage Subpage of the special page.
+        * @return string an AuthManager::ACTION_* constant.
+        */
+       protected function getDefaultAction( $subPage ) {
+               throw new BadMethodCallException( 'Subclass did not implement getDefaultAction' );
+       }
+
+       /**
+        * Return custom message key.
+        * Allows subclasses to customize messages.
+        * @return string
+        */
+       protected function messageKey( $defaultKey ) {
+               return array_key_exists( $defaultKey, static::$messages )
+                       ? static::$messages[$defaultKey] : $defaultKey;
+       }
+
+       /**
+        * Allows blacklisting certain request types.
+        * @return array A list of AuthenticationRequest subclass names
+        */
+       protected function getRequestBlacklist() {
+               return [];
+       }
+
+       /**
+        * Load or initialize $authAction, $authRequests and $subPage.
+        * Subclasses should call this from execute() or otherwise ensure the variables are initialized.
+        * @param string $subPage Subpage of the special page.
+        * @param string $authAction Override auth action specified in request (this is useful
+        *    when the form needs to be changed from <action> to <action>_CONTINUE after a successful
+        *    authentication step)
+        * @param bool $reset Regenerate the requests even if a cached version is available
+        */
+       protected function loadAuth( $subPage, $authAction = null, $reset = false ) {
+               // Do not load if already loaded, to cut down on the number of getAuthenticationRequests
+               // calls. This is important for requests which have hidden information so any
+               // getAuthenticationRequests call would mean putting data into some cache.
+               if (
+                       !$reset && $this->subPage === $subPage && $this->authAction
+                       && ( !$authAction || $authAction === $this->authAction )
+               ) {
+                       return;
+               }
+
+               $request = $this->getRequest();
+               $this->subPage = $subPage;
+               $this->authAction = $authAction ?: $request->getText( 'authAction' );
+               if ( !in_array( $this->authAction, static::$allowedActions, true ) ) {
+                       $this->authAction = $this->getDefaultAction( $subPage );
+                       if ( $request->wasPosted() ) {
+                               $continueAction = $this->getContinueAction( $this->authAction );
+                               if ( in_array( $continueAction, static::$allowedActions, true ) ) {
+                                       $this->authAction = $continueAction;
+                               }
+                       }
+               }
+
+               $allReqs = AuthManager::singleton()->getAuthenticationRequests(
+                       $this->authAction, $this->getUser() );
+               $this->authRequests = array_filter( $allReqs, function ( $req ) use ( $subPage ) {
+                       return !in_array( get_class( $req ), $this->getRequestBlacklist(), true );
+               } );
+       }
+
+       /**
+        * Returns true if this is not the first step of the authentication.
+        * @return bool
+        */
+       protected function isContinued() {
+               return in_array( $this->authAction, [
+                       AuthManager::ACTION_LOGIN_CONTINUE,
+                       AuthManager::ACTION_CREATE_CONTINUE,
+                       AuthManager::ACTION_LINK_CONTINUE,
+               ], true );
+       }
+
+       /**
+        * Gets the _CONTINUE version of an action.
+        * @param string $action An AuthManager::ACTION_* constant.
+        * @return string An AuthManager::ACTION_*_CONTINUE constant.
+        */
+       protected function getContinueAction( $action ) {
+               switch ( $action ) {
+                       case AuthManager::ACTION_LOGIN:
+                               $action = AuthManager::ACTION_LOGIN_CONTINUE;
+                               break;
+                       case AuthManager::ACTION_CREATE:
+                               $action = AuthManager::ACTION_CREATE_CONTINUE;
+                               break;
+                       case AuthManager::ACTION_LINK:
+                               $action = AuthManager::ACTION_LINK_CONTINUE;
+                               break;
+               }
+               return $action;
+       }
+
+       /**
+        * Checks whether AuthManager is ready to perform the action.
+        * ACTION_CHANGE needs special verification (AuthManager::allowsAuthenticationData*) which is
+        * the caller's responsibility.
+        * @param string $action One of the AuthManager::ACTION_* constants in static::$allowedActions
+        * @return bool
+        * @throws LogicException if $action is invalid
+        */
+       protected function isActionAllowed( $action ) {
+               $authManager = AuthManager::singleton();
+               if ( !in_array( $action, static::$allowedActions, true ) ) {
+                       throw new InvalidArgumentException( 'invalid action: ' . $action );
+               }
+
+               // calling getAuthenticationRequests can be expensive, avoid if possible
+               $requests = ( $action === $this->authAction ) ? $this->authRequests
+                       : $authManager->getAuthenticationRequests( $action );
+               if ( !$requests ) {
+                       // no provider supports this action in the current state
+                       return false;
+               }
+
+               switch ( $action ) {
+                       case AuthManager::ACTION_LOGIN:
+                       case AuthManager::ACTION_LOGIN_CONTINUE:
+                               return $authManager->canAuthenticateNow();
+                       case AuthManager::ACTION_CREATE:
+                       case AuthManager::ACTION_CREATE_CONTINUE:
+                               return $authManager->canCreateAccounts();
+                       case AuthManager::ACTION_LINK:
+                       case AuthManager::ACTION_LINK_CONTINUE:
+                               return $authManager->canLinkAccounts();
+                       case AuthManager::ACTION_CHANGE:
+                       case AuthManager::ACTION_REMOVE:
+                       case AuthManager::ACTION_UNLINK:
+                               return true;
+                       default:
+                               // should never reach here but makes static code analyzers happy
+                               throw new InvalidArgumentException( 'invalid action: ' . $action );
+               }
+       }
+
+       /**
+        * @param string $action One of the AuthManager::ACTION_* constants
+        * @param AuthenticationRequest[] $requests
+        * @return AuthenticationResponse
+        * @throws LogicException if $action is invalid
+        */
+       protected function performAuthenticationStep( $action, array $requests ) {
+               if ( !in_array( $action, static::$allowedActions, true ) ) {
+                       throw new InvalidArgumentException( 'invalid action: ' . $action );
+               }
+
+               $authManager = AuthManager::singleton();
+               $returnToUrl = $this->getPageTitle( 'return' )
+                       ->getFullURL( $this->getPreservedParams( true ), false, PROTO_HTTPS );
+
+               switch ( $action ) {
+                       case AuthManager::ACTION_LOGIN:
+                               return $authManager->beginAuthentication( $requests, $returnToUrl );
+                       case AuthManager::ACTION_LOGIN_CONTINUE:
+                               return $authManager->continueAuthentication( $requests );
+                       case AuthManager::ACTION_CREATE:
+                               return $authManager->beginAccountCreation( $this->getUser(), $requests,
+                                       $returnToUrl );
+                       case AuthManager::ACTION_CREATE_CONTINUE:
+                               return $authManager->continueAccountCreation( $requests );
+                       case AuthManager::ACTION_LINK:
+                               return $authManager->beginAccountLink( $this->getUser(), $requests, $returnToUrl );
+                       case AuthManager::ACTION_LINK_CONTINUE:
+                               return $authManager->continueAccountLink( $requests );
+                       case AuthManager::ACTION_CHANGE:
+                       case AuthManager::ACTION_REMOVE:
+                       case AuthManager::ACTION_UNLINK:
+                               if ( count( $requests ) > 1 ) {
+                                       throw new InvalidArgumentException( 'only one auth request can be changed at a time' );
+                               } elseif ( !$requests ) {
+                                       throw new InvalidArgumentException( 'no auth request' );
+                               }
+                               $req = reset( $requests );
+                               $status = $authManager->allowsAuthenticationDataChange( $req );
+                               Hooks::run( 'ChangeAuthenticationDataAudit', [ $req, $status ] );
+                               if ( !$status->isOK() ) {
+                                       return AuthenticationResponse::newFail( $status->getMessage() );
+                               }
+                               $authManager->changeAuthenticationData( $req );
+                               return AuthenticationResponse::newPass();
+                       default:
+                               // should never reach here but makes static code analyzers happy
+                               throw new InvalidArgumentException( 'invalid action: ' . $action );
+               }
+       }
+
+       /**
+        * Attempts to do an authentication step with the submitted data.
+        * Subclasses should probably call this from execute().
+        * @return false|Status
+        *    - false if there was no submit at all
+        *    - a good Status wrapping an AuthenticationResponse if the form submit was successful.
+        *      This does not necessarily mean that the authentication itself was successful; see the
+        *      response for that.
+        *    - a bad Status for form errors.
+        */
+       protected function trySubmit() {
+               $status = false;
+
+               $form = $this->getAuthForm( $this->authRequests, $this->authAction );
+               $form->setSubmitCallback( [ $this, 'handleFormSubmit' ] );
+
+               if ( $this->getRequest()->wasPosted() ) {
+                       // handle tokens manually; $form->tryAuthorizedSubmit only works for logged-in users
+                       $requestTokenValue = $this->getRequest()->getVal( $this->getTokenName() );
+                       $sessionToken = $this->getToken();
+                       if ( $sessionToken->wasNew() ) {
+                               return Status::newFatal( $this->messageKey( 'authform-newtoken' ) );
+                       } elseif ( !$requestTokenValue ) {
+                               return Status::newFatal( $this->messageKey( 'authform-notoken' ) );
+                       } elseif ( !$sessionToken->match( $requestTokenValue ) ) {
+                               return Status::newFatal( $this->messageKey( 'authform-wrongtoken' ) );
+                       }
+
+                       $form->prepareForm();
+                       $status = $form->trySubmit();
+
+                       // HTMLForm submit return values are a mess; let's ensure it is false or a Status
+                       // FIXME this probably should be in HTMLForm
+                       if ( $status === true ) {
+                               // not supposed to happen since our submit handler should always return a Status
+                               throw new UnexpectedValueException( 'HTMLForm::trySubmit() returned true' );
+                       } elseif ( $status === false ) {
+                               // form was not submitted; nothing to do
+                       } elseif ( $status instanceof Status ) {
+                               // already handled by the form; nothing to do
+                       } elseif ( $status instanceof StatusValue ) {
+                               // in theory not an allowed return type but nothing stops the submit handler from
+                               // accidentally returning it so best check and fix
+                               $status = Status::wrap( $status );
+                       } elseif ( is_string( $status ) ) {
+                               $status = Status::newFatal( new RawMessage( '$1', $status ) );
+                       } elseif ( is_array( $status ) ) {
+                               if ( is_string( reset( $status ) ) ) {
+                                       $status = call_user_func_array( 'Status::newFatal', $status );
+                               } elseif ( is_array( reset( $status ) ) ) {
+                                       $status = Status::newGood();
+                                       foreach ( $status as $message ) {
+                                               call_user_func_array( [ $status, 'fatal' ], $message );
+                                       }
+                               } else {
+                                       throw new UnexpectedValueException( 'invalid HTMLForm::trySubmit() return value: '
+                                               . 'first element of array is ' . gettype( reset( $status ) ) );
+                               }
+                       } else {
+                               // not supposed to happen but HTMLForm does not actually verify the return type
+                               // from the submit callback; better safe then sorry
+                               throw new UnexpectedValueException( 'invalid HTMLForm::trySubmit() return type: '
+                                       . gettype( $status ) );
+                       }
+
+                       if ( ( !$status || !$status->isOK() ) && $this->isReturn ) {
+                               // This is awkward. There was a form validation error, which means the data was not
+                               // passed to AuthManager. Normally we would display the form with an error message,
+                               // but for the data we received via the redirect flow that would not be helpful at all.
+                               // Let's just submit the data to AuthManager directly instead.
+                               LoggerFactory::getInstance( 'authmanager' )
+                                       ->warning( 'Validation error on return', [ 'data' => $form->mFieldData,
+                                               'status' => $status->getWikiText() ] );
+                               $status = $this->handleFormSubmit( $form->mFieldData );
+                       }
+               }
+
+               $changeActions = [
+                       AuthManager::ACTION_CHANGE, AuthManager::ACTION_REMOVE, AuthManager::ACTION_UNLINK
+               ];
+               if ( in_array( $this->authAction, $changeActions, true ) && $status && !$status->isOK() ) {
+                       Hooks::run( 'ChangeAuthenticationDataAudit', [ reset( $this->authRequests ), $status ] );
+               }
+
+               return $status;
+       }
+
+       /**
+        * Submit handler callback for HTMLForm
+        * @private
+        * @param $data array Submitted data
+        * @return Status
+        */
+       public function handleFormSubmit( $data ) {
+               $requests = AuthenticationRequest::loadRequestsFromSubmission( $this->authRequests, $data );
+               $response = $this->performAuthenticationStep( $this->authAction, $requests );
+
+               // we can't handle FAIL or similar as failure here since it might require changing the form
+               return Status::newGood( $response );
+       }
+
+       /**
+        * Returns URL query parameters which can be used to reload the page (or leave and return) while
+        * preserving all information that is necessary for authentication to continue. These parameters
+        * will be preserved in the action URL of the form and in the return URL for redirect flow.
+        * @param bool $withToken Include CSRF token
+        * @return array
+        */
+       protected function getPreservedParams( $withToken = false ) {
+               $params = [];
+               if ( $this->authAction !== $this->getDefaultAction( $this->subPage ) ) {
+                       $params['authAction'] = $this->getContinueAction( $this->authAction );
+               }
+               if ( $withToken ) {
+                       $params[$this->getTokenName()] = $this->getToken()->toString();
+               }
+               return $params;
+       }
+
+       /**
+        * Generates a HTMLForm descriptor array from a set of authentication requests.
+        * @param AuthenticationRequest[] $requests
+        * @param string $action AuthManager action name (one of the AuthManager::ACTION_* constants)
+        * @return array
+        */
+       protected function getAuthFormDescriptor( $requests, $action ) {
+               $fieldInfo = AuthenticationRequest::mergeFieldInfo( $requests );
+               $formDescriptor = $this->fieldInfoToFormDescriptor( $requests, $fieldInfo, $action );
+
+               $this->addTabIndex( $formDescriptor );
+
+               return $formDescriptor;
+       }
+
+       /**
+        * @param AuthenticationRequest[] $requests
+        * @param string $action AuthManager action name (one of the AuthManager::ACTION_* constants)
+        * @return HTMLForm
+        */
+       protected function getAuthForm( array $requests, $action ) {
+               $formDescriptor = $this->getAuthFormDescriptor( $requests, $action );
+               $context = $this->getContext();
+               if ( $context->getRequest() !== $this->getRequest() ) {
+                       // We have overridden the request, need to make sure the form uses that too.
+                       $context = new DerivativeContext( $this->getContext() );
+                       $context->setRequest( $this->getRequest() );
+               }
+               $form = HTMLForm::factory( 'ooui', $formDescriptor, $context );
+               $form->setAction( $this->getFullTitle()->getFullURL( $this->getPreservedParams() ) );
+               $form->addHiddenField( $this->getTokenName(), $this->getToken()->toString() );
+               $form->addHiddenField( 'authAction', $this->authAction );
+               $form->suppressDefaultSubmit( !$this->needsSubmitButton( $formDescriptor ) );
+
+               return $form;
+       }
+
+       /**
+        * Display the form.
+        * @param false|Status|StatusValue $status A form submit status, as in HTMLForm::trySubmit()
+        */
+       protected function displayForm( $status ) {
+               if ( $status instanceof StatusValue ) {
+                       $status = Status::wrap( $status );
+               }
+               $form = $this->getAuthForm( $this->authRequests, $this->authAction );
+               $form->prepareForm()->displayForm( $status );
+       }
+
+       /**
+        * Returns true if the form has fields which take values. If all available providers use the
+        * redirect flow, the form might contain nothing but submit buttons, in which case we should
+        * not add an extra submit button which does nothing.
+        *
+        * @param array $formDescriptor A HTMLForm descriptor
+        * @return bool
+        */
+       protected function needsSubmitButton( $formDescriptor ) {
+               return (bool)array_filter( $formDescriptor, function ( $item ) {
+                       $class = false;
+                       if ( array_key_exists( 'class', $item ) ) {
+                               $class = $item['class'];
+                       } elseif ( array_key_exists( 'type', $item ) ) {
+                               $class = HTMLForm::$typeMappings[$item['type']];
+                       }
+                       return !in_array( $class, [ 'HTMLInfoField', 'HTMLSubmitField' ], true );
+               } );
+       }
+
+       /**
+        * Adds a sequential tabindex starting from 1 to all form elements. This way the user can
+        * use the tab key to traverse the form without having to step through all links and such.
+        * @param $formDescriptor
+        */
+       protected function addTabIndex( &$formDescriptor ) {
+               $i = 1;
+               foreach ( $formDescriptor as $field => &$definition ) {
+                       $class = false;
+                       if ( array_key_exists( 'class', $definition ) ) {
+                               $class = $definition['class'];
+                       } elseif ( array_key_exists( 'type', $definition ) ) {
+                               $class = HTMLForm::$typeMappings[$definition['type']];
+                       }
+                       if ( $class !== 'HTMLInfoField' ) {
+                               $definition['tabindex'] = $i;
+                               $i++;
+                       }
+               }
+       }
+
+       /**
+        * Returns the CSRF token.
+        * @return Token
+        */
+       protected function getToken() {
+               return $this->getRequest()->getSession()->getToken( 'AuthManagerSpecialPage:'
+                       . $this->getName() );
+       }
+
+       /**
+        * Returns the name of the CSRF token (under which it should be found in the POST or GET data).
+        * @return string
+        */
+       protected function getTokenName() {
+               return 'wpAuthToken';
+       }
+
+       /**
+        * Turns a field info array into a form descriptor. Behavior can be modified by the
+        * AuthChangeFormFields hook.
+        * @param AuthenticationRequest[] $requests
+        * @param array $fieldInfo Field information, in the format used by
+        *   AuthenticationRequest::getFieldInfo()
+        * @param string $action One of the AuthManager::ACTION_* constants
+        * @return array A form descriptor that can be passed to HTMLForm
+        */
+       protected function fieldInfoToFormDescriptor( array $requests, array $fieldInfo, $action ) {
+               $formDescriptor = [];
+               foreach ( $fieldInfo as $fieldName => $singleFieldInfo ) {
+                       $formDescriptor[$fieldName] = self::mapSingleFieldInfo( $singleFieldInfo, $fieldName );
+               }
+
+               $requestSnapshot = serialize( $requests );
+               $this->onAuthChangeFormFields( $requests, $fieldInfo, $formDescriptor, $action );
+               \Hooks::run( 'AuthChangeFormFields', [ $requests, $fieldInfo, &$formDescriptor, $action ] );
+               if ( $requestSnapshot !== serialize( $requests ) ) {
+                       LoggerFactory::getInstance( 'authentication' )->warning(
+                               'AuthChangeFormFields hook changed auth requests' );
+               }
+
+               // Process the special 'weight' property, which is a way for AuthChangeFormFields hook
+               // subscribers (who only see one field at a time) to influence ordering.
+               self::sortFormDescriptorFields( $formDescriptor );
+
+               return $formDescriptor;
+       }
+
+       /**
+        * Maps an authentication field configuration for a single field (as returned by
+        * AuthenticationRequest::getFieldInfo()) to a HTMLForm field descriptor.
+        * @param array $singleFieldInfo
+        * @return array
+        */
+       protected static function mapSingleFieldInfo( $singleFieldInfo, $fieldName ) {
+               $type = self::mapFieldInfoTypeToFormDescriptorType( $singleFieldInfo['type'] );
+               $descriptor = [
+                       'type' => $type,
+                       // Do not prefix input name with 'wp'. This is important for the redirect flow.
+                       'name' => $fieldName,
+               ];
+
+               if ( $type === 'submit' && isset( $singleFieldInfo['label'] ) ) {
+                       $descriptor['default'] = wfMessage( $singleFieldInfo['label'] )->plain();
+               } elseif ( $type !== 'submit' ) {
+                       $descriptor += array_filter( [
+                               // help-message is omitted as it is usually not really useful for a web interface
+                               'label-message' => self::getField( $singleFieldInfo, 'label' ),
+                       ] );
+
+                       if ( isset( $singleFieldInfo['options'] ) ) {
+                               $descriptor['options'] = array_flip( array_map( function ( $message ) {
+                                       /** @var $message Message */
+                                       return $message->parse();
+                               }, $singleFieldInfo['options'] ) );
+                       }
+
+                       if ( isset( $singleFieldInfo['value'] ) ) {
+                               $descriptor['default'] = $singleFieldInfo['value'];
+                       }
+
+                       if ( empty( $singleFieldInfo['optional'] ) ) {
+                               $descriptor['required'] = true;
+                       }
+               }
+
+               return $descriptor;
+       }
+
+       /**
+        * Sort the fields of a form descriptor by their 'weight' property. (Fields with higher weight
+        * are shown closer to the bottom; weight defaults to 0. Negative weight is allowed.)
+        * Keep order if weights are equal.
+        * @param array $formDescriptor
+        * @return array
+        */
+       protected static function sortFormDescriptorFields( array &$formDescriptor ) {
+               $i = 0;
+               foreach ( $formDescriptor as &$field ) {
+                       $field['__index'] = $i++;
+               }
+               uasort( $formDescriptor, function ( $first, $second ) {
+                       return self::getField( $first, 'weight', 0 ) - self::getField( $second, 'weight', 0 )
+                               ?: $first['__index'] - $second['__index'];
+               } );
+               foreach ( $formDescriptor as &$field ) {
+                       unset( $field['__index'] );
+               }
+       }
+
+       /**
+        * Get an array value, or a default if it does not exist.
+        * @param array $array
+        * @param string $fieldName
+        * @param mixed $default
+        * @return mixed
+        */
+       protected static function getField( array $array, $fieldName, $default = null ) {
+               if ( array_key_exists( $fieldName, $array ) ) {
+                       return $array[$fieldName];
+               } else {
+                       return $default;
+               }
+       }
+
+       /**
+        * Maps AuthenticationRequest::getFieldInfo() types to HTMLForm types
+        * @param string $type
+        * @return string
+        * @throws \LogicException
+        */
+       protected static function mapFieldInfoTypeToFormDescriptorType( $type ) {
+               $map = [
+                       'string' => 'text',
+                       'password' => 'password',
+                       'select' => 'select',
+                       'checkbox' => 'check',
+                       'multiselect' => 'multiselect',
+                       'button' => 'submit',
+                       'hidden' => 'hidden',
+                       'null' => 'info',
+               ];
+               if ( !array_key_exists( $type, $map ) ) {
+                       throw new \LogicException( 'invalid field type: ' . $type );
+               }
+               return $map[$type];
+       }
+}
diff --git a/includes/specialpage/LoginSignupSpecialPage.php b/includes/specialpage/LoginSignupSpecialPage.php
new file mode 100644 (file)
index 0000000..0e4252c
--- /dev/null
@@ -0,0 +1,1636 @@
+<?php
+/**
+ * Holds shared logic for login and account creation pages.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use MediaWiki\Auth\AuthenticationRequest;
+use MediaWiki\Auth\AuthenticationResponse;
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Auth\Throttler;
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\Session\SessionManager;
+use Psr\Log\LogLevel;
+
+/**
+ * Holds shared logic for login and account creation pages.
+ *
+ * @ingroup SpecialPage
+ */
+abstract class LoginSignupSpecialPage extends AuthManagerSpecialPage {
+       protected $mReturnTo;
+       protected $mPosted;
+       protected $mAction;
+       protected $mLanguage;
+       protected $mReturnToQuery;
+       protected $mToken;
+       protected $mStickHTTPS;
+       protected $mFromHTTP;
+       protected $mEntryError = '';
+       protected $mEntryErrorType = 'error';
+
+       protected $mLoaded = false;
+       protected $mSecureLoginUrl;
+
+       /** @var string */
+       protected $securityLevel;
+
+       /** @var bool True if the user if creating an account for someone else. Flag used for internal
+        * communication, only set at the very end. */
+       protected $proxyAccountCreation;
+       /** @var User FIXME another flag for passing data. */
+       protected $targetUser;
+
+       /** @var HTMLForm */
+       protected $authForm;
+
+       /** @var FakeAuthTemplate */
+       protected $fakeTemplate;
+
+       abstract protected function isSignup();
+
+       /**
+        * @param bool $direct True if the action was successful just now; false if that happened
+        *    pre-redirection (so this handler was called already)
+        * @param StatusValue|null $extraMessages
+        * @return void
+        */
+       abstract protected function successfulAction( $direct = false, $extraMessages = null );
+
+       /**
+        * Logs to the authmanager-stats channel.
+        * @param bool $success
+        * @param string|null $status Error message key
+        */
+       abstract protected function logAuthResult( $success, $status = null );
+
+       public function __construct( $name ) {
+               global $wgUseMediaWikiUIEverywhere;
+               parent::__construct( $name );
+
+               // Override UseMediaWikiEverywhere to true, to force login and create form to use mw ui
+               $wgUseMediaWikiUIEverywhere = true;
+       }
+
+       /**
+        * Load data from request.
+        * @private
+        * @param string $subPage Subpage of Special:Userlogin
+        */
+       protected function load( $subPage ) {
+               global $wgSecureLogin;
+
+               if ( $this->mLoaded ) {
+                       return;
+               }
+               $this->mLoaded = true;
+
+               $request = $this->getRequest();
+
+               $this->mPosted = $request->wasPosted();
+               $this->mIsReturn = $subPage === 'return';
+               $this->mAction = $request->getVal( 'action' );
+               $this->mFromHTTP = $request->getBool( 'fromhttp', false )
+                       || $request->getBool( 'wpFromhttp', false );
+               $this->mStickHTTPS = ( !$this->mFromHTTP && $request->getProtocol() === 'https' )
+                       || $request->getBool( 'wpForceHttps', false );
+               $this->mLanguage = $request->getText( 'uselang' );
+               $this->mReturnTo = $request->getVal( 'returnto', '' );
+               $this->mReturnToQuery = $request->getVal( 'returntoquery', '' );
+
+               $securityLevel = $this->getRequest()->getText( 'force' );
+               if (
+                       $securityLevel && AuthManager::singleton()->securitySensitiveOperationStatus(
+                               $securityLevel ) === AuthManager::SEC_REAUTH
+               ) {
+                       $this->securityLevel = $securityLevel;
+               }
+
+               $this->loadAuth( $subPage );
+
+               $this->mToken = $request->getVal( $this->getTokenName() );
+
+               // Show an error or warning passed on from a previous page
+               $entryError = $this->msg( $request->getVal( 'error', '' ) );
+               $entryWarning = $this->msg( $request->getVal( 'warning', '' ) );
+               // bc: provide login link as a parameter for messages where the translation
+               // was not updated
+               $loginreqlink = Linker::linkKnown(
+                       $this->getPageTitle(),
+                       $this->msg( 'loginreqlink' )->escaped(),
+                       [],
+                       [
+                               'returnto' => $this->mReturnTo,
+                               'returntoquery' => $this->mReturnToQuery,
+                               'uselang' => $this->mLanguage,
+                               'fromhttp' => $wgSecureLogin && $this->mFromHTTP ? '1' : null,
+                       ]
+               );
+
+               // Only show valid error or warning messages.
+               if ( $entryError->exists()
+                       && in_array( $entryError->getKey(), LoginHelper::getValidErrorMessages(), true )
+               ) {
+                       $this->mEntryErrorType = 'error';
+                       $this->mEntryError = $entryError->rawParams( $loginreqlink )->parse();
+
+               } elseif ( $entryWarning->exists()
+                       && in_array( $entryWarning->getKey(), LoginHelper::getValidErrorMessages(), true )
+               ) {
+                       $this->mEntryErrorType = 'warning';
+                       $this->mEntryError = $entryWarning->rawParams( $loginreqlink )->parse();
+               }
+
+               # 1. When switching accounts, it sucks to get automatically logged out
+               # 2. Do not return to PasswordReset after a successful password change
+               #    but goto Wiki start page (Main_Page) instead ( bug 33997 )
+               $returnToTitle = Title::newFromText( $this->mReturnTo );
+               if ( is_object( $returnToTitle )
+                       && ( $returnToTitle->isSpecial( 'Userlogout' )
+                               || $returnToTitle->isSpecial( 'PasswordReset' ) )
+               ) {
+                       $this->mReturnTo = '';
+                       $this->mReturnToQuery = '';
+               }
+       }
+
+       protected function getPreservedParams( $withToken = false ) {
+               global $wgSecureLogin;
+
+               $params = parent::getPreservedParams( $withToken );
+               $params += [
+                       'returnto' => $this->mReturnTo ?: null,
+                       'returntoquery' => $this->mReturnToQuery ?: null,
+               ];
+               if ( $wgSecureLogin && !$this->isSignup() ) {
+                       $params['fromhttp'] = $this->mFromHTTP ? '1' : null;
+               }
+               return $params;
+       }
+
+       /**
+        * @param string|null $subPage
+        */
+       public function execute( $subPage ) {
+               $authManager = AuthManager::singleton();
+               $session = SessionManager::getGlobalSession();
+
+               // Session data is used for various things in the authentication process, so we must make
+               // sure a session cookie or some equivalent mechanism is set.
+               $session->persist();
+
+               $this->load( $subPage );
+               $this->setHeaders();
+               $this->checkPermissions();
+
+               // Make sure it's possible to log in
+               if ( !$this->isSignup() && !$session->canSetUser() ) {
+                       throw new ErrorPageError( 'cannotloginnow-title', 'cannotloginnow-text', [
+                                       $session->getProvider()->describe( RequestContext::getMain()->getLanguage() )
+                               ] );
+               }
+
+               /*
+                * In the case where the user is already logged in, and was redirected to
+                * the login form from a page that requires login, do not show the login
+                * page. The use case scenario for this is when a user opens a large number
+                * of tabs, is redirected to the login page on all of them, and then logs
+                * in on one, expecting all the others to work properly.
+                *
+                * However, do show the form if it was visited intentionally (no 'returnto'
+                * is present). People who often switch between several accounts have grown
+                * accustomed to this behavior.
+                *
+                * Also make an exception when force=<level> is set in the URL, which means the user must
+                * reauthenticate for security reasons.
+                */
+               if ( !$this->isSignup() && !$this->mPosted && !$this->securityLevel &&
+                        ( $this->mReturnTo !== '' || $this->mReturnToQuery !== '' ) &&
+                        $this->getUser()->isLoggedIn()
+               ) {
+                       $this->successfulAction();
+               }
+
+               // If logging in and not on HTTPS, either redirect to it or offer a link.
+               global $wgSecureLogin;
+               if ( $this->getRequest()->getProtocol() !== 'https' ) {
+                       $title = $this->getFullTitle();
+                       $query = $this->getPreservedParams( false ) + [
+                                       'title' => null,
+                                       ( $this->mEntryErrorType === 'error' ? 'error'
+                                               : 'warning' ) => $this->mEntryError,
+                               ] + $this->getRequest()->getQueryValues();
+                       $url = $title->getFullURL( $query, false, PROTO_HTTPS );
+                       if ( $wgSecureLogin && !$this->mFromHTTP &&
+                                wfCanIPUseHTTPS( $this->getRequest()->getIP() )
+                       ) {
+                               // Avoid infinite redirect
+                               $url = wfAppendQuery( $url, 'fromhttp=1' );
+                               $this->getOutput()->redirect( $url );
+                               // Since we only do this redir to change proto, always vary
+                               $this->getOutput()->addVaryHeader( 'X-Forwarded-Proto' );
+
+                               return;
+                       } else {
+                               // A wiki without HTTPS login support should set $wgServer to
+                               // http://somehost, in which case the secure URL generated
+                               // above won't actually start with https://
+                               if ( substr( $url, 0, 8 ) === 'https://' ) {
+                                       $this->mSecureLoginUrl = $url;
+                               }
+                       }
+               }
+
+               if ( !$this->isActionAllowed( $this->authAction ) ) {
+                       // FIXME how do we explain this to the user? can we handle session loss better?
+                       // messages used: authpage-cannot-login, authpage-cannot-login-continue,
+                       // authpage-cannot-create, authpage-cannot-create-continue
+                       $this->mainLoginForm( [], 'authpage-cannot-' . $this->authAction );
+                       return;
+               }
+
+               $status = $this->trySubmit();
+
+               if ( !$status || !$status->isGood() ) {
+                       $this->mainLoginForm( $this->authRequests, $status ? $status->getMessage() : '', 'error' );
+                       return;
+               }
+
+               /** @var AuthenticationResponse $response */
+               $response = $status->getValue();
+
+               $returnToUrl = $this->getPageTitle( 'return' )
+                       ->getFullURL( $this->getPreservedParams( true ), false, PROTO_HTTPS );
+               switch ( $response->status ) {
+                       case AuthenticationResponse::PASS:
+                               $this->logAuthResult( true );
+                               $this->proxyAccountCreation = $this->isSignup() && !$this->getUser()->isAnon();
+                               $this->targetUser = User::newFromName( $response->username );
+
+                               if (
+                                       !$this->proxyAccountCreation
+                                       && $response->loginRequest
+                                       && $authManager->canAuthenticateNow()
+                               ) {
+                                       // successful registration; log the user in instantly
+                                       $response2 = $authManager->beginAuthentication( [ $response->loginRequest ],
+                                               $returnToUrl );
+                                       if ( $response2->status !== AuthenticationResponse::PASS ) {
+                                               LoggerFactory::getInstance( 'login' )
+                                                       ->error( 'Could not log in after account creation' );
+                                               $this->successfulAction( true, Status::newFatal( 'createacct-loginerror' ) );
+                                               break;
+                                       }
+                               }
+
+                               if ( !$this->proxyAccountCreation ) {
+                                       // Ensure that the context user is the same as the session user.
+                                       $this->setSessionUserForCurrentRequest();
+                               }
+
+                               $this->successfulAction( true );
+                               break;
+                       case AuthenticationResponse::FAIL:
+                               // fall through
+                       case AuthenticationResponse::RESTART:
+                               unset( $this->authForm );
+                               if ( $response->status === AuthenticationResponse::FAIL ) {
+                                       $action = $this->getDefaultAction( $subPage );
+                                       $messageType = 'error';
+                               } else {
+                                       $action = $this->getContinueAction( $this->authAction );
+                                       $messageType = 'warning';
+                               }
+                               $this->logAuthResult( false, $response->message ? $response->message->getKey() : '-' );
+                               $this->loadAuth( $subPage, $action, true );
+                               $this->mainLoginForm( $this->authRequests, $response->message, $messageType );
+                               break;
+                       case AuthenticationResponse::REDIRECT:
+                               unset( $this->authForm );
+                               $this->getOutput()->redirect( $response->redirectTarget );
+                               break;
+                       case AuthenticationResponse::UI:
+                               unset( $this->authForm );
+                               $this->authAction = $this->isSignup() ? AuthManager::ACTION_CREATE_CONTINUE
+                                       : AuthManager::ACTION_LOGIN_CONTINUE;
+                               $this->authRequests = $response->neededRequests;
+                               $this->mainLoginForm( $response->neededRequests, $response->message, 'warning' );
+                               break;
+                       default:
+                               throw new LogicException( 'invalid AuthenticationResponse' );
+               }
+       }
+
+       /**
+        * Show the success page.
+        *
+        * @param string $type Condition of return to; see `executeReturnTo`
+        * @param string|Message $title Page's title
+        * @param string $msgname
+        * @param string $injected_html
+        * @param StatusValue|null $extraMessages
+        */
+       protected function showSuccessPage(
+               $type, $title, $msgname, $injected_html, $extraMessages
+       ) {
+               $out = $this->getOutput();
+               $out->setPageTitle( $title );
+               if ( $msgname ) {
+                       $out->addWikiMsg( $msgname, wfEscapeWikiText( $this->getUser()->getName() ) );
+               }
+               if ( $extraMessages ) {
+                       $extraMessages = Status::wrap( $extraMessages );
+                       $out->addWikiText( $extraMessages->getWikiText() );
+               }
+
+               $out->addHTML( $injected_html );
+
+               $helper = new LoginHelper( $this->getContext() );
+               $helper->showReturnToPage( $type, $this->mReturnTo, $this->mReturnToQuery, $this->mStickHTTPS );
+       }
+
+       /**
+        * Add a "return to" link or redirect to it.
+        * Extensions can use this to reuse the "return to" logic after
+        * inject steps (such as redirection) into the login process.
+        *
+        * @param string $type One of the following:
+        *    - error: display a return to link ignoring $wgRedirectOnLogin
+        *    - signup: display a return to link using $wgRedirectOnLogin if needed
+        *    - success: display a return to link using $wgRedirectOnLogin if needed
+        *    - successredirect: send an HTTP redirect using $wgRedirectOnLogin if needed
+        * @param string $returnTo
+        * @param array|string $returnToQuery
+        * @param bool $stickHTTPS Keep redirect link on HTTPS
+        * @since 1.22
+        */
+       public function showReturnToPage(
+               $type, $returnTo = '', $returnToQuery = '', $stickHTTPS = false
+       ) {
+               $helper = new LoginHelper( $this->getContext() );
+               $helper->showReturnToPage( $type, $returnTo, $returnToQuery, $stickHTTPS );
+       }
+
+       /**
+        * Replace some globals to make sure the fact that the user has just been logged in is
+        * reflected in the current request.
+        * @param User $user
+        */
+       protected function setSessionUserForCurrentRequest() {
+               global $wgUser, $wgLang;
+
+               $context = RequestContext::getMain();
+               $localContext = $this->getContext();
+               if ( $context !== $localContext ) {
+                       // remove AuthManagerSpecialPage context hack
+                       $this->setContext( $context );
+               }
+
+               $user = $context->getRequest()->getSession()->getUser();
+
+               $wgUser = $user;
+               $context->setUser( $user );
+
+               $code = $this->getRequest()->getVal( 'uselang', $user->getOption( 'language' ) );
+               $userLang = Language::factory( $code );
+               $wgLang = $userLang;
+               $context->setLanguage( $userLang );
+       }
+
+       /**
+        * @param AuthenticationRequest[] $requests A list of AuthorizationRequest objects,
+        *   used to generate the form fields. An empty array means a fatal error
+        *   (authentication cannot continue).
+        * @param string|Message $msg
+        * @param string $msgtype
+        * @throws ErrorPageError
+        * @throws Exception
+        * @throws FatalError
+        * @throws MWException
+        * @throws PermissionsError
+        * @throws ReadOnlyError
+        * @private
+        */
+       protected function mainLoginForm( array $requests, $msg = '', $msgtype = 'error' ) {
+               $titleObj = $this->getPageTitle();
+               $user = $this->getUser();
+               $out = $this->getOutput();
+
+               // FIXME how to handle empty $requests - restart, or no form, just an error message?
+               // no form would be better for no session type errors, restart is better when can* fails.
+               if ( !$requests ) {
+                       $this->authAction = $this->getDefaultAction( $this->subPage );
+                       $this->authForm = null;
+                       $requests = AuthManager::singleton()->getAuthenticationRequests( $this->authAction, $user );
+               }
+
+               // Generic styles and scripts for both login and signup form
+               $out->addModuleStyles( [
+                       'mediawiki.ui',
+                       'mediawiki.ui.button',
+                       'mediawiki.ui.checkbox',
+                       'mediawiki.ui.input',
+                       'mediawiki.special.userlogin.common.styles'
+               ] );
+               if ( $this->isSignup() ) {
+                       // XXX hack pending RL or JS parse() support for complex content messages T27349
+                       $out->addJsConfigVars( 'wgCreateacctImgcaptchaHelp',
+                               $this->msg( 'createacct-imgcaptcha-help' )->parse() );
+
+                       // Additional styles and scripts for signup form
+                       $out->addModules( [
+                               'mediawiki.special.userlogin.signup.js'
+                       ] );
+                       $out->addModuleStyles( [
+                               'mediawiki.special.userlogin.signup.styles'
+                       ] );
+               } else {
+                       // Additional styles for login form
+                       $out->addModuleStyles( [
+                               'mediawiki.special.userlogin.login.styles'
+                       ] );
+               }
+               $out->disallowUserJs(); // just in case...
+
+               $form = $this->getAuthForm( $requests, $this->authAction, $msg, $msgtype );
+               $form->prepareForm();
+               $formHtml = $form->getHTML( $msg ? Status::newFatal( $msg ) : false );
+
+               $out->addHTML( $this->getPageHtml( $formHtml ) );
+       }
+
+       /**
+        * Add page elements which are outside the form.
+        * FIXME this should probably be a template, but use a sane language (handlebars?)
+        * @param string $formHtml
+        * @return string
+        */
+       protected function getPageHtml( $formHtml ) {
+               global $wgLoginLanguageSelector;
+
+               $loginPrompt = $this->isSignup() ? '' : Html::rawElement( 'div',
+                       [ 'id' => 'userloginprompt' ], $this->msg( 'loginprompt' )->parseAsBlock() );
+               $languageLinks = $wgLoginLanguageSelector ? $this->makeLanguageSelector() : '';
+               $signupStartMsg = $this->msg( 'signupstart' );
+               $signupStart = ( $this->isSignup() && !$signupStartMsg->isDisabled() )
+                       ? Html::rawElement( 'div', [ 'id' => 'signupstart' ], $signupStartMsg->parseAsBlock() ) : '';
+               if ( $languageLinks ) {
+                       $languageLinks = Html::rawElement( 'div', [ 'id' => 'languagelinks' ],
+                               Html::rawElement( 'p', [], $languageLinks )
+                       );
+               }
+
+               $benefitsContainer = '';
+               if ( $this->isSignup() && $this->showExtraInformation() ) {
+                       // messages used:
+                       // createacct-benefit-icon1 createacct-benefit-head1 createacct-benefit-body1
+                       // createacct-benefit-icon2 createacct-benefit-head2 createacct-benefit-body2
+                       // createacct-benefit-icon3 createacct-benefit-head3 createacct-benefit-body3
+                       $benefitCount = 3;
+                       $benefitList = '';
+                       for ( $benefitIdx = 1; $benefitIdx <= $benefitCount; $benefitIdx++ ) {
+                               $headUnescaped = $this->msg( "createacct-benefit-head$benefitIdx" )->text();
+                               $iconClass = $this->msg( "createacct-benefit-icon$benefitIdx" )->escaped();
+                               $benefitList .= Html::rawElement( 'div', [ 'class' => "mw-number-text $iconClass" ],
+                                       Html::rawElement( 'h3', [],
+                                               $this->msg( "createacct-benefit-head$benefitIdx" )->escaped()
+                                       )
+                                       . Html::rawElement( 'p', [],
+                                               $this->msg( "createacct-benefit-body$benefitIdx" )->params( $headUnescaped )->escaped()
+                                       )
+                               );
+                       }
+                       $benefitsContainer = Html::rawElement( 'div', [ 'class' => 'mw-createacct-benefits-container' ],
+                               Html::rawElement( 'h2', [], $this->msg( 'createacct-benefit-heading' )->escaped() )
+                               . Html::rawElement( 'div', [ 'class' => 'mw-createacct-benefits-list' ],
+                                       $benefitList
+                               )
+                       );
+               }
+
+               $html = Html::rawElement( 'div', [ 'class' => 'mw-ui-container' ],
+                       $loginPrompt
+                       . $languageLinks
+                       . $signupStart
+                       . Html::rawElement( 'div', [ 'id' => 'userloginForm' ],
+                               $formHtml
+                       )
+                       . $benefitsContainer
+               );
+
+               return $html;
+       }
+
+       /**
+        * Generates a form from the given request.
+        * @param AuthenticationRequest[] $requests
+        * @param string $action AuthManager action name
+        * @param string|Message $msg
+        * @param string $msgType
+        * @return HTMLForm
+        */
+       protected function getAuthForm( array $requests, $action, $msg = '', $msgType = 'error' ) {
+               global $wgSecureLogin, $wgLoginLanguageSelector;
+               // FIXME merge this with parent
+
+               if ( isset( $this->authForm ) ) {
+                       return $this->authForm;
+               }
+
+               $usingHTTPS = $this->getRequest()->getProtocol() === 'https';
+
+               // get basic form description from the auth logic
+               $fieldInfo = AuthenticationRequest::mergeFieldInfo( $requests );
+               $fakeTemplate = $this->getFakeTemplate( $msg, $msgType );
+               $this->fakeTemplate = $fakeTemplate; // FIXME there should be a saner way to pass this to the hook
+               // this will call onAuthChangeFormFields()
+               $formDescriptor = static::fieldInfoToFormDescriptor( $requests, $fieldInfo, $this->authAction );
+               $this->postProcessFormDescriptor( $formDescriptor );
+
+               $context = $this->getContext();
+               if ( $context->getRequest() !== $this->getRequest() ) {
+                       // We have overridden the request, need to make sure the form uses that too.
+                       $context = new DerivativeContext( $this->getContext() );
+                       $context->setRequest( $this->getRequest() );
+               }
+               $form = HTMLForm::factory( 'vform', $formDescriptor, $context );
+
+               $form->addHiddenField( 'authAction', $this->authAction );
+               if ( $wgLoginLanguageSelector ) {
+                       $form->addHiddenField( 'uselang', $this->mLanguage );
+               }
+               $form->addHiddenField( 'force', $this->securityLevel );
+               $form->addHiddenField( $this->getTokenName(), $this->getToken()->toString() );
+               if ( $wgSecureLogin ) {
+                       // If using HTTPS coming from HTTP, then the 'fromhttp' parameter must be preserved
+                       if ( !$this->isSignup() ) {
+                               $form->addHiddenField( 'wpForceHttps', (int)$this->mStickHTTPS );
+                               $form->addHiddenField( 'wpFromhttp', $usingHTTPS );
+                       }
+               }
+
+               // set properties of the form itself
+               $form->setAction( $this->getPageTitle()->getLocalURL( $this->getReturnToQueryStringFragment() ) );
+               $form->setName( 'userlogin' . ( $this->isSignup() ? '2' : '' ) );
+               if ( $this->isSignup() ) {
+                       $form->setId( 'userlogin2' );
+               }
+
+               // add pre/post text
+               // header used by ConfirmEdit, CondfirmAccount, Persona, WikimediaIncubator, SemanticSignup
+               // should be above the error message but HTMLForm doesn't support that
+               $form->addHeaderText( $fakeTemplate->html( 'header' ) );
+
+               // FIXME the old form used this for error/warning messages which does not play well with
+               // HTMLForm (maybe it could with a subclass?); for now only display it for signups
+               // (where the JS username validation needs it) and alway empty
+               if ( $this->isSignup() ) {
+                       // used by the mediawiki.special.userlogin.signup.js module
+                       $statusAreaAttribs = [ 'id' => 'mw-createacct-status-area' ];
+                       // $statusAreaAttribs += $msg ? [ 'class' => "{$msgType}box" ] : [ 'style' => 'display: none;' ];
+                       $form->addHeaderText( Html::element( 'div', $statusAreaAttribs ) );
+               }
+
+               // header used by MobileFrontend
+               $form->addHeaderText( $fakeTemplate->html( 'formheader' ) );
+
+               // blank signup footer for site customization
+               if ( $this->isSignup() && $this->showExtraInformation() ) {
+                       // Use signupend-https for HTTPS requests if it's not blank, signupend otherwise
+                       $signupendMsg = $this->msg( 'signupend' );
+                       $signupendHttpsMsg = $this->msg( 'signupend-https' );
+                       if ( !$signupendMsg->isDisabled() ) {
+                               $signupendText = ( $usingHTTPS && !$signupendHttpsMsg->isBlank() )
+                                       ? $signupendHttpsMsg ->parse() : $signupendMsg->parse();
+                               $form->addPostText( Html::rawElement( 'div', [ 'id' => 'signupend' ], $signupendText ) );
+                       }
+               }
+
+               // warning header for non-standard workflows (e.g. security reauthentication)
+               if ( !$this->isSignup() && $this->getUser()->isLoggedIn() ) {
+                       $reauthMessage = $this->securityLevel ? 'userlogin-reauth' : 'userlogin-loggedin';
+                       $form->addHeaderText( Html::rawElement( 'div', [ 'class' => 'warningbox' ],
+                               $this->msg( $reauthMessage )->params( $this->getUser()->getName() )->parse() ) );
+               }
+
+               if ( !$this->isSignup() && $this->showExtraInformation() ) {
+                       $passwordReset = new PasswordReset( $this->getConfig(), AuthManager::singleton() );
+                       if ( $passwordReset->isAllowed( $this->getUser() ) ) {
+                               $form->addFooterText( Html::rawElement(
+                                       'div',
+                                       [ 'class' => 'mw-ui-vform-field mw-form-related-link-container' ],
+                                       Linker::link(
+                                               SpecialPage::getTitleFor( 'PasswordReset' ),
+                                               $this->msg( 'userlogin-resetpassword-link' )->escaped()
+                                       )
+                               ) );
+                       }
+
+                       // Don't show a "create account" link if the user can't.
+                       if ( $this->showCreateAccountLink() ) {
+                               // link to the other action
+                               $linkTitle = $this->getTitleFor( $this->isSignup() ? 'Userlogin' :'CreateAccount' );
+                               $linkq = $this->getReturnToQueryStringFragment();
+                               // Pass any language selection on to the mode switch link
+                               if ( $wgLoginLanguageSelector && $this->mLanguage ) {
+                                       $linkq .= '&uselang=' . $this->mLanguage;
+                               }
+                               $createOrLoginHref = $linkTitle->getLocalURL( $linkq );
+
+                               if ( $this->getUser()->isLoggedIn() ) {
+                                       $createOrLoginHtml = Html::rawElement( 'div',
+                                               [ 'class' => 'mw-ui-vform-field' ],
+                                               Html::element( 'a',
+                                                       [
+                                                               'id' => 'mw-createaccount-join',
+                                                               'href' => $createOrLoginHref,
+                                                               // put right after all auth inputs in the tab order
+                                                               'tabindex' => 100,
+                                                       ],
+                                                       $this->msg( 'userlogin-createanother' )->escaped()
+                                               )
+                                       );
+                               } else {
+                                       $createOrLoginHtml = Html::rawElement( 'div',
+                                               [ 'id' => 'mw-createaccount-cta',
+                                                       'class' => 'mw-ui-vform-field' ],
+                                               $this->msg( 'userlogin-noaccount' )->escaped()
+                                               . Html::element( 'a',
+                                                       [
+                                                               'id' => 'mw-createaccount-join',
+                                                               'href' => $createOrLoginHref,
+                                                               'class' => 'mw-ui-button',
+                                                               'tabindex' => 100,
+                                                       ],
+                                                       $this->msg( 'userlogin-joinproject' )->escaped()
+                                               )
+                                       );
+                               }
+                               $form->addFooterText( $createOrLoginHtml );
+                       }
+               }
+
+               $form->suppressDefaultSubmit();
+
+               $this->authForm = $form;
+
+               return $form;
+       }
+
+       /**
+        * Temporary B/C method to handle extensions using the UserLoginForm/UserCreateForm hooks.
+        * @param string|Message $msg
+        * @param string $msgType
+        * @return FakeAuthTemplate
+        */
+       protected function getFakeTemplate( $msg, $msgType ) {
+               global $wgAuth, $wgEnableEmail, $wgHiddenPrefs, $wgEmailConfirmToEdit, $wgEnableUserEmail,
+                          $wgSecureLogin, $wgLoginLanguageSelector, $wgPasswordResetRoutes;
+
+               // make a best effort to get the value of fields which used to be fixed in the old login
+               // template but now might or might not exist depending on what providers are used
+               $request = $this->getRequest();
+               $data = (object) [
+                       'mUsername' => $request->getText( 'wpName' ),
+                       'mPassword' => $request->getText( 'wpPassword' ),
+                       'mRetype' => $request->getText( 'wpRetype' ),
+                       'mEmail' => $request->getText( 'wpEmail' ),
+                       'mRealName' => $request->getText( 'wpRealName' ),
+                       'mDomain' => $request->getText( 'wpDomain' ),
+                       'mReason' => $request->getText( 'wpReason' ),
+                       'mRemember' => $request->getCheck( 'wpRemember' ),
+               ];
+
+               // Preserves a bunch of logic from the old code that was rewritten in getAuthForm().
+               // There is no code reuse to make this easier to remove .
+               // If an extension tries to change any of these values, they are out of luck - we only
+               // actually use the domain/usedomain/domainnames, extraInput and extrafields keys.
+
+               $titleObj = $this->getPageTitle();
+               $user = $this->getUser();
+               $template = new FakeAuthTemplate();
+
+               // Pre-fill username (if not creating an account, bug 44775).
+               if ( $data->mUsername == '' && $this->isSignup() ) {
+                       if ( $user->isLoggedIn() ) {
+                               $data->mUsername = $user->getName();
+                       } else {
+                               $data->mUsername = $this->getRequest()->getSession()->suggestLoginUsername();
+                       }
+               }
+
+               if ( $this->isSignup() ) {
+                       // Must match number of benefits defined in messages
+                       $template->set( 'benefitCount', 3 );
+
+                       $q = 'action=submitlogin&type=signup';
+                       $linkq = 'type=login';
+               } else {
+                       $q = 'action=submitlogin&type=login';
+                       $linkq = 'type=signup';
+               }
+
+               if ( $this->mReturnTo !== '' ) {
+                       $returnto = '&returnto=' . wfUrlencode( $this->mReturnTo );
+                       if ( $this->mReturnToQuery !== '' ) {
+                               $returnto .= '&returntoquery=' .
+                                                        wfUrlencode( $this->mReturnToQuery );
+                       }
+                       $q .= $returnto;
+                       $linkq .= $returnto;
+               }
+
+               # Don't show a "create account" link if the user can't.
+               if ( $this->showCreateAccountLink() ) {
+                       # Pass any language selection on to the mode switch link
+                       if ( $wgLoginLanguageSelector && $this->mLanguage ) {
+                               $linkq .= '&uselang=' . $this->mLanguage;
+                       }
+                       // Supply URL, login template creates the button.
+                       $template->set( 'createOrLoginHref', $titleObj->getLocalURL( $linkq ) );
+               } else {
+                       $template->set( 'link', '' );
+               }
+
+               $resetLink = $this->isSignup()
+                       ? null
+                       : is_array( $wgPasswordResetRoutes )
+                         && in_array( true, array_values( $wgPasswordResetRoutes ), true );
+
+               $template->set( 'header', '' );
+               $template->set( 'formheader', '' );
+               $template->set( 'skin', $this->getSkin() );
+
+               $template->set( 'name', $data->mUsername );
+               $template->set( 'password', $data->mPassword );
+               $template->set( 'retype', $data->mRetype );
+               $template->set( 'createemailset', false ); // no easy way to get that from AuthManager
+               $template->set( 'email', $data->mEmail );
+               $template->set( 'realname', $data->mRealName );
+               $template->set( 'domain', $data->mDomain );
+               $template->set( 'reason', $data->mReason );
+               $template->set( 'remember', $data->mRemember );
+
+               $template->set( 'action', $titleObj->getLocalURL( $q ) );
+               $template->set( 'message', $msg );
+               $template->set( 'messagetype', $msgType );
+               $template->set( 'createemail', $wgEnableEmail && $user->isLoggedIn() );
+               $template->set( 'userealname', !in_array( 'realname', $wgHiddenPrefs, true ) );
+               $template->set( 'useemail', $wgEnableEmail );
+               $template->set( 'emailrequired', $wgEmailConfirmToEdit );
+               $template->set( 'emailothers', $wgEnableUserEmail );
+               $template->set( 'canreset', $wgAuth->allowPasswordChange() );
+               $template->set( 'resetlink', $resetLink );
+               $template->set( 'canremember', $request->getSession()->getProvider()
+                       ->getRememberUserDuration() !== null );
+               $template->set( 'usereason', $user->isLoggedIn() );
+               $template->set( 'cansecurelogin', ( $wgSecureLogin ) );
+               $template->set( 'stickhttps', (int)$this->mStickHTTPS );
+               $template->set( 'loggedin', $user->isLoggedIn() );
+               $template->set( 'loggedinuser', $user->getName() );
+               $template->set( 'token', $this->getToken()->toString() );
+
+               $action = $this->isSignup() ? 'signup' : 'login';
+               $wgAuth->modifyUITemplate( $template, $action );
+
+               $oldTemplate = $template;
+               $hookName = $this->isSignup() ? 'UserCreateForm' : 'UserLoginForm';
+               Hooks::run( $hookName, [ &$template ] );
+               if ( $oldTemplate !== $template ) {
+                       wfDeprecated( "reference in $hookName hook", '1.27' );
+               }
+
+               return $template;
+
+       }
+
+       public function onAuthChangeFormFields(
+               array $requests, array $fieldInfo, array &$formDescriptor, $action
+       ) {
+               $coreFieldDescriptors = $this->getFieldDefinitions( $this->fakeTemplate );
+               $specialFields = array_merge( [ 'extraInput', 'linkcontainer' ],
+                       array_keys( $this->fakeTemplate->getExtraInputDefinitions() ) );
+
+               // keep the ordering from getCoreFieldDescriptors() where there is no explicit weight
+               foreach ( $coreFieldDescriptors as $fieldName => $coreField ) {
+                       $requestField = isset( $formDescriptor[$fieldName] ) ?
+                               $formDescriptor[$fieldName] : [];
+
+                       // remove everything that is not in the fieldinfo, is not marked as a supplemental field
+                       // to something in the fieldinfo, and is not a generic or B/C field or a submit button
+                       if (
+                               !isset( $fieldInfo[$fieldName] )
+                               && (
+                                       !isset( $coreField['baseField'] )
+                                       || !isset( $fieldInfo[$coreField['baseField']] )
+                               ) && !in_array( $fieldName, $specialFields, true )
+                               && $coreField['type'] !== 'submit'
+                       ) {
+                               $coreFieldDescriptors[$fieldName] = null;
+                               continue;
+                       }
+
+                       // core message labels should always take priority
+                       if (
+                               isset( $coreField['label'] )
+                               || isset( $coreField['label-message'] )
+                               || isset( $coreField['label-raw'] )
+                       ) {
+                               unset( $requestField['label'], $requestField['label-message'], $coreField['label-raw'] );
+                       }
+
+                       $coreFieldDescriptors[$fieldName] += $requestField;
+               }
+
+               $formDescriptor = array_filter( $coreFieldDescriptors + $formDescriptor );
+               return true;
+       }
+
+       /**
+        * Show extra information such as password recovery information, link from login to signup,
+        * CTA etc? Such information should only be shown on the "landing page", ie. when the user
+        * is at the first step of the authentication process.
+        * @return bool
+        */
+       protected function showExtraInformation() {
+               return $this->authAction !== $this->getContinueAction( $this->authAction )
+                       && !$this->securityLevel;
+       }
+
+       /**
+        * Create a HTMLForm descriptor for the core login fields.
+        * @param FakeAuthTemplate $template B/C data (not used but needed by getBCFieldDefinitions)
+        * @return array
+        */
+       protected function getFieldDefinitions( $template ) {
+               global $wgEmailConfirmToEdit;
+
+               $isLoggedIn = $this->getUser()->isLoggedIn();
+               $continuePart = $this->isContinued() ? 'continue-' : '';
+               $anotherPart = $isLoggedIn ? 'another-' : '';
+               $expiration = $this->getRequest()->getSession()->getProvider()
+                       ->getRememberUserDuration();
+               $expirationDays = ceil( $expiration / ( 3600 * 24 ) );
+               $secureLoginLink = '';
+               if ( $this->mSecureLoginUrl ) {
+                       $secureLoginLink = Html::element( 'a', [
+                               'href' => $this->mSecureLoginUrl,
+                               'class' => 'mw-ui-flush-right mw-secure',
+                       ], $this->msg( 'userlogin-signwithsecure' )->text() );
+               }
+
+               if ( $this->isSignup() ) {
+                       $fieldDefinitions = [
+                               'username' => [
+                                       'label-message' => 'userlogin-yourname',
+                                       // FIXME help-message does not match old formatting
+                                       'help-message' => 'createacct-helpusername',
+                                       'id' => 'wpName2',
+                                       'placeholder-message' => $isLoggedIn ? 'createacct-another-username-ph'
+                                               : 'userlogin-yourname-ph',
+                               ],
+                               'mailpassword' => [
+                                       // create account without providing password, a temporary one will be mailed
+                                       'type' => 'check',
+                                       'label-message' => 'createaccountmail',
+                                       'name' => 'wpCreateaccountMail',
+                                       'id' => 'wpCreateaccountMail',
+                               ],
+                               'password' => [
+                                       'id' => 'wpPassword2',
+                                       'placeholder-message' => 'createacct-yourpassword-ph',
+                                       'hide-if' => [ '===', 'wpCreateaccountMail', '1' ],
+                               ],
+                               'domain' => [],
+                               'retype' => [
+                                       'baseField' => 'password',
+                                       'type' => 'password',
+                                       'label-message' => 'createacct-yourpasswordagain',
+                                       'id' => 'wpRetype',
+                                       'cssclass' => 'loginPassword',
+                                       'size' => 20,
+                                       'validation-callback' => function ( $value, $alldata ) {
+                                               if ( empty( $alldata['mailpassword'] ) && !empty( $alldata['password'] ) ) {
+                                                       if ( !$value ) {
+                                                               return $this->msg( 'htmlform-required' );
+                                                       } elseif ( $value !== $alldata['password'] ) {
+                                                               return $this->msg( 'badretype' );
+                                                       }
+                                               }
+                                               return true;
+                                       },
+                                       'hide-if' => [ '===', 'wpCreateaccountMail', '1' ],
+                                       'placeholder-message' => 'createacct-yourpasswordagain-ph',
+                               ],
+                               'email' => [
+                                       'type' => 'email',
+                                       'label-message' => $wgEmailConfirmToEdit ? 'createacct-emailrequired'
+                                               : 'createacct-emailoptional',
+                                       'id' => 'wpEmail',
+                                       'cssclass' => 'loginText',
+                                       'size' => '20',
+                                       // FIXME will break non-standard providers
+                                       'required' => $wgEmailConfirmToEdit,
+                                       'validation-callback' => function ( $value, $alldata ) {
+                                               global $wgEmailConfirmToEdit;
+
+                                               // AuthManager will check most of these, but that will make the auth
+                                               // session fail and this won't, so nicer to do it this way
+                                               if ( !$value && $wgEmailConfirmToEdit ) {
+                                                       // no point in allowing registration without email when email is
+                                                       // required to edit
+                                                       return $this->msg( 'noemailtitle' );
+                                               } elseif ( !$value && !empty( $alldata['mailpassword'] ) ) {
+                                                       // cannot send password via email when there is no email address
+                                                       return $this->msg( 'noemailcreate' );
+                                               } elseif ( $value && !Sanitizer::validateEmail( $value ) ) {
+                                                       return $this->msg( 'invalidemailaddress' );
+                                               }
+                                               return true;
+                                       },
+                                       'placeholder-message' => 'createacct-' . $anotherPart . 'email-ph',
+                               ],
+                               'realname' => [
+                                       'type' => 'text',
+                                       'help-message' => $isLoggedIn ? 'createacct-another-realname-tip'
+                                               : 'prefs-help-realname',
+                                       'label-message' => 'createacct-realname',
+                                       'cssclass' => 'loginText',
+                                       'size' => 20,
+                                       'id' => 'wpRealName',
+                               ],
+                               'reason' => [
+                                       // comment for the user creation log
+                                       'type' => 'text',
+                                       'label-message' => 'createacct-reason',
+                                       'cssclass' => 'loginText',
+                                       'id' => 'wpReason',
+                                       'size' => '20',
+                                       'placeholder-message' => 'createacct-reason-ph',
+                               ],
+                               'extrainput' => [], // placeholder for fields coming from the template
+                               'createaccount' => [
+                                       // submit button
+                                       'type' => 'submit',
+                                       'default' => $this->msg( 'createacct-' . $anotherPart . $continuePart .
+                                               'submit' )->text(),
+                                       'name' => 'wpCreateaccount',
+                                       'id' => 'wpCreateaccount',
+                                       'weight' => 100,
+                               ],
+                       ];
+               } else {
+                       $fieldDefinitions = [
+                               'username' => [
+                                       'label-raw' => $this->msg( 'userlogin-yourname' )->escaped() . $secureLoginLink,
+                                       'id' => 'wpName1',
+                                       'placeholder-message' => 'userlogin-yourname-ph',
+                               ],
+                               'password' => [
+                                       'id' => 'wpPassword1',
+                                       'placeholder-message' => 'userlogin-yourpassword-ph',
+                               ],
+                               'domain' => [],
+                               'extrainput' => [],
+                               'rememberMe' => [
+                                       // option for saving the user token to a cookie
+                                       'type' => 'check',
+                                       'label-message' => $this->msg( 'userlogin-remembermypassword' )
+                                               ->numParams( $expirationDays ),
+                                       'id' => 'wpRemember',
+                               ],
+                               'loginattempt' => [
+                                       // submit button
+                                       'type' => 'submit',
+                                       'name' => 'wpRemember',
+                                       'default' => $this->msg( 'pt-login-' . $continuePart . 'button' )->text(),
+                                       'id' => 'wpLoginAttempt',
+                                       'weight' => 100,
+                               ],
+                               'linkcontainer' => [
+                                       // help link
+                                       'type' => 'info',
+                                       'cssclass' => 'mw-form-related-link-container',
+                                       'id' => 'mw-userlogin-help',
+                                       'raw' => true,
+                                       'default' => Html::element( 'a', [
+                                               'href' => Skin::makeInternalOrExternalUrl( wfMessage( 'helplogin-url' )
+                                                       ->inContentLanguage()
+                                                       ->text() ),
+                                       ], $this->msg( 'userlogin-helplink2' )->text() ),
+                                       'weight' => 200,
+                               ],
+                       ];
+               }
+               $fieldDefinitions['username'] += [
+                       'type' => 'text',
+                       'name' => 'wpName',
+                       'cssclass' => 'loginText',
+                       'size' => 20,
+                       // 'required' => true,
+               ];
+               $fieldDefinitions['password'] += [
+                       'type' => 'password',
+                       // 'label-message' => 'userlogin-yourpassword', // would override the changepassword label
+                       'name' => 'wpPassword',
+                       'cssclass' => 'loginPassword',
+                       'size' => 20,
+                       // 'required' => true,
+               ];
+
+               if ( !$this->showExtraInformation() ) {
+                       unset( $fieldDefinitions['linkcontainer'] );
+               }
+
+               $fieldDefinitions = $this->getBCFieldDefinitions( $fieldDefinitions, $template );
+               $fieldDefinitions = array_filter( $fieldDefinitions );
+
+               return $fieldDefinitions;
+       }
+
+       /**
+        * Adds fields provided via the deprecated UserLoginForm / UserCreateForm hooks
+        * @param $fieldDefinitions array
+        * @param FakeAuthTemplate $template
+        * @return array
+        */
+       protected function getBCFieldDefinitions( $fieldDefinitions, $template ) {
+               if ( $template->get( 'usedomain', false ) ) {
+                       // TODO probably should be translated to the new domain notation in AuthManager
+                       $fieldDefinitions['domain'] = [
+                               'type' => 'select',
+                               'label-message' => 'yourdomainname',
+                               'options' => array_combine( $template->get( 'domainnames', [] ),
+                                       $template->get( 'domainnames', [] ) ),
+                               'default' => $template->get( 'domain', '' ),
+                               'name' => 'wpDomain',
+                               // FIXME id => 'mw-user-domain-section' on the parent div
+                       ];
+               }
+
+               // poor man's associative array_splice
+               $extraInputPos = array_search( 'extrainput', array_keys( $fieldDefinitions ), true );
+               $fieldDefinitions = array_slice( $fieldDefinitions, 0, $extraInputPos, true )
+                                                       + $template->getExtraInputDefinitions()
+                                                       + array_slice( $fieldDefinitions, $extraInputPos + 1, null, true );
+
+               return $fieldDefinitions;
+       }
+
+       /**
+        * Check if a session cookie is present.
+        *
+        * This will not pick up a cookie set during _this_ request, but is meant
+        * to ensure that the client is returning the cookie which was set on a
+        * previous pass through the system.
+        *
+        * @return bool
+        */
+       protected function hasSessionCookie() {
+               global $wgDisableCookieCheck, $wgInitialSessionId;
+
+               return $wgDisableCookieCheck || (
+                       $wgInitialSessionId &&
+                       $this->getRequest()->getSession()->getId() === (string)$wgInitialSessionId
+               );
+       }
+
+       /**
+        * Returns a string that can be appended to the URL (without encoding) to preserve the
+        * return target. Does not include leading '?'/'&'.
+        */
+       protected function getReturnToQueryStringFragment() {
+               $returnto = '';
+               if ( $this->mReturnTo !== '' ) {
+                       $returnto = 'returnto=' . wfUrlencode( $this->mReturnTo );
+                       if ( $this->mReturnToQuery !== '' ) {
+                               $returnto .= '&returntoquery=' . wfUrlencode( $this->mReturnToQuery );
+                       }
+               }
+               return $returnto;
+       }
+
+       /**
+        * Whether the login/create account form should display a link to the
+        * other form (in addition to whatever the skin provides).
+        * @return bool
+        */
+       private function showCreateAccountLink() {
+               if ( $this->isSignup() ) {
+                       return true;
+               } elseif ( $this->getUser()->isAllowed( 'createaccount' ) ) {
+                       return true;
+               } else {
+                       return false;
+               }
+       }
+
+       protected function getTokenName() {
+               return $this->isSignup() ? 'wpCreateaccountToken' : 'wpLoginToken';
+       }
+
+       /**
+        * Produce a bar of links which allow the user to select another language
+        * during login/registration but retain "returnto"
+        *
+        * @return string
+        */
+       protected function makeLanguageSelector() {
+               $msg = $this->msg( 'loginlanguagelinks' )->inContentLanguage();
+               if ( $msg->isBlank() ) {
+                       return '';
+               }
+               $langs = explode( "\n", $msg->text() );
+               $links = [];
+               foreach ( $langs as $lang ) {
+                       $lang = trim( $lang, '* ' );
+                       $parts = explode( '|', $lang );
+                       if ( count( $parts ) >= 2 ) {
+                               $links[] = $this->makeLanguageSelectorLink( $parts[0], trim( $parts[1] ) );
+                       }
+               }
+
+               return count( $links ) > 0 ? $this->msg( 'loginlanguagelabel' )->rawParams(
+                       $this->getLanguage()->pipeList( $links ) )->escaped() : '';
+       }
+
+       /**
+        * Create a language selector link for a particular language
+        * Links back to this page preserving type and returnto
+        *
+        * @param string $text Link text
+        * @param string $lang Language code
+        * @return string
+        */
+       protected function makeLanguageSelectorLink( $text, $lang ) {
+               if ( $this->getLanguage()->getCode() == $lang ) {
+                       // no link for currently used language
+                       return htmlspecialchars( $text );
+               }
+               $query = [ 'uselang' => $lang ];
+               if ( $this->mReturnTo !== '' ) {
+                       $query['returnto'] = $this->mReturnTo;
+                       $query['returntoquery'] = $this->mReturnToQuery;
+               }
+
+               $attr = [];
+               $targetLanguage = Language::factory( $lang );
+               $attr['lang'] = $attr['hreflang'] = $targetLanguage->getHtmlCode();
+
+               return Linker::linkKnown(
+                       $this->getPageTitle(),
+                       htmlspecialchars( $text ),
+                       $attr,
+                       $query
+               );
+       }
+
+       protected function getGroupName() {
+               return 'login';
+       }
+
+       /**
+        * @param array $formDescriptor
+        */
+       protected function postProcessFormDescriptor( &$formDescriptor ) {
+               // Pre-fill username (if not creating an account, T46775).
+               if (
+                       isset( $formDescriptor['username'] ) &&
+                       !isset( $formDescriptor['username']['default'] ) &&
+                       !$this->isSignup()
+               ) {
+                       $user = $this->getUser();
+                       if ( $user->isLoggedIn() ) {
+                               $formDescriptor['username']['default'] = $user->getName();
+                       } else {
+                               $formDescriptor['username']['default'] =
+                                       $this->getRequest()->getSession()->suggestLoginUsername();
+                       }
+               }
+
+               // don't show a submit button if there is nothing to submit (i.e. the only form content
+               // is other submit buttons, for redirect flows)
+               if ( !$this->needsSubmitButton( $formDescriptor ) ) {
+                       unset( $formDescriptor['createaccount'], $formDescriptor['loginattempt'] );
+               }
+
+               if ( !$this->isSignup() ) {
+                       // FIXME HACK don't focus on non-empty field
+                       // maybe there should be an autofocus-if similar to hide-if?
+                       if (
+                               isset( $formDescriptor['username'] )
+                               && empty( $formDescriptor['username']['default'] )
+                               && !$this->getRequest()->getCheck( 'wpName' )
+                       ) {
+                               $formDescriptor['username']['autofocus'] = true;
+                       } elseif ( isset( $formDescriptor['password'] ) ) {
+                               $formDescriptor['password']['autofocus'] = true;
+                       }
+               }
+
+               $this->addTabIndex( $formDescriptor );
+       }
+}
+
+/**
+ * B/C class to try handling login/signup template modifications even though login/signup does not
+ * actually happen through a template anymore. Just collects extra field definitions and allows
+ * some other class to do decide what to do with threm..
+ * TODO find the right place for adding extra fields and kill this
+ */
+class FakeAuthTemplate extends BaseTemplate {
+       public function execute() {
+               throw new LogicException( 'not used' );
+       }
+
+       /**
+        * Extensions (AntiSpoof and TitleBlacklist) call this in response to
+        * UserCreateForm hook to add checkboxes to the create account form.
+        */
+       public function addInputItem( $name, $value, $type, $msg, $helptext = false ) {
+               // use the same indexes as UserCreateForm just in case someone adds an item manually
+               $this->data['extrainput'][] = [
+                       'name' => $name,
+                       'value' => $value,
+                       'type' => $type,
+                       'msg' => $msg,
+                       'helptext' => $helptext,
+               ];
+       }
+
+       /**
+        * Turns addInputItem-style field definitions into HTMLForm field definitions.
+        * @return array
+        */
+       public function getExtraInputDefinitions() {
+               $definitions = [];
+
+               foreach ( $this->get( 'extrainput', [] ) as $field ) {
+                       $definition = [
+                               'type' => $field['type'] === 'checkbox' ? 'check' : $field['type'],
+                               'name' => $field['name'],
+                               'value' => $field['value'],
+                               'id' => $field['name'],
+                       ];
+                       if ( $field['msg'] ) {
+                               $definition['label-message'] = $this->getMsg( $field['msg'] );
+                       }
+                       if ( $field['helptext'] ) {
+                               $definition['help'] = $this->msgWiki( $field['helptext'] );
+                       }
+
+                       // the array key doesn't matter much when name is defined explicitly but
+                       // let's try and follow HTMLForm conventions
+                       $name = preg_replace( '/^wp(?=[A-Z])/', '', $field['name'] );
+                       $definitions[$name] = $definition;
+               }
+
+               if ( $this->haveData( 'extrafields' ) ) {
+                       $definitions['extrafields'] = [
+                               'type' => 'info',
+                               'raw' => true,
+                               'default' => $this->get( 'extrafields' ),
+                       ];
+               }
+
+               return $definitions;
+       }
+}
+
+/**
+ * A horrible hack to handle AuthManager's feature flag. For other special pages this is done in
+ * SpecialPageFactory, but LoginForm is used directly by some extensions. Will be killed as soon
+ * as AuthManager is stable.
+ */
+class LoginForm extends SpecialPage {
+       private $realLoginForm;
+
+       public function __construct( $request = null ) {
+               global $wgDisableAuthManager;
+               if ( $wgDisableAuthManager ) {
+                       $this->realLoginForm = new LoginFormPreAuthManager( $request );
+               } else {
+                       $this->realLoginForm = new LoginFormAuthManager( $request );
+               }
+       }
+
+       // proxy everything
+
+       public function __get( $name ) {
+               return $this->realLoginForm->$name;
+       }
+
+       public function __set( $name, $value ) {
+               $this->realLoginForm->$name = $value;
+       }
+
+       public function __call( $name, $args ) {
+               return call_user_func_array( [ $this->realLoginForm, $name ], $args );
+       }
+
+       public static function __callStatic( $name, $args ) {
+               global $wgDisableAuthManager;
+               return call_user_func_array( [ $wgDisableAuthManager ? LoginFormPreAuthManager::class
+                       : LoginFormAuthManager::class, $name ], $args );
+       }
+
+       // all public SpecialPage methods need to be proxied explicitly
+
+       public function getName() {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function getRestriction() {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function isListed() {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function setListed( $listed ) {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function listed( $x = null ) {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function isIncludable() {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function including( $x = null ) {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function getLocalName() {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function isExpensive() {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function isCached() {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function isRestricted() {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function userCanExecute( User $user ) {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function displayRestrictionError() {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function checkPermissions() {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function checkReadOnly() {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function requireLogin(
+               $reasonMsg = 'exception-nologin-text', $titleMsg = 'exception-nologin'
+       ) {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function prefixSearchSubpages( $search, $limit, $offset ) {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function execute( $subPage ) {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function getDescription() {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       function getTitle( $subpage = false ) {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       function getPageTitle( $subpage = false ) {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function setContext( $context ) {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function getContext() {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function getRequest() {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function getOutput() {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function getUser() {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function getSkin() {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function getLanguage() {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function getConfig() {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function getFullTitle() {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function getFinalGroupName() {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+       public function doesWrites() {
+               return call_user_func_array( [ $this->realLoginForm, __FUNCTION__ ], func_get_args() );
+       }
+
+       // no way to proxy constants and static properties
+
+       const SUCCESS = 0;
+       const NO_NAME = 1;
+       const ILLEGAL = 2;
+       const WRONG_PLUGIN_PASS = 3;
+       const NOT_EXISTS = 4;
+       const WRONG_PASS = 5;
+       const EMPTY_PASS = 6;
+       const RESET_PASS = 7;
+       const ABORTED = 8;
+       const CREATE_BLOCKED = 9;
+       const THROTTLED = 10;
+       const USER_BLOCKED = 11;
+       const NEED_TOKEN = 12;
+       const WRONG_TOKEN = 13;
+       const USER_MIGRATED = 14;
+
+       public static $statusCodes = [
+               self::SUCCESS => 'success',
+               self::NO_NAME => 'no_name',
+               self::ILLEGAL => 'illegal',
+               self::WRONG_PLUGIN_PASS => 'wrong_plugin_pass',
+               self::NOT_EXISTS => 'not_exists',
+               self::WRONG_PASS => 'wrong_pass',
+               self::EMPTY_PASS => 'empty_pass',
+               self::RESET_PASS => 'reset_pass',
+               self::ABORTED => 'aborted',
+               self::CREATE_BLOCKED => 'create_blocked',
+               self::THROTTLED => 'throttled',
+               self::USER_BLOCKED => 'user_blocked',
+               self::NEED_TOKEN => 'need_token',
+               self::WRONG_TOKEN => 'wrong_token',
+               self::USER_MIGRATED => 'user_migrated',
+       ];
+
+       public static $validErrorMessages = [
+               'exception-nologin-text',
+               'watchlistanontext',
+               'changeemail-no-info',
+               'resetpass-no-info',
+               'confirmemail_needlogin',
+               'prefsnologintext2',
+       ];
+}
+
+/**
+ * LoginForm as a special page has been replaced by SpecialUserLogin and SpecialCreateAccount,
+ * but some extensions called its public methods directly, so the class is retained as a
+ * B/C wrapper. Anything that used it before should use AuthManager instead.
+ */
+class LoginFormAuthManager extends SpecialPage {
+       const SUCCESS = 0;
+       const NO_NAME = 1;
+       const ILLEGAL = 2;
+       const WRONG_PLUGIN_PASS = 3;
+       const NOT_EXISTS = 4;
+       const WRONG_PASS = 5;
+       const EMPTY_PASS = 6;
+       const RESET_PASS = 7;
+       const ABORTED = 8;
+       const CREATE_BLOCKED = 9;
+       const THROTTLED = 10;
+       const USER_BLOCKED = 11;
+       const NEED_TOKEN = 12;
+       const WRONG_TOKEN = 13;
+       const USER_MIGRATED = 14;
+
+       public static $statusCodes = [
+               self::SUCCESS => 'success',
+               self::NO_NAME => 'no_name',
+               self::ILLEGAL => 'illegal',
+               self::WRONG_PLUGIN_PASS => 'wrong_plugin_pass',
+               self::NOT_EXISTS => 'not_exists',
+               self::WRONG_PASS => 'wrong_pass',
+               self::EMPTY_PASS => 'empty_pass',
+               self::RESET_PASS => 'reset_pass',
+               self::ABORTED => 'aborted',
+               self::CREATE_BLOCKED => 'create_blocked',
+               self::THROTTLED => 'throttled',
+               self::USER_BLOCKED => 'user_blocked',
+               self::NEED_TOKEN => 'need_token',
+               self::WRONG_TOKEN => 'wrong_token',
+               self::USER_MIGRATED => 'user_migrated',
+       ];
+
+       /**
+        * @param WebRequest $request
+        */
+       public function __construct( $request = null ) {
+               wfDeprecated( 'LoginForm', '1.27' );
+               parent::__construct();
+       }
+
+       /**
+        * @deprecated since 1.27 - call LoginHelper::getValidErrorMessages instead.
+        */
+       public static function getValidErrorMessages() {
+               return LoginHelper::getValidErrorMessages();
+       }
+
+       /**
+        * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+        */
+       public static function incrementLoginThrottle( $username ) {
+               wfDeprecated( __METHOD__, "1.27" );
+               global $wgRequest;
+               $username = User::getCanonicalName( $username, 'usable' ) ?: $username;
+               $throttler = new Throttler();
+               return $throttler->increase( $username, $wgRequest->getIP(), __METHOD__ );
+       }
+
+       /**
+        * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+        */
+       public static function incLoginThrottle( $username ) {
+               wfDeprecated( __METHOD__, "1.27" );
+               $res = self::incrementLoginThrottle( $username );
+               return is_array( $res ) ? true : 0;
+       }
+
+       /**
+        * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+        */
+       public static function clearLoginThrottle( $username ) {
+               wfDeprecated( __METHOD__, "1.27" );
+               global $wgRequest;
+               $username = User::getCanonicalName( $username, 'usable' ) ?: $username;
+               $throttler = new Throttler();
+               return $throttler->clear( $username, $wgRequest->getIP() );
+       }
+
+       /**
+        * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+        */
+       public static function getLoginToken() {
+               wfDeprecated( __METHOD__, '1.27' );
+               global $wgRequest;
+               return $wgRequest->getSession()->getToken( '', 'login' )->toString();
+       }
+
+       /**
+        * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+        */
+       public static function setLoginToken() {
+               wfDeprecated( __METHOD__, '1.27' );
+       }
+
+       /**
+        * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+        */
+       public static function clearLoginToken() {
+               wfDeprecated( __METHOD__, '1.27' );
+               global $wgRequest;
+               $wgRequest->getSession()->resetToken( 'login' );
+       }
+
+       /**
+        * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+        */
+       public static function getCreateaccountToken() {
+               wfDeprecated( __METHOD__, '1.27' );
+               global $wgRequest;
+               return $wgRequest->getSession()->getToken( '', 'createaccount' )->toString();
+       }
+
+       /**
+        * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+        */
+       public static function setCreateaccountToken() {
+               wfDeprecated( __METHOD__, '1.27' );
+       }
+
+       /**
+        * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+        */
+       public static function clearCreateaccountToken() {
+               wfDeprecated( __METHOD__, '1.27' );
+               global $wgRequest;
+               $wgRequest->getSession()->resetToken( 'createaccount' );
+       }
+}
index b274017..408c726 100644 (file)
@@ -23,6 +23,8 @@ use MediaWiki\MediaWikiServices;
  * @ingroup SpecialPage
  */
 
+use MediaWiki\Auth\AuthManager;
+
 /**
  * Parent class for all special pages.
  *
@@ -296,6 +298,66 @@ class SpecialPage {
                }
        }
 
+       /**
+        * Tells if the special page does something security-sensitive and needs extra defense against
+        * a stolen account (e.g. a reauthentication). What exactly that will mean is decided by the
+        * authentication framework.
+        * @return bool|string False or the argument for AuthManager::securitySensitiveOperationStatus().
+        *   Typically a special page needing elevated security would return its name here.
+        */
+       protected function getLoginSecurityLevel() {
+               return false;
+       }
+
+       /**
+        * Verifies that the user meets the security level, possibly reauthenticating them in the process.
+        *
+        * This should be used when the page does something security-sensitive and needs extra defense
+        * against a stolen account (e.g. a reauthentication). The authentication framework will make
+        * an extra effort to make sure the user account is not compromised. What that exactly means
+        * will depend on the system and user settings; e.g. the user might be required to log in again
+        * unless their last login happened recently, or they might be given a second-factor challenge.
+        *
+        * Calling this method will result in one if these actions:
+        * - return true: all good.
+        * - return false and set a redirect: caller should abort; the redirect will take the user
+        *   to the login page for reauthentication, and back.
+        * - throw an exception if there is no way for the user to meet the requirements without using
+        *   a different access method (e.g. this functionality is only available from a specific IP).
+        *
+        * Note that this does not in any way check that the user is authorized to use this special page
+        * (use checkPermissions() for that).
+        *
+        * @param string $level A security level. Can be an arbitrary string, defaults to the page name.
+        * @return bool False means a redirect to the reauthentication page has been set and processing
+        *   of the special page should be aborted.
+        * @throws ErrorPageError If the security level cannot be met, even with reauthentication.
+        */
+       protected function checkLoginSecurityLevel( $level = null ) {
+               $level = $level ?: $this->getName();
+               $securityStatus = AuthManager::singleton()->securitySensitiveOperationStatus( $level );
+               if ( $securityStatus === AuthManager::SEC_OK ) {
+                       return true;
+               } elseif ( $securityStatus === AuthManager::SEC_REAUTH ) {
+                       $request = $this->getRequest();
+                       $title = SpecialPage::getTitleFor( 'Userlogin' );
+                       $query = [
+                               'returnto' => $this->getFullTitle()->getPrefixedDBkey(),
+                               'returntoquery' => wfArrayToCgi( array_diff_key( $request->getQueryValues(),
+                                       [ 'title' => true ] ) ),
+                               'force' => $level,
+                       ];
+                       $url = $title->getFullURL( $query, false, PROTO_HTTPS );
+
+                       $this->getOutput()->redirect( $url );
+                       return false;
+               }
+
+               $titleMessage = wfMessage( 'specialpage-securitylevel-not-allowed-title' );
+               $errorMessage = wfMessage( 'specialpage-securitylevel-not-allowed' );
+               throw new ErrorPageError( $titleMessage, $errorMessage );
+       }
+
        /**
         * Return an array of subpages beginning with $search that this special page will accept.
         *
@@ -463,6 +525,7 @@ class SpecialPage {
        public function execute( $subPage ) {
                $this->setHeaders();
                $this->checkPermissions();
+               $this->checkLoginSecurityLevel( $this->getLoginSecurityLevel() );
                $this->outputHeader();
        }
 
index 4c869f9..45c5f7d 100644 (file)
@@ -81,18 +81,23 @@ class SpecialPageFactory {
                'PagesWithProp' => 'SpecialPagesWithProp',
                'TrackingCategories' => 'SpecialTrackingCategories',
 
-               // Login/create account
-               'Userlogin' => 'LoginForm',
-               'CreateAccount' => 'SpecialCreateAccount',
+               // Authentication
+               'Userlogin' => 'SpecialUserLogin',
+               'Userlogout' => 'SpecialUserLogoutPreAuthManager',
+               'CreateAccount' => 'SpecialCreateAccountPreAuthManager',
+               'LinkAccounts' => 'SpecialLinkAccounts',
+               'UnlinkAccounts' => 'SpecialUnlinkAccounts',
+               'ChangeCredentials' => 'SpecialChangeCredentials',
+               'RemoveCredentials' => 'SpecialRemoveCredentials',
 
                // Users and rights
                'Activeusers' => 'SpecialActiveUsers',
                'Block' => 'SpecialBlock',
                'Unblock' => 'SpecialUnblock',
                'BlockList' => 'SpecialBlockList',
-               'ChangePassword' => 'SpecialChangePassword',
+               'ChangePassword' => 'SpecialChangePasswordPreAuthManager',
                'BotPasswords' => 'SpecialBotPasswords',
-               'PasswordReset' => 'SpecialPasswordReset',
+               'PasswordReset' => 'SpecialPasswordResetPreAuthManager',
                'DeletedContributions' => 'DeletedContributionsPage',
                'Preferences' => 'SpecialPreferences',
                'ResetTokens' => 'SpecialResetTokens',
@@ -178,7 +183,6 @@ class SpecialPageFactory {
                'Revisiondelete' => 'SpecialRevisionDelete',
                'RunJobs' => 'SpecialRunJobs',
                'Specialpages' => 'SpecialSpecialpages',
-               'Userlogout' => 'SpecialUserlogout',
        ];
 
        private static $list;
@@ -226,6 +230,7 @@ class SpecialPageFactory {
                global $wgDisableInternalSearch, $wgEmailAuthentication;
                global $wgEnableEmail, $wgEnableJavaScriptTest;
                global $wgPageLanguageUseDB, $wgContentHandlerUseDB;
+               global $wgDisableAuthManager;
 
                if ( !is_array( self::$list ) ) {
 
@@ -241,7 +246,7 @@ class SpecialPageFactory {
                        }
 
                        if ( $wgEnableEmail ) {
-                               self::$list['ChangeEmail'] = 'SpecialChangeEmail';
+                               self::$list['ChangeEmail'] = 'SpecialChangeEmailPreAuthManager';
                        }
 
                        if ( $wgEnableJavaScriptTest ) {
@@ -255,6 +260,19 @@ class SpecialPageFactory {
                                self::$list['ChangeContentModel'] = 'SpecialChangeContentModel';
                        }
 
+                       // horrible hack to allow selection between old and new classes via a feature flag - T110756
+                       // will be removed once AuthManager is stable
+                       if ( !$wgDisableAuthManager ) {
+                               self::$list = array_map( function ( $class ) {
+                                       return preg_replace( '/PreAuthManager$/', '', $class );
+                               }, self::$list );
+                       } else {
+                               self::$list['Userlogin'] = 'LoginForm';
+                               self::$list = array_diff_key( self::$list, array_fill_keys( [
+                                       'LinkAccounts', 'UnlinkAccounts', 'ChangeCredentials', 'RemoveCredentials',
+                               ], true ) );
+                       }
+
                        // Add extension special pages
                        self::$list = array_merge( self::$list, $wgSpecialPages );
 
diff --git a/includes/specials/SpecialChangeCredentials.php b/includes/specials/SpecialChangeCredentials.php
new file mode 100644 (file)
index 0000000..382dac7
--- /dev/null
@@ -0,0 +1,252 @@
+<?php
+
+use MediaWiki\Auth\AuthenticationRequest;
+use MediaWiki\Auth\AuthenticationResponse;
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Session\SessionManager;
+
+/**
+ * Special change to change credentials (such as the password).
+ *
+ * Also does most of the work for SpecialRemoveCredentials.
+ */
+class SpecialChangeCredentials extends AuthManagerSpecialPage {
+       protected static $allowedActions = [ AuthManager::ACTION_CHANGE ];
+
+       protected static $messagePrefix = 'changecredentials';
+
+       /** Change action needs user data; remove action does not */
+       protected static $loadUserData = true;
+
+       public function __construct( $name = 'ChangeCredentials' ) {
+               parent::__construct( $name, 'editmyprivateinfo' );
+       }
+
+       protected function getGroupName() {
+               return 'users';
+       }
+
+       public function isListed() {
+               $this->loadAuth( '' );
+               return (bool)$this->authRequests;
+       }
+
+       public function doesWrites() {
+               return true;
+       }
+
+       protected function getDefaultAction( $subPage ) {
+               return AuthManager::ACTION_CHANGE;
+       }
+
+       protected function getPreservedParams( $withToken = false ) {
+               $request = $this->getRequest();
+               $params = parent::getPreservedParams( $withToken );
+               $params += [
+                       'returnto' => $request->getVal( 'returnto' ),
+                       'returntoquery' => $request->getVal( 'returntoquery' ),
+               ];
+               return $params;
+       }
+
+       public function onAuthChangeFormFields(
+               array $requests, array $fieldInfo, array &$formDescriptor, $action
+       ) {
+               // This method is never called for remove actions.
+
+               $extraFields = [];
+               Hooks::run( 'ChangePasswordForm', [ &$extraFields ], '1.27' );
+               foreach ( $extraFields as $extra ) {
+                       list( $name, $label, $type, $default ) = $extra;
+                       $formDescriptor[$name] = [
+                               'type' => $type,
+                               'name' => $name,
+                               'label-message' => $label,
+                               'default' => $default,
+                       ];
+
+               }
+
+               return parent::onAuthChangeFormFields( $requests, $fieldInfo, $formDescriptor, $action );
+       }
+
+       public function execute( $subPage ) {
+               $this->setHeaders();
+               $this->outputHeader();
+
+               $this->loadAuth( $subPage );
+
+               if ( !$subPage ) {
+                       $this->showSubpageList();
+                       return;
+               }
+
+               if ( $this->getRequest()->getCheck( 'wpCancel' ) ) {
+                       $returnUrl = $this->getReturnUrl() ?: Title::newMainPage()->getFullURL();
+                       $this->getOutput()->redirect( $returnUrl );
+                       return;
+               }
+
+               if ( !$this->authRequests ) {
+                       // messages used: changecredentials-invalidsubpage, removecredentials-invalidsubpage
+                       $this->showSubpageList( $this->msg( static::$messagePrefix . '-invalidsubpage', $subPage ) );
+                       return;
+               }
+
+               $status = $this->trySubmit();
+
+               if ( $status === false || !$status->isOK() ) {
+                       $this->displayForm( $status );
+                       return;
+               }
+
+               $response = $status->getValue();
+
+               switch ( $response->status ) {
+                       case AuthenticationResponse::PASS:
+                               $this->success();
+                               break;
+                       case AuthenticationResponse::FAIL:
+                               $this->displayForm( Status::newFatal( $response->message ) );
+                               break;
+                       default:
+                               throw new LogicException( 'invalid AuthenticationResponse' );
+               }
+       }
+
+       protected function loadAuth( $subPage, $authAction = null, $reset = false ) {
+               parent::loadAuth( $subPage, $authAction );
+               if ( $subPage ) {
+                       $this->authRequests = array_filter( $this->authRequests, function ( $req ) use ( $subPage ) {
+                               return $req->getUniqueId() === $subPage;
+                       } );
+                       if ( count( $this->authRequests ) > 1 ) {
+                               throw new LogicException( 'Multiple AuthenticationRequest objects with same ID!' );
+                       }
+               }
+       }
+
+       protected function getAuthFormDescriptor( $requests, $action ) {
+               if ( !static::$loadUserData ) {
+                       return [];
+               } else {
+                       return parent::getAuthFormDescriptor( $requests, $action );
+               }
+       }
+
+       protected function getAuthForm( array $requests, $action ) {
+               $form = parent::getAuthForm( $requests, $action );
+               $req = reset( $requests );
+               $info = $req->describeCredentials();
+
+               $form->addPreText(
+                       Html::openElement( 'dl' )
+                       . Html::element( 'dt', [], wfMessage( 'credentialsform-provider' ) )
+                       . Html::element( 'dd', [], $info['provider'] )
+                       . Html::element( 'dt', [], wfMessage( 'credentialsform-account' ) )
+                       . Html::element( 'dd', [], $info['account'] )
+                       . Html::closeElement( 'dl' )
+               );
+
+               // messages used: changecredentials-submit removecredentials-submit
+               // changecredentials-submit-cancel removecredentials-submit-cancel
+               $form->setSubmitTextMsg( static::$messagePrefix . '-submit' );
+               $form->addButton( [
+                       'name' => 'wpCancel',
+                       'value' => $this->msg( static::$messagePrefix . '-submit-cancel' )->text()
+               ] );
+
+               return $form;
+       }
+
+       protected function needsSubmitButton( $formDescriptor ) {
+               // Change/remove forms show are built from a single AuthenticationRequest and do not allow
+               // for redirect flow; they always need a submit button.
+               return true;
+       }
+
+       public function handleFormSubmit( $data ) {
+               // remove requests do not accept user input
+               $requests = $this->authRequests;
+               if ( static::$loadUserData ) {
+                       $requests = AuthenticationRequest::loadRequestsFromSubmission( $this->authRequests, $data );
+               }
+
+               $response = $this->performAuthenticationStep( $this->authAction, $requests );
+
+               // we can't handle FAIL or similar as failure here since it might require changing the form
+               return Status::newGood( $response );
+       }
+
+       /**
+        * @param Message|null $error
+        */
+       protected function showSubpageList( $error = null ) {
+               $out = $this->getOutput();
+
+               if ( $error ) {
+                       $out->addHTML( $error->parse() );
+               }
+
+               $groupedRequests = [];
+               foreach ( $this->authRequests as $req ) {
+                       $info = $req->describeCredentials();
+                       $groupedRequests[(string)$info['provider']][] = $req;
+               }
+
+               $out->addHTML( Html::openElement( 'dl' ) );
+               foreach ( $groupedRequests as $group => $members ) {
+                       $out->addHTML( Html::element( 'dt', [], $group ) );
+                       foreach ( $members as $req ) {
+                               /** @var AuthenticationRequest $req */
+                               $info = $req->describeCredentials();
+                               $out->addHTML( Html::rawElement( 'dd', [],
+                                       Linker::link( $this->getPageTitle( $req->getUniqueId() ),
+                                               htmlspecialchars( $info['account'], ENT_QUOTES ) )
+                               ) );
+                       }
+               }
+               $out->addHTML( Html::closeElement( 'dl' ) );
+       }
+
+       protected function success() {
+               $session = $this->getRequest()->getSession();
+               $user = $this->getUser();
+               $out = $this->getOutput();
+               $returnUrl = $this->getReturnUrl();
+
+               // change user token and update the session
+               SessionManager::singleton()->invalidateSessionsForUser( $user );
+               $session->setUser( $user );
+               $session->resetId();
+
+               if ( $returnUrl ) {
+                       $out->redirect( $returnUrl );
+               } else {
+                       // messages used: changecredentials-success removecredentials-success
+                       $out->wrapWikiMsg( "<div class=\"successbox\">\n$1\n</div>", static::$messagePrefix
+                               . '-success' );
+                       $out->returnToMain();
+               }
+       }
+
+       /**
+        * @return string|null
+        */
+       protected function getReturnUrl() {
+               $request = $this->getRequest();
+               $returnTo = $request->getText( 'returnto' );
+               $returnToQuery = $request->getText( 'returntoquery', '' );
+
+               if ( !$returnTo ) {
+                       return null;
+               }
+
+               $title = Title::newFromText( $returnTo );
+               return $title->getFullURL( $returnToQuery );
+       }
+
+       protected function getRequestBlacklist() {
+               return $this->getConfig()->get( 'ChangeCredentialsBlacklist' );
+       }
+}
index b35446d..785447f 100644 (file)
@@ -21,6 +21,8 @@
  * @ingroup SpecialPage
  */
 
+use MediaWiki\Auth\AuthManager;
+
 /**
  * Let users change their email address.
  *
@@ -44,9 +46,7 @@ class SpecialChangeEmail extends FormSpecialPage {
         * @return bool
         */
        public function isListed() {
-               global $wgAuth;
-
-               return $wgAuth->allowPropChange( 'emailaddress' );
+               return AuthManager::singleton()->allowsPropertyChange( 'emailaddress' );
        }
 
        /**
@@ -54,6 +54,8 @@ class SpecialChangeEmail extends FormSpecialPage {
         * @param string $par
         */
        function execute( $par ) {
+               $this->checkLoginSecurityLevel();
+
                $out = $this->getOutput();
                $out->disallowUserJs();
 
@@ -61,9 +63,8 @@ class SpecialChangeEmail extends FormSpecialPage {
        }
 
        protected function checkExecutePermissions( User $user ) {
-               global $wgAuth;
 
-               if ( !$wgAuth->allowPropChange( 'emailaddress' ) ) {
+               if ( !AuthManager::singleton()->allowsPropertyChange( 'emailaddress' ) ) {
                        throw new ErrorPageError( 'changeemail', 'cannotchangeemail' );
                }
 
@@ -100,13 +101,6 @@ class SpecialChangeEmail extends FormSpecialPage {
                        ],
                ];
 
-               if ( $this->getConfig()->get( 'RequirePasswordforEmailChange' ) ) {
-                       $fields['Password'] = [
-                               'type' => 'password',
-                               'label-message' => 'changeemail-password'
-                       ];
-               }
-
                return $fields;
        }
 
@@ -121,14 +115,10 @@ class SpecialChangeEmail extends FormSpecialPage {
                $form->addHiddenFields( $this->getRequest()->getValues( 'returnto', 'returntoquery' ) );
 
                $form->addHeaderText( $this->msg( 'changeemail-header' )->parseAsBlock() );
-               if ( $this->getConfig()->get( 'RequirePasswordforEmailChange' ) ) {
-                       $form->addHeaderText( $this->msg( 'changeemail-passwordrequired' )->parseAsBlock() );
-               }
        }
 
        public function onSubmit( array $data ) {
-               $password = isset( $data['Password'] ) ? $data['Password'] : null;
-               $status = $this->attemptChange( $this->getUser(), $password, $data['NewEmail'] );
+               $status = $this->attemptChange( $this->getUser(), $data['NewEmail'] );
 
                $this->status = $status;
 
@@ -158,12 +148,11 @@ class SpecialChangeEmail extends FormSpecialPage {
 
        /**
         * @param User $user
-        * @param string $pass
         * @param string $newaddr
         * @return Status
         */
-       private function attemptChange( User $user, $pass, $newaddr ) {
-               global $wgAuth;
+       private function attemptChange( User $user, $newaddr ) {
+               $authManager = AuthManager::singleton();
 
                if ( $newaddr != '' && !Sanitizer::validateEmail( $newaddr ) ) {
                        return Status::newFatal( 'invalidemailaddress' );
@@ -173,24 +162,6 @@ class SpecialChangeEmail extends FormSpecialPage {
                        return Status::newFatal( 'changeemail-nochange' );
                }
 
-               $throttleInfo = LoginForm::incrementLoginThrottle( $user->getName() );
-               if ( $throttleInfo ) {
-                       $lang = $this->getLanguage();
-                       return Status::newFatal(
-                               'changeemail-throttled',
-                               $lang->formatDuration( $throttleInfo['wait'] )
-                       );
-               }
-
-               if ( $this->getConfig()->get( 'RequirePasswordforEmailChange' )
-                       && !$user->checkTemporaryPassword( $pass )
-                       && !$user->checkPassword( $pass )
-               ) {
-                       return Status::newFatal( 'wrongpassword' );
-               }
-
-               LoginForm::clearLoginThrottle( $user->getName() );
-
                $oldaddr = $user->getEmail();
                $status = $user->setEmailWithConfirmation( $newaddr );
                if ( !$status->isGood() ) {
@@ -200,8 +171,7 @@ class SpecialChangeEmail extends FormSpecialPage {
                Hooks::run( 'PrefsEmailAudit', [ $user, $oldaddr, $newaddr ] );
 
                $user->saveSettings();
-
-               $wgAuth->updateExternalDB( $user );
+               MediaWiki\Auth\AuthManager::callLegacyAuthPlugin( 'updateExternalDB', [ $user ] );
 
                return $status;
        }
index 5adc315..ce769bf 100644 (file)
  * @ingroup SpecialPage
  */
 
+use MediaWiki\Auth\PasswordAuthenticationRequest;
+
 /**
  * Let users recover their password.
  *
  * @ingroup SpecialPage
  */
-class SpecialChangePassword extends FormSpecialPage {
-       protected $mUserName;
-       protected $mDomain;
-
-       // Optional Wikitext Message to show above the password change form
-       protected $mPreTextMessage = null;
-
-       // label for old password input
-       protected $mOldPassMsg = null;
-
+class SpecialChangePassword extends SpecialRedirectToSpecial {
        public function __construct() {
-               parent::__construct( 'ChangePassword', 'editmyprivateinfo' );
-               $this->listed( false );
-       }
-
-       public function doesWrites() {
-               return true;
-       }
-
-       /**
-        * Main execution point
-        * @param string|null $par
-        */
-       function execute( $par ) {
-               $this->getOutput()->disallowUserJs();
-
-               parent::execute( $par );
-       }
-
-       protected function checkExecutePermissions( User $user ) {
-               parent::checkExecutePermissions( $user );
-
-               if ( !$this->getRequest()->wasPosted() ) {
-                       $this->requireLogin( 'resetpass-no-info' );
-               }
-       }
-
-       /**
-        * Set a message at the top of the Change Password form
-        * @since 1.23
-        * @param Message $msg Message to parse and add to the form header
-        */
-       public function setChangeMessage( Message $msg ) {
-               $this->mPreTextMessage = $msg;
-       }
-
-       /**
-        * Set a message at the top of the Change Password form
-        * @since 1.23
-        * @param string $msg Message label for old/temp password field
-        */
-       public function setOldPasswordMessage( $msg ) {
-               $this->mOldPassMsg = $msg;
-       }
-
-       protected function getFormFields() {
-               $user = $this->getUser();
-               $request = $this->getRequest();
-
-               $oldpassMsg = $this->mOldPassMsg;
-               if ( $oldpassMsg === null ) {
-                       $oldpassMsg = $user->isLoggedIn() ? 'oldpassword' : 'resetpass-temp-password';
-               }
-
-               $fields = [
-                       'Name' => [
-                               'type' => 'info',
-                               'label-message' => 'username',
-                               'default' => $request->getVal( 'wpName', $user->getName() ),
-                       ],
-                       'Password' => [
-                               'type' => 'password',
-                               'label-message' => $oldpassMsg,
-                       ],
-                       'NewPassword' => [
-                               'type' => 'password',
-                               'label-message' => 'newpassword',
-                       ],
-                       'Retype' => [
-                               'type' => 'password',
-                               'label-message' => 'retypenew',
-                       ],
-               ];
-
-               if ( !$this->getUser()->isLoggedIn() ) {
-                       $fields['LoginOnChangeToken'] = [
-                               'type' => 'hidden',
-                               'label' => 'Change Password Token',
-                               'default' => LoginForm::getLoginToken()->toString(),
-                       ];
-               }
-
-               $extraFields = [];
-               Hooks::run( 'ChangePasswordForm', [ &$extraFields ] );
-               foreach ( $extraFields as $extra ) {
-                       list( $name, $label, $type, $default ) = $extra;
-                       $fields[$name] = [
-                               'type' => $type,
-                               'name' => $name,
-                               'label-message' => $label,
-                               'default' => $default,
-                       ];
-               }
-
-               if ( !$user->isLoggedIn() ) {
-                       $fields['Remember'] = [
-                               'type' => 'check',
-                               'label' => $this->msg( 'remembermypassword' )
-                                               ->numParams(
-                                                       ceil( $this->getConfig()->get( 'CookieExpiration' ) / ( 3600 * 24 ) )
-                                               )->text(),
-                               'default' => $request->getVal( 'wpRemember' ),
-                       ];
-               }
-
-               return $fields;
-       }
-
-       protected function alterForm( HTMLForm $form ) {
-               $form->setId( 'mw-resetpass-form' );
-               $form->setTableId( 'mw-resetpass-table' );
-               $form->setWrapperLegendMsg( 'resetpass_header' );
-               $form->setSubmitTextMsg(
-                       $this->getUser()->isLoggedIn()
-                               ? 'resetpass-submit-loggedin'
-                               : 'resetpass_submit'
-               );
-               $form->addButton( [
-                       'name' => 'wpCancel',
-                       'value' => $this->msg( 'resetpass-submit-cancel' )->text()
-               ] );
-               $form->setHeaderText( $this->msg( 'resetpass_text' )->parseAsBlock() );
-               if ( $this->mPreTextMessage instanceof Message ) {
-                       $form->addPreText( $this->mPreTextMessage->parseAsBlock() );
-               }
-               $form->addHiddenFields(
-                       $this->getRequest()->getValues( 'wpName', 'wpDomain', 'returnto', 'returntoquery' ) );
-       }
-
-       public function onSubmit( array $data ) {
-               global $wgAuth;
-
-               $request = $this->getRequest();
-
-               if ( $request->getCheck( 'wpLoginToken' ) ) {
-                       // This comes from Special:Userlogin when logging in with a temporary password
-                       return false;
-               }
-
-               if ( !$this->getUser()->isLoggedIn()
-                       && !LoginForm::getLoginToken()->match( $request->getVal( 'wpLoginOnChangeToken' ) )
-               ) {
-                       // Potential CSRF (bug 62497)
-                       return false;
-               }
-
-               if ( $request->getCheck( 'wpCancel' ) ) {
-                       $returnto = $request->getVal( 'returnto' );
-                       $titleObj = $returnto !== null ? Title::newFromText( $returnto ) : null;
-                       if ( !$titleObj instanceof Title ) {
-                               $titleObj = Title::newMainPage();
-                       }
-                       $query = $request->getVal( 'returntoquery' );
-                       $this->getOutput()->redirect( $titleObj->getFullURL( $query ) );
-
-                       return true;
-               }
-
-               $this->mUserName = $request->getVal( 'wpName', $this->getUser()->getName() );
-               $this->mDomain = $wgAuth->getDomain();
-
-               if ( !$wgAuth->allowPasswordChange() ) {
-                       throw new ErrorPageError( 'changepassword', 'resetpass_forbidden' );
-               }
-
-               $status = $this->attemptReset( $data['Password'], $data['NewPassword'], $data['Retype'] );
-
-               return $status;
-       }
-
-       public function onSuccess() {
-               if ( $this->getUser()->isLoggedIn() ) {
-                       $this->getOutput()->wrapWikiMsg(
-                               "<div class=\"successbox\">\n$1\n</div>",
-                               'changepassword-success'
-                       );
-                       $this->getOutput()->returnToMain();
-               } else {
-                       $request = $this->getRequest();
-                       LoginForm::clearLoginToken();
-                       $token = LoginForm::getLoginToken()->toString();
-                       $data = [
-                               'action' => 'submitlogin',
-                               'wpName' => $this->mUserName,
-                               'wpDomain' => $this->mDomain,
-                               'wpLoginToken' => $token,
-                               'wpPassword' => $request->getVal( 'wpNewPassword' ),
-                       ] + $request->getValues( 'wpRemember', 'returnto', 'returntoquery' );
-                       $login = new LoginForm( new DerivativeRequest( $request, $data, true ) );
-                       $login->setContext( $this->getContext() );
-                       $login->execute( null );
-               }
-       }
-
-       /**
-        * Checks the new password if it meets the requirements for passwords and set
-        * it as a current password, otherwise set the passed Status object to fatal
-        * and doesn't change anything
-        *
-        * @param string $oldpass The current (temporary) password.
-        * @param string $newpass The password to set.
-        * @param string $retype The string of the retype password field to check with newpass
-        * @return Status
-        */
-       protected function attemptReset( $oldpass, $newpass, $retype ) {
-               $isSelf = ( $this->mUserName === $this->getUser()->getName() );
-               if ( $isSelf ) {
-                       $user = $this->getUser();
-               } else {
-                       $user = User::newFromName( $this->mUserName );
-               }
-
-               if ( !$user || $user->isAnon() ) {
-                       return Status::newFatal( $this->msg( 'nosuchusershort', $this->mUserName ) );
-               }
-
-               if ( $newpass !== $retype ) {
-                       Hooks::run( 'PrefsPasswordAudit', [ $user, $newpass, 'badretype' ] );
-                       return Status::newFatal( $this->msg( 'badretype' ) );
-               }
-
-               $throttleInfo = LoginForm::incrementLoginThrottle( $this->mUserName );
-               if ( $throttleInfo ) {
-                       return Status::newFatal( $this->msg( 'changepassword-throttled' )
-                               ->durationParams( $throttleInfo['wait'] )
-                       );
-               }
-
-               // @todo Make these separate messages, since the message is written for both cases
-               if ( !$user->checkTemporaryPassword( $oldpass ) && !$user->checkPassword( $oldpass ) ) {
-                       Hooks::run( 'PrefsPasswordAudit', [ $user, $newpass, 'wrongpassword' ] );
-                       return Status::newFatal( $this->msg( 'resetpass-wrong-oldpass' ) );
-               }
-
-               // User is resetting their password to their old password
-               if ( $oldpass === $newpass ) {
-                       return Status::newFatal( $this->msg( 'resetpass-recycled' ) );
-               }
-
-               // Do AbortChangePassword after checking mOldpass, so we don't leak information
-               // by possibly aborting a new password before verifying the old password.
-               $abortMsg = 'resetpass-abort-generic';
-               if ( !Hooks::run( 'AbortChangePassword', [ $user, $oldpass, $newpass, &$abortMsg ] ) ) {
-                       Hooks::run( 'PrefsPasswordAudit', [ $user, $newpass, 'abortreset' ] );
-                       return Status::newFatal( $this->msg( $abortMsg ) );
-               }
-
-               // Please reset throttle for successful logins, thanks!
-               LoginForm::clearLoginThrottle( $this->mUserName );
-
-               try {
-                       $user->setPassword( $newpass );
-                       Hooks::run( 'PrefsPasswordAudit', [ $user, $newpass, 'success' ] );
-               } catch ( PasswordError $e ) {
-                       Hooks::run( 'PrefsPasswordAudit', [ $user, $newpass, 'error' ] );
-                       return Status::newFatal( new RawMessage( $e->getMessage() ) );
-               }
-
-               if ( $isSelf ) {
-                       // This is needed to keep the user connected since
-                       // changing the password also modifies the user's token.
-                       $remember = $this->getRequest()->getCookie( 'Token' ) !== null;
-                       $user->setCookies( null, null, $remember );
-               }
-               $user->saveSettings();
-               $this->resetPasswordExpiration( $user );
-               return Status::newGood();
-       }
-
-       public function requiresUnblock() {
-               return false;
-       }
-
-       protected function getGroupName() {
-               return 'users';
-       }
-
-       /**
-        * For resetting user password expiration, until AuthManager comes along
-        * @param User $user
-        */
-       private function resetPasswordExpiration( User $user ) {
-               global $wgPasswordExpirationDays;
-               $newExpire = null;
-               if ( $wgPasswordExpirationDays ) {
-                       $newExpire = wfTimestamp(
-                               TS_MW,
-                               time() + ( $wgPasswordExpirationDays * 24 * 3600 )
-                       );
-               }
-               // Give extensions a chance to force an expiration
-               Hooks::run( 'ResetPasswordExpiration', [ $this, &$newExpire ] );
-               $dbw = wfGetDB( DB_MASTER );
-               $dbw->update(
-                       'user',
-                       [ 'user_password_expires' => $dbw->timestampOrNull( $newExpire ) ],
-                       [ 'user_id' => $user->getId() ],
-                       __METHOD__
-               );
-       }
-
-       protected function getDisplayFormat() {
-               return 'ooui';
+               parent::__construct( 'ChangePassword', 'ChangeCredentials',
+                       PasswordAuthenticationRequest::class, [ 'returnto', 'returntoquery' ] );
        }
 }
index 69ddcf9..b046bf9 100644 (file)
@@ -1,6 +1,6 @@
 <?php
 /**
- * Redirect page: Special:CreateAccount --> Special:UserLogin/signup.
+ * Implements Special:CreateAccount
  *
  * This program is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
  * @ingroup SpecialPage
  */
 
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Logger\LoggerFactory;
+use Psr\Log\LogLevel;
+
 /**
- * Redirect page: Special:CreateAccount --> Special:UserLogin/signup.
- * @todo FIXME: This (and the rest of the login frontend) needs to die a horrible painful death
+ * Implements Special:CreateAccount
  *
  * @ingroup SpecialPage
  */
-class SpecialCreateAccount extends SpecialRedirectToSpecial {
-       function __construct() {
-               parent::__construct(
-                       'CreateAccount',
-                       'Userlogin',
-                       'signup',
-                       [ 'returnto', 'returntoquery', 'uselang' ]
-               );
-       }
+class SpecialCreateAccount extends LoginSignupSpecialPage {
+       protected static $allowedActions = [
+               AuthManager::ACTION_CREATE,
+               AuthManager::ACTION_CREATE_CONTINUE
+       ];
 
-       public function doesWrites() {
-               return true;
+       protected static $messages = [
+               'authform-newtoken' => 'nocookiesfornew',
+               'authform-notoken' => 'sessionfailure',
+               'authform-wrongtoken' => 'sessionfailure',
+       ];
+
+       public function __construct() {
+               parent::__construct( 'CreateAccount' );
        }
 
-       // No reason to hide this link on Special:Specialpages
-       public function isListed() {
+       public function doesWrites() {
                return true;
        }
 
@@ -54,7 +58,112 @@ class SpecialCreateAccount extends SpecialRedirectToSpecial {
                return $user->isAllowed( 'createaccount' );
        }
 
+       public function checkPermissions() {
+               parent::checkPermissions();
+
+               $user = $this->getUser();
+               $status = AuthManager::singleton()->checkAccountCreatePermissions( $user );
+               if ( !$status->isGood() ) {
+                       throw new ErrorPageError( 'createacct-error', $status->getMessage() );
+               }
+       }
+
+       protected function getLoginSecurityLevel() {
+               return false;
+       }
+
+       protected function getDefaultAction( $subPage ) {
+               return AuthManager::ACTION_CREATE;
+       }
+
+       public function getDescription() {
+               return $this->msg( 'createaccount' )->text();
+       }
+
+       protected function isSignup() {
+               return true;
+       }
+
+       /**
+        * Run any hooks registered for logins, then display a message welcoming
+        * the user.
+        * @param bool $direct True if the action was successful just now; false if that happened
+        *    pre-redirection (so this handler was called already)
+        * @param StatusValue|null $extraMessages
+        */
+       protected function successfulAction( $direct = false, $extraMessages = null ) {
+               $session = $this->getRequest()->getSession();
+               $user = $this->targetUser ?: $this->getUser();
+
+               if ( $direct ) {
+                       # Only save preferences if the user is not creating an account for someone else.
+                       if ( !$this->proxyAccountCreation ) {
+                               Hooks::run( 'AddNewAccount', [ $user, false ] );
+
+                               // If the user does not have a session cookie at this point, they probably need to
+                               // do something to their browser.
+                               if ( !$this->hasSessionCookie() ) {
+                                       $this->mainLoginForm( [ /*?*/ ], $session->getProvider()->whyNoSession() );
+                                       // TODO something more specific? This used to use nocookiesnew
+                                       // FIXME should redirect to login page instead?
+                                       return;
+                               }
+                       } else {
+                               $byEmail = false; // FIXME no way to set this
+
+                               Hooks::run( 'AddNewAccount', [ $user, $byEmail ] );
+
+                               $out = $this->getOutput();
+                               $out->setPageTitle( $this->msg( $byEmail ? 'accmailtitle' : 'accountcreated' ) );
+                               if ( $byEmail ) {
+                                       $out->addWikiMsg( 'accmailtext', $user->getName(), $user->getEmail() );
+                               } else {
+                                       $out->addWikiMsg( 'accountcreatedtext', $user->getName() );
+                               }
+                               $out->addReturnTo( $this->getPageTitle() );
+                               return;
+                       }
+               }
+
+               $this->clearToken();
+
+               # Run any hooks; display injected HTML
+               $injected_html = '';
+               $welcome_creation_msg = 'welcomecreation-msg';
+               Hooks::run( 'UserLoginComplete', [ &$user, &$injected_html ] );
+
+               /**
+                * Let any extensions change what message is shown.
+                * @see https://www.mediawiki.org/wiki/Manual:Hooks/BeforeWelcomeCreation
+                * @since 1.18
+                */
+               Hooks::run( 'BeforeWelcomeCreation', [ &$welcome_creation_msg, &$injected_html ] );
+
+               $this->showSuccessPage( 'signup', $this->msg( 'welcomeuser', $this->getUser()->getName() ),
+                       $welcome_creation_msg, $injected_html, $extraMessages );
+       }
+
+       protected function getToken() {
+               return $this->getRequest()->getSession()->getToken( '', 'createaccount' );
+       }
+
+       protected function clearToken() {
+               return $this->getRequest()->getSession()->resetToken( 'createaccount' );
+       }
+
+       protected function getTokenName() {
+               return 'wpCreateaccountToken';
+       }
+
        protected function getGroupName() {
                return 'login';
        }
+
+       protected function logAuthResult( $success, $status = null ) {
+               LoggerFactory::getInstance( 'authmanager-stats' )->info( 'Account creation attempt', [
+                       'event' => 'accountcreation',
+                       'successful' => $success,
+                       'status' => $status,
+               ] );
+       }
 }
diff --git a/includes/specials/SpecialLinkAccounts.php b/includes/specials/SpecialLinkAccounts.php
new file mode 100644 (file)
index 0000000..da10b90
--- /dev/null
@@ -0,0 +1,111 @@
+<?php
+
+use MediaWiki\Auth\AuthenticationRequest;
+use MediaWiki\Auth\AuthenticationResponse;
+use MediaWiki\Auth\AuthManager;
+
+/**
+ * Links/unlinks external accounts to the current user.
+ *
+ * To interact with this page, account providers need to register themselves with AuthManager.
+ */
+class SpecialLinkAccounts extends AuthManagerSpecialPage {
+       protected static $allowedActions = [
+               AuthManager::ACTION_LINK, AuthManager::ACTION_LINK_CONTINUE,
+       ];
+
+       public function __construct() {
+               parent::__construct( 'LinkAccounts' );
+       }
+
+       protected function getGroupName() {
+               return 'users';
+       }
+
+       public function isListed() {
+               return AuthManager::singleton()->canLinkAccounts();
+       }
+
+       protected function getRequestBlacklist() {
+               return $this->getConfig()->get( 'ChangeCredentialsBlacklist' );
+       }
+
+       /**
+        * @param null|string $subPage
+        * @throws MWException
+        * @throws PermissionsError
+        */
+       public function execute( $subPage ) {
+               $this->setHeaders();
+               $this->loadAuth( $subPage );
+
+               if ( !$this->isActionAllowed( $this->authAction ) ) {
+                       if ( $this->authAction === AuthManager::ACTION_LINK ) {
+                               // looks like no linking provider is installed or willing to take this user
+                               $titleMessage = wfMessage( 'cannotlink-no-provider-title' );
+                               $errorMessage = wfMessage( 'cannotlink-no-provider' );
+                               throw new ErrorPageError( $titleMessage, $errorMessage );
+                       } else {
+                               // user probably back-button-navigated into an auth session that no longer exists
+                               // FIXME would be nice to show a message
+                               $this->getOutput()->redirect( $this->getPageTitle()->getFullURL( '', false,
+                                       PROTO_HTTPS ) );
+                               return;
+                       }
+               }
+
+               $this->outputHeader();
+
+               $status = $this->trySubmit();
+
+               if ( $status === false || !$status->isOK() ) {
+                       $this->displayForm( $status );
+                       return;
+               }
+
+               $response = $status->getValue();
+
+               switch ( $response->status ) {
+                       case AuthenticationResponse::PASS:
+                               $this->success();
+                               break;
+                       case AuthenticationResponse::FAIL:
+                               $this->loadAuth( '', AuthManager::ACTION_LINK, true );
+                               $this->displayForm( StatusValue::newFatal( $response->message ) );
+                               break;
+                       case AuthenticationResponse::REDIRECT:
+                               $this->getOutput()->redirect( $response->redirectTarget );
+                               break;
+                       case AuthenticationResponse::UI:
+                               $this->authAction = AuthManager::ACTION_LINK_CONTINUE;
+                               $this->authRequests = $response->neededRequests;
+                               $this->displayForm( StatusValue::newFatal( $response->message ) );
+                               break;
+                       default:
+                               throw new LogicException( 'invalid AuthenticationResponse' );
+               }
+       }
+
+       protected function getDefaultAction( $subPage ) {
+               return AuthManager::ACTION_LINK;
+       }
+
+       /**
+        * @param AuthenticationRequest[] $requests
+        * @param string $action AuthManager action name, should be ACTION_LINK or ACTION_LINK_CONTINUE
+        * @return HTMLForm
+        */
+       protected function getAuthForm( array $requests, $action ) {
+               $form = parent::getAuthForm( $requests, $action );
+               $form->setSubmitTextMsg( 'linkaccounts-submit' );
+               return $form;
+       }
+
+       /**
+        * Show a success message.
+        */
+       protected function success() {
+               $this->loadAuth( '', AuthManager::ACTION_LINK, true );
+               $this->displayForm( StatusValue::newFatal( $this->msg( 'linkaccounts-success-text' ) ) );
+       }
+}
index c3ed91f..9746ef6 100644 (file)
  * @ingroup SpecialPage
  */
 
+use MediaWiki\Auth\AuthManager;
+
 /**
- * Special page for requesting a password reset email
+ * Special page for requesting a password reset email.
+ *
+ * Requires the TemporaryPasswordPrimaryAuthenticationProvider and the
+ * EmailNotificationSecondaryAuthenticationProvider (or something providing equivalent
+ * functionality) to be enabled.
  *
  * @ingroup SpecialPage
  */
 class SpecialPasswordReset extends FormSpecialPage {
-       /**
-        * @var Message
-        */
-       private $email;
+       /** @var PasswordReset */
+       private $passwordReset;
 
        /**
-        * @var User
+        * @var string[] Temporary storage for the passwords which have been sent out, keyed by username.
         */
-       private $firstUser;
+       private $passwords = [];
 
        /**
         * @var Status
@@ -49,6 +53,7 @@ class SpecialPasswordReset extends FormSpecialPage {
 
        public function __construct() {
                parent::__construct( 'PasswordReset', 'editmyprivateinfo' );
+               $this->passwordReset = new PasswordReset( $this->getConfig(), AuthManager::singleton() );
        }
 
        public function doesWrites() {
@@ -56,22 +61,19 @@ class SpecialPasswordReset extends FormSpecialPage {
        }
 
        public function userCanExecute( User $user ) {
-               return $this->canChangePassword( $user ) === true && parent::userCanExecute( $user );
+               return $this->passwordReset->isAllowed( $user )->isGood();
        }
 
        public function checkExecutePermissions( User $user ) {
-               $error = $this->canChangePassword( $user );
-               if ( is_string( $error ) ) {
-                       throw new ErrorPageError( 'internalerror', $error );
-               } elseif ( !$error ) {
-                       throw new ErrorPageError( 'internalerror', 'resetpass_forbidden' );
+               $status = Status::wrap( $this->passwordReset->isAllowed( $user ) );
+               if ( !$status->isGood() ) {
+                       throw new ErrorPageError( 'internalerror', $status->getMessage() );
                }
 
                parent::checkExecutePermissions( $user );
        }
 
        protected function getFormFields() {
-               global $wgAuth;
                $resetRoutes = $this->getConfig()->get( 'PasswordResetRoutes' );
                $a = [];
                if ( isset( $resetRoutes['username'] ) && $resetRoutes['username'] ) {
@@ -92,15 +94,6 @@ class SpecialPasswordReset extends FormSpecialPage {
                        ];
                }
 
-               if ( isset( $resetRoutes['domain'] ) && $resetRoutes['domain'] ) {
-                       $domains = $wgAuth->domainList();
-                       $a['Domain'] = [
-                               'type' => 'select',
-                               'options' => $domains,
-                               'label-message' => 'passwordreset-domain',
-                       ];
-               }
-
                if ( $this->getUser()->isAllowed( 'passwordreset' ) ) {
                        $a['Capture'] = [
                                'type' => 'check',
@@ -128,9 +121,6 @@ class SpecialPasswordReset extends FormSpecialPage {
                if ( isset( $resetRoutes['email'] ) && $resetRoutes['email'] ) {
                        $i++;
                }
-               if ( isset( $resetRoutes['domain'] ) && $resetRoutes['domain'] ) {
-                       $i++;
-               }
 
                $message = ( $i > 1 ) ? 'passwordreset-text-many' : 'passwordreset-text-one';
 
@@ -145,180 +135,54 @@ class SpecialPasswordReset extends FormSpecialPage {
         * @param array $data
         * @throws MWException
         * @throws ThrottledError|PermissionsError
-        * @return bool|array
+        * @return Status
         */
        public function onSubmit( array $data ) {
-               global $wgAuth, $wgMinimalPasswordLength;
-
-               if ( isset( $data['Domain'] ) ) {
-                       if ( $wgAuth->validDomain( $data['Domain'] ) ) {
-                               $wgAuth->setDomain( $data['Domain'] );
-                       } else {
-                               $wgAuth->setDomain( 'invaliddomain' );
-                       }
-               }
-
                if ( isset( $data['Capture'] ) && !$this->getUser()->isAllowed( 'passwordreset' ) ) {
                        // The user knows they don't have the passwordreset permission,
                        // but they tried to spoof the form. That's naughty
                        throw new PermissionsError( 'passwordreset' );
                }
 
-               /**
-                * @var $firstUser User
-                * @var $users User[]
-                */
-
-               if ( isset( $data['Username'] ) && $data['Username'] !== '' ) {
-                       $method = 'username';
-                       $users = [ User::newFromName( $data['Username'] ) ];
-               } elseif ( isset( $data['Email'] )
-                       && $data['Email'] !== ''
-                       && Sanitizer::validateEmail( $data['Email'] )
-               ) {
-                       $method = 'email';
-                       $res = wfGetDB( DB_SLAVE )->select(
-                               'user',
-                               User::selectFields(),
-                               [ 'user_email' => $data['Email'] ],
-                               __METHOD__
-                       );
-
-                       if ( $res ) {
-                               $users = [];
-
-                               foreach ( $res as $row ) {
-                                       $users[] = User::newFromRow( $row );
-                               }
-                       } else {
-                               // Some sort of database error, probably unreachable
-                               throw new MWException( 'Unknown database error in ' . __METHOD__ );
-                       }
-               } else {
-                       // The user didn't supply any data
-                       return false;
-               }
-
-               // Check for hooks (captcha etc), and allow them to modify the users list
-               $error = [];
-               if ( !Hooks::run( 'SpecialPasswordResetOnSubmit', [ &$users, $data, &$error ] ) ) {
-                       return [ $error ];
-               }
-
-               $this->method = $method;
-
-               if ( count( $users ) == 0 ) {
-                       if ( $method == 'email' ) {
-                               // Don't reveal whether or not an email address is in use
-                               return true;
-                       } else {
-                               return [ 'noname' ];
-                       }
-               }
-
-               $firstUser = $users[0];
+               $username = isset( $data['Username'] ) ? $data['Username'] : null;
+               $email = isset( $data['Email'] ) ? $data['Email'] : null;
+               $capture = !empty( $data['Capture'] );
 
-               if ( !$firstUser instanceof User || !$firstUser->getId() ) {
-                       // Don't parse username as wikitext (bug 65501)
-                       return [ [ 'nosuchuser', wfEscapeWikiText( $data['Username'] ) ] ];
+               $this->method = $username ? 'username' : 'email';
+               $this->result = Status::wrap(
+                       $this->passwordReset->execute( $this->getUser(), $username, $email, $capture ) );
+               if ( $capture && $this->result->isOK() ) {
+                       $this->passwords = $this->result->getValue();
                }
 
-               // Check against the rate limiter
-               if ( $this->getUser()->pingLimiter( 'mailpassword' ) ) {
+               if ( $this->result->hasMessage( 'actionthrottledtext' ) ) {
                        throw new ThrottledError;
                }
 
-               // Check against password throttle
-               foreach ( $users as $user ) {
-                       if ( $user->isPasswordReminderThrottled() ) {
-
-                               # Round the time in hours to 3 d.p., in case someone is specifying
-                               # minutes or seconds.
-                               return [ [
-                                       'throttled-mailpassword',
-                                       round( $this->getConfig()->get( 'PasswordReminderResendTime' ), 3 )
-                               ] ];
-                       }
-               }
-
-               // All the users will have the same email address
-               if ( $firstUser->getEmail() == '' ) {
-                       // This won't be reachable from the email route, so safe to expose the username
-                       return [ [ 'noemail', wfEscapeWikiText( $firstUser->getName() ) ] ];
-               }
-
-               // We need to have a valid IP address for the hook, but per bug 18347, we should
-               // send the user's name if they're logged in.
-               $ip = $this->getRequest()->getIP();
-               if ( !$ip ) {
-                       return [ 'badipaddress' ];
-               }
-               $caller = $this->getUser();
-               Hooks::run( 'User::mailPasswordInternal', [ &$caller, &$ip, &$firstUser ] );
-               $username = $caller->getName();
-               $msg = IP::isValid( $username )
-                       ? 'passwordreset-emailtext-ip'
-                       : 'passwordreset-emailtext-user';
-
-               // Send in the user's language; which should hopefully be the same
-               $userLanguage = $firstUser->getOption( 'language' );
-
-               $passwords = [];
-               foreach ( $users as $user ) {
-                       $password = PasswordFactory::generateRandomPasswordString( $wgMinimalPasswordLength );
-                       $user->setNewpassword( $password );
-                       $user->saveSettings();
-                       $passwords[] = $this->msg( 'passwordreset-emailelement', $user->getName(), $password )
-                               ->inLanguage( $userLanguage )->text(); // We'll escape the whole thing later
-               }
-               $passwordBlock = implode( "\n\n", $passwords );
-
-               $this->email = $this->msg( $msg )->inLanguage( $userLanguage );
-               $this->email->params(
-                       $username,
-                       $passwordBlock,
-                       count( $passwords ),
-                       '<' . Title::newMainPage()->getCanonicalURL() . '>',
-                       round( $this->getConfig()->get( 'NewPasswordExpiry' ) / 86400 )
-               );
-
-               $title = $this->msg( 'passwordreset-emailtitle' )->inLanguage( $userLanguage );
-
-               $this->result = $firstUser->sendMail( $title->text(), $this->email->text() );
-
-               if ( isset( $data['Capture'] ) && $data['Capture'] ) {
-                       // Save the user, will be used if an error occurs when sending the email
-                       $this->firstUser = $firstUser;
-               } else {
-                       // Blank the email if the user is not supposed to see it
-                       $this->email = null;
-               }
-
-               if ( $this->result->isGood() ) {
-                       return true;
-               } elseif ( isset( $data['Capture'] ) && $data['Capture'] ) {
-                       // The email didn't send, but maybe they knew that and that's why they captured it
-                       return true;
-               } else {
-                       // @todo FIXME: The email wasn't sent, but we have already set
-                       // the password throttle timestamp, so they won't be able to try
-                       // again until it expires...  :(
-                       return [ [ 'mailerror', $this->result->getMessage() ] ];
-               }
+               return $this->result;
        }
 
        public function onSuccess() {
-               if ( $this->getUser()->isAllowed( 'passwordreset' ) && $this->email != null ) {
+               if ( $this->getUser()->isAllowed( 'passwordreset' ) && $this->passwords ) {
                        // @todo Logging
 
                        if ( $this->result->isGood() ) {
-                               $this->getOutput()->addWikiMsg( 'passwordreset-emailsent-capture' );
+                               $this->getOutput()->addWikiMsg( 'passwordreset-emailsent-capture2',
+                                       count( $this->passwords ) );
                        } else {
-                               $this->getOutput()->addWikiMsg( 'passwordreset-emailerror-capture',
-                                       $this->result->getMessage(), $this->firstUser->getName() );
+                               $this->getOutput()->addWikiMsg( 'passwordreset-emailerror-capture2',
+                                       $this->result->getMessage(), key( $this->passwords ), count( $this->passwords ) );
                        }
 
-                       $this->getOutput()->addHTML( Html::rawElement( 'pre', [], $this->email->escaped() ) );
+                       $this->getOutput()->addHTML( Html::openElement( 'ul' ) );
+                       foreach ( $this->passwords as $username => $pwd ) {
+                               $this->getOutput()->addHTML( Html::rawElement( 'li', [],
+                                       htmlspecialchars( $username, ENT_QUOTES )
+                                       . $this->msg( 'colon-separator' )->text()
+                                       . htmlspecialchars( $pwd, ENT_QUOTES )
+                               ) );
+                       }
+                       $this->getOutput()->addHTML( Html::closeElement( 'ul' ) );
                }
 
                if ( $this->method === 'email' ) {
@@ -330,42 +194,12 @@ class SpecialPasswordReset extends FormSpecialPage {
                $this->getOutput()->returnToMain();
        }
 
-       protected function canChangePassword( User $user ) {
-               global $wgAuth;
-               $resetRoutes = $this->getConfig()->get( 'PasswordResetRoutes' );
-
-               // Maybe password resets are disabled, or there are no allowable routes
-               if ( !is_array( $resetRoutes ) ||
-                       !in_array( true, array_values( $resetRoutes ) )
-               ) {
-                       return 'passwordreset-disabled';
-               }
-
-               // Maybe the external auth plugin won't allow local password changes
-               if ( !$wgAuth->allowPasswordChange() ) {
-                       return 'resetpass_forbidden';
-               }
-
-               // Maybe email features have been disabled
-               if ( !$this->getConfig()->get( 'EnableEmail' ) ) {
-                       return 'passwordreset-emaildisabled';
-               }
-
-               // Maybe the user is blocked (check this here rather than relying on the parent
-               // method as we have a more specific error message to use here
-               if ( $user->isBlocked() ) {
-                       return 'blocked-mailpassword';
-               }
-
-               return true;
-       }
-
        /**
         * Hide the password reset page if resets are disabled.
         * @return bool
         */
-       function isListed() {
-               if ( $this->canChangePassword( $this->getUser() ) === true ) {
+       public function isListed() {
+               if ( $this->passwordReset->isAllowed( $this->getUser() )->isGood() ) {
                        return parent::isListed();
                }
 
diff --git a/includes/specials/SpecialRemoveCredentials.php b/includes/specials/SpecialRemoveCredentials.php
new file mode 100644 (file)
index 0000000..4efec03
--- /dev/null
@@ -0,0 +1,26 @@
+<?php
+
+use MediaWiki\Auth\AuthManager;
+
+/**
+ * Special change to remove credentials (such as a two-factor token).
+ */
+class SpecialRemoveCredentials extends SpecialChangeCredentials {
+       protected static $allowedActions = [ AuthManager::ACTION_REMOVE ];
+
+       protected static $messagePrefix = 'removecredentials';
+
+       protected static $loadUserData = false;
+
+       public function __construct() {
+               parent::__construct( 'RemoveCredentials' );
+       }
+
+       protected function getDefaultAction( $subPage ) {
+               return AuthManager::ACTION_REMOVE;
+       }
+
+       protected function getRequestBlacklist() {
+               return $this->getConfig()->get( 'RemoveCredentialsBlacklist' );
+       }
+}
index 521f0ce..2139949 100644 (file)
@@ -259,6 +259,9 @@ class SpecialTags extends SpecialPage {
                                        [ 'tag' => $tag ] );
                        }
 
+               }
+
+               if ( $actionLinks ) {
                        $newRow .= Xml::tags( 'td', null, $this->getLanguage()->pipeList( $actionLinks ) );
                }
 
diff --git a/includes/specials/SpecialUnlinkAccounts.php b/includes/specials/SpecialUnlinkAccounts.php
new file mode 100644 (file)
index 0000000..86bc7ed
--- /dev/null
@@ -0,0 +1,78 @@
+<?php
+
+use MediaWiki\Auth\AuthenticationResponse;
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Session\SessionManager;
+
+class SpecialUnlinkAccounts extends AuthManagerSpecialPage {
+       protected static $allowedActions = [ AuthManager::ACTION_UNLINK ];
+
+       public function __construct() {
+               parent::__construct( 'UnlinkAccounts' );
+       }
+
+       protected function getLoginSecurityLevel() {
+               return 'UnlinkAccount';
+       }
+
+       protected function getDefaultAction( $subPage ) {
+               return AuthManager::ACTION_UNLINK;
+       }
+
+       /**
+        * Under which header this special page is listed in Special:SpecialPages.
+        */
+       protected function getGroupName() {
+               return 'users';
+       }
+
+       public function isListed() {
+               return AuthManager::singleton()->canLinkAccounts();
+       }
+
+       protected function getRequestBlacklist() {
+               return $this->getConfig()->get( 'RemoveCredentialsBlacklist' );
+       }
+
+       public function execute( $subPage ) {
+               $this->setHeaders();
+               $this->loadAuth( $subPage );
+               $this->outputHeader();
+
+               $status = $this->trySubmit();
+
+               if ( $status === false || !$status->isOK() ) {
+                       $this->displayForm( $status );
+                       return;
+               }
+
+               /** @var AuthenticationResponse $response */
+               $response = $status->getValue();
+
+               if ( $response->status === AuthenticationResponse::FAIL ) {
+                       $this->displayForm( StatusValue::newFatal( $response->message ) );
+                       return;
+               }
+
+               $status = StatusValue::newGood();
+               $status->warning( wfMessage( 'unlinkaccounts-success' ) );
+               $this->loadAuth( $subPage, null, true ); // update requests so the unlinked one doesn't show up
+
+               // Reset sessions - if the user unlinked an account because it was compromised,
+               // log attackers out from sessions obtained via that account.
+               $session = $this->getRequest()->getSession();
+               $user = $this->getUser();
+               SessionManager::singleton()->invalidateSessionsForUser( $user );
+               $session->setUser( $user );
+               $session->resetId();
+
+               $this->displayForm( $status );
+       }
+
+       public function handleFormSubmit( $data ) {
+               // unlink requests do not accept user input so repeat parent code but skip call to
+               // AuthenticationRequest::loadRequestsFromSubmission
+               $response = $this->performAuthenticationStep( $this->authAction, $this->authRequests );
+               return Status::newGood( $response );
+       }
+}
diff --git a/includes/specials/SpecialUserLogin.php b/includes/specials/SpecialUserLogin.php
new file mode 100644 (file)
index 0000000..28c68aa
--- /dev/null
@@ -0,0 +1,163 @@
+<?php
+/**
+ * Implements Special:UserLogin
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Logger\LoggerFactory;
+use Psr\Log\LogLevel;
+
+/**
+ * Implements Special:UserLogin
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialUserLogin extends LoginSignupSpecialPage {
+       protected static $allowedActions = [
+               AuthManager::ACTION_LOGIN,
+               AuthManager::ACTION_LOGIN_CONTINUE
+       ];
+
+       protected static $messages = [
+               'authform-newtoken' => 'nocookiesforlogin',
+               'authform-notoken' => 'sessionfailure',
+               'authform-wrongtoken' => 'sessionfailure',
+       ];
+
+       public function __construct() {
+               parent::__construct( 'Userlogin' );
+       }
+
+       public function doesWrites() {
+               return true;
+       }
+
+       protected function getLoginSecurityLevel() {
+               return false;
+       }
+
+       protected function getDefaultAction( $subPage ) {
+               return AuthManager::ACTION_LOGIN;
+       }
+
+       public function getDescription() {
+               return $this->msg( 'login' )->text();
+       }
+
+       public function setHeaders() {
+               // override the page title if we are doing a forced reauthentication
+               parent::setHeaders();
+               if ( $this->securityLevel && $this->getUser()->isLoggedIn() ) {
+                       $this->getOutput()->setPageTitle( $this->msg( 'login-security' ) );
+               }
+       }
+
+       protected function isSignup() {
+               return false;
+       }
+
+       protected function beforeExecute( $subPage ) {
+               if ( $subPage === 'signup' || $this->getRequest()->getText( 'type' ) === 'signup' ) {
+                       // B/C for old account creation URLs
+                       $title = SpecialPage::getTitleFor( 'CreateAccount' );
+                       $query = array_diff_key( $this->getRequest()->getValues(),
+                               array_fill_keys( [ 'type', 'title' ], true ) );
+                       $url = $title->getFullURL( $query, false, PROTO_CURRENT );
+                       $this->getOutput()->redirect( $url );
+                       return false;
+               }
+               return parent::beforeExecute( $subPage );
+       }
+
+       /**
+        * Run any hooks registered for logins, then HTTP redirect to
+        * $this->mReturnTo (or Main Page if that's undefined).  Formerly we had a
+        * nice message here, but that's really not as useful as just being sent to
+        * wherever you logged in from.  It should be clear that the action was
+        * successful, given the lack of error messages plus the appearance of your
+        * name in the upper right.
+        * @param bool $direct True if the action was successful just now; false if that happened
+        *    pre-redirection (so this handler was called already)
+        * @param StatusValue|null $extraMessages
+        */
+       protected function successfulAction( $direct = false, $extraMessages = null ) {
+               global $wgSecureLogin;
+
+               $user = $this->targetUser ?: $this->getUser();
+               $session = $this->getRequest()->getSession();
+
+               if ( $direct ) {
+                       $user->touch();
+
+                       $this->clearToken();
+
+                       if ( $user->requiresHTTPS() ) {
+                               $this->mStickHTTPS = true;
+                       }
+                       $session->setForceHTTPS( $wgSecureLogin && $this->mStickHTTPS );
+
+                       // If the user does not have a session cookie at this point, they probably need to
+                       // do something to their browser.
+                       if ( !$this->hasSessionCookie() ) {
+                               $this->mainLoginForm( [ /*?*/ ], $session->getProvider()->whyNoSession() );
+                               // TODO something more specific? This used to use nocookieslogin
+                               return;
+                       }
+               }
+
+               # Run any hooks; display injected HTML if any, else redirect
+               $injected_html = '';
+               Hooks::run( 'UserLoginComplete', [ &$user, &$injected_html ] );
+
+               if ( $injected_html !== '' || $extraMessages ) {
+                       $this->showSuccessPage( 'success', $this->msg( 'loginsuccesstitle' ),
+                               'loginsuccess', $injected_html, $extraMessages );
+               } else {
+                       $helper = new LoginHelper( $this->getContext() );
+                       $helper->showReturnToPage( 'successredirect', $this->mReturnTo, $this->mReturnToQuery,
+                               $this->mStickHTTPS );
+               }
+       }
+
+       protected function getToken() {
+               return $this->getRequest()->getSession()->getToken( '', 'login' );
+       }
+
+       protected function clearToken() {
+               return $this->getRequest()->getSession()->resetToken( 'login' );
+       }
+
+       protected function getTokenName() {
+               return 'wpLoginToken';
+       }
+
+       protected function getGroupName() {
+               return 'login';
+       }
+
+       protected function logAuthResult( $success, $status = null ) {
+               LoggerFactory::getInstance( 'authmanager-stats' )->info( 'Login attempt', [
+                       'event' => 'login',
+                       'successful' => $success,
+                       'status' => $status,
+               ] );
+       }
+}
diff --git a/includes/specials/SpecialUserLogout.php b/includes/specials/SpecialUserLogout.php
new file mode 100644 (file)
index 0000000..c067f44
--- /dev/null
@@ -0,0 +1,85 @@
+<?php
+/**
+ * Implements Special:Userlogout
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Implements Special:Userlogout
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialUserLogout extends UnlistedSpecialPage {
+       function __construct() {
+               parent::__construct( 'Userlogout' );
+       }
+
+       public function doesWrites() {
+               return true;
+       }
+
+       function execute( $par ) {
+               /**
+                * Some satellite ISPs use broken precaching schemes that log people out straight after
+                * they're logged in (bug 17790). Luckily, there's a way to detect such requests.
+                */
+               if ( isset( $_SERVER['REQUEST_URI'] ) && strpos( $_SERVER['REQUEST_URI'], '&amp;' ) !== false ) {
+                       wfDebug( "Special:UserLogout request {$_SERVER['REQUEST_URI']} looks suspicious, denying.\n" );
+                       throw new HttpError( 400, $this->msg( 'suspicious-userlogout' ), $this->msg( 'loginerror' ) );
+               }
+
+               $this->setHeaders();
+               $this->outputHeader();
+
+               // Make sure it's possible to log out
+               $session = MediaWiki\Session\SessionManager::getGlobalSession();
+               if ( !$session->canSetUser() ) {
+                       throw new ErrorPageError(
+                               'cannotlogoutnow-title',
+                               'cannotlogoutnow-text',
+                               [
+                                       $session->getProvider()->describe( RequestContext::getMain()->getLanguage() )
+                               ]
+                       );
+               }
+
+               $user = $this->getUser();
+               $oldName = $user->getName();
+
+               $user->logout();
+
+               $loginURL = SpecialPage::getTitleFor( 'Userlogin' )->getFullURL(
+                       $this->getRequest()->getValues( 'returnto', 'returntoquery' ) );
+
+               $out = $this->getOutput();
+               $out->addWikiMsg( 'logouttext', $loginURL );
+
+               // Hook.
+               $injected_html = '';
+               Hooks::run( 'UserLogoutComplete', [ &$user, &$injected_html, $oldName ] );
+               $out->addHTML( $injected_html );
+
+               $out->returnToMain();
+       }
+
+       protected function getGroupName() {
+               return 'login';
+       }
+}
diff --git a/includes/specials/SpecialUserlogin.php b/includes/specials/SpecialUserlogin.php
deleted file mode 100644 (file)
index 45315a7..0000000
+++ /dev/null
@@ -1,1842 +0,0 @@
-<?php
-/**
- * Implements Special:UserLogin
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- * @ingroup SpecialPage
- */
-use MediaWiki\Logger\LoggerFactory;
-use Psr\Log\LogLevel;
-use MediaWiki\Session\SessionManager;
-
-/**
- * Implements Special:UserLogin
- *
- * @ingroup SpecialPage
- */
-class LoginForm extends SpecialPage {
-       const SUCCESS = 0;
-       const NO_NAME = 1;
-       const ILLEGAL = 2;
-       const WRONG_PLUGIN_PASS = 3;
-       const NOT_EXISTS = 4;
-       const WRONG_PASS = 5;
-       const EMPTY_PASS = 6;
-       const RESET_PASS = 7;
-       const ABORTED = 8;
-       const CREATE_BLOCKED = 9;
-       const THROTTLED = 10;
-       const USER_BLOCKED = 11;
-       const NEED_TOKEN = 12;
-       const WRONG_TOKEN = 13;
-       const USER_MIGRATED = 14;
-
-       public static $statusCodes = [
-               self::SUCCESS => 'success',
-               self::NO_NAME => 'no_name',
-               self::ILLEGAL => 'illegal',
-               self::WRONG_PLUGIN_PASS => 'wrong_plugin_pass',
-               self::NOT_EXISTS => 'not_exists',
-               self::WRONG_PASS => 'wrong_pass',
-               self::EMPTY_PASS => 'empty_pass',
-               self::RESET_PASS => 'reset_pass',
-               self::ABORTED => 'aborted',
-               self::CREATE_BLOCKED => 'create_blocked',
-               self::THROTTLED => 'throttled',
-               self::USER_BLOCKED => 'user_blocked',
-               self::NEED_TOKEN => 'need_token',
-               self::WRONG_TOKEN => 'wrong_token',
-               self::USER_MIGRATED => 'user_migrated',
-       ];
-
-       /**
-        * Valid error and warning messages
-        *
-        * Special:Userlogin can show an error or warning message on the form when
-        * coming from another page. This is done via the ?error= or ?warning= GET
-        * parameters.
-        *
-        * This array is the list of valid message keys. All other values will be
-        * ignored.
-        *
-        * @since 1.24
-        * @var string[]
-        */
-       public static $validErrorMessages = [
-               'exception-nologin-text',
-               'watchlistanontext',
-               'changeemail-no-info',
-               'resetpass-no-info',
-               'confirmemail_needlogin',
-               'prefsnologintext2',
-       ];
-
-       public $mAbortLoginErrorMsg = null;
-       /**
-        * @var int How many seconds user is throttled for
-        * @since 1.27
-        */
-       public $mThrottleWait = '?';
-
-       protected $mUsername;
-       protected $mPassword;
-       protected $mRetype;
-       protected $mReturnTo;
-       protected $mCookieCheck;
-       protected $mPosted;
-       protected $mAction;
-       protected $mCreateaccount;
-       protected $mCreateaccountMail;
-       protected $mLoginattempt;
-       protected $mRemember;
-       protected $mEmail;
-       protected $mDomain;
-       protected $mLanguage;
-       protected $mSkipCookieCheck;
-       protected $mReturnToQuery;
-       protected $mToken;
-       protected $mStickHTTPS;
-       protected $mType;
-       protected $mReason;
-       protected $mRealName;
-       protected $mEntryError = '';
-       protected $mEntryErrorType = 'error';
-
-       private $mTempPasswordUsed;
-       private $mLoaded = false;
-       private $mSecureLoginUrl;
-
-       /** @var WebRequest */
-       private $mOverrideRequest = null;
-
-       /** @var WebRequest Effective request; set at the beginning of load */
-       private $mRequest = null;
-
-       /**
-        * @param WebRequest $request
-        */
-       public function __construct( $request = null ) {
-               global $wgUseMediaWikiUIEverywhere;
-               parent::__construct( 'Userlogin' );
-
-               $this->mOverrideRequest = $request;
-               // Override UseMediaWikiEverywhere to true, to force login and create form to use mw ui
-               $wgUseMediaWikiUIEverywhere = true;
-       }
-
-       public function doesWrites() {
-               return true;
-       }
-
-       /**
-        * Returns an array of all valid error messages.
-        *
-        * @return array
-        */
-       public static function getValidErrorMessages() {
-               static $messages = null;
-               if ( !$messages ) {
-                       $messages = self::$validErrorMessages;
-                       Hooks::run( 'LoginFormValidErrorMessages', [ &$messages ] );
-               }
-
-               return $messages;
-       }
-
-       /**
-        * Loader
-        */
-       function load() {
-               global $wgAuth, $wgHiddenPrefs, $wgEnableEmail;
-
-               if ( $this->mLoaded ) {
-                       return;
-               }
-               $this->mLoaded = true;
-
-               if ( $this->mOverrideRequest === null ) {
-                       $request = $this->getRequest();
-               } else {
-                       $request = $this->mOverrideRequest;
-               }
-               $this->mRequest = $request;
-
-               $this->mType = $request->getText( 'type' );
-               $this->mUsername = $request->getText( 'wpName' );
-               $this->mPassword = $request->getText( 'wpPassword' );
-               $this->mRetype = $request->getText( 'wpRetype' );
-               $this->mDomain = $request->getText( 'wpDomain' );
-               $this->mReason = $request->getText( 'wpReason' );
-               $this->mCookieCheck = $request->getVal( 'wpCookieCheck' );
-               $this->mPosted = $request->wasPosted();
-               $this->mCreateaccountMail = $request->getCheck( 'wpCreateaccountMail' )
-                       && $wgEnableEmail;
-               $this->mCreateaccount = $request->getCheck( 'wpCreateaccount' ) && !$this->mCreateaccountMail;
-               $this->mLoginattempt = $request->getCheck( 'wpLoginattempt' );
-               $this->mAction = $request->getVal( 'action' );
-               $this->mRemember = $request->getCheck( 'wpRemember' );
-               $this->mFromHTTP = $request->getBool( 'fromhttp', false )
-                       || $request->getBool( 'wpFromhttp', false );
-               $this->mStickHTTPS = ( !$this->mFromHTTP && $request->getProtocol() === 'https' )
-                       || $request->getBool( 'wpForceHttps', false );
-               $this->mLanguage = $request->getText( 'uselang' );
-               $this->mSkipCookieCheck = $request->getCheck( 'wpSkipCookieCheck' );
-               $this->mToken = $this->mType == 'signup'
-                       ? $request->getVal( 'wpCreateaccountToken' )
-                       : $request->getVal( 'wpLoginToken' );
-               $this->mReturnTo = $request->getVal( 'returnto', '' );
-               $this->mReturnToQuery = $request->getVal( 'returntoquery', '' );
-
-               // Show an error or warning passed on from a previous page
-               $entryError = $this->msg( $request->getVal( 'error', '' ) );
-               $entryWarning = $this->msg( $request->getVal( 'warning', '' ) );
-               // bc: provide login link as a parameter for messages where the translation
-               // was not updated
-               $loginreqlink = Linker::linkKnown(
-                       $this->getPageTitle(),
-                       $this->msg( 'loginreqlink' )->escaped(),
-                       [],
-                       [
-                               'returnto' => $this->mReturnTo,
-                               'returntoquery' => $this->mReturnToQuery,
-                               'uselang' => $this->mLanguage,
-                               'fromhttp' => $this->mFromHTTP ? '1' : '0',
-                       ]
-               );
-
-               // Only show valid error or warning messages.
-               if ( $entryError->exists()
-                       && in_array( $entryError->getKey(), self::getValidErrorMessages() )
-               ) {
-                       $this->mEntryErrorType = 'error';
-                       $this->mEntryError = $entryError->rawParams( $loginreqlink )->parse();
-
-               } elseif ( $entryWarning->exists()
-                       && in_array( $entryWarning->getKey(), self::getValidErrorMessages() )
-               ) {
-                       $this->mEntryErrorType = 'warning';
-                       $this->mEntryError = $entryWarning->rawParams( $loginreqlink )->parse();
-               }
-
-               if ( $wgEnableEmail ) {
-                       $this->mEmail = $request->getText( 'wpEmail' );
-               } else {
-                       $this->mEmail = '';
-               }
-               if ( !in_array( 'realname', $wgHiddenPrefs ) ) {
-                       $this->mRealName = $request->getText( 'wpRealName' );
-               } else {
-                       $this->mRealName = '';
-               }
-
-               if ( !$wgAuth->validDomain( $this->mDomain ) ) {
-                       $this->mDomain = $wgAuth->getDomain();
-               }
-               $wgAuth->setDomain( $this->mDomain );
-
-               # 1. When switching accounts, it sucks to get automatically logged out
-               # 2. Do not return to PasswordReset after a successful password change
-               #    but goto Wiki start page (Main_Page) instead ( bug 33997 )
-               $returnToTitle = Title::newFromText( $this->mReturnTo );
-               if ( is_object( $returnToTitle )
-                       && ( $returnToTitle->isSpecial( 'Userlogout' )
-                               || $returnToTitle->isSpecial( 'PasswordReset' ) )
-               ) {
-                       $this->mReturnTo = '';
-                       $this->mReturnToQuery = '';
-               }
-       }
-
-       function getDescription() {
-               if ( $this->mType === 'signup' ) {
-                       return $this->msg( 'createaccount' )->text();
-               } else {
-                       return $this->msg( 'login' )->text();
-               }
-       }
-
-       /**
-        * @param string|null $subPage
-        */
-       public function execute( $subPage ) {
-               // Make sure session is persisted
-               $session = SessionManager::getGlobalSession();
-               $session->persist();
-
-               $this->load();
-
-               // Check for [[Special:Userlogin/signup]]. This affects form display and
-               // page title.
-               if ( $subPage == 'signup' ) {
-                       $this->mType = 'signup';
-               }
-               $this->setHeaders();
-
-               // Make sure it's possible to log in
-               if ( $this->mType !== 'signup' && !$session->canSetUser() ) {
-                       throw new ErrorPageError(
-                               'cannotloginnow-title',
-                               'cannotloginnow-text',
-                               [
-                                       $session->getProvider()->describe( RequestContext::getMain()->getLanguage() )
-                               ]
-                       );
-               }
-
-               /**
-                * In the case where the user is already logged in, and was redirected to
-                * the login form from a page that requires login, do not show the login
-                * page. The use case scenario for this is when a user opens a large number
-                * of tabs, is redirected to the login page on all of them, and then logs
-                * in on one, expecting all the others to work properly.
-                *
-                * However, do show the form if it was visited intentionally (no 'returnto'
-                * is present). People who often switch between several accounts have grown
-                * accustomed to this behavior.
-                */
-               if (
-                       $this->mType !== 'signup' &&
-                       !$this->mPosted &&
-                       $this->getUser()->isLoggedIn() &&
-                       ( $this->mReturnTo !== '' || $this->mReturnToQuery !== '' )
-               ) {
-                       $this->successfulLogin();
-               }
-
-               // If logging in and not on HTTPS, either redirect to it or offer a link.
-               global $wgSecureLogin;
-               if ( $this->mRequest->getProtocol() !== 'https' ) {
-                       $title = $this->getFullTitle();
-                       $query = [
-                               'returnto' => $this->mReturnTo !== '' ? $this->mReturnTo : null,
-                               'returntoquery' => $this->mReturnToQuery !== '' ?
-                                       $this->mReturnToQuery : null,
-                               'title' => null,
-                               ( $this->mEntryErrorType === 'error' ? 'error' : 'warning' ) => $this->mEntryError,
-                       ] + $this->mRequest->getQueryValues();
-                       $url = $title->getFullURL( $query, false, PROTO_HTTPS );
-                       if ( $wgSecureLogin
-                               && wfCanIPUseHTTPS( $this->getRequest()->getIP() )
-                               && !$this->mFromHTTP ) // Avoid infinite redirect
-                       {
-                               $url = wfAppendQuery( $url, 'fromhttp=1' );
-                               $this->getOutput()->redirect( $url );
-                               // Since we only do this redir to change proto, always vary
-                               $this->getOutput()->addVaryHeader( 'X-Forwarded-Proto' );
-
-                               return;
-                       } else {
-                               // A wiki without HTTPS login support should set $wgServer to
-                               // http://somehost, in which case the secure URL generated
-                               // above won't actually start with https://
-                               if ( substr( $url, 0, 8 ) === 'https://' ) {
-                                       $this->mSecureLoginUrl = $url;
-                               }
-                       }
-               }
-
-               if ( !is_null( $this->mCookieCheck ) ) {
-                       $this->onCookieRedirectCheck( $this->mCookieCheck );
-
-                       return;
-               } elseif ( $this->mPosted ) {
-                       if ( $this->mCreateaccount ) {
-                               $this->addNewAccount();
-
-                               return;
-                       } elseif ( $this->mCreateaccountMail ) {
-                               $this->addNewAccountMailPassword();
-
-                               return;
-                       } elseif ( ( 'submitlogin' == $this->mAction ) || $this->mLoginattempt ) {
-                               $this->processLogin();
-
-                               return;
-                       }
-               }
-               $this->mainLoginForm( $this->mEntryError, $this->mEntryErrorType );
-       }
-
-       /**
-        * @private
-        */
-       function addNewAccountMailPassword() {
-               if ( $this->mEmail == '' ) {
-                       $this->mainLoginForm( $this->msg( 'noemailcreate' )->escaped() );
-
-                       return;
-               }
-
-               $status = $this->addNewAccountInternal();
-               LoggerFactory::getInstance( 'authmanager' )->info(
-                       'Account creation attempt with mailed password',
-                       [ 'event' => 'accountcreation', 'status' => $status ]
-               );
-               if ( !$status->isGood() ) {
-                       $error = $status->getMessage();
-                       $this->mainLoginForm( $error->toString() );
-
-                       return;
-               }
-
-               /** @var User $u */
-               $u = $status->getValue();
-
-               // Wipe the initial password and mail a temporary one
-               $u->setPassword( null );
-               $u->saveSettings();
-               $result = $this->mailPasswordInternal( $u, false, 'createaccount-title', 'createaccount-text' );
-
-               Hooks::run( 'AddNewAccount', [ $u, true ] );
-               $u->addNewUserLogEntry( 'byemail', $this->mReason );
-
-               $out = $this->getOutput();
-               $out->setPageTitle( $this->msg( 'accmailtitle' ) );
-
-               if ( !$result->isGood() ) {
-                       $this->mainLoginForm( $this->msg( 'mailerror', $result->getWikiText() )->text() );
-               } else {
-                       $out->addWikiMsg( 'accmailtext', $u->getName(), $u->getEmail() );
-                       $this->executeReturnTo( 'success' );
-               }
-       }
-
-       /**
-        * @private
-        * @return bool
-        */
-       function addNewAccount() {
-               global $wgContLang, $wgUser, $wgEmailAuthentication, $wgLoginLanguageSelector;
-
-               # Create the account and abort if there's a problem doing so
-               $status = $this->addNewAccountInternal();
-               LoggerFactory::getInstance( 'authmanager' )->info( 'Account creation attempt', [
-                       'event' => 'accountcreation',
-                       'status' => $status,
-               ] );
-
-               if ( !$status->isGood() ) {
-                       $error = $status->getMessage();
-                       $this->mainLoginForm( $error->toString() );
-
-                       return false;
-               }
-
-               $u = $status->getValue();
-
-               # Only save preferences if the user is not creating an account for someone else.
-               if ( $this->getUser()->isAnon() ) {
-                       # If we showed up language selection links, and one was in use, be
-                       # smart (and sensible) and save that language as the user's preference
-                       if ( $wgLoginLanguageSelector && $this->mLanguage ) {
-                               $u->setOption( 'language', $this->mLanguage );
-                       } else {
-
-                               # Otherwise the user's language preference defaults to $wgContLang,
-                               # but it may be better to set it to their preferred $wgContLang variant,
-                               # based on browser preferences or URL parameters.
-                               $u->setOption( 'language', $wgContLang->getPreferredVariant() );
-                       }
-                       if ( $wgContLang->hasVariants() ) {
-                               $u->setOption( 'variant', $wgContLang->getPreferredVariant() );
-                       }
-               }
-
-               $out = $this->getOutput();
-
-               # Send out an email authentication message if needed
-               if ( $wgEmailAuthentication && Sanitizer::validateEmail( $u->getEmail() ) ) {
-                       $status = $u->sendConfirmationMail();
-                       if ( $status->isGood() ) {
-                               $out->addWikiMsg( 'confirmemail_oncreate' );
-                       } else {
-                               $out->addWikiText( $status->getWikiText( 'confirmemail_sendfailed' ) );
-                       }
-               }
-
-               # Save settings (including confirmation token)
-               $u->saveSettings();
-
-               # If not logged in, assume the new account as the current one and set
-               # session cookies then show a "welcome" message or a "need cookies"
-               # message as needed
-               if ( $this->getUser()->isAnon() ) {
-                       $u->setCookies();
-                       $wgUser = $u;
-                       // This should set it for OutputPage and the Skin
-                       // which is needed or the personal links will be
-                       // wrong.
-                       $this->getContext()->setUser( $u );
-                       Hooks::run( 'AddNewAccount', [ $u, false ] );
-                       $u->addNewUserLogEntry( 'create' );
-                       if ( $this->hasSessionCookie() ) {
-                               $this->successfulCreation();
-                       } else {
-                               $this->cookieRedirectCheck( 'new' );
-                       }
-               } else {
-                       # Confirm that the account was created
-                       $out->setPageTitle( $this->msg( 'accountcreated' ) );
-                       $out->addWikiMsg( 'accountcreatedtext', $u->getName() );
-                       $out->addReturnTo( $this->getPageTitle() );
-                       Hooks::run( 'AddNewAccount', [ $u, false ] );
-                       $u->addNewUserLogEntry( 'create2', $this->mReason );
-               }
-
-               return true;
-       }
-
-       /**
-        * Make a new user account using the loaded data.
-        * @private
-        * @throws PermissionsError|ReadOnlyError
-        * @return Status
-        */
-       public function addNewAccountInternal() {
-               global $wgAuth, $wgAccountCreationThrottle, $wgEmailConfirmToEdit;
-
-               // If the user passes an invalid domain, something is fishy
-               if ( !$wgAuth->validDomain( $this->mDomain ) ) {
-                       return Status::newFatal( 'wrongpassword' );
-               }
-
-               // If we are not allowing users to login locally, we should be checking
-               // to see if the user is actually able to authenticate to the authenti-
-               // cation server before they create an account (otherwise, they can
-               // create a local account and login as any domain user). We only need
-               // to check this for domains that aren't local.
-               if ( 'local' != $this->mDomain && $this->mDomain != '' ) {
-                       if (
-                               !$wgAuth->canCreateAccounts() &&
-                               (
-                                       !$wgAuth->userExists( $this->mUsername ) ||
-                                       !$wgAuth->authenticate( $this->mUsername, $this->mPassword )
-                               )
-                       ) {
-                               return Status::newFatal( 'wrongpassword' );
-                       }
-               }
-
-               if ( wfReadOnly() ) {
-                       throw new ReadOnlyError;
-               }
-
-               # Request forgery checks.
-               $token = self::getCreateaccountToken();
-               if ( $token->wasNew() ) {
-                       return Status::newFatal( 'nocookiesfornew' );
-               }
-
-               # The user didn't pass a createaccount token
-               if ( !$this->mToken ) {
-                       return Status::newFatal( 'sessionfailure' );
-               }
-
-               # Validate the createaccount token
-               if ( !$token->match( $this->mToken ) ) {
-                       return Status::newFatal( 'sessionfailure' );
-               }
-
-               # Check permissions
-               $currentUser = $this->getUser();
-               $creationBlock = $currentUser->isBlockedFromCreateAccount();
-               if ( !$currentUser->isAllowed( 'createaccount' ) ) {
-                       throw new PermissionsError( 'createaccount' );
-               } elseif ( $creationBlock instanceof Block ) {
-                       // Throws an ErrorPageError.
-                       $this->userBlockedMessage( $creationBlock );
-
-                       // This should never be reached.
-                       return false;
-               }
-
-               # Include checks that will include GlobalBlocking (Bug 38333)
-               $permErrors = $this->getPageTitle()->getUserPermissionsErrors(
-                       'createaccount',
-                       $currentUser,
-                       true
-               );
-
-               if ( count( $permErrors ) ) {
-                       throw new PermissionsError( 'createaccount', $permErrors );
-               }
-
-               $ip = $this->getRequest()->getIP();
-               if ( $currentUser->isDnsBlacklisted( $ip, true /* check $wgProxyWhitelist */ ) ) {
-                       return Status::newFatal( 'sorbs_create_account_reason' );
-               }
-
-               # Now create a dummy user ($u) and check if it is valid
-               $u = User::newFromName( $this->mUsername, 'creatable' );
-               if ( !$u ) {
-                       return Status::newFatal( 'noname' );
-               }
-
-               $cache = ObjectCache::getLocalClusterInstance();
-               # Make sure the user does not exist already
-               $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $this->mUsername ) ) );
-               if ( !$lock ) {
-                       return Status::newFatal( 'usernameinprogress' );
-               } elseif ( $u->idForName( User::READ_LOCKING ) ) {
-                       return Status::newFatal( 'userexists' );
-               }
-
-               if ( $this->mCreateaccountMail ) {
-                       # do not force a password for account creation by email
-                       # set invalid password, it will be replaced later by a random generated password
-                       $this->mPassword = null;
-               } else {
-                       if ( $this->mPassword !== $this->mRetype ) {
-                               return Status::newFatal( 'badretype' );
-                       }
-
-                       # check for password validity, return a fatal Status if invalid
-                       $validity = $u->checkPasswordValidity( $this->mPassword, 'create' );
-                       if ( !$validity->isGood() ) {
-                               $validity->ok = false; // make sure this Status is fatal
-                               return $validity;
-                       }
-               }
-
-               # if you need a confirmed email address to edit, then obviously you
-               # need an email address.
-               if ( $wgEmailConfirmToEdit && strval( $this->mEmail ) === '' ) {
-                       return Status::newFatal( 'noemailtitle' );
-               }
-
-               if ( strval( $this->mEmail ) !== '' && !Sanitizer::validateEmail( $this->mEmail ) ) {
-                       return Status::newFatal( 'invalidemailaddress' );
-               }
-
-               # Set some additional data so the AbortNewAccount hook can be used for
-               # more than just username validation
-               $u->setEmail( $this->mEmail );
-               $u->setRealName( $this->mRealName );
-
-               $abortError = '';
-               $abortStatus = null;
-               if ( !Hooks::run( 'AbortNewAccount', [ $u, &$abortError, &$abortStatus ] ) ) {
-                       // Hook point to add extra creation throttles and blocks
-                       wfDebug( "LoginForm::addNewAccountInternal: a hook blocked creation\n" );
-                       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 Status object.
-                               $abortError = new Message( 'createaccount-hook-aborted', [ $abortError ] );
-                               $abortError->text();
-
-                               return Status::newFatal( $abortError );
-                       } else {
-                               // For MediaWiki 1.23+ and updated hooks, return the Status object
-                               // returned from the hook.
-                               return $abortStatus;
-                       }
-               }
-
-               // Hook point to check for exempt from account creation throttle
-               if ( !Hooks::run( 'ExemptFromAccountCreationThrottle', [ $ip ] ) ) {
-                       wfDebug( "LoginForm::exemptFromAccountCreationThrottle: a hook " .
-                               "allowed account creation w/o throttle\n" );
-               } else {
-                       if ( ( $wgAccountCreationThrottle && $currentUser->isPingLimitable() ) ) {
-                               $key = wfGlobalCacheKey( 'acctcreate', 'ip', $ip );
-                               $value = $cache->get( $key );
-                               if ( !$value ) {
-                                       $cache->set( $key, 0, $cache::TTL_DAY );
-                               }
-                               if ( $value >= $wgAccountCreationThrottle ) {
-                                       return Status::newFatal( 'acct_creation_throttle_hit', $wgAccountCreationThrottle );
-                               }
-                               $cache->incr( $key );
-                       }
-               }
-
-               if ( !$wgAuth->addUser( $u, $this->mPassword, $this->mEmail, $this->mRealName ) ) {
-                       return Status::newFatal( 'externaldberror' );
-               }
-
-               self::clearCreateaccountToken();
-
-               return $this->initUser( $u, false );
-       }
-
-       /**
-        * Actually add a user to the database.
-        * Give it a User object that has been initialised with a name.
-        *
-        * @param User $u
-        * @param bool $autocreate True if this is an autocreation via auth plugin
-        * @return Status Status object, with the User object in the value member on success
-        * @private
-        */
-       function initUser( $u, $autocreate ) {
-               global $wgAuth;
-
-               $status = $u->addToDatabase();
-               if ( !$status->isOK() ) {
-                       return $status;
-               }
-
-               if ( $wgAuth->allowPasswordChange() ) {
-                       $u->setPassword( $this->mPassword );
-               }
-
-               $u->setEmail( $this->mEmail );
-               $u->setRealName( $this->mRealName );
-               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' );
-               }
-
-               $u->saveSettings();
-
-               // Update user count
-               DeferredUpdates::addUpdate( new SiteStatsUpdate( 0, 0, 0, 0, 1 ) );
-
-               // Watch user's userpage and talk page
-               $u->addWatch( $u->getUserPage(), User::IGNORE_USER_RIGHTS );
-
-               return Status::newGood( $u );
-       }
-
-       /**
-        * Internally authenticate the login request.
-        *
-        * This may create a local account as a side effect if the
-        * authentication plugin allows transparent local account
-        * creation.
-        * @return int
-        */
-       public function authenticateUserData() {
-               global $wgUser, $wgAuth;
-
-               $this->load();
-
-               if ( $this->mUsername == '' ) {
-                       return self::NO_NAME;
-               }
-
-               // We require a login token to prevent login CSRF
-               // Handle part of this before incrementing the throttle so
-               // token-less login attempts don't count towards the throttle
-               // but wrong-token attempts do.
-
-               // If the user doesn't have a login token yet, set one.
-               $token = self::getLoginToken();
-               if ( $token->wasNew() ) {
-                       return self::NEED_TOKEN;
-               }
-               // If the user didn't pass a login token, tell them we need one
-               if ( !$this->mToken ) {
-                       return self::NEED_TOKEN;
-               }
-
-               $throttleCount = self::incrementLoginThrottle( $this->mUsername );
-               if ( $throttleCount ) {
-                       $this->mThrottleWait = $throttleCount['wait'];
-                       return self::THROTTLED;
-               }
-
-               // Validate the login token
-               if ( !$token->match( $this->mToken ) ) {
-                       return self::WRONG_TOKEN;
-               }
-
-               // Load the current user now, and check to see if we're logging in as
-               // the same name. This is necessary because loading the current user
-               // (say by calling getName()) calls the UserLoadFromSession hook, which
-               // potentially creates the user in the database. Until we load $wgUser,
-               // checking for user existence using User::newFromName($name)->getId() below
-               // will effectively be using stale data.
-               if ( $this->getUser()->getName() === $this->mUsername ) {
-                       wfDebug( __METHOD__ . ": already logged in as {$this->mUsername}\n" );
-
-                       return self::SUCCESS;
-               }
-
-               $u = User::newFromName( $this->mUsername );
-               if ( $u === false ) {
-                       return self::ILLEGAL;
-               }
-
-               $msg = null;
-               // Give extensions a way to indicate the username has been updated,
-               // rather than telling the user the account doesn't exist.
-               if ( !Hooks::run( 'LoginUserMigrated', [ $u, &$msg ] ) ) {
-                       $this->mAbortLoginErrorMsg = $msg;
-                       return self::USER_MIGRATED;
-               }
-
-               if ( !User::isUsableName( $u->getName() ) ) {
-                       return self::ILLEGAL;
-               }
-
-               $isAutoCreated = false;
-               if ( $u->getId() == 0 ) {
-                       $status = $this->attemptAutoCreate( $u );
-                       if ( $status !== self::SUCCESS ) {
-                               return $status;
-                       } else {
-                               $isAutoCreated = true;
-                       }
-               } else {
-                       $u->load();
-               }
-
-               // Give general extensions, such as a captcha, a chance to abort logins
-               $abort = self::ABORTED;
-               if ( !Hooks::run( 'AbortLogin', [ $u, $this->mPassword, &$abort, &$msg ] ) ) {
-                       if ( !in_array( $abort, array_keys( self::$statusCodes ), true ) ) {
-                               throw new Exception( 'Invalid status code returned from AbortLogin hook: ' . $abort );
-                       }
-                       $this->mAbortLoginErrorMsg = $msg;
-                       return $abort;
-               }
-
-               global $wgBlockDisablesLogin;
-               if ( !$u->checkPassword( $this->mPassword ) ) {
-                       if ( $u->checkTemporaryPassword( $this->mPassword ) ) {
-                               /**
-                                * The e-mailed temporary password should not be used for actu-
-                                * al logins; that's a very sloppy habit, and insecure if an
-                                * attacker has a few seconds to click "search" on someone's
-                                * open mail reader.
-                                *
-                                * Allow it to be used only to reset the password a single time
-                                * to a new value, which won't be in the user's e-mail ar-
-                                * chives.
-                                *
-                                * For backwards compatibility, we'll still recognize it at the
-                                * login form to minimize surprises for people who have been
-                                * logging in with a temporary password for some time.
-                                *
-                                * As a side-effect, we can authenticate the user's e-mail ad-
-                                * dress if it's not already done, since the temporary password
-                                * was sent via e-mail.
-                                */
-                               if ( !$u->isEmailConfirmed() && !wfReadOnly() ) {
-                                       $u->confirmEmail();
-                                       $u->saveSettings();
-                               }
-
-                               // At this point we just return an appropriate code/ indicating
-                               // that the UI should show a password reset form; bot inter-
-                               // faces etc will probably just fail cleanly here.
-                               $this->mAbortLoginErrorMsg = 'resetpass-temp-emailed';
-                               $this->mTempPasswordUsed = true;
-                               $retval = self::RESET_PASS;
-                       } else {
-                               $retval = ( $this->mPassword == '' ) ? self::EMPTY_PASS : self::WRONG_PASS;
-                       }
-               } elseif ( $wgBlockDisablesLogin && $u->isBlocked() ) {
-                       // If we've enabled it, make it so that a blocked user cannot login
-                       $retval = self::USER_BLOCKED;
-               } elseif ( $this->checkUserPasswordExpired( $u ) == 'hard' ) {
-                       // Force reset now, without logging in
-                       $retval = self::RESET_PASS;
-                       $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' );
-                       }
-                       $wgUser = $u;
-                       // This should set it for OutputPage and the Skin
-                       // which is needed or the personal links will be
-                       // wrong.
-                       $this->getContext()->setUser( $u );
-
-                       // Please reset throttle for successful logins, thanks!
-                       self::clearLoginThrottle( $this->mUsername );
-
-                       if ( $isAutoCreated ) {
-                               // Must be run after $wgUser is set, for correct new user log
-                               Hooks::run( 'AuthPluginAutoCreate', [ $u ] );
-                       }
-
-                       $retval = self::SUCCESS;
-               }
-               Hooks::run( 'LoginAuthenticateAudit', [ $u, $this->mPassword, $retval ] );
-
-               return $retval;
-       }
-
-       /**
-        * Increment the login attempt throttle hit count for the (username,current IP)
-        * tuple unless the throttle was already reached.
-        *
-        * @since 1.27 Return value changed.
-        * @param string $username The user name
-        * @return bool|array false if below limit or an array if above limit
-        *   Array contains keys wait, count, and throttleIndex
-        */
-       public static function incrementLoginThrottle( $username ) {
-               global $wgPasswordAttemptThrottle, $wgRequest;
-               $username = User::getCanonicalName( $username, 'usable' ) ?: $username;
-
-               $throttleCount = 0;
-               if ( is_array( $wgPasswordAttemptThrottle ) ) {
-                       $throttleConfig = $wgPasswordAttemptThrottle;
-                       if ( isset( $wgPasswordAttemptThrottle['count'] ) ) {
-                               // old style. Convert for backwards compat.
-                               $throttleConfig = [ $wgPasswordAttemptThrottle ];
-                       }
-                       foreach ( $throttleConfig as $index => $specificThrottle ) {
-                               if ( isset( $specificThrottle['allIPs'] ) ) {
-                                       $ip = 'All';
-                               } else {
-                                       $ip = $wgRequest->getIP();
-                               }
-                               $throttleKey = wfGlobalCacheKey( 'password-throttle',
-                                       $index, $ip, md5( $username )
-                               );
-                               $count = $specificThrottle['count'];
-                               $period = $specificThrottle['seconds'];
-
-                               $cache = ObjectCache::getLocalClusterInstance();
-                               $throttleCount = $cache->get( $throttleKey );
-                               if ( !$throttleCount ) {
-                                       $cache->add( $throttleKey, 1, $period ); // start counter
-                               } elseif ( $throttleCount < $count ) {
-                                       $cache->incr( $throttleKey );
-                               } elseif ( $throttleCount >= $count ) {
-                                       $logMsg = 'Login attempt rejected because logins to '
-                                               . '{acct} from IP {ip} have been throttled for '
-                                               . '{period} seconds due to {count} failed attempts';
-                                       // If we are hitting a throttle for >= 50 attempts,
-                                       // it is much more likely to be an attack than someone
-                                       // simply forgetting their password, so log it at a
-                                       // higher level.
-                                       $level = $count >= 50 ? 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.
-                                       LoggerFactory::getInstance( 'password-throttle' )->log(
-                                               $level,
-                                               $logMsg,
-                                               [
-                                                       'ip' => $ip,
-                                                       'period' => $period,
-                                                       'acct' => $username,
-                                                       'count' => $count,
-                                                       'throttleIdentifier' => $index,
-                                                       'method' => __METHOD__
-                                               ]
-                                       );
-
-                                       return [
-                                               'throttleIndex' => $index,
-                                               'wait' => $period,
-                                               'count' => $count
-                                       ];
-                               }
-                       }
-               }
-               return false;
-       }
-
-       /**
-        * Increment the login attempt throttle hit count for the (username,current IP)
-        * tuple unless the throttle was already reached.
-        *
-        * @deprecated Use LoginForm::incrementLoginThrottle instead
-        * @param string $username The user name
-        * @return bool|int true if above throttle, or 0 (prior to 1.27, returned current count)
-        */
-       public static function incLoginThrottle( $username ) {
-               wfDeprecated( __METHOD__, "1.27" );
-               $res = self::incrementLoginThrottle( $username );
-               return is_array( $res ) ? true : 0;
-       }
-
-       /**
-        * Clear the login attempt throttle hit count for the (username,current IP) tuple.
-        * @param string $username The user name
-        * @return void
-        */
-       public static function clearLoginThrottle( $username ) {
-               global $wgRequest, $wgPasswordAttemptThrottle;
-               $username = User::getCanonicalName( $username, 'usable' ) ?: $username;
-
-               if ( is_array( $wgPasswordAttemptThrottle ) ) {
-                       $throttleConfig = $wgPasswordAttemptThrottle;
-                       if ( isset( $wgPasswordAttemptThrottle['count'] ) ) {
-                               // old style. Convert for backwards compat.
-                               $throttleConfig = [ $wgPasswordAttemptThrottle ];
-                       }
-                       foreach ( $throttleConfig as $index => $specificThrottle ) {
-                               if ( isset( $specificThrottle['allIPs'] ) ) {
-                                       $ip = 'All';
-                               } else {
-                                       $ip = $wgRequest->getIP();
-                               }
-                               $throttleKey = wfGlobalCacheKey( 'password-throttle', $index,
-                                       $ip, md5( $username )
-                               );
-                               ObjectCache::getLocalClusterInstance()->delete( $throttleKey );
-                       }
-               }
-       }
-
-       /**
-        * Attempt to automatically create a user on login. Only succeeds if there
-        * is an external authentication method which allows it.
-        *
-        * @param User $user
-        *
-        * @return int Status code
-        */
-       function attemptAutoCreate( $user ) {
-               global $wgAuth;
-
-               if ( $this->getUser()->isBlockedFromCreateAccount() ) {
-                       wfDebug( __METHOD__ . ": user is blocked from account creation\n" );
-
-                       return self::CREATE_BLOCKED;
-               }
-
-               if ( !$wgAuth->autoCreate() ) {
-                       return self::NOT_EXISTS;
-               }
-
-               if ( !$wgAuth->userExists( $user->getName() ) ) {
-                       wfDebug( __METHOD__ . ": user does not exist\n" );
-
-                       return self::NOT_EXISTS;
-               }
-
-               if ( !$wgAuth->authenticate( $user->getName(), $this->mPassword ) ) {
-                       wfDebug( __METHOD__ . ": \$wgAuth->authenticate() returned false, aborting\n" );
-
-                       return self::WRONG_PLUGIN_PASS;
-               }
-
-               $abortError = '';
-               if ( !Hooks::run( 'AbortAutoAccount', [ $user, &$abortError ] ) ) {
-                       // Hook point to add extra creation throttles and blocks
-                       wfDebug( "LoginForm::attemptAutoCreate: a hook blocked creation: $abortError\n" );
-                       $this->mAbortLoginErrorMsg = $abortError;
-
-                       return self::ABORTED;
-               }
-
-               wfDebug( __METHOD__ . ": creating account\n" );
-               $status = $this->initUser( $user, true );
-
-               if ( !$status->isOK() ) {
-                       $errors = $status->getErrorsByType( 'error' );
-                       $this->mAbortLoginErrorMsg = $errors[0]['message'];
-
-                       return self::ABORTED;
-               }
-
-               return self::SUCCESS;
-       }
-
-       function processLogin() {
-               global $wgLang, $wgSecureLogin, $wgInvalidPasswordReset;
-
-               $authRes = $this->authenticateUserData();
-               switch ( $authRes ) {
-                       case self::SUCCESS:
-                               # We've verified now, update the real record
-                               $user = $this->getUser();
-                               $user->touch();
-
-                               if ( $user->requiresHTTPS() ) {
-                                       $this->mStickHTTPS = true;
-                               }
-
-                               if ( $wgSecureLogin && !$this->mStickHTTPS ) {
-                                       $user->setCookies( $this->mRequest, false, $this->mRemember );
-                               } else {
-                                       $user->setCookies( $this->mRequest, null, $this->mRemember );
-                               }
-                               self::clearLoginToken();
-
-                               // Reset the throttle
-                               self::clearLoginThrottle( $this->mUsername );
-
-                               $request = $this->getRequest();
-                               if ( $this->hasSessionCookie() || $this->mSkipCookieCheck ) {
-                                       /* Replace the language object to provide user interface in
-                                        * correct language immediately on this first page load.
-                                        */
-                                       $code = $request->getVal( 'uselang', $user->getOption( 'language' ) );
-                                       $userLang = Language::factory( $code );
-                                       $wgLang = $userLang;
-                                       RequestContext::getMain()->setLanguage( $userLang );
-                                       $this->getContext()->setLanguage( $userLang );
-                                       // Reset SessionID on Successful login (bug 40995)
-                                       $this->renewSessionId();
-                                       if ( $this->checkUserPasswordExpired( $this->getUser() ) == 'soft' ) {
-                                               $this->resetLoginForm( $this->msg( 'resetpass-expired-soft' ) );
-                                       } elseif ( $wgInvalidPasswordReset
-                                               && !$user->isValidPassword( $this->mPassword )
-                                       ) {
-                                               $status = $user->checkPasswordValidity(
-                                                       $this->mPassword,
-                                                       'login'
-                                               );
-                                               $this->resetLoginForm(
-                                                       $status->getMessage( 'resetpass-validity-soft' )
-                                               );
-                                       } else {
-                                               $this->successfulLogin();
-                                       }
-                               } else {
-                                       $this->cookieRedirectCheck( 'login' );
-                               }
-                               break;
-
-                       case self::NEED_TOKEN:
-                               $error = $this->mAbortLoginErrorMsg ?: 'nocookiesforlogin';
-                               $this->mainLoginForm( $this->msg( $error )->parse() );
-                               break;
-                       case self::WRONG_TOKEN:
-                               $error = $this->mAbortLoginErrorMsg ?: 'sessionfailure';
-                               $this->mainLoginForm( $this->msg( $error )->text() );
-                               break;
-                       case self::NO_NAME:
-                       case self::ILLEGAL:
-                               $error = $this->mAbortLoginErrorMsg ?: 'noname';
-                               $this->mainLoginForm( $this->msg( $error )->text() );
-                               break;
-                       case self::WRONG_PLUGIN_PASS:
-                               $error = $this->mAbortLoginErrorMsg ?: 'wrongpassword';
-                               $this->mainLoginForm( $this->msg( $error )->text() );
-                               break;
-                       case self::NOT_EXISTS:
-                               if ( $this->getUser()->isAllowed( 'createaccount' ) ) {
-                                       $error = $this->mAbortLoginErrorMsg ?: 'nosuchuser';
-                                       $this->mainLoginForm( $this->msg( $error,
-                                               wfEscapeWikiText( $this->mUsername ) )->parse() );
-                               } else {
-                                       $error = $this->mAbortLoginErrorMsg ?: 'nosuchusershort';
-                                       $this->mainLoginForm( $this->msg( $error,
-                                               wfEscapeWikiText( $this->mUsername ) )->text() );
-                               }
-                               break;
-                       case self::WRONG_PASS:
-                               $error = $this->mAbortLoginErrorMsg ?: 'wrongpassword';
-                               $this->mainLoginForm( $this->msg( $error )->text() );
-                               break;
-                       case self::EMPTY_PASS:
-                               $error = $this->mAbortLoginErrorMsg ?: 'wrongpasswordempty';
-                               $this->mainLoginForm( $this->msg( $error )->text() );
-                               break;
-                       case self::RESET_PASS:
-                               $error = $this->mAbortLoginErrorMsg ?: 'resetpass_announce';
-                               $this->resetLoginForm( $this->msg( $error ) );
-                               break;
-                       case self::CREATE_BLOCKED:
-                               $this->userBlockedMessage( $this->getUser()->isBlockedFromCreateAccount() );
-                               break;
-                       case self::THROTTLED:
-                               $error = $this->mAbortLoginErrorMsg ?: 'login-throttled';
-                               $this->mainLoginForm( $this->msg( $error )
-                                       ->durationParams( $this->mThrottleWait )->text()
-                               );
-                               break;
-                       case self::USER_BLOCKED:
-                               $error = $this->mAbortLoginErrorMsg ?: 'login-userblocked';
-                               $this->mainLoginForm( $this->msg( $error, $this->mUsername )->escaped() );
-                               break;
-                       case self::ABORTED:
-                               $error = $this->mAbortLoginErrorMsg ?: 'login-abort-generic';
-                               $this->mainLoginForm( $this->msg( $error,
-                                               wfEscapeWikiText( $this->mUsername ) )->text() );
-                               break;
-                       case self::USER_MIGRATED:
-                               $error = $this->mAbortLoginErrorMsg ?: 'login-migrated-generic';
-                               $params = [];
-                               if ( is_array( $error ) ) {
-                                       $error = array_shift( $this->mAbortLoginErrorMsg );
-                                       $params = $this->mAbortLoginErrorMsg;
-                               }
-                               $this->mainLoginForm( $this->msg( $error, $params )->text() );
-                               break;
-                       default:
-                               throw new MWException( 'Unhandled case value' );
-               }
-
-               LoggerFactory::getInstance( 'authmanager' )->info( 'Login attempt', [
-                       'event' => 'login',
-                       'successful' => $authRes === self::SUCCESS,
-                       'status' => LoginForm::$statusCodes[$authRes],
-               ] );
-       }
-
-       /**
-        * Show the Special:ChangePassword form, with custom message
-        * @param Message $msg
-        */
-       protected function resetLoginForm( Message $msg ) {
-               // Allow hooks to explain this password reset in more detail
-               Hooks::run( 'LoginPasswordResetMessage', [ &$msg, $this->mUsername ] );
-               $reset = new SpecialChangePassword();
-               $derivative = new DerivativeContext( $this->getContext() );
-               $derivative->setTitle( $reset->getPageTitle() );
-               $reset->setContext( $derivative );
-               if ( !$this->mTempPasswordUsed ) {
-                       $reset->setOldPasswordMessage( 'oldpassword' );
-               }
-               $reset->setChangeMessage( $msg );
-               $reset->execute( null );
-       }
-
-       /**
-        * @param User $u
-        * @param bool $throttle
-        * @param string $emailTitle Message name of email title
-        * @param string $emailText Message name of email text
-        * @return Status
-        */
-       function mailPasswordInternal( $u, $throttle = true, $emailTitle = 'passwordremindertitle',
-               $emailText = 'passwordremindertext'
-       ) {
-               global $wgNewPasswordExpiry, $wgMinimalPasswordLength;
-
-               if ( $u->getEmail() == '' ) {
-                       return Status::newFatal( 'noemail', $u->getName() );
-               }
-               $ip = $this->getRequest()->getIP();
-               if ( !$ip ) {
-                       return Status::newFatal( 'badipaddress' );
-               }
-
-               $currentUser = $this->getUser();
-               Hooks::run( 'User::mailPasswordInternal', [ &$currentUser, &$ip, &$u ] );
-
-               $np = PasswordFactory::generateRandomPasswordString( $wgMinimalPasswordLength );
-               $u->setNewpassword( $np, $throttle );
-               $u->saveSettings();
-               $userLanguage = $u->getOption( 'language' );
-
-               $mainPage = Title::newMainPage();
-               $mainPageUrl = $mainPage->getCanonicalURL();
-
-               $m = $this->msg( $emailText, $ip, $u->getName(), $np, '<' . $mainPageUrl . '>',
-                       round( $wgNewPasswordExpiry / 86400 ) )->inLanguage( $userLanguage )->text();
-               $result = $u->sendMail( $this->msg( $emailTitle )->inLanguage( $userLanguage )->text(), $m );
-
-               return $result;
-       }
-
-       /**
-        * Run any hooks registered for logins, then HTTP redirect to
-        * $this->mReturnTo (or Main Page if that's undefined).  Formerly we had a
-        * nice message here, but that's really not as useful as just being sent to
-        * wherever you logged in from.  It should be clear that the action was
-        * successful, given the lack of error messages plus the appearance of your
-        * name in the upper right.
-        *
-        * @private
-        */
-       function successfulLogin() {
-               # Run any hooks; display injected HTML if any, else redirect
-               $currentUser = $this->getUser();
-               $injected_html = '';
-               Hooks::run( 'UserLoginComplete', [ &$currentUser, &$injected_html ] );
-
-               if ( $injected_html !== '' ) {
-                       $this->displaySuccessfulAction( 'success', $this->msg( 'loginsuccesstitle' ),
-                               'loginsuccess', $injected_html );
-               } else {
-                       $this->executeReturnTo( 'successredirect' );
-               }
-       }
-
-       /**
-        * Run any hooks registered for logins, then display a message welcoming
-        * the user.
-        *
-        * @private
-        */
-       function successfulCreation() {
-               # Run any hooks; display injected HTML
-               $currentUser = $this->getUser();
-               $injected_html = '';
-               $welcome_creation_msg = 'welcomecreation-msg';
-
-               Hooks::run( 'UserLoginComplete', [ &$currentUser, &$injected_html ] );
-
-               /**
-                * Let any extensions change what message is shown.
-                * @see https://www.mediawiki.org/wiki/Manual:Hooks/BeforeWelcomeCreation
-                * @since 1.18
-                */
-               Hooks::run( 'BeforeWelcomeCreation', [ &$welcome_creation_msg, &$injected_html ] );
-
-               $this->displaySuccessfulAction(
-                       'signup',
-                       $this->msg( 'welcomeuser', $this->getUser()->getName() ),
-                       $welcome_creation_msg, $injected_html
-               );
-       }
-
-       /**
-        * Display a "successful action" page.
-        *
-        * @param string $type Condition of return to; see `executeReturnTo`
-        * @param string|Message $title Page's title
-        * @param string $msgname
-        * @param string $injected_html
-        */
-       private function displaySuccessfulAction( $type, $title, $msgname, $injected_html ) {
-               $out = $this->getOutput();
-               $out->setPageTitle( $title );
-               if ( $msgname ) {
-                       $out->addWikiMsg( $msgname, wfEscapeWikiText( $this->getUser()->getName() ) );
-               }
-
-               $out->addHTML( $injected_html );
-
-               $this->executeReturnTo( $type );
-       }
-
-       /**
-        * Output a message that informs the user that they cannot create an account because
-        * there is a block on them or their IP which prevents account creation.  Note that
-        * User::isBlockedFromCreateAccount(), which gets this block, ignores the 'hardblock'
-        * setting on blocks (bug 13611).
-        * @param Block $block The block causing this error
-        * @throws ErrorPageError
-        */
-       function userBlockedMessage( Block $block ) {
-               # Let's be nice about this, it's likely that this feature will be used
-               # for blocking large numbers of innocent people, e.g. range blocks on
-               # schools. Don't blame it on the user. There's a small chance that it
-               # really is the user's fault, i.e. the username is blocked and they
-               # haven't bothered to log out before trying to create an account to
-               # evade it, but we'll leave that to their guilty conscience to figure
-               # out.
-               $errorParams = [
-                       $block->getTarget(),
-                       $block->mReason ? $block->mReason : $this->msg( 'blockednoreason' )->text(),
-                       $block->getByName()
-               ];
-
-               if ( $block->getType() === Block::TYPE_RANGE ) {
-                       $errorMessage = 'cantcreateaccount-range-text';
-                       $errorParams[] = $this->getRequest()->getIP();
-               } else {
-                       $errorMessage = 'cantcreateaccount-text';
-               }
-
-               throw new ErrorPageError(
-                       'cantcreateaccounttitle',
-                       $errorMessage,
-                       $errorParams
-               );
-       }
-
-       /**
-        * Add a "return to" link or redirect to it.
-        * Extensions can use this to reuse the "return to" logic after
-        * inject steps (such as redirection) into the login process.
-        *
-        * @param string $type One of the following:
-        *    - error: display a return to link ignoring $wgRedirectOnLogin
-        *    - signup: display a return to link using $wgRedirectOnLogin if needed
-        *    - success: display a return to link using $wgRedirectOnLogin if needed
-        *    - successredirect: send an HTTP redirect using $wgRedirectOnLogin if needed
-        * @param string $returnTo
-        * @param array|string $returnToQuery
-        * @param bool $stickHTTPs Keep redirect link on HTTPs
-        * @since 1.22
-        */
-       public function showReturnToPage(
-               $type, $returnTo = '', $returnToQuery = '', $stickHTTPs = false
-       ) {
-               $this->mReturnTo = $returnTo;
-               $this->mReturnToQuery = $returnToQuery;
-               $this->mStickHTTPS = $stickHTTPs;
-               $this->executeReturnTo( $type );
-       }
-
-       /**
-        * Add a "return to" link or redirect to it.
-        *
-        * @param string $type One of the following:
-        *    - error: display a return to link ignoring $wgRedirectOnLogin
-        *    - signup: display a return to link using $wgRedirectOnLogin if needed
-        *    - success: display a return to link using $wgRedirectOnLogin if needed
-        *    - successredirect: send an HTTP redirect using $wgRedirectOnLogin if needed
-        */
-       private function executeReturnTo( $type ) {
-               global $wgRedirectOnLogin, $wgSecureLogin;
-
-               if ( $type != 'error' && $wgRedirectOnLogin !== null ) {
-                       $returnTo = $wgRedirectOnLogin;
-                       $returnToQuery = [];
-               } else {
-                       $returnTo = $this->mReturnTo;
-                       $returnToQuery = wfCgiToArray( $this->mReturnToQuery );
-               }
-
-               // Allow modification of redirect behavior
-               Hooks::run( 'PostLoginRedirect', [ &$returnTo, &$returnToQuery, &$type ] );
-
-               $returnToTitle = Title::newFromText( $returnTo );
-               if ( !$returnToTitle ) {
-                       $returnToTitle = Title::newMainPage();
-               }
-
-               if ( $wgSecureLogin && !$this->mStickHTTPS ) {
-                       $options = [ 'http' ];
-                       $proto = PROTO_HTTP;
-               } elseif ( $wgSecureLogin ) {
-                       $options = [ 'https' ];
-                       $proto = PROTO_HTTPS;
-               } else {
-                       $options = [];
-                       $proto = PROTO_RELATIVE;
-               }
-
-               if ( $type == 'successredirect' ) {
-                       $redirectUrl = $returnToTitle->getFullURL( $returnToQuery, false, $proto );
-                       $this->getOutput()->redirect( $redirectUrl );
-               } else {
-                       $this->getOutput()->addReturnTo( $returnToTitle, $returnToQuery, null, $options );
-               }
-       }
-
-       /**
-        * @param string $msg
-        * @param string $msgtype
-        * @throws ErrorPageError
-        * @throws Exception
-        * @throws FatalError
-        * @throws MWException
-        * @throws PermissionsError
-        * @throws ReadOnlyError
-        * @private
-        */
-       function mainLoginForm( $msg, $msgtype = 'error' ) {
-               global $wgEnableEmail, $wgEnableUserEmail;
-               global $wgHiddenPrefs, $wgLoginLanguageSelector;
-               global $wgAuth, $wgEmailConfirmToEdit;
-               global $wgSecureLogin, $wgPasswordResetRoutes;
-               global $wgExtendedLoginCookieExpiration, $wgCookieExpiration;
-
-               $titleObj = $this->getPageTitle();
-               $user = $this->getUser();
-               $out = $this->getOutput();
-
-               if ( $this->mType == 'signup' ) {
-                       // Block signup here if in readonly. Keeps user from
-                       // going through the process (filling out data, etc)
-                       // and being informed later.
-                       $permErrors = $titleObj->getUserPermissionsErrors( 'createaccount', $user, true );
-                       if ( count( $permErrors ) ) {
-                               throw new PermissionsError( 'createaccount', $permErrors );
-                       } elseif ( $user->isBlockedFromCreateAccount() ) {
-                               $this->userBlockedMessage( $user->isBlockedFromCreateAccount() );
-
-                               return;
-                       } elseif ( wfReadOnly() ) {
-                               throw new ReadOnlyError;
-                       }
-               }
-
-               // Pre-fill username (if not creating an account, bug 44775).
-               if ( $this->mUsername == '' && $this->mType != 'signup' ) {
-                       if ( $user->isLoggedIn() ) {
-                               $this->mUsername = $user->getName();
-                       } else {
-                               $this->mUsername = $this->getRequest()->getSession()->suggestLoginUsername();
-                       }
-               }
-
-               // Generic styles and scripts for both login and signup form
-               $out->addModuleStyles( [
-                       'mediawiki.ui',
-                       'mediawiki.ui.button',
-                       'mediawiki.ui.checkbox',
-                       'mediawiki.ui.input',
-                       'mediawiki.special.userlogin.common.styles'
-               ] );
-
-               if ( $this->mType == 'signup' ) {
-                       // Additional styles and scripts for signup form
-                       $out->addModules( [
-                               'mediawiki.special.userlogin.signup.js'
-                       ] );
-                       $out->addModuleStyles( [
-                               'mediawiki.special.userlogin.signup.styles'
-                       ] );
-
-                       $template = new UsercreateTemplate( $this->getConfig() );
-
-                       // Must match number of benefits defined in messages
-                       $template->set( 'benefitCount', 3 );
-
-                       $q = 'action=submitlogin&type=signup';
-                       $linkq = 'type=login';
-               } else {
-                       // Additional styles for login form
-                       $out->addModuleStyles( [
-                               'mediawiki.special.userlogin.login.styles'
-                       ] );
-
-                       $template = new UserloginTemplate( $this->getConfig() );
-
-                       $q = 'action=submitlogin&type=login';
-                       $linkq = 'type=signup';
-               }
-
-               if ( $this->mReturnTo !== '' ) {
-                       $returnto = '&returnto=' . wfUrlencode( $this->mReturnTo );
-                       if ( $this->mReturnToQuery !== '' ) {
-                               $returnto .= '&returntoquery=' .
-                                       wfUrlencode( $this->mReturnToQuery );
-                       }
-                       $q .= $returnto;
-                       $linkq .= $returnto;
-               }
-
-               # Don't show a "create account" link if the user can't.
-               if ( $this->showCreateOrLoginLink( $user ) ) {
-                       # Pass any language selection on to the mode switch link
-                       if ( $wgLoginLanguageSelector && $this->mLanguage ) {
-                               $linkq .= '&uselang=' . $this->mLanguage;
-                       }
-                       // Supply URL, login template creates the button.
-                       $template->set( 'createOrLoginHref', $titleObj->getLocalURL( $linkq ) );
-               } else {
-                       $template->set( 'link', '' );
-               }
-
-               $resetLink = $this->mType == 'signup'
-                       ? null
-                       : is_array( $wgPasswordResetRoutes ) && in_array( true, array_values( $wgPasswordResetRoutes ) );
-
-               $template->set( 'header', '' );
-               $template->set( 'formheader', '' );
-               $template->set( 'skin', $this->getSkin() );
-               $template->set( 'name', $this->mUsername );
-               $template->set( 'password', $this->mPassword );
-               $template->set( 'retype', $this->mRetype );
-               $template->set( 'createemailset', $this->mCreateaccountMail );
-               $template->set( 'email', $this->mEmail );
-               $template->set( 'realname', $this->mRealName );
-               $template->set( 'domain', $this->mDomain );
-               $template->set( 'reason', $this->mReason );
-
-               $template->set( 'action', $titleObj->getLocalURL( $q ) );
-               $template->set( 'message', $msg );
-               $template->set( 'messagetype', $msgtype );
-               $template->set( 'createemail', $wgEnableEmail && $user->isLoggedIn() );
-               $template->set( 'userealname', !in_array( 'realname', $wgHiddenPrefs ) );
-               $template->set( 'useemail', $wgEnableEmail );
-               $template->set( 'emailrequired', $wgEmailConfirmToEdit );
-               $template->set( 'emailothers', $wgEnableUserEmail );
-               $template->set( 'canreset', $wgAuth->allowPasswordChange() );
-               $template->set( 'resetlink', $resetLink );
-               $template->set( 'canremember', $wgExtendedLoginCookieExpiration === null ?
-                       ( $wgCookieExpiration > 0 ) :
-                       ( $wgExtendedLoginCookieExpiration > 0 ) );
-               $template->set( 'usereason', $user->isLoggedIn() );
-               $template->set( 'remember', $this->mRemember );
-               $template->set( 'cansecurelogin', ( $wgSecureLogin === true ) );
-               $template->set( 'stickhttps', (int)$this->mStickHTTPS );
-               $template->set( 'loggedin', $user->isLoggedIn() );
-               $template->set( 'loggedinuser', $user->getName() );
-
-               if ( $this->mType == 'signup' ) {
-                       $template->set( 'token', self::getCreateaccountToken()->toString() );
-               } else {
-                       $template->set( 'token', self::getLoginToken()->toString() );
-               }
-
-               # Prepare language selection links as needed
-               if ( $wgLoginLanguageSelector ) {
-                       $template->set( 'languages', $this->makeLanguageSelector() );
-                       if ( $this->mLanguage ) {
-                               $template->set( 'uselang', $this->mLanguage );
-                       }
-               }
-
-               $template->set( 'secureLoginUrl', $this->mSecureLoginUrl );
-               // Use signupend-https for HTTPS requests if it's not blank, signupend otherwise
-               $usingHTTPS = $this->mRequest->getProtocol() == 'https';
-               $signupendHTTPS = $this->msg( 'signupend-https' );
-               if ( $usingHTTPS && !$signupendHTTPS->isBlank() ) {
-                       $template->set( 'signupend', $signupendHTTPS->parse() );
-               } else {
-                       $template->set( 'signupend', $this->msg( 'signupend' )->parse() );
-               }
-
-               // If using HTTPS coming from HTTP, then the 'fromhttp' parameter must be preserved
-               if ( $usingHTTPS ) {
-                       $template->set( 'fromhttp', $this->mFromHTTP );
-               }
-
-               // Give authentication and captcha plugins a chance to modify the form
-               $wgAuth->modifyUITemplate( $template, $this->mType );
-               if ( $this->mType == 'signup' ) {
-                       Hooks::run( 'UserCreateForm', [ &$template ] );
-               } else {
-                       Hooks::run( 'UserLoginForm', [ &$template ] );
-               }
-
-               $out->disallowUserJs(); // just in case...
-               $out->addTemplate( $template );
-       }
-
-       /**
-        * Whether the login/create account form should display a link to the
-        * other form (in addition to whatever the skin provides).
-        *
-        * @param User $user
-        * @return bool
-        */
-       private function showCreateOrLoginLink( &$user ) {
-               if ( $this->mType == 'signup' ) {
-                       return true;
-               } elseif ( $user->isAllowed( 'createaccount' ) ) {
-                       return true;
-               } else {
-                       return false;
-               }
-       }
-
-       /**
-        * Check if a session cookie is present.
-        *
-        * This will not pick up a cookie set during _this_ request, but is meant
-        * to ensure that the client is returning the cookie which was set on a
-        * previous pass through the system.
-        *
-        * @private
-        * @return bool
-        */
-       function hasSessionCookie() {
-               global $wgDisableCookieCheck, $wgInitialSessionId;
-
-               return $wgDisableCookieCheck || (
-                       $wgInitialSessionId &&
-                       $this->getRequest()->getSession()->getId() === (string)$wgInitialSessionId
-               );
-       }
-
-       /**
-        * Get the login token from the current session
-        * @since 1.27 returns a MediaWiki\Session\Token instead of a string
-        * @return MediaWiki\Session\Token
-        */
-       public static function getLoginToken() {
-               global $wgRequest;
-               return $wgRequest->getSession()->getToken( '', 'login' );
-       }
-
-       /**
-        * Formerly randomly generated a login token that would be returned by
-        * $this->getLoginToken().
-        *
-        * Since 1.27, this is a no-op. The token is generated as necessary by
-        * $this->getLoginToken().
-        *
-        * @deprecated since 1.27
-        */
-       public static function setLoginToken() {
-               wfDeprecated( __METHOD__, '1.27' );
-       }
-
-       /**
-        * Remove any login token attached to the current session
-        */
-       public static function clearLoginToken() {
-               global $wgRequest;
-               $wgRequest->getSession()->resetToken( 'login' );
-       }
-
-       /**
-        * Get the createaccount token from the current session
-        * @since 1.27 returns a MediaWiki\Session\Token instead of a string
-        * @return MediaWiki\Session\Token
-        */
-       public static function getCreateaccountToken() {
-               global $wgRequest;
-               return $wgRequest->getSession()->getToken( '', 'createaccount' );
-       }
-
-       /**
-        * Formerly randomly generated a createaccount token that would be returned
-        * by $this->getCreateaccountToken().
-        *
-        * Since 1.27, this is a no-op. The token is generated as necessary by
-        * $this->getCreateaccountToken().
-        *
-        * @deprecated since 1.27
-        */
-       public static function setCreateaccountToken() {
-               wfDeprecated( __METHOD__, '1.27' );
-       }
-
-       /**
-        * Remove any createaccount token attached to the current session
-        */
-       public static function clearCreateaccountToken() {
-               global $wgRequest;
-               $wgRequest->getSession()->resetToken( 'createaccount' );
-       }
-
-       /**
-        * Renew the user's session id, using strong entropy
-        */
-       private function renewSessionId() {
-               global $wgSecureLogin, $wgCookieSecure;
-               if ( $wgSecureLogin && !$this->mStickHTTPS ) {
-                       $wgCookieSecure = false;
-               }
-
-               SessionManager::getGlobalSession()->resetId();
-       }
-
-       /**
-        * @param string $type
-        * @private
-        */
-       function cookieRedirectCheck( $type ) {
-               $titleObj = SpecialPage::getTitleFor( 'Userlogin' );
-               $query = [ 'wpCookieCheck' => $type ];
-               if ( $this->mReturnTo !== '' ) {
-                       $query['returnto'] = $this->mReturnTo;
-                       $query['returntoquery'] = $this->mReturnToQuery;
-               }
-               $check = $titleObj->getFullURL( $query );
-
-               $this->getOutput()->redirect( $check );
-       }
-
-       /**
-        * @param string $type
-        * @private
-        */
-       function onCookieRedirectCheck( $type ) {
-               if ( !$this->hasSessionCookie() ) {
-                       if ( $type == 'new' ) {
-                               $this->mainLoginForm( $this->msg( 'nocookiesnew' )->parse() );
-                       } elseif ( $type == 'login' ) {
-                               $this->mainLoginForm( $this->msg( 'nocookieslogin' )->parse() );
-                       } else {
-                               # shouldn't happen
-                               $this->mainLoginForm( $this->msg( 'error' )->text() );
-                       }
-               } else {
-                       $this->successfulLogin();
-               }
-       }
-
-       /**
-        * Produce a bar of links which allow the user to select another language
-        * during login/registration but retain "returnto"
-        *
-        * @return string
-        */
-       function makeLanguageSelector() {
-               $msg = $this->msg( 'loginlanguagelinks' )->inContentLanguage();
-               if ( $msg->isBlank() ) {
-                       return '';
-               }
-               $langs = explode( "\n", $msg->text() );
-               $links = [];
-               foreach ( $langs as $lang ) {
-                       $lang = trim( $lang, '* ' );
-                       $parts = explode( '|', $lang );
-                       if ( count( $parts ) >= 2 ) {
-                               $links[] = $this->makeLanguageSelectorLink( $parts[0], trim( $parts[1] ) );
-                       }
-               }
-
-               return count( $links ) > 0 ? $this->msg( 'loginlanguagelabel' )->rawParams(
-                       $this->getLanguage()->pipeList( $links ) )->escaped() : '';
-       }
-
-       /**
-        * Create a language selector link for a particular language
-        * Links back to this page preserving type and returnto
-        *
-        * @param string $text Link text
-        * @param string $lang Language code
-        * @return string
-        */
-       function makeLanguageSelectorLink( $text, $lang ) {
-               if ( $this->getLanguage()->getCode() == $lang ) {
-                       // no link for currently used language
-                       return htmlspecialchars( $text );
-               }
-               $query = [ 'uselang' => $lang ];
-               if ( $this->mType == 'signup' ) {
-                       $query['type'] = 'signup';
-               }
-               if ( $this->mReturnTo !== '' ) {
-                       $query['returnto'] = $this->mReturnTo;
-                       $query['returntoquery'] = $this->mReturnToQuery;
-               }
-
-               $attr = [];
-               $targetLanguage = Language::factory( $lang );
-               $attr['lang'] = $attr['hreflang'] = $targetLanguage->getHtmlCode();
-
-               return Linker::linkKnown(
-                       $this->getPageTitle(),
-                       htmlspecialchars( $text ),
-                       $attr,
-                       $query
-               );
-       }
-
-       protected function getGroupName() {
-               return 'login';
-       }
-
-       /**
-        * Private function to check password expiration, until AuthManager comes
-        * along to handle that.
-        * @param User $user
-        * @return string|bool
-        */
-       private function checkUserPasswordExpired( User $user ) {
-               global $wgPasswordExpireGrace;
-               $dbr = wfGetDB( DB_SLAVE );
-               $ts = $dbr->selectField( 'user', 'user_password_expires', [ 'user_id' => $user->getId() ] );
-
-               $expired = false;
-               $now = wfTimestamp();
-               $expUnix = wfTimestamp( TS_UNIX, $ts );
-               if ( $ts !== null && $expUnix < $now ) {
-                       $expired = ( $expUnix + $wgPasswordExpireGrace < $now ) ? 'hard' : 'soft';
-               }
-               return $expired;
-       }
-
-       protected function getSubpagesForPrefixSearch() {
-               return [ 'signup' ];
-       }
-}
diff --git a/includes/specials/SpecialUserlogout.php b/includes/specials/SpecialUserlogout.php
deleted file mode 100644 (file)
index 5789e3a..0000000
+++ /dev/null
@@ -1,84 +0,0 @@
-<?php
-/**
- * Implements Special:Userlogout
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- * @ingroup SpecialPage
- */
-
-/**
- * Implements Special:Userlogout
- *
- * @ingroup SpecialPage
- */
-class SpecialUserlogout extends UnlistedSpecialPage {
-       function __construct() {
-               parent::__construct( 'Userlogout' );
-       }
-
-       public function doesWrites() {
-               return true;
-       }
-
-       function execute( $par ) {
-               /**
-                * Some satellite ISPs use broken precaching schemes that log people out straight after
-                * they're logged in (bug 17790). Luckily, there's a way to detect such requests.
-                */
-               if ( isset( $_SERVER['REQUEST_URI'] ) && strpos( $_SERVER['REQUEST_URI'], '&amp;' ) !== false ) {
-                       wfDebug( "Special:Userlogout request {$_SERVER['REQUEST_URI']} looks suspicious, denying.\n" );
-                       throw new HttpError( 400, $this->msg( 'suspicious-userlogout' ), $this->msg( 'loginerror' ) );
-               }
-
-               $this->setHeaders();
-               $this->outputHeader();
-
-               // Make sure it's possible to log out
-               $session = MediaWiki\Session\SessionManager::getGlobalSession();
-               if ( !$session->canSetUser() ) {
-                       throw new ErrorPageError(
-                               'cannotlogoutnow-title',
-                               'cannotlogoutnow-text',
-                               [
-                                       $session->getProvider()->describe( RequestContext::getMain()->getLanguage() )
-                               ]
-                       );
-               }
-
-               $user = $this->getUser();
-               $oldName = $user->getName();
-               $user->logout();
-
-               $loginURL = SpecialPage::getTitleFor( 'Userlogin' )->getFullURL(
-                       $this->getRequest()->getValues( 'returnto', 'returntoquery' ) );
-
-               $out = $this->getOutput();
-               $out->addWikiMsg( 'logouttext', $loginURL );
-
-               // Hook.
-               $injected_html = '';
-               Hooks::run( 'UserLogoutComplete', [ &$user, &$injected_html, $oldName ] );
-               $out->addHTML( $injected_html );
-
-               $out->returnToMain();
-       }
-
-       protected function getGroupName() {
-               return 'login';
-       }
-}
index be110aa..d5affc7 100644 (file)
@@ -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/specials/helpers/LoginHelper.php b/includes/specials/helpers/LoginHelper.php
new file mode 100644 (file)
index 0000000..f853f41
--- /dev/null
@@ -0,0 +1,98 @@
+<?php
+
+/**
+ * Helper functions for the login form that need to be shared with other special pages
+ * (such as CentralAuth's SpecialCentralLogin).
+ * @since 1.27
+ */
+class LoginHelper extends ContextSource {
+       /**
+        * Valid error and warning messages
+        *
+        * Special:Userlogin can show an error or warning message on the form when
+        * coming from another page. This is done via the ?error= or ?warning= GET
+        * parameters.
+        *
+        * This array is the list of valid message keys. Further keys can be added by the
+        * LoginFormValidErrorMessages hook. All other values will be ignored.
+        *
+        * @var string[]
+        */
+       public static $validErrorMessages = [
+               'exception-nologin-text',
+               'watchlistanontext',
+               'changeemail-no-info',
+               'resetpass-no-info',
+               'confirmemail_needlogin',
+               'prefsnologintext2',
+       ];
+
+       /**
+        * Returns an array of all valid error messages.
+        *
+        * @return array
+        * @see LoginHelper::$validErrorMessages
+        */
+       public static function getValidErrorMessages() {
+               static $messages = null;
+               if ( !$messages ) {
+                       $messages = self::$validErrorMessages;
+                       Hooks::run( 'LoginFormValidErrorMessages', [ &$messages ] );
+               }
+
+               return $messages;
+       }
+
+       public function __construct( IContextSource $context ) {
+               $this->setContext( $context );
+       }
+
+       /**
+        * Show a return link or redirect to it.
+        * Extensions can change where the link should point or inject content into the page
+        * (which will change it from redirect to link mode).
+        *
+        * @param string $type One of the following:
+        *    - error: display a return to link ignoring $wgRedirectOnLogin
+        *    - success: display a return to link using $wgRedirectOnLogin if needed
+        *    - successredirect: send an HTTP redirect using $wgRedirectOnLogin if needed
+        * @param string $returnTo
+        * @param array|string $returnToQuery
+        * @param bool $stickHTTPS Keep redirect link on HTTPS
+        */
+       public function showReturnToPage(
+               $type, $returnTo = '', $returnToQuery = '', $stickHTTPS = false
+       ) {
+               global $wgRedirectOnLogin, $wgSecureLogin;
+
+               if ( $type !== 'error' && $wgRedirectOnLogin !== null ) {
+                       $returnTo = $wgRedirectOnLogin;
+                       $returnToQuery = [];
+               } elseif ( is_string( $returnToQuery ) ) {
+                       $returnToQuery = wfCgiToArray( $returnToQuery );
+               }
+
+               // Allow modification of redirect behavior
+               Hooks::run( 'PostLoginRedirect', [ &$returnTo, &$returnToQuery, &$type ] );
+
+               $returnToTitle = Title::newFromText( $returnTo ) ?:  Title::newMainPage();
+
+               if ( $wgSecureLogin && !$stickHTTPS ) {
+                       $options = [ 'http' ];
+                       $proto = PROTO_HTTP;
+               } elseif ( $wgSecureLogin ) {
+                       $options = [ 'https' ];
+                       $proto = PROTO_HTTPS;
+               } else {
+                       $options = [];
+                       $proto = PROTO_RELATIVE;
+               }
+
+               if ( $type === 'successredirect' ) {
+                       $redirectUrl = $returnToTitle->getFullURL( $returnToQuery, false, $proto );
+                       $this->getOutput()->redirect( $redirectUrl );
+               } else {
+                       $this->getOutput()->addReturnTo( $returnToTitle, $returnToQuery, null, $options );
+               }
+       }
+}
diff --git a/includes/specials/pre-authmanager/README b/includes/specials/pre-authmanager/README
new file mode 100644 (file)
index 0000000..1cfdd5f
--- /dev/null
@@ -0,0 +1,10 @@
+This directory temporarily hosts pre-AuthManager code as a way of feature-flagging.
+Class names are postfixed with 'PreAuthManager' and SpecialPageFactory adds/removes
+that postfix based on the feature flag.
+
+This is a horrible hack that will only be in place for a few weeks, to allow instant
+rollback while AuthManager is tested in WMF production and major problems are ironed
+out. In the past such issues have been handled via deployment branches, but that
+meant blocking the work of all WMF developers from being deployed. This is hoped
+to be a less disruptive method.
+
diff --git a/includes/specials/pre-authmanager/SpecialChangeEmail.php b/includes/specials/pre-authmanager/SpecialChangeEmail.php
new file mode 100644 (file)
index 0000000..7861562
--- /dev/null
@@ -0,0 +1,216 @@
+<?php
+/**
+ * Implements Special:ChangeEmail
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Let users change their email address.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialChangeEmailPreAuthManager extends FormSpecialPage {
+       /**
+        * @var Status
+        */
+       private $status;
+
+       public function __construct() {
+               parent::__construct( 'ChangeEmail', 'editmyprivateinfo' );
+       }
+
+       public function doesWrites() {
+               return true;
+       }
+
+       /**
+        * @return bool
+        */
+       public function isListed() {
+               global $wgAuth;
+
+               return $wgAuth->allowPropChange( 'emailaddress' );
+       }
+
+       /**
+        * Main execution point
+        * @param string $par
+        */
+       function execute( $par ) {
+               $out = $this->getOutput();
+               $out->disallowUserJs();
+
+               parent::execute( $par );
+       }
+
+       protected function checkExecutePermissions( User $user ) {
+               global $wgAuth;
+
+               if ( !$wgAuth->allowPropChange( 'emailaddress' ) ) {
+                       throw new ErrorPageError( 'changeemail', 'cannotchangeemail' );
+               }
+
+               $this->requireLogin( 'changeemail-no-info' );
+
+               // This could also let someone check the current email address, so
+               // require both permissions.
+               if ( !$this->getUser()->isAllowed( 'viewmyprivateinfo' ) ) {
+                       throw new PermissionsError( 'viewmyprivateinfo' );
+               }
+
+               parent::checkExecutePermissions( $user );
+       }
+
+       protected function getFormFields() {
+               $user = $this->getUser();
+
+               $fields = [
+                       'Name' => [
+                               'type' => 'info',
+                               'label-message' => 'username',
+                               'default' => $user->getName(),
+                       ],
+                       'OldEmail' => [
+                               'type' => 'info',
+                               'label-message' => 'changeemail-oldemail',
+                               'default' => $user->getEmail() ?: $this->msg( 'changeemail-none' )->text(),
+                       ],
+                       'NewEmail' => [
+                               'type' => 'email',
+                               'label-message' => 'changeemail-newemail',
+                               'autofocus' => true,
+                               'help-message' => 'changeemail-newemail-help',
+                       ],
+               ];
+
+               if ( $this->getConfig()->get( 'RequirePasswordforEmailChange' ) ) {
+                       $fields['Password'] = [
+                               'type' => 'password',
+                               'label-message' => 'changeemail-password'
+                       ];
+               }
+
+               return $fields;
+       }
+
+       protected function getDisplayFormat() {
+               return 'ooui';
+       }
+
+       protected function alterForm( HTMLForm $form ) {
+               $form->setId( 'mw-changeemail-form' );
+               $form->setTableId( 'mw-changeemail-table' );
+               $form->setSubmitTextMsg( 'changeemail-submit' );
+               $form->addHiddenFields( $this->getRequest()->getValues( 'returnto', 'returntoquery' ) );
+
+               $form->addHeaderText( $this->msg( 'changeemail-header' )->parseAsBlock() );
+               if ( $this->getConfig()->get( 'RequirePasswordforEmailChange' ) ) {
+                       $form->addHeaderText( $this->msg( 'changeemail-passwordrequired' )->parseAsBlock() );
+               }
+       }
+
+       public function onSubmit( array $data ) {
+               $password = isset( $data['Password'] ) ? $data['Password'] : null;
+               $status = $this->attemptChange( $this->getUser(), $password, $data['NewEmail'] );
+
+               $this->status = $status;
+
+               return $status;
+       }
+
+       public function onSuccess() {
+               $request = $this->getRequest();
+
+               $returnto = $request->getVal( 'returnto' );
+               $titleObj = $returnto !== null ? Title::newFromText( $returnto ) : null;
+               if ( !$titleObj instanceof Title ) {
+                       $titleObj = Title::newMainPage();
+               }
+               $query = $request->getVal( 'returntoquery' );
+
+               if ( $this->status->value === true ) {
+                       $this->getOutput()->redirect( $titleObj->getFullURL( $query ) );
+               } elseif ( $this->status->value === 'eauth' ) {
+                       # Notify user that a confirmation email has been sent...
+                       $this->getOutput()->wrapWikiMsg( "<div class='error' style='clear: both;'>\n$1\n</div>",
+                               'eauthentsent', $this->getUser()->getName() );
+                       // just show the link to go back
+                       $this->getOutput()->addReturnTo( $titleObj, wfCgiToArray( $query ) );
+               }
+       }
+
+       /**
+        * @param User $user
+        * @param string $pass
+        * @param string $newaddr
+        * @return Status
+        */
+       private function attemptChange( User $user, $pass, $newaddr ) {
+               global $wgAuth;
+
+               if ( $newaddr != '' && !Sanitizer::validateEmail( $newaddr ) ) {
+                       return Status::newFatal( 'invalidemailaddress' );
+               }
+
+               if ( $newaddr === $user->getEmail() ) {
+                       return Status::newFatal( 'changeemail-nochange' );
+               }
+
+               $throttleInfo = LoginForm::incrementLoginThrottle( $user->getName() );
+               if ( $throttleInfo ) {
+                       $lang = $this->getLanguage();
+                       return Status::newFatal(
+                               'changeemail-throttled',
+                               $lang->formatDuration( $throttleInfo['wait'] )
+                       );
+               }
+
+               if ( $this->getConfig()->get( 'RequirePasswordforEmailChange' )
+                       && !$user->checkTemporaryPassword( $pass )
+                       && !$user->checkPassword( $pass )
+               ) {
+                       return Status::newFatal( 'wrongpassword' );
+               }
+
+               LoginForm::clearLoginThrottle( $user->getName() );
+
+               $oldaddr = $user->getEmail();
+               $status = $user->setEmailWithConfirmation( $newaddr );
+               if ( !$status->isGood() ) {
+                       return $status;
+               }
+
+               Hooks::run( 'PrefsEmailAudit', [ $user, $oldaddr, $newaddr ] );
+
+               $user->saveSettings();
+
+               $wgAuth->updateExternalDB( $user );
+
+               return $status;
+       }
+
+       public function requiresUnblock() {
+               return false;
+       }
+
+       protected function getGroupName() {
+               return 'users';
+       }
+}
diff --git a/includes/specials/pre-authmanager/SpecialChangePassword.php b/includes/specials/pre-authmanager/SpecialChangePassword.php
new file mode 100644 (file)
index 0000000..3955fee
--- /dev/null
@@ -0,0 +1,343 @@
+<?php
+/**
+ * Implements Special:ChangePassword
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Let users recover their password.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialChangePasswordPreAuthManager extends FormSpecialPage {
+       protected $mUserName;
+       protected $mDomain;
+
+       // Optional Wikitext Message to show above the password change form
+       protected $mPreTextMessage = null;
+
+       // label for old password input
+       protected $mOldPassMsg = null;
+
+       public function __construct() {
+               parent::__construct( 'ChangePassword', 'editmyprivateinfo' );
+               $this->listed( false );
+       }
+
+       public function doesWrites() {
+               return true;
+       }
+
+       /**
+        * Main execution point
+        * @param string|null $par
+        */
+       function execute( $par ) {
+               $this->getOutput()->disallowUserJs();
+
+               parent::execute( $par );
+       }
+
+       protected function checkExecutePermissions( User $user ) {
+               parent::checkExecutePermissions( $user );
+
+               if ( !$this->getRequest()->wasPosted() ) {
+                       $this->requireLogin( 'resetpass-no-info' );
+               }
+       }
+
+       /**
+        * Set a message at the top of the Change Password form
+        * @since 1.23
+        * @param Message $msg Message to parse and add to the form header
+        */
+       public function setChangeMessage( Message $msg ) {
+               $this->mPreTextMessage = $msg;
+       }
+
+       /**
+        * Set a message at the top of the Change Password form
+        * @since 1.23
+        * @param string $msg Message label for old/temp password field
+        */
+       public function setOldPasswordMessage( $msg ) {
+               $this->mOldPassMsg = $msg;
+       }
+
+       protected function getFormFields() {
+               $user = $this->getUser();
+               $request = $this->getRequest();
+
+               $oldpassMsg = $this->mOldPassMsg;
+               if ( $oldpassMsg === null ) {
+                       $oldpassMsg = $user->isLoggedIn() ? 'oldpassword' : 'resetpass-temp-password';
+               }
+
+               $fields = [
+                       'Name' => [
+                               'type' => 'info',
+                               'label-message' => 'username',
+                               'default' => $request->getVal( 'wpName', $user->getName() ),
+                       ],
+                       'Password' => [
+                               'type' => 'password',
+                               'label-message' => $oldpassMsg,
+                       ],
+                       'NewPassword' => [
+                               'type' => 'password',
+                               'label-message' => 'newpassword',
+                       ],
+                       'Retype' => [
+                               'type' => 'password',
+                               'label-message' => 'retypenew',
+                       ],
+               ];
+
+               if ( !$this->getUser()->isLoggedIn() ) {
+                       $fields['LoginOnChangeToken'] = [
+                               'type' => 'hidden',
+                               'label' => 'Change Password Token',
+                               'default' => LoginForm::getLoginToken()->toString(),
+                       ];
+               }
+
+               $extraFields = [];
+               Hooks::run( 'ChangePasswordForm', [ &$extraFields ] );
+               foreach ( $extraFields as $extra ) {
+                       list( $name, $label, $type, $default ) = $extra;
+                       $fields[$name] = [
+                               'type' => $type,
+                               'name' => $name,
+                               'label-message' => $label,
+                               'default' => $default,
+                       ];
+               }
+
+               if ( !$user->isLoggedIn() ) {
+                       $fields['Remember'] = [
+                               'type' => 'check',
+                               'label' => $this->msg( 'remembermypassword' )
+                                               ->numParams(
+                                                       ceil( $this->getConfig()->get( 'CookieExpiration' ) / ( 3600 * 24 ) )
+                                               )->text(),
+                               'default' => $request->getVal( 'wpRemember' ),
+                       ];
+               }
+
+               return $fields;
+       }
+
+       protected function alterForm( HTMLForm $form ) {
+               $form->setId( 'mw-resetpass-form' );
+               $form->setTableId( 'mw-resetpass-table' );
+               $form->setWrapperLegendMsg( 'resetpass_header' );
+               $form->setSubmitTextMsg(
+                       $this->getUser()->isLoggedIn()
+                               ? 'resetpass-submit-loggedin'
+                               : 'resetpass_submit'
+               );
+               $form->addButton( [
+                       'name' => 'wpCancel',
+                       'value' => $this->msg( 'resetpass-submit-cancel' )->text()
+               ] );
+               $form->setHeaderText( $this->msg( 'resetpass_text' )->parseAsBlock() );
+               if ( $this->mPreTextMessage instanceof Message ) {
+                       $form->addPreText( $this->mPreTextMessage->parseAsBlock() );
+               }
+               $form->addHiddenFields(
+                       $this->getRequest()->getValues( 'wpName', 'wpDomain', 'returnto', 'returntoquery' ) );
+       }
+
+       public function onSubmit( array $data ) {
+               global $wgAuth;
+
+               $request = $this->getRequest();
+
+               if ( $request->getCheck( 'wpLoginToken' ) ) {
+                       // This comes from Special:Userlogin when logging in with a temporary password
+                       return false;
+               }
+
+               if ( !$this->getUser()->isLoggedIn()
+                       && !LoginForm::getLoginToken()->match( $request->getVal( 'wpLoginOnChangeToken' ) )
+               ) {
+                       // Potential CSRF (bug 62497)
+                       return false;
+               }
+
+               if ( $request->getCheck( 'wpCancel' ) ) {
+                       $returnto = $request->getVal( 'returnto' );
+                       $titleObj = $returnto !== null ? Title::newFromText( $returnto ) : null;
+                       if ( !$titleObj instanceof Title ) {
+                               $titleObj = Title::newMainPage();
+                       }
+                       $query = $request->getVal( 'returntoquery' );
+                       $this->getOutput()->redirect( $titleObj->getFullURL( $query ) );
+
+                       return true;
+               }
+
+               $this->mUserName = $request->getVal( 'wpName', $this->getUser()->getName() );
+               $this->mDomain = $wgAuth->getDomain();
+
+               if ( !$wgAuth->allowPasswordChange() ) {
+                       throw new ErrorPageError( 'changepassword', 'resetpass_forbidden' );
+               }
+
+               $status = $this->attemptReset( $data['Password'], $data['NewPassword'], $data['Retype'] );
+
+               return $status;
+       }
+
+       public function onSuccess() {
+               if ( $this->getUser()->isLoggedIn() ) {
+                       $this->getOutput()->wrapWikiMsg(
+                               "<div class=\"successbox\">\n$1\n</div>",
+                               'changepassword-success'
+                       );
+                       $this->getOutput()->returnToMain();
+               } else {
+                       $request = $this->getRequest();
+                       LoginForm::clearLoginToken();
+                       $token = LoginForm::getLoginToken()->toString();
+                       $data = [
+                               'action' => 'submitlogin',
+                               'wpName' => $this->mUserName,
+                               'wpDomain' => $this->mDomain,
+                               'wpLoginToken' => $token,
+                               'wpPassword' => $request->getVal( 'wpNewPassword' ),
+                       ] + $request->getValues( 'wpRemember', 'returnto', 'returntoquery' );
+                       $login = new LoginForm( new DerivativeRequest( $request, $data, true ) );
+                       $login->setContext( $this->getContext() );
+                       $login->execute( null );
+               }
+       }
+
+       /**
+        * Checks the new password if it meets the requirements for passwords and set
+        * it as a current password, otherwise set the passed Status object to fatal
+        * and doesn't change anything
+        *
+        * @param string $oldpass The current (temporary) password.
+        * @param string $newpass The password to set.
+        * @param string $retype The string of the retype password field to check with newpass
+        * @return Status
+        */
+       protected function attemptReset( $oldpass, $newpass, $retype ) {
+               $isSelf = ( $this->mUserName === $this->getUser()->getName() );
+               if ( $isSelf ) {
+                       $user = $this->getUser();
+               } else {
+                       $user = User::newFromName( $this->mUserName );
+               }
+
+               if ( !$user || $user->isAnon() ) {
+                       return Status::newFatal( $this->msg( 'nosuchusershort', $this->mUserName ) );
+               }
+
+               if ( $newpass !== $retype ) {
+                       Hooks::run( 'PrefsPasswordAudit', [ $user, $newpass, 'badretype' ] );
+                       return Status::newFatal( $this->msg( 'badretype' ) );
+               }
+
+               $throttleInfo = LoginForm::incrementLoginThrottle( $this->mUserName );
+               if ( $throttleInfo ) {
+                       return Status::newFatal( $this->msg( 'changepassword-throttled' )
+                               ->durationParams( $throttleInfo['wait'] )
+                       );
+               }
+
+               // @todo Make these separate messages, since the message is written for both cases
+               if ( !$user->checkTemporaryPassword( $oldpass ) && !$user->checkPassword( $oldpass ) ) {
+                       Hooks::run( 'PrefsPasswordAudit', [ $user, $newpass, 'wrongpassword' ] );
+                       return Status::newFatal( $this->msg( 'resetpass-wrong-oldpass' ) );
+               }
+
+               // User is resetting their password to their old password
+               if ( $oldpass === $newpass ) {
+                       return Status::newFatal( $this->msg( 'resetpass-recycled' ) );
+               }
+
+               // Do AbortChangePassword after checking mOldpass, so we don't leak information
+               // by possibly aborting a new password before verifying the old password.
+               $abortMsg = 'resetpass-abort-generic';
+               if ( !Hooks::run( 'AbortChangePassword', [ $user, $oldpass, $newpass, &$abortMsg ] ) ) {
+                       Hooks::run( 'PrefsPasswordAudit', [ $user, $newpass, 'abortreset' ] );
+                       return Status::newFatal( $this->msg( $abortMsg ) );
+               }
+
+               // Please reset throttle for successful logins, thanks!
+               LoginForm::clearLoginThrottle( $this->mUserName );
+
+               try {
+                       $user->setPassword( $newpass );
+                       Hooks::run( 'PrefsPasswordAudit', [ $user, $newpass, 'success' ] );
+               } catch ( PasswordError $e ) {
+                       Hooks::run( 'PrefsPasswordAudit', [ $user, $newpass, 'error' ] );
+                       return Status::newFatal( new RawMessage( $e->getMessage() ) );
+               }
+
+               if ( $isSelf ) {
+                       // This is needed to keep the user connected since
+                       // changing the password also modifies the user's token.
+                       $remember = $this->getRequest()->getCookie( 'Token' ) !== null;
+                       $user->setCookies( null, null, $remember );
+               }
+               $user->saveSettings();
+               $this->resetPasswordExpiration( $user );
+               return Status::newGood();
+       }
+
+       public function requiresUnblock() {
+               return false;
+       }
+
+       protected function getGroupName() {
+               return 'users';
+       }
+
+       /**
+        * For resetting user password expiration, until AuthManager comes along
+        * @param User $user
+        */
+       private function resetPasswordExpiration( User $user ) {
+               global $wgPasswordExpirationDays;
+               $newExpire = null;
+               if ( $wgPasswordExpirationDays ) {
+                       $newExpire = wfTimestamp(
+                               TS_MW,
+                               time() + ( $wgPasswordExpirationDays * 24 * 3600 )
+                       );
+               }
+               // Give extensions a chance to force an expiration
+               Hooks::run( 'ResetPasswordExpiration', [ $this, &$newExpire ] );
+               $dbw = wfGetDB( DB_MASTER );
+               $dbw->update(
+                       'user',
+                       [ 'user_password_expires' => $dbw->timestampOrNull( $newExpire ) ],
+                       [ 'user_id' => $user->getId() ],
+                       __METHOD__
+               );
+       }
+
+       protected function getDisplayFormat() {
+               return 'ooui';
+       }
+}
diff --git a/includes/specials/pre-authmanager/SpecialCreateAccount.php b/includes/specials/pre-authmanager/SpecialCreateAccount.php
new file mode 100644 (file)
index 0000000..14f70b5
--- /dev/null
@@ -0,0 +1,60 @@
+<?php
+/**
+ * Redirect page: Special:CreateAccount --> Special:UserLogin/signup.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Redirect page: Special:CreateAccount --> Special:UserLogin/signup.
+ * @todo FIXME: This (and the rest of the login frontend) needs to die a horrible painful death
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialCreateAccountPreAuthManager extends SpecialRedirectToSpecial {
+       function __construct() {
+               parent::__construct(
+                       'CreateAccount',
+                       'Userlogin',
+                       'signup',
+                       [ 'returnto', 'returntoquery', 'uselang' ]
+               );
+       }
+
+       public function doesWrites() {
+               return true;
+       }
+
+       // No reason to hide this link on Special:Specialpages
+       public function isListed() {
+               return true;
+       }
+
+       public function isRestricted() {
+               return !User::groupHasPermission( '*', 'createaccount' );
+       }
+
+       public function userCanExecute( User $user ) {
+               return $user->isAllowed( 'createaccount' );
+       }
+
+       protected function getGroupName() {
+               return 'login';
+       }
+}
diff --git a/includes/specials/pre-authmanager/SpecialPasswordReset.php b/includes/specials/pre-authmanager/SpecialPasswordReset.php
new file mode 100644 (file)
index 0000000..e8719a7
--- /dev/null
@@ -0,0 +1,378 @@
+<?php
+/**
+ * Implements Special:PasswordReset
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Special page for requesting a password reset email
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialPasswordResetPreAuthManager extends FormSpecialPage {
+       /**
+        * @var Message
+        */
+       private $email;
+
+       /**
+        * @var User
+        */
+       private $firstUser;
+
+       /**
+        * @var Status
+        */
+       private $result;
+
+       /**
+        * @var string $method Identifies which password reset field was specified by the user.
+        */
+       private $method;
+
+       public function __construct() {
+               parent::__construct( 'PasswordReset', 'editmyprivateinfo' );
+       }
+
+       public function doesWrites() {
+               return true;
+       }
+
+       public function userCanExecute( User $user ) {
+               return $this->canChangePassword( $user ) === true && parent::userCanExecute( $user );
+       }
+
+       public function checkExecutePermissions( User $user ) {
+               $error = $this->canChangePassword( $user );
+               if ( is_string( $error ) ) {
+                       throw new ErrorPageError( 'internalerror', $error );
+               } elseif ( !$error ) {
+                       throw new ErrorPageError( 'internalerror', 'resetpass_forbidden' );
+               }
+
+               parent::checkExecutePermissions( $user );
+       }
+
+       protected function getFormFields() {
+               global $wgAuth;
+               $resetRoutes = $this->getConfig()->get( 'PasswordResetRoutes' );
+               $a = [];
+               if ( isset( $resetRoutes['username'] ) && $resetRoutes['username'] ) {
+                       $a['Username'] = [
+                               'type' => 'text',
+                               'label-message' => 'passwordreset-username',
+                       ];
+
+                       if ( $this->getUser()->isLoggedIn() ) {
+                               $a['Username']['default'] = $this->getUser()->getName();
+                       }
+               }
+
+               if ( isset( $resetRoutes['email'] ) && $resetRoutes['email'] ) {
+                       $a['Email'] = [
+                               'type' => 'email',
+                               'label-message' => 'passwordreset-email',
+                       ];
+               }
+
+               if ( isset( $resetRoutes['domain'] ) && $resetRoutes['domain'] ) {
+                       $domains = $wgAuth->domainList();
+                       $a['Domain'] = [
+                               'type' => 'select',
+                               'options' => $domains,
+                               'label-message' => 'passwordreset-domain',
+                       ];
+               }
+
+               if ( $this->getUser()->isAllowed( 'passwordreset' ) ) {
+                       $a['Capture'] = [
+                               'type' => 'check',
+                               'label-message' => 'passwordreset-capture',
+                               'help-message' => 'passwordreset-capture-help',
+                       ];
+               }
+
+               return $a;
+       }
+
+       protected function getDisplayFormat() {
+               return 'ooui';
+       }
+
+       public function alterForm( HTMLForm $form ) {
+               $resetRoutes = $this->getConfig()->get( 'PasswordResetRoutes' );
+
+               $form->addHiddenFields( $this->getRequest()->getValues( 'returnto', 'returntoquery' ) );
+
+               $i = 0;
+               if ( isset( $resetRoutes['username'] ) && $resetRoutes['username'] ) {
+                       $i++;
+               }
+               if ( isset( $resetRoutes['email'] ) && $resetRoutes['email'] ) {
+                       $i++;
+               }
+               if ( isset( $resetRoutes['domain'] ) && $resetRoutes['domain'] ) {
+                       $i++;
+               }
+
+               $message = ( $i > 1 ) ? 'passwordreset-text-many' : 'passwordreset-text-one';
+
+               $form->setHeaderText( $this->msg( $message, $i )->parseAsBlock() );
+               $form->setSubmitTextMsg( 'mailmypassword' );
+       }
+
+       /**
+        * Process the form.  At this point we know that the user passes all the criteria in
+        * userCanExecute(), and if the data array contains 'Username', etc, then Username
+        * resets are allowed.
+        * @param array $data
+        * @throws MWException
+        * @throws ThrottledError|PermissionsError
+        * @return bool|array
+        */
+       public function onSubmit( array $data ) {
+               global $wgAuth, $wgMinimalPasswordLength;
+
+               if ( isset( $data['Domain'] ) ) {
+                       if ( $wgAuth->validDomain( $data['Domain'] ) ) {
+                               $wgAuth->setDomain( $data['Domain'] );
+                       } else {
+                               $wgAuth->setDomain( 'invaliddomain' );
+                       }
+               }
+
+               if ( isset( $data['Capture'] ) && !$this->getUser()->isAllowed( 'passwordreset' ) ) {
+                       // The user knows they don't have the passwordreset permission,
+                       // but they tried to spoof the form. That's naughty
+                       throw new PermissionsError( 'passwordreset' );
+               }
+
+               /**
+                * @var $firstUser User
+                * @var $users User[]
+                */
+
+               if ( isset( $data['Username'] ) && $data['Username'] !== '' ) {
+                       $method = 'username';
+                       $users = [ User::newFromName( $data['Username'] ) ];
+               } elseif ( isset( $data['Email'] )
+                       && $data['Email'] !== ''
+                       && Sanitizer::validateEmail( $data['Email'] )
+               ) {
+                       $method = 'email';
+                       $res = wfGetDB( DB_SLAVE )->select(
+                               'user',
+                               User::selectFields(),
+                               [ 'user_email' => $data['Email'] ],
+                               __METHOD__
+                       );
+
+                       if ( $res ) {
+                               $users = [];
+
+                               foreach ( $res as $row ) {
+                                       $users[] = User::newFromRow( $row );
+                               }
+                       } else {
+                               // Some sort of database error, probably unreachable
+                               throw new MWException( 'Unknown database error in ' . __METHOD__ );
+                       }
+               } else {
+                       // The user didn't supply any data
+                       return false;
+               }
+
+               // Check for hooks (captcha etc), and allow them to modify the users list
+               $error = [];
+               if ( !Hooks::run( 'SpecialPasswordResetOnSubmit', [ &$users, $data, &$error ] ) ) {
+                       return [ $error ];
+               }
+
+               $this->method = $method;
+
+               if ( count( $users ) == 0 ) {
+                       if ( $method == 'email' ) {
+                               // Don't reveal whether or not an email address is in use
+                               return true;
+                       } else {
+                               return [ 'noname' ];
+                       }
+               }
+
+               $firstUser = $users[0];
+
+               if ( !$firstUser instanceof User || !$firstUser->getId() ) {
+                       // Don't parse username as wikitext (bug 65501)
+                       return [ [ 'nosuchuser', wfEscapeWikiText( $data['Username'] ) ] ];
+               }
+
+               // Check against the rate limiter
+               if ( $this->getUser()->pingLimiter( 'mailpassword' ) ) {
+                       throw new ThrottledError;
+               }
+
+               // Check against password throttle
+               foreach ( $users as $user ) {
+                       if ( $user->isPasswordReminderThrottled() ) {
+
+                               # Round the time in hours to 3 d.p., in case someone is specifying
+                               # minutes or seconds.
+                               return [ [
+                                       'throttled-mailpassword',
+                                       round( $this->getConfig()->get( 'PasswordReminderResendTime' ), 3 )
+                               ] ];
+                       }
+               }
+
+               // All the users will have the same email address
+               if ( $firstUser->getEmail() == '' ) {
+                       // This won't be reachable from the email route, so safe to expose the username
+                       return [ [ 'noemail', wfEscapeWikiText( $firstUser->getName() ) ] ];
+               }
+
+               // We need to have a valid IP address for the hook, but per bug 18347, we should
+               // send the user's name if they're logged in.
+               $ip = $this->getRequest()->getIP();
+               if ( !$ip ) {
+                       return [ 'badipaddress' ];
+               }
+               $caller = $this->getUser();
+               Hooks::run( 'User::mailPasswordInternal', [ &$caller, &$ip, &$firstUser ] );
+               $username = $caller->getName();
+               $msg = IP::isValid( $username )
+                       ? 'passwordreset-emailtext-ip'
+                       : 'passwordreset-emailtext-user';
+
+               // Send in the user's language; which should hopefully be the same
+               $userLanguage = $firstUser->getOption( 'language' );
+
+               $passwords = [];
+               foreach ( $users as $user ) {
+                       $password = PasswordFactory::generateRandomPasswordString( $wgMinimalPasswordLength );
+                       $user->setNewpassword( $password );
+                       $user->saveSettings();
+                       $passwords[] = $this->msg( 'passwordreset-emailelement', $user->getName(), $password )
+                               ->inLanguage( $userLanguage )->text(); // We'll escape the whole thing later
+               }
+               $passwordBlock = implode( "\n\n", $passwords );
+
+               $this->email = $this->msg( $msg )->inLanguage( $userLanguage );
+               $this->email->params(
+                       $username,
+                       $passwordBlock,
+                       count( $passwords ),
+                       '<' . Title::newMainPage()->getCanonicalURL() . '>',
+                       round( $this->getConfig()->get( 'NewPasswordExpiry' ) / 86400 )
+               );
+
+               $title = $this->msg( 'passwordreset-emailtitle' )->inLanguage( $userLanguage );
+
+               $this->result = $firstUser->sendMail( $title->text(), $this->email->text() );
+
+               if ( isset( $data['Capture'] ) && $data['Capture'] ) {
+                       // Save the user, will be used if an error occurs when sending the email
+                       $this->firstUser = $firstUser;
+               } else {
+                       // Blank the email if the user is not supposed to see it
+                       $this->email = null;
+               }
+
+               if ( $this->result->isGood() ) {
+                       return true;
+               } elseif ( isset( $data['Capture'] ) && $data['Capture'] ) {
+                       // The email didn't send, but maybe they knew that and that's why they captured it
+                       return true;
+               } else {
+                       // @todo FIXME: The email wasn't sent, but we have already set
+                       // the password throttle timestamp, so they won't be able to try
+                       // again until it expires...  :(
+                       return [ [ 'mailerror', $this->result->getMessage() ] ];
+               }
+       }
+
+       public function onSuccess() {
+               if ( $this->getUser()->isAllowed( 'passwordreset' ) && $this->email != null ) {
+                       // @todo Logging
+
+                       if ( $this->result->isGood() ) {
+                               $this->getOutput()->addWikiMsg( 'passwordreset-emailsent-capture' );
+                       } else {
+                               $this->getOutput()->addWikiMsg( 'passwordreset-emailerror-capture',
+                                       $this->result->getMessage(), $this->firstUser->getName() );
+                       }
+
+                       $this->getOutput()->addHTML( Html::rawElement( 'pre', [], $this->email->escaped() ) );
+               }
+
+               if ( $this->method === 'email' ) {
+                       $this->getOutput()->addWikiMsg( 'passwordreset-emailsentemail' );
+               } else {
+                       $this->getOutput()->addWikiMsg( 'passwordreset-emailsentusername' );
+               }
+
+               $this->getOutput()->returnToMain();
+       }
+
+       protected function canChangePassword( User $user ) {
+               global $wgAuth;
+               $resetRoutes = $this->getConfig()->get( 'PasswordResetRoutes' );
+
+               // Maybe password resets are disabled, or there are no allowable routes
+               if ( !is_array( $resetRoutes ) ||
+                       !in_array( true, array_values( $resetRoutes ) )
+               ) {
+                       return 'passwordreset-disabled';
+               }
+
+               // Maybe the external auth plugin won't allow local password changes
+               if ( !$wgAuth->allowPasswordChange() ) {
+                       return 'resetpass_forbidden';
+               }
+
+               // Maybe email features have been disabled
+               if ( !$this->getConfig()->get( 'EnableEmail' ) ) {
+                       return 'passwordreset-emaildisabled';
+               }
+
+               // Maybe the user is blocked (check this here rather than relying on the parent
+               // method as we have a more specific error message to use here
+               if ( $user->isBlocked() ) {
+                       return 'blocked-mailpassword';
+               }
+
+               return true;
+       }
+
+       /**
+        * Hide the password reset page if resets are disabled.
+        * @return bool
+        */
+       function isListed() {
+               if ( $this->canChangePassword( $this->getUser() ) === true ) {
+                       return parent::isListed();
+               }
+
+               return false;
+       }
+
+       protected function getGroupName() {
+               return 'users';
+       }
+}
diff --git a/includes/specials/pre-authmanager/SpecialUserlogin.php b/includes/specials/pre-authmanager/SpecialUserlogin.php
new file mode 100644 (file)
index 0000000..e8c13e3
--- /dev/null
@@ -0,0 +1,1842 @@
+<?php
+/**
+ * Implements Special:UserLogin
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+use MediaWiki\Logger\LoggerFactory;
+use Psr\Log\LogLevel;
+use MediaWiki\Session\SessionManager;
+
+/**
+ * Implements Special:UserLogin
+ *
+ * @ingroup SpecialPage
+ */
+class LoginFormPreAuthManager extends SpecialPage {
+       const SUCCESS = 0;
+       const NO_NAME = 1;
+       const ILLEGAL = 2;
+       const WRONG_PLUGIN_PASS = 3;
+       const NOT_EXISTS = 4;
+       const WRONG_PASS = 5;
+       const EMPTY_PASS = 6;
+       const RESET_PASS = 7;
+       const ABORTED = 8;
+       const CREATE_BLOCKED = 9;
+       const THROTTLED = 10;
+       const USER_BLOCKED = 11;
+       const NEED_TOKEN = 12;
+       const WRONG_TOKEN = 13;
+       const USER_MIGRATED = 14;
+
+       public static $statusCodes = [
+               self::SUCCESS => 'success',
+               self::NO_NAME => 'no_name',
+               self::ILLEGAL => 'illegal',
+               self::WRONG_PLUGIN_PASS => 'wrong_plugin_pass',
+               self::NOT_EXISTS => 'not_exists',
+               self::WRONG_PASS => 'wrong_pass',
+               self::EMPTY_PASS => 'empty_pass',
+               self::RESET_PASS => 'reset_pass',
+               self::ABORTED => 'aborted',
+               self::CREATE_BLOCKED => 'create_blocked',
+               self::THROTTLED => 'throttled',
+               self::USER_BLOCKED => 'user_blocked',
+               self::NEED_TOKEN => 'need_token',
+               self::WRONG_TOKEN => 'wrong_token',
+               self::USER_MIGRATED => 'user_migrated',
+       ];
+
+       /**
+        * Valid error and warning messages
+        *
+        * Special:Userlogin can show an error or warning message on the form when
+        * coming from another page. This is done via the ?error= or ?warning= GET
+        * parameters.
+        *
+        * This array is the list of valid message keys. All other values will be
+        * ignored.
+        *
+        * @since 1.24
+        * @var string[]
+        */
+       public static $validErrorMessages = [
+               'exception-nologin-text',
+               'watchlistanontext',
+               'changeemail-no-info',
+               'resetpass-no-info',
+               'confirmemail_needlogin',
+               'prefsnologintext2',
+       ];
+
+       public $mAbortLoginErrorMsg = null;
+       /**
+        * @var int How many seconds user is throttled for
+        * @since 1.27
+        */
+       public $mThrottleWait = '?';
+
+       protected $mUsername;
+       protected $mPassword;
+       protected $mRetype;
+       protected $mReturnTo;
+       protected $mCookieCheck;
+       protected $mPosted;
+       protected $mAction;
+       protected $mCreateaccount;
+       protected $mCreateaccountMail;
+       protected $mLoginattempt;
+       protected $mRemember;
+       protected $mEmail;
+       protected $mDomain;
+       protected $mLanguage;
+       protected $mSkipCookieCheck;
+       protected $mReturnToQuery;
+       protected $mToken;
+       protected $mStickHTTPS;
+       protected $mType;
+       protected $mReason;
+       protected $mRealName;
+       protected $mEntryError = '';
+       protected $mEntryErrorType = 'error';
+
+       private $mTempPasswordUsed;
+       private $mLoaded = false;
+       private $mSecureLoginUrl;
+
+       /** @var WebRequest */
+       private $mOverrideRequest = null;
+
+       /** @var WebRequest Effective request; set at the beginning of load */
+       private $mRequest = null;
+
+       /**
+        * @param WebRequest $request
+        */
+       public function __construct( $request = null ) {
+               global $wgUseMediaWikiUIEverywhere;
+               parent::__construct( 'Userlogin' );
+
+               $this->mOverrideRequest = $request;
+               // Override UseMediaWikiEverywhere to true, to force login and create form to use mw ui
+               $wgUseMediaWikiUIEverywhere = true;
+       }
+
+       public function doesWrites() {
+               return true;
+       }
+
+       /**
+        * Returns an array of all valid error messages.
+        *
+        * @return array
+        */
+       public static function getValidErrorMessages() {
+               static $messages = null;
+               if ( !$messages ) {
+                       $messages = self::$validErrorMessages;
+                       Hooks::run( 'LoginFormValidErrorMessages', [ &$messages ] );
+               }
+
+               return $messages;
+       }
+
+       /**
+        * Loader
+        */
+       function load() {
+               global $wgAuth, $wgHiddenPrefs, $wgEnableEmail;
+
+               if ( $this->mLoaded ) {
+                       return;
+               }
+               $this->mLoaded = true;
+
+               if ( $this->mOverrideRequest === null ) {
+                       $request = $this->getRequest();
+               } else {
+                       $request = $this->mOverrideRequest;
+               }
+               $this->mRequest = $request;
+
+               $this->mType = $request->getText( 'type' );
+               $this->mUsername = $request->getText( 'wpName' );
+               $this->mPassword = $request->getText( 'wpPassword' );
+               $this->mRetype = $request->getText( 'wpRetype' );
+               $this->mDomain = $request->getText( 'wpDomain' );
+               $this->mReason = $request->getText( 'wpReason' );
+               $this->mCookieCheck = $request->getVal( 'wpCookieCheck' );
+               $this->mPosted = $request->wasPosted();
+               $this->mCreateaccountMail = $request->getCheck( 'wpCreateaccountMail' )
+                       && $wgEnableEmail;
+               $this->mCreateaccount = $request->getCheck( 'wpCreateaccount' ) && !$this->mCreateaccountMail;
+               $this->mLoginattempt = $request->getCheck( 'wpLoginattempt' );
+               $this->mAction = $request->getVal( 'action' );
+               $this->mRemember = $request->getCheck( 'wpRemember' );
+               $this->mFromHTTP = $request->getBool( 'fromhttp', false )
+                       || $request->getBool( 'wpFromhttp', false );
+               $this->mStickHTTPS = ( !$this->mFromHTTP && $request->getProtocol() === 'https' )
+                       || $request->getBool( 'wpForceHttps', false );
+               $this->mLanguage = $request->getText( 'uselang' );
+               $this->mSkipCookieCheck = $request->getCheck( 'wpSkipCookieCheck' );
+               $this->mToken = $this->mType == 'signup'
+                       ? $request->getVal( 'wpCreateaccountToken' )
+                       : $request->getVal( 'wpLoginToken' );
+               $this->mReturnTo = $request->getVal( 'returnto', '' );
+               $this->mReturnToQuery = $request->getVal( 'returntoquery', '' );
+
+               // Show an error or warning passed on from a previous page
+               $entryError = $this->msg( $request->getVal( 'error', '' ) );
+               $entryWarning = $this->msg( $request->getVal( 'warning', '' ) );
+               // bc: provide login link as a parameter for messages where the translation
+               // was not updated
+               $loginreqlink = Linker::linkKnown(
+                       $this->getPageTitle(),
+                       $this->msg( 'loginreqlink' )->escaped(),
+                       [],
+                       [
+                               'returnto' => $this->mReturnTo,
+                               'returntoquery' => $this->mReturnToQuery,
+                               'uselang' => $this->mLanguage,
+                               'fromhttp' => $this->mFromHTTP ? '1' : '0',
+                       ]
+               );
+
+               // Only show valid error or warning messages.
+               if ( $entryError->exists()
+                       && in_array( $entryError->getKey(), self::getValidErrorMessages() )
+               ) {
+                       $this->mEntryErrorType = 'error';
+                       $this->mEntryError = $entryError->rawParams( $loginreqlink )->parse();
+
+               } elseif ( $entryWarning->exists()
+                       && in_array( $entryWarning->getKey(), self::getValidErrorMessages() )
+               ) {
+                       $this->mEntryErrorType = 'warning';
+                       $this->mEntryError = $entryWarning->rawParams( $loginreqlink )->parse();
+               }
+
+               if ( $wgEnableEmail ) {
+                       $this->mEmail = $request->getText( 'wpEmail' );
+               } else {
+                       $this->mEmail = '';
+               }
+               if ( !in_array( 'realname', $wgHiddenPrefs ) ) {
+                       $this->mRealName = $request->getText( 'wpRealName' );
+               } else {
+                       $this->mRealName = '';
+               }
+
+               if ( !$wgAuth->validDomain( $this->mDomain ) ) {
+                       $this->mDomain = $wgAuth->getDomain();
+               }
+               $wgAuth->setDomain( $this->mDomain );
+
+               # 1. When switching accounts, it sucks to get automatically logged out
+               # 2. Do not return to PasswordReset after a successful password change
+               #    but goto Wiki start page (Main_Page) instead ( bug 33997 )
+               $returnToTitle = Title::newFromText( $this->mReturnTo );
+               if ( is_object( $returnToTitle )
+                       && ( $returnToTitle->isSpecial( 'Userlogout' )
+                               || $returnToTitle->isSpecial( 'PasswordReset' ) )
+               ) {
+                       $this->mReturnTo = '';
+                       $this->mReturnToQuery = '';
+               }
+       }
+
+       function getDescription() {
+               if ( $this->mType === 'signup' ) {
+                       return $this->msg( 'createaccount' )->text();
+               } else {
+                       return $this->msg( 'login' )->text();
+               }
+       }
+
+       /**
+        * @param string|null $subPage
+        */
+       public function execute( $subPage ) {
+               // Make sure session is persisted
+               $session = SessionManager::getGlobalSession();
+               $session->persist();
+
+               $this->load();
+
+               // Check for [[Special:Userlogin/signup]]. This affects form display and
+               // page title.
+               if ( $subPage == 'signup' ) {
+                       $this->mType = 'signup';
+               }
+               $this->setHeaders();
+
+               // Make sure it's possible to log in
+               if ( $this->mType !== 'signup' && !$session->canSetUser() ) {
+                       throw new ErrorPageError(
+                               'cannotloginnow-title',
+                               'cannotloginnow-text',
+                               [
+                                       $session->getProvider()->describe( RequestContext::getMain()->getLanguage() )
+                               ]
+                       );
+               }
+
+               /**
+                * In the case where the user is already logged in, and was redirected to
+                * the login form from a page that requires login, do not show the login
+                * page. The use case scenario for this is when a user opens a large number
+                * of tabs, is redirected to the login page on all of them, and then logs
+                * in on one, expecting all the others to work properly.
+                *
+                * However, do show the form if it was visited intentionally (no 'returnto'
+                * is present). People who often switch between several accounts have grown
+                * accustomed to this behavior.
+                */
+               if (
+                       $this->mType !== 'signup' &&
+                       !$this->mPosted &&
+                       $this->getUser()->isLoggedIn() &&
+                       ( $this->mReturnTo !== '' || $this->mReturnToQuery !== '' )
+               ) {
+                       $this->successfulLogin();
+               }
+
+               // If logging in and not on HTTPS, either redirect to it or offer a link.
+               global $wgSecureLogin;
+               if ( $this->mRequest->getProtocol() !== 'https' ) {
+                       $title = $this->getFullTitle();
+                       $query = [
+                               'returnto' => $this->mReturnTo !== '' ? $this->mReturnTo : null,
+                               'returntoquery' => $this->mReturnToQuery !== '' ?
+                                       $this->mReturnToQuery : null,
+                               'title' => null,
+                               ( $this->mEntryErrorType === 'error' ? 'error' : 'warning' ) => $this->mEntryError,
+                       ] + $this->mRequest->getQueryValues();
+                       $url = $title->getFullURL( $query, false, PROTO_HTTPS );
+                       if ( $wgSecureLogin
+                               && wfCanIPUseHTTPS( $this->getRequest()->getIP() )
+                               && !$this->mFromHTTP ) // Avoid infinite redirect
+                       {
+                               $url = wfAppendQuery( $url, 'fromhttp=1' );
+                               $this->getOutput()->redirect( $url );
+                               // Since we only do this redir to change proto, always vary
+                               $this->getOutput()->addVaryHeader( 'X-Forwarded-Proto' );
+
+                               return;
+                       } else {
+                               // A wiki without HTTPS login support should set $wgServer to
+                               // http://somehost, in which case the secure URL generated
+                               // above won't actually start with https://
+                               if ( substr( $url, 0, 8 ) === 'https://' ) {
+                                       $this->mSecureLoginUrl = $url;
+                               }
+                       }
+               }
+
+               if ( !is_null( $this->mCookieCheck ) ) {
+                       $this->onCookieRedirectCheck( $this->mCookieCheck );
+
+                       return;
+               } elseif ( $this->mPosted ) {
+                       if ( $this->mCreateaccount ) {
+                               $this->addNewAccount();
+
+                               return;
+                       } elseif ( $this->mCreateaccountMail ) {
+                               $this->addNewAccountMailPassword();
+
+                               return;
+                       } elseif ( ( 'submitlogin' == $this->mAction ) || $this->mLoginattempt ) {
+                               $this->processLogin();
+
+                               return;
+                       }
+               }
+               $this->mainLoginForm( $this->mEntryError, $this->mEntryErrorType );
+       }
+
+       /**
+        * @private
+        */
+       function addNewAccountMailPassword() {
+               if ( $this->mEmail == '' ) {
+                       $this->mainLoginForm( $this->msg( 'noemailcreate' )->escaped() );
+
+                       return;
+               }
+
+               $status = $this->addNewAccountInternal();
+               LoggerFactory::getInstance( 'authmanager' )->info(
+                       'Account creation attempt with mailed password',
+                       [ 'event' => 'accountcreation', 'status' => $status ]
+               );
+               if ( !$status->isGood() ) {
+                       $error = $status->getMessage();
+                       $this->mainLoginForm( $error->toString() );
+
+                       return;
+               }
+
+               /** @var User $u */
+               $u = $status->getValue();
+
+               // Wipe the initial password and mail a temporary one
+               $u->setPassword( null );
+               $u->saveSettings();
+               $result = $this->mailPasswordInternal( $u, false, 'createaccount-title', 'createaccount-text' );
+
+               Hooks::run( 'AddNewAccount', [ $u, true ] );
+               $u->addNewUserLogEntry( 'byemail', $this->mReason );
+
+               $out = $this->getOutput();
+               $out->setPageTitle( $this->msg( 'accmailtitle' ) );
+
+               if ( !$result->isGood() ) {
+                       $this->mainLoginForm( $this->msg( 'mailerror', $result->getWikiText() )->text() );
+               } else {
+                       $out->addWikiMsg( 'accmailtext', $u->getName(), $u->getEmail() );
+                       $this->executeReturnTo( 'success' );
+               }
+       }
+
+       /**
+        * @private
+        * @return bool
+        */
+       function addNewAccount() {
+               global $wgContLang, $wgUser, $wgEmailAuthentication, $wgLoginLanguageSelector;
+
+               # Create the account and abort if there's a problem doing so
+               $status = $this->addNewAccountInternal();
+               LoggerFactory::getInstance( 'authmanager' )->info( 'Account creation attempt', [
+                       'event' => 'accountcreation',
+                       'status' => $status,
+               ] );
+
+               if ( !$status->isGood() ) {
+                       $error = $status->getMessage();
+                       $this->mainLoginForm( $error->toString() );
+
+                       return false;
+               }
+
+               $u = $status->getValue();
+
+               # Only save preferences if the user is not creating an account for someone else.
+               if ( $this->getUser()->isAnon() ) {
+                       # If we showed up language selection links, and one was in use, be
+                       # smart (and sensible) and save that language as the user's preference
+                       if ( $wgLoginLanguageSelector && $this->mLanguage ) {
+                               $u->setOption( 'language', $this->mLanguage );
+                       } else {
+
+                               # Otherwise the user's language preference defaults to $wgContLang,
+                               # but it may be better to set it to their preferred $wgContLang variant,
+                               # based on browser preferences or URL parameters.
+                               $u->setOption( 'language', $wgContLang->getPreferredVariant() );
+                       }
+                       if ( $wgContLang->hasVariants() ) {
+                               $u->setOption( 'variant', $wgContLang->getPreferredVariant() );
+                       }
+               }
+
+               $out = $this->getOutput();
+
+               # Send out an email authentication message if needed
+               if ( $wgEmailAuthentication && Sanitizer::validateEmail( $u->getEmail() ) ) {
+                       $status = $u->sendConfirmationMail();
+                       if ( $status->isGood() ) {
+                               $out->addWikiMsg( 'confirmemail_oncreate' );
+                       } else {
+                               $out->addWikiText( $status->getWikiText( 'confirmemail_sendfailed' ) );
+                       }
+               }
+
+               # Save settings (including confirmation token)
+               $u->saveSettings();
+
+               # If not logged in, assume the new account as the current one and set
+               # session cookies then show a "welcome" message or a "need cookies"
+               # message as needed
+               if ( $this->getUser()->isAnon() ) {
+                       $u->setCookies();
+                       $wgUser = $u;
+                       // This should set it for OutputPage and the Skin
+                       // which is needed or the personal links will be
+                       // wrong.
+                       $this->getContext()->setUser( $u );
+                       Hooks::run( 'AddNewAccount', [ $u, false ] );
+                       $u->addNewUserLogEntry( 'create' );
+                       if ( $this->hasSessionCookie() ) {
+                               $this->successfulCreation();
+                       } else {
+                               $this->cookieRedirectCheck( 'new' );
+                       }
+               } else {
+                       # Confirm that the account was created
+                       $out->setPageTitle( $this->msg( 'accountcreated' ) );
+                       $out->addWikiMsg( 'accountcreatedtext', $u->getName() );
+                       $out->addReturnTo( $this->getPageTitle() );
+                       Hooks::run( 'AddNewAccount', [ $u, false ] );
+                       $u->addNewUserLogEntry( 'create2', $this->mReason );
+               }
+
+               return true;
+       }
+
+       /**
+        * Make a new user account using the loaded data.
+        * @private
+        * @throws PermissionsError|ReadOnlyError
+        * @return Status
+        */
+       public function addNewAccountInternal() {
+               global $wgAuth, $wgAccountCreationThrottle, $wgEmailConfirmToEdit;
+
+               // If the user passes an invalid domain, something is fishy
+               if ( !$wgAuth->validDomain( $this->mDomain ) ) {
+                       return Status::newFatal( 'wrongpassword' );
+               }
+
+               // If we are not allowing users to login locally, we should be checking
+               // to see if the user is actually able to authenticate to the authenti-
+               // cation server before they create an account (otherwise, they can
+               // create a local account and login as any domain user). We only need
+               // to check this for domains that aren't local.
+               if ( 'local' != $this->mDomain && $this->mDomain != '' ) {
+                       if (
+                               !$wgAuth->canCreateAccounts() &&
+                               (
+                                       !$wgAuth->userExists( $this->mUsername ) ||
+                                       !$wgAuth->authenticate( $this->mUsername, $this->mPassword )
+                               )
+                       ) {
+                               return Status::newFatal( 'wrongpassword' );
+                       }
+               }
+
+               if ( wfReadOnly() ) {
+                       throw new ReadOnlyError;
+               }
+
+               # Request forgery checks.
+               $token = self::getCreateaccountToken();
+               if ( $token->wasNew() ) {
+                       return Status::newFatal( 'nocookiesfornew' );
+               }
+
+               # The user didn't pass a createaccount token
+               if ( !$this->mToken ) {
+                       return Status::newFatal( 'sessionfailure' );
+               }
+
+               # Validate the createaccount token
+               if ( !$token->match( $this->mToken ) ) {
+                       return Status::newFatal( 'sessionfailure' );
+               }
+
+               # Check permissions
+               $currentUser = $this->getUser();
+               $creationBlock = $currentUser->isBlockedFromCreateAccount();
+               if ( !$currentUser->isAllowed( 'createaccount' ) ) {
+                       throw new PermissionsError( 'createaccount' );
+               } elseif ( $creationBlock instanceof Block ) {
+                       // Throws an ErrorPageError.
+                       $this->userBlockedMessage( $creationBlock );
+
+                       // This should never be reached.
+                       return false;
+               }
+
+               # Include checks that will include GlobalBlocking (Bug 38333)
+               $permErrors = $this->getPageTitle()->getUserPermissionsErrors(
+                       'createaccount',
+                       $currentUser,
+                       true
+               );
+
+               if ( count( $permErrors ) ) {
+                       throw new PermissionsError( 'createaccount', $permErrors );
+               }
+
+               $ip = $this->getRequest()->getIP();
+               if ( $currentUser->isDnsBlacklisted( $ip, true /* check $wgProxyWhitelist */ ) ) {
+                       return Status::newFatal( 'sorbs_create_account_reason' );
+               }
+
+               # Now create a dummy user ($u) and check if it is valid
+               $u = User::newFromName( $this->mUsername, 'creatable' );
+               if ( !$u ) {
+                       return Status::newFatal( 'noname' );
+               }
+
+               $cache = ObjectCache::getLocalClusterInstance();
+               # Make sure the user does not exist already
+               $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $this->mUsername ) ) );
+               if ( !$lock ) {
+                       return Status::newFatal( 'usernameinprogress' );
+               } elseif ( $u->idForName( User::READ_LOCKING ) ) {
+                       return Status::newFatal( 'userexists' );
+               }
+
+               if ( $this->mCreateaccountMail ) {
+                       # do not force a password for account creation by email
+                       # set invalid password, it will be replaced later by a random generated password
+                       $this->mPassword = null;
+               } else {
+                       if ( $this->mPassword !== $this->mRetype ) {
+                               return Status::newFatal( 'badretype' );
+                       }
+
+                       # check for password validity, return a fatal Status if invalid
+                       $validity = $u->checkPasswordValidity( $this->mPassword, 'create' );
+                       if ( !$validity->isGood() ) {
+                               $validity->ok = false; // make sure this Status is fatal
+                               return $validity;
+                       }
+               }
+
+               # if you need a confirmed email address to edit, then obviously you
+               # need an email address.
+               if ( $wgEmailConfirmToEdit && strval( $this->mEmail ) === '' ) {
+                       return Status::newFatal( 'noemailtitle' );
+               }
+
+               if ( strval( $this->mEmail ) !== '' && !Sanitizer::validateEmail( $this->mEmail ) ) {
+                       return Status::newFatal( 'invalidemailaddress' );
+               }
+
+               # Set some additional data so the AbortNewAccount hook can be used for
+               # more than just username validation
+               $u->setEmail( $this->mEmail );
+               $u->setRealName( $this->mRealName );
+
+               $abortError = '';
+               $abortStatus = null;
+               if ( !Hooks::run( 'AbortNewAccount', [ $u, &$abortError, &$abortStatus ] ) ) {
+                       // Hook point to add extra creation throttles and blocks
+                       wfDebug( "LoginForm::addNewAccountInternal: a hook blocked creation\n" );
+                       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 Status object.
+                               $abortError = new Message( 'createaccount-hook-aborted', [ $abortError ] );
+                               $abortError->text();
+
+                               return Status::newFatal( $abortError );
+                       } else {
+                               // For MediaWiki 1.23+ and updated hooks, return the Status object
+                               // returned from the hook.
+                               return $abortStatus;
+                       }
+               }
+
+               // Hook point to check for exempt from account creation throttle
+               if ( !Hooks::run( 'ExemptFromAccountCreationThrottle', [ $ip ] ) ) {
+                       wfDebug( "LoginForm::exemptFromAccountCreationThrottle: a hook " .
+                               "allowed account creation w/o throttle\n" );
+               } else {
+                       if ( ( $wgAccountCreationThrottle && $currentUser->isPingLimitable() ) ) {
+                               $key = wfGlobalCacheKey( 'acctcreate', 'ip', $ip );
+                               $value = $cache->get( $key );
+                               if ( !$value ) {
+                                       $cache->set( $key, 0, $cache::TTL_DAY );
+                               }
+                               if ( $value >= $wgAccountCreationThrottle ) {
+                                       return Status::newFatal( 'acct_creation_throttle_hit', $wgAccountCreationThrottle );
+                               }
+                               $cache->incr( $key );
+                       }
+               }
+
+               if ( !$wgAuth->addUser( $u, $this->mPassword, $this->mEmail, $this->mRealName ) ) {
+                       return Status::newFatal( 'externaldberror' );
+               }
+
+               self::clearCreateaccountToken();
+
+               return $this->initUser( $u, false );
+       }
+
+       /**
+        * Actually add a user to the database.
+        * Give it a User object that has been initialised with a name.
+        *
+        * @param User $u
+        * @param bool $autocreate True if this is an autocreation via auth plugin
+        * @return Status Status object, with the User object in the value member on success
+        * @private
+        */
+       function initUser( $u, $autocreate ) {
+               global $wgAuth;
+
+               $status = $u->addToDatabase();
+               if ( !$status->isOK() ) {
+                       return $status;
+               }
+
+               if ( $wgAuth->allowPasswordChange() ) {
+                       $u->setPassword( $this->mPassword );
+               }
+
+               $u->setEmail( $this->mEmail );
+               $u->setRealName( $this->mRealName );
+               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' );
+               }
+
+               $u->saveSettings();
+
+               // Update user count
+               DeferredUpdates::addUpdate( new SiteStatsUpdate( 0, 0, 0, 0, 1 ) );
+
+               // Watch user's userpage and talk page
+               $u->addWatch( $u->getUserPage(), User::IGNORE_USER_RIGHTS );
+
+               return Status::newGood( $u );
+       }
+
+       /**
+        * Internally authenticate the login request.
+        *
+        * This may create a local account as a side effect if the
+        * authentication plugin allows transparent local account
+        * creation.
+        * @return int
+        */
+       public function authenticateUserData() {
+               global $wgUser, $wgAuth;
+
+               $this->load();
+
+               if ( $this->mUsername == '' ) {
+                       return self::NO_NAME;
+               }
+
+               // We require a login token to prevent login CSRF
+               // Handle part of this before incrementing the throttle so
+               // token-less login attempts don't count towards the throttle
+               // but wrong-token attempts do.
+
+               // If the user doesn't have a login token yet, set one.
+               $token = self::getLoginToken();
+               if ( $token->wasNew() ) {
+                       return self::NEED_TOKEN;
+               }
+               // If the user didn't pass a login token, tell them we need one
+               if ( !$this->mToken ) {
+                       return self::NEED_TOKEN;
+               }
+
+               $throttleCount = self::incrementLoginThrottle( $this->mUsername );
+               if ( $throttleCount ) {
+                       $this->mThrottleWait = $throttleCount['wait'];
+                       return self::THROTTLED;
+               }
+
+               // Validate the login token
+               if ( !$token->match( $this->mToken ) ) {
+                       return self::WRONG_TOKEN;
+               }
+
+               // Load the current user now, and check to see if we're logging in as
+               // the same name. This is necessary because loading the current user
+               // (say by calling getName()) calls the UserLoadFromSession hook, which
+               // potentially creates the user in the database. Until we load $wgUser,
+               // checking for user existence using User::newFromName($name)->getId() below
+               // will effectively be using stale data.
+               if ( $this->getUser()->getName() === $this->mUsername ) {
+                       wfDebug( __METHOD__ . ": already logged in as {$this->mUsername}\n" );
+
+                       return self::SUCCESS;
+               }
+
+               $u = User::newFromName( $this->mUsername );
+               if ( $u === false ) {
+                       return self::ILLEGAL;
+               }
+
+               $msg = null;
+               // Give extensions a way to indicate the username has been updated,
+               // rather than telling the user the account doesn't exist.
+               if ( !Hooks::run( 'LoginUserMigrated', [ $u, &$msg ] ) ) {
+                       $this->mAbortLoginErrorMsg = $msg;
+                       return self::USER_MIGRATED;
+               }
+
+               if ( !User::isUsableName( $u->getName() ) ) {
+                       return self::ILLEGAL;
+               }
+
+               $isAutoCreated = false;
+               if ( $u->getId() == 0 ) {
+                       $status = $this->attemptAutoCreate( $u );
+                       if ( $status !== self::SUCCESS ) {
+                               return $status;
+                       } else {
+                               $isAutoCreated = true;
+                       }
+               } else {
+                       $u->load();
+               }
+
+               // Give general extensions, such as a captcha, a chance to abort logins
+               $abort = self::ABORTED;
+               if ( !Hooks::run( 'AbortLogin', [ $u, $this->mPassword, &$abort, &$msg ] ) ) {
+                       if ( !in_array( $abort, array_keys( self::$statusCodes ), true ) ) {
+                               throw new Exception( 'Invalid status code returned from AbortLogin hook: ' . $abort );
+                       }
+                       $this->mAbortLoginErrorMsg = $msg;
+                       return $abort;
+               }
+
+               global $wgBlockDisablesLogin;
+               if ( !$u->checkPassword( $this->mPassword ) ) {
+                       if ( $u->checkTemporaryPassword( $this->mPassword ) ) {
+                               /**
+                                * The e-mailed temporary password should not be used for actu-
+                                * al logins; that's a very sloppy habit, and insecure if an
+                                * attacker has a few seconds to click "search" on someone's
+                                * open mail reader.
+                                *
+                                * Allow it to be used only to reset the password a single time
+                                * to a new value, which won't be in the user's e-mail ar-
+                                * chives.
+                                *
+                                * For backwards compatibility, we'll still recognize it at the
+                                * login form to minimize surprises for people who have been
+                                * logging in with a temporary password for some time.
+                                *
+                                * As a side-effect, we can authenticate the user's e-mail ad-
+                                * dress if it's not already done, since the temporary password
+                                * was sent via e-mail.
+                                */
+                               if ( !$u->isEmailConfirmed() && !wfReadOnly() ) {
+                                       $u->confirmEmail();
+                                       $u->saveSettings();
+                               }
+
+                               // At this point we just return an appropriate code/ indicating
+                               // that the UI should show a password reset form; bot inter-
+                               // faces etc will probably just fail cleanly here.
+                               $this->mAbortLoginErrorMsg = 'resetpass-temp-emailed';
+                               $this->mTempPasswordUsed = true;
+                               $retval = self::RESET_PASS;
+                       } else {
+                               $retval = ( $this->mPassword == '' ) ? self::EMPTY_PASS : self::WRONG_PASS;
+                       }
+               } elseif ( $wgBlockDisablesLogin && $u->isBlocked() ) {
+                       // If we've enabled it, make it so that a blocked user cannot login
+                       $retval = self::USER_BLOCKED;
+               } elseif ( $this->checkUserPasswordExpired( $u ) == 'hard' ) {
+                       // Force reset now, without logging in
+                       $retval = self::RESET_PASS;
+                       $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' );
+                       }
+                       $wgUser = $u;
+                       // This should set it for OutputPage and the Skin
+                       // which is needed or the personal links will be
+                       // wrong.
+                       $this->getContext()->setUser( $u );
+
+                       // Please reset throttle for successful logins, thanks!
+                       self::clearLoginThrottle( $this->mUsername );
+
+                       if ( $isAutoCreated ) {
+                               // Must be run after $wgUser is set, for correct new user log
+                               Hooks::run( 'AuthPluginAutoCreate', [ $u ] );
+                       }
+
+                       $retval = self::SUCCESS;
+               }
+               Hooks::run( 'LoginAuthenticateAudit', [ $u, $this->mPassword, $retval ] );
+
+               return $retval;
+       }
+
+       /**
+        * Increment the login attempt throttle hit count for the (username,current IP)
+        * tuple unless the throttle was already reached.
+        *
+        * @since 1.27 Return value changed.
+        * @param string $username The user name
+        * @return bool|array false if below limit or an array if above limit
+        *   Array contains keys wait, count, and throttleIndex
+        */
+       public static function incrementLoginThrottle( $username ) {
+               global $wgPasswordAttemptThrottle, $wgRequest;
+               $username = User::getCanonicalName( $username, 'usable' ) ?: $username;
+
+               $throttleCount = 0;
+               if ( is_array( $wgPasswordAttemptThrottle ) ) {
+                       $throttleConfig = $wgPasswordAttemptThrottle;
+                       if ( isset( $wgPasswordAttemptThrottle['count'] ) ) {
+                               // old style. Convert for backwards compat.
+                               $throttleConfig = [ $wgPasswordAttemptThrottle ];
+                       }
+                       foreach ( $throttleConfig as $index => $specificThrottle ) {
+                               if ( isset( $specificThrottle['allIPs'] ) ) {
+                                       $ip = 'All';
+                               } else {
+                                       $ip = $wgRequest->getIP();
+                               }
+                               $throttleKey = wfGlobalCacheKey( 'password-throttle',
+                                       $index, $ip, md5( $username )
+                               );
+                               $count = $specificThrottle['count'];
+                               $period = $specificThrottle['seconds'];
+
+                               $cache = ObjectCache::getLocalClusterInstance();
+                               $throttleCount = $cache->get( $throttleKey );
+                               if ( !$throttleCount ) {
+                                       $cache->add( $throttleKey, 1, $period ); // start counter
+                               } elseif ( $throttleCount < $count ) {
+                                       $cache->incr( $throttleKey );
+                               } elseif ( $throttleCount >= $count ) {
+                                       $logMsg = 'Login attempt rejected because logins to '
+                                               . '{acct} from IP {ip} have been throttled for '
+                                               . '{period} seconds due to {count} failed attempts';
+                                       // If we are hitting a throttle for >= 50 attempts,
+                                       // it is much more likely to be an attack than someone
+                                       // simply forgetting their password, so log it at a
+                                       // higher level.
+                                       $level = $count >= 50 ? 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.
+                                       LoggerFactory::getInstance( 'password-throttle' )->log(
+                                               $level,
+                                               $logMsg,
+                                               [
+                                                       'ip' => $ip,
+                                                       'period' => $period,
+                                                       'acct' => $username,
+                                                       'count' => $count,
+                                                       'throttleIdentifier' => $index,
+                                                       'method' => __METHOD__
+                                               ]
+                                       );
+
+                                       return [
+                                               'throttleIndex' => $index,
+                                               'wait' => $period,
+                                               'count' => $count
+                                       ];
+                               }
+                       }
+               }
+               return false;
+       }
+
+       /**
+        * Increment the login attempt throttle hit count for the (username,current IP)
+        * tuple unless the throttle was already reached.
+        *
+        * @deprecated Use LoginForm::incrementLoginThrottle instead
+        * @param string $username The user name
+        * @return bool|int true if above throttle, or 0 (prior to 1.27, returned current count)
+        */
+       public static function incLoginThrottle( $username ) {
+               wfDeprecated( __METHOD__, "1.27" );
+               $res = self::incrementLoginThrottle( $username );
+               return is_array( $res ) ? true : 0;
+       }
+
+       /**
+        * Clear the login attempt throttle hit count for the (username,current IP) tuple.
+        * @param string $username The user name
+        * @return void
+        */
+       public static function clearLoginThrottle( $username ) {
+               global $wgRequest, $wgPasswordAttemptThrottle;
+               $username = User::getCanonicalName( $username, 'usable' ) ?: $username;
+
+               if ( is_array( $wgPasswordAttemptThrottle ) ) {
+                       $throttleConfig = $wgPasswordAttemptThrottle;
+                       if ( isset( $wgPasswordAttemptThrottle['count'] ) ) {
+                               // old style. Convert for backwards compat.
+                               $throttleConfig = [ $wgPasswordAttemptThrottle ];
+                       }
+                       foreach ( $throttleConfig as $index => $specificThrottle ) {
+                               if ( isset( $specificThrottle['allIPs'] ) ) {
+                                       $ip = 'All';
+                               } else {
+                                       $ip = $wgRequest->getIP();
+                               }
+                               $throttleKey = wfGlobalCacheKey( 'password-throttle', $index,
+                                       $ip, md5( $username )
+                               );
+                               ObjectCache::getLocalClusterInstance()->delete( $throttleKey );
+                       }
+               }
+       }
+
+       /**
+        * Attempt to automatically create a user on login. Only succeeds if there
+        * is an external authentication method which allows it.
+        *
+        * @param User $user
+        *
+        * @return int Status code
+        */
+       function attemptAutoCreate( $user ) {
+               global $wgAuth;
+
+               if ( $this->getUser()->isBlockedFromCreateAccount() ) {
+                       wfDebug( __METHOD__ . ": user is blocked from account creation\n" );
+
+                       return self::CREATE_BLOCKED;
+               }
+
+               if ( !$wgAuth->autoCreate() ) {
+                       return self::NOT_EXISTS;
+               }
+
+               if ( !$wgAuth->userExists( $user->getName() ) ) {
+                       wfDebug( __METHOD__ . ": user does not exist\n" );
+
+                       return self::NOT_EXISTS;
+               }
+
+               if ( !$wgAuth->authenticate( $user->getName(), $this->mPassword ) ) {
+                       wfDebug( __METHOD__ . ": \$wgAuth->authenticate() returned false, aborting\n" );
+
+                       return self::WRONG_PLUGIN_PASS;
+               }
+
+               $abortError = '';
+               if ( !Hooks::run( 'AbortAutoAccount', [ $user, &$abortError ] ) ) {
+                       // Hook point to add extra creation throttles and blocks
+                       wfDebug( "LoginForm::attemptAutoCreate: a hook blocked creation: $abortError\n" );
+                       $this->mAbortLoginErrorMsg = $abortError;
+
+                       return self::ABORTED;
+               }
+
+               wfDebug( __METHOD__ . ": creating account\n" );
+               $status = $this->initUser( $user, true );
+
+               if ( !$status->isOK() ) {
+                       $errors = $status->getErrorsByType( 'error' );
+                       $this->mAbortLoginErrorMsg = $errors[0]['message'];
+
+                       return self::ABORTED;
+               }
+
+               return self::SUCCESS;
+       }
+
+       function processLogin() {
+               global $wgLang, $wgSecureLogin, $wgInvalidPasswordReset;
+
+               $authRes = $this->authenticateUserData();
+               switch ( $authRes ) {
+                       case self::SUCCESS:
+                               # We've verified now, update the real record
+                               $user = $this->getUser();
+                               $user->touch();
+
+                               if ( $user->requiresHTTPS() ) {
+                                       $this->mStickHTTPS = true;
+                               }
+
+                               if ( $wgSecureLogin && !$this->mStickHTTPS ) {
+                                       $user->setCookies( $this->mRequest, false, $this->mRemember );
+                               } else {
+                                       $user->setCookies( $this->mRequest, null, $this->mRemember );
+                               }
+                               self::clearLoginToken();
+
+                               // Reset the throttle
+                               self::clearLoginThrottle( $this->mUsername );
+
+                               $request = $this->getRequest();
+                               if ( $this->hasSessionCookie() || $this->mSkipCookieCheck ) {
+                                       /* Replace the language object to provide user interface in
+                                        * correct language immediately on this first page load.
+                                        */
+                                       $code = $request->getVal( 'uselang', $user->getOption( 'language' ) );
+                                       $userLang = Language::factory( $code );
+                                       $wgLang = $userLang;
+                                       RequestContext::getMain()->setLanguage( $userLang );
+                                       $this->getContext()->setLanguage( $userLang );
+                                       // Reset SessionID on Successful login (bug 40995)
+                                       $this->renewSessionId();
+                                       if ( $this->checkUserPasswordExpired( $this->getUser() ) == 'soft' ) {
+                                               $this->resetLoginForm( $this->msg( 'resetpass-expired-soft' ) );
+                                       } elseif ( $wgInvalidPasswordReset
+                                               && !$user->isValidPassword( $this->mPassword )
+                                       ) {
+                                               $status = $user->checkPasswordValidity(
+                                                       $this->mPassword,
+                                                       'login'
+                                               );
+                                               $this->resetLoginForm(
+                                                       $status->getMessage( 'resetpass-validity-soft' )
+                                               );
+                                       } else {
+                                               $this->successfulLogin();
+                                       }
+                               } else {
+                                       $this->cookieRedirectCheck( 'login' );
+                               }
+                               break;
+
+                       case self::NEED_TOKEN:
+                               $error = $this->mAbortLoginErrorMsg ?: 'nocookiesforlogin';
+                               $this->mainLoginForm( $this->msg( $error )->parse() );
+                               break;
+                       case self::WRONG_TOKEN:
+                               $error = $this->mAbortLoginErrorMsg ?: 'sessionfailure';
+                               $this->mainLoginForm( $this->msg( $error )->text() );
+                               break;
+                       case self::NO_NAME:
+                       case self::ILLEGAL:
+                               $error = $this->mAbortLoginErrorMsg ?: 'noname';
+                               $this->mainLoginForm( $this->msg( $error )->text() );
+                               break;
+                       case self::WRONG_PLUGIN_PASS:
+                               $error = $this->mAbortLoginErrorMsg ?: 'wrongpassword';
+                               $this->mainLoginForm( $this->msg( $error )->text() );
+                               break;
+                       case self::NOT_EXISTS:
+                               if ( $this->getUser()->isAllowed( 'createaccount' ) ) {
+                                       $error = $this->mAbortLoginErrorMsg ?: 'nosuchuser';
+                                       $this->mainLoginForm( $this->msg( $error,
+                                               wfEscapeWikiText( $this->mUsername ) )->parse() );
+                               } else {
+                                       $error = $this->mAbortLoginErrorMsg ?: 'nosuchusershort';
+                                       $this->mainLoginForm( $this->msg( $error,
+                                               wfEscapeWikiText( $this->mUsername ) )->text() );
+                               }
+                               break;
+                       case self::WRONG_PASS:
+                               $error = $this->mAbortLoginErrorMsg ?: 'wrongpassword';
+                               $this->mainLoginForm( $this->msg( $error )->text() );
+                               break;
+                       case self::EMPTY_PASS:
+                               $error = $this->mAbortLoginErrorMsg ?: 'wrongpasswordempty';
+                               $this->mainLoginForm( $this->msg( $error )->text() );
+                               break;
+                       case self::RESET_PASS:
+                               $error = $this->mAbortLoginErrorMsg ?: 'resetpass_announce';
+                               $this->resetLoginForm( $this->msg( $error ) );
+                               break;
+                       case self::CREATE_BLOCKED:
+                               $this->userBlockedMessage( $this->getUser()->isBlockedFromCreateAccount() );
+                               break;
+                       case self::THROTTLED:
+                               $error = $this->mAbortLoginErrorMsg ?: 'login-throttled';
+                               $this->mainLoginForm( $this->msg( $error )
+                                       ->durationParams( $this->mThrottleWait )->text()
+                               );
+                               break;
+                       case self::USER_BLOCKED:
+                               $error = $this->mAbortLoginErrorMsg ?: 'login-userblocked';
+                               $this->mainLoginForm( $this->msg( $error, $this->mUsername )->escaped() );
+                               break;
+                       case self::ABORTED:
+                               $error = $this->mAbortLoginErrorMsg ?: 'login-abort-generic';
+                               $this->mainLoginForm( $this->msg( $error,
+                                               wfEscapeWikiText( $this->mUsername ) )->text() );
+                               break;
+                       case self::USER_MIGRATED:
+                               $error = $this->mAbortLoginErrorMsg ?: 'login-migrated-generic';
+                               $params = [];
+                               if ( is_array( $error ) ) {
+                                       $error = array_shift( $this->mAbortLoginErrorMsg );
+                                       $params = $this->mAbortLoginErrorMsg;
+                               }
+                               $this->mainLoginForm( $this->msg( $error, $params )->text() );
+                               break;
+                       default:
+                               throw new MWException( 'Unhandled case value' );
+               }
+
+               LoggerFactory::getInstance( 'authmanager' )->info( 'Login attempt', [
+                       'event' => 'login',
+                       'successful' => $authRes === self::SUCCESS,
+                       'status' => LoginForm::$statusCodes[$authRes],
+               ] );
+       }
+
+       /**
+        * Show the Special:ChangePassword form, with custom message
+        * @param Message $msg
+        */
+       protected function resetLoginForm( Message $msg ) {
+               // Allow hooks to explain this password reset in more detail
+               Hooks::run( 'LoginPasswordResetMessage', [ &$msg, $this->mUsername ] );
+               $reset = new SpecialChangePassword();
+               $derivative = new DerivativeContext( $this->getContext() );
+               $derivative->setTitle( $reset->getPageTitle() );
+               $reset->setContext( $derivative );
+               if ( !$this->mTempPasswordUsed ) {
+                       $reset->setOldPasswordMessage( 'oldpassword' );
+               }
+               $reset->setChangeMessage( $msg );
+               $reset->execute( null );
+       }
+
+       /**
+        * @param User $u
+        * @param bool $throttle
+        * @param string $emailTitle Message name of email title
+        * @param string $emailText Message name of email text
+        * @return Status
+        */
+       function mailPasswordInternal( $u, $throttle = true, $emailTitle = 'passwordremindertitle',
+               $emailText = 'passwordremindertext'
+       ) {
+               global $wgNewPasswordExpiry, $wgMinimalPasswordLength;
+
+               if ( $u->getEmail() == '' ) {
+                       return Status::newFatal( 'noemail', $u->getName() );
+               }
+               $ip = $this->getRequest()->getIP();
+               if ( !$ip ) {
+                       return Status::newFatal( 'badipaddress' );
+               }
+
+               $currentUser = $this->getUser();
+               Hooks::run( 'User::mailPasswordInternal', [ &$currentUser, &$ip, &$u ] );
+
+               $np = PasswordFactory::generateRandomPasswordString( $wgMinimalPasswordLength );
+               $u->setNewpassword( $np, $throttle );
+               $u->saveSettings();
+               $userLanguage = $u->getOption( 'language' );
+
+               $mainPage = Title::newMainPage();
+               $mainPageUrl = $mainPage->getCanonicalURL();
+
+               $m = $this->msg( $emailText, $ip, $u->getName(), $np, '<' . $mainPageUrl . '>',
+                       round( $wgNewPasswordExpiry / 86400 ) )->inLanguage( $userLanguage )->text();
+               $result = $u->sendMail( $this->msg( $emailTitle )->inLanguage( $userLanguage )->text(), $m );
+
+               return $result;
+       }
+
+       /**
+        * Run any hooks registered for logins, then HTTP redirect to
+        * $this->mReturnTo (or Main Page if that's undefined).  Formerly we had a
+        * nice message here, but that's really not as useful as just being sent to
+        * wherever you logged in from.  It should be clear that the action was
+        * successful, given the lack of error messages plus the appearance of your
+        * name in the upper right.
+        *
+        * @private
+        */
+       function successfulLogin() {
+               # Run any hooks; display injected HTML if any, else redirect
+               $currentUser = $this->getUser();
+               $injected_html = '';
+               Hooks::run( 'UserLoginComplete', [ &$currentUser, &$injected_html ] );
+
+               if ( $injected_html !== '' ) {
+                       $this->displaySuccessfulAction( 'success', $this->msg( 'loginsuccesstitle' ),
+                               'loginsuccess', $injected_html );
+               } else {
+                       $this->executeReturnTo( 'successredirect' );
+               }
+       }
+
+       /**
+        * Run any hooks registered for logins, then display a message welcoming
+        * the user.
+        *
+        * @private
+        */
+       function successfulCreation() {
+               # Run any hooks; display injected HTML
+               $currentUser = $this->getUser();
+               $injected_html = '';
+               $welcome_creation_msg = 'welcomecreation-msg';
+
+               Hooks::run( 'UserLoginComplete', [ &$currentUser, &$injected_html ] );
+
+               /**
+                * Let any extensions change what message is shown.
+                * @see https://www.mediawiki.org/wiki/Manual:Hooks/BeforeWelcomeCreation
+                * @since 1.18
+                */
+               Hooks::run( 'BeforeWelcomeCreation', [ &$welcome_creation_msg, &$injected_html ] );
+
+               $this->displaySuccessfulAction(
+                       'signup',
+                       $this->msg( 'welcomeuser', $this->getUser()->getName() ),
+                       $welcome_creation_msg, $injected_html
+               );
+       }
+
+       /**
+        * Display a "successful action" page.
+        *
+        * @param string $type Condition of return to; see `executeReturnTo`
+        * @param string|Message $title Page's title
+        * @param string $msgname
+        * @param string $injected_html
+        */
+       private function displaySuccessfulAction( $type, $title, $msgname, $injected_html ) {
+               $out = $this->getOutput();
+               $out->setPageTitle( $title );
+               if ( $msgname ) {
+                       $out->addWikiMsg( $msgname, wfEscapeWikiText( $this->getUser()->getName() ) );
+               }
+
+               $out->addHTML( $injected_html );
+
+               $this->executeReturnTo( $type );
+       }
+
+       /**
+        * Output a message that informs the user that they cannot create an account because
+        * there is a block on them or their IP which prevents account creation.  Note that
+        * User::isBlockedFromCreateAccount(), which gets this block, ignores the 'hardblock'
+        * setting on blocks (bug 13611).
+        * @param Block $block The block causing this error
+        * @throws ErrorPageError
+        */
+       function userBlockedMessage( Block $block ) {
+               # Let's be nice about this, it's likely that this feature will be used
+               # for blocking large numbers of innocent people, e.g. range blocks on
+               # schools. Don't blame it on the user. There's a small chance that it
+               # really is the user's fault, i.e. the username is blocked and they
+               # haven't bothered to log out before trying to create an account to
+               # evade it, but we'll leave that to their guilty conscience to figure
+               # out.
+               $errorParams = [
+                       $block->getTarget(),
+                       $block->mReason ? $block->mReason : $this->msg( 'blockednoreason' )->text(),
+                       $block->getByName()
+               ];
+
+               if ( $block->getType() === Block::TYPE_RANGE ) {
+                       $errorMessage = 'cantcreateaccount-range-text';
+                       $errorParams[] = $this->getRequest()->getIP();
+               } else {
+                       $errorMessage = 'cantcreateaccount-text';
+               }
+
+               throw new ErrorPageError(
+                       'cantcreateaccounttitle',
+                       $errorMessage,
+                       $errorParams
+               );
+       }
+
+       /**
+        * Add a "return to" link or redirect to it.
+        * Extensions can use this to reuse the "return to" logic after
+        * inject steps (such as redirection) into the login process.
+        *
+        * @param string $type One of the following:
+        *    - error: display a return to link ignoring $wgRedirectOnLogin
+        *    - signup: display a return to link using $wgRedirectOnLogin if needed
+        *    - success: display a return to link using $wgRedirectOnLogin if needed
+        *    - successredirect: send an HTTP redirect using $wgRedirectOnLogin if needed
+        * @param string $returnTo
+        * @param array|string $returnToQuery
+        * @param bool $stickHTTPs Keep redirect link on HTTPs
+        * @since 1.22
+        */
+       public function showReturnToPage(
+               $type, $returnTo = '', $returnToQuery = '', $stickHTTPs = false
+       ) {
+               $this->mReturnTo = $returnTo;
+               $this->mReturnToQuery = $returnToQuery;
+               $this->mStickHTTPS = $stickHTTPs;
+               $this->executeReturnTo( $type );
+       }
+
+       /**
+        * Add a "return to" link or redirect to it.
+        *
+        * @param string $type One of the following:
+        *    - error: display a return to link ignoring $wgRedirectOnLogin
+        *    - signup: display a return to link using $wgRedirectOnLogin if needed
+        *    - success: display a return to link using $wgRedirectOnLogin if needed
+        *    - successredirect: send an HTTP redirect using $wgRedirectOnLogin if needed
+        */
+       private function executeReturnTo( $type ) {
+               global $wgRedirectOnLogin, $wgSecureLogin;
+
+               if ( $type != 'error' && $wgRedirectOnLogin !== null ) {
+                       $returnTo = $wgRedirectOnLogin;
+                       $returnToQuery = [];
+               } else {
+                       $returnTo = $this->mReturnTo;
+                       $returnToQuery = wfCgiToArray( $this->mReturnToQuery );
+               }
+
+               // Allow modification of redirect behavior
+               Hooks::run( 'PostLoginRedirect', [ &$returnTo, &$returnToQuery, &$type ] );
+
+               $returnToTitle = Title::newFromText( $returnTo );
+               if ( !$returnToTitle ) {
+                       $returnToTitle = Title::newMainPage();
+               }
+
+               if ( $wgSecureLogin && !$this->mStickHTTPS ) {
+                       $options = [ 'http' ];
+                       $proto = PROTO_HTTP;
+               } elseif ( $wgSecureLogin ) {
+                       $options = [ 'https' ];
+                       $proto = PROTO_HTTPS;
+               } else {
+                       $options = [];
+                       $proto = PROTO_RELATIVE;
+               }
+
+               if ( $type == 'successredirect' ) {
+                       $redirectUrl = $returnToTitle->getFullURL( $returnToQuery, false, $proto );
+                       $this->getOutput()->redirect( $redirectUrl );
+               } else {
+                       $this->getOutput()->addReturnTo( $returnToTitle, $returnToQuery, null, $options );
+               }
+       }
+
+       /**
+        * @param string $msg
+        * @param string $msgtype
+        * @throws ErrorPageError
+        * @throws Exception
+        * @throws FatalError
+        * @throws MWException
+        * @throws PermissionsError
+        * @throws ReadOnlyError
+        * @private
+        */
+       function mainLoginForm( $msg, $msgtype = 'error' ) {
+               global $wgEnableEmail, $wgEnableUserEmail;
+               global $wgHiddenPrefs, $wgLoginLanguageSelector;
+               global $wgAuth, $wgEmailConfirmToEdit;
+               global $wgSecureLogin, $wgPasswordResetRoutes;
+               global $wgExtendedLoginCookieExpiration, $wgCookieExpiration;
+
+               $titleObj = $this->getPageTitle();
+               $user = $this->getUser();
+               $out = $this->getOutput();
+
+               if ( $this->mType == 'signup' ) {
+                       // Block signup here if in readonly. Keeps user from
+                       // going through the process (filling out data, etc)
+                       // and being informed later.
+                       $permErrors = $titleObj->getUserPermissionsErrors( 'createaccount', $user, true );
+                       if ( count( $permErrors ) ) {
+                               throw new PermissionsError( 'createaccount', $permErrors );
+                       } elseif ( $user->isBlockedFromCreateAccount() ) {
+                               $this->userBlockedMessage( $user->isBlockedFromCreateAccount() );
+
+                               return;
+                       } elseif ( wfReadOnly() ) {
+                               throw new ReadOnlyError;
+                       }
+               }
+
+               // Pre-fill username (if not creating an account, bug 44775).
+               if ( $this->mUsername == '' && $this->mType != 'signup' ) {
+                       if ( $user->isLoggedIn() ) {
+                               $this->mUsername = $user->getName();
+                       } else {
+                               $this->mUsername = $this->getRequest()->getSession()->suggestLoginUsername();
+                       }
+               }
+
+               // Generic styles and scripts for both login and signup form
+               $out->addModuleStyles( [
+                       'mediawiki.ui',
+                       'mediawiki.ui.button',
+                       'mediawiki.ui.checkbox',
+                       'mediawiki.ui.input',
+                       'mediawiki.special.userlogin.common.styles'
+               ] );
+
+               if ( $this->mType == 'signup' ) {
+                       // Additional styles and scripts for signup form
+                       $out->addModules( [
+                               'mediawiki.special.userlogin.signup.js'
+                       ] );
+                       $out->addModuleStyles( [
+                               'mediawiki.special.userlogin.signup.styles'
+                       ] );
+
+                       $template = new UsercreateTemplate( $this->getConfig() );
+
+                       // Must match number of benefits defined in messages
+                       $template->set( 'benefitCount', 3 );
+
+                       $q = 'action=submitlogin&type=signup';
+                       $linkq = 'type=login';
+               } else {
+                       // Additional styles for login form
+                       $out->addModuleStyles( [
+                               'mediawiki.special.userlogin.login.styles'
+                       ] );
+
+                       $template = new UserloginTemplate( $this->getConfig() );
+
+                       $q = 'action=submitlogin&type=login';
+                       $linkq = 'type=signup';
+               }
+
+               if ( $this->mReturnTo !== '' ) {
+                       $returnto = '&returnto=' . wfUrlencode( $this->mReturnTo );
+                       if ( $this->mReturnToQuery !== '' ) {
+                               $returnto .= '&returntoquery=' .
+                                       wfUrlencode( $this->mReturnToQuery );
+                       }
+                       $q .= $returnto;
+                       $linkq .= $returnto;
+               }
+
+               # Don't show a "create account" link if the user can't.
+               if ( $this->showCreateOrLoginLink( $user ) ) {
+                       # Pass any language selection on to the mode switch link
+                       if ( $wgLoginLanguageSelector && $this->mLanguage ) {
+                               $linkq .= '&uselang=' . $this->mLanguage;
+                       }
+                       // Supply URL, login template creates the button.
+                       $template->set( 'createOrLoginHref', $titleObj->getLocalURL( $linkq ) );
+               } else {
+                       $template->set( 'link', '' );
+               }
+
+               $resetLink = $this->mType == 'signup'
+                       ? null
+                       : is_array( $wgPasswordResetRoutes ) && in_array( true, array_values( $wgPasswordResetRoutes ) );
+
+               $template->set( 'header', '' );
+               $template->set( 'formheader', '' );
+               $template->set( 'skin', $this->getSkin() );
+               $template->set( 'name', $this->mUsername );
+               $template->set( 'password', $this->mPassword );
+               $template->set( 'retype', $this->mRetype );
+               $template->set( 'createemailset', $this->mCreateaccountMail );
+               $template->set( 'email', $this->mEmail );
+               $template->set( 'realname', $this->mRealName );
+               $template->set( 'domain', $this->mDomain );
+               $template->set( 'reason', $this->mReason );
+
+               $template->set( 'action', $titleObj->getLocalURL( $q ) );
+               $template->set( 'message', $msg );
+               $template->set( 'messagetype', $msgtype );
+               $template->set( 'createemail', $wgEnableEmail && $user->isLoggedIn() );
+               $template->set( 'userealname', !in_array( 'realname', $wgHiddenPrefs ) );
+               $template->set( 'useemail', $wgEnableEmail );
+               $template->set( 'emailrequired', $wgEmailConfirmToEdit );
+               $template->set( 'emailothers', $wgEnableUserEmail );
+               $template->set( 'canreset', $wgAuth->allowPasswordChange() );
+               $template->set( 'resetlink', $resetLink );
+               $template->set( 'canremember', $wgExtendedLoginCookieExpiration === null ?
+                       ( $wgCookieExpiration > 0 ) :
+                       ( $wgExtendedLoginCookieExpiration > 0 ) );
+               $template->set( 'usereason', $user->isLoggedIn() );
+               $template->set( 'remember', $this->mRemember );
+               $template->set( 'cansecurelogin', ( $wgSecureLogin === true ) );
+               $template->set( 'stickhttps', (int)$this->mStickHTTPS );
+               $template->set( 'loggedin', $user->isLoggedIn() );
+               $template->set( 'loggedinuser', $user->getName() );
+
+               if ( $this->mType == 'signup' ) {
+                       $template->set( 'token', self::getCreateaccountToken()->toString() );
+               } else {
+                       $template->set( 'token', self::getLoginToken()->toString() );
+               }
+
+               # Prepare language selection links as needed
+               if ( $wgLoginLanguageSelector ) {
+                       $template->set( 'languages', $this->makeLanguageSelector() );
+                       if ( $this->mLanguage ) {
+                               $template->set( 'uselang', $this->mLanguage );
+                       }
+               }
+
+               $template->set( 'secureLoginUrl', $this->mSecureLoginUrl );
+               // Use signupend-https for HTTPS requests if it's not blank, signupend otherwise
+               $usingHTTPS = $this->mRequest->getProtocol() == 'https';
+               $signupendHTTPS = $this->msg( 'signupend-https' );
+               if ( $usingHTTPS && !$signupendHTTPS->isBlank() ) {
+                       $template->set( 'signupend', $signupendHTTPS->parse() );
+               } else {
+                       $template->set( 'signupend', $this->msg( 'signupend' )->parse() );
+               }
+
+               // If using HTTPS coming from HTTP, then the 'fromhttp' parameter must be preserved
+               if ( $usingHTTPS ) {
+                       $template->set( 'fromhttp', $this->mFromHTTP );
+               }
+
+               // Give authentication and captcha plugins a chance to modify the form
+               $wgAuth->modifyUITemplate( $template, $this->mType );
+               if ( $this->mType == 'signup' ) {
+                       Hooks::run( 'UserCreateForm', [ &$template ] );
+               } else {
+                       Hooks::run( 'UserLoginForm', [ &$template ] );
+               }
+
+               $out->disallowUserJs(); // just in case...
+               $out->addTemplate( $template );
+       }
+
+       /**
+        * Whether the login/create account form should display a link to the
+        * other form (in addition to whatever the skin provides).
+        *
+        * @param User $user
+        * @return bool
+        */
+       private function showCreateOrLoginLink( &$user ) {
+               if ( $this->mType == 'signup' ) {
+                       return true;
+               } elseif ( $user->isAllowed( 'createaccount' ) ) {
+                       return true;
+               } else {
+                       return false;
+               }
+       }
+
+       /**
+        * Check if a session cookie is present.
+        *
+        * This will not pick up a cookie set during _this_ request, but is meant
+        * to ensure that the client is returning the cookie which was set on a
+        * previous pass through the system.
+        *
+        * @private
+        * @return bool
+        */
+       function hasSessionCookie() {
+               global $wgDisableCookieCheck, $wgInitialSessionId;
+
+               return $wgDisableCookieCheck || (
+                       $wgInitialSessionId &&
+                       $this->getRequest()->getSession()->getId() === (string)$wgInitialSessionId
+               );
+       }
+
+       /**
+        * Get the login token from the current session
+        * @since 1.27 returns a MediaWiki\Session\Token instead of a string
+        * @return MediaWiki\Session\Token
+        */
+       public static function getLoginToken() {
+               global $wgRequest;
+               return $wgRequest->getSession()->getToken( '', 'login' );
+       }
+
+       /**
+        * Formerly randomly generated a login token that would be returned by
+        * $this->getLoginToken().
+        *
+        * Since 1.27, this is a no-op. The token is generated as necessary by
+        * $this->getLoginToken().
+        *
+        * @deprecated since 1.27
+        */
+       public static function setLoginToken() {
+               wfDeprecated( __METHOD__, '1.27' );
+       }
+
+       /**
+        * Remove any login token attached to the current session
+        */
+       public static function clearLoginToken() {
+               global $wgRequest;
+               $wgRequest->getSession()->resetToken( 'login' );
+       }
+
+       /**
+        * Get the createaccount token from the current session
+        * @since 1.27 returns a MediaWiki\Session\Token instead of a string
+        * @return MediaWiki\Session\Token
+        */
+       public static function getCreateaccountToken() {
+               global $wgRequest;
+               return $wgRequest->getSession()->getToken( '', 'createaccount' );
+       }
+
+       /**
+        * Formerly randomly generated a createaccount token that would be returned
+        * by $this->getCreateaccountToken().
+        *
+        * Since 1.27, this is a no-op. The token is generated as necessary by
+        * $this->getCreateaccountToken().
+        *
+        * @deprecated since 1.27
+        */
+       public static function setCreateaccountToken() {
+               wfDeprecated( __METHOD__, '1.27' );
+       }
+
+       /**
+        * Remove any createaccount token attached to the current session
+        */
+       public static function clearCreateaccountToken() {
+               global $wgRequest;
+               $wgRequest->getSession()->resetToken( 'createaccount' );
+       }
+
+       /**
+        * Renew the user's session id, using strong entropy
+        */
+       private function renewSessionId() {
+               global $wgSecureLogin, $wgCookieSecure;
+               if ( $wgSecureLogin && !$this->mStickHTTPS ) {
+                       $wgCookieSecure = false;
+               }
+
+               SessionManager::getGlobalSession()->resetId();
+       }
+
+       /**
+        * @param string $type
+        * @private
+        */
+       function cookieRedirectCheck( $type ) {
+               $titleObj = SpecialPage::getTitleFor( 'Userlogin' );
+               $query = [ 'wpCookieCheck' => $type ];
+               if ( $this->mReturnTo !== '' ) {
+                       $query['returnto'] = $this->mReturnTo;
+                       $query['returntoquery'] = $this->mReturnToQuery;
+               }
+               $check = $titleObj->getFullURL( $query );
+
+               $this->getOutput()->redirect( $check );
+       }
+
+       /**
+        * @param string $type
+        * @private
+        */
+       function onCookieRedirectCheck( $type ) {
+               if ( !$this->hasSessionCookie() ) {
+                       if ( $type == 'new' ) {
+                               $this->mainLoginForm( $this->msg( 'nocookiesnew' )->parse() );
+                       } elseif ( $type == 'login' ) {
+                               $this->mainLoginForm( $this->msg( 'nocookieslogin' )->parse() );
+                       } else {
+                               # shouldn't happen
+                               $this->mainLoginForm( $this->msg( 'error' )->text() );
+                       }
+               } else {
+                       $this->successfulLogin();
+               }
+       }
+
+       /**
+        * Produce a bar of links which allow the user to select another language
+        * during login/registration but retain "returnto"
+        *
+        * @return string
+        */
+       function makeLanguageSelector() {
+               $msg = $this->msg( 'loginlanguagelinks' )->inContentLanguage();
+               if ( $msg->isBlank() ) {
+                       return '';
+               }
+               $langs = explode( "\n", $msg->text() );
+               $links = [];
+               foreach ( $langs as $lang ) {
+                       $lang = trim( $lang, '* ' );
+                       $parts = explode( '|', $lang );
+                       if ( count( $parts ) >= 2 ) {
+                               $links[] = $this->makeLanguageSelectorLink( $parts[0], trim( $parts[1] ) );
+                       }
+               }
+
+               return count( $links ) > 0 ? $this->msg( 'loginlanguagelabel' )->rawParams(
+                       $this->getLanguage()->pipeList( $links ) )->escaped() : '';
+       }
+
+       /**
+        * Create a language selector link for a particular language
+        * Links back to this page preserving type and returnto
+        *
+        * @param string $text Link text
+        * @param string $lang Language code
+        * @return string
+        */
+       function makeLanguageSelectorLink( $text, $lang ) {
+               if ( $this->getLanguage()->getCode() == $lang ) {
+                       // no link for currently used language
+                       return htmlspecialchars( $text );
+               }
+               $query = [ 'uselang' => $lang ];
+               if ( $this->mType == 'signup' ) {
+                       $query['type'] = 'signup';
+               }
+               if ( $this->mReturnTo !== '' ) {
+                       $query['returnto'] = $this->mReturnTo;
+                       $query['returntoquery'] = $this->mReturnToQuery;
+               }
+
+               $attr = [];
+               $targetLanguage = Language::factory( $lang );
+               $attr['lang'] = $attr['hreflang'] = $targetLanguage->getHtmlCode();
+
+               return Linker::linkKnown(
+                       $this->getPageTitle(),
+                       htmlspecialchars( $text ),
+                       $attr,
+                       $query
+               );
+       }
+
+       protected function getGroupName() {
+               return 'login';
+       }
+
+       /**
+        * Private function to check password expiration, until AuthManager comes
+        * along to handle that.
+        * @param User $user
+        * @return string|bool
+        */
+       private function checkUserPasswordExpired( User $user ) {
+               global $wgPasswordExpireGrace;
+               $dbr = wfGetDB( DB_SLAVE );
+               $ts = $dbr->selectField( 'user', 'user_password_expires', [ 'user_id' => $user->getId() ] );
+
+               $expired = false;
+               $now = wfTimestamp();
+               $expUnix = wfTimestamp( TS_UNIX, $ts );
+               if ( $ts !== null && $expUnix < $now ) {
+                       $expired = ( $expUnix + $wgPasswordExpireGrace < $now ) ? 'hard' : 'soft';
+               }
+               return $expired;
+       }
+
+       protected function getSubpagesForPrefixSearch() {
+               return [ 'signup' ];
+       }
+}
diff --git a/includes/specials/pre-authmanager/SpecialUserlogout.php b/includes/specials/pre-authmanager/SpecialUserlogout.php
new file mode 100644 (file)
index 0000000..6d6a714
--- /dev/null
@@ -0,0 +1,84 @@
+<?php
+/**
+ * Implements Special:Userlogout
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Implements Special:Userlogout
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialUserlogoutPreAuthManager extends UnlistedSpecialPage {
+       function __construct() {
+               parent::__construct( 'Userlogout' );
+       }
+
+       public function doesWrites() {
+               return true;
+       }
+
+       function execute( $par ) {
+               /**
+                * Some satellite ISPs use broken precaching schemes that log people out straight after
+                * they're logged in (bug 17790). Luckily, there's a way to detect such requests.
+                */
+               if ( isset( $_SERVER['REQUEST_URI'] ) && strpos( $_SERVER['REQUEST_URI'], '&amp;' ) !== false ) {
+                       wfDebug( "Special:Userlogout request {$_SERVER['REQUEST_URI']} looks suspicious, denying.\n" );
+                       throw new HttpError( 400, $this->msg( 'suspicious-userlogout' ), $this->msg( 'loginerror' ) );
+               }
+
+               $this->setHeaders();
+               $this->outputHeader();
+
+               // Make sure it's possible to log out
+               $session = MediaWiki\Session\SessionManager::getGlobalSession();
+               if ( !$session->canSetUser() ) {
+                       throw new ErrorPageError(
+                               'cannotlogoutnow-title',
+                               'cannotlogoutnow-text',
+                               [
+                                       $session->getProvider()->describe( RequestContext::getMain()->getLanguage() )
+                               ]
+                       );
+               }
+
+               $user = $this->getUser();
+               $oldName = $user->getName();
+               $user->logout();
+
+               $loginURL = SpecialPage::getTitleFor( 'Userlogin' )->getFullURL(
+                       $this->getRequest()->getValues( 'returnto', 'returntoquery' ) );
+
+               $out = $this->getOutput();
+               $out->addWikiMsg( 'logouttext', $loginURL );
+
+               // Hook.
+               $injected_html = '';
+               Hooks::run( 'UserLogoutComplete', [ &$user, &$injected_html, $oldName ] );
+               $out->addHTML( $injected_html );
+
+               $out->returnToMain();
+       }
+
+       protected function getGroupName() {
+               return 'login';
+       }
+}
index 3824be1..0a5aa61 100644 (file)
@@ -20,6 +20,8 @@
  *
  * @file
  * @ingroup Templates
+ * @deprecated Will be removed when AuthManager lands.
+ *   The signup form will be generated via HTMLForm.
  */
 
 class UsercreateTemplate extends BaseTemplate {
index c2b2df6..e816b62 100644 (file)
@@ -20,6 +20,8 @@
  *
  * @file
  * @ingroup Templates
+ * @deprecated Will be removed when AuthManager lands.
+ *   The login form will be generated via HTMLForm.
  */
 
 class UserloginTemplate extends BaseTemplate {
diff --git a/includes/user/PasswordReset.php b/includes/user/PasswordReset.php
new file mode 100644 (file)
index 0000000..60144bb
--- /dev/null
@@ -0,0 +1,257 @@
+<?php
+/**
+ * User password reset helper for MediaWiki.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Auth\TemporaryPasswordAuthenticationRequest;
+
+/**
+ * Helper class for the password reset functionality shared by the web UI and the API.
+ *
+ * Requires the TemporaryPasswordPrimaryAuthenticationProvider and the
+ * EmailNotificationSecondaryAuthenticationProvider (or something providing equivalent
+ * functionality) to be enabled.
+ */
+class PasswordReset {
+       /** @var Config */
+       protected $config;
+
+       /** @var AuthManager */
+       protected $authManager;
+
+       /**
+        * In-process cache for isAllowed lookups, by username. Contains pairs of StatusValue objects
+        * (for false and true value of $displayPassword, respectively).
+        * @var HashBagOStuff
+        */
+       private $permissionCache;
+
+       public function __construct( Config $config, AuthManager $authManager ) {
+               $this->config = $config;
+               $this->authManager = $authManager;
+               $this->permissionCache = new HashBagOStuff( [ 'maxKeys' => 1 ] );
+       }
+
+       /**
+        * Check if a given user has permission to use this functionality.
+        * @param User $user
+        * @param bool $displayPassword If set, also check whether the user is allowed to reset the
+        *   password of another user and see the temporary password.
+        * @return StatusValue
+        */
+       public function isAllowed( User $user, $displayPassword = false ) {
+               $statuses = $this->permissionCache->get( $user->getName() );
+               if ( $statuses ) {
+                       list ( $status, $status2 ) = $statuses;
+               } else {
+                       $resetRoutes = $this->config->get( 'PasswordResetRoutes' );
+                       $status = StatusValue::newGood();
+
+                       if ( !is_array( $resetRoutes ) ||
+                                !in_array( true, array_values( $resetRoutes ), true )
+                       ) {
+                               // Maybe password resets are disabled, or there are no allowable routes
+                               $status = StatusValue::newFatal( 'passwordreset-disabled' );
+                       } elseif (
+                               ( $providerStatus = $this->authManager->allowsAuthenticationDataChange(
+                                       new TemporaryPasswordAuthenticationRequest(), false ) )
+                               && !$providerStatus->isGood()
+                       ) {
+                               // Maybe the external auth plugin won't allow local password changes
+                               $status = StatusValue::newFatal( 'resetpass_forbidden-reason',
+                                       $providerStatus->getMessage() );
+                       } elseif ( !$this->config->get( 'EnableEmail' ) ) {
+                               // Maybe email features have been disabled
+                               $status = StatusValue::newFatal( 'passwordreset-emaildisabled' );
+                       } elseif ( !$user->isAllowed( 'editmyprivateinfo' ) ) {
+                               // Maybe not all users have permission to change private data
+                               $status = StatusValue::newFatal( 'badaccess' );
+                       } elseif ( $user->isBlocked() ) {
+                               // Maybe the user is blocked (check this here rather than relying on the parent
+                               // method as we have a more specific error message to use here
+                               $status = StatusValue::newFatal( 'blocked-mailpassword' );
+                       }
+
+                       $status2 = StatusValue::newGood();
+                       if ( !$user->isAllowed( 'passwordreset' ) ) {
+                               $status2 = StatusValue::newFatal( 'badaccess' );
+                       }
+
+                       $this->permissionCache->set( $user->getName(), [ $status, $status2 ] );
+               }
+
+               if ( !$displayPassword || !$status->isGood() ) {
+                       return $status;
+               } else {
+                       return $status2;
+               }
+       }
+
+       /**
+        * Do a password reset. Authorization is the caller's responsibility.
+        *
+        * Process the form.  At this point we know that the user passes all the criteria in
+        * userCanExecute(), and if the data array contains 'Username', etc, then Username
+        * resets are allowed.
+        * @param User $performingUser The user that does the password reset
+        * @param string $username The user whose password is reset
+        * @param string $email Alternative way to specify the user
+        * @param bool $displayPassword Whether to display the password
+        * @return StatusValue Will contain the passwords as a username => password array if the
+        *   $displayPassword flag was set
+        * @throws LogicException When the user is not allowed to perform the action
+        * @throws MWException On unexpected DB errors
+        */
+       public function execute(
+               User $performingUser, $username = null, $email = null, $displayPassword = false
+       ) {
+               if ( !$this->isAllowed( $performingUser, $displayPassword )->isGood() ) {
+                       $action = $this->isAllowed( $performingUser )->isGood() ? 'display' : 'reset';
+                       throw new LogicException( 'User ' . $performingUser->getName()
+                               . ' is not allowed to ' . $action . ' passwords' );
+               }
+
+               $resetRoutes = $this->config->get( 'PasswordResetRoutes' )
+                       + [ 'username' => false, 'email' => false ];
+               if ( $resetRoutes['username'] && $username ) {
+                       $method = 'username';
+                       $users = [ User::newFromName( $username ) ];
+               } elseif ( $resetRoutes['email'] && $email ) {
+                       if ( !Sanitizer::validateEmail( $email ) ) {
+                               return StatusValue::newFatal( 'passwordreset-invalidemail' );
+                       }
+                       $method = 'email';
+                       $users = $this->getUsersByEmail( $email );
+               } else {
+                       // The user didn't supply any data
+                       return StatusValue::newFatal( 'passwordreset-nodata' );
+               }
+
+               // Check for hooks (captcha etc), and allow them to modify the users list
+               $error = [];
+               $data = [
+                       'Username' => $username,
+                       'Email' => $email,
+                       'Capture' => $displayPassword ? '1' : null,
+               ];
+               if ( !Hooks::run( 'SpecialPasswordResetOnSubmit', [ &$users, $data, &$error ] ) ) {
+                       return StatusValue::newFatal( wfMessage( $error ) );
+               }
+
+               if ( !$users ) {
+                       if ( $method === 'email' ) {
+                               // Don't reveal whether or not an email address is in use
+                               return StatusValue::newGood( [] );
+                       } else {
+                               return StatusValue::newFatal( 'noname' );
+                       }
+               }
+
+               $firstUser = $users[0];
+
+               if ( !$firstUser instanceof User || !$firstUser->getId() ) {
+                       // Don't parse username as wikitext (bug 65501)
+                       return StatusValue::newFatal( wfMessage( 'nosuchuser', wfEscapeWikiText( $username ) ) );
+               }
+
+               // Check against the rate limiter
+               if ( $performingUser->pingLimiter( 'mailpassword' ) ) {
+                       return StatusValue::newFatal( 'actionthrottledtext' );
+               }
+
+               // All the users will have the same email address
+               if ( !$firstUser->getEmail() ) {
+                       // This won't be reachable from the email route, so safe to expose the username
+                       return StatusValue::newFatal( wfMessage( 'noemail',
+                               wfEscapeWikiText( $firstUser->getName() ) ) );
+               }
+
+               // We need to have a valid IP address for the hook, but per bug 18347, we should
+               // send the user's name if they're logged in.
+               $ip = $performingUser->getRequest()->getIP();
+               if ( !$ip ) {
+                       return StatusValue::newFatal( 'badipaddress' );
+               }
+
+               Hooks::run( 'User::mailPasswordInternal', [ &$performingUser, &$ip, &$firstUser ] );
+
+               $result = StatusValue::newGood();
+               $reqs = [];
+               foreach ( $users as $user ) {
+                       $req = TemporaryPasswordAuthenticationRequest::newRandom();
+                       $req->username = $user->getName();
+                       $req->mailpassword = true;
+                       $req->hasBackchannel = $displayPassword;
+                       $req->caller = $performingUser->getName();
+                       $status = $this->authManager->allowsAuthenticationDataChange( $req, true );
+                       if ( $status->isGood() && $status->getValue() !== 'ignored' ) {
+                               $reqs[] = $req;
+                       } elseif ( $result->isGood() ) {
+                               // only record the first error, to avoid exposing the number of users having the
+                               // same email address
+                               if ( $status->getValue() === 'ignored' ) {
+                                       $status = StatusValue::newFatal( 'passwordreset-ignored' );
+                               }
+                               $result->merge( $status );
+                       }
+               }
+
+               if ( !$result->isGood() ) {
+                       return $result;
+               }
+
+               $passwords = [];
+               foreach ( $reqs as $req ) {
+                       $this->authManager->changeAuthenticationData( $req );
+                       // TODO record mail sending errors
+                       if ( $displayPassword ) {
+                               $passwords[$req->username] = $req->password;
+                       }
+               }
+
+               return StatusValue::newGood( $passwords );
+       }
+
+       /**
+        * @param string $email
+        * @return User[]
+        * @throws MWException On unexpected database errors
+        */
+       protected function getUsersByEmail( $email ) {
+               $res = wfGetDB( DB_SLAVE )->select(
+                       'user',
+                       User::selectFields(),
+                       [ 'user_email' => $email ],
+                       __METHOD__
+               );
+
+               if ( !$res ) {
+                       // Some sort of database error, probably unreachable
+                       throw new MWException( 'Unknown database error in ' . __METHOD__ );
+               }
+
+               $users = [];
+               foreach ( $res as $row ) {
+                       $users[] = User::newFromRow( $row );
+               }
+               return $users;
+       }
+}
index 6bfc3a4..71023c0 100644 (file)
@@ -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,8 +681,11 @@ class User implements IDBAccessObject {
         *  - steal: Whether to reset the account's password and email if it
         *    already exists, default false
         * @return User|null
+        * @since 1.27
         */
        public static function newSystemUser( $name, $options = [] ) {
+               global $wgDisableAuthManager;
+
                $options += [
                        'validate' => 'valid',
                        'create' => true,
@@ -691,13 +697,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__
                );
@@ -710,40 +718,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();
@@ -1082,8 +1100,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:
@@ -1408,7 +1427,7 @@ class User implements IDBAccessObject {
         * @see $wgAutopromoteOnce
         */
        public function addAutopromoteOnceGroups( $event ) {
-               global $wgAutopromoteOnceLogInRC, $wgAuth;
+               global $wgAutopromoteOnceLogInRC;
 
                if ( wfReadOnly() || !$this->getId() ) {
                        return [];
@@ -1429,7 +1448,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
 
@@ -2044,9 +2063,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;
        }
@@ -2062,9 +2080,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;
@@ -2470,13 +2487,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() ) {
@@ -2495,7 +2516,6 @@ class User implements IDBAccessObject {
 
                $this->setOption( 'watchlisttoken', false );
                $this->setPasswordInternal( $str );
-               SessionManager::singleton()->invalidateSessionsForUser( $this );
 
                return true;
        }
@@ -2503,18 +2523,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 );
                }
        }
 
@@ -2524,31 +2547,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;
        }
 
        /**
@@ -2610,63 +2670,74 @@ class User implements IDBAccessObject {
        /**
         * Set the password for a password reminder or new account email
         *
-        * @deprecated since 1.27, AuthManager is coming
+        * @deprecated Removed in 1.27. Use PasswordReset instead.
         * @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;
        }
 
        /**
@@ -4150,110 +4221,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 );
                }
        }
 
@@ -5109,6 +5210,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.
@@ -5121,14 +5223,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
                }
 
@@ -5169,6 +5270,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() {
index 10feac9..70d6735 100644 (file)
        "right-override-export-depth": "Esportar páxines, incluyendo páxines enllazaes fasta una fondura de 5",
        "right-sendemail": "Unviar corréu a otros usuarios",
        "right-passwordreset": "Ver los correos de reestablecimientu de conseña",
-       "right-managechangetags": "Crear y desaniciar [[Special:Tags|etiquetes]] dende la base de datos",
+       "right-managechangetags": "Crear y (des)activar [[Special:Tags|etiquetes]]",
        "right-applychangetags": "Aplicar [[Special:Tags|etiquetes]] xunto colos cambios propios",
        "right-changetags": "Amestar y desaniciar [[Special:Tags|etiquetes]] arbitraries en revisiones individuales y entraes del rexistru",
+       "right-deletechangetags": "Desaniciar [[Special:Tags|etiquetes]] de la base de datos",
        "grant-generic": "Conxuntu de drechos «$1»",
        "grant-group-page-interaction": "Interactuar con páxines",
        "grant-group-file-interaction": "Interactuar con multimedia",
        "action-viewmyprivateinfo": "ver la so información privada",
        "action-editmyprivateinfo": "editar la so información privada",
        "action-editcontentmodel": "editar el modelu de conteníu d'una páxina",
-       "action-managechangetags": "crear y desaniciar etiquetes dende la base de datos",
+       "action-managechangetags": "crear y (des)activar etiquetes",
        "action-applychangetags": "aplicar etiquetes xunto colos cambios",
        "action-changetags": "amestar y desaniciar etiquetes arbitraries en revisiones individuales y entraes del rexistru",
+       "action-deletechangetags": "desaniciar etiquetes de la base de datos",
        "nchanges": "{{PLURAL:$1|un cambiu|$1 cambios}}",
        "enhancedrc-since-last-visit": "$1 {{PLURAL:$1|dende la última visita}}",
        "enhancedrc-history": "historial",
        "changecontentmodel-success-text": "Cambióse'l tipu de conteníu de [[:$1]].",
        "changecontentmodel-cannot-convert": "El conteníu de [[:$1]] nun puede convertise a un tipu de $2.",
        "changecontentmodel-nodirectediting": "El modelu de conteníu $1 nun tien encontu pa edición direuta",
+       "changecontentmodel-emptymodels-title": "Nun hai modelos de conteníu disponibles",
+       "changecontentmodel-emptymodels-text": "El conteníu de [[:$1]] nun pue convertise a nengún tipu.",
        "log-name-contentmodel": "Rexistru de cambios del modelu de conteníu",
        "log-description-contentmodel": "Socesos rellacionaos colos modelos de conteníu d'una páxina",
        "logentry-contentmodel-new": "$1 {{GENDER:$2|creó}} la páxina $3 usando un modelu de conteníu non predetermináu «$5»",
        "whatlinkshere-prev": "{{PLURAL:$1|anterior|anteriores $1}}",
        "whatlinkshere-next": "{{PLURAL:$1|siguiente|siguientes $1}}",
        "whatlinkshere-links": "← enllaces",
-       "whatlinkshere-hideredirs": "$1 redireiciones",
-       "whatlinkshere-hidetrans": "$1 tresclusiones",
-       "whatlinkshere-hidelinks": "$1 enllaces",
-       "whatlinkshere-hideimages": "$1 los enllaces al ficheru",
+       "whatlinkshere-hideredirs": "Anubrir redireiciones",
+       "whatlinkshere-hidetrans": "Anubrir tresclusiones",
+       "whatlinkshere-hidelinks": "Tapecer enllaces",
+       "whatlinkshere-hideimages": "Anubrir los enllaces al ficheru",
        "whatlinkshere-filters": "Peñeres",
        "whatlinkshere-submit": "Dir",
        "autoblockid": "Autobloquiar #$1",
        "lockdbsuccesstext": "Candóse la base de datos.\n<br />Alcuérdate de [[Special:UnlockDB|descandala]] depués d'acabar el so mantenimientu.",
        "unlockdbsuccesstext": "La base de datos foi descandada.",
        "lockfilenotwritable": "L'archivu de candáu de la base de datos nun ye escribible. Pa candar o descandar la base de datos esti tien que poder ser modificáu pol sirvidor.",
+       "databaselocked": "La base de datos yá ta bloquiada.",
        "databasenotlocked": "La base de datos nun ta candada.",
        "lockedbyandtime": "(por $1 el $2 a les $3)",
        "move-page": "Treslladar $1",
        "invalidateemail": "Encaboxar confirmación de corréu electrónicu",
        "notificationemail_subject_changed": "Camudó la dirección de corréu electrónicu rexistrada de {{SITENAME}}",
        "notificationemail_subject_removed": "Desanicióse la dirección de corréu electrónicu rexistrada de {{SITENAME}}",
+       "notificationemail_body_changed": "Daquién, probablemente tu, dende la dirección IP $1,\ncamudó la dirección de corréu electrónicu de la cuenta \"$2\" a \"$3\" en {{SITENAME}}.\n\nSi nun fuisti tu, comunícate darréu con un alministrador del sitiu.",
+       "notificationemail_body_removed": "Daquién, probablemente tu, dende la dirección IP $1,\ndesanició la dirección de corréu electrónicu de la cuenta \"$2\" en {{SITENAME}}.\n\nSi nun fuisti tu, comunícate darréu con un alministrador del sitiu.",
        "scarytranscludedisabled": "[La tresclusión interwiki ta desactivada]",
        "scarytranscludefailed": "[Falló la recuperación de la plantía pa $1]",
        "scarytranscludefailed-httpstatus": "[Falló la recuperación de la plantía pa $1: HTTP $2]",
        "timezone-local": "Llocal",
        "duplicate-defaultsort": "Avisu: La clave d'ordenación predeterminada \"$2\" anula la clave d'ordenación anterior \"$1\".",
        "duplicate-displaytitle": "<strong>Avisu:</strong> El títulu a amosar \"$2\" anula el títulu anterior \"$1\".",
+       "restricted-displaytitle": "<strong>Atención:</strong> El títulu a amosar \"$1\" inoróse porque nun ye equivaliente al títulu real de la páxina.",
        "invalid-indicator-name": "<strong>Error:</strong> L'atributu <code>name</code> de los indicadores d'estáu de la páxina nun pue tar baleru.",
        "version": "Versión",
        "version-extensions": "Estensiones instalaes",
        "tags-delete-not-found": "La etiqueta «$1» nun esiste.",
        "tags-delete-too-many-uses": "La etiqueta «$1» aplícase a más {{PLURAL:$2|d'una revisión|de $2 revisiones}}, lo que quier dicir que nun pue desaniciase.",
        "tags-delete-warnings-after-delete": "Desanicióse la etiqueta «$1», pero {{PLURAL:$2|alcontróse'l siguiente avisu|alcontráronse los siguientes avisos}}:",
+       "tags-delete-no-permission": "Nun tienes permisu pa desaniciar etiquetes de cambiu.",
        "tags-activate-title": "Activar etiqueta",
        "tags-activate-question": "Tas a piques d'activar la etiqueta «$1».",
        "tags-activate-reason": "Motivu:",
        "feedback-useragent": "Axente d'usuariu:",
        "searchsuggest-search": "Buscar",
        "searchsuggest-containing": "que contien...",
+       "api-error-autoblocked": "La to dirección IP bloquióse automáticamente porque la usó un usuariu bloquiáu.",
        "api-error-badaccess-groups": "Nun tienes permisu pa xubir ficheros a esta wiki.",
        "api-error-badtoken": "Fallu internu: token incorreutu.",
+       "api-error-blocked": "Tas bloquiáu pa editar.",
        "api-error-copyuploaddisabled": "Xubir d'una URL ta desactivao nesti sirvidor.",
        "api-error-duplicate": "Yá hai {{PLURAL:$1|otru ficheru|otros ficheros}} nesti sitiu col mesmu conteníu.",
        "api-error-duplicate-archive": "Había {{PLURAL:$1|otru ficheru|otros ficheros}} nesti sitiu col mesmu conteníu, pero se {{PLURAL:$1|desanició|desaniciaron}}.",
        "api-error-nomodule": "Fallu internu: nun se configuró dengún módulu de xubíes.",
        "api-error-ok-but-empty": "Fallu internu: nun hai respuesta del sirvidor.",
        "api-error-overwrite": "Nun ta permitío sobroscribir un ficheru esistente.",
+       "api-error-ratelimited": "Tas tentando xubir más ficheros nun espaciu de tiempu más pequeñu del que permite esta wiki.\nTéntalo otra vuelta en dellos minutos.",
        "api-error-stashfailed": "Fallu internu: el sirvidor nun pudo guardar el ficheru temporal.",
        "api-error-publishfailed": "Fallu internu: el sirvidor nun pudo espublizar el ficheru temporal.",
        "api-error-stasherror": "Hebo un error al xubir el ficheru al almacén.",
        "sessionprovider-nocookies": "Les cookies puen tar desactivaes. Asegúrate de tener activaes les cookies y vuelve a principiar.",
        "randomrootpage": "Páxina raíz al debalu",
        "log-action-filter-block": "Tipu de bloquéu:",
+       "log-action-filter-contentmodel": "Tipu de cambéu de modelu de conteníu:",
        "log-action-filter-delete": "Tipu de desaniciu:",
+       "log-action-filter-import": "Tipu d'importación:",
+       "log-action-filter-managetags": "Tipu d'acción d'alministración d'etiquetes:",
+       "log-action-filter-move": "Tipu de movimientu:",
+       "log-action-filter-newusers": "Tipu de creación de cuenta:",
        "log-action-filter-patrol": "Tipu de patrulla:",
        "log-action-filter-protect": "Tipu de proteición:",
+       "log-action-filter-rights": "Tipu de cambéu de permisos",
+       "log-action-filter-suppress": "Tipu de supresión",
        "log-action-filter-upload": "Tipu de carga:",
        "log-action-filter-all": "Too",
        "log-action-filter-block-block": "Bloquéu",
        "log-action-filter-block-reblock": "Cambiu de bloquéu",
        "log-action-filter-block-unblock": "Desbloquéu",
+       "log-action-filter-contentmodel-change": "Cambéu de modelu de conteníu",
+       "log-action-filter-contentmodel-new": "Creación de páxina con modelu de conteníu non estándar",
        "log-action-filter-delete-delete": "Desaniciu de páxines",
        "log-action-filter-delete-restore": "Restauración de páxines",
        "log-action-filter-delete-event": "Desaniciu de rexistros",
        "log-action-filter-delete-revision": "Desaniciu de revisión",
+       "log-action-filter-import-interwiki": "Importación ente wikis",
+       "log-action-filter-import-upload": "Importar cargando XML",
+       "log-action-filter-managetags-create": "Creación d'etiquetes",
+       "log-action-filter-managetags-delete": "Desaniciu d'etiquetes",
+       "log-action-filter-managetags-activate": "Activación d'etiquetes",
+       "log-action-filter-managetags-deactivate": "Desactivación d'etiquetes",
+       "log-action-filter-move-move": "Treslladar ensin sobreescribir les redireiciones",
+       "log-action-filter-move-move_redir": "Treslladar sobreescribiendo les redireiciones",
+       "log-action-filter-newusers-create": "Creación por usuariu anónimu",
+       "log-action-filter-newusers-create2": "Creación por usuariu rexistráu",
+       "log-action-filter-newusers-autocreate": "Creación automática",
+       "log-action-filter-newusers-byemail": "Creación cola contraseña unviada per corréu",
        "log-action-filter-patrol-patrol": "Patrulláu manual",
        "log-action-filter-patrol-autopatrol": "Patrulláu automáticu",
        "log-action-filter-protect-protect": "Proteición",
        "log-action-filter-protect-modify": "Cambiu na proteición",
        "log-action-filter-protect-unprotect": "Desproteición",
+       "log-action-filter-protect-move_prot": "Proteición treslladada",
+       "log-action-filter-rights-rights": "Cambéu manual",
+       "log-action-filter-rights-autopromote": "Cambéu automáticu",
+       "log-action-filter-suppress-event": "Supresión de rexistru",
+       "log-action-filter-suppress-revision": "Supresión de revisión",
+       "log-action-filter-suppress-delete": "Supresión de páxina",
+       "log-action-filter-suppress-block": "Supresión d'usuariu por bloquéu",
+       "log-action-filter-suppress-reblock": "Supresión d'usuariu por rebloquéu",
        "log-action-filter-upload-upload": "Nueva carga",
        "log-action-filter-upload-overwrite": "Recargar"
 }
index 00e296a..484203d 100644 (file)
        "recentchangeslinked-page": "পাতার নাম:",
        "recentchangeslinked-to": "প্রদত্ত পাতায় সংযুক্ত আছে এমন পাতাগুলোর পরিবর্তন দেখাও",
        "recentchanges-page-added-to-category": "বিষয়শ্রেণীতে [[:$1]] যোগ করা হয়েছে",
-       "recentchanges-page-added-to-category-bundled": "বিষয়শà§\8dরà§\87ণà§\80তà§\87 [[:$1]] à¦\93 [[Special:WhatLinksHere/$1|{{PLURAL:$2|à¦\8fà¦\95à¦\9fি à¦ªà¦¾à¦¤à¦¾|$2à¦\9fি à¦ªà¦¾à¦¤à¦¾}}]] à¦¯à§\8bà¦\97 à¦\95রা à¦¹à¦¯à¦¼à§\87à¦\9bà§\87",
+       "recentchanges-page-added-to-category-bundled": "বিষয়শà§\8dরà§\87ণà§\80তà§\87 [[:$1]] à¦¯à§\8bà¦\97 à¦¹à¦¯à¦¼à§\87à¦\9bà§\87, [[Special:WhatLinksHere/$1|à¦\8fà¦\87 à¦ªà¦¾à¦¤à¦¾à¦\9fি à¦\85নà§\8dয à¦ªà¦¾à¦¤à¦¾à¦\97à§\81লির à¦®à¦§à§\8dযà§\87 à¦\85নà§\8dতরà§\8dভà§\81à¦\95à§\8dতà¦\95à§\83ত]]",
        "recentchanges-page-removed-from-category": "বিষয়শ্রেণী থেকে [[:$1]] সরানো হয়েছে",
-       "recentchanges-page-removed-from-category-bundled": "বিষয়শ্রেণী থেকে [[:$1]] ও [[Special:WhatLinksHere/$1|{{PLURAL:$2|একটি পাতা|$2টি পাতা}}]] সরানো হয়েছে",
+       "recentchanges-page-removed-from-category-bundled": "বিষয়শ্রেণীতে [[:$1]] সরানো হয়েছে, [[Special:WhatLinksHere/$1|এই পাতাটি অন্য পাতাগুলির মধ্যে অন্তর্ভুক্তকৃত]]",
        "autochange-username": "মিডিয়াউইকি স্বয়ংক্রিয় পরিবর্তন",
        "upload": "আপলোড",
        "uploadbtn": "ফাইল আপলোড করুন",
        "sp-contributions-logs": "লগসমূহ",
        "sp-contributions-talk": "আলোচনা",
        "sp-contributions-userrights": "ব্যবহারকারী অধিকার ব্যবস্থাপনা",
-       "sp-contributions-blocked-notice": "এই ব্যবহারকারী বর্তমানে বাধাদানকৃত অবস্থায় রয়েছেন।\nতথ্যসূত্র হিসেবে সাম্প্রতিক বাধাদান লগে ভুক্তিটি নিচে দেওয়া হলো:",
+       "sp-contributions-blocked-notice": "এই ব্যবহারকারী বর্তমানে বাধাদানকৃত অবস্থায় রয়েছেন।\nতথ্যসূত্র হিসেবে সাম্প্রতিক বাধাদান লগে ভুক্তিটি নিচে দেওয়া হলো:",
        "sp-contributions-blocked-notice-anon": "এই আইপি ঠিকানাটি বর্তমানে বাধাদানকৃত অবস্থায় রয়েছে।\nতথ্যসূত্র হিসেবে সাম্প্রতিক বাধাদান লগের ভুক্তিটি নিচে দেওয়া হলো:",
        "sp-contributions-search": "অবদানসমূহের জন্য অনুসন্ধান",
        "sp-contributions-username": "আইপি (IP) ঠিকানা অথবা ব্যবহারকারীর নাম:",
        "whatlinkshere-prev": "{{PLURAL:$1|পূর্ববর্তী|পূর্ববর্তী $1টি}}",
        "whatlinkshere-next": "{{PLURAL:$1|পরবর্তী|পরবর্তী $1টি}}",
        "whatlinkshere-links": "← সংযোগগুলি",
-       "whatlinkshere-hideredirs": "পুননির্দেশনা $1",
-       "whatlinkshere-hidetrans": "অন্তর্ভুক্তকরণ $1",
-       "whatlinkshere-hidelinks": "সংযোগ $1",
-       "whatlinkshere-hideimages": "$1 ফাইল সংযোগ",
+       "whatlinkshere-hideredirs": "পুননির্দেশনা আড়াল করো",
+       "whatlinkshere-hidetrans": "অন্তর্ভুক্তকরণ আড়াল করো",
+       "whatlinkshere-hidelinks": "সংযোগ আড়াল করো",
+       "whatlinkshere-hideimages": "ফাইল সংযোগ আড়াল করো",
        "whatlinkshere-filters": "ছাকনী",
        "whatlinkshere-submit": "চলো",
        "autoblockid": "স্বয়ংক্রিয় বাধা #$1",
index 526fa88..b078329 100644 (file)
        "size-megabytes": "$1 MB",
        "size-gigabytes": "$1 GB",
        "lag-warn-normal": "Bearbeitungen der letzten {{PLURAL:$1|Sekunde|$1 Sekunden}} werden in dieser Liste noch nicht angezeigt.",
-       "lag-warn-high": "Auf Grund hoher Datenbankauslastung werden die Bearbeitungen der letzten {{PLURAL:$1|Sekunde|$1 Sekunden}} noch nicht in dieser Liste angezeigt.",
+       "lag-warn-high": "Aufgrund hoher Datenbankauslastung werden die Bearbeitungen der letzten {{PLURAL:$1|Sekunde|$1 Sekunden}} noch nicht in dieser Liste angezeigt.",
        "watchlistedit-normal-title": "Beobachtungsliste bearbeiten",
        "watchlistedit-normal-legend": "Einträge von der Beobachtungsliste entfernen",
        "watchlistedit-normal-explain": "Dies sind die Einträge deiner Beobachtungsliste. Um Einträge zu entfernen, markiere die Kästchen neben den Einträgen und klicke am Ende der Seite auf „{{int:Watchlistedit-normal-submit}}“. Du kannst deine Beobachtungsliste auch im [[Special:EditWatchlist/raw|Listenformat bearbeiten]].",
        "timezone-local": "Lokal",
        "duplicate-defaultsort": "Achtung: Der Sortierungsschlüssel „$2“ überschreibt den vorher verwendeten Schlüssel „$1“.",
        "duplicate-displaytitle": "<strong>Warnung:</strong> Der Anzeigetitel „$2“ überschreibt den früheren Anzeigetitel „$1“.",
+       "restricted-displaytitle": "<strong>Warnung:</strong> Der Anzeigetitel „$1“ wurde ignoriert, da er nicht mit dem tatsächlichen Seitentitel gleichwertig ist.",
        "invalid-indicator-name": "<strong>Fehler:</strong> Das Attribut <code>name</code> des Seitenstatusindikators darf nicht leer sein.",
        "version": "Version",
        "version-extensions": "Installierte Erweiterungen",
index 3578658..adc99f6 100644 (file)
        "hidden-category-category": "Kategoriyê nımıtey",
        "category-subcat-count": "{{PLURAL:$2|Na kategoriya de $1 bınkategoriyay estê.|$2 kategoriyan ra $1 bınkategoriyay asenê.}}",
        "category-subcat-count-limited": "Na kategoriye de {{PLURAL:$1|ena kategoriya bınêne esta|enê $1 kategoriyê bınêni estê}}.",
-       "category-article-count": "{{PLURAL:$2|Na kategoriye de teyna ena pele esta.|Ebe $2 ra pêro piya {{PLURAL:$1|ena pela na kategoriye dera|$1 enê peli na kategoriye derê.}}",
+       "category-article-count": "{{PLURAL:$2|Na kategoriye de teyna ena pele esta.|Ebe $2 ra pêro piya {{PLURAL:$1|ena pela na kategoriye dera|$1 enê peli na kategoriye derê.}}}}",
        "category-article-count-limited": "{{PLURAL:$1|Pela cêrêne|$1 Pelê cêrêni}} na kategoriye derê.",
        "category-file-count": "<noinclude>{{PLURAL:$2|Na kategoriye tenya dosyayanê cêrênan muhtewa kena.}}</noinclude>\n*Na kategoriye de $2 dosyayan ra {{PLURAL:$1|yew dosya tenêka esta| $1 dosyey asenê}}.",
        "category-file-count-limited": "{{PLURAL:$1|Dosya cêrêne|$1 Dosyê cêrêni}} na kategoriye derê.",
        "search": "Cı geyre",
        "searchbutton": "Cı geyre",
        "go": "Şo",
-       "searcharticle": "Şo",
+       "searcharticle": "So",
        "history": "Tarixê pele",
        "history_short": "Tarix",
        "updatedmarker": "cıkewtena mına peyêne ra dıme biyo rocane",
-       "printableversion": "Asayışê çapkerdışi",
+       "printableversion": "Asaena çapkerdene",
        "permalink": "Gıreyo jûqere",
        "print": "Çap ke",
        "view": "Bıvêne",
        "lastmodifiedat": "Ena pele tewr peyên roca $2, $1 de biya rocaniye.",
        "viewcount": "Ena pele {{PLURAL:$1|rae|$1 rey}} vêniya.",
        "protectedpage": "Pela pawıtiye",
-       "jumpto": "Şo:",
+       "jumpto": "Şo be:",
        "jumptonavigation": "Pusula",
        "jumptosearch": "cı geyre",
        "view-pool-error": "Qaytê qısuri mekerên, serverê ma enıka zêde bar gırewto xo ser.\nHedê xo ra zêde karberi kenê ke seyrê na pele bıkerê.\nŞıma rê zehmet, tenê vınderên, heta ke reyna kenê ke ena pele kewê.\n\n$1",
        "pool-errorunknown": "Xeta nêzanıtiye",
        "poolcounter-usage-error": "Xırab karyayış:$1",
        "aboutsite": "Heqa {{SITENAME}} de",
-       "aboutpage": "Project:Heqa {{SITENAME}} de",
+       "aboutpage": "Project:Heqa",
        "copyright": "Zerrekacı $1 bındı not biya.",
        "copyrightpage": "{{ns:project}}:Heqa telifi",
        "currentevents": "Veng û vac",
        "mainpage": "Pela Seri",
        "mainpage-description": "Pela seri",
        "policy-url": "Project:Terzê hereketi",
-       "portal": "Portalê cemaeti",
-       "portal-url": "Project:Portalê cemaeti",
-       "privacy": "Madeyê dızdiye",
+       "portal": "Portalê kome",
+       "portal-url": "Project:Portalê kome",
+       "privacy": "Madê dızdêni",
        "privacypage": "Project:Xısusiyetê nımtışi",
        "badaccess": "Xeta mısadey",
        "badaccess-group0": "Heqa şıma çıniya, karo ke şıma waşt, bıkerê.",
        "feed-invalid": "Qeydey cıresnayışê  beğşi nêvêreno.",
        "feed-unavailable": "Cıresnayışê şebekey çıniyê",
        "site-rss-feed": "$1 Cıresnayışê RSSi",
-       "site-atom-feed": "$1 Cıresnayışê atomi",
+       "site-atom-feed": "$1 Wari kerdrna Atomi",
        "page-rss-feed": "\"$1\" Cıresnayışê RSSi",
        "page-atom-feed": "\"$1\" Cıresnayışê atomi",
        "feed-atom": "Atom",
        "tooltip-ca-move": "Ena pele bere",
        "tooltip-ca-watch": "Ena pele lista xoya seyrkerdışi ke",
        "tooltip-ca-unwatch": "Ena pele lista xoya seyrkerdışi ra vece",
-       "tooltip-search": "{{SITENAME}} miyan de bıvin",
+       "tooltip-search": "{{SITENAME}} de bıvin",
        "tooltip-search-go": "Ebe nê namey tami şo yew pela ke esta",
-       "tooltip-search-fulltext": "Nê  metni peran dı cı geyre",
-       "tooltip-p-logo": "Şo pela seri",
+       "tooltip-search-fulltext": "Pela miyan dı bı geyr ena metin",
+       "tooltip-p-logo": "pela seri ziyaret ke",
        "tooltip-n-mainpage": "Şo pela seri",
        "tooltip-n-mainpage-description": "Şo pela seri",
        "tooltip-n-portal": "Heqa proceyi de, çı şenay bıkerê, çı koti vêniyeno",
        "tooltip-feed-atom": "Qe ena pele atom feed",
        "tooltip-t-contributions": "Yew lista iştırakanê {{GENDER:$1|nê karberi}}",
        "tooltip-t-emailuser": "Ena karber ri yew email bışırav",
-       "tooltip-t-upload": "Dosya bar ke",
+       "tooltip-t-upload": "Dosyey bar ke",
        "tooltip-t-specialpages": "Yew lista pelanê xasanê pêroyinan",
        "tooltip-t-print": "Hewl versiyona ploğnayışa na perer",
        "tooltip-t-permalink": "Gırêyo daimi be ena versiyonê pele",
        "fileduplicatesearch-result-1": "Dosyayê ''$1î'' de hem-kopya çini yo.",
        "fileduplicatesearch-result-n": "Dosyayê ''$1î'' de {{PLURAL:$2|1 hem-kopya|$2 hem-kopyayî'}} esto.",
        "fileduplicatesearch-noresults": "Ebe namey \"$1\" ra dosya nêdiyayê.",
-       "specialpages": "Pelê xısusiyi",
+       "specialpages": "Pelê xasi",
        "specialpages-note-top": "Kıtabek",
        "specialpages-note": "* Pelê xasê normali.\n* <span class=\"mw-specialpagerestricted\">Pelê xasê nımıtey.</span>",
        "specialpages-group-maintenance": "Raporê pawıtışi",
index 6d198c2..9f1051e 100644 (file)
        "site-atom-feed": "$1 ροή Atom",
        "page-rss-feed": "Ροή RSS «$1»",
        "page-atom-feed": "Ροή Atom «$1»",
-       "feed-atom": "Άτομο",
+       "feed-atom": "Atom",
        "red-link-title": "$1 (η σελίδα δεν υπάρχει)",
        "sort-descending": "Φθίνουσα ταξινόμηση",
        "sort-ascending": "Αύξουσα ταξινόμηση",
        "whatlinkshere-prev": "{{PLURAL:$1|προηγούμενη|προηγούμενες $1}}",
        "whatlinkshere-next": "{{PLURAL:$1|επόμενη|επόμενες $1}}",
        "whatlinkshere-links": "← σύνδεσμοι",
-       "whatlinkshere-hideredirs": "$1 ανακατευθύνσεων",
-       "whatlinkshere-hidetrans": "$1 ενσωματώσεων",
-       "whatlinkshere-hidelinks": "$1 συνδέσμων",
+       "whatlinkshere-hideredirs": "Απόκρυψη ανακατευθύνσεων",
+       "whatlinkshere-hidetrans": "Απόκρυψη ενσωματώσεων",
+       "whatlinkshere-hidelinks": "Απόκρυψη συνδέσμων",
        "whatlinkshere-hideimages": "$1 σύνδεσμοι αρχείων",
        "whatlinkshere-filters": "Φίλτρα",
        "whatlinkshere-submit": "Μετάβαση",
index 08d95b9..c522df8 100644 (file)
        "password-change-forbidden": "You cannot change passwords on this wiki.",
        "externaldberror": "There was either an authentication database error or you are not allowed to update your external account.",
        "login": "Log in",
+       "login-security": "Verify your identity",
        "nav-login-createaccount": "Log in / create account",
        "loginprompt": "",
        "userlogin": "Log in / create account",
        "helplogin-url": "https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Logging_in",
        "userlogin-helplink2": "Help with logging in",
        "userlogin-loggedin": "You are already logged in as {{GENDER:$1|$1}}.\nUse the form below to log in as another user.",
+       "userlogin-reauth": "You must log in again to verify that you are {{GENDER:$1|$1}}.",
        "userlogin-createanother": "Create another account",
        "createacct-emailrequired": "Email address",
        "createacct-emailoptional": "Email address (optional)",
        "createacct-email-ph": "Enter your email address",
        "createacct-another-email-ph": "Enter email address",
        "createaccountmail": "Use a temporary random password and send it to the specified email address",
+       "createaccountmail-help": "Can be used to create account for another person without learning the password.",
        "createacct-realname": "Real name (optional)",
        "createaccountreason": "Reason:",
        "createacct-reason": "Reason",
        "createacct-reason-ph": "Why you are creating another account",
+       "createacct-reason-help": "Message shown in the account creation log",
        "createacct-imgcaptcha-help": "",
        "createacct-submit": "Create your account",
        "createacct-another-submit": "Create account",
+       "createacct-continue-submit": "Continue account creation",
+       "createacct-another-continue-submit": "Continue account creation",
        "createacct-benefit-heading": "{{SITENAME}} is made by people like you.",
        "createacct-benefit-icon1": "icon-edits",
        "createacct-benefit-head1": "{{NUMBEROFEDITS}}",
        "nocookieslogin": "{{SITENAME}} uses cookies to log in users.\nYou have cookies disabled.\nPlease enable them and try again.",
        "nocookiesfornew": "The user account was not created, as we could not confirm its source.\nEnsure you have cookies enabled, reload this page and try again.",
        "nocookiesforlogin": "{{int:nocookieslogin}}",
+       "createacct-loginerror": "The account was successfully created but you could not be logged in automatically. Please proceed to [[Special:UserLogin|manual login]].",
        "noname": "You have not specified a valid username.",
        "loginsuccesstitle": "Logged in",
        "loginsuccess": "<strong>You are now logged in to {{SITENAME}} as \"$1\".</strong>",
-       "nosuchuser": "There is no user by the name \"$1\".\nUsernames are case sensitive.\nCheck your spelling, or [[Special:UserLogin/signup|create a new account]].",
+       "nosuchuser": "There is no user by the name \"$1\".\nUsernames are case sensitive.\nCheck your spelling, or [[Special:CreateAccount|create a new account]].",
        "nosuchusershort": "There is no user by the name \"$1\".\nCheck your spelling.",
        "nouserspecified": "You have to specify a username.",
        "login-userblocked": "This user is blocked. Login not allowed.",
        "createacct-another-realname-tip": "Real name is optional.\nIf you choose to provide it, this will be used for giving the user attribution for their work.",
        "pt-login": "Log in",
        "pt-login-button": "Log in",
+       "pt-login-continue-button": "Continue login",
        "pt-createaccount": "Create account",
        "pt-userlogout": "Log out",
        "pear-mail-error": "$1",
        "botpasswords-invalid-name": "The username specified does not contain the bot password separator (\"$1\").",
        "botpasswords-not-exist": "User \"$1\" does not have a bot password named \"$2\".",
        "resetpass_forbidden": "Passwords cannot be changed",
+       "resetpass_forbidden-reason": "Passwords cannot be changed: $1",
        "resetpass-no-info": "You must be logged in to access this page directly.",
        "resetpass-submit-loggedin": "Change password",
        "resetpass-submit-cancel": "Cancel",
        "passwordreset-emailsentusername": "If there is an email address associated with this username, then a password reset email will be sent.",
        "passwordreset-emailsent-capture": "A password reset email has been sent, which is shown below.",
        "passwordreset-emailerror-capture": "A password reset email was generated, which is shown below, but sending it to the {{GENDER:$2|user}} failed: $1",
+       "passwordreset-emailsent-capture2": "The password reset {{PLURAL:$1|email has|emails have}} been sent. The {{PLURAL:$1|username and password|list of usernames and passwords}} is shown below.",
+       "passwordreset-emailerror-capture2": "Emailing the {{GENDER:$2|user}} failed: $1 The {{PLURAL:$3|username and password|list of usernames and passwords}} is shown below.",
+       "passwordreset-nocaller": "A caller must be provided",
+       "passwordreset-nosuchcaller": "Caller does not exist: $1",
+       "passwordreset-ignored": "The password reset was not handled. Maybe no provider was configured?",
+       "passwordreset-invalideamil": "Invalid email address",
+       "passwordreset-nodata": "Neither a username nor an email address was supplied",
        "changeemail": "Change or remove email address",
        "changeemail-summary": "",
        "changeemail-header": "Complete this form to change your email address. If you would like to remove the association of any email address from your account, leave the new email address blank when submitting the form.",
        "newarticletext": "You have followed a link to a page that does not exist yet.\nTo create the page, start typing in the box below (see the [$1 help page] for more info).\nIf you are here by mistake, click your browser's <strong>back</strong> button.",
        "newarticletextanon": "{{int:newarticletext|$1}}",
        "talkpagetext": "<!-- MediaWiki:talkpagetext -->",
-       "anontalkpagetext": "----\n<em>This is the discussion page for an anonymous user who has not created an account yet, or who does not use it.</em>\nWe therefore have to use the numerical IP address to identify him/her.\nSuch an IP address can be shared by several users.\nIf you are an anonymous user and feel that irrelevant comments have been directed at you, please [[Special:UserLogin/signup|create an account]] or [[Special:UserLogin|log in]] to avoid future confusion with other anonymous users.",
+       "anontalkpagetext": "----\n<em>This is the discussion page for an anonymous user who has not created an account yet, or who does not use it.</em>\nWe therefore have to use the numerical IP address to identify him/her.\nSuch an IP address can be shared by several users.\nIf you are an anonymous user and feel that irrelevant comments have been directed at you, please [[Special:CreateAccount|create an account]] or [[Special:UserLogin|log in]] to avoid future confusion with other anonymous users.",
        "noarticletext": "There is currently no text in this page.\nYou can [[Special:Search/{{PAGENAME}}|search for this page title]] in other pages,\n<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} search the related logs],\nor [{{fullurl:{{FULLPAGENAME}}|action=edit}} create this page]</span>.",
        "noarticletext-nopermission": "There is currently no text in this page.\nYou can [[Special:Search/{{PAGENAME}}|search for this page title]] in other pages, or <span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} search the related logs]</span>, but you do not have permission to create this page.",
        "noarticletextanon": "{{int:noarticletext}}",
        "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.",
+       "authform-nosession-login": "The authentication was successful, but your browser cannot \"remember\" being logged in.\n\n$1",
+       "authform-nosession-signup": "The account was created, but your browser cannot \"remember\" being logged in.\n\n$1",
+       "authform-newtoken": "Missing token. $1",
+       "authform-notoken": "Missing token",
+       "authform-wrongtoken": "Wrong token",
+       "specialpage-securitylevel-not-allowed-title": "Not allowed",
+       "specialpage-securitylevel-not-allowed": "Sorry, you are not allowed to use this page because your identity could not be verified.",
+       "authpage-cannot-login": "Unable to start login.",
+       "authpage-cannot-login-continue": "Unable to continue login. Your session most likely timed out.",
+       "authpage-cannot-create": "Unable to start account creation.",
+       "authpage-cannot-create-continue": "Unable to continue account creation. Your session most likely timed out.",
+       "authpage-cannot-link": "Unable to start account linking.",
+       "authpage-cannot-link-continue": "Unable to continue account linking. Your session most likely timed out.",
+       "cannotauth-not-allowed-title": "Permission denied",
+       "cannotauth-not-allowed": "You are not allowed to use this page",
+       "changecredentials" : "Change credentials",
+       "changecredentials-submit": "Change",
+       "changecredentials-submit-cancel": "Cancel",
+       "changecredentials-invalidsubpage": "$1 is not a valid credential type.",
+       "changecredentials-success": "Your credentials have been changed.",
+       "removecredentials" : "Remove credentials",
+       "removecredentials-submit": "Remove",
+       "removecredentials-submit-cancel": "Cancel",
+       "removecredentials-invalidsubpage": "$1 is not a valid credential type.",
+       "removecredentials-success": "Your credentials have been removed.",
+       "credentialsform-provider": "Credentials type:",
+       "credentialsform-account": "Account name:",
+       "cannotlink-no-provider-title": "There are no linkable accounts",
+       "cannotlink-no-provider": "There are no linkable accounts.",
+       "linkaccounts": "Link accounts",
+       "linkaccounts-success-text": "The account was linked.",
+       "linkaccounts-submit": "Link accounts",
+       "unlinkaccounts": "Unlink accounts",
+       "unlinkaccounts-success": "The account was unlinked."
 }
index a481bf4..7882945 100644 (file)
        "whatlinkshere-links": "← ligiloj",
        "whatlinkshere-hideredirs": "$1 alidirektilojn",
        "whatlinkshere-hidetrans": "$1 transinkluzivaĵojn",
-       "whatlinkshere-hidelinks": "$1 ligilojn",
+       "whatlinkshere-hidelinks": "Kaŝi ligilojn",
        "whatlinkshere-hideimages": "$1 ligiloj al bildo",
        "whatlinkshere-filters": "Filtriloj",
        "whatlinkshere-submit": "Ek",
index 29ede6c..48c7a3b 100644 (file)
        "right-override-export-depth": "Exportar páginas, incluyendo aquellas enlazadas hasta una profundidad de 5",
        "right-sendemail": "Enviar correo electrónico a otros usuarios",
        "right-passwordreset": "Ver los mensajes de restablecimiento de contraseña",
-       "right-managechangetags": "Crear y eliminar [[Special:Tags|etiquetas]] en la base de datos",
+       "right-managechangetags": "Crear y (des)activar [[Special:Tags|etiquetas]]",
        "right-applychangetags": "Aplicar [[Special:Tags|etiquetas]] junto con los cambios propios",
        "right-changetags": "Agregar y quitar [[Special:Tags|etiquetas]] arbitrarias a revisiones individuales y entradas del registro",
+       "right-deletechangetags": "Eliminar [[Special:Tags|tags]] de la base de datos",
        "grant-generic": "Paquete de permisos \"$1\"",
        "grant-group-page-interaction": "Interactuar con páginas",
        "grant-group-file-interaction": "Interactuar con multimedia",
        "action-viewmyprivateinfo": "ver tu información privada",
        "action-editmyprivateinfo": "editar tu información privada",
        "action-editcontentmodel": "editar el modelo de contenido de una página",
-       "action-managechangetags": "crear y eliminar etiquetas en la base de datos",
+       "action-managechangetags": "crear y (des)activar etiquetas",
        "action-applychangetags": "aplicar etiquetas junto con los cambios",
        "action-changetags": "agregar y quitar etiquetas arbitrarias a revisiones individuales y entradas del registro",
+       "action-deletechangetags": "eliminar etiquetas de la base de datos",
        "nchanges": "$1 {{PLURAL:$1|cambio|cambios}}",
        "enhancedrc-since-last-visit": "$1 {{PLURAL:$1|desde la última visita}}",
        "enhancedrc-history": "historial",
        "tags-delete-not-found": "La etiqueta «$1» no existe.",
        "tags-delete-too-many-uses": "No se puede borrar la etiqueta \"$1\" porque se ha aplicado a más de {{PLURAL:$2|una revisión|$2 revisiones}}.",
        "tags-delete-warnings-after-delete": "La etiqueta \"$1\" se borró, pero con {{PLURAL:$2|la siguiente advertencia|las siguientes advertencias}}:",
+       "tags-delete-no-permission": "No tienes permiso para eliminar las etiquetas de cambios.",
        "tags-activate-title": "Activar etiqueta",
        "tags-activate-question": "Estás a punto de activar la etiqueta «$1».",
        "tags-activate-reason": "Motivo:",
index d60607c..6ca0645 100644 (file)
        "whatlinkshere-prev": "{{PLURAL:$1|قبلی|$1 مورد قبلی}}",
        "whatlinkshere-next": "{{PLURAL:$1|بعدی|$1 مورد بعدی}}",
        "whatlinkshere-links": "→ پیوندها",
-       "whatlinkshere-hideredirs": "$1 تغییرمسیر",
-       "whatlinkshere-hidetrans": "$1 تراگنجانش‌ها",
-       "whatlinkshere-hidelinks": "$1 پیوند",
+       "whatlinkshere-hideredirs": "پنهان‌کردن تغییرمسیرها",
+       "whatlinkshere-hidetrans": "پنهان‌کردن تراگنجانش‌ها",
+       "whatlinkshere-hidelinks": "پنهان‌کردن پیوندها",
        "whatlinkshere-hideimages": "$1 پیوندهای پرونده",
        "whatlinkshere-filters": "پالایه‌ها",
        "whatlinkshere-submit": "برو",
index 530a48c..d81df3d 100644 (file)
        "compareselectedversions": "वेंचिल्ल्या पुनर्नियाळांची तुळा करात",
        "editundo": "केल्लें परतावचें",
        "diff-multi-sameuser": "(ह्या वांगड्या सयत {{PLURAL:$1|केल्लें मदलें एक अवतरण दाखोवंक ना|केल्लें मदलें $1 अवतरण दाखोवंक ना}})",
-       "searchresults": "सà¥\8bदाà¤\9aà¥\87 à¤ªà¤°à¤¿à¤£à¤¾à¤®à¤¾à¤\82",
+       "searchresults": "सà¥\8bदाà¤\9aà¥\8b à¤¨à¤¿à¤\95ाल",
        "searchresults-title": "\"$1\" हाच्या सोदाचे परिणामां",
        "prevn": "आदलें{{PLURAL:$1|$1}}",
        "nextn": "दुसरें {{PLURAL:$1|$1}}",
index 5cce726..b11891e 100644 (file)
@@ -66,7 +66,7 @@
        "tog-fancysig": "התייחסות לחתימה כקוד ויקי (ללא קישור אוטומטי)",
        "tog-uselivepreview": "שימוש בתצוגה מקדימה מהירה",
        "tog-forceeditsummary": "הצגת אזהרה בעת הכנסת תקציר עריכה ריק",
-       "tog-watchlisthideown": "×\94סתרת ×\94ער×\99×\9b×\95ת ×©×\9c×\99 ×\91רש×\99×\9eת ×\94×\9eעק×\91",
+       "tog-watchlisthideown": "הסתרת עריכות שלי ברשימת המעקב",
        "tog-watchlisthidebots": "הסתרת עריכות של בוטים ברשימת המעקב",
        "tog-watchlisthideminor": "הסתרת עריכות משניות ברשימת המעקב",
        "tog-watchlisthideliu": "הסתרת עריכות של משתמשים רשומים ברשימת המעקב",
        "mar": "מרץ",
        "apr": "אפר'",
        "may": "מאי",
-       "jun": "יונ'",
-       "jul": "יול'",
+       "jun": "יוני",
+       "jul": "יולי",
        "aug": "אוג'",
        "sep": "ספט'",
        "oct": "אוק'",
        "prefs-editwatchlist-label": "עריכת דפים ברשימת המעקב שלך:",
        "prefs-editwatchlist-edit": "הצגה או הסרה של דפים מרשימת המעקב שלך",
        "prefs-editwatchlist-raw": "עריכת רשימת המעקב הגולמית",
-       "prefs-editwatchlist-clear": "ניקוי רשימת המעקב שלך",
+       "prefs-editwatchlist-clear": "ניקוי רשימת המעקב",
        "prefs-watchlist-days": "מספר הימים שמוצגים ברשימת המעקב:",
        "prefs-watchlist-days-max": "לכל היותר {{PLURAL:$1|יום אחד|יומיים|$1 ימים}}",
        "prefs-watchlist-edits": "המספר המרבי של העריכות שמוצגות ברשימת המעקב המורחבת:",
        "blocklist-tempblocks": "הסתרת חסימות זמניות",
        "blocklist-addressblocks": "הסתרת חסימות IP בודד",
        "blocklist-rangeblocks": "הסתרת חסימות טווחים",
-       "blocklist-timestamp": "×\96×\9e×\9f",
+       "blocklist-timestamp": "ת×\90ר×\99×\9a ×\95שע×\94",
        "blocklist-target": "יעד",
        "blocklist-expiry": "זמן פקיעה",
-       "blocklist-by": "×\9eפע×\99×\9c ×\97×\95ס×\9d",
-       "blocklist-params": "הגדרות חסימה",
+       "blocklist-by": "× ×\97ס×\9d ×¢×\9cÖ¾×\99×\93×\99",
+       "blocklist-params": "×\94×\92×\93ר×\95ת ×\94×\97ס×\99×\9e×\94",
        "blocklist-reason": "סיבה",
        "ipblocklist-submit": "חיפוש",
        "ipblocklist-localblock": "חסימה מקומית",
        "timezone-local": "מקומי",
        "duplicate-defaultsort": "'''אזהרה:''' המיון הרגיל \"$2\" דורס את המיון הרגיל המוקדם ממנו \"$1\".",
        "duplicate-displaytitle": "<strong>אזהרה:</strong> כותרת התצוגה \"$2\" דורסת את כותרת התצוגה הקודמת \"$1\".",
+       "restricted-displaytitle": "<strong>אזהרבה:</strong> כותרת התצוגה \"$1\" לא הופעלה כי היא לא תואמת לכותרת האמתית של הדף.",
        "invalid-indicator-name": "<strong>שגיאה:</strong> התכונה <code>name</code> של מצייני מצב הדף אינה יכולה להיות ריקה.",
        "version": "גרסת התוכנה",
        "version-extensions": "הרחבות מותקנות",
index fa1b19f..48e3554 100644 (file)
        "mw-widgets-dateinput-placeholder-day": "GGGG-MM-DD",
        "mw-widgets-dateinput-placeholder-month": "GGGG-MM",
        "mw-widgets-titleinput-description-new-page": "stranica još ne postoji",
-       "mw-widgets-titleinput-description-redirect": "preusmjeravanje na $1"
+       "mw-widgets-titleinput-description-redirect": "preusmjeravanje na $1",
+       "log-action-filter-upload": "Vrsta postavljanja:",
+       "log-action-filter-all": "sve",
+       "log-action-filter-upload-upload": "novo postavljanje",
+       "log-action-filter-upload-overwrite": "ponovno postavljanje"
 }
index 56efb62..05a68b4 100644 (file)
        "right-override-export-depth": "Exportar paginas includente paginas ligate usque a un profunditate de 5",
        "right-sendemail": "Inviar e-mail a altere usatores",
        "right-passwordreset": "Vider le e-mails pro reinitialisar le contrasigno",
-       "right-managechangetags": "Crear e deler [[Special:Tags|etiquettas]] in le base de datos",
+       "right-managechangetags": "Crear e (de)activar [[Special:Tags|etiquettas]]",
        "right-applychangetags": "Applicar [[Special:Tags|etiquettas]] al proprie modificationes",
        "right-changetags": "Adder e remover qualcunque [[Special:Tags|etiquettas]] sur individual versiones e entratas de registro",
+       "right-deletechangetags": "Deler [[Special:Tags|etiquettas]] del base de datos",
        "grant-generic": "Gruppo de derectos \"$1\"",
        "grant-group-page-interaction": "Interager con paginas",
        "grant-group-file-interaction": "Interager con multimedia",
        "action-viewmyprivateinfo": "vider le proprie information private",
        "action-editmyprivateinfo": "modificar le proprie information private",
        "action-editcontentmodel": "modificar le modello de contento de un pagina",
-       "action-managechangetags": "crear e deler etiquettas in le base de datos",
+       "action-managechangetags": "crear e (de)activar etiquettas",
        "action-applychangetags": "applicar etiquettas al proprie modificationes",
        "action-changetags": "adder e remover qualcunque etiquettas sur individual versiones e entratas de registro",
+       "action-deletechangetags": "deler etiquettas del base de datos",
        "nchanges": "$1 {{PLURAL:$1|modification|modificationes}}",
        "enhancedrc-since-last-visit": "$1 {{PLURAL:$1|desde le ultime visita}}",
        "enhancedrc-history": "historia",
        "recentchanges-page-added-to-category": "[[:$1]] addite al categoria",
        "recentchanges-page-added-to-category-bundled": "[[:$1]] addite al categoria, [[Special:WhatLinksHere/$1|iste pagina es includite in altere paginas]]",
        "recentchanges-page-removed-from-category": "[[:$1]] removite del categoria",
-       "recentchanges-page-removed-from-category-bundled": "[[:$1]] e [[Special:WhatLinksHere/$1|{{PLURAL:$2|un pagina|$2 paginas}}]] removite del categoria",
+       "recentchanges-page-removed-from-category-bundled": "[[:$1]] removite del categoria, [[Special:WhatLinksHere/$1|iste pagina es includite in altere paginas]]",
        "autochange-username": "Cambiamento automatic de MediaWiki",
        "upload": "Incargar file",
        "uploadbtn": "Incargar file",
        "timezone-local": "Local",
        "duplicate-defaultsort": "Attention: Le clave de ordination predefinite \"$2\" supplanta le anterior clave de ordination predefinite \"$1\".",
        "duplicate-displaytitle": "<strong>Attention:</strong> Le titulo a monstrar \"$2\" supplanta le ancian titulo a monstrar \"$1\".",
+       "restricted-displaytitle": "<strong>Attention:</strong> Le titulo a monstrar \"$1\" ha essite ignorate perque illo non es equivalente al titulo real del pagina.",
        "invalid-indicator-name": "<strong>Error:</strong> Le attributo <code>name</code> del indicatores del stato del pagina non pote esser vacue.",
        "version": "Version",
        "version-extensions": "Extensiones installate",
        "tags-delete-not-found": "Le etiquetta \"$1\" non existe.",
        "tags-delete-too-many-uses": "Le etiquetta \"$1\" es applicate a plus de $2 {{PLURAL:$2|version|versiones}}, e per isto non pote esser delite.",
        "tags-delete-warnings-after-delete": "Le etiquetta \"$1\" ha essite delite, ma le sequente {{PLURAL:$2|advertimento|advertimentos}} ha essite incontrate:",
+       "tags-delete-no-permission": "Tu non ha le permission de deler etiquettas de modification.",
        "tags-activate-title": "Activar etiquetta",
        "tags-activate-question": "Tu es sur le puncto de activar le etiquetta \"$1\".",
        "tags-activate-reason": "Motivo:",
        "logentry-protect-protect-cascade": "$1 {{GENDER:$2|protegeva}} $3 $4 [in cascada]",
        "logentry-protect-modify": "$1 {{GENDER:$2|cambiava}} le nivello de protection de $3 $4",
        "logentry-protect-modify-cascade": "$1 {{GENDER:$2|cambiava}} le nivello de protection de $3 $4 [in cascada]",
-       "logentry-rights-rights": "$1 {{GENDER:$2|cambiava}} le appertinentia a gruppos pro $3 de $4 a $5",
+       "logentry-rights-rights": "$1 {{GENDER:$2|cambiava}} le appertinentia a gruppos pro {{GENDER:$6|$3}} de $4 a $5",
        "logentry-rights-rights-legacy": "$1 {{GENDER:$2|cambiava}} le appertinentia a gruppos pro $3",
        "logentry-rights-autopromote": "$1 ha essite automaticamente {{GENDER:$2|promovite}} de $4 a $5",
        "logentry-upload-upload": "$1 {{GENDER:$2|ha incargate}} $3",
        "feedback-useragent": "Agente usator:",
        "searchsuggest-search": "Cercar",
        "searchsuggest-containing": "continente...",
+       "api-error-autoblocked": "Tu adresse IP ha essite blocate automaticamente, perque illo ha essite usate per un usator blocate.",
        "api-error-badaccess-groups": "Tu non ha le permission de incargar files in iste wiki.",
        "api-error-badtoken": "Error interne: indicio invalide.",
+       "api-error-blocked": "Le modification ha essite blocate pro te.",
        "api-error-copyuploaddisabled": "Le incargamentos per URL es disactivate in iste servitor.",
        "api-error-duplicate": "Existe jam {{PLURAL:$1|un altere file|altere files}} in le wiki con le mesme contento.",
        "api-error-duplicate-archive": "Il habeva jam {{PLURAL:$1|un altere file|altere files}} in le sito con le mesme contento, ma {{PLURAL:$1|illo|illos}} ha essite delite.",
        "api-error-nomodule": "Error interne: nulle modulo de incargamento definite.",
        "api-error-ok-but-empty": "Error interne: nulle responsa del servitor.",
        "api-error-overwrite": "Superscriber un file existente non es permittite.",
+       "api-error-ratelimited": "Tu tenta incargar plus files in curte tempore que iste wiki permitte.\nPer favor, reproba in alcun minutas.",
        "api-error-stashfailed": "Error interne: le servitor non poteva immagazinar le file temporari.",
        "api-error-publishfailed": "Error interne: le servitor non poteva publicar le file temporari.",
        "api-error-stasherror": "Un error ha occurrite durante le incargamento del file in \"stash\".",
        "api-error-unknownerror": "Error incognite: \"$1\".",
        "api-error-uploaddisabled": "Le incargamento es disactivate in iste wiki.",
        "api-error-verification-error": "Le file pote esser corrumpite o su nomine pote haber un extension errate.",
+       "api-error-was-deleted": "Un file con iste nomine ha jam essite incargate e postea delite.",
        "duration-seconds": "$1 {{PLURAL:$1|secunda|secundas}}",
        "duration-minutes": "$1 {{PLURAL:$1|minuta|minutas}}",
        "duration-hours": "$1 {{PLURAL:$1|hora|horas}}",
        "expand_templates_generate_xml": "Monstrar arbore syntactic XML",
        "expand_templates_generate_rawhtml": "Monstrar HTML brute",
        "expand_templates_preview": "Previsualisation",
-       "expand_templates_preview_fail_html": "<em>Perque {{SITENAME}} ha HTML crude activate e il habeva un perdita de datos de session, le previsualisation es celate como precaution contra attaccos con JavaScript.</em>\n\n<strong>Si isto es un tentativa de previsualisation legitime, per favor essaya lo de novo.</strong>\nSi illo ancora non functiona, essaya [[Special:UserLogout|clauder le session]] e aperir un nove session.",
+       "expand_templates_preview_fail_html": "<em>Perque {{SITENAME}} ha HTML crude activate e il habeva un perdita de datos de session, le previsualisation es celate como precaution contra attaccos con JavaScript.</em>\n\n<strong>Si isto es un tentativa de previsualisation legitime, per favor essaya lo de novo.</strong>\nSi illo ancora non functiona, essaya [[Special:UserLogout|clauder le session]] e aperir un nove session, e verifica que tu navigator permitte le cookies de iste sito.",
        "expand_templates_preview_fail_html_anon": "<em>Perque {{SITENAME}} ha HTML crude activate e tu non ha aperite session, le previsualisation es celate como precaution contra attaccos con JavaScript.</em>\n\n<strong>Si isto es un tentativa de previsualisation legitime, per favor [[Special:UserLogin|aperi session]] e essaya lo de novo.</strong>",
-       "pagelanguage": "Selector de lingua de pagina",
+       "expand_templates_input_missing": "Tu debe scriber alcun texto de entrata.",
+       "pagelanguage": "Cambiar lingua del pagina",
        "pagelang-name": "Pagina",
        "pagelang-language": "Lingua",
        "pagelang-use-default": "Usar lingua predefinite",
        "pagelang-select-lang": "Selige lingua",
+       "pagelang-submit": "Submitter",
        "right-pagelang": "Cambiar lingua del pagina",
        "action-pagelang": "cambiar le lingua del pagina",
        "log-name-pagelang": "Registro de cambios de lingua",
        "log-description-pagelang": "Isto es un registro de cambios de lingua in paginas.",
-       "logentry-pagelang-pagelang": "$1 {{GENDER:$2|cambiava}} le lingua del pagina $3 de $4 a $5.",
+       "logentry-pagelang-pagelang": "$1 {{GENDER:$2|cambiava}} le lingua de $3 de $4 a $5.",
        "default-skin-not-found": "Attention! Le apparentia predefinite de tu wiki, definite in <code dir=\"ltr\">$wgDefaultSkin</code> como <code>$1</code>, non es disponibile.\n\nLe installation pare includer le sequente {{PLURAL:$4|apparentia|apparentias}}. Vide [https://www.mediawiki.org/wiki/Manual:Skin_configuration Manual: Skin configuration] pro saper como activar {{PLURAL:$4|lo|los e seliger le predefinite}}.\n\n$2\n\n; Si tu ha justo installate MediaWiki:\n: Tu lo ha probabilemente installate a partir de git, o directemente del codice fonte con un altere methodo. Isto es normal. Essaya installar alcun apparentias desde [https://www.mediawiki.org/wiki/Category:All_skins le directorio de apparentias de mediawiki.org], per:\n:* Discargar le [https://www.mediawiki.org/wiki/Download archivo tar del installator], que include plure apparentias e extensiones. Tu pote copiar e collar le directorio <code>skins/</code> de illo.\n:* Discargar archivos tar con apparentias indidivual ab [https://www.mediawiki.org/wiki/Special:SkinDistributor mediawiki.org].\n:* [https://www.mediawiki.org/wiki/Download_from_Git#Using_Git_to_download_MediaWiki_skins Usar Git pro discargar apparentias].\n: Facer isto non deberea interferer con tu repositorio git si tu es un disveloppator de MediaWiki.\n\n; Si tu ha justo actualisate MediaWiki:\n: MediaWiki a partir del version 1.24 non plus activa automaticamente le apparentias installate (vide [https://www.mediawiki.org/wiki/Manual:Skin_autodiscovery Manual: Skin autodiscovery]). Tu pote collar le sequente {{PLURAL:$5|linea|lineas}} in <code>LocalSettings.php</code> pro activar {{PLURAL:$5|le apparentia|tote le apparentias}} actualmente installate:\n\n<pre dir=\"ltr\">$3</pre>\n\n; Si tu ha justo modificate <code>LocalSettings.php</code>:\n: Verifica meticulosemente que le nomines del apparentias non ha errores.",
        "default-skin-not-found-no-skins": "Attention! Le apparentia predefinite de tu wiki, definite in <code>$wgDefaultSkin</code> como <code>$1</code>, non es disponibile.\n\nTu non ha apparentias installate.\n\n; Si tu ha justo installate o actualisate MediaWiki:\n: Tu lo ha probabilemente installate a partir de git, o directemente del codice fonte con un altere methodo. Isto es normal. Essaya installar alcun apparentias desde [https://www.mediawiki.org/wiki/Category:All_skins le directorio de apparentias de mediawiki.org], per:\n:* Discargar le [https://www.mediawiki.org/wiki/Download archivo tar del installator], que include plure apparentias e extensiones. Tu pote copiar e collar le directorio <code>skins/</code> de illo.\n:* Discargar archivos tar con apparentias individual ab [https://www.mediawiki.org/wiki/Special:SkinDistributor mediawiki.org].\n:* [https://www.mediawiki.org/wiki/Download_from_Git#Using_Git_to_download_MediaWiki_skins Usar Git pro discargar apparentias].\n: Facer isto non deberea interferer con tu repositorio git si tu es un disveloppator de MediaWiki. Vide [https://www.mediawiki.org/wiki/Manual:Skin_configuration Manual: Skin configuration] pro saper como activar apparentias e seliger le predefinite.",
        "default-skin-not-found-row-enabled": "* <code>$1</code> / $2 (activate)",
        "mediastatistics": "Statisticas de multimedia",
        "mediastatistics-summary": "Statisticas sur le typos de file incargate. Isto include solmente le version le plus recente de un file. Versiones ancian o delite de files es excludite.",
        "mediastatistics-nbytes": "{{PLURAL:$1|$1 byte|$1 bytes}} ($2; $3%)",
+       "mediastatistics-bytespertype": "Dimension total de files pro iste section: {{PLURAL:$1|$1 byte|$1 bytes}} ($2; $3%).",
+       "mediastatistics-allbytes": "Dimension total de tote le files: {{PLURAL:$1|$1 byte|$1 bytes}} ($2).",
        "mediastatistics-table-mimetype": "Typo MIME",
        "mediastatistics-table-extensions": "Extensiones possibile",
        "mediastatistics-table-count": "Numero de files",
        "mediastatistics-header-text": "Textual",
        "mediastatistics-header-executable": "Executabiles",
        "mediastatistics-header-archive": "Formatos comprimite",
+       "mediastatistics-header-total": "Tote le files",
        "json-warn-trailing-comma": "$1 {{PLURAL:$1|comma|commas}} final ha essite removite de JSON",
        "json-error-unknown": "Il habeva un problema con le JSON. Error: $1",
        "json-error-depth": "Le profunditate maxime del pila ha essite excedite",
        "special-characters-group-ipa": "IPA",
        "special-characters-group-symbols": "Symbolos",
        "special-characters-group-greek": "Greco",
+       "special-characters-group-greekextended": "Greco extense",
        "special-characters-group-cyrillic": "Cyrillic",
        "special-characters-group-arabic": "Arabe",
        "special-characters-group-arabicextended": "Arabe extendite",
        "mw-widgets-titleinput-description-new-page": "pagina non existe ancora",
        "mw-widgets-titleinput-description-redirect": "redirection a $1",
        "api-error-blacklisted": "Per favor elige un altere titulo, plus descriptive.",
-       "randomrootpage": "Pagina-radice aleatori"
+       "sessionmanager-tie": "Impossibile combinar plure typos de authentication de requesta: $1.",
+       "sessionprovider-generic": "sessiones $1",
+       "sessionprovider-mediawiki-session-cookiesessionprovider": "sessiones basate sur cookies",
+       "sessionprovider-nocookies": "Le cookies pote esser disactivate. Assecura te de haber activate le cookies e recomencia.",
+       "randomrootpage": "Pagina radice aleatori",
+       "log-action-filter-block": "Typo de blocada:",
+       "log-action-filter-contentmodel": "Typo de modification de modello de contento:",
+       "log-action-filter-delete": "Typo de deletion:",
+       "log-action-filter-import": "Typo de importation:",
+       "log-action-filter-managetags": "Typo de action de gestion de etiquettas:",
+       "log-action-filter-move": "Typo de renomination:",
+       "log-action-filter-newusers": "Typo de creation de conto:",
+       "log-action-filter-patrol": "Typo de patrulia:",
+       "log-action-filter-protect": "Typo de protection:",
+       "log-action-filter-rights": "Typo de cambio de derecto",
+       "log-action-filter-suppress": "Typo de suppression",
+       "log-action-filter-upload": "Typo de incargamento:",
+       "log-action-filter-all": "Toto",
+       "log-action-filter-block-block": "Blocar",
+       "log-action-filter-block-reblock": "Modification de blocada",
+       "log-action-filter-block-unblock": "Disblocar",
+       "log-action-filter-contentmodel-change": "Cambio de modello de contento",
+       "log-action-filter-contentmodel-new": "Creation de pagina con modello de contento non standard",
+       "log-action-filter-delete-delete": "Deletion de pagina",
+       "log-action-filter-delete-restore": "Restauration de pagina",
+       "log-action-filter-delete-event": "Deletion de registro",
+       "log-action-filter-delete-revision": "Deletion de version",
+       "log-action-filter-import-interwiki": "Importation trans wiki",
+       "log-action-filter-import-upload": "Importation per incargamento XML",
+       "log-action-filter-managetags-create": "Creation de etiquetta",
+       "log-action-filter-managetags-delete": "Deletion de etiquetta",
+       "log-action-filter-managetags-activate": "Activation de etiquetta",
+       "log-action-filter-managetags-deactivate": "Disactivation de etiquetta",
+       "log-action-filter-move-move": "Renomination sin superscriber redirectiones",
+       "log-action-filter-move-move_redir": "Renomination superscribente redirectiones",
+       "log-action-filter-newusers-create": "Creation per usator anonyme",
+       "log-action-filter-newusers-create2": "Creation per usator registrate",
+       "log-action-filter-newusers-autocreate": "Creation automatic",
+       "log-action-filter-newusers-byemail": "Creation con contrasigno inviate per e-mail",
+       "log-action-filter-patrol-patrol": "Patrulia manual",
+       "log-action-filter-patrol-autopatrol": "Patrulia automatic",
+       "log-action-filter-protect-protect": "Protection",
+       "log-action-filter-protect-modify": "Modification de protection",
+       "log-action-filter-protect-unprotect": "Disprotection",
+       "log-action-filter-protect-move_prot": "Protection displaciate",
+       "log-action-filter-rights-rights": "Cambiamento manual",
+       "log-action-filter-rights-autopromote": "Cambiamento automatic",
+       "log-action-filter-suppress-event": "Suppression de registro",
+       "log-action-filter-suppress-revision": "Suppression de version",
+       "log-action-filter-suppress-delete": "Suppression de pagina",
+       "log-action-filter-suppress-block": "Suppression de usator per blocada",
+       "log-action-filter-suppress-reblock": "Suppression de usator per re-blocada",
+       "log-action-filter-upload-upload": "Nove file incargate",
+       "log-action-filter-upload-overwrite": "File re-incargate"
 }
index 71f985c..8ac3982 100644 (file)
@@ -28,9 +28,9 @@
        "tog-watchdefault": "Зем бара списка т|атоха аз хийца оаг|онаши файлай йоазонца сурташ оттадари",
        "tog-watchmoves": "Зем бара списка т|атоха аз цIи хийца оаг|онаши файлаши",
        "tog-watchdeletion": "Зем бара списка т|атоха аз дIаяьккха оаг|онаши файлаши",
-       "tog-minordefault": "ТеÑ\80камза Ñ\85Ñ\83вÑ\86амаÑ\88Ñ\82а Ð»Ð¾Ð°Ñ\80Ñ\85Ó\80амза Ð¼Ð¾ Ð±ÐµÐ»Ð³Ð°Ð»Ð¾ Ñ\85оÑ\82Ñ\82а",
-       "tog-previewontop": "Ð\93Ó\80алаÑ\82нийÑ\81даÑ\80а ÐºÐ¾Ñ\80а Ñ\85Ñ\8cалÑ\85е Ð±Ó\80аÑ\80гÑ\82аÑ\81Ñ\81ам Ð¾Ñ\82Ñ\82ае",
-       "tog-previewonfirst": "Ð\93Ó\80алаÑ\82нийÑ\81даÑ\80е Ð´ÐµÑ\85Ñ\8cавоалаÑ\88/йоалаÑ\88 Ð±Ó\80аÑ\80гÑ\82аÑ\81Ñ\81ам гойта",
+       "tog-minordefault": "Ð\9cаÑ\81Ñ\81аза Ð·Iамига Ð´Ð°Ñ\80аÑ\88 Ñ\81анна Ð±ÐµÐ»Ð³Ð°Ð»Ð´Ðµ Ñ\85Ñ\83вÑ\86амаÑ\88.",
+       "tog-previewontop": "Ð¥Ñ\8cалÑ\85Ñ\85е Ð±Ó\80аÑ\80гÑ\82оÑ\85аÑ\80 Ñ\85Ñ\8cагойÑ\82а Ñ\85Ñ\83вÑ\86ама ÐºÐ¾Ñ\80а Ñ\85Ñ\8cалÑ\85аÑ\88каÑ\85Ñ\8c",
+       "tog-previewonfirst": "Ð¥Ñ\83вÑ\86ама Ð´ÐµÑ\85Ñ\8cавоалаÑ\88 Ñ\85ан Ñ\85Ñ\8cалÑ\85Ñ\85е Ð±Ó\80аÑ\80гÑ\82оÑ\85аÑ\80 Ñ\85Ñ\8cагойта",
        "tog-enotifwatchlistpages": "Tеркама хьат|аяздар чура оаг|онаши паьлаши хувцамаех лаьца д-хоамне т|а дайта хьа",
        "tog-enotifusertalkpages": "Са дувцама оаг|он т|а хувцамаш хилча, д-хоамнец хьахоам бе",
        "tog-enotifminoredits": "Геттара з|амига хувцамаш хилча а, д-хоамнец хьахоам бе",
@@ -71,7 +71,7 @@
        "tue": "Шин",
        "wed": "Кх",
        "thu": "Ер",
-       "fri": "ПIаьр.",
+       "fri": "ПI",
        "sat": "Шоа",
        "january": "АгIой бутт",
        "february": "Саь-кур бутт",
        "extlink_tip": "Арахьара тIахьожаярг (йиц ма ялийтта префикс http://)",
        "headline_sample": "Заголовка текст",
        "headline_tip": "2-гӀа лагӀа заголовок",
-       "nowiki_sample": "Укхаза кийчаде дезаш доаца яздам оттаде",
-       "nowiki_tip": "Ð\9cаÑ\81Ñ\81а-бÑ\83Ñ\81Ñ\82амлоÑ\80г Ñ\82еÑ\80камза Ð´Ð¸Ñ\82а",
+       "nowiki_sample": "Укхаза хувца езаш йоаца текст хьачуоттае",
+       "nowiki_tip": "ТеÑ\80кал Ð¼Ð° Ðµ Ð²Ð¸ÐºÐ¸-Ñ\84оÑ\80маÑ\82иÑ\80овани",
        "image_tip": "ДIачуоттаяь файл",
        "media_tip": "Файла тIахьожавар",
-       "sig_tip": "ШÑ\83н кулгаяздар а, хӀанзара ха а",
+       "sig_tip": "Ð¥Ñ\8cа кулгаяздар а, хӀанзара ха а",
        "hr_tip": "ПхьорагIен така (цох пайда эцар тIехдаьнна кастта ма де)",
-       "summary": "Ð¥Ñ\83вÑ\86амий Ð±ÐµÐ»Ð³Ð°Ð»Ð´Ðµр",
+       "summary": "Ð¥Ñ\83вÑ\86амай Ñ\81Ñ\83Ñ\80Ñ\82 Ð¾Ñ\82Ñ\82адар",
        "subject": "БӀагал/кортале:",
        "minoredit": "ЗӀамига хувцам",
        "watchthis": "Зем бе укх оагӀон",
        "hiddencategories": "Ер оагIув {{PLURAL:$1|$1 къайла категориех|1=цаI къайла категорех}} я:",
        "permissionserrorstext-withaction": "Ер $2 де Хьа бокъо яц {{PLURAL:$1|1=из бахьан долаш|из бахьанаш долаш}}:",
        "recreate-moveddeleted-warn": "'''Зем бе! Шо хьалххе дIайоаккхаш хинна оагӀув хьае гӀерта.'''\n\nХьажа, бокъонцахь езаш йолга.\nКӀалхагIа укх оагӀуви дӀадаккхами цӀи хувцами тептараш хьекха да.",
-       "moveddeleted-notice": "Ер оагӀув дӀаяккха хиннай.\nНовкъостала, кӀалха хьахьекха да дӀадаккхама а хувцама а тептарашкара дIаяздараш.",
+       "moveddeleted-notice": "Ð\95Ñ\80 Ð¾Ð°Ð³Ó\80Ñ\83в Ð´Ó\80аÑ\8fккÑ\85а Ñ\85иннай.\nÐ\9dовкÑ\8aоÑ\81Ñ\82ала, ÐºÓ\80алÑ\85а Ñ\85Ñ\8cаÑ\85Ñ\8cекÑ\85а Ð´Ð° Ð´Ó\80адаккÑ\85ама Ð° Ñ\85Ñ\83вÑ\86ама Ð° Ñ\82епÑ\82аÑ\80аÑ\88каÑ\80а Ð´IаÑ\8fздаÑ\8cÑ\80аÑ\88.",
        "log-fulllog": "Деррига таптара бӀаргтасса",
        "edit-conflict": "Хувцамий къовсам.",
        "post-expand-template-inclusion-warning": "Зембаккхам: жамIан чIабалаш чулоаца дустам геттара доккха да.\nЦхьадола чIабалаш чулоацалургдац.",
        "post-expand-template-inclusion-category": "Чулоаца чIабала мегаш дола дустам дукхалена тӀехьайоала оагӀувнаш",
        "post-expand-template-argument-warning": "Зем бе! Ер оагӀув цаӀ куцкепа |аьлдош мара чулоацац, юхадастара сел доккха дустам йолаш.\nЦу тайпара |аьлдешаш ӀокӀаладаькха да.",
        "post-expand-template-argument-category": "Куцкепий теркамза |аьлдешаш чулоаца оагӀувнаш",
-       "viewpagelogs": "Укх оагӀон тептараш хьокха",
+       "viewpagelogs": "УкÑ\85 Ð¾Ð°Ð³Ó\80он Ñ\82епÑ\82аÑ\80аÑ\88 Ñ\85Ñ\8cаÑ\85Ñ\8cокÑ\85а",
        "currentrev-asof": "тӀеххьара верси $1",
        "revisionasof": "Верси $1",
        "revision-info": "Верси $1; {{GENDER:$6|$2}}$7",
        "deleteotherreason": "Кхыдола бахьан/тIатохар:",
        "deletereasonotherlist": "Кхыдола бахьан",
        "rollbacklink": "юхаяккха",
-       "protectlogpage": "Ð\9bоÑ\80адаÑ\80а тептар",
+       "protectlogpage": "Ð\93Iон тептар",
        "protectedarticle": "\"[[$1]]\" оагIув лорам деж я",
        "modifiedarticleprotection": "\"[[$1]]\" оагIувни лорама лагIа хувцаяьннай",
        "protectcomment": "Бахьан:",
index 760019f..09dec64 100644 (file)
        "doubleredirects": "Pangalihan dobel",
        "doubleredirectstext": "Kaca iki ngandhut daftar kaca sing ngalih ing kaca pangalihan liyané.\nSaben baris ngandhut pranala menyang pangalihan kapisan lan kapindho, sarta tujuan saka pangalihan kapindho, sing biasané kaca tujuan sing \"sajatiné\", yakuwi pangalihan kapisan kuduné dialihaké menyang kaca tujuan iku.\nJeneng sing wis <del>dicorèk</del> tegesé wis rampung didandani.",
        "double-redirect-fixed-move": "[[$1]] wis kapindhahaké, saiki dadi kaca peralihan menyang [[$2]]",
-       "double-redirect-fixed-maintenance": "Mbenakaké rong pangalihan saka [[$1]] nèng [[$2]].",
+       "double-redirect-fixed-maintenance": "Otomatis ndandani lih-lihan dhobel saka [[$1]] nyang [[$2]] nalika ana opèn-opènan.",
        "double-redirect-fixer": "Révisi pangalihan",
        "brokenredirects": "Pangalihan rusak",
        "brokenredirectstext": "Pengalihan ing ngisor iki tumuju menyang kaca sing ora ana:",
        "wantedtemplates": "Cithakan sing diperlokaké",
        "mostlinked": "Kaca sing kerep dhéwé dituju",
        "mostlinkedcategories": "Kategori sing kerep dhéwé dienggo",
-       "mostlinkedtemplates": "Cithakan sing kerep dhéwé dienggo",
+       "mostlinkedtemplates": "Kaca paling akèh transklusi",
        "mostcategories": "Kaca sing kategoriné akèh dhéwé",
        "mostimages": "Berkas sing kerep dhéwé dienggo",
        "mostinterwikis": "Halaman dengan interwiki terbanyak",
        "nopagetext": "Kaca sing panjenengan tuju ora ditemokaké.",
        "pager-newer-n": "{{PLURAL:$1|1 luwih anyar|$1 luwih anyar}}",
        "pager-older-n": "{{PLURAL:$1|1 sing luwih lawas|$1 sing luwih lawas}}",
-       "suppress": "Pangawas (''oversight'')",
+       "suppress": "Dhelikaké",
        "querypage-disabled": "Kaca kusus iki dipatèni kanggo alesan kinerja.",
        "apisandbox": "Kothak wedhi API",
        "apisandbox-api-disabled": "API dipatèni nèng situs iki.",
        "whatlinkshere-prev": "{{PLURAL:$1|sadurungé|$1 sadurungé}}",
        "whatlinkshere-next": "{{PLURAL:$1|sabanjuré|$1 sabanjuré}}",
        "whatlinkshere-links": "← pranala",
-       "whatlinkshere-hideredirs": "$1 lih-lihan",
-       "whatlinkshere-hidetrans": "$1 transklusi",
-       "whatlinkshere-hidelinks": "pranala-pranala $1",
-       "whatlinkshere-hideimages": "$1 pranala berkas",
+       "whatlinkshere-hideredirs": "Dhelikaké lih-lihan",
+       "whatlinkshere-hidetrans": "Dhelikaké transklusi",
+       "whatlinkshere-hidelinks": "Dhelikaké pranala",
+       "whatlinkshere-hideimages": "Dhelikaké pranala barkas",
        "whatlinkshere-filters": "Panyaringan",
        "autoblockid": "Blokir otomatis #$1",
        "block": "Blokir panganggo",
        "unblock": "Uculaké blokirané panganggo",
-       "blockip": "Blokir panganggo",
+       "blockip": "Palang {{GENDER:$1|panganggo}}",
        "blockip-legend": "Blokir panganggo",
        "blockiptext": "Enggonen formulir ing ngisor iki kanggo mblokir sawijining alamat IP utawa panganggo supaya ora bisa nyunting kaca.\nPrekara iki perlu dilakoni kanggo menggak vandalisme, lan miturut [[{{MediaWiki:Policy-url}}|kawicaksanan {{SITENAME}}]].\nLebokna alesan panjenengan ing ngisor iki (contoné njupuk conto kaca sing wis tau dirusak).",
        "ipaddressorusername": "Alamat IP utawa jeneng panganggo",
        "movenotallowedfile": "Panjenengan ora duwé hak kanggo mindhahaké berkas.",
        "cant-move-user-page": "Panjenengan ora nduwèni hak aksès kanggo mindhahaké kaca panganggo (kapisah saka anak-kaca).",
        "cant-move-to-user-page": "Panjenengan ora nduwèni hak aksès kanggo mindhahaké kaca menyang sawijining kaca panganggoa (kajaba menyang anak-kaca panganggo).",
-       "newtitle": "Menyang irah-irahan utawa judhul anyar:",
+       "newtitle": "Sesirah anyar:",
        "move-watch": "Awasna kaca iki",
        "movepagebtn": "Ngalih kaca",
        "pagemovedsub": "Bisa kasil dipindhahaké",
        "movenosubpage": "Kaca iki ora duwé anak-kaca.",
        "movereason": "Alesan:",
        "revertmove": "balèkaké",
-       "delete_and_move_text": "== Perlu mbusak ==\n\nArtikel sing dituju, \"[[:$1]]\", wis ana isiné.\nApa panjenengan kersa mbusak iku supaya kacané bisa dialihaké?",
+       "delete_and_move_text": "Kaca jujugan \"[[:$1]]\" wis ana.\nApa sampéyan kersa mbusak iku supaya kacané bisa dilih?",
        "delete_and_move_confirm": "Ya, busak kaca iku.",
        "delete_and_move_reason": "Dibusak kanggo jaga-jaga ananing pamindhahan saka \"[[$1]]\"",
        "selfmove": "Pangalihan kaca ora bisa dilakoni amerga irah-irahan utawa judhul sumber lan tujuané padha.",
        "move-leave-redirect": "Gawé pangalihan menyang irah-irahan anyar",
        "protectedpagemovewarning": "'''Pènget:''' Kaca iki wis dikunci dadi mung panganggo sing nduwé hak aksès pangurus baé sing bisa mindhahaké.\nCathetan entri pungkasan disadiakaké ing ngisor kanggo referensi:",
        "semiprotectedpagemovewarning": "'''Cathetan:''' Kaca iki wis direksa saéngga mung panganggo kadhaptar sing bisa mindhahaké.\nEntri cathetan pungkasan disadiakake ing ngisor kanggo referensi:",
-       "move-over-sharedrepo": "== Berkas wis ana ==\n[[:$1]] ana ing panyimpenan bebarengan. Mindhahaké berkas mawa judul iki bakal nibani berkas bebarengan.",
+       "move-over-sharedrepo": "[[:$1]] ana ing panyimpenan barengan. Ngalih barkas mawa sesirah iki bakal ngamblegi barkas barengan iku.",
        "file-exists-sharedrepo": "Jeneng berkas kapilih wis ana kanggo nèng panyimpenan bebarengan.\nMangga pilih jeneng liya.",
        "export": "Ekspor kaca",
        "exporttext": "Panjenengan bisa ngèkspor tèks lan sajarah panyuntingan sawijining kaca tartamtu utawa sawijining sèt kaca awujud XML tartamtu. Banjur iki bisa diimpor ing wiki liyané nganggo MediaWiki nganggo fasilitas [[Special:Import|impor kaca]].\n\nKanggo ngèkspor kaca-kaca artikel, lebokna irah-irahan utawa judhul sajroning kothak tèks ing ngisor iki, irah-irahan utawa judhul siji per baris, lan pilihen apa panjenengan péngin ngèkspor jangkep karo vèrsi sadurungé, utawa namung vèrsi saiki mawa cathetan panyuntingan pungkasan.\n\nYèn panjenengan namun péngin ngimpor vèrsi pungkasan, panjenengan uga bisa nganggo pranala kusus, contoné [[{{#Special:Export}}/{{MediaWiki:Mainpage}}]] kanggo ngèkspor artikel \"[[{{MediaWiki:Mainpage}}]]\".",
        "thumbnail_gd-library": "Konfigurasi pustaka GD ora pepak: fungsi $1 ilang",
        "thumbnail_image-missing": "Berkas katonané ilang: $1",
        "import": "Impor kaca",
-       "importinterwiki": "Impor transwiki",
+       "importinterwiki": "Impor saka wiki liya",
        "import-interwiki-text": "Pilih sawijining wiki lan irah-irahan kaca sing arep diimpor.\nTanggal révisi lan jeneng panyunting bakal dilestarèkaké.\nKabèh aktivitas impor transwiki bakal dilog ing [[Special:Log/import|log impor]].",
        "import-interwiki-history": "Tuladen kabèh vèrsi lawas saka kaca iki",
        "import-interwiki-templates": "Katutna kabèh cithakan",
        "duplicate-defaultsort": "Pènget: Kunci pilih asal (''Default sort key'') \"$2\" nggantèkaké kunci pilih asal sadurungé \"$1\".",
        "version": "Versi",
        "version-extensions": "Èkstènsi sing wis diinstalasi",
-       "version-skins": "Kulit",
+       "version-skins": "Kulit sing disetèl",
        "version-specialpages": "Kaca astaméwa (kaca kusus)",
        "version-parserhooks": "Canthèlan parser",
        "version-variables": "Variabel",
        "version-entrypoints": "URL tithik lebon",
        "version-entrypoints-header-entrypoint": "Tithik lebon",
        "version-entrypoints-header-url": "URL",
-       "redirect": "Dialihake dening gambar, panganggo, kaca, utawa ID revisi",
+       "redirect": "Lih-lihan miturut barkas, panganggo, kaca, owahan, utawa cathetan",
        "redirect-summary": "Kaca astamiwa iki dialihake menyang gambar (jeneng gambar diwenehi), kaca (ID revisi utama ID kaca diwenehi), utawa kaca panganggo (ID panganggo diwenehi). Cara nganggo: [[{{#Special:Redirect}}/file/Example.jpg]], [[{{#Special:Redirect}}/page/64308]], [[{{#Special:Redirect}}/revision/328429]], or [[{{#Special:Redirect}}/user/101]].",
        "redirect-submit": "Nuju",
        "redirect-lookup": "Golek:",
        "api-error-badaccess-groups": "Sampéyan ora dililakaké ngunggah berkas nèng wiki iki.",
        "api-error-badtoken": "Kasalahan njero: Token èlèk.",
        "api-error-copyuploaddisabled": "Ngunggah saka URL dipatèni nèng sasana iki.",
-       "api-error-duplicate": "Ana {{PLURAL:$1|berkas liya|pirang-pirang berkas liya}} sing wis ana nèng situsé saha isiné padha.",
+       "api-error-duplicate": "Wis ana {{PLURAL:$1|barkas liya|barkas-barkas liya}} mawa isi sing padha sajeroning sana jaringan iki.",
        "api-error-duplicate-archive": "Ana {{PLURAL:$1|berkas liya|pirang-pirang berkas liya}} sing wis ana nèng situsé saha isiné padha, nanging {{PLURAL:$1|kuwi|kuwi kabèh}} wis dibusak.",
        "api-error-empty-file": "Berkas sing Sampéyan kirim kosong.",
        "api-error-emptypage": "Nggawé kaca kosong anyar ora dilikaké.",
        "special-characters-group-lao": "Lao",
        "special-characters-group-khmer": "Khmer",
        "api-error-blacklisted": "Mangga pilih judhul liya sing njelasaké",
-       "randomrootpage": "Kaca root waton"
+       "randomrootpage": "Kaca dhasaran waton"
 }
index e9a314c..b4893dd 100644 (file)
        "right-managechangetags": "데이터베이스에서 [[Special:Tags|태그]]를 만들거나 지우기",
        "right-applychangetags": "자신이 편집할 때 [[Special:Tags|태그]]를 적용하기",
        "right-changetags": "문서의 특정 판과 특정 기록 항목에 임의의 [[Special:Tags|태그]]를 추가하거나 제거하기",
+       "right-deletechangetags": "데이터베이스에서 [[Special:Tags|태그]]를 지우기",
        "grant-generic": "\"$1\" 권한 번들",
        "grant-group-page-interaction": "문서로 상호 작용",
        "grant-group-file-interaction": "미디어로 상호 작용",
        "action-viewmyprivateinfo": "자신의 개인정보 보기",
        "action-editmyprivateinfo": "자신의 개인정보 편집",
        "action-editcontentmodel": "문서의 콘텐츠 모델을 편집",
-       "action-managechangetags": "ë\8d°ì\9d´í\84°ë² ì\9d´ì\8a¤ì\97\90ì\84\9c í\83\9c그를 ë§\8cë\93¤ê±°ë\82\98 ì§\80ì\9a¸",
+       "action-managechangetags": "ë\8d°ì\9d´í\84°ë² ì\9d´ì\8a¤ì\97\90ì\84\9c í\83\9c그를 ë§\8cë\93¤ê±°ë\82\98 ì§\80ì\9a°ê¸°",
        "action-applychangetags": "당신이 편집할 때 태그를 적용하기",
        "action-changetags": "문서의 특정 판과 특정 기록 항목에 임의의 태그를 추가하거나 제거하기",
+       "action-deletechangetags": "데이터베이스에서 태그를 지우기",
        "nchanges": "$1개 {{PLURAL:$1|바뀜}}",
        "enhancedrc-since-last-visit": "{{PLURAL:$1|마지막 방문 이후}} $1개",
        "enhancedrc-history": "역사",
        "changecontentmodel-success-text": "[[:$1]]의 콘텐츠 종류가 변경되었습니다.",
        "changecontentmodel-cannot-convert": "[[:$1]]의 콘텐츠 모델이 $2의 모델로 전환될 수 없습니다.",
        "changecontentmodel-nodirectediting": "$1 콘텐츠 모델은 직접 편집을 지원하지 않습니다",
+       "changecontentmodel-emptymodels-title": "이용 가능한 콘텐츠 모델이 없음",
+       "changecontentmodel-emptymodels-text": "[[:$1]]의 콘텐츠가 임의의 종류로 전환될 수 없습니다.",
        "log-name-contentmodel": "콘텐츠 모델 변경 기록",
        "log-description-contentmodel": "페이지의 콘텐츠 모델과 관련된 행위",
        "logentry-contentmodel-new": "$1님이 비 기본값 \"$5\" 콘텐츠 모델을 사용해  $3 문서를 {{GENDER:$2|만들었습니다}}",
        "whatlinkshere-prev": "{{PLURAL:$1|이전|이전 $1개}}",
        "whatlinkshere-next": "{{PLURAL:$1|다음|다음 $1개}}",
        "whatlinkshere-links": "← 가리키는 문서 목록",
-       "whatlinkshere-hideredirs": "넘겨주기를 $1",
-       "whatlinkshere-hidetrans": "끼워넣기를 $1",
-       "whatlinkshere-hidelinks": "링크를 $1",
-       "whatlinkshere-hideimages": "파일 링크를 $1",
+       "whatlinkshere-hideredirs": "넘겨주기를 숨기기",
+       "whatlinkshere-hidetrans": "끼워넣기를 숨기기",
+       "whatlinkshere-hidelinks": "링크를 숨기기",
+       "whatlinkshere-hideimages": "파일 링크를 숨기기",
        "whatlinkshere-filters": "필터",
        "whatlinkshere-submit": "계속",
        "autoblockid": "자동 차단 #$1",
        "lockdbsuccesstext": "데이터베이스가 잠겼습니다.<br />\n관리가 끝나면 잊지 말고 [[Special:UnlockDB|잠금을 풀어]] 주세요.",
        "unlockdbsuccesstext": "데이터베이스 잠금 상태가 해제되었습니다.",
        "lockfilenotwritable": "데이터베이스 잠금 파일에 쓰기 권한이 없습니다.\n데이터베이스를 잠그거나 잠금 해제하려면, 웹 서버에서 이 파일의 쓰기 권한을 설정해야 합니다.",
+       "databaselocked": "데이터베이스가 이미 잠겨 있습니다.",
        "databasenotlocked": "데이터베이스가 잠겨 있지 않습니다.",
        "lockedbyandtime": "({{GENDER:$1|$1}} 사용자가 $2 $3에 잠금)",
        "move-page": "$1 이동",
        "timezone-local": "로컬",
        "duplicate-defaultsort": "<strong>경고:</strong> 기본 정렬 키 \"$2\"가 이전의 기본 정렬 키 \"$1\"를 덮어쓰고 있습니다.",
        "duplicate-displaytitle": "<strong>경고:</strong> \"$2\" 제목 표시는 기존의 표시되는 제목 \"$1\"을 덮어씁니다.",
+       "restricted-displaytitle": "<strong>경고:</strong> 표시하려는 제목 \"$1\"은(는) 문서의 실제 제목과 동일하지 않으므로 무시되었습니다.",
        "invalid-indicator-name": "<strong>오류:</strong> 문서 상태 표시기의 <code>name</code> 특성은 비어 있지 않아야 합니다.",
        "version": "버전",
        "version-extensions": "설치된 확장 기능",
        "tags-delete-not-found": "\"$1\" 태그가 존재하지 않습니다.",
        "tags-delete-too-many-uses": "\"$1\" 태그가 $2개 이상의 판에 적용되어 있으므로 삭제할 수 없습니다.",
        "tags-delete-warnings-after-delete": "\"$1\" 태그가 삭제되었으나 다음과 같은 $2개의 경고 태그가 발생하였습니다:",
+       "tags-delete-no-permission": "변경 태그를 삭제할 권한이 없습니다.",
        "tags-activate-title": "태그 활성화",
        "tags-activate-question": "\"$1\" 태그를 활성화하려고 합니다.",
        "tags-activate-reason": "이유:",
        "logentry-protect-protect-cascade": "$1님이 $3 문서를 {{GENDER:$2|보호했습니다}} $4 [연쇄적]",
        "logentry-protect-modify": "$1님이 $3 문서의 보호 수준을 {{GENDER:$2|바꾸었습니다}} $4",
        "logentry-protect-modify-cascade": "$1님이 $3 문서의 보호 수준을 {{GENDER:$2|바꾸었습니다}} $4 [연쇄적]",
-       "logentry-rights-rights": "$1님이 {{GENDER:$6|$3}}을(를) 위해 $3 사용자의 권한을 $4에서 $5(으)로 {{GENDER:$2|바꾸었습니다}}",
+       "logentry-rights-rights": "$1님이 $3 사용자의 권한을 $4에서 $5(으)로 {{GENDER:$2|바꾸었습니다}}",
        "logentry-rights-rights-legacy": "$1님이 $3 사용자의 권한을 {{GENDER:$2|바꾸었습니다}}",
        "logentry-rights-autopromote": "$1님이 권한을 자동적으로 $4에서 $5으로 {{GENDER:$2|바꾸었습니다}}",
        "logentry-upload-upload": "$1님이 $3 파일을 {{GENDER:$2|올렸습니다}}",
index ca96110..cbcf23c 100644 (file)
        "whatlinkshere-next": "de nächste {{PLURAL:$1||$1|noll}} zeije",
        "whatlinkshere-links": "← Links",
        "whatlinkshere-hideredirs": "De Ömleijdonge verschteijsche",
-       "whatlinkshere-hidetrans": "de Oproofe $1",
+       "whatlinkshere-hidetrans": "De Oproofe verschteijsche",
        "whatlinkshere-hidelinks": "De nommahle Lengks verschteijsche",
-       "whatlinkshere-hideimages": "$1 de Lengks op Datteihje",
+       "whatlinkshere-hideimages": "De Lengks op Datteihje verscheijsche",
        "whatlinkshere-filters": "Ußsööke",
        "whatlinkshere-submit": "Lohß jonn!",
        "autoblockid": "Automattesche Sperr Nommer $1",
index 374650e..146c5bf 100644 (file)
@@ -34,6 +34,7 @@
        "tog-watchdefault": "Säiten a Fichieren déi ech änneren op meng Iwwerwaachungslëscht derbäisetzen",
        "tog-watchmoves": "Säiten a Fichieren déi ech réckelen automatesch op meng Iwwerwaachungslëscht derbäisetzen",
        "tog-watchdeletion": "Säiten a Fichieren déi ech läschen op meng Iwwerwaachungslëscht derbäisetzen",
+       "tog-watchuploads": "Nei Fichieren déi ech eroplueden op meng Iwwerwaachungslëscht setzen",
        "tog-watchrollback": "Säiten déi ech zréckgesat hunn op meng Iwwerwaachungslëscht derbäisetzen",
        "tog-minordefault": "All Ännerungen automatesch als 'Kleng Ännerungen' markéieren.",
        "tog-previewontop": "Déi ''nach-net gespäichert Versioun'' iwwer der Ännerungsfënster weisen",
        "whatlinkshere-prev": "{{PLURAL:$1|vireg|vireg $1}}",
        "whatlinkshere-next": "{{PLURAL:$1|nächsten|nächst $1}}",
        "whatlinkshere-links": "← Linken",
-       "whatlinkshere-hideredirs": "Viruleedunge $1",
-       "whatlinkshere-hidetrans": "Agebonne Schabloune $1",
-       "whatlinkshere-hidelinks": "Linken $1",
-       "whatlinkshere-hideimages": "Linken op Fichiere $1",
+       "whatlinkshere-hideredirs": "Viruleedunge verstoppen",
+       "whatlinkshere-hidetrans": "Agebonne Schabloune verstoppen",
+       "whatlinkshere-hidelinks": "Linke verstoppen",
+       "whatlinkshere-hideimages": "Linken op Fichiere verstoppen",
        "whatlinkshere-filters": "Filteren",
        "whatlinkshere-submit": "Lass",
        "autoblockid": "Automatesch Spär #$1",
index bee6f50..bdd2440 100644 (file)
        "sectioneditnotsupported-text": "Je kunnen op disse zied gien seksies bewarken.",
        "permissionserrors": "Gien toestemming",
        "permissionserrorstext": "Je maggen of kunnen dit niet doon. De {{PLURAL:$1|reden|redens}} daorveur {{PLURAL:$1|is|bin}}:",
-       "permissionserrorstext-withaction": "Je hebben gien rech um $2, mit de volgende {{PLURAL:$1|reden|redens}}:",
+       "permissionserrorstext-withaction": "Je hebben gien recht um $2, mit de volgende {{PLURAL:$1|reden|redens}}:",
        "recreate-moveddeleted-warn": "'''Waorschuwing: je maken n zied an die eerder al vortedaon is.'''\n\nBedenk eerst of t neudig is um disse zied veerder te bewarken.\nVeur de dudelikheid steet hieronder  t vortdologboek en t herneumlogboek veur disse zied:",
        "moveddeleted-notice": "Disse zied is vortedaon.\nHieronder steet de informasie uut t vortdologboek en t herneumlogboek.",
        "log-fulllog": "t Hele logboek bekieken",
        "sorbsreason": "Joew IP-adres is op-eneumen as open proxyserver in de zwarte lieste van DNS die'w veur {{SITENAME}} gebruken.",
        "sorbs_create_account_reason": "Joew IP-adres is op-eneumen as open proxyserver in de zwarte lieste van DNS, die'w veur {{SITENAME}} gebruken.\nJe kunnen gien gebrukerszied anmaken.",
        "xffblockreason": "n IP-adres dat jie gebruken is eblokkeerd. Dit steet in de kop 'X-Forwarded-For'. De oorspronkelike reden veur de blokkerings is: $1",
-       "cant-see-hidden-user": "De gebruker die'j proberen te blokkeren is al eblokkeerd en verbörgen.\nUmda'j gien rech hebben um gebrukers te verbargen, ku'j de blokkering van de gebruker niet bekieken of bewarken.",
+       "cant-see-hidden-user": "De gebruker die'j proberen te blokkeren is al eblokkeerd en verbörgen.\nUmda'j gien recht hebben um gebrukers te verbargen, ku'j de blokkering van de gebruker niet bekieken of bewarken.",
        "ipbblocked": "Je kunnen gien aandere gebrukers (de)blokkeren, umda'j zelf eblokkeerd bin",
        "ipbnounblockself": "Je maggen je eigen niet deblokkeren",
        "lockdb": "Databanke blokkeren",
index 37e4a51..af66f5b 100644 (file)
        "oct": "Okt",
        "nov": "Nov.",
        "dec": "Dez",
+       "january-date": "$1. Januar",
+       "february-date": "$1. Februar",
+       "march-date": "$1. März",
+       "april-date": "$1. April",
+       "may-date": "$1. Mai",
+       "june-date": "$1. Juni",
+       "july-date": "$1. Juli",
+       "august-date": "$1. August",
+       "september-date": "$1. September",
+       "october-date": "$1. Oktober",
+       "november-date": "$1. November",
+       "december-date": "$1. Dezember",
        "pagecategories": "{{PLURAL:$1|Kategorie|Kategorien}}",
        "category_header": "Sieden in de Kategorie „$1“",
        "subcategories": "Ünnerkategorien",
        "hidetoc": "Nich wiesen",
        "collapsible-collapse": "Versteken",
        "collapsible-expand": "Wiesen",
+       "confirmable-yes": "Jo",
+       "confirmable-no": "Nee",
        "thisisdeleted": "Ankieken oder weerholen vun $1?",
        "viewdeleted": "$1 ankieken?",
        "restorelink": "{{PLURAL:$1|ene löschte Version|$1 löschte Versionen}}",
        "virus-scanfailed": "Scan hett nich klappt (Code $1)",
        "virus-unknownscanner": "Unbekannten Virenscanner:",
        "logouttext": "'''Du büst nu afmellt.'''\n\nDu kannst {{SITENAME}} nu anonym wiederbruken oder di ünner dissen oder en annern Brukernaam wedder <span class='plainlinks'>[$1 anmellen]</span>.\nDenk dor an, dat welk Sieden ünner Ümstänn noch jümmer so wiest warrn köönt, as wenn du anmellt weerst. Dat ännert sik, wenn du den Cache vun dien Browser leddig maakst.",
+       "welcomeuser": "Willkamen, $1!",
        "yourname": "Dien Brukernaam",
        "userlogin-yourname": "Brukernaam",
        "yourpassword": "Dien Passwoort",
        "expand_templates_ok": "Los",
        "expand_templates_remove_comments": "Kommentaren rutnehmen",
        "expand_templates_generate_xml": "XML-Parser-Boom wiesen",
-       "expand_templates_preview": "Vörschau"
+       "expand_templates_preview": "Vörschau",
+       "pagelang-language": "Spraak"
 }
index 53573c8..9ae9e26 100644 (file)
        "whatlinkshere-prev": "{{PLURAL:$1|vorige|vorige $1}}",
        "whatlinkshere-next": "{{PLURAL:$1|volgende|volgende $1}}",
        "whatlinkshere-links": "← koppelingen",
-       "whatlinkshere-hideredirs": "doorverwijzingen $1",
-       "whatlinkshere-hidetrans": "Transclusies $1",
-       "whatlinkshere-hidelinks": "koppelingen $1",
+       "whatlinkshere-hideredirs": "Verberg doorverwijzingen",
+       "whatlinkshere-hidetrans": "Verberg transclusies",
+       "whatlinkshere-hidelinks": "Verberg links",
        "whatlinkshere-hideimages": "Bestandskoppelingen $1",
        "whatlinkshere-filters": "Filters",
        "whatlinkshere-submit": "OK",
        "mw-widgets-titleinput-description-redirect": "doorverwijzing naar $1",
        "api-error-blacklisted": "Kies een andere, beschrijvende naam.",
        "sessionmanager-tie": "Het is niet mogelijk om meerdere authenticatietypen voor verzoeken te combineren: $1.",
-       "sessionprovider-generic": "$1 sessies",
-       "sessionprovider-mediawiki-session-cookiesessionprovider": "sessies gebaseerd op cookies",
+       "sessionprovider-generic": "$1-sessies",
+       "sessionprovider-mediawiki-session-cookiesessionprovider": "op cookies gebaseerde sessies",
        "sessionprovider-nocookies": "Cookies kunnen uitgeschakeld zijn. Zorg ervoor dat u cookies hebt ingeschakeld en probeer het opnieuw.",
        "randomrootpage": "Willekeurige hoofdpagina",
        "log-action-filter-block": "Soort blokkade:",
index d309c3c..99c5767 100644 (file)
        "right-override-export-depth": "Eksport stron wraz z linkowanymi do głębokości 5 linków",
        "right-sendemail": "Wysyłanie e‐maili do innych użytkowników",
        "right-passwordreset": "Sprawdzanie treści e‐maila o resetowaniu hasła",
-       "right-managechangetags": "Tworzenie i de(aktywowanie) [[Special:Tags|znaczników]]",
+       "right-managechangetags": "Tworzenie i (dez)aktywowanie [[Special:Tags|znaczników]]",
        "right-applychangetags": "Wprowadzanie [[Special:Tags|znaczników]] wraz z własnymi zmianami",
        "right-changetags": "Dodawanie i usuwanie dowolnych [[Special:Tags|znaczników]] z poszczególnych wersji i wpisów w rejestrze",
+       "right-deletechangetags": "Usuwanie [[Special:Tags|znaczników]] z bazy danych",
        "grant-group-page-interaction": "Interakcja ze stronami",
        "grant-group-file-interaction": "Interakcja z plikami multimedialnymi",
        "grant-group-watchlist-interaction": "Interakcja z listą obserwowanych",
index 44336ef..8fa5b74 100644 (file)
        "password-change-forbidden": "Error message shown when an external authentication source does not allow the password to be changed.",
        "externaldberror": "This message is thrown when a valid attempt to change the wiki password for a user fails because of a database error or an error from an external system.",
        "login": "{{Doc-special|UserLogin|unlisted=1}}\n{{Identical|Log in}}",
+       "login-security": "Used as the title of the login page when the user is already logged in but sent to reauthenticate before getting access to a feature with elevated security.",
        "nav-login-createaccount": "Shown to anonymous users in the upper right corner of the page. When you can't create an account, the message {{msg-mw|login}} is shown.\n{{Identical|Log in / create account}}",
        "loginprompt": "{{ignored}}",
        "userlogin": "Since 1.22 no longer used in core, but may still be used by extensions. DEPRECATED\n\n{{Identical|Log in / create account}}",
        "helplogin-url": "{{doc-important|Do not translate the namespace name <code>Help</code>.}}\nUsed as name of the page that provides information about logging into the wiki.\n\nUsed as a link target in the message {{msg-mw|Userlogin-helplink}}.",
        "userlogin-helplink2": "Label for a link to login help.\n\nSee example: [[Special:UserLogin]]\n\nSee also:\n* {{msg-mw|Helplogin-url}}",
        "userlogin-loggedin": "Used as warning on [[Special:UserLogin]] when the current user is already logged in.\n\nFollowed by the Login form.\n\nSee example: [[Special:UserLogin]].\n\nParameters:\n* $1 - user name (used for display and for gender support)\nSee also:\n* {{msg-mw|Mobile-frontend-userlogin-loggedin-register}}",
+       "userlogin-reauth": "Used as an explanatory message on [[Special:UserLogin]] when the user is redirected there to log in again when trying to use a security-sensitive page.\n\nParameters:\n* $1 - user name (used for display and for gender support)",
        "userlogin-createanother": "Used as label for the button on [[Special:UserLogin]] shown when the current user is already logged in.\n{{Identical|Create another account}}",
        "createacct-emailrequired": "Label in create account form for email field when it is required.\n\nSee also:\n* {{msg-mw|Createacct-emailoptional}}\n{{Identical|E-mail address}}",
        "createacct-emailoptional": "Label in vertical-layout create account form for email field when it is optional.\n\nSee example: [{{canonicalurl:Special:UserLogin|type=signup}} Special:UserLogin?type=signup]\n\nSee also:\n* {{msg-mw|Createacct-emailrequired}}",
        "createacct-email-ph": "Placeholder in vertical-layout create account form for email field.\n\nSee example: [{{canonicalurl:Special:UserLogin|type=signup}} Special:UserLogin?type=signup]",
        "createacct-another-email-ph": "Placeholder in create account form for email field when one user creates an account for another.",
-       "createaccountmail": "The label for the checkbox for creating a new account and sending the new password to the specified email address directly, as used on [[Special:UserLogin/signup]] when one user creates an account for another (if creating accounts by email is allowed).\n\nSee example: [{{canonicalurl:Special:UserLogin|type=signup}} Special:UserLogin?type=signup]",
+       "createaccountmail": "The label for the checkbox for creating a new account and sending the new password to the specified email address directly, as used on [[Special:CreateAccount]] when one user creates an account for another (if creating accounts by email is allowed).\n\nSee example: [{{canonicalurl:Special:UserLogin|type=signup}} Special:UserLogin?type=signup]",
+       "createaccountmail-help": "Account creation API help message for the <code>mailpassword</code> parameter.",
        "createacct-realname": "In vertical-layout create account form, label for field to enter optional real name.",
        "createaccountreason": "Since 1.22 no longer used in core, but may be used by some extensions. DEPRECATED\n\n{{Identical|Reason}}",
        "createacct-reason": "In create account form, label for field to enter reason to create an account when already logged-in.\n\nSee example: [{{canonicalurl:Special:UserLogin|type=signup}} Special:UserLogin?type=signup]\n{{Identical|Reason}}",
        "createacct-reason-ph": "Placeholder in vertical-layout create account form for reason field.\n\nSee example: [{{canonicalurl:Special:UserLogin|type=signup}} Special:UserLogin?type=signup]",
+       "createacct-reason-help": "Account creation API help message for the <code>reason</code> parameter.",
        "createacct-imgcaptcha-help": "{{Optional}} Optional help text in vertical-layout create account form for image CAPTCHA input field when repositioned by JavaScript.\n\nBlank by default.",
        "createacct-submit": "Submit button on vertical-layout create account form.\n\nSee example: [{{canonicalurl:Special:UserLogin|type=signup}} Special:UserLogin?type=signup]",
-       "createacct-another-submit": "Submit button of  [[Special:UserLogin/signup]] ([[Special:CreateAccount]]) when accessed by a registered user.\n\nThe original means \"create an account in addition to the one you already have\"; sometimes, but not always, it means you are going to \"Create the account on behalf of somebody else\" or \"Create account for another\".\n{{Identical|Create another account}}",
+       "createacct-another-submit": "Submit button of  [[Special:CreateAccount]] when accessed by a registered user.\n\nThe original means \"create an account in addition to the one you already have\"; sometimes, but not always, it means you are going to \"Create the account on behalf of somebody else\" or \"Create account for another\".\n{{Identical|Create another account}}",
+       "createacct-continue-submit": "Submit button on create account form in second and later steps of a multistep account creation.",
+       "createacct-another-continue-submit": "Submit button on create account form in second and later steps of a multistep account creation, when done by a registered user.",
        "createacct-benefit-heading": "In vertical-layout create account form, the heading for the section describing the benefits of creating an account. See example: [{{canonicalurl:Special:UserLogin|type=signup}} Special:UserLogin?type=signup]\n\nIf in your language you need to know the gender of the name for the wiki (which is the subject of the English sentence), please adapt the sentence as much as you need for your translation to fit.",
        "createacct-benefit-icon1": "In vertical-layout create account form, the CSS style for the div next to the first benefit. If you replace this you will need probably need to adjust CSS.\n\nUsed as a CSS class name.\n\nSee example: [{{canonicalurl:Special:UserLogin|type=signup&useNew=1}} Special:UserLogin?type=signup&useNew=1]",
        "createacct-benefit-head1": "In vertical-layout create account form, the text in the heading for the first benefit. Do not edit the magic word; if you replace it you will probably need to adjust CSS.\n\nFollowed by the message {{msg-mw|Createacct-benefit-body1}}.\n\nSee example: [{{canonicalurl:Special:UserLogin|type=signup&useNew=1}} Special:UserLogin?type=signup&useNew=1]",
        "nocookieslogin": "This message is displayed when someone tried to login, but the browser doesn't accept cookies.",
        "nocookiesfornew": "This message is displayed when the user tried to create a new account, but it failed the cross-site request forgery (CSRF) check. It could be blocking an attack, but most likely, the browser isn't  accepting cookies.",
        "nocookiesforlogin": "{{optional}}\nThis message is displayed when someone tried to login and the CSRF failed (most likely, the browser doesn't accept cookies).\n\nDefault:\n* {{msg-mw|Nocookieslogin}}",
+       "createacct-loginerror": "This message is displayed after a successful registration when there is a server-side error with logging the user in. This is not expected to happen.",
        "noname": "Error message.",
        "loginsuccesstitle": "The title of the page saying that you are logged in. The content of the page is the message {{msg-mw|Loginsuccess}}.\n{{Identical|Log in}}",
        "loginsuccess": "The content of the page saying that you are logged in. The title of the page is {{msg-mw|Loginsuccesstitle}}.\n\nParameters:\n* $1 - the name of the logged in user\n{{Gender}}",
        "createacct-another-realname-tip": "{{doc-singularthey}}\nUsed on the account creation form when creating another user's account. Similar to {{msg-mw|prefs-help-realname}}.\n{{Identical|Real name attribution}}",
        "pt-login": "Shown to anonymous users in the upper right corner of the page when they can't create an account (otherwise the message {{msg-mw|nav-login-createaccount}} is shown there).\n{{Identical|Log in}}",
        "pt-login-button": "Shown as the caption of the button at [[Special:UserLogin]].\n{{Identical|Log in}}",
+       "pt-login-continue-button": "Shown as the caption of the button at [[Special:UserLogin]] in second and later steps of a multipage login.",
        "pt-createaccount": "Used on the top of the page for logged out users, where it appears next to {{msg-mw|login}}, so consider making them similar.\n{{Identical|Create account}}",
        "pt-userlogout": "{{Doc-actionlink}}\n{{Identical|Log out}}",
        "pear-mail-error": "{{notranslate}}\nParameters:\n* $1 - error message which is returned by PEAR mailer.",
        "botpasswords-invalid-name": "Error message when a username lacking the separator character is passed to BotPassword. Parameters:\n* $1 - The separator character.",
        "botpasswords-not-exist": "Error message when a username exists but does not a bot password for the given \"bot name\". Parameters:\n* $1 - username\n* $2 - bot name",
        "resetpass_forbidden": "Used as error message in changing password. Maybe the external auth plugin won't allow local password changes.",
+       "resetpass_forbidden-reason": "Like {{msg-mw|resetpass_forbidden}} but the auth provider gave a reason.\n\nParameters:\n* $1 - reason given by auth provider",
        "resetpass-no-info": "Error message for [[Special:ChangePassword]].\n\nParameters:\n* $1 (unused) - a link to [[Special:UserLogin]] with {{msg-mw|loginreqlink}} as link description",
        "resetpass-submit-loggedin": "Button on [[Special:ResetPass]] to submit new password.\n\n{{Identical|Change password}}",
        "resetpass-submit-cancel": "Used on [[Special:ResetPass]].\n{{Identical|Cancel}}",
        "passwordreset-emailsentusername": "Used in [[Special:PasswordReset]].\n\nSee also:\n* {{msg-mw|Passwordreset-emailsent-capture}}\n* {{msg-mw|Passwordreset-emailerror-capture}}",
        "passwordreset-emailsent-capture": "Used in [[Special:PasswordReset]].\n\nSee also:\n* {{msg-mw|Passwordreset-emailsentemail}}\n* {msg-mw|Passwordreset-emailsentusername}}\n* {{msg-mw|Passwordreset-emailerror-capture}}",
        "passwordreset-emailerror-capture": "Error message displayed in [[Special:PasswordReset]] when sending an email fails. Parameters:\n* $1 - error message\n* $2 - username, used for GENDER\nSee also:\n* {{msg-mw|Passwordreset-emailsentemail}}\n* {msg-mw|Passwordreset-emailsentusername}}\n* {{msg-mw|Passwordreset-emailsent-capture}}",
+       "passwordreset-emailsent-capture2": "Used in [[Special:PasswordReset]].\n\nParameters:\n* $1 - number of accounts notified\n\nSee also:\n* {{msg-mw|Passwordreset-emailsentemail}}\n* {msg-mw|Passwordreset-emailsentusername}}\n* {{msg-mw|Passwordreset-emailerror-capture}}",
+       "passwordreset-emailerror-capture2": "Error message displayed in [[Special:PasswordReset]] when sending an email fails. Parameters:\n* $1 - error message\n* $2 - username, used for GENDER\n* $3 - number of accounts notified\n\nSee also:\n* {{msg-mw|Passwordreset-emailsentemail}}\n* {msg-mw|Passwordreset-emailsentusername}}\n* {{msg-mw|Passwordreset-emailsent-capture}}",
+       "passwordreset-nocaller": "Shown when a password reset was requested but the caller was not provided. This is an internal error.",
+       "passwordreset-nosuchcaller": "Shown when a password reset was requested but the username of the caller could not be resolved to a user. This is an internal error.\n\nParameters:\n* $1 - username of the caller",
+       "passwordreset-ignored": "Shown when password reset was unsuccessful due to configuration problems.",
+       "passwordreset-invalideamil": "Returned when the email address is syntatically invalid.",
+       "passwordreset-nodata": "Returned when no data was provided.",
        "changeemail": "Title of [[Special:ChangeEmail|special page]]. This page also allows removing the user's email address.",
        "changeemail-summary": "{{ignored}}",
        "changeemail-header": "Text of [[Special:ChangeEmail]].",
        "showpreview": "The text of the button to preview the page you are editing. See also {{msg-mw|showdiff}} and {{msg-mw|savearticle}} for the other buttons.\n\nSee also:\n* {{msg-mw|Showpreview}}\n* {{msg-mw|Accesskey-preview}}\n* {{msg-mw|Tooltip-preview}}\n{{Identical|Show preview}}",
        "showdiff": "Button below the edit page. See also {{msg-mw|Showpreview}} and {{msg-mw|Savearticle}} for the other buttons.\n\nSee also:\n* {{msg-mw|Showdiff}}\n* {{msg-mw|Accesskey-diff}}\n* {{msg-mw|Tooltip-diff}}\n{{Identical|Show change}}",
        "blankarticle": "Notice displayed once after the user tries to save an empty page.",
-       "anoneditwarning": "Shown when editing a page anonymously.\n\nParameters:\n* $1 – A link to log in, <nowiki>{{fullurl:Special:UserLogin|returnto={{FULLPAGENAMEE}}}}</nowiki>\n* $2 – A link to sign up, <nowiki>{{fullurl:Special:UserLogin/signup|returnto={{FULLPAGENAMEE}}}}</nowiki>\n\nSee also:\n* {{msg-mw|Mobile-frontend-editor-anonwarning}}",
+       "anoneditwarning": "Shown when editing a page anonymously.\n\nParameters:\n* $1 – A link to log in, <nowiki>{{fullurl:Special:UserLogin|returnto={{FULLPAGENAMEE}}}}</nowiki>\n* $2 – A link to sign up, <nowiki>{{fullurl:Special:CreateAccount|returnto={{FULLPAGENAMEE}}}}</nowiki>\n\nSee also:\n* {{msg-mw|Mobile-frontend-editor-anonwarning}}",
        "anonpreviewwarning": "See also:\n* {{msg-mw|Anoneditwarning}}",
        "missingsummary": "The text \"edit summary\" is in {{msg-mw|Summary}}.\n\nSee also:\n* {{msg-mw|Missingcommentheader}}\n* {{msg-mw|Savearticle}}",
        "selfredirect": "Notice displayed once after the user tries to create a redirect to the same article.",
        "undo-summary-username-hidden": "Edit summary for an undo action where the username of the old revision is hidden.\n\nParameters:\n* $1 - the revision ID being undone\nSee also:\n* {{msg-mw|Undo-summary}}",
        "cantcreateaccounttitle": "Used as title of the error message {{msg-mw|Cantcreateaccount-text}}.",
        "cantcreateaccount-text": "Used as error message, with the title {{msg-mw|Cantcreateaccounttitle}}.\n* $1 - target IP address\n* $2 - reason or {{msg-mw|Blockednoreason}}\n* $3 - username\nSee also:\n* {{msg-mw|Cantcreateaccount-range-text}}",
-       "cantcreateaccount-range-text": "Used as more detailed version of the {{msg-mw|Cantcreateaccount-text}} error message, with the title {{msg-mw|Cantcreateaccounttitle}}.\n* $1 - target IP address range\n* $2 - reason or {{msg-mw|Blockednoreason}}\n* $3 - username\n* $4 - current user's IP address",
+       "cantcreateaccount-range-text": "Used instead of the {{msg-mw|Cantcreateaccount-text}} when the block is a range block.\n* $1 - target IP address range\n* $2 - reason or {{msg-mw|Blockednoreason}}\n* $3 - username\n* $4 - current user's IP address",
        "createaccount-hook-aborted": "Placeholder message to return with API errors on account create; passes through the message from a hook {{notranslate}}",
        "viewpagelogs": "Link displayed in history of pages",
        "nohistory": "Message shown when there are no history to list. See [{{canonicalurl:x|action=history}} example history].\n----\nAlso used as title of error message when the feed is empty. See [{{canonicalurl:x|action=history&feed=atom}} example feed].\n\nSee the error message:\n* {{msg-mw|history-feed-empty}}",
        "timezone-local": "Label to indicate that a time is in the user's local timezone.\n{{Identical|Local}}",
        "duplicate-defaultsort": "See definition of [[w:Sorting|sort key]] on Wikipedia. Parameters:\n* $1 - old default sort key\n* $2 - new default sort key",
        "duplicate-displaytitle": "Warning shown when a page has its display title set multiple times. Parameters:\n* $1 - old display title\n* $2 - new display title",
+       "restricted-displaytitle": "Warning shown a display title is ignored because it is not equivalent to its actual title. Parameters:\n* $1 - the ignored display title",
        "invalid-indicator-name": "Warning shown when the [https://www.mediawiki.org/wiki/Help:Page_status_indicators &lt;indicator name=\"''unique-identifier''\">''content''&lt;/indicator>] parser tag is used incorrectly.",
        "version": "{{doc-special|Version}}\n{{Identical|Version}}",
        "version-summary": "{{doc-specialpagesummary|version}}",
        "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.",
+       "authform-nosession-login": "Error message shown when the login was successful, but the session could not be reestablished on the next request. $1 is an explanation which depends on what session handler is being used, such as {{msg-mw|sessionprovider-nocookies}}.",
+       "authform-nosession-signup": "Error message shown when the account creation was successful, but the session could not be reestablished on the next request. $1 is an explanation which depends on what session handler is being used, such as {{msg-mw|sessionprovider-nocookies}}.",
+       "authform-newtoken": "Error message shown on the auth form when the session has no CSRF token. This can be caused by session expiry but it is more likely that the client does not support sessions for some reason (e.g. a browser with all cookies diabled). $1 is an explanation (in the form of full sentences) given by the session provider of why sessions might not work (usually this will be {{msg-mw|sessionprovider-nocookies}}).",
+       "authform-notoken": "Error message shown on the auth form when the submitted data has no CSRF token.",
+       "authform-wrongtoken": "Error message shown on the auth form when the submitted CSRF token value is invalid.",
+       "specialpage-securitylevel-not-allowed-title": "Error page title shown when the user visits a special page but the authentication security check fails.",
+       "specialpage-securitylevel-not-allowed": "Error message shown when the user visits a special page but the authentication security check fails.",
+       "authpage-cannot-login": "Error message shown on authentication-related special pages when login cannot start. This is not supposed to happen unless the site is misconfigured.",
+       "authpage-cannot-login-continue": "Error message shown on authentication-related special pages when login cannot continue. This most likely means a session timeout.",
+       "authpage-cannot-create": "Error message shown on authentication-related special pages when account creation cannot start. This is not supposed to happen unless the site is misconfigured.",
+       "authpage-cannot-create-continue": "Error message shown on authentication-related special pages when account creation cannot continue. This most likely means a session timeout.",
+       "authpage-cannot-link": "Error message shown on authentication-related special pages when account linking cannot start. This is not supposed to happen unless the site is misconfigured.",
+       "authpage-cannot-link-continue": "Error message shown on authentication-related special pages when account linking cannot continue. This most likely means a session timeout.",
+       "cannotauth-not-allowed-title": "Title of the error page shown when the user tries t use an authentication-related page they should not have access to.",
+       "cannotauth-not-allowed": "Text of the error page shown when the user tries t use an authentication-related page they should not have access to.",
+       "changecredentials" : "Title of the special page [[Special:ChangeCredentials]] which allows changing authentication credentials (such as the password).",
+       "changecredentials-submit": "Used on [[Special:ChangeCredentials]].",
+       "changecredentials-submit-cancel": "Used on [[Special:ChangeCredentials]].\n{{Identical|Cancel}}",
+       "changecredentials-invalidsubpage": "Error message shown when using [[Special:ChangeCredentials]] with an invalid type.\n\nParameters:\n* $1 - subpage name.",
+       "changecredentials-success": "Success message after using [[Special:ChangeCredentials]].",
+       "removecredentials" : "Title of the special page [[Special:RemoveCredentials]] which allows removing authentication credentials (such as a two-factor token).",
+       "removecredentials-submit": "Used on [[Special:RemoveCredentials]].",
+       "removecredentials-submit-cancel": "Used on [[Special:RemoveCredentials]].\n{{Identical|Cancel}}",
+       "removecredentials-invalidsubpage": "Error message shown when using [[Special:RemoveCredentials]] with an invalid type.\n\nParameters:\n* $1 - subpage name.",
+       "removecredentials-success": "Success message after using [[Special:RemoveCredentials]].",
+       "credentialsform-provider": "Shown on [[Special:ChangeCredentials]]/[[Special:RemoveCredentials]] as the label for the authentication type (e.g. \"password\", \"English Wikipedia via OAuth\")",
+       "credentialsform-account": "Shown on [[Special:ChangeCredentials]]/[[Special:RemoveCredentials]] as the label for the account name",
+       "cannotlink-no-provider-title": "Error page title shown when the user visits [[Special:LinkAccounts]] but there is no external account provider that could be linked.",
+       "cannotlink-no-provider": "Error message shown when the user visits [[Special:LinkAccounts]] but there is no external account provider that could be linked.",
+       "linkaccounts": "Title of the special page [[Special:LinkAccounts]] which allows the user to connect the local user accounts with external ones such as Google or Facebook.",
+       "linkaccounts-success-text": "Text shown on top of the form after a successful action.",
+       "linkaccounts-submit": "Text of the main submit button on [[Special:LinkAccounts]] (when there is one)",
+       "unlinkaccounts": "Title of the special page [[Special:UnlinkAccounts]] which allows the user to remove linked remote accounts.",
+       "unlinkaccounts-success": "Account unlinking form success message"
 }
index e5cf8c9..446942d 100644 (file)
        "timezone-local": "Местное",
        "duplicate-defaultsort": "Внимание. Ключ сортировки по умолчанию «$2» переопределяет прежний ключ сортировки по умолчанию «$1».",
        "duplicate-displaytitle": "<strong>Внимание:</strong> Отображаемое название «$2» переопределяет ранее заданное отображаемое название «$1».",
+       "restricted-displaytitle": "<strong>Внимание:</strong> Отображаемое название «$1» было проигнорировано, поскольку она не соответствует актуальному названию страницы.",
        "invalid-indicator-name": "<strong>Ошибка:</strong> Атрибут <code>name</code> индикаторов состояния страницы не должен быть пустым.",
        "version": "Версия",
        "version-extensions": "Установленные расширения",
index cc8a3c8..b1e497d 100644 (file)
        "timezone-local": "Krajevno",
        "duplicate-defaultsort": "'''Opozorilo:''' Privzeti ključ razvrščanja »$2« prepiše prejšnji privzeti ključ razvrščanja »$1«.",
        "duplicate-displaytitle": "<strong>Opozorilo:</strong> Prikazni naslov »$2« prepiše prejšnji prikazni naslov »$1«.",
+       "restricted-displaytitle": "<strong>Opozorilo:</strong> Prikazni naslov »$1« smo prezrli, saj ni enak dejanskemu naslovu strani.",
        "invalid-indicator-name": "<strong>Napaka:</strong> Atribut <code>name</code> indikatorjev stanja strani ne sme biti prazen.",
        "version": "Različica",
        "version-extensions": "Nameščene razširitve",
index faba454..843b413 100644 (file)
        "whatlinkshere-prev": "{{PLURAL:$1|претходни|претходних $1}}",
        "whatlinkshere-next": "{{PLURAL:$1|следећи|следећих $1}}",
        "whatlinkshere-links": "← везе",
-       "whatlinkshere-hideredirs": "$1 преусмерења",
-       "whatlinkshere-hidetrans": "$1 укључивања",
-       "whatlinkshere-hidelinks": "$1 везе",
+       "whatlinkshere-hideredirs": "Сакриј преусмерења",
+       "whatlinkshere-hidetrans": "Сакриј укључивања",
+       "whatlinkshere-hidelinks": "Сакриј везе",
        "whatlinkshere-hideimages": "$1 везе до датотеке",
        "whatlinkshere-filters": "Филтери",
        "whatlinkshere-submit": "Иди",
        "size-megabytes": "$1 MB",
        "size-gigabytes": "$1 GB",
        "lag-warn-normal": "Измене новије од $1 {{PLURAL:$1|секунде|секунде|секунди}} неће бити приказане.",
-       "lag-warn-high": "Због преоптерећења базе података, измене новије од $1 {{PLURAL:$1|секунда|секунде}} неће бити приказане.",
+       "lag-warn-high": "Због преоптерећења базе података, измене новије од $1 {{PLURAL:$1|1=секунде|секунде|секунди}} неће бити приказане.",
        "watchlistedit-normal-title": "Уређивање списка надгледања",
        "watchlistedit-normal-legend": "Уклањање наслова са списка надгледања",
        "watchlistedit-normal-explain": "Наслови на вашем списку надгледања су приказани испод.\nДа бисте уклонили наслов, означите квадратић до њега и кликните на „{{int:Watchlistedit-normal-submit}}“.\nМожете и да [[Special:EditWatchlist/raw|уредите сиров списак]].",
index 3879905..ad41fe9 100644 (file)
        "whatlinkshere-prev": "{{PLURAL:$1|prethodni|prethodnih $1}}",
        "whatlinkshere-next": "{{PLURAL:$1|sledeći|sledećih $1}}",
        "whatlinkshere-links": "← veze",
-       "whatlinkshere-hideredirs": "$1 preusmerenja",
-       "whatlinkshere-hidetrans": "$1 uključivanja",
-       "whatlinkshere-hidelinks": "$1 veze",
+       "whatlinkshere-hideredirs": "Sakrij preusmerenja",
+       "whatlinkshere-hidetrans": "Sakrij uključivanja",
+       "whatlinkshere-hidelinks": "Sakrij veze",
        "whatlinkshere-hideimages": "$1 veze do datoteke",
        "whatlinkshere-filters": "Filteri",
        "whatlinkshere-submit": "Idi",
        "size-megabytes": "$1 MB",
        "size-gigabytes": "$1 GB",
        "lag-warn-normal": "Izmene novije od $1 {{PLURAL:$1|sekunde|sekunde|sekundi}} neće biti prikazane.",
-       "lag-warn-high": "Zbog preopterećenja baze podataka, izmene novije od $1 {{PLURAL:$1|sekunde|sekundi}} neće biti prikazane.",
+       "lag-warn-high": "Zbog preopterećenja baze podataka, izmene novije od $1 {{PLURAL:$1|1=sekunde|sekunde|sekundi}} neće biti prikazane.",
        "watchlistedit-normal-title": "Uređivanje spiska nadgledanja",
        "watchlistedit-normal-legend": "Uklanjanje naslova sa spiska nadgledanja",
        "watchlistedit-normal-explain": "Naslovi na vašem spisku nadgledanja su prikazani ispod.\nDa biste uklonili naslov, označite kvadratić do njega i kliknite na „{{int:Watchlistedit-normal-submit}}“.\nMožete i da [[Special:EditWatchlist/raw|uredite sirov spisak]].",
index 7f197c3..5562be9 100644 (file)
        "autoredircomment": "Omdirigerar till [[$1]]",
        "autosumm-new": "Skapade sidan med '$1'",
        "autosumm-newblank": "Skapade tom sida",
-       "size-bytes": "$1 byte",
+       "size-bytes": "$1 {{PLURAL:$1|byte}}",
        "size-kilobytes": "$1 kbyte",
        "size-megabytes": "$1 Mbyte",
        "size-gigabytes": "$1 Gbyte",
+       "size-pixel": "$1 {{PLURAL:$1|bildpunkt|bildpunkter}}",
        "lag-warn-normal": "Ändringar under {{PLURAL:$1|den senaste sekunden|de $1 senaste sekunderna}} kanske inte visas i den här listan.",
        "lag-warn-high": "På grund av omfattande fördröjning i databasen visas kanske inte ändringar nyare än $1 {{PLURAL:$1|sekund|sekunder}} i den här listan.",
        "watchlistedit-normal-title": "Redigera bevakningslista",
        "timezone-local": "Lokal",
        "duplicate-defaultsort": "'''Varning:''' Standardsorteringsnyckeln \"$2\" tar över från den tidigare standardsorteringsnyckeln \"$1\".",
        "duplicate-displaytitle": "<strong>Varning:</strong> Visningstiteln \"$2\" skriver över den tidigare visningstiteln \"$1\".",
+       "restricted-displaytitle": "<strong>Varning:</strong> Visningstiteln \"$1\" ignorerades eftersom den inte motsvarar sidans riktiga titel.",
        "invalid-indicator-name": "<p>Fel:</strong> Sidstatus-indikatorernas <code>namn</code>-attributet får inte vara tomt.",
        "version": "Version",
        "version-extensions": "Installerade programtillägg",
        "log-action-filter-suppress-event": "Loggcensur",
        "log-action-filter-suppress-revision": "Sidversionscensur",
        "log-action-filter-suppress-delete": "Sidcensur",
+       "log-action-filter-suppress-block": "Användarcensur efter blockering",
+       "log-action-filter-suppress-reblock": "Användarcensur efter återblockering",
        "log-action-filter-upload-upload": "Ny uppladdning",
        "log-action-filter-upload-overwrite": "Återuppladdning"
 }
index 141e30d..f59fda4 100644 (file)
@@ -84,7 +84,8 @@
                        "HakanIST",
                        "Imabadplayer",
                        "İnternion",
-                       "Hbseren"
+                       "Hbseren",
+                       "Kumkumuk"
                ]
        },
        "tog-underline": "Bağlantıların altını çiz:",
        "whatlinkshere-prev": "{{PLURAL:$1|önceki|önceki $1}}",
        "whatlinkshere-next": "{{PLURAL:$1|sonraki|sonraki $1}}",
        "whatlinkshere-links": "← bağlantılar",
-       "whatlinkshere-hideredirs": "Yönlendirmeleri $1",
-       "whatlinkshere-hidetrans": "Dönüştürmeleri $1",
-       "whatlinkshere-hidelinks": "Bağlantıları $1",
+       "whatlinkshere-hideredirs": "Yönlendirmeleri gizle",
+       "whatlinkshere-hidetrans": "Dönüştürmeleri gizle",
+       "whatlinkshere-hidelinks": "Bağlantıları gizle",
        "whatlinkshere-hideimages": "Dosya bağlantılarını $1",
        "whatlinkshere-filters": "Süzgeçler",
        "autoblockid": "Otomatik engelleme #$1",
index 9080afa..0c7294f 100644 (file)
@@ -43,6 +43,7 @@
        "tog-watchdefault": "Мин үзгәрткән битләр һәм файллар күзәтү исемлегемә өстәлсен",
        "tog-watchmoves": "Мин күчергән битләр һәм файллар күзәтү исемлегемә өстәлсен",
        "tog-watchdeletion": "Мин бетергән битләр һәм файлларны күзәтү исемлегемгә өстәлсен",
+       "tog-watchuploads": "Минем тарафтан йөкләнелгән файлларны күзәтү исемлегемә кертергә",
        "tog-minordefault": "Барлык үзгәртүләрне килешү буенча кече дип билгеләнсен",
        "tog-previewontop": "Үзгәртү тәрәзәсеннән өстәрәк битне алдан карау өлкәсен күрсәтелсен",
        "tog-previewonfirst": "Үзгәртү битенә күчкәндә башта алдан карау бите күрсәтелсен",
@@ -59,6 +60,7 @@
        "tog-watchlisthidebots": "Бот үзгәртүләре күзәтү исемлегеннән яшерелсен",
        "tog-watchlisthideminor": "Кече үзгәртүләр күзәтү исемлегеннән яшерелсен",
        "tog-watchlisthideliu": "Авторизацияне узган кулланучыларның үзгәртүләре күзәтү исемлегеннән яшерелсен",
+       "tog-watchlistreloadautomatically": "Фильтр алмашкан очракта күзәтү исемлеген автоматик рәвештә яңартырга (JavaScript кирәк)",
        "tog-watchlisthideanons": "Аноним кулланучыларның үзгәртүләре күзәтү исемлегеннән яшерелсен",
        "tog-watchlisthidepatrolled": "Тикшерелгән үзгәртүләр күзәтү исемлегеннән яшерелсен",
        "tog-watchlisthidecategorization": "Битләрне төркемләшүне ябу",
        "userpage-userdoesnotexist": "«<nowiki>$1</nowiki>» исемле хисап язмасы юк. Сез чынлап та бу битне ясарга яисә үзгәртергә телисезме?",
        "userpage-userdoesnotexist-view": "\"$1\" исемле хисап язмасы юк.",
        "blocked-notice-logextract": "Бу кулланучы хәзергә тыелды.\nТүбәндә тыю көндәлегенең соңгы язу бирелгән:",
-       "clearyourcache": "<strong>Искәрмә:</strong> Сез саклаган үзгәртүләр кулланышка керсен өчен браузерыгызның кешын чистартырга туры киләчәк. \n* <strong>Firefox/Safari:</strong> Shift төймшсенә баскан килеш җиһазлар тасмасында <em>Яңарту (Обновить)</em> язуына басыгыз, яисә <em>Ctrl-F5</em> яки  ''Ctrl-R</em> (Mac өчен <em>⌘-R</em>) төймәләренә басыгыз\n* <strong>Google Chrome:</strong>  <em>Ctrl-Shift-R</em> (Mac өчен <em>⌘-Shift-R</em> ) төймәләренә басыгыз\n* <strong>Internet Explorer:</strong> <em>Ctrl</em>  төймәсенә баскан килеш  <em>Яңарту (Обновить)</em> язуына, яисә <em>Ctrl-F5</em> басыгыз\n* <strong>Opera:</strong> Менюдан кеш чистартуны сайлагыз: <em>Кораллар (Инструменты) → Көйләнмәләр (Настройки)</em>",
+       "clearyourcache": "<strong>Искәрмә:</strong> Сез саклаган үзгәртүләр кулланышка керсен өчен браузерыгызның кешын чистартырга туры киләчәк. \n* <strong>Firefox/Safari:</strong> Shift төймшсенә баскан килеш җиһазлар тасмасында <em>Яңарту (Обновить)</em> язуына басыгыз, яисә <em>Ctrl-F5</em> яки  ''Ctrl-R</em> (Mac өчен <em>⌘-R</em>) төймәләренә басыгыз\n* <strong>Google Chrome:</strong>  <em>Ctrl-Shift-R</em> (Mac өчен <em>⌘-Shift-R</em> ) төймәләренә басыгыз\n* <strong>Internet Explorer:</strong> <em>Ctrl</em>  төймәсенә баскан килеш  <em>Яңарту (Обновить)</em> язуына, яисә <em>Ctrl-F5</em> басыгыз\n* <strong>Opera:</strong> <em>Menu → Көйләнмәләр</em> (<em>Opera → Көйләнмәләр</em> на Mac) бүлегенә күчегез,  аннан <em>Куркынычсызлык → Керүләр тарыхын чистарту → Рәсемнәр кэшлау</em>",
        "usercssyoucanpreview": "'''Ярдәм:''' \"{{int:showpreview}} төймәсенә басып, яңа CSS-файлны тикшереп була.",
        "userjsyoucanpreview": "'''Ярдәм:''' \"{{int:showpreview}}\" төймәсенә басып, яңа JS-файлны тикшереп була.",
        "usercsspreview": "'''Бу бары тик CSS-файлны алдан карау гына, ул әле сакланмаган!'''",
        "recentchangesdays-max": "(иң күбе $1 {{PLURAL:$1|көн}})",
        "recentchangescount": "Төп буларак кулланучы үзгәртүләр саны:",
        "prefs-help-recentchangescount": "Үз өченә үзгәртүләрне, битләрнең тарихын һәм язлу көндәлеген дә кертә.",
+       "prefs-help-watchlist-token2": "Бу сезнең кузәтү исемлеге өчен ясалган веб-агымының серле ачкычы.\nАны белгән һәркем сезнең күзәтү исемлегегезне карый ала, шуңа да башкаларга аны күрсәтмәгез. [[Special:ResetTokens|Ачкычны ташларга теләсәгез, әлеге юрамага басыгыз]].",
        "savedprefs": "Көйләнмәләрегез сакланды.",
        "timezonelegend": "Сәгать поясы:",
        "localtime": "Җирле вакыт",
        "whatlinkshere-prev": "{{PLURAL:$1|1=алдагы}} $1",
        "whatlinkshere-next": "{{PLURAL:$1|1=киләсе}} $1",
        "whatlinkshere-links": "← сылтамалар",
-       "whatlinkshere-hideredirs": "Юнәлтүләрне $1",
-       "whatlinkshere-hidetrans": "Кертүләрне $1",
-       "whatlinkshere-hidelinks": "Сылтамаларны $1",
-       "whatlinkshere-hideimages": "$1 файл сылтамалары",
+       "whatlinkshere-hideredirs": "Юнәлтүләрне яшер",
+       "whatlinkshere-hidetrans": "Кертүләрне яшер",
+       "whatlinkshere-hidelinks": "Сылтамаларны яшер",
+       "whatlinkshere-hideimages": "Файл сылтамаларын яшер",
        "whatlinkshere-filters": "Фильтрлар",
        "whatlinkshere-submit": "Башкару",
        "autoblockid": "Автотыю #$1",
index d63feb5..8b3c913 100644 (file)
        "laggedslavemode": "Pahimatngon: It pakli bangin waray mga kabag-ohan nga bag-o.",
        "readonly": "Gintrankahan an database",
        "enterlockreason": "Pagbutang hin rason para han pagtrangka, upod hin banabana kon san-o kukuha-on an pagtrangka",
-       "readonlytext": "An database in nakatrangka yana ha bag-o nga mga entrada ngan iba nga mga modipikasyon, tungod siguro ha routine database maintenance, kahuman ini in mabalik ha normal.\n\nAn magdudumara nga nagtrangka hini in naghatag hini nga kasayoran: $1",
+       "readonlytext": "An database in nakatrangka yana ha bag-o nga mga entrada ngan iba nga mga modipikasyon, tungod siguro ha routine database maintenance, kahuman ini in mabalik ha normal.\n\nAn system administrator nga nagtrangka hini in naghatag hini nga kasayoran: $1",
        "missing-article": "Ini nga database in waray nakaagi han teksto han pakli nga dapat mabilngan, nga ginngaranan nga \"$1\" $2.\n\nIni in agsob hinungdan han pagsunod han kadaan nga kaibhan o sumpay han kaagi ngadto ha pakli nga ginpara.\n\nKun diri ini an kaso, bangin ka nakabiling hin bug ha software.\nAlayon la igsumat ini ha [[Special:ListUsers/sysop|administrator]], igsurat la an URL.",
        "missingarticle-rev": "(pagbag-o#: $1)",
        "missingarticle-diff": "(Kaibhan: $1, $2)",
        "protectedinterface": "Ini nga pakli in nahatag hin teksto hit interface para han software han hin nga wiki, ngan in pinasasaliporan para makalikay hit pag-abuso.\nPara makadugang o makaliwat hin mga paghubad para han tanan nga mga wiki, alayon paggamit han [//translatewiki.net/ translatewiki.net], an kanan MediaWiki proyekto hin lokalisasyon.",
        "editinginterface": "'''Pahimatngon:''' Imo ginliliwat an pakli nga gingagamit paghatag hin interface text para han software.\nAn mga pagbag-o hinin nga pakli in makakaapekto han itsura han user interface han iba nga mga gumaramit hini nga wiki.",
        "translateinterface": "Para han pagdugang o pagbag-o han mga paghubad han ngatanan nga mga wiki, alayon paggamit han [//translatewiki.net/ translatewiki.net], an MediaWiki lokalisasyon nga proyekto.",
-       "cascadeprotected": "Ini nga pakli in pinapasaliporan hin pagliwat tungod ini in nalalakip ha masunod nga {{PLURAL:$1|pakli, kun diin |mga pakli, kun diin}} pinapasaliporan hit \"cascading\" nga pagpili nga pinaandar:\n$2",
+       "cascadeprotected": "Ini nga pakli in pinapasaliporan tikang ha pagliwat tungod ini in naka-transclude ha masunod nga {{PLURAL:$1|pakli, kun diin |mga pakli, kun diin}} pinapasaliporan hit \"cascading\" nga pagpili nga pinaandar:\n$2",
        "namespaceprotected": "Diri ka gintutugutan pagliwat han mga pakli ha ngaran-lat'ang nga '''$1'''.",
        "customcssprotected": "Diri ka gintutugotan pagliwat hini nga CSS nga pakli, tungod nga nagsusulod ini hin kanan iba nga tawo personal nga karuyagon.",
        "customjsprotected": "Diri ka gintutugotan pagliwat hini nga JavaScript nga pakli, tungod nga nagsusulod ini hin kanan iba nga tawo personal nga karuyagon.",
        "mypreferencesprotected": "Waray nim pagtugot hit pagliwat hit imo mga karuyag.",
        "ns-specialprotected": "Diri maliliwat an mga ispisyal nga pakli.",
        "titleprotected": "Ini nga titulo pinasalipod ha paghimo ni [[User:$1|$1]].\nAn katadungan nga ginhatag amo in <em>$2</em>.",
-       "filereadonlyerror": "Diri maliliwat ini nga paypay \"$1\" tungod an ginsusudlan han paypay nga \"$2\" in aada la ha pagbasa-la nga kahimtang.\n\nAn magdudurmara nga nagtrangka hini in naghatag hini nga eksplenasyon: \"$3\".",
+       "filereadonlyerror": "Diri maliliwat ini nga paypay \"$1\" tungod an ginsusudlan han paypay nga \"$2\" in aada la ha pagbasa-la nga kahimtang.\n\nAn system administrator nga nagtrangka hini in naghatag hini nga eksplenasyon: \"$3\".",
        "invalidtitle-knownnamespace": "Titulo nga inbalido nga may pan-ngaran \"$2 ngan teksto nga \"$3\"",
        "invalidtitle-unknownnamespace": "Diri ginkakarawat nga titulo tungod mayda ini hin mga diri nakikilala nga ngaran-lat'ang ihap $1 ngan teksto \"$2\"",
        "exception-nologin": "Diri nakalog-in",
-       "exception-nologin-text": "Alayon [[Special:Userlogin|pagsakob]] basi makakadto hiní nga pakli o buruhatón.",
+       "exception-nologin-text": "Alayon paglog-in basi makakadto hiní nga pakli o buruhatón.",
        "exception-nologin-text-manual": "Alayon $1 basi makakadto hini nga pakli o buruhatón.",
        "virus-badscanner": "Maraot nga configuration: Waray kasabti nga virus scanner: ''$1''",
        "virus-scanfailed": "Pakyas an pag-scan (kodigo $1)",
        "noemail": "Waray e-mail nga adres nga ginrekord para han nágámit \"$1\".",
        "noemailcreate": "Kinahanglan nim maghatag hin may hinungdan nga e-mail address",
        "passwordsent": "Uska bag-o nga password in ginpadangat ha e-mail address nga nakarehistro kan \"$1\".\nAlayon paglog-in utro kahuman mo makarawat ini.",
-       "blocked-mailpassword": "An imo IP address in ginpugong ha pag-edit, ngan tungod hini in diri gintutugotan paggamit han password recovery function para malikyan an abuso.",
+       "blocked-mailpassword": "An imo IP address in ginpugngan pag-edit. Para pugngan an pag-abuso, ini in diri tinutogotan paggamit hin password recovery function tikang hinin nga IP address.",
        "eauthentsent": "Mayda e-mail hin pagkumpirma nga ginpadará hini nga ginhatag nga e-mail adres.\n\nSan-o magatagán pa hin ibá nga e-mail it akwant, kinahanglan nimo sundon an mga tugon nga nahabutáng han email basi makumpirma nga imo gud itón akawnt.",
        "throttled-mailpassword": "Usa nga tigaman-pagnakob reset email in ginpadangat na, ha sakob han urhi nga  {{PLURAL:$1|oras|$1 ka mga oras}}.\nBasi diri ini maabuso, uusa la nga tigaman-panakob in igpapadangat kada {{PLURAL:$1|oras|$1 ka mga oras}}.",
        "mailerror": "Sayop han pagpadangat hin surat: $1",
        "botpasswords-insert-failed": "Pakyas han pagdugang han ngaran han bot nga \"$1\". Naidugang na ini?",
        "botpasswords-update-failed": "Pakyas han pag-update han bot nga ngaran nga \"$1\". Ginpara na ini?",
        "botpasswords-created-title": "Nahimo an bot password",
-       "botpasswords-created-body": "An bot password nga \"$1\" in malinamposon nga nahimo.",
+       "botpasswords-created-body": "An bot password para han bot nga ngaran nga \"$1\" ni gumaramit \"$2\" in  nahimo.",
        "botpasswords-updated-title": "Gin-update an bot password",
-       "botpasswords-updated-body": "An bot password nga \"$1\" in malinamposon nga na-update.",
+       "botpasswords-updated-body": "An bot password para han ngaran han bot nga \"$1\" ni gumaramit \"$2\" in na-update.",
        "botpasswords-deleted-title": "Ginpara an bot password",
-       "botpasswords-deleted-body": "An bot password nga \"$1\" in ginpara.",
+       "botpasswords-deleted-body": "An bot password para han bot nga ngaran nga \"$1\" ni gumaramit \"$2\" in ginpara.",
        "botpasswords-newpassword": "An bag-o nga password para han pag log-in han <strong>$1</strong> in <strong>$2</strong>. <em>Alayon igrecord ini para han future reference.</em>",
        "botpasswords-no-provider": "BotPasswordsSessionProvider in waray dinhi.",
        "botpasswords-restriction-failed": "An mga restriction han bot password in nagpupugong han pag-login hinin.",
        "resetpass-no-info": "Kinahanglan mo paglog-in para direkta ka makasakob dinhi nga pakli.",
        "resetpass-submit-loggedin": "Igbal-iw an tigaman-pagsulod",
        "resetpass-submit-cancel": "Pasagdi",
-       "resetpass-wrong-oldpass": "Diri balido an temporaryo o yana nga tigaman-panakob.\nImo malinamposon nga ginsalyuan an imo tigaman-panakob o umaro ka na hin bag-o nga temporaryo nga tigman-panakob.",
+       "resetpass-wrong-oldpass": "Diri balido an temporaryo o yana nga password.\nImo na ginsalyuan an imo password o umaro ka na hin bag-o nga temporaryo nga password.",
        "resetpass-recycled": "Alayon pagreset han imo tigaman-pansakob hin lain tikang han imo yanâ nga tigaman-pansakob",
        "resetpass-temp-emailed": "Nagsakob ka pinaagi hin temporary nga gin-email nga kodigo.\nBasi matapos an imo pagsakob, kinahanglan ka maghimo hin bag-o nga tigaman-pansakob dinhi:",
        "resetpass-temp-password": "Temporaryo nga tigaman-pagsakob:",
        "passwordreset-emailtitle": "Mga detalye han akawnt ha {{SITENAME}}",
        "passwordreset-emailtext-ip": "Mayda gumaramit (bangin hi ikaw, tikang han IP adres nga $1) nga naghangyo hin reset han imo tigaman-pansulod han {{SITENAME}} ($4). An nasunod nga gumaramit {{PLURAL:$3|nga akawnt|nga mga akawnt}} nahanungod hini nga email nga adres: \n\n$2\n\n{{PLURAL:$3|Iní nga temporaryo nga tigaman-pansulod|Iní nga mga temporaryo nga tigaman-pansulod}} ma-waray bali hin {{PLURAL:$5|usa ka adlaw|$5 nga mga adlaw}}.\nAngay ka sumakob ngan pumílì hin bag-o nga tigaman-pansulod ha yanâ.  Kun mayda lain nga naghatag hini nga hangyo, o kun nahinumdoman mo an imo orihinal nga tigaman-pansulod, ngan nadírì ka na pagbalyo hiní, puyde mo pasagdan ini nga sumat ngan magpadayon hin paggamit han imo daan nga tigaman-pansulod.",
        "passwordreset-emailelement": "Agnay han gumaramit: \n$1\n\nTemporaryo nga tigaman han pagsakob: \n$2",
-       "passwordreset-emailsentemail": "Ginpadangat an password reset email.",
+       "passwordreset-emailsentemail": "Kun inin nga email address in may pagkahisumpay ha imo account, papadangaton ka hin usa nga password reset email.",
        "passwordreset-emailsent-capture": "Ginpadangat an password reset email, nga ginpakita ha ubos.",
        "passwordreset-emailerror-capture": "Ginhimo an password reset email, kun diin nakikita ha ubos, pero pakyas an pagpadara ha  {{GENDER:$2|gumaramit}}: $1",
        "changeemail": "Igliwat o igtanggal an e-mail address",
-       "changeemail-header": "Igliwan an e-mail address akawnt",
+       "changeemail-header": "Kompletoha ini nga form para masalyuan an imo email address. Kun karuyag nimo tanggalun an may pagkahisumpay han bisan ano nga email address tikang ha imo account, blankoha la an bag-o nga email address kun magsusumiti ka han form.",
        "changeemail-passwordrequired": "Kinahanglan nim igbutang an imo password para igkompirma inin nga pagbag-o.",
        "changeemail-no-info": "Kinahanglanon mo mag-log-in para ka direkta makasakob hini nga pakli.",
        "changeemail-oldemail": "Yana nga e-mail address:",
        "minoredit": "Gutiay ini nga pagliwat",
        "watchthis": "Bantayi ini nga pakli",
        "savearticle": "Igtipig an pakli",
+       "publishpage": "Igmantala an pakli",
        "preview": "Pahiuna nga pagawas",
        "showpreview": "Pakit-a an pahiuna nga pagawas",
        "showdiff": "Igpakita an mga ginliwat",
        "anonpreviewwarning": "''Diri ka naka-log in.  Mahisusurat an imo IP address ngada ha kanan pakli kaagi hit pagliwat kun igtipig nimo.''",
        "missingsummary": "<strong>Pahinumdom:</strong> Waray ka humatag hin halipotay nga masisiring hiton pagliwat. Kun pidliton mo an \"{{int:savearticle}}\" utro, an imo ginliwat in matitipig bisan waray hini.",
        "missingcommenttext": "Alayon pagbutang hin komento ha ilarom.",
-       "missingcommentheader": "'''Pahinumdom:''' Waray ka humatag hin subject/headline para hini nga komento.  Kun pinduton mo an \"{{int:savearticle}}\" utro, an imo pagliwat in matitipig bisan waray hini.",
+       "missingcommentheader": "<strong>Pahinumdom:</strong> Waray ka humatag hin subject para hinin nga komento.  Kun pinduton mo an \"{{int:savearticle}}\" utro, an imo pagliwat in matitipig bisan waray hini.",
        "summary-preview": "Pahiuna nga pagawas han dalikyat nga pulong:",
        "subject-preview": "Pahiuna nga pagawas hit himangrawan:",
        "blockedtitle": "Ginpugngan ini nga gumaramit",
        "mergehistory-into": "Kakadtoan nga pakli:",
        "mergehistory-submit": "Igtampo an mga rebisyon",
        "mergehistory-empty": "Waray mga rebisyon in puydi matampo.",
+       "mergehistory-fail-bad-timestamp": "Diri puydi an timestamp.",
+       "mergehistory-fail-invalid-source": "Diri puydi an ginkuhaan nga pakli.",
+       "mergehistory-fail-invalid-dest": "Diri puydi an kakadtoan nga pakli.",
+       "mergehistory-fail-self-merge": "Pareho an tinikangan ngan kakadtoan nga mga pakli.",
        "mergehistory-no-source": "Waray pa an tinikangan nga pakli nga $1.",
        "mergehistory-no-destination": "Waray pa an kakadtuan nga pakli nga $1.",
+       "mergehistory-invalid-source": "An tinikangan nga pakli in dapat may-ada valid title.",
+       "mergehistory-invalid-destination": "An kakadtoan nga pakli in kinahanglan may-ada valid title.",
        "mergehistory-autocomment": "Gintampo an [[:$1]] tipakadto ha [[:$2]]",
        "mergehistory-comment": "Gintampo an [[:$1]] ngada ha [[:$2]]: $3",
        "mergehistory-same-destination": "An gintikangan ngan kakadtoan nga mga pakli in diri puydi magkaparo",
        "revertmerge": "Igbulag an gintampo",
        "history-title": "Kaagi han pagbag-o han ''$1''",
        "difference-title": "An pagkakaiba han mga rebisyon han \"$1\"",
+       "difference-title-multipage": "An pagkakaiba ha butnga han mga pakli \"$1\" ngan \"$2\"",
        "difference-multipage": "(Kaibhan ha butnga han mga pakli)",
        "lineno": "Bagis $1:",
        "compareselectedversions": "Igkumpara an mga pinili nga pagbabag-o",
        "prefs-watchlist-token": "Token hin talaan hin barantayon:",
        "prefs-misc": "Dirudilain",
        "prefs-resetpass": "Igliwan an tigaman-pagsulod",
-       "prefs-changeemail": "Igliwan an e-mail address",
+       "prefs-changeemail": "Igliwan o tatanggalon an e-mail address",
        "prefs-setemail": "Igbutang an email address",
        "prefs-email": "Mga pagpipilian han e-mail",
        "prefs-rendering": "Hitsura",
        "yournick": "Bag-o nga pirma:",
        "badsiglength": "Hilaba hin duro it im pirma.\nDapat diri malabaw ha $1 {{PLURAL:$1|agi|mga agi}} nga kahilaba.",
        "yourgender": "Ano an karuyag mo nga pangilal-an?",
-       "gender-unknown": "Karuyag ko diri la magyakan",
+       "gender-unknown": "Kun ikaw in mangangaranan, an software in magamit hin gender neutral words kun mahihimo",
        "gender-male": "Hiya in nag-aayad hin mga wiki nga pakli",
        "gender-female": "Hiya in nag-aayad hin mga wiki nga pakli",
        "email": "E-mail",
-       "prefs-help-realname": "Opsyonal an tinuod nga ngaran.\nKun pilion mo nga ihatag, ini in gagamiton ha paghatag hin atribusyon ha imo mga buhat.",
+       "prefs-help-realname": "Opsyonal an paggamit hin tinuod nga ngaran.\nKun ihatag, ini in puydi magamit paghatag hin pagkilala ha imo mga buhat.",
        "prefs-help-email": "Diri pinipirit it pagbutang hin E-mail address, pero kinahanglan ini para hin pag-utro hin tigaman-hit-pagsulod (''password''), ngan kun mangalimot ka hit imo tigaman-hit-pagsulod.",
        "prefs-help-email-others": "Puydi mo pilion nga it iba in makakontak ha imo gamit an e-mail pinaagi han sumpay ha imo gumaramit o hiruhimangraw nga pakli.\nAn imo e-mail address in diri makikit-an kun an iba nga mga gumaramit in makontak ha imo.",
        "prefs-help-email-required": "Kinahanglanon it e-mail address.",
        "userrights": "Pagdudumara hin mga katungod han gumaramit",
        "userrights-lookup-user": "Pagdumaraa han mga hugpo han gumaramit",
        "userrights-user-editname": "Igbutang an agnay han gumaramit:",
-       "editusergroup": "Igliwat han mga hugpo han gumaramit",
-       "editinguser": "Igliliwat an mga katungod han gumaramit han gumaramit '''[[User:$1|$1]]''' $2",
+       "editusergroup": "Igliwat an mga hugpo han {{GENDER:$1|gumaramit}}",
+       "editinguser": "Ginsasaliwanan an katungod-han-gumaramit ni {{GENDER:$1|gumaramit}} <strong>[[User:$1|$1]]</strong> $2",
        "userrights-editusergroup": "Igliwat an mga hugpo hin gumaramit",
-       "saveusergroups": "Igtipig an mga hugpo han gumaramit",
+       "saveusergroups": "Igtipig an mga hugpo han {{GENDER:$1|gumaramit}}",
        "userrights-groupsmember": "Api han:",
        "userrights-groupsmember-auto": "Api nga daan han:",
        "userrights-reason": "Katadungan:",
        "userrights-notallowed": "Waray nim pagtugot hin pagdugang o pagtanggal hin mga katungod han gumaramit.",
        "userrights-changeable-col": "Mga hugpo nga puydi mo labtan",
        "userrights-unchangeable-col": "Mga hugpo nga diri mo puydi labtan",
-       "userrights-removed-self": "Malinamposon nim gintanggal an imo kalugaringon mga katungod. Tungod hito, diri ka na makaka-access hini nga pakli.",
+       "userrights-removed-self": "Imo gintanggal an imo kalugaringon mga katungod. Tungod hito, diri kana makaka-access hinin nga pakli.",
        "group": "Hugpo:",
        "group-user": "Mga gumaramit",
        "group-autoconfirmed": "Mga gumaramit nga lugaring nakokonpirma",
        "group-suppress": "Mga suppressor",
        "group-all": "(ngatanan)",
        "group-user-member": "{{HENERO:$1|gumaramit}}",
+       "group-autoconfirmed-member": "{{GENDER:$1|autoconfirmed user}}",
        "group-bot-member": "bot",
        "group-sysop-member": "magdudumara",
        "group-bureaucrat-member": "{{GENDER:$1|burokrata}}",
-       "group-suppress-member": "{{GENDER:$1|magmarangno}}",
+       "group-suppress-member": "{{GENDER:$1|suppressor}}",
        "grouppage-user": "{{ns:project}}:Mga gumaramit",
        "grouppage-autoconfirmed": "{{ns:project}}:Mga gumaramit nga naka-awtokompirmado",
        "grouppage-bot": "{{ns:project}}:Mga bot",
        "grouppage-sysop": "{{ns:project}}:Mga magdudumara",
        "grouppage-bureaucrat": "{{ns:project}}:Mga burokrata",
-       "grouppage-suppress": "{{ns:project}}:Nanginginano",
+       "grouppage-suppress": "{{ns:project}}:Suppress",
        "right-read": "Igbasa an mga pakli",
        "right-edit": "Igliwat an mga pakli",
        "right-createpage": "Paghimo hin mga pakli (nga diri an mga hiruhimangraw nga mga pakli)",
        "right-import": "Man-aangbit hin mga pakli tikang ha iba nga mga wiki",
        "right-importupload": "Man-aangbit hin mga pakli tikang ha uska paypay nga iginkarga-pasaka",
        "right-patrol": "Igmarka an kanan iba mga pagliwat komo ginpatrolya na",
+       "right-unwatchedpages": "Pakit-a an lista han mga gintanggal an pagbantay nga mga pakli",
        "right-mergehistory": "Igtampo an kaagi han mga pakli",
        "right-userrights": "Igliwat an ngatanan nga mga katungod han gumaramit",
        "right-userrights-interwiki": "Igliwat an mga katungod han gumaramit han mga gumaramit ha iba nga mga wiki",
        "right-siteadmin": "Igtrangka ngan igrangka an database",
        "right-sendemail": "Padad-i hin e-mail ngada ha iba nga mga gumaramit",
+       "right-passwordreset": "Pakit-a an mga password reset email",
+       "right-deletechangetags": "Igpara an [[Special:Tags|tags]] tikang han database",
+       "grant-generic": "mga katungod nga katitirok han \"$1\"",
+       "grant-group-page-interaction": "Pakig-interact han mga pakli",
+       "grant-group-file-interaction": "Pakig-interact hiton media",
+       "grant-group-watchlist-interaction": "Pakig-interact ha imo mga barantayan",
        "grant-group-email": "Padangat hin email",
        "grant-createaccount": "Pahimo hin mga account",
        "grant-createeditmovepage": "Paghimo, pagliwat, ngan pagbalhin hin mga pakli",
        "grant-delete": "Pagpara hin mga pakli, mga rebisyon, ngan mga iginsulod ha log",
+       "grant-editinterface": "Igliwat an MediaWiki namespace ngan kanan gumaramit CSS/JavaScript",
+       "grant-editmycssjs": "Igliwat an imo gumaramit nga CSS/JavaScript",
+       "grant-editmyoptions": "Igliwat an imo gumaramit nga mga karuyagon",
+       "grant-editmywatchlist": "Igliwat an imo barantayon",
+       "grant-editpage": "Igliwat an aada nga mga pakli",
+       "grant-editprotected": "Igliwat an mga pinansaliporan nga mga pakli",
+       "grant-highvolume": "High-volume nga pagliwat",
+       "grant-oversight": "Igtago an mga gumaramit ngan pag-suppress nga mga rebisyon",
+       "grant-patrol": "Igliwat an pagpatrolya ha mga pakli",
+       "grant-protect": "Igpanalipod ngan igtanggal an mga panalipod han mga pakli",
+       "grant-rollback": "Ig-rollback an mga pagliwat ngadto ha mga pakli",
        "grant-sendemail": "Igpadara hin email ngadto ha iba nga mga gumaramit",
        "grant-uploadeditmovefile": "Pagkarga, pagsaliwan, ngan pagbalhin hin mga file",
        "grant-uploadfile": "Pagkarga hin bag-o nga mga file",
        "action-createpage": "pahimo hin mga pakli",
        "action-createtalk": "Paghimo hin hiruhimangraw nga mga pakli",
        "action-createaccount": "Himoa ini nga akawnt hin gumaramit",
+       "action-autocreateaccount": "automatic nga hihimoon ini nga external user account",
+       "action-history": "kitaa an kaagi hinin nga pakli",
        "action-minoredit": "butanga hin tigaman hinin nga pagliwat komo gutiay",
        "action-move": "balhina ini nga pakli",
        "action-move-subpages": "igbalhin ini nga pakli, ngan iya mga bahin-pakli",
        "action-move-rootuserpages": "Igbalhin an gamot nga mga pakli han gumaramit",
+       "action-move-categorypages": "igbalhin an mga kaarangay nga pakli",
        "action-movefile": "igbalhin ini nga paypay",
        "action-upload": "igkarga-pasaka ini nga paypay",
        "action-reupload": "igsapaw ini nga aanhi nga paypay",
+       "action-reupload-shared": "ig-override ini nga file ha pinagsasaroan nga repositoryo",
        "action-upload_by_url": "igkaraga-pasaka ini nga paypay tikang ha uska URL",
+       "action-writeapi": "gamiti an write API",
        "action-delete": "paraa ini nga pakli",
        "action-deleterevision": "igpara ini nga pagbag-o",
        "action-deletedhistory": "kitaa an kanan hini nga pakli kaagi han mga ginpara",
        "action-browsearchive": "Pamiling hin mga ginpara nga mga pakli",
        "action-undelete": "Balika an ginpara hini nga pakli",
+       "action-suppressrevision": "ig-review ngan ig-restore inin nga nakatago nga rebisyon",
        "action-suppressionlog": "kitaa an kanan hini pribado nga talaan",
        "action-block": "Pugnga ini nga gumaramit ha pagliwat",
        "action-protect": "igsaliwan an katupngan han pananalipod para hini nga pakli",
+       "action-rollback": "dagmiti pag-rollback an mga pagliwat an kataposan nga gumaramit nga nagliwat hit usa ka partikular nga pakli",
        "action-import": "ig-angbit hin mga pakli tikang ha iba nga wiki",
        "action-importupload": "ig-angbit hin mga pakli pakli tikang ha uska ginkarga-pasaka nga paypay",
        "action-patrol": "markahi an kanan iba pagliwat komo nakapatrolya",
+       "action-autopatrol": "ig-marka nga ginpatrolyahan na an imo pagliwat",
+       "action-unwatchedpages": "kitaa an mga lista han gintanggal an pagbantay nga mga pakli",
        "action-mergehistory": "Igtampo an kaagi hini nga pakli",
        "action-userrights": "Igliwat an ngatanan nga mga katungod han gumaramit",
+       "action-userrights-interwiki": "igliwat an mga katungod han mga gumaramit ha iba nga mga wiki",
+       "action-siteadmin": "ig-lock o ig-unlock an database",
        "action-sendemail": "Padara hin mga e-mail",
        "action-editmywatchlist": "igliwat an imo watchlist",
        "action-viewmywatchlist": "kitaa an imo watchlist",
        "action-viewmyprivateinfo": "kitaa an imo pribado nga impormasyon",
        "action-editmyprivateinfo": "igliwat an imo pribado nga impormasyon",
        "action-editcontentmodel": "igliwat an content model han uska pakli",
-       "action-managechangetags": "himua ngan igpara na mga tag tikang ha database",
+       "action-managechangetags": "himoa ngan igpaandar/diri-paganahon an mga tag",
+       "action-applychangetags": "ig-apply an mga tag kaupod an imo mga pagliwat",
        "nchanges": "$1 {{PLURAL:$1|pagbag-o|mga pagbabag-o}}",
        "enhancedrc-history": "kasaysayan",
        "recentchanges": "Mga kabag-ohan",
        "recentchanges-legend-heading": "<strong>Leyenda:</strong>",
        "recentchanges-legend-newpage": "{{int:recentchanges-label-newpage}} (kitaa gihapon [[Special:NewPages|talaan han mga bag-o nga pakli]])",
        "recentchanges-submit": "Pakit-a",
-       "rcnotefrom": "An ha ubos in mga pagbabag-o tikang han <strong>$2</strong> (kutob ngadto ha <strong>$1</strong> nga ginpakita).",
+       "rcnotefrom": "Didi ha ubos amo {{PLURAL:$5|an pagbag-o|an mga pagbabag-o}} tikang<strong>$3, $4</strong> (tubtob <strong>$1</strong> nga ginpakita).",
        "rclistfrom": "Pakit-a an mga ginbag-ohan tikang han $3 $2",
        "rcshowhideminor": "$1 gudti nga mga pagliwat",
        "rcshowhideminor-show": "Pakit-a",
        "newpageletter": "B",
        "boteditletter": "b",
        "number_of_watching_users_pageview": "[$1 nagbabatay hin {{PLURAL:$1|gumaramit|mga gumaramit}}]",
-       "rc_categories_any": "Bisan ano nga",
+       "rc_categories_any": "Bisan ano nga pinili",
        "rc-change-size-new": "$1 {{PLURAL:$1|nga byte|nga mga byte}} kahuman han pagbag-o",
        "newsectionsummary": "/* $1 */ bag-o nga bahin",
        "rc-enhanced-expand": "Igpakita an detalye",
        "uploadnologintext": "Alayon $1 para han pag-upload han mga file.",
        "uploaderror": "Sayop hit pagkarga-pasaka",
        "upload-recreate-warning": "'''Pahimatngon:  An fayl nga may-ada hiton nga ngaran in ginpara o ginbalhin.'''\n\nAn taramdan han pagpara ngan pagbalhin para hini nga pakli in ginhahatag para han imo kamurayaw:",
-       "upload-permitted": "Gintutugotan nga mga klase han paypay: $1.",
-       "upload-preferred": "Karuyag nga mga tipo hin paypay: $1.",
-       "upload-prohibited": "Gindidire nga mga klase han paypay: $1.",
+       "upload-permitted": "Gintutugotan nga {{PLURAL:$2|klase|mga klase}} nga file: $1.",
+       "upload-preferred": "Mas karuyag nga {{PLURAL:$2|klase|mga klase}} hin file: $1.",
+       "upload-prohibited": "Gindidire nga {{PLURAL:$2|klase|mga klase}} hin file: $1.",
        "uploadlogpage": "Talaan han mga ginkarga-paigbaw",
        "filename": "Ngaran han fayl",
        "filedesc": "Dalikyat nga pulong",
        "filename-toolong": "Iton ngaran hin paypay in diri puyde na mas lapos pa ha 240 ka mga byte.",
        "badfilename": "An ngaran-han-paypay in ginliwat ngada ha \"$1\".",
        "empty-file": "An paypay nga imo ginsumite in waray sulod.",
+       "file-too-large": "An file nga imo ginhatag in sobra kadako.",
        "filename-tooshort": "An ngaran han fayl in halipot hin duro.",
        "filetype-banned": "Ini nga klase nga paypay in gindidire.",
+       "verification-error": "Ini nga pakli in waray nakapasar han file verification.",
+       "hookaborted": "An modipikasyon nga imo gintatalinguha nga himoon in gin-undang hin usa ka extension.",
        "illegal-filename": "An ngaran han fayl in diri gintutugutan.",
        "overwrite": "It pagsapaw han aada nga paypay in diri gintutugotan.",
        "unknown-error": "Nahitabo an waray kasasabti nga sayop.",
        "tmp-write-error": "Sayop ha pagsurat hin temporaryo nga paypay.",
        "large-file": "Ginrerekomenda nga it mga paypay in diri malapos hin $1;\nini nga paypay in $2.",
        "largefileserver": "Ini nga paypay in durudako kaysa ha ginpapakarawat han serbidor.",
+       "emptyfile": "An file nga imo gin-upload in baga waray sulod.\nIni in bangin tungod nagsayop pag-type han filename.\nAlayon kitaa kun imo karuyag gud nimo ig-upload inin nga file.",
        "windows-nonascii-filename": "Ini nga wiki in diri nakasuportado han mga ngaran-han-paypay nga may-ada pinaurog nga mga karakter.",
        "uploadwarning": "Pahimatngon han pagkarga paigbaw",
        "savefile": "Igtipig an paypay",
        "upload-proto-error": "Sayop nga protocol",
        "upload-file-error": "Sayop ha sulod",
        "upload-misc-error": "Waray kasasabti nga sayop hin pagkarga-paigbaw",
+       "upload-too-many-redirects": "An URL in nagsusulod hin damo hin duro nga mga redirect",
        "upload-http-error": "Mayda nahitabo nga sayop hin HTTP: $1",
        "upload-dialog-title": "Ig-upload an file",
        "upload-dialog-button-cancel": "Pasagda",
        "upload-dialog-button-upload": "Upload",
        "upload-form-label-infoform-title": "Mga detalye",
        "upload-form-label-infoform-name": "Ngaran",
+       "upload-form-label-infoform-description": "Deskripsyon",
        "upload-form-label-usage-title": "Paggamit",
        "upload-form-label-usage-filename": "Ngaran han file",
        "upload-form-label-own-work": "Buhat ko ini",
        "backend-fail-read": "Diri nababasahan han paypay nga \"$1\".",
        "backend-fail-create": "Diri nasusuratan an paypay nga \"$1\".",
        "backend-fail-maxsize": "Diri nasusuratan an paypay nga \"$1\" tungod nga mas dako ini kaysa hin {{PLURAL:\"$2|usa nga byte|$2 nga mga byte}}.",
-       "backend-fail-readonly": "An panluyo nga tiripigan nga \"$1\" in ha pagkayana in panbasa-la.  An rason nga ginhatag in: \"''$2''\"",
+       "backend-fail-readonly": "An storage backend nga \"$1\" in ha pagkayana read-only.  An rason nga ginhatag in: <em>$2</em>",
        "backend-fail-connect": "Diri nakakasumpay ha storage backend \"$1\".",
        "lockmanager-notlocked": "Waray ka rangka an \"$1\"; diri ini nakatrangka.",
        "lockmanager-fail-closelock": "Diri nakakasera han nakatrangka nga paypay para han \"$1\".",
        "lockmanager-fail-svr-acquire": "Diri nakakakarawat in mga trangka ha serbidor $1.",
        "lockmanager-fail-svr-release": "Diri nakakabul-iw in mga trangka ha serbidor $1.",
        "zip-wrong-format": "An espisipikado nga paypay in diri naka ZIP nga paypay.",
-       "uploadstash-errclear": "An paghawan han mga paypay in diri malinamposon.",
+       "uploadstash-errclear": "Pakyas an paghawan han mga file.",
        "uploadstash-refresh": "Igpalab-as utro an talaan hin mga paypay",
        "img-auth-accessdenied": "Diri gintutugutan makasulod",
        "img-auth-nofile": "Waray ngada an paypay nga \"$1\".",
+       "img-auth-streaming": "Nag ii-stream \"$1\".",
        "http-read-error": "HTTP maysayop ha pagbasa.",
        "http-timed-out": "Naubosan hin oras ha pagpaalayon ha HTTP.",
        "http-curl-error": "May sayop ha pagkuha hin URL: $1",
        "statistics-users": "Mga [[Special:ListUsers|gumaramit]] nga nakarehistro",
        "statistics-users-active": "Mga gumaramit nga nanggigios",
        "statistics-users-active-desc": "Mga gumaramit nga may-ada iginbuhat ha urhi nga {{PLURAL:$1|ka adlaw|$1 ka mga adlaw}}",
+       "pageswithprop-prop": "Ngaran han propyudad:",
        "pageswithprop-submit": "Kadto-a",
        "doubleredirects": "Mga doble nga redirekta",
        "double-redirect-fixer": "Mangangayad hin redirekta",
        "wantedtemplates": "Mga ginkikinahanglan nga batakan",
        "mostlinked": "Pinakadamo nga mga ginsumpayan nga pakli",
        "mostlinkedcategories": "Pinakadamo nga mga ginsumpayan nga kaarangay",
-       "mostlinkedtemplates": "Pinakadamo nga mga ginsumpayan nga batakan",
+       "mostlinkedtemplates": "Pinakana-transclude nga mga pakli",
        "mostcategories": "Mga paypay nga may-ada pinakadamo nga mga kaarangay",
        "mostimages": "Pinakadamo nga nahisumpayan nga mga paypay",
        "mostinterwikis": "Mga pakli nga may-ada pinakadamo nga mga interwiki",
        "longpages": "Haglaba nga mga pakli",
        "deadendpages": "Waray na kakadtoan nga mga pakli",
        "protectedpages": "Pinapasaliporan nga mga pakli",
+       "protectedpages-timestamp": "Timestamp",
+       "protectedpages-page": "Pakli",
+       "protectedpages-expiry": "Mahuhuman",
+       "protectedpages-performer": "Pinapasaliporan an gumaramit",
+       "protectedpages-params": "Mga parametro han pananalipod",
+       "protectedpages-reason": "Rason",
+       "protectedpages-submit": "Pagdisplay hin mga pakli",
+       "protectedpages-unknown-timestamp": "Waray kasabti",
+       "protectedpages-unknown-performer": "Waray magpasabot nga gumaramit",
        "protectedtitles": "Pinapasaliporan nga mga titulo",
+       "protectedtitles-submit": "Igpakita an mga titulo",
        "listusers": "Lista han mga gumaramit",
        "listusers-editsonly": "Igpakita la an mga gumaramit nga may-ada ginliwat",
        "listusers-creationsort": "Ginsusunodsunod pinaagi han paghimo nga petsa",
+       "listusers-desc": "Ig-ayos ha paubos nga orden",
        "usereditcount": "$1 {{PLURAL:$1|ka pagliwat|ka mga pagliwat}}",
        "usercreated": "{{GENDER:$3|Ginhimo}} han $1 ha $2",
        "newpages": "Bag-o nga mga pakli",
        "nopagetitle": "Waray sugad hito nga kakadtoan nga pakli",
        "pager-newer-n": "{{PLURAL:$1|burubag-o 1|burubag-o $1}}",
        "pager-older-n": "{{PLURAL:$1|durudaan 1|durudaan $1}}",
+       "suppress": "Ig-suppress",
+       "apihelp": "Pabulig hit API",
+       "apihelp-no-such-module": "Waray kahiagii an Module \"$1\"",
+       "apisandbox": "sandbox hit API",
+       "apisandbox-jsonly": "Kinahanglan hin JavaSript para ha paggamit han API sandbox.",
+       "apisandbox-fullscreen": "Igpahilawig an panel",
+       "apisandbox-fullscreen-tooltip": "Igpahilawig an sandbox panel para masudlan an browser window.",
        "apisandbox-unfullscreen": "Igpakita an pakli",
+       "apisandbox-unfullscreen-tooltip": "Igpaguti an sandbox panel, para an MediaWiki navigation link in mahikit-an.",
        "apisandbox-submit": "Paghimo hin request",
        "apisandbox-reset": "Hawana",
        "apisandbox-retry": "Utroha",
+       "apisandbox-loading": "Nagloload hin impormasyon para han API module nga \"$1\"...",
+       "apisandbox-load-error": "May sayop nga nahitabo samtang nagloload hin impormasyon para han API module nga \"$1\": $2",
+       "apisandbox-no-parameters": "Waray mga parametro ini nga API module.",
        "apisandbox-helpurls": "Mga sumpay hit pabulig",
        "apisandbox-examples": "Mga pananglitan",
        "apisandbox-dynamic-parameters": "Dugang nga mga parameter",
        "apisandbox-dynamic-parameters-add-label": "Dugngi hin parameter:",
        "apisandbox-dynamic-parameters-add-placeholder": "Ngaran hit parameter",
        "apisandbox-dynamic-error-exists": "May-ada na nakangaran nga \"$1\" nga parameter.",
+       "apisandbox-deprecated-parameters": "Naka-deprecate nga mga parametro",
        "apisandbox-results": "Mga resulta",
+       "apisandbox-request-url-label": "Ginpapaalayon nga URL:",
+       "apisandbox-request-time": "Oras nga naglabay han pagpaalayon : {{PLURAL:$1|$1 ms}}",
        "booksources": "Mga libro nga tinikangan",
        "booksources-search-legend": "Pamilnga an mga libro nga gintikangan",
        "booksources-search": "Bilnga",
        "specialloguserlabel": "Magburuhat:",
-       "speciallogtitlelabel": "iiguon (titulo o gumarami):",
+       "speciallogtitlelabel": "Hinihingyap nga (titulo o {{ns:user}}:ngaran-han-gumaramit para han gumaramit):",
        "log": "Mga talaan",
+       "logeventslist-submit": "Igpakita",
        "all-logs-page": "Ngatanan nga mga talaan panpubliko",
+       "checkbox-all": "Ngatanan",
+       "checkbox-none": "Waray",
+       "checkbox-invert": "Baliskara",
        "allpages": "Ngatanan nga mga pakli",
        "nextpage": "Sunod nga pakli ($1)",
        "prevpage": "Nahiuna nga pakli ($1)",
        "allpages-hide-redirects": "Igtago an mga redirekta",
        "cachedspecial-refresh-now": "Igkita an pinakaurhi.",
        "categories": "Mga kaarangay",
+       "categories-submit": "Igpakita",
        "categoriesfrom": "Igpakita in mga kaarangay nga natikang ha:",
        "deletedcontributions": "Mga ginpara nga mga ámot hin nágámit",
        "deletedcontributions-title": "Ginpara nga mga amot han nagamit",
        "listusers-noresult": "Waray gumaramit nga nahiagian.",
        "listusers-blocked": "(ginpugngan)",
        "activeusers": "Taramdan hin mga gumaramit nga nanggigios",
+       "activeusers-from": "Igpakita an mga gumaramit tikang ha:",
        "activeusers-hidebots": "Igtago an mga bot",
        "activeusers-hidesysops": "Igtago an mga magdudumara",
        "activeusers-noresult": "Waray gumaramit nga nahiagian.",
+       "activeusers-submit": "Igpakita an mga gumaramit nga nangigios",
        "listgrouprights": "Mga katungod han grupo hin gumaramit",
        "listgrouprights-summary": "An masunod nga uska talaan hin mga grupo hin gumaramit sumala hinin nga wiki, ngan an ira nahisusumpay nga paggamit nga katungod.  Bangin may-ada [[{{MediaWiki:Listgrouprights-helppage}}|dugang nga impormasyon]] mahiunong han indibiduwal nga mga katungod.",
        "listgrouprights-key": "Leyenda:\n* <span class=\"listgrouprights-granted\">Gintagan hin katungod</span>\n* <span class=\"listgrouprights-revoked\">Waray ginhatag an katungod</span>",
        "listgrouprights-removegroup-self": "Igtanggal an {{PLURAL:$2|grupo|mga grupo}} tikang ha kalugaringon nga akawnt: $1",
        "listgrouprights-addgroup-self-all": "Igdugang an ngatanan nga mga grupo ha kalugaringon nga akawnt",
        "listgrouprights-removegroup-self-all": "Igtanggal an ngatanan nga mga grupo tikang ha kalugaringon nga akawnt",
+       "listgrouprights-namespaceprotection-namespace": "Ngaran-lat'ang",
+       "listgrouprights-namespaceprotection-restrictedto": "(Mga) katungod han pagtugot ha gumaramit nga pagliwat",
+       "listgrants": "Mga iginhatag",
+       "listgrants-grant": "Iginhatag",
        "listgrants-rights": "Mga katungod",
        "trackingcategories": "Ginsusubay an mga kategorya",
+       "trackingcategories-msg": "Ginsusubay an kaarangay",
        "trackingcategories-name": "Ngaran han mensahe",
        "mailnologin": "Waray kakadtoan nga address",
        "mailnologintext": "Kinahanglan nimo nga [[Special:UserLogin|nakalog-in]] ngan may-ada balido nga email address ha imo[[Special:Preferences|mga preperensya]] para makapadangat hin email ngadto ha iba nga mga gumaramit.",
        "notanarticle": "Diri uska unod nga pakli",
        "notvisiblerev": "An urhi nga pagliwat han iba nga gumaramit in ginpara",
        "watchlist-details": "{{PLURAL:$1|$1 nga pakli|$1 nga mga pakli}} nga aada ha imo talaan nga binabantayan, diri bulag nga paglakip han mga hiruhimangraw-nga-pakli.",
-       "wlshowlast": "Igpakita an katapusan nga $1 nga mga oras $2 nga mga adlaw",
+       "wlshowlast": "Igpakita an katapusan nga $1 ka mga oras $2 ka mga adlaw",
        "watchlist-hide": "Tago-a",
        "watchlist-submit": "Pakit-a",
+       "wlshowtime": "Kaiha han oras ha pagdisplay:",
        "wlshowhideminor": "gudti nga mga pagliwat",
        "wlshowhidebots": "Mga bot",
        "wlshowhideliu": "Mga nakarehistro nga gumaramit",
        "wlshowhideanons": "Mga waray magpakilala nga gumaramit",
        "wlshowhidepatr": "Nakapatrolya na nga mga pagliwat",
        "wlshowhidemine": "ako mga pagliwat",
+       "wlshowhidecategorization": "Kategorisasyon han pakli",
        "watchlist-options": "Mga pirilian han talaan han binabantayan",
        "watching": "Ginbabantay...",
        "unwatching": "Diri na ginbabantay...",
        "deletepage": "Igpara an pakli",
        "confirm": "Kompirma",
        "excontent": "An sulod in: ''$1''",
-       "excontentauthor": "an sulod in: ''$1'' (ngan hi \"[[Special:Contributions/$2|$2]]\" la an nag-amot)",
+       "excontentauthor": "An nasusulod in: ''$1'', ngan hi \"[[Special:Contributions/$2|$2]]\" ([[User talk:$2|pakighimangraw]]) la an nag-amot",
        "exbeforeblank": "sulod san-o paghawan in: \"$1\"",
        "delete-confirm": "Igpara \"$1\"",
        "delete-legend": "Igpara",
+       "historyaction-submit": "Igpakita",
        "actioncomplete": "Malinampuson an ginbuhat",
        "actionfailed": "Napakyas an ginbuhat",
        "deletedtext": "Ginpara an \"$1\".\nKitaa an $2 para hin talaan han mga gibag-ohi nga mga ginpamara.",
        "deletereasonotherlist": "Lain nga katadungan",
        "deletereason-dropdown": "*Agsob nga rason hin pagpara\n** Spam\n** Bandalismo\n** Pagtalapas ha katungod hin pagtatag-iya (''copyright'')\n** Tugon han manunurat\n** Utod nga redirek",
        "delete-edit-reasonlist": "Igliwat an mga rason han pagpara",
+       "deleteprotected": "Diri nimo mapapara ini nga pakli tunod ini in ginpasaliporan.",
        "rollback": "Mga libot-pabalik nga pagliwat",
        "rollbacklink": "libot-pabalik",
        "rollbacklinkcount": "rollback $1 {{PLURAL:$1|ka pagliwat|ka mga pagliwat}}",
+       "rollbacklinkcount-morethan": "Igrollback hin labaw han $1 nga {{PLURAL:$1|pagliwat|mga pagliwat}}",
        "rollbackfailed": "Diri malinamposon an paglibot-pabalik",
+       "cantrollback": "Diri makakapabalik han pagliwat;\nan urhi nga nag-amot in amo la an awtor hinin nga pakli.",
+       "editcomment": "An halipotay nga masisiring hiunong han pagliwat in: <em>$1</em>.",
        "revertpage": "Ginpabalik an ginliwat ni [[Special:Contributions/$2|$2]] ([[User talk:$2|hiruhimangraw]]) ngadto ha urhi nga pagliwat ni [[User:$1|$1]]",
        "sessionfailure-title": "Pakyas an sesyon",
+       "changecontentmodel-title-label": "Titulo han pakli",
        "changecontentmodel-reason-label": "Rason:",
+       "changecontentmodel-submit": "Balyo-a",
        "protectlogpage": "Talaan han pinasaliporan",
        "protectedarticle": "pinasaliporan \"[[$1]]\"",
        "prot_1movedto2": "[[$1]] in ginbalhin ngadto ha [[$2]]",
        "restriction-move": "Balhina",
        "restriction-create": "Himo-a",
        "restriction-upload": "Igkarga-pasaka",
+       "restriction-level-sysop": "bug-os nga pinasaliporan",
+       "restriction-level-autoconfirmed": "tunga-tunga nga pinasaliporan",
        "restriction-level-all": "bisan ano nga katupngan",
        "undelete": "Igpakita an mga ginpara nga mga pakli",
+       "undeletepage": "Igpakita ngan igbalik-ha-pagkawara an mga pinara nga pakli",
+       "undeletepagetitle": "<strong>An masunod in nag-uupod hin mga pinara nga mga rebisyon han [[:$1|$1]]</strong>.",
+       "viewdeletedpage": "Igpakita an mga pinara nga pakli",
        "undeletelink": "igpakita/igbalik",
        "undeleteviewlink": "kitaa",
        "undeletecomment": "Katadungan:",
        "whatlinkshere-prev": "{{PLURAL:$1|nahiuna|nahiuna $1}}",
        "whatlinkshere-next": "{{PLURAL:$1|masunod|masunod $1}}",
        "whatlinkshere-links": "← mga sumpay",
-       "whatlinkshere-hideredirs": "$1 nga mga redirek",
-       "whatlinkshere-hidetrans": "$1 nga mga transklusyon",
-       "whatlinkshere-hidelinks": "$1 an mga sumpay",
-       "whatlinkshere-hideimages": "$1 an mga sumpay han paypay",
+       "whatlinkshere-hideredirs": "Igtago an mga redirect",
+       "whatlinkshere-hidetrans": "Igtago an mga tranclusion",
+       "whatlinkshere-hidelinks": "Igtago an mga sumpay",
+       "whatlinkshere-hideimages": "Igtago an mga sumpay han file",
        "whatlinkshere-filters": "Mga panara",
        "whatlinkshere-submit": "Kadto-a",
        "block": "Pugngi an gumaramit",
-       "blockip": "Pugngi an gumaramit",
+       "blockip": "Pugngi an{{GENDER:$1|gumaramit}}",
        "blockip-legend": "Pugngi an gumaramit",
        "ipaddressorusername": "IP address o agnay-hit-gumaramit:",
        "ipbexpiry": "Matitima an dulot:",
        "movenotallowedfile": "Waray ka pagtugot para makabalhin hin mga paypay.",
        "cant-move-user-page": "Diri ka gintutugotan pagbalhin hin mga pakli nga gumaramit (labot la tikang ha mga bahin-pakli).",
        "cant-move-to-user-page": "Diri ka gintutugotan pagbalhin hin uska pakli pakada ha uska pakli hin gumaramit (labot la pakadto ha usa nga bahin-pakli han gumaramit).",
-       "newtitle": "Para ha bag-o nga titulo:",
+       "cant-move-category-page": "Waray ka pagtugot hin pagbalhin han mga kaarangay nga pakli.",
+       "cant-move-to-category-page": "Waray ka pagtugot pagbalhin hin pakli ngada ha kaarangay nga pakli.",
+       "newtitle": "Bag-o nga titulo:",
        "move-watch": "Kitaa an tinikangan nga pakli ngan kakadtoan nga pakli",
        "movepagebtn": "Igbalhin an pakli",
        "pagemovedsub": "Malinamposon an pagbalhin",
        "movepage-moved": "'''\"$1\" in ginbalhin ngadto ha \"$2\"'''",
        "movepage-moved-redirect": "Nahimo an uska redirect.",
+       "movepage-moved-noredirect": "An paghimo hin redirect in nai-suppress.",
+       "articleexists": "May-ada na hiton nga ngaran nga pakli, o an ngaran nga imo pinili in diri balido.\nAlayon pagpili hin iba nga ngaran.",
+       "cantmove-titleprotected": "Didi ka makakabalhin hin pakli ngadi nga lokasyon tungod an bag-o nga titulo in pinasaliporan tikang ha paghimo.",
+       "movetalk": "Igbalhin an may pagkahisumpay nga pakighimangraw-nga-pakli",
        "move-subpages": "Balhina an mga bahin-pakli (tubtob ngadto ha $1)",
        "move-talk-subpages": "Balhina an mga bahin-pakli han pakli han hiruhimangraw (tubtob ngadto ha $1)",
        "movepage-page-exists": "An pakli nga $1 in aada na ngan diri ini lugaring nga masasapawan pagsurat.",
        "movepage-page-unmoved": "An pakli nga $1 in diri mababalhin ngadto ha $2.",
        "movelogpage": "Talaan han pagbalhin",
        "movelogpagetext": "Ubos hini in uska talaan han ngatanan nga nabalhin nga pakli",
+       "movesubpage": "{{PLURAL:$1|Ubos-pakli|Mga ubos-pakli}}",
+       "movesubpagetext": "Ini nga pakli in may-ada $1 nga {{PLURAL:$1|ubos-pakli|mga ubos-pakli}} nga ginpapakita ha ubos.",
        "movenosubpage": "Ini nga pakli in waray mga bahin-pakli.",
        "movereason": "Rason:",
        "revertmove": "igbalik",
+       "delete_and_move_text": "An destinasyon nga pakli nga \"[[:$1]]\" in may-ada na.\nKaruyag mo nga igpara ini para maghatag hin paagi para hini nga pagbalhin?",
        "delete_and_move_confirm": "Oo, paraa an pakli",
        "delete_and_move_reason": "Ginpara para makahatag hin aragian para makabalhin tikang ha \"[[$1]]\"",
        "selfmove": "An tinikangan ngan kakadtoan nga mga titulo in parehas;\ndiri makakabalhin ha iya kalugaringon ngahaw.",
        "immobile-target-namespace-iw": "An sumpay interwiki in diri balido nga irig-on para han pagbalhin hin pakli.",
        "immobile-source-page": "Diri mababalhin ini nga pakli.",
        "immobile-target-page": "Diri makakabalhin ha ngada nga kakadtoon nga titulo.",
+       "imagenocrossnamespace": "Diri nakakabalhin hin file ngadto ha non-file namespace.",
+       "nonfile-cannot-move-to-file": "Diri nakakabalhin hin non-file ngadto ha file namespace.",
        "imageinvalidfilename": "An kakadtoon nga ngaran-han-paypay in diri balido",
        "move-leave-redirect": "Pagbilin hin redirect",
        "export": "Mga pakli hit pagexport",
        "importbadinterwiki": "Maraot nga sumpay hit interwiki",
        "importsuccess": "Natapos an pag-aangbit!",
        "import-noarticle": "Waray pakli nga maaangbit!",
-       "import-token-mismatch": "Nawara an datos sesyon.\nAlayon utroha.",
-       "import-error-edit": "An pakli nga \"$1\" in waray naangbit tungod nga diri ka gintutugotan pagliwat hini.",
-       "import-error-create": "An pakli nga \"$1\" in waray naangbit tungod nga diri ka gintutugotan paghimo hini.",
-       "import-error-interwiki": "An pakli nga \"$1\" in waray naangbit tungod nga an ngaran in nakareserba para han pagsusumpay ha gawas (interwiki).",
-       "import-error-special": "An pakli nga \"$1\" in waray naangbit tungod nga nahihilakip ini ha uska pinaurog nga ngaran-lat'ang nga diri natugot hin mga pakli.",
-       "import-error-invalid": "An pakli nga \"$1\" in waray naangbit tungod nga it iya ngaran in diri balido.",
+       "import-token-mismatch": "Nawara an datos sesyon.\n\n\nBangin ka naglog-out na. <strong>Alayon igberipika kun ikaw in nakalog-in pa ngan utro buhata</strong>. Kun diri pa iton nagana, alayon [[Special:UserLogout|pag-log out]] ngan paglog-in balik, ngan kitaa an imo browser kun natugot hin cookies tikang hinin nga site.",
+       "import-error-edit": "An pakli nga \"$1\" in waray napaangbit tungod nga diri ka gintutugotan pagliwat hini.",
+       "import-error-create": "An pakli nga \"$1\" in waray napaangbit tungod nga diri ka gintutugotan paghimo hini.",
+       "import-error-interwiki": "An pakli nga \"$1\" in waray napaangbit tungod nga an ngaran in nakareserba para han external linking (interwiki).",
+       "import-error-special": "An pakli nga \"$1\" in waray napaangbit tungod nga nahihilakip ini ha usa ka special namespace nga diri natugot hin mga pakli.",
+       "import-error-invalid": "An pakli nga \"$1\" in waray napaangbit tungod nga an ngaran kun diin inin ig-aangbit in diri balido para hinin nga wiki.",
        "import-options-wrong": "Sayop {{PLURAL:$2|nga pirilion|nga mga pirilion}}: <nowiki>$1</nowiki>",
        "import-rootpage-invalid": "An ginhatag nga gamot-pakli in uska diri balido nga titulo.",
        "import-rootpage-nosubpage": "Ngaran-lat'ang nga \"$1\" han gamot-pakli in diri natugot hin mga bahin-pakli.",
        "tooltip-feed-rss": "RSS nga pangarga para hini nga pakli",
        "tooltip-feed-atom": "Atom nga pangarga para hini nga pakli",
        "tooltip-t-contributions": "Kitaa an listahan hin mga amot {{GENDER:$1|hinin nga gumaramit}}",
-       "tooltip-t-emailuser": "Padad-i hin e-mail ini nga nágámit",
+       "tooltip-t-emailuser": "Padad-i hin e-mail ngadto {{GENDER:$1|hinin nga gumaramit}}",
        "tooltip-t-upload": "Pagkarga hin mga paypay",
        "tooltip-t-specialpages": "Talaan hin mga pinaurog nga pakli",
        "tooltip-t-print": "Maipapatik nga bersyon hini nga pakli",
        "tooltip-compareselectedversions": "Kitaa an mga kaibhan ha butnga han duha nga pinili nga mga pagliwat hini nga pakli",
        "tooltip-watch": "Dugnga ini nga pakli ngadto han imo talaan hin ginbibinantayan",
        "tooltip-watchlistedit-normal-submit": "Igtanggal an mga titulo",
+       "tooltip-watchlistedit-raw-submit": "Ig-update an barantayon",
        "tooltip-recreate": "Utroha paghimo an pakli bisan ini in ginpara na",
        "tooltip-upload": "Tikanga an pagkarga-pasaka",
        "tooltip-rollback": "An \"libot-pabalik\" in nabalik han (mga) pagliwat hini nga pakli ngadto han kataposan nga nag-amot hin usa ka pidlit",
        "tooltip-preferences-save": "Tipiga an mga karuyag",
        "tooltip-summary": "Pagbutang hin halipotay nga masisiring hiton pagliwat",
        "interlanguage-link-title": "$1 – $2",
+       "anonymous": "Waray magpakilala nga {{PLURAL:$1|gumaramit|mga gumaramit}} han {{SITENAME}}",
        "siteuser": "{{SITENAME}} gumaramit $1",
        "anonuser": "{{SITENAME}} waray nagpakilala nga gumaramit $1",
+       "lastmodifiedatby": "Ini nga pakli in urhi ginliwat $2, $1 ni $3.",
        "othercontribs": "Ginbasihan ha binuhat ni $1.",
        "others": "mga iba",
-       "siteusers": "{{SITENAME}} {{PLURAL:$2|gumaramit|mga gumaramit}} $1",
+       "siteusers": "{{SITENAME}} {{PLURAL:$2|{{GENDER:$1|gumaramit}}|mga gumaramit}} $1",
+       "anonusers": "{{SITENAME}} waray magpakilala nga {{PLURAL:$2|gumaramit|mga gumaramit}} $1",
+       "creditspage": "Pagkilala ha pakli",
+       "nocredits": "Waray pagkilala nga impormasyon para hinin nga pakli",
+       "spamprotectiontitle": "Filter para pag-iwas hit spam",
+       "spamprotectiontext": "An teksto nga karuyag nimo igtipig in ginpugong han spam filter.\nIni posible nahitabo tungod han sumpay ngadto ha usa nga blacklisted nga external site.",
+       "spamprotectionmatch": "An masunod nga teksto amo an nagpagana han amon spam filter: $1",
+       "spambot_username": "Paglimpyo han MediaWiki spam",
+       "spam_reverting": "Ginbabalik ha urhi nga rebisyon nga waray lakip nga sumpay ngadto ha $1",
+       "spam_blanking": "Ngatan nga mga rebisyon nga may-ada sumpay ngadto ha $1, ginhahawan",
+       "spam_deleting": "Ngatanan nga mga rebisyon nga naglalakip hin sumpay ngadto ha $1, ginpapara",
        "simpleantispam-label": "Anti-spam check.\n<strong>Ayaw</strong> pagbinutangi dinhi!",
        "pageinfo-title": "Impormasyon para \"$1\"",
        "pageinfo-not-current": "Pasaylo-a, imposible makahatag hin impormasyon hiunong han mga daan nga rebisyon.",
        "pageinfo-header-basic": "Panguna nga pananabotan",
        "pageinfo-header-edits": "Kaagi han pagliwat",
        "pageinfo-header-restrictions": "Panalipod han pakli",
+       "pageinfo-header-properties": "Mga propyudad han pakli",
        "pageinfo-display-title": "Iglatag an titulo",
+       "pageinfo-default-sort": "Default sort key",
        "pageinfo-length": "Kahilaba han pakli (ha mga byte)",
        "pageinfo-article-id": "ID han pakli",
+       "pageinfo-language": "An ginagamit nga pinulongan han pakli",
+       "pageinfo-content-model": "An gingagamit nga modelo han pakli",
        "pageinfo-robot-policy": "Pag-index hin mga robot",
        "pageinfo-robot-index": "Gintutugot",
        "pageinfo-robot-noindex": "Dírì gintutugot",
        "pageinfo-watchers": "Ihap han nangingita hin pakli",
+       "pageinfo-visiting-watchers": "An kadamo han mga nagbabantay han pakli nga nagduaw ha mga bag-o nga pagliwat",
+       "pageinfo-few-watchers": "Guruguti han $1 nga {{PLURAL:$1|nagbabantay|mga nagbabantay}}",
+       "pageinfo-few-visiting-watchers": "Bangin may-ada o waray nagbabantay nga gumaramit nga nagduduaw han mga bag-o nga pagliwat",
        "pageinfo-redirects-name": "Ihap hin mga redirek ngani nga pakli",
        "pageinfo-subpages-name": "Mga bahinpakli hin nga pakli",
        "pageinfo-subpages-value": "$1 ($2 {{PLURAL:$2|redirekta|mga redirekta}}; $3 {{PLURAL:$3|diri redirekta|mga diri redirekta}})",
        "pageinfo-lastuser": "Giurhii nga nagliwat",
        "pageinfo-lasttime": "Petsa han kataposan nga pagliwat",
        "pageinfo-edits": "Ngatanan nga ihap han mga pakli",
+       "pageinfo-authors": "An kadamo han mga awtor",
+       "pageinfo-recent-edits": "An kadamo han mga bag-o nga mga pakli (ha sulod han naglabay nga $1)",
+       "pageinfo-recent-authors": "An kadamo han awtor nga nagliwat pala",
+       "pageinfo-magic-words": "Magic {{PLURAL:$1|word|words}} ($1)",
+       "pageinfo-hidden-categories": "Nakatago nga {{PLURAL:$1|kaarangay|mga kaarangay}} ($1)",
+       "pageinfo-templates": "Naka-transclude nga {{PLURAL:$1|batakan|mga batakan}} ($1)",
+       "pageinfo-transclusions": "{{PLURAL:$1|Pakli|Mga pakli}} nga naka-transclude ha ($1)",
        "pageinfo-toolboxlink": "Impormasyon han pakli",
        "pageinfo-redirectsto": "Igredirect ngadto ha",
        "pageinfo-redirectsto-info": "info",
        "show-big-image-preview": "Kadako hin nga pahiuna nga pagawas: $1.",
        "show-big-image-other": "Iba {{PLURAL:$2|nga resolusyon|nga mga resolusyon}}: $1.",
        "show-big-image-size": "$1 × $2 nga mga pixel",
+       "file-info-gif-looped": "naka-loop",
+       "file-info-png-looped": "naka-loop",
+       "file-info-png-frames": "$1 {{PLURAL:$1|frame|mga frame}}",
+       "file-no-thumb-animation": "<strong>Pasabot: Tungod hin limitasyon pan-teknikal\n, an mga thumbnail hinin nga file in diri naka-animate.</strong>",
+       "file-no-thumb-animation-gif": "<strong>Pasabot: Tungod hin limitasyon pan-teknikal\n, an mga thumbnail nga may-ada hitaas nga resolusyon nga GIF images sugad hini in diri naka-animate.</strong>",
        "newimages": "Galeryia hin mga paypay nga bag-o",
+       "imagelisttext": "Ngadi ubos in lista hin  <strong>$1</strong> ka {{PLURAL:$1|file|mga file}} nga naka-sort $2.",
+       "newimages-summary": "Ini nga ispisyal nga pakli in nagpapakita hin kataposan nga gin-upload nga mga file.",
        "newimages-legend": "Panara",
        "newimages-label": "Ngaran han paypay (o uska bahin hini):",
+       "newimages-showbots": "Igpakita an upload han mga bot",
+       "newimages-hidepatrolled": "Igtago an nakapatrolya nga mga upload",
        "noimages": "Waray makikit-an.",
        "ilsubmit": "Bilnga",
        "bydate": "pinaagi han petsa",
+       "sp-newimages-showfrom": "Igpakita an mga bag-o nga file tikang $2, $1",
+       "seconds": "{{PLURAL:$1|$1 ka segundo|$1 ka mga segundo}}",
+       "minutes": "{{PLURAL:$1|$1 ka minuto|$1 ka mga minuto}}",
+       "hours": "{{PLURAL:$1|$1 ka oras|$1 ka mga oras}}",
+       "days": "{{PLURAL:$1|$1 ka adlaw|$1 ka mga adlaw}}",
+       "weeks": "{{PLURAL:$1|$1 ka semana|$1 ka mga semana}}",
+       "months": "{{PLURAL:$1|$1 ka bulan|$1 ka mga bulan}}",
+       "years": "{{PLURAL:$1|$1 ka tuig|$1 ka mga tuig}}",
        "ago": "$1 an nakalabay",
        "just-now": "yana pala",
+       "hours-ago": "{{PLURAL:$1|$1 ka oras|$1 ka mga oras}} nga naglabay",
+       "minutes-ago": "{{PLURAL:$1|$1 ka minuto|$1 ka mga minuto}} nga naglabay",
+       "seconds-ago": "{{PLURAL:$1|$1 ka segundo|$1 ka mga segundo}} nga naglabay",
        "monday-at": "Lunes ha $1",
        "tuesday-at": "Martes ha $1",
        "wednesday-at": "Miyerkules ha $1",
        "bad_image_list": "An kabutangan in masunod:\n\nAn nakatalala la nga mga butang (mga bagis nga nagtitikang hin *) in mahiuupod paglabot.\nAn syahan nga sumpay ha uska bagis in dapat may-ada sumpay ngadto ha maraot nga fayl.\nAn bisan ano nga masunod nga mga sumpay ha kapareho nga bagis in igtratrato nga eksepsyon, sugad hin, mga pakli kun diin an mga fayl in puydi mabubutang ha sulod han bagis.",
        "metadata": "Metadata",
        "metadata-help": "Iní nga paypay mayda dugang nga pagpasabot, nga bangin gindugáng tikang han digital nga camera o iskaner nga gin-gamit paghimo o pag-digitar hini.\nKon an paypay ginliwat tikang han orihinal nga kamutangan, mayda mga detalye nga bangin diri magpakita han ginliwat nga paypay",
+       "metadata-expand": "Igpakita an mga pinahilawig nga detalye",
+       "metadata-collapse": "Igtago an mga pinahilawig nga detalye",
        "metadata-fields": "An mga rumbay han hulagway han metadato nga nakatala dinhi nga mensahe in iglalakip ha padayag hin hulagway nga pakli kun an taramdan metadato in nakalukot.\nAn iba in daan nakatago.\n* make\n* modelo\n* pitsaorasorihinal\n* exposuretime\n* fnumber\n* isospeedratings\n* focallength\n* artist\n* copyright\n* imagedescription\n* gpsngalatitud\n* gpsngalongitud\n* gpsngaaltitud",
        "exif-imagewidth": "Kahaluag",
        "exif-imagelength": "Kahitaas",
+       "exif-bitspersample": "Mga bit kada komponente",
        "exif-orientation": "Orientation",
        "exif-ycbcrpositioning": "Pagpoposisyon han Y ngan C",
        "exif-xresolution": "Resolusyon horizontal",
        "exif-copyrighted": "Kahimtang han copyright",
        "exif-copyrightowner": "Tag-iya han copyright",
        "exif-usageterms": "Mga termino hit paggamit",
+       "exif-copyrighted-true": "Naka-copyright",
        "exif-copyrighted-false": "Status hin katungod-hin-panag-iya waray mahabutang",
        "exif-unknowndate": "Waray kasabti an petsa",
        "exif-orientation-1": "Normal",
+       "exif-orientation-3": "Igpalibot hin 180°",
+       "exif-orientation-4": "Igpalibot patukdaw",
+       "exif-orientation-5": "Igpalibot hin 90° CCW ngan igpalibot patukdaw",
+       "exif-orientation-6": "Igpalibot hin 90° CCW",
+       "exif-orientation-7": "Igpalibot hin 90° CW ngan igpalibot patukdaw",
+       "exif-orientation-8": "Igpalibot hin 90° CW",
        "exif-exposureprogram-1": "Mano-mano",
+       "exif-exposureprogram-2": "Normal nga progama",
        "exif-subjectdistance-value": "$1 ka mga metro",
        "exif-meteringmode-0": "Waray kasabti",
        "exif-meteringmode-255": "iba",
        "confirm-watch-top": "Dudugngon ini nga pakli ngadto han imo talaan hin ginbibinantayan?",
        "confirm-unwatch-button": "OK",
        "confirm-unwatch-top": "Tatanggalon ini nga pakli tikang han imo tala hin binabantayan?",
+       "quotation-marks": "\"$1\"",
        "imgmultipageprev": "← naha-una nga pakli",
        "imgmultipagenext": "sunod nga pakli →",
        "imgmultigo": "Pakadto!",
        "imgmultigoto": "Pakadto ha pakli $1",
+       "img-lang-go": "Kadto",
        "ascending_abbrev": "pasaka",
        "descending_abbrev": "paubos",
        "table_pager_next": "Sunod nga pakli",
        "autosumm-replace": "Ginsaliwanan an sulod hin \"$1\"",
        "autoredircomment": "Ginredirecta an pakli ngada ha [[$1]]",
        "autosumm-new": "Nahimo an pakli nga may \"$1\"",
+       "autosumm-newblank": "Paghimo hin blangko nga pakli",
        "size-bytes": "$1 nga B",
        "size-kilobytes": "$1 nga KB",
        "size-megabytes": "$1 nga MB",
        "size-gigabytes": "$1 nga GB",
+       "lag-warn-normal": "An mga pagbabag-o nga burubag-o han $1 ka {{PLURAL:$1|segundo|mga segundo}} in diri maipapakita dinhi nga lista.",
        "watchlistedit-normal-title": "Igliwat an talaan han binabantayan",
        "watchlistedit-normal-legend": "Igtanggal an mga titulo tikang ha talaan hit binabantayan",
        "watchlistedit-normal-submit": "Igtanggal an mga titulo",
        "watchlistedit-raw-done": "Ginpayana an imo talaan han barantayon.",
        "watchlistedit-raw-added": "{{PLURAL:$1|1 nga titulo|$1 nga mga titulo}} in gindugang:",
        "watchlistedit-raw-removed": "{{PLURAL:$1|1 nga titulo|$1 nga mga titulo}} in gintanggal:",
+       "watchlistedit-clear-titles": "Mga titulo:",
+       "watchlistedit-clear-submit": "Hawana an barantayon nga listahan (Permamente ini!)",
        "watchlisttools-view": "Kitaa an mga nanginginlabot nga mga pagbabag-o",
        "watchlisttools-edit": "Kitaa ngan igliwat an talaan han binabantayan",
        "watchlisttools-raw": "Igliwat an hilaw nga talaan han binabantayan",
        "signature": "[[{{ns:user}}:$1|$2]] ([[{{ns:user_talk}}:$1|hiruhimangraw]])",
+       "timezone-local": "Lokal",
        "duplicate-defaultsort": "'''Pahimatngon:''' An daan-aada nga paglainlain nga piridlitan nga \"$2\" in igsasapaw an durudaan nga daan-aada nga paglainlain nga piridlitan nga \"$1\".",
        "version": "Bersyon",
-       "version-skins": "Mga panit",
+       "version-skins": "Mga panit nga naka-install",
        "version-specialpages": "Mga pinaurog nga pakli",
        "version-parserhooks": "Mga kawil parser",
        "version-variables": "Mga variable",
        "version-hook-name": "Ngaran han kawil",
        "version-version": "($1)",
        "version-license": "MediaWiki nga Lisensya",
+       "version-ext-license": "Lisensya",
+       "version-skin-colheader-name": "Panit",
+       "version-ext-colheader-version": "Bersyon",
+       "version-ext-colheader-license": "Lisensya",
+       "version-ext-colheader-description": "Deskripsyon",
+       "version-ext-colheader-credits": "Mga awtor",
+       "version-license-title": "Lisensya para han $1",
+       "version-license-not-found": "Waray detalye han impormasyon han lisensya nga mahihiagian para hinin nga ekstensyon.",
+       "version-credits-title": "Pagkilala para kan $1",
+       "version-credits-not-found": "Waray detalye nga mga pagkilala nga impormasyon an mahihiagian para hinin nga ekstensyon.",
        "version-poweredby-credits": "Ini nga wiki in pinapaandar han '''[https://www.mediawiki.org/ MediaWiki]''', copyright © 2001-$1 $2.",
        "version-poweredby-others": "mga iba",
+       "version-poweredby-translators": "mga maghuhubad han translatewiki.net",
+       "version-credits-summary": "Gusto namon ngaranon an masunod nga mga tawo tungod han ira mga amot ha [[Special:Version|MediaWiki]].",
        "version-software-product": "Produkto",
        "version-software-version": "Bersyon",
        "version-entrypoints": "Surudlan nga mga URL",
        "version-entrypoints-header-entrypoint": "Surudlan",
        "version-entrypoints-header-url": "URL",
+       "version-libraries-version": "Bersyon",
+       "version-libraries-license": "Lisensya",
+       "version-libraries-description": "Deskripsyon",
+       "version-libraries-authors": "Mga awtor",
+       "redirect-submit": "Kadtoa",
+       "redirect-file": "Ngaran han file",
        "fileduplicatesearch": "Pamiling hin nadoble nga mga paypay",
        "fileduplicatesearch-filename": "Ngaran han paypay:",
        "fileduplicatesearch-submit": "Pamilnga",
        "fileduplicatesearch-noresults": "Waray nabilngan nga paypay nga an ngaran in \"$1\".",
        "specialpages": "Mga pinaurog nga pakli",
+       "specialpages-note-top": "Leyenda",
        "specialpages-group-maintenance": "Mga sumat han pagmintinar",
        "specialpages-group-other": "Mga iba nga pinaurog nga pakli",
        "specialpages-group-login": "Magpalista nga masakob / paghimo hin bag-o nga akawnt",
        "tag-filter": "[[Special:Tags|Tag]] panara:",
        "tag-filter-submit": "Panara",
        "tag-list-wrapper": "([[Special:Tags|{{PLURAL:$1|ka tag|ka mga tag}}]]: $2)",
+       "tags-title": "Mga tag",
+       "tags-tag": "Ngaran han tag",
+       "tags-source-header": "Tinikangan",
+       "tags-active-header": "Nagios?",
+       "tags-actions-header": "Mga buhat",
+       "tags-active-yes": "Oo",
+       "tags-active-no": "Diri",
        "tags-edit": "igliwat",
+       "tags-delete": "paraa",
        "tags-hitcount": "$1 {{PLURAL:$1|nga pagbag-o|nga mga pagbag-o}}",
+       "tags-create-reason": "Rason:",
+       "tags-create-submit": "Himoa",
+       "tags-delete-title": "Igpara an tag",
+       "tags-delete-reason": "Rason:",
+       "tags-activate-reason": "Rason:",
+       "tags-deactivate-reason": "Rason:",
+       "tags-edit-new-tags": "Mga bag-o nga tag:",
+       "tags-edit-add": "Dugngi ini nga mga tag:",
+       "tags-edit-remove": "Tanggala ini nga mga tag:",
+       "tags-edit-remove-all-tags": "(tanggala an ngatanan nga mga tag)",
+       "tags-edit-chosen-placeholder": "Pilia an pipira nga mga tag",
+       "tags-edit-chosen-no-results": "Waray tag nga kaparehas",
+       "tags-edit-reason": "Rason:",
        "comparepages": "Igkumpara an mga pakli",
        "compare-page1": "Pakli 1",
        "compare-page2": "Pakli 2",
        "logentry-newusers-autocreate": "An gumaramit nga akawnt nga $1 in lugaring nga {{GENDER:$2|ginhimo}}",
        "logentry-upload-upload": "Hi $1 {{GENDER:$2|gin-upload}} an $3",
        "rightsnone": "(waray)",
+       "feedback-back": "Balik",
        "feedback-cancel": "Pasagdi",
        "feedback-close": "Human na.",
+       "feedback-error-title": "Sayop",
        "feedback-error2": "Sayop: Pakyas an pagliwat",
        "feedback-message": "Mensahe:",
        "feedback-subject": "Himangrawon:",
+       "feedback-thanks-title": "Salamat!",
        "searchsuggest-search": "Pamilnga",
        "searchsuggest-containing": "nagsusulod. . .",
        "api-error-badaccess-groups": "Diri ka gintutugotan pagkarga paigbaw ha dinhi nga wiki.",
        "duration-years": "$1 {{PLURAL:$1|tuig|mga tuig}}",
        "duration-decades": "$1 {{PLURAL:$1|dekada|mga dekada}}",
        "duration-centuries": "$1 {{PLURAL:$1|gatostuig|mga gatostuig}}",
-       "duration-millennia": "$1 {{PLURAL:$1|yukottuig|mga yukottuig}}"
+       "duration-millennia": "$1 {{PLURAL:$1|yukottuig|mga yukottuig}}",
+       "mediastatistics-header-unknown": "Waray kasabti",
+       "mediastatistics-header-audio": "Audio",
+       "mediastatistics-header-video": "Mga video",
+       "mediastatistics-header-office": "Buhatan",
+       "mediastatistics-header-total": "Ngatanan nga pakli"
 }
index cd62c12..77b9619 100644 (file)
        "timezone-local": "本地",
        "duplicate-defaultsort": "<strong>警告:</strong>默认排序关键词“$2”覆盖了之前的默认排序关键词“$1”。",
        "duplicate-displaytitle": "<strong>警告:</strong>显示的标题“$2”重写了此前显示的标题“$1”。",
+       "restricted-displaytitle": "<strong>警告:</strong>显示标题“$1”被忽略,因为它不等同于页面的实际标题。",
        "invalid-indicator-name": "<strong>错误:</strong>页面状态指示器的<code>name</code>属性必须不为空。",
        "version": "版本",
        "version-extensions": "安装的扩展程序",
index 674be13..589144c 100644 (file)
@@ -407,6 +407,7 @@ $specialPageAliases = [
        'BrokenRedirects'           => [ 'BrokenRedirects' ],
        'Categories'                => [ 'Categories' ],
        'ChangeContentModel'        => [ 'ChangeContentModel' ],
+       'ChangeCredentials'         => [ 'ChangeCredentials' ],
        'ChangeEmail'               => [ 'ChangeEmail' ],
        'ChangePassword'            => [ 'ChangePassword', 'ResetPass', 'ResetPassword' ],
        'ComparePages'              => [ 'ComparePages' ],
@@ -430,6 +431,7 @@ $specialPageAliases = [
        'JavaScriptTest'            => [ 'JavaScriptTest' ],
        'BlockList'                 => [ 'BlockList', 'ListBlocks', 'IPBlockList' ],
        'LinkSearch'                => [ 'LinkSearch' ],
+       'LinkAccounts'              => [ 'LinkAccounts' ],
        'Listadmins'                => [ 'ListAdmins' ],
        'Listbots'                  => [ 'ListBots' ],
        'Listfiles'                 => [ 'ListFiles', 'FileList', 'ImageList' ],
@@ -475,6 +477,7 @@ $specialPageAliases = [
        'Recentchanges'             => [ 'RecentChanges' ],
        'Recentchangeslinked'       => [ 'RecentChangesLinked', 'RelatedChanges' ],
        'Redirect'                  => [ 'Redirect' ],
+       'RemoveCredentials'         => [ 'RemoveCredentials' ],
        'ResetTokens'               => [ 'ResetTokens' ],
        'Revisiondelete'            => [ 'RevisionDelete' ],
        'RunJobs'                   => [ 'RunJobs' ],
@@ -490,6 +493,7 @@ $specialPageAliases = [
        'Uncategorizedpages'        => [ 'UncategorizedPages' ],
        'Uncategorizedtemplates'    => [ 'UncategorizedTemplates' ],
        'Undelete'                  => [ 'Undelete' ],
+       'UnlinkAccounts'            => [ 'UnlinkAccounts' ],
        'Unlockdb'                  => [ 'UnlockDB' ],
        'Unusedcategories'          => [ 'UnusedCategories' ],
        'Unusedimages'              => [ 'UnusedFiles', 'UnusedImages' ],
index cfdf797..ec6cee4 100644 (file)
@@ -534,6 +534,7 @@ table.mw_metadata caption {
 
 table.mw_metadata th {
        font-weight: normal;
+       text-align: center;
 }
 
 table.mw_metadata td {
@@ -547,7 +548,6 @@ table.mw_metadata {
 
 table.mw_metadata td,
 table.mw_metadata th {
-       text-align: center;
        border: 1px solid #aaaaaa;
        padding-left: 5px;
        padding-right: 5px;
index 26085b8..ebb6d90 100644 (file)
@@ -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",
 
index 25e0e31..9f3aa11 100644 (file)
@@ -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/ApiCreateAccountTest.php b/tests/phpunit/includes/api/ApiCreateAccountTest.php
deleted file mode 100644 (file)
index 9a83e61..0000000
+++ /dev/null
@@ -1,160 +0,0 @@
-<?php
-
-/**
- * @group Database
- * @group API
- * @group medium
- *
- * @covers ApiCreateAccount
- */
-class ApiCreateAccountTest extends ApiTestCase {
-       protected function setUp() {
-               parent::setUp();
-               $this->setMwGlobals( [ 'wgEnableEmail' => true ] );
-       }
-
-       /**
-        * Test the account creation API with a valid request. Also
-        * make sure the new account can log in and is valid.
-        *
-        * This test does multiple API requests so it might end up being
-        * a bit slow. Raise the default timeout.
-        * @group medium
-        */
-       public function testValid() {
-               global $wgServer;
-
-               if ( !isset( $wgServer ) ) {
-                       $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' );
-               }
-
-               $password = PasswordFactory::generateRandomPasswordString();
-
-               $ret = $this->doApiRequest( [
-                       'action' => 'createaccount',
-                       'name' => 'Apitestnew',
-                       'password' => $password,
-                       'email' => 'test@domain.test',
-                       'realname' => 'Test Name'
-               ] );
-
-               $result = $ret[0];
-               $this->assertNotInternalType( 'bool', $result );
-               $this->assertNotInternalType( 'null', $result['createaccount'] );
-
-               // Should first ask for token.
-               $a = $result['createaccount'];
-               $this->assertEquals( 'NeedToken', $a['result'] );
-               $token = $a['token'];
-
-               // Finally create the account
-               $ret = $this->doApiRequest(
-                       [
-                               'action' => 'createaccount',
-                               'name' => 'Apitestnew',
-                               'password' => $password,
-                               'token' => $token,
-                               'email' => 'test@domain.test',
-                               'realname' => 'Test Name'
-                       ],
-                       $ret[2]
-               );
-
-               $result = $ret[0];
-               $this->assertNotInternalType( 'bool', $result );
-               $this->assertEquals( 'Success', $result['createaccount']['result'] );
-
-               // Try logging in with the new user.
-               $ret = $this->doApiRequest( [
-                       'action' => 'login',
-                       'lgname' => 'Apitestnew',
-                       'lgpassword' => $password,
-               ] );
-
-               $result = $ret[0];
-               $this->assertNotInternalType( 'bool', $result );
-               $this->assertNotInternalType( 'null', $result['login'] );
-
-               $a = $result['login']['result'];
-               $this->assertEquals( 'NeedToken', $a );
-               $token = $result['login']['token'];
-
-               $ret = $this->doApiRequest(
-                       [
-                               'action' => 'login',
-                               'lgtoken' => $token,
-                               'lgname' => 'Apitestnew',
-                               'lgpassword' => $password,
-                       ],
-                       $ret[2]
-               );
-
-               $result = $ret[0];
-
-               $this->assertNotInternalType( 'bool', $result );
-               $a = $result['login']['result'];
-
-               $this->assertEquals( 'Success', $a );
-
-               // log out to destroy the session
-               $ret = $this->doApiRequest(
-                       [
-                               'action' => 'logout',
-                       ],
-                       $ret[2]
-               );
-               $this->assertEquals( [], $ret[0] );
-       }
-
-       /**
-        * Make sure requests with no names are invalid.
-        * @expectedException UsageException
-        */
-       public function testNoName() {
-               $this->doApiRequest( [
-                       'action' => 'createaccount',
-                       'token' => LoginForm::getCreateaccountToken()->toString(),
-                       'password' => 'password',
-               ] );
-       }
-
-       /**
-        * Make sure requests with no password are invalid.
-        * @expectedException UsageException
-        */
-       public function testNoPassword() {
-               $this->doApiRequest( [
-                       'action' => 'createaccount',
-                       'name' => 'testName',
-                       'token' => LoginForm::getCreateaccountToken()->toString(),
-               ] );
-       }
-
-       /**
-        * Make sure requests with existing users are invalid.
-        * @expectedException UsageException
-        */
-       public function testExistingUser() {
-               $this->doApiRequest( [
-                       'action' => 'createaccount',
-                       'name' => 'Apitestsysop',
-                       'token' => LoginForm::getCreateaccountToken()->toString(),
-                       'password' => 'password',
-                       'email' => 'test@domain.test',
-               ] );
-       }
-
-       /**
-        * Make sure requests with invalid emails are invalid.
-        * @expectedException UsageException
-        */
-       public function testInvalidEmail() {
-               $this->doApiRequest( [
-                       'action' => 'createaccount',
-                       'name' => 'Test User',
-                       'token' => LoginForm::getCreateaccountToken()->toString(),
-                       'password' => 'password',
-                       'email' => 'invalid',
-               ] );
-       }
-}
index bcd884e..e9afd45 100644 (file)
@@ -13,6 +13,8 @@ class ApiLoginTest extends ApiTestCase {
         * Test result of attempted login with an empty username
         */
        public function testApiLoginNoName() {
+               global $wgDisableAuthManager;
+
                $session = [
                        'wsTokenSecrets' => [ 'login' => 'foobar' ],
                ];
@@ -20,11 +22,11 @@ class ApiLoginTest extends ApiTestCase {
                        'lgname' => '', 'lgpassword' => self::$users['sysop']->password,
                        'lgtoken' => (string)( new MediaWiki\Session\Token( 'foobar', '' ) )
                ], $session );
-               $this->assertEquals( 'NoName', $data[0]['login']['result'] );
+               $this->assertEquals( $wgDisableAuthManager ? 'NoName' : 'Failed', $data[0]['login']['result'] );
        }
 
        public function testApiLoginBadPass() {
-               global $wgServer;
+               global $wgServer, $wgDisableAuthManager;
 
                $user = self::$users['sysop'];
                $user->getUser()->logout();
@@ -61,7 +63,7 @@ class ApiLoginTest extends ApiTestCase {
                $this->assertNotInternalType( "bool", $result );
                $a = $result["login"]["result"];
 
-               $this->assertEquals( "WrongPass", $a );
+               $this->assertEquals( $wgDisableAuthManager ? 'WrongPass' : 'Failed', $a );
        }
 
        public function testApiLoginGoodPass() {
index 246ea3d..ff5640a 100644 (file)
@@ -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 );
@@ -183,6 +184,13 @@ abstract class ApiTestCase extends MediaWikiLangTestCase {
                        $data[2]
                );
 
+               if ( $data[0]['login']['result'] === 'Success' ) {
+                       // DWIM
+                       global $wgUser;
+                       $wgUser = self::$users[$user]->getUser();
+                       RequestContext::getMain()->setUser( $wgUser );
+               }
+
                return $data;
        }
 
diff --git a/tests/phpunit/includes/auth/AbstractAuthenticationProviderTest.php b/tests/phpunit/includes/auth/AbstractAuthenticationProviderTest.php
new file mode 100644 (file)
index 0000000..1ded0df
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\AbstractAuthenticationProvider
+ */
+class AbstractAuthenticationProviderTest extends \MediaWikiTestCase {
+       protected function setUp() {
+               global $wgDisableAuthManager;
+
+               parent::setUp();
+               if ( $wgDisableAuthManager ) {
+                       $this->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 (file)
index 0000000..ecce932
--- /dev/null
@@ -0,0 +1,233 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\AbstractPasswordPrimaryAuthenticationProvider
+ */
+class AbstractPasswordPrimaryAuthenticationProviderTest extends \MediaWikiTestCase {
+       protected function setUp() {
+               global $wgDisableAuthManager;
+
+               parent::setUp();
+               if ( $wgDisableAuthManager ) {
+                       $this->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 (file)
index 0000000..c35430e
--- /dev/null
@@ -0,0 +1,54 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\AbstractPreAuthenticationProvider
+ */
+class AbstractPreAuthenticationProviderTest extends \MediaWikiTestCase {
+       protected function setUp() {
+               global $wgDisableAuthManager;
+
+               parent::setUp();
+               if ( $wgDisableAuthManager ) {
+                       $this->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 (file)
index 0000000..420a330
--- /dev/null
@@ -0,0 +1,183 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\AbstractPrimaryAuthenticationProvider
+ */
+class AbstractPrimaryAuthenticationProviderTest extends \MediaWikiTestCase {
+       protected function setUp() {
+               global $wgDisableAuthManager;
+
+               parent::setUp();
+               if ( $wgDisableAuthManager ) {
+                       $this->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 (file)
index 0000000..9cdc051
--- /dev/null
@@ -0,0 +1,93 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\AbstractSecondaryAuthenticationProvider
+ */
+class AbstractSecondaryAuthenticationProviderTest extends \MediaWikiTestCase {
+       protected function setUp() {
+               global $wgDisableAuthManager;
+
+               parent::setUp();
+               if ( $wgDisableAuthManager ) {
+                       $this->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 (file)
index 0000000..377abe2
--- /dev/null
@@ -0,0 +1,3654 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+use MediaWiki\Session\SessionInfo;
+use MediaWiki\Session\UserInfo;
+use Psr\Log\LogLevel;
+use StatusValue;
+
+/**
+ * @group AuthManager
+ * @group Database
+ * @covers MediaWiki\Auth\AuthManager
+ */
+class AuthManagerTest extends \MediaWikiTestCase {
+       /** @var WebRequest */
+       protected $request;
+       /** @var Config */
+       protected $config;
+       /** @var \\Psr\\Log\\LoggerInterface */
+       protected $logger;
+
+       protected $preauthMocks = [];
+       protected $primaryauthMocks = [];
+       protected $secondaryauthMocks = [];
+
+       /** @var AuthManager */
+       protected $manager;
+       /** @var TestingAccessWrapper */
+       protected $managerPriv;
+
+       protected function setUp() {
+               global $wgDisableAuthManager;
+
+               parent::setUp();
+               if ( $wgDisableAuthManager ) {
+                       $this->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 (file)
index 0000000..b676d69
--- /dev/null
@@ -0,0 +1,706 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\AuthPluginPrimaryAuthenticationProvider
+ */
+class AuthPluginPrimaryAuthenticationProviderTest extends \MediaWikiTestCase {
+       protected function setUp() {
+               global $wgDisableAuthManager;
+
+               parent::setUp();
+               if ( $wgDisableAuthManager ) {
+                       $this->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 (file)
index 0000000..84a0ea6
--- /dev/null
@@ -0,0 +1,514 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\AuthenticationRequest
+ */
+class AuthenticationRequestTest extends \MediaWikiTestCase {
+       protected function setUp() {
+               global $wgDisableAuthManager;
+
+               parent::setUp();
+               if ( $wgDisableAuthManager ) {
+                       $this->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 (file)
index 0000000..aafcd09
--- /dev/null
@@ -0,0 +1,96 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ */
+abstract class AuthenticationRequestTestCase extends \MediaWikiTestCase {
+       protected function setUp() {
+               global $wgDisableAuthManager;
+
+               parent::setUp();
+               if ( $wgDisableAuthManager ) {
+                       $this->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 (file)
index 0000000..58ff8b6
--- /dev/null
@@ -0,0 +1,104 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\AuthenticationResponse
+ */
+class AuthenticationResponseTest extends \MediaWikiTestCase {
+       protected function setUp() {
+               global $wgDisableAuthManager;
+
+               parent::setUp();
+               if ( $wgDisableAuthManager ) {
+                       $this->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 (file)
index 0000000..3bc077c
--- /dev/null
@@ -0,0 +1,64 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\ButtonAuthenticationRequest
+ */
+class ButtonAuthenticationRequestTest extends AuthenticationRequestTestCase {
+
+       protected function getInstance( array $args = [] ) {
+               $data = array_intersect_key( $args, [ 'name' => 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 (file)
index 0000000..f2341bc
--- /dev/null
@@ -0,0 +1,195 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @group Database
+ * @covers MediaWiki\Auth\CheckBlocksSecondaryAuthenticationProvider
+ */
+class CheckBlocksSecondaryAuthenticationProviderTest extends \MediaWikiTestCase {
+       protected function setUp() {
+               global $wgDisableAuthManager;
+
+               parent::setUp();
+               if ( $wgDisableAuthManager ) {
+                       $this->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 (file)
index 0000000..f208cc4
--- /dev/null
@@ -0,0 +1,68 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+use InvalidArgumentException;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\ConfirmLinkAuthenticationRequest
+ */
+class ConfirmLinkAuthenticationRequestTest extends AuthenticationRequestTestCase {
+
+       protected function getInstance( array $args = [] ) {
+               return new ConfirmLinkAuthenticationRequest( $this->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 (file)
index 0000000..09d046c
--- /dev/null
@@ -0,0 +1,276 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\ConfirmLinkSecondaryAuthenticationProvider
+ */
+class ConfirmLinkSecondaryAuthenticationProviderTest extends \MediaWikiTestCase {
+       protected function setUp() {
+               global $wgDisableAuthManager;
+
+               parent::setUp();
+               if ( $wgDisableAuthManager ) {
+                       $this->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 (file)
index 0000000..fb0613d
--- /dev/null
@@ -0,0 +1,26 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\CreateFromLoginAuthenticationRequest
+ */
+class CreateFromLoginAuthenticationRequestTest extends AuthenticationRequestTestCase {
+
+       protected function getInstance( array $args = [] ) {
+               return new CreateFromLoginAuthenticationRequest(
+                       null, []
+               );
+       }
+
+       public function provideLoadFromSubmission() {
+               return [
+                       'Empty request' => [
+                               [],
+                               [],
+                               [],
+                       ],
+               ];
+       }
+}
diff --git a/tests/phpunit/includes/auth/CreatedAccountAuthenticationRequestTest.php b/tests/phpunit/includes/auth/CreatedAccountAuthenticationRequestTest.php
new file mode 100644 (file)
index 0000000..fc1e6f1
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\CreatedAccountAuthenticationRequest
+ */
+class CreatedAccountAuthenticationRequestTest extends AuthenticationRequestTestCase {
+
+       protected function getInstance( array $args = [] ) {
+               return new CreatedAccountAuthenticationRequest( 42, 'Test' );
+       }
+
+       public function testConstructor() {
+               $ret = new CreatedAccountAuthenticationRequest( 42, 'Test' );
+               $this->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 (file)
index 0000000..cce1e8c
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\CreationReasonAuthenticationRequest
+ */
+class CreationReasonAuthenticationRequestTest extends AuthenticationRequestTestCase {
+
+       protected function getInstance( array $args = [] ) {
+               return new CreationReasonAuthenticationRequest();
+       }
+
+       public function provideLoadFromSubmission() {
+               return [
+                       'Empty request' => [
+                               [],
+                               [],
+                               false
+                       ],
+                       'Reason given' => [
+                               [],
+                               $data = [ 'reason' => 'Because' ],
+                               $data,
+                       ],
+                       'Reason empty' => [
+                               [],
+                               [ 'reason' => '' ],
+                               false
+                       ],
+               ];
+       }
+}
diff --git a/tests/phpunit/includes/auth/EmailNotificationSecondaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/EmailNotificationSecondaryAuthenticationProviderTest.php
new file mode 100644 (file)
index 0000000..18c46f7
--- /dev/null
@@ -0,0 +1,103 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+use Psr\Log\LoggerInterface;
+
+class EmailNotificationSecondaryAuthenticationProviderTest extends \PHPUnit_Framework_TestCase {
+       public function testConstructor() {
+               $config = new \HashConfig( [
+                       'EnableEmail' => true,
+                       'EmailAuthentication' => true,
+               ] );
+
+               $provider = new EmailNotificationSecondaryAuthenticationProvider();
+               $provider->setConfig( $config );
+               $providerPriv = \TestingAccessWrapper::newFromObject( $provider );
+               $this->assertTrue( $providerPriv->sendConfirmationEmail );
+
+               $provider = new EmailNotificationSecondaryAuthenticationProvider( [
+                       'sendConfirmationEmail' => false,
+               ] );
+               $provider->setConfig( $config );
+               $providerPriv = \TestingAccessWrapper::newFromObject( $provider );
+               $this->assertFalse( $providerPriv->sendConfirmationEmail );
+       }
+
+       /**
+        * @dataProvider provideGetAuthenticationRequests
+        * @param string $action
+        * @param AuthenticationRequest[] $expected
+        */
+       public function testGetAuthenticationRequests( $action, $expected ) {
+               $provider = new EmailNotificationSecondaryAuthenticationProvider( [
+                       'sendConfirmationEmail' => true,
+               ] );
+               $this->assertSame( $expected, $provider->getAuthenticationRequests( $action, [] ) );
+       }
+
+       public function provideGetAuthenticationRequests() {
+               return [
+                       [ AuthManager::ACTION_LOGIN, [] ],
+                       [ AuthManager::ACTION_CREATE, [] ],
+                       [ AuthManager::ACTION_LINK, [] ],
+                       [ AuthManager::ACTION_CHANGE, [] ],
+                       [ AuthManager::ACTION_REMOVE, [] ],
+               ];
+       }
+
+       public function testBeginSecondaryAuthentication() {
+               $provider = new EmailNotificationSecondaryAuthenticationProvider( [
+                       'sendConfirmationEmail' => true,
+               ] );
+               $this->assertEquals( AuthenticationResponse::newAbstain(),
+                       $provider->beginSecondaryAuthentication( \User::newFromName( 'Foo' ), [] ) );
+       }
+
+       public function testBeginSecondaryAccountCreation() {
+               $authManager = new AuthManager( new \FauxRequest(), new \HashConfig() );
+
+               $creator = $this->getMock( 'User' );
+               $userWithoutEmail = $this->getMock( 'User' );
+               $userWithoutEmail->expects( $this->any() )->method( 'getEmail' )->willReturn( '' );
+               $userWithoutEmail->expects( $this->never() )->method( 'sendConfirmationMail' );
+               $userWithEmailError = $this->getMock( 'User' );
+               $userWithEmailError->expects( $this->any() )->method( 'getEmail' )->willReturn( 'foo@bar.baz' );
+               $userWithEmailError->expects( $this->any() )->method( 'sendConfirmationMail' )
+                       ->willReturn( \Status::newFatal( 'fail' ) );
+               $userExpectsConfirmation = $this->getMock( 'User' );
+               $userExpectsConfirmation->expects( $this->any() )->method( 'getEmail' )
+                       ->willReturn( 'foo@bar.baz' );
+               $userExpectsConfirmation->expects( $this->once() )->method( 'sendConfirmationMail' )
+                       ->willReturn( \Status::newGood() );
+               $userNotExpectsConfirmation = $this->getMock( 'User' );
+               $userNotExpectsConfirmation->expects( $this->any() )->method( 'getEmail' )
+                       ->willReturn( 'foo@bar.baz' );
+               $userNotExpectsConfirmation->expects( $this->never() )->method( 'sendConfirmationMail' );
+
+               $provider = new EmailNotificationSecondaryAuthenticationProvider( [
+                       'sendConfirmationEmail' => false,
+               ] );
+               $provider->setManager( $authManager );
+               $provider->beginSecondaryAccountCreation( $userNotExpectsConfirmation, $creator, [] );
+
+               $provider = new EmailNotificationSecondaryAuthenticationProvider( [
+                       'sendConfirmationEmail' => true,
+               ] );
+               $provider->setManager( $authManager );
+               $provider->beginSecondaryAccountCreation( $userWithoutEmail, $creator, [] );
+               $provider->beginSecondaryAccountCreation( $userExpectsConfirmation, $creator, [] );
+
+               // test logging of email errors
+               $logger = $this->getMockForAbstractClass( LoggerInterface::class );
+               $logger->expects( $this->once() )->method( 'warning' );
+               $provider->setLogger( $logger );
+               $provider->beginSecondaryAccountCreation( $userWithEmailError, $creator, [] );
+
+               // test disable flag used by other providers
+               $authManager->setAuthenticationSessionData( 'no-email', true );
+               $provider->setManager( $authManager );
+               $provider->beginSecondaryAccountCreation( $userNotExpectsConfirmation, $creator, [] );
+
+       }
+}
diff --git a/tests/phpunit/includes/auth/LegacyHookPreAuthenticationProviderTest.php b/tests/phpunit/includes/auth/LegacyHookPreAuthenticationProviderTest.php
new file mode 100644 (file)
index 0000000..edee6fc
--- /dev/null
@@ -0,0 +1,420 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @group Database
+ * @covers MediaWiki\Auth\LegacyHookPreAuthenticationProvider
+ */
+class LegacyHookPreAuthenticationProviderTest extends \MediaWikiTestCase {
+       protected function setUp() {
+               global $wgDisableAuthManager;
+
+               parent::setUp();
+               if ( $wgDisableAuthManager ) {
+                       $this->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&#39;' ]
+                       ],
+                       'AbortLogin, NOT_EXISTS, with message' => [
+                               "User'", 'A', null, \LoginForm::NOT_EXISTS, 'badname', 'badname', [ 'User&#39;' ]
+                       ],
+                       '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&#39;' ]
+                       ],
+                       'AbortLogin, USER_BLOCKED, with message' => [
+                               "User'", 'P', null, \LoginForm::USER_BLOCKED, 'blocked', 'blocked', [ 'User&#39;' ]
+                       ],
+                       'AbortLogin, ABORTED, no message' => [
+                               "User'", 'P', null, \LoginForm::ABORTED, null, 'login-abort-generic', [ 'User&#39;' ]
+                       ],
+                       'AbortLogin, ABORTED, with message' => [
+                               "User'", 'P', null, \LoginForm::ABORTED, 'aborted', 'aborted', [ 'User&#39;' ]
+                       ],
+                       '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 (file)
index 0000000..fa68dee
--- /dev/null
@@ -0,0 +1,666 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @group Database
+ * @covers MediaWiki\Auth\LocalPasswordPrimaryAuthenticationProvider
+ */
+class LocalPasswordPrimaryAuthenticationProviderTest extends \MediaWikiTestCase {
+
+       private $manager = null;
+       private $config = null;
+       private $validity = null;
+
+       protected function setUp() {
+               global $wgDisableAuthManager;
+
+               parent::setUp();
+               if ( $wgDisableAuthManager ) {
+                       $this->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( '<invalid>' ) );
+
+               $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 = '<invalid>';
+               $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( '<invalid>' ) );
+
+               $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 = '<invalid>';
+               $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 (file)
index 0000000..3387e7c
--- /dev/null
@@ -0,0 +1,138 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\PasswordAuthenticationRequest
+ */
+class PasswordAuthenticationRequestTest extends AuthenticationRequestTestCase {
+
+       protected function getInstance( array $args = [] ) {
+               $ret = new PasswordAuthenticationRequest();
+               $ret->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 (file)
index 0000000..f746515
--- /dev/null
@@ -0,0 +1,159 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\PasswordDomainAuthenticationRequest
+ */
+class PasswordDomainAuthenticationRequestTest extends AuthenticationRequestTestCase {
+
+       protected function getInstance( array $args = [] ) {
+               $ret = new PasswordDomainAuthenticationRequest( [ 'd1', 'd2' ] );
+               $ret->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 (file)
index 0000000..3f90169
--- /dev/null
@@ -0,0 +1,55 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\RememberMeAuthenticationRequest
+ */
+class RememberMeAuthenticationRequestTest extends AuthenticationRequestTestCase {
+
+       public static function provideGetFieldInfo() {
+               return [
+                       [ [ 1 ] ],
+                       [ [ null ] ],
+               ];
+       }
+
+       public function testGetFieldInfo_2() {
+               $req = new RememberMeAuthenticationRequest();
+               $reqWrapper = \TestingAccessWrapper::newFromObject( $req );
+
+               $reqWrapper->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 (file)
index 0000000..59edede
--- /dev/null
@@ -0,0 +1,313 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\ResetPasswordSecondaryAuthenticationProvider
+ */
+class ResetPasswordSecondaryAuthenticationProviderTest extends \MediaWikiTestCase {
+       protected function setUp() {
+               global $wgDisableAuthManager;
+
+               parent::setUp();
+               if ( $wgDisableAuthManager ) {
+                       $this->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 (file)
index 0000000..05c5165
--- /dev/null
@@ -0,0 +1,79 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\TemporaryPasswordAuthenticationRequest
+ */
+class TemporaryPasswordAuthenticationRequestTest extends AuthenticationRequestTestCase {
+
+       protected function getInstance( array $args = [] ) {
+               $ret = new TemporaryPasswordAuthenticationRequest;
+               $ret->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 (file)
index 0000000..8d0bf96
--- /dev/null
@@ -0,0 +1,749 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @group Database
+ * @covers MediaWiki\Auth\TemporaryPasswordPrimaryAuthenticationProvider
+ */
+class TemporaryPasswordPrimaryAuthenticationProviderTest extends \MediaWikiTestCase {
+
+       private $manager = null;
+       private $config = null;
+       private $validity = null;
+
+       protected function setUp() {
+               global $wgDisableAuthManager;
+
+               parent::setUp();
+               if ( $wgDisableAuthManager ) {
+                       $this->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( '<invalid>' ) );
+
+               $req = new PasswordAuthenticationRequest;
+               $req->action = AuthManager::ACTION_CHANGE;
+               $req->username = '<invalid>';
+               $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( '<invalid>' ) );
+               $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 = '<invalid>';
+               $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, '<invalid>', \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 = '<Invalid>';
+               $status = $provider->providerAllowsAuthenticationDataChange( $req, true );
+               $this->assertEquals( \StatusValue::newFatal( 'passwordreset-nosuchcaller', '<Invalid>' ),
+                       $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 = '<invalid>';
+               $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 (file)
index 0000000..8b273b5
--- /dev/null
@@ -0,0 +1,235 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @group Database
+ * @covers MediaWiki\Auth\ThrottlePreAuthenticationProvider
+ */
+class ThrottlePreAuthenticationProviderTest extends \MediaWikiTestCase {
+       protected function setUp() {
+               global $wgDisableAuthManager;
+
+               parent::setUp();
+               if ( $wgDisableAuthManager ) {
+                       $this->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 (file)
index 0000000..dba748b
--- /dev/null
@@ -0,0 +1,246 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+use BagOStuff;
+use HashBagOStuff;
+use InvalidArgumentException;
+use Psr\Log\AbstractLogger;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\Throttler
+ */
+class ThrottlerTest extends \MediaWikiTestCase {
+       protected function setUp() {
+               global $wgDisableAuthManager;
+
+               parent::setUp();
+               if ( $wgDisableAuthManager ) {
+                       $this->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 (file)
index 0000000..7fe3351
--- /dev/null
@@ -0,0 +1,177 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\UserDataAuthenticationRequest
+ */
+class UserDataAuthenticationRequestTest extends AuthenticationRequestTestCase {
+
+       protected function getInstance( array $args = [] ) {
+               return new UserDataAuthenticationRequest;
+       }
+
+       protected function setUp() {
+               parent::setUp();
+               $this->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 (file)
index 0000000..63628dd
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\UsernameAuthenticationRequest
+ */
+class UsernameAuthenticationRequestTest extends AuthenticationRequestTestCase {
+
+       protected function getInstance( array $args = [] ) {
+               return new UsernameAuthenticationRequest();
+       }
+
+       public function provideLoadFromSubmission() {
+               return [
+                       'Empty request' => [
+                               [],
+                               [],
+                               false
+                       ],
+                       'Username' => [
+                               [],
+                               $data = [ 'username' => 'User' ],
+                               $data,
+                       ],
+                       'Username empty' => [
+                               [],
+                               [ 'username' => '' ],
+                               false
+                       ],
+               ];
+       }
+}
index 70e89d4..9600184 100644 (file)
@@ -14,7 +14,6 @@ use Psr\Log\LogLevel;
 class CookieSessionProviderTest extends MediaWikiTestCase {
 
        private function getConfig() {
-               global $wgCookieExpiration;
                return new \HashConfig( [
                        'CookiePrefix' => 'CookiePrefix',
                        'CookiePath' => 'CookiePath',
@@ -22,8 +21,9 @@ class CookieSessionProviderTest extends MediaWikiTestCase {
                        'CookieSecure' => true,
                        'CookieHttpOnly' => true,
                        'SessionName' => false,
+                       'CookieExpiration' => 100,
                        'ExtendedLoginCookies' => [ 'UserID', 'Token' ],
-                       'ExtendedLoginCookieExpiration' => $wgCookieExpiration * 2,
+                       'ExtendedLoginCookieExpiration' => 200,
                ] );
        }
 
@@ -377,8 +377,6 @@ class CookieSessionProviderTest extends MediaWikiTestCase {
        }
 
        public function testPersistSession() {
-               $this->setMwGlobals( [ 'wgCookieExpiration' => 100 ] );
-
                $provider = new CookieSessionProvider( [
                        'priority' => 1,
                        'sessionName' => 'MySessionName',
@@ -461,7 +459,6 @@ class CookieSessionProviderTest extends MediaWikiTestCase {
         */
        public function testCookieData( $secure, $remember ) {
                $this->setMwGlobals( [
-                       'wgCookieExpiration' => 100,
                        'wgSecureLogin' => false,
                ] );
 
@@ -783,4 +780,39 @@ class CookieSessionProviderTest extends MediaWikiTestCase {
                $this->assertNull( $provider->getCookie( $request, 'Baz', 'x' ) );
        }
 
+       public function testGetRememberUserDuration() {
+               $config = $this->getConfig();
+               $provider = new CookieSessionProvider( [ 'priority' => 10 ] );
+               $provider->setLogger( new \Psr\Log\NullLogger() );
+               $provider->setConfig( $config );
+               $provider->setManager( SessionManager::singleton() );
+
+               $this->assertSame( 200, $provider->getRememberUserDuration() );
+
+               $config->set( 'ExtendedLoginCookieExpiration', null );
+
+               $this->assertSame( 100, $provider->getRememberUserDuration() );
+
+               $config->set( 'ExtendedLoginCookieExpiration', 0 );
+
+               $this->assertSame( null, $provider->getRememberUserDuration() );
+       }
+
+       public function testGetLoginCookieExpiration() {
+               $config = $this->getConfig();
+               $provider = \TestingAccessWrapper::newFromObject( new CookieSessionProvider( [
+                       'priority' => 10
+               ] ) );
+               $provider->setLogger( new \Psr\Log\NullLogger() );
+               $provider->setConfig( $config );
+               $provider->setManager( SessionManager::singleton() );
+
+               $this->assertSame( 200, $provider->getLoginCookieExpiration( 'Token' ) );
+               $this->assertSame( 100, $provider->getLoginCookieExpiration( 'User' ) );
+
+               $config->set( 'ExtendedLoginCookieExpiration', null );
+
+               $this->assertSame( 100, $provider->getLoginCookieExpiration( 'Token' ) );
+               $this->assertSame( 100, $provider->getLoginCookieExpiration( 'User' ) );
+       }
 }
index d705fc0..78edb76 100644 (file)
@@ -249,7 +249,7 @@ class ImmutableSessionProviderWithCookieTest extends MediaWikiTestCase {
                        }
                        $this->assertEquals( [
                                'value' => 'true',
-                               'expire' => $remember ? 100 : null,
+                               'expire' => null,
                                'path' => 'CookiePath',
                                'domain' => 'CookieDomain',
                                'secure' => false,
index 5f387ea..e725fee 100644 (file)
@@ -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__ ] );
index f80baf2..4cbeeb9 100644 (file)
@@ -35,6 +35,8 @@ class SessionProviderTest extends MediaWikiTestCase {
 
                $this->assertSame( get_class( $provider ), (string)$provider );
 
+               $this->assertNull( $provider->getRememberUserDuration() );
+
                $this->assertNull( $provider->whyNoSession() );
 
                $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
diff --git a/tests/phpunit/includes/user/PasswordResetTest.php b/tests/phpunit/includes/user/PasswordResetTest.php
new file mode 100644 (file)
index 0000000..4db636b
--- /dev/null
@@ -0,0 +1,161 @@
+<?php
+
+use MediaWiki\Auth\AuthManager;
+
+/**
+ * @group Database
+ */
+class PasswordResetTest extends PHPUnit_Framework_TestCase {
+       /**
+        * @dataProvider provideIsAllowed
+        */
+       public function testIsAllowed( $passwordResetRoutes, $enableEmail,
+               $allowsAuthenticationDataChange, $canEditPrivate, $canSeePassword,
+               $userIsBlocked, $isAllowed, $isAllowedToDisplayPassword
+       ) {
+               $config = new HashConfig( [
+                       'PasswordResetRoutes' => $passwordResetRoutes,
+                       'EnableEmail' => $enableEmail,
+               ] );
+
+               $authManager = $this->getMockBuilder( AuthManager::class )->disableOriginalConstructor()
+                       ->getMock();
+               $authManager->expects( $this->any() )->method( 'allowsAuthenticationDataChange' )
+                       ->willReturn( $allowsAuthenticationDataChange ? Status::newGood() : Status::newFatal( 'foo' ) );
+
+               $user = $this->getMock( User::class );
+               $user->expects( $this->any() )->method( 'getName' )->willReturn( 'Foo' );
+               $user->expects( $this->any() )->method( 'isBlocked' )->willReturn( $userIsBlocked );
+               $user->expects( $this->any() )->method( 'isAllowed' )
+                       ->will( $this->returnCallback( function ( $perm ) use ( $canEditPrivate, $canSeePassword ) {
+                               if ( $perm === 'editmyprivateinfo' ) {
+                                       return $canEditPrivate;
+                               } elseif ( $perm === 'passwordreset' ) {
+                                       return $canSeePassword;
+                               } else {
+                                       $this->fail( 'Unexpected permission check' );
+                               }
+                       } ) );
+
+               $passwordReset = new PasswordReset( $config, $authManager );
+
+               $this->assertSame( $isAllowed, $passwordReset->isAllowed( $user )->isGood() );
+               $this->assertSame( $isAllowedToDisplayPassword,
+                       $passwordReset->isAllowed( $user, true )->isGood() );
+       }
+
+       public function provideIsAllowed() {
+               return [
+                       [
+                               'passwordResetRoutes' => [],
+                               'enableEmail' => true,
+                               'allowsAuthenticationDataChange' => true,
+                               'canEditPrivate' => true,
+                               'canSeePassword' => true,
+                               'userIsBlocked' => false,
+                               'isAllowed' => false,
+                               'isAllowedToDisplayPassword' => false,
+                       ],
+                       [
+                               'passwordResetRoutes' => [ 'username' => true ],
+                               'enableEmail' => false,
+                               'allowsAuthenticationDataChange' => true,
+                               'canEditPrivate' => true,
+                               'canSeePassword' => true,
+                               'userIsBlocked' => false,
+                               'isAllowed' => false,
+                               'isAllowedToDisplayPassword' => false,
+                       ],
+                       [
+                               'passwordResetRoutes' => [ 'username' => true ],
+                               'enableEmail' => true,
+                               'allowsAuthenticationDataChange' => false,
+                               'canEditPrivate' => true,
+                               'canSeePassword' => true,
+                               'userIsBlocked' => false,
+                               'isAllowed' => false,
+                               'isAllowedToDisplayPassword' => false,
+                       ],
+                       [
+                               'passwordResetRoutes' => [ 'username' => true ],
+                               'enableEmail' => true,
+                               'allowsAuthenticationDataChange' => true,
+                               'canEditPrivate' => false,
+                               'canSeePassword' => true,
+                               'userIsBlocked' => false,
+                               'isAllowed' => false,
+                               'isAllowedToDisplayPassword' => false,
+                       ],
+                       [
+                               'passwordResetRoutes' => [ 'username' => true ],
+                               'enableEmail' => true,
+                               'allowsAuthenticationDataChange' => true,
+                               'canEditPrivate' => true,
+                               'canSeePassword' => true,
+                               'userIsBlocked' => true,
+                               'isAllowed' => false,
+                               'isAllowedToDisplayPassword' => false,
+                       ],
+                       [
+                               'passwordResetRoutes' => [ 'username' => true ],
+                               'enableEmail' => true,
+                               'allowsAuthenticationDataChange' => true,
+                               'canEditPrivate' => true,
+                               'canSeePassword' => false,
+                               'userIsBlocked' => false,
+                               'isAllowed' => true,
+                               'isAllowedToDisplayPassword' => false,
+                       ],
+                       [
+                               'passwordResetRoutes' => [ 'username' => true ],
+                               'enableEmail' => true,
+                               'allowsAuthenticationDataChange' => true,
+                               'canEditPrivate' => true,
+                               'canSeePassword' => true,
+                               'userIsBlocked' => false,
+                               'isAllowed' => true,
+                               'isAllowedToDisplayPassword' => true,
+                       ],
+               ];
+       }
+
+       public function testExecute_email() {
+               $config = new HashConfig( [
+                       'PasswordResetRoutes' => [ 'username' => true, 'email' => true ],
+                       'EnableEmail' => true,
+               ] );
+
+               $authManager = $this->getMockBuilder( AuthManager::class )->disableOriginalConstructor()
+                       ->getMock();
+               $authManager->expects( $this->any() )->method( 'allowsAuthenticationDataChange' )
+                       ->willReturn( Status::newGood() );
+               $authManager->expects( $this->exactly( 2 ) )->method( 'changeAuthenticationData' );
+
+               $request = new FauxRequest();
+               $request->setIP( '1.2.3.4' );
+               $performingUser = $this->getMock( User::class );
+               $performingUser->expects( $this->any() )->method( 'getRequest' )->willReturn( $request );
+               $performingUser->expects( $this->any() )->method( 'isAllowed' )->willReturn( true );
+
+               $targetUser1 = $this->getMock( User::class );
+               $targetUser2 = $this->getMock( User::class );
+               $targetUser1->expects( $this->any() )->method( 'getName' )->willReturn( 'User1' );
+               $targetUser2->expects( $this->any() )->method( 'getName' )->willReturn( 'User2' );
+               $targetUser1->expects( $this->any() )->method( 'getId' )->willReturn( 1 );
+               $targetUser2->expects( $this->any() )->method( 'getId' )->willReturn( 2 );
+               $targetUser1->expects( $this->any() )->method( 'getEmail' )->willReturn( 'foo@bar.baz' );
+               $targetUser2->expects( $this->any() )->method( 'getEmail' )->willReturn( 'foo@bar.baz' );
+
+               $passwordReset = $this->getMockBuilder( PasswordReset::class )
+                       ->setMethods( [ 'getUsersByEmail' ] )->setConstructorArgs( [ $config, $authManager ] )
+                       ->getMock();
+               $passwordReset->expects( $this->any() )->method( 'getUsersByEmail' )->with( 'foo@bar.baz' )
+                       ->willReturn( [ $targetUser1, $targetUser2 ] );
+
+               $status = $passwordReset->isAllowed( $performingUser );
+               $this->assertTrue( $status->isGood() );
+
+               $status = $passwordReset->execute( $performingUser, null, 'foo@bar.baz' );
+               $this->assertTrue( $status->isGood() );
+       }
+}
index d876c45..d13da60 100755 (executable)
@@ -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(