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

daycry / auth / 26937880755

04 Jun 2026 07:38AM UTC coverage: 75.983% (+4.4%) from 71.569%
26937880755

push

github

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

feat

613 of 719 new or added lines in 42 files covered. (85.26%)

3 existing lines in 3 files now uncovered.

5179 of 6816 relevant lines covered (75.98%)

69.66 hits per line

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

0.0
/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\HTTP\RedirectResponse;
17
use CodeIgniter\HTTP\ResponseInterface;
18
use Daycry\Auth\Authentication\Passwords;
19
use Daycry\Auth\Libraries\TOTP;
20
use Daycry\Auth\Models\DeviceSessionModel;
21
use Daycry\Auth\Models\LoginModel;
22
use Daycry\Auth\Services\AuditLogger;
23

24
/**
25
 * UserSecurityController
26
 *
27
 * Lets authenticated users manage their own security settings:
28
 *   - Active device sessions (list + revoke)
29
 *   - TOTP 2FA setup / disable
30
 *
31
 * Routes to add in your app (example):
32
 *   $routes->group('account/security', ['filter' => 'session'], static function ($routes): void {
33
 *       $routes->get('/',                  'Daycry\Auth\Controllers\UserSecurityController::index',             ['as' => 'security']);
34
 *       $routes->post('sessions/revoke',   'Daycry\Auth\Controllers\UserSecurityController::revokeSession',     ['as' => 'security-revoke-session']);
35
 *       $routes->post('sessions/revoke-all','Daycry\Auth\Controllers\UserSecurityController::revokeAllSessions',['as' => 'security-revoke-all']);
36
 *       $routes->get('totp/setup',         'Daycry\Auth\Controllers\UserSecurityController::totpSetup',        ['as' => 'totp-setup']);
37
 *       $routes->post('totp/setup/confirm','Daycry\Auth\Controllers\UserSecurityController::totpSetupConfirm', ['as' => 'totp-setup-confirm']);
38
 *       $routes->post('totp/disable',      'Daycry\Auth\Controllers\UserSecurityController::totpDisable',      ['as' => 'totp-disable']);
39
 *       $routes->post('sessions/password', 'Daycry\Auth\Controllers\UserSecurityController::changePassword',   ['as' => 'security-change-password']);
40
 *       $routes->get('email/change',       'Daycry\Auth\Controllers\UserSecurityController::changeEmailView',  ['as' => 'security-change-email']);
41
 *       $routes->post('email/change',      'Daycry\Auth\Controllers\UserSecurityController::changeEmail',      ['as' => 'security-change-email-action']);
42
 *       $routes->get('email/confirm',      'Daycry\Auth\Controllers\UserSecurityController::confirmEmailChange',['as' => 'security-confirm-email']);
43
 *       $routes->post('oauth/unlink',      'Daycry\Auth\Controllers\UserSecurityController::unlinkOauth',      ['as' => 'security-unlink-oauth']);
44
 *   });
45
 */
