diff --git a/README.md b/README.md index 5d10c9f31..54bac0d7a 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ and authorization purposes in web applications. * Stateless authentication using Personal Access Tokens * Optional Email verification on account registration * Optional Email-based Two-Factor Authentication after login -* Magic Link Login when a user forgets their password +* Magic Login when a user forgets their password * Flexible Groups-based access control (think Roles, but more flexible) * Users can be granted additional Permissions diff --git a/UPGRADING.md b/UPGRADING.md index d44b19991..e95fa7e1e 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,4 +1,34 @@ # Upgrade Guide +## Version 1.2.0 to 1.3.0 + +### Mandatory Config Changes + +A new configuration option has been introduced for controlling how Magic Login codes are generated and delivered. +The following property must be added in **app/Config/Auth.php**. + +```php +/** + * -------------------------------------------------------------------- + * Magic Login Mode + * -------------------------------------------------------------------- + * Determines how magic login works: + * + * - 'clickable' => send an email with a clickable link (default) + * - '-numeric' => send a numeric code with the specified length + * - '-alpha' => send an alphabetic code with the specified length + * - '-alnum' => send an alphanumeric code with the specified length + * - '-oneof' => send a code of the specified length; system chooses + * automatically one of: numeric, alpha, or alnum + * + * Examples: + * 'clickable' + * '6-numeric' // 6-digit numeric code + * '8-alpha' // 8-letter alphabetic code + * '7-alnum' // 7-character alphanumeric code + * '6-oneof' // 6-character code, chosen automatically + */ +public string $magicLoginMode = 'clickable'; +``` ## Version 1.0.0-beta.8 to 1.0.0 diff --git a/docs/customization/extending_controllers.md b/docs/customization/extending_controllers.md index 47a214898..12b64f647 100644 --- a/docs/customization/extending_controllers.md +++ b/docs/customization/extending_controllers.md @@ -8,8 +8,7 @@ various parts of the authentication process: - **ActionController** handles the after-login and after-registration actions, like Two Factor Authentication and Email Verification. - **LoginController** handles the login process. - **RegisterController** handles the registration process. Overriding this class allows you to customize the User Provider, the User Entity, and the validation rules. -- **MagicLinkController** handles the "lost password" process that allows a user to login with a link sent to their email. This allows you to - override the message that is displayed to a user to describe what is happening, if you'd like to provide more information than simply swapping out the view used. +- **MagicLinkController** handles the password-recovery and password-less login flow. It can deliver authentication credentials as a one-time login link or a one-time code (OTP) via email, depending on configuration. Developers may extend this controller to customize user-facing messages, providing clear context about the selected magic login method instead of only swapping view templates. ## How to Extend diff --git a/docs/customization/route_config.md b/docs/customization/route_config.md index 7b06c7d70..91460fb16 100644 --- a/docs/customization/route_config.md +++ b/docs/customization/route_config.md @@ -57,6 +57,7 @@ The above code registers the following routes: | POST | {locale}/login/magic-link | » | \CodeIgniter\Shield\Controllers\MagicLinkController::loginAction | | toolbar | | POST | {locale}/auth/a/handle | auth-action-handle | \CodeIgniter\Shield\Controllers\ActionController::handle | | toolbar | | POST | {locale}/auth/a/verify | auth-action-verify | \CodeIgniter\Shield\Controllers\ActionController::verify | | toolbar | +| POST | {locale}/login/verify-magic-link | verify-magic-link | \CodeIgniter\Shield\Controllers\MagicLinkController::verify | | toolbar | +--------+----------------------------------+--------------------+--------------------------------------------------------------------+----------------+---------------+ ``` diff --git a/docs/index.md b/docs/index.md index 10a91e15e..c166f9b31 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,7 +20,7 @@ The primary goals for Shield are: - **Stateless Authentication** using **Access Token**, **HMAC SHA256 Token**, or **JWT** - Optional **Email verification** on account registration - Optional **Email-based Two-Factor Authentication** after login -- **Magic Link Login** when a user forgets their password +- **Magic Login** when a user forgets their password - Flexible **Group-based Access Control** (think Roles, but more flexible), and users can be granted additional **Permissions** - A simple **Auth Helper** that provides access to the most common auth actions - Save initial settings in your code, so it can be in version control, but can also be updated in the database, thanks to our [Settings](https://github.com/codeigniter4/settings) library diff --git a/docs/references/authentication/session.md b/docs/references/authentication/session.md index 9245ebf7e..0a8d4bdb2 100644 --- a/docs/references/authentication/session.md +++ b/docs/references/authentication/session.md @@ -126,7 +126,7 @@ The following is a list of Events and Logging for Session Authenticator. - Send remember-me cookie w/o session cookie - OK → no event - NG → no event -- Magic-link +- Magic-login 1. Post email - OK → no event - NG → no event diff --git a/docs/references/events.md b/docs/references/events.md index 744362187..5833e4c41 100644 --- a/docs/references/events.md +++ b/docs/references/events.md @@ -47,7 +47,7 @@ Events::on('failedLogin', function($credentials) { // Outputs: ['email' => 'foo@example.com']; ``` -When the magic link login fails, the following array will be provided: +When the magic login fails, the following array will be provided: ```php ['magicLinkToken' => 'the token value used'] @@ -59,7 +59,7 @@ Fired immediately after a successful logout. The only argument is the `User` ent #### magicLogin -Fired when a user has been successfully logged in via a magic link. This event does not have any parameters passed in. The authenticated user can be discovered through the `auth()` helper. +Fired when a user has been successfully logged in via a magic login. This event does not have any parameters passed in. The authenticated user can be discovered through the `auth()` helper. ```php Events::on('magicLogin', function() { diff --git a/docs/references/magic_link_login.md b/docs/references/magic_link_login.md index e18edbd4e..a5b94b50a 100644 --- a/docs/references/magic_link_login.md +++ b/docs/references/magic_link_login.md @@ -1,43 +1,77 @@ -# Magic Link Login +# Magic Login -Magic Link Login is a feature that allows users to log in if they forget their -password. +Magic Login is an authentication feature that allows users to sign in using a one-time login link(magic link) or a one-time verification code(opt-code) sent to their email. This method provides a secure, password-less entry option, and is especially valuable for users who have forgotten their password, offering a fast and friction-free recovery experience. ## Configuration -### Configure Magic Link Login Functionality +### Configure Magic Login Functionality -Magic Link Login functionality is enabled by default. +Magic Login functionality is enabled by default. You can change it within the **app/Config/Auth.php** file. ```php public bool $allowMagicLinkLogins = true; ``` -### Magic Link Lifetime +### Magic Login Mode -By default, Magic Link can be used for 1 hour. This can be easily modified +Defines the format and type of the one-time credential sent to users for magic login. The system supports both password-less login via email link as well as multiple one-time verification code formats. The delivery method and code type are specified using configurable mode patterns. + +#### Supported Modes + +`clickable` (default) — Sends a secure, one-time login link to the user’s email that can be clicked to authenticate instantly. + +`-numeric` — Sends a numeric one-time code with the defined character length. + +`-alpha` — Sends an alphabet-only one-time code with the defined character length. + +`-alnum` — Sends an alphanumeric one-time code with the defined character length. + +`-oneof` — Sends a one-time code with the defined length, while the system automatically selects the random code type from: numeric, alpha, or alnum. + +```php +public string $magicLoginMode = '8-oneof'; +``` + +### Magic Lifetime + +By default, Magic can be used for 1 hour. This can be easily modified in the **app/Config/Auth.php** file. ```php public int $magicLinkLifetime = HOUR; ``` +!!! warning + + One-time numeric codes (such as a 6-digit `6-numeric` format) are inherently more predictable and may be easier to guess. If you plan to deliver a one-time password (OTP) instead of a magic link, we strongly recommend using the `6-oneof` credential mode, which allows the system to randomly generate numeric, alphabetic, or alphanumeric codes. This approach significantly increases unpredictability and makes OTP guessing more difficult. + + Additionally, to further mitigate brute-force and code-guessing risks, it is advisable to reduce the `$magicLinkLifetime` to a short window of only a few minutes (e.g., 120–300 seconds). + + ### Bot Detection -Some apps or devices may try to be "too helpful" by automatically visiting links - for example, to check if they're safe or to prepare for read-aloud features. Since this is a one-time magic link, such automated visits could invalidate it. To prevent this, Shield relies on the framework's `UserAgents::robots` config property (**app/Config/UserAgents.php**) to filter out requests that are likely initiated by non-human agents. +Some apps or devices may try to be "too helpful" by automatically visiting links - for example, to check if they're safe or to prepare for read-aloud features. Since this is a one-time magic link(`clickable`), such automated visits could invalidate it. To prevent this, Shield relies on the framework's `UserAgents::robots` config property (**app/Config/UserAgents.php**) to filter out requests that are likely initiated by non-human agents. -## Responding to Magic Link Logins +Unlike one-time login links, one-time codes (OTP) are not impacted by automated URL visits, since they require manual user input to complete authentication. Therefore, if your application delivers OTP(`6-numeric`,`6-alpha`,`6-alnum`,`6-oneof`) credentials, bot auto-visits do not introduce the same credential-invalidation risk. + +## Responding to Magic Logins !!! note You need to configure **app/Config/Email.php** to allow Shield to send emails. See [Installation](../getting_started/install.md#initial-setup). -Magic Link logins allow a user that has forgotten their password to have an email sent with a unique, one-time login link. Once they've logged in you can decide how to respond. In some cases, you might want to redirect them to a special page where they must choose a new password. In other cases, you might simply want to display a one-time message prompting them to go to their account page and choose a new password. +Magic Login allows users who have forgotten their password to authenticate using a secure, one-time credential delivered via email. Depending on configuration or product requirements, this credential can be either: + +1. unique, single-use login link + +2. one-time opt-code for manual entry in the application + +After authentication succeeds, the system behavior is up to the developer. Possible responses include redirecting the user to a dedicated password reset screen, or displaying a one-time confirmation message encouraging them to update their password from their account settings. ### Session Notification -You can detect if a user has finished the magic link login by checking for a session value, `magicLogin`. If they have recently completed the flow, it will exist and have a value of `true`. +You can detect if a user has finished the magic login by checking for a session value, `magicLogin`. If they have recently completed the flow, it will exist and have a value of `true`. ```php if (session('magicLogin')) { diff --git a/phpstan-baseline.php b/phpstan-baseline.php index 2c6844959..280e0aa28 100644 --- a/phpstan-baseline.php +++ b/phpstan-baseline.php @@ -199,7 +199,7 @@ 'rawMessage' => 'Call to deprecated function random_string(): The type \'basic\', \'md5\', and \'sha1\' are deprecated. They are not cryptographically secure.', 'identifier' => 'function.deprecated', - 'count' => 1, + 'count' => 3, 'path' => __DIR__ . '/src/Controllers/MagicLinkController.php', ]; $ignoreErrors[] = [ diff --git a/src/Config/Auth.php b/src/Config/Auth.php index b4f007b2c..d3e698d24 100644 --- a/src/Config/Auth.php +++ b/src/Config/Auth.php @@ -56,7 +56,9 @@ class Auth extends BaseConfig 'action_email_activate_email' => '\CodeIgniter\Shield\Views\Email\email_activate_email', 'magic-link-login' => '\CodeIgniter\Shield\Views\magic_link_form', 'magic-link-message' => '\CodeIgniter\Shield\Views\magic_link_message', + 'magic-link-code' => '\CodeIgniter\Shield\Views\magic_link_code', // (new) 'magic-link-email' => '\CodeIgniter\Shield\Views\Email\magic_link_email', + 'magic-link-email-code' => '\CodeIgniter\Shield\Views\Email\magic_link_email_code', // (new) ]; /** @@ -173,22 +175,45 @@ class Auth extends BaseConfig /** * -------------------------------------------------------------------- - * Allow Magic Link Logins + * Allow Magic Login * -------------------------------------------------------------------- - * If true, will allow the use of "magic links" sent via the email - * as a way to log a user in without the need for a password. - * By default, this is used in place of a password reset flow, but - * could be modified as the only method of login once an account - * has been set up. + * If true, users may log in using a secure, one-time credential sent by email. + * 5 delivery modes are supported: clickable login **link**, or a one-time **code** for manual entry. */ public bool $allowMagicLinkLogins = true; /** * -------------------------------------------------------------------- - * Magic Link Lifetime + * Magic Login Mode * -------------------------------------------------------------------- - * Specifies the amount of time, in seconds, that a magic link is valid. - * You can use Time Constants or any desired number. + * Determines how magic login works: + * + * - 'clickable' => send an email with a clickable link (default) + * - '-numeric' => send a numeric code with specified length + * - '-alpha' => send an alphabetic code with specified length + * - '-alnum' => send an alphanumeric code with specified length + * - '-oneof' => send a code of specified length; system chooses + * automatically one of: numeric, alpha, or alnum + * + * Examples: + * 'clickable' + * '6-numeric' // 6-digit numeric code + * '8-alpha' // 8-letter alphabetic code + * '7-alnum' // 7-character alphanumeric code + * '6-oneof' // 6-character code, type chosen automatically + */ + public string $magicLoginMode = 'clickable'; + + /** + * -------------------------------------------------------------------- + * Magic Login Lifetime + * -------------------------------------------------------------------- + * Time in seconds that a magic login credential remains valid. + * Applies to both **clickable login links** and **one-time codes**. + * + * When using one-time code mode, it is strongly recommended to set + * the lifetime to only a few minutes (e.g., 120-300 seconds) to reduce + * the risk of guessing or brute-force attempts. */ public int $magicLinkLifetime = HOUR; diff --git a/src/Config/AuthRoutes.php b/src/Config/AuthRoutes.php index 141978565..4e8381544 100644 --- a/src/Config/AuthRoutes.php +++ b/src/Config/AuthRoutes.php @@ -62,6 +62,12 @@ class AuthRoutes extends BaseConfig 'MagicLinkController::verify', 'verify-magic-link', // Route name ], + [ + 'post', + 'login/verify-magic-link', + 'MagicLinkController::verify', + 'verify-magic-link', // Route name + ], ], 'logout' => [ [ diff --git a/src/Controllers/MagicLinkController.php b/src/Controllers/MagicLinkController.php index cd399803d..b14b69ed5 100644 --- a/src/Controllers/MagicLinkController.php +++ b/src/Controllers/MagicLinkController.php @@ -20,6 +20,7 @@ use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\I18n\Time; use CodeIgniter\Shield\Authentication\Authenticators\Session; +use CodeIgniter\Shield\Exceptions\InvalidArgumentException; use CodeIgniter\Shield\Models\LoginModel; use CodeIgniter\Shield\Models\UserIdentityModel; use CodeIgniter\Shield\Models\UserModel; @@ -102,9 +103,9 @@ public function loginAction() // Delete any previous magic-link identities $identityModel->deleteIdentitiesByType($user, Session::ID_TYPE_MAGIC_LINK); - // Generate the code and save it as an identity - helper('text'); - $token = random_string('crypto', 20); + $mode = $this->resolveMode(); + + $token = $mode['token']; $identityModel->insert([ 'user_id' => $user->id, @@ -125,9 +126,11 @@ public function loginAction() $email = emailer(['mailType' => 'html']) ->setFrom(setting('Email.fromEmail'), setting('Email.fromName') ?? ''); $email->setTo($user->email); - $email->setSubject(lang('Auth.magicLinkSubject')); + + $email->setSubject($mode['emailSubject']); + $email->setMessage($this->view( - setting('Auth.views')['magic-link-email'], + setting('Auth.views')[$mode['emailView']], ['token' => $token, 'user' => $user, 'ipAddress' => $ipAddress, 'userAgent' => $userAgent, 'date' => $date], ['debug' => false], )); @@ -149,7 +152,9 @@ public function loginAction() */ protected function displayMessage(): string { - return $this->view(setting('Auth.views')['magic-link-message']); + $viewFile = $this->resolveMode()['displayMessageView']; + + return $this->view(config('Auth')->views[$viewFile]); } /** @@ -165,20 +170,24 @@ public function verify(): RedirectResponse throw PageNotFoundException::forPageNotFound(); } - $token = $this->request->getGet('token'); + $identifier = $this->request->getGet('token'); + + if ($this->request->is('post')) { + $identifier = $this->request->getPost('magicCode'); + } /** @var UserIdentityModel $identityModel */ $identityModel = model(UserIdentityModel::class); - $identity = $identityModel->getIdentityBySecret(Session::ID_TYPE_MAGIC_LINK, $token); + $identity = $identityModel->getIdentityBySecret(Session::ID_TYPE_MAGIC_LINK, $identifier); - $identifier = $token ?? ''; + $identifier ??= ''; // No token found? if ($identity === null) { $this->recordLoginAttempt($identifier, false); - $credentials = ['magicLinkToken' => $token]; + $credentials = ['magicLinkToken' => $identifier]; Events::trigger('failedLogin', $credentials); return redirect()->route('magic-link')->with('error', lang('Auth.magicTokenNotFound')); @@ -191,7 +200,7 @@ public function verify(): RedirectResponse if (Time::now()->isAfter($identity->expires)) { $this->recordLoginAttempt($identifier, false); - $credentials = ['magicLinkToken' => $token]; + $credentials = ['magicLinkToken' => $identifier]; Events::trigger('failedLogin', $credentials); return redirect()->route('magic-link')->with('error', lang('Auth.magicLinkExpired')); @@ -254,4 +263,76 @@ protected function getValidationRules(): array 'email' => config('Auth')->emailValidationRules, ]; } + + /** + * resolveMode magic-login settings based on the configured mode. + * + * @param string $mode The selected magic login mode (e.g. "clickable", "6-numeric"). + * + * @throws InvalidArgumentException + */ + protected function resolveMode(?string $mode = null): array + { + $mode ??= config('Auth')->magicLoginMode; + + helper('text'); + + if ($mode === 'clickable') { + return [ + 'displayMessageView' => 'magic-link-message', + 'emailView' => 'magic-link-email', + 'emailSubject' => lang('Auth.magicLinkSubject'), + 'token' => random_string('crypto', 20), + ]; + } + + $parts = explode('-', $mode, 2); + + if (count($parts) !== 2) { + throw new InvalidArgumentException( + "Invalid magic login mode format '{$mode}'. Expected format: '-' or 'clickable'.", + ); + } + + [$length, $type] = $parts; + + if (! is_numeric($length) || (int) $length <= 0) { + throw new InvalidArgumentException( + "Invalid length '{$length}' in magic login mode '{$mode}'. Must be a positive integer.", + ); + } + + $length = (int) $length; + + return match ($type) { + 'numeric', 'alpha', 'alnum' => [ + 'displayMessageView' => 'magic-link-code', + 'emailView' => 'magic-link-email-code', + 'emailSubject' => lang('Auth.magicCodeSubject'), + 'token' => random_string($type, $length), + ], + + 'oneof' => [ + 'displayMessageView' => 'magic-link-code', + 'emailView' => 'magic-link-email-code', + 'emailSubject' => lang('Auth.magicCodeSubject'), + 'token' => $this->generateOneofToken($length), + ], + + default => throw new InvalidArgumentException("Invalid magic login mode '{$mode}'. Expected format: '-'."), + }; + } + + /** + * Generate a token by picking ONE of the fixed patterns: numeric, alpha, alnum. + */ + private function generateOneofToken(int $length): string + { + helper('text'); + + $patterns = ['numeric', 'alpha', 'alnum']; + $chosenMode = $patterns[array_rand($patterns)]; + + return random_string($chosenMode, $length); + } } diff --git a/src/Language/ar/Auth.php b/src/Language/ar/Auth.php index cef33f1d8..6d127e36d 100644 --- a/src/Language/ar/Auth.php +++ b/src/Language/ar/Auth.php @@ -51,11 +51,15 @@ 'registerSuccess' => 'أهلا بك!', // Login - 'login' => 'تسجيل دخول', - 'needAccount' => 'هل تحتاج الى حساب؟', - 'rememberMe' => 'تذكر دخولي؟', - 'forgotPassword' => 'نسيت كلمة المرور؟', - 'useMagicLink' => 'تسجيل دخول بواسطة رابط دخول', + 'login' => 'تسجيل دخول', + 'needAccount' => 'هل تحتاج الى حساب؟', + 'rememberMe' => 'تذكر دخولي؟', + 'forgotPassword' => 'نسيت كلمة المرور؟', + 'verifyMagicCode' => '(To be translated) Verify Magic Code', + 'useMagicLink' => 'تسجيل دخول بواسطة رابط دخول', + + 'magicCodeSubject' => '(To be translated) Your Login Magic Code', + 'magicCodeText' => '(To be translated) A {0}‑character code has been sent to your email. Please enter it.', 'magicLinkSubject' => 'رابط الدخول الخاص بك', 'magicTokenNotFound' => 'تعذر التحقق من صحة الرابط.', 'magicLinkExpired' => 'عذرا ، لقد انتهت صلاحية الرابط.', diff --git a/src/Language/bg/Auth.php b/src/Language/bg/Auth.php index 413a8cff7..84fb28751 100644 --- a/src/Language/bg/Auth.php +++ b/src/Language/bg/Auth.php @@ -55,7 +55,10 @@ 'needAccount' => 'Нуждаете се от акаунт?', 'rememberMe' => 'Запомни ме?', 'forgotPassword' => 'Забравена парола?', + 'verifyMagicCode' => '(To be translated) Verify Magic Code', 'useMagicLink' => 'Използвайте линк за вход', + 'magicCodeSubject' => '(To be translated) Your Login Magic Code', + 'magicCodeText' => '(To be translated) A {0}‑character code has been sent to your email. Please enter it.', 'magicLinkSubject' => 'Вашият линк за вход', 'magicTokenNotFound' => 'Не може да се потвърди линка.', 'magicLinkExpired' => 'Съжаляваме, линкът е изтекъл.', diff --git a/src/Language/cs/Auth.php b/src/Language/cs/Auth.php index e497e7fa7..be9444e8a 100644 --- a/src/Language/cs/Auth.php +++ b/src/Language/cs/Auth.php @@ -55,7 +55,10 @@ 'needAccount' => 'Potřebujete účet?', 'rememberMe' => 'Zapamatovat si přihlášení?', 'forgotPassword' => 'Zapomněli jste heslo?', + 'verifyMagicCode' => '(To be translated) Verify Magic Code', 'useMagicLink' => 'Použít odkaz pro přihlášení', + 'magicCodeSubject' => '(To be translated) Your Login Magic Code', + 'magicCodeText' => '(To be translated) A {0}‑character code has been sent to your email. Please enter it.', 'magicLinkSubject' => 'Váš odkaz pro přihlášení', 'magicTokenNotFound' => 'Odkaz se nepodařilo ověřit.', 'magicLinkExpired' => 'Litujeme, ale platnost odkazu vypršela.', diff --git a/src/Language/de/Auth.php b/src/Language/de/Auth.php index ab786756c..45e8e0390 100644 --- a/src/Language/de/Auth.php +++ b/src/Language/de/Auth.php @@ -55,7 +55,10 @@ 'needAccount' => 'Brauchen Sie ein Konto?', 'rememberMe' => 'Angemeldet bleiben', 'forgotPassword' => 'Passwort vergessen?', + 'verifyMagicCode' => '(To be translated) Verify Magic Code', 'useMagicLink' => 'Einen Login-Link verwenden', + 'magicCodeSubject' => '(To be translated) Your Login Magic Code', + 'magicCodeText' => '(To be translated) A {0}‑character code has been sent to your email. Please enter it.', 'magicLinkSubject' => 'Ihr Login-Link', 'magicTokenNotFound' => 'Der Link konnte nicht verifiziert werden.', 'magicLinkExpired' => 'Sorry, der Link ist abgelaufen.', diff --git a/src/Language/en/Auth.php b/src/Language/en/Auth.php index 57a5ebb13..93dc4fe20 100644 --- a/src/Language/en/Auth.php +++ b/src/Language/en/Auth.php @@ -55,7 +55,10 @@ 'needAccount' => 'Need an account?', 'rememberMe' => 'Remember me?', 'forgotPassword' => 'Forgot your password?', + 'verifyMagicCode' => 'Verify Magic Code', 'useMagicLink' => 'Use a Login Link', + 'magicCodeSubject' => 'Your Login Magic Code', + 'magicCodeText' => 'A {0}‑character code has been sent to your email. Please enter it.', 'magicLinkSubject' => 'Your Login Link', 'magicTokenNotFound' => 'Unable to verify the link.', 'magicLinkExpired' => 'Sorry, link has expired.', diff --git a/src/Language/es/Auth.php b/src/Language/es/Auth.php index db54aae72..a375834a3 100644 --- a/src/Language/es/Auth.php +++ b/src/Language/es/Auth.php @@ -55,7 +55,10 @@ 'needAccount' => '¿Necesitas una cuenta?', 'rememberMe' => 'Recordarme', 'forgotPassword' => '¿Olvidaste tu contraseña', + 'verifyMagicCode' => '(To be translated) Verify Magic Code', 'useMagicLink' => 'Usar un enlace de inicio de sesión', + 'magicCodeSubject' => '(To be translated) Your Login Magic Code', + 'magicCodeText' => '(To be translated) A {0}‑character code has been sent to your email. Please enter it.', 'magicLinkSubject' => 'Tu enlace de inicio de sesión', 'magicTokenNotFound' => 'No se puede verificar el enlace.', 'magicLinkExpired' => 'Lo siento, el enlace ha caducado.', diff --git a/src/Language/fa/Auth.php b/src/Language/fa/Auth.php index 289709a4f..4a47c86e8 100644 --- a/src/Language/fa/Auth.php +++ b/src/Language/fa/Auth.php @@ -55,7 +55,10 @@ 'needAccount' => 'نیاز به یک حساب کاربری دارید؟', 'rememberMe' => 'مرا به خاطر بسپار؟', 'forgotPassword' => 'رمز عبور را فراموش کرده اید؟', + 'verifyMagicCode' => 'تایید با کد جادویی', 'useMagicLink' => 'از لینک ورود استفاده کنید', + 'magicCodeSubject' => 'کد جادویی ورود', + 'magicCodeText' => 'یک کد {0} کارکتری به ایمیل شما ارسال شده است. لطفا اینجا وارد کنید.', 'magicLinkSubject' => 'لینک ورود شما', 'magicTokenNotFound' => 'تایید لینک ممکن نیست.', 'magicLinkExpired' => 'متاسفانه, لینک منقضی شده است.', diff --git a/src/Language/fr/Auth.php b/src/Language/fr/Auth.php index eaf803a0b..9226bff3e 100644 --- a/src/Language/fr/Auth.php +++ b/src/Language/fr/Auth.php @@ -55,7 +55,10 @@ 'needAccount' => 'Pas encore de compte ?', 'rememberMe' => 'Se souvenir de moi', 'forgotPassword' => 'Mot de passe oublié ?', + 'verifyMagicCode' => '(To be translated) Verify Magic Code', 'useMagicLink' => 'Utiliser un lien de connexion', + 'magicCodeSubject' => '(To be translated) Your Login Magic Code', + 'magicCodeText' => '(To be translated) A {0}‑character code has been sent to your email. Please enter it.', 'magicLinkSubject' => 'Votre lien de connexion', 'magicTokenNotFound' => 'Impossible de vérifier le lien.', 'magicLinkExpired' => 'Désolé, le lien a expiré.', diff --git a/src/Language/id/Auth.php b/src/Language/id/Auth.php index 6c3397cc5..5e065afe3 100644 --- a/src/Language/id/Auth.php +++ b/src/Language/id/Auth.php @@ -55,7 +55,10 @@ 'needAccount' => 'Butuh Akun?', 'rememberMe' => 'Ingat saya?', 'forgotPassword' => 'Lupa kata sandi?', + 'verifyMagicCode' => '(To be translated) Verify Magic Code', 'useMagicLink' => 'Gunakan tautan masuk', + 'magicCodeSubject' => '(To be translated) Your Login Magic Code', + 'magicCodeText' => '(To be translated) A {0}‑character code has been sent to your email. Please enter it.', 'magicLinkSubject' => 'Tautan masuk Anda', 'magicTokenNotFound' => 'Tidak dapat memverifikasi tautan.', 'magicLinkExpired' => 'Maaf, tautan sudah tidak berlaku.', diff --git a/src/Language/it/Auth.php b/src/Language/it/Auth.php index 08ee76218..1554b57c2 100644 --- a/src/Language/it/Auth.php +++ b/src/Language/it/Auth.php @@ -55,7 +55,10 @@ 'needAccount' => 'Hai bisogno di un account?', 'rememberMe' => 'Ricordami?', 'forgotPassword' => 'Password dimenticata?', + 'verifyMagicCode' => '(To be translated) Verify Magic Code', 'useMagicLink' => 'Usa un Login Link', + 'magicCodeSubject' => '(To be translated) Your Login Magic Code', + 'magicCodeText' => '(To be translated) A {0}‑character code has been sent to your email. Please enter it.', 'magicLinkSubject' => 'Il tuo Login Link', 'magicTokenNotFound' => 'Impossibile verificare il link.', 'magicLinkExpired' => 'Spiacente, il link è scaduto.', diff --git a/src/Language/ja/Auth.php b/src/Language/ja/Auth.php index fc545ecf6..317910c63 100644 --- a/src/Language/ja/Auth.php +++ b/src/Language/ja/Auth.php @@ -55,7 +55,10 @@ 'needAccount' => 'アカウントが必要な方', // 'Need an account?' 'rememberMe' => 'ログイン状態を保持する', // 'Remember me?' 'forgotPassword' => 'パスワードをお忘れの方', // 'Forgot your password?' + 'verifyMagicCode' => '(To be translated) Verify Magic Code', 'useMagicLink' => 'ログインリンクを使用する', // 'Use a Login Link' + 'magicCodeSubject' => '(To be translated) Your Login Magic Code', + 'magicCodeText' => '(To be translated) A {0}‑character code has been sent to your email. Please enter it.', 'magicLinkSubject' => 'あなたのログインリンク', // 'Your Login Link' 'magicTokenNotFound' => 'リンクを確認できません。', // 'Unable to verify the link.' 'magicLinkExpired' => '申し訳ございません、リンクは切れています。', // 'Sorry, link has expired.' diff --git a/src/Language/lt/Auth.php b/src/Language/lt/Auth.php index d70b1613d..52006efc3 100644 --- a/src/Language/lt/Auth.php +++ b/src/Language/lt/Auth.php @@ -55,7 +55,10 @@ 'needAccount' => 'Reikia paskyros?', 'rememberMe' => 'Atsiminti mane?', 'forgotPassword' => 'Pamiršote slaptažodį?', + 'verifyMagicCode' => '(To be translated) Verify Magic Code', 'useMagicLink' => 'Naudoti prisijungimo nuorodą', + 'magicCodeSubject' => '(To be translated) Your Login Magic Code', + 'magicCodeText' => '(To be translated) A {0}‑character code has been sent to your email. Please enter it.', 'magicLinkSubject' => 'Jūsų prisijungimo nuoroda', 'magicTokenNotFound' => 'Nepavyksta patvirtinti nuorodos.', 'magicLinkExpired' => 'Deja, nuorodos galiojimas baigėsi.', diff --git a/src/Language/nl/Auth.php b/src/Language/nl/Auth.php index cfc057a75..50baf3cc8 100644 --- a/src/Language/nl/Auth.php +++ b/src/Language/nl/Auth.php @@ -51,11 +51,15 @@ 'registerSuccess' => 'Welkom!', // Login - 'login' => 'Inloggen', - 'needAccount' => 'Heb je nog geen account?', - 'rememberMe' => 'Ingelogd blijven', - 'forgotPassword' => 'Wachtwoord vergeten?', - 'useMagicLink' => 'Gebruik een Login Link', + 'login' => 'Inloggen', + 'needAccount' => 'Heb je nog geen account?', + 'rememberMe' => 'Ingelogd blijven', + 'forgotPassword' => 'Wachtwoord vergeten?', + 'verifyMagicCode' => '(To be translated) Verify Magic Code', + 'useMagicLink' => 'Gebruik een Login Link', + 'magicCodeSubject' => '(To be translated) Your Login Magic Code', + 'magicCodeText' => '(To be translated) A {0}‑character code has been sent to your email. Please enter it.', + 'magicLinkSubject' => 'Jou Login Link', 'magicTokenNotFound' => 'Kan de link niet verifiëren.', 'magicLinkExpired' => 'Sorry, de Login Link verlopen. Vraag een nieuwe aan.', diff --git a/src/Language/pl/Auth.php b/src/Language/pl/Auth.php index 9afbd5078..4c29347e7 100644 --- a/src/Language/pl/Auth.php +++ b/src/Language/pl/Auth.php @@ -55,7 +55,10 @@ 'needAccount' => 'Potrzebujesz konta?', 'rememberMe' => 'Zapamiętaj mnie', 'forgotPassword' => 'Zapomniałeś hasła?', + 'verifyMagicCode' => '(To be translated) Verify Magic Code', 'useMagicLink' => 'Użyj linku logowania', + 'magicCodeSubject' => '(To be translated) Your Login Magic Code', + 'magicCodeText' => '(To be translated) A {0}‑character code has been sent to your email. Please enter it.', 'magicLinkSubject' => 'Twój link logowania', 'magicTokenNotFound' => 'Nie można zweryfikować linku.', 'magicLinkExpired' => 'Przepraszamy, link wygasł.', diff --git a/src/Language/pt-BR/Auth.php b/src/Language/pt-BR/Auth.php index 5e278aa9d..43157f945 100644 --- a/src/Language/pt-BR/Auth.php +++ b/src/Language/pt-BR/Auth.php @@ -55,7 +55,10 @@ 'needAccount' => 'Precisa de uma conta?', 'rememberMe' => 'Lembrar de mim?', 'forgotPassword' => 'Esqueceu sua senha?', + 'verifyMagicCode' => '(To be translated) Verify Magic Code', 'useMagicLink' => 'Use um link de login', + 'magicCodeSubject' => '(To be translated) Your Login Magic Code', + 'magicCodeText' => '(To be translated) A {0}‑character code has been sent to your email. Please enter it.', 'magicLinkSubject' => 'Seu link de login', 'magicTokenNotFound' => 'Não foi possível verificar o link.', 'magicLinkExpired' => 'Desculpe, o link expirou.', diff --git a/src/Language/pt/Auth.php b/src/Language/pt/Auth.php index 319ce5be4..059736c96 100644 --- a/src/Language/pt/Auth.php +++ b/src/Language/pt/Auth.php @@ -55,7 +55,10 @@ 'needAccount' => 'Precisa de uma conta?', 'rememberMe' => 'Lembrar', 'forgotPassword' => 'Esqueceu a sua password?', + 'verifyMagicCode' => '(To be translated) Verify Magic Code', 'useMagicLink' => 'Use um Link de Login', + 'magicCodeSubject' => '(To be translated) Your Login Magic Code', + 'magicCodeText' => '(To be translated) A {0}‑character code has been sent to your email. Please enter it.', 'magicLinkSubject' => 'O seu Link de Login', 'magicTokenNotFound' => 'Não foi possível verificar o link.', 'magicLinkExpired' => 'Desculpe, o link expirou.', diff --git a/src/Language/ru/Auth.php b/src/Language/ru/Auth.php index e756323d4..bdc44d5b4 100644 --- a/src/Language/ru/Auth.php +++ b/src/Language/ru/Auth.php @@ -55,7 +55,10 @@ 'needAccount' => 'Нужна учётная запись?', 'rememberMe' => 'Запомнить меня', 'forgotPassword' => 'Забыли пароль?', + 'verifyMagicCode' => '(To be translated) Verify Magic Code', 'useMagicLink' => 'Воспользуйтесь ссылкой для входа', + 'magicCodeSubject' => '(To be translated) Your Login Magic Code', + 'magicCodeText' => '(To be translated) A {0}‑character code has been sent to your email. Please enter it.', 'magicLinkSubject' => 'Ваша ссылка для входа', 'magicTokenNotFound' => 'Не удалось проверить ссылку.', 'magicLinkExpired' => 'Извините, срок действия ссылки истёк.', diff --git a/src/Language/sk/Auth.php b/src/Language/sk/Auth.php index 7ea5d0b4f..67dcec993 100644 --- a/src/Language/sk/Auth.php +++ b/src/Language/sk/Auth.php @@ -55,7 +55,10 @@ 'needAccount' => 'Potrebujete účet?', 'rememberMe' => 'Zapamätať si ma?', 'forgotPassword' => 'Zabudli ste heslo?', + 'verifyMagicCode' => '(To be translated) Verify Magic Code', 'useMagicLink' => 'Použiť odkaz na prihlásenie', + 'magicCodeSubject' => '(To be translated) Your Login Magic Code', + 'magicCodeText' => '(To be translated) A {0}‑character code has been sent to your email. Please enter it.', 'magicLinkSubject' => 'Váš odkaz na prihlásenie', 'magicTokenNotFound' => 'Odkaz sa nepodarilo overiť.', 'magicLinkExpired' => 'Ľutujeme, platnosť odkazu vypršala.', diff --git a/src/Language/sr/Auth.php b/src/Language/sr/Auth.php index 16d4af5ce..a57fde5b5 100644 --- a/src/Language/sr/Auth.php +++ b/src/Language/sr/Auth.php @@ -55,7 +55,10 @@ 'needAccount' => 'Potreban Vam je nalog?', 'rememberMe' => 'Zapmti me?', 'forgotPassword' => 'Zaboravljena lozinka?', + 'verifyMagicCode' => '(To be translated) Verify Magic Code', 'useMagicLink' => 'Koristi pristupni link', + 'magicCodeSubject' => '(To be translated) Your Login Magic Code', + 'magicCodeText' => '(To be translated) A {0}‑character code has been sent to your email. Please enter it.', 'magicLinkSubject' => 'Vaš pristupni link', 'magicTokenNotFound' => 'Nije moguća verifikacija linka.', 'magicLinkExpired' => 'Žao nam je, link je istekao.', diff --git a/src/Language/sv-SE/Auth.php b/src/Language/sv-SE/Auth.php index 8703ec647..69d4443da 100644 --- a/src/Language/sv-SE/Auth.php +++ b/src/Language/sv-SE/Auth.php @@ -55,7 +55,10 @@ 'needAccount' => 'Behöver du ett konto?', 'rememberMe' => 'Kom ihåg mig?', 'forgotPassword' => 'Glömt ditt lösenord?', + 'verifyMagicCode' => '(To be translated) Verify Magic Code', 'useMagicLink' => 'Använd en login-länk', + 'magicCodeSubject' => '(To be translated) Your Login Magic Code', + 'magicCodeText' => '(To be translated) A {0}‑character code has been sent to your email. Please enter it.', 'magicLinkSubject' => 'Din login-länk', 'magicTokenNotFound' => 'Kan inte verifiera länken.', 'magicLinkExpired' => 'Tyvärr, länken har gått ut.', diff --git a/src/Language/tr/Auth.php b/src/Language/tr/Auth.php index 5df3da9db..165305c53 100644 --- a/src/Language/tr/Auth.php +++ b/src/Language/tr/Auth.php @@ -55,7 +55,10 @@ 'needAccount' => 'Bir hesaba mı ihtiyacınız var?', 'rememberMe' => 'Beni hatırla?', 'forgotPassword' => 'Şifrenizi mı unuttunuz?', + 'verifyMagicCode' => '(To be translated) Verify Magic Code', 'useMagicLink' => 'Giriş Bağlantısı Kullanın', + 'magicCodeSubject' => '(To be translated) Your Login Magic Code', + 'magicCodeText' => '(To be translated) A {0}‑character code has been sent to your email. Please enter it.', 'magicLinkSubject' => 'Giriş Bağlantınız', 'magicTokenNotFound' => 'Bağlantı doğrulanamıyor.', 'magicLinkExpired' => 'Üzgünüm, bağlantının süresi doldu.', diff --git a/src/Language/uk/Auth.php b/src/Language/uk/Auth.php index 5595a39d0..e672ebde9 100644 --- a/src/Language/uk/Auth.php +++ b/src/Language/uk/Auth.php @@ -55,7 +55,10 @@ 'needAccount' => 'Потрібен обліковий запис?', 'rememberMe' => 'Запам’ятати мене', 'forgotPassword' => 'Забули пароль?', + 'verifyMagicCode' => '(To be translated) Verify Magic Code', 'useMagicLink' => 'Скористайтеся посиланням для входу', + 'magicCodeSubject' => '(To be translated) Your Login Magic Code', + 'magicCodeText' => '(To be translated) A {0}‑character code has been sent to your email. Please enter it.', 'magicLinkSubject' => 'Ваше посилання для входу', 'magicTokenNotFound' => 'Неможливо перевірити посилання.', 'magicLinkExpired' => 'Вибачте, термін дії посилання закінчився.', diff --git a/src/Views/Email/magic_link_email_code.php b/src/Views/Email/magic_link_email_code.php new file mode 100644 index 000000000..d3e56b2af --- /dev/null +++ b/src/Views/Email/magic_link_email_code.php @@ -0,0 +1,32 @@ + + + + + + + + <?= lang('Auth.magicLinkSubject') ?> + + + +

