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:
parent
ac504f232f
commit
2a0af8e299
16 changed files with 933 additions and 0 deletions
11
resources/sql/autopatches/20150209.invite.sql
Normal file
11
resources/sql/autopatches/20150209.invite.sql
Normal 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};
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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.'));
|
||||
}
|
||||
|
||||
|
||||
}
|
255
src/applications/auth/engine/PhabricatorAuthInviteEngine.php
Normal file
255
src/applications/auth/engine/PhabricatorAuthInviteEngine.php
Normal 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/';
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Exception raised when the user is logged in to the wrong account.
|
||||
*/
|
||||
final class PhabricatorAuthInviteAccountException
|
||||
extends PhabricatorAuthInviteDialogException {}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
<?php
|
||||
|
||||
abstract class PhabricatorAuthInviteException extends Exception {}
|
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Exception raised when an invite code is invalid.
|
||||
*/
|
||||
final class PhabricatorAuthInviteInvalidException
|
||||
extends PhabricatorAuthInviteDialogException {}
|
|
@ -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 {}
|
|
@ -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 {}
|
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Exception raised when the user needs to verify an action.
|
||||
*/
|
||||
final class PhabricatorAuthInviteVerifyException
|
||||
extends PhabricatorAuthInviteDialogException {}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
55
src/applications/auth/storage/PhabricatorAuthInvite.php
Normal file
55
src/applications/auth/storage/PhabricatorAuthInvite.php
Normal 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();
|
||||
}
|
||||
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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'),
|
||||
|
|
Loading…
Reference in a new issue