Remove all "FileHasObject" edge reads and writes
[phabricator.git] / src / applications / auth / factor / PhabricatorAuthFactor.php
blobfefd9b5fd1fc6a955298d2117731559a838f250b
1 <?php
3 abstract class PhabricatorAuthFactor extends Phobject {
5 abstract public function getFactorName();
6 abstract public function getFactorShortName();
7 abstract public function getFactorKey();
8 abstract public function getFactorCreateHelp();
9 abstract public function getFactorDescription();
10 abstract public function processAddFactorForm(
11 PhabricatorAuthFactorProvider $provider,
12 AphrontFormView $form,
13 AphrontRequest $request,
14 PhabricatorUser $user);
16 abstract public function renderValidateFactorForm(
17 PhabricatorAuthFactorConfig $config,
18 AphrontFormView $form,
19 PhabricatorUser $viewer,
20 PhabricatorAuthFactorResult $validation_result);
22 public function getParameterName(
23 PhabricatorAuthFactorConfig $config,
24 $name) {
25 return 'authfactor.'.$config->getID().'.'.$name;
28 public static function getAllFactors() {
29 return id(new PhutilClassMapQuery())
30 ->setAncestorClass(__CLASS__)
31 ->setUniqueMethod('getFactorKey')
32 ->execute();
35 protected function newConfigForUser(PhabricatorUser $user) {
36 return id(new PhabricatorAuthFactorConfig())
37 ->setUserPHID($user->getPHID())
38 ->setFactorSecret('');
41 protected function newResult() {
42 return new PhabricatorAuthFactorResult();
45 public function newIconView() {
46 return id(new PHUIIconView())
47 ->setIcon('fa-mobile');
50 public function canCreateNewProvider() {
51 return true;
54 public function getProviderCreateDescription() {
55 return null;
58 public function canCreateNewConfiguration(
59 PhabricatorAuthFactorProvider $provider,
60 PhabricatorUser $user) {
61 return true;
64 public function getConfigurationCreateDescription(
65 PhabricatorAuthFactorProvider $provider,
66 PhabricatorUser $user) {
67 return null;
70 public function getConfigurationListDetails(
71 PhabricatorAuthFactorConfig $config,
72 PhabricatorAuthFactorProvider $provider,
73 PhabricatorUser $viewer) {
74 return null;
77 public function newEditEngineFields(
78 PhabricatorEditEngine $engine,
79 PhabricatorAuthFactorProvider $provider) {
80 return array();
83 public function newChallengeStatusView(
84 PhabricatorAuthFactorConfig $config,
85 PhabricatorAuthFactorProvider $provider,
86 PhabricatorUser $viewer,
87 PhabricatorAuthChallenge $challenge) {
88 return null;
91 /**
92 * Is this a factor which depends on the user's contact number?
94 * If a user has a "contact number" factor configured, they can not modify
95 * or switch their primary contact number.
97 * @return bool True if this factor should lock contact numbers.
99 public function isContactNumberFactor() {
100 return false;
103 abstract public function getEnrollDescription(
104 PhabricatorAuthFactorProvider $provider,
105 PhabricatorUser $user);
107 public function getEnrollButtonText(
108 PhabricatorAuthFactorProvider $provider,
109 PhabricatorUser $user) {
110 return pht('Continue');
113 public function getFactorOrder() {
114 return 1000;
117 final public function newSortVector() {
118 return id(new PhutilSortVector())
119 ->addInt($this->canCreateNewProvider() ? 0 : 1)
120 ->addInt($this->getFactorOrder())
121 ->addString($this->getFactorName());
124 protected function newChallenge(
125 PhabricatorAuthFactorConfig $config,
126 PhabricatorUser $viewer) {
128 $engine = $config->getSessionEngine();
130 return PhabricatorAuthChallenge::initializeNewChallenge()
131 ->setUserPHID($viewer->getPHID())
132 ->setSessionPHID($viewer->getSession()->getPHID())
133 ->setFactorPHID($config->getPHID())
134 ->setIsNewChallenge(true)
135 ->setWorkflowKey($engine->getWorkflowKey());
138 abstract public function getRequestHasChallengeResponse(
139 PhabricatorAuthFactorConfig $config,
140 AphrontRequest $response);
142 final public function getNewIssuedChallenges(
143 PhabricatorAuthFactorConfig $config,
144 PhabricatorUser $viewer,
145 array $challenges) {
146 assert_instances_of($challenges, 'PhabricatorAuthChallenge');
148 $now = PhabricatorTime::getNow();
150 // Factor implementations may need to perform writes in order to issue
151 // challenges, particularly push factors like SMS.
152 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
154 $new_challenges = $this->newIssuedChallenges(
155 $config,
156 $viewer,
157 $challenges);
159 if ($this->isAuthResult($new_challenges)) {
160 unset($unguarded);
161 return $new_challenges;
164 assert_instances_of($new_challenges, 'PhabricatorAuthChallenge');
166 foreach ($new_challenges as $new_challenge) {
167 $ttl = $new_challenge->getChallengeTTL();
168 if (!$ttl) {
169 throw new Exception(
170 pht('Newly issued MFA challenges must have a valid TTL!'));
173 if ($ttl < $now) {
174 throw new Exception(
175 pht(
176 'Newly issued MFA challenges must have a future TTL. This '.
177 'factor issued a bad TTL ("%s"). (Did you use a relative '.
178 'time instead of an epoch?)',
179 $ttl));
183 foreach ($new_challenges as $challenge) {
184 $challenge->save();
187 unset($unguarded);
189 return $new_challenges;
192 abstract protected function newIssuedChallenges(
193 PhabricatorAuthFactorConfig $config,
194 PhabricatorUser $viewer,
195 array $challenges);
197 final public function getResultFromIssuedChallenges(
198 PhabricatorAuthFactorConfig $config,
199 PhabricatorUser $viewer,
200 array $challenges) {
201 assert_instances_of($challenges, 'PhabricatorAuthChallenge');
203 $result = $this->newResultFromIssuedChallenges(
204 $config,
205 $viewer,
206 $challenges);
208 if ($result === null) {
209 return $result;
212 if (!$this->isAuthResult($result)) {
213 throw new Exception(
214 pht(
215 'Expected "newResultFromIssuedChallenges()" to return null or '.
216 'an object of class "%s"; got something else (in "%s").',
217 'PhabricatorAuthFactorResult',
218 get_class($this)));
221 return $result;
224 final public function getResultForPrompt(
225 PhabricatorAuthFactorConfig $config,
226 PhabricatorUser $viewer,
227 AphrontRequest $request,
228 array $challenges) {
229 assert_instances_of($challenges, 'PhabricatorAuthChallenge');
231 $result = $this->newResultForPrompt(
232 $config,
233 $viewer,
234 $request,
235 $challenges);
237 if (!$this->isAuthResult($result)) {
238 throw new Exception(
239 pht(
240 'Expected "newResultForPrompt()" to return an object of class "%s", '.
241 'but it returned something else ("%s"; in "%s").',
242 'PhabricatorAuthFactorResult',
243 phutil_describe_type($result),
244 get_class($this)));
247 return $result;
250 protected function newResultForPrompt(
251 PhabricatorAuthFactorConfig $config,
252 PhabricatorUser $viewer,
253 AphrontRequest $request,
254 array $challenges) {
255 return $this->newResult();
258 abstract protected function newResultFromIssuedChallenges(
259 PhabricatorAuthFactorConfig $config,
260 PhabricatorUser $viewer,
261 array $challenges);
263 final public function getResultFromChallengeResponse(
264 PhabricatorAuthFactorConfig $config,
265 PhabricatorUser $viewer,
266 AphrontRequest $request,
267 array $challenges) {
268 assert_instances_of($challenges, 'PhabricatorAuthChallenge');
270 $result = $this->newResultFromChallengeResponse(
271 $config,
272 $viewer,
273 $request,
274 $challenges);
276 if (!$this->isAuthResult($result)) {
277 throw new Exception(
278 pht(
279 'Expected "newResultFromChallengeResponse()" to return an object '.
280 'of class "%s"; got something else (in "%s").',
281 'PhabricatorAuthFactorResult',
282 get_class($this)));
285 return $result;
288 abstract protected function newResultFromChallengeResponse(
289 PhabricatorAuthFactorConfig $config,
290 PhabricatorUser $viewer,
291 AphrontRequest $request,
292 array $challenges);
294 final protected function newAutomaticControl(
295 PhabricatorAuthFactorResult $result) {
297 $is_error = $result->getIsError();
298 if ($is_error) {
299 return $this->newErrorControl($result);
302 $is_continue = $result->getIsContinue();
303 if ($is_continue) {
304 return $this->newContinueControl($result);
307 $is_answered = (bool)$result->getAnsweredChallenge();
308 if ($is_answered) {
309 return $this->newAnsweredControl($result);
312 $is_wait = $result->getIsWait();
313 if ($is_wait) {
314 return $this->newWaitControl($result);
317 return null;
320 private function newWaitControl(
321 PhabricatorAuthFactorResult $result) {
323 $error = $result->getErrorMessage();
325 $icon = $result->getIcon();
326 if (!$icon) {
327 $icon = id(new PHUIIconView())
328 ->setIcon('fa-clock-o', 'red');
331 return id(new PHUIFormTimerControl())
332 ->setIcon($icon)
333 ->appendChild($error)
334 ->setError(pht('Wait'));
337 private function newAnsweredControl(
338 PhabricatorAuthFactorResult $result) {
340 $icon = $result->getIcon();
341 if (!$icon) {
342 $icon = id(new PHUIIconView())
343 ->setIcon('fa-check-circle-o', 'green');
346 return id(new PHUIFormTimerControl())
347 ->setIcon($icon)
348 ->appendChild(
349 pht('You responded to this challenge correctly.'));
352 private function newErrorControl(
353 PhabricatorAuthFactorResult $result) {
355 $error = $result->getErrorMessage();
357 $icon = $result->getIcon();
358 if (!$icon) {
359 $icon = id(new PHUIIconView())
360 ->setIcon('fa-times', 'red');
363 return id(new PHUIFormTimerControl())
364 ->setIcon($icon)
365 ->appendChild($error)
366 ->setError(pht('Error'));
369 private function newContinueControl(
370 PhabricatorAuthFactorResult $result) {
372 $error = $result->getErrorMessage();
374 $icon = $result->getIcon();
375 if (!$icon) {
376 $icon = id(new PHUIIconView())
377 ->setIcon('fa-commenting', 'green');
380 $control = id(new PHUIFormTimerControl())
381 ->setIcon($icon)
382 ->appendChild($error);
384 $status_challenge = $result->getStatusChallenge();
385 if ($status_challenge) {
386 $id = $status_challenge->getID();
387 $uri = "/auth/mfa/challenge/status/{$id}/";
388 $control->setUpdateURI($uri);
391 return $control;
396 /* -( Synchronizing New Factors )------------------------------------------ */
399 final protected function loadMFASyncToken(
400 PhabricatorAuthFactorProvider $provider,
401 AphrontRequest $request,
402 AphrontFormView $form,
403 PhabricatorUser $user) {
405 // If the form included a synchronization key, load the corresponding
406 // token. The user must synchronize to a key we generated because this
407 // raises the barrier to theoretical attacks where an attacker might
408 // provide a known key for factors like TOTP.
410 // (We store and verify the hash of the key, not the key itself, to limit
411 // how useful the data in the table is to an attacker.)
413 $sync_type = PhabricatorAuthMFASyncTemporaryTokenType::TOKENTYPE;
414 $sync_token = null;
416 $sync_key = $request->getStr($this->getMFASyncTokenFormKey());
417 if (strlen($sync_key)) {
418 $sync_key_digest = PhabricatorHash::digestWithNamedKey(
419 $sync_key,
420 PhabricatorAuthMFASyncTemporaryTokenType::DIGEST_KEY);
422 $sync_token = id(new PhabricatorAuthTemporaryTokenQuery())
423 ->setViewer($user)
424 ->withTokenResources(array($user->getPHID()))
425 ->withTokenTypes(array($sync_type))
426 ->withExpired(false)
427 ->withTokenCodes(array($sync_key_digest))
428 ->executeOne();
431 if (!$sync_token) {
433 // Don't generate a new sync token if there are too many outstanding
434 // tokens already. This is mostly relevant for push factors like SMS,
435 // where generating a token has the side effect of sending a user a
436 // message.
438 $outstanding_limit = 10;
439 $outstanding_tokens = id(new PhabricatorAuthTemporaryTokenQuery())
440 ->setViewer($user)
441 ->withTokenResources(array($user->getPHID()))
442 ->withTokenTypes(array($sync_type))
443 ->withExpired(false)
444 ->execute();
445 if (count($outstanding_tokens) > $outstanding_limit) {
446 throw new Exception(
447 pht(
448 'Your account has too many outstanding, incomplete MFA '.
449 'synchronization attempts. Wait an hour and try again.'));
452 $now = PhabricatorTime::getNow();
454 $sync_key = Filesystem::readRandomCharacters(32);
455 $sync_key_digest = PhabricatorHash::digestWithNamedKey(
456 $sync_key,
457 PhabricatorAuthMFASyncTemporaryTokenType::DIGEST_KEY);
458 $sync_ttl = $this->getMFASyncTokenTTL();
460 $sync_token = id(new PhabricatorAuthTemporaryToken())
461 ->setIsNewTemporaryToken(true)
462 ->setTokenResource($user->getPHID())
463 ->setTokenType($sync_type)
464 ->setTokenCode($sync_key_digest)
465 ->setTokenExpires($now + $sync_ttl);
467 $properties = $this->newMFASyncTokenProperties(
468 $provider,
469 $user);
471 if ($this->isAuthResult($properties)) {
472 return $properties;
475 foreach ($properties as $key => $value) {
476 $sync_token->setTemporaryTokenProperty($key, $value);
479 $sync_token->save();
482 $form->addHiddenInput($this->getMFASyncTokenFormKey(), $sync_key);
484 return $sync_token;
487 protected function newMFASyncTokenProperties(
488 PhabricatorAuthFactorProvider $provider,
489 PhabricatorUser $user) {
490 return array();
493 private function getMFASyncTokenFormKey() {
494 return 'sync.key';
497 private function getMFASyncTokenTTL() {
498 return phutil_units('1 hour in seconds');
501 final protected function getChallengeForCurrentContext(
502 PhabricatorAuthFactorConfig $config,
503 PhabricatorUser $viewer,
504 array $challenges) {
506 $session_phid = $viewer->getSession()->getPHID();
507 $engine = $config->getSessionEngine();
508 $workflow_key = $engine->getWorkflowKey();
510 foreach ($challenges as $challenge) {
511 if ($challenge->getSessionPHID() !== $session_phid) {
512 continue;
515 if ($challenge->getWorkflowKey() !== $workflow_key) {
516 continue;
519 if ($challenge->getIsCompleted()) {
520 continue;
523 if ($challenge->getIsReusedChallenge()) {
524 continue;
527 return $challenge;
530 return null;
535 * @phutil-external-symbol class QRcode
537 final protected function newQRCode($uri) {
538 $root = dirname(phutil_get_library_root('phabricator'));
539 require_once $root.'/externals/phpqrcode/phpqrcode.php';
541 $lines = QRcode::text($uri);
543 $total_width = 240;
544 $cell_size = floor($total_width / count($lines));
546 $rows = array();
547 foreach ($lines as $line) {
548 $cells = array();
549 for ($ii = 0; $ii < strlen($line); $ii++) {
550 if ($line[$ii] == '1') {
551 $color = '#000';
552 } else {
553 $color = '#fff';
556 $cells[] = phutil_tag(
557 'td',
558 array(
559 'width' => $cell_size,
560 'height' => $cell_size,
561 'style' => 'background: '.$color,
563 '');
565 $rows[] = phutil_tag('tr', array(), $cells);
568 return phutil_tag(
569 'table',
570 array(
571 'style' => 'margin: 24px auto;',
573 $rows);
576 final protected function getInstallDisplayName() {
577 $uri = PhabricatorEnv::getURI('/');
578 $uri = new PhutilURI($uri);
579 return $uri->getDomain();
582 final protected function getChallengeResponseParameterName(
583 PhabricatorAuthFactorConfig $config) {
584 return $this->getParameterName($config, 'mfa.response');
587 final protected function getChallengeResponseFromRequest(
588 PhabricatorAuthFactorConfig $config,
589 AphrontRequest $request) {
591 $name = $this->getChallengeResponseParameterName($config);
593 $value = $request->getStr($name);
594 $value = (string)$value;
595 $value = trim($value);
597 return $value;
600 final protected function hasCSRF(PhabricatorAuthFactorConfig $config) {
601 $engine = $config->getSessionEngine();
602 $request = $engine->getRequest();
604 if (!$request->isHTTPPost()) {
605 return false;
608 return $request->validateCSRF();
611 final protected function loadConfigurationsForProvider(
612 PhabricatorAuthFactorProvider $provider,
613 PhabricatorUser $user) {
615 return id(new PhabricatorAuthFactorConfigQuery())
616 ->setViewer($user)
617 ->withUserPHIDs(array($user->getPHID()))
618 ->withFactorProviderPHIDs(array($provider->getPHID()))
619 ->execute();
622 final protected function isAuthResult($object) {
623 return ($object instanceof PhabricatorAuthFactorResult);