├── .cursorrules ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── otpz.php ├── database ├── factories │ └── OtpFactory.php └── migrations │ └── create_otps_table.php.stub ├── resources └── views │ ├── .gitkeep │ ├── components │ └── template.blade.php │ ├── mail │ ├── notification.blade.php │ └── otpz.blade.php │ └── otp.blade.php └── src ├── Actions ├── AttemptOtp.php ├── CreateOtp.php ├── GetUserFromEmail.php └── SendOtp.php ├── Enums └── OtpStatus.php ├── Exceptions ├── InvalidAuthenticatableModel.php ├── OtpAttemptException.php ├── OtpExistsException.php ├── OtpExpiredException.php └── OtpThrottleException.php ├── Http ├── Controllers │ ├── GetOtpController.php │ └── PostOtpController.php └── Requests │ └── OtpRequest.php ├── Mail └── OtpzMail.php ├── Models ├── Concerns │ ├── HasOtps.php │ └── Otpable.php └── Otp.php ├── OtpzServiceProvider.php └── Support └── Config.php /.cursorrules: -------------------------------------------------------------------------------- 1 | 2 | You are an expert in Laravel, PHP, and related web development technologies. 3 | 4 | Key Principles 5 | - Write concise, technical responses with accurate PHP examples. 6 | - Follow Laravel best practices and conventions. 7 | - Use object-oriented programming with a focus on SOLID principles. 8 | - Prefer iteration and modularization over duplication. 9 | - Use descriptive variable and method names. 10 | - Use lowercase with dashes for directories (e.g., app/Http/Controllers). 11 | - Favor dependency injection and service containers. 12 | 13 | PHP/Laravel 14 | - Use PHP 8.3+ features when appropriate (e.g., typed properties, match expressions). 15 | - Follow PSR-12 coding standards. 16 | - Use strict typing: declare(strict_types=1); 17 | - Utilize Laravel's built-in features and helpers when possible. 18 | - File structure: Follow Laravel's directory structure and naming conventions. 19 | - Implement proper error handling and logging: 20 | - Use Laravel's exception handling and logging features. 21 | - Create custom exceptions when necessary. 22 | - Use try-catch blocks for expected exceptions. 23 | - Use Laravel's validation features for form and request validation. 24 | - Implement middleware for request filtering and modification. 25 | - Utilize Laravel's Eloquent ORM for database interactions. 26 | - Use Laravel's query builder for complex database queries. 27 | - Implement proper database migrations and seeders. 28 | 29 | Dependencies 30 | - Laravel (latest stable version) 31 | - Composer for dependency management 32 | - PestPHP (latest stable version) 33 | 34 | Laravel Best Practices 35 | - Use Eloquent ORM instead of raw SQL queries when possible. 36 | - Use Laravel's built-in authentication and authorization features. 37 | - Utilize Laravel's caching mechanisms for improved performance. 38 | - Implement job queues for long-running tasks. 39 | - Use PestPHP for all tests. 40 | - Implement API versioning for public APIs. 41 | - Use Laravel's localization features for multi-language support. 42 | - Implement proper CSRF protection and security measures. 43 | - Use Laravel Vite for asset compilation. 44 | - Implement proper database indexing for improved query performance. 45 | - Use Laravel's built-in pagination features. 46 | - Implement proper error logging and monitoring. 47 | - All javascript should be written using Alpine.js 48 | - Encapsulate application logic in descriptively named action classes. 49 | 50 | Test Writing Best Practices 51 | - All tests should be written using PestPHP. 52 | - All tests should be written as feature tests with access to the database. 53 | - Any test that calls an external service should mock that external service. 54 | - Tests should scaffold their own test data using model factories. 55 | - Tests should live in a folder structure that corresponds to the class being tested. For example, a test for app/Http/Controllers/UserController.php should live in tests/Feature/Http/Controllers/UserControllerTest.php. 56 | 57 | Key Conventions 58 | 1. Follow Laravel's MVC architecture. 59 | 2. Use Laravel's routing system for defining application endpoints. 60 | 3. Implement proper request validation using Form Requests. 61 | 4. Use Laravel's Blade templating engine for views. 62 | 5. Implement proper database relationships using Eloquent. 63 | 6. Use Laravel's built-in authentication scaffolding. 64 | 7. Implement proper API resource transformations. 65 | 8. Implement proper database transactions for data integrity. 66 | 9. Use Laravel's built-in scheduling features for recurring tasks. 67 | 10. Encapsulate application logic action classes whenever possible. 68 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `otpz` will be documented in this file. 4 | 5 | ## v0.4.0 - 2025-03-14 6 | 7 | ### What's Changed 8 | 9 | * 19 integrate with laravel starter kits by @benbjurstrom in https://github.com/benbjurstrom/otpz/pull/20 10 | * 21 make create account optional by @benbjurstrom in https://github.com/benbjurstrom/otpz/pull/22 11 | 12 | **Full Changelog**: https://github.com/benbjurstrom/otpz/compare/v0.3.0...v0.4.0 13 | 14 | ## v0.3.0 - 2025-02-18 15 | 16 | ### What's Changed 17 | 18 | * 17 support laravel 12 by @benbjurstrom in https://github.com/benbjurstrom/otpz/pull/18 19 | 20 | **Full Changelog**: https://github.com/benbjurstrom/otpz/compare/v0.2.2...v0.3.0 21 | 22 | ## v0.2.2 - 2025-01-14 23 | 24 | ### What's Changed 25 | 26 | * 10 update styles by @benbjurstrom in https://github.com/benbjurstrom/otpz/pull/11 27 | 28 | **Full Changelog**: https://github.com/benbjurstrom/otpz/compare/v0.2.1...v0.2.2 29 | 30 | ## v0.2.1 - 2025-01-12 31 | 32 | ### What's Changed 33 | 34 | * 8 add tailwind styles by @benbjurstrom in https://github.com/benbjurstrom/otpz/pull/9 35 | 36 | **Full Changelog**: https://github.com/benbjurstrom/otpz/compare/v0.2.0...v0.2.1 37 | 38 | ## v0.2.0 - 2025-01-12 39 | 40 | ### What's Changed 41 | 42 | * 6 add remember me by @benbjurstrom in https://github.com/benbjurstrom/otpz/pull/7 43 | 44 | **Full Changelog**: https://github.com/benbjurstrom/otpz/compare/v0.1.1...v0.2.0 45 | 46 | ## v0.1.1 - 2024-12-10 47 | 48 | ### What's Changed 49 | 50 | * 4 add inertia usage to readme by @benbjurstrom in https://github.com/benbjurstrom/otpz/pull/5 51 | 52 | **Full Changelog**: https://github.com/benbjurstrom/otpz/compare/v0.1.0...v0.1.1 53 | 54 | ## v0.1.0 - 2024-12-10 55 | 56 | ### What's Changed 57 | 58 | * 2 adopt plink architecture by @benbjurstrom in https://github.com/benbjurstrom/otpz/pull/3 59 | 60 | **Full Changelog**: https://github.com/benbjurstrom/otpz/commits/v0.1.0 61 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Ben Bjurstrom 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 | OTPz Screenshot 3 |
4 | 5 | # First Factor One-Time Passwords for Laravel 6 | 7 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/benbjurstrom/otpz.svg?style=flat-square)](https://packagist.org/packages/benbjurstrom/otpz) 8 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/benbjurstrom/otpz/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/benbjurstrom/otpz/actions?query=workflow%3Arun-tests+branch%3Amain) 9 | [![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/benbjurstrom/otpz/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/benbjurstrom/otpz/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) 10 | 11 | This package provides secure first factor one-time passwords (OTPs) for Laravel applications. Users enter their email and receive a one-time code to sign in. 12 | 13 | - ✅ Rate-limited 14 | - ✅ Configurable expiration 15 | - ✅ Invalidated after first use 16 | - ✅ Locked to the user's session 17 | - ✅ Invalidated after too many failed attempts 18 | - ✅ Detailed error messages 19 | - ✅ Customizable mail template 20 | - ✅ Auditable logs 21 | 22 | ## Starter Kits 23 | 24 | ### Laravel + React Starter Kit 25 | 26 | 1. **New Applications** 27 | 28 | Create a new Laravel project using the OTPz + React starter kit with the following command: 29 | ```bash 30 | laravel new --using benbjurstrom/otpz-react-starter-kit otpz-react 31 | ``` 32 | 33 | 2. **Existing Applications**: 34 | 35 | You can see a diff of all changes needed to integrate OTPz with the official Laravel + React Starter Kit here: https://github.com/laravel/react-starter-kit/compare/main...benbjurstrom:otpz-react-starter-kit:main 36 | 37 | ### Laravel + Vue Starter Kit 38 | 39 | 1. **New Applications** 40 | 41 | Create a new Laravel project using the OTPz + Vue starter kit with the following command: 42 | ```bash 43 | laravel new --using benbjurstrom/otpz-vue-starter-kit otpz-vue 44 | ``` 45 | 46 | 2. **Existing Applications**: 47 | 48 | You can see a diff of all changes needed to integrate OTPz with the official Laravel + Vue Starter Kit here: https://github.com/laravel/vue-starter-kit/compare/main...benbjurstrom:otpz-vue-starter-kit:main 49 | 50 | ### Laravel + Livewire Starter Kit 51 | 52 | 1. **New Applications** 53 | 54 | Create a new Laravel project using the OTPz + Livewire starter kit with the following command: 55 | ```bash 56 | laravel new --using benbjurstrom/otpz-livewire-starter-kit otpz-livewire 57 | ``` 58 | 59 | 2. **Existing Applications**: 60 | 61 | You can see a diff of all changes needed to integrate OTPz with the official Laravel + Livewire Starter Kit here: https://github.com/laravel/livewire-starter-kit/compare/main...benbjurstrom:otpz-livewire-starter-kit:main 62 | 63 | ## Installation 64 | 65 | ### 1. Install the package via composer: 66 | 67 | ```bash 68 | composer require benbjurstrom/otpz 69 | ``` 70 | 71 | ### 2. Publish and run the migrations 72 | 73 | ```bash 74 | php artisan vendor:publish --tag="otpz-migrations" 75 | php artisan migrate 76 | ``` 77 | 78 | ### 3. Add the package's interface and trait to your Authenticatable model 79 | 80 | ```php 81 | // app/Models/User.php 82 | namespace App\Models; 83 | 84 | //... 85 | use BenBjurstrom\Otpz\Models\Concerns\HasOtps; 86 | use BenBjurstrom\Otpz\Models\Concerns\Otpable; 87 | 88 | class User extends Authenticatable implements Otpable 89 | { 90 | use HasFactory, Notifiable, HasOtps; 91 | 92 | // ... 93 | } 94 | ``` 95 | 96 | ### 4. (Optional) Add the following routes 97 | Not needed with Laravel 12 starter kits. Instead, see the [Usage](#usage) section for examples. 98 | 99 | ```php 100 | // routes/auth.php 101 | use BenBjurstrom\Otpz\Http\Controllers\GetOtpController; 102 | use BenBjurstrom\Otpz\Http\Controllers\PostOtpController; 103 | //... 104 | Route::get('otpz/{id}', GetOtpController::class) 105 | ->name('otpz.show')->middleware('guest'); 106 | 107 | Route::post('otpz/{id}', PostOtpController::class) 108 | ->name('otpz.post')->middleware('guest'); 109 | ``` 110 | 111 | ### 5. (Optional) Publish the views for custom styling 112 | 113 | ```bash 114 | php artisan vendor:publish --tag="otpz-views" 115 | ``` 116 | 117 | This package publishes the following views: 118 | ```bash 119 | resources/ 120 | └── views/ 121 | └── vendor/ 122 | └── otpz/ 123 | ├── otp.blade.php (for entering the OTP) 124 | ├── components/template.blade.php 125 | └── mail/ 126 | ├── notification.blade.php (standard template) 127 | └── otpz.blade.php (custom template) 128 | ``` 129 | 130 | ### 6. (Optional) Publish the config file 131 | 132 | ```bash 133 | php artisan vendor:publish --tag="otpz-config" 134 | ``` 135 | 136 | This is the contents of the published config file: 137 | 138 | ```php 139 | 5, // Minutes 154 | 155 | 'limits' => [ 156 | ['limit' => 1, 'minutes' => 1], 157 | ['limit' => 3, 'minutes' => 5], 158 | ['limit' => 5, 'minutes' => 30], 159 | ], 160 | 161 | /* 162 | |-------------------------------------------------------------------------- 163 | | Model Configuration 164 | |-------------------------------------------------------------------------- 165 | | 166 | | This setting determines the model used by Otpz to store and retrieve 167 | | one-time passwords. By default, it uses the 'App\Models\User' model. 168 | | 169 | */ 170 | 171 | 'models' => [ 172 | 'authenticatable' => App\Models\User::class, 173 | ], 174 | 175 | /* 176 | |-------------------------------------------------------------------------- 177 | | Mailable Configuration 178 | |-------------------------------------------------------------------------- 179 | | 180 | | This setting determines the Mailable class used by Otpz to send emails. 181 | | Change this to your own Mailable class if you want to customize the email 182 | | sending behavior. 183 | | 184 | */ 185 | 186 | 'mailable' => BenBjurstrom\Otpz\Mail\OtpzMail::class, 187 | 188 | /* 189 | |-------------------------------------------------------------------------- 190 | | Template Configuration 191 | |-------------------------------------------------------------------------- 192 | | 193 | | This setting determines the email template used by Otpz to send emails. 194 | | Switch to 'otpz::mail.notification' if you prefer to use the default 195 | | Laravel notification template. 196 | | 197 | */ 198 | 199 | 'template' => 'otpz::mail.otpz', 200 | // 'template' => 'otpz::mail.notification', 201 | 202 | /* 203 | |-------------------------------------------------------------------------- 204 | | User Resolver 205 | |-------------------------------------------------------------------------- 206 | | 207 | | Defines the class responsible for finding or creating users by email address. 208 | | The default implementation will create a new user when an email doesn't exist. 209 | | Replace with your own implementation for custom user resolution logic. 210 | | 211 | */ 212 | 213 | 'user_resolver' => BenBjurstrom\Otpz\Actions\GetUserFromEmail::class, 214 | ]; 215 | ``` 216 | 217 | ## Usage With Breeze 218 | 219 | ### Laravel Breeze Livewire Example 220 | 1. Replace the Breeze provided [App\Livewire\Forms\LoginForm::authenticate](https://github.com/laravel/breeze/blob/2.x/stubs/livewire-common/app/Livewire/Forms/LoginForm.php#L29C6-L29C41) method with a sendEmail method that runs the SendOtp action. Also be sure to remove password from the LoginForm's properties. 221 | 222 | ```php 223 | // app/Livewire/Forms/LoginForm.php 224 | 225 | use BenBjurstrom\Otpz\Actions\SendOtp; 226 | use BenBjurstrom\Otpz\Exceptions\OtpThrottleException; 227 | use BenBjurstrom\Otpz\Models\Otp; 228 | //... 229 | 230 | #[Validate('required|string|email')] 231 | public string $email = ''; 232 | 233 | #[Validate('boolean')] 234 | public bool $remember = false; 235 | //... 236 | 237 | public function sendEmail(): Otp 238 | { 239 | $this->validate(); 240 | 241 | $this->ensureIsNotRateLimited(); 242 | RateLimiter::hit($this->throttleKey(), 300); 243 | 244 | try { 245 | $otp = (new SendOtp)->handle($this->email, $this->remember); 246 | } catch (OtpThrottleException $e) { 247 | throw ValidationException::withMessages([ 248 | 'form.email' => $e->getMessage(), 249 | ]); 250 | } 251 | 252 | RateLimiter::clear($this->throttleKey()); 253 | 254 | return $otp; 255 | } 256 | ```` 257 | 258 | 2. Update [resources/views/livewire/pages/auth/login.blade.php](https://github.com/laravel/breeze/blob/2.x/stubs/livewire/resources/views/livewire/pages/auth/login.blade.php) such that the login function calls our new sendEmail method and redirects to the OTP entry page. You can also remove the password input field in this same file. 259 | 260 | ```php 261 | public function login(): void 262 | { 263 | $this->validate(); 264 | 265 | $otp = $this->form->sendEmail(); 266 | 267 | $this->redirect($otp->url); 268 | } 269 | ``` 270 | 271 | ### Laravel Breeze Inertia Example 272 | 273 | 1. Replace the Breeze provided [App\Http\Requests\Auth\LoginRequest::authenticate](https://github.com/laravel/breeze/blob/e05ae1a21954c8d83bb0fcc78db87f157c16ac6c/stubs/default/app/Http/Requests/Auth/LoginRequest.php) method with a sendEmail method that runs the SendOtp action. Also be sure to remove password from the rules array. 274 | 275 | ```php 276 | // app/Http/Requests/Auth/LoginRequest.php 277 | 278 | use BenBjurstrom\Otpz\Actions\SendOtp; 279 | use BenBjurstrom\Otpz\Exceptions\OtpThrottleException; 280 | use BenBjurstrom\Otpz\Models\Otp; 281 | //... 282 | 283 | public function rules(): array 284 | { 285 | return [ 286 | 'email' => ['required', 'string', 'email'] 287 | ]; 288 | } 289 | //... 290 | 291 | public function sendEmail(): Otp 292 | { 293 | $this->ensureIsNotRateLimited(); 294 | RateLimiter::hit($this->throttleKey(), 300); 295 | 296 | try { 297 | $otp = (new SendOtp)->handle($this->email, $this->remember); 298 | } catch (OtpThrottleException $e) { 299 | throw ValidationException::withMessages([ 300 | 'email' => $e->getMessage(), 301 | ]); 302 | } 303 | 304 | RateLimiter::clear($this->throttleKey()); 305 | 306 | return $otp; 307 | } 308 | ``` 309 | 310 | 2. Update the [App\Http\Controllers\Auth\AuthenticatedSessionController::store](https://github.com/laravel/breeze/blob/e05ae1a21954c8d83bb0fcc78db87f157c16ac6c/stubs/inertia-common/app/Http/Controllers/Auth/AuthenticatedSessionController.php) method to call our new sendEmail method and redirect to the OTP entry page. 311 | 312 | ```php 313 | public function store(LoginRequest $request): \Symfony\Component\HttpFoundation\Response 314 | { 315 | $otp = $request->sendEmail(); 316 | 317 | return Inertia::location($otp->url); 318 | } 319 | ``` 320 | 321 | 3. Remove the password input field from the [resources/js/Pages/Auth/Login.vue](https://github.com/laravel/breeze/blob/e05ae1a21954c8d83bb0fcc78db87f157c16ac6c/stubs/inertia-vue/resources/js/Pages/Auth/Login.vue) file. 322 | 323 | Everything else is handled by the package components. 324 | 325 | ## Testing 326 | 327 | ```bash 328 | composer test 329 | ``` 330 | 331 | ## Changelog 332 | 333 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 334 | 335 | ## Contributing 336 | 337 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 338 | 339 | ## Security Vulnerabilities 340 | 341 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 342 | 343 | ## Credits 344 | 345 | - [Ben Bjurstrom](https://github.com/benbjurstrom) 346 | - [All Contributors](../../contributors) 347 | 348 | ## License 349 | 350 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 351 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "benbjurstrom/otpz", 3 | "description": "First Factor One-Time Passwords for Laravel (Passwordless OTP Login)", 4 | "keywords": [ 5 | "Ben Bjurstrom", 6 | "laravel", 7 | "otpz" 8 | ], 9 | "homepage": "https://github.com/benbjurstrom/otpz", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Ben Bjurstrom", 14 | "email": "benbjurstrom@users.noreply.github.com", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.2", 20 | "spatie/laravel-package-tools": "^1.16", 21 | "illuminate/contracts": "^10.0||^11.0||^12.0" 22 | }, 23 | "require-dev": { 24 | "laravel/pint": "^1.21", 25 | "nunomaduro/collision": "^8.5.0||^7.10.0", 26 | "larastan/larastan": "^2.9.13", 27 | "orchestra/testbench": "^9.10.0||^8.22.0", 28 | "pestphp/pest": "^2.36", 29 | "pestphp/pest-plugin-arch": "^2.7", 30 | "pestphp/pest-plugin-laravel": "^2.4", 31 | "phpstan/phpstan": "1.12.12", 32 | "phpstan/extension-installer": "1.4.3", 33 | "phpstan/phpstan-deprecation-rules": "1.2.1", 34 | "phpstan/phpstan-phpunit": "1.4.1" 35 | }, 36 | "autoload": { 37 | "psr-4": { 38 | "BenBjurstrom\\Otpz\\": "src/", 39 | "BenBjurstrom\\Otpz\\Database\\Factories\\": "database/factories/" 40 | } 41 | }, 42 | "autoload-dev": { 43 | "psr-4": { 44 | "BenBjurstrom\\Otpz\\Tests\\": "tests/", 45 | "Workbench\\App\\": "workbench/app/" 46 | } 47 | }, 48 | "scripts": { 49 | "post-autoload-dump": "@composer run prepare", 50 | "clear": "@php vendor/bin/testbench package:purge-otpz --ansi", 51 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 52 | "build": [ 53 | "@composer run prepare", 54 | "@php vendor/bin/testbench workbench:build --ansi" 55 | ], 56 | "start": [ 57 | "Composer\\Config::disableProcessTimeout", 58 | "@composer run build", 59 | "@php vendor/bin/testbench serve" 60 | ], 61 | "analyse": "vendor/bin/phpstan analyse", 62 | "test": "vendor/bin/pest", 63 | "test-coverage": "vendor/bin/pest --coverage", 64 | "format": "vendor/bin/pint" 65 | }, 66 | "config": { 67 | "sort-packages": true, 68 | "allow-plugins": { 69 | "pestphp/pest-plugin": true, 70 | "phpstan/extension-installer": true 71 | } 72 | }, 73 | "extra": { 74 | "laravel": { 75 | "providers": [ 76 | "BenBjurstrom\\Otpz\\OtpzServiceProvider" 77 | ], 78 | "aliases": { 79 | "Otpz": "BenBjurstrom\\Otpz\\Facades\\Otpz" 80 | } 81 | } 82 | }, 83 | "minimum-stability": "dev", 84 | "prefer-stable": true 85 | } 86 | -------------------------------------------------------------------------------- /config/otpz.php: -------------------------------------------------------------------------------- 1 | 5, // Minutes 16 | 17 | 'limits' => [ 18 | ['limit' => 1, 'minutes' => 1], 19 | ['limit' => 3, 'minutes' => 5], 20 | ['limit' => 5, 'minutes' => 30], 21 | ], 22 | 23 | /* 24 | |-------------------------------------------------------------------------- 25 | | Model Configuration 26 | |-------------------------------------------------------------------------- 27 | | 28 | | This setting determines the model used by Otpz to store and retrieve 29 | | one-time passwords. By default, it uses the 'App\Models\User' model. 30 | | 31 | */ 32 | 33 | 'models' => [ 34 | 'authenticatable' => App\Models\User::class, 35 | ], 36 | 37 | /* 38 | |-------------------------------------------------------------------------- 39 | | Mailable Configuration 40 | |-------------------------------------------------------------------------- 41 | | 42 | | This setting determines the Mailable class used by Otpz to send emails. 43 | | Change this to your own Mailable class if you want to customize the email 44 | | sending behavior. 45 | | 46 | */ 47 | 48 | 'mailable' => BenBjurstrom\Otpz\Mail\OtpzMail::class, 49 | 50 | /* 51 | |-------------------------------------------------------------------------- 52 | | Template Configuration 53 | |-------------------------------------------------------------------------- 54 | | 55 | | This setting determines the email template used by Otpz to send emails. 56 | | Switch to 'otpz::mail.notification' if you prefer to use the default 57 | | Laravel notification template. 58 | | 59 | */ 60 | 61 | 'template' => 'otpz::mail.otpz', 62 | // 'template' => 'otpz::mail.notification', 63 | 64 | /* 65 | |-------------------------------------------------------------------------- 66 | | User Resolver 67 | |-------------------------------------------------------------------------- 68 | | 69 | | Defines the class responsible for finding or creating users by email address. 70 | | The default implementation will create a new user when an email doesn't exist. 71 | | Replace with your own implementation for custom user resolution logic. 72 | | 73 | */ 74 | 75 | 'user_resolver' => BenBjurstrom\Otpz\Actions\GetUserFromEmail::class, 76 | ]; 77 | -------------------------------------------------------------------------------- /database/factories/OtpFactory.php: -------------------------------------------------------------------------------- 1 | User::factory(), 21 | 'status' => OtpStatus::ACTIVE, 22 | 'code' => $code, 23 | 'ip_address' => fake()->ipv4(), 24 | 'created_at' => now(), 25 | 'updated_at' => now(), 26 | ]; 27 | } 28 | 29 | public function expired(): static 30 | { 31 | return $this->state(fn (array $attributes) => [ 32 | 'status' => OtpStatus::EXPIRED, 33 | 'created_at' => now()->subMinutes(6), 34 | ]); 35 | } 36 | 37 | public function used(): static 38 | { 39 | return $this->state(fn (array $attributes) => [ 40 | 'status' => OtpStatus::USED, 41 | ]); 42 | } 43 | 44 | public function superseded(): static 45 | { 46 | return $this->state(fn (array $attributes) => [ 47 | 'status' => OtpStatus::SUPERSEDED, 48 | ]); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /database/migrations/create_otps_table.php.stub: -------------------------------------------------------------------------------- 1 | uuid('id')->primary(); 13 | $table->foreignId('user_id')->constrained()->onDelete('cascade'); 14 | $table->string('code'); 15 | $table->tinyInteger('attempts')->default(0); 16 | $table->tinyInteger('status')->default(0); 17 | $table->boolean('remember')->default(false); 18 | $table->ipAddress('ip_address'); 19 | $table->timestamps(); 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /resources/views/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benbjurstrom/otpz/9784995469c82e95af8d401937fd2f9a52db7834/resources/views/.gitkeep -------------------------------------------------------------------------------- /resources/views/components/template.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 21 | Sign-in to {{ config('app.name') }} 22 | 73 | 74 | 75 |
76 |
77 | 78 | 79 | 114 | 115 |
80 | 85 | 86 | 87 | 111 | 112 |
88 |

