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