Skip to content

Commit b8cbfda

Browse files
author
epriestley
committedDec 17, 2018
Track MFA "challenges" so we can bind challenges to sessions and support SMS and other push MFA
Summary: Ref T13222. See PHI873. Ref T9770. Currently, we support only TOTP MFA. For some MFA (SMS and "push-to-app"-style MFA) we may need to keep track of MFA details (e.g., the code we SMS'd you). There isn't much support for that yet. We also currently allow free reuse of TOTP responses across sessions and workflows. This hypothetically enables some "spyglass" attacks where you look at someone's phone and type the code in before they do. T9770 discusses this in more detail, but is focused on an attack window starting when the user submits the form. I claim the attack window opens when the TOTP code is shown on their phone, and the window between the code being shown and being submitted is //much// more interesting than the window after it is submitted. To address both of these cases, start tracking MFA "Challenges". These are basically a record that we asked you to give us MFA credentials. For TOTP, the challenge binds a particular timestep to a given session, so an attacker can't look at your phone and type the code into their browser before (or after) you do -- they have a different session. For now, this means that codes are reusable in the same session, but that will be refined in the future. For SMS / push, the "Challenge" would store the code we sent you so we could validate it. This is mostly a step on the way toward one-shot MFA, ad-hoc MFA in comment action stacks, and figuring out what's going on with Duo. Test Plan: - Passed MFA normally. - Passed MFA normally, simultaneously, as two different users. - With two different sessions for the same user: - Opened MFA in A, opened MFA in B. B got a "wait". - Submitted MFA in A. - Clicked "Wait" a bunch in B. - Submitted MFA in B when prompted. - Passed MFA normally, then passed MFA normally again with the same code in the same session. (This change does not prevent code reuse.) Reviewers: amckinley Reviewed By: amckinley Subscribers: PHID-OPKG-gm6ozazyms6q6i22gyam Maniphest Tasks: T13222, T9770 Differential Revision: https://secure.phabricator.com/D19886
1 parent c731508 commit b8cbfda

10 files changed

+572
-50
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
CREATE TABLE {$NAMESPACE}_auth.auth_challenge (
2+
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
3+
phid VARBINARY(64) NOT NULL,
4+
userPHID VARBINARY(64) NOT NULL,
5+
factorPHID VARBINARY(64) NOT NULL,
6+
sessionPHID VARBINARY(64) NOT NULL,
7+
challengeKey VARCHAR(255) NOT NULL COLLATE {$COLLATE_TEXT},
8+
challengeTTL INT UNSIGNED NOT NULL,
9+
properties LONGTEXT NOT NULL COLLATE {$COLLATE_TEXT},
10+
dateCreated INT UNSIGNED NOT NULL,
11+
dateModified INT UNSIGNED NOT NULL
12+
) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT};

‎src/__phutil_library_map__.php

+9
Original file line numberDiff line numberDiff line change
@@ -2187,6 +2187,9 @@
21872187
'PhabricatorAuthApplication' => 'applications/auth/application/PhabricatorAuthApplication.php',
21882188
'PhabricatorAuthAuthFactorPHIDType' => 'applications/auth/phid/PhabricatorAuthAuthFactorPHIDType.php',
21892189
'PhabricatorAuthAuthProviderPHIDType' => 'applications/auth/phid/PhabricatorAuthAuthProviderPHIDType.php',
2190+
'PhabricatorAuthChallenge' => 'applications/auth/storage/PhabricatorAuthChallenge.php',
2191+
'PhabricatorAuthChallengePHIDType' => 'applications/auth/phid/PhabricatorAuthChallengePHIDType.php',
2192+
'PhabricatorAuthChallengeQuery' => 'applications/auth/query/PhabricatorAuthChallengeQuery.php',
21902193
'PhabricatorAuthChangePasswordAction' => 'applications/auth/action/PhabricatorAuthChangePasswordAction.php',
21912194
'PhabricatorAuthConduitAPIMethod' => 'applications/auth/conduit/PhabricatorAuthConduitAPIMethod.php',
21922195
'PhabricatorAuthConduitTokenRevoker' => 'applications/auth/revoker/PhabricatorAuthConduitTokenRevoker.php',
@@ -7823,6 +7826,12 @@
78237826
'PhabricatorAuthApplication' => 'PhabricatorApplication',
78247827
'PhabricatorAuthAuthFactorPHIDType' => 'PhabricatorPHIDType',
78257828
'PhabricatorAuthAuthProviderPHIDType' => 'PhabricatorPHIDType',
7829+
'PhabricatorAuthChallenge' => array(
7830+
'PhabricatorAuthDAO',
7831+
'PhabricatorPolicyInterface',
7832+
),
7833+
'PhabricatorAuthChallengePHIDType' => 'PhabricatorPHIDType',
7834+
'PhabricatorAuthChallengeQuery' => 'PhabricatorCursorPagedPolicyAwareQuery',
78267835
'PhabricatorAuthChangePasswordAction' => 'PhabricatorSystemAction',
78277836
'PhabricatorAuthConduitAPIMethod' => 'ConduitAPIMethod',
78287837
'PhabricatorAuthConduitTokenRevoker' => 'PhabricatorAuthRevoker',

