• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

daycry / auth / 25518434194

07 May 2026 07:49PM UTC coverage: 58.608% (-6.4%) from 64.989%
25518434194

push

github

web-flow
Merge pull request #47 from daycry/development

Implement security enhancements and new account features

277 of 1030 new or added lines in 55 files covered. (26.89%)

11 existing lines in 6 files now uncovered.

3544 of 6047 relevant lines covered (58.61%)

47.97 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/Commands/GdprCommand.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of Daycry Auth.
7
 *
8
 * (c) Daycry <daycry9@proton.me>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace Daycry\Auth\Commands;
15

16
use CodeIgniter\CLI\CLI;
17
use CodeIgniter\I18n\Time;
18
use Daycry\Auth\Authentication\Authenticators\AccessToken;
19
use Daycry\Auth\Entities\User;
20
use Daycry\Auth\Enums\IdentityType;
21
use Daycry\Auth\Models\AuditLogModel;
22
use Daycry\Auth\Models\DeviceSessionModel;
23
use Daycry\Auth\Models\LoginModel;
24
use Daycry\Auth\Models\PasswordHistoryModel;
25
use Daycry\Auth\Models\TotpBackupCodeModel;
26
use Daycry\Auth\Models\UserIdentityModel;
27
use Daycry\Auth\Models\UserModel;
28
use Daycry\Auth\Services\AuditLogger;
29
use Throwable;
30

31
/**
32
 * GDPR-friendly data export and account anonymization.
33
 *
34
 * Usage:
35
 *   php spark auth:gdpr export -e <email> [-o <path>]
36
 *   php spark auth:gdpr anonymize -e <email>
37
 *
38
 * `export` writes a JSON document containing the user's row, identities
39
 * (with secrets redacted), device sessions, login history, audit log
40
 * entries, and password-history metadata.
41
 *
42
 * `anonymize` keeps the user row (preserving FK integrity) but replaces
43
 * personal fields with placeholders, deletes identities/tokens/device
44
 * sessions/password-history/backup-codes, and writes an audit-log entry.
45
 */
