Skip to content

Commit 80d3e6c

Browse files
committed
feat: Magic Login Mode
1 parent 67fe054 commit 80d3e6c

File tree

1 file changed

+92
-10
lines changed

1 file changed

+92
-10
lines changed

src/Controllers/MagicLinkController.php

Lines changed: 92 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use CodeIgniter\HTTP\RedirectResponse;
2121
use CodeIgniter\I18n\Time;
2222
use CodeIgniter\Shield\Authentication\Authenticators\Session;
23+
use CodeIgniter\Shield\Exceptions\InvalidArgumentException;
2324
use CodeIgniter\Shield\Models\LoginModel;
2425
use CodeIgniter\Shield\Models\UserIdentityModel;
2526
use CodeIgniter\Shield\Models\UserModel;
@@ -103,8 +104,7 @@ public function loginAction()
103104
$identityModel->deleteIdentitiesByType($user, Session::ID_TYPE_MAGIC_LINK);
104105

105106
// Generate the code and save it as an identity
106-
helper('text');
107-
$token = random_string('crypto', 20);
107+
$token = $this->resolveMode()['token'];
108108

109109
$identityModel->insert([
110110
'user_id' => $user->id,
@@ -125,9 +125,13 @@ public function loginAction()
125125
$email = emailer(['mailType' => 'html'])
126126
->setFrom(setting('Email.fromEmail'), setting('Email.fromName') ?? '');
127127
$email->setTo($user->email);
128-
$email->setSubject(lang('Auth.magicLinkSubject'));
128+
129+
$email->setSubject($this->resolveMode()['emailSubject']);
130+
131+
$emailBodyViewFile = $this->resolveMode()['emailView'];
132+
129133
$email->setMessage($this->view(
130-
setting('Auth.views')['magic-link-email'],
134+
config('Auth')->views[$emailBodyViewFile],
131135
['token' => $token, 'user' => $user, 'ipAddress' => $ipAddress, 'userAgent' => $userAgent, 'date' => $date],
132136
['debug' => false],
133137
));
@@ -149,7 +153,9 @@ public function loginAction()
149153
*/
150154
protected function displayMessage(): string
151155
{
152-
return $this->view(setting('Auth.views')['magic-link-message']);
156+
$viewFile = $this->resolveMode()['displayMessageView'];
157+
158+
return $this->view(config('Auth')->views[$viewFile]);
153159
}
154160

155161
/**
@@ -165,20 +171,24 @@ public function verify(): RedirectResponse
165171
throw PageNotFoundException::forPageNotFound();
166172
}
167173

168-
$token = $this->request->getGet('token');
174+
$identifier = $this->request->getGet('token');
175+
176+
if ($this->request->is('post')) {
177+
$identifier = $this->request->getPost('magicCode');
178+
}
169179

170180
/** @var UserIdentityModel $identityModel */
171181
$identityModel = model(UserIdentityModel::class);
172182

173-
$identity = $identityModel->getIdentityBySecret(Session::ID_TYPE_MAGIC_LINK, $token);
183+
$identity = $identityModel->getIdentityBySecret(Session::ID_TYPE_MAGIC_LINK, $identifier);
174184

175-
$identifier = $token ?? '';
185+
$identifier ??= '';
176186

177187
// No token found?
178188
if ($identity === null) {
179189
$this->recordLoginAttempt($identifier, false);
180190

181-
$credentials = ['magicLinkToken' => $token];
191+
$credentials = ['magicLinkToken' => $identifier];
182192
Events::trigger('failedLogin', $credentials);
183193

184194
return redirect()->route('magic-link')->with('error', lang('Auth.magicTokenNotFound'));
@@ -191,7 +201,7 @@ public function verify(): RedirectResponse
191201
if (Time::now()->isAfter($identity->expires)) {
192202
$this->recordLoginAttempt($identifier, false);
193203

194-
$credentials = ['magicLinkToken' => $token];
204+
$credentials = ['magicLinkToken' => $identifier];
195205
Events::trigger('failedLogin', $credentials);
196206

197207
return redirect()->route('magic-link')->with('error', lang('Auth.magicLinkExpired'));
@@ -254,4 +264,76 @@ protected function getValidationRules(): array
254264
'email' => config('Auth')->emailValidationRules,
255265
];
256266
}
267+
268+
/**
269+
* resolveMode magic-login settings based on the configured mode.
270+
*
271+
* @param string $mode The selected magic login mode (e.g. "clickable", "6-numeric").
272+
*
273+
* @throws InvalidArgumentException
274+
*/
275+
protected function resolveMode(?string $mode = null): array
276+
{
277+
$mode ??= config('Auth')->magicLoginMode;
278+
279+
helper('text');
280+
281+
if ($mode === 'clickable') {
282+
return [
283+
'displayMessageView' => 'magic-link-message',
284+
'emailView' => 'magic-link-email',
285+
'emailSubject' => lang('Auth.magicLinkSubject'),
286+
'token' => random_string('crypto', 20),
287+
];
288+
}
289+
290+
$parts = explode('-', $mode, 2);
291+
292+
if (count($parts) !== 2) {
293+
throw new InvalidArgumentException(
294+
"Invalid magic login mode format '{$mode}'. Expected format: '<length>-<numeric|alpha|alnum|oneof>' or 'clickable'.",
295+
);
296+
}
297+
298+
[$length, $type] = $parts;
299+
300+
if (! is_numeric($length) || (int) $length <= 0) {
301+
throw new InvalidArgumentException(
302+
"Invalid length '{$length}' in magic login mode '{$mode}'. Must be a positive integer.",
303+
);
304+
}
305+
306+
$length = (int) $length;
307+
308+
return match ($type) {
309+
'numeric', 'alpha', 'alnum' => [
310+
'displayMessageView' => 'magic-link-code',
311+
'emailView' => 'magic-link-email-code',
312+
'emailSubject' => lang('Auth.magicCodeSubject'),
313+
'token' => random_string($type, $length),
314+
],
315+
316+
'oneof' => [
317+
'displayMessageView' => 'magic-link-code',
318+
'emailView' => 'magic-link-email-code',
319+
'emailSubject' => lang('Auth.magicCodeSubject'),
320+
'token' => $this->generateOneofToken($length),
321+
],
322+
323+
default => throw new InvalidArgumentException("Invalid magic login mode '{$mode}'. Expected format: '<length>-<numeric|alpha|alnum|oneof>'."),
324+
};
325+
}
326+
327+
/**
328+
* Generate a token by picking ONE of the fixed patterns: numeric, alpha, alnum.
329+
*/
330+
private function generateOneofToken(int $length): string
331+
{
332+
helper('text');
333+
334+
$patterns = ['numeric', 'alpha', 'alnum'];
335+
$chosenMode = $patterns[array_rand($patterns)];
336+
337+
return random_string($chosenMode, $length);
338+
}
257339
}

0 commit comments

Comments
 (0)