diff --git a/src/applications/auth/controller/PhabricatorAuthUnlinkController.php b/src/applications/auth/controller/PhabricatorAuthUnlinkController.php index 43e7b1b362..ede9d9d94a 100644 --- a/src/applications/auth/controller/PhabricatorAuthUnlinkController.php +++ b/src/applications/auth/controller/PhabricatorAuthUnlinkController.php @@ -1,141 +1,141 @@ getViewer(); $id = $request->getURIData('id'); $account = id(new PhabricatorExternalAccountQuery()) ->setViewer($viewer) ->withIDs(array($id)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$account) { return new Aphront404Response(); } $done_uri = '/settings/panel/external/'; $config = $account->getProviderConfig(); $provider = $config->getProvider(); if (!$provider->shouldAllowAccountUnlink()) { return $this->renderNotUnlinkableErrorDialog($provider, $done_uri); } $confirmations = $request->getStrList('confirmations'); $confirmations = array_fuse($confirmations); if (!$request->isFormOrHisecPost() || !isset($confirmations['unlink'])) { return $this->renderConfirmDialog($confirmations, $config, $done_uri); } // Check that this account isn't the only account which can be used to // login. We warn you when you remove your only login account. if ($account->isUsableForLogin()) { $other_accounts = id(new PhabricatorExternalAccountQuery()) ->setViewer($viewer) ->withUserPHIDs(array($viewer->getPHID())) ->execute(); $valid_accounts = 0; foreach ($other_accounts as $other_account) { if ($other_account->isUsableForLogin()) { $valid_accounts++; } } if ($valid_accounts < 2) { if (!isset($confirmations['only'])) { return $this->renderOnlyUsableAccountConfirmDialog( $confirmations, $done_uri); } } } $workflow_key = sprintf( 'account.unlink(%s)', $account->getPHID()); $hisec_token = id(new PhabricatorAuthSessionEngine()) ->setWorkflowKey($workflow_key) ->requireHighSecurityToken($viewer, $request, $done_uri); - $account->delete(); + $account->unlinkAccount(); id(new PhabricatorAuthSessionEngine())->terminateLoginSessions( $viewer, new PhutilOpaqueEnvelope( $request->getCookie(PhabricatorCookies::COOKIE_SESSION))); return id(new AphrontRedirectResponse())->setURI($done_uri); } private function renderNotUnlinkableErrorDialog( PhabricatorAuthProvider $provider, $done_uri) { return $this->newDialog() ->setTitle(pht('Permanent Account Link')) ->appendChild( pht( 'You can not unlink this account because the administrator has '. 'configured Phabricator to make links to "%s" accounts permanent.', $provider->getProviderName())) ->addCancelButton($done_uri); } private function renderOnlyUsableAccountConfirmDialog( array $confirmations, $done_uri) { $confirmations[] = 'only'; return $this->newDialog() ->setTitle(pht('Unlink Your Only Login Account?')) ->addHiddenInput('confirmations', implode(',', $confirmations)) ->appendParagraph( pht( 'This is the only external login account linked to your Phabicator '. 'account. If you remove it, you may no longer be able to log in.')) ->appendParagraph( pht( 'If you lose access to your account, you can recover access by '. 'sending yourself an email login link from the login screen.')) ->addCancelButton($done_uri) ->addSubmitButton(pht('Unlink External Account')); } private function renderConfirmDialog( array $confirmations, PhabricatorAuthProviderConfig $config, $done_uri) { $confirmations[] = 'unlink'; $provider = $config->getProvider(); $title = pht('Unlink "%s" Account?', $provider->getProviderName()); $body = pht( 'You will no longer be able to use your %s account to '. 'log in to Phabricator.', $provider->getProviderName()); return $this->newDialog() ->setTitle($title) ->addHiddenInput('confirmations', implode(',', $confirmations)) ->appendParagraph($body) ->appendParagraph( pht( 'Note: Unlinking an authentication provider will terminate any '. 'other active login sessions.')) ->addSubmitButton(pht('Unlink Account')) ->addCancelButton($done_uri); } } diff --git a/src/applications/auth/provider/PhabricatorAuthProvider.php b/src/applications/auth/provider/PhabricatorAuthProvider.php index 01ed5ca8d0..3ea05e1271 100644 --- a/src/applications/auth/provider/PhabricatorAuthProvider.php +++ b/src/applications/auth/provider/PhabricatorAuthProvider.php @@ -1,577 +1,590 @@ providerConfig = $config; return $this; } public function hasProviderConfig() { return (bool)$this->providerConfig; } public function getProviderConfig() { if ($this->providerConfig === null) { throw new PhutilInvalidStateException('attachProviderConfig'); } return $this->providerConfig; } public function getConfigurationHelp() { return null; } public function getDefaultProviderConfig() { return id(new PhabricatorAuthProviderConfig()) ->setProviderClass(get_class($this)) ->setIsEnabled(1) ->setShouldAllowLogin(1) ->setShouldAllowRegistration(1) ->setShouldAllowLink(1) ->setShouldAllowUnlink(1); } public function getNameForCreate() { return $this->getProviderName(); } public function getDescriptionForCreate() { return null; } public function getProviderKey() { return $this->getAdapter()->getAdapterKey(); } public function getProviderType() { return $this->getAdapter()->getAdapterType(); } public function getProviderDomain() { return $this->getAdapter()->getAdapterDomain(); } public static function getAllBaseProviders() { return id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->execute(); } public static function getAllProviders() { static $providers; if ($providers === null) { $objects = self::getAllBaseProviders(); $configs = id(new PhabricatorAuthProviderConfigQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->execute(); $providers = array(); foreach ($configs as $config) { if (!isset($objects[$config->getProviderClass()])) { // This configuration is for a provider which is not installed. continue; } $object = clone $objects[$config->getProviderClass()]; $object->attachProviderConfig($config); $key = $object->getProviderKey(); if (isset($providers[$key])) { throw new Exception( pht( "Two authentication providers use the same provider key ". "('%s'). Each provider must be identified by a unique key.", $key)); } $providers[$key] = $object; } } return $providers; } public static function getAllEnabledProviders() { $providers = self::getAllProviders(); foreach ($providers as $key => $provider) { if (!$provider->isEnabled()) { unset($providers[$key]); } } return $providers; } public static function getEnabledProviderByKey($provider_key) { return idx(self::getAllEnabledProviders(), $provider_key); } abstract public function getProviderName(); abstract public function getAdapter(); public function isEnabled() { return $this->getProviderConfig()->getIsEnabled(); } public function shouldAllowLogin() { return $this->getProviderConfig()->getShouldAllowLogin(); } public function shouldAllowRegistration() { if (!$this->shouldAllowLogin()) { return false; } return $this->getProviderConfig()->getShouldAllowRegistration(); } public function shouldAllowAccountLink() { return $this->getProviderConfig()->getShouldAllowLink(); } public function shouldAllowAccountUnlink() { return $this->getProviderConfig()->getShouldAllowUnlink(); } public function shouldTrustEmails() { return $this->shouldAllowEmailTrustConfiguration() && $this->getProviderConfig()->getShouldTrustEmails(); } /** * Should we allow the adapter to be marked as "trusted". This is true for * all adapters except those that allow the user to type in emails (see * @{class:PhabricatorPasswordAuthProvider}). */ public function shouldAllowEmailTrustConfiguration() { return true; } public function buildLoginForm(PhabricatorAuthStartController $controller) { return $this->renderLoginForm($controller->getRequest(), $mode = 'start'); } public function buildInviteForm(PhabricatorAuthStartController $controller) { return $this->renderLoginForm($controller->getRequest(), $mode = 'invite'); } abstract public function processLoginRequest( PhabricatorAuthLoginController $controller); public function buildLinkForm($controller) { return $this->renderLoginForm($controller->getRequest(), $mode = 'link'); } public function shouldAllowAccountRefresh() { return true; } public function buildRefreshForm( PhabricatorAuthLinkController $controller) { return $this->renderLoginForm($controller->getRequest(), $mode = 'refresh'); } protected function renderLoginForm(AphrontRequest $request, $mode) { throw new PhutilMethodNotImplementedException(); } public function createProviders() { return array($this); } protected function willSaveAccount(PhabricatorExternalAccount $account) { return; } final protected function newExternalAccountForIdentifiers( array $identifiers) { assert_instances_of($identifiers, 'PhabricatorExternalAccountIdentifier'); if (!$identifiers) { throw new Exception( pht( 'Authentication provider (of class "%s") is attempting to '. 'load or create an external account, but provided no account '. 'identifiers.', get_class($this))); } if (count($identifiers) !== 1) { throw new Exception( pht( 'Unexpected number of account identifiers returned (by class "%s").', get_class($this))); } $config = $this->getProviderConfig(); $viewer = PhabricatorUser::getOmnipotentUser(); $raw_identifiers = mpull($identifiers, 'getIdentifierRaw'); $accounts = id(new PhabricatorExternalAccountQuery()) ->setViewer($viewer) ->withProviderConfigPHIDs(array($config->getPHID())) ->withAccountIDs($raw_identifiers) + ->needAccountIdentifiers(true) ->execute(); if (!$accounts) { $account = $this->newExternalAccount() ->setAccountID(head($raw_identifiers)); } else if (count($accounts) === 1) { $account = head($accounts); } else { throw new Exception( pht( 'Authentication provider (of class "%s") is attempting to load '. 'or create an external account, but provided a list of '. 'account identifiers which map to more than one account: %s.', get_class($this), implode(', ', $raw_identifiers))); } + // See T13493. Add all the identifiers to the account. In the case where + // an account initially has a lower-quality identifier (like an email + // address) and later adds a higher-quality identifier (like a GUID), this + // allows us to automatically upgrade toward the higher-quality identifier + // and survive API changes which remove the lower-quality identifier more + // gracefully. + + foreach ($identifiers as $identifier) { + $account->appendIdentifier($identifier); + } + return $this->didUpdateAccount($account); } final protected function newExternalAccountForUser(PhabricatorUser $user) { $config = $this->getProviderConfig(); // When a user logs in with a provider like username/password, they // always already have a Phabricator account (since there's no way they // could have a username otherwise). // These users should never go to registration, so we're building a // dummy "external account" which just links directly back to their // internal account. $account = id(new PhabricatorExternalAccountQuery()) ->setViewer($user) ->withProviderConfigPHIDs(array($config->getPHID())) ->withUserPHIDs(array($user->getPHID())) ->executeOne(); if (!$account) { $account = $this->newExternalAccount() ->setUserPHID($user->getPHID()); // TODO: Remove this when "accountID" is removed; the column is not // nullable. $account->setAccountID(''); } return $this->didUpdateAccount($account); } private function didUpdateAccount(PhabricatorExternalAccount $account) { $adapter = $this->getAdapter(); $account->setUsername($adapter->getAccountName()); $account->setRealName($adapter->getAccountRealName()); $account->setEmail($adapter->getAccountEmail()); $account->setAccountURI($adapter->getAccountURI()); $account->setProfileImagePHID(null); $image_uri = $adapter->getAccountImageURI(); if ($image_uri) { try { $name = PhabricatorSlug::normalize($this->getProviderName()); $name = $name.'-profile.jpg'; // TODO: If the image has not changed, we do not need to make a new // file entry for it, but there's no convenient way to do this with // PhabricatorFile right now. The storage will get shared, so the impact // here is negligible. $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $image_file = PhabricatorFile::newFromFileDownload( $image_uri, array( 'name' => $name, 'viewPolicy' => PhabricatorPolicies::POLICY_NOONE, )); if ($image_file->isViewableImage()) { $image_file ->setViewPolicy(PhabricatorPolicies::getMostOpenPolicy()) ->setCanCDN(true) ->save(); $account->setProfileImagePHID($image_file->getPHID()); } else { $image_file->delete(); } unset($unguarded); } catch (Exception $ex) { // Log this but proceed, it's not especially important that we // be able to pull profile images. phlog($ex); } } $this->willSaveAccount($account); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $account->save(); unset($unguarded); return $account; } public function getLoginURI() { $app = PhabricatorApplication::getByClass('PhabricatorAuthApplication'); return $app->getApplicationURI('/login/'.$this->getProviderKey().'/'); } public function getSettingsURI() { return '/settings/panel/external/'; } public function getStartURI() { $app = PhabricatorApplication::getByClass('PhabricatorAuthApplication'); $uri = $app->getApplicationURI('/start/'); return $uri; } public function isDefaultRegistrationProvider() { return false; } public function shouldRequireRegistrationPassword() { return false; } public function newDefaultExternalAccount() { return $this->newExternalAccount(); } protected function newExternalAccount() { $config = $this->getProviderConfig(); $adapter = $this->getAdapter(); return id(new PhabricatorExternalAccount()) ->setAccountType($adapter->getAdapterType()) ->setAccountDomain($adapter->getAdapterDomain()) - ->setProviderConfigPHID($config->getPHID()); + ->setProviderConfigPHID($config->getPHID()) + ->attachAccountIdentifiers(array()); } public function getLoginOrder() { return '500-'.$this->getProviderName(); } protected function getLoginIcon() { return 'Generic'; } public function newIconView() { return id(new PHUIIconView()) ->setSpriteSheet(PHUIIconView::SPRITE_LOGIN) ->setSpriteIcon($this->getLoginIcon()); } public function isLoginFormAButton() { return false; } public function renderConfigPropertyTransactionTitle( PhabricatorAuthProviderConfigTransaction $xaction) { return null; } public function readFormValuesFromProvider() { return array(); } public function readFormValuesFromRequest(AphrontRequest $request) { return array(); } public function processEditForm( AphrontRequest $request, array $values) { $errors = array(); $issues = array(); return array($errors, $issues, $values); } public function extendEditForm( AphrontRequest $request, AphrontFormView $form, array $values, array $issues) { return; } public function willRenderLinkedAccount( PhabricatorUser $viewer, PHUIObjectItemView $item, PhabricatorExternalAccount $account) { $account_view = id(new PhabricatorAuthAccountView()) ->setExternalAccount($account) ->setAuthProvider($this); $item->appendChild( phutil_tag( 'div', array( 'class' => 'mmr mml mst mmb', ), $account_view)); } /** * Return true to use a two-step configuration (setup, configure) instead of * the default single-step configuration. In practice, this means that * creating a new provider instance will redirect back to the edit page * instead of the provider list. * * @return bool True if this provider uses two-step configuration. */ public function hasSetupStep() { return false; } /** * Render a standard login/register button element. * * The `$attributes` parameter takes these keys: * * - `uri`: URI the button should take the user to when clicked. * - `method`: Optional HTTP method the button should use, defaults to GET. * * @param AphrontRequest HTTP request. * @param string Request mode string. * @param map Additional parameters, see above. * @return wild Log in button. */ protected function renderStandardLoginButton( AphrontRequest $request, $mode, array $attributes = array()) { PhutilTypeSpec::checkMap( $attributes, array( 'method' => 'optional string', 'uri' => 'string', 'sigil' => 'optional string', )); $viewer = $request->getUser(); $adapter = $this->getAdapter(); if ($mode == 'link') { $button_text = pht('Link External Account'); } else if ($mode == 'refresh') { $button_text = pht('Refresh Account Link'); } else if ($mode == 'invite') { $button_text = pht('Register Account'); } else if ($this->shouldAllowRegistration()) { $button_text = pht('Log In or Register'); } else { $button_text = pht('Log In'); } $icon = id(new PHUIIconView()) ->setSpriteSheet(PHUIIconView::SPRITE_LOGIN) ->setSpriteIcon($this->getLoginIcon()); $button = id(new PHUIButtonView()) ->setSize(PHUIButtonView::BIG) ->setColor(PHUIButtonView::GREY) ->setIcon($icon) ->setText($button_text) ->setSubtext($this->getProviderName()); $uri = $attributes['uri']; $uri = new PhutilURI($uri); $params = $uri->getQueryParamsAsPairList(); $uri->removeAllQueryParams(); $content = array($button); foreach ($params as $pair) { list($key, $value) = $pair; $content[] = phutil_tag( 'input', array( 'type' => 'hidden', 'name' => $key, 'value' => $value, )); } $static_response = CelerityAPI::getStaticResourceResponse(); $static_response->addContentSecurityPolicyURI('form-action', (string)$uri); foreach ($this->getContentSecurityPolicyFormActions() as $csp_uri) { $static_response->addContentSecurityPolicyURI('form-action', $csp_uri); } return phabricator_form( $viewer, array( 'method' => idx($attributes, 'method', 'GET'), 'action' => (string)$uri, 'sigil' => idx($attributes, 'sigil'), ), $content); } public function renderConfigurationFooter() { return null; } public function getAuthCSRFCode(AphrontRequest $request) { $phcid = $request->getCookie(PhabricatorCookies::COOKIE_CLIENTID); if (!strlen($phcid)) { throw new AphrontMalformedRequestException( pht('Missing Client ID Cookie'), pht( 'Your browser did not submit a "%s" cookie with client state '. 'information in the request. Check that cookies are enabled. '. 'If this problem persists, you may need to clear your cookies.', PhabricatorCookies::COOKIE_CLIENTID), true); } return PhabricatorHash::weakDigest($phcid); } protected function verifyAuthCSRFCode(AphrontRequest $request, $actual) { $expect = $this->getAuthCSRFCode($request); if (!strlen($actual)) { throw new Exception( pht( 'The authentication provider did not return a client state '. 'parameter in its response, but one was expected. If this '. 'problem persists, you may need to clear your cookies.')); } if (!phutil_hashes_are_identical($actual, $expect)) { throw new Exception( pht( 'The authentication provider did not return the correct client '. 'state parameter in its response. If this problem persists, you may '. 'need to clear your cookies.')); } } public function supportsAutoLogin() { return false; } public function getAutoLoginURI(AphrontRequest $request) { throw new PhutilMethodNotImplementedException(); } protected function getContentSecurityPolicyFormActions() { return array(); } } diff --git a/src/applications/auth/query/PhabricatorExternalAccountQuery.php b/src/applications/auth/query/PhabricatorExternalAccountQuery.php index 1c5b3fe14f..55b27870a6 100644 --- a/src/applications/auth/query/PhabricatorExternalAccountQuery.php +++ b/src/applications/auth/query/PhabricatorExternalAccountQuery.php @@ -1,204 +1,227 @@ userPHIDs = $user_phids; return $this; } public function withAccountIDs(array $account_ids) { $this->accountIDs = $account_ids; return $this; } public function withAccountDomains(array $account_domains) { $this->accountDomains = $account_domains; return $this; } public function withAccountTypes(array $account_types) { $this->accountTypes = $account_types; return $this; } public function withPHIDs(array $phids) { $this->phids = $phids; return $this; } public function withIDs($ids) { $this->ids = $ids; return $this; } public function withAccountSecrets(array $secrets) { $this->accountSecrets = $secrets; return $this; } public function needImages($need) { $this->needImages = $need; return $this; } + public function needAccountIdentifiers($need) { + $this->needAccountIdentifiers = $need; + return $this; + } + public function withProviderConfigPHIDs(array $phids) { $this->providerConfigPHIDs = $phids; return $this; } public function newResultObject() { return new PhabricatorExternalAccount(); } protected function loadPage() { return $this->loadStandardPage($this->newResultObject()); } protected function willFilterPage(array $accounts) { $viewer = $this->getViewer(); $configs = id(new PhabricatorAuthProviderConfigQuery()) ->setViewer($viewer) ->withPHIDs(mpull($accounts, 'getProviderConfigPHID')) ->execute(); $configs = mpull($configs, null, 'getPHID'); foreach ($accounts as $key => $account) { $config_phid = $account->getProviderConfigPHID(); $config = idx($configs, $config_phid); if (!$config) { unset($accounts[$key]); continue; } $account->attachProviderConfig($config); } if ($this->needImages) { $file_phids = mpull($accounts, 'getProfileImagePHID'); $file_phids = array_filter($file_phids); if ($file_phids) { // NOTE: We use the omnipotent viewer here because these files are // usually created during registration and can't be associated with // the correct policies, since the relevant user account does not exist // yet. In effect, if you can see an ExternalAccount, you can see its // profile image. $files = id(new PhabricatorFileQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs($file_phids) ->execute(); $files = mpull($files, null, 'getPHID'); } else { $files = array(); } $default_file = null; foreach ($accounts as $account) { $image_phid = $account->getProfileImagePHID(); if ($image_phid && isset($files[$image_phid])) { $account->attachProfileImageFile($files[$image_phid]); } else { if ($default_file === null) { $default_file = PhabricatorFile::loadBuiltin( $this->getViewer(), 'profile.png'); } $account->attachProfileImageFile($default_file); } } } + if ($this->needAccountIdentifiers) { + $account_phids = mpull($accounts, 'getPHID'); + + $identifiers = id(new PhabricatorExternalAccountIdentifierQuery()) + ->setViewer($viewer) + ->setParentQuery($this) + ->withExternalAccountPHIDs($account_phids) + ->execute(); + + $identifiers = mgroup($identifiers, 'getExternalAccountPHID'); + foreach ($accounts as $account) { + $account_phid = $account->getPHID(); + $account_identifiers = idx($identifiers, $account_phid, array()); + $account->attachAccountIdentifiers($account_identifiers); + } + } + return $accounts; } protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { $where = parent::buildWhereClauseParts($conn); if ($this->ids !== null) { $where[] = qsprintf( $conn, 'id IN (%Ld)', $this->ids); } if ($this->phids !== null) { $where[] = qsprintf( $conn, 'phid IN (%Ls)', $this->phids); } if ($this->accountTypes !== null) { $where[] = qsprintf( $conn, 'accountType IN (%Ls)', $this->accountTypes); } if ($this->accountDomains !== null) { $where[] = qsprintf( $conn, 'accountDomain IN (%Ls)', $this->accountDomains); } if ($this->accountIDs !== null) { $where[] = qsprintf( $conn, 'accountID IN (%Ls)', $this->accountIDs); } if ($this->userPHIDs !== null) { $where[] = qsprintf( $conn, 'userPHID IN (%Ls)', $this->userPHIDs); } if ($this->accountSecrets !== null) { $where[] = qsprintf( $conn, 'accountSecret IN (%Ls)', $this->accountSecrets); } if ($this->providerConfigPHIDs !== null) { $where[] = qsprintf( $conn, 'providerConfigPHID IN (%Ls)', $this->providerConfigPHIDs); } return $where; } public function getQueryApplicationClass() { return 'PhabricatorPeopleApplication'; } } diff --git a/src/applications/people/storage/PhabricatorExternalAccount.php b/src/applications/people/storage/PhabricatorExternalAccount.php index b845a44bb6..62d1c74c94 100644 --- a/src/applications/people/storage/PhabricatorExternalAccount.php +++ b/src/applications/people/storage/PhabricatorExternalAccount.php @@ -1,188 +1,270 @@ assertAttached($this->profileImageFile); } public function attachProfileImageFile(PhabricatorFile $file) { $this->profileImageFile = $file; return $this; } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorPeopleExternalPHIDType::TYPECONST); } protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_SERIALIZATION => array( 'properties' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( 'userPHID' => 'phid?', 'accountType' => 'text16', 'accountDomain' => 'text64', 'accountSecret' => 'text?', 'accountID' => 'text64', 'displayName' => 'text255?', 'username' => 'text255?', 'realName' => 'text255?', 'email' => 'text255?', 'emailVerified' => 'bool', 'profileImagePHID' => 'phid?', 'accountURI' => 'text255?', ), self::CONFIG_KEY_SCHEMA => array( 'account_details' => array( 'columns' => array('accountType', 'accountDomain', 'accountID'), 'unique' => true, ), 'key_user' => array( 'columns' => array('userPHID'), ), ), ) + parent::getConfiguration(); } public function getProviderKey() { return $this->getAccountType().':'.$this->getAccountDomain(); } public function save() { if (!$this->getAccountSecret()) { $this->setAccountSecret(Filesystem::readRandomCharacters(32)); } - return parent::save(); + + $this->openTransaction(); + + $result = parent::save(); + + $account_phid = $this->getPHID(); + $config_phid = $this->getProviderConfigPHID(); + + if ($this->accountIdentifiers !== self::ATTACHABLE) { + foreach ($this->getAccountIdentifiers() as $identifier) { + $identifier + ->setExternalAccountPHID($account_phid) + ->setProviderConfigPHID($config_phid) + ->save(); + } + } + + $this->saveTransaction(); + + return $result; + } + + public function unlinkAccount() { + + // When unlinking an account, we disassociate it from the user and + // remove all the identifying information. We retain the PHID, the + // object itself, and the "ExternalAccountIdentifier" objects in the + // external table. + + // TODO: This unlinks (but does not destroy) any profile image. + + return $this + ->setUserPHID(null) + ->setDisplayName(null) + ->setUsername(null) + ->setRealName(null) + ->setEmail(null) + ->setEmailVerified(0) + ->setProfileImagePHID(null) + ->setAccountURI(null) + ->setProperties(array()) + ->save(); } public function setProperty($key, $value) { $this->properties[$key] = $value; return $this; } public function getProperty($key, $default = null) { return idx($this->properties, $key, $default); } public function isUsableForLogin() { $config = $this->getProviderConfig(); if (!$config->getIsEnabled()) { return false; } $provider = $config->getProvider(); if (!$provider->shouldAllowLogin()) { return false; } return true; } public function getDisplayName() { if (strlen($this->displayName)) { return $this->displayName; } // TODO: Figure out how much identifying information we're going to show // to users about external accounts. For now, just show a string which is // clearly not an error, but don't disclose any identifying information. $map = array( 'email' => pht('Email User'), ); $type = $this->getAccountType(); return idx($map, $type, pht('"%s" User', $type)); } public function attachProviderConfig(PhabricatorAuthProviderConfig $config) { $this->providerConfig = $config; return $this; } public function getProviderConfig() { return $this->assertAttached($this->providerConfig); } + public function getAccountIdentifiers() { + $raw = $this->assertAttached($this->accountIdentifiers); + return array_values($raw); + } + + public function attachAccountIdentifiers(array $identifiers) { + assert_instances_of($identifiers, 'PhabricatorExternalAccountIdentifier'); + $this->accountIdentifiers = mpull($identifiers, null, 'getIdentifierRaw'); + return $this; + } + + public function appendIdentifier( + PhabricatorExternalAccountIdentifier $identifier) { + + $this->assertAttached($this->accountIdentifiers); + + $map = $this->accountIdentifiers; + $raw = $identifier->getIdentifierRaw(); + + $old = idx($map, $raw); + $new = $identifier; + + if ($old === null) { + $result = $new; + } else { + // Here, we already know about an identifier and have rediscovered it. + + // We could copy properties from the new version of the identifier here, + // or merge them in some other way (for example, update a "last seen + // from the provider" timestamp), but no such properties currently exist. + $result = $old; + } + + $this->accountIdentifiers[$raw] = $result; + + return $this; + } + /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return PhabricatorPolicies::getMostOpenPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: return PhabricatorPolicies::POLICY_NOONE; } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return ($viewer->getPHID() == $this->getUserPHID()); } public function describeAutomaticCapability($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return null; case PhabricatorPolicyCapability::CAN_EDIT: return pht( 'External accounts can only be edited by the account owner.'); } } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $viewer = $engine->getViewer(); $identifiers = id(new PhabricatorExternalAccountIdentifierQuery()) ->setViewer($viewer) ->withExternalAccountPHIDs(array($this->getPHID())) ->newIterator(); foreach ($identifiers as $identifier) { $engine->destroyObject($identifier); } + // TODO: This may leave a profile image behind. + $this->delete(); } } diff --git a/src/applications/people/storage/PhabricatorExternalAccountIdentifier.php b/src/applications/people/storage/PhabricatorExternalAccountIdentifier.php index 638f6f5d4b..ecf766723b 100644 --- a/src/applications/people/storage/PhabricatorExternalAccountIdentifier.php +++ b/src/applications/people/storage/PhabricatorExternalAccountIdentifier.php @@ -1,78 +1,81 @@ true, self::CONFIG_COLUMN_SCHEMA => array( 'identifierHash' => 'bytes12', 'identifierRaw' => 'text', ), self::CONFIG_KEY_SCHEMA => array( 'key_identifier' => array( 'columns' => array('providerConfigPHID', 'identifierHash'), 'unique' => true, ), 'key_account' => array( 'columns' => array('externalAccountPHID'), ), ), ) + parent::getConfiguration(); } public function save() { $identifier_raw = $this->getIdentifierRaw(); - $this->identiferHash = PhabricatorHash::digestForIndex($identifier_raw); + + $identifier_hash = PhabricatorHash::digestForIndex($identifier_raw); + $this->setIdentifierHash($identifier_hash); + return parent::save(); } /* -( PhabricatorPolicyInterface )----------------------------------------- */ // TODO: These permissions aren't very good. They should just be the same // as the associated ExternalAccount. See T13381. public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return PhabricatorPolicies::getMostOpenPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: return PhabricatorPolicies::POLICY_NOONE; } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return false; } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->delete(); } }