Page MenuHomePhabricator

D11723.diff
No OneTemporary

D11723.diff

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<pkey>[^/]+)/(?:(?P<extra>[^/]+)/)?'
=> 'PhabricatorAuthLoginController',
'(?P<loggedout>loggedout)/' => 'PhabricatorAuthStartController',
+ 'invite/(?P<code>[^/]+)/' => 'PhabricatorAuthInviteController',
'register/(?:(?P<akey>[^/]+)/)?' => '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 @@
+<?php
+
+final class PhabricatorAuthInviteController
+ extends PhabricatorAuthController {
+
+ public function shouldRequireLogin() {
+ return false;
+ }
+
+ public function handleRequest(AphrontRequest $request) {
+ $viewer = $this->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 @@
+<?php
+
+
+/**
+ * This class does an unusual amount of flow control via exceptions. The intent
+ * is to make the workflows highly testable, because this code is high-stakes
+ * and difficult to test.
+ */
+final class PhabricatorAuthInviteEngine extends Phobject {
+
+ private $viewer;
+ private $userHasConfirmedVerify;
+
+ public function setViewer(PhabricatorUser $viewer) {
+ $this->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 @@
+<?php
+
+/**
+ * Exception raised when the user is logged in to the wrong account.
+ */
+final class PhabricatorAuthInviteAccountException
+ extends PhabricatorAuthInviteDialogException {}
diff --git a/src/applications/auth/exception/PhabricatorAuthInviteDialogException.php b/src/applications/auth/exception/PhabricatorAuthInviteDialogException.php
new file mode 100644
--- /dev/null
+++ b/src/applications/auth/exception/PhabricatorAuthInviteDialogException.php
@@ -0,0 +1,63 @@
+<?php
+
+abstract class PhabricatorAuthInviteDialogException
+ extends PhabricatorAuthInviteException {
+
+ private $title;
+ private $body;
+ private $submitButtonText;
+ private $submitButtonURI;
+ private $cancelButtonText;
+ private $cancelButtonURI;
+
+ public function __construct($title, $body) {
+ $this->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 @@
+<?php
+
+abstract class PhabricatorAuthInviteException extends Exception {}
diff --git a/src/applications/auth/exception/PhabricatorAuthInviteInvalidException.php b/src/applications/auth/exception/PhabricatorAuthInviteInvalidException.php
new file mode 100644
--- /dev/null
+++ b/src/applications/auth/exception/PhabricatorAuthInviteInvalidException.php
@@ -0,0 +1,7 @@
+<?php
+
+/**
+ * Exception raised when an invite code is invalid.
+ */
+final class PhabricatorAuthInviteInvalidException
+ extends PhabricatorAuthInviteDialogException {}
diff --git a/src/applications/auth/exception/PhabricatorAuthInviteLoginException.php b/src/applications/auth/exception/PhabricatorAuthInviteLoginException.php
new file mode 100644
--- /dev/null
+++ b/src/applications/auth/exception/PhabricatorAuthInviteLoginException.php
@@ -0,0 +1,9 @@
+<?php
+
+/**
+ * Exception raised when the user must log in to continue with the invite
+ * workflow (for example, the because the email address is already bound to an
+ * account).
+ */
+final class PhabricatorAuthInviteLoginException
+ extends PhabricatorAuthInviteDialogException {}
diff --git a/src/applications/auth/exception/PhabricatorAuthInviteRegisteredException.php b/src/applications/auth/exception/PhabricatorAuthInviteRegisteredException.php
new file mode 100644
--- /dev/null
+++ b/src/applications/auth/exception/PhabricatorAuthInviteRegisteredException.php
@@ -0,0 +1,8 @@
+<?php
+
+/**
+ * Exception raised when the user is already registered and the invite is a
+ * no-op.
+ */
+final class PhabricatorAuthInviteRegisteredException
+ extends PhabricatorAuthInviteException {}
diff --git a/src/applications/auth/exception/PhabricatorAuthInviteVerifyException.php b/src/applications/auth/exception/PhabricatorAuthInviteVerifyException.php
new file mode 100644
--- /dev/null
+++ b/src/applications/auth/exception/PhabricatorAuthInviteVerifyException.php
@@ -0,0 +1,7 @@
+<?php
+
+/**
+ * Exception raised when the user needs to verify an action.
+ */
+final class PhabricatorAuthInviteVerifyException
+ extends PhabricatorAuthInviteDialogException {}
diff --git a/src/applications/auth/factor/__tests__/PhabricatorAuthInviteTestCase.php b/src/applications/auth/factor/__tests__/PhabricatorAuthInviteTestCase.php
new file mode 100644
--- /dev/null
+++ b/src/applications/auth/factor/__tests__/PhabricatorAuthInviteTestCase.php
@@ -0,0 +1,374 @@
+<?php
+
+final class PhabricatorAuthInviteTestCase extends PhabricatorTestCase {
+
+
+ protected function getPhabricatorTestCaseConfiguration() {
+ return array(
+ self::PHABRICATOR_TESTCONFIG_BUILD_STORAGE_FIXTURES => 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 @@
+<?php
+
+final class PhabricatorAuthInvite
+ extends PhabricatorUserDAO {
+
+ protected $authorPHID;
+ protected $emailAddress;
+ protected $verificationHash;
+ protected $acceptedByPHID;
+
+ private $verificationCode;
+
+ protected function getConfiguration() {
+ return array(
+ self::CONFIG_COLUMN_SCHEMA => 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'),

File Metadata

Mime Type
text/plain
Expires
Mon, Mar 10, 7:52 PM (2 d, 21 h ago)
Storage Engine
amazon-s3
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
phabricator/secure/f6/6w/6bd62iqtvzddytuz
Default Alt Text
D11723.diff (36 KB)

Event Timeline