diff --git a/resources/sql/autopatches/20181217.auth.01.digest.sql b/resources/sql/autopatches/20181217.auth.01.digest.sql
new file mode 100644
index 0000000000..8e30143e8f
--- /dev/null
+++ b/resources/sql/autopatches/20181217.auth.01.digest.sql
@@ -0,0 +1,2 @@
+ALTER TABLE {$NAMESPACE}_auth.auth_challenge
+  ADD responseDigest VARCHAR(255) COLLATE {$COLLATE_TEXT};
diff --git a/resources/sql/autopatches/20181217.auth.02.ttl.sql b/resources/sql/autopatches/20181217.auth.02.ttl.sql
new file mode 100644
index 0000000000..c8e883dbea
--- /dev/null
+++ b/resources/sql/autopatches/20181217.auth.02.ttl.sql
@@ -0,0 +1,2 @@
+ALTER TABLE {$NAMESPACE}_auth.auth_challenge
+  ADD responseTTL INT UNSIGNED;
diff --git a/resources/sql/autopatches/20181217.auth.03.completed.sql b/resources/sql/autopatches/20181217.auth.03.completed.sql
new file mode 100644
index 0000000000..22ca6e21ff
--- /dev/null
+++ b/resources/sql/autopatches/20181217.auth.03.completed.sql
@@ -0,0 +1,2 @@
+ALTER TABLE {$NAMESPACE}_auth.auth_challenge
+  ADD isCompleted BOOL NOT NULL;
diff --git a/src/applications/auth/engine/PhabricatorAuthSessionEngine.php b/src/applications/auth/engine/PhabricatorAuthSessionEngine.php
index f3814b949d..74183f4ffa 100644
--- a/src/applications/auth/engine/PhabricatorAuthSessionEngine.php
+++ b/src/applications/auth/engine/PhabricatorAuthSessionEngine.php
@@ -1,1040 +1,1052 @@
 <?php
 
 /**
  *
  * @task use      Using Sessions
  * @task new      Creating Sessions
  * @task hisec    High Security
  * @task partial  Partial Sessions
  * @task onetime  One Time Login URIs
  * @task cache    User Cache
  */
 final class PhabricatorAuthSessionEngine extends Phobject {
 
   /**
    * Session issued to normal users after they login through a standard channel.
    * Associates the client with a standard user identity.
    */
   const KIND_USER      = 'U';
 
 
   /**
    * Session issued to users who login with some sort of credentials but do not
    * have full accounts. These are sometimes called "grey users".
    *
    * TODO: We do not currently issue these sessions, see T4310.
    */
   const KIND_EXTERNAL  = 'X';
 
 
   /**
    * Session issued to logged-out users which has no real identity information.
    * Its purpose is to protect logged-out users from CSRF.
    */
   const KIND_ANONYMOUS = 'A';
 
 
   /**
    * Session kind isn't known.
    */
   const KIND_UNKNOWN   = '?';
 
 
   const ONETIME_RECOVER = 'recover';
   const ONETIME_RESET = 'reset';
   const ONETIME_WELCOME = 'welcome';
   const ONETIME_USERNAME = 'rename';
 
 
   private $workflowKey;
 
   public function setWorkflowKey($workflow_key) {
     $this->workflowKey = $workflow_key;
     return $this;
   }
 
   public function getWorkflowKey() {
 
     // TODO: A workflow key should become required in order to issue an MFA
     // challenge, but allow things to keep working for now until we can update
     // callsites.
     if ($this->workflowKey === null) {
       return 'legacy';
     }
 
     return $this->workflowKey;
   }
 
 
   /**
    * Get the session kind (e.g., anonymous, user, external account) from a
    * session token. Returns a `KIND_` constant.
    *
    * @param   string  Session token.
    * @return  const   Session kind constant.
    */
   public static function getSessionKindFromToken($session_token) {
     if (strpos($session_token, '/') === false) {
       // Old-style session, these are all user sessions.
       return self::KIND_USER;
     }
 
     list($kind, $key) = explode('/', $session_token, 2);
 
     switch ($kind) {
       case self::KIND_ANONYMOUS:
       case self::KIND_USER:
       case self::KIND_EXTERNAL:
         return $kind;
       default:
         return self::KIND_UNKNOWN;
     }
   }
 
 
   /**
    * Load the user identity associated with a session of a given type,
    * identified by token.
    *
    * When the user presents a session token to an API, this method verifies
    * it is of the correct type and loads the corresponding identity if the
    * session exists and is valid.
    *
    * NOTE: `$session_type` is the type of session that is required by the
    * loading context. This prevents use of a Conduit sesssion as a Web
    * session, for example.
    *
    * @param const The type of session to load.
    * @param string The session token.
    * @return PhabricatorUser|null
    * @task use
    */
   public function loadUserForSession($session_type, $session_token) {
     $session_kind = self::getSessionKindFromToken($session_token);
     switch ($session_kind) {
       case self::KIND_ANONYMOUS:
         // Don't bother trying to load a user for an anonymous session, since
         // neither the session nor the user exist.
         return null;
       case self::KIND_UNKNOWN:
         // If we don't know what kind of session this is, don't go looking for
         // it.
         return null;
       case self::KIND_USER:
         break;
       case self::KIND_EXTERNAL:
         // TODO: Implement these (T4310).
         return null;
     }
 
     $session_table = new PhabricatorAuthSession();
     $user_table = new PhabricatorUser();
     $conn = $session_table->establishConnection('r');
 
     // TODO: See T13225. We're moving sessions to a more modern digest
     // algorithm, but still accept older cookies for compatibility.
     $session_key = PhabricatorAuthSession::newSessionDigest(
       new PhutilOpaqueEnvelope($session_token));
     $weak_key = PhabricatorHash::weakDigest($session_token);
 
     $cache_parts = $this->getUserCacheQueryParts($conn);
     list($cache_selects, $cache_joins, $cache_map, $types_map) = $cache_parts;
 
     $info = queryfx_one(
       $conn,
       'SELECT
           s.id AS s_id,
           s.phid AS s_phid,
           s.sessionExpires AS s_sessionExpires,
           s.sessionStart AS s_sessionStart,
           s.highSecurityUntil AS s_highSecurityUntil,
           s.isPartial AS s_isPartial,
           s.signedLegalpadDocuments as s_signedLegalpadDocuments,
           IF(s.sessionKey = %P, 1, 0) as s_weak,
           u.*
           %Q
         FROM %R u JOIN %R s ON u.phid = s.userPHID
         AND s.type = %s AND s.sessionKey IN (%P, %P) %Q',
       new PhutilOpaqueEnvelope($weak_key),
       $cache_selects,
       $user_table,
       $session_table,
       $session_type,
       new PhutilOpaqueEnvelope($session_key),
       new PhutilOpaqueEnvelope($weak_key),
       $cache_joins);
 
     if (!$info) {
       return null;
     }
 
     // TODO: Remove this, see T13225.
     $is_weak = (bool)$info['s_weak'];
     unset($info['s_weak']);
 
     $session_dict = array(
       'userPHID' => $info['phid'],
       'sessionKey' => $session_key,
       'type' => $session_type,
     );
 
     $cache_raw = array_fill_keys($cache_map, null);
     foreach ($info as $key => $value) {
       if (strncmp($key, 's_', 2) === 0) {
         unset($info[$key]);
         $session_dict[substr($key, 2)] = $value;
         continue;
       }
 
       if (isset($cache_map[$key])) {
         unset($info[$key]);
         $cache_raw[$cache_map[$key]] = $value;
         continue;
       }
     }
 
     $user = $user_table->loadFromArray($info);
 
     $cache_raw = $this->filterRawCacheData($user, $types_map, $cache_raw);
     $user->attachRawCacheData($cache_raw);
 
     switch ($session_type) {
       case PhabricatorAuthSession::TYPE_WEB:
         // Explicitly prevent bots and mailing lists from establishing web
         // sessions. It's normally impossible to attach authentication to these
         // accounts, and likewise impossible to generate sessions, but it's
         // technically possible that a session could exist in the database. If
         // one does somehow, refuse to load it.
         if (!$user->canEstablishWebSessions()) {
           return null;
         }
         break;
     }
 
     $session = id(new PhabricatorAuthSession())->loadFromArray($session_dict);
 
     $ttl = PhabricatorAuthSession::getSessionTypeTTL($session_type);
 
     // If more than 20% of the time on this session has been used, refresh the
     // TTL back up to the full duration. The idea here is that sessions are
     // good forever if used regularly, but get GC'd when they fall out of use.
 
     // NOTE: If we begin rotating session keys when extending sessions, the
     // CSRF code needs to be updated so CSRF tokens survive session rotation.
 
     if (time() + (0.80 * $ttl) > $session->getSessionExpires()) {
       $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
         $conn_w = $session_table->establishConnection('w');
         queryfx(
           $conn_w,
           'UPDATE %T SET sessionExpires = UNIX_TIMESTAMP() + %d WHERE id = %d',
           $session->getTableName(),
           $ttl,
           $session->getID());
       unset($unguarded);
     }
 
     // TODO: Remove this, see T13225.
     if ($is_weak) {
       $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
         $conn_w = $session_table->establishConnection('w');
         queryfx(
           $conn_w,
           'UPDATE %T SET sessionKey = %P WHERE id = %d',
           $session->getTableName(),
           new PhutilOpaqueEnvelope($session_key),
           $session->getID());
       unset($unguarded);
     }
 
     $user->attachSession($session);
     return $user;
   }
 
 
   /**
    * Issue a new session key for a given identity. Phabricator supports
    * different types of sessions (like "web" and "conduit") and each session
    * type may have multiple concurrent sessions (this allows a user to be
    * logged in on multiple browsers at the same time, for instance).
    *
    * Note that this method is transport-agnostic and does not set cookies or
    * issue other types of tokens, it ONLY generates a new session key.
    *
    * You can configure the maximum number of concurrent sessions for various
    * session types in the Phabricator configuration.
    *
    * @param   const     Session type constant (see
    *                    @{class:PhabricatorAuthSession}).
    * @param   phid|null Identity to establish a session for, usually a user
    *                    PHID. With `null`, generates an anonymous session.
    * @param   bool      True to issue a partial session.
    * @return  string    Newly generated session key.
    */
   public function establishSession($session_type, $identity_phid, $partial) {
     // Consume entropy to generate a new session key, forestalling the eventual
     // heat death of the universe.
     $session_key = Filesystem::readRandomCharacters(40);
 
     if ($identity_phid === null) {
       return self::KIND_ANONYMOUS.'/'.$session_key;
     }
 
     $session_table = new PhabricatorAuthSession();
     $conn_w = $session_table->establishConnection('w');
 
     // This has a side effect of validating the session type.
     $session_ttl = PhabricatorAuthSession::getSessionTypeTTL($session_type);
 
     $digest_key = PhabricatorAuthSession::newSessionDigest(
       new PhutilOpaqueEnvelope($session_key));
 
     // Logging-in users don't have CSRF stuff yet, so we have to unguard this
     // write.
     $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
       id(new PhabricatorAuthSession())
         ->setUserPHID($identity_phid)
         ->setType($session_type)
         ->setSessionKey($digest_key)
         ->setSessionStart(time())
         ->setSessionExpires(time() + $session_ttl)
         ->setIsPartial($partial ? 1 : 0)
         ->setSignedLegalpadDocuments(0)
         ->save();
 
       $log = PhabricatorUserLog::initializeNewLog(
         null,
         $identity_phid,
         ($partial
           ? PhabricatorUserLog::ACTION_LOGIN_PARTIAL
           : PhabricatorUserLog::ACTION_LOGIN));
 
       $log->setDetails(
         array(
           'session_type' => $session_type,
         ));
       $log->setSession($digest_key);
       $log->save();
     unset($unguarded);
 
     $info = id(new PhabricatorAuthSessionInfo())
       ->setSessionType($session_type)
       ->setIdentityPHID($identity_phid)
       ->setIsPartial($partial);
 
     $extensions = PhabricatorAuthSessionEngineExtension::getAllExtensions();
     foreach ($extensions as $extension) {
       $extension->didEstablishSession($info);
     }
 
     return $session_key;
   }
 
 
   /**
    * Terminate all of a user's login sessions.
    *
    * This is used when users change passwords, linked accounts, or add
    * multifactor authentication.
    *
    * @param PhabricatorUser User whose sessions should be terminated.
    * @param string|null Optionally, one session to keep. Normally, the current
    *   login session.
    *
    * @return void
    */
   public function terminateLoginSessions(
     PhabricatorUser $user,
     PhutilOpaqueEnvelope $except_session = null) {
 
     $sessions = id(new PhabricatorAuthSessionQuery())
       ->setViewer($user)
       ->withIdentityPHIDs(array($user->getPHID()))
       ->execute();
 
     if ($except_session !== null) {
       $except_session = PhabricatorAuthSession::newSessionDigest(
         $except_session);
     }
 
     foreach ($sessions as $key => $session) {
       if ($except_session !== null) {
         $is_except = phutil_hashes_are_identical(
           $session->getSessionKey(),
           $except_session);
         if ($is_except) {
           continue;
         }
       }
 
       $session->delete();
     }
   }
 
   public function logoutSession(
     PhabricatorUser $user,
     PhabricatorAuthSession $session) {
 
     $log = PhabricatorUserLog::initializeNewLog(
       $user,
       $user->getPHID(),
       PhabricatorUserLog::ACTION_LOGOUT);
     $log->save();
 
     $extensions = PhabricatorAuthSessionEngineExtension::getAllExtensions();
     foreach ($extensions as $extension) {
       $extension->didLogout($user, array($session));
     }
 
     $session->delete();
   }
 
 
 /* -(  High Security  )------------------------------------------------------ */
 
 
   /**
    * Require the user respond to a high security (MFA) check.
    *
    * This method differs from @{method:requireHighSecuritySession} in that it
    * does not upgrade the user's session as a side effect. This method is
    * appropriate for one-time checks.
    *
    * @param PhabricatorUser User whose session needs to be in high security.
    * @param AphrontReqeust  Current request.
    * @param string          URI to return the user to if they cancel.
    * @return PhabricatorAuthHighSecurityToken Security token.
    * @task hisec
    */
   public function requireHighSecurityToken(
     PhabricatorUser $viewer,
     AphrontRequest $request,
     $cancel_uri) {
 
     return $this->newHighSecurityToken(
       $viewer,
       $request,
       $cancel_uri,
       false,
       false);
   }
 
 
   /**
    * Require high security, or prompt the user to enter high security.
    *
    * If the user's session is in high security, this method will return a
    * token. Otherwise, it will throw an exception which will eventually
    * be converted into a multi-factor authentication workflow.
    *
    * This method upgrades the user's session to high security for a short
    * period of time, and is appropriate if you anticipate they may need to
    * take multiple high security actions. To perform a one-time check instead,
    * use @{method:requireHighSecurityToken}.
    *
    * @param PhabricatorUser User whose session needs to be in high security.
    * @param AphrontReqeust  Current request.
    * @param string          URI to return the user to if they cancel.
    * @param bool            True to jump partial sessions directly into high
    *                        security instead of just upgrading them to full
    *                        sessions.
    * @return PhabricatorAuthHighSecurityToken Security token.
    * @task hisec
    */
   public function requireHighSecuritySession(
     PhabricatorUser $viewer,
     AphrontRequest $request,
     $cancel_uri,
     $jump_into_hisec = false) {
 
     return $this->newHighSecurityToken(
       $viewer,
       $request,
       $cancel_uri,
       false,
       true);
   }
 
   private function newHighSecurityToken(
     PhabricatorUser $viewer,
     AphrontRequest $request,
     $cancel_uri,
     $jump_into_hisec,
     $upgrade_session) {
 
     if (!$viewer->hasSession()) {
       throw new Exception(
         pht('Requiring a high-security session from a user with no session!'));
     }
 
     // TODO: If a user answers a "requireHighSecurityToken()" prompt and hits
     // a "requireHighSecuritySession()" prompt a short time later, the one-shot
     // token should be good enough to upgrade the session.
 
     $session = $viewer->getSession();
 
     // Check if the session is already in high security mode.
     $token = $this->issueHighSecurityToken($session);
     if ($token) {
       return $token;
     }
 
     // Load the multi-factor auth sources attached to this account.
     $factors = id(new PhabricatorAuthFactorConfig())->loadAllWhere(
       'userPHID = %s',
       $viewer->getPHID());
 
     // If the account has no associated multi-factor auth, just issue a token
     // without putting the session into high security mode. This is generally
     // easier for users. A minor but desirable side effect is that when a user
     // adds an auth factor, existing sessions won't get a free pass into hisec,
     // since they never actually got marked as hisec.
     if (!$factors) {
       return $this->issueHighSecurityToken($session, true);
     }
 
     foreach ($factors as $factor) {
       $factor->setSessionEngine($this);
     }
 
     // Check for a rate limit without awarding points, so the user doesn't
     // get partway through the workflow only to get blocked.
     PhabricatorSystemActionEngine::willTakeAction(
       array($viewer->getPHID()),
       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)) {
 
         // Limit factor verification rates to prevent brute force attacks.
         PhabricatorSystemActionEngine::willTakeAction(
           array($viewer->getPHID()),
           new PhabricatorAuthTryFactorAction(),
           1);
 
         foreach ($factors as $factor) {
           $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;
           }
 
+          $issued_challenges = idx($challenge_map, $factor_phid, array());
+
           $impl = $factor->requireImplementation();
 
           $validation_result = $impl->getResultFromChallengeResponse(
             $factor,
             $viewer,
             $request,
             $issued_challenges);
 
           if (!$validation_result->getIsValid()) {
             $ok = false;
           }
 
           $validation_results[$factor_phid] = $validation_result;
         }
 
         if ($ok) {
+          // We're letting you through, so mark all the challenges you
+          // responded to as completed. These challenges can never be used
+          // again, even by the same session and workflow: you can't use the
+          // same response to take two different actions, even if those actions
+          // are of the same type.
+          foreach ($validation_results as $validation_result) {
+            $challenge = $validation_result->getAnsweredChallenge()
+              ->markChallengeAsCompleted();
+          }
+
           // Give the user a credit back for a successful factor verification.
           PhabricatorSystemActionEngine::willTakeAction(
             array($viewer->getPHID()),
             new PhabricatorAuthTryFactorAction(),
             -1);
 
           if ($session->getIsPartial() && !$jump_into_hisec) {
             // If we have a partial session and are not jumping directly into
             // hisec, just issue a token without putting it in high security
             // mode.
             return $this->issueHighSecurityToken($session, true);
           }
 
           // If we aren't upgrading the session itself, just issue a token.
           if (!$upgrade_session) {
             return $this->issueHighSecurityToken($session, true);
           }
 
           $until = time() + phutil_units('15 minutes in seconds');
           $session->setHighSecurityUntil($until);
 
           queryfx(
             $session->establishConnection('w'),
             'UPDATE %T SET highSecurityUntil = %d WHERE id = %d',
             $session->getTableName(),
             $until,
             $session->getID());
 
           $log = PhabricatorUserLog::initializeNewLog(
             $viewer,
             $viewer->getPHID(),
             PhabricatorUserLog::ACTION_ENTER_HISEC);
           $log->save();
         } else {
           $log = PhabricatorUserLog::initializeNewLog(
             $viewer,
             $viewer->getPHID(),
             PhabricatorUserLog::ACTION_FAIL_HISEC);
           $log->save();
         }
       }
     }
 
     $token = $this->issueHighSecurityToken($session);
     if ($token) {
       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)
       ->setFactorValidationResults($validation_results);
   }
 
 
   /**
    * Issue a high security token for a session, if authorized.
    *
    * @param PhabricatorAuthSession Session to issue a token for.
    * @param bool Force token issue.
    * @return PhabricatorAuthHighSecurityToken|null Token, if authorized.
    * @task hisec
    */
   private function issueHighSecurityToken(
     PhabricatorAuthSession $session,
     $force = false) {
 
     if ($session->isHighSecuritySession() || $force) {
       return new PhabricatorAuthHighSecurityToken();
     }
 
     return null;
   }
 
 
   /**
    * Render a form for providing relevant multi-factor credentials.
    *
    * @param PhabricatorUser Viewing user.
    * @param AphrontRequest Current request.
    * @return AphrontFormView Renderable form.
    * @task hisec
    */
   public function renderHighSecurityForm(
     array $factors,
     array $validation_results,
     PhabricatorUser $viewer,
     AphrontRequest $request) {
     assert_instances_of($validation_results, 'PhabricatorAuthFactorResult');
 
     $form = id(new AphrontFormView())
       ->setUser($viewer)
       ->appendRemarkupInstructions('');
 
     foreach ($factors as $factor) {
       $result = $validation_results[$factor->getPHID()];
 
       $factor->requireImplementation()->renderValidateFactorForm(
         $factor,
         $form,
         $viewer,
         $result);
     }
 
     $form->appendRemarkupInstructions('');
 
     return $form;
   }
 
 
   /**
    * Strip the high security flag from a session.
    *
    * Kicks a session out of high security and logs the exit.
    *
    * @param PhabricatorUser Acting user.
    * @param PhabricatorAuthSession Session to return to normal security.
    * @return void
    * @task hisec
    */
   public function exitHighSecurity(
     PhabricatorUser $viewer,
     PhabricatorAuthSession $session) {
 
     if (!$session->getHighSecurityUntil()) {
       return;
     }
 
     queryfx(
       $session->establishConnection('w'),
       'UPDATE %T SET highSecurityUntil = NULL WHERE id = %d',
       $session->getTableName(),
       $session->getID());
 
     $log = PhabricatorUserLog::initializeNewLog(
       $viewer,
       $viewer->getPHID(),
       PhabricatorUserLog::ACTION_EXIT_HISEC);
     $log->save();
   }
 
 
 /* -(  Partial Sessions  )--------------------------------------------------- */
 
 
   /**
    * Upgrade a partial session to a full session.
    *
    * @param PhabricatorAuthSession Session to upgrade.
    * @return void
    * @task partial
    */
   public function upgradePartialSession(PhabricatorUser $viewer) {
 
     if (!$viewer->hasSession()) {
       throw new Exception(
         pht('Upgrading partial session of user with no session!'));
     }
 
     $session = $viewer->getSession();
 
     if (!$session->getIsPartial()) {
       throw new Exception(pht('Session is not partial!'));
     }
 
     $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
       $session->setIsPartial(0);
 
       queryfx(
         $session->establishConnection('w'),
         'UPDATE %T SET isPartial = %d WHERE id = %d',
         $session->getTableName(),
         0,
         $session->getID());
 
       $log = PhabricatorUserLog::initializeNewLog(
         $viewer,
         $viewer->getPHID(),
         PhabricatorUserLog::ACTION_LOGIN_FULL);
       $log->save();
     unset($unguarded);
   }
 
 
 /* -(  Legalpad Documents )-------------------------------------------------- */
 
 
   /**
    * Upgrade a session to have all legalpad documents signed.
    *
    * @param PhabricatorUser User whose session should upgrade.
    * @param array LegalpadDocument objects
    * @return void
    * @task partial
    */
   public function signLegalpadDocuments(PhabricatorUser $viewer, array $docs) {
 
     if (!$viewer->hasSession()) {
       throw new Exception(
         pht('Signing session legalpad documents of user with no session!'));
     }
 
     $session = $viewer->getSession();
 
     if ($session->getSignedLegalpadDocuments()) {
       throw new Exception(pht(
         'Session has already signed required legalpad documents!'));
     }
 
     $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
       $session->setSignedLegalpadDocuments(1);
 
       queryfx(
         $session->establishConnection('w'),
         'UPDATE %T SET signedLegalpadDocuments = %d WHERE id = %d',
         $session->getTableName(),
         1,
         $session->getID());
 
       if (!empty($docs)) {
         $log = PhabricatorUserLog::initializeNewLog(
           $viewer,
           $viewer->getPHID(),
           PhabricatorUserLog::ACTION_LOGIN_LEGALPAD);
         $log->save();
       }
     unset($unguarded);
   }
 
 
 /* -(  One Time Login URIs  )------------------------------------------------ */
 
 
   /**
    * Retrieve a temporary, one-time URI which can log in to an account.
    *
    * These URIs are used for password recovery and to regain access to accounts
    * which users have been locked out of.
    *
    * @param PhabricatorUser User to generate a URI for.
    * @param PhabricatorUserEmail Optionally, email to verify when
    *  link is used.
    * @param string Optional context string for the URI. This is purely cosmetic
    *  and used only to customize workflow and error messages.
    * @return string Login URI.
    * @task onetime
    */
   public function getOneTimeLoginURI(
     PhabricatorUser $user,
     PhabricatorUserEmail $email = null,
     $type = self::ONETIME_RESET) {
 
     $key = Filesystem::readRandomCharacters(32);
     $key_hash = $this->getOneTimeLoginKeyHash($user, $email, $key);
     $onetime_type = PhabricatorAuthOneTimeLoginTemporaryTokenType::TOKENTYPE;
 
     $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
       id(new PhabricatorAuthTemporaryToken())
         ->setTokenResource($user->getPHID())
         ->setTokenType($onetime_type)
         ->setTokenExpires(time() + phutil_units('1 day in seconds'))
         ->setTokenCode($key_hash)
         ->save();
     unset($unguarded);
 
     $uri = '/login/once/'.$type.'/'.$user->getID().'/'.$key.'/';
     if ($email) {
       $uri = $uri.$email->getID().'/';
     }
 
     try {
       $uri = PhabricatorEnv::getProductionURI($uri);
     } catch (Exception $ex) {
       // If a user runs `bin/auth recover` before configuring the base URI,
       // just show the path. We don't have any way to figure out the domain.
       // See T4132.
     }
 
     return $uri;
   }
 
 
   /**
    * Load the temporary token associated with a given one-time login key.
    *
    * @param PhabricatorUser User to load the token for.
    * @param PhabricatorUserEmail Optionally, email to verify when
    *  link is used.
    * @param string Key user is presenting as a valid one-time login key.
    * @return PhabricatorAuthTemporaryToken|null Token, if one exists.
    * @task onetime
    */
   public function loadOneTimeLoginKey(
     PhabricatorUser $user,
     PhabricatorUserEmail $email = null,
     $key = null) {
 
     $key_hash = $this->getOneTimeLoginKeyHash($user, $email, $key);
     $onetime_type = PhabricatorAuthOneTimeLoginTemporaryTokenType::TOKENTYPE;
 
     return id(new PhabricatorAuthTemporaryTokenQuery())
       ->setViewer($user)
       ->withTokenResources(array($user->getPHID()))
       ->withTokenTypes(array($onetime_type))
       ->withTokenCodes(array($key_hash))
       ->withExpired(false)
       ->executeOne();
   }
 
 
   /**
    * Hash a one-time login key for storage as a temporary token.
    *
    * @param PhabricatorUser User this key is for.
    * @param PhabricatorUserEmail Optionally, email to verify when
    *  link is used.
    * @param string The one time login key.
    * @return string Hash of the key.
    * task onetime
    */
   private function getOneTimeLoginKeyHash(
     PhabricatorUser $user,
     PhabricatorUserEmail $email = null,
     $key = null) {
 
     $parts = array(
       $key,
       $user->getAccountSecret(),
     );
 
     if ($email) {
       $parts[] = $email->getVerificationCode();
     }
 
     return PhabricatorHash::weakDigest(implode(':', $parts));
   }
 
 
 /* -(  User Cache  )--------------------------------------------------------- */
 
 
   /**
    * @task cache
    */
   private function getUserCacheQueryParts(AphrontDatabaseConnection $conn) {
     $cache_selects = array();
     $cache_joins = array();
     $cache_map = array();
 
     $keys = array();
     $types_map = array();
 
     $cache_types = PhabricatorUserCacheType::getAllCacheTypes();
     foreach ($cache_types as $cache_type) {
       foreach ($cache_type->getAutoloadKeys() as $autoload_key) {
         $keys[] = $autoload_key;
         $types_map[$autoload_key] = $cache_type;
       }
     }
 
     $cache_table = id(new PhabricatorUserCache())->getTableName();
 
     $cache_idx = 1;
     foreach ($keys as $key) {
       $join_as = 'ucache_'.$cache_idx;
       $select_as = 'ucache_'.$cache_idx.'_v';
 
       $cache_selects[] = qsprintf(
         $conn,
         '%T.cacheData %T',
         $join_as,
         $select_as);
 
       $cache_joins[] = qsprintf(
         $conn,
         'LEFT JOIN %T AS %T ON u.phid = %T.userPHID
           AND %T.cacheIndex = %s',
         $cache_table,
         $join_as,
         $join_as,
         $join_as,
         PhabricatorHash::digestForIndex($key));
 
       $cache_map[$select_as] = $key;
 
       $cache_idx++;
     }
 
     if ($cache_selects) {
       $cache_selects = qsprintf($conn, ', %LQ', $cache_selects);
     } else {
       $cache_selects = qsprintf($conn, '');
     }
 
     if ($cache_joins) {
       $cache_joins = qsprintf($conn, '%LJ', $cache_joins);
     } else {
       $cache_joins = qsprintf($conn, '');
     }
 
     return array($cache_selects, $cache_joins, $cache_map, $types_map);
   }
 
   private function filterRawCacheData(
     PhabricatorUser $user,
     array $types_map,
     array $cache_raw) {
 
     foreach ($cache_raw as $cache_key => $cache_data) {
       $type = $types_map[$cache_key];
       if ($type->shouldValidateRawCacheData()) {
         if (!$type->isRawCacheDataValid($user, $cache_key, $cache_data)) {
           unset($cache_raw[$cache_key]);
         }
       }
     }
 
     return $cache_raw;
   }
 
   public function willServeRequestForUser(PhabricatorUser $user) {
     // We allow the login user to generate any missing cache data inline.
     $user->setAllowInlineCacheGeneration(true);
 
     // Switch to the user's translation.
     PhabricatorEnv::setLocaleCode($user->getTranslation());
 
     $extensions = PhabricatorAuthSessionEngineExtension::getAllExtensions();
     foreach ($extensions as $extension) {
       $extension->willServeRequestForUser($user);
     }
   }
 
 }