‎src/aphront/handler/PhabricatorHighSecurityRequestExceptionHandler.php

+17-2
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,28 @@ public function handleRequestThrowable(
2929
$throwable) {
3030

3131
$viewer = $this->getViewer($request);
32+
$results = $throwable->getFactorValidationResults();
3233

3334
$form = id(new PhabricatorAuthSessionEngine())->renderHighSecurityForm(
3435
$throwable->getFactors(),
35-
$throwable->getFactorValidationResults(),
36+
$results,
3637
$viewer,
3738
$request);
3839

40+
$is_wait = false;
41+
foreach ($results as $result) {
42+
if ($result->getIsWait()) {
43+
$is_wait = true;
44+
break;
45+
}
46+
}
47+
48+
if ($is_wait) {
49+
$submit = pht('Wait Patiently');
50+
} else {
51+
$submit = pht('Enter High Security');
52+
}
53+
3954
$dialog = id(new AphrontDialogView())
4055
->setUser($viewer)
4156
->setTitle(pht('Entering High Security'))
@@ -62,7 +77,7 @@ public function handleRequestThrowable(
6277
'actions, you should leave high security.'))
6378
->setSubmitURI($request->getPath())
6479
->addCancelButton($throwable->getCancelURI())
65-
->addSubmitButton(pht('Enter High Security'));
80+
->addSubmitButton($submit);
6681

6782
$request_parameters = $request->getPassthroughRequestParameters(
6883
$respect_quicksand = true);

‎src/applications/auth/engine/PhabricatorAuthSessionEngine.php

+77-15
Original file line numberDiff line numberDiff line change
@@ -480,7 +480,59 @@ private function newHighSecurityToken(
480480
new PhabricatorAuthTryFactorAction(),
481481
0);
482482

