Add maintenance script for deleting local passwords
authorGergő Tisza <tgr.huwiki@gmail.com>
Sun, 10 Jun 2018 23:05:59 +0000 (01:05 +0200)
committerReedy <reedy@wikimedia.org>
Thu, 2 Aug 2018 13:47:33 +0000 (13:47 +0000)
This is mainly for the benefit of authentication extensions which
all need similar functionality for removing local passwords on a
wiki where local authentication was used for a while but has been
disabled, but can be used directly to just indiscriminately remove
the passwords of all users.

To test the change without irreversibly locking out users, an
option is provided to make the password invalid in an
easy-to-reverse way.

The immediate use case is I974184899c33.

This patch also introduces the maintenance/includes directory
to hold PHP files which are not executable scripts themselves.
(Previously such files had a .inc extension, but that is so PHP4.)

Bug: T57420
Change-Id: If7207b80a2c8374e90182e0b09d8f76ee94264b0

autoload.php
maintenance/deleteLocalPasswords.php [new file with mode: 0644]
maintenance/includes/DeleteLocalPasswords.php [new file with mode: 0644]

index b5b5c52..38f6ba9 100644 (file)
@@ -378,6 +378,7 @@ $wgAutoloadLocalClasses = [
        'DeleteEqualMessages' => __DIR__ . '/maintenance/deleteEqualMessages.php',
        'DeleteFileOp' => __DIR__ . '/includes/libs/filebackend/fileop/DeleteFileOp.php',
        'DeleteLinksJob' => __DIR__ . '/includes/jobqueue/jobs/DeleteLinksJob.php',
        'DeleteEqualMessages' => __DIR__ . '/maintenance/deleteEqualMessages.php',
        'DeleteFileOp' => __DIR__ . '/includes/libs/filebackend/fileop/DeleteFileOp.php',
        'DeleteLinksJob' => __DIR__ . '/includes/jobqueue/jobs/DeleteLinksJob.php',
+       'DeleteLocalPasswords' => __DIR__ . '/maintenance/includes/DeleteLocalPasswords.php',
        'DeleteLogFormatter' => __DIR__ . '/includes/logging/DeleteLogFormatter.php',
        'DeleteOldRevisions' => __DIR__ . '/maintenance/deleteOldRevisions.php',
        'DeleteOrphanedRevisions' => __DIR__ . '/maintenance/deleteOrphanedRevisions.php',
        'DeleteLogFormatter' => __DIR__ . '/includes/logging/DeleteLogFormatter.php',
        'DeleteOldRevisions' => __DIR__ . '/maintenance/deleteOldRevisions.php',
        'DeleteOrphanedRevisions' => __DIR__ . '/maintenance/deleteOrphanedRevisions.php',
diff --git a/maintenance/deleteLocalPasswords.php b/maintenance/deleteLocalPasswords.php
new file mode 100644 (file)
index 0000000..ecee239
--- /dev/null
@@ -0,0 +1,27 @@
+<?php
+/**
+ * Delete unused local passwords.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Maintenance
+ */
+
+require_once __DIR__ . '/includes/deleteLocalPasswords.php';
+
+$maintClass = "DeleteLocalPasswords";
+require_once RUN_MAINTENANCE_IF_MAIN;
diff --git a/maintenance/includes/DeleteLocalPasswords.php b/maintenance/includes/DeleteLocalPasswords.php
new file mode 100644 (file)
index 0000000..e3f8926
--- /dev/null
@@ -0,0 +1,186 @@
+<?php
+/**
+ * Helper for deleting unused local passwords.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Maintenance
+ */
+
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\IDatabase;
+
+require_once __DIR__ . '/../Maintenance.php';
+
+/**
+ * Delete unused local passwords.
+ *
+ * Mainly intended to be used as a base class by authentication extensions to provide maintenance
+ * scripts which allow deleting local passwords for users who have another way of logging in.
+ * Such scripts would customize how to locate users who have other login methods and don't need
+ * local login anymore.
+ * Make sure to set LocalPasswordPrimaryAuthenticationProvider to loginOnly => true or disable it
+ * completely before running this, otherwise it might recreate passwords.
+ *
+ * This class can also be used directly to just delete all local passwords, or those for a specific
+ * user. Deleting all passwords is useful when the wiki has used local password login in the past
+ * but it has been disabled.
+ */
+class DeleteLocalPasswords extends Maintenance {
+       /** @var string|null User to run on, or null for all. */
+       protected $user;
+
+       /** @var int Number of deleted passwords. */
+       protected $total;
+
+       public function __construct() {
+               parent::__construct();
+               $this->mDescription = "Deletes local password for users.";
+               $this->setBatchSize( 1000 );
+
+               $this->addOption( 'user', 'If specified, only checks the given user', false, true );
+               $this->addOption( 'delete', 'Really delete. To prevent accidents, you must provide this flag.' );
+               $this->addOption( 'prefix', "Instead of deleting, make passwords invalid by prefixing with "
+                       . "':null:'. Make sure PasswordConfig has a 'null' entry. This is meant for testing before "
+                       . "hard delete." );
+               $this->addOption( 'unprefix', 'Instead of deleting, undo the effect of --prefix.' );
+       }
+
+       protected function initialize() {
+               if (
+                       $this->hasOption( 'delete' ) + $this->hasOption( 'prefix' )
+                       + $this->hasOption( 'unprefix' ) !== 1
+               ) {
+                       $this->fatalError( "Exactly one of the 'delete', 'prefix', 'unprefix' options must be used\n" );
+               }
+               if ( $this->hasOption( 'prefix' ) || $this->hasOption( 'unprefix' ) ) {
+                       $passwordHashTypes = MediaWikiServices::getInstance()->getPasswordFactory()->getTypes();
+                       if (
+                               !isset( $passwordHashTypes['null'] )
+                               || $passwordHashTypes['null']['class'] !== InvalidPassword::class
+                       ) {
+                               $this->fatalError(
+<<<'ERROR'
+'null' password entry missing. To use password prefixing, add
+    $wgPasswordConfig['null'] = [ 'class' => InvalidPassword::class ];
+to your configuration (and remove once the passwords were deleted).
+ERROR
+                               );
+                       }
+               }
+
+               $user = $this->getOption( 'user', false );
+               if ( $user !== false ) {
+                       $this->user = User::getCanonicalName( $user );
+                       if ( $this->user === false ) {
+                               $this->fatalError( "Invalid user name\n" );
+                       }
+               }
+       }
+
+       public function execute() {
+               $this->initialize();
+
+               foreach ( $this->getUserBatches() as $userBatch ) {
+                       $this->processUsers( $userBatch, $this->getUserDB() );
+               }
+
+               $this->output( "done. (wrote $this->total rows)\n" );
+       }
+
+       /**
+        * Get the master DB handle for the current user batch. This is provided for the benefit
+        * of authentication extensions which subclass this and work with wiki farms.
+        */
+       protected function getUserDB() {
+               return $this->getDB( DB_MASTER );
+       }
+
+       protected function processUsers( array $userBatch, IDatabase $dbw ) {
+               if ( !$userBatch ) {
+                       return;
+               }
+               if ( $this->getOption( 'delete' ) ) {
+                       $dbw->update( 'user',
+                               [ 'user_password' => PasswordFactory::newInvalidPassword()->toString() ],
+                               [ 'user_name' => $userBatch ],
+                               __METHOD__
+                       );
+               } elseif ( $this->getOption( 'prefix' ) ) {
+                       $dbw->update( 'user',
+                               [ 'user_password = ' . $dbw->buildConcat( [ $dbw->addQuotes( ':null:' ),
+                                               'user_password' ] ) ],
+                               [
+                                       'NOT (user_password ' . $dbw->buildLike( ':null:', $dbw->anyString() ) . ')',
+                                       "user_password != " . $dbw->addQuotes( PasswordFactory::newInvalidPassword()->toString() ),
+                                       'user_password IS NOT NULL',
+                                       'user_name' => $userBatch,
+                               ],
+                               __METHOD__
+                       );
+               } elseif ( $this->getOption( 'unprefix' ) ) {
+                       $dbw->update( 'user',
+                               [ 'user_password = ' . $dbw->buildSubString( 'user_password', strlen( ':null:' ) + 1 ) ],
+                               [
+                                       'user_password ' . $dbw->buildLike( ':null:', $dbw->anyString() ),
+                                       'user_name' => $userBatch,
+                               ],
+                               __METHOD__
+                       );
+               }
+               $this->total += $dbw->affectedRows();
+       }
+
+       /**
+        * This method iterates through the requested users and returns their names in batches of
+        * self::$mBatchSize.
+        *
+        * Subclasses should reimplement this and locate users who use the specific authentication
+        * method. The default implementation just iterates through all users. Extensions that work
+        * with wikifarm should also update self::getUserDB() as necessary.
+        * @return Generator
+        */
+       protected function getUserBatches() {
+               if ( !is_null( $this->user ) ) {
+                       $this->output( "\t ... querying '$this->user'\n" );
+                       yield [ $this->user ];
+                       return;
+               }
+
+               $lastUsername = '';
+               $dbw = $this->getDB( DB_MASTER );
+               do {
+                       $this->output( "\t ... querying from '$lastUsername'\n" );
+                       $users = $dbw->selectFieldValues(
+                               'user',
+                               'user_name',
+                               [
+                                       'user_name > ' .$dbw->addQuotes( $lastUsername ),
+                               ],
+                               __METHOD__,
+                               [
+                                       'LIMIT' => $this->getBatchSize(),
+                                       'ORDER BY' => 'user_name ASC',
+                               ]
+                       );
+                       if ( $users ) {
+                               yield $users;
+                               $lastUsername = end( $users );
+                       }
+               } while ( count( $users ) === $this->getBatchSize() );
+       }
+}