diff --git a/src/applications/auth/factor/PhabricatorAuthFactor.php b/src/applications/auth/factor/PhabricatorAuthFactor.php
index be99df9c79..8cd61f089d 100644
--- a/src/applications/auth/factor/PhabricatorAuthFactor.php
+++ b/src/applications/auth/factor/PhabricatorAuthFactor.php
@@ -1,168 +1,168 @@
 <?php
 
 abstract class PhabricatorAuthFactor extends Phobject {
 
   abstract public function getFactorName();
   abstract public function getFactorKey();
   abstract public function getFactorDescription();
   abstract public function processAddFactorForm(
     AphrontFormView $form,
     AphrontRequest $request,
     PhabricatorUser $user);
 
   abstract public function renderValidateFactorForm(
     PhabricatorAuthFactorConfig $config,
     AphrontFormView $form,
     PhabricatorUser $viewer,
     PhabricatorAuthFactorResult $validation_result);
 
   public function getParameterName(
     PhabricatorAuthFactorConfig $config,
     $name) {
     return 'authfactor.'.$config->getID().'.'.$name;
   }
 
   public static function getAllFactors() {
     return id(new PhutilClassMapQuery())
       ->setAncestorClass(__CLASS__)
       ->setUniqueMethod('getFactorKey')
       ->execute();
   }
 
   protected function newConfigForUser(PhabricatorUser $user) {
     return id(new PhabricatorAuthFactorConfig())
       ->setUserPHID($user->getPHID())
       ->setFactorKey($this->getFactorKey());
   }
 
   protected function newResult() {
     return new PhabricatorAuthFactorResult();
   }
 
   protected function newChallenge(
     PhabricatorAuthFactorConfig $config,
     PhabricatorUser $viewer) {
 
     $engine = $config->getSessionEngine();
 
-    return id(new PhabricatorAuthChallenge())
+    return PhabricatorAuthChallenge::initializeNewChallenge()
       ->setUserPHID($viewer->getPHID())
       ->setSessionPHID($viewer->getSession()->getPHID())
       ->setFactorPHID($config->getPHID())
       ->setWorkflowKey($engine->getWorkflowKey());
   }
 
   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);
 
 }
