1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-12-29 00:40:57 +01:00

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
This commit is contained in:
epriestley 2015-02-09 16:12:36 -08:00
parent ac504f232f
commit 2a0af8e299
16 changed files with 933 additions and 0 deletions

View file

@ -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};

View file

@ -1344,6 +1344,17 @@ phutil_register_library_map(array(
'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 @@ phutil_register_library_map(array(
'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',

View file

@ -98,6 +98,7 @@ final class PhabricatorAuthApplication extends PhabricatorApplication {
'login/(?P<pkey>[^/]+)/(?:(?P<extra>[^/]+)/)?'
=> 'PhabricatorAuthLoginController',
'(?P<loggedout>loggedout)/' => 'PhabricatorAuthStartController',
'invite/(?P<code>[^/]+)/' => 'PhabricatorAuthInviteController',
'register/(?:(?P<akey>[^/]+)/)?' => 'PhabricatorAuthRegisterController',
'start/' => 'PhabricatorAuthStartController',
'validate/' => 'PhabricatorAuthValidateController',

View file

@ -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.'));
}
}

View file

@ -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/';
}
}

View file

@ -0,0 +1,7 @@
<?php
/**
* Exception raised when the user is logged in to the wrong account.
*/
final class PhabricatorAuthInviteAccountException
extends PhabricatorAuthInviteDialogException {}

View file

@ -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;
}
}

View file

@ -0,0 +1,3 @@
<?php
abstract class PhabricatorAuthInviteException extends Exception {}

View file

@ -0,0 +1,7 @@
<?php
/**
* Exception raised when an invite code is invalid.
*/
final class PhabricatorAuthInviteInvalidException
extends PhabricatorAuthInviteDialogException {}

View file

@ -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 {}

View file

@ -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 {}

View file

@ -0,0 +1,7 @@
<?php
/**
* Exception raised when the user needs to verify an action.
*/
final class PhabricatorAuthInviteVerifyException
extends PhabricatorAuthInviteDialogException {}

View file

@ -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;
}
}

View file

@ -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();
}
}

View file

@ -554,7 +554,58 @@ final class PhabricatorUserEditor extends PhabricatorEditor {
$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();
}

View file

@ -26,6 +26,7 @@ final class PhabricatorUserLog extends PhabricatorUserDAO
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 @@ final class PhabricatorUserLog extends PhabricatorUserDAO
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'),