├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── one-time-passwords.php ├── database ├── factories │ └── UserFactory.php └── migrations │ └── create_one_time_passwords_table.php.stub ├── phpstan.neon ├── resources ├── lang │ ├── ar │ │ ├── form.php │ │ ├── notifications.php │ │ └── validation.php │ ├── en │ │ ├── form.php │ │ ├── notifications.php │ │ └── validation.php │ ├── fr │ │ ├── form.php │ │ ├── notifications.php │ │ └── validation.php │ ├── pt_BR │ │ ├── form.php │ │ ├── notifications.php │ │ └── validation.php │ └── pt_PT │ │ ├── form.php │ │ ├── notifications.php │ │ └── validation.php └── views │ ├── livewire │ ├── email-form.blade.php │ └── one-time-password-form.blade.php │ └── mail.blade.php └── src ├── Actions ├── ConsumeOneTimePasswordAction.php └── CreateOneTimePasswordAction.php ├── Enums └── ConsumeOneTimePasswordResult.php ├── Events ├── FailedToConsumeOneTimePassword.php └── OneTimePasswordSuccessfullyConsumed.php ├── Exceptions ├── InvalidActionClass.php └── InvalidConfig.php ├── Livewire └── OneTimePasswordComponent.php ├── Models ├── Concerns │ └── HasOneTimePasswords.php └── OneTimePassword.php ├── Notifications └── OneTimePasswordNotification.php ├── OneTimePasswordsServiceProvider.php ├── Rules └── OneTimePasswordRule.php └── Support ├── Config.php ├── OriginInspector ├── DefaultOriginEnforcer.php ├── DoNotEnforceOrigin.php └── OriginEnforcer.php └── PasswordGenerators ├── NumericOneTimePasswordGenerator.php └── OneTimePasswordGenerator.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-one-time-passwords` will be documented in this file. 4 | 5 | ## 1.0.4 - 2025-05-23 6 | 7 | ### What's Changed 8 | 9 | * Support PHP ^8.2 and Laravel ^11 by @x-JOC in https://github.com/spatie/laravel-one-time-passwords/pull/21 10 | 11 | **Full Changelog**: https://github.com/spatie/laravel-one-time-passwords/compare/1.0.3...1.0.4 12 | 13 | ## 1.0.3 - 2025-05-22 14 | 15 | ### What's Changed 16 | 17 | * Incorrect migration file name fixed. by @sdebacker in https://github.com/spatie/laravel-one-time-passwords/pull/18 18 | * Incorrect tag fixed. by @sdebacker in https://github.com/spatie/laravel-one-time-passwords/pull/19 19 | * french translations by @sdebacker in https://github.com/spatie/laravel-one-time-passwords/pull/20 20 | 21 | ### New Contributors 22 | 23 | * @sdebacker made their first contribution in https://github.com/spatie/laravel-one-time-passwords/pull/18 24 | 25 | **Full Changelog**: https://github.com/spatie/laravel-one-time-passwords/compare/1.0.2...1.0.3 26 | 27 | ## 1.0.2 - 2025-05-22 28 | 29 | ### What's Changed 30 | 31 | * Add Arabic language support by @x-JOC in https://github.com/spatie/laravel-one-time-passwords/pull/14 32 | 33 | ### New Contributors 34 | 35 | * @x-JOC made their first contribution in https://github.com/spatie/laravel-one-time-passwords/pull/14 36 | 37 | **Full Changelog**: https://github.com/spatie/laravel-one-time-passwords/compare/1.0.1...1.0.2 38 | 39 | ## 1.0.1 - 2025-05-22 40 | 41 | **Full Changelog**: https://github.com/spatie/laravel-one-time-passwords/compare/1.0.0...1.0.1 42 | 43 | ## 1.0.0 - 2025-05-20 44 | 45 | - initial release 🚀 46 | 47 | ## 0.0.4 - 2025-05-20 48 | 49 | ### What's Changed 50 | 51 | * docs: clarify return type in gatherProperties PHPDoc by @Ayoub-Mabrouk in https://github.com/spatie/laravel-one-time-passwords/pull/5 52 | 53 | **Full Changelog**: https://github.com/spatie/laravel-one-time-passwords/compare/0.0.3...0.0.4 54 | 55 | ## 0.0.3 - 2025-05-17 56 | 57 | ### What's Changed 58 | 59 | * fix: return OTP as strict string with secure randomness by @Ayoub-Mabrouk in https://github.com/spatie/laravel-one-time-passwords/pull/4 60 | * Add Portuguese language support by @kidiatoliny in https://github.com/spatie/laravel-one-time-passwords/pull/3 61 | * chore: add PHPStan, commit config, and remove it from .gitignore by @Ayoub-Mabrouk in https://github.com/spatie/laravel-one-time-passwords/pull/2 62 | 63 | ### New Contributors 64 | 65 | * @Ayoub-Mabrouk made their first contribution in https://github.com/spatie/laravel-one-time-passwords/pull/4 66 | * @kidiatoliny made their first contribution in https://github.com/spatie/laravel-one-time-passwords/pull/3 67 | 68 | **Full Changelog**: https://github.com/spatie/laravel-one-time-passwords/compare/0.0.2...0.0.3 69 | 70 | ## 0.0.2 - 2025-05-16 71 | 72 | **Full Changelog**: https://github.com/spatie/laravel-one-time-passwords/compare/0.0.1...0.0.2 73 | 74 | ## 0.0.1 - 2025-05-14 75 | 76 | ### What's Changed 77 | 78 | * Bump dependabot/fetch-metadata from 2.3.0 to 2.4.0 by @dependabot in https://github.com/spatie/laravel-one-time-passwords/pull/1 79 | 80 | ### New Contributors 81 | 82 | * @dependabot made their first contribution in https://github.com/spatie/laravel-one-time-passwords/pull/1 83 | 84 | **Full Changelog**: https://github.com/spatie/laravel-one-time-passwords/commits/0.0.1 85 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Spatie 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | Logo for laravel-permission 6 | 7 | 8 | 9 |

One-time passwords (OTP) for Laravel apps

10 | 11 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/spatie/laravel-one-time-passwords.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-one-time-passwords) 12 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/spatie/laravel-one-time-passwords/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/spatie/laravel-one-time-passwords/actions?query=workflow%3Arun-tests+branch%3Amain) 13 | [![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/spatie/laravel-one-time-passwords/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/spatie/laravel-one-time-passwords/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) 14 | [![Total Downloads](https://img.shields.io/packagist/dt/spatie/laravel-one-time-passwords.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-one-time-passwords) 15 | 16 |
17 | 18 | Using this package, you can securely create and consume one-time passwords. By default, a one-time password is a number 19 | of six digits long that will be sent via a mail notification. This notification can be extended so it can be sent via other channels, like SMS. 20 | 21 | The package ships with a Livewire component to allow users to login using a one-time password. 22 | 23 | ![image](/docs/images/form-email.png) 24 | 25 | ![image](/docs/images/form-code.png) 26 | 27 | Alternatively, you can to build the one-time password login flow you want with the easy-to-use methods the package provides. 28 | 29 | Here's how you would send a one-time password to a user 30 | 31 | ```php 32 | // send a mail containing a one-time password 33 | 34 | $user->sendOneTimePassword(); 35 | ``` 36 | 37 | This is what the notification mail looks like: 38 | 39 | ![image](/docs/images/notification.png) 40 | 41 | Here's how you would try to log in a user using a one-time password. 42 | 43 | ```php 44 | use Spatie\OneTimePasswords\Enums\ConsumeOneTimePasswordResult; 45 | 46 | $result = $user->attemptLoginUsingOneTimePassword($oneTimePassword); 47 | 48 | if ($result->isOk()) { 49 | // it is best practice to regenerate the session id after a login 50 | $request->session()->regenerate(); 51 | 52 | return redirect()->intended('dashboard'); 53 | } 54 | 55 | return back()->withErrors([ 56 | 'one_time_password' => $result->validationMessage(), 57 | ])->onlyInput('one_time_password'); 58 | ``` 59 | 60 | The package tries to make one-time passwords as secure as can be by: 61 | 62 | - letting them expire in a short timeframe (2 minutes by default) 63 | - only allowing to consume a one-time password on the same IP and user agent as it was generated 64 | 65 | All behavior is implemented in action classes that can be modified to your liking. 66 | 67 | ## Documentation 68 | 69 | All documentation is available [on our documentation site](https://spatie.be/docs/laravel-one-time-passwords). 70 | 71 | ## Support us 72 | 73 | [](https://spatie.be/github-ad-click/laravel-one-time-passwords) 74 | 75 | We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us). 76 | 77 | We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards). 78 | 79 | 80 | ## Testing 81 | 82 | ```bash 83 | composer test 84 | ``` 85 | 86 | ## Changelog 87 | 88 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 89 | 90 | ## Contributing 91 | 92 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 93 | 94 | ## Security Vulnerabilities 95 | 96 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 97 | 98 | ## Credits 99 | 100 | - [Freek Van der Herten](https://github.com/freekmurze) 101 | - [All Contributors](../../contributors) 102 | 103 | ## License 104 | 105 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 106 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spatie/laravel-one-time-passwords", 3 | "description": "Use one-time passwords (OTP) to authenticate in your Laravel app", 4 | "keywords": [ 5 | "Spatie", 6 | "laravel", 7 | "laravel-one-time-passwords" 8 | ], 9 | "homepage": "https://github.com/spatie/laravel-one-time-passwords", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Freek Van der Herten", 14 | "email": "freek@spatie.be", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.2", 20 | "spatie/laravel-package-tools": "^1.16", 21 | "illuminate/contracts": "^11.0|^12.0" 22 | }, 23 | "require-dev": { 24 | "laravel/pint": "^1.14", 25 | "livewire/livewire": "^3.6", 26 | "nunomaduro/collision": "^8.1.1", 27 | "orchestra/testbench": "^9.0|^10.0.0", 28 | "pestphp/pest": "^3.0", 29 | "pestphp/pest-plugin-arch": "^3.0", 30 | "pestphp/pest-plugin-laravel": "^3.0", 31 | "phpstan/phpstan": "^2.1", 32 | "spatie/laravel-ray": "^1.35" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "Spatie\\OneTimePasswords\\": "src/", 37 | "Spatie\\OneTimePasswords\\Database\\Factories\\": "database/factories/" 38 | } 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { 42 | "Spatie\\OneTimePasswords\\Tests\\": "tests/", 43 | "Workbench\\App\\": "workbench/app/" 44 | } 45 | }, 46 | "scripts": { 47 | "post-autoload-dump": "@composer run prepare", 48 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 49 | "analyse": "vendor/bin/phpstan analyse", 50 | "test": "vendor/bin/pest", 51 | "test-coverage": "vendor/bin/pest --coverage", 52 | "format": "vendor/bin/pint" 53 | }, 54 | "config": { 55 | "sort-packages": true, 56 | "allow-plugins": { 57 | "pestphp/pest-plugin": true, 58 | "phpstan/extension-installer": true 59 | } 60 | }, 61 | "extra": { 62 | "laravel": { 63 | "providers": [ 64 | "Spatie\\OneTimePasswords\\OneTimePasswordsServiceProvider" 65 | ] 66 | } 67 | }, 68 | "minimum-stability": "dev", 69 | "prefer-stable": true 70 | } 71 | -------------------------------------------------------------------------------- /config/one-time-passwords.php: -------------------------------------------------------------------------------- 1 | 2, 8 | 9 | /* 10 | * When this setting is active, we'll delete all previous one-time passwords for 11 | * a user when generating a new one 12 | */ 13 | 'only_one_active_one_time_password_per_user' => true, 14 | 15 | /* 16 | * When this option is active, we'll try to ensure that the one-time password can only 17 | * be consumed on the platform where it was requested on 18 | */ 19 | 'enforce_same_origin' => true, 20 | 21 | /* 22 | * This class is responsible to enforce that the one-time password can only be consumed on 23 | * the platform it was requested on. 24 | * 25 | * If you do not wish to enforce this, set this value to 26 | * Spatie\OneTimePasswords\Support\OriginInspector\DoNotEnforceOrigin 27 | */ 28 | 'origin_enforcer' => Spatie\OneTimePasswords\Support\OriginInspector\DefaultOriginEnforcer::class, 29 | 30 | /* 31 | * This class generates a random password 32 | */ 33 | 'password_generator' => Spatie\OneTimePasswords\Support\PasswordGenerators\NumericOneTimePasswordGenerator::class, 34 | 35 | /* 36 | * By default, the password generator will create a password with 37 | * this number of digits 38 | */ 39 | 'password_length' => 6, 40 | 41 | /* 42 | * The Livewire component will redirect successfully authenticated users 43 | * to this URL. 44 | */ 45 | 'redirect_successful_authentication_to' => '/dashboard', 46 | 47 | /* 48 | * These values are used to rate limit the number of attempts 49 | * that may be made to consume a one-time password. 50 | */ 51 | 'rate_limit_attempts' => [ 52 | 'max_attempts_per_user' => 5, 53 | 'time_window_in_seconds' => 60, 54 | ], 55 | 56 | /* 57 | * The model uses to store one-time passwords 58 | */ 59 | 'model' => Spatie\OneTimePasswords\Models\OneTimePassword::class, 60 | 61 | /* 62 | * The notification used to send a one-time password to a user 63 | */ 64 | 'notification' => Spatie\OneTimePasswords\Notifications\OneTimePasswordNotification::class, 65 | 66 | /* 67 | * These class are responsible for performing core tasks regarding one-time passwords. 68 | * You can customize them by creating a class that extends the default, and 69 | * by specifying your custom class name here. 70 | */ 71 | 'actions' => [ 72 | 'create_one_time_password' => Spatie\OneTimePasswords\Actions\CreateOneTimePasswordAction::class, 73 | 'consume_one_time_password' => Spatie\OneTimePasswords\Actions\ConsumeOneTimePasswordAction::class, 74 | ], 75 | ]; 76 | -------------------------------------------------------------------------------- /database/factories/UserFactory.php: -------------------------------------------------------------------------------- 1 | 14 | * 15 | * @phpstan-extends Factory 16 | */ 17 | class UserFactory extends Factory 18 | { 19 | /** 20 | * The name of the factory's corresponding model. 21 | * 22 | * @var string 23 | */ 24 | protected $model = User::class; 25 | 26 | /** 27 | * Define the model's default state. 28 | * 29 | * @return array 30 | */ 31 | public function definition(): array 32 | { 33 | return [ 34 | 'name' => $this->faker->name(), 35 | 'email' => $this->faker->unique()->safeEmail(), 36 | 'password' => Hash::make('password'), 37 | 'remember_token' => Str::random(10), 38 | ]; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /database/migrations/create_one_time_passwords_table.php.stub: -------------------------------------------------------------------------------- 1 | id(); 13 | 14 | $table->string('password'); 15 | $table->json('origin_properties')->nullable(); 16 | 17 | $table->dateTime('expires_at'); 18 | $table->morphs('authenticatable'); 19 | 20 | $table->timestamps(); 21 | }); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: max 3 | paths: 4 | - src -------------------------------------------------------------------------------- /resources/lang/ar/form.php: -------------------------------------------------------------------------------- 1 | 'تسجيل الدخول باستخدام رمز التحقق', 5 | 'email_label' => 'البريد الإلكتروني', 6 | 'send_login_code_button' => 'إرسال رمز التحقق', 7 | 8 | 'one_time_password_form_title' => 'أدخل رمز التحقق الخاص بك', 9 | 'password_label' => 'رمز تسجيل الدخول', 10 | 'submit_login_code_button' => 'إرسال رمز التحقق', 11 | 12 | 'resend_code' => 'إعادة إرسال رمز التحقق', 13 | ]; 14 | -------------------------------------------------------------------------------- /resources/lang/ar/notifications.php: -------------------------------------------------------------------------------- 1 | ':oneTimePassword هو رمز التحقق الخاص بك', 5 | 'title' => 'رمز التحقق', 6 | 'intro' => 'هذا هو رمز التحقق الذي يمكنك استخدامه على :url:', 7 | 'outro' => 'لحماية حسابك، لا تشارك هذا الرمز مع أي شخص. إذا لم تقم بهذا الطلب، يمكنك تجاهل هذا البريد الإلكتروني بأمان.', 8 | ]; 9 | -------------------------------------------------------------------------------- /resources/lang/ar/validation.php: -------------------------------------------------------------------------------- 1 | 'الرمز المدخل غير صحيح', 5 | 'incorrect_one_time_password' => 'الرمز المدخل غير صحيح', 6 | 'request_did_not_match' => 'الرمز المدخل غير صحيح', 7 | 'one_time_password_expired' => 'الرمز المدخل لم يعد صالحا', 8 | ]; 9 | -------------------------------------------------------------------------------- /resources/lang/en/form.php: -------------------------------------------------------------------------------- 1 | 'Login with One-Time Login Code', 5 | 'email_label' => 'E-mail', 6 | 'send_login_code_button' => 'Send one-time login code', 7 | 8 | 'one_time_password_form_title' => 'Enter your login code', 9 | 'password_label' => 'Login code', 10 | 'submit_login_code_button' => 'Submit one-time login code', 11 | 12 | 'resend_code' => 'Resend code', 13 | ]; 14 | -------------------------------------------------------------------------------- /resources/lang/en/notifications.php: -------------------------------------------------------------------------------- 1 | ':oneTimePassword is your one-time login code', 5 | 'title' => 'One time login code', 6 | 'intro' => 'This is your one-time login code to use on :url:', 7 | 'outro' => "To protect your account, do not share this code with anyone. If you didn't make this request, you can safely ignore this email.", 8 | ]; 9 | -------------------------------------------------------------------------------- /resources/lang/en/validation.php: -------------------------------------------------------------------------------- 1 | 'The given code is not correct', 5 | 'incorrect_one_time_password' => 'The given code is not correct', 6 | 'request_did_not_match' => 'The given code is not correct', 7 | 'one_time_password_expired' => 'The given code is not valid anymore', 8 | ]; 9 | -------------------------------------------------------------------------------- /resources/lang/fr/form.php: -------------------------------------------------------------------------------- 1 | 'Se connecter avec un code de connexion unique', 5 | 'email_label' => 'E-mail', 6 | 'send_login_code_button' => 'Envoyer le code de connexion', 7 | 8 | 'one_time_password_form_title' => 'Entrez votre code de connexion', 9 | 'password_label' => 'Code de connexion', 10 | 'submit_login_code_button' => 'Envoyer le code de connexion', 11 | 12 | 'resend_code' => 'Renvoyer le code', 13 | ]; 14 | -------------------------------------------------------------------------------- /resources/lang/fr/notifications.php: -------------------------------------------------------------------------------- 1 | ':oneTimePassword est votre code de connexion à usage unique', 5 | 'title' => 'Code de connexion unique', 6 | 'intro' => 'Ceci est votre code de connexion unique à utiliser sur :url :', 7 | 'outro' => 'Pour protéger votre compte, ne communiquez ce code à personne. Si vous n’avez pas fait cette demande, vous pouvez ignorer cet e-mail.', 8 | ]; 9 | -------------------------------------------------------------------------------- /resources/lang/fr/validation.php: -------------------------------------------------------------------------------- 1 | 'Le code donné n’est pas correct', 5 | 'incorrect_one_time_password' => 'Le code donné n’est pas correct', 6 | 'request_did_not_match' => 'Le code donné n’est pas correct', 7 | 'one_time_password_expired' => 'Le code donné n’est plus valide', 8 | ]; 9 | -------------------------------------------------------------------------------- /resources/lang/pt_BR/form.php: -------------------------------------------------------------------------------- 1 | 'Entrar com código de acesso único', 5 | 'email_label' => 'E-mail', 6 | 'send_login_code_button' => 'Enviar código de acesso único', 7 | 8 | 'one_time_password_form_title' => 'Digite seu código de acesso', 9 | 'password_label' => 'Código de acesso', 10 | 'submit_login_code_button' => 'Enviar código de acesso único', 11 | 12 | 'resend_code' => 'Reenviar código', 13 | ]; 14 | -------------------------------------------------------------------------------- /resources/lang/pt_BR/notifications.php: -------------------------------------------------------------------------------- 1 | ':oneTimePassword é o seu código de acesso único', 5 | 'title' => 'Código de acesso único', 6 | 'intro' => 'Este é o seu código de acesso único para usar em :url:', 7 | 'outro' => 'Para proteger sua conta, não compartilhe este código com ninguém. Se você não fez esta solicitação, pode ignorar este e-mail com segurança.', 8 | ]; 9 | -------------------------------------------------------------------------------- /resources/lang/pt_BR/validation.php: -------------------------------------------------------------------------------- 1 | 'O código informado está incorreto.', 5 | 'incorrect_one_time_password' => 'O código informado está incorreto.', 6 | 'request_did_not_match' => 'O código informado está incorreto.', 7 | 'one_time_password_expired' => 'O código informado expirou.', 8 | ]; 9 | -------------------------------------------------------------------------------- /resources/lang/pt_PT/form.php: -------------------------------------------------------------------------------- 1 | 'Iniciar sessão com código de acesso único', 5 | 'email_label' => 'E-mail', 6 | 'send_login_code_button' => 'Enviar código de acesso único', 7 | 8 | 'one_time_password_form_title' => 'Introduza o seu código de acesso', 9 | 'password_label' => 'Código de acesso', 10 | 'submit_login_code_button' => 'Submeter código de acesso único', 11 | 12 | 'resend_code' => 'Reenviar código', 13 | ]; 14 | -------------------------------------------------------------------------------- /resources/lang/pt_PT/notifications.php: -------------------------------------------------------------------------------- 1 | ':oneTimePassword é o seu código único de acesso', 5 | 'title' => 'Código único de acesso', 6 | 'intro' => 'Este é o seu código único de acesso para usar em :url:', 7 | 'outro' => 'Para proteger a sua conta, não partilhe este código com ninguém. Se não solicitou este código, pode ignorar este e-mail com segurança.', 8 | ]; 9 | -------------------------------------------------------------------------------- /resources/lang/pt_PT/validation.php: -------------------------------------------------------------------------------- 1 | 'O código fornecido não está correto', 5 | 'incorrect_one_time_password' => 'O código fornecido não está correto', 6 | 'request_did_not_match' => 'O código fornecido não está correto', 7 | 'one_time_password_expired' => 'O código fornecido já não é válido', 8 | ]; 9 | -------------------------------------------------------------------------------- /resources/views/livewire/email-form.blade.php: -------------------------------------------------------------------------------- 1 |
2 |

