├── .gitattributes ├── CHANGELOG ├── LICENSE ├── README.md ├── VERSION ├── _config.php ├── _config └── webservices.yml ├── code ├── auth │ ├── WebserviceAuthenticator.php │ └── WebserviceMethodHmacValidator.php ├── controllers │ └── WebServiceController.php ├── extensions │ └── TokenAccessible.php ├── serialisers │ ├── ArrayJsonConverter.php │ ├── ArrayXmlConverter.php │ ├── DataObjectJsonConverter.php │ ├── DataObjectSetJsonConverter.php │ ├── DataObjectSetXmlConverter.php │ └── DataObjectXmlConverter.php └── services │ ├── CalendarWebService.php │ ├── DummyWebService.php │ ├── TokenAuthenticator.php │ └── WebServiceable.php ├── composer.json └── javascript └── webservices.js /.gitattributes: -------------------------------------------------------------------------------- 1 | /tests export-ignore 2 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 2 | 2013-12-10 v3.1.1 3 | ----------------- 4 | 5 | * Swapped to 3.1 config structures 6 | 7 | 2013-12-06 v3.1.0 8 | ----------------- 9 | 10 | * Added experimental XML output formatters 11 | 12 | 2013-10-01 v3.0.3 13 | ----------------- 14 | 15 | * Fallback to using request's httpMethod() for method detection 16 | 17 | 2013-09-25 v3.0.2 18 | ----------------- 19 | 20 | * Auth token no longer stored in CMS - users must take token from the CMS 21 | soon after it's re-generated for use in client application 22 | 23 | 3013-09-18 v3.0.1 24 | ----------------- 25 | 26 | * Change the authentication logic to only check security ID for authentication 27 | iff the user is already logged in. 28 | 29 | 2013-09-16 v3.0.0 30 | ----------------- 31 | 32 | * 3.1 private static updates 33 | 34 | 2013-09-04 v2.1.2 35 | ----------------- 36 | 37 | * Account for additional content-type information when parsing out arguments in 38 | the request 39 | 40 | 2013-05-28 v2.1.1 41 | ----------------- 42 | 43 | * Refactored authentication into a separate class structure for the moment. 44 | * Updated documentation around file uploads 45 | 46 | 2013-02-20 v2.1.0 47 | ----------------- 48 | 49 | * Changes to support accept a post body as a file being uploaded 50 | * Accept key/value pairs in the URL as param/value arguments 51 | 52 | 2013-01-15 v2.0.2 53 | ----------------- 54 | 55 | * Make sure to check $_POST before assuming a $_GET request 56 | 57 | 2012-09-26 v2.0.1 58 | ----------------- 59 | 60 | * Auth token can now be provided via X-Auth-Token instead of a URL param 61 | * POST methods can be called by passing in a JSON encoded POST body instead 62 | of passing urlencoded params 63 | 64 | 65 | 2012-09-21 v2.0.0 66 | ----------------- 67 | 68 | * Minor SS3 updates 69 | * Added ability to specify a SilverStripe permission to be required before being able to access a service 70 | 71 | 2012-05-27 v1.1.3 72 | ----------------- 73 | 74 | * Added example calendar web service that demonstrates how to create an event 75 | via a web service 76 | 77 | 2012-05-01 v1.1.2 78 | ----------------- 79 | 80 | * Use permission denied exceptions from the restricted objects module to 81 | throw back 403 status codes instead of 500 82 | 83 | 2012-04-30 v1.1.1 84 | ----------------- 85 | 86 | * Fix a couple of problems with the way the session object isn't properly 87 | attaching in handleRequest 88 | 89 | 2012-03-10 v1.1.0 90 | ----------------- 91 | 92 | * Allowed non-logged in access to methods if explicitly declared public methods 93 | 94 | 2011-08-16 v1.0.0 95 | ----------------- 96 | 97 | * First release 98 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009, SilverStripe Australia PTY LTD - www.silverstripe.com.au 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the 8 | documentation and/or other materials provided with the distribution. 9 | * Neither the name of SilverStripe nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Web Services module 2 | 3 | A module for exposing defined business logic via URLs, in web consumable 4 | formats, and for providing access to consumers of these webservices via 5 | token access. 6 | 7 | These are NOT strictly speaking REST services, in that a single URL isn't 8 | meant to be accessible via GET and POST; it is RPC over HTTP. 9 | 10 | ## Maintainer Contacts 11 | 12 | * Marcus Nyeholt 13 | 14 | ## Versions 15 | 16 | The master branch of this module is currently aiming for SilverStripe 3.1 compatibility 17 | 18 | * [SilverStripe 3.0 compatible version](https://github.com/nyeholt/silverstripe-webservices/tree/2.0) 19 | * [SilverStripe 2.4 compatible version](https://github.com/nyeholt/silverstripe-webservices/tree/ss24) 20 | 21 | ## Requirements 22 | 23 | * SilverStripe 2.4+ 24 | 25 | ## Installation 26 | 27 | * Place this directory in the root of your SilverStripe installation. 28 | * Run dev/build 29 | * Creates your service class, which will be accessible by both controller 30 | code and directly via the web 31 | * Access your service via the appropriate URL, eg 32 | 33 | http://yourUrl/jsonservice/DummyWeb/myMethod?param=stuff&SecurityID=security_id_here 34 | 35 | 36 | * Use the response 37 | 38 | ## Documentation 39 | 40 | The webservices module will automatically translate a url based request to a 41 | system service from URL + parameters to a method and variables, then 42 | automatically convert the response into a web accessible format (JSON and XML 43 | supported, though XML is not yet complete). Part of its goal is to encourage 44 | service oriented architectures in SilverStripe projects so that business logic 45 | is encapsulated in a layer away from controllers (an all too common problem). 46 | 47 | URLs are decomposed as follows 48 | 49 | (type)/(service)/(method)?(paramName)=(value)&token=(token)[&SecurityID=(securityID)] 50 | 51 | * type: either jsonservice or webservice, indicates the desired output format 52 | * service: the name of a service class (see below). It expects the service to 53 | end in the string 'Service' - in fact, that will be automatically appended to 54 | the name passed through here. 55 | * method: the name of the method to call on the service 56 | * paramName/value: key/value pairs representing parameters. The paramName MUST 57 | match the name of the parameter in the method declaration. 58 | * See the note below for information on how to pass DataObjects 59 | * token | SecurityID: Each request must be verified; this is either through 60 | the use of a token parameter (which matches up with a user's API token) OR 61 | by passing the security ID for a user who has already logged in. The token 62 | can be found by looking on the user's details tab in the CMS 63 | 64 | Included is an example service for creating events in a calendar using the 65 | event_calendar module. You can create events for this using the following 66 | curl statement 67 | 68 | curl -X POST -d "token={your token here}&parentCalendar={ID of calendar}&title=My new event&content=This is an event on a day here and now&startDate=2012-04-21" http://{your site url}/jsonservice/calendarweb/createEvent 69 | 70 | 71 | ### Passing DataObjects 72 | 73 | In some cases you'll be wanting to pass data objects to your service method. 74 | To allow that from URL parameters, you can instead pass an ID and Type parameter 75 | which will be converted for use appropriately. For example, this method signature 76 | 77 | public function myMethod(DataObject $page) { 78 | 79 | } 80 | 81 | can be accessed via 82 | 83 | jsonservice/Service/myMethod?pageID=1&pageType=Page 84 | 85 | The module will take care of converting that to the relevant object type 86 | 87 | ### Allowing access to services and their methods 88 | 89 | To be able to call methods on a service, the service has to be marked as being 90 | accessible - this is done in one of two ways 91 | 92 | * Implement the `WebServiceable` interface 93 | * Implement the `webEnabledMethods` method, which returns an map of method names 94 | and the request type that they support (GET or POST only supported currently). 95 | Note that to perform user level access control, you should perform the checks 96 | in the method directly, OR wrap user checking logic around the array this 97 | method generates. 98 | 99 | So, by default, it's not possible to arbitrarily call any service. Also, the 100 | `webEnabledMethods` call does NOT use SilverStripe's hasMethod construct (as 101 | it is not expected that a service carry the overhead of inheriting from 102 | 'Object') so at the moment this is not overrideable using extensions. There will 103 | be ways to update this via extensions at a later date. 104 | 105 | ### Passing parameters via URL 106 | 107 | The webservice controller supports passing parameter/value pairs via the URL. 108 | For example, the following request will have the parameters extracted 109 | 110 | /jsonservice/my/method/name/something/id/2 111 | 112 | Will call My::method(), passing parameters of 113 | 114 | * name = something 115 | * id = 2 116 | 117 | ### File uploads 118 | 119 | Files can be handled as either multipart uploads or by posting the raw file to 120 | the webservice, typically in conjuction with the URL parameter referencing above. 121 | 122 | To have the file accessible to your service method, you MUST have a method 123 | parameter named "file" and a request type of POST; the raw post body of the 124 | request will be placed in the "file" variable, and any parameters found on the 125 | URL also passed through. 126 | 127 | eg in your service 128 | 129 | public function method($file, $name) { 130 | 131 | } 132 | 133 | sending a post such as 134 | 135 | curl -X POST -d @somefile.jpg http://site/jsonservice/my/method/name/myfile.jpg 136 | 137 | will send the body of somefile.jpg in the $file variable, and $name == 'myfile.jpg' 138 | 139 | ### Sending POST body 140 | 141 | Instead of passing parameters via the URL, you can send all method parameters 142 | in a JSON object that is the body of a POST request. For example, the JSON data 143 | 144 | ```JSON 145 | { 146 | "parentCalendar": "2", 147 | "title": "This is a calendar event" 148 | } 149 | ``` 150 | 151 | will pass the two fields as the parameters to whichever method is named by the 152 | URL. The important thing is that you MUST send the `Content-Type: application/json` 153 | header. 154 | 155 | 156 | ### Tokens 157 | 158 | Authentication tokens can be passed either in the URL as the _token_ GET 159 | parameter, or by including the `X-Auth-Token` header in the request. 160 | 161 | 162 | The module will automatically add a 'Token' field to user objects, and 163 | generate a random token when a user account is created. This can be used 164 | to access the API without being logged in first. 165 | 166 | Otherwise, if the user is logged in, they can pass through the security token 167 | as a parameter (appropriately named, by default SecurityID) with the request 168 | which will grant the user access. This can be disabled by setting the 169 | `allowPublicAccess` parameter for the WebserviceAuthenticator 170 | 171 | Injector: 172 | WebserviceAuthenticator: 173 | properties: 174 | allowPublicAccess: 0 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 3.1.1 2 | -------------------------------------------------------------------------------- /_config.php: -------------------------------------------------------------------------------- 1 | 7 | * @license BSD License http://www.silverstripe.org/bsd-license 8 | */ 9 | class WebserviceAuthenticator { 10 | 11 | private static $dependencies = array( 12 | 'tokenAuthenticator' => '%$TokenAuthenticator', 13 | // uncomment this to enable a basic hmac validator 14 | // 'hmacValidator' => '%$WebserviceMethodHmacValidator', 15 | ); 16 | 17 | /** 18 | * Disable all public requests by default; If this is 19 | * set to true, services must still explicitly allow public access 20 | * on those services that can be called by non-auth'd users. 21 | * 22 | * @var boolean 23 | */ 24 | public $allowPublicAccess = false; 25 | 26 | 27 | /** 28 | * Whether allowing access to the API by passing a security ID after 29 | * logging in. 30 | * 31 | * @var boolean 32 | */ 33 | public $allowSecurityId = true; 34 | 35 | /** 36 | * 37 | * @var TokenAuthenticator 38 | */ 39 | public $tokenAuthenticator; 40 | 41 | /** 42 | * Optionally set an hmac validator if you want to require hmac auth on 43 | * the messages. 44 | * 45 | * @var HmacValidator 46 | */ 47 | public $hmacValidator; 48 | 49 | public function authenticate(SS_HTTPRequest $request) { 50 | $token = $this->getToken($request); 51 | 52 | $user = null; 53 | 54 | if ((!Member::currentUserID() && !$this->allowPublicAccess) || $token) { 55 | if (!$token) { 56 | throw new WebServiceException(403, "Missing token parameter"); 57 | } 58 | $user = $this->tokenAuthenticator->authenticate($token); 59 | if (!$user) { 60 | throw new WebServiceException(403, "Invalid user token"); 61 | } 62 | } else if ($this->allowSecurityId && Member::currentUserID()) { 63 | // we check the SecurityID parameter for the current user 64 | $secParam = SecurityToken::inst()->getName(); 65 | $securityID = $request->requestVar($secParam); 66 | if ($securityID && ($securityID != SecurityToken::inst()->getValue())) { 67 | throw new WebServiceException(403, "Invalid security ID"); 68 | } 69 | $user = Member::currentUser(); 70 | } 71 | 72 | if (!$user && !$this->allowPublicAccess) { 73 | throw new WebServiceException(403, "Invalid request"); 74 | } 75 | 76 | // now, if we have an hmacValidator in place, use it 77 | if ($this->hmacValidator && $user) { 78 | if (!$this->hmacValidator->validateHmac($user, $request)) { 79 | throw new WebServiceException(403, "Invalid message"); 80 | } 81 | } 82 | 83 | return true; 84 | } 85 | 86 | protected function getToken(SS_HTTPRequest $request) { 87 | $token = $request->requestVar('token'); 88 | if (!$token) { 89 | $token = $request->getHeader('X-Auth-Token'); 90 | } 91 | 92 | return $token; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /code/auth/WebserviceMethodHmacValidator.php: -------------------------------------------------------------------------------- 1 | 5 | * @license BSD License http://www.silverstripe.org/bsd-license 6 | */ 7 | class WebserviceMethodHmacValidator { 8 | 9 | /** 10 | * Verify whether the given user/request has a valid HMAC header 11 | * 12 | * HMAC should be calculated as a concatenation of 13 | * 14 | * service name 15 | * method called 16 | * gmdate in format YmdH 17 | * 18 | * So an example before hashing would be 19 | * 20 | * product-getPrice-20130225 21 | * 22 | * The key used for signing should come from the user's "AuthPrivateKey" field 23 | * 24 | * The validator will accept an hour either side of 'now' 25 | * 26 | * @param type $user 27 | * @param SS_HTTPRequest $request 28 | * @return boolean 29 | */ 30 | public function validateHmac($user, SS_HTTPRequest $request) { 31 | $service = $request->param('Service'); 32 | $method = $request->param('Method'); 33 | $hmac = $request->getHeader('X-Silverstripe-Hmac'); 34 | 35 | $key = $user->AuthPrivateKey; 36 | 37 | if (!strlen($key)) { 38 | return false; 39 | } 40 | 41 | $times = array( 42 | gmdate('YmdH', strtotime('-1 hour')), 43 | gmdate('YmdH'), 44 | gmdate('YmdH', strtotime('+1 hour')), 45 | ); 46 | 47 | foreach ($times as $time) { 48 | $message = $this->generateHmac(array($service, $method, $time), $key); 49 | if ($message == $hmac) { 50 | return true; 51 | } 52 | } 53 | 54 | return false; 55 | } 56 | 57 | public function generateHmac($args, $key) { 58 | $msg = implode('-', $args); 59 | return hash_hmac('sha1', $msg, $key); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /code/controllers/WebServiceController.php: -------------------------------------------------------------------------------- 1 | json converter classes 14 | * 15 | * @var array 16 | */ 17 | protected $converters = array(); 18 | 19 | protected $format = 'json'; 20 | 21 | private static $dependencies = array( 22 | 'webserviceAuthenticator' => '%$WebserviceAuthenticator', 23 | 'injector' => '%$Injector', 24 | ); 25 | 26 | /** 27 | * @var WebserviceAuthenticator 28 | */ 29 | public $webserviceAuthenticator; 30 | 31 | /** 32 | * @var Injector 33 | */ 34 | public $injector; 35 | 36 | public function init() { 37 | 38 | $this->converters['json'] = array( 39 | 'DataObject' => new DataObjectJsonConverter(), 40 | 'DataObjectSet' => new DataObjectSetJsonConverter(), 41 | 'DataList' => new DataObjectSetJsonConverter(), 42 | 'ArrayList' => new DataObjectSetJsonConverter(), 43 | 'Array' => new ArrayJsonConverter(), 44 | 'ScalarItem' => new ScalarJsonConverter(), 45 | 'stdClass' => new ScalarJsonConverter(), 46 | 'FinalConverter' => new FinalJsonConverter() 47 | ); 48 | 49 | $this->converters['xml'] = array( 50 | 'DataObject' => new DataObjectXmlConverter(), 51 | 'DataObjectSet' => new DataObjectSetXmlConverter(), 52 | 'DataList' => new DataObjectSetXmlConverter(), 53 | 'ArrayList' => new DataObjectSetXmlConverter(), 54 | 'Array' => new ArrayXmlConverter(), 55 | 'ScalarItem' => new ScalarXmlConverter(), 56 | 'stdClass' => new ScalarXmlConverter(), 57 | 'FinalConverter' => new FinalXmlConverter() 58 | ); 59 | 60 | if (strpos($this->request->getURL(), 'xmlservice') === 0) { 61 | $this->format = 'xml'; 62 | } 63 | } 64 | 65 | public function handleRequest(SS_HTTPRequest $request, DataModel $model) { 66 | try { 67 | $this->pushCurrent(); 68 | 69 | $auth = $this->webserviceAuthenticator->authenticate($request); 70 | 71 | if (!$auth) { 72 | throw new WebServiceException(403, 'User not found'); 73 | } 74 | 75 | // borrowed from Controller 76 | $this->urlParams = $request->allParams(); 77 | $this->request = $request; 78 | $this->response = new SS_HTTPResponse(); 79 | $this->setDataModel($model); 80 | 81 | $this->extend('onBeforeInit'); 82 | 83 | $this->init(); 84 | 85 | $this->extend('onAfterInit'); 86 | 87 | if($this->response->isFinished()) { 88 | $this->popCurrent(); 89 | return $this->response; 90 | } 91 | 92 | $response = $this->handleService($request, $model); 93 | 94 | if (self::has_curr()) { 95 | $this->popCurrent(); 96 | } 97 | 98 | if ($response instanceof SS_HTTPResponse) { 99 | $response->addHeader('Content-Type', 'application/'.$this->format); 100 | } 101 | // HTTP::add_cache_headers($this->response); 102 | 103 | return $response; 104 | } catch (WebServiceException $exception) { 105 | $this->response = new SS_HTTPResponse(); 106 | $this->response->setStatusCode($exception->status); 107 | $this->response->setBody($this->ajaxResponse($exception->getMessage(), $exception->status)); 108 | } catch (SS_HTTPResponse_Exception $e) { 109 | $this->response = $e->getResponse(); 110 | $this->response->setBody($this->ajaxResponse($e->getMessage(), $e->getCode())); 111 | } catch (Exception $exception) { 112 | $code = 500; 113 | // check type explicitly in case the Restricted Objects module isn't installed 114 | if (class_exists('PermissionDeniedException') && $exception instanceof PermissionDeniedException) { 115 | $code = 403; 116 | } 117 | 118 | $this->response = new SS_HTTPResponse(); 119 | $this->response->setStatusCode($code); 120 | $this->response->setBody($this->ajaxResponse($exception->getMessage(), $code)); 121 | } 122 | 123 | return $this->response; 124 | } 125 | 126 | /** 127 | * Calls to webservices are routed through here and converted 128 | * from url params to method calls. 129 | * 130 | * @return mixed 131 | */ 132 | public function handleService() { 133 | $service = ucfirst($this->request->param('Service')) . 'Service'; 134 | $method = $this->request->param('Method'); 135 | 136 | $body = $this->request->getBody(); 137 | $requestType = strlen($body) > 0 ? 'POST' : $this->request->httpMethod(); // (count($this->request->postVars()) > 0 ? 'POST' : 'GET'); 138 | 139 | $svc = $this->injector->get($service); 140 | 141 | $response = ''; 142 | 143 | if ($svc && ($svc instanceof WebServiceable || method_exists($svc, 'webEnabledMethods'))) { 144 | $allowedMethods = array(); 145 | if (method_exists($svc, 'webEnabledMethods')) { 146 | $allowedMethods = $svc->webEnabledMethods(); 147 | } 148 | 149 | // if we have a list of methods, lets use those to restrict 150 | if (count($allowedMethods)) { 151 | $this->checkMethods($method, $allowedMethods, $requestType); 152 | } else { 153 | // we only allow 'read only' requests so we wrap everything 154 | // in a readonly transaction so that any database request 155 | // disallows write() calls 156 | // @TODO 157 | } 158 | 159 | if (!Member::currentUserID()) { 160 | // require service to explicitly state that the method is allowed 161 | if (method_exists($svc, 'publicWebMethods')) { 162 | $publicMethods = $svc->publicWebMethods(); 163 | if (!isset($publicMethods[$method])) { 164 | throw new WebServiceException(403, "Public method $method not allowed"); 165 | } 166 | } else { 167 | throw new WebServiceException(403, "Method $method not allowed; no public methods defined"); 168 | } 169 | } 170 | 171 | $refObj = new ReflectionObject($svc); 172 | $refMeth = $refObj->getMethod($method); 173 | /* @var $refMeth ReflectionMethod */ 174 | if ($refMeth) { 175 | 176 | $allArgs = $this->getRequestArgs($requestType); 177 | 178 | $refParams = $refMeth->getParameters(); 179 | $params = array(); 180 | 181 | foreach ($refParams as $refParm) { 182 | /* @var $refParm ReflectionParameter */ 183 | $paramClass = $refParm->getClass(); 184 | // if we're after a dataobject, we'll try and find one using 185 | // this name with ID and Type parameters 186 | if ($paramClass && ($paramClass->getName() == 'DataObject' || $paramClass->isSubclassOf('DataObject'))) { 187 | $idArg = $refParm->getName().'ID'; 188 | $typeArg = $refParm->getName().'Type'; 189 | 190 | if (isset($allArgs[$idArg]) && isset($allArgs[$typeArg]) && class_exists($allArgs[$typeArg])) { 191 | $object = null; 192 | if (class_exists('DataService')) { 193 | $object = $this->injector->DataService->byId($allArgs[$typeArg], $allArgs[$idArg]); 194 | } else { 195 | $object = DataObject::get_by_id($allArgs[$typeArg], $allArgs[$idArg]); 196 | if (!$object->canView()) { 197 | $object = null; 198 | } 199 | } 200 | if ($object) { 201 | $params[$refParm->getName()] = $object; 202 | } 203 | } else { 204 | $params[$refParm->getName()] = null; 205 | } 206 | } else if (isset($allArgs[$refParm->getName()])) { 207 | $params[$refParm->getName()] = $allArgs[$refParm->getName()]; 208 | } else if ($refParm->getName() == 'file' && $requestType == 'POST') { 209 | // special case of a binary file upload 210 | $params['file'] = $body; 211 | } else if ($refParm->isOptional()) { 212 | $params[$refParm->getName()] = $refParm->getDefaultValue(); 213 | } else { 214 | throw new WebServiceException(500, "Service method $method expects parameter " . $refParm->getName()); 215 | } 216 | } 217 | 218 | $return = $refMeth->invokeArgs($svc, $params); 219 | 220 | $responseItem = $this->convertResponse($return); 221 | 222 | $response = $this->converters[$this->format]['FinalConverter']->convert($responseItem); 223 | } 224 | } 225 | 226 | $this->response->setBody($response); 227 | return $this->response; 228 | } 229 | 230 | /** 231 | * Process a request URL + body to get all parameters for a request 232 | * 233 | * @param string $requestType 234 | * @return array 235 | */ 236 | protected function getRequestArgs($requestType = 'GET') { 237 | if ($requestType == 'GET') { 238 | $allArgs = $this->request->getVars(); 239 | } else { 240 | $allArgs = $this->request->postVars(); 241 | } 242 | 243 | unset($allArgs['url']); 244 | 245 | $contentType = strtolower($this->request->getHeader('Content-Type')); 246 | 247 | if (strpos($contentType, 'application/json') !== false && !count($allArgs) && strlen($this->request->getBody())) { 248 | // decode the body to a params array 249 | $bodyParams = Convert::json2array($this->request->getBody()); 250 | if (isset($bodyParams['params'])) { 251 | $allArgs = $bodyParams['params']; 252 | } else { 253 | $allArgs = $bodyParams; 254 | } 255 | } 256 | 257 | // see if there's any other URL bits to chew up 258 | $remaining = $this->request->remaining(); 259 | $bits = explode('/', $remaining); 260 | 261 | for ($i = 0, $c = count($bits); $i < $c; ) { 262 | $key = $bits[$i]; 263 | $val = isset($bits[$i + 1]) ? $bits[$i + 1] : null; 264 | if ($val && !isset($allArgs[$key])) { 265 | $allArgs[$key] = $val; 266 | } 267 | $i += 2; 268 | } 269 | 270 | return $allArgs; 271 | } 272 | 273 | /** 274 | * Check the allowed methods for access rights 275 | * 276 | * @param array $allowedMethods 277 | * @throws WebServiceException 278 | */ 279 | protected function checkMethods($method, $allowedMethods, $requestType) { 280 | if (!isset($allowedMethods[$method])) { 281 | throw new WebServiceException(403, "You do not have permission to $method"); 282 | } 283 | 284 | $info = $allowedMethods[$method]; 285 | $allowedType = $info; 286 | if (is_array($info)) { 287 | $allowedType = isset($info['type']) ? $info['type'] : ''; 288 | 289 | if (isset($info['perm'])) { 290 | if (!Permission::check($info['perm'])) { 291 | throw new WebServiceException(403, "You do not have permission to $method"); 292 | } 293 | } 294 | } 295 | 296 | // otherwise it might be the wrong request type 297 | if ($requestType != $allowedType) { 298 | throw new WebServiceException(405, "$method does not support $requestType"); 299 | } 300 | } 301 | 302 | /** 303 | * Converts the given object to something appropriate for a response 304 | */ 305 | public function convertResponse($return) { 306 | if (is_object($return)) { 307 | $cls = get_class($return); 308 | } else if (is_array($return)) { 309 | $cls = 'Array'; 310 | } else { 311 | $cls = 'ScalarItem'; 312 | } 313 | 314 | if (isset($this->converters[$this->format][$cls])) { 315 | return $this->converters[$this->format][$cls]->convert($return, $this); 316 | } 317 | 318 | // otherwise, check the hierarchy if the class actually exists 319 | if (class_exists($cls)) { 320 | $hierarchy = array_reverse(array_keys(ClassInfo::ancestry($cls))); 321 | foreach ($hierarchy as $cls) { 322 | if (isset($this->converters[$this->format][$cls])) { 323 | return $this->converters[$this->format][$cls]->convert($return, $this); 324 | } 325 | } 326 | } 327 | 328 | return $return; 329 | } 330 | 331 | /** 332 | * Indicate whether public users can access web services in general 333 | * 334 | * @param boolean $value 335 | */ 336 | public static function set_allow_public($value) { 337 | self::$allow_public_access = $value; 338 | } 339 | 340 | protected function ajaxResponse($message, $status) { 341 | return Convert::raw2json(array( 342 | 'message' => $message, 343 | 'status' => $status, 344 | )); 345 | } 346 | 347 | } 348 | 349 | class WebServiceException extends Exception { 350 | public $status; 351 | 352 | public function __construct($status=403, $message='', $code=null, $previous=null) { 353 | $this->status = $status; 354 | parent::__construct($message, $code, $previous); 355 | } 356 | } 357 | 358 | class ScalarJsonConverter { 359 | public function convert($value) { 360 | return Convert::raw2json($value); 361 | } 362 | } 363 | 364 | class ScalarXmlConverter { 365 | public function convert($value) { 366 | return '' . Convert::raw2xml($value) . ''; 367 | } 368 | } 369 | 370 | class FinalJsonConverter { 371 | public function convert($value) { 372 | $return = '{"response": '.$value . '}'; 373 | return $return; 374 | } 375 | } 376 | 377 | class FinalXmlConverter { 378 | public function convert($value) { 379 | $return = "\n" . ''.$value.''; 380 | return $return; 381 | } 382 | } -------------------------------------------------------------------------------- /code/extensions/TokenAccessible.php: -------------------------------------------------------------------------------- 1 | 'Boolean', 15 | 'Token' => 'Varchar(128)', 16 | 'AuthPrivateKey' => 'Varchar(128)', 17 | 'RegenerateTokens' => 'Boolean', 18 | ); 19 | 20 | public function onBeforeWrite() { 21 | if (!$this->owner->Token || !$this->owner->AuthPrivateKey) { 22 | $this->owner->RegenerateTokens = true; 23 | } 24 | 25 | if ($this->owner->RegenerateTokens) { 26 | $this->owner->RegenerateTokens = false; 27 | $this->generateTokens(); 28 | } 29 | } 30 | 31 | public function updateCMSFields(\FieldList $fields) { 32 | parent::updateCMSFields($fields); 33 | 34 | $token = $this->userToken(); 35 | 36 | if (!$token) { 37 | $token = "This user token can no longer be displayed - if you do not know this value, regenerate tokens by selecting Regenerate below"; 38 | } else { 39 | $token = $this->owner->ID . ':' . $token; 40 | } 41 | 42 | $readOnly = ReadonlyField::create('DisplayToken', 'Token', $token); 43 | $fields->addFieldToTab('Root.Main', $readOnly, 'AuthPrivateKey'); 44 | 45 | $field = $fields->dataFieldByName('AuthPrivateKey'); 46 | $fields->replaceField('AuthPrivateKey', $field->performReadonlyTransformation()); 47 | 48 | $fields->removeByName('Token'); 49 | } 50 | 51 | public function onAfterWrite() { 52 | if ($this->authToken) { 53 | // store the new token so it can be displayed later 54 | Session::set('member_auth_token_' . $this->owner->ID, $this->authToken); 55 | } 56 | } 57 | 58 | /** 59 | * Generate and store the authentication tokens required 60 | * 61 | * @TODO Rework this, it's not really any better than storing text passwords 62 | */ 63 | public function generateTokens() { 64 | $generator = new RandomGenerator(); 65 | $token = $generator->randomToken('sha1'); 66 | $this->owner->Token = $this->owner->encryptWithUserSettings($token); 67 | 68 | $this->authToken = $token; 69 | 70 | 71 | $authToken = $generator->randomToken('whirlpool'); 72 | $this->owner->AuthPrivateKey = $authToken; 73 | } 74 | 75 | public function userToken() { 76 | return Session::get('member_auth_token_' . $this->owner->ID); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /code/serialisers/ArrayJsonConverter.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class ArrayJsonConverter { 9 | public function convert($array, $controller) { 10 | $isMap = false; 11 | $vals = array(); 12 | foreach ($array as $key => $value) { 13 | if (!is_int($key)) { 14 | $isMap = true; 15 | } 16 | 17 | $vals[] = $controller->convertResponse($value); 18 | 19 | } 20 | 21 | if (!$isMap) { 22 | $retString = rtrim(implode(",", $vals), ','); 23 | return '[' . $retString . ']'; 24 | } 25 | 26 | // otherwise, we need to go through and do a key/val pairing 27 | $keys = array_keys($array); 28 | $ret = array(); 29 | for ($i = 0, $c = count($keys); $i < $c; $i++) { 30 | $ret[] = '"' . str_replace('"', '\"', $keys[$i]). '": ' . $vals[$i]; 31 | } 32 | 33 | $retString = rtrim(implode(",", $ret), ','); 34 | return '{ ' . $retString . ' }'; 35 | } 36 | } -------------------------------------------------------------------------------- /code/serialisers/ArrayXmlConverter.php: -------------------------------------------------------------------------------- 1 | 7 | * @license BSD License http://www.silverstripe.org/bsd-license 8 | */ 9 | class ArrayXmlConverter { 10 | public function convert($array, $controller) { 11 | $converter = new ArrayToXml('items'); 12 | return $converter->convertArray($array); 13 | } 14 | } 15 | 16 | class ArrayToXml { 17 | 18 | public function __construct($name = 'items') { 19 | $this->name = $name; 20 | } 21 | 22 | public function convertArray($data) { 23 | return "<$this->name>" . $this->convert($data) . "name>"; 24 | } 25 | 26 | public function convert($value) { 27 | if (is_scalar($value) || is_null($value)) { 28 | return Convert::raw2xml($value); 29 | } else { 30 | $bits = array(); 31 | foreach ($value as $key => $itemVal) { 32 | if (is_int($key)) { 33 | $elem = 'item'; 34 | } else { 35 | $elem = $key; 36 | } 37 | 38 | $bits[] = "<$elem>" . $this->convert($itemVal) .""; 39 | } 40 | return implode($bits); 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /code/serialisers/DataObjectJsonConverter.php: -------------------------------------------------------------------------------- 1 | hasMethod('toFilteredMap')) { 13 | return Convert::raw2json($object->toFilteredMap()); 14 | } 15 | return Convert::raw2json($object->toMap()); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /code/serialisers/DataObjectSetJsonConverter.php: -------------------------------------------------------------------------------- 1 | items = array(); 14 | foreach ($set as $item) { 15 | if ($item instanceof SS_Object && $item->hasMethod('toFilteredMap')) { 16 | $ret->items[] = $item->toFilteredMap(); 17 | } else if (method_exists($item, 'toMap')) { 18 | $ret->items[] = $item->toMap(); 19 | } else { 20 | $ret->items[] = $item; 21 | } 22 | } 23 | 24 | return Convert::raw2json($ret); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /code/serialisers/DataObjectSetXmlConverter.php: -------------------------------------------------------------------------------- 1 | 7 | * @license BSD License http://www.silverstripe.org/bsd-license 8 | */ 9 | class DataObjectSetXmlConverter { 10 | public function convert($set) { 11 | $items = array(); 12 | 13 | foreach ($set as $item) { 14 | if ($item instanceof SS_Object && $item->hasMethod('toFilteredMap')) { 15 | $items[] = $item->toFilteredMap(); 16 | } else if (method_exists($item, 'toMap')) { 17 | $items[] = $item->toMap(); 18 | } else { 19 | $items[] = $item; 20 | } 21 | } 22 | 23 | $converter = new ArrayToXml('items'); 24 | return $converter->convertArray($items); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /code/serialisers/DataObjectXmlConverter.php: -------------------------------------------------------------------------------- 1 | 7 | * @license BSD License http://www.silverstripe.org/bsd-license 8 | */ 9 | class DataObjectXmlConverter { 10 | public function convert(DataObject $object) { 11 | if ($object->hasMethod('toFilteredMap')) { 12 | $data = $object->toFilteredMap(); 13 | } else { 14 | $data = $object->toMap(); 15 | } 16 | 17 | $converter = new ArrayToXml('item'); 18 | return $converter->convertArray($data); 19 | } 20 | } 21 | 22 | -------------------------------------------------------------------------------- /code/services/CalendarWebService.php: -------------------------------------------------------------------------------- 1 | 'POST', 14 | ); 15 | } 16 | 17 | public function __construct() { 18 | 19 | } 20 | 21 | public function createEvent($title, $content, $parentCalendar, $startDate, $endDate=null, $canRegister = false, $doPublish = true) { 22 | if (!class_exists("Calendar")) { 23 | return; 24 | } 25 | 26 | $parentCalendar = (int) $parentCalendar; 27 | 28 | if (!$parentCalendar) { 29 | throw new Exception("Parent calendar ID expected"); 30 | } 31 | 32 | if ($parentCalendar) { 33 | $parentCalendar = DataObject::get_by_id('Calendar', $parentCalendar); 34 | } 35 | 36 | if (!$parentCalendar || !$parentCalendar->exists()) { 37 | throw new Exception("Could not find parent calendar"); 38 | } 39 | 40 | if (!$parentCalendar->canEdit()) { 41 | throw new WebServiceException(403, "Access denied to that calendar"); 42 | } 43 | 44 | if ($canRegister && !class_exists('RegisterableEvent')) { 45 | throw new Exception("Event registration not supported"); 46 | } 47 | 48 | $startDate = date('Y-m-d', strtotime($startDate)); 49 | 50 | if (!$endDate) { 51 | $endDate = date('Y-m-d', strtotime($startDate)); 52 | } else { 53 | $endDate = date('Y-m-d', strtotime($endDate)); 54 | } 55 | 56 | $type = $canRegister ? 'RegisterableEvent' : 'CalendarEvent'; 57 | $dateTimeType = $canRegister ? 'RegisterableDateTime' : 'CalendarDateTime'; 58 | 59 | $event = new $type; 60 | $event->Title = $title; 61 | $event->Content = $content; 62 | $event->ParentID = $parentCalendar->ID; 63 | $event->write(); 64 | 65 | $dateTime = new $dateTimeType; 66 | $dateTime->Title = $title; 67 | $dateTime->StartDate = $startDate; 68 | $dateTime->EndDate = $endDate; 69 | $dateTime->is_all_day = $startDate == $endDate; 70 | $dateTime->EventID = $event->ID; 71 | $dateTime->write(); 72 | 73 | if ($doPublish) { 74 | $event->doPublish(); 75 | } 76 | 77 | return $event; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /code/services/DummyWebService.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class DummyWebService implements WebServiceable { 12 | 13 | /** 14 | * Something that silverstripe imposes! 15 | */ 16 | public function __construct() { 17 | 18 | } 19 | 20 | public function webEnabledMethods() { 21 | return array( 22 | 'myMethod' => 'GET', 23 | ); 24 | } 25 | 26 | public function myMethod($param) { 27 | return array( 28 | 'SomeParam' => 'Goes here', 29 | 'Boolean' => true, 30 | 'Return' => $param, 31 | ); 32 | } 33 | } -------------------------------------------------------------------------------- /code/services/TokenAuthenticator.php: -------------------------------------------------------------------------------- 1 | byID($uid); 20 | if ($user && $user->exists()) { 21 | $hash = $user->encryptWithUserSettings($token); 22 | // we're not comparing against the RawToken because we want the 'slow' process above to execute 23 | if ($hash == $user->Token) { 24 | $this->loginUser($user); 25 | return $user; 26 | } 27 | } 28 | } 29 | 30 | /** 31 | * Log a user in. 32 | * 33 | * Copies some code from Member::login, but doesn't do any writes or other checks like that 34 | * 35 | * @param Member $member 36 | */ 37 | protected function loginUser($member) { 38 | Member::session_regenerate_id(); 39 | Session::set("loggedInAs", $member->ID); 40 | 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /code/services/WebServiceable.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | interface WebServiceable { 9 | 10 | } -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "silverstripe/webservices", 3 | "description": "A module for exposing SilverStripe service classes via web endpoints as webservices.", 4 | "type": "silverstripe-module", 5 | "keywords": ["silverstripe", "webservices"], 6 | "license": "BSD-3-Clause", 7 | "authors": [ 8 | { 9 | "name": "Marcus Nyeholt", 10 | "email": "marcus@silverstripe.com.au" 11 | } 12 | ], 13 | "require": 14 | { 15 | "silverstripe/framework": "~3.1@dev", 16 | "silverstripe/cms": "~3.1@dev" 17 | }, 18 | "extra": { 19 | "branch-alias": { 20 | "dev-master": "3.3.x-dev" 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /javascript/webservices.js: -------------------------------------------------------------------------------- 1 | 2 | ;(function ($) { 3 | window.SSWebServices = (function () { 4 | var securityId = $('#SecurityID').val(); 5 | if (!securityId) { 6 | securityId = $('input[name=SecurityID]').val(); 7 | } 8 | 9 | var getService = function (name, method, params, cb) { 10 | params['SecurityID'] = securityId; 11 | return $.get('jsonservice/' + name + '/' + method, params, cb); 12 | } 13 | 14 | var postService = function (name, method, params, cb) { 15 | params['SecurityID'] = securityId; 16 | return $.post('jsonservice/' + name + '/' + method, params, cb); 17 | } 18 | 19 | return { 20 | get: getService, 21 | post: postService 22 | }; 23 | })(); 24 | })(jQuery); --------------------------------------------------------------------------------