diff --git a/src/applications/auth/factor/PhabricatorAuthFactorResult.php b/src/applications/auth/factor/PhabricatorAuthFactorResult.php
index d75480747d..faa25b4f42 100644
--- a/src/applications/auth/factor/PhabricatorAuthFactorResult.php
+++ b/src/applications/auth/factor/PhabricatorAuthFactorResult.php
@@ -1,58 +1,75 @@
 <?php
 
 final class PhabricatorAuthFactorResult
   extends Phobject {
 
-  private $isValid = false;
+  private $answeredChallenge;
   private $isWait = false;
   private $errorMessage;
   private $value;
   private $issuedChallenges = array();
 
-  public function setIsValid($is_valid) {
-    $this->isValid = $is_valid;
+  public function setAnsweredChallenge(PhabricatorAuthChallenge $challenge) {
+    if (!$challenge->getIsAnsweredChallenge()) {
+      throw new PhutilInvalidStateException('markChallengeAsAnswered');
+    }
+
+    if ($challenge->getIsCompleted()) {
+      throw new Exception(
+        pht(
+          'A completed challenge was provided as an answered challenge. '.
+          'The underlying factor is implemented improperly, challenges '.
+          'may not be reused.'));
+    }
+
+    $this->answeredChallenge = $challenge;
+
     return $this;
   }
 
+  public function getAnsweredChallenge() {
+    return $this->answeredChallenge;
+  }
+
   public function getIsValid() {
-    return $this->isValid;
+    return (bool)$this->getAnsweredChallenge();
   }
 
   public function setIsWait($is_wait) {
     $this->isWait = $is_wait;
     return $this;
   }
 
   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) {
     $this->value = $value;
     return $this;
   }
 
   public function getValue() {
     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;
   }
 
 }
