From 0379bdbc3aeab8208bdcb35a9a99cd54d4e4aef7 Mon Sep 17 00:00:00 2001 From: Roman Schmid Date: Fri, 19 Jun 2015 14:23:11 +0200 Subject: [PATCH 1/2] Added refresh-token action and DB field. --- code/authenticator/RESTfulAPI_TokenAuthExtension.php | 2 ++ code/authenticator/RESTfulAPI_TokenAuthenticator.php | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/code/authenticator/RESTfulAPI_TokenAuthExtension.php b/code/authenticator/RESTfulAPI_TokenAuthExtension.php index 64af2bd..36cc828 100644 --- a/code/authenticator/RESTfulAPI_TokenAuthExtension.php +++ b/code/authenticator/RESTfulAPI_TokenAuthExtension.php @@ -16,12 +16,14 @@ class RESTfulAPI_TokenAuthExtension extends DataExtension { private static $db = array( 'ApiToken' => 'Varchar(160)', + 'RefreshToken' => 'Varchar(160)', 'ApiTokenExpire' => 'Int' ); function updateCMSFields(FieldList $fields) { $fields->removeByName('ApiToken'); + $fields->removeByName('RefreshToken'); $fields->removeByName('ApiTokenExpire'); } } \ No newline at end of file diff --git a/code/authenticator/RESTfulAPI_TokenAuthenticator.php b/code/authenticator/RESTfulAPI_TokenAuthenticator.php index 58c8a01..23e8c06 100644 --- a/code/authenticator/RESTfulAPI_TokenAuthenticator.php +++ b/code/authenticator/RESTfulAPI_TokenAuthenticator.php @@ -84,7 +84,8 @@ class RESTfulAPI_TokenAuthenticator implements RESTfulAPI_Authenticator private static $allowed_actions = array( 'login', 'logout', - 'lostPassword' + 'lostPassword', + 'refreshToken' ); From e5b87a727932cf88c546e8c0a3e60dafb70dfc0d Mon Sep 17 00:00:00 2001 From: Roman Schmid Date: Thu, 28 May 2015 15:26:31 +0200 Subject: [PATCH 2/2] FIX #39: Check for token uniqueness when generating tokens. ADD #44: Implement API method to refresh token. - Reverted to non-unique indexes on `RESTfulAPI_TokenAuthExtension` since there's an issue with MsSQL DBs. - Implemented token refresh methods. - Updated documentation. - Added test for "refreshToken". - Updated token uniqueness test. --- README.md | 1 + .../RESTfulAPI_TokenAuthExtension.php | 18 +- .../RESTfulAPI_TokenAuthenticator.php | 239 ++++++++++++------ doc/TokenAuthenticator.md | 16 ++ .../RESTfulAPI_TokenAuthenticator_Test.php | 114 +++++++++ 5 files changed, 308 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 7910fe1..50cfd2d 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ This module implements a RESTful API for read/write access to your SilverStripe | Delete a record | `DELETE` | `api/Model/ID` | | - | - | - | | Login & get token | n/a | `api/auth/login?email=***&pwd=***` | +| Refresh token | n/a | `api/auth/refreshToken?refreshtoken=***`| | Logout | n/a | `api/auth/logout` | | Password reset email | n/a | `api/auth/lostPassword?email=***` | | - | - | - | diff --git a/code/authenticator/RESTfulAPI_TokenAuthExtension.php b/code/authenticator/RESTfulAPI_TokenAuthExtension.php index 36cc828..6d59d31 100644 --- a/code/authenticator/RESTfulAPI_TokenAuthExtension.php +++ b/code/authenticator/RESTfulAPI_TokenAuthExtension.php @@ -15,15 +15,25 @@ class RESTfulAPI_TokenAuthExtension extends DataExtension { private static $db = array( - 'ApiToken' => 'Varchar(160)', - 'RefreshToken' => 'Varchar(160)', - 'ApiTokenExpire' => 'Int' + 'ApiToken' => 'Varchar(160)', + 'ApiRefreshToken' => 'Varchar(161)', + 'ApiTokenExpire' => 'Int' ); + // Add a index to the API token and refresh token. + // Should increase lookup performance. + // Cannot use unique constraint because MSSQL doesn't allow multiple null values: + // https://github.com/silverstripe/silverstripe-mssql/issues/24 + // TODO: Move to unique indexes, when it's properly supported by all Database bindings for SilverStripe + private static $indexes = array( + 'ApiToken' => true, + 'ApiRefreshToken' => true + ); + function updateCMSFields(FieldList $fields) { $fields->removeByName('ApiToken'); - $fields->removeByName('RefreshToken'); + $fields->removeByName('ApiRefreshToken'); $fields->removeByName('ApiTokenExpire'); } } \ No newline at end of file diff --git a/code/authenticator/RESTfulAPI_TokenAuthenticator.php b/code/authenticator/RESTfulAPI_TokenAuthenticator.php index 23e8c06..684b140 100644 --- a/code/authenticator/RESTfulAPI_TokenAuthenticator.php +++ b/code/authenticator/RESTfulAPI_TokenAuthenticator.php @@ -61,6 +61,13 @@ class RESTfulAPI_TokenAuthenticator implements RESTfulAPI_Authenticator private static $autoRefreshLifetime = false; + /** + * DB Column to store the refresh token in. + * @var string + */ + private static $refreshTokenColumn = 'ApiRefreshToken'; + + /** * Stores current token authentication configurations * header, var, class, db columns.... @@ -70,10 +77,12 @@ class RESTfulAPI_TokenAuthenticator implements RESTfulAPI_Authenticator protected $tokenConfig; - const AUTH_CODE_LOGGED_IN = 0; - const AUTH_CODE_LOGIN_FAIL = 1; - const AUTH_CODE_TOKEN_INVALID = 2; - const AUTH_CODE_TOKEN_EXPIRED = 3; + const AUTH_CODE_LOGGED_IN = 0; + const AUTH_CODE_LOGIN_FAIL = 1; + const AUTH_CODE_TOKEN_INVALID = 2; + const AUTH_CODE_TOKEN_EXPIRED = 3; + const AUTH_CODE_REFRESH_TOKEN_MISSING = 4; + const AUTH_CODE_REFRESH_TOKEN_INVALID = 5; /** @@ -97,11 +106,12 @@ public function __construct() $config = array(); $configInstance = Config::inst(); - $config['life'] = $configInstance->get('RESTfulAPI_TokenAuthenticator', 'tokenLife'); - $config['header'] = $configInstance->get('RESTfulAPI_TokenAuthenticator', 'tokenHeader'); - $config['queryVar'] = $configInstance->get('RESTfulAPI_TokenAuthenticator', 'tokenQueryVar'); - $config['owner'] = $configInstance->get('RESTfulAPI_TokenAuthenticator', 'tokenOwnerClass'); - $config['autoRefresh'] = $configInstance->get('RESTfulAPI_TokenAuthenticator', 'autoRefreshLifetime'); + $config['life'] = $configInstance->get('RESTfulAPI_TokenAuthenticator', 'tokenLife'); + $config['header'] = $configInstance->get('RESTfulAPI_TokenAuthenticator', 'tokenHeader'); + $config['queryVar'] = $configInstance->get('RESTfulAPI_TokenAuthenticator', 'tokenQueryVar'); + $config['owner'] = $configInstance->get('RESTfulAPI_TokenAuthenticator', 'tokenOwnerClass'); + $config['autoRefresh'] = $configInstance->get('RESTfulAPI_TokenAuthenticator', 'autoRefreshLifetime'); + $config['refreshDBColumn'] = $configInstance->get('RESTfulAPI_TokenAuthenticator', 'refreshTokenColumn'); $tokenDBColumns = $configInstance->get('RESTfulAPI_TokenAuthExtension', 'db'); $tokenDBColumn = array_search('Varchar(160)', $tokenDBColumns); @@ -153,37 +163,79 @@ public function login(SS_HTTPRequest $request) )); if ( $member ) { - $tokenData = $this->generateToken(); - - $tokenDBColumn = $this->tokenConfig['DBColumn']; - $expireDBColumn = $this->tokenConfig['expireDBColumn']; - - $member->{$tokenDBColumn} = $tokenData['token']; - $member->{$expireDBColumn} = $tokenData['expire']; - $member->write(); + $tokenData = $this->updateToken($member); $member->login(); } } if ( !$member ) { - $response['result'] = false; - $response['message'] = 'Authentication fail.'; - $response['code'] = self::AUTH_CODE_LOGIN_FAIL; + $response['result'] = false; + $response['message'] = 'Authentication fail.'; + $response['code'] = self::AUTH_CODE_LOGIN_FAIL; } else{ - $response['result'] = true; - $response['message'] = 'Logged in.'; - $response['code'] = self::AUTH_CODE_LOGGED_IN; - $response['token'] = $tokenData['token']; - $response['expire'] = $tokenData['expire']; - $response['userID'] = $member->ID; + $response['result'] = true; + $response['message'] = 'Logged in.'; + $response['code'] = self::AUTH_CODE_LOGGED_IN; + $response['token'] = $tokenData['token']; + $response['expire'] = $tokenData['expire']; + $response['refreshtoken'] = $tokenData['refreshtoken']; + $response['userID'] = $member->ID; } } return $response; } + /** + * Perform a refresh of the API token. + * In order to do so, the user has to supply his current (non-expired) API-token and the refresh-token + * that was returned on login. + * @param SS_HTTPRequest $request + * @return array|RESTfulAPI_Error + */ + public function refreshToken(SS_HTTPRequest $request) + { + // need to be authenticated to refresh the token + $response = $this->authenticate($request); + if($response !== true){ + return $response; + } + + if($refreshToken = $request->requestVar('refreshtoken')){ + $apiToken = $this->getRequestToken($request); + $tokenDBColumn = $this->tokenConfig['DBColumn']; + $refreshDBColumn = $this->tokenConfig['refreshDBColumn']; + $ownerTable = $this->tokenConfig['owner']; + + // find the owner that belongs to the refresh-token + $owner = DataObject::get($ownerTable)->filter(array($refreshDBColumn => $refreshToken))->first(); + + // check if the owner exists and if the API token also matches + if(!$owner || $owner->{$tokenDBColumn} !== $apiToken){ + return new RESTfulAPI_Error(403, + 'Refreshing tokens failed.', + array( + 'message' => 'Refresh token invalid.', + 'code' => self::AUTH_CODE_REFRESH_TOKEN_INVALID + ) + ); + } + + return $this->updateToken($owner); + } + + //no refresh-token, bad news + return new RESTfulAPI_Error(403, + 'Refresh token missing.', + array( + 'message' => 'Refresh token missing.', + 'code' => self::AUTH_CODE_REFRESH_TOKEN_MISSING + ) + ); + } + /** * Logout a user from framework @@ -204,16 +256,7 @@ public function logout(SS_HTTPRequest $request) if ( $this->tokenConfig['owner'] === 'Member' ) { - //generate expired token - $tokenData = $this->generateToken( true ); - - //write - $tokenDBColumn = $this->tokenConfig['DBColumn']; - $expireDBColumn = $this->tokenConfig['expireDBColumn']; - - $member->{$tokenDBColumn} = $tokenData['token']; - $member->{$expireDBColumn} = $tokenData['expire']; - $member->write(); + $this->updateToken($member, true); } } } @@ -266,7 +309,7 @@ public function getToken($id) $tokenDBColumn = $this->tokenConfig['DBColumn']; return $owner->{$tokenDBColumn}; } - else{ + else { user_error("API Token owner '$ownerClass' not found with ID = $id", E_USER_WARNING); } } @@ -292,16 +335,7 @@ public function resetToken($id, $expired = false) if ( $owner ) { - //generate token - $tokenData = $this->generateToken( $expired ); - - //write - $tokenDBColumn = $this->tokenConfig['DBColumn']; - $expireDBColumn = $this->tokenConfig['expireDBColumn']; - - $owner->{$tokenDBColumn} = $tokenData['token']; - $owner->{$expireDBColumn} = $tokenData['expire']; - $owner->write(); + $this->updateToken($owner, $expired); } else{ user_error("API Token owner '$ownerClass' not found with ID = $id", E_USER_WARNING); @@ -312,26 +346,80 @@ public function resetToken($id, $expired = false) } } - /** - * Generates an encrypted random token - * and an expiry date - * - * @param boolean $expired Set to true to generate an outdated token - * @return array token data array('token' => HASH, 'expire' => EXPIRY_DATE) + * Update the token of a token owner by recreating the token and refresh-token values + * @param $owner The token owner instance to update + * @param bool $expired Set to true to generate an outdated token + * @return array|null Token data array('token' => HASH, 'refreshtoken' => REFRESH_TOKEN, 'expire' => EXPIRY_DATE) */ - private function generateToken($expired = false) + private function updateToken(DataObject $owner, $expired = false) { - $life = $this->tokenConfig['life']; + // DB field names + $tokenDBColumn = $this->tokenConfig['DBColumn']; + $expireDBColumn = $this->tokenConfig['expireDBColumn']; + $refreshDBColumn = $this->tokenConfig['refreshDBColumn']; + + // token lifetime + $life = $this->tokenConfig['life']; + $expire = 0; + // create the API access-token + $token = $this->createUniqueToken($owner, $tokenDBColumn); + + $refreshToken = null; if ( !$expired ) { $expire = time() + $life; + // create a refresh-token + $refreshToken = $this->createUniqueToken($owner, $refreshDBColumn); } else{ $expire = time() - ($life * 2); } - + + $owner->{$expireDBColumn} = $expire; + $owner->{$tokenDBColumn} = $token; + if($refreshToken){ + $owner->{$refreshDBColumn} = $refreshToken; + } + $owner->write(); + + return array( + 'expire' => $expire, + 'refreshtoken' => $refreshToken, + 'token' => $token + ); + } + + /** + * Create a unique token for the given owner on the given column + * @param DataObject $owner the data-owner + * @param string $DBcolumn string the DB column that contains the token + * @return string + */ + protected function createUniqueToken($owner, $DBcolumn) + { + // get all the existing tokens from the DB, so we don't recreate the same token again + $existingTokens = $owner->get()->column($DBcolumn); + + // don't perform more than 100 attempts.. if that happens something is severely flawed and we should error out + for($i = 0; $i < 100; $i++){ + $token = $this->generateToken(); + if(!in_array($token, $existingTokens, true)){ + return $token; + } + } + + user_error('Unable to create unique token', E_USER_ERROR); + } + + /** + * Generates an encrypted random token + * + * @return string the token string + */ + protected function generateToken() + { $generator = new RandomGenerator(); $tokenString = $generator->randomToken(); @@ -339,10 +427,7 @@ private function generateToken($expired = false) $salt = $e->salt($tokenString); $token = $e->encrypt($tokenString, $salt); - return array( - 'token' => substr($token, 7), - 'expire' => $expire - ); + return substr($token, 7); } @@ -357,14 +442,7 @@ public function getOwner(SS_HTTPRequest $request) { $owner = null; - //get the token - $token = $request->getHeader( $this->tokenConfig['header'] ); - if (!$token) - { - $token = $request->requestVar( $this->tokenConfig['queryVar'] ); - } - - if ( $token ) + if ( $token = $this->getRequestToken($request) ) { $SQL_token = Convert::raw2sql($token); @@ -393,14 +471,7 @@ public function getOwner(SS_HTTPRequest $request) */ public function authenticate(SS_HTTPRequest $request) { - //get the token - $token = $request->getHeader( $this->tokenConfig['header'] ); - if (!$token) - { - $token = $request->requestVar( $this->tokenConfig['queryVar'] ); - } - - if ( $token ) + if ( $token = $this->getRequestToken($request)) { //check token validity return $this->validateAPIToken( $token ); @@ -416,6 +487,22 @@ public function authenticate(SS_HTTPRequest $request) ); } } + + /** + * Get the token that was sent with the given request + * @param SS_HTTPRequest $request + * @return string|null the token parameter from the request + */ + protected function getRequestToken(SS_HTTPRequest $request) + { + //get the token + $token = $request->getHeader( $this->tokenConfig['header'] ); + if (!$token) + { + $token = $request->requestVar( $this->tokenConfig['queryVar'] ); + } + return $token; + } /** diff --git a/doc/TokenAuthenticator.md b/doc/TokenAuthenticator.md index de6f03b..a31fe86 100644 --- a/doc/TokenAuthenticator.md +++ b/doc/TokenAuthenticator.md @@ -15,15 +15,31 @@ Config | Type | Info | Default `tokenQueryVar` | `string` | Fallback GET/POST HTTP query var storing the token | 'token' `tokenOwnerClass` | `string` | DataObject class name for the token's owner | 'Member' `autoRefreshLifetime` | `boolean` | Whether or not token lifetime should be updated with every request | false +`refreshTokenColumn` | `string` | Column name that contains the refresh-token. Only modify this if you use different column names for the `RESTfulAPI_TokenAuthExtension` (eg. by using a custom extension) | 'ApiRefreshToken' +## Making use of the refresh-token + +A successful login request will also return a `refreshtoken`. This is a token that can be used to refresh the user-token as long as the token has not expired. After a successful refresh, both API-token and refresh-token will be reset and the API-token will get a new lifetime. + +Typically a client-application will perform login by supplying user-credentials. The client-application then has to perform a token refresh before the access-token is about to expire. + +To perform a token-refresh, use the API endpoint: + + api/auth/refreshToken?refreshtoken=; + +Or pass the parameter in a JSON body. **NOTE:** This is a request that requires authentication, so you have to pass the existing (non-expired) API-token with the request as well. + +When making use of the refresh-token, it's advised to set token lifetime (`tokenLife`) to a lower value than the default (which is 3 hours). A sensible value would be `900` or `1800`. Also do not enable `autoRefreshLifetime` as this would defeat the purpose of the refresh-token. ## Token Authentication Data Extension `RESTfulAPI_TokenAuthExtension` This extension **MUST** be applied to a `DataObject` to use `RESTfulAPI_TokenAuthenticator` and update the `tokenOwnerClass` config accordingly. e.g. + ```yaml Member: extensions: - RESTfulAPI_TokenAuthExtension ``` + ```yaml ApiUser: extensions: diff --git a/tests/authenticator/RESTfulAPI_TokenAuthenticator_Test.php b/tests/authenticator/RESTfulAPI_TokenAuthenticator_Test.php index 984b949..cbed763 100644 --- a/tests/authenticator/RESTfulAPI_TokenAuthenticator_Test.php +++ b/tests/authenticator/RESTfulAPI_TokenAuthenticator_Test.php @@ -26,6 +26,16 @@ protected function getAuthenticator() return $auth; } + protected function getFlawedAuthenticator() + { + $injector = new Injector(); + $auth = new FlawedAuthenticator(); + + $injector->inject($auth); + + return $auth; + } + public function setUpOnce() { @@ -209,4 +219,108 @@ public function testAuthenticate() "TokenAuth authentication failure should return a RESTfulAPI_Error" ); } + + public function testTokenRefresh() + { + $auth = $this->getAuthenticator(); + $request = new SS_HTTPRequest( + 'GET', + 'api/auth/login', + array(), + array('email' => 'test@test.com', 'pwd' => 'test') + ); + + $result = $auth->login($request); + $this->assertContains( + 'refreshtoken', + array_keys($result), + 'Login response should return a refresh token' + ); + + $token = $result['token']; + $refreshtoken = $result['refreshtoken']; + $request = new SS_HTTPRequest( + 'GET', + 'api/auth/refreshToken', + array(), + array('refreshtoken' => $refreshtoken) + ); + $request->addHeader('X-Silverstripe-Apitoken', $token); + + $result2 = $auth->refreshToken($request); + $resultKeys = array_keys($result2); + // test if the result contains values for 'refreshtoken', 'token', 'expire' + foreach(array('refreshtoken', 'token', 'expire') as $key){ + $this->assertContains( + $key, + $resultKeys, + 'Refreshing the token should return an array that contains ' . $key + ); + } + + $newToken = $result2['token']; + $newRefreshToken = $result2['refreshtoken']; + $this->assertThat( + $token, + $this->logicalNot( + $this->equalTo($newToken) + ), + "TokenAuth refreshToken should generate a new token" + ); + + $this->assertThat( + $refreshtoken, + $this->logicalNot( + $this->equalTo($newRefreshToken) + ), + "TokenAuth refreshToken should generate a new refresh token" + ); + } + + /** + * Test edge case of a member with the same API token + */ + public function testTokenUniqueness() + { + $member = Member::get()->filter(array( + 'Email' => 'test@test.com' + ))->first(); + + // get an authenticator that always creates the same token + $auth = $this->getFlawedAuthenticator(); + $auth->resetToken($member->ID); + + $newMemberID = Member::create(array( + 'Email' => 'TestMember', + 'Password' => 'test' + ))->write(); + + $hasErrors = false; + try { + $auth->resetToken($newMemberID); + } catch(Exception $e){ + $hasErrors = true; + } + + $this->assertTrue( + $hasErrors, + 'Creating the same token twice should raise an error' + ); + } + + + public function test() + { + } +} + +/** + * Flawed authenticator to test the case when non-unique tokens are generated + */ +class FlawedAuthenticator extends RESTfulAPI_TokenAuthenticator +{ + protected function generateToken() + { + return 'I always return the same token!'; + } } \ No newline at end of file