From 84506a582ceacab28cbda1c03baf90a6861898ff Mon Sep 17 00:00:00 2001 From: Shane Mc Cormack Date: Sun, 10 Mar 2019 22:36:09 +0000 Subject: [PATCH] Ground work for 2FA Push and support for Authy Push notifications. #35 --- admin/init_functions.php | 8 ++++ classes/twofactorkey.php | 75 +++++++++++++++++++++++++++++++ composer.json | 3 +- composer.lock | 50 ++++++++++++++++++++- config.php | 5 +++ functions.php | 16 +++++++ tasks/workers/verify_2fa_push.php | 36 +++++++++++++++ web/1.0/index.php | 26 ++++++++++- web/1.0/methods/useradmin.php | 11 ++++- 9 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 tasks/workers/verify_2fa_push.php diff --git a/admin/init_functions.php b/admin/init_functions.php index 1594bc2..252283d 100644 --- a/admin/init_functions.php +++ b/admin/init_functions.php @@ -423,6 +423,14 @@ public function run($pdo) { $dataChanges[25] = new DBChange(<< 0, 'expires' => 0, 'active' => false, + 'push' => false, 'type' => 'rfc6238', 'onetime' => false, 'internal' => false, @@ -41,6 +42,7 @@ public function setKey($value) { break; case "yubikeyotp": + case "authy": throw new TwoFactorKeyAutoValueException($type . ' does not use auto-generated keys.'); break; @@ -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'); } @@ -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')); } @@ -169,6 +189,10 @@ public static function getKeyTypes() { $result[] = "yubikeyotp"; } + if (self::canUseAuthy()) { + $result[] = "authy"; + } + return $result; } @@ -196,6 +220,10 @@ public function isUsableKey() { $usable = false; } + if ($this->getType() == 'authy' && !self::canUseAuthy()) { + $usable = false; + } + return $usable; } @@ -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": @@ -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); @@ -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; + } } diff --git a/composer.json b/composer.json index 6d0cc46..20e172e 100644 --- a/composer.json +++ b/composer.json @@ -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": [ diff --git a/composer.lock b/composer.lock index da8e659..84e9728 100644 --- a/composer.lock +++ b/composer.lock @@ -1,11 +1,57 @@ { "_readme": [ "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "content-hash": "25b6b9f9e89559385e1fcb8c9eb47167", + "content-hash": "6975c769a893c275a5b4f4dbf8b7057a", "packages": [ + { + "name": "authy/php", + "version": "3.0.4", + "source": { + "type": "git", + "url": "https://github.com/twilio/authy-php.git", + "reference": "3fa3b3664f838f94331be5749a4d198b2053cc94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twilio/authy-php/zipball/3fa3b3664f838f94331be5749a4d198b2053cc94", + "reference": "3fa3b3664f838f94331be5749a4d198b2053cc94", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "~6.0", + "php": ">=5.6.0" + }, + "require-dev": { + "eher/phpunit": ">= 1.6" + }, + "type": "library", + "autoload": { + "psr-0": { + "Authy": "lib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "David Cuadrado", + "email": "david@authy.com" + } + ], + "description": "PHP client for Authy", + "homepage": "https://github.com/authy/authy-php", + "keywords": [ + "Authentication", + "api", + "two-factor" + ], + "time": "2018-09-06T20:10:54+00:00" + }, { "name": "cocur/background-process", "version": "v0.7", diff --git a/config.php b/config.php index f52b048..c39fe56 100644 --- a/config.php +++ b/config.php @@ -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` diff --git a/functions.php b/functions.php index b051bf2..34f1134 100644 --- a/functions.php +++ b/functions.php @@ -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') { @@ -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 diff --git a/tasks/workers/verify_2fa_push.php b/tasks/workers/verify_2fa_push.php new file mode 100644 index 0000000..a655b02 --- /dev/null +++ b/tasks/workers/verify_2fa_push.php @@ -0,0 +1,36 @@ +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.'); + } + } + } diff --git a/web/1.0/index.php b/web/1.0/index.php index 93dbf4a..f9660c9 100644 --- a/web/1.0/index.php +++ b/web/1.0/index.php @@ -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(); @@ -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.'; diff --git a/web/1.0/methods/useradmin.php b/web/1.0/methods/useradmin.php index 756fc97..6c5b550 100644 --- a/web/1.0/methods/useradmin.php +++ b/web/1.0/methods/useradmin.php @@ -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.'); }