• 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

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'], [
×
67
            'sessions'    => $sessions,
×
68
            'currentSid'  => $currentSid,
×
69
            'totpEnabled' => $user->hasTotpEnabled(),
×
70
        ]);
×
71
    }
72

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

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

84
        $user = auth()->user();
×
85

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

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

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

96
        $deviceModel->terminateSession($sessionId);
×
97

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

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

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

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

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

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

129
        $otpAuthUrl = $user->enableTotp();
×
130
        $secret     = $user->getTotpSecret();
×
131

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

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

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

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

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

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

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

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

180
        $user->disableTotp();
×
181

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

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

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

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

205
        $entries = $loginModel->recentForUser($user, $limit);
×
206

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

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

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

NEW
229
        $content = $this->view($viewName);
×
230

NEW
231
        return $this->response->setBody($content);
×
232
    }
233

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

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

254
        /** @var Passwords $passwords */
NEW
255
        $passwords = service('passwords');
×
256

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

NEW
262
        session()->set('password_confirmed_at', time());
×
263

NEW
264
        (new AuditLogger())->record(AuditLogger::EVENT_PASSWORD_CONFIRMED, (int) $user->id);
×
265

NEW
266
        $intended = (string) (session()->getTempdata('passwordConfirmIntendedUrl') ?? '');
×
NEW
267
        session()->removeTempdata('passwordConfirmIntendedUrl');
×
268

NEW
269
        if ($intended === '') {
×
NEW
270
            $intended = config('Auth')->loginRedirect();
×
271
        }
272

NEW
273
        return redirect()->to($intended);
×
274
    }
275
}
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