diff --git a/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php b/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php
index e2f0dc74b3..0984347197 100644
--- a/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php
+++ b/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php
@@ -1,476 +1,496 @@
 <?php
 
 final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor {
 
   const DIGEST_TEMPORARY_KEY = 'mfa.totp.sync';
 
   public function getFactorKey() {
     return 'totp';
   }
 
   public function getFactorName() {
     return pht('Mobile Phone App (TOTP)');
   }
 
   public function getFactorDescription() {
     return pht(
       'Attach a mobile authenticator application (like Authy '.
       'or Google Authenticator) to your account. When you need to '.
       'authenticate, you will enter a code shown on your phone.');
   }
 
   public function processAddFactorForm(
     AphrontFormView $form,
     AphrontRequest $request,
     PhabricatorUser $user) {
 
     $totp_token_type = PhabricatorAuthTOTPKeyTemporaryTokenType::TOKENTYPE;
 
     $key = $request->getStr('totpkey');
     if (strlen($key)) {
       // If the user is providing a key, make sure it's a key we generated.
       // This raises the barrier to theoretical attacks where an attacker might
       // provide a known key (such attacks are already prevented by CSRF, but
       // this is a second barrier to overcome).
 
       // (We store and verify the hash of the key, not the key itself, to limit
       // how useful the data in the table is to an attacker.)
 
       $token_code = PhabricatorHash::digestWithNamedKey(
         $key,
         self::DIGEST_TEMPORARY_KEY);
 
       $temporary_token = id(new PhabricatorAuthTemporaryTokenQuery())
         ->setViewer($user)
         ->withTokenResources(array($user->getPHID()))
         ->withTokenTypes(array($totp_token_type))
         ->withExpired(false)
         ->withTokenCodes(array($token_code))
         ->executeOne();
       if (!$temporary_token) {
         // If we don't have a matching token, regenerate the key below.
         $key = null;
       }
     }
 
     if (!strlen($key)) {
       $key = self::generateNewTOTPKey();
 
       // Mark this key as one we generated, so the user is allowed to submit
       // a response for it.
 
       $token_code = PhabricatorHash::digestWithNamedKey(
         $key,
         self::DIGEST_TEMPORARY_KEY);
 
       $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
         id(new PhabricatorAuthTemporaryToken())
           ->setTokenResource($user->getPHID())
           ->setTokenType($totp_token_type)
           ->setTokenExpires(time() + phutil_units('1 hour in seconds'))
           ->setTokenCode($token_code)
           ->save();
       unset($unguarded);
     }
 
     $code = $request->getStr('totpcode');
 
     $e_code = true;
     if ($request->getExists('totp')) {
       $okay = (bool)$this->getTimestepAtWhichResponseIsValid(
         $this->getAllowedTimesteps($this->getCurrentTimestep()),
         new PhutilOpaqueEnvelope($key),
         (string)$code);
 
       if ($okay) {
         $config = $this->newConfigForUser($user)
           ->setFactorName(pht('Mobile App (TOTP)'))
           ->setFactorSecret($key);
 
         return $config;
       } else {
         if (!strlen($code)) {
           $e_code = pht('Required');
         } else {
           $e_code = pht('Invalid');
         }
       }
     }
 
     $form->addHiddenInput('totp', true);
     $form->addHiddenInput('totpkey', $key);
 
     $form->appendRemarkupInstructions(
       pht(
         'First, download an authenticator application on your phone. Two '.
         'applications which work well are **Authy** and **Google '.
         'Authenticator**, but any other TOTP application should also work.'));
 
     $form->appendInstructions(
       pht(
         'Launch the application on your phone, and add a new entry for '.
         'this Phabricator install. When prompted, scan the QR code or '.
         'manually enter the key shown below into the application.'));
 
     $prod_uri = new PhutilURI(PhabricatorEnv::getProductionURI('/'));
     $issuer = $prod_uri->getDomain();
 
     $uri = urisprintf(
       'otpauth://totp/%s:%s?secret=%s&issuer=%s',
       $issuer,
       $user->getUsername(),
       $key,
       $issuer);
 
     $qrcode = $this->renderQRCode($uri);
     $form->appendChild($qrcode);
 
     $form->appendChild(
       id(new AphrontFormStaticControl())
         ->setLabel(pht('Key'))
         ->setValue(phutil_tag('strong', array(), $key)));
 
     $form->appendInstructions(
       pht(
         '(If given an option, select that this key is "Time Based", not '.
         '"Counter Based".)'));
 
     $form->appendInstructions(
       pht(
         'After entering the key, the application should display a numeric '.
         'code. Enter that code below to confirm that you have configured '.
         'the authenticator correctly:'));
 
     $form->appendChild(
       id(new PHUIFormNumberControl())
         ->setLabel(pht('TOTP Code'))
         ->setName('totpcode')
         ->setValue($code)
         ->setError($e_code));
 
   }
 
   protected function newIssuedChallenges(
     PhabricatorAuthFactorConfig $config,
     PhabricatorUser $viewer,
     array $challenges) {
 
     $current_step = $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 and compute
     // the TTL.
 
     // When computing the TTL, note that we accept codes within a certain
     // window of the challenge timestep to account for clock skew and users
     // needing time to enter codes.
 
     // We don't want this challenge to expire until after all valid responses
     // to it are no longer valid responses to any other challenge we might
     // issue in the future. If the challenge expires too quickly, we may issue
     // a new challenge which can accept the same TOTP code response.
 
     // This means that we need to keep this challenge alive for double the
     // window size: if we're currently at timestep 3, the user might respond
     // with the code for timestep 5. This is valid, since timestep 5 is within
     // the window for timestep 3.
 
     // But the code for timestep 5 can be used to respond at timesteps 3, 4, 5,
     // 6, and 7. To prevent any valid response to this challenge from being
     // used again, we need to keep this challenge active until timestep 8.
 
     $window_size = $this->getTimestepWindowSize();
     $step_duration = $this->getTimestepDuration();
 
     $ttl_steps = ($window_size * 2) + 1;
     $ttl_seconds = ($ttl_steps * $step_duration);
 
     return array(
       $this->newChallenge($config, $viewer)
         ->setChallengeKey($current_step)
         ->setChallengeTTL(PhabricatorTime::getNow() + $ttl_seconds),
     );
   }
 
   public function renderValidateFactorForm(
     PhabricatorAuthFactorConfig $config,
     AphrontFormView $form,
     PhabricatorUser $viewer,
     PhabricatorAuthFactorResult $result) {
 
     $value = $result->getValue();
     $error = $result->getErrorMessage();
     $is_wait = $result->getIsWait();
 
     if ($is_wait) {
       $control = id(new AphrontFormMarkupControl())
         ->setValue($error)
         ->setError(pht('Wait'));
     } else {
       $control = id(new PHUIFormNumberControl())
         ->setName($this->getParameterName($config, 'totpcode'))
         ->setDisableAutocomplete(true)
         ->setValue($value)
         ->setError($error);
     }
 
     $control
       ->setLabel(pht('App Code'))
       ->setCaption(pht('Factor Name: %s', $config->getFactorName()));
 
     $form->appendChild($control);
   }
 
   protected function newResultFromIssuedChallenges(
     PhabricatorAuthFactorConfig $config,
     PhabricatorUser $viewer,
     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.
     $session_phid = $viewer->getSession()->getPHID();
     $now = PhabricatorTime::getNow();
 
     $engine = $config->getSessionEngine();
     $workflow_key = $engine->getWorkflowKey();
 
     $current_timestep = $this->getCurrentTimestep();
 
     foreach ($challenges as $challenge) {
       $challenge_timestep = (int)$challenge->getChallengeKey();
       $wait_duration = ($challenge->getChallengeTTL() - $now) + 1;
 
       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 second(s) for the code to cycle, then try '.
               'again.',
               new PhutilNumber($wait_duration)));
       }
 
       if ($challenge->getWorkflowKey() !== $workflow_key) {
         return $this->newResult()
           ->setIsWait(true)
           ->setErrorMessage(
             pht(
               'This factor recently issued a challenge for a different '.
               'workflow. Wait %s second(s) for the code to cycle, then try '.
               'again.',
               new PhutilNumber($wait_duration)));
       }
 
       // If the current realtime timestep isn't a valid response to the current
       // challenge but the challenge hasn't expired yet, we're locking out
       // the factor to prevent challenge windows from overlapping. Let the user
       // know that they should wait for a new challenge.
       $challenge_timesteps = $this->getAllowedTimesteps($challenge_timestep);
       if (!isset($challenge_timesteps[$current_timestep])) {
         return $this->newResult()
           ->setIsWait(true)
           ->setErrorMessage(
             pht(
               'This factor recently issued a challenge which has expired. '.
               'A new challenge can not be issued yet. Wait %s second(s) for '.
               'the code to cycle, then try again.',
               new PhutilNumber($wait_duration)));
       }
