This commit is contained in:
Maxime 2022-05-18 17:40:18 +02:00
parent 7b3a698daa
commit 4156db2cdc
8 changed files with 201 additions and 387 deletions

View File

@ -13,18 +13,19 @@ return [
'login_url' => '/connect/authorize', 'login_url' => '/connect/authorize',
/** /**
* Use post-login remember cookie
*/
'login_remember' => true,
/**
* OAuth callback URL * OAuth callback URL
*/ */
'redirect_url' => env('BCONNECT_REDIRECT', 'http://localhost:8000/connect/callback'), 'redirect_url' => env('BCONNECT_REDIRECT', 'http://localhost:8000/connect/callback'),
/** /**
* OAuth client id * OAuth client identifiers
*/ */
'client_id' => env('BCONNECT_CLIENT_ID', null), 'client_id' => env('BCONNECT_CLIENT_ID', null),
/**
* OAuth client secret
*/
'client_secret' => env('BCONNECT_CLIENT_SECRET', null), 'client_secret' => env('BCONNECT_CLIENT_SECRET', null),
/** /**

View File

@ -0,0 +1,45 @@
<?php
namespace Bluesquare\Connect\Commands;
use Bluesquare\Connect\Connect;
use Bluesquare\Connect\ConnectException;
use Bluesquare\Connect\Traits\HasConnectTokens;
use Illuminate\Console\Command;
class RefreshTokens extends Command
{
protected $signature = 'connect:refresh';
protected $description = 'Refresh Bluesquare Connect tokens';
public function handle(Connect $connect)
{
$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) {
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;
}
}

View File

@ -3,41 +3,45 @@
namespace Bluesquare\Connect\Commands; namespace Bluesquare\Connect\Commands;
use Bluesquare\Connect\Connect; use Bluesquare\Connect\Connect;
use Bluesquare\Connect\ConnectException;
use Bluesquare\Connect\Traits\HasConnectTokens;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class Sync extends Command class Sync extends Command
{ {
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'connect:sync'; protected $signature = 'connect:sync';
/** protected $description = 'Sync Bluesquare Connect users';
* The console command description.
*
* @var string
*/
protected $description = 'Synchronize Bluesquare Connect resources';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle(Connect $connect) 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;
} }
} }

View File

