Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
99.31% |
143 / 144 |
|
90.91% |
10 / 11 |
CRAP | |
0.00% |
0 / 1 |
| LocalPasswordPrimaryAuthenticationProvider | |
99.31% |
143 / 144 |
|
90.91% |
10 / 11 |
54 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| getPasswordResetData | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
4 | |||
| beginPrimaryAuthentication | |
100.00% |
29 / 29 |
|
100.00% |
1 / 1 |
13 | |||
| testUserCanAuthenticate | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
4 | |||
| testUserExists | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
2 | |||
| providerAllowsAuthenticationDataChange | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
8 | |||
| providerChangeAuthenticationData | |
95.45% |
21 / 22 |
|
0.00% |
0 / 1 |
6 | |||
| accountCreationType | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| testForAccountCreation | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
6 | |||
| beginPrimaryAccountCreation | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
6 | |||
| finishAccountCreation | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | */ |
| 6 | |
| 7 | namespace MediaWiki\Auth; |
| 8 | |
| 9 | use BadMethodCallException; |
| 10 | use MediaWiki\Deferred\DeferredUpdates; |
| 11 | use MediaWiki\MainConfigNames; |
| 12 | use MediaWiki\Password\InvalidPassword; |
| 13 | use MediaWiki\Status\Status; |
| 14 | use MediaWiki\User\UserRigorOptions; |
| 15 | use StatusValue; |
| 16 | use stdClass; |
| 17 | use Wikimedia\Rdbms\DBAccessObjectUtils; |
| 18 | use Wikimedia\Rdbms\IConnectionProvider; |
| 19 | use Wikimedia\Rdbms\IDBAccessObject; |
| 20 | use Wikimedia\Timestamp\TimestampFormat as TS; |
| 21 | |
| 22 | /** |
| 23 | * A primary authentication provider that uses the password field in the 'user' table. |
| 24 | * |
| 25 | * @ingroup Auth |
| 26 | * @since 1.27 |
| 27 | */ |
| 28 | class LocalPasswordPrimaryAuthenticationProvider |
| 29 | extends AbstractPasswordPrimaryAuthenticationProvider |
| 30 | { |
| 31 | |
| 32 | /** @var bool If true, this instance is for legacy logins only. */ |
| 33 | protected $loginOnly = false; |
| 34 | |
| 35 | private IConnectionProvider $dbProvider; |
| 36 | |
| 37 | /** |
| 38 | * @param IConnectionProvider $dbProvider |
| 39 | * @param array $params Settings |
| 40 | * - loginOnly: If true, the local passwords are for legacy logins only: |
| 41 | * the local password will be invalidated when authentication is changed |
| 42 | * and new users will not have a valid local password set. |
| 43 | */ |
| 44 | public function __construct( IConnectionProvider $dbProvider, $params = [] ) { |
| 45 | parent::__construct( $params ); |
| 46 | $this->loginOnly = !empty( $params['loginOnly'] ); |
| 47 | $this->dbProvider = $dbProvider; |
| 48 | } |
| 49 | |
| 50 | /** |
| 51 | * Check if the password has expired and needs a reset |
| 52 | * |
| 53 | * @param string $username |
| 54 | * @param stdClass $row A row from the user table |
| 55 | * @return stdClass|null |
| 56 | */ |
| 57 | protected function getPasswordResetData( $username, $row ) { |
| 58 | $now = (int)wfTimestamp(); |
| 59 | $expiration = wfTimestampOrNull( TS::UNIX, $row->user_password_expires ); |
| 60 | if ( $expiration === null || (int)$expiration >= $now ) { |
| 61 | return null; |
| 62 | } |
| 63 | |
| 64 | $grace = $this->config->get( MainConfigNames::PasswordExpireGrace ); |
| 65 | if ( (int)$expiration + $grace < $now ) { |
| 66 | $data = [ |
| 67 | 'hard' => true, |
| 68 | 'msg' => Status::newFatal( 'resetpass-expired' )->getMessage(), |
| 69 | ]; |
| 70 | } else { |
| 71 | $data = [ |
| 72 | 'hard' => false, |
| 73 | 'msg' => Status::newFatal( 'resetpass-expired-soft' )->getMessage(), |
| 74 | ]; |
| 75 | } |
| 76 | |
| 77 | return (object)$data; |
| 78 | } |
| 79 | |
| 80 | /** @inheritDoc */ |
| 81 | public function beginPrimaryAuthentication( array $reqs ) { |
| 82 | $req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class ); |
| 83 | if ( !$req || $req->username === null || $req->password === null ) { |
| 84 | return AuthenticationResponse::newAbstain(); |
| 85 | } |
| 86 | |
| 87 | $username = $this->userNameUtils->getCanonical( |
| 88 | $req->username, UserRigorOptions::RIGOR_USABLE ); |
| 89 | if ( $username === false ) { |
| 90 | return AuthenticationResponse::newAbstain(); |
| 91 | } |
| 92 | |
| 93 | $row = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder() |
| 94 | ->select( [ 'user_id', 'user_password', 'user_password_expires' ] ) |
| 95 | ->from( 'user' ) |
| 96 | ->where( [ 'user_name' => $username ] ) |
| 97 | ->caller( __METHOD__ )->fetchRow(); |
| 98 | if ( !$row ) { |
| 99 | // Do not reveal whether its bad username or |
| 100 | // bad password to prevent username enumeration |
| 101 | // on private wikis. (T134100) |
| 102 | return $this->failResponse( $req ); |
| 103 | } |
| 104 | |
| 105 | $oldRow = clone $row; |
| 106 | // Check for *really* old password hashes that don't even have a type |
| 107 | // The old hash format was just an MD5 hex hash, with no type information |
| 108 | if ( preg_match( '/^[0-9a-f]{32}$/', $row->user_password ) ) { |
| 109 | $row->user_password = ":B:{$row->user_id}:{$row->user_password}"; |
| 110 | } |
| 111 | |
| 112 | $status = $this->checkPasswordValidity( $username, $req->password ); |
| 113 | if ( !$status->isOK() ) { |
| 114 | return $this->getFatalPasswordErrorResponse( $username, $status ); |
| 115 | } |
| 116 | |
| 117 | $pwhash = $this->getPassword( $row->user_password ); |
| 118 | if ( !$pwhash->verify( $req->password ) ) { |
| 119 | if ( $this->config->get( MainConfigNames::LegacyEncoding ) ) { |
| 120 | // Some wikis were converted from ISO 8859-1 to UTF-8, the passwords can't be converted |
| 121 | // Check for this with iconv |
| 122 | $cp1252Password = iconv( 'UTF-8', 'WINDOWS-1252//TRANSLIT', $req->password ); |
| 123 | if ( $cp1252Password === $req->password || !$pwhash->verify( $cp1252Password ) ) { |
| 124 | return $this->failResponse( $req ); |
| 125 | } |
| 126 | } else { |
| 127 | return $this->failResponse( $req ); |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | // @codeCoverageIgnoreStart |
| 132 | if ( $this->getPasswordFactory()->needsUpdate( $pwhash ) ) { |
| 133 | $newHash = $this->getPasswordFactory()->newFromPlaintext( $req->password ); |
| 134 | DeferredUpdates::addCallableUpdate( function ( $fname ) use ( $newHash, $oldRow ) { |
| 135 | $dbw = $this->dbProvider->getPrimaryDatabase(); |
| 136 | $dbw->newUpdateQueryBuilder() |
| 137 | ->update( 'user' ) |
| 138 | ->set( [ 'user_password' => $newHash->toString() ] ) |
| 139 | ->where( [ |
| 140 | 'user_id' => $oldRow->user_id, |
| 141 | 'user_password' => $oldRow->user_password, |
| 142 | ] ) |
| 143 | ->caller( $fname ) |
| 144 | ->execute(); |
| 145 | } ); |
| 146 | } |
| 147 | // @codeCoverageIgnoreEnd |
| 148 | |
| 149 | $this->setPasswordResetFlag( $username, $status, $row ); |
| 150 | |
| 151 | return AuthenticationResponse::newPass( $username ); |
| 152 | } |
| 153 | |
| 154 | /** @inheritDoc */ |
| 155 | public function testUserCanAuthenticate( $username ) { |
| 156 | $username = $this->userNameUtils->getCanonical( |
| 157 | $username, |
| 158 | UserRigorOptions::RIGOR_USABLE |
| 159 | ); |
| 160 | if ( $username === false ) { |
| 161 | return false; |
| 162 | } |
| 163 | |
| 164 | $password = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder() |
| 165 | ->select( [ 'user_password' ] ) |
| 166 | ->from( 'user' ) |
| 167 | ->where( [ 'user_name' => $username ] ) |
| 168 | ->caller( __METHOD__ ) |
| 169 | ->fetchField(); |
| 170 | if ( $password === false ) { |
| 171 | return false; |
| 172 | } |
| 173 | |
| 174 | // Check for *really* old password hashes that don't even have a type |
| 175 | // The old hash format was just an MD5 hex hash, with no type information |
| 176 | if ( preg_match( '/^[0-9a-f]{32}$/', $password ) ) { |
| 177 | return true; |
| 178 | } |
| 179 | |
| 180 | return !$this->getPassword( $password ) instanceof InvalidPassword; |
| 181 | } |
| 182 | |
| 183 | /** @inheritDoc */ |
| 184 | public function testUserExists( $username, $flags = IDBAccessObject::READ_NORMAL ) { |
| 185 | $username = $this->userNameUtils->getCanonical( |
| 186 | $username, |
| 187 | UserRigorOptions::RIGOR_USABLE |
| 188 | ); |
| 189 | if ( $username === false ) { |
| 190 | return false; |
| 191 | } |
| 192 | |
| 193 | $db = DBAccessObjectUtils::getDBFromRecency( $this->dbProvider, $flags ); |
| 194 | return (bool)$db->newSelectQueryBuilder() |
| 195 | ->select( [ 'user_id' ] ) |
| 196 | ->from( 'user' ) |
| 197 | ->where( [ 'user_name' => $username ] ) |
| 198 | ->caller( __METHOD__ )->fetchField(); |
| 199 | } |
| 200 | |
| 201 | /** @inheritDoc */ |
| 202 | public function providerAllowsAuthenticationDataChange( |
| 203 | AuthenticationRequest $req, $checkData = true |
| 204 | ) { |
| 205 | // We only want to blank the password if something else will accept the |
| 206 | // new authentication data, so return 'ignore' here. |
| 207 | if ( $this->loginOnly ) { |
| 208 | return StatusValue::newGood( 'ignored' ); |
| 209 | } |
| 210 | |
| 211 | if ( get_class( $req ) === PasswordAuthenticationRequest::class ) { |
| 212 | if ( !$checkData ) { |
| 213 | return StatusValue::newGood(); |
| 214 | } |
| 215 | |
| 216 | $username = $this->userNameUtils->getCanonical( $req->username, |
| 217 | UserRigorOptions::RIGOR_USABLE ); |
| 218 | if ( $username !== false ) { |
| 219 | $row = $this->dbProvider->getPrimaryDatabase()->newSelectQueryBuilder() |
| 220 | ->select( [ 'user_id' ] ) |
| 221 | ->from( 'user' ) |
| 222 | ->where( [ 'user_name' => $username ] ) |
| 223 | ->caller( __METHOD__ )->fetchRow(); |
| 224 | if ( $row ) { |
| 225 | $sv = StatusValue::newGood(); |
| 226 | if ( $req->password !== null ) { |
| 227 | if ( $req->password !== $req->retype ) { |
| 228 | $sv->fatal( 'badretype' ); |
| 229 | } else { |
| 230 | $sv->merge( $this->checkPasswordValidity( $username, $req->password ) ); |
| 231 | } |
| 232 | } |
| 233 | return $sv; |
| 234 | } |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | return StatusValue::newGood( 'ignored' ); |
| 239 | } |
| 240 | |
| 241 | public function providerChangeAuthenticationData( AuthenticationRequest $req ) { |
| 242 | $username = $req->username !== null |
| 243 | ? $this->userNameUtils->getCanonical( $req->username, UserRigorOptions::RIGOR_USABLE ) |
| 244 | : false; |
| 245 | if ( $username === false ) { |
| 246 | return; |
| 247 | } |
| 248 | |
| 249 | $pwhash = null; |
| 250 | |
| 251 | if ( get_class( $req ) === PasswordAuthenticationRequest::class ) { |
| 252 | if ( $this->loginOnly ) { |
| 253 | $pwhash = $this->getPasswordFactory()->newFromCiphertext( null ); |
| 254 | $expiry = null; |
| 255 | } else { |
| 256 | $pwhash = $this->getPasswordFactory()->newFromPlaintext( $req->password ); |
| 257 | $expiry = $this->getNewPasswordExpiry( $username ); |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | if ( $pwhash ) { |
| 262 | $dbw = $this->dbProvider->getPrimaryDatabase(); |
| 263 | $dbw->newUpdateQueryBuilder() |
| 264 | ->update( 'user' ) |
| 265 | ->set( [ |
| 266 | 'user_password' => $pwhash->toString(), |
| 267 | // @phan-suppress-next-line PhanPossiblyUndeclaredVariable expiry is set together with pwhash |
| 268 | 'user_password_expires' => $dbw->timestampOrNull( $expiry ), |
| 269 | ] ) |
| 270 | ->where( [ 'user_name' => $username ] ) |
| 271 | ->caller( __METHOD__ )->execute(); |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | /** @inheritDoc */ |
| 276 | public function accountCreationType() { |
| 277 | return $this->loginOnly ? self::TYPE_NONE : self::TYPE_CREATE; |
| 278 | } |
| 279 | |
| 280 | /** @inheritDoc */ |
| 281 | public function testForAccountCreation( $user, $creator, array $reqs ) { |
| 282 | $req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class ); |
| 283 | |
| 284 | $ret = StatusValue::newGood(); |
| 285 | if ( !$this->loginOnly && $req && $req->username !== null && $req->password !== null ) { |
| 286 | if ( $req->password !== $req->retype ) { |
| 287 | $ret->fatal( 'badretype' ); |
| 288 | } else { |
| 289 | $ret->merge( |
| 290 | $this->checkPasswordValidity( $user->getName(), $req->password ) |
| 291 | ); |
| 292 | } |
| 293 | } |
| 294 | return $ret; |
| 295 | } |
| 296 | |
| 297 | /** @inheritDoc */ |
| 298 | public function beginPrimaryAccountCreation( $user, $creator, array $reqs ) { |
| 299 | if ( $this->accountCreationType() === self::TYPE_NONE ) { |
| 300 | throw new BadMethodCallException( 'Shouldn\'t call this when accountCreationType() is NONE' ); |
| 301 | } |
| 302 | |
| 303 | $req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class ); |
| 304 | if ( $req && $req->username !== null && $req->password !== null ) { |
| 305 | // Nothing we can do besides claim it, because the user isn't in |
| 306 | // the DB yet |
| 307 | if ( $req->username !== $user->getName() ) { |
| 308 | $req = clone $req; |
| 309 | $req->username = $user->getName(); |
| 310 | } |
| 311 | $ret = AuthenticationResponse::newPass( $req->username ); |
| 312 | $ret->createRequest = $req; |
| 313 | return $ret; |
| 314 | } |
| 315 | return AuthenticationResponse::newAbstain(); |
| 316 | } |
| 317 | |
| 318 | /** @inheritDoc */ |
| 319 | public function finishAccountCreation( $user, $creator, AuthenticationResponse $res ) { |
| 320 | if ( $this->accountCreationType() === self::TYPE_NONE ) { |
| 321 | throw new BadMethodCallException( 'Shouldn\'t call this when accountCreationType() is NONE' ); |
| 322 | } |
| 323 | |
| 324 | // Now that the user is in the DB, set the password on it. |
| 325 | $this->providerChangeAuthenticationData( $res->createRequest ); |
| 326 | |
| 327 | return null; |
| 328 | } |
| 329 | } |