46
class GdprCommand extends BaseCommand
47
{
48
    protected $name        = 'auth:gdpr';
49
    protected $description = 'GDPR helpers: export user data / anonymize a user account.';
50
    protected $usage       = <<<'EOL'
51
        auth:gdpr export -e <email> [-o <path>]
52
        auth:gdpr anonymize -e <email>
53
        EOL;
54

55
    /**
56
     * @var array<string, string>
57
     */
58
    protected $options = [
59
        '-e' => 'Target user email.',
60
        '-i' => 'Target user id (alternative to -e).',
61
        '-o' => 'Output file path (export only). Defaults to stdout.',
62
    ];
63

NEW
64
    public function run(array $params): int
×
65
    {
NEW
66
        $action = array_shift($params) ?? '';
×
NEW
67
        $email  = (string) (CLI::getOption('e') ?? '');
×
NEW
68
        $id     = (string) (CLI::getOption('i') ?? '');
×
69

NEW
70
        if ($email === '' && $id === '') {
×
NEW
71
            CLI::error('Specify -e <email> or -i <id>.');
×
72

NEW
73
            return 1;
×
74
        }
75

76
        /** @var UserModel $userModel */
NEW
77
        $userModel = model(UserModel::class);
×
NEW
78
        $user      = $id !== ''
×
NEW
79
            ? $userModel->findById((int) $id)
×
NEW
80
            : $userModel->findByCredentials(['email' => $email]);
×
81

NEW
82
        if (! $user instanceof User) {
×
NEW
83
            CLI::error('User not found.');
×
84

NEW
85
            return 1;
×
86
        }
87

NEW
88
        return match ($action) {
×
NEW
89
            'export'    => $this->exportAction($user),
×
NEW
90
            'anonymize' => $this->anonymizeAction($user, $userModel),
×
NEW
91
            default     => $this->unsupported(),
×
NEW
92
        };
×
93
    }
94

NEW
95
    private function unsupported(): int
×
96
    {
NEW
97
        CLI::error('Unsupported action. Supported: export, anonymize.');
×
98

NEW
99
        return 1;
×
100
    }
101

NEW
102
    private function exportAction(User $user): int
×
103
    {
104
        try {
NEW
105
            $payload = [
×
NEW
106
                'exported_at' => Time::now()->toDateTimeString(),
×
NEW
107
                'user'        => [
×
NEW
108
                    'id'                  => $user->id,
×
NEW
109
                    'uuid'                => $user->uuid ?? null,
×
NEW
110
                    'username'            => $user->username,
×
NEW
111
                    'email'               => $user->email ?? null,
×
NEW
112
                    'active'              => (bool) ($user->active ?? false),
×
NEW
113
                    'created_at'          => (string) ($user->created_at ?? ''),
×
NEW
114
                    'updated_at'          => (string) ($user->updated_at ?? ''),
×
NEW
115
                    'failed_login_count'  => $user->failed_login_count ?? 0,
×
NEW
116
                    'locked_until'        => $user->locked_until ?? null,
×
NEW
117
                    'password_changed_at' => $user->password_changed_at ?? null,
×
NEW
118
                ],
×
NEW
119
                'identities'       => $this->collectIdentities($user),
×
NEW
120
                'device_sessions'  => $this->collectDeviceSessions($user),
×
NEW
121
                'login_history'    => $this->collectLoginHistory($user),
×
NEW
122
                'audit_log'        => $this->collectAuditLog($user),
×
NEW
123
                'password_history' => $this->collectPasswordHistoryMeta($user),
×
NEW
124
                'backup_codes'     => $this->collectBackupCodesMeta($user),
×
NEW
125
            ];
×
126

NEW
127
            $json = json_encode(
×
NEW
128
                $payload,
×
NEW
129
                JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
×
NEW
130
            );
×
131

NEW
132
            if ($json === false) {
×
NEW
133
                CLI::error('JSON encoding failed.');
×
134

NEW
135
                return 1;
×
136
            }
137

NEW
138
            $output = (string) (CLI::getOption('o') ?? '');
×
139

NEW
140
            if ($output !== '') {
×
NEW
141
                file_put_contents($output, $json);
×
NEW
142
                CLI::write('Wrote export to ' . $output, 'green');
×
143
            } else {
NEW
144
                CLI::write($json);
×
145
            }
146

NEW
147
            return 0;
×
NEW
148
        } catch (Throwable $e) {
×
NEW
149
            CLI::error('Export failed: ' . $e->getMessage());
×
150

NEW
151
            return 1;
×
152
        }
153
    }
154

NEW
155
    private function anonymizeAction(User $user, UserModel $userModel): int
×
156
    {
NEW
157
        if (CLI::prompt('This will permanently anonymize user ' . $user->id . '. Continue?', ['y', 'n']) !== 'y') {
×
NEW
158
            CLI::write('Aborted.', 'yellow');
×
159

NEW
160
            return 0;
×
161
        }
162

163
        try {
NEW
164
            $userId = (int) $user->id;
×
165

166
            // Soft-revoke and delete identities (passwords, tokens, OAuth).
167
            /** @var UserIdentityModel $identityModel */
NEW
168
            $identityModel = model(UserIdentityModel::class);
×
NEW
169
            $identityModel->where('user_id', $userId)->delete();
×
170

171
            // Drop device sessions / login history / backup codes /
172
            // password history. We keep audit log entries with user_id set
173
            // but the row itself anonymised below.
174
            /** @var DeviceSessionModel $deviceModel */
NEW
175
            $deviceModel = model(DeviceSessionModel::class);
×
NEW
176
            $deviceModel->where('user_id', $userId)->delete();
×
177

178
            /** @var TotpBackupCodeModel $backup */
NEW
179
            $backup = model(TotpBackupCodeModel::class);
×
NEW
180
            $backup->where('user_id', $userId)->delete();
×
181

182
            /** @var PasswordHistoryModel $history */
NEW
183
            $history = model(PasswordHistoryModel::class);
×
NEW
184
            $history->where('user_id', $userId)->delete();
×
185

186
            // Anonymise the user row (preserve FKs).
NEW
187
            $userModel->where('id', $userId)->set([
×
NEW
188
                'username'            => 'deleted_' . $userId,
×
NEW
189
                'active'              => 0,
×
NEW
190
                'failed_login_count'  => 0,
×
NEW
191
                'locked_until'        => null,
×
NEW
192
                'password_changed_at' => null,
×
NEW
193
            ])->update();
×
194

195
            // Final audit-log entry with anonymisation.
NEW
196
            (new AuditLogger())->record(
×
NEW
197
                AuditLogger::EVENT_USER_ANONYMIZED,
×
NEW
198
                $userId,
×
NEW
199
                ['initiator' => 'cli'],
×
NEW
200
            );
×
201

NEW
202
            CLI::write('User ' . $userId . ' anonymised. Identities and tokens removed.', 'green');
×
203

NEW
204
            return 0;
×
NEW
205
        } catch (Throwable $e) {
×
NEW
206
            CLI::error('Anonymization failed: ' . $e->getMessage());
×
207

NEW
208
            return 1;
×
209
        }
210
    }
211

212
    /**
213
     * @return list<array<string, mixed>>
214
     */
NEW
215
    private function collectIdentities(User $user): array
×
216
    {
217
        /** @var UserIdentityModel $identityModel */
NEW
218
        $identityModel = model(UserIdentityModel::class);
×
219

NEW
220
        $rows   = $identityModel->where('user_id', $user->id)->findAll();
×
NEW
221
        $result = [];
×
222

NEW
223
        foreach ($rows as $row) {
×
NEW
224
            $type = is_object($row) ? ($row->type ?? '') : ($row['type'] ?? '');
×
225

NEW
226
            $entry = [
×
NEW
227
                'id'           => is_object($row) ? ($row->id ?? null) : ($row['id'] ?? null),
×
NEW
228
                'type'         => $type,
×
NEW
229
                'name'         => is_object($row) ? ($row->name ?? null) : ($row['name'] ?? null),
×
NEW
230
                'expires'      => is_object($row) ? ($row->expires ?? null) : ($row['expires'] ?? null),
×
NEW
231
                'last_used_at' => is_object($row) ? ($row->last_used_at ?? null) : ($row['last_used_at'] ?? null),
×
NEW
232
                'revoked_at'   => is_object($row) ? ($row->revoked_at ?? null) : ($row['revoked_at'] ?? null),
×
NEW
233
                'created_at'   => is_object($row) ? ($row->created_at ?? null) : ($row['created_at'] ?? null),
×
NEW
234
            ];
×
235

236
            // Redact secret fields — they are not portable user data and
237
            // exposing them would defeat the purpose of revoking a token.
NEW
238
            if ($type === IdentityType::EMAIL_PASSWORD->value) {
×
NEW
239
                $entry['secret']  = is_object($row) ? ($row->secret ?? null) : ($row['secret'] ?? null);
×
NEW
240
                $entry['secret2'] = '<redacted: bcrypt hash>';
×
NEW
241
            } elseif ($type === AccessToken::ID_TYPE_ACCESS_TOKEN || $type === IdentityType::JWT_REFRESH->value) {
×
NEW
242
                $entry['secret']  = '<redacted: hashed token>';
×
NEW
243
                $entry['secret2'] = null;
×
244
            } else {
NEW
245
                $entry['secret']  = is_object($row) ? ($row->secret ?? null) : ($row['secret'] ?? null);
×
NEW
246
                $entry['secret2'] = is_object($row) ? ($row->secret2 ?? null) : ($row['secret2'] ?? null);
×
247
            }
248

NEW
249
            $result[] = $entry;
×
250
        }
251

NEW
252
        return $result;
×
253
    }
254

255
    /**
256
     * @return list<array<string, mixed>>
257
     */
NEW
258
    private function collectDeviceSessions(User $user): array
×
259
    {
260
        /** @var DeviceSessionModel $deviceModel */
NEW
261
        $deviceModel = model(DeviceSessionModel::class);
×
262

NEW
263
        $rows   = $deviceModel->where('user_id', $user->id)->findAll();
×
NEW
264
        $result = [];
×
265

NEW
266
        foreach ($rows as $row) {
×
NEW
267
            $result[] = [
×
NEW
268
                'uuid'          => is_object($row) ? ($row->uuid ?? null) : ($row['uuid'] ?? null),
×
NEW
269
                'session_id'    => is_object($row) ? ($row->session_id ?? null) : ($row['session_id'] ?? null),
×
NEW
270
                'device_name'   => is_object($row) ? ($row->device_name ?? null) : ($row['device_name'] ?? null),
×
NEW
271
                'ip_address'    => is_object($row) ? ($row->ip_address ?? null) : ($row['ip_address'] ?? null),
×
NEW
272
                'user_agent'    => is_object($row) ? ($row->user_agent ?? null) : ($row['user_agent'] ?? null),
×
NEW
273
                'last_active'   => is_object($row) ? ($row->last_active ?? null) : ($row['last_active'] ?? null),
×
NEW
274
                'logged_out_at' => is_object($row) ? ($row->logged_out_at ?? null) : ($row['logged_out_at'] ?? null),
×
NEW
275
                'trusted_until' => is_object($row) ? ($row->trusted_until ?? null) : ($row['trusted_until'] ?? null),
×
NEW
276
                'created_at'    => is_object($row) ? ($row->created_at ?? null) : ($row['created_at'] ?? null),
×
NEW
277
            ];
×
278
        }
279

NEW
280
        return $result;
×
281
    }
282

283
    /**
284
     * @return list<array<string, mixed>>
285
     */
NEW
286
    private function collectLoginHistory(User $user): array
×
287
    {
288
        /** @var LoginModel $loginModel */
NEW
289
        $loginModel = model(LoginModel::class);
×
290

NEW
291
        $rows   = $loginModel->recentForUser($user, 500);
×
NEW
292
        $result = [];
×
293

NEW
294
        foreach ($rows as $row) {
×
NEW
295
            $result[] = [
×
NEW
296
                'date'       => (string) ($row->date ?? ''),
×
NEW
297
                'success'    => (bool) ($row->success ?? false),
×
NEW
298
                'id_type'    => (string) ($row->id_type ?? ''),
×
NEW
299
                'identifier' => (string) ($row->identifier ?? ''),
×
NEW
300
                'ip_address' => (string) ($row->ip_address ?? ''),
×
NEW
301
                'user_agent' => (string) ($row->user_agent ?? ''),
×
NEW
302
            ];
×
303
        }
304

NEW
305
        return $result;
×
306
    }
307

308
    /**
309
     * @return list<array<string, mixed>>
310
     */
NEW
311
    private function collectAuditLog(User $user): array
×
312
    {
313
        /** @var AuditLogModel $auditModel */
NEW
314
        $auditModel = model(AuditLogModel::class);
×
NEW
315
        $rows       = $auditModel->recentForUser((int) $user->id, 500);
×
NEW
316
        $result     = [];
×
317

NEW
318
        foreach ($rows as $row) {
×
NEW
319
            $result[] = [
×
NEW
320
                'created_at' => (string) ($row->created_at ?? ''),
×
NEW
321
                'event_type' => (string) ($row->event_type ?? ''),
×
NEW
322
                'ip_address' => (string) ($row->ip_address ?? ''),
×
NEW
323
                'user_agent' => (string) ($row->user_agent ?? ''),
×
NEW
324
                'metadata'   => $row->getMetadata(),
×
NEW
325
            ];
×
326
        }
327

NEW
328
        return $result;
×
329
    }
330

331
    /**
332
     * Returns metadata about historic password hashes (count + dates only;
333
     * never the hashes themselves — those are bcrypt strings, not user data).
334
     *
335
     * @return array<string, mixed>
336
     */
NEW
337
    private function collectPasswordHistoryMeta(User $user): array
×
338
    {
339
        /** @var PasswordHistoryModel $history */
NEW
340
        $history = model(PasswordHistoryModel::class);
×
341

NEW
342
        $count = $history->where('user_id', $user->id)->countAllResults();
×
343

NEW
344
        return ['count' => $count];
×
345
    }
346

347
    /**
348
     * Returns counts of remaining/used backup codes.
349
     *
350
     * @return array<string, int>
351
     */
NEW
352
    private function collectBackupCodesMeta(User $user): array
×
353
    {
354
        /** @var TotpBackupCodeModel $backup */
NEW
355
        $backup = model(TotpBackupCodeModel::class);
×
356

NEW
357
        $remaining = $backup->where('user_id', $user->id)->where('used_at')->countAllResults();
×
NEW
358
        $used      = $backup->where('user_id', $user->id)->where('used_at !=')->countAllResults();
×
359

NEW
360
        return ['remaining' => $remaining, 'used' => $used];
×
361
    }
362
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc