diff --git a/src/applications/auth/controller/PhabricatorAuthOneTimeLoginController.php b/src/applications/auth/controller/PhabricatorAuthOneTimeLoginController.php index 12decda80d..a40ab02d89 100644 --- a/src/applications/auth/controller/PhabricatorAuthOneTimeLoginController.php +++ b/src/applications/auth/controller/PhabricatorAuthOneTimeLoginController.php @@ -1,190 +1,199 @@ <?php final class PhabricatorAuthOneTimeLoginController extends PhabricatorAuthController { private $id; private $key; private $emailID; private $linkType; public function shouldRequireLogin() { return false; } public function willProcessRequest(array $data) { $this->linkType = $data['type']; $this->id = $data['id']; $this->key = $data['key']; $this->emailID = idx($data, 'emailID'); } public function processRequest() { $request = $this->getRequest(); if ($request->getUser()->isLoggedIn()) { return $this->renderError( pht('You are already logged in.')); } $target_user = id(new PhabricatorPeopleQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withIDs(array($this->id)) ->executeOne(); if (!$target_user) { return new Aphront404Response(); } // NOTE: As a convenience to users, these one-time login URIs may also // be associated with an email address which will be verified when the // URI is used. // This improves the new user experience for users receiving "Welcome" // emails on installs that require verification: if we did not verify the // email, they'd immediately get roadblocked with a "Verify Your Email" // error and have to go back to their email account, wait for a // "Verification" email, and then click that link to actually get access to // their account. This is hugely unwieldy, and if the link was only sent // to the user's email in the first place we can safely verify it as a // side effect of login. // The email hashed into the URI so users can't verify some email they // do not own by doing this: // // - Add some address you do not own; // - request a password reset; // - change the URI in the email to the address you don't own; // - login via the email link; and // - get a "verified" address you don't control. $target_email = null; if ($this->emailID) { $target_email = id(new PhabricatorUserEmail())->loadOneWhere( 'userPHID = %s AND id = %d', $target_user->getPHID(), $this->emailID); if (!$target_email) { return new Aphront404Response(); } } $engine = new PhabricatorAuthSessionEngine(); $token = $engine->loadOneTimeLoginKey( $target_user, $target_email, $this->key); if (!$token) { return $this->newDialog() ->setTitle(pht('Unable to Login')) ->setShortTitle(pht('Login Failure')) ->appendParagraph( pht( 'The login link you clicked is invalid, out of date, or has '. 'already been used.')) ->appendParagraph( pht( 'Make sure you are copy-and-pasting the entire link into '. 'your browser. Login links are only valid for 24 hours, and '. 'can only be used once.')) ->appendParagraph( pht('You can try again, or request a new link via email.')) ->addCancelButton('/login/email/', pht('Send Another Email')); } if ($request->isFormPost()) { // If we have an email bound into this URI, verify email so that clicking // the link in the "Welcome" email is good enough, without requiring users // to go through a second round of email verification. $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); - // Nuke the token so that this URI is one-time only. - $token->delete(); + // Nuke the token and all other outstanding password reset tokens. + // There is no particular security benefit to destroying them all, but + // it should reduce HackerOne reports of nebulous harm. + + PhabricatorAuthTemporaryToken::revokeTokens( + $target_user, + array($target_user->getPHID()), + array( + PhabricatorAuthSessionEngine::ONETIME_TEMPORARY_TOKEN_TYPE, + PhabricatorAuthSessionEngine::PASSWORD_TEMPORARY_TOKEN_TYPE, + )); if ($target_email) { id(new PhabricatorUserEditor()) ->setActor($target_user) ->verifyEmail($target_user, $target_email); } unset($unguarded); $next = '/'; if (!PhabricatorPasswordAuthProvider::getPasswordProvider()) { $next = '/settings/panel/external/'; } else if (PhabricatorEnv::getEnvConfig('account.editable')) { // We're going to let the user reset their password without knowing // the old one. Generate a one-time token for that. $key = Filesystem::readRandomCharacters(16); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); id(new PhabricatorAuthTemporaryToken()) ->setObjectPHID($target_user->getPHID()) ->setTokenType( PhabricatorAuthSessionEngine::PASSWORD_TEMPORARY_TOKEN_TYPE) ->setTokenExpires(time() + phutil_units('1 hour in seconds')) ->setTokenCode(PhabricatorHash::digest($key)) ->save(); unset($unguarded); $next = (string)id(new PhutilURI('/settings/panel/password/')) ->setQueryParams( array( 'key' => $key, )); $request->setTemporaryCookie(PhabricatorCookies::COOKIE_HISEC, 'yes'); } PhabricatorCookies::setNextURICookie($request, $next, $force = true); return $this->loginUser($target_user); } // NOTE: We need to CSRF here so attackers can't generate an email link, // then log a user in to an account they control via sneaky invisible // form submissions. switch ($this->linkType) { case PhabricatorAuthSessionEngine::ONETIME_WELCOME: $title = pht('Welcome to Phabricator'); break; case PhabricatorAuthSessionEngine::ONETIME_RECOVER: $title = pht('Account Recovery'); break; case PhabricatorAuthSessionEngine::ONETIME_USERNAME: case PhabricatorAuthSessionEngine::ONETIME_RESET: default: $title = pht('Login to Phabricator'); break; } $body = array(); $body[] = pht( 'Use the button below to log in as: %s', phutil_tag('strong', array(), $target_user->getUsername())); if ($target_email && !$target_email->getIsVerified()) { $body[] = pht( 'Logging in will verify %s as an email address you own.', phutil_tag('strong', array(), $target_email->getAddress())); } $body[] = pht( 'After logging in you should set a password for your account, or '. 'link your account to an external account that you can use to '. 'authenticate in the future.'); $dialog = $this->newDialog() ->setTitle($title) ->addSubmitButton(pht('Login (%s)', $target_user->getUsername())) ->addCancelButton('/'); foreach ($body as $paragraph) { $dialog->appendParagraph($paragraph); } return id(new AphrontDialogResponse())->setDialog($dialog); } } diff --git a/src/applications/auth/controller/PhabricatorEmailLoginController.php b/src/applications/auth/controller/PhabricatorEmailLoginController.php index 7ec9ae97b1..8b5a6b19c5 100644 --- a/src/applications/auth/controller/PhabricatorEmailLoginController.php +++ b/src/applications/auth/controller/PhabricatorEmailLoginController.php @@ -1,155 +1,175 @@ <?php final class PhabricatorEmailLoginController extends PhabricatorAuthController { public function shouldRequireLogin() { return false; } public function processRequest() { $request = $this->getRequest(); if (!PhabricatorPasswordAuthProvider::getPasswordProvider()) { return new Aphront400Response(); } $e_email = true; $e_captcha = true; $errors = array(); $is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business'); if ($request->isFormPost()) { $e_email = null; $e_captcha = pht('Again'); $captcha_ok = AphrontFormRecaptchaControl::processCaptcha($request); if (!$captcha_ok) { $errors[] = pht('Captcha response is incorrect, try again.'); $e_captcha = pht('Invalid'); } $email = $request->getStr('email'); if (!strlen($email)) { $errors[] = pht('You must provide an email address.'); $e_email = pht('Required'); } if (!$errors) { // NOTE: Don't validate the email unless the captcha is good; this makes // it expensive to fish for valid email addresses while giving the user // a better error if they goof their email. $target_email = id(new PhabricatorUserEmail())->loadOneWhere( 'address = %s', $email); $target_user = null; if ($target_email) { $target_user = id(new PhabricatorUser())->loadOneWhere( 'phid = %s', $target_email->getUserPHID()); } if (!$target_user) { $errors[] = pht('There is no account associated with that email address.'); $e_email = pht('Invalid'); } + // If this address is unverified, only send a reset link to it if + // the account has no verified addresses. This prevents an opportunistic + // attacker from compromising an account if a user adds an email + // address but mistypes it and doesn't notice. + + // (For a newly created account, all the addresses may be unverified, + // which is why we'll send to an unverified address in that case.) + + if ($target_email && !$target_email->getIsVerified()) { + $verified_addresses = id(new PhabricatorUserEmail())->loadAllWhere( + 'userPHID = %s AND isVerified = 1', + $target_email->getUserPHID()); + if ($verified_addresses) { + $errors[] = pht( + 'That email addess is not verified. You can only send '. + 'password reset links to a verified address.'); + $e_email = pht('Unverified'); + } + } + if (!$errors) { $engine = new PhabricatorAuthSessionEngine(); $uri = $engine->getOneTimeLoginURI( $target_user, null, PhabricatorAuthSessionEngine::ONETIME_RESET); if ($is_serious) { $body = <<<EOBODY You can use this link to reset your Phabricator password: {$uri} EOBODY; } else { $body = <<<EOBODY Condolences on forgetting your password. You can use this link to reset it: {$uri} After you set a new password, consider writing it down on a sticky note and attaching it to your monitor so you don't forget again! Choosing a very short, easy-to-remember password like "cat" or "1234" might also help. Best Wishes, Phabricator EOBODY; } // NOTE: Don't set the user as 'from', or they may not receive the // mail if they have the "don't send me email about my own actions" // preference set. $mail = id(new PhabricatorMetaMTAMail()) ->setSubject(pht('[Phabricator] Password Reset')) ->addRawTos(array($target_email->getAddress())) ->setBody($body) ->saveAndSend(); return $this->newDialog() ->setTitle(pht('Check Your Email')) ->setShortTitle(pht('Email Sent')) ->appendParagraph( pht('An email has been sent with a link you can use to login.')) ->addCancelButton('/', pht('Done')); } } } $error_view = null; if ($errors) { $error_view = new AphrontErrorView(); $error_view->setErrors($errors); } $email_auth = new PHUIFormLayoutView(); $email_auth->appendChild($error_view); $email_auth ->setUser($request->getUser()) ->setFullWidth(true) ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Email')) ->setName('email') ->setValue($request->getStr('email')) ->setError($e_email)) ->appendChild( id(new AphrontFormRecaptchaControl()) ->setLabel(pht('Captcha')) ->setError($e_captcha)); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(pht('Reset Password')); $dialog = new AphrontDialogView(); $dialog->setUser($request->getUser()); $dialog->setTitle(pht( 'Forgot Password / Email Login')); $dialog->appendChild($email_auth); $dialog->addSubmitButton(pht('Send Email')); $dialog->setSubmitURI('/login/email/'); return $this->buildApplicationPage( array( $crumbs, $dialog, ), array( 'title' => pht('Forgot Password'), )); } }