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

daycry / auth / 40106541843

14 Oct 2026 10:48AM UTC coverage: 78.462% (+0.9%) from 77.59%
40106541843

push

github

web-flow
Merge pull request #78 from daycry/feat/self-service-account-endpoints

feat(account): self-service account management + security notification e-mails

282 of 299 new or added lines in 6 files covered. (94.31%)

5796 of 7387 relevant lines covered (78.46%)

85.11 hits per line

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

69.84
/src/Controllers/UserSecurityController.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\Controllers;
15

16
use CodeIgniter\Database\Exceptions\DatabaseException as FrameworkDatabaseException;
17
use CodeIgniter\Events\Events;
18
use CodeIgniter\Exceptions\RuntimeException;
19
use CodeIgniter\HTTP\RedirectResponse;
20
use CodeIgniter\HTTP\ResponseInterface;
21
use CodeIgniter\I18n\Time;
22
use Daycry\Auth\Authentication\Authenticators\Session;
23
use Daycry\Auth\Authentication\Passwords;
24
use Daycry\Auth\Entities\User;
25
use Daycry\Auth\Entities\UserIdentity;
26
use Daycry\Auth\Enums\IdentityType;
27
use Daycry\Auth\Exceptions\DatabaseException;
28
use Daycry\Auth\Libraries\TokenEmailSender;
29
use Daycry\Auth\Libraries\TOTP;
30
use Daycry\Auth\Models\DeviceSessionModel;
31
use Daycry\Auth\Models\LoginModel;
32
use Daycry\Auth\Models\RememberModel;
33
use Daycry\Auth\Models\UserIdentityModel;
34
use Daycry\Auth\Models\UserModel;
35
use Daycry\Auth\Services\AuditLogger;
36
use Daycry\Auth\Services\PasswordChangeRecorder;
37
use Daycry\Auth\Services\UserAnonymizer;
38
use Throwable;
39

40
/**
41
 * UserSecurityController
42
 *
43
 * Lets authenticated users manage their own account and security settings:
44
 *   - device sessions (list + revoke), TOTP 2FA (setup / disable), login activity
45
 *   - password change (or set a first password on a password-less account)
46
 *   - e-mail change (confirmed through a link sent to the new address)
47
 *   - unlink an OAuth provider (never the last way to sign in)
48
 *   - account deletion (GDPR anonymisation)
49
 *
50
 * Password-less accounts (OAuth-created) re-authenticate with a recent
51
 * interactive sign-in (AuthSecurity::$passwordConfirmationLifetime) instead
52
 * of a current password; their deletion is confirmed by e-mail.
53
 *
54
 * Routes to add in your app. Keep CSRF protection on (CI4 `csrf` filter) for
55
 * every POST, and consider the `rates` filter for the mutating routes:
56
 *
57
 *   $c = '\Daycry\Auth\Controllers\UserSecurityController::';
58
 *   $routes->group('account/security', ['filter' => 'auth:session'], static function ($routes) use ($c): void {
59
 *       $routes->get('/',                       $c . 'index',                    ['as' => 'security']);
60
 *       $routes->post('sessions/revoke',        $c . 'revokeSession',            ['as' => 'security-revoke-session']);
61
 *       $routes->post('sessions/revoke-all',    $c . 'revokeAllSessions',        ['as' => 'security-revoke-all']);
62
 *       $routes->get('totp/setup',              $c . 'totpSetup',                ['as' => 'totp-setup']);
63
 *       $routes->post('totp/setup/confirm',     $c . 'totpSetupConfirm',         ['as' => 'totp-setup-confirm']);
64
 *       $routes->post('totp/disable',           $c . 'totpDisable',              ['as' => 'totp-disable']);
65
 *       $routes->get('activity',                $c . 'loginActivity',            ['as' => 'security-activity']);
66
 *       $routes->get('password',                $c . 'changePasswordView',       ['as' => 'security-change-password']);
67
 *       $routes->post('password',               $c . 'changePassword',           ['as' => 'security-change-password-action']);
68
 *       $routes->get('email',                   $c . 'changeEmailView',          ['as' => 'security-change-email']);
69
 *       $routes->post('email',                  $c . 'changeEmail',              ['as' => 'security-change-email-action']);
70
 *       $routes->get('email/confirm',           $c . 'confirmEmailChangeView',   ['as' => 'security-confirm-email']);
71
 *       $routes->post('email/confirm',          $c . 'confirmEmailChange',       ['as' => 'security-confirm-email-action']);
72
 *       $routes->post('oauth/(:segment)/unlink', $c . 'unlinkOauth/$1',          ['as' => 'security-unlink-oauth']);
73
 *       $routes->get('delete',                  $c . 'deleteAccountView',        ['as' => 'security-delete-account']);
74
 *       $routes->post('delete',                 $c . 'deleteAccount',            ['as' => 'security-delete-account-action']);
75
 *       $routes->get('delete/confirm',          $c . 'confirmDeleteAccountView', ['as' => 'security-confirm-delete']);
76
 *       $routes->post('delete/confirm',         $c . 'confirmDeleteAccount',     ['as' => 'security-confirm-delete-action']);
77
 *   });
78
 *
79
 * The e-mailed links point at `security-confirm-email` / `security-confirm-delete`,
80
 * so those route names must exist.
81
 */