@ -2,6 +2,7 @@
namespace Bluesquare\Connect; namespace Bluesquare\Connect;
use Bluesquare\Connect\Traits\HasConnectData;
use Bluesquare\Connect\Traits\HasConnectSync; use Bluesquare\Connect\Traits\HasConnectSync;
use Bluesquare\Connect\Traits\HasConnectTokens; use Bluesquare\Connect\Traits\HasConnectTokens;
use GuzzleHttp\Client; use GuzzleHttp\Client;
@ -14,59 +15,13 @@ use Psr\Http\Message\StreamInterface;
class Connect 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 $app;
protected $synchronized = [];
public function __construct($app) public function __construct($app)
{ {
$this->app = $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 // API
/** /**
@ -76,7 +31,7 @@ class Connect
* @return array * @return array
* @throws ConnectException * @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 = $this->getUrl();
$url = $url . '/' . trim($uri, '/'); $url = $url . '/' . trim($uri, '/');
@ -89,11 +44,8 @@ class Connect
] ]
]; ];
if ($auth === true) { if ($access_token !== null) {
$config['headers']['Authorization'] = 'Bearer ' . $this->getAccessToken(); $config['headers']['Authorization'] = 'Bearer ' . $access_token;
}
elseif ($auth !== false) {
$config['headers']['Authorization'] = 'Bearer ' . $auth;
} }
if (!is_null($data)) { if (!is_null($data)) {
@ -110,12 +62,13 @@ class Connect
return json_decode($body, true); return json_decode($body, true);
} catch(\Exception $e) { } catch(\Exception $e) {
$this->deleteAccessToken(); $this->flushTokens();
throw new ConnectException($e->getMessage()); throw new ConnectException($e->getMessage());
} }
} }
// OAuth (user) // Authorization flow
public function redirect($state = null) public function redirect($state = null)
{ {
@ -123,9 +76,12 @@ class Connect
$state = Str::random(); $state = Str::random();
$states = session()->get('connect_states'); $states = session()->get('connect_states');
if (!is_array($states)) if (!is_array($states))
$states = []; $states = [];
$states[] = $state; $states[] = $state;
session()->put('connect_states', $states); session()->put('connect_states', $states);
$query = http_build_query([ $query = http_build_query([
@ -137,80 +93,59 @@ class Connect
]); ]);
$url = $this->getUrl() . '/oauth/authorize?' . $query; $url = $this->getUrl() . '/oauth/authorize?' . $query;
return redirect()->to($url); return redirect()->to($url);
} }
public function checkState(Request $request) public function checkState(Request $request)
{ {
if (!session()->has('connect_states')) { if (!session()->has('connect_states'))
Log::debug("Missing session states");
return false; return false;
}
$states = session()->get('connect_states'); $states = session()->get('connect_states');
if (!is_array($states)) { if (!is_array($states))
Log::debug("Invalid session state");
return false; return false;
}
if (!$request->has('state') || !in_array($request->state, $states)) { if (!$request->has('state') || !in_array($request->state, $states))
Log::debug("Missing valid state in request");
return false; return false;
}
unset($states[array_search($request->state, $states)]); unset($states[array_search($request->state, $states)]);
session()->put('connect_states', $states); session()->put('connect_states', $states);
return true; return true;
} }
public function loginFromCallback(Request $request, $redirect_to = '/') public function loginFromCallback(Request $request, $redirect_to = '/')
{ {
if (!$this->checkState($request)) if (!$this->checkState($request) || !$request->has('code'))
return redirect('/'); return redirect('/');
// Code check
if (!$request->has('code')) {
Log::debug("Missing authorization code");
return redirect('/');
}
// Access token // Access token
$expires_at = now(); $expires_at = now();
$connect_data = $this->getAccessTokenFromAuthorizationCode($request->code); $connect_data = $this->getAccessTokenFromAuthorizationCode($request->code);
$connect_data['expires_at'] = $expires_at->addSeconds($connect_data['expires_in']); $connect_data['expires_at'] = $expires_at->addSeconds($connect_data['expires_in']);
$model_data = $this->getUserData($connect_data['access_token']); // User data
$model = config('bconnect.model');
if (in_array($model, $this->synchronized)) { $user_data = $this->getUserData($connect_data['access_token']);
$user = $model::findConnectResource($model_data['id']) ?? new $model; $user = $this->resolveUser($user_data);
}
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); $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); return redirect($redirect_to);
} }
// OAuth methods
public function getAccessTokenFromAuthorizationCode($code) public function getAccessTokenFromAuthorizationCode($code)
{ {
$data = $this->request('post', 'oauth/token', [ $data = $this->request('post', 'oauth/token', [
@ -220,7 +155,7 @@ class Connect
'scope' => config('bconnect.user_scopes'), 'scope' => config('bconnect.user_scopes'),
'redirect_uri' => config('bconnect.redirect_url'), 'redirect_uri' => config('bconnect.redirect_url'),
'code' => $code 'code' => $code
], false); ]);
return $data; return $data;
} }
@ -234,7 +169,7 @@ class Connect
'scope' => config('bconnect.user_scopes'), 'scope' => config('bconnect.user_scopes'),
'redirect_uri' => config('bconnect.redirect_url'), 'redirect_uri' => config('bconnect.redirect_url'),
'refresh_token' => $refresh_token 'refresh_token' => $refresh_token
], false); ]);
return $data; return $data;
} }
@ -244,164 +179,65 @@ class Connect
return $this->request('get', 'api/user', null, $access_token); 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)))) $class = get_class($model);
return false; $has_fields = in_array(HasConnectTokens::class, class_uses($class));
$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) { 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()) { if ($model->connect_expires_at <= now()->addHour()) {
$connect_data = $this->getAccessTokenFromRefreshToken($user->connect_refresh_token); $connect_data = $this->getAccessTokenFromRefreshToken($model->connect_refresh_token);
$this->updateUserConnectData($user, $connect_data); $this->updateUserConnectData($model, $connect_data);
return $connect_data['access_token']; 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) public function handleWebhook(Request $request)
{ {
$data = $request->validate([ $data = $request->validate([
'connectEventType' => 'required|in:created,updated,deleted', 'event_type' => 'required|in:created,updated,deleted,restored',
'connectResourceType' => 'required', 'connect_data' => 'required|array',
'connectResourceTable' => 'required', 'connect_data.id' => 'required'
'connectResourceData' => 'required|array',
'connectResourceData.id' => 'required'
]); ]);
if (!array_key_exists($data['connectResourceType'], $this->synchronized)) //@TODO
return false;
$model = $this->synchronized[$data['connectResourceType']]; return ['handled' => false];
$method = $this->getEventMethod($data['connectEventType']); }
$data = $data['connectResourceData']; public function updateUserConnectData($user, $data)
{
if (in_array(HasConnectTokens::class, class_uses(get_class($user))))
$user->fillConnectTokens($data);
}
try { public function updateUserData($user, $data)
$data = $this->get($data['connectResourceType'], $data['connectResourceData']['id']); {
if ($data['connectEventType'] == 'deleted') { if (in_array(HasConnectData::class, class_uses(get_class($user))))
abort(403, "This resource still exists."); $user->fillConnectData($data);
} }
} catch (\Exception $e) {
if ($data['connectEventType'] != 'deleted') { protected function resolveUser($data)
abort(404, "Could not retrieve this resource."); {
} $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); return $user;
$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 // Routing
@ -425,44 +261,14 @@ class Connect
// Misc // Misc
protected function resolveResourceType($class) protected function flushTokens()
{ {
if (in_array($class, $this->synchronized)) session()->forget('bconnect.access_token');
return array_flip($this->synchronized)[$class]; session()->forget('bconnect.access_token_expiration');
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() protected function getUrl()
{ {
return config('bconnect.url') ?? 'https://connect.bluesquare.io'; 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;
}
} }

View File

@ -2,70 +2,54 @@
namespace Bluesquare\Connect; namespace Bluesquare\Connect;
use Bluesquare\Connect\Commands\RefreshTokens;
use Bluesquare\Connect\Commands\Sync; use Bluesquare\Connect\Commands\Sync;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class ConnectServiceProvider extends ServiceProvider class ConnectServiceProvider extends ServiceProvider
{ {
/**
* Register any application services.
*
* @return void
*/
public function register() public function register()
{ {
// Config $this->mergeConfigFrom($this->path('src/config/bconnect.php'), 'bconnect');
$this->mergeConfigFrom(
__DIR__ . '/../config/bconnect.php',
'bconnect'
);
// Singletons
$this->app->singleton(Connect::class, function ($app) { $this->app->singleton(Connect::class, function ($app) {
return new Connect($app); return new Connect($app);
}); });
} }
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot() public function boot()
{ {
// Config $config_path = $this->path('src/config/bconnect.php');
$views_path = $this->path('resources/views/connect');
$this->publishes([ $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 if ($this->app->runningInConsole()) {
$this->commands([
$this->loadViewsFrom(__DIR__.'/../resources/views/connect', 'connect'); RefreshTokens::class,
Sync::class,
$this->publishes([ ]);
__DIR__.'/../resources/views/connect' => resource_path('views/vendor/connect'), }
]);
// Laravel 7+
if (method_exists($this, 'loadViewComponentsAs')) { if (method_exists($this, 'loadViewComponentsAs')) {
// Laravel 7+
$this->loadViewComponentsAs('connect', [ $this->loadViewComponentsAs('connect', [
\Bluesquare\Connect\View\Components\Button::class \Bluesquare\Connect\View\Components\Button::class
]); ]);
} }
}
// Commands // Misc
if ($this->app->runningInConsole()) { private function path($path = '')
$this->commands([ {
Sync::class return __DIR__ . "/../../$path";
]);
}
} }
} }

View File

@ -0,0 +1,23 @@
<?php
namespace Bluesquare\Connect\Traits;
trait HasConnectData
{
public static $connectIdentifier = 'connect_id';
protected $connectFillable = [];
public function fillConnectData(array $data)
{
foreach ($this->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);
}

View File

@ -1,54 +0,0 @@
<?php
namespace Bluesquare\Connect\Traits;
trait HasConnectSync
{
abstract function fill(array $attributes);
abstract function save();
abstract function delete();
public static $connectResource;
public static $connectColumnId = 'connect_resource_id';
public static function findConnectResource($id)
{
return self::query()->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;
}
}

View File

@ -4,5 +4,10 @@ namespace Bluesquare\Connect\Traits;
trait HasConnectTokens 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'];
}
} }