+
+

+
+ + + + + + +
+   +
+ +

: username) ?>

+

+

+

+ + + diff --git a/src/Views/magic_link_code.php b/src/Views/magic_link_code.php new file mode 100644 index 000000000..8f86b76a5 --- /dev/null +++ b/src/Views/magic_link_code.php @@ -0,0 +1,21 @@ +extend(config('Auth')->views['layout']) ?> + +section('title') ?> endSection() ?> + +section('main') ?> +
+
+
+
+
+ +
+ + +
+ +
+
+
+
+endSection() ?> diff --git a/tests/Controllers/MagicLinkTest.php b/tests/Controllers/MagicLinkTest.php index 97a4ebbd2..c9046a436 100644 --- a/tests/Controllers/MagicLinkTest.php +++ b/tests/Controllers/MagicLinkTest.php @@ -18,12 +18,15 @@ use CodeIgniter\I18n\Time; use CodeIgniter\Shield\Authentication\Actions\EmailActivator; use CodeIgniter\Shield\Authentication\Authenticators\Session; +use CodeIgniter\Shield\Controllers\MagicLinkController; use CodeIgniter\Shield\Entities\User; +use CodeIgniter\Shield\Exceptions\InvalidArgumentException; use CodeIgniter\Shield\Models\UserIdentityModel; use CodeIgniter\Shield\Models\UserModel; use CodeIgniter\Test\DatabaseTestTrait; use CodeIgniter\Test\FeatureTestTrait; use Config\Services; +use ReflectionMethod; use Tests\Support\FakeUser; use Tests\Support\TestCase; @@ -210,4 +213,228 @@ public function testMagicLinkVerifyReturns404ForRobotUserAgent(): void $this->get(route_to('verify-magic-link') . '?token=validtoken123'); } + + public function testMagicCodeShowLoginForm(): void + { + $config = config('Auth'); + $config->magicLoginMode = '6-numeric'; + Factories::injectMock('config', 'Auth', $config); + + $this->user->createEmailIdentity([ + 'email' => 'foo@example.com', + 'password' => 'secret123', + ]); + + $result = $this->post('/login/magic-link', [ + 'email' => 'foo@example.com', + ]); + + // must contain a code input form + $result->seeElement('#magicCode'); + $result->assertStatus(200); + } + + public function testMagicCodeShowsOneOFLoginForm(): void + { + $config = config('Auth'); + $config->magicLoginMode = '15-oneof'; + Factories::injectMock('config', 'Auth', $config); + + $this->user->createEmailIdentity([ + 'email' => 'foo@example.com', + 'password' => 'secret123', + ]); + + $result = $this->post('/login/magic-link', [ + 'email' => 'foo@example.com', + ]); + + $result->assertSee('A 15‑character code has been sent to your email. Please enter it.'); + // must contain a code input form + $result->seeElement('#magicCode'); + $result->assertStatus(200); + } + + public function testMagicCodeEmailContainsSixDigitCode(): void + { + $config = config('Auth'); + $config->magicLoginMode = '6-numeric'; + Factories::injectMock('config', 'Auth', $config); + + $this->user->createEmailIdentity([ + 'email' => 'foo@example.com', + 'password' => 'secret123', + ]); + + $this->post('/login/magic-link', [ + 'email' => 'foo@example.com', + ]); + + $email = service('email')->archive['body']; + + // Should have sent an email with the link.... + $this->assertStringContainsString( + lang('Auth.email2FAMailBody'), + (string) $email, + ); + + $this->assertMatchesRegularExpression( + '!

[0-9]{6}

!', + $email, + ); + } + + public function testValidMagicCodeLogsUserIn(): void + { + $config = config('Auth'); + $config->magicLoginMode = '6-numeric'; + Factories::injectMock('config', 'Auth', $config); + + $this->user->createEmailIdentity([ + 'email' => 'foo@example.com', + 'password' => 'secret123', + ]); + + $this->post('/login/magic-link', [ + 'email' => 'foo@example.com', + ]); + + // Extract sent email body & OTP code + $email = service('email')->archive['body']; + preg_match('/\d{6}/', (string) $email, $match); + $code = $match[0]; + $result = $this->post('/login/verify-magic-link', [ + 'magicCode' => $code, + ]); + + $result->assertStatus(302); + $result->assertRedirectTo(config('Auth')->loginRedirect()); + $this->assertTrue(auth()->loggedIn()); + } + + public function testInvalidMagicCodeShowsError(): void + { + $config = config('Auth'); + $config->magicLoginMode = '6-numeric'; + Factories::injectMock('config', 'Auth', $config); + + $this->user->createEmailIdentity([ + 'email' => 'foo@example.com', + 'password' => 'secret123', + ]); + + $this->post('/login/magic-link', [ + 'email' => 'foo@example.com', + ]); + + $result = $this->post('/login/verify-magic-link', [ + 'magicCode' => '000000', // surely invalid + ]); + + $result->assertStatus(302); + $result->assertRedirectTo('/login/magic-link'); + $result->assertSessionHas('error', lang('Auth.magicTokenNotFound')); + + $this->assertFalse(auth()->loggedIn()); + } + + public function testClickableMode(): void + { + $result = $this->callPrivateMethod('clickable'); + + $this->assertSame('magic-link-message', $result['displayMessageView']); + $this->assertSame('magic-link-email', $result['emailView']); + $this->assertSame(lang('Auth.magicLinkSubject'), $result['emailSubject']); + $this->assertSame(20, strlen((string) $result['token'])); + } + + public function testNumericMode(): void + { + $config = config('Auth'); + $config->magicLoginMode = '6-numeric'; + Factories::injectMock('config', 'Auth', $config); + + $result = $this->callPrivateMethod(); + + $this->assertSame('magic-link-code', $result['displayMessageView']); + $this->assertSame('magic-link-email-code', $result['emailView']); + $this->assertSame(6, strlen((string) $result['token'])); + $this->assertMatchesRegularExpression('/^\d{6}$/', $result['token']); + } + + public function testAlnumMode(): void + { + $config = config('Auth'); + $config->magicLoginMode = '8-alnum'; + Factories::injectMock('config', 'Auth', $config); + + $result = $this->callPrivateMethod(); + + $this->assertSame(8, strlen((string) $result['token'])); + $this->assertMatchesRegularExpression('/^[A-Za-z0-9]{8}$/', $result['token']); + } + + public function testOneofMode(): void + { + $result = $this->callPrivateMethod('4-oneof'); + + $this->assertSame('magic-link-code', $result['displayMessageView']); + $this->assertSame('magic-link-email-code', $result['emailView']); + $this->assertSame(lang('Auth.magicCodeSubject'), $result['emailSubject']); + + $token = $result['token']; + + $this->assertSame(4, strlen((string) $token)); + + $tokens = []; + $uniqueCount = 0; + + for ($i = 0; $i < 10; $i++) { + $newToken = $this->callPrivateMethod('4-oneof')['token']; + $tokens[] = $newToken; + + if ($newToken !== $token) { + $uniqueCount++; + } + } + + $this->assertGreaterThan( + 0, + $uniqueCount, + 'Tokens generated in loop should not all be identical.', + ); + } + + public function testInvalidFormatThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->callPrivateMethod('INVALID_FORMAT'); + } + + public function testInvalidLengthThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->callPrivateMethod('x-numeric'); + } + + public function testZeroLengthThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->callPrivateMethod('0-numeric'); + } + + public function testInvalidTypeThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->callPrivateMethod('5-invalid'); + } + + private function callPrivateMethod(?string $mode = null): array + { + $controller = new MagicLinkController(); + + $method = new ReflectionMethod($controller, 'resolveMode'); + + return $method->invoke($controller, $mode); + } }