+
+      if ($challenge->getIsReusedChallenge()) {
+        return $this->newResult()
+          ->setIsWait(true)
+          ->setErrorMessage(
+            pht(
+              'You recently provided a response to this factor. Responses '.
+              'may not be reused. Wait %s second(s) 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'));
 
     $result = $this->newResult()
       ->setValue($code);
 
     // We expect to reach TOTP validation with exactly one valid challenge.
     if (count($challenges) !== 1) {
       throw new Exception(
         pht(
           'Reached TOTP challenge validation with an unexpected number of '.
           'unexpired challenges (%d), expected exactly one.',
           phutil_count($challenges)));
     }
 
     $challenge = head($challenges);
 
     $challenge_timestep = (int)$challenge->getChallengeKey();
     $current_timestep = $this->getCurrentTimestep();
 
     $challenge_timesteps = $this->getAllowedTimesteps($challenge_timestep);
     $current_timesteps = $this->getAllowedTimesteps($current_timestep);
 
     // We require responses be both valid for the challenge and valid for the
     // current timestep. A longer challenge TTL doesn't let you use older
     // codes for a longer period of time.
     $valid_timestep = $this->getTimestepAtWhichResponseIsValid(
       array_intersect_key($challenge_timesteps, $current_timesteps),
       new PhutilOpaqueEnvelope($config->getFactorSecret()),
       (string)$code);
 
     if ($valid_timestep) {
-      $result->setIsValid(true);
+      $now = PhabricatorTime::getNow();
+      $step_duration = $this->getTimestepDuration();
+      $step_window = $this->getTimestepWindowSize();
+      $ttl = $now + ($step_duration * $step_window);
+
+      $challenge
+        ->setProperty('totp.timestep', $valid_timestep)
+        ->markChallengeAsAnswered($ttl);
+
+      $result->setAnsweredChallenge($challenge);
     } else {
       if (strlen($code)) {
         $error_message = pht('Invalid');
       } else {
         $error_message = pht('Required');
       }
       $result->setErrorMessage($error_message);
     }
 
     return $result;
   }
 
   public static function generateNewTOTPKey() {
     return strtoupper(Filesystem::readRandomCharacters(32));
   }
 
   public static function base32Decode($buf) {
     $buf = strtoupper($buf);
 
     $map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
     $map = str_split($map);
     $map = array_flip($map);
 
     $out = '';
     $len = strlen($buf);
     $acc = 0;
     $bits = 0;
     for ($ii = 0; $ii < $len; $ii++) {
       $chr = $buf[$ii];
       $val = $map[$chr];
 
       $acc = $acc << 5;
       $acc = $acc + $val;
 
       $bits += 5;
       if ($bits >= 8) {
         $bits = $bits - 8;
         $out .= chr(($acc & (0xFF << $bits)) >> $bits);
       }
     }
 
     return $out;
   }
 
   public static function getTOTPCode(PhutilOpaqueEnvelope $key, $timestamp) {
     $binary_timestamp = pack('N*', 0).pack('N*', $timestamp);
     $binary_key = self::base32Decode($key->openEnvelope());
 
     $hash = hash_hmac('sha1', $binary_timestamp, $binary_key, true);
 
     // See RFC 4226.
 
     $offset = ord($hash[19]) & 0x0F;
 
     $code = ((ord($hash[$offset + 0]) & 0x7F) << 24) |
             ((ord($hash[$offset + 1]) & 0xFF) << 16) |
             ((ord($hash[$offset + 2]) & 0xFF) <<  8) |
             ((ord($hash[$offset + 3])       )      );
 
     $code = ($code % 1000000);
     $code = str_pad($code, 6, '0', STR_PAD_LEFT);
 
     return $code;
   }
 
 
   /**
    * @phutil-external-symbol class QRcode
    */
   private function renderQRCode($uri) {
     $root = dirname(phutil_get_library_root('phabricator'));
     require_once $root.'/externals/phpqrcode/phpqrcode.php';
 
     $lines = QRcode::text($uri);
 
     $total_width = 240;
     $cell_size = floor($total_width / count($lines));
 
     $rows = array();
     foreach ($lines as $line) {
       $cells = array();
       for ($ii = 0; $ii < strlen($line); $ii++) {
         if ($line[$ii] == '1') {
           $color = '#000';
         } else {
           $color = '#fff';
         }
 
         $cells[] = phutil_tag(
           'td',
           array(
             'width' => $cell_size,
             'height' => $cell_size,
             'style' => 'background: '.$color,
           ),
           '');
       }
       $rows[] = phutil_tag('tr', array(), $cells);
     }
 
     return phutil_tag(
       'table',
       array(
         'style' => 'margin: 24px auto;',
       ),
       $rows);
   }
 
   private function getTimestepDuration() {
     return 30;
   }
 
   private function getCurrentTimestep() {
     $duration = $this->getTimestepDuration();
     return (int)(PhabricatorTime::getNow() / $duration);
   }
 
   private function getAllowedTimesteps($at_timestep) {
     $window = $this->getTimestepWindowSize();
     $range = range($at_timestep - $window, $at_timestep + $window);
     return array_fuse($range);
   }
 
   private function getTimestepWindowSize() {
     // The user is allowed to provide a code from the recent past or the
     // near future to account for minor clock skew between the client
     // and server, and the time it takes to actually enter a code.
     return 2;
   }
 
   private function getTimestepAtWhichResponseIsValid(
     array $timesteps,
     PhutilOpaqueEnvelope $key,
     $code) {
 
     foreach ($timesteps as $timestep) {
       $expect_code = self::getTOTPCode($key, $timestep);
       if (phutil_hashes_are_identical($code, $expect_code)) {
         return $timestep;
       }
     }
 
     return null;
   }
 
 
 
 }