82
class UserSecurityController extends BaseAuthController
83
{
84
    protected function getValidationRules(): array
×
85
    {
86
        return [];
×
87
    }
88

89
    /**
90
     * Security overview: device sessions + TOTP status.
91
     */
92
    public function index(): string
1 ✔
93
    {
94
        $user = auth()->user();
1 ✔
95

96
        /** @var DeviceSessionModel $deviceModel */
97
        $deviceModel = model(DeviceSessionModel::class);
1 ✔
98

99
        $sessions   = $deviceModel->getActiveForUser($user);
1 ✔
100
        $currentSid = session_id();
1 ✔
101

102
        return $this->view(setting('Auth.views')['security_overview'], [
1 ✔
103
            'sessions'            => $sessions,
1 ✔
104
            'currentSid'          => $currentSid,
1 ✔
105
            'totpEnabled'         => $user->hasTotpEnabled(),
1 ✔
106
            'webAuthnCredentials' => $user->webAuthnCredentials(),
1 ✔
107
            'webauthnEnabled'     => (bool) (setting('AuthSecurity.webauthnEnabled') ?? false),
1 ✔
108
        ]);
1 ✔
109
    }
110

111
    /**
112
     * Revoke a single device session.
113
     */
114
    public function revokeSession(): RedirectResponse
×
115
    {
116
        $sessionId = (string) $this->request->getPost('session_id');
×
117

118
        if ($sessionId === '') {
×
119
            return redirect()->back()->with('error', 'Invalid session.');
×
120
        }
121

122
        $user = auth()->user();
×
123

124
        /** @var DeviceSessionModel $deviceModel */
125
        $deviceModel = model(DeviceSessionModel::class);
×
126

127
        // Safety check: ensure the session belongs to the current user
128
        $session = $deviceModel->findBySessionId($sessionId);
×
129

130
        if ($session === null || $session->user_id !== $user->id) {
×
131
            return redirect()->back()->with('error', 'Session not found.');
×
132
        }
133

134
        $deviceModel->terminateSession($sessionId);
×
135

136
        return redirect()->back()->with('message', 'Session revoked successfully.');
×
137
    }
138

139
    /**
140
     * Revoke all other active sessions (keep current one).
141
     */
142
    public function revokeAllSessions(): RedirectResponse
×
143
    {
144
        $user = auth()->user();
×
145

146
        /** @var DeviceSessionModel $deviceModel */
147
        $deviceModel = model(DeviceSessionModel::class);
×
148
        $deviceModel->terminateAllForUser($user, session_id());
×
149

150
        return redirect()->back()->with('message', 'All other sessions have been revoked.');
×
151
    }
152

153
    /**
154
     * Show the TOTP setup page with QR code.
155
     *
156
     * Always generates a fresh secret. If the user navigates away before
157
     * confirming, a new QR code will be issued on their next visit.
158
     */
159
    public function totpSetup(): RedirectResponse|string
×
160
    {
161
        $user = auth()->user();
×
162

163
        if ($user->hasTotpEnabled()) {
×
164
            return redirect()->route('security')->with('error', 'TOTP is already enabled.');
×
165
        }
166

167
        $otpAuthUrl = $user->enableTotp();
×
168
        $secret     = $user->getTotpSecret();
×
169

170
        return $this->view(setting('Auth.views')['action_totp_setup_show'], [
×
171
            'otpAuthUrl' => $otpAuthUrl,
×
172
            'secret'     => $secret ?? '',
×
173
            'qrCodeUrl'  => TOTP::getQRCodeUrl($otpAuthUrl),
×
174
            'confirmUrl' => url_to('totp-setup-confirm'),
×
175
        ]);
×
176
    }
177

178
    /**
179
     * Confirm TOTP setup by verifying the first code.
180
     *
181
     * On failure the pending secret is kept so the user can retry with the same
182
     * QR code already in their authenticator app.
183
     */
184
    public function totpSetupConfirm(): RedirectResponse|string
×
185
    {
186
        $user = auth()->user();
×
187
        $code = (string) $this->request->getPost('token');
×
188

189
        if (! $user->verifyTotpCode($code)) {
×
190
            return redirect()->route('totp-setup')
×
191
                ->with('error', lang('Auth.totpSetupInvalidCode'));
×
192
        }
193

194
        // Mark the secret as confirmed — from this point hasTotpEnabled() returns true.
195
        $user->confirmTotp();
×
196

197
        // Generate backup codes — shown to the user once on this page.
198
        $backupCodes = $user->generateBackupCodes();
×
199

200
        return $this->view(setting('Auth.views')['action_totp_setup_success'], [
×
201
            'redirectUrl' => url_to('security'),
×
202
            'backupCodes' => $backupCodes,
×
203
        ]);
×
204
    }
205

206
    /**
207
     * Disable TOTP for the current user.
208
     */
209
    public function totpDisable(): RedirectResponse
2 ✔
210
    {
211
        $code = (string) $this->request->getPost('token');
2 ✔
212
        $user = auth()->user();
2 ✔
213

214
        if (! $user->verifyTotpCode($code)) {
2 ✔
215
            return redirect()->back()->with('error', lang('Auth.invalidTotpToken'));
1 ✔
216
        }
217

218
        $user->disableTotp();
1 ✔
219
        service('securityNotifier')->totpDisabled($user);
1 ✔
220

221
        return redirect()->route('security')->with('message', 'Two-factor authentication has been disabled.');
1 ✔
222
    }
223

224
    /**
225
     * User-facing login activity feed: lists the user's recent login attempts
226
     * (success + failure) so they can spot suspicious activity targeting
227
     * their account.
228
     *
229
     * Add the route, e.g.:
230
     *     $routes->get('account/security/activity',
231
     *         'Daycry\Auth\Controllers\UserSecurityController::loginActivity',
232
     *         ['as' => 'security-activity']);
233
     */
234
    public function loginActivity(): string
×
235
    {
236
        $user = auth()->user();
×
237

238
        /** @var LoginModel $loginModel */
239
        $loginModel = model(LoginModel::class);
×
240

241
        $limit = max(1, (int) ($this->request->getGet('limit') ?? 25));
×
242
        $limit = min($limit, 100);
×
243

244
        $entries = $loginModel->recentForUser($user, $limit);
×
245

246
        $views    = setting('Auth.views');
×
247
        $viewName = $views['security_login_activity'] ?? 'Daycry\Auth\Views\security\login_activity';
×
248

249
        return $this->view($viewName, [
×
250
            'entries' => $entries,
×
251
            'limit'   => $limit,
×
252
        ]);
×
253
    }
254

255
    /**
256
     * Renders the password confirmation form.
257
     *
258
     * Wire as:
259
     *     $routes->get('auth/confirm-password',
260
     *         'Daycry\Auth\Controllers\UserSecurityController::confirmPasswordView',
261
     *         ['filter' => 'session', 'as' => 'password-confirm-show']);
262
     */
263
    public function confirmPasswordView(): ResponseInterface
×
264
    {
265
        $views    = setting('Auth.views');
×
266
        $viewName = $views['confirm_password'] ?? 'Daycry\Auth\Views\confirm_password';
×
267

268
        $content = $this->view($viewName);
×
269

270
        return $this->response->setBody($content);
×
271
    }
272

273
    /**
274
     * Verifies the submitted password against the logged-in user, stamps
275
     * `password_confirmed_at` in the session, then redirects to the URL
276
     * the user was originally trying to reach (or to a fallback).
277
     *
278
     * Wire as:
279
     *     $routes->post('auth/confirm-password',
280
     *         'Daycry\Auth\Controllers\UserSecurityController::confirmPasswordAction',
281
     *         ['filter' => 'session', 'as' => 'password-confirm']);
282
     */
283
    public function confirmPasswordAction(): RedirectResponse
×
284
    {
285
        $user     = auth()->user();
×
286
        $password = (string) $this->request->getPost('password');
×
287

288
        if ($user === null || $password === '') {
×
289
            return redirect()->route('password-confirm-show')
×
290
                ->with('error', lang('Auth.invalidPassword'));
×
291
        }
292

293
        /** @var Passwords $passwords */
294
        $passwords = service('passwords');
×
295

296
        if (! $passwords->verify($password, $user->getPasswordHash())) {
×
297
            return redirect()->route('password-confirm-show')
×
298
                ->with('error', lang('Auth.invalidPassword'));
×
299
        }
300

301
        session()->set('password_confirmed_at', time());
×
302

303
        (new AuditLogger())->record(AuditLogger::EVENT_PASSWORD_CONFIRMED, (int) $user->id);
×
304

305
        $intended = (string) (session()->getTempdata('passwordConfirmIntendedUrl') ?? '');
×
306
        session()->removeTempdata('passwordConfirmIntendedUrl');
×
307

308
        if ($intended === '') {
×
309
            $intended = config('Auth')->loginRedirect();
×
310
        }
311

312
        return redirect()->to($intended);
×
313
    }
314

315
    /**
316
     * Change-password form, or the set-password form for an account that has
317
     * no usable password yet (e.g. created through OAuth).
318
     */
319
    public function changePasswordView(): string
2 ✔
320
    {
321
        return $this->view($this->viewFor('security-change-password'), [
2 ✔
322
            'hasPassword' => $this->hasUsablePassword(auth()->user()),
2 ✔
323
        ]);
2 ✔
324
    }
325

326
    /**
327
     * Changes the password (requires the current one), or sets a first
328
     * password on a password-less account (requires an e-mail address and a
329
     * recent interactive sign-in instead).
330
     *
331
     * Every other device session and remember-me token is revoked, issued
332
     * JWTs are invalidated; the current session stays signed in.
333
     */
334
    public function changePassword(): RedirectResponse
8 ✔
335
    {
336
        $user        = auth()->user();
8 ✔
337
        $hasPassword = $this->hasUsablePassword($user);
8 ✔
338

339
        /** @var Passwords $passwords */
340
        $passwords = service('passwords');
8 ✔
341

342
        if (! $hasPassword && (string) $user->email === '') {
8 ✔
343
            return redirect()->back()->with('error', lang('Auth.changePasswordNeedsEmail'));
1 ✔
344
        }
345

346
        $denied = $this->reauthenticate($user);
7 ✔
347
        if ($denied instanceof RedirectResponse) {
7 ✔
348
            return $denied;
2 ✔
349
        }
350

351
        $newPassword = (string) $this->request->getPost('new_password');
5 ✔
352

353
        if ($newPassword !== (string) $this->request->getPost('new_password_confirm')) {
5 ✔
354
            return redirect()->back()->with('error', lang('Auth.changePasswordMismatch'));
1 ✔
355
        }
356

357
        $check = $passwords->check($newPassword, $user);
4 ✔
358

359
        if (! $check->isOK()) {
4 ✔
360
            return redirect()->back()->with('error', $check->reason());
1 ✔
361
        }
362

363
        $previousHash = $user->getPasswordHash();
3 ✔
364

365
        $user->setPassword($newPassword);
3 ✔
366
        model(UserModel::class)->save($user);
3 ✔
367

368
        // History, password_changed_at, JWT access + refresh revocation.
369
        (new PasswordChangeRecorder())->record($user, $previousHash);
3 ✔
370

371
        // Sign out everywhere else; keep this session.
372
        model(DeviceSessionModel::class)->terminateAllForUser($user, session_id());
3 ✔
373
        model(RememberModel::class)->purgeRememberTokensByUserId($user->id);
3 ✔
374

375
        (new AuditLogger())->record(AuditLogger::EVENT_PASSWORD_CHANGED, (int) $user->id);
3 ✔
376
        service('securityNotifier')->passwordChanged($user);
3 ✔
377
        Events::trigger('password-changed', $user);
3 ✔
378

379
        return redirect()->route('security')
3 ✔
380
            ->with('message', lang($hasPassword ? 'Auth.changePasswordSuccess' : 'Auth.setPasswordSuccess'));
3 ✔
381
    }
382

383
    /**
384
     * Form to request an e-mail address change.
385
     */
NEW
386
    public function changeEmailView(): string
×
387
    {
NEW
388
        $user = auth()->user();
×
389

NEW
390
        return $this->view($this->viewFor('security-change-email'), [
×
NEW
391
            'hasPassword'  => $this->hasUsablePassword($user),
×
NEW
392
            'currentEmail' => (string) $user->email,
×
NEW
393
        ]);
×
394
    }
395

396
    /**
397
     * Requests an e-mail change: re-authenticates (current password, or a
398
     * recent sign-in on a password-less account), e-mails a single-use
399
     * confirmation link to the NEW address and warns the CURRENT one.
400
     * Nothing changes until the link is confirmed.
401
     */
402
    public function changeEmail(): RedirectResponse
7 ✔
403
    {
404
        $user = auth()->user();
7 ✔
405

406
        $denied = $this->reauthenticate($user);
7 ✔
407
        if ($denied instanceof RedirectResponse) {
7 ✔
408
            return $denied;
2 ✔
409
        }
410

411
        $newEmail = strtolower(trim((string) $this->request->getPost('new_email')));
6 ✔
412

413
        if (filter_var($newEmail, FILTER_VALIDATE_EMAIL) === false) {
6 ✔
414
            return redirect()->back()->with('error', lang('Auth.changeEmailInvalid'));
1 ✔
415
        }
416

417
        if ($newEmail === strtolower((string) $user->email)) {
6 ✔
418
            return redirect()->back()->with('error', lang('Auth.changeEmailUnchanged'));
1 ✔
419
        }
420

421
        // Authenticated-only, low-severity enumeration surface (session +
422
        // CSRF; add the `rates` filter to the route).
423
        if ($this->emailBelongsToAnotherAccount($newEmail, $user)) {
6 ✔
424
            return redirect()->back()->with('error', lang('Auth.changeEmailAlreadyUsed'));
1 ✔
425
        }
426

427
        try {
428
            (new TokenEmailSender())->sendTokenEmail(
5 ✔
429
                $user,
5 ✔
430
                IdentityType::EMAIL_CHANGE->value,
5 ✔
431
                (int) setting('AuthSecurity.emailChangeLifetime'),
5 ✔
432
                lang('Auth.changeEmailSubject'),
5 ✔
433
                $this->viewFor('email-change-email'),
5 ✔
434
                [],
5 ✔
435
                null,
5 ✔
436
                $newEmail,
5 ✔
437
                ['new_email' => $newEmail],
5 ✔
438
            );
5 ✔
NEW
439
        } catch (RuntimeException $e) {
×
NEW
440
            log_message('error', 'E-mail change confirmation could not be sent: {message}', ['message' => $e->getMessage()]);
×
441

NEW
442
            return redirect()->back()->with('error', lang('Auth.unableSendEmailToUser', [$newEmail]));
×
443
        }
444

445
        service('securityNotifier')->emailChangeRequested($user, $newEmail);
5 ✔
446
        (new AuditLogger())->record(AuditLogger::EVENT_EMAIL_CHANGE_REQUEST, (int) $user->id);
5 ✔
447

448
        return redirect()->back()->with('message', lang('Auth.changeEmailSent'));
5 ✔
449
    }
450

451
    /**
452
     * Confirmation page for the e-mailed link. GET never mutates (mail
453
     * scanners and link prefetchers follow GET links); it renders a form that
454
     * POSTs the token to confirmEmailChange().
455
     */
456
    public function confirmEmailChangeView(): RedirectResponse|string
2 ✔
457
    {
458
        $token = (string) $this->request->getGet('token');
2 ✔
459

460
        [$identity, $error] = $this->pendingToken(IdentityType::EMAIL_CHANGE, $token, 'changeEmail');
2 ✔
461
        if ($identity === null) {
2 ✔
462
            return redirect()->route('security')->with('error', $error);
1 ✔
463
        }
464

465
        return $this->view($this->viewFor('security-confirm-email-change'), [
1 ✔
466
            'token'    => $token,
1 ✔
467
            'newEmail' => (string) ($this->identityExtra($identity)['new_email'] ?? ''),
1 ✔
468
        ]);
1 ✔
469
    }
470

471
    /**
472
     * Applies a confirmed e-mail change. The token is single-use and must
473
     * belong to the signed-in user; the new address is re-checked.
474
     */
475
    public function confirmEmailChange(): RedirectResponse
4 ✔
476
    {
477
        $user = auth()->user();
4 ✔
478

479
        [$identity, $error] = $this->pendingToken(IdentityType::EMAIL_CHANGE, (string) $this->request->getPost('token'), 'changeEmail');
4 ✔
480
        if ($identity === null) {
4 ✔
481
            return redirect()->route('security')->with('error', $error);
2 ✔
482
        }
483

484
        // Consume first: the link is single-use whatever happens next.
485
        model(UserIdentityModel::class)->delete($identity->id);
3 ✔
486

487
        $newEmail = (string) ($this->identityExtra($identity)['new_email'] ?? '');
3 ✔
488

489
        if (filter_var($newEmail, FILTER_VALIDATE_EMAIL) === false) {
3 ✔
NEW
490
            return redirect()->route('security')->with('error', lang('Auth.changeEmailTokenInvalid'));
×
491
        }
492

493
        if ($this->emailBelongsToAnotherAccount($newEmail, $user)) {
3 ✔
494
            return redirect()->route('security')->with('error', lang('Auth.changeEmailAlreadyUsed'));
1 ✔
495
        }
496

497
        $oldEmail = (string) $user->email;
2 ✔
498

499
        // Save the ENTITY: UserModel syncs the login identity (an array update
500
        // would skip saveEmailIdentity and leave the old login address).
501
        $user->setEmail($newEmail);
2 ✔
502

503
        try {
504
            model(UserModel::class)->save($user);
2 ✔
NEW
505
        } catch (DatabaseException|FrameworkDatabaseException) {
×
506
            // Lost a race on the unique (type, secret) index.
NEW
507
            return redirect()->route('security')->with('error', lang('Auth.changeEmailAlreadyUsed'));
×
508
        }
509

510
        (new AuditLogger())->record(AuditLogger::EVENT_EMAIL_CHANGED, (int) $user->id);
2 ✔
511

512
        if ($oldEmail !== '') {
2 ✔
513
            service('securityNotifier')->emailChanged($user, $oldEmail);
1 ✔
514
        }
515

516
        Events::trigger('email-changed', $user, $oldEmail);
2 ✔
517

518
        return redirect()->route('security')->with('message', lang('Auth.changeEmailSuccess'));
2 ✔
519
    }
520

521
    /**
522
     * Disconnects an OAuth provider from the account. Refused when it is the
523
     * last way to sign in: a usable password, another linked provider or a
524
     * passkey (stored in its own table) must remain.
525
     */
526
    public function unlinkOauth(string $provider): RedirectResponse
5 ✔
527
    {
528
        $user          = auth()->user();
5 ✔
529
        $type          = IdentityType::oauthProvider($provider);
5 ✔
530
        $identityModel = model(UserIdentityModel::class);
5 ✔
531
        $identity      = $identityModel->getIdentityByType($user, $type);
5 ✔
532

533
        if ($identity === null) {
5 ✔
534
            return redirect()->back()->with('error', lang('Auth.unlinkOauthNotFound', [$provider]));
1 ✔
535
        }
536

537
        $otherProviders = array_filter(
4 ✔
538
            $identityModel->getIdentities($user),
4 ✔
539
            static fn (UserIdentity $i): bool => str_starts_with((string) $i->type, 'oauth_') && $i->type !== $type,
4 ✔
540
        );
4 ✔
541

542
        if (! $this->hasUsablePassword($user) && $otherProviders === [] && ! $user->hasWebAuthnCredentials()) {
4 ✔
543
            return redirect()->back()->with('error', lang('Auth.unlinkOauthLastMethod'));
2 ✔
544
        }
545

546
        $identityModel->delete($identity->id);
2 ✔
547

548
        (new AuditLogger())->record(AuditLogger::EVENT_OAUTH_UNLINKED, (int) $user->id, ['provider' => $provider]);
2 ✔
549
        Events::trigger('oauth-unlinked', $user, $provider);
2 ✔
550

551
        return redirect()->back()->with('message', lang('Auth.unlinkOauthSuccess', [$provider]));
2 ✔
552
    }
553

554
    /**
555
     * Account deletion form: password re-auth, or "send me a confirmation
556
     * link" on a password-less account.
557
     */
558
    public function deleteAccountView(): string
1 ✔
559
    {
560
        $user = auth()->user();
1 ✔
561

562
        return $this->view($this->viewFor('security-delete-account'), [
1 ✔
563
            'hasPassword' => $this->hasUsablePassword($user),
1 ✔
564
            'hasEmail'    => (string) $user->email !== '',
1 ✔
565
        ]);
1 ✔
566
    }
567

568
    /**
569
     * Deletes (anonymises) the account. A password account is deleted right
570
     * after its current password is verified; a password-less account gets a
571
     * single-use confirmation link by e-mail instead (the link is the re-auth).
572
     */
573
    public function deleteAccount(): RedirectResponse
6 ✔
574
    {
575
        $user = auth()->user();
6 ✔
576

577
        if ($this->hasUsablePassword($user)) {
6 ✔
578
            $denied = $this->reauthenticate($user);
3 ✔
579

580
            return $denied ?? $this->performAccountDeletion($user);
3 ✔
581
        }
582

583
        if ((string) $user->email === '') {
3 ✔
584
            return redirect()->back()->with('error', lang('Auth.deleteAccountNeedsEmail'));
1 ✔
585
        }
586

587
        try {
588
            (new TokenEmailSender())->sendTokenEmail(
2 ✔
589
                $user,
2 ✔
590
                IdentityType::ACCOUNT_DELETE->value,
2 ✔
591
                (int) setting('AuthSecurity.emailChangeLifetime'),
2 ✔
592
                lang('Auth.deleteAccountSubject'),
2 ✔
593
                $this->viewFor('security-account-delete-email'),
2 ✔
594
            );
2 ✔
NEW
595
        } catch (RuntimeException $e) {
×
NEW
596
            log_message('error', 'Account deletion confirmation could not be sent: {message}', ['message' => $e->getMessage()]);
×
597

NEW
598
            return redirect()->back()->with('error', lang('Auth.unableSendEmailToUser', [(string) $user->email]));
×
599
        }
600

601
        return redirect()->back()->with('message', lang('Auth.deleteAccountEmailSent'));
2 ✔
602
    }
603

604
    /**
605
     * Confirmation page for the e-mailed deletion link (GET never mutates).
606
     */
607
    public function confirmDeleteAccountView(): RedirectResponse|string
1 ✔
608
    {
609
        $token = (string) $this->request->getGet('token');
1 ✔
610

611
        [$identity, $error] = $this->pendingToken(IdentityType::ACCOUNT_DELETE, $token, 'deleteAccount');
1 ✔
612
        if ($identity === null) {
1 ✔
NEW
613
            return redirect()->route('security')->with('error', $error);
×
614
        }
615

616
        return $this->view($this->viewFor('security-confirm-delete-account'), ['token' => $token]);
1 ✔
617
    }
618

619
    /**
620
     * Deletes a password-less account from its confirmed link.
621
     */
622
    public function confirmDeleteAccount(): RedirectResponse
2 ✔
623
    {
624
        [$identity, $error] = $this->pendingToken(IdentityType::ACCOUNT_DELETE, (string) $this->request->getPost('token'), 'deleteAccount');
2 ✔
625
        if ($identity === null) {
2 ✔
626
            return redirect()->route('security')->with('error', $error);
1 ✔
627
        }
628

629
        return $this->performAccountDeletion(auth()->user());
1 ✔
630
    }
631

632
    /**
633
     * Anonymises the account first (one transaction), then — only once it
634
     * succeeded — sends the farewell notice and signs the user out.
635
     */
636
    protected function performAccountDeletion(User $user): RedirectResponse
3 ✔
637
    {
638
        $userId = (int) $user->id;
3 ✔
639

640
        // Load the address now: the anonymizer removes the identity holding it.
641
        $user->getEmail();
3 ✔
642

643
        try {
644
            (new UserAnonymizer())->anonymize($user, 'self-service', $userId);
3 ✔
645
        } catch (Throwable $e) {
1 ✔
646
            log_message('error', 'Self-service account deletion failed for user {id}: {message}', ['id' => $userId, 'message' => $e->getMessage()]);
1 ✔
647

648
            return redirect()->back()->with('error', lang('Auth.deleteAccountFailed'));
1 ✔
649
        }
650

651
        service('securityNotifier')->accountDeleted($user);
2 ✔
652

653
        auth('session')->logout();
2 ✔
654

655
        Events::trigger('account-deleted', $userId);
2 ✔
656

657
        return redirect()->to(config('Auth')->logoutRedirect())->with('message', lang('Auth.deleteAccountSuccess'));
2 ✔
658
    }
659

660
    /**
661
     * Re-authentication for a sensitive change: the current password on a
662
     * password account, a recent interactive sign-in on a password-less one.
663
     *
664
     * @return RedirectResponse|null Null when the user is re-authenticated
665
     */
666
    protected function reauthenticate(User $user): ?RedirectResponse
17 ✔
667
    {
668
        if ($this->hasUsablePassword($user)) {
17 ✔
669
            /** @var Passwords $passwords */
670
            $passwords = service('passwords');
14 ✔
671

672
            if (! $passwords->verify((string) $this->request->getPost('current_password'), (string) $user->getPasswordHash())) {
14 ✔
673
                return redirect()->back()->with('error', lang('Auth.changePasswordCurrentInvalid'));
3 ✔
674
            }
675

676
            return null;
11 ✔
677
        }
678

679
        if (! $this->recentlyAuthenticated()) {
3 ✔
680
            return redirect()->back()->with('error', lang('Auth.reauthRequired'));
2 ✔
681
        }
682

683
        return null;
2 ✔
684
    }
685

686
    /**
687
     * Resolves a self-service confirmation token of the signed-in user.
688
     *
689
     * @param string $langPrefix 'changeEmail' | 'deleteAccount' (selects the *TokenInvalid / *TokenExpired line)
690
     *
691
     * @return array{0: UserIdentity|null, 1: string|null} [identity, error message]
692
     */
693
    protected function pendingToken(IdentityType $type, string $token, string $langPrefix): array
6 ✔
694
    {
695
        $identityModel = model(UserIdentityModel::class);
6 ✔
696
        $identity      = $token === '' ? null : $identityModel->getIdentityBySecret($type->value, $token);
6 ✔
697

698
        if ($identity === null || (int) $identity->user_id !== (int) auth()->id()) {
6 ✔
699
            return [null, lang('Auth.' . $langPrefix . 'TokenInvalid')];
3 ✔
700
        }
701

702
        if ($identity->expires !== null && Time::now()->isAfter($identity->expires)) {
5 ✔
703
            $identityModel->delete($identity->id);
1 ✔
704

705
            return [null, lang('Auth.' . $langPrefix . 'TokenExpired')];
1 ✔
706
        }
707

708
        return [$identity, null];
4 ✔
709
    }
710

711
    /**
712
     * @return array<string, mixed>
713
     */
714
    protected function identityExtra(UserIdentity $identity): array
3 ✔
715
    {
716
        $extra = json_decode((string) $identity->extra, true);
3 ✔
717

718
        return is_array($extra) ? $extra : [];
3 ✔
719
    }
720

721
    /**
722
     * Whether $email is the login address of an account other than $user.
723
     */
724
    protected function emailBelongsToAnotherAccount(string $email, User $user): bool
6 ✔
725
    {
726
        return model(UserIdentityModel::class)
6 ✔
727
            ->where('type', IdentityType::EMAIL_PASSWORD->value)
6 ✔
728
            ->where('secret', strtolower($email))
6 ✔
729
            ->where('user_id !=', $user->id)
6 ✔
730
            ->countAllResults() > 0;
6 ✔
731
    }
732

733
    /**
734
     * Whether the user can sign in with a password: an e-mail identity whose
735
     * hash is not the empty password (OAuth accounts that later confirm an
736
     * e-mail get an identity without a usable password).
737
     */
738
    protected function hasUsablePassword(User $user): bool
26 ✔
739
    {
740
        $hash = (string) $user->getPasswordHash();
26 ✔
741

742
        if ($hash === '') {
26 ✔
743
            return false;
5 ✔
744
        }
745

746
        /** @var Passwords $passwords */
747
        $passwords = service('passwords');
21 ✔
748

749
        return ! $passwords->verify('', $hash);
21 ✔
750
    }
751

752
    /**
753
     * Whether the user proved who they are recently: an interactive sign-in or
754
     * a password confirmation within AuthSecurity::$passwordConfirmationLifetime.
755
     * Gates sensitive changes on password-less accounts, where there is no
756
     * current password to ask for.
757
     */
758
    protected function recentlyAuthenticated(): bool
3 ✔
759
    {
760
        $lifetime = (int) (setting('AuthSecurity.passwordConfirmationLifetime') ?? 0);
3 ✔
761
        $latest   = max(
3 ✔
762
            (int) session(Session::AUTHENTICATED_AT_KEY),
3 ✔
763
            (int) session('password_confirmed_at'),
3 ✔
764
        );
3 ✔
765

766
        return $latest > 0 && time() - $latest <= $lifetime;
3 ✔
767
    }
768

769
    /**
770
     * View name for a self-service screen, with a fallback for published
771
     * Config\Auth::$views arrays that predate the key.
772
     */
773
    protected function viewFor(string $key): string
10 ✔
774
    {
775
        $defaults = [
10 ✔
776
            'security-change-password'        => '\Daycry\Auth\Views\security\change_password',
10 ✔
777
            'security-change-email'           => '\Daycry\Auth\Views\security\change_email',
10 ✔
778
            'security-confirm-email-change'   => '\Daycry\Auth\Views\security\confirm_email_change',
10 ✔
779
            'security-delete-account'         => '\Daycry\Auth\Views\security\delete_account',
10 ✔
780
            'security-confirm-delete-account' => '\Daycry\Auth\Views\security\confirm_delete_account',
10 ✔
781
            'security-account-delete-email'   => '\Daycry\Auth\Views\Email\account_delete_email',
10 ✔
782
            'email-change-email'              => '\Daycry\Auth\Views\Email\email_change_email',
10 ✔
783
        ];
10 ✔
784

785
        return setting('Auth.views')[$key] ?? $defaults[$key];
10 ✔
786
    }
787
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc