From 073b57e50a21c299231e49f6215092cbe721f2a3 Mon Sep 17 00:00:00 2001 From: Marcelo Rocha Date: Fri, 8 Feb 2019 10:01:51 -0200 Subject: [PATCH 01/18] Added migration for u2f feature --- .../20190207112757_CreateU2fRegistrations.php | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 config/Migrations/20190207112757_CreateU2fRegistrations.php diff --git a/config/Migrations/20190207112757_CreateU2fRegistrations.php b/config/Migrations/20190207112757_CreateU2fRegistrations.php new file mode 100644 index 000000000..4590f4ecd --- /dev/null +++ b/config/Migrations/20190207112757_CreateU2fRegistrations.php @@ -0,0 +1,49 @@ +table('u2f_registrations'); + $table->addColumn('user_id', 'uuid', [ + 'null' => false, + ]); + $table->addColumn('keyHandle', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => false, + ]); + $table->addColumn('publicKey', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => false, + ]); + $table->addColumn('certificate', 'text', [ + 'default' => null, + 'null' => false, + ]); + $table->addColumn('counter', 'integer', [ + 'default' => null, + 'limit' => 11, + 'null' => false, + ]); + $table->create(); + } +} From bd6e149023c088af8fdf0f7bc6391ae1e7e8d294 Mon Sep 17 00:00:00 2001 From: Marcelo Rocha Date: Fri, 8 Feb 2019 10:04:21 -0200 Subject: [PATCH 02/18] Added classes to check when u2f is enabled/disabled --- src/Auth/DefaultU2fAuthenticationChecker.php | 43 ++++++++++++ src/Auth/U2fAuthenticationCheckerFactory.php | 39 +++++++++++ .../U2fAuthenticationCheckerInterface.php | 30 +++++++++ .../DefaultU2fAuthenticationCheckerTest.php | 67 +++++++++++++++++++ .../U2fAuthenticationCheckerFactoryTest.php | 44 ++++++++++++ 5 files changed, 223 insertions(+) create mode 100644 src/Auth/DefaultU2fAuthenticationChecker.php create mode 100644 src/Auth/U2fAuthenticationCheckerFactory.php create mode 100644 src/Auth/U2fAuthenticationCheckerInterface.php create mode 100644 tests/TestCase/Auth/DefaultU2fAuthenticationCheckerTest.php create mode 100644 tests/TestCase/Auth/U2fAuthenticationCheckerFactoryTest.php diff --git a/src/Auth/DefaultU2fAuthenticationChecker.php b/src/Auth/DefaultU2fAuthenticationChecker.php new file mode 100644 index 000000000..3b9f49487 --- /dev/null +++ b/src/Auth/DefaultU2fAuthenticationChecker.php @@ -0,0 +1,43 @@ +isEnabled(); + } +} diff --git a/src/Auth/U2fAuthenticationCheckerFactory.php b/src/Auth/U2fAuthenticationCheckerFactory.php new file mode 100644 index 000000000..5defdbae1 --- /dev/null +++ b/src/Auth/U2fAuthenticationCheckerFactory.php @@ -0,0 +1,39 @@ +assertFalse($Checker->isEnabled()); + + Configure::write('U2f.enabled', true); + $Checker = new DefaultU2fAuthenticationChecker(); + $this->assertTrue($Checker->isEnabled()); + + Configure::delete('U2f.enabled'); + $Checker = new DefaultU2fAuthenticationChecker(); + $this->assertTrue($Checker->isEnabled()); + } + + /** + * Test isRequired method + * + * @return void + */ + public function testIsRequired() + { + Configure::write('U2f.enabled', false); + $Checker = new DefaultU2fAuthenticationChecker(); + $this->assertFalse($Checker->isRequired(['id' => 10])); + + Configure::write('U2f.enabled', true); + $Checker = new DefaultU2fAuthenticationChecker(); + $this->assertTrue($Checker->isRequired(['id' => 10])); + + Configure::delete('U2f.enabled'); + $Checker = new DefaultU2fAuthenticationChecker(); + $this->assertTrue($Checker->isRequired(['id' => 10])); + + $Checker = new DefaultU2fAuthenticationChecker(); + $this->assertFalse($Checker->isRequired([])); + } +} diff --git a/tests/TestCase/Auth/U2fAuthenticationCheckerFactoryTest.php b/tests/TestCase/Auth/U2fAuthenticationCheckerFactoryTest.php new file mode 100644 index 000000000..bf40723ed --- /dev/null +++ b/tests/TestCase/Auth/U2fAuthenticationCheckerFactoryTest.php @@ -0,0 +1,44 @@ +build(); + $this->assertInstanceOf(DefaultU2fAuthenticationChecker::class, $result); + } + + /** + * Test getChecker method + * + * @return void + */ + public function testGetCheckerInvalidInterface() + { + Configure::write('U2f.checker', 'stdClass'); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid config for 'U2f.checker', 'stdClass' does not implement 'CakeDC\Users\Auth\U2fAuthenticationCheckerInterface'"); + (new U2fAuthenticationCheckerFactory())->build(); + } +} From 601469fd45bc139b6410f97b3d552ddeceaba365 Mon Sep 17 00:00:00 2001 From: Marcelo Rocha Date: Fri, 8 Feb 2019 10:07:45 -0200 Subject: [PATCH 03/18] Added actions for u2f feature --- composer.json | 3 +- config/users.php | 10 + .../Component/UsersAuthComponent.php | 6 + src/Controller/Traits/U2fTrait.php | 207 +++++ src/Controller/UsersController.php | 18 + src/Model/Table/UsersTable.php | 4 + src/Template/Users/u2f_authenticate.ctp | 58 ++ src/Template/Users/u2f_register.ctp | 63 ++ tests/Fixture/U2fRegistrationsFixture.php | 55 ++ .../Controller/Traits/U2fTraitTest.php | 706 ++++++++++++++++++ 10 files changed, 1129 insertions(+), 1 deletion(-) create mode 100644 src/Controller/Traits/U2fTrait.php create mode 100644 src/Template/Users/u2f_authenticate.ctp create mode 100644 src/Template/Users/u2f_register.ctp create mode 100644 tests/Fixture/U2fRegistrationsFixture.php create mode 100644 tests/TestCase/Controller/Traits/U2fTraitTest.php diff --git a/composer.json b/composer.json index ed122d9e0..7d4293bfe 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,8 @@ "league/oauth2-linkedin": "@stable", "luchianenco/oauth2-amazon": "^1.1", "google/recaptcha": "@stable", - "robthree/twofactorauth": "~1.6.0" + "robthree/twofactorauth": "~1.6.0", + "yubico/u2flib-server": "^1.0" }, "suggest": { "league/oauth1-client": "Provides Social Authentication with Twitter", diff --git a/config/users.php b/config/users.php index 45a15f5ec..982aab6ad 100644 --- a/config/users.php +++ b/config/users.php @@ -126,6 +126,16 @@ 'prefix' => false, ], ], + 'U2f' => [ + 'enabled' => false, + 'checker' => \CakeDC\Users\Auth\DefaultU2fAuthenticationChecker::class, + 'startAction' => [ + 'plugin' => 'CakeDC/Users', + 'controller' => 'Users', + 'action' => 'u2f', + 'prefix' => false, + ] + ], // default configuration used to auto-load the Auth Component, override to change the way Auth works 'Auth' => [ 'loginAction' => [ diff --git a/src/Controller/Component/UsersAuthComponent.php b/src/Controller/Component/UsersAuthComponent.php index 92fa56e7d..5e1458396 100644 --- a/src/Controller/Component/UsersAuthComponent.php +++ b/src/Controller/Component/UsersAuthComponent.php @@ -141,6 +141,12 @@ protected function _initAuth() // Social 'endpoint', 'authenticated', + + 'u2f', + 'u2fRegister', + 'u2fRegisterFinish', + 'u2fAuthenticate', + 'u2fAuthenticateFinish', ]); } } diff --git a/src/Controller/Traits/U2fTrait.php b/src/Controller/Traits/U2fTrait.php new file mode 100644 index 000000000..6c8c43077 --- /dev/null +++ b/src/Controller/Traits/U2fTrait.php @@ -0,0 +1,207 @@ +request->getSession()->read('U2f.User'); + if (!isset($user['id'])) { + return $this->redirect([ + 'action' => 'login' + ]); + } + $user = $this->getUsersTable()->get($user['id']); + $hasRegistration = $this->getUsersTable()->U2fRegistrations->exists([ + 'user_id' => $user['id'] + ]); + + if (!$hasRegistration) { + return $this->redirect([ + 'action' => 'u2fRegister' + ]); + } + + return $this->redirect([ + 'action' => 'u2fAuthenticate' + ]); + } + + /** + * Show u2f register start step + * + * @return \Cake\Http\Response|null + * @throws \u2flib_server\Error + */ + public function u2fRegister() + { + $data = $this->getU2fData(); + if (!$data['valid']) { + return $this->redirect([ + 'action' => 'login' + ]); + } + + if (!$data['registration']) { + list($registerRequest, $signs) = $this->createU2fLib()->getRegisterData(); + $this->request->getSession()->write('U2f.registerRequest', json_encode($registerRequest)); + $this->set(compact('registerRequest', 'signs')); + + return null; + } + + return $this->redirect([ + 'action' => 'u2fAuthenticate' + ]); + } + + /** + * Show u2f register finish step + * + * @return \Cake\Http\Response|null + */ + public function u2fRegisterFinish() + { + $data = $this->getU2fData(); + $request = json_decode($this->request->getSession()->read('U2f.registerRequest')); + $response = json_decode($this->request->getData('registerResponse')); + try { + $registration = $this->createU2fLib()->doRegister($request, $response); + $registration = json_decode(json_encode($registration), true); + $registration['user_id'] = $data['user']['id']; + $table = $this->getUsersTable()->U2fRegistrations; + $entity = $table->newEntity($registration); + $table->saveOrFail($entity); + $this->request->getSession()->delete('U2f.registerRequest'); + + return $this->redirect([ + 'action' => 'u2fAuthenticate' + ]); + } catch (\Exception $e) { + $this->request->getSession()->delete('U2f.registerRequest'); + + return $this->redirect([ + 'action' => 'u2fRegister' + ]); + } + } + + /** + * Show u2f authenticate start step + * + * @return \Cake\Http\Response|null + */ + public function u2fAuthenticate() + { + $data = $this->getU2fData(); + if (!$data['valid']) { + return $this->redirect([ + 'action' => 'login' + ]); + } + + if (!$data['registration']) { + return $this->redirect([ + 'action' => 'u2fRegister' + ]); + } + $authenticateRequest = $this->createU2fLib()->getAuthenticateData([$data['registration']]); + $this->request->getSession()->write('U2f.authenticateRequest', json_encode($authenticateRequest)); + $this->set(compact('authenticateRequest')); + } + + /** + * Show u2f Authenticate finish step + * + * @return \Cake\Http\Response|null + */ + public function u2fAuthenticateFinish() + { + $data = $this->getU2fData(); + $request = json_decode($this->request->getSession()->read('U2f.authenticateRequest')); + $response = json_decode($this->request->getData('authenticateResponse')); + + try { + $Model = $this->getUsersTable()->U2fRegistrations; + $registration = $Model->find('all')->where([ + 'user_id' => $data['user']['id'] + ])->first(); + + $result = $this->createU2fLib()->doAuthenticate($request, [$registration], $response); + $registration->counter = $result->counter; + $Model->saveOrFail($registration); + $this->request->getSession()->delete('U2f'); + $this->Auth->setUser($data['user']); + + return $this->redirect($this->Auth->redirectUrl()); + } catch (\Exception $e) { + $this->request->getSession()->delete('U2f.authenticateRequest'); + + return $this->redirect([ + 'action' => 'u2fAuthenticate' + ]); + } + } + + /** + * Create a u2f lib + * + * @return U2F + * @throws \u2flib_server\Error + */ + protected function createU2fLib() + { + $appId = $this->request->scheme() . '://' . $this->request->host(); + + return new U2F($appId); + } + + /** + * Get essential U2f data + * + * @return array + */ + protected function getU2fData() + { + $data = [ + 'valid' => false, + 'user' => null, + 'registration' => null + ]; + $data['user'] = $this->request->getSession()->read('U2f.User'); + if (!isset($data['user']['id'])) { + return $data; + } + $data['valid'] = $this->getU2fAuthenticationChecker()->isEnabled(); + $data['registration'] = $this->getUsersTable()->U2fRegistrations->find('all')->where([ + 'user_id' => $data['user']['id'] + ])->first(); + + return $data; + } + + /** + * Get the configured two factory authentication + * + * @return \CakeDC\Users\Auth\U2fAuthenticationCheckerInterface + */ + protected function getU2fAuthenticationChecker() + { + return (new U2fAuthenticationCheckerFactory())->build(); + } +} diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index b86bd0940..30f8f1fe9 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -20,6 +20,7 @@ use CakeDC\Users\Controller\Traits\RegisterTrait; use CakeDC\Users\Controller\Traits\SimpleCrudTrait; use CakeDC\Users\Controller\Traits\SocialTrait; +use CakeDC\Users\Controller\Traits\U2fTrait; use CakeDC\Users\Model\Table\UsersTable; use Cake\Core\Configure; use Cake\ORM\Table; @@ -38,4 +39,21 @@ class UsersController extends AppController use RegisterTrait; use SimpleCrudTrait; use SocialTrait; + use U2fTrait; + + /** + * Initialize + * + * @return void + */ + public function initialize() + { + parent::initialize(); + if ($this->components()->has('Security')) { + $this->Security->setConfig( + 'unlockedActions', + ['u2fRegister', 'u2fRegisterFinish', 'u2fAuthenticate', 'u2fAuthenticateFinish'] + ); + } + } } diff --git a/src/Model/Table/UsersTable.php b/src/Model/Table/UsersTable.php index d8e1919f7..fb9808ca2 100644 --- a/src/Model/Table/UsersTable.php +++ b/src/Model/Table/UsersTable.php @@ -59,6 +59,10 @@ public function initialize(array $config) 'foreignKey' => 'user_id', 'className' => 'CakeDC/Users.SocialAccounts' ]); + $this->hasMany('U2fRegistrations', [ + 'foreignKey' => 'user_id', + 'className' => 'CakeDC/Users.U2fRegistrations' + ]); } /** diff --git a/src/Template/Users/u2f_authenticate.ctp b/src/Template/Users/u2f_authenticate.ctp new file mode 100644 index 000000000..f34fe04b3 --- /dev/null +++ b/src/Template/Users/u2f_authenticate.ctp @@ -0,0 +1,58 @@ +Html->script('CakeDC/Users.u2f-api.js', ['block' => true]); +?> +
+
+
+
+ Form->create(false, [ + 'url' => [ + 'action' => 'u2fAuthenticateFinish' + ], + 'id' => 'u2fAuthenticateFrm' + ]) ?> + + Flash->render('auth') ?> + Flash->render() ?> +
+

+

+

+

+

Html->link( + __('Reload'), + ['action' => 'u2fAuthenticate'], + ['class' => 'btn btn-primary'] + )?>

+
+ Form->hidden('authenticateResponse', ['secure' => false, 'id' => 'authenticateResponse'])?> + Form->end() ?> +
+
+
+
+Html->scriptStart(['block' => true]); +?> + setTimeout(function() { + var req = ; + var appId = req[0].appId; + var challenge = req[0].challenge; + + u2f.sign(appId, challenge, req, function(data) { + var targetForm = document.getElementById('u2fAuthenticateFrm'); + var targetInput = document.getElementById('authenticateResponse'); + if(data.errorCode && data.errorCode != 0) { + alert("Yubico key check has failed, please try again"); + + return; + } + targetInput.value = JSON.stringify(data); + targetForm.submit(); + }); + }, 1000); +Html->scriptEnd();?> diff --git a/src/Template/Users/u2f_register.ctp b/src/Template/Users/u2f_register.ctp new file mode 100644 index 000000000..9bad510f0 --- /dev/null +++ b/src/Template/Users/u2f_register.ctp @@ -0,0 +1,63 @@ +Html->script('CakeDC/Users.u2f-api.js', ['block' => true]); +?> +
+
+
+
+ Form->create(false, [ + 'url' => [ + 'action' => 'u2fRegisterFinish' + ], + 'id' => 'u2fRegisterFrm' + ]) ?> + + Flash->render('auth') ?> + Flash->render() ?> +
+

+

+

In order to enable your YubiKey the first step is to perform a registration.

+

When the YubiKey starts blinking, press the golden disc to activate it. Depending on the web browser you might need to confirm the use of extended information from the YubiKey.

+

Html->link( + __('Reload'), + ['action' => 'u2fRegister'], + ['class' => 'btn btn-primary'] + )?>

+
+ Form->hidden('registerResponse', ['secure' => false, 'id' => 'registerResponse'])?> + Form->end() ?> +
+
+
+
+ $registerRequest->appId, + 'version' => $registerRequest->version, + 'challenge' => $registerRequest->challenge, + 'attestation' => 'direct' +]); +$this->Html->scriptStart(['block' => true]); +?> +setTimeout(function() { + var req = ; + var appId = req.appId; + var registerRequests = [req]; + u2f.register(appId, registerRequests, [], function(data) { + var targetForm = document.getElementById('u2fRegisterFrm'); + var targetInput = document.getElementById('registerResponse'); + + if(data.errorCode && data.errorCode != 0) { + alert("Yubico key check has failed, please try again"); + + return; + } + targetInput.value = JSON.stringify(data); + targetForm.submit(); + }); +}, 1000); +Html->scriptEnd();?> diff --git a/tests/Fixture/U2fRegistrationsFixture.php b/tests/Fixture/U2fRegistrationsFixture.php new file mode 100644 index 000000000..5f1cd092a --- /dev/null +++ b/tests/Fixture/U2fRegistrationsFixture.php @@ -0,0 +1,55 @@ + ['type' => 'integer', 'length' => 11, 'unsigned' => false, 'null' => false, 'default' => null, 'comment' => '', 'autoIncrement' => true, 'precision' => null], + 'user_id' => ['type' => 'uuid', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], + 'keyHandle' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'collate' => 'utf8_slovenian_ci', 'comment' => '', 'precision' => null, 'fixed' => null], + 'publicKey' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'collate' => 'utf8_slovenian_ci', 'comment' => '', 'precision' => null, 'fixed' => null], + 'certificate' => ['type' => 'text', 'length' => null, 'null' => false, 'default' => null, 'collate' => 'utf8_slovenian_ci', 'comment' => '', 'precision' => null], + 'counter' => ['type' => 'integer', 'length' => 11, 'unsigned' => false, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null, 'autoIncrement' => null], + '_constraints' => [ + 'primary' => ['type' => 'primary', 'columns' => ['id'], 'length' => []], + ], + '_options' => [ + 'engine' => 'InnoDB', + 'collation' => 'utf8_slovenian_ci' + ], + ]; + // @codingStandardsIgnoreEnd + + /** + * Init method + * + * @return void + */ + public function init() + { + $this->records = [ + [ + 'id' => 1, + 'user_id' => '00000000-0000-0000-0000-000000000001', + 'keyHandle' => 'fake key handle', + 'publicKey' => 'afdoaj0-23u423-ad ujsf-as8-0-afsd', + 'certificate' => '23jdsfoasdj0f9sa082304823423', + 'counter' => 1 + ], + ]; + parent::init(); + } +} diff --git a/tests/TestCase/Controller/Traits/U2fTraitTest.php b/tests/TestCase/Controller/Traits/U2fTraitTest.php new file mode 100644 index 000000000..bcf325b9d --- /dev/null +++ b/tests/TestCase/Controller/Traits/U2fTraitTest.php @@ -0,0 +1,706 @@ +traitClassName = 'CakeDC\Users\Controller\Traits\U2fTrait'; + $this->traitMockMethods = ['dispatchEvent', 'isStopped', 'redirect', 'getUsersTable', 'set', 'createU2fLib', 'getData']; + + parent::setUp(); + + $this->Trait->Auth = $this->getMockBuilder('Cake\Controller\Component\AuthComponent') + ->setMethods(['setConfig', 'redirectUrl', 'setUser']) + ->disableOriginalConstructor() + ->getMock(); + + $request = new ServerRequest(); + $this->Trait->request = $request; + Configure::write('U2f.enabled', true); + } + + /** + * Mock session and mock session attributes + * + * @return \Cake\Http\Session + */ + protected function _mockSession($attributes) + { + $session = new \Cake\Http\Session(); + + foreach ($attributes as $field => $value) { + $session->write($field, $value); + } + + $this->Trait->request + ->expects($this->any()) + ->method('getSession') + ->willReturn($session); + + return $session; + } + + /** + * Data provider for testU2User + * + * @return array + */ + public function dataProviderU2User() + { + $empty = []; + $withRegistration = [ + 'id' => '00000000-0000-0000-0000-000000000001', + 'username' => 'user-1', + ]; + $withWhoutRegistration = [ + 'id' => '00000000-0000-0000-0000-000000000002', + 'username' => 'user-2', + ]; + + return [ + [$empty, ['action' => 'login']], + [$withWhoutRegistration, ['action' => 'u2fRegister']], + [$withRegistration, ['action' => 'u2fAuthenticate']] + ]; + } + /** + * Test u2f method + * + * @param array $userData session user data + * @param mixed $redirect expetected redirect + * + * @dataProvider dataProviderU2User + * @return void + */ + public function testU2fCustomUser($userData, $redirect) + { + $this->Trait->request = $this->getMockBuilder('Cake\Http\ServerRequest') + ->setMethods(['getSession']) + ->getMock(); + $response = new Response([ + 'body' => time() + ]); + $this->Trait->expects($this->once()) + ->method('redirect') + ->with( + $this->equalTo($redirect) + )->will($this->returnValue($response)); + $this->_mockSession([ + 'U2f.User' => $userData, + ]); + $actual = $this->Trait->u2f(); + $this->assertSame($response, $actual); + } + + /** + * Test u2fRegister method + * + * @return void + */ + public function testU2fRegisterOkay() + { + $this->Trait->request = $this->getMockBuilder('Cake\Http\ServerRequest') + ->setMethods(['getSession']) + ->getMock(); + + $u2fLib = $this->getMockBuilder(U2F::class) + ->setConstructorArgs(['https://localhost']) + ->setMethods(['getRegisterData']) + ->getMock(); + + $registerRequest = new RegisterRequest("sample chalange", "https://localhost"); + $signs = [ + ['fake' => new \stdClass()], + ['fake2' => new \stdClass()], + ]; + $u2fLib->expects($this->once()) + ->method('getRegisterData') + ->will($this->returnValue([$registerRequest, $signs])); + + $this->Trait->expects($this->once()) + ->method('createU2fLib') + ->will($this->returnValue($u2fLib)); + $this->Trait->expects($this->once()) + ->method('set') + ->with( + $this->equalTo([ + 'registerRequest' => $registerRequest, + 'signs' => $signs + ]) + ); + $this->Trait->expects($this->never()) + ->method('redirect'); + + $this->_mockSession([ + 'U2f.User' => [ + 'id' => '00000000-0000-0000-0000-000000000002', + 'username' => 'user-2', + ], + ]); + $actual = $this->Trait->u2fRegister(); + $this->assertNull($actual); + $actual = $this->Trait->request->getSession()->read('U2f.registerRequest'); + $expected = json_encode($registerRequest); + $this->assertEquals($expected, $actual); + } + + /** + * Data provider for testU2fRegisterRedirect + * + * @return array + */ + public function dataProviderU2fRegisterRedirect() + { + $empty = []; + $withRegistration = [ + 'id' => '00000000-0000-0000-0000-000000000001', + 'username' => 'user-1', + ]; + + return [ + [$empty, ['action' => 'login']], + [$withRegistration, ['action' => 'u2fAuthenticate']] + ]; + } + + /** + * Test u2fRegister method + * + * @param array $userData session user data + * @param mixed $redirect expetected redirect + * + * @dataProvider dataProviderU2fRegisterRedirect + * @return void + */ + public function testU2fRegisterRedirect($userData, $redirect) + { + $this->Trait->request = $this->getMockBuilder('Cake\Http\ServerRequest') + ->setMethods(['getSession']) + ->getMock(); + + $this->Trait->expects($this->never()) + ->method('createU2fLib'); + + $this->Trait->expects($this->never()) + ->method('set'); + + $this->_mockSession([ + 'U2f.User' => $userData, + ]); + $response = new Response([ + 'body' => time() + ]); + $this->Trait->expects($this->once()) + ->method('redirect') + ->with( + $this->equalTo($redirect) + )->will($this->returnValue($response)); + + $actual = $this->Trait->u2fRegister(); + $this->assertSame($response, $actual); + $actual = $this->Trait->request->getSession()->read('U2f.registerRequest'); + $expected = null; + $this->assertEquals($expected, $actual); + } + + /** + * Test u2fRegister method + * + * @return void + */ + public function testU2fRegisterFinish() + { + $this->Trait->request = $this->getMockBuilder('Cake\Http\ServerRequest') + ->setMethods(['getSession', 'getData']) + ->getMock(); + + $u2fLib = $this->getMockBuilder(U2F::class) + ->setConstructorArgs(['https://localhost']) + ->setMethods(['doRegister']) + ->getMock(); + + $registerRequest = new RegisterRequest("sample chalange", "https://localhost"); + $registerRequest = json_decode(json_encode($registerRequest)); + $signs = [ + ['fake' => new \stdClass()], + ['fake2' => new \stdClass()], + ]; + $registerResponse = json_decode(json_encode([ + 'fakeA' => 'fakevaluea', + 'fakeB' => 'fakevalueb' + ])); + $registration = new Registration(); + $registration->certificate = "user registration cert " . time(); + $registration->counter = 1; + $registration->publicKey = "pub skska08u90234230990"; + $registration->keyHandle = 'hahdofa02390423udu9ma0dumfá0dsufm2um9432uu903u923'; + + $this->Trait->request->expects($this->once()) + ->method('getData') + ->with($this->equalTo('registerResponse')) + ->will($this->returnValue(json_encode($registerResponse))); + $this->_mockSession([ + 'U2f' => [ + 'User' => [ + 'id' => '00000000-0000-0000-0000-000000000002', + 'username' => 'user-2', + ], + 'registerRequest' => json_encode($registerRequest) + ], + ]); + $u2fLib->expects($this->once()) + ->method('doRegister') + ->with( + $this->equalTo($registerRequest), + $this->equalTo($registerResponse) + ) + ->will($this->returnValue($registration)); + + $this->Trait->expects($this->once()) + ->method('createU2fLib') + ->will($this->returnValue($u2fLib)); + + $actual = $this->Trait->request->getSession()->read('U2f'); + $this->assertNotNull($actual); + + $response = new Response(); + $this->Trait->expects($this->once()) + ->method('redirect') + ->with( + $this->equalTo([ + 'action' => 'u2fAuthenticate' + ]) + )->will($this->returnValue($response)); + + $actual = $this->Trait->u2fRegisterFinish(); + $this->assertSame($response, $actual); + $actual = $this->Trait->request->getSession()->read('U2f'); + $this->assertEquals( + [ + 'User' => [ + 'id' => '00000000-0000-0000-0000-000000000002', + 'username' => 'user-2', + ], + ], + $actual + ); + + $registration = json_decode(json_encode($registration), true); + $registration['user_id'] = '00000000-0000-0000-0000-000000000002'; + $exists = TableRegistry::getTableLocator()->get('CakeDC/Users.U2fRegistrations')->exists($registration); + $this->assertTrue($exists); + + $registration = new Registration(); + $registration->certificate = "user registration cert " . time(); + $registration->counter = 1; + $registration->publicKey = "pub skska08u90234230990"; + $registration->keyHandle = 'hahdofa02390423udu9ma0dumfá0dsufm2um9432uu903u923'; + } + + /** + * Test u2fRegister method + * + * @return void + */ + public function testU2fRegisterFinishException() + { + $this->Trait->request = $this->getMockBuilder('Cake\Http\ServerRequest') + ->setMethods(['getSession', 'getData']) + ->getMock(); + + $u2fLib = $this->getMockBuilder(U2F::class) + ->setConstructorArgs(['https://localhost']) + ->setMethods(['doRegister']) + ->getMock(); + + $registerRequest = new RegisterRequest("sample chalange", "https://localhost"); + $registerRequest = json_decode(json_encode($registerRequest)); + $registerResponse = json_decode(json_encode([ + 'fakeA' => 'fakevaluea', + 'fakeB' => 'fakevalueb' + ])); + $registration = new Registration(); + $registration->certificate = "user registration cert " . time(); + $registration->counter = 1; + $registration->publicKey = "pub skska08u90234230990"; + $registration->keyHandle = 'hahdofa02390423udu9ma0dumfá0dsufm2um9432uu903u923'; + + $this->Trait->request->expects($this->once()) + ->method('getData') + ->with($this->equalTo('registerResponse')) + ->will($this->returnValue(json_encode($registerResponse))); + $this->_mockSession([ + 'U2f' => [ + 'User' => [ + 'id' => '00000000-0000-0000-0000-000000000002', + 'username' => 'user-2', + ], + 'registerRequest' => json_encode($registerRequest) + ], + ]); + $u2fLib->expects($this->once()) + ->method('doRegister') + ->with( + $this->equalTo($registerRequest), + $this->equalTo($registerResponse) + ) + ->will($this->throwException(new \Exception('Invalid request'))); + + $this->Trait->expects($this->once()) + ->method('createU2fLib') + ->will($this->returnValue($u2fLib)); + + $actual = $this->Trait->request->getSession()->read('U2f'); + $this->assertNotNull($actual); + + $response = new Response(); + $this->Trait->expects($this->once()) + ->method('redirect') + ->with( + $this->equalTo([ + 'action' => 'u2fRegister' + ]) + )->will($this->returnValue($response)); + + $actual = $this->Trait->u2fRegisterFinish(); + $this->assertSame($response, $actual); + $actual = $this->Trait->request->getSession()->read('U2f'); + $this->assertEquals( + [ + 'User' => [ + 'id' => '00000000-0000-0000-0000-000000000002', + 'username' => 'user-2', + ], + ], + $actual + ); + + $registration = json_decode(json_encode($registration), true); + $registration['user_id'] = '00000000-0000-0000-0000-000000000002'; + $exists = TableRegistry::getTableLocator()->get('CakeDC/Users.U2fRegistrations')->exists($registration); + $this->assertFalse($exists); + + $registration = new Registration(); + $registration->certificate = "user registration cert " . time(); + $registration->counter = 1; + $registration->publicKey = "pub skska08u90234230990"; + $registration->keyHandle = 'hahdofa02390423udu9ma0dumfá0dsufm2um9432uu903u923'; + } + + /** + * Data provider for testU2fAuthenticateRedirectCustomUser + * + * @return array + */ + public function dataProviderU2fAuthenticateRedirectCustomUser() + { + $empty = []; + $withWhoutRegistration = [ + 'id' => '00000000-0000-0000-0000-000000000002', + 'username' => 'user-2', + ]; + + return [ + [$empty, ['action' => 'login']], + [$withWhoutRegistration, ['action' => 'u2fRegister']], + ]; + } + /** + * Test u2fAuthenticate method redirect cases + * + * @param array $userData session user data + * @param mixed $redirect expetected redirect + * + * @dataProvider dataProviderU2fAuthenticateRedirectCustomUser + * @return void + */ + public function testU2fAuthenticateRedirectCustomUser($userData, $redirect) + { + $this->Trait->request = $this->getMockBuilder('Cake\Http\ServerRequest') + ->setMethods(['getSession']) + ->getMock(); + $response = new Response([ + 'body' => time() + ]); + $this->Trait->expects($this->once()) + ->method('redirect') + ->with( + $this->equalTo($redirect) + )->will($this->returnValue($response)); + $this->_mockSession([ + 'U2f.User' => $userData, + ]); + $actual = $this->Trait->u2fAuthenticate(); + $this->assertSame($response, $actual); + } + + /** + * Test u2fAuthenticate method + * + * @return void + */ + public function testU2fAuthenticate() + { + $this->Trait->request = $this->getMockBuilder('Cake\Http\ServerRequest') + ->setMethods(['getSession']) + ->getMock(); + + $u2fLib = $this->getMockBuilder(U2F::class) + ->setConstructorArgs(['https://localhost']) + ->setMethods(['getAuthenticateData']) + ->getMock(); + + $signs = [ + ['fake' => new \stdClass()], + ['fake2' => new \stdClass()], + ]; + $registrations = TableRegistry::getTableLocator() + ->get('CakeDC/Users.U2fRegistrations') + ->findByUserId('00000000-0000-0000-0000-000000000001') + ->all() + ->toArray(); + $this->assertCount(1, $registrations); + $u2fLib->expects($this->once()) + ->method('getAuthenticateData') + ->with( + $this->equalTo($registrations) + ) + ->will($this->returnValue($signs)); + + $this->Trait->expects($this->once()) + ->method('createU2fLib') + ->will($this->returnValue($u2fLib)); + $this->Trait->expects($this->once()) + ->method('set') + ->with( + $this->equalTo([ + 'authenticateRequest' => $signs + ]) + ); + $this->Trait->expects($this->never()) + ->method('redirect'); + + $this->_mockSession([ + 'U2f.User' => [ + 'id' => '00000000-0000-0000-0000-000000000001', + 'username' => 'user-1', + ], + ]); + $actual = $this->Trait->u2fAuthenticate(); + $this->assertNull($actual); + $actual = $this->Trait->request->getSession()->read('U2f.authenticateRequest'); + $expected = json_encode($signs); + $this->assertEquals($expected, $actual); + } + + /** + * Test u2fAuthenticateFinish method + * + * @return void + */ + public function testU2fAutheticateFinish() + { + $registration = TableRegistry::getTableLocator() + ->get('CakeDC/Users.U2fRegistrations') + ->findByUserId('00000000-0000-0000-0000-000000000001') + ->first(); + $this->assertNotNull($registration); + + $registrationEntityResult = new Registration(); + $registrationEntityResult->keyHandle = $registration->keyHandle; + $registrationEntityResult->publicKey = $registration->publicKey; + $registrationEntityResult->counter = $registration->counter + 1; + $registrationEntityResult->certificate = $registration->certificate; + + $this->Trait->Auth->expects($this->once()) + ->method('redirectUrl') + ->will($this->returnValue('/my-home-page')); + + $this->Trait->Auth->expects($this->once()) + ->method('setUser') + ->with( + $this->equalTo([ + 'id' => '00000000-0000-0000-0000-000000000001', + 'username' => 'user-1', + ]) + ); + + $this->Trait->request = $this->getMockBuilder('Cake\Http\ServerRequest') + ->setMethods(['getSession', 'getData']) + ->getMock(); + + $u2fLib = $this->getMockBuilder(U2F::class) + ->setConstructorArgs(['https://localhost']) + ->setMethods(['doAuthenticate']) + ->getMock(); + + $signs = json_decode(json_encode([ + ['fake' => new \stdClass()], + ['fake2' => new \stdClass()], + ])); + $authenticateResponse = json_decode(json_encode([ + 'fakeA' => 'fakevaluea', + 'fakeB' => 'fakevalueb' + ])); + + $this->Trait->request->expects($this->once()) + ->method('getData') + ->with($this->equalTo('authenticateResponse')) + ->will($this->returnValue(json_encode($authenticateResponse))); + $this->_mockSession([ + 'U2f' => [ + 'User' => [ + 'id' => '00000000-0000-0000-0000-000000000001', + 'username' => 'user-1', + ], + 'authenticateRequest' => json_encode($signs) + ], + ]); + + $u2fLib->expects($this->once()) + ->method('doAuthenticate') + ->with( + $this->equalTo($signs), + $this->equalTo([$registration]), + $this->equalTo($authenticateResponse) + ) + ->will($this->returnValue($registrationEntityResult)); + + $this->Trait->expects($this->once()) + ->method('createU2fLib') + ->will($this->returnValue($u2fLib)); + + $actual = $this->Trait->request->getSession()->read('U2f'); + $this->assertNotNull($actual); + + $response = new Response(); + $this->Trait->expects($this->once()) + ->method('redirect') + ->with('/my-home-page')->will($this->returnValue($response)); + + $actual = $this->Trait->u2fAuthenticateFinish(); + $this->assertSame($response, $actual); + $actual = $this->Trait->request->getSession()->read('U2f'); + $this->assertNull($actual); + + $updatedEntity = TableRegistry::getTableLocator()->get('CakeDC/Users.U2fRegistrations')->get($registration['id']); + $this->assertEquals($registrationEntityResult->counter, $updatedEntity->counter); + } + + /** + * Test u2fAuthenticateFinish method with exception + * + * @return void + */ + public function testU2fAutheticateFinishWithException() + { + $registration = TableRegistry::getTableLocator() + ->get('CakeDC/Users.U2fRegistrations') + ->findByUserId('00000000-0000-0000-0000-000000000001') + ->first(); + $counter = $registration->counter; + $this->assertNotNull($registration); + + $this->Trait->Auth->expects($this->never()) + ->method('redirectUrl'); + + $this->Trait->Auth->expects($this->never()) + ->method('setUser'); + + $this->Trait->request = $this->getMockBuilder('Cake\Http\ServerRequest') + ->setMethods(['getSession', 'getData']) + ->getMock(); + + $u2fLib = $this->getMockBuilder(U2F::class) + ->setConstructorArgs(['https://localhost']) + ->setMethods(['doAuthenticate']) + ->getMock(); + + $signs = json_decode(json_encode([ + ['fake' => new \stdClass()], + ['fake2' => new \stdClass()], + ])); + $authenticateResponse = json_decode(json_encode([ + 'fakeA' => 'fakevaluea', + 'fakeB' => 'fakevalueb' + ])); + + $this->Trait->request->expects($this->once()) + ->method('getData') + ->with($this->equalTo('authenticateResponse')) + ->will($this->returnValue(json_encode($authenticateResponse))); + + $this->_mockSession([ + 'U2f' => [ + 'User' => [ + 'id' => '00000000-0000-0000-0000-000000000001', + 'username' => 'user-1', + ], + 'authenticateRequest' => json_encode($signs) + ], + ]); + + $u2fLib->expects($this->once()) + ->method('doAuthenticate') + ->with( + $this->equalTo($signs), + $this->equalTo([$registration]), + $this->equalTo($authenticateResponse) + ) + ->will($this->throwException(new \Exception('Invalid'))); + + $this->Trait->expects($this->once()) + ->method('createU2fLib') + ->will($this->returnValue($u2fLib)); + + $response = new Response(); + $this->Trait->expects($this->once()) + ->method('redirect') + ->with(['action' => 'u2fAuthenticate']) + ->will($this->returnValue($response)); + + $actual = $this->Trait->u2fAuthenticateFinish(); + $this->assertSame($response, $actual); + $actual = $this->Trait->request->getSession()->read('U2f'); + $this->assertEquals( + [ + 'User' => [ + 'id' => '00000000-0000-0000-0000-000000000001', + 'username' => 'user-1', + ], + ], + $actual + ); + + $updatedEntity = TableRegistry::getTableLocator()->get('CakeDC/Users.U2fRegistrations')->get($registration['id']); + $this->assertEquals($counter, $updatedEntity->counter); + } +} From 73518c7a028c8af3e17d4c5a3cc2b3d14aa25436 Mon Sep 17 00:00:00 2001 From: Marcelo Rocha Date: Fri, 8 Feb 2019 10:08:46 -0200 Subject: [PATCH 04/18] Redirect user to u2f process after login --- src/Controller/Traits/LoginTrait.php | 18 ++++++++++++++++-- .../Controller/Traits/LoginTraitTest.php | 6 +++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/Controller/Traits/LoginTrait.php b/src/Controller/Traits/LoginTrait.php index 10a99ff12..091ed53aa 100644 --- a/src/Controller/Traits/LoginTrait.php +++ b/src/Controller/Traits/LoginTrait.php @@ -12,6 +12,7 @@ namespace CakeDC\Users\Controller\Traits; use CakeDC\Users\Auth\TwoFactorAuthenticationCheckerFactory; +use CakeDC\Users\Auth\U2fAuthenticationCheckerFactory; use CakeDC\Users\Controller\Component\UsersAuthComponent; use CakeDC\Users\Exception\AccountNotActiveException; use CakeDC\Users\Exception\MissingEmailException; @@ -184,7 +185,8 @@ public function login() return $this->_afterIdentifyUser( $user, $socialLogin, - $user && $this->getTwoFactorAuthenticationChecker()->isRequired($user) + $user && $this->getTwoFactorAuthenticationChecker()->isRequired($user), + $user && $this->getU2fAuthenticationChecker()->isRequired($user) ); } @@ -330,11 +332,23 @@ protected function _checkReCaptcha() * @param array $user user data after identified * @param bool $socialLogin is social login * @param bool $googleAuthenticatorLogin googleAuthenticatorLogin + * @param bool $u2fLogin should perform u2f authentication * @return array */ - protected function _afterIdentifyUser($user, $socialLogin = false, $googleAuthenticatorLogin = false) + protected function _afterIdentifyUser($user, $socialLogin = false, $googleAuthenticatorLogin = false, $u2fLogin = false) { if (!empty($user)) { + if ($u2fLogin) { + // storing user's session in the temporary one + $this->request->getSession()->write('U2f.User', $user); + $url = Configure::read('U2f.startAction'); + $url = array_merge($url, [ + '?' => $this->request->getQueryParams() + ]); + + return $this->redirect($url); + } + if ($googleAuthenticatorLogin) { // storing user's session in the temporary one // until the GA verification is checked diff --git a/tests/TestCase/Controller/Traits/LoginTraitTest.php b/tests/TestCase/Controller/Traits/LoginTraitTest.php index a897f0f50..3087065fb 100644 --- a/tests/TestCase/Controller/Traits/LoginTraitTest.php +++ b/tests/TestCase/Controller/Traits/LoginTraitTest.php @@ -11,6 +11,7 @@ namespace CakeDC\Users\Test\TestCase\Controller\Traits; +use CakeDC\Users\Auth\DefaultU2fAuthenticationChecker; use CakeDC\Users\Controller\Component\GoogleAuthenticatorComponent; use CakeDC\Users\Controller\Component\UsersAuthComponent; use CakeDC\Users\Controller\Traits\LoginTrait; @@ -40,8 +41,11 @@ public function setUp() parent::setUp(); $request = new ServerRequest(); $this->Trait = $this->getMockBuilder('CakeDC\Users\Controller\Traits\LoginTrait') - ->setMethods(['dispatchEvent', 'redirect', 'set']) + ->setMethods(['dispatchEvent', 'redirect', 'set', 'getU2fAuthenticationChecker']) ->getMockForTrait(); + $this->Trait->expects($this->any()) + ->method('getU2fAuthenticationChecker') + ->will($this->returnValue(new DefaultU2fAuthenticationChecker())); $this->Trait->Auth = $this->getMockBuilder('Cake\Controller\Component\AuthComponent') ->setMethods(['setConfig']) From 7f3c443dea8b414579439c846308f1daf89ea6b4 Mon Sep 17 00:00:00 2001 From: Marcelo Rocha Date: Fri, 8 Feb 2019 10:22:14 -0200 Subject: [PATCH 05/18] Added actions for u2f feature --- webroot/js/u2f-api.js | 748 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 748 insertions(+) create mode 100644 webroot/js/u2f-api.js diff --git a/webroot/js/u2f-api.js b/webroot/js/u2f-api.js new file mode 100644 index 000000000..ac478feff --- /dev/null +++ b/webroot/js/u2f-api.js @@ -0,0 +1,748 @@ +//Copyright 2014-2015 Google Inc. All rights reserved. + +//Use of this source code is governed by a BSD-style +//license that can be found in the LICENSE file or at +//https://developers.google.com/open-source/licenses/bsd + +/** + * @fileoverview The U2F api. + */ +'use strict'; + + +/** + * Namespace for the U2F api. + * @type {Object} + */ +var u2f = u2f || {}; + +/** + * FIDO U2F Javascript API Version + * @number + */ +var js_api_version; + +/** + * The U2F extension id + * @const {string} + */ +// The Chrome packaged app extension ID. +// Uncomment this if you want to deploy a server instance that uses +// the package Chrome app and does not require installing the U2F Chrome extension. + u2f.EXTENSION_ID = 'kmendfapggjehodndflmmgagdbamhnfd'; +// The U2F Chrome extension ID. +// Uncomment this if you want to deploy a server instance that uses +// the U2F Chrome extension to authenticate. +// u2f.EXTENSION_ID = 'pfboblefjcgdjicmnffhdgionmgcdmne'; + + +/** + * Message types for messsages to/from the extension + * @const + * @enum {string} + */ +u2f.MessageTypes = { + 'U2F_REGISTER_REQUEST': 'u2f_register_request', + 'U2F_REGISTER_RESPONSE': 'u2f_register_response', + 'U2F_SIGN_REQUEST': 'u2f_sign_request', + 'U2F_SIGN_RESPONSE': 'u2f_sign_response', + 'U2F_GET_API_VERSION_REQUEST': 'u2f_get_api_version_request', + 'U2F_GET_API_VERSION_RESPONSE': 'u2f_get_api_version_response' +}; + + +/** + * Response status codes + * @const + * @enum {number} + */ +u2f.ErrorCodes = { + 'OK': 0, + 'OTHER_ERROR': 1, + 'BAD_REQUEST': 2, + 'CONFIGURATION_UNSUPPORTED': 3, + 'DEVICE_INELIGIBLE': 4, + 'TIMEOUT': 5 +}; + + +/** + * A message for registration requests + * @typedef {{ + * type: u2f.MessageTypes, + * appId: ?string, + * timeoutSeconds: ?number, + * requestId: ?number + * }} + */ +u2f.U2fRequest; + + +/** + * A message for registration responses + * @typedef {{ + * type: u2f.MessageTypes, + * responseData: (u2f.Error | u2f.RegisterResponse | u2f.SignResponse), + * requestId: ?number + * }} + */ +u2f.U2fResponse; + + +/** + * An error object for responses + * @typedef {{ + * errorCode: u2f.ErrorCodes, + * errorMessage: ?string + * }} + */ +u2f.Error; + +/** + * Data object for a single sign request. + * @typedef {enum {BLUETOOTH_RADIO, BLUETOOTH_LOW_ENERGY, USB, NFC, USB_INTERNAL}} + */ +u2f.Transport; + + +/** + * Data object for a single sign request. + * @typedef {Array} + */ +u2f.Transports; + +/** + * Data object for a single sign request. + * @typedef {{ + * version: string, + * challenge: string, + * keyHandle: string, + * appId: string + * }} + */ +u2f.SignRequest; + + +/** + * Data object for a sign response. + * @typedef {{ + * keyHandle: string, + * signatureData: string, + * clientData: string + * }} + */ +u2f.SignResponse; + + +/** + * Data object for a registration request. + * @typedef {{ + * version: string, + * challenge: string + * }} + */ +u2f.RegisterRequest; + + +/** + * Data object for a registration response. + * @typedef {{ + * version: string, + * keyHandle: string, + * transports: Transports, + * appId: string + * }} + */ +u2f.RegisterResponse; + + +/** + * Data object for a registered key. + * @typedef {{ + * version: string, + * keyHandle: string, + * transports: ?Transports, + * appId: ?string + * }} + */ +u2f.RegisteredKey; + + +/** + * Data object for a get API register response. + * @typedef {{ + * js_api_version: number + * }} + */ +u2f.GetJsApiVersionResponse; + + +//Low level MessagePort API support + +/** + * Sets up a MessagePort to the U2F extension using the + * available mechanisms. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + */ +u2f.getMessagePort = function(callback) { + if (typeof chrome != 'undefined' && chrome.runtime) { + // The actual message here does not matter, but we need to get a reply + // for the callback to run. Thus, send an empty signature request + // in order to get a failure response. + var msg = { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: [] + }; + chrome.runtime.sendMessage(u2f.EXTENSION_ID, msg, function() { + if (!chrome.runtime.lastError) { + // We are on a whitelisted origin and can talk directly + // with the extension. + u2f.getChromeRuntimePort_(callback); + } else { + // chrome.runtime was available, but we couldn't message + // the extension directly, use iframe + u2f.getIframePort_(callback); + } + }); + } else if (u2f.isAndroidChrome_()) { + u2f.getAuthenticatorPort_(callback); + } else if (u2f.isIosChrome_()) { + u2f.getIosPort_(callback); + } else { + // chrome.runtime was not available at all, which is normal + // when this origin doesn't have access to any extensions. + u2f.getIframePort_(callback); + } +}; + +/** + * Detect chrome running on android based on the browser's useragent. + * @private + */ +u2f.isAndroidChrome_ = function() { + var userAgent = navigator.userAgent; + return userAgent.indexOf('Chrome') != -1 && + userAgent.indexOf('Android') != -1; +}; + +/** + * Detect chrome running on iOS based on the browser's platform. + * @private + */ +u2f.isIosChrome_ = function() { + return ["iPhone", "iPad", "iPod"].indexOf(navigator.platform) > -1; +}; + +/** + * Connects directly to the extension via chrome.runtime.connect. + * @param {function(u2f.WrappedChromeRuntimePort_)} callback + * @private + */ +u2f.getChromeRuntimePort_ = function(callback) { + var port = chrome.runtime.connect(u2f.EXTENSION_ID, + {'includeTlsChannelId': true}); + setTimeout(function() { + callback(new u2f.WrappedChromeRuntimePort_(port)); + }, 0); +}; + +/** + * Return a 'port' abstraction to the Authenticator app. + * @param {function(u2f.WrappedAuthenticatorPort_)} callback + * @private + */ +u2f.getAuthenticatorPort_ = function(callback) { + setTimeout(function() { + callback(new u2f.WrappedAuthenticatorPort_()); + }, 0); +}; + +/** + * Return a 'port' abstraction to the iOS client app. + * @param {function(u2f.WrappedIosPort_)} callback + * @private + */ +u2f.getIosPort_ = function(callback) { + setTimeout(function() { + callback(new u2f.WrappedIosPort_()); + }, 0); +}; + +/** + * A wrapper for chrome.runtime.Port that is compatible with MessagePort. + * @param {Port} port + * @constructor + * @private + */ +u2f.WrappedChromeRuntimePort_ = function(port) { + this.port_ = port; +}; + +/** + * Format and return a sign request compliant with the JS API version supported by the extension. + * @param {Array} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.formatSignRequest_ = + function(appId, challenge, registeredKeys, timeoutSeconds, reqId) { + if (js_api_version === undefined || js_api_version < 1.1) { + // Adapt request to the 1.0 JS API + var signRequests = []; + for (var i = 0; i < registeredKeys.length; i++) { + signRequests[i] = { + version: registeredKeys[i].version, + challenge: challenge, + keyHandle: registeredKeys[i].keyHandle, + appId: appId + }; + } + return { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: signRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + } + // JS 1.1 API + return { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + appId: appId, + challenge: challenge, + registeredKeys: registeredKeys, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; +}; + +/** + * Format and return a register request compliant with the JS API version supported by the extension.. + * @param {Array} signRequests + * @param {Array} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.formatRegisterRequest_ = + function(appId, registeredKeys, registerRequests, timeoutSeconds, reqId) { + if (js_api_version === undefined || js_api_version < 1.1) { + // Adapt request to the 1.0 JS API + for (var i = 0; i < registerRequests.length; i++) { + registerRequests[i].appId = appId; + } + var signRequests = []; + for (var i = 0; i < registeredKeys.length; i++) { + signRequests[i] = { + version: registeredKeys[i].version, + challenge: registerRequests[0], + keyHandle: registeredKeys[i].keyHandle, + appId: appId + }; + } + return { + type: u2f.MessageTypes.U2F_REGISTER_REQUEST, + signRequests: signRequests, + registerRequests: registerRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + } + // JS 1.1 API + return { + type: u2f.MessageTypes.U2F_REGISTER_REQUEST, + appId: appId, + registerRequests: registerRequests, + registeredKeys: registeredKeys, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; +}; + + +/** + * Posts a message on the underlying channel. + * @param {Object} message + */ +u2f.WrappedChromeRuntimePort_.prototype.postMessage = function(message) { + this.port_.postMessage(message); +}; + + +/** + * Emulates the HTML 5 addEventListener interface. Works only for the + * onmessage event, which is hooked up to the chrome.runtime.Port.onMessage. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedChromeRuntimePort_.prototype.addEventListener = + function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name == 'message' || name == 'onmessage') { + this.port_.onMessage.addListener(function(message) { + // Emulate a minimal MessageEvent object + handler({'data': message}); + }); + } else { + console.error('WrappedChromeRuntimePort only supports onMessage'); + } +}; + +/** + * Wrap the Authenticator app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedAuthenticatorPort_ = function() { + this.requestId_ = -1; + this.requestObject_ = null; +} + +/** + * Launch the Authenticator intent. + * @param {Object} message + */ +u2f.WrappedAuthenticatorPort_.prototype.postMessage = function(message) { + var intentUrl = + u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ + + ';S.request=' + encodeURIComponent(JSON.stringify(message)) + + ';end'; + document.location = intentUrl; +}; + +/** + * Tells what type of port this is. + * @return {String} port type + */ +u2f.WrappedAuthenticatorPort_.prototype.getPortType = function() { + return "WrappedAuthenticatorPort_"; +}; + + +/** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedAuthenticatorPort_.prototype.addEventListener = function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name == 'message') { + var self = this; + /* Register a callback to that executes when + * chrome injects the response. */ + window.addEventListener( + 'message', self.onRequestUpdate_.bind(self, handler), false); + } else { + console.error('WrappedAuthenticatorPort only supports message'); + } +}; + +/** + * Callback invoked when a response is received from the Authenticator. + * @param function({data: Object}) callback + * @param {Object} message message Object + */ +u2f.WrappedAuthenticatorPort_.prototype.onRequestUpdate_ = + function(callback, message) { + var messageObject = JSON.parse(message.data); + var intentUrl = messageObject['intentURL']; + + var errorCode = messageObject['errorCode']; + var responseObject = null; + if (messageObject.hasOwnProperty('data')) { + responseObject = /** @type {Object} */ ( + JSON.parse(messageObject['data'])); + } + + callback({'data': responseObject}); +}; + +/** + * Base URL for intents to Authenticator. + * @const + * @private + */ +u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ = + 'intent:#Intent;action=com.google.android.apps.authenticator.AUTHENTICATE'; + +/** + * Wrap the iOS client app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedIosPort_ = function() {}; + +/** + * Launch the iOS client app request + * @param {Object} message + */ +u2f.WrappedIosPort_.prototype.postMessage = function(message) { + var str = JSON.stringify(message); + var url = "u2f://auth?" + encodeURI(str); + location.replace(url); +}; + +/** + * Tells what type of port this is. + * @return {String} port type + */ +u2f.WrappedIosPort_.prototype.getPortType = function() { + return "WrappedIosPort_"; +}; + +/** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedIosPort_.prototype.addEventListener = function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name !== 'message') { + console.error('WrappedIosPort only supports message'); + } +}; + +/** + * Sets up an embedded trampoline iframe, sourced from the extension. + * @param {function(MessagePort)} callback + * @private + */ +u2f.getIframePort_ = function(callback) { + // Create the iframe + var iframeOrigin = 'chrome-extension://' + u2f.EXTENSION_ID; + var iframe = document.createElement('iframe'); + iframe.src = iframeOrigin + '/u2f-comms.html'; + iframe.setAttribute('style', 'display:none'); + document.body.appendChild(iframe); + + var channel = new MessageChannel(); + var ready = function(message) { + if (message.data == 'ready') { + channel.port1.removeEventListener('message', ready); + callback(channel.port1); + } else { + console.error('First event on iframe port was not "ready"'); + } + }; + channel.port1.addEventListener('message', ready); + channel.port1.start(); + + iframe.addEventListener('load', function() { + // Deliver the port to the iframe and initialize + iframe.contentWindow.postMessage('init', iframeOrigin, [channel.port2]); + }); +}; + + +//High-level JS API + +/** + * Default extension response timeout in seconds. + * @const + */ +u2f.EXTENSION_TIMEOUT_SEC = 30; + +/** + * A singleton instance for a MessagePort to the extension. + * @type {MessagePort|u2f.WrappedChromeRuntimePort_} + * @private + */ +u2f.port_ = null; + +/** + * Callbacks waiting for a port + * @type {Array} + * @private + */ +u2f.waitingForPort_ = []; + +/** + * A counter for requestIds. + * @type {number} + * @private + */ +u2f.reqCounter_ = 0; + +/** + * A map from requestIds to client callbacks + * @type {Object.} + * @private + */ +u2f.callbackMap_ = {}; + +/** + * Creates or retrieves the MessagePort singleton to use. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + * @private + */ +u2f.getPortSingleton_ = function(callback) { + if (u2f.port_) { + callback(u2f.port_); + } else { + if (u2f.waitingForPort_.length == 0) { + u2f.getMessagePort(function(port) { + u2f.port_ = port; + u2f.port_.addEventListener('message', + /** @type {function(Event)} */ (u2f.responseHandler_)); + + // Careful, here be async callbacks. Maybe. + while (u2f.waitingForPort_.length) + u2f.waitingForPort_.shift()(u2f.port_); + }); + } + u2f.waitingForPort_.push(callback); + } +}; + +/** + * Handles response messages from the extension. + * @param {MessageEvent.} message + * @private + */ +u2f.responseHandler_ = function(message) { + var response = message.data; + var reqId = response['requestId']; + if (!reqId || !u2f.callbackMap_[reqId]) { + console.error('Unknown or missing requestId in response.'); + return; + } + var cb = u2f.callbackMap_[reqId]; + delete u2f.callbackMap_[reqId]; + cb(response['responseData']); +}; + +/** + * Dispatches an array of sign requests to available U2F tokens. + * If the JS API version supported by the extension is unknown, it first sends a + * message to the extension to find out the supported API version and then it sends + * the sign request. + * @param {string=} appId + * @param {string=} challenge + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sign = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { + if (js_api_version === undefined) { + // Send a message to get the extension to JS API version, then send the actual sign request. + u2f.getApiVersion( + function (response) { + js_api_version = response['js_api_version'] === undefined ? 0 : response['js_api_version']; + console.log("Extension JS API Version: ", js_api_version); + u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); + }); + } else { + // We know the JS API version. Send the actual sign request in the supported API version. + u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); + } +}; + +/** + * Dispatches an array of sign requests to available U2F tokens. + * @param {string=} appId + * @param {string=} challenge + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sendSignRequest = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); + var req = u2f.formatSignRequest_(appId, challenge, registeredKeys, timeoutSeconds, reqId); + port.postMessage(req); + }); +}; + +/** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * If the JS API version supported by the extension is unknown, it first sends a + * message to the extension to find out the supported API version and then it sends + * the register request. + * @param {string=} appId + * @param {Array} registerRequests + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.register = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { + if (js_api_version === undefined) { + // Send a message to get the extension to JS API version, then send the actual register request. + u2f.getApiVersion( + function (response) { + js_api_version = response['js_api_version'] === undefined ? 0: response['js_api_version']; + console.log("Extension JS API Version: ", js_api_version); + u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, + callback, opt_timeoutSeconds); + }); + } else { + // We know the JS API version. Send the actual register request in the supported API version. + u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, + callback, opt_timeoutSeconds); + } +}; + +/** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * @param {string=} appId + * @param {Array} registerRequests + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sendRegisterRequest = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); + var req = u2f.formatRegisterRequest_( + appId, registeredKeys, registerRequests, timeoutSeconds, reqId); + port.postMessage(req); + }); +}; + + +/** + * Dispatches a message to the extension to find out the supported + * JS API version. + * If the user is on a mobile phone and is thus using Google Authenticator instead + * of the Chrome extension, don't send the request and simply return 0. + * @param {function((u2f.Error|u2f.GetJsApiVersionResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.getApiVersion = function(callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + // If we are using Android Google Authenticator or iOS client app, + // do not fire an intent to ask which JS API version to use. + if (port.getPortType) { + var apiVersion; + switch (port.getPortType()) { + case 'WrappedIosPort_': + case 'WrappedAuthenticatorPort_': + apiVersion = 1.1; + break; + + default: + apiVersion = 0; + break; + } + callback({ 'js_api_version': apiVersion }); + return; + } + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var req = { + type: u2f.MessageTypes.U2F_GET_API_VERSION_REQUEST, + timeoutSeconds: (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC), + requestId: reqId + }; + port.postMessage(req); + }); +}; From 3b91946649f0e4a9e0490312410a5287df3c3b36 Mon Sep 17 00:00:00 2001 From: Marcelo Rocha Date: Fri, 8 Feb 2019 10:38:13 -0200 Subject: [PATCH 06/18] u2f alert with translated message from php --- src/Template/Users/u2f_authenticate.ctp | 2 +- src/Template/Users/u2f_register.ctp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Template/Users/u2f_authenticate.ctp b/src/Template/Users/u2f_authenticate.ctp index f34fe04b3..6530b7ad4 100644 --- a/src/Template/Users/u2f_authenticate.ctp +++ b/src/Template/Users/u2f_authenticate.ctp @@ -47,7 +47,7 @@ $this->Html->scriptStart(['block' => true]); var targetForm = document.getElementById('u2fAuthenticateFrm'); var targetInput = document.getElementById('authenticateResponse'); if(data.errorCode && data.errorCode != 0) { - alert("Yubico key check has failed, please try again"); + alert(""); return; } diff --git a/src/Template/Users/u2f_register.ctp b/src/Template/Users/u2f_register.ctp index 9bad510f0..9c3858914 100644 --- a/src/Template/Users/u2f_register.ctp +++ b/src/Template/Users/u2f_register.ctp @@ -52,7 +52,7 @@ setTimeout(function() { var targetInput = document.getElementById('registerResponse'); if(data.errorCode && data.errorCode != 0) { - alert("Yubico key check has failed, please try again"); + alert(""); return; } From b435804c6ebe6806358a998eb87c08794f0901e2 Mon Sep 17 00:00:00 2001 From: Marcelo Rocha Date: Fri, 8 Feb 2019 10:58:18 -0200 Subject: [PATCH 07/18] u2f should redirect to url set at 'redirect' query property --- src/Controller/Traits/U2fTrait.php | 36 ++++++++++++++++++------- src/Template/Users/u2f_authenticate.ctp | 3 ++- src/Template/Users/u2f_register.ctp | 3 ++- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/Controller/Traits/U2fTrait.php b/src/Controller/Traits/U2fTrait.php index 6c8c43077..ff4c79e71 100644 --- a/src/Controller/Traits/U2fTrait.php +++ b/src/Controller/Traits/U2fTrait.php @@ -13,6 +13,22 @@ trait U2fTrait { + /** + * Perform redirect keeping current query string + * + * @param array $url base url + * + * @return \Cake\Http\Response + */ + public function redirectWithQuery($url) + { + $url['?'] = $this->request->getQueryParams(); + if (empty($url['?'])) { + unset($url['?']); + } + + return $this->redirect($url); + } /** * U2f entry point * @@ -22,7 +38,7 @@ public function u2f() { $user = $this->request->getSession()->read('U2f.User'); if (!isset($user['id'])) { - return $this->redirect([ + return $this->redirectWithQuery([ 'action' => 'login' ]); } @@ -32,12 +48,12 @@ public function u2f() ]); if (!$hasRegistration) { - return $this->redirect([ + return $this->redirectWithQuery([ 'action' => 'u2fRegister' ]); } - return $this->redirect([ + return $this->redirectWithQuery([ 'action' => 'u2fAuthenticate' ]); } @@ -52,7 +68,7 @@ public function u2fRegister() { $data = $this->getU2fData(); if (!$data['valid']) { - return $this->redirect([ + return $this->redirectWithQuery([ 'action' => 'login' ]); } @@ -65,7 +81,7 @@ public function u2fRegister() return null; } - return $this->redirect([ + return $this->redirectWithQuery([ 'action' => 'u2fAuthenticate' ]); } @@ -89,13 +105,13 @@ public function u2fRegisterFinish() $table->saveOrFail($entity); $this->request->getSession()->delete('U2f.registerRequest'); - return $this->redirect([ + return $this->redirectWithQuery([ 'action' => 'u2fAuthenticate' ]); } catch (\Exception $e) { $this->request->getSession()->delete('U2f.registerRequest'); - return $this->redirect([ + return $this->redirectWithQuery([ 'action' => 'u2fRegister' ]); } @@ -110,13 +126,13 @@ public function u2fAuthenticate() { $data = $this->getU2fData(); if (!$data['valid']) { - return $this->redirect([ + return $this->redirectWithQuery([ 'action' => 'login' ]); } if (!$data['registration']) { - return $this->redirect([ + return $this->redirectWithQuery([ 'action' => 'u2fRegister' ]); } @@ -152,7 +168,7 @@ public function u2fAuthenticateFinish() } catch (\Exception $e) { $this->request->getSession()->delete('U2f.authenticateRequest'); - return $this->redirect([ + return $this->redirectWithQuery([ 'action' => 'u2fAuthenticate' ]); } diff --git a/src/Template/Users/u2f_authenticate.ctp b/src/Template/Users/u2f_authenticate.ctp index 6530b7ad4..0ae1bbb7a 100644 --- a/src/Template/Users/u2f_authenticate.ctp +++ b/src/Template/Users/u2f_authenticate.ctp @@ -10,7 +10,8 @@
Form->create(false, [ 'url' => [ - 'action' => 'u2fAuthenticateFinish' + 'action' => 'u2fAuthenticateFinish', + '?' => $this->request->getQueryParams() ], 'id' => 'u2fAuthenticateFrm' ]) ?> diff --git a/src/Template/Users/u2f_register.ctp b/src/Template/Users/u2f_register.ctp index 9c3858914..e487bcb2b 100644 --- a/src/Template/Users/u2f_register.ctp +++ b/src/Template/Users/u2f_register.ctp @@ -10,7 +10,8 @@
Form->create(false, [ 'url' => [ - 'action' => 'u2fRegisterFinish' + 'action' => 'u2fRegisterFinish', + '?' => $this->request->getQueryParams() ], 'id' => 'u2fRegisterFrm' ]) ?> From 5b002aa4502764943e7223b3cba2db2c432ac53f Mon Sep 17 00:00:00 2001 From: Marcelo Rocha Date: Fri, 8 Feb 2019 11:10:54 -0200 Subject: [PATCH 08/18] Added doc for yubico u2f --- Docs/Documentation/Yubico-U2F.md | 29 +++++++++++++++++++++++++++++ Docs/Home.md | 1 + 2 files changed, 30 insertions(+) create mode 100644 Docs/Documentation/Yubico-U2F.md diff --git a/Docs/Documentation/Yubico-U2F.md b/Docs/Documentation/Yubico-U2F.md new file mode 100644 index 000000000..bf398013b --- /dev/null +++ b/Docs/Documentation/Yubico-U2F.md @@ -0,0 +1,29 @@ +YubicoKey U2F +============= + +Installation +------------ +To enable this feature you need to + +``` +composer require yubico/u2flib-server:^1.0 +``` + +Setup +----- + +Enable it in your bootstrap.php file: + +Config/bootstrap.php +``` +Configure::write('U2f.enabled', true); +``` + +How does it work +---------------- +When the user log-in, he is requested to insert and tap his registered yubico key, +if this is the first time he access he need to register the yubico key. + +Please check the yubico site for more information about U2F +https://developers.yubico.com/U2F/ + diff --git a/Docs/Home.md b/Docs/Home.md index 378d61db9..7f0900847 100644 --- a/Docs/Home.md +++ b/Docs/Home.md @@ -18,6 +18,7 @@ Documentation * [ApiKeyAuthenticate](https://github.com/CakeDC/auth/blob/master/Docs/Documentation/ApiKeyAuthenticate.md) * [SocialAuthenticate](Documentation/SocialAuthenticate.md) * [Google Authenticator](Documentation/Google-Two-Factor-Authenticator.md) +* [Yubico U2F](Documentation/Yubico-U2F.md) * [UserHelper](Documentation/UserHelper.md) * [Events](Documentation/Events.md) * [Extending the Plugin](Documentation/Extending-the-Plugin.md) From d870456272badb872ae015957d53b51db32ec401 Mon Sep 17 00:00:00 2001 From: Marcelo Rocha Date: Fri, 8 Feb 2019 11:19:46 -0200 Subject: [PATCH 09/18] Two-factor is a covered feature --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 798976075..2f918b4b7 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ It covers the following features: * Remember me (Cookie) via https://github.com/CakeDC/auth * Manage user's profile * Admin management +* Yubico U2F for Two-Factor Authentication +* One-Time Password for Two-Factor Authentication The plugin is here to provide users related features following 2 approaches: * Quick drop-in working solution for users login/registration. Get users working in 5 minutes. From fb943649a87a8ea6a1af0035074c66cbfcd965fa Mon Sep 17 00:00:00 2001 From: Marcelo Rocha Date: Fri, 8 Feb 2019 11:21:01 -0200 Subject: [PATCH 10/18] Added doc for yubico u2f --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index de13ed452..6a04a8b7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ Changelog Releases for CakePHP 3 ------------- +* 8.1 + * Added Yubico U2F Authentication + * 8.0.3 * Updated to latest version of Google OAuth * Added plugin object From 4c0535b664606dfcb75e14d717b599623336a6f1 Mon Sep 17 00:00:00 2001 From: Marcelo Rocha Date: Fri, 8 Feb 2019 11:29:41 -0200 Subject: [PATCH 11/18] Fixing fixture table options --- tests/Fixture/U2fRegistrationsFixture.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Fixture/U2fRegistrationsFixture.php b/tests/Fixture/U2fRegistrationsFixture.php index 5f1cd092a..cf1f38274 100644 --- a/tests/Fixture/U2fRegistrationsFixture.php +++ b/tests/Fixture/U2fRegistrationsFixture.php @@ -28,7 +28,7 @@ class U2fRegistrationsFixture extends TestFixture ], '_options' => [ 'engine' => 'InnoDB', - 'collation' => 'utf8_slovenian_ci' + 'collation' => 'utf8_general_ci' ], ]; // @codingStandardsIgnoreEnd From 496979909c3263449dcddb43cbd0c882b5577e2f Mon Sep 17 00:00:00 2001 From: Marcelo Rocha Date: Fri, 8 Feb 2019 11:40:32 -0200 Subject: [PATCH 12/18] Fixing fixture table options --- tests/Fixture/UsersFixture.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Fixture/UsersFixture.php b/tests/Fixture/UsersFixture.php index d0256aed8..7d604660e 100644 --- a/tests/Fixture/UsersFixture.php +++ b/tests/Fixture/UsersFixture.php @@ -1,6 +1,6 @@ Date: Fri, 8 Feb 2019 12:00:19 -0200 Subject: [PATCH 13/18] Added file doc --- tests/Fixture/U2fRegistrationsFixture.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/Fixture/U2fRegistrationsFixture.php b/tests/Fixture/U2fRegistrationsFixture.php index cf1f38274..2142586db 100644 --- a/tests/Fixture/U2fRegistrationsFixture.php +++ b/tests/Fixture/U2fRegistrationsFixture.php @@ -1,4 +1,13 @@ Date: Fri, 8 Feb 2019 12:06:38 -0200 Subject: [PATCH 14/18] Fixing fixture table collate --- tests/Fixture/U2fRegistrationsFixture.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Fixture/U2fRegistrationsFixture.php b/tests/Fixture/U2fRegistrationsFixture.php index 2142586db..5121dff95 100644 --- a/tests/Fixture/U2fRegistrationsFixture.php +++ b/tests/Fixture/U2fRegistrationsFixture.php @@ -28,9 +28,9 @@ class U2fRegistrationsFixture extends TestFixture public $fields = [ 'id' => ['type' => 'integer', 'length' => 11, 'unsigned' => false, 'null' => false, 'default' => null, 'comment' => '', 'autoIncrement' => true, 'precision' => null], 'user_id' => ['type' => 'uuid', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], - 'keyHandle' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'collate' => 'utf8_slovenian_ci', 'comment' => '', 'precision' => null, 'fixed' => null], - 'publicKey' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'collate' => 'utf8_slovenian_ci', 'comment' => '', 'precision' => null, 'fixed' => null], - 'certificate' => ['type' => 'text', 'length' => null, 'null' => false, 'default' => null, 'collate' => 'utf8_slovenian_ci', 'comment' => '', 'precision' => null], + 'keyHandle' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'collate' => 'utf8_general_ci', 'comment' => '', 'precision' => null, 'fixed' => null], + 'publicKey' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'collate' => 'utf8_general_ci', 'comment' => '', 'precision' => null, 'fixed' => null], + 'certificate' => ['type' => 'text', 'length' => null, 'null' => false, 'default' => null, 'collate' => 'utf8_general_ci', 'comment' => '', 'precision' => null], 'counter' => ['type' => 'integer', 'length' => 11, 'unsigned' => false, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null, 'autoIncrement' => null], '_constraints' => [ 'primary' => ['type' => 'primary', 'columns' => ['id'], 'length' => []], From 4eea422a7bd808056ffa8f733c16e1283033be75 Mon Sep 17 00:00:00 2001 From: Marcelo Rocha Date: Fri, 8 Feb 2019 12:15:33 -0200 Subject: [PATCH 15/18] removed table collate --- tests/Fixture/U2fRegistrationsFixture.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Fixture/U2fRegistrationsFixture.php b/tests/Fixture/U2fRegistrationsFixture.php index 5121dff95..5e2abcffb 100644 --- a/tests/Fixture/U2fRegistrationsFixture.php +++ b/tests/Fixture/U2fRegistrationsFixture.php @@ -28,9 +28,9 @@ class U2fRegistrationsFixture extends TestFixture public $fields = [ 'id' => ['type' => 'integer', 'length' => 11, 'unsigned' => false, 'null' => false, 'default' => null, 'comment' => '', 'autoIncrement' => true, 'precision' => null], 'user_id' => ['type' => 'uuid', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], - 'keyHandle' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'collate' => 'utf8_general_ci', 'comment' => '', 'precision' => null, 'fixed' => null], - 'publicKey' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'collate' => 'utf8_general_ci', 'comment' => '', 'precision' => null, 'fixed' => null], - 'certificate' => ['type' => 'text', 'length' => null, 'null' => false, 'default' => null, 'collate' => 'utf8_general_ci', 'comment' => '', 'precision' => null], + 'keyHandle' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], + 'publicKey' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], + 'certificate' => ['type' => 'text', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 'counter' => ['type' => 'integer', 'length' => 11, 'unsigned' => false, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null, 'autoIncrement' => null], '_constraints' => [ 'primary' => ['type' => 'primary', 'columns' => ['id'], 'length' => []], From dfdc82c60560abc4cd423cc6b25fdf8d1e9ff014 Mon Sep 17 00:00:00 2001 From: Marcelo Rocha Date: Fri, 8 Feb 2019 15:54:49 -0200 Subject: [PATCH 16/18] U2f data should be saved at users table in additional_data column --- .../20190207112757_CreateU2fRegistrations.php | 49 -- ...0190208174112_AddAdditionalDataToUsers.php | 32 ++ src/Controller/Traits/U2fTrait.php | 52 ++- src/Model/Entity/User.php | 15 + src/Model/Table/UsersTable.php | 18 +- tests/Fixture/U2fRegistrationsFixture.php | 64 --- tests/Fixture/UsersFixture.php | 428 +++++++++--------- .../Controller/Traits/U2fTraitTest.php | 90 ++-- 8 files changed, 365 insertions(+), 383 deletions(-) delete mode 100644 config/Migrations/20190207112757_CreateU2fRegistrations.php create mode 100644 config/Migrations/20190208174112_AddAdditionalDataToUsers.php delete mode 100644 tests/Fixture/U2fRegistrationsFixture.php diff --git a/config/Migrations/20190207112757_CreateU2fRegistrations.php b/config/Migrations/20190207112757_CreateU2fRegistrations.php deleted file mode 100644 index 4590f4ecd..000000000 --- a/config/Migrations/20190207112757_CreateU2fRegistrations.php +++ /dev/null @@ -1,49 +0,0 @@ -table('u2f_registrations'); - $table->addColumn('user_id', 'uuid', [ - 'null' => false, - ]); - $table->addColumn('keyHandle', 'string', [ - 'default' => null, - 'limit' => 255, - 'null' => false, - ]); - $table->addColumn('publicKey', 'string', [ - 'default' => null, - 'limit' => 255, - 'null' => false, - ]); - $table->addColumn('certificate', 'text', [ - 'default' => null, - 'null' => false, - ]); - $table->addColumn('counter', 'integer', [ - 'default' => null, - 'limit' => 11, - 'null' => false, - ]); - $table->create(); - } -} diff --git a/config/Migrations/20190208174112_AddAdditionalDataToUsers.php b/config/Migrations/20190208174112_AddAdditionalDataToUsers.php new file mode 100644 index 000000000..731be0fc5 --- /dev/null +++ b/config/Migrations/20190208174112_AddAdditionalDataToUsers.php @@ -0,0 +1,32 @@ +table('users'); + $table->addColumn('additional_data', 'text', [ + 'default' => null, + 'null' => true, + ]); + $table->update(); + } +} diff --git a/src/Controller/Traits/U2fTrait.php b/src/Controller/Traits/U2fTrait.php index ff4c79e71..ba4887267 100644 --- a/src/Controller/Traits/U2fTrait.php +++ b/src/Controller/Traits/U2fTrait.php @@ -1,4 +1,13 @@ request->getSession()->read('U2f.User'); - if (!isset($user['id'])) { + $data = $this->getU2fData(); + if (!$data['valid']) { return $this->redirectWithQuery([ 'action' => 'login' ]); } - $user = $this->getUsersTable()->get($user['id']); - $hasRegistration = $this->getUsersTable()->U2fRegistrations->exists([ - 'user_id' => $user['id'] - ]); - if (!$hasRegistration) { + if (!$data['registration']) { return $this->redirectWithQuery([ 'action' => 'u2fRegister' ]); @@ -97,12 +102,11 @@ public function u2fRegisterFinish() $request = json_decode($this->request->getSession()->read('U2f.registerRequest')); $response = json_decode($this->request->getData('registerResponse')); try { - $registration = $this->createU2fLib()->doRegister($request, $response); - $registration = json_decode(json_encode($registration), true); - $registration['user_id'] = $data['user']['id']; - $table = $this->getUsersTable()->U2fRegistrations; - $entity = $table->newEntity($registration); - $table->saveOrFail($entity); + $result = $this->createU2fLib()->doRegister($request, $response); + $additionalData = $data['user']->additional_data; + $additionalData['u2f_registration'] = $result; + $data['user']->additional_data = $additionalData; + $this->getUsersTable()->saveOrFail($data['user'], ['checkRules' => false]); $this->request->getSession()->delete('U2f.registerRequest'); return $this->redirectWithQuery([ @@ -153,16 +157,15 @@ public function u2fAuthenticateFinish() $response = json_decode($this->request->getData('authenticateResponse')); try { - $Model = $this->getUsersTable()->U2fRegistrations; - $registration = $Model->find('all')->where([ - 'user_id' => $data['user']['id'] - ])->first(); - + $registration = $data['registration']; $result = $this->createU2fLib()->doAuthenticate($request, [$registration], $response); $registration->counter = $result->counter; - $Model->saveOrFail($registration); + $additionalData = $data['user']->additional_data; + $additionalData['u2f_registration'] = $result; + $data['user']->additional_data = $additionalData; + $this->getUsersTable()->saveOrFail($data['user'], ['checkRules' => false]); $this->request->getSession()->delete('U2f'); - $this->Auth->setUser($data['user']); + $this->Auth->setUser($data['user']->toArray()); return $this->redirect($this->Auth->redirectUrl()); } catch (\Exception $e) { @@ -199,14 +202,13 @@ protected function getU2fData() 'user' => null, 'registration' => null ]; - $data['user'] = $this->request->getSession()->read('U2f.User'); - if (!isset($data['user']['id'])) { + $user = $this->request->getSession()->read('U2f.User'); + if (!isset($user['id'])) { return $data; } + $data['user'] = $this->getUsersTable()->get($user['id']); $data['valid'] = $this->getU2fAuthenticationChecker()->isEnabled(); - $data['registration'] = $this->getUsersTable()->U2fRegistrations->find('all')->where([ - 'user_id' => $data['user']['id'] - ])->first(); + $data['registration'] = $data['user']->u2f_registration; return $data; } diff --git a/src/Model/Entity/User.php b/src/Model/Entity/User.php index e4c8b8520..2b37d058d 100644 --- a/src/Model/Entity/User.php +++ b/src/Model/Entity/User.php @@ -149,6 +149,21 @@ protected function _getAvatar() return $avatar; } + /** + * Return the u2f_registration inside additional_data + * + * @return object|null + */ + protected function _getU2fRegistration() + { + if (!isset($this->additional_data['u2f_registration'])) { + return null; + } + $object = (object)$this->additional_data['u2f_registration']; + + return isset($object->keyHandle) ? $object : null; + } + /** * Generate token_expires and token in a user * @param int $tokenExpiration seconds to expire the token from Now diff --git a/src/Model/Table/UsersTable.php b/src/Model/Table/UsersTable.php index fb9808ca2..18a78a54b 100644 --- a/src/Model/Table/UsersTable.php +++ b/src/Model/Table/UsersTable.php @@ -11,6 +11,7 @@ namespace CakeDC\Users\Model\Table; +use Cake\Database\Schema\TableSchema; use Cake\ORM\Query; use Cake\ORM\RulesChecker; use Cake\ORM\Table; @@ -36,6 +37,19 @@ class UsersTable extends Table */ public $isValidateEmail = false; + /** + * Field additional_data is json + * + * @param \Cake\Database\Schema\TableSchema $schema The table definition fetched from database. + * @return \Cake\Database\Schema\TableSchema the altered schema + */ + protected function _initializeSchema(TableSchema $schema) + { + $schema->setColumnType('additional_data', 'json'); + + return parent::_initializeSchema($schema); + } + /** * Initialize method * @@ -59,10 +73,6 @@ public function initialize(array $config) 'foreignKey' => 'user_id', 'className' => 'CakeDC/Users.SocialAccounts' ]); - $this->hasMany('U2fRegistrations', [ - 'foreignKey' => 'user_id', - 'className' => 'CakeDC/Users.U2fRegistrations' - ]); } /** diff --git a/tests/Fixture/U2fRegistrationsFixture.php b/tests/Fixture/U2fRegistrationsFixture.php deleted file mode 100644 index 5e2abcffb..000000000 --- a/tests/Fixture/U2fRegistrationsFixture.php +++ /dev/null @@ -1,64 +0,0 @@ - ['type' => 'integer', 'length' => 11, 'unsigned' => false, 'null' => false, 'default' => null, 'comment' => '', 'autoIncrement' => true, 'precision' => null], - 'user_id' => ['type' => 'uuid', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], - 'keyHandle' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], - 'publicKey' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], - 'certificate' => ['type' => 'text', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], - 'counter' => ['type' => 'integer', 'length' => 11, 'unsigned' => false, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null, 'autoIncrement' => null], - '_constraints' => [ - 'primary' => ['type' => 'primary', 'columns' => ['id'], 'length' => []], - ], - '_options' => [ - 'engine' => 'InnoDB', - 'collation' => 'utf8_general_ci' - ], - ]; - // @codingStandardsIgnoreEnd - - /** - * Init method - * - * @return void - */ - public function init() - { - $this->records = [ - [ - 'id' => 1, - 'user_id' => '00000000-0000-0000-0000-000000000001', - 'keyHandle' => 'fake key handle', - 'publicKey' => 'afdoaj0-23u423-ad ujsf-as8-0-afsd', - 'certificate' => '23jdsfoasdj0f9sa082304823423', - 'counter' => 1 - ], - ]; - parent::init(); - } -} diff --git a/tests/Fixture/UsersFixture.php b/tests/Fixture/UsersFixture.php index 7d604660e..a28adfbdb 100644 --- a/tests/Fixture/UsersFixture.php +++ b/tests/Fixture/UsersFixture.php @@ -5,7 +5,7 @@ * Licensed under The MIT License * Redistributions of files must retain the above copyright notice. * - * @copyright Copyright 2010 - 2017, Cake Development Corporation (https://www.cakedc.com) + * @copyright Copyright 2010 - 2019, Cake Development Corporation (https://www.cakedc.com) * @license MIT License (http://www.opensource.org/licenses/mit-license.php) */ @@ -45,6 +45,7 @@ class UsersFixture extends TestFixture 'role' => ['type' => 'string', 'length' => 255, 'null' => true, 'default' => 'user', 'comment' => '', 'precision' => null, 'fixed' => null], 'created' => ['type' => 'datetime', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 'modified' => ['type' => 'datetime', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], + 'additional_data' => ['type' => 'text', 'length' => null, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], '_constraints' => [ 'primary' => ['type' => 'primary', 'columns' => ['id'], 'length' => []], ], @@ -56,212 +57,225 @@ class UsersFixture extends TestFixture // @codingStandardsIgnoreEnd /** - * Records + * Init method * - * @var array + * @return void */ - public $records = [ - [ - 'id' => '00000000-0000-0000-0000-000000000001', - 'username' => 'user-1', - 'email' => 'user-1@test.com', - 'password' => '12345', - 'first_name' => 'first1', - 'last_name' => 'last1', - 'token' => 'ae93ddbe32664ce7927cf0c5c5a5e59d', - 'token_expires' => '2035-06-24 17:33:54', - 'api_token' => 'yyy', - 'activation_date' => '2015-06-24 17:33:54', - 'secret' => 'yyy', - 'secret_verified' => false, - 'tos_date' => '2015-06-24 17:33:54', - 'active' => false, - 'is_superuser' => true, - 'role' => 'admin', - 'created' => '2015-06-24 17:33:54', - 'modified' => '2015-06-24 17:33:54' - ], - [ - 'id' => '00000000-0000-0000-0000-000000000002', - 'username' => 'user-2', - 'email' => 'user-2@test.com', - 'password' => '12345', - 'first_name' => 'user', - 'last_name' => 'second', - 'token' => '6614f65816754310a5f0553436dd89e9', - 'token_expires' => '2015-06-24 17:33:54', - 'api_token' => 'xxx', - 'activation_date' => '2015-06-24 17:33:54', - 'secret' => 'xxx', - 'secret_verified' => false, - 'tos_date' => '2015-06-24 17:33:54', - 'active' => true, - 'is_superuser' => true, - 'role' => 'admin', - 'created' => '2015-06-24 17:33:54', - 'modified' => '2015-06-24 17:33:54' - ], - [ - 'id' => '00000000-0000-0000-0000-000000000003', - 'username' => 'user-3', - 'email' => 'user-3@test.com', - 'password' => '12345', - 'first_name' => 'user', - 'last_name' => 'third', - 'token' => 'token-3', - 'token_expires' => '2030-06-20 17:33:54', - 'api_token' => 'xxx', - 'activation_date' => '2015-06-24 17:33:54', - 'secret' => 'xxx', - 'secret_verified' => true, - 'is_superuser' => true, - 'tos_date' => '2015-06-24 17:33:54', - 'active' => false, - 'is_superuser' => true, - 'role' => 'admin', - 'created' => '2015-06-24 17:33:54', - 'modified' => '2015-06-24 17:33:54' - ], - [ - 'id' => '00000000-0000-0000-0000-000000000004', - 'username' => 'user-4', - 'email' => '4@example.com', - 'password' => 'Lorem ipsum dolor sit amet', - 'first_name' => 'FirstName4', - 'last_name' => 'Lorem ipsum dolor sit amet', - 'token' => 'token-4', - 'token_expires' => '2030-06-24 17:33:54', - 'api_token' => 'Lorem ipsum dolor sit amet', - 'activation_date' => '2015-06-24 17:33:54', - 'secret' => 'Lorem ipsum dolor sit amet', - 'secret_verified' => true, - 'is_superuser' => false, - 'tos_date' => '2015-06-24 17:33:54', - 'active' => true, - 'is_superuser' => false, - 'role' => 'Lorem ipsum dolor sit amet', - 'created' => '2015-06-24 17:33:54', - 'modified' => '2015-06-24 17:33:54' - ], - [ - 'id' => '00000000-0000-0000-0000-000000000005', - 'username' => 'user-5', - 'email' => 'test@example.com', - 'password' => '12345', - 'first_name' => 'first-user-5', - 'last_name' => 'firts name 5', - 'token' => 'token-5', - 'token_expires' => '2015-06-24 17:33:54', - 'api_token' => '', - 'activation_date' => '2015-06-24 17:33:54', - 'secret' => '', - 'secret_verified' => false, - 'tos_date' => '2015-06-24 17:33:54', - 'active' => true, - 'is_superuser' => false, - 'role' => 'user', - 'created' => '2015-06-24 17:33:54', - 'modified' => '2015-06-24 17:33:54' - ], - [ - 'id' => '00000000-0000-0000-0000-000000000006', - 'username' => 'user-6', - 'email' => '6@example.com', - 'password' => '$2y$10$IPPgJNSfvATsMBLbv/2r8OtpyTBibyM1g5GDxD4PivW9qBRwRkRbC', - 'first_name' => 'first-user-6', - 'last_name' => 'firts name 6', - 'token' => 'token-6', - 'token_expires' => '2015-06-24 17:33:54', - 'api_token' => '', - 'activation_date' => '2015-06-24 17:33:54', - 'secret' => '', - 'secret_verified' => false, - 'tos_date' => '2015-06-24 17:33:54', - 'active' => true, - 'is_superuser' => false, - 'role' => 'user', - 'created' => '2015-06-24 17:33:54', - 'modified' => '2015-06-24 17:33:54' - ], - [ - 'id' => '00000000-0000-0000-0000-000000000007', - 'username' => 'Lorem ipsum dolor sit amet', - 'email' => 'Lorem ipsum dolor sit amet', - 'password' => 'Lorem ipsum dolor sit amet', - 'first_name' => 'Lorem ipsum dolor sit amet', - 'last_name' => 'Lorem ipsum dolor sit amet', - 'token' => 'Lorem ipsum dolor sit amet', - 'token_expires' => '2015-06-24 17:33:54', - 'api_token' => 'Lorem ipsum dolor sit amet', - 'activation_date' => '2015-06-24 17:33:54', - 'secret' => 'Lorem ipsum dolor sit amet', - 'secret_verified' => false, - 'tos_date' => '2015-06-24 17:33:54', - 'active' => true, - 'is_superuser' => false, - 'role' => 'Lorem ipsum dolor sit amet', - 'created' => '2015-06-24 17:33:54', - 'modified' => '2015-06-24 17:33:54' - ], - [ - 'id' => '00000000-0000-0000-0000-000000000008', - 'username' => 'Lorem ipsum dolor sit amet', - 'email' => 'Lorem ipsum dolor sit amet', - 'password' => 'Lorem ipsum dolor sit amet', - 'first_name' => 'Lorem ipsum dolor sit amet', - 'last_name' => 'Lorem ipsum dolor sit amet', - 'token' => 'Lorem ipsum dolor sit amet', - 'token_expires' => '2015-06-24 17:33:54', - 'api_token' => 'Lorem ipsum dolor sit amet', - 'activation_date' => '2015-06-24 17:33:54', - 'secret' => 'Lorem ipsum dolor sit amet', - 'secret_verified' => false, - 'tos_date' => '2015-06-24 17:33:54', - 'active' => true, - 'is_superuser' => false, - 'role' => 'Lorem ipsum dolor sit amet', - 'created' => '2015-06-24 17:33:54', - 'modified' => '2015-06-24 17:33:54' - ], - [ - 'id' => '00000000-0000-0000-0000-000000000009', - 'username' => 'Lorem ipsum dolor sit amet', - 'email' => 'Lorem ipsum dolor sit amet', - 'password' => 'Lorem ipsum dolor sit amet', - 'first_name' => 'Lorem ipsum dolor sit amet', - 'last_name' => 'Lorem ipsum dolor sit amet', - 'token' => 'Lorem ipsum dolor sit amet', - 'token_expires' => '2015-06-24 17:33:54', - 'api_token' => 'Lorem ipsum dolor sit amet', - 'activation_date' => '2015-06-24 17:33:54', - 'secret' => 'Lorem ipsum dolor sit amet', - 'secret_verified' => false, - 'tos_date' => '2015-06-24 17:33:54', - 'active' => true, - 'is_superuser' => false, - 'role' => 'Lorem ipsum dolor sit amet', - 'created' => '2015-06-24 17:33:54', - 'modified' => '2015-06-24 17:33:54' - ], - [ - 'id' => '00000000-0000-0000-0000-000000000010', - 'username' => 'Lorem ipsum dolor sit amet', - 'email' => 'Lorem ipsum dolor sit amet', - 'password' => 'Lorem ipsum dolor sit amet', - 'first_name' => 'Lorem ipsum dolor sit amet', - 'last_name' => 'Lorem ipsum dolor sit amet', - 'token' => 'Lorem ipsum dolor sit amet', - 'token_expires' => '2015-06-24 17:33:54', - 'api_token' => 'Lorem ipsum dolor sit amet', - 'activation_date' => '2015-06-24 17:33:54', - 'secret' => 'Lorem ipsum dolor sit amet', - 'secret_verified' => false, - 'tos_date' => '2015-06-24 17:33:54', - 'active' => true, - 'is_superuser' => false, - 'role' => 'Lorem ipsum dolor sit amet', - 'created' => '2015-06-24 17:33:54', - 'modified' => '2015-06-24 17:33:54' - ], - ]; + public function init() + { + $this->records = [ + [ + 'id' => '00000000-0000-0000-0000-000000000001', + 'username' => 'user-1', + 'email' => 'user-1@test.com', + 'password' => '12345', + 'first_name' => 'first1', + 'last_name' => 'last1', + 'token' => 'ae93ddbe32664ce7927cf0c5c5a5e59d', + 'token_expires' => '2035-06-24 17:33:54', + 'api_token' => 'yyy', + 'activation_date' => '2015-06-24 17:33:54', + 'secret' => 'yyy', + 'secret_verified' => false, + 'tos_date' => '2015-06-24 17:33:54', + 'active' => false, + 'is_superuser' => true, + 'role' => 'admin', + 'created' => '2015-06-24 17:33:54', + 'modified' => '2015-06-24 17:33:54', + 'additional_data' => json_encode([ + 'u2f_registration' => [ + 'keyHandle' => 'fake key handle', + 'publicKey' => 'afdoaj0-23u423-ad ujsf-as8-0-afsd', + 'certificate' => '23jdsfoasdj0f9sa082304823423', + 'counter' => 1 + ] + ]) + ], + [ + 'id' => '00000000-0000-0000-0000-000000000002', + 'username' => 'user-2', + 'email' => 'user-2@test.com', + 'password' => '12345', + 'first_name' => 'user', + 'last_name' => 'second', + 'token' => '6614f65816754310a5f0553436dd89e9', + 'token_expires' => '2015-06-24 17:33:54', + 'api_token' => 'xxx', + 'activation_date' => '2015-06-24 17:33:54', + 'secret' => 'xxx', + 'secret_verified' => false, + 'tos_date' => '2015-06-24 17:33:54', + 'active' => true, + 'is_superuser' => true, + 'role' => 'admin', + 'created' => '2015-06-24 17:33:54', + 'modified' => '2015-06-24 17:33:54' + ], + [ + 'id' => '00000000-0000-0000-0000-000000000003', + 'username' => 'user-3', + 'email' => 'user-3@test.com', + 'password' => '12345', + 'first_name' => 'user', + 'last_name' => 'third', + 'token' => 'token-3', + 'token_expires' => '2030-06-20 17:33:54', + 'api_token' => 'xxx', + 'activation_date' => '2015-06-24 17:33:54', + 'secret' => 'xxx', + 'secret_verified' => true, + 'is_superuser' => true, + 'tos_date' => '2015-06-24 17:33:54', + 'active' => false, + 'is_superuser' => true, + 'role' => 'admin', + 'created' => '2015-06-24 17:33:54', + 'modified' => '2015-06-24 17:33:54' + ], + [ + 'id' => '00000000-0000-0000-0000-000000000004', + 'username' => 'user-4', + 'email' => '4@example.com', + 'password' => 'Lorem ipsum dolor sit amet', + 'first_name' => 'FirstName4', + 'last_name' => 'Lorem ipsum dolor sit amet', + 'token' => 'token-4', + 'token_expires' => '2030-06-24 17:33:54', + 'api_token' => 'Lorem ipsum dolor sit amet', + 'activation_date' => '2015-06-24 17:33:54', + 'secret' => 'Lorem ipsum dolor sit amet', + 'secret_verified' => true, + 'is_superuser' => false, + 'tos_date' => '2015-06-24 17:33:54', + 'active' => true, + 'is_superuser' => false, + 'role' => 'Lorem ipsum dolor sit amet', + 'created' => '2015-06-24 17:33:54', + 'modified' => '2015-06-24 17:33:54' + ], + [ + 'id' => '00000000-0000-0000-0000-000000000005', + 'username' => 'user-5', + 'email' => 'test@example.com', + 'password' => '12345', + 'first_name' => 'first-user-5', + 'last_name' => 'firts name 5', + 'token' => 'token-5', + 'token_expires' => '2015-06-24 17:33:54', + 'api_token' => '', + 'activation_date' => '2015-06-24 17:33:54', + 'secret' => '', + 'secret_verified' => false, + 'tos_date' => '2015-06-24 17:33:54', + 'active' => true, + 'is_superuser' => false, + 'role' => 'user', + 'created' => '2015-06-24 17:33:54', + 'modified' => '2015-06-24 17:33:54' + ], + [ + 'id' => '00000000-0000-0000-0000-000000000006', + 'username' => 'user-6', + 'email' => '6@example.com', + 'password' => '$2y$10$IPPgJNSfvATsMBLbv/2r8OtpyTBibyM1g5GDxD4PivW9qBRwRkRbC', + 'first_name' => 'first-user-6', + 'last_name' => 'firts name 6', + 'token' => 'token-6', + 'token_expires' => '2015-06-24 17:33:54', + 'api_token' => '', + 'activation_date' => '2015-06-24 17:33:54', + 'secret' => '', + 'secret_verified' => false, + 'tos_date' => '2015-06-24 17:33:54', + 'active' => true, + 'is_superuser' => false, + 'role' => 'user', + 'created' => '2015-06-24 17:33:54', + 'modified' => '2015-06-24 17:33:54' + ], + [ + 'id' => '00000000-0000-0000-0000-000000000007', + 'username' => 'Lorem ipsum dolor sit amet', + 'email' => 'Lorem ipsum dolor sit amet', + 'password' => 'Lorem ipsum dolor sit amet', + 'first_name' => 'Lorem ipsum dolor sit amet', + 'last_name' => 'Lorem ipsum dolor sit amet', + 'token' => 'Lorem ipsum dolor sit amet', + 'token_expires' => '2015-06-24 17:33:54', + 'api_token' => 'Lorem ipsum dolor sit amet', + 'activation_date' => '2015-06-24 17:33:54', + 'secret' => 'Lorem ipsum dolor sit amet', + 'secret_verified' => false, + 'tos_date' => '2015-06-24 17:33:54', + 'active' => true, + 'is_superuser' => false, + 'role' => 'Lorem ipsum dolor sit amet', + 'created' => '2015-06-24 17:33:54', + 'modified' => '2015-06-24 17:33:54' + ], + [ + 'id' => '00000000-0000-0000-0000-000000000008', + 'username' => 'Lorem ipsum dolor sit amet', + 'email' => 'Lorem ipsum dolor sit amet', + 'password' => 'Lorem ipsum dolor sit amet', + 'first_name' => 'Lorem ipsum dolor sit amet', + 'last_name' => 'Lorem ipsum dolor sit amet', + 'token' => 'Lorem ipsum dolor sit amet', + 'token_expires' => '2015-06-24 17:33:54', + 'api_token' => 'Lorem ipsum dolor sit amet', + 'activation_date' => '2015-06-24 17:33:54', + 'secret' => 'Lorem ipsum dolor sit amet', + 'secret_verified' => false, + 'tos_date' => '2015-06-24 17:33:54', + 'active' => true, + 'is_superuser' => false, + 'role' => 'Lorem ipsum dolor sit amet', + 'created' => '2015-06-24 17:33:54', + 'modified' => '2015-06-24 17:33:54' + ], + [ + 'id' => '00000000-0000-0000-0000-000000000009', + 'username' => 'Lorem ipsum dolor sit amet', + 'email' => 'Lorem ipsum dolor sit amet', + 'password' => 'Lorem ipsum dolor sit amet', + 'first_name' => 'Lorem ipsum dolor sit amet', + 'last_name' => 'Lorem ipsum dolor sit amet', + 'token' => 'Lorem ipsum dolor sit amet', + 'token_expires' => '2015-06-24 17:33:54', + 'api_token' => 'Lorem ipsum dolor sit amet', + 'activation_date' => '2015-06-24 17:33:54', + 'secret' => 'Lorem ipsum dolor sit amet', + 'secret_verified' => false, + 'tos_date' => '2015-06-24 17:33:54', + 'active' => true, + 'is_superuser' => false, + 'role' => 'Lorem ipsum dolor sit amet', + 'created' => '2015-06-24 17:33:54', + 'modified' => '2015-06-24 17:33:54' + ], + [ + 'id' => '00000000-0000-0000-0000-000000000010', + 'username' => 'Lorem ipsum dolor sit amet', + 'email' => 'Lorem ipsum dolor sit amet', + 'password' => 'Lorem ipsum dolor sit amet', + 'first_name' => 'Lorem ipsum dolor sit amet', + 'last_name' => 'Lorem ipsum dolor sit amet', + 'token' => 'Lorem ipsum dolor sit amet', + 'token_expires' => '2015-06-24 17:33:54', + 'api_token' => 'Lorem ipsum dolor sit amet', + 'activation_date' => '2015-06-24 17:33:54', + 'secret' => 'Lorem ipsum dolor sit amet', + 'secret_verified' => false, + 'tos_date' => '2015-06-24 17:33:54', + 'active' => true, + 'is_superuser' => false, + 'role' => 'Lorem ipsum dolor sit amet', + 'created' => '2015-06-24 17:33:54', + 'modified' => '2015-06-24 17:33:54' + ], + ]; + + parent::init(); + } } diff --git a/tests/TestCase/Controller/Traits/U2fTraitTest.php b/tests/TestCase/Controller/Traits/U2fTraitTest.php index bcf325b9d..e4e128026 100644 --- a/tests/TestCase/Controller/Traits/U2fTraitTest.php +++ b/tests/TestCase/Controller/Traits/U2fTraitTest.php @@ -1,4 +1,14 @@ Trait->request = $this->getMockBuilder('Cake\Http\ServerRequest') ->setMethods(['getSession', 'getData']) @@ -313,10 +322,13 @@ public function testU2fRegisterFinish() $actual ); - $registration = json_decode(json_encode($registration), true); - $registration['user_id'] = '00000000-0000-0000-0000-000000000002'; - $exists = TableRegistry::getTableLocator()->get('CakeDC/Users.U2fRegistrations')->exists($registration); - $this->assertTrue($exists); + $saveUser = TableRegistry::getTableLocator() + ->get('CakeDC/Users.Users') + ->get('00000000-0000-0000-0000-000000000002'); + + $savedRegistration = $saveUser->u2f_registration; + $this->assertNotNull($savedRegistration); + $this->assertEquals(json_encode($registration), json_encode($savedRegistration)); $registration = new Registration(); $registration->certificate = "user registration cert " . time(); @@ -403,10 +415,12 @@ public function testU2fRegisterFinishException() $actual ); - $registration = json_decode(json_encode($registration), true); - $registration['user_id'] = '00000000-0000-0000-0000-000000000002'; - $exists = TableRegistry::getTableLocator()->get('CakeDC/Users.U2fRegistrations')->exists($registration); - $this->assertFalse($exists); + $saveUser = TableRegistry::getTableLocator() + ->get('CakeDC/Users.Users') + ->get('00000000-0000-0000-0000-000000000002'); + + $savedRegistration = $saveUser->u2f_registration; + $this->assertNull($savedRegistration); $registration = new Registration(); $registration->certificate = "user registration cert " . time(); @@ -482,12 +496,15 @@ public function testU2fAuthenticate() ['fake' => new \stdClass()], ['fake2' => new \stdClass()], ]; - $registrations = TableRegistry::getTableLocator() - ->get('CakeDC/Users.U2fRegistrations') - ->findByUserId('00000000-0000-0000-0000-000000000001') - ->all() - ->toArray(); - $this->assertCount(1, $registrations); + $reg1 = [ + 'keyHandle' => 'fake key handle', + 'publicKey' => 'afdoaj0-23u423-ad ujsf-as8-0-afsd', + 'certificate' => '23jdsfoasdj0f9sa082304823423', + 'counter' => 1 + ]; + $registrations = [ + (object)$reg1 + ]; $u2fLib->expects($this->once()) ->method('getAuthenticateData') ->with( @@ -528,12 +545,12 @@ public function testU2fAuthenticate() */ public function testU2fAutheticateFinish() { - $registration = TableRegistry::getTableLocator() - ->get('CakeDC/Users.U2fRegistrations') - ->findByUserId('00000000-0000-0000-0000-000000000001') - ->first(); - $this->assertNotNull($registration); + $user = TableRegistry::getTableLocator() + ->get('CakeDC/Users.Users') + ->get('00000000-0000-0000-0000-000000000001'); + $this->assertNotNull( $user->u2f_registration); + $registration = $user->u2f_registration; $registrationEntityResult = new Registration(); $registrationEntityResult->keyHandle = $registration->keyHandle; $registrationEntityResult->publicKey = $registration->publicKey; @@ -545,13 +562,7 @@ public function testU2fAutheticateFinish() ->will($this->returnValue('/my-home-page')); $this->Trait->Auth->expects($this->once()) - ->method('setUser') - ->with( - $this->equalTo([ - 'id' => '00000000-0000-0000-0000-000000000001', - 'username' => 'user-1', - ]) - ); + ->method('setUser'); $this->Trait->request = $this->getMockBuilder('Cake\Http\ServerRequest') ->setMethods(['getSession', 'getData']) @@ -611,7 +622,11 @@ public function testU2fAutheticateFinish() $actual = $this->Trait->request->getSession()->read('U2f'); $this->assertNull($actual); - $updatedEntity = TableRegistry::getTableLocator()->get('CakeDC/Users.U2fRegistrations')->get($registration['id']); + $updatedEntity = TableRegistry::getTableLocator() + ->get('CakeDC/Users.Users') + ->get($user['id']) + ->u2f_registration; + $this->assertEquals($registrationEntityResult->counter, $updatedEntity->counter); } @@ -622,10 +637,13 @@ public function testU2fAutheticateFinish() */ public function testU2fAutheticateFinishWithException() { - $registration = TableRegistry::getTableLocator() - ->get('CakeDC/Users.U2fRegistrations') - ->findByUserId('00000000-0000-0000-0000-000000000001') - ->first(); + $saveUser = TableRegistry::getTableLocator() + ->get('CakeDC/Users.Users') + ->get('00000000-0000-0000-0000-000000000001'); + + $savedRegistration = $saveUser->u2f_registration; + $this->assertNotNull($savedRegistration); + $registration = $saveUser->u2f_registration; $counter = $registration->counter; $this->assertNotNull($registration); @@ -700,7 +718,11 @@ public function testU2fAutheticateFinishWithException() $actual ); - $updatedEntity = TableRegistry::getTableLocator()->get('CakeDC/Users.U2fRegistrations')->get($registration['id']); + $updatedEntityUser = TableRegistry::getTableLocator() + ->get('CakeDC/Users.Users') + ->get('00000000-0000-0000-0000-000000000001'); + + $updatedEntity = $updatedEntityUser->u2f_registration; $this->assertEquals($counter, $updatedEntity->counter); } } From af6942519b42199139ea77b1dd8f17d2837cc3de Mon Sep 17 00:00:00 2001 From: Marcelo Rocha Date: Fri, 8 Feb 2019 15:56:20 -0200 Subject: [PATCH 17/18] phpcs fixes --- src/Controller/Traits/U2fTrait.php | 2 +- tests/TestCase/Controller/Traits/U2fTraitTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Controller/Traits/U2fTrait.php b/src/Controller/Traits/U2fTrait.php index ba4887267..d74ff2511 100644 --- a/src/Controller/Traits/U2fTrait.php +++ b/src/Controller/Traits/U2fTrait.php @@ -163,7 +163,7 @@ public function u2fAuthenticateFinish() $additionalData = $data['user']->additional_data; $additionalData['u2f_registration'] = $result; $data['user']->additional_data = $additionalData; - $this->getUsersTable()->saveOrFail($data['user'], ['checkRules' => false]); + $this->getUsersTable()->saveOrFail($data['user'], ['checkRules' => false]); $this->request->getSession()->delete('U2f'); $this->Auth->setUser($data['user']->toArray()); diff --git a/tests/TestCase/Controller/Traits/U2fTraitTest.php b/tests/TestCase/Controller/Traits/U2fTraitTest.php index e4e128026..35869a8ff 100644 --- a/tests/TestCase/Controller/Traits/U2fTraitTest.php +++ b/tests/TestCase/Controller/Traits/U2fTraitTest.php @@ -548,7 +548,7 @@ public function testU2fAutheticateFinish() $user = TableRegistry::getTableLocator() ->get('CakeDC/Users.Users') ->get('00000000-0000-0000-0000-000000000001'); - $this->assertNotNull( $user->u2f_registration); + $this->assertNotNull($user->u2f_registration); $registration = $user->u2f_registration; $registrationEntityResult = new Registration(); From 082bf504eef4a98babe35a307d823bac6d9c6e69 Mon Sep 17 00:00:00 2001 From: Marcelo Rocha Date: Mon, 11 Feb 2019 08:53:47 -0200 Subject: [PATCH 18/18] Version 8.1.0 --- .semver | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.semver b/.semver index 87b0ac22f..8372dfe62 100644 --- a/.semver +++ b/.semver @@ -1,5 +1,5 @@ --- :major: 8 -:minor: 0 -:patch: 3 +:minor: 1 +:patch: 0 :special: ''