diff --git a/src/applications/auth/storage/PhabricatorAuthChallenge.php b/src/applications/auth/storage/PhabricatorAuthChallenge.php
index 63d2092e49..89ffd67ef7 100644
--- a/src/applications/auth/storage/PhabricatorAuthChallenge.php
+++ b/src/applications/auth/storage/PhabricatorAuthChallenge.php
@@ -1,59 +1,172 @@
 <?php
 
 final class PhabricatorAuthChallenge
   extends PhabricatorAuthDAO
   implements PhabricatorPolicyInterface {
 
   protected $userPHID;
   protected $factorPHID;
   protected $sessionPHID;
   protected $workflowKey;
   protected $challengeKey;
   protected $challengeTTL;
+  protected $responseDigest;
+  protected $responseTTL;
+  protected $isCompleted;
   protected $properties = array();
 
+  private $responseToken;
+
+  const TOKEN_DIGEST_KEY = 'auth.challenge.token';
+
+  public static function initializeNewChallenge() {
+    return id(new self())
+      ->setIsCompleted(0);
+  }
+
   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',
         'workflowKey' => 'text255',
+        'responseDigest' => 'text255?',
+        'responseTTL' => 'epoch?',
+        'isCompleted' => 'bool',
       ),
       self::CONFIG_KEY_SCHEMA => array(
         'key_issued' => array(
           'columns' => array('userPHID', 'challengeTTL'),
         ),
         'key_collection' => array(
           'columns' => array('challengeTTL'),
         ),
       ),
     ) + parent::getConfiguration();
   }
 
   public function getPHIDType() {
     return PhabricatorAuthChallengePHIDType::TYPECONST;
   }
 
