diff --git a/resources/sql/autopatches/20150209.invite.sql b/resources/sql/autopatches/20150209.invite.sql new file mode 100644 --- /dev/null +++ b/resources/sql/autopatches/20150209.invite.sql @@ -0,0 +1,11 @@ +CREATE TABLE {$NAMESPACE}_user.user_authinvite ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + authorPHID VARBINARY(64) NOT NULL, + emailAddress VARCHAR(128) NOT NULL COLLATE {$COLLATE_SORT}, + verificationHash BINARY(12) NOT NULL, + acceptedByPHID VARBINARY(64), + dateCreated INT UNSIGNED NOT NULL, + dateModified INT UNSIGNED NOT NULL, + UNIQUE KEY `key_address` (emailAddress), + UNIQUE KEY `key_code` (verificationHash) +) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT}; diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -1344,6 +1344,17 @@ 'PhabricatorAuthFinishController' => 'applications/auth/controller/PhabricatorAuthFinishController.php', 'PhabricatorAuthHighSecurityRequiredException' => 'applications/auth/exception/PhabricatorAuthHighSecurityRequiredException.php', 'PhabricatorAuthHighSecurityToken' => 'applications/auth/data/PhabricatorAuthHighSecurityToken.php', + 'PhabricatorAuthInvite' => 'applications/auth/storage/PhabricatorAuthInvite.php', + 'PhabricatorAuthInviteAccountException' => 'applications/auth/exception/PhabricatorAuthInviteAccountException.php', + 'PhabricatorAuthInviteController' => 'applications/auth/controller/PhabricatorAuthInviteController.php', + 'PhabricatorAuthInviteDialogException' => 'applications/auth/exception/PhabricatorAuthInviteDialogException.php', + 'PhabricatorAuthInviteEngine' => 'applications/auth/engine/PhabricatorAuthInviteEngine.php', + 'PhabricatorAuthInviteException' => 'applications/auth/exception/PhabricatorAuthInviteException.php', + 'PhabricatorAuthInviteInvalidException' => 'applications/auth/exception/PhabricatorAuthInviteInvalidException.php', + 'PhabricatorAuthInviteLoginException' => 'applications/auth/exception/PhabricatorAuthInviteLoginException.php', + 'PhabricatorAuthInviteRegisteredException' => 'applications/auth/exception/PhabricatorAuthInviteRegisteredException.php', + 'PhabricatorAuthInviteTestCase' => 'applications/auth/factor/__tests__/PhabricatorAuthInviteTestCase.php', + 'PhabricatorAuthInviteVerifyException' => 'applications/auth/exception/PhabricatorAuthInviteVerifyException.php', 'PhabricatorAuthLinkController' => 'applications/auth/controller/PhabricatorAuthLinkController.php', 'PhabricatorAuthListController' => 'applications/auth/controller/config/PhabricatorAuthListController.php', 'PhabricatorAuthLoginController' => 'applications/auth/controller/PhabricatorAuthLoginController.php', @@ -4550,6 +4561,17 @@ 'PhabricatorAuthFactorConfig' => 'PhabricatorAuthDAO', 'PhabricatorAuthFinishController' => 'PhabricatorAuthController', 'PhabricatorAuthHighSecurityRequiredException' => 'Exception', + 'PhabricatorAuthInvite' => 'PhabricatorUserDAO', + 'PhabricatorAuthInviteAccountException' => 'PhabricatorAuthInviteDialogException', + 'PhabricatorAuthInviteController' => 'PhabricatorAuthController', + 'PhabricatorAuthInviteDialogException' => 'PhabricatorAuthInviteException', + 'PhabricatorAuthInviteEngine' => 'Phobject', + 'PhabricatorAuthInviteException' => 'Exception', + 'PhabricatorAuthInviteInvalidException' => 'PhabricatorAuthInviteDialogException', + 'PhabricatorAuthInviteLoginException' => 'PhabricatorAuthInviteDialogException', + 'PhabricatorAuthInviteRegisteredException' => 'PhabricatorAuthInviteException', + 'PhabricatorAuthInviteTestCase' => 'PhabricatorTestCase', + 'PhabricatorAuthInviteVerifyException' => 'PhabricatorAuthInviteDialogException', 'PhabricatorAuthLinkController' => 'PhabricatorAuthController', 'PhabricatorAuthListController' => 'PhabricatorAuthProviderConfigController', 'PhabricatorAuthLoginController' => 'PhabricatorAuthController', diff --git a/src/applications/auth/application/PhabricatorAuthApplication.php b/src/applications/auth/application/PhabricatorAuthApplication.php --- a/src/applications/auth/application/PhabricatorAuthApplication.php +++ b/src/applications/auth/application/PhabricatorAuthApplication.php @@ -98,6 +98,7 @@ 'login/(?P[^/]+)/(?:(?P[^/]+)/)?' => 'PhabricatorAuthLoginController', '(?Ploggedout)/' => 'PhabricatorAuthStartController', + 'invite/(?P[^/]+)/' => 'PhabricatorAuthInviteController', 'register/(?:(?P[^/]+)/)?' => 'PhabricatorAuthRegisterController', 'start/' => 'PhabricatorAuthStartController', 'validate/' => 'PhabricatorAuthValidateController', diff --git a/src/applications/auth/controller/PhabricatorAuthInviteController.php b/src/applications/auth/controller/PhabricatorAuthInviteController.php new file mode 100644 --- /dev/null +++ b/src/applications/auth/controller/PhabricatorAuthInviteController.php @@ -0,0 +1,58 @@ +getViewer(); + + $engine = id(new PhabricatorAuthInviteEngine()) + ->setViewer($viewer); + + if ($request->isFormPost()) { + $engine->setUserHasConfirmedVerify(true); + } + + try { + $invite = $engine->processInviteCode($request->getURIData('code')); + } catch (PhabricatorAuthInviteDialogException $ex) { + $response = $this->newDialog() + ->setTitle($ex->getTitle()) + ->appendParagraph($ex->getBody()); + + $submit_text = $ex->getSubmitButtonText(); + if ($submit_text) { + $response->addSubmitButton($submit_text); + } + + $submit_uri = $ex->getSubmitButtonURI(); + if ($submit_uri) { + $response->setSubmitURI($submit_uri); + } + + $cancel_uri = $ex->getCancelButtonURI(); + $cancel_text = $ex->getCancelButtonText(); + if ($cancel_uri && $cancel_text) { + $response->addCancelButton($cancel_uri, $cancel_text); + } else if ($cancel_uri) { + $response->addCancelButton($cancel_uri); + } + + return $response; + } catch (PhabricatorAuthInviteRegisteredException $ex) { + // We're all set on processing this invite, just send the user home. + return id(new AphrontRedirectResponse())->setURI('/'); + } + + + // TODO: This invite is good, but we need to drive the user through + // registration. + throw new Exception(pht('TODO: Build invite/registration workflow.')); + } + + +} diff --git a/src/applications/auth/engine/PhabricatorAuthInviteEngine.php b/src/applications/auth/engine/PhabricatorAuthInviteEngine.php new file mode 100644 --- /dev/null +++ b/src/applications/auth/engine/PhabricatorAuthInviteEngine.php @@ -0,0 +1,255 @@ +viewer = $viewer; + return $this; + } + + public function getViewer() { + if (!$this->viewer) { + throw new Exception(pht('Call setViewer() before getViewer()!')); + } + return $this->viewer; + } + + public function setUserHasConfirmedVerify($confirmed) { + $this->userHasConfirmedVerify = $confirmed; + return $this; + } + + private function shouldVerify() { + return $this->userHasConfirmedVerify; + } + + public function processInviteCode($code) { + $viewer = $this->getViewer(); + + $invite = id(new PhabricatorAuthInvite())->loadOneWhere( + 'verificationHash = %s', + PhabricatorHash::digestForIndex($code)); + if (!$invite) { + throw id(new PhabricatorAuthInviteInvalidException( + pht('Bad Invite Code'), + pht( + 'The invite code in the link you clicked is invalid. Check that '. + 'you followed the link correctly.'))) + ->setCancelButtonURI('/') + ->setCancelButtonText(pht('Curses!')); + } + + $accepted_phid = $invite->getAcceptedByPHID(); + if ($accepted_phid) { + if ($accepted_phid == $viewer->getPHID()) { + throw id(new PhabricatorAuthInviteInvalidException( + pht('Already Accepted'), + pht( + 'You have already accepted this invitation.'))) + ->setCancelButtonURI('/') + ->setCancelButtonText(pht('Awesome')); + } else { + throw id(new PhabricatorAuthInviteInvalidException( + pht('Already Accepted'), + pht( + 'The invite code in the link you clicked has already '. + 'been accepted.'))) + ->setCancelButtonURI('/') + ->setCancelButtonText(pht('Continue')); + } + } + + $email = id(new PhabricatorUserEmail())->loadOneWhere( + 'address = %s', + $invite->getEmailAddress()); + + if ($viewer->isLoggedIn()) { + $this->handleLoggedInInvite($invite, $viewer, $email); + } + + if ($email) { + $other_user = $this->loadUserForEmail($email); + + if ($email->getIsVerified()) { + throw id(new PhabricatorAuthInviteLoginException( + pht('Already Registered'), + pht( + 'The email address you just clicked a link from is already '. + 'verified and associated with a registered account (%s). Log '. + 'in to continue.', + phutil_tag('strong', array(), $other_user->getName())))) + ->setCancelButtonText(pht('Log In')) + ->setCancelButtonURI($this->getLoginURI()); + } else if ($email->getIsPrimary()) { + throw id(new PhabricatorAuthInviteLoginException( + pht('Already Registered'), + pht( + 'The email address you just clicked a link from is already '. + 'the primary email address for a registered account (%s). Log '. + 'in to continue.', + phutil_tag('strong', array(), $other_user->getName())))) + ->setCancelButtonText(pht('Log In')) + ->setCancelButtonURI($this->getLoginURI()); + } else if (!$this->shouldVerify()) { + throw id(new PhabricatorAuthInviteVerifyException( + pht('Already Associated'), + pht( + 'The email address you just clicked a link from is already '. + 'associated with a registered account (%s), but is not '. + 'verified. Log in to that account to continue. If you can not '. + 'log in, you can register a new account.', + phutil_tag('strong', array(), $other_user->getName())))) + ->setCancelButtonText(pht('Log In')) + ->setCancelButtonURI($this->getLoginURI()) + ->setSubmitButtonText(pht('Register New Account')); + } else { + // NOTE: The address is not verified and not a primary address, so + // we will eventually steal it if the user completes registration. + } + } + + // The invite and email address are OK, but the user needs to register. + return $invite; + } + + private function handleLoggedInInvite( + PhabricatorAuthInvite $invite, + PhabricatorUser $viewer, + PhabricatorUserEmail $email = null) { + + if ($email && ($email->getUserPHID() !== $viewer->getPHID())) { + $other_user = $this->loadUserForEmail($email); + if ($email->getIsVerified()) { + throw id(new PhabricatorAuthInviteAccountException( + pht('Wrong Account'), + pht( + 'You are logged in as %s, but the email address you just '. + 'clicked a link from is already verified and associated '. + 'with another account (%s). Switch accounts, then try again.', + phutil_tag('strong', array(), $viewer->getUsername()), + phutil_tag('strong', array(), $other_user->getName())))) + ->setSubmitButtonText(pht('Log Out')) + ->setSubmitButtonURI($this->getLogoutURI()) + ->setCancelButtonURI('/'); + } else if ($email->getIsPrimary()) { + // NOTE: We never steal primary addresses from other accounts, even + // if they are unverified. This would leave the other account with + // no address. Users can use password recovery to access the other + // account if they really control the address. + throw id(new PhabricatorAuthInviteAccountException( + pht('Wrong Acount'), + pht( + 'You are logged in as %s, but the email address you just '. + 'clicked a link from is already the primary email address '. + 'for another account (%s). Switch accounts, then try again.', + phutil_tag('strong', array(), $viewer->getUsername()), + phutil_tag('strong', array(), $other_user->getName())))) + ->setSubmitButtonText(pht('Log Out')) + ->setSubmitButtonURI($this->getLogoutURI()) + ->setCancelButtonURI('/'); + } else if (!$this->shouldVerify()) { + throw id(new PhabricatorAuthInviteVerifyException( + pht('Verify Email'), + pht( + 'You are logged in as %s, but the email address (%s) you just '. + 'clicked a link from is already associated with another '. + 'account (%s). You can log out to switch accounts, or verify '. + 'the address and attach it to your current account. Attach '. + 'email address %s to user account %s?', + phutil_tag('strong', array(), $viewer->getUsername()), + phutil_tag('strong', array(), $invite->getEmailAddress()), + phutil_tag('strong', array(), $other_user->getName()), + phutil_tag('strong', array(), $invite->getEmailAddress()), + phutil_tag('strong', array(), $viewer->getUsername())))) + ->setSubmitButtonText( + pht( + 'Verify %s', + $invite->getEmailAddress())) + ->setCancelButtonText(pht('Log Out')) + ->setCancelButtonURI($this->getLogoutURI()); + } + } + + if (!$email) { + $email = id(new PhabricatorUserEmail()) + ->setAddress($invite->getEmailAddress()) + ->setIsVerified(0) + ->setIsPrimary(0); + } + + if (!$email->getIsVerified()) { + // We're doing this check here so that we can verify the address if + // it's already attached to the viewer's account, just not verified. + if (!$this->shouldVerify()) { + throw id(new PhabricatorAuthInviteVerifyException( + pht('Verify Email'), + pht( + 'Verify this email address (%s) and attach it to your '. + 'account (%s)?', + phutil_tag('strong', array(), $invite->getEmailAddress()), + phutil_tag('strong', array(), $viewer->getUsername())))) + ->setSubmitButtonText( + pht( + 'Verify %s', + $invite->getEmailAddress())) + ->setCancelButtonURI('/'); + } + + $editor = id(new PhabricatorUserEditor()) + ->setActor($viewer); + + // If this is a new email, add it to the user's account. + if (!$email->getUserPHID()) { + $editor->addEmail($viewer, $email); + } + + // If another user added this email (but has not verified it), + // take it from them. + $editor->reassignEmail($viewer, $email); + + $editor->verifyEmail($viewer, $email); + } + + $invite->setAcceptedByPHID($viewer->getPHID()); + $invite->save(); + + // If we make it here, the user was already logged in with the email + // address attached to their account and verified, or we attached it to + // their account (if it was not already attached) and verified it. + throw new PhabricatorAuthInviteRegisteredException(); + } + + private function loadUserForEmail(PhabricatorUserEmail $email) { + $user = id(new PhabricatorHandleQuery()) + ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->withPHIDs(array($email->getUserPHID())) + ->executeOne(); + if (!$user) { + throw new Exception( + pht( + 'Email record ("%s") has bad associated user PHID ("%s").', + $email->getAddress(), + $email->getUserPHID())); + } + + return $user; + } + + private function getLoginURI() { + return '/auth/start/'; + } + + private function getLogoutURI() { + return '/auth/logout/'; + } + +} diff --git a/src/applications/auth/exception/PhabricatorAuthInviteAccountException.php b/src/applications/auth/exception/PhabricatorAuthInviteAccountException.php new file mode 100644 --- /dev/null +++ b/src/applications/auth/exception/PhabricatorAuthInviteAccountException.php @@ -0,0 +1,7 @@ +title = $title; + $this->body = $body; + parent::__construct(pht('%s: %s', $title, $body)); + } + + public function getTitle() { + return $this->title; + } + + public function getBody() { + return $this->body; + } + + public function setSubmitButtonText($submit_button_text) { + $this->submitButtonText = $submit_button_text; + return $this; + } + + public function getSubmitButtonText() { + return $this->submitButtonText; + } + + public function setSubmitButtonURI($submit_button_uri) { + $this->submitButtonURI = $submit_button_uri; + return $this; + } + + public function getSubmitButtonURI() { + return $this->submitButtonURI; + } + + public function setCancelButtonText($cancel_button_text) { + $this->cancelButtonText = $cancel_button_text; + return $this; + } + + public function getCancelButtonText() { + return $this->cancelButtonText; + } + + public function setCancelButtonURI($cancel_button_uri) { + $this->cancelButtonURI = $cancel_button_uri; + return $this; + } + + public function getCancelButtonURI() { + return $this->cancelButtonURI; + } + +} diff --git a/src/applications/auth/exception/PhabricatorAuthInviteException.php b/src/applications/auth/exception/PhabricatorAuthInviteException.php new file mode 100644 --- /dev/null +++ b/src/applications/auth/exception/PhabricatorAuthInviteException.php @@ -0,0 +1,3 @@ + true, + ); + } + + + /** + * Test that invalid invites can not be accepted. + */ + public function testInvalidInvite() { + $viewer = $this->generateUser(); + $engine = $this->generateEngine($viewer); + + $caught = null; + try { + $engine->processInviteCode('asdf1234'); + } catch (PhabricatorAuthInviteInvalidException $ex) { + $caught = $ex; + } + + $this->assertTrue($caught instanceof Exception); + } + + + /** + * Test that invites can be accepted exactly once. + */ + public function testDuplicateInvite() { + $author = $this->generateUser(); + $viewer = $this->generateUser(); + $address = Filesystem::readRandomCharacters(16).'@example.com'; + + $invite = id(new PhabricatorAuthInvite()) + ->setAuthorPHID($author->getPHID()) + ->setEmailAddress($address) + ->save(); + + $engine = $this->generateEngine($viewer); + $engine->setUserHasConfirmedVerify(true); + + $caught = null; + try { + $result = $engine->processInviteCode($invite->getVerificationCode()); + } catch (Exception $ex) { + $caught = $ex; + } + + // This first time should accept the invite and verify the addresss. + $this->assertTrue( + ($caught instanceof PhabricatorAuthInviteRegisteredException)); + + try { + $result = $engine->processInviteCode($invite->getVerificationCode()); + } catch (Exception $ex) { + $caught = $ex; + } + + // The second time through, the invite should not be acceptable. + $this->assertTrue( + ($caught instanceof PhabricatorAuthInviteInvalidException)); + } + + + /** + * Test easy invite cases, where the email is not anywhere in the system. + */ + public function testInviteWithNewEmail() { + $expect_map = array( + 'out' => array( + null, + null, + ), + 'in' => array( + 'PhabricatorAuthInviteVerifyException', + 'PhabricatorAuthInviteRegisteredException', + ), + ); + + $author = $this->generateUser(); + $logged_in = $this->generateUser(); + $logged_out = new PhabricatorUser(); + + foreach (array('out', 'in') as $is_logged_in) { + foreach (array(0, 1) as $should_verify) { + $address = Filesystem::readRandomCharacters(16).'@example.com'; + + $invite = id(new PhabricatorAuthInvite()) + ->setAuthorPHID($author->getPHID()) + ->setEmailAddress($address) + ->save(); + + switch ($is_logged_in) { + case 'out': + $viewer = $logged_out; + break; + case 'in': + $viewer = $logged_in; + break; + } + + $engine = $this->generateEngine($viewer); + $engine->setUserHasConfirmedVerify($should_verify); + + $caught = null; + try { + $result = $engine->processInviteCode($invite->getVerificationCode()); + } catch (Exception $ex) { + $caught = $ex; + } + + $expect = $expect_map[$is_logged_in]; + $expect = $expect[$should_verify]; + + $this->assertEqual( + ($expect !== null), + ($caught instanceof Exception), + pht( + 'user=%s, should_verify=%s', + $is_logged_in, + $should_verify)); + + if ($expect === null) { + $this->assertEqual($invite->getPHID(), $result->getPHID()); + } else { + $this->assertEqual( + $expect, + get_class($caught), + pht('Actual exception: %s', $caught->getMessage())); + } + } + } + } + + + /** + * Test hard invite cases, where the email is already known and attached + * to some user account. + */ + public function testInviteWithKnownEmail() { + + // This tests all permutations of: + // + // - Is the user logged out, logged in with a different account, or + // logged in with the correct account? + // - Is the address verified, or unverified? + // - Is the address primary, or nonprimary? + // - Has the user confirmed that they want to verify the address? + + $expect_map = array( + 'out' => array( + array( + array( + // For example, this corresponds to a logged out user trying to + // follow an invite with an unverified, nonprimary address, and + // they haven't clicked the "Verify" button yet. We ask them to + // verify that they want to register a new account. + 'PhabricatorAuthInviteVerifyException', + + // In this case, they have clicked the verify button. The engine + // continues the workflow. + null, + ), + array( + // And so on. All of the rest of these cases cover the other + // permutations. + 'PhabricatorAuthInviteLoginException', + 'PhabricatorAuthInviteLoginException', + ), + ), + array( + array( + 'PhabricatorAuthInviteLoginException', + 'PhabricatorAuthInviteLoginException', + ), + array( + 'PhabricatorAuthInviteLoginException', + 'PhabricatorAuthInviteLoginException', + ), + ), + ), + 'in' => array( + array( + array( + 'PhabricatorAuthInviteVerifyException', + array(true, 'PhabricatorAuthInviteRegisteredException'), + ), + array( + 'PhabricatorAuthInviteAccountException', + 'PhabricatorAuthInviteAccountException', + ), + ), + array( + array( + 'PhabricatorAuthInviteAccountException', + 'PhabricatorAuthInviteAccountException', + ), + array( + 'PhabricatorAuthInviteAccountException', + 'PhabricatorAuthInviteAccountException', + ), + ), + ), + 'same' => array( + array( + array( + 'PhabricatorAuthInviteVerifyException', + array(true, 'PhabricatorAuthInviteRegisteredException'), + ), + array( + 'PhabricatorAuthInviteVerifyException', + array(true, 'PhabricatorAuthInviteRegisteredException'), + ), + ), + array( + array( + 'PhabricatorAuthInviteRegisteredException', + 'PhabricatorAuthInviteRegisteredException', + ), + array( + 'PhabricatorAuthInviteRegisteredException', + 'PhabricatorAuthInviteRegisteredException', + ), + ), + ), + ); + + $author = $this->generateUser(); + $logged_in = $this->generateUser(); + $logged_out = new PhabricatorUser(); + + foreach (array('out', 'in', 'same') as $is_logged_in) { + foreach (array(0, 1) as $is_verified) { + foreach (array(0, 1) as $is_primary) { + foreach (array(0, 1) as $should_verify) { + $other = $this->generateUser(); + + switch ($is_logged_in) { + case 'out': + $viewer = $logged_out; + break; + case 'in'; + $viewer = $logged_in; + break; + case 'same': + $viewer = clone $other; + break; + } + + $email = $this->generateEmail($other, $is_verified, $is_primary); + + $invite = id(new PhabricatorAuthInvite()) + ->setAuthorPHID($author->getPHID()) + ->setEmailAddress($email->getAddress()) + ->save(); + $code = $invite->getVerificationCode(); + + $engine = $this->generateEngine($viewer); + $engine->setUserHasConfirmedVerify($should_verify); + + $caught = null; + try { + $result = $engine->processInviteCode($code); + } catch (Exception $ex) { + $caught = $ex; + } + + $expect = $expect_map[$is_logged_in]; + $expect = $expect[$is_verified]; + $expect = $expect[$is_primary]; + $expect = $expect[$should_verify]; + + if (is_array($expect)) { + list($expect_reassign, $expect_exception) = $expect; + } else { + $expect_reassign = false; + $expect_exception = $expect; + } + + $case_info = pht( + 'user=%s, verified=%s, primary=%s, should_verify=%s', + $is_logged_in, + $is_verified, + $is_primary, + $should_verify); + + $this->assertEqual( + ($expect_exception !== null), + ($caught instanceof Exception), + $case_info); + + if ($expect_exception === null) { + $this->assertEqual($invite->getPHID(), $result->getPHID()); + } else { + $this->assertEqual( + $expect_exception, + get_class($caught), + pht('%s, exception=%s', $case_info, $caught->getMessage())); + } + + if ($expect_reassign) { + $email->reload(); + + $this->assertEqual( + $viewer->getPHID(), + $email->getUserPHID(), + pht( + 'Expected email address reassignment (%s).', + $case_info)); + } + + switch ($expect_exception) { + case 'PhabricatorAuthInviteRegisteredException': + $invite->reload(); + + $this->assertEqual( + $viewer->getPHID(), + $invite->getAcceptedByPHID(), + pht( + 'Expected invite accepted (%s).', + $case_info)); + break; + } + + } + } + } + } + } + + private function generateUser() { + return $this->generateNewTestUser(); + } + + private function generateEngine(PhabricatorUser $viewer) { + return id(new PhabricatorAuthInviteEngine()) + ->setViewer($viewer); + } + + private function generateEmail( + PhabricatorUser $user, + $is_verified, + $is_primary) { + + // NOTE: We're being a little bit sneaky here because UserEditor will not + // let you make an unverified address a primary account address, and + // the test user will already have a verified primary address. + + $email = id(new PhabricatorUserEmail()) + ->setAddress(Filesystem::readRandomCharacters(16).'@example.com') + ->setIsVerified((int)($is_verified || $is_primary)) + ->setIsPrimary(0); + + $editor = id(new PhabricatorUserEditor()) + ->setActor($user); + + $editor->addEmail($user, $email); + + if ($is_primary) { + $editor->changePrimaryEmail($user, $email); + } + + $email->setIsVerified((int)$is_verified); + $email->save(); + + return $email; + } + +} diff --git a/src/applications/auth/storage/PhabricatorAuthInvite.php b/src/applications/auth/storage/PhabricatorAuthInvite.php new file mode 100644 --- /dev/null +++ b/src/applications/auth/storage/PhabricatorAuthInvite.php @@ -0,0 +1,55 @@ + array( + 'emailAddress' => 'sort128', + 'verificationHash' => 'bytes12', + 'acceptedByPHID' => 'phid?', + ), + self::CONFIG_KEY_SCHEMA => array( + 'key_address' => array( + 'columns' => array('emailAddress'), + 'unique' => true, + ), + 'key_code' => array( + 'columns' => array('verificationHash'), + 'unique' => true, + ), + ), + ) + parent::getConfiguration(); + } + + public function getVerificationCode() { + if (!$this->getVerificationHash()) { + if ($this->verificationHash) { + throw new Exception( + pht( + 'Verification code can not be regenerated after an invite is '. + 'created.')); + } + $this->verificationCode = Filesystem::readRandomCharacters(16); + } + return $this->verificationCode; + } + + public function save() { + if (!$this->getVerificationHash()) { + $hash = PhabricatorHash::digestForIndex($this->getVerificationCode()); + $this->setVerificationHash($hash); + } + + return parent::save(); + } + +} diff --git a/src/applications/people/editor/PhabricatorUserEditor.php b/src/applications/people/editor/PhabricatorUserEditor.php --- a/src/applications/people/editor/PhabricatorUserEditor.php +++ b/src/applications/people/editor/PhabricatorUserEditor.php @@ -554,7 +554,58 @@ $user->endWriteLocking(); $user->saveTransaction(); + } + + + /** + * Reassign an unverified email address. + */ + public function reassignEmail( + PhabricatorUser $user, + PhabricatorUserEmail $email) { + $actor = $this->requireActor(); + + if (!$user->getID()) { + throw new Exception(pht('User has not been created yet!')); + } + + if (!$email->getID()) { + throw new Exception(pht('Email has not been created yet!')); + } + $user->openTransaction(); + $user->beginWriteLocking(); + + $user->reload(); + $email->reload(); + + $old_user = $email->getUserPHID(); + + if ($old_user != $user->getPHID()) { + if ($email->getIsVerified()) { + throw new Exception( + pht( + 'Verified email addresses can not be reassigned.')); + } + if ($email->getIsPrimary()) { + throw new Exception( + pht( + 'Primary email addresses can not be reassigned.')); + } + + $email->setUserPHID($user->getPHID()); + $email->save(); + + $log = PhabricatorUserLog::initializeNewLog( + $actor, + $user->getPHID(), + PhabricatorUserLog::ACTION_EMAIL_REASSIGN); + $log->setNewValue($email->getAddress()); + $log->save(); + } + + $user->endWriteLocking(); + $user->saveTransaction(); } diff --git a/src/applications/people/storage/PhabricatorUserLog.php b/src/applications/people/storage/PhabricatorUserLog.php --- a/src/applications/people/storage/PhabricatorUserLog.php +++ b/src/applications/people/storage/PhabricatorUserLog.php @@ -26,6 +26,7 @@ const ACTION_EMAIL_REMOVE = 'email-remove'; const ACTION_EMAIL_ADD = 'email-add'; const ACTION_EMAIL_VERIFY = 'email-verify'; + const ACTION_EMAIL_REASSIGN = 'email-reassign'; const ACTION_CHANGE_PASSWORD = 'change-password'; const ACTION_CHANGE_USERNAME = 'change-username'; @@ -69,6 +70,7 @@ self::ACTION_EMAIL_ADD => pht('Email: Add Address'), self::ACTION_EMAIL_REMOVE => pht('Email: Remove Address'), self::ACTION_EMAIL_VERIFY => pht('Email: Verify'), + self::ACTION_EMAIL_REASSIGN => pht('Email: Reassign'), self::ACTION_CHANGE_PASSWORD => pht('Change Password'), self::ACTION_CHANGE_USERNAME => pht('Change Username'), self::ACTION_ENTER_HISEC => pht('Hisec: Enter'),