auth: Add AuthManagerFilterProviders hook

Allow disabling authentication providers. This allows for
extensions to replace core providers with their own.

This is using the $wgAuthManagerAutoConfig keys instead of
AuthenticationProvider::getUniqueId() as the keys to filter.
This makes it more useful for site administrators, and also
it's probably the better known of the two identifiers so
more intuitive.

No effort is made to prevent the hook from filtering
differently in different steps of the same authentication
process.

Bug: T369180
Change-Id: If5435b54a4fc08f685c04fc10eb44c6d72cd78fa
This commit is contained in:
Gergő Tisza 2024-07-28 16:54:28 +02:00 committed by Bartosz Dziewoński
parent e04879ad01
commit cde00b5585
6 changed files with 197 additions and 34 deletions

View file

@ -113,7 +113,7 @@ For notes on 1.42.x and older releases, see HISTORY.
accurate text representation of duration.
* Added an interactive mode to the install.php maintenance script.
* The ResourceLoaderModifyStartupSourceUrls hook was added.
*
* The AuthManagerFilterProviders hook was added.
* …
=== External library changes in 1.43 ===

View file

@ -869,6 +869,7 @@ $wgAutoloadLocalClasses = [
'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\\Hook\\AuthManagerFilterProvidersHook' => __DIR__ . '/includes/auth/Hook/AuthManagerFilterProvidersHook.php',
'MediaWiki\\Auth\\Hook\\AuthManagerLoginAuthenticateAuditHook' => __DIR__ . '/includes/auth/Hook/AuthManagerLoginAuthenticateAuditHook.php',
'MediaWiki\\Auth\\Hook\\AuthPreserveQueryParamsHook' => __DIR__ . '/includes/auth/Hook/AuthPreserveQueryParamsHook.php',
'MediaWiki\\Auth\\Hook\\ExemptFromAccountCreationThrottleHook' => __DIR__ . '/includes/auth/Hook/ExemptFromAccountCreationThrottleHook.php',

View file

@ -43,6 +43,7 @@ use Wikimedia\Rdbms\SelectQueryBuilder;
*/
class HookRunner implements
\MediaWiki\Actions\Hook\GetActionNameHook,
\MediaWiki\Auth\Hook\AuthManagerFilterProvidersHook,
\MediaWiki\Auth\Hook\AuthManagerLoginAuthenticateAuditHook,
\MediaWiki\Auth\Hook\AuthPreserveQueryParamsHook,
\MediaWiki\Auth\Hook\ExemptFromAccountCreationThrottleHook,
@ -886,6 +887,14 @@ class HookRunner implements
);
}
public function onAuthManagerFilterProviders( array &$providers ): void {
$this->container->run(
'AuthManagerFilterProviders',
[ &$providers ],
[ 'abortable' => false ]
);
}
public function onAuthManagerLoginAuthenticateAudit( $response, $user,
$username, $extraData
) {

View file

@ -25,6 +25,7 @@ namespace MediaWiki\Auth;
use IDBAccessObject;
use InvalidArgumentException;
use LogicException;
use MediaWiki\Block\BlockManager;
use MediaWiki\Config\Config;
use MediaWiki\Context\RequestContext;
@ -53,10 +54,12 @@ use MediaWiki\User\UserIdentityLookup;
use MediaWiki\User\UserNameUtils;
use MediaWiki\User\UserRigorOptions;
use MediaWiki\Watchlist\WatchlistManager;
use MWExceptionHandler;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use StatusValue;
use Wikimedia\NormalizedException\NormalizedException;
use Wikimedia\ObjectFactory\ObjectFactory;
use Wikimedia\Rdbms\ILoadBalancer;
use Wikimedia\Rdbms\ReadOnlyMode;
@ -400,7 +403,7 @@ class AuthManager implements LoggerAwareInterface {
if ( !$session->canSetUser() ) {
// Caller should have called canAuthenticateNow()
$session->remove( self::AUTHN_STATE );
throw new \LogicException( 'Authentication is not possible now' );
throw new LogicException( 'Authentication is not possible now' );
}
$guessUserName = null;
@ -424,7 +427,7 @@ class AuthManager implements LoggerAwareInterface {
);
if ( $req ) {
if ( !in_array( $req, $this->createdAccountAuthenticationRequests, true ) ) {
throw new \LogicException(
throw new LogicException(
'CreatedAccountAuthenticationRequests are only valid on ' .
'the same AuthManager that created the account'
);
@ -481,6 +484,7 @@ class AuthManager implements LoggerAwareInterface {
'reqs' => $reqs,
'returnToUrl' => $returnToUrl,
'guessUserName' => $guessUserName,
'providerIds' => $this->getProviderIds(),
'primary' => null,
'primaryResponse' => null,
'secondary' => [],
@ -531,7 +535,7 @@ class AuthManager implements LoggerAwareInterface {
if ( !$session->canSetUser() ) {
// Caller should have called canAuthenticateNow()
// @codeCoverageIgnoreStart
throw new \LogicException( 'Authentication is not possible now' );
throw new LogicException( 'Authentication is not possible now' );
// @codeCoverageIgnoreEnd
}
@ -541,6 +545,26 @@ class AuthManager implements LoggerAwareInterface {
wfMessage( 'authmanager-authn-not-in-progress' )
);
}
if ( $state['providerIds'] !== $this->getProviderIds() ) {
// An inconsistent AuthManagerFilterProviders hook, or site configuration changed
// while the user was in the middle of authentication. The first is a bug, the
// second is rare but expected when deploying a config change. Try handle in a way
// that's useful for both cases.
// @codeCoverageIgnoreStart
MWExceptionHandler::logException( new NormalizedException(
'Authentication failed because of inconsistent provider array',
[ 'old' => json_encode( $state['providerIds'] ), 'new' => json_encode( $this->getProviderIds() ) ]
) );
$ret = AuthenticationResponse::newFail(
wfMessage( 'authmanager-authn-not-in-progress' )
);
$this->callMethodOnProviders( 7, 'postAuthentication',
[ $this->userFactory->newFromName( (string)$state['guessUserName'] ), $ret ]
);
$session->remove( self::AUTHN_STATE );
return $ret;
// @codeCoverageIgnoreEnd
}
$state['continueRequests'] = [];
$guessUserName = $state['guessUserName'];
@ -1272,7 +1296,7 @@ class AuthManager implements LoggerAwareInterface {
if ( !$this->canCreateAccounts() ) {
// Caller should have called canCreateAccounts()
$session->remove( self::ACCOUNT_CREATION_STATE );
throw new \LogicException( 'Account creation is not possible' );
throw new LogicException( 'Account creation is not possible' );
}
try {
@ -1337,6 +1361,7 @@ class AuthManager implements LoggerAwareInterface {
'creatorname' => $creator->getUser()->getName(),
'reqs' => $reqs,
'returnToUrl' => $returnToUrl,
'providerIds' => $this->getProviderIds(),
'primary' => null,
'primaryResponse' => null,
'secondary' => [],
@ -1375,7 +1400,7 @@ class AuthManager implements LoggerAwareInterface {
if ( !$this->canCreateAccounts() ) {
// Caller should have called canCreateAccounts()
$session->remove( self::ACCOUNT_CREATION_STATE );
throw new \LogicException( 'Account creation is not possible' );
throw new LogicException( 'Account creation is not possible' );
}
$state = $session->getSecret( self::ACCOUNT_CREATION_STATE );
@ -1407,6 +1432,25 @@ class AuthManager implements LoggerAwareInterface {
$creator->setName( $state['creatorname'] );
}
if ( $state['providerIds'] !== $this->getProviderIds() ) {
// An inconsistent AuthManagerFilterProviders hook, or site configuration changed
// while the user was in the middle of authentication. The first is a bug, the
// second is rare but expected when deploying a config change. Try handle in a way
// that's useful for both cases.
// @codeCoverageIgnoreStart
MWExceptionHandler::logException( new NormalizedException(
'Authentication failed because of inconsistent provider array',
[ 'old' => json_encode( $state['providerIds'] ), 'new' => json_encode( $this->getProviderIds() ) ]
) );
$ret = AuthenticationResponse::newFail(
wfMessage( 'authmanager-create-not-in-progress' )
);
$this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
$session->remove( self::ACCOUNT_CREATION_STATE );
return $ret;
// @codeCoverageIgnoreEnd
}
// Avoid account creation races on double submissions
$cache = MediaWikiServices::getInstance()->getObjectCacheFactory()->getLocalClusterInstance();
$lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $user->getName() ) ) );
@ -2087,7 +2131,7 @@ class AuthManager implements LoggerAwareInterface {
if ( !$this->canLinkAccounts() ) {
// Caller should have called canLinkAccounts()
throw new \LogicException( 'Account linking is not possible' );
throw new LogicException( 'Account linking is not possible' );
}
if ( !$user->isRegistered() ) {
@ -2124,6 +2168,7 @@ class AuthManager implements LoggerAwareInterface {
'username' => $user->getName(),
'userid' => $user->getId(),
'returnToUrl' => $returnToUrl,
'providerIds' => $this->getProviderIds(),
'primary' => null,
'continueRequests' => [],
];
@ -2196,7 +2241,7 @@ class AuthManager implements LoggerAwareInterface {
if ( !$this->canLinkAccounts() ) {
// Caller should have called canLinkAccounts()
$session->remove( self::ACCOUNT_LINK_STATE );
throw new \LogicException( 'Account linking is not possible' );
throw new LogicException( 'Account linking is not possible' );
}
$state = $session->getSecret( self::ACCOUNT_LINK_STATE );
@ -2224,6 +2269,25 @@ class AuthManager implements LoggerAwareInterface {
);
}
if ( $state['providerIds'] !== $this->getProviderIds() ) {
// An inconsistent AuthManagerFilterProviders hook, or site configuration changed
// while the user was in the middle of authentication. The first is a bug, the
// second is rare but expected when deploying a config change. Try handle in a way
// that's useful for both cases.
// @codeCoverageIgnoreStart
MWExceptionHandler::logException( new NormalizedException(
'Authentication failed because of inconsistent provider array',
[ 'old' => json_encode( $state['providerIds'] ), 'new' => json_encode( $this->getProviderIds() ) ]
) );
$ret = AuthenticationResponse::newFail(
wfMessage( 'authmanager-link-not-in-progress' )
);
$this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $ret ] );
$session->remove( self::ACCOUNT_LINK_STATE );
return $ret;
// @codeCoverageIgnoreEnd
}
foreach ( $reqs as $req ) {
$req->username = $state['username'];
$req->returnToUrl = $state['returnToUrl'];
@ -2611,24 +2675,13 @@ class AuthManager implements LoggerAwareInterface {
return $ret;
}
/**
* @return array
*/
private function getConfiguration() {
return $this->config->get( MainConfigNames::AuthManagerConfig )
?: $this->config->get( MainConfigNames::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']
);
$this->initializeAuthenticationProviders();
}
return $this->preAuthenticationProviders;
}
@ -2639,10 +2692,7 @@ class AuthManager implements LoggerAwareInterface {
*/
protected function getPrimaryAuthenticationProviders() {
if ( $this->primaryAuthenticationProviders === null ) {
$conf = $this->getConfiguration();
$this->primaryAuthenticationProviders = $this->providerArrayFromSpecs(
PrimaryAuthenticationProvider::class, $conf['primaryauth']
);
$this->initializeAuthenticationProviders();
}
return $this->primaryAuthenticationProviders;
}
@ -2653,14 +2703,40 @@ class AuthManager implements LoggerAwareInterface {
*/
protected function getSecondaryAuthenticationProviders() {
if ( $this->secondaryAuthenticationProviders === null ) {
$conf = $this->getConfiguration();
$this->secondaryAuthenticationProviders = $this->providerArrayFromSpecs(
SecondaryAuthenticationProvider::class, $conf['secondaryauth']
);
$this->initializeAuthenticationProviders();
}
return $this->secondaryAuthenticationProviders;
}
private function getProviderIds(): array {
return [
'preauth' => array_keys( $this->getPreAuthenticationProviders() ),
'primaryauth' => array_keys( $this->getPrimaryAuthenticationProviders() ),
'secondaryauth' => array_keys( $this->getSecondaryAuthenticationProviders() ),
];
}
private function initializeAuthenticationProviders() {
$conf = $this->config->get( MainConfigNames::AuthManagerConfig )
?: $this->config->get( MainConfigNames::AuthManagerAutoConfig );
$providers = array_map( fn ( $stepConf ) => array_fill_keys( array_keys( $stepConf ), true ), $conf );
$this->getHookRunner()->onAuthManagerFilterProviders( $providers );
foreach ( $conf as $step => $stepConf ) {
$conf[$step] = array_intersect_key( $stepConf, array_filter( $providers[$step] ) );
}
$this->preAuthenticationProviders = $this->providerArrayFromSpecs(
PreAuthenticationProvider::class, $conf['preauth']
);
$this->primaryAuthenticationProviders = $this->providerArrayFromSpecs(
PrimaryAuthenticationProvider::class, $conf['primaryauth']
);
$this->secondaryAuthenticationProviders = $this->providerArrayFromSpecs(
SecondaryAuthenticationProvider::class, $conf['secondaryauth']
);
}
/**
* Log the user in
* @param User $user

View file

@ -0,0 +1,41 @@
<?php
namespace MediaWiki\Auth\Hook;
use MediaWiki\MainConfigSchema;
/**
* This is a hook handler interface, see docs/Hooks.md.
* Use the hook name "AuthManagerFilterProviders" to register handlers implementing this interface.
*
* @stable to implement
* @ingroup Hooks
*/
interface AuthManagerFilterProvidersHook {
/**
* Filter the list of authentication available providers. Providers removed from the
* list will be disabled for the current request, and any authentication process started
* from the current request.
*
* Hook handlers don't have to always return the same result for the given configuration
* (can depend on the request, e.g. feature flags) but they do have to be consistent
* within an authentication process that spans multiple requests.
*
* @since 1.43
*
* @param bool[][] &$providers An array with three sub-arrays: 'preauth', 'primaryauth',
* 'secondaryauth'. Each field in the subarrays is a map of <provider key> => true.
* (The provider key is the same array key that's used in $wgAuthManagerAutoConfig or
* $wgAuthManagerConfig). Unsetting a field or setting its value to falsy disables the
* corresponding provider.
* @phpcs:ignore Generic.Files.LineLength.TooLong
* @phan-param array{preauth:array<string,true>,primaryauth:array<string,true>,secondaryauth:array<string,true>} $providers
* @return void This hook must not abort, it must return no value
*
* @see https://www.mediawiki.org/wiki/Manual:Hooks/AuthManagerFilterProviders
* @see MainConfigSchema::AuthManagerAutoConfig
*/
public function onAuthManagerFilterProviders( array &$providers ): void;
}

View file

@ -111,7 +111,7 @@ class AuthManagerTest extends MediaWikiIntegrationTestCase {
/** @var AuthManager */
protected $manager;
/** @var TestingAccessWrapper */
/** @var TestingAccessWrapper|AuthManager */
protected $managerPriv;
/** @var BlockManager */
@ -339,8 +339,7 @@ class AuthManagerTest extends MediaWikiIntegrationTestCase {
$this->botPasswordStore,
$this->userFactory,
$this->userIdentityLookup,
$this->userOptionsManager,
$this->objectCacheFactory
$this->userOptionsManager
);
$this->manager->setLogger( $this->logger );
$this->managerPriv = TestingAccessWrapper::newFromObject( $this->manager );
@ -713,7 +712,7 @@ class AuthManagerTest extends MediaWikiIntegrationTestCase {
$class1 = get_class( $mock1 );
$class2 = get_class( $mock2 );
$this->assertSame(
"Duplicate specifications for id X (classes $class1 and $class2)", $ex->getMessage()
"Duplicate specifications for id X (classes $class2 and $class1)", $ex->getMessage()
);
}
@ -722,8 +721,8 @@ class AuthManagerTest extends MediaWikiIntegrationTestCase {
$mock->method( 'getUniqueId' )->willReturn( 'X' );
$class = get_class( $mock );
$this->preauthMocks = [ $mock ];
$this->primaryauthMocks = [ $mock ];
$this->secondaryauthMocks = [ $mock ];
$this->primaryauthMocks = [];
$this->secondaryauthMocks = [];
$this->initializeManager( true );
try {
$this->managerPriv->getPreAuthenticationProviders();
@ -734,6 +733,10 @@ class AuthManagerTest extends MediaWikiIntegrationTestCase {
$ex->getMessage()
);
}
$this->preauthMocks = [];
$this->primaryauthMocks = [ $mock ];
$this->secondaryauthMocks = [];
$this->initializeManager( true );
try {
$this->managerPriv->getPrimaryAuthenticationProviders();
$this->fail( 'Expected exception not thrown' );
@ -743,6 +746,10 @@ class AuthManagerTest extends MediaWikiIntegrationTestCase {
$ex->getMessage()
);
}
$this->preauthMocks = [];
$this->primaryauthMocks = [];
$this->secondaryauthMocks = [ $mock ];
$this->initializeManager( true );
try {
$this->managerPriv->getSecondaryAuthenticationProviders();
$this->fail( 'Expected exception not thrown' );
@ -780,6 +787,33 @@ class AuthManagerTest extends MediaWikiIntegrationTestCase {
[ 'C' => $mock3, 'B' => $mock2, 'A' => $mock1 ],
$this->managerPriv->getPrimaryAuthenticationProviders()
);
// filtering
$mockPreAuth1 = $this->createMock( AbstractPreAuthenticationProvider::class );
$mockPreAuth2 = $this->createMock( AbstractPreAuthenticationProvider::class );
$mockPrimary1 = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
$mockPrimary2 = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
$mockSecondary1 = $this->createMock( AbstractSecondaryAuthenticationProvider::class );
$mockSecondary2 = $this->createMock( AbstractSecondaryAuthenticationProvider::class );
$mockPreAuth1->method( 'getUniqueId' )->willReturn( 'pre1' );
$mockPreAuth2->method( 'getUniqueId' )->willReturn( 'pre2' );
$mockPrimary1->method( 'getUniqueId' )->willReturn( 'primary1' );
$mockPrimary2->method( 'getUniqueId' )->willReturn( 'primary2' );
$mockSecondary1->method( 'getUniqueId' )->willReturn( 'secondary1' );
$mockSecondary2->method( 'getUniqueId' )->willReturn( 'secondary2' );
$this->preauthMocks = [ $mockPreAuth1, $mockPreAuth2 ];
$this->primaryauthMocks = [ $mockPrimary1, $mockPrimary2 ];
$this->secondaryauthMocks = [ $mockSecondary1, $mockSecondary2 ];
$this->initializeConfig();
$this->initializeManager( true );
$this->hookContainer->register( 'AuthManagerFilterProviders', static function ( &$providers ) {
unset( $providers['preauth']['pre1'] );
$providers['primaryauth']['primary2'] = false;
$providers['secondaryauth'] = [ 'secondary2' => true ];
} );
$this->assertSame( [ 'pre2' => $mockPreAuth2 ], $this->managerPriv->getPreAuthenticationProviders() );
$this->assertSame( [ 'primary1' => $mockPrimary1 ], $this->managerPriv->getPrimaryAuthenticationProviders() );
$this->assertSame( [ 'secondary2' => $mockSecondary2 ], $this->managerPriv->getSecondaryAuthenticationProviders() );
}
/**
@ -2013,6 +2047,7 @@ class AuthManagerTest extends MediaWikiIntegrationTestCase {
'creatorid' => 0,
'creatorname' => $username,
'reqs' => [],
'providerIds' => [ 'preauth' => [], 'primaryauth' => [], 'secondaryauth' => [] ],
'primary' => null,
'primaryResponse' => null,
'secondary' => [],
@ -2036,6 +2071,7 @@ class AuthManagerTest extends MediaWikiIntegrationTestCase {
$mock->method( 'beginPrimaryAccountCreation' )
->willReturn( AuthenticationResponse::newFail( $this->message( 'fail' ) ) );
$this->primaryauthMocks = [ $mock ];
$session['providerIds']['primaryauth'][] = 'X';
$this->initializeManager( true );
$this->request->getSession()->setSecret( AuthManager::ACCOUNT_CREATION_STATE, null );