46
class UserSecurityController extends BaseAuthController
47
{
48
    protected function getValidationRules(): array
×
49
    {
50
        return [];
×
51
    }
52

53
    /**
54
     * Security overview: device sessions + TOTP status.
55
     */
56
    public function index(): string
×
57
    {
58
        $user = auth()->user();
×
59

60
        /** @var DeviceSessionModel $deviceModel */
61
        $deviceModel = model(DeviceSessionModel::class);
×
62

63
        $sessions   = $deviceModel->getActiveForUser($user);
×
64
        $currentSid = session_id();
×
65

66
        return $this->view(setting('Auth.views')['security_overview'], [
×
NEW
67
            'sessions'            => $sessions,
×
NEW
68
            'currentSid'          => $currentSid,
×
NEW
69
            'totpEnabled'         => $user->hasTotpEnabled(),
×
NEW
70
            'webAuthnCredentials' => $user->webAuthnCredentials(),
×
NEW
71
            'webauthnEnabled'     => (bool) (setting('AuthSecurity.webauthnEnabled') ?? false),
×
UNCOV
72
        ]);
×
73
    }
74

75
    /**
76
     * Revoke a single device session.
77
     */
78
    public function revokeSession(): RedirectResponse
×
79
    {
80
        $sessionId = (string) $this->request->getPost('session_id');
×
81

82
        if ($sessionId === '') {
×
83
            return redirect()->back()->with('error', 'Invalid session.');
×
84
        }
85

86
        $user = auth()->user();
×
87

88
        /** @var DeviceSessionModel $deviceModel */
89
        $deviceModel = model(DeviceSessionModel::class);
×
90

91
        // Safety check: ensure the session belongs to the current user
92
        $session = $deviceModel->findBySessionId($sessionId);
×
93

94
        if ($session === null || $session->user_id !== $user->id) {
×
95
            return redirect()->back()->with('error', 'Session not found.');
×
96
        }
97

98
        $deviceModel->terminateSession($sessionId);
×
99

100
        return redirect()->back()->with('message', 'Session revoked successfully.');
×
101
    }
102

103
    /**
104
     * Revoke all other active sessions (keep current one).
105
     */
106
    public function revokeAllSessions(): RedirectResponse
×
107
    {
108
        $user = auth()->user();
×
109

110
        /** @var DeviceSessionModel $deviceModel */
111
        $deviceModel = model(DeviceSessionModel::class);
×
112
        $deviceModel->terminateAllForUser($user, session_id());
×
113

114
        return redirect()->back()->with('message', 'All other sessions have been revoked.');
×
115
    }
116

117
    /**
118
     * Show the TOTP setup page with QR code.
119
     *
120
     * Always generates a fresh secret. If the user navigates away before
121
     * confirming, a new QR code will be issued on their next visit.
122
     */
123
    public function totpSetup(): RedirectResponse|string
×
124
    {
125
        $user = auth()->user();
×
126

127
        if ($user->hasTotpEnabled()) {
×
128
            return redirect()->route('security')->with('error', 'TOTP is already enabled.');
×
129
        }
130

131
        $otpAuthUrl = $user->enableTotp();
×
132
        $secret     = $user->getTotpSecret();
×
133

134
        return $this->view(setting('Auth.views')['action_totp_setup_show'], [
×
135
            'otpAuthUrl' => $otpAuthUrl,
×
136
            'secret'     => $secret ?? '',
×
137
            'qrCodeUrl'  => TOTP::getQRCodeUrl($otpAuthUrl),
×
138
            'confirmUrl' => url_to('totp-setup-confirm'),
×
139
        ]);
×
140
    }
141

142
    /**
143
     * Confirm TOTP setup by verifying the first code.
144
     *
145
     * On failure the pending secret is kept so the user can retry with the same
146
     * QR code already in their authenticator app.
147
     */
148
    public function totpSetupConfirm(): RedirectResponse|string
×
149
    {
150
        $user = auth()->user();
×
151
        $code = (string) $this->request->getPost('token');
×
152

153
        if (! $user->verifyTotpCode($code)) {
×
154
            return redirect()->route('totp-setup')
×
155
                ->with('error', lang('Auth.totpSetupInvalidCode'));
×
156
        }
157

158
        // Mark the secret as confirmed — from this point hasTotpEnabled() returns true.
159
        $user->confirmTotp();
×
160

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

164
        return $this->view(setting('Auth.views')['action_totp_setup_success'], [
×
165
            'redirectUrl' => url_to('security'),
×
166
            'backupCodes' => $backupCodes,
×
167
        ]);
×
168
    }
169

170
    /**
171
     * Disable TOTP for the current user.
172
     */
173
    public function totpDisable(): RedirectResponse
×
174
    {
175
        $code = (string) $this->request->getPost('token');
×
176
        $user = auth()->user();
×
177

178
        if (! $user->verifyTotpCode($code)) {
×
179
            return redirect()->back()->with('error', lang('Auth.invalidTotpToken'));
×
180
        }
181

182
        $user->disableTotp();
×
183

184
        return redirect()->route('security')->with('message', 'Two-factor authentication has been disabled.');
×
185
    }
186

187
    /**
188
     * User-facing login activity feed: lists the user's recent login attempts
189
     * (success + failure) so they can spot suspicious activity targeting
190
     * their account.
191
     *
192
     * Add the route, e.g.:
193
     *     $routes->get('account/security/activity',
194
     *         'Daycry\Auth\Controllers\UserSecurityController::loginActivity',
195
     *         ['as' => 'security-activity']);
196
     */
197
    public function loginActivity(): string
×
198
    {
199
        $user = auth()->user();
×
200

201
        /** @var LoginModel $loginModel */
202
        $loginModel = model(LoginModel::class);
×
203

204
        $limit = max(1, (int) ($this->request->getGet('limit') ?? 25));
×
205
        $limit = min($limit, 100);
×
206

207
        $entries = $loginModel->recentForUser($user, $limit);
×
208

209
        $views    = setting('Auth.views');
×
210
        $viewName = $views['security_login_activity'] ?? 'Daycry\Auth\Views\security\login_activity';
×
211

212
        return $this->view($viewName, [
×
213
            'entries' => $entries,
×
214
            'limit'   => $limit,
×
215
        ]);
×
216
    }
217

218
    /**
219
     * Renders the password confirmation form.
220
     *
221
     * Wire as:
222
     *     $routes->get('auth/confirm-password',
223
     *         'Daycry\Auth\Controllers\UserSecurityController::confirmPasswordView',
224
     *         ['filter' => 'session', 'as' => 'password-confirm-show']);
225
     */
226
    public function confirmPasswordView(): ResponseInterface
×
227
    {
228
        $views    = setting('Auth.views');
×
229
        $viewName = $views['confirm_password'] ?? 'Daycry\Auth\Views\confirm_password';
×
230

231
        $content = $this->view($viewName);
×
232

233
        return $this->response->setBody($content);
×
234
    }
235

236
    /**
237
     * Verifies the submitted password against the logged-in user, stamps
238
     * `password_confirmed_at` in the session, then redirects to the URL
239
     * the user was originally trying to reach (or to a fallback).
240
     *
241
     * Wire as:
242
     *     $routes->post('auth/confirm-password',
243
     *         'Daycry\Auth\Controllers\UserSecurityController::confirmPasswordAction',
244
     *         ['filter' => 'session', 'as' => 'password-confirm']);
245
     */
246
    public function confirmPasswordAction(): RedirectResponse
×
247
    {
248
        $user     = auth()->user();
×
249
        $password = (string) $this->request->getPost('password');
×
250

251
        if ($user === null || $password === '') {
×
252
            return redirect()->route('password-confirm-show')
×
253
                ->with('error', lang('Auth.invalidPassword'));
×
254
        }
255

256
        /** @var Passwords $passwords */
257
        $passwords = service('passwords');
×
258

259
        if (! $passwords->verify($password, $user->getPasswordHash())) {
×
260
            return redirect()->route('password-confirm-show')
×
261
                ->with('error', lang('Auth.invalidPassword'));
×
262
        }
263

264
        session()->set('password_confirmed_at', time());
×
265

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

268
        $intended = (string) (session()->getTempdata('passwordConfirmIntendedUrl') ?? '');
×
269
        session()->removeTempdata('passwordConfirmIntendedUrl');
×
270

271
        if ($intended === '') {
×
272
            $intended = config('Auth')->loginRedirect();
×
273
        }
274

275
        return redirect()->to($intended);
×
276
    }
277
}
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