Skip to content

Commit

Permalink
Ground work for 2FA Push and support for Authy Push notifications. #35
Browse files Browse the repository at this point in the history
  • Loading branch information
ShaneMcC committed Mar 10, 2019
1 parent af27ac0 commit 84506a5
Show file tree
Hide file tree
Showing 9 changed files with 225 additions and 5 deletions.
8 changes: 8 additions & 0 deletions admin/init_functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,14 @@ public function run($pdo) {
$dataChanges[25] = new DBChange(<<<MYSQLQUERY
ALTER TABLE `users` ADD COLUMN `avatar` varchar(255) NOT NULL DEFAULT 'gravatar' AFTER `acceptterms`;
MYSQLQUERY
);

// ------------------------------------------------------------------------
// 2FA Key "push-based" keys.
// ------------------------------------------------------------------------
$dataChanges[26] = new DBChange(<<<MYSQLQUERY
ALTER TABLE `twofactorkeys` ADD COLUMN `push` ENUM('false', 'true') NOT NULL DEFAULT 'false' AFTER `active`;
MYSQLQUERY
);

return $dataChanges;
Expand Down
75 changes: 75 additions & 0 deletions classes/twofactorkey.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class TwoFactorKey extends DBObject {
'lastused' => 0,
'expires' => 0,
'active' => false,
'push' => false,
'type' => 'rfc6238',
'onetime' => false,
'internal' => false,
Expand Down Expand Up @@ -41,6 +42,7 @@ public function setKey($value) {
break;

case "yubikeyotp":
case "authy":
throw new TwoFactorKeyAutoValueException($type . ' does not use auto-generated keys.');
break;

Expand Down Expand Up @@ -99,9 +101,23 @@ public function setType($value) {
// Clear key when changing type.
$this->setData('key', NULL);

switch (strtolower($value)) {
case "authy":
$this->setPush(true);
break;

default:
$this->setPush(false);
break;
}

return $this->setData('type', strtolower($value));
}

public function setPush($value) {
return $this->setData('push', parseBool($value) ? 'true' : 'false');
}

public function setOneTime($value) {
return $this->setData('onetime', parseBool($value) ? 'true' : 'false');
}
Expand Down Expand Up @@ -150,6 +166,10 @@ public function getType() {
return $this->getData('type');
}

public function isPush() {
return parseBool($this->getData('push'));
}

public function isOneTime() {
return parseBool($this->getData('onetime'));
}
Expand All @@ -169,6 +189,10 @@ public static function getKeyTypes() {
$result[] = "yubikeyotp";
}

if (self::canUseAuthy()) {
$result[] = "authy";
}

return $result;
}

Expand Down Expand Up @@ -196,6 +220,10 @@ public function isUsableKey() {
$usable = false;
}

if ($this->getType() == 'authy' && !self::canUseAuthy()) {
$usable = false;
}

return $usable;
}

Expand Down Expand Up @@ -232,6 +260,9 @@ public function validate() {
}

public function verify($code, $discrepancy = 1) {
// Push-Based tokens don't verify with a code.
if ($this->isPush()) { return FALSE; }

$type = $this->getType();
switch ($type) {
case "rfc6238":
Expand All @@ -248,12 +279,32 @@ public function verify($code, $discrepancy = 1) {
}
}

public function pushVerify($message) {
// Non-Push-Based tokens verify with a code.
if (!$this->isPush()) { return FALSE; }

$type = $this->getType();
switch ($type) {
case "authy":
return $this->verify_authypush($message);

default:
throw new Exception('Unknown key type: ' . $type);
}
}

private static function canUseYubikey() {
global $config;

return isset($config['twofactor']['yubikey']['enabled']) && $config['twofactor']['yubikey']['enabled'];
}

private static function canUseAuthy() {
global $config;

return isset($config['twofactor']['authy']['enabled']) && $config['twofactor']['authy']['enabled'];
}

private function yubikey_getData($code, $check = true) {
global $config;
$code = strtolower($code);
Expand Down Expand Up @@ -299,4 +350,28 @@ private function verify_rfc6238($code, $discrepancy = 1) {

return false;
}

private function verify_authypush($message) {
global $config;
if (!self::canUseAuthy()) { return FALSE; }

$authy_api = new Authy\AuthyApi($config['twofactor']['authy']['apikey']);

$response = $authy_api->createApprovalRequest($this->getKey(), $message);
$uuid = $response->bodyvar('approval_request')->uuid;

// 20 second time out.
for ($i = 0; $i < 20; $i++) {
$response = $authy_api->getApprovalRequest($uuid);
$status = $response->bodyvar('approval_request')->status;

if ($status == 'approved') { return TRUE; }
if ($status == 'denied') { return FALSE; }

// Sleep a bit.
sleep(1);
}

return FALSE;
}
}
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"shanemcc/php-db": "0.4",
"influxdb/influxdb-php": "^1.14",
"react/child-process": "^0.6.0",
"enygma/yubikey": "^3.3"
"enygma/yubikey": "^3.3",
"authy/php": "^3.0"
},
"autoload": {
"classmap": [
Expand Down
50 changes: 48 additions & 2 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions config.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
$config['twofactor']['yubikey']['secret'] = 'FOOBAR=';
$config['twofactor']['yubikey']['enabled'] = false;

// Configuration for AUTHY Authentication
$config['twofactor']['authy']['clientid'] = '12345';
$config['twofactor']['authy']['secret'] = 'FOOBAR=';
$config['twofactor']['authy']['enabled'] = false;

// Minimum terms time required to be considered "accepted".
//
// If this is not met, /userdata responses will show `"acceptterms": false`
Expand Down
16 changes: 16 additions & 0 deletions functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ function checkDBAlive() {

HookManager::get()->addHookType('send_mail');

HookManager::get()->addHookType('verify_2fa_push');

HookManager::get()->addHookType('call_domain_hooks');

if ($config['jobserver']['type'] == 'gearman') {
Expand All @@ -76,10 +78,24 @@ function checkDBAlive() {
HookManager::get()->addHook('send_mail', function($to, $subject, $message, $htmlmessage = NULL) use ($gmc) {
@$gmc->doBackground('sendmail', json_encode(['to' => $to, 'subject' => $subject, 'message' => $message, 'htmlmessage' => $htmlmessage]));
});

HookManager::get()->addHook('verify_2fa_push', function($key, $message) use ($gmc) {
@$gmc->doBackground('verify_2fa_push', json_encode(['keyid' => $key->getID(), 'userid' => $key->getUserID(), 'message' => $message]));
});
} else {
HookManager::get()->addHookBackground('send_mail', function($to, $subject, $message, $htmlmessage = NULL) {
Mailer::get()->send($to, $subject, $message, $htmlmessage);
});

HookManager::get()->addHookBackground('verify_2fa_push', function($key, $message) use ($config) {
if ($key->isPush()) {
if ($key->pushVerify($message)) {
$key->setActive(true);
if (!$key->isOneTime()) { $key->setLastUsed(time()); }
$key->save();
}
}
});
}

// Load the hooks
Expand Down
36 changes: 36 additions & 0 deletions tasks/workers/verify_2fa_push.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php
use shanemcc\phpdb\DB;

/**
* Task to verify 2FA Keys.
*
* Payload should be a json string with 'keyid', 'userid' and 'message' fields.
*/
class verify_2fa_push extends TaskWorker {
public function run($job) {
$payload = $job->getPayload();

if (isset($payload['keyid']) && isset($payload['userid']) && isset($payload['message'])) {

$key = TwoFactorKey::loadFromUserKey(DB::get(), $payload['userid'], $payload['keyid']);

if ($key != FALSE && $key->isPush()) {
if ($key->pushVerify($message)) {
$key->setActive(true);
if (!$key->isOneTime()) { $key->setLastUsed(time()); }
$key->save();

$job->setResult('OK');
} else {
$job->setError('Push not approved.');
}
} else {
$job->setError('Key not found');
}


} else {
$job->setError('Missing fields in payload.');
}
}
}
26 changes: 25 additions & 1 deletion web/1.0/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -182,10 +182,11 @@
if (count($keys) > 0) {
$valid = false;
$testCode = isset($_SERVER['HTTP_X_2FA_KEY']) ? $_SERVER['HTTP_X_2FA_KEY'] : NULL;
$tryPush = isset($_SERVER['HTTP_X_2FA_PUSH']) && parseBool($_SERVER['HTTP_X_2FA_PUSH']);

if ($testCode !== NULL) {
foreach ($keys as $key) {
if ($key->verify($testCode, 1)) {
if (!$key->isPush() && $key->verify($testCode, 1)) {
$valid = true;
$key->setLastUsed(time())->save();

Expand Down Expand Up @@ -221,9 +222,32 @@
}
$errorExtraData = '2FA key invalid.';
$resp->setHeader('login_error', '2fa_invalid');
} else if ($tryPush) {
foreach ($keys as $key) {
if ($key->isPush() && $key->pushVerify('Login to ' . $config['sitename'])) {
// Valid push.

// Create a new short-term key.
$tempKey = (new TwoFactorKey($context['db']))->setUserID($user->getID())->setCreated(time());
$tempKey->setDescription('Push Login Token');
$tempKey->setType('plain')->setKey(true)->setOneTime(true)->setInternal(true)->setActive(true)->setExpires(time() + 30);
$tempKey->save();

// Let the user know the key code.
$errorExtraData = '2FA key required.';
$resp->setHeader('login_error', '2fa_required');
$resp->setHeader('pushcode', $tempKey->getKey());
}
}
} else {
$errorExtraData = '2FA key required.';
$resp->setHeader('login_error', '2fa_required');
foreach ($keys as $key) {
if ($key->isPush()) {
$resp->setHeader('2fa_push', '2fa push supported');
break;
}
}
}
} else if (empty($keys) && isset($_SERVER['HTTP_X_2FA_KEY'])) {
$errorExtraData = '2FA key provided but not required.';
Expand Down
11 changes: 10 additions & 1 deletion web/1.0/methods/useradmin.php
Original file line number Diff line number Diff line change
Expand Up @@ -598,12 +598,21 @@ private function doUpdate2FAKey($key, $data) {
}

protected function verify2FAKey($user, $key) {
global $config;

$data = $this->getContextKey('data');
if (!isset($data['data']) || !is_array($data['data']) || !isset($data['data']['code'])) {
$this->getContextKey('response')->sendError('No code provided for verification.');
}

if (!$key->verify($data['data']['code'], 1)) {
if ($key->isPush()) {
$this->getContextKey('response')->setHeader('info', 'Key will be verified in the background.');

// Fire off a key-validation hook.
HookManager::get()->handle('verify_2fa_push', [$key, 'Key verification on ' . $config['sitename']]);

return TRUE;
} else if (!$key->isPush() && !$key->verify($data['data']['code'], 1)) {
$this->getContextKey('response')->sendError('Invalid code provided for verification.');
}

Expand Down

0 comments on commit 84506a5

Please sign in to comment.