Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
freebsd
GitHub Repository: freebsd/phabricator
Path: blob/master/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php
12256 views
1
<?php
2
3
final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor {
4
5
public function getFactorKey() {
6
return 'totp';
7
}
8
9
public function getFactorName() {
10
return pht('Mobile Phone App (TOTP)');
11
}
12
13
public function getFactorShortName() {
14
return pht('TOTP');
15
}
16
17
public function getFactorCreateHelp() {
18
return pht(
19
'Allow users to attach a mobile authenticator application (like '.
20
'Google Authenticator) to their account.');
21
}
22
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.');
28
}
29
30
public function getEnrollDescription(
31
PhabricatorAuthFactorProvider $provider,
32
PhabricatorUser $user) {
33
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.');
43
}
44
45
public function getConfigurationListDetails(
46
PhabricatorAuthFactorConfig $config,
47
PhabricatorAuthFactorProvider $provider,
48
PhabricatorUser $viewer) {
49
50
$bits = strlen($config->getFactorSecret()) * 8;
51
return pht('%d-Bit Secret', $bits);
52
}
53
54
public function processAddFactorForm(
55
PhabricatorAuthFactorProvider $provider,
56
AphrontFormView $form,
57
AphrontRequest $request,
58
PhabricatorUser $user) {
59
60
$sync_token = $this->loadMFASyncToken(
61
$provider,
62
$request,
63
$form,
64
$user);
65
$secret = $sync_token->getTemporaryTokenProperty('secret');
66
67
$code = $request->getStr('totpcode');
68
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);
75
76
if ($okay) {
77
$config = $this->newConfigForUser($user)
78
->setFactorName(pht('Mobile App (TOTP)'))
79
->setFactorSecret($secret)
80
->setMFASyncToken($sync_token);
81
82
return $config;
83
} else {
84
if (!strlen($code)) {
85
$e_code = pht('Required');
86
} else {
87
$e_code = pht('Invalid');
88
}
89
}
90
}
91
92
$form->appendInstructions(
93
pht(
94
'Scan the QR code or manually enter the key shown below into the '.
95
'application.'));
96
97
$prod_uri = new PhutilURI(PhabricatorEnv::getProductionURI('/'));
98
$issuer = $prod_uri->getDomain();
99
100
$uri = urisprintf(
101
'otpauth://totp/%s:%s?secret=%s&issuer=%s',
102
$issuer,
103
$user->getUsername(),
104
$secret,
105
$issuer);
106
107
$qrcode = $this->newQRCode($uri);
108
$form->appendChild($qrcode);
109
110
$form->appendChild(
111
id(new AphrontFormStaticControl())
112
->setLabel(pht('Key'))
113
->setValue(phutil_tag('strong', array(), $secret)));
114
115
$form->appendInstructions(
116
pht(
117
'(If given an option, select that this key is "Time Based", not '.
118
'"Counter Based".)'));
119
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:'));
125
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));
133
134
}
135
136
protected function newIssuedChallenges(
137
PhabricatorAuthFactorConfig $config,
138
PhabricatorUser $viewer,
139
array $challenges) {
140
141
$current_step = $this->getCurrentTimestep();
142
143
// If we already issued a valid challenge, don't issue a new one.
144
if ($challenges) {
145
return array();
146
}
147
148
// Otherwise, generate a new challenge for the current timestep and compute
149
// the TTL.
150
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.
154
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.
159
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.
164
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.
168
169
$window_size = $this->getTimestepWindowSize();
170
$step_duration = $this->getTimestepDuration();
171
172
$ttl_steps = ($window_size * 2) + 1;
173
$ttl_seconds = ($ttl_steps * $step_duration);
174
175
return array(
176
$this->newChallenge($config, $viewer)
177
->setChallengeKey($current_step)
178
->setChallengeTTL(PhabricatorTime::getNow() + $ttl_seconds),
179
);
180
}
181
182
public function renderValidateFactorForm(
183
PhabricatorAuthFactorConfig $config,
184
AphrontFormView $form,
185
PhabricatorUser $viewer,
186
PhabricatorAuthFactorResult $result) {
187
188
$control = $this->newAutomaticControl($result);
189
if (!$control) {
190
$value = $result->getValue();
191
$error = $result->getErrorMessage();
192
$name = $this->getChallengeResponseParameterName($config);
193
194
$control = id(new PHUIFormNumberControl())
195
->setName($name)
196
->setDisableAutocomplete(true)
197
->setAutofocus(true)
198
->setValue($value)
199
->setError($error);
200
}
201
202
$control
203
->setLabel(pht('App Code'))
204
->setCaption(pht('Factor Name: %s', $config->getFactorName()));
205
206
$form->appendChild($control);
207
}
208
209
public function getRequestHasChallengeResponse(
210
PhabricatorAuthFactorConfig $config,
211
AphrontRequest $request) {
212
213
$value = $this->getChallengeResponseFromRequest($config, $request);
214
return (bool)strlen($value);
215
}
216
217
218
protected function newResultFromIssuedChallenges(
219
PhabricatorAuthFactorConfig $config,
220
PhabricatorUser $viewer,
221
array $challenges) {
222
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();
229
230
$engine = $config->getSessionEngine();
231
$workflow_key = $engine->getWorkflowKey();
232
233
$current_timestep = $this->getCurrentTimestep();
234
235
foreach ($challenges as $challenge) {
236
$challenge_timestep = (int)$challenge->getChallengeKey();
237
$wait_duration = ($challenge->getChallengeTTL() - $now) + 1;
238
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)));
248
}
249
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)));
259
}
260
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)));
275
}
276
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)));
286
}
287
}
288
289
return null;
290
}
291
292
protected function newResultFromChallengeResponse(
293
PhabricatorAuthFactorConfig $config,
294
PhabricatorUser $viewer,
295
AphrontRequest $request,
296
array $challenges) {
297
298
$code = $this->getChallengeResponseFromRequest(
299
$config,
300
$request);
301
302
$result = $this->newResult()
303
->setValue($code);
304
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)));
312
}
313
314
$challenge = head($challenges);
315
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);
320
}
321
322
$challenge_timestep = (int)$challenge->getChallengeKey();
323
$current_timestep = $this->getCurrentTimestep();
324
325
$challenge_timesteps = $this->getAllowedTimesteps($challenge_timestep);
326
$current_timesteps = $this->getAllowedTimesteps($current_timestep);
327
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);
335
336
if ($valid_timestep) {
337
$ttl = PhabricatorTime::getNow() + 60;
338
339
$challenge
340
->setProperty('totp.timestep', $valid_timestep)
341
->markChallengeAsAnswered($ttl);
342
343
$result->setAnsweredChallenge($challenge);
344
} else {
345
if (strlen($code)) {
346
$error_message = pht('Invalid');
347
} else {
348
$error_message = pht('Required');
349
}
350
$result->setErrorMessage($error_message);
351
}
352
353
return $result;
354
}
355
356
public static function generateNewTOTPKey() {
357
return strtoupper(Filesystem::readRandomCharacters(32));
358
}
359
360
public static function base32Decode($buf) {
361
$buf = strtoupper($buf);
362
363
$map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
364
$map = str_split($map);
365
$map = array_flip($map);
366
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];
374
375
$acc = $acc << 5;
376
$acc = $acc + $val;
377
378
$bits += 5;
379
if ($bits >= 8) {
380
$bits = $bits - 8;
381
$out .= chr(($acc & (0xFF << $bits)) >> $bits);
382
}
383
}
384
385
return $out;
386
}
387
388
public static function getTOTPCode(PhutilOpaqueEnvelope $key, $timestamp) {
389
$binary_timestamp = pack('N*', 0).pack('N*', $timestamp);
390
$binary_key = self::base32Decode($key->openEnvelope());
391
392
$hash = hash_hmac('sha1', $binary_timestamp, $binary_key, true);
393
394
// See RFC 4226.
395
396
$offset = ord($hash[19]) & 0x0F;
397
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]) ) );
402
403
$code = ($code % 1000000);
404
$code = str_pad($code, 6, '0', STR_PAD_LEFT);
405
406
return $code;
407
}
408
409
private function getTimestepDuration() {
410
return 30;
411
}
412
413
private function getCurrentTimestep() {
414
$duration = $this->getTimestepDuration();
415
return (int)(PhabricatorTime::getNow() / $duration);
416
}
417
418
private function getAllowedTimesteps($at_timestep) {
419
$window = $this->getTimestepWindowSize();
420
$range = range($at_timestep - $window, $at_timestep + $window);
421
return array_fuse($range);
422
}
423
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;
429
}
430
431
private function getTimestepAtWhichResponseIsValid(
432
array $timesteps,
433
PhutilOpaqueEnvelope $key,
434
$code) {
435
436
foreach ($timesteps as $timestep) {
437
$expect_code = self::getTOTPCode($key, $timestep);
438
if (phutil_hashes_are_identical($code, $expect_code)) {
439
return $timestep;
440
}
441
}
442
443
return null;
444
}
445
446
protected function newMFASyncTokenProperties(
447
PhabricatorAuthFactorProvider $providerr,
448
PhabricatorUser $user) {
449
return array(
450
'secret' => self::generateNewTOTPKey(),
451
);
452
}
453
454
}
455
456