Merge "mediawiki.jqueryMsg: Implement `<nowiki>` support"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Mon, 16 May 2016 19:32:46 +0000 (19:32 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Mon, 16 May 2016 19:32:46 +0000 (19:32 +0000)
249 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/GlobalFunctions.php
includes/Html.php
includes/MediaWikiServices.php
includes/Preferences.php
includes/ServiceWiring.php
includes/Setup.php
includes/Title.php
includes/WatchedItem.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/ApiManageTags.php
includes/api/ApiPageSet.php
includes/api/ApiQuery.php
includes/api/ApiQueryAuthManagerInfo.php [new file with mode: 0644]
includes/api/ApiQuerySiteinfo.php
includes/api/ApiQueryUsers.php
includes/api/ApiRemoveAuthenticationData.php [new file with mode: 0644]
includes/api/ApiResetPassword.php [new file with mode: 0644]
includes/api/ApiStashEdit.php
includes/api/i18n/en.json
includes/api/i18n/id.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/cache/LinkBatch.php
includes/cache/LinkCache.php
includes/changetags/ChangeTags.php
includes/deferred/LinksDeletionUpdate.php
includes/deferred/LinksUpdate.php
includes/exception/UserNotLoggedIn.php
includes/htmlform/OOUIHTMLForm.php
includes/installer/i18n/ko.json
includes/jobqueue/JobRunner.php
includes/jobqueue/jobs/AssembleUploadChunksJob.php
includes/jobqueue/jobs/PublishStashedFileJob.php
includes/libs/Xhprof.php
includes/libs/XhprofData.php [new file with mode: 0644]
includes/libs/objectcache/WANObjectCache.php
includes/parser/CoreParserFunctions.php
includes/parser/LinkHolderArray.php
includes/parser/StripState.php
includes/password/PasswordPolicyChecks.php
includes/profiler/ProfilerXhprof.php
includes/registration/ExtensionProcessor.php
includes/resourceloader/ResourceLoaderUploadDialogModule.php [new file with mode: 0644]
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/title/MediaWikiTitleCodec.php
includes/user/PasswordReset.php [new file with mode: 0644]
includes/user/User.php
languages/i18n/ast.json
languages/i18n/be-tarask.json
languages/i18n/be.json
languages/i18n/bn.json
languages/i18n/ca.json
languages/i18n/cs.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/eu.json
languages/i18n/fa.json
languages/i18n/fi.json
languages/i18n/fr.json
languages/i18n/gl.json
languages/i18n/gom-deva.json
languages/i18n/he.json
languages/i18n/hr.json
languages/i18n/ht.json
languages/i18n/ia.json
languages/i18n/id.json
languages/i18n/inh.json
languages/i18n/it.json
languages/i18n/ja.json
languages/i18n/jv.json
languages/i18n/ko.json
languages/i18n/ksh.json
languages/i18n/ku-latn.json
languages/i18n/lb.json
languages/i18n/lv.json
languages/i18n/mk.json
languages/i18n/nds-nl.json
languages/i18n/nds.json
languages/i18n/nl.json
languages/i18n/pl.json
languages/i18n/pms.json
languages/i18n/qqq.json
languages/i18n/ru.json
languages/i18n/si.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/uk.json
languages/i18n/ur.json
languages/i18n/war.json
languages/i18n/zh-hans.json
languages/messages/MessagesEn.php
maintenance/updateCollation.php
resources/Resources.php
resources/src/mediawiki.legacy/shared.css
resources/src/mediawiki/mediawiki.ForeignStructuredUpload.BookletLayout.js
resources/src/mediawiki/mediawiki.ForeignStructuredUpload.js
resources/src/mediawiki/mediawiki.ForeignUpload.js
resources/src/mediawiki/mediawiki.Upload.BookletLayout.js
resources/src/mediawiki/mediawiki.htmlform.ooui.css
resources/src/mediawiki/mediawiki.jqueryMsg.js
tests/TestsAutoLoader.php
tests/parser/parserTest.inc
tests/parser/parserTests.txt
tests/phpunit/MediaWikiTestCase.php
tests/phpunit/includes/HtmlTest.php
tests/phpunit/includes/LinkerTest.php
tests/phpunit/includes/MediaWikiServicesTest.php
tests/phpunit/includes/OutputPageTest.php
tests/phpunit/includes/TitleTest.php
tests/phpunit/includes/WatchedItemIntegrationTest.php
tests/phpunit/includes/XmlSelectTest.php
tests/phpunit/includes/XmlTest.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/content/ContentHandlerTest.php
tests/phpunit/includes/content/JsonContentTest.php
tests/phpunit/includes/libs/XhprofDataTest.php [new file with mode: 0644]
tests/phpunit/includes/libs/XhprofTest.php
tests/phpunit/includes/objectcache/RedisBagOStuffTest.php [new file with mode: 0644]
tests/phpunit/includes/parser/NewParserTest.php
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/title/MediaWikiTitleCodecTest.php
tests/phpunit/includes/user/PasswordResetTest.php [new file with mode: 0644]
tests/phpunit/phpunit.php
tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js

index 3a0326e..7c50e4f 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,27 @@ 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.
+* The file upload dialog (available if you install WikiEditor or VisualEditor)
+  can now be configured using $wgUploadDialog.
 
 === External library changes in 1.27 ===
 
@@ -239,6 +282,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 +326,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 +531,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 1e656e4..f79bace 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',
@@ -1093,6 +1143,7 @@ $wgAutoloadLocalClasses = [
        'ResourceLoaderSkinModule' => __DIR__ . '/includes/resourceloader/ResourceLoaderSkinModule.php',
        'ResourceLoaderSpecialCharacterDataModule' => __DIR__ . '/includes/resourceloader/ResourceLoaderSpecialCharacterDataModule.php',
        'ResourceLoaderStartUpModule' => __DIR__ . '/includes/resourceloader/ResourceLoaderStartUpModule.php',
+       'ResourceLoaderUploadDialogModule' => __DIR__ . '/includes/resourceloader/ResourceLoaderUploadDialogModule.php',
        'ResourceLoaderUserCSSPrefsModule' => __DIR__ . '/includes/resourceloader/ResourceLoaderUserCSSPrefsModule.php',
        'ResourceLoaderUserDefaultsModule' => __DIR__ . '/includes/resourceloader/ResourceLoaderUserDefaultsModule.php',
        'ResourceLoaderUserGroupsModule' => __DIR__ . '/includes/resourceloader/ResourceLoaderUserGroupsModule.php',
@@ -1201,11 +1252,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 +1270,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 +1293,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 +1306,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 +1317,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',
@@ -1465,6 +1526,7 @@ $wgAutoloadLocalClasses = [
        'XMPReader' => __DIR__ . '/includes/media/XMP.php',
        'XMPValidate' => __DIR__ . '/includes/media/XMPValidate.php',
        'Xhprof' => __DIR__ . '/includes/libs/Xhprof.php',
+       'XhprofData' => __DIR__ . '/includes/libs/XhprofData.php',
        'Xml' => __DIR__ . '/includes/Xml.php',
        'XmlDumpWriter' => __DIR__ . '/includes/export/XmlDumpWriter.php',
        'XmlJsCode' => __DIR__ . '/includes/Xml.php',
index ed3eaa9..1d2b2f0 100644 (file)
                        "type": "object",
                        "description": "Registry of factory functions to create Config objects"
                },
+               "SessionProviders": {
+                       "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 383f0ad..0b70d16 100644 (file)
@@ -535,6 +535,64 @@ $wgUseInstantCommons = false;
  */
 $wgForeignUploadTargets = [];
 
+/**
+ * Configuration for file uploads using the embeddable upload dialog
+ * (https://www.mediawiki.org/wiki/Upload_dialog).
+ *
+ * This applies also to foreign uploads to this wiki (the configuration is loaded by remote wikis
+ * using the action=query&meta=siteinfo API).
+ *
+ * See below for documentation of each property. None of the properties may be omitted.
+ */
+$wgUploadDialog = [
+       // Fields to make available in the dialog. `true` means that this field is visible, `false` means
+       // that it is hidden. The "Name" field can't be hidden. Note that you also have to add the
+       // matching replacement to the 'filepage' format key below to make use of these.
+       'fields' => [
+               'description' => true,
+               'date' => false,
+               'categories' => false,
+       ],
+       // Suffix of localisation messages used to describe the license under which the uploaded file will
+       // be released. The same value may be set for both 'local' and 'foreign' uploads.
+       'licensemessages' => [
+               // The 'local' messages are used for local uploads on this wiki:
+               // * upload-form-label-own-work-message-generic-local
+               // * upload-form-label-not-own-work-message-generic-local
+               // * upload-form-label-not-own-work-local-generic-local
+               'local' => 'generic-local',
+               // The 'foreign' messages are used for cross-wiki uploads from other wikis to this wiki:
+               // * upload-form-label-own-work-message-generic-foreign
+               // * upload-form-label-not-own-work-message-generic-foreign
+               // * upload-form-label-not-own-work-local-generic-foreign
+               'foreign' => 'generic-foreign',
+       ],
+       // Upload comment to use. Available replacements:
+       // * $HOST - domain name from which a cross-wiki upload originates
+       // * $PAGENAME - wiki page name from which an upload originates
+       'comment' => '',
+       // Format of the file page wikitext to be generated from the fields input by the user.
+       'format' => [
+               // Wrapper for the whole page. Available replacements:
+               // * $DESCRIPTION - file description, as input by the user (only if the 'description' field is
+               //   enabled), wrapped as defined below in the 'description' key
+               // * $DATE - file creation date, as input by the user (only if the 'date' field is enabled)
+               // * $SOURCE - as defined below in the 'ownwork' key, may be extended in the future
+               // * $AUTHOR - linked user name, may be extended in the future
+               // * $LICENSE - as defined below in the 'license' key, may be extended in the future
+               // * $CATEGORIES - file categories wikitext, as input by the user (only if the 'categories'
+               //   field is enabled), or if no input, as defined below in the 'uncategorized' key
+               'filepage' => '$DESCRIPTION',
+               // Wrapped for file description. Available replacements:
+               // * $LANGUAGE - source wiki's content language
+               // * $TEXT - input by the user
+               'description' => '$TEXT',
+               'ownwork' => '',
+               'license' => '',
+               'uncategorized' => '',
+       ],
+];
+
 /**
  * File backend structure configuration.
  *
@@ -3130,24 +3188,6 @@ $wgHTMLFormAllowTableFormat = true;
  */
 $wgUseMediaWikiUIEverywhere = false;
 
-/**
- * Should we try to make our HTML output well-formed XML?  If set to false,
- * output will be a few bytes shorter, and the HTML will arguably be more
- * readable.  If set to true, life will be much easier for the authors of
- * screen-scraping bots, and the HTML will arguably be more readable.
- *
- * Setting this to false may omit quotation marks on some attributes, omit
- * slashes from some self-closing tags, omit some ending tags, etc., where
- * permitted by HTML5.  Setting it to true will not guarantee that all pages
- * will be well-formed, although non-well-formed pages should be rare and it's
- * a bug if you find one.  Conversely, setting it to false doesn't mean that
- * all XML-y constructs will be omitted, just that they might be.
- *
- * Because of compatibility with screen-scraping bots, and because it's
- * controversial, this is currently left to true by default.
- */
-$wgWellFormedXml = true;
-
 /**
  * Permit other namespaces in addition to the w3.org default.
  *
@@ -4406,6 +4446,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
@@ -4903,6 +5081,7 @@ $wgGroupPermissions['sysop']['suppressredirect'] = true;
 # $wgGroupPermissions['sysop']['upload_by_url'] = true;
 $wgGroupPermissions['sysop']['mergehistory'] = true;
 $wgGroupPermissions['sysop']['managechangetags'] = true;
+$wgGroupPermissions['sysop']['deletechangetags'] = true;
 
 // Permission to change users' group assignments
 $wgGroupPermissions['bureaucrat']['userrights'] = true;
index 02093ff..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>",
@@ -3860,7 +3860,7 @@ HTML
                        ],
                        $showSignature ? [
                                'id'     => 'mw-editbutton-signature',
-                               'open'   => '--~~~~',
+                               'open'   => wfMessage( 'sig-text', '~~~~' )->inContentLanguage()->text(),
                                'close'  => '',
                                'sample' => '',
                                'tip'    => wfMessage( 'sig_tip' )->text(),
index 537bdef..618fa4c 100644 (file)
@@ -2134,6 +2134,24 @@ function wfTempDir() {
                        return $tmp;
                }
        }
+
+       /**
+        * PHP on Windows will detect C:\Windows\Temp as not writable even though PHP can write to it
+        * so create a directory within that called 'mwtmp' with a suffix of the user running the
+        * current process.
+        * The user is included as if various scripts are run by different users they will likely
+        * not be able to access each others temporary files.
+        */
+       if ( wfIsWindows() ) {
+               $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'mwtmp' . '-' . get_current_user();
+               if ( !file_exists( $tmp ) ) {
+                       mkdir( $tmp );
+               }
+               if ( file_exists( $tmp ) && is_dir( $tmp ) && is_writable( $tmp ) ) {
+                       return $tmp;
+               }
+       }
+
        throw new MWException( 'No writable temporary directory could be found. ' .
                'Please set $wgTmpDirectory to a writable directory.' );
 }
index 890beb0..e5128d1 100644 (file)
@@ -38,8 +38,6 @@
  *
  * $wgMimeType: If this is set to an xml MIME type then output should be
  *     valid XHTML5.
- * $wgWellFormedXml: If this is set to true, then all output should be
- *     well-formed XML (quotes on attributes, self-closing tags, etc.).
  *
  * This class is meant to be confined to utility functions that are called from
  * trusted code paths.  It does not do enforcement of policy like not allowing
@@ -199,8 +197,7 @@ class Html {
         * This is quite similar to Xml::tags(), but it implements some useful
         * HTML-specific logic.  For instance, there is no $allowShortTag
         * parameter: the closing tag is magically omitted if $element has an empty
-        * content model.  If $wgWellFormedXml is false, then a few bytes will be
-        * shaved off the HTML output as well.
+        * content model.
         *
         * @param string $element The element's name, e.g., 'a'
         * @param array $attribs Associative array of attributes, e.g., array(
@@ -211,14 +208,10 @@ class Html {
         * @return string Raw HTML
         */
        public static function rawElement( $element, $attribs = [], $contents = '' ) {
-               global $wgWellFormedXml;
                $start = self::openElement( $element, $attribs );
                if ( in_array( $element, self::$voidElements ) ) {
-                       if ( $wgWellFormedXml ) {
-                               // Silly XML.
-                               return substr( $start, 0, -1 ) . '/>';
-                       }
-                       return $start;
+                       // Silly XML.
+                       return substr( $start, 0, -1 ) . '/>';
                } else {
                        return "$start$contents" . self::closeElement( $element );
                }
@@ -443,8 +436,6 @@ class Html {
         * 'http://www.mediawiki.org/' ) becomes something like
         * ' href="http://www.mediawiki.org"'.  Again, this is like
         * Xml::expandAttributes(), but it implements some HTML-specific logic.
-        * For instance, it will omit quotation marks if $wgWellFormedXml is false,
-        * and will treat boolean attributes specially.
         *
         * Attributes that can contain space-separated lists ('class', 'accesskey' and 'rel') array
         * values are allowed as well, which will automagically be normalized
@@ -479,8 +470,6 @@ class Html {
         *   (starting with a space if at least one attribute is output)
         */
        public static function expandAttributes( array $attribs ) {
-               global $wgWellFormedXml;
-
                $ret = '';
                foreach ( $attribs as $key => $value ) {
                        // Support intuitive array( 'checked' => true/false ) form
@@ -564,31 +553,10 @@ class Html {
                                throw new MWException( "HTML attribute $key can not contain a list of values" );
                        }
 
-                       // See the "Attributes" section in the HTML syntax part of HTML5,
-                       // 9.1.2.3 as of 2009-08-10.  Most attributes can have quotation
-                       // marks omitted, but not all.  (Although a literal " is not
-                       // permitted, we don't check for that, since it will be escaped
-                       // anyway.)
-
-                       // See also research done on further characters that need to be
-                       // escaped: http://code.google.com/p/html5lib/issues/detail?id=93
-                       $badChars = "\\x00- '=<>`/\x{00a0}\x{1680}\x{180e}\x{180F}\x{2000}\x{2001}"
-                               . "\x{2002}\x{2003}\x{2004}\x{2005}\x{2006}\x{2007}\x{2008}\x{2009}"
-                               . "\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}";
-                       if ( $wgWellFormedXml || $value === '' || preg_match( "![$badChars]!u", $value ) ) {
-                               $quote = '"';
-                       } else {
-                               $quote = '';
-                       }
+                       $quote = '"';
 
                        if ( in_array( $key, self::$boolAttribs ) ) {
-                               // In HTML5, we can leave the value empty. If we don't need
-                               // well-formed XML, we can omit the = entirely.
-                               if ( !$wgWellFormedXml ) {
-                                       $ret .= " $key";
-                               } else {
-                                       $ret .= " $key=\"\"";
-                               }
+                               $ret .= " $key=\"\"";
                        } else {
                                // Apparently we need to entity-encode \n, \r, \t, although the
                                // spec doesn't mention that.  Since we're doing strtr() anyway,
@@ -599,22 +567,18 @@ class Html {
                                // don't because we're stubborn and like our marginal savings on
                                // byte size from not having to encode unnecessary quotes.
                                // The only difference between this transform and the one by
-                               // Sanitizer::encodeAttribute() is '<' is only encoded here if
-                               // $wgWellFormedXml is set, and ' is not encoded.
+                               // Sanitizer::encodeAttribute() is ' is not encoded.
                                $map = [
                                        '&' => '&amp;',
                                        '"' => '&quot;',
                                        '>' => '&gt;',
+                                       // '<' allegedly allowed per spec
+                                       // but breaks some tools if not escaped.
+                                       "<" => '&lt;',
                                        "\n" => '&#10;',
                                        "\r" => '&#13;',
                                        "\t" => '&#9;'
                                ];
-                               if ( $wgWellFormedXml ) {
-                                       // This is allowed per spec: <http://www.w3.org/TR/xml/#NT-AttValue>
-                                       // But reportedly it breaks some XML tools?
-                                       // @todo FIXME: Is this really true?
-                                       $map['<'] = '&lt;';
-                               }
                                $ret .= " $key=$quote" . strtr( $value, $map ) . $quote;
                        }
                }
@@ -631,11 +595,9 @@ class Html {
         * @return string Raw HTML
         */
        public static function inlineScript( $contents ) {
-               global $wgWellFormedXml;
-
                $attrs = [];
 
-               if ( $wgWellFormedXml && preg_match( '/[<&]/', $contents ) ) {
+               if ( preg_match( '/[<&]/', $contents ) ) {
                        $contents = "/*<![CDATA[*/$contents/*]]>*/";
                }
 
@@ -665,9 +627,7 @@ class Html {
         * @return string Raw HTML
         */
        public static function inlineStyle( $contents, $media = 'all' ) {
-               global $wgWellFormedXml;
-
-               if ( $wgWellFormedXml && preg_match( '/[<&]/', $contents ) ) {
+               if ( preg_match( '/[<&]/', $contents ) ) {
                        $contents = "/*<![CDATA[*/$contents/*]]>*/";
                }
 
index 5bb5597..891f426 100644 (file)
@@ -8,6 +8,7 @@ use GenderCache;
 use GlobalVarConfig;
 use Hooks;
 use LBFactory;
+use LinkCache;
 use Liuggio\StatsdClient\Factory\StatsdDataFactory;
 use LoadBalancer;
 use MediaWiki\Services\ServiceContainer;
@@ -460,6 +461,15 @@ class MediaWikiServices extends ServiceContainer {
        public function getGenderCache() {
                return $this->getService( 'GenderCache' );
        }
+
+       /**
+        * @since 1.28
+        * @return LinkCache
+        */
+       public function getLinkCache() {
+               return $this->getService( 'LinkCache' );
+       }
+
        /**
         * @since 1.28
         * @return TitleFormatter
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 aa99a71..293e6eb 100644 (file)
@@ -139,6 +139,12 @@ return [
                return $store;
        },
 
+       'LinkCache' => function( MediaWikiServices $services ) {
+               return new LinkCache(
+                       $services->getTitleFormatter()
+               );
+       },
+
        'GenderCache' => function( MediaWikiServices $services ) {
                return new GenderCache();
        },
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 d070609..25fbce3 100644 (file)
@@ -170,6 +170,9 @@ class Title implements LinkTarget {
                return MediaWikiServices::getInstance()->getTitleFormatter();
        }
 
+       /**
+        * @access protected
+        */
        function __construct() {
        }
 
@@ -1710,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 ) ) {
index 50c79dc..b070e1e 100644 (file)
@@ -152,7 +152,7 @@ class WatchedItem {
         *             or WatchedItemStore::loadWatchedItem()
         */
        public static function fromUserTitle( $user, $title, $checkRights = User::CHECK_USER_RIGHTS ) {
-               // wfDeprecated( __METHOD__, '1.27' );
+               wfDeprecated( __METHOD__, '1.27' );
                return new self( $user, $title, self::DEPRECATED_USAGE_TIMESTAMP, (bool)$checkRights );
        }
 
@@ -160,7 +160,7 @@ class WatchedItem {
         * @deprecated since 1.27 Use WatchedItemStore::resetNotificationTimestamp()
         */
        public function resetNotificationTimestamp( $force = '', $oldid = 0 ) {
-               // wfDeprecated( __METHOD__, '1.27' );
+               wfDeprecated( __METHOD__, '1.27' );
                if ( $this->checkRights && !$this->user->isAllowed( 'editmywatchlist' ) ) {
                        return;
                }
@@ -176,7 +176,7 @@ class WatchedItem {
         * @deprecated since 1.27 Use WatchedItemStore::addWatchBatch()
         */
        public static function batchAddWatch( array $items ) {
-               // wfDeprecated( __METHOD__, '1.27' );
+               wfDeprecated( __METHOD__, '1.27' );
                if ( !$items ) {
                        return false;
                }
@@ -209,7 +209,7 @@ class WatchedItem {
         * @return bool
         */
        public function addWatch() {
-               // wfDeprecated( __METHOD__, '1.27' );
+               wfDeprecated( __METHOD__, '1.27' );
                $this->user->addWatch( $this->getTitle(), $this->checkRights );
                return true;
        }
@@ -219,7 +219,7 @@ class WatchedItem {
         * @return bool
         */
        public function removeWatch() {
-               // wfDeprecated( __METHOD__, '1.27' );
+               wfDeprecated( __METHOD__, '1.27' );
                if ( $this->checkRights && !$this->user->isAllowed( 'editmywatchlist' ) ) {
                        return false;
                }
@@ -232,7 +232,7 @@ class WatchedItem {
         * @return bool
         */
        public function isWatched() {
-               // wfDeprecated( __METHOD__, '1.27' );
+               wfDeprecated( __METHOD__, '1.27' );
                return $this->user->isWatched( $this->getTitle(), $this->checkRights );
        }
 
@@ -240,7 +240,7 @@ class WatchedItem {
         * @deprecated since 1.27 Use WatchedItemStore::duplicateAllAssociatedEntries()
         */
        public static function duplicateEntries( Title $oldTitle, Title $newTitle ) {
-               // wfDeprecated( __METHOD__, '1.27' );
+               wfDeprecated( __METHOD__, '1.27' );
                $store = MediaWikiServices::getInstance()->getWatchedItemStore();
                $store->duplicateAllAssociatedEntries( $oldTitle, $newTitle );
        }
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 685a9ef..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',
@@ -1135,7 +1141,7 @@ class ApiMain extends ApiBase {
                                                                        TS_MW, time() - $this->getConfig()->get( 'SquidMaxage' )
                                                                );
                                                        }
-                                                       Hooks::run( 'OutputPageCheckLastModified', [ &$modifiedTimes ] );
+                                                       Hooks::run( 'OutputPageCheckLastModified', [ &$modifiedTimes, $this->getOutput() ] );
                                                        $lastMod = max( $modifiedTimes );
                                                        $return304 = wfTimestamp( TS_MW, $lastMod ) <= $ts->getTimestamp( TS_MW );
                                                }
@@ -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 60fb4dc..617db22 100644 (file)
@@ -29,8 +29,14 @@ class ApiManageTags extends ApiBase {
                $params = $this->extractRequestParams();
 
                // make sure the user is allowed
-               if ( !$this->getUser()->isAllowed( 'managechangetags' ) ) {
-                       $this->dieUsage( "You don't have permission to manage change tags", 'permissiondenied' );
+               if ( $params['operation'] !== 'delete'
+                       && !$this->getUser()->isAllowed( 'managechangetags' )
+               ) {
+                       $this->dieUsage( "You don't have permission to manage change tags",
+                               'permissiondenied' );
+               } elseif ( !$this->getUser()->isAllowed( 'deletechangetags' ) ) {
+                       $this->dieUsage( "You don't have permission to delete change tags",
+                               'permissiondenied' );
                }
 
                $result = $this->getResult();
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 f05556e..a08740a 100644 (file)
@@ -108,6 +108,9 @@ class ApiQuerySiteinfo extends ApiQueryBase {
                                case 'defaultoptions':
                                        $fit = $this->appendDefaultOptions( $p );
                                        break;
+                               case 'uploaddialog':
+                                       $fit = $this->appendUploadDialog( $p );
+                                       break;
                                default:
                                        ApiBase::dieDebug( __METHOD__, "Unknown prop=$p" );
                        }
@@ -771,6 +774,11 @@ class ApiQuerySiteinfo extends ApiQueryBase {
                return $this->getResult()->addValue( 'query', $property, $options );
        }
 
+       public function appendUploadDialog( $property ) {
+               $config = $this->getConfig()->get( 'UploadDialog' );
+               return $this->getResult()->addValue( 'query', $property, $config );
+       }
+
        private function formatParserTags( $item ) {
                return "<{$item}>";
        }
@@ -838,6 +846,7 @@ class ApiQuerySiteinfo extends ApiQueryBase {
                                        'variables',
                                        'protocols',
                                        'defaultoptions',
+                                       'uploaddialog',
                                ],
                                ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
                        ],
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 5efefbd..3539eed 100644 (file)
@@ -147,13 +147,15 @@ class ApiStashEdit extends ApiBase {
                        Hooks::run( 'ParserOutputStashForEdit', [ $page, $content, $editInfo->output ] );
 
                        list( $stashInfo, $ttl ) = self::buildStashValue(
-                               $editInfo->pstContent, $editInfo->output, $editInfo->timestamp
+                               $editInfo->pstContent,
+                               $editInfo->output,
+                               $editInfo->timestamp,
+                               $user
                        );
 
                        if ( $stashInfo ) {
                                $ok = $cache->set( $key, $stashInfo, $ttl );
                                if ( $ok ) {
-
                                        $logger->debug( "Cached parser output for key '$key'." );
                                        return self::ERROR_NONE;
                                } else {
@@ -223,7 +225,7 @@ class ApiStashEdit extends ApiBase {
                $pOut->setCacheTime( wfTimestampNow() );
 
                // Build a value to cache with a proper TTL
-               list( $stashInfo, $ttl ) = self::buildStashValue( $pstContent, $pOut, $timestamp );
+               list( $stashInfo, $ttl ) = self::buildStashValue( $pstContent, $pOut, $timestamp, $user );
                if ( !$stashInfo ) {
                        $logger->info( "Uncacheable parser output for key '$key' (rev/TTL)." );
                        return false;
@@ -291,6 +293,10 @@ class ApiStashEdit extends ApiBase {
                        $stats->increment( 'editstash.cache_hits.presumed_fresh' );
                        $logger->debug( "Timestamp-based cache hit for key '$key' (age: $age sec)." );
                        return $editInfo; // assume nothing changed
+               } elseif ( isset( $editInfo->edits ) && $editInfo->edits === $user->getEditCount() ) {
+                       $stats->increment( 'editstash.cache_hits.presumed_fresh' );
+                       $logger->debug( "Edit count based cache hit for key '$key' (age: $age sec)." );
+                       return $editInfo; // use made no local upload/template edits in the meantime
                }
 
                $dbr = wfGetDB( DB_SLAVE );
@@ -385,12 +391,14 @@ class ApiStashEdit extends ApiBase {
         * @param Content $pstContent
         * @param ParserOutput $parserOutput
         * @param string $timestamp TS_MW
+        * @param User $user
         * @return array (stash info array, TTL in seconds) or (null, 0)
         */
        protected static function buildStashValue(
-               Content $pstContent, ParserOutput $parserOutput, $timestamp
+               Content $pstContent, ParserOutput $parserOutput, $timestamp, User $user
        ) {
-               // If an item is renewed, mind the cache TTL determined by config and parser functions
+               // If an item is renewed, mind the cache TTL determined by config and parser functions.
+               // Put an upper limit on the TTL for sanity to avoid extreme template/file staleness.
                $since = time() - wfTimestamp( TS_UNIX, $parserOutput->getTimestamp() );
                $ttl = min( $parserOutput->getCacheExpiry() - $since, 5 * 60 );
 
@@ -399,7 +407,8 @@ class ApiStashEdit extends ApiBase {
                        $stashInfo = (object)[
                                'pstContent' => $pstContent,
                                'output'     => $parserOutput,
-                               'timestamp'  => $timestamp
+                               'timestamp'  => $timestamp,
+                               'edits'      => $user->getEditCount()
                        ];
                        return [ $stashInfo, $ttl ];
                }
index 2b23da0..a802cc7 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+siteinfo-paramvalue-prop-variables": "Returns a list of variable IDs.",
        "apihelp-query+siteinfo-paramvalue-prop-protocols": "Returns a list of protocols that are allowed in external links.",
        "apihelp-query+siteinfo-paramvalue-prop-defaultoptions": "Returns the default values for user preferences.",
+       "apihelp-query+siteinfo-paramvalue-prop-uploaddialog": "Returns the upload dialog configuration.",
        "apihelp-query+siteinfo-param-filteriw": "Return only local or only nonlocal entries of the interwiki map.",
        "apihelp-query+siteinfo-param-showalldb": "List all database servers, not just the one lagging the most.",
        "apihelp-query+siteinfo-param-numberingroup": "Lists the number of users in user groups.",
        "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 b114289..dae55de 100644 (file)
@@ -3,11 +3,13 @@
                "authors": [
                        "WongKentir",
                        "Beeyan",
-                       "Rachmat.Wahidi"
+                       "Rachmat.Wahidi",
+                       "Kenrick95"
                ]
        },
        "apihelp-block-description": "Blokir pengguna.",
        "apihelp-block-param-user": "Nama pengguna, alamat IP, atau rentang alamat IP untuk diblokir.",
        "apihelp-createaccount-param-name": "Nama pengguna",
+       "apihelp-login-example-login": "Masuk log.",
        "apihelp-revisiondelete-param-ids": "Penanda untuk perubahan yang akan dihapus"
 }
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..e6faa21 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+siteinfo-paramvalue-prop-variables": "{{doc-apihelp-paramvalue|query+siteinfo|prop|variables}}",
        "apihelp-query+siteinfo-paramvalue-prop-protocols": "{{doc-apihelp-paramvalue|query+siteinfo|prop|protocols}}",
        "apihelp-query+siteinfo-paramvalue-prop-defaultoptions": "{{doc-apihelp-paramvalue|query+siteinfo|prop|defaultoptions}}",
+       "apihelp-query+siteinfo-paramvalue-prop-uploaddialog": "{{doc-apihelp-paramvalue|query+siteinfo|prop|uploaddialog}}",
        "apihelp-query+siteinfo-param-filteriw": "{{doc-apihelp-param|query+siteinfo|filteriw}}",
        "apihelp-query+siteinfo-param-showalldb": "{{doc-apihelp-param|query+siteinfo|showalldb}}",
        "apihelp-query+siteinfo-param-numberingroup": "{{doc-apihelp-param|query+siteinfo|numberingroup}}",
        "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 c5bd290..a7dd570 100644 (file)
@@ -179,8 +179,6 @@ class LinkBatch {
         * @return bool|ResultWrapper
         */
        public function doQuery() {
-               global $wgContentHandlerUseDB, $wgPageLanguageUseDB;
-
                if ( $this->isEmpty() ) {
                        return false;
                }
@@ -188,15 +186,10 @@ class LinkBatch {
                // This is similar to LinkHolderArray::replaceInternal
                $dbr = wfGetDB( DB_SLAVE );
                $table = 'page';
-               $fields = [ 'page_id', 'page_namespace', 'page_title', 'page_len',
-                       'page_is_redirect', 'page_latest' ];
-
-               if ( $wgContentHandlerUseDB ) {
-                       $fields[] = 'page_content_model';
-               }
-               if ( $wgPageLanguageUseDB ) {
-                       $fields[] = 'page_lang';
-               }
+               $fields = array_merge(
+                       LinkCache::getSelectFields(),
+                       [ 'page_namespace', 'page_title' ]
+               );
 
                $conds = $this->constructSet( 'page', $dbr );
 
index b8f2329..de44f9b 100644 (file)
@@ -20,6 +20,8 @@
  * @file
  * @ingroup Cache
  */
+use MediaWiki\Linker\LinkTarget;
+use MediaWiki\MediaWikiServices;
 
 /**
  * Cache for article titles (prefixed DB keys) and ids linked from one source
@@ -38,56 +40,30 @@ class LinkCache {
        private $mForUpdate = false;
 
        /**
-        * How many Titles to store. There are two caches, so the amount actually
-        * stored in memory can be up to twice this.
+        * @var TitleFormatter
         */
-       const MAX_SIZE = 10000;
+       private $titleFormatter;
 
        /**
-        * @var LinkCache
+        * How many Titles to store. There are two caches, so the amount actually
+        * stored in memory can be up to twice this.
         */
-       protected static $instance;
+       const MAX_SIZE = 10000;
 
-       public function __construct() {
+       public function __construct( TitleFormatter $titleFormatter ) {
                $this->mGoodLinks = new HashBagOStuff( [ 'maxKeys' => self::MAX_SIZE ] );
                $this->mBadLinks = new HashBagOStuff( [ 'maxKeys' => self::MAX_SIZE ] );
+               $this->titleFormatter = $titleFormatter;
        }
 
        /**
         * Get an instance of this class.
         *
         * @return LinkCache
+        * @deprecated since 1.28, use MediaWikiServices instead
         */
        public static function singleton() {
-               if ( !self::$instance ) {
-                       self::$instance = new LinkCache;
-               }
-
-               return self::$instance;
-       }
-
-       /**
-        * Destroy the singleton instance
-        *
-        * A new one will be created next time singleton() is called.
-        *
-        * @since 1.22
-        */
-       public static function destroySingleton() {
-               self::$instance = null;
-       }
-
-       /**
-        * Set the singleton instance to a given object.
-        *
-        * Since we do not have an interface for LinkCache, you have to be sure the
-        * given object implements all the LinkCache public methods.
-        *
-        * @param LinkCache $instance
-        * @since 1.22
-        */
-       public static function setSingleton( LinkCache $instance ) {
-               self::$instance = $instance;
+               return MediaWikiServices::getInstance()->getLinkCache();
        }
 
        /**
@@ -119,12 +95,12 @@ class LinkCache {
        /**
         * Get a field of a title object from cache.
         * If this link is not a cached good title, it will return NULL.
-        * @param Title $title
+        * @param LinkTarget $target
         * @param string $field ('length','redirect','revision','model')
         * @return string|int|null
         */
-       public function getGoodLinkFieldObj( Title $title, $field ) {
-               $dbkey = $title->getPrefixedDBkey();
+       public function getGoodLinkFieldObj( LinkTarget $target, $field ) {
+               $dbkey = $this->titleFormatter->getPrefixedDBkey( $target );
                $info = $this->mGoodLinks->get( $dbkey );
                if ( !$info ) {
                        return null;
@@ -145,17 +121,17 @@ class LinkCache {
         * Add a link for the title to the link cache
         *
         * @param int $id Page's ID
-        * @param Title $title
+        * @param LinkTarget $target
         * @param int $len Text's length
         * @param int $redir Whether the page is a redirect
         * @param int $revision Latest revision's ID
         * @param string|null $model Latest revision's content model ID
         * @param string|null $lang Language code of the page, if not the content language
         */
-       public function addGoodLinkObj( $id, Title $title, $len = -1, $redir = null,
+       public function addGoodLinkObj( $id, LinkTarget $target, $len = -1, $redir = null,
                $revision = 0, $model = null, $lang = null
        ) {
-               $dbkey = $title->getPrefixedDBkey();
+               $dbkey = $this->titleFormatter->getPrefixedDBkey( $target );
                $this->mGoodLinks->set( $dbkey, [
                        'id' => (int)$id,
                        'length' => (int)$len,
@@ -169,12 +145,12 @@ class LinkCache {
        /**
         * Same as above with better interface.
         * @since 1.19
-        * @param Title $title
+        * @param LinkTarget $target
         * @param stdClass $row Object which has the fields page_id, page_is_redirect,
         *  page_latest and page_content_model
         */
-       public function addGoodLinkObjFromRow( Title $title, $row ) {
-               $dbkey = $title->getPrefixedDBkey();
+       public function addGoodLinkObjFromRow( LinkTarget $target, $row ) {
+               $dbkey = $this->titleFormatter->getPrefixedDBkey( $target );
                $this->mGoodLinks->set( $dbkey, [
                        'id' => intval( $row->page_id ),
                        'length' => intval( $row->page_len ),
@@ -186,10 +162,10 @@ class LinkCache {
        }
 
        /**
-        * @param Title $title
+        * @param LinkTarget $target
         */
-       public function addBadLinkObj( Title $title ) {
-               $dbkey = $title->getPrefixedDBkey();
+       public function addBadLinkObj( LinkTarget $target ) {
+               $dbkey = $this->titleFormatter->getPrefixedDBkey( $target );
                if ( !$this->isBadLink( $dbkey ) ) {
                        $this->mBadLinks->set( $dbkey, 1 );
                }
@@ -203,10 +179,10 @@ class LinkCache {
        }
 
        /**
-        * @param Title $title
+        * @param LinkTarget $target
         */
-       public function clearLink( Title $title ) {
-               $dbkey = $title->getPrefixedDBkey();
+       public function clearLink( LinkTarget $target ) {
+               $dbkey = $this->titleFormatter->getPrefixedDBkey( $target );
                $this->mBadLinks->delete( $dbkey );
                $this->mGoodLinks->delete( $dbkey );
        }
@@ -214,6 +190,7 @@ class LinkCache {
        /**
         * Add a title to the link cache, return the page_id or zero if non-existent
         *
+        * @deprecated since 1.27, unused
         * @param string $title Prefixed DB key
         * @return int Page ID or zero
         */
@@ -226,15 +203,33 @@ class LinkCache {
        }
 
        /**
-        * Add a title to the link cache, return the page_id or zero if non-existent
+        * Fields that LinkCache needs to select
         *
-        * @param Title $nt Title object to add
-        * @return int Page ID or zero
+        * @since 1.28
+        * @return array
         */
-       public function addLinkObj( Title $nt ) {
+       public static function getSelectFields() {
                global $wgContentHandlerUseDB, $wgPageLanguageUseDB;
 
-               $key = $nt->getPrefixedDBkey();
+               $fields = [ 'page_id', 'page_len', 'page_is_redirect', 'page_latest' ];
+               if ( $wgContentHandlerUseDB ) {
+                       $fields[] = 'page_content_model';
+               }
+               if ( $wgPageLanguageUseDB ) {
+                       $fields[] = 'page_lang';
+               }
+
+               return $fields;
+       }
+
+       /**
+        * Add a title to the link cache, return the page_id or zero if non-existent
+        *
+        * @param LinkTarget $nt LinkTarget object to add
+        * @return int Page ID or zero
+        */
+       public function addLinkObj( LinkTarget $nt ) {
+               $key = $this->titleFormatter->getPrefixedDBkey( $nt );
                if ( $this->isBadLink( $key ) || $nt->isExternal() ) {
                        return 0;
                }
@@ -250,15 +245,7 @@ class LinkCache {
                // Some fields heavily used for linking...
                $db = $this->mForUpdate ? wfGetDB( DB_MASTER ) : wfGetDB( DB_SLAVE );
 
-               $fields = [ 'page_id', 'page_len', 'page_is_redirect', 'page_latest' ];
-               if ( $wgContentHandlerUseDB ) {
-                       $fields[] = 'page_content_model';
-               }
-               if ( $wgPageLanguageUseDB ) {
-                       $fields[] = 'page_lang';
-               }
-
-               $row = $db->selectRow( 'page', $fields,
+               $row = $db->selectRow( 'page', self::getSelectFields(),
                        [ 'page_namespace' => $nt->getNamespace(), 'page_title' => $nt->getDBkey() ],
                        __METHOD__
                );
index 2d4d20f..a2945af 100644 (file)
@@ -1055,8 +1055,8 @@ class ChangeTags {
                $tagUsage = self::tagUsageStatistics();
 
                if ( !is_null( $user ) ) {
-                       if ( !$user->isAllowed( 'managechangetags' ) ) {
-                               return Status::newFatal( 'tags-manage-no-permission' );
+                       if ( !$user->isAllowed( 'deletechangetags' ) ) {
+                               return Status::newFatal( 'tags-delete-no-permission' );
                        } elseif ( $user->isBlocked() ) {
                                return Status::newFatal( 'tags-manage-blocked' );
                        }
index 1770639..65a8c0e 100644 (file)
@@ -49,6 +49,9 @@ class LinksDeletionUpdate extends SqlDataUpdate implements EnqueueableDataUpdate
        public function doUpdate() {
                # Page may already be deleted, so don't just getId()
                $id = $this->pageId;
+               // Make sure all links update threads see the changes of each other.
+               // This handles the case when updates have to batched into several COMMITs.
+               $scopedLock = LinksUpdate::acquirePageLock( $this->mDb, $id );
 
                # Delete restrictions for it
                $this->mDb->delete( 'page_restrictions', [ 'pr_page' => $id ], __METHOD__ );
@@ -101,6 +104,11 @@ class LinksDeletionUpdate extends SqlDataUpdate implements EnqueueableDataUpdate
                                $this->mDb->delete( 'recentchanges', [ 'rc_id' => $rcIds ], __METHOD__ );
                        }
                }
+
+               $this->mDb->onTransactionIdle( function() use ( &$scopedLock ) {
+                       // Release the lock *after* the final COMMIT for correctness
+                       ScopedCallback::consume( $scopedLock );
+               } );
        }
 
        public function getAsJobSpecification() {
index c0205be..ac08374 100644 (file)
  */
 
 /**
- * See docs/deferred.txt
+ * Class the manages updates of *_link tables as well as similar extension-managed tables
+ *
+ * @note: LinksUpdate is managed by DeferredUpdates::execute(). Do not run this in a transaction.
  *
- * @todo document (e.g. one-sentence top-level class description).
+ * See docs/deferred.txt
  */
 class LinksUpdate extends SqlDataUpdate implements EnqueueableDataUpdate {
        // @todo make members protected, but make sure extensions don't break
@@ -82,6 +84,8 @@ class LinksUpdate extends SqlDataUpdate implements EnqueueableDataUpdate {
         */
        private $user;
 
+       const BATCH_SIZE = 500; // try to keep typical updates in a single transaction
+
        /**
         * Constructor
         *
@@ -91,7 +95,8 @@ class LinksUpdate extends SqlDataUpdate implements EnqueueableDataUpdate {
         * @throws MWException
         */
        function __construct( Title $title, ParserOutput $parserOutput, $recursive = true ) {
-               parent::__construct( false ); // no implicit transaction
+               // Implicit transactions are disabled as they interfere with batching
+               parent::__construct( false );
 
                $this->mTitle = $title;
                $this->mId = $title->getArticleID( Title::GAID_FOR_UPDATE );
@@ -141,16 +146,46 @@ class LinksUpdate extends SqlDataUpdate implements EnqueueableDataUpdate {
 
        /**
         * Update link tables with outgoing links from an updated article
+        *
+        * @note: this is managed by DeferredUpdates::execute(). Do not run this in a transaction.
         */
        public function doUpdate() {
+               // Make sure all links update threads see the changes of each other.
+               // This handles the case when updates have to batched into several COMMITs.
+               $scopedLock = self::acquirePageLock( $this->mDb, $this->mId );
+
                Hooks::run( 'LinksUpdate', [ &$this ] );
                $this->doIncrementalUpdate();
 
-               $this->mDb->onTransactionIdle( function() {
+               $this->mDb->onTransactionIdle( function() use ( &$scopedLock ) {
                        Hooks::run( 'LinksUpdateComplete', [ &$this ] );
+                       // Release the lock *after* the final COMMIT for correctness
+                       ScopedCallback::consume( $scopedLock );
                } );
        }
 
+       /**
+        * Acquire a lock for performing link table updates for a page on a DB
+        *
+        * @param IDatabase $dbw
+        * @param integer $pageId
+        * @return ScopedCallback|null Returns null on failure
+        * @throws RuntimeException
+        * @since 1.27
+        */
+       public static function acquirePageLock( IDatabase $dbw, $pageId ) {
+               $scopedLock = $dbw->getScopedLockAndFlush(
+                       "LinksUpdate:pageid:$pageId",
+                       __METHOD__,
+                       15
+               );
+               if ( !$scopedLock ) {
+                       throw new RuntimeException( "Could not acquire lock on page #$pageId." );
+               }
+
+               return $scopedLock;
+       }
+
        protected function doIncrementalUpdate() {
                # Page links
                $existing = $this->getExistingLinks();
@@ -160,7 +195,6 @@ class LinksUpdate extends SqlDataUpdate implements EnqueueableDataUpdate {
 
                # Image links
                $existing = $this->getExistingImages();
-
                $imageDeletes = $this->getImageDeletions( $existing );
                $this->incrTableUpdate( 'imagelinks', 'il', $imageDeletes,
                        $this->getImageInsertions( $existing ) );
@@ -191,9 +225,7 @@ class LinksUpdate extends SqlDataUpdate implements EnqueueableDataUpdate {
 
                # Category links
                $existing = $this->getExistingCategories();
-
                $categoryDeletes = $this->getCategoryDeletions( $existing );
-
                $this->incrTableUpdate( 'categorylinks', 'cl', $categoryDeletes,
                        $this->getCategoryInsertions( $existing ) );
 
@@ -205,9 +237,7 @@ class LinksUpdate extends SqlDataUpdate implements EnqueueableDataUpdate {
 
                # Page properties
                $existing = $this->getExistingProperties();
-
                $propertiesDeletes = $this->getPropertyDeletions( $existing );
-
                $this->incrTableUpdate( 'page_props', 'pp', $propertiesDeletes,
                        $this->getPropertyInsertions( $existing ) );
 
@@ -307,44 +337,69 @@ class LinksUpdate extends SqlDataUpdate implements EnqueueableDataUpdate {
         * @param array $deletions
         * @param array $insertions Rows to insert
         */
-       function incrTableUpdate( $table, $prefix, $deletions, $insertions ) {
-               if ( $table == 'page_props' ) {
+       private function incrTableUpdate( $table, $prefix, $deletions, $insertions ) {
+               if ( $table === 'page_props' ) {
                        $fromField = 'pp_page';
                } else {
                        $fromField = "{$prefix}_from";
                }
-               $where = [ $fromField => $this->mId ];
-               if ( $table == 'pagelinks' || $table == 'templatelinks' || $table == 'iwlinks' ) {
-                       if ( $table == 'iwlinks' ) {
-                               $baseKey = 'iwl_prefix';
-                       } else {
-                               $baseKey = "{$prefix}_namespace";
+
+               $deleteWheres = []; // list of WHERE clause arrays for each DB delete() call
+               if ( $table === 'pagelinks' || $table === 'templatelinks' || $table === 'iwlinks' ) {
+                       $baseKey =  ( $table === 'iwlinks' ) ? 'iwl_prefix' : "{$prefix}_namespace";
+
+                       $curBatchSize = 0;
+                       $curDeletionBatch = [];
+                       $deletionBatches = [];
+                       foreach ( $deletions as $ns => $dbKeys ) {
+                               foreach ( $dbKeys as $dbKey => $unused ) {
+                                       $curDeletionBatch[$ns][$dbKey] = 1;
+                                       if ( ++$curBatchSize >= self::BATCH_SIZE ) {
+                                               $deletionBatches[] = $curDeletionBatch;
+                                               $curDeletionBatch = [];
+                                               $curBatchSize = 0;
+                                       }
+                               }
                        }
-                       $clause = $this->mDb->makeWhereFrom2d( $deletions, $baseKey, "{$prefix}_title" );
-                       if ( $clause ) {
-                               $where[] = $clause;
-                       } else {
-                               $where = false;
+                       if ( $curDeletionBatch ) {
+                               $deletionBatches[] = $curDeletionBatch;
+                       }
+
+                       foreach ( $deletionBatches as $deletionBatch ) {
+                               $deleteWheres[] = [
+                                       $fromField => $this->mId,
+                                       $this->mDb->makeWhereFrom2d( $deletionBatch, $baseKey, "{$prefix}_title" )
+                               ];
                        }
                } else {
-                       if ( $table == 'langlinks' ) {
+                       if ( $table === 'langlinks' ) {
                                $toField = 'll_lang';
-                       } elseif ( $table == 'page_props' ) {
+                       } elseif ( $table === 'page_props' ) {
                                $toField = 'pp_propname';
                        } else {
                                $toField = $prefix . '_to';
                        }
-                       if ( count( $deletions ) ) {
-                               $where[$toField] = array_keys( $deletions );
-                       } else {
-                               $where = false;
+
+                       $deletionBatches = array_chunk( array_keys( $deletions ), self::BATCH_SIZE );
+                       foreach ( $deletionBatches as $deletionBatch ) {
+                               $deleteWheres[] = [ $fromField => $this->mId, $toField => $deletionBatch ];
                        }
                }
-               if ( $where ) {
-                       $this->mDb->delete( $table, $where, __METHOD__ );
+
+               foreach ( $deleteWheres as $deleteWhere ) {
+                       $this->mDb->delete( $table, $deleteWhere, __METHOD__ );
+                       $this->mDb->commit( __METHOD__, 'flush' );
+                       wfGetLBFactory()->waitForReplication();
+               }
+
+               $insertBatches = array_chunk( $insertions, self::BATCH_SIZE );
+               foreach ( $insertBatches as $insertBatch ) {
+                       $this->mDb->insert( $table, $insertBatch, __METHOD__, 'IGNORE' );
+                       $this->mDb->commit( __METHOD__, 'flush' );
+                       wfGetLBFactory()->waitForReplication();
                }
+
                if ( count( $insertions ) ) {
-                       $this->mDb->insert( $table, $insertions, __METHOD__, 'IGNORE' );
                        Hooks::run( 'LinksUpdateAfterInsert', [ $this, $table, $insertions ] );
                }
        }
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 7a2ed50..711750b 100644 (file)
@@ -221,22 +221,27 @@ class OOUIHTMLForm extends HTMLForm {
                // FIXME This only works for forms with no subsections
                if ( $fieldset instanceof OOUI\FieldsetLayout ) {
                        $classes = [ 'mw-htmlform-ooui-header' ];
-                       if ( !$this->mHeader ) {
-                               $classes[] = 'mw-htmlform-ooui-header-empty';
-                       }
                        if ( $this->oouiErrors ) {
                                $classes[] = 'mw-htmlform-ooui-header-errors';
                        }
-                       $fieldset->addItems( [
-                               new OOUI\FieldLayout(
-                                       new OOUI\LabelWidget( [ 'label' => new OOUI\HtmlSnippet( $this->mHeader ) ] ),
-                                       [
-                                               'align' => 'top',
-                                               'errors' => $this->oouiErrors,
-                                               'classes' => $classes,
-                                       ]
-                               )
-                       ], 0 );
+                       if ( $this->mHeader || $this->oouiErrors ) {
+                               // if there's no header, don't create an (empty) LabelWidget, simply use a placeholder
+                               if ( $this->mHeader ) {
+                                       $element = new OOUI\LabelWidget( [ 'label' => new OOUI\HtmlSnippet( $this->mHeader ) ] );
+                               } else {
+                                       $element = new OOUI\Widget( [] );
+                               }
+                               $fieldset->addItems( [
+                                       new OOUI\FieldLayout(
+                                               $element,
+                                               [
+                                                       'align' => 'top',
+                                                       'errors' => $this->oouiErrors,
+                                                       'classes' => $classes,
+                                               ]
+                                       )
+                               ], 0 );
+                       }
                }
                return $fieldset;
        }
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 d0f067f..9c1ec8e 100644 (file)
  * @file
  */
 
-use RunningStat\RunningStat;
-
 /**
  * Convenience class for working with XHProf
  * <https://github.com/phacility/xhprof>. XHProf can be installed as a PECL
  * package for use with PHP5 (Zend PHP) and is built-in to HHVM 3.3.0.
  *
- * @author Bryan Davis <bd808@wikimedia.org>
- * @copyright © 2014 Bryan Davis and Wikimedia Foundation.
- * @since 1.25
+ * @since 1.28
  */
 class Xhprof {
-
-       /**
-        * @var array $config
-        */
-       protected $config;
-
-       /**
-        * Hierarchical profiling data returned by xhprof.
-        * @var array $hieraData
-        */
-       protected $hieraData;
-
        /**
-        * Per-function inclusive data.
-        * @var array $inclusive
+        * @var bool $enabled Whether XHProf is currently running.
         */
-       protected $inclusive;
+       protected static $enabled;
 
        /**
-        * Per-function inclusive and exclusive data.
-        * @var array $complete
+        * Start xhprof profiler
         */
-       protected $complete;
-
-       /**
-        * Configuration data can contain:
-        * - flags:   Optional flags to add additional information to the
-        *            profiling data collected.
-        *            (XHPROF_FLAGS_NO_BUILTINS, XHPROF_FLAGS_CPU,
-        *            XHPROF_FLAGS_MEMORY)
-        * - exclude: Array of function names to exclude from profiling.
-        * - include: Array of function names to include in profiling.
-        * - sort:    Key to sort per-function reports on.
-        *
-        * Note: When running under HHVM, xhprof will always behave as though the
-        * XHPROF_FLAGS_NO_BUILTINS flag has been used unless the
-        * Eval.JitEnableRenameFunction option is enabled for the HHVM process.
-        *
-        * @param array $config
-        */
-       public function __construct( array $config = [] ) {
-               $this->config = array_merge(
-                       [
-                               'flags' => 0,
-                               'exclude' => [],
-                               'include' => null,
-                               'sort' => 'wt',
-                       ],
-                       $config
-               );
-
-               xhprof_enable( $this->config['flags'], [
-                       'ignored_functions' => $this->config['exclude']
-               ] );
+       public static function isEnabled() {
+               return self::$enabled;
        }
 
        /**
-        * Stop collecting profiling data.
-        *
-        * Only the first invocation of this method will effect the internal
-        * object state. Subsequent calls will return the data collected by the
-        * initial call.
-        *
-        * @return array Collected profiling data (possibly cached)
+        * Start xhprof profiler
         */
-       public function stop() {
-               if ( $this->hieraData === null ) {
-                       $this->hieraData = $this->pruneData( xhprof_disable() );
+       public static function enable( $flags = 0, $options = [] ) {
+               if ( self::isEnabled() ) {
+                       throw new Exception( 'Xhprof profiling is already enabled.' );
                }
-               return $this->hieraData;
-       }
-
-       /**
-        * Load raw data from a prior run for analysis.
-        * Stops any existing data collection and clears internal caches.
-        *
-        * Any 'include' filters configured for this Xhprof instance will be
-        * enforced on the data as it is loaded. 'exclude' filters will however
-        * not be enforced as they are an XHProf intrinsic behavior.
-        *
-        * @param array $data
-        * @see getRawData()
-        */
-       public function loadRawData( array $data ) {
-               $this->stop();
-               $this->inclusive = null;
-               $this->complete = null;
-               $this->hieraData = $this->pruneData( $data );
-       }
-
-       /**
-        * Get raw data collected by xhprof.
-        *
-        * If data collection has not been stopped yet this method will halt
-        * collection to gather the profiling data.
-        *
-        * Each key in the returned array is an edge label for the call graph in
-        * the form "caller==>callee". There is once special case edge labled
-        * simply "main()" which represents the global scope entry point of the
-        * application.
-        *
-        * XHProf will collect different data depending on the flags that are used:
-        * - ct:    Number of matching events seen.
-        * - wt:    Inclusive elapsed wall time for this event in microseconds.
-        * - cpu:   Inclusive elapsed cpu time for this event in microseconds.
-        *          (XHPROF_FLAGS_CPU)
-        * - mu:    Delta of memory usage from start to end of callee in bytes.
-        *          (XHPROF_FLAGS_MEMORY)
-        * - pmu:   Delta of peak memory usage from start to end of callee in
-        *          bytes. (XHPROF_FLAGS_MEMORY)
-        * - alloc: Delta of amount memory requested from malloc() by the callee,
-        *          in bytes. (XHPROF_FLAGS_MALLOC)
-        * - free:  Delta of amount of memory passed to free() by the callee, in
-        *          bytes. (XHPROF_FLAGS_MALLOC)
-        *
-        * @return array
-        * @see stop()
-        * @see getInclusiveMetrics()
-        * @see getCompleteMetrics()
-        */
-       public function getRawData() {
-               return $this->stop();
+               self::$enabled = true;
+               xhprof_enable( $flags, $options );
        }
 
        /**
-        * Convert an xhprof data key into an array of ['parent', 'child']
-        * function names.
-        *
-        * The resulting array is left padded with nulls, so a key
-        * with no parent (eg 'main()') will return [null, 'function'].
+        * Stop xhprof profiler
         *
-        * @return array
+        * @return array|null xhprof data from the run, or null if xhprof was not running.
         */
-       public static function splitKey( $key ) {
-               return array_pad( explode( '==>', $key, 2 ), -2, null );
-       }
-
-       /**
-        * Remove data for functions that are not included in the 'include'
-        * configuration array.
-        *
-        * @param array $data Raw xhprof data
-        * @return array
-        */
-       protected function pruneData( $data ) {
-               if ( !$this->config['include'] ) {
-                       return $data;
-               }
-
-               $want = array_fill_keys( $this->config['include'], true );
-               $want['main()'] = true;
-
-               $keep = [];
-               foreach ( $data as $key => $stats ) {
-                       list( $parent, $child ) = self::splitKey( $key );
-                       if ( isset( $want[$parent] ) || isset( $want[$child] ) ) {
-                               $keep[$key] = $stats;
-                       }
+       public static function disable() {
+               if ( self::isEnabled() ) {
+                       self::$enabled = false;
+                       return xhprof_disable();
                }
-               return $keep;
-       }
-
-       /**
-        * Get the inclusive metrics for each function call. Inclusive metrics
-        * for given function include the metrics for all functions that were
-        * called from that function during the measurement period.
-        *
-        * If data collection has not been stopped yet this method will halt
-        * collection to gather the profiling data.
-        *
-        * See getRawData() for a description of the metric that are returned for
-        * each funcition call. The values for the wt, cpu, mu and pmu metrics are
-        * arrays with these values:
-        * - total: Cumulative value
-        * - min: Minimum value
-        * - mean: Mean (average) value
-        * - max: Maximum value
-        * - variance: Variance (spread) of the values
-        *
-        * @return array
-        * @see getRawData()
-        * @see getCompleteMetrics()
-        */
-       public function getInclusiveMetrics() {
-               if ( $this->inclusive === null ) {
-                       // Make sure we have data to work with
-                       $this->stop();
-
-                       $main = $this->hieraData['main()'];
-                       $hasCpu = isset( $main['cpu'] );
-                       $hasMu = isset( $main['mu'] );
-                       $hasAlloc = isset( $main['alloc'] );
-
-                       $this->inclusive = [];
-                       foreach ( $this->hieraData as $key => $stats ) {
-                               list( $parent, $child ) = self::splitKey( $key );
-                               if ( !isset( $this->inclusive[$child] ) ) {
-                                       $this->inclusive[$child] = [
-                                               'ct' => 0,
-                                               'wt' => new RunningStat(),
-                                       ];
-                                       if ( $hasCpu ) {
-                                               $this->inclusive[$child]['cpu'] = new RunningStat();
-                                       }
-                                       if ( $hasMu ) {
-                                               $this->inclusive[$child]['mu'] = new RunningStat();
-                                               $this->inclusive[$child]['pmu'] = new RunningStat();
-                                       }
-                                       if ( $hasAlloc ) {
-                                               $this->inclusive[$child]['alloc'] = new RunningStat();
-                                               $this->inclusive[$child]['free'] = new RunningStat();
-                                       }
-                               }
-
-                               $this->inclusive[$child]['ct'] += $stats['ct'];
-                               foreach ( $stats as $stat => $value ) {
-                                       if ( $stat === 'ct' ) {
-                                               continue;
-                                       }
-
-                                       if ( !isset( $this->inclusive[$child][$stat] ) ) {
-                                               // Ignore unknown stats
-                                               continue;
-                                       }
-
-                                       for ( $i = 0; $i < $stats['ct']; $i++ ) {
-                                               $this->inclusive[$child][$stat]->addObservation(
-                                                       $value / $stats['ct']
-                                               );
-                                       }
-                               }
-                       }
-
-                       // Convert RunningStat instances to static arrays and add
-                       // percentage stats.
-                       foreach ( $this->inclusive as $func => $stats ) {
-                               foreach ( $stats as $name => $value ) {
-                                       if ( $value instanceof RunningStat ) {
-                                               $total = $value->m1 * $value->n;
-                                               $percent = ( isset( $main[$name] ) && $main[$name] )
-                                                       ? 100 * $total / $main[$name]
-                                                       : 0;
-                                               $this->inclusive[$func][$name] = [
-                                                       'total' => $total,
-                                                       'min' => $value->min,
-                                                       'mean' => $value->m1,
-                                                       'max' => $value->max,
-                                                       'variance' => $value->m2,
-                                                       'percent' => $percent,
-                                               ];
-                                       }
-                               }
-                       }
-
-                       uasort( $this->inclusive, self::makeSortFunction(
-                               $this->config['sort'], 'total'
-                       ) );
-               }
-               return $this->inclusive;
-       }
-
-       /**
-        * Get the inclusive and exclusive metrics for each function call.
-        *
-        * If data collection has not been stopped yet this method will halt
-        * collection to gather the profiling data.
-        *
-        * In addition to the normal data contained in the inclusive metrics, the
-        * metrics have an additional 'exclusive' measurement which is the total
-        * minus the totals of all child function calls.
-        *
-        * @return array
-        * @see getRawData()
-        * @see getInclusiveMetrics()
-        */
-       public function getCompleteMetrics() {
-               if ( $this->complete === null ) {
-                       // Start with inclusive data
-                       $this->complete = $this->getInclusiveMetrics();
-
-                       foreach ( $this->complete as $func => $stats ) {
-                               foreach ( $stats as $stat => $value ) {
-                                       if ( $stat === 'ct' ) {
-                                               continue;
-                                       }
-                                       // Initialize exclusive data with inclusive totals
-                                       $this->complete[$func][$stat]['exclusive'] = $value['total'];
-                               }
-                               // Add sapce for call tree information to be filled in later
-                               $this->complete[$func]['calls'] = [];
-                               $this->complete[$func]['subcalls'] = [];
-                       }
-
-                       foreach ( $this->hieraData as $key => $stats ) {
-                               list( $parent, $child ) = self::splitKey( $key );
-                               if ( $parent !== null ) {
-                                       // Track call tree information
-                                       $this->complete[$child]['calls'][$parent] = $stats;
-                                       $this->complete[$parent]['subcalls'][$child] = $stats;
-                               }
-
-                               if ( isset( $this->complete[$parent] ) ) {
-                                       // Deduct child inclusive data from exclusive data
-                                       foreach ( $stats as $stat => $value ) {
-                                               if ( $stat === 'ct' ) {
-                                                       continue;
-                                               }
-
-                                               if ( !isset( $this->complete[$parent][$stat] ) ) {
-                                                       // Ignore unknown stats
-                                                       continue;
-                                               }
-
-                                               $this->complete[$parent][$stat]['exclusive'] -= $value;
-                                       }
-                               }
-                       }
-
-                       uasort( $this->complete, self::makeSortFunction(
-                               $this->config['sort'], 'exclusive'
-                       ) );
-               }
-               return $this->complete;
-       }
-
-       /**
-        * Get a list of all callers of a given function.
-        *
-        * @param string $function Function name
-        * @return array
-        * @see getEdges()
-        */
-       public function getCallers( $function ) {
-               $edges = $this->getCompleteMetrics();
-               if ( isset( $edges[$function]['calls'] ) ) {
-                       return array_keys( $edges[$function]['calls'] );
-               } else {
-                       return [];
-               }
-       }
-
-       /**
-        * Get a list of all callees from a given function.
-        *
-        * @param string $function Function name
-        * @return array
-        * @see getEdges()
-        */
-       public function getCallees( $function ) {
-               $edges = $this->getCompleteMetrics();
-               if ( isset( $edges[$function]['subcalls'] ) ) {
-                       return array_keys( $edges[$function]['subcalls'] );
-               } else {
-                       return [];
-               }
-       }
-
-       /**
-        * Find the critical path for the given metric.
-        *
-        * @param string $metric Metric to find critical path for
-        * @return array
-        */
-       public function getCriticalPath( $metric = 'wt' ) {
-               $this->stop();
-               $func = 'main()';
-               $path = [
-                       $func => $this->hieraData[$func],
-               ];
-               while ( $func ) {
-                       $callees = $this->getCallees( $func );
-                       $maxCallee = null;
-                       $maxCall = null;
-                       foreach ( $callees as $callee ) {
-                               $call = "{$func}==>{$callee}";
-                               if ( $maxCall === null ||
-                                       $this->hieraData[$call][$metric] >
-                                               $this->hieraData[$maxCall][$metric]
-                               ) {
-                                       $maxCallee = $callee;
-                                       $maxCall = $call;
-                               }
-                       }
-                       if ( $maxCall !== null ) {
-                               $path[$maxCall] = $this->hieraData[$maxCall];
-                       }
-                       $func = $maxCallee;
-               }
-               return $path;
-       }
-
-       /**
-        * Make a closure to use as a sort function. The resulting function will
-        * sort by descending numeric values (largest value first).
-        *
-        * @param string $key Data key to sort on
-        * @param string $sub Sub key to sort array values on
-        * @return Closure
-        */
-       public static function makeSortFunction( $key, $sub ) {
-               return function ( $a, $b ) use ( $key, $sub ) {
-                       if ( isset( $a[$key] ) && isset( $b[$key] ) ) {
-                               // Descending sort: larger values will be first in result.
-                               // Assumes all values are numeric.
-                               // Values for 'main()' will not have sub keys
-                               $valA = is_array( $a[$key] ) ? $a[$key][$sub] : $a[$key];
-                               $valB = is_array( $b[$key] ) ? $b[$key][$sub] : $b[$key];
-                               return $valB - $valA;
-                       } else {
-                               // Sort datum with the key before those without
-                               return isset( $a[$key] ) ? -1 : 1;
-                       }
-               };
        }
 }
diff --git a/includes/libs/XhprofData.php b/includes/libs/XhprofData.php
new file mode 100644 (file)
index 0000000..c6da432
--- /dev/null
@@ -0,0 +1,384 @@
+<?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
+ */
+
+use RunningStat\RunningStat;
+
+/**
+ * Convenience class for working with XHProf profiling data
+ * <https://github.com/phacility/xhprof>. XHProf can be installed as a PECL
+ * package for use with PHP5 (Zend PHP) and is built-in to HHVM 3.3.0.
+ *
+ * @author Bryan Davis <bd808@wikimedia.org>
+ * @copyright © 2014 Bryan Davis and Wikimedia Foundation.
+ * @since 1.28
+ */
+class XhprofData {
+
+       /**
+        * @var array $config
+        */
+       protected $config;
+
+       /**
+        * Hierarchical profiling data returned by xhprof.
+        * @var array $hieraData
+        */
+       protected $hieraData;
+
+       /**
+        * Per-function inclusive data.
+        * @var array $inclusive
+        */
+       protected $inclusive;
+
+       /**
+        * Per-function inclusive and exclusive data.
+        * @var array $complete
+        */
+       protected $complete;
+
+       /**
+        * Configuration data can contain:
+        * - include: Array of function names to include in profiling.
+        * - sort:    Key to sort per-function reports on.
+        *
+        * @param array $data Xhprof profiling data, as returned by xhprof_disable()
+        * @param array $config
+        */
+       public function __construct( array $data, array $config = [] ) {
+               $this->config = array_merge( [
+                       'include' => null,
+                       'sort' => 'wt',
+               ], $config );
+
+               $this->hieraData = $this->pruneData( $data );
+       }
+
+       /**
+        * Get raw data collected by xhprof.
+        *
+        * Each key in the returned array is an edge label for the call graph in
+        * the form "caller==>callee". There is once special case edge labled
+        * simply "main()" which represents the global scope entry point of the
+        * application.
+        *
+        * XHProf will collect different data depending on the flags that are used:
+        * - ct:    Number of matching events seen.
+        * - wt:    Inclusive elapsed wall time for this event in microseconds.
+        * - cpu:   Inclusive elapsed cpu time for this event in microseconds.
+        *          (XHPROF_FLAGS_CPU)
+        * - mu:    Delta of memory usage from start to end of callee in bytes.
+        *          (XHPROF_FLAGS_MEMORY)
+        * - pmu:   Delta of peak memory usage from start to end of callee in
+        *          bytes. (XHPROF_FLAGS_MEMORY)
+        * - alloc: Delta of amount memory requested from malloc() by the callee,
+        *          in bytes. (XHPROF_FLAGS_MALLOC)
+        * - free:  Delta of amount of memory passed to free() by the callee, in
+        *          bytes. (XHPROF_FLAGS_MALLOC)
+        *
+        * @return array
+        * @see getInclusiveMetrics()
+        * @see getCompleteMetrics()
+        */
+       public function getRawData() {
+               return $this->hieraData;
+       }
+
+       /**
+        * Convert an xhprof data key into an array of ['parent', 'child']
+        * function names.
+        *
+        * The resulting array is left padded with nulls, so a key
+        * with no parent (eg 'main()') will return [null, 'function'].
+        *
+        * @return array
+        */
+       public static function splitKey( $key ) {
+               return array_pad( explode( '==>', $key, 2 ), -2, null );
+       }
+
+       /**
+        * Remove data for functions that are not included in the 'include'
+        * configuration array.
+        *
+        * @param array $data Raw xhprof data
+        * @return array
+        */
+       protected function pruneData( $data ) {
+               if ( !$this->config['include'] ) {
+                       return $data;
+               }
+
+               $want = array_fill_keys( $this->config['include'], true );
+               $want['main()'] = true;
+
+               $keep = [];
+               foreach ( $data as $key => $stats ) {
+                       list( $parent, $child ) = self::splitKey( $key );
+                       if ( isset( $want[$parent] ) || isset( $want[$child] ) ) {
+                               $keep[$key] = $stats;
+                       }
+               }
+               return $keep;
+       }
+
+       /**
+        * Get the inclusive metrics for each function call. Inclusive metrics
+        * for given function include the metrics for all functions that were
+        * called from that function during the measurement period.
+        *
+        * See getRawData() for a description of the metric that are returned for
+        * each funcition call. The values for the wt, cpu, mu and pmu metrics are
+        * arrays with these values:
+        * - total: Cumulative value
+        * - min: Minimum value
+        * - mean: Mean (average) value
+        * - max: Maximum value
+        * - variance: Variance (spread) of the values
+        *
+        * @return array
+        * @see getRawData()
+        * @see getCompleteMetrics()
+        */
+       public function getInclusiveMetrics() {
+               if ( $this->inclusive === null ) {
+                       $main = $this->hieraData['main()'];
+                       $hasCpu = isset( $main['cpu'] );
+                       $hasMu = isset( $main['mu'] );
+                       $hasAlloc = isset( $main['alloc'] );
+
+                       $this->inclusive = [];
+                       foreach ( $this->hieraData as $key => $stats ) {
+                               list( $parent, $child ) = self::splitKey( $key );
+                               if ( !isset( $this->inclusive[$child] ) ) {
+                                       $this->inclusive[$child] = [
+                                               'ct' => 0,
+                                               'wt' => new RunningStat(),
+                                       ];
+                                       if ( $hasCpu ) {
+                                               $this->inclusive[$child]['cpu'] = new RunningStat();
+                                       }
+                                       if ( $hasMu ) {
+                                               $this->inclusive[$child]['mu'] = new RunningStat();
+                                               $this->inclusive[$child]['pmu'] = new RunningStat();
+                                       }
+                                       if ( $hasAlloc ) {
+                                               $this->inclusive[$child]['alloc'] = new RunningStat();
+                                               $this->inclusive[$child]['free'] = new RunningStat();
+                                       }
+                               }
+
+                               $this->inclusive[$child]['ct'] += $stats['ct'];
+                               foreach ( $stats as $stat => $value ) {
+                                       if ( $stat === 'ct' ) {
+                                               continue;
+                                       }
+
+                                       if ( !isset( $this->inclusive[$child][$stat] ) ) {
+                                               // Ignore unknown stats
+                                               continue;
+                                       }
+
+                                       for ( $i = 0; $i < $stats['ct']; $i++ ) {
+                                               $this->inclusive[$child][$stat]->addObservation(
+                                                       $value / $stats['ct']
+                                               );
+                                       }
+                               }
+                       }
+
+                       // Convert RunningStat instances to static arrays and add
+                       // percentage stats.
+                       foreach ( $this->inclusive as $func => $stats ) {
+                               foreach ( $stats as $name => $value ) {
+                                       if ( $value instanceof RunningStat ) {
+                                               $total = $value->m1 * $value->n;
+                                               $percent = ( isset( $main[$name] ) && $main[$name] )
+                                                       ? 100 * $total / $main[$name]
+                                                       : 0;
+                                               $this->inclusive[$func][$name] = [
+                                                       'total' => $total,
+                                                       'min' => $value->min,
+                                                       'mean' => $value->m1,
+                                                       'max' => $value->max,
+                                                       'variance' => $value->m2,
+                                                       'percent' => $percent,
+                                               ];
+                                       }
+                               }
+                       }
+
+                       uasort( $this->inclusive, self::makeSortFunction(
+                               $this->config['sort'], 'total'
+                       ) );
+               }
+               return $this->inclusive;
+       }
+
+       /**
+        * Get the inclusive and exclusive metrics for each function call.
+        *
+        * In addition to the normal data contained in the inclusive metrics, the
+        * metrics have an additional 'exclusive' measurement which is the total
+        * minus the totals of all child function calls.
+        *
+        * @return array
+        * @see getRawData()
+        * @see getInclusiveMetrics()
+        */
+       public function getCompleteMetrics() {
+               if ( $this->complete === null ) {
+                       // Start with inclusive data
+                       $this->complete = $this->getInclusiveMetrics();
+
+                       foreach ( $this->complete as $func => $stats ) {
+                               foreach ( $stats as $stat => $value ) {
+                                       if ( $stat === 'ct' ) {
+                                               continue;
+                                       }
+                                       // Initialize exclusive data with inclusive totals
+                                       $this->complete[$func][$stat]['exclusive'] = $value['total'];
+                               }
+                               // Add sapce for call tree information to be filled in later
+                               $this->complete[$func]['calls'] = [];
+                               $this->complete[$func]['subcalls'] = [];
+                       }
+
+                       foreach ( $this->hieraData as $key => $stats ) {
+                               list( $parent, $child ) = self::splitKey( $key );
+                               if ( $parent !== null ) {
+                                       // Track call tree information
+                                       $this->complete[$child]['calls'][$parent] = $stats;
+                                       $this->complete[$parent]['subcalls'][$child] = $stats;
+                               }
+
+                               if ( isset( $this->complete[$parent] ) ) {
+                                       // Deduct child inclusive data from exclusive data
+                                       foreach ( $stats as $stat => $value ) {
+                                               if ( $stat === 'ct' ) {
+                                                       continue;
+                                               }
+
+                                               if ( !isset( $this->complete[$parent][$stat] ) ) {
+                                                       // Ignore unknown stats
+                                                       continue;
+                                               }
+
+                                               $this->complete[$parent][$stat]['exclusive'] -= $value;
+                                       }
+                               }
+                       }
+
+                       uasort( $this->complete, self::makeSortFunction(
+                               $this->config['sort'], 'exclusive'
+                       ) );
+               }
+               return $this->complete;
+       }
+
+       /**
+        * Get a list of all callers of a given function.
+        *
+        * @param string $function Function name
+        * @return array
+        * @see getEdges()
+        */
+       public function getCallers( $function ) {
+               $edges = $this->getCompleteMetrics();
+               if ( isset( $edges[$function]['calls'] ) ) {
+                       return array_keys( $edges[$function]['calls'] );
+               } else {
+                       return [];
+               }
+       }
+
+       /**
+        * Get a list of all callees from a given function.
+        *
+        * @param string $function Function name
+        * @return array
+        * @see getEdges()
+        */
+       public function getCallees( $function ) {
+               $edges = $this->getCompleteMetrics();
+               if ( isset( $edges[$function]['subcalls'] ) ) {
+                       return array_keys( $edges[$function]['subcalls'] );
+               } else {
+                       return [];
+               }
+       }
+
+       /**
+        * Find the critical path for the given metric.
+        *
+        * @param string $metric Metric to find critical path for
+        * @return array
+        */
+       public function getCriticalPath( $metric = 'wt' ) {
+               $func = 'main()';
+               $path = [
+                       $func => $this->hieraData[$func],
+               ];
+               while ( $func ) {
+                       $callees = $this->getCallees( $func );
+                       $maxCallee = null;
+                       $maxCall = null;
+                       foreach ( $callees as $callee ) {
+                               $call = "{$func}==>{$callee}";
+                               if ( $maxCall === null ||
+                                       $this->hieraData[$call][$metric] >
+                                               $this->hieraData[$maxCall][$metric]
+                               ) {
+                                       $maxCallee = $callee;
+                                       $maxCall = $call;
+                               }
+                       }
+                       if ( $maxCall !== null ) {
+                               $path[$maxCall] = $this->hieraData[$maxCall];
+                       }
+                       $func = $maxCallee;
+               }
+               return $path;
+       }
+
+       /**
+        * Make a closure to use as a sort function. The resulting function will
+        * sort by descending numeric values (largest value first).
+        *
+        * @param string $key Data key to sort on
+        * @param string $sub Sub key to sort array values on
+        * @return Closure
+        */
+       public static function makeSortFunction( $key, $sub ) {
+               return function ( $a, $b ) use ( $key, $sub ) {
+                       if ( isset( $a[$key] ) && isset( $b[$key] ) ) {
+                               // Descending sort: larger values will be first in result.
+                               // Assumes all values are numeric.
+                               // Values for 'main()' will not have sub keys
+                               $valA = is_array( $a[$key] ) ? $a[$key][$sub] : $a[$key];
+                               $valB = is_array( $b[$key] ) ? $b[$key][$sub] : $b[$key];
+                               return $valB - $valA;
+                       } else {
+                               // Sort datum with the key before those without
+                               return isset( $a[$key] ) ? -1 : 1;
+                       }
+               };
+       }
+}
index 18cc10e..470a38c 100644 (file)
@@ -110,12 +110,12 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
        /** Cache format version number */
        const VERSION = 1;
 
-       const FLD_VERSION = 0;
-       const FLD_VALUE = 1;
-       const FLD_TTL = 2;
-       const FLD_TIME = 3;
-       const FLD_FLAGS = 4;
-       const FLD_HOLDOFF = 5;
+       const FLD_VERSION = 0; // key to cache version number
+       const FLD_VALUE = 1; // key to the cached value
+       const FLD_TTL = 2; // key to the original TTL
+       const FLD_TIME = 3; // key to the cache time
+       const FLD_FLAGS = 4; // key to the flags bitfield
+       const FLD_HOLDOFF = 5; // key to any hold-off TTL
 
        /** @var integer Treat this value as expired-on-arrival */
        const FLG_STALE = 1;
@@ -377,8 +377,9 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
         * @return bool Success
         */
        final public function set( $key, $value, $ttl = 0, array $opts = [] ) {
+               $now = microtime( true );
                $lockTSE = isset( $opts['lockTSE'] ) ? $opts['lockTSE'] : self::TSE_NONE;
-               $age = isset( $opts['since'] ) ? max( 0, microtime( true ) - $opts['since'] ) : 0;
+               $age = isset( $opts['since'] ) ? max( 0, $now - $opts['since'] ) : 0;
                $lag = isset( $opts['lag'] ) ? $opts['lag'] : 0;
 
                // Do not cache potentially uncommitted data as it might get rolled back
@@ -413,7 +414,7 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
                }
 
                // Wrap that value with time/TTL/version metadata
-               $wrapped = $this->wrap( $value, $ttl ) + $wrapExtra;
+               $wrapped = $this->wrap( $value, $ttl, $now ) + $wrapExtra;
 
                $func = function ( $cache, $key, $cWrapped ) use ( $wrapped ) {
                        return ( is_string( $cWrapped ) )
@@ -1009,14 +1010,15 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
         *
         * @param mixed $value
         * @param integer $ttl [0=forever]
+        * @param float $now Unix Current timestamp just before calling set()
         * @return array
         */
-       protected function wrap( $value, $ttl ) {
+       protected function wrap( $value, $ttl, $now ) {
                return [
                        self::FLD_VERSION => self::VERSION,
                        self::FLD_VALUE => $value,
                        self::FLD_TTL => $ttl,
-                       self::FLD_TIME => microtime( true )
+                       self::FLD_TIME => $now
                ];
        }
 
@@ -1024,7 +1026,7 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
         * Do not use this method outside WANObjectCache
         *
         * @param array|string|bool $wrapped
-        * @param float $now Unix Current timestamp (preferrable pre-query)
+        * @param float $now Unix Current timestamp (preferrably pre-query)
         * @return array (mixed; false if absent/invalid, current time left)
         */
        protected function unwrap( $wrapped, $now ) {
index a55ddf3..3b8b513 100644 (file)
@@ -456,10 +456,18 @@ class CoreParserFunctions {
                                                $converter->markNoConversion( wfEscapeWikiText( $text ) )
                                        )->inContentLanguage()->text() .
                                        '</span>';
+                       } else {
+                               return '';
                        }
+               } else {
+                       $converter = $parser->getConverterLanguage()->getConverter();
+                       return '<span class="error">' .
+                               wfMessage( 'restricted-displaytitle',
+                                       // Message should be parsed, but this param should only be escaped.
+                                       $converter->markNoConversion( wfEscapeWikiText( $text ) )
+                               )->inContentLanguage()->text() .
+                               '</span>';
                }
-
-               return '';
        }
 
        /**
index 04b5614..8575e69 100644 (file)
@@ -282,7 +282,7 @@ class LinkHolderArray {
                        return;
                }
 
-               global $wgContLang, $wgContentHandlerUseDB, $wgPageLanguageUseDB;
+               global $wgContLang;
 
                $colours = [];
                $linkCache = LinkCache::singleton();
@@ -297,7 +297,9 @@ class LinkHolderArray {
                $linkcolour_ids = [];
 
                # Generate query
-               $queries = [];
+               $lb = new LinkBatch();
+               $lb->setCaller( __METHOD__ );
+
                foreach ( $this->internals as $ns => $entries ) {
                        foreach ( $entries as $entry ) {
                                /** @var Title $title */
@@ -325,37 +327,21 @@ class LinkHolderArray {
                                                $colours[$pdbk] = 'new';
                                        } else {
                                                # Not in the link cache, add it to the query
-                                               $queries[$ns][] = $title->getDBkey();
+                                               $lb->addObj( $title );
                                        }
                                }
                        }
                }
-               if ( $queries ) {
-                       $where = [];
-                       foreach ( $queries as $ns => $pages ) {
-                               $where[] = $dbr->makeList(
-                                       [
-                                               'page_namespace' => $ns,
-                                               'page_title' => array_unique( $pages ),
-                                       ],
-                                       LIST_AND
-                               );
-                       }
-
-                       $fields = [ 'page_id', 'page_namespace', 'page_title',
-                               'page_is_redirect', 'page_len', 'page_latest' ];
-
-                       if ( $wgContentHandlerUseDB ) {
-                               $fields[] = 'page_content_model';
-                       }
-                       if ( $wgPageLanguageUseDB ) {
-                               $fields[] = 'page_lang';
-                       }
+               if ( !$lb->isEmpty() ) {
+                       $fields = array_merge(
+                               LinkCache::getSelectFields(),
+                               [ 'page_namespace', 'page_title' ]
+                       );
 
                        $res = $dbr->select(
                                'page',
                                $fields,
-                               $dbr->makeList( $where, LIST_OR ),
+                               $lb->constructSet( 'page', $dbr ),
                                __METHOD__
                        );
 
@@ -463,7 +449,7 @@ class LinkHolderArray {
         * @param array $colours
         */
        protected function doVariants( &$colours ) {
-               global $wgContLang, $wgContentHandlerUseDB, $wgPageLanguageUseDB;
+               global $wgContLang;
                $linkBatch = new LinkBatch();
                $variantMap = []; // maps $pdbkey_Variant => $keys (of link holders)
                $output = $this->parent->getOutput();
@@ -513,9 +499,6 @@ class LinkHolderArray {
                                }
 
                                $variantTitle = Title::makeTitle( $ns, $textVariant );
-                               if ( is_null( $variantTitle ) ) {
-                                       continue;
-                               }
 
                                // Self-link checking for mixed/different variant titles. At this point, we
                                // already know the exact title does not exist, so the link cannot be to a
@@ -552,15 +535,10 @@ class LinkHolderArray {
                if ( !$linkBatch->isEmpty() ) {
                        // construct query
                        $dbr = wfGetDB( DB_SLAVE );
-                       $fields = [ 'page_id', 'page_namespace', 'page_title',
-                               'page_is_redirect', 'page_len', 'page_latest' ];
-
-                       if ( $wgContentHandlerUseDB ) {
-                               $fields[] = 'page_content_model';
-                       }
-                       if ( $wgPageLanguageUseDB ) {
-                               $fields[] = 'page_lang';
-                       }
+                       $fields = array_merge(
+                               LinkCache::getSelectFields(),
+                               [ 'page_namespace', 'page_title' ]
+                       );
 
                        $varRes = $dbr->select( 'page',
                                $fields,
index c168aa6..4ed176c 100644 (file)
@@ -50,7 +50,7 @@ class StripState {
                        'nowiki' => [],
                        'general' => []
                ];
-               $this->regex = '/' . Parser::MARKER_PREFIX . "([^\x7f]+)" . Parser::MARKER_SUFFIX . '/';
+               $this->regex = '/' . Parser::MARKER_PREFIX . "([^\x7f<>&'\"]+)" . Parser::MARKER_SUFFIX . '/';
                $this->circularRefGuard = [];
        }
 
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 7c4fde4..8fc0b77 100644 (file)
@@ -52,9 +52,9 @@
  */
 class ProfilerXhprof extends Profiler {
        /**
-        * @var Xhprof $xhprof
+        * @var XhprofData|null $xhprofData
         */
-       protected $xhprof;
+       protected $xhprofData;
 
        /**
         * Profiler for explicit, arbitrary, frame labels
@@ -68,10 +68,24 @@ class ProfilerXhprof extends Profiler {
         */
        public function __construct( array $params = [] ) {
                parent::__construct( $params );
-               $this->xhprof = new Xhprof( $params );
+
+               $flags = isset( $params['flags'] ) ? $params['flags'] : 0;
+               $options = isset( $params['exclude'] )
+                       ? [ 'ignored_functions' => $params['exclude'] ] : [];
+               Xhprof::enable( $flags, $options );
                $this->sprofiler = new SectionProfiler();
        }
 
+       /**
+        * @return XhprofData
+        */
+       public function getXhprofData() {
+               if ( !$this->xhprofData ) {
+                       $this->xhprofData = new XhprofData( Xhprof::disable(), $this->params );
+               }
+               return $this->xhprofData;
+       }
+
        public function scopedProfileIn( $section ) {
                $key = 'section.' . ltrim( $section, '.' );
                return $this->sprofiler->scopedProfileIn( $key );
@@ -112,7 +126,7 @@ class ProfilerXhprof extends Profiler {
        }
 
        public function getFunctionStats() {
-               $metrics = $this->xhprof->getCompleteMetrics();
+               $metrics = $this->getXhprofData()->getCompleteMetrics();
                $profile = [];
 
                $main = null; // units in ms
@@ -216,6 +230,6 @@ class ProfilerXhprof extends Profiler {
         * @return array
         */
        public function getRawData() {
-               return $this->xhprof->getRawData();
+               return $this->getXhprofData()->getRawData();
        }
 }
index 415e664..78f9370 100644 (file)
@@ -23,6 +23,8 @@ class ExtensionProcessor implements Processor {
                'AvailableRights',
                'ContentHandlers',
                'ConfigRegistry',
+               'SessionProviders',
+               'AuthManagerAutoConfig',
                'CentralIdLookupProviders',
                'RateLimits',
                'RecentChangesFlags',
@@ -67,6 +69,7 @@ class ExtensionProcessor implements Processor {
                'wgNamespaceProtection' => 'array_plus',
                'wgCapitalLinkOverrides' => 'array_plus',
                'wgRateLimits' => 'array_plus_2d',
+               'wgAuthManagerAutoConfig' => 'array_plus_2d',
        ];
 
        /**
diff --git a/includes/resourceloader/ResourceLoaderUploadDialogModule.php b/includes/resourceloader/ResourceLoaderUploadDialogModule.php
new file mode 100644 (file)
index 0000000..52e2210
--- /dev/null
@@ -0,0 +1,42 @@
+<?php
+/**
+ * ResourceLoader module for the upload dialog configuration data.
+ *
+ * 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
+ */
+
+/**
+ * ResourceLoader module for the upload dialog configuration data.
+ *
+ * @since 1.27
+ */
+class ResourceLoaderUploadDialogModule extends ResourceLoaderModule {
+
+       protected $targets = [ 'desktop', 'mobile' ];
+
+       public function getScript( ResourceLoaderContext $context ) {
+               $config = $context->getResourceLoader()->getConfig();
+               return ResourceLoader::makeConfigSetScript( [
+                       'wgUploadDialog' => $config->get( 'UploadDialog' ),
+               ] );
+       }
+
+       public function enableModuleContentVersion() {
+               return true;
+       }
+}
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 e79fd6e..2139949 100644 (file)
@@ -77,6 +77,7 @@ class SpecialTags extends SpecialPage {
 
                $user = $this->getUser();
                $userCanManage = $user->isAllowed( 'managechangetags' );
+               $userCanDelete = $user->isAllowed( 'deletechangetags' );
                $userCanEditInterface = $user->isAllowed( 'editinterface' );
 
                // Show form to create a tag
@@ -154,12 +155,13 @@ class SpecialTags extends SpecialPage {
 
                // Insert tags that have been applied at least once
                foreach ( $tagStats as $tag => $hitcount ) {
-                       $html .= $this->doTagRow( $tag, $hitcount, $userCanManage, $userCanEditInterface );
+                       $html .= $this->doTagRow( $tag, $hitcount, $userCanManage,
+                               $userCanDelete, $userCanEditInterface );
                }
                // Insert tags defined somewhere but never applied
                foreach ( $definedTags as $tag ) {
                        if ( !isset( $tagStats[$tag] ) ) {
-                               $html .= $this->doTagRow( $tag, 0, $userCanManage, $userCanEditInterface );
+                               $html .= $this->doTagRow( $tag, 0, $userCanManage, $userCanDelete, $userCanEditInterface );
                        }
                }
 
@@ -170,7 +172,7 @@ class SpecialTags extends SpecialPage {
                ) );
        }
 
-       function doTagRow( $tag, $hitcount, $showActions, $showEditLinks ) {
+       function doTagRow( $tag, $hitcount, $showManageActions, $showDeleteActions, $showEditLinks ) {
                $newRow = '';
                $newRow .= Xml::tags( 'td', null, Xml::element( 'code', null, $tag ) );
 
@@ -229,16 +231,17 @@ class SpecialTags extends SpecialPage {
                $newRow .= Xml::tags( 'td', [ 'data-sort-value' => $hitcount ], $hitcountLabel );
 
                // actions
-               if ( $showActions ) { // we've already checked that the user had the requisite userright
-                       $actionLinks = [];
+               $actionLinks = [];
 
-                       // delete
-                       if ( ChangeTags::canDeleteTag( $tag )->isOK() ) {
-                               $actionLinks[] = Linker::linkKnown( $this->getPageTitle( 'delete' ),
-                                       $this->msg( 'tags-delete' )->escaped(),
-                                       [],
-                                       [ 'tag' => $tag ] );
-                       }
+               // delete
+               if ( $showDeleteActions && ChangeTags::canDeleteTag( $tag )->isOK() ) {
+                       $actionLinks[] = Linker::linkKnown( $this->getPageTitle( 'delete' ),
+                               $this->msg( 'tags-delete' )->escaped(),
+                               [],
+                               [ 'tag' => $tag ] );
+               }
+
+               if ( $showManageActions ) { // we've already checked that the user had the requisite userright
 
                        // activate
                        if ( ChangeTags::canActivateTag( $tag )->isOK() ) {
@@ -256,6 +259,9 @@ class SpecialTags extends SpecialPage {
                                        [ 'tag' => $tag ] );
                        }
 
+               }
+
+               if ( $actionLinks ) {
                        $newRow .= Xml::tags( 'td', null, $this->getLanguage()->pipeList( $actionLinks ) );
                }
 
@@ -319,8 +325,8 @@ class SpecialTags extends SpecialPage {
 
        protected function showDeleteTagForm( $tag ) {
                $user = $this->getUser();
-               if ( !$user->isAllowed( 'managechangetags' ) ) {
-                       throw new PermissionsError( 'managechangetags' );
+               if ( !$user->isAllowed( 'deletechangetags' ) ) {
+                       throw new PermissionsError( 'deletechangetags' );
                }
 
                $out = $this->getOutput();
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 {
index 5c504f3..38e9ecd 100644 (file)
@@ -192,12 +192,18 @@ class MediaWikiTitleCodec implements TitleFormatter, TitleParser {
                if ( $target->isExternal() ) {
                        $key .= $target->getInterwiki() . ':';
                }
-               $nsName = $this->getNamespaceName(
-                       $target->getNamespace(),
-                       $target->getText()
-               );
+               // Try to get a namespace name, but fallback
+               // to empty string if it doesn't exist
+               try {
+                       $nsName = $this->getNamespaceName(
+                               $target->getNamespace(),
+                               $target->getText()
+                       );
+               } catch ( InvalidArgumentException $e ) {
+                       $nsName = '';
+               }
 
-               if ( $nsName !== '' ) {
+               if ( $target->getNamespace() !== 0 ) {
                        $key .= $nsName . ':';
                }
 
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 3d7d71c..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.
@@ -127,6 +130,7 @@ class User implements IDBAccessObject {
                'createpage',
                'createtalk',
                'delete',
+               'deletechangetags',
                'deletedhistory',
                'deletedtext',
                'deletelogentry',
@@ -498,7 +502,10 @@ class User implements IDBAccessObject {
                $data = $processCache->get( $key );
                if ( !is_array( $data ) ) {
                        $data = $cache->get( $key );
-                       if ( !is_array( $data ) || $data['mVersion'] < self::VERSION ) {
+                       if ( !is_array( $data )
+                               || !isset( $data['mVersion'] )
+                               || $data['mVersion'] < self::VERSION
+                       ) {
                                // Object is expired
                                return false;
                        }
@@ -674,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,
@@ -687,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__
                );
@@ -706,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();
@@ -1078,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:
@@ -1404,7 +1427,7 @@ class User implements IDBAccessObject {
         * @see $wgAutopromoteOnce
         */
        public function addAutopromoteOnceGroups( $event ) {
-               global $wgAutopromoteOnceLogInRC, $wgAuth;
+               global $wgAutopromoteOnceLogInRC;
 
                if ( wfReadOnly() || !$this->getId() ) {
                        return [];
@@ -1425,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
 
@@ -2040,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;
        }
@@ -2058,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;
@@ -2466,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() ) {
@@ -2491,7 +2516,6 @@ class User implements IDBAccessObject {
 
                $this->setOption( 'watchlisttoken', false );
                $this->setPasswordInternal( $str );
-               SessionManager::singleton()->invalidateSessionsForUser( $this );
 
                return true;
        }
@@ -2499,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 );
                }
        }
 
@@ -2520,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;
        }
 
        /**
@@ -2606,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(),
+                       ];
 
-               $dbw->update( 'user', $update, [ 'user_id' => $id ], __METHOD__ );
+                       if ( $str === null ) {
+                               $update['user_newpass_time'] = null;
+                       } elseif ( $throttle ) {
+                               $update['user_newpass_time'] = $dbw->timestamp();
+                       }
+
+                       $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;
        }
 
        /**
@@ -3403,12 +3478,14 @@ class User implements IDBAccessObject {
         * @since 1.28
         */
        public function isBot() {
-               $isBot = false;
-               if ( !Hooks::run( "UserIsBot", [ $this, &$isBot ] ) ) {
-                       return $isBot;
+               if ( in_array( 'bot', $this->getGroups() ) && $this->isAllowed( 'bot' ) ) {
+                       return true;
                }
 
-               return ( $isBot || in_array( 'bot', $this->getGroups() ) );
+               $isBot = false;
+               Hooks::run( "UserIsBot", [ $this, &$isBot ] );
+
+               return $isBot;
        }
 
        /**
@@ -4144,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 );
                }
        }
 
@@ -5103,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.
@@ -5115,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
                }
 
@@ -5163,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 c132dca..96a7359 100644 (file)
        "mailmypassword": "Скінуць пароль",
        "passwordremindertitle": "Новы часовы пароль для {{GRAMMAR:родны|{{SITENAME}}}}",
        "passwordremindertext": "Нехта (магчыма Вы, з IP-адрасу $1) запытаў нас даслаць новы пароль для {{GRAMMAR:родны|{{SITENAME}}}} ($4). Для ўдзельніка «$2» быў створаны часовы пароль і ён цяпер «$3». Калі гэта была Вашая ініцыятыва, Вам трэба ўвайсьці ў сыстэму і адразу зьмяніць пароль. Тэрмін дзеяньня Вашага часовага паролю — $5 {{PLURAL:$5|дзень|дні|дзён}}.\n\nКалі гэты запыт адправіў нехта іншы, альбо Вы ўзгадалі свой пароль і ўжо не жадаеце яго зьмяніць, Вы можаце праігнараваць гэты ліст і працягваць карыстацца старым паролем.",
-       "noemail": "{{GENDER:$1|УдзелÑ\8cнÑ\96к Â«$1» Ð½Ðµ Ð¿Ð°Ð·Ð½Ð°Ñ\87Ñ\8bÑ\9e|УдзелÑ\8cнÑ\96Ñ\86а Â«$1» Ð½Ðµ Ð¿Ð°Ð·Ð½Ð°Ñ\87Ñ\8bла}} Ð½Ñ\96Ñ\8fкага Ð°Ð´Ñ\80аÑ\81Ñ\83 электроннай пошты.",
-       "noemailcreate": "Вы павінны пазначыць слушны адрас электроннай пошты",
+       "noemail": "{{GENDER:$1|УдзелÑ\8cнÑ\96к Â«$1» Ð½Ðµ Ð¿Ð°Ð·Ð½Ð°Ñ\87Ñ\8bÑ\9e|УдзелÑ\8cнÑ\96Ñ\86а Â«$1» Ð½Ðµ Ð¿Ð°Ð·Ð½Ð°Ñ\87Ñ\8bла}} Ð°Ð´Ñ\80аÑ\81 электроннай пошты.",
+       "noemailcreate": "Вы павінныя пазначыць слушны адрас электроннай пошты.",
        "passwordsent": "Новы пароль быў дасланы на адрас электроннай пошты ўдзельніка «$1».\nКалі ласка, увайдзіце ў сыстэму пасьля яго атрыманьня.",
        "blocked-mailpassword": "З Вашага IP-адрасу забароненыя рэдагаваньні. Каб пазьбегнуць злоўжываньняў, з гэтага IP-адрасу забаронена аднаўляць пароль.",
        "eauthentsent": "Пацьверджаньне было дасланае на пазначаны адрас электроннай пошты.\nУ лісьце ўтрымліваюцца інструкцыі, па выкананьні якіх Вы зможаце пацьвердзіць, што адрас сапраўды належыць Вам, і на гэты адрас будзе дасылацца пошта адсюль.",
        "right-override-export-depth": "экспартаваньне старонак, уключаючы зьвязаныя старонкі з глыбінёй да 5",
        "right-sendemail": "адпраўка электронных лістоў іншым удзельнікам",
        "right-passwordreset": "прагляд электронных лістоў з ачысткай паролю",
-       "right-managechangetags": "ствараць і выдаляць [[Special:Tags|меткі]] з базы зьвестак",
+       "right-managechangetags": "стварэньне і (дэ)актывацыя [[Special:Tags|метак]]",
        "right-applychangetags": "дадаваць [[Special:Tags|меткі]] пры рэдагаваньні",
        "right-changetags": "дадаваць і выдаляць адвольныя [[Special:Tags|меткі]] да асобных вэрсіяў і запісаў у журнале падзеяў",
        "grant-generic": "Набор правоў «$1»",
        "action-viewmyprivateinfo": "прагляд вашых прыватных зьвестак",
        "action-editmyprivateinfo": "рэдагаваньне вашых прыватных зьвестак",
        "action-editcontentmodel": "рэдагаваньне мадэлі зьместу старонкі",
-       "action-managechangetags": "стварэньне і выдаленьне метак з базы зьвестак",
+       "action-managechangetags": "стварэньне і (дэ)актывацыю метак",
        "action-applychangetags": "дадаваньне метак пры рэдагаваньні",
        "action-changetags": "дадаваньне і выдаленьне адвольных метак да асобных вэрсіяў і запісаў у журнале падзеяў",
        "nchanges": "$1 {{PLURAL:$1|зьмена|зьмены|зьменаў}}",
        "changecontentmodel-title-label": "Назва старонкі",
        "changecontentmodel-model-label": "Новая мадэль зьместу",
        "changecontentmodel-reason-label": "Прычына:",
+       "changecontentmodel-submit": "Зьмяніць",
        "changecontentmodel-success-title": "Мадэль зьместу была зьмененая",
        "changecontentmodel-success-text": "Тып зьместу [[:$1]] быў зьменены.",
        "changecontentmodel-cannot-convert": "Зьмест [[:$1]] ня можа быць ператвораны ў тып $2.",
        "whatlinkshere-next": "{{PLURAL:$1|наступная|наступныя}} $1",
        "whatlinkshere-links": "← спасылкі",
        "whatlinkshere-hideredirs": "Схаваць перанакіраваньні",
-       "whatlinkshere-hidetrans": "$1 уключэньні",
-       "whatlinkshere-hidelinks": "$1 спасылкі",
+       "whatlinkshere-hidetrans": "Схаваць уключэньні",
+       "whatlinkshere-hidelinks": "Схаваць спасылкі",
        "whatlinkshere-hideimages": "$1 спасылкі на выявы",
        "whatlinkshere-filters": "Фільтры",
        "whatlinkshere-submit": "Перайсьці",
index f018af4..a565f4f 100644 (file)
        "minoredit": "Дробная праўка",
        "watchthis": "Назіраць за гэтай старонкай",
        "savearticle": "Запісаць",
+       "publishpage": "Апублікаваць старонку",
        "preview": "Перадпаказ",
        "showpreview": "Як будзе",
        "showdiff": "Розніца",
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 cfe09b6..86f481e 100644 (file)
        "whatlinkshere-prev": "{{PLURAL:$1|anterior|anteriors $1}}",
        "whatlinkshere-next": "{{PLURAL:$1|següent|següents $1}}",
        "whatlinkshere-links": "← enllaços",
-       "whatlinkshere-hideredirs": "$1 redireccions",
-       "whatlinkshere-hidetrans": "$1 inclusions",
-       "whatlinkshere-hidelinks": "$1 enllaços",
+       "whatlinkshere-hideredirs": "Amaga les redireccions",
+       "whatlinkshere-hidetrans": "Amagar transclusions",
+       "whatlinkshere-hidelinks": "Amagar enllaços",
        "whatlinkshere-hideimages": "$1 enllaços de fitxers",
        "whatlinkshere-filters": "Filtres",
        "whatlinkshere-submit": "Vés-hi",
index 98b5cee..f3c4a68 100644 (file)
        "right-override-export-depth": "Exportovat stránky včetně odkazovaných stránek až do hloubky 5",
        "right-sendemail": "Odesílání e-mailů ostatním uživatelům",
        "right-passwordreset": "Prohlížení e-mailů pro znovunastavení hesla",
-       "right-managechangetags": "Vytváření [[Special:Tags|značek]] a jejich mazání z databáze",
+       "right-managechangetags": "Vytváření a (de)aktivace [[Special:Tags|značek]]",
        "right-applychangetags": "Přidávání [[Special:Tags|značek]] k vlastním změnám",
        "right-changetags": "Přidávání libovolných [[Special:Tags|značek]] na jednotlivé revize a protokolovací záznamy a jejich odebírání",
+       "right-deletechangetags": "Mazání [[Special:Tags|značek]] z databáze",
        "grant-generic": "Balíček oprávnění „$1“",
        "grant-group-page-interaction": "Interakce se stránkami",
        "grant-group-file-interaction": "Interakce se soubory",
        "action-viewmyprivateinfo": "prohlížet si své soukromé údaje",
        "action-editmyprivateinfo": "změnit své soukromé údaje",
        "action-editcontentmodel": "editovat model obsahu stránky",
-       "action-managechangetags": "vytvářet a mazat značky z databáze",
+       "action-managechangetags": "vytvářet a (de)aktivovat značky",
        "action-applychangetags": "přidávat značky k vlastním změnám",
        "action-changetags": "přidávat libovolné značky na jednotlivé revize a protokolovací záznamy a odebírat je",
+       "action-deletechangetags": "mazat značky z databáze",
        "nchanges": "$1 {{PLURAL:$1|změna|změny|změn}}",
        "enhancedrc-since-last-visit": "$1 {{PLURAL:$1|od poslední návštěvy}}",
        "enhancedrc-history": "historie",
        "whatlinkshere-prev": "{{PLURAL:$1|předchozí|předchozí $1|předchozích $1}}",
        "whatlinkshere-next": "{{PLURAL:$1|následující|následující $1|následujících $1}}",
        "whatlinkshere-links": "← odkazy",
-       "whatlinkshere-hideredirs": "$1 přesměrování",
-       "whatlinkshere-hidetrans": "$1 vložení",
-       "whatlinkshere-hidelinks": "$1 odkazy",
-       "whatlinkshere-hideimages": "$1 vložení souboru",
+       "whatlinkshere-hideredirs": "Skrýt přesměrování",
+       "whatlinkshere-hidetrans": "Skrýt vložení",
+       "whatlinkshere-hidelinks": "Skrýt odkazy",
+       "whatlinkshere-hideimages": "Skrýt vložení souboru",
        "whatlinkshere-filters": "Filtry",
        "whatlinkshere-submit": "Přejít",
        "autoblockid": "Autoblok #$1",
        "tags-delete-not-found": "Značka „$1“ neexistuje.",
        "tags-delete-too-many-uses": "Značkou „$1“ {{PLURAL:$2|je označena více než $2 revize|jsou označeny více než $2 revize|je označeno více než $2 revizí}}, což znamená, že ji nelze smazat.",
        "tags-delete-warnings-after-delete": "Značka „$1“ byla smazána, ale {{PLURAL:$2|bylo zjištěno|byla zjištěna}} následující varování:",
+       "tags-delete-no-permission": "Nemáte oprávnění mazat značky pro změny.",
        "tags-activate-title": "Aktivovat značku",
        "tags-activate-question": "Chystáte se aktivovat značku „$1“.",
        "tags-activate-reason": "Důvod:",
index be7ed04..b078329 100644 (file)
        "databaseerror-query": "Abfrage: $1",
        "databaseerror-function": "Funktion: $1",
        "databaseerror-error": "Fehler: $1",
-       "transaction-duration-limit-exceeded": "Um eine hohe Nachbildungsverzögerung zu vermeiden, wurde diese Transaktion abgebrochen, da die Schreibdauer ($1) die Grenze von {{PLURAL:$2|einer Sekunde|$2 Sekunden}} überschritten hat. Falls du viele Objekte auf einmal änderst, versuche stattdessen, mehrere kleine Operationen auszuführen.",
+       "transaction-duration-limit-exceeded": "Um eine große Verzögerung in der Datenreplikation zu vermeiden, wurde diese Transaktion abgebrochen. Die Schreibdauer ($1) hat die Grenze von {{PLURAL:$2|einer Sekunde|$2 Sekunden}} überschritten. Falls du viele Objekte auf einmal änderst, versuche stattdessen, die Änderungen auf mehrere Operationen aufzuteilen.",
        "laggedslavemode": "<strong>Achtung:</strong> Die angezeigte Seite könnte unter Umständen nicht die letzten Bearbeitungen enthalten.",
        "readonly": "Datenbank gesperrt",
        "enterlockreason": "Bitte gib einen Grund ein, warum die Datenbank gesperrt werden soll und eine Abschätzung über die Dauer der Sperrung",
        "right-override-export-depth": "Exportiere Seiten einschließlich verlinkter Seiten bis zu einer Tiefe von 5",
        "right-sendemail": "E-Mails an andere Benutzer senden",
        "right-passwordreset": "Passwort eines Benutzers zurücksetzen und die dazu verschickte E-Mail einsehen",
-       "right-managechangetags": "[[Special:Tags|Markierungen]] erstellen und aus der Datenbank löschen",
+       "right-managechangetags": "[[Special:Tags|Markierungen]] erstellen und (de)aktivieren",
        "right-applychangetags": "[[Special:Tags|Markierungen]] zusammen mit den Änderungen anwenden",
        "right-changetags": "Beliebige [[Special:Tags|Markierungen]] zu einzelnen Versionen und Logbucheinträgen hinzufügen und entfernen",
+       "right-deletechangetags": "[[Special:Tags|Markierungen]] aus der Datenbank löschen",
        "grant-generic": "Rechtegruppe „$1“",
        "grant-group-page-interaction": "Mit Seiten interagieren",
        "grant-group-file-interaction": "Mit Medien interagieren",
        "action-viewmyprivateinfo": "deine privaten Informationen einzusehen",
        "action-editmyprivateinfo": "deine privaten Informationen zu bearbeiten",
        "action-editcontentmodel": "das Inhaltsmodell einer Seite zu bearbeiten",
-       "action-managechangetags": "Markierungen zu erstellen und aus der Datenbank zu löschen",
+       "action-managechangetags": "Markierungen zu erstellen und zu (de)aktivieren",
        "action-applychangetags": "Markierungen zusammen mit deinen Änderungen anzuwenden",
        "action-changetags": "beliebige Markierungen zu einzelnen Versionen und Logbucheinträgen hinzuzufügen und zu entfernen",
+       "action-deletechangetags": "Markierungen aus der Datenbank zu löschen",
        "nchanges": "$1 {{PLURAL:$1|Änderung|Änderungen}}",
        "enhancedrc-since-last-visit": "$1 {{PLURAL:$1|seit dem letzten Besuch}}",
        "enhancedrc-history": "Versionsgeschichte",
        "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",
        "tags-delete-not-found": "Die Markierung „$1“ ist nicht vorhanden.",
        "tags-delete-too-many-uses": "Die Markierung „$1“ wird bei mehr als {{PLURAL:$2|einer Version|$2 Versionen}} verwendet und kann deshalb nicht gelöscht werden.",
        "tags-delete-warnings-after-delete": "Die Markierung „$1“ wurde gelöscht, aber die {{PLURAL:$2|folgende Warnung ist|folgenden Warnungen sind}} aufgetreten:",
+       "tags-delete-no-permission": "Du hast keine Berechtigung, Änderungsmarkierungen zu löschen.",
        "tags-activate-title": "Markierung aktivieren",
        "tags-activate-question": "Du bist dabei, die Markierung „$1“ zu aktivieren.",
        "tags-activate-reason": "Grund:",
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 e7fa4c7..8b9fefe 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.",
        "image_tip": "Embedded file",
        "media_sample": "Example.ogg",
        "media_tip": "File link",
+       "sig-text": "--$1",
        "sig_tip": "Your signature with timestamp",
        "hr_tip": "Horizontal line (use sparingly)",
        "summary": "Summary:",
        "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}}",
        "right-override-export-depth": "Export pages including linked pages up to a depth of 5",
        "right-sendemail": "Send email to other users",
        "right-passwordreset": "View password reset emails",
-       "right-managechangetags": "Create and delete [[Special:Tags|tags]] from the database",
+       "right-managechangetags": "Create and (de)activate [[Special:Tags|tags]]",
        "right-applychangetags": "Apply [[Special:Tags|tags]] along with one's changes",
        "right-changetags": "Add and remove arbitrary [[Special:Tags|tags]] on individual revisions and log entries",
+       "right-deletechangetags": "Delete [[Special:Tags|tags]] from the database",
        "grant-generic": "\"$1\" rights bundle",
        "grant-group-page-interaction": "Interact with pages",
        "grant-group-file-interaction": "Interact with media",
        "action-viewmyprivateinfo": "view your private information",
        "action-editmyprivateinfo": "edit your private information",
        "action-editcontentmodel": "edit the content model of a page",
-       "action-managechangetags": "create and delete tags from the database",
+       "action-managechangetags": "create and (de)activate tags",
        "action-applychangetags": "apply tags along with your changes",
        "action-changetags": "add and remove arbitrary tags on individual revisions and log entries",
+       "action-deletechangetags": "delete tags from the database",
        "nchanges": "$1 {{PLURAL:$1|change|changes}}",
        "enhancedrc-since-last-visit": "$1 {{PLURAL:$1|since last visit}}",
        "enhancedrc-history": "history",
        "upload-form-label-own-work": "This is my own work",
        "upload-form-label-infoform-categories": "Categories",
        "upload-form-label-infoform-date": "Date",
-       "upload-form-label-own-work-message-local": "I confirm that I am uploading this file following the terms of service and licensing policies on {{SITENAME}}.",
-       "upload-form-label-not-own-work-message-local": "If you are not able to upload this file under the policies of {{SITENAME}}, please close this dialog and try another method.",
-       "upload-form-label-not-own-work-local-local": "You may also want to try [[Special:Upload|the default upload page]].",
-       "upload-form-label-own-work-message-default": "I understand that I am uploading this file to a shared repository. I confirm that I am doing so following the terms of service and licensing policies there.",
-       "upload-form-label-not-own-work-message-default": "If you are not able to upload this file under the policies of the shared repository, please close this dialog and try another method.",
-       "upload-form-label-not-own-work-local-default": "You may also want to try using [[Special:Upload|the upload page on {{SITENAME}}]], if this file can be uploaded there under their policies.",
-       "upload-form-label-own-work-message-shared": "I attest that I own the copyright on this file, and agree to irrevocably release this file to Wikimedia Commons under the [https://creativecommons.org/licenses/by-sa/4.0/ Creative Commons Attribution-ShareAlike 4.0] license, and I agree to the [https://wikimediafoundation.org/wiki/Terms_of_Use Terms of Use].",
-       "upload-form-label-not-own-work-message-shared": "If you do not own the copyright on this file, or you wish to release it under a different license, consider using the [https://commons.wikimedia.org/wiki/Special:UploadWizard Commons Upload Wizard].",
-       "upload-form-label-not-own-work-local-shared": "You may also want to try using [[Special:Upload|the upload page on {{SITENAME}}]], if the site allows the upload of this file under their policies.",
+       "upload-form-label-own-work-message-generic-local": "I confirm that I am uploading this file following the terms of service and licensing policies on {{SITENAME}}.",
+       "upload-form-label-not-own-work-message-generic-local": "If you are not able to upload this file under the policies of {{SITENAME}}, please close this dialog and try another method.",
+       "upload-form-label-not-own-work-local-generic-local": "You may also want to try [[Special:Upload|the default upload page]].",
+       "upload-form-label-own-work-message-generic-foreign": "I understand that I am uploading this file to a shared repository. I confirm that I am doing so following the terms of service and licensing policies there.",
+       "upload-form-label-not-own-work-message-generic-foreign": "If you are not able to upload this file under the policies of the shared repository, please close this dialog and try another method.",
+       "upload-form-label-not-own-work-local-generic-foreign": "You may also want to try using [[Special:Upload|the upload page on {{SITENAME}}]], if this file can be uploaded there under their policies.",
        "backend-fail-stream": "Could not stream file \"$1\".",
        "backend-fail-backup": "Could not backup file \"$1\".",
        "backend-fail-notexists": "The file $1 does not exist.",
        "timezone-local": "Local",
        "duplicate-defaultsort": "<strong>Warning:</strong> Default sort key \"$2\" overrides earlier default sort key \"$1\".",
        "duplicate-displaytitle": "<strong>Warning:</strong> Display title \"$2\" overrides earlier display title \"$1\".",
+       "restricted-displaytitle": "<strong>Warning:</strong> Display title \"$1\" was ignored since it is not equivalent to the page's actual title.",
        "invalid-indicator-name": "<strong>Error:</strong> Page status indicators' <code>name</code> attribute must not be empty.",
        "version": "Version",
        "version-summary": "",
        "tags-delete-not-found": "The tag \"$1\" does not exist.",
        "tags-delete-too-many-uses": "The tag \"$1\" is applied to more than $2 {{PLURAL:$2|revision|revisions}}, which means it cannot be deleted.",
        "tags-delete-warnings-after-delete": "The tag \"$1\" was deleted, but the following {{PLURAL:$2|warning was|warnings were}} encountered:",
+       "tags-delete-no-permission": "You do not have permission to delete change tags.",
        "tags-activate-title": "Activate tag",
        "tags-activate-question": "You are about to activate the tag \"$1\".",
        "tags-activate-reason": "Reason:",
        "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 52e1cd1..7882945 100644 (file)
        "apisandbox-jsonly": "JavaScript estas postulita por uzi la API provejon.",
        "apisandbox-api-disabled": "API estas malŝalta en ĉi tiu retejo.",
        "apisandbox-intro": "Uzu tiun ĉi paĝon por eksperimenti kun '''MediaWiki API'''.\nVidu [//www.mediawiki.org/wiki/API:Main_page la API-dokumentadon] por pli da detaloj pri la uzo de API. Ekz-e: [//www.mediawiki.org/wiki/API#A_simple_example atingi la enhavon de la Ĉefpaĝo]. Elektu agon por vidi pliajn ekzemplojn.\n\nNotu ke, kvankam ĉi tiu estas provejo, agoj kiun vi faros en ĉi tiu paĝo povas modifi la vikion.",
+       "apisandbox-fullscreen": "Etendi panelon",
+       "apisandbox-fullscreen-tooltip": "Etendi la proveja panelo por plenigi la retumilan fenestron.",
        "apisandbox-unfullscreen": "Montri paĝon",
+       "apisandbox-unfullscreen-tooltip": "Maletendi la provejan panelon, tiel Mediavikiaj navigadaj ligoj estas haveblaj.",
        "apisandbox-submit": "Fari mendon",
        "apisandbox-reset": "Nuligi",
        "apisandbox-retry": "Reprovi",
+       "apisandbox-loading": "Ŝutas informon de la APIa modulo je \"$1\"…",
+       "apisandbox-load-error": "Eraro okazis dum ŝutis informon por APIa modulo je \"$1\": $2",
+       "apisandbox-no-parameters": "Ĉi tiu APIa modulo ne havas parametron.",
        "apisandbox-helpurls": "Ligiloj pri helpo",
        "apisandbox-examples": "Ekzemploj",
        "apisandbox-dynamic-parameters": "Aldonaj parametroj",
        "apisandbox-submit-invalid-fields-message": "Bonvolu ĝustigi la markitajn kampojn kaj provi denove.",
        "apisandbox-results": "Rezultoj",
        "apisandbox-sending-request": "Sendanta aplikprograminterfacan peton…",
+       "apisandbox-loading-results": "Ricevas APIajn rezultojn…",
+       "apisandbox-results-error": "Eraro okazis dum ŝutis la APIan petan respondon: $1.",
        "apisandbox-request-url-label": "Mendi URL-on.",
        "apisandbox-request-time": "Tempo de peto:{{PLURAL:$1|$1 ms}}",
+       "apisandbox-results-fixtoken": "Korekti ĵetonon kaj resendi",
+       "apisandbox-results-fixtoken-fail": "Malsukcese venigis ĵetonon je \"$1\".",
+       "apisandbox-alert-page": "Kampoj de ĉi tiu paĝo ne estas validaj.",
+       "apisandbox-alert-field": "La valoro de ĉi tiu kampo ne estas valida.",
        "booksources": "Librofontoj",
        "booksources-search-legend": "Serĉi librofontojn",
        "booksources-search": "Serĉi",
        "activeusers-hidebots": "kaŝi robotojn",
        "activeusers-hidesysops": "Kaŝi administrantojn",
        "activeusers-noresult": "Neniuj uzantoj trovitaj.",
+       "activeusers-submit": "Montri la agemajn uzantojn",
        "listgrouprights": "Gruprajtoj de uzantoj",
        "listgrouprights-summary": "Jen listo de uzanto-grupoj difinitaj en ĉi tiu vikio, kun ties asociaj atingrajtoj.\nEstas [[{{MediaWiki:Listgrouprights-helppage}}|aldona informo]] pri individuaj rajtoj.",
        "listgrouprights-key": "* <span class=\"listgrouprights-granted\">Donita rajto</span>\n* <span class=\"listgrouprights-revoked\">Forigita rajto</span>",
        "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 0337736..48c7a3b 100644 (file)
                        "Lemondoge",
                        "Jdforrester",
                        "Indiralena",
-                       "Rubentl134"
+                       "Rubentl134",
+                       "Codynguyen1116"
                ]
        },
        "tog-underline": "Subrayar los enlaces:",
        "tog-watchdefault": "Añadir las páginas y archivos que edite a mi lista de seguimiento",
        "tog-watchmoves": "Añadir las páginas y archivos que mueva a mi lista de seguimiento",
        "tog-watchdeletion": "Añadir las páginas y archivos que borre a mi lista de seguimiento",
-       "tog-watchuploads": "Agregar nuevos archivos puedo subir a mi lista de favoritos",
+       "tog-watchuploads": "Agregar los archivos nuevos que suba a mi lista de seguimiento",
        "tog-watchrollback": "Añadir las páginas donde haya realizado una reversión a mi lista de seguimiento",
        "tog-minordefault": "Marcar todas las ediciones como menores de manera predeterminada",
        "tog-previewontop": "Mostrar previsualización antes del cuadro de edición",
        "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",
        "whatlinkshere-prev": "{{PLURAL:$1|previa|previas $1}}",
        "whatlinkshere-next": "{{PLURAL:$1|siguiente|siguientes $1}}",
        "whatlinkshere-links": "← enlaces",
-       "whatlinkshere-hideredirs": "$1 redirecciones",
-       "whatlinkshere-hidetrans": "$1 inclusiones",
-       "whatlinkshere-hidelinks": "$1 enlaces",
+       "whatlinkshere-hideredirs": "Ocultar redirecciones",
+       "whatlinkshere-hidetrans": "Ocultar transclusiones",
+       "whatlinkshere-hidelinks": "Ocultar enlaces",
        "whatlinkshere-hideimages": "Ocultar los vínculos de archivo",
        "whatlinkshere-filters": "Filtros",
        "whatlinkshere-submit": "Ir",
        "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 4926942..7f68ff5 100644 (file)
        "whatlinkshere-links": "← loturak",
        "whatlinkshere-hideredirs": "$1 birzuzenketak",
        "whatlinkshere-hidetrans": "$1 transklusioak",
-       "whatlinkshere-hidelinks": "$1 loturak",
+       "whatlinkshere-hidelinks": "Ezkutatu loturak",
        "whatlinkshere-hideimages": "$1 irudi loturak",
        "whatlinkshere-filters": "Iragazleak",
        "whatlinkshere-submit": "Joan",
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 89834e6..c32549a 100644 (file)
        "right-override-export-depth": "Viedä sivuja sisältäen viitatut sivut viiden syvyydellä",
        "right-sendemail": "Lähettää sähköpostia muille käyttäjille",
        "right-passwordreset": "Tarkastella salasanan alustusviestejä",
-       "right-managechangetags": "Luoda ja poistaa [[Special:Tags|merkkauksia]] tietokannasta",
+       "right-managechangetags": "Luoda ja ottaa käyttöön [[Special:Tags|merkkauksia]]",
        "right-applychangetags": "Asettaa [[Special:Tags|merkkauksia]] omien muutosten yhteyteen",
        "right-changetags": "Lisätä ja poistaa satunnaisia [[Special:Tags|merkkauksia]] yksittäisissä sivuversioissa tai lokimerkinnöissä",
+       "right-deletechangetags": "Poistaa [[Special:Tags|merkkauksia]] tietokannasta",
        "grant-generic": "\"$1\" oikeuksien joukko",
        "grant-group-page-interaction": "Ole vuorovaikutuksessa sivujen kanssa",
        "grant-group-file-interaction": "Ole vuorovaikutuksessa mediatiedostojen kanssa",
        "action-viewmyprivateinfo": "katsoa omia yksityisiä tietojasi",
        "action-editmyprivateinfo": "muokata omia yksityisiä tietojasi",
        "action-editcontentmodel": "muokata sivun sisältömallia",
-       "action-managechangetags": "luoda ja poistaa merkkauksia tietokannasta",
+       "action-managechangetags": "luoda ja ottaa käyttöön merkkauksia",
        "action-applychangetags": "käyttää merkkauksia muutostesi yhteydessä",
        "action-changetags": "lisätä ja poistaa satunnaisia merkkauksia yksittäisissä sivuversioissa ja lokimerkinnöissä",
+       "action-deletechangetags": "poistaa merkkauksia tietokannasta",
        "nchanges": "$1 {{PLURAL:$1|muutos|muutosta}}",
        "enhancedrc-since-last-visit": "$1 {{PLURAL:$1|viimeisen käynnin jälkeen}}",
        "enhancedrc-history": "historia",
        "changecontentmodel-success-text": "Sisältötyyppiä kohteessa [[:$1]] on muutettu.",
        "changecontentmodel-cannot-convert": "Sisältöä sivulla [[:$1]] ei voida muuntaa tyypiksi $2.",
        "changecontentmodel-nodirectediting": "Sisältömalli $1 ei tue suoraa muokkaamista",
+       "changecontentmodel-emptymodels-title": "Mitään sisältömallia ei ole saatavilla",
+       "changecontentmodel-emptymodels-text": "Sisältöä sivulla [[:$1]] ei voida muuntaa mihinkään muotoon.",
        "log-name-contentmodel": "Sisältömallin muutosloki",
        "log-description-contentmodel": "Tapahtumat, jotka liittyvät sivun sisältömalleihin",
        "logentry-contentmodel-new": "$1 {{GENDER:$2|loi}} sivun $3 käyttäen normaalista poikkeavaa sisältömallia \"$5\"",
        "whatlinkshere-prev": "← {{PLURAL:$1|edellinen sivu|$1 edellistä sivua}}",
        "whatlinkshere-next": "{{PLURAL:$1|seuraava sivu|$1 seuraavaa sivua}} →",
        "whatlinkshere-links": "viittaukset",
-       "whatlinkshere-hideredirs": "$1 ohjaukset",
-       "whatlinkshere-hidetrans": "$1 sisällytykset",
-       "whatlinkshere-hidelinks": "$1 linkit",
-       "whatlinkshere-hideimages": "$1 tiedostolinkit",
+       "whatlinkshere-hideredirs": "Piilota ohjaukset",
+       "whatlinkshere-hidetrans": "Piilota sisällytykset",
+       "whatlinkshere-hidelinks": "Piilota linkit",
+       "whatlinkshere-hideimages": "Piilota tiedostolinkit",
        "whatlinkshere-filters": "Suotimet",
        "whatlinkshere-submit": "Siirry",
        "autoblockid": "Automaattinen esto #$1",
        "lockdbsuccesstext": "Tietokanta on lukittu.<br />\nMuista [[Special:UnlockDB|poistaa tietokannan lukitus]] kun huolto on tehty.",
        "unlockdbsuccesstext": "Tietokannan lukitus on poistettu.",
        "lockfilenotwritable": "Tietokannan lukitustiedostoa ei voi kirjoittaa. Tarkista oikeudet.",
+       "databaselocked": "Tietokanta on jo lukittu.",
        "databasenotlocked": "Tietokantaa ei ole lukittu.",
        "lockedbyandtime": "(lukinnut {{GENDER:$1|$1}} $2 kello $3)",
        "move-page": "Siirrä $1",
        "tags-delete-not-found": "Merkkausta \"$1\" ei ole olemassa.",
        "tags-delete-too-many-uses": "Tämä merkkaus \"$1\" on käytössä useammassa kuin $2 sivuversiossa, joten sitä ei voi poistaa.",
        "tags-delete-warnings-after-delete": "Merkkaus \"$1\" poistettiin, mutta toimenpide sai aikaan {{PLURAL:$2|seuraavan varoituksen|seuraavat varoitukset}}:",
+       "tags-delete-no-permission": "Sinulla ei ole oikeutta poistaa muutoksien yhteydessä olevia merkkauksia.",
        "tags-activate-title": "Aktivoi merkkaus",
        "tags-activate-question": "Olet nyt aktivoimassa merkkausta \"$1\".",
        "tags-activate-reason": "Syy:",
        "feedback-useragent": "User agent:",
        "searchsuggest-search": "Hae",
        "searchsuggest-containing": "sisältää...",
+       "api-error-autoblocked": "Sinun IP-osoitteesi on estetty automaattisesti, koska sitä on käyttänyt estetty käyttäjätunnus.",
        "api-error-badaccess-groups": "Sinulla ei ole oikeutta tallentaa tiedostoja tähän wikiin.",
        "api-error-badtoken": "Sisäinen virhe: virheellinen tarkistussumma.",
+       "api-error-blocked": "Sinut on estetty muokkaamasta.",
        "api-error-copyuploaddisabled": "Tallentaminen URL-osoitteesta ei ole käytössä.",
        "api-error-duplicate": "Samansisältöisiä tiedostoja löytyi {{PLURAL:$1|yksi kappale|useampia kappaleita}}.",
        "api-error-duplicate-archive": "Sivustolla oli aiemmin {{PLURAL:$1|toinen samansisältöinen tiedosto|toisia samansisältöisiä tiedostoja}}, mutta {{PLURAL:$1|se|ne}} poistettiin.",
index 672b071..74c126c 100644 (file)
        "right-override-export-depth": "Exporter les pages en incluant les pages liées jusqu'à une profondeur de 5 niveaux",
        "right-sendemail": "Envoyer un courriel aux autres utilisateurs",
        "right-passwordreset": "Voir les courriels de réinitialisation des mots de passe",
-       "right-managechangetags": "Créer et supprimer des [[Spécial:Balises|balises]] de la base de données",
+       "right-managechangetags": "Créer et (dés)activer des [[Special:Tags|balises]]",
        "right-applychangetags": "Appliquer [[Special:Tags|les balises]] avec ses propres modifications",
        "right-changetags": "Ajouter et supprimer de façon arbitraire [[Special:Tags|des balises]] sur des révisions individuelles et des entrées de journal",
+       "right-deletechangetags": "Supprimer des [[Special:Tags|balises]] de la base de données",
        "grant-generic": "ensemble de droits « $1 »",
        "grant-group-page-interaction": "Interagir avec des pages",
        "grant-group-file-interaction": "Interagir avec des médias",
        "action-viewmyprivateinfo": "voir vos informations personnelles",
        "action-editmyprivateinfo": "modifier vos informations personnelles",
        "action-editcontentmodel": "modifier le modèle de contenu d’une page",
-       "action-managechangetags": "créer et supprimer des balises de la base de données",
+       "action-managechangetags": "créer et (dés)activer des balises",
        "action-applychangetags": "appliquer les balises avec vos modifications",
        "action-changetags": "ajouter et supprimer de façon arbitraire des balises sur des révisions individuelles et des entrées de journal",
+       "action-deletechangetags": "supprimer des balises de la base de données",
        "nchanges": "$1 modification{{PLURAL:$1||s}}",
        "enhancedrc-since-last-visit": "$1 {{PLURAL:$1|depuis la dernière visite}}",
        "enhancedrc-history": "historique",
        "tags-delete-not-found": "La balise « $1 » n’existe pas.",
        "tags-delete-too-many-uses": "La balise « $1 » est appliquée à plus de $2 {{PLURAL:$2|révision|révisions}}, ce qui signifie qu'elle ne peut pas être supprimée.",
        "tags-delete-warnings-after-delete": "La balise « $1 » a été supprimée, mais {{PLURAL:$2|l’avertissement suivant a|les avertissements suivants ont}} été rencontré{{PLURAL:$2||s}} :",
+       "tags-delete-no-permission": "Vous n’avez pas le droit de supprimer des balises de changement.",
        "tags-activate-title": "Activer la balise",
        "tags-activate-question": "Vous êtes sur le point d'activer la balise « $1 ».",
        "tags-activate-reason": "Motif :",
index ba7e524..839df37 100644 (file)
        "right-override-export-depth": "Exportar páxinas incluíndo as páxinas ligadas ata unha profundidade de 5",
        "right-sendemail": "Enviar correos electrónicos a outros usuarios",
        "right-passwordreset": "Ver os correos electrónicos de restablecemento de contrasinais",
-       "right-managechangetags": "Crear e borrar [[Special:Tags|tags]] da base de datos",
+       "right-managechangetags": "Crear e (des)activar [[Special:Tags|tags]]",
        "right-applychangetags": "Aplicar [[Special:Tags|etiquetas]] xunto cos cambios propios",
        "right-changetags": "Engadir e quitar [[Special:Tags|etiquetas]] arbitrarias a revisións individuais e entradas do rexistro",
+       "right-deletechangetags": "Suprimir as [[Special:Tags|etiquetas]] da base de datos",
        "grant-generic": "conxunto de dereitos \"$1\"",
        "grant-group-page-interaction": "Interactuar con páxinas",
        "grant-group-file-interaction": "Interactuar con ficheiros multimedia",
        "action-viewmyprivateinfo": "ver a súa información privada",
        "action-editmyprivateinfo": "editar a súa información privada",
        "action-editcontentmodel": "editar o modelo de contido dunha páxina",
-       "action-managechangetags": "crear e borrar etiquetas da base de datos",
+       "action-managechangetags": "crear e (des)activar etiquetas",
        "action-applychangetags": "aplicar etiquetas xunto cos cambios",
        "action-changetags": "engadir e quitar etiquetas arbitrarias a revisións individuais e entradas do rexistro",
+       "action-deletechangetags": "borrar etiquetas da base de datos",
        "nchanges": "$1 {{PLURAL:$1|modificación|modificacións}}",
        "enhancedrc-since-last-visit": "$1 {{PLURAL:$1|desde a última visita}}",
        "enhancedrc-history": "historial",
        "tags-delete-not-found": "A páxina \"$1\" non existe.",
        "tags-delete-too-many-uses": "A etiqueta \"$1\" aplícase a máis de $2 {{PLURAL:$2|revisión|revisións}}; isto significa que non se pode borrar.",
        "tags-delete-warnings-after-delete": "A etiqueta \"$1\" borrouse; con todo, {{PLURAL:$2|atopouse o seguinte aviso|atopáronse os seguintes avisos}}:",
+       "tags-delete-no-permission": "Non ten permisos para borrar etiquetas de cambio.",
        "tags-activate-title": "Activar unha etiqueta",
        "tags-activate-question": "Está a piques de activar a etiqueta\"$1\".",
        "tags-activate-reason": "Motivo:",
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 0d61e70..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": "אוק'",
        "category_header": "דפים בקטגוריה \"$1\"",
        "subcategories": "קטגוריות משנה",
        "category-media-header": "קובצי מדיה בקטגוריה \"$1\"",
-       "category-empty": "<em>קטגוריה זו אינה כוללת דפים או קובצי מדיה.</em>",
+       "category-empty": "<strong>קטגוריה זו אינה כוללת דפים או קובצי מדיה.</strong>",
        "hidden-categories": "{{PLURAL:$1|קטגוריה מוסתרת|קטגוריות מוסתרות}}",
        "hidden-category-category": "קטגוריות מוסתרות",
        "category-subcat-count": "{{PLURAL:$2|קטגוריה זו כוללת את קטגוריית המשנה הבאה בלבד.|קטגוריה זו כוללת את {{PLURAL:$1|קטגוריית המשנה המוצגת להלן|$1 קטגוריות המשנה המוצגות להלן}}, וכוללת בסך־הכול $2 קטגוריות משנה.}}",
        "editingsection": "עריכת הדף $1 (פסקה)",
        "editingcomment": "עריכת הדף $1 (פסקה חדשה)",
        "editconflict": "התנגשות עריכה: $1",
-       "explainconflict": "×\9eשת×\9eש ×\90×\97ר ×©×\99× ×\94 ×\90ת ×\94×\93×£ ×\9e×\90×\96 ×©×\94ת×\97×\9cת×\9d ×\9cער×\95×\9a ×\90×\95ת×\95.\n×\97×\9c×\95×\9f ×\94ער×\99×\9b×\94 ×\94×¢×\9c×\99×\95×\9f ×\9e×\9b×\99×\9c ×\90ת ×\94×\98קס×\98 ×\91×\93×£ ×\9bפ×\99 ×©×\94×\95×\90 ×¢×ª×\94.\n×\94ש×\99× ×\95×\99×\99×\9d ×©×\9c×\9b×\9d ×\9e×\95צ×\92×\99×\9d ×\91×\97×\9c×\95×\9f ×\94ער×\99×\9b×\94 ×\94ת×\97ת×\95×\9f.\n×¢×\9c×\99×\9b×\9d ×\9c×\9e×\96×\92 ×\90ת ×\94ש×\99× ×\95×\99×\99×\9d ×©×\9c×\9b×\9d ×\9cת×\95×\9a ×\94×\98קס×\98 ×\94ק×\99×\99×\9d.\n'''רק''' הטקסט בחלון העריכה העליון יישמר כשתלחצו על \"{{int:savearticle}}\".",
+       "explainconflict": "×\9eשת×\9eש ×\90×\97ר ×©×\99× ×\94 ×\90ת ×\94×\93×£ ×\9e×\90×\96 ×©×\94ת×\97×\9cת×\9d ×\9cער×\95×\9a ×\90×\95ת×\95.\n×\97×\9c×\95×\9f ×\94ער×\99×\9b×\94 ×\94×¢×\9c×\99×\95×\9f ×\9eצ×\99×\92 ×\90ת ×\94×\98קס×\98 ×\91×\93×£ ×\9bפ×\99 ×©×\94×\95×\90 ×\9bר×\92×¢.\n×\94ש×\99× ×\95×\99×\99×\9d ×©×\9c×\9b×\9d ×\9e×\95צ×\92×\99×\9d ×\91×\97×\9c×\95×\9f ×\94ער×\99×\9b×\94 ×\94ת×\97ת×\95×\9f.\n×¢×\9c×\99×\9b×\9d ×\9c×\9e×\96×\92 ×\90ת ×\94ש×\99× ×\95×\99×\99×\9d ×©×\9c×\9b×\9d ×\9cת×\95×\9a ×\94×\98קס×\98 ×\94ק×\99×\99×\9d.\n<strong>רק</strong> הטקסט בחלון העריכה העליון יישמר כשתלחצו על \"{{int:savearticle}}\".",
        "yourtext": "הטקסט שלך",
        "storedversion": "גרסה שמורה",
        "nonunicodebrowser": "'''אזהרה: הדפדפן שלך אינו תואם לתקן יוניקוד.'''\nכדי למנוע בעיות הנוצרות כתוצאה מכך ולאפשר לך לערוך דפים בבטחה, תווים שאינם ב־ASCII יוצגו בתיבת העריכה כקודים הקסדצימליים.",
-       "editingold": "'''אזהרה: אתם עורכים גרסה לא עדכנית של דף זה.'''\nאם תשמרו את הדף, כל השינויים שנעשו מאז גרסה זו יאבדו.",
+       "editingold": "<strong>אזהרה: אתם עורכים גרסה לא עדכנית של דף זה.</strong>\nאם תשמרו את הדף, כל השינויים שנעשו מאז גרסה זו יאבדו.",
        "yourdiff": "הבדלים",
        "copyrightwarning": "'''שימו לב:''' תרומתכם ל{{grammar:תחילית|{{SITENAME}}}} תפורסם תחת תנאי הרישיון $2 (ראו $1 לפרטים נוספים). אם אינכם רוצים שעבודתכם תהיה זמינה לעריכה על־ידי אחרים, שתופץ לעיני כול, ושאחרים יוכלו להעתיק ממנה בציון המקור – אל תפרסמו אותה פה. כמו־כן, אתם מבטיחים לנו כי כתבתם את הטקסט הזה בעצמכם, או העתקתם אותו ממקור שאינו מוגן בזכויות יוצרים. '''אל תעשו שימוש בחומר המוגן בזכויות יוצרים ללא רשות!'''",
        "copyrightwarning2": "'''שימו לב:''' תורמים אחרים עשויים לערוך או אף להסיר את תרומתכם ל{{grammar:תחילית|{{SITENAME}}}}. אם אינכם רוצים שעבודתכם תהיה זמינה לעריכה על־ידי אחרים, אל תפרסמו אותה פה. כמו־כן, אתם מבטיחים לנו כי כתבתם את הטקסט הזה בעצמכם, או העתקתם אותו ממקור שאינו מוגן בזכויות יוצרים (ראו $1 לפרטים נוספים). '''אל תעשו שימוש בחומר המוגן בזכויות יוצרים ללא רשות!'''",
        "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": "המספר המרבי של העריכות שמוצגות ברשימת המעקב המורחבת:",
        "right-override-export-depth": "ייצוא דפים כולל דפים מקושרים עד עומק של חמישה",
        "right-sendemail": "שליחת דואר אלקטרוני למשתמשים אחרים",
        "right-passwordreset": "צפייה בדואר אלקטרוני של איפוס סיסמה",
-       "right-managechangetags": "×\99צ×\99רת ×\95×\9e×\97×\99קת [[Special:Tags|ת×\92×\99×\95ת]] ×\9e×\91ס×\99ס ×\94נת×\95× ×\99×\9d",
+       "right-managechangetags": "×\99צ×\99ר×\94, ×\94פע×\9c×\94 ×\95×\91×\99×\98×\95×\9c ×©×\9c [[Special:Tags|ת×\92×\99×\95ת]]",
        "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": "ליצור ולמחוק תגיות מבסיס הנתונים",
+       "action-managechangetags": "ליצור, להפעיל ולבטל תגיות",
        "action-applychangetags": "להחיל תגיות יחד עם השינויים שלכם",
        "action-changetags": "להוסיף ולהסיר תגיות כלשהן לגרסאות מסוימות ולרשומות יומן",
+       "action-deletechangetags": "למחוק תגיות מבסיס הנתונים",
        "nchanges": "{{PLURAL:$1|שינוי אחד|$1 שינויים}}",
        "enhancedrc-since-last-visit": "$1 {{PLURAL:$1|מאז ביקורך האחרון}}",
        "enhancedrc-history": "היסטוריה",
        "recentchanges-label-unpatrolled": "עריכה זו טרם נבדקה",
        "recentchanges-label-plusminus": "גודל הדף השתנה במספר זה של בתים",
        "recentchanges-legend-heading": "<strong>מקרא:</strong>",
-       "recentchanges-legend-newpage": "{{int:recentchanges-label-newpage}} (ראו גם [[Special:NewPages|רשימת דפים חדשים]])",
+       "recentchanges-legend-newpage": "{{int:recentchanges-label-newpage}} (ראו גם את [[Special:NewPages|רשימת הדפים החדשים]])",
        "recentchanges-legend-plusminus": "(''±123'')",
        "recentchanges-submit": "הצגה",
        "rcnotefrom": "להלן {{PLURAL:$5|השינוי שבוצע|השינויים שבוצעו}} מאז <strong>$3, $4</strong> (מוצגים עד <strong>$1</strong>).",
        "enotif_body": "לכבוד $WATCHINGUSERNAME,\n\n$PAGEINTRO $NEWPAGE\n\nתקציר העריכה: $PAGESUMMARY $PAGEMINOREDIT\n\nבאפשרותכם ליצור קשר עם העורך:\nבדואר האלקטרוני: $PAGEEDITOR_EMAIL\nבאתר: $PAGEEDITOR_WIKI\n\nלא תהיינה הודעות על פעולות נוספות עד שתבקרו בדף כשאתם מחוברים לחשבון. באפשרותכם גם לאפס את דגלי ההודעות בכל הדפים שברשימת המעקב.\n\nמערכת ההודעות של {{SITENAME}}\n\n--\nכדי לשנות את ההגדרות של הודעות הדוא\"ל הנשלחות אליכם, בקרו בדף\n{{canonicalurl:{{#special:Preferences}}}}\n\nכדי לשנות את הגדרות רשימת המעקב, בקרו בדף\n{{canonicalurl:{{#special:EditWatchlist}}}}\n\nכדי למחוק את הדף מרשימת המעקב שלכם, בקרו בדף\n$UNWATCHURL\n\nלמשוב ולעזרה נוספת:\n$HELPPAGE",
        "created": "נוצר",
        "changed": "שונה",
-       "deletepage": "×\9e×\97×\99ק×\94",
+       "deletepage": "×\9e×\97×\99קת ×\94×\93×£",
        "confirm": "אישור",
        "excontent": "התוכן היה: \"$1\"",
        "excontentauthor": "התוכן היה: \"$1\", {{GENDER:$2|והתורם היחיד היה|והתורמת היחידה הייתה}} \"[[Special:Contributions/$2|$2]]\" ([[User talk:$2|שיחה]])",
        "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": "חסימה מקומית",
        "cant-move-to-category-page": "אין לך הרשאה להעביר דף לדף קטגוריה.",
        "newtitle": "השם החדש:",
        "move-watch": "מעקב אחרי דף המקור ואחרי דף היעד",
-       "movepagebtn": "×\94×¢×\91ר×\94",
+       "movepagebtn": "×\94×¢×\91רת ×\94×\93×£",
        "pagemovedsub": "ההעברה הושלמה בהצלחה",
        "movepage-moved": "הדף \"$1\" הועבר לשם \"$2\".",
        "movepage-moved-redirect": "נוצרה הפניה.",
        "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": "הרחבות מותקנות",
        "tags-delete-not-found": "התגית \"$1\" אינה קיימת.",
        "tags-delete-too-many-uses": "התגית \"$1\" מוחלת על יותר {{PLURAL:$2|מגרסה אחת|מ־$2 גרסאות}}, ולכן לא ניתן למחוק אותה.",
        "tags-delete-warnings-after-delete": "התגית \"$1\" נמחקה, אבל {{PLURAL:$2|התקבלה האזהרה הבאה|התקבלו האזהרות הבאות}}:",
+       "tags-delete-no-permission": "אין לך הרשאה למחוק תגיות שינויים.",
        "tags-activate-title": "הפעלת תגית",
        "tags-activate-question": "אתם עומדים להפעיל את התגית \"$1\".",
        "tags-activate-reason": "הסבר:",
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 140f56a..3d9cd3b 100644 (file)
        "otherlanguages": "Nan lòt lang yo",
        "redirectedfrom": "(Redirije depi $1)",
        "redirectpagesub": "Paj pou redireksyon",
-       "redirectto": "Voye sou:",
+       "redirectto": "Redireksyon sou&nbsp;:",
        "lastmodifiedat": "Paj sa te modifye pou dènye fwa $1 a $2.<br />",
        "viewcount": "Paj sa te konsilte {{PLURAL:$1|yon fwa|$1 fwa}}.",
        "protectedpage": "Paj pwoteje",
        "whatlinkshere-page": "Paj :",
        "linkshere": "Paj yo ki anba ap mene nan <b>[[:$1]]</b> :",
        "nolinkshere": "Pyès paj genyen lyen pou paj sa a <b>[[:$1]]</b>.",
-       "isredirect": "Paj redireksyon",
+       "isredirect": "paj redireksyon",
        "istemplate": "anndan",
        "isimage": "lyen fichye a",
        "whatlinkshere-prev": "{{PLURAL:$1|presedan|$1 presedan yo}}",
index 0f60d82..05a68b4 100644 (file)
@@ -32,6 +32,7 @@
        "tog-watchdefault": "Adder le paginas e files que io modifica a mi observatorio",
        "tog-watchmoves": "Adder le paginas e files que io renomina a mi observatorio",
        "tog-watchdeletion": "Adder le paginas e files que io dele a mi observatorio",
+       "tog-watchuploads": "Adder le nove files que io incarga a mi observatorio",
        "tog-watchrollback": "Adder a mi observatorio le paginas in que io ha effectuate un revocation",
        "tog-minordefault": "Marcar omne modificationes initialmente como minor",
        "tog-previewontop": "Monstrar previsualisation ante le quadro de modification",
@@ -56,7 +57,7 @@
        "tog-ccmeonemails": "Inviar me copias del messages de e-mail que io invia a altere usatores",
        "tog-diffonly": "Non monstrar le contento del pagina sub le comparation de duo versiones",
        "tog-showhiddencats": "Monstrar categorias celate",
-       "tog-norollbackdiff": "Omitter le diff post le execution de un revocation",
+       "tog-norollbackdiff": "Non monstrar differentias post exequer un revocation",
        "tog-useeditwarning": "Advertir me quando io quita un pagina de modification sin publicar le cambiamentos",
        "tog-prefershttps": "Sempre usar un connexion secur in session aperte",
        "underline-always": "Sempre",
        "minoredit": "Isto es un modification minor",
        "watchthis": "Observar iste pagina",
        "savearticle": "Publicar pagina",
+       "publishpage": "Publicar pagina",
        "preview": "Previsualisation",
        "showpreview": "Monstrar previsualisation",
        "showdiff": "Detaliar modificationes",
        "userpage-userdoesnotexist": "Le conto de usator \"<nowiki>$1</nowiki>\" non es registrate. Per favor verifica que tu vole crear/modificar iste pagina.",
        "userpage-userdoesnotexist-view": "Le conto de usator \"$1\" non es registrate.",
        "blocked-notice-logextract": "Iste usator es actualmente blocate.\nLe ultime entrata del registro de blocadas es reproducite ci infra pro information:",
-       "clearyourcache": "'''Nota:''' Post confirmar, il pote esser necessari refrescar le ''cache'' de tu navigator pro vider le cambiamentos.\n* '''Firefox / Safari:''' Tenente ''Shift'' clicca ''Reload (Recargar)'', o preme ''Ctrl-F5'' o ''Ctrl-R'' (''⌘-R'' sur Mac)\n* '''Google Chrome:''' Preme ''Ctrl-Shift-R'' (''⌘-Shift-R'' sur Mac)\n* '''Internet Explorer:''' Tenente ''Ctrl'' clicca ''Refresh (Refrescar)'', o preme ''Ctrl-F5'' \n* '''Opera:''' Vacua le ''cache'' in ''Tools → Preferences (Utensiles → Preferentias)''",
+       "clearyourcache": "<strong>Nota:</strong> Post confirmar, il pote esser necessari refrescar le <em>cache</em> de tu navigator pro vider le cambiamentos.\n* <strong>Firefox / Safari:</strong> Tenente <em>Shift</em> clicca <em>Reload (Recargar)</em>, o preme <em>Ctrl-F5</em> o <em>Ctrl-R</em> (<em>⌘-R</em> sur Mac)\n* <strong>Google Chrome:</strong> Preme <em>Ctrl-Shift-R</em> (<em>⌘-Shift-R</em> sur Mac)\n* <strong>Internet Explorer:</strong> Tenente <em>Ctrl</em> clicca <em>Refresh (Refrescar)</em>, o preme <em>Ctrl-F5</em> \n* <strong>Opera:</strong> Vade a <em>Menu → Configurationes</em> (<em>Opera → Preferentias</em> sur un Mac) e alora a <em>Privacy & securitate → Rader datos de navigation → Files e imagines in cache</em>.",
        "usercssyoucanpreview": "'''Consilio:''' Usa le button \"{{int:showpreview}}\" pro testar tu nove CSS ante de salveguardar lo.",
        "userjsyoucanpreview": "'''Consilio:''' Usa le button \"{{int:showpreview}}\" pro testar tu nove JavaScript ante de salveguardar lo.",
        "usercsspreview": "'''Non oblida que isto es solmente un previsualisation de tu CSS personalisate.'''\n'''Le modificationes non ha ancora essite salveguardate!'''",
        "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",
        "recentchangeslinked-page": "Nomine del pagina:",
        "recentchangeslinked-to": "Monstrar modificationes in paginas con ligamines al pagina specificate",
        "recentchanges-page-added-to-category": "[[:$1]] addite al categoria",
-       "recentchanges-page-added-to-category-bundled": "[[:$1]] e [[Special:WhatLinksHere/$1|{{PLURAL:$2|un pagina|$2 paginas}}]] 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",
        "changecontentmodel-success-text": "Le typo de contento de [[:$1]] ha essite cambiate.",
        "changecontentmodel-cannot-convert": "Le contento de [[:$1]] non pote esser convertite a un typo de $2.",
        "changecontentmodel-nodirectediting": "Le modello de contento $1 non supporta le modification directe",
+       "changecontentmodel-emptymodels-title": "Nulle modello de contento disponibile",
+       "changecontentmodel-emptymodels-text": "Le contento in [[:$1]] non pote esser convertite in alcun typo.",
        "log-name-contentmodel": "Registro de cambiamentos de modello de contento",
        "log-description-contentmodel": "Eventos relative al modellos de contento de un pagina",
        "logentry-contentmodel-new": "$1 {{GENDER:$2|creava}} le pagina $3 con le modello de contento non predefinite \"$5\"",
        "whatlinkshere-prev": "{{PLURAL:$1|precedente|precedente $1}}",
        "whatlinkshere-next": "{{PLURAL:$1|sequente|sequente $1}}",
        "whatlinkshere-links": "← ligamines",
-       "whatlinkshere-hideredirs": "$1 redirectiones",
-       "whatlinkshere-hidetrans": "$1 transclusiones",
-       "whatlinkshere-hidelinks": "$1 ligamines",
-       "whatlinkshere-hideimages": "$1 le ligamines a files",
+       "whatlinkshere-hideredirs": "Celar redirectiones",
+       "whatlinkshere-hidetrans": "Celar transclusiones",
+       "whatlinkshere-hidelinks": "Celar ligamines",
+       "whatlinkshere-hideimages": "Celar le ligamines a files",
        "whatlinkshere-filters": "Filtros",
        "whatlinkshere-submit": "Va",
        "autoblockid": "Auto-blocada №$1",
        "lockdbsuccesstext": "Le base de datos de {{SITENAME}} ha essite blocate.\n<br />Rememora te de disblocar lo post completar tu mantenentia.",
        "unlockdbsuccesstext": "Le base de datos de {{SITENAME}} ha essite disblocate.",
        "lockfilenotwritable": "Impossibile scriber al file de blocada del base de datos.\nPro blocar o disblocar le base de datos, le servitor web debe poter scriber a iste file.",
+       "databaselocked": "Le base de datos es jam blocate.",
        "databasenotlocked": "Le base de datos non es blocate.",
        "lockedbyandtime": "(per $1 le $2 a $3)",
        "move-page": "Renominar $1",
        "tooltip-ca-nstab-category": "Vider le pagina del categoria",
        "tooltip-minoredit": "Marcar iste modification como minor",
        "tooltip-save": "Confirmar tu modificationes",
+       "tooltip-publish": "Publicar tu cambiamentos",
        "tooltip-preview": "Per favor verifica tu modificationes ante que tu los publica!",
        "tooltip-diff": "Detaliar le modificationes que tu ha facite in le texto.",
        "tooltip-compareselectedversions": "Vider le differentias inter le seligite duo versiones de iste pagina.",
        "confirmemail_body_set": "Un persona, probabilemente tu, usante le adresse IP $1,\nha specificate que iste adresse de e-mail pertine al conto \"$2\" in {{SITENAME}}.\n\nPro confirmar que iste conto vermente pertine a te, e pro activar le functionalitate\nde e-mail in {{SITENAME}}, visita iste ligamine in tu navigator:\n\n$3\n\nSi le conto *non* pertine a te, seque iste ligamine\npro cancellar le confirmation del adresse de e-mail:\n\n$5\n\nIste codice de confirmation expirara le $6 a $7.",
        "confirmemail_invalidated": "Confirmation del adresse de e-mail cancellate",
        "invalidateemail": "Cancellar confirmation del adresse de e-mail",
+       "notificationemail_subject_changed": "Le adresse de e-mail registrate sur {{SITENAME}} ha essite cambiate",
+       "notificationemail_subject_removed": "Le adresse de e-mail registrate sur {{SITENAME}} ha essite removite",
+       "notificationemail_body_changed": "Qualcuno, probabilemente tu, ab le adresse IP $1, ha cambiate le adresse de e-mail del conto \"$2\" in \"$3\" sur {{SITENAME}}.\n\nSi isto non esseva tu, contacta immediatemente un administrator del sito.",
+       "notificationemail_body_removed": "Qualcuno, probabilemente tu, ab le adresse IP $1, ha removite le adresse de e-mail del conto \"$2\" sur {{SITENAME}}.\n\nSi isto non esseva tu, contacta immediatemente un administrator del sito.",
        "scarytranscludedisabled": "[Le transclusion interwiki es disactivate]",
        "scarytranscludefailed": "[Falleva de obtener le patrono pro $1]",
        "scarytranscludefailed-httpstatus": "[Obtention de patrono fallite pro $1: HTTP $2]",
        "watchlistedit-raw-done": "Tu observatorio ha essite actualisate.",
        "watchlistedit-raw-added": "{{PLURAL:$1|1 titulo|$1 titulos}} ha essite addite:",
        "watchlistedit-raw-removed": "{{PLURAL:$1|1 titulo|$1 titulos}} ha essite removite:",
-       "watchlistedit-clear-title": "Observatorio radite",
+       "watchlistedit-clear-title": "Rader observatorio",
        "watchlistedit-clear-legend": "Rader observatorio",
        "watchlistedit-clear-explain": "Tote le titulos essera removite de tu observatorio",
        "watchlistedit-clear-titles": "Titulos:",
        "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",
        "version-libraries-description": "Description",
        "version-libraries-authors": "Autores",
        "redirect": "Rediriger per ID de file, usator, pagina, version o registro",
-       "redirect-summary": "Iste pagina special redirige a un file (si es date le nomine de un file), a un pagina (si es date un ID de version o ID de pagina) o a un pagina de usator (si es date un ID de usator numeric). Usage: [[{{#Special:Redirect}}/file/Example.jpg]], [[{{#Special:Redirect}}/page/64308]], [[{{#Special:Redirect}}/revision/328429]] o [[{{#Special:Redirect}}/user/101]].",
+       "redirect-summary": "Iste pagina special redirige a un file (si es date le nomine de un file), a un pagina (si es date un ID de version o ID de pagina), a un pagina de usator (si es date un ID de usator numeric) o a un entrata de registro (si es date le ID de un registro). Usage: [[{{#Special:Redirect}}/file/Example.jpg]], [[{{#Special:Redirect}}/page/64308]], [[{{#Special:Redirect}}/revision/328429]], [[{{#Special:Redirect}}/user/101]] o [[{{#Special:Redirect}}/logid/186]].",
        "redirect-submit": "Va",
        "redirect-lookup": "Cercar:",
        "redirect-value": "Valor:",
        "redirect-page": "ID del pagina",
        "redirect-revision": "Version de pagina",
        "redirect-file": "Nomine de file",
+       "redirect-logid": "ID de registro",
        "redirect-not-exists": "Valor non trovate",
        "fileduplicatesearch": "Cercar files duplicate",
        "fileduplicatesearch-summary": "Cercar files duplicate a base de lor summas de verification ''(hash).''",
        "tags-deactivate": "disactivar",
        "tags-hitcount": "$1 {{PLURAL:$1|modification|modificationes}}",
        "tags-manage-no-permission": "Tu non ha le permission de gerer le etiquettas de modification.",
+       "tags-manage-blocked": "Tu non pote gerer etiquettas de cambiamento durante que tu es blocate.",
        "tags-create-heading": "Crear un nove etiquetta",
        "tags-create-explanation": "Per configuration predefinite, le etiquettas novemente create essera disponibile pro le uso per usatores e robots.",
        "tags-create-tag-name": "Nomine del etiquetta:",
        "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:",
        "tags-deactivate-not-allowed": "Non es possibile disactivar le etiquetta \"$1\".",
        "tags-deactivate-submit": "Disactivar",
        "tags-apply-no-permission": "Tu non ha le permission de adjunger etiquettas de cambiamento a tu cambiamentos.",
+       "tags-apply-blocked": "Tu non pote applicar etiquettas de cambiamento con tu cambiamentos durante que tu es blocate.",
        "tags-apply-not-allowed-one": "Non es permittite applicar le etiquetta \"$1\" manualmente.",
        "tags-apply-not-allowed-multi": "Le sequente {{PLURAL:$2|etiquetta|etiquettas}} non es autorisate a esser manualmente applicate: $1",
        "tags-update-no-permission": "Tu non ha le permission de adder o remover etiquettas de cambiamento sur individual versiones o entratas de registro.",
+       "tags-update-blocked": "Tu non pote adder o remover etiquettas de cambiamento durante que tu es blocate.",
        "tags-update-add-not-allowed-one": "Non es permittite adjunger le etiquetta \"$1\" manualmente.",
        "tags-update-add-not-allowed-multi": "Le sequente {{PLURAL:$2|etiquetta|etiquettas}} non es autorisate a esser manualmente adjungite: $1",
        "tags-update-remove-not-allowed-one": "Non es permittite remover le etiquetta \"$1\".",
        "tags-edit-revision-legend": "Adder o remover etiquettas de {{PLURAL:$1|iste version|tote le $1 versiones}}",
        "tags-edit-logentry-legend": "Adder o remover etiquettas de {{PLURAL:$1|iste entrata|tote le $1 entratas}} de registro",
        "tags-edit-existing-tags": "Etiquettas existente:",
-       "tags-edit-existing-tags-none": "\"Nulle\"",
+       "tags-edit-existing-tags-none": "<em>Nulle</em>",
        "tags-edit-new-tags": "Nove etiquettas:",
        "tags-edit-add": "Adder iste etiquettas:",
        "tags-edit-remove": "Remover iste etiquettas:",
        "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 8b0af46..13d38a0 100644 (file)
        "changepassword-success": "Kata sandi Anda telah diubah!",
        "changepassword-throttled": "Anda terlalu sering mencoba masuk log.\nMohon tunggu $1 sebelum mencoba lagi.",
        "botpasswords": "Kata sandi bot",
+       "botpasswords-summary": "<em>Kata sandi bot</em> memungkinkan akses ke akun pengguna menggunakan API tanpa menggunakan kredensial masuk log utama akun tersebut. Hak pengguna yang tersedia ketika masuk log dengan kata sandi bot mungkin akan dibatasi.\n\nJika Anda tidak tahu kenapa Anda ingin melakukan hal ini, sebaiknya jangan lakukan. Semestinya tidak ada orang lain yang boleh meminta Anda untuk menciptakan dan menyerahkan kata sandi bot ini kepadanya.",
        "botpasswords-disabled": "Kata sandi bot dinonaktifkan.",
        "botpasswords-no-central-id": "Untuk menggunakan kata sandi bot, Anda harus masuk log ke akun yang telah tersentralisasi.",
        "botpasswords-existing": "Kata sandi bot tersedia",
        "botpasswords-label-delete": "Hapus",
        "botpasswords-label-resetpassword": "Setel ulang kata sandi",
        "botpasswords-label-grants": "Akses yang dapat diberikan:",
+       "botpasswords-help-grants": "Tiap izin memberikan akses ke hak-hak pengguna yang telah dimiliki suatu akun pengguna. Lihat [[Special:ListGrants|tabel izin]] untuk informasi lebih lanjut.",
        "botpasswords-label-restrictions": "Batasan penggunaan:",
        "botpasswords-label-grants-column": "Izin diberikan",
        "botpasswords-bad-appid": "Nama bot \"$1\" tidak valid.",
        "botpasswords-insert-failed": "Gagal menambah nama bot \"$1\". Apakah sudah ditambahkan sebelum ini?",
        "botpasswords-update-failed": "Gagal memperbarui nama bot \"$1\". Apakah sebelumnya sudah pernah dihapus?",
        "botpasswords-created-title": "Kata sandi bot dibuat",
-       "botpasswords-created-body": "Kata sandi bot \"$1\" sukses dibuat.",
+       "botpasswords-created-body": "Kata sandi bot \"$1\" berhasil dibuat.",
        "botpasswords-updated-title": "Kata sandi bot diperbarui",
-       "botpasswords-updated-body": "Kata sandi bot \"$1\" sukses diperbarui.",
+       "botpasswords-updated-body": "Kata sandi bot \"$1\" berhasil diperbarui.",
        "botpasswords-deleted-title": "Kata sandi bot dihapus",
        "botpasswords-deleted-body": "Kata sandi bot \"$1\" telah dihapus.",
        "botpasswords-newpassword": "Kata sandi baru untuk masuk log dengan '''$1''' adalah '''$2'''. ''Mohon simpan untuk referensi di kemudian hari.''",
        "resetpass-no-info": "Anda harus masuk log untuk mengakses halaman ini secara langsung.",
        "resetpass-submit-loggedin": "Ganti kata sandi",
        "resetpass-submit-cancel": "Batalkan",
-       "resetpass-wrong-oldpass": "Kata sandi tidak sah.\nAnda mungkin telah berhasil mengganti kata sandi Anda atau telah meminta kata sandi sementara yang baru.",
+       "resetpass-wrong-oldpass": "Kata sandi tidak sah.\nAnda mungkin telah mengganti kata sandi Anda atau telah meminta kata sandi sementara yang baru.",
        "resetpass-recycled": "Mohon menyetel ulang kata sandi Anda ke sesuatu yang berbeda dari kata sandi Anda sekarang.",
        "resetpass-temp-emailed": "Anda masuk log dengan kode sementara yang disurel.\nUntuk menyelesaikan masuk log, Anda harus mengatur sandi baru di sini:",
        "resetpass-temp-password": "Kata sandi sementara:",
        "minoredit": "Ini adalah suntingan kecil.",
        "watchthis": "Pantau halaman ini",
        "savearticle": "Simpan halaman",
+       "publishpage": "Terbitkan halaman",
        "preview": "Pratayang",
        "showpreview": "Lihat pratayang",
        "showdiff": "Lihat perubahan",
        "userpage-userdoesnotexist": "Akun pengguna \"<nowiki>$1</nowiki>\" tidak terdaftar.",
        "userpage-userdoesnotexist-view": "Pengguna \"$1\" tidak terdaftar.",
        "blocked-notice-logextract": "Pengguna ini sedang diblokir.\nEntri log pemblokiran terakhir tersedia di bawah ini sebagai rujukan:",
-       "clearyourcache": "'''Catatan:''' Setelah menyimpan, Anda mungkin harus memintas singgahan peramban Anda untuk melihat perubahan.\n* '''Firefox / Safari:''' Tahan ''Shift'' sambil mengeklik ''Reload'', atau tekan ''Ctrl-F5'' atau ''Ctrl-R'' (''⌘-R'' di Mac)\n* '''Google Chrome:''' Tekan ''Ctrl-Shift-R'' (''⌘-Shift-R'' di Mac)\n* '''Internet Explorer:''' Tahan ''Ctrl'' sambl mengeklik ''Refresh'', atau tekan ''Ctrl-F5''\n* '''Opera:''' Bersihkan tembolok di ''Tools → Preferences''",
+       "clearyourcache": "'''Catatan:''' Setelah menyimpan, Anda mungkin harus memintas isi singgahan peramban Anda untuk melihat perubahan.\n* '''Firefox / Safari:''' Tahan ''Shift'' sambil mengeklik ''Reload'', atau tekan ''Ctrl-F5'' atau ''Ctrl-R'' (''⌘-R'' di Mac)\n* '''Google Chrome:''' Tekan ''Ctrl-Shift-R'' (''⌘-Shift-R'' di Mac)\n* '''Internet Explorer:''' Tahan ''Ctrl'' sambl mengeklik ''Refresh'', atau tekan ''Ctrl-F5''\n* '''Opera:''' Buka <em> Menu → Settings</em> (<em>Opera → Preferences</em>Privacy & Security  → Clear browsing data  → Cached images and files</em>.",
        "usercssyoucanpreview": "'''Tips:''' Gunakan tombol \"{{int:showpreview}}\" untuk menguji CSS baru Anda sebelum menyimpannya.",
        "userjsyoucanpreview": "'''Tips:''' Gunakan tombol \"{{int:showpreview}}\" untuk menguji JS baru Anda sebelum menyimpannya.",
        "usercsspreview": "'''Ingatlah bahwa Anda sedang menampilkan pratayang dari CSS Anda.\nPratayang ini belum disimpan!'''",
        "userrights-unchangeable-col": "Kelompok yang tidak dapat Anda ubah",
        "userrights-irreversible-marker": "$1*",
        "userrights-conflict": "Konflik perubahan hak pengguna! Silakan tinjau ulang dan konfirmasi perubahan Anda.",
-       "userrights-removed-self": "Anda berhasil mencabut hak-hak Anda. Anda tidak bisa lagi mengakses halaman ini.",
+       "userrights-removed-self": "Anda telah mencabut hak-hak Anda sendiri. Anda tidak bisa lagi mengakses halaman ini.",
        "group": "Kelompok:",
        "group-user": "Pengguna",
        "group-autoconfirmed": "Pengguna terkonfirmasi otomatis",
        "uploaded-script-svg": "Terdapat elemen terskrip \"$1\" dalam berkas SVG yang diunggah.",
        "uploaded-hostile-svg": "Terdapat CSS yang tidak aman dalam elemen gaya berkas SVG yang diunggah.",
        "uploaded-event-handler-on-svg": "Penetapan atribut <i>event-handler</i> $1=\"$2\" tidak diizinkan dalam berkas SVG.",
+       "uploaded-href-attribute-svg": "Atribut href pada berkas SVG hanya dibolehkan untuk ditautkan ke target http:// atau https://, ditemukan <code>&lt;$1 $2=\"$3\"&gt;</code>.",
+       "uploaded-href-unsafe-target-svg": "Menemukan href ke data tidak aman: URI menarget <code>&lt;$1 $2=\"$3\"&gt;</code> dalam berkas SVG yang diunggah.",
        "uploaded-setting-event-handler-svg": "Penyetelan atribut event-handler diblokir, menemukan <code>&lt;$1 $2=\"$3\"&gt;</code> dalam berkas SVG yang diunggah.",
        "uploadscriptednamespace": "Berkas SVG ini memuat ruang nama ilegal \"$1\"",
        "uploadinvalidxml": "XML dalam berkas yang diunggah tidak bisa diuraikan.",
        "upload-options": "Opsi pengunggahan",
        "watchthisupload": "Pantau berkas ini",
        "filewasdeleted": "Suatu berkas dengan nama ini pernah dimuat dan selanjutnya dihapus. Harap cek $1 sebelum memuat lagi berkas tersebut.",
+       "filename-thumb-name": "Tampaknya ini adalah judul gambar mini. Mohon jangan mengunggah gambar mini kembali ke wiki yang sama. Bila tidak, silakan perbaiki nama berkas sehingga lebih bermakna, dan tidak memiliki awalan seperti judul gambar mini.",
        "filename-bad-prefix": "Nama berkas yang Anda muat diawali dengan '''\"$1\"''', yang merupakan nama non-deskriptif yang biasanya diberikan secara otomatis oleh kamera digital. Harap pilih nama lain yang lebih deskriptif untuk berkas Anda.",
        "filename-prefix-blacklist": " #<!-- biarkan baris ini seperti adanya --> <pre>\n# Contohnya sebagai berikut:\n#   * Semuanya dari karekter \"#\" sampai akhir baris ini adalah komentar\n#   * Setiap garis \"_\" adalah awalan untuk nama file khas yang diberikan secara otomatis oleh kamera digital\nCIMG # Casio\nDSC_ # Nikon\nDSCF # Fuji\nDSCN # Nikon\nDUW # beberapa model telpon seluler\nIMG # generik\nJD # Jenoptik\nMGP # Pentax\nPICT # lainnya.\n #</pre> <!-- biarkan baris ini seperti adanya -->",
        "upload-proto-error": "Protokol tak tepat",
        "upload-form-label-own-work": "Ini adalah karya saya sendiri",
        "upload-form-label-infoform-categories": "Kategori",
        "upload-form-label-infoform-date": "Tanggal",
+       "upload-form-label-own-work-message-shared": "Saya menegaskan bahwa saya memiliki hak cipta atas berkas ini, dan setuju untuk melepas berkas ini menurut lisensi [https://creativecommons.org/licenses/by-sa/4.0/ Creative Commons Atribusi-BerbagiSerupa 4.0], dan saya menyetujui [https://wikimediafoundation.org/wiki/Terms_of_Use ketentuan pemakaian dari Yayasan Wikimedia].",
+       "upload-form-label-not-own-work-message-shared": "Jika Anda tidak memiliki hak cipta atas berkas ini, atau Anda ingin melepasnya dengan lisensi berbeda, pertimbangkan untuk menggunakan [https://commons.wikimedia.org/wiki/Special:UploadWizard Wisaya penggunggah Commons].",
+       "upload-form-label-not-own-work-local-shared": "Anda juga mungkin ingin mencoba menggunakan  [[Special:Upload|laman pengunggahan pada{{SITENAME}}]],jika situs tersebut membolehkan pengunggahan berkas ini menurut kebijakannya.",
        "backend-fail-stream": "Tidak bisa mengalikan berkas $1.",
        "backend-fail-backup": "Tidak dapat mencadangkan berkas $1.",
        "backend-fail-notexists": "Berkas $1 tidak ada.",
        "uploadstash-summary": "Halaman ini memberikan akses terhadap berkas yang diunggah (atau dalam proses pengunggahan), namun belum diterbitkan ke wiki. Berkas-berkas ini tidak dapat dilihat oleh siapa pun kecuali pengunggahnya.",
        "uploadstash-clear": "Hapus berkas simpanan",
        "uploadstash-nofiles": "Anda tidak memiliki berkas simpanan.",
-       "uploadstash-badtoken": "Pelaksanaan tindakan tersebut gagal. Mungkin karena hak penyuntingan Anda telah kedaluwarsa. Coba lagi.",
+       "uploadstash-badtoken": "Pelaksanaan tindakan tersebut gagal. Mungkin karena hak penyuntingan Anda telah kedaluwarsa. Silakan coba lagi.",
        "uploadstash-errclear": "Penghapusan berkas gagal.",
        "uploadstash-refresh": "Segarkan daftar berkas.",
+       "uploadstash-thumbnail": "lihat miniatur",
        "invalid-chunk-offset": "Ofset potongan tidak valid",
        "img-auth-accessdenied": "Akses ditolak",
        "img-auth-nopathinfo": "PATH_INFO hilang.\nServer Anda tidak diatur untuk melewatkan informasi ini.\nServer tersebut mungkin berbasis CGI dan tidak dapat mendukung img_auth.\nLihat https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Image_Authorization.",
        "changecontentmodel-title-label": "Judul halaman",
        "changecontentmodel-model-label": "Model konten baru",
        "changecontentmodel-reason-label": "Alasan:",
+       "changecontentmodel-submit": "Ubah",
        "changecontentmodel-success-title": "Model konten ini telah diubah",
        "changecontentmodel-success-text": "Jenis konten [[:$1]] telah diubah",
        "changecontentmodel-cannot-convert": "Isi pada [[:$1]] tidak dapat ditukar kepada jenis $2.",
        "whatlinkshere-prev": "{{PLURAL:$1|sebelumnya $1}}",
        "whatlinkshere-next": "{{PLURAL:$1|selanjutnya $1}}",
        "whatlinkshere-links": "← pranala",
-       "whatlinkshere-hideredirs": "$1 pengalihan",
-       "whatlinkshere-hidetrans": "$1 transklusi",
-       "whatlinkshere-hidelinks": "$1 pranala",
-       "whatlinkshere-hideimages": "$1 pranala berkas",
+       "whatlinkshere-hideredirs": "Sembunyikan pengalihan",
+       "whatlinkshere-hidetrans": "Sembunyikan transklusi",
+       "whatlinkshere-hidelinks": "Sembunyikan pranala",
+       "whatlinkshere-hideimages": "Sembunyikan pranala berkas",
        "whatlinkshere-filters": "Penyaring",
        "whatlinkshere-submit": "Tuju ke",
        "autoblockid": "Blokir otomatis #$1",
        "ipb-unblock": "Hilangkan blokir seorang pengguna atau suatu alamat IP",
        "ipb-blocklist": "Lihat blokir yang diterapkan",
        "ipb-blocklist-contribs": "Kontribusi untuk {{GENDER:$1|$1}}",
+       "ipb-blocklist-duration-left": "Tersisa $1",
        "unblockip": "Buka blokir pengguna",
        "unblockiptext": "Gunakan formulir di bawah untuk mengembalikan kemampuan menulis sebuah alamat IP atau pengguna yang sebelumnya telah diblokir.",
        "ipusubmit": "Hilangkan blokir ini",
        "lockdbsuccesstext": "Basis data telah dikunci.<br />\nPastikan Anda [[Special:UnlockDB|membuka kuncinya]] setelah pemeliharaan selesai.",
        "unlockdbsuccesstext": "Kunci basis data telah dibuka.",
        "lockfilenotwritable": "Berkas kunci basis data tidak dapat ditulis. Untuk mengunci atau membuka basis data, berkas ini harus dapat ditulis oleh server web.",
+       "databaselocked": "Basis data telah terkunci.",
        "databasenotlocked": "Basis data tidak terkunci.",
        "lockedbyandtime": "(oleh $1 pada $2 $3)",
        "move-page": "Pindahkan $1",
        "tooltip-ca-nstab-category": "Lihat halaman kategori",
        "tooltip-minoredit": "Tandai ini sebagai suntingan kecil",
        "tooltip-save": "Simpan perubahan Anda",
+       "tooltip-publish": "Terbitkan perubahan Anda",
        "tooltip-preview": "Pratayang perubahan Anda, harap gunakan ini sebelum menyimpan!",
        "tooltip-diff": "Lihat perubahan yang telah Anda lakukan.",
        "tooltip-compareselectedversions": "Lihat perbedaan antara dua versi halaman yang dipilih.",
        "watchlistedit-raw-done": "Daftar pantauan Anda telah diperbarui.",
        "watchlistedit-raw-added": "{{PLURAL:$1|$1 judul telah}} ditambahkan:",
        "watchlistedit-raw-removed": "{{PLURAL:$1|$1 judul telah}} dikeluarkan:",
-       "watchlistedit-clear-title": "Daftar pantauan dihapus",
+       "watchlistedit-clear-title": "Hapus daftar pantauan",
        "watchlistedit-clear-legend": "Hapus daftar pantauan",
        "watchlistedit-clear-explain": "Semua judul akan dihapus dari daftar pantauan Anda",
        "watchlistedit-clear-titles": "Judul:",
        "tags-create-invalid-chars": "Nama tag tidak boleh mengandung koma (<code>,</code>) atau garis miring (<code>/</code>).",
        "tags-create-invalid-title-chars": "Nama tag tidak boleh mengandung karakter yang tidak bisa digunakan dalam judul halaman.",
        "tags-create-already-exists": "Tag \"$1\" sudah ada.",
+       "tags-create-warnings-below": "Apakah Anda ingin melanjutkan pembuatan tanda ini?",
        "tags-delete-reason": "Alasan:",
        "tags-activate-reason": "Alasan:",
        "tags-activate-submit": "Aktifkan",
        "feedback-bugornote": "Jika Anda sudah siap untuk mendeskripsikan masalah teknis secara rinci silakan [$1 melaporkan bug].\nJika tidak, Anda dapat menggunakan formulir mudah di bawah ini. Komentar Anda akan ditambahkan ke halaman \"[$3 $2]\", bersama dengan nama pengguna Anda dan apa browser yang Anda gunakan.",
        "feedback-cancel": "Batal",
        "feedback-close": "Selesai",
+       "feedback-dialog-title": "Kirimkan saran dan tanggapan",
+       "feedback-dialog-intro": "Anda bisa menggunakan formulir sederhana di bawah untuk mengirimkan saran dan masukan. Komentar Anda akan ditambahkan pada laman \"$1\" bersama nama pengguna Anda.",
        "feedback-error-title": "Kesalahan",
        "feedback-error1": "Galat: Hasil tidak dikenal dari API",
        "feedback-error2": "Galat: Penyuntingan gagal",
        "api-error-blacklisted": "Pilih judul lain yang deskriptif",
        "randomrootpage": "Halaman dasar sembarang",
        "log-action-filter-block": "Jenis pemblokiran:",
+       "log-action-filter-all": "Semua",
        "log-action-filter-block-block": "Blokir",
        "log-action-filter-suppress-block": "Perahasiaan pengguna menurut pemblokiran"
 }
index ac588bf..8ac3982 100644 (file)
        "tog-hidecategorization": "Къайлаяха оагӀонай категореш",
        "tog-extendwatchlist": "Хьашеръяь йола зем бара список, массадола хувцамаш ше чулоацаш, т|ехьара даь хувцамаш хинна ца Iеш.",
        "tog-usenewrc": "Керда хувцамашка а хьат|аяздара зембаккхарга а эргадаккхараш тоабаде (JavaScript эша)",
-       "tog-numberheadings": "Ð\9aеÑ\80Ñ\82аÑ\88каÑ\88Ñ\82а Ð°Ð»Ð°Ð½Ð·Ð° Ñ\82аÑ\8cÑ\80аÑ\85Ñ\8cа Ñ\85оÑ\82Ñ\82а",
-       "tog-showtoolbar": "Г|алатнийcдара г|ирсагартакх хьахьокха (JavaScript)",
-       "tog-editondblclick": "Шозза Ð´|аÑ\82о|амÑ\86a oаг|Ñ\83в Ñ\85Ñ\83вÑ\86а (JavaScript)",
-       "tog-editsectiononrightclick": "РалÑ\81декÑ\8aаÑ\80аÑ\88 Ñ\85Ñ\83вÑ\86а Ð´Ð°Ñ\85каÑ\86а Ð°Ñ\8cÑ\82Ñ\82а Ð´|аÑ\82о|амÑ\86а  ÐºÐµÑ\80Ñ\82аÑ\88ка Ñ\82|а (JavaScript)",
-       "tog-watchcreations": "Tеркама хьат|аяздар т|а аз яь оаг|онаши чуяьккха паьлаши т|атоха",
-       "tog-watchdefault": "Tеркама хьат|аяздар т|а аз хийца оаг|онаши паьлаша кустяздараши т|атоха",
-       "tog-watchmoves": "Tеркама хьат|аяздар т|а аз ц|ихийца оагӀонаши паьлаши т|атоха",
-       "tog-watchdeletion": "Tеркама хьат|аяздар т|а аз д|аяьккха оагӀонаши паьлаши т|атоха",
-       "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-numberheadings": "Ð\90вÑ\82омаÑ\82иÑ\87еÑ\81ки Ð·Ð°Ð³Ð¾Ð»Ð¾Ð²ÐºÐ°Ñ\88Ñ\82а Ð½Ñ\83меÑ\80аÑ\86и Ñ\85Ñ\8cае",
+       "tog-showtoolbar": "ГIирсай панель хьахьокха хувцам беч хана",
+       "tog-editondblclick": "Ð\9dиÑ\81Ñ\8aе Ð¾Ð°Ð³Ó\80онаÑ\88 Ñ\88озза IоÑ\82Ó\80аÑ\82оÓ\80аеÑ\87а (JavaScript)",
+       "tog-editsectiononrightclick": "Ð\9dийÑ\81де Ð´Ð°ÐºÑ\8aа Ñ\88озза Ð´Ð°Ñ\85ка Ð°Ñ\8cÑ\82Ñ\82а Ñ\82оIаеÑ\80 Ñ\82Ó\80аÑ\82оÓ\80айиÑ\87а Ð·Ð°Ð³Ð¾Ð»Ð¾Ð²ÐºÐ° Ñ\82Iа (JavaScript)",
+       "tog-watchcreations": "Зем бара списка т|атоха аз хьаяь оаг|онаши чуяьккха файлаши",
+       "tog-watchdefault": "Зем бара списка т|атоха аз хийца оаг|онаши файлай йоазонца сурташ оттадари",
+       "tog-watchmoves": "Зем бара списка т|атоха аз цIи хийца оаг|онаши файлаши",
+       "tog-watchdeletion": "Зем бара списка т|атоха аз дIаяьккха оаг|онаши файлаши",
+       "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": "Геттара з|амига хувцамаш хилча а, д-хоамнец хьахоам бе",
@@ -69,9 +69,9 @@
        "sun": "КIиранди",
        "mon": "Ор",
        "tue": "Шин",
-       "wed": "Кха",
+       "wed": "Кх",
        "thu": "Ер",
-       "fri": "П|аь",
+       "fri": "ПI",
        "sat": "Шоа",
        "january": "АгIой бутт",
        "february": "Саь-кур бутт",
@@ -87,7 +87,7 @@
        "december": "Чан-тар бутт",
        "january-gen": "АгIой бетт",
        "february-gen": "Саь-кур бетт",
-       "march-gen": "Муттхьол бетт",
+       "march-gen": "Мутт-хьал бетт",
        "april-gen": "Тушоли бетт",
        "may-gen": "Села бетт",
        "june-gen": "Этинга бетт",
@@ -96,7 +96,7 @@
        "september-gen": "Тов\\Михий бетт",
        "october-gen": "Ардарий\\АьрхIий бетт",
        "november-gen": "Лай чилла бетт",
-       "december-gen": "Чантар бетт",
+       "december-gen": "Чан-тар бетт",
        "jan": "АгIой",
        "feb": "Саь-кур",
        "mar": "Мутт-хьал",
        "period-pm": "ДТ",
        "pagecategories": "{{PLURAL:$1|1=Категори|Категореш}}",
        "category_header": "«$1» категори чура оагIонаш",
-       "subcategories": "ЧÑ\83Ñ\80акаÑ\82агаш",
+       "subcategories": "Ð\9aIалкаÑ\82егоÑ\80еш",
        "category-media-header": "\"$1\" Категори чура файлаш",
        "category-empty": "''Ер категори хӀанза яьсса я (цхьаккха оагIонаш е файлаш йоацаш).''",
        "hidden-categories": "{{PLURAL:$1|1=Къайла категори|Къайла категореш}}",
        "categorypage": "Катага оаг|oн т|а б|аргтасса",
        "viewtalkpage": "Дувцамага б|аргтасса",
        "otherlanguages": "Кхыча меттаех",
-       "redirectedfrom": "($1 Ñ\82IaÑ\80а Ñ\85Ñ\8cаÑ\85Ñ\8cожадаÑ\8c Ð´Ð°)",
+       "redirectedfrom": "($1 Ñ\82IaÑ\80а Ñ\83кÑ\85аз Ñ\85Ñ\8cаÑ\85Ñ\8cожаÑ\8fÑ\8c Ñ\8f)",
        "redirectpagesub": "Д|а-хьа дайта оаг|ув",
-       "redirectto": "ТIахьожадар укхаза:",
+       "redirectto": "Ð\94Iа-Ñ\81ахьожадар укхаза:",
        "lastmodifiedat": "Укх оагIoн тIеххьара хувцам: $2, $1.",
        "viewcount": "Укх оаг|oн т|а б|аргтассаб {{PLURAL:$1|цхьааца\n|$1 times}}. {{PLURAL:$1|1=цхьазза|$1за}}.",
        "protectedpage": "Лорама оаг|ув",
        "page-atom-feed": "«$1» — Atom-мугI",
        "red-link-title": "$1 (укх тайпара оагӀув яц)",
        "nstab-main": "Йоазув",
-       "nstab-user": "Ð\94акÑ\8aалаÑ\8cÑ\86аÑ\80хо",
+       "nstab-user": "Ð\94оакÑ\8aаÑ\88хо",
        "nstab-media": "Медифаг",
        "nstab-special": "Балха оагӀув",
        "nstab-project": "Проектах лаьца",
        "virus-unknownscanner": "довзашдоаца мазаундохьалург:",
        "welcomeuser": "Маьрша доаг|алд, $1!",
        "yourname": "Дакъалаьцархочунна цӀи:",
-       "userlogin-yourname": "Доакъашхочунна цӀи",
-       "userlogin-yourname-ph": "Чуйоалае доакъашхочун цӀи",
+       "userlogin-yourname": "Доакъашхочун цӀи",
+       "userlogin-yourname-ph": "Iочуязъе хьай учёта яздара (доакъашхочун) цӀи",
        "createacct-another-username-ph": "Чуйоалае доакъашхочун цӀи",
        "yourpassword": "КъайладIоагӀа:",
        "userlogin-yourpassword": "Пароль",
        "notloggedin": "Оаш шоай цӀи хьааьннадац",
        "nologin": "Леламе дIаяздар дац? '''$1'''.",
        "nologinlink": "Леламе дIаяздар кхолла",
-       "createaccount": "Учёта яздар кхолла",
+       "createaccount": "Учёта яздар хьакхолла",
        "gotaccount": "Укхаза дӀаязабенна дий шо? '''$1'''.",
        "gotaccountlink": "Чувала/яла",
        "userlogin-resetlink": "Чувала/яла цӀии дIоагӀаи дийцаденнадий?",
        "resetpass-submit-cancel": "Юхавал/ялa",
        "passwordreset-username": "Дакъалаьцархочунна цӀи:",
        "passwordreset-email": "Д-хоамни моттиг:",
-       "bold_sample": "Сома Ñ\8fздам",
-       "bold_tip": "Сома Ñ\8fздам",
-       "italic_sample": "Ð\9aÑ\83лга Ñ\8fздам",
-       "italic_tip": "Ð\9aÑ\83лга Ñ\8fздам",
-       "link_sample": "Ӏинка кортале",
-       "link_tip": "Чура хьожаярг",
-       "extlink_sample": "Ӏинка кортале http://www.example.com",
-       "extlink_tip": "Ð\90Ñ\80ен Ó\80инка (http:// Ñ\82амагÓ\80аÑ\85 Ð´Ð¸Ð¹Ñ\86а Ð¼Ð° Ð»Ðµ)",
-       "headline_sample": "Ð\9aоÑ\80Ñ\82ален Ñ\8fздам",
-       "headline_tip": "2-гӀа лагӀарлен кортале",
-       "nowiki_sample": "Укхаза кийчаде дезаш доаца яздам оттаде",
-       "nowiki_tip": "Ð\9cаÑ\81Ñ\81а-бÑ\83Ñ\81Ñ\82амлоÑ\80г Ñ\82еÑ\80камза Ð´Ð¸Ñ\82а",
-       "image_tip": "ЧÑ\83Ñ\8fÑ\8cккÑ\85а Ð¿Ð°Ñ\8cла",
-       "media_tip": "Файлан тIахьожавар",
-       "sig_tip": "ШÑ\83н кулгаяздар а, хӀанзара ха а",
-       "hr_tip": "Ð\9cÑ\83Ñ\85ала Ð¼Ñ\83гÓ\80 (могаÑ\88 Ñ\82айпаÑ\80а Ðº|еззига Ñ\85айÑ\80аде)",
-       "summary": "Ð¥Ñ\83вÑ\86амий Ð±ÐµÐ»Ð³Ð°Ð»Ð´Ðµр",
+       "bold_sample": "Ð\90Ñ\85\81ома Ñ\82екÑ\81Ñ\82",
+       "bold_tip": "Ð\90Ñ\85\81ома Ñ\82екÑ\81Ñ\82",
+       "italic_sample": "СиÑ\85а Ð¹Ð¾Ð°Ð·Ð¾Ð½ Ñ\82екÑ\81Ñ\82",
+       "italic_tip": "СиÑ\85а Ð¹Ð¾Ð°Ð·Ð¾Ð½ Ñ\82екÑ\81Ñ\82",
+       "link_sample": "ТIахьожаярга заголовок",
+       "link_tip": "ЧÑ\83Ñ\80а Ñ\82IаÑ\85Ñ\8cожаÑ\8fÑ\80г",
+       "extlink_sample": "http://www.example.com тIахьожаярга заголовок",
+       "extlink_tip": "Ð\90Ñ\80аÑ\85Ñ\8cаÑ\80а Ñ\82IаÑ\85Ñ\8cожаÑ\8fÑ\80г (йиÑ\86 Ð¼Ð° Ñ\8fлийÑ\82Ñ\82а Ð¿Ñ\80еÑ\84икÑ\81 http://)",
+       "headline_sample": "Ð\97аголовка Ñ\82екÑ\81Ñ\82",
+       "headline_tip": "2-гӀа лагӀа заголовок",
+       "nowiki_sample": "Укхаза хувца езаш йоаца текст хьачуоттае",
+       "nowiki_tip": "ТеÑ\80кал Ð¼Ð° Ðµ Ð²Ð¸ÐºÐ¸-Ñ\84оÑ\80маÑ\82иÑ\80овани",
+       "image_tip": "Ð\94IаÑ\87Ñ\83оÑ\82Ñ\82аÑ\8fÑ\8c Ñ\84айл",
+       "media_tip": "Файла тIахьожавар",
+       "sig_tip": "Ð¥Ñ\8cа кулгаяздар а, хӀанзара ха а",
+       "hr_tip": "Ð\9fÑ\85Ñ\8cоÑ\80агIен Ñ\82ака (Ñ\86оÑ\85 Ð¿Ð°Ð¹Ð´Ð° Ñ\8dÑ\86аÑ\80 Ñ\82IеÑ\85даÑ\8cнна ÐºÐ°Ñ\81Ñ\82Ñ\82а Ð¼Ð° де)",
+       "summary": "Ð¥Ñ\83вÑ\86амай Ñ\81Ñ\83Ñ\80Ñ\82 Ð¾Ñ\82Ñ\82адар",
        "subject": "БӀагал/кортале:",
        "minoredit": "ЗӀамига хувцам",
        "watchthis": "Зем бе укх оагӀон",
        "savearticle": "ОагӀув дIаязъе",
        "preview": "Хьалхе бӀаргтассар",
-       "showpreview": "Хьалххе хьажар",
+       "showpreview": "Хьалххе бIаргтохар",
        "showdiff": "Даь хувцамаш",
        "anoneditwarning": "<strong>Теркам бе!</strong> Хьо автор хинна система чуваьннавац. Нагахьа санна Iа моллагIа хувцам бой, Хьа IP-адрес дийла массанен бIаргагуш хургда. Нагахьа санна Хьо <strong>[$1 хьачувоале]</strong> е <strong>[$2 учёта яздар хьакхолле]</strong>, нийсдараш (хувцамаш) бувзам болаш хургда Хьа доакъашхой цIерца, иштта кхыдола толажагIи гIойленагIи дола дикаьш хургда Хьона.",
        "summary-preview": "Лоацам ба:",
        "newarticle": "(Kерда)",
        "newarticletext": "Шо хьожаяргаца дехьадаьннад йоаца оагӀон тӀа.\nИз кхолларгьйолаш кӀалхагӀа доала корачу текст Iочуязаде (нагахьа кхетаде хала дале [$1 новкъосталан оагӀонга] хьажа).\nЦа ховш укхаза нийсденнадале, шоай браузера '''Юха''' (назад) кнопка тӀа пӀелга тоӀабе.",
        "noarticletext": "ХIанз укх оагӀув тӀа текст яц.\nШун аьттув ба [[Special:Search/{{PAGENAME}}|цу тайпара цӀи хьоаяр кораде]] кхыйола йоазуваш чу, иштта\n<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} тара дола тептарий яздаьраш], е\n'''[{{fullurl:{{FULLPAGENAME}}|action=edit}} изза мо цӀи йолаш оагӀув кхолла]'''</span>.",
-       "noarticletext-nopermission": "ХIанз укх оагӀув тӀа яздам дац.\nШун йиш я, кхыдола йоазувнашках [[Special:Search/{{PAGENAME}}|дола цӀерий хаттам корае]] е <span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} нийсамий тептара йоазувнаш корае].</span>",
+       "noarticletext-nopermission": "ХIанз укх оагӀон тӀа текст яц.\nШун аьттув ба [[Special:Search/{{PAGENAME}}|цу тайпара цӀи белгалъяр хьалаха]] кхыйола оагIонаш тIа, иштта\n<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} цун тара дола тептарай яздаьраш].</span> Иштта йола (Ер) оагӀув хьакхолла Хьа бокъо яц.",
        "note": "'''ХӀамоалар:'''",
        "previewnote": "'''Хьалхе б|аргтассам мара бац.'''\nЯздам кхы яздаь дац!",
        "editing": "Хувцам: $1",
        "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",
        "shown-title": "Хьóкха $1 {{PLURAL:$1|даь йоазо|даь йоазонаш}} укх оáгIувна тIа",
        "viewprevnext": "ДIахьажа ($1 {{int:pipe-separator}} $2) ($3)",
        "searchmenu-exists": "'''Укх масса-хьахьоадайтамач ер оаг|ув \"[[:$1]]\" я'''",
-       "searchmenu-new": "<strong>Ð\9aÑ\85олла Ð¾Ð°Ð³IÑ\83в Â«[[:$1]]» Ñ\83кÑ\85 Ð²Ð¸ÐºÐ¸-пÑ\80оекÑ\82е!</strong>\n{{PLURAL:$2|0=|Ð\98Ñ\88Ñ\82Ñ\82а Ñ\85Ñ\8cажа Ñ\85Ñ\8cай Ð»Ð¸Ð¹Ñ\85а Ð¾Ð°Ð³IÑ\83внага.|Иштта хьажа хьай лахара хьахиннарашка.}}",
+       "searchmenu-new": "<strong>Ð¥Ñ\8cакÑ\85олла Ð¾Ð°Ð³IÑ\83в Â«[[:$1]]» Ñ\83кÑ\85 Ð²Ð¸ÐºÐ¸-пÑ\80оекÑ\82е!</strong>\n{{PLURAL:$2|0=|Ð\98Ñ\88Ñ\82Ñ\82а Ñ\85Ñ\8cажа IайÑ\85а Ð»Ð¸Ð¹Ñ\85а Ð¾Ð°Ð³Iонга.|Иштта хьажа хьай лахара хьахиннарашка.}}",
        "searchprofile-articles": "Кертера оагIонаш",
        "searchprofile-images": "Мультимедиа",
        "searchprofile-everything": "Массанахьа",
        "search-result-size": "$1 ({{PLURAL:$2|$2 дош|$2 дешаш}})",
        "search-result-category-size": "{{PLURAL:$1|1=$1 дакъа|$1 дакъаш}} ({{PLURAL:$2|1=$2 кIалцатег|$2 кIалцатегаш}}, {{PLURAL:$3|1=$3 паьла|$3 паьлий}})",
        "search-redirect": "(дIа-сахьожадар $1 тIара)",
-       "search-section": " (дакъа $1)",
+       "search-section": "(дакъа «$1»)",
        "search-suggest": "Хьона эшар ер хила мега: $1",
        "search-interwiki-caption": "Гаргалон хьахьоадайтамаш",
        "search-interwiki-default": "$1 хьахиннараш:",
        "powersearch-toggleall": "Деррига",
        "powersearch-togglenone": "Цхьаккха",
        "preferences": "Оттамаш",
-       "mypreferences": "Ð\9eÑ\82Ñ\82амаш",
+       "mypreferences": "Ð\93IиÑ\80Ñ\81аш",
        "prefs-skin": "БIагала куц",
        "skin-preview": "Хьажа",
        "prefs-personal": "Хьа хьай далам",
        "enhancedrc-history": "истори",
        "recentchanges": "Керда хувцамаш",
        "recentchanges-legend": "Керда хувцамай оттамаш",
-       "recentchanges-summary": "КIалхагIа лоарамий доаламе тIехьара оагIувний хувцамаш дIаязадаь да {{grammar:genitive|{{SITENAME}}}}.",
+       "recentchanges-summary": "КIалхагIа ханашца нийсдаь дIаяьздаь да {{grammar:genitive|{{SITENAME}}}}  оагIонай тIеххьара хувцамаш.",
        "recentchanges-feed-description": "Укх ларамца тIехьара массахувцамашт теркам бе.",
        "recentchanges-label-newpage": "Укх хувцамаца керда оагIув кхелла хиннай",
        "recentchanges-label-minor": "Ер зIамига хувцам ба",
        "rcnotefrom": "КIалхагIа хувцамаш хьахьекха я <strong>$2</strong> денза (<strong>$1</strong> кхачалца).",
        "rclistfrom": "$3 $2 денза даь хувцамаш хьахьокха",
        "rcshowhideminor": "$1 зIамига нийсдараш",
-       "rcshowhideminor-hide": "Ð\9aÑ\8aайлдаккха",
+       "rcshowhideminor-hide": "Ð\94IакÑ\8aайладаккха",
        "rcshowhidebots": "$1 боташ",
        "rcshowhidebots-show": "Хьахьокха",
        "rcshowhideliu": "$1 бовзийтарчара доакъашхой",
        "rcshowhideanons-hide": "Къайлабаха",
        "rcshowhidepatr": "$1 теркам даь хувцамаш",
        "rcshowhidemine": "$1 хьа нийсдараш",
-       "rcshowhidemine-hide": "Ð\9aÑ\8aайлдаккха",
+       "rcshowhidemine-hide": "Ð\94IакÑ\8aайладаккха",
        "rclinks": "Хьахьокха $2 дийнахь даь хинна тIеххьара $1 хувцамаш\n<br />$3",
        "diff": "башхало",
        "hist": "истори",
-       "hide": "Къайлдаккха",
+       "hide": "Ð\9aÑ\8aайладаккÑ\85а",
        "show": "Хьахьокха",
        "minoreditletter": "зI",
        "newpageletter": "К",
        "rc-change-size-new": "Хувцам баьнначул тӀехьагIа бола боарам: $1 {{PLURAL:$1|байт}}",
        "rc-enhanced-expand": "Ма дарра чулоацамаш хьахьокха (JavaScriptаца)",
        "rc-enhanced-hide": "Ма дарра чулоацамаш къайладаккха",
-       "recentchangeslinked": "Ð\93аÑ\80галон Ñ\85Ñ\83вÑ\86амаш",
+       "recentchangeslinked": "Ð\92IаÑ\88агIдÑ\83взаденна Ð½Ð¸Ð¹Ñ\81даÑ\80аш",
        "recentchangeslinked-feed": "Гаргалон хувцамаш",
        "recentchangeslinked-toolbox": "Укханца вIашагIдувзаденна хувцамаш",
        "recentchangeslinked-title": "$1ца вIашидувзаденна хувцамаш",
        "recentchangeslinked-summary": "Ер, Iинк яь йола оагIув (е укх цатегачу чуйоагIараш), дукха ха йоацаш хьийца оагIувнашкий дагарле я.\n[[Special:Watchlist|Шун теркама дагарленашках]] чуйоагIа оагIувнаш '''белгалаяь я'''.",
-       "recentchangeslinked-page": "ОагIува цIи",
-       "recentchangeslinked-to": "Ð\9eагIÑ\83внаÑ\88 Ñ\82Iа Ñ\85Ñ\83вÑ\86амаÑ\88 Ñ\85Ñ\8cаÑ\85Ñ\8cокÑ\85а, Ñ\85Ñ\8cаÑ\85Ñ\8cекÑ\85а Ð¹Ð¾Ð»Ð° Ð¾Ð°Ð³IÑ\83в Ñ\82Iа IинкаÑ\88 ÐµÑ\88 Ð¹Ð¾Ð»а.",
+       "recentchangeslinked-page": "ОагIон цIи",
+       "recentchangeslinked-to": "Ð\92еÑ\88Ñ\82а, Ð±ÐµÐ»Ð³Ð°Ð»Ñ\8fÑ\8cккÑ\85а Ð¾Ð°Ð³Iон Ñ\82IаÑ\85Ñ\8cожавеÑ\88 Ð´Ð¾Ð»Ð° Ð¾Ð°Ð³IонаÑ\88Ñ\82а Ð´Ð°Ñ\8c Ñ\85Ñ\83вÑ\86амаÑ\88 Ñ\85Ñ\8cаÑ\85Ñ\8cокÑ\85а.",
        "upload": "Файл чуяккха",
        "uploadbtn": "Паьл чуяьккха",
        "uploadlogpage": "Чуяьккхамий тептар",
-       "filedesc": "Ð\9bоаÑ\86а Ð»Ð¾Ð°Ñ\86ам",
+       "filedesc": "Ð\9bоаÑ\86а Ð¹Ð¾Ð°Ð·Ð¾Ð½Ñ\86а Ñ\81Ñ\83Ñ\80Ñ\82 Ð¾Ñ\82Ñ\82адаÑ\80",
        "fileuploadsummary": "Лоаца лоацам:",
        "license": "ЦIийяздар",
-       "license-header": "ЦIийÑ\8fздаÑ\80",
+       "license-header": "Ð\9bиÑ\86ензиÑ\80ование",
        "imgfile": "файл",
        "listfiles": "Паьлий дагарче",
        "listfiles_date": "Денха",
        "filehist-comment": "Белгалдаккхар",
        "imagelinks": "Файлах пайда эцар",
        "linkstoimage": "{{PLURAL:$1|1=ТIехьайоагIача $1 оагIуво тIахьожаву|ТIехьайоагIача $1 оагIувнаша тIахьожаву}} укх файла тIа:",
-       "nolinkstoimage": "Ð\99ола Ð¿Ð°Ñ\8cла Ñ\82Iа  Iинк Ñ\8e Ð¾Ð°Ð³IÑ\83внаÑ\88 Ð´Ð°Ñ\86",
+       "nolinkstoimage": "УкÑ\85 Ñ\84айла Ñ\82IаÑ\85Ñ\8cожавеÑ\88 Ð¹Ð¾Ð»Ð° Ð¾Ð°Ð³IонаÑ\88 Ñ\8fÑ\86.",
        "sharedupload": "Ер паьла $1чера я, кхыча хьахьоадайтамча хьахайраде йийшайолаш я.",
        "sharedupload-desc-here": "Ер файл $1 чура я, иштта кхыйола проекташ чу пайда эца аьттув болаш я.\nЦун [$2 йоазонца сурт оттадара оагIон] информаци кIалхахь хьайоалаяй.",
        "uploadnewversion-linktext": "Укх паьлий керда бIаса чуяьккха",
        "listgrouprights-members": "(тоабий дагарче)",
        "emailuser": "Дакъалаьцархочоа д-хоамни:",
        "watchlist": "Теркама дагарче",
-       "mywatchlist": "ТеÑ\80кама Ð´Ð°Ð³Ð°Ñ\80ле",
+       "mywatchlist": "Ð\97ем Ð±Ð°Ñ\80а Ñ\81пиÑ\81ок",
        "watchlistfor2": "$1 $2 царна",
        "addedwatchtext": "\"[[:$1]]\" оагIув, шун [[Special:Watchlist|теркама дагаршкахь]] чуяккха я. \nТехьара мел йола укх оагIувни хувцамаш цу дагаршкахь хоам беш хургья. Вешта [[Special:RecentChanges|керда хувцама дагаршкаехь]] сома къоалмаца хьакъоастлуш хургья.",
        "removedwatchtext": "\"[[:$1]]\" оагIув, шун [[Special:Watchlist|теркама дарагчера]] дIаяккха хиннай.",
-       "watch": "Зе",
+       "watch": "Зем бе",
        "watchthispage": "Укх оагIува теркам бе",
        "unwatch": "Лора ма де",
        "watchlist-details": "Шун теркама дагарченгахь йола  $1 {{PLURAL:$1|1=оагIув|оагIувнаш}}, дувцама оагIувнаш ца лоархIаш.",
        "deleteotherreason": "Кхыдола бахьан/тIатохар:",
        "deletereasonotherlist": "Кхыдола бахьан",
        "rollbacklink": "юхаяккха",
-       "protectlogpage": "Ð\9bоÑ\80адаÑ\80а тептар",
+       "protectlogpage": "Ð\93Iон тептар",
        "protectedarticle": "\"[[$1]]\" оагIув лорам деж я",
        "modifiedarticleprotection": "\"[[$1]]\" оагIувни лорама лагIа хувцаяьннай",
        "protectcomment": "Бахьан:",
        "sp-contributions-toponly": "ТIехьара доржамаш лоархаш дола хувцамаш мара ма хьокха",
        "sp-contributions-submit": "Хьалáха",
        "whatlinkshere": "Хьожаяргаш укхаза",
-       "whatlinkshere-title": "\"$1\" тIа Iинкаш еш йола оагIувнаш",
+       "whatlinkshere-title": "\"$1\" тIахьожавеш йола оагIонаш",
        "whatlinkshere-page": "ОагIув",
-       "linkshere": "ТIехьайоагIа оагIувнаш тIахьожаву «'''[[:$1]]'''»:",
+       "linkshere": "ТIехьайоагIа оагIонаш тIахьожаву «'''[[:$1]]'''»:",
        "nolinkshere": "'''[[:$1]]''' оагIув тIа, кхыдола оагIувашкара Iинкаш йоацаш я",
-       "isredirect": "ТIаÑ\85Ñ\8cожадаÑ\80ан Ð¾Ð°Ð³IÑ\83в",
+       "isredirect": "оагIÑ\83в-дIа-Ñ\81аÑ\85Ñ\8cожадаÑ\80",
        "istemplate": "юкъейоалаяр",
-       "isimage": "Файлан хьожаярг",
+       "isimage": "Файлови Ñ\82Iахьожаярг",
        "whatlinkshere-prev": "{{PLURAL:$1|1=хьалхайоагIа|хьалхайоагIараш}} $1",
        "whatlinkshere-next": "{{PLURAL:$1|1=тIехьайоагIар|тIехьайоагIараш}} $1",
-       "whatlinkshere-links": "← хьожаяргаш",
-       "whatlinkshere-hideredirs": "$1 дIа-хьа чуяьккхамаш",
-       "whatlinkshere-hidetrans": "$1 чуяьккхамаш",
-       "whatlinkshere-hidelinks": "$1 Iинкаш",
+       "whatlinkshere-links": "â\86\90 Ñ\82IаÑ\85Ñ\8cожаÑ\8fÑ\80гаÑ\88",
+       "whatlinkshere-hideredirs": "ДIакъайладаккха дIа-сахьожадар",
+       "whatlinkshere-hidetrans": "ДIакъайладаккха юкъедахараш",
+       "whatlinkshere-hidelinks": "ДIакъайлаяккха тIахьожаяргаш",
        "whatlinkshere-hideimages": "$1 суртIинкаш",
-       "whatlinkshere-filters": "ЦIенÑ\8aеÑ\80аÑ\88",
+       "whatlinkshere-filters": "ФилÑ\8cÑ\82Ñ\80Ñ\8b",
        "blockip": "Укх {{GENDER:$1|доакъошхочоа}} ч|ега бола",
        "ipboptions": "2 сахьат:2 hours,1 ди:1 day,3 ди:3 days,1 кIира:1 week,2 кIира:2 weeks,1 бутт:1 month,3 бутт:3 months,6 бутт:6 months,1 шу:1 year,сиха ца луш:infinite",
        "ipblocklist": "ЧIега бела дакъалаьцархой",
        "movelogpage": "ЦIи хувцара тептар",
        "movereason": "Бахьан",
        "revertmove": "юхаяьккха",
-       "export": "ОагIувий эхфортам",
+       "export": "ОагIонай экспорт",
        "allmessagesname": "ЦIи",
        "allmessagesdefault": "Сатийна улла яздам",
        "allmessages-filter-all": "Дерригаш",
        "thumbnail-more": "Доккха де",
        "thumbnail_error": "ЗIамигасуртанчий кхеллама гIалат: $1",
        "import-upload-filename": "ПаьлацIи:",
-       "tooltip-pt-userpage": "{{GENDER:|Хьа}} доакъашхочунна оагIув",
+       "tooltip-pt-userpage": "{{GENDER:|Хьа}} доакъашхочун оагIув",
        "tooltip-pt-mytalk": "{{GENDER:|Хьа}} дувца оттадара оагIув",
-       "tooltip-pt-preferences": "{{GENDER:|Ð¥Ñ\8cа Ð¾Ñ\82Ñ\82амаш}}",
-       "tooltip-pt-watchlist": "ОоагIувна дагарле, шо бIаргалокхаш йола",
+       "tooltip-pt-preferences": "{{GENDER:|Ð¥Ñ\8cа Ð³IиÑ\80Ñ\81аш}}",
+       "tooltip-pt-watchlist": "Iа зем бу оагIонаш",
        "tooltip-pt-mycontris": "{{GENDER:|хьа}} хувцамаш",
        "tooltip-pt-login": "Укхаза хьай цIи аьле чувала/яла йиша я, амма из параз дац",
        "tooltip-pt-logout": "Аравала/яла",
        "tooltip-ca-protect": "Eр оагIув лорае",
        "tooltip-ca-delete": "Ер оагIув дIаяькха",
        "tooltip-ca-move": "Укх оагIон цIи хувца",
-       "tooltip-ca-watch": "Ер оагIув хьай теркам беча каьхата тIа тIаяьккха",
+       "tooltip-ca-watch": "Ер оагIув Iайха зувш йолча оагIонашта юкъеяккха",
        "tooltip-ca-unwatch": "Ер оагIув теркам беча каьхата тIара дIаяькха",
        "tooltip-search": "Хьалáха {{grammar:prepositional|{{SITENAME}}}} чу",
        "tooltip-search-go": "Изза мо цӀи йолаш оагӀув тӀa дехьавала",
        "tooltip-t-recentchangeslinked": "Укх оагIуво тIахьожавеш йолча оагIонай тIеххьара хувцамаш",
        "tooltip-feed-rss": "Укх оагIувна RSSчу гойтар",
        "tooltip-feed-atom": "Укх оаг|увна Atomчу гойтар",
-       "tooltip-t-contributions": "{{GENDER:$1|Укх доакъашхочо хийца}} йола оагIувнаш",
+       "tooltip-t-contributions": "{{GENDER:$1|Укх доакъашхочо хийца}} йола оагIонаш",
        "tooltip-t-emailuser": "Укх дакъалаьцархочоа зIы яхьийта",
        "tooltip-t-upload": "Файлаш чуяккха",
        "tooltip-t-specialpages": "ГIулакха оагIувнаш",
        "tooltip-t-print": "Укх оагIон зарба тохара верси",
        "tooltip-t-permalink": "ОагIон укх версин тIахьожавеш йола даим латташ йола хьожаярг",
        "tooltip-ca-nstab-main": "ОагIон чурадар",
-       "tooltip-ca-nstab-user": "Ð\94акÑ\8aалаÑ\8cÑ\86аÑ\80Ñ\85оÑ\87Ñ\83нна Ñ\88ий оагIув",
+       "tooltip-ca-nstab-user": "Ð\94оакÑ\8aаÑ\88Ñ\85оÑ\87Ñ\83н Ñ\88е Ð´Ð¾Ð°Ð»Ð°Ñ\85Ñ\8c Ð¹Ð¾Ð»Ð° оагIув",
        "tooltip-ca-nstab-special": "Ер гIулакха оагIув я, из хувца бокъо яц",
        "tooltip-ca-nstab-project": "Проектан оагIув",
        "tooltip-ca-nstab-image": "Файлан оагӀув",
-       "tooltip-ca-nstab-template": "Ð\9bон оагIув",
+       "tooltip-ca-nstab-template": "Ð\9bеÑ\80а оагIув",
        "tooltip-ca-nstab-help": "ГӀон оагIув",
        "tooltip-ca-nstab-category": "Категорий оагӀув",
        "tooltip-minoredit": "Ер хувцар башха доаца санна белгалде",
-       "tooltip-save": "Ð¥Ñ\8cай Ñ\85Ñ\83вÑ\86амаÑ\88 Ð»Ð¾Ñ\80адеÑ\88 Ð´IаÑ\8fзаде",
-       "tooltip-preview": "Дехар да, оагӀув лораешь дIаязаелехь из мишта я тахка хьалххе хьажарах пайда эцаш!",
+       "tooltip-save": "Хьай хувцамаш лорадеш дIаязде",
+       "tooltip-preview": "Дехар да, оагӀув лораешь дIаязъелехь из мишта я тахка хьалххе хьажарах пайда эцаш!",
        "tooltip-diff": "ДIадолалу текстаца даь хувцамаш хьахьокха",
        "tooltip-compareselectedversions": "Укх оагIувни шин доржамаш тIа юкъера хувцамаш зе.",
        "tooltip-watch": "Ер оагIув теркам беча каьхата тIа яькха",
        "tooltip-rollback": "Цкъа пIелг тоIабе дIадаккха тIехьара редакторас даь хувцамаш",
-       "tooltip-undo": "Даь хувцар дIадаьккха, хьалххе хьажар хьахьокха, дIадаккхара бахьан Iочуязаде аьттув болаш.",
-       "tooltip-summary": "Ð\9bоаÑ\86а Ð¹Ð¾Ð°Ð·Ð¾Ð½Ñ\86а Ñ\81Ñ\83Ñ\80Ñ\82 Ð¾Ñ\82Ñ\82адаÑ\80 IоÑ\87Ñ\83Ñ\8fзаде",
+       "tooltip-undo": "Даь хувцар дIадаьккха, хьалххе бIаргтохар хьахьокха, дIадаккхара бахьан Iочуязде аьттув болаш.",
+       "tooltip-summary": "Лоаца йоазонца сурт оттадар Iочуязде",
        "pageinfo-hidden-categories": "{{PLURAL:$1|1=Къайла категори|Къайла категореш}} ($1)",
        "pageinfo-toolboxlink": "ОагIонах бола хоам",
        "previousdiff": "← Хьалхара нийсдар",
        "metadata-fields": "Укх списке дагaрадаь суртай метахоамай йистош, хьахьекха хургда суртан оагIон тIа, метахоамай таблица хьоарчая йолаш. Юхедиса йистош къайла хургда.\n* make\n* model\n* datetimeoriginal\n* exposuretime\n* fnumber\n* isospeedratings\n* focallength\n* artist\n* copyright\n* imagedescription\n* gpslatitude\n* gpslongitude\n* gpsaltitude",
        "exif-imagewidth": "Шерал",
        "exif-imagelength": "Лакхал",
-       "exif-orientation": "Суртан белгало",
+       "exif-orientation": "Сурта белгало",
        "exif-imagedescription": "Сурта цIи",
        "exif-model": "Камера модель",
        "exif-software": "Программни Iалашдар",
        "exif-artist": "Яздархо",
        "exif-exifversion": "Верси Exif",
-       "exif-colorspace": "Ð\91аÑ\81аÑ\80а Ð°Ñ\80е",
+       "exif-colorspace": "Ð\91еÑ\81ай Ð¼Ð¾Ñ\82Ñ\82",
        "exif-pixelxdimension": "Сурта шорал",
        "exif-pixelydimension": "Сурта лакхал",
        "exif-datetimedigitized": "Оцифровк яь таьрахь а, ха а",
index 14da229..9f6beb9 100644 (file)
        "right-override-export-depth": "Esporta le pagine includendo le pagine collegate fino ad una profondità di 5",
        "right-sendemail": "Invia email ad altri utenti",
        "right-passwordreset": "Vede i messaggi di reimpostazione della password",
-       "right-managechangetags": "Crea ed elimina dal database i [[Special:Tags|tag]]",
+       "right-managechangetags": "Crea e attiva/disattiva le [[Special:Tags|etichette]]",
        "right-applychangetags": "Applica delle [[Special:Tags|etichette]] alle proprie modifiche",
        "right-changetags": "Aggiunge e rimuove specifiche [[Special:Tags|etichette]] su singole versioni o voci di registro",
+       "right-deletechangetags": "Cancella le [[Special:Tags|etichette]] dal database",
        "grant-generic": "Pacchetto diritti \"$1\"",
        "grant-group-page-interaction": "Interagisce con le pagine",
        "grant-group-file-interaction": "Interagisce con i file multimediali",
        "action-viewmyprivateinfo": "vedere i propri dati personali",
        "action-editmyprivateinfo": "modificare i propri dati personali",
        "action-editcontentmodel": "modificare il modello di contenuto di una pagina",
-       "action-managechangetags": "crea ed elimina i tag dal database",
+       "action-managechangetags": "creare e attivare/disattivare le etichette",
        "action-applychangetags": "applicare delle etichette alle tue modifiche",
        "action-changetags": "aggiungere o rimuovere specifiche etichette su singole versioni o voci di registro",
+       "action-deletechangetags": "cancellare le etichette dal database",
        "nchanges": "$1 {{PLURAL:$1|modifica|modifiche}}",
        "enhancedrc-since-last-visit": "$1 {{PLURAL:$1|dall'ultima visita}}",
        "enhancedrc-history": "cronologia",
        "tags-activate": "attiva",
        "tags-deactivate": "disattiva",
        "tags-hitcount": "$1 {{PLURAL:$1|modifica|modifiche}}",
-       "tags-manage-no-permission": "Non hai il permesso di gestire il cambiamento tag.",
+       "tags-manage-no-permission": "Non si dispone dei permessi necessari per gestire le etichette di modifica.",
        "tags-manage-blocked": "Non puoi gestire le etichette alle modifiche mentre sei bloccato.",
        "tags-create-heading": "Crea un nuovo tag",
        "tags-create-explanation": "Per impostazione predefinita, i tag appena creati saranno disponibili per l'utilizzo di utenti e bot.",
        "tags-delete-not-found": "Il tag \"$1\" non esiste.",
        "tags-delete-too-many-uses": "Il tag \"$1\" è applicato a più di $2 {{PLURAL:$2|revisione|revisioni}}, il che significa che non può essere eliminato.",
        "tags-delete-warnings-after-delete": "L'etichetta \"$1\" è stata cancellata, ma fai attenzione {{PLURAL:$2|al seguente avviso|ai seguenti avvisi}}:",
+       "tags-delete-no-permission": "Non si dispone dei permessi necessari per cancellare le etichette di modifica.",
        "tags-activate-title": "Attiva tag",
        "tags-activate-question": "Stai per attivare il tag \"$1\".",
        "tags-activate-reason": "Motivo:",
        "tags-apply-blocked": "Non puoi applicare le etichette alle modifiche mentre sei bloccato.",
        "tags-apply-not-allowed-one": "L'etichetta \"$1\" non può essere applicata manualmente.",
        "tags-apply-not-allowed-multi": "{{PLURAL:$2|La seguente etichetta non può essere applicata|Le seguenti etichette non possono essere applicate}}  manualmente: $1",
-       "tags-update-no-permission": "Non hai il permesso di aggiungere o rimuovere modifiche di tag dalle singole revisioni o voci di registro.",
+       "tags-update-no-permission": "Non si dispone dei permessi necessari per aggiungere o rimuovere le etichette di modifica dalle singole versioni o voci di registro.",
        "tags-update-blocked": "Non puoi aggiungere o rimuovere le etichette alle modifiche mentre sei bloccato.",
        "tags-update-add-not-allowed-one": "Il tag \"$1\" non può essere aggiunto manualmente.",
        "tags-update-add-not-allowed-multi": "{{PLURAL:$2|Il seguente tag non può essere aggiunto|I seguenti tag non possono essere aggiunti}} manualmente: $1",
        "api-error-nomodule": "Errore interno: non è stato impostato il modulo di caricamento.",
        "api-error-ok-but-empty": "Errore interno: nessuna risposta dal server.",
        "api-error-overwrite": "Sovrascrivere un file esistente non è consentito.",
+       "api-error-ratelimited": "Stai cercando di caricare più file in meno tempo di quanto questo wiki permette.\nRiprova tra pochi minuti.",
        "api-error-stashfailed": "Errore interno: il server non è riuscito a memorizzare il documento temporaneo.",
        "api-error-publishfailed": "Errore interno: il server non è riuscito a pubblicare il documento temporaneo.",
        "api-error-stasherror": "Si è verificato un errore durante il caricamento del file in stash.",
index 1cdd9dc..9c32573 100644 (file)
@@ -69,7 +69,8 @@
                        "Sujiniku",
                        "Azeha",
                        "Kana Higashikawa",
-                       "Shield-9"
+                       "Shield-9",
+                       "Waiesu"
                ]
        },
        "tog-underline": "リンクの下線:",
        "content-model-css": "CSS",
        "content-json-empty-object": "空のオブジェクト",
        "content-json-empty-array": "空の配列",
-       "duplicate-args-warning": "<strong>警告:</strong> [[:$1]]は「$3」パラメータの値が複数存在する[[:$2]]を呼び出しています。提供されている最後の値のみが使用されます。",
+       "duplicate-args-warning": "<strong>警告:</strong> [[:$1]]は複数の「$3」パラメータを伴って[[:$2]]を呼び出しています。提供されている最後の値のみが使用されます。",
        "duplicate-args-category": "テンプレート呼び出しで引数が重複しているページ",
        "duplicate-args-category-desc": "引数が重複したテンプレート呼び出しを含むページ。例: <code><nowiki>{{foo|bar=1|bar=2}}</nowiki></code>、<code><nowiki>{{foo|bar|1=baz}}</nowiki></code>",
        "expensive-parserfunction-warning": "<strong>警告:</strong> このページでは、高負荷なパーサー関数の呼び出し回数が多過ぎます。\n\n{{PLURAL:$2|呼び出しを $2 回}}未満にしてください ({{PLURAL:$1|現在は $1 回}})。",
        "whatlinkshere-prev": "前の$1件",
        "whatlinkshere-next": "次の$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",
        "tags-delete-not-found": "タグ「$1」は存在しません。",
        "tags-delete-too-many-uses": "タグ「$1」は少なくとも$2版に付与されており、削除できません。",
        "tags-delete-warnings-after-delete": "タグ「$1」の削除しましたが、以下の{{PLURAL:$2|警告}}が発生しました:",
+       "tags-delete-no-permission": "変更タグを削除する権限がありません。",
        "tags-activate-title": "タグの有効化",
        "tags-activate-question": "タグ「$1」を有効化しようとしています。",
        "tags-activate-reason": "理由:",
index a20bb2f..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",
        "exif-sublocationdest": "Dhaèrahé kutha katampilaké",
        "exif-objectname": "Judhul cendhèk",
        "exif-specialinstructions": "Prèntah kusus",
-       "exif-headline": "Warta utama",
+       "exif-headline": "Tajuk",
        "exif-credit": "Krédit/Panyadhiya",
        "exif-source": "Sumber",
        "exif-editstatus": "Status kapanyuntingan gambar",
        "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 8277a44..4114ce8 100644 (file)
        "actionthrottled": "Hejmara guherandinên hatine hesibandin",
        "actionthrottledtext": "Te ev tişt di demeke gelekî kin de kir. Ji kerema xwe çend xulekan bisekine û carekî din biceribîne.",
        "protectedpagetext": "Ev rûpel ji bo guhertin û karên din ne kirin hatiye parastin.",
-       "viewsourcetext": "Tu dikarî li çavkaniya vê rûpelê binêrî û wê kopî bikî:",
+       "viewsourcetext": "Tu dikarî li çavkaniya vê rûpelê binêrî û wê kopî bikî.",
        "viewyourtext": "Hûn çavkaniyê <strong>guhertinê xwe</strong> yê di vê rûpelê de dikarin bibînin û kopî bikin:",
        "protectedinterface": "Di vê rûpelê de nivîsandin ji bo navrû(interface)yî zimanan yê vê nivîsbariyê ye û ew tê parastin ku vandalîzm li vê derê çênebe.\nBo lêzêdekirin an jî guherandina wergerên bo hemû wîkiyan ji kerema xwe re mehelîkirina Mediawîkiyê [//translatewiki.net/ translatewiki.net]'ê bi kar bîne.",
        "editinginterface": "'''Hişyarî:''' Tu rûpelekê a ku di Wîkîpediya de ji bo sîstemê girîng e,  diguherînî. Guherandinên di vê rûpelê de wê ji aliyê hemû bikarhêneran ve werin dîtin. Ji bo wergerê ji kerema xwe di [//translatewiki.net/wiki/Main_Page?setlang=ku-latn translatewiki.net] de bixebite, projeya MediaWiki.",
        "newpassword": "Şîfreya nû",
        "retypenew": "Şîfreya nû careke din binîvîse",
        "resetpass_submit": "Şîfreyê pêkbîne û têkeve",
-       "changepassword-success": "Guhertine şîfreya te serkeftî bû!",
+       "changepassword-success": "Şîfreya te hate guhertandin!",
        "botpasswords-label-appid": "Navê bot:",
        "botpasswords-label-create": "Çêke",
        "botpasswords-label-update": "Rojane bike",
        "right-sendemail": "Ji bikarhênerên di re ename bişîne",
        "grant-editpage": "Rûpelên ku hene biguherîne",
        "grant-editprotected": "Rûpelên parastî bigûherîne",
+       "grant-basic": "Mafên bingehîn",
        "newuserlogpage": "Çêkirina hesabê nû",
        "newuserlogpagetext": "Ev têketina hesabên bikarhêneriyê ye ên ku nû hatine afirandin.",
        "rightslog": "Guhertina mafê bikarhêneriyê",
        "listusers-noresult": "Bikarhêner nehate dîtin.",
        "listusers-blocked": "(hate astengkirin)",
        "activeusers": "Lîsteya bikarhênerên çalak",
+       "activeusers-from": "Li bikarhênerên bi vê dest pê dikin bigere:",
        "activeusers-hidebots": "Bot'an veşêre",
        "activeusers-hidesysops": "Rêveberan veşêre",
        "activeusers-noresult": "Tu bikarhêner nehate dîtin.",
        "whatlinkshere-prev": "{{PLURAL:$1|yê|$1 yên}} berê",
        "whatlinkshere-next": "{{PLURAL:$1|yê|$1 yên}} din",
        "whatlinkshere-links": "← girêdan",
-       "whatlinkshere-hideredirs": "Beralîkirinan $1",
+       "whatlinkshere-hideredirs": "Beralîkirinan veşêre",
        "whatlinkshere-hidetrans": "Naverokan $1",
-       "whatlinkshere-hidelinks": "Girêdanan $1",
-       "whatlinkshere-hideimages": "Girêdanên wêneyan $1",
+       "whatlinkshere-hidelinks": "Girêdanan veşêre",
+       "whatlinkshere-hideimages": "Girêdanên wêneyan veşêre",
        "whatlinkshere-filters": "Parzûn",
        "block": "Bikarhêner asteng bike",
        "unblock": "Astengkirinê rake",
        "tooltip-feed-rss": "RSS feed'ên ji bo rûpelê",
        "tooltip-feed-atom": "Atom feed'ên ji bo vê rûpelê",
        "tooltip-t-contributions": "Lîsteyekî beşdariyên {{GENDER:$1|vê bikarhênerê}} bibîne",
-       "tooltip-t-emailuser": "Jê re name bişîne",
+       "tooltip-t-emailuser": "Jê {{GENDER:$1|vî bikarhênerî}}re peyamê bişîne",
        "tooltip-t-info": "Bêhtir agahî di derbarê vê rûpelê de",
        "tooltip-t-upload": "Dosyeyan bar bike",
        "tooltip-t-specialpages": "Lîsteya hemû rûpelên taybetî",
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 6c0b718..0235767 100644 (file)
        "site-atom-feed": "$1 Atom padeve",
        "page-rss-feed": "\"$1\" RSS barotne",
        "page-atom-feed": "\"$1\" Atom barotne",
-       "red-link-title": "$1 (lapa neeksistē)",
+       "red-link-title": "$1 (lapa nepastāv)",
        "sort-descending": "Kārtot dilstošā secībā",
        "sort-ascending": "Kārtot augošā secībā",
        "nstab-main": "Raksts",
        "special-characters-group-sinhala": "Singāļu",
        "special-characters-group-gujarati": "Gudžarati",
        "mw-widgets-dateinput-no-date": "Nav izvēlēts datums",
+       "mw-widgets-titleinput-description-new-page": "lapa vēl nepastāv",
        "api-error-blacklisted": "Lūdzu, izvēlieties citu, aprakstošu nosaukumu!"
 }
index 4093966..0f2260b 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 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 43656ec..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 usuwanie [[Special:Tags|znaczników]] z bazy danych",
+       "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",
        "action-viewmyprivateinfo": "zobaczenia swoich prywatnych danych",
        "action-editmyprivateinfo": "edycji swoich prywatnych danych",
        "action-editcontentmodel": "edycji modelu zawartości strony",
-       "action-managechangetags": "utwórz lub usuń znaczniki z bazy danych",
+       "action-managechangetags": "tworzenia i de(aktywowania) znaczników",
        "action-applychangetags": "wprowadzania znaczników wraz z własnymi zmianami",
        "action-changetags": "dodawania i usuwania dowolnych znaczników z poszczególnych wersji i wpisów w rejestrze",
+       "action-deletechangetags": "usuwania znaczników z bazy danych",
        "nchanges": "$1 {{PLURAL:$1|zmiana|zmiany|zmian}}",
        "enhancedrc-since-last-visit": "$1 {{PLURAL:$1|od ostatniej wizyty}}",
        "enhancedrc-history": "historia",
index f127930..ad33079 100644 (file)
        "whatlinkshere-prev": "{{PLURAL:$1|d'un andré|andré ëd $1}}",
        "whatlinkshere-next": "{{PLURAL:$1|d'un anans|anans ëd $1}}",
        "whatlinkshere-links": "← anliure",
-       "whatlinkshere-hideredirs": "$1 le ridiression",
-       "whatlinkshere-hidetrans": "$1 anclusion",
-       "whatlinkshere-hidelinks": "$1 anliura",
+       "whatlinkshere-hideredirs": "Stërmé ridiression",
+       "whatlinkshere-hidetrans": "Stërmé transclusion",
+       "whatlinkshere-hidelinks": "Stërmé anliure",
        "whatlinkshere-hideimages": "$1 j'archivi lijà",
        "whatlinkshere-filters": "Filtr",
        "autoblockid": "Blocagi automàtich #$1",
        "javascripttest": "Preuva ëd JavaScript",
        "javascripttest-pagetext-unknownaction": "Assion nen conossùa «$1».",
        "javascripttest-qunit-intro": "Vëdde [$1 la documentassion dle preuve] dzora a mediawiki.org.",
-       "tooltip-pt-userpage": "Soa pàgina utent",
+       "tooltip-pt-userpage": "{{GENDER:|Soa}} pàgina utent",
        "tooltip-pt-anonuserpage": "La pàgina utent për l'IP con ël qual chiel a contribuiss",
        "tooltip-pt-mytalk": "Soa pàgina ëd discussion e ciaciarade",
        "tooltip-pt-anontalk": "La pàgina ëd ciaciarade an sle contribussion da costa adrëssa IP",
        "tooltip-pt-preferences": "Coma che i veuj mia {{SITENAME}}.",
        "tooltip-pt-watchlist": "Lista dle pàgine che chiel as ten sot euj.",
-       "tooltip-pt-mycontris": "Lista ëd soe contribussion",
+       "tooltip-pt-mycontris": "Lista ëd {{GENDER:|soe}} contribussion",
        "tooltip-pt-login": "Un a l'é nen obligà a rintré ant al sistema, ma se a lo fa a l'é mej",
        "tooltip-pt-logout": "Seurte da",
        "tooltip-pt-createaccount": "I-j consejoma ëd creé un cont e ëd rintré ant ël sistema; però a l'é nen obligatòri",
index 751bb82..21e1344 100644 (file)
                        "Robin van der Vliet",
                        "Conquistador",
                        "Frigory",
-                       "Psychoslave"
+                       "Psychoslave",
+                       "Guycn2"
                ]
        },
        "sidebar": "{{notranslate}}",
        "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]].",
        "image_tip": "This is the text that appears when you hover the mouse over the sixth (middle) button on the edit toolbar.\n\n{{Identical|Embedded file}}",
        "media_sample": "{{optional}}\n{{Identical|Example}}",
        "media_tip": "This is the text that appears when you hover the mouse over the fifth button from the right in the edit toolbar.\n{{Identical|File link}}",
+       "sig-text": "{{notranslate}} This is the text that appears when you click on the signature button (second button from the right) on the edit toolbar. $1 will be replaced with four tildes (which cannot be included directly in the message for technical reasons).",
        "sig_tip": "This is the text that appears when you hover the mouse over the second key from the right on the edit toolbar.\n{{Identical|Signature with timestamp}}",
        "hr_tip": "This is the text that appears when you hover the mouse over the first button on the right on the edit toolbar.",
        "summary": "The Summary text beside the edit summary field\n\nSee also:\n* {{msg-mw|Subject}}\nSee also:\n* {{msg-mw|Accesskey-summary}}\n* {{msg-mw|Tooltip-summary}}\n{{Identical|Summary}}",
        "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.",
        "yourtext": "Used in Diff Preview page. The diff is between {{msg-mw|currentrev}} and {{msg-mw|yourtext}}.\n\nAlso used in Edit Conflict page; the diff between {{msg-mw|yourtext}} and {{msg-mw|storedversion}}.",
        "storedversion": "This is used in an edit conflict as the label for the top revision that has been stored, as opposed to your version {{msg-mw|yourtext}} that has not been stored which is shown at the bottom of the page.",
        "nonunicodebrowser": "Used as warning when editing page.",
-       "editingold": "Used as warning when editing page.",
+       "editingold": "Used as warning when editing an old revision of a page.",
        "yourdiff": "Used as h2 header for the diff of the current version of a page with the user's version in case there is an edit conflict or a spam filter hit.",
        "copyrightwarning": "Copyright warning displayed under the edit box in editor. Parameters:\n* $1 - link\n* $2 - license name",
        "copyrightwarning2": "Copyright warning displayed under the edit box in editor\n*$1 - license name",
        "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}}",
        "right-managechangetags": "{{doc-right|managechangetags}}",
        "right-applychangetags": "{{doc-right|applychangetags}}",
        "right-changetags": "{{doc-right|changetags}}",
+       "right-deletechangetags": "{{doc-right|deletechangetags}}",
        "grant-generic": "Used if the grant name is not defined. Parameters:\n* $1 - grant name\n\nDefined grants (grant name refers: blockusers, createeditmovepage, ...):\n* {{msg-mw|grant-checkuser}}\n* {{msg-mw|grant-blockusers}}\n* {{msg-mw|grant-createaccount}}\n* {{msg-mw|grant-createeditmovepage}}\n* {{msg-mw|grant-delete}}\n* {{msg-mw|grant-editinterface}}\n* {{msg-mw|grant-editmycssjs}}\n* {{msg-mw|grant-editmyoptions}}\n* {{msg-mw|grant-editmywatchlist}}\n* {{msg-mw|grant-editpage}}\n* {{msg-mw|grant-editprotected}}\n* {{msg-mw|grant-highvolume}}\n* {{msg-mw|grant-oversight}}\n* {{msg-mw|grant-patrol}}\n* {{msg-mw|grant-protect}}\n* {{msg-mw|grant-rollback}}\n* {{msg-mw|grant-sendemail}}\n* {{msg-mw|grant-uploadeditmovefile}}\n* {{msg-mw|grant-uploadfile}}\n* {{msg-mw|grant-basic}}\n* {{msg-mw|grant-viewdeleted}}\n* {{msg-mw|grant-viewmywatchlist}}",
        "grant-group-page-interaction": "{{Related|grant-group}}",
        "grant-group-file-interaction": "{{Related|grant-group}}",
        "action-managechangetags": "{{doc-action|managechangetags}}",
        "action-applychangetags": "{{doc-action|applychangetags}}",
        "action-changetags": "{{doc-action|changetags}}",
+       "action-deletechangetags": "{{doc-action|deletechangetags}}",
        "nchanges": "Appears on enhanced watchlist and recent changes when page has more than one change on given date, linking to a diff of the changes.\n\nParameters:\n* $1 - the number of changes on that day (2 or more)\nThree messages are shown side-by-side: ({{msg-mw|Nchanges}} | {{msg-mw|Enhancedrc-since-last-visit}} | {{msg-mw|Enhancedrc-history}}).",
        "enhancedrc-since-last-visit": "Appears on enhanced watchlist and recent changes when page has more than one change on given date and at least one that the user hasn't seen yet, linking to a diff of the unviewed changes.\n\nParameters:\n* $1 - the number of unviewed changes (1 or more)\nThree messages are shown side-by-side: ({{msg-mw|nchanges}} | {{msg-mw|enhancedrc-since-last-visit}} | {{msg-mw|enhancedrc-history}}).",
        "enhancedrc-history": "Appears on enhanced watchlist and recent changes when page has more than one change on given date, linking to its history.\n\nThis is the same as {{msg-mw|hist}}, but not abbreviated.\n\nThree messages are shown side-by-side: ({{msg-mw|nchanges}} | {{msg-mw|enhancedrc-since-last-visit}} | {{msg-mw|enhancedrc-history}}).\n{{Identical|History}}",
        "upload-form-label-own-work": "[[File:Cross-wiki media upload dialog, December 2015 AB test option 1.png|thumb]] Label for own work confirmation checkbox",
        "upload-form-label-infoform-categories": "Label for category selector input\n{{Identical|Category}}",
        "upload-form-label-infoform-date": "Label for date input\n{{Identical|Date}}",
-       "upload-form-label-own-work-message-local": "Message shown by local when a user affirms that their file upload to the local wiki follows the terms of service and licensing policies of the local wiki.",
-       "upload-form-label-not-own-work-message-local": "Message shown by local when a user cannot upload a file to the local wiki.",
-       "upload-form-label-not-own-work-local-local": "Suggests uploading a file via Special:Upload instead of using whatever method they're currently using.",
-       "upload-form-label-own-work-message-default": "Message shown by default when a user affirms that they are allowed to upload a file to a remote wiki.",
-       "upload-form-label-not-own-work-message-default": "Message shown by default when a user cannot upload a file to a remote wiki.",
-       "upload-form-label-not-own-work-local-default": "Suggests uploading a file locally instead of to a remote wiki.",
-       "upload-form-label-own-work-message-shared": "[[File:Cross-wiki media upload dialog, December 2015 AB test option 1.png|thumb]] Legal message, confirming that the user is allowed to upload the file.",
-       "upload-form-label-not-own-work-message-shared": "[[File:Cross-wiki media upload dialog, December 2015 AB test option 1.png|thumb]] Explains alternatives when the copyright isn't owned by the uploader.",
-       "upload-form-label-not-own-work-local-shared": "[[File:Cross-wiki media upload dialog, December 2015 AB test option 1.png|thumb]] Message suggesting the user might want to upload a file locally instead of to Wikimedia Commons.",
+       "upload-form-label-own-work-message-generic-local": "Message shown by local when a user affirms that their file upload to the local wiki follows the terms of service and licensing policies of the local wiki.",
+       "upload-form-label-not-own-work-message-generic-local": "Message shown by local when a user cannot upload a file to the local wiki.",
+       "upload-form-label-not-own-work-local-generic-local": "Suggests uploading a file via Special:Upload instead of using whatever method they're currently using.",
+       "upload-form-label-own-work-message-generic-foreign": "Message shown by default when a user affirms that they are allowed to upload a file to a remote wiki.",
+       "upload-form-label-not-own-work-message-generic-foreign": "Message shown by default when a user cannot upload a file to a remote wiki.",
+       "upload-form-label-not-own-work-local-generic-foreign": "Suggests uploading a file locally instead of to a remote wiki.",
        "backend-fail-stream": "Parameters:\n* $1 - a filename",
        "backend-fail-backup": "Parameters:\n* $1 - a filename",
        "backend-fail-notexists": "Parameters:\n* $1 - a filename",
        "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}}",
        "tags-delete-not-found": "Error message on [[Special:Tags]]",
        "tags-delete-too-many-uses": "Error message on [[Special:Tags]]",
        "tags-delete-warnings-after-delete": "Warning shown after deleting a tag.\n\nParameters:\n* $1 - the code name of the tag that was deleted\n* $2 - the number of warnings",
+       "tags-delete-no-permission": "Error message on [[Special:Tags]]",
        "tags-activate-title": "The title of a page used to activate a tag. For more information on tags see [[mw:Manual:Tags|MediaWiki]].",
        "tags-activate-question": "An explanation to tell users what they are about to do.\n\nParameters:\n* $1 - the code name of the tag that is about to be activated",
        "tags-activate-reason": "{{Identical|Reason}}",
        "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 bfd77ff..446942d 100644 (file)
        "right-override-export-depth": "экспортирование страниц, включая связанные страницы с глубиной до 5",
        "right-sendemail": "отправка электронной почты другим участникам",
        "right-passwordreset": "просмотр электронных писем с изменением пароля",
-       "right-managechangetags": "создание и удаление [[Special:Tags|меток]] из базы данных",
+       "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": "создание и удаление меток из базы данных",
+       "action-managechangetags": "создание и (де)активацию меток",
        "action-applychangetags": " применять теги наряду с Вашими изменениями",
        "action-changetags": "Добавлять и удалять произвольные теги на отдельных изменениях и записях в журнале",
+       "action-deletechangetags": "удаление меток из базы данных",
        "nchanges": "$1 {{PLURAL:$1|изменение|изменения|изменений}}",
        "enhancedrc-since-last-visit": "$1 {{PLURAL:$1|с последнего посещения}}",
        "enhancedrc-history": "история",
        "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",
        "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": "Установленные расширения",
        "tags-delete-not-found": "Метка «$1» не существует.",
        "tags-delete-too-many-uses": "Метка «$1» применяется в более чем $2 {{PLURAL:$2|версии|версиям}}, что означает, что она не может быть удалена.",
        "tags-delete-warnings-after-delete": "Метка «$1» была удалена, но {{PLURAL:$2|было обнаружено следующее предупреждение|были обнаружены следующие предупреждения}}:",
+       "tags-delete-no-permission": "У вас нет прав на удаление изменений меток.",
        "tags-activate-title": "Активировать метку",
        "tags-activate-question": "Вы собираетесь активировать метку «$1».",
        "tags-activate-reason": "Причина:",
index 0edaa16..d284111 100644 (file)
@@ -31,7 +31,8 @@
                        "Macofe",
                        "Roonyh",
                        "Matma Rex",
-                       "SusithCM"
+                       "SusithCM",
+                       "Sandaru"
                ]
        },
        "tog-underline": "සබැඳි යටීර කිරීම:",
        "tog-newpageshidepatrolled": "විමසුමට ලක්කෙරුණු පිටු, අළුත් පිටු ලැයිස්තුව තුල නොපෙන්වන්න",
        "tog-hidecategorization": "පිටුවේ ප්‍රවර්ගීකරණය සගවන්න",
        "tog-extendwatchlist": "මෑත වෙනස්වීම් පමණක් නොව, අදාළ සියළු වෙනස්වීම් දක්වා පෙන්වන අයුරින් මුර-ලැයිස්තුව පුළුල් කරන්න",
-       "tog-usenewrc": "මෑත වෙනස්වීම් සහ මුර ලැයිස්තුව හී පිටුව අනුව සමූහ වෙනස්වීම් (ජාවාස්ක්‍රිප්ට් ඇවැසිය)",
+       "tog-usenewrc": "මෑත වෙනස්වීම් සහ මුර ලැයිස්තුව හී පිටුව අනුව සමූහ වෙනස්වීම්",
        "tog-numberheadings": "ශීර්ෂ-නාම ස්වයංක්‍රීයව අංකනය කරන්න",
        "tog-showtoolbar": "සංස්කරණ මෙවලම්තීරුව පෙන්වන්න",
        "tog-editondblclick": "ද්විත්ව-ක්ලික් කිරීම මගින් පිටු සංස්කරණය අරඹන්න",
-       "tog-editsectiononrightclick": "ඡේද ශීර්ෂ මත දකුණු-ක්ලික් කිරීමෙන් ඡේද සංස්කරණය සක්‍රීය කරන්න (ජාවාස්ක්‍රිප්ට්)",
+       "tog-editsectiononrightclick": "ඡේද ශීර්ෂ මත දකුණු-ක්ලික් කිරීමෙන් ඡේද සංස්කරණය සක්‍රීය කරන්න",
        "tog-watchcreations": "මම තනන පිටු හා මම උඩුගත කරන ගොනු මාගේ මුරලැයිස්තුවට එක් කරන්න",
        "tog-watchdefault": "මම සංස්කරණය කරන පිටු හා ගොනු මාගේ මුර ලැයිස්තුවට එක් කරන්න",
        "tog-watchmoves": "මම ගෙනයන පිටු හා ගොනු මාගේ මුර ලැයිස්තුවට එක් කරන්න",
index 728952f..b1e497d 100644 (file)
        "right-override-export-depth": "Izvoz strani, vključno s povezaimi straneh do globine 5",
        "right-sendemail": "Pošiljanje e-pošte drugim uporabnikom",
        "right-passwordreset": "Ogled e-pošt ponastavitve gesel",
-       "right-managechangetags": "Ustvarjanje in brisanje [[Special:Tags|oznak]] iz zbirke podatkov",
+       "right-managechangetags": "Ustvarjanje in (dez)aktivacijo [[Special:Tags|oznak]]",
        "right-applychangetags": "Uveljavitev [[Special:Tags|oznak]] skupaj s spremembami",
        "right-changetags": "Dodajanje in odstranjevanje poljubnih [[Special:Tags|oznak]] na posameznih redakcijah in dnevniških vnosih",
+       "right-deletechangetags": "Izbris [[Special:Tags|oznak]] iz zbirke podatkov",
        "grant-generic": "Snov pravic »$1«",
        "grant-group-page-interaction": "Interakcija s stranmi",
        "grant-group-file-interaction": "Interakcija s predstavnostjo",
        "action-viewmyprivateinfo": "ogled svojih zasebnih informacij",
        "action-editmyprivateinfo": "urejanje svojih zasebnih informacij",
        "action-editcontentmodel": "urejanje vsebinskega modela strani",
-       "action-managechangetags": "ustvarjanje in brisanje oznak iz zbirke podatkov",
+       "action-managechangetags": "ustvarjanje in (dez)aktivacijo oznak",
        "action-applychangetags": "uveljavitev oznak skupaj z vašimi spremembami",
        "action-changetags": "dodajanje in odstranjevanje poljubnih oznak na posameznih redakcijah in dnevniških vnosih",
+       "action-deletechangetags": "izbris oznak iz zbirke podatkov",
        "nchanges": "$1 {{PLURAL:$1|sprememba|spremembi|spremembe|sprememb|sprememb}}",
        "enhancedrc-since-last-visit": "$1 {{PLURAL:$1|od zadnjega obiska}}",
        "enhancedrc-history": "zgodovina",
        "whatlinkshere-prev": "{{PLURAL:$1|prejšnji|prejšnja $1|prejšnji $1|prejšnjih $1|prejšnjih $1}}",
        "whatlinkshere-next": "{{PLURAL:$1|naslednji|naslednja $1|naslednji $1|naslednjih $1|naslednjih $1}}",
        "whatlinkshere-links": "← povezave",
-       "whatlinkshere-hideredirs": "$1 preusmeritve",
-       "whatlinkshere-hidetrans": "$1 vključitve",
-       "whatlinkshere-hidelinks": "$1 povezave",
-       "whatlinkshere-hideimages": "$1 povezave datotek",
+       "whatlinkshere-hideredirs": "Skrij preusmeritve",
+       "whatlinkshere-hidetrans": "Skrij vključitve",
+       "whatlinkshere-hidelinks": "Skrij povezave",
+       "whatlinkshere-hideimages": "Skrij povezave datotek",
        "whatlinkshere-filters": "Filtri",
        "whatlinkshere-submit": "Pojdi",
        "autoblockid": "Samodejna blokada št. $1",
        "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",
        "tags-delete-not-found": "Oznaka »$1« ne obstaja.",
        "tags-delete-too-many-uses": "Oznaka »$1« je uporabljena pri več kot $2 {{PLURAL:$2|redakciji|redakcijah}}, kar pomeni, da je ni mogoče izbrisati.",
        "tags-delete-warnings-after-delete": "Oznako »$1« smo izbrisali, vendar smo naleteli na {{PLURAL:$2|naslednjo težavo|naslednji težavi|naslednje težave}}:",
+       "tags-delete-no-permission": "Nimate dovoljenja za izbris oznak sprememb.",
        "tags-activate-title": "Aktiviraj oznako",
        "tags-activate-question": "Aktivirali boste oznako »$1«.",
        "tags-activate-reason": "Razlog:",
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 df323fe..5562be9 100644 (file)
        "right-override-export-depth": "Exportera sidor inklusive länkade sidor till ett djup på 5",
        "right-sendemail": "Skicka e-post till andra användare",
        "right-passwordreset": "Visa e-postmeddelanden med lösenordsåterställning",
-       "right-managechangetags": "Skapa och radera [[Special:Tags|märken]] från databasen",
+       "right-managechangetags": "Skapa och (in)aktivera [[Special:Tags|märken]]",
        "right-applychangetags": "Tillämpa [[Special:Tags|märken]] tillsammans med ens ändringar",
        "right-changetags": "Lägg till och ta bort godtyckliga [[Special:Tags|märken]] på individuella sidversioner och loggposter.",
+       "right-deletechangetags": "Radera [[Special:Tags|märken]] från databasen",
        "grant-generic": "Rättighetsgrupp \"$1\"",
        "grant-group-page-interaction": "Interagera med sidor",
        "grant-group-file-interaction": "Interagera med media",
        "action-viewmyprivateinfo": "visa din privata information",
        "action-editmyprivateinfo": "redigera din privata information",
        "action-editcontentmodel": "ändra innehållsmodellen för en sida",
-       "action-managechangetags": "skapa och radera märken från databasen",
+       "action-managechangetags": "skapa och (in)aktivera märken",
        "action-applychangetags": "tillämpa märken tillsammans med dina ändringar",
        "action-changetags": "lägg till och ta bort godtyckliga märken på individuella sidversioner och loggposter",
+       "action-deletechangetags": "radera märken från databasen",
        "nchanges": "$1 {{PLURAL:$1|ändring|ändringar}}",
        "enhancedrc-since-last-visit": "$1 {{PLURAL:$1|sedan senaste besöket}}",
        "enhancedrc-history": "historik",
        "whatlinkshere-prev": "{{PLURAL:$1|förra|förra $1}}",
        "whatlinkshere-next": "{{PLURAL:$1|nästa|nästa $1}}",
        "whatlinkshere-links": "← länkar",
-       "whatlinkshere-hideredirs": "$1 omdirigeringar",
-       "whatlinkshere-hidetrans": "$1 inkluderingar",
-       "whatlinkshere-hidelinks": "$1 länkar",
-       "whatlinkshere-hideimages": "$1 fillänkar",
+       "whatlinkshere-hideredirs": "Dölj omdirigeringar",
+       "whatlinkshere-hidetrans": "Dölj inkluderingar",
+       "whatlinkshere-hidelinks": "Dölj länkar",
+       "whatlinkshere-hideimages": "Dölj fillänkar",
        "whatlinkshere-filters": "Filter",
        "whatlinkshere-submit": "Gå",
        "autoblockid": "Autoblockera #$1",
        "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",
        "tags-delete-not-found": "Märket \"$1\" finns inte.",
        "tags-delete-too-many-uses": "Märket \"$1\" appliceras på fler än $2 {{PLURAL:$2|version|versioner}}, vilket innebär att det inte kan raderas.",
        "tags-delete-warnings-after-delete": "Märket \"$1\" raderades, men följande {{PLURAL:$2|varning|varningar}} uppstod:",
+       "tags-delete-no-permission": "Du har inte behörighet att radera ändringsmärken.",
        "tags-activate-title": "Aktivera märke",
        "tags-activate-question": "Du är på väg att aktivera märket \"$1\".",
        "tags-activate-reason": "Anledning:",
        "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 24f55bf..842500f 100644 (file)
        "movesubpagetext": "Ця сторінка має $1 {{PLURAL:$1|підсторінку|підсторінки|підсторінок}}.",
        "movenosubpage": "Ця сторінка не має підсторінок.",
        "movereason": "Причина:",
-       "revertmove": "відкинути",
+       "revertmove": "скасувати перейменування",
        "delete_and_move_text": "Сторінка з назвою [[:$1|«$1»]] вже існує.\nБажаєте вилучити її для можливості перейменування?",
        "delete_and_move_confirm": "Так, вилучити для перейменування",
        "delete_and_move_reason": "Вилучена для можливості перейменування сторінки «[[$1]]»",
index f29edf3..e7d3e3a 100644 (file)
        "allmessagesdefault": "طے شدہ متن",
        "allmessagescurrent": "موجودہ متن",
        "allmessagestext": "یہ میڈیاویکی: جاۓ نام میں دستیاب نظامی پیغامات کی فہرست ہے۔",
+       "allmessages-filter": "تلاش بلحاظ:",
        "allmessages-filter-all": "تمام",
        "allmessages-filter-modified": "تبدیل شدہ",
+       "allmessages-prefix": "تلاش بلحاظ سابقہ:",
        "allmessages-language": "زبان:",
        "allmessages-filter-submit": "ٹھیک",
        "allmessages-filter-translate": "ترجمہ",
        "logentry-delete-delete": "$1 {{GENDER:$2|حذف کیا گیا}} صفحہ $3",
        "logentry-move-move": "$1 نے صفحہ $3 کو بجانب $4 منتقل کیا",
        "logentry-newusers-create": "صارف کھاتہ $1 {{GENDER:$2|بنایا گیا}}",
+       "logentry-protect-modify": "$1 نے $3 کا درجۂ حفاظت {{GENDER:$2|تبدیل کیا}} $4",
        "logentry-upload-upload": "$1 {{GENDER:$2|اپلوڈ}} $3",
        "rightsnone": "(کچھ نہیں)",
        "revdelete-summary": "خلاصۂ تدوین",
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 f261a47..77b9619 100644 (file)
        "right-override-export-depth": "导出页面,包括最多5层链接",
        "right-sendemail": "发送电子邮件给其他用户",
        "right-passwordreset": "查看密码重置电子邮件",
-       "right-managechangetags": "从数据库创建和删除[[Special:Tags|标签]]",
+       "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": "创建和从数据库中删除标签",
+       "action-managechangetags": "创建和(取消)激活标签",
        "action-applychangetags": "连同您的更改应用标签",
        "action-changetags": "在个别修订和日志记录中添加和移除任意标签",
+       "action-deletechangetags": "从数据库删除标签",
        "nchanges": "$1次更改",
        "enhancedrc-since-last-visit": "{{PLURAL:$1|上次访问后}}$1个",
        "enhancedrc-history": "历史",
        "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”已删除,但遇到了以下{{PLURAL:$2|警告}}:",
+       "tags-delete-no-permission": "您没有权限删除更改标签。",
        "tags-activate-title": "激活标签",
        "tags-activate-question": "您将要激活标签“$1”。",
        "tags-activate-reason": "原因:",
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 2db0139..922cc87 100644 (file)
@@ -34,7 +34,7 @@ require_once __DIR__ . '/Maintenance.php';
  */
 class UpdateCollation extends Maintenance {
        const BATCH_SIZE = 100; // Number of rows to process in one batch
-       const SYNC_INTERVAL = 20; // Wait for slaves after this many batches
+       const SYNC_INTERVAL = 5; // Wait for slaves after this many batches
 
        public $sizeHistogram = [];
 
index cf2abdb..3fc5801 100644 (file)
@@ -1169,10 +1169,14 @@ return [
                        'upload-foreign-cant-upload',
                ]
        ],
+       'mediawiki.ForeignStructuredUpload.config' => [
+               'class' => 'ResourceLoaderUploadDialogModule',
+       ],
        'mediawiki.ForeignStructuredUpload' => [
                'scripts' => 'resources/src/mediawiki/mediawiki.ForeignStructuredUpload.js',
                'dependencies' => [
                        'mediawiki.ForeignUpload',
+                       'mediawiki.ForeignStructuredUpload.config',
                ],
        ],
        'mediawiki.Upload.Dialog' => [
@@ -1284,6 +1288,7 @@ return [
                        'mediawiki.widgets.CategorySelector',
                        'mediawiki.widgets.DateInputWidget',
                        'mediawiki.jqueryMsg',
+                       'mediawiki.api.messages',
                        'moment',
                        'mediawiki.libs.jpegmeta',
                ],
@@ -1291,15 +1296,12 @@ return [
                        'upload-form-label-own-work',
                        'upload-form-label-infoform-categories',
                        'upload-form-label-infoform-date',
-                       'upload-form-label-own-work-message-default',
-                       'upload-form-label-not-own-work-message-default',
-                       'upload-form-label-not-own-work-local-default',
-                       'upload-form-label-own-work-message-shared',
-                       'upload-form-label-not-own-work-message-shared',
-                       'upload-form-label-not-own-work-local-shared',
-                       'upload-form-label-own-work-message-local',
-                       'upload-form-label-not-own-work-message-local',
-                       'upload-form-label-not-own-work-local-local',
+                       'upload-form-label-own-work-message-generic-local',
+                       'upload-form-label-not-own-work-message-generic-local',
+                       'upload-form-label-not-own-work-local-generic-local',
+                       'upload-form-label-own-work-message-generic-foreign',
+                       'upload-form-label-not-own-work-message-generic-foreign',
+                       'upload-form-label-not-own-work-local-generic-foreign',
                ],
        ],
        'mediawiki.toc' => [
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 8509fbc..16fec73 100644 (file)
                var booklet = this;
                return mw.ForeignStructuredUpload.BookletLayout.parent.prototype.initialize.call( this ).then(
                        function () {
-                               // Point the CategorySelector to the right wiki
-                               return booklet.upload.getApi().then(
-                                       function ( api ) {
+                               return $.when(
+                                       // Point the CategorySelector to the right wiki
+                                       booklet.upload.getApi().then( function ( api ) {
                                                // If this is a ForeignApi, it will have a apiUrl, otherwise we don't need to do anything
                                                if ( api.apiUrl ) {
                                                        // Can't reuse the same object, CategorySelector calls #abort on its mw.Api instance
                                                        booklet.categoriesWidget.api = new mw.ForeignApi( api.apiUrl );
                                                }
                                                return $.Deferred().resolve();
-                                       },
-                                       function () {
-                                               return $.Deferred().resolve();
-                                       }
+                                       } ),
+                                       // Set up booklet fields and license messages to match configuration
+                                       booklet.upload.loadConfig().then( function ( config ) {
+                                               var
+                                                       msgPromise,
+                                                       isLocal = booklet.upload.target === 'local',
+                                                       fields = config.fields,
+                                                       msgs = config.licensemessages[ isLocal ? 'local' : 'foreign' ];
+
+                                               // Hide disabled fields
+                                               booklet.descriptionField.toggle( !!fields.description );
+                                               booklet.categoriesField.toggle( !!fields.categories );
+                                               booklet.dateField.toggle( !!fields.date );
+                                               // Update form validity
+                                               booklet.onInfoFormChange();
+
+                                               // Load license messages from the remote wiki if we don't have these messages locally
+                                               // (this means that we only load messages from the foreign wiki for custom config)
+                                               if ( mw.message( 'upload-form-label-own-work-message-' + msgs ).exists() ) {
+                                                       msgPromise = $.Deferred().resolve();
+                                               } else {
+                                                       msgPromise = booklet.upload.apiPromise.then( function ( api ) {
+                                                               return api.loadMessages( [
+                                                                       'upload-form-label-own-work-message-' + msgs,
+                                                                       'upload-form-label-not-own-work-message-' + msgs,
+                                                                       'upload-form-label-not-own-work-local-' + msgs
+                                                               ] );
+                                                       } );
+                                               }
+
+                                               // Update license messages
+                                               return msgPromise.then( function () {
+                                                       booklet.$ownWorkMessage
+                                                               .msg( 'upload-form-label-own-work-message-' + msgs )
+                                                               .find( 'a' ).attr( 'target', '_blank' );
+                                                       booklet.$notOwnWorkMessage
+                                                               .msg( 'upload-form-label-not-own-work-message-' + msgs )
+                                                               .find( 'a' ).attr( 'target', '_blank' );
+                                                       booklet.$notOwnWorkLocal
+                                                               .msg( 'upload-form-label-not-own-work-local-' + msgs )
+                                                               .find( 'a' ).attr( 'target', '_blank' );
+                                               } );
+                                       } )
                                );
-                       },
-                       function () {
-                               return $.Deferred().resolve();
                        }
+               ).then(
+                       null,
+                       // Always resolve, never reject
+                       function () { return $.Deferred().resolve(); }
                );
        };
 
         * @inheritdoc
         */
        mw.ForeignStructuredUpload.BookletLayout.prototype.renderUploadForm = function () {
-               var fieldset, $ownWorkMessage, $notOwnWorkMessage,
-                       ownWorkMessage, notOwnWorkMessage, notOwnWorkLocal,
-                       validTargets = mw.config.get( 'wgForeignUploadTargets' ),
-                       target = this.target || validTargets[ 0 ] || 'local',
+               var fieldset,
                        layout = this;
 
-               // upload-form-label-own-work-message-local
-               // upload-form-label-own-work-message-shared
-               ownWorkMessage = mw.message( 'upload-form-label-own-work-message-' + target );
-               // upload-form-label-not-own-work-message-local
-               // upload-form-label-not-own-work-message-shared
-               notOwnWorkMessage = mw.message( 'upload-form-label-not-own-work-message-' + target );
-               // upload-form-label-not-own-work-local-local
-               // upload-form-label-not-own-work-local-shared
-               notOwnWorkLocal = mw.message( 'upload-form-label-not-own-work-local-' + target );
-
-               if ( !ownWorkMessage.exists() ) {
-                       ownWorkMessage = mw.message( 'upload-form-label-own-work-message-default' );
-               }
-               if ( !notOwnWorkMessage.exists() ) {
-                       notOwnWorkMessage = mw.message( 'upload-form-label-not-own-work-message-default' );
-               }
-               if ( !notOwnWorkLocal.exists() ) {
-                       notOwnWorkLocal = mw.message( 'upload-form-label-not-own-work-local-default' );
-               }
-
-               $ownWorkMessage = $( '<p>' ).append( ownWorkMessage.parseDom() )
+               // These elements are filled with text in #initialize
+               // TODO Refactor this to be in one place
+               this.$ownWorkMessage = $( '<p>' )
                        .addClass( 'mw-foreignStructuredUpload-bookletLayout-license' );
-               $notOwnWorkMessage = $( '<div>' ).append(
-                       $( '<p>' ).append( notOwnWorkMessage.parseDom() ),
-                       $( '<p>' ).append( notOwnWorkLocal.parseDom() )
-               );
-               $ownWorkMessage.add( $notOwnWorkMessage ).find( 'a' ).attr( 'target', '_blank' );
+               this.$notOwnWorkMessage = $( '<p>' );
+               this.$notOwnWorkLocal = $( '<p>' );
 
                this.selectFileWidget = new OO.ui.SelectFileWidget( {
                        showDropTarget: true
                } );
                this.messageLabel = new OO.ui.LabelWidget( {
-                       label: $notOwnWorkMessage
+                       label: $( '<div>' ).append(
+                               this.$notOwnWorkMessage,
+                               this.$notOwnWorkLocal
+                       )
                } );
                this.ownWorkCheckbox = new OO.ui.CheckboxInputWidget().on( 'change', function ( on ) {
                        layout.messageLabel.toggle( !on );
                                align: 'inline',
                                label: $( '<div>' ).append(
                                        $( '<p>' ).text( mw.msg( 'upload-form-label-own-work' ) ),
-                                       $ownWorkMessage
+                                       this.$ownWorkMessage
                                )
                        } ),
                        new OO.ui.FieldLayout( this.messageLabel, {
                        mustBeBefore: moment().add( 1, 'day' ).locale( 'en' ).format( 'YYYY-MM-DD' ) // Tomorrow
                } );
 
+               this.filenameField = new OO.ui.FieldLayout( this.filenameWidget, {
+                       label: mw.msg( 'upload-form-label-infoform-name' ),
+                       align: 'top',
+                       classes: [ 'mw-foreignStructuredUploa-bookletLayout-small-notice' ],
+                       notices: [ mw.msg( 'upload-form-label-infoform-name-tooltip' ) ]
+               } );
+               this.descriptionField = new OO.ui.FieldLayout( this.descriptionWidget, {
+                       label: mw.msg( 'upload-form-label-infoform-description' ),
+                       align: 'top',
+                       classes: [ 'mw-foreignStructuredUploa-bookletLayout-small-notice' ],
+                       notices: [ mw.msg( 'upload-form-label-infoform-description-tooltip' ) ]
+               } );
+               this.categoriesField = new OO.ui.FieldLayout( this.categoriesWidget, {
+                       label: mw.msg( 'upload-form-label-infoform-categories' ),
+                       align: 'top'
+               } );
+               this.dateField = new OO.ui.FieldLayout( this.dateWidget, {
+                       label: mw.msg( 'upload-form-label-infoform-date' ),
+                       align: 'top'
+               } );
+
                fieldset = new OO.ui.FieldsetLayout( {
                        label: mw.msg( 'upload-form-label-infoform-title' )
                } );
                fieldset.addItems( [
-                       new OO.ui.FieldLayout( this.filenameWidget, {
-                               label: mw.msg( 'upload-form-label-infoform-name' ),
-                               align: 'top',
-                               classes: [ 'mw-foreignStructuredUploa-bookletLayout-small-notice' ],
-                               notices: [ mw.msg( 'upload-form-label-infoform-name-tooltip' ) ]
-                       } ),
-                       new OO.ui.FieldLayout( this.descriptionWidget, {
-                               label: mw.msg( 'upload-form-label-infoform-description' ),
-                               align: 'top',
-                               classes: [ 'mw-foreignStructuredUploa-bookletLayout-small-notice' ],
-                               notices: [ mw.msg( 'upload-form-label-infoform-description-tooltip' ) ]
-                       } ),
-                       new OO.ui.FieldLayout( this.categoriesWidget, {
-                               label: mw.msg( 'upload-form-label-infoform-categories' ),
-                               align: 'top'
-                       } ),
-                       new OO.ui.FieldLayout( this.dateWidget, {
-                               label: mw.msg( 'upload-form-label-infoform-date' ),
-                               align: 'top'
-                       } )
+                       this.filenameField,
+                       this.descriptionField,
+                       this.categoriesField,
+                       this.dateField
                ] );
                this.infoForm = new OO.ui.FormLayout( {
                        classes: [ 'mw-upload-bookletLayout-infoForm' ],
         * @inheritdoc
         */
        mw.ForeignStructuredUpload.BookletLayout.prototype.onInfoFormChange = function () {
-               var layout = this;
-               $.when(
-                       this.filenameWidget.getValidity(),
-                       this.descriptionWidget.getValidity(),
-                       this.dateWidget.getValidity()
-               ).done( function () {
+               var layout = this,
+                       validityPromises = [];
+
+               validityPromises.push( this.filenameWidget.getValidity() );
+               if ( this.descriptionField.isVisible() ) {
+                       validityPromises.push( this.descriptionWidget.getValidity() );
+               }
+               if ( this.dateField.isVisible() ) {
+                       validityPromises.push( this.dateWidget.getValidity() );
+               }
+
+               $.when.apply( $, validityPromises ).done( function () {
                        layout.emit( 'infoValid', true );
                } ).fail( function () {
                        layout.emit( 'infoValid', false );
index f90071c..4a0366a 100644 (file)
@@ -1,4 +1,4 @@
-( function ( mw, OO ) {
+( function ( mw, $, OO ) {
        /**
         * @class mw.ForeignStructuredUpload
         * @extends mw.ForeignUpload
                this.descriptions = [];
                this.categories = [];
 
+               // Config for uploads to local wiki.
+               // Can be overridden with foreign wiki config when #loadConfig is called.
+               this.config = mw.config.get( 'wgUploadDialog' );
+
                mw.ForeignUpload.call( this, target, apiconfig );
        }
 
        OO.inheritClass( ForeignStructuredUpload, mw.ForeignUpload );
 
+       /**
+        * Get the configuration for the form and filepage from the foreign wiki, if any, and use it for
+        * this upload.
+        *
+        * @return {jQuery.Promise} Promise returning config object
+        */
+       ForeignStructuredUpload.prototype.loadConfig = function () {
+               var deferred,
+                       upload = this;
+
+               if ( this.configPromise ) {
+                       return this.configPromise;
+               }
+
+               if ( this.target === 'local' ) {
+                       deferred = $.Deferred();
+                       setTimeout( function () {
+                               // Resolve asynchronously, so that it's harder to accidentally write synchronous code that
+                               // will break for cross-wiki uploads
+                               deferred.resolve( upload.config );
+                       } );
+                       this.configPromise = deferred.promise();
+               } else {
+                       this.configPromise = this.apiPromise.then( function ( api ) {
+                               // Get the config from the foreign wiki
+                               return api.get( {
+                                       action: 'query',
+                                       meta: 'siteinfo',
+                                       siprop: 'uploaddialog',
+                                       // For convenient true/false booleans
+                                       formatversion: 2
+                               } ).then( function ( resp ) {
+                                       // Foreign wiki might be running a pre-1.27 MediaWiki, without support for this
+                                       if ( resp.query && resp.query.uploaddialog ) {
+                                               upload.config = resp.query.uploaddialog;
+                                       }
+                                       return upload.config;
+                               } );
+                       } );
+               }
+
+               return this.configPromise;
+       };
+
        /**
         * Add categories to the upload.
         *
         * @return {string}
         */
        ForeignStructuredUpload.prototype.getText = function () {
-               return (
-                       '== {{int:filedesc}} ==\n' +
-                       '{{Information' +
-                       '\n|description=' +
-                       this.getDescriptions() +
-                       '\n|date=' +
-                       this.getDate() +
-                       '\n|source=' +
-                       this.getSource() +
-                       '\n|author=' +
-                       this.getUser() +
-                       '\n}}\n\n' +
-                       '== {{int:license-header}} ==\n' +
-                       this.getLicense() +
-                       '\n\n' +
-                       this.getCategories()
-               );
+               return this.config.format.filepage
+                       // Replace "numbered parameters" with the given information
+                       .replace( '$DESCRIPTION', this.getDescriptions() )
+                       .replace( '$DATE', this.getDate() )
+                       .replace( '$SOURCE', this.getSource() )
+                       .replace( '$AUTHOR', this.getUser() )
+                       .replace( '$LICENSE', this.getLicense() )
+                       .replace( '$CATEGORIES', this.getCategories() );
+       };
+
+       /**
+        * @inheritdoc
+        */
+       ForeignStructuredUpload.prototype.getComment = function () {
+               return this.config.comment
+                       .replace( '$PAGENAME', mw.config.get( 'wgPageName' ) )
+                       .replace( '$HOST', location.host );
        };
 
        /**
 
                for ( i = 0; i < this.descriptions.length; i++ ) {
                        desc = this.descriptions[ i ];
-                       templateCalls.push( '{{' + desc.language + '|1=' + desc.text + '}}' );
+                       templateCalls.push(
+                               this.config.format.description
+                                       .replace( '$LANGUAGE', desc.language )
+                                       .replace( '$TEXT', desc.text )
+                       );
                }
 
                return templateCalls.join( '\n' );
                var i, cat, categoryLinks = [];
 
                if ( this.categories.length === 0 ) {
-                       return '{{subst:unc}}';
+                       return this.config.format.uncategorized;
                }
 
                for ( i = 0; i < this.categories.length; i++ ) {
         * @return {string}
         */
        ForeignStructuredUpload.prototype.getLicense = function () {
-               // Make sure this matches the messages for different targets in
-               // mw.ForeignStructuredUpload.BookletLayout.prototype.renderUploadForm
-               return this.target === 'shared' ? '{{self|cc-by-sa-4.0}}' : '';
+               return this.config.format.license;
        };
 
        /**
         * @return {string}
         */
        ForeignStructuredUpload.prototype.getSource = function () {
-               return '{{own}}';
+               return this.config.format.ownwork;
        };
 
        /**
        };
 
        mw.ForeignStructuredUpload = ForeignStructuredUpload;
-}( mediaWiki, OO ) );
+}( mediaWiki, jQuery, OO ) );
index 1a0b59a..eeeab68 100644 (file)
                // actual API call methods to wait for the apiPromise to resolve
                // before continuing.
                mw.Upload.call( this, null );
-
-               if ( this.target !== 'local' ) {
-                       // Keep these untranslated. We don't know the content language of the foreign wiki, best to
-                       // stick to English in the text.
-                       this.setComment( 'Cross-wiki upload from ' + location.host );
-               }
        }
 
        OO.inheritClass( ForeignUpload, mw.Upload );
index 2b28cb4..7a7469a 100644 (file)
 
                return this.upload.getApi().then(
                        function ( api ) {
-                               // If the user can't upload anything, don't give them the option to.
-                               return api.getUserInfo().then(
-                                       function ( userInfo ) {
+                               return $.when(
+                                       booklet.upload.loadConfig(),
+                                       // If the user can't upload anything, don't give them the option to.
+                                       api.getUserInfo().then( function ( userInfo ) {
                                                if ( userInfo.rights.indexOf( 'upload' ) === -1 ) {
                                                        // TODO Use a better error message when not all logged-in users can upload
                                                        booklet.getPage( 'upload' ).$element.msg( 'api-error-mustbeloggedin' );
                                                }
                                                return $.Deferred().resolve();
-                                       },
-                                       function () {
-                                               return $.Deferred().resolve();
-                                       }
+                                       } )
+                               ).then(
+                                       null,
+                                       // Always resolve, never reject
+                                       function () { return $.Deferred().resolve(); }
                                );
                        },
                        function ( errorMsg ) {
index 260fd37..8f0ad6b 100644 (file)
@@ -4,16 +4,6 @@
        margin: 1em 0;
 }
 
-.oo-ui-fieldLayout.mw-htmlform-ooui-header-empty,
-.oo-ui-fieldLayout.mw-htmlform-ooui-header-empty .oo-ui-fieldLayout-body {
-       display: none;
-}
-
-.oo-ui-fieldLayout.mw-htmlform-ooui-header-errors {
-       /* Override 'display: none' from above */
-       display: block;
-}
-
 .mw-htmlform-ooui .mw-htmlform-submit-buttons {
        margin-top: 1em;
 }
index 8a89fbc..b8d0b09 100644 (file)
                        } catch ( e ) {
                                fallback = parser.settings.messages.get( key );
                                mw.log.warn( 'mediawiki.jqueryMsg: ' + key + ': ' + e.message );
+                               mw.track( 'mediawiki.jqueryMsg.error', {
+                                       messageKey: key,
+                                       errorMessage: e.message
+                               } );
                                return $( '<span>' ).text( fallback );
                        }
                };
                                return result === null ? null : result.join( '' );
                        }
 
-                       asciiAlphabetLiteral = makeRegexParser( /[A-Za-z]+/ );
+                       asciiAlphabetLiteral = makeRegexParser( /^[A-Za-z]+/ );
                        htmlDoubleQuoteAttributeValue = makeRegexParser( /^[^"]*/ );
                        htmlSingleQuoteAttributeValue = makeRegexParser( /^[^']*/ );
 
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 ccb86d0..1be2d62 100644 (file)
@@ -211,6 +211,7 @@ class ParserTest {
                # add a namespace shadowing a interwiki link, to test
                # proper precedence when resolving links. (bug 51680)
                $wgExtraNamespaces[100] = 'MemoryAlpha';
+               $wgExtraNamespaces[101] = 'MemoryAlpha talk';
 
                // XXX: tests won't run without this (for CACHE_DB)
                if ( $wgMainCacheType === CACHE_DB ) {
@@ -913,7 +914,6 @@ class ParserTest {
                        'wgExperimentalHtmlIds' => false,
                        'wgExternalLinkTarget' => false,
                        'wgHtml5' => true,
-                       'wgWellFormedXml' => true,
                        'wgAdaptiveMessageCache' => true,
                        'wgDisableLangConversion' => false,
                        'wgDisableTitleConversion' => false,
index 23bdbde..7051b4f 100644 (file)
@@ -5632,7 +5632,7 @@ Parenthesis in external links, w/ transclusion or comment
 </p><p>(<a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>)
 </p>
 !! html/parsoid
-<p>(<a typeof="mw:ExpandedAttrs" about="#mwt2" rel="mw:ExtLink" href="http://example.com/hi" data-parsoid='{"stx":"url","a":{"href":"http://example.com/hi"},"sa":{"href":"http://example.com/{{echo|hi}}"}}' data-mw='{"attribs":[[{"txt":"href"},{"html":"http://example.com/&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=\"{&amp;quot;pi&amp;quot;:[[{&amp;quot;k&amp;quot;:&amp;quot;1&amp;quot;}]],&amp;quot;dsr&amp;quot;:[20,31,null,null]}\" data-mw=\"{&amp;quot;parts&amp;quot;:[{&amp;quot;template&amp;quot;:{&amp;quot;target&amp;quot;:{&amp;quot;wt&amp;quot;:&amp;quot;echo&amp;quot;,&amp;quot;href&amp;quot;:&amp;quot;./Template:Echo&amp;quot;},&amp;quot;params&amp;quot;:{&amp;quot;1&amp;quot;:{&amp;quot;wt&amp;quot;:&amp;quot;hi&amp;quot;}},&amp;quot;i&amp;quot;:0}}]}\">hi&lt;/span>"}]]}'>http://example.com/hi</a>)</p>
+<p>(<a typeof="mw:ExpandedAttrs" about="#mwt2" rel="mw:ExtLink" href="http://example.com/hi" data-parsoid='{"stx":"url","a":{"href":"http://example.com/hi"},"sa":{"href":"http://example.com/{{echo|hi}}"}}' data-mw='{"attribs":[[{"txt":"href"},{"html":"http://example.com/&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[{\"k\":\"1\"}]],\"dsr\":[20,31,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"hi\"}},\"i\":0}}]}&#39;>hi&lt;/span>"}]]}'>http://example.com/hi</a>)</p>
 
 <p>(<a rel="mw:ExtLink" href="http://example.com" data-parsoid='{"stx":"url","a":{"href":"http://example.com"},"sa":{"href":"http://example.com&lt;!-- hi -->"}}'>http://example.com</a>)</p>
 !! end
@@ -5650,7 +5650,7 @@ parsoid={ "modes": ["html2wt"], "suppressErrors": true }
 text
 <nowiki>*</nowiki>text
 <nowiki>[[foo]]</nowiki>
-<nowiki>*[[foo]]</nowiki>
+<nowiki>*</nowiki>a <nowiki>[[foo]]</nowiki>
 !! end
 
 !! test
@@ -5658,7 +5658,7 @@ mw:ExtLink -vs- mw:WikiLink (T94723)
 !! options
 parsoid=html2wt
 !! html/parsoid
-<a rel="mw:WikiLink" href="./Foo" title="Foo" data-parsoid='{"stx":"piped","a":{"href":"./Foo"},"sa":{"href":"Foo"},"dsr":[0,11,6,2]}'>Bar</a>
+<a rel="mw:WikiLink" href="./Foo" title="Foo" data-parsoid='{"stx":"piped","a":{"href":"./Foo"},"sa":{"href":"Foo"}}'>Bar</a>
 <a rel="mw:WikiLink" href="./Foo" title="Foo">Bar</a>
 <a rel="mw:WikiLink" href="http://en.wikipedia.org/wiki/Foo" title="Foo">Bar</a>
 <a rel="mw:ExtLink" href="http://en.wikipedia.org/wiki/Foo" title="Foo">Bar</a>
@@ -7110,6 +7110,25 @@ parsoid=html2wt
 |}
 !! end
 
+!! test
+Testing serialization after deletion in references
+!! options
+parsoid={
+  "modes": ["wt2wt"],
+  "changes": [
+    ["#x", "remove"]
+  ]
+}
+!! wikitext
+hi <ref><div id="x">ho</div></ref>
+
+<references />
+!! wikitext/edited
+hi <ref></ref>
+
+<references />
+!! end
+
 !!test
 Testing serialization after deletion of table cells
 !!options
@@ -7572,10 +7591,10 @@ Broken image links with HTML captions (bug 39700)
 <a href="/index.php?title=Special:Upload&amp;wpDestFile=Nonexistent" class="new" title="File:Nonexistent">abc</a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Error mw:Image" data-mw='{"errors":[{"key":"missing-image","message":"This image does not exist."}],"caption":"&amp;lt;script&amp;gt;&amp;lt;/script&amp;gt;"}'><a href="./File:Nonexistent"><img resource="./File:Nonexistent" src="./Special:FilePath/Nonexistent" height="220" width="220"/></a></span>
-<span typeof="mw:Error mw:Image" data-mw='{"errors":[{"key":"missing-image","message":"This image does not exist."}],"caption":"&amp;lt;script&amp;gt;&amp;lt;/script&amp;gt;"}'><a href="./File:Nonexistent"><img resource="./File:Nonexistent" src="./Special:FilePath/Nonexistent" height="100" width="100"/></a></span>
-<span class="mw-default-size" typeof="mw:Error mw:Image" data-mw='{"errors":[{"key":"missing-image","message":"This image does not exist."}],"caption":"&lt;span typeof=\"mw:Entity\" data-parsoid=\"{&amp;quot;src&amp;quot;:&amp;quot;&amp;amp;lt;&amp;quot;,&amp;quot;srcContent&amp;quot;:&amp;quot;&lt;&amp;quot;,&amp;quot;dsr&amp;quot;:[107,111,null,null]}\">&amp;lt;&lt;/span>"}'><a href="./File:Nonexistent"><img resource="./File:Nonexistent" src="./Special:FilePath/Nonexistent" height="220" width="220"/></a></span>
-<span class="mw-default-size" typeof="mw:Error mw:Image" data-mw='{"errors":[{"key":"missing-image","message":"This image does not exist."}],"caption":"a&lt;i data-parsoid=\"{&amp;quot;stx&amp;quot;:&amp;quot;html&amp;quot;,&amp;quot;dsr&amp;quot;:[134,142,3,4]}\">b&lt;/i>c"}'><a href="./File:Nonexistent"><img resource="./File:Nonexistent" src="./Special:FilePath/Nonexistent" height="220" width="220"/></a></span></p>
+<p><span class="mw-default-size" typeof="mw:Error mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&lt;script>&lt;/script>"}]}' data-mw='{"errors":[{"key":"missing-image","message":"This image does not exist."}],"caption":"&amp;lt;script>&amp;lt;/script>"}'><a href="./File:Nonexistent" data-parsoid='{"a":{"href":"./File:Nonexistent"},"sa":{}}'><img resource="./File:Nonexistent" src="./Special:FilePath/Nonexistent" height="220" width="220" data-parsoid='{"a":{"resource":"./File:Nonexistent","height":"220","width":"220"},"sa":{"resource":"File:Nonexistent"}}'/></a></span>
+<span typeof="mw:Error mw:Image" data-parsoid='{"optList":[{"ck":"width","ak":"100x100px"},{"ck":"caption","ak":"&lt;script>&lt;/script>"}]}' data-mw='{"errors":[{"key":"missing-image","message":"This image does not exist."}],"caption":"&amp;lt;script>&amp;lt;/script>"}'><a href="./File:Nonexistent" data-parsoid='{"a":{"href":"./File:Nonexistent"},"sa":{}}'><img resource="./File:Nonexistent" src="./Special:FilePath/Nonexistent" height="100" width="100" data-parsoid='{"a":{"resource":"./File:Nonexistent","height":"100","width":"100"},"sa":{"resource":"File:Nonexistent"}}'/></a></span>
+<span class="mw-default-size" typeof="mw:Error mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&amp;lt;"}]}' data-mw='{"errors":[{"key":"missing-image","message":"This image does not exist."}],"caption":"&lt;span typeof=\"mw:Entity\" data-parsoid=&#39;{\"src\":\"&amp;amp;lt;\",\"srcContent\":\"&amp;lt;\",\"dsr\":[107,111,null,null]}&#39;>&amp;lt;&lt;/span>"}'><a href="./File:Nonexistent" data-parsoid='{"a":{"href":"./File:Nonexistent"},"sa":{}}'><img resource="./File:Nonexistent" src="./Special:FilePath/Nonexistent" height="220" width="220" data-parsoid='{"a":{"resource":"./File:Nonexistent","height":"220","width":"220"},"sa":{"resource":"File:Nonexistent"}}'/></a></span>
+<span class="mw-default-size" typeof="mw:Error mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"a&lt;i>b&lt;/i>c"}]}' data-mw='{"errors":[{"key":"missing-image","message":"This image does not exist."}],"caption":"a&lt;i data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[134,142,3,4]}&#39;>b&lt;/i>c"}'><a href="./File:Nonexistent" data-parsoid='{"a":{"href":"./File:Nonexistent"},"sa":{}}'><img resource="./File:Nonexistent" src="./Special:FilePath/Nonexistent" height="220" width="220" data-parsoid='{"a":{"resource":"./File:Nonexistent","height":"220","width":"220"},"sa":{"resource":"File:Nonexistent"}}'/></a></span></p>
 !! end
 
 !! test
@@ -8432,7 +8451,7 @@ Blah blah blah
 !! wikitext
 #REDIRECT [[{{echo|Foo}}bar]]
 !! html/parsoid
-<link typeof="mw:ExpandedAttrs" rel="mw:PageProp/redirect" href="./Foobar" data-mw='{"attribs":[[{"txt":"href"},{"html":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=\"{&amp;quot;pi&amp;quot;:[[{&amp;quot;k&amp;quot;:&amp;quot;1&amp;quot;}]],&amp;quot;dsr&amp;quot;:[12,24,null,null]}\" data-mw=\"{&amp;quot;parts&amp;quot;:[{&amp;quot;template&amp;quot;:{&amp;quot;target&amp;quot;:{&amp;quot;wt&amp;quot;:&amp;quot;echo&amp;quot;,&amp;quot;href&amp;quot;:&amp;quot;./Template:Echo&amp;quot;},&amp;quot;params&amp;quot;:{&amp;quot;1&amp;quot;:{&amp;quot;wt&amp;quot;:&amp;quot;Foo&amp;quot;}},&amp;quot;i&amp;quot;:0}}]}\">Foo&lt;/span>bar"}]]}'/>
+<link about="#mwt2" typeof="mw:ExpandedAttrs" rel="mw:PageProp/redirect" href="./Foobar" data-parsoid='{"a":{"href":"./Foobar"},"sa":{"href":"{{echo|Foo}}bar"}}' data-mw='{"attribs":[[{"txt":"href"},{"html":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[{\"k\":\"1\"}]],\"dsr\":[12,24,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"Foo\"}},\"i\":0}}]}&#39;>Foo&lt;/span>bar"}]]}'/>
 !! end
 
 !! test
@@ -9912,7 +9931,7 @@ Parsoid: Page property magic word with magic word contents
 !! wikitext
 {{DISPLAYTITLE:''{{PAGENAME}}''}}
 !! html/parsoid
-<meta property="mw:PageProp/displaytitle" content="Main Page" about="#mwt2" typeof="mw:ExpandedAttrs" data-mw='{"attribs":[[{"txt":"content"},{"html":"&lt;i data-parsoid=\"{&amp;quot;dsr&amp;quot;:[15,31,2,2]}\">&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=\"{&amp;quot;pi&amp;quot;:[[]],&amp;quot;dsr&amp;quot;:[17,29,null,null]}\" data-mw=\"{&amp;quot;parts&amp;quot;:[{&amp;quot;template&amp;quot;:{&amp;quot;target&amp;quot;:{&amp;quot;wt&amp;quot;:&amp;quot;PAGENAME&amp;quot;,&amp;quot;function&amp;quot;:&amp;quot;pagename&amp;quot;},&amp;quot;params&amp;quot;:{},&amp;quot;i&amp;quot;:0}}]}\">Main Page&lt;/span>&lt;/i>"}]]}'/>
+<meta property="mw:PageProp/displaytitle" content="Main Page" about="#mwt2" typeof="mw:ExpandedAttrs" data-parsoid='{"src":"{{DISPLAYTITLE:&#39;&#39;{{PAGENAME}}&#39;&#39;}}"}' data-mw='{"attribs":[[{"txt":"content"},{"html":"&lt;i data-parsoid=&#39;{\"dsr\":[15,31,2,2]}&#39;>&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[]],\"dsr\":[17,29,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"PAGENAME\",\"function\":\"pagename\"},\"params\":{},\"i\":0}}]}&#39;>Main Page&lt;/span>&lt;/i>"}]]}'/>
 !! end
 
 !! test
@@ -9920,7 +9939,7 @@ Parsoid: Template-generated DISPLAYTITLE
 !! wikitext
 {{{{echo|DISPLAYTITLE}}:Foo}}
 !! html/parsoid
-<meta property="mw:PageProp/displaytitle" content="Foo" about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"dsr":[0,29,null,null],"pi":[[]]}' data-mw='{"parts":[{"template":{"target":{"wt":"{{echo|DISPLAYTITLE}}:Foo"},"params":{},"i":0}}]}'/>
+<meta property="mw:PageProp/displaytitle" content="Foo" about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"pi":[[]]}' data-mw='{"parts":[{"template":{"target":{"wt":"{{echo|DISPLAYTITLE}}:Foo"},"params":{},"i":0}}]}'/>
 !! end
 
 !! test
@@ -11211,10 +11230,9 @@ parsoid=wt2html
 |c
 |}
 !!html/parsoid
-<meta typeof="mw:Includes/IncludeOnly"/><meta typeof="mw:Includes/IncludeOnly/End"/><table about="#mwt2" typeof="mw:ExpandedAttrs" data-mw='{"attribs":[[{"txt":"{{{b}}}","html":"&lt;span about=\"#mwt1\" typeof=\"mw:Param\" data-parsoid=\"{&amp;quot;dsr&amp;quot;:[31,38,null,null],&amp;quot;src&amp;quot;:&amp;quot;{{{b}}}&amp;quot;}\">{{{b}}}&lt;/span>"},{"html":""}]]}' data-parsoid='{"a":{"{{{b}}}":null},"sa":{"{{{b}}}":""}}'>
+<meta typeof="mw:Includes/IncludeOnly" data-parsoid='{"src":"&lt;includeonly>a&lt;/includeonly>"}'/><meta typeof="mw:Includes/IncludeOnly/End" data-parsoid='{"src":""}'/><table about="#mwt2" typeof="mw:ExpandedAttrs" data-parsoid='{"a":{"{{{b}}}":null},"sa":{"{{{b}}}":""}}' data-mw='{"attribs":[[{"txt":"{{{b}}}","html":"&lt;span about=\"#mwt1\" typeof=\"mw:Param\" data-parsoid=&#39;{\"dsr\":[31,38,null,null],\"src\":\"{{{b}}}\"}&#39;>{{{b}}}&lt;/span>"},{"html":""}]]}'>
 <tbody><tr><td>c</td></tr>
 </tbody></table>
-
 !!end
 
 ###
@@ -11572,7 +11590,7 @@ Templates: Support for templates generating attributes and content
 <div style="background:#f9f9f9;">foo</div>
 
 !! html/parsoid
-<div style="background:#f9f9f9;" about="#mwt3" typeof="mw:ExpandedAttrs" data-parsoid='{"stx":"html"}' data-mw='{"attribs":[[{"txt":"style","html":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=\"{&amp;quot;pi&amp;quot;:[[{&amp;quot;k&amp;quot;:&amp;quot;1&amp;quot;}]],&amp;quot;dsr&amp;quot;:[5,49,null,null]}\" data-mw=\"{&amp;quot;parts&amp;quot;:[{&amp;quot;template&amp;quot;:{&amp;quot;target&amp;quot;:{&amp;quot;wt&amp;quot;:&amp;quot;echo&amp;quot;,&amp;quot;href&amp;quot;:&amp;quot;./Template:Echo&amp;quot;},&amp;quot;params&amp;quot;:{&amp;quot;1&amp;quot;:{&amp;quot;wt&amp;quot;:&amp;quot;style{{=}}\\&amp;quot;background:&amp;amp;#35;f9f9f9;\\&amp;quot;&amp;quot;}},&amp;quot;i&amp;quot;:0}}]}\">style&lt;/span>&lt;span typeof=\"mw:Nowiki\" about=\"#mwt1\" data-parsoid=\"{}\">=&lt;/span>&lt;span about=\"#mwt1\" data-parsoid=\"{}\">\"background:&lt;/span>&lt;span typeof=\"mw:Entity\" about=\"#mwt1\" data-parsoid=\"{&amp;quot;src&amp;quot;:&amp;quot;&amp;amp;#35;&amp;quot;,&amp;quot;srcContent&amp;quot;:&amp;quot;#&amp;quot;}\">#&lt;/span>&lt;span about=\"#mwt1\" data-parsoid=\"{}\">f9f9f9;\"&lt;/span>"},{"html":""}]]}'>foo</div>
+<div style="background:#f9f9f9;" about="#mwt3" typeof="mw:ExpandedAttrs" data-parsoid='{"stx":"html"}' data-mw='{"attribs":[[{"txt":"style","html":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[{\"k\":\"1\"}]],\"dsr\":[5,49,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"style{{=}}\\\"background:&amp;amp;#35;f9f9f9;\\\"\"}},\"i\":0}}]}&#39;>style&lt;/span>&lt;span typeof=\"mw:Nowiki\" about=\"#mwt1\" data-parsoid=\"{}\">=&lt;/span>&lt;span about=\"#mwt1\" data-parsoid=\"{}\">\"background:&lt;/span>&lt;span typeof=\"mw:Entity\" about=\"#mwt1\" data-parsoid=&#39;{\"src\":\"&amp;amp;#35;\",\"srcContent\":\"#\"}&#39;>#&lt;/span>&lt;span about=\"#mwt1\" data-parsoid=\"{}\">f9f9f9;\"&lt;/span>"},{"html":""}]]}'>foo</div>
 !! end
 
 !! test
@@ -12931,7 +12949,7 @@ parsoid=wt2html,wt2wt,html2html
 <div class="thumb tright"><div class="thumbinner" style="width:139px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/137px-Foobar.jpg" width="137" height="16" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/206px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/274px-Foobar.jpg 2x" /></a>  <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>This is a caption</div></div></div>
 
 !! html/parsoid
-<figure typeof="mw:Image/Thumb mw:ExpandedAttrs" about="#mwt2" data-mw='{"attribs":[["thumbnail",{"html":"thumb"}],["width",{"html":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=\"{&amp;quot;pi&amp;quot;:[[{&amp;quot;k&amp;quot;:&amp;quot;1&amp;quot;}]],&amp;quot;dsr&amp;quot;:[24,38,null,null]}\" data-mw=\"{&amp;quot;parts&amp;quot;:[{&amp;quot;template&amp;quot;:{&amp;quot;target&amp;quot;:{&amp;quot;wt&amp;quot;:&amp;quot;echo&amp;quot;,&amp;quot;href&amp;quot;:&amp;quot;./Template:Echo&amp;quot;},&amp;quot;params&amp;quot;:{&amp;quot;1&amp;quot;:{&amp;quot;wt&amp;quot;:&amp;quot;137px&amp;quot;}},&amp;quot;i&amp;quot;:0}}]}\">137px&lt;/span>"}]]}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/137px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="16" width="137"/></a><figcaption>This is a caption</figcaption></figure>
+<figure typeof="mw:Image/Thumb mw:ExpandedAttrs" about="#mwt2" data-parsoid='{"optList":[{"ck":"thumbnail","ak":"thumb"},{"ck":"width","ak":"{{echo|137px}}"},{"ck":"caption","ak":"This is a caption"}]}' data-mw='{"attribs":[["thumbnail",{"html":"thumb"}],["width",{"html":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[{\"k\":\"1\"}]],\"dsr\":[24,38,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"137px\"}},\"i\":0}}]}&#39;>137px&lt;/span>"}]]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/137px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="16" width="137" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"16","width":"137"},"sa":{"resource":"File:Foobar.jpg"}}'/></a><figcaption>This is a caption</figcaption></figure>
 !! end
 
 !! test
@@ -12942,7 +12960,7 @@ parsoid=wt2html,wt2wt,html2html
 <div class="thumb tright"><div class="thumbinner" style="width:139px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/137px-Foobar.jpg" width="137" height="16" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/206px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/274px-Foobar.jpg 2x" /></a>  <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>This is a caption</div></div></div>
 
 !! html/parsoid
-<figure typeof="mw:Image/Thumb mw:ExpandedAttrs" about="#mwt3" data-mw='{"attribs":[["thumbnail",{"html":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=\"{&amp;quot;pi&amp;quot;:[[{&amp;quot;k&amp;quot;:&amp;quot;1&amp;quot;}]],&amp;quot;dsr&amp;quot;:[18,32,null,null]}\" data-mw=\"{&amp;quot;parts&amp;quot;:[{&amp;quot;template&amp;quot;:{&amp;quot;target&amp;quot;:{&amp;quot;wt&amp;quot;:&amp;quot;echo&amp;quot;,&amp;quot;href&amp;quot;:&amp;quot;./Template:Echo&amp;quot;},&amp;quot;params&amp;quot;:{&amp;quot;1&amp;quot;:{&amp;quot;wt&amp;quot;:&amp;quot;thumb&amp;quot;}},&amp;quot;i&amp;quot;:0}}]}\">thumb&lt;/span>"}],["width",{"html":"&lt;span about=\"#mwt2\" typeof=\"mw:Transclusion\" data-parsoid=\"{&amp;quot;pi&amp;quot;:[[{&amp;quot;k&amp;quot;:&amp;quot;1&amp;quot;}]],&amp;quot;dsr&amp;quot;:[33,47,null,null]}\" data-mw=\"{&amp;quot;parts&amp;quot;:[{&amp;quot;template&amp;quot;:{&amp;quot;target&amp;quot;:{&amp;quot;wt&amp;quot;:&amp;quot;echo&amp;quot;,&amp;quot;href&amp;quot;:&amp;quot;./Template:Echo&amp;quot;},&amp;quot;params&amp;quot;:{&amp;quot;1&amp;quot;:{&amp;quot;wt&amp;quot;:&amp;quot;137px&amp;quot;}},&amp;quot;i&amp;quot;:0}}]}\">137px&lt;/span>"}]]}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/137px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="16" width="137"/></a><figcaption>This is a caption</figcaption></figure>
+<figure typeof="mw:Image/Thumb mw:ExpandedAttrs" about="#mwt3" data-parsoid='{"optList":[{"ck":"thumbnail","ak":"{{echo|thumb}}"},{"ck":"width","ak":"{{echo|137px}}"},{"ck":"caption","ak":"This is a caption"}]}' data-mw='{"attribs":[["thumbnail",{"html":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[{\"k\":\"1\"}]],\"dsr\":[18,32,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"thumb\"}},\"i\":0}}]}&#39;>thumb&lt;/span>"}],["width",{"html":"&lt;span about=\"#mwt2\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[{\"k\":\"1\"}]],\"dsr\":[33,47,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"137px\"}},\"i\":0}}]}&#39;>137px&lt;/span>"}]]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/137px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="16" width="137" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"16","width":"137"},"sa":{"resource":"File:Foobar.jpg"}}'/></a><figcaption>This is a caption</figcaption></figure>
 !! end
 
 !! test
@@ -12953,7 +12971,7 @@ parsoid=wt2html,wt2wt,html2html
 <p><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" width="50" height="6" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/75px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/100px-Foobar.jpg 2x" /></a>
 </p>
 !! html/parsoid
-<p><span typeof="mw:Image mw:ExpandedAttrs" about="#mwt2" data-parsoid='{"optList":[{"ck":"width","ak":"{{echo|50px}}"}]}' data-mw='{"attribs":[["width",{"html":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=\"{&amp;quot;pi&amp;quot;:[[{&amp;quot;k&amp;quot;:&amp;quot;1&amp;quot;}]],&amp;quot;dsr&amp;quot;:[18,31,null,null]}\" data-mw=\"{&amp;quot;parts&amp;quot;:[{&amp;quot;template&amp;quot;:{&amp;quot;target&amp;quot;:{&amp;quot;wt&amp;quot;:&amp;quot;echo&amp;quot;,&amp;quot;href&amp;quot;:&amp;quot;./Template:Echo&amp;quot;},&amp;quot;params&amp;quot;:{&amp;quot;1&amp;quot;:{&amp;quot;wt&amp;quot;:&amp;quot;50px&amp;quot;}},&amp;quot;i&amp;quot;:0}}]}\">50px&lt;/span>"}]]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50"/></a></span></p>
+<p><span typeof="mw:Image mw:ExpandedAttrs" about="#mwt2" data-parsoid='{"optList":[{"ck":"width","ak":"{{echo|50px}}"}]}' data-mw='{"attribs":[["width",{"html":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[{\"k\":\"1\"}]],\"dsr\":[18,31,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"50px\"}},\"i\":0}}]}&#39;>50px&lt;/span>"}]]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"6","width":"50"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
 !! end
 
 ## Parsoid does not provide editing support for images where templates produce multiple image attributes.
@@ -13323,8 +13341,9 @@ Image with wiki markup in implicit alt
 </p><p><a href="/wiki/File:Foobar.jpg" class="image"><img alt="testing bold in alt" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"testing &lt;b data-parsoid=\"{&amp;quot;dsr&amp;quot;:[27,37,3,3]}\">bold&lt;/b> in alt"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"Image:Foobar.jpg"}}'/></a></span></p>
-<p><span class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img alt="testing bold in alt" resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"alt":"testing bold in alt","resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"alt":"alt=testing &#39;&#39;&#39;bold&#39;&#39;&#39; in alt","resource":"Image:Foobar.jpg"}}'/></a></span></p>
+<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"testing &#39;&#39;&#39;bold&#39;&#39;&#39; in alt"}]}' data-mw='{"caption":"testing &lt;b data-parsoid=&#39;{\"dsr\":[27,37,3,3]}&#39;>bold&lt;/b> in alt"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"Image:Foobar.jpg"}}'/></a></span></p>
+
+<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"alt","ak":"alt=testing &#39;&#39;&#39;bold&#39;&#39;&#39; in alt"}]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img alt="testing bold in alt" resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"alt":"testing bold in alt","resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"alt":"alt=testing &#39;&#39;&#39;bold&#39;&#39;&#39; in alt","resource":"Image:Foobar.jpg"}}'/></a></span></p>
 !! end
 
 !! test
@@ -13335,7 +13354,7 @@ Alt image option should handle most kinds of wikitext without barfing
 <div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="This is a link and a bold template." src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a>  <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>This is the image caption</div></div></div>
 
 !! html/parsoid
-<figure class="mw-default-size" typeof="mw:Image/Thumb mw:ExpandedAttrs" about="#mwt2" data-parsoid='{"optList":[{"ck":"thumbnail","ak":"thumb"},{"ck":"caption","ak":"This is the image caption"},{"ck":"alt","ak":"alt=This is a [[link]] and a {{echo|&#39;&#39;bold template&#39;&#39;}}."}]}' data-mw='{"attribs":[["thumbnail",{"html":"thumb"}],["alt",{"html":"alt=This is a &lt;a rel=\"mw:WikiLink\" href=\"./Link\" title=\"Link\" data-parsoid=\"{&amp;quot;stx&amp;quot;:&amp;quot;simple&amp;quot;,&amp;quot;a&amp;quot;:{&amp;quot;href&amp;quot;:&amp;quot;./Link&amp;quot;},&amp;quot;sa&amp;quot;:{&amp;quot;href&amp;quot;:&amp;quot;link&amp;quot;},&amp;quot;dsr&amp;quot;:[65,73,2,2]}\">link&lt;/a> and a &lt;i about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=\"{&amp;quot;dsr&amp;quot;:[80,106,null,null],&amp;quot;pi&amp;quot;:[[{&amp;quot;k&amp;quot;:&amp;quot;1&amp;quot;}]]}\" data-mw=\"{&amp;quot;parts&amp;quot;:[{&amp;quot;template&amp;quot;:{&amp;quot;target&amp;quot;:{&amp;quot;wt&amp;quot;:&amp;quot;echo&amp;quot;,&amp;quot;href&amp;quot;:&amp;quot;./Template:Echo&amp;quot;},&amp;quot;params&amp;quot;:{&amp;quot;1&amp;quot;:{&amp;quot;wt&amp;quot;:&amp;quot;&#39;&#39;bold template&#39;&#39;&amp;quot;}},&amp;quot;i&amp;quot;:0}}]}\">bold template&lt;/i>."}]]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img alt="This is a link and a bold template." resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220" data-parsoid='{"a":{"alt":"This is a link and a bold template.","resource":"./File:Foobar.jpg","height":"25","width":"220"},"sa":{"alt":"alt=This is a [[link]] and a {{echo|&#39;&#39;bold template&#39;&#39;}}.","resource":"Image:Foobar.jpg"}}'/></a><figcaption>This is the image caption</figcaption></figure>
+<figure class="mw-default-size" typeof="mw:Image/Thumb mw:ExpandedAttrs" about="#mwt2" data-parsoid='{"optList":[{"ck":"thumbnail","ak":"thumb"},{"ck":"caption","ak":"This is the image caption"},{"ck":"alt","ak":"alt=This is a [[link]] and a {{echo|&#39;&#39;bold template&#39;&#39;}}."}]}' data-mw='{"attribs":[["thumbnail",{"html":"thumb"}],["alt",{"html":"alt=This is a &lt;a rel=\"mw:WikiLink\" href=\"./Link\" title=\"Link\" data-parsoid=&#39;{\"stx\":\"simple\",\"a\":{\"href\":\"./Link\"},\"sa\":{\"href\":\"link\"},\"dsr\":[65,73,2,2]}&#39;>link&lt;/a> and a &lt;i about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"dsr\":[80,106,null,null],\"pi\":[[{\"k\":\"1\"}]]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"&amp;#39;&amp;#39;bold template&amp;#39;&amp;#39;\"}},\"i\":0}}]}&#39;>bold template&lt;/i>."}]]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img alt="This is a link and a bold template." resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220" data-parsoid='{"a":{"alt":"This is a link and a bold template.","resource":"./File:Foobar.jpg","height":"25","width":"220"},"sa":{"alt":"alt=This is a [[link]] and a {{echo|&#39;&#39;bold template&#39;&#39;}}.","resource":"Image:Foobar.jpg"}}'/></a><figcaption>This is the image caption</figcaption></figure>
 !! end
 
 ###################
@@ -13562,7 +13581,7 @@ Frameless image caption with a free URL
 <p><a href="/wiki/File:Foobar.jpg" class="image" title="http://example.com"><img alt="http://example.com" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"&lt;a rel=\"mw:ExtLink\" href=\"http://example.com\" data-parsoid=\"{&amp;quot;stx&amp;quot;:&amp;quot;url&amp;quot;,&amp;quot;dsr&amp;quot;:[18,36,0,0]}\">http://example.com&lt;/a>"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span></p>
+<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"http://example.com"}]}' data-mw='{"caption":"&lt;a rel=\"mw:ExtLink\" href=\"http://example.com\" data-parsoid=&#39;{\"stx\":\"url\",\"dsr\":[18,36,0,0]}&#39;>http://example.com&lt;/a>"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
 !! end
 
 !! test
@@ -13672,7 +13691,7 @@ BUG 648: Frameless image caption with a link
 <p><a href="/wiki/File:Foobar.jpg" class="image" title="text with a link in it"><img alt="text with a link in it" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"text with a &lt;a rel=\"mw:WikiLink\" href=\"./Link\" title=\"Link\" data-parsoid=\"{&amp;quot;stx&amp;quot;:&amp;quot;simple&amp;quot;,&amp;quot;a&amp;quot;:{&amp;quot;href&amp;quot;:&amp;quot;./Link&amp;quot;},&amp;quot;sa&amp;quot;:{&amp;quot;href&amp;quot;:&amp;quot;link&amp;quot;},&amp;quot;dsr&amp;quot;:[30,38,2,2]}\">link&lt;/a> in it"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span></p>
+<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"text with a [[link]] in it"}]}' data-mw='{"caption":"text with a &lt;a rel=\"mw:WikiLink\" href=\"./Link\" title=\"Link\" data-parsoid=&#39;{\"stx\":\"simple\",\"a\":{\"href\":\"./Link\"},\"sa\":{\"href\":\"link\"},\"dsr\":[30,38,2,2]}&#39;>link&lt;/a> in it"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
 !! end
 
 !! test
@@ -13683,7 +13702,7 @@ BUG 648: Frameless image caption with a link (suffix)
 <p><a href="/wiki/File:Foobar.jpg" class="image" title="text with a linkfoo in it"><img alt="text with a linkfoo in it" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"text with a &lt;a rel=\"mw:WikiLink\" href=\"./Link\" title=\"Link\" data-parsoid=\"{&amp;quot;stx&amp;quot;:&amp;quot;simple&amp;quot;,&amp;quot;a&amp;quot;:{&amp;quot;href&amp;quot;:&amp;quot;./Link&amp;quot;},&amp;quot;sa&amp;quot;:{&amp;quot;href&amp;quot;:&amp;quot;link&amp;quot;},&amp;quot;dsr&amp;quot;:[30,41,2,5],&amp;quot;tail&amp;quot;:&amp;quot;foo&amp;quot;}\">linkfoo&lt;/a> in it"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span></p>
+<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"text with a [[link]]foo in it"}]}' data-mw='{"caption":"text with a &lt;a rel=\"mw:WikiLink\" href=\"./Link\" title=\"Link\" data-parsoid=&#39;{\"stx\":\"simple\",\"a\":{\"href\":\"./Link\"},\"sa\":{\"href\":\"link\"},\"dsr\":[30,41,2,5],\"tail\":\"foo\"}&#39;>linkfoo&lt;/a> in it"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
 !! end
 
 !! test
@@ -13694,7 +13713,7 @@ BUG 648: Frameless image caption with an interwiki link
 <p><a href="/wiki/File:Foobar.jpg" class="image" title="text with a MeatBall:Link in it"><img alt="text with a MeatBall:Link in it" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"text with a &lt;a rel=\"mw:ExtLink\" href=\"http://www.usemod.com/cgi-bin/mb.pl?Link\" title=\"meatball:Link\" data-parsoid=\"{&amp;quot;stx&amp;quot;:&amp;quot;simple&amp;quot;,&amp;quot;a&amp;quot;:{&amp;quot;href&amp;quot;:&amp;quot;http://www.usemod.com/cgi-bin/mb.pl?Link&amp;quot;},&amp;quot;sa&amp;quot;:{&amp;quot;href&amp;quot;:&amp;quot;MeatBall:Link&amp;quot;},&amp;quot;isIW&amp;quot;:true,&amp;quot;dsr&amp;quot;:[30,47,2,2]}\">MeatBall:Link&lt;/a> in it"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span></p>
+<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"text with a [[MeatBall:Link]] in it"}]}' data-mw='{"caption":"text with a &lt;a rel=\"mw:ExtLink\" href=\"http://www.usemod.com/cgi-bin/mb.pl?Link\" title=\"meatball:Link\" data-parsoid=&#39;{\"stx\":\"simple\",\"a\":{\"href\":\"http://www.usemod.com/cgi-bin/mb.pl?Link\"},\"sa\":{\"href\":\"MeatBall:Link\"},\"isIW\":true,\"dsr\":[30,47,2,2]}&#39;>MeatBall:Link&lt;/a> in it"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
 !! end
 
 !! test
@@ -13705,7 +13724,7 @@ BUG 648: Frameless image caption with a piped interwiki link
 <p><a href="/wiki/File:Foobar.jpg" class="image" title="text with a link in it"><img alt="text with a link in it" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"text with a &lt;a rel=\"mw:ExtLink\" href=\"http://www.usemod.com/cgi-bin/mb.pl?Link\" title=\"meatball:Link\" data-parsoid=\"{&amp;quot;stx&amp;quot;:&amp;quot;piped&amp;quot;,&amp;quot;a&amp;quot;:{&amp;quot;href&amp;quot;:&amp;quot;http://www.usemod.com/cgi-bin/mb.pl?Link&amp;quot;},&amp;quot;sa&amp;quot;:{&amp;quot;href&amp;quot;:&amp;quot;MeatBall:Link&amp;quot;},&amp;quot;isIW&amp;quot;:true,&amp;quot;dsr&amp;quot;:[30,52,16,2]}\">link&lt;/a> in it"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span></p>
+<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"text with a [[MeatBall:Link|link]] in it"}]}' data-mw='{"caption":"text with a &lt;a rel=\"mw:ExtLink\" href=\"http://www.usemod.com/cgi-bin/mb.pl?Link\" title=\"meatball:Link\" data-parsoid=&#39;{\"stx\":\"piped\",\"a\":{\"href\":\"http://www.usemod.com/cgi-bin/mb.pl?Link\"},\"sa\":{\"href\":\"MeatBall:Link\"},\"isIW\":true,\"dsr\":[30,52,16,2]}&#39;>link&lt;/a> in it"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
 !! end
 
 !! test
@@ -13713,7 +13732,7 @@ T107474: Frameless image caption with <nowiki>
 !! wikitext
 [[File:Foobar.jpg|<nowiki>text with a [[MeatBall:Link|link]] in it</nowiki>]]
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"&lt;span typeof=\"mw:Nowiki\" data-parsoid=\"{&amp;quot;dsr&amp;quot;:[18,75,8,9]}\">text with a [[MeatBall:Link|link]] in it&lt;/span>"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span></p>
+<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&lt;nowiki>text with a [[MeatBall:Link|link]] in it&lt;/nowiki>"}]}' data-mw='{"caption":"&lt;span typeof=\"mw:Nowiki\" data-parsoid=&#39;{\"dsr\":[18,75,8,9]}&#39;>text with a [[MeatBall:Link|link]] in it&lt;/span>"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
 !! end
 
 !! test
@@ -13724,7 +13743,7 @@ Escape HTML special chars in image alt text
 <p><a href="/wiki/File:Foobar.jpg" class="image" title="&amp; &lt; &gt; &quot;"><img alt="&amp; &lt; &gt; &quot;" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"&amp;amp; &amp;lt; &amp;gt; \""}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span></p>
+<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&amp; &lt; > \""}]}' data-mw='{"caption":"&amp;amp; &amp;lt; > \""}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
 !! end
 
 !! test
@@ -13735,7 +13754,7 @@ BUG 499: Alt text should have &#1234;, not &amp;1234;
 <p><a href="/wiki/File:Foobar.jpg" class="image" title="♀"><img alt="♀" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"&lt;span typeof=\"mw:Entity\" data-parsoid=\"{&amp;quot;src&amp;quot;:&amp;quot;&amp;amp;#9792;&amp;quot;,&amp;quot;srcContent&amp;quot;:&amp;quot;♀&amp;quot;,&amp;quot;dsr&amp;quot;:[18,25,null,null]}\">♀&lt;/span>"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span></p>
+<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&amp;#9792;"}]}' data-mw='{"caption":"&lt;span typeof=\"mw:Entity\" data-parsoid=&#39;{\"src\":\"&amp;amp;#9792;\",\"srcContent\":\"♀\",\"dsr\":[18,25,null,null]}&#39;>♀&lt;/span>"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
 !! end
 
 !! test
@@ -14073,7 +14092,7 @@ Parsoid-specific image handling - simple image with a formatted caption
 !! wikitext
 [[File:Foobar.jpg|<table><tr><td>a</td><td>b</td></tr><tr><td>c</td></tr></table>]]
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"&lt;table data-parsoid=\"{&amp;quot;stx&amp;quot;:&amp;quot;html&amp;quot;,&amp;quot;dsr&amp;quot;:[18,81,7,8]}\">&lt;tbody data-parsoid=\"{&amp;quot;dsr&amp;quot;:[25,73,0,0]}\">&lt;tr data-parsoid=\"{&amp;quot;stx&amp;quot;:&amp;quot;html&amp;quot;,&amp;quot;dsr&amp;quot;:[25,54,4,5]}\">&lt;td data-parsoid=\"{&amp;quot;stx&amp;quot;:&amp;quot;html&amp;quot;,&amp;quot;dsr&amp;quot;:[29,39,4,5]}\">a&lt;/td>&lt;td data-parsoid=\"{&amp;quot;stx&amp;quot;:&amp;quot;html&amp;quot;,&amp;quot;dsr&amp;quot;:[39,49,4,5]}\">b&lt;/td>&lt;/tr>&lt;tr data-parsoid=\"{&amp;quot;stx&amp;quot;:&amp;quot;html&amp;quot;,&amp;quot;dsr&amp;quot;:[54,73,4,5]}\">&lt;td data-parsoid=\"{&amp;quot;stx&amp;quot;:&amp;quot;html&amp;quot;,&amp;quot;dsr&amp;quot;:[58,68,4,5]}\">c&lt;/td>&lt;/tr>&lt;/tbody>&lt;/table>"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span></p>
+<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&lt;table>&lt;tr>&lt;td>a&lt;/td>&lt;td>b&lt;/td>&lt;/tr>&lt;tr>&lt;td>c&lt;/td>&lt;/tr>&lt;/table>"}]}' data-mw='{"caption":"&lt;table data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[18,81,7,8]}&#39;>&lt;tbody data-parsoid=&#39;{\"dsr\":[25,73,0,0]}&#39;>&lt;tr data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[25,54,4,5]}&#39;>&lt;td data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[29,39,4,5]}&#39;>a&lt;/td>&lt;td data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[39,49,4,5]}&#39;>b&lt;/td>&lt;/tr>&lt;tr data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[54,73,4,5]}&#39;>&lt;td data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[58,68,4,5]}&#39;>c&lt;/td>&lt;/tr>&lt;/tbody>&lt;/table>"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
 !! end
 
 !! test
@@ -14167,7 +14186,7 @@ T93580: 2. <ref> inside inline images
 
 <references />
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"Undisplayed caption in inline image with ref: &lt;ref>foo&lt;/ref>"}]}' data-mw='{"caption":"Undisplayed caption in inline image with ref: &lt;span about=\"#mwt2\" class=\"mw-ref\" id=\"cite_ref-1\" rel=\"dc:references\" typeof=\"mw:Extension/ref\" data-parsoid=\"{&amp;quot;dsr&amp;quot;:[64,78,5,6]}\" data-mw=\"{&amp;quot;name&amp;quot;:&amp;quot;ref&amp;quot;,&amp;quot;body&amp;quot;:{&amp;quot;id&amp;quot;:&amp;quot;mw-reference-text-cite_note-1&amp;quot;},&amp;quot;attrs&amp;quot;:{}}\">&lt;a href=\"#cite_note-1\" style=\"counter-reset: mw-Ref 1;\">&lt;span class=\"mw-reflink-text\">[1]&lt;/span>&lt;/a>&lt;/span>&lt;meta typeof=\"mw:Extension/ref/Marker\" about=\"#mwt2\" data-parsoid=\"{&amp;quot;group&amp;quot;:&amp;quot;&amp;quot;,&amp;quot;name&amp;quot;:&amp;quot;&amp;quot;,&amp;quot;content&amp;quot;:&amp;quot;foo&amp;quot;,&amp;quot;hasRefInRef&amp;quot;:false,&amp;quot;dsr&amp;quot;:[64,78,5,6],&amp;quot;tmp&amp;quot;:{}}\" data-mw=\"{}\">"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
+<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"Undisplayed caption in inline image with ref: &lt;ref>foo&lt;/ref>"}]}' data-mw='{"caption":"Undisplayed caption in inline image with ref: &lt;span about=\"#mwt2\" class=\"mw-ref\" id=\"cite_ref-1\" rel=\"dc:references\" typeof=\"mw:Extension/ref\" data-parsoid=&#39;{\"dsr\":[64,78,5,6]}&#39; data-mw=&#39;{\"name\":\"ref\",\"body\":{\"id\":\"mw-reference-text-cite_note-1\"},\"attrs\":{}}&#39;>&lt;a href=\"#cite_note-1\" style=\"counter-reset: mw-Ref 1;\" data-parsoid=\"{}\">&lt;span class=\"mw-reflink-text\" data-parsoid=\"{}\">[1]&lt;/span>&lt;/a>&lt;/span>&lt;meta typeof=\"mw:Extension/ref/Marker\" about=\"#mwt2\" data-parsoid=&#39;{\"group\":\"\",\"name\":\"\",\"content\":\"foo\",\"hasRefInRef\":false,\"dsr\":[64,78,5,6]}&#39;/>"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
 
 <ol class="mw-references" typeof="mw:Extension/references" about="#mwt4" data-mw='{"name":"references","attrs":{}}'><li about="#cite_note-1" id="cite_note-1"><a href="#cite_ref-1" rel="mw:referencedBy"><span class="mw-linkback-text">↑ </span></a> <span id="mw-reference-text-cite_note-1" class="mw-reference-text" data-parsoid="{}">foo</span></li></ol>
 !! end
@@ -14179,7 +14198,7 @@ T93580: 3. Templated <ref> inside inline images
 
 <references />
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"Undisplayed caption in inline image with ref: {{echo|&lt;ref>{{echo|foo}}&lt;/ref>}}"}]}' data-mw='{"caption":"Undisplayed caption in inline image with ref: &lt;span about=\"#mwt2\" class=\"mw-ref\" id=\"cite_ref-1\" rel=\"dc:references\" typeof=\"mw:Transclusion  mw:Extension/ref\" data-parsoid=\"{&amp;quot;dsr&amp;quot;:[64,96,null,null],&amp;quot;pi&amp;quot;:[[{&amp;quot;k&amp;quot;:&amp;quot;1&amp;quot;}]]}\" data-mw=\"{&amp;quot;parts&amp;quot;:[{&amp;quot;template&amp;quot;:{&amp;quot;target&amp;quot;:{&amp;quot;wt&amp;quot;:&amp;quot;echo&amp;quot;,&amp;quot;href&amp;quot;:&amp;quot;./Template:Echo&amp;quot;},&amp;quot;params&amp;quot;:{&amp;quot;1&amp;quot;:{&amp;quot;wt&amp;quot;:&amp;quot;&lt;ref>{{echo|foo}}&lt;/ref>&amp;quot;}},&amp;quot;i&amp;quot;:0}}]}\">&lt;a href=\"#cite_note-1\" style=\"counter-reset: mw-Ref 1;\">&lt;span class=\"mw-reflink-text\">[1]&lt;/span>&lt;/a>&lt;/span>&lt;meta typeof=\"mw:Transclusion mw:Extension/ref/Marker\" about=\"#mwt2\" data-parsoid=\"{&amp;quot;group&amp;quot;:&amp;quot;&amp;quot;,&amp;quot;name&amp;quot;:&amp;quot;&amp;quot;,&amp;quot;content&amp;quot;:&amp;quot;foo&amp;quot;,&amp;quot;hasRefInRef&amp;quot;:false,&amp;quot;dsr&amp;quot;:[64,96,null,null],&amp;quot;pi&amp;quot;:[[{&amp;quot;k&amp;quot;:&amp;quot;1&amp;quot;}]],&amp;quot;tmp&amp;quot;:{}}\" data-mw=\"{&amp;quot;parts&amp;quot;:[{&amp;quot;template&amp;quot;:{&amp;quot;target&amp;quot;:{&amp;quot;wt&amp;quot;:&amp;quot;echo&amp;quot;,&amp;quot;href&amp;quot;:&amp;quot;./Template:Echo&amp;quot;},&amp;quot;params&amp;quot;:{&amp;quot;1&amp;quot;:{&amp;quot;wt&amp;quot;:&amp;quot;&lt;ref>{{echo|foo}}&lt;/ref>&amp;quot;}},&amp;quot;i&amp;quot;:0}}]}\">"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
+<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"Undisplayed caption in inline image with ref: {{echo|&lt;ref>{{echo|foo}}&lt;/ref>}}"}]}' data-mw='{"caption":"Undisplayed caption in inline image with ref: &lt;span about=\"#mwt2\" class=\"mw-ref\" id=\"cite_ref-1\" rel=\"dc:references\" typeof=\"mw:Transclusion  mw:Extension/ref\" data-parsoid=&#39;{\"dsr\":[64,96,null,null],\"pi\":[[{\"k\":\"1\"}]]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"&amp;lt;ref>{{echo|foo}}&amp;lt;/ref>\"}},\"i\":0}}]}&#39;>&lt;a href=\"#cite_note-1\" style=\"counter-reset: mw-Ref 1;\" data-parsoid=\"{}\">&lt;span class=\"mw-reflink-text\" data-parsoid=\"{}\">[1]&lt;/span>&lt;/a>&lt;/span>&lt;meta typeof=\"mw:Transclusion mw:Extension/ref/Marker\" about=\"#mwt2\" data-parsoid=&#39;{\"group\":\"\",\"name\":\"\",\"content\":\"foo\",\"hasRefInRef\":false,\"dsr\":[64,96,null,null],\"pi\":[[{\"k\":\"1\"}]]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"&amp;lt;ref>{{echo|foo}}&amp;lt;/ref>\"}},\"i\":0}}]}&#39;/>"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
 
 <ol class="mw-references" typeof="mw:Extension/references" about="#mwt6" data-mw='{"name":"references","attrs":{}}'><li about="#cite_note-1" id="cite_note-1"><a href="#cite_ref-1" rel="mw:referencedBy"><span class="mw-linkback-text">↑ </span></a> <span id="mw-reference-text-cite_note-1" class="mw-reference-text" data-parsoid="{}">foo</span></li></ol>
 !! end
@@ -14781,7 +14800,7 @@ Parsoid: Defaultsort (template-generated)
 !! wikitext
 {{{{echo|DEFAULTSORT}}:Foo}}
 !! html/parsoid
-<meta property="mw:PageProp/categorydefaultsort" content="Foo" about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"dsr":[0,28,null,null],"pi":[[]]}' data-mw='{"parts":[{"template":{"target":{"wt":"{{echo|DEFAULTSORT}}:Foo"},"params":{},"i":0}}]}'/>
+<meta property="mw:PageProp/categorydefaultsort" content="Foo" about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"pi":[[]]}' data-mw='{"parts":[{"template":{"target":{"wt":"{{echo|DEFAULTSORT}}:Foo"},"params":{},"i":0}}]}'/>
 !! end
 
 ###
@@ -16010,7 +16029,7 @@ Bug 2304: HTML attribute safety (dangerous template; 2309)
 <div title=""></div>
 
 !! html/parsoid
-<div title='" onmouseover="alert(document.cookie)' about="#mwt2" typeof="mw:ExpandedAttrs" data-parsoid='{"stx":"html","a":{"title":"\" onmouseover=\"alert(document.cookie)"},"sa":{"title":"{{dangerous attribute}}"}}' data-mw='{"attribs":[[{"txt":"title"},{"html":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=\"{&amp;quot;pi&amp;quot;:[[]],&amp;quot;dsr&amp;quot;:[12,35,null,null]}\" data-mw=\"{&amp;quot;parts&amp;quot;:[{&amp;quot;template&amp;quot;:{&amp;quot;target&amp;quot;:{&amp;quot;wt&amp;quot;:&amp;quot;dangerous attribute&amp;quot;,&amp;quot;href&amp;quot;:&amp;quot;./Template:Dangerous_attribute&amp;quot;},&amp;quot;params&amp;quot;:{},&amp;quot;i&amp;quot;:0}}]}\">\" onmouseover=\"alert(document.cookie)&lt;/span>"}]]}'></div>
+<div title='" onmouseover="alert(document.cookie)' about="#mwt2" typeof="mw:ExpandedAttrs" data-parsoid='{"stx":"html","a":{"title":"\" onmouseover=\"alert(document.cookie)"},"sa":{"title":"{{dangerous attribute}}"}}' data-mw='{"attribs":[[{"txt":"title"},{"html":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[]],\"dsr\":[12,35,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"dangerous attribute\",\"href\":\"./Template:Dangerous_attribute\"},\"params\":{},\"i\":0}}]}&#39;>\" onmouseover=\"alert(document.cookie)&lt;/span>"}]]}'></div>
 !! end
 
 !! test
@@ -16549,9 +16568,12 @@ array (
 <pre typeof="mw:Extension/tag" data-mw='{"name":"tag","attrs":{"foo":"bar"},"body":null}' data-parsoid='{}' about="#mwt2"></pre>text
 !! end
 
-# </tag> should be output literally since there is no matching tag that begins it
+## </tag> should be output literally since there is no matching tag that begins it
+## Don't expect parsoid to rt this form.
 !! test
 Parser hook: basic arguments using terminated empty elements (bug 2374)
+!! options
+parsoid=wt2html
 !! wikitext
 <tag width=200 height = "100" depth = '50' square/>
 other stuff
@@ -16569,6 +16591,28 @@ array (
 <p>other stuff
 &lt;/tag&gt;
 </p>
+!! html/parsoid
+<pre typeof="mw:Extension/tag" data-mw='{"name":"tag","attrs":{"width":"200","height":"100","depth":"50","square":""},"body":null}' about="#mwt2"></pre><p>other stuff
+&lt;/tag></p>
+!! end
+
+## Don't expect parsoid to rt this form.
+!! test
+Parser hook: Don't allow unclosed extension tags
+!! options
+parsoid=wt2html
+!! wikitext
+test <tag>123
+
+this is a '''test'''
+!! html/php
+<p>test &lt;tag&gt;123
+</p><p>this is a <b>test</b>
+</p>
+!! html/parsoid
+<p>test &lt;tag>123</p>
+
+<p>this is a <b>test</b></p>
 !! end
 
 ###
@@ -16891,7 +16935,7 @@ HTML nested bullet list, closed tags (bug 5497)
 </ul>
 </li>
 </ul>
-!! html
+!! html/php
 <ul>
 <li>One</li>
 <li>Two:
@@ -16902,6 +16946,16 @@ HTML nested bullet list, closed tags (bug 5497)
 </li>
 </ul>
 
+!! html/parsoid
+<ul data-parsoid='{"stx":"html"}'>
+<li data-parsoid='{"stx":"html"}'>One</li>
+<li data-parsoid='{"stx":"html"}'>Two:
+<ul data-parsoid='{"stx":"html"}'>
+<li data-parsoid='{"stx":"html"}'>Sub-one</li>
+<li data-parsoid='{"stx":"html"}'>Sub-two</li>
+</ul>
+</li>
+</ul>
 !! end
 
 !! test
@@ -17271,7 +17325,7 @@ Fuzz testing: image with bogus manual thumbnail
 <div class="thumb tright"><div class="thumbinner" style="width:182px;">Error creating thumbnail:   <div class="thumbcaption"></div></div></div>
 
 !! html/parsoid
-<figure class="mw-default-size" typeof="mw:Error mw:Image/Thumb" data-parsoid='{"optList":[{"ck":"manualthumb","ak":"thumbnail= "}],"dsr":[0,32,2,2]}' data-mw='{"errors":[{"key":"missing-thumbnail","message":"This thumbnail does not exist.","params":{"name":""}}],"thumb":""}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{},"dsr":[2,30,null,null]}'><img resource="./File:Foobar.jpg" src="./Special:FilePath/" height="220" width="220" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"220"},"sa":{"resource":"Image:foobar.jpg"}}'/></a></figure>
+<figure class="mw-default-size" typeof="mw:Error mw:Image/Thumb" data-parsoid='{"optList":[{"ck":"manualthumb","ak":"thumbnail= "}]}' data-mw='{"errors":[{"key":"missing-thumbnail","message":"This thumbnail does not exist.","params":{"name":""}}],"thumb":""}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="./Special:FilePath/" height="220" width="220" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"220"},"sa":{"resource":"Image:foobar.jpg"}}'/></a></figure>
 !!end
 
 !! test
@@ -20592,6 +20646,7 @@ this is not the the title
 !! html/php
 Screen
 <p>this is not the the title
+<span class="error"><strong>Warning:</strong> Display title "whatever" was ignored since it is not equivalent to the page's actual title.</span>
 </p>
 !! end
 
@@ -20855,9 +20910,9 @@ percent-encoding and + signs in internal links (Bug 26410)
 <a href="/index.php?title=3E&amp;action=edit&amp;redlink=1" class="new" title="3E (page does not exist)">3E</a> <a href="/index.php?title=3E%2B&amp;action=edit&amp;redlink=1" class="new" title="3E+ (page does not exist)">3E+</a>
 </p>
 !! html/parsoid
-<p><a rel="mw:WikiLink" href="./User:+%25" title="User:+%">User:+%</a> <a rel="mw:WikiLink" href="Page+title%25" title="Page+title%">Page+title%</a>
-<a rel="mw:WikiLink" href="%25+" title="%+">%+</a> <a rel="mw:WikiLink" href="%25+" title="%+">%20</a> <a rel="mw:WikiLink" href="%25+" title="%+">%+ </a> <a rel="mw:WikiLink" href="%25+r" title="%+r">%+r</a>
-<a rel="mw:WikiLink" href="%25" title="%">%</a> <a rel="mw:WikiLink" href="+" title="+">+</a> <span class="mw-default-size" typeof="mw:Error mw:Image" data-parsoid='{"optList":[{"ck":"bogus","ak":"foo"},{"ck":"caption","ak":"[[bar]]"}]}' data-mw='{"errors":[{"key":"missing-image","message":"This image does not exist."}],"caption":"&lt;a rel=\"mw:WikiLink\" href=\"./Bar\" title=\"Bar\" data-parsoid=\"{&amp;quot;stx&amp;quot;:&amp;quot;simple&amp;quot;,&amp;quot;a&amp;quot;:{&amp;quot;href&amp;quot;:&amp;quot;./Bar&amp;quot;},&amp;quot;sa&amp;quot;:{&amp;quot;href&amp;quot;:&amp;quot;bar&amp;quot;},&amp;quot;dsr&amp;quot;:[94,101,2,2]}\">bar&lt;/a>"}'><a href="./File:%25+abc9"><img resource="./File:%25+abc9" src="./Special:FilePath/%25+abc9" height="220" width="220" data-parsoid='{"a":{"resource":"./File:%25+abc9","height":"220","width":"220"},"sa":{"resource":"File:%+abc%39"}}'/></a></span>
+<p><a rel="mw:WikiLink" href="./User:+%25" title="User:+%" data-parsoid='{"stx":"simple","a":{"href":"./User:+%25"},"sa":{"href":"User:+%"}}'>User:+%</a> <a rel="mw:WikiLink" href="./Page+title%25" title="Page+title%" data-parsoid='{"stx":"simple","a":{"href":"./Page+title%25"},"sa":{"href":"Page+title%"}}'>Page+title%</a>
+<a rel="mw:WikiLink" href="./%25+" title="%+" data-parsoid='{"stx":"simple","a":{"href":"./%25+"},"sa":{"href":"%+"}}'>%+</a> <a rel="mw:WikiLink" href="./%25+" title="%+" data-parsoid='{"stx":"piped","a":{"href":"./%25+"},"sa":{"href":"%+"}}'>%20</a> <a rel="mw:WikiLink" href="./%25+" title="%+" data-parsoid='{"stx":"simple","a":{"href":"./%25+"},"sa":{"href":"%+ "}}'>%+ </a> <a rel="mw:WikiLink" href="./%25+r" title="%+r" data-parsoid='{"stx":"simple","a":{"href":"./%25+r"},"sa":{"href":"%+r"}}'>%+r</a>
+<a rel="mw:WikiLink" href="./%25" title="%" data-parsoid='{"stx":"simple","a":{"href":"./%25"},"sa":{"href":"%"}}'>%</a> <a rel="mw:WikiLink" href="./+" title="+" data-parsoid='{"stx":"simple","a":{"href":"./+"},"sa":{"href":"+"}}'>+</a> <span class="mw-default-size" typeof="mw:Error mw:Image" data-parsoid='{"optList":[{"ck":"bogus","ak":"foo"},{"ck":"caption","ak":"[[bar]]"}]}' data-mw='{"errors":[{"key":"missing-image","message":"This image does not exist."}],"caption":"&lt;a rel=\"mw:WikiLink\" href=\"./Bar\" title=\"Bar\" data-parsoid=&#39;{\"stx\":\"simple\",\"a\":{\"href\":\"./Bar\"},\"sa\":{\"href\":\"bar\"},\"dsr\":[94,101,2,2]}&#39;>bar&lt;/a>"}'><a href="./File:%25+abc9" data-parsoid='{"a":{"href":"./File:%25+abc9"},"sa":{}}'><img resource="./File:%25+abc9" src="./Special:FilePath/%25+abc9" height="220" width="220" data-parsoid='{"a":{"resource":"./File:%25+abc9","height":"220","width":"220"},"sa":{"resource":"File:%+abc%39"}}'/></a></span>
 <a rel="mw:WikiLink" href="./3E" title="3E" data-parsoid='{"stx":"simple","a":{"href":"./3E"},"sa":{"href":"%33%45"}}'>3E</a> <a rel="mw:WikiLink" href="./3E+" title="3E+" data-parsoid='{"stx":"simple","a":{"href":"./3E+"},"sa":{"href":"%33%45+"}}'>3E+</a></p>
 !! end
 
@@ -22108,7 +22163,7 @@ This should just get lost.
 B <span about="#mwt4" class="mw-ref" id="cite_ref-b_2-0" rel="dc:references" typeof="mw:Extension/ref" data-mw='{"name":"ref","body":{"id":"mw-reference-text-cite_note-b-2"},"attrs":{"name":"b"}}'><a href="#cite_note-b-2"><span class="mw-reflink-text">[2]</span></a></span></p>
 
 
-<ol class="mw-references" typeof="mw:Extension/references" about="#mwt6" data-mw='{"name":"references","attrs":{},"body":{"html":"\n&lt;span about=\"#mwt8\" class=\"mw-ref\" rel=\"dc:references\" typeof=\"mw:Extension/ref\" data-parsoid=&#39;{\"dsr\":[59,82,14,6]}&#39; data-mw=&#39;{\"name\":\"ref\",\"body\":{\"id\":\"mw-reference-text-cite_note-a-1\"},\"attrs\":{\"name\":\"a\"}}&#39;>&lt;a href=\"#cite_note-a-1\" style=\"counter-reset: mw-Ref 1;\">&lt;span class=\"mw-reflink-text\">[1]&lt;/span>&lt;/a>&lt;/span>\n"}}'><li about="#cite_note-a-1" id="cite_note-a-1"><a href="#cite_ref-a_1-0" rel="mw:referencedBy"><span class="mw-linkback-text">↑ </span></a> <span id="mw-reference-text-cite_note-a-1" class="mw-reference-text">foo</span></li><li about="#cite_note-b-2" id="cite_note-b-2"><a href="#cite_ref-b_2-0" rel="mw:referencedBy"><span class="mw-linkback-text">↑ </span></a> <span id="mw-reference-text-cite_note-b-2" class="mw-reference-text">bar</span></li>
+<ol class="mw-references" typeof="mw:Extension/references" about="#mwt6" data-mw='{"name":"references","attrs":{},"body":{"html":"\n&lt;span about=\"#mwt8\" class=\"mw-ref\" rel=\"dc:references\" typeof=\"mw:Extension/ref\" data-parsoid=&#39;{\"dsr\":[59,82,14,6]}&#39; data-mw=&#39;{\"name\":\"ref\",\"body\":{\"id\":\"mw-reference-text-cite_note-a-1\"},\"attrs\":{\"name\":\"a\"}}&#39;>&lt;a href=\"#cite_note-a-1\" style=\"counter-reset: mw-Ref 1;\" data-parsoid=\"{}\">&lt;span class=\"mw-reflink-text\" data-parsoid=\"{}\">[1]&lt;/span>&lt;/a>&lt;/span>\n"}}'><li about="#cite_note-a-1" id="cite_note-a-1"><a href="#cite_ref-a_1-0" rel="mw:referencedBy"><span class="mw-linkback-text">↑ </span></a> <span id="mw-reference-text-cite_note-a-1" class="mw-reference-text">foo</span></li><li about="#cite_note-b-2" id="cite_note-b-2"><a href="#cite_ref-b_2-0" rel="mw:referencedBy"><span class="mw-linkback-text">↑ </span></a> <span id="mw-reference-text-cite_note-b-2" class="mw-reference-text">bar</span></li>
 </ol>
 !! end
 
@@ -22141,7 +22196,7 @@ B <span about="#mwt4" class="mw-ref" id="cite_ref-b_2-0" rel="dc:references" typ
 <li about="#cite_note-1" id="cite_note-1"><a href="#cite_ref-1" rel="mw:referencedBy"><span class="mw-linkback-text">↑ </span></a> <span id="mw-reference-text-cite_note-1" class="mw-reference-text">foo bar for a</span></li>
 </ol>
 
-<ol class="mw-references" typeof="mw:Extension/references" about="#mwt8" data-mw-group="X" data-mw='{"name":"references","attrs":{"group":"X"},"body":{"html":"\n&lt;span about=\"#mwt10\" class=\"mw-ref\" rel=\"dc:references\" typeof=\"mw:Extension/ref\" data-parsoid=&#39;{\"dsr\":[96,119,14,6]}&#39; data-mw=&#39;{\"name\":\"ref\",\"body\":{\"id\":\"mw-reference-text-cite_note-b-2\"},\"attrs\":{\"name\":\"b\"}}&#39;>&lt;a href=\"#cite_note-b-2\" style=\"counter-reset: mw-Ref 1;\" data-mw-group=\"X\">&lt;span class=\"mw-reflink-text\">[X 1]&lt;/span>&lt;/a>&lt;/span>\n"}}'>
+<ol class="mw-references" typeof="mw:Extension/references" about="#mwt8" data-mw-group="X" data-mw='{"name":"references","attrs":{"group":"X"},"body":{"html":"\n&lt;span about=\"#mwt10\" class=\"mw-ref\" rel=\"dc:references\" typeof=\"mw:Extension/ref\" data-parsoid=&#39;{\"dsr\":[96,119,14,6]}&#39; data-mw=&#39;{\"name\":\"ref\",\"body\":{\"id\":\"mw-reference-text-cite_note-b-2\"},\"attrs\":{\"name\":\"b\"}}&#39;>&lt;a href=\"#cite_note-b-2\" style=\"counter-reset: mw-Ref 1;\" data-mw-group=\"X\" data-parsoid=\"{}\">&lt;span class=\"mw-reflink-text\" data-parsoid=\"{}\">[X 1]&lt;/span>&lt;/a>&lt;/span>\n"}}'>
 <li about="#cite_note-b-2" id="cite_note-b-2"><a href="#cite_ref-b_2-0" data-mw-group="X" rel="mw:referencedBy"><span class="mw-linkback-text">↑ </span></a> <span id="mw-reference-text-cite_note-b-2" class="mw-reference-text">foo</span></li>
 </ol>
 !! end
@@ -22204,8 +22259,11 @@ Entities in ref name
 </ol>
 !! end
 
-# This test is wt2html only because we're permitting the serializer to produce
-# dirty diffs, normalizing the unclosed references to the self-closed version.
+## The output here may look funny, but it's what the php parser will do.  The
+## unclosed references tag becomes escaped text, and then a new references
+## tag is auto-generated.  The test is wt2html only because it roundtrips with
+## nowiki tags, and the auto-generated references tag is only dropped in
+## rtTestMode.
 !! test
 Generate references for unclosed references tag
 !! options
@@ -22215,9 +22273,10 @@ a<ref>foo</ref>
 
 <references>
 !! html/parsoid
-<p>a<span about="#mwt2" class="mw-ref" id="cite_ref-1" rel="dc:references" typeof="mw:Extension/ref" data-mw='{"name":"ref","body":{"id":"mw-reference-text-cite_note-1"},"attrs":{}}'><a href="#cite_note-1"><span class="mw-reflink-text">[1]</span></a></span></p>
-<ol class="mw-references" typeof="mw:Extension/references" about="#mwt4" data-mw='{"name":"references","attrs":{}}'>
-<li about="#cite_note-1" id="cite_note-1"><a href="#cite_ref-1" rel="mw:referencedBy"><span class="mw-linkback-text">↑ </span></a> <span id="mw-reference-text-cite_note-1" class="mw-reference-text">foo</span></li></ol>
+<p>a<span about="#mwt2" class="mw-ref" id="cite_ref-1" rel="dc:references" typeof="mw:Extension/ref" data-mw='{"name":"ref","body":{"id":"mw-reference-text-cite_note-1"},"attrs":{}}'><a href="#cite_note-1" style="counter-reset: mw-Ref 1;"><span class="mw-reflink-text">[1]</span></a></span></p>
+
+<p>&lt;references></p>
+<ol class="mw-references" typeof="mw:Extension/references" about="#mwt3" data-mw='{"name":"references","attrs":{},"autoGenerated":true}'><li about="#cite_note-1" id="cite_note-1"><a href="#cite_ref-1" rel="mw:referencedBy"><span class="mw-linkback-text">↑ </span></a> <span id="mw-reference-text-cite_note-1" class="mw-reference-text">foo</span></li></ol>
 !! end
 
 !! test
 !! test
 2. Leading whitespace in non-indent-pre contexts should not be escaped
 !! options
-parsoid=htm2wt
+parsoid=html2wt
 !! html/parsoid
 <p>foo <span about="#mwt2" class="mw-ref" id="cite_ref-1" rel="dc:references" typeof="mw:Extension/ref" data-mw='{"name":"ref","body":{"id":"mw-reference-text-cite_note-1"},"attrs":{}}'><a href="#cite_note-1"><span class="mw-reflink-text">[1]</span></a></span></p>
 <ol class="mw-references" typeof="mw:Extension/references" about="#mwt4" data-mw='{"name":"references","attrs":{}}'>
-<li about="#cite_note-1" id="cite_note-1"><a href="#cite_ref-1" rel="mw:referencedBy"><span class="mw-linkback-text">↑ </span></a> <span id="mw-reference-text-cite_note-1" class="mw-reference-text"><i data-parsoid='{"dsr":[9,14,2,2]}'>a</i>
+<li about="#cite_note-1" id="cite_note-1"><a href="#cite_ref-1" rel="mw:referencedBy"><span class="mw-linkback-text">↑ </span></a> <span id="mw-reference-text-cite_note-1" class="mw-reference-text"><i>a</i>
  b</span></li>
 </ol>
 !! wikitext
@@ -24663,7 +24722,7 @@ T115289: Don't migrate newlines out of tables with fostered content
 !! wikitext
 <table><td></td>{{echo|<tr>[[Category:One]]}}<!--c-->[[Category:Two]]
 !! html/parsoid
-<link rel="mw:PageProp/Category" href="./Category:One" about="#mwt2" typeof="mw:Transclusion" data-parsoid='{"stx":"simple","a":{"href":"./Category:One"},"sa":{"href":"Category:One"},"fostered":true,"pi":[[{"k":"1"}]]}' data-mw='{"parts":["&lt;table>&lt;td>&lt;/td>",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;tr>[[Category:One]]"}},"i":0}},"&lt;!--c-->[[Category:Two]]"]}'/><link rel="mw:PageProp/Category" href="./Category:Two" about="#mwt2"/><table about="#mwt2" data-parsoid='{"stx":"html","autoInsertedEnd":true,"dsr":[0,53,7,0]}'><tbody><tr><td></td></tr><tr><!--c--></tr></tbody></table>
+<link rel="mw:PageProp/Category" href="./Category:One" about="#mwt2" typeof="mw:Transclusion" data-parsoid='{"stx":"simple","a":{"href":"./Category:One"},"sa":{"href":"Category:One"},"fostered":true,"pi":[[{"k":"1"}]]}' data-mw='{"parts":["&lt;table>&lt;td>&lt;/td>",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;tr>[[Category:One]]"}},"i":0}},"&lt;!--c-->[[Category:Two]]"]}'/><link rel="mw:PageProp/Category" href="./Category:Two" about="#mwt2"/><table about="#mwt2" data-parsoid='{"stx":"html","autoInsertedEnd":true}'><tbody><tr><td></td></tr><tr><!--c--></tr></tbody></table>
 !! end
 
 !! test
@@ -26689,8 +26748,8 @@ parsoid=html2wt
 !! html/parsoid
 <p>x<meta typeof="mw:Placeholder" data-parsoid='{"src":"&lt;nowiki/>"}'/><meta typeof="mw:Placeholder" data-parsoid='{"src":"&lt;nowiki/>"}'/>
 y</p>
-<p><span about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"dsr":[0,23,null,null],"pi":[[{"k":"1","named":true,"spc":["\n"," "," ",""]}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;nowiki/>"}},"i":0}}]}'></span></p>
-<p><span about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"dsr":[0,24,null,null],"pi":[[{"k":"1","named":true,"spc":["\n"," "," ","\n"]}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;nowiki/>"}},"i":0}}]}'></span></p>
+<p><span about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"pi":[[{"k":"1","named":true,"spc":["\n"," "," ",""]}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;nowiki/>"}},"i":0}}]}'></span></p>
+<p><span about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"pi":[[{"k":"1","named":true,"spc":["\n"," "," ","\n"]}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;nowiki/>"}},"i":0}}]}'></span></p>
 !! wikitext
 x
 y
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' ) );
 
index 6c92b8c..a01f2f5 100644 (file)
@@ -7,7 +7,6 @@ class HtmlTest extends MediaWikiTestCase {
                parent::setUp();
 
                $this->setMwGlobals( [
-                       'wgWellFormedXml' => false,
                        'wgUseMediaWikiUIEverywhere' => false,
                ] );
 
@@ -45,7 +44,7 @@ class HtmlTest extends MediaWikiTestCase {
         */
        public function testElementBasics() {
                $this->assertEquals(
-                       '<img>',
+                       '<img/>',
                        Html::element( 'img', null, '' ),
                        'No close tag for short-tag elements'
                );
@@ -62,12 +61,10 @@ class HtmlTest extends MediaWikiTestCase {
                        'Close tag for empty element (array, string)'
                );
 
-               $this->setMwGlobals( 'wgWellFormedXml', true );
-
                $this->assertEquals(
                        '<img/>',
                        Html::element( 'img', null, '' ),
-                       'Self-closing tag for short-tag elements (wgWellFormedXml = true)'
+                       'Self-closing tag for short-tag elements'
                );
        }
 
@@ -134,22 +131,20 @@ class HtmlTest extends MediaWikiTestCase {
                );
 
                $this->assertEquals(
-                       ' selected',
+                       ' selected=""',
                        Html::expandAttributes( [ 'selected' => true ] ),
                        'Boolean attributes have no value when value is true'
                );
                $this->assertEquals(
-                       ' selected',
+                       ' selected=""',
                        Html::expandAttributes( [ 'selected' ] ),
                        'Boolean attributes have no value when value is true (passed as numerical array)'
                );
 
-               $this->setMwGlobals( 'wgWellFormedXml', true );
-
                $this->assertEquals(
                        ' selected=""',
                        Html::expandAttributes( [ 'selected' => true ] ),
-                       'Boolean attributes have empty string value when value is true (wgWellFormedXml)'
+                       'Boolean attributes have empty string value when value is true'
                );
        }
 
@@ -158,12 +153,12 @@ class HtmlTest extends MediaWikiTestCase {
         */
        public function testExpandAttributesForNumbers() {
                $this->assertEquals(
-                       ' value=1',
+                       ' value="1"',
                        Html::expandAttributes( [ 'value' => 1 ] ),
                        'Integer value is cast to a string'
                );
                $this->assertEquals(
-                       ' value=1.1',
+                       ' value="1.1"',
                        Html::expandAttributes( [ 'value' => 1.1 ] ),
                        'Float value is cast to a string'
                );
@@ -174,7 +169,7 @@ class HtmlTest extends MediaWikiTestCase {
         */
        public function testExpandAttributesForObjects() {
                $this->assertEquals(
-                       ' value=stringValue',
+                       ' value="stringValue"',
                        Html::expandAttributes( [ 'value' => new HtmlTestValue() ] ),
                        'Object value is converted to a string'
                );
@@ -193,43 +188,21 @@ class HtmlTest extends MediaWikiTestCase {
                        'Empty string is always quoted'
                );
                $this->assertEquals(
-                       ' key=value',
+                       ' key="value"',
                        Html::expandAttributes( [ 'key' => 'value' ] ),
                        'Simple string value needs no quotes'
                );
                $this->assertEquals(
-                       ' one=1',
+                       ' one="1"',
                        Html::expandAttributes( [ 'one' => 1 ] ),
                        'Number 1 value needs no quotes'
                );
                $this->assertEquals(
-                       ' zero=0',
+                       ' zero="0"',
                        Html::expandAttributes( [ 'zero' => 0 ] ),
                        'Number 0 value needs no quotes'
                );
 
-               $this->setMwGlobals( 'wgWellFormedXml', true );
-
-               $this->assertEquals(
-                       ' empty_string=""',
-                       Html::expandAttributes( [ 'empty_string' => '' ] ),
-                       'Attribute values are always quoted (wgWellFormedXml): Empty string'
-               );
-               $this->assertEquals(
-                       ' key="value"',
-                       Html::expandAttributes( [ 'key' => 'value' ] ),
-                       'Attribute values are always quoted (wgWellFormedXml): Simple string'
-               );
-               $this->assertEquals(
-                       ' one="1"',
-                       Html::expandAttributes( [ 'one' => 1 ] ),
-                       'Attribute values are always quoted (wgWellFormedXml): Number 1'
-               );
-               $this->assertEquals(
-                       ' zero="0"',
-                       Html::expandAttributes( [ 'zero' => 0 ] ),
-                       'Attribute values are always quoted (wgWellFormedXml): Number 0'
-               );
        }
 
        /**
@@ -346,48 +319,48 @@ class HtmlTest extends MediaWikiTestCase {
         */
        public function testNamespaceSelector() {
                $this->assertEquals(
-                       '<select id=namespace name=namespace>' . "\n" .
-                               '<option value=0>(Main)</option>' . "\n" .
-                               '<option value=1>Talk</option>' . "\n" .
-                               '<option value=2>User</option>' . "\n" .
-                               '<option value=3>User talk</option>' . "\n" .
-                               '<option value=4>MyWiki</option>' . "\n" .
-                               '<option value=5>MyWiki Talk</option>' . "\n" .
-                               '<option value=6>File</option>' . "\n" .
-                               '<option value=7>File talk</option>' . "\n" .
-                               '<option value=8>MediaWiki</option>' . "\n" .
-                               '<option value=9>MediaWiki talk</option>' . "\n" .
-                               '<option value=10>Template</option>' . "\n" .
-                               '<option value=11>Template talk</option>' . "\n" .
-                               '<option value=14>Category</option>' . "\n" .
-                               '<option value=15>Category talk</option>' . "\n" .
-                               '<option value=100>Custom</option>' . "\n" .
-                               '<option value=101>Custom talk</option>' . "\n" .
+                       '<select id="namespace" name="namespace">' . "\n" .
+                               '<option value="0">(Main)</option>' . "\n" .
+                               '<option value="1">Talk</option>' . "\n" .
+                               '<option value="2">User</option>' . "\n" .
+                               '<option value="3">User talk</option>' . "\n" .
+                               '<option value="4">MyWiki</option>' . "\n" .
+                               '<option value="5">MyWiki Talk</option>' . "\n" .
+                               '<option value="6">File</option>' . "\n" .
+                               '<option value="7">File talk</option>' . "\n" .
+                               '<option value="8">MediaWiki</option>' . "\n" .
+                               '<option value="9">MediaWiki talk</option>' . "\n" .
+                               '<option value="10">Template</option>' . "\n" .
+                               '<option value="11">Template talk</option>' . "\n" .
+                               '<option value="14">Category</option>' . "\n" .
+                               '<option value="15">Category talk</option>' . "\n" .
+                               '<option value="100">Custom</option>' . "\n" .
+                               '<option value="101">Custom talk</option>' . "\n" .
                                '</select>',
                        Html::namespaceSelector(),
                        'Basic namespace selector without custom options'
                );
 
                $this->assertEquals(
-                       '<label for=mw-test-namespace>Select a namespace:</label>&#160;' .
-                               '<select id=mw-test-namespace name=wpNamespace>' . "\n" .
-                               '<option value=all>all</option>' . "\n" .
-                               '<option value=0>(Main)</option>' . "\n" .
-                               '<option value=1>Talk</option>' . "\n" .
-                               '<option value=2 selected>User</option>' . "\n" .
-                               '<option value=3>User talk</option>' . "\n" .
-                               '<option value=4>MyWiki</option>' . "\n" .
-                               '<option value=5>MyWiki Talk</option>' . "\n" .
-                               '<option value=6>File</option>' . "\n" .
-                               '<option value=7>File talk</option>' . "\n" .
-                               '<option value=8>MediaWiki</option>' . "\n" .
-                               '<option value=9>MediaWiki talk</option>' . "\n" .
-                               '<option value=10>Template</option>' . "\n" .
-                               '<option value=11>Template talk</option>' . "\n" .
-                               '<option value=14>Category</option>' . "\n" .
-                               '<option value=15>Category talk</option>' . "\n" .
-                               '<option value=100>Custom</option>' . "\n" .
-                               '<option value=101>Custom talk</option>' . "\n" .
+                       '<label for="mw-test-namespace">Select a namespace:</label>&#160;' .
+                               '<select id="mw-test-namespace" name="wpNamespace">' . "\n" .
+                               '<option value="all">all</option>' . "\n" .
+                               '<option value="0">(Main)</option>' . "\n" .
+                               '<option value="1">Talk</option>' . "\n" .
+                               '<option value="2" selected="">User</option>' . "\n" .
+                               '<option value="3">User talk</option>' . "\n" .
+                               '<option value="4">MyWiki</option>' . "\n" .
+                               '<option value="5">MyWiki Talk</option>' . "\n" .
+                               '<option value="6">File</option>' . "\n" .
+                               '<option value="7">File talk</option>' . "\n" .
+                               '<option value="8">MediaWiki</option>' . "\n" .
+                               '<option value="9">MediaWiki talk</option>' . "\n" .
+                               '<option value="10">Template</option>' . "\n" .
+                               '<option value="11">Template talk</option>' . "\n" .
+                               '<option value="14">Category</option>' . "\n" .
+                               '<option value="15">Category talk</option>' . "\n" .
+                               '<option value="100">Custom</option>' . "\n" .
+                               '<option value="101">Custom talk</option>' . "\n" .
                                '</select>',
                        Html::namespaceSelector(
                                [ 'selected' => '2', 'all' => 'all', 'label' => 'Select a namespace:' ],
@@ -397,24 +370,24 @@ class HtmlTest extends MediaWikiTestCase {
                );
 
                $this->assertEquals(
-                       '<label for=namespace>Select a namespace:</label>&#160;' .
-                               '<select id=namespace name=namespace>' . "\n" .
-                               '<option value=0>(Main)</option>' . "\n" .
-                               '<option value=1>Talk</option>' . "\n" .
-                               '<option value=2>User</option>' . "\n" .
-                               '<option value=3>User talk</option>' . "\n" .
-                               '<option value=4>MyWiki</option>' . "\n" .
-                               '<option value=5>MyWiki Talk</option>' . "\n" .
-                               '<option value=6>File</option>' . "\n" .
-                               '<option value=7>File talk</option>' . "\n" .
-                               '<option value=8>MediaWiki</option>' . "\n" .
-                               '<option value=9>MediaWiki talk</option>' . "\n" .
-                               '<option value=10>Template</option>' . "\n" .
-                               '<option value=11>Template talk</option>' . "\n" .
-                               '<option value=14>Category</option>' . "\n" .
-                               '<option value=15>Category talk</option>' . "\n" .
-                               '<option value=100>Custom</option>' . "\n" .
-                               '<option value=101>Custom talk</option>' . "\n" .
+                       '<label for="namespace">Select a namespace:</label>&#160;' .
+                               '<select id="namespace" name="namespace">' . "\n" .
+                               '<option value="0">(Main)</option>' . "\n" .
+                               '<option value="1">Talk</option>' . "\n" .
+                               '<option value="2">User</option>' . "\n" .
+                               '<option value="3">User talk</option>' . "\n" .
+                               '<option value="4">MyWiki</option>' . "\n" .
+                               '<option value="5">MyWiki Talk</option>' . "\n" .
+                               '<option value="6">File</option>' . "\n" .
+                               '<option value="7">File talk</option>' . "\n" .
+                               '<option value="8">MediaWiki</option>' . "\n" .
+                               '<option value="9">MediaWiki talk</option>' . "\n" .
+                               '<option value="10">Template</option>' . "\n" .
+                               '<option value="11">Template talk</option>' . "\n" .
+                               '<option value="14">Category</option>' . "\n" .
+                               '<option value="15">Category talk</option>' . "\n" .
+                               '<option value="100">Custom</option>' . "\n" .
+                               '<option value="101">Custom talk</option>' . "\n" .
                                '</select>',
                        Html::namespaceSelector(
                                [ 'label' => 'Select a namespace:' ]
@@ -425,18 +398,18 @@ class HtmlTest extends MediaWikiTestCase {
 
        public function testCanFilterOutNamespaces() {
                $this->assertEquals(
-                       '<select id=namespace name=namespace>' . "\n" .
-                               '<option value=2>User</option>' . "\n" .
-                               '<option value=4>MyWiki</option>' . "\n" .
-                               '<option value=5>MyWiki Talk</option>' . "\n" .
-                               '<option value=6>File</option>' . "\n" .
-                               '<option value=7>File talk</option>' . "\n" .
-                               '<option value=8>MediaWiki</option>' . "\n" .
-                               '<option value=9>MediaWiki talk</option>' . "\n" .
-                               '<option value=10>Template</option>' . "\n" .
-                               '<option value=11>Template talk</option>' . "\n" .
-                               '<option value=14>Category</option>' . "\n" .
-                               '<option value=15>Category talk</option>' . "\n" .
+                       '<select id="namespace" name="namespace">' . "\n" .
+                               '<option value="2">User</option>' . "\n" .
+                               '<option value="4">MyWiki</option>' . "\n" .
+                               '<option value="5">MyWiki Talk</option>' . "\n" .
+                               '<option value="6">File</option>' . "\n" .
+                               '<option value="7">File talk</option>' . "\n" .
+                               '<option value="8">MediaWiki</option>' . "\n" .
+                               '<option value="9">MediaWiki talk</option>' . "\n" .
+                               '<option value="10">Template</option>' . "\n" .
+                               '<option value="11">Template talk</option>' . "\n" .
+                               '<option value="14">Category</option>' . "\n" .
+                               '<option value="15">Category talk</option>' . "\n" .
                                '</select>',
                        Html::namespaceSelector(
                                [ 'exclude' => [ 0, 1, 3, 100, 101 ] ]
@@ -447,23 +420,23 @@ class HtmlTest extends MediaWikiTestCase {
 
        public function testCanDisableANamespaces() {
                $this->assertEquals(
-                       '<select id=namespace name=namespace>' . "\n" .
-                               '<option disabled value=0>(Main)</option>' . "\n" .
-                               '<option disabled value=1>Talk</option>' . "\n" .
-                               '<option disabled value=2>User</option>' . "\n" .
-                               '<option disabled value=3>User talk</option>' . "\n" .
-                               '<option disabled value=4>MyWiki</option>' . "\n" .
-                               '<option value=5>MyWiki Talk</option>' . "\n" .
-                               '<option value=6>File</option>' . "\n" .
-                               '<option value=7>File talk</option>' . "\n" .
-                               '<option value=8>MediaWiki</option>' . "\n" .
-                               '<option value=9>MediaWiki talk</option>' . "\n" .
-                               '<option value=10>Template</option>' . "\n" .
-                               '<option value=11>Template talk</option>' . "\n" .
-                               '<option value=14>Category</option>' . "\n" .
-                               '<option value=15>Category talk</option>' . "\n" .
-                               '<option value=100>Custom</option>' . "\n" .
-                               '<option value=101>Custom talk</option>' . "\n" .
+                       '<select id="namespace" name="namespace">' . "\n" .
+                               '<option disabled="" value="0">(Main)</option>' . "\n" .
+                               '<option disabled="" value="1">Talk</option>' . "\n" .
+                               '<option disabled="" value="2">User</option>' . "\n" .
+                               '<option disabled="" value="3">User talk</option>' . "\n" .
+                               '<option disabled="" value="4">MyWiki</option>' . "\n" .
+                               '<option value="5">MyWiki Talk</option>' . "\n" .
+                               '<option value="6">File</option>' . "\n" .
+                               '<option value="7">File talk</option>' . "\n" .
+                               '<option value="8">MediaWiki</option>' . "\n" .
+                               '<option value="9">MediaWiki talk</option>' . "\n" .
+                               '<option value="10">Template</option>' . "\n" .
+                               '<option value="11">Template talk</option>' . "\n" .
+                               '<option value="14">Category</option>' . "\n" .
+                               '<option value="15">Category talk</option>' . "\n" .
+                               '<option value="100">Custom</option>' . "\n" .
+                               '<option value="101">Custom talk</option>' . "\n" .
                                '</select>',
                        Html::namespaceSelector( [
                                'disable' => [ 0, 1, 2, 3, 4 ]
@@ -478,7 +451,7 @@ class HtmlTest extends MediaWikiTestCase {
         */
        public function testHtmlElementAcceptsNewHtml5TypesInHtml5Mode( $HTML5InputType ) {
                $this->assertEquals(
-                       '<input type=' . $HTML5InputType . '>',
+                       '<input type="' . $HTML5InputType . '"/>',
                        Html::element( 'input', [ 'type' => $HTML5InputType ] ),
                        'In HTML5, Html::element() should accept type="' . $HTML5InputType . '"'
                );
@@ -528,14 +501,14 @@ class HtmlTest extends MediaWikiTestCase {
                $cases = [];
 
                # ## Generic cases, match $attribDefault static array
-               $cases[] = [ '<area>',
+               $cases[] = [ '<area/>',
                        'area', [ 'shape' => 'rect' ]
                ];
 
-               $cases[] = [ '<button type=submit></button>',
+               $cases[] = [ '<button type="submit"></button>',
                        'button', [ 'formaction' => 'GET' ]
                ];
-               $cases[] = [ '<button type=submit></button>',
+               $cases[] = [ '<button type="submit"></button>',
                        'button', [ 'formenctype' => 'application/x-www-form-urlencoded' ]
                ];
 
@@ -553,7 +526,7 @@ class HtmlTest extends MediaWikiTestCase {
                        'canvas', [ 'width' => 300 ]
                ];
 
-               $cases[] = [ '<command>',
+               $cases[] = [ '<command/>',
                        'command', [ 'type' => 'command' ]
                ];
 
@@ -567,18 +540,18 @@ class HtmlTest extends MediaWikiTestCase {
                        'form', [ 'enctype' => 'application/x-www-form-urlencoded' ]
                ];
 
-               $cases[] = [ '<input>',
+               $cases[] = [ '<input/>',
                        'input', [ 'formaction' => 'GET' ]
                ];
-               $cases[] = [ '<input>',
+               $cases[] = [ '<input/>',
                        'input', [ 'type' => 'text' ]
                ];
 
-               $cases[] = [ '<keygen>',
+               $cases[] = [ '<keygen/>',
                        'keygen', [ 'keytype' => 'rsa' ]
                ];
 
-               $cases[] = [ '<link>',
+               $cases[] = [ '<link/>',
                        'link', [ 'media' => 'all' ]
                ];
 
@@ -604,44 +577,44 @@ class HtmlTest extends MediaWikiTestCase {
                # ## SPECIFIC CASES
 
                # <link type="text/css">
-               $cases[] = [ '<link>',
+               $cases[] = [ '<link/>',
                        'link', [ 'type' => 'text/css' ]
                ];
 
                # <input> specific handling
-               $cases[] = [ '<input type=checkbox>',
+               $cases[] = [ '<input type="checkbox"/>',
                        'input', [ 'type' => 'checkbox', 'value' => 'on' ],
                        'Default value "on" is stripped of checkboxes',
                ];
-               $cases[] = [ '<input type=radio>',
+               $cases[] = [ '<input type="radio"/>',
                        'input', [ 'type' => 'radio', 'value' => 'on' ],
                        'Default value "on" is stripped of radio buttons',
                ];
-               $cases[] = [ '<input type=submit value=Submit>',
+               $cases[] = [ '<input type="submit" value="Submit"/>',
                        'input', [ 'type' => 'submit', 'value' => 'Submit' ],
                        'Default value "Submit" is kept on submit buttons (for possible l10n issues)',
                ];
-               $cases[] = [ '<input type=color>',
+               $cases[] = [ '<input type="color"/>',
                        'input', [ 'type' => 'color', 'value' => '' ],
                ];
-               $cases[] = [ '<input type=range>',
+               $cases[] = [ '<input type="range"/>',
                        'input', [ 'type' => 'range', 'value' => '' ],
                ];
 
                # <button> specific handling
                # see remarks on http://msdn.microsoft.com/en-us/library/ie/ms535211%28v=vs.85%29.aspx
-               $cases[] = [ '<button type=submit></button>',
+               $cases[] = [ '<button type="submit"></button>',
                        'button', [ 'type' => 'submit' ],
                        'According to standard the default type is "submit". '
                                . 'Depending on compatibility mode IE might use "button", instead.',
                ];
 
                # <select> specific handling
-               $cases[] = [ '<select multiple></select>',
+               $cases[] = [ '<select multiple=""></select>',
                        'select', [ 'size' => '4', 'multiple' => true ],
                ];
                # .. with numeric value
-               $cases[] = [ '<select multiple></select>',
+               $cases[] = [ '<select multiple=""></select>',
                        'select', [ 'size' => 4, 'multiple' => true ],
                ];
                $cases[] = [ '<select></select>',
@@ -693,7 +666,7 @@ class HtmlTest extends MediaWikiTestCase {
                        'Blacklist form validation attributes.'
                );
                $this->assertEquals(
-                       ' step=any',
+                       ' step="any"',
                        Html::expandAttributes(
                                [
                                        'min' => 1,
@@ -709,12 +682,12 @@ class HtmlTest extends MediaWikiTestCase {
 
        public function testWrapperInput() {
                $this->assertEquals(
-                       '<input type=radio value=testval name=testname>',
+                       '<input type="radio" value="testval" name="testname"/>',
                        Html::input( 'testname', 'testval', 'radio' ),
                        'Input wrapper with type and value.'
                );
                $this->assertEquals(
-                       '<input name=testname>',
+                       '<input name="testname"/>',
                        Html::input( 'testname' ),
                        'Input wrapper with all default values.'
                );
@@ -722,17 +695,17 @@ class HtmlTest extends MediaWikiTestCase {
 
        public function testWrapperCheck() {
                $this->assertEquals(
-                       '<input type=checkbox value=1 name=testname>',
+                       '<input type="checkbox" value="1" name="testname"/>',
                        Html::check( 'testname' ),
                        'Checkbox wrapper unchecked.'
                );
                $this->assertEquals(
-                       '<input checked type=checkbox value=1 name=testname>',
+                       '<input checked="" type="checkbox" value="1" name="testname"/>',
                        Html::check( 'testname', true ),
                        'Checkbox wrapper checked.'
                );
                $this->assertEquals(
-                       '<input type=checkbox value=testval name=testname>',
+                       '<input type="checkbox" value="testval" name="testname"/>',
                        Html::check( 'testname', false, [ 'value' => 'testval' ] ),
                        'Checkbox wrapper with a value override.'
                );
@@ -740,17 +713,17 @@ class HtmlTest extends MediaWikiTestCase {
 
        public function testWrapperRadio() {
                $this->assertEquals(
-                       '<input type=radio value=1 name=testname>',
+                       '<input type="radio" value="1" name="testname"/>',
                        Html::radio( 'testname' ),
                        'Radio wrapper unchecked.'
                );
                $this->assertEquals(
-                       '<input checked type=radio value=1 name=testname>',
+                       '<input checked="" type="radio" value="1" name="testname"/>',
                        Html::radio( 'testname', true ),
                        'Radio wrapper checked.'
                );
                $this->assertEquals(
-                       '<input type=radio value=testval name=testname>',
+                       '<input type="radio" value="testval" name="testname"/>',
                        Html::radio( 'testname', false, [ 'value' => 'testval' ] ),
                        'Radio wrapper with a value override.'
                );
@@ -758,7 +731,7 @@ class HtmlTest extends MediaWikiTestCase {
 
        public function testWrapperLabel() {
                $this->assertEquals(
-                       '<label for=testid>testlabel</label>',
+                       '<label for="testid">testlabel</label>',
                        Html::label( 'testlabel', 'testid' ),
                        'Label wrapper'
                );
index d701a81..6edf034 100644 (file)
@@ -13,7 +13,6 @@ class LinkerTest extends MediaWikiLangTestCase {
        public function testUserLink( $expected, $userId, $userName, $altUserName = false, $msg = '' ) {
                $this->setMwGlobals( [
                        'wgArticlePath' => '/wiki/$1',
-                       'wgWellFormedXml' => true,
                ] );
 
                $this->assertEquals( $expected,
@@ -112,7 +111,6 @@ class LinkerTest extends MediaWikiLangTestCase {
                $this->setMwGlobals( [
                        'wgScript' => '/wiki/index.php',
                        'wgArticlePath' => '/wiki/$1',
-                       'wgWellFormedXml' => true,
                        'wgCapitalLinks' => true,
                        'wgConf' => $conf,
                ] );
@@ -277,7 +275,6 @@ class LinkerTest extends MediaWikiLangTestCase {
                $this->setMwGlobals( [
                        'wgScript' => '/wiki/index.php',
                        'wgArticlePath' => '/wiki/$1',
-                       'wgWellFormedXml' => true,
                        'wgCapitalLinks' => true,
                        'wgConf' => $conf,
                ] );
@@ -367,7 +364,6 @@ class LinkerTest extends MediaWikiLangTestCase {
        public function testLinkBeginHook( $callback, $expected ) {
                $this->setMwGlobals( [
                        'wgArticlePath' => '/wiki/$1',
-                       'wgWellFormedXml' => true,
                        'wgServer' => '//example.org',
                        'wgCanonicalServer' => 'http://example.org',
                        'wgScriptPath' => '/w',
@@ -414,7 +410,6 @@ class LinkerTest extends MediaWikiLangTestCase {
        public function testLinkEndHook( $callback, $expected ) {
                $this->setMwGlobals( [
                        'wgArticlePath' => '/wiki/$1',
-                       'wgWellFormedXml' => true,
                ] );
 
                $this->setMwGlobals( 'wgHooks', [ 'LinkEnd' => [ $callback ] ] );
index 467a2ad..51ef9d7 100644 (file)
@@ -243,6 +243,7 @@ class MediaWikiServicesTest extends PHPUnit_Framework_TestCase {
                        'DBLoadBalancer' => [ 'DBLoadBalancer', 'LoadBalancer' ],
                        'WatchedItemStore' => [ 'WatchedItemStore', WatchedItemStore::class ],
                        'GenderCache' => [ 'GenderCache', GenderCache::class ],
+                       'LinkCache' => [ 'LinkCache', LinkCache::class ],
                        '_MediaWikiTitleCodec' => [ '_MediaWikiTitleCodec', MediaWikiTitleCodec::class ],
                        'TitleFormatter' => [ 'TitleFormatter', TitleFormatter::class ],
                        'TitleParser' => [ 'TitleParser', TitleParser::class ],
index 8d4a347..9934749 100644 (file)
@@ -149,14 +149,14 @@ class OutputPageTest extends MediaWikiTestCase {
                        [
                                // Don't condition wrap raw modules (like the startup module)
                                [ 'test.raw', ResourceLoaderModule::TYPE_SCRIPTS ],
-                               '<script async src="http://127.0.0.1:8080/w/load.php?debug=false&amp;lang=en&amp;modules=test.raw&amp;only=scripts&amp;skin=fallback"></script>'
+                               '<script async="" src="http://127.0.0.1:8080/w/load.php?debug=false&amp;lang=en&amp;modules=test.raw&amp;only=scripts&amp;skin=fallback"></script>'
                        ],
                        // Load module styles only
                        // This also tests the order the modules are put into the url
                        [
                                [ [ 'test.baz', 'test.foo', 'test.bar' ], ResourceLoaderModule::TYPE_STYLES ],
 
-                               '<link rel=stylesheet href="http://127.0.0.1:8080/w/load.php?debug=false&amp;lang=en&amp;modules=test.bar%2Cbaz%2Cfoo&amp;only=styles&amp;skin=fallback">'
+                               '<link rel="stylesheet" href="http://127.0.0.1:8080/w/load.php?debug=false&amp;lang=en&amp;modules=test.bar%2Cbaz%2Cfoo&amp;only=styles&amp;skin=fallback"/>'
                        ],
                        // Load private module (only=scripts)
                        [
@@ -181,7 +181,7 @@ class OutputPageTest extends MediaWikiTestCase {
                        // noscript group
                        [
                                [ 'test.noscript', ResourceLoaderModule::TYPE_STYLES ],
-                               '<noscript><link rel=stylesheet href="http://127.0.0.1:8080/w/load.php?debug=false&amp;lang=en&amp;modules=test.noscript&amp;only=styles&amp;skin=fallback"></noscript>'
+                               '<noscript><link rel="stylesheet" href="http://127.0.0.1:8080/w/load.php?debug=false&amp;lang=en&amp;modules=test.noscript&amp;only=styles&amp;skin=fallback"/></noscript>'
                        ],
                        // Load two modules in separate groups
                        [
@@ -210,8 +210,6 @@ class OutputPageTest extends MediaWikiTestCase {
                $this->setMwGlobals( [
                        'wgResourceLoaderDebug' => false,
                        'wgLoadScript' => 'http://127.0.0.1:8080/w/load.php',
-                       // Affects whether CDATA is inserted
-                       'wgWellFormedXml' => false,
                ] );
                $class = new ReflectionClass( 'OutputPage' );
                $method = $class->getMethod( 'makeResourceLoaderLink' );
index 496f18f..7850f24 100644 (file)
@@ -709,4 +709,42 @@ class TitleTest extends MediaWikiTestCase {
                $this->assertEquals( $title->getInterwiki(), $fragmentTitle->getInterwiki() );
                $this->assertEquals( $fragment, $fragmentTitle->getFragment() );
        }
+
+       public function provideGetPrefixedText() {
+               return [
+                       // ns = 0
+                       [
+                               Title::makeTitle( NS_MAIN, 'Foobar' ),
+                               'Foobar'
+                       ],
+                       // ns = 2
+                       [
+                               Title::makeTitle( NS_USER, 'Foobar' ),
+                               'User:Foobar'
+                       ],
+                       // fragment not included
+                       [
+                               Title::makeTitle( NS_MAIN, 'Foobar', 'fragment' ),
+                               'Foobar'
+                       ],
+                       // ns = -2
+                       [
+                               Title::makeTitle( NS_MEDIA, 'Foobar' ),
+                               'Media:Foobar'
+                       ],
+                       // non-existent namespace
+                       [
+                               Title::makeTitle( 100000, 'Foobar' ),
+                               ':Foobar'
+                       ],
+               ];
+       }
+
+       /**
+        * @covers Title::getPrefixedText
+        * @dataProvider provideGetPrefixedText
+        */
+       public function testGetPrefixedText( Title $title, $expected ) {
+               $this->assertEquals( $expected, $title->getPrefixedText() );
+       }
 }
index e536205..be22260 100644 (file)
@@ -13,6 +13,14 @@ class WatchedItemIntegrationTest extends MediaWikiTestCase {
                parent::setUp();
                self::$users['WatchedItemIntegrationTestUser']
                        = new TestUser( 'WatchedItemIntegrationTestUser' );
+
+               $this->hideDeprecated( 'WatchedItem::fromUserTitle' );
+               $this->hideDeprecated( 'WatchedItem::addWatch' );
+               $this->hideDeprecated( 'WatchedItem::removeWatch' );
+               $this->hideDeprecated( 'WatchedItem::isWatched' );
+               $this->hideDeprecated( 'WatchedItem::resetNotificationTimestamp' );
+               $this->hideDeprecated( 'WatchedItem::duplicateEntries' );
+               $this->hideDeprecated( 'WatchedItem::batchAddWatch' );
        }
 
        private function getUser() {
@@ -20,6 +28,7 @@ class WatchedItemIntegrationTest extends MediaWikiTestCase {
        }
 
        public function testWatchAndUnWatchItem() {
+
                $user = $this->getUser();
                $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
                // Cleanup after previous tests
index 0d10c1a..f80f512 100644 (file)
@@ -12,9 +12,6 @@ class XmlSelectTest extends MediaWikiTestCase {
 
        protected function setUp() {
                parent::setUp();
-               $this->setMwGlobals( [
-                       'wgWellFormedXml' => true,
-               ] );
                $this->select = new XmlSelect();
        }
 
index 00d429e..dbd1299 100644 (file)
@@ -30,7 +30,6 @@ class XmlTest extends MediaWikiTestCase {
 
                $this->setMwGlobals( [
                        'wgLang' => $langObj,
-                       'wgWellFormedXml' => true,
                        'wgUseMediaWikiUIEverywhere' => false,
                ] );
        }
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 91f27fb..545b964 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+use MediaWiki\MediaWikiServices;
 
 /**
  * @group ContentHandler
@@ -36,7 +37,7 @@ class ContentHandlerTest extends MediaWikiTestCase {
                MWNamespace::getCanonicalNamespaces( true );
                $wgContLang->resetNamespaces();
                // And LinkCache
-               LinkCache::destroySingleton();
+               MediaWikiServices::getInstance()->resetServiceForTesting( 'LinkCache' );
        }
 
        protected function tearDown() {
@@ -46,7 +47,7 @@ class ContentHandlerTest extends MediaWikiTestCase {
                MWNamespace::getCanonicalNamespaces( true );
                $wgContLang->resetNamespaces();
                // And LinkCache
-               LinkCache::destroySingleton();
+               MediaWikiServices::getInstance()->resetServiceForTesting( 'LinkCache' );
 
                parent::tearDown();
        }
index 8a48734..de8e371 100644 (file)
@@ -6,12 +6,6 @@
  */
 class JsonContentTest extends MediaWikiLangTestCase {
 
-       protected function setUp() {
-               parent::setUp();
-
-               $this->setMwGlobals( 'wgWellFormedXml', true );
-       }
-
        public static function provideValidConstruction() {
                return [
                        [ 'foo', false, null ],
diff --git a/tests/phpunit/includes/libs/XhprofDataTest.php b/tests/phpunit/includes/libs/XhprofDataTest.php
new file mode 100644 (file)
index 0000000..a0fb563
--- /dev/null
@@ -0,0 +1,277 @@
+<?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
+ */
+
+/**
+ * @uses XhprofData
+ * @uses AutoLoader
+ * @author Bryan Davis <bd808@wikimedia.org>
+ * @copyright © 2014 Bryan Davis and Wikimedia Foundation.
+ * @since 1.25
+ */
+class XhprofDataTest extends PHPUnit_Framework_TestCase {
+
+       /**
+        * @covers XhprofData::splitKey
+        * @dataProvider provideSplitKey
+        */
+       public function testSplitKey( $key, $expect ) {
+               $this->assertSame( $expect, XhprofData::splitKey( $key ) );
+       }
+
+       public function provideSplitKey() {
+               return [
+                       [ 'main()', [ null, 'main()' ] ],
+                       [ 'foo==>bar', [ 'foo', 'bar' ] ],
+                       [ 'bar@1==>bar@2', [ 'bar@1', 'bar@2' ] ],
+                       [ 'foo==>bar==>baz', [ 'foo', 'bar==>baz' ] ],
+                       [ '==>bar', [ '', 'bar' ] ],
+                       [ '', [ null, '' ] ],
+               ];
+       }
+
+       /**
+        * @covers XhprofData::pruneData
+        */
+       public function testInclude() {
+               $xhprofData = $this->getXhprofDataFixture( [
+                       'include' => [ 'main()' ],
+               ] );
+               $raw = $xhprofData->getRawData();
+               $this->assertArrayHasKey( 'main()', $raw );
+               $this->assertArrayHasKey( 'main()==>foo', $raw );
+               $this->assertArrayHasKey( 'main()==>xhprof_disable', $raw );
+               $this->assertSame( 3, count( $raw ) );
+       }
+
+       /**
+        * Validate the structure of data returned by
+        * Xhprof::getInclusiveMetrics(). This acts as a guard against unexpected
+        * structural changes to the returned data in lieu of using a more heavy
+        * weight typed response object.
+        *
+        * @covers XhprofData::getInclusiveMetrics
+        */
+       public function testInclusiveMetricsStructure() {
+               $metricStruct = [
+                       'ct' => 'int',
+                       'wt' => 'array',
+                       'cpu' => 'array',
+                       'mu' => 'array',
+                       'pmu' => 'array',
+               ];
+               $statStruct = [
+                       'total' => 'numeric',
+                       'min' => 'numeric',
+                       'mean' => 'numeric',
+                       'max' => 'numeric',
+                       'variance' => 'numeric',
+                       'percent' => 'numeric',
+               ];
+
+               $xhprofData = $this->getXhprofDataFixture();
+               $metrics = $xhprofData->getInclusiveMetrics();
+
+               foreach ( $metrics as $name => $metric ) {
+                       $this->assertArrayStructure( $metricStruct, $metric );
+
+                       foreach ( $metricStruct as $key => $type ) {
+                               if ( $type === 'array' ) {
+                                       $this->assertArrayStructure( $statStruct, $metric[$key] );
+                                       if ( $name === 'main()' ) {
+                                               $this->assertEquals( 100, $metric[$key]['percent'] );
+                                       }
+                               }
+                       }
+               }
+       }
+
+       /**
+        * Validate the structure of data returned by
+        * Xhprof::getCompleteMetrics(). This acts as a guard against unexpected
+        * structural changes to the returned data in lieu of using a more heavy
+        * weight typed response object.
+        *
+        * @covers XhprofData::getCompleteMetrics
+        */
+       public function testCompleteMetricsStructure() {
+               $metricStruct = [
+                       'ct' => 'int',
+                       'wt' => 'array',
+                       'cpu' => 'array',
+                       'mu' => 'array',
+                       'pmu' => 'array',
+                       'calls' => 'array',
+                       'subcalls' => 'array',
+               ];
+               $statsMetrics = [ 'wt', 'cpu', 'mu', 'pmu' ];
+               $statStruct = [
+                       'total' => 'numeric',
+                       'min' => 'numeric',
+                       'mean' => 'numeric',
+                       'max' => 'numeric',
+                       'variance' => 'numeric',
+                       'percent' => 'numeric',
+                       'exclusive' => 'numeric',
+               ];
+
+               $xhprofData = $this->getXhprofDataFixture();
+               $metrics = $xhprofData->getCompleteMetrics();
+
+               foreach ( $metrics as $name => $metric ) {
+                       $this->assertArrayStructure( $metricStruct, $metric, $name );
+
+                       foreach ( $metricStruct as $key => $type ) {
+                               if ( in_array( $key, $statsMetrics ) ) {
+                                       $this->assertArrayStructure(
+                                               $statStruct, $metric[$key], $key
+                                       );
+                                       $this->assertLessThanOrEqual(
+                                               $metric[$key]['total'], $metric[$key]['exclusive']
+                                       );
+                               }
+                       }
+               }
+       }
+
+       /**
+        * @covers XhprofData::getCallers
+        * @covers XhprofData::getCallees
+        * @uses XhprofData
+        */
+       public function testEdges() {
+               $xhprofData = $this->getXhprofDataFixture();
+               $this->assertSame( [], $xhprofData->getCallers( 'main()' ) );
+               $this->assertSame( [ 'foo', 'xhprof_disable' ],
+                       $xhprofData->getCallees( 'main()' )
+               );
+               $this->assertSame( [ 'main()' ],
+                       $xhprofData->getCallers( 'foo' )
+               );
+               $this->assertSame( [], $xhprofData->getCallees( 'strlen' ) );
+       }
+
+       /**
+        * @covers XhprofData::getCriticalPath
+        * @uses XhprofData
+        */
+       public function testCriticalPath() {
+               $xhprofData = $this->getXhprofDataFixture();
+               $path = $xhprofData->getCriticalPath();
+
+               $last = null;
+               foreach ( $path as $key => $value ) {
+                       list( $func, $call ) = XhprofData::splitKey( $key );
+                       $this->assertSame( $last, $func );
+                       $last = $call;
+               }
+               $this->assertSame( $last, 'bar@1' );
+       }
+
+       /**
+        * Get an Xhprof instance that has been primed with a set of known testing
+        * data. Tests for the Xhprof class should laregly be concerned with
+        * evaluating the manipulations of the data collected by xhprof rather
+        * than the data collection process itself.
+        *
+        * The returned Xhprof instance primed will be with a data set created by
+        * running this trivial program using the PECL xhprof implementation:
+        * @code
+        * function bar( $x ) {
+        *   if ( $x > 0 ) {
+        *     bar($x - 1);
+        *   }
+        * }
+        * function foo() {
+        *   for ( $idx = 0; $idx < 2; $idx++ ) {
+        *     bar( $idx );
+        *     $x = strlen( 'abc' );
+        *   }
+        * }
+        * xhprof_enable( XHPROF_FLAGS_CPU | XHPROF_FLAGS_MEMORY );
+        * foo();
+        * $x = xhprof_disable();
+        * var_export( $x );
+        * @endcode
+        *
+        * @return Xhprof
+        */
+       protected function getXhprofDataFixture( array $opts = [] ) {
+               return new XhprofData( [
+                       'foo==>bar' => [
+                               'ct' => 2,
+                               'wt' => 57,
+                               'cpu' => 92,
+                               'mu' => 1896,
+                               'pmu' => 0,
+                       ],
+                       'foo==>strlen' => [
+                               'ct' => 2,
+                               'wt' => 21,
+                               'cpu' => 141,
+                               'mu' => 752,
+                               'pmu' => 0,
+                       ],
+                       'bar==>bar@1' => [
+                               'ct' => 1,
+                               'wt' => 18,
+                               'cpu' => 19,
+                               'mu' => 752,
+                               'pmu' => 0,
+                       ],
+                       'main()==>foo' => [
+                               'ct' => 1,
+                               'wt' => 304,
+                               'cpu' => 307,
+                               'mu' => 4008,
+                               'pmu' => 0,
+                       ],
+                       'main()==>xhprof_disable' => [
+                               'ct' => 1,
+                               'wt' => 8,
+                               'cpu' => 10,
+                               'mu' => 768,
+                               'pmu' => 392,
+                       ],
+                       'main()' => [
+                               'ct' => 1,
+                               'wt' => 353,
+                               'cpu' => 351,
+                               'mu' => 6112,
+                               'pmu' => 1424,
+                       ],
+               ], $opts );
+       }
+
+       /**
+        * Assert that the given array has the described structure.
+        *
+        * @param array $struct Array of key => type mappings
+        * @param array $actual Array to check
+        * @param string $label
+        */
+       protected function assertArrayStructure( $struct, $actual, $label = null ) {
+               $this->assertInternalType( 'array', $actual, $label );
+               $this->assertCount( count( $struct ), $actual, $label );
+               foreach ( $struct as $key => $type ) {
+                       $this->assertArrayHasKey( $key, $actual );
+                       $this->assertInternalType( $type, $actual[$key] );
+               }
+       }
+}
index 22925bf..6748115 100644 (file)
  * @file
  */
 
-/**
- * @uses Xhprof
- * @uses AutoLoader
- * @author Bryan Davis <bd808@wikimedia.org>
- * @copyright © 2014 Bryan Davis and Wikimedia Foundation.
- * @since 1.25
- */
 class XhprofTest extends PHPUnit_Framework_TestCase {
-
-       public function setUp() {
-               if ( !function_exists( 'xhprof_enable' ) ) {
-                       $this->markTestSkipped( 'No xhprof support detected.' );
-               }
-       }
-
-       /**
-        * @covers Xhprof::splitKey
-        * @dataProvider provideSplitKey
-        */
-       public function testSplitKey( $key, $expect ) {
-               $this->assertSame( $expect, Xhprof::splitKey( $key ) );
-       }
-
-       public function provideSplitKey() {
-               return [
-                       [ 'main()', [ null, 'main()' ] ],
-                       [ 'foo==>bar', [ 'foo', 'bar' ] ],
-                       [ 'bar@1==>bar@2', [ 'bar@1', 'bar@2' ] ],
-                       [ 'foo==>bar==>baz', [ 'foo', 'bar==>baz' ] ],
-                       [ '==>bar', [ '', 'bar' ] ],
-                       [ '', [ null, '' ] ],
-               ];
-       }
-
-       /**
-        * @covers Xhprof::__construct
-        * @covers Xhprof::stop
-        * @covers Xhprof::getRawData
-        * @dataProvider provideRawData
-        */
-       public function testRawData( $flags, $keys ) {
-               $xhprof = new Xhprof( [ 'flags' => $flags ] );
-               $raw = $xhprof->getRawData();
-               $this->assertArrayHasKey( 'main()', $raw );
-               foreach ( $keys as $key ) {
-                       $this->assertArrayHasKey( $key, $raw['main()'] );
-               }
-       }
-
-       public function provideRawData() {
-               $tests = [
-                       [ 0, [ 'ct', 'wt' ] ],
-               ];
-
-               if ( defined( 'XHPROF_FLAGS_CPU' ) && defined( 'XHPROF_FLAGS_CPU' ) ) {
-                       $tests[] = [ XHPROF_FLAGS_MEMORY, [
-                               'ct', 'wt', 'mu', 'pmu',
-                       ] ];
-                       $tests[] = [ XHPROF_FLAGS_CPU, [
-                               'ct', 'wt', 'cpu',
-                       ] ];
-                       $tests[] = [ XHPROF_FLAGS_MEMORY | XHPROF_FLAGS_CPU, [
-                                       'ct', 'wt', 'mu', 'pmu', 'cpu',
-                               ] ];
-               }
-
-               return $tests;
-       }
-
-       /**
-        * @covers Xhprof::pruneData
-        */
-       public function testInclude() {
-               $xhprof = $this->getXhprofFixture( [
-                       'include' => [ 'main()' ],
-               ] );
-               $raw = $xhprof->getRawData();
-               $this->assertArrayHasKey( 'main()', $raw );
-               $this->assertArrayHasKey( 'main()==>foo', $raw );
-               $this->assertArrayHasKey( 'main()==>xhprof_disable', $raw );
-               $this->assertSame( 3, count( $raw ) );
-       }
-
        /**
-        * Validate the structure of data returned by
-        * Xhprof::getInclusiveMetrics(). This acts as a guard against unexpected
-        * structural changes to the returned data in lieu of using a more heavy
-        * weight typed response object.
+        * Trying to enable Xhprof when it is already enabled causes an exception
+        * to be thrown.
         *
-        * @covers Xhprof::getInclusiveMetrics
-        */
-       public function testInclusiveMetricsStructure() {
-               $metricStruct = [
-                       'ct' => 'int',
-                       'wt' => 'array',
-                       'cpu' => 'array',
-                       'mu' => 'array',
-                       'pmu' => 'array',
-               ];
-               $statStruct = [
-                       'total' => 'numeric',
-                       'min' => 'numeric',
-                       'mean' => 'numeric',
-                       'max' => 'numeric',
-                       'variance' => 'numeric',
-                       'percent' => 'numeric',
-               ];
-
-               $xhprof = $this->getXhprofFixture();
-               $metrics = $xhprof->getInclusiveMetrics();
-
-               foreach ( $metrics as $name => $metric ) {
-                       $this->assertArrayStructure( $metricStruct, $metric );
-
-                       foreach ( $metricStruct as $key => $type ) {
-                               if ( $type === 'array' ) {
-                                       $this->assertArrayStructure( $statStruct, $metric[$key] );
-                                       if ( $name === 'main()' ) {
-                                               $this->assertEquals( 100, $metric[$key]['percent'] );
-                                       }
-                               }
-                       }
-               }
-       }
-
-       /**
-        * Validate the structure of data returned by
-        * Xhprof::getCompleteMetrics(). This acts as a guard against unexpected
-        * structural changes to the returned data in lieu of using a more heavy
-        * weight typed response object.
-        *
-        * @covers Xhprof::getCompleteMetrics
-        */
-       public function testCompleteMetricsStructure() {
-               $metricStruct = [
-                       'ct' => 'int',
-                       'wt' => 'array',
-                       'cpu' => 'array',
-                       'mu' => 'array',
-                       'pmu' => 'array',
-                       'calls' => 'array',
-                       'subcalls' => 'array',
-               ];
-               $statsMetrics = [ 'wt', 'cpu', 'mu', 'pmu' ];
-               $statStruct = [
-                       'total' => 'numeric',
-                       'min' => 'numeric',
-                       'mean' => 'numeric',
-                       'max' => 'numeric',
-                       'variance' => 'numeric',
-                       'percent' => 'numeric',
-                       'exclusive' => 'numeric',
-               ];
-
-               $xhprof = $this->getXhprofFixture();
-               $metrics = $xhprof->getCompleteMetrics();
-
-               foreach ( $metrics as $name => $metric ) {
-                       $this->assertArrayStructure( $metricStruct, $metric, $name );
-
-                       foreach ( $metricStruct as $key => $type ) {
-                               if ( in_array( $key, $statsMetrics ) ) {
-                                       $this->assertArrayStructure(
-                                               $statStruct, $metric[$key], $key
-                                       );
-                                       $this->assertLessThanOrEqual(
-                                               $metric[$key]['total'], $metric[$key]['exclusive']
-                                       );
-                               }
-                       }
-               }
-       }
-
-       /**
-        * @covers Xhprof::getCallers
-        * @covers Xhprof::getCallees
-        * @uses Xhprof
-        */
-       public function testEdges() {
-               $xhprof = $this->getXhprofFixture();
-               $this->assertSame( [], $xhprof->getCallers( 'main()' ) );
-               $this->assertSame( [ 'foo', 'xhprof_disable' ],
-                       $xhprof->getCallees( 'main()' )
-               );
-               $this->assertSame( [ 'main()' ],
-                       $xhprof->getCallers( 'foo' )
-               );
-               $this->assertSame( [], $xhprof->getCallees( 'strlen' ) );
-       }
-
-       /**
-        * @covers Xhprof::getCriticalPath
-        * @uses Xhprof
-        */
-       public function testCriticalPath() {
-               $xhprof = $this->getXhprofFixture();
-               $path = $xhprof->getCriticalPath();
-
-               $last = null;
-               foreach ( $path as $key => $value ) {
-                       list( $func, $call ) = Xhprof::splitKey( $key );
-                       $this->assertSame( $last, $func );
-                       $last = $call;
-               }
-               $this->assertSame( $last, 'bar@1' );
-       }
-
-       /**
-        * Get an Xhprof instance that has been primed with a set of known testing
-        * data. Tests for the Xhprof class should laregly be concerned with
-        * evaluating the manipulations of the data collected by xhprof rather
-        * than the data collection process itself.
-        *
-        * The returned Xhprof instance primed will be with a data set created by
-        * running this trivial program using the PECL xhprof implementation:
-        * @code
-        * function bar( $x ) {
-        *   if ( $x > 0 ) {
-        *     bar($x - 1);
-        *   }
-        * }
-        * function foo() {
-        *   for ( $idx = 0; $idx < 2; $idx++ ) {
-        *     bar( $idx );
-        *     $x = strlen( 'abc' );
-        *   }
-        * }
-        * xhprof_enable( XHPROF_FLAGS_CPU | XHPROF_FLAGS_MEMORY );
-        * foo();
-        * $x = xhprof_disable();
-        * var_export( $x );
-        * @endcode
-        *
-        * @return Xhprof
-        */
-       protected function getXhprofFixture( array $opts = [] ) {
-               $xhprof = new Xhprof( $opts );
-               $xhprof->loadRawData( [
-                       'foo==>bar' => [
-                               'ct' => 2,
-                               'wt' => 57,
-                               'cpu' => 92,
-                               'mu' => 1896,
-                               'pmu' => 0,
-                       ],
-                       'foo==>strlen' => [
-                               'ct' => 2,
-                               'wt' => 21,
-                               'cpu' => 141,
-                               'mu' => 752,
-                               'pmu' => 0,
-                       ],
-                       'bar==>bar@1' => [
-                               'ct' => 1,
-                               'wt' => 18,
-                               'cpu' => 19,
-                               'mu' => 752,
-                               'pmu' => 0,
-                       ],
-                       'main()==>foo' => [
-                               'ct' => 1,
-                               'wt' => 304,
-                               'cpu' => 307,
-                               'mu' => 4008,
-                               'pmu' => 0,
-                       ],
-                       'main()==>xhprof_disable' => [
-                               'ct' => 1,
-                               'wt' => 8,
-                               'cpu' => 10,
-                               'mu' => 768,
-                               'pmu' => 392,
-                       ],
-                       'main()' => [
-                               'ct' => 1,
-                               'wt' => 353,
-                               'cpu' => 351,
-                               'mu' => 6112,
-                               'pmu' => 1424,
-                       ],
-               ] );
-               return $xhprof;
-       }
-
-       /**
-        * Assert that the given array has the described structure.
-        *
-        * @param array $struct Array of key => type mappings
-        * @param array $actual Array to check
-        * @param string $label
-        */
-       protected function assertArrayStructure( $struct, $actual, $label = null ) {
-               $this->assertInternalType( 'array', $actual, $label );
-               $this->assertCount( count( $struct ), $actual, $label );
-               foreach ( $struct as $key => $type ) {
-                       $this->assertArrayHasKey( $key, $actual );
-                       $this->assertInternalType( $type, $actual[$key] );
-               }
+        * @expectedException        Exception
+        * @expectedExceptionMessage already enabled
+        * @covers Xhprof::enable
+        */
+       public function testEnable() {
+               $xhprof = new ReflectionClass( 'Xhprof' );
+               $enabled = $xhprof->getProperty( 'enabled' );
+               $enabled->setAccessible( true );
+               $enabled->setValue( true );
+               $xhprof->getMethod( 'enable' )->invoke( null );
        }
 }
diff --git a/tests/phpunit/includes/objectcache/RedisBagOStuffTest.php b/tests/phpunit/includes/objectcache/RedisBagOStuffTest.php
new file mode 100644 (file)
index 0000000..cf87a98
--- /dev/null
@@ -0,0 +1,101 @@
+<?php
+/**
+ * @group BagOStuff
+ */
+class RedisBagOStuffTest extends MediaWikiTestCase {
+       /** @var RedisBagOStuff */
+       private $cache;
+
+       protected function setUp() {
+               parent::setUp();
+               $this->cache = TestingAccessWrapper::newFromObject( new RedisBagOStuff( [ 'servers' => [] ] ) );
+       }
+
+       /**
+        * @covers RedisBagOStuff::unserialize
+        * @dataProvider unserializeProvider
+        */
+       public function testUnserialize( $expected, $input, $message ) {
+               $actual = $this->cache->unserialize( $input );
+               $this->assertSame( $expected, $actual, $message );
+       }
+
+       public function unserializeProvider() {
+               return [
+                       [
+                               -1,
+                               '-1',
+                               'String representation of \'-1\'',
+                       ],
+                       [
+                               0,
+                               '0',
+                               'String representation of \'0\'',
+                       ],
+                       [
+                               1,
+                               '1',
+                               'String representation of \'1\'',
+                       ],
+                       [
+                               -1.0,
+                               'd:-1;',
+                               'Serialized negative double',
+                       ],
+                       [
+                               'foo',
+                               's:3:"foo";',
+                               'Serialized string',
+                       ]
+               ];
+       }
+
+       /**
+        * @covers RedisBagOStuff::serialize
+        * @dataProvider serializeProvider
+        */
+       public function testSerialize( $expected, $input, $message ) {
+               $actual = $this->cache->serialize( $input );
+               $this->assertSame( $expected, $actual, $message );
+       }
+
+       public function serializeProvider() {
+               return [
+                       [
+                               -1,
+                               -1,
+                               '-1 as integer',
+                       ],
+                       [
+                               0,
+                               0,
+                               '0 as integer',
+                       ],
+                       [
+                               1,
+                               1,
+                               '1 as integer',
+                       ],
+                       [
+                               'd:-1;',
+                               -1.0,
+                               'Negative double',
+                       ],
+                       [
+                               's:3:"2.1";',
+                               '2.1',
+                               'Decimal string',
+                       ],
+                       [
+                               's:1:"1";',
+                               '1',
+                               'String representation of 1',
+                       ],
+                       [
+                               's:3:"foo";',
+                               'foo',
+                               'String',
+                       ],
+               ];
+       }
+}
index 4c973e5..c024555 100644 (file)
@@ -100,7 +100,6 @@ class NewParserTest extends MediaWikiTestCase {
                $tmpGlobals['wgUseImageResize'] = true;
                $tmpGlobals['wgAllowExternalImages'] = true;
                $tmpGlobals['wgRawHtml'] = false;
-               $tmpGlobals['wgWellFormedXml'] = true;
                $tmpGlobals['wgExperimentalHtmlIds'] = false;
                $tmpGlobals['wgAdaptiveMessageCache'] = true;
                $tmpGlobals['wgUseDatabaseMessages'] = true;
@@ -150,7 +149,10 @@ class NewParserTest extends MediaWikiTestCase {
                $tmpGlobals['wgHooks'] = $tmpHooks;
                # add a namespace shadowing a interwiki link, to test
                # proper precedence when resolving links. (bug 51680)
-               $tmpGlobals['wgExtraNamespaces'] = [ 100 => 'MemoryAlpha' ];
+               $tmpGlobals['wgExtraNamespaces'] = [
+                       100 => 'MemoryAlpha',
+                       101 => 'MemoryAlpha_talk'
+               ];
 
                $tmpGlobals['wgLocalInterwikis'] = [ 'local', 'mi' ];
                # "extra language links"
@@ -433,7 +435,6 @@ class NewParserTest extends MediaWikiTestCase {
                        'wgThumbLimits' => [ self::getOptionValue( 'thumbsize', $opts, 180 ) ],
                        'wgMaxTocLevel' => $maxtoclevel,
                        'wgUseTeX' => isset( $opts['math'] ) || isset( $opts['texvc'] ),
-                       'wgWellFormedXml' => true,
                        'wgMathDirectory' => $uploadDir . '/math',
                        'wgDefaultLanguageVariant' => $variant,
                        'wgLinkHolderBatchSize' => $linkHolderBatchSize,
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, [
index 40065f5..e55a3a4 100644 (file)
@@ -191,7 +191,10 @@ class MediaWikiTitleCodecTest extends MediaWikiTestCase {
                        // names ending in "a" to be female.
                        [ NS_USER, 'Lisa_Müller', '', '', 'de', 'Benutzerin:Lisa_Müller' ],
 
-                       [ NS_MAIN, 'Remote_page', '', 'remotetestiw', 'en', 'remotetestiw:Remote_page' ]
+                       [ NS_MAIN, 'Remote_page', '', 'remotetestiw', 'en', 'remotetestiw:Remote_page' ],
+
+                       // non-existent namespace
+                       [ 10000000, 'Foobar', '', '', 'en', ':Foobar' ],
                ];
        }
 
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(
index 8c96697..aa68bb2 100644 (file)
        } );
 
        // HTML in wikitext
-       QUnit.test( 'HTML', 32, function ( assert ) {
+       QUnit.test( 'HTML', 33, function ( assert ) {
                mw.messages.set( 'jquerymsg-italics-msg', '<i>Very</i> important' );
 
                assertBothModes( assert, [ 'jquerymsg-italics-msg' ], mw.messages.get( 'jquerymsg-italics-msg' ), 'Simple italics unchanged' );
                        'Self-closing tags don\'t cause a parse error'
                );
 
+               mw.messages.set( 'jquerymsg-asciialphabetliteral-regression', '<b >>>="dir">asd</b>' );
+               assert.htmlEqual(
+                       formatParse( 'jquerymsg-asciialphabetliteral-regression' ),
+                       '<b>&gt;&gt;="dir"&gt;asd</b>',
+                       'Regression test for bad "asciiAlphabetLiteral" definition'
+               );
+
                mw.messages.set( 'jquerymsg-entities1', 'A&B' );
                mw.messages.set( 'jquerymsg-entities2', 'A&gt;B' );
                mw.messages.set( 'jquerymsg-entities3', 'A&rarr;B' );