diff --git a/config/bconnect.php b/config/bconnect.php index 2e217da..0acc9b3 100644 --- a/config/bconnect.php +++ b/config/bconnect.php @@ -13,18 +13,19 @@ return [ 'login_url' => '/connect/authorize', /** + * Use post-login remember cookie + */ + 'login_remember' => true, + + /** * OAuth callback URL */ 'redirect_url' => env('BCONNECT_REDIRECT', 'http://localhost:8000/connect/callback'), /** - * OAuth client id + * OAuth client identifiers */ 'client_id' => env('BCONNECT_CLIENT_ID', null), - - /** - * OAuth client secret - */ 'client_secret' => env('BCONNECT_CLIENT_SECRET', null), /** diff --git a/src/Commands/RefreshTokens.php b/src/Commands/RefreshTokens.php new file mode 100644 index 0000000..8b3415e --- /dev/null +++ b/src/Commands/RefreshTokens.php @@ -0,0 +1,45 @@ +chunks(10, function ($models) use ($connect) { + $models->each(function ($model) use ($connect) { + if (!empty($model->connect_refresh_token) && $model->connect_expires_at <= now()->addHour()) { + try { + $tokens = $connect->getAccessTokenFromRefreshToken($model->connect_refresh_token); + $connect->updateUserConnectData($model, $tokens); + $model->save(); + } + catch (\Exception $exception) { + $this->warn("Failed to refresh model tokens", $model->toArray()); + } + } + }); + }); + + $this->info("Tokens refreshed"); + + return 0; + } +} diff --git a/src/Commands/Sync.php b/src/Commands/Sync.php index daa21eb..2b9915e 100644 --- a/src/Commands/Sync.php +++ b/src/Commands/Sync.php @@ -3,41 +3,45 @@ namespace Bluesquare\Connect\Commands; use Bluesquare\Connect\Connect; +use Bluesquare\Connect\ConnectException; +use Bluesquare\Connect\Traits\HasConnectTokens; use Illuminate\Console\Command; class Sync extends Command { - /** - * The name and signature of the console command. - * - * @var string - */ protected $signature = 'connect:sync'; - /** - * The console command description. - * - * @var string - */ - protected $description = 'Synchronize Bluesquare Connect resources'; + protected $description = 'Sync Bluesquare Connect users'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - - /** - * Execute the console command. - * - * @return mixed - */ public function handle(Connect $connect) { - $connect->syncAll(); + $this->call('connect:refresh'); + + $class = config('bconnect.model'); + + $has_fields = in_array(HasConnectTokens::class, class_uses($class)); + + if (!$has_fields) { + throw new ConnectException("$class does not implement HasConnectTokens"); + } + + $class::query()->chunks(10, function ($models) use ($connect) { + $models->each(function ($model) use ($connect) { + try { + if (!empty($model->connect_access_token)) { + $data = $connect->getUserData($model->connect_access_token); + $connect->updateUserData($model, $data); + $model->save(); + } + } + catch (\Exception $exception) { + $this->warn("Failed to sync model data", $model->toArray()); + } + }); + }); + + $this->info("Models synced"); + + return 0; } } diff --git a/src/Connect.php b/src/Connect.php index a842c70..ea18f2c 100644 --- a/src/Connect.php +++ b/src/Connect.php @@ -2,6 +2,7 @@ namespace Bluesquare\Connect; +use Bluesquare\Connect\Traits\HasConnectData; use Bluesquare\Connect\Traits\HasConnectSync; use Bluesquare\Connect\Traits\HasConnectTokens; use GuzzleHttp\Client; @@ -14,59 +15,13 @@ use Psr\Http\Message\StreamInterface; class Connect { - protected static $resources = [ - 'Role', - 'Company', - 'Team', - 'User', - 'UserTeam' - ]; - - protected static $foreignKeys = [ - 'role_id' => 'Role', - 'company_id' => 'Company', - 'team_id' => 'Team', - 'user_id' => 'User', - 'user_teams_id' => 'UserTeam' - ]; - protected $app; - protected $synchronized = []; public function __construct($app) { $this->app = $app; } - // User config - - public function setSynchronized($models) - { - $items = []; - - 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); - - $items[$resource] = $model; - } - - $synchronized = []; - - foreach (self::$resources as $resourceType) { // Re-ordering - foreach ($items as $resource => $model) { - if ($resource == $resourceType) - $synchronized[$resource] = $model; - } - } - - $this->synchronized = $synchronized; - } - // API /** @@ -76,7 +31,7 @@ class Connect * @return array * @throws ConnectException */ - public function request($method, $uri, $data = null, $auth = true): array + public function request($method, $uri, $data = null, $access_token = null): array { $url = $this->getUrl(); $url = $url . '/' . trim($uri, '/'); @@ -89,11 +44,8 @@ class Connect ] ]; - if ($auth === true) { - $config['headers']['Authorization'] = 'Bearer ' . $this->getAccessToken(); - } - elseif ($auth !== false) { - $config['headers']['Authorization'] = 'Bearer ' . $auth; + if ($access_token !== null) { + $config['headers']['Authorization'] = 'Bearer ' . $access_token; } if (!is_null($data)) { @@ -110,12 +62,13 @@ class Connect return json_decode($body, true); } catch(\Exception $e) { - $this->deleteAccessToken(); + $this->flushTokens(); + throw new ConnectException($e->getMessage()); } } - // OAuth (user) + // Authorization flow public function redirect($state = null) { @@ -123,9 +76,12 @@ class Connect $state = Str::random(); $states = session()->get('connect_states'); + if (!is_array($states)) $states = []; + $states[] = $state; + session()->put('connect_states', $states); $query = http_build_query([ @@ -137,80 +93,59 @@ class Connect ]); $url = $this->getUrl() . '/oauth/authorize?' . $query; + return redirect()->to($url); } public function checkState(Request $request) { - if (!session()->has('connect_states')) { - Log::debug("Missing session states"); + if (!session()->has('connect_states')) return false; - } $states = session()->get('connect_states'); - if (!is_array($states)) { - Log::debug("Invalid session state"); + if (!is_array($states)) return false; - } - if (!$request->has('state') || !in_array($request->state, $states)) { - Log::debug("Missing valid state in request"); + if (!$request->has('state') || !in_array($request->state, $states)) return false; - } unset($states[array_search($request->state, $states)]); session()->put('connect_states', $states); + return true; } public function loginFromCallback(Request $request, $redirect_to = '/') { - if (!$this->checkState($request)) + if (!$this->checkState($request) || !$request->has('code')) return redirect('/'); - // Code check - - if (!$request->has('code')) { - Log::debug("Missing authorization code"); - return redirect('/'); - } - // Access token + $expires_at = now(); $connect_data = $this->getAccessTokenFromAuthorizationCode($request->code); $connect_data['expires_at'] = $expires_at->addSeconds($connect_data['expires_in']); - $model_data = $this->getUserData($connect_data['access_token']); - $model = config('bconnect.model'); + // User data - if (in_array($model, $this->synchronized)) { - $user = $model::findConnectResource($model_data['id']) ?? new $model; - } - else { - $user = $model::where('email', $model_data['email'])->first() ?? new $model; - } - - $model_data = $this->convertForeignKeys($model_data); - - $user->fill($model_data); - - if (in_array($model, $this->synchronized)) - $user->{$model::$connectColumnId} = $model_data['id']; - - $user->save(); - - if (in_array($model, $this->synchronized)) - $user = $model::findConnectResource($model_data['id']); + $user_data = $this->getUserData($connect_data['access_token']); + $user = $this->resolveUser($user_data); $this->updateUserConnectData($user, $connect_data); + $this->updateUserData($user, $user_data); + $user->save(); - auth()->login($user, true); + // Login + + auth()->login($user, config('bconnect.login_remember', true)); return redirect($redirect_to); } + // OAuth methods + public function getAccessTokenFromAuthorizationCode($code) { $data = $this->request('post', 'oauth/token', [ @@ -220,7 +155,7 @@ class Connect 'scope' => config('bconnect.user_scopes'), 'redirect_uri' => config('bconnect.redirect_url'), 'code' => $code - ], false); + ]); return $data; } @@ -234,7 +169,7 @@ class Connect 'scope' => config('bconnect.user_scopes'), 'redirect_uri' => config('bconnect.redirect_url'), 'refresh_token' => $refresh_token - ], false); + ]); return $data; } @@ -244,164 +179,65 @@ class Connect return $this->request('get', 'api/user', null, $access_token); } - public function updateUserConnectData($user, $data) + public function getUserAccessToken($model) { - if (!in_array(HasConnectTokens::class, class_uses(get_class($user)))) - return false; - - $user->connect_access_token = $data['access_token']; - $user->connect_refresh_token = $data['refresh_token']; - $user->connect_expires_at = $data['expires_at']; - - return $user->save(); - } - - public function getUserAccessToken($user) - { - $has_fields = in_array(HasConnectTokens::class, class_uses(get_class($user))); + $class = get_class($model); + $has_fields = in_array(HasConnectTokens::class, class_uses($class)); if (!$has_fields) { - throw new ConnectException("User class does not implement HasConnectTokens"); + throw new ConnectException("$class does not implement HasConnectTokens"); } - if ($user->connect_expires_at <= now()) { - $connect_data = $this->getAccessTokenFromRefreshToken($user->connect_refresh_token); - $this->updateUserConnectData($user, $connect_data); + if ($model->connect_expires_at <= now()->addHour()) { + $connect_data = $this->getAccessTokenFromRefreshToken($model->connect_refresh_token); + $this->updateUserConnectData($model, $connect_data); return $connect_data['access_token']; } - return $user->connect_access_token; + return $model->connect_access_token; } - // OAuth (client) + // Sync - 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'); - } - - // Webhook 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' + 'event_type' => 'required|in:created,updated,deleted,restored', + 'connect_data' => 'required|array', + 'connect_data.id' => 'required' ]); - if (!array_key_exists($data['connectResourceType'], $this->synchronized)) - return false; + //@TODO - $model = $this->synchronized[$data['connectResourceType']]; - $method = $this->getEventMethod($data['connectEventType']); + return ['handled' => false]; + } - $data = $data['connectResourceData']; + public function updateUserConnectData($user, $data) + { + if (in_array(HasConnectTokens::class, class_uses(get_class($user)))) + $user->fillConnectTokens($data); + } - try { - $data = $this->get($data['connectResourceType'], $data['connectResourceData']['id']); - if ($data['connectEventType'] == 'deleted') { - abort(403, "This resource still exists."); - } - } catch (\Exception $e) { - if ($data['connectEventType'] != 'deleted') { - abort(404, "Could not retrieve this resource."); - } + public function updateUserData($user, $data) + { + if (in_array(HasConnectData::class, class_uses(get_class($user)))) + $user->fillConnectData($data); + } + + protected function resolveUser($data) + { + $model = config('bconnect.model'); + + if (in_array(HasConnectData::class, class_uses($model))) { + $origin = is_array($model::$connectIdentifier) ? $model::$connectIdentifier[0] : $model::$connectIdentifier; + $target = is_array($model::$connectIdentifier) ? $model::$connectIdentifier[1] : $model::$connectIdentifier; + + $user = $model::where($target, $data[$origin])->first() ?? new $model; + } else { + $user = $model::where('email', $data['email'])->first() ?? new $model; } - $data = $this->convertForeignKeys($data); - - $model::$method($data['id'], $data); - - return true; - } - - // Resources getters - - 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 syncing - - public function syncAll($resourceTypes = null) - { - $resourceTypes = $resourceTypes ?? $this->synchronized; - - foreach ($resourceTypes as $resourceType) - { - $resourceType = $this->resolveResourceType($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->{$model::$connectColumnId}), $identifiers)) - $model::onConnectResourceDoesNotExist($item); - } - } - } - - public function sync($resourceType, $resourceId, $resourceData = null) - { - $resourceType = $this->resolveResourceType($resourceType); - - if (is_null($resourceData)) { - $resourceData = $this->get($resourceType, $resourceId); - } - - $model = $this->synchronized[$resourceType]; - $item = $model::findConnectResource($resourceId); - $method = $this->getEventMethod($item ? 'updated' : 'created'); - - $data = $this->convertForeignKeys($resourceData); - $model::$method($resourceId, $data); + return $user; } // Routing @@ -425,44 +261,14 @@ class Connect // Misc - protected function resolveResourceType($class) + protected function flushTokens() { - if (in_array($class, $this->synchronized)) - return array_flip($this->synchronized)[$class]; - - return $class; - } - - protected function resolveResourceModel($class) - { - if (array_key_exists($class, $this->synchronized)) - return $this->synchronized[$class]; - - return $class; - } - - protected function getEventMethod($event) - { - return 'onConnectResource' . ucfirst($event); + session()->forget('bconnect.access_token'); + session()->forget('bconnect.access_token_expiration'); } protected function getUrl() { return config('bconnect.url') ?? 'https://connect.bluesquare.io'; } - - protected function convertForeignKeys($data) - { - foreach (self::$foreignKeys as $key => $resourceType) - { - if (!array_key_exists($key, $data)) continue; - if (!array_key_exists($resourceType, $this->synchronized)) continue; - - $model = $this->resolveResourceModel($resourceType); - $record = $model::findConnectResource($data[$key]); - $data[$key] = $record ? $record->id : null; - } - - return $data; - } } diff --git a/src/ConnectServiceProvider.php b/src/ConnectServiceProvider.php index bf90f76..acb2d7b 100644 --- a/src/ConnectServiceProvider.php +++ b/src/ConnectServiceProvider.php @@ -2,70 +2,54 @@ namespace Bluesquare\Connect; +use Bluesquare\Connect\Commands\RefreshTokens; use Bluesquare\Connect\Commands\Sync; use Illuminate\Support\ServiceProvider; class ConnectServiceProvider extends ServiceProvider { - /** - * Register any application services. - * - * @return void - */ public function register() { - // Config - - $this->mergeConfigFrom( - __DIR__ . '/../config/bconnect.php', - 'bconnect' - ); - - // Singletons + $this->mergeConfigFrom($this->path('src/config/bconnect.php'), 'bconnect'); $this->app->singleton(Connect::class, function ($app) { return new Connect($app); }); } - /** - * Bootstrap any application services. - * - * @return void - */ public function boot() { - // Config + $config_path = $this->path('src/config/bconnect.php'); + $views_path = $this->path('resources/views/connect'); $this->publishes([ - __DIR__ . '/../config/bconnect.php' => config_path('bconnect.php') + $config_path => config_path('bconnect.php'), + $views_path => resource_path('views/vendor/connect'), ]); - // Translations + $this->loadTranslationsFrom($this->path('resources/translations'), 'connect'); - $this->loadTranslationsFrom(__DIR__.'/../resources/translations', 'connect'); + $this->loadViewsFrom($this->path('resources/views/connect'), 'connect'); - // Views - - $this->loadViewsFrom(__DIR__.'/../resources/views/connect', 'connect'); - - $this->publishes([ - __DIR__.'/../resources/views/connect' => resource_path('views/vendor/connect'), - ]); + if ($this->app->runningInConsole()) { + $this->commands([ + RefreshTokens::class, + Sync::class, + ]); + } + // Laravel 7+ if (method_exists($this, 'loadViewComponentsAs')) { - // Laravel 7+ $this->loadViewComponentsAs('connect', [ \Bluesquare\Connect\View\Components\Button::class ]); } + } - // Commands + // Misc - if ($this->app->runningInConsole()) { - $this->commands([ - Sync::class - ]); - } + private function path($path = '') + { + return __DIR__ . "/../../$path"; } } diff --git a/src/Traits/HasConnectData.php b/src/Traits/HasConnectData.php new file mode 100644 index 0000000..d7c5452 --- /dev/null +++ b/src/Traits/HasConnectData.php @@ -0,0 +1,23 @@ +connectFillable as $origin => $target) { + if (is_string($origin)) { + $this->$target = $data[$origin] ?? null; + } else { + $this->$target = $data[$target] ?? null; + } + } + } + + abstract public function fill(array $attributes); +} diff --git a/src/Traits/HasConnectSync.php b/src/Traits/HasConnectSync.php deleted file mode 100644 index fc66388..0000000 --- a/src/Traits/HasConnectSync.php +++ /dev/null @@ -1,54 +0,0 @@ -where(self::$connectColumnId, $id)->first(); - } - - public static function onConnectResourceCreated($id, $data) - { - $record = self::findConnectResource($id) ?? new self; - $attributes = $record->getConnectFillableAttributes(); - - foreach ($data as $key => $value) { - if (in_array($key, $attributes)) - $record->$key = $value; - } - - $record->{self::$connectColumnId} = $id; - return $record->save(); - } - - public static function onConnectResourceUpdated($id, $data) - { - return self::onConnectResourceCreated($id, $data); - } - - public static function onConnectResourceDeleted($id, $data = null) - { - $record = self::findConnectResource($id); - return $record ? $record->forceDelete() : false; - } - - public static function onConnectResourceDoesNotExist($record) - { - return $record->forceDelete(); - } - - public function getConnectFillableAttributes() - { - return $this->fillable; - } -} diff --git a/src/Traits/HasConnectTokens.php b/src/Traits/HasConnectTokens.php index d2a044c..9429d8e 100644 --- a/src/Traits/HasConnectTokens.php +++ b/src/Traits/HasConnectTokens.php @@ -4,5 +4,10 @@ namespace Bluesquare\Connect\Traits; trait HasConnectTokens { - // + public function fillConnectTokens(array $data) + { + $this->connect_access_token = $data['access_token']; + $this->connect_refresh_token = $data['refresh_token']; + $this->connect_expires_at = $data['expires_at']; + } }