Summary: I stumbled across this TODO and was worried that there was a glaring hole in MFA that I'd somehow forgotten about, but the TODO is just out of date. These actions are rate limited properly by `PhabricatorAuthTryFactorAction`, which permits a maximum of 10 actions per hour. - Remove the TODO. - Add `bin/auth unlimit` to make it easier to reset rate limits if someone needs to do that for whatever reason. Test Plan: - Tried to brute force through MFA. - Got rate limited properly after 10 failures. - Reset rate limit with `bin/auth unlimit`. - Saw the expected number of actions clear. {F805288} Reviewers: chad Reviewed By: chad Subscribers: joshuaspence Differential Revision: https://secure.phabricator.com/D14105
306 lines
8.3 KiB
PHP
306 lines
8.3 KiB
PHP
<?php
|
|
|
|
final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor {
|
|
|
|
const TEMPORARY_TOKEN_TYPE = 'mfa:totp:key';
|
|
|
|
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) {
|
|
|
|
$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.)
|
|
|
|
$temporary_token = id(new PhabricatorAuthTemporaryTokenQuery())
|
|
->setViewer($user)
|
|
->withObjectPHIDs(array($user->getPHID()))
|
|
->withTokenTypes(array(self::TEMPORARY_TOKEN_TYPE))
|
|
->withExpired(false)
|
|
->withTokenCodes(array(PhabricatorHash::digest($key)))
|
|
->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.
|
|
|
|
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
|
|
id(new PhabricatorAuthTemporaryToken())
|
|
->setObjectPHID($user->getPHID())
|
|
->setTokenType(self::TEMPORARY_TOKEN_TYPE)
|
|
->setTokenExpires(time() + phutil_units('1 hour in seconds'))
|
|
->setTokenCode(PhabricatorHash::digest($key))
|
|
->save();
|
|
unset($unguarded);
|
|
}
|
|
|
|
$code = $request->getStr('totpcode');
|
|
|
|
$e_code = true;
|
|
if ($request->getExists('totp')) {
|
|
$okay = self::verifyTOTPCode(
|
|
$user,
|
|
new PhutilOpaqueEnvelope($key),
|
|
$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 AphrontFormTextControl())
|
|
->setLabel(pht('TOTP Code'))
|
|
->setName('totpcode')
|
|
->setValue($code)
|
|
->setError($e_code));
|
|
|
|
}
|
|
|
|
public function renderValidateFactorForm(
|
|
PhabricatorAuthFactorConfig $config,
|
|
AphrontFormView $form,
|
|
PhabricatorUser $viewer,
|
|
$validation_result) {
|
|
|
|
if (!$validation_result) {
|
|
$validation_result = array();
|
|
}
|
|
|
|
$form->appendChild(
|
|
id(new AphrontFormTextControl())
|
|
->setName($this->getParameterName($config, 'totpcode'))
|
|
->setLabel(pht('App Code'))
|
|
->setCaption(pht('Factor Name: %s', $config->getFactorName()))
|
|
->setValue(idx($validation_result, 'value'))
|
|
->setError(idx($validation_result, 'error', true)));
|
|
}
|
|
|
|
public function processValidateFactorForm(
|
|
PhabricatorAuthFactorConfig $config,
|
|
PhabricatorUser $viewer,
|
|
AphrontRequest $request) {
|
|
|
|
$code = $request->getStr($this->getParameterName($config, 'totpcode'));
|
|
$key = new PhutilOpaqueEnvelope($config->getFactorSecret());
|
|
|
|
if (self::verifyTOTPCode($viewer, $key, $code)) {
|
|
return array(
|
|
'error' => null,
|
|
'value' => $code,
|
|
'valid' => true,
|
|
);
|
|
} else {
|
|
return array(
|
|
'error' => strlen($code) ? pht('Invalid') : pht('Required'),
|
|
'value' => $code,
|
|
'valid' => false,
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
public static function generateNewTOTPKey() {
|
|
return strtoupper(Filesystem::readRandomCharacters(16));
|
|
}
|
|
|
|
public static function verifyTOTPCode(
|
|
PhabricatorUser $user,
|
|
PhutilOpaqueEnvelope $key,
|
|
$code) {
|
|
|
|
$now = (int)(time() / 30);
|
|
|
|
// Allow the user to enter a code a few minutes away on either side, in
|
|
// case the server or client has some clock skew.
|
|
for ($offset = -2; $offset <= 2; $offset++) {
|
|
$real = self::getTOTPCode($key, $now + $offset);
|
|
if (phutil_hashes_are_identical($real, $code)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// TODO: After validating a code, this should mark it as used and prevent
|
|
// it from being reused.
|
|
|
|
return false;
|
|
}
|
|
|
|
|
|
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);
|
|
}
|
|
|
|
}
|