includes/user/BotPasswordStore.php
<?php
/**
* BotPassword interaction with databases
*
* 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
*/
namespace MediaWiki\User;
use FormatJson;
use IDBAccessObject;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\MainConfigNames;
use MediaWiki\User\CentralId\CentralIdLookup;
use MWCryptRand;
use MWRestrictions;
use Password;
use PasswordFactory;
use StatusValue;
use Wikimedia\Rdbms\IConnectionProvider;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\IReadableDatabase;
/**
* @author DannyS712
* @since 1.37
*/
class BotPasswordStore {
/**
* @internal For use by ServiceWiring
*/
public const CONSTRUCTOR_OPTIONS = [
MainConfigNames::EnableBotPasswords,
];
private ServiceOptions $options;
private IConnectionProvider $dbProvider;
private CentralIdLookup $centralIdLookup;
/**
* @param ServiceOptions $options
* @param CentralIdLookup $centralIdLookup
* @param IConnectionProvider $dbProvider
*/
public function __construct(
ServiceOptions $options,
CentralIdLookup $centralIdLookup,
IConnectionProvider $dbProvider
) {
$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
$this->options = $options;
$this->centralIdLookup = $centralIdLookup;
$this->dbProvider = $dbProvider;
}
/**
* Get a database connection for the bot passwords database
* @return IReadableDatabase
* @internal
*/
public function getReplicaDatabase(): IReadableDatabase {
return $this->dbProvider->getReplicaDatabase( 'virtual-botpasswords' );
}
/**
* Get a database connection for the bot passwords database
* @return IDatabase
* @internal
*/
public function getPrimaryDatabase(): IDatabase {
return $this->dbProvider->getPrimaryDatabase( 'virtual-botpasswords' );
}
/**
* Load a BotPassword from the database based on a UserIdentity object
* @param UserIdentity $userIdentity
* @param string $appId
* @param int $flags IDBAccessObject read flags
* @return BotPassword|null
*/
public function getByUser(
UserIdentity $userIdentity,
string $appId,
int $flags = IDBAccessObject::READ_NORMAL
): ?BotPassword {
if ( !$this->options->get( MainConfigNames::EnableBotPasswords ) ) {
return null;
}
$centralId = $this->centralIdLookup->centralIdFromLocalUser(
$userIdentity,
CentralIdLookup::AUDIENCE_RAW,
$flags
);
return $centralId ? $this->getByCentralId( $centralId, $appId, $flags ) : null;
}
/**
* Load a BotPassword from the database
* @param int $centralId from CentralIdLookup
* @param string $appId
* @param int $flags IDBAccessObject read flags
* @return BotPassword|null
*/
public function getByCentralId(
int $centralId,
string $appId,
int $flags = IDBAccessObject::READ_NORMAL
): ?BotPassword {
if ( !$this->options->get( MainConfigNames::EnableBotPasswords ) ) {
return null;
}
if ( ( $flags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) {
$db = $this->dbProvider->getPrimaryDatabase( 'virtual-botpasswords' );
} else {
$db = $this->dbProvider->getReplicaDatabase( 'virtual-botpasswords' );
}
$row = $db->newSelectQueryBuilder()
->select( [ 'bp_user', 'bp_app_id', 'bp_token', 'bp_restrictions', 'bp_grants' ] )
->from( 'bot_passwords' )
->where( [ 'bp_user' => $centralId, 'bp_app_id' => $appId ] )
->recency( $flags )
->caller( __METHOD__ )->fetchRow();
return $row ? new BotPassword( $row, true, $flags ) : null;
}
/**
* Create an unsaved BotPassword
* @param array $data Data to use to create the bot password. Keys are:
* - user: (UserIdentity) UserIdentity to create the password for. Overrides username and centralId.
* - username: (string) Username to create the password for. Overrides centralId.
* - centralId: (int) User central ID to create the password for.
* - appId: (string, required) App ID for the password.
* - restrictions: (MWRestrictions, optional) Restrictions.
* - grants: (string[], optional) Grants.
* @param int $flags IDBAccessObject read flags
* @return BotPassword|null
*/
public function newUnsavedBotPassword(
array $data,
int $flags = IDBAccessObject::READ_NORMAL
): ?BotPassword {
if ( isset( $data['user'] ) && ( !$data['user'] instanceof UserIdentity ) ) {
return null;
}
$row = (object)[
'bp_user' => 0,
'bp_app_id' => trim( $data['appId'] ?? '' ),
'bp_token' => '**unsaved**',
'bp_restrictions' => $data['restrictions'] ?? MWRestrictions::newDefault(),
'bp_grants' => $data['grants'] ?? [],
];
if (
$row->bp_app_id === '' ||
strlen( $row->bp_app_id ) > BotPassword::APPID_MAXLENGTH ||
!$row->bp_restrictions instanceof MWRestrictions ||
!is_array( $row->bp_grants )
) {
return null;
}
$row->bp_restrictions = $row->bp_restrictions->toJson();
$row->bp_grants = FormatJson::encode( $row->bp_grants );
if ( isset( $data['user'] ) ) {
// Must be a UserIdentity object, already checked above
$row->bp_user = $this->centralIdLookup->centralIdFromLocalUser(
$data['user'],
CentralIdLookup::AUDIENCE_RAW,
$flags
);
} elseif ( isset( $data['username'] ) ) {
$row->bp_user = $this->centralIdLookup->centralIdFromName(
$data['username'],
CentralIdLookup::AUDIENCE_RAW,
$flags
);
} elseif ( isset( $data['centralId'] ) ) {
$row->bp_user = $data['centralId'];
}
if ( !$row->bp_user ) {
return null;
}
return new BotPassword( $row, false, $flags );
}
/**
* Save the new BotPassword to the database
*
* @internal
*
* @param BotPassword $botPassword
* @param Password|null $password Use null for an invalid password
* @return StatusValue if everything worked, the value of the StatusValue is the new token
*/
public function insertBotPassword(
BotPassword $botPassword,
Password $password = null
): StatusValue {
$res = $this->validateBotPassword( $botPassword );
if ( !$res->isGood() ) {
return $res;
}
if ( $password === null ) {
$password = PasswordFactory::newInvalidPassword();
}
$dbw = $this->getPrimaryDatabase();
$dbw->newInsertQueryBuilder()
->insertInto( 'bot_passwords' )
->ignore()
->row( [
'bp_user' => $botPassword->getUserCentralId(),
'bp_app_id' => $botPassword->getAppId(),
'bp_token' => MWCryptRand::generateHex( User::TOKEN_LENGTH ),
'bp_restrictions' => $botPassword->getRestrictions()->toJson(),
'bp_grants' => FormatJson::encode( $botPassword->getGrants() ),
'bp_password' => $password->toString(),
] )
->caller( __METHOD__ )->execute();
$ok = (bool)$dbw->affectedRows();
if ( $ok ) {
$token = $dbw->newSelectQueryBuilder()
->select( 'bp_token' )
->from( 'bot_passwords' )
->where( [ 'bp_user' => $botPassword->getUserCentralId(), 'bp_app_id' => $botPassword->getAppId(), ] )
->caller( __METHOD__ )->fetchField();
return StatusValue::newGood( $token );
}
return StatusValue::newFatal( 'botpasswords-insert-failed', $botPassword->getAppId() );
}
/**
* Update an existing BotPassword in the database
*
* @internal
*
* @param BotPassword $botPassword
* @param Password|null $password Use null for an invalid password
* @return StatusValue if everything worked, the value of the StatusValue is the new token
*/
public function updateBotPassword(
BotPassword $botPassword,
Password $password = null
): StatusValue {
$res = $this->validateBotPassword( $botPassword );
if ( !$res->isGood() ) {
return $res;
}
$conds = [
'bp_user' => $botPassword->getUserCentralId(),
'bp_app_id' => $botPassword->getAppId(),
];
$fields = [
'bp_token' => MWCryptRand::generateHex( User::TOKEN_LENGTH ),
'bp_restrictions' => $botPassword->getRestrictions()->toJson(),
'bp_grants' => FormatJson::encode( $botPassword->getGrants() ),
];
if ( $password !== null ) {
$fields['bp_password'] = $password->toString();
}
$dbw = $this->getPrimaryDatabase();
$dbw->newUpdateQueryBuilder()
->update( 'bot_passwords' )
->set( $fields )
->where( $conds )
->caller( __METHOD__ )->execute();
$ok = (bool)$dbw->affectedRows();
if ( $ok ) {
$token = $dbw->newSelectQueryBuilder()
->select( 'bp_token' )
->from( 'bot_passwords' )
->where( $conds )
->caller( __METHOD__ )->fetchField();
return StatusValue::newGood( $token );
}
return StatusValue::newFatal( 'botpasswords-update-failed', $botPassword->getAppId() );
}
/**
* Check if a BotPassword is valid to save in the database (either inserting a new
* one or updating an existing one) based on the size of the restrictions and grants
*
* @param BotPassword $botPassword
* @return StatusValue
*/
private function validateBotPassword( BotPassword $botPassword ): StatusValue {
$res = StatusValue::newGood();
$restrictions = $botPassword->getRestrictions()->toJson();
if ( strlen( $restrictions ) > BotPassword::RESTRICTIONS_MAXLENGTH ) {
$res->fatal( 'botpasswords-toolong-restrictions' );
}
$grants = FormatJson::encode( $botPassword->getGrants() );
if ( strlen( $grants ) > BotPassword::GRANTS_MAXLENGTH ) {
$res->fatal( 'botpasswords-toolong-grants' );
}
return $res;
}
/**
* Delete an existing BotPassword in the database
*
* @param BotPassword $botPassword
* @return bool
*/
public function deleteBotPassword( BotPassword $botPassword ): bool {
$dbw = $this->getPrimaryDatabase();
$dbw->newDeleteQueryBuilder()
->deleteFrom( 'bot_passwords' )
->where( [ 'bp_user' => $botPassword->getUserCentralId() ] )
->andWhere( [ 'bp_app_id' => $botPassword->getAppId() ] )
->caller( __METHOD__ )->execute();
return (bool)$dbw->affectedRows();
}
/**
* Invalidate all passwords for a user, by name
* @param string $username
* @return bool Whether any passwords were invalidated
*/
public function invalidateUserPasswords( string $username ): bool {
if ( !$this->options->get( MainConfigNames::EnableBotPasswords ) ) {
return false;
}
$centralId = $this->centralIdLookup->centralIdFromName(
$username,
CentralIdLookup::AUDIENCE_RAW,
IDBAccessObject::READ_LATEST
);
if ( !$centralId ) {
return false;
}
$dbw = $this->getPrimaryDatabase();
$dbw->newUpdateQueryBuilder()
->update( 'bot_passwords' )
->set( [ 'bp_password' => PasswordFactory::newInvalidPassword()->toString() ] )
->where( [ 'bp_user' => $centralId ] )
->caller( __METHOD__ )->execute();
return (bool)$dbw->affectedRows();
}
/**
* Remove all passwords for a user, by name
* @param string $username
* @return bool Whether any passwords were removed
*/
public function removeUserPasswords( string $username ): bool {
if ( !$this->options->get( MainConfigNames::EnableBotPasswords ) ) {
return false;
}
$centralId = $this->centralIdLookup->centralIdFromName(
$username,
CentralIdLookup::AUDIENCE_RAW,
IDBAccessObject::READ_LATEST
);
if ( !$centralId ) {
return false;
}
$dbw = $this->getPrimaryDatabase();
$dbw->newDeleteQueryBuilder()
->deleteFrom( 'bot_passwords' )
->where( [ 'bp_user' => $centralId ] )
->caller( __METHOD__ )->execute();
return (bool)$dbw->affectedRows();
}
}