From ec689215957e7536efb07e845effa445b48572a2 Mon Sep 17 00:00:00 2001 From: kiswa Date: Mon, 23 May 2016 20:34:06 +0000 Subject: [PATCH] Auth controller implemented and 100% test coverage --- src/api/controllers/Auth.php | 131 ++++++++++++++++++++----- src/api/controllers/BaseController.php | 4 +- src/api/controllers/Users.php | 12 ++- src/api/index.php | 53 +++++----- src/api/models/User.php | 3 + test/api/Mocks.php | 10 +- test/api/controllers/AuthTest.php | 111 +++++++++++++++++++-- 7 files changed, 262 insertions(+), 62 deletions(-) diff --git a/src/api/controllers/Auth.php b/src/api/controllers/Auth.php index 20cc182..f2ad35b 100644 --- a/src/api/controllers/Auth.php +++ b/src/api/controllers/Auth.php @@ -4,22 +4,68 @@ use Firebase\JWT\JWT; class Auth extends BaseController { + public static function CreateInitialAdmin($container) { + $admin = new User($container, 1); + + if ($admin->id === 1) { + return; + } + + $admin->security_level = new SecurityLevel(SecurityLevel::Admin); + $admin->username = 'admin'; + $admin->password_hash = password_hash('admin', PASSWORD_BCRYPT); + $admin->save(); + } + + public static function CreateJwtKey() { + $key = R::load('jwt', 1); + + // Don't create more than one secret key + if ($key->id) { + return; + } + + // Generate a JWT signing key by hashing the current time. + $key->secret = hash('sha512', strval(time())); + + R::store($key); + } + + // TODO: Determine if this endpoint is needed. + // The new API should be varifying and updating the user's + // token on each call so an authentication endpoint should not + // be needed. The code will remain for now as an example of what + // to do in the future. public function authenticate($request, $response, $args) { if (!$request->hasHeader('Authorization')) { - return $response->withStatus(400); // Bad Request + return $this->jsonResponse($response, 400); } - $jwt = $request->getHeader('Authorization'); - $payload = null; + $jwt = $request->getHeader('Authorization')[0]; + $payload = $this->getJwtPayload($jwt); - try { - $payload = JWT::decode($jwt, $this->getJwtKey(), array('HS256')); - } catch (Exception $ex) { + if ($payload === null) { + return $this->jsonResponse($response, 401); } - // Issue new token with extended expiration + $user = new User($this->container, (int) $payload->uid); + if ($user->active_token !== $jwt) { + $user->active_token = ''; + $user->save(); - return $response->withJson(json_encode($jwt)); + $this->apiJson->addAlert('error', 'Invalid access token.'); + + return $this->jsonResponse($response, 401); + } + + $jwt = $this->createJwt($payload->uid); + $user->active_token = $jwt; + $user->save(); + + $this->apiJson->setSuccess(); + $this->apiJson->addData($jwt); + + return $this->jsonResponse($response); } public function login($request, $response, $args) { @@ -29,38 +75,77 @@ class Auth extends BaseController { if ($user === null) { $this->apiJson->addAlert('error', 'Invalid username or password.'); - return $this->jsonResponse($response); + return $this->jsonResponse($response, 401); } if (!password_verify($data->password, $user->password_hash)) { $this->apiJson->addAlert('error', 'Invalid username or password.'); - return $this->jsonResponse($response); + return $this->jsonResponse($response, 401); } - // Username and password verified - // Issue JWT + $jwt = $this->createJwt($user->id); + $user = new User($this->container, $user->id); + + $user->active_token = $jwt; + $user->last_login = time(); + $user->save(); + + $this->apiJson->setSuccess(); + $this->apiJson->addData($jwt); + + return $this->jsonResponse($response); } public function logout($request, $response, $args) { + if (!$request->hasHeader('Authorization')) { + return $this->jsonResponse($response, 400); + } + + $jwt = $request->getHeader('Authorization')[0]; + $payload = $this->getJwtPayload($jwt); + + if ($payload === null) { + return $this->jsonResponse($response, 401); + } + + $user = new User($this->container, $payload->uid); + + if ($user->id) { + $user->active_token = ''; + $user->save(); + } + + $this->apiJson->setSuccess(); + $this->apiJson->addAlert('success', 'You have been logged out.'); + + return $this->jsonResponse($response); } - private function generateJwt() { + private function getJwtPayload($jwt) { + try { + $payload = JWT::decode($jwt, $this->getJwtKey(), ['HS256']); + } catch (Exception $ex) { + $this->apiJson->addAlert('error', 'Invalid access token.'); + + return null; + } + + return $payload; + } + + private function createJwt($userId) { + return JWT::encode(array( + 'exp' => time() + (60 * 30), // 30 minutes + 'uid' => $userId + ), $this->getJwtKey()); } private function getJwtKey() { + self::CreateJwtKey(); $key = R::load('jwt', 1); - if ($key->id === 0) { - // Generate a JWT key by hashing the current time. - // This should make (effectively) every instance of TaskBoard - // have a unique secret key for JWTs. - $key->token = password_hash(strval(time()), PASSWORD_BCRYPT); - - R::store($key); - } - - return $key->token; + return $key->secret; } } diff --git a/src/api/controllers/BaseController.php b/src/api/controllers/BaseController.php index b775c15..2089273 100644 --- a/src/api/controllers/BaseController.php +++ b/src/api/controllers/BaseController.php @@ -13,8 +13,8 @@ abstract class BaseController { $this->container = $container; } - public function jsonResponse($response) { - return $response->withJson($this->apiJson); + public function jsonResponse($response, $status = 200) { + return $response->withStatus($status)->withJson($this->apiJson); } } diff --git a/src/api/controllers/Users.php b/src/api/controllers/Users.php index 60a21c6..eb29fb9 100644 --- a/src/api/controllers/Users.php +++ b/src/api/controllers/Users.php @@ -13,7 +13,7 @@ class Users extends BaseController { $user = new User($this->container); $user->loadFromBean($bean); - $this->apiJson->addData($user); + $this->apiJson->addData($this->cleanUser($user)); } } else { $this->logger->addInfo('No users in database.'); @@ -36,7 +36,7 @@ class Users extends BaseController { } $this->apiJson->setSuccess(); - $this->apiJson->addData($user); + $this->apiJson->addData($this->cleanUser($user)); return $this->jsonResponse($response); } @@ -120,5 +120,13 @@ class Users extends BaseController { return $this->jsonResponse($response); } + + private function cleanUser($user) { + $user->security_level = $user->security_level->getValue(); + unset($user->password_hash); + unset($user->active_token); + + return $user; + } } diff --git a/src/api/index.php b/src/api/index.php index 44d1f71..b062df1 100644 --- a/src/api/index.php +++ b/src/api/index.php @@ -7,47 +7,50 @@ R::setup('sqlite:taskboard.sqlite'); $app = new Slim\App(); require 'app-setup.php'; +Auth::CreateInitialAdmin($container); +Auth::CreateJwtKey(); + $app->get ('/', 'Invalid:noApi'); -$app->get ('/boards', 'Boards:getAllBoards'); -$app->get ('/boards/{id}', 'Boards:getBoard'); -$app->post ('/boards', 'Boards:addBoard'); -$app->post ('/boards/{id}', 'Boards:updateBoard'); -$app->delete('/boards/{id}', 'Boards:removeBoard'); +$app->get ('/boards', 'Boards:getAllBoards'); +$app->get ('/boards/{id}', 'Boards:getBoard'); +$app->post ('/boards', 'Boards:addBoard'); +$app->post ('/boards/{id}', 'Boards:updateBoard'); +$app->delete('/boards/{id}', 'Boards:removeBoard'); $app->get ('/autoactions', 'AutoActions:getAllActions'); $app->post ('/autoactions', 'AutoActions:addAction'); $app->delete('/autoactions/{id}', 'AutoActions:removeAction'); -$app->get ('/columns/{id}', 'Columns:getColumn'); -$app->post ('/columns', 'Columns:addColumn'); -$app->post ('/columns/{id}', 'Columns:updateColumn'); -$app->delete('/columns/{id}', 'Columns:removeColumn'); +$app->get ('/columns/{id}', 'Columns:getColumn'); +$app->post ('/columns', 'Columns:addColumn'); +$app->post ('/columns/{id}', 'Columns:updateColumn'); +$app->delete('/columns/{id}', 'Columns:removeColumn'); -$app->get ('/tasks/{id}', 'Tasks:getTask'); -$app->post ('/tasks', 'Tasks:addTask'); -$app->post ('/tasks/{id}', 'Tasks:updateTask'); -$app->delete('/tasks/{id}', 'Tasks:removeTask'); +$app->get ('/tasks/{id}', 'Tasks:getTask'); +$app->post ('/tasks', 'Tasks:addTask'); +$app->post ('/tasks/{id}', 'Tasks:updateTask'); +$app->delete('/tasks/{id}', 'Tasks:removeTask'); -$app->get ('/comments/{id}', 'Comments:getComment'); -$app->post ('/comments', 'Comments:addComment'); -$app->post ('/comments/{id}', 'Comments:updateComment'); -$app->delete('/comments/{id}', 'Comments:removeComment'); +$app->get ('/comments/{id}', 'Comments:getComment'); +$app->post ('/comments', 'Comments:addComment'); +$app->post ('/comments/{id}', 'Comments:updateComment'); +$app->delete('/comments/{id}', 'Comments:removeComment'); $app->get ('/attachments/{id}', 'Attachments:getAttachment'); $app->post ('/attachments', 'Attachments:addAttachment'); $app->post ('/attachments/{id}', 'Attachments:updateAttachment'); $app->delete('/attachments/{id}', 'Attachments:removeAttachment'); -$app->get ('/users', 'Users:getAllUsers'); -$app->get ('/users/{id}', 'Users:getUser'); -$app->post ('/users', 'Users:addUser'); -$app->post ('/users/{id}', 'Users:updateUser'); -$app->delete('/users/{id}', 'Users:removeUser'); +$app->get ('/users', 'Users:getAllUsers'); +$app->get ('/users/{id}', 'Users:getUser'); +$app->post ('/users', 'Users:addUser'); +$app->post ('/users/{id}', 'Users:updateUser'); +$app->delete('/users/{id}', 'Users:removeUser'); -$app->post('/authenticate', 'Auth:authenticate'); -$app->post('/login', 'Auth:login'); -$app->post('/logout', 'Auth:logout'); +$app->post('/authenticate', 'Auth:authenticate'); +$app->post('/login', 'Auth:login'); +$app->post('/logout', 'Auth:logout'); $app->run(); R::close(); diff --git a/src/api/models/User.php b/src/api/models/User.php index 59fa079..5afbe28 100644 --- a/src/api/models/User.php +++ b/src/api/models/User.php @@ -16,6 +16,7 @@ class User extends BaseModel { public $default_board_id = 0; public $user_option_id = 0; public $last_login = 0; + public $active_token = ''; public function __construct($container, $id = 0) { parent::__construct('user', $id, $container); @@ -36,6 +37,7 @@ class User extends BaseModel { $bean->default_board_id = $this->default_board_id; $bean->user_option_id = $this->user_option_id; $bean->last_login = $this->last_login; + $bean->active_token = $this->active_token; } public function loadFromBean($bean) { @@ -76,6 +78,7 @@ class User extends BaseModel { $this->default_board_id = (int) $obj->default_board_id; $this->user_option_id = (int) $obj->user_option_id; $this->last_login = (int) $obj->last_login; + $this->active_token = $obj->active_token; } catch (Exception $ex) { $this->is_valid = false; } diff --git a/test/api/Mocks.php b/test/api/Mocks.php index ffbfa88..8cb391b 100644 --- a/test/api/Mocks.php +++ b/test/api/Mocks.php @@ -63,6 +63,7 @@ class DataMock { $user->default_board_id = 1; $user->user_option_id = 1; $user->last_login = 123456789; + $user->active_token = ''; return $user; } @@ -162,6 +163,7 @@ class RequestMock { public $invalidPayload = false; public $payload = null; public $hasHeader = true; + public $header = null; public function getBody() { if ($this->invalidPayload) { @@ -180,7 +182,11 @@ class RequestMock { } public function getHeader($header) { - return $header; + if ($this->header) { + return $this->header; + } + + return (array) $header; } } @@ -191,7 +197,7 @@ class ResponseMock { } public function withStatus($status) { - return $status; + return $this; } } diff --git a/test/api/controllers/AuthTest.php b/test/api/controllers/AuthTest.php index 55c1983..ebafa47 100644 --- a/test/api/controllers/AuthTest.php +++ b/test/api/controllers/AuthTest.php @@ -1,36 +1,131 @@ auth = new Auth(new ContainerMock()); } - /** - * @group single - */ - public function testAuthenticate() { + public function testAuthenticateFailures() { $request = new RequestMock(); $request->hasHeader = false; $actual = $this->auth->authenticate($request, new ResponseMock(), null); - $this->assertTrue($actual === 400); + $this->assertTrue($actual->status === 'failure'); $actual = $this->auth->authenticate(new RequestMock(), new ResponseMock(), null); - $this->assertTrue($actual === json_encode('Authorization')); + $expected = new ApiJson(); + $expected->addAlert('error', 'Invalid access token.'); + + $this->assertEquals($expected, $actual); + } + + public function testAuthenticate() { + Auth::CreateInitialAdmin(new ContainerMock()); + // Called twice to verify coverage of the check for existing admin + Auth::CreateInitialAdmin(new ContainerMock()); + Auth::CreateJwtKey(); + + $jwtKey = R::load('jwt', 1); + $admin = R::load('user', 1); + + $token = JWT::encode(array( + 'exp' => time() + 600, + 'uid' => 1 + ), $jwtKey->secret); + + $admin->active_token = $token; + R::store($admin); + + $request = new RequestMock(); + $request->header = [$token]; + + $actual = $this->auth->authenticate($request, + new ResponseMock(), null); + $this->assertTrue(strlen($actual->data[0]) > 0); + + $this->auth = new Auth(new ContainerMock()); + + $admin->active_token = ''; + R::store($admin); + + $actual = $this->auth->authenticate($request, + new ResponseMock(), null); + $this->assertTrue($actual->status === 'failure'); + } + + public function testLogin() { + $data = new stdClass(); + $data->username = 'admin'; + $data->password = 'admin'; + + $request = new RequestMock(); + $request->payload = $data; + + $actual = $this->auth->login($request, new ResponseMock(), null); + $this->assertTrue($actual->status === 'failure'); + + $this->auth = new Auth(new ContainerMock()); + Auth::CreateInitialAdmin(new ContainerMock()); + Auth::CreateJwtKey(); + + $actual = $this->auth->login($request, new ResponseMock(), null); + $this->assertTrue($actual->status === 'success'); + + $this->auth = new Auth(new ContainerMock()); + $request->payload->password = 'asdf'; + + $actual = $this->auth->login($request, new ResponseMock(), null); + $this->assertTrue($actual->status === 'failure'); + } + + public function testLogout() { + Auth::CreateInitialAdmin(new ContainerMock()); + $data = new stdClass(); + $data->username = 'admin'; + $data->password = 'admin'; + + $request = new RequestMock(); + $request->payload = $data; + + $actual = $this->auth->login($request, new ResponseMock(), null); + $jwt = $actual->data[0]; + $jwtKey = R::load('jwt', 1); + + $this->auth = new Auth(new ContainerMock()); + $request = new RequestMock(); + $request->header = [$jwt]; + + $actual = $this->auth->logout($request, new ResponseMock(), null); + $this->assertTrue($actual->status === 'success'); + } + + public function testLogoutFailures() { + $actual = $this->auth->logout(new RequestMock(), + new ResponseMock(), null); + $this->assertTrue($actual->status === 'failure'); + + $this->auth = new Auth(new ContainerMock()); + $request = new RequestMock(); + $request->hasHeader = false; + + $actual = $this->auth->logout($request, new ResponseMock(), null); + $this->assertTrue($actual->status === 'failure'); } }