Changeset View
Standalone View
src/applications/auth/factor/PhabricatorAuthFactor.php
<?php | <?php | ||||
abstract class PhabricatorAuthFactor extends Phobject { | abstract class PhabricatorAuthFactor extends Phobject { | ||||
abstract public function getFactorName(); | abstract public function getFactorName(); | ||||
abstract public function getFactorKey(); | abstract public function getFactorKey(); | ||||
abstract public function getFactorDescription(); | abstract public function getFactorDescription(); | ||||
abstract public function processAddFactorForm( | abstract public function processAddFactorForm( | ||||
AphrontFormView $form, | AphrontFormView $form, | ||||
AphrontRequest $request, | AphrontRequest $request, | ||||
PhabricatorUser $user); | PhabricatorUser $user); | ||||
abstract public function renderValidateFactorForm( | abstract public function renderValidateFactorForm( | ||||
PhabricatorAuthFactorConfig $config, | PhabricatorAuthFactorConfig $config, | ||||
AphrontFormView $form, | AphrontFormView $form, | ||||
PhabricatorUser $viewer, | PhabricatorUser $viewer, | ||||
PhabricatorAuthFactorResult $validation_result = null); | PhabricatorAuthFactorResult $validation_result); | ||||
abstract public function processValidateFactorForm( | |||||
PhabricatorAuthFactorConfig $config, | |||||
PhabricatorUser $viewer, | |||||
AphrontRequest $request); | |||||
public function getParameterName( | public function getParameterName( | ||||
PhabricatorAuthFactorConfig $config, | PhabricatorAuthFactorConfig $config, | ||||
$name) { | $name) { | ||||
return 'authfactor.'.$config->getID().'.'.$name; | return 'authfactor.'.$config->getID().'.'.$name; | ||||
} | } | ||||
public static function getAllFactors() { | public static function getAllFactors() { | ||||
return id(new PhutilClassMapQuery()) | return id(new PhutilClassMapQuery()) | ||||
->setAncestorClass(__CLASS__) | ->setAncestorClass(__CLASS__) | ||||
->setUniqueMethod('getFactorKey') | ->setUniqueMethod('getFactorKey') | ||||
->execute(); | ->execute(); | ||||
} | } | ||||
protected function newConfigForUser(PhabricatorUser $user) { | protected function newConfigForUser(PhabricatorUser $user) { | ||||
return id(new PhabricatorAuthFactorConfig()) | return id(new PhabricatorAuthFactorConfig()) | ||||
->setUserPHID($user->getPHID()) | ->setUserPHID($user->getPHID()) | ||||
->setFactorKey($this->getFactorKey()); | ->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); | |||||
amckinley: This is probably a good time to talk about how `AphrontWriteGuard` works, because I understand… | |||||
Done Inline Actions
Yeah -- the write guard is for the case where you load the form for the first time and it says "hey, answer this MFA challenge". Sometimes that's in response to an actual form POST, but sometimes it's a GET or not a CSRF'd POST. One example is that when you GET into the "VCS Password" settings page, we immediately prompt you for MFA (admittedly, this is a little weird).
A lot of the way it's hooked up is pretty magic, but the actual implementation is as dumb as rocks. We tell the query layer "before you do any write, call this method" (in AphrontApplicationConfiguration): $write_guard = new AphrontWriteGuard(array($request, 'validateCSRF')); This object is magic and registers itself with the query layer. A more modern implementation would probably be a little less magic, but this was written in like 2011. The query layer tests each query to see if it starts with "SELECT", "SHOW", or "EXPLAIN". If it doesn't, it determines that it's probably a write and calls the callback: // NOTE: The opening "(" allows queries in the form of: // // (SELECT ...) UNION (SELECT ...) $is_write = !preg_match('/^[(]*(SELECT|SHOW|EXPLAIN)\s/', $raw_query); if ($is_write) { // .... AphrontWriteGuard::willWrite(); } The callback checks that $_REQUEST has a valid CSRF token. If it doesn't, it throws an exception.
Mostly, just so that we don't have to have a lot of unguarded write boilerplate in every single implementation. Challenge has a freeform $properties property, so my expectation is that any reasonable MFA factor implementation doesn't need other persistence. If that turns out to be untrue, maybe this was a bad call. In theory, the write guard mechanism works with any service that does writes, although I'm not sure we ever bothered to implement it in any others. There's normally no way to do a service write without also doing a database write, and service writes are normally not interesting/dangerous (e.g., uploading data to S3 or whatever, I guess?). epriestley: > Is it because this request to generate a challenge is coming in via a GET request?
Yeah… | |||||
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); | |||||
} | } |
This is probably a good time to talk about how AphrontWriteGuard works, because I understand it mostly as "magic herbs that ward off evil CSRF spirits". Why wouldn't we have access to CSRF tokens at this point? Is it because this request to generate a challenge is coming in via a GET request? Why are we calling save() instead of letting newResultFromIssuedChallenges implementations handle that (especially if those implementations might have their own special form of persistence)?