3 | {{ __('one-time-passwords::form.email_form_title') }} 4 |

5 | 6 |
7 |
8 | 11 | 18 | @error('email') 19 |

{{ $message }}

20 | @enderror 21 |
22 | 23 |
24 | 27 |
28 |
29 |
30 | -------------------------------------------------------------------------------- /resources/views/livewire/one-time-password-form.blade.php: -------------------------------------------------------------------------------- 1 |
2 |

3 | {{ __('one-time-passwords::form.one_time_password_form_title') }} 4 |

5 |
6 |
7 | 10 | 16 | @error('oneTimePassword') 17 |

{{ $message }}

18 | @enderror 19 |
20 | 21 |
22 | 25 |
26 | 27 | 44 |
45 |
46 | -------------------------------------------------------------------------------- /resources/views/mail.blade.php: -------------------------------------------------------------------------------- 1 | 2 | # {{ __('one-time-passwords::notifications.title') }} 3 | 4 | {{ __('one-time-passwords::notifications.intro', ['url' => config('app.url')]) }} 5 | 6 | **{{ $oneTimePassword->password }}** 7 | 8 | {{ __('one-time-passwords::notifications.outro') }} 9 | 10 | -------------------------------------------------------------------------------- /src/Actions/ConsumeOneTimePasswordAction.php: -------------------------------------------------------------------------------- 1 | call( 33 | callback: fn () => $this->consumeOneTimePassword($user, $password, $request), 34 | microseconds: CarbonInterval::milliseconds(100)->microseconds, 35 | ); 36 | } 37 | 38 | /** 39 | * @param Authenticatable&HasOneTimePasswords $user 40 | */ 41 | protected function consumeOneTimePassword( 42 | Authenticatable $user, 43 | string $password, 44 | Request $request 45 | ): ConsumeOneTimePasswordResult { 46 | $oneTimePasswords = $this->getAllOneTimePasswordsForUser($user); 47 | 48 | if (! $this->allowedByRateLimit($user)) { 49 | return $this->onFailedToValidate($user, ConsumeOneTimePasswordResult::RateLimitExceeded); 50 | } 51 | 52 | if (! count($oneTimePasswords)) { 53 | return $this->onFailedToValidate($user, ConsumeOneTimePasswordResult::NoOneTimePasswordsFound); 54 | } 55 | 56 | $oneTimePassword = $oneTimePasswords->firstWhere('password', $password); 57 | 58 | if (! $oneTimePassword) { 59 | return $this->onFailedToValidate($user, ConsumeOneTimePasswordResult::IncorrectOneTimePassword); 60 | } 61 | 62 | if ($oneTimePassword->isExpired()) { 63 | return $this->onFailedToValidate($user, ConsumeOneTimePasswordResult::OneTimePasswordExpired); 64 | } 65 | 66 | $originPropertiesAreValid = $this->originEnforcer->verifyProperties( 67 | $oneTimePassword, 68 | $request, 69 | ); 70 | 71 | if (! $originPropertiesAreValid) { 72 | return $this->onFailedToValidate($user, ConsumeOneTimePasswordResult::DifferentOrigin); 73 | } 74 | 75 | $this->onSuccessfullyValidated($user, $oneTimePassword); 76 | 77 | return ConsumeOneTimePasswordResult::Ok; 78 | } 79 | 80 | /** 81 | * @param Authenticatable&HasOneTimePasswords $user 82 | * @return Collection 83 | */ 84 | protected function getAllOneTimePasswordsForUser(Authenticatable $user): Collection 85 | { 86 | return $user->oneTimePasswords()->get(); 87 | } 88 | 89 | protected function validateRequestProperties( 90 | OneTimePassword $oneTimePassword, 91 | Request $request, 92 | ): bool { 93 | if ($request->userAgent() !== $oneTimePassword->origin_properties['userAgent']) { 94 | return false; 95 | } 96 | 97 | if ($request->ip() !== $oneTimePassword->origin_properties['ip']) { 98 | return false; 99 | } 100 | 101 | return true; 102 | } 103 | 104 | protected function onSuccessfullyValidated(Authenticatable $user, OneTimePassword $oneTimePassword): void 105 | { 106 | event(new OneTimePasswordSuccessfullyConsumed($user, $oneTimePassword)); 107 | 108 | $oneTimePassword->delete(); 109 | } 110 | 111 | protected function onFailedToValidate( 112 | Authenticatable $user, 113 | ConsumeOneTimePasswordResult $validationResult 114 | ): ConsumeOneTimePasswordResult { 115 | event(new FailedToConsumeOneTimePassword($user, $validationResult)); 116 | 117 | return $validationResult; 118 | } 119 | 120 | protected function allowedByRateLimit(Authenticatable $user): bool 121 | { 122 | return RateLimiter::attempt( 123 | "consume-one-time-password-attempt:{$user->getKey()}", 124 | maxAttempts: config('one-time-passwords.rate_limit_attempts.max_attempts_per_user'), 125 | callback: function () {}, 126 | decaySeconds: config('one-time-passwords.rate_limit_attempts.time_window_in_seconds'), 127 | ); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/Actions/CreateOneTimePasswordAction.php: -------------------------------------------------------------------------------- 1 | deleteOldOneTimePasswords($user); 31 | } 32 | 33 | return $this->createNewOneTimePassword($expiresInMinutes, $user, $request); 34 | } 35 | 36 | /** 37 | * @param Authenticatable&HasOneTimePasswords $user 38 | */ 39 | protected function createNewOneTimePassword(?int $expiresInMinutes, Authenticatable $user, ?Request $request): OneTimePassword 40 | { 41 | $expiresInMinutes = $expiresInMinutes ?? config('one-time-passwords.default_expires_in_minutes'); 42 | 43 | return $user->oneTimePasswords()->create([ 44 | 'password' => $this->passwordGenerator->generate(), 45 | 'expires_at' => Carbon::now()->addMinutes($expiresInMinutes), 46 | 'origin_properties' => $this->originEnforcer->gatherProperties($request ?? request()), 47 | ]); 48 | } 49 | 50 | /** 51 | * @param Authenticatable&HasOneTimePasswords $user 52 | */ 53 | protected function deleteOldOneTimePasswords(Authenticatable $user): void 54 | { 55 | $user->oneTimePasswords()->delete(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Enums/ConsumeOneTimePasswordResult.php: -------------------------------------------------------------------------------- 1 | value); 24 | 25 | return __("one-time-passwords::validation.{$validationKey}"); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Events/FailedToConsumeOneTimePassword.php: -------------------------------------------------------------------------------- 1 | email = $email; 28 | 29 | if ($this->email) { 30 | $this->isFixedEmail = true; 31 | $this->displayingEmailForm = false; 32 | } 33 | 34 | $this->redirectTo = $redirectTo 35 | ?? config('one-time-passwords.redirect_successful_authentication_to'); 36 | } 37 | 38 | public function submitEmail(): void 39 | { 40 | $this->validate([ 41 | 'email' => 'required|email', 42 | ]); 43 | 44 | $user = $this->findUser(); 45 | 46 | if (! $user) { 47 | $this->addError('email', 'We could not find a user with that email address.'); 48 | 49 | return; 50 | } 51 | 52 | $this->sendCode(); 53 | 54 | $this->displayingEmailForm = false; 55 | } 56 | 57 | public function resendCode(): void 58 | { 59 | $this->sendCode(); 60 | } 61 | 62 | protected function sendCode(): void 63 | { 64 | $user = $this->findUser(); 65 | 66 | if ($this->rateLimitHit()) { 67 | return; 68 | } 69 | 70 | $this->displayingEmailForm = false; 71 | 72 | $user->sendOneTimePassword(); 73 | } 74 | 75 | protected function rateLimitHit(): bool 76 | { 77 | $rateLimitKey = "one-time-password-component-send-code.{$this->email}"; 78 | 79 | if (RateLimiter::tooManyAttempts($rateLimitKey, 10)) { 80 | return true; 81 | } 82 | 83 | RateLimiter::hit($rateLimitKey, 60); // 60 seconds decay time 84 | 85 | return false; 86 | } 87 | 88 | public function displayEmailForm(): void 89 | { 90 | $this->email = null; 91 | 92 | $this->displayingEmailForm = true; 93 | } 94 | 95 | public function submitOneTimePassword() 96 | { 97 | $user = $this->findUser(); 98 | 99 | $this->validate([ 100 | 'oneTimePassword' => ['required', new OneTimePasswordRule($user)], 101 | ]); 102 | 103 | $this->authenticate($user); 104 | 105 | return $this->redirect($this->redirectTo); 106 | } 107 | 108 | public function render(): View 109 | { 110 | return view("one-time-passwords::livewire.{$this->showViewName()}"); 111 | } 112 | 113 | /** 114 | * @return HasOneTimePasswords&Model&Authenticatable 115 | */ 116 | protected function findUser(): ?Authenticatable 117 | { 118 | $authenticatableModel = config('auth.providers.users.model'); 119 | 120 | return $authenticatableModel::firstWhere('email', $this->email); 121 | } 122 | 123 | public function authenticate(Authenticatable $user): void 124 | { 125 | auth()->login($user); 126 | } 127 | 128 | public function showViewName(): string 129 | { 130 | return $this->displayingEmailForm 131 | ? 'email-form' 132 | : 'one-time-password-form'; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/Models/Concerns/HasOneTimePasswords.php: -------------------------------------------------------------------------------- 1 | 20 | * 21 | * @throws InvalidConfig 22 | */ 23 | public function oneTimePasswords(): MorphMany 24 | { 25 | $modelClass = Config::oneTimePasswordModel(); 26 | 27 | return $this->morphMany($modelClass, 'authenticatable'); 28 | } 29 | 30 | public function deleteAllOneTimePasswords(): void 31 | { 32 | $this->oneTimePasswords()->delete(); 33 | } 34 | 35 | public function createOneTimePassword(?int $expiresInMinutes = null): OneTimePassword 36 | { 37 | $action = Config::getAction('create_one_time_password', CreateOneTimePasswordAction::class); 38 | 39 | $expiresInMinutes = $expiresInMinutes ?? config('one-time-passwords.default_expires_in_minutes'); 40 | 41 | return $action->execute($this, $expiresInMinutes); 42 | } 43 | 44 | public function sendOneTimePassword(?int $expiresInMinutes = null): self 45 | { 46 | $oneTimePassword = $this->createOneTimePassword($expiresInMinutes); 47 | 48 | $notificationClass = Config::oneTimePasswordNotificationClass(); 49 | $this->notify(new $notificationClass($oneTimePassword)); 50 | 51 | return $this; 52 | } 53 | 54 | public function consumeOneTimePassword(string $password): ConsumeOneTimePasswordResult 55 | { 56 | $action = Config::getAction('consume_one_time_password', ConsumeOneTimePasswordAction::class); 57 | 58 | return $action->execute($this, $password, request()); 59 | } 60 | 61 | public function attemptLoginUsingOneTimePassword(string $password, bool $remember = false): ConsumeOneTimePasswordResult 62 | { 63 | $result = $this->consumeOneTimePassword($password); 64 | 65 | if ($result->isOk()) { 66 | auth()->login($this, $remember); 67 | } 68 | 69 | return $result; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Models/OneTimePassword.php: -------------------------------------------------------------------------------- 1 | 'array', 23 | 'expires_at' => 'datetime', 24 | ]; 25 | } 26 | 27 | public function authenticatable(): MorphTo 28 | { 29 | return $this->morphTo(); 30 | } 31 | 32 | public static function generateFor(Model&Authenticatable $model, int $expiresInMinutes = 10): self 33 | { 34 | $action = Config::getAction('create_one_time_password', CreateOneTimePasswordAction::class); 35 | 36 | $action->execute($model, $expiresInMinutes); 37 | } 38 | 39 | public function isExpired(): bool 40 | { 41 | return $this->expires_at->isPast(); 42 | } 43 | 44 | public function prunable(): Builder 45 | { 46 | return static::query()->wherePast('expires_at'); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Notifications/OneTimePasswordNotification.php: -------------------------------------------------------------------------------- 1 | subject($this->subject()) 22 | ->markdown('one-time-passwords::mail', [ 23 | 'oneTimePassword' => $this->oneTimePassword, 24 | ]); 25 | } 26 | 27 | public function via(object $notifiable): string|array 28 | { 29 | return 'mail'; 30 | } 31 | 32 | public function subject(): string 33 | { 34 | return __('one-time-passwords::notifications.mail_subject', [ 35 | 'oneTimePassword' => $this->oneTimePassword->password, 36 | ]); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/OneTimePasswordsServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('one-time-passwords') 19 | ->hasConfigFile() 20 | ->hasViews() 21 | ->hasTranslations() 22 | ->hasMigration('create_one_time_passwords_table'); 23 | } 24 | 25 | public function packageRegistered(): void 26 | { 27 | $this->app->bind(OriginEnforcer::class, config('one-time-passwords.origin_enforcer')); 28 | 29 | $this->app->bind(OneTimePasswordGenerator::class, function () { 30 | $generator = Config::getPasswordGenerator(); 31 | 32 | $generator->numberOfCharacters(config('one-time-passwords.password_length')); 33 | 34 | return $generator; 35 | }); 36 | 37 | if (class_exists(Livewire::class)) { 38 | Livewire::component('one-time-password', OneTimePasswordComponent::class); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Rules/OneTimePasswordRule.php: -------------------------------------------------------------------------------- 1 | execute( 17 | $this->user, 18 | $value, 19 | request(), 20 | ); 21 | 22 | if ($result->isOk()) { 23 | return; 24 | } 25 | 26 | $fail($result->validationMessage()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Support/Config.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | public static function oneTimePasswordModel(): string 17 | { 18 | $modelClass = config('one-time-passwords.model'); 19 | 20 | if (! is_a($modelClass, OneTimePassword::class, true)) { 21 | throw InvalidConfig::invalidModel($modelClass); 22 | } 23 | 24 | return $modelClass; 25 | } 26 | 27 | public static function oneTimePasswordNotificationClass(): string 28 | { 29 | $notificationClass = config('one-time-passwords.notification'); 30 | 31 | if (! is_a($notificationClass, OneTimePasswordNotification::class, true)) { 32 | throw InvalidConfig::invalidNotification($notificationClass); 33 | } 34 | 35 | return $notificationClass; 36 | } 37 | 38 | /** 39 | * @template T 40 | * 41 | * @param class-string $actionBaseClass 42 | * @return T 43 | */ 44 | public static function getAction(string $actionName, string $actionBaseClass) 45 | { 46 | $actionClass = self::getActionClass($actionName, $actionBaseClass); 47 | 48 | return app($actionClass); 49 | } 50 | 51 | public static function getActionClass(string $actionName, string $actionBaseClass): string 52 | { 53 | $actionClass = config("one-time-passwords.actions.{$actionName}"); 54 | 55 | self::ensureValidActionClass($actionName, $actionBaseClass, $actionClass); 56 | 57 | return $actionClass; 58 | } 59 | 60 | public static function getPasswordGenerator(): OneTimePasswordGenerator 61 | { 62 | $generatorClass = config('one-time-passwords.password_generator'); 63 | 64 | if (! is_a($generatorClass, OneTimePasswordGenerator::class, true)) { 65 | throw InvalidConfig::invalidPasswordGenerator($generatorClass); 66 | } 67 | 68 | return app($generatorClass); 69 | } 70 | 71 | protected static function ensureValidActionClass(string $actionName, string $actionBaseClass, string $actionClass): void 72 | { 73 | if (! is_a($actionClass, $actionBaseClass, true)) { 74 | throw InvalidActionClass::make($actionName, $actionBaseClass, $actionClass); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Support/OriginInspector/DefaultOriginEnforcer.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | public function gatherProperties(Request $request): array 14 | { 15 | return [ 16 | 'ip' => $request->ip(), 17 | 'userAgent' => $request->userAgent(), 18 | ]; 19 | } 20 | 21 | public function verifyProperties(OneTimePassword $oneTimePassword, Request $request): bool 22 | { 23 | $requestProperties = $oneTimePassword->origin_properties ?? []; 24 | 25 | if ($requestProperties['ip'] !== $request->ip()) { 26 | return false; 27 | } 28 | 29 | if ($requestProperties['userAgent'] !== $request->userAgent()) { 30 | return false; 31 | } 32 | 33 | return true; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Support/OriginInspector/DoNotEnforceOrigin.php: -------------------------------------------------------------------------------- 1 | */ 11 | public function gatherProperties(Request $request): array; 12 | 13 | public function verifyProperties(OneTimePassword $oneTimePassword, Request $request): bool; 14 | } 15 | -------------------------------------------------------------------------------- /src/Support/PasswordGenerators/NumericOneTimePasswordGenerator.php: -------------------------------------------------------------------------------- 1 | numberOfCharacters) - 1; 10 | 11 | $randomNumber = random_int(0, $max); 12 | 13 | return str_pad($randomNumber, $this->numberOfCharacters, '0', STR_PAD_LEFT); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Support/PasswordGenerators/OneTimePasswordGenerator.php: -------------------------------------------------------------------------------- 1 | numberOfCharacters = $numberOfCharacters; 12 | 13 | return $this; 14 | } 15 | 16 | abstract public function generate(): string; 17 | } 18 | --------------------------------------------------------------------------------