diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..47ae637 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# For more information about the properties used in this file, +# please see the EditorConfig documentation: +# http://editorconfig.org + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[{*.yml,package.json}] +indent_size = 2 + +# The indent size used in the package.json file cannot be changed: +# https://github.com/npm/npm/pull/3180#issuecomment-16336516 diff --git a/.upgrade.yml b/.upgrade.yml new file mode 100644 index 0000000..e8ab575 --- /dev/null +++ b/.upgrade.yml @@ -0,0 +1,5 @@ +mappings: + ApiKeyMemberExtension: Sminnee\ApiKey\ApiKeyMemberExtension + ApiKeyRequestFilter: Sminnee\ApiKey\ApiKeyRequestFilter + GridFieldAddApiKeyButton: Sminnee\ApiKey\GridFieldAddApiKeyButton + MemberApiKey: Sminnee\ApiKey\MemberApiKey diff --git a/README.md b/README.md index b9d630b..2f9dc06 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,30 @@ -SilverStripe API Key -==================== +# SilverStripe API Key This module provides a way of creating an managing API keys within SilverStripe. This can be useful for building RESTful and other APIs. -How it works ------------- +## Requirements + + * SilverStripe ^4.0 + * PHP 5.5+ + +## How it works * Extensions the the `SecurityAdmin` provide interfaces for seeing API keys, and generating new ones. API keys are allocated member-by-member. * A `RequestFilter` will look for an API key header (default: `X-API-Key`) and if it is present, authenticate the - user so that Member::currentUser() will return the corresponding member. + user so that Member::currentUser() will return the corresponding member. This should be configured by non-GraphQL + requests. + * A `ApiKeyAuthenticator` should be configured for [GraphQL](https://github.com/silverstripe/silverstripe-graphql) + request and will return the authenticated member for GraphQL contexts to use, while not applying it to the CMS + session. -Limitations ------------ +## Limitations * You can't limit the rights that the API key has to be more granular than "all rights of the given user". * Keys can't be disabled, only deleted * No support for storing encrypted ("read-once") keys -Status ------- +## Status This should be considered experimental for now, and used with care. It has not received a security audit. diff --git a/_config/apikey.yml b/_config/apikey.yml index efd78f1..da67729 100644 --- a/_config/apikey.yml +++ b/_config/apikey.yml @@ -1,15 +1,22 @@ --- Name: apikey --- -Member: +SilverStripe\Security\Member: extensions: - - ApiKeyMemberExtension + - Sminnee\ApiKey\ApiKeyMemberExtension -Injector: - RequestProcessor: - properties: - filters: - - '%$ApiKeyRequestFilter' +Sminnee\ApiKeyRequestFilter: + header_name: 'X-Api-Key' -ApiKeyRequestFilter: - header_name: 'X-Api-Key' \ No newline at end of file +# For regular requests, enable the RequestFilter: +# SilverStripe\Core\Injector\Injector: +# SilverStripe\Control\RequestProcessor: +# properties: +# filters: +# - '%$ApiKeyRequestFilter' + +# For GraphQL requests, enable the ApiKeyAuthenticator: +SilverStripe\GraphQL\Auth\Handler: + authenticators: + - class: Sminnee\ApiKey\ApiKeyAuthenticator + priority: 30 diff --git a/code/ApiKeyMemberExtension.php b/code/ApiKeyMemberExtension.php deleted file mode 100644 index 71f977a..0000000 --- a/code/ApiKeyMemberExtension.php +++ /dev/null @@ -1,29 +0,0 @@ - 'MemberApiKey', - ]; - - public function updateCMSFields(FieldList $fields) { - $grid = $fields->dataFieldByName('ApiKeys'); - if(!$grid) return; - - $gridConfig = $grid->getConfig(); - - // Simplify view - $gridConfig->removeComponentsByType('GridFieldAddExistingAutocompleter'); - $gridConfig->removeComponentsByType('GridFieldDetailForm'); - $gridConfig->removeComponentsByType('GridFieldEditButton'); - - // Better add key button - $gridConfig->removeComponentsByType('GridFieldAddNewButton'); - $gridConfig->addComponent(new GridFieldAddApiKeyButton('buttons-before-left')); - - // Replace unlink with a real delete - $gridConfig->removeComponentsByType('GridFieldDeleteAction'); - $gridConfig->addComponent(new GridFieldDeleteAction()); - } -} diff --git a/code/ApiKeyRequestFilter.php b/code/ApiKeyRequestFilter.php deleted file mode 100644 index de03d93..0000000 --- a/code/ApiKeyRequestFilter.php +++ /dev/null @@ -1,46 +0,0 @@ -get('ApiKeyRequestFilter', 'header_name'); - - if($key = $request->getHeader($headerName)) { - try { - $matchingKey = MemberApiKey::findByKey($key); - } catch(LogicException $e) { - } - - if($matchingKey) { - // Log-in can't have session injected, we need to to push $session into the global state - $controller = new Controller; - $controller->setSession($session); - $controller->pushCurrent(); - - $matchingKey->Member()->logIn(); - - // Undo our global state manipulation - $controller->popCurrent(); - - $matchingKey->markUsed(); - - } else { - throw new SS_HTTPResponse_Exception("Bad X-API-Key", 400); - } - } - - return true; - } - - public function postRequest(SS_HTTPRequest $request, SS_HTTPResponse $response, DataModel $model) { - return true; - } - -} diff --git a/code/GridFieldAddApiKeyButton.php b/code/GridFieldAddApiKeyButton.php deleted file mode 100644 index c2e9c45..0000000 --- a/code/GridFieldAddApiKeyButton.php +++ /dev/null @@ -1,52 +0,0 @@ -targetFragment = $targetFragment; - } - - /** - * Place the export button in a

