wikimedia/mediawiki-core

View on GitHub
includes/auth/AbstractTemporaryPasswordPrimaryAuthenticationProvider.php

Summary

Maintainability
D
2 days
Test Coverage
<?php
/**
 * 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 Auth
 */

namespace MediaWiki\Auth;

use MediaWiki\MainConfigNames;
use MediaWiki\Password\InvalidPassword;
use MediaWiki\Password\Password;
use MediaWiki\Password\PasswordFactory;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Title\Title;
use MediaWiki\User\Options\UserOptionsLookup;
use MediaWiki\User\User;
use MediaWiki\User\UserRigorOptions;
use Wikimedia\IPUtils;
use Wikimedia\Rdbms\IConnectionProvider;
use Wikimedia\Rdbms\IDBAccessObject;

/**
 * A primary authentication provider that uses a temporary password.
 *
 * A successful login will force a password reset.
 *
 * @note For proper operation, this should generally come before any other
 *  password-based authentication providers.
 * @stable to extend
 * @ingroup Auth
 * @since 1.43
 */
abstract class AbstractTemporaryPasswordPrimaryAuthenticationProvider
    extends AbstractPasswordPrimaryAuthenticationProvider
{
    /** @var bool */
    protected $emailEnabled = null;

    /** @var int */
    protected $newPasswordExpiry = null;

    /** @var int */
    protected $passwordReminderResendTime = null;

    protected IConnectionProvider $dbProvider;
    protected UserOptionsLookup $userOptionsLookup;

    /**
     * @param IConnectionProvider $dbProvider
     * @param UserOptionsLookup $userOptionsLookup
     * @param array $params
     *  - emailEnabled: (bool) must be true for the option to email passwords to be present
     *  - newPasswordExpiry: (int) expiration time of temporary passwords, in seconds
     *  - passwordReminderResendTime: (int) cooldown period in hours until a password reminder can
     *    be sent to the same user again
     */
    public function __construct(
        IConnectionProvider $dbProvider,
        UserOptionsLookup $userOptionsLookup,
        $params = []
    ) {
        parent::__construct( $params );

        if ( isset( $params['emailEnabled'] ) ) {
            $this->emailEnabled = (bool)$params['emailEnabled'];
        }
        if ( isset( $params['newPasswordExpiry'] ) ) {
            $this->newPasswordExpiry = (int)$params['newPasswordExpiry'];
        }
        if ( isset( $params['passwordReminderResendTime'] ) ) {
            $this->passwordReminderResendTime = $params['passwordReminderResendTime'];
        }
        $this->dbProvider = $dbProvider;
        $this->userOptionsLookup = $userOptionsLookup;
    }

    protected function postInitSetup() {
        $this->emailEnabled ??= $this->config->get( MainConfigNames::EnableEmail );
        $this->newPasswordExpiry ??= $this->config->get( MainConfigNames::NewPasswordExpiry );
        $this->passwordReminderResendTime ??=
            $this->config->get( MainConfigNames::PasswordReminderResendTime );
    }

    protected function getPasswordResetData( $username, $data ) {
        // Always reset
        return (object)[
            'msg' => wfMessage( 'resetpass-temp-emailed' ),
            'hard' => true,
        ];
    }

    public function getAuthenticationRequests( $action, array $options ) {
        switch ( $action ) {
            case AuthManager::ACTION_LOGIN:
                return [ new PasswordAuthenticationRequest() ];

            case AuthManager::ACTION_CHANGE:
                return [ TemporaryPasswordAuthenticationRequest::newRandom() ];

            case AuthManager::ACTION_CREATE:
                // Allow named users creating a new account to email a temporary password to a given address
                // in case they are creating an account for somebody else.
                // This isn't a likely scenario for account creations by anonymous or temporary users
                // and is therefore disabled for them (T328718).
                if (
                    isset( $options['username'] ) &&
                    !$this->userNameUtils->isTemp( $options['username'] ) &&
                    $this->emailEnabled
                ) {
                    return [ TemporaryPasswordAuthenticationRequest::newRandom() ];
                } else {
                    return [];
                }

            case AuthManager::ACTION_REMOVE:
                return [ new TemporaryPasswordAuthenticationRequest ];

            default:
                return [];
        }
    }

    public function beginPrimaryAuthentication( array $reqs ) {
        $req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class );
        if ( !$req || $req->username === null || $req->password === null ) {
            return AuthenticationResponse::newAbstain();
        }

        $username = $this->userNameUtils->getCanonical(
            $req->username, UserRigorOptions::RIGOR_USABLE );
        if ( $username === false ) {
            return AuthenticationResponse::newAbstain();
        }

        [ $tempPassHash, $tempPassTime ] = $this->getTemporaryPassword( $username, IDBAccessObject::READ_LATEST );
        if ( !$tempPassHash ) {
            return AuthenticationResponse::newAbstain();
        }

        $status = $this->checkPasswordValidity( $username, $req->password );
        if ( !$status->isOK() ) {
            return $this->getFatalPasswordErrorResponse( $username, $status );
        }

        if ( !$tempPassHash->verify( $req->password ) ||
            !$this->isTimestampValid( $tempPassTime )
        ) {
            return $this->failResponse( $req );
        }

        // Add an extra log entry since a temporary password is
        // an unusual way to log in, so its important to keep track
        // of in case of abuse.
        $this->logger->info( "{user} successfully logged in using temp password",
            [
                'provider' => static::class,
                'user' => $username,
                'requestIP' => $this->manager->getRequest()->getIP()
            ]
        );

        $this->setPasswordResetFlag( $username, $status );

        return AuthenticationResponse::newPass( $username );
    }

    public function testUserCanAuthenticate( $username ) {
        $username = $this->userNameUtils->getCanonical( $username, UserRigorOptions::RIGOR_USABLE );
        if ( $username === false ) {
            return false;
        }

        [ $tempPassHash, $tempPassTime ] = $this->getTemporaryPassword( $username );
        return $tempPassHash &&
            !( $tempPassHash instanceof InvalidPassword ) &&
            $this->isTimestampValid( $tempPassTime );
    }

    public function providerAllowsAuthenticationDataChange(
        AuthenticationRequest $req, $checkData = true
    ) {
        if ( get_class( $req ) !== TemporaryPasswordAuthenticationRequest::class ) {
            // We don't really ignore it, but this is what the caller expects.
            return \StatusValue::newGood( 'ignored' );
        }

        if ( !$checkData ) {
            return \StatusValue::newGood();
        }

        $username = $this->userNameUtils->getCanonical(
            $req->username, UserRigorOptions::RIGOR_USABLE );
        if ( $username === false ) {
            return \StatusValue::newGood( 'ignored' );
        }

        [ $tempPassHash, $tempPassTime ] = $this->getTemporaryPassword( $username, IDBAccessObject::READ_LATEST );
        if ( !$tempPassHash ) {
            return \StatusValue::newGood( 'ignored' );
        }

        $sv = \StatusValue::newGood();
        if ( $req->password !== null ) {
            $sv->merge( $this->checkPasswordValidity( $username, $req->password ) );

            if ( $req->mailpassword ) {
                if ( !$this->emailEnabled ) {
                    return \StatusValue::newFatal( 'passwordreset-emaildisabled' );
                }

                // We don't check whether the user has an email address;
                // that information should not be exposed to the caller.

                // do not allow temporary password creation within
                // $wgPasswordReminderResendTime from the last attempt
                if (
                    $this->passwordReminderResendTime
                    && $tempPassTime
                    && time() < (int)wfTimestamp( TS_UNIX, $tempPassTime )
                        + $this->passwordReminderResendTime * 3600
                ) {
                    // Round the time in hours to 3 d.p., in case someone is specifying
                    // minutes or seconds.
                    return \StatusValue::newFatal( 'throttled-mailpassword',
                        round( $this->passwordReminderResendTime, 3 ) );
                }

                if ( !$req->caller ) {
                    return \StatusValue::newFatal( 'passwordreset-nocaller' );
                }
                if ( !IPUtils::isValid( $req->caller ) ) {
                    $caller = User::newFromName( $req->caller );
                    if ( !$caller ) {
                        return \StatusValue::newFatal( 'passwordreset-nosuchcaller', $req->caller );
                    }
                }
            }
        }
        return $sv;
    }

    public function providerChangeAuthenticationData( AuthenticationRequest $req ) {
        $username = $req->username !== null ?
            $this->userNameUtils->getCanonical( $req->username, UserRigorOptions::RIGOR_USABLE ) : false;
        if ( $username === false ) {
            return;
        }

        $sendMail = false;
        if ( $req->action !== AuthManager::ACTION_REMOVE &&
            get_class( $req ) === TemporaryPasswordAuthenticationRequest::class
        ) {
            $tempPassHash = $this->getPasswordFactory()->newFromPlaintext( $req->password );
            $tempPassTime = wfTimestampNow();
            $sendMail = $req->mailpassword;
            // Prevent other temp password providers from sending duplicate emails
            $req->mailpassword = false;
        } else {
            // Invalidate the temporary password when any other auth is reset, or when removing
            $tempPassHash = PasswordFactory::newInvalidPassword();
            $tempPassTime = null;
        }

        $this->setTemporaryPassword( $username, $tempPassHash, $tempPassTime );

        if ( $sendMail ) {
            $this->maybeSendPasswordResetEmail( $req );
        }
    }

    public function accountCreationType() {
        return self::TYPE_CREATE;
    }

    public function testForAccountCreation( $user, $creator, array $reqs ) {
        /** @var TemporaryPasswordAuthenticationRequest $req */
        $req = AuthenticationRequest::getRequestByClass(
            $reqs, TemporaryPasswordAuthenticationRequest::class
        );

        $ret = \StatusValue::newGood();
        if ( $req ) {
            if ( $req->mailpassword ) {
                if ( !$this->emailEnabled ) {
                    $ret->merge( \StatusValue::newFatal( 'emaildisabled' ) );
                } elseif ( !$user->getEmail() ) {
                    $ret->merge( \StatusValue::newFatal( 'noemailcreate' ) );
                }
            }

            $ret->merge(
                $this->checkPasswordValidity( $user->getName(), $req->password )
            );
        }
        return $ret;
    }

    public function beginPrimaryAccountCreation( $user, $creator, array $reqs ) {
        /** @var TemporaryPasswordAuthenticationRequest $req */
        $req = AuthenticationRequest::getRequestByClass(
            $reqs, TemporaryPasswordAuthenticationRequest::class
        );
        if ( $req && $req->username !== null && $req->password !== null ) {
            // Nothing we can do yet, because the user isn't in the DB yet
            if ( $req->username !== $user->getName() ) {
                $req = clone $req;
                $req->username = $user->getName();
            }

            if ( $req->mailpassword ) {
                // prevent EmailNotificationSecondaryAuthenticationProvider from sending another mail
                $this->manager->setAuthenticationSessionData( 'no-email', true );
            }

            $ret = AuthenticationResponse::newPass( $req->username );
            $ret->createRequest = $req;
            return $ret;
        }
        return AuthenticationResponse::newAbstain();
    }

    public function finishAccountCreation( $user, $creator, AuthenticationResponse $res ) {
        /** @var TemporaryPasswordAuthenticationRequest $req */
        $req = $res->createRequest;
        $mailpassword = $req->mailpassword;
        // Prevent providerChangeAuthenticationData() from sending the wrong email
        $req->mailpassword = false;

        // Now that the user is in the DB, set the password on it.
        $this->providerChangeAuthenticationData( $req );

        if ( $mailpassword ) {
            $this->maybeSendNewAccountEmail( $user, $creator, $req->password );
        }

        return $mailpassword ? 'byemail' : null;
    }

    /**
     * Check that a temporary password is still valid (hasn't expired).
     * @param string|int|null $timestamp Timestamp in the database's format; null means it doesn't
     *   expire
     * @return bool
     */
    protected function isTimestampValid( $timestamp ) {
        $time = wfTimestampOrNull( TS_MW, $timestamp );
        if ( $time !== null ) {
            $expiry = (int)wfTimestamp( TS_UNIX, $time ) + $this->newPasswordExpiry;
            if ( time() >= $expiry ) {
                return false;
            }
        }
        return true;
    }

    /**
     * Wait for the new account to be recorded, and if successful, send an email about the new
     * account creation and the temporary password.
     *
     * If overridden, the override must call sendNewAccountEmail().
     *
     * @stable to override
     * @param User $user The new user account
     * @param User $creatingUser The user who created the account (can be anonymous)
     * @param string $password The temporary password
     */
    protected function maybeSendNewAccountEmail( User $user, User $creatingUser, $password ): void {
        // Send email after DB commit (the callback does not run in case of DB rollback)
        $this->dbProvider->getPrimaryDatabase()->onTransactionCommitOrIdle(
            function () use ( $user, $creatingUser, $password ) {
                $this->sendNewAccountEmail( $user, $creatingUser, $password );
            },
            __METHOD__
        );
    }

    /**
     * Send an email about the new account creation and the temporary password.
     *
     * @stable to override
     * @param User $user The new user account
     * @param User $creatingUser The user who created the account (can be anonymous)
     * @param string $password The temporary password
     */
    protected function sendNewAccountEmail( User $user, User $creatingUser, $password ): void {
        $ip = $creatingUser->getRequest()->getIP();
        // @codeCoverageIgnoreStart
        if ( !$ip ) {
            return;
        }
        // @codeCoverageIgnoreEnd

        $this->getHookRunner()->onUser__mailPasswordInternal( $creatingUser, $ip, $user );

        $mainPageUrl = Title::newMainPage()->getCanonicalURL();
        $userLanguage = $this->userOptionsLookup->getOption( $user, 'language' );
        $subjectMessage = wfMessage( 'createaccount-title' )->inLanguage( $userLanguage );
        $bodyMessage = wfMessage( 'createaccount-text', $ip, $user->getName(), $password,
            '<' . $mainPageUrl . '>', round( $this->newPasswordExpiry / 86400 ) )
            ->inLanguage( $userLanguage );

        $status = $user->sendMail( $subjectMessage->text(), $bodyMessage->text() );

        // @codeCoverageIgnoreStart
        if ( !$status->isGood() ) {
            $this->logger->warning( 'Could not send account creation email: ' .
                $status->getWikiText( false, false, 'en' ) );
        }
        // @codeCoverageIgnoreEnd
    }

    /**
     * Wait for the new temporary password to be recorded, and if successful, send an email about it.
     *
     * If overridden, the override must call sendPasswordResetEmail().
     *
     * @stable to override
     * @param TemporaryPasswordAuthenticationRequest $req
     */
    protected function maybeSendPasswordResetEmail( TemporaryPasswordAuthenticationRequest $req ): void {
        // Send email after DB commit (the callback does not run in case of DB rollback)
        $this->dbProvider->getPrimaryDatabase()->onTransactionCommitOrIdle(
            function () use ( $req ) {
                $this->sendPasswordResetEmail( $req );
            },
            __METHOD__
        );
    }

    /**
     * Send an email about the new temporary password.
     *
     * @stable to override
     * @param TemporaryPasswordAuthenticationRequest $req
     */
    protected function sendPasswordResetEmail( TemporaryPasswordAuthenticationRequest $req ): void {
        $user = User::newFromName( $req->username );
        if ( !$user ) {
            return;
        }
        $userLanguage = $this->userOptionsLookup->getOption( $user, 'language' );
        $callerIsAnon = IPUtils::isValid( $req->caller );
        $callerName = $callerIsAnon ? $req->caller : User::newFromName( $req->caller )->getName();
        $passwordMessage = wfMessage( 'passwordreset-emailelement', $user->getName(),
            $req->password )->inLanguage( $userLanguage );
        $emailMessage = wfMessage( $callerIsAnon ? 'passwordreset-emailtext-ip'
            : 'passwordreset-emailtext-user' )->inLanguage( $userLanguage );
        $body = $emailMessage->params( $callerName, $passwordMessage->text(), 1,
            '<' . Title::newMainPage()->getCanonicalURL() . '>',
            round( $this->newPasswordExpiry / 86400 ) )->text();

        // Hint that the user can choose to require email address to request a temporary password
        if (
            !$this->userOptionsLookup->getBoolOption( $user, 'requireemail' )
        ) {
            $url = SpecialPage::getTitleFor( 'Preferences', false, 'mw-prefsection-personal-email' )
                ->getCanonicalURL();
            $body .= "\n\n" . wfMessage( 'passwordreset-emailtext-require-email' )
                ->inLanguage( $userLanguage )
                ->params( "<$url>" )
                ->text();
        }

        $emailTitle = wfMessage( 'passwordreset-emailtitle' )->inLanguage( $userLanguage );
        $user->sendMail( $emailTitle->text(), $body );
    }

    /**
     * Return a tuple of temporary password and the time when it was generated.
     *
     * The password may be an InvalidPassword to represent that it was unset, or null if the user
     * can't authenticate for other reasons.
     *
     * The time is a a timestamp in the database's format or null (use wfTimestampOrNull() to parse
     * it). If it's null, the password doesn't expire. Otherwise, the password should be considered
     * expired after $wgNewPasswordExpiry seconds since that time.
     *
     * @stable to override
     * @param string $username Canonical username
     * @param int $flags Bitfield of IDBAccessObject::READ_* constants
     * @return array
     * @phan-return array{0:?Password, 1:?string|int}
     */
    abstract protected function getTemporaryPassword( string $username, $flags = IDBAccessObject::READ_NORMAL ): array;

    /**
     * Set a temporary password and the time when it was generated.
     *
     * @param string $username Canonical username
     * @param Password $tempPassHash Password, or an InvalidPassword to unset
     * @param string|int|null $tempPassTime Timestamp in a format accepted by wfTimestampOrNull();
     *   null means it doesn't expire
     */
    abstract protected function setTemporaryPassword( string $username, Password $tempPassHash, $tempPassTime ): void;
}