├── .gitignore ├── .php-cs-fixer.php ├── LICENSE ├── composer.json ├── config └── gdpr.php ├── readme.md ├── routes └── web.php └── src ├── Console └── Commands │ └── Cleanup.php ├── Contracts └── Portable.php ├── EncryptsAttributes.php ├── Events ├── GdprDownloaded.php ├── GdprInactiveUser.php └── GdprInactiveUserDeleted.php ├── GdprServiceProvider.php ├── Http ├── Controllers │ └── GdprController.php └── Requests │ └── GdprDownload.php ├── Jobs └── Cleanup │ ├── CleanupJob.php │ ├── CleanupStrategy.php │ └── Strategies │ └── DefaultStrategy.php ├── Portable.php └── Retentionable.php /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .php-cs-fixer.cache 3 | /vendor 4 | composer.lock 5 | Thumbs.db 6 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | exclude($excluded_folders) 11 | ->in(__DIR__); 12 | 13 | return (new PhpCsFixer\Config()) 14 | ->setRules([ 15 | '@Symfony' => true, 16 | 'binary_operator_spaces' => [ 17 | 'operators' => [ 18 | '=>' => 'align_single_space_minimal', 19 | ], 20 | ], 21 | 'array_syntax' => ['syntax' => 'short'], 22 | 'linebreak_after_opening_tag' => true, 23 | 'not_operator_with_successor_space' => true, 24 | 'ordered_imports' => ['sort_algorithm' => 'length'], 25 | 'phpdoc_order' => true, 26 | 'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline'], 27 | ]) 28 | ->setFinder($finder) 29 | ; 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Sander de Vos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "soved/laravel-gdpr", 3 | "description": "GDPR compliance with ease", 4 | "keywords": [ 5 | "laravel", 6 | "gdpr", 7 | "data-portability", 8 | "soved", 9 | "data-retention", 10 | "encryption" 11 | ], 12 | "type": "library", 13 | "require": { 14 | "php": ">=7.0.0", 15 | "laravel/framework": "~5.5|~6.0|~7.0|~8.0|^9.0|^10.0|^11.0|^12.0" 16 | }, 17 | "autoload": { 18 | "psr-4": { 19 | "Soved\\Laravel\\Gdpr\\": "src/" 20 | } 21 | }, 22 | "license": "MIT", 23 | "authors": [ 24 | { 25 | "name": "Sander de Vos", 26 | "email": "sander@tutanota.de" 27 | } 28 | ], 29 | "minimum-stability": "stable", 30 | "extra": { 31 | "laravel": { 32 | "providers": [ 33 | "Soved\\Laravel\\Gdpr\\GdprServiceProvider" 34 | ] 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /config/gdpr.php: -------------------------------------------------------------------------------- 1 | 'gdpr', 15 | 16 | /* 17 | |-------------------------------------------------------------------------- 18 | | Route Middleware 19 | |-------------------------------------------------------------------------- 20 | | 21 | | These middleware are run during every request to the GDPR routes. Please 22 | | keep in mind to only allow authenticated users to access the routes. 23 | | 24 | */ 25 | 26 | 'middleware' => [ 27 | 'web', 28 | 'auth', 29 | ], 30 | 31 | /* 32 | |-------------------------------------------------------------------------- 33 | | Re-authentication 34 | |-------------------------------------------------------------------------- 35 | | 36 | | Only authenticated users should be able to download their data. 37 | | Re-authentication is recommended to prevent information leakage. 38 | | 39 | */ 40 | 41 | 're-authenticate' => true, 42 | 43 | /* 44 | |-------------------------------------------------------------------------- 45 | | Cleanup Strategy 46 | |-------------------------------------------------------------------------- 47 | | 48 | | This strategy will be used to clean up inactive users. Do not forget to 49 | | mention these thresholds in your terms and conditions. 50 | | 51 | */ 52 | 53 | 'cleanup' => [ 54 | 'strategy' => 'Soved\Laravel\Gdpr\Jobs\Cleanup\Strategies\DefaultStrategy', 55 | 56 | 'defaultStrategy' => [ 57 | /* 58 | * The number of months for which inactive users must be kept. 59 | */ 60 | 'keepInactiveUsersForMonths' => 6, 61 | 62 | /* 63 | * The number of days before deletion at which inactive users will be notified. 64 | */ 65 | 'notifyUsersDaysBeforeDeletion' => 14, 66 | ], 67 | ], 68 | ]; 69 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # GDPR compliance with ease 2 | 3 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/sander3/laravel-gdpr/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/sander3/laravel-gdpr/?branch=master) 4 | [![Latest Stable Version](https://poser.pugx.org/soved/laravel-gdpr/v/stable)](https://packagist.org/packages/soved/laravel-gdpr) 5 | [![Monthly Downloads](https://poser.pugx.org/soved/laravel-gdpr/d/monthly)](https://packagist.org/packages/soved/laravel-gdpr) 6 | [![License](https://poser.pugx.org/soved/laravel-gdpr/license)](https://packagist.org/packages/soved/laravel-gdpr) 7 | 8 | This package exposes an endpoint where authenticated users can download their data as required by GDPR article 20. This package also provides you with a trait to easily [encrypt personal data](#encryption) and a strategy to [clean up inactive users](#data-retention) as required by GDPR article 5e. 9 | 10 | [Buy me a coffee ☕️](https://www.buymeacoffee.com/sander3) 11 | 12 | ## Requirements 13 | 14 | - PHP 7.0 or higher 15 | - Laravel 5.5+ (6.0, 7.0, 8.0) 16 | 17 | ## Installation 18 | 19 | First, install the package via the Composer package manager: 20 | 21 | ```bash 22 | $ composer require soved/laravel-gdpr 23 | ``` 24 | 25 | After installing the package, you should publish the configuration file: 26 | 27 | ```bash 28 | $ php artisan vendor:publish --tag=gdpr-config 29 | ``` 30 | 31 | Finally, add the `Soved\Laravel\Gdpr\Portable` trait to the `App\User` model and implement the `Soved\Laravel\Gdpr\Contracts\Portable` contract: 32 | 33 | ```php 34 | toArray(); 77 | 78 | // Customize array... 79 | 80 | return $array; 81 | } 82 | } 83 | 84 | ``` 85 | 86 | ### Lazy Eager Loading Relationships 87 | 88 | You may need to include a relationship in the data that will be made available for download. To do so, add a `$gdprWith` property to your `App\User` model: 89 | 90 | ```php 91 | Before using encryption, you must set a `key` option in your `config/app.php` configuration file. If this value is not properly set, all encrypted values will be insecure. 174 | 175 | You may encrypt/decrypt attributes on the fly using the `Soved\Laravel\Gdpr\EncryptsAttributes` trait on any model. The trait expects the `$encrypted` property to be filled with attribute keys: 176 | 177 | ```php 178 | name('gdpr.download'); 6 | -------------------------------------------------------------------------------- /src/Console/Commands/Cleanup.php: -------------------------------------------------------------------------------- 1 | info('CleanupJob dispatched'); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Contracts/Portable.php: -------------------------------------------------------------------------------- 1 | encrypted) && 19 | ! is_null($value)) { 20 | return decrypt($value); 21 | } 22 | 23 | return $value; 24 | } 25 | 26 | /** 27 | * Set a given attribute on the model. 28 | * 29 | * @param string $key 30 | * @param mixed $value 31 | * 32 | * @return $this 33 | */ 34 | public function setAttribute( 35 | $key, 36 | $value 37 | ) { 38 | if (in_array($key, $this->encrypted) && 39 | ! is_null($value)) { 40 | $value = encrypt($value); 41 | } 42 | 43 | parent::setAttribute($key, $value); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Events/GdprDownloaded.php: -------------------------------------------------------------------------------- 1 | user = $user; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Events/GdprInactiveUser.php: -------------------------------------------------------------------------------- 1 | user = $user; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Events/GdprInactiveUserDeleted.php: -------------------------------------------------------------------------------- 1 | user = $user; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/GdprServiceProvider.php: -------------------------------------------------------------------------------- 1 | registerRoutes(); 19 | $this->registerCommands(); 20 | } 21 | 22 | /** 23 | * Register the GDPR routes. 24 | * 25 | * @return void 26 | */ 27 | protected function registerRoutes() 28 | { 29 | Route::group([ 30 | 'prefix' => config('gdpr.uri'), 31 | 'namespace' => 'Soved\Laravel\Gdpr\Http\Controllers', 32 | 'middleware' => config('gdpr.middleware'), 33 | ], function () { 34 | $this->loadRoutesFrom(__DIR__.'/../routes/web.php'); 35 | }); 36 | } 37 | 38 | /** 39 | * Register the GDPR commands. 40 | * 41 | * @return void 42 | */ 43 | protected function registerCommands() 44 | { 45 | if ($this->app->runningInConsole()) { 46 | $this->commands([ 47 | Cleanup::class, 48 | ]); 49 | } 50 | } 51 | 52 | /** 53 | * Register the application services. 54 | * 55 | * @return void 56 | */ 57 | public function register() 58 | { 59 | $this->configure(); 60 | $this->offerPublishing(); 61 | } 62 | 63 | /** 64 | * Setup the configuration for GDPR. 65 | * 66 | * @return void 67 | */ 68 | protected function configure() 69 | { 70 | $this->mergeConfigFrom( 71 | __DIR__.'/../config/gdpr.php', 72 | 'gdpr' 73 | ); 74 | } 75 | 76 | /** 77 | * Setup the resource publishing groups for GDPR. 78 | * 79 | * @return void 80 | */ 81 | protected function offerPublishing() 82 | { 83 | if ($this->app->runningInConsole()) { 84 | $this->publishes([ 85 | __DIR__.'/../config/gdpr.php' => config_path('gdpr.php'), 86 | ], 'gdpr-config'); 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Http/Controllers/GdprController.php: -------------------------------------------------------------------------------- 1 | validateRequest($request)) { 21 | return $this->sendFailedLoginResponse(); 22 | } 23 | 24 | $data = $request->user()->portable(); 25 | 26 | event(new GdprDownloaded($request->user())); 27 | 28 | // Backward compatible streamDownload() behavior 29 | 30 | return response()->json( 31 | $data, 32 | 200, 33 | [ 34 | 'Content-Disposition' => 'attachment; filename="user.json"', 35 | ] 36 | ); 37 | } 38 | 39 | /** 40 | * Validate the request. 41 | * 42 | * @return bool 43 | */ 44 | protected function validateRequest(FormRequest $request) 45 | { 46 | if (config('gdpr.re-authenticate', true)) { 47 | return $this->hasValidCredentials($request); 48 | } 49 | 50 | return Auth::check(); 51 | } 52 | 53 | /** 54 | * Validate a user's credentials. 55 | * 56 | * @return bool 57 | */ 58 | protected function hasValidCredentials(FormRequest $request) 59 | { 60 | $credentials = [ 61 | $request->user()->getAuthIdentifierName() => $request->user()->getAuthIdentifier(), 62 | 'password' => $request->input('password'), 63 | ]; 64 | 65 | return Auth::validate($credentials); 66 | } 67 | 68 | /** 69 | * Get the failed login response. 70 | * 71 | * @return void 72 | */ 73 | protected function sendFailedLoginResponse() 74 | { 75 | abort(403, 'Unauthorized.'); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Http/Requests/GdprDownload.php: -------------------------------------------------------------------------------- 1 | 'string', 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Jobs/Cleanup/CleanupJob.php: -------------------------------------------------------------------------------- 1 | users = $users; 41 | $this->strategy = $strategy; 42 | } 43 | 44 | /** 45 | * Execute the job. 46 | * 47 | * @return void 48 | */ 49 | public function handle() 50 | { 51 | $this->strategy->execute($this->users); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Jobs/Cleanup/CleanupStrategy.php: -------------------------------------------------------------------------------- 1 | config = $config; 23 | } 24 | 25 | /** 26 | * Execute cleanup strategy. 27 | * 28 | * @return void 29 | */ 30 | abstract public function execute(Collection $users); 31 | } 32 | -------------------------------------------------------------------------------- /src/Jobs/Cleanup/Strategies/DefaultStrategy.php: -------------------------------------------------------------------------------- 1 | config->get('gdpr.cleanup.defaultStrategy'); 22 | 23 | // Users are considered inactive if their last activity is older than this timestamp 24 | $inactivity = Carbon::now() 25 | ->subMonths($config['keepInactiveUsersForMonths']); 26 | 27 | $this->notifyInactiveUsers( 28 | $inactivity, 29 | $config['notifyUsersDaysBeforeDeletion'], 30 | $users 31 | ); 32 | 33 | $this->deleteInactiveUsers($inactivity, $users); 34 | } 35 | 36 | /** 37 | * Notify inactive users about their deletion. 38 | * 39 | * @return void 40 | */ 41 | private function notifyInactiveUsers( 42 | Carbon $inactivity, 43 | int $notificationThreshold, 44 | Collection $users 45 | ) { 46 | $users->filter( 47 | function (Authenticatable $user) use ($inactivity, $notificationThreshold) { 48 | return $user->last_activity->diffInDays($inactivity) 49 | === $notificationThreshold; 50 | } 51 | )->each(function (Authenticatable $user) { 52 | event(new GdprInactiveUser($user)); 53 | }); 54 | } 55 | 56 | /** 57 | * Delete inactive users. 58 | * 59 | * @return void 60 | */ 61 | private function deleteInactiveUsers( 62 | Carbon $inactivity, 63 | Collection $users 64 | ) { 65 | $users->filter(function (Authenticatable $user) use ($inactivity) { 66 | return $user->last_activity < $inactivity; 67 | })->each(function (Authenticatable $user) { 68 | $user->delete(); 69 | 70 | event(new GdprInactiveUserDeleted($user)); 71 | }); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Portable.php: -------------------------------------------------------------------------------- 1 | gdprWith)) { 18 | $this->loadRelations($this->gdprWith); 19 | } 20 | 21 | // Make the given attributes visible 22 | if (isset($this->gdprVisible)) { 23 | $this->setVisible($this->gdprVisible); 24 | } 25 | 26 | // Make the given attributes hidden 27 | if (isset($this->gdprHidden)) { 28 | $this->setHidden($this->gdprHidden); 29 | } 30 | 31 | return $this->toPortableArray(); 32 | } 33 | 34 | /** 35 | * Eager load the given relations. 36 | * 37 | * @return void 38 | */ 39 | public function loadRelations(array $relations) 40 | { 41 | $portableRelations = $this->getPortableRelations($relations); 42 | 43 | array_walk($portableRelations, [$this, 'loadPortableRelation']); 44 | 45 | $this->load(array_diff($relations, $portableRelations)); 46 | } 47 | 48 | /** 49 | * Get all portable relations. 50 | * 51 | * @return array 52 | */ 53 | private function getPortableRelations(array $relations) 54 | { 55 | $portableRelations = []; 56 | 57 | foreach ($relations as $relation) { 58 | if ($this->$relation()->getRelated() instanceof PortableContract) { 59 | $portableRelations[] = $relation; 60 | } 61 | } 62 | 63 | return $portableRelations; 64 | } 65 | 66 | /** 67 | * Load and transform a portable relation. 68 | * 69 | * @return void 70 | */ 71 | private function loadPortableRelation(string $relation) 72 | { 73 | $instance = $this->$relation(); 74 | 75 | $collection = $instance 76 | ->get() 77 | ->transform(function ($item) { 78 | return $item->portable(); 79 | }); 80 | 81 | $class = class_basename(get_class($instance)); 82 | 83 | if (in_array($class, ['HasOne', 'BelongsTo'])) { 84 | $collection = $collection->first(); 85 | } 86 | 87 | $this->attributes[$relation] = $collection; 88 | } 89 | 90 | /** 91 | * Get the GDPR compliant data portability array for the model. 92 | * 93 | * @return array 94 | */ 95 | public function toPortableArray() 96 | { 97 | return $this->toArray(); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Retentionable.php: -------------------------------------------------------------------------------- 1 | updated_at; 17 | } 18 | } 19 | --------------------------------------------------------------------------------