'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 /** * @param $method * @param $uri * @param null $data * @return array * @throws ConnectException */ public function request($method, $uri, $data = null, $auth = true): array { $url = $this->getUrl(); $url = $url . '/' . trim($uri, '/'); $client = new Client(); $config = [ 'headers' => [ 'Accept' => 'application/json' ] ]; if ($auth === true) { $config['headers']['Authorization'] = 'Bearer ' . $this->getAccessToken(); } elseif ($auth !== false) { $config['headers']['Authorization'] = 'Bearer ' . $auth; } if (!is_null($data)) { $config['form_params'] = $data; } try { $response = $client->request($method, $url, $config); $body = (string) $response->getBody(); if ($response->getStatusCode() !== 200) throw new ConnectException($body); return json_decode($body, 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_url'), 'response_type' => 'code', 'state' => $state ]); $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"); return false; } $states = session()->get('connect_states'); if (!is_array($states)) { Log::debug("Invalid session state"); return false; } if (!$request->has('state') || !in_array($request->state, $states)) { Log::debug("Missing valid state in request"); 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)) 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'); 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']); $this->updateUserConnectData($user, $connect_data); auth()->login($user, true); return redirect($redirect_to); } public function getAccessTokenFromAuthorizationCode($code) { $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'), 'redirect_uri' => config('bconnect.redirect_url'), 'code' => $code ], false); return $data; } public function getAccessTokenFromRefreshToken($refresh_token) { $data = $this->request('post', 'oauth/token', [ 'grant_type' => 'refresh_token', 'client_id' => config('bconnect.client_id'), 'client_secret' => config('bconnect.client_secret'), 'scope' => config('bconnect.user_scopes'), 'redirect_uri' => config('bconnect.redirect_url'), 'refresh_token' => $refresh_token ], false); return $data; } public function getUserData($access_token) { return $this->request('get', 'api/user', null, $access_token); } public function updateUserConnectData($user, $data) { 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))); if (!$has_fields) { throw new ConnectException("User class does not implement HasConnectTokens"); } if ($user->connect_expires_at <= now()) { $connect_data = $this->getAccessTokenFromRefreshToken($user->connect_refresh_token); $this->updateUserConnectData($user, $connect_data); return $connect_data['access_token']; } return $user->connect_access_token; } // 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'); } // 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' ]); if (!array_key_exists($data['connectResourceType'], $this->synchronized)) return false; $model = $this->synchronized[$data['connectResourceType']]; $method = $this->getEventMethod($data['connectEventType']); $data = $data['connectResourceData']; 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."); } } $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); } // Routing public function routes() { Route::namespace('\Bluesquare\Connect\Controllers') ->group(function () { Route::get('connect/authorize', 'ConnectController@authorize'); Route::get('connect/callback', 'ConnectController@callback'); }); } public function apiRoutes() { Route::namespace('\Bluesquare\Connect\Controllers') ->group(function () { Route::post('connect/webhook', 'ConnectController@webhook'); }); } // Misc protected function resolveResourceType($class) { 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); } 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; } }