tag below the field - */ - public function getHTMLFragments($gridField) { - $button = new GridField_FormAction( - $gridField, - 'addapikey', - _t('GridFieldAddApiKeyButton.CREATE_API_KEY', 'Create API Key'), - 'addapikey', - null - ); - $button->setAttribute('data-icon', 'add'); - - return array( - $this->targetFragment => $button->Field(), - ); - } - - public function getActions($gridField) { - return array('addApiKey'); - } - - public function handleAction(GridField $gridField, $actionName, $arguments, $data) { - if($actionName == 'addapikey') { - MemberApiKey::createKey($gridField->getForm()->getRecord()->ID); - } - } -} diff --git a/code/MemberApiKey.php b/code/MemberApiKey.php deleted file mode 100644 index 54ad520..0000000 --- a/code/MemberApiKey.php +++ /dev/null @@ -1,97 +0,0 @@ - 'Varchar', - 'LastUsed' => 'Datetime', - 'TimesUsed' => 'Int', - ]; - - private static $indexes = [ - 'ApiKeyIdx' => ['type' => 'unique', 'value' => '"ApiKey"' ], - ]; - - private static $has_one = [ - 'Member' => 'Member', - ]; - - private static $summary_fields = [ - 'ApiKey', - 'LastUsed', - 'TimesUsed', - ]; - - /** - * Defines the length of randomly-generated keys - */ - private static $key_length = 48; - - /** - * MemberApiKey factory. Writes to the database. - * @param int $memberID The member to create a key for - * @return MemberApiKey - */ - public static function createKey($memberID) { - // Basic argument validation - if(!$memberID || !is_numeric($memberID)) { - throw new InvalidArgumentException('Please pass a numeric $memberID'); - } - - // Find a unique key - $key = self::randKey(); - while(MemberApiKey::get()->filter(['ApiKey' => $key])->count() > 0) { - $key = self::randKey(); - } - - // Construct record - $obj = new MemberApiKey; - $obj->MemberID = $memberID; - $obj->ApiKey = $key; - $obj->write(); - - return $obj; - } - - /** - * Find the relevant MemberApiKey object for the given key - */ - public static function findByKey($key) { - $matches = MemberApiKey::get()->filter(['ApiKey' => $key]); - switch($matches->count()) { - case 1: - return $matches->first(); - - case 0: - return null; - - default: - throw new LogicException("Multiple MemberApiKey records for '$key' - database corrupt!"); - } - } - - /** - * Mark the given key as used. - * Keeps usage stats up-to-date - */ - public function markUsed() { - $this->LastUsed = date('Y-m-d H:i:s'); - $this->TimesUsed++; - $this->write(); - } - - /** - * Helper function to generate a random key - */ - protected static function randKey() { - $key = ''; - $src = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; - $keyLength = Config::inst()->get('MemberApiKey', 'key_length'); - - for($i = 0; $i < $keyLength; $i++) { - $key .= $src[rand(0, strlen($src)-1)]; - } - - return $key; - } -} \ No newline at end of file diff --git a/composer.json b/composer.json index 5349c03..70e9587 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "API Key management for SilverStripe", "type": "silverstripe-module", "require": { - "silverstripe/framework": "^3.1" + "silverstripe/framework": "^4.0@dev" }, "license": "BSD-3-Clause", "authors": [ @@ -11,5 +11,17 @@ "name": "Sam Minnee", "email": "sam@silverstripe.com" } - ] + ], + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Sminnee\\ApiKey\\": "src/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true } diff --git a/src/ApiKeyMemberExtension.php b/src/ApiKeyMemberExtension.php new file mode 100644 index 0000000..95cd8d9 --- /dev/null +++ b/src/ApiKeyMemberExtension.php @@ -0,0 +1,42 @@ + MemberApiKey::class, + ]; + + public function updateCMSFields(FieldList $fields) + { + $grid = $fields->dataFieldByName('ApiKeys'); + if (!$grid) { + return; + } + + $gridConfig = $grid->getConfig(); + + // Simplify view + $gridConfig->removeComponentsByType(GridFieldAddExistingAutocompleter::class); + $gridConfig->removeComponentsByType(GridFieldDetailForm::class); + $gridConfig->removeComponentsByType(GridFieldEditButton::class); + + // Better add key button + $gridConfig->removeComponentsByType(GridFieldAddNewButton::class); + $gridConfig->addComponent(new GridFieldAddApiKeyButton('buttons-before-left')); + + // Replace unlink with a real delete + $gridConfig->removeComponentsByType(GridFieldDeleteAction::class); + $gridConfig->addComponent(new GridFieldDeleteAction()); + } +} diff --git a/src/ApiKeyRequestFilter.php b/src/ApiKeyRequestFilter.php new file mode 100644 index 0000000..2f6ab24 --- /dev/null +++ b/src/ApiKeyRequestFilter.php @@ -0,0 +1,57 @@ +get(self::class, 'header_name'); + + if ($key = $request->getHeader($headerName)) { + try { + $matchingKey = MemberApiKey::findByKey($key); + } catch (LogicException $e) { + } + + if ($matchingKey) { + // Log-in can't have session injected, we need to to push $session into the global state + $controller = new Controller; + $controller->setSession($session); + $controller->pushCurrent(); + + $matchingKey->Member()->logIn(); + + // Undo our global state manipulation + $controller->popCurrent(); + + $matchingKey->markUsed(); + } else { + throw new HTTPResponse_Exception("Bad X-API-Key", 400); + } + } + + return true; + } + + public function postRequest(HTTPRequest $request, HTTPResponse $response, DataModel $model) + { + return true; + } +} diff --git a/src/GridFieldAddApiKeyButton.php b/src/GridFieldAddApiKeyButton.php new file mode 100644 index 0000000..0d6f46e --- /dev/null +++ b/src/GridFieldAddApiKeyButton.php @@ -0,0 +1,64 @@ +targetFragment = $targetFragment; + } + + /** + * Place the export button in a

tag below the field + */ + public function getHTMLFragments($gridField) + { + $button = new GridField_FormAction( + $gridField, + 'addapikey', + _t('GridFieldAddApiKeyButton.CREATE_API_KEY', 'Create API Key'), + 'addapikey', + null + ); + $button->setAttribute('data-icon', 'add'); + + return array( + $this->targetFragment => $button->Field(), + ); + } + + public function getActions($gridField) + { + return array('addApiKey'); + } + + public function handleAction(GridField $gridField, $actionName, $arguments, $data) + { + if ($actionName == 'addapikey') { + MemberApiKey::createKey($gridField->getForm()->getRecord()->ID); + } + } +} diff --git a/src/MemberApiKey.php b/src/MemberApiKey.php new file mode 100644 index 0000000..a788477 --- /dev/null +++ b/src/MemberApiKey.php @@ -0,0 +1,109 @@ + 'Varchar', + 'LastUsed' => 'DBDatetime', + 'TimesUsed' => 'Int', + ]; + + private static $indexes = [ + 'ApiKeyIdx' => ['type' => 'unique', 'value' => '"ApiKey"' ], + ]; + + private static $has_one = [ + 'Member' => Member::class + ]; + + private static $summary_fields = [ + 'ApiKey', + 'LastUsed', + 'TimesUsed', + ]; + + /** + * Defines the length of randomly-generated keys + */ + private static $key_length = 48; + + /** + * MemberApiKey factory. Writes to the database. + * @param int $memberID The member to create a key for + * @return MemberApiKey + */ + public static function createKey($memberID) + { + // Basic argument validation + if (!$memberID || !is_numeric($memberID)) { + throw new InvalidArgumentException('Please pass a numeric $memberID'); + } + + // Find a unique key + $key = self::randKey(); + while (MemberApiKey::get()->filter(['ApiKey' => $key])->count() > 0) { + $key = self::randKey(); + } + + // Construct record + $obj = new MemberApiKey; + $obj->MemberID = $memberID; + $obj->ApiKey = $key; + $obj->write(); + + return $obj; + } + + /** + * Find the relevant MemberApiKey object for the given key + */ + public static function findByKey($key) + { + $matches = MemberApiKey::get()->filter(['ApiKey' => $key]); + switch ($matches->count()) { + case 1: + return $matches->first(); + + case 0: + return null; + + default: + throw new LogicException("Multiple MemberApiKey records for '$key' - database corrupt!"); + } + } + + /** + * Mark the given key as used. + * Keeps usage stats up-to-date + */ + public function markUsed() + { + $this->LastUsed = date('Y-m-d H:i:s'); + $this->TimesUsed++; + $this->write(); + } + + /** + * Helper function to generate a random key + */ + protected static function randKey() + { + $key = ''; + $src = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $keyLength = Config::inst()->get(self::class, 'key_length'); + + for ($i = 0; $i < $keyLength; $i++) { + $key .= $src[rand(0, strlen($src)-1)]; + } + + return $key; + } +}