MigrateEncryptionCommand::handle()   D
last analyzed

Complexity

Conditions 20
Paths 18

Size

Total Lines 156
Code Lines 83

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 58
CRAP Score 28.3187

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 20
eloc 83
c 1
b 0
f 0
nc 18
nop 0
dl 0
loc 156
rs 4.1666
ccs 58
cts 80
cp 0.725
crap 28.3187

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
/**
3
 * src/Console/Commands/MigrateEncryptionCommand.php.
4
 *
5
 * @author      Austin Heap <[email protected]>
6
 * @version     v0.2.1
7
 */
8
declare(strict_types=1);
9
10
namespace AustinHeap\Database\Encryption\Console\Commands;
11
12
use AustinHeap\Database\Encryption\EncryptionFacade as DatabaseEncryption;
13
use Exception;
14
use Illuminate\Encryption\Encrypter;
15
use Illuminate\Support\Facades\Config;
16
use Illuminate\Support\Facades\DB;
17
use Illuminate\Support\Facades\Log;
18
use RuntimeException;
19
20
/**
21
 * Class MigrateEncryptionCommand.
22
 *
23
 * This console job locates data in the database that contains data encrypted
24
 * using a wrong/deprecated encryption key, and re-encrypts it using the
25
 * correct/new encryption key.
26
 *
27
 * It can be used to fix badly encrypted data, or can be used to decrypt data using
28
 * one key and re-encrypt using another key.
29
 *
30
 * ### Installation
31
 *
32
 * * Override this class and change the setupKeys() function to set the keys that
33
 *   are to be used ($old_keys and $new_key) as well as the array of $table names.
34
 *
35
 * * Add 'App\Console\Commands\MigrateEncryptionCommand' to the $commands array in
36
 *   your 'App\Console\Kernel' package.
37
 *
38
 * ### Example
39
 *
40
 * <code>
41
 * php artisan migrate:encryption
42
 * </code>
43
 */
