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

daycry / auth / 16343465380

17 Jul 2025 11:07AM UTC coverage: 59.224% (-0.6%) from 59.854%
16343465380

push

github

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

Improvements

57 of 292 new or added lines in 16 files covered. (19.52%)

6 existing lines in 4 files now uncovered.

1939 of 3274 relevant lines covered (59.22%)

22.81 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')) {
×
NEW
56
            return $this->handleError(
×
NEW
57
                config('Auth')->loginRoute(),
×
NEW
58
                lang('Auth.magicLinkDisabled'),
×
NEW
59
            );
×
60
        }
61

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

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

NEW
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')) {
×
NEW
79
            return $this->handleError(
×
NEW
80
                config('Auth')->loginRoute(),
×
NEW
81
                lang('Auth.magicLinkDisabled'),
×
NEW
82
            );
×
83
        }
84

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

NEW
89
        if (! $this->validateRequest($postData, $rules)) {
×
NEW
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) {
×
NEW
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'));
×
NEW
130
        $email->setMessage($this->view(setting('Auth.views')['magic-link-email'], [
×
NEW
131
            'token'     => $token,
×
NEW
132
            'ipAddress' => $ipAddress,
×
NEW
133
            'userAgent' => $userAgent,
×
NEW
134
            'date'      => $date,
×
NEW
135
        ]));
×
136

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

NEW
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
NEW
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
    {
NEW
163
        $content = $this->view(setting('Auth.views')['magic-link-message']);
×
164

NEW
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