89 | {{ $greeting }} 90 |

91 |

92 | {{ $copy }} 93 |

94 |
95 |
96 |
97 | {{ $code }} 98 |
99 |
100 |
101 |

102 | {{ $subcopy }} 103 |

104 | 105 |

106 |

107 |

{{ $footer }} 108 |

109 |

110 |
113 |
116 |
117 |
118 | 119 | 120 | -------------------------------------------------------------------------------- /resources/views/mail/notification.blade.php: -------------------------------------------------------------------------------- 1 | 2 | {{-- Greeting --}} 3 | # Hello! 4 | 5 | {{-- Intro Lines --}} 6 | Click the button below to securely log in to your account: 7 | 8 | {{-- Action Button --}} 9 | 10 | Sign-In to {{ config('app.name') }} 11 | 12 | 13 | {{-- Outro Lines --}} 14 | This link expires after 5 minutes and can only be used once. 15 | 16 | {{-- Salutation --}} 17 | Thank you for using {{ config('app.name') }}! 18 | 19 | {{-- Subcopy --}} 20 | 21 | @lang( 22 | "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\n". 23 | 'into your web browser:', 24 | [ 25 | 'actionText' => 'Sign-In to ' . config('app.name'), 26 | ] 27 | ) 28 | [{{ $url }}]({{ $url }}) 29 | 30 | 31 | -------------------------------------------------------------------------------- /resources/views/mail/otpz.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Sign in to {{ config('app.name') }} 10 | 11 | 12 | 13 | We received a sign-in request for the account {{ $email }}. Use the code below to sign in. 14 | 15 | 16 | 17 | {{ $code }} 18 | 19 | 20 | 21 | If you didn't request this login link, you can safely ignore this email. 22 | 23 | 24 | 25 | Security Reminder: Fraudulent websites may try to steal your login code. Only enter this code at {{ config('app.url') }}. Never enter this code on any other website or share it with anyone. 26 | 27 | 28 | -------------------------------------------------------------------------------- /resources/views/otp.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{ config('app.name', 'Laravel') }} 9 | 10 | 11 | 12 | 16 | 17 | 18 | 22 | 23 | 24 | 28 | 29 | 30 | {{-- @vite(['resources/css/app.css']) --}} 31 | 34 | 35 | 36 | 39 |
40 | 49 | 50 |
53 | Sign-in to {{ config('app.name') }} 54 |
55 | Enter the alpha numeric code sent to 56 | test@example.com 57 | . The code is case insensitive and dashes will be added 58 | automatically. 59 |
60 |
61 | 62 |
68 | @csrf 69 |
70 |
71 | 87 | 92 |
93 | 94 | @if ($errors->get('code')) 95 |
99 | 112 | @foreach ((array) $errors->get('code') as $message) 113 | {{ $message }} 114 | @endforeach 115 |
116 | @endif 117 |
118 | 119 | 125 | 126 |
127 |
130 | 131 | 134 | or 135 | 136 | 137 |
140 |
141 | 142 | 150 |
151 |
152 | 153 | 154 | -------------------------------------------------------------------------------- /src/Actions/AttemptOtp.php: -------------------------------------------------------------------------------- 1 | validateSignature(); 25 | $this->validateSession($sessionId); 26 | $otp = Otp::findOrFail($id); 27 | $this->validateStatus($otp); 28 | $this->validateNotExpired($otp); 29 | $this->validateAttempts($otp); 30 | $this->validateCode($otp, $code); 31 | 32 | // if everything above passes mark the otp as used 33 | $otp->update(['status' => OtpStatus::USED]); 34 | 35 | return $otp; 36 | } 37 | 38 | protected function getOtp(Otpable $user): ?Otp 39 | { 40 | return $user->otps() 41 | ->orderBy('created_at', 'DESC') 42 | ->first(); 43 | } 44 | 45 | /** 46 | * @throws OtpAttemptException 47 | */ 48 | protected function validateSession(string $sessionId): void 49 | { 50 | if ($sessionId !== session()->getId()) { 51 | throw new OtpAttemptException(OtpStatus::SESSION->errorMessage()); 52 | } 53 | } 54 | 55 | /** 56 | * @throws OtpAttemptException 57 | */ 58 | protected function validateStatus(Otp $otp): void 59 | { 60 | if ($otp->status !== OtpStatus::ACTIVE) { 61 | throw new OtpAttemptException($otp->status->errorMessage()); 62 | } 63 | } 64 | 65 | /** 66 | * @throws OtpAttemptException 67 | */ 68 | protected function validateNotExpired(Otp $otp): void 69 | { 70 | $expiration = Carbon::now()->subMinutes(config('otpz.expiration', 5)); 71 | if ($otp->created_at->lt($expiration)) { 72 | $otp->update(['status' => OtpStatus::EXPIRED]); 73 | throw new OtpAttemptException($otp->status->errorMessage()); 74 | } 75 | } 76 | 77 | /** 78 | * @throws OtpAttemptException 79 | */ 80 | protected function validateAttempts(Otp $otp): void 81 | { 82 | if ($otp->attempts >= 3) { 83 | $otp->update(['status' => OtpStatus::ATTEMPTED]); 84 | throw new OtpAttemptException($otp->status->errorMessage()); 85 | } 86 | } 87 | 88 | /** 89 | * @throws OtpAttemptException 90 | */ 91 | protected function validateCode(Otp $otp, string $code): void 92 | { 93 | if (! Hash::check($code, $otp->code)) { 94 | $otp->increment('attempts'); 95 | throw new OtpAttemptException(OtpStatus::INVALID->errorMessage()); 96 | } 97 | } 98 | 99 | /** 100 | * @throws OtpAttemptException 101 | */ 102 | protected function ValidateSignature(): void 103 | { 104 | if (! request()->hasValidSignature()) { 105 | if (! url()->signatureHasNotExpired(request())) { 106 | throw new OtpAttemptException(OtpStatus::SIGNATURE->errorMessage()); 107 | } 108 | 109 | throw new OtpAttemptException(OtpStatus::SIGNATURE->errorMessage()); 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Actions/CreateOtp.php: -------------------------------------------------------------------------------- 1 | 19 | * 20 | * @throws OtpThrottleException 21 | */ 22 | public function handle(Otpable $user, bool $remember = false): array 23 | { 24 | $this->throttle($user); 25 | 26 | return $this->createOtp($user, $remember); 27 | } 28 | 29 | /** 30 | * @throws OtpThrottleException 31 | */ 32 | public function throttle(Otpable $user) 33 | { 34 | foreach ($this->getThresholds() as $threshold) { 35 | $count = $this->getOtpCount($user, $threshold['minutes']); 36 | 37 | if ($count >= $threshold['limit']) { 38 | $remaining = $this->calculateRemainingTime($user, $threshold['minutes']); 39 | throw new OtpThrottleException($remaining['minutes'], $remaining['seconds']); 40 | } 41 | } 42 | } 43 | 44 | private function getThresholds(): array 45 | { 46 | return config('otpz.limits', [ 47 | ['limit' => 1, 'minutes' => 1], 48 | ['limit' => 3, 'minutes' => 5], 49 | ['limit' => 5, 'minutes' => 30], 50 | ]); 51 | } 52 | 53 | private function getOtpCount(Otpable $user, int $minutes): int 54 | { 55 | return $user->otps() 56 | ->where('status', '!=', OtpStatus::USED) 57 | ->where('created_at', '>=', now()->subMinutes($minutes)) 58 | ->count(); 59 | } 60 | 61 | private function calculateRemainingTime(Otpable $user, int $minutes): array 62 | { 63 | $earliestOtp = $user->otps() 64 | ->where('created_at', '>=', now()->subMinutes($minutes)) 65 | ->orderBy('created_at', 'asc') 66 | ->first(); 67 | 68 | if ($earliestOtp) { 69 | $availableAt = $earliestOtp->created_at->addMinutes($minutes); 70 | $remainingSeconds = now()->diffInSeconds($availableAt, false); 71 | 72 | return [ 73 | 'minutes' => floor($remainingSeconds / 60), 74 | 'seconds' => $remainingSeconds % 60, 75 | ]; 76 | } 77 | 78 | return ['minutes' => 0, 'seconds' => 0]; 79 | } 80 | 81 | /** 82 | * @return list 83 | */ 84 | private function createOtp(Otpable $user, bool $remember): array 85 | { 86 | return DB::transaction(function () use ($user, $remember) { 87 | // Generate a secure 9-digit OTP code 88 | $code = Str::upper(Str::random(10)); 89 | $code = str_replace('O', '0', $code); 90 | 91 | // Invalidate existing active OTPs 92 | $user->otps() 93 | ->where('status', OtpStatus::ACTIVE) 94 | ->update(['status' => OtpStatus::SUPERSEDED]); 95 | 96 | // Create and save the new OTP 97 | $otp = $user->otps()->create([ 98 | 'code' => $code, 99 | 'status' => OtpStatus::ACTIVE, 100 | 'ip_address' => request()->ip(), 101 | 'remember' => $remember, 102 | ]); 103 | 104 | return [$otp, $code]; 105 | }); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Actions/GetUserFromEmail.php: -------------------------------------------------------------------------------- 1 | first(); 18 | 19 | if (! $user) { 20 | $user = new $authenticatableModel; 21 | $user->email = $email; 22 | $user->password = Str::random(32); 23 | $user->name = ''; 24 | $user->save(); 25 | } 26 | 27 | return $user; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Actions/SendOtp.php: -------------------------------------------------------------------------------- 1 | handle($email); 23 | [$otp, $code] = (new CreateOtp)->handle($user, $remember); 24 | 25 | Mail::to($user)->send(new $mailable($otp, $code)); 26 | 27 | return $otp; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Enums/OtpStatus.php: -------------------------------------------------------------------------------- 1 | 'The code is still active.', 20 | self::SUPERSEDED => 'The active code has been superseded. Please request a new code.', 21 | self::EXPIRED => 'The active code has expired. Please request a new code.', 22 | self::ATTEMPTED => 'Too many attempts. Please request a new code.', 23 | self::USED => 'The active code has already been used. Please request a new code.', 24 | self::INVALID => 'The given code is invalid.', 25 | self::SIGNATURE => 'The route signature is invalid.', 26 | self::SESSION => 'The sign-in code was requested in a different session. Please login using the same browser that requested the code.', 27 | }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidAuthenticatableModel.php: -------------------------------------------------------------------------------- 1 | hasValidSignature()) { 18 | $message = OtpStatus::SIGNATURE->errorMessage(); 19 | Session::flash('status', __($message)); 20 | 21 | return redirect()->route('login'); 22 | } 23 | 24 | if ($request->sessionId !== request()->session()->getId()) { 25 | $message = OtpStatus::SESSION->errorMessage(); 26 | Session::flash('status', __($message)); 27 | 28 | return redirect()->route('login'); 29 | } 30 | 31 | $otp = Otp::findOrFail($id); 32 | 33 | $url = URL::temporarySignedRoute( 34 | 'otpz.post', now()->addMinutes(5), [ 35 | 'id' => $otp->id, 36 | 'sessionId' => request()->session()->getId(), 37 | ], 38 | ); 39 | 40 | return view('otpz::otp', [ 41 | 'email' => $otp->user->email, 42 | 'url' => $url, 43 | 'code' => $request->code, 44 | ]); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Http/Controllers/PostOtpController.php: -------------------------------------------------------------------------------- 1 | safe()->only(['code', 'sessionId']); 20 | 21 | $otp = (new AttemptOtp)->handle($id, $data['code'], $data['sessionId']); 22 | 23 | Auth::loginUsingId($otp->user_id, $otp->remember); // fires Illuminate\Auth\Events\Login; 24 | Session::regenerate(); 25 | 26 | if (! $otp->user->hasVerifiedEmail()) { 27 | $otp->user->markEmailAsVerified(); 28 | } 29 | 30 | return redirect()->intended('/dashboard'); 31 | } catch (OtpAttemptException $e) { 32 | throw ValidationException::withMessages(['code' => $e->getMessage()]); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Http/Requests/OtpRequest.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | public function rules(): array 24 | { 25 | return [ 26 | 'code' => ['required', 'string', 'size:10'], 27 | 'sessionId' => ['required', 'string'], 28 | ]; 29 | } 30 | 31 | protected function prepareForValidation(): void 32 | { 33 | $code = preg_replace('/[^0-9A-Z]/', '', strtoupper($this->code)); 34 | $this->merge([ 35 | 'code' => $code, 36 | ]); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Mail/OtpzMail.php: -------------------------------------------------------------------------------- 1 | otp->user->email; 40 | 41 | // Format the code with hyphens for readability 42 | $formattedCode = substr_replace($this->code, '-', 5, 0); 43 | 44 | $template = config('otpz.template', 'otpz::mail.otpz'); 45 | 46 | return new Content( 47 | markdown: $template, 48 | with: [ 49 | 'email' => $email, 50 | 'code' => $formattedCode, 51 | ], 52 | ); 53 | } 54 | 55 | /** 56 | * Get the attachments for the message. 57 | * 58 | * @return array 59 | */ 60 | public function attachments(): array 61 | { 62 | return []; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Models/Concerns/HasOtps.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | public function otps(): HasMany 14 | { 15 | return $this->hasMany(Otp::class); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Models/Concerns/Otpable.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | public function otps(): HasMany; 17 | 18 | /** 19 | * @return void 20 | */ 21 | public function notify(mixed $instance); 22 | } 23 | -------------------------------------------------------------------------------- /src/Models/Otp.php: -------------------------------------------------------------------------------- 1 | 33 | */ 34 | protected $casts = [ 35 | 'remember' => 'boolean', 36 | 'status' => OtpStatus::class, 37 | 'code' => 'hashed', 38 | ]; 39 | 40 | /** 41 | * The attributes that are mass assignable. 42 | * 43 | * @var array 44 | */ 45 | protected $fillable = [ 46 | 'remember', 47 | 'code', 48 | 'status', 49 | 'ip_address', 50 | ]; 51 | 52 | /** 53 | * @return BelongsTo 54 | * 55 | * @throws InvalidAuthenticatableModel 56 | */ 57 | public function user(): BelongsTo 58 | { 59 | $authenticatableModel = Config::getAuthenticatableModel(); 60 | 61 | return $this->belongsTo($authenticatableModel); 62 | } 63 | 64 | public function getUrlAttribute(): string 65 | { 66 | return URL::temporarySignedRoute('otpz.show', now()->addMinutes(5), [ 67 | 'id' => $this->id, 68 | 'sessionId' => request()->session()->getId(), 69 | ]); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/OtpzServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('otpz') 19 | ->hasConfigFile() 20 | ->hasViews('otpz') 21 | ->hasMigration('create_otps_table'); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Support/Config.php: -------------------------------------------------------------------------------- 1 |