diff --git a/resources/sql/autopatches/20140423.session.1.hisec.sql b/resources/sql/autopatches/20140423.session.1.hisec.sql new file mode 100644 --- /dev/null +++ b/resources/sql/autopatches/20140423.session.1.hisec.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_user.phabricator_session + ADD highSecurityUntil INT UNSIGNED; diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -1206,7 +1206,10 @@ 'PhabricatorAuthController' => 'applications/auth/controller/PhabricatorAuthController.php', 'PhabricatorAuthDAO' => 'applications/auth/storage/PhabricatorAuthDAO.php', 'PhabricatorAuthDisableController' => 'applications/auth/controller/config/PhabricatorAuthDisableController.php', + 'PhabricatorAuthDowngradeSessionController' => 'applications/auth/controller/PhabricatorAuthDowngradeSessionController.php', 'PhabricatorAuthEditController' => 'applications/auth/controller/config/PhabricatorAuthEditController.php', + 'PhabricatorAuthHighSecurityRequiredException' => 'applications/auth/exception/PhabricatorAuthHighSecurityRequiredException.php', + 'PhabricatorAuthHighSecurityToken' => 'applications/auth/data/PhabricatorAuthHighSecurityToken.php', 'PhabricatorAuthLinkController' => 'applications/auth/controller/PhabricatorAuthLinkController.php', 'PhabricatorAuthListController' => 'applications/auth/controller/config/PhabricatorAuthListController.php', 'PhabricatorAuthLoginController' => 'applications/auth/controller/PhabricatorAuthLoginController.php', @@ -3947,7 +3950,9 @@ 'PhabricatorAuthController' => 'PhabricatorController', 'PhabricatorAuthDAO' => 'PhabricatorLiskDAO', 'PhabricatorAuthDisableController' => 'PhabricatorAuthProviderConfigController', + 'PhabricatorAuthDowngradeSessionController' => 'PhabricatorAuthController', 'PhabricatorAuthEditController' => 'PhabricatorAuthProviderConfigController', + 'PhabricatorAuthHighSecurityRequiredException' => 'Exception', 'PhabricatorAuthLinkController' => 'PhabricatorAuthController', 'PhabricatorAuthListController' => 'PhabricatorAuthProviderConfigController', 'PhabricatorAuthLoginController' => 'PhabricatorAuthController', diff --git a/src/aphront/AphrontRequest.php b/src/aphront/AphrontRequest.php --- a/src/aphront/AphrontRequest.php +++ b/src/aphront/AphrontRequest.php @@ -18,6 +18,7 @@ const TYPE_WORKFLOW = '__wflow__'; const TYPE_CONTINUE = '__continue__'; const TYPE_PREVIEW = '__preview__'; + const TYPE_HISEC = '__hisec__'; private $host; private $path; @@ -263,6 +264,7 @@ final public function isFormPost() { $post = $this->getExists(self::TYPE_FORM) && + !$this->getExists(self::TYPE_HISEC) && $this->isHTTPPost(); if (!$post) { diff --git a/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php b/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php --- a/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php +++ b/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php @@ -123,6 +123,49 @@ return $response; } + if ($ex instanceof PhabricatorAuthHighSecurityRequiredException) { + + $form = id(new PhabricatorAuthSessionEngine())->renderHighSecurityForm( + $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()) { diff --git a/src/applications/auth/application/PhabricatorApplicationAuth.php b/src/applications/auth/application/PhabricatorApplicationAuth.php --- a/src/applications/auth/application/PhabricatorApplicationAuth.php +++ b/src/applications/auth/application/PhabricatorApplicationAuth.php @@ -88,6 +88,8 @@ => 'PhabricatorAuthConfirmLinkController', 'session/terminate/(?P[^/]+)/' => 'PhabricatorAuthTerminateSessionController', + 'session/downgrade/' + => 'PhabricatorAuthDowngradeSessionController', ), '/oauth/(?P\w+)/login/' diff --git a/src/applications/auth/controller/PhabricatorAuthDowngradeSessionController.php b/src/applications/auth/controller/PhabricatorAuthDowngradeSessionController.php new file mode 100644 --- /dev/null +++ b/src/applications/auth/controller/PhabricatorAuthDowngradeSessionController.php @@ -0,0 +1,52 @@ +getRequest(); + $viewer = $request->getUser(); + + $panel_uri = '/settings/panel/sessions/'; + + $session = $viewer->getSession(); + if ($session->getHighSecurityUntil() < time()) { + return $this->newDialog() + ->setTitle(pht('Normal Security Restored')) + ->appendParagraph( + pht('Your session is no longer in high security.')) + ->addCancelButton($panel_uri, pht('Continue')); + } + + if ($request->isFormPost()) { + + queryfx( + $session->establishConnection('w'), + 'UPDATE %T SET highSecurityUntil = NULL WHERE id = %d', + $session->getTableName(), + $session->getID()); + + return id(new AphrontRedirectResponse()) + ->setURI($this->getApplicationURI('session/downgrade/')); + } + + return $this->newDialog() + ->setTitle(pht('Leaving High Security')) + ->appendParagraph( + pht( + 'Leave high security and return your session to normal '. + 'security levels?')) + ->appendParagraph( + pht( + 'If you leave high security, you will need to authenticate '. + 'again the next time you try to take a high security action.')) + ->appendParagraph( + pht( + 'On the plus side, that purple notification bubble will '. + 'disappear.')) + ->addSubmitButton(pht('Leave High Security')) + ->addCancelButton($panel_uri, pht('Stay in High Security')); + } + + +} diff --git a/src/applications/auth/data/PhabricatorAuthHighSecurityToken.php b/src/applications/auth/data/PhabricatorAuthHighSecurityToken.php new file mode 100644 --- /dev/null +++ b/src/applications/auth/data/PhabricatorAuthHighSecurityToken.php @@ -0,0 +1,5 @@ +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. + // 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.sessionExpires AS _sessionExpires, s.id AS _sessionID, u.* + '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, - PhabricatorHash::digest($session_token)); + $session_key); if (!$info) { return null; } - $expires = $info['_sessionExpires']; - $id = $info['_sessionID']; - unset($info['_sessionExpires']); - unset($info['_sessionID']); + $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); @@ -107,19 +125,21 @@ // 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) > $expires) { + 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_table->getTableName(), + $session->getTableName(), $ttl, - $id); + $session->getID()); unset($unguarded); } - return $user_table->loadFromArray($info); + $user = $user_table->loadFromArray($info); + $user->attachSession($session); + return $user; } @@ -182,4 +202,104 @@ 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(); + + $token = $this->issueHighSecurityToken($session); + if ($token) { + return $token; + } + + 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()); + } + } + + $token = $this->issueHighSecurityToken($session); + if ($token) { + return $token; + } + + throw id(new PhabricatorAuthHighSecurityRequiredException()) + ->setCancelURI($cancel_uri); + } + + + /** + * 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( + PhabricatorUser $viewer, + AphrontRequest $request) { + + // TODO: This is stubbed. + + $form = id(new AphrontFormView()) + ->setUser($viewer) + ->appendRemarkupInstructions('') + ->appendChild( + id(new AphrontFormTextControl()) + ->setLabel(pht('Secret Stuff'))) + ->appendRemarkupInstructions(''); + + return $form; + } + + } diff --git a/src/applications/auth/exception/PhabricatorAuthHighSecurityRequiredException.php b/src/applications/auth/exception/PhabricatorAuthHighSecurityRequiredException.php new file mode 100644 --- /dev/null +++ b/src/applications/auth/exception/PhabricatorAuthHighSecurityRequiredException.php @@ -0,0 +1,16 @@ +cancelURI = $cancel_uri; + return $this; + } + + public function getCancelURI() { + return $this->cancelURI; + } + +} diff --git a/src/applications/auth/storage/PhabricatorAuthSession.php b/src/applications/auth/storage/PhabricatorAuthSession.php --- a/src/applications/auth/storage/PhabricatorAuthSession.php +++ b/src/applications/auth/storage/PhabricatorAuthSession.php @@ -11,6 +11,7 @@ protected $sessionKey; protected $sessionStart; protected $sessionExpires; + protected $highSecurityUntil; private $identityObject = self::ATTACHABLE; diff --git a/src/applications/people/storage/PhabricatorUser.php b/src/applications/people/storage/PhabricatorUser.php --- a/src/applications/people/storage/PhabricatorUser.php +++ b/src/applications/people/storage/PhabricatorUser.php @@ -42,6 +42,7 @@ private $customFields = self::ATTACHABLE; private $alternateCSRFString = self::ATTACHABLE; + private $session = self::ATTACHABLE; protected function readField($field) { switch ($field) { @@ -178,6 +179,19 @@ return $result; } + public function attachSession(PhabricatorAuthSession $session) { + $this->session = $session; + return $this; + } + + public function getSession() { + return $this->assertAttached($this->session); + } + + public function hasSession() { + return ($this->session !== self::ATTACHABLE); + } + private function generateConduitCertificate() { return Filesystem::readRandomCharacters(255); } diff --git a/src/applications/settings/panel/PhabricatorSettingsPanelSSHKeys.php b/src/applications/settings/panel/PhabricatorSettingsPanelSSHKeys.php --- a/src/applications/settings/panel/PhabricatorSettingsPanelSSHKeys.php +++ b/src/applications/settings/panel/PhabricatorSettingsPanelSSHKeys.php @@ -38,6 +38,18 @@ 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/'); + + */ + $id = nonempty($edit, $delete); if ($id && is_numeric($id)) { diff --git a/src/applications/settings/panel/PhabricatorSettingsPanelSessions.php b/src/applications/settings/panel/PhabricatorSettingsPanelSessions.php --- a/src/applications/settings/panel/PhabricatorSettingsPanelSessions.php +++ b/src/applications/settings/panel/PhabricatorSettingsPanelSessions.php @@ -66,10 +66,15 @@ pht('Terminate')); } + $hisec = ($session->getHighSecurityUntil() - time()); + $rows[] = array( $handles[$session->getUserPHID()]->renderLink(), substr($session->getSessionKey(), 0, 6), $session->getType(), + ($hisec > 0) + ? phabricator_format_relative_time($hisec) + : null, phabricator_datetime($session->getSessionStart(), $viewer), phabricator_date($session->getSessionExpires(), $viewer), $button, @@ -84,6 +89,7 @@ pht('Identity'), pht('Session'), pht('Type'), + pht('HiSec'), pht('Created'), pht('Expires'), pht(''), @@ -95,6 +101,7 @@ '', 'right', 'right', + 'right', 'action', )); @@ -113,6 +120,20 @@ ->setHeader(pht('Active Login Sessions')) ->addActionLink($terminate_button); + $hisec = ($viewer->getSession()->getHighSecurityUntil() - time()); + if ($hisec > 0) { + $hisec_icon = id(new PHUIIconView()) + ->setSpriteSheet(PHUIIconView::SPRITE_ICONS) + ->setSpriteIcon('lock'); + $hisec_button = id(new PHUIButtonView()) + ->setText(pht('Leave High Security')) + ->setHref('/auth/session/downgrade/') + ->setTag('a') + ->setWorkflow(true) + ->setIcon($hisec_icon); + $header->addActionLink($hisec_button); + } + $panel = id(new PHUIObjectBoxView()) ->setHeader($header) ->appendChild($table); diff --git a/src/view/page/PhabricatorStandardPageView.php b/src/view/page/PhabricatorStandardPageView.php --- a/src/view/page/PhabricatorStandardPageView.php +++ b/src/view/page/PhabricatorStandardPageView.php @@ -168,6 +168,22 @@ Javelin::initBehavior('device'); + if ($user->hasSession()) { + $hisec = ($user->getSession()->getHighSecurityUntil() - time()); + if ($hisec > 0) { + $remaining_time = phabricator_format_relative_time($hisec); + Javelin::initBehavior( + 'high-security-warning', + array( + 'uri' => '/auth/session/downgrade/', + 'message' => pht( + 'Your session is in high security mode. When you '. + 'finish using it, click here to leave.', + $remaining_time), + )); + } + } + if ($console) { require_celerity_resource('aphront-dark-console-css'); diff --git a/webroot/rsrc/css/aphront/notification.css b/webroot/rsrc/css/aphront/notification.css --- a/webroot/rsrc/css/aphront/notification.css +++ b/webroot/rsrc/css/aphront/notification.css @@ -47,6 +47,11 @@ border: 1px solid {$red}; } +.jx-notification-security { + background: {$lightviolet}; + border: 1px solid {$violet}; +} + .jx-notification-container .phabricator-notification { padding: 0; } diff --git a/webroot/rsrc/js/core/behavior-high-security-warning.js b/webroot/rsrc/js/core/behavior-high-security-warning.js new file mode 100644 --- /dev/null +++ b/webroot/rsrc/js/core/behavior-high-security-warning.js @@ -0,0 +1,19 @@ +/** + * @provides javelin-behavior-high-security-warning + * @requires javelin-behavior + * javelin-uri + * phabricator-notification + */ + +JX.behavior('high-security-warning', function(config) { + + var n = new JX.Notification() + .setContent(config.message) + .setDuration(0) + .alterClassName('jx-notification-security', true); + + n.listen('activate', function() { JX.$U(config.uri).go(); }); + + n.show(); + +});