Correct a parameter order swap in "diffusion.historyquery" for Mercurial
[phabricator.git] / src / applications / auth / factor / PhabricatorTOTPAuthFactor.php
blobebdf1b72182b7030e1143033189d62117f40bc76
1 <?php
3 final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor {
5 public function getFactorKey() {
6 return 'totp';
9 public function getFactorName() {
10 return pht('Mobile Phone App (TOTP)');
13 public function getFactorShortName() {
14 return pht('TOTP');
17 public function getFactorCreateHelp() {
18 return pht(
19 'Allow users to attach a mobile authenticator application (like '.
20 'Google Authenticator) to their account.');
23 public function getFactorDescription() {
24 return pht(
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) {
34 return pht(
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.'.
39 "\n\n".
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(
61 $provider,
62 $request,
63 $form,
64 $user);
65 $secret = $sync_token->getTemporaryTokenProperty('secret');
67 $code = $request->getStr('totpcode');
69 $e_code = true;
70 if (!$sync_token->getIsNewTemporaryToken()) {
71 $okay = (bool)$this->getTimestepAtWhichResponseIsValid(
72 $this->getAllowedTimesteps($this->getCurrentTimestep()),
73 new PhutilOpaqueEnvelope($secret),
74 $code);
76 if ($okay) {
77 $config = $this->newConfigForUser($user)
78 ->setFactorName(pht('Mobile App (TOTP)'))
79 ->setFactorSecret($secret)
80 ->setMFASyncToken($sync_token);
82 return $config;
83 } else {
84 if (!strlen($code)) {
85 $e_code = pht('Required');
86 } else {
87 $e_code = pht('Invalid');
92 $form->appendInstructions(
93 pht(
94 'Scan the QR code or manually enter the key shown below into the '.
95 'application.'));
97 $prod_uri = new PhutilURI(PhabricatorEnv::getProductionURI('/'));
98 $issuer = $prod_uri->getDomain();
100 $uri = urisprintf(
101 'otpauth://totp/%s:%s?secret=%s&issuer=%s',
102 $issuer,
103 $user->getUsername(),
104 $secret,
105 $issuer);
107 $qrcode = $this->newQRCode($uri);
108 $form->appendChild($qrcode);
110 $form->appendChild(
111 id(new AphrontFormStaticControl())
112 ->setLabel(pht('Key'))
113 ->setValue(phutil_tag('strong', array(), $secret)));
115 $form->appendInstructions(
116 pht(
117 '(If given an option, select that this key is "Time Based", not '.
118 '"Counter Based".)'));
120 $form->appendInstructions(
121 pht(
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:'));
126 $form->appendChild(
127 id(new PHUIFormNumberControl())
128 ->setLabel(pht('TOTP Code'))
129 ->setName('totpcode')
130 ->setValue($code)
131 ->setAutofocus(true)
132 ->setError($e_code));
136 protected function newIssuedChallenges(
137 PhabricatorAuthFactorConfig $config,
138 PhabricatorUser $viewer,
139 array $challenges) {
141 $current_step = $this->getCurrentTimestep();
143 // If we already issued a valid challenge, don't issue a new one.
144 if ($challenges) {
145 return array();
148 // Otherwise, generate a new challenge for the current timestep and compute
149 // the TTL.
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);
175 return array(
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);
189 if (!$control) {
190 $value = $result->getValue();
191 $error = $result->getErrorMessage();
192 $name = $this->getChallengeResponseParameterName($config);
194 $control = id(new PHUIFormNumberControl())
195 ->setName($name)
196 ->setDisableAutocomplete(true)
197 ->setAutofocus(true)
198 ->setValue($value)
199 ->setError($error);
202 $control
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,
221 array $challenges) {
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()
241 ->setIsWait(true)
242 ->setErrorMessage(
243 pht(
244 'This factor recently issued a challenge to a different login '.
245 'session. Wait %s second(s) for the code to cycle, then try '.
246 'again.',
247 new PhutilNumber($wait_duration)));
250 if ($challenge->getWorkflowKey() !== $workflow_key) {
251 return $this->newResult()
252 ->setIsWait(true)
253 ->setErrorMessage(
254 pht(
255 'This factor recently issued a challenge for a different '.
256 'workflow. Wait %s second(s) for the code to cycle, then try '.
257 'again.',
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()
268 ->setIsWait(true)
269 ->setErrorMessage(
270 pht(
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()
279 ->setIsWait(true)
280 ->setErrorMessage(
281 pht(
282 'You recently provided a response to this factor. Responses '.
283 'may not be reused. Wait %s second(s) for the code to cycle, '.
284 'then try again.',
285 new PhutilNumber($wait_duration)));
289 return null;
292 protected function newResultFromChallengeResponse(
293 PhabricatorAuthFactorConfig $config,
294 PhabricatorUser $viewer,
295 AphrontRequest $request,
296 array $challenges) {
298 $code = $this->getChallengeResponseFromRequest(
299 $config,
300 $request);
302 $result = $this->newResult()
303 ->setValue($code);
305 // We expect to reach TOTP validation with exactly one valid challenge.
306 if (count($challenges) !== 1) {
307 throw new Exception(
308 pht(
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()),
334 $code);
336 if ($valid_timestep) {
337 $ttl = PhabricatorTime::getNow() + 60;
339 $challenge
340 ->setProperty('totp.timestep', $valid_timestep)
341 ->markChallengeAsAnswered($ttl);
343 $result->setAnsweredChallenge($challenge);
344 } else {
345 if (strlen($code)) {
346 $error_message = pht('Invalid');
347 } else {
348 $error_message = pht('Required');
350 $result->setErrorMessage($error_message);
353 return $result;
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);
367 $out = '';
368 $len = strlen($buf);
369 $acc = 0;
370 $bits = 0;
371 for ($ii = 0; $ii < $len; $ii++) {
372 $chr = $buf[$ii];
373 $val = $map[$chr];
375 $acc = $acc << 5;
376 $acc = $acc + $val;
378 $bits += 5;
379 if ($bits >= 8) {
380 $bits = $bits - 8;
381 $out .= chr(($acc & (0xFF << $bits)) >> $bits);
385 return $out;
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);
394 // See RFC 4226.
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);
406 return $code;
409 private function getTimestepDuration() {
410 return 30;
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.
428 return 1;
431 private function getTimestepAtWhichResponseIsValid(
432 array $timesteps,
433 PhutilOpaqueEnvelope $key,
434 $code) {
436 foreach ($timesteps as $timestep) {
437 $expect_code = self::getTOTPCode($key, $timestep);
438 if (phutil_hashes_are_identical($code, $expect_code)) {
439 return $timestep;
443 return null;
446 protected function newMFASyncTokenProperties(
447 PhabricatorAuthFactorProvider $providerr,
448 PhabricatorUser $user) {
449 return array(
450 'secret' => self::generateNewTOTPKey(),