Page Menu
Home
Phabricator
Search
Configure Global Search
Log In
Files
F18857960
D11723.id28224.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
30 KB
Referenced Files
None
Subscribers
None
D11723.id28224.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,10 @@
+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},
+ verificationCode VARCHAR(16) NOT NULL COLLATE {$COLLATE_TEXT},
+ dateCreated INT UNSIGNED NOT NULL,
+ dateModified INT UNSIGNED NOT NULL,
+ UNIQUE KEY `key_address` (emailAddress),
+ UNIQUE KEY `key_code` (verificationCode)
+) 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',
@@ -4548,6 +4559,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->getUser();
+
+ $engine = id(new PhabricatorAuthInviteEngine())
+ ->setViewer($viewer);
+
+ if ($request->isFormPost()) {
+ $engine->setUserHasConfirmedVerify(true);
+ }
+
+ try {
+ $invite = $engine->processInviteCode($request->getURIData('code'));
+ } catch (PhabricatorAuthInviteDialogException $ex) {
+ $response = $this->newDialogResponse()
+ ->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,240 @@
+<?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(
+ 'verificationCode = %s',
+ $code);
+ if (!$invite) {
+ throw id(new PhabricatorAuthInviteInvalidException(
+ pht('Bad Invite Code'),
+ pht(
+ 'The invite code in the link you clicked is invalid or has '.
+ 'already been used. Check that you followed the link correctly.')))
+ ->setCancelButtonURI('/')
+ ->setCancelButtonText(pht('Curses!'));
+ }
+
+ $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('/');
+ }
+
+ $email->openTransaction();
+ $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.
+ if ($email->getUserPHID() !== $viewer->getPHID()) {
+ $email->setUserPHID($viewer->getPHID());
+ $email->setIsPrimary(0);
+ $email->save();
+ }
+
+ $editor->verifyEmail($viewer, $email);
+ $email->saveTransaction();
+
+ $invite->delete();
+ }
+
+ // 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,298 @@
+<?php
+
+final class PhabricatorAuthInviteTestCase extends PhabricatorTestCase {
+
+
+ protected function getPhabricatorTestCaseConfiguration() {
+ return array(
+ self::PHABRICATOR_TESTCONFIG_BUILD_STORAGE_FIXTURES => true,
+ );
+ }
+
+ 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);
+ }
+
+ public function testInviteWithNewEmail() {
+
+ // These are the easy cases, where the email is not attached to any
+ // user account.
+
+ $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()));
+ }
+ }
+ }
+ }
+
+ public function testInviteWithKnownEmail() {
+
+ // This is all of the hard cases where the email address already exists
+ // somewhere in the system and we need to do complicated things to deal
+ // with it securely while still letting the user move forward.
+
+ // 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',
+ 'PhabricatorAuthInviteRegisteredException',
+ ),
+ array(
+ 'PhabricatorAuthInviteAccountException',
+ 'PhabricatorAuthInviteAccountException',
+ ),
+ ),
+ array(
+ array(
+ 'PhabricatorAuthInviteAccountException',
+ 'PhabricatorAuthInviteAccountException',
+ ),
+ array(
+ 'PhabricatorAuthInviteAccountException',
+ 'PhabricatorAuthInviteAccountException',
+ ),
+ ),
+ ),
+ 'same' => array(
+ array(
+ array(
+ 'PhabricatorAuthInviteVerifyException',
+ 'PhabricatorAuthInviteRegisteredException',
+ ),
+ array(
+ 'PhabricatorAuthInviteVerifyException',
+ '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];
+
+ $case_info = pht(
+ 'user=%s, verified=%s, primary=%s, should_verify=%s',
+ $is_logged_in,
+ $is_verified,
+ $is_primary,
+ $should_verify);
+
+ $this->assertEqual(
+ ($expect !== null),
+ ($caught instanceof Exception),
+ $case_info);
+
+ if ($expect === null) {
+ $this->assertEqual($invite->getPHID(), $result->getPHID());
+ } else {
+ $this->assertEqual(
+ $expect,
+ get_class($caught),
+ pht('%s, exception=%s', $case_info, $caught->getMessage()));
+ }
+ }
+ }
+ }
+ }
+ }
+
+ 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,37 @@
+<?php
+
+final class PhabricatorAuthInvite
+ extends PhabricatorUserDAO {
+
+ protected $authorPHID;
+ protected $emailAddress;
+ protected $verificationCode;
+
+ protected function getConfiguration() {
+ return array(
+ self::CONFIG_COLUMN_SCHEMA => array(
+ 'emailAddress' => 'sort128',
+ 'verificationCode' => 'text16',
+ ),
+ self::CONFIG_KEY_SCHEMA => array(
+ 'key_address' => array(
+ 'columns' => array('emailAddress'),
+ 'unique' => true,
+ ),
+ 'key_code' => array(
+ 'columns' => array('verificationCode'),
+ 'unique' => true,
+ ),
+ ),
+ ) + parent::getConfiguration();
+ }
+
+ public function save() {
+ if (!$this->verificationCode) {
+ $this->setVerificationCode(Filesystem::readRandomCharacters(16));
+ }
+
+ return parent::save();
+ }
+
+}
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Nov 2 2025, 7:10 PM (5 w, 3 d ago)
Storage Engine
blob
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
8995881
Default Alt Text
D11723.id28224.diff (30 KB)
Attached To
Mode
D11723: Add email invites to Phabricator (logic only)
Attached
Detach File
Event Timeline
Log In to Comment