Path: blob/master/src/applications/auth/factor/PhabricatorAuthFactor.php
12256 views
<?php12abstract class PhabricatorAuthFactor extends Phobject {34abstract public function getFactorName();5abstract public function getFactorShortName();6abstract public function getFactorKey();7abstract public function getFactorCreateHelp();8abstract public function getFactorDescription();9abstract public function processAddFactorForm(10PhabricatorAuthFactorProvider $provider,11AphrontFormView $form,12AphrontRequest $request,13PhabricatorUser $user);1415abstract public function renderValidateFactorForm(16PhabricatorAuthFactorConfig $config,17AphrontFormView $form,18PhabricatorUser $viewer,19PhabricatorAuthFactorResult $validation_result);2021public function getParameterName(22PhabricatorAuthFactorConfig $config,23$name) {24return 'authfactor.'.$config->getID().'.'.$name;25}2627public static function getAllFactors() {28return id(new PhutilClassMapQuery())29->setAncestorClass(__CLASS__)30->setUniqueMethod('getFactorKey')31->execute();32}3334protected function newConfigForUser(PhabricatorUser $user) {35return id(new PhabricatorAuthFactorConfig())36->setUserPHID($user->getPHID())37->setFactorSecret('');38}3940protected function newResult() {41return new PhabricatorAuthFactorResult();42}4344public function newIconView() {45return id(new PHUIIconView())46->setIcon('fa-mobile');47}4849public function canCreateNewProvider() {50return true;51}5253public function getProviderCreateDescription() {54return null;55}5657public function canCreateNewConfiguration(58PhabricatorAuthFactorProvider $provider,59PhabricatorUser $user) {60return true;61}6263public function getConfigurationCreateDescription(64PhabricatorAuthFactorProvider $provider,65PhabricatorUser $user) {66return null;67}6869public function getConfigurationListDetails(70PhabricatorAuthFactorConfig $config,71PhabricatorAuthFactorProvider $provider,72PhabricatorUser $viewer) {73return null;74}7576public function newEditEngineFields(77PhabricatorEditEngine $engine,78PhabricatorAuthFactorProvider $provider) {79return array();80}8182public function newChallengeStatusView(83PhabricatorAuthFactorConfig $config,84PhabricatorAuthFactorProvider $provider,85PhabricatorUser $viewer,86PhabricatorAuthChallenge $challenge) {87return null;88}8990/**91* Is this a factor which depends on the user's contact number?92*93* If a user has a "contact number" factor configured, they can not modify94* or switch their primary contact number.95*96* @return bool True if this factor should lock contact numbers.97*/98public function isContactNumberFactor() {99return false;100}101102abstract public function getEnrollDescription(103PhabricatorAuthFactorProvider $provider,104PhabricatorUser $user);105106public function getEnrollButtonText(107PhabricatorAuthFactorProvider $provider,108PhabricatorUser $user) {109return pht('Continue');110}111112public function getFactorOrder() {113return 1000;114}115116final public function newSortVector() {117return id(new PhutilSortVector())118->addInt($this->canCreateNewProvider() ? 0 : 1)119->addInt($this->getFactorOrder())120->addString($this->getFactorName());121}122123protected function newChallenge(124PhabricatorAuthFactorConfig $config,125PhabricatorUser $viewer) {126127$engine = $config->getSessionEngine();128129return PhabricatorAuthChallenge::initializeNewChallenge()130->setUserPHID($viewer->getPHID())131->setSessionPHID($viewer->getSession()->getPHID())132->setFactorPHID($config->getPHID())133->setIsNewChallenge(true)134->setWorkflowKey($engine->getWorkflowKey());135}136137abstract public function getRequestHasChallengeResponse(138PhabricatorAuthFactorConfig $config,139AphrontRequest $response);140141final public function getNewIssuedChallenges(142PhabricatorAuthFactorConfig $config,143PhabricatorUser $viewer,144array $challenges) {145assert_instances_of($challenges, 'PhabricatorAuthChallenge');146147$now = PhabricatorTime::getNow();148149// Factor implementations may need to perform writes in order to issue150// challenges, particularly push factors like SMS.151$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();152153$new_challenges = $this->newIssuedChallenges(154$config,155$viewer,156$challenges);157158if ($this->isAuthResult($new_challenges)) {159unset($unguarded);160return $new_challenges;161}162163assert_instances_of($new_challenges, 'PhabricatorAuthChallenge');164165foreach ($new_challenges as $new_challenge) {166$ttl = $new_challenge->getChallengeTTL();167if (!$ttl) {168throw new Exception(169pht('Newly issued MFA challenges must have a valid TTL!'));170}171172if ($ttl < $now) {173throw new Exception(174pht(175'Newly issued MFA challenges must have a future TTL. This '.176'factor issued a bad TTL ("%s"). (Did you use a relative '.177'time instead of an epoch?)',178$ttl));179}180}181182foreach ($new_challenges as $challenge) {183$challenge->save();184}185186unset($unguarded);187188return $new_challenges;189}190191abstract protected function newIssuedChallenges(192PhabricatorAuthFactorConfig $config,193PhabricatorUser $viewer,194array $challenges);195196final public function getResultFromIssuedChallenges(197PhabricatorAuthFactorConfig $config,198PhabricatorUser $viewer,199array $challenges) {200assert_instances_of($challenges, 'PhabricatorAuthChallenge');201202$result = $this->newResultFromIssuedChallenges(203$config,204$viewer,205$challenges);206207if ($result === null) {208return $result;209}210211if (!$this->isAuthResult($result)) {212throw new Exception(213pht(214'Expected "newResultFromIssuedChallenges()" to return null or '.215'an object of class "%s"; got something else (in "%s").',216'PhabricatorAuthFactorResult',217get_class($this)));218}219220return $result;221}222223final public function getResultForPrompt(224PhabricatorAuthFactorConfig $config,225PhabricatorUser $viewer,226AphrontRequest $request,227array $challenges) {228assert_instances_of($challenges, 'PhabricatorAuthChallenge');229230$result = $this->newResultForPrompt(231$config,232$viewer,233$request,234$challenges);235236if (!$this->isAuthResult($result)) {237throw new Exception(238pht(239'Expected "newResultForPrompt()" to return an object of class "%s", '.240'but it returned something else ("%s"; in "%s").',241'PhabricatorAuthFactorResult',242phutil_describe_type($result),243get_class($this)));244}245246return $result;247}248249protected function newResultForPrompt(250PhabricatorAuthFactorConfig $config,251PhabricatorUser $viewer,252AphrontRequest $request,253array $challenges) {254return $this->newResult();255}256257abstract protected function newResultFromIssuedChallenges(258PhabricatorAuthFactorConfig $config,259PhabricatorUser $viewer,260array $challenges);261262final public function getResultFromChallengeResponse(263PhabricatorAuthFactorConfig $config,264PhabricatorUser $viewer,265AphrontRequest $request,266array $challenges) {267assert_instances_of($challenges, 'PhabricatorAuthChallenge');268269$result = $this->newResultFromChallengeResponse(270$config,271$viewer,272$request,273$challenges);274275if (!$this->isAuthResult($result)) {276throw new Exception(277pht(278'Expected "newResultFromChallengeResponse()" to return an object '.279'of class "%s"; got something else (in "%s").',280'PhabricatorAuthFactorResult',281get_class($this)));282}283284return $result;285}286287abstract protected function newResultFromChallengeResponse(288PhabricatorAuthFactorConfig $config,289PhabricatorUser $viewer,290AphrontRequest $request,291array $challenges);292293final protected function newAutomaticControl(294PhabricatorAuthFactorResult $result) {295296$is_error = $result->getIsError();297if ($is_error) {298return $this->newErrorControl($result);299}300301$is_continue = $result->getIsContinue();302if ($is_continue) {303return $this->newContinueControl($result);304}305306$is_answered = (bool)$result->getAnsweredChallenge();307if ($is_answered) {308return $this->newAnsweredControl($result);309}310311$is_wait = $result->getIsWait();312if ($is_wait) {313return $this->newWaitControl($result);314}315316return null;317}318319private function newWaitControl(320PhabricatorAuthFactorResult $result) {321322$error = $result->getErrorMessage();323324$icon = $result->getIcon();325if (!$icon) {326$icon = id(new PHUIIconView())327->setIcon('fa-clock-o', 'red');328}329330return id(new PHUIFormTimerControl())331->setIcon($icon)332->appendChild($error)333->setError(pht('Wait'));334}335336private function newAnsweredControl(337PhabricatorAuthFactorResult $result) {338339$icon = $result->getIcon();340if (!$icon) {341$icon = id(new PHUIIconView())342->setIcon('fa-check-circle-o', 'green');343}344345return id(new PHUIFormTimerControl())346->setIcon($icon)347->appendChild(348pht('You responded to this challenge correctly.'));349}350351private function newErrorControl(352PhabricatorAuthFactorResult $result) {353354$error = $result->getErrorMessage();355356$icon = $result->getIcon();357if (!$icon) {358$icon = id(new PHUIIconView())359->setIcon('fa-times', 'red');360}361362return id(new PHUIFormTimerControl())363->setIcon($icon)364->appendChild($error)365->setError(pht('Error'));366}367368private function newContinueControl(369PhabricatorAuthFactorResult $result) {370371$error = $result->getErrorMessage();372373$icon = $result->getIcon();374if (!$icon) {375$icon = id(new PHUIIconView())376->setIcon('fa-commenting', 'green');377}378379$control = id(new PHUIFormTimerControl())380->setIcon($icon)381->appendChild($error);382383$status_challenge = $result->getStatusChallenge();384if ($status_challenge) {385$id = $status_challenge->getID();386$uri = "/auth/mfa/challenge/status/{$id}/";387$control->setUpdateURI($uri);388}389390return $control;391}392393394395/* -( Synchronizing New Factors )------------------------------------------ */396397398final protected function loadMFASyncToken(399PhabricatorAuthFactorProvider $provider,400AphrontRequest $request,401AphrontFormView $form,402PhabricatorUser $user) {403404// If the form included a synchronization key, load the corresponding405// token. The user must synchronize to a key we generated because this406// raises the barrier to theoretical attacks where an attacker might407// provide a known key for factors like TOTP.408409// (We store and verify the hash of the key, not the key itself, to limit410// how useful the data in the table is to an attacker.)411412$sync_type = PhabricatorAuthMFASyncTemporaryTokenType::TOKENTYPE;413$sync_token = null;414415$sync_key = $request->getStr($this->getMFASyncTokenFormKey());416if (phutil_nonempty_string($sync_key)) {417$sync_key_digest = PhabricatorHash::digestWithNamedKey(418$sync_key,419PhabricatorAuthMFASyncTemporaryTokenType::DIGEST_KEY);420421$sync_token = id(new PhabricatorAuthTemporaryTokenQuery())422->setViewer($user)423->withTokenResources(array($user->getPHID()))424->withTokenTypes(array($sync_type))425->withExpired(false)426->withTokenCodes(array($sync_key_digest))427->executeOne();428}429430if (!$sync_token) {431432// Don't generate a new sync token if there are too many outstanding433// tokens already. This is mostly relevant for push factors like SMS,434// where generating a token has the side effect of sending a user a435// message.436437$outstanding_limit = 10;438$outstanding_tokens = id(new PhabricatorAuthTemporaryTokenQuery())439->setViewer($user)440->withTokenResources(array($user->getPHID()))441->withTokenTypes(array($sync_type))442->withExpired(false)443->execute();444if (count($outstanding_tokens) > $outstanding_limit) {445throw new Exception(446pht(447'Your account has too many outstanding, incomplete MFA '.448'synchronization attempts. Wait an hour and try again.'));449}450451$now = PhabricatorTime::getNow();452453$sync_key = Filesystem::readRandomCharacters(32);454$sync_key_digest = PhabricatorHash::digestWithNamedKey(455$sync_key,456PhabricatorAuthMFASyncTemporaryTokenType::DIGEST_KEY);457$sync_ttl = $this->getMFASyncTokenTTL();458459$sync_token = id(new PhabricatorAuthTemporaryToken())460->setIsNewTemporaryToken(true)461->setTokenResource($user->getPHID())462->setTokenType($sync_type)463->setTokenCode($sync_key_digest)464->setTokenExpires($now + $sync_ttl);465466$properties = $this->newMFASyncTokenProperties(467$provider,468$user);469470if ($this->isAuthResult($properties)) {471return $properties;472}473474foreach ($properties as $key => $value) {475$sync_token->setTemporaryTokenProperty($key, $value);476}477478$sync_token->save();479}480481$form->addHiddenInput($this->getMFASyncTokenFormKey(), $sync_key);482483return $sync_token;484}485486protected function newMFASyncTokenProperties(487PhabricatorAuthFactorProvider $provider,488PhabricatorUser $user) {489return array();490}491492private function getMFASyncTokenFormKey() {493return 'sync.key';494}495496private function getMFASyncTokenTTL() {497return phutil_units('1 hour in seconds');498}499500final protected function getChallengeForCurrentContext(501PhabricatorAuthFactorConfig $config,502PhabricatorUser $viewer,503array $challenges) {504505$session_phid = $viewer->getSession()->getPHID();506$engine = $config->getSessionEngine();507$workflow_key = $engine->getWorkflowKey();508509foreach ($challenges as $challenge) {510if ($challenge->getSessionPHID() !== $session_phid) {511continue;512}513514if ($challenge->getWorkflowKey() !== $workflow_key) {515continue;516}517518if ($challenge->getIsCompleted()) {519continue;520}521522if ($challenge->getIsReusedChallenge()) {523continue;524}525526return $challenge;527}528529return null;530}531532533/**534* @phutil-external-symbol class QRcode535*/536final protected function newQRCode($uri) {537$root = dirname(phutil_get_library_root('phabricator'));538require_once $root.'/externals/phpqrcode/phpqrcode.php';539540$lines = QRcode::text($uri);541542$total_width = 240;543$cell_size = floor($total_width / count($lines));544545$rows = array();546foreach ($lines as $line) {547$cells = array();548for ($ii = 0; $ii < strlen($line); $ii++) {549if ($line[$ii] == '1') {550$color = '#000';551} else {552$color = '#fff';553}554555$cells[] = phutil_tag(556'td',557array(558'width' => $cell_size,559'height' => $cell_size,560'style' => 'background: '.$color,561),562'');563}564$rows[] = phutil_tag('tr', array(), $cells);565}566567return phutil_tag(568'table',569array(570'style' => 'margin: 24px auto;',571),572$rows);573}574575final protected function getInstallDisplayName() {576$uri = PhabricatorEnv::getURI('/');577$uri = new PhutilURI($uri);578return $uri->getDomain();579}580581final protected function getChallengeResponseParameterName(582PhabricatorAuthFactorConfig $config) {583return $this->getParameterName($config, 'mfa.response');584}585586final protected function getChallengeResponseFromRequest(587PhabricatorAuthFactorConfig $config,588AphrontRequest $request) {589590$name = $this->getChallengeResponseParameterName($config);591592$value = $request->getStr($name);593$value = (string)$value;594$value = trim($value);595596return $value;597}598599final protected function hasCSRF(PhabricatorAuthFactorConfig $config) {600$engine = $config->getSessionEngine();601$request = $engine->getRequest();602603if (!$request->isHTTPPost()) {604return false;605}606607return $request->validateCSRF();608}609610final protected function loadConfigurationsForProvider(611PhabricatorAuthFactorProvider $provider,612PhabricatorUser $user) {613614return id(new PhabricatorAuthFactorConfigQuery())615->setViewer($user)616->withUserPHIDs(array($user->getPHID()))617->withFactorProviderPHIDs(array($provider->getPHID()))618->execute();619}620621final protected function isAuthResult($object) {622return ($object instanceof PhabricatorAuthFactorResult);623}624625}626627628