@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement SMS MFA

Summary:
Depends on D20021. Ref T13222. This has a few rough edges, including:

- The challenges theselves are CSRF-able.
- You can go disable/edit your contact number after setting up SMS MFA and lock yourself out of your account.
- SMS doesn't require MFA so an attacker can just swap your number to their number.

...but mostly works.

Test Plan:
- Added SMS MFA to my account.
- Typed in the number I was texted.
- Typed in some other different numbers (didn't work).
- Cancelled/resumed the workflow, used SMS in conjunction with other factors, tried old codes, etc.

Reviewers: amckinley

Reviewed By: amckinley

Maniphest Tasks: T13222

Differential Revision: https://secure.phabricator.com/D20022

+414 -6
+2
src/__phutil_library_map__.php
··· 4297 4297 'PhabricatorResourceSite' => 'aphront/site/PhabricatorResourceSite.php', 4298 4298 'PhabricatorRobotsController' => 'applications/system/controller/PhabricatorRobotsController.php', 4299 4299 'PhabricatorS3FileStorageEngine' => 'applications/files/engine/PhabricatorS3FileStorageEngine.php', 4300 + 'PhabricatorSMSAuthFactor' => 'applications/auth/factor/PhabricatorSMSAuthFactor.php', 4300 4301 'PhabricatorSQLPatchList' => 'infrastructure/storage/patch/PhabricatorSQLPatchList.php', 4301 4302 'PhabricatorSSHKeyGenerator' => 'infrastructure/util/PhabricatorSSHKeyGenerator.php', 4302 4303 'PhabricatorSSHKeysSettingsPanel' => 'applications/settings/panel/PhabricatorSSHKeysSettingsPanel.php', ··· 10393 10394 'PhabricatorResourceSite' => 'PhabricatorSite', 10394 10395 'PhabricatorRobotsController' => 'PhabricatorController', 10395 10396 'PhabricatorS3FileStorageEngine' => 'PhabricatorFileStorageEngine', 10397 + 'PhabricatorSMSAuthFactor' => 'PhabricatorAuthFactor', 10396 10398 'PhabricatorSQLPatchList' => 'Phobject', 10397 10399 'PhabricatorSSHKeyGenerator' => 'Phobject', 10398 10400 'PhabricatorSSHKeysSettingsPanel' => 'PhabricatorSettingsPanel',
+42 -5
src/applications/auth/factor/PhabricatorAuthFactor.php
··· 33 33 34 34 protected function newConfigForUser(PhabricatorUser $user) { 35 35 return id(new PhabricatorAuthFactorConfig()) 36 - ->setUserPHID($user->getPHID()); 36 + ->setUserPHID($user->getPHID()) 37 + ->setFactorSecret(''); 37 38 } 38 39 39 40 protected function newResult() { ··· 107 108 108 109 $now = PhabricatorTime::getNow(); 109 110 111 + // Factor implementations may need to perform writes in order to issue 112 + // challenges, particularly push factors like SMS. 113 + $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 114 + 110 115 $new_challenges = $this->newIssuedChallenges( 111 116 $config, 112 117 $viewer, ··· 131 136 } 132 137 } 133 138 134 - $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 135 - foreach ($new_challenges as $challenge) { 136 - $challenge->save(); 137 - } 139 + foreach ($new_challenges as $challenge) { 140 + $challenge->save(); 141 + } 142 + 138 143 unset($unguarded); 139 144 140 145 return $new_challenges; ··· 349 354 350 355 private function getMFASyncTokenTTL() { 351 356 return phutil_units('1 hour in seconds'); 357 + } 358 + 359 + final protected function getChallengeForCurrentContext( 360 + PhabricatorAuthFactorConfig $config, 361 + PhabricatorUser $viewer, 362 + array $challenges) { 363 + 364 + $session_phid = $viewer->getSession()->getPHID(); 365 + $engine = $config->getSessionEngine(); 366 + $workflow_key = $engine->getWorkflowKey(); 367 + 368 + foreach ($challenges as $challenge) { 369 + if ($challenge->getSessionPHID() !== $session_phid) { 370 + continue; 371 + } 372 + 373 + if ($challenge->getWorkflowKey() !== $workflow_key) { 374 + continue; 375 + } 376 + 377 + if ($challenge->getIsCompleted()) { 378 + continue; 379 + } 380 + 381 + if ($challenge->getIsReusedChallenge()) { 382 + continue; 383 + } 384 + 385 + return $challenge; 386 + } 387 + 388 + return null; 352 389 } 353 390 354 391 }
+367
src/applications/auth/factor/PhabricatorSMSAuthFactor.php
··· 1 + <?php 2 + 3 + final class PhabricatorSMSAuthFactor 4 + extends PhabricatorAuthFactor { 5 + 6 + public function getFactorKey() { 7 + return 'sms'; 8 + } 9 + 10 + public function getFactorName() { 11 + return pht('SMS'); 12 + } 13 + 14 + public function getFactorCreateHelp() { 15 + return pht( 16 + 'Allow users to receive a code via SMS.'); 17 + } 18 + 19 + public function getFactorDescription() { 20 + return pht( 21 + 'When you need to authenticate, a text message with a code will '. 22 + 'be sent to your phone.'); 23 + } 24 + 25 + public function getFactorOrder() { 26 + // Sort this factor toward the end of the list because SMS is relatively 27 + // weak. 28 + return 2000; 29 + } 30 + 31 + public function canCreateNewProvider() { 32 + return $this->isSMSMailerConfigured(); 33 + } 34 + 35 + public function getProviderCreateDescription() { 36 + $messages = array(); 37 + 38 + if (!$this->isSMSMailerConfigured()) { 39 + $messages[] = id(new PHUIInfoView()) 40 + ->setErrors( 41 + array( 42 + pht( 43 + 'You have not configured an outbound SMS mailer. You must '. 44 + 'configure one before you can set up SMS. See: %s', 45 + phutil_tag( 46 + 'a', 47 + array( 48 + 'href' => '/config/edit/cluster.mailers/', 49 + ), 50 + 'cluster.mailers')), 51 + )); 52 + } 53 + 54 + $messages[] = id(new PHUIInfoView()) 55 + ->setSeverity(PHUIInfoView::SEVERITY_WARNING) 56 + ->setErrors( 57 + array( 58 + pht( 59 + 'SMS is weak, and relatively easy for attackers to compromise. '. 60 + 'Strongly consider using a different MFA provider.'), 61 + )); 62 + 63 + return $messages; 64 + } 65 + 66 + public function canCreateNewConfiguration(PhabricatorUser $user) { 67 + if (!$this->loadUserContactNumber($user)) { 68 + return false; 69 + } 70 + 71 + return true; 72 + } 73 + 74 + public function getConfigurationCreateDescription(PhabricatorUser $user) { 75 + 76 + $messages = array(); 77 + 78 + if (!$this->loadUserContactNumber($user)) { 79 + $messages[] = id(new PHUIInfoView()) 80 + ->setSeverity(PHUIInfoView::SEVERITY_WARNING) 81 + ->setErrors( 82 + array( 83 + pht( 84 + 'You have not configured a primary contact number. Configure '. 85 + 'a contact number before adding SMS as an authentication '. 86 + 'factor.'), 87 + )); 88 + } 89 + 90 + return $messages; 91 + } 92 + 93 + public function getEnrollDescription( 94 + PhabricatorAuthFactorProvider $provider, 95 + PhabricatorUser $user) { 96 + return pht( 97 + 'To verify your phone as an authentication factor, a text message with '. 98 + 'a secret code will be sent to the phone number you have listed as '. 99 + 'your primary contact number.'); 100 + } 101 + 102 + public function getEnrollButtonText( 103 + PhabricatorAuthFactorProvider $provider, 104 + PhabricatorUser $user) { 105 + $contact_number = $this->loadUserContactNumber($user); 106 + 107 + return pht('Send SMS: %s', $contact_number->getDisplayName()); 108 + } 109 + 110 + public function processAddFactorForm( 111 + PhabricatorAuthFactorProvider $provider, 112 + AphrontFormView $form, 113 + AphrontRequest $request, 114 + PhabricatorUser $user) { 115 + 116 + $token = $this->loadMFASyncToken($request, $form, $user); 117 + $code = $request->getStr('sms.code'); 118 + 119 + $e_code = true; 120 + if (!$token->getIsNewTemporaryToken()) { 121 + $expect_code = $token->getTemporaryTokenProperty('code'); 122 + 123 + $okay = phutil_hashes_are_identical( 124 + $this->normalizeSMSCode($code), 125 + $this->normalizeSMSCode($expect_code)); 126 + 127 + if ($okay) { 128 + $config = $this->newConfigForUser($user) 129 + ->setFactorName(pht('SMS')); 130 + 131 + return $config; 132 + } else { 133 + if (!strlen($code)) { 134 + $e_code = pht('Required'); 135 + } else { 136 + $e_code = pht('Invalid'); 137 + } 138 + } 139 + } 140 + 141 + $form->appendRemarkupInstructions( 142 + pht( 143 + 'Enter the code from the text message which was sent to your '. 144 + 'primary contact number.')); 145 + 146 + $form->appendChild( 147 + id(new PHUIFormNumberControl()) 148 + ->setLabel(pht('SMS Code')) 149 + ->setName('sms.code') 150 + ->setValue($code) 151 + ->setError($e_code)); 152 + } 153 + 154 + protected function newIssuedChallenges( 155 + PhabricatorAuthFactorConfig $config, 156 + PhabricatorUser $viewer, 157 + array $challenges) { 158 + 159 + // If we already issued a valid challenge for this workflow and session, 160 + // don't issue a new one. 161 + 162 + $challenge = $this->getChallengeForCurrentContext( 163 + $config, 164 + $viewer, 165 + $challenges); 166 + if ($challenge) { 167 + return array(); 168 + } 169 + 170 + // Otherwise, issue a new challenge. 171 + 172 + $challenge_code = $this->newSMSChallengeCode(); 173 + $envelope = new PhutilOpaqueEnvelope($challenge_code); 174 + $this->sendSMSCodeToUser($envelope, $viewer); 175 + 176 + $ttl_seconds = phutil_units('15 minutes in seconds'); 177 + 178 + return array( 179 + $this->newChallenge($config, $viewer) 180 + ->setChallengeKey($challenge_code) 181 + ->setChallengeTTL(PhabricatorTime::getNow() + $ttl_seconds), 182 + ); 183 + } 184 + 185 + protected function newResultFromIssuedChallenges( 186 + PhabricatorAuthFactorConfig $config, 187 + PhabricatorUser $viewer, 188 + array $challenges) { 189 + 190 + $challenge = $this->getChallengeForCurrentContext( 191 + $config, 192 + $viewer, 193 + $challenges); 194 + 195 + if ($challenge->getIsAnsweredChallenge()) { 196 + return $this->newResult() 197 + ->setAnsweredChallenge($challenge); 198 + } 199 + 200 + return null; 201 + } 202 + 203 + public function renderValidateFactorForm( 204 + PhabricatorAuthFactorConfig $config, 205 + AphrontFormView $form, 206 + PhabricatorUser $viewer, 207 + PhabricatorAuthFactorResult $result) { 208 + 209 + $control = $this->newAutomaticControl($result); 210 + if (!$control) { 211 + $value = $result->getValue(); 212 + $error = $result->getErrorMessage(); 213 + $name = $this->getChallengeResponseParameterName($config); 214 + 215 + $control = id(new PHUIFormNumberControl()) 216 + ->setName($name) 217 + ->setDisableAutocomplete(true) 218 + ->setValue($value) 219 + ->setError($error); 220 + } 221 + 222 + $control 223 + ->setLabel(pht('SMS Code')) 224 + ->setCaption(pht('Factor Name: %s', $config->getFactorName())); 225 + 226 + $form->appendChild($control); 227 + } 228 + 229 + public function getRequestHasChallengeResponse( 230 + PhabricatorAuthFactorConfig $config, 231 + AphrontRequest $request) { 232 + $value = $this->getChallengeResponseFromRequest($config, $request); 233 + return (bool)strlen($value); 234 + } 235 + 236 + protected function newResultFromChallengeResponse( 237 + PhabricatorAuthFactorConfig $config, 238 + PhabricatorUser $viewer, 239 + AphrontRequest $request, 240 + array $challenges) { 241 + 242 + $challenge = $this->getChallengeForCurrentContext( 243 + $config, 244 + $viewer, 245 + $challenges); 246 + 247 + $code = $this->getChallengeResponseFromRequest( 248 + $config, 249 + $request); 250 + 251 + $result = $this->newResult() 252 + ->setValue($code); 253 + 254 + if ($challenge->getIsAnsweredChallenge()) { 255 + return $result->setAnsweredChallenge($challenge); 256 + } 257 + 258 + if (phutil_hashes_are_identical($code, $challenge->getChallengeKey())) { 259 + $ttl = PhabricatorTime::getNow() + phutil_units('15 minutes in seconds'); 260 + 261 + $challenge 262 + ->markChallengeAsAnswered($ttl); 263 + 264 + return $result->setAnsweredChallenge($challenge); 265 + } 266 + 267 + if (strlen($code)) { 268 + $error_message = pht('Invalid'); 269 + } else { 270 + $error_message = pht('Required'); 271 + } 272 + 273 + $result->setErrorMessage($error_message); 274 + 275 + return $result; 276 + } 277 + 278 + private function newSMSChallengeCode() { 279 + $value = Filesystem::readRandomInteger(0, 99999999); 280 + $value = sprintf('%08d', $value); 281 + return $value; 282 + } 283 + 284 + private function isSMSMailerConfigured() { 285 + $mailers = PhabricatorMetaMTAMail::newMailers( 286 + array( 287 + 'outbound' => true, 288 + 'media' => array( 289 + PhabricatorMailSMSMessage::MESSAGETYPE, 290 + ), 291 + )); 292 + 293 + return (bool)$mailers; 294 + } 295 + 296 + private function loadUserContactNumber(PhabricatorUser $user) { 297 + $contact_numbers = id(new PhabricatorAuthContactNumberQuery()) 298 + ->setViewer($user) 299 + ->withObjectPHIDs(array($user->getPHID())) 300 + ->withStatuses( 301 + array( 302 + PhabricatorAuthContactNumber::STATUS_ACTIVE, 303 + )) 304 + ->withIsPrimary(true) 305 + ->execute(); 306 + 307 + if (count($contact_numbers) !== 1) { 308 + return null; 309 + } 310 + 311 + return head($contact_numbers); 312 + } 313 + 314 + protected function newMFASyncTokenProperties(PhabricatorUser $user) { 315 + $sms_code = $this->newSMSChallengeCode(); 316 + 317 + $envelope = new PhutilOpaqueEnvelope($sms_code); 318 + $this->sendSMSCodeToUser($envelope, $user); 319 + 320 + return array( 321 + 'code' => $sms_code, 322 + ); 323 + } 324 + 325 + private function sendSMSCodeToUser( 326 + PhutilOpaqueEnvelope $envelope, 327 + PhabricatorUser $user) { 328 + 329 + $uri = PhabricatorEnv::getURI('/'); 330 + $uri = new PhutilURI($uri); 331 + 332 + return id(new PhabricatorMetaMTAMail()) 333 + ->setMessageType(PhabricatorMailSMSMessage::MESSAGETYPE) 334 + ->addTos(array($user->getPHID())) 335 + ->setForceDelivery(true) 336 + ->setSensitiveContent(true) 337 + ->setBody( 338 + pht( 339 + 'Phabricator (%s) MFA Code: %s', 340 + $uri->getDomain(), 341 + $envelope->openEnvelope())) 342 + ->save(); 343 + } 344 + 345 + private function normalizeSMSCode($code) { 346 + return trim($code); 347 + } 348 + 349 + private function getChallengeResponseParameterName( 350 + PhabricatorAuthFactorConfig $config) { 351 + return $this->getParameterName($config, 'sms.code'); 352 + } 353 + 354 + private function getChallengeResponseFromRequest( 355 + PhabricatorAuthFactorConfig $config, 356 + AphrontRequest $request) { 357 + 358 + $name = $this->getChallengeResponseParameterName($config); 359 + 360 + $value = $request->getStr($name); 361 + $value = (string)$value; 362 + $value = trim($value); 363 + 364 + return $value; 365 + } 366 + 367 + }
+3 -1
src/applications/auth/phid/PhabricatorAuthMessagePHIDType.php
··· 19 19 protected function buildQueryForObjects( 20 20 PhabricatorObjectQuery $query, 21 21 array $phids) { 22 - return new PhabricatorAuthMessageQuery(); 22 + 23 + return id(new PhabricatorAuthMessageQuery()) 24 + ->withPHIDs($phids); 23 25 } 24 26 25 27 public function loadHandles(