@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator

Add email invites to Phabricator (logic only)

Summary:
Ref T7152. This builds the core of email invites and implements all the hard logic for them, covering it with a pile of tests.

There's no UI to create these yet, so users can't actually get invites (and administrators can't send them).

This stuff is a complicated mess because there are so many interactions between accounts, email addresses, email verification, email primary-ness, and user verification. However, I think I got it right and got test coverage everwhere.

The degree to which this is exception-driven is a little icky, but I think it's a reasonable way to get the testability we want while still making it hard for callers to get the flow wrong. In particular, I expect there to be at least two callers (one invite flow in the upstream, and one derived invite flow in Instances) so I believe there is merit in burying as much of this logic inside the Engine as is reasonably possible.

Test Plan: Unit tests only.

Reviewers: btrahan

Reviewed By: btrahan

Subscribers: epriestley

Maniphest Tasks: T7152

Differential Revision: https://secure.phabricator.com/D11723

+933
+11
resources/sql/autopatches/20150209.invite.sql
··· 1 + CREATE TABLE {$NAMESPACE}_user.user_authinvite ( 2 + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, 3 + authorPHID VARBINARY(64) NOT NULL, 4 + emailAddress VARCHAR(128) NOT NULL COLLATE {$COLLATE_SORT}, 5 + verificationHash BINARY(12) NOT NULL, 6 + acceptedByPHID VARBINARY(64), 7 + dateCreated INT UNSIGNED NOT NULL, 8 + dateModified INT UNSIGNED NOT NULL, 9 + UNIQUE KEY `key_address` (emailAddress), 10 + UNIQUE KEY `key_code` (verificationHash) 11 + ) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT};
+22
src/__phutil_library_map__.php
··· 1344 1344 'PhabricatorAuthFinishController' => 'applications/auth/controller/PhabricatorAuthFinishController.php', 1345 1345 'PhabricatorAuthHighSecurityRequiredException' => 'applications/auth/exception/PhabricatorAuthHighSecurityRequiredException.php', 1346 1346 'PhabricatorAuthHighSecurityToken' => 'applications/auth/data/PhabricatorAuthHighSecurityToken.php', 1347 + 'PhabricatorAuthInvite' => 'applications/auth/storage/PhabricatorAuthInvite.php', 1348 + 'PhabricatorAuthInviteAccountException' => 'applications/auth/exception/PhabricatorAuthInviteAccountException.php', 1349 + 'PhabricatorAuthInviteController' => 'applications/auth/controller/PhabricatorAuthInviteController.php', 1350 + 'PhabricatorAuthInviteDialogException' => 'applications/auth/exception/PhabricatorAuthInviteDialogException.php', 1351 + 'PhabricatorAuthInviteEngine' => 'applications/auth/engine/PhabricatorAuthInviteEngine.php', 1352 + 'PhabricatorAuthInviteException' => 'applications/auth/exception/PhabricatorAuthInviteException.php', 1353 + 'PhabricatorAuthInviteInvalidException' => 'applications/auth/exception/PhabricatorAuthInviteInvalidException.php', 1354 + 'PhabricatorAuthInviteLoginException' => 'applications/auth/exception/PhabricatorAuthInviteLoginException.php', 1355 + 'PhabricatorAuthInviteRegisteredException' => 'applications/auth/exception/PhabricatorAuthInviteRegisteredException.php', 1356 + 'PhabricatorAuthInviteTestCase' => 'applications/auth/factor/__tests__/PhabricatorAuthInviteTestCase.php', 1357 + 'PhabricatorAuthInviteVerifyException' => 'applications/auth/exception/PhabricatorAuthInviteVerifyException.php', 1347 1358 'PhabricatorAuthLinkController' => 'applications/auth/controller/PhabricatorAuthLinkController.php', 1348 1359 'PhabricatorAuthListController' => 'applications/auth/controller/config/PhabricatorAuthListController.php', 1349 1360 'PhabricatorAuthLoginController' => 'applications/auth/controller/PhabricatorAuthLoginController.php', ··· 4550 4561 'PhabricatorAuthFactorConfig' => 'PhabricatorAuthDAO', 4551 4562 'PhabricatorAuthFinishController' => 'PhabricatorAuthController', 4552 4563 'PhabricatorAuthHighSecurityRequiredException' => 'Exception', 4564 + 'PhabricatorAuthInvite' => 'PhabricatorUserDAO', 4565 + 'PhabricatorAuthInviteAccountException' => 'PhabricatorAuthInviteDialogException', 4566 + 'PhabricatorAuthInviteController' => 'PhabricatorAuthController', 4567 + 'PhabricatorAuthInviteDialogException' => 'PhabricatorAuthInviteException', 4568 + 'PhabricatorAuthInviteEngine' => 'Phobject', 4569 + 'PhabricatorAuthInviteException' => 'Exception', 4570 + 'PhabricatorAuthInviteInvalidException' => 'PhabricatorAuthInviteDialogException', 4571 + 'PhabricatorAuthInviteLoginException' => 'PhabricatorAuthInviteDialogException', 4572 + 'PhabricatorAuthInviteRegisteredException' => 'PhabricatorAuthInviteException', 4573 + 'PhabricatorAuthInviteTestCase' => 'PhabricatorTestCase', 4574 + 'PhabricatorAuthInviteVerifyException' => 'PhabricatorAuthInviteDialogException', 4553 4575 'PhabricatorAuthLinkController' => 'PhabricatorAuthController', 4554 4576 'PhabricatorAuthListController' => 'PhabricatorAuthProviderConfigController', 4555 4577 'PhabricatorAuthLoginController' => 'PhabricatorAuthController',
+1
src/applications/auth/application/PhabricatorAuthApplication.php
··· 98 98 'login/(?P<pkey>[^/]+)/(?:(?P<extra>[^/]+)/)?' 99 99 => 'PhabricatorAuthLoginController', 100 100 '(?P<loggedout>loggedout)/' => 'PhabricatorAuthStartController', 101 + 'invite/(?P<code>[^/]+)/' => 'PhabricatorAuthInviteController', 101 102 'register/(?:(?P<akey>[^/]+)/)?' => 'PhabricatorAuthRegisterController', 102 103 'start/' => 'PhabricatorAuthStartController', 103 104 'validate/' => 'PhabricatorAuthValidateController',
+58
src/applications/auth/controller/PhabricatorAuthInviteController.php
··· 1 + <?php 2 + 3 + final class PhabricatorAuthInviteController 4 + extends PhabricatorAuthController { 5 + 6 + public function shouldRequireLogin() { 7 + return false; 8 + } 9 + 10 + public function handleRequest(AphrontRequest $request) { 11 + $viewer = $this->getViewer(); 12 + 13 + $engine = id(new PhabricatorAuthInviteEngine()) 14 + ->setViewer($viewer); 15 + 16 + if ($request->isFormPost()) { 17 + $engine->setUserHasConfirmedVerify(true); 18 + } 19 + 20 + try { 21 + $invite = $engine->processInviteCode($request->getURIData('code')); 22 + } catch (PhabricatorAuthInviteDialogException $ex) { 23 + $response = $this->newDialog() 24 + ->setTitle($ex->getTitle()) 25 + ->appendParagraph($ex->getBody()); 26 + 27 + $submit_text = $ex->getSubmitButtonText(); 28 + if ($submit_text) { 29 + $response->addSubmitButton($submit_text); 30 + } 31 + 32 + $submit_uri = $ex->getSubmitButtonURI(); 33 + if ($submit_uri) { 34 + $response->setSubmitURI($submit_uri); 35 + } 36 + 37 + $cancel_uri = $ex->getCancelButtonURI(); 38 + $cancel_text = $ex->getCancelButtonText(); 39 + if ($cancel_uri && $cancel_text) { 40 + $response->addCancelButton($cancel_uri, $cancel_text); 41 + } else if ($cancel_uri) { 42 + $response->addCancelButton($cancel_uri); 43 + } 44 + 45 + return $response; 46 + } catch (PhabricatorAuthInviteRegisteredException $ex) { 47 + // We're all set on processing this invite, just send the user home. 48 + return id(new AphrontRedirectResponse())->setURI('/'); 49 + } 50 + 51 + 52 + // TODO: This invite is good, but we need to drive the user through 53 + // registration. 54 + throw new Exception(pht('TODO: Build invite/registration workflow.')); 55 + } 56 + 57 + 58 + }
+255
src/applications/auth/engine/PhabricatorAuthInviteEngine.php
··· 1 + <?php 2 + 3 + 4 + /** 5 + * This class does an unusual amount of flow control via exceptions. The intent 6 + * is to make the workflows highly testable, because this code is high-stakes 7 + * and difficult to test. 8 + */ 9 + final class PhabricatorAuthInviteEngine extends Phobject { 10 + 11 + private $viewer; 12 + private $userHasConfirmedVerify; 13 + 14 + public function setViewer(PhabricatorUser $viewer) { 15 + $this->viewer = $viewer; 16 + return $this; 17 + } 18 + 19 + public function getViewer() { 20 + if (!$this->viewer) { 21 + throw new Exception(pht('Call setViewer() before getViewer()!')); 22 + } 23 + return $this->viewer; 24 + } 25 + 26 + public function setUserHasConfirmedVerify($confirmed) { 27 + $this->userHasConfirmedVerify = $confirmed; 28 + return $this; 29 + } 30 + 31 + private function shouldVerify() { 32 + return $this->userHasConfirmedVerify; 33 + } 34 + 35 + public function processInviteCode($code) { 36 + $viewer = $this->getViewer(); 37 + 38 + $invite = id(new PhabricatorAuthInvite())->loadOneWhere( 39 + 'verificationHash = %s', 40 + PhabricatorHash::digestForIndex($code)); 41 + if (!$invite) { 42 + throw id(new PhabricatorAuthInviteInvalidException( 43 + pht('Bad Invite Code'), 44 + pht( 45 + 'The invite code in the link you clicked is invalid. Check that '. 46 + 'you followed the link correctly.'))) 47 + ->setCancelButtonURI('/') 48 + ->setCancelButtonText(pht('Curses!')); 49 + } 50 + 51 + $accepted_phid = $invite->getAcceptedByPHID(); 52 + if ($accepted_phid) { 53 + if ($accepted_phid == $viewer->getPHID()) { 54 + throw id(new PhabricatorAuthInviteInvalidException( 55 + pht('Already Accepted'), 56 + pht( 57 + 'You have already accepted this invitation.'))) 58 + ->setCancelButtonURI('/') 59 + ->setCancelButtonText(pht('Awesome')); 60 + } else { 61 + throw id(new PhabricatorAuthInviteInvalidException( 62 + pht('Already Accepted'), 63 + pht( 64 + 'The invite code in the link you clicked has already '. 65 + 'been accepted.'))) 66 + ->setCancelButtonURI('/') 67 + ->setCancelButtonText(pht('Continue')); 68 + } 69 + } 70 + 71 + $email = id(new PhabricatorUserEmail())->loadOneWhere( 72 + 'address = %s', 73 + $invite->getEmailAddress()); 74 + 75 + if ($viewer->isLoggedIn()) { 76 + $this->handleLoggedInInvite($invite, $viewer, $email); 77 + } 78 + 79 + if ($email) { 80 + $other_user = $this->loadUserForEmail($email); 81 + 82 + if ($email->getIsVerified()) { 83 + throw id(new PhabricatorAuthInviteLoginException( 84 + pht('Already Registered'), 85 + pht( 86 + 'The email address you just clicked a link from is already '. 87 + 'verified and associated with a registered account (%s). Log '. 88 + 'in to continue.', 89 + phutil_tag('strong', array(), $other_user->getName())))) 90 + ->setCancelButtonText(pht('Log In')) 91 + ->setCancelButtonURI($this->getLoginURI()); 92 + } else if ($email->getIsPrimary()) { 93 + throw id(new PhabricatorAuthInviteLoginException( 94 + pht('Already Registered'), 95 + pht( 96 + 'The email address you just clicked a link from is already '. 97 + 'the primary email address for a registered account (%s). Log '. 98 + 'in to continue.', 99 + phutil_tag('strong', array(), $other_user->getName())))) 100 + ->setCancelButtonText(pht('Log In')) 101 + ->setCancelButtonURI($this->getLoginURI()); 102 + } else if (!$this->shouldVerify()) { 103 + throw id(new PhabricatorAuthInviteVerifyException( 104 + pht('Already Associated'), 105 + pht( 106 + 'The email address you just clicked a link from is already '. 107 + 'associated with a registered account (%s), but is not '. 108 + 'verified. Log in to that account to continue. If you can not '. 109 + 'log in, you can register a new account.', 110 + phutil_tag('strong', array(), $other_user->getName())))) 111 + ->setCancelButtonText(pht('Log In')) 112 + ->setCancelButtonURI($this->getLoginURI()) 113 + ->setSubmitButtonText(pht('Register New Account')); 114 + } else { 115 + // NOTE: The address is not verified and not a primary address, so 116 + // we will eventually steal it if the user completes registration. 117 + } 118 + } 119 + 120 + // The invite and email address are OK, but the user needs to register. 121 + return $invite; 122 + } 123 + 124 + private function handleLoggedInInvite( 125 + PhabricatorAuthInvite $invite, 126 + PhabricatorUser $viewer, 127 + PhabricatorUserEmail $email = null) { 128 + 129 + if ($email && ($email->getUserPHID() !== $viewer->getPHID())) { 130 + $other_user = $this->loadUserForEmail($email); 131 + if ($email->getIsVerified()) { 132 + throw id(new PhabricatorAuthInviteAccountException( 133 + pht('Wrong Account'), 134 + pht( 135 + 'You are logged in as %s, but the email address you just '. 136 + 'clicked a link from is already verified and associated '. 137 + 'with another account (%s). Switch accounts, then try again.', 138 + phutil_tag('strong', array(), $viewer->getUsername()), 139 + phutil_tag('strong', array(), $other_user->getName())))) 140 + ->setSubmitButtonText(pht('Log Out')) 141 + ->setSubmitButtonURI($this->getLogoutURI()) 142 + ->setCancelButtonURI('/'); 143 + } else if ($email->getIsPrimary()) { 144 + // NOTE: We never steal primary addresses from other accounts, even 145 + // if they are unverified. This would leave the other account with 146 + // no address. Users can use password recovery to access the other 147 + // account if they really control the address. 148 + throw id(new PhabricatorAuthInviteAccountException( 149 + pht('Wrong Acount'), 150 + pht( 151 + 'You are logged in as %s, but the email address you just '. 152 + 'clicked a link from is already the primary email address '. 153 + 'for another account (%s). Switch accounts, then try again.', 154 + phutil_tag('strong', array(), $viewer->getUsername()), 155 + phutil_tag('strong', array(), $other_user->getName())))) 156 + ->setSubmitButtonText(pht('Log Out')) 157 + ->setSubmitButtonURI($this->getLogoutURI()) 158 + ->setCancelButtonURI('/'); 159 + } else if (!$this->shouldVerify()) { 160 + throw id(new PhabricatorAuthInviteVerifyException( 161 + pht('Verify Email'), 162 + pht( 163 + 'You are logged in as %s, but the email address (%s) you just '. 164 + 'clicked a link from is already associated with another '. 165 + 'account (%s). You can log out to switch accounts, or verify '. 166 + 'the address and attach it to your current account. Attach '. 167 + 'email address %s to user account %s?', 168 + phutil_tag('strong', array(), $viewer->getUsername()), 169 + phutil_tag('strong', array(), $invite->getEmailAddress()), 170 + phutil_tag('strong', array(), $other_user->getName()), 171 + phutil_tag('strong', array(), $invite->getEmailAddress()), 172 + phutil_tag('strong', array(), $viewer->getUsername())))) 173 + ->setSubmitButtonText( 174 + pht( 175 + 'Verify %s', 176 + $invite->getEmailAddress())) 177 + ->setCancelButtonText(pht('Log Out')) 178 + ->setCancelButtonURI($this->getLogoutURI()); 179 + } 180 + } 181 + 182 + if (!$email) { 183 + $email = id(new PhabricatorUserEmail()) 184 + ->setAddress($invite->getEmailAddress()) 185 + ->setIsVerified(0) 186 + ->setIsPrimary(0); 187 + } 188 + 189 + if (!$email->getIsVerified()) { 190 + // We're doing this check here so that we can verify the address if 191 + // it's already attached to the viewer's account, just not verified. 192 + if (!$this->shouldVerify()) { 193 + throw id(new PhabricatorAuthInviteVerifyException( 194 + pht('Verify Email'), 195 + pht( 196 + 'Verify this email address (%s) and attach it to your '. 197 + 'account (%s)?', 198 + phutil_tag('strong', array(), $invite->getEmailAddress()), 199 + phutil_tag('strong', array(), $viewer->getUsername())))) 200 + ->setSubmitButtonText( 201 + pht( 202 + 'Verify %s', 203 + $invite->getEmailAddress())) 204 + ->setCancelButtonURI('/'); 205 + } 206 + 207 + $editor = id(new PhabricatorUserEditor()) 208 + ->setActor($viewer); 209 + 210 + // If this is a new email, add it to the user's account. 211 + if (!$email->getUserPHID()) { 212 + $editor->addEmail($viewer, $email); 213 + } 214 + 215 + // If another user added this email (but has not verified it), 216 + // take it from them. 217 + $editor->reassignEmail($viewer, $email); 218 + 219 + $editor->verifyEmail($viewer, $email); 220 + } 221 + 222 + $invite->setAcceptedByPHID($viewer->getPHID()); 223 + $invite->save(); 224 + 225 + // If we make it here, the user was already logged in with the email 226 + // address attached to their account and verified, or we attached it to 227 + // their account (if it was not already attached) and verified it. 228 + throw new PhabricatorAuthInviteRegisteredException(); 229 + } 230 + 231 + private function loadUserForEmail(PhabricatorUserEmail $email) { 232 + $user = id(new PhabricatorHandleQuery()) 233 + ->setViewer(PhabricatorUser::getOmnipotentUser()) 234 + ->withPHIDs(array($email->getUserPHID())) 235 + ->executeOne(); 236 + if (!$user) { 237 + throw new Exception( 238 + pht( 239 + 'Email record ("%s") has bad associated user PHID ("%s").', 240 + $email->getAddress(), 241 + $email->getUserPHID())); 242 + } 243 + 244 + return $user; 245 + } 246 + 247 + private function getLoginURI() { 248 + return '/auth/start/'; 249 + } 250 + 251 + private function getLogoutURI() { 252 + return '/auth/logout/'; 253 + } 254 + 255 + }
+7
src/applications/auth/exception/PhabricatorAuthInviteAccountException.php
··· 1 + <?php 2 + 3 + /** 4 + * Exception raised when the user is logged in to the wrong account. 5 + */ 6 + final class PhabricatorAuthInviteAccountException 7 + extends PhabricatorAuthInviteDialogException {}
+63
src/applications/auth/exception/PhabricatorAuthInviteDialogException.php
··· 1 + <?php 2 + 3 + abstract class PhabricatorAuthInviteDialogException 4 + extends PhabricatorAuthInviteException { 5 + 6 + private $title; 7 + private $body; 8 + private $submitButtonText; 9 + private $submitButtonURI; 10 + private $cancelButtonText; 11 + private $cancelButtonURI; 12 + 13 + public function __construct($title, $body) { 14 + $this->title = $title; 15 + $this->body = $body; 16 + parent::__construct(pht('%s: %s', $title, $body)); 17 + } 18 + 19 + public function getTitle() { 20 + return $this->title; 21 + } 22 + 23 + public function getBody() { 24 + return $this->body; 25 + } 26 + 27 + public function setSubmitButtonText($submit_button_text) { 28 + $this->submitButtonText = $submit_button_text; 29 + return $this; 30 + } 31 + 32 + public function getSubmitButtonText() { 33 + return $this->submitButtonText; 34 + } 35 + 36 + public function setSubmitButtonURI($submit_button_uri) { 37 + $this->submitButtonURI = $submit_button_uri; 38 + return $this; 39 + } 40 + 41 + public function getSubmitButtonURI() { 42 + return $this->submitButtonURI; 43 + } 44 + 45 + public function setCancelButtonText($cancel_button_text) { 46 + $this->cancelButtonText = $cancel_button_text; 47 + return $this; 48 + } 49 + 50 + public function getCancelButtonText() { 51 + return $this->cancelButtonText; 52 + } 53 + 54 + public function setCancelButtonURI($cancel_button_uri) { 55 + $this->cancelButtonURI = $cancel_button_uri; 56 + return $this; 57 + } 58 + 59 + public function getCancelButtonURI() { 60 + return $this->cancelButtonURI; 61 + } 62 + 63 + }
+3
src/applications/auth/exception/PhabricatorAuthInviteException.php
··· 1 + <?php 2 + 3 + abstract class PhabricatorAuthInviteException extends Exception {}
+7
src/applications/auth/exception/PhabricatorAuthInviteInvalidException.php
··· 1 + <?php 2 + 3 + /** 4 + * Exception raised when an invite code is invalid. 5 + */ 6 + final class PhabricatorAuthInviteInvalidException 7 + extends PhabricatorAuthInviteDialogException {}
+9
src/applications/auth/exception/PhabricatorAuthInviteLoginException.php
··· 1 + <?php 2 + 3 + /** 4 + * Exception raised when the user must log in to continue with the invite 5 + * workflow (for example, the because the email address is already bound to an 6 + * account). 7 + */ 8 + final class PhabricatorAuthInviteLoginException 9 + extends PhabricatorAuthInviteDialogException {}
+8
src/applications/auth/exception/PhabricatorAuthInviteRegisteredException.php
··· 1 + <?php 2 + 3 + /** 4 + * Exception raised when the user is already registered and the invite is a 5 + * no-op. 6 + */ 7 + final class PhabricatorAuthInviteRegisteredException 8 + extends PhabricatorAuthInviteException {}
+7
src/applications/auth/exception/PhabricatorAuthInviteVerifyException.php
··· 1 + <?php 2 + 3 + /** 4 + * Exception raised when the user needs to verify an action. 5 + */ 6 + final class PhabricatorAuthInviteVerifyException 7 + extends PhabricatorAuthInviteDialogException {}
+374
src/applications/auth/factor/__tests__/PhabricatorAuthInviteTestCase.php
··· 1 + <?php 2 + 3 + final class PhabricatorAuthInviteTestCase extends PhabricatorTestCase { 4 + 5 + 6 + protected function getPhabricatorTestCaseConfiguration() { 7 + return array( 8 + self::PHABRICATOR_TESTCONFIG_BUILD_STORAGE_FIXTURES => true, 9 + ); 10 + } 11 + 12 + 13 + /** 14 + * Test that invalid invites can not be accepted. 15 + */ 16 + public function testInvalidInvite() { 17 + $viewer = $this->generateUser(); 18 + $engine = $this->generateEngine($viewer); 19 + 20 + $caught = null; 21 + try { 22 + $engine->processInviteCode('asdf1234'); 23 + } catch (PhabricatorAuthInviteInvalidException $ex) { 24 + $caught = $ex; 25 + } 26 + 27 + $this->assertTrue($caught instanceof Exception); 28 + } 29 + 30 + 31 + /** 32 + * Test that invites can be accepted exactly once. 33 + */ 34 + public function testDuplicateInvite() { 35 + $author = $this->generateUser(); 36 + $viewer = $this->generateUser(); 37 + $address = Filesystem::readRandomCharacters(16).'@example.com'; 38 + 39 + $invite = id(new PhabricatorAuthInvite()) 40 + ->setAuthorPHID($author->getPHID()) 41 + ->setEmailAddress($address) 42 + ->save(); 43 + 44 + $engine = $this->generateEngine($viewer); 45 + $engine->setUserHasConfirmedVerify(true); 46 + 47 + $caught = null; 48 + try { 49 + $result = $engine->processInviteCode($invite->getVerificationCode()); 50 + } catch (Exception $ex) { 51 + $caught = $ex; 52 + } 53 + 54 + // This first time should accept the invite and verify the addresss. 55 + $this->assertTrue( 56 + ($caught instanceof PhabricatorAuthInviteRegisteredException)); 57 + 58 + try { 59 + $result = $engine->processInviteCode($invite->getVerificationCode()); 60 + } catch (Exception $ex) { 61 + $caught = $ex; 62 + } 63 + 64 + // The second time through, the invite should not be acceptable. 65 + $this->assertTrue( 66 + ($caught instanceof PhabricatorAuthInviteInvalidException)); 67 + } 68 + 69 + 70 + /** 71 + * Test easy invite cases, where the email is not anywhere in the system. 72 + */ 73 + public function testInviteWithNewEmail() { 74 + $expect_map = array( 75 + 'out' => array( 76 + null, 77 + null, 78 + ), 79 + 'in' => array( 80 + 'PhabricatorAuthInviteVerifyException', 81 + 'PhabricatorAuthInviteRegisteredException', 82 + ), 83 + ); 84 + 85 + $author = $this->generateUser(); 86 + $logged_in = $this->generateUser(); 87 + $logged_out = new PhabricatorUser(); 88 + 89 + foreach (array('out', 'in') as $is_logged_in) { 90 + foreach (array(0, 1) as $should_verify) { 91 + $address = Filesystem::readRandomCharacters(16).'@example.com'; 92 + 93 + $invite = id(new PhabricatorAuthInvite()) 94 + ->setAuthorPHID($author->getPHID()) 95 + ->setEmailAddress($address) 96 + ->save(); 97 + 98 + switch ($is_logged_in) { 99 + case 'out': 100 + $viewer = $logged_out; 101 + break; 102 + case 'in': 103 + $viewer = $logged_in; 104 + break; 105 + } 106 + 107 + $engine = $this->generateEngine($viewer); 108 + $engine->setUserHasConfirmedVerify($should_verify); 109 + 110 + $caught = null; 111 + try { 112 + $result = $engine->processInviteCode($invite->getVerificationCode()); 113 + } catch (Exception $ex) { 114 + $caught = $ex; 115 + } 116 + 117 + $expect = $expect_map[$is_logged_in]; 118 + $expect = $expect[$should_verify]; 119 + 120 + $this->assertEqual( 121 + ($expect !== null), 122 + ($caught instanceof Exception), 123 + pht( 124 + 'user=%s, should_verify=%s', 125 + $is_logged_in, 126 + $should_verify)); 127 + 128 + if ($expect === null) { 129 + $this->assertEqual($invite->getPHID(), $result->getPHID()); 130 + } else { 131 + $this->assertEqual( 132 + $expect, 133 + get_class($caught), 134 + pht('Actual exception: %s', $caught->getMessage())); 135 + } 136 + } 137 + } 138 + } 139 + 140 + 141 + /** 142 + * Test hard invite cases, where the email is already known and attached 143 + * to some user account. 144 + */ 145 + public function testInviteWithKnownEmail() { 146 + 147 + // This tests all permutations of: 148 + // 149 + // - Is the user logged out, logged in with a different account, or 150 + // logged in with the correct account? 151 + // - Is the address verified, or unverified? 152 + // - Is the address primary, or nonprimary? 153 + // - Has the user confirmed that they want to verify the address? 154 + 155 + $expect_map = array( 156 + 'out' => array( 157 + array( 158 + array( 159 + // For example, this corresponds to a logged out user trying to 160 + // follow an invite with an unverified, nonprimary address, and 161 + // they haven't clicked the "Verify" button yet. We ask them to 162 + // verify that they want to register a new account. 163 + 'PhabricatorAuthInviteVerifyException', 164 + 165 + // In this case, they have clicked the verify button. The engine 166 + // continues the workflow. 167 + null, 168 + ), 169 + array( 170 + // And so on. All of the rest of these cases cover the other 171 + // permutations. 172 + 'PhabricatorAuthInviteLoginException', 173 + 'PhabricatorAuthInviteLoginException', 174 + ), 175 + ), 176 + array( 177 + array( 178 + 'PhabricatorAuthInviteLoginException', 179 + 'PhabricatorAuthInviteLoginException', 180 + ), 181 + array( 182 + 'PhabricatorAuthInviteLoginException', 183 + 'PhabricatorAuthInviteLoginException', 184 + ), 185 + ), 186 + ), 187 + 'in' => array( 188 + array( 189 + array( 190 + 'PhabricatorAuthInviteVerifyException', 191 + array(true, 'PhabricatorAuthInviteRegisteredException'), 192 + ), 193 + array( 194 + 'PhabricatorAuthInviteAccountException', 195 + 'PhabricatorAuthInviteAccountException', 196 + ), 197 + ), 198 + array( 199 + array( 200 + 'PhabricatorAuthInviteAccountException', 201 + 'PhabricatorAuthInviteAccountException', 202 + ), 203 + array( 204 + 'PhabricatorAuthInviteAccountException', 205 + 'PhabricatorAuthInviteAccountException', 206 + ), 207 + ), 208 + ), 209 + 'same' => array( 210 + array( 211 + array( 212 + 'PhabricatorAuthInviteVerifyException', 213 + array(true, 'PhabricatorAuthInviteRegisteredException'), 214 + ), 215 + array( 216 + 'PhabricatorAuthInviteVerifyException', 217 + array(true, 'PhabricatorAuthInviteRegisteredException'), 218 + ), 219 + ), 220 + array( 221 + array( 222 + 'PhabricatorAuthInviteRegisteredException', 223 + 'PhabricatorAuthInviteRegisteredException', 224 + ), 225 + array( 226 + 'PhabricatorAuthInviteRegisteredException', 227 + 'PhabricatorAuthInviteRegisteredException', 228 + ), 229 + ), 230 + ), 231 + ); 232 + 233 + $author = $this->generateUser(); 234 + $logged_in = $this->generateUser(); 235 + $logged_out = new PhabricatorUser(); 236 + 237 + foreach (array('out', 'in', 'same') as $is_logged_in) { 238 + foreach (array(0, 1) as $is_verified) { 239 + foreach (array(0, 1) as $is_primary) { 240 + foreach (array(0, 1) as $should_verify) { 241 + $other = $this->generateUser(); 242 + 243 + switch ($is_logged_in) { 244 + case 'out': 245 + $viewer = $logged_out; 246 + break; 247 + case 'in'; 248 + $viewer = $logged_in; 249 + break; 250 + case 'same': 251 + $viewer = clone $other; 252 + break; 253 + } 254 + 255 + $email = $this->generateEmail($other, $is_verified, $is_primary); 256 + 257 + $invite = id(new PhabricatorAuthInvite()) 258 + ->setAuthorPHID($author->getPHID()) 259 + ->setEmailAddress($email->getAddress()) 260 + ->save(); 261 + $code = $invite->getVerificationCode(); 262 + 263 + $engine = $this->generateEngine($viewer); 264 + $engine->setUserHasConfirmedVerify($should_verify); 265 + 266 + $caught = null; 267 + try { 268 + $result = $engine->processInviteCode($code); 269 + } catch (Exception $ex) { 270 + $caught = $ex; 271 + } 272 + 273 + $expect = $expect_map[$is_logged_in]; 274 + $expect = $expect[$is_verified]; 275 + $expect = $expect[$is_primary]; 276 + $expect = $expect[$should_verify]; 277 + 278 + if (is_array($expect)) { 279 + list($expect_reassign, $expect_exception) = $expect; 280 + } else { 281 + $expect_reassign = false; 282 + $expect_exception = $expect; 283 + } 284 + 285 + $case_info = pht( 286 + 'user=%s, verified=%s, primary=%s, should_verify=%s', 287 + $is_logged_in, 288 + $is_verified, 289 + $is_primary, 290 + $should_verify); 291 + 292 + $this->assertEqual( 293 + ($expect_exception !== null), 294 + ($caught instanceof Exception), 295 + $case_info); 296 + 297 + if ($expect_exception === null) { 298 + $this->assertEqual($invite->getPHID(), $result->getPHID()); 299 + } else { 300 + $this->assertEqual( 301 + $expect_exception, 302 + get_class($caught), 303 + pht('%s, exception=%s', $case_info, $caught->getMessage())); 304 + } 305 + 306 + if ($expect_reassign) { 307 + $email->reload(); 308 + 309 + $this->assertEqual( 310 + $viewer->getPHID(), 311 + $email->getUserPHID(), 312 + pht( 313 + 'Expected email address reassignment (%s).', 314 + $case_info)); 315 + } 316 + 317 + switch ($expect_exception) { 318 + case 'PhabricatorAuthInviteRegisteredException': 319 + $invite->reload(); 320 + 321 + $this->assertEqual( 322 + $viewer->getPHID(), 323 + $invite->getAcceptedByPHID(), 324 + pht( 325 + 'Expected invite accepted (%s).', 326 + $case_info)); 327 + break; 328 + } 329 + 330 + } 331 + } 332 + } 333 + } 334 + } 335 + 336 + private function generateUser() { 337 + return $this->generateNewTestUser(); 338 + } 339 + 340 + private function generateEngine(PhabricatorUser $viewer) { 341 + return id(new PhabricatorAuthInviteEngine()) 342 + ->setViewer($viewer); 343 + } 344 + 345 + private function generateEmail( 346 + PhabricatorUser $user, 347 + $is_verified, 348 + $is_primary) { 349 + 350 + // NOTE: We're being a little bit sneaky here because UserEditor will not 351 + // let you make an unverified address a primary account address, and 352 + // the test user will already have a verified primary address. 353 + 354 + $email = id(new PhabricatorUserEmail()) 355 + ->setAddress(Filesystem::readRandomCharacters(16).'@example.com') 356 + ->setIsVerified((int)($is_verified || $is_primary)) 357 + ->setIsPrimary(0); 358 + 359 + $editor = id(new PhabricatorUserEditor()) 360 + ->setActor($user); 361 + 362 + $editor->addEmail($user, $email); 363 + 364 + if ($is_primary) { 365 + $editor->changePrimaryEmail($user, $email); 366 + } 367 + 368 + $email->setIsVerified((int)$is_verified); 369 + $email->save(); 370 + 371 + return $email; 372 + } 373 + 374 + }
+55
src/applications/auth/storage/PhabricatorAuthInvite.php
··· 1 + <?php 2 + 3 + final class PhabricatorAuthInvite 4 + extends PhabricatorUserDAO { 5 + 6 + protected $authorPHID; 7 + protected $emailAddress; 8 + protected $verificationHash; 9 + protected $acceptedByPHID; 10 + 11 + private $verificationCode; 12 + 13 + protected function getConfiguration() { 14 + return array( 15 + self::CONFIG_COLUMN_SCHEMA => array( 16 + 'emailAddress' => 'sort128', 17 + 'verificationHash' => 'bytes12', 18 + 'acceptedByPHID' => 'phid?', 19 + ), 20 + self::CONFIG_KEY_SCHEMA => array( 21 + 'key_address' => array( 22 + 'columns' => array('emailAddress'), 23 + 'unique' => true, 24 + ), 25 + 'key_code' => array( 26 + 'columns' => array('verificationHash'), 27 + 'unique' => true, 28 + ), 29 + ), 30 + ) + parent::getConfiguration(); 31 + } 32 + 33 + public function getVerificationCode() { 34 + if (!$this->getVerificationHash()) { 35 + if ($this->verificationHash) { 36 + throw new Exception( 37 + pht( 38 + 'Verification code can not be regenerated after an invite is '. 39 + 'created.')); 40 + } 41 + $this->verificationCode = Filesystem::readRandomCharacters(16); 42 + } 43 + return $this->verificationCode; 44 + } 45 + 46 + public function save() { 47 + if (!$this->getVerificationHash()) { 48 + $hash = PhabricatorHash::digestForIndex($this->getVerificationCode()); 49 + $this->setVerificationHash($hash); 50 + } 51 + 52 + return parent::save(); 53 + } 54 + 55 + }
+51
src/applications/people/editor/PhabricatorUserEditor.php
··· 554 554 555 555 $user->endWriteLocking(); 556 556 $user->saveTransaction(); 557 + } 557 558 559 + 560 + /** 561 + * Reassign an unverified email address. 562 + */ 563 + public function reassignEmail( 564 + PhabricatorUser $user, 565 + PhabricatorUserEmail $email) { 566 + $actor = $this->requireActor(); 567 + 568 + if (!$user->getID()) { 569 + throw new Exception(pht('User has not been created yet!')); 570 + } 571 + 572 + if (!$email->getID()) { 573 + throw new Exception(pht('Email has not been created yet!')); 574 + } 575 + 576 + $user->openTransaction(); 577 + $user->beginWriteLocking(); 578 + 579 + $user->reload(); 580 + $email->reload(); 581 + 582 + $old_user = $email->getUserPHID(); 583 + 584 + if ($old_user != $user->getPHID()) { 585 + if ($email->getIsVerified()) { 586 + throw new Exception( 587 + pht( 588 + 'Verified email addresses can not be reassigned.')); 589 + } 590 + if ($email->getIsPrimary()) { 591 + throw new Exception( 592 + pht( 593 + 'Primary email addresses can not be reassigned.')); 594 + } 595 + 596 + $email->setUserPHID($user->getPHID()); 597 + $email->save(); 598 + 599 + $log = PhabricatorUserLog::initializeNewLog( 600 + $actor, 601 + $user->getPHID(), 602 + PhabricatorUserLog::ACTION_EMAIL_REASSIGN); 603 + $log->setNewValue($email->getAddress()); 604 + $log->save(); 605 + } 606 + 607 + $user->endWriteLocking(); 608 + $user->saveTransaction(); 558 609 } 559 610 560 611
+2
src/applications/people/storage/PhabricatorUserLog.php
··· 26 26 const ACTION_EMAIL_REMOVE = 'email-remove'; 27 27 const ACTION_EMAIL_ADD = 'email-add'; 28 28 const ACTION_EMAIL_VERIFY = 'email-verify'; 29 + const ACTION_EMAIL_REASSIGN = 'email-reassign'; 29 30 30 31 const ACTION_CHANGE_PASSWORD = 'change-password'; 31 32 const ACTION_CHANGE_USERNAME = 'change-username'; ··· 69 70 self::ACTION_EMAIL_ADD => pht('Email: Add Address'), 70 71 self::ACTION_EMAIL_REMOVE => pht('Email: Remove Address'), 71 72 self::ACTION_EMAIL_VERIFY => pht('Email: Verify'), 73 + self::ACTION_EMAIL_REASSIGN => pht('Email: Reassign'), 72 74 self::ACTION_CHANGE_PASSWORD => pht('Change Password'), 73 75 self::ACTION_CHANGE_USERNAME => pht('Change Username'), 74 76 self::ACTION_ENTER_HISEC => pht('Hisec: Enter'),