+  public function getIsReusedChallenge() {
+    if ($this->getIsCompleted()) {
+      return true;
+    }
+
+    // TODO: A challenge is "reused" if it has been answered previously and
+    // the request doesn't include proof that the client provided the answer.
+    // Since we aren't tracking client responses yet, any answered challenge
+    // is always a reused challenge for now.
+
+    return $this->getIsAnsweredChallenge();
+  }
+
+  public function getIsAnsweredChallenge() {
+    return (bool)$this->getResponseDigest();
+  }
+
+  public function markChallengeAsAnswered($ttl) {
+    $token = Filesystem::readRandomCharacters(32);
+    $token = new PhutilOpaqueEnvelope($token);
+
+    return $this
+      ->setResponseToken($token, $ttl)
+      ->save();
+  }
+
+  public function markChallengeAsCompleted() {
+    return $this
+      ->setIsCompleted(true)
+      ->save();
+  }
+
+  public function setResponseToken(PhutilOpaqueEnvelope $token, $ttl) {
+    if (!$this->getUserPHID()) {
+      throw new PhutilInvalidStateException('setUserPHID');
+    }
+
+    if ($this->responseToken) {
+      throw new Exception(
+        pht(
+          'This challenge already has a response token; you can not '.
+          '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())) {
+      throw new Exception(
+        pht(
+          'The response token for this challenge is invalid: response '.
+          'tokens may not include spaces.'));
+    }
+
+    $digest = PhabricatorHash::digestWithNamedKey(
+      $token->openEnvelope(),
+      self::TOKEN_DIGEST_KEY);
+
+    if ($this->responseDigest !== null) {
+      if (!phutil_hashes_are_identical($digest, $this->responseDigest)) {
+        throw new Exception(
+          pht(
+            'Invalid response token for this challenge: token digest does '.
+            'not match stored digest.'));
+      }
+    } else {
+      $this->responseDigest = $digest;
+    }
+
+    $this->responseToken = $token;
+    $this->responseTTL = $ttl;
+
+    return $this;
+  }
+
+  public function setResponseDigest($value) {
+    throw new Exception(
+      pht(
+        'You can not set the response digest for a challenge directly. '.
+        'Instead, set a response token. A response digest will be computed '.
+        'automatically.'));
+  }
+
+  public function setProperty($key, $value) {
+    $this->properties[$key] = $value;
+    return $this;
+  }
+
+  public function getProperty($key, $default = null) {
+    return $this->properties[$key];
+  }
+
 
 /* -(  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());
   }
 
 }