diff --git a/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php b/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php index 8da9b22b23..5a56efb255 100644 --- a/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php +++ b/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php @@ -1,310 +1,312 @@ getResourceURIMapRules() + array( '/~/' => array( '' => 'DarkConsoleController', 'data/(?P[^/]+)/' => 'DarkConsoleDataController', ), ); } protected function getResourceURIMapRules() { return array( '/res/' => array( '(?:(?P[0-9]+)T/)?'. '(?P[^/]+)/'. '(?P[a-f0-9]{8})/'. '(?P.+\.(?:css|js|jpg|png|swf|gif|woff))' => 'CelerityPhabricatorResourceController', ), ); } /** * @phutil-external-symbol class PhabricatorStartup */ public function buildRequest() { $parser = new PhutilQueryStringParser(); $data = array(); // If the request has "multipart/form-data" content, we can't use // PhutilQueryStringParser to parse it, and the raw data supposedly is not // available anyway (according to the PHP documentation, "php://input" is // not available for "multipart/form-data" requests). However, it is // available at least some of the time (see T3673), so double check that // we aren't trying to parse data we won't be able to parse correctly by // examining the Content-Type header. $content_type = idx($_SERVER, 'CONTENT_TYPE'); $is_form_data = preg_match('@^multipart/form-data@i', $content_type); $raw_input = PhabricatorStartup::getRawInput(); if (strlen($raw_input) && !$is_form_data) { $data += $parser->parseQueryString($raw_input); } else if ($_POST) { $data += $_POST; } $data += $parser->parseQueryString(idx($_SERVER, 'QUERY_STRING', '')); $cookie_prefix = PhabricatorEnv::getEnvConfig('phabricator.cookie-prefix'); $request = new AphrontRequest($this->getHost(), $this->getPath()); $request->setRequestData($data); $request->setApplicationConfiguration($this); $request->setCookiePrefix($cookie_prefix); return $request; } public function handleException(Exception $ex) { $request = $this->getRequest(); // For Conduit requests, return a Conduit response. if ($request->isConduit()) { $response = new ConduitAPIResponse(); $response->setErrorCode(get_class($ex)); $response->setErrorInfo($ex->getMessage()); return id(new AphrontJSONResponse()) ->setAddJSONShield(false) ->setContent($response->toDictionary()); } // For non-workflow requests, return a Ajax response. if ($request->isAjax() && !$request->isJavelinWorkflow()) { // Log these; they don't get shown on the client and can be difficult // to debug. phlog($ex); $response = new AphrontAjaxResponse(); $response->setError( array( 'code' => get_class($ex), 'info' => $ex->getMessage(), )); return $response; } $user = $request->getUser(); if (!$user) { // If we hit an exception very early, we won't have a user. $user = new PhabricatorUser(); } if ($ex instanceof PhabricatorSystemActionRateLimitException) { $dialog = id(new AphrontDialogView()) ->setTitle(pht('Slow Down!')) ->setUser($user) ->setErrors(array(pht('You are being rate limited.'))) ->appendParagraph($ex->getMessage()) ->appendParagraph($ex->getRateExplanation()) ->addCancelButton('/', pht('Okaaaaaaaaaaaaaay...')); $response = new AphrontDialogResponse(); $response->setDialog($dialog); return $response; } if ($ex instanceof PhabricatorAuthHighSecurityRequiredException) { $form = id(new PhabricatorAuthSessionEngine())->renderHighSecurityForm( + $ex->getFactors(), + $ex->getFactorValidationResults(), $user, $request); $dialog = id(new AphrontDialogView()) ->setUser($user) ->setTitle(pht('Entering High Security')) ->setShortTitle(pht('Security Checkpoint')) ->setWidth(AphrontDialogView::WIDTH_FORM) ->addHiddenInput(AphrontRequest::TYPE_HISEC, true) ->setErrors( array( pht( 'You are taking an action which requires you to enter '. 'high security.'), )) ->appendParagraph( pht( 'High security mode helps protect your account from security '. 'threats, like session theft or someone messing with your stuff '. 'while you\'re grabbing a coffee. To enter high security mode, '. 'confirm your credentials.')) ->appendChild($form->buildLayoutView()) ->appendParagraph( pht( 'Your account will remain in high security mode for a short '. 'period of time. When you are finished taking sensitive '. 'actions, you should leave high security.')) ->setSubmitURI($request->getPath()) ->addCancelButton($ex->getCancelURI()) ->addSubmitButton(pht('Enter High Security')); foreach ($request->getPassthroughRequestParameters() as $key => $value) { $dialog->addHiddenInput($key, $value); } $response = new AphrontDialogResponse(); $response->setDialog($dialog); return $response; } if ($ex instanceof PhabricatorPolicyException) { if (!$user->isLoggedIn()) { // If the user isn't logged in, just give them a login form. This is // probably a generally more useful response than a policy dialog that // they have to click through to get a login form. // // Possibly we should add a header here like "you need to login to see // the thing you are trying to look at". $login_controller = new PhabricatorAuthStartController($request); $auth_app_class = 'PhabricatorApplicationAuth'; $auth_app = PhabricatorApplication::getByClass($auth_app_class); $login_controller->setCurrentApplication($auth_app); return $login_controller->processRequest(); } $list = $ex->getMoreInfo(); foreach ($list as $key => $item) { $list[$key] = phutil_tag('li', array(), $item); } if ($list) { $list = phutil_tag('ul', array(), $list); } $content = array( phutil_tag( 'div', array( 'class' => 'aphront-policy-rejection', ), $ex->getRejection()), phutil_tag( 'div', array( 'class' => 'aphront-capability-details', ), pht('Users with the "%s" capability:', $ex->getCapabilityName())), $list, ); $dialog = new AphrontDialogView(); $dialog ->setTitle($ex->getTitle()) ->setClass('aphront-access-dialog') ->setUser($user) ->appendChild($content); if ($this->getRequest()->isAjax()) { $dialog->addCancelButton('/', pht('Close')); } else { $dialog->addCancelButton('/', pht('OK')); } $response = new AphrontDialogResponse(); $response->setDialog($dialog); return $response; } if ($ex instanceof AphrontUsageException) { $error = new AphrontErrorView(); $error->setTitle($ex->getTitle()); $error->appendChild($ex->getMessage()); $view = new PhabricatorStandardPageView(); $view->setRequest($this->getRequest()); $view->appendChild($error); $response = new AphrontWebpageResponse(); $response->setContent($view->render()); $response->setHTTPResponseCode(500); return $response; } // Always log the unhandled exception. phlog($ex); $class = get_class($ex); $message = $ex->getMessage(); if ($ex instanceof AphrontQuerySchemaException) { $message .= "\n\n". "NOTE: This usually indicates that the MySQL schema has not been ". "properly upgraded. Run 'bin/storage upgrade' to ensure your ". "schema is up to date."; } if (PhabricatorEnv::getEnvConfig('phabricator.developer-mode')) { $trace = id(new AphrontStackTraceView()) ->setUser($user) ->setTrace($ex->getTrace()); } else { $trace = null; } $content = phutil_tag( 'div', array('class' => 'aphront-unhandled-exception'), array( phutil_tag('div', array('class' => 'exception-message'), $message), $trace, )); $dialog = new AphrontDialogView(); $dialog ->setTitle('Unhandled Exception ("'.$class.'")') ->setClass('aphront-exception-dialog') ->setUser($user) ->appendChild($content); if ($this->getRequest()->isAjax()) { $dialog->addCancelButton('/', 'Close'); } $response = new AphrontDialogResponse(); $response->setDialog($dialog); $response->setHTTPResponseCode(500); return $response; } public function willSendResponse(AphrontResponse $response) { return $response; } public function build404Controller() { return array(new Phabricator404Controller($this->getRequest()), array()); } public function buildRedirectController($uri) { return array( new PhabricatorRedirectController($this->getRequest()), array( 'uri' => $uri, )); } } diff --git a/src/applications/auth/engine/PhabricatorAuthSessionEngine.php b/src/applications/auth/engine/PhabricatorAuthSessionEngine.php index 9cffd4812e..9a1b1036be 100644 --- a/src/applications/auth/engine/PhabricatorAuthSessionEngine.php +++ b/src/applications/auth/engine/PhabricatorAuthSessionEngine.php @@ -1,328 +1,371 @@ establishConnection('r'); $session_key = PhabricatorHash::digest($session_token); // NOTE: We're being clever here because this happens on every page load, // and by joining we can save a query. This might be getting too clever // for its own good, though... $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, u.* FROM %T u JOIN %T s ON u.phid = s.userPHID AND s.type = %s AND s.sessionKey = %s', $user_table->getTableName(), $session_table->getTableName(), $session_type, $session_key); if (!$info) { return null; } $session_dict = array( 'userPHID' => $info['phid'], 'sessionKey' => $session_key, 'type' => $session_type, ); foreach ($info as $key => $value) { if (strncmp($key, 's_', 2) === 0) { unset($info[$key]); $session_dict[substr($key, 2)] = $value; } } $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. 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 = $user_table->loadFromArray($info); $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. * @return string Newly generated session key. */ public function establishSession($session_type, $identity_phid) { // 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); // 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(PhabricatorHash::digest($session_key)) ->setSessionStart(time()) ->setSessionExpires(time() + $session_ttl) ->save(); $log = PhabricatorUserLog::initializeNewLog( null, $identity_phid, PhabricatorUserLog::ACTION_LOGIN); $log->setDetails( array( 'session_type' => $session_type, )); $log->setSession($session_key); $log->save(); unset($unguarded); return $session_key; } /** * 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. * @return PhabricatorAuthHighSecurityToken Security token. */ public function requireHighSecuritySession( PhabricatorUser $viewer, AphrontRequest $request, $cancel_uri) { 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); + } + + $validation_results = array(); if ($request->isHTTPPost()) { $request->validateCSRF(); if ($request->getExists(AphrontRequest::TYPE_HISEC)) { - // TODO: Actually verify that the user provided some multi-factor - // auth credentials here. For now, we just let you enter high - // security. - - $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(); + $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) { + $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); + ->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. * @return PhabricatorAuthHighSecurityToken|null Token, if authorized. */ private function issueHighSecurityToken(PhabricatorAuthSession $session) { $until = $session->getHighSecurityUntil(); if ($until > time()) { 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. */ public function renderHighSecurityForm( + array $factors, + array $validation_results, PhabricatorUser $viewer, AphrontRequest $request) { - // TODO: This is stubbed. - $form = id(new AphrontFormView()) ->setUser($viewer) - ->appendRemarkupInstructions('') - ->appendChild( - id(new AphrontFormTextControl()) - ->setLabel(pht('Secret Stuff'))) ->appendRemarkupInstructions(''); + foreach ($factors as $factor) { + $factor->requireImplementation()->renderValidateFactorForm( + $factor, + $form, + $viewer, + idx($validation_results, $factor->getID())); + } + + $form->appendRemarkupInstructions(''); + return $form; } public function exitHighSecurity( PhabricatorUser $viewer, PhabricatorAuthSession $session) { 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(); } } diff --git a/src/applications/auth/exception/PhabricatorAuthHighSecurityRequiredException.php b/src/applications/auth/exception/PhabricatorAuthHighSecurityRequiredException.php index 16022e53b8..56a4f9fc89 100644 --- a/src/applications/auth/exception/PhabricatorAuthHighSecurityRequiredException.php +++ b/src/applications/auth/exception/PhabricatorAuthHighSecurityRequiredException.php @@ -1,16 +1,37 @@ factorValidationResults = $results; + return $this; + } + + public function getFactorValidationResults() { + return $this->factorValidationResults; + } + + public function setFactors(array $factors) { + assert_instances_of($factors, 'PhabricatorAuthFactorConfig'); + $this->factors = $factors; + return $this; + } + + public function getFactors() { + return $this->factors; + } public function setCancelURI($cancel_uri) { $this->cancelURI = $cancel_uri; return $this; } public function getCancelURI() { return $this->cancelURI; } } diff --git a/src/applications/auth/factor/PhabricatorAuthFactor.php b/src/applications/auth/factor/PhabricatorAuthFactor.php index 75599200eb..f579667ce8 100644 --- a/src/applications/auth/factor/PhabricatorAuthFactor.php +++ b/src/applications/auth/factor/PhabricatorAuthFactor.php @@ -1,51 +1,74 @@ getID().'.'.$name; + } + public static function getAllFactors() { static $factors; if ($factors === null) { $map = id(new PhutilSymbolLoader()) ->setAncestorClass(__CLASS__) ->loadObjects(); $factors = array(); foreach ($map as $factor) { $key = $factor->getFactorKey(); if (empty($factors[$key])) { $factors[$key] = $factor; } else { $this_class = get_class($factor); $that_class = get_class($factors[$key]); throw new Exception( pht( 'Two auth factors (with classes "%s" and "%s") both provide '. 'implementations with the same key ("%s"). Each factor must '. 'have a unique key.', $this_class, $that_class, $key)); } } } return $factors; } protected function newConfigForUser(PhabricatorUser $user) { return id(new PhabricatorAuthFactorConfig()) ->setUserPHID($user->getPHID()) ->setFactorKey($this->getFactorKey()); } } diff --git a/src/applications/auth/factor/PhabricatorAuthFactorTOTP.php b/src/applications/auth/factor/PhabricatorAuthFactorTOTP.php index cc09126368..2d00d220fe 100644 --- a/src/applications/auth/factor/PhabricatorAuthFactorTOTP.php +++ b/src/applications/auth/factor/PhabricatorAuthFactorTOTP.php @@ -1,179 +1,222 @@ getStr('totpkey'); if (!strlen($key)) { // TODO: When the user submits a key, we should require that it be // one we generated for them, so there's no way an attacker can ever // force a key they control onto an account. However, it's clumsy to // do this right now. Once we have one-time tokens for SMS and email, // we should be able to put it on that infrastructure. $key = self::generateNewTOTPKey(); } $code = $request->getStr('totpcode'); $e_code = true; if ($request->getExists('totp')) { $okay = self::verifyTOTPCode( $user, new PhutilOpaqueEnvelope($key), $code); if ($okay) { $config = $this->newConfigForUser($user) ->setFactorName(pht('Mobile App (TOTP)')) ->setFactorSecret($key); return $config; } else { if (!strlen($code)) { $e_code = pht('Required'); } else { $e_code = pht('Invalid'); } } } $form->addHiddenInput('totp', true); $form->addHiddenInput('totpkey', $key); $form->appendRemarkupInstructions( pht( 'First, download an authenticator application on your phone. Two '. 'applications which work well are **Authy** and **Google '. 'Authenticator**, but any other TOTP application should also work.')); $form->appendInstructions( pht( 'Launch the application on your phone, and add a new entry for '. 'this Phabricator install. When prompted, enter the key shown '. 'below into the application.')); $form->appendChild( id(new AphrontFormStaticControl()) ->setLabel(pht('Key')) ->setValue(phutil_tag('strong', array(), $key))); $form->appendInstructions( pht( '(If given an option, select that this key is "Time Based", not '. '"Counter Based".)')); $form->appendInstructions( pht( 'After entering the key, the application should display a numeric '. 'code. Enter that code below to confirm that you have configured '. 'the authenticator correctly:')); $form->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('TOTP Code')) ->setName('totpcode') ->setValue($code) ->setError($e_code)); } + public function renderValidateFactorForm( + PhabricatorAuthFactorConfig $config, + AphrontFormView $form, + PhabricatorUser $viewer, + $validation_result) { + + if (!$validation_result) { + $validation_result = array(); + } + + $form->appendChild( + id(new AphrontFormTextControl()) + ->setName($this->getParameterName($config, 'totpcode')) + ->setLabel(pht('App Code')) + ->setCaption(pht('Factor Name: %s', $config->getFactorName())) + ->setValue(idx($validation_result, 'value')) + ->setError(idx($validation_result, 'error', true))); + } + + public function processValidateFactorForm( + PhabricatorAuthFactorConfig $config, + PhabricatorUser $viewer, + AphrontRequest $request) { + + $code = $request->getStr($this->getParameterName($config, 'totpcode')); + $key = new PhutilOpaqueEnvelope($config->getFactorSecret()); + + if (self::verifyTOTPCode($viewer, $key, $code)) { + return array( + 'error' => null, + 'value' => $code, + 'valid' => true, + ); + } else { + return array( + 'error' => strlen($code) ? pht('Invalid') : pht('Required'), + 'value' => $code, + 'valid' => false, + ); + } + } + + public static function generateNewTOTPKey() { return strtoupper(Filesystem::readRandomCharacters(16)); } public static function verifyTOTPCode( PhabricatorUser $user, PhutilOpaqueEnvelope $key, $code) { // TODO: This should use rate limiting to prevent multiple attempts in a // short period of time. $now = (int)(time() / 30); // Allow the user to enter a code a few minutes away on either side, in // case the server or client has some clock skew. for ($offset = -2; $offset <= 2; $offset++) { $real = self::getTOTPCode($key, $now + $offset); if ($real === $code) { return true; } } // TODO: After validating a code, this should mark it as used and prevent // it from being reused. return false; } public static function base32Decode($buf) { $buf = strtoupper($buf); $map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $map = str_split($map); $map = array_flip($map); $out = ''; $len = strlen($buf); $acc = 0; $bits = 0; for ($ii = 0; $ii < $len; $ii++) { $chr = $buf[$ii]; $val = $map[$chr]; $acc = $acc << 5; $acc = $acc + $val; $bits += 5; if ($bits >= 8) { $bits = $bits - 8; $out .= chr(($acc & (0xFF << $bits)) >> $bits); } } return $out; } public static function getTOTPCode(PhutilOpaqueEnvelope $key, $timestamp) { $binary_timestamp = pack('N*', 0).pack('N*', $timestamp); $binary_key = self::base32Decode($key->openEnvelope()); $hash = hash_hmac('sha1', $binary_timestamp, $binary_key, true); // See RFC 4226. $offset = ord($hash[19]) & 0x0F; $code = ((ord($hash[$offset + 0]) & 0x7F) << 24) | ((ord($hash[$offset + 1]) & 0xFF) << 16) | ((ord($hash[$offset + 2]) & 0xFF) << 8) | ((ord($hash[$offset + 3]) ) ); $code = ($code % 1000000); $code = str_pad($code, 6, '0', STR_PAD_LEFT); return $code; } } diff --git a/src/applications/auth/storage/PhabricatorAuthFactorConfig.php b/src/applications/auth/storage/PhabricatorAuthFactorConfig.php index cc2d7d447b..6e22a10e0d 100644 --- a/src/applications/auth/storage/PhabricatorAuthFactorConfig.php +++ b/src/applications/auth/storage/PhabricatorAuthFactorConfig.php @@ -1,29 +1,42 @@ array( 'properties' => self::SERIALIZATION_JSON, ), self::CONFIG_AUX_PHID => true, ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorAuthPHIDTypeAuthFactor::TYPECONST); } public function getImplementation() { return idx(PhabricatorAuthFactor::getAllFactors(), $this->getFactorKey()); } + public function requireImplementation() { + $impl = $this->getImplementation(); + if (!$impl) { + throw new Exception( + pht( + 'Attempting to operate on multi-factor auth which has no '. + 'corresponding implementation (factor key is "%s").', + $this->getFactorKey())); + } + + return $impl; + } + } diff --git a/src/applications/people/storage/PhabricatorUserLog.php b/src/applications/people/storage/PhabricatorUserLog.php index c61e10fbe7..74f399595c 100644 --- a/src/applications/people/storage/PhabricatorUserLog.php +++ b/src/applications/people/storage/PhabricatorUserLog.php @@ -1,172 +1,174 @@ pht('Login'), self::ACTION_LOGIN_FAILURE => pht('Login Failure'), self::ACTION_LOGOUT => pht('Logout'), self::ACTION_RESET_PASSWORD => pht('Reset Password'), self::ACTION_CREATE => pht('Create Account'), self::ACTION_EDIT => pht('Edit Account'), self::ACTION_ADMIN => pht('Add/Remove Administrator'), self::ACTION_SYSTEM_AGENT => pht('Add/Remove System Agent'), self::ACTION_DISABLE => pht('Enable/Disable'), self::ACTION_APPROVE => pht('Approve Registration'), self::ACTION_DELETE => pht('Delete User'), self::ACTION_CONDUIT_CERTIFICATE => pht('Conduit: Read Certificate'), self::ACTION_CONDUIT_CERTIFICATE_FAILURE => pht('Conduit: Read Certificate Failure'), self::ACTION_EMAIL_PRIMARY => pht('Email: Change Primary'), self::ACTION_EMAIL_ADD => pht('Email: Add Address'), self::ACTION_EMAIL_REMOVE => pht('Email: Remove Address'), self::ACTION_CHANGE_PASSWORD => pht('Change Password'), self::ACTION_CHANGE_USERNAME => pht('Change Username'), self::ACTION_ENTER_HISEC => pht('Hisec: Enter'), self::ACTION_EXIT_HISEC => pht('Hisec: Exit'), + self::ACTION_FAIL_HISEC => pht('Hisec: Failed Attempt'), self::ACTION_MULTI_ADD => pht('Multi-Factor: Add Factor'), self::ACTION_MULTI_REMOVE => pht('Multi-Factor: Remove Factor'), ); } public static function initializeNewLog( PhabricatorUser $actor = null, $object_phid, $action) { $log = new PhabricatorUserLog(); if ($actor) { $log->setActorPHID($actor->getPHID()); if ($actor->hasSession()) { $session = $actor->getSession(); // NOTE: This is a hash of the real session value, so it's safe to // store it directly in the logs. $log->setSession($session->getSessionKey()); } } $log->setUserPHID((string)$object_phid); $log->setAction($action); $log->remoteAddr = idx($_SERVER, 'REMOTE_ADDR', ''); return $log; } public static function loadRecentEventsFromThisIP($action, $timespan) { return id(new PhabricatorUserLog())->loadAllWhere( 'action = %s AND remoteAddr = %s AND dateCreated > %d ORDER BY dateCreated DESC', $action, idx($_SERVER, 'REMOTE_ADDR'), time() - $timespan); } public function save() { $this->details['host'] = php_uname('n'); $this->details['user_agent'] = AphrontRequest::getHTTPHeader('User-Agent'); return parent::save(); } public function getConfiguration() { return array( self::CONFIG_SERIALIZATION => array( 'oldValue' => self::SERIALIZATION_JSON, 'newValue' => self::SERIALIZATION_JSON, 'details' => self::SERIALIZATION_JSON, ), ) + parent::getConfiguration(); } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return PhabricatorPolicies::POLICY_NOONE; } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { if ($viewer->getIsAdmin()) { return true; } $viewer_phid = $viewer->getPHID(); if ($viewer_phid) { $user_phid = $this->getUserPHID(); if ($viewer_phid == $user_phid) { return true; } $actor_phid = $this->getActorPHID(); if ($viewer_phid == $actor_phid) { return true; } } return false; } public function describeAutomaticCapability($capability) { return array( pht('Users can view their activity and activity that affects them.'), pht('Administrators can always view all activity.'), ); } } diff --git a/src/applications/settings/panel/PhabricatorSettingsPanelMultiFactor.php b/src/applications/settings/panel/PhabricatorSettingsPanelMultiFactor.php index ba8fe22da0..0ba72962c1 100644 --- a/src/applications/settings/panel/PhabricatorSettingsPanelMultiFactor.php +++ b/src/applications/settings/panel/PhabricatorSettingsPanelMultiFactor.php @@ -1,309 +1,304 @@ getExists('new')) { return $this->processNew($request); } if ($request->getExists('edit')) { return $this->processEdit($request); } if ($request->getExists('delete')) { return $this->processDelete($request); } $user = $this->getUser(); $viewer = $request->getUser(); $factors = id(new PhabricatorAuthFactorConfig())->loadAllWhere( 'userPHID = %s', $user->getPHID()); $rows = array(); $rowc = array(); $highlight_id = $request->getInt('id'); foreach ($factors as $factor) { $impl = $factor->getImplementation(); if ($impl) { $type = $impl->getFactorName(); } else { $type = $factor->getFactorKey(); } if ($factor->getID() == $highlight_id) { $rowc[] = 'highlighted'; } else { $rowc[] = null; } $rows[] = array( javelin_tag( 'a', array( 'href' => $this->getPanelURI('?edit='.$factor->getID()), 'sigil' => 'workflow', ), $factor->getFactorName()), $type, phabricator_datetime($factor->getDateCreated(), $viewer), javelin_tag( 'a', array( 'href' => $this->getPanelURI('?delete='.$factor->getID()), 'sigil' => 'workflow', 'class' => 'small grey button', ), pht('Remove')), ); } $table = new AphrontTableView($rows); $table->setNoDataString( pht("You haven't added any authentication factors to your account yet.")); $table->setHeaders( array( pht('Name'), pht('Type'), pht('Created'), '', )); $table->setColumnClasses( array( 'wide pri', '', 'right', 'action', )); $table->setRowClasses($rowc); $table->setDeviceVisibility( array( true, false, false, true, )); $panel = new PHUIObjectBoxView(); $header = new PHUIHeaderView(); $create_icon = id(new PHUIIconView()) ->setSpriteSheet(PHUIIconView::SPRITE_ICONS) ->setSpriteIcon('new'); $create_button = id(new PHUIButtonView()) ->setText(pht('Add Authentication Factor')) ->setHref($this->getPanelURI('?new=true')) ->setTag('a') ->setWorkflow(true) ->setIcon($create_icon); $header->setHeader(pht('Authentication Factors')); $header->addActionLink($create_button); $panel->setHeader($header); $panel->appendChild($table); return $panel; } private function processNew(AphrontRequest $request) { $viewer = $request->getUser(); $user = $this->getUser(); $token = id(new PhabricatorAuthSessionEngine())->requireHighSecuritySession( $viewer, $request, $this->getPanelURI()); $factors = PhabricatorAuthFactor::getAllFactors(); $form = id(new AphrontFormView()) ->setUser($viewer); $type = $request->getStr('type'); if (empty($factors[$type]) || !$request->isFormPost()) { $factor = null; } else { $factor = $factors[$type]; } $dialog = id(new AphrontDialogView()) ->setUser($viewer) ->addHiddenInput('new', true); if ($factor === null) { $choice_control = id(new AphrontFormRadioButtonControl()) ->setName('type') ->setValue(key($factors)); foreach ($factors as $available_factor) { $choice_control->addButton( $available_factor->getFactorKey(), $available_factor->getFactorName(), $available_factor->getFactorDescription()); } $dialog->appendParagraph( pht( 'Adding an additional authentication factor increases the security '. 'of your account.')); $form ->appendChild($choice_control); } else { $dialog->addHiddenInput('type', $type); $config = $factor->processAddFactorForm( $form, $request, $user); if ($config) { $config->save(); $log = PhabricatorUserLog::initializeNewLog( $viewer, $user->getPHID(), PhabricatorUserLog::ACTION_MULTI_ADD); $log->save(); return id(new AphrontRedirectResponse()) ->setURI($this->getPanelURI('?id='.$config->getID())); } } $dialog ->setWidth(AphrontDialogView::WIDTH_FORM) ->setTitle(pht('Add Authentication Factor')) ->appendChild($form->buildLayoutView()) ->addSubmitButton(pht('Continue')) ->addCancelButton($this->getPanelURI()); return id(new AphrontDialogResponse()) ->setDialog($dialog); } private function processEdit(AphrontRequest $request) { $viewer = $request->getUser(); $user = $this->getUser(); $factor = id(new PhabricatorAuthFactorConfig())->loadOneWhere( 'id = %d AND userPHID = %s', $request->getInt('edit'), $user->getPHID()); if (!$factor) { return new Aphront404Response(); } $e_name = true; $errors = array(); if ($request->isFormPost()) { $name = $request->getStr('name'); if (!strlen($name)) { $e_name = pht('Required'); $errors[] = pht( 'Authentication factors must have a name to identify them.'); } if (!$errors) { $factor->setFactorName($name); $factor->save(); return id(new AphrontRedirectResponse()) ->setURI($this->getPanelURI('?id='.$factor->getID())); } } else { $name = $factor->getFactorName(); } $form = id(new AphrontFormView()) ->setUser($viewer) ->appendChild( id(new AphrontFormTextControl()) ->setName('name') ->setLabel(pht('Name')) ->setValue($name) ->setError($e_name)); $dialog = id(new AphrontDialogView()) ->setUser($viewer) ->addHiddenInput('edit', $factor->getID()) ->setTitle(pht('Edit Authentication Factor')) ->setErrors($errors) ->appendChild($form->buildLayoutView()) ->addSubmitButton(pht('Save')) ->addCancelButton($this->getPanelURI()); return id(new AphrontDialogResponse()) ->setDialog($dialog); } private function processDelete(AphrontRequest $request) { $viewer = $request->getUser(); $user = $this->getUser(); $token = id(new PhabricatorAuthSessionEngine())->requireHighSecuritySession( $viewer, $request, $this->getPanelURI()); $factor = id(new PhabricatorAuthFactorConfig())->loadOneWhere( 'id = %d AND userPHID = %s', $request->getInt('delete'), $user->getPHID()); if (!$factor) { return new Aphront404Response(); } if ($request->isFormPost()) { $factor->delete(); $log = PhabricatorUserLog::initializeNewLog( $viewer, $user->getPHID(), PhabricatorUserLog::ACTION_MULTI_REMOVE); $log->save(); return id(new AphrontRedirectResponse()) ->setURI($this->getPanelURI()); } $dialog = id(new AphrontDialogView()) ->setUser($viewer) ->addHiddenInput('delete', $factor->getID()) ->setTitle(pht('Delete Authentication Factor')) ->appendParagraph( pht( 'Really remove the authentication factor %s from your account?', phutil_tag('strong', array(), $factor->getFactorName()))) ->addSubmitButton(pht('Remove Factor')) ->addCancelButton($this->getPanelURI()); return id(new AphrontDialogResponse()) ->setDialog($dialog); } } diff --git a/src/applications/settings/panel/PhabricatorSettingsPanelSSHKeys.php b/src/applications/settings/panel/PhabricatorSettingsPanelSSHKeys.php index 3af78bd169..8ab4a4da6a 100644 --- a/src/applications/settings/panel/PhabricatorSettingsPanelSSHKeys.php +++ b/src/applications/settings/panel/PhabricatorSettingsPanelSSHKeys.php @@ -1,425 +1,418 @@ getUser(); $user = $this->getUser(); $generate = $request->getStr('generate'); if ($generate) { return $this->processGenerate($request); } $edit = $request->getStr('edit'); $delete = $request->getStr('delete'); if (!$edit && !$delete) { return $this->renderKeyListView($request); } - /* - - NOTE: Uncomment this to test hisec. - TOOD: Implement this fully once hisec does something useful. - $token = id(new PhabricatorAuthSessionEngine())->requireHighSecuritySession( $viewer, $request, - '/settings/panel/ssh/'); - - */ + $this->getPanelURI()); $id = nonempty($edit, $delete); if ($id && is_numeric($id)) { // NOTE: This prevents editing/deleting of keys not owned by the user. $key = id(new PhabricatorUserSSHKey())->loadOneWhere( 'userPHID = %s AND id = %d', $user->getPHID(), (int)$id); if (!$key) { return new Aphront404Response(); } } else { $key = new PhabricatorUserSSHKey(); $key->setUserPHID($user->getPHID()); } if ($delete) { return $this->processDelete($request, $key); } $e_name = true; $e_key = true; $errors = array(); $entire_key = $key->getEntireKey(); if ($request->isFormPost()) { $key->setName($request->getStr('name')); $entire_key = $request->getStr('key'); if (!strlen($entire_key)) { $errors[] = pht('You must provide an SSH Public Key.'); $e_key = pht('Required'); } else { try { list($type, $body, $comment) = self::parsePublicKey($entire_key); $key->setKeyType($type); $key->setKeyBody($body); $key->setKeyHash(md5($body)); $key->setKeyComment($comment); $e_key = null; } catch (Exception $ex) { $e_key = pht('Invalid'); $errors[] = $ex->getMessage(); } } if (!strlen($key->getName())) { $errors[] = pht('You must name this public key.'); $e_name = pht('Required'); } else { $e_name = null; } if (!$errors) { try { $key->save(); return id(new AphrontRedirectResponse()) ->setURI($this->getPanelURI()); } catch (AphrontQueryDuplicateKeyException $ex) { $e_key = pht('Duplicate'); $errors[] = pht('This public key is already associated with a user '. 'account.'); } } } $is_new = !$key->getID(); if ($is_new) { $header = pht('Add New SSH Public Key'); $save = pht('Add Key'); } else { $header = pht('Edit SSH Public Key'); $save = pht('Save Changes'); } $form = id(new AphrontFormView()) ->setUser($viewer) ->addHiddenInput('edit', $is_new ? 'true' : $key->getID()) ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Name')) ->setName('name') ->setValue($key->getName()) ->setError($e_name)) ->appendChild( id(new AphrontFormTextAreaControl()) ->setLabel(pht('Public Key')) ->setName('key') ->setValue($entire_key) ->setError($e_key)) ->appendChild( id(new AphrontFormSubmitControl()) ->addCancelButton($this->getPanelURI()) ->setValue($save)); $form_box = id(new PHUIObjectBoxView()) ->setHeaderText($header) ->setFormErrors($errors) ->setForm($form); return $form_box; } private function renderKeyListView(AphrontRequest $request) { $user = $this->getUser(); $viewer = $request->getUser(); $keys = id(new PhabricatorUserSSHKey())->loadAllWhere( 'userPHID = %s', $user->getPHID()); $rows = array(); foreach ($keys as $key) { $rows[] = array( phutil_tag( 'a', array( 'href' => $this->getPanelURI('?edit='.$key->getID()), ), $key->getName()), $key->getKeyComment(), $key->getKeyType(), phabricator_date($key->getDateCreated(), $viewer), phabricator_time($key->getDateCreated(), $viewer), javelin_tag( 'a', array( 'href' => $this->getPanelURI('?delete='.$key->getID()), 'class' => 'small grey button', 'sigil' => 'workflow', ), pht('Delete')), ); } $table = new AphrontTableView($rows); $table->setNoDataString(pht("You haven't added any SSH Public Keys.")); $table->setHeaders( array( pht('Name'), pht('Comment'), pht('Type'), pht('Created'), pht('Time'), '', )); $table->setColumnClasses( array( 'wide pri', '', '', '', 'right', 'action', )); $panel = new PHUIObjectBoxView(); $header = new PHUIHeaderView(); $upload_icon = id(new PHUIIconView()) ->setSpriteSheet(PHUIIconView::SPRITE_ICONS) ->setSpriteIcon('upload'); $upload_button = id(new PHUIButtonView()) ->setText(pht('Upload Public Key')) ->setHref($this->getPanelURI('?edit=true')) ->setTag('a') ->setIcon($upload_icon); try { PhabricatorSSHKeyGenerator::assertCanGenerateKeypair(); $can_generate = true; } catch (Exception $ex) { $can_generate = false; } $generate_icon = id(new PHUIIconView()) ->setSpriteSheet(PHUIIconView::SPRITE_ICONS) ->setSpriteIcon('lock'); $generate_button = id(new PHUIButtonView()) ->setText(pht('Generate Keypair')) ->setHref($this->getPanelURI('?generate=true')) ->setTag('a') ->setWorkflow(true) ->setDisabled(!$can_generate) ->setIcon($generate_icon); $header->setHeader(pht('SSH Public Keys')); $header->addActionLink($generate_button); $header->addActionLink($upload_button); $panel->setHeader($header); $panel->appendChild($table); return $panel; } private function processDelete( AphrontRequest $request, PhabricatorUserSSHKey $key) { $viewer = $request->getUser(); $user = $this->getUser(); $name = phutil_tag('strong', array(), $key->getName()); if ($request->isDialogFormPost()) { $key->delete(); return id(new AphrontReloadResponse()) ->setURI($this->getPanelURI()); } $dialog = id(new AphrontDialogView()) ->setUser($viewer) ->addHiddenInput('delete', $key->getID()) ->setTitle(pht('Really delete SSH Public Key?')) ->appendChild(phutil_tag('p', array(), pht( 'The key "%s" will be permanently deleted, and you will not longer be '. 'able to use the corresponding private key to authenticate.', $name))) ->addSubmitButton(pht('Delete Public Key')) ->addCancelButton($this->getPanelURI()); return id(new AphrontDialogResponse()) ->setDialog($dialog); } private function processGenerate(AphrontRequest $request) { $user = $this->getUser(); $viewer = $request->getUser(); $is_self = ($user->getPHID() == $viewer->getPHID()); if ($request->isFormPost()) { $keys = PhabricatorSSHKeyGenerator::generateKeypair(); list($public_key, $private_key) = $keys; $file = PhabricatorFile::buildFromFileDataOrHash( $private_key, array( 'name' => 'id_rsa_phabricator.key', 'ttl' => time() + (60 * 10), 'viewPolicy' => PhabricatorPolicies::POLICY_NOONE, )); list($type, $body, $comment) = self::parsePublicKey($public_key); $key = id(new PhabricatorUserSSHKey()) ->setUserPHID($user->getPHID()) ->setName('id_rsa_phabricator') ->setKeyType($type) ->setKeyBody($body) ->setKeyHash(md5($body)) ->setKeyComment(pht('Generated')) ->save(); // NOTE: We're disabling workflow on submit so the download works. We're // disabling workflow on cancel so the page reloads, showing the new // key. if ($is_self) { $what_happened = pht( 'The public key has been associated with your Phabricator '. 'account. Use the button below to download the private key.'); } else { $what_happened = pht( 'The public key has been associated with the %s account. '. 'Use the button below to download the private key.', phutil_tag('strong', array(), $user->getUsername())); } $dialog = id(new AphrontDialogView()) ->setTitle(pht('Download Private Key')) ->setUser($viewer) ->setDisableWorkflowOnCancel(true) ->setDisableWorkflowOnSubmit(true) ->setSubmitURI($file->getDownloadURI()) ->appendParagraph( pht( 'Successfully generated a new keypair.')) ->appendParagraph($what_happened) ->appendParagraph( pht( 'After you download the private key, it will be destroyed. '. 'You will not be able to retrieve it if you lose your copy.')) ->addSubmitButton(pht('Download Private Key')) ->addCancelButton($this->getPanelURI(), pht('Done')); return id(new AphrontDialogResponse()) ->setDialog($dialog); } $dialog = id(new AphrontDialogView()) ->setUser($viewer) ->addCancelButton($this->getPanelURI()); try { PhabricatorSSHKeyGenerator::assertCanGenerateKeypair(); if ($is_self) { $explain = pht( 'This will generate an SSH keypair, associate the public key '. 'with your account, and let you download the private key.'); } else { $explain = pht( 'This will generate an SSH keypair, associate the public key with '. 'the %s account, and let you download the private key.', phutil_tag('strong', array(), $user->getUsername())); } $dialog ->addHiddenInput('generate', true) ->setTitle(pht('Generate New Keypair')) ->appendParagraph($explain) ->appendParagraph( pht( "Phabricator will not retain a copy of the private key.")) ->addSubmitButton(pht('Generate Keypair')); } catch (Exception $ex) { $dialog ->setTitle(pht('Unable to Generate Keys')) ->appendParagraph($ex->getMessage()); } return id(new AphrontDialogResponse()) ->setDialog($dialog); } private static function parsePublicKey($entire_key) { $parts = str_replace("\n", '', trim($entire_key)); $parts = preg_split('/\s+/', $parts); if (count($parts) == 2) { $parts[] = ''; // Add an empty comment part. } else if (count($parts) == 3) { // This is the expected case. } else { if (preg_match('/private\s*key/i', $entire_key)) { // Try to give the user a better error message if it looks like // they uploaded a private key. throw new Exception( pht('Provide your public key, not your private key!')); } else { throw new Exception( pht('Provided public key is not properly formatted.')); } } list($type, $body, $comment) = $parts; $recognized_keys = array( 'ssh-dsa', 'ssh-dss', 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', ); if (!in_array($type, $recognized_keys)) { $type_list = implode(', ', $recognized_keys); throw new Exception( pht( 'Public key type should be one of: %s', $type_list)); } return array($type, $body, $comment); } }