Page Menu
Home
Phabricator
Search
Configure Global Search
Log In
Files
F18771694
D11723.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
36 KB
Referenced Files
None
Subscribers
None
D11723.diff
View Options
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
Details
Attached
Mime Type
text/plain
Expires
Oct 9 2025, 9:08 PM (8 w, 6 d ago)
Storage Engine
amazon-s3
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
phabricator/secure/6p/kt/dwnfqom47wtt6fxt
Default Alt Text
D11723.diff (36 KB)
Attached To
Mode
D11723: Add email invites to Phabricator (logic only)
Attached
Detach File
Event Timeline
Log In to Comment