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

daycry / auth / 26956401634

04 Jun 2026 01:57PM UTC coverage: 76.441% (+0.5%) from 75.983%
26956401634

push

github

web-flow
Merge pull request #59 from daycry/feat/email-otp-login

feat: passwordless email OTP login (Magic Link code delivery mode)

73 of 93 new or added lines in 2 files covered. (78.49%)

5266 of 6889 relevant lines covered (76.44%)

70.74 hits per line

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

71.01
/src/Controllers/MagicLinkController.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\Events\Events;
17
use CodeIgniter\Exceptions\RuntimeException;
18
use CodeIgniter\HTTP\RedirectResponse;
19
use CodeIgniter\HTTP\ResponseInterface;
20
use CodeIgniter\I18n\Time;
21
use Daycry\Auth\Authentication\Authenticators\Session;
22
use Daycry\Auth\Libraries\TokenEmailSender;
23
use Daycry\Auth\Libraries\Utils;
24
use Daycry\Auth\Models\UserIdentityModel;
25
use Daycry\Auth\Models\UserModel;
26

27
/**
28
 * Handles "Magic Link" logins - an email-based
29
 * no-password login protocol. This works much
30
 * like password reset would, but Shield provides
31
 * this in place of password reset. It can also
32
 * be used on it's own without an email/password
33
 * login strategy.
34
 */
