diff --git a/resources/sql/autopatches/20140427.mfactor.1.sql b/resources/sql/autopatches/20140427.mfactor.1.sql new file mode 100644 --- /dev/null +++ b/resources/sql/autopatches/20140427.mfactor.1.sql @@ -0,0 +1,13 @@ +CREATE TABLE {$NAMESPACE}_auth.auth_factorconfig ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + phid VARCHAR(64) NOT NULL COLLATE utf8_bin, + userPHID VARCHAR(64) NOT NULL COLLATE utf8_bin, + factorKey VARCHAR(64) NOT NULL COLLATE utf8_bin, + factorName LONGTEXT NOT NULL COLLATE utf8_general_ci, + factorSecret LONGTEXT NOT NULL COLLATE utf8_bin, + properties LONGTEXT NOT NULL COLLATE utf8_bin, + dateCreated INT UNSIGNED NOT NULL, + dateModified INT UNSIGNED NOT NULL, + KEY `key_user` (userPHID), + UNIQUE KEY `key_phid` (phid) +) ENGINE=InnoDB, COLLATE utf8_general_ci; 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 @@ -1211,6 +1211,10 @@ 'PhabricatorAuthDisableController' => 'applications/auth/controller/config/PhabricatorAuthDisableController.php', 'PhabricatorAuthDowngradeSessionController' => 'applications/auth/controller/PhabricatorAuthDowngradeSessionController.php', 'PhabricatorAuthEditController' => 'applications/auth/controller/config/PhabricatorAuthEditController.php', + 'PhabricatorAuthFactor' => 'applications/auth/factor/PhabricatorAuthFactor.php', + 'PhabricatorAuthFactorConfig' => 'applications/auth/storage/PhabricatorAuthFactorConfig.php', + 'PhabricatorAuthFactorTOTP' => 'applications/auth/factor/PhabricatorAuthFactorTOTP.php', + 'PhabricatorAuthFactorTOTPTestCase' => 'applications/auth/factor/__tests__/PhabricatorAuthFactorTOTPTestCase.php', 'PhabricatorAuthHighSecurityRequiredException' => 'applications/auth/exception/PhabricatorAuthHighSecurityRequiredException.php', 'PhabricatorAuthHighSecurityToken' => 'applications/auth/data/PhabricatorAuthHighSecurityToken.php', 'PhabricatorAuthLinkController' => 'applications/auth/controller/PhabricatorAuthLinkController.php', @@ -1223,6 +1227,7 @@ 'PhabricatorAuthNeedsApprovalController' => 'applications/auth/controller/PhabricatorAuthNeedsApprovalController.php', 'PhabricatorAuthNewController' => 'applications/auth/controller/config/PhabricatorAuthNewController.php', 'PhabricatorAuthOldOAuthRedirectController' => 'applications/auth/controller/PhabricatorAuthOldOAuthRedirectController.php', + 'PhabricatorAuthPHIDTypeAuthFactor' => 'applications/auth/phid/PhabricatorAuthPHIDTypeAuthFactor.php', 'PhabricatorAuthProvider' => 'applications/auth/provider/PhabricatorAuthProvider.php', 'PhabricatorAuthProviderConfig' => 'applications/auth/storage/PhabricatorAuthProviderConfig.php', 'PhabricatorAuthProviderConfigController' => 'applications/auth/controller/config/PhabricatorAuthProviderConfigController.php', @@ -2067,6 +2072,7 @@ 'PhabricatorSettingsPanelEmailPreferences' => 'applications/settings/panel/PhabricatorSettingsPanelEmailPreferences.php', 'PhabricatorSettingsPanelExternalAccounts' => 'applications/settings/panel/PhabricatorSettingsPanelExternalAccounts.php', 'PhabricatorSettingsPanelHomePreferences' => 'applications/settings/panel/PhabricatorSettingsPanelHomePreferences.php', + 'PhabricatorSettingsPanelMultiFactor' => 'applications/settings/panel/PhabricatorSettingsPanelMultiFactor.php', 'PhabricatorSettingsPanelPassword' => 'applications/settings/panel/PhabricatorSettingsPanelPassword.php', 'PhabricatorSettingsPanelSSHKeys' => 'applications/settings/panel/PhabricatorSettingsPanelSSHKeys.php', 'PhabricatorSettingsPanelSearchPreferences' => 'applications/settings/panel/PhabricatorSettingsPanelSearchPreferences.php', @@ -3955,6 +3961,10 @@ 'PhabricatorAuthDisableController' => 'PhabricatorAuthProviderConfigController', 'PhabricatorAuthDowngradeSessionController' => 'PhabricatorAuthController', 'PhabricatorAuthEditController' => 'PhabricatorAuthProviderConfigController', + 'PhabricatorAuthFactor' => 'Phobject', + 'PhabricatorAuthFactorConfig' => 'PhabricatorAuthDAO', + 'PhabricatorAuthFactorTOTP' => 'PhabricatorAuthFactor', + 'PhabricatorAuthFactorTOTPTestCase' => 'PhabricatorTestCase', 'PhabricatorAuthHighSecurityRequiredException' => 'Exception', 'PhabricatorAuthLinkController' => 'PhabricatorAuthController', 'PhabricatorAuthListController' => 'PhabricatorAuthProviderConfigController', @@ -3966,6 +3976,7 @@ 'PhabricatorAuthNeedsApprovalController' => 'PhabricatorAuthController', 'PhabricatorAuthNewController' => 'PhabricatorAuthProviderConfigController', 'PhabricatorAuthOldOAuthRedirectController' => 'PhabricatorAuthController', + 'PhabricatorAuthPHIDTypeAuthFactor' => 'PhabricatorPHIDType', 'PhabricatorAuthProviderConfig' => array( 0 => 'PhabricatorAuthDAO', @@ -4956,6 +4967,7 @@ 'PhabricatorSettingsPanelEmailPreferences' => 'PhabricatorSettingsPanel', 'PhabricatorSettingsPanelExternalAccounts' => 'PhabricatorSettingsPanel', 'PhabricatorSettingsPanelHomePreferences' => 'PhabricatorSettingsPanel', + 'PhabricatorSettingsPanelMultiFactor' => 'PhabricatorSettingsPanel', 'PhabricatorSettingsPanelPassword' => 'PhabricatorSettingsPanel', 'PhabricatorSettingsPanelSSHKeys' => 'PhabricatorSettingsPanel', 'PhabricatorSettingsPanelSearchPreferences' => 'PhabricatorSettingsPanel', diff --git a/src/applications/auth/factor/PhabricatorAuthFactor.php b/src/applications/auth/factor/PhabricatorAuthFactor.php new file mode 100644 --- /dev/null +++ b/src/applications/auth/factor/PhabricatorAuthFactor.php @@ -0,0 +1,51 @@ +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 new file mode 100644 --- /dev/null +++ b/src/applications/auth/factor/PhabricatorAuthFactorTOTP.php @@ -0,0 +1,179 @@ +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 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/factor/__tests__/PhabricatorAuthFactorTOTPTestCase.php b/src/applications/auth/factor/__tests__/PhabricatorAuthFactorTOTPTestCase.php new file mode 100644 --- /dev/null +++ b/src/applications/auth/factor/__tests__/PhabricatorAuthFactorTOTPTestCase.php @@ -0,0 +1,44 @@ +assertEqual( + $code, + PhabricatorAuthFactorTOTP::getTOTPCode( + new PhutilOpaqueEnvelope($key), + $time)); + } + + } + + + +} diff --git a/src/applications/auth/phid/PhabricatorAuthPHIDTypeAuthFactor.php b/src/applications/auth/phid/PhabricatorAuthPHIDTypeAuthFactor.php new file mode 100644 --- /dev/null +++ b/src/applications/auth/phid/PhabricatorAuthPHIDTypeAuthFactor.php @@ -0,0 +1,39 @@ + $handle) { + $factor = $objects[$phid]; + + $handle->setName($factor->getFactorName()); + } + } + +} diff --git a/src/applications/auth/storage/PhabricatorAuthFactorConfig.php b/src/applications/auth/storage/PhabricatorAuthFactorConfig.php new file mode 100644 --- /dev/null +++ b/src/applications/auth/storage/PhabricatorAuthFactorConfig.php @@ -0,0 +1,29 @@ + 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()); + } + +} diff --git a/src/applications/people/storage/PhabricatorUserLog.php b/src/applications/people/storage/PhabricatorUserLog.php --- a/src/applications/people/storage/PhabricatorUserLog.php +++ b/src/applications/people/storage/PhabricatorUserLog.php @@ -30,6 +30,9 @@ const ACTION_ENTER_HISEC = 'hisec-enter'; const ACTION_EXIT_HISEC = 'hisec-exit'; + const ACTION_MULTI_ADD = 'multi-add'; + const ACTION_MULTI_REMOVE = 'multi-remove'; + protected $actorPHID; protected $userPHID; protected $action; @@ -63,6 +66,8 @@ self::ACTION_CHANGE_USERNAME => pht('Change Username'), self::ACTION_ENTER_HISEC => pht('Hisec: Enter'), self::ACTION_EXIT_HISEC => pht('Hisec: Exit'), + self::ACTION_MULTI_ADD => pht('Multi-Factor: Add Factor'), + self::ACTION_MULTI_REMOVE => pht('Multi-Factor: Remove Factor'), ); } diff --git a/src/applications/settings/panel/PhabricatorSettingsPanelActivity.php b/src/applications/settings/panel/PhabricatorSettingsPanelActivity.php --- a/src/applications/settings/panel/PhabricatorSettingsPanelActivity.php +++ b/src/applications/settings/panel/PhabricatorSettingsPanelActivity.php @@ -3,6 +3,10 @@ final class PhabricatorSettingsPanelActivity extends PhabricatorSettingsPanel { + public function isEditableByAdministrators() { + return true; + } + public function getPanelKey() { return 'activity'; } diff --git a/src/applications/settings/panel/PhabricatorSettingsPanelMultiFactor.php b/src/applications/settings/panel/PhabricatorSettingsPanelMultiFactor.php new file mode 100644 --- /dev/null +++ b/src/applications/settings/panel/PhabricatorSettingsPanelMultiFactor.php @@ -0,0 +1,309 @@ +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, + 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); + } + + +}