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

daycry / auth / 25552752016

08 May 2026 11:20AM UTC coverage: 71.592% (+13.0%) from 58.608%
25552752016

push

github

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

Add Laravel-parity authentication features: Gates, Password Confirmation, Basic Auth

198 of 252 new or added lines in 18 files covered. (78.57%)

1 existing line in 1 file now uncovered.

4453 of 6220 relevant lines covered (71.59%)

62.44 hits per line

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

78.44
/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\I18n\Time;
17
use Daycry\Auth\Authentication\Authenticators\AccessToken;
18
use Daycry\Auth\Entities\User;
19
use Daycry\Auth\Enums\IdentityType;
20
use Daycry\Auth\Models\AuditLogModel;
21
use Daycry\Auth\Models\DeviceSessionModel;
22
use Daycry\Auth\Models\LoginModel;
23
use Daycry\Auth\Models\PasswordHistoryModel;
24
use Daycry\Auth\Models\TotpBackupCodeModel;
25
use Daycry\Auth\Models\UserIdentityModel;
26
use Daycry\Auth\Models\UserModel;
27
use Daycry\Auth\Services\AuditLogger;
28
use Throwable;
29

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

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

63
    public function run(array $params): int
9✔
64
    {
65
        $action = $params[0] ?? '';
9✔
66
        $email  = (string) ($params['e'] ?? '');
9✔
67
        $id     = (string) ($params['i'] ?? '');
9✔
68

69
        if ($email === '' && $id === '') {
9✔
70
            $this->error('Specify -e <email> or -i <id>.');
1✔
71

72
            return 1;
1✔
73
        }
74

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

81
        if (! $user instanceof User) {
8✔
82
            $this->error('User not found.');
1✔
83

84
            return 1;
1✔
85
        }
86

87
        return match ($action) {
7✔
88
            'export'    => $this->exportAction($user, (string) ($params['o'] ?? '')),
4✔
89
            'anonymize' => $this->anonymizeAction($user, $userModel),
2✔
90
            default     => $this->unsupported(),
7✔
91
        };
7✔
92
    }
93

94
    private function unsupported(): int
1✔
95
    {
96
        $this->error('Unsupported action. Supported: export, anonymize.');
1✔
97

98
        return 1;
1✔
99
    }
100

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

126
            $json = json_encode(
5✔
127
                $payload,
5✔
128
                JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
5✔
129
            );
5✔
130

131
            if ($json === false) {
5✔
NEW
132
                $this->error('JSON encoding failed.');
×
133

134
                return 1;
×
135
            }
136

137
            if ($outputPath !== '') {
5✔
138
                file_put_contents($outputPath, $json);
1✔
139
                $this->write('Wrote export to ' . $outputPath, 'green');
1✔
140
            } else {
141
                $this->write($json);
4✔
142
            }
143

144
            return 0;
5✔
145
        } catch (Throwable $e) {
×
NEW
146
            $this->error('Export failed: ' . $e->getMessage());
×
147

148
            return 1;
×
149
        }
150
    }
151

152
    private function anonymizeAction(User $user, UserModel $userModel): int
2✔
153
    {
154
        if ($this->prompt('This will permanently anonymize user ' . $user->id . '. Continue?', ['y', 'n']) !== 'y') {
2✔
155
            $this->write('Aborted.', 'yellow');
1✔
156

157
            return 0;
1✔
158
        }
159

160
        try {
161
            $userId = (int) $user->id;
1✔
162

163
            // Soft-revoke and delete identities (passwords, tokens, OAuth).
164
            /** @var UserIdentityModel $identityModel */
165
            $identityModel = model(UserIdentityModel::class);
1✔
166
            $identityModel->where('user_id', $userId)->delete();
1✔
167

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

175
            /** @var TotpBackupCodeModel $backup */
176
            $backup = model(TotpBackupCodeModel::class);
1✔
177
            $backup->where('user_id', $userId)->delete();
1✔
178

179
            /** @var PasswordHistoryModel $history */
180
            $history = model(PasswordHistoryModel::class);
1✔
181
            $history->where('user_id', $userId)->delete();
1✔
182

183
            // Anonymise the user row (preserve FKs).
184
            $userModel->where('id', $userId)->set([
1✔
185
                'username'            => 'deleted_' . $userId,
1✔
186
                'active'              => 0,
1✔
187
                'failed_login_count'  => 0,
1✔
188
                'locked_until'        => null,
1✔
189
                'password_changed_at' => null,
1✔
190
            ])->update();
1✔
191

192
            // Final audit-log entry with anonymisation.
193
            (new AuditLogger())->record(
1✔
194
                AuditLogger::EVENT_USER_ANONYMIZED,
1✔
195
                $userId,
1✔
196
                ['initiator' => 'cli'],
1✔
197
            );
1✔
198

199
            $this->write('User ' . $userId . ' anonymised. Identities and tokens removed.', 'green');
1✔
200

201
            return 0;
1✔
202
        } catch (Throwable $e) {
×
NEW
203
            $this->error('Anonymization failed: ' . $e->getMessage());
×
204

205
            return 1;
×
206
        }
207
    }
