Changeset View
Changeset View
Standalone View
Standalone View
src/applications/auth/storage/PhabricatorAuthChallenge.php
Show All 11 Lines | final class PhabricatorAuthChallenge | ||||
protected $challengeTTL; | protected $challengeTTL; | ||||
protected $responseDigest; | protected $responseDigest; | ||||
protected $responseTTL; | protected $responseTTL; | ||||
protected $isCompleted; | protected $isCompleted; | ||||
protected $properties = array(); | protected $properties = array(); | ||||
private $responseToken; | private $responseToken; | ||||
const HTTPKEY = '__hisec.challenges__'; | |||||
const TOKEN_DIGEST_KEY = 'auth.challenge.token'; | const TOKEN_DIGEST_KEY = 'auth.challenge.token'; | ||||
public static function initializeNewChallenge() { | public static function initializeNewChallenge() { | ||||
return id(new self()) | return id(new self()) | ||||
->setIsCompleted(0); | ->setIsCompleted(0); | ||||
} | } | ||||
public static function newHTTPParametersFromChallenges(array $challenges) { | |||||
assert_instances_of($challenges, __CLASS__); | |||||
$token_list = array(); | |||||
foreach ($challenges as $challenge) { | |||||
$token = $challenge->getResponseToken(); | |||||
if ($token) { | |||||
$token_list[] = sprintf( | |||||
'%s:%s', | |||||
$challenge->getPHID(), | |||||
$token->openEnvelope()); | |||||
} | |||||
} | |||||
if (!$token_list) { | |||||
return array(); | |||||
} | |||||
$token_list = implode(' ', $token_list); | |||||
return array( | |||||
self::HTTPKEY => $token_list, | |||||
); | |||||
} | |||||
public static function newChallengeResponsesFromRequest( | |||||
array $challenges, | |||||
AphrontRequest $request) { | |||||
assert_instances_of($challenges, __CLASS__); | |||||
$token_list = $request->getStr(self::HTTPKEY); | |||||
$token_list = explode(' ', $token_list); | |||||
$token_map = array(); | |||||
foreach ($token_list as $token_element) { | |||||
$token_element = trim($token_element, ' '); | |||||
if (!strlen($token_element)) { | |||||
continue; | |||||
} | |||||
// NOTE: This error message is intentionally not printing the token to | |||||
// avoid disclosing it. As a result, it isn't terribly useful, but no | |||||
// normal user should ever end up here. | |||||
if (!preg_match('/^[^:]+:/', $token_element)) { | |||||
throw new Exception( | |||||
pht( | |||||
'This request included an improperly formatted MFA challenge '. | |||||
'token and can not be processed.')); | |||||
} | |||||
list($phid, $token) = explode(':', $token_element, 2); | |||||
if (isset($token_map[$phid])) { | |||||
throw new Exception( | |||||
pht( | |||||
'This request improperly specifies an MFA challenge token ("%s") '. | |||||
'multiple times and can not be processed.', | |||||
$phid)); | |||||
} | |||||
$token_map[$phid] = new PhutilOpaqueEnvelope($token); | |||||
} | |||||
$challenges = mpull($challenges, null, 'getPHID'); | |||||
$now = PhabricatorTime::getNow(); | |||||
foreach ($challenges as $challenge_phid => $challenge) { | |||||
// If the response window has expired, don't attach the token. | |||||
if ($challenge->getResponseTTL() < $now) { | |||||
continue; | |||||
} | |||||
$token = idx($token_map, $challenge_phid); | |||||
if (!$token) { | |||||
continue; | |||||
} | |||||
$challenge->setResponseToken($token); | |||||
} | |||||
} | |||||
protected function getConfiguration() { | protected function getConfiguration() { | ||||
return array( | return array( | ||||
self::CONFIG_SERIALIZATION => array( | self::CONFIG_SERIALIZATION => array( | ||||
'properties' => self::SERIALIZATION_JSON, | 'properties' => self::SERIALIZATION_JSON, | ||||
), | ), | ||||
self::CONFIG_AUX_PHID => true, | self::CONFIG_AUX_PHID => true, | ||||
self::CONFIG_COLUMN_SCHEMA => array( | self::CONFIG_COLUMN_SCHEMA => array( | ||||
'challengeKey' => 'text255', | 'challengeKey' => 'text255', | ||||
Show All 18 Lines | public function getPHIDType() { | ||||
return PhabricatorAuthChallengePHIDType::TYPECONST; | return PhabricatorAuthChallengePHIDType::TYPECONST; | ||||
} | } | ||||
public function getIsReusedChallenge() { | public function getIsReusedChallenge() { | ||||
if ($this->getIsCompleted()) { | if ($this->getIsCompleted()) { | ||||
return true; | return true; | ||||
} | } | ||||
// TODO: A challenge is "reused" if it has been answered previously and | if (!$this->getIsAnsweredChallenge()) { | ||||
// the request doesn't include proof that the client provided the answer. | return false; | ||||
// Since we aren't tracking client responses yet, any answered challenge | } | ||||
// is always a reused challenge for now. | |||||
return $this->getIsAnsweredChallenge(); | // If the challenge has been answered but the client has provided a token | ||||
// proving that they answered it, this is still a valid response. | |||||
if ($this->getResponseToken()) { | |||||
return false; | |||||
} | |||||
return true; | |||||
} | } | ||||
public function getIsAnsweredChallenge() { | public function getIsAnsweredChallenge() { | ||||
return (bool)$this->getResponseDigest(); | return (bool)$this->getResponseDigest(); | ||||
} | } | ||||
public function markChallengeAsAnswered($ttl) { | public function markChallengeAsAnswered($ttl) { | ||||
$token = Filesystem::readRandomCharacters(32); | $token = Filesystem::readRandomCharacters(32); | ||||
$token = new PhutilOpaqueEnvelope($token); | $token = new PhutilOpaqueEnvelope($token); | ||||
return $this | return $this | ||||
->setResponseToken($token, $ttl) | ->setResponseToken($token) | ||||
->setResponseTTL($ttl) | |||||
->save(); | ->save(); | ||||
} | } | ||||
public function markChallengeAsCompleted() { | public function markChallengeAsCompleted() { | ||||
return $this | return $this | ||||
->setIsCompleted(true) | ->setIsCompleted(true) | ||||
->save(); | ->save(); | ||||
} | } | ||||
public function setResponseToken(PhutilOpaqueEnvelope $token, $ttl) { | public function setResponseToken(PhutilOpaqueEnvelope $token) { | ||||
if (!$this->getUserPHID()) { | if (!$this->getUserPHID()) { | ||||
throw new PhutilInvalidStateException('setUserPHID'); | throw new PhutilInvalidStateException('setUserPHID'); | ||||
} | } | ||||
if ($this->responseToken) { | if ($this->responseToken) { | ||||
throw new Exception( | throw new Exception( | ||||
pht( | pht( | ||||
'This challenge already has a response token; you can not '. | 'This challenge already has a response token; you can not '. | ||||
'set a new response token.')); | 'set a new response token.')); | ||||
} | } | ||||
$now = PhabricatorTime::getNow(); | |||||
if ($ttl < $now) { | |||||
throw new Exception( | |||||
pht( | |||||
'Response TTL is invalid: TTLs must be an epoch timestamp '. | |||||
'coresponding to a future time (did you use a relative TTL by '. | |||||
'mistake?).')); | |||||
} | |||||
if (preg_match('/ /', $token->openEnvelope())) { | if (preg_match('/ /', $token->openEnvelope())) { | ||||
throw new Exception( | throw new Exception( | ||||
pht( | pht( | ||||
'The response token for this challenge is invalid: response '. | 'The response token for this challenge is invalid: response '. | ||||
'tokens may not include spaces.')); | 'tokens may not include spaces.')); | ||||
} | } | ||||
$digest = PhabricatorHash::digestWithNamedKey( | $digest = PhabricatorHash::digestWithNamedKey( | ||||
$token->openEnvelope(), | $token->openEnvelope(), | ||||
self::TOKEN_DIGEST_KEY); | self::TOKEN_DIGEST_KEY); | ||||
if ($this->responseDigest !== null) { | if ($this->responseDigest !== null) { | ||||
if (!phutil_hashes_are_identical($digest, $this->responseDigest)) { | if (!phutil_hashes_are_identical($digest, $this->responseDigest)) { | ||||
throw new Exception( | throw new Exception( | ||||
pht( | pht( | ||||
'Invalid response token for this challenge: token digest does '. | 'Invalid response token for this challenge: token digest does '. | ||||
'not match stored digest.')); | 'not match stored digest.')); | ||||
} | } | ||||
} else { | } else { | ||||
$this->responseDigest = $digest; | $this->responseDigest = $digest; | ||||
} | } | ||||
$this->responseToken = $token; | $this->responseToken = $token; | ||||
$this->responseTTL = $ttl; | |||||
return $this; | return $this; | ||||
} | } | ||||
public function getResponseToken() { | |||||
return $this->responseToken; | |||||
} | |||||
public function setResponseDigest($value) { | public function setResponseDigest($value) { | ||||
throw new Exception( | throw new Exception( | ||||
pht( | pht( | ||||
'You can not set the response digest for a challenge directly. '. | 'You can not set the response digest for a challenge directly. '. | ||||
'Instead, set a response token. A response digest will be computed '. | 'Instead, set a response token. A response digest will be computed '. | ||||
'automatically.')); | 'automatically.')); | ||||
} | } | ||||
Show All 28 Lines |