35
class MagicLinkController extends BaseAuthController
36
{
37
    /**
38
     * @var UserModel
39
     */
40
    protected $provider;
41

42
    public function __construct()
16 ✔
43
    {
44
        /** @var class-string<UserModel> $providerClass */
45
        $providerClass = setting('Auth.userProvider');
16 ✔
46

47
        $this->provider = new $providerClass();
16 ✔
48
    }
49

50
    /**
51
     * Displays the view to enter their email address
52
     * so an email can be sent to them.
53
     */
54
    public function loginView(): ResponseInterface
×
55
    {
56
        if (! setting('AuthSecurity.allowMagicLinkLogins')) {
×
57
            return $this->handleError(
×
58
                config('Auth')->loginRoute(),
×
59
                lang('Auth.magicLinkDisabled'),
×
60
            );
×
61
        }
62

63
        if (($redirect = $this->redirectIfLoggedIn()) instanceof RedirectResponse) {
×
64
            return $redirect;
×
65
        }
66

67
        $content = $this->view(setting('Auth.views')['magic-link-login']);
×
68

69
        return $this->response->setBody($content);
×
70
    }
71

72
    /**
73
     * Receives the email from the user, creates the hash
74
     * to a user identity, and sends an email to the given
75
     * email address.
76
     */
77
    public function loginAction(): RedirectResponse
4 ✔
78
    {
79
        if (! setting('AuthSecurity.allowMagicLinkLogins')) {
4 ✔
80
            return $this->handleError(
×
81
                config('Auth')->loginRoute(),
×
82
                lang('Auth.magicLinkDisabled'),
×
83
            );
×
84
        }
85

86
        $rules    = $this->getValidationRules();
4 ✔
87
        $postData = $this->request->getPost();
4 ✔
88

89
        // Resolve the form URL once. The 'magic-link' route is login/magic-link,
90
        // so redirecting to the literal 'magic-link' path would 404 — use the
91
        // named route.
92
        $formUrl = route_to('magic-link');
4 ✔
93

94
        if (! $this->validateRequest($postData, $rules)) {
4 ✔
NEW
95
            return $this->handleValidationError($formUrl);
×
96
        }
97

98
        $delivery = $this->request->getPost('delivery') === 'code' ? 'code' : 'link';
4 ✔
99

100
        if ($delivery === 'link' && ! setting('AuthSecurity.magicLinkEnableLink')) {
4 ✔
NEW
101
            return $this->handleError($formUrl, lang('Auth.magicLinkDisabled'));
×
102
        }
103
        if ($delivery === 'code' && ! setting('AuthSecurity.magicLinkEnableCode')) {
4 ✔
104
            return $this->handleError($formUrl, lang('Auth.magicLinkDisabled'));
1 ✔
105
        }
106

107
        $email = $this->request->getPost('email');
3 ✔
108
        $user  = $this->provider->findByCredentials(['email' => $email]);
3 ✔
109

110
        if ($delivery === 'code') {
3 ✔
111
            // Session-bound: remember the requested email regardless of whether
112
            // it exists (anti-enumeration), then show the code form.
113
            session()->set('magicCodeEmail', $email);
2 ✔
114

115
            if ($user !== null) {
2 ✔
116
                try {
117
                    (new TokenEmailSender())->sendTokenEmail(
1 ✔
118
                        $user,
1 ✔
119
                        Session::ID_TYPE_MAGIC_CODE,
1 ✔
120
                        setting('AuthSecurity.magicCodeLifetime'),
1 ✔
121
                        lang('Auth.magicCodeSubject'),
1 ✔
122
                        setting('Auth.views')['magic-link-code-email'],
1 ✔
123
                        [],
1 ✔
124
                        static fn (): string => Utils::generateNumericCode(6),
1 ✔
125
                    );
1 ✔
NEW
126
                } catch (RuntimeException $e) {
×
127
                    // Swallow send failures so the response can't be used to
128
                    // distinguish existing from non-existing accounts.
NEW
129
                    log_message('error', 'Magic code email failed: {m}', ['m' => $e->getMessage()]);
×
130
                }
131
            }
132

133
            return redirect()->route('magic-link-code');
2 ✔
134
        }
135

136
        // Link mode (existing behaviour, now anti-enumeration: unknown emails
137
        // and send failures both fall through to the generic message page).
138
        if ($user !== null) {
1 ✔
139
            try {
NEW
140
                (new TokenEmailSender())->sendTokenEmail(
×
NEW
141
                    $user,
×
NEW
142
                    Session::ID_TYPE_MAGIC_LINK,
×
NEW
143
                    setting('AuthSecurity.magicLinkLifetime'),
×
NEW
144
                    lang('Auth.magicLinkSubject'),
×
NEW
145
                    setting('Auth.views')['magic-link-email'],
×
NEW
146
                );
×
NEW
147
            } catch (RuntimeException $e) {
×
NEW
148
                log_message('error', 'Magic link email failed: {m}', ['m' => $e->getMessage()]);
×
149
            }
150
        }
151

152
        return redirect()->route('magic-link-message');
1 ✔
153
    }
154

155
    /**
156
     * Display the "What's happening/next" message to the user.
157
     */
158
    protected function displayMessage(): string
×
159
    {
160
        return $this->view(setting('Auth.views')['magic-link-message']);
×
161
    }
162

163
    /**
164
     * Shows the message view (public route)
165
     */
166
    public function messageView(): ResponseInterface
×
167
    {
168
        $content = $this->view(setting('Auth.views')['magic-link-message']);
×
169

170
        return $this->response->setBody($content);
×
171
    }
172

173
    /**
174
     * Shows the 6-digit code entry form. Only reachable after a code has been
175
     * requested (the pending email is in the session).
176
     */
177
    public function codeView(): ResponseInterface
2 ✔
178
    {
179
        if (! setting('AuthSecurity.allowMagicLinkLogins') || ! setting('AuthSecurity.magicLinkEnableCode')) {
2 ✔
NEW
180
            return $this->handleError(
×
NEW
181
                config('Auth')->loginRoute(),
×
NEW
182
                lang('Auth.magicLinkDisabled'),
×
NEW
183
            );
×
184
        }
185

186
        if (! session()->has('magicCodeEmail')) {
2 ✔
187
            return redirect()->route('magic-link');
1 ✔
188
        }
189

190
        $content = $this->view(setting('Auth.views')['magic-link-code']);
1 ✔
191

192
        return $this->response->setBody($content);
1 ✔
193
    }
194

195
    /**
196
     * Handles the GET request from the email
197
     */
198
    public function verify(): RedirectResponse
3 ✔
199
    {
200
        if (! setting('AuthSecurity.allowMagicLinkLogins')) {
3 ✔
201
            return redirect()->route('login')->with('error', lang('Auth.magicLinkDisabled'));
×
202
        }
203

204
        $token = $this->request->getGet('token');
3 ✔
205

206
        /** @var UserIdentityModel $identityModel */
207
        $identityModel = model(UserIdentityModel::class);
3 ✔
208

209
        $identity = $identityModel->getIdentityBySecret(Session::ID_TYPE_MAGIC_LINK, $token);
3 ✔
210

211
        $identifier = $token ?? '';
3 ✔
212

213
        // No token found?
214
        if ($identity === null) {
3 ✔
215
            $this->recordLoginAttempt(Session::ID_TYPE_MAGIC_LINK, $identifier, false);
2 ✔
216

217
            $credentials = ['magicLinkToken' => $token];
2 ✔
218
            Events::trigger('failedLogin', $credentials);
2 ✔
219

220
            return redirect()->route('magic-link')->with('error', lang('Auth.magicTokenNotFound'));
2 ✔
221
        }
222

223
        // Delete the db entry so it cannot be used again.
224
        $identityModel->delete($identity->id);
2 ✔
225

226
        // Token expired?
227
        if (Time::now()->isAfter($identity->expires)) {
2 ✔
228
            $this->recordLoginAttempt(Session::ID_TYPE_MAGIC_LINK, $identifier, false);
1 ✔
229

230
            $credentials = ['magicLinkToken' => $token];
1 ✔
231
            Events::trigger('failedLogin', $credentials);
1 ✔
232

233
            return redirect()->route('magic-link')->with('error', lang('Auth.magicLinkExpired'));
1 ✔
234
        }
235

236
        /** @var Session $authenticator */
237
        $authenticator = auth('session')->getAuthenticator();
1 ✔
238

239
        // If an action has been defined
240
        if ($authenticator->hasAction($identity->user_id)) {
1 ✔
241
            return redirect()->route('auth-action-show')->with('error', lang('Auth.needActivate'));
×
242
        }
243

244
        // Log the user in
245
        $authenticator->loginById($identity->user_id);
1 ✔
246

247
        $user = $authenticator->getUser();
1 ✔
248

249
        $this->recordLoginAttempt(Session::ID_TYPE_MAGIC_LINK, $identifier, true, $user->id);
1 ✔
250

251
        // Give the developer a way to know the user
252
        // logged in via a magic link.
253
        session()->setTempdata('magicLogin', true);
1 ✔
254

255
        Events::trigger('magicLogin');
1 ✔
256

257
        // Get our login redirect url
258
        return redirect()->to(config('Auth')->loginRedirect());
1 ✔
259
    }
260

261
    /**
262
     * Verifies the 6-digit code (code delivery mode). The pending email is read
263
     * from the session, the code is matched against that user's own MAGIC_CODE
264
     * identity (never a global lookup), and the account's brute-force lockout
265
     * applies. Generic errors throughout (anti-enumeration).
266
     */
267
    public function verifyCode(): RedirectResponse
7 ✔
268
    {
269
        if (! setting('AuthSecurity.allowMagicLinkLogins') || ! setting('AuthSecurity.magicLinkEnableCode')) {
7 ✔
NEW
270
            return redirect()->route('login')->with('error', lang('Auth.magicLinkDisabled'));
×
271
        }
272

273
        $email = session()->get('magicCodeEmail');
7 ✔
274
        if (empty($email)) {
7 ✔
275
            return redirect()->route('magic-link');
1 ✔
276
        }
277

278
        $code = (string) $this->request->getPost('token');
6 ✔
279
        $user = $this->provider->findByCredentials(['email' => $email]);
6 ✔
280

281
        /** @var Session $authenticator */
282
        $authenticator = auth('session')->getAuthenticator();
6 ✔
283

284
        if ($user !== null) {
6 ✔
285
            $lockout       = $authenticator->getLockoutManager();
6 ✔
286
            $lockoutResult = $lockout->isLockedOut($user);
6 ✔
287

288
            if ($lockoutResult !== null) {
6 ✔
289
                // Generic message even while locked out: surfacing the lockout
290
                // reason here would confirm the account exists (a non-existent
291
                // email is never locked out and always gets the generic error),
292
                // breaking anti-enumeration.
293
                return redirect()->route('magic-link-code')->with('error', lang('Auth.magicCodeInvalid'));
1 ✔
294
            }
295

296
            /** @var UserIdentityModel $identityModel */
297
            $identityModel = model(UserIdentityModel::class);
6 ✔
298
            $identities    = $identityModel->getIdentitiesByTypes($user, [Session::ID_TYPE_MAGIC_CODE]);
6 ✔
299
            $identity      = $identities[0] ?? null;
6 ✔
300

301
            if (
302
                $identity !== null
6 ✔
303
                && $code !== ''
6 ✔
304
                && hash_equals((string) $identity->secret, hash('sha256', $code))
6 ✔
305
                && Time::now()->isBefore($identity->expires)
6 ✔
306
            ) {
307
                // Success — consume the code (single-use) and clear state.
308
                $identityModel->delete($identity->id);
2 ✔
309
                $lockout->resetOnSuccess($user);
2 ✔
310
                session()->remove('magicCodeEmail');
2 ✔
311

312
                $this->recordLoginAttempt(Session::ID_TYPE_MAGIC_CODE, (string) $email, true, $user->id);
2 ✔
313

314
                // Respect any pending post-auth action (mirrors verify()).
315
                if ($authenticator->hasAction($user->id)) {
2 ✔
NEW
316
                    return redirect()->route('auth-action-show')->with('error', lang('Auth.needActivate'));
×
317
                }
318

319
                $authenticator->loginById($user->id);
2 ✔
320
                session()->setTempdata('magicLogin', true);
2 ✔
321
                Events::trigger('magicLogin');
2 ✔
322

323
                return redirect()->to(config('Auth')->loginRedirect());
2 ✔
324
            }
325

326
            // Existing user, bad/expired code → count the failed attempt.
327
            $lockout->recordFailedAttempt($user);
5 ✔
328
        }
329

330
        // Generic failure path (unknown email OR bad/expired code).
331
        $this->recordLoginAttempt(Session::ID_TYPE_MAGIC_CODE, (string) $email, false);
5 ✔
332
        Events::trigger('failedLogin', ['magicCode' => $code]);
5 ✔
333

334
        return redirect()->route('magic-link-code')->with('error', lang('Auth.magicCodeInvalid'));
5 ✔
335
    }
336

337
    /**
338
     * Returns the rules that should be used for validation.
339
     *
340
     * @return         array<string, array<string, list<string>|string>>
341
     * @phpstan-return array<string, array<string, string|list<string>>>
342
     */
343
    protected function getValidationRules(): array
4 ✔
344
    {
345
        return [
4 ✔
346
            'email' => config('Auth')->emailValidationRules,
4 ✔
347
        ];
4 ✔
348
    }
349
}
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