3 final class PhabricatorTOTPAuthFactor
extends PhabricatorAuthFactor
{
5 public function getFactorKey() {
9 public function getFactorName() {
10 return pht('Mobile Phone App (TOTP)');
13 public function getFactorShortName() {
17 public function getFactorCreateHelp() {
19 'Allow users to attach a mobile authenticator application (like '.
20 'Google Authenticator) to their account.');
23 public function getFactorDescription() {
25 'Attach a mobile authenticator application (like Authy '.
26 'or Google Authenticator) to your account. When you need to '.
27 'authenticate, you will enter a code shown on your phone.');
30 public function getEnrollDescription(
31 PhabricatorAuthFactorProvider
$provider,
32 PhabricatorUser
$user) {
35 'To add a TOTP factor to your account, you will first need to install '.
36 'a mobile authenticator application on your phone. Two applications '.
37 'which work well are **Google Authenticator** and **Authy**, but any '.
38 'other TOTP application should also work.'.
40 'If you haven\'t already, download and install a TOTP application on '.
41 'your phone now. Once you\'ve launched the application and are ready '.
42 'to add a new TOTP code, continue to the next step.');
45 public function getConfigurationListDetails(
46 PhabricatorAuthFactorConfig
$config,
47 PhabricatorAuthFactorProvider
$provider,
48 PhabricatorUser
$viewer) {
50 $bits = strlen($config->getFactorSecret()) * 8;
51 return pht('%d-Bit Secret', $bits);
54 public function processAddFactorForm(
55 PhabricatorAuthFactorProvider
$provider,
56 AphrontFormView
$form,
57 AphrontRequest
$request,
58 PhabricatorUser
$user) {
60 $sync_token = $this->loadMFASyncToken(
65 $secret = $sync_token->getTemporaryTokenProperty('secret');
67 $code = $request->getStr('totpcode');
70 if (!$sync_token->getIsNewTemporaryToken()) {
71 $okay = (bool)$this->getTimestepAtWhichResponseIsValid(
72 $this->getAllowedTimesteps($this->getCurrentTimestep()),
73 new PhutilOpaqueEnvelope($secret),
77 $config = $this->newConfigForUser($user)
78 ->setFactorName(pht('Mobile App (TOTP)'))
79 ->setFactorSecret($secret)
80 ->setMFASyncToken($sync_token);
85 $e_code = pht('Required');
87 $e_code = pht('Invalid');
92 $form->appendInstructions(
94 'Scan the QR code or manually enter the key shown below into the '.
97 $prod_uri = new PhutilURI(PhabricatorEnv
::getProductionURI('/'));
98 $issuer = $prod_uri->getDomain();
101 'otpauth://totp/%s:%s?secret=%s&issuer=%s',
103 $user->getUsername(),
107 $qrcode = $this->newQRCode($uri);
108 $form->appendChild($qrcode);
111 id(new AphrontFormStaticControl())
112 ->setLabel(pht('Key'))
113 ->setValue(phutil_tag('strong', array(), $secret)));
115 $form->appendInstructions(
117 '(If given an option, select that this key is "Time Based", not '.
118 '"Counter Based".)'));
120 $form->appendInstructions(
122 'After entering the key, the application should display a numeric '.
123 'code. Enter that code below to confirm that you have configured '.
124 'the authenticator correctly:'));
127 id(new PHUIFormNumberControl())
128 ->setLabel(pht('TOTP Code'))
129 ->setName('totpcode')
132 ->setError($e_code));
136 protected function newIssuedChallenges(
137 PhabricatorAuthFactorConfig
$config,
138 PhabricatorUser
$viewer,
141 $current_step = $this->getCurrentTimestep();
143 // If we already issued a valid challenge, don't issue a new one.
148 // Otherwise, generate a new challenge for the current timestep and compute
151 // When computing the TTL, note that we accept codes within a certain
152 // window of the challenge timestep to account for clock skew and users
153 // needing time to enter codes.
155 // We don't want this challenge to expire until after all valid responses
156 // to it are no longer valid responses to any other challenge we might
157 // issue in the future. If the challenge expires too quickly, we may issue
158 // a new challenge which can accept the same TOTP code response.
160 // This means that we need to keep this challenge alive for double the
161 // window size: if we're currently at timestep 3, the user might respond
162 // with the code for timestep 5. This is valid, since timestep 5 is within
163 // the window for timestep 3.
165 // But the code for timestep 5 can be used to respond at timesteps 3, 4, 5,
166 // 6, and 7. To prevent any valid response to this challenge from being
167 // used again, we need to keep this challenge active until timestep 8.
169 $window_size = $this->getTimestepWindowSize();
170 $step_duration = $this->getTimestepDuration();
172 $ttl_steps = ($window_size * 2) +
1;
173 $ttl_seconds = ($ttl_steps * $step_duration);
176 $this->newChallenge($config, $viewer)
177 ->setChallengeKey($current_step)
178 ->setChallengeTTL(PhabricatorTime
::getNow() +
$ttl_seconds),
182 public function renderValidateFactorForm(
183 PhabricatorAuthFactorConfig
$config,
184 AphrontFormView
$form,
185 PhabricatorUser
$viewer,
186 PhabricatorAuthFactorResult
$result) {
188 $control = $this->newAutomaticControl($result);
190 $value = $result->getValue();
191 $error = $result->getErrorMessage();
192 $name = $this->getChallengeResponseParameterName($config);
194 $control = id(new PHUIFormNumberControl())
196 ->setDisableAutocomplete(true)
203 ->setLabel(pht('App Code'))
204 ->setCaption(pht('Factor Name: %s', $config->getFactorName()));
206 $form->appendChild($control);
209 public function getRequestHasChallengeResponse(
210 PhabricatorAuthFactorConfig
$config,
211 AphrontRequest
$request) {
213 $value = $this->getChallengeResponseFromRequest($config, $request);
214 return (bool)strlen($value);
218 protected function newResultFromIssuedChallenges(
219 PhabricatorAuthFactorConfig
$config,
220 PhabricatorUser
$viewer,
223 // If we've already issued a challenge at the current timestep or any
224 // nearby timestep, require that it was issued to the current session.
225 // This is defusing attacks where you (broadly) look at someone's phone
226 // and type the code in more quickly than they do.
227 $session_phid = $viewer->getSession()->getPHID();
228 $now = PhabricatorTime
::getNow();
230 $engine = $config->getSessionEngine();
231 $workflow_key = $engine->getWorkflowKey();
233 $current_timestep = $this->getCurrentTimestep();
235 foreach ($challenges as $challenge) {
236 $challenge_timestep = (int)$challenge->getChallengeKey();
237 $wait_duration = ($challenge->getChallengeTTL() - $now) +
1;
239 if ($challenge->getSessionPHID() !== $session_phid) {
240 return $this->newResult()
244 'This factor recently issued a challenge to a different login '.
245 'session. Wait %s second(s) for the code to cycle, then try '.
247 new PhutilNumber($wait_duration)));
250 if ($challenge->getWorkflowKey() !== $workflow_key) {
251 return $this->newResult()
255 'This factor recently issued a challenge for a different '.
256 'workflow. Wait %s second(s) for the code to cycle, then try '.
258 new PhutilNumber($wait_duration)));
261 // If the current realtime timestep isn't a valid response to the current
262 // challenge but the challenge hasn't expired yet, we're locking out
263 // the factor to prevent challenge windows from overlapping. Let the user
264 // know that they should wait for a new challenge.
265 $challenge_timesteps = $this->getAllowedTimesteps($challenge_timestep);
266 if (!isset($challenge_timesteps[$current_timestep])) {
267 return $this->newResult()
271 'This factor recently issued a challenge which has expired. '.
272 'A new challenge can not be issued yet. Wait %s second(s) for '.
273 'the code to cycle, then try again.',
274 new PhutilNumber($wait_duration)));
277 if ($challenge->getIsReusedChallenge()) {
278 return $this->newResult()
282 'You recently provided a response to this factor. Responses '.
283 'may not be reused. Wait %s second(s) for the code to cycle, '.
285 new PhutilNumber($wait_duration)));
292 protected function newResultFromChallengeResponse(
293 PhabricatorAuthFactorConfig
$config,
294 PhabricatorUser
$viewer,
295 AphrontRequest
$request,
298 $code = $this->getChallengeResponseFromRequest(
302 $result = $this->newResult()
305 // We expect to reach TOTP validation with exactly one valid challenge.
306 if (count($challenges) !== 1) {
309 'Reached TOTP challenge validation with an unexpected number of '.
310 'unexpired challenges (%d), expected exactly one.',
311 phutil_count($challenges)));
314 $challenge = head($challenges);
316 // If the client has already provided a valid answer to this challenge and
317 // submitted a token proving they answered it, we're all set.
318 if ($challenge->getIsAnsweredChallenge()) {
319 return $result->setAnsweredChallenge($challenge);
322 $challenge_timestep = (int)$challenge->getChallengeKey();
323 $current_timestep = $this->getCurrentTimestep();
325 $challenge_timesteps = $this->getAllowedTimesteps($challenge_timestep);
326 $current_timesteps = $this->getAllowedTimesteps($current_timestep);
328 // We require responses be both valid for the challenge and valid for the
329 // current timestep. A longer challenge TTL doesn't let you use older
330 // codes for a longer period of time.
331 $valid_timestep = $this->getTimestepAtWhichResponseIsValid(
332 array_intersect_key($challenge_timesteps, $current_timesteps),
333 new PhutilOpaqueEnvelope($config->getFactorSecret()),
336 if ($valid_timestep) {
337 $ttl = PhabricatorTime
::getNow() +
60;
340 ->setProperty('totp.timestep', $valid_timestep)
341 ->markChallengeAsAnswered($ttl);
343 $result->setAnsweredChallenge($challenge);
346 $error_message = pht('Invalid');
348 $error_message = pht('Required');
350 $result->setErrorMessage($error_message);
356 public static function generateNewTOTPKey() {
357 return strtoupper(Filesystem
::readRandomCharacters(32));
360 public static function base32Decode($buf) {
361 $buf = strtoupper($buf);
363 $map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
364 $map = str_split($map);
365 $map = array_flip($map);
371 for ($ii = 0; $ii < $len; $ii++
) {
381 $out .= chr(($acc & (0xFF << $bits)) >> $bits);
388 public static function getTOTPCode(PhutilOpaqueEnvelope
$key, $timestamp) {
389 $binary_timestamp = pack('N*', 0).pack('N*', $timestamp);
390 $binary_key = self
::base32Decode($key->openEnvelope());
392 $hash = hash_hmac('sha1', $binary_timestamp, $binary_key, true);
396 $offset = ord($hash[19]) & 0x0F;
398 $code = ((ord($hash[$offset +
0]) & 0x7F) << 24) |
399 ((ord($hash[$offset +
1]) & 0xFF) << 16) |
400 ((ord($hash[$offset +
2]) & 0xFF) << 8) |
401 ((ord($hash[$offset +
3]) ) );
403 $code = ($code %
1000000);
404 $code = str_pad($code, 6, '0', STR_PAD_LEFT
);
409 private function getTimestepDuration() {
413 private function getCurrentTimestep() {
414 $duration = $this->getTimestepDuration();
415 return (int)(PhabricatorTime
::getNow() / $duration);
418 private function getAllowedTimesteps($at_timestep) {
419 $window = $this->getTimestepWindowSize();
420 $range = range($at_timestep - $window, $at_timestep +
$window);
421 return array_fuse($range);
424 private function getTimestepWindowSize() {
425 // The user is allowed to provide a code from the recent past or the
426 // near future to account for minor clock skew between the client
427 // and server, and the time it takes to actually enter a code.
431 private function getTimestepAtWhichResponseIsValid(
433 PhutilOpaqueEnvelope
$key,
436 foreach ($timesteps as $timestep) {
437 $expect_code = self
::getTOTPCode($key, $timestep);
438 if (phutil_hashes_are_identical($code, $expect_code)) {
446 protected function newMFASyncTokenProperties(
447 PhabricatorAuthFactorProvider
$providerr,
448 PhabricatorUser
$user) {
450 'secret' => self
::generateNewTOTPKey(),