Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.34% covered (warning)
75.34%
55 / 73
42.86% covered (danger)
42.86%
3 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
ExpireTemporaryAccounts
75.34% covered (warning)
75.34%
55 / 73
42.86% covered (danger)
42.86%
3 / 7
16.94
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 initServices
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 verboseLog
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getTempAccountsToExpireQueryBuilder
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 queryBuilderToUserIdentities
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
3.19
 expireTemporaryAccount
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 execute
69.23% covered (warning)
69.23%
18 / 26
0.00% covered (danger)
0.00%
0 / 1
7.05
1<?php
2
3use MediaWiki\Auth\AuthManager;
4use MediaWiki\Maintenance\Maintenance;
5use MediaWiki\Session\SessionManagerInterface;
6use MediaWiki\User\TempUser\TempUserConfig;
7use MediaWiki\User\UserFactory;
8use MediaWiki\User\UserIdentity;
9use MediaWiki\User\UserIdentityLookup;
10use MediaWiki\User\UserIdentityUtils;
11use MediaWiki\User\UserSelectQueryBuilder;
12use Wikimedia\Rdbms\SelectQueryBuilder;
13use Wikimedia\Timestamp\ConvertibleTimestamp;
14use Wikimedia\Timestamp\TimestampFormat as TS;
15
16// @codeCoverageIgnoreStart
17require_once __DIR__ . '/Maintenance.php';
18// @codeCoverageIgnoreEnd
19
20/**
21 * Expire temporary accounts that are registered for longer than `expireAfterDays` days
22 * (defined in $wgAutoCreateTempUser) by forcefully logging them out.
23 *
24 * Extensions can extend this class to provide their own logic of determining a list
25 * of temporary accounts to expire.
26 *
27 * @stable to extend
28 * @since 1.42
29 */
30class ExpireTemporaryAccounts extends Maintenance {
31
32    protected UserIdentityLookup $userIdentityLookup;
33    protected UserFactory $userFactory;
34    protected AuthManager $authManager;
35    protected TempUserConfig $tempUserConfig;
36    protected UserIdentityUtils $userIdentityUtils;
37    protected SessionManagerInterface $sessionManager;
38
39    public function __construct() {
40        parent::__construct();
41
42        $this->addDescription( 'Expire temporary accounts that exist for more than N days' );
43        $this->addOption(
44            'frequency',
45            'How frequently the script runs [days]. When used with "expiry", determines the ' .
46            'cutoff for registration of accounts to be expired. For example, if "expiry" is 90 ' .
47            'days and "frequency" is 1 day, then the script will expire accounts that ' .
48            'registered more than 90 days ago but not more than 90 + 1 days ago.',
49            true,
50            true
51        );
52        $this->addOption(
53            'expiry',
54            'Expire accounts older than this number of days. Use 0 to expire all temporary accounts',
55            false,
56            true
57        );
58        $this->addOption( 'verbose', 'Verbose logging output' );
59    }
60
61    /**
62     * Construct services the script needs to use
63     *
64     * @stable to override
65     */
66    protected function initServices(): void {
67        $services = $this->getServiceContainer();
68
69        $this->userIdentityLookup = $services->getUserIdentityLookup();
70        $this->userFactory = $services->getUserFactory();
71        $this->authManager = $services->getAuthManager();
72        $this->tempUserConfig = $services->getTempUserConfig();
73        $this->userIdentityUtils = $services->getUserIdentityUtils();
74        $this->sessionManager = $services->getSessionManager();
75    }
76
77    /**
78     * If --verbose is passed, log to output
79     *
80     * @param string $log
81     * @return void
82     */
83    protected function verboseLog( string $log ) {
84        if ( $this->hasOption( 'verbose' ) ) {
85            $this->output( $log );
86        }
87    }
88
89    /**
90     * Return a SelectQueryBuilder that returns temp accounts to invalidate
91     *
92     * This method should return temporary accounts that registered before $registeredBeforeUnix.
93     * To avoid returning an ever-growing set of accounts, the method should skip users that were
94     * supposedly invalidated by a previous script run (script runs each $frequencyDays days).
95     *
96     * If you override this method, you probably also want to override
97     * queryBuilderToUserIdentities().
98     *
99     * @stable to override
100     * @param int $registeredBeforeUnix Cutoff Unix timestamp
101     * @param int $frequencyDays Script runs each $frequencyDays days
102     * @return SelectQueryBuilder
103     */
104    protected function getTempAccountsToExpireQueryBuilder(
105        int $registeredBeforeUnix,
106        int $frequencyDays
107    ): SelectQueryBuilder {
108        return $this->userIdentityLookup->newSelectQueryBuilder()
109            ->temp()
110            ->whereRegisteredTimestamp( wfTimestamp(
111                TS::MW,
112                $registeredBeforeUnix
113            ), true )
114            ->whereRegisteredTimestamp( wfTimestamp(
115                TS::MW,
116                $registeredBeforeUnix - ( 86_400 * $frequencyDays )
117            ), false );
118    }
119
120    /**
121     * Convert a SelectQueryBuilder into a list of user identities
122     *
123     * Default implementation expects $queryBuilder is an instance of UserSelectQueryBuilder. If
124     * you override getTempAccountsToExpireQueryBuilder() to work with a different query builder,
125     * this method should be overridden to properly convert the query builder into user identities.
126     *
127     * @throws LogicException if $queryBuilder is not UserSelectQueryBuilder
128     * @stable to override
129     * @param SelectQueryBuilder $queryBuilder
130     * @return Iterator<UserIdentity>
131     */
132    protected function queryBuilderToUserIdentities( SelectQueryBuilder $queryBuilder ): Iterator {
133        if ( $queryBuilder instanceof UserSelectQueryBuilder ) {
134            return $queryBuilder->fetchUserIdentities();
135        }
136
137        throw new LogicException(
138            '$queryBuilder is not UserSelectQueryBuilder. Did you forget to override ' .
139            __METHOD__ . '?'
140        );
141    }
142
143    /**
144     * Expire a temporary account
145     *
146     * Default implementation calls AuthManager::revokeAccessForUser and
147     * SessionManager::invalidateSessionsForUser.
148     *
149     * @stable to override
150     * @param UserIdentity $tempAccountUserIdentity
151     */
152    protected function expireTemporaryAccount( UserIdentity $tempAccountUserIdentity ): void {
153        $this->authManager->revokeAccessForUser( $tempAccountUserIdentity->getName() );
154        $this->sessionManager->invalidateSessionsForUser(
155            $this->userFactory->newFromUserIdentity( $tempAccountUserIdentity )
156        );
157    }
158
159    /**
160     * @inheritDoc
161     */
162    public function execute() {
163        $this->initServices();
164
165        if ( !$this->tempUserConfig->isKnown() ) {
166            $this->output( 'Temporary accounts are disabled' . PHP_EOL );
167            return;
168        }
169
170        $frequencyDays = (int)$this->getOption( 'frequency' );
171        if ( $this->getOption( 'expiry' ) !== null ) {
172            $expireAfterDays = (int)$this->getOption( 'expiry' );
173        } else {
174            $expireAfterDays = $this->tempUserConfig->getExpireAfterDays();
175        }
176        if ( $expireAfterDays === null ) {
177            $this->output( 'Temporary account expiry is not enabled' . PHP_EOL );
178            return;
179        }
180        $registeredBeforeUnix = (int)ConvertibleTimestamp::now( TS::UNIX ) - ( 86_400 * $expireAfterDays );
181
182        $tempAccounts = $this->queryBuilderToUserIdentities( $this->getTempAccountsToExpireQueryBuilder(
183            $registeredBeforeUnix,
184            $frequencyDays
185        )->caller( __METHOD__ ) );
186
187        $revokedUsers = 0;
188        foreach ( $tempAccounts as $tempAccountUserIdentity ) {
189            if ( !$this->userIdentityUtils->isTemp( $tempAccountUserIdentity ) ) {
190                // Not a temporary account, skip it.
191                continue;
192            }
193
194            $this->expireTemporaryAccount( $tempAccountUserIdentity );
195
196            $this->verboseLog(
197                'Revoking access for ' . $tempAccountUserIdentity->getName() . PHP_EOL
198            );
199            $revokedUsers++;
200        }
201
202        $this->output( "Revoked access for $revokedUsers temporary users." . PHP_EOL );
203    }
204}
205
206// @codeCoverageIgnoreStart
207$maintClass = ExpireTemporaryAccounts::class;
208require_once RUN_MAINTENANCE_IF_MAIN;
209// @codeCoverageIgnoreEnd