diff --git a/resources/celerity/map.php b/resources/celerity/map.php index 5764623ad5..51bc9338d2 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -9,7 +9,7 @@ return array( 'names' => array( 'conpherence.pkg.css' => '3c8a0668', 'conpherence.pkg.js' => '020aebcf', - 'core.pkg.css' => 'e94cc920', + 'core.pkg.css' => 'e0cb8094', 'core.pkg.js' => '5c737607', 'differential.pkg.css' => 'b8df73d4', 'differential.pkg.js' => '67c9ea4c', @@ -127,7 +127,7 @@ return array( 'rsrc/css/phui/calendar/phui-calendar-list.css' => 'ccd7e4e2', 'rsrc/css/phui/calendar/phui-calendar-month.css' => 'cb758c42', 'rsrc/css/phui/calendar/phui-calendar.css' => 'f11073aa', - 'rsrc/css/phui/object-item/phui-oi-big-ui.css' => 'e5b1fb04', + 'rsrc/css/phui/object-item/phui-oi-big-ui.css' => '9e037c7a', 'rsrc/css/phui/object-item/phui-oi-color.css' => 'b517bfa0', 'rsrc/css/phui/object-item/phui-oi-drag-ui.css' => 'da15d3dc', 'rsrc/css/phui/object-item/phui-oi-flush-ui.css' => '490e2e2e', @@ -151,7 +151,7 @@ return array( 'rsrc/css/phui/phui-document.css' => '52b748a5', 'rsrc/css/phui/phui-feed-story.css' => 'a0c05029', 'rsrc/css/phui/phui-fontkit.css' => '9b714a5e', - 'rsrc/css/phui/phui-form-view.css' => '9508671e', + 'rsrc/css/phui/phui-form-view.css' => '0807e7ac', 'rsrc/css/phui/phui-form.css' => '159e2d9c', 'rsrc/css/phui/phui-head-thing.css' => 'd7f293df', 'rsrc/css/phui/phui-header-view.css' => '93cea4ec', @@ -159,7 +159,7 @@ return array( 'rsrc/css/phui/phui-icon-set-selector.css' => '7aa5f3ec', 'rsrc/css/phui/phui-icon.css' => '281f964d', 'rsrc/css/phui/phui-image-mask.css' => '62c7f4d2', - 'rsrc/css/phui/phui-info-view.css' => 'f9464caf', + 'rsrc/css/phui/phui-info-view.css' => '37b8d9ce', 'rsrc/css/phui/phui-invisible-character-view.css' => 'c694c4a4', 'rsrc/css/phui/phui-left-right.css' => '68513c34', 'rsrc/css/phui/phui-lightbox.css' => '4ebf22da', @@ -817,7 +817,7 @@ return array( 'phui-font-icon-base-css' => 'd7994e06', 'phui-fontkit-css' => '9b714a5e', 'phui-form-css' => '159e2d9c', - 'phui-form-view-css' => '9508671e', + 'phui-form-view-css' => '0807e7ac', 'phui-head-thing-view-css' => 'd7f293df', 'phui-header-view-css' => '93cea4ec', 'phui-hovercard' => '074f0783', @@ -825,14 +825,14 @@ return array( 'phui-icon-set-selector-css' => '7aa5f3ec', 'phui-icon-view-css' => '281f964d', 'phui-image-mask-css' => '62c7f4d2', - 'phui-info-view-css' => 'f9464caf', + 'phui-info-view-css' => '37b8d9ce', 'phui-inline-comment-view-css' => '48acce5b', 'phui-invisible-character-view-css' => 'c694c4a4', 'phui-left-right-css' => '68513c34', 'phui-lightbox-css' => '4ebf22da', 'phui-list-view-css' => '470b1adb', 'phui-object-box-css' => '9b58483d', - 'phui-oi-big-ui-css' => 'e5b1fb04', + 'phui-oi-big-ui-css' => '9e037c7a', 'phui-oi-color-css' => 'b517bfa0', 'phui-oi-drag-ui-css' => 'da15d3dc', 'phui-oi-flush-ui-css' => '490e2e2e', @@ -1710,6 +1710,9 @@ return array( 'javelin-uri', 'phabricator-textareautils', ), + '9e037c7a' => array( + 'phui-oi-list-view-css', + ), '9f081f05' => array( 'javelin-behavior', 'javelin-dom', @@ -2024,9 +2027,6 @@ return array( 'e562708c' => array( 'javelin-install', ), - 'e5b1fb04' => array( - 'phui-oi-list-view-css', - ), 'e5bdb730' => array( 'javelin-behavior', 'javelin-stratcom', diff --git a/resources/sql/autopatches/20190115.mfa.01.provider.sql b/resources/sql/autopatches/20190115.mfa.01.provider.sql new file mode 100644 index 0000000000..52e818f8d8 --- /dev/null +++ b/resources/sql/autopatches/20190115.mfa.01.provider.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_auth.auth_factorconfig + ADD factorProviderPHID VARBINARY(64) NOT NULL; diff --git a/resources/sql/autopatches/20190115.mfa.02.migrate.php b/resources/sql/autopatches/20190115.mfa.02.migrate.php new file mode 100644 index 0000000000..95a60789c3 --- /dev/null +++ b/resources/sql/autopatches/20190115.mfa.02.migrate.php @@ -0,0 +1,72 @@ +establishConnection('w'); + +$provider_table = new PhabricatorAuthFactorProvider(); +$provider_phid = null; +$iterator = new LiskRawMigrationIterator($conn, $table->getTableName()); +$totp_key = 'totp'; +foreach ($iterator as $row) { + + // This wasn't a TOTP factor, so skip it. + if ($row['factorKey'] !== $totp_key) { + continue; + } + + // This factor already has an associated provider. + if (strlen($row['factorProviderPHID'])) { + continue; + } + + // Find (or create) a suitable TOTP provider. Note that we can't "save()" + // an object or this migration will break if the object ever gets new + // columns; just INSERT the raw fields instead. + + if ($provider_phid === null) { + $provider_row = queryfx_one( + $conn, + 'SELECT phid FROM %R WHERE providerFactorKey = %s LIMIT 1', + $provider_table, + $totp_key); + + if ($provider_row) { + $provider_phid = $provider_row['phid']; + } else { + $provider_phid = $provider_table->generatePHID(); + queryfx( + $conn, + 'INSERT INTO %R + (phid, providerFactorKey, name, status, properties, + dateCreated, dateModified) + VALUES (%s, %s, %s, %s, %s, %d, %d)', + $provider_table, + $provider_phid, + $totp_key, + '', + 'active', + '{}', + PhabricatorTime::getNow(), + PhabricatorTime::getNow()); + } + } + + queryfx( + $conn, + 'UPDATE %R SET factorProviderPHID = %s WHERE id = %d', + $table, + $provider_phid, + $row['id']); +} diff --git a/resources/sql/autopatches/20190115.mfa.03.factorkey.sql b/resources/sql/autopatches/20190115.mfa.03.factorkey.sql new file mode 100644 index 0000000000..619787a838 --- /dev/null +++ b/resources/sql/autopatches/20190115.mfa.03.factorkey.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_auth.auth_factorconfig + DROP factorKey; diff --git a/resources/sql/autopatches/20190116.contact.01.number.sql b/resources/sql/autopatches/20190116.contact.01.number.sql new file mode 100644 index 0000000000..14e2b78d1d --- /dev/null +++ b/resources/sql/autopatches/20190116.contact.01.number.sql @@ -0,0 +1,11 @@ +CREATE TABLE {$NAMESPACE}_auth.auth_contactnumber ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + phid VARBINARY(64) NOT NULL, + objectPHID VARBINARY(64) NOT NULL, + contactNumber VARCHAR(255) NOT NULL COLLATE {$COLLATE_TEXT}, + status VARCHAR(32) NOT NULL COLLATE {$COLLATE_TEXT}, + properties LONGTEXT NOT NULL COLLATE {$COLLATE_TEXT}, + uniqueKey BINARY(12), + dateCreated INT UNSIGNED NOT NULL, + dateModified INT UNSIGNED NOT NULL +) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT}; diff --git a/resources/sql/autopatches/20190116.contact.02.xaction.sql b/resources/sql/autopatches/20190116.contact.02.xaction.sql new file mode 100644 index 0000000000..bd0d361bc5 --- /dev/null +++ b/resources/sql/autopatches/20190116.contact.02.xaction.sql @@ -0,0 +1,19 @@ +CREATE TABLE {$NAMESPACE}_auth.auth_contactnumbertransaction ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + phid VARBINARY(64) NOT NULL, + authorPHID VARBINARY(64) NOT NULL, + objectPHID VARBINARY(64) NOT NULL, + viewPolicy VARBINARY(64) NOT NULL, + editPolicy VARBINARY(64) NOT NULL, + commentPHID VARBINARY(64) DEFAULT NULL, + commentVersion INT UNSIGNED NOT NULL, + transactionType VARCHAR(32) NOT NULL, + oldValue LONGTEXT NOT NULL, + newValue LONGTEXT NOT NULL, + contentSource LONGTEXT NOT NULL, + metadata LONGTEXT NOT NULL, + dateCreated INT UNSIGNED NOT NULL, + dateModified INT UNSIGNED NOT NULL, + UNIQUE KEY `key_phid` (`phid`), + KEY `key_object` (`objectPHID`) +) ENGINE=InnoDB DEFAULT CHARSET={$CHARSET} COLLATE {$COLLATE_TEXT}; diff --git a/resources/sql/autopatches/20190121.contact.01.primary.sql b/resources/sql/autopatches/20190121.contact.01.primary.sql new file mode 100644 index 0000000000..84a7570679 --- /dev/null +++ b/resources/sql/autopatches/20190121.contact.01.primary.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_auth.auth_contactnumber + ADD isPrimary BOOL NOT NULL; diff --git a/scripts/user/account_admin.php b/scripts/user/account_admin.php index 4ad722e125..4e4500a2f7 100755 --- a/scripts/user/account_admin.php +++ b/scripts/user/account_admin.php @@ -218,6 +218,7 @@ $user->openTransaction(); ->setActor($actor) ->setActingAsPHID($people_application_phid) ->setContentSource($content_source) + ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true); $transaction_editor->applyTransactions($user, $xactions); diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 8ee28d39a7..c5723894d5 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -2076,7 +2076,6 @@ phutil_register_library_map(array( 'PhabricatorAccessLog' => 'infrastructure/log/PhabricatorAccessLog.php', 'PhabricatorAccessLogConfigOptions' => 'applications/config/option/PhabricatorAccessLogConfigOptions.php', 'PhabricatorAccessibilitySetting' => 'applications/settings/setting/PhabricatorAccessibilitySetting.php', - 'PhabricatorAccountSettingsPanel' => 'applications/settings/panel/PhabricatorAccountSettingsPanel.php', 'PhabricatorActionListView' => 'view/layout/PhabricatorActionListView.php', 'PhabricatorActionView' => 'view/layout/PhabricatorActionView.php', 'PhabricatorActivitySettingsPanel' => 'applications/settings/panel/PhabricatorActivitySettingsPanel.php', @@ -2084,6 +2083,7 @@ phutil_register_library_map(array( 'PhabricatorAjaxRequestExceptionHandler' => 'aphront/handler/PhabricatorAjaxRequestExceptionHandler.php', 'PhabricatorAlmanacApplication' => 'applications/almanac/application/PhabricatorAlmanacApplication.php', 'PhabricatorAmazonAuthProvider' => 'applications/auth/provider/PhabricatorAmazonAuthProvider.php', + 'PhabricatorAmazonSNSFuture' => 'applications/metamta/future/PhabricatorAmazonSNSFuture.php', 'PhabricatorAnchorView' => 'view/layout/PhabricatorAnchorView.php', 'PhabricatorAphlictManagementDebugWorkflow' => 'applications/aphlict/management/PhabricatorAphlictManagementDebugWorkflow.php', 'PhabricatorAphlictManagementNotifyWorkflow' => 'applications/aphlict/management/PhabricatorAphlictManagementNotifyWorkflow.php', @@ -2200,6 +2200,24 @@ phutil_register_library_map(array( 'PhabricatorAuthConduitAPIMethod' => 'applications/auth/conduit/PhabricatorAuthConduitAPIMethod.php', 'PhabricatorAuthConduitTokenRevoker' => 'applications/auth/revoker/PhabricatorAuthConduitTokenRevoker.php', 'PhabricatorAuthConfirmLinkController' => 'applications/auth/controller/PhabricatorAuthConfirmLinkController.php', + 'PhabricatorAuthContactNumber' => 'applications/auth/storage/PhabricatorAuthContactNumber.php', + 'PhabricatorAuthContactNumberController' => 'applications/auth/controller/contact/PhabricatorAuthContactNumberController.php', + 'PhabricatorAuthContactNumberDisableController' => 'applications/auth/controller/contact/PhabricatorAuthContactNumberDisableController.php', + 'PhabricatorAuthContactNumberEditController' => 'applications/auth/controller/contact/PhabricatorAuthContactNumberEditController.php', + 'PhabricatorAuthContactNumberEditEngine' => 'applications/auth/editor/PhabricatorAuthContactNumberEditEngine.php', + 'PhabricatorAuthContactNumberEditor' => 'applications/auth/editor/PhabricatorAuthContactNumberEditor.php', + 'PhabricatorAuthContactNumberMFAEngine' => 'applications/auth/engine/PhabricatorAuthContactNumberMFAEngine.php', + 'PhabricatorAuthContactNumberNumberTransaction' => 'applications/auth/xaction/PhabricatorAuthContactNumberNumberTransaction.php', + 'PhabricatorAuthContactNumberPHIDType' => 'applications/auth/phid/PhabricatorAuthContactNumberPHIDType.php', + 'PhabricatorAuthContactNumberPrimaryController' => 'applications/auth/controller/contact/PhabricatorAuthContactNumberPrimaryController.php', + 'PhabricatorAuthContactNumberPrimaryTransaction' => 'applications/auth/xaction/PhabricatorAuthContactNumberPrimaryTransaction.php', + 'PhabricatorAuthContactNumberQuery' => 'applications/auth/query/PhabricatorAuthContactNumberQuery.php', + 'PhabricatorAuthContactNumberStatusTransaction' => 'applications/auth/xaction/PhabricatorAuthContactNumberStatusTransaction.php', + 'PhabricatorAuthContactNumberTestController' => 'applications/auth/controller/contact/PhabricatorAuthContactNumberTestController.php', + 'PhabricatorAuthContactNumberTransaction' => 'applications/auth/storage/PhabricatorAuthContactNumberTransaction.php', + 'PhabricatorAuthContactNumberTransactionQuery' => 'applications/auth/query/PhabricatorAuthContactNumberTransactionQuery.php', + 'PhabricatorAuthContactNumberTransactionType' => 'applications/auth/xaction/PhabricatorAuthContactNumberTransactionType.php', + 'PhabricatorAuthContactNumberViewController' => 'applications/auth/controller/contact/PhabricatorAuthContactNumberViewController.php', 'PhabricatorAuthController' => 'applications/auth/controller/PhabricatorAuthController.php', 'PhabricatorAuthDAO' => 'applications/auth/storage/PhabricatorAuthDAO.php', 'PhabricatorAuthDisableController' => 'applications/auth/controller/config/PhabricatorAuthDisableController.php', @@ -2207,14 +2225,22 @@ phutil_register_library_map(array( 'PhabricatorAuthEditController' => 'applications/auth/controller/config/PhabricatorAuthEditController.php', 'PhabricatorAuthFactor' => 'applications/auth/factor/PhabricatorAuthFactor.php', 'PhabricatorAuthFactorConfig' => 'applications/auth/storage/PhabricatorAuthFactorConfig.php', + 'PhabricatorAuthFactorConfigQuery' => 'applications/auth/query/PhabricatorAuthFactorConfigQuery.php', 'PhabricatorAuthFactorProvider' => 'applications/auth/storage/PhabricatorAuthFactorProvider.php', 'PhabricatorAuthFactorProviderController' => 'applications/auth/controller/mfa/PhabricatorAuthFactorProviderController.php', + 'PhabricatorAuthFactorProviderDuoCredentialTransaction' => 'applications/auth/xaction/PhabricatorAuthFactorProviderDuoCredentialTransaction.php', + 'PhabricatorAuthFactorProviderDuoEnrollTransaction' => 'applications/auth/xaction/PhabricatorAuthFactorProviderDuoEnrollTransaction.php', + 'PhabricatorAuthFactorProviderDuoHostnameTransaction' => 'applications/auth/xaction/PhabricatorAuthFactorProviderDuoHostnameTransaction.php', + 'PhabricatorAuthFactorProviderDuoUsernamesTransaction' => 'applications/auth/xaction/PhabricatorAuthFactorProviderDuoUsernamesTransaction.php', 'PhabricatorAuthFactorProviderEditController' => 'applications/auth/controller/mfa/PhabricatorAuthFactorProviderEditController.php', 'PhabricatorAuthFactorProviderEditEngine' => 'applications/auth/editor/PhabricatorAuthFactorProviderEditEngine.php', 'PhabricatorAuthFactorProviderEditor' => 'applications/auth/editor/PhabricatorAuthFactorProviderEditor.php', 'PhabricatorAuthFactorProviderListController' => 'applications/auth/controller/mfa/PhabricatorAuthFactorProviderListController.php', + 'PhabricatorAuthFactorProviderMFAEngine' => 'applications/auth/engine/PhabricatorAuthFactorProviderMFAEngine.php', 'PhabricatorAuthFactorProviderNameTransaction' => 'applications/auth/xaction/PhabricatorAuthFactorProviderNameTransaction.php', 'PhabricatorAuthFactorProviderQuery' => 'applications/auth/query/PhabricatorAuthFactorProviderQuery.php', + 'PhabricatorAuthFactorProviderStatus' => 'applications/auth/constants/PhabricatorAuthFactorProviderStatus.php', + 'PhabricatorAuthFactorProviderStatusTransaction' => 'applications/auth/xaction/PhabricatorAuthFactorProviderStatusTransaction.php', 'PhabricatorAuthFactorProviderTransaction' => 'applications/auth/storage/PhabricatorAuthFactorProviderTransaction.php', 'PhabricatorAuthFactorProviderTransactionQuery' => 'applications/auth/query/PhabricatorAuthFactorProviderTransactionQuery.php', 'PhabricatorAuthFactorProviderTransactionType' => 'applications/auth/xaction/PhabricatorAuthFactorProviderTransactionType.php', @@ -2249,10 +2275,12 @@ phutil_register_library_map(array( 'PhabricatorAuthLoginMessageType' => 'applications/auth/message/PhabricatorAuthLoginMessageType.php', 'PhabricatorAuthLogoutConduitAPIMethod' => 'applications/auth/conduit/PhabricatorAuthLogoutConduitAPIMethod.php', 'PhabricatorAuthMFAEditEngineExtension' => 'applications/auth/engineextension/PhabricatorAuthMFAEditEngineExtension.php', + 'PhabricatorAuthMFASyncTemporaryTokenType' => 'applications/auth/factor/PhabricatorAuthMFASyncTemporaryTokenType.php', 'PhabricatorAuthMainMenuBarExtension' => 'applications/auth/extension/PhabricatorAuthMainMenuBarExtension.php', 'PhabricatorAuthManagementCachePKCS8Workflow' => 'applications/auth/management/PhabricatorAuthManagementCachePKCS8Workflow.php', 'PhabricatorAuthManagementLDAPWorkflow' => 'applications/auth/management/PhabricatorAuthManagementLDAPWorkflow.php', 'PhabricatorAuthManagementListFactorsWorkflow' => 'applications/auth/management/PhabricatorAuthManagementListFactorsWorkflow.php', + 'PhabricatorAuthManagementListMFAProvidersWorkflow' => 'applications/auth/management/PhabricatorAuthManagementListMFAProvidersWorkflow.php', 'PhabricatorAuthManagementRecoverWorkflow' => 'applications/auth/management/PhabricatorAuthManagementRecoverWorkflow.php', 'PhabricatorAuthManagementRefreshWorkflow' => 'applications/auth/management/PhabricatorAuthManagementRefreshWorkflow.php', 'PhabricatorAuthManagementRevokeWorkflow' => 'applications/auth/management/PhabricatorAuthManagementRevokeWorkflow.php', @@ -2279,6 +2307,7 @@ phutil_register_library_map(array( 'PhabricatorAuthNeedsApprovalController' => 'applications/auth/controller/PhabricatorAuthNeedsApprovalController.php', 'PhabricatorAuthNeedsMultiFactorController' => 'applications/auth/controller/PhabricatorAuthNeedsMultiFactorController.php', 'PhabricatorAuthNewController' => 'applications/auth/controller/config/PhabricatorAuthNewController.php', + 'PhabricatorAuthNewFactorAction' => 'applications/auth/action/PhabricatorAuthNewFactorAction.php', 'PhabricatorAuthOldOAuthRedirectController' => 'applications/auth/controller/PhabricatorAuthOldOAuthRedirectController.php', 'PhabricatorAuthOneTimeLoginController' => 'applications/auth/controller/PhabricatorAuthOneTimeLoginController.php', 'PhabricatorAuthOneTimeLoginTemporaryTokenType' => 'applications/auth/tokentype/PhabricatorAuthOneTimeLoginTemporaryTokenType.php', @@ -2341,7 +2370,6 @@ phutil_register_library_map(array( 'PhabricatorAuthSetPasswordController' => 'applications/auth/controller/PhabricatorAuthSetPasswordController.php', 'PhabricatorAuthSetupCheck' => 'applications/config/check/PhabricatorAuthSetupCheck.php', 'PhabricatorAuthStartController' => 'applications/auth/controller/PhabricatorAuthStartController.php', - 'PhabricatorAuthTOTPKeyTemporaryTokenType' => 'applications/auth/factor/PhabricatorAuthTOTPKeyTemporaryTokenType.php', 'PhabricatorAuthTemporaryToken' => 'applications/auth/storage/PhabricatorAuthTemporaryToken.php', 'PhabricatorAuthTemporaryTokenGarbageCollector' => 'applications/auth/garbagecollector/PhabricatorAuthTemporaryTokenGarbageCollector.php', 'PhabricatorAuthTemporaryTokenQuery' => 'applications/auth/query/PhabricatorAuthTemporaryTokenQuery.php', @@ -2349,6 +2377,7 @@ phutil_register_library_map(array( 'PhabricatorAuthTemporaryTokenType' => 'applications/auth/tokentype/PhabricatorAuthTemporaryTokenType.php', 'PhabricatorAuthTemporaryTokenTypeModule' => 'applications/auth/tokentype/PhabricatorAuthTemporaryTokenTypeModule.php', 'PhabricatorAuthTerminateSessionController' => 'applications/auth/controller/PhabricatorAuthTerminateSessionController.php', + 'PhabricatorAuthTestSMSAction' => 'applications/auth/action/PhabricatorAuthTestSMSAction.php', 'PhabricatorAuthTryFactorAction' => 'applications/auth/action/PhabricatorAuthTryFactorAction.php', 'PhabricatorAuthUnlinkController' => 'applications/auth/controller/PhabricatorAuthUnlinkController.php', 'PhabricatorAuthValidateController' => 'applications/auth/controller/PhabricatorAuthValidateController.php', @@ -2737,6 +2766,7 @@ phutil_register_library_map(array( 'PhabricatorConpherenceWidgetVisibleSetting' => 'applications/settings/setting/PhabricatorConpherenceWidgetVisibleSetting.php', 'PhabricatorConsoleApplication' => 'applications/console/application/PhabricatorConsoleApplication.php', 'PhabricatorConsoleContentSource' => 'infrastructure/contentsource/PhabricatorConsoleContentSource.php', + 'PhabricatorContactNumbersSettingsPanel' => 'applications/settings/panel/PhabricatorContactNumbersSettingsPanel.php', 'PhabricatorContentSource' => 'infrastructure/contentsource/PhabricatorContentSource.php', 'PhabricatorContentSourceModule' => 'infrastructure/contentsource/PhabricatorContentSourceModule.php', 'PhabricatorContentSourceView' => 'infrastructure/contentsource/PhabricatorContentSourceView.php', @@ -2774,6 +2804,7 @@ phutil_register_library_map(array( 'PhabricatorCountdownTransactionType' => 'applications/countdown/xaction/PhabricatorCountdownTransactionType.php', 'PhabricatorCountdownView' => 'applications/countdown/view/PhabricatorCountdownView.php', 'PhabricatorCountdownViewController' => 'applications/countdown/controller/PhabricatorCountdownViewController.php', + 'PhabricatorCredentialEditField' => 'applications/transactions/editfield/PhabricatorCredentialEditField.php', 'PhabricatorCursorPagedPolicyAwareQuery' => 'infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php', 'PhabricatorCustomField' => 'infrastructure/customfield/field/PhabricatorCustomField.php', 'PhabricatorCustomFieldApplicationSearchAnyFunctionDatasource' => 'infrastructure/customfield/datasource/PhabricatorCustomFieldApplicationSearchAnyFunctionDatasource.php', @@ -2960,6 +2991,8 @@ phutil_register_library_map(array( 'PhabricatorDraftEngine' => 'applications/transactions/draft/PhabricatorDraftEngine.php', 'PhabricatorDraftInterface' => 'applications/transactions/draft/PhabricatorDraftInterface.php', 'PhabricatorDrydockApplication' => 'applications/drydock/application/PhabricatorDrydockApplication.php', + 'PhabricatorDuoAuthFactor' => 'applications/auth/factor/PhabricatorDuoAuthFactor.php', + 'PhabricatorDuoFuture' => 'applications/auth/future/PhabricatorDuoFuture.php', 'PhabricatorEdgeChangeRecord' => 'infrastructure/edges/util/PhabricatorEdgeChangeRecord.php', 'PhabricatorEdgeChangeRecordTestCase' => 'infrastructure/edges/__tests__/PhabricatorEdgeChangeRecordTestCase.php', 'PhabricatorEdgeConfig' => 'infrastructure/edges/constants/PhabricatorEdgeConfig.php', @@ -3347,6 +3380,7 @@ phutil_register_library_map(array( 'PhabricatorKeyringConfigOptionType' => 'applications/files/keyring/PhabricatorKeyringConfigOptionType.php', 'PhabricatorLDAPAuthProvider' => 'applications/auth/provider/PhabricatorLDAPAuthProvider.php', 'PhabricatorLabelProfileMenuItem' => 'applications/search/menuitem/PhabricatorLabelProfileMenuItem.php', + 'PhabricatorLanguageSettingsPanel' => 'applications/settings/panel/PhabricatorLanguageSettingsPanel.php', 'PhabricatorLegalpadApplication' => 'applications/legalpad/application/PhabricatorLegalpadApplication.php', 'PhabricatorLegalpadDocumentPHIDType' => 'applications/legalpad/phid/PhabricatorLegalpadDocumentPHIDType.php', 'PhabricatorLegalpadSignaturePolicyRule' => 'applications/legalpad/policyrule/PhabricatorLegalpadSignaturePolicyRule.php', @@ -3402,6 +3436,7 @@ phutil_register_library_map(array( 'PhabricatorMacroViewController' => 'applications/macro/controller/PhabricatorMacroViewController.php', 'PhabricatorMailAdapter' => 'applications/metamta/adapter/PhabricatorMailAdapter.php', 'PhabricatorMailAmazonSESAdapter' => 'applications/metamta/adapter/PhabricatorMailAmazonSESAdapter.php', + 'PhabricatorMailAmazonSNSAdapter' => 'applications/metamta/adapter/PhabricatorMailAmazonSNSAdapter.php', 'PhabricatorMailAttachment' => 'applications/metamta/message/PhabricatorMailAttachment.php', 'PhabricatorMailConfigTestCase' => 'applications/metamta/storage/__tests__/PhabricatorMailConfigTestCase.php', 'PhabricatorMailEmailEngine' => 'applications/metamta/engine/PhabricatorMailEmailEngine.php', @@ -3436,6 +3471,7 @@ phutil_register_library_map(array( 'PhabricatorMailReceiverTestCase' => 'applications/metamta/receiver/__tests__/PhabricatorMailReceiverTestCase.php', 'PhabricatorMailReplyHandler' => 'applications/metamta/replyhandler/PhabricatorMailReplyHandler.php', 'PhabricatorMailRoutingRule' => 'applications/metamta/constants/PhabricatorMailRoutingRule.php', + 'PhabricatorMailSMSEngine' => 'applications/metamta/engine/PhabricatorMailSMSEngine.php', 'PhabricatorMailSMSMessage' => 'applications/metamta/message/PhabricatorMailSMSMessage.php', 'PhabricatorMailSMTPAdapter' => 'applications/metamta/adapter/PhabricatorMailSMTPAdapter.php', 'PhabricatorMailSendGridAdapter' => 'applications/metamta/adapter/PhabricatorMailSendGridAdapter.php', @@ -3868,6 +3904,7 @@ phutil_register_library_map(array( 'PhabricatorPholioApplication' => 'applications/pholio/application/PhabricatorPholioApplication.php', 'PhabricatorPholioMockTestDataGenerator' => 'applications/pholio/lipsum/PhabricatorPholioMockTestDataGenerator.php', 'PhabricatorPhoneNumber' => 'applications/metamta/message/PhabricatorPhoneNumber.php', + 'PhabricatorPhoneNumberTestCase' => 'applications/metamta/message/__tests__/PhabricatorPhoneNumberTestCase.php', 'PhabricatorPhortuneApplication' => 'applications/phortune/application/PhabricatorPhortuneApplication.php', 'PhabricatorPhortuneContentSource' => 'applications/phortune/contentsource/PhabricatorPhortuneContentSource.php', 'PhabricatorPhortuneManagementInvoiceWorkflow' => 'applications/phortune/management/PhabricatorPhortuneManagementInvoiceWorkflow.php', @@ -4275,6 +4312,7 @@ phutil_register_library_map(array( 'PhabricatorResourceSite' => 'aphront/site/PhabricatorResourceSite.php', 'PhabricatorRobotsController' => 'applications/system/controller/PhabricatorRobotsController.php', 'PhabricatorS3FileStorageEngine' => 'applications/files/engine/PhabricatorS3FileStorageEngine.php', + 'PhabricatorSMSAuthFactor' => 'applications/auth/factor/PhabricatorSMSAuthFactor.php', 'PhabricatorSQLPatchList' => 'infrastructure/storage/patch/PhabricatorSQLPatchList.php', 'PhabricatorSSHKeyGenerator' => 'infrastructure/util/PhabricatorSSHKeyGenerator.php', 'PhabricatorSSHKeysSettingsPanel' => 'applications/settings/panel/PhabricatorSSHKeysSettingsPanel.php', @@ -7740,7 +7778,6 @@ phutil_register_library_map(array( 'PhabricatorAccessLog' => 'Phobject', 'PhabricatorAccessLogConfigOptions' => 'PhabricatorApplicationConfigOptions', 'PhabricatorAccessibilitySetting' => 'PhabricatorSelectSetting', - 'PhabricatorAccountSettingsPanel' => 'PhabricatorEditEngineSettingsPanel', 'PhabricatorActionListView' => 'AphrontTagView', 'PhabricatorActionView' => 'AphrontView', 'PhabricatorActivitySettingsPanel' => 'PhabricatorSettingsPanel', @@ -7748,6 +7785,7 @@ phutil_register_library_map(array( 'PhabricatorAjaxRequestExceptionHandler' => 'PhabricatorRequestExceptionHandler', 'PhabricatorAlmanacApplication' => 'PhabricatorApplication', 'PhabricatorAmazonAuthProvider' => 'PhabricatorOAuth2AuthProvider', + 'PhabricatorAmazonSNSFuture' => 'PhutilAWSFuture', 'PhabricatorAnchorView' => 'AphrontView', 'PhabricatorAphlictManagementDebugWorkflow' => 'PhabricatorAphlictManagementWorkflow', 'PhabricatorAphlictManagementNotifyWorkflow' => 'PhabricatorAphlictManagementWorkflow', @@ -7882,26 +7920,63 @@ phutil_register_library_map(array( 'PhabricatorAuthConduitAPIMethod' => 'ConduitAPIMethod', 'PhabricatorAuthConduitTokenRevoker' => 'PhabricatorAuthRevoker', 'PhabricatorAuthConfirmLinkController' => 'PhabricatorAuthController', + 'PhabricatorAuthContactNumber' => array( + 'PhabricatorAuthDAO', + 'PhabricatorApplicationTransactionInterface', + 'PhabricatorPolicyInterface', + 'PhabricatorDestructibleInterface', + 'PhabricatorEditEngineMFAInterface', + ), + 'PhabricatorAuthContactNumberController' => 'PhabricatorAuthController', + 'PhabricatorAuthContactNumberDisableController' => 'PhabricatorAuthContactNumberController', + 'PhabricatorAuthContactNumberEditController' => 'PhabricatorAuthContactNumberController', + 'PhabricatorAuthContactNumberEditEngine' => 'PhabricatorEditEngine', + 'PhabricatorAuthContactNumberEditor' => 'PhabricatorApplicationTransactionEditor', + 'PhabricatorAuthContactNumberMFAEngine' => 'PhabricatorEditEngineMFAEngine', + 'PhabricatorAuthContactNumberNumberTransaction' => 'PhabricatorAuthContactNumberTransactionType', + 'PhabricatorAuthContactNumberPHIDType' => 'PhabricatorPHIDType', + 'PhabricatorAuthContactNumberPrimaryController' => 'PhabricatorAuthContactNumberController', + 'PhabricatorAuthContactNumberPrimaryTransaction' => 'PhabricatorAuthContactNumberTransactionType', + 'PhabricatorAuthContactNumberQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', + 'PhabricatorAuthContactNumberStatusTransaction' => 'PhabricatorAuthContactNumberTransactionType', + 'PhabricatorAuthContactNumberTestController' => 'PhabricatorAuthContactNumberController', + 'PhabricatorAuthContactNumberTransaction' => 'PhabricatorModularTransaction', + 'PhabricatorAuthContactNumberTransactionQuery' => 'PhabricatorApplicationTransactionQuery', + 'PhabricatorAuthContactNumberTransactionType' => 'PhabricatorModularTransactionType', + 'PhabricatorAuthContactNumberViewController' => 'PhabricatorAuthContactNumberController', 'PhabricatorAuthController' => 'PhabricatorController', 'PhabricatorAuthDAO' => 'PhabricatorLiskDAO', 'PhabricatorAuthDisableController' => 'PhabricatorAuthProviderConfigController', 'PhabricatorAuthDowngradeSessionController' => 'PhabricatorAuthController', 'PhabricatorAuthEditController' => 'PhabricatorAuthProviderConfigController', 'PhabricatorAuthFactor' => 'Phobject', - 'PhabricatorAuthFactorConfig' => 'PhabricatorAuthDAO', + 'PhabricatorAuthFactorConfig' => array( + 'PhabricatorAuthDAO', + 'PhabricatorPolicyInterface', + 'PhabricatorDestructibleInterface', + ), + 'PhabricatorAuthFactorConfigQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 'PhabricatorAuthFactorProvider' => array( 'PhabricatorAuthDAO', 'PhabricatorApplicationTransactionInterface', 'PhabricatorPolicyInterface', 'PhabricatorExtendedPolicyInterface', + 'PhabricatorEditEngineMFAInterface', ), 'PhabricatorAuthFactorProviderController' => 'PhabricatorAuthProviderController', + 'PhabricatorAuthFactorProviderDuoCredentialTransaction' => 'PhabricatorAuthFactorProviderTransactionType', + 'PhabricatorAuthFactorProviderDuoEnrollTransaction' => 'PhabricatorAuthFactorProviderTransactionType', + 'PhabricatorAuthFactorProviderDuoHostnameTransaction' => 'PhabricatorAuthFactorProviderTransactionType', + 'PhabricatorAuthFactorProviderDuoUsernamesTransaction' => 'PhabricatorAuthFactorProviderTransactionType', 'PhabricatorAuthFactorProviderEditController' => 'PhabricatorAuthFactorProviderController', 'PhabricatorAuthFactorProviderEditEngine' => 'PhabricatorEditEngine', 'PhabricatorAuthFactorProviderEditor' => 'PhabricatorApplicationTransactionEditor', 'PhabricatorAuthFactorProviderListController' => 'PhabricatorAuthProviderController', + 'PhabricatorAuthFactorProviderMFAEngine' => 'PhabricatorEditEngineMFAEngine', 'PhabricatorAuthFactorProviderNameTransaction' => 'PhabricatorAuthFactorProviderTransactionType', 'PhabricatorAuthFactorProviderQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', + 'PhabricatorAuthFactorProviderStatus' => 'Phobject', + 'PhabricatorAuthFactorProviderStatusTransaction' => 'PhabricatorAuthFactorProviderTransactionType', 'PhabricatorAuthFactorProviderTransaction' => 'PhabricatorModularTransaction', 'PhabricatorAuthFactorProviderTransactionQuery' => 'PhabricatorApplicationTransactionQuery', 'PhabricatorAuthFactorProviderTransactionType' => 'PhabricatorModularTransactionType', @@ -7939,10 +8014,12 @@ phutil_register_library_map(array( 'PhabricatorAuthLoginMessageType' => 'PhabricatorAuthMessageType', 'PhabricatorAuthLogoutConduitAPIMethod' => 'PhabricatorAuthConduitAPIMethod', 'PhabricatorAuthMFAEditEngineExtension' => 'PhabricatorEditEngineExtension', + 'PhabricatorAuthMFASyncTemporaryTokenType' => 'PhabricatorAuthTemporaryTokenType', 'PhabricatorAuthMainMenuBarExtension' => 'PhabricatorMainMenuBarExtension', 'PhabricatorAuthManagementCachePKCS8Workflow' => 'PhabricatorAuthManagementWorkflow', 'PhabricatorAuthManagementLDAPWorkflow' => 'PhabricatorAuthManagementWorkflow', 'PhabricatorAuthManagementListFactorsWorkflow' => 'PhabricatorAuthManagementWorkflow', + 'PhabricatorAuthManagementListMFAProvidersWorkflow' => 'PhabricatorAuthManagementWorkflow', 'PhabricatorAuthManagementRecoverWorkflow' => 'PhabricatorAuthManagementWorkflow', 'PhabricatorAuthManagementRefreshWorkflow' => 'PhabricatorAuthManagementWorkflow', 'PhabricatorAuthManagementRevokeWorkflow' => 'PhabricatorAuthManagementWorkflow', @@ -7974,6 +8051,7 @@ phutil_register_library_map(array( 'PhabricatorAuthNeedsApprovalController' => 'PhabricatorAuthController', 'PhabricatorAuthNeedsMultiFactorController' => 'PhabricatorAuthController', 'PhabricatorAuthNewController' => 'PhabricatorAuthProviderConfigController', + 'PhabricatorAuthNewFactorAction' => 'PhabricatorSystemAction', 'PhabricatorAuthOldOAuthRedirectController' => 'PhabricatorAuthController', 'PhabricatorAuthOneTimeLoginController' => 'PhabricatorAuthController', 'PhabricatorAuthOneTimeLoginTemporaryTokenType' => 'PhabricatorAuthTemporaryTokenType', @@ -8052,7 +8130,6 @@ phutil_register_library_map(array( 'PhabricatorAuthSetPasswordController' => 'PhabricatorAuthController', 'PhabricatorAuthSetupCheck' => 'PhabricatorSetupCheck', 'PhabricatorAuthStartController' => 'PhabricatorAuthController', - 'PhabricatorAuthTOTPKeyTemporaryTokenType' => 'PhabricatorAuthTemporaryTokenType', 'PhabricatorAuthTemporaryToken' => array( 'PhabricatorAuthDAO', 'PhabricatorPolicyInterface', @@ -8063,6 +8140,7 @@ phutil_register_library_map(array( 'PhabricatorAuthTemporaryTokenType' => 'Phobject', 'PhabricatorAuthTemporaryTokenTypeModule' => 'PhabricatorConfigModule', 'PhabricatorAuthTerminateSessionController' => 'PhabricatorAuthController', + 'PhabricatorAuthTestSMSAction' => 'PhabricatorSystemAction', 'PhabricatorAuthTryFactorAction' => 'PhabricatorSystemAction', 'PhabricatorAuthUnlinkController' => 'PhabricatorAuthController', 'PhabricatorAuthValidateController' => 'PhabricatorAuthController', @@ -8516,6 +8594,7 @@ phutil_register_library_map(array( 'PhabricatorConpherenceWidgetVisibleSetting' => 'PhabricatorInternalSetting', 'PhabricatorConsoleApplication' => 'PhabricatorApplication', 'PhabricatorConsoleContentSource' => 'PhabricatorContentSource', + 'PhabricatorContactNumbersSettingsPanel' => 'PhabricatorSettingsPanel', 'PhabricatorContentSource' => 'Phobject', 'PhabricatorContentSourceModule' => 'PhabricatorConfigModule', 'PhabricatorContentSourceView' => 'AphrontView', @@ -8564,6 +8643,7 @@ phutil_register_library_map(array( 'PhabricatorCountdownTransactionType' => 'PhabricatorModularTransactionType', 'PhabricatorCountdownView' => 'AphrontView', 'PhabricatorCountdownViewController' => 'PhabricatorCountdownController', + 'PhabricatorCredentialEditField' => 'PhabricatorEditField', 'PhabricatorCursorPagedPolicyAwareQuery' => 'PhabricatorPolicyAwareQuery', 'PhabricatorCustomField' => 'Phobject', 'PhabricatorCustomFieldApplicationSearchAnyFunctionDatasource' => 'PhabricatorTypeaheadDatasource', @@ -8768,6 +8848,8 @@ phutil_register_library_map(array( 'PhabricatorDraftDAO' => 'PhabricatorLiskDAO', 'PhabricatorDraftEngine' => 'Phobject', 'PhabricatorDrydockApplication' => 'PhabricatorApplication', + 'PhabricatorDuoAuthFactor' => 'PhabricatorAuthFactor', + 'PhabricatorDuoFuture' => 'FutureProxy', 'PhabricatorEdgeChangeRecord' => 'Phobject', 'PhabricatorEdgeChangeRecordTestCase' => 'PhabricatorTestCase', 'PhabricatorEdgeConfig' => 'PhabricatorEdgeConstants', @@ -9204,6 +9286,7 @@ phutil_register_library_map(array( 'PhabricatorKeyringConfigOptionType' => 'PhabricatorConfigJSONOptionType', 'PhabricatorLDAPAuthProvider' => 'PhabricatorAuthProvider', 'PhabricatorLabelProfileMenuItem' => 'PhabricatorProfileMenuItem', + 'PhabricatorLanguageSettingsPanel' => 'PhabricatorEditEngineSettingsPanel', 'PhabricatorLegalpadApplication' => 'PhabricatorApplication', 'PhabricatorLegalpadDocumentPHIDType' => 'PhabricatorPHIDType', 'PhabricatorLegalpadSignaturePolicyRule' => 'PhabricatorPolicyRule', @@ -9259,6 +9342,7 @@ phutil_register_library_map(array( 'PhabricatorMacroViewController' => 'PhabricatorMacroController', 'PhabricatorMailAdapter' => 'Phobject', 'PhabricatorMailAmazonSESAdapter' => 'PhabricatorMailAdapter', + 'PhabricatorMailAmazonSNSAdapter' => 'PhabricatorMailAdapter', 'PhabricatorMailAttachment' => 'Phobject', 'PhabricatorMailConfigTestCase' => 'PhabricatorTestCase', 'PhabricatorMailEmailEngine' => 'PhabricatorMailMessageEngine', @@ -9293,6 +9377,7 @@ phutil_register_library_map(array( 'PhabricatorMailReceiverTestCase' => 'PhabricatorTestCase', 'PhabricatorMailReplyHandler' => 'Phobject', 'PhabricatorMailRoutingRule' => 'Phobject', + 'PhabricatorMailSMSEngine' => 'PhabricatorMailMessageEngine', 'PhabricatorMailSMSMessage' => 'PhabricatorMailExternalMessage', 'PhabricatorMailSMTPAdapter' => 'PhabricatorMailAdapter', 'PhabricatorMailSendGridAdapter' => 'PhabricatorMailAdapter', @@ -9808,6 +9893,7 @@ phutil_register_library_map(array( 'PhabricatorPholioApplication' => 'PhabricatorApplication', 'PhabricatorPholioMockTestDataGenerator' => 'PhabricatorTestDataGenerator', 'PhabricatorPhoneNumber' => 'Phobject', + 'PhabricatorPhoneNumberTestCase' => 'PhabricatorTestCase', 'PhabricatorPhortuneApplication' => 'PhabricatorApplication', 'PhabricatorPhortuneContentSource' => 'PhabricatorContentSource', 'PhabricatorPhortuneManagementInvoiceWorkflow' => 'PhabricatorPhortuneManagementWorkflow', @@ -10340,6 +10426,7 @@ phutil_register_library_map(array( 'PhabricatorResourceSite' => 'PhabricatorSite', 'PhabricatorRobotsController' => 'PhabricatorController', 'PhabricatorS3FileStorageEngine' => 'PhabricatorFileStorageEngine', + 'PhabricatorSMSAuthFactor' => 'PhabricatorAuthFactor', 'PhabricatorSQLPatchList' => 'Phobject', 'PhabricatorSSHKeyGenerator' => 'Phobject', 'PhabricatorSSHKeysSettingsPanel' => 'PhabricatorSettingsPanel', diff --git a/src/aphront/handler/PhabricatorHighSecurityRequestExceptionHandler.php b/src/aphront/handler/PhabricatorHighSecurityRequestExceptionHandler.php index fe9af45666..2a737ecf5c 100644 --- a/src/aphront/handler/PhabricatorHighSecurityRequestExceptionHandler.php +++ b/src/aphront/handler/PhabricatorHighSecurityRequestExceptionHandler.php @@ -38,10 +38,14 @@ final class PhabricatorHighSecurityRequestExceptionHandler $request); $is_wait = false; + $is_continue = false; foreach ($results as $result) { if ($result->getIsWait()) { $is_wait = true; - break; + } + + if ($result->getIsContinue()) { + $is_continue = true; } } @@ -55,7 +59,7 @@ final class PhabricatorHighSecurityRequestExceptionHandler if ($is_wait) { $submit = pht('Wait Patiently'); - } else if ($is_upgrade) { + } else if ($is_upgrade && !$is_continue) { $submit = pht('Enter High Security'); } else { $submit = pht('Continue'); @@ -74,19 +78,21 @@ final class PhabricatorHighSecurityRequestExceptionHandler $form_layout = $form->buildLayoutView(); if ($is_upgrade) { + $messages = array( + pht( + 'You are taking an action which requires you to enter '. + 'high security.'), + ); + + $info_view = id(new PHUIInfoView()) + ->setSeverity(PHUIInfoView::SEVERITY_MFA) + ->setErrors($messages); + $dialog - ->setErrors( - array( - pht( - 'You are taking an action which requires you to enter '. - 'high security.'), - )) + ->appendChild($info_view) ->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.')) + 'To enter high security mode, confirm your credentials:')) ->appendChild($form_layout) ->appendParagraph( pht( diff --git a/src/applications/almanac/engineextension/AlmanacCacheEngineExtension.php b/src/applications/almanac/engineextension/AlmanacCacheEngineExtension.php index 20c6bbcd71..d00926232d 100644 --- a/src/applications/almanac/engineextension/AlmanacCacheEngineExtension.php +++ b/src/applications/almanac/engineextension/AlmanacCacheEngineExtension.php @@ -30,6 +30,14 @@ final class AlmanacCacheEngineExtension foreach ($interfaces as $interface) { $results[] = $interface; } + + $bindings = id(new AlmanacBindingQuery()) + ->setViewer($viewer) + ->withDevicePHIDs(mpull($devices, 'getPHID')) + ->execute(); + foreach ($bindings as $binding) { + $results[] = $binding; + } } foreach ($this->selectObjects($objects, 'AlmanacInterface') as $iface) { diff --git a/src/applications/auth/action/PhabricatorAuthNewFactorAction.php b/src/applications/auth/action/PhabricatorAuthNewFactorAction.php new file mode 100644 index 0000000000..c1244587f1 --- /dev/null +++ b/src/applications/auth/action/PhabricatorAuthNewFactorAction.php @@ -0,0 +1,21 @@ + 'PhabricatorAuthRevokeTokenController', 'session/downgrade/' => 'PhabricatorAuthDowngradeSessionController', - 'multifactor/' - => 'PhabricatorAuthNeedsMultiFactorController', + 'enroll/' => array( + '(?:(?P[^/]+)/)?(?:(?Psaved)/)?' + => 'PhabricatorAuthNeedsMultiFactorController', + ), 'sshkey/' => array( $this->getQueryRoutePattern('for/(?P[^/]+)/') => 'PhabricatorAuthSSHKeyListController', @@ -104,6 +106,18 @@ final class PhabricatorAuthApplication extends PhabricatorApplication { 'PhabricatorAuthMessageViewController', ), + 'contact/' => array( + $this->getEditRoutePattern('edit/') => + 'PhabricatorAuthContactNumberEditController', + '(?P[1-9]\d*)/' => + 'PhabricatorAuthContactNumberViewController', + '(?Pdisable|enable)/(?P[1-9]\d*)/' => + 'PhabricatorAuthContactNumberDisableController', + 'primary/(?P[1-9]\d*)/' => + 'PhabricatorAuthContactNumberPrimaryController', + 'test/(?P[1-9]\d*)/' => + 'PhabricatorAuthContactNumberTestController', + ), ), '/oauth/(?P\w+)/login/' diff --git a/src/applications/auth/constants/PhabricatorAuthFactorProviderStatus.php b/src/applications/auth/constants/PhabricatorAuthFactorProviderStatus.php new file mode 100644 index 0000000000..61d4a12576 --- /dev/null +++ b/src/applications/auth/constants/PhabricatorAuthFactorProviderStatus.php @@ -0,0 +1,103 @@ +key = $status; + $result->spec = self::newSpecification($status); + + return $result; + } + + public function getName() { + return idx($this->spec, 'name', $this->key); + } + + public function getStatusHeaderIcon() { + return idx($this->spec, 'header.icon'); + } + + public function getStatusHeaderColor() { + return idx($this->spec, 'header.color'); + } + + public function isActive() { + return ($this->key === self::STATUS_ACTIVE); + } + + public function getListIcon() { + return idx($this->spec, 'list.icon'); + } + + public function getListColor() { + return idx($this->spec, 'list.color'); + } + + public function getFactorIcon() { + return idx($this->spec, 'factor.icon'); + } + + public function getFactorColor() { + return idx($this->spec, 'factor.color'); + } + + public function getOrder() { + return idx($this->spec, 'order', 0); + } + + public static function getMap() { + $specs = self::newSpecifications(); + return ipull($specs, 'name'); + } + + private static function newSpecification($key) { + $specs = self::newSpecifications(); + return idx($specs, $key, array()); + } + + private static function newSpecifications() { + return array( + self::STATUS_ACTIVE => array( + 'name' => pht('Active'), + 'header.icon' => 'fa-check', + 'header.color' => null, + 'list.icon' => null, + 'list.color' => null, + 'factor.icon' => 'fa-check', + 'factor.color' => 'green', + 'order' => 1, + ), + self::STATUS_DEPRECATED => array( + 'name' => pht('Deprecated'), + 'header.icon' => 'fa-ban', + 'header.color' => 'indigo', + 'list.icon' => 'fa-ban', + 'list.color' => 'indigo', + 'factor.icon' => 'fa-ban', + 'factor.color' => 'indigo', + 'order' => 2, + ), + self::STATUS_DISABLED => array( + 'name' => pht('Disabled'), + 'header.icon' => 'fa-times', + 'header.color' => 'red', + 'list.icon' => 'fa-times', + 'list.color' => 'red', + 'factor.icon' => 'fa-times', + 'factor.color' => 'grey', + 'order' => 3, + ), + ); + } + +} diff --git a/src/applications/auth/controller/PhabricatorAuthNeedsMultiFactorController.php b/src/applications/auth/controller/PhabricatorAuthNeedsMultiFactorController.php index 27e03485ca..259e4c6743 100644 --- a/src/applications/auth/controller/PhabricatorAuthNeedsMultiFactorController.php +++ b/src/applications/auth/controller/PhabricatorAuthNeedsMultiFactorController.php @@ -30,80 +30,221 @@ final class PhabricatorAuthNeedsMultiFactorController return new Aphront400Response(); } - $panel = id(new PhabricatorMultiFactorSettingsPanel()) - ->setUser($viewer) - ->setViewer($viewer) - ->setOverrideURI($this->getApplicationURI('/multifactor/')) - ->processRequest($request); + $panels = $this->loadPanels(); - if ($panel instanceof AphrontResponse) { - return $panel; + $multifactor_key = id(new PhabricatorMultiFactorSettingsPanel()) + ->getPanelKey(); + + $panel_key = $request->getURIData('pageKey'); + if (!strlen($panel_key)) { + $panel_key = $multifactor_key; } - $crumbs = $this->buildApplicationCrumbs(); - $crumbs->addTextCrumb(pht('Add Multi-Factor Auth')); + if (!isset($panels[$panel_key])) { + return new Aphront404Response(); + } + + $nav = $this->newNavigation(); + $nav->selectFilter($panel_key); + + $panel = $panels[$panel_key]; $viewer->updateMultiFactorEnrollment(); - if (!$viewer->getIsEnrolledInMultiFactor()) { - $help = id(new PHUIInfoView()) - ->setTitle(pht('Add Multi-Factor Authentication To Your Account')) - ->setSeverity(PHUIInfoView::SEVERITY_WARNING) - ->setErrors( - array( - pht( - 'Before you can use Phabricator, you need to add multi-factor '. - 'authentication to your account.'), - pht( - 'Multi-factor authentication helps secure your account by '. - 'making it more difficult for attackers to gain access or '. - 'take sensitive actions.'), - pht( - 'To learn more about multi-factor authentication, click the '. - '%s button below.', - phutil_tag('strong', array(), pht('Help'))), - pht( - 'To add an authentication factor, click the %s button below.', - phutil_tag('strong', array(), pht('Add Authentication Factor'))), - pht( - 'To continue, add at least one authentication factor to your '. - 'account.'), - )); + if ($panel_key === $multifactor_key) { + $header_text = pht('Add Multi-Factor Auth'); + $help = $this->newGuidance(); + $panel->setIsEnrollment(true); } else { - $help = id(new PHUIInfoView()) - ->setTitle(pht('Multi-Factor Authentication Configured')) - ->setSeverity(PHUIInfoView::SEVERITY_NOTICE) - ->setErrors( - array( - pht( - 'You have successfully configured multi-factor authentication '. - 'for your account.'), - pht( - 'You can make adjustments from the Settings panel later.'), - pht( - 'When you are ready, %s.', - phutil_tag( - 'strong', - array(), - phutil_tag( - 'a', - array( - 'href' => '/', - ), - pht('continue to Phabricator')))), - )); + $header_text = $panel->getPanelName(); + $help = null; } - $view = array( - $help, - $panel, - ); + $response = $panel + ->setController($this) + ->setNavigation($nav) + ->processRequest($request); + + if (($response instanceof AphrontResponse) || + ($response instanceof AphrontResponseProducerInterface)) { + return $response; + } + + $crumbs = $this->buildApplicationCrumbs() + ->addTextCrumb(pht('Add Multi-Factor Auth')) + ->setBorder(true); + + $header = id(new PHUIHeaderView()) + ->setHeader($header_text); + + $view = id(new PHUITwoColumnView()) + ->setHeader($header) + ->setFooter( + array( + $help, + $response, + )); return $this->newPage() ->setTitle(pht('Add Multi-Factor Authentication')) ->setCrumbs($crumbs) + ->setNavigation($nav) ->appendChild($view); } + private function loadPanels() { + $viewer = $this->getViewer(); + $preferences = PhabricatorUserPreferences::loadUserPreferences($viewer); + + $panels = PhabricatorSettingsPanel::getAllDisplayPanels(); + $base_uri = $this->newEnrollBaseURI(); + + $result = array(); + foreach ($panels as $key => $panel) { + $panel + ->setPreferences($preferences) + ->setViewer($viewer) + ->setUser($viewer) + ->setOverrideURI(urisprintf('%s%s/', $base_uri, $key)); + + if (!$panel->isEnabled()) { + continue; + } + + if (!$panel->isUserPanel()) { + continue; + } + + if (!$panel->isMultiFactorEnrollmentPanel()) { + continue; + } + + if (!empty($result[$key])) { + throw new Exception(pht( + "Two settings panels share the same panel key ('%s'): %s, %s.", + $key, + get_class($panel), + get_class($result[$key]))); + } + + $result[$key] = $panel; + } + + return $result; + } + + + private function newNavigation() { + $viewer = $this->getViewer(); + + $enroll_uri = $this->newEnrollBaseURI(); + + $nav = id(new AphrontSideNavFilterView()) + ->setBaseURI(new PhutilURI($enroll_uri)); + + $multifactor_key = id(new PhabricatorMultiFactorSettingsPanel()) + ->getPanelKey(); + + $nav->addFilter( + $multifactor_key, + pht('Enroll in MFA'), + null, + 'fa-exclamation-triangle blue'); + + $panels = $this->loadPanels(); + + if ($panels) { + $nav->addLabel(pht('Settings')); + } + + foreach ($panels as $panel_key => $panel) { + if ($panel_key === $multifactor_key) { + continue; + } + + $nav->addFilter( + $panel->getPanelKey(), + $panel->getPanelName(), + null, + $panel->getPanelMenuIcon()); + } + + return $nav; + } + + private function newEnrollBaseURI() { + return $this->getApplicationURI('enroll/'); + } + + private function newGuidance() { + $viewer = $this->getViewer(); + + if ($viewer->getIsEnrolledInMultiFactor()) { + $guidance = pht( + '{icon check, color="green"} **Setup Complete!**'. + "\n\n". + 'You have successfully configured multi-factor authentication '. + 'for your account.'. + "\n\n". + 'You can make adjustments from the [[ /settings/ | Settings ]] panel '. + 'later.'); + + return $this->newDialog() + ->setTitle(pht('Multi-Factor Authentication Setup Complete')) + ->setWidth(AphrontDialogView::WIDTH_FULL) + ->appendChild(new PHUIRemarkupView($viewer, $guidance)) + ->addCancelButton('/', pht('Continue')); + } + + $views = array(); + + $messages = array(); + + $messages[] = pht( + 'Before you can use Phabricator, you need to add multi-factor '. + 'authentication to your account. Multi-factor authentication helps '. + 'secure your account by making it more difficult for attackers to '. + 'gain access or take sensitive actions.'); + + $view = id(new PHUIInfoView()) + ->setTitle(pht('Add Multi-Factor Authentication To Your Account')) + ->setSeverity(PHUIInfoView::SEVERITY_WARNING) + ->setErrors($messages); + + $views[] = $view; + + + $providers = id(new PhabricatorAuthFactorProviderQuery()) + ->setViewer($viewer) + ->withStatuses( + array( + PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE, + )) + ->execute(); + if (!$providers) { + $messages = array(); + + $required_key = 'security.require-multi-factor-auth'; + + $messages[] = pht( + 'This install has the configuration option "%s" enabled, but does '. + 'not have any active multifactor providers configured. This means '. + 'you are required to add MFA, but are also prevented from doing so. '. + 'An administrator must disable "%s" or enable an MFA provider to '. + 'allow you to continue.', + $required_key, + $required_key); + + $view = id(new PHUIInfoView()) + ->setTitle(pht('Multi-Factor Authentication is Misconfigured')) + ->setSeverity(PHUIInfoView::SEVERITY_ERROR) + ->setErrors($messages); + + $views[] = $view; + } + + return $views; + } + } diff --git a/src/applications/auth/controller/PhabricatorAuthStartController.php b/src/applications/auth/controller/PhabricatorAuthStartController.php index 3dc8c61a51..29fa7e0b9f 100644 --- a/src/applications/auth/controller/PhabricatorAuthStartController.php +++ b/src/applications/auth/controller/PhabricatorAuthStartController.php @@ -88,7 +88,7 @@ final class PhabricatorAuthStartController 'This Phabricator install is not configured with any enabled '. 'authentication providers which can be used to log in. If you '. 'have accidentally locked yourself out by disabling all providers, '. - 'you can use `%s` to recover access to an administrative account.', + 'you can use `%s` to recover access to an account.', 'phabricator/bin/auth recover ')); } diff --git a/src/applications/auth/controller/contact/PhabricatorAuthContactNumberController.php b/src/applications/auth/controller/contact/PhabricatorAuthContactNumberController.php new file mode 100644 index 0000000000..3ae923fbbc --- /dev/null +++ b/src/applications/auth/controller/contact/PhabricatorAuthContactNumberController.php @@ -0,0 +1,31 @@ +addTextCrumb( + pht('Contact Numbers'), + pht('/settings/panel/contact/')); + + return $crumbs; + } + +} diff --git a/src/applications/auth/controller/contact/PhabricatorAuthContactNumberDisableController.php b/src/applications/auth/controller/contact/PhabricatorAuthContactNumberDisableController.php new file mode 100644 index 0000000000..a525e7b930 --- /dev/null +++ b/src/applications/auth/controller/contact/PhabricatorAuthContactNumberDisableController.php @@ -0,0 +1,88 @@ +getViewer(); + $id = $request->getURIData('id'); + + $number = id(new PhabricatorAuthContactNumberQuery()) + ->setViewer($viewer) + ->withIDs(array($id)) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->executeOne(); + if (!$number) { + return new Aphront404Response(); + } + + $is_disable = ($request->getURIData('action') == 'disable'); + $id = $number->getID(); + $cancel_uri = $number->getURI(); + + if ($request->isFormOrHisecPost()) { + $xactions = array(); + + if ($is_disable) { + $new_status = PhabricatorAuthContactNumber::STATUS_DISABLED; + } else { + $new_status = PhabricatorAuthContactNumber::STATUS_ACTIVE; + } + + $xactions[] = id(new PhabricatorAuthContactNumberTransaction()) + ->setTransactionType( + PhabricatorAuthContactNumberStatusTransaction::TRANSACTIONTYPE) + ->setNewValue($new_status); + + $editor = id(new PhabricatorAuthContactNumberEditor()) + ->setActor($viewer) + ->setContentSourceFromRequest($request) + ->setContinueOnNoEffect(true) + ->setContinueOnMissingFields(true) + ->setCancelURI($cancel_uri); + + try { + $editor->applyTransactions($number, $xactions); + } catch (PhabricatorApplicationTransactionValidationException $ex) { + // This happens when you enable a number which collides with another + // number. + return $this->newDialog() + ->setTitle(pht('Changing Status Failed')) + ->setValidationException($ex) + ->addCancelButton($cancel_uri); + } + + return id(new AphrontRedirectResponse())->setURI($cancel_uri); + } + + $number_display = phutil_tag( + 'strong', + array(), + $number->getDisplayName()); + + if ($is_disable) { + $title = pht('Disable Contact Number'); + $body = pht( + 'Disable the contact number %s?', + $number_display); + $button = pht('Disable Number'); + } else { + $title = pht('Enable Contact Number'); + $body = pht( + 'Enable the contact number %s?', + $number_display); + $button = pht('Enable Number'); + } + + return $this->newDialog() + ->setTitle($title) + ->appendParagraph($body) + ->addSubmitButton($button) + ->addCancelButton($cancel_uri); + } + +} diff --git a/src/applications/auth/controller/contact/PhabricatorAuthContactNumberEditController.php b/src/applications/auth/controller/contact/PhabricatorAuthContactNumberEditController.php new file mode 100644 index 0000000000..95764496da --- /dev/null +++ b/src/applications/auth/controller/contact/PhabricatorAuthContactNumberEditController.php @@ -0,0 +1,12 @@ +setController($this) + ->buildResponse(); + } + +} diff --git a/src/applications/auth/controller/contact/PhabricatorAuthContactNumberPrimaryController.php b/src/applications/auth/controller/contact/PhabricatorAuthContactNumberPrimaryController.php new file mode 100644 index 0000000000..cad1bbf3fc --- /dev/null +++ b/src/applications/auth/controller/contact/PhabricatorAuthContactNumberPrimaryController.php @@ -0,0 +1,88 @@ +getViewer(); + $id = $request->getURIData('id'); + + $number = id(new PhabricatorAuthContactNumberQuery()) + ->setViewer($viewer) + ->withIDs(array($id)) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->executeOne(); + if (!$number) { + return new Aphront404Response(); + } + + $id = $number->getID(); + $cancel_uri = $number->getURI(); + + if ($number->isDisabled()) { + return $this->newDialog() + ->setTitle(pht('Number Disabled')) + ->appendParagraph( + pht( + 'You can not make a disabled number your primary contact number.')) + ->addCancelButton($cancel_uri); + } + + if ($number->getIsPrimary()) { + return $this->newDialog() + ->setTitle(pht('Number Already Primary')) + ->appendParagraph( + pht( + 'This contact number is already your primary contact number.')) + ->addCancelButton($cancel_uri); + } + + if ($request->isFormOrHisecPost()) { + $xactions = array(); + + $xactions[] = id(new PhabricatorAuthContactNumberTransaction()) + ->setTransactionType( + PhabricatorAuthContactNumberPrimaryTransaction::TRANSACTIONTYPE) + ->setNewValue(true); + + $editor = id(new PhabricatorAuthContactNumberEditor()) + ->setActor($viewer) + ->setContentSourceFromRequest($request) + ->setContinueOnNoEffect(true) + ->setContinueOnMissingFields(true) + ->setCancelURI($cancel_uri); + + try { + $editor->applyTransactions($number, $xactions); + } catch (PhabricatorApplicationTransactionValidationException $ex) { + // This happens when you try to make a number into your primary + // number, but you have contact number MFA on your account. + return $this->newDialog() + ->setTitle(pht('Unable to Make Primary')) + ->setValidationException($ex) + ->addCancelButton($cancel_uri); + } + + return id(new AphrontRedirectResponse())->setURI($cancel_uri); + } + + $number_display = phutil_tag( + 'strong', + array(), + $number->getDisplayName()); + + return $this->newDialog() + ->setTitle(pht('Set Primary Contact Number')) + ->appendParagraph( + pht( + 'Designate %s as your primary contact number?', + $number_display)) + ->addSubmitButton(pht('Make Primary')) + ->addCancelButton($cancel_uri); + } + +} diff --git a/src/applications/auth/controller/contact/PhabricatorAuthContactNumberTestController.php b/src/applications/auth/controller/contact/PhabricatorAuthContactNumberTestController.php new file mode 100644 index 0000000000..2c25fa3f4a --- /dev/null +++ b/src/applications/auth/controller/contact/PhabricatorAuthContactNumberTestController.php @@ -0,0 +1,64 @@ +getViewer(); + $id = $request->getURIData('id'); + + $number = id(new PhabricatorAuthContactNumberQuery()) + ->setViewer($viewer) + ->withIDs(array($id)) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->executeOne(); + if (!$number) { + return new Aphront404Response(); + } + + $id = $number->getID(); + $cancel_uri = $number->getURI(); + + // NOTE: This is a global limit shared by all users. + PhabricatorSystemActionEngine::willTakeAction( + array(id(new PhabricatorAuthApplication())->getPHID()), + new PhabricatorAuthTestSMSAction(), + 1); + + if ($request->isFormPost()) { + $uri = PhabricatorEnv::getURI('/'); + $uri = new PhutilURI($uri); + + $mail = id(new PhabricatorMetaMTAMail()) + ->setMessageType(PhabricatorMailSMSMessage::MESSAGETYPE) + ->addTos(array($viewer->getPHID())) + ->setSensitiveContent(false) + ->setBody( + pht( + 'This is a terse test text message from Phabricator (%s).', + $uri->getDomain())) + ->save(); + + return id(new AphrontRedirectResponse())->setURI($mail->getURI()); + } + + $number_display = phutil_tag( + 'strong', + array(), + $number->getDisplayName()); + + return $this->newDialog() + ->setTitle(pht('Set Test Message')) + ->appendParagraph( + pht( + 'Send a test message to %s?', + $number_display)) + ->addSubmitButton(pht('Send SMS')) + ->addCancelButton($cancel_uri); + } + +} diff --git a/src/applications/auth/controller/contact/PhabricatorAuthContactNumberViewController.php b/src/applications/auth/controller/contact/PhabricatorAuthContactNumberViewController.php new file mode 100644 index 0000000000..027d288dbc --- /dev/null +++ b/src/applications/auth/controller/contact/PhabricatorAuthContactNumberViewController.php @@ -0,0 +1,139 @@ +getViewer(); + + $number = id(new PhabricatorAuthContactNumberQuery()) + ->setViewer($viewer) + ->withIDs(array($request->getURIData('id'))) + ->executeOne(); + if (!$number) { + return new Aphront404Response(); + } + + $crumbs = $this->buildApplicationCrumbs() + ->addTextCrumb($number->getObjectName()) + ->setBorder(true); + + $header = $this->buildHeaderView($number); + $properties = $this->buildPropertiesView($number); + $curtain = $this->buildCurtain($number); + + $timeline = $this->buildTransactionTimeline( + $number, + new PhabricatorAuthContactNumberTransactionQuery()); + $timeline->setShouldTerminate(true); + + $view = id(new PHUITwoColumnView()) + ->setHeader($header) + ->setCurtain($curtain) + ->setMainColumn( + array( + $timeline, + )) + ->addPropertySection(pht('Details'), $properties); + + return $this->newPage() + ->setTitle($number->getDisplayName()) + ->setCrumbs($crumbs) + ->setPageObjectPHIDs( + array( + $number->getPHID(), + )) + ->appendChild($view); + } + + private function buildHeaderView(PhabricatorAuthContactNumber $number) { + $viewer = $this->getViewer(); + + $view = id(new PHUIHeaderView()) + ->setViewer($viewer) + ->setHeader($number->getObjectName()) + ->setPolicyObject($number); + + if ($number->isDisabled()) { + $view->setStatus('fa-ban', 'red', pht('Disabled')); + } else if ($number->getIsPrimary()) { + $view->setStatus('fa-certificate', 'blue', pht('Primary')); + } + + return $view; + } + + private function buildPropertiesView( + PhabricatorAuthContactNumber $number) { + $viewer = $this->getViewer(); + + $view = id(new PHUIPropertyListView()) + ->setViewer($viewer); + + $view->addProperty( + pht('Owner'), + $viewer->renderHandle($number->getObjectPHID())); + + $view->addProperty(pht('Contact Number'), $number->getDisplayName()); + + return $view; + } + + private function buildCurtain(PhabricatorAuthContactNumber $number) { + $viewer = $this->getViewer(); + $id = $number->getID(); + + $can_edit = PhabricatorPolicyFilter::hasCapability( + $viewer, + $number, + PhabricatorPolicyCapability::CAN_EDIT); + + $curtain = $this->newCurtainView($number); + + $curtain->addAction( + id(new PhabricatorActionView()) + ->setName(pht('Edit Contact Number')) + ->setIcon('fa-pencil') + ->setHref($this->getApplicationURI("contact/edit/{$id}/")) + ->setDisabled(!$can_edit) + ->setWorkflow(!$can_edit)); + + $curtain->addAction( + id(new PhabricatorActionView()) + ->setName(pht('Send Test Message')) + ->setIcon('fa-envelope-o') + ->setHref($this->getApplicationURI("contact/test/{$id}/")) + ->setDisabled(!$can_edit) + ->setWorkflow(true)); + + if ($number->isDisabled()) { + $disable_uri = $this->getApplicationURI("contact/enable/{$id}/"); + $disable_name = pht('Enable Contact Number'); + $disable_icon = 'fa-check'; + $can_primary = false; + } else { + $disable_uri = $this->getApplicationURI("contact/disable/{$id}/"); + $disable_name = pht('Disable Contact Number'); + $disable_icon = 'fa-ban'; + $can_primary = !$number->getIsPrimary(); + } + + $curtain->addAction( + id(new PhabricatorActionView()) + ->setName(pht('Make Primary Number')) + ->setIcon('fa-certificate') + ->setHref($this->getApplicationURI("contact/primary/{$id}/")) + ->setDisabled(!$can_primary) + ->setWorkflow(true)); + + $curtain->addAction( + id(new PhabricatorActionView()) + ->setName($disable_name) + ->setIcon($disable_icon) + ->setHref($disable_uri) + ->setWorkflow(true)); + + return $curtain; + } + +} diff --git a/src/applications/auth/controller/mfa/PhabricatorAuthFactorProviderEditController.php b/src/applications/auth/controller/mfa/PhabricatorAuthFactorProviderEditController.php index 0dde1b3c6f..a8d87e2ead 100644 --- a/src/applications/auth/controller/mfa/PhabricatorAuthFactorProviderEditController.php +++ b/src/applications/auth/controller/mfa/PhabricatorAuthFactorProviderEditController.php @@ -41,18 +41,33 @@ final class PhabricatorAuthFactorProviderEditController ->setBig(true) ->setFlush(true); + $factors = msortv($factors, 'newSortVector'); + foreach ($factors as $factor_key => $factor) { $factor_uri = id(new PhutilURI('/mfa/edit/')) ->setQueryParam('providerFactorKey', $factor_key); $factor_uri = $this->getApplicationURI($factor_uri); + $is_enabled = $factor->canCreateNewProvider(); + $item = id(new PHUIObjectItemView()) ->setHeader($factor->getFactorName()) - ->setHref($factor_uri) - ->setClickable(true) ->setImageIcon($factor->newIconView()) ->addAttribute($factor->getFactorCreateHelp()); + if ($is_enabled) { + $item + ->setHref($factor_uri) + ->setClickable(true); + } else { + $item->setDisabled(true); + } + + $create_description = $factor->getProviderCreateDescription(); + if ($create_description) { + $item->appendChild($create_description); + } + $menu->addItem($item); } diff --git a/src/applications/auth/controller/mfa/PhabricatorAuthFactorProviderListController.php b/src/applications/auth/controller/mfa/PhabricatorAuthFactorProviderListController.php index 293728cf36..d19671c3ce 100644 --- a/src/applications/auth/controller/mfa/PhabricatorAuthFactorProviderListController.php +++ b/src/applications/auth/controller/mfa/PhabricatorAuthFactorProviderListController.php @@ -20,6 +20,16 @@ final class PhabricatorAuthFactorProviderListController ->setHeader($provider->getDisplayName()) ->setHref($provider->getURI()); + $status = $provider->newStatus(); + + $icon = $status->getListIcon(); + $color = $status->getListColor(); + if ($icon !== null) { + $item->setStatusIcon("{$icon} {$color}", $status->getName()); + } + + $item->setDisabled(!$status->isActive()); + $list->addItem($item); } diff --git a/src/applications/auth/controller/mfa/PhabricatorAuthFactorProviderViewController.php b/src/applications/auth/controller/mfa/PhabricatorAuthFactorProviderViewController.php index 67edf2f81b..3047c8714d 100644 --- a/src/applications/auth/controller/mfa/PhabricatorAuthFactorProviderViewController.php +++ b/src/applications/auth/controller/mfa/PhabricatorAuthFactorProviderViewController.php @@ -58,6 +58,15 @@ final class PhabricatorAuthFactorProviderViewController ->setHeader($provider->getDisplayName()) ->setPolicyObject($provider); + $status = $provider->newStatus(); + + $header_icon = $status->getStatusHeaderIcon(); + $header_color = $status->getStatusHeaderColor(); + $header_name = $status->getName(); + if ($header_icon !== null) { + $view->setStatus($header_icon, $header_color, $header_name); + } + return $view; } diff --git a/src/applications/auth/editor/PhabricatorAuthContactNumberEditEngine.php b/src/applications/auth/editor/PhabricatorAuthContactNumberEditEngine.php new file mode 100644 index 0000000000..5b1a059b2f --- /dev/null +++ b/src/applications/auth/editor/PhabricatorAuthContactNumberEditEngine.php @@ -0,0 +1,86 @@ +getViewer(); + return PhabricatorAuthContactNumber::initializeNewContactNumber($viewer); + } + + protected function newObjectQuery() { + return new PhabricatorAuthContactNumberQuery(); + } + + protected function getObjectCreateTitleText($object) { + return pht('Create Contact Number'); + } + + protected function getObjectCreateButtonText($object) { + return pht('Create Contact Number'); + } + + protected function getObjectEditTitleText($object) { + return pht('Edit Contact Number'); + } + + protected function getObjectEditShortText($object) { + return $object->getObjectName(); + } + + protected function getObjectCreateShortText() { + return pht('Create Contact Number'); + } + + protected function getObjectName() { + return pht('Contact Number'); + } + + protected function getEditorURI() { + return '/auth/contact/edit/'; + } + + protected function getObjectCreateCancelURI($object) { + return '/settings/panel/contact/'; + } + + protected function getObjectViewURI($object) { + return $object->getURI(); + } + + protected function buildCustomEditFields($object) { + return array( + id(new PhabricatorTextEditField()) + ->setKey('contactNumber') + ->setTransactionType( + PhabricatorAuthContactNumberNumberTransaction::TRANSACTIONTYPE) + ->setLabel(pht('Contact Number')) + ->setDescription(pht('The contact number.')) + ->setValue($object->getContactNumber()) + ->setIsRequired(true), + ); + } + +} diff --git a/src/applications/auth/editor/PhabricatorAuthContactNumberEditor.php b/src/applications/auth/editor/PhabricatorAuthContactNumberEditor.php new file mode 100644 index 0000000000..9dfb569e89 --- /dev/null +++ b/src/applications/auth/editor/PhabricatorAuthContactNumberEditor.php @@ -0,0 +1,38 @@ +getFactor()->getFactorName(); + $factor = $object->getFactor(); + $factor_name = $factor->getFactorName(); - return array( + $status_map = PhabricatorAuthFactorProviderStatus::getMap(); + + $fields = array( id(new PhabricatorStaticEditField()) ->setKey('displayType') ->setLabel(pht('Factor Type')) @@ -109,7 +112,22 @@ final class PhabricatorAuthFactorProviderEditEngine ->setDescription(pht('Display name for the MFA provider.')) ->setValue($object->getName()) ->setPlaceholder($factor_name), + id(new PhabricatorSelectEditField()) + ->setKey('status') + ->setTransactionType( + PhabricatorAuthFactorProviderStatusTransaction::TRANSACTIONTYPE) + ->setLabel(pht('Status')) + ->setDescription(pht('Status of the MFA provider.')) + ->setValue($object->getStatus()) + ->setOptions($status_map), ); + + $factor_fields = $factor->newEditEngineFields($this, $object); + foreach ($factor_fields as $field) { + $fields[] = $field; + } + + return $fields; } } diff --git a/src/applications/auth/engine/PhabricatorAuthContactNumberMFAEngine.php b/src/applications/auth/engine/PhabricatorAuthContactNumberMFAEngine.php new file mode 100644 index 0000000000..969ca320a0 --- /dev/null +++ b/src/applications/auth/engine/PhabricatorAuthContactNumberMFAEngine.php @@ -0,0 +1,10 @@ +workflowKey = $workflow_key; @@ -65,6 +66,10 @@ final class PhabricatorAuthSessionEngine extends Phobject { return $this->workflowKey; } + public function getRequest() { + return $this->request; + } + /** * Get the session kind (e.g., anonymous, user, external account) from a @@ -462,10 +467,25 @@ final class PhabricatorAuthSessionEngine extends Phobject { return $token; } - // Load the multi-factor auth sources attached to this account. - $factors = id(new PhabricatorAuthFactorConfig())->loadAllWhere( - 'userPHID = %s', - $viewer->getPHID()); + // Load the multi-factor auth sources attached to this account. Note that + // we order factors from oldest to newest, which is not the default query + // ordering but makes the greatest sense in context. + $factors = id(new PhabricatorAuthFactorConfigQuery()) + ->setViewer($viewer) + ->withUserPHIDs(array($viewer->getPHID())) + ->withFactorProviderStatuses( + array( + PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE, + PhabricatorAuthFactorProviderStatus::STATUS_DEPRECATED, + )) + ->execute(); + + // Sort factors in the same order that they appear in on the Settings + // panel. This means that administrators changing provider statuses may + // change the order of prompts for users, but the alternative is that the + // Settings panel order disagrees with the prompt order, which seems more + // disruptive. + $factors = msort($factors, 'newSortVector'); // If the account has no associated multi-factor auth, just issue a token // without putting the session into high security mode. This is generally @@ -476,6 +496,7 @@ final class PhabricatorAuthSessionEngine extends Phobject { return $this->issueHighSecurityToken($session, true); } + $this->request = $request; foreach ($factors as $factor) { $factor->setSessionEngine($this); } @@ -516,13 +537,29 @@ final class PhabricatorAuthSessionEngine extends Phobject { foreach ($factors as $factor) { $factor_phid = $factor->getPHID(); $issued_challenges = idx($challenge_map, $factor_phid, array()); - $impl = $factor->requireImplementation(); + $provider = $factor->getFactorProvider(); + $impl = $provider->getFactor(); $new_challenges = $impl->getNewIssuedChallenges( $factor, $viewer, $issued_challenges); + // NOTE: We may get a list of challenges back, or may just get an early + // result. For example, this can happen on an SMS factor if all SMS + // mailers have been disabled. + if ($new_challenges instanceof PhabricatorAuthFactorResult) { + $result = $new_challenges; + + if (!$result->getIsValid()) { + $ok = false; + } + + $validation_results[$factor_phid] = $result; + $challenge_map[$factor_phid] = $issued_challenges; + continue; + } + foreach ($new_challenges as $new_challenge) { $issued_challenges[] = $new_challenge; } @@ -541,7 +578,10 @@ final class PhabricatorAuthSessionEngine extends Phobject { continue; } - $ok = false; + if (!$result->getIsValid()) { + $ok = false; + } + $validation_results[$factor_phid] = $result; } @@ -552,7 +592,18 @@ final class PhabricatorAuthSessionEngine extends Phobject { // Limit factor verification rates to prevent brute force attacks. $any_attempt = false; foreach ($factors as $factor) { - $impl = $factor->requireImplementation(); + $factor_phid = $factor->getPHID(); + + $provider = $factor->getFactorProvider(); + $impl = $provider->getFactor(); + + // If we already have a result (normally "wait..."), we won't try + // to validate whatever the user submitted, so this doesn't count as + // an attempt for rate limiting purposes. + if (isset($validation_results[$factor_phid])) { + continue; + } + if ($impl->getRequestHasChallengeResponse($factor, $request)) { $any_attempt = true; break; @@ -577,7 +628,8 @@ final class PhabricatorAuthSessionEngine extends Phobject { $issued_challenges = idx($challenge_map, $factor_phid, array()); - $impl = $factor->requireImplementation(); + $provider = $factor->getFactorProvider(); + $impl = $provider->getFactor(); $validation_result = $impl->getResultFromChallengeResponse( $factor, @@ -716,7 +768,10 @@ final class PhabricatorAuthSessionEngine extends Phobject { foreach ($factors as $factor) { $result = $validation_results[$factor->getPHID()]; - $factor->requireImplementation()->renderValidateFactorForm( + $provider = $factor->getFactorProvider(); + $impl = $provider->getFactor(); + + $impl->renderValidateFactorForm( $factor, $form, $viewer, diff --git a/src/applications/auth/factor/PhabricatorAuthFactor.php b/src/applications/auth/factor/PhabricatorAuthFactor.php index ef6ab5d04b..345ace3df9 100644 --- a/src/applications/auth/factor/PhabricatorAuthFactor.php +++ b/src/applications/auth/factor/PhabricatorAuthFactor.php @@ -3,10 +3,12 @@ abstract class PhabricatorAuthFactor extends Phobject { abstract public function getFactorName(); + abstract public function getFactorShortName(); abstract public function getFactorKey(); abstract public function getFactorCreateHelp(); abstract public function getFactorDescription(); abstract public function processAddFactorForm( + PhabricatorAuthFactorProvider $provider, AphrontFormView $form, AphrontRequest $request, PhabricatorUser $user); @@ -33,7 +35,7 @@ abstract class PhabricatorAuthFactor extends Phobject { protected function newConfigForUser(PhabricatorUser $user) { return id(new PhabricatorAuthFactorConfig()) ->setUserPHID($user->getPHID()) - ->setFactorKey($this->getFactorKey()); + ->setFactorSecret(''); } protected function newResult() { @@ -45,6 +47,72 @@ abstract class PhabricatorAuthFactor extends Phobject { ->setIcon('fa-mobile'); } + public function canCreateNewProvider() { + return true; + } + + public function getProviderCreateDescription() { + return null; + } + + public function canCreateNewConfiguration( + PhabricatorAuthFactorProvider $provider, + PhabricatorUser $user) { + return true; + } + + public function getConfigurationCreateDescription( + PhabricatorAuthFactorProvider $provider, + PhabricatorUser $user) { + return null; + } + + public function getConfigurationListDetails( + PhabricatorAuthFactorConfig $config, + PhabricatorAuthFactorProvider $provider, + PhabricatorUser $viewer) { + return null; + } + + public function newEditEngineFields( + PhabricatorEditEngine $engine, + PhabricatorAuthFactorProvider $provider) { + return array(); + } + + /** + * Is this a factor which depends on the user's contact number? + * + * If a user has a "contact number" factor configured, they can not modify + * or switch their primary contact number. + * + * @return bool True if this factor should lock contact numbers. + */ + public function isContactNumberFactor() { + return false; + } + + abstract public function getEnrollDescription( + PhabricatorAuthFactorProvider $provider, + PhabricatorUser $user); + + public function getEnrollButtonText( + PhabricatorAuthFactorProvider $provider, + PhabricatorUser $user) { + return pht('Continue'); + } + + public function getFactorOrder() { + return 1000; + } + + final public function newSortVector() { + return id(new PhutilSortVector()) + ->addInt($this->canCreateNewProvider() ? 0 : 1) + ->addInt($this->getFactorOrder()) + ->addString($this->getFactorName()); + } + protected function newChallenge( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer) { @@ -70,11 +138,20 @@ abstract class PhabricatorAuthFactor extends Phobject { $now = PhabricatorTime::getNow(); + // Factor implementations may need to perform writes in order to issue + // challenges, particularly push factors like SMS. + $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); + $new_challenges = $this->newIssuedChallenges( $config, $viewer, $challenges); + if ($new_challenges instanceof PhabricatorAuthFactorResult) { + unset($unguarded); + return $new_challenges; + } + assert_instances_of($new_challenges, 'PhabricatorAuthChallenge'); foreach ($new_challenges as $new_challenge) { @@ -94,10 +171,10 @@ abstract class PhabricatorAuthFactor extends Phobject { } } - $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); - foreach ($new_challenges as $challenge) { - $challenge->save(); - } + foreach ($new_challenges as $challenge) { + $challenge->save(); + } + unset($unguarded); return $new_challenges; @@ -178,6 +255,16 @@ abstract class PhabricatorAuthFactor extends Phobject { final protected function newAutomaticControl( PhabricatorAuthFactorResult $result) { + $is_error = $result->getIsError(); + if ($is_error) { + return $this->newErrorControl($result); + } + + $is_continue = $result->getIsContinue(); + if ($is_continue) { + return $this->newContinueControl($result); + } + $is_answered = (bool)$result->getAnsweredChallenge(); if ($is_answered) { return $this->newAnsweredControl($result); @@ -217,5 +304,255 @@ abstract class PhabricatorAuthFactor extends Phobject { pht('You responded to this challenge correctly.')); } + private function newErrorControl( + PhabricatorAuthFactorResult $result) { + + $error = $result->getErrorMessage(); + + $icon = id(new PHUIIconView()) + ->setIcon('fa-times', 'red'); + + return id(new PHUIFormTimerControl()) + ->setIcon($icon) + ->appendChild($error) + ->setError(pht('Error')); + } + + private function newContinueControl( + PhabricatorAuthFactorResult $result) { + + $error = $result->getErrorMessage(); + + $icon = id(new PHUIIconView()) + ->setIcon('fa-commenting', 'green'); + + return id(new PHUIFormTimerControl()) + ->setIcon($icon) + ->appendChild($error); + } + + + +/* -( Synchronizing New Factors )------------------------------------------ */ + + + final protected function loadMFASyncToken( + PhabricatorAuthFactorProvider $provider, + AphrontRequest $request, + AphrontFormView $form, + PhabricatorUser $user) { + + // If the form included a synchronization key, load the corresponding + // token. The user must synchronize to a key we generated because this + // raises the barrier to theoretical attacks where an attacker might + // provide a known key for factors like TOTP. + + // (We store and verify the hash of the key, not the key itself, to limit + // how useful the data in the table is to an attacker.) + + $sync_type = PhabricatorAuthMFASyncTemporaryTokenType::TOKENTYPE; + $sync_token = null; + + $sync_key = $request->getStr($this->getMFASyncTokenFormKey()); + if (strlen($sync_key)) { + $sync_key_digest = PhabricatorHash::digestWithNamedKey( + $sync_key, + PhabricatorAuthMFASyncTemporaryTokenType::DIGEST_KEY); + + $sync_token = id(new PhabricatorAuthTemporaryTokenQuery()) + ->setViewer($user) + ->withTokenResources(array($user->getPHID())) + ->withTokenTypes(array($sync_type)) + ->withExpired(false) + ->withTokenCodes(array($sync_key_digest)) + ->executeOne(); + } + + if (!$sync_token) { + + // Don't generate a new sync token if there are too many outstanding + // tokens already. This is mostly relevant for push factors like SMS, + // where generating a token has the side effect of sending a user a + // message. + + $outstanding_limit = 10; + $outstanding_tokens = id(new PhabricatorAuthTemporaryTokenQuery()) + ->setViewer($user) + ->withTokenResources(array($user->getPHID())) + ->withTokenTypes(array($sync_type)) + ->withExpired(false) + ->execute(); + if (count($outstanding_tokens) > $outstanding_limit) { + throw new Exception( + pht( + 'Your account has too many outstanding, incomplete MFA '. + 'synchronization attempts. Wait an hour and try again.')); + } + + $now = PhabricatorTime::getNow(); + + $sync_key = Filesystem::readRandomCharacters(32); + $sync_key_digest = PhabricatorHash::digestWithNamedKey( + $sync_key, + PhabricatorAuthMFASyncTemporaryTokenType::DIGEST_KEY); + $sync_ttl = $this->getMFASyncTokenTTL(); + + $sync_token = id(new PhabricatorAuthTemporaryToken()) + ->setIsNewTemporaryToken(true) + ->setTokenResource($user->getPHID()) + ->setTokenType($sync_type) + ->setTokenCode($sync_key_digest) + ->setTokenExpires($now + $sync_ttl); + + $properties = $this->newMFASyncTokenProperties( + $provider, + $user); + + foreach ($properties as $key => $value) { + $sync_token->setTemporaryTokenProperty($key, $value); + } + + $sync_token->save(); + } + + $form->addHiddenInput($this->getMFASyncTokenFormKey(), $sync_key); + + return $sync_token; + } + + protected function newMFASyncTokenProperties( + PhabricatorAuthFactorProvider $provider, + PhabricatorUser $user) { + return array(); + } + + private function getMFASyncTokenFormKey() { + return 'sync.key'; + } + + private function getMFASyncTokenTTL() { + return phutil_units('1 hour in seconds'); + } + + final protected function getChallengeForCurrentContext( + PhabricatorAuthFactorConfig $config, + PhabricatorUser $viewer, + array $challenges) { + + $session_phid = $viewer->getSession()->getPHID(); + $engine = $config->getSessionEngine(); + $workflow_key = $engine->getWorkflowKey(); + + foreach ($challenges as $challenge) { + if ($challenge->getSessionPHID() !== $session_phid) { + continue; + } + + if ($challenge->getWorkflowKey() !== $workflow_key) { + continue; + } + + if ($challenge->getIsCompleted()) { + continue; + } + + if ($challenge->getIsReusedChallenge()) { + continue; + } + + return $challenge; + } + + return null; + } + + + /** + * @phutil-external-symbol class QRcode + */ + final protected function newQRCode($uri) { + $root = dirname(phutil_get_library_root('phabricator')); + require_once $root.'/externals/phpqrcode/phpqrcode.php'; + + $lines = QRcode::text($uri); + + $total_width = 240; + $cell_size = floor($total_width / count($lines)); + + $rows = array(); + foreach ($lines as $line) { + $cells = array(); + for ($ii = 0; $ii < strlen($line); $ii++) { + if ($line[$ii] == '1') { + $color = '#000'; + } else { + $color = '#fff'; + } + + $cells[] = phutil_tag( + 'td', + array( + 'width' => $cell_size, + 'height' => $cell_size, + 'style' => 'background: '.$color, + ), + ''); + } + $rows[] = phutil_tag('tr', array(), $cells); + } + + return phutil_tag( + 'table', + array( + 'style' => 'margin: 24px auto;', + ), + $rows); + } + + final protected function getInstallDisplayName() { + $uri = PhabricatorEnv::getURI('/'); + $uri = new PhutilURI($uri); + return $uri->getDomain(); + } + + final protected function getChallengeResponseParameterName( + PhabricatorAuthFactorConfig $config) { + return $this->getParameterName($config, 'mfa.response'); + } + + final protected function getChallengeResponseFromRequest( + PhabricatorAuthFactorConfig $config, + AphrontRequest $request) { + + $name = $this->getChallengeResponseParameterName($config); + + $value = $request->getStr($name); + $value = (string)$value; + $value = trim($value); + + return $value; + } + + final protected function hasCSRF(PhabricatorAuthFactorConfig $config) { + $engine = $config->getSessionEngine(); + $request = $engine->getRequest(); + + if (!$request->isHTTPPost()) { + return false; + } + + return $request->validateCSRF(); + } + + final protected function loadConfigurationsForProvider( + PhabricatorAuthFactorProvider $provider, + PhabricatorUser $user) { + + return id(new PhabricatorAuthFactorConfigQuery()) + ->setViewer($user) + ->withUserPHIDs(array($user->getPHID())) + ->withFactorProviderPHIDs(array($provider->getPHID())) + ->execute(); + } } diff --git a/src/applications/auth/factor/PhabricatorAuthFactorResult.php b/src/applications/auth/factor/PhabricatorAuthFactorResult.php index faa25b4f42..2282f162a9 100644 --- a/src/applications/auth/factor/PhabricatorAuthFactorResult.php +++ b/src/applications/auth/factor/PhabricatorAuthFactorResult.php @@ -5,6 +5,8 @@ final class PhabricatorAuthFactorResult private $answeredChallenge; private $isWait = false; + private $isError = false; + private $isContinue = false; private $errorMessage; private $value; private $issuedChallenges = array(); @@ -44,6 +46,24 @@ final class PhabricatorAuthFactorResult return $this->isWait; } + public function setIsError($is_error) { + $this->isError = $is_error; + return $this; + } + + public function getIsError() { + return $this->isError; + } + + public function setIsContinue($is_continue) { + $this->isContinue = $is_continue; + return $this; + } + + public function getIsContinue() { + return $this->isContinue; + } + public function setErrorMessage($error_message) { $this->errorMessage = $error_message; return $this; diff --git a/src/applications/auth/factor/PhabricatorAuthTOTPKeyTemporaryTokenType.php b/src/applications/auth/factor/PhabricatorAuthMFASyncTemporaryTokenType.php similarity index 52% rename from src/applications/auth/factor/PhabricatorAuthTOTPKeyTemporaryTokenType.php rename to src/applications/auth/factor/PhabricatorAuthMFASyncTemporaryTokenType.php index 02f62e76be..e44da0b00c 100644 --- a/src/applications/auth/factor/PhabricatorAuthTOTPKeyTemporaryTokenType.php +++ b/src/applications/auth/factor/PhabricatorAuthMFASyncTemporaryTokenType.php @@ -1,17 +1,18 @@ loadConfigurationsForProvider($provider, $user)) { + return false; + } + + return true; + } + + public function getConfigurationCreateDescription( + PhabricatorAuthFactorProvider $provider, + PhabricatorUser $user) { + + $messages = array(); + + if ($this->loadConfigurationsForProvider($provider, $user)) { + $messages[] = id(new PHUIInfoView()) + ->setSeverity(PHUIInfoView::SEVERITY_WARNING) + ->setErrors( + array( + pht( + 'You already have Duo authentication attached to your account '. + 'for this provider.'), + )); + } + + return $messages; + } + + public function getConfigurationListDetails( + PhabricatorAuthFactorConfig $config, + PhabricatorAuthFactorProvider $provider, + PhabricatorUser $viewer) { + + $duo_user = $config->getAuthFactorConfigProperty('duo.username'); + + return pht('Duo Username: %s', $duo_user); + } + + + public function newEditEngineFields( + PhabricatorEditEngine $engine, + PhabricatorAuthFactorProvider $provider) { + + $viewer = $engine->getViewer(); + + $credential_phid = $provider->getAuthFactorProviderProperty( + self::PROP_CREDENTIAL); + + $hostname = $provider->getAuthFactorProviderProperty(self::PROP_HOSTNAME); + $usernames = $provider->getAuthFactorProviderProperty(self::PROP_USERNAMES); + $enroll = $provider->getAuthFactorProviderProperty(self::PROP_ENROLL); + + $credential_type = PassphrasePasswordCredentialType::CREDENTIAL_TYPE; + $provides_type = PassphrasePasswordCredentialType::PROVIDES_TYPE; + + $credentials = id(new PassphraseCredentialQuery()) + ->setViewer($viewer) + ->withIsDestroyed(false) + ->withProvidesTypes(array($provides_type)) + ->execute(); + + $xaction_hostname = + PhabricatorAuthFactorProviderDuoHostnameTransaction::TRANSACTIONTYPE; + $xaction_credential = + PhabricatorAuthFactorProviderDuoCredentialTransaction::TRANSACTIONTYPE; + $xaction_usernames = + PhabricatorAuthFactorProviderDuoUsernamesTransaction::TRANSACTIONTYPE; + $xaction_enroll = + PhabricatorAuthFactorProviderDuoEnrollTransaction::TRANSACTIONTYPE; + + return array( + id(new PhabricatorTextEditField()) + ->setLabel(pht('Duo API Hostname')) + ->setKey('duo.hostname') + ->setValue($hostname) + ->setTransactionType($xaction_hostname) + ->setIsRequired(true), + id(new PhabricatorCredentialEditField()) + ->setLabel(pht('Duo API Credential')) + ->setKey('duo.credential') + ->setValue($credential_phid) + ->setTransactionType($xaction_credential) + ->setCredentialType($credential_type) + ->setCredentials($credentials), + id(new PhabricatorSelectEditField()) + ->setLabel(pht('Duo Username')) + ->setKey('duo.usernames') + ->setValue($usernames) + ->setTransactionType($xaction_usernames) + ->setOptions( + array( + 'username' => pht('Use Phabricator Username'), + 'email' => pht('Use Primary Email Address'), + )), + id(new PhabricatorSelectEditField()) + ->setLabel(pht('Create Accounts')) + ->setKey('duo.enroll') + ->setValue($enroll) + ->setTransactionType($xaction_enroll) + ->setOptions( + array( + 'deny' => pht('Require Existing Duo Account'), + 'allow' => pht('Create New Duo Account'), + )), + ); + } + + + public function processAddFactorForm( + PhabricatorAuthFactorProvider $provider, + AphrontFormView $form, + AphrontRequest $request, + PhabricatorUser $user) { + + $token = $this->loadMFASyncToken($provider, $request, $form, $user); + + $enroll = $token->getTemporaryTokenProperty('duo.enroll'); + $duo_id = $token->getTemporaryTokenProperty('duo.user-id'); + $duo_uri = $token->getTemporaryTokenProperty('duo.uri'); + $duo_user = $token->getTemporaryTokenProperty('duo.username'); + + $is_external = ($enroll === 'external'); + $is_auto = ($enroll === 'auto'); + $is_blocked = ($enroll === 'blocked'); + + if (!$token->getIsNewTemporaryToken()) { + if ($is_auto) { + return $this->newDuoConfig($user, $duo_user); + } else if ($is_external || $is_blocked) { + $parameters = array( + 'username' => $duo_user, + ); + + $result = $this->newDuoFuture($provider) + ->setMethod('preauth', $parameters) + ->resolve(); + + $result_code = $result['response']['result']; + switch ($result_code) { + case 'auth': + case 'allow': + return $this->newDuoConfig($user, $duo_user); + case 'enroll': + if ($is_blocked) { + // We'll render an equivalent static control below, so skip + // rendering here. We explicitly don't want to give the user + // an enroll workflow. + break; + } + + $duo_uri = $result['response']['enroll_portal_url']; + + $waiting_icon = id(new PHUIIconView()) + ->setIcon('fa-mobile', 'red'); + + $waiting_control = id(new PHUIFormTimerControl()) + ->setIcon($waiting_icon) + ->setError(pht('Not Complete')) + ->appendChild( + pht( + 'You have not completed Duo enrollment yet. '. + 'Complete enrollment, then click continue.')); + + $form->appendControl($waiting_control); + break; + default: + case 'deny': + break; + } + } else { + $parameters = array( + 'user_id' => $duo_id, + 'activation_code' => $duo_uri, + ); + + $future = $this->newDuoFuture($provider) + ->setMethod('enroll_status', $parameters); + + $result = $future->resolve(); + $response = $result['response']; + + switch ($response) { + case 'success': + return $this->newDuoConfig($user, $duo_user); + case 'waiting': + $waiting_icon = id(new PHUIIconView()) + ->setIcon('fa-mobile', 'red'); + + $waiting_control = id(new PHUIFormTimerControl()) + ->setIcon($waiting_icon) + ->setError(pht('Not Complete')) + ->appendChild( + pht( + 'You have not activated this enrollment in the Duo '. + 'application on your phone yet. Complete activation, then '. + 'click continue.')); + + $form->appendControl($waiting_control); + break; + case 'invalid': + default: + throw new Exception( + pht( + 'This Duo enrollment attempt is invalid or has '. + 'expired ("%s"). Cancel the workflow and try again.', + $response)); + } + } + } + + if ($is_blocked) { + $blocked_icon = id(new PHUIIconView()) + ->setIcon('fa-times', 'red'); + + $blocked_control = id(new PHUIFormTimerControl()) + ->setIcon($blocked_icon) + ->appendChild( + pht( + 'Your Duo account ("%s") has not completed Duo enrollment. '. + 'Check your email and complete enrollment to continue.', + phutil_tag('strong', array(), $duo_user))); + + $form->appendControl($blocked_control); + } else if ($is_auto) { + $auto_icon = id(new PHUIIconView()) + ->setIcon('fa-check', 'green'); + + $auto_control = id(new PHUIFormTimerControl()) + ->setIcon($auto_icon) + ->appendChild( + pht( + 'Duo account ("%s") is fully enrolled.', + phutil_tag('strong', array(), $duo_user))); + + $form->appendControl($auto_control); + } else { + $duo_button = phutil_tag( + 'a', + array( + 'href' => $duo_uri, + 'class' => 'button button-grey', + 'target' => ($is_external ? '_blank' : null), + ), + pht('Enroll Duo Account: %s', $duo_user)); + + $duo_button = phutil_tag( + 'div', + array( + 'class' => 'mfa-form-enroll-button', + ), + $duo_button); + + if ($is_external) { + $form->appendRemarkupInstructions( + pht( + 'Complete enrolling your phone with Duo:')); + + $form->appendControl( + id(new AphrontFormMarkupControl()) + ->setValue($duo_button)); + } else { + + $form->appendRemarkupInstructions( + pht( + 'Scan this QR code with the Duo application on your mobile '. + 'phone:')); + + + $qr_code = $this->newQRCode($duo_uri); + $form->appendChild($qr_code); + + $form->appendRemarkupInstructions( + pht( + 'If you are currently using your phone to view this page, '. + 'click this button to open the Duo application:')); + + $form->appendControl( + id(new AphrontFormMarkupControl()) + ->setValue($duo_button)); + } + + $form->appendRemarkupInstructions( + pht( + 'Once you have completed setup on your phone, click continue.')); + } + } + + + protected function newMFASyncTokenProperties( + PhabricatorAuthFactorProvider $provider, + PhabricatorUser $user) { + + $duo_user = $this->getDuoUsername($provider, $user); + + // Duo automatically normalizes usernames to lowercase. Just do that here + // so that our value agrees more closely with Duo. + $duo_user = phutil_utf8_strtolower($duo_user); + + $parameters = array( + 'username' => $duo_user, + ); + + $result = $this->newDuoFuture($provider) + ->setMethod('preauth', $parameters) + ->resolve(); + + $external_uri = null; + $result_code = $result['response']['result']; + switch ($result_code) { + case 'auth': + case 'allow': + // If the user already has a Duo account, they don't need to do + // anything. + return array( + 'duo.enroll' => 'auto', + 'duo.username' => $duo_user, + ); + case 'enroll': + if (!$this->shouldAllowDuoEnrollment($provider)) { + return array( + 'duo.enroll' => 'blocked', + 'duo.username' => $duo_user, + ); + } + + $external_uri = $result['response']['enroll_portal_url']; + + // Otherwise, enrollment is permitted so we're going to continue. + break; + default: + case 'deny': + return $this->newResult() + ->setIsError(true) + ->setErrorMessage( + pht('Your account is not permitted to access this system.')); + } + + // Duo's "/enroll" API isn't repeatable for the same username. If we're + // the first call, great: we can do inline enrollment, which is way more + // user friendly. Otherwise, we have to send the user on an adventure. + + $parameters = array( + 'username' => $duo_user, + 'valid_secs' => phutil_units('1 hour in seconds'), + ); + + try { + $result = $this->newDuoFuture($provider) + ->setMethod('enroll', $parameters) + ->resolve(); + } catch (HTTPFutureHTTPResponseStatus $ex) { + return array( + 'duo.enroll' => 'external', + 'duo.username' => $duo_user, + 'duo.uri' => $external_uri, + ); + } + + return array( + 'duo.enroll' => 'inline', + 'duo.uri' => $result['response']['activation_code'], + 'duo.username' => $duo_user, + 'duo.user-id' => $result['response']['user_id'], + ); + } + + protected function newIssuedChallenges( + PhabricatorAuthFactorConfig $config, + PhabricatorUser $viewer, + array $challenges) { + + // If we already issued a valid challenge for this workflow and session, + // don't issue a new one. + + $challenge = $this->getChallengeForCurrentContext( + $config, + $viewer, + $challenges); + if ($challenge) { + return array(); + } + + if (!$this->hasCSRF($config)) { + return $this->newResult() + ->setIsContinue(true) + ->setErrorMessage( + pht( + 'An authorization request will be pushed to the Duo '. + 'application on your phone.')); + } + + $provider = $config->getFactorProvider(); + + // Otherwise, issue a new challenge. + $duo_user = (string)$config->getAuthFactorConfigProperty('duo.username'); + + $parameters = array( + 'username' => $duo_user, + ); + + $response = $this->newDuoFuture($provider) + ->setMethod('preauth', $parameters) + ->resolve(); + $response = $response['response']; + + $next_step = $response['result']; + $status_message = $response['status_msg']; + switch ($next_step) { + case 'auth': + // We're good to go. + break; + case 'allow': + // Duo is telling us to bypass MFA. For now, refuse. + return $this->newResult() + ->setIsError(true) + ->setErrorMessage( + pht( + 'Duo is not requiring a challenge, which defeats the '. + 'purpose of MFA. Duo must be configured to challenge you.')); + case 'enroll': + return $this->newResult() + ->setIsError(true) + ->setErrorMessage( + pht( + 'Your Duo account ("%s") requires enrollment. Contact your '. + 'Duo administrator for help. Duo status message: %s', + $duo_user, + $status_message)); + case 'deny': + default: + return $this->newResult() + ->setIsError(true) + ->setErrorMessage( + pht( + 'Duo has denied you access. Duo status message ("%s"): %s', + $next_step, + $status_message)); + } + + $has_push = false; + $devices = $response['devices']; + foreach ($devices as $device) { + $capabilities = array_fuse($device['capabilities']); + if (isset($capabilities['push'])) { + $has_push = true; + break; + } + } + + if (!$has_push) { + return $this->newResult() + ->setIsError(true) + ->setErrorMessage( + pht( + 'This factor has been removed from your device, so Phabricator '. + 'can not send you a challenge. To continue, an administrator '. + 'must strip this factor from your account.')); + } + + $push_info = array( + pht('Domain') => $this->getInstallDisplayName(), + ); + foreach ($push_info as $k => $v) { + $push_info[$k] = rawurlencode($k).'='.rawurlencode($v); + } + $push_info = implode('&', $push_info); + + $parameters = array( + 'username' => $duo_user, + 'factor' => 'push', + 'async' => '1', + + // Duo allows us to specify a device, or to pass "auto" to have it pick + // the first one. For now, just let it pick. + 'device' => 'auto', + + // This is a hard-coded prefix for the word "... request" in the Duo UI, + // which defaults to "Login". We could pass richer information from + // workflows here, but it's not very flexible anyway. + 'type' => 'Authentication', + + 'display_username' => $viewer->getUsername(), + 'pushinfo' => $push_info, + ); + + $result = $this->newDuoFuture($provider) + ->setMethod('auth', $parameters) + ->resolve(); + + $duo_xaction = $result['response']['txid']; + + // The Duo push timeout is 60 seconds. Set our challenge to expire slightly + // more quickly so that we'll re-issue a new challenge before Duo times out. + // This should keep users away from a dead-end where they can't respond to + // Duo but Phabricator won't issue a new challenge yet. + $ttl_seconds = 55; + + return array( + $this->newChallenge($config, $viewer) + ->setChallengeKey($duo_xaction) + ->setChallengeTTL(PhabricatorTime::getNow() + $ttl_seconds), + ); + } + + protected function newResultFromIssuedChallenges( + PhabricatorAuthFactorConfig $config, + PhabricatorUser $viewer, + array $challenges) { + + $challenge = $this->getChallengeForCurrentContext( + $config, + $viewer, + $challenges); + + if ($challenge->getIsAnsweredChallenge()) { + return $this->newResult() + ->setAnsweredChallenge($challenge); + } + + $provider = $config->getFactorProvider(); + $duo_xaction = $challenge->getChallengeKey(); + + $parameters = array( + 'txid' => $duo_xaction, + ); + + // This endpoint always long-polls, so use a timeout to force it to act + // more asynchronously. + try { + $result = $this->newDuoFuture($provider) + ->setHTTPMethod('GET') + ->setMethod('auth_status', $parameters) + ->setTimeout(5) + ->resolve(); + + $state = $result['response']['result']; + $status = $result['response']['status']; + } catch (HTTPFutureCURLResponseStatus $exception) { + if ($exception->isTimeout()) { + $state = 'waiting'; + $status = 'poll'; + } else { + throw $exception; + } + } + + $now = PhabricatorTime::getNow(); + + switch ($state) { + case 'allow': + $ttl = PhabricatorTime::getNow() + + phutil_units('15 minutes in seconds'); + + $challenge + ->markChallengeAsAnswered($ttl); + + return $this->newResult() + ->setAnsweredChallenge($challenge); + case 'waiting': + // No result yet, we'll render a default state later on. + break; + default: + case 'deny': + if ($status === 'timeout') { + return $this->newResult() + ->setIsError(true) + ->setErrorMessage( + pht( + 'This request has timed out because you took too long to '. + 'respond.')); + } else { + $wait_duration = ($challenge->getChallengeTTL() - $now) + 1; + + return $this->newResult() + ->setIsWait(true) + ->setErrorMessage( + pht( + 'You denied this request. Wait %s second(s) to try again.', + new PhutilNumber($wait_duration))); + } + break; + } + + return null; + } + + public function renderValidateFactorForm( + PhabricatorAuthFactorConfig $config, + AphrontFormView $form, + PhabricatorUser $viewer, + PhabricatorAuthFactorResult $result) { + + $control = $this->newAutomaticControl($result); + if (!$control) { + $result = $this->newResult() + ->setIsContinue(true) + ->setErrorMessage( + pht( + 'A challenge has been sent to your phone. Open the Duo '. + 'application and confirm the challenge, then continue.')); + $control = $this->newAutomaticControl($result); + } + + $control + ->setLabel(pht('Duo')) + ->setCaption(pht('Factor Name: %s', $config->getFactorName())); + + $form->appendChild($control); + } + + public function getRequestHasChallengeResponse( + PhabricatorAuthFactorConfig $config, + AphrontRequest $request) { + $value = $this->getChallengeResponseFromRequest($config, $request); + return (bool)strlen($value); + } + + protected function newResultFromChallengeResponse( + PhabricatorAuthFactorConfig $config, + PhabricatorUser $viewer, + AphrontRequest $request, + array $challenges) { + + $challenge = $this->getChallengeForCurrentContext( + $config, + $viewer, + $challenges); + + $code = $this->getChallengeResponseFromRequest( + $config, + $request); + + $result = $this->newResult() + ->setValue($code); + + if ($challenge->getIsAnsweredChallenge()) { + return $result->setAnsweredChallenge($challenge); + } + + if (phutil_hashes_are_identical($code, $challenge->getChallengeKey())) { + $ttl = PhabricatorTime::getNow() + phutil_units('15 minutes in seconds'); + + $challenge + ->markChallengeAsAnswered($ttl); + + return $result->setAnsweredChallenge($challenge); + } + + if (strlen($code)) { + $error_message = pht('Invalid'); + } else { + $error_message = pht('Required'); + } + + $result->setErrorMessage($error_message); + + return $result; + } + + private function newDuoFuture(PhabricatorAuthFactorProvider $provider) { + $credential_phid = $provider->getAuthFactorProviderProperty( + self::PROP_CREDENTIAL); + + $omnipotent = PhabricatorUser::getOmnipotentUser(); + + $credential = id(new PassphraseCredentialQuery()) + ->setViewer($omnipotent) + ->withPHIDs(array($credential_phid)) + ->needSecrets(true) + ->executeOne(); + if (!$credential) { + throw new Exception( + pht( + 'Unable to load Duo API credential ("%s").', + $credential_phid)); + } + + $duo_key = $credential->getUsername(); + $duo_secret = $credential->getSecret(); + if (!$duo_secret) { + throw new Exception( + pht( + 'Duo API credential ("%s") has no secret key.', + $credential_phid)); + } + + $duo_host = $provider->getAuthFactorProviderProperty( + self::PROP_HOSTNAME); + self::requireDuoAPIHostname($duo_host); + + return id(new PhabricatorDuoFuture()) + ->setIntegrationKey($duo_key) + ->setSecretKey($duo_secret) + ->setAPIHostname($duo_host) + ->setTimeout(10) + ->setHTTPMethod('POST'); + } + + private function getDuoUsername( + PhabricatorAuthFactorProvider $provider, + PhabricatorUser $user) { + + $mode = $provider->getAuthFactorProviderProperty(self::PROP_USERNAMES); + switch ($mode) { + case 'username': + return $user->getUsername(); + case 'email': + return $user->loadPrimaryEmailAddress(); + default: + throw new Exception( + pht( + 'Duo username pairing mode ("%s") is not supported.', + $mode)); + } + } + + private function shouldAllowDuoEnrollment( + PhabricatorAuthFactorProvider $provider) { + + $mode = $provider->getAuthFactorProviderProperty(self::PROP_ENROLL); + switch ($mode) { + case 'deny': + return false; + case 'allow': + return true; + default: + throw new Exception( + pht( + 'Duo enrollment mode ("%s") is not supported.', + $mode)); + } + } + + private function newDuoConfig(PhabricatorUser $user, $duo_user) { + $config_properties = array( + 'duo.username' => $duo_user, + ); + + $config = $this->newConfigForUser($user) + ->setFactorName(pht('Duo (%s)', $duo_user)) + ->setProperties($config_properties); + + return $config; + } + + public static function requireDuoAPIHostname($hostname) { + if (preg_match('/\.duosecurity\.com\z/', $hostname)) { + return; + } + + throw new Exception( + pht( + 'Duo API hostname ("%s") is invalid, hostname must be '. + '"*.duosecurity.com".', + $hostname)); + } + +} diff --git a/src/applications/auth/factor/PhabricatorSMSAuthFactor.php b/src/applications/auth/factor/PhabricatorSMSAuthFactor.php new file mode 100644 index 0000000000..ba46de980e --- /dev/null +++ b/src/applications/auth/factor/PhabricatorSMSAuthFactor.php @@ -0,0 +1,401 @@ +isSMSMailerConfigured(); + } + + public function getProviderCreateDescription() { + $messages = array(); + + if (!$this->isSMSMailerConfigured()) { + $messages[] = id(new PHUIInfoView()) + ->setErrors( + array( + pht( + 'You have not configured an outbound SMS mailer. You must '. + 'configure one before you can set up SMS. See: %s', + phutil_tag( + 'a', + array( + 'href' => '/config/edit/cluster.mailers/', + ), + 'cluster.mailers')), + )); + } + + $messages[] = id(new PHUIInfoView()) + ->setSeverity(PHUIInfoView::SEVERITY_WARNING) + ->setErrors( + array( + pht( + 'SMS is weak, and relatively easy for attackers to compromise. '. + 'Strongly consider using a different MFA provider.'), + )); + + return $messages; + } + + public function canCreateNewConfiguration( + PhabricatorAuthFactorProvider $provider, + PhabricatorUser $user) { + + if (!$this->loadUserContactNumber($user)) { + return false; + } + + if ($this->loadConfigurationsForProvider($provider, $user)) { + return false; + } + + return true; + } + + public function getConfigurationCreateDescription( + PhabricatorAuthFactorProvider $provider, + PhabricatorUser $user) { + + $messages = array(); + + if (!$this->loadUserContactNumber($user)) { + $messages[] = id(new PHUIInfoView()) + ->setSeverity(PHUIInfoView::SEVERITY_WARNING) + ->setErrors( + array( + pht( + 'You have not configured a primary contact number. Configure '. + 'a contact number before adding SMS as an authentication '. + 'factor.'), + )); + } + + if ($this->loadConfigurationsForProvider($provider, $user)) { + $messages[] = id(new PHUIInfoView()) + ->setSeverity(PHUIInfoView::SEVERITY_WARNING) + ->setErrors( + array( + pht( + 'You already have SMS authentication attached to your account.'), + )); + } + + return $messages; + } + + public function getEnrollDescription( + PhabricatorAuthFactorProvider $provider, + PhabricatorUser $user) { + return pht( + 'To verify your phone as an authentication factor, a text message with '. + 'a secret code will be sent to the phone number you have listed as '. + 'your primary contact number.'); + } + + public function getEnrollButtonText( + PhabricatorAuthFactorProvider $provider, + PhabricatorUser $user) { + $contact_number = $this->loadUserContactNumber($user); + + return pht('Send SMS: %s', $contact_number->getDisplayName()); + } + + public function processAddFactorForm( + PhabricatorAuthFactorProvider $provider, + AphrontFormView $form, + AphrontRequest $request, + PhabricatorUser $user) { + + $token = $this->loadMFASyncToken($provider, $request, $form, $user); + $code = $request->getStr('sms.code'); + + $e_code = true; + if (!$token->getIsNewTemporaryToken()) { + $expect_code = $token->getTemporaryTokenProperty('code'); + + $okay = phutil_hashes_are_identical( + $this->normalizeSMSCode($code), + $this->normalizeSMSCode($expect_code)); + + if ($okay) { + $config = $this->newConfigForUser($user) + ->setFactorName(pht('SMS')); + + return $config; + } else { + if (!strlen($code)) { + $e_code = pht('Required'); + } else { + $e_code = pht('Invalid'); + } + } + } + + $form->appendRemarkupInstructions( + pht( + 'Enter the code from the text message which was sent to your '. + 'primary contact number.')); + + $form->appendChild( + id(new PHUIFormNumberControl()) + ->setLabel(pht('SMS Code')) + ->setName('sms.code') + ->setValue($code) + ->setError($e_code)); + } + + protected function newIssuedChallenges( + PhabricatorAuthFactorConfig $config, + PhabricatorUser $viewer, + array $challenges) { + + // If we already issued a valid challenge for this workflow and session, + // don't issue a new one. + + $challenge = $this->getChallengeForCurrentContext( + $config, + $viewer, + $challenges); + if ($challenge) { + return array(); + } + + if (!$this->loadUserContactNumber($viewer)) { + return $this->newResult() + ->setIsError(true) + ->setErrorMessage( + pht( + 'Your account has no primary contact number.')); + } + + if (!$this->isSMSMailerConfigured()) { + return $this->newResult() + ->setIsError(true) + ->setErrorMessage( + pht( + 'No outbound mailer which can deliver SMS messages is '. + 'configured.')); + } + + if (!$this->hasCSRF($config)) { + return $this->newResult() + ->setIsContinue(true) + ->setErrorMessage( + pht( + 'A text message with an authorization code will be sent to your '. + 'primary contact number.')); + } + + // Otherwise, issue a new challenge. + + $challenge_code = $this->newSMSChallengeCode(); + $envelope = new PhutilOpaqueEnvelope($challenge_code); + $this->sendSMSCodeToUser($envelope, $viewer); + + $ttl_seconds = phutil_units('15 minutes in seconds'); + + return array( + $this->newChallenge($config, $viewer) + ->setChallengeKey($challenge_code) + ->setChallengeTTL(PhabricatorTime::getNow() + $ttl_seconds), + ); + } + + protected function newResultFromIssuedChallenges( + PhabricatorAuthFactorConfig $config, + PhabricatorUser $viewer, + array $challenges) { + + $challenge = $this->getChallengeForCurrentContext( + $config, + $viewer, + $challenges); + + if ($challenge->getIsAnsweredChallenge()) { + return $this->newResult() + ->setAnsweredChallenge($challenge); + } + + return null; + } + + public function renderValidateFactorForm( + PhabricatorAuthFactorConfig $config, + AphrontFormView $form, + PhabricatorUser $viewer, + PhabricatorAuthFactorResult $result) { + + $control = $this->newAutomaticControl($result); + if (!$control) { + $value = $result->getValue(); + $error = $result->getErrorMessage(); + $name = $this->getChallengeResponseParameterName($config); + + $control = id(new PHUIFormNumberControl()) + ->setName($name) + ->setDisableAutocomplete(true) + ->setValue($value) + ->setError($error); + } + + $control + ->setLabel(pht('SMS Code')) + ->setCaption(pht('Factor Name: %s', $config->getFactorName())); + + $form->appendChild($control); + } + + public function getRequestHasChallengeResponse( + PhabricatorAuthFactorConfig $config, + AphrontRequest $request) { + $value = $this->getChallengeResponseFromRequest($config, $request); + return (bool)strlen($value); + } + + protected function newResultFromChallengeResponse( + PhabricatorAuthFactorConfig $config, + PhabricatorUser $viewer, + AphrontRequest $request, + array $challenges) { + + $challenge = $this->getChallengeForCurrentContext( + $config, + $viewer, + $challenges); + + $code = $this->getChallengeResponseFromRequest( + $config, + $request); + + $result = $this->newResult() + ->setValue($code); + + if ($challenge->getIsAnsweredChallenge()) { + return $result->setAnsweredChallenge($challenge); + } + + if (phutil_hashes_are_identical($code, $challenge->getChallengeKey())) { + $ttl = PhabricatorTime::getNow() + phutil_units('15 minutes in seconds'); + + $challenge + ->markChallengeAsAnswered($ttl); + + return $result->setAnsweredChallenge($challenge); + } + + if (strlen($code)) { + $error_message = pht('Invalid'); + } else { + $error_message = pht('Required'); + } + + $result->setErrorMessage($error_message); + + return $result; + } + + private function newSMSChallengeCode() { + $value = Filesystem::readRandomInteger(0, 99999999); + $value = sprintf('%08d', $value); + return $value; + } + + private function isSMSMailerConfigured() { + $mailers = PhabricatorMetaMTAMail::newMailers( + array( + 'outbound' => true, + 'media' => array( + PhabricatorMailSMSMessage::MESSAGETYPE, + ), + )); + + return (bool)$mailers; + } + + private function loadUserContactNumber(PhabricatorUser $user) { + $contact_numbers = id(new PhabricatorAuthContactNumberQuery()) + ->setViewer($user) + ->withObjectPHIDs(array($user->getPHID())) + ->withStatuses( + array( + PhabricatorAuthContactNumber::STATUS_ACTIVE, + )) + ->withIsPrimary(true) + ->execute(); + + if (count($contact_numbers) !== 1) { + return null; + } + + return head($contact_numbers); + } + + protected function newMFASyncTokenProperties( + PhabricatorAuthFactorProvider $providerr, + PhabricatorUser $user) { + + $sms_code = $this->newSMSChallengeCode(); + + $envelope = new PhutilOpaqueEnvelope($sms_code); + $this->sendSMSCodeToUser($envelope, $user); + + return array( + 'code' => $sms_code, + ); + } + + private function sendSMSCodeToUser( + PhutilOpaqueEnvelope $envelope, + PhabricatorUser $user) { + return id(new PhabricatorMetaMTAMail()) + ->setMessageType(PhabricatorMailSMSMessage::MESSAGETYPE) + ->addTos(array($user->getPHID())) + ->setForceDelivery(true) + ->setSensitiveContent(true) + ->setBody( + pht( + 'Phabricator (%s) MFA Code: %s', + $this->getInstallDisplayName(), + $envelope->openEnvelope())) + ->save(); + } + + private function normalizeSMSCode($code) { + return trim($code); + } + +} diff --git a/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php b/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php index 3632ca5c45..ba6613c014 100644 --- a/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php +++ b/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php @@ -2,8 +2,6 @@ final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor { - const DIGEST_TEMPORARY_KEY = 'mfa.totp.sync'; - public function getFactorKey() { return 'totp'; } @@ -12,6 +10,10 @@ final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor { return pht('Mobile Phone App (TOTP)'); } + public function getFactorShortName() { + return pht('TOTP'); + } + public function getFactorCreateHelp() { return pht( 'Allow users to attach a mobile authenticator application (like '. @@ -25,73 +27,57 @@ final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor { 'authenticate, you will enter a code shown on your phone.'); } + public function getEnrollDescription( + PhabricatorAuthFactorProvider $provider, + PhabricatorUser $user) { + + return pht( + 'To add a TOTP factor to your account, you will first need to install '. + 'a mobile authenticator application on your phone. Two applications '. + 'which work well are **Google Authenticator** and **Authy**, but any '. + 'other TOTP application should also work.'. + "\n\n". + 'If you haven\'t already, download and install a TOTP application on '. + 'your phone now. Once you\'ve launched the application and are ready '. + 'to add a new TOTP code, continue to the next step.'); + } + + public function getConfigurationListDetails( + PhabricatorAuthFactorConfig $config, + PhabricatorAuthFactorProvider $provider, + PhabricatorUser $viewer) { + + $bits = strlen($config->getFactorSecret()) * 8; + return pht('%d-Bit Secret', $bits); + } + public function processAddFactorForm( + PhabricatorAuthFactorProvider $provider, AphrontFormView $form, AphrontRequest $request, PhabricatorUser $user) { - $totp_token_type = PhabricatorAuthTOTPKeyTemporaryTokenType::TOKENTYPE; - - $key = $request->getStr('totpkey'); - if (strlen($key)) { - // If the user is providing a key, make sure it's a key we generated. - // This raises the barrier to theoretical attacks where an attacker might - // provide a known key (such attacks are already prevented by CSRF, but - // this is a second barrier to overcome). - - // (We store and verify the hash of the key, not the key itself, to limit - // how useful the data in the table is to an attacker.) - - $token_code = PhabricatorHash::digestWithNamedKey( - $key, - self::DIGEST_TEMPORARY_KEY); - - $temporary_token = id(new PhabricatorAuthTemporaryTokenQuery()) - ->setViewer($user) - ->withTokenResources(array($user->getPHID())) - ->withTokenTypes(array($totp_token_type)) - ->withExpired(false) - ->withTokenCodes(array($token_code)) - ->executeOne(); - if (!$temporary_token) { - // If we don't have a matching token, regenerate the key below. - $key = null; - } - } - - if (!strlen($key)) { - $key = self::generateNewTOTPKey(); - - // Mark this key as one we generated, so the user is allowed to submit - // a response for it. - - $token_code = PhabricatorHash::digestWithNamedKey( - $key, - self::DIGEST_TEMPORARY_KEY); - - $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); - id(new PhabricatorAuthTemporaryToken()) - ->setTokenResource($user->getPHID()) - ->setTokenType($totp_token_type) - ->setTokenExpires(time() + phutil_units('1 hour in seconds')) - ->setTokenCode($token_code) - ->save(); - unset($unguarded); - } + $sync_token = $this->loadMFASyncToken( + $provider, + $request, + $form, + $user); + $secret = $sync_token->getTemporaryTokenProperty('secret'); $code = $request->getStr('totpcode'); $e_code = true; - if ($request->getExists('totp')) { + if (!$sync_token->getIsNewTemporaryToken()) { $okay = (bool)$this->getTimestepAtWhichResponseIsValid( $this->getAllowedTimesteps($this->getCurrentTimestep()), - new PhutilOpaqueEnvelope($key), + new PhutilOpaqueEnvelope($secret), $code); if ($okay) { $config = $this->newConfigForUser($user) ->setFactorName(pht('Mobile App (TOTP)')) - ->setFactorSecret($key); + ->setFactorSecret($secret) + ->setMFASyncToken($sync_token); return $config; } else { @@ -103,20 +89,10 @@ final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor { } } - $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, scan the QR code or '. - 'manually enter the key shown below into the application.')); + 'Scan the QR code or manually enter the key shown below into the '. + 'application.')); $prod_uri = new PhutilURI(PhabricatorEnv::getProductionURI('/')); $issuer = $prod_uri->getDomain(); @@ -125,16 +101,16 @@ final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor { 'otpauth://totp/%s:%s?secret=%s&issuer=%s', $issuer, $user->getUsername(), - $key, + $secret, $issuer); - $qrcode = $this->renderQRCode($uri); + $qrcode = $this->newQRCode($uri); $form->appendChild($qrcode); $form->appendChild( id(new AphrontFormStaticControl()) ->setLabel(pht('Key')) - ->setValue(phutil_tag('strong', array(), $key))); + ->setValue(phutil_tag('strong', array(), $secret))); $form->appendInstructions( pht( @@ -428,49 +404,6 @@ final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor { return $code; } - - /** - * @phutil-external-symbol class QRcode - */ - private function renderQRCode($uri) { - $root = dirname(phutil_get_library_root('phabricator')); - require_once $root.'/externals/phpqrcode/phpqrcode.php'; - - $lines = QRcode::text($uri); - - $total_width = 240; - $cell_size = floor($total_width / count($lines)); - - $rows = array(); - foreach ($lines as $line) { - $cells = array(); - for ($ii = 0; $ii < strlen($line); $ii++) { - if ($line[$ii] == '1') { - $color = '#000'; - } else { - $color = '#fff'; - } - - $cells[] = phutil_tag( - 'td', - array( - 'width' => $cell_size, - 'height' => $cell_size, - 'style' => 'background: '.$color, - ), - ''); - } - $rows[] = phutil_tag('tr', array(), $cells); - } - - return phutil_tag( - 'table', - array( - 'style' => 'margin: 24px auto;', - ), - $rows); - } - private function getTimestepDuration() { return 30; } @@ -508,21 +441,12 @@ final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor { return null; } - private function getChallengeResponseParameterName( - PhabricatorAuthFactorConfig $config) { - return $this->getParameterName($config, 'totpcode'); + protected function newMFASyncTokenProperties( + PhabricatorAuthFactorProvider $providerr, + PhabricatorUser $user) { + return array( + 'secret' => self::generateNewTOTPKey(), + ); } - private function getChallengeResponseFromRequest( - PhabricatorAuthFactorConfig $config, - AphrontRequest $request) { - - $name = $this->getChallengeResponseParameterName($config); - - $value = $request->getStr($name); - $value = (string)$value; - $value = trim($value); - - return $value; - } } diff --git a/src/applications/auth/future/PhabricatorDuoFuture.php b/src/applications/auth/future/PhabricatorDuoFuture.php new file mode 100644 index 0000000000..fd95906da1 --- /dev/null +++ b/src/applications/auth/future/PhabricatorDuoFuture.php @@ -0,0 +1,155 @@ +integrationKey = $integration_key; + return $this; + } + + public function setSecretKey(PhutilOpaqueEnvelope $key) { + $this->secretKey = $key; + return $this; + } + + public function setAPIHostname($hostname) { + $this->apiHostname = $hostname; + return $this; + } + + public function setMethod($method, array $parameters) { + $this->method = $method; + $this->parameters = $parameters; + return $this; + } + + public function setTimeout($timeout) { + $this->timeout = $timeout; + return $this; + } + + public function getTimeout() { + return $this->timeout; + } + + public function setHTTPMethod($method) { + $this->httpMethod = $method; + return $this; + } + + public function getHTTPMethod() { + return $this->httpMethod; + } + + protected function getProxiedFuture() { + if (!$this->future) { + if ($this->integrationKey === null) { + throw new PhutilInvalidStateException('setIntegrationKey'); + } + + if ($this->secretKey === null) { + throw new PhutilInvalidStateException('setSecretKey'); + } + + if ($this->apiHostname === null) { + throw new PhutilInvalidStateException('setAPIHostname'); + } + + if ($this->method === null || $this->parameters === null) { + throw new PhutilInvalidStateException('setMethod'); + } + + $path = (string)urisprintf('/auth/v2/%s', $this->method); + + $host = $this->apiHostname; + $host = phutil_utf8_strtolower($host); + + $uri = id(new PhutilURI('')) + ->setProtocol('https') + ->setDomain($host) + ->setPath($path); + + $data = $this->parameters; + $date = date('r'); + + $http_method = $this->getHTTPMethod(); + + ksort($data); + $data_parts = array(); + foreach ($data as $key => $value) { + $data_parts[] = rawurlencode($key).'='.rawurlencode($value); + } + $data_parts = implode('&', $data_parts); + + $corpus = array( + $date, + $http_method, + $host, + $path, + $data_parts, + ); + $corpus = implode("\n", $corpus); + + $signature = hash_hmac( + 'sha1', + $corpus, + $this->secretKey->openEnvelope()); + $signature = new PhutilOpaqueEnvelope($signature); + + if ($http_method === 'GET') { + $uri->setQueryParams($data); + $data = array(); + } + + $future = id(new HTTPSFuture($uri, $data)) + ->setHTTPBasicAuthCredentials($this->integrationKey, $signature) + ->setMethod($http_method) + ->addHeader('Accept', 'application/json') + ->addHeader('Date', $date); + + $timeout = $this->getTimeout(); + if ($timeout) { + $future->setTimeout($timeout); + } + + $this->future = $future; + } + + return $this->future; + } + + protected function didReceiveResult($result) { + list($status, $body, $headers) = $result; + + if ($status->isError()) { + throw $status; + } + + try { + $data = phutil_json_decode($body); + } catch (PhutilJSONParserException $ex) { + throw new PhutilProxyException( + pht('Expected JSON response from Duo.'), + $ex); + } + + return $data; + } + +} diff --git a/src/applications/auth/management/PhabricatorAuthManagementListFactorsWorkflow.php b/src/applications/auth/management/PhabricatorAuthManagementListFactorsWorkflow.php index 1367335cd2..1e4ab7d8df 100644 --- a/src/applications/auth/management/PhabricatorAuthManagementListFactorsWorkflow.php +++ b/src/applications/auth/management/PhabricatorAuthManagementListFactorsWorkflow.php @@ -14,9 +14,8 @@ final class PhabricatorAuthManagementListFactorsWorkflow public function execute(PhutilArgumentParser $args) { $factors = PhabricatorAuthFactor::getAllFactors(); - $console = PhutilConsole::getConsole(); foreach ($factors as $factor) { - $console->writeOut( + echo tsprintf( "%s\t%s\n", $factor->getFactorKey(), $factor->getFactorName()); diff --git a/src/applications/auth/management/PhabricatorAuthManagementListMFAProvidersWorkflow.php b/src/applications/auth/management/PhabricatorAuthManagementListMFAProvidersWorkflow.php new file mode 100644 index 0000000000..8121bf955f --- /dev/null +++ b/src/applications/auth/management/PhabricatorAuthManagementListMFAProvidersWorkflow.php @@ -0,0 +1,33 @@ +setName('list-mfa-providers') + ->setExamples('**list-mfa-providerrs**') + ->setSynopsis( + pht( + 'List available multi-factor authentication providers.')) + ->setArguments(array()); + } + + public function execute(PhutilArgumentParser $args) { + $viewer = $this->getViewer(); + + $providers = id(new PhabricatorAuthFactorProviderQuery()) + ->setViewer($viewer) + ->execute(); + + foreach ($providers as $provider) { + echo tsprintf( + "%s\t%s\n", + $provider->getPHID(), + $provider->getDisplayName()); + } + + return 0; + } + +} diff --git a/src/applications/auth/management/PhabricatorAuthManagementStripWorkflow.php b/src/applications/auth/management/PhabricatorAuthManagementStripWorkflow.php index f25d05301b..22bfacb6f4 100644 --- a/src/applications/auth/management/PhabricatorAuthManagementStripWorkflow.php +++ b/src/applications/auth/management/PhabricatorAuthManagementStripWorkflow.php @@ -24,12 +24,22 @@ final class PhabricatorAuthManagementStripWorkflow 'name' => 'type', 'param' => 'factortype', 'repeat' => true, - 'help' => pht('Strip a specific factor type.'), + 'help' => pht( + 'Strip a specific factor type. Use `bin/auth list-factors` for '. + 'a list of factor types.'), ), array( 'name' => 'all-types', 'help' => pht('Strip all factors, regardless of type.'), ), + array( + 'name' => 'provider', + 'param' => 'phid', + 'repeat' => true, + 'help' => pht( + 'Strip factors for a specific provider. Use '. + '`bin/auth list-mfa-providers` for a list of providers.'), + ), array( 'name' => 'force', 'help' => pht('Strip factors without prompting.'), @@ -42,6 +52,8 @@ final class PhabricatorAuthManagementStripWorkflow } public function execute(PhutilArgumentParser $args) { + $viewer = $this->getViewer(); + $usernames = $args->getArg('user'); $all_users = $args->getArg('all-users'); @@ -55,10 +67,8 @@ final class PhabricatorAuthManagementStripWorkflow } else if (!$usernames && !$all_users) { throw new PhutilArgumentUsageException( pht( - 'Use %s to specify which user to strip factors from, or '. - '%s to strip factors from all users.', - '--user', - '--all-users')); + 'Use "--user " to specify which user to strip factors '. + 'from, or "--all-users" to strip factors from all users.')); } else if ($usernames) { $users = id(new PhabricatorPeopleQuery()) ->setViewer($this->getViewer()) @@ -79,37 +89,83 @@ final class PhabricatorAuthManagementStripWorkflow } $types = $args->getArg('type'); + $provider_phids = $args->getArg('provider'); $all_types = $args->getArg('all-types'); if ($types && $all_types) { throw new PhutilArgumentUsageException( pht( - 'Specify either specific factors with --type, or all factors with '. - '--all-types, but not both.')); - } else if (!$types && !$all_types) { + 'Specify either specific factors with "--type", or all factors with '. + '"--all-types", but not both.')); + } else if ($provider_phids && $all_types) { throw new PhutilArgumentUsageException( pht( - 'Use --type to specify which factor to strip, or --all-types to '. - 'strip all factors. Use `auth list-factors` to show the available '. - 'factor types.')); + 'Specify either specific factors with "--provider", or all factors '. + 'with "--all-types", but not both.')); + } else if (!$types && !$all_types && !$provider_phids) { + throw new PhutilArgumentUsageException( + pht( + 'Use "--type " or "--provider " to specify which '. + 'factors to strip, or "--all-types" to strip all factors. '. + 'Use `bin/auth list-factors` to show the available factor types '. + 'or `bin/auth list-mfa-providers` to show available providers.')); } - if ($users && $types) { - $factors = id(new PhabricatorAuthFactorConfig())->loadAllWhere( - 'userPHID IN (%Ls) AND factorKey IN (%Ls)', - mpull($users, 'getPHID'), - $types); - } else if ($users) { - $factors = id(new PhabricatorAuthFactorConfig())->loadAllWhere( - 'userPHID IN (%Ls)', - mpull($users, 'getPHID')); - } else if ($types) { - $factors = id(new PhabricatorAuthFactorConfig())->loadAllWhere( - 'factorKey IN (%Ls)', - $types); - } else { - $factors = id(new PhabricatorAuthFactorConfig())->loadAll(); + $type_map = PhabricatorAuthFactor::getAllFactors(); + + if ($types) { + foreach ($types as $type) { + if (!isset($type_map[$type])) { + throw new PhutilArgumentUsageException( + pht( + 'Factor type "%s" is unknown. Use `bin/auth list-factors` to '. + 'get a list of known factor types.', + $type)); + } + } } + $provider_query = id(new PhabricatorAuthFactorProviderQuery()) + ->setViewer($viewer); + + if ($provider_phids) { + $provider_query->withPHIDs($provider_phids); + } + + if ($types) { + $provider_query->withProviderFactorKeys($types); + } + + $providers = $provider_query->execute(); + $providers = mpull($providers, null, 'getPHID'); + + if ($provider_phids) { + foreach ($provider_phids as $provider_phid) { + if (!isset($providers[$provider_phid])) { + throw new PhutilArgumentUsageException( + pht( + 'No provider with PHID "%s" exists. '. + 'Use `bin/auth list-mfa-providers` to list providers.', + $provider_phid)); + } + } + } else { + if (!$providers) { + throw new PhutilArgumentUsageException( + pht( + 'There are no configured multi-factor providers.')); + } + } + + $factor_query = id(new PhabricatorAuthFactorConfigQuery()) + ->setViewer($viewer) + ->withFactorProviderPHIDs(array_keys($providers)); + + if ($users) { + $factor_query->withUserPHIDs(mpull($users, 'getPHID')); + } + + $factors = $factor_query->execute(); + if (!$factors) { throw new PhutilArgumentUsageException( pht('There are no matching factors to strip.')); @@ -125,14 +181,13 @@ final class PhabricatorAuthManagementStripWorkflow $console->writeOut("%s\n\n", pht('These auth factors will be stripped:')); foreach ($factors as $factor) { - $impl = $factor->getImplementation(); - $console->writeOut( + $provider = $factor->getFactorProvider(); + + echo tsprintf( " %s\t%s\t%s\n", $handles[$factor->getUserPHID()]->getName(), - $factor->getFactorKey(), - ($impl - ? $impl->getFactorName() - : '?')); + $provider->getProviderFactorKey(), + $provider->getDisplayName()); } $is_dry_run = $args->getArg('dry-run'); @@ -154,17 +209,9 @@ final class PhabricatorAuthManagementStripWorkflow $console->writeOut("%s\n", pht('Stripping authentication factors...')); + $engine = new PhabricatorDestructionEngine(); foreach ($factors as $factor) { - $user = id(new PhabricatorPeopleQuery()) - ->setViewer($this->getViewer()) - ->withPHIDs(array($factor->getUserPHID())) - ->executeOne(); - - $factor->delete(); - - if ($user) { - $user->updateMultiFactorEnrollment(); - } + $engine->destroyObject($factor); } $console->writeOut("%s\n", pht('Done.')); diff --git a/src/applications/auth/phid/PhabricatorAuthContactNumberPHIDType.php b/src/applications/auth/phid/PhabricatorAuthContactNumberPHIDType.php new file mode 100644 index 0000000000..8a4953f4d3 --- /dev/null +++ b/src/applications/auth/phid/PhabricatorAuthContactNumberPHIDType.php @@ -0,0 +1,38 @@ +withPHIDs($phids); + } + + public function loadHandles( + PhabricatorHandleQuery $query, + array $handles, + array $objects) { + + foreach ($handles as $phid => $handle) { + $contact_number = $objects[$phid]; + } + } + +} diff --git a/src/applications/auth/phid/PhabricatorAuthMessagePHIDType.php b/src/applications/auth/phid/PhabricatorAuthMessagePHIDType.php index cc37880ac8..7a023c4a4c 100644 --- a/src/applications/auth/phid/PhabricatorAuthMessagePHIDType.php +++ b/src/applications/auth/phid/PhabricatorAuthMessagePHIDType.php @@ -19,7 +19,9 @@ final class PhabricatorAuthMessagePHIDType extends PhabricatorPHIDType { protected function buildQueryForObjects( PhabricatorObjectQuery $query, array $phids) { - return new PhabricatorAuthMessageQuery(); + + return id(new PhabricatorAuthMessageQuery()) + ->withPHIDs($phids); } public function loadHandles( diff --git a/src/applications/auth/query/PhabricatorAuthContactNumberQuery.php b/src/applications/auth/query/PhabricatorAuthContactNumberQuery.php new file mode 100644 index 0000000000..77b3b559dd --- /dev/null +++ b/src/applications/auth/query/PhabricatorAuthContactNumberQuery.php @@ -0,0 +1,103 @@ +ids = $ids; + return $this; + } + + public function withPHIDs(array $phids) { + $this->phids = $phids; + return $this; + } + + public function withObjectPHIDs(array $object_phids) { + $this->objectPHIDs = $object_phids; + return $this; + } + + public function withStatuses(array $statuses) { + $this->statuses = $statuses; + return $this; + } + + public function withUniqueKeys(array $unique_keys) { + $this->uniqueKeys = $unique_keys; + return $this; + } + + public function withIsPrimary($is_primary) { + $this->isPrimary = $is_primary; + return $this; + } + + public function newResultObject() { + return new PhabricatorAuthContactNumber(); + } + + protected function loadPage() { + return $this->loadStandardPage($this->newResultObject()); + } + + protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { + $where = parent::buildWhereClauseParts($conn); + + if ($this->ids !== null) { + $where[] = qsprintf( + $conn, + 'id IN (%Ld)', + $this->ids); + } + + if ($this->phids !== null) { + $where[] = qsprintf( + $conn, + 'phid IN (%Ls)', + $this->phids); + } + + if ($this->objectPHIDs !== null) { + $where[] = qsprintf( + $conn, + 'objectPHID IN (%Ls)', + $this->objectPHIDs); + } + + if ($this->statuses !== null) { + $where[] = qsprintf( + $conn, + 'status IN (%Ls)', + $this->statuses); + } + + if ($this->uniqueKeys !== null) { + $where[] = qsprintf( + $conn, + 'uniqueKey IN (%Ls)', + $this->uniqueKeys); + } + + if ($this->isPrimary !== null) { + $where[] = qsprintf( + $conn, + 'isPrimary = %d', + (int)$this->isPrimary); + } + + return $where; + } + + public function getQueryApplicationClass() { + return 'PhabricatorAuthApplication'; + } + +} diff --git a/src/applications/auth/query/PhabricatorAuthContactNumberTransactionQuery.php b/src/applications/auth/query/PhabricatorAuthContactNumberTransactionQuery.php new file mode 100644 index 0000000000..a443cbab42 --- /dev/null +++ b/src/applications/auth/query/PhabricatorAuthContactNumberTransactionQuery.php @@ -0,0 +1,10 @@ +ids = $ids; + return $this; + } + + public function withPHIDs(array $phids) { + $this->phids = $phids; + return $this; + } + + public function withUserPHIDs(array $user_phids) { + $this->userPHIDs = $user_phids; + return $this; + } + + public function withFactorProviderPHIDs(array $provider_phids) { + $this->factorProviderPHIDs = $provider_phids; + return $this; + } + + public function withFactorProviderStatuses(array $statuses) { + $this->factorProviderStatuses = $statuses; + return $this; + } + + public function newResultObject() { + return new PhabricatorAuthFactorConfig(); + } + + protected function loadPage() { + return $this->loadStandardPage($this->newResultObject()); + } + + protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { + $where = parent::buildWhereClauseParts($conn); + + if ($this->ids !== null) { + $where[] = qsprintf( + $conn, + 'config.id IN (%Ld)', + $this->ids); + } + + if ($this->phids !== null) { + $where[] = qsprintf( + $conn, + 'config.phid IN (%Ls)', + $this->phids); + } + + if ($this->userPHIDs !== null) { + $where[] = qsprintf( + $conn, + 'config.userPHID IN (%Ls)', + $this->userPHIDs); + } + + if ($this->factorProviderPHIDs !== null) { + $where[] = qsprintf( + $conn, + 'config.factorProviderPHID IN (%Ls)', + $this->factorProviderPHIDs); + } + + if ($this->factorProviderStatuses !== null) { + $where[] = qsprintf( + $conn, + 'provider.status IN (%Ls)', + $this->factorProviderStatuses); + } + + return $where; + } + + protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) { + $joins = parent::buildJoinClauseParts($conn); + + if ($this->factorProviderStatuses !== null) { + $joins[] = qsprintf( + $conn, + 'JOIN %R provider ON config.factorProviderPHID = provider.phid', + new PhabricatorAuthFactorProvider()); + } + + return $joins; + } + + protected function willFilterPage(array $configs) { + $provider_phids = mpull($configs, 'getFactorProviderPHID'); + + $providers = id(new PhabricatorAuthFactorProviderQuery()) + ->setViewer($this->getViewer()) + ->withPHIDs($provider_phids) + ->execute(); + $providers = mpull($providers, null, 'getPHID'); + + foreach ($configs as $key => $config) { + $provider = idx($providers, $config->getFactorProviderPHID()); + + if (!$provider) { + unset($configs[$key]); + $this->didRejectResult($config); + continue; + } + + $config->attachFactorProvider($provider); + } + + return $configs; + } + + protected function getPrimaryTableAlias() { + return 'config'; + } + + public function getQueryApplicationClass() { + return 'PhabricatorAuthApplication'; + } + +} diff --git a/src/applications/auth/query/PhabricatorAuthFactorProviderQuery.php b/src/applications/auth/query/PhabricatorAuthFactorProviderQuery.php index f4ce60773b..57b554885c 100644 --- a/src/applications/auth/query/PhabricatorAuthFactorProviderQuery.php +++ b/src/applications/auth/query/PhabricatorAuthFactorProviderQuery.php @@ -5,6 +5,8 @@ final class PhabricatorAuthFactorProviderQuery private $ids; private $phids; + private $statuses; + private $providerFactorKeys; public function withIDs(array $ids) { $this->ids = $ids; @@ -15,6 +17,17 @@ final class PhabricatorAuthFactorProviderQuery $this->phids = $phids; return $this; } + + public function withProviderFactorKeys(array $keys) { + $this->providerFactorKeys = $keys; + return $this; + } + + public function withStatuses(array $statuses) { + $this->statuses = $statuses; + return $this; + } + public function newResultObject() { return new PhabricatorAuthFactorProvider(); } @@ -40,6 +53,20 @@ final class PhabricatorAuthFactorProviderQuery $this->phids); } + if ($this->statuses !== null) { + $where[] = qsprintf( + $conn, + 'status IN (%Ls)', + $this->statuses); + } + + if ($this->providerFactorKeys !== null) { + $where[] = qsprintf( + $conn, + 'providerFactorKey IN (%Ls)', + $this->providerFactorKeys); + } + return $where; } diff --git a/src/applications/auth/storage/PhabricatorAuthChallenge.php b/src/applications/auth/storage/PhabricatorAuthChallenge.php index 9e49ee154a..8fa07d712f 100644 --- a/src/applications/auth/storage/PhabricatorAuthChallenge.php +++ b/src/applications/auth/storage/PhabricatorAuthChallenge.php @@ -163,10 +163,16 @@ final class PhabricatorAuthChallenge $token = Filesystem::readRandomCharacters(32); $token = new PhutilOpaqueEnvelope($token); - return $this + $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); + + $this ->setResponseToken($token) ->setResponseTTL($ttl) ->save(); + + unset($unguarded); + + return $this; } public function markChallengeAsCompleted() { diff --git a/src/applications/auth/storage/PhabricatorAuthContactNumber.php b/src/applications/auth/storage/PhabricatorAuthContactNumber.php new file mode 100644 index 0000000000..2a138e2444 --- /dev/null +++ b/src/applications/auth/storage/PhabricatorAuthContactNumber.php @@ -0,0 +1,243 @@ + array( + 'properties' => self::SERIALIZATION_JSON, + ), + self::CONFIG_AUX_PHID => true, + self::CONFIG_COLUMN_SCHEMA => array( + 'contactNumber' => 'text255', + 'status' => 'text32', + 'uniqueKey' => 'bytes12?', + 'isPrimary' => 'bool', + ), + self::CONFIG_KEY_SCHEMA => array( + 'key_object' => array( + 'columns' => array('objectPHID'), + ), + 'key_unique' => array( + 'columns' => array('uniqueKey'), + 'unique' => true, + ), + ), + ) + parent::getConfiguration(); + } + + public static function initializeNewContactNumber($object) { + return id(new self()) + ->setStatus(self::STATUS_ACTIVE) + ->setObjectPHID($object->getPHID()) + ->setIsPrimary(0); + } + + public function getPHIDType() { + return PhabricatorAuthContactNumberPHIDType::TYPECONST; + } + + public function getURI() { + return urisprintf('/auth/contact/%s/', $this->getID()); + } + + public function getObjectName() { + return pht('Contact Number %d', $this->getID()); + } + + public function getDisplayName() { + return $this->getContactNumber(); + } + + public function isDisabled() { + return ($this->getStatus() === self::STATUS_DISABLED); + } + + public function newIconView() { + if ($this->isDisabled()) { + return id(new PHUIIconView()) + ->setIcon('fa-ban', 'grey') + ->setTooltip(pht('Disabled')); + } + + if ($this->getIsPrimary()) { + return id(new PHUIIconView()) + ->setIcon('fa-certificate', 'blue') + ->setTooltip(pht('Primary Number')); + } + + return id(new PHUIIconView()) + ->setIcon('fa-hashtag', 'bluegrey') + ->setTooltip(pht('Active Phone Number')); + } + + public function newUniqueKey() { + $parts = array( + // This is future-proofing for a world where we have multiple types + // of contact numbers, so we might be able to avoid re-hashing + // everything. + 'phone', + $this->getContactNumber(), + ); + + $parts = implode("\0", $parts); + + return PhabricatorHash::digestForIndex($parts); + } + + public function save() { + // We require that active contact numbers be unique, but it's okay to + // disable a number and then reuse it somewhere else. + if ($this->isDisabled()) { + $this->uniqueKey = null; + } else { + $this->uniqueKey = $this->newUniqueKey(); + } + + parent::save(); + + return $this->updatePrimaryContactNumber(); + } + + private function updatePrimaryContactNumber() { + // Update the "isPrimary" column so that at most one number is primary for + // each user, and no disabled number is primary. + + $conn = $this->establishConnection('w'); + $this_id = (int)$this->getID(); + + if ($this->getIsPrimary() && !$this->isDisabled()) { + // If we're trying to make this number primary and it's active, great: + // make this number the primary number. + $primary_id = $this_id; + } else { + // If we aren't trying to make this number primary or it is disabled, + // pick another number to make primary if we can. A number must be active + // to become primary. + + // If there are multiple active numbers, pick the oldest one currently + // marked primary (usually, this should mean that we just keep the + // current primary number as primary). + + // If none are marked primary, just pick the oldest one. + $primary_row = queryfx_one( + $conn, + 'SELECT id FROM %R + WHERE objectPHID = %s AND status = %s + ORDER BY isPrimary DESC, id ASC + LIMIT 1', + $this, + $this->getObjectPHID(), + self::STATUS_ACTIVE); + if ($primary_row) { + $primary_id = (int)$primary_row['id']; + } else { + $primary_id = -1; + } + } + + // Set the chosen number to primary, and all other numbers to nonprimary. + + queryfx( + $conn, + 'UPDATE %R SET isPrimary = IF(id = %d, 1, 0) + WHERE objectPHID = %s', + $this, + $primary_id, + $this->getObjectPHID()); + + $this->setIsPrimary((int)($primary_id === $this_id)); + + return $this; + } + + public static function getStatusNameMap() { + return ipull(self::getStatusPropertyMap(), 'name'); + } + + private static function getStatusPropertyMap() { + return array( + self::STATUS_ACTIVE => array( + 'name' => pht('Active'), + ), + self::STATUS_DISABLED => array( + 'name' => pht('Disabled'), + ), + ); + } + + public function getSortVector() { + // Sort the primary number first, then active numbers, then disabled + // numbers. In each group, sort from oldest to newest. + return id(new PhutilSortVector()) + ->addInt($this->getIsPrimary() ? 0 : 1) + ->addInt($this->isDisabled() ? 1 : 0) + ->addInt($this->getID()); + } + + +/* -( PhabricatorPolicyInterface )----------------------------------------- */ + + + public function getCapabilities() { + return array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + ); + } + + public function getPolicy($capability) { + return $this->getObjectPHID(); + } + + public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { + return false; + } + + +/* -( PhabricatorDestructibleInterface )----------------------------------- */ + + + public function destroyObjectPermanently( + PhabricatorDestructionEngine $engine) { + $this->delete(); + } + + +/* -( PhabricatorApplicationTransactionInterface )------------------------- */ + + + public function getApplicationTransactionEditor() { + return new PhabricatorAuthContactNumberEditor(); + } + + public function getApplicationTransactionTemplate() { + return new PhabricatorAuthContactNumberTransaction(); + } + + +/* -( PhabricatorEditEngineMFAInterface )---------------------------------- */ + + + public function newEditEngineMFAEngine() { + return new PhabricatorAuthContactNumberMFAEngine(); + } + +} diff --git a/src/applications/auth/storage/PhabricatorAuthContactNumberTransaction.php b/src/applications/auth/storage/PhabricatorAuthContactNumberTransaction.php new file mode 100644 index 0000000000..d6faccf497 --- /dev/null +++ b/src/applications/auth/storage/PhabricatorAuthContactNumberTransaction.php @@ -0,0 +1,18 @@ + true, self::CONFIG_COLUMN_SCHEMA => array( - 'factorKey' => 'text64', 'factorName' => 'text', 'factorSecret' => 'text', ), @@ -29,26 +35,18 @@ final class PhabricatorAuthFactorConfig extends PhabricatorAuthDAO { ) + parent::getConfiguration(); } - public function generatePHID() { - return PhabricatorPHID::generateNewPHID( - PhabricatorAuthAuthFactorPHIDType::TYPECONST); + public function getPHIDType() { + return PhabricatorAuthAuthFactorPHIDType::TYPECONST; } - public function getImplementation() { - return idx(PhabricatorAuthFactor::getAllFactors(), $this->getFactorKey()); + public function attachFactorProvider( + PhabricatorAuthFactorProvider $provider) { + $this->factorProvider = $provider; + return $this; } - 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; + public function getFactorProvider() { + return $this->assertAttached($this->factorProvider); } public function setSessionEngine(PhabricatorAuthSessionEngine $engine) { @@ -64,4 +62,66 @@ final class PhabricatorAuthFactorConfig extends PhabricatorAuthDAO { return $this->sessionEngine; } + public function setMFASyncToken(PhabricatorAuthTemporaryToken $token) { + $this->mfaSyncToken = $token; + return $this; + } + + public function getMFASyncToken() { + return $this->mfaSyncToken; + } + + public function getAuthFactorConfigProperty($key, $default = null) { + return idx($this->properties, $key, $default); + } + + public function setAuthFactorConfigProperty($key, $value) { + $this->properties[$key] = $value; + return $this; + } + + public function newSortVector() { + return id(new PhutilSortVector()) + ->addInt($this->getFactorProvider()->newStatus()->getOrder()) + ->addInt($this->getID()); + } + + +/* -( PhabricatorPolicyInterface )----------------------------------------- */ + + + public function getCapabilities() { + return array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + ); + } + + public function getPolicy($capability) { + return $this->getUserPHID(); + } + + public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { + return false; + } + + +/* -( PhabricatorDestructibleInterface )----------------------------------- */ + + + public function destroyObjectPermanently( + PhabricatorDestructionEngine $engine) { + + $user = id(new PhabricatorPeopleQuery()) + ->setViewer($engine->getViewer()) + ->withPHIDs(array($this->getUserPHID())) + ->executeOne(); + + $this->delete(); + + if ($user) { + $user->updateMultiFactorEnrollment(); + } + } + } diff --git a/src/applications/auth/storage/PhabricatorAuthFactorProvider.php b/src/applications/auth/storage/PhabricatorAuthFactorProvider.php index 13140395df..79acd4f23e 100644 --- a/src/applications/auth/storage/PhabricatorAuthFactorProvider.php +++ b/src/applications/auth/storage/PhabricatorAuthFactorProvider.php @@ -5,7 +5,8 @@ final class PhabricatorAuthFactorProvider implements PhabricatorApplicationTransactionInterface, PhabricatorPolicyInterface, - PhabricatorExtendedPolicyInterface { + PhabricatorExtendedPolicyInterface, + PhabricatorEditEngineMFAInterface { protected $providerFactorKey; protected $name; @@ -14,15 +15,11 @@ final class PhabricatorAuthFactorProvider private $factor = self::ATTACHABLE; - const STATUS_ACTIVE = 'active'; - const STATUS_DEPRECATED = 'deprecated'; - const STATUS_DISABLED = 'disabled'; - public static function initializeNewProvider(PhabricatorAuthFactor $factor) { return id(new self()) ->setProviderFactorKey($factor->getFactorKey()) ->attachFactor($factor) - ->setStatus(self::STATUS_ACTIVE); + ->setStatus(PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE); } protected function getConfiguration() { @@ -78,6 +75,67 @@ final class PhabricatorAuthFactorProvider return $this->getFactor()->getFactorName(); } + public function newIconView() { + return $this->getFactor()->newIconView(); + } + + public function getDisplayDescription() { + return $this->getFactor()->getFactorDescription(); + } + + public function processAddFactorForm( + AphrontFormView $form, + AphrontRequest $request, + PhabricatorUser $user) { + + $factor = $this->getFactor(); + + $config = $factor->processAddFactorForm($this, $form, $request, $user); + if ($config) { + $config->setFactorProviderPHID($this->getPHID()); + } + + return $config; + } + + public function newSortVector() { + $factor = $this->getFactor(); + + return id(new PhutilSortVector()) + ->addInt($factor->getFactorOrder()) + ->addInt($this->getID()); + } + + public function getEnrollDescription(PhabricatorUser $user) { + return $this->getFactor()->getEnrollDescription($this, $user); + } + + public function getEnrollButtonText(PhabricatorUser $user) { + return $this->getFactor()->getEnrollButtonText($this, $user); + } + + public function newStatus() { + $status_key = $this->getStatus(); + return PhabricatorAuthFactorProviderStatus::newForStatus($status_key); + } + + public function canCreateNewConfiguration(PhabricatorUser $user) { + return $this->getFactor()->canCreateNewConfiguration($this, $user); + } + + public function getConfigurationCreateDescription(PhabricatorUser $user) { + return $this->getFactor()->getConfigurationCreateDescription($this, $user); + } + + public function getConfigurationListDetails( + PhabricatorAuthFactorConfig $config, + PhabricatorUser $viewer) { + return $this->getFactor()->getConfigurationListDetails( + $config, + $this, + $viewer); + } + /* -( PhabricatorApplicationTransactionInterface )------------------------- */ @@ -131,4 +189,11 @@ final class PhabricatorAuthFactorProvider } +/* -( PhabricatorEditEngineMFAInterface )---------------------------------- */ + + + public function newEditEngineMFAEngine() { + return new PhabricatorAuthFactorProviderMFAEngine(); + } + } diff --git a/src/applications/auth/storage/PhabricatorAuthTemporaryToken.php b/src/applications/auth/storage/PhabricatorAuthTemporaryToken.php index 8ffd603a47..2b96c7815f 100644 --- a/src/applications/auth/storage/PhabricatorAuthTemporaryToken.php +++ b/src/applications/auth/storage/PhabricatorAuthTemporaryToken.php @@ -10,7 +10,9 @@ final class PhabricatorAuthTemporaryToken extends PhabricatorAuthDAO protected $tokenExpires; protected $tokenCode; protected $userPHID; - protected $properties; + protected $properties = array(); + + private $isNew = false; protected function getConfiguration() { return array( @@ -114,6 +116,14 @@ final class PhabricatorAuthTemporaryToken extends PhabricatorAuthDAO return $this->getTemporaryTokenProperty('force-full-session', false); } + public function setIsNewTemporaryToken($is_new) { + $this->isNew = $is_new; + return $this; + } + + public function getIsNewTemporaryToken() { + return $this->isNew; + } /* -( PhabricatorPolicyInterface )----------------------------------------- */ diff --git a/src/applications/auth/xaction/PhabricatorAuthContactNumberNumberTransaction.php b/src/applications/auth/xaction/PhabricatorAuthContactNumberNumberTransaction.php new file mode 100644 index 0000000000..88d9d4bffc --- /dev/null +++ b/src/applications/auth/xaction/PhabricatorAuthContactNumberNumberTransaction.php @@ -0,0 +1,96 @@ +getContactNumber(); + } + + public function generateNewValue($object, $value) { + $number = new PhabricatorPhoneNumber($value); + return $number->toE164(); + } + + public function applyInternalEffects($object, $value) { + $object->setContactNumber($value); + } + + public function getTitle() { + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + return pht( + '%s changed this contact number from %s to %s.', + $this->renderAuthor(), + $this->renderOldValue(), + $this->renderNewValue()); + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + $current_value = $object->getContactNumber(); + if ($this->isEmptyTextTransaction($current_value, $xactions)) { + $errors[] = $this->newRequiredError( + pht('Contact numbers must have a contact number.')); + return $errors; + } + + $max_length = $object->getColumnMaximumByteLength('contactNumber'); + foreach ($xactions as $xaction) { + $new_value = $xaction->getNewValue(); + $new_length = strlen($new_value); + if ($new_length > $max_length) { + $errors[] = $this->newInvalidError( + pht( + 'Contact numbers can not be longer than %s characters.', + new PhutilNumber($max_length)), + $xaction); + continue; + } + + try { + new PhabricatorPhoneNumber($new_value); + } catch (Exception $ex) { + $errors[] = $this->newInvalidError( + pht( + 'Contact number is invalid: %s', + $ex->getMessage()), + $xaction); + continue; + } + + $new_value = $this->generateNewValue($object, $new_value); + + $unique_key = id(clone $object) + ->setContactNumber($new_value) + ->newUniqueKey(); + + $other = id(new PhabricatorAuthContactNumberQuery()) + ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->withUniqueKeys(array($unique_key)) + ->executeOne(); + + if ($other) { + if ($other->getID() !== $object->getID()) { + $errors[] = $this->newInvalidError( + pht('Contact number is already in use.'), + $xaction); + continue; + } + } + + $mfa_error = $this->newContactNumberMFAError($object, $xaction); + if ($mfa_error) { + $errors[] = $mfa_error; + continue; + } + } + + return $errors; + } + +} diff --git a/src/applications/auth/xaction/PhabricatorAuthContactNumberPrimaryTransaction.php b/src/applications/auth/xaction/PhabricatorAuthContactNumberPrimaryTransaction.php new file mode 100644 index 0000000000..42788029b5 --- /dev/null +++ b/src/applications/auth/xaction/PhabricatorAuthContactNumberPrimaryTransaction.php @@ -0,0 +1,55 @@ +getIsPrimary(); + } + + public function applyInternalEffects($object, $value) { + $object->setIsPrimary((int)$value); + } + + public function getTitle() { + return pht( + '%s made this the primary contact number.', + $this->renderAuthor()); + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + foreach ($xactions as $xaction) { + $new_value = $xaction->getNewValue(); + + if (!$new_value) { + $errors[] = $this->newInvalidError( + pht( + 'To choose a different primary contact number, make that '. + 'number primary (instead of trying to demote this one).'), + $xaction); + continue; + } + + if ($object->isDisabled()) { + $errors[] = $this->newInvalidError( + pht( + 'You can not make a disabled number a primary contact number.'), + $xaction); + continue; + } + + $mfa_error = $this->newContactNumberMFAError($object, $xaction); + if ($mfa_error) { + $errors[] = $mfa_error; + continue; + } + } + + return $errors; + } + +} diff --git a/src/applications/auth/xaction/PhabricatorAuthContactNumberStatusTransaction.php b/src/applications/auth/xaction/PhabricatorAuthContactNumberStatusTransaction.php new file mode 100644 index 0000000000..5dab6fe8c0 --- /dev/null +++ b/src/applications/auth/xaction/PhabricatorAuthContactNumberStatusTransaction.php @@ -0,0 +1,65 @@ +getStatus(); + } + + public function applyInternalEffects($object, $value) { + $object->setStatus($value); + } + + public function getTitle() { + $new = $this->getNewValue(); + + if ($new === PhabricatorAuthContactNumber::STATUS_DISABLED) { + return pht( + '%s disabled this contact number.', + $this->renderAuthor()); + } else { + return pht( + '%s enabled this contact number.', + $this->renderAuthor()); + } + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + $map = PhabricatorAuthContactNumber::getStatusNameMap(); + + foreach ($xactions as $xaction) { + $new_value = $xaction->getNewValue(); + + if (!isset($map[$new_value])) { + $errors[] = $this->newInvalidError( + pht( + 'Status ("%s") is not a valid contact number status. Valid '. + 'status constants are: %s.', + $new_value, + implode(', ', array_keys($map))), + $xaction); + continue; + } + + $mfa_error = $this->newContactNumberMFAError($object, $xaction); + if ($mfa_error) { + $errors[] = $mfa_error; + continue; + } + + // NOTE: Enabling a contact number may cause us to collide with another + // active contact number. However, there might also be a transaction in + // this group that changes the number itself. Since we can't easily + // predict if we'll collide or not, just let the duplicate key logic + // handle it when we do. + } + + return $errors; + } + +} diff --git a/src/applications/auth/xaction/PhabricatorAuthContactNumberTransactionType.php b/src/applications/auth/xaction/PhabricatorAuthContactNumberTransactionType.php new file mode 100644 index 0000000000..a74c78d4c4 --- /dev/null +++ b/src/applications/auth/xaction/PhabricatorAuthContactNumberTransactionType.php @@ -0,0 +1,72 @@ +getTransactionType() === $primary_type) { + // We're trying to make a non-primary number into the primary number, + // so do MFA checks. + $is_primary = false; + } else if ($object->getIsPrimary()) { + // We're editing the primary number, so do MFA checks. + $is_primary = true; + } else { + // Editing a non-primary number and not making it primary, so this is + // fine. + return null; + } + + $target_phid = $object->getObjectPHID(); + $omnipotent = PhabricatorUser::getOmnipotentUser(); + + $user_configs = id(new PhabricatorAuthFactorConfigQuery()) + ->setViewer($omnipotent) + ->withUserPHIDs(array($target_phid)) + ->execute(); + + $problem_configs = array(); + foreach ($user_configs as $config) { + $provider = $config->getFactorProvider(); + $factor = $provider->getFactor(); + + if ($factor->isContactNumberFactor()) { + $problem_configs[] = $config; + } + } + + if (!$problem_configs) { + return null; + } + + $problem_config = head($problem_configs); + + if ($is_primary) { + return $this->newInvalidError( + pht( + 'You currently have multi-factor authentication ("%s") which '. + 'depends on your primary contact number. You must remove this '. + 'authentication factor before you can modify or disable your '. + 'primary contact number.', + $problem_config->getFactorName()), + $xaction); + } else { + return $this->newInvalidError( + pht( + 'You currently have multi-factor authentication ("%s") which '. + 'depends on your primary contact number. You must remove this '. + 'authentication factor before you can designate a new primary '. + 'contact number.', + $problem_config->getFactorName()), + $xaction); + } + } + +} diff --git a/src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoCredentialTransaction.php b/src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoCredentialTransaction.php new file mode 100644 index 0000000000..532fc271f4 --- /dev/null +++ b/src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoCredentialTransaction.php @@ -0,0 +1,65 @@ +getAuthFactorProviderProperty($key); + } + + public function applyInternalEffects($object, $value) { + $key = PhabricatorDuoAuthFactor::PROP_CREDENTIAL; + $object->setAuthFactorProviderProperty($key, $value); + } + + public function getTitle() { + return pht( + '%s changed the credential for this provider from %s to %s.', + $this->renderAuthor(), + $this->renderOldHandle(), + $this->renderNewHandle()); + } + + public function validateTransactions($object, array $xactions) { + $actor = $this->getActor(); + $errors = array(); + + $old_value = $this->generateOldValue($object); + if ($this->isEmptyTextTransaction($old_value, $xactions)) { + $errors[] = $this->newRequiredError( + pht('Duo providers must have an API credential.')); + } + + foreach ($xactions as $xaction) { + $new_value = $xaction->getNewValue(); + + if (!strlen($new_value)) { + continue; + } + + if ($new_value === $old_value) { + continue; + } + + $credential = id(new PassphraseCredentialQuery()) + ->setViewer($actor) + ->withIsDestroyed(false) + ->withPHIDs(array($new_value)) + ->executeOne(); + if (!$credential) { + $errors[] = $this->newInvalidError( + pht( + 'Credential ("%s") is not valid.', + $new_value), + $xaction); + continue; + } + } + + return $errors; + } + +} diff --git a/src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoEnrollTransaction.php b/src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoEnrollTransaction.php new file mode 100644 index 0000000000..e1823274b8 --- /dev/null +++ b/src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoEnrollTransaction.php @@ -0,0 +1,26 @@ +getAuthFactorProviderProperty($key); + } + + public function applyInternalEffects($object, $value) { + $key = PhabricatorDuoAuthFactor::PROP_ENROLL; + $object->setAuthFactorProviderProperty($key, $value); + } + + public function getTitle() { + return pht( + '%s changed the enrollment policy for this provider from %s to %s.', + $this->renderAuthor(), + $this->renderOldValue(), + $this->renderNewValue()); + } + +} diff --git a/src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoHostnameTransaction.php b/src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoHostnameTransaction.php new file mode 100644 index 0000000000..ce1838594e --- /dev/null +++ b/src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoHostnameTransaction.php @@ -0,0 +1,59 @@ +getAuthFactorProviderProperty($key); + } + + public function applyInternalEffects($object, $value) { + $key = PhabricatorDuoAuthFactor::PROP_HOSTNAME; + $object->setAuthFactorProviderProperty($key, $value); + } + + public function getTitle() { + return pht( + '%s changed the hostname for this provider from %s to %s.', + $this->renderAuthor(), + $this->renderOldValue(), + $this->renderNewValue()); + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + $old_value = $this->generateOldValue($object); + if ($this->isEmptyTextTransaction($old_value, $xactions)) { + $errors[] = $this->newRequiredError( + pht('Duo providers must have an API hostname.')); + } + + foreach ($xactions as $xaction) { + $new_value = $xaction->getNewValue(); + + if (!strlen($new_value)) { + continue; + } + + if ($new_value === $old_value) { + continue; + } + + try { + PhabricatorDuoAuthFactor::requireDuoAPIHostname($new_value); + } catch (Exception $ex) { + $errors[] = $this->newInvalidError( + $ex->getMessage(), + $xaction); + continue; + } + } + + return $errors; + } + +} diff --git a/src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoUsernamesTransaction.php b/src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoUsernamesTransaction.php new file mode 100644 index 0000000000..8d9be1244f --- /dev/null +++ b/src/applications/auth/xaction/PhabricatorAuthFactorProviderDuoUsernamesTransaction.php @@ -0,0 +1,26 @@ +getAuthFactorProviderProperty($key); + } + + public function applyInternalEffects($object, $value) { + $key = PhabricatorDuoAuthFactor::PROP_USERNAMES; + $object->setAuthFactorProviderProperty($key, $value); + } + + public function getTitle() { + return pht( + '%s changed the username policy for this provider from %s to %s.', + $this->renderAuthor(), + $this->renderOldValue(), + $this->renderNewValue()); + } + +} diff --git a/src/applications/auth/xaction/PhabricatorAuthFactorProviderStatusTransaction.php b/src/applications/auth/xaction/PhabricatorAuthFactorProviderStatusTransaction.php new file mode 100644 index 0000000000..37674f7b38 --- /dev/null +++ b/src/applications/auth/xaction/PhabricatorAuthFactorProviderStatusTransaction.php @@ -0,0 +1,103 @@ +getStatus(); + } + + public function applyInternalEffects($object, $value) { + $object->setStatus($value); + } + + public function getTitle() { + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + $old_display = PhabricatorAuthFactorProviderStatus::newForStatus($old) + ->getName(); + $new_display = PhabricatorAuthFactorProviderStatus::newForStatus($new) + ->getName(); + + return pht( + '%s changed the status of this provider from %s to %s.', + $this->renderAuthor(), + $this->renderValue($old_display), + $this->renderValue($new_display)); + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + $actor = $this->getActor(); + + $map = PhabricatorAuthFactorProviderStatus::getMap(); + foreach ($xactions as $xaction) { + $new_value = $xaction->getNewValue(); + + if (!isset($map[$new_value])) { + $errors[] = $this->newInvalidError( + pht( + 'Status "%s" is invalid. Valid statuses are: %s.', + $new_value, + implode(', ', array_keys($map))), + $xaction); + continue; + } + + $require_key = 'security.require-multi-factor-auth'; + $require_mfa = PhabricatorEnv::getEnvConfig($require_key); + + if ($require_mfa) { + $status_active = PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE; + if ($new_value !== $status_active) { + $active_providers = id(new PhabricatorAuthFactorProviderQuery()) + ->setViewer($actor) + ->withStatuses( + array( + $status_active, + )) + ->execute(); + $active_providers = mpull($active_providers, null, 'getID'); + unset($active_providers[$object->getID()]); + + if (!$active_providers) { + $errors[] = $this->newInvalidError( + pht( + 'You can not deprecate or disable the last active MFA '. + 'provider while "%s" is enabled, because new users would '. + 'be unable to enroll in MFA. Disable the MFA requirement '. + 'in Config, or create or enable another MFA provider first.', + $require_key)); + continue; + } + } + } + } + + return $errors; + } + + public function didCommitTransaction($object, $value) { + $status = PhabricatorAuthFactorProviderStatus::newForStatus($value); + + // If a provider has undergone a status change, reset the MFA enrollment + // cache for all users. This may immediately force a lot of users to redo + // MFA enrollment. + + // We could be more surgical about this: we only really need to affect + // users who had a factor under the provider, and only really need to + // do anything if a provider was disabled. This is just a little simpler. + + $table = new PhabricatorUser(); + $conn = $table->establishConnection('w'); + + queryfx( + $conn, + 'UPDATE %R SET isEnrolledInMultiFactor = 0', + $table); + } + +} diff --git a/src/applications/conduit/settings/PhabricatorConduitTokensSettingsPanel.php b/src/applications/conduit/settings/PhabricatorConduitTokensSettingsPanel.php index 2075582386..cd97e2fd7f 100644 --- a/src/applications/conduit/settings/PhabricatorConduitTokensSettingsPanel.php +++ b/src/applications/conduit/settings/PhabricatorConduitTokensSettingsPanel.php @@ -19,6 +19,10 @@ final class PhabricatorConduitTokensSettingsPanel return pht('Conduit API Tokens'); } + public function getPanelMenuIcon() { + return id(new PhabricatorConduitApplication())->getIcon(); + } + public function getPanelGroupKey() { return PhabricatorSettingsLogsPanelGroup::PANELGROUPKEY; } diff --git a/src/applications/config/check/PhabricatorPHPPreflightSetupCheck.php b/src/applications/config/check/PhabricatorPHPPreflightSetupCheck.php index 7c9653f4ff..30c6036c8f 100644 --- a/src/applications/config/check/PhabricatorPHPPreflightSetupCheck.php +++ b/src/applications/config/check/PhabricatorPHPPreflightSetupCheck.php @@ -11,16 +11,23 @@ final class PhabricatorPHPPreflightSetupCheck extends PhabricatorSetupCheck { } protected function executeChecks() { - if (version_compare(phpversion(), 7, '>=') && - version_compare(phpversion(), 7.1, '<')) { + $version = phpversion(); + if (version_compare($version, 7, '>=') && + version_compare($version, 7.1, '<')) { $message = pht( - 'This version of Phabricator does not support PHP 7.0. You '. - 'are running PHP %s. Upgrade to PHP 7.1 or newer.', - phpversion()); + 'You are running PHP version %s. Phabricator does not support PHP '. + 'versions between 7.0 and 7.1.'. + "\n\n". + 'PHP removed signal handling features that Phabricator requires in '. + 'PHP 7.0, and did not restore them until PHP 7.1.'. + "\n\n". + 'Upgrade to PHP 7.1 or newer (recommended) or downgrade to an older '. + 'version of PHP 5 (discouraged).', + $version); $this->newIssue('php.version7') ->setIsFatal(true) - ->setName(pht('PHP 7.0 Not Supported')) + ->setName(pht('PHP 7.0-7.1 Not Supported')) ->setMessage($message) ->addLink( 'https://phurl.io/u/php7', diff --git a/src/applications/diffusion/panel/DiffusionSetPasswordSettingsPanel.php b/src/applications/diffusion/panel/DiffusionSetPasswordSettingsPanel.php index 1899302223..789adfbf57 100644 --- a/src/applications/diffusion/panel/DiffusionSetPasswordSettingsPanel.php +++ b/src/applications/diffusion/panel/DiffusionSetPasswordSettingsPanel.php @@ -18,6 +18,10 @@ final class DiffusionSetPasswordSettingsPanel extends PhabricatorSettingsPanel { return pht('VCS Password'); } + public function getPanelMenuIcon() { + return 'fa-code'; + } + public function getPanelGroupKey() { return PhabricatorSettingsAuthenticationPanelGroup::PANELGROUPKEY; } diff --git a/src/applications/metamta/adapter/PhabricatorMailAmazonSNSAdapter.php b/src/applications/metamta/adapter/PhabricatorMailAmazonSNSAdapter.php new file mode 100644 index 0000000000..b34e422dba --- /dev/null +++ b/src/applications/metamta/adapter/PhabricatorMailAmazonSNSAdapter.php @@ -0,0 +1,63 @@ + 'string', + 'secret-key' => 'string', + 'endpoint' => 'string', + 'region' => 'string', + )); + } + + public function newDefaultOptions() { + return array( + 'access-key' => null, + 'secret-key' => null, + 'endpoint' => null, + 'region' => null, + ); + } + + public function sendMessage(PhabricatorMailExternalMessage $message) { + $access_key = $this->getOption('access-key'); + + $secret_key = $this->getOption('secret-key'); + $secret_key = new PhutilOpaqueEnvelope($secret_key); + + $endpoint = $this->getOption('endpoint'); + $region = $this->getOption('region'); + + $to_number = $message->getToNumber(); + $text_body = $message->getTextBody(); + + $params = array( + 'Version' => '2010-03-31', + 'Action' => 'Publish', + 'PhoneNumber' => $to_number->toE164(), + 'Message' => $text_body, + ); + + return id(new PhabricatorAmazonSNSFuture()) + ->setParameters($params) + ->setEndpoint($endpoint) + ->setAccessKey($access_key) + ->setSecretKey($secret_key) + ->setRegion($region) + ->setTimeout(60) + ->resolve(); + } + +} diff --git a/src/applications/metamta/adapter/PhabricatorMailTestAdapter.php b/src/applications/metamta/adapter/PhabricatorMailTestAdapter.php index f0840ba7bf..a6258a8874 100644 --- a/src/applications/metamta/adapter/PhabricatorMailTestAdapter.php +++ b/src/applications/metamta/adapter/PhabricatorMailTestAdapter.php @@ -33,6 +33,7 @@ final class PhabricatorMailTestAdapter public function getSupportedMessageTypes() { return array( PhabricatorMailEmailMessage::MESSAGETYPE, + PhabricatorMailSMSMessage::MESSAGETYPE, ); } @@ -63,6 +64,28 @@ final class PhabricatorMailTestAdapter pht('Unit Test (Temporary)')); } + switch ($message->getMessageType()) { + case PhabricatorMailEmailMessage::MESSAGETYPE: + $guts = $this->newEmailGuts($message); + break; + case PhabricatorMailSMSMessage::MESSAGETYPE: + $guts = $this->newSMSGuts($message); + break; + } + + $guts['did-send'] = true; + $this->guts = $guts; + } + + public function getBody() { + return idx($this->guts, 'body'); + } + + public function getHTMLBody() { + return idx($this->guts, 'html-body'); + } + + private function newEmailGuts(PhabricatorMailExternalMessage $message) { $guts = array(); $from = $message->getFromAddress(); @@ -123,19 +146,16 @@ final class PhabricatorMailTestAdapter } $guts['attachments'] = $file_list; - $guts['did-send'] = true; - - $this->guts = $guts; + return $guts; } + private function newSMSGuts(PhabricatorMailExternalMessage $message) { + $guts = array(); - public function getBody() { - return idx($this->guts, 'body'); + $guts['to'] = $message->getToNumber(); + $guts['body'] = $message->getTextBody(); + + return $guts; } - public function getHTMLBody() { - return idx($this->guts, 'html-body'); - } - - } diff --git a/src/applications/metamta/controller/PhabricatorMetaMTAMailViewController.php b/src/applications/metamta/controller/PhabricatorMetaMTAMailViewController.php index b831a9c9d6..d7d31ba254 100644 --- a/src/applications/metamta/controller/PhabricatorMetaMTAMailViewController.php +++ b/src/applications/metamta/controller/PhabricatorMetaMTAMailViewController.php @@ -187,6 +187,9 @@ final class PhabricatorMetaMTAMailViewController ->setStacked(true); $headers = $mail->getDeliveredHeaders(); + if (!$headers) { + $headers = array(); + } // Sort headers by name. $headers = isort($headers, 0); diff --git a/src/applications/metamta/engine/PhabricatorMailSMSEngine.php b/src/applications/metamta/engine/PhabricatorMailSMSEngine.php new file mode 100644 index 0000000000..9f5c2fef36 --- /dev/null +++ b/src/applications/metamta/engine/PhabricatorMailSMSEngine.php @@ -0,0 +1,75 @@ +getMailer(); + $mail = $this->getMail(); + + $message = new PhabricatorMailSMSMessage(); + + $phids = $mail->getToPHIDs(); + if (!$phids) { + $mail->setMessage(pht('Message has no "To" recipient.')); + return null; + } + + if (count($phids) > 1) { + $mail->setMessage(pht('Message has more than one "To" recipient.')); + return null; + } + + $phid = head($phids); + + $actor = $this->getActor($phid); + if (!$actor) { + $mail->setMessage(pht('Message recipient has no mailable actor.')); + return null; + } + + if (!$actor->isDeliverable()) { + $mail->setMessage(pht('Message recipient is not deliverable.')); + return null; + } + + $omnipotent = PhabricatorUser::getOmnipotentUser(); + + $contact_numbers = id(new PhabricatorAuthContactNumberQuery()) + ->setViewer($omnipotent) + ->withObjectPHIDs(array($phid)) + ->withStatuses( + array( + PhabricatorAuthContactNumber::STATUS_ACTIVE, + )) + ->withIsPrimary(true) + ->execute(); + + if (!$contact_numbers) { + $mail->setMessage( + pht('Message recipient has no primary contact number.')); + return null; + } + + // The database does not strictly guarantee that only one number is + // primary, so make sure no one has monkeyed with stuff. + if (count($contact_numbers) > 1) { + $mail->setMessage( + pht('Message recipient has more than one primary contact number.')); + return null; + } + + $contact_number = head($contact_numbers); + $contact_number = $contact_number->getContactNumber(); + $to_number = new PhabricatorPhoneNumber($contact_number); + $message->setToNumber($to_number); + + $body = $mail->getBody(); + if ($body !== null) { + $message->setTextBody($body); + } + + return $message; + } + +} diff --git a/src/applications/metamta/future/PhabricatorAmazonSNSFuture.php b/src/applications/metamta/future/PhabricatorAmazonSNSFuture.php new file mode 100644 index 0000000000..3be236eee2 --- /dev/null +++ b/src/applications/metamta/future/PhabricatorAmazonSNSFuture.php @@ -0,0 +1,41 @@ +parameters = $parameters; + return $this; + } + + protected function getParameters() { + return $this->parameters; + } + + public function getServiceName() { + return 'sns'; + } + + public function setTimeout($timeout) { + $this->timeout = $timeout; + return $this; + } + + public function getTimeout() { + return $this->timeout; + } + + protected function getProxiedFuture() { + $future = parent::getProxiedFuture(); + + $timeout = $this->getTimeout(); + if ($timeout) { + $future->setTimeout($timeout); + } + + return $future; + + } + +} diff --git a/src/applications/metamta/future/PhabricatorTwilioFuture.php b/src/applications/metamta/future/PhabricatorTwilioFuture.php index 91b0588d23..8dc70329f8 100644 --- a/src/applications/metamta/future/PhabricatorTwilioFuture.php +++ b/src/applications/metamta/future/PhabricatorTwilioFuture.php @@ -58,7 +58,7 @@ final class PhabricatorTwilioFuture extends FutureProxy { $this->accountSID, $this->method); - $uri = id(new PhutilURI('https://api.twilio.com/2010-04-01/accounts/')) + $uri = id(new PhutilURI('https://api.twilio.com/')) ->setPath($path); $data = $this->parameters; diff --git a/src/applications/metamta/management/PhabricatorMailManagementListOutboundWorkflow.php b/src/applications/metamta/management/PhabricatorMailManagementListOutboundWorkflow.php index a83dafb0a8..30939dd436 100644 --- a/src/applications/metamta/management/PhabricatorMailManagementListOutboundWorkflow.php +++ b/src/applications/metamta/management/PhabricatorMailManagementListOutboundWorkflow.php @@ -7,8 +7,7 @@ final class PhabricatorMailManagementListOutboundWorkflow $this ->setName('list-outbound') ->setSynopsis(pht('List outbound messages sent by Phabricator.')) - ->setExamples( - '**list-outbound**') + ->setExamples('**list-outbound**') ->setArguments( array( array( @@ -39,6 +38,7 @@ final class PhabricatorMailManagementListOutboundWorkflow ->addColumn('id', array('title' => pht('ID'))) ->addColumn('encrypt', array('title' => pht('#'))) ->addColumn('status', array('title' => pht('Status'))) + ->addColumn('type', array('title' => pht('Type'))) ->addColumn('subject', array('title' => pht('Subject'))); foreach (array_reverse($mails) as $mail) { @@ -48,6 +48,7 @@ final class PhabricatorMailManagementListOutboundWorkflow 'id' => $mail->getID(), 'encrypt' => ($mail->getMustEncrypt() ? '#' : ' '), 'status' => PhabricatorMailOutboundStatus::getStatusName($status), + 'type' => $mail->getMessageType(), 'subject' => $mail->getSubject(), )); } diff --git a/src/applications/metamta/management/PhabricatorMailManagementSendTestWorkflow.php b/src/applications/metamta/management/PhabricatorMailManagementSendTestWorkflow.php index ab5bd7f336..f390ff27df 100644 --- a/src/applications/metamta/management/PhabricatorMailManagementSendTestWorkflow.php +++ b/src/applications/metamta/management/PhabricatorMailManagementSendTestWorkflow.php @@ -60,6 +60,12 @@ final class PhabricatorMailManagementSendTestWorkflow 'name' => 'bulk', 'help' => pht('Send with bulk headers.'), ), + array( + 'name' => 'type', + 'param' => 'message-type', + 'help' => pht( + 'Send the specified type of message (email, sms, ...).'), + ), )); } @@ -67,6 +73,20 @@ final class PhabricatorMailManagementSendTestWorkflow $console = PhutilConsole::getConsole(); $viewer = $this->getViewer(); + $type = $args->getArg('type'); + if (!strlen($type)) { + $type = PhabricatorMailEmailMessage::MESSAGETYPE; + } + + $type_map = PhabricatorMailExternalMessage::getAllMessageTypes(); + if (!isset($type_map[$type])) { + throw new PhutilArgumentUsageException( + pht( + 'Message type "%s" is unknown, supported message types are: %s.', + $type, + implode(', ', array_keys($type_map)))); + } + $from = $args->getArg('from'); if ($from) { $user = id(new PhabricatorPeopleQuery()) @@ -86,9 +106,8 @@ final class PhabricatorMailManagementSendTestWorkflow if (!$tos && !$ccs) { throw new PhutilArgumentUsageException( pht( - 'Specify one or more users to send mail to with `%s` and `%s`.', - '--to', - '--cc')); + 'Specify one or more users to send a message to with "--to" and/or '. + '"--cc".')); } $names = array_merge($tos, $ccs); @@ -166,26 +185,32 @@ final class PhabricatorMailManagementSendTestWorkflow $mail->setFrom($from->getPHID()); } + $mailers = PhabricatorMetaMTAMail::newMailers( + array( + 'media' => array($type), + 'outbound' => true, + )); + $mailers = mpull($mailers, null, 'getKey'); + + if (!$mailers) { + throw new PhutilArgumentUsageException( + pht( + 'No configured mailers support outbound messages of type "%s".', + $type)); + } + $mailer_key = $args->getArg('mailer'); if ($mailer_key !== null) { - $mailers = PhabricatorMetaMTAMail::newMailers(array()); - - $mailers = mpull($mailers, null, 'getKey'); if (!isset($mailers[$mailer_key])) { throw new PhutilArgumentUsageException( pht( - 'Mailer key ("%s") is not configured. Available keys are: %s.', + 'Mailer key ("%s") is not configured, or does not support '. + 'outbound messages of type "%s". Available mailers are: %s.', $mailer_key, + $type, implode(', ', array_keys($mailers)))); } - if (!$mailers[$mailer_key]->getSupportsOutbound()) { - throw new PhutilArgumentUsageException( - pht( - 'Mailer ("%s") is not configured to support outbound mail.', - $mailer_key)); - } - $mail->setTryMailers(array($mailer_key)); } @@ -197,6 +222,8 @@ final class PhabricatorMailManagementSendTestWorkflow $mail->addAttachment($file); } + $mail->setMessageType($type); + PhabricatorWorker::setRunAllTasksInProcess(true); $mail->save(); diff --git a/src/applications/metamta/message/PhabricatorMailEmailMessage.php b/src/applications/metamta/message/PhabricatorMailEmailMessage.php index 577b6052ea..c98cdc2e33 100644 --- a/src/applications/metamta/message/PhabricatorMailEmailMessage.php +++ b/src/applications/metamta/message/PhabricatorMailEmailMessage.php @@ -15,6 +15,10 @@ final class PhabricatorMailEmailMessage private $textBody; private $htmlBody; + public function newMailMessageEngine() { + return new PhabricatorMailEmailEngine(); + } + public function setFromAddress(PhutilEmailAddress $from_address) { $this->fromAddress = $from_address; return $this; diff --git a/src/applications/metamta/message/PhabricatorMailExternalMessage.php b/src/applications/metamta/message/PhabricatorMailExternalMessage.php index f691090d18..048b20ab54 100644 --- a/src/applications/metamta/message/PhabricatorMailExternalMessage.php +++ b/src/applications/metamta/message/PhabricatorMailExternalMessage.php @@ -7,4 +7,11 @@ abstract class PhabricatorMailExternalMessage return $this->getPhobjectClassConstant('MESSAGETYPE'); } + final public static function getAllMessageTypes() { + return id(new PhutilClassMapQuery()) + ->setAncestorClass(__CLASS__) + ->setUniqueMethod('getMessageType') + ->execute(); + } + } diff --git a/src/applications/metamta/message/PhabricatorMailSMSMessage.php b/src/applications/metamta/message/PhabricatorMailSMSMessage.php index a7e1d10923..ae7cd7122d 100644 --- a/src/applications/metamta/message/PhabricatorMailSMSMessage.php +++ b/src/applications/metamta/message/PhabricatorMailSMSMessage.php @@ -8,6 +8,10 @@ final class PhabricatorMailSMSMessage private $toNumber; private $textBody; + public function newMailMessageEngine() { + return new PhabricatorMailSMSEngine(); + } + public function setToNumber(PhabricatorPhoneNumber $to_number) { $this->toNumber = $to_number; return $this; diff --git a/src/applications/metamta/message/PhabricatorPhoneNumber.php b/src/applications/metamta/message/PhabricatorPhoneNumber.php index 6099ad0736..9d81936850 100644 --- a/src/applications/metamta/message/PhabricatorPhoneNumber.php +++ b/src/applications/metamta/message/PhabricatorPhoneNumber.php @@ -8,13 +8,23 @@ final class PhabricatorPhoneNumber public function __construct($raw_number) { $number = preg_replace('/[^\d]+/', '', $raw_number); - if (!preg_match('/^[1-9]\d{1,14}\z/', $number)) { + if (!preg_match('/^[1-9]\d{9,14}\z/', $number)) { throw new Exception( pht( - 'Phone number ("%s") is not in a recognized format.', + 'Phone number ("%s") is not in a recognized format: expected a '. + 'US number like "(555) 555-5555", or an international number '. + 'like "+55 5555 555555".', $raw_number)); } + // If the number didn't start with "+" and has has 10 digits, assume it is + // a US number with no country code prefix, like "(555) 555-5555". + if (!preg_match('/^[+]/', $raw_number)) { + if (strlen($number) === 10) { + $number = '1'.$number; + } + } + $this->number = $number; } diff --git a/src/applications/metamta/message/__tests__/PhabricatorPhoneNumberTestCase.php b/src/applications/metamta/message/__tests__/PhabricatorPhoneNumberTestCase.php new file mode 100644 index 0000000000..4a5da3bcc5 --- /dev/null +++ b/src/applications/metamta/message/__tests__/PhabricatorPhoneNumberTestCase.php @@ -0,0 +1,37 @@ + '+15555555555', + '+1 (555) 555-5555' => '+15555555555', + '(555) 555-5555' => '+15555555555', + + '' => false, + '1-800-CALL-SAUL' => false, + ); + + foreach ($map as $input => $expect) { + $caught = null; + try { + $actual = id(new PhabricatorPhoneNumber($input)) + ->toE164(); + } catch (Exception $ex) { + $caught = $ex; + } + + $this->assertEqual( + (bool)$caught, + ($expect === false), + pht('Exception raised by: %s', $input)); + + if ($expect !== false) { + $this->assertEqual($expect, $actual, pht('E164 of: %s', $input)); + } + } + + } + +} diff --git a/src/applications/metamta/storage/PhabricatorMetaMTAMail.php b/src/applications/metamta/storage/PhabricatorMetaMTAMail.php index 70d94ccb0c..cc3ae82bef 100644 --- a/src/applications/metamta/storage/PhabricatorMetaMTAMail.php +++ b/src/applications/metamta/storage/PhabricatorMetaMTAMail.php @@ -400,6 +400,18 @@ final class PhabricatorMetaMTAMail return $this->getParam('cc', array()); } + public function setMessageType($message_type) { + return $this->setParam('message.type', $message_type); + } + + public function getMessageType() { + return $this->getParam( + 'message.type', + PhabricatorMailEmailMessage::MESSAGETYPE); + } + + + /** * Force delivery of a message, even if recipients have preferences which * would otherwise drop the message. @@ -529,6 +541,9 @@ final class PhabricatorMetaMTAMail $mailers = self::newMailers( array( 'outbound' => true, + 'media' => array( + $this->getMessageType(), + ), )); $try_mailers = $this->getParam('mailers.try'); @@ -699,10 +714,19 @@ final class PhabricatorMetaMTAMail $file->attachToObject($this->getPHID()); } + $type_map = PhabricatorMailExternalMessage::getAllMessageTypes(); + $type = idx($type_map, $this->getMessageType()); + if (!$type) { + throw new Exception( + pht( + 'Unable to send message with unknown message type "%s".', + $type)); + } + $exceptions = array(); foreach ($mailers as $mailer) { try { - $message = id(new PhabricatorMailEmailEngine()) + $message = $type->newMailMessageEngine() ->setMailer($mailer) ->setMail($this) ->setActors($actors) diff --git a/src/applications/oauthserver/panel/PhabricatorOAuthServerAuthorizationsSettingsPanel.php b/src/applications/oauthserver/panel/PhabricatorOAuthServerAuthorizationsSettingsPanel.php index 37e85ab53b..89a1cc0281 100644 --- a/src/applications/oauthserver/panel/PhabricatorOAuthServerAuthorizationsSettingsPanel.php +++ b/src/applications/oauthserver/panel/PhabricatorOAuthServerAuthorizationsSettingsPanel.php @@ -11,6 +11,10 @@ final class PhabricatorOAuthServerAuthorizationsSettingsPanel return pht('OAuth Authorizations'); } + public function getPanelMenuIcon() { + return 'fa-exchange'; + } + public function getPanelGroupKey() { return PhabricatorSettingsLogsPanelGroup::PANELGROUPKEY; } diff --git a/src/applications/passphrase/controller/PassphraseCredentialRevealController.php b/src/applications/passphrase/controller/PassphraseCredentialRevealController.php index 3a40d253c9..99b6711ae6 100644 --- a/src/applications/passphrase/controller/PassphraseCredentialRevealController.php +++ b/src/applications/passphrase/controller/PassphraseCredentialRevealController.php @@ -21,12 +21,8 @@ final class PassphraseCredentialRevealController return new Aphront404Response(); } - $view_uri = '/K'.$credential->getID(); + $view_uri = $credential->getURI(); - $token = id(new PhabricatorAuthSessionEngine())->requireHighSecuritySession( - $viewer, - $request, - $view_uri); $is_locked = $credential->getIsLocked(); if ($is_locked) { @@ -39,7 +35,7 @@ final class PassphraseCredentialRevealController ->addCancelButton($view_uri); } - if ($request->isFormPost()) { + if ($request->isFormOrHisecPost()) { $secret = $credential->getSecret(); if (!$secret) { $body = pht('This credential has no associated secret.'); @@ -76,6 +72,7 @@ final class PassphraseCredentialRevealController $editor = id(new PassphraseCredentialTransactionEditor()) ->setActor($viewer) + ->setCancelURI($view_uri) ->setContinueOnNoEffect(true) ->setContentSourceFromRequest($request) ->applyTransactions($credential, $xactions); diff --git a/src/applications/passphrase/storage/PassphraseCredential.php b/src/applications/passphrase/storage/PassphraseCredential.php index b10d392d36..c470ea661f 100644 --- a/src/applications/passphrase/storage/PassphraseCredential.php +++ b/src/applications/passphrase/storage/PassphraseCredential.php @@ -52,6 +52,10 @@ final class PassphraseCredential extends PassphraseDAO return 'K'.$this->getID(); } + public function getURI() { + return '/'.$this->getMonogram(); + } + protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, diff --git a/src/applications/passphrase/xaction/PassphraseCredentialLookedAtTransaction.php b/src/applications/passphrase/xaction/PassphraseCredentialLookedAtTransaction.php index 3d8cb36f31..fc76ab0d56 100644 --- a/src/applications/passphrase/xaction/PassphraseCredentialLookedAtTransaction.php +++ b/src/applications/passphrase/xaction/PassphraseCredentialLookedAtTransaction.php @@ -30,4 +30,10 @@ final class PassphraseCredentialLookedAtTransaction return 'blue'; } + public function shouldTryMFA( + $object, + PhabricatorApplicationTransaction $xaction) { + return true; + } + } diff --git a/src/applications/people/controller/PhabricatorPeopleRenameController.php b/src/applications/people/controller/PhabricatorPeopleRenameController.php index 42ff2e7988..42eebfc8ae 100644 --- a/src/applications/people/controller/PhabricatorPeopleRenameController.php +++ b/src/applications/people/controller/PhabricatorPeopleRenameController.php @@ -17,14 +17,9 @@ final class PhabricatorPeopleRenameController $done_uri = $this->getApplicationURI("manage/{$id}/"); - id(new PhabricatorAuthSessionEngine())->requireHighSecuritySession( - $viewer, - $request, - $done_uri); - $validation_exception = null; $username = $user->getUsername(); - if ($request->isFormPost()) { + if ($request->isFormOrHisecPost()) { $username = $request->getStr('username'); $xactions = array(); @@ -36,6 +31,7 @@ final class PhabricatorPeopleRenameController $editor = id(new PhabricatorUserTransactionEditor()) ->setActor($viewer) ->setContentSourceFromRequest($request) + ->setCancelURI($done_uri) ->setContinueOnMissingFields(true); try { diff --git a/src/applications/people/storage/PhabricatorUser.php b/src/applications/people/storage/PhabricatorUser.php index e24024d96d..0b18c292c2 100644 --- a/src/applications/people/storage/PhabricatorUser.php +++ b/src/applications/people/storage/PhabricatorUser.php @@ -908,9 +908,15 @@ final class PhabricatorUser * @task factors */ public function updateMultiFactorEnrollment() { - $factors = id(new PhabricatorAuthFactorConfig())->loadAllWhere( - 'userPHID = %s', - $this->getPHID()); + $factors = id(new PhabricatorAuthFactorConfigQuery()) + ->setViewer($this) + ->withUserPHIDs(array($this->getPHID())) + ->withFactorProviderStatuses( + array( + PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE, + PhabricatorAuthFactorProviderStatus::STATUS_DEPRECATED, + )) + ->execute(); $enrolled = count($factors) ? 1 : 0; if ($enrolled !== $this->isEnrolledInMultiFactor) { diff --git a/src/applications/people/xaction/PhabricatorUserUsernameTransaction.php b/src/applications/people/xaction/PhabricatorUserUsernameTransaction.php index db134a5c78..b6d23b3511 100644 --- a/src/applications/people/xaction/PhabricatorUserUsernameTransaction.php +++ b/src/applications/people/xaction/PhabricatorUserUsernameTransaction.php @@ -89,4 +89,11 @@ final class PhabricatorUserUsernameTransaction return null; } + + public function shouldTryMFA( + $object, + PhabricatorApplicationTransaction $xaction) { + return true; + } + } diff --git a/src/applications/repository/storage/PhabricatorRepository.php b/src/applications/repository/storage/PhabricatorRepository.php index e1febe7ac8..ed90f47f13 100644 --- a/src/applications/repository/storage/PhabricatorRepository.php +++ b/src/applications/repository/storage/PhabricatorRepository.php @@ -2024,10 +2024,35 @@ final class PhabricatorRepository extends PhabricatorRepositoryDAO } if ($never_proxy) { + // See PHI1030. This error can arise from various device name/address + // mismatches which are hard to detect, so try to provide as much + // information as we can. + + if ($writable) { + $request_type = pht('(This is a write request.)'); + } else { + $request_type = pht('(This is a read request.)'); + } + throw new Exception( pht( - 'Refusing to proxy a repository request from a cluster host. '. - 'Cluster hosts must correctly route their intracluster requests.')); + 'This repository request (for repository "%s") has been '. + 'incorrectly routed to a cluster host (with device name "%s", '. + 'and hostname "%s") which can not serve the request.'. + "\n\n". + 'The Almanac device address for the correct device may improperly '. + 'point at this host, or the "device.id" configuration file on '. + 'this host may be incorrect.'. + "\n\n". + 'Requests routed within the cluster by Phabricator are always '. + 'expected to be sent to a node which can serve the request. To '. + 'prevent loops, this request will not be proxied again.'. + "\n\n". + "%s", + $this->getDisplayName(), + $local_device, + php_uname('n'), + $request_type)); } if (count($results) > 1) { @@ -2147,7 +2172,7 @@ final class PhabricatorRepository extends PhabricatorRepositoryDAO $parts = array( "repo({$repository_phid})", "serv({$service_phid})", - 'v3', + 'v4', ); return implode('.', $parts); diff --git a/src/applications/search/view/PhabricatorSearchResultView.php b/src/applications/search/view/PhabricatorSearchResultView.php index 6c527733e8..b209b4422a 100644 --- a/src/applications/search/view/PhabricatorSearchResultView.php +++ b/src/applications/search/view/PhabricatorSearchResultView.php @@ -126,7 +126,7 @@ final class PhabricatorSearchResultView extends AphrontView { } // Go through the string one display glyph at a time. If a glyph starts - // on a highlighted byte position, turn on highlighting for the nubmer + // on a highlighted byte position, turn on highlighting for the number // of matching bytes. If a query searches for "e" and the document contains // an "e" followed by a bunch of combining marks, this will correctly // highlight the entire glyph. diff --git a/src/applications/settings/controller/PhabricatorSettingsMainController.php b/src/applications/settings/controller/PhabricatorSettingsMainController.php index 9dc84a9bd0..ded20a8e96 100644 --- a/src/applications/settings/controller/PhabricatorSettingsMainController.php +++ b/src/applications/settings/controller/PhabricatorSettingsMainController.php @@ -115,7 +115,7 @@ final class PhabricatorSettingsMainController $crumbs->setBorder(true); if ($this->user) { - $header_text = pht('Edit Settings (%s)', $user->getUserName()); + $header_text = pht('Edit Settings: %s', $user->getUserName()); } else { $header_text = pht('Edit Global Settings'); } @@ -127,15 +127,13 @@ final class PhabricatorSettingsMainController $view = id(new PHUITwoColumnView()) ->setHeader($header) - ->setFixed(true) - ->setNavigation($nav) - ->setMainColumn($response); + ->setFooter($response); return $this->newPage() ->setTitle($title) ->setCrumbs($crumbs) + ->setNavigation($nav) ->appendChild($view); - } private function buildPanels(PhabricatorUserPreferences $preferences) { @@ -211,7 +209,11 @@ final class PhabricatorSettingsMainController } } - $nav->addFilter($panel->getPanelKey(), $panel->getPanelName()); + $nav->addFilter( + $panel->getPanelKey(), + $panel->getPanelName(), + null, + $panel->getPanelMenuIcon()); } return $nav; diff --git a/src/applications/settings/editor/PhabricatorSettingsEditEngine.php b/src/applications/settings/editor/PhabricatorSettingsEditEngine.php index 30e831543d..34a6132d80 100644 --- a/src/applications/settings/editor/PhabricatorSettingsEditEngine.php +++ b/src/applications/settings/editor/PhabricatorSettingsEditEngine.php @@ -101,7 +101,7 @@ final class PhabricatorSettingsEditEngine protected function getPageHeader($object) { $user = $object->getUser(); if ($user) { - $text = pht('Edit Settings (%s)', $user->getUserName()); + $text = pht('Edit Settings: %s', $user->getUserName()); } else { $text = pht('Edit Global Settings'); } @@ -152,7 +152,7 @@ final class PhabricatorSettingsEditEngine $viewer = $this->getViewer(); $user = $object->getUser(); - $panels = PhabricatorSettingsPanel::getAllPanels(); + $panels = PhabricatorSettingsPanel::getAllDisplayPanels(); foreach ($panels as $key => $panel) { if (!($panel instanceof PhabricatorEditEngineSettingsPanel)) { diff --git a/src/applications/settings/panel/PhabricatorActivitySettingsPanel.php b/src/applications/settings/panel/PhabricatorActivitySettingsPanel.php index 2759f3a26c..a3654a4388 100644 --- a/src/applications/settings/panel/PhabricatorActivitySettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorActivitySettingsPanel.php @@ -10,6 +10,10 @@ final class PhabricatorActivitySettingsPanel extends PhabricatorSettingsPanel { return pht('Activity Logs'); } + public function getPanelMenuIcon() { + return 'fa-list'; + } + public function getPanelGroupKey() { return PhabricatorSettingsLogsPanelGroup::PANELGROUPKEY; } diff --git a/src/applications/settings/panel/PhabricatorConpherencePreferencesSettingsPanel.php b/src/applications/settings/panel/PhabricatorConpherencePreferencesSettingsPanel.php index 6ed6325d67..3ce72af2f8 100644 --- a/src/applications/settings/panel/PhabricatorConpherencePreferencesSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorConpherencePreferencesSettingsPanel.php @@ -9,6 +9,10 @@ final class PhabricatorConpherencePreferencesSettingsPanel return pht('Conpherence'); } + public function getPanelMenuIcon() { + return 'fa-comment-o'; + } + public function getPanelGroupKey() { return PhabricatorSettingsApplicationsPanelGroup::PANELGROUPKEY; } diff --git a/src/applications/settings/panel/PhabricatorContactNumbersSettingsPanel.php b/src/applications/settings/panel/PhabricatorContactNumbersSettingsPanel.php new file mode 100644 index 0000000000..7056fd02de --- /dev/null +++ b/src/applications/settings/panel/PhabricatorContactNumbersSettingsPanel.php @@ -0,0 +1,91 @@ +getUser(); + $viewer = $request->getUser(); + + $numbers = id(new PhabricatorAuthContactNumberQuery()) + ->setViewer($viewer) + ->withObjectPHIDs(array($user->getPHID())) + ->execute(); + $numbers = msortv($numbers, 'getSortVector'); + + $rows = array(); + $row_classes = array(); + foreach ($numbers as $number) { + if ($number->getIsPrimary()) { + $primary_display = pht('Primary'); + $row_classes[] = 'highlighted'; + } else { + $primary_display = null; + $row_classes[] = null; + } + + $rows[] = array( + $number->newIconView(), + phutil_tag( + 'a', + array( + 'href' => $number->getURI(), + ), + $number->getDisplayName()), + $primary_display, + phabricator_datetime($number->getDateCreated(), $viewer), + ); + } + + $table = id(new AphrontTableView($rows)) + ->setNoDataString( + pht("You haven't added any contact numbers to your account.")) + ->setRowClasses($row_classes) + ->setHeaders( + array( + null, + pht('Number'), + pht('Status'), + pht('Created'), + )) + ->setColumnClasses( + array( + null, + 'wide pri', + null, + 'right', + )); + + $buttons = array(); + + $buttons[] = id(new PHUIButtonView()) + ->setTag('a') + ->setIcon('fa-plus') + ->setText(pht('Add Contact Number')) + ->setHref('/auth/contact/edit/') + ->setColor(PHUIButtonView::GREY); + + return $this->newBox(pht('Contact Numbers'), $table, $buttons); + } + +} diff --git a/src/applications/settings/panel/PhabricatorDateTimeSettingsPanel.php b/src/applications/settings/panel/PhabricatorDateTimeSettingsPanel.php index e5ca46510e..285bc6989f 100644 --- a/src/applications/settings/panel/PhabricatorDateTimeSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorDateTimeSettingsPanel.php @@ -9,6 +9,10 @@ final class PhabricatorDateTimeSettingsPanel return pht('Date and Time'); } + public function getPanelMenuIcon() { + return 'fa-calendar'; + } + public function getPanelGroupKey() { return PhabricatorSettingsAccountPanelGroup::PANELGROUPKEY; } diff --git a/src/applications/settings/panel/PhabricatorDeveloperPreferencesSettingsPanel.php b/src/applications/settings/panel/PhabricatorDeveloperPreferencesSettingsPanel.php index 384f7e3be9..e6ed8e7564 100644 --- a/src/applications/settings/panel/PhabricatorDeveloperPreferencesSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorDeveloperPreferencesSettingsPanel.php @@ -9,6 +9,10 @@ final class PhabricatorDeveloperPreferencesSettingsPanel return pht('Developer Settings'); } + public function getPanelMenuIcon() { + return 'fa-magic'; + } + public function getPanelGroupKey() { return PhabricatorSettingsDeveloperPanelGroup::PANELGROUPKEY; } diff --git a/src/applications/settings/panel/PhabricatorDiffPreferencesSettingsPanel.php b/src/applications/settings/panel/PhabricatorDiffPreferencesSettingsPanel.php index 2e055c3408..acb7f50541 100644 --- a/src/applications/settings/panel/PhabricatorDiffPreferencesSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorDiffPreferencesSettingsPanel.php @@ -9,6 +9,10 @@ final class PhabricatorDiffPreferencesSettingsPanel return pht('Diff Preferences'); } + public function getPanelMenuIcon() { + return 'fa-cog'; + } + public function getPanelGroupKey() { return PhabricatorSettingsApplicationsPanelGroup::PANELGROUPKEY; } diff --git a/src/applications/settings/panel/PhabricatorDisplayPreferencesSettingsPanel.php b/src/applications/settings/panel/PhabricatorDisplayPreferencesSettingsPanel.php index 6033ef79e9..7c17a9fea5 100644 --- a/src/applications/settings/panel/PhabricatorDisplayPreferencesSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorDisplayPreferencesSettingsPanel.php @@ -9,6 +9,10 @@ final class PhabricatorDisplayPreferencesSettingsPanel return pht('Display Preferences'); } + public function getPanelMenuIcon() { + return 'fa-desktop'; + } + public function getPanelGroupKey() { return PhabricatorSettingsApplicationsPanelGroup::PANELGROUPKEY; } diff --git a/src/applications/settings/panel/PhabricatorEmailAddressesSettingsPanel.php b/src/applications/settings/panel/PhabricatorEmailAddressesSettingsPanel.php index cd1bfba540..1b69adcd62 100644 --- a/src/applications/settings/panel/PhabricatorEmailAddressesSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorEmailAddressesSettingsPanel.php @@ -11,6 +11,10 @@ final class PhabricatorEmailAddressesSettingsPanel return pht('Email Addresses'); } + public function getPanelMenuIcon() { + return 'fa-at'; + } + public function getPanelGroupKey() { return PhabricatorSettingsEmailPanelGroup::PANELGROUPKEY; } diff --git a/src/applications/settings/panel/PhabricatorEmailDeliverySettingsPanel.php b/src/applications/settings/panel/PhabricatorEmailDeliverySettingsPanel.php index 86260c1b5a..55932aa49b 100644 --- a/src/applications/settings/panel/PhabricatorEmailDeliverySettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorEmailDeliverySettingsPanel.php @@ -9,6 +9,10 @@ final class PhabricatorEmailDeliverySettingsPanel return pht('Email Delivery'); } + public function getPanelMenuIcon() { + return 'fa-envelope-o'; + } + public function getPanelGroupKey() { return PhabricatorSettingsEmailPanelGroup::PANELGROUPKEY; } diff --git a/src/applications/settings/panel/PhabricatorEmailFormatSettingsPanel.php b/src/applications/settings/panel/PhabricatorEmailFormatSettingsPanel.php index 51ff40ed9d..5a4a707a05 100644 --- a/src/applications/settings/panel/PhabricatorEmailFormatSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorEmailFormatSettingsPanel.php @@ -9,6 +9,10 @@ final class PhabricatorEmailFormatSettingsPanel return pht('Email Format'); } + public function getPanelMenuIcon() { + return 'fa-font'; + } + public function getPanelGroupKey() { return PhabricatorSettingsEmailPanelGroup::PANELGROUPKEY; } @@ -19,17 +23,6 @@ final class PhabricatorEmailFormatSettingsPanel public function isManagementPanel() { return false; -/* - if (!$this->isUserPanel()) { - return false; - } - - if ($this->getUser()->getIsMailingList()) { - return true; - } - - return false; -*/ } public function isTemplatePanel() { diff --git a/src/applications/settings/panel/PhabricatorEmailPreferencesSettingsPanel.php b/src/applications/settings/panel/PhabricatorEmailPreferencesSettingsPanel.php index faa79889ed..defee73393 100644 --- a/src/applications/settings/panel/PhabricatorEmailPreferencesSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorEmailPreferencesSettingsPanel.php @@ -11,6 +11,10 @@ final class PhabricatorEmailPreferencesSettingsPanel return pht('Email Preferences'); } + public function getPanelMenuIcon() { + return 'fa-envelope-open-o'; + } + public function getPanelGroupKey() { return PhabricatorSettingsEmailPanelGroup::PANELGROUPKEY; } diff --git a/src/applications/settings/panel/PhabricatorExternalAccountsSettingsPanel.php b/src/applications/settings/panel/PhabricatorExternalAccountsSettingsPanel.php index e380248a83..1215487208 100644 --- a/src/applications/settings/panel/PhabricatorExternalAccountsSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorExternalAccountsSettingsPanel.php @@ -11,6 +11,10 @@ final class PhabricatorExternalAccountsSettingsPanel return pht('External Accounts'); } + public function getPanelMenuIcon() { + return 'fa-users'; + } + public function getPanelGroupKey() { return PhabricatorSettingsAuthenticationPanelGroup::PANELGROUPKEY; } diff --git a/src/applications/settings/panel/PhabricatorAccountSettingsPanel.php b/src/applications/settings/panel/PhabricatorLanguageSettingsPanel.php similarity index 57% rename from src/applications/settings/panel/PhabricatorAccountSettingsPanel.php rename to src/applications/settings/panel/PhabricatorLanguageSettingsPanel.php index a7eb3ad099..39bd5deac9 100644 --- a/src/applications/settings/panel/PhabricatorAccountSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorLanguageSettingsPanel.php @@ -1,12 +1,16 @@ isEnrollment = $is_enrollment; + return $this; + } + + public function getIsEnrollment() { + return $this->isEnrollment; + } + public function processRequest(AphrontRequest $request) { - if ($request->getExists('new')) { + if ($request->getExists('new') || $request->getExists('providerPHID')) { return $this->processNew($request); } @@ -31,22 +50,18 @@ final class PhabricatorMultiFactorSettingsPanel $user = $this->getUser(); $viewer = $request->getUser(); - $factors = id(new PhabricatorAuthFactorConfig())->loadAllWhere( - 'userPHID = %s', - $user->getPHID()); + $factors = id(new PhabricatorAuthFactorConfigQuery()) + ->setViewer($viewer) + ->withUserPHIDs(array($user->getPHID())) + ->execute(); + $factors = msort($factors, 'newSortVector'); $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(); - } + $provider = $factor->getFactorProvider(); if ($factor->getID() == $highlight_id) { $rowc[] = 'highlighted'; @@ -54,7 +69,18 @@ final class PhabricatorMultiFactorSettingsPanel $rowc[] = null; } + $status = $provider->newStatus(); + $status_icon = $status->getFactorIcon(); + $status_color = $status->getFactorColor(); + + $icon = id(new PHUIIconView()) + ->setIcon("{$status_icon} {$status_color}") + ->setTooltip(pht('Provider: %s', $status->getName())); + + $details = $provider->getConfigurationListDetails($factor, $viewer); + $rows[] = array( + $icon, javelin_tag( 'a', array( @@ -62,7 +88,9 @@ final class PhabricatorMultiFactorSettingsPanel 'sigil' => 'workflow', ), $factor->getFactorName()), - $type, + $provider->getFactor()->getFactorShortName(), + $provider->getDisplayName(), + $details, phabricator_datetime($factor->getDateCreated(), $viewer), javelin_tag( 'a', @@ -80,15 +108,21 @@ final class PhabricatorMultiFactorSettingsPanel pht("You haven't added any authentication factors to your account yet.")); $table->setHeaders( array( + null, pht('Name'), pht('Type'), + pht('Provider'), + pht('Details'), pht('Created'), - '', + null, )); $table->setColumnClasses( array( + null, 'wide pri', - '', + null, + null, + null, 'right', 'action', )); @@ -96,6 +130,9 @@ final class PhabricatorMultiFactorSettingsPanel $table->setDeviceVisibility( array( true, + true, + false, + false, false, false, true, @@ -106,13 +143,24 @@ final class PhabricatorMultiFactorSettingsPanel $buttons = array(); + // If we're enrolling a new account in MFA, provide a small visual hint + // that this is the button they want to click. + if ($this->getIsEnrollment()) { + $add_color = PHUIButtonView::BLUE; + } else { + $add_color = PHUIButtonView::GREY; + } + + $can_add = (bool)$this->loadActiveMFAProviders(); + $buttons[] = id(new PHUIButtonView()) ->setTag('a') ->setIcon('fa-plus') ->setText(pht('Add Auth Factor')) ->setHref($this->getPanelURI('?new=true')) ->setWorkflow(true) - ->setColor(PHUIButtonView::GREY); + ->setDisabled(!$can_add) + ->setColor($add_color); $buttons[] = id(new PHUIButtonView()) ->setTag('a') @@ -128,88 +176,168 @@ final class PhabricatorMultiFactorSettingsPanel $viewer = $request->getUser(); $user = $this->getUser(); + $cancel_uri = $this->getPanelURI(); + + // Check that we have providers before we send the user through the MFA + // gate, so you don't authenticate and then immediately get roadblocked. + $providers = $this->loadActiveMFAProviders(); + + if (!$providers) { + return $this->newDialog() + ->setTitle(pht('No MFA Providers')) + ->appendParagraph( + pht( + 'This install does not have any active MFA providers configured. '. + 'At least one provider must be configured and active before you '. + 'can add new MFA factors.')) + ->addCancelButton($cancel_uri); + } + $token = id(new PhabricatorAuthSessionEngine())->requireHighSecuritySession( $viewer, $request, - $this->getPanelURI()); + $cancel_uri); - $factors = PhabricatorAuthFactor::getAllFactors(); + $selected_phid = $request->getStr('providerPHID'); + if (empty($providers[$selected_phid])) { + $selected_provider = null; + } else { + $selected_provider = $providers[$selected_phid]; + + // Only let the user continue creating a factor for a given provider if + // they actually pass the provider's checks. + if (!$selected_provider->canCreateNewConfiguration($viewer)) { + $selected_provider = null; + } + } + + if (!$selected_provider) { + $menu = id(new PHUIObjectItemListView()) + ->setViewer($viewer) + ->setBig(true) + ->setFlush(true); + + foreach ($providers as $provider_phid => $provider) { + $provider_uri = id(new PhutilURI($this->getPanelURI())) + ->setQueryParam('providerPHID', $provider_phid); + + $is_enabled = $provider->canCreateNewConfiguration($viewer); + + $item = id(new PHUIObjectItemView()) + ->setHeader($provider->getDisplayName()) + ->setImageIcon($provider->newIconView()) + ->addAttribute($provider->getDisplayDescription()); + + if ($is_enabled) { + $item + ->setHref($provider_uri) + ->setClickable(true); + } else { + $item->setDisabled(true); + } + + $create_description = $provider->getConfigurationCreateDescription( + $viewer); + if ($create_description) { + $item->appendChild($create_description); + } + + $menu->addItem($item); + } + + return $this->newDialog() + ->setTitle(pht('Choose Factor Type')) + ->appendChild($menu) + ->addCancelButton($cancel_uri); + } + + // NOTE: Beyond providing guidance, this step is also providing a CSRF gate + // on this endpoint, since prompting the user to respond to a challenge + // sometimes requires us to push a challenge to them as a side effect (for + // example, with SMS). + if (!$request->isFormPost() || !$request->getBool('mfa.start')) { + $description = $selected_provider->getEnrollDescription($viewer); + + return $this->newDialog() + ->addHiddenInput('providerPHID', $selected_provider->getPHID()) + ->addHiddenInput('mfa.start', 1) + ->setTitle(pht('Add Authentication Factor')) + ->appendChild(new PHUIRemarkupView($viewer, $description)) + ->addCancelButton($cancel_uri) + ->addSubmitButton($selected_provider->getEnrollButtonText($viewer)); + } $form = id(new AphrontFormView()) - ->setUser($viewer); + ->setViewer($viewer); - $type = $request->getStr('type'); - if (empty($factors[$type]) || !$request->isFormPost()) { - $factor = null; + if ($request->getBool('mfa.enroll')) { + // Subject users to rate limiting so that it's difficult to add factors + // by pure brute force. This is normally not much of an attack, but push + // factor types may have side effects. + PhabricatorSystemActionEngine::willTakeAction( + array($viewer->getPHID()), + new PhabricatorAuthNewFactorAction(), + 1); } else { - $factor = $factors[$type]; + // Test the limit before showing the user a form, so we don't give them + // a form which can never possibly work because it will always hit rate + // limiting. + PhabricatorSystemActionEngine::willTakeAction( + array($viewer->getPHID()), + new PhabricatorAuthNewFactorAction(), + 0); } - $dialog = id(new AphrontDialogView()) - ->setUser($viewer) - ->addHiddenInput('new', true); + $config = $selected_provider->processAddFactorForm( + $form, + $request, + $user); - if ($factor === null) { - $choice_control = id(new AphrontFormRadioButtonControl()) - ->setName('type') - ->setValue(key($factors)); + if ($config) { + // If the user added a factor, give them a rate limiting point back. + PhabricatorSystemActionEngine::willTakeAction( + array($viewer->getPHID()), + new PhabricatorAuthNewFactorAction(), + -1); - foreach ($factors as $available_factor) { - $choice_control->addButton( - $available_factor->getFactorKey(), - $available_factor->getFactorName(), - $available_factor->getFactorDescription()); + $config->save(); + + // If we used a temporary token to handle synchronizing the factor, + // revoke it now. + $sync_token = $config->getMFASyncToken(); + if ($sync_token) { + $sync_token->revokeToken(); } - $dialog->appendParagraph( - pht( - 'Adding an additional authentication factor improves the security '. - 'of your account. Choose the type of factor to add:')); + $log = PhabricatorUserLog::initializeNewLog( + $viewer, + $user->getPHID(), + PhabricatorUserLog::ACTION_MULTI_ADD); + $log->save(); - $form - ->appendChild($choice_control); + $user->updateMultiFactorEnrollment(); - } else { - $dialog->addHiddenInput('type', $type); + // Terminate other sessions so they must log in and survive the + // multi-factor auth check. - $config = $factor->processAddFactorForm( - $form, - $request, - $user); + id(new PhabricatorAuthSessionEngine())->terminateLoginSessions( + $user, + new PhutilOpaqueEnvelope( + $request->getCookie(PhabricatorCookies::COOKIE_SESSION))); - if ($config) { - $config->save(); - - $log = PhabricatorUserLog::initializeNewLog( - $viewer, - $user->getPHID(), - PhabricatorUserLog::ACTION_MULTI_ADD); - $log->save(); - - $user->updateMultiFactorEnrollment(); - - // Terminate other sessions so they must log in and survive the - // multi-factor auth check. - - id(new PhabricatorAuthSessionEngine())->terminateLoginSessions( - $user, - new PhutilOpaqueEnvelope( - $request->getCookie(PhabricatorCookies::COOKIE_SESSION))); - - return id(new AphrontRedirectResponse()) - ->setURI($this->getPanelURI('?id='.$config->getID())); - } + return id(new AphrontRedirectResponse()) + ->setURI($this->getPanelURI('?id='.$config->getID())); } - $dialog + return $this->newDialog() + ->addHiddenInput('providerPHID', $selected_provider->getPHID()) + ->addHiddenInput('mfa.start', 1) + ->addHiddenInput('mfa.enroll', 1) ->setWidth(AphrontDialogView::WIDTH_FORM) ->setTitle(pht('Add Authentication Factor')) ->appendChild($form->buildLayoutView()) ->addSubmitButton(pht('Continue')) - ->addCancelButton($this->getPanelURI()); - - return id(new AphrontDialogResponse()) - ->setDialog($dialog); + ->addCancelButton($cancel_uri); } private function processEdit(AphrontRequest $request) { @@ -316,5 +444,22 @@ final class PhabricatorMultiFactorSettingsPanel ->setDialog($dialog); } + private function loadActiveMFAProviders() { + $viewer = $this->getViewer(); + + $providers = id(new PhabricatorAuthFactorProviderQuery()) + ->setViewer($viewer) + ->withStatuses( + array( + PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE, + )) + ->execute(); + + $providers = mpull($providers, null, 'getPHID'); + $providers = msortv($providers, 'newSortVector'); + + return $providers; + } + } diff --git a/src/applications/settings/panel/PhabricatorNotificationsSettingsPanel.php b/src/applications/settings/panel/PhabricatorNotificationsSettingsPanel.php index 797bcafcb3..d0165dc3f1 100644 --- a/src/applications/settings/panel/PhabricatorNotificationsSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorNotificationsSettingsPanel.php @@ -21,6 +21,10 @@ final class PhabricatorNotificationsSettingsPanel return pht('Notifications'); } + public function getPanelMenuIcon() { + return 'fa-bell-o'; + } + public function getPanelGroupKey() { return PhabricatorSettingsApplicationsPanelGroup::PANELGROUPKEY; } diff --git a/src/applications/settings/panel/PhabricatorPasswordSettingsPanel.php b/src/applications/settings/panel/PhabricatorPasswordSettingsPanel.php index 79d7610f2f..37393d5d4f 100644 --- a/src/applications/settings/panel/PhabricatorPasswordSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorPasswordSettingsPanel.php @@ -10,6 +10,10 @@ final class PhabricatorPasswordSettingsPanel extends PhabricatorSettingsPanel { return pht('Password'); } + public function getPanelMenuIcon() { + return 'fa-key'; + } + public function getPanelGroupKey() { return PhabricatorSettingsAuthenticationPanelGroup::PANELGROUPKEY; } diff --git a/src/applications/settings/panel/PhabricatorSSHKeysSettingsPanel.php b/src/applications/settings/panel/PhabricatorSSHKeysSettingsPanel.php index 13944411ed..131f602974 100644 --- a/src/applications/settings/panel/PhabricatorSSHKeysSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorSSHKeysSettingsPanel.php @@ -18,6 +18,10 @@ final class PhabricatorSSHKeysSettingsPanel extends PhabricatorSettingsPanel { return pht('SSH Public Keys'); } + public function getPanelMenuIcon() { + return 'fa-file-text-o'; + } + public function getPanelGroupKey() { return PhabricatorSettingsAuthenticationPanelGroup::PANELGROUPKEY; } diff --git a/src/applications/settings/panel/PhabricatorSessionsSettingsPanel.php b/src/applications/settings/panel/PhabricatorSessionsSettingsPanel.php index 314d68f69d..fb10572e11 100644 --- a/src/applications/settings/panel/PhabricatorSessionsSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorSessionsSettingsPanel.php @@ -10,6 +10,10 @@ final class PhabricatorSessionsSettingsPanel extends PhabricatorSettingsPanel { return pht('Sessions'); } + public function getPanelMenuIcon() { + return 'fa-user'; + } + public function getPanelGroupKey() { return PhabricatorSettingsLogsPanelGroup::PANELGROUPKEY; } diff --git a/src/applications/settings/panel/PhabricatorSettingsPanel.php b/src/applications/settings/panel/PhabricatorSettingsPanel.php index 19ac6fec62..e2efd92093 100644 --- a/src/applications/settings/panel/PhabricatorSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorSettingsPanel.php @@ -131,6 +131,16 @@ abstract class PhabricatorSettingsPanel extends Phobject { abstract public function getPanelName(); + /** + * Return an icon for the panel in the menu. + * + * @return string Icon identifier. + * @task config + */ + public function getPanelMenuIcon() { + return 'fa-wrench'; + } + /** * Return a panel group key constant for this panel. * @@ -188,6 +198,17 @@ abstract class PhabricatorSettingsPanel extends Phobject { return false; } + /** + * Return true if this panel should be available when enrolling in MFA on + * a new account with MFA requiredd. + * + * @return bool True to allow configuration during MFA enrollment. + * @task config + */ + public function isMultiFactorEnrollmentPanel() { + return false; + } + /* -( Panel Implementation )----------------------------------------------- */ diff --git a/src/applications/settings/panel/PhabricatorTokensSettingsPanel.php b/src/applications/settings/panel/PhabricatorTokensSettingsPanel.php index f2021bafa5..91064a432f 100644 --- a/src/applications/settings/panel/PhabricatorTokensSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorTokensSettingsPanel.php @@ -10,6 +10,10 @@ final class PhabricatorTokensSettingsPanel extends PhabricatorSettingsPanel { return pht('Temporary Tokens'); } + public function getPanelMenuIcon() { + return 'fa-ticket'; + } + public function getPanelGroupKey() { return PhabricatorSettingsLogsPanelGroup::PANELGROUPKEY; } diff --git a/src/applications/settings/panelgroup/PhabricatorSettingsAccountPanelGroup.php b/src/applications/settings/panelgroup/PhabricatorSettingsAccountPanelGroup.php index 826118b881..f81d5d487a 100644 --- a/src/applications/settings/panelgroup/PhabricatorSettingsAccountPanelGroup.php +++ b/src/applications/settings/panelgroup/PhabricatorSettingsAccountPanelGroup.php @@ -6,7 +6,7 @@ final class PhabricatorSettingsAccountPanelGroup const PANELGROUPKEY = 'account'; public function getPanelGroupName() { - return null; + return pht('Account'); } protected function getPanelGroupOrder() { diff --git a/src/applications/settings/setting/PhabricatorPronounSetting.php b/src/applications/settings/setting/PhabricatorPronounSetting.php index 2ab30f0ab0..51425f3cfd 100644 --- a/src/applications/settings/setting/PhabricatorPronounSetting.php +++ b/src/applications/settings/setting/PhabricatorPronounSetting.php @@ -10,7 +10,7 @@ final class PhabricatorPronounSetting } public function getSettingPanelKey() { - return PhabricatorAccountSettingsPanel::PANELKEY; + return PhabricatorLanguageSettingsPanel::PANELKEY; } protected function getSettingOrder() { diff --git a/src/applications/settings/setting/PhabricatorTranslationSetting.php b/src/applications/settings/setting/PhabricatorTranslationSetting.php index 6c0bec8799..09f77c2ba4 100644 --- a/src/applications/settings/setting/PhabricatorTranslationSetting.php +++ b/src/applications/settings/setting/PhabricatorTranslationSetting.php @@ -10,7 +10,7 @@ final class PhabricatorTranslationSetting } public function getSettingPanelKey() { - return PhabricatorAccountSettingsPanel::PANELKEY; + return PhabricatorLanguageSettingsPanel::PANELKEY; } protected function getSettingOrder() { diff --git a/src/applications/transactions/editengine/PhabricatorEditEngine.php b/src/applications/transactions/editengine/PhabricatorEditEngine.php index 9aa60872dc..b3106d27b2 100644 --- a/src/applications/transactions/editengine/PhabricatorEditEngine.php +++ b/src/applications/transactions/editengine/PhabricatorEditEngine.php @@ -1249,6 +1249,8 @@ abstract class PhabricatorEditEngine $view->setHeader($page_header); } + $view->setFooter($content); + $page = $controller->newPage() ->setTitle($header_text) ->setCrumbs($crumbs) @@ -1256,11 +1258,7 @@ abstract class PhabricatorEditEngine $navigation = $this->getNavigation(); if ($navigation) { - $view->setFixed(true); - $view->setNavigation($navigation); - $view->setMainColumn($content); - } else { - $view->setFooter($content); + $page->setNavigation($navigation); } return $page; diff --git a/src/applications/transactions/editengine/PhabricatorEditEngineMFAEngine.php b/src/applications/transactions/editengine/PhabricatorEditEngineMFAEngine.php index 523672340a..271ec6f446 100644 --- a/src/applications/transactions/editengine/PhabricatorEditEngineMFAEngine.php +++ b/src/applications/transactions/editengine/PhabricatorEditEngineMFAEngine.php @@ -34,6 +34,28 @@ abstract class PhabricatorEditEngineMFAEngine ->setObject($object); } - abstract public function shouldRequireMFA(); + /** + * Do edits to this object REQUIRE that the user submit MFA? + * + * This is a strict requirement: users will need to add MFA to their accounts + * if they don't already have it. + * + * @return bool True to strictly require MFA. + */ + public function shouldRequireMFA() { + return false; + } + + /** + * Should edits to this object prompt for MFA if it's available? + * + * This is advisory: users without MFA on their accounts will be able to + * perform edits without being required to add MFA. + * + * @return bool True to prompt for MFA if available. + */ + public function shouldTryMFA() { + return false; + } } diff --git a/src/applications/transactions/editfield/PhabricatorCredentialEditField.php b/src/applications/transactions/editfield/PhabricatorCredentialEditField.php new file mode 100644 index 0000000000..7c70bf288e --- /dev/null +++ b/src/applications/transactions/editfield/PhabricatorCredentialEditField.php @@ -0,0 +1,43 @@ +credentialType = $credential_type; + return $this; + } + + public function getCredentialType() { + return $this->credentialType; + } + + public function setCredentials(array $credentials) { + $this->credentials = $credentials; + return $this; + } + + public function getCredentials() { + return $this->credentials; + } + + protected function newControl() { + $control = id(new PassphraseCredentialControl()) + ->setCredentialType($this->getCredentialType()) + ->setOptions($this->getCredentials()); + + return $control; + } + + protected function newHTTPParameterType() { + return new AphrontPHIDHTTPParameterType(); + } + + protected function newConduitParameterType() { + return new ConduitPHIDParameterType(); + } + +} diff --git a/src/applications/transactions/editfield/PhabricatorSpaceEditField.php b/src/applications/transactions/editfield/PhabricatorSpaceEditField.php index ee15f0b19e..c15213bd93 100644 --- a/src/applications/transactions/editfield/PhabricatorSpaceEditField.php +++ b/src/applications/transactions/editfield/PhabricatorSpaceEditField.php @@ -28,7 +28,6 @@ final class PhabricatorSpaceEditField return new ConduitPHIDParameterType(); } - public function shouldReadValueFromRequest() { return $this->getPolicyField()->shouldReadValueFromRequest(); } diff --git a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php index 64f375fd88..c6458b0631 100644 --- a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php +++ b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php @@ -447,6 +447,12 @@ abstract class PhabricatorApplicationTransactionEditor 'edge:type')); } + // See T13082. If this is an inverse edit, the parent editor has + // already populated the transaction values correctly. + if ($this->getIsInverseEdgeEditor()) { + return $xaction->getOldValue(); + } + $old_edges = array(); if ($object->getPHID()) { $edge_src = $object->getPHID(); @@ -513,6 +519,12 @@ abstract class PhabricatorApplicationTransactionEditor return $space_phid; } case PhabricatorTransactions::TYPE_EDGE: + // See T13082. If this is an inverse edit, the parent editor has + // already populated appropriate transaction values. + if ($this->getIsInverseEdgeEditor()) { + return $xaction->getNewValue(); + } + $new_value = $this->getEdgeTransactionNewValue($xaction); $edge_type = $xaction->getMetadataValue('edge:type'); @@ -790,14 +802,6 @@ abstract class PhabricatorApplicationTransactionEditor $src = $object->getPHID(); $const = $xaction->getMetadataValue('edge:type'); - $type = PhabricatorEdgeType::getByConstant($const); - if ($type->shouldWriteInverseTransactions()) { - $this->applyInverseEdgeTransactions( - $object, - $xaction, - $type->getInverseEdgeConstant()); - } - foreach ($new as $dst_phid => $edge) { $new[$dst_phid]['src'] = $src; } @@ -900,6 +904,30 @@ abstract class PhabricatorApplicationTransactionEditor foreach ($xactions as $xaction) { $type = $xaction->getTransactionType(); + // See T13082. When we're writing edges that imply corresponding inverse + // transactions, apply those inverse transactions now. We have to wait + // until the object we're editing (with this editor) has committed its + // transactions to do this. If we don't, the inverse editor may race, + // build a mail before we actually commit this object, and render "alice + // added an edge: Unknown Object". + + if ($type === PhabricatorTransactions::TYPE_EDGE) { + // Don't do anything if we're already an inverse edge editor. + if ($this->getIsInverseEdgeEditor()) { + continue; + } + + $edge_const = $xaction->getMetadataValue('edge:type'); + $edge_type = PhabricatorEdgeType::getByConstant($edge_const); + if ($edge_type->shouldWriteInverseTransactions()) { + $this->applyInverseEdgeTransactions( + $object, + $xaction, + $edge_type->getInverseEdgeConstant()); + } + continue; + } + $xtype = $this->getModularTransactionType($type); if (!$xtype) { continue; @@ -1504,6 +1532,12 @@ abstract class PhabricatorApplicationTransactionEditor $expect_value = !$xaction->shouldGenerateOldValue(); $has_value = $xaction->hasOldValue(); + // See T13082. In the narrow case of applying inverse edge edits, we + // expect the old value to be populated. + if ($this->getIsInverseEdgeEditor()) { + $expect_value = true; + } + if ($expect_value && !$has_value) { throw new PhabricatorApplicationTransactionStructureException( $xaction, @@ -3853,6 +3887,8 @@ abstract class PhabricatorApplicationTransactionEditor ->withPHIDs($all) ->execute(); + $object_phid = $object->getPHID(); + foreach ($nodes as $node) { if (!($node instanceof PhabricatorApplicationTransactionInterface)) { continue; @@ -3865,22 +3901,38 @@ abstract class PhabricatorApplicationTransactionEditor continue; } + $node_phid = $node->getPHID(); $editor = $node->getApplicationTransactionEditor(); $template = $node->getApplicationTransactionTemplate(); - if (isset($add[$node->getPHID()])) { - $edge_edit_type = '+'; + // See T13082. We have to build these transactions with synthetic values + // because we've already applied the actual edit to the edge database + // table. If we try to apply this transaction naturally, it will no-op + // itself because it doesn't have any effect. + + $edge_query = id(new PhabricatorEdgeQuery()) + ->withSourcePHIDs(array($node_phid)) + ->withEdgeTypes(array($inverse_type)); + + $edge_query->execute(); + + $edge_phids = $edge_query->getDestinationPHIDs(); + $edge_phids = array_fuse($edge_phids); + + $new_phids = $edge_phids; + $old_phids = $edge_phids; + + if (isset($add[$node_phid])) { + unset($old_phids[$object_phid]); } else { - $edge_edit_type = '-'; + $old_phids[$object_phid] = $object_phid; } $template ->setTransactionType($xaction->getTransactionType()) ->setMetadataValue('edge:type', $inverse_type) - ->setNewValue( - array( - $edge_edit_type => array($object->getPHID() => $object->getPHID()), - )); + ->setOldValue($old_phids) + ->setNewValue($new_phids); $editor ->setContinueOnNoEffect(true) @@ -4854,16 +4906,47 @@ abstract class PhabricatorApplicationTransactionEditor PhabricatorLiskDAO $object, array $xactions) { - $is_mfa = ($object instanceof PhabricatorEditEngineMFAInterface); - if (!$is_mfa) { + $has_engine = ($object instanceof PhabricatorEditEngineMFAInterface); + if ($has_engine) { + $engine = PhabricatorEditEngineMFAEngine::newEngineForObject($object) + ->setViewer($this->getActor()); + $require_mfa = $engine->shouldRequireMFA(); + $try_mfa = $engine->shouldTryMFA(); + } else { + $require_mfa = false; + $try_mfa = false; + } + + // If the user is mentioning an MFA object on another object or creating + // a relationship like "parent" or "child" to this object, we always + // allow the edit to move forward without requiring MFA. + if ($this->getIsInverseEdgeEditor()) { return $xactions; } - $engine = PhabricatorEditEngineMFAEngine::newEngineForObject($object) - ->setViewer($this->getActor()); - $require_mfa = $engine->shouldRequireMFA(); - if (!$require_mfa) { + // If the object hasn't already opted into MFA, see if any of the + // transactions want it. + if (!$try_mfa) { + foreach ($xactions as $xaction) { + $type = $xaction->getTransactionType(); + + $xtype = $this->getModularTransactionType($type); + if ($xtype) { + $xtype = clone $xtype; + $xtype->setStorage($xaction); + if ($xtype->shouldTryMFA($object, $xaction)) { + $try_mfa = true; + break; + } + } + } + } + + if ($try_mfa) { + $this->setShouldRequireMFA(true); + } + return $xactions; } @@ -4881,13 +4964,6 @@ abstract class PhabricatorApplicationTransactionEditor return $xactions; } - // If the user is mentioning an MFA object on another object or creating - // a relationship like "parent" or "child" to this object, we allow the - // edit to move forward without requiring MFA. - if ($this->getIsInverseEdgeEditor()) { - return $xactions; - } - $template = $object->getApplicationTransactionTemplate(); $mfa_xaction = id(clone $template) diff --git a/src/applications/transactions/storage/PhabricatorModularTransactionType.php b/src/applications/transactions/storage/PhabricatorModularTransactionType.php index 3d2efe0501..abe7a31025 100644 --- a/src/applications/transactions/storage/PhabricatorModularTransactionType.php +++ b/src/applications/transactions/storage/PhabricatorModularTransactionType.php @@ -425,4 +425,10 @@ abstract class PhabricatorModularTransactionType return PhabricatorPolicyCapability::CAN_EDIT; } + public function shouldTryMFA( + $object, + PhabricatorApplicationTransaction $xaction) { + return false; + } + } diff --git a/src/docs/user/configuration/configuring_accounts_and_registration.diviner b/src/docs/user/configuration/configuring_accounts_and_registration.diviner index 8a4c59b193..05d11b11f3 100644 --- a/src/docs/user/configuration/configuring_accounts_and_registration.diviner +++ b/src/docs/user/configuration/configuring_accounts_and_registration.diviner @@ -14,8 +14,6 @@ there is a "Username/Password" authentication provider available, which allows users to log in with a traditional username and password. Other providers support logging in with other credentials. For example: - - **Username/Password:** Users use a username and password to log in or - register. - **LDAP:** Users use LDAP credentials to log in or register. - **OAuth:** Users use accounts on a supported OAuth2 provider (like GitHub, Facebook, or Google) to log in or register. @@ -30,16 +28,16 @@ After you add a provider, you can link it to existing accounts (for example, associate an existing Phabricator account with a GitHub OAuth account) or users can use it to register new accounts (assuming you enable these options). -= Recovering Administrator Accounts = += Recovering Inaccessible Accounts = -If you accidentally lock yourself out of Phabricator, you can use the `bin/auth` -script to recover access to an administrator account. To recover access, run: +If you accidentally lock yourself out of Phabricator (for example, by disabling +all authentication providers), you can use the `bin/auth` +script to recover access to an account. To recover access, run: phabricator/ $ ./bin/auth recover -...where `` is the admin account username you want to recover access -to. This will give you a link which will log you in as the specified -administrative user. +...where `` is the account username you want to recover access +to. This will generate a link which will log you in as the specified user. = Managing Accounts with the Web Console = @@ -57,9 +55,9 @@ To use the CLI script, run: phabricator/ $ ./bin/accountadmin -Some options (like setting passwords and changing certain account flags) are -only available from the CLI. You can also use this script to make a user -an administrator (if you accidentally remove your admin flag) or create an +Some options (like changing certain account flags) are only available from +the CLI. You can also use this script to make a user +an administrator (if you accidentally remove your admin flag) or to create an administrative account. = Next Steps = diff --git a/src/docs/user/configuration/configuring_outbound_email.diviner b/src/docs/user/configuration/configuring_outbound_email.diviner index 7691a88d24..4d18ba0eb2 100644 --- a/src/docs/user/configuration/configuring_outbound_email.diviner +++ b/src/docs/user/configuration/configuring_outbound_email.diviner @@ -1,33 +1,45 @@ @title Configuring Outbound Email @group config -Instructions for configuring Phabricator to send mail. +Instructions for configuring Phabricator to send email and other types of +messages, like text messages. Overview ======== -Phabricator can send outbound email through several different mail services, +Phabricator sends outbound messages through "mailers". Most mailers send +email and most messages are email messages, but mailers may also send other +types of messages (like text messages). + +Phabricator can send outbound messages through multiple different mailers, including a local mailer or various third-party services. Options include: -| Send Mail With | Setup | Cost | Inbound | Notes | -|---------|-------|------|---------|-------| -| Postmark | Easy | Cheap | Yes | Recommended | -| Mailgun | Easy | Cheap | Yes | Recommended | -| Amazon SES | Easy | Cheap | No | Recommended | -| SendGrid | Medium | Cheap | Yes | Discouraged | -| External SMTP | Medium | Varies | No | Gmail, etc. | -| Local SMTP | Hard | Free | No | sendmail, postfix, etc | -| Custom | Hard | Free | No | Write a custom mailer for some other service. | -| Drop in a Hole | Easy | Free | No | Drops mail in a deep, dark hole. | +| Send Mail With | Setup | Cost | Inbound | Media | Notes | +|----------------|-------|------|---------|-------|-------| +| Postmark | Easy | Cheap | Yes | Email | Recommended | +| Mailgun | Easy | Cheap | Yes | Email | Recommended | +| Amazon SES | Easy | Cheap | No | Email | | +| SendGrid | Medium | Cheap | Yes | Email | | +| Twilio | Easy | Cheap | No | SMS | Recommended | +| Amazon SNS | Easy | Cheap | No | SMS | Recommended | +| External SMTP | Medium | Varies | No | Email | Gmail, etc. | +| Local SMTP | Hard | Free | No | Email | sendmail, postfix, etc | +| Custom | Hard | Free | No | All | Write a custom mailer. | +| Drop in a Hole | Easy | Free | No | All | Drops mail in a deep, dark hole. | See below for details on how to select and configure mail delivery for each mailer. -Overall, Postmark and Mailgun are much easier to set up, and using one of them -is recommended. Both will also let you set up inbound email easily. +For email, Postmark or Mailgun are recommended because they make it easy to +set up inbound and outbound mail and have good track records in our production +services. Other services will also generally work well, but they may be more +difficult to set up. -If you have some internal mail service you'd like to use you can also write a -custom mailer, but this requires digging into the code. +For SMS, Twilio or SNS are recommended. They're also your only upstream +options. + +If you have some internal mail or messaging service you'd like to use you can +also write a custom mailer, but this requires digging into the code. Phabricator sends mail in the background, so the daemons need to be running for it to be able to deliver mail. You should receive setup warnings if they are @@ -91,12 +103,14 @@ The supported keys for each mailer are: types. Normally, you do not need to configure this. See below for a list of media types. -The `type` field can be used to select these third-party mailers: +The `type` field can be used to select these mailer services: - `mailgun`: Use Mailgun. - `ses`: Use Amazon SES. - `sendgrid`: Use SendGrid. - `postmark`: Use Postmark. + - `twilio`: Use Twilio. + - `sns`: Use Amazon SNS. It also supports these local mailers: @@ -152,6 +166,12 @@ For alternatives and more information on configuration, see Mailer: Postmark ================ +| Media | Email +|---------| +| Inbound | Yes +|---------| + + Postmark is a third-party email delivery service. You can learn more at . @@ -182,8 +202,13 @@ documented at: Mailer: Mailgun =============== +| Media | Email +|---------| +| Inbound | Yes +|---------| + Mailgun is a third-party email delivery service. You can learn more at -. Mailgun is easy to configure and works well. +. Mailgun is easy to configure and works well. To use this mailer, set `type` to `mailgun`, then configure these `options`: @@ -194,8 +219,13 @@ To use this mailer, set `type` to `mailgun`, then configure these `options`: Mailer: Amazon SES ================== +| Media | Email +|---------| +| Inbound | No +|---------| + Amazon SES is Amazon's cloud email service. You can learn more at -. +. To use this mailer, set `type` to `ses`, then configure these `options`: @@ -208,12 +238,58 @@ which "From" address to use by setting `metamta.default-address` in your config, then follow the Amazon SES verification process to verify it. You won't be able to send email until you do this! +Mailer: Twilio +================== + +| Media | SMS +|---------| +| Inbound | No +|---------| + +Twilio is a third-party notification service. You can learn more at +. + + +To use this mailer, set `type` to `twilio`, then configure these options: + + - `account-sid`: Your Twilio Account SID. + - `auth-token`: Your Twilio Auth Token. + - `from-number`: Number to send text messages from, in E.164 format + (like `+15551237890`). + +Mailer: Amazon SNS +================== + +| Media | SMS +|---------| +| Inbound | No +|---------| + + +Amazon SNS is Amazon's cloud notification service. You can learn more at +. Note that this mailer is only able to send +SMS messages, not emails. + +To use this mailer, set `type` to `sns`, then configure these options: + + - `access-key`: Required string. Your Amazon SNS access key. + - `secret-key`: Required string. Your Amazon SNS secret key. + - `endpoint`: Required string. Your Amazon SNS endpoint. + - `region`: Required string. Your Amazon SNS region. + +You can find the correct `region` value for your endpoint in the SNS +documentation. Mailer: SendGrid ================ +| Media | Email +|---------| +| Inbound | Yes +|---------| + SendGrid is a third-party email delivery service. You can learn more at -. +. You can configure SendGrid in two ways: you can send via SMTP or via the REST API. To use SMTP, configure Phabricator to use an `smtp` mailer. @@ -230,10 +306,16 @@ including an "API User". Make sure you're configuring your "API Key". Mailer: Sendmail ================ +| Media | Email +|---------| +| Inbound | Requires Configuration +|---------| + + This requires a `sendmail` binary to be installed on the system. Most MTAs -(e.g., sendmail, qmail, postfix) should do this, but your machine may not have -one installed by default. For install instructions, consult the documentation -for your favorite MTA. +(e.g., sendmail, qmail, postfix) should install one for you, but your machine +may not have one installed by default. For install instructions, consult the +documentation for your favorite MTA. Since you'll be sending the mail yourself, you are subject to things like SPF rules, blackholes, and MTA configuration which are beyond the scope of this @@ -248,6 +330,11 @@ configure. Mailer: SMTP ============ +| Media | Email +|---------| +| Inbound | Requires Configuration +|---------| + You can use this adapter to send mail via an external SMTP server, like Gmail. To use this mailer, set `type` to `smtp`, then configure these `options`: @@ -263,7 +350,15 @@ To use this mailer, set `type` to `smtp`, then configure these `options`: Disable Mail ============ -To disable mail, just don't configure any mailers. +| Media | All +|---------| +| Inbound | No +|---------| + + +To disable mail, just don't configure any mailers. (You can safely ignore the +setup warning reminding you to set up mailers if you don't plan to configure +any.) Testing and Debugging Outbound Email @@ -278,6 +373,9 @@ particular: Run `bin/mail help ` for more help on using these commands. +By default, `bin/mail send-test` sends email messages, but you can use +the `--type` flag to send different types of messages. + You can monitor daemons using the Daemon Console (`/daemon/`, or click **Daemon Console** from the homepage). diff --git a/src/docs/user/userguide/multi_factor_auth.diviner b/src/docs/user/userguide/multi_factor_auth.diviner index c17c80d296..eca85d0f92 100644 --- a/src/docs/user/userguide/multi_factor_auth.diviner +++ b/src/docs/user/userguide/multi_factor_auth.diviner @@ -9,40 +9,39 @@ Overview Multi-factor authentication allows you to add additional credentials to your account to make it more secure. -This sounds complicated, but in most cases it just means that Phabricator will -make sure you have your mobile phone (by sending you a text message or having -you enter a code from a mobile application) before allowing you to log in or -take certain "high security" actions (like changing your password). +Once multi-factor authentication is configured on your account, you'll usually +use your mobile phone to provide an authorization code or an extra confirmation +when you try to log in to a new session or take certain actions (like changing +your password). Requiring you to prove you're really you by asking for something you know (your password) //and// something you have (your mobile phone) makes it much harder for attackers to access your account. The phone is an additional "factor" which protects your account from attacks. -Requiring re-authentication before performing high security actions further -limits the damage an attacker can do even if they manage to compromise a -login session. - How Multi-Factor Authentication Works ===================================== If you've configured multi-factor authentication and try to log in to your -account or take certain high security actions (like changing your password), +account or take certain sensitive actions (like changing your password), you'll be stopped and asked to enter additional credentials. -Usually, this means you'll receive an SMS with a security code on your phone, or -you'll open an app on your phone which will show you a security code. -In both cases, you'll enter the security code into Phabricator. +Usually, this means you'll receive an SMS with a authorization code on your +phone, or you'll open an app on your phone which will show you a authorization +code or ask you to confirm the action. If you're given a authorization code, +you'll enter it into Phabricator. If you're logging in, Phabricator will log you in after you enter the code. -If you're taking a high security action, Phabricator will put your account in -"high security" mode for a few minutes. In this mode, you can take high security -actions like changing passwords or SSH keys freely without entering any more -credentials. You can explicitly leave high security once you're done performing -account management, or your account will naturally return to normal security -after a short period of time. +If you're taking a sensitive action, Phabricator will sometimes put your +account in "high security" mode for a few minutes. In this mode, you can take +sensitive actions like changing passwords or SSH keys freely, without +entering any more credentials. + +You can explicitly leave high security once you're done performing account +management, or your account will naturally return to normal security after a +short period of time. While your account is in high security, you'll see a notification on screen with instructions for returning to normal security. @@ -52,8 +51,8 @@ Configuring Multi-Factor Authentication ======================================= To manage authentication factors for your account, go to -Settings > Multi-Factor Auth. You can use this control panel to add or remove -authentication factors from your account. +{nav Settings > Multi-Factor Auth}. You can use this control panel to add +or remove authentication factors from your account. You can also rename a factor by clicking the name. This can help you identify factors if you have several similar factors attached to your account. @@ -65,7 +64,7 @@ Factor: Mobile Phone App (TOTP) =============================== TOTP stands for "Time-based One-Time Password". This factor operates by having -you enter security codes from your mobile phone into Phabricator. The codes +you enter authorization codes from your mobile phone into Phabricator. The codes change every 30 seconds, so you will need to have your phone with you in order to enter them. @@ -79,23 +78,91 @@ application, so check any in-house documentation for details. In general, any TOTP application should work properly. After you've downloaded the application onto your phone, use the Phabricator -settings panel to add a factor to your account. You'll be prompted to enter a -master key into your phone, and then read a security code from your phone and -type it into Phabricator. +settings panel to add a factor to your account. You'll be prompted to scan a +QR code, and then read an authorization code from your phone and type it into +Phabricator. Later, when you need to authenticate, you'll follow this same process: launch -the application, read the security code, and type it into Phabricator. This will -prove you have your phone. +the application, read the authorization code, and type it into Phabricator. +This will prove you have your phone. Don't lose your phone! You'll need it to log into Phabricator in the future. -Recovering from Lost Factors -============================ +Factor: SMS +=========== -If you've lost a factor associated with your account (for example, your phone -has been lost or damaged), an administrator can strip the factor off your -account so that you can log in without it. +This factor operates by texting you a short authorization code when you try to +log in or perform a sensitive action. + +To use SMS, first add your phone number in {nav Settings > Contact Numbers}. +Once a primary contact number is configured on your account, you'll be able +to add an SMS factor. + +To enroll in SMS, you'll be sent a confirmation code to make sure your contact +number is correct and SMS is being delivered properly. Enter it when prompted. + +When you're asked to confirm your identity in the future, you'll be texted +an authorization code to enter into the prompt. + +(WARNING) SMS is a very weak factor and can be compromised or intercepted. For +details, see: . + + +Factor: Duo +=========== + +This factor supports integration with [[ https://duo.com/ | Duo Security ]], a +third-party authentication service popular with enterprises that have a lot of +policies to enforce. + +To use Duo, you'll install the Duo application on your phone. When you try +to take a sensitive action, you'll be asked to confirm it in the application. + + +Administration: Configuration +============================= + +New Phabricator installs start without any multi-factor providers enabled. +Users won't be able to add new factors until you set up multi-factor +authentication by configuring at least one provider. + +Configure new providers in {nav Auth > Multi-Factor}. + +Providers may be in these states: + + - **Active**: Users may add new factors. Users will be prompted to respond + to challenges from these providers when they take a sensitive action. + - **Deprecated**: Users may not add new factors, but they will still be + asked to respond to challenges from exising factors. + - **Disabled**: Users may not add new factors, and existing factors will + not be used. If MFA is required and a user only has disabled factors, + they will be forced to add a new factor. + +If you want to change factor types for your organization, the process will +normally look something like this: + + - Configure and test a new provider. + - Deprecate the old provider. + - Notify users that the old provider is deprecated and that they should move + to the new provider at their convenience, but before some upcoming + deadline. + - Once the deadline arrives, disable the old provider. + + +Administration: Requiring MFA +============================= + +As an administrator, you can require all users to add MFA to their accounts by +setting the `security.require-multi-factor-auth` option in Config. + + +Administration: Recovering from Lost Factors +============================================ + +If a user has lost a factor associated with their account (for example, their +phone has been lost or damaged), an administrator with host access can strip +the factor off their account so that they can log in without it. IMPORTANT: Before stripping factors from a user account, be absolutely certain that the user is who they claim to be! @@ -113,9 +180,10 @@ advance and require them to perform it. But no matter what you do, be certain the user (not an attacker //pretending// to be the user) is really the one making the request before stripping factors. -After verifying identity, administrators can strip authentication factors from -user accounts using the `bin/auth strip` command. For example, to strip all -factors from the account of a user who has lost their phone, run this command: +After verifying identity, administrators with host access can strip +authentication factors from user accounts using the `bin/auth strip` command. +For example, to strip all factors from the account of a user who has lost +their phone, run this command: ```lang=console # Strip all factors from a given user account. @@ -125,10 +193,30 @@ phabricator/ $ ./bin/auth strip --user --all-types You can run `bin/auth help strip` for more detail and all available flags and arguments. -This command can selectively strip types of factors. You can use -`bin/auth list-factors` for a list of available factor types. +This command can selectively strip factors by factor type. You can use +`bin/auth list-factors` to get a list of available factor types. ```lang=console # Show supported factor types. phabricator/ $ ./bin/auth list-factors ``` + +Once you've identified the factor types you want to strip, you can strip +matching factors by using the `--type` flag to specify one or more factor +types: + +```lang=console +# Strip all SMS and TOTP factors for a user. +phabricator/ $ ./bin/auth strip --user --type sms --type totp +``` + +The `bin/auth strip` command can also selectively strip factors for certain +providers. This is more granular than stripping all factors of a given type. +You can use `bin/auth list-mfa-providers` to get a list of providers. + +Once you have a provider PHID, use `--provider` to select factors to strip: + +```lang=console +# Strip all factors for a particular provider. +phabricator/ $ ./bin/auth strip --user --provider +``` diff --git a/src/infrastructure/cluster/config/PhabricatorClusterMailersConfigType.php b/src/infrastructure/cluster/config/PhabricatorClusterMailersConfigType.php index d23ee11d8b..e9b01f8cbf 100644 --- a/src/infrastructure/cluster/config/PhabricatorClusterMailersConfigType.php +++ b/src/infrastructure/cluster/config/PhabricatorClusterMailersConfigType.php @@ -45,6 +45,7 @@ final class PhabricatorClusterMailersConfigType 'options' => 'optional wild', 'inbound' => 'optional bool', 'outbound' => 'optional bool', + 'media' => 'optional list', )); } catch (Exception $ex) { throw $this->newException( diff --git a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php index 931840e8fb..9f7a69909a 100644 --- a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php +++ b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php @@ -2855,6 +2855,13 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery } } + // See T13240. If this query raises policy exceptions, don't filter objects + // in the MySQL layer. We want them to reach the application layer so we + // can reject them and raise an exception. + if ($this->shouldRaisePolicyExceptions()) { + return null; + } + $space_phids = array(); $include_null = false; diff --git a/src/view/phui/PHUIInfoView.php b/src/view/phui/PHUIInfoView.php index 69d0549299..af984f583e 100644 --- a/src/view/phui/PHUIInfoView.php +++ b/src/view/phui/PHUIInfoView.php @@ -8,6 +8,7 @@ final class PHUIInfoView extends AphrontTagView { const SEVERITY_NODATA = 'nodata'; const SEVERITY_SUCCESS = 'success'; const SEVERITY_PLAIN = 'plain'; + const SEVERITY_MFA = 'mfa'; private $title; private $errors = array(); @@ -73,20 +74,22 @@ final class PHUIInfoView extends AphrontTagView { switch ($this->getSeverity()) { case self::SEVERITY_ERROR: $icon = 'fa-exclamation-circle'; - break; + break; case self::SEVERITY_WARNING: $icon = 'fa-exclamation-triangle'; - break; + break; case self::SEVERITY_NOTICE: $icon = 'fa-info-circle'; - break; + break; case self::SEVERITY_PLAIN: case self::SEVERITY_NODATA: return null; - break; case self::SEVERITY_SUCCESS: $icon = 'fa-check-circle'; - break; + break; + case self::SEVERITY_MFA: + $icon = 'fa-lock'; + break; } $icon = id(new PHUIIconView()) diff --git a/webroot/rsrc/css/phui/object-item/phui-oi-big-ui.css b/webroot/rsrc/css/phui/object-item/phui-oi-big-ui.css index 6f60560a2e..a793c018c3 100644 --- a/webroot/rsrc/css/phui/object-item/phui-oi-big-ui.css +++ b/webroot/rsrc/css/phui/object-item/phui-oi-big-ui.css @@ -72,3 +72,8 @@ .device-desktop .phui-oi-linked-container a:hover { text-decoration: none; } + +/* Spacing for InfoView inside an object item list, like MFA setup. */ +.phui-oi .phui-info-view { + margin: 0 4px 4px; +} diff --git a/webroot/rsrc/css/phui/phui-form-view.css b/webroot/rsrc/css/phui/phui-form-view.css index cd44b1135e..3368bcaafb 100644 --- a/webroot/rsrc/css/phui/phui-form-view.css +++ b/webroot/rsrc/css/phui/phui-form-view.css @@ -574,3 +574,7 @@ properly, and submit values. */ color: {$darkgreytext}; vertical-align: middle; } + +.mfa-form-enroll-button { + text-align: center; +} diff --git a/webroot/rsrc/css/phui/phui-info-view.css b/webroot/rsrc/css/phui/phui-info-view.css index 55400956e4..b4fafc6e59 100644 --- a/webroot/rsrc/css/phui/phui-info-view.css +++ b/webroot/rsrc/css/phui/phui-info-view.css @@ -93,6 +93,15 @@ h1.phui-info-view-head { color: {$red}; } +.phui-info-severity-mfa { + border-color: {$blue}; + border-left-width: 6px; +} + +.phui-info-severity-mfa .phui-info-icon { + color: {$blue}; +} + .phui-info-severity-warning { border-color: {$yellow}; border-left-width: 6px;