1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-09-19 16:58:48 +02:00

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
This commit is contained in:
epriestley 2018-12-13 10:13:56 -08:00
parent c731508d74
commit b8cbfda07c
10 changed files with 573 additions and 51 deletions

View file

@ -0,0 +1,12 @@
CREATE TABLE {$NAMESPACE}_auth.auth_challenge (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
phid VARBINARY(64) NOT NULL,
userPHID VARBINARY(64) NOT NULL,
factorPHID VARBINARY(64) NOT NULL,
sessionPHID VARBINARY(64) NOT NULL,
challengeKey VARCHAR(255) NOT NULL COLLATE {$COLLATE_TEXT},
challengeTTL INT UNSIGNED NOT NULL,
properties LONGTEXT NOT NULL COLLATE {$COLLATE_TEXT},
dateCreated INT UNSIGNED NOT NULL,
dateModified INT UNSIGNED NOT NULL
) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT};

View file

@ -2187,6 +2187,9 @@ phutil_register_library_map(array(
'PhabricatorAuthApplication' => 'applications/auth/application/PhabricatorAuthApplication.php',
'PhabricatorAuthAuthFactorPHIDType' => 'applications/auth/phid/PhabricatorAuthAuthFactorPHIDType.php',
'PhabricatorAuthAuthProviderPHIDType' => 'applications/auth/phid/PhabricatorAuthAuthProviderPHIDType.php',
'PhabricatorAuthChallenge' => 'applications/auth/storage/PhabricatorAuthChallenge.php',
'PhabricatorAuthChallengePHIDType' => 'applications/auth/phid/PhabricatorAuthChallengePHIDType.php',
'PhabricatorAuthChallengeQuery' => 'applications/auth/query/PhabricatorAuthChallengeQuery.php',
'PhabricatorAuthChangePasswordAction' => 'applications/auth/action/PhabricatorAuthChangePasswordAction.php',
'PhabricatorAuthConduitAPIMethod' => 'applications/auth/conduit/PhabricatorAuthConduitAPIMethod.php',
'PhabricatorAuthConduitTokenRevoker' => 'applications/auth/revoker/PhabricatorAuthConduitTokenRevoker.php',
@ -7823,6 +7826,12 @@ phutil_register_library_map(array(
'PhabricatorAuthApplication' => 'PhabricatorApplication',
'PhabricatorAuthAuthFactorPHIDType' => 'PhabricatorPHIDType',
'PhabricatorAuthAuthProviderPHIDType' => 'PhabricatorPHIDType',
'PhabricatorAuthChallenge' => array(
'PhabricatorAuthDAO',
'PhabricatorPolicyInterface',
),
'PhabricatorAuthChallengePHIDType' => 'PhabricatorPHIDType',
'PhabricatorAuthChallengeQuery' => 'PhabricatorCursorPagedPolicyAwareQuery',
'PhabricatorAuthChangePasswordAction' => 'PhabricatorSystemAction',
'PhabricatorAuthConduitAPIMethod' => 'ConduitAPIMethod',
'PhabricatorAuthConduitTokenRevoker' => 'PhabricatorAuthRevoker',

View file

@ -29,13 +29,28 @@ final class PhabricatorHighSecurityRequestExceptionHandler
$throwable) {
$viewer = $this->getViewer($request);
$results = $throwable->getFactorValidationResults();
$form = id(new PhabricatorAuthSessionEngine())->renderHighSecurityForm(
$throwable->getFactors(),
$throwable->getFactorValidationResults(),
$results,
$viewer,
$request);
$is_wait = false;
foreach ($results as $result) {
if ($result->getIsWait()) {
$is_wait = true;
break;
}
}
if ($is_wait) {
$submit = pht('Wait Patiently');
} else {
$submit = pht('Enter High Security');
}
$dialog = id(new AphrontDialogView())
->setUser($viewer)
->setTitle(pht('Entering High Security'))
@ -62,7 +77,7 @@ final class PhabricatorHighSecurityRequestExceptionHandler
'actions, you should leave high security.'))
->setSubmitURI($request->getPath())
->addCancelButton($throwable->getCancelURI())
->addSubmitButton(pht('Enter High Security'));
->addSubmitButton($submit);
$request_parameters = $request->getPassthroughRequestParameters(
$respect_quicksand = true);

View file

@ -480,7 +480,59 @@ final class PhabricatorAuthSessionEngine extends Phobject {
new PhabricatorAuthTryFactorAction(),
0);
$now = PhabricatorTime::getNow();
// We need to do challenge validation first, since this happens whether you
// submitted responses or not. You can't get a "bad response" error before
// you actually submit a response, but you can get a "wait, we can't
// issue a challenge yet" response. Load all issued challenges which are
// currently valid.
$challenges = id(new PhabricatorAuthChallengeQuery())
->setViewer($viewer)
->withFactorPHIDs(mpull($factors, 'getPHID'))
->withUserPHIDs(array($viewer->getPHID()))
->withChallengeTTLBetween($now, null)
->execute();
$challenge_map = mgroup($challenges, 'getFactorPHID');
$validation_results = array();
$ok = true;
// Validate each factor against issued challenges. For example, this
// prevents you from receiving or responding to a TOTP challenge if another
// challenge was recently issued to a different session.
foreach ($factors as $factor) {
$factor_phid = $factor->getPHID();
$issued_challenges = idx($challenge_map, $factor_phid, array());
$impl = $factor->requireImplementation();
$new_challenges = $impl->getNewIssuedChallenges(
$factor,
$viewer,
$issued_challenges);
foreach ($new_challenges as $new_challenge) {
$issued_challenges[] = $new_challenge;
}
$challenge_map[$factor_phid] = $issued_challenges;
if (!$issued_challenges) {
continue;
}
$result = $impl->getResultFromIssuedChallenges(
$factor,
$viewer,
$issued_challenges);
if (!$result) {
continue;
}
$ok = false;
$validation_results[$factor_phid] = $result;
}
if ($request->isHTTPPost()) {
$request->validateCSRF();
if ($request->getExists(AphrontRequest::TYPE_HISEC)) {
@ -491,30 +543,28 @@ final class PhabricatorAuthSessionEngine extends Phobject {
new PhabricatorAuthTryFactorAction(),
1);
$ok = true;
foreach ($factors as $factor) {
$id = $factor->getID();
$factor_phid = $factor->getPHID();
// If we already have a validation result from previously issued
// challenges, skip validating this factor.
if (isset($validation_results[$factor_phid])) {
continue;
}
$impl = $factor->requireImplementation();
$validation_result = $impl->processValidateFactorForm(
$validation_result = $impl->getResultFromChallengeResponse(
$factor,
$viewer,
$request);
if (!($validation_result instanceof PhabricatorAuthFactorResult)) {
throw new Exception(
pht(
'Expected "processValidateFactorForm()" to return an object '.
'of class "%s"; got something else (from "%s").',
'PhabricatorAuthFactorResult',
get_class($impl)));
}
$request,
$issued_challenges);
if (!$validation_result->getIsValid()) {
$ok = false;
}
$validation_results[$id] = $validation_result;
$validation_results[$factor_phid] = $validation_result;
}
if ($ok) {
@ -566,6 +616,18 @@ final class PhabricatorAuthSessionEngine extends Phobject {
return $token;
}
// If we don't have a validation result for some factors yet, fill them
// in with an empty result so form rendering doesn't have to care if the
// results exist or not. This happens when you first load the form and have
// not submitted any responses yet.
foreach ($factors as $factor) {
$factor_phid = $factor->getPHID();
if (isset($validation_results[$factor_phid])) {
continue;
}
$validation_results[$factor_phid] = new PhabricatorAuthFactorResult();
}
throw id(new PhabricatorAuthHighSecurityRequiredException())
->setCancelURI($cancel_uri)
->setFactors($factors)
@ -613,7 +675,7 @@ final class PhabricatorAuthSessionEngine extends Phobject {
->appendRemarkupInstructions('');
foreach ($factors as $factor) {
$result = idx($validation_results, $factor->getID());
$result = $validation_results[$factor->getPHID()];
$factor->requireImplementation()->renderValidateFactorForm(
$factor,

View file

@ -14,12 +14,7 @@ abstract class PhabricatorAuthFactor extends Phobject {
PhabricatorAuthFactorConfig $config,
AphrontFormView $form,
PhabricatorUser $viewer,
PhabricatorAuthFactorResult $validation_result = null);
abstract public function processValidateFactorForm(
PhabricatorAuthFactorConfig $config,
PhabricatorUser $viewer,
AphrontRequest $request);
PhabricatorAuthFactorResult $validation_result);
public function getParameterName(
PhabricatorAuthFactorConfig $config,
@ -40,4 +35,131 @@ abstract class PhabricatorAuthFactor extends Phobject {
->setFactorKey($this->getFactorKey());
}
protected function newResult() {
return new PhabricatorAuthFactorResult();
}
protected function newChallenge(
PhabricatorAuthFactorConfig $config,
PhabricatorUser $viewer) {
return id(new PhabricatorAuthChallenge())
->setUserPHID($viewer->getPHID())
->setSessionPHID($viewer->getSession()->getPHID())
->setFactorPHID($config->getPHID());
}
final public function getNewIssuedChallenges(
PhabricatorAuthFactorConfig $config,
PhabricatorUser $viewer,
array $challenges) {
assert_instances_of($challenges, 'PhabricatorAuthChallenge');
$now = PhabricatorTime::getNow();
$new_challenges = $this->newIssuedChallenges(
$config,
$viewer,
$challenges);
assert_instances_of($new_challenges, 'PhabricatorAuthChallenge');
foreach ($new_challenges as $new_challenge) {
$ttl = $new_challenge->getChallengeTTL();
if (!$ttl) {
throw new Exception(
pht('Newly issued MFA challenges must have a valid TTL!'));
}
if ($ttl < $now) {
throw new Exception(
pht(
'Newly issued MFA challenges must have a future TTL. This '.
'factor issued a bad TTL ("%s"). (Did you use a relative '.
'time instead of an epoch?)',
$ttl));
}
}
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
foreach ($new_challenges as $challenge) {
$challenge->save();
}
unset($unguarded);
return $new_challenges;
}
abstract protected function newIssuedChallenges(
PhabricatorAuthFactorConfig $config,
PhabricatorUser $viewer,
array $challenges);
final public function getResultFromIssuedChallenges(
PhabricatorAuthFactorConfig $config,
PhabricatorUser $viewer,
array $challenges) {
assert_instances_of($challenges, 'PhabricatorAuthChallenge');
$result = $this->newResultFromIssuedChallenges(
$config,
$viewer,
$challenges);
if ($result === null) {
return $result;
}
if (!($result instanceof PhabricatorAuthFactorResult)) {
throw new Exception(
pht(
'Expected "newResultFromIssuedChallenges()" to return null or '.
'an object of class "%s"; got something else (in "%s").',
'PhabricatorAuthFactorResult',
get_class($this)));
}
$result->setIssuedChallenges($challenges);
return $result;
}
abstract protected function newResultFromIssuedChallenges(
PhabricatorAuthFactorConfig $config,
PhabricatorUser $viewer,
array $challenges);
final public function getResultFromChallengeResponse(
PhabricatorAuthFactorConfig $config,
PhabricatorUser $viewer,
AphrontRequest $request,
array $challenges) {
assert_instances_of($challenges, 'PhabricatorAuthChallenge');
$result = $this->newResultFromChallengeResponse(
$config,
$viewer,
$request,
$challenges);
if (!($result instanceof PhabricatorAuthFactorResult)) {
throw new Exception(
pht(
'Expected "newResultFromChallengeResponse()" to return an object '.
'of class "%s"; got something else (in "%s").',
'PhabricatorAuthFactorResult',
get_class($this)));
}
$result->setIssuedChallenges($challenges);
return $result;
}
abstract protected function newResultFromChallengeResponse(
PhabricatorAuthFactorConfig $config,
PhabricatorUser $viewer,
AphrontRequest $request,
array $challenges);
}

View file

@ -4,8 +4,10 @@ final class PhabricatorAuthFactorResult
extends Phobject {
private $isValid = false;
private $hint;
private $isWait = false;
private $errorMessage;
private $value;
private $issuedChallenges = array();
public function setIsValid($is_valid) {
$this->isValid = $is_valid;
@ -16,13 +18,22 @@ final class PhabricatorAuthFactorResult
return $this->isValid;
}
public function setHint($hint) {
$this->hint = $hint;
public function setIsWait($is_wait) {
$this->isWait = $is_wait;
return $this;
}
public function getHint() {
return $this->hint;
public function getIsWait() {
return $this->isWait;
}
public function setErrorMessage($error_message) {
$this->errorMessage = $error_message;
return $this;
}
public function getErrorMessage() {
return $this->errorMessage;
}
public function setValue($value) {
@ -34,4 +45,14 @@ final class PhabricatorAuthFactorResult
return $this->value;
}
public function setIssuedChallenges(array $issued_challenges) {
assert_instances_of($issued_challenges, 'PhabricatorAuthChallenge');
$this->issuedChallenges = $issued_challenges;
return $this;
}
public function getIssuedChallenges() {
return $this->issuedChallenges;
}
}

View file

@ -77,7 +77,7 @@ final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor {
$e_code = true;
if ($request->getExists('totp')) {
$okay = self::verifyTOTPCode(
$okay = $this->verifyTOTPCode(
$user,
new PhutilOpaqueEnvelope($key),
$code);
@ -150,50 +150,131 @@ final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor {
}
protected function newIssuedChallenges(
PhabricatorAuthFactorConfig $config,
PhabricatorUser $viewer,
array $challenges) {
$now = $this->getCurrentTimestep();
// If we already issued a valid challenge, don't issue a new one.
if ($challenges) {
return array();
}
// Otherwise, generate a new challenge for the current timestep. It TTLs
// after it would fall off the bottom of the window.
$timesteps = $this->getAllowedTimesteps();
$min_step = min($timesteps);
$step_duration = $this->getTimestepDuration();
$ttl_steps = ($now - $min_step) + 1;
$ttl_seconds = ($ttl_steps * $step_duration);
return array(
$this->newChallenge($config, $viewer)
->setChallengeKey($now)
->setChallengeTTL(PhabricatorTime::getNow() + $ttl_seconds),
);
}
public function renderValidateFactorForm(
PhabricatorAuthFactorConfig $config,
AphrontFormView $form,
PhabricatorUser $viewer,
PhabricatorAuthFactorResult $validation_result = null) {
PhabricatorAuthFactorResult $result) {
if ($validation_result) {
$value = $validation_result->getValue();
$hint = $validation_result->getHint();
$value = $result->getValue();
$error = $result->getErrorMessage();
$is_wait = $result->getIsWait();
if ($is_wait) {
$control = id(new AphrontFormMarkupControl())
->setValue($error)
->setError(pht('Wait'));
} else {
$value = null;
$hint = true;
$control = id(new PHUIFormNumberControl())
->setName($this->getParameterName($config, 'totpcode'))
->setDisableAutocomplete(true)
->setValue($value)
->setError($error);
}
$form->appendChild(
id(new PHUIFormNumberControl())
->setName($this->getParameterName($config, 'totpcode'))
->setLabel(pht('App Code'))
->setDisableAutocomplete(true)
->setCaption(pht('Factor Name: %s', $config->getFactorName()))
->setValue($value)
->setError($hint));
$control
->setLabel(pht('App Code'))
->setCaption(pht('Factor Name: %s', $config->getFactorName()));
$form->appendChild($control);
}
public function processValidateFactorForm(
protected function newResultFromIssuedChallenges(
PhabricatorAuthFactorConfig $config,
PhabricatorUser $viewer,
AphrontRequest $request) {
array $challenges) {
// If we've already issued a challenge at the current timestep or any
// nearby timestep, require that it was issued to the current session.
// This is defusing attacks where you (broadly) look at someone's phone
// and type the code in more quickly than they do.
$step_duration = $this->getTimestepDuration();
$now = $this->getCurrentTimestep();
$timesteps = $this->getAllowedTimesteps();
$timesteps = array_fuse($timesteps);
$min_step = min($timesteps);
$session_phid = $viewer->getSession()->getPHID();
foreach ($challenges as $challenge) {
$challenge_timestep = (int)$challenge->getChallengeKey();
// This challenge isn't for one of the timesteps you'd be able to respond
// to if you submitted the form right now, so we're good to keep going.
if (!isset($timesteps[$challenge_timestep])) {
continue;
}
// This is the number of timesteps you need to wait for the problem
// timestep to leave the window, rounded up.
$wait_steps = ($challenge_timestep - $min_step) + 1;
$wait_duration = ($wait_steps * $step_duration);
if ($challenge->getSessionPHID() !== $session_phid) {
return $this->newResult()
->setIsWait(true)
->setErrorMessage(
pht(
'This factor recently issued a challenge to a different login '.
'session. Wait %s seconds for the code to cycle, then try '.
'again.',
new PhutilNumber($wait_duration)));
}
}
return null;
}
protected function newResultFromChallengeResponse(
PhabricatorAuthFactorConfig $config,
PhabricatorUser $viewer,
AphrontRequest $request,
array $challenges) {
$code = $request->getStr($this->getParameterName($config, 'totpcode'));
$key = new PhutilOpaqueEnvelope($config->getFactorSecret());
$result = id(new PhabricatorAuthFactorResult())
$result = $this->newResult()
->setValue($code);
if (self::verifyTOTPCode($viewer, $key, $code)) {
if ($this->verifyTOTPCode($viewer, $key, (string)$code)) {
$result->setIsValid(true);
} else {
if (strlen($code)) {
$hint = pht('Invalid');
$error_message = pht('Invalid');
} else {
$hint = pht('Required');
$error_message = pht('Required');
}
$result->setHint($hint);
$result->setErrorMessage($error_message);
}
return $result;
@ -203,7 +284,7 @@ final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor {
return strtoupper(Filesystem::readRandomCharacters(32));
}
public static function verifyTOTPCode(
private function verifyTOTPCode(
PhabricatorUser $user,
PhutilOpaqueEnvelope $key,
$code) {
@ -318,4 +399,19 @@ final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor {
$rows);
}
private function getTimestepDuration() {
return 30;
}
private function getCurrentTimestep() {
$duration = $this->getTimestepDuration();
return (int)(PhabricatorTime::getNow() / $duration);
}
private function getAllowedTimesteps() {
$now = $this->getCurrentTimestep();
return range($now - 2, $now + 2);
}
}

View file

@ -0,0 +1,32 @@
<?php
final class PhabricatorAuthChallengePHIDType extends PhabricatorPHIDType {
const TYPECONST = 'CHAL';
public function getTypeName() {
return pht('Auth Challenge');
}
public function newObject() {
return new PhabricatorAuthChallenge();
}
public function getPHIDTypeApplicationClass() {
return 'PhabricatorAuthApplication';
}
protected function buildQueryForObjects(
PhabricatorObjectQuery $query,
array $phids) {
return new PhabricatorAuthChallengeQuery();
}
public function loadHandles(
PhabricatorHandleQuery $query,
array $handles,
array $objects) {
return;
}
}

View file

@ -0,0 +1,99 @@
<?php
final class PhabricatorAuthChallengeQuery
extends PhabricatorCursorPagedPolicyAwareQuery {
private $ids;
private $phids;
private $userPHIDs;
private $factorPHIDs;
private $challengeTTLMin;
private $challengeTTLMax;
public function withIDs(array $ids) {
$this->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 withFactorPHIDs(array $factor_phids) {
$this->factorPHIDs = $factor_phids;
return $this;
}
public function withChallengeTTLBetween($challenge_min, $challenge_max) {
$this->challengeTTLMin = $challenge_min;
$this->challengeTTLMax = $challenge_max;
return $this;
}
public function newResultObject() {
return new PhabricatorAuthChallenge();
}
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->userPHIDs !== null) {
$where[] = qsprintf(
$conn,
'userPHID IN (%Ls)',
$this->userPHIDs);
}
if ($this->factorPHIDs !== null) {
$where[] = qsprintf(
$conn,
'factorPHID IN (%Ls)',
$this->factorPHIDs);
}
if ($this->challengeTTLMin !== null) {
$where[] = qsprintf(
$conn,
'challengeTTL >= %d',
$this->challengeTTLMin);
}
if ($this->challengeTTLMax !== null) {
$where[] = qsprintf(
$conn,
'challengeTTL <= %d',
$this->challengeTTLMax);
}
return $where;
}
public function getQueryApplicationClass() {
return 'PhabricatorAuthApplication';
}
}

View file

@ -0,0 +1,54 @@
<?php
final class PhabricatorAuthChallenge
extends PhabricatorAuthDAO
implements PhabricatorPolicyInterface {
protected $userPHID;
protected $factorPHID;
protected $sessionPHID;
protected $challengeKey;
protected $challengeTTL;
protected $properties = array();
protected function getConfiguration() {
return array(
self::CONFIG_SERIALIZATION => array(
'properties' => self::SERIALIZATION_JSON,
),
self::CONFIG_AUX_PHID => true,
self::CONFIG_COLUMN_SCHEMA => array(
'challengeKey' => 'text255',
'challengeTTL' => 'epoch',
),
self::CONFIG_KEY_SCHEMA => array(
'key_issued' => array(
'columns' => array('userPHID', 'challengeTTL'),
),
),
) + parent::getConfiguration();
}
public function getPHIDType() {
return PhabricatorAuthChallengePHIDType::TYPECONST;
}
/* -( PhabricatorPolicyInterface )----------------------------------------- */
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
);
}
public function getPolicy($capability) {
return PhabricatorPolicies::POLICY_NOONE;
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
return ($viewer->getPHID() === $this->getUserPHID());
}
}