commit ba104bd67444e74d685abfc927f519ba3ff5dbc6 Author: Maxime Renou Date: Mon May 11 16:26:34 2020 +0200 First commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ddcf91 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea/ +/vendor/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e817926 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# laravel-bconnect + +The Bluesquare Connect package allows you to use its OAuth server and sync its resources. + +## Installation + +First in your composer.json, add: + +``` +"require": { + "bluesquare/laravel-connect": "dev-master" +} +``` + + +``` +"repositories": [ + { + "type": "vcs", + "url": "https://git.bluesquare.io/bluesquare/laravel-connect" + } +] +``` + +Next, update your package: + +```bash +composer update bluesquare/laravel-connect +``` + +Eventually, if you want to customize the config system: + +```bash +php artisan vendor:publish +``` + +Finally, add in your `.env`: + +```bash +BCONNECT_CLIENT_ID=your_client_id +BCONNECT_CLIENT_SECRET=your_client_secret +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..3b9c9cc --- /dev/null +++ b/composer.json @@ -0,0 +1,39 @@ +{ + "name": "bluesquare/laravel-connect", + "description": "Consume Bluesquare Connect resources and use its OAuth server.", + "keywords": [ + "package", + "bluesquare", + "api", + "connect", + "oauth" + ], + "homepage": "https://git.bluesquare.io/bluesquare/laravel-connect", + "license": "proprietary", + "authors": [ + { + "name": "Bluesquare", + "email": "contact@bluesquare.io", + "homepage": "https://bluesquare.io/", + "role": "Developers" + } + ], + "minimum-stability": "dev", + "autoload": { + "psr-4": { + "Bluesquare\\Connect\\": "src/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Bluesquare\\Connect\\ConnectServiceProvider" + ] + } + }, + "require": { + "guzzlehttp/guzzle": "^6.5", + "php": "^7.2" + }, + "prefer-stable": true +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..ea916a2 --- /dev/null +++ b/composer.lock @@ -0,0 +1,297 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "98b09ff09c6392aadb0f56bb9dc51a96", + "packages": [ + { + "name": "guzzlehttp/guzzle", + "version": "6.5.2", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "43ece0e75098b7ecd8d13918293029e555a50f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/43ece0e75098b7ecd8d13918293029e555a50f82", + "reference": "43ece0e75098b7ecd8d13918293029e555a50f82", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^1.0", + "guzzlehttp/psr7": "^1.6.1", + "php": ">=5.5" + }, + "require-dev": { + "ext-curl": "*", + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.4 || ^7.0", + "psr/log": "^1.1" + }, + "suggest": { + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.5-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "homepage": "http://guzzlephp.org/", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "rest", + "web service" + ], + "time": "2019-12-23T11:57:10+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "v1.3.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/a59da6cf61d80060647ff4d3eb2c03a2bc694646", + "reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646", + "shasum": "" + }, + "require": { + "php": ">=5.5.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "time": "2016-12-20T10:07:11+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "1.6.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "239400de7a173fe9901b9ac7c06497751f00727a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/239400de7a173fe9901b9ac7c06497751f00727a", + "reference": "239400de7a173fe9901b9ac7c06497751f00727a", + "shasum": "" + }, + "require": { + "php": ">=5.4.0", + "psr/http-message": "~1.0", + "ralouphie/getallheaders": "^2.0.5 || ^3.0.0" + }, + "provide": { + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "ext-zlib": "*", + "phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.8" + }, + "suggest": { + "zendframework/zend-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.6-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Schultze", + "homepage": "https://github.com/Tobion" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "time": "2019-07-01T23:21:34+00:00" + }, + { + "name": "psr/http-message", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "time": "2016-08-06T14:39:51+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "time": "2019-03-08T08:55:37+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [] +} diff --git a/config/bconnect.php b/config/bconnect.php new file mode 100644 index 0000000..eafea48 --- /dev/null +++ b/config/bconnect.php @@ -0,0 +1,34 @@ + \App\User::class, + + /** + * OAuth redirect URI + */ + 'redirect' => env('BCONNECT_REDIRECT', url('/oauth/callback')), + + /** + * OAuth client id + */ + 'client_id' => env('BCONNECT_CLIENT_ID', null), + + /** + * OAuth client secret + */ + 'client_secret' => env('BCONNECT_CLIENT_SECRET', null), + + /** + * OAuth scopes (separated with commas) + */ + 'user_scopes' => '*', + 'client_scopes' => '*', + + /** + * Bluesquare Connect URL + */ + 'url' => env('BCONNECT_URL', null), +]; diff --git a/src/Connect.php b/src/Connect.php new file mode 100644 index 0000000..dabc46c --- /dev/null +++ b/src/Connect.php @@ -0,0 +1,264 @@ +app = $app; + } + + // User config + + public function setSynchronized($models) + { + $this->synchronized = []; + + foreach ($models as $model) + { + if (!in_array(HasConnectSync::class, class_uses($model))) + throw new ConnectException("$model does not implement HasConnectSync trait."); + + $class = explode('\\', $model); + $resource = $model::$connectResource ?? end($class); + + $this->synchronized[$resource] = $model; + } + } + + // API + + /** + * @param $method + * @param $uri + * @param null $data + * @return \Psr\Http\Message\StreamInterface + * @throws ConnectException + */ + public function request($method, $uri, $data = null, $auth = true): StreamInterface + { + $url = config('bconnect.url') ?? 'https://connect.bluesquare.io'; + $url = $url . '/' . trim($uri, '/'); + + $client = new Client(); + + $config = [ + 'headers' => [ + 'Accept' => 'application/json' + ] + ]; + + if ($auth === true) { + $config['Authorization'] = 'Bearer ' . $this->getAccessToken(); + } + elseif ($auth !== false) { + $config['Authorization'] = 'Bearer ' . $auth; + } + + if (!is_null($data)) { + $config['form_params'] = $data; + } + + try { + return json_decode( + $client->request($method, $url, $config)->getBody(), + true + ); + + } catch(\Exception $e) { + $this->deleteAccessToken(); + throw new ConnectException($e->getMessage()); + } + } + + // OAuth (user) + + public function redirect($state = null) + { + if (is_null($state)) + $state = Str::random(); + + $states = session()->get('connect_states'); + if (!is_array($states)) + $states = []; + $states[] = $state; + session()->put('connect_states', $states); + + $query = http_build_query([ + 'client_id' => config('bconnect.client_id'), + 'scope' => config('bconnect.scopes'), + 'redirect_uri' => config('bconnect.redirect'), + 'response_type' => 'code', + 'state' => $state + ]); + + $url = config('bconnect.url') . '/oauth/authorize?' . $query; + return redirect()->to($url); + } + + public function loginFromCallback(Request $request) + { + // State check + + if (!session()->has('connect_states')) + abort(403, "Session expired"); + + $states = session()->get('connect_states'); + + if (!is_array($states)) + abort(403, "Session expired"); + + if (!$request->has('state') || !in_array($request->state, $states)) + abort(403, "Invalid state"); + + unset($states[array_search($request->state, $states)]); + + session()->put('connect_states', $states); + + // Code check + + if (!$request->has('code')) + abort(403, "Missing authorization code"); + + $code = $request->code; + + // Access token + + $data = $this->request('post', 'oauth/token', [ + 'grant_type' => 'authorization_code', + 'client_id' => config('bconnect.client_id'), + 'client_secret' => config('bconnect.client_secret'), + 'scope' => config('bconnect.user_scopes'), + 'authorization_code' => $code + ], false); + + return $data; + } + + // OAuth (client) + + public function getAccessToken() + { + $access_token = cache()->get('bconnect.access_token'); + $access_token_expiration = cache()->get('bconnect.access_token_expiration'); + + if ($access_token && $access_token_expiration > time() + 60) { + return $access_token; + } + + $data = $this->request('post', '/oauth/token', [ + 'grant_type' => 'client_credentials', + 'client_id' => config('bconnect.client_id'), + 'client_secret' => config('bconnect.client_secret'), + 'scope' => config('bconnect.client_scopes') + ], false); + + cache()->set('bconnect.access_token', $data['access_token']); + cache()->set('bconnect.access_token_expiration', time() + $data['expires_in']); + + return $data['access_token']; + } + + public function deleteAccessToken() + { + cache()->delete('bconnect.access_token'); + cache()->delete('bconnect.access_token_expiration'); + } + + // Webhooks handler + + /** + * @param Request $request + * @return bool + */ + public function handleWebhook(Request $request) + { + $data = $request->validate([ + 'connectEventType' => 'required|in:created,updated,deleted', + 'connectResourceType' => 'required', + 'connectResourceTable' => 'required', + 'connectResourceData' => 'required|array', + 'connectResourceData.id' => 'required' + ]); + + if (!array_key_exists($data['connectResourceType'], $this->synchronized)) + return false; + + $model = $this->synchronized[$data['connectResourceType']]; + $method = $this->getEventMethod($data['connectEventType']); + $data = $data['connectResourceData']; + $model::$method($data['id'], $data); + + return true; + } + + // Resources endpoints + + public function getAll($resourceType) + { + return $this->request('get', "api/resources/$resourceType"); + } + + public function get($resourceType, $resourceId) + { + return $this->request('get', "api/resources/$resourceType/$resourceId"); + } + + // Resources sync + + public function syncAll($resourceTypes = null) + { + $resourceTypes = $resourceTypes ?? $this->synchronized; + + foreach ($resourceTypes as $resourceType) + { + if (!array_key_exists($resourceType, $this->synchronized)) + throw new ConnectException("Resource $resourceType not declared as synchronized."); + + $resources = $this->getAll($resourceType); + $model = $this->synchronized[$resourceType]; + $identifiers = []; + + foreach ($resources as $data) + { + $identifiers[] = intval($data['id']); + $this->sync($resourceType, $data['id'], $data); + } + + foreach ($model::all() as $item) + { + if (!in_array(intval($item->id), $identifiers)) + $item->delete(); + } + } + } + + public function sync($resourceType, $resourceId, $resourceData = null) + { + if (is_null($resourceData)) { + $resourceData = $this->get($resourceType, $resourceId); + } + + $model = $this->synchronized[$resourceType]; + $item = $model::find($resourceId); + $method = $this->getEventMethod($item ? 'updated' : 'created'); + $model::$method($resourceId, $resourceData); + } + + // + + protected function getEventMethod($event) + { + return 'onConnectResource' . ucfirst($event); + } +} diff --git a/src/ConnectException.php b/src/ConnectException.php new file mode 100644 index 0000000..2aaa17c --- /dev/null +++ b/src/ConnectException.php @@ -0,0 +1,5 @@ +mergeConfigFrom( + __DIR__ . '/../config/bconnect.php', + 'bmail' + ); + + $this->app->singleton(Connect::class, function ($app) { + return new Connect($app); + }); + } + + /** + * Bootstrap any application services. + * + * @return void + */ + public function boot() + { + $this->publishes([ + __DIR__ . '/../config/bconnect.php' => config_path('bconnect.php') + ], 'config'); + } +} diff --git a/src/traits/HasConnectSync.php b/src/traits/HasConnectSync.php new file mode 100644 index 0000000..61d9463 --- /dev/null +++ b/src/traits/HasConnectSync.php @@ -0,0 +1,34 @@ +fill($data); + $record->save(); + return true; + } + + public static function onConnectResourceUpdated($id, $data) + { + $record = self::find($id) ?? new self; + $record->fill($data); + $record->save(); + return true; + } + + public static function onConnectResourceDeleted($id, $data) + { + $record = self::find($id); + return $record ? $record->delete() : false; + } +}