@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<?php
2
3abstract class PhabricatorAuthFactor extends Phobject {
4
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);
15
16 abstract public function renderValidateFactorForm(
17 PhabricatorAuthFactorConfig $config,
18 AphrontFormView $form,
19 PhabricatorUser $viewer,
20 PhabricatorAuthFactorResult $validation_result);
21
22 public function getParameterName(
23 PhabricatorAuthFactorConfig $config,
24 $name) {
25 return 'authfactor.'.$config->getID().'.'.$name;
26 }
27
28 public static function getAllFactors() {
29 return id(new PhutilClassMapQuery())
30 ->setAncestorClass(self::class)
31 ->setUniqueMethod('getFactorKey')
32 ->execute();
33 }
34
35 protected function newConfigForUser(PhabricatorUser $user) {
36 return id(new PhabricatorAuthFactorConfig())
37 ->setUserPHID($user->getPHID())
38 ->setFactorSecret('');
39 }
40
41 protected function newResult() {
42 return new PhabricatorAuthFactorResult();
43 }
44
45 public function newIconView() {
46 return id(new PHUIIconView())
47 ->setIcon('fa-mobile');
48 }
49
50 public function canCreateNewProvider() {
51 return true;
52 }
53
54 public function getProviderCreateDescription() {
55 return null;
56 }
57
58 public function canCreateNewConfiguration(
59 PhabricatorAuthFactorProvider $provider,
60 PhabricatorUser $user) {
61 return true;
62 }
63
64 public function getConfigurationCreateDescription(
65 PhabricatorAuthFactorProvider $provider,
66 PhabricatorUser $user) {
67 return null;
68 }
69
70 public function getConfigurationListDetails(
71 PhabricatorAuthFactorConfig $config,
72 PhabricatorAuthFactorProvider $provider,
73 PhabricatorUser $viewer) {
74 return null;
75 }
76
77 public function newEditEngineFields(
78 PhabricatorEditEngine $engine,
79 PhabricatorAuthFactorProvider $provider) {
80 return array();
81 }
82
83 public function newChallengeStatusView(
84 PhabricatorAuthFactorConfig $config,
85 PhabricatorAuthFactorProvider $provider,
86 PhabricatorUser $viewer,
87 PhabricatorAuthChallenge $challenge) {
88 return null;
89 }
90
91 /**
92 * Is this a factor which depends on the user's contact number?
93 *
94 * If a user has a "contact number" factor configured, they can not modify
95 * or switch their primary contact number.
96 *
97 * @return bool True if this factor should lock contact numbers.
98 */
99 public function isContactNumberFactor() {
100 return false;
101 }
102
103 abstract public function getEnrollDescription(
104 PhabricatorAuthFactorProvider $provider,
105 PhabricatorUser $user);
106
107 public function getEnrollButtonText(
108 PhabricatorAuthFactorProvider $provider,
109 PhabricatorUser $user) {
110 return pht('Continue');
111 }
112
113 public function getFactorOrder() {
114 return 1000;
115 }
116
117 final public function newSortVector() {
118 return id(new PhutilSortVector())
119 ->addInt($this->canCreateNewProvider() ? 0 : 1)
120 ->addInt($this->getFactorOrder())
121 ->addString($this->getFactorName());
122 }
123
124 protected function newChallenge(
125 PhabricatorAuthFactorConfig $config,
126 PhabricatorUser $viewer) {
127
128 $engine = $config->getSessionEngine();
129
130 return PhabricatorAuthChallenge::initializeNewChallenge()
131 ->setUserPHID($viewer->getPHID())
132 ->setSessionPHID($viewer->getSession()->getPHID())
133 ->setFactorPHID($config->getPHID())
134 ->setIsNewChallenge(true)
135 ->setWorkflowKey($engine->getWorkflowKey());
136 }
137
138 abstract public function getRequestHasChallengeResponse(
139 PhabricatorAuthFactorConfig $config,
140 AphrontRequest $response);
141
142 /**
143 * @param PhabricatorAuthFactorConfig $config
144 * @param PhabricatorUser $viewer
145 * @param array<PhabricatorAuthChallenge> $challenges
146 */
147 final public function getNewIssuedChallenges(
148 PhabricatorAuthFactorConfig $config,
149 PhabricatorUser $viewer,
150 array $challenges) {
151 assert_instances_of($challenges, PhabricatorAuthChallenge::class);
152
153 $now = PhabricatorTime::getNow();
154
155 // Factor implementations may need to perform writes in order to issue
156 // challenges, particularly push factors like SMS.
157 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
158
159 $new_challenges = $this->newIssuedChallenges(
160 $config,
161 $viewer,
162 $challenges);
163
164 if ($this->isAuthResult($new_challenges)) {
165 unset($unguarded);
166 return $new_challenges;
167 }
168
169 assert_instances_of($new_challenges, PhabricatorAuthChallenge::class);
170
171 foreach ($new_challenges as $new_challenge) {
172 $ttl = $new_challenge->getChallengeTTL();
173 if (!$ttl) {
174 throw new Exception(
175 pht('Newly issued MFA challenges must have a valid TTL!'));
176 }
177
178 if ($ttl < $now) {
179 throw new Exception(
180 pht(
181 'Newly issued MFA challenges must have a future TTL. This '.
182 'factor issued a bad TTL ("%s"). (Did you use a relative '.
183 'time instead of an epoch?)',
184 $ttl));
185 }
186 }
187
188 foreach ($new_challenges as $challenge) {
189 $challenge->save();
190 }
191
192 unset($unguarded);
193
194 return $new_challenges;
195 }
196
197 abstract protected function newIssuedChallenges(
198 PhabricatorAuthFactorConfig $config,
199 PhabricatorUser $viewer,
200 array $challenges);
201
202 /**
203 * @param PhabricatorAuthFactorConfig $config
204 * @param PhabricatorUser $viewer
205 * @param array<PhabricatorAuthChallenge> $challenges
206 */
207 final public function getResultFromIssuedChallenges(
208 PhabricatorAuthFactorConfig $config,
209 PhabricatorUser $viewer,
210 array $challenges) {
211 assert_instances_of($challenges, PhabricatorAuthChallenge::class);
212
213 $result = $this->newResultFromIssuedChallenges(
214 $config,
215 $viewer,
216 $challenges);
217
218 if ($result === null) {
219 return $result;
220 }
221
222 if (!$this->isAuthResult($result)) {
223 throw new Exception(
224 pht(
225 'Expected "newResultFromIssuedChallenges()" to return null or '.
226 'an object of class "%s"; got something else (in "%s").',
227 'PhabricatorAuthFactorResult',
228 get_class($this)));
229 }
230
231 return $result;
232 }
233
234 /**
235 * @param PhabricatorAuthFactorConfig $config
236 * @param PhabricatorUser $viewer
237 * @param AphrontRequest $request
238 * @param array<PhabricatorAuthChallenge> $challenges
239 */
240 final public function getResultForPrompt(
241 PhabricatorAuthFactorConfig $config,
242 PhabricatorUser $viewer,
243 AphrontRequest $request,
244 array $challenges) {
245 assert_instances_of($challenges, PhabricatorAuthChallenge::class);
246
247 $result = $this->newResultForPrompt(
248 $config,
249 $viewer,
250 $request,
251 $challenges);
252
253 if (!$this->isAuthResult($result)) {
254 throw new Exception(
255 pht(
256 'Expected "newResultForPrompt()" to return an object of class "%s", '.
257 'but it returned something else ("%s"; in "%s").',
258 'PhabricatorAuthFactorResult',
259 phutil_describe_type($result),
260 get_class($this)));
261 }
262
263 return $result;
264 }
265
266 protected function newResultForPrompt(
267 PhabricatorAuthFactorConfig $config,
268 PhabricatorUser $viewer,
269 AphrontRequest $request,
270 array $challenges) {
271 return $this->newResult();
272 }
273
274 abstract protected function newResultFromIssuedChallenges(
275 PhabricatorAuthFactorConfig $config,
276 PhabricatorUser $viewer,
277 array $challenges);
278
279 /**
280 * @param PhabricatorAuthFactorConfig $config
281 * @param PhabricatorUser $viewer
282 * @param AphrontRequest $request
283 * @param array<PhabricatorAuthChallenge> $challenges
284 */
285 final public function getResultFromChallengeResponse(
286 PhabricatorAuthFactorConfig $config,
287 PhabricatorUser $viewer,
288 AphrontRequest $request,
289 array $challenges) {
290 assert_instances_of($challenges, PhabricatorAuthChallenge::class);
291
292 $result = $this->newResultFromChallengeResponse(
293 $config,
294 $viewer,
295 $request,
296 $challenges);
297
298 if (!$this->isAuthResult($result)) {
299 throw new Exception(
300 pht(
301 'Expected "newResultFromChallengeResponse()" to return an object '.
302 'of class "%s"; got something else (in "%s").',
303 'PhabricatorAuthFactorResult',
304 get_class($this)));
305 }
306
307 return $result;
308 }
309
310 abstract protected function newResultFromChallengeResponse(
311 PhabricatorAuthFactorConfig $config,
312 PhabricatorUser $viewer,
313 AphrontRequest $request,
314 array $challenges);
315
316 final protected function newAutomaticControl(
317 PhabricatorAuthFactorResult $result) {
318
319 $is_error = $result->getIsError();
320 if ($is_error) {
321 return $this->newErrorControl($result);
322 }
323
324 $is_continue = $result->getIsContinue();
325 if ($is_continue) {
326 return $this->newContinueControl($result);
327 }
328
329 $is_answered = (bool)$result->getAnsweredChallenge();
330 if ($is_answered) {
331 return $this->newAnsweredControl($result);
332 }
333
334 $is_wait = $result->getIsWait();
335 if ($is_wait) {
336 return $this->newWaitControl($result);
337 }
338
339 return null;
340 }
341
342 private function newWaitControl(
343 PhabricatorAuthFactorResult $result) {
344
345 $error = $result->getErrorMessage();
346
347 $icon = $result->getIcon();
348 if (!$icon) {
349 $icon = id(new PHUIIconView())
350 ->setIcon('fa-clock-o', 'red');
351 }
352
353 return id(new PHUIFormTimerControl())
354 ->setIcon($icon)
355 ->appendChild($error)
356 ->setError(pht('Wait'));
357 }
358
359 private function newAnsweredControl(
360 PhabricatorAuthFactorResult $result) {
361
362 $icon = $result->getIcon();
363 if (!$icon) {
364 $icon = id(new PHUIIconView())
365 ->setIcon('fa-check-circle-o', 'green');
366 }
367
368 return id(new PHUIFormTimerControl())
369 ->setIcon($icon)
370 ->appendChild(
371 pht('You responded to this challenge correctly.'));
372 }
373
374 private function newErrorControl(
375 PhabricatorAuthFactorResult $result) {
376
377 $error = $result->getErrorMessage();
378
379 $icon = $result->getIcon();
380 if (!$icon) {
381 $icon = id(new PHUIIconView())
382 ->setIcon('fa-times', 'red');
383 }
384
385 return id(new PHUIFormTimerControl())
386 ->setIcon($icon)
387 ->appendChild($error)
388 ->setError(pht('Error'));
389 }
390
391 private function newContinueControl(
392 PhabricatorAuthFactorResult $result) {
393
394 $error = $result->getErrorMessage();
395
396 $icon = $result->getIcon();
397 if (!$icon) {
398 $icon = id(new PHUIIconView())
399 ->setIcon('fa-commenting', 'green');
400 }
401
402 $control = id(new PHUIFormTimerControl())
403 ->setIcon($icon)
404 ->appendChild($error);
405
406 $status_challenge = $result->getStatusChallenge();
407 if ($status_challenge) {
408 $id = $status_challenge->getID();
409 $uri = "/auth/mfa/challenge/status/{$id}/";
410 $control->setUpdateURI($uri);
411 }
412
413 return $control;
414 }
415
416
417
418/* -( Synchronizing New Factors )------------------------------------------ */
419
420
421 final protected function loadMFASyncToken(
422 PhabricatorAuthFactorProvider $provider,
423 AphrontRequest $request,
424 AphrontFormView $form,
425 PhabricatorUser $user) {
426
427 // If the form included a synchronization key, load the corresponding
428 // token. The user must synchronize to a key we generated because this
429 // raises the barrier to theoretical attacks where an attacker might
430 // provide a known key for factors like TOTP.
431
432 // (We store and verify the hash of the key, not the key itself, to limit
433 // how useful the data in the table is to an attacker.)
434
435 $sync_type = PhabricatorAuthMFASyncTemporaryTokenType::TOKENTYPE;
436 $sync_token = null;
437
438 $sync_key = $request->getStr($this->getMFASyncTokenFormKey(), '');
439 if ($sync_key !== '') {
440 $sync_key_digest = PhabricatorHash::digestWithNamedKey(
441 $sync_key,
442 PhabricatorAuthMFASyncTemporaryTokenType::DIGEST_KEY);
443
444 $sync_token = id(new PhabricatorAuthTemporaryTokenQuery())
445 ->setViewer($user)
446 ->withTokenResources(array($user->getPHID()))
447 ->withTokenTypes(array($sync_type))
448 ->withExpired(false)
449 ->withTokenCodes(array($sync_key_digest))
450 ->executeOne();
451 }
452
453 if (!$sync_token) {
454
455 // Don't generate a new sync token if there are too many outstanding
456 // tokens already. This is mostly relevant for push factors like SMS,
457 // where generating a token has the side effect of sending a user a
458 // message.
459
460 $outstanding_limit = 10;
461 $outstanding_tokens = id(new PhabricatorAuthTemporaryTokenQuery())
462 ->setViewer($user)
463 ->withTokenResources(array($user->getPHID()))
464 ->withTokenTypes(array($sync_type))
465 ->withExpired(false)
466 ->execute();
467 if (count($outstanding_tokens) > $outstanding_limit) {
468 throw new Exception(
469 pht(
470 'Your account has too many outstanding, incomplete MFA '.
471 'synchronization attempts. Wait an hour and try again.'));
472 }
473
474 $now = PhabricatorTime::getNow();
475
476 $sync_key = Filesystem::readRandomCharacters(32);
477 $sync_key_digest = PhabricatorHash::digestWithNamedKey(
478 $sync_key,
479 PhabricatorAuthMFASyncTemporaryTokenType::DIGEST_KEY);
480 $sync_ttl = $this->getMFASyncTokenTTL();
481
482 $sync_token = id(new PhabricatorAuthTemporaryToken())
483 ->setIsNewTemporaryToken(true)
484 ->setTokenResource($user->getPHID())
485 ->setTokenType($sync_type)
486 ->setTokenCode($sync_key_digest)
487 ->setTokenExpires($now + $sync_ttl);
488
489 $properties = $this->newMFASyncTokenProperties(
490 $provider,
491 $user);
492
493 if ($this->isAuthResult($properties)) {
494 return $properties;
495 }
496
497 foreach ($properties as $key => $value) {
498 $sync_token->setTemporaryTokenProperty($key, $value);
499 }
500
501 $sync_token->save();
502 }
503
504 $form->addHiddenInput($this->getMFASyncTokenFormKey(), $sync_key);
505
506 return $sync_token;
507 }
508
509 protected function newMFASyncTokenProperties(
510 PhabricatorAuthFactorProvider $provider,
511 PhabricatorUser $user) {
512 return array();
513 }
514
515 private function getMFASyncTokenFormKey() {
516 return 'sync.key';
517 }
518
519 private function getMFASyncTokenTTL() {
520 return phutil_units('1 hour in seconds');
521 }
522
523 final protected function getChallengeForCurrentContext(
524 PhabricatorAuthFactorConfig $config,
525 PhabricatorUser $viewer,
526 array $challenges) {
527
528 $session_phid = $viewer->getSession()->getPHID();
529 $engine = $config->getSessionEngine();
530 $workflow_key = $engine->getWorkflowKey();
531
532 foreach ($challenges as $challenge) {
533 if ($challenge->getSessionPHID() !== $session_phid) {
534 continue;
535 }
536
537 if ($challenge->getWorkflowKey() !== $workflow_key) {
538 continue;
539 }
540
541 if ($challenge->getIsCompleted()) {
542 continue;
543 }
544
545 if ($challenge->getIsReusedChallenge()) {
546 continue;
547 }
548
549 return $challenge;
550 }
551
552 return null;
553 }
554
555
556 /**
557 * @phutil-external-symbol class QRcode
558 */
559 final protected function newQRCode($uri) {
560 $root = dirname(phutil_get_library_root('phabricator'));
561 require_once $root.'/externals/phpqrcode/phpqrcode.php';
562
563 $lines = QRcode::text($uri);
564
565 $total_width = 240;
566 $cell_size = floor($total_width / count($lines));
567
568 $rows = array();
569 foreach ($lines as $line) {
570 $cells = array();
571 for ($ii = 0; $ii < strlen($line); $ii++) {
572 if ($line[$ii] == '1') {
573 $color = '#000';
574 } else {
575 $color = '#fff';
576 }
577
578 $cells[] = phutil_tag(
579 'td',
580 array(
581 'width' => $cell_size,
582 'height' => $cell_size,
583 'style' => 'background: '.$color,
584 ),
585 '');
586 }
587 $rows[] = phutil_tag('tr', array(), $cells);
588 }
589
590 return phutil_tag(
591 'table',
592 array(
593 'style' => 'margin: 24px auto;',
594 ),
595 $rows);
596 }
597
598 final protected function getInstallDisplayName() {
599 $uri = PhabricatorEnv::getURI('/');
600 $uri = new PhutilURI($uri);
601 return $uri->getDomain();
602 }
603
604 final protected function getChallengeResponseParameterName(
605 PhabricatorAuthFactorConfig $config) {
606 return $this->getParameterName($config, 'mfa.response');
607 }
608
609 final protected function getChallengeResponseFromRequest(
610 PhabricatorAuthFactorConfig $config,
611 AphrontRequest $request) {
612
613 $name = $this->getChallengeResponseParameterName($config);
614
615 $value = $request->getStr($name);
616 $value = (string)$value;
617 $value = trim($value);
618
619 return $value;
620 }
621
622 final protected function hasCSRF(PhabricatorAuthFactorConfig $config) {
623 $engine = $config->getSessionEngine();
624 $request = $engine->getRequest();
625
626 if (!$request->isHTTPPost()) {
627 return false;
628 }
629
630 return $request->validateCSRF();
631 }
632
633 final protected function loadConfigurationsForProvider(
634 PhabricatorAuthFactorProvider $provider,
635 PhabricatorUser $user) {
636
637 return id(new PhabricatorAuthFactorConfigQuery())
638 ->setViewer($user)
639 ->withUserPHIDs(array($user->getPHID()))
640 ->withFactorProviderPHIDs(array($provider->getPHID()))
641 ->execute();
642 }
643
644 final protected function isAuthResult($object) {
645 return ($object instanceof PhabricatorAuthFactorResult);
646 }
647
648}