BotPassword.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  1. <?php
  2. /**
  3. * Utility class for bot 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. use MediaWiki\Auth\AuthenticationResponse;
  21. use MediaWiki\MediaWikiServices;
  22. use MediaWiki\Session\BotPasswordSessionProvider;
  23. use Wikimedia\Rdbms\IDatabase;
  24. /**
  25. * Utility class for bot passwords
  26. * @since 1.27
  27. */
  28. class BotPassword implements IDBAccessObject {
  29. const APPID_MAXLENGTH = 32;
  30. /** @var bool */
  31. private $isSaved;
  32. /** @var int */
  33. private $centralId;
  34. /** @var string */
  35. private $appId;
  36. /** @var string */
  37. private $token;
  38. /** @var MWRestrictions */
  39. private $restrictions;
  40. /** @var string[] */
  41. private $grants;
  42. /** @var int */
  43. private $flags = self::READ_NORMAL;
  44. /**
  45. * @param object $row bot_passwords database row
  46. * @param bool $isSaved Whether the bot password was read from the database
  47. * @param int $flags IDBAccessObject read flags
  48. */
  49. protected function __construct( $row, $isSaved, $flags = self::READ_NORMAL ) {
  50. $this->isSaved = $isSaved;
  51. $this->flags = $flags;
  52. $this->centralId = (int)$row->bp_user;
  53. $this->appId = $row->bp_app_id;
  54. $this->token = $row->bp_token;
  55. $this->restrictions = MWRestrictions::newFromJson( $row->bp_restrictions );
  56. $this->grants = FormatJson::decode( $row->bp_grants );
  57. }
  58. /**
  59. * Get a database connection for the bot passwords database
  60. * @param int $db Index of the connection to get, e.g. DB_MASTER or DB_REPLICA.
  61. * @return IDatabase
  62. */
  63. public static function getDB( $db ) {
  64. global $wgBotPasswordsCluster, $wgBotPasswordsDatabase;
  65. $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
  66. $lb = $wgBotPasswordsCluster
  67. ? $lbFactory->getExternalLB( $wgBotPasswordsCluster )
  68. : $lbFactory->getMainLB( $wgBotPasswordsDatabase );
  69. return $lb->getConnectionRef( $db, [], $wgBotPasswordsDatabase );
  70. }
  71. /**
  72. * Load a BotPassword from the database
  73. * @param User $user
  74. * @param string $appId
  75. * @param int $flags IDBAccessObject read flags
  76. * @return BotPassword|null
  77. */
  78. public static function newFromUser( User $user, $appId, $flags = self::READ_NORMAL ) {
  79. $centralId = CentralIdLookup::factory()->centralIdFromLocalUser(
  80. $user, CentralIdLookup::AUDIENCE_RAW, $flags
  81. );
  82. return $centralId ? self::newFromCentralId( $centralId, $appId, $flags ) : null;
  83. }
  84. /**
  85. * Load a BotPassword from the database
  86. * @param int $centralId from CentralIdLookup
  87. * @param string $appId
  88. * @param int $flags IDBAccessObject read flags
  89. * @return BotPassword|null
  90. */
  91. public static function newFromCentralId( $centralId, $appId, $flags = self::READ_NORMAL ) {
  92. global $wgEnableBotPasswords;
  93. if ( !$wgEnableBotPasswords ) {
  94. return null;
  95. }
  96. list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $flags );
  97. $db = self::getDB( $index );
  98. $row = $db->selectRow(
  99. 'bot_passwords',
  100. [ 'bp_user', 'bp_app_id', 'bp_token', 'bp_restrictions', 'bp_grants' ],
  101. [ 'bp_user' => $centralId, 'bp_app_id' => $appId ],
  102. __METHOD__,
  103. $options
  104. );
  105. return $row ? new self( $row, true, $flags ) : null;
  106. }
  107. /**
  108. * Create an unsaved BotPassword
  109. * @param array $data Data to use to create the bot password. Keys are:
  110. * - user: (User) User object to create the password for. Overrides username and centralId.
  111. * - username: (string) Username to create the password for. Overrides centralId.
  112. * - centralId: (int) User central ID to create the password for.
  113. * - appId: (string) App ID for the password.
  114. * - restrictions: (MWRestrictions, optional) Restrictions.
  115. * - grants: (string[], optional) Grants.
  116. * @param int $flags IDBAccessObject read flags
  117. * @return BotPassword|null
  118. */
  119. public static function newUnsaved( array $data, $flags = self::READ_NORMAL ) {
  120. $row = (object)[
  121. 'bp_user' => 0,
  122. 'bp_app_id' => isset( $data['appId'] ) ? trim( $data['appId'] ) : '',
  123. 'bp_token' => '**unsaved**',
  124. 'bp_restrictions' => $data['restrictions'] ?? MWRestrictions::newDefault(),
  125. 'bp_grants' => $data['grants'] ?? [],
  126. ];
  127. if (
  128. $row->bp_app_id === '' || strlen( $row->bp_app_id ) > self::APPID_MAXLENGTH ||
  129. !$row->bp_restrictions instanceof MWRestrictions ||
  130. !is_array( $row->bp_grants )
  131. ) {
  132. return null;
  133. }
  134. $row->bp_restrictions = $row->bp_restrictions->toJson();
  135. $row->bp_grants = FormatJson::encode( $row->bp_grants );
  136. if ( isset( $data['user'] ) ) {
  137. if ( !$data['user'] instanceof User ) {
  138. return null;
  139. }
  140. $row->bp_user = CentralIdLookup::factory()->centralIdFromLocalUser(
  141. $data['user'], CentralIdLookup::AUDIENCE_RAW, $flags
  142. );
  143. } elseif ( isset( $data['username'] ) ) {
  144. $row->bp_user = CentralIdLookup::factory()->centralIdFromName(
  145. $data['username'], CentralIdLookup::AUDIENCE_RAW, $flags
  146. );
  147. } elseif ( isset( $data['centralId'] ) ) {
  148. $row->bp_user = $data['centralId'];
  149. }
  150. if ( !$row->bp_user ) {
  151. return null;
  152. }
  153. return new self( $row, false, $flags );
  154. }
  155. /**
  156. * Indicate whether this is known to be saved
  157. * @return bool
  158. */
  159. public function isSaved() {
  160. return $this->isSaved;
  161. }
  162. /**
  163. * Get the central user ID
  164. * @return int
  165. */
  166. public function getUserCentralId() {
  167. return $this->centralId;
  168. }
  169. /**
  170. * Get the app ID
  171. * @return string
  172. */
  173. public function getAppId() {
  174. return $this->appId;
  175. }
  176. /**
  177. * Get the token
  178. * @return string
  179. */
  180. public function getToken() {
  181. return $this->token;
  182. }
  183. /**
  184. * Get the restrictions
  185. * @return MWRestrictions
  186. */
  187. public function getRestrictions() {
  188. return $this->restrictions;
  189. }
  190. /**
  191. * Get the grants
  192. * @return string[]
  193. */
  194. public function getGrants() {
  195. return $this->grants;
  196. }
  197. /**
  198. * Get the separator for combined user name + app ID
  199. * @return string
  200. */
  201. public static function getSeparator() {
  202. global $wgUserrightsInterwikiDelimiter;
  203. return $wgUserrightsInterwikiDelimiter;
  204. }
  205. /**
  206. * Get the password
  207. * @return Password
  208. */
  209. protected function getPassword() {
  210. list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $this->flags );
  211. $db = self::getDB( $index );
  212. $password = $db->selectField(
  213. 'bot_passwords',
  214. 'bp_password',
  215. [ 'bp_user' => $this->centralId, 'bp_app_id' => $this->appId ],
  216. __METHOD__,
  217. $options
  218. );
  219. if ( $password === false ) {
  220. return PasswordFactory::newInvalidPassword();
  221. }
  222. $passwordFactory = MediaWikiServices::getInstance()->getPasswordFactory();
  223. try {
  224. return $passwordFactory->newFromCiphertext( $password );
  225. } catch ( PasswordError $ex ) {
  226. return PasswordFactory::newInvalidPassword();
  227. }
  228. }
  229. /**
  230. * Whether the password is currently invalid
  231. * @since 1.32
  232. * @return bool
  233. */
  234. public function isInvalid() {
  235. return $this->getPassword() instanceof InvalidPassword;
  236. }
  237. /**
  238. * Save the BotPassword to the database
  239. * @param string $operation 'update' or 'insert'
  240. * @param Password|null $password Password to set.
  241. * @return bool Success
  242. */
  243. public function save( $operation, Password $password = null ) {
  244. $conds = [
  245. 'bp_user' => $this->centralId,
  246. 'bp_app_id' => $this->appId,
  247. ];
  248. $fields = [
  249. 'bp_token' => MWCryptRand::generateHex( User::TOKEN_LENGTH ),
  250. 'bp_restrictions' => $this->restrictions->toJson(),
  251. 'bp_grants' => FormatJson::encode( $this->grants ),
  252. ];
  253. if ( $password !== null ) {
  254. $fields['bp_password'] = $password->toString();
  255. } elseif ( $operation === 'insert' ) {
  256. $fields['bp_password'] = PasswordFactory::newInvalidPassword()->toString();
  257. }
  258. $dbw = self::getDB( DB_MASTER );
  259. switch ( $operation ) {
  260. case 'insert':
  261. $dbw->insert( 'bot_passwords', $fields + $conds, __METHOD__, [ 'IGNORE' ] );
  262. break;
  263. case 'update':
  264. $dbw->update( 'bot_passwords', $fields, $conds, __METHOD__ );
  265. break;
  266. default:
  267. return false;
  268. }
  269. $ok = (bool)$dbw->affectedRows();
  270. if ( $ok ) {
  271. $this->token = $dbw->selectField( 'bot_passwords', 'bp_token', $conds, __METHOD__ );
  272. $this->isSaved = true;
  273. }
  274. return $ok;
  275. }
  276. /**
  277. * Delete the BotPassword from the database
  278. * @return bool Success
  279. */
  280. public function delete() {
  281. $conds = [
  282. 'bp_user' => $this->centralId,
  283. 'bp_app_id' => $this->appId,
  284. ];
  285. $dbw = self::getDB( DB_MASTER );
  286. $dbw->delete( 'bot_passwords', $conds, __METHOD__ );
  287. $ok = (bool)$dbw->affectedRows();
  288. if ( $ok ) {
  289. $this->token = '**unsaved**';
  290. $this->isSaved = false;
  291. }
  292. return $ok;
  293. }
  294. /**
  295. * Invalidate all passwords for a user, by name
  296. * @param string $username User name
  297. * @return bool Whether any passwords were invalidated
  298. */
  299. public static function invalidateAllPasswordsForUser( $username ) {
  300. $centralId = CentralIdLookup::factory()->centralIdFromName(
  301. $username, CentralIdLookup::AUDIENCE_RAW, CentralIdLookup::READ_LATEST
  302. );
  303. return $centralId && self::invalidateAllPasswordsForCentralId( $centralId );
  304. }
  305. /**
  306. * Invalidate all passwords for a user, by central ID
  307. * @param int $centralId
  308. * @return bool Whether any passwords were invalidated
  309. */
  310. public static function invalidateAllPasswordsForCentralId( $centralId ) {
  311. global $wgEnableBotPasswords;
  312. if ( !$wgEnableBotPasswords ) {
  313. return false;
  314. }
  315. $dbw = self::getDB( DB_MASTER );
  316. $dbw->update(
  317. 'bot_passwords',
  318. [ 'bp_password' => PasswordFactory::newInvalidPassword()->toString() ],
  319. [ 'bp_user' => $centralId ],
  320. __METHOD__
  321. );
  322. return (bool)$dbw->affectedRows();
  323. }
  324. /**
  325. * Remove all passwords for a user, by name
  326. * @param string $username User name
  327. * @return bool Whether any passwords were removed
  328. */
  329. public static function removeAllPasswordsForUser( $username ) {
  330. $centralId = CentralIdLookup::factory()->centralIdFromName(
  331. $username, CentralIdLookup::AUDIENCE_RAW, CentralIdLookup::READ_LATEST
  332. );
  333. return $centralId && self::removeAllPasswordsForCentralId( $centralId );
  334. }
  335. /**
  336. * Remove all passwords for a user, by central ID
  337. * @param int $centralId
  338. * @return bool Whether any passwords were removed
  339. */
  340. public static function removeAllPasswordsForCentralId( $centralId ) {
  341. global $wgEnableBotPasswords;
  342. if ( !$wgEnableBotPasswords ) {
  343. return false;
  344. }
  345. $dbw = self::getDB( DB_MASTER );
  346. $dbw->delete(
  347. 'bot_passwords',
  348. [ 'bp_user' => $centralId ],
  349. __METHOD__
  350. );
  351. return (bool)$dbw->affectedRows();
  352. }
  353. /**
  354. * Returns a (raw, unhashed) random password string.
  355. * @param Config $config
  356. * @return string
  357. */
  358. public static function generatePassword( $config ) {
  359. return PasswordFactory::generateRandomPasswordString(
  360. max( 32, $config->get( 'MinimalPasswordLength' ) ) );
  361. }
  362. /**
  363. * There are two ways to login with a bot password: "username@appId", "password" and
  364. * "username", "appId@password". Transform it so it is always in the first form.
  365. * Returns [bot username, bot password].
  366. * If this cannot be a bot password login just return false.
  367. * @param string $username
  368. * @param string $password
  369. * @return array|false
  370. */
  371. public static function canonicalizeLoginData( $username, $password ) {
  372. $sep = self::getSeparator();
  373. // the strlen check helps minimize the password information obtainable from timing
  374. if ( strlen( $password ) >= 32 && strpos( $username, $sep ) !== false ) {
  375. // the separator is not valid in new usernames but might appear in legacy ones
  376. if ( preg_match( '/^[0-9a-w]{32,}$/', $password ) ) {
  377. return [ $username, $password ];
  378. }
  379. } elseif ( strlen( $password ) > 32 && strpos( $password, $sep ) !== false ) {
  380. $segments = explode( $sep, $password );
  381. $password = array_pop( $segments );
  382. $appId = implode( $sep, $segments );
  383. if ( preg_match( '/^[0-9a-w]{32,}$/', $password ) ) {
  384. return [ $username . $sep . $appId, $password ];
  385. }
  386. }
  387. return false;
  388. }
  389. /**
  390. * Try to log the user in
  391. * @param string $username Combined user name and app ID
  392. * @param string $password Supplied password
  393. * @param WebRequest $request
  394. * @return Status On success, the good status's value is the new Session object
  395. */
  396. public static function login( $username, $password, WebRequest $request ) {
  397. global $wgEnableBotPasswords, $wgPasswordAttemptThrottle;
  398. if ( !$wgEnableBotPasswords ) {
  399. return Status::newFatal( 'botpasswords-disabled' );
  400. }
  401. $manager = MediaWiki\Session\SessionManager::singleton();
  402. $provider = $manager->getProvider( BotPasswordSessionProvider::class );
  403. if ( !$provider ) {
  404. return Status::newFatal( 'botpasswords-no-provider' );
  405. }
  406. // Split name into name+appId
  407. $sep = self::getSeparator();
  408. if ( strpos( $username, $sep ) === false ) {
  409. return self::loginHook( $username, null, Status::newFatal( 'botpasswords-invalid-name', $sep ) );
  410. }
  411. list( $name, $appId ) = explode( $sep, $username, 2 );
  412. // Find the named user
  413. $user = User::newFromName( $name );
  414. if ( !$user || $user->isAnon() ) {
  415. return self::loginHook( $user ?: $name, null, Status::newFatal( 'nosuchuser', $name ) );
  416. }
  417. if ( $user->isLocked() ) {
  418. return Status::newFatal( 'botpasswords-locked' );
  419. }
  420. $throttle = null;
  421. if ( !empty( $wgPasswordAttemptThrottle ) ) {
  422. $throttle = new MediaWiki\Auth\Throttler( $wgPasswordAttemptThrottle, [
  423. 'type' => 'botpassword',
  424. 'cache' => ObjectCache::getLocalClusterInstance(),
  425. ] );
  426. $result = $throttle->increase( $user->getName(), $request->getIP(), __METHOD__ );
  427. if ( $result ) {
  428. $msg = wfMessage( 'login-throttled' )->durationParams( $result['wait'] );
  429. return self::loginHook( $user, null, Status::newFatal( $msg ) );
  430. }
  431. }
  432. // Get the bot password
  433. $bp = self::newFromUser( $user, $appId );
  434. if ( !$bp ) {
  435. return self::loginHook( $user, $bp,
  436. Status::newFatal( 'botpasswords-not-exist', $name, $appId ) );
  437. }
  438. // Check restrictions
  439. $status = $bp->getRestrictions()->check( $request );
  440. if ( !$status->isOK() ) {
  441. return self::loginHook( $user, $bp, Status::newFatal( 'botpasswords-restriction-failed' ) );
  442. }
  443. // Check the password
  444. $passwordObj = $bp->getPassword();
  445. if ( $passwordObj instanceof InvalidPassword ) {
  446. return self::loginHook( $user, $bp,
  447. Status::newFatal( 'botpasswords-needs-reset', $name, $appId ) );
  448. }
  449. if ( !$passwordObj->verify( $password ) ) {
  450. return self::loginHook( $user, $bp, Status::newFatal( 'wrongpassword' ) );
  451. }
  452. // Ok! Create the session.
  453. if ( $throttle ) {
  454. $throttle->clear( $user->getName(), $request->getIP() );
  455. }
  456. return self::loginHook( $user, $bp,
  457. // @phan-suppress-next-line PhanUndeclaredMethod
  458. Status::newGood( $provider->newSessionForRequest( $user, $bp, $request ) ) );
  459. }
  460. /**
  461. * Call AuthManagerLoginAuthenticateAudit
  462. *
  463. * To facilitate logging all authentications, even ones not via
  464. * AuthManager, call the AuthManagerLoginAuthenticateAudit hook.
  465. *
  466. * @param User|string $user User being logged in
  467. * @param BotPassword|null $bp Bot sub-account, if it can be identified
  468. * @param Status $status Login status
  469. * @return Status The passed-in status
  470. */
  471. private static function loginHook( $user, $bp, Status $status ) {
  472. $extraData = [];
  473. if ( $user instanceof User ) {
  474. $name = $user->getName();
  475. if ( $bp ) {
  476. $extraData['appId'] = $name . self::getSeparator() . $bp->getAppId();
  477. }
  478. } else {
  479. $name = $user;
  480. $user = null;
  481. }
  482. if ( $status->isGood() ) {
  483. $response = AuthenticationResponse::newPass( $name );
  484. } else {
  485. $response = AuthenticationResponse::newFail( $status->getMessage() );
  486. }
  487. Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $response, $user, $name, $extraData ] );
  488. return $status;
  489. }
  490. }