Path: blob/master/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php
12256 views
<?php12final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor {34public function getFactorKey() {5return 'totp';6}78public function getFactorName() {9return pht('Mobile Phone App (TOTP)');10}1112public function getFactorShortName() {13return pht('TOTP');14}1516public function getFactorCreateHelp() {17return pht(18'Allow users to attach a mobile authenticator application (like '.19'Google Authenticator) to their account.');20}2122public function getFactorDescription() {23return pht(24'Attach a mobile authenticator application (like Authy '.25'or Google Authenticator) to your account. When you need to '.26'authenticate, you will enter a code shown on your phone.');27}2829public function getEnrollDescription(30PhabricatorAuthFactorProvider $provider,31PhabricatorUser $user) {3233return pht(34'To add a TOTP factor to your account, you will first need to install '.35'a mobile authenticator application on your phone. Two applications '.36'which work well are **Google Authenticator** and **Authy**, but any '.37'other TOTP application should also work.'.38"\n\n".39'If you haven\'t already, download and install a TOTP application on '.40'your phone now. Once you\'ve launched the application and are ready '.41'to add a new TOTP code, continue to the next step.');42}4344public function getConfigurationListDetails(45PhabricatorAuthFactorConfig $config,46PhabricatorAuthFactorProvider $provider,47PhabricatorUser $viewer) {4849$bits = strlen($config->getFactorSecret()) * 8;50return pht('%d-Bit Secret', $bits);51}5253public function processAddFactorForm(54PhabricatorAuthFactorProvider $provider,55AphrontFormView $form,56AphrontRequest $request,57PhabricatorUser $user) {5859$sync_token = $this->loadMFASyncToken(60$provider,61$request,62$form,63$user);64$secret = $sync_token->getTemporaryTokenProperty('secret');6566$code = $request->getStr('totpcode');6768$e_code = true;69if (!$sync_token->getIsNewTemporaryToken()) {70$okay = (bool)$this->getTimestepAtWhichResponseIsValid(71$this->getAllowedTimesteps($this->getCurrentTimestep()),72new PhutilOpaqueEnvelope($secret),73$code);7475if ($okay) {76$config = $this->newConfigForUser($user)77->setFactorName(pht('Mobile App (TOTP)'))78->setFactorSecret($secret)79->setMFASyncToken($sync_token);8081return $config;82} else {83if (!strlen($code)) {84$e_code = pht('Required');85} else {86$e_code = pht('Invalid');87}88}89}9091$form->appendInstructions(92pht(93'Scan the QR code or manually enter the key shown below into the '.94'application.'));9596$prod_uri = new PhutilURI(PhabricatorEnv::getProductionURI('/'));97$issuer = $prod_uri->getDomain();9899$uri = urisprintf(100'otpauth://totp/%s:%s?secret=%s&issuer=%s',101$issuer,102$user->getUsername(),103$secret,104$issuer);105106$qrcode = $this->newQRCode($uri);107$form->appendChild($qrcode);108109$form->appendChild(110id(new AphrontFormStaticControl())111->setLabel(pht('Key'))112->setValue(phutil_tag('strong', array(), $secret)));113114$form->appendInstructions(115pht(116'(If given an option, select that this key is "Time Based", not '.117'"Counter Based".)'));118119$form->appendInstructions(120pht(121'After entering the key, the application should display a numeric '.122'code. Enter that code below to confirm that you have configured '.123'the authenticator correctly:'));124125$form->appendChild(126id(new PHUIFormNumberControl())127->setLabel(pht('TOTP Code'))128->setName('totpcode')129->setValue($code)130->setAutofocus(true)131->setError($e_code));132133}134135protected function newIssuedChallenges(136PhabricatorAuthFactorConfig $config,137PhabricatorUser $viewer,138array $challenges) {139140$current_step = $this->getCurrentTimestep();141142// If we already issued a valid challenge, don't issue a new one.143if ($challenges) {144return array();145}146147// Otherwise, generate a new challenge for the current timestep and compute148// the TTL.149150// When computing the TTL, note that we accept codes within a certain151// window of the challenge timestep to account for clock skew and users152// needing time to enter codes.153154// We don't want this challenge to expire until after all valid responses155// to it are no longer valid responses to any other challenge we might156// issue in the future. If the challenge expires too quickly, we may issue157// a new challenge which can accept the same TOTP code response.158159// This means that we need to keep this challenge alive for double the160// window size: if we're currently at timestep 3, the user might respond161// with the code for timestep 5. This is valid, since timestep 5 is within162// the window for timestep 3.163164// But the code for timestep 5 can be used to respond at timesteps 3, 4, 5,165// 6, and 7. To prevent any valid response to this challenge from being166// used again, we need to keep this challenge active until timestep 8.167168$window_size = $this->getTimestepWindowSize();169$step_duration = $this->getTimestepDuration();170171$ttl_steps = ($window_size * 2) + 1;172$ttl_seconds = ($ttl_steps * $step_duration);173174return array(175$this->newChallenge($config, $viewer)176->setChallengeKey($current_step)177->setChallengeTTL(PhabricatorTime::getNow() + $ttl_seconds),178);179}180181public function renderValidateFactorForm(182PhabricatorAuthFactorConfig $config,183AphrontFormView $form,184PhabricatorUser $viewer,185PhabricatorAuthFactorResult $result) {186187$control = $this->newAutomaticControl($result);188if (!$control) {189$value = $result->getValue();190$error = $result->getErrorMessage();191$name = $this->getChallengeResponseParameterName($config);192193$control = id(new PHUIFormNumberControl())194->setName($name)195->setDisableAutocomplete(true)196->setAutofocus(true)197->setValue($value)198->setError($error);199}200201$control202->setLabel(pht('App Code'))203->setCaption(pht('Factor Name: %s', $config->getFactorName()));204205$form->appendChild($control);206}207208public function getRequestHasChallengeResponse(209PhabricatorAuthFactorConfig $config,210AphrontRequest $request) {211212$value = $this->getChallengeResponseFromRequest($config, $request);213return (bool)strlen($value);214}215216217protected function newResultFromIssuedChallenges(218PhabricatorAuthFactorConfig $config,219PhabricatorUser $viewer,220array $challenges) {221222// If we've already issued a challenge at the current timestep or any223// nearby timestep, require that it was issued to the current session.224// This is defusing attacks where you (broadly) look at someone's phone225// and type the code in more quickly than they do.226$session_phid = $viewer->getSession()->getPHID();227$now = PhabricatorTime::getNow();228229$engine = $config->getSessionEngine();230$workflow_key = $engine->getWorkflowKey();231232$current_timestep = $this->getCurrentTimestep();233234foreach ($challenges as $challenge) {235$challenge_timestep = (int)$challenge->getChallengeKey();236$wait_duration = ($challenge->getChallengeTTL() - $now) + 1;237238if ($challenge->getSessionPHID() !== $session_phid) {239return $this->newResult()240->setIsWait(true)241->setErrorMessage(242pht(243'This factor recently issued a challenge to a different login '.244'session. Wait %s second(s) for the code to cycle, then try '.245'again.',246new PhutilNumber($wait_duration)));247}248249if ($challenge->getWorkflowKey() !== $workflow_key) {250return $this->newResult()251->setIsWait(true)252->setErrorMessage(253pht(254'This factor recently issued a challenge for a different '.255'workflow. Wait %s second(s) for the code to cycle, then try '.256'again.',257new PhutilNumber($wait_duration)));258}259260// If the current realtime timestep isn't a valid response to the current261// challenge but the challenge hasn't expired yet, we're locking out262// the factor to prevent challenge windows from overlapping. Let the user263// know that they should wait for a new challenge.264$challenge_timesteps = $this->getAllowedTimesteps($challenge_timestep);265if (!isset($challenge_timesteps[$current_timestep])) {266return $this->newResult()267->setIsWait(true)268->setErrorMessage(269pht(270'This factor recently issued a challenge which has expired. '.271'A new challenge can not be issued yet. Wait %s second(s) for '.272'the code to cycle, then try again.',273new PhutilNumber($wait_duration)));274}275276if ($challenge->getIsReusedChallenge()) {277return $this->newResult()278->setIsWait(true)279->setErrorMessage(280pht(281'You recently provided a response to this factor. Responses '.282'may not be reused. Wait %s second(s) for the code to cycle, '.283'then try again.',284new PhutilNumber($wait_duration)));285}286}287288return null;289}290291protected function newResultFromChallengeResponse(292PhabricatorAuthFactorConfig $config,293PhabricatorUser $viewer,294AphrontRequest $request,295array $challenges) {296297$code = $this->getChallengeResponseFromRequest(298$config,299$request);300301$result = $this->newResult()302->setValue($code);303304// We expect to reach TOTP validation with exactly one valid challenge.305if (count($challenges) !== 1) {306throw new Exception(307pht(308'Reached TOTP challenge validation with an unexpected number of '.309'unexpired challenges (%d), expected exactly one.',310phutil_count($challenges)));311}312313$challenge = head($challenges);314315// If the client has already provided a valid answer to this challenge and316// submitted a token proving they answered it, we're all set.317if ($challenge->getIsAnsweredChallenge()) {318return $result->setAnsweredChallenge($challenge);319}320321$challenge_timestep = (int)$challenge->getChallengeKey();322$current_timestep = $this->getCurrentTimestep();323324$challenge_timesteps = $this->getAllowedTimesteps($challenge_timestep);325$current_timesteps = $this->getAllowedTimesteps($current_timestep);326327// We require responses be both valid for the challenge and valid for the328// current timestep. A longer challenge TTL doesn't let you use older329// codes for a longer period of time.330$valid_timestep = $this->getTimestepAtWhichResponseIsValid(331array_intersect_key($challenge_timesteps, $current_timesteps),332new PhutilOpaqueEnvelope($config->getFactorSecret()),333$code);334335if ($valid_timestep) {336$ttl = PhabricatorTime::getNow() + 60;337338$challenge339->setProperty('totp.timestep', $valid_timestep)340->markChallengeAsAnswered($ttl);341342$result->setAnsweredChallenge($challenge);343} else {344if (strlen($code)) {345$error_message = pht('Invalid');346} else {347$error_message = pht('Required');348}349$result->setErrorMessage($error_message);350}351352return $result;353}354355public static function generateNewTOTPKey() {356return strtoupper(Filesystem::readRandomCharacters(32));357}358359public static function base32Decode($buf) {360$buf = strtoupper($buf);361362$map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';363$map = str_split($map);364$map = array_flip($map);365366$out = '';367$len = strlen($buf);368$acc = 0;369$bits = 0;370for ($ii = 0; $ii < $len; $ii++) {371$chr = $buf[$ii];372$val = $map[$chr];373374$acc = $acc << 5;375$acc = $acc + $val;376377$bits += 5;378if ($bits >= 8) {379$bits = $bits - 8;380$out .= chr(($acc & (0xFF << $bits)) >> $bits);381}382}383384return $out;385}386387public static function getTOTPCode(PhutilOpaqueEnvelope $key, $timestamp) {388$binary_timestamp = pack('N*', 0).pack('N*', $timestamp);389$binary_key = self::base32Decode($key->openEnvelope());390391$hash = hash_hmac('sha1', $binary_timestamp, $binary_key, true);392393// See RFC 4226.394395$offset = ord($hash[19]) & 0x0F;396397$code = ((ord($hash[$offset + 0]) & 0x7F) << 24) |398((ord($hash[$offset + 1]) & 0xFF) << 16) |399((ord($hash[$offset + 2]) & 0xFF) << 8) |400((ord($hash[$offset + 3]) ) );401402$code = ($code % 1000000);403$code = str_pad($code, 6, '0', STR_PAD_LEFT);404405return $code;406}407408private function getTimestepDuration() {409return 30;410}411412private function getCurrentTimestep() {413$duration = $this->getTimestepDuration();414return (int)(PhabricatorTime::getNow() / $duration);415}416417private function getAllowedTimesteps($at_timestep) {418$window = $this->getTimestepWindowSize();419$range = range($at_timestep - $window, $at_timestep + $window);420return array_fuse($range);421}422423private function getTimestepWindowSize() {424// The user is allowed to provide a code from the recent past or the425// near future to account for minor clock skew between the client426// and server, and the time it takes to actually enter a code.427return 1;428}429430private function getTimestepAtWhichResponseIsValid(431array $timesteps,432PhutilOpaqueEnvelope $key,433$code) {434435foreach ($timesteps as $timestep) {436$expect_code = self::getTOTPCode($key, $timestep);437if (phutil_hashes_are_identical($code, $expect_code)) {438return $timestep;439}440}441442return null;443}444445protected function newMFASyncTokenProperties(446PhabricatorAuthFactorProvider $providerr,447PhabricatorUser $user) {448return array(449'secret' => self::generateNewTOTPKey(),450);451}452453}454455456