diff --git a/scripts/ssh/ssh-exec.php b/scripts/ssh/ssh-exec.php index 5d8ede291..56b13ff14 100755 --- a/scripts/ssh/ssh-exec.php +++ b/scripts/ssh/ssh-exec.php @@ -1,301 +1,304 @@ #!/usr/bin/env php setTagline(pht('execute SSH requests')); $args->setSynopsis(<<parseStandardArguments(); $args->parse( array( array( 'name' => 'phabricator-ssh-user', 'param' => 'username', 'help' => pht( 'If the request authenticated with a user key, the name of the '. 'user.'), ), array( 'name' => 'phabricator-ssh-device', 'param' => 'name', 'help' => pht( 'If the request authenticated with a device key, the name of the '. 'device.'), ), array( 'name' => 'phabricator-ssh-key', 'param' => 'id', 'help' => pht( 'The ID of the SSH key which authenticated this request. This is '. 'used to allow logs to report when specific keys were used, to make '. 'it easier to manage credentials.'), ), array( 'name' => 'ssh-command', 'param' => 'command', 'help' => pht( 'Provide a command to execute. This makes testing this script '. 'easier. When running normally, the command is read from the '. 'environment (%s), which is populated by sshd.', 'SSH_ORIGINAL_COMMAND'), ), )); try { $remote_address = null; $ssh_client = getenv('SSH_CLIENT'); if ($ssh_client) { // This has the format " ". Grab the IP. $remote_address = head(explode(' ', $ssh_client)); $ssh_log->setData( array( 'r' => $remote_address, )); } $key_id = $args->getArg('phabricator-ssh-key'); if ($key_id) { $ssh_log->setData( array( 'k' => $key_id, )); } $user_name = $args->getArg('phabricator-ssh-user'); $device_name = $args->getArg('phabricator-ssh-device'); $user = null; $device = null; $is_cluster_request = false; if ($user_name && $device_name) { throw new Exception( pht( 'The %s and %s flags are mutually exclusive. You can not '. 'authenticate as both a user ("%s") and a device ("%s"). '. 'Specify one or the other, but not both.', '--phabricator-ssh-user', '--phabricator-ssh-device', $user_name, $device_name)); } else if (strlen($user_name)) { $user = id(new PhabricatorPeopleQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withUsernames(array($user_name)) ->executeOne(); if (!$user) { throw new Exception( pht( 'Invalid username ("%s"). There is no user with this username.', $user_name)); } + + id(new PhabricatorAuthSessionEngine()) + ->willServeRequestForUser($user); } else if (strlen($device_name)) { if (!$remote_address) { throw new Exception( pht( 'Unable to identify remote address from the %s environment '. 'variable. Device authentication is accepted only from trusted '. 'sources.', 'SSH_CLIENT')); } if (!PhabricatorEnv::isClusterAddress($remote_address)) { throw new Exception( pht( 'This request originates from outside of the Phabricator cluster '. 'address range. Requests signed with a trusted device key must '. 'originate from trusted hosts.')); } $device = id(new AlmanacDeviceQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withNames(array($device_name)) ->executeOne(); if (!$device) { throw new Exception( pht( 'Invalid device name ("%s"). There is no device with this name.', $device_name)); } // We're authenticated as a device, but we're going to read the user out of // the command below. $is_cluster_request = true; } else { throw new Exception( pht( 'This script must be invoked with either the %s or %s flag.', '--phabricator-ssh-user', '--phabricator-ssh-device')); } if ($args->getArg('ssh-command')) { $original_command = $args->getArg('ssh-command'); } else { $original_command = getenv('SSH_ORIGINAL_COMMAND'); } $original_argv = id(new PhutilShellLexer()) ->splitArguments($original_command); if ($device) { // If we're authenticating as a device, the first argument may be a // "@username" argument to act as a particular user. $first_argument = head($original_argv); if (preg_match('/^@/', $first_argument)) { $act_as_name = array_shift($original_argv); $act_as_name = substr($act_as_name, 1); $user = id(new PhabricatorPeopleQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withUsernames(array($act_as_name)) ->executeOne(); if (!$user) { throw new Exception( pht( 'Device request identifies an acting user with an invalid '. 'username ("%s"). There is no user with this username.', $act_as_name)); } } else { $user = PhabricatorUser::getOmnipotentUser(); } } if ($user->isOmnipotent()) { $user_name = 'device/'.$device->getName(); } else { $user_name = $user->getUsername(); } $ssh_log->setData( array( 'u' => $user_name, 'P' => $user->getPHID(), )); if (!$user->canEstablishSSHSessions()) { throw new Exception( pht( 'Your account ("%s") does not have permission to establish SSH '. 'sessions. Visit the web interface for more information.', $user_name)); } $workflows = id(new PhutilClassMapQuery()) ->setAncestorClass('PhabricatorSSHWorkflow') ->setUniqueMethod('getName') ->execute(); if (!$original_argv) { throw new Exception( pht( "Welcome to Phabricator.\n\n". "You are logged in as %s.\n\n". "You haven't specified a command to run. This means you're requesting ". "an interactive shell, but Phabricator does not provide an ". "interactive shell over SSH.\n\n". "Usually, you should run a command like `%s` or `%s` ". "rather than connecting directly with SSH.\n\n". "Supported commands are: %s.", $user_name, 'git clone', 'hg push', implode(', ', array_keys($workflows)))); } $log_argv = implode(' ', $original_argv); $log_argv = id(new PhutilUTF8StringTruncator()) ->setMaximumCodepoints(128) ->truncateString($log_argv); $ssh_log->setData( array( 'C' => $original_argv[0], 'U' => $log_argv, )); $command = head($original_argv); $parseable_argv = $original_argv; array_unshift($parseable_argv, 'phabricator-ssh-exec'); $parsed_args = new PhutilArgumentParser($parseable_argv); if (empty($workflows[$command])) { throw new Exception(pht('Invalid command.')); } $workflow = $parsed_args->parseWorkflows($workflows); $workflow->setUser($user); $workflow->setOriginalArguments($original_argv); $workflow->setIsClusterRequest($is_cluster_request); $sock_stdin = fopen('php://stdin', 'r'); if (!$sock_stdin) { throw new Exception(pht('Unable to open stdin.')); } $sock_stdout = fopen('php://stdout', 'w'); if (!$sock_stdout) { throw new Exception(pht('Unable to open stdout.')); } $sock_stderr = fopen('php://stderr', 'w'); if (!$sock_stderr) { throw new Exception(pht('Unable to open stderr.')); } $socket_channel = new PhutilSocketChannel( $sock_stdin, $sock_stdout); $error_channel = new PhutilSocketChannel(null, $sock_stderr); $metrics_channel = new PhutilMetricsChannel($socket_channel); $workflow->setIOChannel($metrics_channel); $workflow->setErrorChannel($error_channel); $rethrow = null; try { $err = $workflow->execute($parsed_args); $metrics_channel->flush(); $error_channel->flush(); } catch (Exception $ex) { $rethrow = $ex; } // Always write this if we got as far as building a metrics channel. $ssh_log->setData( array( 'i' => $metrics_channel->getBytesRead(), 'o' => $metrics_channel->getBytesWritten(), )); if ($rethrow) { throw $rethrow; } } catch (Exception $ex) { fwrite(STDERR, "phabricator-ssh-exec: ".$ex->getMessage()."\n"); $err = 1; } $ssh_log->setData( array( 'c' => $err, 'T' => (int)(1000000 * (microtime(true) - $ssh_start_time)), )); exit($err); diff --git a/src/applications/auth/engine/PhabricatorAuthSessionEngine.php b/src/applications/auth/engine/PhabricatorAuthSessionEngine.php index a7f551305..e7d5c9414 100644 --- a/src/applications/auth/engine/PhabricatorAuthSessionEngine.php +++ b/src/applications/auth/engine/PhabricatorAuthSessionEngine.php @@ -1,835 +1,842 @@ establishConnection('r'); $session_key = PhabricatorHash::digest($session_token); $cache_parts = $this->getUserCacheQueryParts($conn_r); list($cache_selects, $cache_joins, $cache_map, $types_map) = $cache_parts; $info = queryfx_one( $conn_r, 'SELECT s.id AS s_id, s.sessionExpires AS s_sessionExpires, s.sessionStart AS s_sessionStart, s.highSecurityUntil AS s_highSecurityUntil, s.isPartial AS s_isPartial, s.signedLegalpadDocuments as s_signedLegalpadDocuments, u.* %Q FROM %T u JOIN %T s ON u.phid = s.userPHID AND s.type = %s AND s.sessionKey = %s %Q', $cache_selects, $user_table->getTableName(), $session_table->getTableName(), $session_type, $session_key, $cache_joins); if (!$info) { return null; } $session_dict = array( 'userPHID' => $info['phid'], 'sessionKey' => $session_key, 'type' => $session_type, ); $cache_raw = array_fill_keys($cache_map, null); foreach ($info as $key => $value) { if (strncmp($key, 's_', 2) === 0) { unset($info[$key]); $session_dict[substr($key, 2)] = $value; continue; } if (isset($cache_map[$key])) { unset($info[$key]); $cache_raw[$cache_map[$key]] = $value; continue; } } $user = $user_table->loadFromArray($info); $cache_raw = $this->filterRawCacheData($user, $types_map, $cache_raw); $user->attachRawCacheData($cache_raw); - $user->setAllowInlineCacheGeneration(true); switch ($session_type) { case PhabricatorAuthSession::TYPE_WEB: // Explicitly prevent bots and mailing lists from establishing web // sessions. It's normally impossible to attach authentication to these // accounts, and likewise impossible to generate sessions, but it's // technically possible that a session could exist in the database. If // one does somehow, refuse to load it. if (!$user->canEstablishWebSessions()) { return null; } break; } $session = id(new PhabricatorAuthSession())->loadFromArray($session_dict); $ttl = PhabricatorAuthSession::getSessionTypeTTL($session_type); // If more than 20% of the time on this session has been used, refresh the // TTL back up to the full duration. The idea here is that sessions are // good forever if used regularly, but get GC'd when they fall out of use. // NOTE: If we begin rotating session keys when extending sessions, the // CSRF code needs to be updated so CSRF tokens survive session rotation. if (time() + (0.80 * $ttl) > $session->getSessionExpires()) { $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $conn_w = $session_table->establishConnection('w'); queryfx( $conn_w, 'UPDATE %T SET sessionExpires = UNIX_TIMESTAMP() + %d WHERE id = %d', $session->getTableName(), $ttl, $session->getID()); unset($unguarded); } $user->attachSession($session); return $user; } /** * Issue a new session key for a given identity. Phabricator supports * different types of sessions (like "web" and "conduit") and each session * type may have multiple concurrent sessions (this allows a user to be * logged in on multiple browsers at the same time, for instance). * * Note that this method is transport-agnostic and does not set cookies or * issue other types of tokens, it ONLY generates a new session key. * * You can configure the maximum number of concurrent sessions for various * session types in the Phabricator configuration. * * @param const Session type constant (see * @{class:PhabricatorAuthSession}). * @param phid|null Identity to establish a session for, usually a user * PHID. With `null`, generates an anonymous session. * @param bool True to issue a partial session. * @return string Newly generated session key. */ public function establishSession($session_type, $identity_phid, $partial) { // Consume entropy to generate a new session key, forestalling the eventual // heat death of the universe. $session_key = Filesystem::readRandomCharacters(40); if ($identity_phid === null) { return self::KIND_ANONYMOUS.'/'.$session_key; } $session_table = new PhabricatorAuthSession(); $conn_w = $session_table->establishConnection('w'); // This has a side effect of validating the session type. $session_ttl = PhabricatorAuthSession::getSessionTypeTTL($session_type); $digest_key = PhabricatorHash::digest($session_key); // Logging-in users don't have CSRF stuff yet, so we have to unguard this // write. $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); id(new PhabricatorAuthSession()) ->setUserPHID($identity_phid) ->setType($session_type) ->setSessionKey($digest_key) ->setSessionStart(time()) ->setSessionExpires(time() + $session_ttl) ->setIsPartial($partial ? 1 : 0) ->setSignedLegalpadDocuments(0) ->save(); $log = PhabricatorUserLog::initializeNewLog( null, $identity_phid, ($partial ? PhabricatorUserLog::ACTION_LOGIN_PARTIAL : PhabricatorUserLog::ACTION_LOGIN)); $log->setDetails( array( 'session_type' => $session_type, )); $log->setSession($digest_key); $log->save(); unset($unguarded); return $session_key; } /** * Terminate all of a user's login sessions. * * This is used when users change passwords, linked accounts, or add * multifactor authentication. * * @param PhabricatorUser User whose sessions should be terminated. * @param string|null Optionally, one session to keep. Normally, the current * login session. * * @return void */ public function terminateLoginSessions( PhabricatorUser $user, $except_session = null) { $sessions = id(new PhabricatorAuthSessionQuery()) ->setViewer($user) ->withIdentityPHIDs(array($user->getPHID())) ->execute(); if ($except_session !== null) { $except_session = PhabricatorHash::digest($except_session); } foreach ($sessions as $key => $session) { if ($except_session !== null) { $is_except = phutil_hashes_are_identical( $session->getSessionKey(), $except_session); if ($is_except) { continue; } } $session->delete(); } } public function logoutSession( PhabricatorUser $user, PhabricatorAuthSession $session) { $log = PhabricatorUserLog::initializeNewLog( $user, $user->getPHID(), PhabricatorUserLog::ACTION_LOGOUT); $log->save(); $extensions = PhabricatorAuthSessionEngineExtension::getAllExtensions(); foreach ($extensions as $extension) { $extension->didLogout($user, array($session)); } $session->delete(); } /* -( High Security )------------------------------------------------------ */ /** * Require high security, or prompt the user to enter high security. * * If the user's session is in high security, this method will return a * token. Otherwise, it will throw an exception which will eventually * be converted into a multi-factor authentication workflow. * * @param PhabricatorUser User whose session needs to be in high security. * @param AphrontReqeust Current request. * @param string URI to return the user to if they cancel. * @param bool True to jump partial sessions directly into high * security instead of just upgrading them to full * sessions. * @return PhabricatorAuthHighSecurityToken Security token. * @task hisec */ public function requireHighSecuritySession( PhabricatorUser $viewer, AphrontRequest $request, $cancel_uri, $jump_into_hisec = false) { if (!$viewer->hasSession()) { throw new Exception( pht('Requiring a high-security session from a user with no session!')); } $session = $viewer->getSession(); // Check if the session is already in high security mode. $token = $this->issueHighSecurityToken($session); if ($token) { return $token; } // Load the multi-factor auth sources attached to this account. $factors = id(new PhabricatorAuthFactorConfig())->loadAllWhere( 'userPHID = %s', $viewer->getPHID()); // If the account has no associated multi-factor auth, just issue a token // without putting the session into high security mode. This is generally // easier for users. A minor but desirable side effect is that when a user // adds an auth factor, existing sessions won't get a free pass into hisec, // since they never actually got marked as hisec. if (!$factors) { return $this->issueHighSecurityToken($session, true); } // Check for a rate limit without awarding points, so the user doesn't // get partway through the workflow only to get blocked. PhabricatorSystemActionEngine::willTakeAction( array($viewer->getPHID()), new PhabricatorAuthTryFactorAction(), 0); $validation_results = array(); if ($request->isHTTPPost()) { $request->validateCSRF(); if ($request->getExists(AphrontRequest::TYPE_HISEC)) { // Limit factor verification rates to prevent brute force attacks. PhabricatorSystemActionEngine::willTakeAction( array($viewer->getPHID()), new PhabricatorAuthTryFactorAction(), 1); $ok = true; foreach ($factors as $factor) { $id = $factor->getID(); $impl = $factor->requireImplementation(); $validation_results[$id] = $impl->processValidateFactorForm( $factor, $viewer, $request); if (!$impl->isFactorValid($factor, $validation_results[$id])) { $ok = false; } } if ($ok) { // Give the user a credit back for a successful factor verification. PhabricatorSystemActionEngine::willTakeAction( array($viewer->getPHID()), new PhabricatorAuthTryFactorAction(), -1); if ($session->getIsPartial() && !$jump_into_hisec) { // If we have a partial session and are not jumping directly into // hisec, just issue a token without putting it in high security // mode. return $this->issueHighSecurityToken($session, true); } $until = time() + phutil_units('15 minutes in seconds'); $session->setHighSecurityUntil($until); queryfx( $session->establishConnection('w'), 'UPDATE %T SET highSecurityUntil = %d WHERE id = %d', $session->getTableName(), $until, $session->getID()); $log = PhabricatorUserLog::initializeNewLog( $viewer, $viewer->getPHID(), PhabricatorUserLog::ACTION_ENTER_HISEC); $log->save(); } else { $log = PhabricatorUserLog::initializeNewLog( $viewer, $viewer->getPHID(), PhabricatorUserLog::ACTION_FAIL_HISEC); $log->save(); } } } $token = $this->issueHighSecurityToken($session); if ($token) { return $token; } throw id(new PhabricatorAuthHighSecurityRequiredException()) ->setCancelURI($cancel_uri) ->setFactors($factors) ->setFactorValidationResults($validation_results); } /** * Issue a high security token for a session, if authorized. * * @param PhabricatorAuthSession Session to issue a token for. * @param bool Force token issue. * @return PhabricatorAuthHighSecurityToken|null Token, if authorized. * @task hisec */ private function issueHighSecurityToken( PhabricatorAuthSession $session, $force = false) { $until = $session->getHighSecurityUntil(); if ($until > time() || $force) { return new PhabricatorAuthHighSecurityToken(); } return null; } /** * Render a form for providing relevant multi-factor credentials. * * @param PhabricatorUser Viewing user. * @param AphrontRequest Current request. * @return AphrontFormView Renderable form. * @task hisec */ public function renderHighSecurityForm( array $factors, array $validation_results, PhabricatorUser $viewer, AphrontRequest $request) { $form = id(new AphrontFormView()) ->setUser($viewer) ->appendRemarkupInstructions(''); foreach ($factors as $factor) { $factor->requireImplementation()->renderValidateFactorForm( $factor, $form, $viewer, idx($validation_results, $factor->getID())); } $form->appendRemarkupInstructions(''); return $form; } /** * Strip the high security flag from a session. * * Kicks a session out of high security and logs the exit. * * @param PhabricatorUser Acting user. * @param PhabricatorAuthSession Session to return to normal security. * @return void * @task hisec */ public function exitHighSecurity( PhabricatorUser $viewer, PhabricatorAuthSession $session) { if (!$session->getHighSecurityUntil()) { return; } queryfx( $session->establishConnection('w'), 'UPDATE %T SET highSecurityUntil = NULL WHERE id = %d', $session->getTableName(), $session->getID()); $log = PhabricatorUserLog::initializeNewLog( $viewer, $viewer->getPHID(), PhabricatorUserLog::ACTION_EXIT_HISEC); $log->save(); } /* -( Partial Sessions )--------------------------------------------------- */ /** * Upgrade a partial session to a full session. * * @param PhabricatorAuthSession Session to upgrade. * @return void * @task partial */ public function upgradePartialSession(PhabricatorUser $viewer) { if (!$viewer->hasSession()) { throw new Exception( pht('Upgrading partial session of user with no session!')); } $session = $viewer->getSession(); if (!$session->getIsPartial()) { throw new Exception(pht('Session is not partial!')); } $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $session->setIsPartial(0); queryfx( $session->establishConnection('w'), 'UPDATE %T SET isPartial = %d WHERE id = %d', $session->getTableName(), 0, $session->getID()); $log = PhabricatorUserLog::initializeNewLog( $viewer, $viewer->getPHID(), PhabricatorUserLog::ACTION_LOGIN_FULL); $log->save(); unset($unguarded); } /* -( Legalpad Documents )-------------------------------------------------- */ /** * Upgrade a session to have all legalpad documents signed. * * @param PhabricatorUser User whose session should upgrade. * @param array LegalpadDocument objects * @return void * @task partial */ public function signLegalpadDocuments(PhabricatorUser $viewer, array $docs) { if (!$viewer->hasSession()) { throw new Exception( pht('Signing session legalpad documents of user with no session!')); } $session = $viewer->getSession(); if ($session->getSignedLegalpadDocuments()) { throw new Exception(pht( 'Session has already signed required legalpad documents!')); } $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $session->setSignedLegalpadDocuments(1); queryfx( $session->establishConnection('w'), 'UPDATE %T SET signedLegalpadDocuments = %d WHERE id = %d', $session->getTableName(), 1, $session->getID()); if (!empty($docs)) { $log = PhabricatorUserLog::initializeNewLog( $viewer, $viewer->getPHID(), PhabricatorUserLog::ACTION_LOGIN_LEGALPAD); $log->save(); } unset($unguarded); } /* -( One Time Login URIs )------------------------------------------------ */ /** * Retrieve a temporary, one-time URI which can log in to an account. * * These URIs are used for password recovery and to regain access to accounts * which users have been locked out of. * * @param PhabricatorUser User to generate a URI for. * @param PhabricatorUserEmail Optionally, email to verify when * link is used. * @param string Optional context string for the URI. This is purely cosmetic * and used only to customize workflow and error messages. * @return string Login URI. * @task onetime */ public function getOneTimeLoginURI( PhabricatorUser $user, PhabricatorUserEmail $email = null, $type = self::ONETIME_RESET) { $key = Filesystem::readRandomCharacters(32); $key_hash = $this->getOneTimeLoginKeyHash($user, $email, $key); $onetime_type = PhabricatorAuthOneTimeLoginTemporaryTokenType::TOKENTYPE; $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); id(new PhabricatorAuthTemporaryToken()) ->setTokenResource($user->getPHID()) ->setTokenType($onetime_type) ->setTokenExpires(time() + phutil_units('1 day in seconds')) ->setTokenCode($key_hash) ->save(); unset($unguarded); $uri = '/login/once/'.$type.'/'.$user->getID().'/'.$key.'/'; if ($email) { $uri = $uri.$email->getID().'/'; } try { $uri = PhabricatorEnv::getProductionURI($uri); } catch (Exception $ex) { // If a user runs `bin/auth recover` before configuring the base URI, // just show the path. We don't have any way to figure out the domain. // See T4132. } return $uri; } /** * Load the temporary token associated with a given one-time login key. * * @param PhabricatorUser User to load the token for. * @param PhabricatorUserEmail Optionally, email to verify when * link is used. * @param string Key user is presenting as a valid one-time login key. * @return PhabricatorAuthTemporaryToken|null Token, if one exists. * @task onetime */ public function loadOneTimeLoginKey( PhabricatorUser $user, PhabricatorUserEmail $email = null, $key = null) { $key_hash = $this->getOneTimeLoginKeyHash($user, $email, $key); $onetime_type = PhabricatorAuthOneTimeLoginTemporaryTokenType::TOKENTYPE; return id(new PhabricatorAuthTemporaryTokenQuery()) ->setViewer($user) ->withTokenResources(array($user->getPHID())) ->withTokenTypes(array($onetime_type)) ->withTokenCodes(array($key_hash)) ->withExpired(false) ->executeOne(); } /** * Hash a one-time login key for storage as a temporary token. * * @param PhabricatorUser User this key is for. * @param PhabricatorUserEmail Optionally, email to verify when * link is used. * @param string The one time login key. * @return string Hash of the key. * task onetime */ private function getOneTimeLoginKeyHash( PhabricatorUser $user, PhabricatorUserEmail $email = null, $key = null) { $parts = array( $key, $user->getAccountSecret(), ); if ($email) { $parts[] = $email->getVerificationCode(); } return PhabricatorHash::digest(implode(':', $parts)); } /* -( User Cache )--------------------------------------------------------- */ /** * @task cache */ private function getUserCacheQueryParts(AphrontDatabaseConnection $conn) { $cache_selects = array(); $cache_joins = array(); $cache_map = array(); $keys = array(); $types_map = array(); $cache_types = PhabricatorUserCacheType::getAllCacheTypes(); foreach ($cache_types as $cache_type) { foreach ($cache_type->getAutoloadKeys() as $autoload_key) { $keys[] = $autoload_key; $types_map[$autoload_key] = $cache_type; } } $cache_table = id(new PhabricatorUserCache())->getTableName(); $cache_idx = 1; foreach ($keys as $key) { $join_as = 'ucache_'.$cache_idx; $select_as = 'ucache_'.$cache_idx.'_v'; $cache_selects[] = qsprintf( $conn, '%T.cacheData %T', $join_as, $select_as); $cache_joins[] = qsprintf( $conn, 'LEFT JOIN %T AS %T ON u.phid = %T.userPHID AND %T.cacheIndex = %s', $cache_table, $join_as, $join_as, $join_as, PhabricatorHash::digestForIndex($key)); $cache_map[$select_as] = $key; $cache_idx++; } if ($cache_selects) { $cache_selects = ', '.implode(', ', $cache_selects); } else { $cache_selects = ''; } if ($cache_joins) { $cache_joins = implode(' ', $cache_joins); } else { $cache_joins = ''; } return array($cache_selects, $cache_joins, $cache_map, $types_map); } private function filterRawCacheData( PhabricatorUser $user, array $types_map, array $cache_raw) { foreach ($cache_raw as $cache_key => $cache_data) { $type = $types_map[$cache_key]; if ($type->shouldValidateRawCacheData()) { if (!$type->isRawCacheDataValid($user, $cache_key, $cache_data)) { unset($cache_raw[$cache_key]); } } } return $cache_raw; } + public function willServeRequestForUser(PhabricatorUser $user) { + // We allow the login user to generate any missing cache data inline. + $user->setAllowInlineCacheGeneration(true); + + // Switch to the user's translation. + PhabricatorEnv::setLocaleCode($user->getTranslation()); + } + } diff --git a/src/applications/base/controller/PhabricatorController.php b/src/applications/base/controller/PhabricatorController.php index 83e223d19..08966f29a 100644 --- a/src/applications/base/controller/PhabricatorController.php +++ b/src/applications/base/controller/PhabricatorController.php @@ -1,614 +1,615 @@ shouldRequireLogin()) { return false; } if (!$this->shouldRequireEnabledUser()) { return false; } if ($this->shouldAllowPartialSessions()) { return false; } $user = $this->getRequest()->getUser(); if (!$user->getIsStandardUser()) { return false; } return PhabricatorEnv::getEnvConfig('security.require-multi-factor-auth'); } public function shouldAllowLegallyNonCompliantUsers() { return false; } public function isGlobalDragAndDropUploadEnabled() { return false; } public function willBeginExecution() { $request = $this->getRequest(); if ($request->getUser()) { // NOTE: Unit tests can set a user explicitly. Normal requests are not // permitted to do this. PhabricatorTestCase::assertExecutingUnitTests(); $user = $request->getUser(); } else { $user = new PhabricatorUser(); $session_engine = new PhabricatorAuthSessionEngine(); $phsid = $request->getCookie(PhabricatorCookies::COOKIE_SESSION); if (strlen($phsid)) { $session_user = $session_engine->loadUserForSession( PhabricatorAuthSession::TYPE_WEB, $phsid); if ($session_user) { $user = $session_user; } } else { // If the client doesn't have a session token, generate an anonymous // session. This is used to provide CSRF protection to logged-out users. $phsid = $session_engine->establishSession( PhabricatorAuthSession::TYPE_WEB, null, $partial = false); // This may be a resource request, in which case we just don't set // the cookie. if ($request->canSetCookies()) { $request->setCookie(PhabricatorCookies::COOKIE_SESSION, $phsid); } } if (!$user->isLoggedIn()) { $user->attachAlternateCSRFString(PhabricatorHash::digest($phsid)); } $request->setUser($user); } - PhabricatorEnv::setLocaleCode($user->getTranslation()); + id(new PhabricatorAuthSessionEngine()) + ->willServeRequestForUser($user); if (PhabricatorEnv::getEnvConfig('darkconsole.enabled')) { $dark_console = PhabricatorDarkConsoleSetting::SETTINGKEY; if ($user->getUserSetting($dark_console) || PhabricatorEnv::getEnvConfig('darkconsole.always-on')) { $console = new DarkConsoleCore(); $request->getApplicationConfiguration()->setConsole($console); } } // NOTE: We want to set up the user first so we can render a real page // here, but fire this before any real logic. $restricted = array( 'code', ); foreach ($restricted as $parameter) { if ($request->getExists($parameter)) { if (!$this->shouldAllowRestrictedParameter($parameter)) { throw new Exception( pht( 'Request includes restricted parameter "%s", but this '. 'controller ("%s") does not whitelist it. Refusing to '. 'serve this request because it might be part of a redirection '. 'attack.', $parameter, get_class($this))); } } } if ($this->shouldRequireEnabledUser()) { if ($user->isLoggedIn() && !$user->getIsApproved()) { $controller = new PhabricatorAuthNeedsApprovalController(); return $this->delegateToController($controller); } if ($user->getIsDisabled()) { $controller = new PhabricatorDisabledUserController(); return $this->delegateToController($controller); } } $auth_class = 'PhabricatorAuthApplication'; $auth_application = PhabricatorApplication::getByClass($auth_class); // Require partial sessions to finish login before doing anything. if (!$this->shouldAllowPartialSessions()) { if ($user->hasSession() && $user->getSession()->getIsPartial()) { $login_controller = new PhabricatorAuthFinishController(); $this->setCurrentApplication($auth_application); return $this->delegateToController($login_controller); } } // Check if the user needs to configure MFA. $need_mfa = $this->shouldRequireMultiFactorEnrollment(); $have_mfa = $user->getIsEnrolledInMultiFactor(); if ($need_mfa && !$have_mfa) { // Check if the cache is just out of date. Otherwise, roadblock the user // and require MFA enrollment. $user->updateMultiFactorEnrollment(); if (!$user->getIsEnrolledInMultiFactor()) { $mfa_controller = new PhabricatorAuthNeedsMultiFactorController(); $this->setCurrentApplication($auth_application); return $this->delegateToController($mfa_controller); } } if ($this->shouldRequireLogin()) { // This actually means we need either: // - a valid user, or a public controller; and // - permission to see the application; and // - permission to see at least one Space if spaces are configured. $allow_public = $this->shouldAllowPublic() && PhabricatorEnv::getEnvConfig('policy.allow-public'); // If this controller isn't public, and the user isn't logged in, require // login. if (!$allow_public && !$user->isLoggedIn()) { $login_controller = new PhabricatorAuthStartController(); $this->setCurrentApplication($auth_application); return $this->delegateToController($login_controller); } if ($user->isLoggedIn()) { if ($this->shouldRequireEmailVerification()) { if (!$user->getIsEmailVerified()) { $controller = new PhabricatorMustVerifyEmailController(); $this->setCurrentApplication($auth_application); return $this->delegateToController($controller); } } } // If Spaces are configured, require that the user have access to at // least one. If we don't do this, they'll get confusing error messages // later on. $spaces = PhabricatorSpacesNamespaceQuery::getSpacesExist(); if ($spaces) { $viewer_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces( $user); if (!$viewer_spaces) { $controller = new PhabricatorSpacesNoAccessController(); return $this->delegateToController($controller); } } // If the user doesn't have access to the application, don't let them use // any of its controllers. We query the application in order to generate // a policy exception if the viewer doesn't have permission. $application = $this->getCurrentApplication(); if ($application) { id(new PhabricatorApplicationQuery()) ->setViewer($user) ->withPHIDs(array($application->getPHID())) ->executeOne(); } } if (!$this->shouldAllowLegallyNonCompliantUsers()) { $legalpad_class = 'PhabricatorLegalpadApplication'; $legalpad = id(new PhabricatorApplicationQuery()) ->setViewer($user) ->withClasses(array($legalpad_class)) ->withInstalled(true) ->execute(); $legalpad = head($legalpad); $doc_query = id(new LegalpadDocumentQuery()) ->setViewer($user) ->withSignatureRequired(1) ->needViewerSignatures(true); if ($user->hasSession() && !$user->getSession()->getIsPartial() && !$user->getSession()->getSignedLegalpadDocuments() && $user->isLoggedIn() && $legalpad) { $sign_docs = $doc_query->execute(); $must_sign_docs = array(); foreach ($sign_docs as $sign_doc) { if (!$sign_doc->getUserSignature($user->getPHID())) { $must_sign_docs[] = $sign_doc; } } if ($must_sign_docs) { $controller = new LegalpadDocumentSignController(); $this->getRequest()->setURIMap(array( 'id' => head($must_sign_docs)->getID(), )); $this->setCurrentApplication($legalpad); return $this->delegateToController($controller); } else { $engine = id(new PhabricatorAuthSessionEngine()) ->signLegalpadDocuments($user, $sign_docs); } } } // NOTE: We do this last so that users get a login page instead of a 403 // if they need to login. if ($this->shouldRequireAdmin() && !$user->getIsAdmin()) { return new Aphront403Response(); } } public function getApplicationURI($path = '') { if (!$this->getCurrentApplication()) { throw new Exception(pht('No application!')); } return $this->getCurrentApplication()->getApplicationURI($path); } public function willSendResponse(AphrontResponse $response) { $request = $this->getRequest(); if ($response instanceof AphrontDialogResponse) { if (!$request->isAjax() && !$request->isQuicksand()) { $dialog = $response->getDialog(); $title = $dialog->getTitle(); $short = $dialog->getShortTitle(); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(coalesce($short, $title)); $page_content = array( $crumbs, $response->buildResponseString(), ); $view = id(new PhabricatorStandardPageView()) ->setRequest($request) ->setController($this) ->setDeviceReady(true) ->setTitle($title) ->appendChild($page_content); $response = id(new AphrontWebpageResponse()) ->setContent($view->render()) ->setHTTPResponseCode($response->getHTTPResponseCode()); } else { $response->getDialog()->setIsStandalone(true); return id(new AphrontAjaxResponse()) ->setContent(array( 'dialog' => $response->buildResponseString(), )); } } else if ($response instanceof AphrontRedirectResponse) { if ($request->isAjax() || $request->isQuicksand()) { return id(new AphrontAjaxResponse()) ->setContent( array( 'redirect' => $response->getURI(), )); } } return $response; } /** * WARNING: Do not call this in new code. * * @deprecated See "Handles Technical Documentation". */ protected function loadViewerHandles(array $phids) { return id(new PhabricatorHandleQuery()) ->setViewer($this->getRequest()->getUser()) ->withPHIDs($phids) ->execute(); } public function buildApplicationMenu() { return null; } protected function buildApplicationCrumbs() { $crumbs = array(); $application = $this->getCurrentApplication(); if ($application) { $icon = $application->getIcon(); if (!$icon) { $icon = 'fa-puzzle'; } $crumbs[] = id(new PHUICrumbView()) ->setHref($this->getApplicationURI()) ->setName($application->getName()) ->setIcon($icon); } $view = new PHUICrumbsView(); foreach ($crumbs as $crumb) { $view->addCrumb($crumb); } return $view; } protected function hasApplicationCapability($capability) { return PhabricatorPolicyFilter::hasCapability( $this->getRequest()->getUser(), $this->getCurrentApplication(), $capability); } protected function requireApplicationCapability($capability) { PhabricatorPolicyFilter::requireCapability( $this->getRequest()->getUser(), $this->getCurrentApplication(), $capability); } protected function explainApplicationCapability( $capability, $positive_message, $negative_message) { $can_act = $this->hasApplicationCapability($capability); if ($can_act) { $message = $positive_message; $icon_name = 'fa-play-circle-o lightgreytext'; } else { $message = $negative_message; $icon_name = 'fa-lock'; } $icon = id(new PHUIIconView()) ->setIcon($icon_name); require_celerity_resource('policy-css'); $phid = $this->getCurrentApplication()->getPHID(); $explain_uri = "/policy/explain/{$phid}/{$capability}/"; $message = phutil_tag( 'div', array( 'class' => 'policy-capability-explanation', ), array( $icon, javelin_tag( 'a', array( 'href' => $explain_uri, 'sigil' => 'workflow', ), $message), )); return array($can_act, $message); } public function getDefaultResourceSource() { return 'phabricator'; } /** * Create a new @{class:AphrontDialogView} with defaults filled in. * * @return AphrontDialogView New dialog. */ public function newDialog() { $submit_uri = new PhutilURI($this->getRequest()->getRequestURI()); $submit_uri = $submit_uri->getPath(); return id(new AphrontDialogView()) ->setUser($this->getRequest()->getUser()) ->setSubmitURI($submit_uri); } public function newPage() { $page = id(new PhabricatorStandardPageView()) ->setRequest($this->getRequest()) ->setController($this) ->setDeviceReady(true); $application = $this->getCurrentApplication(); if ($application) { $page->setApplicationName($application->getName()); if ($application->getTitleGlyph()) { $page->setGlyph($application->getTitleGlyph()); } } $viewer = $this->getRequest()->getUser(); if ($viewer) { $page->setUser($viewer); } return $page; } public function newApplicationMenu() { return id(new PHUIApplicationMenuView()) ->setViewer($this->getViewer()); } public function newCurtainView($object) { $viewer = $this->getViewer(); $action_list = id(new PhabricatorActionListView()) ->setViewer($viewer); // NOTE: Applications (objects of class PhabricatorApplication) can't // currently be set here, although they don't need any of the extensions // anyway. This should probably work differently than it does, though. if ($object instanceof PhabricatorLiskDAO) { $action_list->setObject($object); } $curtain = id(new PHUICurtainView()) ->setViewer($viewer) ->setActionList($action_list); $panels = PHUICurtainExtension::buildExtensionPanels($viewer, $object); foreach ($panels as $panel) { $curtain->addPanel($panel); } return $curtain; } protected function buildTransactionTimeline( PhabricatorApplicationTransactionInterface $object, PhabricatorApplicationTransactionQuery $query, PhabricatorMarkupEngine $engine = null, $render_data = array()) { $viewer = $this->getRequest()->getUser(); $xaction = $object->getApplicationTransactionTemplate(); $view = $xaction->getApplicationTransactionViewObject(); $pager = id(new AphrontCursorPagerView()) ->readFromRequest($this->getRequest()) ->setURI(new PhutilURI( '/transactions/showolder/'.$object->getPHID().'/')); $xactions = $query ->setViewer($viewer) ->withObjectPHIDs(array($object->getPHID())) ->needComments(true) ->executeWithCursorPager($pager); $xactions = array_reverse($xactions); if ($engine) { foreach ($xactions as $xaction) { if ($xaction->getComment()) { $engine->addObject( $xaction->getComment(), PhabricatorApplicationTransactionComment::MARKUP_FIELD_COMMENT); } } $engine->process(); $view->setMarkupEngine($engine); } $timeline = $view ->setUser($viewer) ->setObjectPHID($object->getPHID()) ->setTransactions($xactions) ->setPager($pager) ->setRenderData($render_data) ->setQuoteTargetID($this->getRequest()->getStr('quoteTargetID')) ->setQuoteRef($this->getRequest()->getStr('quoteRef')); $object->willRenderTimeline($timeline, $this->getRequest()); return $timeline; } public function buildApplicationCrumbsForEditEngine() { // TODO: This is kind of gross, I'm bascially just making this public so // I can use it in EditEngine. We could do this without making it public // by using controller delegation, or make it properly public. return $this->buildApplicationCrumbs(); } /* -( Deprecated )--------------------------------------------------------- */ /** * DEPRECATED. Use @{method:newPage}. */ public function buildStandardPageView() { return $this->newPage(); } /** * DEPRECATED. Use @{method:newPage}. */ public function buildStandardPageResponse($view, array $data) { $page = $this->buildStandardPageView(); $page->appendChild($view); return $page->produceAphrontResponse(); } /** * DEPRECATED. Use @{method:newPage}. */ public function buildApplicationPage($view, array $options) { $page = $this->newPage(); $title = PhabricatorEnv::getEnvConfig('phabricator.serious-business') ? 'Phabricator' : pht('Bacon Ice Cream for Breakfast'); $page->setTitle(idx($options, 'title', $title)); if (idx($options, 'class')) { $page->addClass($options['class']); } if (!($view instanceof AphrontSideNavFilterView)) { $nav = new AphrontSideNavFilterView(); $nav->appendChild($view); $view = $nav; } $page->appendChild($view); $object_phids = idx($options, 'pageObjects', array()); if ($object_phids) { $page->setPageObjectPHIDs($object_phids); } if (!idx($options, 'device', true)) { $page->setDeviceReady(false); } $page->setShowFooter(idx($options, 'showFooter', true)); $page->setShowChrome(idx($options, 'chrome', true)); return $page->produceAphrontResponse(); } } diff --git a/src/applications/conduit/controller/PhabricatorConduitAPIController.php b/src/applications/conduit/controller/PhabricatorConduitAPIController.php index 690f6cc1d..b9e8b1b15 100644 --- a/src/applications/conduit/controller/PhabricatorConduitAPIController.php +++ b/src/applications/conduit/controller/PhabricatorConduitAPIController.php @@ -1,703 +1,707 @@ getURIData('method'); $time_start = microtime(true); $api_request = null; $method_implementation = null; $log = new PhabricatorConduitMethodCallLog(); $log->setMethod($method); $metadata = array(); $multimeter = MultimeterControl::getInstance(); if ($multimeter) { $multimeter->setEventContext('api.'.$method); } try { list($metadata, $params) = $this->decodeConduitParams($request, $method); $call = new ConduitCall($method, $params); $method_implementation = $call->getMethodImplementation(); $result = null; // TODO: The relationship between ConduitAPIRequest and ConduitCall is a // little odd here and could probably be improved. Specifically, the // APIRequest is a sub-object of the Call, which does not parallel the // role of AphrontRequest (which is an indepenent object). // In particular, the setUser() and getUser() existing independently on // the Call and APIRequest is very awkward. $api_request = $call->getAPIRequest(); $allow_unguarded_writes = false; $auth_error = null; $conduit_username = '-'; if ($call->shouldRequireAuthentication()) { $auth_error = $this->authenticateUser($api_request, $metadata, $method); // If we've explicitly authenticated the user here and either done // CSRF validation or are using a non-web authentication mechanism. $allow_unguarded_writes = true; if ($auth_error === null) { $conduit_user = $api_request->getUser(); if ($conduit_user && $conduit_user->getPHID()) { $conduit_username = $conduit_user->getUsername(); } $call->setUser($api_request->getUser()); } } $access_log = PhabricatorAccessLog::getLog(); if ($access_log) { $access_log->setData( array( 'u' => $conduit_username, 'm' => $method, )); } if ($call->shouldAllowUnguardedWrites()) { $allow_unguarded_writes = true; } if ($auth_error === null) { if ($allow_unguarded_writes) { $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); } try { $result = $call->execute(); $error_code = null; $error_info = null; } catch (ConduitException $ex) { $result = null; $error_code = $ex->getMessage(); if ($ex->getErrorDescription()) { $error_info = $ex->getErrorDescription(); } else { $error_info = $call->getErrorDescription($error_code); } } if ($allow_unguarded_writes) { unset($unguarded); } } else { list($error_code, $error_info) = $auth_error; } } catch (Exception $ex) { if (!($ex instanceof ConduitMethodNotFoundException)) { phlog($ex); } $result = null; $error_code = ($ex instanceof ConduitException ? 'ERR-CONDUIT-CALL' : 'ERR-CONDUIT-CORE'); $error_info = $ex->getMessage(); } $time_end = microtime(true); $log ->setCallerPHID( isset($conduit_user) ? $conduit_user->getPHID() : null) ->setError((string)$error_code) ->setDuration(1000000 * ($time_end - $time_start)); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $log->save(); unset($unguarded); $response = id(new ConduitAPIResponse()) ->setResult($result) ->setErrorCode($error_code) ->setErrorInfo($error_info); switch ($request->getStr('output')) { case 'human': return $this->buildHumanReadableResponse( $method, $api_request, $response->toDictionary(), $method_implementation); case 'json': default: return id(new AphrontJSONResponse()) ->setAddJSONShield(false) ->setContent($response->toDictionary()); } } /** * Authenticate the client making the request to a Phabricator user account. * * @param ConduitAPIRequest Request being executed. * @param dict Request metadata. * @return null|pair Null to indicate successful authentication, or * an error code and error message pair. */ private function authenticateUser( ConduitAPIRequest $api_request, array $metadata, $method) { $request = $this->getRequest(); if ($request->getUser()->getPHID()) { $request->validateCSRF(); return $this->validateAuthenticatedUser( $api_request, $request->getUser()); } $auth_type = idx($metadata, 'auth.type'); if ($auth_type === ConduitClient::AUTH_ASYMMETRIC) { $host = idx($metadata, 'auth.host'); if (!$host) { return array( 'ERR-INVALID-AUTH', pht( 'Request is missing required "%s" parameter.', 'auth.host'), ); } // TODO: Validate that we are the host! $raw_key = idx($metadata, 'auth.key'); $public_key = PhabricatorAuthSSHPublicKey::newFromRawKey($raw_key); $ssl_public_key = $public_key->toPKCS8(); // First, verify the signature. try { $protocol_data = $metadata; ConduitClient::verifySignature( $method, $api_request->getAllParameters(), $protocol_data, $ssl_public_key); } catch (Exception $ex) { return array( 'ERR-INVALID-AUTH', pht( 'Signature verification failure. %s', $ex->getMessage()), ); } // If the signature is valid, find the user or device which is // associated with this public key. $stored_key = id(new PhabricatorAuthSSHKeyQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withKeys(array($public_key)) ->withIsActive(true) ->executeOne(); if (!$stored_key) { return array( 'ERR-INVALID-AUTH', pht('No user or device is associated with that public key.'), ); } $object = $stored_key->getObject(); if ($object instanceof PhabricatorUser) { $user = $object; } else { if (!$stored_key->getIsTrusted()) { return array( 'ERR-INVALID-AUTH', pht( 'The key which signed this request is not trusted. Only '. 'trusted keys can be used to sign API calls.'), ); } if (!PhabricatorEnv::isClusterRemoteAddress()) { return array( 'ERR-INVALID-AUTH', pht( 'This request originates from outside of the Phabricator '. 'cluster address range. Requests signed with trusted '. 'device keys must originate from within the cluster.'), ); } $user = PhabricatorUser::getOmnipotentUser(); // Flag this as an intracluster request. $api_request->setIsClusterRequest(true); } return $this->validateAuthenticatedUser( $api_request, $user); } else if ($auth_type === null) { // No specified authentication type, continue with other authentication // methods below. } else { return array( 'ERR-INVALID-AUTH', pht( 'Provided "%s" ("%s") is not recognized.', 'auth.type', $auth_type), ); } $token_string = idx($metadata, 'token'); if (strlen($token_string)) { if (strlen($token_string) != 32) { return array( 'ERR-INVALID-AUTH', pht( 'API token "%s" has the wrong length. API tokens should be '. '32 characters long.', $token_string), ); } $type = head(explode('-', $token_string)); $valid_types = PhabricatorConduitToken::getAllTokenTypes(); $valid_types = array_fuse($valid_types); if (empty($valid_types[$type])) { return array( 'ERR-INVALID-AUTH', pht( 'API token "%s" has the wrong format. API tokens should be '. '32 characters long and begin with one of these prefixes: %s.', $token_string, implode(', ', $valid_types)), ); } $token = id(new PhabricatorConduitTokenQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withTokens(array($token_string)) ->withExpired(false) ->executeOne(); if (!$token) { $token = id(new PhabricatorConduitTokenQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withTokens(array($token_string)) ->withExpired(true) ->executeOne(); if ($token) { return array( 'ERR-INVALID-AUTH', pht( 'API token "%s" was previously valid, but has expired.', $token_string), ); } else { return array( 'ERR-INVALID-AUTH', pht( 'API token "%s" is not valid.', $token_string), ); } } // If this is a "cli-" token, it expires shortly after it is generated // by default. Once it is actually used, we extend its lifetime and make // it permanent. This allows stray tokens to get cleaned up automatically // if they aren't being used. if ($token->getTokenType() == PhabricatorConduitToken::TYPE_COMMANDLINE) { if ($token->getExpires()) { $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $token->setExpires(null); $token->save(); unset($unguarded); } } // If this is a "clr-" token, Phabricator must be configured in cluster // mode and the remote address must be a cluster node. if ($token->getTokenType() == PhabricatorConduitToken::TYPE_CLUSTER) { if (!PhabricatorEnv::isClusterRemoteAddress()) { return array( 'ERR-INVALID-AUTH', pht( 'This request originates from outside of the Phabricator '. 'cluster address range. Requests signed with cluster API '. 'tokens must originate from within the cluster.'), ); } // Flag this as an intracluster request. $api_request->setIsClusterRequest(true); } $user = $token->getObject(); if (!($user instanceof PhabricatorUser)) { return array( 'ERR-INVALID-AUTH', pht('API token is not associated with a valid user.'), ); } return $this->validateAuthenticatedUser( $api_request, $user); } $access_token = idx($metadata, 'access_token'); if ($access_token) { $token = id(new PhabricatorOAuthServerAccessToken()) ->loadOneWhere('token = %s', $access_token); if (!$token) { return array( 'ERR-INVALID-AUTH', pht('Access token does not exist.'), ); } $oauth_server = new PhabricatorOAuthServer(); $authorization = $oauth_server->authorizeToken($token); if (!$authorization) { return array( 'ERR-INVALID-AUTH', pht('Access token is invalid or expired.'), ); } $user = id(new PhabricatorPeopleQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs(array($token->getUserPHID())) ->executeOne(); if (!$user) { return array( 'ERR-INVALID-AUTH', pht('Access token is for invalid user.'), ); } $ok = $this->authorizeOAuthMethodAccess($authorization, $method); if (!$ok) { return array( 'ERR-OAUTH-ACCESS', pht('You do not have authorization to call this method.'), ); } $api_request->setOAuthToken($token); return $this->validateAuthenticatedUser( $api_request, $user); } // For intracluster requests, use a public user if no authentication // information is provided. We could do this safely for any request, // but making the API fully public means there's no way to disable badly // behaved clients. if (PhabricatorEnv::isClusterRemoteAddress()) { if (PhabricatorEnv::getEnvConfig('policy.allow-public')) { $api_request->setIsClusterRequest(true); $user = new PhabricatorUser(); return $this->validateAuthenticatedUser( $api_request, $user); } } // Handle sessionless auth. // TODO: This is super messy. // TODO: Remove this in favor of token-based auth. if (isset($metadata['authUser'])) { $user = id(new PhabricatorUser())->loadOneWhere( 'userName = %s', $metadata['authUser']); if (!$user) { return array( 'ERR-INVALID-AUTH', pht('Authentication is invalid.'), ); } $token = idx($metadata, 'authToken'); $signature = idx($metadata, 'authSignature'); $certificate = $user->getConduitCertificate(); $hash = sha1($token.$certificate); if (!phutil_hashes_are_identical($hash, $signature)) { return array( 'ERR-INVALID-AUTH', pht('Authentication is invalid.'), ); } return $this->validateAuthenticatedUser( $api_request, $user); } // Handle session-based auth. // TODO: Remove this in favor of token-based auth. $session_key = idx($metadata, 'sessionKey'); if (!$session_key) { return array( 'ERR-INVALID-SESSION', pht('Session key is not present.'), ); } $user = id(new PhabricatorAuthSessionEngine()) ->loadUserForSession(PhabricatorAuthSession::TYPE_CONDUIT, $session_key); if (!$user) { return array( 'ERR-INVALID-SESSION', pht('Session key is invalid.'), ); } return $this->validateAuthenticatedUser( $api_request, $user); } private function validateAuthenticatedUser( ConduitAPIRequest $request, PhabricatorUser $user) { if (!$user->canEstablishAPISessions()) { return array( 'ERR-INVALID-AUTH', pht('User account is not permitted to use the API.'), ); } $request->setUser($user); + + id(new PhabricatorAuthSessionEngine()) + ->willServeRequestForUser($user); + return null; } private function buildHumanReadableResponse( $method, ConduitAPIRequest $request = null, $result = null, ConduitAPIMethod $method_implementation = null) { $param_rows = array(); $param_rows[] = array('Method', $this->renderAPIValue($method)); if ($request) { foreach ($request->getAllParameters() as $key => $value) { $param_rows[] = array( $key, $this->renderAPIValue($value), ); } } $param_table = new AphrontTableView($param_rows); $param_table->setColumnClasses( array( 'header', 'wide', )); $result_rows = array(); foreach ($result as $key => $value) { $result_rows[] = array( $key, $this->renderAPIValue($value), ); } $result_table = new AphrontTableView($result_rows); $result_table->setColumnClasses( array( 'header', 'wide', )); $param_panel = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Method Parameters')) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setTable($param_table); $result_panel = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Method Result')) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setTable($result_table); $method_uri = $this->getApplicationURI('method/'.$method.'/'); $crumbs = $this->buildApplicationCrumbs() ->addTextCrumb($method, $method_uri) ->addTextCrumb(pht('Call')) ->setBorder(true); $example_panel = null; if ($request && $method_implementation) { $params = $request->getAllParameters(); $example_panel = $this->renderExampleBox( $method_implementation, $params); } $title = pht('Method Call Result'); $header = id(new PHUIHeaderView()) ->setHeader($title) ->setHeaderIcon('fa-exchange'); $view = id(new PHUITwoColumnView()) ->setHeader($header) ->setFooter(array( $param_panel, $result_panel, $example_panel, )); $title = pht('Method Call Result'); return $this->newPage() ->setTitle($title) ->setCrumbs($crumbs) ->appendChild($view); } private function renderAPIValue($value) { $json = new PhutilJSON(); if (is_array($value)) { $value = $json->encodeFormatted($value); } $value = phutil_tag( 'pre', array('style' => 'white-space: pre-wrap;'), $value); return $value; } private function decodeConduitParams( AphrontRequest $request, $method) { // Look for parameters from the Conduit API Console, which are encoded // as HTTP POST parameters in an array, e.g.: // // params[name]=value¶ms[name2]=value2 // // The fields are individually JSON encoded, since we require users to // enter JSON so that we avoid type ambiguity. $params = $request->getArr('params', null); if ($params !== null) { foreach ($params as $key => $value) { if ($value == '') { // Interpret empty string null (e.g., the user didn't type anything // into the box). $value = 'null'; } $decoded_value = json_decode($value, true); if ($decoded_value === null && strtolower($value) != 'null') { // When json_decode() fails, it returns null. This almost certainly // indicates that a user was using the web UI and didn't put quotes // around a string value. We can either do what we think they meant // (treat it as a string) or fail. For now, err on the side of // caution and fail. In the future, if we make the Conduit API // actually do type checking, it might be reasonable to treat it as // a string if the parameter type is string. throw new Exception( pht( "The value for parameter '%s' is not valid JSON. All ". "parameters must be encoded as JSON values, including strings ". "(which means you need to surround them in double quotes). ". "Check your syntax. Value was: %s.", $key, $value)); } $params[$key] = $decoded_value; } $metadata = idx($params, '__conduit__', array()); unset($params['__conduit__']); return array($metadata, $params); } // Otherwise, look for a single parameter called 'params' which has the // entire param dictionary JSON encoded. $params_json = $request->getStr('params'); if (strlen($params_json)) { $params = null; try { $params = phutil_json_decode($params_json); } catch (PhutilJSONParserException $ex) { throw new PhutilProxyException( pht( "Invalid parameter information was passed to method '%s'.", $method), $ex); } $metadata = idx($params, '__conduit__', array()); unset($params['__conduit__']); return array($metadata, $params); } // If we do not have `params`, assume this is a simple HTTP request with // HTTP key-value pairs. $params = array(); $metadata = array(); foreach ($request->getPassthroughRequestData() as $key => $value) { $meta_key = ConduitAPIMethod::getParameterMetadataKey($key); if ($meta_key !== null) { $metadata[$meta_key] = $value; } else { $params[$key] = $value; } } return array($metadata, $params); } private function authorizeOAuthMethodAccess( PhabricatorOAuthClientAuthorization $authorization, $method_name) { $method = ConduitAPIMethod::getConduitMethod($method_name); if (!$method) { return false; } $required_scope = $method->getRequiredScope(); switch ($required_scope) { case ConduitAPIMethod::SCOPE_ALWAYS: return true; case ConduitAPIMethod::SCOPE_NEVER: return false; } $authorization_scope = $authorization->getScope(); if (!empty($authorization_scope[$required_scope])) { return true; } return false; } }