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

daycry / auth / 22527357078

28 Feb 2026 07:22PM UTC coverage: 63.267% (+0.7%) from 62.568%
22527357078

push

github

daycry
Remove PHP 8.1 from PHPUnit CI matrix

Update .github/workflows/phpunit.yml to drop PHP 8.1 from the test matrix. CI will now run PHPUnit only on PHP 8.2 and 8.3, reducing the matrix to current supported versions.

3064 of 4843 relevant lines covered (63.27%)

41.52 hits per line

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

0.0
/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\HTTP\IncomingRequest;
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\Models\LoginModel;
23
use Daycry\Auth\Models\UserIdentityModel;
24
use Daycry\Auth\Models\UserModel;
25

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

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

46
        $this->provider = new $providerClass();
×
47
    }
48

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

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

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

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

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

85
        // Validate email format
86
        $rules    = $this->getValidationRules();
×
87
        $postData = $this->request->getPost();
×
88

89
        if (! $this->validateRequest($postData, $rules)) {
×
90
            return $this->handleValidationError('magic-link');
×
91
        }
92

93
        // Check if the user exists
94
        $email = $this->request->getPost('email');
×
95
        $user  = $this->provider->findByCredentials(['email' => $email]);
×
96

97
        if ($user === null) {
×
98
            return $this->handleError('magic-link', lang('Auth.invalidEmail'));
×
99
        }
100

101
        /** @var UserIdentityModel $identityModel */
102
        $identityModel = model(UserIdentityModel::class);
×
103

104
        // Delete any previous magic-link identities
105
        $identityModel->deleteIdentitiesByType($user, Session::ID_TYPE_MAGIC_LINK);
×
106

107
        // Generate the code and save it as an identity
108
        helper('text');
×
109
        $token = random_string('crypto', 20);
×
110

111
        $identityModel->insert([
×
112
            'user_id' => $user->id,
×
113
            'type'    => Session::ID_TYPE_MAGIC_LINK,
×
114
            'secret'  => $token,
×
115
            'expires' => Time::now()->addSeconds(setting('Auth.magicLinkLifetime'))->format('Y-m-d H:i:s'),
×
116
        ]);
×
117

118
        /** @var IncomingRequest $request */
119
        $request = service('request');
×
120

121
        $ipAddress = $request->getIPAddress();
×
122
        $userAgent = (string) $request->getUserAgent();
×
123
        $date      = Time::now()->toDateTimeString();
×
124

125
        // Send the user an email with the code
126
        helper('email');
×
127
        $email = emailer()->setFrom(setting('Email.fromEmail'), setting('Email.fromName') ?? '');
×
128
        $email->setTo($user->email);
×
129
        $email->setSubject(lang('Auth.magicLinkSubject'));
×
130
        $email->setMessage($this->view(setting('Auth.views')['magic-link-email'], [
×
131
            'token'     => $token,
×
132
            'ipAddress' => $ipAddress,
×
133
            'userAgent' => $userAgent,
×
134
            'date'      => $date,
×
135
        ]));
×
136

137
        if ($email->send(false) === false) {
×
138
            log_message('error', $email->printDebugger(['headers']));
×
139

140
            return $this->handleError('magic-link', lang('Auth.unableSendEmailToUser', [$user->email]));
×
141
        }
142

143
        // Clear the email
144
        $email->clear();
×
145

146
        // Redirect to message page instead of returning the view directly
147
        return redirect()->route('magic-link-message');
×
148
    }
149

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

158
    /**
159
     * Shows the message view (public route)
160
     */
161
    public function messageView(): ResponseInterface
×
162
    {
163
        $content = $this->view(setting('Auth.views')['magic-link-message']);
×
164

165
        return $this->response->setBody($content);
×
166
    }
167

168
    /**
169
     * Handles the GET request from the email
170
     */
171
    public function verify(): RedirectResponse
×
172
    {
173
        if (! setting('Auth.allowMagicLinkLogins')) {
×
174
            return redirect()->route('login')->with('error', lang('Auth.magicLinkDisabled'));
×
175
        }
176

177
        $token = $this->request->getGet('token');
×
178

179
        /** @var UserIdentityModel $identityModel */
180
        $identityModel = model(UserIdentityModel::class);
×
181

182
        $identity = $identityModel->getIdentityBySecret(Session::ID_TYPE_MAGIC_LINK, $token);
×
183

184
        $identifier = $token ?? '';
×
185

186
        // No token found?
187
        if ($identity === null) {
×
188
            $this->recordLoginAttempt($identifier, false);
×
189

190
            $credentials = ['magicLinkToken' => $token];
×
191
            Events::trigger('failedLogin', $credentials);
×
192

193
            return redirect()->route('magic-link')->with('error', lang('Auth.magicTokenNotFound'));
×
194
        }
195

196
        // Delete the db entry so it cannot be used again.
197
        $identityModel->delete($identity->id);
×
198

199
        // Token expired?
200
        if (Time::now()->isAfter($identity->expires)) {
×
201
            $this->recordLoginAttempt($identifier, false);
×
202

203
            $credentials = ['magicLinkToken' => $token];
×
204
            Events::trigger('failedLogin', $credentials);
×
205

206
            return redirect()->route('magic-link')->with('error', lang('Auth.magicLinkExpired'));
×
207
        }
208

209
        /** @var Session $authenticator */
210
        $authenticator = auth('session')->getAuthenticator();
×
211

212
        // If an action has been defined
213
        if ($authenticator->hasAction($identity->user_id)) {
×
214
            return redirect()->route('auth-action-show')->with('error', lang('Auth.needActivate'));
×
215
        }
216

217
        // Log the user in
218
        $authenticator->loginById($identity->user_id);
×
219

220
        $user = $authenticator->getUser();
×
221

222
        $this->recordLoginAttempt($identifier, true, $user->id);
×
223

224
        // Give the developer a way to know the user
225
        // logged in via a magic link.
226
        session()->setTempdata('magicLogin', true);
×
227

228
        Events::trigger('magicLogin');
×
229

230
        // Get our login redirect url
231
        return redirect()->to(config('Auth')->loginRedirect());
×
232
    }
233

234
    /**
235
     * @param int|string|null $userId
236
     */
237
    private function recordLoginAttempt(
×
238
        string $identifier,
239
        bool $success,
240
        $userId = null,
241
    ): void {
242
        /** @var LoginModel $loginModel */
243
        $loginModel = model(LoginModel::class);
×
244

245
        $loginModel->recordLoginAttempt(
×
246
            Session::ID_TYPE_MAGIC_LINK,
×
247
            $identifier,
×
248
            $success,
×
249
            $this->request->getIPAddress(),
×
250
            (string) $this->request->getUserAgent(),
×
251
            $userId,
×
252
        );
×
253
    }
254

255
    /**
256
     * Returns the rules that should be used for validation.
257
     *
258
     * @return         array<string, array<string, list<string>|string>>
259
     * @phpstan-return array<string, array<string, string|list<string>>>
260
     */
261
    protected function getValidationRules(): array
×
262
    {
263
        return [
×
264
            'email' => config('Auth')->emailValidationRules,
×
265
        ];
×
266
    }
267
}
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