IP Masking: Expire temporary accounts in 1 year
Why: Temporary accounts (introduced as part of IP Masking) are supposed to expire 1 year after their registration. Automatic account expiration can be done via a maintenance script, which would be periodically executed via cron / systemd. Make it possible for extensions to provide their own logic for generating a list of temporary accounts to invalidate. This is used in CentralAuth to base registration timestamp on the global registration timestamp. The default behavior is "temporary accounts do not expire", given the feature requires a maintenance script to run periodically, which will not be the case on third party instances. What: * Add `expireAfterDays` to $wgAutoCreateTempUser, controlling how many days temporary accounts have. * Add UserSelectQueryBuilder::whereRegisteredTimestamp(), filtering accounts based on user_registration. * Add ExpireTemporaryAccounts maintenance script, which is @stable to extend. Bug: T344695 Change-Id: If17bf84ee6620c8eb784b7d835682ad5e7afdfcc
This commit is contained in:
parent
fd5fe2ec21
commit
c9908da103
20 changed files with 245 additions and 1 deletions
|
|
@ -443,6 +443,7 @@ $wgAutoloadLocalClasses = [
|
|||
'ExecutableFinder' => __DIR__ . '/includes/utils/ExecutableFinder.php',
|
||||
'Exif' => __DIR__ . '/includes/media/Exif.php',
|
||||
'ExifBitmapHandler' => __DIR__ . '/includes/media/ExifBitmapHandler.php',
|
||||
'ExpireTemporaryAccounts' => __DIR__ . '/maintenance/expireTemporaryAccounts.php',
|
||||
'ExplodeIterator' => __DIR__ . '/includes/libs/ExplodeIterator.php',
|
||||
'ExportProgressFilter' => __DIR__ . '/includes/export/ExportProgressFilter.php',
|
||||
'ExportSites' => __DIR__ . '/maintenance/exportSites.php',
|
||||
|
|
|
|||
|
|
@ -4855,6 +4855,7 @@ config-schema:
|
|||
reservedPattern: { type: [string, 'null'], default: null }
|
||||
serialProvider: { type: object, default: { type: local } }
|
||||
serialMapping: { type: object, default: { type: plain-numeric } }
|
||||
expireAfterDays: { type: [integer, 'null'], default: null }
|
||||
type: object
|
||||
description: |-
|
||||
Configuration for automatic creation of temporary accounts on page save.
|
||||
|
|
@ -4901,6 +4902,9 @@ config-schema:
|
|||
be zero-based array indexes.
|
||||
- uppercase: (bool) With "filtered-radix", whether to use uppercase
|
||||
letters, default false.
|
||||
- expireAfterDays: (int|null, default null) If set, how many days should the temporary
|
||||
accounts expire? Require expireTemporaryAccounts.php to be periodically executed in
|
||||
order to work.
|
||||
@since 1.39
|
||||
default: null
|
||||
AutoblockExpiry:
|
||||
|
|
|
|||
|
|
@ -7669,6 +7669,9 @@ class MainConfigSchema {
|
|||
* be zero-based array indexes.
|
||||
* - uppercase: (bool) With "filtered-radix", whether to use uppercase
|
||||
* letters, default false.
|
||||
* - expireAfterDays: (int|null, default null) If set, how many days should the temporary
|
||||
* accounts expire? Require expireTemporaryAccounts.php to be periodically executed in
|
||||
* order to work.
|
||||
*
|
||||
* @since 1.39
|
||||
*/
|
||||
|
|
@ -7680,7 +7683,8 @@ class MainConfigSchema {
|
|||
'matchPattern' => [ 'type' => 'string', 'default' => '*$1' ],
|
||||
'reservedPattern' => [ 'type' => 'string|null', 'default' => null ],
|
||||
'serialProvider' => [ 'type' => 'object', 'default' => [ 'type' => 'local' ] ],
|
||||
'serialMapping' => [ 'type' => 'object', 'default' => [ 'type' => 'plain-numeric' ] ]
|
||||
'serialMapping' => [ 'type' => 'object', 'default' => [ 'type' => 'plain-numeric' ] ],
|
||||
'expireAfterDays' => [ 'type' => 'int|null', 'default' => null ],
|
||||
],
|
||||
'type' => 'object',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1160,6 +1160,7 @@ return [
|
|||
'serialMapping' => [
|
||||
'type' => 'plain-numeric',
|
||||
],
|
||||
'expireAfterDays' => null,
|
||||
],
|
||||
'AutoblockExpiry' => 86400,
|
||||
'BlockAllowsUTEdit' => true,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ class RealTempUserConfig implements TempUserConfig {
|
|||
/** @var Pattern|null */
|
||||
private $reservedPattern;
|
||||
|
||||
/** @var int|null */
|
||||
private $expireAfterDays;
|
||||
|
||||
/**
|
||||
* @param array $config See the documentation of $wgAutoCreateTempUser.
|
||||
* - enabled: bool
|
||||
|
|
@ -41,6 +44,7 @@ class RealTempUserConfig implements TempUserConfig {
|
|||
* - reservedPattern: string, optional
|
||||
* - serialProvider: array
|
||||
* - serialMapping: array
|
||||
* - expireAfterDays: int, optional
|
||||
*/
|
||||
public function __construct( $config ) {
|
||||
if ( $config['enabled'] ?? false ) {
|
||||
|
|
@ -54,6 +58,7 @@ class RealTempUserConfig implements TempUserConfig {
|
|||
}
|
||||
$this->serialProviderConfig = $config['serialProvider'];
|
||||
$this->serialMappingConfig = $config['serialMapping'];
|
||||
$this->expireAfterDays = $config['expireAfterDays'];
|
||||
}
|
||||
if ( isset( $config['reservedPattern'] ) ) {
|
||||
$this->reservedPattern = new Pattern( 'reservedPattern', $config['reservedPattern'] );
|
||||
|
|
@ -104,6 +109,10 @@ class RealTempUserConfig implements TempUserConfig {
|
|||
}
|
||||
}
|
||||
|
||||
public function getExpireAfterDays(): ?int {
|
||||
return $this->expireAfterDays;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal For TempUserCreator only
|
||||
* @return Pattern
|
||||
|
|
|
|||
|
|
@ -74,4 +74,14 @@ interface TempUserConfig {
|
|||
* @return Pattern
|
||||
*/
|
||||
public function getMatchPattern(): Pattern;
|
||||
|
||||
/**
|
||||
* After how many days do temporary users expire?
|
||||
*
|
||||
* @note expireTemporaryAccounts.php maintenance script needs to be periodically executed for
|
||||
* temp account expiry to work.
|
||||
* @since 1.42
|
||||
* @return int|null Null if temp accounts should never expire
|
||||
*/
|
||||
public function getExpireAfterDays(): ?int;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,10 @@ class TempUserCreator implements TempUserConfig {
|
|||
return $this->config->getMatchPattern();
|
||||
}
|
||||
|
||||
public function getExpireAfterDays(): ?int {
|
||||
return $this->config->getExpireAfterDays();
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a new username and return it. Permanently reserve the ID in
|
||||
* the database.
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ class UserSelectQueryBuilder extends SelectQueryBuilder {
|
|||
private $actorStore;
|
||||
private TempUserConfig $tempUserConfig;
|
||||
|
||||
private bool $userJoined = false;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* @param IReadableDatabase $db
|
||||
|
|
@ -132,6 +134,26 @@ class UserSelectQueryBuilder extends SelectQueryBuilder {
|
|||
return $this->whereUserNamePrefix( $prefix );
|
||||
}
|
||||
|
||||
/**
|
||||
* Find registered users who registered
|
||||
*
|
||||
* @param string $timestamp
|
||||
* @param bool $direction Direction flag (if true, user_registration must be before $timestamp)
|
||||
* @since 1.42
|
||||
* @return UserSelectQueryBuilder
|
||||
*/
|
||||
public function whereRegisteredTimestamp( string $timestamp, bool $direction ): self {
|
||||
if ( !$this->userJoined ) {
|
||||
$this->join( 'user', null, [ "actor_user=user_id" ] );
|
||||
$this->userJoined = true;
|
||||
}
|
||||
|
||||
$this->conds( 'user_registration ' .
|
||||
( $direction ? '< ' : '> ' ) .
|
||||
$this->db->addQuotes( $this->db->timestamp( $timestamp ) ) );
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order results by name in $direction
|
||||
*
|
||||
|
|
|
|||
176
maintenance/expireTemporaryAccounts.php
Normal file
176
maintenance/expireTemporaryAccounts.php
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
<?php
|
||||
|
||||
use MediaWiki\Auth\AuthManager;
|
||||
use MediaWiki\MediaWikiServices;
|
||||
use MediaWiki\Session\SessionManager;
|
||||
use MediaWiki\User\TempUser\TempUserConfig;
|
||||
use MediaWiki\User\UserFactory;
|
||||
use MediaWiki\User\UserIdentity;
|
||||
use MediaWiki\User\UserIdentityLookup;
|
||||
use MediaWiki\User\UserSelectQueryBuilder;
|
||||
use Wikimedia\LightweightObjectStore\ExpirationAwareness;
|
||||
use Wikimedia\Rdbms\SelectQueryBuilder;
|
||||
|
||||
require_once __DIR__ . '/Maintenance.php';
|
||||
|
||||
/**
|
||||
* Expire temporary accounts that are registered for longer than `expiryAfterDays` days
|
||||
* (defined in $wgAutoCreateTempUser) by forcefully logging them out.
|
||||
*
|
||||
* Extensions can extend this class to provide their own logic of determining a list
|
||||
* of temporary accounts to expire.
|
||||
*
|
||||
* @stable to extend
|
||||
* @since 1.42
|
||||
*/
|
||||
class ExpireTemporaryAccounts extends Maintenance {
|
||||
|
||||
protected UserIdentityLookup $userIdentityLookup;
|
||||
protected UserFactory $userFactory;
|
||||
protected AuthManager $authManager;
|
||||
protected TempUserConfig $tempUserConfig;
|
||||
|
||||
public function __construct() {
|
||||
parent::__construct();
|
||||
|
||||
$this->addDescription( 'Expire temporary accounts that exist for more than N days' );
|
||||
$this->addOption( 'frequency', 'How frequently the script runs [days]', true, true );
|
||||
$this->addOption( 'verbose', 'Verbose logging output' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct services the script needs to use
|
||||
*
|
||||
* @stable to override
|
||||
*/
|
||||
protected function initServices(): void {
|
||||
$services = MediaWikiServices::getInstance();
|
||||
|
||||
$this->userIdentityLookup = $services->getUserIdentityLookup();
|
||||
$this->userFactory = $services->getUserFactory();
|
||||
$this->authManager = $services->getAuthManager();
|
||||
$this->tempUserConfig = $services->getTempUserConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* If --verbose is passed, log to output
|
||||
*
|
||||
* @param string $log
|
||||
* @return void
|
||||
*/
|
||||
protected function verboseLog( string $log ) {
|
||||
if ( $this->hasOption( 'verbose' ) ) {
|
||||
$this->output( $log );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a SelectQueryBuilder that returns temp accounts to invalidate
|
||||
*
|
||||
* This method should return temporary accounts that registered before $registeredBeforeUnix.
|
||||
* To avoid returning an ever-growing set of accounts, the method should skip users that were
|
||||
* supposedly invalidated by a previous script run (script runs each $frequencyDays days).
|
||||
*
|
||||
* If you override this method, you probably also want to override
|
||||
* queryBuilderToUserIdentities().
|
||||
*
|
||||
* @stable to override
|
||||
* @param int $registeredBeforeUnix Cutoff Unix timestamp
|
||||
* @param int $frequencyDays Script runs each $frequencyDays days
|
||||
* @return SelectQueryBuilder
|
||||
*/
|
||||
protected function getTempAccountsToExpireQueryBuilder(
|
||||
int $registeredBeforeUnix,
|
||||
int $frequencyDays
|
||||
): SelectQueryBuilder {
|
||||
return $this->userIdentityLookup->newSelectQueryBuilder()
|
||||
->temp()
|
||||
->whereRegisteredTimestamp( wfTimestamp(
|
||||
TS_MW,
|
||||
$registeredBeforeUnix
|
||||
), true )
|
||||
->whereRegisteredTimestamp( wfTimestamp(
|
||||
TS_MW,
|
||||
$registeredBeforeUnix - ExpirationAwareness::TTL_DAY * $frequencyDays
|
||||
), false );
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a SelectQueryBuilder into a list of user identities
|
||||
*
|
||||
* Default implementation expects $queryBuilder is an instance of UserSelectQueryBuilder. If
|
||||
* you override getTempAccountsToExpireQueryBuilder() to work with a different query builder,
|
||||
* this method should be overriden to properly convert the query builder into user identities.
|
||||
*
|
||||
* @throws LogicException if $queryBuilder is not UserSelectQueryBuilder
|
||||
* @stable to override
|
||||
* @param SelectQueryBuilder $queryBuilder
|
||||
* @return Iterator<UserIdentity>
|
||||
*/
|
||||
protected function queryBuilderToUserIdentities( SelectQueryBuilder $queryBuilder ): Iterator {
|
||||
if ( $queryBuilder instanceof UserSelectQueryBuilder ) {
|
||||
return $queryBuilder->fetchUserIdentities();
|
||||
}
|
||||
|
||||
throw new LogicException(
|
||||
'$queryBuilder is not UserSelectQueryBuilder. Did you forget to override ' .
|
||||
__METHOD__ . '?'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expire a temporary account
|
||||
*
|
||||
* Default implementation calls AuthManager::revokeAccessForUser and
|
||||
* SessionManager::invalidateSessionsForUser.
|
||||
*
|
||||
* @stable to override
|
||||
* @param UserIdentity $tempAccountUserIdentity
|
||||
*/
|
||||
protected function expireTemporaryAccount( UserIdentity $tempAccountUserIdentity ): void {
|
||||
$this->authManager->revokeAccessForUser( $tempAccountUserIdentity->getName() );
|
||||
SessionManager::singleton()->invalidateSessionsForUser(
|
||||
$this->userFactory->newFromUserIdentity( $tempAccountUserIdentity )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function execute() {
|
||||
$this->initServices();
|
||||
|
||||
if ( !$this->tempUserConfig->isEnabled() ) {
|
||||
$this->output( 'Temporary accounts are disabled' . PHP_EOL );
|
||||
return;
|
||||
}
|
||||
|
||||
$frequencyDays = (int)$this->getOption( 'frequency' );
|
||||
$expiryAfterDays = $this->tempUserConfig->getExpireAfterDays();
|
||||
if ( !$expiryAfterDays ) {
|
||||
$this->output( 'Temporary account expiry is not enabled' . PHP_EOL );
|
||||
return;
|
||||
}
|
||||
$registeredBeforeUnix = (int)wfTimestamp( TS_UNIX ) - ExpirationAwareness::TTL_DAY * $expiryAfterDays;
|
||||
|
||||
$tempAccounts = $this->queryBuilderToUserIdentities( $this->getTempAccountsToExpireQueryBuilder(
|
||||
$registeredBeforeUnix,
|
||||
$frequencyDays
|
||||
)->caller( __METHOD__ ) );
|
||||
|
||||
$revokedUsers = 0;
|
||||
foreach ( $tempAccounts as $tempAccountUserIdentity ) {
|
||||
$this->expireTemporaryAccount( $tempAccountUserIdentity );
|
||||
|
||||
$this->verboseLog(
|
||||
'Revoking access for ' . $tempAccountUserIdentity->getName() . PHP_EOL
|
||||
);
|
||||
$revokedUsers++;
|
||||
}
|
||||
|
||||
$this->output( "Revoked access for $revokedUsers temporary users." . PHP_EOL );
|
||||
}
|
||||
}
|
||||
|
||||
$maintClass = ExpireTemporaryAccounts::class;
|
||||
require_once RUN_MAINTENANCE_IF_MAIN;
|
||||
|
|
@ -427,6 +427,7 @@ class PermissionManagerTest extends MediaWikiLangTestCase {
|
|||
$this->overrideConfigValues( [
|
||||
MainConfigNames::AutoCreateTempUser => [
|
||||
'enabled' => true,
|
||||
'expireAfterDays' => null,
|
||||
'actions' => [ 'edit' ],
|
||||
'serialProvider' => [ 'type' => 'local' ],
|
||||
'serialMapping' => [ 'type' => 'plain-numeric' ],
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ class ApiQueryInfoTest extends ApiTestCase {
|
|||
$this->setGroupPermissions( '*', 'createaccount', true );
|
||||
$this->overrideConfigValue( MainConfigNames::AutoCreateTempUser, [
|
||||
'enabled' => true,
|
||||
'expireAfterDays' => null,
|
||||
'actions' => [ 'edit' ],
|
||||
'genPattern' => 'Unregistered $1',
|
||||
'serialProvider' => [],
|
||||
|
|
|
|||
|
|
@ -377,6 +377,7 @@ class ApiQuerySiteinfoTest extends ApiTestCase {
|
|||
|
||||
$config = [
|
||||
'enabled' => true,
|
||||
'expireAfterDays' => null,
|
||||
'actions' => [ 'edit' ],
|
||||
'genPattern' => 'Unregistered $1',
|
||||
'reservedPattern' => null,
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ class DatabaseBlockTest extends MediaWikiLangTestCase {
|
|||
MainConfigNames::AutoCreateTempUser,
|
||||
[
|
||||
'enabled' => true,
|
||||
'expireAfterDays' => null,
|
||||
'actions' => [ 'edit' ],
|
||||
'genPattern' => '*Unregistered $1',
|
||||
'matchPattern' => '*$1',
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ class UserGroupManagerTest extends MediaWikiIntegrationTestCase {
|
|||
new TestLogger(),
|
||||
new RealTempUserConfig( [
|
||||
'enabled' => true,
|
||||
'expireAfterDays' => null,
|
||||
'actions' => [ 'edit' ],
|
||||
'serialProvider' => [ 'type' => 'local' ],
|
||||
'serialMapping' => [ 'type' => 'plain-numeric' ],
|
||||
|
|
|
|||
|
|
@ -1734,6 +1734,7 @@ class UserTest extends MediaWikiIntegrationTestCase {
|
|||
MainConfigNames::AutoCreateTempUser,
|
||||
[
|
||||
'enabled' => true,
|
||||
'expireAfterDays' => null,
|
||||
'actions' => [ 'edit' ],
|
||||
'genPattern' => '*Unregistered $1',
|
||||
'matchPattern' => '*$1',
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ class BlockUserTest extends MediaWikiIntegrationTestCase {
|
|||
MainConfigNames::AutoCreateTempUser,
|
||||
[
|
||||
'enabled' => true,
|
||||
'expireAfterDays' => null,
|
||||
'actions' => [ 'edit' ],
|
||||
'genPattern' => '*Unregistered $1',
|
||||
'matchPattern' => '*$1',
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ class RealTempUserConfigTest extends \MediaWikiIntegrationTestCase {
|
|||
/** This is meant to be the default config from MainConfigSchema */
|
||||
private const DEFAULTS = [
|
||||
'enabled' => false,
|
||||
'expireAfterDays' => null,
|
||||
'actions' => [ 'edit' ],
|
||||
'genPattern' => '*Unregistered $1',
|
||||
'matchPattern' => '*$1',
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ class TempUserCreatorTest extends \MediaWikiIntegrationTestCase {
|
|||
/** This is meant to be the default config from MainConfigSchema */
|
||||
private const DEFAULTS = [
|
||||
'enabled' => false,
|
||||
'expireAfterDays' => null,
|
||||
'actions' => [ 'edit' ],
|
||||
'genPattern' => '*Unregistered $1',
|
||||
'matchPattern' => '*$1',
|
||||
|
|
@ -39,6 +40,7 @@ class TempUserCreatorTest extends \MediaWikiIntegrationTestCase {
|
|||
MainConfigNames::AutoCreateTempUser,
|
||||
[
|
||||
'enabled' => true,
|
||||
'expireAfterDays' => null,
|
||||
'actions' => [ 'edit' ],
|
||||
'genPattern' => '*Unregistered $1',
|
||||
'matchPattern' => '*$1',
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ class UserFactoryTest extends MediaWikiIntegrationTestCase {
|
|||
MainConfigNames::AutoCreateTempUser,
|
||||
[
|
||||
'enabled' => true,
|
||||
'expireAfterDays' => null,
|
||||
'actions' => [ 'edit' ],
|
||||
'genPattern' => '*Unregistered $1',
|
||||
'matchPattern' => '*$1',
|
||||
|
|
@ -214,6 +215,7 @@ class UserFactoryTest extends MediaWikiIntegrationTestCase {
|
|||
MainConfigNames::AutoCreateTempUser,
|
||||
[
|
||||
'enabled' => true,
|
||||
'expireAfterDays' => null,
|
||||
'actions' => [ 'edit' ],
|
||||
'genPattern' => '*Unregistered $1',
|
||||
'matchPattern' => '*$1',
|
||||
|
|
|
|||
|
|
@ -514,6 +514,7 @@ trait DummyServicesTrait {
|
|||
$options['hookContainer'] ?? $this->createHookContainer(),
|
||||
new RealTempUserConfig( [
|
||||
'enabled' => true,
|
||||
'expireAfterDays' => null,
|
||||
'actions' => [ 'edit' ],
|
||||
'serialProvider' => [ 'type' => 'local' ],
|
||||
'serialMapping' => [ 'type' => 'plain-numeric' ],
|
||||
|
|
|
|||
Loading…
Reference in a new issue