Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 81
0.00% covered (danger)
0.00%
0 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
DeleteLocalPasswords
0.00% covered (danger)
0.00%
0 / 80
0.00% covered (danger)
0.00%
0 / 6
420
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 initialize
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
72
 execute
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getUserDB
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 processUsers
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
30
 getUserBatches
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * Helper for deleting unused local passwords.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup Maintenance
22 */
23
24use Wikimedia\Rdbms\IDatabase;
25use Wikimedia\Rdbms\IExpression;
26use Wikimedia\Rdbms\LikeValue;
27
28require_once __DIR__ . '/../Maintenance.php';
29
30/**
31 * Delete unused local passwords.
32 *
33 * Mainly intended to be used as a base class by authentication extensions to provide maintenance
34 * scripts which allow deleting local passwords for users who have another way of logging in.
35 * Such scripts would customize how to locate users who have other login methods and don't need
36 * local login anymore.
37 * Make sure to set LocalPasswordPrimaryAuthenticationProvider to loginOnly => true or disable it
38 * completely before running this, otherwise it might recreate passwords.
39 *
40 * This class can also be used directly to just delete all local passwords, or those for a specific
41 * user. Deleting all passwords is useful when the wiki has used local password login in the past
42 * but it has been disabled.
43 */
44class DeleteLocalPasswords extends Maintenance {
45    /** @var string|null User to run on, or null for all. */
46    protected $user;
47
48    /** @var int Number of deleted passwords. */
49    protected $total;
50
51    public function __construct() {
52        parent::__construct();
53        $this->addDescription( "Deletes local password for users." );
54        $this->setBatchSize( 1000 );
55
56        $this->addOption( 'user', 'If specified, only checks the given user', false, true );
57        $this->addOption( 'delete', 'Really delete. To prevent accidents, you must provide this flag.' );
58        $this->addOption( 'prefix', "Instead of deleting, make passwords invalid by prefixing with "
59            . "':null:'. Make sure PasswordConfig has a 'null' entry. This is meant for testing before "
60            . "hard delete." );
61        $this->addOption( 'unprefix', 'Instead of deleting, undo the effect of --prefix.' );
62    }
63
64    protected function initialize() {
65        if (
66            (int)$this->hasOption( 'delete' ) + (int)$this->hasOption( 'prefix' )
67            + (int)$this->hasOption( 'unprefix' ) !== 1
68        ) {
69            $this->fatalError( "Exactly one of the 'delete', 'prefix', 'unprefix' options must be used\n" );
70        }
71        if ( $this->hasOption( 'prefix' ) || $this->hasOption( 'unprefix' ) ) {
72            $passwordHashTypes = $this->getServiceContainer()->getPasswordFactory()->getTypes();
73            if (
74                !isset( $passwordHashTypes['null'] )
75                || $passwordHashTypes['null']['class'] !== InvalidPassword::class
76            ) {
77                $this->fatalError(
78<<<'ERROR'
79'null' password entry missing. To use password prefixing, add
80    $wgPasswordConfig['null'] = [ 'class' => InvalidPassword::class ];
81to your configuration (and remove once the passwords were deleted).
82ERROR
83                );
84            }
85        }
86
87        $user = $this->getOption( 'user', false );
88        if ( $user !== false ) {
89            $userNameUtils = $this->getServiceContainer()->getUserNameUtils();
90            $this->user = $userNameUtils->getCanonical( $user );
91            if ( $this->user === false ) {
92                $this->fatalError( "Invalid user name\n" );
93            }
94        }
95    }
96
97    public function execute() {
98        $this->initialize();
99
100        foreach ( $this->getUserBatches() as $userBatch ) {
101            $this->processUsers( $userBatch, $this->getUserDB() );
102        }
103
104        $this->output( "done. (wrote $this->total rows)\n" );
105    }
106
107    /**
108     * Get the primary DB handle for the current user batch. This is provided for the benefit
109     * of authentication extensions which subclass this and work with wiki farms.
110     * @return IDatabase
111     */
112    protected function getUserDB() {
113        return $this->getPrimaryDB();
114    }
115
116    protected function processUsers( array $userBatch, IDatabase $dbw ) {
117        if ( !$userBatch ) {
118            return;
119        }
120        if ( $this->getOption( 'delete' ) ) {
121            $dbw->newUpdateQueryBuilder()
122                ->update( 'user' )
123                ->set( [ 'user_password' => PasswordFactory::newInvalidPassword()->toString() ] )
124                ->where( [ 'user_name' => $userBatch ] )
125                ->caller( __METHOD__ )->execute();
126        } elseif ( $this->getOption( 'prefix' ) ) {
127            $dbw->newUpdateQueryBuilder()
128                ->update( 'user' )
129                ->set( [ 'user_password = ' . $dbw->buildConcat( [ $dbw->addQuotes( ':null:' ),
130                        'user_password' ] ) ] )
131                ->where( [
132                    'NOT (user_password ' . $dbw->buildLike( ':null:', $dbw->anyString() ) . ')',
133                    $dbw->expr( 'user_password', '!=', PasswordFactory::newInvalidPassword()->toString() ),
134                    'user_password IS NOT NULL',
135                    'user_name' => $userBatch,
136                ] )
137                ->caller( __METHOD__ )->execute();
138        } elseif ( $this->getOption( 'unprefix' ) ) {
139            $dbw->newUpdateQueryBuilder()
140                ->update( 'user' )
141                ->set( [ 'user_password = ' . $dbw->buildSubString( 'user_password', strlen( ':null:' ) + 1 ) ] )
142                ->where( [
143                    $dbw->expr( 'user_password', IExpression::LIKE, new LikeValue( ':null:', $dbw->anyString() ) ),
144                    'user_name' => $userBatch,
145                ] )
146                ->caller( __METHOD__ )->execute();
147        }
148        $this->total += $dbw->affectedRows();
149        $this->waitForReplication();
150    }
151
152    /**
153     * This method iterates through the requested users and returns their names in batches of
154     * self::$mBatchSize.
155     *
156     * Subclasses should reimplement this and locate users who use the specific authentication
157     * method. The default implementation just iterates through all users. Extensions that work
158     * with wikifarm should also update self::getUserDB() as necessary.
159     * @return Generator
160     */
161    protected function getUserBatches() {
162        if ( $this->user !== null ) {
163            $this->output( "\t ... querying '$this->user'\n" );
164            yield [ [ $this->user ] ];
165            return;
166        }
167
168        $lastUsername = '';
169        $dbw = $this->getPrimaryDB();
170        do {
171            $this->output( "\t ... querying from '$lastUsername'\n" );
172            $users = $dbw->newSelectQueryBuilder()
173                ->select( 'user_name' )
174                ->from( 'user' )
175                ->where( $dbw->expr( 'user_name', '>', $lastUsername ) )
176                ->orderBy( 'user_name ASC' )
177                ->limit( $this->getBatchSize() )
178                ->caller( __METHOD__ )->fetchFieldValues();
179            if ( $users ) {
180                yield $users;
181                $lastUsername = end( $users );
182            }
183        } while ( count( $users ) === $this->getBatchSize() );
184    }
185}