diff --git a/src/applications/auth/adapter/PhutilAuthAdapter.php b/src/applications/auth/adapter/PhutilAuthAdapter.php index 73ef9e4834..7d33b5a6f3 100644 --- a/src/applications/auth/adapter/PhutilAuthAdapter.php +++ b/src/applications/auth/adapter/PhutilAuthAdapter.php @@ -1,123 +1,144 @@ newAccountIdentifiers(); + assert_instances_of($result, 'PhabricatorExternalAccountIdentifier'); + return $result; + } + + protected function newAccountIdentifiers() { + $identifiers = array(); + + $raw_identifier = $this->getAccountID(); + if ($raw_identifier !== null) { + $identifiers[] = $this->newAccountIdentifier($raw_identifier); + } + + return $identifiers; + } + /** - * Get a unique identifier associated with the identity. For most providers, - * this is an account ID. + * Get a unique identifier associated with the account. * - * The account ID needs to be unique within this adapter's configuration, such - * that `` is globally unique and always identifies the - * same identity. + * This identifier should be permanent, immutable, and uniquely identify + * the account. If possible, it should be nonsensitive. For providers that + * have a GUID or PHID value for accounts, these are the best values to use. + * + * You can implement @{method:newAccountIdentifiers} instead if a provider + * is unable to emit identifiers with all of these properties. * * If the adapter was unable to authenticate an identity, it should return * `null`. * * @return string|null Unique account identifier, or `null` if authentication * failed. */ - abstract public function getAccountID(); + public function getAccountID() { + throw new PhutilMethodNotImplementedException(); + } /** * Get a string identifying this adapter, like "ldap". This string should be * unique to the adapter class. * * @return string Unique adapter identifier. */ abstract public function getAdapterType(); /** * Get a string identifying the domain this adapter is acting on. This allows * an adapter (like LDAP) to act against different identity domains without * conflating credentials. For providers like Facebook or Google, the adapters * just return the relevant domain name. * * @return string Domain the adapter is associated with. */ abstract public function getAdapterDomain(); /** * Generate a string uniquely identifying this adapter configuration. Within * the scope of a given key, all account IDs must uniquely identify exactly * one identity. * * @return string Unique identifier for this adapter configuration. */ public function getAdapterKey() { return $this->getAdapterType().':'.$this->getAdapterDomain(); } /** * Optionally, return an email address associated with this account. * * @return string|null An email address associated with the account, or * `null` if data is not available. */ public function getAccountEmail() { return null; } /** * Optionally, return a human readable username associated with this account. * * @return string|null Account username, or `null` if data isn't available. */ public function getAccountName() { return null; } /** * Optionally, return a URI corresponding to a human-viewable profile for * this account. * * @return string|null A profile URI associated with this account, or * `null` if the data isn't available. */ public function getAccountURI() { return null; } /** * Optionally, return a profile image URI associated with this account. * * @return string|null URI for an account profile image, or `null` if one is * not available. */ public function getAccountImageURI() { return null; } /** * Optionally, return a real name associated with this account. * * @return string|null A human real name, or `null` if this data is not * available. */ public function getAccountRealName() { return null; } } diff --git a/src/applications/auth/controller/PhabricatorAuthController.php b/src/applications/auth/controller/PhabricatorAuthController.php index 505620b3f3..c3dc4e2d98 100644 --- a/src/applications/auth/controller/PhabricatorAuthController.php +++ b/src/applications/auth/controller/PhabricatorAuthController.php @@ -1,311 +1,295 @@ setTitle($title); $view->setErrors($messages); return $this->newPage() ->setTitle($title) ->appendChild($view); } /** * Returns true if this install is newly setup (i.e., there are no user * accounts yet). In this case, we enter a special mode to permit creation * of the first account form the web UI. */ protected function isFirstTimeSetup() { // If there are any auth providers, this isn't first time setup, even if // we don't have accounts. if (PhabricatorAuthProvider::getAllEnabledProviders()) { return false; } // Otherwise, check if there are any user accounts. If not, we're in first // time setup. $any_users = id(new PhabricatorPeopleQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->setLimit(1) ->execute(); return !$any_users; } /** * Log a user into a web session and return an @{class:AphrontResponse} which * corresponds to continuing the login process. * * Normally, this is a redirect to the validation controller which makes sure * the user's cookies are set. However, event listeners can intercept this * event and do something else if they prefer. * * @param PhabricatorUser User to log the viewer in as. * @param bool True to issue a full session immediately, bypassing MFA. * @return AphrontResponse Response which continues the login process. */ protected function loginUser( PhabricatorUser $user, $force_full_session = false) { $response = $this->buildLoginValidateResponse($user); $session_type = PhabricatorAuthSession::TYPE_WEB; if ($force_full_session) { $partial_session = false; } else { $partial_session = true; } $session_key = id(new PhabricatorAuthSessionEngine()) ->establishSession($session_type, $user->getPHID(), $partial_session); // NOTE: We allow disabled users to login and roadblock them later, so // there's no check for users being disabled here. $request = $this->getRequest(); $request->setCookie( PhabricatorCookies::COOKIE_USERNAME, $user->getUsername()); $request->setCookie( PhabricatorCookies::COOKIE_SESSION, $session_key); $this->clearRegistrationCookies(); return $response; } protected function clearRegistrationCookies() { $request = $this->getRequest(); // Clear the registration key. $request->clearCookie(PhabricatorCookies::COOKIE_REGISTRATION); // Clear the client ID / OAuth state key. $request->clearCookie(PhabricatorCookies::COOKIE_CLIENTID); // Clear the invite cookie. $request->clearCookie(PhabricatorCookies::COOKIE_INVITE); } private function buildLoginValidateResponse(PhabricatorUser $user) { $validate_uri = new PhutilURI($this->getApplicationURI('validate/')); $validate_uri->replaceQueryParam('expect', $user->getUsername()); return id(new AphrontRedirectResponse())->setURI((string)$validate_uri); } protected function renderError($message) { return $this->renderErrorPage( pht('Authentication Error'), array( $message, )); } protected function loadAccountForRegistrationOrLinking($account_key) { $request = $this->getRequest(); $viewer = $request->getUser(); $account = null; $provider = null; $response = null; if (!$account_key) { $response = $this->renderError( pht('Request did not include account key.')); return array($account, $provider, $response); } // NOTE: We're using the omnipotent user because the actual user may not // be logged in yet, and because we want to tailor an error message to // distinguish between "not usable" and "does not exist". We do explicit // checks later on to make sure this account is valid for the intended // operation. This requires edit permission for completeness and consistency // but it won't actually be meaningfully checked because we're using the // omnipotent user. $account = id(new PhabricatorExternalAccountQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withAccountSecrets(array($account_key)) ->needImages(true) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$account) { $response = $this->renderError(pht('No valid linkable account.')); return array($account, $provider, $response); } if ($account->getUserPHID()) { if ($account->getUserPHID() != $viewer->getPHID()) { $response = $this->renderError( pht( 'The account you are attempting to register or link is already '. 'linked to another user.')); } else { $response = $this->renderError( pht( 'The account you are attempting to link is already linked '. 'to your account.')); } return array($account, $provider, $response); } $registration_key = $request->getCookie( PhabricatorCookies::COOKIE_REGISTRATION); // NOTE: This registration key check is not strictly necessary, because // we're only creating new accounts, not linking existing accounts. It // might be more hassle than it is worth, especially for email. // // The attack this prevents is getting to the registration screen, then // copy/pasting the URL and getting someone else to click it and complete // the process. They end up with an account bound to credentials you // control. This doesn't really let you do anything meaningful, though, // since you could have simply completed the process yourself. if (!$registration_key) { $response = $this->renderError( pht( 'Your browser did not submit a registration key with the request. '. 'You must use the same browser to begin and complete registration. '. 'Check that cookies are enabled and try again.')); return array($account, $provider, $response); } // We store the digest of the key rather than the key itself to prevent a // theoretical attacker with read-only access to the database from // hijacking registration sessions. $actual = $account->getProperty('registrationKey'); $expect = PhabricatorHash::weakDigest($registration_key); if (!phutil_hashes_are_identical($actual, $expect)) { $response = $this->renderError( pht( 'Your browser submitted a different registration key than the one '. 'associated with this account. You may need to clear your cookies.')); return array($account, $provider, $response); } - $other_account = id(new PhabricatorExternalAccount())->loadAllWhere( - 'accountType = %s AND accountDomain = %s AND accountID = %s - AND id != %d', - $account->getAccountType(), - $account->getAccountDomain(), - $account->getAccountID(), - $account->getID()); - - if ($other_account) { - $response = $this->renderError( - pht( - 'The account you are attempting to register with already belongs '. - 'to another user.')); - return array($account, $provider, $response); - } - $config = $account->getProviderConfig(); if (!$config->getIsEnabled()) { $response = $this->renderError( pht( 'The account you are attempting to register with uses a disabled '. 'authentication provider ("%s"). An administrator may have '. 'recently disabled this provider.', $config->getDisplayName())); return array($account, $provider, $response); } $provider = $config->getProvider(); return array($account, $provider, null); } protected function loadInvite() { $invite_cookie = PhabricatorCookies::COOKIE_INVITE; $invite_code = $this->getRequest()->getCookie($invite_cookie); if (!$invite_code) { return null; } $engine = id(new PhabricatorAuthInviteEngine()) ->setViewer($this->getViewer()) ->setUserHasConfirmedVerify(true); try { return $engine->processInviteCode($invite_code); } catch (Exception $ex) { // If this fails for any reason, just drop the invite. In normal // circumstances, we gave them a detailed explanation of any error // before they jumped into this workflow. return null; } } protected function renderInviteHeader(PhabricatorAuthInvite $invite) { $viewer = $this->getViewer(); // Since the user hasn't registered yet, they may not be able to see other // user accounts. Load the inviting user with the omnipotent viewer. $omnipotent_viewer = PhabricatorUser::getOmnipotentUser(); $invite_author = id(new PhabricatorPeopleQuery()) ->setViewer($omnipotent_viewer) ->withPHIDs(array($invite->getAuthorPHID())) ->needProfileImage(true) ->executeOne(); // If we can't load the author for some reason, just drop this message. // We lose the value of contextualizing things without author details. if (!$invite_author) { return null; } $invite_item = id(new PHUIObjectItemView()) ->setHeader(pht('Welcome to Phabricator!')) ->setImageURI($invite_author->getProfileImageURI()) ->addAttribute( pht( '%s has invited you to join Phabricator.', $invite_author->getFullName())); $invite_list = id(new PHUIObjectItemListView()) ->addItem($invite_item) ->setFlush(true); return id(new PHUIBoxView()) ->addMargin(PHUI::MARGIN_LARGE) ->appendChild($invite_list); } final protected function newCustomStartMessage() { $viewer = $this->getViewer(); $text = PhabricatorAuthMessage::loadMessageText( $viewer, PhabricatorAuthLoginMessageType::MESSAGEKEY); if (!strlen($text)) { return null; } $remarkup_view = new PHUIRemarkupView($viewer, $text); return phutil_tag( 'div', array( 'class' => 'auth-custom-message', ), $remarkup_view); } }