Compare commits

..

No commits in common. "master" and "1.2" have entirely different histories.
master ... 1.2

11 changed files with 546 additions and 489 deletions

306
README.md
View File

@ -1,217 +1,213 @@
# laravel-connect # laravel-connect
Ce package permet d'utiliser [Bluesquare Connect](https://connect.bluesquare.io) comme méthode d'authentification grâce au protocole OAuth 2.0. The Bluesquare Connect package allows you to use its OAuth server and sync its resources.
## Installation ## Installation
Mettre à jour les sources de `composer.json` avec : Update your `composer.json`:
``` ```
composer config repositories.connect vcs https://git.bluesquare.io/bluesquare/laravel-connect.git -n "repositories": [
{
"type": "vcs",
"url": "https://git.bluesquare.io/bluesquare/laravel-connect"
}
]
``` ```
Puis installer le package : Install the package:
```bash ```bash
composer require bluesquare/laravel-connect "2.3" composer require bluesquare/laravel-connect "1.2"
``` ```
Mettre à jour le `.env` avec les identifiants du client OAuth généré sur [Bluesquare Connect](https://connect.bluesquare.io) : Finally, update your `.env` with your client's credentials:
```bash ```bash
BCONNECT_CLIENT_ID=your_client_id BCONNECT_CLIENT_ID=your_client_id
BCONNECT_CLIENT_SECRET=your_client_secret BCONNECT_CLIENT_SECRET=your_client_secret
BCONNECT_REDIRECT="${APP_URL}/connect/callback" BCONNECT_REDIRECT=http://localhost:8000/connect/callback
``` ```
Et optionnellement : ### Sign in with Bluesquare Connect
```bash Follow there instructions to add Bluesquare Connect's authentication to your app.
# URL du serveur OAuth
BCONNECT_URL=https://connect.bluesquare.io Update your `routes/web.php`:
# Données demandées (scopes)
BCONNECT_USER_SCOPES=profile
``` ```
Il est possible de modifier d'autres comportements, à savoir :
- Model utilisateur utilisé (par défaut `App\Models\User`)
- URL de connexion via Bluesquare Connect (par défaut `/connect/authorize`)
- Remember me : garder la session active post-connexion (par défaut `true`)
Pour cela, il suffit de modifier le fichier `config/bconnect.php` généré via la commande `php artisan vendor:publish`.
### Configuration minimale de `User`
À moins d'utiliser le trait `HasConnectData` (voir ci-dessous), il faut s'assurer que toutes les colonnes autres que `email` sont `nullable` dans la table `users` (y compris `password`).
La colonne `email` est alors utilisée comme identifiant de référence par Bluesquare Connect.
### Connexion
Pour activer la connexion via Bluesquare Connect, il faut mettre à jour `web.php` en y ajoutant :
```php
use Bluesquare\Connect\Facades\Connect;
Connect::routes(); Connect::routes();
``` ```
Si Blade est utilisé, un bouton "Connexion via Bluesquare Connect" peut être ajouté ainsi : Add the "Sign in with Bluesquare Connect" button in your blade login page:
```blade ```blade
<x-connect-button/> <x-connect-button/>
``` ```
Sinon il suffit de rediriger vers `/connect/authorize` pour déclencher le flux. Make sure that the `password` column of users table is nullable.
### Configuration personnalisée de `User` #### Keep user tokens (optional)
Le trait `HasConnectData` permet d'exploiter au maximum les données fournies par Bluesquare Connect. Notamment : First, make sure that your model implements `HasConnectTokens` trait.
- Définir quelle colonne sert d'identifiant unique (par défaut `connect_id`) avec `$connectIdentifier` ;
- Définir quelles données sont à injectées dans le Model à la connexion, avec `$connectFillable`
Pour utiliser l'identifiant par défaut, il convient d'ajouter cette colonne à `User` : Then, add the following columns to your table:
```php
$table->string('connect_id')->nullable();
``` ```
Ou alors, et pour revenir à l'utilisation de `email` comme identifiant :
```php
use HasConnectData;
protected $connectIdentifier = 'email';
```
Ensuite, pour injecter automatiquement les données fournies par Bluesquare Connect (liste indicative ci-dessous) :
```php
protected $connectFillable = [
'name',
'email'
];
```
Il est possible de mapper les colonnes avec des noms différents et des valeurs par defaut, voire même d'injecter dans plusieurs colonnes, comme ceci :
```php
protected $connectIdentifier = ['connect_id' => 'oauth_id'];
protected $connectFillable = [
'name' => 'fullname|Anonymous',
'firstname' => ['firstname', 'nickname'],
'email' => 'email_address',
'group' => 'role|user'
];
```
Un hook est disponible pour un post-traitement des données Connect (après création / mise à jour de l'utilisateur) ce qui peut être utile pour mettre à jour des relations :
```php
use HasConnectData;
public function postFillConnectData(array $data)
{
// Exemple
$this->roles()->sync(
Role::whereIn('name', $data['groups'])->pluck('id')
);
}
```
## Synchronisation des données _(optionnel)_
Par défaut, les données des utilisateurs sont mises à jour à chaque nouvelle connexion.
Cependant, deux méthodes peuvent être utilisées pour conserver les données de tous les utilisateurs à jour sans reconnexion.
### 1. Synchronisation passive
Le trait `HasConnectTokens` permet de conserver les tokens OAuth pour une utilisation future (par exemple, pour synchroniser les utilisateurs périodiquement).
Avant tout, il faut ajouter ces colonnes dans la table `users` :
```php
$table->text('connect_access_token')->nullable(); $table->text('connect_access_token')->nullable();
$table->text('connect_refresh_token')->nullable(); $table->text('connect_refresh_token')->nullable();
$table->datetime('connect_expires_at')->nullable(); $table->dateTime('connect_expires_at')->nullable();
``` ```
Il est ensuite possible de mettre à jour les utilisateurs avec cette commande : ### Database syncing
Follow these instructions to sync your database with Bluesquare Connect.
In your `AppServiceProvider`, specify in the `boot()` function which entities you want to sync:
```
$connect->setSynchronized([
Role::class,
Company::class,
Team::class,
User::class,
UserTeam::class
]);
```
Your models must use `HasConnectSync` trait. This trait allows you to customize the syncing behavior.
You also need to add this column to your synced tables:
```
$table->unsignedBigInteger('connect_resource_id')->unique();
```
_You can customize this column name and the syncing behavior in your model. Take a look at `HasConnectSync`._
Finally, use this command to sync everything:
```bash ```bash
php artisan connect:sync php artisan connect:sync
``` ```
Mais aussi de seulement mettre à jour les tokens avant expiration avec cette commande : #### Live updates (optional)
```bash First, configure a webhook on Bluesquare Connect :
php artisan connect:refresh
```
Il est conseillé d'appeler ces commandes périodiquement via une tâche CRON dans `Console/Kernel.php`.
### 2. Synchronisation active (webhook)
Avant tout, il est requis de créer une `App` liée au client utilisé sur Bluesquare Connect, et d'en activer le webhook.
Ensuite, pour pouvoir utiliser le webhook, il faut ajouter ceci à `routes/api.php`:
```
use Bluesquare\Connect\Facades\Connect;
Connect::apiRoutes();
```
Cela va permettre à Bluesquare Connect d'appeler cette URL à chaque modification d'un utilisateur :
``` ```
https://your-app.com/api/connect/webhook https://your-app.com/api/connect/webhook
``` ```
Pour terminer, il suffit d'ajouter le trait `HasConnectWebhook` à `User`. Then, update your `routes/api.php`:
#### Configurer le comportement du webhook ```
Connect::apiRoutes();
```
Par défaut, le webhook se comporte ainsi : ## Advanced usage
- Lorsqu'un utilisateur est créé, il est inséré dans `users` comme à la première connexion ;
- Lorsqu'un utilisateur est mis à jour, il est modifié comme à chaque connexion / synchronisation ;
- Lorsqu'un utilisateur est supprimé, deux cas se présentent :
- si `User` implémente `SoftDeletes`, alors le webhook fait appel à `$user->delete()` ;
- si `SoftDeletes` est absent, alors la session utilisateur est simplement expirée en repassant `remember_token` à `null`.
- Si l'utilisateur est restauré, deux cas se présentent :
- si `User` implémente `SoftDeletes` et que l'utilisateur est bien présent en base de données, alors le webhook fait appel à `$user->restore()` ;
- si `SoftDeletes` est absent ou que l'utilisateur n'existe pas, alors on recréé l'utilisateur (s'il existe, il est simplement mis à jour).
Il est recommandé de personnaliser le cas de la suppression en ajoutant la fonction suivante à `User.php` : ### OAuth (sign in)
```php #### Authorization
public function onConnectDelete(array $data)
Redirect to Bluesquare Connect authorization page:
```
public function authorize(Connect $connect)
{ {
// Optionnel : avec cette fonction on met à jour une dernière fois l'utilisateur return $connect->redirect($optional_custom_state);
$this->onConnectUpdate($data);
//...supprimer l'utilisateur comme souhaité...
} }
``` ```
Les autres cas peuvent être personnalisés avec ces différentes fonctions : #### Authorization callback
```php Auto: check state, login and redirect
public function onConnectCreate(array $data);
public function onConnectUpdate(array $data); ```
public function onConnectRestore(array $data); public function callback(Request $request, Connect $connect)
{
return $connect->loginFromCallback($request, $optional_redirect_to);
}
``` ```
## Données fournies par Bluesquare Connect Manual: check state
- `id` et `connect_id` : identifiant unique de l'utilisateur ```
- `email` : adresse e-mail public function callback(Request $request, Connect $connect)
- `name` : nom complet {
- `firstname` et `nickname` : prénom $valid = $connect->checkState($request);
- `lastname` : nom de famille // ...
- `avatar`, `picture` et `profile_picture` : URL de la photo de profil }
- `company` : entreprise ```
- `job` : mêtier exercé dans l'entreprise
- `group` : rôle principal (les groupes sont personnalisées pour chaque App sur Bluesquare Connect) #### Tokens management
- `groups` : tableau contenant tous les rôles associés à l'utilisateur
```
// Retrieve tokens from an authorization code
$connect_data = $connect->getAccessTokenFromAuthorizationCode($code);
// Retrieve tokens from a refresh token
$connect_data = $connect->getAccessTokenFromRefreshToken($connect_data['refresh_token']);
// With HasConnectTokens trait: get your local user tokens
$connect->getUserAccessToken($user);
```
#### User data
```
// Retrieve user data from an access token
$user_data = $connect->getUserData($connect_data['access_token']);
// Example: find the corresponding user in your database
$user = User::where('email', $user_data['email'])->first();
```
### OAuth (client)
#### Token management
```
// Get an access token
$connect->getAccessToken();
// Delete the current access token from cache
$connect->deleteAccessToken();
```
#### API resources
```
// Fetch all users
$connect->getAll('User');
// Fetch an user
$connect->get('User', 1);
```
#### Syncing
```
// Sync everything
$optional_resource_types = ['User', ...];
$connect->syncAll($optional_resource_types);
// Sync a specific resource
$connect->sync('User', 1);
```
### Webhook
```
// Handle a webhook request
$connect->handleWebhook($request);
```
### Configuration
Publish our config file (`config/bconnect.php`) to customize the package configuration:
```bash
php artisan vendor:publish
```

View File

@ -13,19 +13,18 @@ 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 identifiers * OAuth client id
*/ */
'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

@ -1,45 +0,0 @@
<?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,50 +3,41 @@
namespace Bluesquare\Connect\Commands; namespace Bluesquare\Connect\Commands;
use Bluesquare\Connect\Connect; use Bluesquare\Connect\Connect;
use Bluesquare\Connect\ConnectException;
use Bluesquare\Connect\Traits\HasConnectData;
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)
{ {
$this->call('connect:refresh'); $connect->syncAll();
$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();
if (in_array(HasConnectData::class, class_uses($model))) {
$model->postFillConnectData($data);
}
}
}
catch (\Exception $exception) {
$this->warn("Failed to sync model data", $model->toArray());
}
});
});
$this->info("Models synced");
return 0;
} }
} }

View File

@ -2,11 +2,9 @@
namespace Bluesquare\Connect; namespace Bluesquare\Connect;
use Bluesquare\Connect\Traits\HasConnectData; use Bluesquare\Connect\Traits\HasConnectSync;
use Bluesquare\Connect\Traits\HasConnectTokens; use Bluesquare\Connect\Traits\HasConnectTokens;
use Bluesquare\Connect\Traits\HasConnectWebhook;
use GuzzleHttp\Client; use GuzzleHttp\Client;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Router; use Illuminate\Routing\Router;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@ -16,13 +14,59 @@ use Psr\Http\Message\StreamInterface;
class Connect class Connect
{ {
protected $app; protected static $resources = [
'Role',
'Company',
'Team',
'User',
'UserTeam'
];
public function __construct(Application $app) 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; $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
/** /**
@ -32,7 +76,7 @@ class Connect
* @return array * @return array
* @throws ConnectException * @throws ConnectException
*/ */
public function request($method, $uri, $data = null, $access_token = null): array public function request($method, $uri, $data = null, $auth = true): array
{ {
$url = $this->getUrl(); $url = $this->getUrl();
$url = $url . '/' . trim($uri, '/'); $url = $url . '/' . trim($uri, '/');
@ -45,8 +89,11 @@ class Connect
] ]
]; ];
if (! is_null($access_token)) { if ($auth === true) {
$config['headers']['Authorization'] = 'Bearer ' . $access_token; $config['headers']['Authorization'] = 'Bearer ' . $this->getAccessToken();
}
elseif ($auth !== false) {
$config['headers']['Authorization'] = 'Bearer ' . $auth;
} }
if (!is_null($data)) { if (!is_null($data)) {
@ -63,13 +110,12 @@ class Connect
return json_decode($body, true); return json_decode($body, true);
} catch(\Exception $e) { } catch(\Exception $e) {
$this->flushTokens(); $this->deleteAccessToken();
throw new ConnectException($e->getMessage()); throw new ConnectException($e->getMessage());
} }
} }
// Authorization flow // OAuth (user)
public function redirect($state = null) public function redirect($state = null)
{ {
@ -77,12 +123,9 @@ 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([
@ -94,80 +137,106 @@ class Connect
]); ]);
$url = $this->getUrl() . '/oauth/authorize?' . $query; $url = $this->getUrl() . '/oauth/authorize?' . $query;
return redirect()->to($url);
return redirect($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) || ! $request->has('code')) if (!$this->checkState($request))
return redirect('/'); return redirect('/');
// Access token // Code check
if (!$request->has('code')) {
Log::debug("Missing authorization code");
return redirect('/');
}
// Access token
$expires_at = now(); $expires_at = now();
$connect_data = $this->getAccessTokenFromAuthorizationCode($request->get('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']);
// User data $model_data = $this->getUserData($connect_data['access_token']);
$model = config('bconnect.model');
$user_data = $this->getUserData($connect_data['access_token']); 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 = $this->sync('create', $user_data);
$this->updateUserConnectData($user, $connect_data);
$user->save(); $user->save();
// Login if (in_array($model, $this->synchronized))
$user = $model::findConnectResource($model_data['id']);
auth()->login($user, config('bconnect.login_remember', true)); $this->updateUserConnectData($user, $connect_data);
auth()->login($user, true);
return redirect($redirect_to); return redirect($redirect_to);
} }
// OAuth methods
public function getAccessTokenFromAuthorizationCode($code) public function getAccessTokenFromAuthorizationCode($code)
{ {
return $this->request('post', 'oauth/token', [ $data = $this->request('post', 'oauth/token', [
'grant_type' => 'authorization_code', 'grant_type' => 'authorization_code',
'client_id' => config('bconnect.client_id'), 'client_id' => config('bconnect.client_id'),
'client_secret' => config('bconnect.client_secret'), 'client_secret' => config('bconnect.client_secret'),
'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;
} }
public function getAccessTokenFromRefreshToken($refresh_token) public function getAccessTokenFromRefreshToken($refresh_token)
{ {
return $this->request('post', 'oauth/token', [ $data = $this->request('post', 'oauth/token', [
'grant_type' => 'refresh_token', 'grant_type' => 'refresh_token',
'client_id' => config('bconnect.client_id'), 'client_id' => config('bconnect.client_id'),
'client_secret' => config('bconnect.client_secret'), 'client_secret' => config('bconnect.client_secret'),
'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;
} }
public function getUserData($access_token) public function getUserData($access_token)
@ -175,102 +244,164 @@ class Connect
return $this->request('get', 'api/user', null, $access_token); return $this->request('get', 'api/user', null, $access_token);
} }
public function getUserAccessToken($model) public function updateUserConnectData($user, $data)
{ {
$class = get_class($model); if (!in_array(HasConnectTokens::class, class_uses(get_class($user))))
$has_fields = in_array(HasConnectTokens::class, class_uses($class)); return false;
if (! $has_fields) { $user->connect_access_token = $data['access_token'];
throw new ConnectException("$class does not implement HasConnectTokens"); $user->connect_refresh_token = $data['refresh_token'];
$user->connect_expires_at = $data['expires_at'];
return $user->save();
} }
if ($model->connect_expires_at <= now()->addHour()) { public function getUserAccessToken($user)
$connect_data = $this->getAccessTokenFromRefreshToken($model->connect_refresh_token); {
$this->updateUserConnectData($model, $connect_data); $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 $connect_data['access_token'];
} }
return $model->connect_access_token; return $user->connect_access_token;
} }
// Sync // OAuth (client)
public function sync(string $event, array $data) public function getAccessToken()
{ {
$class = config('bconnect.model'); $access_token = cache()->get('bconnect.access_token');
$access_token_expiration = cache()->get('bconnect.access_token_expiration');
if (in_array($event, ['update', 'delete'])) { if ($access_token && $access_token_expiration > time() + 60) {
$model = $this->resolveUser($data); return $access_token;
if (! $model->exists() && $event === 'delete')
return $model;
}
else {
$hasSoftDeletes = in_array(\Illuminate\Database\Eloquent\SoftDeletes::class, class_uses($class));
$model = $this->resolveUser($data, $hasSoftDeletes);
if ($model->exists()) {
if ($hasSoftDeletes && $model->trashed()) {
$event = 'restore';
} else {
$event = 'update';
}
}
} }
if (in_array(HasConnectWebhook::class, class_uses($class))) { $data = $this->request('post', '/oauth/token', [
$method = 'onConnect' . ucfirst($event); 'grant_type' => 'client_credentials',
$model->$method($data); 'client_id' => config('bconnect.client_id'),
} else { 'client_secret' => config('bconnect.client_secret'),
$this->updateUserData($model, $data); 'scope' => config('bconnect.client_scopes')
$model->save(); ], false);
if (in_array(HasConnectData::class, class_uses($model))) { cache()->set('bconnect.access_token', $data['access_token']);
$model->postFillConnectData($data); cache()->set('bconnect.access_token_expiration', time() + $data['expires_in']);
}
return $data['access_token'];
} }
return $model; public function deleteAccessToken()
}
public function updateUserConnectData($user, $data)
{ {
if (in_array(HasConnectTokens::class, class_uses(get_class($user)))) cache()->delete('bconnect.access_token');
$user->fillConnectTokens($data); cache()->delete('bconnect.access_token_expiration');
} }
public function updateUserData($user, $data) // Webhook handler
/**
* @param Request $request
* @return bool
*/
public function handleWebhook(Request $request)
{ {
if (in_array(HasConnectData::class, class_uses(get_class($user)))) $data = $request->validate([
$user->fillConnectData($data); '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.");
}
} }
protected function resolveUser($data, $withTrashed = false) $data = $this->convertForeignKeys($data);
$model::$method($data['id'], $data);
return true;
}
// Resources getters
public function getAll($resourceType)
{ {
$class = config('bconnect.model'); return $this->request('get', "api/resources/$resourceType");
$query = $class::query();
if ($withTrashed)
$query->withTrashed();
$model = new $class;
if (in_array(HasConnectData::class, class_uses($class))) {
$id = $model->getConnectIdentifier();
$origin = is_array($id) ? $id[0] : $id;
$target = is_array($id) ? $id[1] : $id;
$model = $query->where($target, $data[$origin])->first() ?? $model;
$model->$target = $data[$origin];
} else {
$model = $query->where('email', $data['email'])->first() ?? $model;
if (! $model->exists())
$model->email = $data['email'];
} }
return $model; 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
@ -294,14 +425,44 @@ class Connect
// Misc // Misc
protected function flushTokens() protected function resolveResourceType($class)
{ {
session()->forget('bconnect.access_token'); if (in_array($class, $this->synchronized))
session()->forget('bconnect.access_token_expiration'); 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() 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,54 +2,70 @@
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()
{ {
$this->mergeConfigFrom($this->path('config/bconnect.php'), 'bconnect'); // Config
$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_path = $this->path('config/bconnect.php'); // Config
$views_path = $this->path('resources/views/connect');
$this->publishes([ $this->publishes([
$config_path => config_path('bconnect.php'), __DIR__ . '/../config/bconnect.php' => config_path('bconnect.php')
$views_path => resource_path('views/vendor/connect'),
]); ]);
$this->loadTranslationsFrom($this->path('resources/translations'), 'connect'); // Translations
$this->loadViewsFrom($this->path('resources/views/connect'), 'connect'); $this->loadTranslationsFrom(__DIR__.'/../resources/translations', 'connect');
if ($this->app->runningInConsole()) { // Views
$this->commands([
RefreshTokens::class, $this->loadViewsFrom(__DIR__.'/../resources/views/connect', 'connect');
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
]); ]);
} }
}
// Misc // Commands
private function path($path = '') if ($this->app->runningInConsole()) {
{ $this->commands([
return __DIR__ . "/../$path"; Sync::class
]);
}
} }
} }

View File

@ -20,19 +20,6 @@ class ConnectController extends Controller
public function webhook(Request $request, Connect $connect) public function webhook(Request $request, Connect $connect)
{ {
$hash = sha1(config('bconnect.client_secret') . date('Y-m-d')); return $connect->handleWebhook($request);
if ($request->header('x-connect-hash') !== $hash)
abort(403);
$data = $request->validate([
'event_type' => 'required|in:create,update,delete',
'connect_data' => 'required|array',
'connect_data.*' => 'nullable',
'connect_data.id' => 'required',
'connect_data.email' => 'required_if:event_type,create|required_if:event_type,update',
]);
$connect->sync($data['event_type'], $data['connect_data']);
} }
} }

View File

@ -1,52 +0,0 @@
<?php
namespace Bluesquare\Connect\Traits;
use Illuminate\Support\Facades\Log;
trait HasConnectData
{
public function getConnectIdentifier()
{
return $this->connectIdentifier ?? 'connect_id';
}
public function fillConnectData(array $data)
{
$touched = [];
$fillable = $this->connectFillable ?? [];
foreach ($fillable as $origin => $targets) {
$value = is_string($origin) ? $data[$origin] : $data[$targets];
$targets = is_string($origin) && is_array($targets) ? $targets : [$targets];
foreach ($targets as $target) {
$parts = explode('|', $target);
$target = $parts[0];
$currentValue = $value ?? ($parts[1] ?? null);
$target_model = $this;
$parts = explode('.', $target);
foreach ($parts as $i => $property) {
if ($i < count($parts) - 1) {
$target_model = $target_model->$property;
continue;
}
if ($target_model !== $this)
$touched[] = $target_model;
$target_model->$property = $currentValue;
}
}
}
foreach ($touched as $model)
$model->save();
}
public function postFillConnectData(array $data)
{
// Intended for post-processing / relationship handling
}
}

View File

@ -0,0 +1,54 @@
<?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,10 +4,5 @@ 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'];
}
} }

View File

@ -1,45 +0,0 @@
<?php
namespace Bluesquare\Connect\Traits;
trait HasConnectWebhook
{
public function onConnectCreate(array $data)
{
$this->onConnectUpdate($data);
}
public function onConnectUpdate(array $data)
{
if (in_array(HasConnectData::class, class_uses(self::class))) {
$this->fillConnectData($data);
} else {
$this->email = $data['email'];
}
$this->save();
if (in_array(HasConnectData::class, class_uses(self::class))) {
$this->postFillConnectData($data);
}
}
public function onConnectDelete(array $data)
{
$this->onConnectUpdate($data);
if (in_array(\Illuminate\Database\Eloquent\SoftDeletes::class, class_uses(self::class))) {
$this->delete();
} elseif (array_key_exists('remember_token', $this->attributes)) {
$this->remember_token = null;
$this->save();
}
}
public function onConnectRestore(array $data)
{
$this->restore();
$this->onConnectUpdate($data);
}
}