44
class MigrateEncryptionCommand extends \Illuminate\Console\Command
45
{
46
    /**
47
     * The stats of the last run of the console command.
48
     *
49
     * @var array
50
     */
51
    private static $stats = null;
52
53
    /**
54
     * The name and signature of the console command.
55
     *
56
     * @var string
57
     */
58
    protected $signature = 'migrate:encryption';
59
60
    /**
61
     * The console command description.
62
     *
63
     * @var string
64
     */
65
    protected $description = 'Rotate keys used for database encryption';
66
67
    /**
68
     * An array of old keys.  Each one is to be tried in turn.
69
     *
70
     * @var array
71
     */
72
    protected $old_keys = null;
73
74
    /**
75
     * The new encryption key.
76
     *
77
     * @var string
78
     */
79
    protected $new_key = null;
80
81
    /**
82
     * The list of tables to be scanned.
83
     *
84
     * @var array
85
     */
86
    protected $tables = null;
87
88
    /**
89
     * Get the configuration setting for the prefix used to determine if a string is encrypted.
90
     *
91
     * @return string
92
     */
93 1
    protected function getEncryptionPrefix(): string
94
    {
95 1
        return DatabaseEncryption::getPrefix();
0 ignored issues
show
Bug introduced by
The method getPrefix() does not exist on AustinHeap\Database\Encryption\EncryptionFacade. Since you implemented __callStatic, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

95
        return DatabaseEncryption::/** @scrutinizer ignore-call */ getPrefix();
Loading history...
96
    }
97
98
    /**
99
     * Determine whether a string has already been encrypted.
100
     *
101
     * @param mixed $value
102
     *
103
     * @return bool
104
     */
105 1
    protected function isEncrypted($value): bool
106
    {
107 1
        return strpos((string) $value, $this->getEncryptionPrefix()) === 0;
108
    }
109
110
    /**
111
     * Return the encrypted value of an attribute's value.
112
     *
113
     * @param string    $value
114
     * @param Encrypter $cipher
115
     *
116
     * @return null|string
117
     */
118
    public function encryptedAttribute($value, $cipher): ?string
119
    {
120
        return $this->getEncryptionPrefix().$cipher->encrypt($value);
121
    }
122
123
    /**
124
     * Return the decrypted value of an attribute's encrypted value.
125
     *
126
     * @param string    $value
127
     * @param Encrypter $cipher
128
     *
129
     * @return null|string
130
     */
131
    public function decryptedAttribute($value, $cipher): ?string
132
    {
133
        return $cipher->decrypt(str_replace($this->getEncryptionPrefix(), '', $value));
134
    }
135
136
    /**
137
     * Set up keys.
138
     *
139
     * @return void
140
     */
141 1
    protected function setupKeys()
142
    {
143
        // Over-ride this function to set:
144
        //
145
        // * $this->old_keys
146
        // * $this->new_key
147
        // * $this->tables
148 1
    }
149
150
    /**
151
     * Execute the console command.
152
     *
153
     * @return mixed
154
     */
155 5
    public function handle()
156
    {
157
        // Keys
158 5
        $this->setupKeys();
159
160 5
        throw_if(! is_array($this->old_keys) || empty($this->old_keys) || count($this->old_keys) == 0,
161 5
                 RuntimeException::class,
162 5
                 'You must override this class with (array)$old_keys set correctly.');
163 3
        throw_if(! is_string($this->new_key) || empty($this->new_key), RuntimeException::class,
164 3
                 'You must override this class with (string)$new_key set correctly.');
165 2
        throw_if(! is_array($this->tables) || empty($this->tables) || count($this->tables) == 0, RuntimeException::class,
166 2
                 'You must override this class with (array)$tables set correctly.');
167
168
        // Encrypter objects
169 1
        $cipher = Config::get('app.cipher', 'AES-256-CBC');
170 1
        $base_encrypter = new Encrypter($this->new_key, $cipher);
171 1
        $old_encrypter = [];
172
173 1
        foreach ($this->old_keys as $key => $value) {
174 1
            $old_encrypter[$key] = new Encrypter($value, $cipher);
175
        }
176
177
        // Stats
178
        $stats = [
179 1
            'tables'     => count($this->tables),
180 1
            'rows'       => 0,
181 1
            'attributes' => 0,
182 1
            'failed'     => 0,
183 1
            'migrated'   => 0,
184 1
            'skipped'    => 0,
185
        ];
186
187
        // Main
188 1
        $this->writeln('<fg=green>Migrating <fg=blue>'.count($this->old_keys).'</> old database encryption key(s) on <fg=blue>'.$stats['tables'].'</> table(s).</>');
189
190 1
        foreach ($this->tables as $table_name) {
191
            // Process table
192 1
            $this->writeln('<fg=yellow>Fetching data from: <fg=white>"</><fg=green>'.$table_name.'<fg=white>"</>.</>');
193
194
            // Setup table stats
195 1
            $table_stats = ['rows' => 0, 'attributes' => 0, 'failed' => 0, 'migrated' => 0, 'skipped' => 0];
196
197
            // Get count of records
198 1
            $count = DB::table($table_name)
199 1
                       ->count();
200
201
            // Create progress bar
202 1
            $bar = defined('LARAVEL_DATABASE_ENCRYPTION_TESTS') ? null : $this->output->createProgressBar($count);
203
204 1
            $this->writeln('<fg=yellow>Found <fg=blue>'.number_format($count, 0).'</> record(s) in database; checking encryption keys.</>');
205
206
            // Get table object
207 1
            $table_data = DB::table($table_name)
208 1
                            ->orderBy('id');
209
210
            // Cycle through table data 1k records at a time
211 1
            $chunk = 1000;
212
            $table_data->chunk($chunk, function ($data) use (
213 1
                &$stats,
0 ignored issues
show
Unused Code introduced by
The import $stats is not used and could be removed.

This check looks for imports that have been defined, but are not used in the scope.

Loading history...
214 1
                &$table_stats,
215 1
                $bar,
216 1
                $chunk,
217 1
                $base_encrypter,
218 1
                $old_encrypter,
219 1
                $table_name
220
            ) {
221 1
                foreach ($data as $datum) {
222
                    // Check every column of the table for an encrypted value.  If the value is
223
                    // encrypted then try to decrypt it with the base encrypter.
224 1
                    $datum_array = get_object_vars($datum);
225 1
                    $adjust = [];
226
227 1
                    $table_stats['rows'] += 1;
228
229 1
                    foreach ($datum_array as $key => $value) {
230 1
                        $table_stats['attributes'] += 1;
231
232 1
                        if (! $this->isEncrypted($value)) {
233 1
                            $table_stats['skipped'] += 1;
234 1
                            continue;
235
                        }
236
237
                        try {
238
                            $test = $this->decryptedAttribute($value, $base_encrypter);
0 ignored issues
show
Unused Code introduced by
The assignment to $test is dead and can be removed.
Loading history...
239
                            continue;
240
                        } catch (Exception $e) {
241
242
                            // If the base encrypter fails then try to decrypt it with each
243
                            // other encrypter until one works or they all fail.
244
                            $new_value = '';
245
246
                            foreach ($old_encrypter as $cipher) {
247
                                try {
248
                                    $test = $this->decryptedAttribute($value, $cipher);
249
250
                                    // If that did not throw an exception then we have a match
251
                                    // between the old encrypter and the encrypted value, so
252
                                    // adjust the new value.
253
                                    $new_value = $this->encryptedAttribute($test, $base_encrypter);
254
                                    continue;
255
                                } catch (\Exception $e) {
256
                                    // Do nothing, keep trying.
257
                                }
258
                            }
259
260
                            // If we got a match then empty($new_value) != true
261
                            if (empty($new_value)) {
262
                                Log::error(
263
                                    __CLASS__.':'.__TRAIT__.':'.__FILE__.':'.__LINE__.':'.__FUNCTION__.':'.
264
                                    'Unable to find encryption key for: '.$table_name->key.' #'.$datum->id
265
                                );
266
267
                                $table_stats['failed'] += 1;
268
                                continue;
269
                            }
270
271
                            // We got a match
272
                            $adjust[$key] = $new_value;
273
                            $table_stats['migrated'] += 1;
274
                        }
275
276
                        $table_stats['attributes'] += 1;
277
                    }
278
279
                    // If we have anything in $adjust, write that back to the database
280 1
                    if (count($adjust) == 0) {
281 1
                        continue;
282
                    }
283
284
                    DB::table($table_name)
285
                      ->where('id', '=', $datum->id)
286
                      ->update($adjust);
287
                }
288
289
                // Advance progress bar
290 1
                if (! defined('LARAVEL_DATABASE_ENCRYPTION_TESTS')) {
291
                    $bar->advance($chunk);
0 ignored issues
show
Bug introduced by
The method advance() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

291
                    $bar->/** @scrutinizer ignore-call */ 
292
                          advance($chunk);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
292
                }
293 1
            });
294
295
            // Finish progress bar
296 1
            if (! defined('LARAVEL_DATABASE_ENCRYPTION_TESTS')) {
297
                $bar->finish();
298
            }
299
300
            // And display stats
301 1
            foreach ($table_stats as $key => $value) {
302 1
                $stats[$key] += $value;
303
            }
304
305 1
            $this->writeln('');
306 1
            $this->writeln('<fg=blue>Database encryption migration for table <fg=white>"</><fg=green>'.$table_name.'</><fg=white>"</> complete: '.self::buildStatsString($table_stats).'.</>');
307
        }
308
309 1
        $this->writeln('<fg=green>Database encryption migration for all <fg=blue>'.$stats['tables'].'</> table(s) complete: '.self::buildStatsString($stats).'.</>');
310 1
        self::setStats($stats);
311 1
    }
312
313 1
    private function writeln(string $line): void
314
    {
315 1
        $output = $this->getOutput();
316
317 1
        if (! is_null($output)) {
318
            $output->writeln($line);
319
        }
320 1
    }
321
322 1
    private static function buildStatsString(array $stats, string $stat = null, bool $stylize = true): string
323
    {
324 1
        $string = '';
325
326 1
        foreach ($stats as $key => $value) {
327 1
            if (! is_null($stat) && $key != $stat) {
328
                continue;
329
            }
330
331 1
            $string .= self::stylizeStatsString($key, 'fg=white', $stylize).
332 1
                       self::stylizeStatsString(' = ', 'fg=yellow', $stylize).
333 1
                       self::stylizeStatsString(is_int($value) ? number_format($value, 0) : $value, 'fg=magenta',
334 1
                                                $stylize).'; ';
335
        }
336
337 1
        return empty($string) ? '' : substr($string, 0, -2);
338
    }
339
340 1
    private static function stylizeStatsString(string $string, string $style, bool $stylize = true): string
341
    {
342 1
        return ! $stylize ? $string : '<'.$style.'>'.$string.'</'.(strpos($style,
343 1
                                                                                   '<fg') === 0 ? '' : $style).'>';
344
    }
345
346 1
    private static function setStats(array $stats): void
347
    {
348 1
        self::$stats = $stats;
349 1
    }
350
351 1
    public static function getStats(): array
352
    {
353 1
        throw_if(is_null(self::$stats), RuntimeException::class, 'Stats do not exist; command has not been executed.');
354
355 1
        return self::$stats;
356
    }
357
}
358