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

codeigniter4 / shield / 26749153941

01 Jun 2026 10:23AM UTC coverage: 92.987% (+0.2%) from 92.798%
26749153941

Pull #1327

github

web-flow
Merge 2294c68b5 into 82e825a8a
Pull Request #1327: feat: support hierarchical permission wildcards

51 of 51 new or added lines in 3 files covered. (100.0%)

41 existing lines in 2 files now uncovered.

2970 of 3194 relevant lines covered (92.99%)

56.0 hits per line

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

97.78
/src/Controllers/MagicLinkController.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter Shield.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
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 CodeIgniter\Shield\Controllers;
15

16
use App\Controllers\BaseController;
17
use CodeIgniter\Events\Events;
18
use CodeIgniter\Exceptions\PageNotFoundException;
19
use CodeIgniter\HTTP\IncomingRequest;
20
use CodeIgniter\HTTP\RedirectResponse;
21
use CodeIgniter\I18n\Time;
22
use CodeIgniter\Shield\Authentication\Authenticators\Session;
23
use CodeIgniter\Shield\Entities\User;
24
use CodeIgniter\Shield\Models\LoginModel;
25
use CodeIgniter\Shield\Models\UserIdentityModel;
26
use CodeIgniter\Shield\Models\UserModel;
27
use CodeIgniter\Shield\Traits\Viewable;
28

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

41
    /**
42
     * @var UserModel
43
     */
44
    protected $provider;
45

46
    public function __construct()
47
    {
48
        /** @var class-string<UserModel> $providerClass */
49
        $providerClass = setting('Auth.userProvider');
38✔
50

51
        $this->provider = new $providerClass();
38✔
52
    }
53

54
    /**
55
     * Displays the view to enter their email address
56
     * so an email can be sent to them.
57
     *
58
     * @return RedirectResponse|string
59
     */
60
    public function loginView()
61
    {
62
        if (! setting('Auth.allowMagicLinkLogins')) {
8✔
63
            return redirect()->route('login')->with('error', lang('Auth.magicLinkDisabled'));
2✔
64
        }
65

66
        if (auth()->loggedIn()) {
6✔
67
            return redirect()->to(config('Auth')->loginRedirect());
2✔
68
        }
69

70
        return $this->view(setting('Auth.views')['magic-link-login']);
4✔
71
    }
72

73
    /**
74
     * Receives the email from the user, creates the hash
75
     * to a user identity, and sends an email to the given
76
     * email address.
77
     *
78
     * @return RedirectResponse|string
79
     */
80
    public function loginAction()
81
    {
82
        if (! setting('Auth.allowMagicLinkLogins')) {
10✔
83
            return redirect()->route('login')->with('error', lang('Auth.magicLinkDisabled'));
2✔
84
        }
85

86
        // Validate email format
87
        $rules = $this->getValidationRules();
8✔
88
        if (! $this->validateData($this->request->getPost(), $rules, [], config('Auth')->DBGroup)) {
8✔
89
            return redirect()->route('magic-link')->with('errors', $this->validator->getErrors());
4✔
90
        }
91

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

96
        if ($user === null) {
4✔
97
            return redirect()->route('magic-link')->with('error', lang('Auth.invalidEmail', [$email]));
2✔
98
        }
99

100
        /** @var UserIdentityModel $identityModel */
101
        $identityModel = model(UserIdentityModel::class);
2✔
102

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

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

110
        $identityModel->insert([
2✔
111
            'user_id' => $user->id,
2✔
112
            'type'    => Session::ID_TYPE_MAGIC_LINK,
2✔
113
            'secret'  => $token,
2✔
114
            'expires' => Time::now()->addSeconds(setting('Auth.magicLinkLifetime')),
2✔
115
        ]);
2✔
116

117
        /** @var IncomingRequest $request */
118
        $request = service('request');
2✔
119

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

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

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

UNCOV
139
            return redirect()->route('magic-link')->with('error', lang('Auth.unableSendEmailToUser', [$user->email]));
×
140
        }
141

142
        // Clear the email
143
        $email->clear();
2✔
144

145
        return $this->displayMessage();
2✔
146
    }
147

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

156
    /**
157
     * Handles the GET request from the email
158
     */
159
    public function verify(): RedirectResponse
160
    {
161
        if (! setting('Auth.allowMagicLinkLogins')) {
20✔
162
            return redirect()->route('login')->with('error', lang('Auth.magicLinkDisabled'));
2✔
163
        }
164

165
        if ($this->request->getUserAgent()->isRobot()) {
18✔
166
            throw PageNotFoundException::forPageNotFound();
2✔
167
        }
168

169
        $token = $this->request->getGet('token');
16✔
170

171
        /** @var UserIdentityModel $identityModel */
172
        $identityModel = model(UserIdentityModel::class);
16✔
173

174
        $identity = $identityModel->getIdentityBySecret(Session::ID_TYPE_MAGIC_LINK, $token);
16✔
175

176
        $identifier = $token ?? '';
16✔
177

178
        // No token found?
179
        if ($identity === null) {
16✔
180
            $this->recordLoginAttempt($identifier, false);
2✔
181

182
            $credentials = ['magicLinkToken' => $token];
2✔
183
            Events::trigger('failedLogin', $credentials);
2✔
184

185
            return redirect()->route('magic-link')->with('error', lang('Auth.magicTokenNotFound'));
2✔
186
        }
187

188
        // Delete the db entry so it cannot be used again.
189
        $identityModel->delete($identity->id);
14✔
190

191
        // Token expired?
192
        if (Time::now()->isAfter($identity->expires)) {
14✔
193
            $this->recordLoginAttempt($identifier, false);
2✔
194

195
            $credentials = ['magicLinkToken' => $token];
2✔
196
            Events::trigger('failedLogin', $credentials);
2✔
197

198
            return redirect()->route('magic-link')->with('error', lang('Auth.magicLinkExpired'));
2✔
199
        }
200

201
        /** @var Session $authenticator */
202
        $authenticator = auth('session')->getAuthenticator();
12✔
203

204
        // If an action has been defined
205
        if ($authenticator->hasAction($identity->user_id)) {
12✔
206
            return redirect()->route('auth-action-show')->with('error', lang('Auth.needActivate'));
4✔
207
        }
208

209
        $user = $this->provider->findById($identity->user_id);
8✔
210

211
        // Start any login action that has been defined.
212
        if ($user instanceof User && $authenticator->startUpAction('login', $user) && $authenticator->hasAction($user->id)) {
8✔
213
            $this->recordLoginAttempt($identifier, true, $user->id);
4✔
214
            $authenticator->setPendingLoginMethod(Session::ID_TYPE_MAGIC_LINK);
4✔
215

216
            return redirect()->route('auth-action-show');
4✔
217
        }
218

219
        $authenticator->setPendingLoginMethod(Session::ID_TYPE_MAGIC_LINK);
4✔
220

221
        // Log the user in.
222
        $authenticator->loginById($identity->user_id);
4✔
223

224
        $user = $authenticator->getUser();
4✔
225

226
        $this->recordLoginAttempt($identifier, true, $user->id);
4✔
227

228
        // Get our login redirect url
229
        return redirect()->to(config('Auth')->loginRedirect());
4✔
230
    }
231

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

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

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