208

209
    /**
210
     * @return list<array<string, mixed>>
211
     */
212
    private function collectIdentities(User $user): array
5✔
213
    {
214
        /** @var UserIdentityModel $identityModel */
215
        $identityModel = model(UserIdentityModel::class);
5✔
216

217
        $rows   = $identityModel->where('user_id', $user->id)->findAll();
5✔
218
        $result = [];
5✔
219

220
        foreach ($rows as $row) {
5✔
221
            $type = is_object($row) ? ($row->type ?? '') : ($row['type'] ?? '');
5✔
222

223
            $entry = [
5✔
224
                'id'           => is_object($row) ? ($row->id ?? null) : ($row['id'] ?? null),
5✔
225
                'type'         => $type,
5✔
226
                'name'         => is_object($row) ? ($row->name ?? null) : ($row['name'] ?? null),
5✔
227
                'expires'      => is_object($row) ? ($row->expires ?? null) : ($row['expires'] ?? null),
5✔
228
                'last_used_at' => is_object($row) ? ($row->last_used_at ?? null) : ($row['last_used_at'] ?? null),
5✔
229
                'revoked_at'   => is_object($row) ? ($row->revoked_at ?? null) : ($row['revoked_at'] ?? null),
5✔
230
                'created_at'   => is_object($row) ? ($row->created_at ?? null) : ($row['created_at'] ?? null),
5✔
231
            ];
5✔
232

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

246
            $result[] = $entry;
5✔
247
        }
248

249
        return $result;
5✔
250
    }
251

252
    /**
253
     * @return list<array<string, mixed>>
254
     */
255
    private function collectDeviceSessions(User $user): array
5✔
256
    {
257
        /** @var DeviceSessionModel $deviceModel */
258
        $deviceModel = model(DeviceSessionModel::class);
5✔
259

260
        $rows   = $deviceModel->where('user_id', $user->id)->findAll();
5✔
261
        $result = [];
5✔
262

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

277
        return $result;
5✔
278
    }
279

280
    /**
281
     * @return list<array<string, mixed>>
282
     */
283
    private function collectLoginHistory(User $user): array
5✔
284
    {
285
        /** @var LoginModel $loginModel */
286
        $loginModel = model(LoginModel::class);
5✔
287

288
        $rows   = $loginModel->recentForUser($user, 500);
5✔
289
        $result = [];
5✔
290

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

302
        return $result;
5✔
303
    }
304

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

315
        foreach ($rows as $row) {
5✔
316
            $result[] = [
×
317
                'created_at' => (string) ($row->created_at ?? ''),
×
318
                'event_type' => (string) ($row->event_type ?? ''),
×
319
                'ip_address' => (string) ($row->ip_address ?? ''),
×
320
                'user_agent' => (string) ($row->user_agent ?? ''),
×
321
                'metadata'   => $row->getMetadata(),
×
322
            ];
×
323
        }
324

325
        return $result;
5✔
326
    }
327

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

339
        $count = $history->where('user_id', $user->id)->countAllResults();
5✔
340

341
        return ['count' => $count];
5✔
342
    }
343

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

354
        $remaining = $backup->where('user_id', $user->id)->where('used_at')->countAllResults();
5✔
355
        $used      = $backup->where('user_id', $user->id)->where('used_at !=')->countAllResults();
5✔
356

357
        return ['remaining' => $remaining, 'used' => $used];
5✔
358
    }
359
}
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