483+
$now = PhabricatorTime::getNow();
484+
485+
// We need to do challenge validation first, since this happens whether you
486+
// submitted responses or not. You can't get a "bad response" error before
487+
// you actually submit a response, but you can get a "wait, we can't
488+
// issue a challenge yet" response. Load all issued challenges which are
489+
// currently valid.
490+
$challenges = id(new PhabricatorAuthChallengeQuery())
491+
->setViewer($viewer)
492+
->withFactorPHIDs(mpull($factors, 'getPHID'))
493+
->withUserPHIDs(array($viewer->getPHID()))
494+
->withChallengeTTLBetween($now, null)
495+
->execute();
496+
$challenge_map = mgroup($challenges, 'getFactorPHID');
497+
483498
$validation_results = array();
499+
$ok = true;
500+
501+
// Validate each factor against issued challenges. For example, this
502+
// prevents you from receiving or responding to a TOTP challenge if another
503+
// challenge was recently issued to a different session.
504+
foreach ($factors as $factor) {
505+
$factor_phid = $factor->getPHID();
506+
$issued_challenges = idx($challenge_map, $factor_phid, array());
507+
$impl = $factor->requireImplementation();
508+
509+
$new_challenges = $impl->getNewIssuedChallenges(
510+
$factor,
511+
$viewer,
512+
$issued_challenges);
513+
514+
foreach ($new_challenges as $new_challenge) {
515+
$issued_challenges[] = $new_challenge;
516+
}
517+
$challenge_map[$factor_phid] = $issued_challenges;
518+
519+
if (!$issued_challenges) {
520+
continue;
521+
}
522+
523+
$result = $impl->getResultFromIssuedChallenges(
524+
$factor,
525+
$viewer,
526+
$issued_challenges);
527+
528+
if (!$result) {
529+
continue;
530+
}
531+
532+
$ok = false;
533+
$validation_results[$factor_phid] = $result;
534+
}
535+
484536
if ($request->isHTTPPost()) {
485537
$request->validateCSRF();
486538
if ($request->getExists(AphrontRequest::TYPE_HISEC)) {
@@ -491,30 +543,28 @@ private function newHighSecurityToken(
491543
new PhabricatorAuthTryFactorAction(),
492544
1);
493545

494-
$ok = true;
495546
foreach ($factors as $factor) {
496-
$id = $factor->getID();
547+
$factor_phid = $factor->getPHID();
548+
549+
// If we already have a validation result from previously issued
550+
// challenges, skip validating this factor.
551+
if (isset($validation_results[$factor_phid])) {
552+
continue;
553+
}
554+
497555
$impl = $factor->requireImplementation();
498556

499-
$validation_result = $impl->processValidateFactorForm(
557+
$validation_result = $impl->getResultFromChallengeResponse(
500558
$factor,
501559
$viewer,
502-
$request);
503-
504-
if (!($validation_result instanceof PhabricatorAuthFactorResult)) {
505-
throw new Exception(
506-
pht(
507-
'Expected "processValidateFactorForm()" to return an object '.
508-
'of class "%s"; got something else (from "%s").',
509-
'PhabricatorAuthFactorResult',
510-
get_class($impl)));
511-
}
560+
$request,
561+
$issued_challenges);
512562

513563
if (!$validation_result->getIsValid()) {
514564
$ok = false;
515565
}
516566

517-
$validation_results[$id] = $validation_result;
567+
$validation_results[$factor_phid] = $validation_result;
518568
}
519569

520570
if ($ok) {
@@ -566,6 +616,18 @@ private function newHighSecurityToken(
566616
return $token;
567617
}
568618

619+
// If we don't have a validation result for some factors yet, fill them
620+
// in with an empty result so form rendering doesn't have to care if the
621+
// results exist or not. This happens when you first load the form and have
622+
// not submitted any responses yet.
623+
foreach ($factors as $factor) {
624+
$factor_phid = $factor->getPHID();
625+
if (isset($validation_results[$factor_phid])) {
626+
continue;
627+
}
628+
$validation_results[$factor_phid] = new PhabricatorAuthFactorResult();
629+
}
630+
569631
throw id(new PhabricatorAuthHighSecurityRequiredException())
570632
->setCancelURI($cancel_uri)
571633
->setFactors($factors)
@@ -613,7 +675,7 @@ public function renderHighSecurityForm(
613675
->appendRemarkupInstructions('');
614676

615677
foreach ($factors as $factor) {
616-
$result = idx($validation_results, $factor->getID());
678+
$result = $validation_results[$factor->getPHID()];
617679

618680
$factor->requireImplementation()->renderValidateFactorForm(
619681
$factor,

‎src/applications/auth/factor/PhabricatorAuthFactor.php

+128-6
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,7 @@ abstract public function renderValidateFactorForm(
1414
PhabricatorAuthFactorConfig $config,
1515
AphrontFormView $form,
1616
PhabricatorUser $viewer,
17-
PhabricatorAuthFactorResult $validation_result = null);
18-
19-
abstract public function processValidateFactorForm(
20-
PhabricatorAuthFactorConfig $config,
21-
PhabricatorUser $viewer,
22-
AphrontRequest $request);
17+
PhabricatorAuthFactorResult $validation_result);
2318

2419
public function getParameterName(
2520
PhabricatorAuthFactorConfig $config,
@@ -40,4 +35,131 @@ protected function newConfigForUser(PhabricatorUser $user) {
4035
->setFactorKey($this->getFactorKey());
4136
}
4237

38+
protected function newResult() {
39+
return new PhabricatorAuthFactorResult();
40+
}
41+
42+
protected function newChallenge(
43+
PhabricatorAuthFactorConfig $config,
44+
PhabricatorUser $viewer) {
45+
46+
return id(new PhabricatorAuthChallenge())
47+
->setUserPHID($viewer->getPHID())
48+
->setSessionPHID($viewer->getSession()->getPHID())
49+
->setFactorPHID($config->getPHID());
50+
}
51+
52+
final public function getNewIssuedChallenges(
53+
PhabricatorAuthFactorConfig $config,
54+
PhabricatorUser $viewer,
55+
array $challenges) {
56+
assert_instances_of($challenges, 'PhabricatorAuthChallenge');
57+
58+
$now = PhabricatorTime::getNow();
59+
60+
$new_challenges = $this->newIssuedChallenges(
61+
$config,
62+
$viewer,
63+
$challenges);
64+
65+
assert_instances_of($new_challenges, 'PhabricatorAuthChallenge');
66+
67+
foreach ($new_challenges as $new_challenge) {
68+
$ttl = $new_challenge->getChallengeTTL();
69+
if (!$ttl) {
70+
throw new Exception(
71+
pht('Newly issued MFA challenges must have a valid TTL!'));
72+
}
73+
74+
if ($ttl < $now) {
75+
throw new Exception(
76+
pht(
77+
'Newly issued MFA challenges must have a future TTL. This '.
78+
'factor issued a bad TTL ("%s"). (Did you use a relative '.
79+
'time instead of an epoch?)',
80+
$ttl));
81+
}
82+
}
83+
84+
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
85+
foreach ($new_challenges as $challenge) {
86+
$challenge->save();
87+
}
88+
unset($unguarded);
89+
90+
return $new_challenges;
91+
}
92+
93+
abstract protected function newIssuedChallenges(
94+
PhabricatorAuthFactorConfig $config,
95+
PhabricatorUser $viewer,
96+
array $challenges);
97+
98+
final public function getResultFromIssuedChallenges(
99+
PhabricatorAuthFactorConfig $config,
100+
PhabricatorUser $viewer,
101+
array $challenges) {
102+
assert_instances_of($challenges, 'PhabricatorAuthChallenge');
103+
104+
$result = $this->newResultFromIssuedChallenges(
105+
$config,
106+
$viewer,
107+
$challenges);
108+
109+
if ($result === null) {
110+
return $result;
111+
}
112+
113+
if (!($result instanceof PhabricatorAuthFactorResult)) {
114+
throw new Exception(
115+
pht(
116+
'Expected "newResultFromIssuedChallenges()" to return null or '.
117+
'an object of class "%s"; got something else (in "%s").',
118+
'PhabricatorAuthFactorResult',
119+
get_class($this)));
120+
}
121+
122+
$result->setIssuedChallenges($challenges);
123+
124+
return $result;
125+
}
126+
127+
abstract protected function newResultFromIssuedChallenges(
128+
PhabricatorAuthFactorConfig $config,
129+
PhabricatorUser $viewer,
130+
array $challenges);
131+
132+
final public function getResultFromChallengeResponse(
133+
PhabricatorAuthFactorConfig $config,
134+
PhabricatorUser $viewer,
135+
AphrontRequest $request,
136+
array $challenges) {
137+
assert_instances_of($challenges, 'PhabricatorAuthChallenge');
138+
139+
$result = $this->newResultFromChallengeResponse(
140+
$config,
141+
$viewer,
142+
$request,
143+
$challenges);
144+
145+
if (!($result instanceof PhabricatorAuthFactorResult)) {
146+
throw new Exception(
147+
pht(
148+
'Expected "newResultFromChallengeResponse()" to return an object '.
149+
'of class "%s"; got something else (in "%s").',
150+
'PhabricatorAuthFactorResult',
151+
get_class($this)));
152+
}
153+
154+
$result->setIssuedChallenges($challenges);
155+
156+
return $result;
157+
}
158+
159+
abstract protected function newResultFromChallengeResponse(
160+
PhabricatorAuthFactorConfig $config,
161+
PhabricatorUser $viewer,
162+
AphrontRequest $request,
163+
array $challenges);
164+
43165
}

0 commit comments

Comments
 (0)
Failed to load comments.